侧栏部分完成

This commit is contained in:
xmdhs 2023-10-01 21:31:56 +08:00
parent 72fa9876a7
commit 5bbe89a3a6
No known key found for this signature in database
GPG Key ID: E809D6D43DEFCC95
13 changed files with 243 additions and 121 deletions

View File

@ -20,8 +20,7 @@ type Config struct {
RsaPriKey string RsaPriKey string
TexturePath string TexturePath string
TextureBaseUrl string TextureBaseUrl string
HomepageUrl string WebBaseUrl string
RegisterUrl string
ServerName string ServerName string
Captcha struct { Captcha struct {

View File

@ -28,7 +28,8 @@ func (User) Fields() []ent.Field {
field.String("reg_ip").SchemaType(map[string]string{ field.String("reg_ip").SchemaType(map[string]string{
dialect.MySQL: "VARCHAR(32)", dialect.MySQL: "VARCHAR(32)",
}), }),
// 二进制状态位,保留 // 二进制状态位
// 第一位为 1 则是 admin
field.Int("state"), field.Int("state"),
field.Int64("reg_time"), field.Int64("reg_time"),
} }

View File

@ -20,7 +20,8 @@
"jotai": "^2.4.2", "jotai": "^2.4.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.16.0" "react-router-dom": "^6.16.0",
"tilg": "^0.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.6.2", "@types/node": "^20.6.2",

View File

@ -38,6 +38,9 @@ dependencies:
react-router-dom: react-router-dom:
specifier: ^6.16.0 specifier: ^6.16.0
version: 6.16.0(react-dom@18.2.0)(react@18.2.0) version: 6.16.0(react-dom@18.2.0)(react@18.2.0)
tilg:
specifier: ^0.1.1
version: 0.1.1(react@18.2.0)
devDependencies: devDependencies:
'@types/node': '@types/node':
@ -2107,6 +2110,14 @@ packages:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
dev: true dev: true
/tilg@0.1.1(react@18.2.0):
resolution: {integrity: sha512-0uHyTAUM0tJL792LeviRPFkJtCbF6Za3/hbbnRmWGUaicOhbJ0IpvBViXiXTF7nk6R0L6vve2XLesQzn5jEVng==}
peerDependencies:
react: ^18.0.0 || ^17.0.0
dependencies:
react: 18.2.0
dev: false
/to-fast-properties@2.0.0: /to-fast-properties@2.0.0:
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
engines: {node: '>=4'} engines: {node: '>=4'}

View File

@ -1,5 +1,5 @@
import type { tokenData, ApiUser } from '@/apis/model' import type { tokenData, ApiUser, ApiServerInfo } from '@/apis/model'
import { apiWrapGet } from '@/apis/utils' import { apiGet } from '@/apis/utils'
export async function login(username: string, password: string) { export async function login(username: string, password: string) {
const v = await fetch(import.meta.env.VITE_APIADDR + "/api/yggdrasil/authserver/authenticate", { const v = await fetch(import.meta.env.VITE_APIADDR + "/api/yggdrasil/authserver/authenticate", {
@ -11,7 +11,7 @@ export async function login(username: string, password: string) {
}) })
const data = await v.json() const data = await v.json()
if (!v.ok) { if (!v.ok) {
throw data?.errorMessage throw new Error(data?.errorMessage)
} }
return data as tokenData return data as tokenData
} }
@ -26,7 +26,7 @@ export async function register(email: string, username: string, password: string
"CaptchaToken": captchaToken "CaptchaToken": captchaToken
}) })
}) })
return await apiWrapGet(v) return await apiGet(v)
} }
export async function userInfo(token: string) { export async function userInfo(token: string) {
@ -36,6 +36,11 @@ export async function userInfo(token: string) {
"Authorization": "Bearer " + token "Authorization": "Bearer " + token
} }
}) })
return await apiWrapGet<ApiUser>(v) return await apiGet<ApiUser>(v)
} }
export async function serverInfo() {
const v = await fetch(import.meta.env.VITE_APIADDR + "/api/yggdrasil")
return await v.json() as ApiServerInfo
}

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

@ -21,9 +21,14 @@ interface captcha {
export type ApiCaptcha = Api<captcha> export type ApiCaptcha = Api<captcha>
interface user { export interface ApiUser {
uid: string uid: string
uuid: string uuid: string
is_admin: boolean
} }
export type ApiUser = Api<user> export interface ApiServerInfo {
meta: {
serverName: string
}
}

View File

@ -1,7 +1,10 @@
export async function apiWrapGet<T>(v: Response) { import { ApiErr } from "./error"
const data = await v.json()
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) { if (!v.ok) {
throw data.msg throw new ApiErr(data.code, data.msg)
} }
return data as T return data.data
} }

View File

