pref: test

This commit is contained in:
thehrz 2024-11-16 13:41:20 +08:00
parent 2efe423131
commit c69b23f4e0
Signed by: thehrz
GPG Key ID: C84CBCE7D5F88855
34 changed files with 3238 additions and 451 deletions

View File

@ -1,32 +1,43 @@
{
"name": "test",
"name": "authlib-skin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@mui/material": "^6.1.7",
"@marsidev/react-turnstile": "^0.3.2",
"@mui/icons-material": "^5.16.7",
"@mui/material": "^5.16.7",
"ahooks": "^3.8.1",
"immer": "^10.1.1",
"jotai": "^2.10.1",
"mui-file-input": "^3.0.2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"skinview3d": "^3.1.0",
"tilg": "^0.1.1"
},
"devDependencies": {
"@eslint/js": "^9.13.0",
"@types/node": "^20.17.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^9.13.0",
"eslint-plugin-react-hooks": "^5.0.0",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react-swc": "^3.7.1",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.14",
"globals": "^15.11.0",
"typescript": "~5.6.2",
"typescript-eslint": "^8.11.0",
"vite": "^5.4.10"
"rollup-plugin-visualizer": "^5.12.0",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}
}

1130
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@ -1,14 +1,13 @@
import './App.css'
import { Button } from '@mui/material'
import { PageRoute } from '@/Route'
function App() {
return (
<>
<Button variant="contained">Hello world</Button>;
<PageRoute />
</>
)
}
export default App

58
frontend/src/Route.tsx Normal file
View File

@ -0,0 +1,58 @@
import { Routes, Route, createBrowserRouter, RouterProvider, Outlet } from "react-router-dom";
import { ScrollRestoration } from "react-router-dom";
import Login from '@/views/Login'
import Register from '@/views/Register'
import Profile from '@/views/profile/Profile'
import Textures from '@/views/profile/Textures'
import Security from '@/views/profile/Security'
import Layout from '@/views/Layout'
import UserAdmin from "@/views/admin/UserAdmin";
import NeedLogin from "@/components/NeedLogin";
import Index from "@/views/Index";
import SendEmail from "@/views/SendEmail";
import { sendForgotEmail, sendRegEmail } from "@/apis/apis";
import Forgot from "@/views/Forgot";
const router = createBrowserRouter([
{ path: "*", Component: Root },
])
function Root() {
return (
<>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Index />} />
<Route path="/*" element={<p>404</p>} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/register_email" element={<SendEmail title="注册" sendService={sendRegEmail} />} />
<Route path="/forgot_email" element={<SendEmail title="重设密码" anyEmail sendService={sendForgotEmail} />} />
<Route path="/forgot" element={<Forgot />} />
<Route element={<NeedLogin><Outlet /></NeedLogin>}>
<Route path="/profile" element={<Profile />} />
<Route path="/textures" element={<Textures />} />
<Route path="/security" element={<Security />} />
</Route>
<Route path="admin" element={<NeedLogin needAdmin><Outlet /></NeedLogin>}>
<Route path="user" element={<UserAdmin />} />
</Route>
</Route>
</Routes>
<ScrollRestoration />
</>
)
}
export function PageRoute() {
return (
<>
<RouterProvider router={router} />
</>
)
}

156
frontend/src/apis/apis.ts Normal file
View File

@ -0,0 +1,156 @@
import type { tokenData, ApiUser, YggProfile, ApiConfig, List, UserInfo, EditUser } from '@/apis/model'
import { apiGet } from '@/apis/utils'
import root from '@/utils/root'
export async function login(email: string, password: string, captchaToken: string) {
const v = await fetch(root() + "/api/v1/user/login", {
method: "POST",
body: JSON.stringify({
"email": email,
"password": password,
"CaptchaToken": captchaToken
})
})
return await apiGet<tokenData>(v)
}
export async function register(email: string, username: string, password: string, captchaToken: string, code: string) {
const v = await fetch(root() + "/api/v1/user/reg", {
method: "POST",
body: JSON.stringify({
"Email": email,
"Password": password,
"Name": username,
"CaptchaToken": captchaToken,
"EmailJwt": code,
})
})
return await apiGet<tokenData>(v)
}
export async function userInfo(token: string) {
if (token == "") return
const v = await fetch(root() + "/api/v1/user", {
headers: {
"Authorization": "Bearer " + token
}
})
return await apiGet<ApiUser>(v)
}
export async function yggProfile(uuid: string) {
if (uuid == "") return
const v = await fetch(root() + "/api/yggdrasil/sessionserver/session/minecraft/profile/" + uuid)
const data = await v.json()
if (!v.ok) {
throw new Error(data?.errorMessage)
}
return data as YggProfile
}
export async function upTextures(token: string, textureType: 'skin' | 'cape', model: 'slim' | '', file: File) {
const f = new FormData()
f.set("file", file)
f.set("model", model)
const r = await fetch(root() + "/api/v1/user/skin/" + textureType, {
method: "PUT",
body: f,
headers: {
"Authorization": "Bearer " + token
}
})
return await apiGet<unknown>(r)
}
export async function changePasswd(old: string, newpa: string, token: string) {
const r = await fetch(root() + "/api/v1/user/password", {
method: "POST",
body: JSON.stringify({
"old": old,
"new": newpa
}),
headers: {
"Authorization": "Bearer " + token
}
})
return await apiGet<unknown>(r)
}
export async function getConfig() {
const r = await fetch(root() + "/api/v1/config")
return await apiGet<ApiConfig>(r)
}
export async function changeName(name: string, token: string) {
const r = await fetch(root() + "/api/v1/user/name", {
method: "POST",
body: JSON.stringify({
"name": name,
}),
headers: {
"Authorization": "Bearer " + token
}
})
return await apiGet<unknown>(r)
}
export async function ListUser(page: number, token: string, email: string, name: string) {
const u = new URL(root() + "/api/v1/admin/users")
u.searchParams.set("page", String(page))
u.searchParams.set("email", email)
u.searchParams.set("name", name)
const r = await fetch(u.toString(), {
method: "GET",
headers: {
"Authorization": "Bearer " + token
}
})
return await apiGet<List<UserInfo>>(r)
}
export async function editUser(u: EditUser, token: string, uid: string) {
const r = await fetch(root() + "/api/v1/admin/user/" + uid, {
method: "PATCH",
headers: {
"Authorization": "Bearer " + token
},
body: JSON.stringify(u)
})
return await apiGet<unknown>(r)
}
export async function sendRegEmail(email: string, captchaToken: string) {
const r = await fetch(root() + "/api/v1/user/reg_email", {
method: "POST",
body: JSON.stringify({
"email": email,
"captchaToken": captchaToken
})
})
return await apiGet<unknown>(r)
}
export async function sendForgotEmail(email: string, captchaToken: string) {
const r = await fetch(root() + "/api/v1/user/forgot_email", {
method: "POST",
body: JSON.stringify({
"email": email,
"captchaToken": captchaToken
})
})
return await apiGet<unknown>(r)
}
export async function forgotPassWord(email: string, emailJwt: string, password: string) {
const r = await fetch(root() + "/api/v1/user/forgot", {
method: "POST",
body: JSON.stringify({
"email": email,
"emailJwt": emailJwt,
"passWord": password,
})
})
return await apiGet<unknown>(r)
}

View File

@ -0,0 +1,8 @@
export class ApiErr extends Error {
readonly code: number
constructor(code: number, msg: string) {
super(msg)
this.code = code
}
}

View File

