feat: integrate invitation access, world NPCs, and deployment
This commit is contained in:
@@ -1 +1 @@
|
||||
VITE_API_BASE_URL=https://whaletownend.xinghangee.icu
|
||||
VITE_API_BASE_URL=/api
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"antd": "^5.27.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.1"
|
||||
"react-router-dom": "^7.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.24",
|
||||
|
||||
@@ -10,6 +10,8 @@ export function AdminLayout() {
|
||||
|
||||
const selectedKey = location.pathname.startsWith('/logs')
|
||||
? 'logs'
|
||||
: location.pathname.startsWith('/invitation-codes')
|
||||
? 'invitation-codes'
|
||||
: location.pathname.startsWith('/users')
|
||||
? 'users'
|
||||
: 'users';
|
||||
@@ -32,6 +34,11 @@ export function AdminLayout() {
|
||||
label: '用户管理',
|
||||
onClick: () => navigate('/users'),
|
||||
},
|
||||
{
|
||||
key: 'invitation-codes',
|
||||
label: '邀请码管理',
|
||||
onClick: () => navigate('/invitation-codes'),
|
||||
},
|
||||
{
|
||||
key: 'logs',
|
||||
label: '运行日志',
|
||||
|
||||
@@ -4,12 +4,13 @@ import { AdminLayout } from './AdminLayout';
|
||||
import { LoginPage } from '../pages/LoginPage';
|
||||
import { UsersPage } from '../pages/UsersPage';
|
||||
import { LogsPage } from '../pages/LogsPage';
|
||||
import { InvitationCodesPage } from '../pages/InvitationCodesPage';
|
||||
import { isAuthed } from '../lib/adminAuth';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ConfigProvider>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
@@ -18,6 +19,7 @@ export function App() {
|
||||
>
|
||||
<Route index element={<Navigate to="/users" replace />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="invitation-codes" element={<InvitationCodesPage />} />
|
||||
<Route path="logs" element={<LogsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to={isAuthed() ? '/users' : '/login'} replace />} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getToken, clearAuth } from './adminAuth';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000';
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
@@ -120,9 +120,18 @@ export const api = {
|
||||
resetUserPassword: (userId: string, newPassword: string) =>
|
||||
request<any>(`/admin/users/${encodeURIComponent(userId)}/reset-password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ new_password: newPassword }),
|
||||
body: JSON.stringify({ newPassword }),
|
||||
}),
|
||||
|
||||
listInvitationCodes: (limit = 100, offset = 0) =>
|
||||
request<any>(`/admin/invitation-codes?limit=${limit}&offset=${offset}`),
|
||||
|
||||
generateInvitationCodes: (payload: { count: number; max_uses: number; expires_at?: string; note?: string }) =>
|
||||
request<any>('/admin/invitation-codes', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
|
||||
revokeInvitationCode: (id: string) =>
|
||||
request<any>(`/admin/invitation-codes/${encodeURIComponent(id)}/revoke`, { method: 'POST' }),
|
||||
|
||||
getRuntimeLogs: (lines = 200) =>
|
||||
request<any>(`/admin/logs/runtime?lines=${encodeURIComponent(lines)}`),
|
||||
|
||||
|
||||
68
client/src/pages/InvitationCodesPage.tsx
Normal file
68
client/src/pages/InvitationCodesPage.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Button, DatePicker, Form, Input, InputNumber, Modal, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
type Row = { id: string; code: string; max_uses: number; used_count: number; effective_status: string; expires_at?: string; note?: string; created_at: string };
|
||||
|
||||
const statusText: Record<string, [string, string]> = {
|
||||
active: ['可使用', 'green'], exhausted: ['已用完', 'default'], expired: ['已过期', 'orange'], revoked: ['已作废', 'red'],
|
||||
};
|
||||
|
||||
export function InvitationCodesPage() {
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [generated, setGenerated] = useState<string[]>([]);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try { setRows((await api.listInvitationCodes())?.data?.items || []); }
|
||||
catch (e: any) { message.error(e?.message || '加载失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
const generate = async () => {
|
||||
try {
|
||||
const value = await form.validateFields();
|
||||
const res = await api.generateInvitationCodes({ ...value, expires_at: value.expires_at?.toISOString() });
|
||||
setGenerated((res?.data?.codes || []).map((item: any) => item.code));
|
||||
setOpen(false); form.resetFields(); await load();
|
||||
} catch (e: any) { if (!e?.errorFields) message.error(e?.message || '生成失败'); }
|
||||
};
|
||||
|
||||
const copyGenerated = async () => {
|
||||
await navigator.clipboard.writeText(generated.join('\n'));
|
||||
message.success('已复制全部邀请码');
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '邀请码', dataIndex: 'code' },
|
||||
{ title: '状态', dataIndex: 'effective_status', render: (v: string) => { const item = statusText[v] || [v, 'default']; return <Tag color={item[1]}>{item[0]}</Tag>; } },
|
||||
{ title: '使用量', render: (_: unknown, row: Row) => `${row.used_count} / ${row.max_uses}` },
|
||||
{ title: '有效期至', dataIndex: 'expires_at', render: (v?: string) => v ? new Date(v).toLocaleString() : '永久' },
|
||||
{ title: '备注', dataIndex: 'note' },
|
||||
{ title: '操作', render: (_: unknown, row: Row) => <Button danger size="small" disabled={row.effective_status === 'revoked'} onClick={() => Modal.confirm({ title: '作废该邀请码?', onOk: async () => { await api.revokeInvitationCode(row.id); await load(); } })}>作废</Button> },
|
||||
];
|
||||
|
||||
return <Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>邀请码管理</Typography.Title>
|
||||
<Space><Button onClick={load} loading={loading}>刷新</Button><Button type="primary" onClick={() => setOpen(true)}>生成邀请码</Button></Space>
|
||||
</Space>
|
||||
<Table rowKey="id" columns={columns as any} dataSource={rows} loading={loading} pagination={{ pageSize: 20 }} />
|
||||
<Modal title="生成邀请码" open={open} onOk={generate} onCancel={() => setOpen(false)} okText="生成" cancelText="取消">
|
||||
<Form form={form} layout="vertical" initialValues={{ count: 10, max_uses: 1 }}>
|
||||
<Form.Item name="count" label="数量" rules={[{ required: true }]}><InputNumber min={1} max={200} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="max_uses" label="每个邀请码可使用次数" rules={[{ required: true }]}><InputNumber min={1} max={10000} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="expires_at" label="有效期至(可选)"><DatePicker showTime style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="note" label="备注"><Input maxLength={255} placeholder="例如:8 月内测用户" /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<Modal title="邀请码已生成" open={generated.length > 0} onCancel={() => setGenerated([])} footer={<Button type="primary" onClick={copyGenerated}>复制全部</Button>}>
|
||||
<Typography.Paragraph type="warning">完整邀请码只显示这一次,请立即复制保存。</Typography.Paragraph>
|
||||
<Input.TextArea value={generated.join('\n')} autoSize={{ minRows: 5, maxRows: 14 }} readOnly />
|
||||
</Modal>
|
||||
</Space>;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
base: '/admin/',
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
|
||||
Reference in New Issue
Block a user