Files
gongxue-base/apps/admin/src/components/StudentProfileContent/index.tsx
wangziqi 9565a0f23c feat(admin): 深化 TanStack Query 数据层
- 新增 queryKeys.ts 统一 QueryKey 工厂(覆盖全部模块,invalidate/refetch/prefetch 同源)
- 新增 queryClient.ts 全局配置:retry 1、staleTime 30s、gcTime 5min、refetchOnWindowFocus false
- 新增 useApiQuery:zod schema 校验 + 类型收敛 + select 转换,消除页面重复 validateResponse 样板
- useApiMutation 支持 onMutate/onSettled/context(乐观更新)
- 示范迁移:Organizations(useApiQuery + 乐观更新归档/恢复)、Students(useApiQuery + queryKeys + 打开抽屉前 prefetch 档案聚合)
- StudentProfileContent queryKey 统一为 queryKeys.archive.detail,prefetch 可命中缓存
2026-08-08 18:37:55 +08:00

652 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useMemo } from 'react';
import {
Tabs,
Card,
Descriptions,
Table,
Button,
Tag,
Space,
Empty,
Row,
Col,
Statistic,
Spin,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
EyeOutlined,
CloseOutlined,
FileTextOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
import { useViewSensitive } from '../../hooks/useViewSensitive';
import { message } from '../../ui/app-message';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import { queryKeys } from '../../api/queryKeys';
import { organizationOptionsSchema, studentProfileAggregateSchema } from '../../api/schemas';
import EditableCell from '../EditableCell';
import { usePermission } from '../../hooks/usePermission';
import { QueryErrorState } from '../QueryState';
import { ADMISSION_STATUS_MAP, ATTENDANCE_STATUS_MAP, SESSION_LABELS, getOptionLabel } from './shared';
import type { AttendanceRecordItem, ProfileData, ResultData, StudentInfo, StudentProfileAggregate, StudentProfileContentProps } from './shared';
import { EnrollmentsTab } from './EnrollmentsTab';
import { ExamScoresTab } from './ExamScoresTab';
import { LearningTab } from './LearningTab';
import { AttachmentsTab } from './AttachmentsTab';
const EditableField: React.FC<{
value: unknown;
onSave: (value: unknown) => Promise<void> | void;
editor?: React.ComponentProps<typeof EditableCell>['editor'];
min?: number;
required?: boolean;
children?: React.ReactNode;
}> = ({ value, onSave, editor, min, required, children }) => (
<EditableCell
value={value}
editor={editor}
min={min}
required={required}
permission="student:edit"
onSave={async (next) => {
await onSave(next);
}}
>
{children ?? String(value ?? '-')}
</EditableCell>
);
const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => {
const columns: ColumnsType<AttendanceRecordItem> = [
{ title: '日期', dataIndex: 'attendanceDate', width: 120 },
{
title: '课程',
render: (_: unknown, record) => record.schedule?.subject || record.class?.name || '课程考勤',
},
{
title: '时段',
dataIndex: 'session',
width: 100,
render: (value: string) => SESSION_LABELS[value] || value || '-',
},
{
title: '结果',
dataIndex: 'status',
width: 90,
render: (value: string) => {
const meta = ATTENDANCE_STATUS_MAP[value] || { text: value || '-', color: 'default' };
return <Tag color={meta.color}>{meta.text}</Tag>;
},
},
{
title: '打卡时间',
dataIndex: 'punchTime',
width: 170,
render: (value?: string | null) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-'),
},
{
title: '打卡设备',
render: (_: unknown, record) => {
const name = record.punchDeviceName?.trim();
const id = record.punchDeviceId?.trim();
if (name && id && name !== id) return `${name}${id}`;
return name || id || (record.source === 'manual' ? '老师手动标记' : '-');
},
},
{ title: '备注', dataIndex: 'remark', render: (value?: string | null) => value || '-' },
];
return data.length > 0 ? (
<Table<AttendanceRecordItem>
columns={columns}
dataSource={data}
rowKey="id"
scroll={{ x: 900 }}
pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50] }}
/>
) : (
<Empty description="暂无出勤记录" />
);
};
const InlineArchiveSummary: React.FC<{
studentId: number;
student: StudentInfo;
profile: ProfileData | null;
result: ResultData | null;
organizations: Array<{ id: number; name: string }>;
onRefresh: () => void;
onViewSensitive: (fieldLabel: string, value: string) => void;
canViewSensitive: boolean;
canChooseOrganization: boolean;
}> = ({
studentId,
student,
profile,
result,
organizations,
onViewSensitive,
canViewSensitive,
canChooseOrganization,
}) => {
const saveStudentMutation = useApiMutation(
async ({ field, value }: { field: keyof StudentInfo; value: unknown }) =>
api.put(`/students/${studentId}`, { [field]: value }),
{ invalidate: [['archive', studentId], ['students']] },
);
const saveProfileMutation = useApiMutation(
async ({ field, value }: { field: keyof ProfileData; value: unknown }) =>
api.put(`/archive/${studentId}/profile`, { [field]: value }),
{ invalidate: [['archive', studentId]] },
);
const saveResultMutation = useApiMutation(
async ({ field, value }: { field: keyof ResultData; value: unknown }) =>
api.put(`/archive/${studentId}/result`, { [field]: value }),
{ invalidate: [['archive', studentId]] },
);
const saveStudent = async (field: keyof StudentInfo, value: unknown) => {
try {
await saveStudentMutation.mutateAsync({ field, value });
message.success('学生资料已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const saveProfile = async (field: keyof ProfileData, value: unknown) => {
try {
await saveProfileMutation.mutateAsync({ field, value });
message.success('档案已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const saveResult = async (field: keyof ResultData, value: unknown) => {
try {
await saveResultMutation.mutateAsync({ field, value });
message.success('录取信息已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const admissionStatus = getOptionLabel(
Object.entries(ADMISSION_STATUS_MAP).map(([value, meta]) => ({
value,
label: meta.text,
})),
result?.admissionStatus,
);
return (
<Descriptions bordered column={{ xs: 1, sm: 2, lg: 3 }} size="small" style={{ marginBottom: 24 }}>
<Descriptions.Item label="手机号">
<EditableCell
value={student.phone}
permission="student:edit"
onSave={(next) => saveStudent('phone', next)}
>
{student.phone ? (
<span>
<span style={{ marginRight: 8 }}>{maskPhone(student.phone)}</span>
{canViewSensitive ? (
<a onClick={() => onViewSensitive('电话', student.phone)}>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
) : null}
</span>
) : (
'-'
)}
</EditableCell>
</Descriptions.Item>
<Descriptions.Item label="姓名">
<EditableField value={student.name} required onSave={(next) => saveStudent('name', next)}>
{student.name || '-'}
</EditableField>
</Descriptions.Item>
<Descriptions.Item label="学号">
<EditableField value={student.studentNo} onSave={(next) => saveStudent('studentNo', next)}>
{student.studentNo || '-'}
</EditableField>
</Descriptions.Item>
<Descriptions.Item label="性别">
<EditableField value={student.gender} onSave={(next) => saveStudent('gender', next)}>
{student.gender || '-'}
</EditableField>
</Descriptions.Item>
<Descriptions.Item label="身份证号">
<EditableCell
value={student.idNumber}
permission="student:edit"
onSave={(next) => saveStudent('idNumber', next)}
>
{student.idNumber ? (
<span>
<span style={{ marginRight: 8 }}>{maskIdNumber(student.idNumber)}</span>
{canViewSensitive ? (
<a onClick={() => onViewSensitive('身份证号', student.idNumber)}>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
) : null}
</span>
) : (
'-'
)}
</EditableCell>
</Descriptions.Item>
<Descriptions.Item label="民族">
<EditableField value={student.ethnicity} onSave={(next) => saveStudent('ethnicity', next)}>
{student.ethnicity || '-'}
</EditableField>
</Descriptions.Item>
<Descriptions.Item label="紧急联系人">
<EditableCell
value={student.emergencyContact}
permission="student:edit"
onSave={(next) => saveStudent('emergencyContact', next)}
>
{student.emergencyContact || '-'}
</EditableCell>
</Descriptions.Item>
<Descriptions.Item label="紧急联系人电话">
<EditableCell
value={student.emergencyPhone}
permission="student:edit"
onSave={(next) => saveStudent('emergencyPhone', next)}
>
{student.emergencyPhone ? (
<span>
<span style={{ marginRight: 8 }}>{maskPhone(student.emergencyPhone)}</span>
{canViewSensitive ? (
<a onClick={() => onViewSensitive('紧急联系人电话', student.emergencyPhone || '')}>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
) : null}
</span>
) : (
'-'
)}
</EditableCell>
</Descriptions.Item>
<Descriptions.Item label="所属机构">
{canChooseOrganization ? (
<EditableCell
value={student.organizationId}
editor="select"
options={organizations.map((item) => ({ value: item.id, label: item.name }))}
permission="student:edit"
onSave={(next) => saveStudent('organizationId', next)}
>
{student.organization?.name ? (
<Tag color="purple">{student.organization.name}</Tag>
) : (
'-'
)}
</EditableCell>
) : student.organization?.name ? (
<Tag color="purple">{student.organization.name}</Tag>
) : (
'-'
)}
</Descriptions.Item>
<Descriptions.Item label="负责人">
<EditableCell
value={student.supervisor}
permission="student:edit"
onSave={(next) => saveStudent('supervisor', next)}
>
{student.supervisor || '-'}
</EditableCell>
</Descriptions.Item>
<Descriptions.Item label="目标院校">
<EditableCell
value={profile?.targetCollege}
permission="student:edit"
onSave={(next) => saveProfile('targetCollege', next)}
>
{profile?.targetCollege || '-'}
</EditableCell>
</Descriptions.Item>
<Descriptions.Item label="目标专业">
<EditableCell
value={profile?.targetMajor}
permission="student:edit"
onSave={(next) => saveProfile('targetMajor', next)}
>
{profile?.targetMajor || '-'}
</EditableCell>
</Descriptions.Item>
<Descriptions.Item label="大专院校">
<EditableCell
value={profile?.collegeSchool}
permission="student:edit"
onSave={(next) => saveProfile('collegeSchool', next)}
>
{profile?.collegeSchool || '-'}
</EditableCell>
</Descriptions.Item>
<Descriptions.Item label="大专专业">
<EditableCell
value={profile?.collegeMajor}
permission="student:edit"
onSave={(next) => saveProfile('collegeMajor', next)}
>
{profile?.collegeMajor || '-'}
</EditableCell>
</Descriptions.Item>
<Descriptions.Item label="选科方向">
<EditableCell
value={profile?.subjectDirection}
permission="student:edit"
onSave={(next) => saveProfile('subjectDirection', next)}
>
{profile?.subjectDirection || '-'}
</EditableCell>
</Descriptions.Item>
<Descriptions.Item label="年级">
<EditableCell
value={profile?.grade}
permission="student:edit"
onSave={(next) => saveProfile('grade', next)}
>
{profile?.grade || '-'}
</EditableCell>
</Descriptions.Item>
<Descriptions.Item label="建档日期">
<EditableCell
value={profile?.profileDate}
editor="date"
permission="student:edit"
onSave={(next) => saveProfile('profileDate', next)}
>
{profile?.profileDate || '-'}
</EditableCell>
</Descriptions.Item>
<Descriptions.Item label="档案备注">
<EditableCell
value={profile?.notes}
editor="textarea"
permission="student:edit"
onSave={(next) => saveProfile('notes', next)}
>
{profile?.notes || '-'}
</EditableCell>
</Descriptions.Item>
<Descriptions.Item label="文化课最终分">
<EditableCell
value={result?.cultureFinalScore}
editor="number"
min={0}
permission="student:edit"
onSave={(next) => saveResult('cultureFinalScore', next)}
>
{result?.cultureFinalScore ?? '-'}
</EditableCell>
</Descriptions.Item>
<Descriptions.Item label="专业课最终分">
<EditableCell
value={result?.professionalFinalScore}
editor="number"
min={0}
permission="student:edit"
onSave={(next) => saveResult('professionalFinalScore', next)}
>
{result?.professionalFinalScore ?? '-'}
</EditableCell>
</Descriptions.Item>
<Descriptions.Item label="录取状态">
<EditableCell
value={result?.admissionStatus}
editor="select"
options={Object.entries(ADMISSION_STATUS_MAP).map(([value, meta]) => ({
value,
label: meta.text,
}))}
permission="student:edit"
onSave={(next) => saveResult('admissionStatus', next)}
>
{admissionStatus}
</EditableCell>
</Descriptions.Item>
<Descriptions.Item label="录取院校">
<EditableCell
value={result?.admittedCollege}
permission="student:edit"
onSave={(next) => saveResult('admittedCollege', next)}
>
{result?.admittedCollege || '-'}
</EditableCell>
</Descriptions.Item>
<Descriptions.Item label="录取专业">
<EditableCell
value={result?.admittedMajor}
permission="student:edit"
onSave={(next) => saveResult('admittedMajor', next)}
>
{result?.admittedMajor || '-'}
</EditableCell>
</Descriptions.Item>
</Descriptions>
);
};
const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
studentId,
inDrawer,
onClose,
}) => {
const { hasPermission, hasAnyPermission } = usePermission();
const canLoadOrganizations = hasAnyPermission(
'organization:view',
'student:create',
'student:edit',
);
const canChooseOrganization = hasAnyPermission('student:create', 'student:edit');
const {
data: aggregateData,
isLoading,
isFetching,
isError,
refetch,
} = useQuery<StudentProfileAggregate | null>({
queryKey: queryKeys.archive.detail(studentId),
queryFn: async () => {
return validateResponse<StudentProfileAggregate>(
studentProfileAggregateSchema,
await api.get<StudentProfileAggregate>(`/archive/${studentId}`),
);
},
});
const { data: organizations = [] } = useQuery<
Array<{ id: number; name: string; isHost?: boolean }>
>({
queryKey: queryKeys.organizations.options(),
enabled: canLoadOrganizations,
queryFn: async () => {
try {
return validateResponse<Array<{ id: number; name: string; isHost?: boolean }>>(
organizationOptionsSchema,
await api.get('/organizations/options'),
);
} catch {
return [];
}
},
});
const loading = isLoading || isFetching;
const fetchData = useCallback(() => refetch(), [refetch]);
const handlePreviewReport = useCallback(async () => {
try {
const { html } = await api.get<{ html: string }>(`/archive/${studentId}/report-html`);
const w = window.open('', '_blank');
if (w) {
w.document.write(html);
w.document.close();
}
} catch (e) {
console.error('加载报告失败', e);
message.error('加载报告失败');
}
}, [studentId]);
const handleViewSensitive = useViewSensitive(studentId, '学生档案', hasPermission('log:create'));
const tabItems = useMemo(() => {
if (!aggregateData) return [];
const { enrollments, examScores, learningRecords, attachments, attendances } = aggregateData;
return [
{
key: 'enrollments',
label: `报读班型 (${enrollments.length})`,
children: <EnrollmentsTab data={enrollments} studentId={studentId} onRefresh={fetchData} />,
},
{
key: 'exams',
label: `考试成绩 (${examScores.length})`,
children: (
<ExamScoresTab
data={examScores}
studentId={studentId}
enrollments={enrollments}
onRefresh={fetchData}
/>
),
},
{
key: 'attendance',
label: `出勤记录 (${attendances.length})`,
children: <AttendanceTab data={attendances} />,
},
{
key: 'learning',
label: `课堂回访 (${learningRecords.length})`,
children: (
<LearningTab data={learningRecords} studentId={studentId} onRefresh={fetchData} />
),
},
{
key: 'attachments',
label: `附件 (${attachments.length})`,
children: <AttachmentsTab data={attachments} studentId={studentId} onRefresh={fetchData} />,
},
{
key: 'reports',
label: '报告版本',
children: <Empty description="暂无报告版本" />,
},
];
}, [aggregateData, studentId, fetchData]);
if (!aggregateData) {
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (isError) {
return (
<QueryErrorState
title="档案数据加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
);
}
return null;
}
const { student, profile, result } = aggregateData;
return (
<div>
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
{inDrawer && (
<Row justify="space-between" align="middle" style={{ marginBottom: 24 }}>
<Space>
<Button type="text" icon={<CloseOutlined />} onClick={onClose} aria-label="关闭档案" />
<span style={{ fontSize: 16, fontWeight: 500 }}>
- {student.name}
{student.studentNo ? ` (${student.studentNo})` : ''}
</span>
</Space>
<Space>
<Button icon={<FileTextOutlined />} onClick={handlePreviewReport}>
</Button>
<Button icon={<ReloadOutlined />} onClick={fetchData} loading={loading}>
</Button>
</Space>
</Row>
)}
<Row gutter={16} style={{ marginBottom: 24 }}>
{[
{ title: '入学测试总分' },
{ title: '阶段最高分' },
{ title: '阶段提升分' },
{ title: '出勤率' },
].map((item) => (
<Col span={6} key={item.title}>
<Card size="small">
<Statistic title={item.title} value="-" />
</Card>
</Col>
))}
</Row>
<InlineArchiveSummary
studentId={studentId}
student={student}
profile={profile}
result={result}
organizations={organizations}
onRefresh={fetchData}
onViewSensitive={handleViewSensitive}
canViewSensitive={hasPermission('log:create')}
canChooseOrganization={canChooseOrganization}
/>
<Tabs defaultActiveKey="enrollments" items={tabItems} />
</div>
);
};
export default StudentProfileContent;