零 Node 部署、超级管理员,并完善本地构建发布文档。

- FastAPI 单进程托管 frontend/dist,systemd 替代 PM2

- 超级管理员 admin、注册开关与用户管理

- README/DEPLOY/USAGE 说明:改代码须本地构建 dist 后 push,服务器 update.sh

- 提交 frontend/dist 与 build-frontend 脚本
This commit is contained in:
dekun
2026-06-28 13:19:41 +08:00
parent a3d4875bde
commit f1ad4273f4
34 changed files with 1567 additions and 268 deletions
+9
View File
@@ -1,6 +1,7 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { useAuth } from './context/AuthContext'
import LoginPage from './pages/LoginPage'
import SettingsPage from './pages/SettingsPage'
import StudentDetailPage from './pages/StudentDetailPage'
import StudentsPage from './pages/StudentsPage'
@@ -31,6 +32,14 @@ export default function App() {
</PrivateRoute>
}
/>
<Route
path="/settings"
element={
<PrivateRoute>
<SettingsPage />
</PrivateRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
+24
View File
@@ -1,10 +1,13 @@
import axios from 'axios'
import type {
AdminUser,
Exam,
PublicSettings,
ScoreInput,
SchoolLevel,
Student,
Subject,
SystemSettings,
TokenResponse,
TrendResponse,
User,
@@ -59,6 +62,27 @@ export const authApi = {
me: () => api.get<User>('/auth/me'),
}
export const settingsApi = {
public: () => api.get<PublicSettings>('/settings/public'),
}
export const adminApi = {
getSettings: () => api.get<SystemSettings>('/admin/settings'),
updateSettings: (data: { registration_enabled?: boolean }) =>
api.patch<SystemSettings>('/admin/settings', data),
updateProfile: (data: {
username?: string
current_password?: string
password?: string
}) => api.patch<AdminUser>('/admin/profile', data),
listUsers: () => api.get<AdminUser[]>('/admin/users'),
createUser: (data: { username: string; password: string }) =>
api.post<AdminUser>('/admin/users', data),
resetUserPassword: (id: string, password: string) =>
api.patch<AdminUser>(`/admin/users/${id}`, { password }),
deleteUser: (id: string) => api.delete(`/admin/users/${id}`),
}
export const studentApi = {
list: () => api.get<Student[]>('/students'),
create: (data: {
+76 -57
View File
@@ -1,7 +1,9 @@
import { LockOutlined, UserOutlined } from '@ant-design/icons'
import { Button, Card, Form, Input, Tabs, Typography, message } from 'antd'
import { Button, Card, Form, Input, Spin, Tabs, Typography, message } from 'antd'
import axios from 'axios'
import { useEffect, useState } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { settingsApi } from '../api/client'
import { useAuth } from '../context/AuthContext'
function apiErrorMessage(error: unknown, fallback: string) {
@@ -18,6 +20,16 @@ export default function LoginPage() {
const navigate = useNavigate()
const [loginForm] = Form.useForm()
const [registerForm] = Form.useForm()
const [registrationEnabled, setRegistrationEnabled] = useState(true)
const [settingsLoading, setSettingsLoading] = useState(true)
useEffect(() => {
settingsApi
.public()
.then((res) => setRegistrationEnabled(res.data.registration_enabled))
.catch(() => setRegistrationEnabled(true))
.finally(() => setSettingsLoading(false))
}, [])
if (!loading && user) return <Navigate to="/" replace />
@@ -45,6 +57,61 @@ export default function LoginPage() {
}
}
const tabItems = [
{
key: 'login',
label: '登录',
children: (
<Form form={loginForm} onFinish={onLogin} layout="vertical">
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
),
},
]
if (registrationEnabled) {
tabItems.push({
key: 'register',
label: '注册',
children: (
<Form form={registerForm} onFinish={onRegister} layout="vertical">
<Form.Item
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, message: '至少3个字符' },
]}
>
<Input prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '至少6个字符' },
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item name="confirm" rules={[{ required: true, message: '请确认密码' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="确认密码" />
</Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
),
})
}
return (
<div
style={{
@@ -60,62 +127,14 @@ export default function LoginPage() {
<Typography.Paragraph type="secondary" style={{ textAlign: 'center' }}>
· ·
</Typography.Paragraph>
<Tabs
items={[
{
key: 'login',
label: '登录',
children: (
<Form form={loginForm} onFinish={onLogin} layout="vertical">
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
),
},
{
key: 'register',
label: '注册',
children: (
<Form form={registerForm} onFinish={onRegister} layout="vertical">
<Form.Item
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, message: '至少3个字符' },
]}
>
<Input prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '至少6个字符' },
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item
name="confirm"
rules={[{ required: true, message: '请确认密码' }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="确认密码" />
</Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
),
},
]}
/>
<Spin spinning={settingsLoading}>
{!settingsLoading && !registrationEnabled && (
<Typography.Paragraph type="secondary" style={{ textAlign: 'center', marginBottom: 16 }}>
</Typography.Paragraph>
)}
<Tabs items={tabItems} />
</Spin>
<Typography.Paragraph
type="secondary"
style={{ textAlign: 'center', fontSize: 12, marginTop: 16, marginBottom: 0 }}
+247
View File
@@ -0,0 +1,247 @@
import { LockOutlined, SettingOutlined, UserOutlined } from '@ant-design/icons'
import {
Button,
Card,
Form,
Input,
Modal,
Popconfirm,
Space,
Switch,
Table,
Tabs,
Typography,
message,
} from 'antd'
import { useEffect, useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { adminApi } from '../api/client'
import { useAuth } from '../context/AuthContext'
import type { AdminUser, SystemSettings } from '../types'
export default function SettingsPage() {
const { user } = useAuth()
const [settings, setSettings] = useState<SystemSettings | null>(null)
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [profileForm] = Form.useForm()
const [createForm] = Form.useForm()
const [createOpen, setCreateOpen] = useState(false)
const [resetUser, setResetUser] = useState<AdminUser | null>(null)
const [resetForm] = Form.useForm()
if (!user?.is_superuser) return <Navigate to="/" replace />
const load = async () => {
setLoading(true)
try {
const [settingsRes, usersRes] = await Promise.all([
adminApi.getSettings(),
adminApi.listUsers(),
])
setSettings(settingsRes.data)
setUsers(usersRes.data)
profileForm.setFieldsValue({ username: user.username })
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
const toggleRegistration = async (checked: boolean) => {
const { data } = await adminApi.updateSettings({ registration_enabled: checked })
setSettings(data)
message.success(checked ? '已开放注册' : '已关闭注册')
}
const saveProfile = async (values: {
username: string
current_password?: string
password?: string
confirm?: string
}) => {
if (values.password && values.password !== values.confirm) {
message.error('两次密码不一致')
return
}
await adminApi.updateProfile({
username: values.username !== user?.username ? values.username : undefined,
current_password: values.password ? values.current_password : undefined,
password: values.password || undefined,
})
message.success('账号信息已更新,若修改了用户名或密码请重新登录')
}
const createUser = async (values: { username: string; password: string }) => {
await adminApi.createUser(values)
message.success('用户已创建')
setCreateOpen(false)
createForm.resetFields()
load()
}
const resetPassword = async (values: { password: string }) => {
if (!resetUser) return
await adminApi.resetUserPassword(resetUser.id, values.password)
message.success('密码已重置')
setResetUser(null)
resetForm.resetFields()
}
const removeUser = async (id: string) => {
await adminApi.deleteUser(id)
message.success('用户已删除')
load()
}
return (
<div style={{ maxWidth: 960, margin: '0 auto', padding: '16px 16px 32px' }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Title level={3} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
· <Link to="/"></Link>
</Typography.Text>
</div>
<Tabs
items={[
{
key: 'general',
label: '基本设置',
children: (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Card title="注册开关" loading={loading}>
<Space>
<Switch
checked={settings?.registration_enabled ?? true}
onChange={toggleRegistration}
/>
<Typography.Text>
{settings?.registration_enabled
? '开放注册:用户可在登录页自行注册'
: '关闭注册:仅超级管理员可添加用户'}
</Typography.Text>
</Space>
</Card>
<Card title="管理员账号">
<Form form={profileForm} layout="vertical" onFinish={saveProfile}>
<Form.Item
name="username"
label="用户名"
rules={[{ required: true, min: 3 }]}
>
<Input prefix={<UserOutlined />} />
</Form.Item>
<Form.Item name="current_password" label="当前密码(修改密码时必填)">
<Input.Password prefix={<LockOutlined />} />
</Form.Item>
<Form.Item name="password" label="新密码(留空则不修改)">
<Input.Password prefix={<LockOutlined />} />
</Form.Item>
<Form.Item name="confirm" label="确认新密码">
<Input.Password prefix={<LockOutlined />} />
</Form.Item>
<Button type="primary" htmlType="submit" icon={<SettingOutlined />}>
</Button>
</Form>
</Card>
</Space>
),
},
{
key: 'users',
label: '用户管理',
children: (
<Card
title="用户列表"
extra={
<Button type="primary" onClick={() => setCreateOpen(true)}>
</Button>
}
>
<Table
rowKey="id"
loading={loading}
dataSource={users}
pagination={false}
columns={[
{ title: '用户名', dataIndex: 'username' },
{
title: '角色',
dataIndex: 'is_superuser',
render: (v: boolean) => (v ? '超级管理员' : '普通用户'),
},
{
title: '创建时间',
dataIndex: 'created_at',
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: '操作',
render: (_, record) =>
record.is_superuser ? (
<Typography.Text type="secondary"></Typography.Text>
) : (
<Space>
<Button size="small" onClick={() => setResetUser(record)}>
</Button>
<Popconfirm title="确定删除该用户?" onConfirm={() => removeUser(record.id)}>
<Button size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
]}
/>
</Card>
),
},
]}
/>
</Space>
<Modal
title="添加用户"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => createForm.submit()}
destroyOnHidden
>
<Form form={createForm} layout="vertical" onFinish={createUser}>
<Form.Item name="username" label="用户名" rules={[{ required: true, min: 3 }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, min: 6 }]}>
<Input.Password />
</Form.Item>
</Form>
</Modal>
<Modal
title={`重置密码 — ${resetUser?.username}`}
open={!!resetUser}
onCancel={() => setResetUser(null)}
onOk={() => resetForm.submit()}
destroyOnHidden
>
<Form form={resetForm} layout="vertical" onFinish={resetPassword}>
<Form.Item name="password" label="新密码" rules={[{ required: true, min: 6 }]}>
<Input.Password />
</Form.Item>
</Form>
</Modal>
</div>
)
}
+6 -1
View File
@@ -1,4 +1,4 @@
import { LogoutOutlined, PlusOutlined, UserOutlined } from '@ant-design/icons'
import { LogoutOutlined, PlusOutlined, SettingOutlined, UserOutlined } from '@ant-design/icons'
import { Button, Card, Col, Form, Input, Modal, Row, Select, Space, Spin, Tag, Typography, message } from 'antd'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
@@ -62,6 +62,11 @@ export default function StudentsPage() {
<Typography.Text type="secondary">{user?.username}</Typography.Text>
</div>
<Space wrap>
{user?.is_superuser && (
<Link to="/settings">
<Button icon={<SettingOutlined />}></Button>
</Link>
)}
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
+17
View File
@@ -7,6 +7,23 @@ export interface TokenResponse {
export interface User {
id: string
username: string
is_superuser: boolean
created_at: string
}
export interface PublicSettings {
registration_enabled: boolean
}
export interface SystemSettings {
registration_enabled: boolean
updated_at: string
}
export interface AdminUser {
id: string
username: string
is_superuser: boolean
created_at: string
}