@ -0,0 +1,66 @@
export interface tokenData {
token: string
name: string
uuid: string
}
export interface Api<T> {
code: number
msg: string
data: T
}
export interface List<T> {
total: number
list: T[]
}
interface captcha {
type: string
siteKey: string
}
export interface ApiUser {
uid: string
uuid: string
is_admin: boolean
}
export interface YggProfile {
name: string
properties: {
name: string
value: string
}[]
}
export interface ApiConfig {
captcha: captcha
AllowChangeName: boolean
serverName: string
NeedEmail: boolean
AllowDomain: string[]
EmailReg: string
EmailRegMsg: string
}
export interface UserInfo {
uid: number
uuid: string
is_admin: boolean
is_disable: boolean
email: string
reg_ip: string
name: string
}
export interface EditUser {
email?: string
name?: string
password?: string
is_admin?: boolean
is_disable?: boolean
del_textures?: boolean
}

View File

@ -0,0 +1,10 @@
import { ApiErr } from "./error"
export async function apiGet<T>(v: Response) {
type api = { data: T, msg: string, code: number }
const data = await v.json() as api
if (!v.ok) {
throw new ApiErr(data.code, data.msg)
}
return data.data
}

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,65 @@
import { Turnstile } from '@marsidev/react-turnstile'
import Button from '@mui/material/Button'
import { useRef, useState, memo, forwardRef, useImperativeHandle, useEffect } from 'react'
import type { TurnstileInstance } from '@marsidev/react-turnstile'
import Alert from '@mui/material/Alert';
import Skeleton from '@mui/material/Skeleton';
import { useRequest } from 'ahooks';
import { getConfig } from '@/apis/apis';
interface prop {
onSuccess: ((token: string) => void)
}
export type refType = {
reload: () => void
}
const CaptchaWidget = forwardRef<refType, prop>(({ onSuccess }, ref) => {
const Turnstileref = useRef<TurnstileInstance>(null)
const [key, setKey] = useState(1)
const { data, error, loading } = useRequest(getConfig, {
cacheKey: "/api/v1/config",
staleTime: 600000,
loadingDelay: 200
})
useImperativeHandle(ref, () => {
return {
reload: () => {
setKey(key + 1)
}
}
})
useEffect(() => {
if (data?.captcha?.type != "turnstile") {
onSuccess("ok")
return
}
}, [data?.captcha?.type, onSuccess])
if (error) {
console.warn(error)
return <Alert severity="warning">{String(error)}</Alert>
}
if (!data && loading) {
return <Skeleton variant="rectangular" width={300} height={65} />
}
if (data?.captcha.type == "") {
return <></>
}
return (
<>
<Turnstile siteKey={data?.captcha?.siteKey ?? ""} key={key} onSuccess={onSuccess} ref={Turnstileref} scriptOptions={{ async: true }} />
<Button onClick={() => setKey(key + 1)}></Button>
</>
)
})
const CaptchaWidgetMemo = memo(CaptchaWidget)
export default CaptchaWidgetMemo

View File

@ -0,0 +1,65 @@
import TextField from '@mui/material/TextField';
import { useState, useImperativeHandle, forwardRef } from 'react';
import type { TextFieldProps } from '@mui/material/TextField';
import { useControllableValue } from 'ahooks';
export type refType = {
verify: () => boolean
}
type prop = {
checkList: {
errMsg: string
reg: RegExp
}[]
} & Omit<Omit<TextFieldProps, 'error'>, 'helperText'>
export const CheckInput = forwardRef<refType, prop>(({ required, checkList, ...textFied }, ref) => {
const [err, setErr] = useState("");
const [value, setValue] = useControllableValue<string>(textFied);
const check = (value: string) => {
if (required && (!value || value == "")) {
setErr("此项必填")
return false
}
for (const v of checkList) {
if (!v.reg.test(value)) {
setErr(v.errMsg)
return false
}
}
setErr("")
return true
}
const verify = () => {
return check(value)
}
useImperativeHandle(ref, () => {
return {
verify
}
})
const onChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = event.target.value
setValue(value)
check(value)
}
return <TextField
error={err != ""}
onChange={onChange}
helperText={err}
required={required}
value={value}
{...textFied}
/>
})
export default CheckInput

View File

@ -0,0 +1,15 @@
import Backdrop from "@mui/material/Backdrop";
import CircularProgress from "@mui/material/CircularProgress";
export default function Loading() {
return (
<>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.modal + 1 }}
open={true}
>
<CircularProgress color="inherit" />
</Backdrop>
</>
)
}

View File

@ -0,0 +1,41 @@
import { userInfo } from "@/apis/apis";
import { ApiErr } from "@/apis/error";
import { token } from "@/store/store";
import { useRequest } from "ahooks";
import { useAtomValue } from "jotai";
import { useEffect } from "react";
import { useNavigate, Navigate } from "react-router-dom";
export default function NeedLogin({ children, needAdmin = false }: { children: JSX.Element, needAdmin?: boolean }) {
const t = useAtomValue(token)
const navigate = useNavigate();
const u = useRequest(() => userInfo(t), {
refreshDeps: [t],
cacheKey: "/api/v1/user" + t,
staleTime: 60000,
onError: e => {
if (e instanceof ApiErr && e.code == 5) {
navigate("/login")
}
console.warn(e)
}
})
useEffect(() => {
if (!u.data) return
if (!u.data.is_admin && needAdmin) {
navigate("/login")
}
if (u.data.uuid == "") {
navigate("/login")
}
}, [navigate, needAdmin, u.data])
if (!localStorage.getItem("token") || localStorage.getItem("token") == '""') {
return <Navigate to="/login" />
}
return <> {children}</>
}

View File

@ -0,0 +1,89 @@
import { yggProfile } from "@/apis/apis";
import { decodeSkin } from "@/utils/skin";
import Skeleton from "@mui/material/Skeleton";
import { useHover, useMemoizedFn, useRequest, useUnmount } from "ahooks";
import { memo, useEffect, useRef, useState } from "react";
import ReactSkinview3d, { ReactSkinview3dOptions } from "@/components/Skinview3d";
import { SkinViewer, WalkingAnimation } from "skinview3d";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
interface prop {
uuid: string
width: number
height: number
}
const SkinViewUUID = memo(function SkinViewUUID({ uuid, width, height }: prop) {
const [textures, setTextures] = useState({ skin: "", cape: "", model: "default" })
const [err, setErr] = useState("")
const SkinInfo = useRequest(() => yggProfile(uuid), {
cacheKey: "/api/yggdrasil/sessionserver/session/minecraft/profile/" + uuid,
onError: e => {
console.warn(e)
setErr(String(e))
},
refreshDeps: [uuid],
})
useEffect(() => {
if (!SkinInfo.data) return
const [skin, cape, model] = decodeSkin(SkinInfo.data)
setTextures({ cape: cape, skin: skin, model: model })
}, [SkinInfo.data])
if (err != "") {
return <Typography color={"error"}>{err}</Typography>
}
return (<>
{
(SkinInfo.loading && !SkinInfo.data) ? <Skeleton variant="rectangular" width={width} height={height} />
: (textures.skin != "" || textures.cape != "") ? (
<MySkin
skinUrl={textures.skin}
capeUrl={textures.cape}
height={width}
width={height}
options={{ model: textures.model as "default" | "slim" }}
/>) : <Box sx={{ minHeight: height + "px" }}>
<Typography></Typography>
</Box>
}
</>)
})
const MySkin = function MySkin(p: ReactSkinview3dOptions) {
const refSkinview3d = useRef(null);
const skinisHovering = useHover(refSkinview3d);
const skinview3dView = useRef<SkinViewer | null>(null);
useEffect(() => {
if (skinview3dView.current) {
skinview3dView.current.autoRotate = !skinisHovering
}
if (skinview3dView.current?.animation) {
skinview3dView.current.animation.paused = skinisHovering
}
}, [skinisHovering])
useUnmount(() => {
skinview3dView.current?.dispose()
})
const handelOnReady = useMemoizedFn(v => {
v.viewer.animation = new WalkingAnimation()
v.viewer.autoRotate = true
skinview3dView.current = v.viewer
})
return <div ref={refSkinview3d}>
<ReactSkinview3d
{...p}
onReady={handelOnReady}
/>
</div>
}
export default SkinViewUUID

