| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687 |
- import { CardStepTitle, StatusIcon, UploadWrongLocalHeaderError } from "@/components";
- import ExamGradeController from "@/services/apis/ExamGradeController";
- import ExamPlanController from "@/services/apis/ExamPlanController";
- import ExamStudentController from "@/services/apis/ExamStudentController";
- import SysOrgController from "@/services/apis/SysOrgController";
- import { CertificateType, DataImportMode, EducationStage, Gender } from "@/services/enums";
- import { BulbOutlined, InboxOutlined } from "@ant-design/icons";
- import { ActionType, PageContainer, ProCard, ProColumns, ProForm, ProFormInstance, ProFormSelect, ProTable } from "@ant-design/pro-components";
- import { useModel, useParams } from "@umijs/max";
- import { useRequest } from "ahooks";
- import { Alert, App, Button, Space, Table, Tooltip, Tour, Typography, Upload, theme } from "antd";
- import type { RcFile, UploadFile, UploadProps } from "antd/lib/upload/interface";
- import lodash from 'lodash';
- import { useRef, useState } from "react";
- import ExamStudentImportEditModal from "./components/ExamStudentImportEditModal";
- type ClassTotalItem = {
- classNumber: number;
- total: number;
- success: number;
- error: number;
- };
- type ImportFormData = {
- examGradeId: number;
- sysOrgBranchId?: number;
- };
- /** 监测学生批量导入 */
- const OrgExamStudentImport: React.FC = () => {
- const reqParams = useParams() as unknown as { examPlanId: number };
- const formRef = useRef<ProFormInstance<ImportFormData>>();
- const actionRef = useRef<ActionType>();
- const [tourOpen, setTourOpen] = useState(false);
- const currentRef = useRef<API.UploadExamStudentOutput>();
- const [editOpen, setEditOpen] = useState(false);
- const [grade, setGrade] = useState<API.ExamGradeOutput>();
- const { token } = theme.useToken();
- const { message, modal } = App.useApp();
- const { getDictValueEnum } = useModel('useDict');
- const { initialState } = useModel('@@initialState');
- const { currentUser } = initialState ?? {};
- const [fileList, setFileList] = useState<UploadFile[]>([]);
- const [uploading, setUploading] = useState(false);
- const [data, setData] = useState<API.UploadExamDataOutput_UploadExamStudentOutput>();
- const [classTotal, setClassTotal] = useState<ClassTotalItem[]>();
- // const [dataImportMode, setDataImportMode] = useState(DataImportMode.CLEAR_APPEND);
- const tourGradeRef = useRef(null);
- const tourDownloadRef = useRef(null);
- const tourChooseFileRef = useRef(null);
- const tourUploadRef = useRef(null);
- const tourStuCountRef = useRef(null);
- const tourStuDetailRef = useRef(null);
- const tourSubmitRef = useRef(null);
- const { data: baseData, loading } = useRequest(async () => {
- const res1 = await ExamGradeController.getListByExamPlanId({ examplanid: reqParams.examPlanId });
- const res2 = await SysOrgController.getOrgBranchByOrgId({ orgid: currentUser?.sysOrgId ?? 0 });
- const res3 = await ExamPlanController.getById({ id: reqParams.examPlanId });
- return {
- examGrades: res1 ?? [],
- branches: res2 ?? [],
- hasNceeCourseComb: res3?.educationStage === EducationStage.SENIOR_HIGH_SCHOOL_STAGE,
- };
- });
- const uploadProps: UploadProps = {
- onRemove: () => {
- setFileList([]);
- },
- beforeUpload: (file) => {
- setFileList([file]);
- return false;
- },
- fileList,
- multiple: false,
- accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel',
- };
- const handleBack = () => history.back();
- // 计算统计数据
- const handleClassTotal = (rows: API.UploadExamStudentOutput[]) => {
- let cts: ClassTotalItem[] = [];
- lodash.forEach(lodash.countBy(rows, 'classNumber'), function (v, k) {
- const classNumber = parseInt(k);
- const success = rows.filter(t => t.classNumber === classNumber && t.isSuccess === true)?.length ?? 0;
- cts.push({
- classNumber,
- total: v,
- success,
- error: v - success,
- });
- });
- setClassTotal(cts);
- }
- // 上传
- const handleUpload = async () => {
- if (!grade) {
- message.error('请先选择年级');
- return;
- }
- const formData = new FormData();
- formData.append('examPlanId', `${reqParams.examPlanId}`);
- formData.append('examGradeId', `${grade?.id}`);
- fileList.forEach((file) => {
- formData.append('file', file as RcFile);
- });
- setUploading(true);
- try {
- const res = await ExamStudentController.upload(formData);
- setData(res);
- handleClassTotal(res?.rows ?? []);
- }
- catch { }
- finally {
- setUploading(false);
- }
- }
- // 保存编辑
- const handleEdit = (v: API.UploadExamStudentOutput) => {
- const i = data?.rows?.findIndex(t => t.rowNumber === v.rowNumber) ?? -1;
- if (i !== -1) {
- const d = { ...data };
- if (d.rows) {
- d.rows[i] = v;
- handleClassTotal(d.rows ?? []);
- setData(d);
- }
- }
- }
- // 删除行
- const handleDelete = (rowNumber: number) => {
- modal.confirm({
- title: '警告',
- content: '确定立即删除吗',
- okText: '确定',
- cancelText: '取消',
- centered: true,
- onOk: async () => {
- const i = data?.rows?.findIndex(t => t.rowNumber === rowNumber) ?? -1;
- if (i !== -1) {
- const d = { ...data };
- if (d.rows) {
- d.rows.splice(i, 1);
- setData(d);
- handleClassTotal(d.rows ?? []);
- message.success('已删除');
- return;
- }
- }
- message.error('删除失败');
- },
- });
- }
- // 确认导入
- const handleImport = async () => {
- const sysOrgId = currentUser?.sysOrgId;
- if (!sysOrgId) {
- message.error('获取机构ID错误');
- return;
- }
- try {
- const importFormValues = await formRef.current?.validateFields();
- if (!importFormValues) {
- return;
- }
- const { examGradeId, ...restValues } = importFormValues;
- const grade = baseData?.examGrades.find(t => t.id === examGradeId);
- if (!grade) {
- return;
- }
- const items = [...(data?.rows ?? [])];
- if (items.some(t => t.isSuccess === false)) {
- message.error('还有校验未通过的数据不能导入');
- return;
- }
- modal.confirm({
- title: '导入提示',
- content: '批量导入会覆盖当前选择年级的所有学生信息,确认立即导入吗?',
- okText: '确定',
- cancelText: '取消',
- centered: true,
- onOk: async () => {
- try {
- await ExamStudentController.importAction({
- examPlanId: reqParams.examPlanId,
- examGradeId,
- gradeId: grade?.gradeId,
- ...restValues,
- items: items.map(t => ({
- examPlanId: reqParams.examPlanId,
- examGradeId,
- gradeId: grade?.gradeId,
- classNumber: t.classNumber,
- nceeCourseCombId: t.nceeCourseCombId,
- certificateType: t.certificateType ? JSON.parse(`${t.certificateType}`) : CertificateType.NONE,
- idNumber: t.idNumber?.toUpperCase(),
- name: t.name,
- gender: t.gender ? JSON.parse(`${t.gender}`) : Gender.UNKNOWN,
- examNumber: t.examNumber,
- studentNumber: t.studentNumber,
- roomNumber: t.roomNumber,
- seatNumber: t.seatNumber,
- remark: t.remark,
- })),
- dataImportMode: DataImportMode.CLEAR_APPEND,
- });
- message.success('导入成功');
- handleBack();
- }
- catch { }
- }
- });
- }
- catch {
- message.error('年级或校区未选择');
- window.scrollTo({ top: 0, behavior: 'smooth' });
- }
- }
- const columns: ProColumns<API.UploadExamStudentOutput>[] = [
- {
- title: '验证',
- valueType: 'option',
- width: 48,
- align: 'center',
- render: (_, r) => {
- if (r.isSuccess) {
- return (<StatusIcon status="success" filled />);
- }
- return (
- <Tooltip title={r.errorMessage}>
- <StatusIcon status="error" filled />
- </Tooltip>
- );
- },
- },
- {
- title: '行号',
- valueType: 'option',
- render: (v, _, index) => {
- return <Typography.Text type="secondary">{index + 1}</Typography.Text>;
- },
- width: 48,
- align: 'center',
- },
- {
- title: '班级',
- dataIndex: 'classNumber',
- width: 48,
- align: 'center',
- },
- {
- title: '姓名',
- dataIndex: 'name',
- align: 'center',
- width: 120,
- // className: 'minw-80',
- },
- // {
- // title: '证件类型',
- // dataIndex: 'certificateTypeName',
- // width: 112,
- // align: 'center',
- // },
- {
- title: '证件类型',
- dataIndex: 'certificateType',
- valueEnum: getDictValueEnum('certificate_type'),
- width: 120,
- align: 'center',
- },
- {
- title: '证件号码',
- dataIndex: 'idNumber',
- width: 160,
- align: 'center',
- },
- {
- title: '性别',
- dataIndex: 'gender',
- valueEnum: getDictValueEnum('gender'),
- width: 48,
- align: 'center',
- hideInSearch: true,
- },
- {
- title: '自编监测号',
- dataIndex: 'examNumber',
- width: 112,
- align: 'center',
- },
- ...(baseData?.hasNceeCourseComb ? [{
- title: '选科组合',
- dataIndex: 'nceeCourseCombName',
- width: 80,
- align: 'center',
- hideInSearch: true,
- } as ProColumns] : []),
- {
- title: '校验结果',
- dataIndex: 'errorMessage',
- ellipsis: true,
- hideInSearch: true,
- width: 280,
- render: (_, r) => {
- if (r.isSuccess) {
- return '通过';
- }
- return `${r.errorMessage?.join('、')}有误`;
- },
- },
- {
- title: '备注',
- dataIndex: 'remark',
- ellipsis: true,
- hideInSearch: true,
- width: 96,
- },
- {
- title: '考场号',
- dataIndex: 'roomNumber',
- width: 64,
- align: 'center',
- },
- {
- title: '座位号',
- dataIndex: 'seatNumber',
- width: 64,
- align: 'center',
- },
- {
- title: '操作',
- valueType: 'option',
- fixed: 'right',
- width: 112,
- align: 'center',
- render: (_, r) => {
- return (
- <>
- <Button type="link" size="small" onClick={() => {
- currentRef.current = { ...r };
- setEditOpen(true);
- }}>修改</Button>
- <Button type="link" size="small" onClick={() => handleDelete(r.rowNumber)}>删除</Button>
- </>
- );
- },
- },
- ];
- return (
- <PageContainer
- title="监测学生批量导入"
- onBack={handleBack}
- extra={
- <Button
- type="link"
- icon={<BulbOutlined />}
- onClick={() => {
- window.scrollTo({ top: 0 });
- setTourOpen(true);
- }}
- >查看操作指引</Button>
- }
- loading={loading}
- >
- <ProCard
- title={<CardStepTitle>第一步:选择年级</CardStepTitle>}
- ref={tourGradeRef}
- >
- <Alert type="warning" showIcon message="每个年级所有班级学生填写在一个批量导入文件中,每个年级分开导入,有几个年级导入几次!" closable style={{ marginBottom: token.margin, color: token.colorError }} />
- <ProForm<ImportFormData>
- layout="inline"
- submitter={false}
- formRef={formRef}
- scrollToFirstError={true}
- >
- <ProFormSelect
- label="年级"
- name="examGradeId"
- options={baseData?.examGrades?.sort((a, b) => a.grade.gradeNumber - b.grade.gradeNumber)?.map(t => ({ label: `${t.grade.fullName}(${t.gradeBeginName})`, value: t.id }))}
- style={{ width: 240 }}
- required
- rules={[{ required: true, message: '年级必须选择' }]}
- onChange={(e: number) => {
- const g = baseData?.examGrades.find(t => t.id === e);
- setGrade(g);
- }}
- />
- {grade && grade.isRequiredSelfExamNumber &&
- <Alert
- type="warning"
- message={`该年级必须自编监测号(长度为${grade.selfExamNumberLength}位)`}
- showIcon
- style={{
- marginRight: token.marginLG,
- }}
- />
- }
- {baseData?.branches && baseData.branches.length > 0 &&
- <ProFormSelect
- label="校区"
- name="sysOrgBranchId"
- options={baseData.branches.map(t => ({ label: t.name, value: t.id }))}
- style={{ width: 240 }}
- required
- rules={[{ required: true, message: '校区必须选择' }]}
- />
- }
- </ProForm>
- </ProCard>
- <ProCard
- title={<CardStepTitle>第二步:下载模板</CardStepTitle>}
- extra={
- <Button
- type="primary"
- ref={tourDownloadRef}
- onClick={() => {
- window.open('/doc-templates/学生信息上报-填报模板.xlsx');
- }}
- >下载模板</Button>
- }
- style={{ marginTop: token.margin }}
- >
- <Typography.Paragraph>
- <Typography.Text type="warning" strong>批量上传文件填写说明及注意事项:</Typography.Text>
- </Typography.Paragraph>
- <Typography.Paragraph>
- <Typography.Text strong>模板格式:</Typography.Text>
- 模板文件中第一行为填写说明,第二行为标题行(从A至H列分别为班级、姓名、证件类型、证件号码、性别、自编监测号、选科组合、备注)。
- <Typography.Text type="danger">填报数据必须在第一个工作表,并不能删除填写说明和标题行,不能删除和添加表格列!</Typography.Text>
- </Typography.Paragraph>
- <Typography.Paragraph>
- <Typography.Text strong>A. 班级</Typography.Text>
- (<Typography.Text type="danger">必填</Typography.Text>):
- 填写班级数字序号,
- <Typography.Text mark>如幼儿园中一班填1、中二班填2、大一班填1,小学三年级1班填1。</Typography.Text>
- </Typography.Paragraph>
- <Typography.Paragraph>
- <Typography.Text strong>B. 姓名</Typography.Text>
- (<Typography.Text type="danger">必填</Typography.Text>):
- 学生真实姓名。
- </Typography.Paragraph>
- <Typography.Paragraph>
- <Typography.Text strong>C. 证件类型</Typography.Text>
- (<Typography.Text type="danger">必填</Typography.Text>):
- 有居民身份证时,必须选择居民身份证,特殊情况可选择其他证件类型。
- </Typography.Paragraph>
- <Typography.Paragraph>
- <Typography.Text strong>D. 证件号码</Typography.Text>:
- 有证件类型时必须填写,居民身份证为18位系统会进行严格的验证。
- </Typography.Paragraph>
- <Typography.Paragraph>
- <Typography.Text strong>E. 性别</Typography.Text>:
- 证件类型选择为居民身份证时会自动处理。
- </Typography.Paragraph>
- <Typography.Paragraph>
- <Typography.Text strong>F. 自编监测号</Typography.Text>:
- 一般情况不需要填写,有统一要求时才需要填写。
- </Typography.Paragraph>
- <Typography.Paragraph>
- <Typography.Text strong>G. 选科组合</Typography.Text>:
- <Typography.Text mark>仅高中阶段已选科学生需要填写</Typography.Text>,
- 学前、小学和初中不需要填写。
- </Typography.Paragraph>
- <Typography.Paragraph>
- <Typography.Text strong>H. 备注</Typography.Text>:
- 有特殊情况自行填写。
- </Typography.Paragraph>
- <Typography.Paragraph>
- <Typography.Text strong>I. 考场号</Typography.Text>:
- 有自编监测号时,学校有需要自行填写。
- </Typography.Paragraph>
- <Typography.Paragraph>
- <Typography.Text strong>J. 座位号</Typography.Text>:
- 有自编监测号时,学校有需要自行填写。
- </Typography.Paragraph>
- </ProCard>
- <ProCard
- title={
- <CardStepTitle>
- 第三步:上传文件
- {!grade && <Typography.Text type="danger">(请先选择年级!)</Typography.Text>}
- </CardStepTitle>}
- style={{ marginTop: token.margin }}
- ref={tourChooseFileRef}
- >
- <Space direction="vertical" style={{ width: '100%' }}>
- <Upload.Dragger {...uploadProps} disabled={!grade}>
- <p className="ant-upload-drag-icon"><InboxOutlined /></p>
- <p className="ant-upload-text">点击或拖入文件到此处</p>
- </Upload.Dragger>
- <Space>
- <Button
- type="primary"
- disabled={fileList.length === 0}
- loading={uploading}
- onClick={handleUpload}
- ref={tourUploadRef}
- >{uploading ? '上传中' : '立即上传'}</Button>
- <UploadWrongLocalHeaderError />
- </Space>
- {data?.structureCorrect === false &&
- <Alert
- type="error"
- showIcon
- message={data?.errorMessage?.join('')}
- />
- }
- </Space>
- </ProCard>
- <ProCard
- style={{ marginTop: token.margin }}
- title={<CardStepTitle>第四步:确认数据</CardStepTitle>}
- >
- {/* <p className="ant-upload-text">根据表格验证提示修改数据无误后提交导入</p> */}
- <div ref={tourStuCountRef}>
- <Typography.Title level={5}>1.人数统计</Typography.Title>
- <Table
- pagination={false}
- scroll={{ x: 'max-content' }}
- size="small"
- bordered
- rowKey="classNumber"
- columns={[
- { title: '班级', dataIndex: 'classNumber', width: 80, align: 'center', },
- { title: '学生人数', dataIndex: 'total', width: 80, align: 'center' },
- {
- title: '验证通过',
- dataIndex: 'success',
- width: 120,
- align: 'center',
- render: (v) => <Typography.Text type="success">{v}</Typography.Text>,
- },
- {
- title: '验证失败',
- dataIndex: 'error',
- width: 120,
- align: 'center',
- render: (v) => v > 0 ? <Typography.Text type="danger">{v}</Typography.Text> : null,
- },
- {},
- ]}
- dataSource={classTotal}
- summary={(rows) => {
- const total = rows.map(t => t.total).reduce((p, n) => p + n, 0);
- const success = rows.map(t => t.success).reduce((p, n) => p + n, 0);
- const error = rows.map(t => t.error).reduce((p, n) => p + n, 0);
- return (
- <Table.Summary.Row style={{ fontStyle: 'italic', fontWeight: 'bold' }}>
- <Table.Summary.Cell index={0} align="center">合计</Table.Summary.Cell>
- <Table.Summary.Cell index={1} align="center">{total}</Table.Summary.Cell>
- <Table.Summary.Cell index={2} align="center">
- <Typography.Text type="success">{success}</Typography.Text>
- </Table.Summary.Cell>
- <Table.Summary.Cell index={3} align="center">
- <Typography.Text type="danger">{error || ''}</Typography.Text>
- </Table.Summary.Cell>
- <Table.Summary.Cell index={4} align="center"></Table.Summary.Cell>
- </Table.Summary.Row>
- );
- }}
- />
- </div>
- <div ref={tourStuDetailRef}>
- <ProTable<API.UploadExamStudentOutput>
- actionRef={actionRef}
- cardProps={false}
- columns={columns}
- size="small"
- rowKey="rowNumber"
- bordered
- options={false}
- sticky={{ offsetHeader: 56 }}
- dataSource={[...data?.rows ?? []]}
- virtual
- scroll={{ y: Math.min(document.body.clientHeight - 56 - 24, 800) }}
- pagination={false}
- rowClassName={(r) => {
- if (!r.isSuccess) {
- return 'yb-row-error';
- }
- return '';
- }}
- toolbar={{
- title: <Typography.Title level={5} style={{ marginBottom: 0 }}>2.学生明细</Typography.Title>,
- subTitle: '有校验错误的数据,点击对应行操作中的修改进行修改。',
- actions: [
- // <Radio.Group
- // key="importType"
- // options={getDictOptions('data_import_mode')}
- // value={dataImportMode}
- // onChange={e => setDataImportMode(e.target.value)}
- // />,
- <Typography.Text key="tooltip" type="warning">重要提示:导入前会删除所选校区(若有)年级已填报学生!</Typography.Text>,
- <Button
- key="import"
- type="primary"
- disabled={!data?.structureCorrect || uploading}
- onClick={handleImport}
- ref={tourSubmitRef}
- >确认导入</Button>
- ],
- }}
- search={false}
- />
- </div>
- </ProCard>
- <Tour
- steps={[
- {
- title: '选择年级',
- description: '导入前必须选择年级,如果有多个校区的学校必须选择校区。',
- target: () => tourGradeRef.current,
- },
- {
- title: '下载模板',
- description: '下载批量导入监测学生模板文件,请按模板填写说明及注意事项填写文件。',
- target: () => tourDownloadRef.current,
- },
- {
- title: '选择文件',
- description: '拖动文件至此区域,或点击该区域选择文件上传。',
- target: () => tourChooseFileRef.current,
- },
- {
- title: '上传文件',
- description: '选择好批量导入文件后,点击此按钮上传文件,后台解析后返回校验结果和数据。',
- target: () => tourUploadRef.current,
- },
- {
- title: '人数统计',
- description: '上传文件后会显示各班级人数、验证通过和失败的数量。',
- target: () => tourStuCountRef.current,
- },
- {
- title: '学生明细',
- description: '所有上传的学生明细,请根据校验结果修改数据。',
- target: () => tourStuDetailRef.current,
- },
- {
- title: '确认导入',
- description: '检查修改确认批量导入学生信息无误后确认导入。',
- target: () => tourSubmitRef.current,
- },
- ]}
- open={tourOpen}
- onClose={() => setTourOpen(false)}
- />
- {editOpen && currentRef.current &&
- <ExamStudentImportEditModal
- isRequiredSelfExamNumber={grade?.isRequiredSelfExamNumber}
- selfExamNumberLength={grade?.selfExamNumberLength}
- data={currentRef.current}
- hasNceeCourseComb={baseData?.hasNceeCourseComb}
- onFinish={handleEdit}
- onClose={() => setEditOpen(false)}
- />
- }
- {/* <FloatButton.BackTop visibilityHeight={100} /> */}
- </PageContainer>
- );
- }
- export default OrgExamStudentImport;
|