feat: 学生档案与报告生成
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { DatePicker, Input, InputNumber, Select, Spin, Tooltip } from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import equal from 'fast-deep-equal';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { message } from '../../ui/app-message';
|
||||
import './style.css';
|
||||
import { getErrorMessage } from '../../utils/error';
|
||||
|
||||
export type EditableCellEditor =
|
||||
| 'text'
|
||||
@@ -66,7 +67,7 @@ export function serializeEditableValue(value: unknown, editor: EditableCellEdito
|
||||
}
|
||||
|
||||
export function editableValuesEqual(left: unknown, right: unknown) {
|
||||
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
|
||||
return equal(left ?? null, right ?? null);
|
||||
}
|
||||
|
||||
function isEditorOverlay(target: EventTarget | null) {
|
||||
@@ -112,43 +113,40 @@ const EditableCell = <Value,>({
|
||||
[editor, formatValue, value],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
|
||||
}
|
||||
}, [editing, editor, formatValue, value]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
|
||||
if (activeCell?.id === idRef.current) activeCell = null;
|
||||
setEditing(false);
|
||||
}, [editor, formatValue, value]);
|
||||
|
||||
const saveValue = useCallback(async (nextDraft: unknown) => {
|
||||
if (saving) return false;
|
||||
const serialized = serializeEditableValue(nextDraft, editor);
|
||||
if (required && (serialized === '' || serialized === undefined || serialized === null)) {
|
||||
message.error('该字段不能为空');
|
||||
return false;
|
||||
}
|
||||
if (editableValuesEqual(serialized, original)) {
|
||||
if (activeCell?.id === idRef.current) activeCell = null;
|
||||
setEditing(false);
|
||||
return true;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(parseValue ? parseValue(serialized) : (serialized as Value));
|
||||
if (activeCell?.id === idRef.current) activeCell = null;
|
||||
setEditing(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
message.error((error as { message?: string })?.message || '保存失败');
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [editor, onSave, original, parseValue, required, saving]);
|
||||
const saveValue = useCallback(
|
||||
async (nextDraft: unknown) => {
|
||||
if (saving) return false;
|
||||
const serialized = serializeEditableValue(nextDraft, editor);
|
||||
if (required && (serialized === '' || serialized === undefined || serialized === null)) {
|
||||
message.error('该字段不能为空');
|
||||
return false;
|
||||
}
|
||||
if (editableValuesEqual(serialized, original)) {
|
||||
if (activeCell?.id === idRef.current) activeCell = null;
|
||||
setEditing(false);
|
||||
return true;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(parseValue ? parseValue(serialized) : (serialized as Value));
|
||||
if (activeCell?.id === idRef.current) activeCell = null;
|
||||
setEditing(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, '保存失败'));
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[editor, onSave, original, parseValue, required, saving],
|
||||
);
|
||||
|
||||
const save = useCallback(() => saveValue(draft), [draft, saveValue]);
|
||||
|
||||
@@ -199,6 +197,7 @@ const EditableCell = <Value,>({
|
||||
if (!saved) return;
|
||||
}
|
||||
activeCell = { id: idRef.current, save };
|
||||
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useState } from 'react';
|
||||
import { App, Button, Popconfirm, Space, Table, Upload } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { EyeOutlined, InboxOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { getErrorMessage } from '../../utils/error';
|
||||
import { ATTACHMENT_CATEGORY_OPTIONS, formatFileSize } from './shared';
|
||||
import type { AttachmentRecord, TabProps } from './shared';
|
||||
|
||||
export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({
|
||||
data,
|
||||
studentId,
|
||||
}) => {
|
||||
const { modal } = App.useApp();
|
||||
const { hasPermission } = usePermission();
|
||||
const canPurgeArchive = hasPermission('archive:purge');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const deleteAttachmentMutation = useApiMutation(
|
||||
async (attachmentId: number) => api.delete(`/archive/attachments/${attachmentId}`),
|
||||
{ invalidate: [['archive', studentId]] },
|
||||
);
|
||||
const purgeAttachmentMutation = useApiMutation(
|
||||
async (id: number) => api.delete(`/archive/attachments/${id}/permanent`),
|
||||
{ invalidate: [['archive', studentId]] },
|
||||
);
|
||||
const uploadAttachmentMutation = useApiMutation(
|
||||
async (formData: FormData) =>
|
||||
api.post(`/archive/${studentId}/attachments`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}),
|
||||
{ invalidate: [['archive', studentId]] },
|
||||
);
|
||||
|
||||
const handleDelete = async (attachmentId: number) => {
|
||||
try {
|
||||
await deleteAttachmentMutation.mutateAsync(attachmentId);
|
||||
message.success('已归档');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
};
|
||||
|
||||
const handlePurge = (record: AttachmentRecord) => {
|
||||
modal.confirm({
|
||||
title: `永久删除附件「${record.fileName}」?`,
|
||||
content: '删除后不可恢复,磁盘上的附件文件将被清除。确定继续?',
|
||||
okText: '永久删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await purgeAttachmentMutation.mutateAsync(record.id);
|
||||
message.success('已永久删除(不可恢复)');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnsType<AttachmentRecord> = [
|
||||
{
|
||||
title: '类别',
|
||||
dataIndex: 'category',
|
||||
render: (v: string) => ATTACHMENT_CATEGORY_OPTIONS.find((o) => o.value === v)?.label || v,
|
||||
},
|
||||
{ title: '文件名', dataIndex: 'fileName' },
|
||||
{ title: '大小', dataIndex: 'fileSize', render: formatFileSize },
|
||||
{
|
||||
title: '操作',
|
||||
render: (_: unknown, record: AttachmentRecord) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const blob = await api.get<Blob>(`/archive/${studentId}/attachments/${record.id}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e, '查看失败'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
{hasPermission('student:edit') && record.status !== 'archived' ? (
|
||||
<Popconfirm title="确定归档该附件?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button size="small" danger icon={<InboxOutlined />}>
|
||||
归档
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
{record.status === 'archived' && canPurgeArchive ? (
|
||||
<Button size="small" danger type="link" onClick={() => handlePurge(record)}>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{hasPermission('student:edit') ? (
|
||||
<Upload
|
||||
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'),
|
||||
);
|
||||
setUploading(true);
|
||||
try {
|
||||
await uploadAttachmentMutation.mutateAsync(formData);
|
||||
message.success('上传成功');
|
||||
options.onSuccess?.({});
|
||||
} catch (e) {
|
||||
options.onError?.(e instanceof Error ? e : new Error('上传失败'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={uploading}>
|
||||
上传附件
|
||||
</Button>
|
||||
</Upload>
|
||||
) : null}
|
||||
<Table<AttachmentRecord>
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50],
|
||||
}}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import EditableCell from '../EditableCell';
|
||||
|
||||
/**
|
||||
* 学生档案模块统一的可编辑单元格:
|
||||
* 固定 student:edit 权限,配合各 Tab 的 saveCell 使用。
|
||||
*/
|
||||
export const EditableArchiveCell = <R extends { id: number }>({
|
||||
value,
|
||||
field,
|
||||
record,
|
||||
editor,
|
||||
min,
|
||||
max,
|
||||
required,
|
||||
options,
|
||||
onSave,
|
||||
children,
|
||||
}: {
|
||||
value: unknown;
|
||||
field: string;
|
||||
record: R;
|
||||
editor?: React.ComponentProps<typeof EditableCell>['editor'];
|
||||
min?: number;
|
||||
max?: number;
|
||||
required?: boolean;
|
||||
options?: Array<{ value: string | number; label: string }>;
|
||||
onSave: (record: R, field: string, value: unknown) => Promise<void> | void;
|
||||
children?: React.ReactNode;
|
||||
}) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
editor={editor}
|
||||
min={min}
|
||||
max={max}
|
||||
required={required}
|
||||
options={options}
|
||||
permission="student:edit"
|
||||
onSave={async (next) => {
|
||||
await onSave(record, field, next);
|
||||
}}
|
||||
>
|
||||
{children ?? String(value ?? '-')}
|
||||
</EditableCell>
|
||||
);
|
||||
@@ -0,0 +1,260 @@
|
||||
import React, { useState } from 'react';
|
||||
import { App, Button, DatePicker, Form, Input, Modal, Select, Table, Tag } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import PermissionButton from '../PermissionButton';
|
||||
import { EditableArchiveCell } from './EditableArchiveCell';
|
||||
import { CLASS_TYPE_OPTIONS, COURSE_CATEGORY_OPTIONS, ENROLLMENT_STATUS_MAP, formatEnrollmentDisplayName, getClassTypeLabel, getCourseCategoryLabel, getEnrollmentStatus } from './shared';
|
||||
import type { EnrollmentRecord, TabProps } from './shared';
|
||||
|
||||
const ENROLLMENT_FIELDS = {
|
||||
courseCategory: 'courseCategory',
|
||||
classType: 'classType',
|
||||
className: 'className',
|
||||
headTeacher: 'headTeacher',
|
||||
subjectTeacher: 'subjectTeacher',
|
||||
startDate: 'startDate',
|
||||
endDate: 'endDate',
|
||||
status: 'status',
|
||||
} as const;
|
||||
|
||||
export const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
|
||||
data,
|
||||
studentId,
|
||||
}) => {
|
||||
const { modal } = App.useApp();
|
||||
const { hasPermission } = usePermission();
|
||||
const canPurgeArchive = hasPermission('archive:purge');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const addEnrollmentMutation = useApiMutation(
|
||||
async (payload: Record<string, unknown>) =>
|
||||
api.post(`/archive/${studentId}/enrollments`, payload),
|
||||
{ invalidate: [['archive', studentId]] },
|
||||
);
|
||||
const saveEnrollmentCellMutation = useApiMutation(
|
||||
async ({ id, field, value }: { id: number; field: string; value: unknown }) =>
|
||||
api.put(`/archive/enrollments/${id}`, { [field]: value }),
|
||||
{ invalidate: [['archive', studentId]] },
|
||||
);
|
||||
const purgeEnrollmentMutation = useApiMutation(
|
||||
async (id: number) => api.delete(`/archive/enrollments/${id}/permanent`),
|
||||
{ invalidate: [['archive', studentId]] },
|
||||
);
|
||||
|
||||
const handleAdd = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
await addEnrollmentMutation.mutateAsync({
|
||||
...values,
|
||||
startDate: values.startDate?.format('YYYY-MM-DD'),
|
||||
endDate: values.endDate?.format('YYYY-MM-DD'),
|
||||
});
|
||||
message.success('报读记录已添加');
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveCell = async (record: EnrollmentRecord, field: string, value: unknown) => {
|
||||
try {
|
||||
await saveEnrollmentCellMutation.mutateAsync({ id: record.id, field, value });
|
||||
message.success('报读记录已保存');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
};
|
||||
|
||||
const handlePurge = (record: EnrollmentRecord) => {
|
||||
modal.confirm({
|
||||
title: `永久删除报读记录(${formatEnrollmentDisplayName(record)})?`,
|
||||
content: '删除后不可恢复,被考试成绩引用时将无法删除。确定继续?',
|
||||
okText: '永久删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await purgeEnrollmentMutation.mutateAsync(record.id);
|
||||
message.success('已永久删除(不可恢复)');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnsType<EnrollmentRecord> = [
|
||||
{
|
||||
title: '课程类别',
|
||||
dataIndex: 'courseCategory',
|
||||
render: (v: string, r) => (
|
||||
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.courseCategory} record={r} editor="select" options={COURSE_CATEGORY_OPTIONS} onSave={saveCell}>
|
||||
{getCourseCategoryLabel(v)}
|
||||
</EditableArchiveCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '班型',
|
||||
dataIndex: 'classType',
|
||||
render: (v: string, r) => (
|
||||
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.classType} record={r} editor="select" options={CLASS_TYPE_OPTIONS} onSave={saveCell}>
|
||||
{getClassTypeLabel(v)}
|
||||
</EditableArchiveCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '班级名称',
|
||||
dataIndex: 'className',
|
||||
render: (v: string, r) => (
|
||||
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.className} record={r} onSave={saveCell}>
|
||||
{v || '-'}
|
||||
</EditableArchiveCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '班主任',
|
||||
dataIndex: 'headTeacher',
|
||||
render: (v: string, r) => (
|
||||
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.headTeacher} record={r} onSave={saveCell}>
|
||||
{v || '-'}
|
||||
</EditableArchiveCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '任课教师',
|
||||
dataIndex: 'subjectTeacher',
|
||||
render: (v: string, r) => (
|
||||
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.subjectTeacher} record={r} onSave={saveCell}>
|
||||
{v || '-'}
|
||||
</EditableArchiveCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '开始日期',
|
||||
dataIndex: 'startDate',
|
||||
render: (v: string, r) => (
|
||||
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.startDate} record={r} editor="date" onSave={saveCell}>
|
||||
{v || '-'}
|
||||
</EditableArchiveCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '结束日期',
|
||||
dataIndex: 'endDate',
|
||||
render: (v: string, r) => (
|
||||
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.endDate} record={r} editor="date" onSave={saveCell}>
|
||||
{v || '-'}
|
||||
</EditableArchiveCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (v: string, r) => {
|
||||
const status = getEnrollmentStatus(v);
|
||||
return (
|
||||
<EditableArchiveCell
|
||||
value={v}
|
||||
field={ENROLLMENT_FIELDS.status}
|
||||
record={r}
|
||||
editor="select"
|
||||
options={Object.entries(ENROLLMENT_STATUS_MAP).map(([value, item]) => ({
|
||||
value,
|
||||
label: item.text,
|
||||
}))}
|
||||
onSave={saveCell}
|
||||
>
|
||||
<Tag color={status.color}>{status.text}</Tag>
|
||||
</EditableArchiveCell>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
render: (_: unknown, r: EnrollmentRecord) =>
|
||||
r.status === 'archived' && canPurgeArchive ? (
|
||||
<Button size="small" danger type="link" onClick={() => handlePurge(r)}>
|
||||
删除
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PermissionButton
|
||||
permission="student:edit"
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
添加报读记录
|
||||
</PermissionButton>
|
||||
<Table<EnrollmentRecord>
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50],
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title="添加报读记录"
|
||||
open={modalOpen && hasPermission('student:edit')}
|
||||
onOk={hasPermission('student:edit') ? handleAdd : undefined}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<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: '请选择班型' }]}
|
||||
>
|
||||
<Select options={CLASS_TYPE_OPTIONS} placeholder="请选择" />
|
||||
</Form.Item>
|
||||
<Form.Item name="className" label="班级名称">
|
||||
<Input placeholder="如:2024届冲刺班" />
|
||||
</Form.Item>
|
||||
<Form.Item name="headTeacher" label="班主任">
|
||||
<Input placeholder="班主任姓名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="subjectTeacher" label="任课教师">
|
||||
<Input placeholder="任课教师姓名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="startDate" label="开始日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="endDate" label="结束日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,260 @@
|
||||
import React, { useState } from 'react';
|
||||
import { App, Button, DatePicker, Form, Input, InputNumber, Modal, Select, Table } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import PermissionButton from '../PermissionButton';
|
||||
import { EditableArchiveCell } from './EditableArchiveCell';
|
||||
import { EXAM_TYPE_OPTIONS, formatEnrollmentDisplayName, getClassTypeLabel } from './shared';
|
||||
import type { EnrollmentRecord, ExamScoreRecord, TabProps } from './shared';
|
||||
|
||||
const EXAM_SCORE_FIELDS = {
|
||||
examType: 'examType',
|
||||
examName: 'examName',
|
||||
subject: 'subject',
|
||||
score: 'score',
|
||||
classAvg: 'classAvg',
|
||||
rank: 'rank',
|
||||
examDate: 'examDate',
|
||||
enrollmentId: 'enrollmentId',
|
||||
} as const;
|
||||
|
||||
export const ExamScoresTab: React.FC<
|
||||
TabProps & { data: ExamScoreRecord[]; enrollments: EnrollmentRecord[] }
|
||||
> = ({ data, studentId, enrollments }) => {
|
||||
const { modal } = App.useApp();
|
||||
const { hasPermission } = usePermission();
|
||||
const canPurgeArchive = hasPermission('archive:purge');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const addExamScoreMutation = useApiMutation(
|
||||
async (payload: Record<string, unknown>) =>
|
||||
api.post(`/archive/${studentId}/exam-scores`, payload),
|
||||
{ invalidate: [['archive', studentId]] },
|
||||
);
|
||||
const saveExamScoreCellMutation = useApiMutation(
|
||||
async ({ id, field, value }: { id: number; field: string; value: unknown }) =>
|
||||
api.put(`/archive/exam-scores/${id}`, { [field]: value }),
|
||||
{ invalidate: [['archive', studentId]] },
|
||||
);
|
||||
const purgeExamScoreMutation = useApiMutation(
|
||||
async (id: number) => api.delete(`/archive/exam-scores/${id}/permanent`),
|
||||
{ invalidate: [['archive', studentId]] },
|
||||
);
|
||||
|
||||
const handleAdd = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
await addExamScoreMutation.mutateAsync({
|
||||
...values,
|
||||
examDate: values.examDate?.format('YYYY-MM-DD'),
|
||||
});
|
||||
message.success('考试成绩已添加');
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveCell = async (record: ExamScoreRecord, field: string, value: unknown) => {
|
||||
try {
|
||||
await saveExamScoreCellMutation.mutateAsync({ id: record.id, field, value });
|
||||
message.success('考试成绩已保存');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
};
|
||||
|
||||
const handlePurge = (record: ExamScoreRecord) => {
|
||||
modal.confirm({
|
||||
title: `永久删除考试成绩(${record.examName || record.subject || `记录${record.id}`})?`,
|
||||
content: '删除后不可恢复,成绩记录将被物理删除。确定继续?',
|
||||
okText: '永久删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await purgeExamScoreMutation.mutateAsync(record.id);
|
||||
message.success('已永久删除(不可恢复)');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ExamScoreRecord> = [
|
||||
{
|
||||
title: '考试类型',
|
||||
dataIndex: 'examType',
|
||||
render: (v: string, r) => (
|
||||
<EditableArchiveCell value={v} field={EXAM_SCORE_FIELDS.examType} record={r} editor="select" options={EXAM_TYPE_OPTIONS} onSave={saveCell}>
|
||||
{EXAM_TYPE_OPTIONS.find((o) => o.value === v)?.label || v}
|
||||
</EditableArchiveCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '考试名称',
|
||||
dataIndex: 'examName',
|
||||
render: (v: string, r) => (
|
||||
<EditableArchiveCell value={v} field={EXAM_SCORE_FIELDS.examName} record={r} onSave={saveCell}>
|
||||
{v || '-'}
|
||||
</EditableArchiveCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '科目',
|
||||
dataIndex: 'subject',
|
||||
render: (v: string, r) => (
|
||||
<EditableArchiveCell value={v} field={EXAM_SCORE_FIELDS.subject} record={r} required onSave={saveCell}>
|
||||
{v}
|
||||
</EditableArchiveCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '成绩',
|
||||
dataIndex: 'score',
|
||||
render: (v: number | null, r) => (
|
||||
<EditableArchiveCell value={v ?? undefined} field={EXAM_SCORE_FIELDS.score} record={r} editor="number" min={0} onSave={saveCell}>
|
||||
{v ?? '-'}
|
||||
</EditableArchiveCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '班级均分',
|
||||
dataIndex: 'classAvg',
|
||||
render: (v: number | undefined, r) => (
|
||||
<EditableArchiveCell value={v} field={EXAM_SCORE_FIELDS.classAvg} record={r} editor="number" min={0} onSave={saveCell}>
|
||||
{v !== undefined ? v : '-'}
|
||||
</EditableArchiveCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '排名',
|
||||
dataIndex: 'rank',
|
||||
render: (v: number | undefined, r) => (
|
||||
<EditableArchiveCell value={v} field={EXAM_SCORE_FIELDS.rank} record={r} editor="number" min={1} onSave={saveCell}>
|
||||
{v !== undefined ? v : '-'}
|
||||
</EditableArchiveCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '考试日期',
|
||||
dataIndex: 'examDate',
|
||||
render: (v: string, r) => (
|
||||
<EditableArchiveCell value={v} field={EXAM_SCORE_FIELDS.examDate} record={r} editor="date" onSave={saveCell}>
|
||||
{v || '-'}
|
||||
</EditableArchiveCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '关联报读',
|
||||
dataIndex: 'enrollmentId',
|
||||
render: (v: number | undefined, r) => (
|
||||
<EditableArchiveCell value={v} field={EXAM_SCORE_FIELDS.enrollmentId} record={r} editor="select" options={enrollments.map((item) => ({ value: item.id, label: formatEnrollmentDisplayName(item), }))} onSave={saveCell}>
|
||||
{(() => {
|
||||
if (r.examId) return r.exam?.class?.name || '-';
|
||||
if (v === undefined) return '-';
|
||||
const enr = enrollments.find((e) => e.id === v);
|
||||
return enr ? formatEnrollmentDisplayName(enr) : String(v);
|
||||
})()}
|
||||
</EditableArchiveCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
render: (_: unknown, r: ExamScoreRecord) =>
|
||||
r.status === 'archived' && canPurgeArchive ? (
|
||||
<Button size="small" danger type="link" onClick={() => handlePurge(r)}>
|
||||
删除
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PermissionButton
|
||||
permission="student:edit"
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
添加考试成绩
|
||||
</PermissionButton>
|
||||
<Table<ExamScoreRecord>
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50],
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title="添加考试成绩"
|
||||
open={modalOpen && hasPermission('student:edit')}
|
||||
onOk={hasPermission('student:edit') ? handleAdd : undefined}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<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: '请输入科目' }]}
|
||||
>
|
||||
<Input placeholder="如:数学" />
|
||||
</Form.Item>
|
||||
<Form.Item name="score" label="成绩" rules={[{ required: true, message: '请输入成绩' }]}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="classAvg" label="班级均分">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="rank" label="排名">
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="examDate" label="考试日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="enrollmentId" label="关联报读">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="选择关联的报读记录"
|
||||
options={enrollments.map((e) => ({
|
||||
value: e.id,
|
||||
label: `${formatEnrollmentDisplayName(e)}(${getClassTypeLabel(e.classType)})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
215
apps/admin/src/components/StudentProfileContent/LearningTab.tsx
Normal file
215
apps/admin/src/components/StudentProfileContent/LearningTab.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState } from 'react';
|
||||
import { App, Button, DatePicker, Form, Input, Modal, Select, Table } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import PermissionButton from '../PermissionButton';
|
||||
import { EditableArchiveCell } from './EditableArchiveCell';
|
||||
import { RECORD_TYPE_OPTIONS } from './shared';
|
||||
import type { LearningRecord, TabProps } from './shared';
|
||||
|
||||
const LEARNING_FIELDS = {
|
||||
recordDate: 'recordDate',
|
||||
recordType: 'recordType',
|
||||
content: 'content',
|
||||
followUpMethod: 'followUpMethod',
|
||||
nextStep: 'nextStep',
|
||||
} as const;
|
||||
|
||||
export const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({
|
||||
data,
|
||||
studentId,
|
||||
}) => {
|
||||
const { modal } = App.useApp();
|
||||
const { hasPermission } = usePermission();
|
||||
const canPurgeArchive = hasPermission('archive:purge');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const addLearningMutation = useApiMutation(
|
||||
async (payload: Record<string, unknown>) =>
|
||||
api.post(`/archive/${studentId}/learning-records`, payload),
|
||||
{ invalidate: [['archive', studentId]] },
|
||||
);
|
||||
const saveLearningCellMutation = useApiMutation(
|
||||
async ({ id, field, value }: { id: number; field: string; value: unknown }) =>
|
||||
api.put(`/archive/learning-records/${id}`, { [field]: value }),
|
||||
{ invalidate: [['archive', studentId]] },
|
||||
);
|
||||
const purgeLearningMutation = useApiMutation(
|
||||
async (id: number) => api.delete(`/archive/learning-records/${id}/permanent`),
|
||||
{ invalidate: [['archive', studentId]] },
|
||||
);
|
||||
|
||||
const handleAdd = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
await addLearningMutation.mutateAsync({
|
||||
...values,
|
||||
recordDate: values.recordDate?.format('YYYY-MM-DD'),
|
||||
});
|
||||
message.success('学情记录已添加');
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveCell = async (record: LearningRecord, field: string, value: unknown) => {
|
||||
try {
|
||||
await saveLearningCellMutation.mutateAsync({ id: record.id, field, value });
|
||||
message.success('学情记录已保存');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
};
|
||||
|
||||
const handlePurge = (record: LearningRecord) => {
|
||||
modal.confirm({
|
||||
title: `永久删除学情记录(${record.recordType || `记录${record.id}`})?`,
|
||||
content: '删除后不可恢复,学习记录将被物理删除。确定继续?',
|
||||
okText: '永久删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await purgeLearningMutation.mutateAsync(record.id);
|
||||
message.success('已永久删除(不可恢复)');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnsType<LearningRecord> = [
|
||||
{
|
||||
title: '记录日期',
|
||||
dataIndex: 'recordDate',
|
||||
render: (v: string, r) => (
|
||||
<EditableArchiveCell value={v} field={LEARNING_FIELDS.recordDate} record={r} editor="date" onSave={saveCell}>
|
||||
{v}
|
||||
</EditableArchiveCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '记录类型',
|
||||
dataIndex: 'recordType',
|
||||
render: (v: string, r) => (
|
||||
<EditableArchiveCell value={v} field={LEARNING_FIELDS.recordType} record={r} editor="select" options={RECORD_TYPE_OPTIONS} onSave={saveCell}>
|
||||
{RECORD_TYPE_OPTIONS.find((o) => o.value === v)?.label || v}
|
||||
</EditableArchiveCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '内容',
|
||||
dataIndex: 'content',
|
||||
ellipsis: true,
|
||||
render: (v: string, r) => (
|
||||
<EditableArchiveCell value={v} field={LEARNING_FIELDS.content} record={r} editor="textarea" onSave={saveCell}>
|
||||
{v}
|
||||
</EditableArchiveCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '跟进方式',
|
||||
dataIndex: 'followUpMethod',
|
||||
render: (v: string, r) => (
|
||||
<EditableArchiveCell value={v} field={LEARNING_FIELDS.followUpMethod} record={r} onSave={saveCell}>
|
||||
{v || '-'}
|
||||
</EditableArchiveCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '下一步计划',
|
||||
dataIndex: 'nextStep',
|
||||
render: (v: string, r) => (
|
||||
<EditableArchiveCell value={v} field={LEARNING_FIELDS.nextStep} record={r} editor="textarea" onSave={saveCell}>
|
||||
{v || '-'}
|
||||
</EditableArchiveCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
render: (_: unknown, r: LearningRecord) =>
|
||||
r.status === 'archived' && canPurgeArchive ? (
|
||||
<Button size="small" danger type="link" onClick={() => handlePurge(r)}>
|
||||
删除
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PermissionButton
|
||||
permission="student:edit"
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
添加学情记录
|
||||
</PermissionButton>
|
||||
<Table<LearningRecord>
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50],
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title="添加学情记录"
|
||||
open={modalOpen && hasPermission('student:edit')}
|
||||
onOk={hasPermission('student:edit') ? handleAdd : undefined}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="recordDate"
|
||||
label="记录日期"
|
||||
rules={[{ required: true, message: '请选择日期' }]}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<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: '请输入内容' }]}
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="请记录学情内容" />
|
||||
</Form.Item>
|
||||
<Form.Item name="followUpMethod" label="跟进方式">
|
||||
<Input placeholder="如:电话、微信、面谈" />
|
||||
</Form.Item>
|
||||
<Form.Item name="nextStep" label="下一步计划">
|
||||
<Input placeholder="后续跟进计划" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
216
apps/admin/src/components/StudentProfileContent/shared.ts
Normal file
216
apps/admin/src/components/StudentProfileContent/shared.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
export interface StudentInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
phone: string;
|
||||
idNumber: string;
|
||||
studentNo: string;
|
||||
gender?: string;
|
||||
ethnicity?: string;
|
||||
emergencyContact?: string;
|
||||
emergencyPhone?: string;
|
||||
organizationId?: number;
|
||||
organization?: { id?: number; name?: string } | null;
|
||||
supervisor?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ProfileData {
|
||||
targetCollege?: string;
|
||||
targetMajor?: string;
|
||||
collegeSchool?: string;
|
||||
collegeMajor?: string;
|
||||
subjectDirection?: string;
|
||||
grade?: string;
|
||||
profileDate?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface EnrollmentRecord {
|
||||
id: number;
|
||||
courseCategory: string;
|
||||
classType: string;
|
||||
className?: string;
|
||||
headTeacher?: string;
|
||||
subjectTeacher?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ExamScoreRecord {
|
||||
id: number;
|
||||
status?: string;
|
||||
examId?: number;
|
||||
exam?: { class?: { name?: string } };
|
||||
examType: string;
|
||||
examName?: string;
|
||||
subject: string;
|
||||
score: number | null;
|
||||
classAvg?: number;
|
||||
rank?: number;
|
||||
examDate?: string;
|
||||
enrollmentId?: number;
|
||||
}
|
||||
|
||||
export interface LearningRecord {
|
||||
id: number;
|
||||
status?: string;
|
||||
recordDate: string;
|
||||
recordType: string;
|
||||
content: string;
|
||||
followUpMethod?: string;
|
||||
nextStep?: string;
|
||||
}
|
||||
|
||||
export interface ResultData {
|
||||
cultureFinalScore?: number;
|
||||
professionalFinalScore?: number;
|
||||
admissionStatus?: string;
|
||||
admittedCollege?: string;
|
||||
admittedMajor?: string;
|
||||
}
|
||||
|
||||
export interface AttachmentRecord {
|
||||
id: number;
|
||||
status?: string;
|
||||
category: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
export interface AttendanceRecordItem {
|
||||
id: number;
|
||||
attendanceDate: string;
|
||||
session: string;
|
||||
status: string;
|
||||
source?: string;
|
||||
remark?: string | null;
|
||||
punchTime?: string | null;
|
||||
punchDeviceName?: string | null;
|
||||
punchDeviceId?: string | null;
|
||||
schedule?: { subject?: string } | null;
|
||||
class?: { name?: string } | null;
|
||||
}
|
||||
|
||||
export interface StudentProfileAggregate {
|
||||
student: StudentInfo;
|
||||
profile: ProfileData | null;
|
||||
enrollments: EnrollmentRecord[];
|
||||
examScores: ExamScoreRecord[];
|
||||
learningRecords: LearningRecord[];
|
||||
result: ResultData | null;
|
||||
attachments: AttachmentRecord[];
|
||||
attendances: AttendanceRecordItem[];
|
||||
}
|
||||
|
||||
export interface StudentProfileContentProps {
|
||||
studentId: number;
|
||||
inDrawer?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const ADMISSION_STATUS_MAP: Record<string, { text: string; color: string }> = {
|
||||
admitted: { text: '已录取', color: 'green' },
|
||||
pending: { text: '待录取', color: 'orange' },
|
||||
rejected: { text: '未录取', color: 'red' },
|
||||
withdrawn: { text: '放弃', color: '#999' },
|
||||
};
|
||||
|
||||
export const EXAM_TYPE_OPTIONS = [
|
||||
{ value: 'monthly', label: '月考' },
|
||||
{ value: 'midterm', label: '期中' },
|
||||
{ value: 'final', label: '期末' },
|
||||
{ value: 'mock', label: '模拟考' },
|
||||
{ value: 'entrance', label: '入学测试' },
|
||||
{ value: 'other', label: '其他' },
|
||||
];
|
||||
|
||||
export const RECORD_TYPE_OPTIONS = [
|
||||
{ value: 'study_feedback', label: '学习反馈' },
|
||||
{ value: 'parent_communication', label: '家长沟通' },
|
||||
{ value: 'behavior_note', label: '行为记录' },
|
||||
{ value: 'meeting', label: '会议记录' },
|
||||
{ value: 'other', label: '其他' },
|
||||
];
|
||||
|
||||
export const ENROLLMENT_STATUS_MAP: Record<string, { text: string; color: string }> = {
|
||||
active: { text: '报读中', color: 'green' },
|
||||
completed: { text: '已结课', color: 'blue' },
|
||||
withdrawn: { text: '已退训', color: 'red' },
|
||||
archived: { text: '已归档', color: '#999' },
|
||||
};
|
||||
|
||||
export const COURSE_CATEGORY_OPTIONS = [
|
||||
{ value: 'culture', label: '文化课' },
|
||||
{ value: 'professional', label: '专业课' },
|
||||
{ value: 'comprehensive', label: '综合' },
|
||||
];
|
||||
|
||||
export const CLASS_TYPE_OPTIONS = [
|
||||
{ value: 'one_on_one', label: '一对一' },
|
||||
{ value: 'small_group', label: '小班' },
|
||||
{ value: 'large_class', label: '大班' },
|
||||
{ value: 'online', label: '线上' },
|
||||
{ value: 'offline', label: '线下' },
|
||||
];
|
||||
|
||||
export const getOptionLabel = (
|
||||
options: Array<{ value: string; label: string }>,
|
||||
value?: string | null,
|
||||
): string => {
|
||||
if (!value) return '-';
|
||||
return options.find((option) => option.value === value)?.label || value;
|
||||
};
|
||||
|
||||
export const getCourseCategoryLabel = (value?: string | null): string =>
|
||||
getOptionLabel(COURSE_CATEGORY_OPTIONS, value);
|
||||
|
||||
export const getClassTypeLabel = (value?: string | null): string =>
|
||||
getOptionLabel(CLASS_TYPE_OPTIONS, value);
|
||||
|
||||
export const getEnrollmentStatus = (value?: string | null): { text: string; color: string } => {
|
||||
if (!value) return { text: '-', color: 'default' };
|
||||
return ENROLLMENT_STATUS_MAP[value] || { text: value, color: 'default' };
|
||||
};
|
||||
|
||||
export const formatEnrollmentDisplayName = (enrollment: EnrollmentRecord): string =>
|
||||
enrollment.className ||
|
||||
(enrollment.courseCategory
|
||||
? getCourseCategoryLabel(enrollment.courseCategory)
|
||||
: String(enrollment.id));
|
||||
|
||||
export const ATTACHMENT_CATEGORY_OPTIONS = [
|
||||
{ value: 'id_card', label: '身份证' },
|
||||
{ value: 'transcript', label: '成绩单' },
|
||||
{ value: 'certificate', label: '证书' },
|
||||
{ value: 'contract', label: '合同' },
|
||||
{ value: 'photo', label: '照片' },
|
||||
{ value: 'other', label: '其他' },
|
||||
];
|
||||
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
export const ATTENDANCE_STATUS_MAP: Record<string, { text: string; color: string }> = {
|
||||
present: { text: '出勤', color: 'green' },
|
||||
late: { text: '迟到', color: 'orange' },
|
||||
absent: { text: '缺勤', color: 'red' },
|
||||
leave: { text: '请假', color: 'blue' },
|
||||
pending: { text: '待确认', color: 'default' },
|
||||
};
|
||||
|
||||
export const SESSION_LABELS: Record<string, string> = {
|
||||
morning_reading: '早自习',
|
||||
morning: '上午',
|
||||
afternoon: '下午',
|
||||
evening_study: '晚自习',
|
||||
night_check: '晚寝',
|
||||
};
|
||||
|
||||
export interface TabProps {
|
||||
studentId: number;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { Card, Button, Space } from 'antd';
|
||||
import { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import StudentProfileContent from '../../components/StudentProfileContent';
|
||||
|
||||
@@ -35,22 +35,47 @@ describe('归档数据视图', () => {
|
||||
});
|
||||
|
||||
it('正常与归档视图的批量动作互斥,且归档视图只读', () => {
|
||||
expect(archiveViewPolicy('active')).toEqual({ batchAction: 'archive', readonly: false });
|
||||
expect(archiveViewPolicy('archived')).toEqual({ batchAction: 'restore', readonly: true });
|
||||
expect(archiveViewPolicy('active')).toEqual({
|
||||
batchAction: 'archive',
|
||||
readonly: false,
|
||||
purgeBatch: false,
|
||||
});
|
||||
expect(archiveViewPolicy('archived')).toEqual({
|
||||
batchAction: 'restore',
|
||||
readonly: true,
|
||||
purgeBatch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('入住三态分别只提供退宿、归档和恢复动作', () => {
|
||||
expect(occupancyViewPolicy('active')).toEqual({
|
||||
batchAction: 'checkout',
|
||||
readonly: false,
|
||||
purgeBatch: false,
|
||||
});
|
||||
expect(occupancyViewPolicy('all')).toEqual({
|
||||
batchAction: 'archive',
|
||||
readonly: false,
|
||||
purgeBatch: false,
|
||||
});
|
||||
expect(occupancyViewPolicy('all')).toEqual({ batchAction: 'archive', readonly: false });
|
||||
expect(occupancyViewPolicy('archived')).toEqual({
|
||||
batchAction: 'restore',
|
||||
readonly: true,
|
||||
purgeBatch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('批量删除只出现在归档视图,且与批量恢复互斥', () => {
|
||||
expect(archiveViewPolicy('active').purgeBatch).toBe(false);
|
||||
expect(archiveViewPolicy('archived').purgeBatch).toBe(true);
|
||||
expect(occupancyViewPolicy('active').purgeBatch).toBe(false);
|
||||
expect(occupancyViewPolicy('all').purgeBatch).toBe(false);
|
||||
expect(occupancyViewPolicy('archived').purgeBatch).toBe(true);
|
||||
// 归档视图中批量动作固定为恢复,不会同时出现归档;批量删除只在归档视图开启
|
||||
expect(archiveViewPolicy('archived').batchAction).toBe('restore');
|
||||
expect(occupancyViewPolicy('archived').batchAction).toBe('restore');
|
||||
});
|
||||
|
||||
it('只有实际切换视图时才要求清空选择', () => {
|
||||
expect(shouldClearSelectionOnViewChange('active', 'archived')).toBe(true);
|
||||
expect(shouldClearSelectionOnViewChange('archived', 'archived')).toBe(false);
|
||||
|
||||
@@ -5,6 +5,8 @@ export type BatchAction = 'archive' | 'restore' | 'checkout';
|
||||
export interface ViewPolicy {
|
||||
batchAction: BatchAction;
|
||||
readonly: boolean;
|
||||
/** 批量永久删除只在已归档视图中出现,与批量恢复互斥 */
|
||||
purgeBatch: boolean;
|
||||
}
|
||||
|
||||
export const selectArchiveRecords = <T extends { status?: string }>(
|
||||
@@ -20,11 +22,13 @@ export const expenseStatusForView = (view: ArchiveView) => view;
|
||||
export const archiveViewPolicy = (view: ArchiveView): ViewPolicy => ({
|
||||
batchAction: view === 'archived' ? 'restore' : 'archive',
|
||||
readonly: view === 'archived',
|
||||
purgeBatch: view === 'archived',
|
||||
});
|
||||
|
||||
export const occupancyViewPolicy = (view: OccupancyView): ViewPolicy => ({
|
||||
batchAction: view === 'active' ? 'checkout' : view === 'all' ? 'archive' : 'restore',
|
||||
readonly: view === 'archived',
|
||||
purgeBatch: view === 'archived',
|
||||
});
|
||||
|
||||
export const shouldClearSelectionOnViewChange = <T extends string>(current: T, next: T) =>
|
||||
|
||||
Reference in New Issue
Block a user