View File

@ -0,0 +1,101 @@
import { memo, useEffect, useRef } from "react";
import { SkinViewer, SkinViewerOptions } from "skinview3d";
// https://github.com/Hacksore/react-skinview3d/blob/master/src/index.tsx
/**
* This is the interface that describes the parameter in `onReady`
*/
export interface ViewerReadyCallbackOptions {
/**
* The instance of the skinview3d
*/
viewer: SkinViewer;
/**
* The ref to the canvas element
*/
canvasRef: HTMLCanvasElement;
}
export interface ReactSkinview3dOptions {
/**
* The class names to apply to the canvas
*/
className?: string;
/**
* The width of the canvas
*/
width: number | string;
/**
* The height of the canvas
*/
height: number | string;
/**
* The skin to load in the canvas
*/
skinUrl: string;
/**
* The cape to load in the canvas
*/
capeUrl?: string;
/**
* A function that is called when the skin viewer is ready
* @param {SkinViewer} instance callback function to execute when the viewer is loaded {@link SkinViewer}
* @example
* onReady((instance) => {
* console.log(instance)
* })
*/
onReady?: ({ viewer, canvasRef }: ViewerReadyCallbackOptions) => void;
/**
* Parameters passed to the skinview3d constructor allowing you to override or add extra features
* @notes please take a look at the upstream repo for more info
* [bs-community/skinview3d](https://bs-community.github.io/skinview3d/)
*/
options?: SkinViewerOptions;
}
/**
* A skinview3d component
*/
const ReactSkinview3d = memo(function ReactSkinview3d({
className,
width,
height,
skinUrl,
capeUrl,
onReady,
options,
}: ReactSkinview3dOptions) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const skinviewRef = useRef<SkinViewer>();
useEffect(() => {
if (!canvasRef.current) return
const viewer = new SkinViewer({
canvas: canvasRef.current,
width: Number(width),
height: Number(height),
...options,
});
// handle cape/skin load initially
skinUrl && viewer.loadSkin(skinUrl, { model: options?.model ?? "auto-detect" });
capeUrl && viewer.loadCape(capeUrl);
skinviewRef.current = viewer;
// call onReady with the viewer instance
if (onReady) {
onReady({ viewer: skinviewRef.current, canvasRef: canvasRef.current });
}
return () => viewer.dispose()
}, [capeUrl, height, onReady, options, skinUrl, width]);
return <canvas className={className} ref={canvasRef} />;
})
export default ReactSkinview3d;

View File

@ -0,0 +1,16 @@
import { getConfig } from '@/apis/apis'
import { useTitle as auseTitle, useRequest } from 'ahooks'
import { useEffect } from 'react'
export default function useTitle(title: string) {
const { data, error } = useRequest(getConfig, {
cacheKey: "/api/v1/config",
staleTime: 60000,
})
useEffect(() => {
error && console.warn(error)
}, [error])
auseTitle(title + " - " + data?.serverName, {
restoreOnUnmount: true
})
}

View File

@ -1,68 +0,0 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@ -1,10 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import CssBaseline from '@mui/material/CssBaseline';
createRoot(document.getElementById('root')!).render(
<StrictMode>
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<CssBaseline />
<App />
</StrictMode>,
</React.StrictMode>
)

View File

@ -0,0 +1,10 @@
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
export const token = atomWithStorage("token", "")
export const user = atomWithStorage("username", {
name: "",
uuid: ""
})
export const LayoutAlertErr = atom("")

View File

@ -0,0 +1,3 @@
export function checkEmail(email: string) {
return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email);
}

View File

@ -0,0 +1,6 @@
export default function root() {
if (import.meta.env.VITE_APIADDR != "") {
return import.meta.env.VITE_APIADDR
}
return location.origin
}

View File

@ -0,0 +1,17 @@
import { YggProfile } from "@/apis/model";
export function decodeSkin(y: YggProfile) {
if (y.properties.length == 0) {
return ["", "", ""]
}
const p = y.properties.find(v => v.name == "textures")
if (!p?.value || p?.value == "") {
return ["", "", ""]
}
const textures = JSON.parse(atob(p.value))
const skin = textures?.textures?.SKIN?.url as string ?? ""
const cape = textures?.textures?.CAPE?.url as string ?? ""
const model = textures?.textures?.SKIN?.metadata?.model as string ?? "default"
return [skin, cape, model]
}

View File

@ -0,0 +1,120 @@
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import CssBaseline from '@mui/material/CssBaseline';
import Grid from '@mui/material/Grid';
import Box from '@mui/material/Box';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import Typography from '@mui/material/Typography';
import Container from '@mui/material/Container';
import TextField from '@mui/material/TextField';
import { useTitle } from 'ahooks';
import { useEffect, useState } from 'react';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import Loading from '@/components/Loading';
import { produce } from 'immer';
import { forgotPassWord } from '@/apis/apis';
import { useNavigate } from 'react-router-dom';
export default function Forgot() {
const [err, setErr] = useState("")
useTitle("重设密码")
const [passerr, setPasserr] = useState("")
const [pass, setPass] = useState({
pass1: "",
pass2: "",
})
const [load, setLoad] = useState(false)
const [email, setEmail] = useState("")
const [code, setCode] = useState("")
const navigate = useNavigate();
useEffect(() => {
if (pass.pass1 != pass.pass2 && pass.pass2 != "") {
setPasserr("密码不相等")
return
}
setPasserr("")
}, [pass.pass1, pass.pass2])
const u = new URL(location.href)
useEffect(() => {
setEmail(u.searchParams.get("email") ?? "")
setCode(u.searchParams.get("code") ?? "")
}, [u.searchParams])
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setLoad(true)
forgotPassWord(email, code, pass.pass1).then(() => {
navigate("/")
}).catch(e => {
setErr(String(e))
}).finally(() => { setLoad(false) })
}
return (
<Container component="main" maxWidth="xs">
<CssBaseline />
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Avatar sx={{ m: 1, bgcolor: 'secondary.main' }}>
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
</Typography>
<Box component="form" noValidate onSubmit={onSubmit} sx={{ mt: 3 }}>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
margin='dense'
fullWidth
label="新密码"
type="password"
required
onChange={p => setPass(produce(v => { v.pass1 = p.target.value }))}
autoComplete="new-password"
/>
</Grid>
<Grid item xs={12}>
<TextField
margin='dense'
fullWidth
label="确认新密码"
type="password"
required
error={passerr != ""}
helperText={passerr}
onChange={p => setPass(produce(v => { v.pass2 = p.target.value }))}
autoComplete="new-password"
/>
</Grid>
</Grid>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
</Button>
</Box>
</Box>
<Snackbar anchorOrigin={{ vertical: 'top', horizontal: 'center' }} open={err !== ""}>
<Alert onClose={() => setErr("")} severity="error">{err}</Alert>
</Snackbar>
{load && <Loading />}
</Container>
)
}

View File

@ -0,0 +1,9 @@
import Profile from "@/views/profile/Profile"
import Login from "@/views/Login"
export default function Index() {
if (localStorage.getItem("token") && localStorage.getItem("token") != '""') {
return <Profile />
}
return <Login />
}

