feat: refine admin forms, attendance and finance workflows

Squash merge PR #23.

Included changes:
- complete occupancy check-in required fields/default payload
- improve responsive admin management pages
- fix attendance edge cases and attendance period config
- refine wallet/finance-related workflow handling

Checks:
- npm run typecheck -w apps/admin
- npm run typecheck -w apps/server
This commit is contained in:
2026-07-18 12:54:10 +00:00
parent 92d303ed01
commit 375c7ec60b
64 changed files with 5169 additions and 2404 deletions

View File

@@ -15,7 +15,9 @@ const DefaultRoute: React.FC = () => {
})();
const firstPath = findRoleAwareLandingPath(roles, permissions);
if (firstPath) return <Navigate to={firstPath} replace />;
return <Result status="403" title="暂无可访问功能" subTitle="请联系管理员为当前账号分配功能权限" />;
return (
<Result status="403" title="暂无可访问功能" subTitle="请联系管理员为当前账号分配功能权限" />
);
};
export default DefaultRoute;

View File

@@ -34,16 +34,20 @@ const NotificationBell: React.FC = () => {
const fetchNotifications = async () => {
try {
const data = await api.get('/notifications?limit=20') as unknown as NotificationItem[];
const data = (await api.get('/notifications?limit=20')) as unknown as NotificationItem[];
setNotifications(data);
} catch { /* ignore */ }
} catch {
/* ignore */
}
};
const fetchUnread = async () => {
try {
const data = await api.get('/notifications/unread-count') as unknown as { count: number };
const data = (await api.get('/notifications/unread-count')) as unknown as { count: number };
setUnreadCount(data.count);
} catch { /* ignore */ }
} catch {
/* ignore */
}
};
const openRef = useRef(open);
openRef.current = open;
@@ -60,7 +64,9 @@ const NotificationBell: React.FC = () => {
JSON.parse(event.data);
setUnreadCount((c) => c + 1);
if (openRef.current) fetchNotifications();
} catch { /* ignore */ }
} catch {
/* ignore */
}
};
es.onerror = () => {
es.close();
@@ -84,7 +90,9 @@ const NotificationBell: React.FC = () => {
try {
await api.put(`/notifications/${item.id}/read`);
setUnreadCount((c) => Math.max(0, c - 1));
} catch { /* ignore */ }
} catch {
/* ignore */
}
}
setOpen(false);
if (item.link) navigate(item.link);
@@ -94,10 +102,10 @@ const NotificationBell: React.FC = () => {
try {
await api.put('/notifications/read-all');
setUnreadCount(0);
setNotifications((prev) =>
prev.map((n) => ({ ...n, isRead: true })),
);
} catch { /* ignore */ }
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
} catch {
/* ignore */
}
};
const content = (
@@ -148,18 +156,13 @@ const NotificationBell: React.FC = () => {
)
}
title={
<Typography.Text
strong={!item.isRead}
style={{ fontSize: 14 }}
>
[{notificationTypeLabels[item.type] || item.type}] {formatNotificationText(item.title)}
<Typography.Text strong={!item.isRead} style={{ fontSize: 14 }}>
[{notificationTypeLabels[item.type] || item.type}]{' '}
{formatNotificationText(item.title)}
</Typography.Text>
}
description={
<Typography.Text
type="secondary"
style={{ fontSize: 12 }}
>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{timeAgo(item.createdAt)}
</Typography.Text>
}

View File

@@ -25,7 +25,13 @@ const PermissionRoute: React.FC<PermissionRouteProps> = ({ permission, children
status="403"
title="无权访问"
subTitle="您没有访问此页面的权限"
extra={firstPath ? <Button type="primary" onClick={() => navigate(firstPath, { replace: true })}>访</Button> : undefined}
extra={
firstPath ? (
<Button type="primary" onClick={() => navigate(firstPath, { replace: true })}>
访
</Button>
) : undefined
}
/>
);
}

View File

