feat: 学生档案与报告生成
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { DatePicker, Input, InputNumber, Select, Spin, Tooltip } from 'antd';
|
import { DatePicker, Input, InputNumber, Select, Spin, Tooltip } from 'antd';
|
||||||
import type { Dayjs } from 'dayjs';
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
import dayjs from 'dayjs';
|
import equal from 'fast-deep-equal';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
import { getErrorMessage } from '../../utils/error';
|
||||||
|
|
||||||
export type EditableCellEditor =
|
export type EditableCellEditor =
|
||||||
| 'text'
|
| 'text'
|
||||||
@@ -66,7 +67,7 @@ export function serializeEditableValue(value: unknown, editor: EditableCellEdito
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function editableValuesEqual(left: unknown, right: unknown) {
|
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) {
|
function isEditorOverlay(target: EventTarget | null) {
|
||||||
@@ -112,43 +113,40 @@ const EditableCell = <Value,>({
|
|||||||
[editor, formatValue, value],
|
[editor, formatValue, value],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editing) {
|
|
||||||
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
|
|
||||||
}
|
|
||||||
}, [editing, editor, formatValue, value]);
|
|
||||||
|
|
||||||
const cancel = useCallback(() => {
|
const cancel = useCallback(() => {
|
||||||
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
|
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
|
||||||
if (activeCell?.id === idRef.current) activeCell = null;
|
if (activeCell?.id === idRef.current) activeCell = null;
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
}, [editor, formatValue, value]);
|
}, [editor, formatValue, value]);
|
||||||
|
|
||||||
const saveValue = useCallback(async (nextDraft: unknown) => {
|
const saveValue = useCallback(
|
||||||
if (saving) return false;
|
async (nextDraft: unknown) => {
|
||||||
const serialized = serializeEditableValue(nextDraft, editor);
|
if (saving) return false;
|
||||||
if (required && (serialized === '' || serialized === undefined || serialized === null)) {
|
const serialized = serializeEditableValue(nextDraft, editor);
|
||||||
message.error('该字段不能为空');
|
if (required && (serialized === '' || serialized === undefined || serialized === null)) {
|
||||||
return false;
|
message.error('该字段不能为空');
|
||||||
}
|
return false;
|
||||||
if (editableValuesEqual(serialized, original)) {
|
}
|
||||||
if (activeCell?.id === idRef.current) activeCell = null;
|
if (editableValuesEqual(serialized, original)) {
|
||||||
setEditing(false);
|
if (activeCell?.id === idRef.current) activeCell = null;
|
||||||
return true;
|
setEditing(false);
|
||||||
}
|
return true;
|
||||||
setSaving(true);
|
}
|
||||||
try {
|
setSaving(true);
|
||||||
await onSave(parseValue ? parseValue(serialized) : (serialized as Value));
|
try {
|
||||||
if (activeCell?.id === idRef.current) activeCell = null;
|
await onSave(parseValue ? parseValue(serialized) : (serialized as Value));
|
||||||
setEditing(false);
|
if (activeCell?.id === idRef.current) activeCell = null;
|
||||||
return true;
|
setEditing(false);
|
||||||
} catch (error) {
|
return true;
|
||||||
message.error((error as { message?: string })?.message || '保存失败');
|
} catch (error) {
|
||||||
return false;
|
message.error(getErrorMessage(error, '保存失败'));
|
||||||
} finally {
|
return false;
|
||||||
setSaving(false);
|
} finally {
|
||||||
}
|
setSaving(false);
|
||||||
}, [editor, onSave, original, parseValue, required, saving]);
|
}
|
||||||
|
},
|
||||||
|
[editor, onSave, original, parseValue, required, saving],
|
||||||
|
);
|
||||||
|
|
||||||
const save = useCallback(() => saveValue(draft), [draft, saveValue]);
|
const save = useCallback(() => saveValue(draft), [draft, saveValue]);
|
||||||
|
|
||||||
@@ -199,6 +197,7 @@ const EditableCell = <Value,>({
|
|||||||
if (!saved) return;
|
if (!saved) return;
|
||||||
}
|
}
|
||||||
activeCell = { id: idRef.current, save };
|
activeCell = { id: idRef.current, save };
|
||||||
|
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
|
||||||
setEditing(true);
|
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 React, { useCallback } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { Card, Button, Space } from 'antd';
|
import { Card, Button, Space } from 'antd';
|
||||||
import { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';
|
import { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';
|
||||||
import StudentProfileContent from '../../components/StudentProfileContent';
|
import StudentProfileContent from '../../components/StudentProfileContent';
|
||||||
|
|||||||
@@ -35,22 +35,47 @@ describe('归档数据视图', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('正常与归档视图的批量动作互斥,且归档视图只读', () => {
|
it('正常与归档视图的批量动作互斥,且归档视图只读', () => {
|
||||||
expect(archiveViewPolicy('active')).toEqual({ batchAction: 'archive', readonly: false });
|
expect(archiveViewPolicy('active')).toEqual({
|
||||||
expect(archiveViewPolicy('archived')).toEqual({ batchAction: 'restore', readonly: true });
|
batchAction: 'archive',
|
||||||
|
readonly: false,
|
||||||
|
purgeBatch: false,
|
||||||
|
});
|
||||||
|
expect(archiveViewPolicy('archived')).toEqual({
|
||||||
|
batchAction: 'restore',
|
||||||
|
readonly: true,
|
||||||
|
purgeBatch: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('入住三态分别只提供退宿、归档和恢复动作', () => {
|
it('入住三态分别只提供退宿、归档和恢复动作', () => {
|
||||||
expect(occupancyViewPolicy('active')).toEqual({
|
expect(occupancyViewPolicy('active')).toEqual({
|
||||||
batchAction: 'checkout',
|
batchAction: 'checkout',
|
||||||
readonly: false,
|
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({
|
expect(occupancyViewPolicy('archived')).toEqual({
|
||||||
batchAction: 'restore',
|
batchAction: 'restore',
|
||||||
readonly: true,
|
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('只有实际切换视图时才要求清空选择', () => {
|
it('只有实际切换视图时才要求清空选择', () => {
|
||||||
expect(shouldClearSelectionOnViewChange('active', 'archived')).toBe(true);
|
expect(shouldClearSelectionOnViewChange('active', 'archived')).toBe(true);
|
||||||
expect(shouldClearSelectionOnViewChange('archived', 'archived')).toBe(false);
|
expect(shouldClearSelectionOnViewChange('archived', 'archived')).toBe(false);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export type BatchAction = 'archive' | 'restore' | 'checkout';
|
|||||||
export interface ViewPolicy {
|
export interface ViewPolicy {
|
||||||
batchAction: BatchAction;
|
batchAction: BatchAction;
|
||||||
readonly: boolean;
|
readonly: boolean;
|
||||||
|
/** 批量永久删除只在已归档视图中出现,与批量恢复互斥 */
|
||||||
|
purgeBatch: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const selectArchiveRecords = <T extends { status?: string }>(
|
export const selectArchiveRecords = <T extends { status?: string }>(
|
||||||
@@ -20,11 +22,13 @@ export const expenseStatusForView = (view: ArchiveView) => view;
|
|||||||
export const archiveViewPolicy = (view: ArchiveView): ViewPolicy => ({
|
export const archiveViewPolicy = (view: ArchiveView): ViewPolicy => ({
|
||||||
batchAction: view === 'archived' ? 'restore' : 'archive',
|
batchAction: view === 'archived' ? 'restore' : 'archive',
|
||||||
readonly: view === 'archived',
|
readonly: view === 'archived',
|
||||||
|
purgeBatch: view === 'archived',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const occupancyViewPolicy = (view: OccupancyView): ViewPolicy => ({
|
export const occupancyViewPolicy = (view: OccupancyView): ViewPolicy => ({
|
||||||
batchAction: view === 'active' ? 'checkout' : view === 'all' ? 'archive' : 'restore',
|
batchAction: view === 'active' ? 'checkout' : view === 'all' ? 'archive' : 'restore',
|
||||||
readonly: view === 'archived',
|
readonly: view === 'archived',
|
||||||
|
purgeBatch: view === 'archived',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const shouldClearSelectionOnViewChange = <T extends string>(current: T, next: T) =>
|
export const shouldClearSelectionOnViewChange = <T extends string>(current: T, next: T) =>
|
||||||
|
|||||||
165
apps/server/src/archive/archive-report.attendance.ts
Normal file
165
apps/server/src/archive/archive-report.attendance.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||||
|
import { esc, pageFrame, pageFooter, pageHeader } from './archive-report.helpers';
|
||||||
|
|
||||||
|
export function buildAttendance(records: AttendanceRecord[], now: string): string {
|
||||||
|
const present = records.filter((r) => r.status === 'present').length;
|
||||||
|
const absent = records.filter((r) => r.status === 'absent').length;
|
||||||
|
const late = records.filter((r) => r.status === 'late').length;
|
||||||
|
const leave = records.filter((r) => r.status === 'leave').length;
|
||||||
|
const total = records.length;
|
||||||
|
const rate = total > 0 ? ((present / total) * 100).toFixed(1) : '0';
|
||||||
|
|
||||||
|
const metricHtml = `
|
||||||
|
<div class="title-row">
|
||||||
|
<div>
|
||||||
|
<div class="source">${esc(now)} · 系统生成</div>
|
||||||
|
<div class="section-title">出勤记录</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid-4" style="margin-bottom:14px;">
|
||||||
|
<div class="metric">
|
||||||
|
<div class="label">总考勤次数</div>
|
||||||
|
<strong>${total}</strong>
|
||||||
|
<p>累计记录</p>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="label">出勤率</div>
|
||||||
|
<strong>${esc(rate)}%</strong>
|
||||||
|
<p>出勤: ${present} 次</p>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="label">缺勤 / 迟到</div>
|
||||||
|
<strong>${absent} / ${late}</strong>
|
||||||
|
<p>缺勤 ${absent} · 迟到 ${late}</p>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="label">请假</div>
|
||||||
|
<strong>${leave}</strong>
|
||||||
|
<p>累计请假次数</p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const chart = renderAttendanceBar(records);
|
||||||
|
const matrix = renderAttendanceMatrix(records);
|
||||||
|
|
||||||
|
let extraHtml = '';
|
||||||
|
if (records.length === 0) {
|
||||||
|
extraHtml = '<div class="banner-note">暂无出勤记录</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageFrame(`
|
||||||
|
${pageHeader('出勤记录')}
|
||||||
|
${metricHtml}
|
||||||
|
${extraHtml}
|
||||||
|
${chart}
|
||||||
|
${matrix}
|
||||||
|
${pageFooter()}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderAttendanceBar(records: AttendanceRecord[]): string {
|
||||||
|
if (records.length === 0) return '';
|
||||||
|
|
||||||
|
const statuses = ['present', 'absent', 'late', 'leave'] as const;
|
||||||
|
const counts = statuses.map((s) => records.filter((r) => r.status === s).length);
|
||||||
|
const labels = ['出勤', '缺勤', '迟到', '请假'];
|
||||||
|
const colors = ['#18a77d', '#dc2626', '#f59e0b', '#f15b75'];
|
||||||
|
const maxCount = Math.max(...counts, 1);
|
||||||
|
|
||||||
|
const w = 600;
|
||||||
|
const h = 150;
|
||||||
|
const pad = { top: 20, right: 20, bottom: 30, left: 40 };
|
||||||
|
const plotW = w - pad.left - pad.right;
|
||||||
|
const plotH = h - pad.top - pad.bottom;
|
||||||
|
const barGap = 30;
|
||||||
|
const barW = (plotW - barGap * (statuses.length - 1)) / statuses.length;
|
||||||
|
|
||||||
|
const scaleH = (v: number): number => (v / maxCount) * plotH;
|
||||||
|
|
||||||
|
let bars = '';
|
||||||
|
for (let i = 0; i < statuses.length; i++) {
|
||||||
|
const x = pad.left + i * (barW + barGap);
|
||||||
|
const bh = scaleH(counts[i]);
|
||||||
|
const y = pad.top + plotH - bh;
|
||||||
|
bars += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${bh.toFixed(1)}" fill="${colors[i]}" rx="4"/>`;
|
||||||
|
bars += `<text x="${(x + barW / 2).toFixed(1)}" y="${(y - 6).toFixed(1)}" text-anchor="middle" fill="#101828" font-size="12" font-weight="700">${counts[i]}</text>`;
|
||||||
|
bars += `<text x="${(x + barW / 2).toFixed(1)}" y="${(h - 8).toFixed(1)}" text-anchor="middle" fill="#667085" font-size="11">${labels[i]}</text>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Y-axis grid
|
||||||
|
const ySteps = 4;
|
||||||
|
let yGrid = '';
|
||||||
|
for (let i = 0; i <= ySteps; i++) {
|
||||||
|
const val = Math.round((maxCount * i) / ySteps);
|
||||||
|
const y = pad.top + plotH - (plotH * i) / ySteps;
|
||||||
|
yGrid += `<text x="${pad.left - 6}" y="${y + 4}" text-anchor="end" fill="#667085" font-size="10">${val}</text>`;
|
||||||
|
if (i < ySteps) {
|
||||||
|
yGrid += `<line x1="${pad.left}" y1="${y}" x2="${w - pad.right}" y2="${y}" stroke="#eaf3fd" stroke-width="1"/>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="card" style="margin-bottom:14px;">
|
||||||
|
<h3>出勤统计</h3>
|
||||||
|
<svg class="bar-chart" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="0" y="0" width="${w}" height="${h}" fill="#fff"/>
|
||||||
|
${yGrid}
|
||||||
|
${bars}
|
||||||
|
</svg>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderAttendanceMatrix(records: AttendanceRecord[]): string {
|
||||||
|
if (records.length === 0) return '';
|
||||||
|
|
||||||
|
// Group by date
|
||||||
|
const dateMap = new Map<string, AttendanceRecord[]>();
|
||||||
|
for (const r of records) {
|
||||||
|
const existing = dateMap.get(r.attendanceDate) ?? [];
|
||||||
|
existing.push(r);
|
||||||
|
dateMap.set(r.attendanceDate, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dates = [...dateMap.keys()].sort();
|
||||||
|
const sessions = ['上午', '下午', '晚自习'];
|
||||||
|
|
||||||
|
let rows = '';
|
||||||
|
for (const date of dates.slice(-30)) {
|
||||||
|
const dayRecords = dateMap.get(date) ?? [];
|
||||||
|
const cellMap = new Map<string, string>();
|
||||||
|
for (const r of dayRecords) {
|
||||||
|
cellMap.set(r.session, r.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cells = '';
|
||||||
|
for (const session of sessions) {
|
||||||
|
const status = cellMap.get(session) ?? '';
|
||||||
|
cells += `<td>${status ? statusBadge(status) : '-'}</td>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows += `<tr><td class="nowrap">${esc(date)}</td>${cells}</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="card" style="margin-bottom:14px;">
|
||||||
|
<h3>考勤明细(最近30条)</h3>
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>日期</th>
|
||||||
|
${sessions.map((s) => `<th>${esc(s)}</th>`).join('')}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="note">图例: <span class="status present">到</span> 出勤 <span class="status absent">缺</span> 缺勤 <span class="status late">迟</span> 迟到 <span class="status leave">假</span> 请假</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusBadge(status: string): string {
|
||||||
|
const map: Record<string, { cls: string; text: string }> = {
|
||||||
|
present: { cls: 'present', text: '到' },
|
||||||
|
absent: { cls: 'absent', text: '缺' },
|
||||||
|
late: { cls: 'late', text: '迟' },
|
||||||
|
leave: { cls: 'leave', text: '假' },
|
||||||
|
};
|
||||||
|
const entry = map[status];
|
||||||
|
if (!entry) return `<span class="muted">${esc(status)}</span>`;
|
||||||
|
return `<span class="status ${entry.cls}">${entry.text}</span>`;
|
||||||
|
}
|
||||||
89
apps/server/src/archive/archive-report.cover.ts
Normal file
89
apps/server/src/archive/archive-report.cover.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Student } from '../entities/student.entity';
|
||||||
|
import { StudentProfile } from '../entities/student-profile.entity';
|
||||||
|
import { StudentEnrollment } from '../entities/student-enrollment.entity';
|
||||||
|
import { esc, pageFrame, pageFooter, pageHeader } from './archive-report.helpers';
|
||||||
|
import { buildEnrollmentSection } from './archive-report.enrollment';
|
||||||
|
|
||||||
|
export function buildCover(
|
||||||
|
student: Student,
|
||||||
|
profile: StudentProfile | null,
|
||||||
|
enrollments: StudentEnrollment[],
|
||||||
|
now: string,
|
||||||
|
): string {
|
||||||
|
const types = enrollments.map((e) => e.classType).filter(Boolean).join(' / ') || '-';
|
||||||
|
|
||||||
|
return pageFrame(`
|
||||||
|
${pageHeader('封面')}
|
||||||
|
<div class="cover-title">学生档案报告</div>
|
||||||
|
<div class="cover-subtitle">生成日期: ${esc(now)}</div>
|
||||||
|
<div class="cover-main">
|
||||||
|
<div class="cover-name-card">
|
||||||
|
<div class="cover-name">${esc(student.name)}</div>
|
||||||
|
<div class="cover-desc">学号: ${esc(student.studentNo || '-')}<br>身份证号: ${esc(student.idNumber || '-')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cover-info">
|
||||||
|
<div class="cover-cell">
|
||||||
|
<div class="label">科类方向</div>
|
||||||
|
<div class="value">${esc(profile?.subjectDirection || '-')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cover-cell">
|
||||||
|
<div class="label">目标院校</div>
|
||||||
|
<div class="value">${esc(profile?.targetCollege || '-')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cover-cell">
|
||||||
|
<div class="label">目标专业</div>
|
||||||
|
<div class="value">${esc(profile?.targetMajor || '-')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cover-cell">
|
||||||
|
<div class="label">报读班型</div>
|
||||||
|
<div class="value">${esc(types)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="toc">
|
||||||
|
<div class="toc-row"><span class="toc-index">01</span><span class="toc-name">基础信息与报读记录</span><span class="toc-page">第 2 页</span></div>
|
||||||
|
<div class="toc-row"><span class="toc-index">02</span><span class="toc-name">考试成绩总览</span><span class="toc-page">第 3 页</span></div>
|
||||||
|
<div class="toc-row"><span class="toc-index">03</span><span class="toc-name">出勤记录</span><span class="toc-page">第 4 页</span></div>
|
||||||
|
<div class="toc-row"><span class="toc-index">04</span><span class="toc-name">文化课考试成绩</span><span class="toc-page">第 5 页</span></div>
|
||||||
|
<div class="toc-row"><span class="toc-index">05</span><span class="toc-name">学情记录与录取归档</span><span class="toc-page">第 6 页</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="watermark">恭学教育</div>
|
||||||
|
${pageFooter()}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBasicInfo(
|
||||||
|
student: Student,
|
||||||
|
profile: StudentProfile | null,
|
||||||
|
enrollments: StudentEnrollment[],
|
||||||
|
now: string,
|
||||||
|
): string {
|
||||||
|
const infoCards = `
|
||||||
|
<div class="title-row">
|
||||||
|
<div>
|
||||||
|
<div class="source">${esc(now)} · 系统生成</div>
|
||||||
|
<div class="section-title">基础信息</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-bottom:14px;">
|
||||||
|
<h3>个人信息</h3>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="summary-row"><span>姓名</span><span><strong>${esc(student.name)}</strong></span></div>
|
||||||
|
<div class="summary-row"><span>性别</span><span>${esc(student.gender || '-')}</span></div>
|
||||||
|
<div class="summary-row"><span>电话</span><span>${esc(student.phone || '-')}</span></div>
|
||||||
|
<div class="summary-row"><span>民族</span><span>${esc(student.ethnicity || '-')}</span></div>
|
||||||
|
<div class="summary-row"><span>紧急联系人</span><span>${esc(student.emergencyContact || '-')}</span></div>
|
||||||
|
<div class="summary-row"><span>紧急电话</span><span>${esc(student.emergencyPhone || '-')}</span></div>
|
||||||
|
<div class="summary-row"><span>年级</span><span>${esc(profile?.grade || '-')}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const enrollmentSection = buildEnrollmentSection(enrollments);
|
||||||
|
|
||||||
|
return pageFrame(`
|
||||||
|
${pageHeader('基础信息')}
|
||||||
|
${infoCards}
|
||||||
|
${enrollmentSection}
|
||||||
|
${pageFooter()}
|
||||||
|
`);
|
||||||
|
}
|
||||||
80
apps/server/src/archive/archive-report.enrollment.ts
Normal file
80
apps/server/src/archive/archive-report.enrollment.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { StudentEnrollment } from '../entities/student-enrollment.entity';
|
||||||
|
import { esc } from './archive-report.helpers';
|
||||||
|
|
||||||
|
export function buildEnrollmentSection(enrollments: StudentEnrollment[]): string {
|
||||||
|
if (enrollments.length === 0) {
|
||||||
|
return `<div class="banner-note">暂无报读记录</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderEnrollmentTable = (enrs: StudentEnrollment[]): string => {
|
||||||
|
if (enrs.length === 0) {
|
||||||
|
return `<div class="banner-note">暂无数据</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = '';
|
||||||
|
for (const e of enrs) {
|
||||||
|
rows += `<tr>
|
||||||
|
<td>${esc(e.courseCategory || '-')}</td>
|
||||||
|
<td>${esc(e.classType || '-')}</td>
|
||||||
|
<td>${esc(e.className || '-')}</td>
|
||||||
|
<td>${esc(e.headTeacher || '-')}</td>
|
||||||
|
<td>${esc(e.subjectTeacher || '-')}</td>
|
||||||
|
<td class="nowrap">${esc(e.startDate || '-')}</td>
|
||||||
|
<td class="nowrap">${esc(e.endDate || '-')}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<table class="data-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>课程类别</th><th>班型</th><th>班级</th>
|
||||||
|
<th>班主任</th><th>任课老师</th><th>开班日期</th><th>结课日期</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Multi-enrollment: split culture vs professional
|
||||||
|
const cultureEnrollments = enrollments.filter(
|
||||||
|
(e) => e.courseCategory && e.courseCategory.includes('文化'),
|
||||||
|
);
|
||||||
|
const profEnrollments = enrollments.filter(
|
||||||
|
(e) => e.courseCategory && e.courseCategory.includes('专业'),
|
||||||
|
);
|
||||||
|
const otherEnrollments = enrollments.filter(
|
||||||
|
(e) =>
|
||||||
|
!e.courseCategory ||
|
||||||
|
(!e.courseCategory.includes('文化') && !e.courseCategory.includes('专业')),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cultureEnrollments.length > 0 || profEnrollments.length > 0) {
|
||||||
|
let html =
|
||||||
|
'<div class="card" style="margin-bottom:14px;"><h3>报读记录</h3>';
|
||||||
|
html += '<div class="grid-2" style="gap:14px;">';
|
||||||
|
|
||||||
|
html += '<div>';
|
||||||
|
html += '<h3 style="font-size:13px;margin-bottom:8px;">文化课报读</h3>';
|
||||||
|
html += renderEnrollmentTable(cultureEnrollments);
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
html += '<div>';
|
||||||
|
html += '<h3 style="font-size:13px;margin-bottom:8px;">专业课报读</h3>';
|
||||||
|
html += renderEnrollmentTable(profEnrollments);
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
if (otherEnrollments.length > 0) {
|
||||||
|
html +=
|
||||||
|
'<h3 style="font-size:13px;margin:10px 0 8px;">其他报读</h3>';
|
||||||
|
html += renderEnrollmentTable(otherEnrollments);
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="card" style="margin-bottom:14px;">
|
||||||
|
<h3>报读记录</h3>
|
||||||
|
${renderEnrollmentTable(enrollments)}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
249
apps/server/src/archive/archive-report.exam.ts
Normal file
249
apps/server/src/archive/archive-report.exam.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { ExamScore } from '../entities/exam-score.entity';
|
||||||
|
import { esc, pageFrame, pageFooter, pageHeader } from './archive-report.helpers';
|
||||||
|
|
||||||
|
export function buildExamOverview(exams: ExamScore[], now: string): string {
|
||||||
|
const cultureExams = exams.filter(
|
||||||
|
(e) => e.examType && e.examType.includes('文化'),
|
||||||
|
);
|
||||||
|
const entranceExam = exams.find((e) => e.examType === '入学测试');
|
||||||
|
const highestExam = [...exams].sort((a, b) => (b.score ?? 0) - (a.score ?? 0))[0];
|
||||||
|
|
||||||
|
const entranceScore = entranceExam?.score?.toFixed(1) ?? '-';
|
||||||
|
const highestScore = highestExam?.score?.toFixed(1) ?? '-';
|
||||||
|
const highestName = highestExam?.examName ?? '-';
|
||||||
|
|
||||||
|
// Improvement: last exam score minus first exam score
|
||||||
|
const sortedScores = cultureExams
|
||||||
|
.map((exam) => exam.score)
|
||||||
|
.filter((score): score is number => score !== null && score !== undefined);
|
||||||
|
let improvement = '—';
|
||||||
|
if (sortedScores.length >= 2) {
|
||||||
|
const first = sortedScores[0];
|
||||||
|
const last = sortedScores[sortedScores.length - 1];
|
||||||
|
improvement = (last - first).toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgScore =
|
||||||
|
cultureExams.length > 0
|
||||||
|
? (
|
||||||
|
cultureExams.reduce((sum, e) => sum + (e.score ?? 0), 0) /
|
||||||
|
cultureExams.length
|
||||||
|
).toFixed(1)
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
const metricHtml = `
|
||||||
|
<div class="title-row">
|
||||||
|
<div>
|
||||||
|
<div class="source">${esc(now)} · 系统生成</div>
|
||||||
|
<div class="section-title">考试成绩总览</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid-4" style="margin-bottom:14px;">
|
||||||
|
<div class="metric">
|
||||||
|
<div class="label">入学测试成绩</div>
|
||||||
|
<strong>${esc(entranceScore)}</strong>
|
||||||
|
<p>入学摸底测试</p>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="label">最高分</div>
|
||||||
|
<strong>${esc(highestScore)}</strong>
|
||||||
|
<p>${esc(highestName)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="label">进步幅度</div>
|
||||||
|
<strong>${esc(improvement)}</strong>
|
||||||
|
<p>首考 → 末考变化</p>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="label">平均分</div>
|
||||||
|
<strong>${esc(avgScore)}</strong>
|
||||||
|
<p>文化课考试均分</p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const scoreTable = renderScoreTable(cultureExams);
|
||||||
|
const trendChart = renderScoreTrendChart(cultureExams);
|
||||||
|
|
||||||
|
let extraHtml = '';
|
||||||
|
if (cultureExams.length === 0) {
|
||||||
|
extraHtml = '<div class="banner-note">暂无文化课考试成绩</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageFrame(`
|
||||||
|
${pageHeader('考试成绩总览')}
|
||||||
|
${metricHtml}
|
||||||
|
${extraHtml}
|
||||||
|
${scoreTable}
|
||||||
|
${trendChart}
|
||||||
|
${pageFooter()}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderScoreTable(exams: ExamScore[]): string {
|
||||||
|
if (exams.length === 0) return '';
|
||||||
|
|
||||||
|
return `<div class="card" style="margin-bottom:14px;">
|
||||||
|
<h3>文化课考试成绩</h3>
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>类型</th><th>名称</th><th>科目</th>
|
||||||
|
<th>分数</th><th>班均</th><th>排名</th><th>日期</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${exams
|
||||||
|
.map(
|
||||||
|
(e) =>
|
||||||
|
`<tr>
|
||||||
|
<td>${esc(e.examType || '-')}</td>
|
||||||
|
<td>${esc(e.examName || '-')}</td>
|
||||||
|
<td>${esc(e.subject || '-')}</td>
|
||||||
|
<td>${e.score != null ? e.score : '-'}</td>
|
||||||
|
<td>${e.classAvg != null ? e.classAvg : '-'}</td>
|
||||||
|
<td>${e.rank != null ? e.rank : '-'}</td>
|
||||||
|
<td class="nowrap">${esc(e.examDate || '-')}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderScoreTrendChart(exams: ExamScore[]): string {
|
||||||
|
const cultureExams = exams.filter((e) => e.score != null);
|
||||||
|
if (cultureExams.length === 0) return '';
|
||||||
|
|
||||||
|
const scores = cultureExams.map((e) => Number(e.score));
|
||||||
|
const labels = cultureExams.map((e) => {
|
||||||
|
const d = e.examDate || '-';
|
||||||
|
return d.length > 7 ? d.slice(5) : d;
|
||||||
|
});
|
||||||
|
|
||||||
|
const w = 600;
|
||||||
|
const h = 180;
|
||||||
|
const pad = { top: 20, right: 20, bottom: 30, left: 40 };
|
||||||
|
const plotW = w - pad.left - pad.right;
|
||||||
|
const plotH = h - pad.top - pad.bottom;
|
||||||
|
|
||||||
|
const minScore = Math.min(...scores);
|
||||||
|
const maxScore = Math.max(...scores);
|
||||||
|
const scoreRange = maxScore - minScore || 1;
|
||||||
|
|
||||||
|
const scaleY = (s: number): number =>
|
||||||
|
pad.top + plotH - ((s - minScore) / scoreRange) * plotH;
|
||||||
|
|
||||||
|
let points = '';
|
||||||
|
let lines = '';
|
||||||
|
for (let i = 0; i < scores.length; i++) {
|
||||||
|
const x = pad.left + (i / Math.max(scores.length - 1, 1)) * plotW;
|
||||||
|
const y = scaleY(scores[i]);
|
||||||
|
points += `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="4" fill="#155aa8" stroke="#fff" stroke-width="2"/>`;
|
||||||
|
if (i > 0) {
|
||||||
|
const px = pad.left + ((i - 1) / Math.max(scores.length - 1, 1)) * plotW;
|
||||||
|
const py = scaleY(scores[i - 1]);
|
||||||
|
lines += `<line x1="${px.toFixed(1)}" y1="${py.toFixed(1)}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}" stroke="#155aa8" stroke-width="2" stroke-linecap="round"/>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Y-axis labels
|
||||||
|
const ySteps = 4;
|
||||||
|
let yLabels = '';
|
||||||
|
for (let i = 0; i <= ySteps; i++) {
|
||||||
|
const val = minScore + (scoreRange * i) / ySteps;
|
||||||
|
const y = scaleY(val);
|
||||||
|
yLabels += `<text x="${pad.left - 6}" y="${y.toFixed(1) + 4}" text-anchor="end" fill="#667085" font-size="10">${val.toFixed(0)}</text>`;
|
||||||
|
if (i > 0) {
|
||||||
|
yLabels += `<line x1="${pad.left}" y1="${y.toFixed(1)}" x2="${w - pad.right}" y2="${y.toFixed(1)}" stroke="#eaf3fd" stroke-width="1"/>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// X-axis labels
|
||||||
|
let xLabels = '';
|
||||||
|
const labelStep = Math.max(1, Math.floor(labels.length / 6));
|
||||||
|
for (let i = 0; i < labels.length; i += labelStep) {
|
||||||
|
const x = pad.left + (i / Math.max(scores.length - 1, 1)) * plotW;
|
||||||
|
xLabels += `<text x="${x.toFixed(1)}" y="${h - 6}" text-anchor="middle" fill="#667085" font-size="10">${esc(labels[i])}</text>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="card" style="margin-bottom:14px;">
|
||||||
|
<h3>成绩趋势</h3>
|
||||||
|
<svg class="line-chart" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="0" y="0" width="${w}" height="${h}" fill="#fff"/>
|
||||||
|
${yLabels}
|
||||||
|
${xLabels}
|
||||||
|
${lines}
|
||||||
|
${points}
|
||||||
|
</svg>
|
||||||
|
<div class="note">趋势图展示文化课考试成绩的变化轨迹,点数代每次考试的分数</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildExamDetail(exams: ExamScore[], now: string): string {
|
||||||
|
const cultureExams = exams.filter(
|
||||||
|
(e) => e.examType && e.examType.includes('文化'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cultureExams.length === 0) {
|
||||||
|
return pageFrame(`
|
||||||
|
${pageHeader('文化课考试成绩')}
|
||||||
|
<div class="title-row">
|
||||||
|
<div>
|
||||||
|
<div class="source">${esc(now)} · 系统生成</div>
|
||||||
|
<div class="section-title">文化课考试成绩</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="banner-note">暂无文化课考试成绩</div>
|
||||||
|
${pageFooter()}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by subject
|
||||||
|
const subjectMap = new Map<string, ExamScore[]>();
|
||||||
|
for (const e of cultureExams) {
|
||||||
|
const subject = e.subject || '其他';
|
||||||
|
const existing = subjectMap.get(subject) ?? [];
|
||||||
|
existing.push(e);
|
||||||
|
subjectMap.set(subject, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
let subjectCards = '';
|
||||||
|
for (const [subject, subExams] of subjectMap) {
|
||||||
|
const best = Math.max(...subExams.map((e) => e.score ?? 0));
|
||||||
|
const avg = (
|
||||||
|
subExams.reduce((sum, e) => sum + (e.score ?? 0), 0) / subExams.length
|
||||||
|
).toFixed(1);
|
||||||
|
|
||||||
|
let rows = '';
|
||||||
|
for (const e of subExams) {
|
||||||
|
rows += `<tr>
|
||||||
|
<td>${esc(e.examName || '-')}</td>
|
||||||
|
<td>${e.score != null ? e.score : '-'}</td>
|
||||||
|
<td>${e.classAvg != null ? e.classAvg : '-'}</td>
|
||||||
|
<td>${e.rank != null ? e.rank : '-'}</td>
|
||||||
|
<td class="nowrap">${esc(e.examDate || '-')}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
subjectCards += `<div class="card" style="margin-bottom:14px;">
|
||||||
|
<h3>${esc(subject)} · 最佳 ${best} · 均分 ${esc(avg)}</h3>
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>考试名称</th><th>分数</th><th>班均</th><th>排名</th><th>日期</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageFrame(`
|
||||||
|
${pageHeader('文化课考试成绩')}
|
||||||
|
<div class="title-row">
|
||||||
|
<div>
|
||||||
|
<div class="source">${esc(now)} · 系统生成</div>
|
||||||
|
<div class="section-title">文化课考试成绩</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${subjectCards}
|
||||||
|
${pageFooter()}
|
||||||
|
`);
|
||||||
|
}
|
||||||
20
apps/server/src/archive/archive-report.helpers.ts
Normal file
20
apps/server/src/archive/archive-report.helpers.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export function esc(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageFrame(inner: string): string {
|
||||||
|
return `<div class="page"><div class="frame"></div>${inner}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageHeader(title: string): string {
|
||||||
|
return `<div class="header"><span class="logo">G</span><span class="brand">恭学教育 · 学生档案</span><span class="page-kicker">${esc(title)}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageFooter(): string {
|
||||||
|
return `<div class="footer"><span>恭学教育 · 学生档案报告</span><span>机密 · 仅限内部使用</span></div>`;
|
||||||
|
}
|
||||||
85
apps/server/src/archive/archive-report.learning.ts
Normal file
85
apps/server/src/archive/archive-report.learning.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { LearningRecord } from '../entities/learning-record.entity';
|
||||||
|
import { ResultArchive } from '../entities/result-archive.entity';
|
||||||
|
import { esc, pageFrame, pageFooter, pageHeader } from './archive-report.helpers';
|
||||||
|
|
||||||
|
export function buildLearningAndResult(
|
||||||
|
learnings: LearningRecord[],
|
||||||
|
result: ResultArchive | null,
|
||||||
|
now: string,
|
||||||
|
): string {
|
||||||
|
let learningHtml = '';
|
||||||
|
if (learnings.length === 0) {
|
||||||
|
learningHtml = `
|
||||||
|
<div class="title-row">
|
||||||
|
<div>
|
||||||
|
<div class="source">${esc(now)} · 系统生成</div>
|
||||||
|
<div class="section-title">学情记录</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="banner-note">暂无学情记录</div>`;
|
||||||
|
} else {
|
||||||
|
const latest = learnings.slice(0, 15);
|
||||||
|
let rows = '';
|
||||||
|
for (const r of latest) {
|
||||||
|
rows += `<tr>
|
||||||
|
<td class="nowrap">${esc(r.recordDate || '-')}</td>
|
||||||
|
<td>${esc(r.recordType || '-')}</td>
|
||||||
|
<td>${esc((r.content || '-').slice(0, 200))}</td>
|
||||||
|
<td>${esc(r.followUpMethod || '-')}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
learningHtml = `
|
||||||
|
<div class="title-row">
|
||||||
|
<div>
|
||||||
|
<div class="source">${esc(now)} · 系统生成</div>
|
||||||
|
<div class="section-title">学情记录</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-bottom:14px;">
|
||||||
|
<h3>最近学情记录</h3>
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width:90px;">日期</th><th style="width:60px;">类型</th>
|
||||||
|
<th>内容</th><th style="width:70px;">跟进方式</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resultHtml = '';
|
||||||
|
if (result) {
|
||||||
|
resultHtml = `
|
||||||
|
<div class="title-row">
|
||||||
|
<div>
|
||||||
|
<div class="section-title">录取归档</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="summary-row"><span>文化课成绩</span><span><strong>${result.cultureFinalScore != null ? result.cultureFinalScore : '-'}</strong></span></div>
|
||||||
|
<div class="summary-row"><span>专业课成绩</span><span><strong>${result.professionalFinalScore != null ? result.professionalFinalScore : '-'}</strong></span></div>
|
||||||
|
<div class="summary-row"><span>录取状态</span><span><strong>${esc(result.admissionStatus || '-')}</strong></span></div>
|
||||||
|
<div class="summary-row"><span>录取院校</span><span><strong>${esc(result.admittedCollege || '-')}</strong></span></div>
|
||||||
|
<div class="summary-row"><span>录取专业</span><span><strong>${esc(result.admittedMajor || '-')}</strong></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="note">录取归档信息为最终结果,如有疑问请联系教务处</div>`;
|
||||||
|
} else {
|
||||||
|
resultHtml = `
|
||||||
|
<div class="title-row">
|
||||||
|
<div>
|
||||||
|
<div class="section-title">录取归档</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="banner-note">暂无录取归档信息</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageFrame(`
|
||||||
|
${pageHeader('学情记录与录取归档')}
|
||||||
|
${learningHtml}
|
||||||
|
${resultHtml}
|
||||||
|
${pageFooter()}
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -8,6 +8,12 @@ import { LearningRecord } from '../entities/learning-record.entity';
|
|||||||
import { ResultArchive } from '../entities/result-archive.entity';
|
import { ResultArchive } from '../entities/result-archive.entity';
|
||||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||||
import { Student } from '../entities/student.entity';
|
import { Student } from '../entities/student.entity';
|
||||||
|
import { ARCHIVE_REPORT_CSS } from './archive-report.styles';
|
||||||
|
import { esc } from './archive-report.helpers';
|
||||||
|
import { buildCover, buildBasicInfo } from './archive-report.cover';
|
||||||
|
import { buildExamOverview, buildExamDetail } from './archive-report.exam';
|
||||||
|
import { buildAttendance } from './archive-report.attendance';
|
||||||
|
import { buildLearningAndResult } from './archive-report.learning';
|
||||||
|
|
||||||
interface ReportData {
|
interface ReportData {
|
||||||
student: Student;
|
student: Student;
|
||||||
@@ -45,7 +51,7 @@ export class ArchiveReportService {
|
|||||||
|
|
||||||
if (!student) throw new Error('学生不存在');
|
if (!student) throw new Error('学生不存在');
|
||||||
|
|
||||||
const data: ReportData = {
|
return this.buildHtml({
|
||||||
student,
|
student,
|
||||||
profile,
|
profile,
|
||||||
enrollments,
|
enrollments,
|
||||||
@@ -53,166 +59,7 @@ export class ArchiveReportService {
|
|||||||
learnings,
|
learnings,
|
||||||
result,
|
result,
|
||||||
attendances,
|
attendances,
|
||||||
};
|
});
|
||||||
|
|
||||||
return this.buildHtml(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private css(): string {
|
|
||||||
return `
|
|
||||||
@page { size: A4; margin: 0; }
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0; background: #eef3f8; color: #101828;
|
|
||||||
font-family: "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
|
|
||||||
-webkit-print-color-adjust: exact; print-color-adjust: exact;
|
|
||||||
}
|
|
||||||
.page {
|
|
||||||
position: relative; width: 210mm; height: 297mm;
|
|
||||||
margin: 0 auto 18px; padding: 14mm 15mm 10mm;
|
|
||||||
overflow: hidden; background: #fff; page-break-after: always;
|
|
||||||
}
|
|
||||||
.frame {
|
|
||||||
position: absolute; inset: 14mm; border: 1px solid #cfe0f2; pointer-events: none;
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
position: relative; z-index: 1; display: flex; align-items: center;
|
|
||||||
height: 39px; padding-bottom: 8px; border-bottom: 1px solid #cfe0f2;
|
|
||||||
}
|
|
||||||
.logo {
|
|
||||||
width: 24px; height: 24px; border-radius: 6px;
|
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
|
||||||
margin-right: 8px; color: #fff; background: #155aa8;
|
|
||||||
font-weight: 800; font-size: 11px;
|
|
||||||
}
|
|
||||||
.brand { font-size: 10px; font-weight: 700; }
|
|
||||||
.page-kicker { margin-left: auto; font-size: 10px; color: #667085; }
|
|
||||||
.footer {
|
|
||||||
position: absolute; left: 15mm; right: 15mm; bottom: 8mm; z-index: 1;
|
|
||||||
display: flex; justify-content: space-between;
|
|
||||||
border-top: 1px solid #cfe0f2; padding-top: 5px;
|
|
||||||
font-size: 10px; color: #667085;
|
|
||||||
}
|
|
||||||
h1, h2, h3, p { margin: 0; }
|
|
||||||
.section-title { font-size: 24px; line-height: 1.24; font-weight: 800; }
|
|
||||||
.source { font-size: 12px; color: #667085; padding-bottom: 2px; }
|
|
||||||
.title-row {
|
|
||||||
display: flex; align-items: flex-end; justify-content: space-between;
|
|
||||||
margin: 26px 0 17px;
|
|
||||||
}
|
|
||||||
.cover-title { margin-top: 60px; font-size: 34px; line-height: 1.22; font-weight: 800; }
|
|
||||||
.cover-subtitle { margin-top: 22px; font-size: 16px; color: #667085; }
|
|
||||||
.cover-main {
|
|
||||||
display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 63px;
|
|
||||||
}
|
|
||||||
.cover-name-card {
|
|
||||||
min-height: 174px; border: 1px solid #cfe0f2;
|
|
||||||
border-left: 5px solid #155aa8; padding: 22px 24px;
|
|
||||||
}
|
|
||||||
.cover-name {
|
|
||||||
font-size: 44px; line-height: 1.14; font-weight: 800; color: #155aa8;
|
|
||||||
}
|
|
||||||
.cover-desc { margin-top: 22px; font-size: 16px; color: #667085; }
|
|
||||||
.cover-info { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
||||||
.cover-cell {
|
|
||||||
min-height: 61px; border: 1px solid #cfe0f2; padding: 11px 14px;
|
|
||||||
}
|
|
||||||
.label { font-size: 11px; color: #667085; margin-bottom: 8px; }
|
|
||||||
.value { font-size: 14px; line-height: 1.5; font-weight: 700; }
|
|
||||||
.toc { margin-top: 58px; }
|
|
||||||
.toc-row {
|
|
||||||
display: grid; grid-template-columns: 48px 1fr 72px; align-items: center;
|
|
||||||
height: 47px; border-bottom: 1px solid #cfe0f2;
|
|
||||||
}
|
|
||||||
.toc-index { color: #155aa8; font-size: 15px; font-weight: 800; }
|
|
||||||
.toc-name { font-size: 14px; font-weight: 800; }
|
|
||||||
.toc-page { text-align: right; color: #667085; font-size: 12px; }
|
|
||||||
.watermark {
|
|
||||||
position: absolute; right: 36px; bottom: 82px; color: #eaf1fb;
|
|
||||||
font-size: 56px; font-weight: 900; writing-mode: vertical-rl;
|
|
||||||
}
|
|
||||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
||||||
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
|
||||||
.card { border: 1px solid #cfe0f2; padding: 14px; background: #fff; }
|
|
||||||
.card h3 { font-size: 16px; margin-bottom: 14px; }
|
|
||||||
.data-table {
|
|
||||||
width: 100%; border-collapse: collapse; table-layout: fixed;
|
|
||||||
}
|
|
||||||
.data-table th, .data-table td {
|
|
||||||
border: 1px solid #d6e3f2; padding: 8px 9px; font-size: 12px;
|
|
||||||
line-height: 1.55; vertical-align: top; text-align: left;
|
|
||||||
}
|
|
||||||
.data-table th {
|
|
||||||
background: #eaf3fd; color: #173f6f; font-weight: 800; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.data-table td { overflow-wrap: anywhere; word-break: break-word; }
|
|
||||||
.data-table .nowrap { white-space: nowrap; }
|
|
||||||
.metric {
|
|
||||||
min-height: 88px; border: 1px solid #cfe0f2; padding: 13px 14px;
|
|
||||||
}
|
|
||||||
.metric .label { margin-bottom: 7px; }
|
|
||||||
.metric strong {
|
|
||||||
display: block; color: #155aa8; font-size: 27px; line-height: 1.16;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.metric p {
|
|
||||||
color: #667085; font-size: 12px; line-height: 1.45;
|
|
||||||
}
|
|
||||||
.summary-row {
|
|
||||||
display: grid; grid-template-columns: 92px 1fr; gap: 12px;
|
|
||||||
padding: 14px 0; border-bottom: 1px solid #d6e3f2;
|
|
||||||
font-size: 13px; line-height: 1.6;
|
|
||||||
}
|
|
||||||
.summary-row:last-child { border-bottom: 0; }
|
|
||||||
.summary-row strong { color: #155aa8; }
|
|
||||||
.note {
|
|
||||||
margin-top: 14px; padding: 12px 16px; border-left: 4px solid #155aa8;
|
|
||||||
background: #eef5ff; color: #173f6f; font-size: 12px; line-height: 1.7;
|
|
||||||
}
|
|
||||||
.banner-note {
|
|
||||||
margin-top: 12px; padding: 11px 16px; background: #eef5ff;
|
|
||||||
color: #173f6f; font-size: 12px; line-height: 1.7;
|
|
||||||
}
|
|
||||||
.line-chart { width: 100%; height: 180px; display: block; }
|
|
||||||
.bar-chart { width: 100%; height: 150px; display: block; }
|
|
||||||
.status {
|
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
|
||||||
width: 18px; height: 18px; border-radius: 5px; margin-right: 6px;
|
|
||||||
color: #fff; font-size: 11px; font-weight: 800;
|
|
||||||
}
|
|
||||||
.present { background: #18a77d; }
|
|
||||||
.leave { background: #f15b75; }
|
|
||||||
.late { background: #f59e0b; }
|
|
||||||
.absent { background: #dc2626; }
|
|
||||||
.progress-row {
|
|
||||||
display: grid; grid-template-columns: 72px 1fr 42px; align-items: center;
|
|
||||||
gap: 8px; margin: 10px 0; font-size: 12px;
|
|
||||||
}
|
|
||||||
.progress-track {
|
|
||||||
height: 11px; border-radius: 999px; background: #dfeaf6; overflow: hidden;
|
|
||||||
}
|
|
||||||
.progress-track i {
|
|
||||||
display: block; height: 100%; border-radius: 999px;
|
|
||||||
background: linear-gradient(90deg, #155aa8, #2e7df0);
|
|
||||||
}
|
|
||||||
.muted { color: #667085; }
|
|
||||||
@media print {
|
|
||||||
body { background: #fff; }
|
|
||||||
.page { margin: 0; box-shadow: none; }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private pageFrame(inner: string): string {
|
|
||||||
return `<div class="page"><div class="frame"></div>${inner}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private pageHeader(title: string): string {
|
|
||||||
return `<div class="header"><span class="logo">G</span><span class="brand">恭学教育 · 学生档案</span><span class="page-kicker">${this.esc(title)}</span></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private pageFooter(): string {
|
|
||||||
return `<div class="footer"><span>恭学教育 · 学生档案报告</span><span>机密 · 仅限内部使用</span></div>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildHtml(data: ReportData): string {
|
private buildHtml(data: ReportData): string {
|
||||||
@@ -226,679 +73,15 @@ export class ArchiveReportService {
|
|||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head><meta charset="UTF-8"><title>学生档案报告 - ${this.esc(name)}</title>
|
<head><meta charset="UTF-8"><title>学生档案报告 - ${esc(name)}</title>
|
||||||
<style>${this.css()}</style></head>
|
<style>${ARCHIVE_REPORT_CSS}</style></head>
|
||||||
<body>
|
<body>
|
||||||
${this.buildCover(student, profile, enrollments, now)}
|
${buildCover(student, profile, enrollments, now)}
|
||||||
${this.buildBasicInfo(student, profile, enrollments, now)}
|
${buildBasicInfo(student, profile, enrollments, now)}
|
||||||
${this.buildExamOverview(exams, now)}
|
${buildExamOverview(exams, now)}
|
||||||
${this.buildAttendance(attendances, now)}
|
${buildAttendance(attendances, now)}
|
||||||
${this.buildExamDetail(exams, now)}
|
${buildExamDetail(exams, now)}
|
||||||
${this.buildLearningAndResult(learnings, result, now)}
|
${buildLearningAndResult(learnings, result, now)}
|
||||||
</body></html>`;
|
</body></html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildCover(
|
|
||||||
student: Student,
|
|
||||||
profile: StudentProfile | null,
|
|
||||||
enrollments: StudentEnrollment[],
|
|
||||||
now: string,
|
|
||||||
): string {
|
|
||||||
const types = enrollments.map((e) => e.classType).filter(Boolean).join(' / ') || '-';
|
|
||||||
|
|
||||||
return this.pageFrame(`
|
|
||||||
${this.pageHeader('封面')}
|
|
||||||
<div class="cover-title">学生档案报告</div>
|
|
||||||
<div class="cover-subtitle">生成日期: ${this.esc(now)}</div>
|
|
||||||
<div class="cover-main">
|
|
||||||
<div class="cover-name-card">
|
|
||||||
<div class="cover-name">${this.esc(student.name)}</div>
|
|
||||||
<div class="cover-desc">学号: ${this.esc(student.studentNo || '-')}<br>身份证号: ${this.esc(student.idNumber || '-')}</div>
|
|
||||||
</div>
|
|
||||||
<div class="cover-info">
|
|
||||||
<div class="cover-cell">
|
|
||||||
<div class="label">科类方向</div>
|
|
||||||
<div class="value">${this.esc(profile?.subjectDirection || '-')}</div>
|
|
||||||
</div>
|
|
||||||
<div class="cover-cell">
|
|
||||||
<div class="label">目标院校</div>
|
|
||||||
<div class="value">${this.esc(profile?.targetCollege || '-')}</div>
|
|
||||||
</div>
|
|
||||||
<div class="cover-cell">
|
|
||||||
<div class="label">目标专业</div>
|
|
||||||
<div class="value">${this.esc(profile?.targetMajor || '-')}</div>
|
|
||||||
</div>
|
|
||||||
<div class="cover-cell">
|
|
||||||
<div class="label">报读班型</div>
|
|
||||||
<div class="value">${this.esc(types)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="toc">
|
|
||||||
<div class="toc-row"><span class="toc-index">01</span><span class="toc-name">基础信息与报读记录</span><span class="toc-page">第 2 页</span></div>
|
|
||||||
<div class="toc-row"><span class="toc-index">02</span><span class="toc-name">考试成绩总览</span><span class="toc-page">第 3 页</span></div>
|
|
||||||
<div class="toc-row"><span class="toc-index">03</span><span class="toc-name">出勤记录</span><span class="toc-page">第 4 页</span></div>
|
|
||||||
<div class="toc-row"><span class="toc-index">04</span><span class="toc-name">文化课考试成绩</span><span class="toc-page">第 5 页</span></div>
|
|
||||||
<div class="toc-row"><span class="toc-index">05</span><span class="toc-name">学情记录与录取归档</span><span class="toc-page">第 6 页</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="watermark">恭学教育</div>
|
|
||||||
${this.pageFooter()}
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildBasicInfo(
|
|
||||||
student: Student,
|
|
||||||
profile: StudentProfile | null,
|
|
||||||
enrollments: StudentEnrollment[],
|
|
||||||
now: string,
|
|
||||||
): string {
|
|
||||||
const infoCards = `
|
|
||||||
<div class="title-row">
|
|
||||||
<div>
|
|
||||||
<div class="source">${this.esc(now)} · 系统生成</div>
|
|
||||||
<div class="section-title">基础信息</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="margin-bottom:14px;">
|
|
||||||
<h3>个人信息</h3>
|
|
||||||
<div class="grid-2">
|
|
||||||
<div class="summary-row"><span>姓名</span><span><strong>${this.esc(student.name)}</strong></span></div>
|
|
||||||
<div class="summary-row"><span>性别</span><span>${this.esc(student.gender || '-')}</span></div>
|
|
||||||
<div class="summary-row"><span>电话</span><span>${this.esc(student.phone || '-')}</span></div>
|
|
||||||
<div class="summary-row"><span>民族</span><span>${this.esc(student.ethnicity || '-')}</span></div>
|
|
||||||
<div class="summary-row"><span>紧急联系人</span><span>${this.esc(student.emergencyContact || '-')}</span></div>
|
|
||||||
<div class="summary-row"><span>紧急电话</span><span>${this.esc(student.emergencyPhone || '-')}</span></div>
|
|
||||||
<div class="summary-row"><span>年级</span><span>${this.esc(profile?.grade || '-')}</span></div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const enrollmentSection = this.buildEnrollmentSection(enrollments);
|
|
||||||
|
|
||||||
return this.pageFrame(`
|
|
||||||
${this.pageHeader('基础信息')}
|
|
||||||
${infoCards}
|
|
||||||
${enrollmentSection}
|
|
||||||
${this.pageFooter()}
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildEnrollmentSection(enrollments: StudentEnrollment[]): string {
|
|
||||||
if (enrollments.length === 0) {
|
|
||||||
return `<div class="banner-note">暂无报读记录</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderEnrollmentTable = (enrs: StudentEnrollment[]): string => {
|
|
||||||
if (enrs.length === 0) {
|
|
||||||
return `<div class="banner-note">暂无数据</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let rows = '';
|
|
||||||
for (const e of enrs) {
|
|
||||||
rows += `<tr>
|
|
||||||
<td>${this.esc(e.courseCategory || '-')}</td>
|
|
||||||
<td>${this.esc(e.classType || '-')}</td>
|
|
||||||
<td>${this.esc(e.className || '-')}</td>
|
|
||||||
<td>${this.esc(e.headTeacher || '-')}</td>
|
|
||||||
<td>${this.esc(e.subjectTeacher || '-')}</td>
|
|
||||||
<td class="nowrap">${this.esc(e.startDate || '-')}</td>
|
|
||||||
<td class="nowrap">${this.esc(e.endDate || '-')}</td>
|
|
||||||
</tr>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<table class="data-table">
|
|
||||||
<thead><tr>
|
|
||||||
<th>课程类别</th><th>班型</th><th>班级</th>
|
|
||||||
<th>班主任</th><th>任课老师</th><th>开班日期</th><th>结课日期</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>${rows}</tbody>
|
|
||||||
</table>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Multi-enrollment: split culture vs professional
|
|
||||||
const cultureEnrollments = enrollments.filter(
|
|
||||||
(e) => e.courseCategory && e.courseCategory.includes('文化'),
|
|
||||||
);
|
|
||||||
const profEnrollments = enrollments.filter(
|
|
||||||
(e) => e.courseCategory && e.courseCategory.includes('专业'),
|
|
||||||
);
|
|
||||||
const otherEnrollments = enrollments.filter(
|
|
||||||
(e) =>
|
|
||||||
!e.courseCategory ||
|
|
||||||
(!e.courseCategory.includes('文化') && !e.courseCategory.includes('专业')),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (cultureEnrollments.length > 0 || profEnrollments.length > 0) {
|
|
||||||
let html =
|
|
||||||
'<div class="card" style="margin-bottom:14px;"><h3>报读记录</h3>';
|
|
||||||
html += '<div class="grid-2" style="gap:14px;">';
|
|
||||||
|
|
||||||
html += '<div>';
|
|
||||||
html += '<h3 style="font-size:13px;margin-bottom:8px;">文化课报读</h3>';
|
|
||||||
html += renderEnrollmentTable(cultureEnrollments);
|
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
html += '<div>';
|
|
||||||
html += '<h3 style="font-size:13px;margin-bottom:8px;">专业课报读</h3>';
|
|
||||||
html += renderEnrollmentTable(profEnrollments);
|
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
if (otherEnrollments.length > 0) {
|
|
||||||
html +=
|
|
||||||
'<h3 style="font-size:13px;margin:10px 0 8px;">其他报读</h3>';
|
|
||||||
html += renderEnrollmentTable(otherEnrollments);
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '</div>';
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<div class="card" style="margin-bottom:14px;">
|
|
||||||
<h3>报读记录</h3>
|
|
||||||
${renderEnrollmentTable(enrollments)}
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildExamOverview(exams: ExamScore[], now: string): string {
|
|
||||||
const cultureExams = exams.filter(
|
|
||||||
(e) => e.examType && e.examType.includes('文化'),
|
|
||||||
);
|
|
||||||
const entranceExam = exams.find((e) => e.examType === '入学测试');
|
|
||||||
const highestExam = [...exams].sort((a, b) => (b.score ?? 0) - (a.score ?? 0))[0];
|
|
||||||
|
|
||||||
const entranceScore = entranceExam?.score?.toFixed(1) ?? '-';
|
|
||||||
const highestScore = highestExam?.score?.toFixed(1) ?? '-';
|
|
||||||
const highestName = highestExam?.examName ?? '-';
|
|
||||||
|
|
||||||
// Improvement: last exam score minus first exam score
|
|
||||||
const sortedScores = cultureExams
|
|
||||||
.map((exam) => exam.score)
|
|
||||||
.filter((score): score is number => score !== null && score !== undefined);
|
|
||||||
let improvement = '—';
|
|
||||||
if (sortedScores.length >= 2) {
|
|
||||||
const first = sortedScores[0];
|
|
||||||
const last = sortedScores[sortedScores.length - 1];
|
|
||||||
improvement = (last - first).toFixed(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const avgScore =
|
|
||||||
cultureExams.length > 0
|
|
||||||
? (
|
|
||||||
cultureExams.reduce((sum, e) => sum + (e.score ?? 0), 0) /
|
|
||||||
cultureExams.length
|
|
||||||
).toFixed(1)
|
|
||||||
: '-';
|
|
||||||
|
|
||||||
const metricHtml = `
|
|
||||||
<div class="title-row">
|
|
||||||
<div>
|
|
||||||
<div class="source">${this.esc(now)} · 系统生成</div>
|
|
||||||
<div class="section-title">考试成绩总览</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid-4" style="margin-bottom:14px;">
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">入学测试成绩</div>
|
|
||||||
<strong>${this.esc(entranceScore)}</strong>
|
|
||||||
<p>入学摸底测试</p>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">最高分</div>
|
|
||||||
<strong>${this.esc(highestScore)}</strong>
|
|
||||||
<p>${this.esc(highestName)}</p>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">进步幅度</div>
|
|
||||||
<strong>${this.esc(improvement)}</strong>
|
|
||||||
<p>首考 → 末考变化</p>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">平均分</div>
|
|
||||||
<strong>${this.esc(avgScore)}</strong>
|
|
||||||
<p>文化课考试均分</p>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const scoreTable = this.renderScoreTable(cultureExams);
|
|
||||||
|
|
||||||
const trendChart = this.renderScoreTrendChart(cultureExams);
|
|
||||||
|
|
||||||
let extraHtml = '';
|
|
||||||
if (cultureExams.length === 0) {
|
|
||||||
extraHtml = '<div class="banner-note">暂无文化课考试成绩</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.pageFrame(`
|
|
||||||
${this.pageHeader('考试成绩总览')}
|
|
||||||
${metricHtml}
|
|
||||||
${extraHtml}
|
|
||||||
${scoreTable}
|
|
||||||
${trendChart}
|
|
||||||
${this.pageFooter()}
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderScoreTable(exams: ExamScore[]): string {
|
|
||||||
if (exams.length === 0) return '';
|
|
||||||
|
|
||||||
return `<div class="card" style="margin-bottom:14px;">
|
|
||||||
<h3>文化课考试成绩</h3>
|
|
||||||
<table class="data-table">
|
|
||||||
<thead><tr>
|
|
||||||
<th>类型</th><th>名称</th><th>科目</th>
|
|
||||||
<th>分数</th><th>班均</th><th>排名</th><th>日期</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>
|
|
||||||
${exams
|
|
||||||
.map(
|
|
||||||
(e) =>
|
|
||||||
`<tr>
|
|
||||||
<td>${this.esc(e.examType || '-')}</td>
|
|
||||||
<td>${this.esc(e.examName || '-')}</td>
|
|
||||||
<td>${this.esc(e.subject || '-')}</td>
|
|
||||||
<td>${e.score != null ? e.score : '-'}</td>
|
|
||||||
<td>${e.classAvg != null ? e.classAvg : '-'}</td>
|
|
||||||
<td>${e.rank != null ? e.rank : '-'}</td>
|
|
||||||
<td class="nowrap">${this.esc(e.examDate || '-')}</td>
|
|
||||||
</tr>`,
|
|
||||||
)
|
|
||||||
.join('')}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderScoreTrendChart(exams: ExamScore[]): string {
|
|
||||||
const cultureExams = exams.filter((e) => e.score != null);
|
|
||||||
if (cultureExams.length === 0) return '';
|
|
||||||
|
|
||||||
const scores = cultureExams.map((e) => Number(e.score));
|
|
||||||
const labels = cultureExams.map((e) => {
|
|
||||||
const d = e.examDate || '-';
|
|
||||||
return d.length > 7 ? d.slice(5) : d;
|
|
||||||
});
|
|
||||||
|
|
||||||
const w = 600;
|
|
||||||
const h = 180;
|
|
||||||
const pad = { top: 20, right: 20, bottom: 30, left: 40 };
|
|
||||||
const plotW = w - pad.left - pad.right;
|
|
||||||
const plotH = h - pad.top - pad.bottom;
|
|
||||||
|
|
||||||
const minScore = Math.min(...scores);
|
|
||||||
const maxScore = Math.max(...scores);
|
|
||||||
const scoreRange = maxScore - minScore || 1;
|
|
||||||
|
|
||||||
const scaleY = (s: number): number =>
|
|
||||||
pad.top + plotH - ((s - minScore) / scoreRange) * plotH;
|
|
||||||
|
|
||||||
let points = '';
|
|
||||||
let lines = '';
|
|
||||||
for (let i = 0; i < scores.length; i++) {
|
|
||||||
const x = pad.left + (i / Math.max(scores.length - 1, 1)) * plotW;
|
|
||||||
const y = scaleY(scores[i]);
|
|
||||||
points += `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="4" fill="#155aa8" stroke="#fff" stroke-width="2"/>`;
|
|
||||||
if (i > 0) {
|
|
||||||
const px = pad.left + ((i - 1) / Math.max(scores.length - 1, 1)) * plotW;
|
|
||||||
const py = scaleY(scores[i - 1]);
|
|
||||||
lines += `<line x1="${px.toFixed(1)}" y1="${py.toFixed(1)}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}" stroke="#155aa8" stroke-width="2" stroke-linecap="round"/>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Y-axis labels
|
|
||||||
const ySteps = 4;
|
|
||||||
let yLabels = '';
|
|
||||||
for (let i = 0; i <= ySteps; i++) {
|
|
||||||
const val = minScore + (scoreRange * i) / ySteps;
|
|
||||||
const y = scaleY(val);
|
|
||||||
yLabels += `<text x="${pad.left - 6}" y="${y.toFixed(1) + 4}" text-anchor="end" fill="#667085" font-size="10">${val.toFixed(0)}</text>`;
|
|
||||||
if (i > 0) {
|
|
||||||
yLabels += `<line x1="${pad.left}" y1="${y.toFixed(1)}" x2="${w - pad.right}" y2="${y.toFixed(1)}" stroke="#eaf3fd" stroke-width="1"/>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// X-axis labels
|
|
||||||
let xLabels = '';
|
|
||||||
const labelStep = Math.max(1, Math.floor(labels.length / 6));
|
|
||||||
for (let i = 0; i < labels.length; i += labelStep) {
|
|
||||||
const x = pad.left + (i / Math.max(scores.length - 1, 1)) * plotW;
|
|
||||||
xLabels += `<text x="${x.toFixed(1)}" y="${h - 6}" text-anchor="middle" fill="#667085" font-size="10">${this.esc(labels[i])}</text>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<div class="card" style="margin-bottom:14px;">
|
|
||||||
<h3>成绩趋势</h3>
|
|
||||||
<svg class="line-chart" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect x="0" y="0" width="${w}" height="${h}" fill="#fff"/>
|
|
||||||
${yLabels}
|
|
||||||
${xLabels}
|
|
||||||
${lines}
|
|
||||||
${points}
|
|
||||||
</svg>
|
|
||||||
<div class="note">趋势图展示文化课考试成绩的变化轨迹,点数代每次考试的分数</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildAttendance(records: AttendanceRecord[], now: string): string {
|
|
||||||
const present = records.filter((r) => r.status === 'present').length;
|
|
||||||
const absent = records.filter((r) => r.status === 'absent').length;
|
|
||||||
const late = records.filter((r) => r.status === 'late').length;
|
|
||||||
const leave = records.filter((r) => r.status === 'leave').length;
|
|
||||||
const total = records.length;
|
|
||||||
const rate = total > 0 ? ((present / total) * 100).toFixed(1) : '0';
|
|
||||||
|
|
||||||
const metricHtml = `
|
|
||||||
<div class="title-row">
|
|
||||||
<div>
|
|
||||||
<div class="source">${this.esc(now)} · 系统生成</div>
|
|
||||||
<div class="section-title">出勤记录</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid-4" style="margin-bottom:14px;">
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">总考勤次数</div>
|
|
||||||
<strong>${total}</strong>
|
|
||||||
<p>累计记录</p>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">出勤率</div>
|
|
||||||
<strong>${this.esc(rate)}%</strong>
|
|
||||||
<p>出勤: ${present} 次</p>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">缺勤 / 迟到</div>
|
|
||||||
<strong>${absent} / ${late}</strong>
|
|
||||||
<p>缺勤 ${absent} · 迟到 ${late}</p>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">请假</div>
|
|
||||||
<strong>${leave}</strong>
|
|
||||||
<p>累计请假次数</p>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const chart = this.renderAttendanceBar(records);
|
|
||||||
const matrix = this.renderAttendanceMatrix(records);
|
|
||||||
|
|
||||||
let extraHtml = '';
|
|
||||||
if (records.length === 0) {
|
|
||||||
extraHtml = '<div class="banner-note">暂无出勤记录</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.pageFrame(`
|
|
||||||
${this.pageHeader('出勤记录')}
|
|
||||||
${metricHtml}
|
|
||||||
${extraHtml}
|
|
||||||
${chart}
|
|
||||||
${matrix}
|
|
||||||
${this.pageFooter()}
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderAttendanceBar(records: AttendanceRecord[]): string {
|
|
||||||
if (records.length === 0) return '';
|
|
||||||
|
|
||||||
const statuses = ['present', 'absent', 'late', 'leave'] as const;
|
|
||||||
const counts = statuses.map((s) => records.filter((r) => r.status === s).length);
|
|
||||||
const labels = ['出勤', '缺勤', '迟到', '请假'];
|
|
||||||
const colors = ['#18a77d', '#dc2626', '#f59e0b', '#f15b75'];
|
|
||||||
const maxCount = Math.max(...counts, 1);
|
|
||||||
|
|
||||||
const w = 600;
|
|
||||||
const h = 150;
|
|
||||||
const pad = { top: 20, right: 20, bottom: 30, left: 40 };
|
|
||||||
const plotW = w - pad.left - pad.right;
|
|
||||||
const plotH = h - pad.top - pad.bottom;
|
|
||||||
const barGap = 30;
|
|
||||||
const barW = (plotW - barGap * (statuses.length - 1)) / statuses.length;
|
|
||||||
|
|
||||||
const scaleH = (v: number): number => (v / maxCount) * plotH;
|
|
||||||
|
|
||||||
let bars = '';
|
|
||||||
for (let i = 0; i < statuses.length; i++) {
|
|
||||||
const x = pad.left + i * (barW + barGap);
|
|
||||||
const bh = scaleH(counts[i]);
|
|
||||||
const y = pad.top + plotH - bh;
|
|
||||||
bars += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${bh.toFixed(1)}" fill="${colors[i]}" rx="4"/>`;
|
|
||||||
bars += `<text x="${(x + barW / 2).toFixed(1)}" y="${(y - 6).toFixed(1)}" text-anchor="middle" fill="#101828" font-size="12" font-weight="700">${counts[i]}</text>`;
|
|
||||||
bars += `<text x="${(x + barW / 2).toFixed(1)}" y="${(h - 8).toFixed(1)}" text-anchor="middle" fill="#667085" font-size="11">${labels[i]}</text>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Y-axis grid
|
|
||||||
const ySteps = 4;
|
|
||||||
let yGrid = '';
|
|
||||||
for (let i = 0; i <= ySteps; i++) {
|
|
||||||
const val = Math.round((maxCount * i) / ySteps);
|
|
||||||
const y = pad.top + plotH - (plotH * i) / ySteps;
|
|
||||||
yGrid += `<text x="${pad.left - 6}" y="${y + 4}" text-anchor="end" fill="#667085" font-size="10">${val}</text>`;
|
|
||||||
if (i < ySteps) {
|
|
||||||
yGrid += `<line x1="${pad.left}" y1="${y}" x2="${w - pad.right}" y2="${y}" stroke="#eaf3fd" stroke-width="1"/>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<div class="card" style="margin-bottom:14px;">
|
|
||||||
<h3>出勤统计</h3>
|
|
||||||
<svg class="bar-chart" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect x="0" y="0" width="${w}" height="${h}" fill="#fff"/>
|
|
||||||
${yGrid}
|
|
||||||
${bars}
|
|
||||||
</svg>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderAttendanceMatrix(records: AttendanceRecord[]): string {
|
|
||||||
if (records.length === 0) return '';
|
|
||||||
|
|
||||||
// Group by date
|
|
||||||
const dateMap = new Map<string, AttendanceRecord[]>();
|
|
||||||
for (const r of records) {
|
|
||||||
const existing = dateMap.get(r.attendanceDate) ?? [];
|
|
||||||
existing.push(r);
|
|
||||||
dateMap.set(r.attendanceDate, existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dates = [...dateMap.keys()].sort();
|
|
||||||
const sessions = ['上午', '下午', '晚自习'];
|
|
||||||
|
|
||||||
let rows = '';
|
|
||||||
for (const date of dates.slice(-30)) {
|
|
||||||
const dayRecords = dateMap.get(date) ?? [];
|
|
||||||
const cellMap = new Map<string, string>();
|
|
||||||
for (const r of dayRecords) {
|
|
||||||
cellMap.set(r.session, r.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
let cells = '';
|
|
||||||
for (const session of sessions) {
|
|
||||||
const status = cellMap.get(session) ?? '';
|
|
||||||
cells += `<td>${status ? this.statusBadge(status) : '-'}</td>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
rows += `<tr><td class="nowrap">${this.esc(date)}</td>${cells}</tr>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<div class="card" style="margin-bottom:14px;">
|
|
||||||
<h3>考勤明细(最近30条)</h3>
|
|
||||||
<table class="data-table">
|
|
||||||
<thead><tr>
|
|
||||||
<th>日期</th>
|
|
||||||
${sessions.map((s) => `<th>${this.esc(s)}</th>`).join('')}
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>${rows}</tbody>
|
|
||||||
</table>
|
|
||||||
<div class="note">图例: <span class="status present">到</span> 出勤 <span class="status absent">缺</span> 缺勤 <span class="status late">迟</span> 迟到 <span class="status leave">假</span> 请假</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private statusBadge(status: string): string {
|
|
||||||
const map: Record<string, { cls: string; text: string }> = {
|
|
||||||
present: { cls: 'present', text: '到' },
|
|
||||||
absent: { cls: 'absent', text: '缺' },
|
|
||||||
late: { cls: 'late', text: '迟' },
|
|
||||||
leave: { cls: 'leave', text: '假' },
|
|
||||||
};
|
|
||||||
const entry = map[status];
|
|
||||||
if (!entry) return `<span class="muted">${this.esc(status)}</span>`;
|
|
||||||
return `<span class="status ${entry.cls}">${entry.text}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildExamDetail(exams: ExamScore[], now: string): string {
|
|
||||||
const cultureExams = exams.filter(
|
|
||||||
(e) => e.examType && e.examType.includes('文化'),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (cultureExams.length === 0) {
|
|
||||||
return this.pageFrame(`
|
|
||||||
${this.pageHeader('文化课考试成绩')}
|
|
||||||
<div class="title-row">
|
|
||||||
<div>
|
|
||||||
<div class="source">${this.esc(now)} · 系统生成</div>
|
|
||||||
<div class="section-title">文化课考试成绩</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="banner-note">暂无文化课考试成绩</div>
|
|
||||||
${this.pageFooter()}
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group by subject
|
|
||||||
const subjectMap = new Map<string, ExamScore[]>();
|
|
||||||
for (const e of cultureExams) {
|
|
||||||
const subject = e.subject || '其他';
|
|
||||||
const existing = subjectMap.get(subject) ?? [];
|
|
||||||
existing.push(e);
|
|
||||||
subjectMap.set(subject, existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
let subjectCards = '';
|
|
||||||
for (const [subject, subExams] of subjectMap) {
|
|
||||||
const best = Math.max(...subExams.map((e) => e.score ?? 0));
|
|
||||||
const avg = (
|
|
||||||
subExams.reduce((sum, e) => sum + (e.score ?? 0), 0) / subExams.length
|
|
||||||
).toFixed(1);
|
|
||||||
|
|
||||||
let rows = '';
|
|
||||||
for (const e of subExams) {
|
|
||||||
rows += `<tr>
|
|
||||||
<td>${this.esc(e.examName || '-')}</td>
|
|
||||||
<td>${e.score != null ? e.score : '-'}</td>
|
|
||||||
<td>${e.classAvg != null ? e.classAvg : '-'}</td>
|
|
||||||
<td>${e.rank != null ? e.rank : '-'}</td>
|
|
||||||
<td class="nowrap">${this.esc(e.examDate || '-')}</td>
|
|
||||||
</tr>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
subjectCards += `<div class="card" style="margin-bottom:14px;">
|
|
||||||
<h3>${this.esc(subject)} · 最佳 ${best} · 均分 ${this.esc(avg)}</h3>
|
|
||||||
<table class="data-table">
|
|
||||||
<thead><tr>
|
|
||||||
<th>考试名称</th><th>分数</th><th>班均</th><th>排名</th><th>日期</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>${rows}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.pageFrame(`
|
|
||||||
${this.pageHeader('文化课考试成绩')}
|
|
||||||
<div class="title-row">
|
|
||||||
<div>
|
|
||||||
<div class="source">${this.esc(now)} · 系统生成</div>
|
|
||||||
<div class="section-title">文化课考试成绩</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
${subjectCards}
|
|
||||||
${this.pageFooter()}
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildLearningAndResult(
|
|
||||||
learnings: LearningRecord[],
|
|
||||||
result: ResultArchive | null,
|
|
||||||
now: string,
|
|
||||||
): string {
|
|
||||||
let learningHtml = '';
|
|
||||||
if (learnings.length === 0) {
|
|
||||||
learningHtml = `
|
|
||||||
<div class="title-row">
|
|
||||||
<div>
|
|
||||||
<div class="source">${this.esc(now)} · 系统生成</div>
|
|
||||||
<div class="section-title">学情记录</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="banner-note">暂无学情记录</div>`;
|
|
||||||
} else {
|
|
||||||
const latest = learnings.slice(0, 15);
|
|
||||||
let rows = '';
|
|
||||||
for (const r of latest) {
|
|
||||||
rows += `<tr>
|
|
||||||
<td class="nowrap">${this.esc(r.recordDate || '-')}</td>
|
|
||||||
<td>${this.esc(r.recordType || '-')}</td>
|
|
||||||
<td>${this.esc((r.content || '-').slice(0, 200))}</td>
|
|
||||||
<td>${this.esc(r.followUpMethod || '-')}</td>
|
|
||||||
</tr>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
learningHtml = `
|
|
||||||
<div class="title-row">
|
|
||||||
<div>
|
|
||||||
<div class="source">${this.esc(now)} · 系统生成</div>
|
|
||||||
<div class="section-title">学情记录</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="margin-bottom:14px;">
|
|
||||||
<h3>最近学情记录</h3>
|
|
||||||
<table class="data-table">
|
|
||||||
<thead><tr>
|
|
||||||
<th style="width:90px;">日期</th><th style="width:60px;">类型</th>
|
|
||||||
<th>内容</th><th style="width:70px;">跟进方式</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>${rows}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let resultHtml = '';
|
|
||||||
if (result) {
|
|
||||||
resultHtml = `
|
|
||||||
<div class="title-row">
|
|
||||||
<div>
|
|
||||||
<div class="section-title">录取归档</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="grid-2">
|
|
||||||
<div class="summary-row"><span>文化课成绩</span><span><strong>${result.cultureFinalScore != null ? result.cultureFinalScore : '-'}</strong></span></div>
|
|
||||||
<div class="summary-row"><span>专业课成绩</span><span><strong>${result.professionalFinalScore != null ? result.professionalFinalScore : '-'}</strong></span></div>
|
|
||||||
<div class="summary-row"><span>录取状态</span><span><strong>${this.esc(result.admissionStatus || '-')}</strong></span></div>
|
|
||||||
<div class="summary-row"><span>录取院校</span><span><strong>${this.esc(result.admittedCollege || '-')}</strong></span></div>
|
|
||||||
<div class="summary-row"><span>录取专业</span><span><strong>${this.esc(result.admittedMajor || '-')}</strong></span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="note">录取归档信息为最终结果,如有疑问请联系教务处</div>`;
|
|
||||||
} else {
|
|
||||||
resultHtml = `
|
|
||||||
<div class="title-row">
|
|
||||||
<div>
|
|
||||||
<div class="section-title">录取归档</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="banner-note">暂无录取归档信息</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.pageFrame(`
|
|
||||||
${this.pageHeader('学情记录与录取归档')}
|
|
||||||
${learningHtml}
|
|
||||||
${resultHtml}
|
|
||||||
${this.pageFooter()}
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private esc(value: string): string {
|
|
||||||
return value
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
142
apps/server/src/archive/archive-report.styles.ts
Normal file
142
apps/server/src/archive/archive-report.styles.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
export const ARCHIVE_REPORT_CSS = `
|
||||||
|
@page { size: A4; margin: 0; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; background: #eef3f8; color: #101828;
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
|
||||||
|
-webkit-print-color-adjust: exact; print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
position: relative; width: 210mm; height: 297mm;
|
||||||
|
margin: 0 auto 18px; padding: 14mm 15mm 10mm;
|
||||||
|
overflow: hidden; background: #fff; page-break-after: always;
|
||||||
|
}
|
||||||
|
.frame {
|
||||||
|
position: absolute; inset: 14mm; border: 1px solid #cfe0f2; pointer-events: none;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
position: relative; z-index: 1; display: flex; align-items: center;
|
||||||
|
height: 39px; padding-bottom: 8px; border-bottom: 1px solid #cfe0f2;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 24px; height: 24px; border-radius: 6px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
margin-right: 8px; color: #fff; background: #155aa8;
|
||||||
|
font-weight: 800; font-size: 11px;
|
||||||
|
}
|
||||||
|
.brand { font-size: 10px; font-weight: 700; }
|
||||||
|
.page-kicker { margin-left: auto; font-size: 10px; color: #667085; }
|
||||||
|
.footer {
|
||||||
|
position: absolute; left: 15mm; right: 15mm; bottom: 8mm; z-index: 1;
|
||||||
|
display: flex; justify-content: space-between;
|
||||||
|
border-top: 1px solid #cfe0f2; padding-top: 5px;
|
||||||
|
font-size: 10px; color: #667085;
|
||||||
|
}
|
||||||
|
h1, h2, h3, p { margin: 0; }
|
||||||
|
.section-title { font-size: 24px; line-height: 1.24; font-weight: 800; }
|
||||||
|
.source { font-size: 12px; color: #667085; padding-bottom: 2px; }
|
||||||
|
.title-row {
|
||||||
|
display: flex; align-items: flex-end; justify-content: space-between;
|
||||||
|
margin: 26px 0 17px;
|
||||||
|
}
|
||||||
|
.cover-title { margin-top: 60px; font-size: 34px; line-height: 1.22; font-weight: 800; }
|
||||||
|
.cover-subtitle { margin-top: 22px; font-size: 16px; color: #667085; }
|
||||||
|
.cover-main {
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 63px;
|
||||||
|
}
|
||||||
|
.cover-name-card {
|
||||||
|
min-height: 174px; border: 1px solid #cfe0f2;
|
||||||
|
border-left: 5px solid #155aa8; padding: 22px 24px;
|
||||||
|
}
|
||||||
|
.cover-name {
|
||||||
|
font-size: 44px; line-height: 1.14; font-weight: 800; color: #155aa8;
|
||||||
|
}
|
||||||
|
.cover-desc { margin-top: 22px; font-size: 16px; color: #667085; }
|
||||||
|
.cover-info { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.cover-cell {
|
||||||
|
min-height: 61px; border: 1px solid #cfe0f2; padding: 11px 14px;
|
||||||
|
}
|
||||||
|
.label { font-size: 11px; color: #667085; margin-bottom: 8px; }
|
||||||
|
.value { font-size: 14px; line-height: 1.5; font-weight: 700; }
|
||||||
|
.toc { margin-top: 58px; }
|
||||||
|
.toc-row {
|
||||||
|
display: grid; grid-template-columns: 48px 1fr 72px; align-items: center;
|
||||||
|
height: 47px; border-bottom: 1px solid #cfe0f2;
|
||||||
|
}
|
||||||
|
.toc-index { color: #155aa8; font-size: 15px; font-weight: 800; }
|
||||||
|
.toc-name { font-size: 14px; font-weight: 800; }
|
||||||
|
.toc-page { text-align: right; color: #667085; font-size: 12px; }
|
||||||
|
.watermark {
|
||||||
|
position: absolute; right: 36px; bottom: 82px; color: #eaf1fb;
|
||||||
|
font-size: 56px; font-weight: 900; writing-mode: vertical-rl;
|
||||||
|
}
|
||||||
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||||
|
.card { border: 1px solid #cfe0f2; padding: 14px; background: #fff; }
|
||||||
|
.card h3 { font-size: 16px; margin-bottom: 14px; }
|
||||||
|
.data-table {
|
||||||
|
width: 100%; border-collapse: collapse; table-layout: fixed;
|
||||||
|
}
|
||||||
|
.data-table th, .data-table td {
|
||||||
|
border: 1px solid #d6e3f2; padding: 8px 9px; font-size: 12px;
|
||||||
|
line-height: 1.55; vertical-align: top; text-align: left;
|
||||||
|
}
|
||||||
|
.data-table th {
|
||||||
|
background: #eaf3fd; color: #173f6f; font-weight: 800; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.data-table td { overflow-wrap: anywhere; word-break: break-word; }
|
||||||
|
.data-table .nowrap { white-space: nowrap; }
|
||||||
|
.metric {
|
||||||
|
min-height: 88px; border: 1px solid #cfe0f2; padding: 13px 14px;
|
||||||
|
}
|
||||||
|
.metric .label { margin-bottom: 7px; }
|
||||||
|
.metric strong {
|
||||||
|
display: block; color: #155aa8; font-size: 27px; line-height: 1.16;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.metric p {
|
||||||
|
color: #667085; font-size: 12px; line-height: 1.45;
|
||||||
|
}
|
||||||
|
.summary-row {
|
||||||
|
display: grid; grid-template-columns: 92px 1fr; gap: 12px;
|
||||||
|
padding: 14px 0; border-bottom: 1px solid #d6e3f2;
|
||||||
|
font-size: 13px; line-height: 1.6;
|
||||||
|
}
|
||||||
|
.summary-row:last-child { border-bottom: 0; }
|
||||||
|
.summary-row strong { color: #155aa8; }
|
||||||
|
.note {
|
||||||
|
margin-top: 14px; padding: 12px 16px; border-left: 4px solid #155aa8;
|
||||||
|
background: #eef5ff; color: #173f6f; font-size: 12px; line-height: 1.7;
|
||||||
|
}
|
||||||
|
.banner-note {
|
||||||
|
margin-top: 12px; padding: 11px 16px; background: #eef5ff;
|
||||||
|
color: #173f6f; font-size: 12px; line-height: 1.7;
|
||||||
|
}
|
||||||
|
.line-chart { width: 100%; height: 180px; display: block; }
|
||||||
|
.bar-chart { width: 100%; height: 150px; display: block; }
|
||||||
|
.status {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 18px; height: 18px; border-radius: 5px; margin-right: 6px;
|
||||||
|
color: #fff; font-size: 11px; font-weight: 800;
|
||||||
|
}
|
||||||
|
.present { background: #18a77d; }
|
||||||
|
.leave { background: #f15b75; }
|
||||||
|
.late { background: #f59e0b; }
|
||||||
|
.absent { background: #dc2626; }
|
||||||
|
.progress-row {
|
||||||
|
display: grid; grid-template-columns: 72px 1fr 42px; align-items: center;
|
||||||
|
gap: 8px; margin: 10px 0; font-size: 12px;
|
||||||
|
}
|
||||||
|
.progress-track {
|
||||||
|
height: 11px; border-radius: 999px; background: #dfeaf6; overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-track i {
|
||||||
|
display: block; height: 100%; border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, #155aa8, #2e7df0);
|
||||||
|
}
|
||||||
|
.muted { color: #667085; }
|
||||||
|
@media print {
|
||||||
|
body { background: #fff; }
|
||||||
|
.page { margin: 0; box-shadow: none; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
} from './dto/archive.dto';
|
} from './dto/archive.dto';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import { extractRequestInfo } from '../common/request-utils';
|
import { withAuditLog } from '../common/with-audit-log';
|
||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
|
|
||||||
interface AuthenticatedRequest extends ExpressRequest {
|
interface AuthenticatedRequest extends ExpressRequest {
|
||||||
@@ -49,19 +49,9 @@ export class ArchiveController {
|
|||||||
@Get(':studentId')
|
@Get(':studentId')
|
||||||
@RequirePermission('student:view')
|
@RequirePermission('student:view')
|
||||||
async getProfile(@Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest) {
|
async getProfile(@Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
return withAuditLog(this.logService, req, (_result) => ({
|
||||||
const result = await this.archiveService.getProfile(studentId);
|
module: '学生档案', action: '查看档案', targetId: studentId, targetType: 'archive',
|
||||||
await this.logService.log({
|
}), () => this.archiveService.getProfile(studentId));
|
||||||
userId: req.user?.id,
|
|
||||||
username: req.user?.username,
|
|
||||||
module: '学生档案',
|
|
||||||
action: '查看档案',
|
|
||||||
targetId: studentId,
|
|
||||||
targetType: 'archive',
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':studentId/profile')
|
@Put(':studentId/profile')
|
||||||
@@ -71,20 +61,9 @@ export class ArchiveController {
|
|||||||
@Body() dto: UpsertProfileDto,
|
@Body() dto: UpsertProfileDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
return withAuditLog(this.logService, req, (_result) => ({
|
||||||
const result = await this.archiveService.upsertProfile(studentId, dto);
|
module: '学生档案', action: '更新档案信息', targetId: studentId, targetType: 'student_profile', detail: JSON.stringify(dto),
|
||||||
await this.logService.log({
|
}), () => this.archiveService.upsertProfile(studentId, dto));
|
||||||
userId: req.user?.id,
|
|
||||||
username: req.user?.username,
|
|
||||||
module: '学生档案',
|
|
||||||
action: '更新档案信息',
|
|
||||||
targetId: studentId,
|
|
||||||
targetType: 'student_profile',
|
|
||||||
detail: JSON.stringify(dto),
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':studentId/enrollments')
|
@Post(':studentId/enrollments')
|
||||||
@@ -94,20 +73,9 @@ export class ArchiveController {
|
|||||||
@Body() dto: CreateEnrollmentDto,
|
@Body() dto: CreateEnrollmentDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
return withAuditLog(this.logService, req, (result) => ({
|
||||||
const result = await this.archiveService.addEnrollment(studentId, dto);
|
module: '学生档案', action: '添加报名记录', targetId: result.id, targetType: 'student_enrollment', detail: `${dto.courseCategory} - ${dto.classType}`,
|
||||||
await this.logService.log({
|
}), () => this.archiveService.addEnrollment(studentId, dto));
|
||||||
userId: req.user?.id,
|
|
||||||
username: req.user?.username,
|
|
||||||
module: '学生档案',
|
|
||||||
action: '添加报名记录',
|
|
||||||
targetId: result.id,
|
|
||||||
targetType: 'student_enrollment',
|
|
||||||
detail: `${dto.courseCategory} - ${dto.classType}`,
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('enrollments/:id')
|
@Put('enrollments/:id')
|
||||||
@@ -117,38 +85,25 @@ export class ArchiveController {
|
|||||||
@Body() dto: UpdateEnrollmentDto,
|
@Body() dto: UpdateEnrollmentDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
return withAuditLog(this.logService, req, (_result) => ({
|
||||||
const result = await this.archiveService.updateEnrollment(id, dto);
|
module: '学生档案', action: '编辑报名记录', targetId: id, targetType: 'student_enrollment', detail: JSON.stringify(dto),
|
||||||
await this.logService.log({
|
}), () => this.archiveService.updateEnrollment(id, dto));
|
||||||
userId: req.user?.id,
|
|
||||||
username: req.user?.username,
|
|
||||||
module: '学生档案',
|
|
||||||
action: '编辑报名记录',
|
|
||||||
targetId: id,
|
|
||||||
targetType: 'student_enrollment',
|
|
||||||
detail: JSON.stringify(dto),
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('enrollments/:id')
|
@Delete('enrollments/:id')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async deleteEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
async deleteEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
return withAuditLog(this.logService, req, (_result) => ({
|
||||||
const result = await this.archiveService.deleteEnrollment(id);
|
module: '学生档案', action: '归档报名记录', targetId: id, targetType: 'student_enrollment',
|
||||||
await this.logService.log({
|
}), () => this.archiveService.deleteEnrollment(id));
|
||||||
userId: req.user?.id,
|
}
|
||||||
username: req.user?.username,
|
|
||||||
module: '学生档案',
|
@Delete('enrollments/:id/permanent')
|
||||||
action: '归档报名记录',
|
@RequirePermission('archive:purge')
|
||||||
targetId: id,
|
async purgeEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
targetType: 'student_enrollment',
|
return withAuditLog(this.logService, req, (_result) => ({
|
||||||
ipAddress,
|
module: '学生档案', action: '永久删除报名记录', targetId: id, targetType: 'student_enrollment', detail: '物理删除,不可恢复',
|
||||||
userAgent,
|
}), () => this.archiveService.purgeEnrollment(id));
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':studentId/exam-scores')
|
@Post(':studentId/exam-scores')
|
||||||
@@ -158,20 +113,9 @@ export class ArchiveController {
|
|||||||
@Body() dto: CreateExamScoreDto,
|
@Body() dto: CreateExamScoreDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
return withAuditLog(this.logService, req, (result) => ({
|
||||||
const result = await this.archiveService.addExamScore(studentId, dto);
|
module: '学生档案', action: '添加考试成绩', targetId: result.id, targetType: 'exam_score', detail: `${dto.examType} - ${dto.subject}: ${dto.score}`,
|
||||||
await this.logService.log({
|
}), () => this.archiveService.addExamScore(studentId, dto));
|
||||||
userId: req.user?.id,
|
|
||||||
username: req.user?.username,
|
|
||||||
module: '学生档案',
|
|
||||||
action: '添加考试成绩',
|
|
||||||
targetId: result.id,
|
|
||||||
targetType: 'exam_score',
|
|
||||||
detail: `${dto.examType} - ${dto.subject}: ${dto.score}`,
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('exam-scores/:id')
|
@Put('exam-scores/:id')
|
||||||
@@ -181,38 +125,25 @@ export class ArchiveController {
|
|||||||
@Body() dto: UpdateExamScoreDto,
|
@Body() dto: UpdateExamScoreDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
return withAuditLog(this.logService, req, (_result) => ({
|
||||||
const result = await this.archiveService.updateExamScore(id, dto);
|
module: '学生档案', action: '编辑考试成绩', targetId: id, targetType: 'exam_score', detail: JSON.stringify(dto),
|
||||||
await this.logService.log({
|
}), () => this.archiveService.updateExamScore(id, dto));
|
||||||
userId: req.user?.id,
|
|
||||||
username: req.user?.username,
|
|
||||||
module: '学生档案',
|
|
||||||
action: '编辑考试成绩',
|
|
||||||
targetId: id,
|
|
||||||
targetType: 'exam_score',
|
|
||||||
detail: JSON.stringify(dto),
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('exam-scores/:id')
|
@Delete('exam-scores/:id')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async deleteExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
async deleteExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
return withAuditLog(this.logService, req, (_result) => ({
|
||||||
const result = await this.archiveService.deleteExamScore(id);
|
module: '学生档案', action: '归档考试成绩', targetId: id, targetType: 'exam_score',
|
||||||
await this.logService.log({
|
}), () => this.archiveService.deleteExamScore(id));
|
||||||
userId: req.user?.id,
|
}
|
||||||
username: req.user?.username,
|
|
||||||
module: '学生档案',
|
@Delete('exam-scores/:id/permanent')
|
||||||
action: '归档考试成绩',
|
@RequirePermission('archive:purge')
|
||||||
targetId: id,
|
async purgeExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
targetType: 'exam_score',
|
return withAuditLog(this.logService, req, (_result) => ({
|
||||||
ipAddress,
|
module: '学生档案', action: '永久删除考试成绩', targetId: id, targetType: 'exam_score', detail: '物理删除,不可恢复',
|
||||||
userAgent,
|
}), () => this.archiveService.purgeExamScore(id));
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':studentId/learning-records')
|
@Post(':studentId/learning-records')
|
||||||
@@ -222,20 +153,9 @@ export class ArchiveController {
|
|||||||
@Body() dto: CreateLearningRecordDto,
|
@Body() dto: CreateLearningRecordDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
return withAuditLog(this.logService, req, (result) => ({
|
||||||
const result = await this.archiveService.addLearningRecord(studentId, dto);
|
module: '学生档案', action: '添加学习记录', targetId: result.id, targetType: 'learning_record', detail: `${dto.recordType}: ${dto.content.substring(0, 50)}`,
|
||||||
await this.logService.log({
|
}), () => this.archiveService.addLearningRecord(studentId, dto));
|
||||||
userId: req.user?.id,
|
|
||||||
username: req.user?.username,
|
|
||||||
module: '学生档案',
|
|
||||||
action: '添加学习记录',
|
|
||||||
targetId: result.id,
|
|
||||||
targetType: 'learning_record',
|
|
||||||
detail: `${dto.recordType}: ${dto.content.substring(0, 50)}`,
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('learning-records/:id')
|
@Put('learning-records/:id')
|
||||||
@@ -245,38 +165,25 @@ export class ArchiveController {
|
|||||||
@Body() dto: UpdateLearningRecordDto,
|
@Body() dto: UpdateLearningRecordDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
return withAuditLog(this.logService, req, (_result) => ({
|
||||||
const result = await this.archiveService.updateLearningRecord(id, dto);
|
module: '学生档案', action: '编辑学习记录', targetId: id, targetType: 'learning_record', detail: JSON.stringify(dto),
|
||||||
await this.logService.log({
|
}), () => this.archiveService.updateLearningRecord(id, dto));
|
||||||
userId: req.user?.id,
|
|
||||||
username: req.user?.username,
|
|
||||||
module: '学生档案',
|
|
||||||
action: '编辑学习记录',
|
|
||||||
targetId: id,
|
|
||||||
targetType: 'learning_record',
|
|
||||||
detail: JSON.stringify(dto),
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('learning-records/:id')
|
@Delete('learning-records/:id')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async deleteLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
async deleteLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
return withAuditLog(this.logService, req, (_result) => ({
|
||||||
const result = await this.archiveService.deleteLearningRecord(id);
|
module: '学生档案', action: '归档学习记录', targetId: id, targetType: 'learning_record',
|
||||||
await this.logService.log({
|
}), () => this.archiveService.deleteLearningRecord(id));
|
||||||
userId: req.user?.id,
|
}
|
||||||
username: req.user?.username,
|
|
||||||
module: '学生档案',
|
@Delete('learning-records/:id/permanent')
|
||||||
action: '归档学习记录',
|
@RequirePermission('archive:purge')
|
||||||
targetId: id,
|
async purgeLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
targetType: 'learning_record',
|
return withAuditLog(this.logService, req, (_result) => ({
|
||||||
ipAddress,
|
module: '学生档案', action: '永久删除学习记录', targetId: id, targetType: 'learning_record', detail: '物理删除,不可恢复',
|
||||||
userAgent,
|
}), () => this.archiveService.purgeLearningRecord(id));
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':studentId/result')
|
@Put(':studentId/result')
|
||||||
@@ -286,20 +193,9 @@ export class ArchiveController {
|
|||||||
@Body() dto: UpsertResultDto,
|
@Body() dto: UpsertResultDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
return withAuditLog(this.logService, req, (_result) => ({
|
||||||
const result = await this.archiveService.upsertResult(studentId, dto);
|
module: '学生档案', action: '更新录取结果', targetId: studentId, targetType: 'result_archive', detail: JSON.stringify(dto),
|
||||||
await this.logService.log({
|
}), () => this.archiveService.upsertResult(studentId, dto));
|
||||||
userId: req.user?.id,
|
|
||||||
username: req.user?.username,
|
|
||||||
module: '学生档案',
|
|
||||||
action: '更新录取结果',
|
|
||||||
targetId: studentId,
|
|
||||||
targetType: 'result_archive',
|
|
||||||
detail: JSON.stringify(dto),
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':studentId/attachments')
|
@Post(':studentId/attachments')
|
||||||
@@ -311,20 +207,9 @@ export class ArchiveController {
|
|||||||
@Body('category') category: string,
|
@Body('category') category: string,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
return withAuditLog(this.logService, req, (result) => ({
|
||||||
const result = await this.archiveService.addAttachment(studentId, file, category || 'other');
|
module: '学生档案', action: '上传附件', targetId: result.id, targetType: 'archive_attachment', detail: `${file.originalname} (${category || 'other'})`,
|
||||||
await this.logService.log({
|
}), () => this.archiveService.addAttachment(studentId, file, category || 'other'));
|
||||||
userId: req.user?.id,
|
|
||||||
username: req.user?.username,
|
|
||||||
module: '学生档案',
|
|
||||||
action: '上传附件',
|
|
||||||
targetId: result.id,
|
|
||||||
targetType: 'archive_attachment',
|
|
||||||
detail: `${file.originalname} (${category || 'other'})`,
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':studentId/attachments/:id')
|
@Get(':studentId/attachments/:id')
|
||||||
@@ -347,19 +232,17 @@ export class ArchiveController {
|
|||||||
@Delete('attachments/:id')
|
@Delete('attachments/:id')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async deleteAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
async deleteAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
return withAuditLog(this.logService, req, (_result) => ({
|
||||||
const result = await this.archiveService.deleteAttachment(id);
|
module: '学生档案', action: '归档附件', targetId: id, targetType: 'archive_attachment',
|
||||||
await this.logService.log({
|
}), () => this.archiveService.deleteAttachment(id));
|
||||||
userId: req.user?.id,
|
}
|
||||||
username: req.user?.username,
|
|
||||||
module: '学生档案',
|
@Delete('attachments/:id/permanent')
|
||||||
action: '归档附件',
|
@RequirePermission('archive:purge')
|
||||||
targetId: id,
|
async purgeAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
targetType: 'archive_attachment',
|
return withAuditLog(this.logService, req, (_result) => ({
|
||||||
ipAddress,
|
module: '学生档案', action: '永久删除附件', targetId: id, targetType: 'archive_attachment', detail: '物理删除,不可恢复',
|
||||||
userAgent,
|
}), () => this.archiveService.purgeAttachment(id));
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':studentId/report-html')
|
@Get(':studentId/report-html')
|
||||||
@@ -368,18 +251,11 @@ export class ArchiveController {
|
|||||||
@Param('studentId', ParseIntPipe) studentId: number,
|
@Param('studentId', ParseIntPipe) studentId: number,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
return withAuditLog(this.logService, req, () => ({
|
||||||
await this.logService.log({
|
module: 'archive', action: 'generate_report_html', targetId: studentId, targetType: 'student',
|
||||||
userId: req.user?.id,
|
}), async () => {
|
||||||
username: req.user?.username,
|
const html = await this.reportService.generateReportHtml(studentId);
|
||||||
module: 'archive',
|
return { html };
|
||||||
action: 'generate_report_html',
|
|
||||||
targetId: studentId,
|
|
||||||
targetType: 'student',
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
});
|
||||||
const html = await this.reportService.generateReportHtml(studentId);
|
|
||||||
return { html };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
apps/server/src/archive/archive.purge.controller.spec.ts
Normal file
38
apps/server/src/archive/archive.purge.controller.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
|
||||||
|
import { ArchiveController } from './archive.controller';
|
||||||
|
|
||||||
|
describe('ArchiveController purge routes', () => {
|
||||||
|
it('requires archive:purge on permanent delete routes', () => {
|
||||||
|
expect(
|
||||||
|
Reflect.getMetadata(PERMISSION_KEY, ArchiveController.prototype.purgeEnrollment),
|
||||||
|
).toEqual(['archive:purge']);
|
||||||
|
expect(
|
||||||
|
Reflect.getMetadata(PERMISSION_KEY, ArchiveController.prototype.purgeExamScore),
|
||||||
|
).toEqual(['archive:purge']);
|
||||||
|
expect(
|
||||||
|
Reflect.getMetadata(PERMISSION_KEY, ArchiveController.prototype.purgeLearningRecord),
|
||||||
|
).toEqual(['archive:purge']);
|
||||||
|
expect(
|
||||||
|
Reflect.getMetadata(PERMISSION_KEY, ArchiveController.prototype.purgeAttachment),
|
||||||
|
).toEqual(['archive:purge']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes permanent delete audit logs for sub-records', async () => {
|
||||||
|
const archiveService = {
|
||||||
|
purgeEnrollment: jest.fn().mockResolvedValue({ message: '已永久删除报名记录(不可恢复)' }),
|
||||||
|
};
|
||||||
|
const log = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const controller = new ArchiveController(
|
||||||
|
archiveService as never,
|
||||||
|
{ log } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
|
||||||
|
await controller.purgeEnrollment(1, req);
|
||||||
|
expect(archiveService.purgeEnrollment).toHaveBeenCalledWith(1);
|
||||||
|
expect(log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ module: '学生档案', action: '永久删除报名记录', targetId: 1 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
114
apps/server/src/archive/archive.purge.spec.ts
Normal file
114
apps/server/src/archive/archive.purge.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { ArchiveService } from './archive.service';
|
||||||
|
|
||||||
|
describe('ArchiveService purge sub-records', () => {
|
||||||
|
const createService = (overrides?: {
|
||||||
|
enrollment?: Record<string, unknown>;
|
||||||
|
examScore?: Record<string, unknown>;
|
||||||
|
learningRecord?: Record<string, unknown>;
|
||||||
|
attachment?: Record<string, unknown>;
|
||||||
|
scoreCount?: number;
|
||||||
|
}) => {
|
||||||
|
const enrollmentRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
status: 'archived',
|
||||||
|
...overrides?.enrollment,
|
||||||
|
}),
|
||||||
|
delete: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||||
|
};
|
||||||
|
const examScoreRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({
|
||||||
|
id: 2,
|
||||||
|
status: 'archived',
|
||||||
|
...overrides?.examScore,
|
||||||
|
}),
|
||||||
|
count: jest.fn().mockResolvedValue(overrides?.scoreCount ?? 0),
|
||||||
|
delete: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||||
|
};
|
||||||
|
const learningRecordRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({
|
||||||
|
id: 3,
|
||||||
|
status: 'archived',
|
||||||
|
...overrides?.learningRecord,
|
||||||
|
}),
|
||||||
|
delete: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||||
|
};
|
||||||
|
const attachmentRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({
|
||||||
|
id: 4,
|
||||||
|
status: 'archived',
|
||||||
|
filePath: 'x.pdf',
|
||||||
|
...overrides?.attachment,
|
||||||
|
}),
|
||||||
|
delete: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||||
|
};
|
||||||
|
const service = new ArchiveService(
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
enrollmentRepo as never,
|
||||||
|
examScoreRepo as never,
|
||||||
|
learningRecordRepo as never,
|
||||||
|
{} as never,
|
||||||
|
attachmentRepo as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
return { service, enrollmentRepo, examScoreRepo, learningRecordRepo, attachmentRepo };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('rejects non-archived sub-records', async () => {
|
||||||
|
const { service, enrollmentRepo } = createService({ enrollment: { status: 'active' } });
|
||||||
|
await expect(service.purgeEnrollment(1)).rejects.toThrow(
|
||||||
|
new BadRequestException('仅已归档报名记录可以永久删除,请先归档'),
|
||||||
|
);
|
||||||
|
expect(enrollmentRepo.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects enrollments referenced by exam scores', async () => {
|
||||||
|
const { service, enrollmentRepo } = createService({ scoreCount: 1 });
|
||||||
|
await expect(service.purgeEnrollment(1)).rejects.toThrow(
|
||||||
|
new BadRequestException('该报名记录已被考试成绩引用,无法永久删除'),
|
||||||
|
);
|
||||||
|
expect(enrollmentRepo.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes archived enrollment, exam score, and learning record', async () => {
|
||||||
|
const { service, enrollmentRepo, examScoreRepo, learningRecordRepo } = createService();
|
||||||
|
await expect(service.purgeEnrollment(1)).resolves.toEqual({
|
||||||
|
message: '已永久删除报名记录(不可恢复)',
|
||||||
|
});
|
||||||
|
await expect(service.purgeExamScore(2)).resolves.toEqual({
|
||||||
|
message: '已永久删除考试成绩(不可恢复)',
|
||||||
|
});
|
||||||
|
await expect(service.purgeLearningRecord(3)).resolves.toEqual({
|
||||||
|
message: '已永久删除学习记录(不可恢复)',
|
||||||
|
});
|
||||||
|
expect(enrollmentRepo.delete).toHaveBeenCalledWith(1);
|
||||||
|
expect(examScoreRepo.delete).toHaveBeenCalledWith(2);
|
||||||
|
expect(learningRecordRepo.delete).toHaveBeenCalledWith(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes the attachment row and removes the disk file', async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'archive-purge-'));
|
||||||
|
process.env.UPLOAD_DIR = tmpDir;
|
||||||
|
const filePath = 'x.pdf';
|
||||||
|
const fullPath = path.join(tmpDir, 'archive', filePath);
|
||||||
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
fs.writeFileSync(fullPath, 'data');
|
||||||
|
try {
|
||||||
|
const { service, attachmentRepo } = createService({ attachment: { filePath } });
|
||||||
|
await expect(service.purgeAttachment(4)).resolves.toEqual({
|
||||||
|
message: '已永久删除附件(不可恢复)',
|
||||||
|
});
|
||||||
|
expect(fs.existsSync(fullPath)).toBe(false);
|
||||||
|
expect(attachmentRepo.delete).toHaveBeenCalledWith(4);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
delete process.env.UPLOAD_DIR;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -74,15 +74,15 @@ export class ArchiveService {
|
|||||||
attendances,
|
attendances,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.profileRepo.findOne({ where: { studentId } }),
|
this.profileRepo.findOne({ where: { studentId } }),
|
||||||
this.enrollmentRepo.find({ where: { studentId, status: 'active' }, order: { createdAt: 'DESC' } }),
|
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||||
this.examScoreRepo.find({
|
this.examScoreRepo.find({
|
||||||
where: { studentId, status: 'active' },
|
where: { studentId },
|
||||||
relations: ['exam', 'exam.class'],
|
relations: ['exam', 'exam.class'],
|
||||||
order: { examDate: 'DESC' },
|
order: { examDate: 'DESC' },
|
||||||
}),
|
}),
|
||||||
this.learningRecordRepo.find({ where: { studentId, status: 'active' }, order: { recordDate: 'DESC' } }),
|
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
|
||||||
this.resultRepo.findOne({ where: { studentId } }),
|
this.resultRepo.findOne({ where: { studentId } }),
|
||||||
this.attachmentRepo.find({ where: { studentId, status: 'active' }, order: { createdAt: 'DESC' } }),
|
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||||
this.attendanceRepo.find({
|
this.attendanceRepo.find({
|
||||||
where: { studentId },
|
where: { studentId },
|
||||||
relations: ['schedule', 'class'],
|
relations: ['schedule', 'class'],
|
||||||
@@ -138,6 +138,20 @@ export class ArchiveService {
|
|||||||
return { message: '已归档' };
|
return { message: '已归档' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async purgeEnrollment(id: number) {
|
||||||
|
const entity = await this.enrollmentRepo.findOne({ where: { id } });
|
||||||
|
if (!entity) throw new NotFoundException('报名记录不存在');
|
||||||
|
if (entity.status !== 'archived') {
|
||||||
|
throw new BadRequestException('仅已归档报名记录可以永久删除,请先归档');
|
||||||
|
}
|
||||||
|
const scoreCount = await this.examScoreRepo.count({ where: { enrollmentId: id } });
|
||||||
|
if (scoreCount > 0) {
|
||||||
|
throw new BadRequestException('该报名记录已被考试成绩引用,无法永久删除');
|
||||||
|
}
|
||||||
|
await this.enrollmentRepo.delete(id);
|
||||||
|
return { message: '已永久删除报名记录(不可恢复)' };
|
||||||
|
}
|
||||||
|
|
||||||
private async assertEnrollmentBelongsToStudent(studentId: number, enrollmentId?: number) {
|
private async assertEnrollmentBelongsToStudent(studentId: number, enrollmentId?: number) {
|
||||||
if (enrollmentId === undefined) return;
|
if (enrollmentId === undefined) return;
|
||||||
const enrollment = await this.enrollmentRepo.findOne({
|
const enrollment = await this.enrollmentRepo.findOne({
|
||||||
@@ -173,6 +187,16 @@ export class ArchiveService {
|
|||||||
return { message: '已归档' };
|
return { message: '已归档' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async purgeExamScore(id: number) {
|
||||||
|
const entity = await this.examScoreRepo.findOne({ where: { id } });
|
||||||
|
if (!entity) throw new NotFoundException('考试成绩不存在');
|
||||||
|
if (entity.status !== 'archived') {
|
||||||
|
throw new BadRequestException('仅已归档考试成绩可以永久删除,请先归档');
|
||||||
|
}
|
||||||
|
await this.examScoreRepo.delete(id);
|
||||||
|
return { message: '已永久删除考试成绩(不可恢复)' };
|
||||||
|
}
|
||||||
|
|
||||||
async addLearningRecord(studentId: number, dto: CreateLearningRecordDto) {
|
async addLearningRecord(studentId: number, dto: CreateLearningRecordDto) {
|
||||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||||
if (!student) throw new NotFoundException('学生不存在');
|
if (!student) throw new NotFoundException('学生不存在');
|
||||||
@@ -196,6 +220,16 @@ export class ArchiveService {
|
|||||||
return { message: '已归档' };
|
return { message: '已归档' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async purgeLearningRecord(id: number) {
|
||||||
|
const entity = await this.learningRecordRepo.findOne({ where: { id } });
|
||||||
|
if (!entity) throw new NotFoundException('学习记录不存在');
|
||||||
|
if (entity.status !== 'archived') {
|
||||||
|
throw new BadRequestException('仅已归档学习记录可以永久删除,请先归档');
|
||||||
|
}
|
||||||
|
await this.learningRecordRepo.delete(id);
|
||||||
|
return { message: '已永久删除学习记录(不可恢复)' };
|
||||||
|
}
|
||||||
|
|
||||||
async upsertResult(studentId: number, dto: UpsertResultDto) {
|
async upsertResult(studentId: number, dto: UpsertResultDto) {
|
||||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||||
if (!student) throw new NotFoundException('学生不存在');
|
if (!student) throw new NotFoundException('学生不存在');
|
||||||
@@ -257,4 +291,23 @@ export class ArchiveService {
|
|||||||
await this.attachmentRepo.update(id, { status: 'archived' });
|
await this.attachmentRepo.update(id, { status: 'archived' });
|
||||||
return { message: '已归档' };
|
return { message: '已归档' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async purgeAttachment(id: number) {
|
||||||
|
const entity = await this.attachmentRepo.findOne({ where: { id } });
|
||||||
|
if (!entity) throw new NotFoundException('附件不存在');
|
||||||
|
if (entity.status !== 'archived') {
|
||||||
|
throw new BadRequestException('仅已归档附件可以永久删除,请先归档');
|
||||||
|
}
|
||||||
|
if (entity.filePath) {
|
||||||
|
try {
|
||||||
|
const fullPath = this.resolveAttachmentPath(entity.filePath);
|
||||||
|
if (fs.existsSync(fullPath)) fs.unlinkSync(fullPath);
|
||||||
|
} catch (error) {
|
||||||
|
// 磁盘文件删除失败仅告警,不阻塞数据库删除
|
||||||
|
console.warn(`[ArchiveService] 附件文件删除失败: ${entity.filePath}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.attachmentRepo.delete(id);
|
||||||
|
return { message: '已永久删除附件(不可恢复)' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user