feat:简单添加管理员后台功能
This commit is contained in:
4
client/.env.example
Normal file
4
client/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
# 前端后台配置
|
||||
# 复制为 .env.local
|
||||
|
||||
VITE_API_BASE_URL=http://localhost:3000
|
||||
12
client/index.html
Normal file
12
client/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Whale Town Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
24
client/package.json
Normal file
24
client/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "whale-town-admin",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"antd": "^5.27.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.24",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.1.3"
|
||||
}
|
||||
}
|
||||
52
client/src/app/AdminLayout.tsx
Normal file
52
client/src/app/AdminLayout.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Layout, Menu, Typography } from 'antd';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { clearAuth } from '../lib/adminAuth';
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
|
||||
export function AdminLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const selectedKey = location.pathname.startsWith('/users') ? 'users' : 'users';
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider width={220}>
|
||||
<div style={{ padding: 16 }}>
|
||||
<Typography.Title level={5} style={{ color: 'white', margin: 0 }}>
|
||||
Whale Town Admin
|
||||
</Typography.Title>
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
items={[
|
||||
{
|
||||
key: 'users',
|
||||
label: '用户管理',
|
||||
onClick: () => navigate('/users'),
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
label: '退出登录',
|
||||
onClick: () => {
|
||||
clearAuth();
|
||||
navigate('/login');
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header style={{ background: 'white', display: 'flex', alignItems: 'center' }}>
|
||||
<Typography.Text>后台管理</Typography.Text>
|
||||
</Header>
|
||||
<Content style={{ padding: 16 }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
26
client/src/app/App.tsx
Normal file
26
client/src/app/App.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ConfigProvider } from 'antd';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { AdminLayout } from './AdminLayout';
|
||||
import { LoginPage } from '../pages/LoginPage';
|
||||
import { UsersPage } from '../pages/UsersPage';
|
||||
import { isAuthed } from '../lib/adminAuth';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ConfigProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={isAuthed() ? <AdminLayout /> : <Navigate to="/login" replace />}
|
||||
>
|
||||
<Route index element={<Navigate to="/users" replace />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to={isAuthed() ? '/users' : '/login'} replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
17
client/src/lib/adminAuth.ts
Normal file
17
client/src/lib/adminAuth.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
const TOKEN_KEY = 'whale_town_admin_token';
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearAuth(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function isAuthed(): boolean {
|
||||
return Boolean(getToken());
|
||||
}
|
||||
62
client/src/lib/api.ts
Normal file
62
client/src/lib/api.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { getToken, clearAuth } from './adminAuth';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000';
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const token = getToken();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...headers,
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
clearAuth();
|
||||
}
|
||||
|
||||
const data = (await res.json().catch(() => ({}))) as any;
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(data?.message || `请求失败: ${res.status}`, res.status);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
adminLogin: (identifier: string, password: string) =>
|
||||
request<any>('/admin/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ identifier, password }),
|
||||
}),
|
||||
|
||||
listUsers: (limit = 100, offset = 0) =>
|
||||
request<any>(`/admin/users?limit=${encodeURIComponent(limit)}&offset=${encodeURIComponent(offset)}`),
|
||||
|
||||
resetUserPassword: (userId: string, newPassword: string) =>
|
||||
request<any>(`/admin/users/${encodeURIComponent(userId)}/reset-password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ new_password: newPassword }),
|
||||
}),
|
||||
};
|
||||
9
client/src/main.tsx
Normal file
9
client/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './app/App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
50
client/src/pages/LoginPage.tsx
Normal file
50
client/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Button, Card, Form, Input, Typography, message } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../lib/api';
|
||||
import { setToken } from '../lib/adminAuth';
|
||||
|
||||
type LoginValues = {
|
||||
identifier: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm<LoginValues>();
|
||||
|
||||
const onFinish = async (values: LoginValues) => {
|
||||
try {
|
||||
const res = await api.adminLogin(values.identifier, values.password);
|
||||
if (!res?.success || !res?.data?.access_token) {
|
||||
throw new Error(res?.message || '登录失败');
|
||||
}
|
||||
|
||||
setToken(res.data.access_token);
|
||||
message.success('登录成功');
|
||||
navigate('/users');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '登录失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Card style={{ width: 420 }}>
|
||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||
管理员登录
|
||||
</Typography.Title>
|
||||
<Form form={form} layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item name="identifier" label="用户名/邮箱/手机号" rules={[{ required: true }]}>
|
||||
<Input placeholder="admin" autoComplete="username" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码" rules={[{ required: true }]}>
|
||||
<Input.Password placeholder="请输入密码" autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
登录
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
client/src/pages/UsersPage.tsx
Normal file
161
client/src/pages/UsersPage.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Button, Card, Form, Input, Modal, Space, Table, Typography, message } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
email?: string;
|
||||
email_verified: boolean;
|
||||
phone?: string;
|
||||
role: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type ResetValues = {
|
||||
newPassword: string;
|
||||
};
|
||||
|
||||
export function UsersPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [rows, setRows] = useState<UserRow[]>([]);
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
const [resetUserId, setResetUserId] = useState<string | null>(null);
|
||||
const [resetForm] = Form.useForm<ResetValues>();
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 90 },
|
||||
{ title: '用户名', dataIndex: 'username', key: 'username' },
|
||||
{ title: '昵称', dataIndex: 'nickname', key: 'nickname' },
|
||||
{ title: '邮箱', dataIndex: 'email', key: 'email' },
|
||||
{
|
||||
title: '邮箱验证',
|
||||
dataIndex: 'email_verified',
|
||||
key: 'email_verified',
|
||||
render: (v: boolean) => (v ? '已验证' : '未验证'),
|
||||
width: 100,
|
||||
},
|
||||
{ title: '手机号', dataIndex: 'phone', key: 'phone' },
|
||||
{ title: '角色', dataIndex: 'role', key: 'role', width: 80 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
render: (_: any, row: UserRow) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setResetUserId(row.id);
|
||||
resetForm.resetFields();
|
||||
setResetOpen(true);
|
||||
}}
|
||||
>
|
||||
重置密码
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[resetForm],
|
||||
);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.listUsers(200, 0);
|
||||
const users = res?.data?.users || [];
|
||||
setRows(
|
||||
users.map((u: any) => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
nickname: u.nickname,
|
||||
email: u.email || undefined,
|
||||
email_verified: Boolean(u.email_verified),
|
||||
phone: u.phone || undefined,
|
||||
role: u.role,
|
||||
created_at: u.created_at,
|
||||
})),
|
||||
);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const onResetOk = async () => {
|
||||
try {
|
||||
const values = await resetForm.validateFields();
|
||||
if (!resetUserId) return;
|
||||
|
||||
await api.resetUserPassword(resetUserId, values.newPassword);
|
||||
message.success('密码已重置');
|
||||
setResetOpen(false);
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '重置失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
用户管理
|
||||
</Typography.Title>
|
||||
<Button onClick={load} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns as any}
|
||||
dataSource={rows}
|
||||
pagination={false}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
title={`重置密码${resetUserId ? `(用户ID: ${resetUserId})` : ''}`}
|
||||
open={resetOpen}
|
||||
onOk={onResetOk}
|
||||
onCancel={() => setResetOpen(false)}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={resetForm} layout="vertical">
|
||||
<Form.Item
|
||||
label="新密码"
|
||||
name="newPassword"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 8, message: '至少8位' },
|
||||
{
|
||||
validator: (_, v) => {
|
||||
const hasLetter = /[a-zA-Z]/.test(v || '');
|
||||
const hasNumber = /\d/.test(v || '');
|
||||
if (!v) return Promise.resolve();
|
||||
if (!hasLetter || !hasNumber) return Promise.reject(new Error('必须包含字母和数字'));
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="例如 NewPass1234" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
19
client/tsconfig.json
Normal file
19
client/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
9
client/vite.config.ts
Normal file
9
client/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user