From 3f96c9c6502243b06efd4bb34a189e72e3a5557c Mon Sep 17 00:00:00 2001
From: yearning <10538594+wangweifeng1999@user.noreply.gitee.com>
Date: 星期一, 28 九月 2026 19:24:55 +0800
Subject: [PATCH] 复制项目

---
 frontend/src/pages/AdminImportPage.tsx |  163 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 163 insertions(+), 0 deletions(-)

diff --git a/frontend/src/pages/AdminImportPage.tsx b/frontend/src/pages/AdminImportPage.tsx
new file mode 100644
index 0000000..86e0834
--- /dev/null
+++ b/frontend/src/pages/AdminImportPage.tsx
@@ -0,0 +1,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> 鏂囦欢锛孶TF-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}锛宔xam_id锛�${ids.join("銆�") || "鈥�"}銆傚皢鍒犻櫎璇ユ壒娆′笅鍏ㄩ儴鍦烘涓庢垚缁╋紝涓嶅彲鎭㈠銆俙,
+                        onOk: async () => {
+                          await api.delete(`/api/admin/import/batches/${row.id}`);
+                          message.success("宸叉竻闄�");
+                          load();
+                        },
+                      });
+                    }}
+                  >
+                    娓呴櫎
+                  </Button>
+                );
+              },
+            },
+          ]}
+        />
+      </Card>
+    </div>
+  );
+}

--
Gitblit v1.8.0