feat: DingTalk attendance import + integration config + expense types + UI polish

Server:
- Add DingTalk attendance import service with SSE progress streaming
- Add IntegrationConfig entity & module for multi-tenant DingTalk setup
- Add ExpenseType entity & ExpenseTypesModule
- Add SeedModule for DB initialization
- Add UserDingMapping entity for DingTalk user linkage
- Attendance service: import flow with dedup & student auto-mapping
- Rooms service: time-range overlap queries
- Sync controller/service: DingTalk integration wiring
- Permission guard: refactor to pure re-export
- Campus scope middleware: tenant-aware filtering

Admin UI:
- Attendance page: import UI with progress & result summary
- All pages: tableStyle/tablePagination standardization
- Login page: responsive styling
- Sensitive data: useViewSensitive hook for masked viewing
- Vite config: path aliases, build optimization
- Test infra: vitest config, test utilities

Docs: PRD DingTalk batch 1 & 2 design docs
This commit is contained in:
2026-07-09 09:11:56 +08:00
parent f1959f0d2a
commit 42d3f0e27f
71 changed files with 5331 additions and 609 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import {
Tabs,
Card,
@@ -20,6 +20,7 @@ import {
Row,
Col,
Statistic,
Spin,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
@@ -33,6 +34,8 @@ import {
} from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
import { useViewSensitive } from '../../hooks/useViewSensitive';
// ---- Types ----
@@ -752,16 +755,87 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
}
}, [studentId]);
if (!aggregateData) return null;
const handleViewSensitive = useViewSensitive(studentId, '学生档案');
const { student, profile, enrollments, examScores, learningRecords, result, attachments } = aggregateData;
const tabItems = useMemo(() => {
if (!aggregateData) return [];
const { profile, enrollments, examScores, learningRecords, result, attachments } = aggregateData;
return [
{
key: 'profile',
label: '扩展档案',
children: <ProfileTab data={profile} studentId={studentId} onRefresh={fetchData} />,
},
{
key: 'enrollments',
label: `报读班型 (${enrollments.length})`,
children: (
<EnrollmentsTab data={enrollments} studentId={studentId} onRefresh={fetchData} />
),
},
{
key: 'exams',
label: `考试成绩 (${examScores.length})`,
children: (
<ExamScoresTab
data={examScores}
studentId={studentId}
enrollments={enrollments}
onRefresh={fetchData}
/>
),
},
{
key: 'attendance',
label: '出勤记录',
children: <Empty description="暂无出勤记录" />,
},
{
key: 'learning',
label: `课堂回访 (${learningRecords.length})`,
children: (
<LearningTab data={learningRecords} studentId={studentId} onRefresh={fetchData} />
),
},
{
key: 'result',
label: '录取归档',
children: <ResultTab data={result} studentId={studentId} onRefresh={fetchData} />,
},
{
key: 'attachments',
label: `附件 (${attachments.length})`,
children: (
<AttachmentsTab data={attachments} studentId={studentId} onRefresh={fetchData} />
),
},
{
key: 'reports',
label: '报告版本',
children: <Empty description="暂无报告版本" />,
},
];
}, [aggregateData, studentId, fetchData]);
if (!aggregateData) {
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
return null;
}
const { student, profile } = aggregateData;
return (
<div>
{inDrawer && (
<Row justify="space-between" align="middle" style={{ marginBottom: 24 }}>
<Space>
<Button type="text" icon={<CloseOutlined />} onClick={onClose} />
<Button type="text" icon={<CloseOutlined />} onClick={onClose} aria-label="关闭档案" />
<span style={{ fontSize: 16, fontWeight: 500 }}>
- {student.name} ({student.studentNo})
</span>
@@ -794,8 +868,26 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
<Descriptions bordered column={3} size="small" style={{ marginBottom: 24 }}>
<Descriptions.Item label="学号">{student.studentNo || '-'}</Descriptions.Item>
<Descriptions.Item label="电话">{student.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="身份证号">{student.idNumber || '-'}</Descriptions.Item>
<Descriptions.Item label="电话">
{student.phone ? (
<span>
<span style={{ marginRight: 8 }}>{maskPhone(student.phone)}</span>
<a onClick={() => handleViewSensitive('电话', student.phone)}>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
</span>
) : '-'}
</Descriptions.Item>
<Descriptions.Item label="身份证号">
{student.idNumber ? (
<span>
<span style={{ marginRight: 8 }}>{maskIdNumber(student.idNumber)}</span>
<a onClick={() => handleViewSensitive('身份证号', student.idNumber)}>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
</span>
) : '-'}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag>{student.status || '-'}</Tag>
</Descriptions.Item>
@@ -818,61 +910,7 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
<Tabs
defaultActiveKey="profile"
items={[
{
key: 'profile',
label: '扩展档案',
children: <ProfileTab data={profile} studentId={studentId} onRefresh={fetchData} />,
},
{
key: 'enrollments',
label: `报读班型 (${enrollments.length})`,
children: (
<EnrollmentsTab data={enrollments} studentId={studentId} onRefresh={fetchData} />
),
},
{
key: 'exams',
label: `考试成绩 (${examScores.length})`,
children: (
<ExamScoresTab
data={examScores}
studentId={studentId}
enrollments={enrollments}
onRefresh={fetchData}
/>
),
},
{
key: 'attendance',
label: '出勤记录',
children: <Empty description="暂无出勤记录" />,
},
{
key: 'learning',
label: `课堂回访 (${learningRecords.length})`,
children: (
<LearningTab data={learningRecords} studentId={studentId} onRefresh={fetchData} />
),
},
{
key: 'result',
label: '录取归档',
children: <ResultTab data={result} studentId={studentId} onRefresh={fetchData} />,
},
{
key: 'attachments',
label: `附件 (${attachments.length})`,
children: (
<AttachmentsTab data={attachments} studentId={studentId} onRefresh={fetchData} />
),
},
{
key: 'reports',
label: '报告版本',
children: <Empty description="暂无报告版本" />,
},
]}
items={tabItems}
/>
</div>
);

View File

@@ -0,0 +1,43 @@
import { useCallback } from 'react';
import { Modal, message } from 'antd';
import api from '../api';
/**
* Shared hook for viewing sensitive student info (phone / ID number).
* Logs an audit entry before revealing the unmasked value.
*
* @param studentId - The student whose data is being viewed
* @param module - Audit module label (e.g. '学生管理', '学生档案')
*/
export function useViewSensitive(studentId: number, module: string) {
return useCallback(
(field: string, value: string) => {
Modal.confirm({
title: '查看敏感信息',
content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`,
okText: '确认查看',
cancelText: '取消',
onOk: async () => {
try {
await api.post('/operation-logs/audit', {
module,
action: '查看敏感信息',
targetId: studentId,
targetType: 'student',
detail: `查看${field}`,
});
} catch {
message.error('操作日志记录失败,请稍后重试');
return;
}
Modal.info({
title: field,
content: value,
okText: '关闭',
});
},
});
},
[studentId, module],
);
}

View File

@@ -15,9 +15,41 @@ body {
min-height: 100vh;
}
/* === 通用:表格容器横向滚动 === */
/* === 通用:表格容器横向滚动(防双重滚动条) === */
.ant-table-wrapper {
overflow-x: auto;
overflow-y: hidden;
}
/* ── 表格单元格省略号截断(按需启用) ──
在 .ant-table-wrapper 上添加 .table-cell-ellipsis 类即可生效:
<Table className="table-cell-ellipsis" ... /> */
.table-cell-ellipsis .ant-table-thead > tr > th,
.table-cell-ellipsis .ant-table-tbody > tr > td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 操作列按钮不换行 */
.ant-table-wrapper .ant-table-cell .ant-space {
flex-wrap: nowrap;
}
/* ── 宿舍总览卡片Tag 溢出截断 ── */
.room-card-tag-wrapper {
overflow: hidden;
}
.room-card-tag-wrapper .ant-tag {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-flex;
align-items: center;
}
.room-card-tag-wrapper .ant-tag .anticon {
flex-shrink: 0;
}
/* === 手机 (< 576px) === */

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Layout, Menu, Button, Avatar, Dropdown, Drawer, Grid } from 'antd';
import {
@@ -123,7 +123,7 @@ const MainLayout: React.FC = () => {
const [drawerOpen, setDrawerOpen] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const user = JSON.parse(localStorage.getItem('user') || '{}');
const user = useMemo(() => JSON.parse(localStorage.getItem('user') || '{}'), []);
const { hasPermission } = usePermission();
const screens = Grid.useBreakpoint();
@@ -146,19 +146,19 @@ const MainLayout: React.FC = () => {
.filter(Boolean) as MenuItemType[];
};
const menuItems = filterByPermission(allMenuItems);
const menuItems = useMemo(() => filterByPermission(allMenuItems), [hasPermission]);
const handleLogout = () => {
const handleLogout = useCallback(() => {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('permissions');
navigate('/login');
};
}, [navigate]);
const handleMenuClick = (key: string) => {
const handleMenuClick = useCallback((key: string) => {
navigate(key);
if (isMobile) setDrawerOpen(false);
};
}, [navigate, isMobile]);
const findSelectedKeys = (items: MenuItemType[], pathname: string): string[] => {
for (const item of items) {
@@ -182,8 +182,8 @@ const MainLayout: React.FC = () => {
return [];
};
const selectedKeys = findSelectedKeys(menuItems, location.pathname);
const openKeys = findOpenKeys(menuItems, location.pathname);
const selectedKeys = useMemo(() => findSelectedKeys(menuItems, location.pathname), [menuItems, location.pathname]);
const openKeys = useMemo(() => findOpenKeys(menuItems, location.pathname), [menuItems, location.pathname]);
const transformToMenuItems = (items: MenuItemType[]): any[] => {
@@ -194,7 +194,7 @@ const MainLayout: React.FC = () => {
children: item.children ? transformToMenuItems(item.children) : undefined,
}));
};
const menuContent = (
const menuContent = useMemo(() => (
<Menu
theme="light"
mode="inline"
@@ -204,7 +204,7 @@ const MainLayout: React.FC = () => {
onClick={({ key }) => handleMenuClick(key)}
style={{ border: 'none' }}
/>
);
), [selectedKeys, openKeys, menuItems, handleMenuClick]);
return (
<Layout style={{ minHeight: '100vh' }}>
@@ -259,6 +259,7 @@ const MainLayout: React.FC = () => {
>
<Button
type="text"
aria-label={isMobile || isTablet ? '打开菜单' : collapsed ? '展开侧边栏' : '收起侧边栏'}
icon={
isMobile || isTablet ? (
<MenuUnfoldOutlined />
@@ -285,7 +286,12 @@ const MainLayout: React.FC = () => {
],
}}
>
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}>
<div
role="button"
tabIndex={0}
aria-label="用户菜单"
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}
>
<Avatar icon={<UserOutlined />} />
{isDesktop && <span>{user.name || user.username || '用户'}</span>}
</div>
@@ -298,7 +304,6 @@ const MainLayout: React.FC = () => {
padding: isMobile ? 12 : isTablet ? 16 : 24,
background: '#fff',
borderRadius: 12,
overflow: 'auto',
}}
>
<Outlet />

View File

@@ -8,6 +8,7 @@ import {
CalendarOutlined,
UnorderedListOutlined,
ExportOutlined,
CloudDownloadOutlined,
} from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
@@ -140,6 +141,12 @@ const AttendancePage: React.FC = () => {
const [dingPageSize, setDingPageSize] = useState(20);
const [dingTotal, setDingTotal] = useState(0);
const [dingMatchStatus, setDingMatchStatus] = useState<string | undefined>(undefined);
// DingTalk import
const [importModalOpen, setImportModalOpen] = useState(false);
const [importDateRange, setImportDateRange] = useState<[Dayjs, Dayjs] | null>(null);
const [importAutoMatch, setImportAutoMatch] = useState(true);
const [importing, setImporting] = useState(false);
const [importProgressMsg, setImportProgressMsg] = useState('');
// Match modal
const [matchModalOpen, setMatchModalOpen] = useState(false);
@@ -261,11 +268,56 @@ const AttendancePage: React.FC = () => {
}
}, [dingPage, dingPageSize, filterClassId, filterDateRange, dingMatchStatus]);
// ── DingTalk import handler ──
const handleImportDingTalk = useCallback(async () => {
if (!importDateRange?.[0] || !importDateRange?.[1]) {
message.warning('请选择导入日期范围');
return;
}
setImporting(true);
setImportProgressMsg('正在从钉钉拉取考勤数据...');
try {
const result = await api.post<{
success: boolean; imported: number; skipped: number; matched: number; errors: string[]; duration: number;
}>('/attendance-records/import/dingtalk', {
start: importDateRange[0].format('YYYY-MM-DD'),
end: importDateRange[1].format('YYYY-MM-DD'),
autoMatch: importAutoMatch,
});
setImportProgressMsg('');
if (result.success) {
message.success(
`导入完成:${result.imported} 条新增, ${result.skipped} 条跳过, ${result.matched} 条匹配 (${result.duration}ms)`,
);
if (result.errors?.length) {
message.warning(`警告:${result.errors.join('; ')}`);
}
setImportModalOpen(false);
fetchDingRecords();
} else {
message.error(`导入失败:${result.errors.join('; ')}`);
}
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '导入失败');
} finally {
setImporting(false);
}
}, [importDateRange, importAutoMatch, fetchDingRecords]);
// ── Effects ──
useEffect(() => {
fetchClasses();
}, [fetchClasses]);
useEffect(() => { api.get<AlertItem[]>('/attendance-records/alerts').then(setAlerts).catch(() => {}) }, []);
useEffect(() => {
let cancelled = false;
api.get<AlertItem[]>('/attendance-records/alerts')
.then((data) => { if (!cancelled) setAlerts(data); })
.catch(() => {});
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (calendarView) {
@@ -564,12 +616,44 @@ const AttendancePage: React.FC = () => {
return Array.from(dates).sort();
}, [calendarData]);
const calendarColumns = useMemo(() => [
{
title: '学生',
dataIndex: 'studentName',
key: 'studentName',
width: 100,
fixed: 'left' as const,
},
...calendarDates.map((date) => ({
title: (
<div style={{ textAlign: 'center', fontSize: 12 }}>
<div>{dayjs(date).format('MM/DD')}</div>
<div style={{ color: '#999' }}>{dayjs(date).format('ddd')}</div>
</div>
),
key: date,
width: 80,
render: (_: unknown, record: { studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }) => {
const dayRecord = record.days.find((d) => d.date === date);
if (!dayRecord) return <span style={{ color: '#d9d9d9' }}>-</span>;
const statusInfo = STATUS_MAP[dayRecord.status];
return (
<Tooltip title={`${SESSION_MAP[dayRecord.session] || dayRecord.session}: ${statusInfo?.text || dayRecord.status}`}>
<Tag color={statusInfo?.color || 'default'} style={{ margin: 0, cursor: 'pointer' }}>
{statusInfo?.text || dayRecord.status}
</Tag>
</Tooltip>
);
},
})),
], [calendarDates]);
// ── Render ──
return (
<div>
{alerts.length > 0 && (
<Alert type="warning" showIcon closable
message={`考勤预警:${alerts.length} 名学生异常`}
title={`考勤预警:${alerts.length} 名学生异常`}
description={alerts.map(a => `${a.studentName}(${a.className || '-'})${a.type} ${a.count}次,最近${a.lastDate}`).join('')}
style={{ marginBottom: 16 }} />)}
<Tabs
@@ -718,37 +802,7 @@ const AttendancePage: React.FC = () => {
dataSource={calendarData}
pagination={false}
scroll={{ x: Math.max(800, calendarDates.length * 80) }}
columns={[
{
title: '学生',
dataIndex: 'studentName',
key: 'studentName',
width: 100,
fixed: 'left' as const,
},
...calendarDates.map((date) => ({
title: (
<div style={{ textAlign: 'center', fontSize: 12 }}>
<div>{dayjs(date).format('MM/DD')}</div>
<div style={{ color: '#999' }}>{dayjs(date).format('ddd')}</div>
</div>
),
key: date,
width: 80,
render: (_: unknown, record: { studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }) => {
const dayRecord = record.days.find((d) => d.date === date);
if (!dayRecord) return <span style={{ color: '#d9d9d9' }}>-</span>;
const statusInfo = STATUS_MAP[dayRecord.status];
return (
<Tooltip title={`${SESSION_MAP[dayRecord.session] || dayRecord.session}: ${statusInfo?.text || dayRecord.status}`}>
<Tag color={statusInfo?.color || 'default'} style={{ margin: 0, cursor: 'pointer' }}>
{statusInfo?.text || dayRecord.status}
</Tag>
</Tooltip>
);
},
})),
]}
columns={calendarColumns}
/>
)}
</Card>
@@ -803,7 +857,22 @@ const AttendancePage: React.FC = () => {
options={MATCH_STATUS_OPTIONS}
/>
</Col>
<Col>
<Button
type="primary"
icon={<CloudDownloadOutlined />}
loading={importing}
onClick={() => setImportModalOpen(true)}
>
</Button>
</Col>
</Row>
{importing && (
<div style={{ marginTop: 12 }}>
<span style={{ fontSize: 12, color: '#888' }}>{importProgressMsg}</span>
</div>
)}
</Card>
{/* ── DingTalk table ── */}
@@ -925,6 +994,39 @@ const AttendancePage: React.FC = () => {
</Form>
</Modal>
{/* ── DingTalk import modal ── */}
<Modal
title="从钉钉拉取考勤数据"
open={importModalOpen}
onOk={handleImportDingTalk}
onCancel={() => { setImportModalOpen(false); setImportDateRange(null); setImportProgressMsg(''); }}
confirmLoading={importing}
okText="开始拉取"
cancelText="取消"
>
<Form layout="vertical">
<Form.Item label="日期范围" required>
<RangePicker
value={importDateRange}
onChange={(dates) => setImportDateRange(dates as [Dayjs, Dayjs] | null)}
style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']}
/>
</Form.Item>
<Form.Item label="自动匹配">
<Select
value={importAutoMatch ? 'yes' : 'no'}
onChange={(v) => setImportAutoMatch(v === 'yes')}
options={[
{ value: 'yes', label: '是 — 导入后按姓名自动匹配学生' },
{ value: 'no', label: '否 — 仅导入原始数据,稍后手动匹配' },
]}
/>
</Form.Item>
</Form>
</Modal>
{/* ── Edit modal ── */}
<Modal title="编辑考勤" open={editModalOpen} onOk={handleEditSubmit} onCancel={() => setEditModalOpen(false)}>
<Form form={editForm} layout="vertical">

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useMemo } from 'react';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import {
Table,
Modal,
@@ -12,6 +12,7 @@ import {
Input,
Select,
Tooltip,
Spin,
} from 'antd';
import {
FileTextOutlined,
@@ -22,6 +23,7 @@ import {
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { downloadBlob } from '../../utils/download';
const { RangePicker } = DatePicker;
@@ -50,8 +52,10 @@ const BillsPage: React.FC = () => {
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [filterExpenseType, setFilterExpenseType] = useState<string | undefined>(undefined);
const [generateForm] = Form.useForm();
const [saving, setSaving] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const fetchData = async () => {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string | undefined> = {};
@@ -63,11 +67,11 @@ const BillsPage: React.FC = () => {
console.error(e);
}
setLoading(false);
};
}, [filterStatus, filterExpenseType]);
useEffect(() => {
fetchData();
}, [filterStatus, filterExpenseType]);
}, [fetchData]);
const filteredBills = useMemo(() => {
return bills.filter((b: any) => {
@@ -83,6 +87,7 @@ const BillsPage: React.FC = () => {
}, [bills, searchText, filterStatus]);
const handleGenerate = async () => {
setSaving(true);
const values = await generateForm.validateFields();
try {
const res: any = await api.post('/bills/generate', {
@@ -95,15 +100,20 @@ const BillsPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '生成失败');
} finally {
setSaving(false);
}
};
const showDetail = async (id: number) => {
setDetailLoading(true);
try {
const res = await api.get(`/bills/${id}`);
setDetailModal(res);
} catch (e) {
console.error(e);
} finally {
setDetailLoading(false);
}
};
@@ -155,65 +165,40 @@ const BillsPage: React.FC = () => {
};
const handleExportExcel = () => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
const url = `${baseURL}/bills/export/excel`;
const a = document.createElement('a');
// 使用 fetch 来携带 token
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then((res) => res.blob())
.then((blob) => {
const blobUrl = URL.createObjectURL(blob);
a.href = blobUrl;
a.download = `账单导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
a.click();
URL.revokeObjectURL(blobUrl);
message.success('Excel 导出成功');
})
.catch(() => message.error('导出失败'));
downloadBlob('/bills/export/excel', `账单导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`).then(
() => message.success('Excel 导出成功'),
() => message.error('导出失败'),
);
};
const handleExportPdf = (billId: number) => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/bills/export/pdf/${billId}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `账单_${billId}.pdf`;
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('导出失败'));
downloadBlob(`/bills/export/pdf/${billId}`, `账单_${billId}.pdf`).catch(() =>
message.error('导出失败'),
);
};
const columns = [
const columns = useMemo(() => [
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
{ title: '账单周期', width: 200, render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}` },
{
title: '分摊费用',
dataIndex: 'sharedAmount',
width: 120,
align: 'right' as const,
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '个人费用',
dataIndex: 'personalAmount',
width: 120,
align: 'right' as const,
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '总计',
dataIndex: 'totalAmount',
width: 100,
align: 'right' as const,
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
},
{
@@ -308,7 +293,7 @@ const BillsPage: React.FC = () => {
</Space>
),
},
];
], [showDetail, updateStatus, handleDelete, handleExportPdf]);
return (
<div>
@@ -417,6 +402,7 @@ const BillsPage: React.FC = () => {
onOk={handleGenerate}
onCancel={() => setGenerateModal(false)}
okText="生成"
confirmLoading={saving}
>
<Form form={generateForm} layout="vertical">
<Form.Item
@@ -442,7 +428,7 @@ const BillsPage: React.FC = () => {
width={800}
>
{detailModal && (
<>
<Spin spinning={detailLoading}>
<Descriptions bordered size="small" column={2} style={{ marginBottom: 16 }}>
<Descriptions.Item label="学生">{detailModal.student?.name}</Descriptions.Item>
<Descriptions.Item label="状态">
@@ -536,7 +522,7 @@ const BillsPage: React.FC = () => {
},
]}
/>
</>
</Spin>
)}
</Modal>
</div>

View File

@@ -75,6 +75,7 @@ const ClassesPage: React.FC = () => {
const [filterStatus, setFilterStatus] = useState<string>();
const [filterType, setFilterType] = useState<string>();
const [form] = Form.useForm<ClassFormValues>();
const [saving, setSaving] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
@@ -119,6 +120,7 @@ const ClassesPage: React.FC = () => {
};
const handleSubmit = async () => {
setSaving(true);
try {
const values = await form.validateFields();
const payload = {
@@ -137,6 +139,8 @@ const ClassesPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSaving(false);
}
};
@@ -150,7 +154,7 @@ const ClassesPage: React.FC = () => {
}
};
const columns: ColumnsType<ClassItem> = [
const columns: ColumnsType<ClassItem> = useMemo(() => [
{
title: '班级名称', dataIndex: 'name', width: 120,
sorter: (a, b) => a.name.localeCompare(b.name),
@@ -193,7 +197,7 @@ const ClassesPage: React.FC = () => {
</Space>
),
},
];
], []);
return (
<Card>
@@ -239,6 +243,7 @@ const ClassesPage: React.FC = () => {
open={modalOpen}
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
confirmLoading={saving}
width={600}
>
<Form form={form} layout="vertical">

View File

@@ -18,6 +18,7 @@ import {
import { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import { downloadBlob } from '../../utils/download';
import PermissionButton from '../../components/PermissionButton';
const ClassroomRentalsPage: React.FC = () => {
@@ -30,6 +31,7 @@ const ClassroomRentalsPage: React.FC = () => {
const [form] = Form.useForm();
const [filterMonth, setFilterMonth] = useState<Dayjs | null>(null);
const [searchText, setSearchText] = useState('');
const [saving, setSaving] = useState(false);
const filteredData = useMemo(() => {
if (!searchText) return data;
@@ -73,6 +75,7 @@ const ClassroomRentalsPage: React.FC = () => {
const handleSave = async () => {
const values = await form.validateFields();
setSaving(true);
const payload = {
classroomId: values.classroomId,
tenantId: values.tenantId,
@@ -103,6 +106,8 @@ const ClassroomRentalsPage: React.FC = () => {
} else {
message.error(e?.message || '操作失败');
}
} finally {
setSaving(false);
}
};
@@ -116,27 +121,15 @@ const ClassroomRentalsPage: React.FC = () => {
}
};
const handleDownloadContract = (id: number, filename?: string) => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/classroom-rentals/${id}/contract`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => {
if (!res.ok) throw new Error('下载失败');
return res.blob();
})
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || `contract-${id}.pdf`;
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('下载失败(可能文件已丢失)'));
const handleDownloadContract = async (id: number, filename?: string) => {
try {
await downloadBlob(
`/classroom-rentals/${id}/contract`,
filename || `contract-${id}.pdf`,
);
} catch {
message.error('下载失败(可能文件已丢失)');
}
};
const handleDeleteContract = async (id: number) => {
@@ -162,7 +155,7 @@ const ClassroomRentalsPage: React.FC = () => {
setModalOpen(true);
};
const columns = [
const columns = useMemo(() => [
{
title: '教室', width: 120,
dataIndex: 'classroom',
@@ -215,7 +208,7 @@ const ClassroomRentalsPage: React.FC = () => {
</Button>
</Tooltip>
<Popconfirm title="删除合同文件?" onConfirm={() => handleDeleteContract(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
<Button size="small" danger icon={<DeleteOutlined />} aria-label="删除合同文件" />
</Popconfirm>
</Space>
) : (
@@ -268,7 +261,7 @@ const ClassroomRentalsPage: React.FC = () => {
</Space>
),
},
];
], []);
return (
<div>
@@ -330,6 +323,7 @@ const ClassroomRentalsPage: React.FC = () => {
setModalOpen(false);
setEditing(null);
}}
confirmLoading={saving}
okText="保存"
width={600}
>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useMemo } from 'react';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import {
DatePicker,
Card,
@@ -16,6 +16,7 @@ import {
import { CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import { downloadBlob } from '../../utils/download';
interface ScheduleData {
year: number;
@@ -36,7 +37,7 @@ const ClassroomSchedulePage: React.FC = () => {
const [data, setData] = useState<ScheduleData | null>(null);
const [detailModal, setDetailModal] = useState<any>(null);
const fetchData = async () => {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res: any = await api.get('/classroom-rentals/schedule', {
@@ -47,11 +48,11 @@ const ClassroomSchedulePage: React.FC = () => {
console.error(e);
}
setLoading(false);
};
}, [month]);
useEffect(() => {
fetchData();
}, [month]);
}, [fetchData]);
// 按楼栋+楼层分组教室
const groups = useMemo(() => {
@@ -89,23 +90,15 @@ const ClassroomSchedulePage: React.FC = () => {
}
};
const handleDownloadContract = (id: number, filename?: string) => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/classroom-rentals/${id}/contract`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || `contract-${id}.pdf`;
a.click();
URL.revokeObjectURL(url);
});
const handleDownloadContract = async (id: number, filename?: string) => {
try {
await downloadBlob(
`/classroom-rentals/${id}/contract`,
filename || `contract-${id}.pdf`,
);
} catch {
// downloadBlob already shows an error via throw
}
};
return (
@@ -164,8 +157,10 @@ const ClassroomSchedulePage: React.FC = () => {
title="整体占用率"
value={overall.rate}
suffix="%"
valueStyle={{
color: overall.rate > 70 ? '#cf1322' : overall.rate > 40 ? '#fa8c16' : '#3f8600',
styles={{
value: {
color: overall.rate > 70 ? '#cf1322' : overall.rate > 40 ? '#fa8c16' : '#3f8600',
},
}}
/>
</Card>
@@ -202,7 +197,7 @@ const ClassroomSchedulePage: React.FC = () => {
size="small"
title={group.name}
style={{ marginBottom: 12 }}
bodyStyle={{ padding: 0 }}
styles={{ body: { padding: 0 } }}
>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>

View File

@@ -55,6 +55,8 @@ const ClassroomsPage: React.FC = () => {
const [searchText, setSearchText] = useState('');
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [saving, setSaving] = useState(false);
const filteredData = useMemo(() => {
let result = data;
if (searchText) { const s = searchText.toLowerCase(); result = result.filter((d: Record<string, unknown>) => (typeof d.name === 'string' && d.name.toLowerCase().includes(s)) || (typeof d.building === 'string' && d.building.toLowerCase().includes(s))); }
@@ -79,6 +81,7 @@ const ClassroomsPage: React.FC = () => {
const handleSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) {
await api.put(`/classrooms/${editing.id}`, values);
@@ -93,6 +96,8 @@ const ClassroomsPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSaving(false);
}
};
@@ -134,7 +139,7 @@ const ClassroomsPage: React.FC = () => {
.catch(() => message.error('下载失败'));
};
const columns = [
const columns = useMemo(() => [
{
title: '教室名', width: 120,
dataIndex: 'name',
@@ -201,7 +206,7 @@ const ClassroomsPage: React.FC = () => {
</Space>
),
},
];
], []);
return (
<div>
@@ -293,6 +298,7 @@ const ClassroomsPage: React.FC = () => {
setModalOpen(false);
setEditing(null);
}}
confirmLoading={saving}
okText="保存"
>
<Form form={form} layout="vertical">

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Row, Col, Card, Statistic, DatePicker, Spin, Grid } from 'antd';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Row, Col, Card, Statistic, DatePicker, Spin, Grid, message } from 'antd';
import { TeamOutlined, HomeOutlined, CheckCircleOutlined, BankOutlined, PercentageOutlined, UserSwitchOutlined, SolutionOutlined, FileProtectOutlined, ReadOutlined, CalendarOutlined } from '@ant-design/icons';
import ReactECharts from 'echarts-for-react';
import dayjs from 'dayjs';
@@ -62,6 +62,17 @@ interface DashboardStats {
incomeTrend: IncomeTrendRow[];
}
const attendanceLabelMap: Record<string, string> = {
present: '出勤',
absent: '缺勤',
late: '迟到',
early: '早退',
leave: '请假',
};
const SECTION_ROW_STYLE: React.CSSProperties = { marginBottom: 24 };
const MARGIN_BOTTOM_16_STYLE: React.CSSProperties = { marginBottom: 16 };
const DashboardPage: React.FC = () => {
const screens = Grid.useBreakpoint();
const isMobile = !screens.sm;
@@ -77,7 +88,7 @@ const DashboardPage: React.FC = () => {
dayjs().endOf('month').format('YYYY-MM-DD'),
]);
const fetchData = async () => {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [s, rr, cr, g] = await Promise.all([
@@ -100,13 +111,14 @@ const DashboardPage: React.FC = () => {
setClassroomUtil(cu);
} catch (e) {
console.error(e);
message.error('数据加载失败,请稍后重试');
}
setLoading(false);
};
}, [period]);
useEffect(() => {
fetchData();
}, [period]);
}, [fetchData, period]);
const [expenseTypeMap, setExpenseTypeMap] = useState<Record<string, string>>({});
@@ -118,16 +130,8 @@ const DashboardPage: React.FC = () => {
}).catch(() => {});
}, []);
const attendanceLabelMap: Record<string, string> = {
present: '出勤',
absent: '缺勤',
late: '迟到',
early: '早退',
leave: '请假',
};
// 今日出勤状态分布环图
const attendanceRingOption = {
const attendanceRingOption = useMemo(() => ({
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [
@@ -143,11 +147,11 @@ const DashboardPage: React.FC = () => {
},
],
color: COLORS,
};
}), [stats?.attendanceByStatus]);
// 宿舍费用排行
const barOption = {
const barOption = useMemo(() => ({
tooltip: {},
grid: { left: 80, right: 20, bottom: 30, top: 10 },
xAxis: { type: 'value' },
@@ -163,7 +167,7 @@ const DashboardPage: React.FC = () => {
itemStyle: { color: '#007AFF', borderRadius: [0, 4, 4, 0] },
},
],
};
}), [roomRanking]);
// 班级考勤排行 - 前5
const classRankingTopOption = {
@@ -249,7 +253,7 @@ const DashboardPage: React.FC = () => {
};
// 入住时间线(甘特图)
const ganttOption = {
const ganttOption = useMemo(() => ({
tooltip: {
formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) =>
`${p.data.name}<br/>入住: ${p.data.value[1]}<br/>退宿: ${p.data.value[2]}`,
@@ -300,7 +304,7 @@ const DashboardPage: React.FC = () => {
}))
),
}],
};
}), [ganttData]);
if (loading && !stats)
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
@@ -319,6 +323,7 @@ const DashboardPage: React.FC = () => {
>
<h2 style={{ margin: 0 }}></h2>
<RangePicker
aria-label="选择日期范围"
value={[dayjs(period[0]), dayjs(period[1])]}
onChange={(dates) => {
if (dates) setPeriod([dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')]);
@@ -326,7 +331,7 @@ const DashboardPage: React.FC = () => {
/>
</div>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic title="宿舍总数" value={stats?.totalRooms || 0} prefix={<HomeOutlined />} />
@@ -363,7 +368,7 @@ const DashboardPage: React.FC = () => {
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic title="教室总数" value={stats?.classroomCount || 0} prefix={<BankOutlined />} />
@@ -401,7 +406,7 @@ const DashboardPage: React.FC = () => {
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic title="班级总数" value={stats?.classCount ?? 0} prefix={<TeamOutlined />} />
@@ -439,7 +444,7 @@ const DashboardPage: React.FC = () => {
</Col>
</Row>
<Card title="教室利用率" style={{ marginBottom: 16 }}>
<Card title="教室利用率" style={MARGIN_BOTTOM_16_STYLE}>
<Row gutter={[24, 16]}>
<Col xs={12} sm={6}>
<Statistic title="教室总数" value={classroomUtil?.totalClassrooms ?? '-'} prefix={<ReadOutlined />} />
@@ -453,7 +458,7 @@ const DashboardPage: React.FC = () => {
value={classroomUtil?.utilizationRate ?? '-'}
suffix="%"
prefix={<PercentageOutlined />}
valueStyle={{ color: Number(classroomUtil?.utilizationRate ?? 0) > 70 ? '#34C759' : '#FF9500' }}
styles={{ value: { color: Number(classroomUtil?.utilizationRate ?? 0) > 70 ? '#34C759' : '#FF9500' } }}
/>
</Col>
<Col xs={12} sm={6}>
@@ -462,7 +467,7 @@ const DashboardPage: React.FC = () => {
</Row>
</Card>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={24} sm={12}>
<Card title="考勤趋势近30天">
{(stats?.attendanceTrend || []).length > 0 ? (
@@ -483,7 +488,7 @@ const DashboardPage: React.FC = () => {
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={24} sm={12}>
<Card title="班级出勤率 TOP 5">
{classRanking.top.length > 0 ? (
@@ -504,7 +509,7 @@ const DashboardPage: React.FC = () => {
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={24}>
<Card title="教室占用热力图">
{classroomOccupancy.length > 0 ? (
@@ -533,7 +538,7 @@ const DashboardPage: React.FC = () => {
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={24} sm={12}>
<Card title="费用类型分布">
{((stats?.expenseByType) ?? []).length > 0 ? (

View File

@@ -82,6 +82,7 @@ const DepartmentsPage: React.FC = () => {
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<DepartmentItem | null>(null);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const fetchTree = useCallback(async () => {
setTreeLoading(true);
@@ -160,6 +161,7 @@ const DepartmentsPage: React.FC = () => {
}, [selectedDept, form]);
const handleSubmit = useCallback(async () => {
setSaving(true);
try {
const values = await form.validateFields();
const payload = {
@@ -180,8 +182,18 @@ const DepartmentsPage: React.FC = () => {
if (editing && selectedId) {
fetchDetail(editing.id);
}
} catch {
// form validation error: do nothing
} catch (err: unknown) {
if (
err &&
typeof err === 'object' &&
'message' in err &&
typeof (err as { message: string }).message === 'string'
) {
message.error((err as { message: string }).message);
}
// form validation error falls through silently
} finally {
setSaving(false);
}
}, [editing, fetchTree, fetchDetail, selectedId, form]);
@@ -367,6 +379,7 @@ const DepartmentsPage: React.FC = () => {
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
destroyOnHidden
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item

View File

@@ -18,6 +18,7 @@ import {
import { PlusOutlined, DeleteOutlined, DollarOutlined, CheckOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
import PermissionButton from '../../components/PermissionButton';
const statusMap: Record<string, { text: string; color: string }> = {
@@ -66,6 +67,7 @@ const DepositsPage: React.FC = () => {
const [searchText, setSearchText] = useState('');
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [activeTab, setActiveTab] = useState<string>('all');
const [saving, setSaving] = useState(false);
const fetchData = async () => {
setLoading(true);
@@ -105,7 +107,19 @@ const DepositsPage: React.FC = () => {
});
}, [data, searchText, filterStatus]);
const studentOptions = useMemo(
() =>
students
.filter((s: any) => s.status === 'active')
.map((s: any) => ({
value: s.id,
label: `${s.name} (${s.idNumber ? maskIdNumber(s.idNumber) : (s.phone ? maskPhone(s.phone) : '')})`,
})),
[students],
);
const handleCreate = async () => {
setSaving(true);
const values = await createForm.validateFields();
try {
await api.post('/deposits', {
@@ -120,10 +134,13 @@ const DepositsPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSaving(false);
}
};
const handleRefund = async () => {
setSaving(true);
const values = await refundForm.validateFields();
try {
await api.put(`/deposits/${refundModal.id}/refund`, {
@@ -138,6 +155,8 @@ const DepositsPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSaving(false);
}
};
@@ -165,6 +184,7 @@ const DepositsPage: React.FC = () => {
const handleRejectRefund = async () => {
if (!rejectModal) return;
setSaving(true);
try {
await api.put(`/deposits/${rejectModal.id}/reject-refund`, { reason: rejectReason || '未说明原因' });
message.success('已驳回退款申请');
@@ -174,6 +194,8 @@ const DepositsPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '驳回失败');
} finally {
setSaving(false);
}
};
const handleAddInstallment = async () => {
@@ -216,7 +238,7 @@ const DepositsPage: React.FC = () => {
}
};
const columns = [
const columns = useMemo(() => [
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
{ title: '押金金额', dataIndex: 'amount', width: 110, render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '缴纳日期', dataIndex: 'paidDate', width: 110 },
@@ -304,7 +326,7 @@ const DepositsPage: React.FC = () => {
</Space>
),
},
];
], [handleRequestRefund, fetchData]);
const pendingColumns = [
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
@@ -444,6 +466,7 @@ const DepositsPage: React.FC = () => {
onOk={handleCreate}
onCancel={() => setCreateModal(false)}
okText="确认"
confirmLoading={saving}
>
<Form form={createForm} layout="vertical">
<Form.Item
@@ -455,12 +478,7 @@ const DepositsPage: React.FC = () => {
showSearch
optionFilterProp="label"
placeholder="搜索并选择学生"
options={students
.filter((s: any) => s.status === 'active')
.map((s: any) => ({
value: s.id,
label: `${s.name} (${s.idNumber || s.phone || ''})`,
}))}
options={studentOptions}
/>
</Form.Item>
<Form.Item name="amount" label="押金金额(元)" rules={[{ required: true }]}>
@@ -482,6 +500,7 @@ const DepositsPage: React.FC = () => {
onOk={handleRefund}
onCancel={() => setRefundModal(null)}
okText="确认退还"
confirmLoading={saving}
>
<Form form={refundForm} layout="vertical">
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
@@ -624,9 +643,10 @@ const DepositsPage: React.FC = () => {
onCancel={() => { setRejectModal(null); setRejectReason(''); }}
okText="确认驳回"
okButtonProps={{ danger: true }}
confirmLoading={saving}
>
<Input.TextArea
placeholder="请输入驳回原因"
aria-label="驳回原因"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
rows={3}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useMemo } from 'react';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import {
Table,
Button,
@@ -26,6 +26,7 @@ import {
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { downloadBlob } from '../../utils/download';
const { RangePicker } = DatePicker;
@@ -49,6 +50,7 @@ const ExpensesPage: React.FC = () => {
const [personalTypeFilter, setPersonalTypeFilter] = useState<string | undefined>(undefined);
const [selectedRoomKeys, setSelectedRoomKeys] = useState<number[]>([]);
const [selectedPersonalKeys, setSelectedPersonalKeys] = useState<number[]>([]);
const [saving, setSaving] = useState(false);
// Dynamic expense type options from API
const [typeOptions, setTypeOptions] = useState<{ value: string; label: string }[]>([]);
@@ -99,7 +101,7 @@ const ExpensesPage: React.FC = () => {
}
};
const fetchData = async () => {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [re, pe, rm, st]: any[] = await Promise.all([
@@ -116,11 +118,11 @@ const ExpensesPage: React.FC = () => {
console.error(e);
}
setLoading(false);
};
}, []);
useEffect(() => {
fetchData();
}, []);
}, [fetchData]);
const filteredRoomExpenses = useMemo(() => {
return roomExpenses.filter((r: any) => {
@@ -145,6 +147,7 @@ const ExpensesPage: React.FC = () => {
}, [personalExpenses, personalSearch, personalTypeFilter]);
const handleRoomExpense = async () => {
setSaving(true);
const values = await roomForm.validateFields();
const payload = {
roomId: values.roomId,
@@ -168,10 +171,13 @@ const ExpensesPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSaving(false);
}
};
const handlePersonalExpense = async () => {
setSaving(true);
const values = await personalForm.validateFields();
const payload = {
studentId: values.studentId,
@@ -195,10 +201,12 @@ const ExpensesPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSaving(false);
}
};
const roomColumns = [
const roomColumns = useMemo(() => [
{ title: '宿舍', width: 120, render: (_: any, r: any) => r.room?.roomNumber || '-' },
{
title: '费用类型', width: 100,
@@ -256,9 +264,9 @@ const ExpensesPage: React.FC = () => {
</Space>
),
},
];
], [setEditingRoom, roomForm, setRoomModal, fetchData, typeMap]);
const personalColumns = [
const personalColumns = useMemo(() => [
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
{
title: '费用类型', width: 100,
@@ -312,7 +320,7 @@ const ExpensesPage: React.FC = () => {
</Space>
),
},
];
], [setEditingPersonal, personalForm, setPersonalModal, fetchData, typeMap]);
return (
<div>
@@ -357,9 +365,7 @@ const ExpensesPage: React.FC = () => {
const formData = new FormData();
formData.append('file', file);
try {
const res: any = await api.post('/expenses/utility/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const res: any = await api.post('/expenses/utility/import', formData);
if (res.errors?.length > 0) {
Modal.warning({
title: res.message,
@@ -383,23 +389,9 @@ const ExpensesPage: React.FC = () => {
permission="expense:view"
icon={<DownloadOutlined />}
onClick={() => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/expenses/utility/template`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '水电费导入模板.xlsx';
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('下载失败'));
downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch(() =>
message.error('下载失败'),
);
}}
>
@@ -508,23 +500,9 @@ const ExpensesPage: React.FC = () => {
permission="expense:view"
icon={<DownloadOutlined />}
onClick={() => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/expenses/personal/template`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '个人附加费导入模板.xlsx';
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('下载失败'));
downloadBlob('/expenses/personal/template', '个人附加费导入模板.xlsx').catch(
() => message.error('下载失败'),
);
}}
>
@@ -533,23 +511,9 @@ const ExpensesPage: React.FC = () => {
permission="expense:view"
icon={<ExportOutlined />}
onClick={() => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/expenses/personal/export`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '个人附加费导出.xlsx';
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('导出失败'));
downloadBlob('/expenses/personal/export', '个人附加费导出.xlsx').catch(() =>
message.error('导出失败'),
);
}}
>
@@ -613,6 +577,7 @@ const ExpensesPage: React.FC = () => {
setEditingRoom(null);
}}
okText={editingRoom ? '保存' : '确认录入'}
confirmLoading={saving}
>
<Form form={roomForm} layout="vertical">
<Form.Item name="roomId" label="宿舍" rules={[{ required: true }]}>
@@ -653,6 +618,7 @@ const ExpensesPage: React.FC = () => {
setEditingPersonal(null);
}}
okText={editingPersonal ? '保存' : '确认录入'}
confirmLoading={saving}
>
<Form form={personalForm} layout="vertical">
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Form, Input, Button, Card, message, Typography } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
@@ -10,7 +10,7 @@ const LoginPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const onFinish = async (values: any) => {
const onFinish = useCallback(async (values: any) => {
setLoading(true);
try {
const res: any = await api.post('/auth/login', values);
@@ -24,7 +24,7 @@ const LoginPage: React.FC = () => {
} finally {
setLoading(false);
}
};
}, [navigate]);
return (
<div
@@ -52,10 +52,10 @@ const LoginPage: React.FC = () => {
<p style={{ color: '#86868b', marginTop: 8 }}></p>
</div>
<Form name="login" onFinish={onFinish} size="large">
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Form.Item label="用户名" name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Form.Item label="密码" name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { List, Typography, Menu, Layout, Button, Empty, Spin, Space } from 'antd';
import { List, Typography, Menu, Layout, Button, Empty, Spin, Space, message } from 'antd';
import {
BellOutlined,
DollarOutlined,
@@ -57,7 +57,10 @@ const NotificationsPage: React.FC = () => {
try {
const data = await api.get('/notifications?limit=50') as unknown as NotificationItem[];
setNotifications(data);
} catch { /* ignore */ }
} catch (e: any) {
console.error('加载通知失败', e);
message.error(e?.message || '加载通知失败');
}
setLoading(false);
};
@@ -72,7 +75,10 @@ const NotificationsPage: React.FC = () => {
setNotifications((prev) =>
prev.map((n) => (n.id === item.id ? { ...n, isRead: true } : n)),
);
} catch { /* ignore */ }
} catch (e: any) {
console.error('标记已读失败', e);
message.error(e?.message || '标记已读失败');
}
}
if (item.link) navigate(item.link);
};
@@ -83,7 +89,10 @@ const NotificationsPage: React.FC = () => {
setNotifications((prev) =>
prev.map((n) => ({ ...n, isRead: true })),
);
} catch { /* ignore */ }
} catch (e: any) {
console.error('全部已读失败', e);
message.error(e?.message || '操作失败');
}
};
const filtered = filter === 'all'
@@ -121,7 +130,16 @@ const NotificationsPage: React.FC = () => {
const meta = typeMap[item.type] || { label: item.type, icon: <BellOutlined /> };
return (
<List.Item
role="button"
tabIndex={0}
aria-label={`通知: ${item.title}`}
onClick={() => handleClick(item)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick(item);
}
}}
style={{
cursor: 'pointer',
padding: '16px 0',

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useMemo } from 'react';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import {
Table,
Button,
@@ -28,6 +28,8 @@ import {
} from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import { downloadBlob } from '../../utils/download';
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
import PermissionButton from '../../components/PermissionButton';
const { RangePicker } = DatePicker;
@@ -47,34 +49,42 @@ const OccupanciesPage: React.FC = () => {
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null);
const [batchCheckOutModal, setBatchCheckOutModal] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [saving, setSaving] = useState(false);
const [checkInForm] = Form.useForm();
const [checkOutForm] = Form.useForm();
const [transferForm] = Form.useForm();
const [batchCheckOutForm] = Form.useForm();
const fetchData = async () => {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [occ, stu, rm, tn]: any[] = await Promise.all([
const [occRes, stuRes, rmRes, tnRes] = (await Promise.allSettled([
api.get('/occupancies', { params: { active: showActive ? 'true' : undefined, dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), dateTo: dateRange?.[1]?.format('YYYY-MM-DD') } }),
api.get('/students'),
api.get('/rooms/overview'),
api.get('/tenants'),
]);
setData(occ);
setStudents(stu);
setRooms(rm);
setTenants(tn);
])) as PromiseSettledResult<any>[];
const labels = ['入住数据', '学生列表', '房间列表', '租赁方'];
[occRes, stuRes, rmRes, tnRes].forEach((res, i) => {
if (res.status === 'rejected') {
message.warning(`${labels[i]}加载失败`);
}
});
setData(occRes.status === 'fulfilled' ? occRes.value : []);
setStudents(stuRes.status === 'fulfilled' ? stuRes.value : []);
setRooms(rmRes.status === 'fulfilled' ? rmRes.value : []);
setTenants(tnRes.status === 'fulfilled' ? tnRes.value : []);
} catch (e) {
console.error(e);
message.error('数据加载异常');
}
setLoading(false);
};
}, [showActive, dateRange]);
useEffect(() => {
fetchData();
setSelectedRowKeys([]);
}, [showActive, dateRange]);
}, [fetchData]);
const filteredData = useMemo(() => {
if (!searchText) return data;
@@ -88,6 +98,7 @@ const OccupanciesPage: React.FC = () => {
const handleCheckIn = async () => {
const values = await checkInForm.validateFields();
setSaving(true);
try {
await api.post('/occupancies/check-in', {
studentId: values.studentId,
@@ -104,11 +115,14 @@ const OccupanciesPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSaving(false);
}
};
const handleCheckOut = async () => {
const values = await checkOutForm.validateFields();
setSaving(true);
try {
await api.put(`/occupancies/${checkOutModal.id}/check-out`, {
checkOutDate: values.checkOutDate.format('YYYY-MM-DD'),
@@ -121,11 +135,14 @@ const OccupanciesPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSaving(false);
}
};
const handleTransfer = async () => {
const values = await transferForm.validateFields();
setSaving(true);
try {
await api.put(`/occupancies/${transferModal.id}/transfer`, {
newRoomId: values.newRoomId,
@@ -140,6 +157,8 @@ const OccupanciesPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSaving(false);
}
};
@@ -173,7 +192,7 @@ const OccupanciesPage: React.FC = () => {
}
};
const columns = [
const columns = useMemo(() => [
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
{ title: '宿舍', width: 120, render: (_: any, r: any) => r.room?.roomNumber || '-' },
{ title: '入住日期', dataIndex: 'checkInDate', width: 110 },
@@ -237,19 +256,19 @@ const OccupanciesPage: React.FC = () => {
</Space>
),
},
];
], [fetchData, setCheckOutModal, checkOutForm, setTransferModal, transferForm]);
const rowSelection = {
const rowSelection = useMemo(() => ({
selectedRowKeys,
onChange: (keys: any[]) => setSelectedRowKeys(keys),
// 「在住记录」Tab禁用已退宿防止误选用于批量退宿「全部记录」Tab均可选用于批量删除
getCheckboxProps: (record: any) => (showActive ? { disabled: !!record.checkOutDate } : {}),
};
}), [selectedRowKeys, showActive]);
return (
<div>
<Alert
message="一站式导入"
title="一站式导入"
description="导入入住名单时会自动创建学生和宿舍,无需单独在「学生管理」或「宿舍管理」中手动添加。后续仅需在此页面处理换房/退宿等日常操作即可。"
type="info"
showIcon
@@ -337,23 +356,9 @@ const OccupanciesPage: React.FC = () => {
permission="occupancy:view"
icon={<DownloadOutlined />}
onClick={() => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/occupancies/template`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '入住名单导入模板.xlsx';
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('下载失败'));
downloadBlob('/occupancies/template', '入住名单导入模板.xlsx').catch(() =>
message.error('下载失败'),
);
}}
>
@@ -362,24 +367,11 @@ const OccupanciesPage: React.FC = () => {
permission="occupancy:view"
icon={<ExportOutlined />}
onClick={() => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
const params = showActive ? '?active=true' : '';
fetch(`${baseURL}/occupancies/export${params}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = showActive ? '在住记录.xlsx' : '全部入住记录.xlsx';
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('导出失败'));
const filename = showActive ? '在住记录.xlsx' : '全部入住记录.xlsx';
downloadBlob('/occupancies/export' + params, filename).catch(() =>
message.error('导出失败'),
);
}}
>
@@ -388,21 +380,34 @@ const OccupanciesPage: React.FC = () => {
<Switch size="small" checked={autoDeposit} onChange={setAutoDeposit} />
{autoDeposit && (
<Space.Compact>
<InputNumber
size="small"
min={0}
value={depositAmount}
onChange={(v) => setDepositAmount(v || 500)}
style={{ width: 80 }}
addonAfter="元"
style={{ width: 60 }}
/>
<span
style={{
padding: '0 8px',
display: 'flex',
alignItems: 'center',
border: '1px solid #d9d9d9',
backgroundColor: '#fafafa',
fontSize: 12,
}}
>
</span>
</Space.Compact>
)}
</span>
</Space>
</div>
{selectedRowKeys.length > 0 && (
<Alert
message={
title={
<span>
<strong>{selectedRowKeys.length}</strong>
{showActive ? (
@@ -464,6 +469,7 @@ const OccupanciesPage: React.FC = () => {
onOk={handleCheckIn}
onCancel={() => setCheckInModal(false)}
okText="确认入住"
confirmLoading={saving}
width={500}
>
<Form form={checkInForm} layout="vertical">
@@ -480,7 +486,7 @@ const OccupanciesPage: React.FC = () => {
.filter((s: any) => s.status === 'active')
.map((s: any) => ({
value: s.id,
label: `${s.name} (${s.idNumber || s.phone || ''})`,
label: `${s.name} (${s.idNumber ? maskIdNumber(s.idNumber) : (s.phone ? maskPhone(s.phone) : '')})`,
}))}
/>
</Form.Item>
@@ -549,6 +555,7 @@ const OccupanciesPage: React.FC = () => {
onOk={handleCheckOut}
onCancel={() => setCheckOutModal(null)}
okText="确认退宿"
confirmLoading={saving}
>
<Form form={checkOutForm} layout="vertical">
<Form.Item name="checkOutDate" label="退宿日期" rules={[{ required: true }]}>
@@ -636,6 +643,7 @@ const OccupanciesPage: React.FC = () => {
onOk={handleTransfer}
onCancel={() => setTransferModal(null)}
okText="确认换房"
confirmLoading={saving}
width={500}
>
<Form form={transferForm} layout="vertical">

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { Table, Select, DatePicker, Space, Tag, Tooltip } from 'antd';
import dayjs from 'dayjs';
import api from '../../api';
@@ -28,7 +28,7 @@ const OperationLogsPage: React.FC = () => {
const [filterModule, setFilterModule] = useState<string | undefined>();
const [dateRange, setDateRange] = useState<[string, string] | null>(null);
const fetchData = async () => {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: any = { page, pageSize: 20 };
@@ -44,13 +44,13 @@ const OperationLogsPage: React.FC = () => {
console.error(e);
}
setLoading(false);
};
}, [page, filterModule, dateRange]);
useEffect(() => {
fetchData();
}, [page, filterModule, dateRange]);
}, [fetchData]);
const columns = [
const columns = useMemo(() => [
{
title: '时间',
dataIndex: 'createdAt',
@@ -106,7 +106,7 @@ const OperationLogsPage: React.FC = () => {
);
},
},
];
], []);
return (
<div>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Card, Tag, Input, Space, Spin } from 'antd';
import { Card, Tag, Input, Space, Spin, Empty } from 'antd';
import api from '../../api';
interface PermissionItem {
@@ -68,6 +68,7 @@ const PermissionsPage: React.FC = () => {
<h2 style={{ margin: 0 }}></h2>
<Input.Search
placeholder="搜索权限名称或编码"
aria-label="搜索权限名称或编码"
allowClear
style={{ width: 280 }}
onSearch={setSearch}
@@ -97,6 +98,7 @@ const PermissionsPage: React.FC = () => {
</Space>
</Card>
))}
{filteredTree.length === 0 && <Empty description="未找到匹配的权限" />}
</Space>
</div>
);

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import {
Table,
Modal,
@@ -39,8 +39,9 @@ const RolesPage: React.FC = () => {
const [allPerms, setAllPerms] = useState<{ group: string; permissions: PermissionItem[] }[]>([]);
const [form] = Form.useForm();
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
const [saving, setSaving] = useState(false);
const fetchData = async () => {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [roles, permTree] = await Promise.all([
@@ -55,11 +56,11 @@ const RolesPage: React.FC = () => {
console.error(e);
}
setLoading(false);
};
}, []);
useEffect(() => {
fetchData();
}, []);
}, [fetchData]);
const handleAdd = () => {
setEditing(null);
@@ -76,6 +77,7 @@ const RolesPage: React.FC = () => {
};
const handleSubmit = async () => {
setSaving(true);
const values = await form.validateFields();
try {
if (editing) {
@@ -97,6 +99,8 @@ const RolesPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e.message || '操作失败');
} finally {
setSaving(false);
}
};
@@ -126,7 +130,7 @@ const RolesPage: React.FC = () => {
role: '角色管理',
};
const columns = [
const columns = useMemo(() => [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '名称', dataIndex: 'name', width: 120 },
{ title: '描述', dataIndex: 'description', width: 200 },
@@ -172,7 +176,7 @@ const RolesPage: React.FC = () => {
</Space>
),
},
];
], []);
const handleGroupCheckAll = (group: string, checked: boolean) => {
const groupPermIds =
@@ -235,6 +239,7 @@ const RolesPage: React.FC = () => {
onCancel={() => setModalOpen(false)}
width={700}
destroyOnHidden
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item

View File

@@ -1,36 +1,86 @@
import React, { useEffect, useState } from 'react';
import { Row, Col, Card, Tag, Select, Statistic, Modal, Spin, Badge, Tooltip } from 'antd';
import { HomeOutlined, UserOutlined, CalendarOutlined, BankOutlined } from '@ant-design/icons';
import React, { useEffect, useState, useCallback } from 'react';
import { Row, Col, Card, Tag, Select, Statistic, Modal, Spin, Badge, Tooltip, DatePicker, Alert, Button } from 'antd';
import { HomeOutlined, UserOutlined, CalendarOutlined, BankOutlined, HistoryOutlined, ShopOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
function getCardStyle(room: any): React.CSSProperties {
let base: React.CSSProperties;
if (room.status === 'maintenance') base = { background: '#f5f5f5', borderColor: '#d9d9d9' };
else if (room.currentCount === 0) base = { background: '#f6ffed', borderColor: '#b7eb8f' };
else if (room.currentCount >= room.capacity) base = { background: '#fff2f0', borderColor: '#ffccc7' };
else base = { background: '#e6f4ff', borderColor: '#91caff' };
if (room.tenantColor) {
return { ...base, background: `color-mix(in srgb, ${room.tenantColor} 15%, ${base.background || '#fff'} 85%)` };
}
return base;
}
function getStatusLabel(room: any) {
if (room.status === 'maintenance') return <Tag color="default"></Tag>;
if (room.currentCount === 0) return <Tag color="success"></Tag>;
if (room.currentCount >= room.capacity) return <Tag color="error"></Tag>;
return <Tag color="processing"></Tag>;
}
function getTenantTags(occupants: any[]) {
const tenantList = [
...new Map(
occupants
.filter((o: any) => o.tenantName)
.map((o: any) => [o.tenantId, { name: o.tenantName, color: o.tenantColor }]),
).values(),
] as { name: string; color: string | null }[];
if (tenantList.length === 0) return null;
return (
<div className="room-card-tag-wrapper" style={{ marginBottom: 6 }}>
{tenantList.map((t) => (
<Tag
key={t.name}
color={t.color || 'gold'}
style={{ fontSize: 12, marginBottom: 4, maxWidth: '100%' }}
icon={<ShopOutlined />}
>
{t.name}
</Tag>
))}
</div>
);
}
const RoomVisualPage: React.FC = () => {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [selectedBuilding, setSelectedBuilding] = useState<string>('all');
const [selectedTenant, setSelectedTenant] = useState<number | 'all'>('all');
const [detailRoom, setDetailRoom] = useState<any>(null);
const [asOf, setAsOf] = useState<Dayjs | null>(null);
const fetchData = async () => {
const isHistorical = !!asOf && !asOf.isSame(dayjs(), 'day');
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res: any = await api.get('/rooms/visual');
const params = isHistorical ? { asOf: asOf!.format('YYYY-MM-DD') } : undefined;
const res: any = await api.get('/rooms/visual', { params });
setData(res);
} catch (e) {
console.error(e);
}
setLoading(false);
};
}, [isHistorical, asOf]);
useEffect(() => {
fetchData();
}, []);
}, [fetchData]);
if (loading || !data)
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!data) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
const rooms =
selectedBuilding === 'all'
? data.rooms
: data.rooms.filter((r: any) => r.building === selectedBuilding);
const rooms = data.rooms.filter((r: any) => {
if (selectedBuilding !== 'all' && r.building !== selectedBuilding) return false;
if (selectedTenant !== 'all' && !(r.tenantIds || []).includes(selectedTenant)) return false;
return true;
});
const totalRooms = rooms.length;
const emptyRooms = rooms.filter(
@@ -43,24 +93,7 @@ const RoomVisualPage: React.FC = () => {
);
const fullRooms = rooms.filter((r: any) => r.currentCount >= r.capacity).length;
const getCardStyle = (room: any): React.CSSProperties => {
let base: React.CSSProperties;
if (room.status === 'maintenance') base = { background: '#f5f5f5', borderColor: '#d9d9d9' };
else if (room.currentCount === 0) base = { background: '#f6ffed', borderColor: '#b7eb8f' };
else if (room.currentCount >= room.capacity) base = { background: '#fff2f0', borderColor: '#ffccc7' };
else base = { background: '#e6f4ff', borderColor: '#91caff' };
if (room.tenantColor) {
return { ...base, background: `color-mix(in srgb, ${room.tenantColor} 15%, ${base.background || '#fff'} 85%)` };
}
return base;
};
const getStatusLabel = (room: any) => {
if (room.status === 'maintenance') return <Tag color="default"></Tag>;
if (room.currentCount === 0) return <Tag color="success"></Tag>;
if (room.currentCount >= room.capacity) return <Tag color="error"></Tag>;
return <Tag color="processing"></Tag>;
};
/* getCardStyle, getStatusLabel, getTenantTags are now standalone functions outside the component */
return (
<div>
@@ -75,17 +108,64 @@ const RoomVisualPage: React.FC = () => {
}}
>
<h2 style={{ margin: 0 }}>宿</h2>
<Select
value={selectedBuilding}
onChange={setSelectedBuilding}
style={{ width: 160 }}
options={[
{ value: 'all', label: '全部楼栋' },
...data.buildings.map((b: string) => ({ value: b, label: b })),
]}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<DatePicker
value={asOf}
onChange={setAsOf}
allowClear
placeholder="查看历史日期"
suffixIcon={<HistoryOutlined />}
disabledDate={(d) => d && d.isAfter(dayjs(), 'day')}
style={{ width: 180 }}
/>
<Select
value={selectedBuilding}
onChange={setSelectedBuilding}
style={{ width: 160 }}
options={[
{ value: 'all', label: '全部楼栋' },
...data.buildings.map((b: string) => ({ value: b, label: b })),
]}
/>
<Select
value={selectedTenant}
onChange={setSelectedTenant}
style={{ width: 180 }}
options={[
{ value: 'all', label: '全部租赁方' },
...(data.tenants || []).map((t: any) => ({
value: t.id,
label: (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{t.color && (
<span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: t.color, display: 'inline-block' }} />
)}
{t.name}
</span>
),
})),
]}
/>
</div>
</div>
{isHistorical && (
<Alert
type="warning"
showIcon
icon={<HistoryOutlined />}
style={{ marginBottom: 16 }}
title={`正在查看 ${asOf!.format('YYYY年M月D日')} 的历史入住情况(含当日已归档房间),非实时数据`}
action={
<Button size="small" type="link" onClick={() => setAsOf(null)}>
</Button>
}
/>
)}
{loading && <Spin style={{ display: 'block', margin: '8px auto 16px' }} />}
{/* 统计栏 */}
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
<Col xs={12} sm={6}>
@@ -95,17 +175,17 @@ const RoomVisualPage: React.FC = () => {
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="空闲房间" value={emptyRooms} valueStyle={{ color: '#34C759' }} />
<Statistic title="空闲房间" value={emptyRooms} styles={{ value: { color: '#34C759' } }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="可安排床位" value={availableBeds} valueStyle={{ color: '#007AFF' }} />
<Statistic title="可安排床位" value={availableBeds} styles={{ value: { color: '#007AFF' } }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="满员房间" value={fullRooms} valueStyle={{ color: '#FF3B30' }} />
<Statistic title="满员房间" value={fullRooms} styles={{ value: { color: '#FF3B30' } }} />
</Card>
</Col>
</Row>
@@ -161,22 +241,23 @@ const RoomVisualPage: React.FC = () => {
: room.currentCount > 0
? '#007AFF'
: '#34C759',
fontSize: 11,
fontSize: 12,
}}
/>
</div>
{room.orgLabel && (
<div style={{ marginBottom: 6 }}>
<Tag color="purple" style={{ fontSize: 11 }} icon={<BankOutlined />}>
<div className="room-card-tag-wrapper" style={{ marginBottom: 6 }}>
<Tag color="purple" style={{ fontSize: 12 }} icon={<BankOutlined />}>
{room.orgLabel}
</Tag>
</div>
)}
{getTenantTags(room.occupants)}
{room.occupants.length > 0 && (
<div style={{ borderTop: '1px solid rgba(0,0,0,0.06)', paddingTop: 6 }}>
<div className="room-card-tag-wrapper" style={{ borderTop: '1px solid rgba(0,0,0,0.06)', paddingTop: 6 }}>
{room.occupants.slice(0, 4).map((o: any) => (
<Tooltip key={o.studentId} title={`入住 ${o.days} 天 (${o.checkInDate} 起)`}>
<Tag style={{ margin: '0 4px 4px 0', fontSize: 11 }} icon={<UserOutlined />}>
<Tag style={{ margin: '0 4px 4px 0', fontSize: 12, maxWidth: '100%' }} icon={<UserOutlined />}>
{o.studentName}
</Tag>
</Tooltip>
@@ -241,7 +322,7 @@ const RoomVisualPage: React.FC = () => {
<UserOutlined style={{ marginRight: 6 }} />
<strong>{o.studentName}</strong>
{o.organization && (
<Tag color="purple" style={{ marginLeft: 6, fontSize: 11 }}>
<Tag color="purple" style={{ marginLeft: 6, fontSize: 12 }}>
{o.organization}
</Tag>
)}

View File

@@ -26,6 +26,7 @@ import {
DeleteOutlined,
} from '@ant-design/icons';
import api from '../../api';
import { downloadBlob } from '../../utils/download';
import PermissionButton from '../../components/PermissionButton';
const statusMap: Record<string, { text: string; color: string }> = {
@@ -35,6 +36,31 @@ const statusMap: Record<string, { text: string; color: string }> = {
archived: { text: '已归档', color: '#999' },
};
function parseRoomNumber(input: string) {
const cleaned = input.replace(/[(].*?[)]/g, '').trim();
const familyMatch = cleaned.match(/^(\d+)-(\d+)-(\d+)$/);
if (familyMatch) {
return {
building: `${familyMatch[1]}-${familyMatch[2]}`,
floor: parseInt(familyMatch[3].charAt(0), 10) || undefined,
roomType: '家庭房',
capacity: 4,
};
}
const stdMatch = cleaned.match(/^(\d+)-(\d+)$/);
if (stdMatch) {
const bldgNum = stdMatch[1];
const roomPart = stdMatch[2];
const floor = parseInt(roomPart.charAt(0), 10) || undefined;
let roomType = '四人间';
let capacity = 4;
if (bldgNum === '2') { roomType = '单人间'; capacity = 1; }
else if (bldgNum === '8') { roomType = '爆改房'; capacity = 2; }
return { building: `${bldgNum}号楼`, floor, roomType, capacity };
}
return null;
}
const RoomsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
@@ -47,6 +73,7 @@ const RoomsPage: React.FC = () => {
const [filterBuilding, setFilterBuilding] = useState<string | undefined>(undefined);
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [saving, setSaving] = useState(false);
const [form] = Form.useForm();
const handleBatchDelete = async () => {
@@ -99,6 +126,7 @@ const RoomsPage: React.FC = () => {
const handleSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) {
await api.put(`/rooms/${editing.id}`, values);
@@ -113,6 +141,8 @@ const RoomsPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSaving(false);
}
};
@@ -146,43 +176,15 @@ const RoomsPage: React.FC = () => {
};
const handleDownloadTemplate = () => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/rooms/template`, { headers: { Authorization: `Bearer ${token}` } })
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '宿舍导入模板.xlsx';
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('下载失败'));
downloadBlob('/rooms/template', '房间导入模板.xlsx').catch(() => message.error('下载失败'));
};
const handleExport = () => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
const params = showArchived ? '?includeArchived=true' : '';
fetch(`${baseURL}/rooms/export${params}`, { headers: { Authorization: `Bearer ${token}` } })
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '宿舍列表.xlsx';
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('导出失败'));
downloadBlob('/rooms/export' + params, '房间列表.xlsx').catch(() => message.error('导出失败'));
};
const columns = [
const columns = useMemo(() => [
{
title: '房间号',
dataIndex: 'roomNumber',
@@ -288,7 +290,7 @@ const RoomsPage: React.FC = () => {
</Space>
),
},
];
], [showArchived, buildings, handleBatchDelete, handleRestore, handleArchive, showDetail]);
return (
<div>
@@ -416,10 +418,17 @@ const RoomsPage: React.FC = () => {
setEditing(null);
}}
okText="保存"
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item name="roomNumber" label="房间号" rules={[{ required: true }]}>
<Input placeholder="如4-102自动解析楼栋楼层" />
<Input
placeholder="如4-102自动解析楼栋楼层"
onChange={(e) => {
const parsed = parseRoomNumber(e.target.value);
if (parsed) form.setFieldsValue(parsed);
}}
/>
</Form.Item>
<Form.Item name="building" label="楼栋">
<Input placeholder="如4号楼留空自动解析" />

View File

@@ -509,7 +509,16 @@ const SchedulesPage: React.FC = () => {
return (
<td
key={wd}
tabIndex={0}
role="button"
aria-label={`选择教室 ${classroom.name} ${WEEKDAYS[wd - 1]} 排课`}
onClick={() => handleCellClick(classroom.id, wd)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleCellClick(classroom.id, wd);
}
}}
style={{
padding: 4,
border: '1px solid #f0f0f0',
@@ -610,7 +619,16 @@ const SchedulesPage: React.FC = () => {
return (
<td
key={di}
tabIndex={0}
role="button"
aria-label={`${day.format('YYYY-MM-DD')} 排课详情`}
onClick={() => handleDateClick(day)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleDateClick(day);
}
}}
style={{
padding: '6px 8px',
border: '1px solid #f0f0f0',

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Button, Space } from 'antd';
import { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';
@@ -14,7 +14,7 @@ const StudentProfilePage: React.FC = () => {
const studentId = Number(id);
const handlePreviewReport = async () => {
const handlePreviewReport = useCallback(async () => {
try {
const { html } = await api.get<{ html: string }>(`/archive/${studentId}/report-html`);
const w = window.open('', '_blank');
@@ -25,7 +25,7 @@ const StudentProfilePage: React.FC = () => {
} catch (err) {
console.error('Failed to load report HTML:', err);
}
};
}, [studentId]);
return (
<Card

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import {
Table,
Button,
@@ -31,16 +31,7 @@ import {
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import StudentProfileContent from '../../components/StudentProfileContent';
const maskPhone = (phone: string) => {
if (!phone || phone.length < 7) return phone || '-';
return phone.slice(0, 3) + '****' + phone.slice(-4);
};
const maskIdNumber = (id: string) => {
if (!id || id.length < 8) return id || '-';
return id.slice(0, 3) + '***********' + id.slice(-4);
};
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
const statusMap: Record<string, { text: string; color: string }> = {
active: { text: '在读', color: 'green' },
@@ -85,6 +76,7 @@ const StudentsPage: React.FC = () => {
const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerStudentId, setDrawerStudentId] = useState<number | undefined>(undefined);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const openDrawer = (studentId: number) => {
setDrawerStudentId(studentId);
@@ -98,18 +90,22 @@ const StudentsPage: React.FC = () => {
okText: '确认查看',
cancelText: '取消',
onOk: async () => {
api.post('/operation-logs/audit', {
module: '学生管理',
action: '查看敏感信息',
targetId: studentId,
targetType: 'student',
detail: `查看${field}`,
}).catch(() => {});
modal.info({
title: field,
content: value,
okText: '关闭',
});
try {
await api.post('/operation-logs/audit', {
module: '学生管理',
action: '查看敏感信息',
targetId: studentId,
targetType: 'student',
detail: `查看${field}`,
});
modal.info({
title: field,
content: value,
okText: '关闭',
});
} catch {
message.error('审计日志记录失败,请稍后重试');
}
},
});
};
@@ -125,7 +121,7 @@ const StudentsPage: React.FC = () => {
}
};
const fetchData = async () => {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, unknown> = { name: searchName || undefined, includeArchived: 'true' };
@@ -140,11 +136,11 @@ const StudentsPage: React.FC = () => {
console.error(e);
}
setLoading(false);
};
}, [searchName, showArchived, filterStatus, filterTenantId]);
useEffect(() => {
fetchData();
}, [searchName, showArchived, filterStatus, filterTenantId]);
}, [fetchData]);
useEffect(() => {
api.get('/tenants', { params: { includeArchived: 'false' } }).then((res: unknown) => {
@@ -153,6 +149,7 @@ const StudentsPage: React.FC = () => {
}, []);
const handleSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) {
await api.put(`/students/${editing.id}`, values);
@@ -167,6 +164,8 @@ const StudentsPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSaving(false);
}
};
@@ -227,14 +226,14 @@ const StudentsPage: React.FC = () => {
.catch(() => message.error('导出失败'));
};
const columns = [
const columns = useMemo(() => [
{ title: 'ID', dataIndex: 'id', width: 70 },
{
title: '姓名',
dataIndex: 'name',
width: 120,
render: (v: string, record: any) => (
<a onClick={() => openDrawer(record.id)}>{v}</a>
<Button type="link" size="small" onClick={() => openDrawer(record.id)}>{v}</Button>
),
},
{
@@ -246,16 +245,16 @@ const StudentsPage: React.FC = () => {
return (
<span>
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
<a onClick={() => handleViewSensitive(record.id, '电话', v)} title="点击查看完整号码">
<Button type="link" size="small" style={{ padding: '8px 4px' }} onClick={() => handleViewSensitive(record.id, '电话', v)} title="点击查看完整号码">
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
</Button>
</span>
);
},
},
{
title: '学号',
dataIndex: 'studentNumber',
dataIndex: 'studentNo',
width: 120,
render: (v: string) => v || '-',
},
@@ -268,9 +267,9 @@ const StudentsPage: React.FC = () => {
return (
<span>
<span style={{ marginRight: 4 }}>{maskIdNumber(v)}</span>
<a onClick={() => handleViewSensitive(record.id, '身份证号', v)} title="点击查看完整号码">
<Button type="link" size="small" style={{ padding: '8px 4px' }} onClick={() => handleViewSensitive(record.id, '身份证号', v)} title="点击查看完整号码">
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
</Button>
</span>
);
},
@@ -283,14 +282,14 @@ const StudentsPage: React.FC = () => {
dataIndex: 'tenant',
width: 100,
render: (tenant: { name?: string } | null) =>
tenant?.name ? <Tag color="purple">{tenant.name}</Tag> : '-',
tenant?.name ? <Tag color="purple" style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}>{tenant.name}</Tag> : '-',
},
{ title: '负责人', dataIndex: 'supervisor', width: 100 },
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
render: (s: string) => <Tag color={statusMap[s]?.color} style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}>{statusMap[s]?.text || s}</Tag>,
},
{
title: '操作',
@@ -344,7 +343,7 @@ const StudentsPage: React.FC = () => {
</Space>
),
},
];
], [handleViewSensitive, openDrawer, showArchived, tenants]);
return (
<div>
@@ -513,12 +512,13 @@ const StudentsPage: React.FC = () => {
setEditing(null);
}}
okText="保存"
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="studentNumber" label="学号">
<Form.Item name="studentNo" label="学号">
<Input placeholder="学生的学号" />
</Form.Item>
<Form.Item name="gender" label="性别">
@@ -533,7 +533,7 @@ const StudentsPage: React.FC = () => {
<Form.Item name="phone" label="电话">
<Input />
</Form.Item>
<Form.Item name="idNumber" label="学号/身份证">
<Form.Item name="idNumber" label="身份证">
<Input />
</Form.Item>
<Form.Item name="ethnicity" label="民族">
@@ -580,7 +580,7 @@ const StudentsPage: React.FC = () => {
title={null}
open={drawerOpen}
onClose={() => { setDrawerOpen(false); }}
width={720}
size={720}
>
{drawerStudentId && (
<StudentProfileContent

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Card, Tabs, Table, Tag, Empty, Spin } from 'antd';
import React, { useEffect, useState, useMemo } from 'react';
import { Card, Tabs, Table, Tag, Empty, Spin, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import api from '../../api';
@@ -65,6 +65,7 @@ const TeacherWorkspacePage: React.FC = () => {
setData(res);
} catch (e) {
console.error(e);
message.error('数据加载失败');
} finally {
setLoading(false);
}
@@ -72,7 +73,7 @@ const TeacherWorkspacePage: React.FC = () => {
fetchData();
}, []);
const classColumns: ColumnsType<AssignedClass> = [
const classColumns: ColumnsType<AssignedClass> = useMemo(() => [
{
title: '班级名称',
dataIndex: 'className',
@@ -88,9 +89,9 @@ const TeacherWorkspacePage: React.FC = () => {
dataIndex: 'subject',
render: (v: string | null) => v || '-',
},
];
], []);
const scheduleColumns: ColumnsType<ScheduleItem> = [
const scheduleColumns: ColumnsType<ScheduleItem> = useMemo(() => [
{
title: '时间',
key: 'time',
@@ -114,14 +115,14 @@ const TeacherWorkspacePage: React.FC = () => {
</Tag>
),
},
];
], []);
const studentColumns: ColumnsType<StudentItem> = [
const studentColumns: ColumnsType<StudentItem> = useMemo(() => [
{ title: '姓名', dataIndex: 'studentName' },
{ title: '学号', dataIndex: 'studentNo', render: (v: string) => v || '-' },
{ title: '班级', dataIndex: 'className' },
{ title: '加入日期', dataIndex: 'joinDate', render: (v: string) => v || '-' },
];
], []);
if (loading) {
return (

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { Table, Input, Button, Modal, Form, Select, DatePicker, Tag, Space, message } from 'antd';
import { EditOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
@@ -51,6 +51,7 @@ const TeachersPage: React.FC = () => {
const [search, setSearch] = useState('');
const [profileModal, setProfileModal] = useState<TeacherRow | null>(null);
const [form] = Form.useForm<ProfileFormValues>();
const [saving, setSaving] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
@@ -73,6 +74,7 @@ const TeachersPage: React.FC = () => {
const handleSaveProfile = async () => {
const values = await form.validateFields();
if (!profileModal) return;
setSaving(true);
try {
await api.put(`/rbac/teachers/${profileModal.id}/profile`, {
subjects: values.subjects || [],
@@ -88,10 +90,12 @@ const TeachersPage: React.FC = () => {
msg = String(e.message);
}
message.error(msg);
} finally {
setSaving(false);
}
};
const columns = [
const columns = useMemo(() => [
{ title: '姓名', dataIndex: 'name', key: 'name', width: 120 },
{ title: '用户名', dataIndex: 'username', key: 'username', width: 130 },
{
@@ -166,7 +170,7 @@ const TeachersPage: React.FC = () => {
</Button>
),
},
];
], []);
return (
<div>
@@ -216,6 +220,7 @@ const TeachersPage: React.FC = () => {
onOk={handleSaveProfile}
onCancel={() => setProfileModal(null)}
okText="保存"
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item name="subjects" label="任教学科">

View File

@@ -23,6 +23,7 @@ const TenantsPage: React.FC = () => {
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const [searchText, setSearchText] = useState('');
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
@@ -55,6 +56,7 @@ const TenantsPage: React.FC = () => {
const handleSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) {
await api.put(`/tenants/${editing.id}`, values);
@@ -69,6 +71,8 @@ const TenantsPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSaving(false);
}
};
@@ -82,7 +86,7 @@ const TenantsPage: React.FC = () => {
}
};
const columns = [
const columns = useMemo(() => [
{
title: '租赁方名称', width: 120,
dataIndex: 'name',
@@ -148,7 +152,7 @@ const TenantsPage: React.FC = () => {
</Space>
),
},
];
], [handleArchive]);
return (
<div>
@@ -203,6 +207,7 @@ const TenantsPage: React.FC = () => {
setEditing(null);
}}
okText="保存"
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
@@ -215,28 +220,44 @@ const TenantsPage: React.FC = () => {
<Input />
</Form.Item>
<Form.Item name="color" label="标签颜色" tooltip="可视化排期时用的颜色,留空则自动分配">
<Input
placeholder="#40a9ff"
addonAfter={
<Space size={4}>
{PRESET_COLORS.map((c) => (
<span
key={c}
onClick={() => form.setFieldValue('color', c)}
style={{
display: 'inline-block',
width: 16,
height: 16,
background: c,
borderRadius: 3,
cursor: 'pointer',
border: '1px solid #d9d9d9',
}}
/>
))}
</Space>
}
/>
<Space.Compact>
<Input placeholder="#40a9ff" style={{ flex: 1 }} />
<span
style={{
padding: '0 4px',
display: 'flex',
alignItems: 'center',
border: '1px solid #d9d9d9',
backgroundColor: '#fafafa',
gap: 4,
}}
>
{PRESET_COLORS.map((c) => (
<span
key={c}
role="button"
tabIndex={0}
aria-label={`选择颜色 ${c}`}
onClick={() => form.setFieldValue('color', c)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
form.setFieldValue('color', c);
}
}}
style={{
display: 'inline-block',
width: 28,
height: 28,
background: c,
borderRadius: 3,
cursor: 'pointer',
border: '1px solid #d9d9d9',
}}
/>
))}
</span>
</Space.Compact>
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import {
Table,
Modal,
@@ -11,7 +11,7 @@ import {
Popconfirm,
message,
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined, IdcardOutlined } from '@ant-design/icons';
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined, IdcardOutlined, CloudDownloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
@@ -29,6 +29,8 @@ const UsersPage: React.FC = () => {
const [profileForm] = Form.useForm();
const [form] = Form.useForm();
const [pwdForm] = Form.useForm();
const [syncing, setSyncing] = useState(false);
const [saving, setSaving] = useState(false);
const handleOpenProfile = async (record: any) => {
setProfileUser(record);
@@ -42,14 +44,21 @@ const UsersPage: React.FC = () => {
};
const handleProfileSubmit = async () => {
setSaving(true);
const values = await profileForm.validateFields();
await api.put(`/rbac/users/${profileUser.id}/profile`, values);
message.success('档案更新成功');
setProfileModalOpen(false);
fetchData();
try {
await api.put(`/rbac/users/${profileUser.id}/profile`, values);
message.success('档案更新成功');
setProfileModalOpen(false);
fetchData();
} catch (e: any) {
message.error(e.message || '操作失败');
} finally {
setSaving(false);
}
};
const fetchData = async () => {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [users, rolesRes] = await Promise.all([
@@ -62,11 +71,25 @@ const UsersPage: React.FC = () => {
console.error(e);
}
setLoading(false);
}, []);
const handleSyncDingTalk = async () => {
setSyncing(true);
try {
const res: any = await api.post('/sync/trigger?platform=dingtalk');
const log = res.logs?.[0];
message.success(`同步完成:${log?.recordsCount || 0} 条记录`);
fetchData();
} catch (e: any) {
message.error(e?.message || '同步失败');
} finally {
setSyncing(false);
}
};
useEffect(() => {
fetchData();
}, []);
}, [fetchData]);
const handleAdd = () => {
setEditing(null);
@@ -86,6 +109,7 @@ const UsersPage: React.FC = () => {
};
const handleSubmit = async () => {
setSaving(true);
const values = await form.validateFields();
try {
if (editing) {
@@ -109,6 +133,8 @@ const UsersPage: React.FC = () => {
fetchData();
} catch (e: any) {
message.error(e.message || '操作失败');
} finally {
setSaving(false);
}
};
@@ -129,6 +155,7 @@ const UsersPage: React.FC = () => {
};
const handlePwdSubmit = async () => {
setSaving(true);
const values = await pwdForm.validateFields();
try {
await api.put(`/rbac/users/${resetTarget.id}/password`, { password: values.password });
@@ -136,10 +163,12 @@ const UsersPage: React.FC = () => {
setPwdModalOpen(false);
} catch (e: any) {
message.error(e.message || '操作失败');
} finally {
setSaving(false);
}
};
const columns = [
const columns = useMemo(() => [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '用户名', dataIndex: 'username', width: 120 },
{ title: '姓名', dataIndex: 'name', width: 120 },
@@ -219,7 +248,7 @@ const UsersPage: React.FC = () => {
</Space>
),
},
];
], []);
return (
<div>
@@ -242,6 +271,14 @@ const UsersPage: React.FC = () => {
>
</PermissionButton>
<PermissionButton
permission="sync:trigger"
icon={<CloudDownloadOutlined />}
loading={syncing}
onClick={handleSyncDingTalk}
>
</PermissionButton>
</div>
<Table
columns={columns}
@@ -258,6 +295,7 @@ const UsersPage: React.FC = () => {
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
destroyOnHidden
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item
@@ -309,6 +347,7 @@ const UsersPage: React.FC = () => {
onOk={handlePwdSubmit}
onCancel={() => setPwdModalOpen(false)}
destroyOnHidden
confirmLoading={saving}
>
<Form form={pwdForm} layout="vertical">
<Form.Item
@@ -327,6 +366,7 @@ const UsersPage: React.FC = () => {
onOk={handleProfileSubmit}
onCancel={() => setProfileModalOpen(false)}
destroyOnHidden
confirmLoading={saving}
>
<Form form={profileForm} layout="vertical">
<Form.Item name="joinedAt" label="入职日期">

View File

@@ -0,0 +1,226 @@
/**
* Test fixtures — consistent test data used across integration tests.
*
* These mirror the PRD data models and are used to seed/verify API responses.
* All IDs are prefixed "test-" to distinguish from real data in a shared dev DB.
*/
// ── Auth ────────────────────────────────────────────────────────────
export const CREDENTIALS = {
superAdmin: { username: 'admin', password: 'admin123' },
staff: { username: 'staff1', password: 'staff123' },
classTeacher: { username: 'teacher1', password: 'teacher123' },
student: { username: 'student1', password: 'student123' },
} as const;
// ── Student (PRD §3) ────────────────────────────────────────────────
export const SAMPLE_STUDENT = {
name: '测试学员A',
phone: '13800000001',
idCard: '110101200001011234',
gender: '男',
ethnicity: '汉族',
status: 'active',
emergencyContact: '张三',
emergencyPhone: '13900000001',
studentNo: 'TEST-2026-001',
};
export const SAMPLE_STUDENT_B = {
name: '测试学员B',
phone: '13800000002',
idCard: '110101200001011235',
gender: '女',
ethnicity: '汉族',
status: 'active',
emergencyContact: '李四',
emergencyPhone: '13900000002',
studentNo: 'TEST-2026-002',
};
// ── Class (PRD §5) ──────────────────────────────────────────────────
export const SAMPLE_CLASS = {
name: '2026届文化课冲刺1班',
code: 'TEST-WHK-2026-001',
classType: '文化课',
startDate: '2026-03-01',
endDate: '2026-06-30',
status: '在读',
maxStudents: 40,
};
// ── Schedule (PRD §6) ───────────────────────────────────────────────
export const SAMPLE_SCHEDULE = {
weekDay: 1, // 周一
startTime: '09:00',
endTime: '10:30',
subject: '语文',
scheduleType: 'INTERNAL',
status: 'active',
};
// Conflicting schedule: same classroom, same weekday, overlapping time
export const CONFLICT_SCHEDULE = {
weekDay: 1,
startTime: '09:30', // overlaps with 09:00-10:30
endTime: '11:00',
subject: '数学',
scheduleType: 'INTERNAL',
status: 'active',
};
// Non-conflicting: same classroom, same weekday, non-overlapping
export const NON_CONFLICT_SCHEDULE = {
weekDay: 1,
startTime: '10:30', // exactly at boundary — no overlap
endTime: '12:00',
subject: '英语',
scheduleType: 'INTERNAL',
status: 'active',
};
// ── Room / Dormitory (PRD §7) ───────────────────────────────────────
export const SAMPLE_ROOM = {
roomNumber: 'TEST-401',
building: '1号楼',
floor: 4,
capacity: 6,
status: 'available',
gender: '男',
rentalCategory: 'short',
roomType: '标准间',
};
export const SAMPLE_LONG_RENT_ROOM = {
roomNumber: 'TEST-501',
building: '1号楼',
floor: 5,
capacity: 4,
status: 'available',
gender: '女',
rentalCategory: 'long',
monthlyRate: 800,
roomType: '标准间',
};
// ── Occupancy (PRD §8) ──────────────────────────────────────────────
export const SAMPLE_OCCUPANCY = {
checkInDate: '2026-03-01',
billingStartDate: '2026-03-01',
billingEndDate: '2026-06-30',
rentalType: 'short',
};
// ── Bill / Expense (PRD §9-10) ──────────────────────────────────────
export const SAMPLE_EXPENSE = {
type: 'water',
amount: 150.0,
billingMonth: '2026-03',
description: '3月水费公摊',
};
export const SAMPLE_PERSONAL_EXPENSE = {
type: 'damage',
amount: 50.0,
description: '损坏赔偿-台灯',
};
// ── Deposit (PRD §11) ───────────────────────────────────────────────
export const SAMPLE_DEPOSIT = {
amount: 500.0,
type: 'collect' as const,
notes: '入学押金',
};
// ── Attendance (PRD §13) ────────────────────────────────────────────
export const SAMPLE_ATTENDANCE = {
attendanceDate: '2026-03-15',
session: '上午',
status: '出勤',
source: '人工点名',
courseName: '语文',
};
export const SAMPLE_ATTENDANCE_ABSENT = {
attendanceDate: '2026-03-16',
session: '上午',
status: '缺勤',
source: '人工点名',
courseName: '语文',
};
// ── Classroom (PRD §6) ──────────────────────────────────────────────
export const SAMPLE_CLASSROOM = {
name: 'TEST-301教室',
building: '教学楼A',
floor: 3,
capacity: 50,
roomType: '大',
status: 'available',
};
// ── Tenant (PRD §12) ────────────────────────────────────────────────
export const SAMPLE_TENANT = {
name: '测试合作机构A',
contact: '王经理',
phone: '13700000001',
color: '#1890ff',
status: 'active',
};
// ── Operation Log expectation (PRD §18) ─────────────────────────────
export const LOG_ACTIONS = {
STUDENT_CREATE: { module: 'students', action: 'create' },
STUDENT_UPDATE: { module: 'students', action: 'update' },
STUDENT_DELETE: { module: 'students', action: 'delete' },
BILL_GENERATE: { module: 'bills', action: 'generate' },
BILL_CONFIRM: { module: 'bills', action: 'confirm' },
DEPOSIT_COLLECT: { module: 'deposits', action: 'collect' },
DEPOSIT_REFUND: { module: 'deposits', action: 'refund' },
OCCUPANCY_CHECKIN: { module: 'occupancies', action: 'create' },
OCCUPANCY_CHECKOUT: { module: 'occupancies', action: 'checkout' },
EXPENSE_CREATE: { module: 'expenses', action: 'create' },
CLASS_CREATE: { module: 'classes', action: 'create' },
CLASS_DELETE: { module: 'classes', action: 'delete' },
SCHEDULE_CREATE: { module: 'schedules', action: 'create' },
ATTENDANCE_BATCH: { module: 'attendance', action: 'batch' },
SENSITIVE_VIEW: { module: 'students', action: 'view_sensitive' },
} as const;
// ── Permission nodes (PRD §17) ──────────────────────────────────────
export const PERMISSION_NODES = [
'student:view', 'student:add', 'student:update', 'student:delete',
'student:import', 'student:export',
'room:view', 'room:add', 'room:update', 'room:delete',
'occupancy:view', 'occupancy:add', 'occupancy:update',
'bill:view', 'bill:generate', 'bill:confirm', 'bill:markPaid', 'bill:export',
'expense:view', 'expense:add', 'expense:update', 'expense:delete',
'deposit:view', 'deposit:collect', 'deposit:refund',
'class:view', 'class:add', 'class:update', 'class:delete',
'schedule:view', 'schedule:add', 'schedule:update', 'schedule:delete',
'attendance:view', 'attendance:add', 'attendance:update', 'attendance:delete',
'attendance:batch',
'classroom:view', 'classroom:add', 'classroom:update', 'classroom:delete',
'tenant:view', 'tenant:add', 'tenant:update', 'tenant:delete',
'rental:view', 'rental:add', 'rental:update', 'rental:delete',
'archive:view', 'archive:import', 'archive:export',
'report:generate',
'log:view',
'role:view', 'role:add', 'role:update', 'role:delete',
'user:view', 'user:add', 'user:update', 'user:delete',
'department:view', 'department:add', 'department:update', 'department:delete',
'dashboard:view',
] as const;

View File

@@ -0,0 +1,168 @@
/**
* Shared browser-test helpers.
*
* Import this in every `*.integration.test.ts` file.
* Provides login, API calling, and page-navigation utilities
* that work inside the Vitest browser environment.
*/
import { expect } from 'vitest';
import { CREDENTIALS } from './fixtures';
import { BASE } from './setup';
// ── Types ───────────────────────────────────────────────────────────
interface ApiResponse<T = unknown> {
code: number;
data: T;
message?: string;
}
type Role = keyof typeof CREDENTIALS;
// ── Auth helpers ────────────────────────────────────────────────────
/**
* Login as a specific role and store the token in localStorage.
* Returns the parsed response data.
*/
export async function loginAs(role: Role): Promise<{ token: string; user: Record<string, unknown> }> {
const creds = CREDENTIALS[role];
const res = await fetch(`${BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(creds),
});
expect(res.status).toBe(201);
const json = (await res.json()) as ApiResponse<{ token: string; user: Record<string, unknown> }>;
expect(json.code).toBe(0);
localStorage.setItem('token', json.data.token);
localStorage.setItem('user', JSON.stringify(json.data.user));
return json.data;
}
/**
* Logout: clear localStorage.
*/
export function logout(): void {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('permissions');
localStorage.removeItem('currentCampusId');
}
// ── API helpers (authenticated) ─────────────────────────────────────
function authHeaders(): Record<string, string> {
const token = localStorage.getItem('token');
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
export async function apiGet<T = unknown>(url: string): Promise<ApiResponse<T>> {
const res = await fetch(`${BASE}${url}`, { headers: authHeaders() });
return (await res.json()) as ApiResponse<T>;
}
export async function apiPost<T = unknown>(url: string, body?: unknown): Promise<ApiResponse<T>> {
const res = await fetch(`${BASE}${url}`, {
method: 'POST',
headers: authHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return (await res.json()) as ApiResponse<T>;
}
export async function apiPut<T = unknown>(url: string, body?: unknown): Promise<ApiResponse<T>> {
const res = await fetch(`${BASE}${url}`, {
method: 'PUT',
headers: authHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return (await res.json()) as ApiResponse<T>;
}
export async function apiDelete<T = unknown>(url: string): Promise<ApiResponse<T>> {
const res = await fetch(`${BASE}${url}`, {
method: 'DELETE',
headers: authHeaders(),
});
return (await res.json()) as ApiResponse<T>;
}
// ── Page helpers ────────────────────────────────────────────────────
/**
* Navigate to a page and wait for it to load.
*/
export async function goTo(path: string): Promise<void> {
document.location.href = `${BASE}${path}`;
// Wait for React to render
await new Promise((r) => setTimeout(r, 500));
}
/**
* Assert the current page URL contains the given path.
*/
export async function assertOnPage(path: string): Promise<void> {
// Wait a tick for SPA routing
await new Promise((r) => setTimeout(r, 300));
expect(window.location.pathname).toContain(path);
}
// ── Wait helpers ────────────────────────────────────────────────────
/** Poll until a condition is true or timeout. */
export async function waitFor(
condition: () => boolean | Promise<boolean>,
timeout = 5000,
interval = 200,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
if (await condition()) return;
await new Promise((r) => setTimeout(r, interval));
}
throw new Error(`waitFor timed out after ${timeout}ms`);
}
// ── Assertion helpers ───────────────────────────────────────────────
/** Assert an API response is successful (code === 0). */
export function assertOk<T>(res: ApiResponse<T>, msg?: string): T {
expect(res.code, msg ?? 'API should return code 0').toBe(0);
return res.data;
}
/** Assert an API response is an error (code !== 0). */
export function assertError(res: ApiResponse, expectedCode?: number): void {
expect(res.code).not.toBe(0);
if (expectedCode !== undefined) {
expect(res.code).toBe(expectedCode);
}
}
/** Assert a 403 is returned (permission denied). */
export async function assertForbidden(promise: Promise<Response>): Promise<void> {
const res = await promise;
expect(res.status).toBe(403);
}
/** Assert a 401 is returned (unauthenticated). */
export async function assertUnauthenticated(promise: Promise<Response>): Promise<void> {
const res = await promise;
expect(res.status).toBe(401);
}
// ── Sensitive data helpers (PRD §3.3) ───────────────────────────────
/** Assert phone number is masked: 138****0001 */
export function assertPhoneMasked(displayed: string): void {
expect(displayed).toMatch(/^\d{3}\*{4}\d{4}$/);
}
/** Assert ID card is masked: 110101********1234 */
export function assertIdCardMasked(displayed: string): void {
expect(displayed).toMatch(/^\d{6}\*{8}\d{4}$/);
}

View File

@@ -0,0 +1,24 @@
/**
* Vitest browser-mode setup.
* Runs once before all tests.
*/
import { beforeAll, afterEach } from 'vitest';
// Base URL: the Vite dev server proxies /api → localhost:3003
const BASE = 'http://localhost:3002';
beforeAll(() => {
// Ensure we're running against the local dev server
console.log(`[setup] browser integration tests → ${BASE}`);
});
afterEach(() => {
// Clear localStorage between tests to avoid state leakage
// (only clear auth-related keys; keep other state if needed)
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('permissions');
localStorage.removeItem('currentCampusId');
});
export { BASE };

View File

@@ -0,0 +1,31 @@
/**
* Download a file from the API as a blob and trigger a browser download.
*
* @param endpoint - API path (e.g. '/rooms/template')
* @param filename - Suggested filename for the download
*/
export async function downloadBlob(endpoint: string, filename: string): Promise<void> {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
const res = await fetch(`${baseURL}${endpoint}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `下载失败 (HTTP ${res.status})`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,9 @@
export const maskPhone = (phone: string): string => {
if (!phone || phone.length < 7) return phone || '-';
return phone.slice(0, 3) + '****' + phone.slice(-4);
};
export const maskIdNumber = (id: string): string => {
if (!id || id.length < 8) return id || '-';
return id.slice(0, 3) + '***********' + id.slice(-4);
};