yearning
12 小时以前 3f96c9c6502243b06efd4bb34a189e72e3a5557c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
import { useEffect, useState } from "react";
import { Alert, Button, Card, Form, Input, Table, Typography, Upload, message, Modal, Tag } from "antd";
import { UploadOutlined } from "@ant-design/icons";
import { api } from "../api";
 
type Batch = {
  id: number;
  subject_name: string;
  exam_id: number;
  exam_ids: number[];
  zip_filename: string;
  imported_at: string;
  status: string;
  attempt_count: number;
  paper_name?: string;
};
 
export default function AdminImportPage() {
  const [batches, setBatches] = useState<Batch[]>([]);
  const [file, setFile] = useState<File | null>(null);
  const [loading, setLoading] = useState(false);
 
  const load = () => api.get<{ items: Batch[] }>("/api/admin/import/batches").then((r) => setBatches(r.data.items));
 
  useEffect(() => {
    load();
  }, []);
 
  return (
    <div>
      <Card title="导入数据" style={{ marginBottom: 16 }}>
        <Alert
          type="info"
          showIcon
          style={{ marginBottom: 16 }}
          message="导入要求说明"
          description={
            <Typography.Paragraph style={{ marginBottom: 0 }}>
              <ul style={{ margin: "8px 0 0", paddingLeft: 20 }}>
                <li>请上传 <strong>ZIP 压缩包</strong>,内含导出 SQL(仅 <code>.sql</code> 文件,UTF-8 编码)。</li>
                <li>
                  必须包含 5 类数据(文件名需能识别,推荐与样例一致):
                  <ul>
                    <li>考生:<code>examination_examinee</code>(勿与答案表混淆)</li>
                    <li>考试考生 / 成绩:<code>examination_exam_examinee</code></li>
                    <li>试卷:<code>examination_paper_source</code></li>
                    <li>试题明细:<code>examination_paper_item</code></li>
                    <li>考生答案:<code>examination_examinee_exam_answer</code></li>
                  </ul>
                </li>
                <li>
                  一个 ZIP 可包含<strong>多个 exam_id</strong>(同卷多场次):ZIP 内须为<strong>同一份试卷</strong>(相同{" "}
                  <code>external_paper_id + revision</code>),系统将共用一套试题判分。
                </li>
                <li>
                  若 ZIP 内含<strong>多份不同试卷</strong>,须额外提供 <code>examination_exam</code> SQL,用{" "}
                  <code>paper_source_id</code> 标明每场考试使用的试卷;否则无法导入。
                </li>
                <li>
                  每个 <code>exam_id</code> 在全库仅能导入一次;已存在的试卷(相同{" "}
                  <code>external_paper_id + revision</code>)会复用题库,仅新增场次与成绩。
                </li>
                <li>
                  <strong>科目名称</strong>由管理员填写,用于成绩列表筛选;清除请用下方「导入历史」按批次操作(会删除该 ZIP 对应的全部场次数据)。
                </li>
                <li>导入后将按试题明细与答案自动判分,并以明细汇总结果更新总分(可与原 SQL 中 score 对照)。</li>
              </ul>
            </Typography.Paragraph>
          }
        />
        <Form
          layout="vertical"
          onFinish={async (v) => {
            if (!file) {
              message.warning("请选择 ZIP 文件");
              return;
            }
            const fd = new FormData();
            fd.append("subject_name", v.subject_name);
            fd.append("file", file);
            setLoading(true);
            try {
              const { data } = await api.post("/api/admin/import", fd);
              message.success("导入成功");
              Modal.info({ title: "导入报告", content: JSON.stringify(data.report, null, 2), width: 600 });
              setFile(null);
              load();
            } catch (e: unknown) {
              const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || "导入失败";
              message.error(msg);
            } finally {
              setLoading(false);
            }
          }}
        >
          <Form.Item name="subject_name" label="科目名称" rules={[{ required: true }]}>
            <Input placeholder="导入后用于成绩列表科目筛选" />
          </Form.Item>
          <Form.Item label="ZIP 文件" required>
            <Upload
              beforeUpload={(f) => {
                setFile(f);
                return false;
              }}
              maxCount={1}
              onRemove={() => setFile(null)}
            >
              <Button icon={<UploadOutlined />}>选择文件</Button>
            </Upload>
          </Form.Item>
          <Button type="primary" htmlType="submit" loading={loading}>开始导入</Button>
        </Form>
      </Card>
      <Card title="导入历史">
        <Table
          rowKey="id"
          scroll={{ x: 960 }}
          dataSource={batches}
          columns={[
            { title: "科目", dataIndex: "subject_name" },
            {
              title: "exam_id",
              dataIndex: "exam_ids",
              render: (ids: number[] | undefined, row) => {
                const list = ids?.length ? ids : row.exam_id != null ? [row.exam_id] : [];
                return list.map((id) => <Tag key={id}>{id}</Tag>);
              },
            },
            { title: "试卷", dataIndex: "paper_name", ellipsis: true },
            { title: "文件", dataIndex: "zip_filename", ellipsis: true },
            { title: "导入时间", dataIndex: "imported_at" },
            { title: "考生数", dataIndex: "attempt_count" },
            {
              title: "操作",
              render: (_, row) => {
                const ids = row.exam_ids?.length ? row.exam_ids : row.exam_id != null ? [row.exam_id] : [];
                return (
                  <Button
                    danger
                    type="link"
                    onClick={() => {
                      Modal.confirm({
                        title: "清除本次导入的全部数据?",
                        content: `科目:${row.subject_name},exam_id:${ids.join("、") || "—"}。将删除该批次下全部场次与成绩,不可恢复。`,
                        onOk: async () => {
                          await api.delete(`/api/admin/import/batches/${row.id}`);
                          message.success("已清除");
                          load();
                        },
                      });
                    }}
                  >
                    清除
                  </Button>
                );
              },
            },
          ]}
        />
      </Card>
    </div>
  );
}