View File

@ -0,0 +1,291 @@
import * as React from 'react';
import { styled, useTheme } from '@mui/material/styles';
import Box from '@mui/material/Box';
import Drawer from '@mui/material/Drawer';
import Toolbar from '@mui/material/Toolbar';
import List from '@mui/material/List';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import MenuIcon from '@mui/icons-material/Menu';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import AppBar from '@mui/material/AppBar';
import { Outlet } from 'react-router-dom';
import { AccountCircle } from '@mui/icons-material';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { LayoutAlertErr, token, user } from '@/store/store';
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
import Button from '@mui/material/Button';
import { useNavigate } from "react-router-dom";
import { useRequest, useMemoizedFn } from 'ahooks';
import { getConfig, userInfo } from '@/apis/apis'
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import { memo } from 'react';
import useMediaQuery from '@mui/material/useMediaQuery';
import Typography from '@mui/material/Typography';
import Container from '@mui/material/Container';
import PersonIcon from '@mui/icons-material/Person';
import SecurityIcon from '@mui/icons-material/Security';
import SettingsIcon from '@mui/icons-material/Settings';
import { Link } from "react-router-dom";
import GroupIcon from '@mui/icons-material/Group';
import { ApiErr } from '@/apis/error';
const drawerWidth = 240;
const DrawerOpen = atom(false)
const DrawerHeader = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
padding: theme.spacing(0, 1),
// necessary for content to be below app bar
...theme.mixins.toolbar,
justifyContent: 'flex-end',
}));
interface ListItem {
icon: JSX.Element
title: string
link: string
}
const Layout = memo(function Layout() {
const theme = useTheme();
const [err, setErr] = useAtom(LayoutAlertErr)
return (<>
<Box sx={{ display: 'flex' }}>
<AppBar position="fixed"
sx={{
zIndex: { lg: theme.zIndex.drawer + 1 }
}}
>
<MyToolbar />
</AppBar>
<MyDrawer />
<Snackbar anchorOrigin={{ vertical: 'top', horizontal: 'center' }} open={err != ""} >
<Alert onClose={() => setErr("")} severity="error">{err}</Alert>
</Snackbar>
<Box
component="main"
sx={{
flexGrow: 1, bgcolor: 'background.default', p: 3
}}
>
<Toolbar />
<Container maxWidth="lg">
<Outlet />
</Container>
</Box>
</Box>
</>)
})
const MyToolbar = memo(function MyToolbar() {
const [nowUser, setNowUser] = useAtom(user)
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const navigate = useNavigate();
const setToken = useSetAtom(token)
const setErr = useSetAtom(LayoutAlertErr)
const setOpen = useSetAtom(DrawerOpen)
const server = useRequest(getConfig, {
cacheKey: "/api/v1/config",
staleTime: 60000,
onError: e => {
console.warn(e)
setErr(String(e))
}
})
const handleLogOut = useMemoizedFn(() => {
setAnchorEl(null);
setNowUser({ name: "", uuid: "" })
setToken("")
navigate("/login")
})
return (
<>
<Toolbar>
{nowUser.name != "" && (<>
<IconButton
size="large"
edge="start"
color="inherit"
aria-label="menu"
sx={{ mr: 2, display: { lg: 'none' } }}
onClick={() => setOpen(true)}
>
<MenuIcon />
</IconButton >
</>)
}
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
<Link to="/" style={{ color: "unset", textDecoration: "unset" }}>
{server.data?.serverName ?? "皮肤站"}
</Link>
</Typography>
{nowUser.name != "" && (
<div>
<IconButton
size="large"
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={event => setAnchorEl(event.currentTarget)}
color="inherit"
>
<AccountCircle />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
>
<MenuItem onClick={handleLogOut}></MenuItem>
</Menu>
</div>
)}
{nowUser.name == "" && (
<Button color="inherit" onClick={() => navigate("/login")} ></Button>
)}
</Toolbar>
</>)
})
const MyList = memo(function MyList(p: { list: ListItem[] }) {
return (
<>
<List>
{p.list.map(item =>
<MyListItem {...item} key={item.title} />
)}
</List>
</>
)
})
const MyListItem = function MyListItem(p: ListItem) {
const navigate = useNavigate();
const handleClick = () => {
navigate(p.link)
}
return (
<ListItem disablePadding>
<ListItemButton onClick={handleClick}>
<ListItemIcon>
{p.icon}
</ListItemIcon>
<ListItemText primary={p.title} />
</ListItemButton>
</ListItem>
)
}
const MyDrawer = function MyDrawer() {
const nowToken = useAtomValue(token)
const setErr = useSetAtom(LayoutAlertErr)
const theme = useTheme();
const isLg = useMediaQuery(theme.breakpoints.up('lg'))
const [open, setOpen] = useAtom(DrawerOpen)
const navigate = useNavigate();
const userinfo = useRequest(() => userInfo(nowToken), {
refreshDeps: [nowToken],
cacheKey: "/api/v1/user" + nowToken,
staleTime: 60000,
onError: e => {
if (e instanceof ApiErr && e.code == 5) {
navigate("/login")
}
console.warn(e)
setErr(String(e))
},
})
const userDrawerList = React.useMemo(() => [
{
icon: <PersonIcon />,
title: '个人信息',
link: '/profile'
},
{
icon: <SettingsIcon />,
title: '皮肤设置',
link: '/textures'
},
{
icon: <SecurityIcon />,
title: '账号设置',
link: '/security'
}
] as ListItem[], [])
const adminDrawerList = React.useMemo(() => [
{
icon: <GroupIcon />,
title: '用户管理',
link: '/admin/user'
}
] as ListItem[], [])
return (<>
{userinfo.data && (
<Drawer
sx={{
width: drawerWidth,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: drawerWidth,
boxSizing: 'border-box',
},
}}
variant={isLg ? "persistent" : "temporary"}
anchor="left"
open={open || isLg}
onClose={() => setOpen(false)}
>
<DrawerHeader>
<IconButton onClick={() => setOpen(false)}>
{theme.direction === 'ltr' ? <ChevronLeftIcon /> : <ChevronRightIcon />}
</IconButton>
</DrawerHeader>
<Divider />
<MyList list={userDrawerList} />
{userinfo.data?.is_admin && (
<>
<Divider />
<MyList list={adminDrawerList} />
</>)}
</Drawer>
)}
</>)
}
export default Layout

View File

