pref: test
This commit is contained in:
parent
2efe423131
commit
c69b23f4e0
@ -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
1130
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
}
|
@ -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
58
frontend/src/Route.tsx
Normal 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
156
frontend/src/apis/apis.ts
Normal 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)
|
||||
}
|
8
frontend/src/apis/error.ts
Normal file
8
frontend/src/apis/error.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export class ApiErr extends Error {
|
||||
readonly code: number
|
||||
|
||||
constructor(code: number, msg: string) {
|
||||
super(msg)
|
||||
this.code = code
|
||||
}
|
||||
}
|
66
frontend/src/apis/model.ts
Normal file
66
frontend/src/apis/model.ts
Normal 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
|
||||
}
|
10
frontend/src/apis/utils.ts
Normal file
10
frontend/src/apis/utils.ts
Normal 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
|
||||
}
|
@ -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 |
65
frontend/src/components/CaptchaWidget.tsx
Normal file
65
frontend/src/components/CaptchaWidget.tsx
Normal 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
|
65
frontend/src/components/CheckInput.tsx
Normal file
65
frontend/src/components/CheckInput.tsx
Normal 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
|
15
frontend/src/components/Loading.tsx
Normal file
15
frontend/src/components/Loading.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
41
frontend/src/components/NeedLogin.tsx
Normal file
41
frontend/src/components/NeedLogin.tsx
Normal 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}</>
|
||||
}
|
89
frontend/src/components/SkinViewUUID.tsx
Normal file
89
frontend/src/components/SkinViewUUID.tsx
Normal 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
|
101
frontend/src/components/Skinview3d.tsx
Normal file
101
frontend/src/components/Skinview3d.tsx
Normal 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;
|
16
frontend/src/hooks/useTitle.ts
Normal file
16
frontend/src/hooks/useTitle.ts
Normal 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
|
||||
})
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
||||
|
10
frontend/src/store/store.ts
Normal file
10
frontend/src/store/store.ts
Normal 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("")
|
3
frontend/src/utils/email.ts
Normal file
3
frontend/src/utils/email.ts
Normal 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);
|
||||
}
|
6
frontend/src/utils/root.ts
Normal file
6
frontend/src/utils/root.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export default function root() {
|
||||
if (import.meta.env.VITE_APIADDR != "") {
|
||||
return import.meta.env.VITE_APIADDR
|
||||
}
|
||||
return location.origin
|
||||
}
|
17
frontend/src/utils/skin.ts
Normal file
17
frontend/src/utils/skin.ts
Normal 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]
|
||||
}
|
120
frontend/src/views/Forgot.tsx
Normal file
120
frontend/src/views/Forgot.tsx
Normal 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>
|
||||
)
|
||||
}
|
9
frontend/src/views/Index.tsx
Normal file
9
frontend/src/views/Index.tsx
Normal 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 />
|
||||
}
|
291
frontend/src/views/Layout.tsx
Normal file
291
frontend/src/views/Layout.tsx
Normal 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
|
169
frontend/src/views/Login.tsx
Normal file
169
frontend/src/views/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
225
frontend/src/views/Register.tsx
Normal file
225
frontend/src/views/Register.tsx
Normal 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>
|
||||
);
|
||||
}
|
181
frontend/src/views/SendEmail.tsx
Normal file
181
frontend/src/views/SendEmail.tsx
Normal 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>
|
||||
)
|
||||
}
|
237
frontend/src/views/admin/UserAdmin.tsx
Normal file
237
frontend/src/views/admin/UserAdmin.tsx
Normal 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>
|
||||
</>)
|
||||
}
|
69
frontend/src/views/profile/Profile.tsx
Normal file
69
frontend/src/views/profile/Profile.tsx
Normal 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
|
202
frontend/src/views/profile/Security.tsx
Normal file
202
frontend/src/views/profile/Security.tsx
Normal 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 />}
|
||||
</>)
|
||||
}
|
133
frontend/src/views/profile/Textures.tsx
Normal file
133
frontend/src/views/profile/Textures.tsx
Normal 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
|
@ -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']
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
Loading…
x
Reference in New Issue
Block a user