@@ -215,7 +215,9 @@ const getStudentStatus = (value?: string | null): { text: string; color: string
const formatEnrollmentDisplayName = (enrollment: EnrollmentRecord): string =>
enrollment.className ||
(enrollment.courseCategory ? getCourseCategoryLabel(enrollment.courseCategory) : String(enrollment.id));
(enrollment.courseCategory
? getCourseCategoryLabel(enrollment.courseCategory)
: String(enrollment.id));
const ATTACHMENT_CATEGORY_OPTIONS = [
{ value: 'id_card', label: '身份证' },
@@ -253,16 +255,31 @@ const SESSION_LABELS: Record<string, string> = {
const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => {
const columns: ColumnsType<AttendanceRecordItem> = [
{ title: '日期', dataIndex: 'attendanceDate', width: 120 },
{ title: '课程', render: (_: unknown, record) => record.schedule?.subject || record.class?.name || '课程考勤' },
{ title: '时段', dataIndex: 'session', width: 100, render: (value: string) => SESSION_LABELS[value] || value || '-' },
{
title: '结果', dataIndex: 'status', width: 90,
title: '课程',
render: (_: unknown, record) => record.schedule?.subject || record.class?.name || '课程考勤',
},
{
title: '时段',
dataIndex: 'session',
width: 100,
render: (value: string) => SESSION_LABELS[value] || value || '-',
},
{
title: '结果',
dataIndex: 'status',
width: 90,
render: (value: string) => {
const meta = ATTENDANCE_STATUS_MAP[value] || { text: value || '-', color: 'default' };
return <Tag color={meta.color}>{meta.text}</Tag>;
},
},
{ title: '打卡时间', dataIndex: 'punchTime', width: 170, render: (value?: string | null) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-' },
{
title: '打卡时间',
dataIndex: 'punchTime',
width: 170,
render: (value?: string | null) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-'),
},
{
title: '打卡设备',
render: (_: unknown, record) => {
@@ -283,7 +300,9 @@ const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) =>
scroll={{ x: 900 }}
pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50] }}
/>
) : <Empty description="暂无出勤记录" />;
) : (
<Empty description="暂无出勤记录" />
);
};
interface TabProps {
@@ -291,11 +310,11 @@ interface TabProps {
onRefresh: () => void;
}
const ProfileTab: React.FC<{ data: ProfileData | null; studentId: number; onRefresh: () => void }> = ({
data,
studentId,
onRefresh,
}) => {
const ProfileTab: React.FC<{
data: ProfileData | null;
studentId: number;
onRefresh: () => void;
}> = ({ data, studentId, onRefresh }) => {
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
@@ -439,10 +458,18 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item name="courseCategory" label="课程类别" rules={[{ required: true, message: '请选择课程类别' }]}>
<Form.Item
name="courseCategory"
label="课程类别"
rules={[{ required: true, message: '请选择课程类别' }]}
>
<Select options={COURSE_CATEGORY_OPTIONS} placeholder="请选择" />
</Form.Item>
<Form.Item name="classType" label="班型" rules={[{ required: true, message: '请选择班型' }]}>
<Form.Item
name="classType"
label="班型"
rules={[{ required: true, message: '请选择班型' }]}
>
<Select options={CLASS_TYPE_OPTIONS} placeholder="请选择" />
</Form.Item>
<Form.Item name="className" label="班级名称">
@@ -466,12 +493,9 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
);
};
const ExamScoresTab: React.FC<TabProps & { data: ExamScoreRecord[]; enrollments: EnrollmentRecord[] }> = ({
data,
studentId,
enrollments,
onRefresh,
}) => {
const ExamScoresTab: React.FC<
TabProps & { data: ExamScoreRecord[]; enrollments: EnrollmentRecord[] }
> = ({ data, studentId, enrollments, onRefresh }) => {
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
@@ -505,8 +529,16 @@ const ExamScoresTab: React.FC<TabProps & { data: ExamScoreRecord[]; enrollments:
{ title: '考试名称', dataIndex: 'examName', render: (v: string) => v || '-' },
{ title: '科目', dataIndex: 'subject' },
{ title: '成绩', dataIndex: 'score' },
{ title: '班级均分', dataIndex: 'classAvg', render: (v: number | undefined) => (v !== undefined ? v : '-') },
{ title: '排名', dataIndex: 'rank', render: (v: number | undefined) => (v !== undefined ? v : '-') },
{
title: '班级均分',
dataIndex: 'classAvg',
render: (v: number | undefined) => (v !== undefined ? v : '-'),
},
{
title: '排名',
dataIndex: 'rank',
render: (v: number | undefined) => (v !== undefined ? v : '-'),
},
{ title: '考试日期', dataIndex: 'examDate', render: (v: string) => v || '-' },
{
title: '关联报读',
@@ -550,13 +582,21 @@ const ExamScoresTab: React.FC<TabProps & { data: ExamScoreRecord[]; enrollments:
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item name="examType" label="考试类型" rules={[{ required: true, message: '请选择考试类型' }]}>
<Form.Item
name="examType"
label="考试类型"
rules={[{ required: true, message: '请选择考试类型' }]}
>
<Select options={EXAM_TYPE_OPTIONS} placeholder="请选择" />
</Form.Item>
<Form.Item name="examName" label="考试名称">
<Input placeholder="如2024第一次月考" />
</Form.Item>
<Form.Item name="subject" label="科目" rules={[{ required: true, message: '请输入科目' }]}>
<Form.Item
name="subject"
label="科目"
rules={[{ required: true, message: '请输入科目' }]}
>
<Input placeholder="如:数学" />
</Form.Item>
<Form.Item name="score" label="成绩" rules={[{ required: true, message: '请输入成绩' }]}>
@@ -587,7 +627,11 @@ const ExamScoresTab: React.FC<TabProps & { data: ExamScoreRecord[]; enrollments:
);
};
const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({ data, studentId, onRefresh }) => {
const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({
data,
studentId,
onRefresh,
}) => {
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
@@ -655,13 +699,25 @@ const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({ data, st
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item name="recordDate" label="记录日期" rules={[{ required: true, message: '请选择日期' }]}>
<Form.Item
name="recordDate"
label="记录日期"
rules={[{ required: true, message: '请选择日期' }]}
>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="recordType" label="记录类型" rules={[{ required: true, message: '请选择记录类型' }]}>
<Form.Item
name="recordType"
label="记录类型"
rules={[{ required: true, message: '请选择记录类型' }]}
>
<Select options={RECORD_TYPE_OPTIONS} placeholder="请选择" />
</Form.Item>
<Form.Item name="content" label="内容" rules={[{ required: true, message: '请输入内容' }]}>
<Form.Item
name="content"
label="内容"
rules={[{ required: true, message: '请输入内容' }]}
>
<Input.TextArea rows={4} placeholder="请记录学情内容" />
</Form.Item>
<Form.Item name="followUpMethod" label="跟进方式">
@@ -676,7 +732,11 @@ const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({ data, st
);
};
const ResultTab: React.FC<TabProps & { data: ResultData | null }> = ({ data, studentId, onRefresh }) => {
const ResultTab: React.FC<TabProps & { data: ResultData | null }> = ({
data,
studentId,
onRefresh,
}) => {
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
@@ -741,7 +801,11 @@ const ResultTab: React.FC<TabProps & { data: ResultData | null }> = ({ data, stu
);
};
const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({ data, studentId, onRefresh }) => {
const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({
data,
studentId,
onRefresh,
}) => {
const [uploading, setUploading] = useState(false);
const handleDelete = async (attachmentId: number) => {
@@ -802,7 +866,12 @@ const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({ dat
showUploadList={false}
customRequest={async (options) => {
const formData = new FormData();
formData.append('file', options.file instanceof File ? options.file : new File([options.file as Blob], 'attachment'));
formData.append(
'file',
options.file instanceof File
? options.file
: new File([options.file as Blob], 'attachment'),
);
setUploading(true);
try {
await api.post(`/archive/${studentId}/attachments`, formData, {
@@ -883,63 +952,60 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
const tabItems = useMemo(() => {
if (!aggregateData) return [];
const { profile, enrollments, examScores, learningRecords, result, attachments, attendances } = aggregateData;
const { profile, enrollments, examScores, learningRecords, result, attachments, attendances } =
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: `出勤记录 (${attendances.length})`,
children: <AttendanceTab data={attendances} />,
},
{
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]);
{
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: `出勤记录 (${attendances.length})`,
children: <AttendanceTab data={attendances} />,
},
{
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) {
@@ -1000,7 +1066,9 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
</span>
) : '-'}
) : (
'-'
)}
</Descriptions.Item>
<Descriptions.Item label="身份证号">
{student.idNumber ? (
@@ -1010,7 +1078,9 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
</span>
) : '-'}
) : (
'-'
)}
</Descriptions.Item>
<Descriptions.Item label="状态">
{(() => {
@@ -1024,18 +1094,13 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
{profile?.targetMajor && (
<Descriptions.Item label="目标专业">{profile.targetMajor}</Descriptions.Item>
)}
{profile?.grade && (
<Descriptions.Item label="年级">{profile.grade}</Descriptions.Item>
)}
{profile?.grade && <Descriptions.Item label="年级">{profile.grade}</Descriptions.Item>}
{profile?.subjectDirection && (
<Descriptions.Item label="选科方向">{profile.subjectDirection}</Descriptions.Item>
)}
</Descriptions>
<Tabs
defaultActiveKey="profile"
items={tabItems}
/>
<Tabs defaultActiveKey="profile" items={tabItems} />
</div>
);
};