@ -20,10 +20,19 @@ import { Outlet } from 'react-router-dom';
import { AccountCircle } from '@mui/icons-material'; import { AccountCircle } from '@mui/icons-material';
import Menu from '@mui/material/Menu'; import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem'; import MenuItem from '@mui/material/MenuItem';
import { user } from '@/store/store'; import { token, user } from '@/store/store';
import { useAtom } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useRequest, useMemoizedFn } from 'ahooks';
import { serverInfo, 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 useTilg from 'tilg'
import { ApiErr } from '@/apis/error';
import Typography from '@mui/material/Typography';
const drawerWidth = 240; const drawerWidth = 240;
@ -40,114 +49,181 @@ const DrawerHeader = styled('div')(({ theme }) => ({
export default function Layout() { export default function Layout() {
const theme = useTheme(); const theme = useTheme();
const isLg = useMediaQuery(theme.breakpoints.up('lg'))
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null); const nowToken = useAtomValue(token)
const [nowUser, setNowUser] = useAtom(user) const [err, setErr] = React.useState("");
const navigate = useNavigate(); const navigate = useNavigate();
const handleLogOut = () => {
setAnchorEl(null);
setNowUser({ name: "", uuid: "" })
};
const userinfo = useRequest(() => userInfo(nowToken), {
refreshDeps: [nowToken],
cacheKey: "/api/v1/user",
cacheTime: 10000,
onError: e => {
if (e instanceof ApiErr && e.code == 5) {
navigate("/login")
}
console.warn(e)
setErr(String(e))
}
})
useTilg(isLg, open)
return (<> return (<>
<Box sx={{ flexGrow: 1 }}> <Box sx={{ display: 'flex' }}>
<AppBar position="static"> <AppBar position="fixed"
<Toolbar> sx={{
zIndex: { lg: theme.zIndex.drawer + 1 }
}}
>
<MyToolbar setOpen={setOpen}></MyToolbar>
</AppBar>
{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 />
<List>
{['Inbox', 'Starred', 'Send email', 'Drafts'].map((text, index) => (
<ListItem key={text} disablePadding>
<ListItemButton>
<ListItemIcon>
{index % 2 === 0 ? <InboxIcon /> : <MailIcon />}
</ListItemIcon>
<ListItemText primary={text} />
</ListItemButton>
</ListItem>
))}
</List>
{userinfo.data?.is_admin && (
<>
<Divider />
<List>
{['All mail', 'Trash', 'Spam'].map((text, index) => (
<ListItem key={text} disablePadding>
<ListItemButton>
<ListItemIcon>
{index % 2 === 0 ? <InboxIcon /> : <MailIcon />}
</ListItemIcon>
<ListItemText primary={text} />
</ListItemButton>
</ListItem>
))}
</List>
</>)}
</Drawer>
)}
<Snackbar anchorOrigin={{ vertical: 'top', horizontal: 'center' }} open={err != ""} onClose={() => setErr("")} >
<Alert onClose={() => setErr("")} severity="error">{err}</Alert>
</Snackbar>
<Box
component="main"
sx={{
flexGrow: 1, bgcolor: 'background.default', p: 3
}}
>
<Outlet />
</Box>
</Box>
</>)
}
const MyToolbar = memo((p: { setOpen: (v: boolean) => void }) => {
const [nowUser, setNowUser] = useAtom(user)
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const navigate = useNavigate();
const [, setToken] = useAtom(token)
const server = useRequest(serverInfo, {
cacheKey: "/api/yggdrasil",
cacheTime: 100000,
onError: e => {
console.warn(e)
}
})
const handleLogOut = useMemoizedFn(() => {
setAnchorEl(null);
setNowUser({ name: "", uuid: "" })
setToken("")
navigate("/")
})
return (
<>
<Toolbar>
{nowUser.name != "" && (<>
<IconButton <IconButton
size="large" size="large"
edge="start" edge="start"
color="inherit" color="inherit"
aria-label="menu" aria-label="menu"
sx={{ mr: 2 }} sx={{ mr: 2, display: { lg: 'none' } }}
onClick={() => setOpen(true)} onClick={() => p.setOpen(true)}
> >
<MenuIcon /> <MenuIcon />
</IconButton > </IconButton >
<Box sx={{ flexGrow: 1 }}></Box> </>)
{nowUser.name != "" && ( }
<div> <Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
<IconButton {server.data?.meta.serverName ?? "皮肤站"}
size="large" </Typography>
aria-label="account of current user" {nowUser.name != "" && (
aria-controls="menu-appbar" <div>
aria-haspopup="true" <IconButton
onClick={event => setAnchorEl(event.currentTarget)} size="large"
color="inherit" aria-label="account of current user"
> aria-controls="menu-appbar"
<AccountCircle /> aria-haspopup="true"
</IconButton> onClick={event => setAnchorEl(event.currentTarget)}
<Menu color="inherit"
id="menu-appbar" >
anchorEl={anchorEl} <AccountCircle />
anchorOrigin={{ </IconButton>
vertical: 'top', <Menu
horizontal: 'right', id="menu-appbar"
}} anchorEl={anchorEl}
keepMounted anchorOrigin={{
transformOrigin={{ vertical: 'top',
vertical: 'top', horizontal: 'right',
horizontal: 'right', }}
}} keepMounted
open={Boolean(anchorEl)} transformOrigin={{
onClose={() => setAnchorEl(null)} vertical: 'top',
> horizontal: 'right',
<MenuItem onClick={handleLogOut}></MenuItem> }}
</Menu> open={Boolean(anchorEl)}
</div> onClose={() => setAnchorEl(null)}
)} >
{nowUser.name == "" && ( <MenuItem onClick={handleLogOut}></MenuItem>
<Button color="inherit" onClick={()=> navigate("/login")} ></Button> </Menu>
)} </div>
</Toolbar> )}
</AppBar> {nowUser.name == "" && (
</Box> <Button color="inherit" onClick={() => navigate("/login")} ></Button>
<Drawer )}
sx={{ </Toolbar>
width: drawerWidth, </>)
flexShrink: 0, })
'& .MuiDrawer-paper': {
width: drawerWidth,
boxSizing: 'border-box',
},
}}
variant="persistent"
anchor="left"
open={open}
>
<DrawerHeader>
<IconButton onClick={() => setOpen(false)}>
{theme.direction === 'ltr' ? <ChevronLeftIcon /> : <ChevronRightIcon />}
</IconButton>
</DrawerHeader>
<Divider />
<List>
{['Inbox', 'Starred', 'Send email', 'Drafts'].map((text, index) => (
<ListItem key={text} disablePadding>
<ListItemButton>
<ListItemIcon>
{index % 2 === 0 ? <InboxIcon /> : <MailIcon />}
</ListItemIcon>
<ListItemText primary={text} />
</ListItemButton>
</ListItem>
))}
</List>
<Divider />
<List>
{['All mail', 'Trash', 'Spam'].map((text, index) => (
<ListItem key={text} disablePadding>
<ListItemButton>
<ListItemIcon>
{index % 2 === 0 ? <InboxIcon /> : <MailIcon />}
</ListItemIcon>
<ListItemText primary={text} />
</ListItemButton>
</ListItem>
))}
</List>
</Drawer>
<Outlet />
</>)
}

View File

@ -50,7 +50,7 @@ export default function SignIn() {
uuid: v.selectedProfile.uuid, uuid: v.selectedProfile.uuid,
name: v.selectedProfile.name, name: v.selectedProfile.name,
}) })
navigate("/user") navigate("/")
}). }).
catch(v => [setErr(String(v)), console.warn(v)]). catch(v => [setErr(String(v)), console.warn(v)]).
finally(() => setLoading(false)) finally(() => setLoading(false))