@ -0,0 +1,169 @@
import * as React from 'react';
import { useState } from 'react';
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Link from '@mui/material/Link';
import Grid from '@mui/material/Grid';
import Box from '@mui/material/Box';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import Typography from '@mui/material/Typography';
import Container from '@mui/material/Container';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import { useSetAtom } from 'jotai';
import { token, user } from '@/store/store'
import { getConfig, login } from '@/apis/apis'
import { Link as RouterLink, useNavigate } from "react-router-dom";
import Loading from '@/components/Loading'
import CheckInput, { refType } from '@/components/CheckInput'
import useTitle from '@/hooks/useTitle';
import CaptchaWidget from '@/components/CaptchaWidget';
import type { refType as CaptchaWidgetRef } from '@/components/CaptchaWidget'
import { ApiErr } from '@/apis/error';
import { useRequest } from 'ahooks';
export default function SignIn() {
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const setToken = useSetAtom(token)
const setUserInfo = useSetAtom(user)
const checkList = React.useRef<Map<string, refType>>(new Map<string, refType>())
const navigate = useNavigate();
useTitle("登录")
const captchaRef = React.useRef<CaptchaWidgetRef>(null)
const [captchaToken, setCaptchaToken] = useState("");
const server = useRequest(getConfig, {
cacheKey: "/api/v1/config",
staleTime: 60000,
onError: e => {
console.warn(e)
setErr(String(e))
}
})
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const postData = {
email: data.get('email')?.toString(),
password: data.get('password')?.toString(),
}
if (!Array.from(checkList.current.values()).map(v => v.verify()).reduce((p, v) => (p == true) && (v == true))) {
return
}
if (loading) return
setLoading(true)
login(postData.email!, postData.password ?? "", captchaToken).
then(v => {
if (!v) return
setToken(v.token)
setUserInfo({
uuid: v.uuid,
name: v.name,
})
navigate("/profile")
}).
catch(v => {
captchaRef.current?.reload()
console.warn(v)
if (v instanceof ApiErr) {
switch (v.code) {
case 10:
setErr("验证码错误")
return
case 6:
setErr("密码或用户名错误")
return
case 9:
setErr("用户已被禁用")
return
}
}
setErr(String(v))
}).
finally(() => setLoading(false))
};
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Avatar sx={{ m: 1, bgcolor: 'secondary.main' }}>
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
</Typography>
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
<CheckInput
ref={(dom) => {
dom && checkList.current.set("1", dom)
}}
checkList={[
{
errMsg: "需为邮箱",
reg: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
}
]}
margin="normal"
fullWidth
id="email"
label="邮箱"
name="email"
autoComplete="email"
autoFocus
/>
<TextField
margin="normal"
fullWidth
name="password"
label="密码"
type="password"
id="password"
autoComplete="current-password"
/>
<CaptchaWidget ref={captchaRef} onSuccess={setCaptchaToken} />
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 2, mb: 2 }}
>
</Button>
<Grid container>
<Grid item xs>
{server.data?.NeedEmail && <Link component={RouterLink} to="/forgot_email" variant="body2">
</Link>}
</Grid>
<Grid item>
<Link component={RouterLink} to="/register" variant="body2">
{"注册"}
</Link>
</Grid>
</Grid>
</Box>
</Box>
<Snackbar anchorOrigin={{ vertical: 'top', horizontal: 'center' }} open={err !== ""}>
<Alert onClose={() => setErr("")} severity="error">{err}</Alert>
</Snackbar>
{loading && <Loading />}
</Container>
);
}

View File

@ -0,0 +1,225 @@
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import CssBaseline from '@mui/material/CssBaseline';
import Link from '@mui/material/Link';
import Grid from '@mui/material/Grid';
import Box from '@mui/material/Box';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import Typography from '@mui/material/Typography';
import Container from '@mui/material/Container';
import { Link as RouterLink } from "react-router-dom";
import { getConfig, register } from '@/apis/apis'
import CheckInput, { refType } from '@/components/CheckInput'
import { useRef, useState } from 'react';
import Alert from '@mui/material/Alert';
import Snackbar from '@mui/material/Snackbar';
import Loading from '@/components/Loading'
import { useNavigate } from "react-router-dom";
import CaptchaWidget from '@/components/CaptchaWidget';
import type { refType as CaptchaWidgetRef } from '@/components/CaptchaWidget'
import useTitle from '@/hooks/useTitle';
import { ApiErr } from '@/apis/error';
import { useSetAtom } from 'jotai';
import { token, user } from '@/store/store';
import { useRequest } from 'ahooks';
export default function SignUp() {
const [regErr, setRegErr] = useState("");
const navigate = useNavigate();
const [captchaToken, setCaptchaToken] = useState("");
const captchaRef = useRef<CaptchaWidgetRef>(null)
const [loading, setLoading] = useState(false);
useTitle("注册")
const setToken = useSetAtom(token)
const setUserInfo = useSetAtom(user)
const [code, setCode] = useState("")
const [email, setEmail] = useState("")
const [disableEmail, setDisableEmail] = useState(false)
const u = new URL(location.href)
React.useEffect(() => {
const e = u.searchParams.get("email")
if (!e || e == "") return
setEmail(e)
setDisableEmail(true)
}, [u.searchParams])
const server = useRequest(getConfig, {
cacheKey: "/api/v1/config",
staleTime: 60000,
onError: e => {
console.warn(e)
setRegErr(String(e))
}
})
React.useEffect(() => {
if (!server.data || !server.data.NeedEmail) return
const code = u.searchParams.get("code")
if (!code || code == "") {
navigate("/register_email")
return
}
setCode(code)
}, [server.data, u.searchParams, navigate])
const checkList = React.useRef<Map<string, refType>>(new Map<string, refType>())
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (loading) return
const data = new FormData(event.currentTarget);
const d = {
password: data.get('password')?.toString(),
username: data.get("username")?.toString()
}
if (!Array.from(checkList.current.values()).map(v => v.verify()).reduce((p, v) => (p == true) && (v == true))) {
return
}
if (captchaToken == "") {
setRegErr("验证码无效")
return
}
setLoading(true)
register(email ?? "", d.username ?? "", d.password ?? "", captchaToken, code).
then(v => {
if (!v) return
setToken(v.token)
setUserInfo({
uuid: v.uuid,
name: v.name,
})
navigate("/profile")
}).
catch(v => {
captchaRef.current?.reload()
console.warn(v)
if (v instanceof ApiErr) {
switch (v.code) {
case 10:
setRegErr("验证码错误")
return
case 3:
setRegErr("邮箱已存在")
return
case 7:
setRegErr("用户名已存在")
return
}
}
setRegErr(String(v))
}).
finally(() => setLoading(false))
};
return (
<Container component="main" maxWidth="xs">
<CssBaseline />
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Avatar sx={{ m: 1, bgcolor: 'secondary.main' }}>
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
</Typography>
<Box component="form" noValidate onSubmit={handleSubmit} sx={{ mt: 3 }}>
<Grid container spacing={2}>
<Grid item xs={12}>
<CheckInput
checkList={[
{
errMsg: "需为邮箱",
reg: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
}
]}
required
fullWidth
name="email"
label="邮箱"
value={email}
disabled={disableEmail}
onChange={v => setEmail(v.target.value)}
autoComplete="email"
ref={(dom) => {
dom && checkList.current.set("1", dom)
}}
/>
</Grid>
<Grid item xs={12}>
<CheckInput
ref={(dom) => {
dom && checkList.current.set("2", dom)
}}
checkList={[
{
errMsg: "长度在 3-16 之间",
reg: /^.{3,16}$/
}
]}
required
fullWidth
name="username"
label="角色名"
autoComplete="username"
/>
</Grid>
<Grid item xs={12}>
<CheckInput
ref={(dom) => {
dom && checkList.current.set("3", dom)
}}
checkList={[
{
errMsg: "长度在 6-50 之间",
reg: /^.{6,50}$/
}
]}
required
fullWidth
label="密码"
type="password"
name="password"
autoComplete="new-password"
/>
</Grid>
<Grid item xs={12}>
<CaptchaWidget ref={captchaRef} onSuccess={setCaptchaToken} />
</Grid>
</Grid>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
</Button>
<Grid container justifyContent="flex-end">
<Grid item>
<Link component={RouterLink} to={"/login"} variant="body2">
</Link>
</Grid>
</Grid>
</Box>
</Box>
<Snackbar anchorOrigin={{ vertical: 'top', horizontal: 'center' }} open={regErr !== ""}>
<Alert onClose={() => setRegErr("")} severity="error">{regErr}</Alert>
</Snackbar>
{loading && <Loading />}
</Container>
);
}

View File

