feat(web): migrate teaching and enrollment administration

Add React workflows for teaching eligibility, course equivalency rules, enrollment management, and campus wallet administration. Preserve role-aware navigation, college scope, audit trails, and existing server authorization.
This commit is contained in:
biss authored and biss committed 2026-10-03 21:38:14 +08:00
1 parent 1b27029b5a
commit e28123f1c7
9 files changed
+158

No files matched your search

@@ -0,0 +1,32 @@
import { BankOutlined, DownloadOutlined, UploadOutlined } from '@ant-design/icons'
import { Alert, App, Button, Empty, Form, Input, InputNumber, Modal, Pagination, Select, Skeleton, Table } from 'antd'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useMemo, useRef, useState } from 'react'
import { downloadApiFile, importExcel } from '../api/files'
import http, { apiErrorMessage } from '../api/http'
import PageHeader from '../components/PageHeader'
import './admin-ledgers.css'
interface WalletStudent { id: string; studentNumber: string; name: string; collegeName: string; majorName: string; className: string; balance: number; lastChangedAt?: string }
interface Option { id: string; name: string }
interface Major extends Option { collegeId: string }
interface ClassOption extends Option { grade: number; majorId: string }
interface Options { colleges: Option[]; majors: Major[]; classes: ClassOption[] }
interface Paged<T> { items: T[]; total: number }
interface Filters { grade?: number; collegeId?: string; majorId?: string; administrativeClassId?: string }
export default function CampusWalletManagementPage() {
const { message } = App.useApp(); const client = useQueryClient(); const fileRef = useRef<HTMLInputElement>(null); const [page, setPage] = useState(1); const [keyword, setKeyword] = useState(''); const [search, setSearch] = useState(''); const [filters, setFilters] = useState<Filters>({}); const [adjusting, setAdjusting] = useState<WalletStudent>(); const [form] = Form.useForm<{ amount: number; reason: string }>()
const options = useQuery({ queryKey: ['wallet-filter-options'], queryFn: async () => (await http.get<Options>('/campus-registrations/wallets/filter-options')).data })
const wallets = useQuery({ queryKey: ['student-wallets', page, search, filters], enabled: options.isSuccess, queryFn: async () => (await http.get<Paged<WalletStudent>>('/campus-registrations/wallets/students', { params: { keyword: search || undefined, ...filters, page, pageSize: 20 } })).data })
const majors = useMemo(() => options.data?.majors.filter((item) => !filters.collegeId || item.collegeId === filters.collegeId) ?? [], [filters.collegeId, options.data])
const classes = useMemo(() => options.data?.classes.filter((item) => (!filters.majorId || item.majorId === filters.majorId) && (!filters.grade || item.grade === filters.grade)) ?? [], [filters.grade, filters.majorId, options.data])
const grades = useMemo(() => [...new Set((options.data?.classes ?? []).map((item) => item.grade))].sort((a, b) => b - a), [options.data])
const refresh = () => client.invalidateQueries({ queryKey: ['student-wallets'] })
async function adjust(values: { amount: number; reason: string }) { if (!adjusting) return; try { await http.post(`/campus-registrations/wallets/students/${adjusting.id}/adjust`, values); message.success('余额已调整'); setAdjusting(undefined); form.resetFields(); await refresh() } catch (error) { message.error(apiErrorMessage(error)) } }
async function importFile(file?: File) { if (!file) return; try { const { data } = await importExcel('/campus-registrations/wallets/import-adjustments', file); message.success(`已调整 ${data.adjusted} 名学生`); await refresh() } catch (error) { message.error(apiErrorMessage(error)) } finally { if (fileRef.current) fileRef.current.value = '' } }
async function download(path: string, name: string) { try { await downloadApiFile(path, name) } catch (error) { message.error(apiErrorMessage(error)) } }
if (options.isLoading) return <main className="page-container"><Skeleton active paragraph={{ rows: 9 }} /></main>
if (options.isError) return <main className="page-container"><Alert type="error" showIcon message="余额管理无法加载" description={apiErrorMessage(options.error)} /></main>
const optionData = options.data!
return <main className="page-container wallet-management"><PageHeader kicker="FINANCIAL LEDGER" title="学生余额管理" description="在独立资金台账中查询、调整和批量导入学生校园余额。" aside={<div className="ledger-seal"><BankOutlined /><strong>{wallets.data?.total ?? 0}</strong><span>名范围内学生</span></div>} /><section className="ledger-card"><div className="wallet-toolbar"><Input allowClear value={keyword} placeholder="按学号或姓名搜索" onChange={(event) => setKeyword(event.target.value)} onPressEnter={() => { setSearch(keyword.trim()); setPage(1) }} /><Select allowClear value={filters.grade} placeholder="年级" options={grades.map((value) => ({ value, label: `${value} 级` }))} onChange={(value) => { setFilters((item) => ({ ...item, grade: value, administrativeClassId: undefined })); setPage(1) }} /><Select allowClear value={filters.collegeId} placeholder="学院" options={optionData.colleges.map((item) => ({ value: item.id, label: item.name }))} onChange={(value) => { setFilters((item) => ({ ...item, collegeId: value, majorId: undefined, administrativeClassId: undefined })); setPage(1) }} /><Select allowClear value={filters.majorId} placeholder="专业" options={majors.map((item) => ({ value: item.id, label: item.name }))} onChange={(value) => { setFilters((item) => ({ ...item, majorId: value, administrativeClassId: undefined })); setPage(1) }} /><Select allowClear value={filters.administrativeClassId} placeholder="行政班" options={classes.map((item) => ({ value: item.id, label: `${item.grade}级 ${item.name}` }))} onChange={(value) => { setFilters((item) => ({ ...item, administrativeClassId: value })); setPage(1) }} /><Button type="primary" onClick={() => { setSearch(keyword.trim()); setPage(1) }}>查询</Button><Button onClick={() => void download('/campus-registrations/wallets/adjustment-template.xlsx', '学生余额批量调整模板.xlsx')}>下载模板</Button><input ref={fileRef} hidden type="file" accept=".xlsx" onChange={(event) => void importFile(event.target.files?.[0])} /><Button icon={<UploadOutlined />} onClick={() => fileRef.current?.click()}>批量调整</Button><Button icon={<DownloadOutlined />} onClick={() => void download('/campus-registrations/wallets/export.xlsx', '学生余额.xlsx')}>导出余额</Button></div><Alert type="info" showIcon message="批量模板必须填写学号、调整金额和原因;正数充值,负数扣减,每次操作都会永久保留流水。" />{wallets.isError ? <Alert type="error" showIcon message="学生余额无法加载" description={apiErrorMessage(wallets.error)} /> : <Table<WalletStudent> rowKey="id" loading={wallets.isLoading} pagination={false} dataSource={wallets.data?.items} locale={{ emptyText: <Empty description="没有符合条件的学生" /> }} columns={[{ title: '学生', render: (_, item) => <div className="ledger-primary"><strong>{item.studentNumber} · {item.name}</strong><span>{item.collegeName} · {item.majorName} · {item.className}</span></div> }, { title: '当前余额', dataIndex: 'balance', width: 150, render: (value: number) => <strong className="wallet-balance">¥{value.toFixed(2)}</strong> }, { title: '最后变动', dataIndex: 'lastChangedAt', width: 190, render: (value?: string) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '从未调整' }, { title: '操作', width: 110, fixed: 'right', render: (_, item) => <Button type="link" onClick={() => { setAdjusting(item); form.resetFields() }}>调整余额</Button> }]} />}{(wallets.data?.total ?? 0) > 20 && <Pagination current={page} pageSize={20} total={wallets.data?.total} showSizeChanger={false} onChange={setPage} />}</section><Modal open={!!adjusting} title={`调整余额 · ${adjusting?.studentNumber} ${adjusting?.name}`} okText="确认调整" onCancel={() => setAdjusting(undefined)} onOk={() => form.submit()}><Alert type="warning" showIcon message={`当前余额 ¥${adjusting?.balance.toFixed(2) ?? '0.00'};调整后余额不能小于零。`} /><Form form={form} layout="vertical" className="review-form" onFinish={(values) => void adjust(values)}><Form.Item name="amount" label="调整金额" rules={[{ required: true, message: '请输入非零调整金额' }]}><InputNumber precision={2} className="full-width" placeholder="正数充值,负数扣减" /></Form.Item><Form.Item name="reason" label="调整原因" rules={[{ required: true, whitespace: true, message: '必须填写调整原因' }]}><Input.TextArea rows={3} maxLength={300} showCount /></Form.Item></Form></Modal></main>
}