View File

@ -61,13 +61,16 @@ func (y *Yggdrasil) YggdrasilRoot() httprouter.Handle {
return h, err return h, err
}, r.Host) }, r.Host)
} }
homepage, _ := url.JoinPath(y.config.WebBaseUrl, "/login")
register, _ := url.JoinPath(y.config.WebBaseUrl, "/register")
w.Write(lo.Must1(json.Marshal(yggdrasilM.Yggdrasil{ w.Write(lo.Must1(json.Marshal(yggdrasilM.Yggdrasil{
Meta: yggdrasilM.YggdrasilMeta{ Meta: yggdrasilM.YggdrasilMeta{
ImplementationName: "authlib-skin", ImplementationName: "authlib-skin",
ImplementationVersion: "0.0.1", ImplementationVersion: "0.0.1",
Links: yggdrasilM.YggdrasilMetaLinks{ Links: yggdrasilM.YggdrasilMetaLinks{
Homepage: y.config.HomepageUrl, Homepage: homepage,
Register: y.config.RegisterUrl, Register: register,
}, },
ServerName: y.config.ServerName, ServerName: y.config.ServerName,
EnableProfileKey: true, EnableProfileKey: true,

View File

@ -31,6 +31,7 @@ type Captcha struct {
} }
type UserInfo struct { type UserInfo struct {
UID int `json:"uid"` UID int `json:"uid"`
UUID string `json:"uuid"` UUID string `json:"uuid"`
IsAdmin bool `json:"is_admin"`
} }

View File

@ -96,8 +96,17 @@ func (w *WebService) Info(ctx context.Context, token string) (model.UserInfo, er
if err != nil { if err != nil {
return model.UserInfo{}, fmt.Errorf("Info: %w", err) return model.UserInfo{}, fmt.Errorf("Info: %w", err)
} }
u, err := w.client.User.Query().Where(user.ID(t.UID)).First(ctx)
if err != nil {
return model.UserInfo{}, fmt.Errorf("Info: %w", err)
}
isAdmin := false
if u.State&1 == 1 {
isAdmin = true
}
return model.UserInfo{ return model.UserInfo{
UID: t.UID, UID: t.UID,
UUID: t.Subject, UUID: t.Subject,
IsAdmin: isAdmin,
}, nil }, nil
} }