@ -0,0 +1,181 @@
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import CssBaseline from '@mui/material/CssBaseline';
import Grid from '@mui/material/Grid';
import Box from '@mui/material/Box';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import Typography from '@mui/material/Typography';
import Container from '@mui/material/Container';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import TextField from '@mui/material/TextField';
import { useRequest, useTitle } from 'ahooks';
import { getConfig } from '@/apis/apis';
import { useEffect, useRef, useState } from 'react';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import CaptchaWidget from '@/components/CaptchaWidget';
import type { refType as CaptchaWidgetRef } from '@/components/CaptchaWidget'
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import { useNavigate } from "react-router-dom";
import { ApiErr } from '@/apis/error';
import Loading from '@/components/Loading';
export default function SendEmail({ title, anyEmail = false, sendService }: { title: string, anyEmail?: boolean, sendService: (email: string, captchaToken: string) => Promise<unknown> }) {
const [err, setErr] = useState("");
const [domain, setDomain] = useState("");
const [email, setEmail] = useState("")
const captchaRef = useRef<CaptchaWidgetRef>(null)
const [captchaToken, setCaptchaToken] = useState("");
const [open, setOpen] = useState(false);
useTitle(title)
const navigate = useNavigate();
const [helperText, setHelperText] = useState("")
const [loading, setLoading] = useState(false);
const server = useRequest(getConfig, {
cacheKey: "/api/v1/config",
staleTime: 60000,
onError: e => {
console.warn(e)
setErr(String(e))
}
})
useEffect(() => {
if (server.data?.AllowDomain.length != 0) {
setDomain(server.data?.AllowDomain[0] ?? "")
}
}, [server.data?.AllowDomain])
const emailonChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setEmail(e.target.value)
if (e.target.value == "") {
setHelperText("邮箱不得为空")
}
setHelperText("")
}
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (email == "") {
setHelperText("邮箱不得为空")
}
const sendEmail = (() => {
if (!anyEmail && domain != "") {
return `${email}@${domain}`
}
return email
})()
if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(sendEmail)) {
setHelperText("邮箱格式错误")
return
}
if (!anyEmail && server.data?.EmailReg && server.data?.EmailReg != ""
&& !new RegExp(server.data?.EmailReg).test(sendEmail)) {
setHelperText(server.data?.EmailRegMsg ?? "邮箱不满足正则要求")
return
}
if (server.data?.captcha.type != "" && captchaToken == "") {
return
}
setLoading(true)
sendService(sendEmail, captchaToken).then(() => setOpen(true)).catch(e => {
captchaRef.current?.reload()
console.warn(e)
if (e instanceof ApiErr) {
switch (e.code) {
case 10:
setErr("验证码错误")
return
case 11:
setErr("暂时无法对此邮箱发送邮件")
return
}
}
setErr(String(e))
}).finally(() => setLoading(false))
}
const handleClose = () => {
navigate("/")
}
return (
<Container component="main" maxWidth="xs">
<CssBaseline />
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Avatar sx={{ m: 1, bgcolor: 'secondary.main' }}>
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
{title}
</Typography>
<Box component="form" noValidate onSubmit={onSubmit} sx={{ mt: 3 }}>
<Grid container spacing={2}>
<Grid item xs={12} sx={{ display: 'grid', columnGap: '3px', gridTemplateColumns: "1fr auto" }}>
<TextField fullWidth
required
name="email"
label="邮箱"
value={email}
helperText={helperText}
error={helperText != ""}
onChange={emailonChange}
/>
{
server.data?.AllowDomain.length != 0 && !anyEmail &&
<FormControl>
<InputLabel></InputLabel>
<Select label="域名" value={domain} onChange={v => setDomain(v.target.value)}>
{server.data?.AllowDomain.map(v => <MenuItem value={v}>@{v}</MenuItem>)}
</Select>
</FormControl>
}
</Grid>
<Grid item xs={12}>
<CaptchaWidget ref={captchaRef} onSuccess={setCaptchaToken} />
</Grid>
</Grid>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
</Button>
</Box>
</Box>
<Snackbar anchorOrigin={{ vertical: 'top', horizontal: 'center' }} open={err !== ""}>
<Alert onClose={() => setErr("")} severity="error">{err}</Alert>
</Snackbar>
<Dialog open={open}>
<DialogTitle></DialogTitle>
<DialogContent>
<Typography></Typography>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}></Button>
</DialogActions>
</Dialog>
{loading && <Loading />}
</Container>
)
}

View File

