feat:简单添加管理员后台功能

This commit is contained in:
jianuo
2025-12-19 19:17:47 +08:00
parent 17c16588aa
commit dd4fb6edd3
29 changed files with 1431 additions and 3 deletions

View 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
View 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>
);
}

View 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
View 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
View 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>,
);

View 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>
);
}

View 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>
);
}