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

@@ -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>
);
};