@ -0,0 +1,237 @@
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';
import useTitle from '@/hooks/useTitle';
import { useRequest } from 'ahooks';
import { ListUser, editUser } from '@/apis/apis';
import { useEffect, useRef, useState } from 'react';
import { useAtomValue } from 'jotai';
import { token } from '@/store/store';
import TablePagination from '@mui/material/TablePagination';
import Alert from '@mui/material/Alert';
import Snackbar from '@mui/material/Snackbar';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import { EditUser, UserInfo } from '@/apis/model';
import { produce } from 'immer'
import Checkbox from '@mui/material/Checkbox';
import FormGroup from '@mui/material/FormGroup';
import FormControlLabel from '@mui/material/FormControlLabel';
import SkinViewUUID from '@/components/SkinViewUUID';
import Loading from '@/components/Loading';
export default function UserAdmin() {
useTitle("用户管理")
const [page, setPage] = useState(1)
const nowtoken = useAtomValue(token)
const [err, setErr] = useState("")
const [email, setEmail] = useState("")
const [name, setName] = useState("")
const [open, setOpen] = useState(false);
const [row, setRow] = useState<UserInfo | null>(null)
const handleOpen = (row: UserInfo) => {
setRow(row)
setOpen(true)
}
const uq = new URLSearchParams("/api/v1/admin/users")
uq.set("page", String(page))
uq.set("email", email)
uq.set("name", name)
const { data, run } = useRequest(ListUser, {
cacheKey: uq.toString(),
debounceWait: 300,
onError: e => {
setErr(String(e))
}
})
useEffect(() => {
run(page, nowtoken, email, name)
}, [page, nowtoken, run, email, name])
return (<>
<Paper>
<Box sx={{ p: "1em", display: "flex", gap: "1em", alignItems: "flex-end" }}>
<Chip label="前缀筛选" />
<TextField onChange={v => setEmail(v.target.value)} label="邮箱" variant="standard" />
<TextField onChange={v => setName(v.target.value)} label="用户名" variant="standard" />
</Box>
<TableContainer >
<Table>
<TableHead>
<TableRow>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell> ip</TableCell>
<TableCell>uuid</TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
<TableBody>
{data?.list.map((row) => (
<TableRow key={row.uid}>
<TableCell sx={{ maxWidth: 'min-content' }}>{row.email}</TableCell>
<TableCell>{row.name}</TableCell>
<TableCell>{row.reg_ip}</TableCell>
<TableCell>{row.uuid}</TableCell>
<TableCell><Button onClick={() => handleOpen(row)}></Button></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[20]}
component="div"
count={data?.total ?? 0}
rowsPerPage={20}
page={page - 1}
onPageChange={(_, page) => setPage(page + 1)}
/>
</Paper >
<Snackbar anchorOrigin={{ vertical: 'top', horizontal: 'center' }} open={err != ""} >
<Alert onClose={() => setErr("")} severity="error">{err}</Alert>
</Snackbar>
<MyDialog open={open} setOpen={setOpen} row={row} onUpdate={() => run(page, nowtoken, email, name)} />
</>);
}
interface MyDialogProp {
open: boolean
setOpen: (b: boolean) => void
row: UserInfo | null
onUpdate: () => void
}
function MyDialog({ open, row, setOpen, onUpdate }: MyDialogProp) {
const handleClose = () => {
setOpen(false)
}
const [erow, setErow] = useState<EditUser>({
email: "",
name: "",
password: "",
is_admin: false,
is_disable: false,
del_textures: false,
})
const [load, setLoad] = useState(false)
const nowToken = useAtomValue(token)
const [err, setErr] = useState("")
const editValue = useRef<EditUser>({});
useEffect(() => {
if (!row) return
setErow({
email: row.email,
name: row.name,
password: "",
is_admin: row.is_admin,
is_disable: row.is_disable,
del_textures: false,
})
editValue.current = {}
}, [row, open])
const handleOpen = () => {
if (load) return
setLoad(true)
editUser(editValue.current, nowToken, String(row?.uid)).then(() => [setOpen(false), onUpdate(), editValue.current = {}]).finally(() => setLoad(false)).
catch(e => setErr(String(e)))
}
type StringKeys<T> = {
[K in keyof T]: T[K] extends string ? K : never;
}[keyof T];
function handleSetValue(key: StringKeys<Required<EditUser>>, value: string) {
setErow(produce(v => {
v[key] = value
editValue.current[key] = value
}))
}
type BoolKeys<T> = {
[K in keyof T]: T[K] extends boolean ? K : never;
}[keyof T];
function handleSetChecked(key: BoolKeys<Required<EditUser>>, value: boolean) {
setErow(produce(v => {
v[key] = value
editValue.current[key] = value
}))
}
return (<>
<Dialog open={open}>
<DialogTitle></DialogTitle>
<DialogContent sx={{
display: "grid", gap: '1em',
gridTemplateAreas: {
md: "'a c' 'b b'",
xs: "'a' 'c' 'b'"
}
}}>
<Box sx={{ display: "flex", flexDirection: 'column', gap: '0.5em', gridArea: "a" }}>
<TextField
margin="dense"
label="邮箱"
type="email"
variant="standard"
value={erow?.email}
onChange={e => handleSetValue('email', e.target.value)}
/>
<TextField
margin="dense"
label="用户名"
type="text"
variant="standard"
value={erow?.name}
onChange={e => handleSetValue('name', e.target.value)}
/>
<TextField
margin="dense"
label="密码"
type="text"
placeholder='(未更改)'
variant="standard"
value={erow?.password}
onChange={e => handleSetValue('password', e.target.value)}
/>
</Box>
<FormGroup row sx={{ gridArea: "b" }}>
<FormControlLabel control={<Checkbox checked={erow?.is_admin} onChange={e => handleSetChecked('is_admin', e.target.checked)} />} label="管理权限" />
<FormControlLabel control={<Checkbox checked={erow?.is_disable} onChange={e => handleSetChecked('is_disable', e.target.checked)} />} label="禁用" />
<FormControlLabel control={<Checkbox checked={erow?.del_textures} onChange={e => handleSetChecked('del_textures', e.target.checked)} />} label="清空材质" />
</FormGroup>
<Box sx={{ gridArea: "c" }}>
<SkinViewUUID uuid={row?.uuid ?? ""} width={175} height={175} />
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}></Button>
<Button onClick={handleOpen}></Button>
</DialogActions>
</Dialog>
{load && <Loading />}
<Snackbar anchorOrigin={{ vertical: 'top', horizontal: 'center' }} open={err != ""} >
<Alert onClose={() => setErr("")} severity="error">{err}</Alert>
</Snackbar>
</>)
}

View File

@ -0,0 +1,69 @@
import Card from '@mui/material/Card';
import CardActions from '@mui/material/CardActions';
import CardContent from '@mui/material/CardContent';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import CardHeader from '@mui/material/CardHeader';
import { user } from '@/store/store';
import { useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom';
import Box from '@mui/material/Box';
import useTitle from '@/hooks/useTitle';
import SkinViewUUID from '@/components/SkinViewUUID';
import root from '@/utils/root';
const Profile = function Profile() {
const navigate = useNavigate();
const userinfo = useAtomValue(user)
useTitle("个人信息")
return (
<>
<Box sx={{
display: "grid", gap: "1em", gridTemplateAreas: {
lg: '"a b d" "c b d"',
xs: '"a" "b" "c" "d"'
}, gridTemplateColumns: { lg: "1fr 1fr auto" }
}}>
<Card sx={{ gridArea: "a" }}>
<CardHeader title="信息" />
<CardContent sx={{ display: "grid", gridTemplateColumns: "4em auto" }}>
<Typography>name</Typography>
<Typography>{userinfo.name}</Typography>
<Typography>uuid</Typography>
<Typography sx={{ wordBreak: 'break-all' }}>{userinfo.uuid}</Typography>
</CardContent>
{/* <CardActions>
<Button size="small"></Button>
</CardActions> */}
</Card>
<Card sx={{ gridArea: "b" }}>
<CardHeader title="皮肤" />
<CardContent sx={{ display: "flex", justifyContent: 'center' }}>
<SkinViewUUID uuid={userinfo?.uuid ?? ""} width={250} height={250} />
</CardContent>
<CardActions>
<Button onClick={() => navigate('/textures')} size="small"></Button>
</CardActions>
</Card>
<Card sx={{ gridArea: "c" }}>
<CardHeader title="启动器设置" />
<CardContent>
<Typography> Yggdrasil API </Typography>
<code style={{ wordBreak: "break-all" }}>{getYggRoot()}</code>
</CardContent>
</Card>
<Box sx={{ gridArea: "d" }}></Box>
</Box >
</>
)
}
function getYggRoot() {
const u = new URL(root() + "/api/yggdrasil")
return u.toString()
}
export default Profile

View File

@ -0,0 +1,202 @@
import Button from "@mui/material/Button";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import CardHeader from "@mui/material/CardHeader";
import TextField from "@mui/material/TextField";
import { useEffect, useState } from "react";
import { produce } from 'immer'
import { changeName, changePasswd, getConfig } from "@/apis/apis";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { LayoutAlertErr, token, user } from "@/store/store";
import Loading from "@/components/Loading";
import { ApiErr } from "@/apis/error";
import { useNavigate } from "react-router-dom";
import useTitle from "@/hooks/useTitle";
import Box from "@mui/material/Box";
import { useRequest } from "ahooks";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogActions from "@mui/material/DialogActions";
export default function Security() {
useTitle("账号设置")
const setLayoutErr = useSetAtom(LayoutAlertErr)
const { data } = useRequest(getConfig, {
cacheKey: "/api/v1/config",
staleTime: 600000,
onError: e => {
setLayoutErr(String(e))
}
})
return (<>
<Box sx={{
display: "grid", gap: "1em",
gridTemplateColumns: {
lg: "1fr 1fr"
}
}}>
<ChangePasswd />
{data?.AllowChangeName && <ChangeName />}
</Box>
</>)
}
function ChangePasswd() {
const [pass, setPass] = useState({
old: "",
pass1: "",
pass2: "",
})
const [err, setErr] = useState("")
const [oldPassErr, setOldPassErr] = useState(false)
const [nowToken, setToken] = useAtom(token)
const [load, setLoad] = useState(false)
const setLayoutErr = useSetAtom(LayoutAlertErr)
const setUser = useSetAtom(user)
const navigate = useNavigate();
useEffect(() => {
if (pass.pass1 != pass.pass2 && pass.pass2 != "") {
setErr("密码不相等")
return
}
setErr("")
}, [pass.pass1, pass.pass2])
const handelClick = () => {
if (pass.pass1 != pass.pass2) return
if (load) return
setLoad(true)
changePasswd(pass.old, pass.pass1, nowToken)
.then(() => [navigate("/login"), setToken(""), setUser({ name: "", uuid: "" })])
.catch(e => {
if (e instanceof ApiErr && e.code == 6) {
setOldPassErr(true)
return
}
setLayoutErr(String(e))
}).finally(() => setLoad(false))
}
return (<>
<Card sx={{ maxWidth: "30em" }}>
<CardHeader title="更改密码" />
<CardContent>
<TextField
margin='dense'
fullWidth
label="旧密码"
type="password"
required
error={oldPassErr}
helperText={oldPassErr ? "旧密码错误" : ""}
onChange={p => setPass(produce(v => { v.old = p.target.value }))}
autoComplete="current-password"
/>
<TextField
margin='dense'
fullWidth
label="新密码"
type="password"
required
onChange={p => setPass(produce(v => { v.pass1 = p.target.value }))}
autoComplete="new-password"
/>
<TextField
margin='dense'
fullWidth
label="确认新密码"
type="password"
required
error={err != ""}
helperText={err}
onChange={p => setPass(produce(v => { v.pass2 = p.target.value }))}
autoComplete="new-password"
/>
<Button sx={{ marginTop: "1em" }} onClick={handelClick} variant='contained'></Button>
</CardContent>
</Card>
{load && <Loading />}
</>)
}
function ChangeName() {
const [err, setErr] = useState("")
const [name, setName] = useState("")
const [open, setOpen] = useState(false)
const [load, setLoad] = useState(false)
const nowToken = useAtomValue(token)
const setUser = useSetAtom(user)
const handelClick = () => {
if (name == "") return
setOpen(true)
}
const handleClose = () => {
setOpen(false)
}
const handleSubmit = () => {
if (load) return
setLoad(true)
changeName(name, nowToken).then(() => {
setName("")
setUser(v => { return { name: name, uuid: v.uuid } })
}).catch(e => {
if (e instanceof ApiErr && e.code == 7) {
setErr("用户名已存在")
return
}
setErr(String(e))
console.warn(e)
}).finally(() => [setLoad(false), setOpen(false)])
}
return (<>
<Card sx={{ height: "min-content" }}>
<CardHeader title="更改用户名" />
<CardContent>
<TextField
margin='dense'
fullWidth
label="新用户名"
type='text'
required
error={err != ""}
helperText={err}
value={name}
onChange={v => setName(v.target.value)}
autoComplete="username"
/>
<Button sx={{ marginTop: "1em" }} onClick={handelClick} variant='contained'></Button>
</CardContent>
</Card>
<Dialog
open={open}
onClose={handleClose}
>
<DialogTitle>
</DialogTitle>
<DialogContent>
<DialogContentText>
{`用户名改为`} {<code> {name} </code>} {``}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}></Button>
<Button onClick={handleSubmit} autoFocus>
</Button>
</DialogActions>
</Dialog>
{load && <Loading />}
</>)
}

View File

@ -0,0 +1,133 @@
import { useEffect, useState } from "react";
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import FormControl from "@mui/material/FormControl";
import FormLabel from "@mui/material/FormLabel";
import FormControlLabel from "@mui/material/FormControlLabel";
import Radio from "@mui/material/Radio";
import RadioGroup from "@mui/material/RadioGroup";
import Button from "@mui/material/Button";
import { CardHeader } from "@mui/material";
import useTitle from "@/hooks/useTitle";
import { MuiFileInput } from 'mui-file-input'
import Box from "@mui/material/Box";
import ReactSkinview3d from '@/components/Skinview3d'
import { useUnmount } from "ahooks";
import { useAtomValue, useSetAtom } from "jotai";
import { LayoutAlertErr, token } from "@/store/store";
import { upTextures } from "@/apis/apis";
import Loading from "@/components/Loading";
import Snackbar from "@mui/material/Snackbar";
const Textures = function Textures() {
const [redioValue, setRedioValue] = useState("skin")
useTitle("上传皮肤")
const [file, setFile] = useState<File | null>(null)
const setErr = useSetAtom(LayoutAlertErr)
const [loading, setLoading] = useState(false)
const nowToken = useAtomValue(token)
const [ok, setOk] = useState(false)
const [skinInfo, setSkinInfo] = useState({
skin: "",
cape: "",
model: "default"
})
useUnmount(() => {
skinInfo.skin && URL.revokeObjectURL(skinInfo.skin)
skinInfo.cape && URL.revokeObjectURL(skinInfo.cape)
})
useEffect(() => {
if (file) {
setSkinInfo(v => {
URL.revokeObjectURL(v.skin);
URL.revokeObjectURL(v.cape);
return { skin: "", cape: "", model: "" }
})
const nu = URL.createObjectURL(file)
switch (redioValue) {
case "skin":
setSkinInfo({ skin: nu, cape: "", model: "default" })
break
case "slim":
setSkinInfo({ skin: nu, cape: "", model: "slim" })
break
case "cape":
setSkinInfo({ skin: "", cape: nu, model: "slim" })
}
}
}, [file, redioValue])
const onRadioChange = (_a: React.ChangeEvent<HTMLInputElement>, value: string) => {
setRedioValue(value)
}
const handleChange = (newFile: File | null) => {
setFile(newFile)
}
const handleToUpload = () => {
if (!file || loading) return
setLoading(true)
const textureType = redioValue == "cape" ? "cape" : "skin"
const model = redioValue == "slim" ? "slim" : ""
upTextures(nowToken, textureType, model, file).then(() => setOk(true)).catch(e => [setErr(String(e)), console.warn(e)]).
finally(() => setLoading(false))
}
return (<>
<Box sx={{
display: "grid", gap: "1em", gridTemplateAreas: {
lg: '"a b" ". b"',
xs: '"a" "b"'
}, gridTemplateColumns: { lg: "1fr 1fr" }
}}>
<Card sx={{ gridArea: "a" }}>
<CardHeader title="设置皮肤" />
<CardContent>
<FormControl>
<FormLabel></FormLabel>
<RadioGroup
row
onChange={onRadioChange}
value={redioValue}
>
<FormControlLabel value="skin" control={<Radio />} label="Steve" />
<FormControlLabel value="slim" control={<Radio />} label="Alex" />
<FormControlLabel value="cape" control={<Radio />} label="披风" />
</RadioGroup>
<br />
<MuiFileInput label="选择文件" value={file} inputProps={{ accept: 'image/png' }} onChange={handleChange} />
<br />
<Button variant="contained" sx={{ maxWidth: "3em" }} onClick={handleToUpload}></Button>
</FormControl>
</CardContent>
</Card>
<Card sx={{ gridArea: "b" }}>
<CardHeader title="预览" />
<CardContent>
{file && <ReactSkinview3d
skinUrl={skinInfo.skin}
capeUrl={skinInfo.cape}
height="250"
width="250"
options={{ model: skinInfo.model as "default" | "slim" }}
/>}
</CardContent>
</Card>
</Box>
<Snackbar
open={ok}
autoHideDuration={6000}
anchorOrigin={{ vertical: "top", horizontal: "center" }}
onClose={() => setOk(false)}
message="成功"
/>
{loading && <Loading />}
</>)
}
export default Textures

View File

@ -1,7 +1,28 @@
import { defineConfig } from 'vite'
import { defineConfig, type PluginOption } from 'vite'
import react from '@vitejs/plugin-react-swc'
import { join } from "path";
import { visualizer } from "rollup-plugin-visualizer";
// https://vite.dev/config/
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [
visualizer() as PluginOption,
react(),
],
resolve: {
alias: {
'@': join(__dirname, "src")
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
['skinview3d']: ['skinview3d'],
['@mui/material']: ['@mui/material']
},
},
},
},
})