]> acesimba.cloud Git - studybase.git/commitdiff
重构为 Web 版:FastAPI + Vue3 前后端分离,部署于 https://acesimba.cloud/StudyBase。后端逐端口移植原桌面版全部功能逻辑...
authorroot <root@localhost.localdomain>
Sat, 12 Sep 2026 03:36:27 +0000 (11:36 +0800)
committerroot <root@localhost.localdomain>
Sat, 12 Sep 2026 03:36:27 +0000 (11:36 +0800)
94 files changed:
.gitignore
app.log [deleted file]
app.py [deleted file]
backend/create_word.py [new file with mode: 0644]
backend/main.py [new file with mode: 0644]
backend/media_utils.py [new file with mode: 0644]
backend/parsers/__init__.py [new file with mode: 0644]
backend/parsers/zhixuewang_file_parsing.py [new file with mode: 0644]
backend/parsers/zizuword_file_parsing.py [new file with mode: 0644]
backend/parsers/zujuanwang_file_parsing.py [new file with mode: 0644]
custom/conf/app.ini [deleted file]
database.py [deleted file]
frontend/dist/assets/AnalysisChapters-DTn9LWoa.css [new file with mode: 0644]
frontend/dist/assets/AnalysisChapters-rXnOtC4r.js [new file with mode: 0644]
frontend/dist/assets/AnalysisKnowledge-5QPt6Ak_.css [new file with mode: 0644]
frontend/dist/assets/AnalysisKnowledge-D1yJ4mIN.js [new file with mode: 0644]
frontend/dist/assets/AnalysisPending-BfBXB5e_.js [new file with mode: 0644]
frontend/dist/assets/AnalysisPending-C5SzviRH.css [new file with mode: 0644]
frontend/dist/assets/FileImport-B8PhhnGN.css [new file with mode: 0644]
frontend/dist/assets/FileImport-BF8-5MOQ.js [new file with mode: 0644]
frontend/dist/assets/Home-BS2dZmih.css [new file with mode: 0644]
frontend/dist/assets/Home-DWL4BmBg.js [new file with mode: 0644]
frontend/dist/assets/ImportPreview-CFWTYbgQ.js [new file with mode: 0644]
frontend/dist/assets/ImportPreview-Dx1LXj5L.css [new file with mode: 0644]
frontend/dist/assets/PapersCreateById-CT-UILr2.js [new file with mode: 0644]
frontend/dist/assets/PapersDetail-CpYT1ySO.js [new file with mode: 0644]
frontend/dist/assets/PapersList-CRTR-6gM.js [new file with mode: 0644]
frontend/dist/assets/PapersList-CnsFf_No.css [new file with mode: 0644]
frontend/dist/assets/PapersPreview-ffjysDLy.js [new file with mode: 0644]
frontend/dist/assets/PapersResult-BN_2zJ7H.js [new file with mode: 0644]
frontend/dist/assets/QuestionAdd-MfLONVTZ.js [new file with mode: 0644]
frontend/dist/assets/QuestionDetail-iaLTMiSx.css [new file with mode: 0644]
frontend/dist/assets/QuestionDetail-s29u4nvx.js [new file with mode: 0644]
frontend/dist/assets/QuestionList-Dv5J_SvP.css [new file with mode: 0644]
frontend/dist/assets/QuestionList-cbNQnKl-.js [new file with mode: 0644]
frontend/dist/assets/RichContent-i88-frEH.css [new file with mode: 0644]
frontend/dist/assets/RichContent-vEKXmEUi.js [new file with mode: 0644]
frontend/dist/assets/Settings-CzRRQaVw.js [new file with mode: 0644]
frontend/dist/assets/Settings-pzGFQctT.css [new file with mode: 0644]
frontend/dist/assets/SettingsDefaultParams-Bwa8C6ww.js [new file with mode: 0644]
frontend/dist/assets/SettingsErrorCauses-2WPrQUsX.js [new file with mode: 0644]
frontend/dist/assets/SettingsErrorCauses-BA8Zr_cv.css [new file with mode: 0644]
frontend/dist/assets/SettingsStatuses-C1SsLo53.js [new file with mode: 0644]
frontend/dist/assets/SettingsStatuses-XHEFsW5N.css [new file with mode: 0644]
frontend/dist/assets/SettingsSubjects-BOWGx4MO.js [new file with mode: 0644]
frontend/dist/assets/SettingsSubjects-bGGsFjfA.css [new file with mode: 0644]
frontend/dist/assets/SubjectPortal-C7-krIg3.js [new file with mode: 0644]
frontend/dist/assets/SubjectPortal-yfCLF9Wp.css [new file with mode: 0644]
frontend/dist/assets/api-gDOaCrl1.js [new file with mode: 0644]
frontend/dist/assets/index-D5_k2e2e.css [new file with mode: 0644]
frontend/dist/assets/index-Dc_QwQl6.js [new file with mode: 0644]
frontend/dist/index.html [new file with mode: 0644]
frontend/index.html [new file with mode: 0644]
frontend/package-lock.json [new file with mode: 0644]
frontend/package.json [new file with mode: 0644]
frontend/src/App.vue [new file with mode: 0644]
frontend/src/api.js [new file with mode: 0644]
frontend/src/components/RichContent.vue [new file with mode: 0644]
frontend/src/main.js [new file with mode: 0644]
frontend/src/views/AnalysisChapters.vue [new file with mode: 0644]
frontend/src/views/AnalysisKnowledge.vue [new file with mode: 0644]
frontend/src/views/AnalysisPending.vue [new file with mode: 0644]
frontend/src/views/FileImport.vue [new file with mode: 0644]
frontend/src/views/Home.vue [new file with mode: 0644]
frontend/src/views/ImportPreview.vue [new file with mode: 0644]
frontend/src/views/PapersCreateById.vue [new file with mode: 0644]
frontend/src/views/PapersDetail.vue [new file with mode: 0644]
frontend/src/views/PapersList.vue [new file with mode: 0644]
frontend/src/views/PapersPreview.vue [new file with mode: 0644]
frontend/src/views/PapersResult.vue [new file with mode: 0644]
frontend/src/views/QuestionAdd.vue [new file with mode: 0644]
frontend/src/views/QuestionDetail.vue [new file with mode: 0644]
frontend/src/views/QuestionList.vue [new file with mode: 0644]
frontend/src/views/Settings.vue [new file with mode: 0644]
frontend/src/views/SettingsDefaultParams.vue [new file with mode: 0644]
frontend/src/views/SettingsErrorCauses.vue [new file with mode: 0644]
frontend/src/views/SettingsStatuses.vue [new file with mode: 0644]
frontend/src/views/SettingsSubjects.vue [new file with mode: 0644]
frontend/src/views/SubjectPortal.vue [new file with mode: 0644]
frontend/vite.config.js [new file with mode: 0644]
generate_report.py [deleted file]
project_all_in_one.txt [deleted file]
s/schema.sql [deleted file]
s/studybase.sqbpro [deleted file]
s/workmode-studybase.sql [deleted file]
static/css/style.css [deleted file]
templates/index.html [deleted file]
templates/papers/index.html [deleted file]
templates/questions/index.html [deleted file]
templates/questions/question_list.html [deleted file]
templates/settings/index.html [deleted file]
templates/subject/chapters.html [deleted file]
templates/subject/index.html [deleted file]
templates/subject/knowledge.html [deleted file]

index fe66e6c5aec8fcd766e8424d558ef4843184964c..3501a031e6229195327df271005fd5dd9588d580 100644 (file)
@@ -7,3 +7,7 @@ ENV/
 Media/
 *.db
 .DS_Store
+node_modules/
+nohup.out
+temp_preview.docx
+temp_review_preview.docx
diff --git a/app.log b/app.log
deleted file mode 100644 (file)
index 7daa52b..0000000
--- a/app.log
+++ /dev/null
@@ -1,41 +0,0 @@
-nohup: ignoring input
- * Serving Flask app 'app'
- * Debug mode: off
-WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
- * Running on all addresses (0.0.0.0)
- * Running on http://127.0.0.1:5000
- * Running on http://10.0.0.16:5000
-Press CTRL+C to quit
-219.144.254.168 - - [18/May/2026 07:11:39] "GET / HTTP/1.1" 200 -
-219.144.254.168 - - [18/May/2026 07:11:39] "GET /static/css/style.css HTTP/1.1" 304 -
-219.144.254.168 - - [18/May/2026 07:11:39] "GET /favicon.ico HTTP/1.1" 404 -
-219.144.254.168 - - [18/May/2026 07:11:47] "GET /subject/ HTTP/1.1" 200 -
-219.144.254.168 - - [18/May/2026 07:11:47] "GET /static/css/style.css HTTP/1.1" 304 -
-219.144.254.168 - - [18/May/2026 07:11:50] "GET / HTTP/1.1" 200 -
-219.144.254.168 - - [18/May/2026 07:11:50] "GET /static/css/style.css HTTP/1.1" 304 -
-219.144.254.168 - - [18/May/2026 07:11:51] "GET /questions/ HTTP/1.1" 200 -
-219.144.254.168 - - [18/May/2026 07:11:51] "GET /static/css/style.css HTTP/1.1" 304 -
-219.144.254.168 - - [18/May/2026 07:11:52] "GET / HTTP/1.1" 200 -
-219.144.254.168 - - [18/May/2026 07:11:52] "GET /static/css/style.css HTTP/1.1" 304 -
-219.144.254.168 - - [18/May/2026 07:16:45] "GET / HTTP/1.1" 200 -
-219.144.254.168 - - [18/May/2026 07:16:45] "GET /static/css/style.css HTTP/1.1" 304 -
-219.144.254.168 - - [18/May/2026 07:17:23] "GET /settings/ HTTP/1.1" 200 -
-219.144.254.168 - - [18/May/2026 07:17:23] "GET /static/css/style.css HTTP/1.1" 304 -
-219.144.254.168 - - [18/May/2026 07:17:25] "GET / HTTP/1.1" 200 -
-219.144.254.168 - - [18/May/2026 07:17:25] "GET /static/css/style.css HTTP/1.1" 304 -
-219.144.254.168 - - [18/May/2026 07:17:26] "GET /papers/ HTTP/1.1" 200 -
-219.144.254.168 - - [18/May/2026 07:17:26] "GET /static/css/style.css HTTP/1.1" 304 -
-219.144.254.168 - - [18/May/2026 07:18:24] "GET / HTTP/1.1" 200 -
-219.144.254.168 - - [18/May/2026 07:18:24] "GET /static/css/style.css HTTP/1.1" 304 -
-219.144.254.168 - - [18/May/2026 07:18:25] "GET /papers/ HTTP/1.1" 200 -
-219.144.254.168 - - [18/May/2026 07:18:25] "GET /static/css/style.css HTTP/1.1" 304 -
-220.196.160.101 - - [18/May/2026 07:18:29] "GET / HTTP/1.1" 200 -
-220.196.160.101 - - [18/May/2026 07:18:29] "GET /static/css/style.css HTTP/1.1" 200 -
-219.144.254.168 - - [18/May/2026 07:19:45] "GET /papers/ HTTP/1.1" 200 -
-219.144.254.168 - - [18/May/2026 07:19:45] "GET /static/css/style.css HTTP/1.1" 304 -
-219.144.254.168 - - [18/May/2026 07:19:46] "GET /papers/ HTTP/1.1" 200 -
-219.144.254.168 - - [18/May/2026 07:19:46] "GET /static/css/style.css HTTP/1.1" 304 -
-219.144.254.168 - - [18/May/2026 07:19:47] "GET /papers/ HTTP/1.1" 200 -
-219.144.254.168 - - [18/May/2026 07:19:47] "GET /static/css/style.css HTTP/1.1" 304 -
-219.144.254.168 - - [18/May/2026 07:21:04] "GET / HTTP/1.1" 200 -
-219.144.254.168 - - [18/May/2026 07:21:04] "GET /static/css/style.css HTTP/1.1" 304 -
diff --git a/app.py b/app.py
deleted file mode 100644 (file)
index 959a0ff..0000000
--- a/app.py
+++ /dev/null
@@ -1,134 +0,0 @@
-from flask import Flask, render_template, request, jsonify
-from database import query_db
-
-app = Flask(__name__)
-
-# ==================== 1. 核心页面路由 ====================
-
-@app.route('/')
-def index():
-    return render_template('index.html')
-
-# --- 题目列表模块 ---
-@app.route('/questions/')
-def questions_list():
-    return render_template('questions/index.html')
-
-# --- 试卷列表模块 ---
-@app.route('/papers/')
-def papers_list():
-    return render_template('papers/index.html')
-
-# --- 系统设定模块 ---
-@app.route('/settings/')
-def settings_page():
-    return render_template('settings/index.html')
-
-# --- 错题分析模块 (刚才修复的部分) ---
-@app.route('/subject/')
-def subject_portal():
-    subjects = query_db("SELECT ID, Subject as name FROM Subject WHERE enabled = 1")
-    stages = query_db("SELECT ID, stage_name FROM stage WHERE enabled = 1 ORDER BY ID")
-    default_id = subjects[0]['ID'] if subjects else None
-    return render_template('subject/index.html', subjects=subjects, stages=stages, default_id=default_id)
-
-@app.route('/subject/chapters')
-def subject_chapters():
-    subjects = query_db("SELECT DISTINCT Subject FROM Subject ORDER BY Subject")
-    return render_template('subject/chapters.html', subjects=subjects)
-
-@app.route('/subject/knowledge')
-def subject_knowledge():
-    subjects = query_db("SELECT DISTINCT Subject FROM Subject ORDER BY Subject")
-    chapter_id = request.args.get('chapter_id', '')
-    return render_template('subject/knowledge.html', subjects=subjects, chapter_id=chapter_id)
-
-
-# ==================== 2. API 数据接口 ====================
-
-# 科目概览统计
-@app.route('/api/subject_stats')
-def get_subject_stats():
-    sub_id = request.args.get('subject_id')
-    stage_ids_raw = request.args.get('stage_ids', '')
-    stage_ids = [sid for sid in stage_ids_raw.split(',') if sid]
-    
-    if not sub_id or not stage_ids:
-        return jsonify({"wrong": {"total": 0, "fixed": 0, "ratio": "0%", "remained": 0},
-                        "push": {"total": 0, "fixed": 0, "ratio": "0%", "remained": 0}})
-
-    placeholders = ','.join(['?'] * len(stage_ids))
-    def get_count(q_type, extra_sql=""):
-        sql = f"SELECT COUNT(*) as c FROM questions WHERE subject_id = ? AND stage_id IN ({placeholders}) AND question_type = ? {extra_sql}"
-        res = query_db(sql, [sub_id] + stage_ids + [q_type], one=True)
-        return res['c'] if res else 0
-
-    try:
-        w_total = get_count('错题'); w_fixed = get_count('错题', 'AND right_times > 0')
-        p_total = get_count('推题'); p_fixed = get_count('推题', 'AND right_times > 0')
-        return jsonify({
-            "wrong": {"total": w_total, "fixed": w_fixed, "ratio": f"{(w_fixed/w_total*100):.1f}%" if w_total>0 else "0%", "remained": w_total-w_fixed},
-            "push": {"total": p_total, "fixed": p_fixed, "ratio": f"{(p_fixed/p_total*100):.1f}%" if p_total>0 else "0%", "remained": p_total-p_fixed}
-        })
-    except Exception as e:
-        return jsonify({"error": str(e)}), 500
-
-# 章节数据接口 (已适配 chapter_desc)
-@app.route('/api/chapters')
-def get_chapters():
-    subject_name = request.args.get('subject', '全部')
-    keyword = request.args.get('keyword', '')
-    params = []
-    where = " WHERE 1=1"
-    if subject_name != '全部':
-        where += " AND s.Subject = ?"; params.append(subject_name)
-    if keyword:
-        where += " AND c.chapter_desc LIKE ?"; params.append(f"%{keyword}%")
-
-    sql = f"""
-    SELECT c.id AS chapter_id, s.Subject AS SubjectName, c.chapter_desc AS ChapterDesc,
-        (SELECT COUNT(DISTINCT r.question_id) FROM question_kp_relation r JOIN knowledge_point kp ON r.kp_id = kp.id WHERE kp.chapter_id = c.id) AS total,
-        (SELECT COUNT(DISTINCT r.question_id) FROM question_kp_relation r JOIN knowledge_point kp ON r.kp_id = kp.id JOIN questions q ON r.question_id = q.id WHERE kp.chapter_id = c.id AND q.right_times > 0) AS correct
-    FROM chapters c JOIN Subject s ON c.subject_id = s.ID {where}
-    """
-    try:
-        rows = query_db(sql, params)
-        result = [dict(row) for row in rows]
-        for item in result:
-            item['not_correct'] = (item['total'] or 0) - (item['correct'] or 0)
-        return jsonify([i for i in result if i['total'] > 0])
-    except Exception as e:
-        return jsonify({"error": str(e)}), 500
-
-# 知识点数据接口
-@app.route('/api/knowledge')
-def get_knowledge():
-    subject_name = request.args.get('subject', '全部')
-    keyword = request.args.get('keyword', '')
-    chapter_id = request.args.get('chapter_id', '')
-    params = []
-    where = " WHERE kp.is_enabled = 1"
-    if chapter_id:
-        where += " AND kp.chapter_id = ?"; params.append(chapter_id)
-    if subject_name != '全部':
-        where += " AND s.Subject = ?"; params.append(subject_name)
-    if keyword:
-        where += " AND kp.content LIKE ?"; params.append(f"%{keyword}%")
-
-    sql = f"""
-    SELECT kp.id, s.Subject as subject_name, kp.content,
-        (SELECT COUNT(*) FROM question_kp_relation r WHERE r.kp_id = kp.id) as total,
-        (SELECT COUNT(DISTINCT r.question_id) FROM question_kp_relation r JOIN questions q ON r.question_id = q.id WHERE r.kp_id = kp.id AND q.right_times >= 1) as correct
-    FROM knowledge_point kp JOIN Subject s ON kp.subject_id = s.ID {where}
-    """
-    try:
-        rows = query_db(sql, params)
-        result = [dict(row) for row in rows]
-        for item in result:
-            item['not_correct'] = (item['total'] or 0) - (item['correct'] or 0)
-        return jsonify(result)
-    except Exception as e:
-        return jsonify({"error": str(e)}), 500
-
-if __name__ == '__main__':
-    app.run(host='0.0.0.0', port=5000, debug=True)
diff --git a/backend/create_word.py b/backend/create_word.py
new file mode 100644 (file)
index 0000000..41ce016
--- /dev/null
@@ -0,0 +1,590 @@
+# create_word.py
+import os
+import datetime
+import sqlite3
+import re
+import traceback
+
+from docx import Document
+from docx.shared import Inches, Pt, RGBColor, Emu, Cm
+from docx.enum.text import WD_PARAGRAPH_ALIGNMENT, WD_LINE_SPACING
+from docx.oxml.ns import qn
+from docx.oxml import OxmlElement
+
+# 页面内容区域最大宽度(保守值)
+MAX_DOCX_CONTENT_WIDTH_EMU = Inches(7.36)
+BLUE = RGBColor(0, 102, 204)
+
+
+def _get_val(row, key_name, default=""):
+    keys = row.keys()
+    for k in keys:
+        if k.lower() == key_name.lower():
+            val = row[k]
+            return str(val) if val is not None and str(val).strip() != "" else default
+    return default
+
+
+def _get_int(row, key_name, default=0):
+    try:
+        keys = row.keys()
+        for k in keys:
+            if k.lower() == key_name.lower():
+                val = row[k]
+                if val is None or str(val).strip() == "":
+                    return default
+                return int(val)
+    except Exception:
+        return default
+    return default
+
+
+def _set_font(run, name, size, color=None):
+    run.font.name = name
+    run._element.rPr.rFonts.set(qn('w:eastAsia'), name)
+    run.font.size = Pt(size)
+    if color:
+        run.font.color.rgb = color
+
+
+def _insert_field(run, field_type):
+    fld = OxmlElement('w:fldChar')
+    fld.set(qn('w:fldCharType'), 'begin')
+    run._element.append(fld)
+
+    instr = OxmlElement('w:instrText')
+    instr.set(qn('xml:space'), 'preserve')
+    instr.text = field_type
+    run._element.append(instr)
+
+    fld = OxmlElement('w:fldChar')
+    fld.set(qn('w:fldCharType'), 'end')
+    run._element.append(fld)
+
+
+def _insert_mixed_content(paragraph, full_text, base_dir, font_name='SimSun', font_size=12, debug_print=False):
+    if not full_text:
+        return
+    full_text = str(full_text).replace('\xa0', ' ')
+    img_pattern = r'\s*(!\[\]\s*\(\s*.*?\s*\))\s*'
+    parts = re.split(img_pattern, full_text)
+    for part in parts:
+        if not part:
+            continue
+        match = re.match(r'!\[\]\s*\(\s*(.*?)\s*\)', part)
+        if match:
+            content_inner = match.group(1).strip()
+            rel_path = content_inner
+            width_emu = None
+            height_emu = None
+            if '?' in content_inner:
+                try:
+                    rel_path, params = content_inner.split('?', 1)
+                    w_match = re.search(r'w=(\d+)', params)
+                    h_match = re.search(r'h=(\d+)', params)
+                    if w_match:
+                        width_emu = int(w_match.group(1))
+                    if h_match:
+                        height_emu = int(h_match.group(1))
+                except Exception:
+                    pass
+
+            rel_path = rel_path.strip().replace('\\', '/')
+            full_img_path = os.path.normpath(os.path.join(base_dir, rel_path))
+            if debug_print:
+                print("DEBUG image candidate:", full_img_path)
+
+            if not os.path.exists(full_img_path) and "Media" in rel_path:
+                no_space_path = rel_path.replace(" ", "")
+                full_img_path_b = os.path.normpath(os.path.join(base_dir, no_space_path))
+                if os.path.exists(full_img_path_b):
+                    full_img_path = full_img_path_b
+
+            if os.path.exists(full_img_path):
+                try:
+                    run = paragraph.add_run()
+                    target_width = None
+                    target_height = None
+                    if width_emu and height_emu:
+                        target_width = Emu(width_emu)
+                        target_height = Emu(height_emu)
+                    elif width_emu:
+                        target_width = Emu(width_emu)
+                    elif height_emu:
+                        target_height = Emu(height_emu)
+
+                    if target_width:
+                        if target_width > MAX_DOCX_CONTENT_WIDTH_EMU:
+                            run.add_picture(full_img_path, width=MAX_DOCX_CONTENT_WIDTH_EMU)
+                        else:
+                            if target_height:
+                                run.add_picture(full_img_path, width=target_width, height=target_height)
+                            else:
+                                run.add_picture(full_img_path, width=target_width)
+                    elif target_height:
+                        run.add_picture(full_img_path, height=target_height)
+                    else:
+                        run.add_picture(full_img_path, width=MAX_DOCX_CONTENT_WIDTH_EMU)
+                except Exception as e:
+                    print(f"Insert image error: {e}, Path: {full_img_path}")
+                    _set_font(paragraph.add_run("[图片错误]"), font_name, font_size, RGBColor(255, 0, 0))
+            else:
+                _set_font(paragraph.add_run(part), font_name, font_size)
+        else:
+            _set_font(paragraph.add_run(part), font_name, font_size)
+
+
+def _set_table_borders(table, color="000000", size=4):
+    def _make_border_element(name, color, size):
+        el = OxmlElement(f"w:{name}")
+        el.set(qn("w:val"), "single")
+        el.set(qn("w:sz"), str(size))
+        el.set(qn("w:color"), color)
+        el.set(qn("w:space"), "0")
+        return el
+
+    tbl = table._element
+    tblPr = getattr(tbl, 'tblPr', None)
+    if tblPr is None:
+        tblPr = OxmlElement("w:tblPr")
+        tbl.insert(0, tblPr)
+
+    tblBorders = getattr(tblPr, 'tblBorders', None)
+    if tblBorders is None:
+        tblBorders = OxmlElement("w:tblBorders")
+        tblPr.append(tblBorders)
+    else:
+        for child in list(tblBorders):
+            tblBorders.remove(child)
+
+    for border_name in ("top", "left", "bottom", "right", "insideH", "insideV"):
+        tblBorders.append(_make_border_element(border_name, color, size))
+
+    for row in table.rows:
+        for cell in row.cells:
+            tc = cell._element
+            tcPr = getattr(tc, 'tcPr', None)
+            if tcPr is None:
+                tcPr = OxmlElement("w:tcPr")
+                tc.insert(0, tcPr)
+
+            tcBorders = getattr(tcPr, 'tcBorders', None)
+            if tcBorders is None:
+                tcBorders = OxmlElement("w:tcBorders")
+                tcPr.append(tcBorders)
+            else:
+                for child in list(tcBorders):
+                    tcBorders.remove(child)
+
+            for bname in ("top", "left", "bottom", "right"):
+                tcBorders.append(_make_border_element(bname, color, size))
+
+
+def _starts_with_image_markdown(text):
+    img_pattern = r'^\s*!\[\]\s*\(\s*.*?\s*\)'
+    return re.match(img_pattern, text.strip()) is not None
+
+
+def _add_header_footer(section, left_header_text):
+    # 页眉
+    header = section.header
+    if header.paragraphs:
+        header.paragraphs[0].text = ""
+    htable = header.add_table(1, 2, width=Inches(6.7))
+    htable.autofit = False
+
+    cell_left = htable.cell(0, 0)
+    try:
+        cell_left.paragraphs[0].clear()
+    except Exception:
+        cell_left.paragraphs[0].text = ""
+    _set_font(cell_left.paragraphs[0].add_run(left_header_text), 'Microsoft YaHei', 10.5, BLUE)
+
+    cell_right = htable.cell(0, 1)
+    cell_right.paragraphs[0].alignment = WD_PARAGRAPH_ALIGNMENT.RIGHT
+    _set_font(cell_right.paragraphs[0].add_run("试卷生成日期:" + datetime.datetime.now().strftime("%Y-%m-%d")), '黑体', 9)
+
+    # 页脚
+    footer_p = section.footer.paragraphs[0]
+    footer_p.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
+    parts = [("第 ", None), (" ", "PAGE"), (" 页 共 ", None), (" ", "NUMPAGES"), (" 页", None)]
+    for text, field in parts:
+        run = footer_p.add_run(text)
+        _set_font(run, 'SimSun', 9)
+        if field:
+            _insert_field(run, field)
+
+
+def _load_questions(selected_ids, get_db_connection):
+    conn = get_db_connection()
+    conn.row_factory = sqlite3.Row
+    cursor = conn.cursor()
+
+    placeholders = ','.join(['?'] * len(selected_ids))
+    query = f"""
+        SELECT 
+            q.*, 
+            s.Subject, 
+            qs.source_description,
+            q.latest_status,
+            q.half_right_description
+        FROM questions q 
+        LEFT JOIN Subject s ON q.subject_id = s.ID 
+        LEFT JOIN question_source qs ON q.source_id = qs.id 
+        WHERE q.id IN ({placeholders}) 
+        ORDER BY q.subject_id ASC, q.id ASC
+    """
+    cursor.execute(query, tuple(sorted(selected_ids)))
+    rows = cursor.fetchall()
+    conn.close()
+    return rows
+
+
+def _resolve_real_paper_id(selected_ids, questions_data, paper_id_for_header):
+    real_paper_id = None
+    if paper_id_for_header is not None:
+        real_paper_id = str(paper_id_for_header)
+    else:
+        if questions_data:
+            first_row = questions_data[0]
+            candidate_keys = ['paper_id', 'paperid', 'paper', 'exam_id', 'examid', 'test_id', 'testid', 'paperID', 'PaperID', 'paperId']
+            for k in first_row.keys():
+                if k.lower() in [c.lower() for c in candidate_keys]:
+                    val = first_row[k]
+                    if val is not None and str(val).strip() != "":
+                        real_paper_id = str(val)
+                        break
+            if real_paper_id is None:
+                for k in first_row.keys():
+                    if 'paper' in k.lower() or 'exam' in k.lower() or 'test' in k.lower():
+                        val = first_row[k]
+                        if val is not None and str(val).strip() != "":
+                            real_paper_id = str(val)
+                            break
+
+    if real_paper_id is None and hasattr(selected_ids, '__iter__'):
+        try:
+            real_paper_id = str(next(iter(selected_ids)))
+        except StopIteration:
+            real_paper_id = "未知"
+    return real_paper_id
+
+
+def _render_question_stem(doc, q, idx, total_questions, base_dir, default_font_size_pt=12, debug_print=False):
+    # 第一行:题号 + 基础信息 + 统计
+    info_p = doc.add_paragraph()
+
+    num_run = info_p.add_run(f"{idx+1}. ")
+    _set_font(num_run, 'SimSun', 9, RGBColor(0, 0, 0))
+    num_run.bold = True
+
+    base_text = (
+        f"ID:{_get_val(q, 'id')} | "
+        f"{_get_val(q,'question_type','题目')} | "
+        f"{_get_val(q,'Subject')} | "
+        f"{_get_val(q,'source_description')}"
+    )
+    _set_font(info_p.add_run(base_text), 'SimSun', 9, RGBColor(0, 0, 0))
+
+    paper_count = _get_int(q, 'paper_count', 0)
+    right_times = _get_int(q, 'right_times', 0)
+    wrong_times = _get_int(q, 'wrong_times', 0)
+    half_right_times = _get_int(q, 'half_right_times', 0)
+
+    _set_font(info_p.add_run(" | 组卷次数:"), 'SimSun', 9, RGBColor(0, 0, 0))
+    cnt_run = info_p.add_run(str(paper_count))
+    _set_font(cnt_run, 'SimSun', 9, RGBColor(255, 0, 0) if paper_count > 2 else RGBColor(0, 0, 0))
+    _set_font(
+        info_p.add_run(f" | 正确次数:{right_times} | 错误次数:{wrong_times} | 部分正确次数:{half_right_times}"),
+        'SimSun', 9, RGBColor(0, 0, 0)
+    )
+
+    # 上次部分错误说明
+    latest_status = _get_val(q, 'latest_status')
+    half_right_description = _get_val(q, 'half_right_description')
+    if latest_status == 'half_right' and half_right_description:
+        half_right_p = doc.add_paragraph()
+        run_half_right_prefix = half_right_p.add_run("上次部分错误:")
+        _set_font(run_half_right_prefix, 'SimSun', 9, RGBColor(0, 0, 0))
+        run_half_right_prefix.bold = True
+        run_half_right_desc = half_right_p.add_run(half_right_description)
+        _set_font(run_half_right_desc, 'SimSun', 9, RGBColor(0, 0, 0))
+
+    # 题干渲染
+    content_text = _get_val(q, 'content', '')
+    content_lines = content_text.split('\n')
+
+    first_line = True
+    i = 0
+    while i < len(content_lines):
+        line_content = content_lines[i]
+        if not line_content.strip() and len(content_lines) > 1:
+            i += 1
+            continue
+
+        stripped_line = line_content.strip()
+
+        # 表格
+        if " | " in stripped_line:
+            table_rows = []
+            while i < len(content_lines):
+                row_candidate = content_lines[i].strip()
+                if not row_candidate or " | " not in row_candidate:
+                    break
+                table_rows.append(row_candidate)
+                i += 1
+
+            if table_rows:
+                num_cols = 0
+                for r in table_rows:
+                    cols = r.split(" | ")
+                    if len(cols) > num_cols:
+                        num_cols = len(cols)
+                if num_cols < 1:
+                    num_cols = 1
+
+                tbl = doc.add_table(rows=len(table_rows), cols=num_cols)
+                tbl.autofit = True
+                _set_table_borders(tbl, color="000000", size=4)
+
+                for r_idx, row_text in enumerate(table_rows):
+                    cells_text = row_text.split(" | ")
+                    for c_idx in range(num_cols):
+                        cell_para = tbl.cell(r_idx, c_idx).paragraphs[0]
+                        cell_para.paragraph_format.line_spacing_rule = WD_LINE_SPACING.AT_LEAST
+                        cell_para.paragraph_format.line_spacing = Pt(default_font_size_pt * 1.5)
+                        this_cell_text = cells_text[c_idx] if c_idx < len(cells_text) else ""
+                        _insert_mixed_content(
+                            cell_para,
+                            this_cell_text.strip(),
+                            base_dir,
+                            font_size=default_font_size_pt,
+                            debug_print=debug_print
+                        )
+                continue
+
+        # 普通行
+        p = doc.add_paragraph()
+        p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.AT_LEAST
+        p.paragraph_format.line_spacing = Pt(default_font_size_pt * 4)
+
+        prefix = "" if first_line else "   "
+        _insert_mixed_content(
+            p,
+            f"{prefix}{stripped_line}",
+            base_dir,
+            font_size=default_font_size_pt,
+            debug_print=debug_print
+        )
+        first_line = False
+        i += 1
+
+    # 分页/空行规则(原逻辑)
+    is_sub_str = _get_val(q, 'is_subjective', '0')
+    if debug_print:
+        print(f"DEBUG: 题目ID={_get_val(q, 'id')}, is_subjective={is_sub_str}, Current Index={idx}, Total Questions={total_questions}")
+
+    return str(is_sub_str).lower() in ['1', 'true', 'yes', 'on']
+
+
+def _render_answer_block(doc, q, idx, base_dir, default_font_size_pt=12, debug_print=False):
+    analysis_text = _get_val(q, 'analysis', '') or _get_val(q, 'answer', '略')
+    lines = analysis_text.split('\n')
+
+    # 标题行
+    title_p = doc.add_paragraph()
+    title_p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.AT_LEAST
+    title_p.paragraph_format.line_spacing = Pt(default_font_size_pt * 4)
+
+    run_prefix = title_p.add_run(f"ID:{_get_val(q, 'id')} | {idx+1}. 【参考答案】")
+    _set_font(run_prefix, 'SimSun', 9, RGBColor(0, 0, 0))
+    run_prefix.bold = True
+
+    # 内容行
+    for line_content in lines:
+        if not line_content.strip():
+            continue
+        ans_p = doc.add_paragraph()
+        ans_p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.AT_LEAST
+        ans_p.paragraph_format.line_spacing = Pt(default_font_size_pt * 4)
+        _insert_mixed_content(ans_p, line_content.strip(), base_dir, font_size=default_font_size_pt, debug_print=debug_print)
+
+
+def generate_docx(selected_ids, base_dir, get_db_connection, title=None, paper_id_for_header=None, debug_print=False):
+    """
+    原功能:题目页 + 末尾集中“答案与解析”页
+    """
+    if not selected_ids:
+        raise ValueError("selected_ids 为空,无法生成试卷")
+
+    doc = Document()
+    section = doc.sections[0]
+    section.page_height = Cm(29.7)
+    section.page_width = Cm(21)
+    section.left_margin = Cm(1.3)
+    section.right_margin = Cm(1.0)
+    section.top_margin = Cm(2.0)
+    section.bottom_margin = Cm(1.0)
+
+    default_font_size_pt = 12
+
+    try:
+        questions_data = _load_questions(selected_ids, get_db_connection)
+        total_questions = len(questions_data)
+
+        real_paper_id = _resolve_real_paper_id(selected_ids, questions_data, paper_id_for_header)
+        _add_header_footer(section, f"试卷{real_paper_id}")
+
+        # 标题
+        title_text = title if title else f"试卷 {datetime.datetime.now().strftime('%Y%m%d')}"
+        title_p = doc.add_paragraph()
+        title_p.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
+        title_run = title_p.add_run(title_text)
+        _set_font(title_run, '黑体', 16)
+        title_run.bold = True
+
+        # 题目页
+        for idx, q in enumerate(questions_data):
+            is_subjective = _render_question_stem(
+                doc, q, idx, total_questions, base_dir,
+                default_font_size_pt=default_font_size_pt,
+                debug_print=debug_print
+            )
+
+            if is_subjective and idx < total_questions - 1:
+                doc.add_page_break()
+            else:
+                if idx < total_questions - 1:
+                    doc.add_paragraph()
+
+        # 答案页
+        if questions_data:
+            doc.add_page_break()
+            ans_title_p = doc.add_paragraph()
+            ans_title_p.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
+            ans_run = ans_title_p.add_run("答案与解析")
+            _set_font(ans_run, '黑体', 16)
+            ans_run.bold = True
+
+            for idx, q in enumerate(questions_data):
+                analysis_text = _get_val(q, 'analysis', '') or _get_val(q, 'answer', '略')
+                lines = analysis_text.split('\n')
+
+                for line_idx, line_content in enumerate(lines):
+                    if not line_content.strip():
+                        continue
+
+                    ans_p = doc.add_paragraph()
+                    ans_p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.AT_LEAST
+                    ans_p.paragraph_format.line_spacing = Pt(default_font_size_pt * 4)
+
+                    prefix = ""
+                    if line_idx == 0:
+                        prefix_content = f"ID:{_get_val(q, 'id')} | {idx+1}. 【参考答案】"
+                        if _starts_with_image_markdown(line_content.strip()):
+                            _insert_mixed_content(
+                                ans_p,
+                                prefix_content,
+                                base_dir,
+                                font_size=default_font_size_pt,
+                                debug_print=debug_print
+                            )
+                            ans_p = doc.add_paragraph()
+                            ans_p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.AT_LEAST
+                            ans_p.paragraph_format.line_spacing = Pt(default_font_size_pt * 4)
+                        else:
+                            prefix = prefix_content
+                    else:
+                        prefix = "      "
+
+                    _insert_mixed_content(ans_p, f"{prefix}{line_content.strip()}", base_dir, font_size=default_font_size_pt, debug_print=debug_print)
+
+    except Exception as e:
+        print(f"ERROR in generate_docx: {e}")
+        traceback.print_exc()
+        raise
+
+    out_path = os.path.join(base_dir, "temp_preview.docx")
+    try:
+        doc.save(out_path)
+    except Exception as e:
+        print(f"Save docx error: {e}")
+        traceback.print_exc()
+        raise
+
+# =========================
+# 新增:复习卷生成(答案紧跟题目后)
+# =========================
+
+def generate_review_docx(selected_ids, base_dir, get_db_connection, title=None, paper_id_for_header=None, debug_print=False):
+    """
+    复习卷:
+    - 每道题的答案与解析紧跟该题题干之后
+    - 页眉试卷ID后追加“-复习卷”
+    - 其他生成规则保持与 generate_docx 一致
+    """
+    if not selected_ids:
+        raise ValueError("selected_ids 为空,无法生成试卷")
+
+    doc = Document()
+    section = doc.sections[0]
+    section.page_height = Cm(29.7)
+    section.page_width = Cm(21)
+    section.left_margin = Cm(1.3)
+    section.right_margin = Cm(1.0)
+    section.top_margin = Cm(2.0)
+    section.bottom_margin = Cm(1.0)
+
+    default_font_size_pt = 12
+
+    try:
+        questions_data = _load_questions(selected_ids, get_db_connection)
+        total_questions = len(questions_data)
+
+        real_paper_id = _resolve_real_paper_id(selected_ids, questions_data, paper_id_for_header)
+        _add_header_footer(section, f"试卷{real_paper_id}-复习卷")
+
+        # 标题(与原规则一致)
+        title_text = title if title else f"试卷 {datetime.datetime.now().strftime('%Y%m%d')}"
+        title_p = doc.add_paragraph()
+        title_p.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
+        title_run = title_p.add_run(title_text)
+        _set_font(title_run, '黑体', 16)
+        title_run.bold = True
+
+        for idx, q in enumerate(questions_data):
+            # 题干(复用原渲染逻辑)
+            is_subjective = _render_question_stem(
+                doc, q, idx, total_questions, base_dir,
+                default_font_size_pt=default_font_size_pt,
+                debug_print=debug_print
+            )
+
+            # 关键:答案紧跟在本题后
+            _render_answer_block(
+                doc, q, idx, base_dir,
+                default_font_size_pt=default_font_size_pt,
+                debug_print=debug_print
+            )
+
+            # 分页规则保持与原逻辑一致(主观题分页)
+            if is_subjective and idx < total_questions - 1:
+                doc.add_page_break()
+            else:
+                if idx < total_questions - 1:
+                    doc.add_paragraph()
+
+    except Exception as e:
+        print(f"ERROR in generate_review_docx: {e}")
+        traceback.print_exc()
+        raise
+
+    out_path = os.path.join(base_dir, "temp_review_preview.docx")
+    try:
+        doc.save(out_path)
+    except Exception as e:
+        print(f"Save review docx error: {e}")
+        traceback.print_exc()
+        raise
+
+    return out_path
diff --git a/backend/main.py b/backend/main.py
new file mode 100644 (file)
index 0000000..475f010
--- /dev/null
@@ -0,0 +1,1964 @@
+# ===================== StudyBase Web backend (FastAPI) =====================
+# Every endpoint ports the logic of the corresponding Tkinter View from the
+# original desktop app 1:1 (SQL, validations, side-effects included).
+import os
+import io
+import re
+import sqlite3
+import datetime
+import shutil
+import tempfile
+import traceback
+
+from fastapi import FastAPI, Request, UploadFile, File, Form, HTTPException
+from fastapi.responses import FileResponse, StreamingResponse, JSONResponse
+from pydantic import BaseModel
+from typing import Optional, List, Dict, Any
+
+import create_word
+import media_utils
+from parsers.zhixuewang_file_parsing import ZhixuewangParser
+from parsers.zujuanwang_file_parsing import ZujuanwangParser
+from parsers.zizuword_file_parsing import ZizuWordParser
+
+BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
+DB_PATH = os.path.join(BASE_DIR, "s", "studybase.db")
+MEDIA_BASE_DIR = os.path.join(BASE_DIR, "Media")
+
+app = FastAPI(title="StudyBase Web")
+
+# per-request image counters (question_detail uses question ids as keys,
+# manuallyadd uses question numbers as keys)
+_image_counters: Dict[Any, int] = {}
+
+
+def get_db():
+    conn = sqlite3.connect(DB_PATH, timeout=10)
+    conn.row_factory = sqlite3.Row
+    return conn
+
+
+def row_to_dict(row):
+    return {k: row[k] for k in row.keys()} if row is not None else None
+
+
+def now_str():
+    return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+
+# ============================================================
+# 科目面板 (SubjectPortalView)
+# ============================================================
+
+@app.get("/api/portal/options")
+def portal_options():
+    conn = get_db()
+    cur = conn.cursor()
+    cur.execute("SELECT Subject FROM Subject WHERE enabled = 1")
+    subjects = [r[0] for r in cur.fetchall()]
+    if not subjects:
+        subjects = ["数学"]  # original fallback
+    cur.execute("SELECT ID, stage_name FROM stage WHERE enabled = 1 ORDER BY ID")
+    stages = [{"id": r[0], "name": r[1]} for r in cur.fetchall()]
+    conn.close()
+    return {"subjects": subjects, "stages": stages}
+
+
+class PortalStatsReq(BaseModel):
+    subject: str
+    stage_ids: List[int]
+
+
+@app.post("/api/portal/stats")
+def portal_stats(req: PortalStatsReq):
+    stats = {
+        "wrong_total": 0, "wrong_fixed": 0, "wrong_ratio": "0%", "wrong_remained": 0,
+        "push_total": 0, "push_fixed": 0, "push_ratio": "0%", "push_remained": 0,
+    }
+    if not req.stage_ids:
+        return stats
+
+    conn = get_db()
+    cur = conn.cursor()
+    cur.execute("SELECT ID FROM Subject WHERE Subject = ?", (req.subject,))
+    res = cur.fetchone()
+    if not res:
+        conn.close()
+        return stats
+    sub_id = res[0]
+
+    stage_filter = f"AND stage_id IN ({','.join(map(str, req.stage_ids))})"
+
+    cur.execute(f"SELECT COUNT(*) FROM questions WHERE question_type = '错题' AND subject_id = ? {stage_filter}", (sub_id,))
+    w_total = cur.fetchone()[0]
+    cur.execute(f"SELECT COUNT(*) FROM questions WHERE question_type = '错题' AND subject_id = ? AND right_times > 0 {stage_filter}", (sub_id,))
+    w_fixed = cur.fetchone()[0]
+    cur.execute(f"SELECT COUNT(*) FROM questions WHERE question_type = '推题' AND subject_id = ? {stage_filter}", (sub_id,))
+    p_total = cur.fetchone()[0]
+    cur.execute(f"SELECT COUNT(*) FROM questions WHERE question_type = '推题' AND subject_id = ? AND right_times > 0 {stage_filter}", (sub_id,))
+    p_fixed = cur.fetchone()[0]
+    conn.close()
+
+    stats.update({
+        "wrong_total": w_total,
+        "wrong_fixed": w_fixed,
+        "wrong_ratio": f"{(w_fixed / w_total * 100):.1f}%" if w_total > 0 else "0%",
+        "wrong_remained": w_total - w_fixed,
+        "push_total": p_total,
+        "push_fixed": p_fixed,
+        "push_ratio": f"{(p_fixed / p_total * 100):.1f}%" if p_total > 0 else "0%",
+        "push_remained": p_total - p_fixed,
+    })
+    return stats
+
+
+# ============================================================
+# 题目列表 (QuestionListView)
+# ============================================================
+
+@app.get("/api/questions/filter-options")
+def question_filter_options():
+    conn = get_db()
+    cur = conn.cursor()
+
+    def opts(table, col, enabled_only=False):
+        if enabled_only:
+            cur.execute(f"SELECT DISTINCT {col} FROM {table} WHERE Enabled = 1 ORDER BY {col}")
+        else:
+            cur.execute(f"SELECT DISTINCT {col} FROM {table} WHERE {col} IS NOT NULL ORDER BY {col}")
+        return [r[0] for r in cur.fetchall()]
+
+    data = {
+        "subject": opts("Subject", "Subject"),
+        "cause": opts("Error_Cause", "Cause"),
+        "source": opts("question_source", "source_description"),
+        "archive_status": opts("Question_Status", "StatusName", enabled_only=True),
+        "term": opts("term", "term_name"),
+        "stage": opts("stage", "stage_name"),
+    }
+    conn.close()
+    return data
+
+
+class QuestionSearchReq(BaseModel):
+    filters: Dict[str, Any] = {}
+    id_input: str = ""
+    kp_input: str = ""
+    chapter_id_filter: Optional[int] = None
+    kp_id_filter: Optional[int] = None
+    right_times_filter: Optional[str] = None
+    mode: str = "table"  # table | preview
+    sort_col: str = "q.id"
+    sort_desc: bool = False
+
+
+# map of table-column -> db column (question_list.sort_by)
+SORT_COL_WHITELIST = {
+    "subject_id", "q.id", "latest_status", "question_type", "knowledge_points_desc",
+    "answer_time", "input_time", "paper_count", "right_times", "wrong_times",
+    "half_right_times", "last_right_time",
+}
+
+
+def build_question_search_sql(req: QuestionSearchReq):
+    """Exact port of QuestionListView.load_filtered_data SQL construction."""
+    conn = get_db()
+    cur = conn.cursor()
+
+    sql = ("SELECT q.*, s.Subject, qs.source_description, ec.Cause, t.term_name, st.stage_name, qst.StatusName, "
+           "(SELECT GROUP_CONCAT(kp.content, '、') FROM question_kp_relation qkr JOIN knowledge_point kp ON qkr.kp_id = kp.id WHERE qkr.question_id = q.id) AS knowledge_points_desc "
+           "FROM questions q "
+           "LEFT JOIN Subject s ON q.subject_id = s.ID "
+           "LEFT JOIN question_source qs ON q.source_id = qs.id "
+           "LEFT JOIN Error_Cause ec ON q.error_cause_id = ec.ID "
+           "LEFT JOIN term t ON q.term_id = t.ID "
+           "LEFT JOIN stage st ON q.stage_id = st.ID "
+           "LEFT JOIN Question_Status qst ON q.status_id = qst.ID "
+           "WHERE 1=1")
+
+    params = []
+    f = req.filters or {}
+
+    if req.chapter_id_filter is not None:
+        sql += " AND EXISTS (SELECT 1 FROM question_kp_relation qkr JOIN knowledge_point kp ON qkr.kp_id = kp.id WHERE qkr.question_id = q.id AND kp.chapter_id = ?)"
+        params.append(req.chapter_id_filter)
+    if req.kp_id_filter is not None:
+        sql += " AND EXISTS (SELECT 1 FROM question_kp_relation qkr WHERE qkr.kp_id = ? AND qkr.question_id = q.id)"
+        params.append(req.kp_id_filter)
+    if req.right_times_filter == '>=1':
+        sql += " AND q.latest_status = 'right'"
+    elif req.right_times_filter == '=0':
+        sql += " AND (q.latest_status != 'right' OR q.latest_status IS NULL)"
+
+    v_sub = f.get("subject")
+    if v_sub and v_sub != "全部":
+        sql += " AND s.Subject = ?"; params.append(v_sub)
+    v_nature = f.get("question_nature")
+    if v_nature and v_nature != "全部":
+        sql += " AND q.question_type = ?"; params.append(v_nature)
+    v_latest_status = f.get("status")
+    if v_latest_status and v_latest_status != "全部":
+        if v_latest_status == "未完成":
+            sql += " AND (q.latest_status IS NULL OR q.latest_status = '')"
+        elif v_latest_status == "正确":
+            sql += " AND q.latest_status = 'right'"
+        elif v_latest_status == "错误":
+            sql += " AND q.latest_status = 'wrong'"
+        elif v_latest_status == "部分正确":
+            sql += " AND q.latest_status = 'half_right'"
+    v_archive_status = f.get("archive_status")
+    if v_archive_status and v_archive_status != "全部":
+        if v_archive_status == "未归档":
+            cur.execute("SELECT ID FROM Question_Status WHERE StatusName = '未归档' AND Enabled = 1")
+            not_archive_id_row = cur.fetchone()
+            if not_archive_id_row:
+                sql += " AND (q.status_id = ? OR q.status_id IS NULL)"
+                params.append(not_archive_id_row[0])
+            else:
+                sql += " AND q.status_id IS NULL"
+        else:
+            cur.execute("SELECT ID FROM Question_Status WHERE StatusName = ? AND Enabled = 1", (v_archive_status,))
+            archive_id_row = cur.fetchone()
+            if archive_id_row:
+                sql += " AND q.status_id = ?"
+                params.append(archive_id_row[0])
+            else:
+                sql += " AND 1=0"
+
+    v_cause = f.get("cause")
+    if v_cause and v_cause != "全部":
+        sql += " AND ec.Cause = ?"; params.append(v_cause)
+    v_src = f.get("source")
+    if v_src and v_src != "全部":
+        sql += " AND qs.source_description = ?"; params.append(v_src)
+    v_term = f.get("term")
+    if v_term and v_term != "全部":
+        sql += " AND t.term_name = ?"; params.append(v_term)
+    v_stage = f.get("stage")
+    if v_stage and v_stage != "全部":
+        sql += " AND st.stage_name = ?"; params.append(v_stage)
+
+    cnt_map = {"paper_count": "q.paper_count", "right_times": "q.right_times",
+               "wrong_times": "q.wrong_times", "half_right_times": "q.half_right_times"}
+    for k, col in cnt_map.items():
+        if k == "right_times" and req.right_times_filter is not None:
+            continue
+        val = f.get(k)
+        if val and val != "全部":
+            if val == "4次及以上":
+                sql += f" AND {col} >= 4"
+            else:
+                try:
+                    sql += f" AND {col} = ?"
+                    params.append(int(val))
+                except ValueError:
+                    pass
+
+    id_in = (req.id_input or "").strip()
+    if id_in:
+        ids = []
+        for s_id in id_in.split():
+            try:
+                ids.append(int(s_id))
+            except ValueError:
+                pass
+        if ids:
+            sql += f" AND q.id IN ({','.join(['?'] * len(ids))})"
+            params.extend(ids)
+    kp_in = (req.kp_input or "").strip()
+    if kp_in:
+        for w in kp_in.split():
+            sql += " AND EXISTS (SELECT 1 FROM question_kp_relation qkr_c JOIN knowledge_point kp_c ON qkr_c.kp_id = kp_c.id WHERE qkr_c.question_id = q.id AND kp_c.content LIKE ?)"
+            params.append(f"%{w}%")
+
+    if req.mode == "table":
+        sort_col = req.sort_col if req.sort_col in SORT_COL_WHITELIST else "q.id"
+        sql += f" ORDER BY {sort_col} {'DESC' if req.sort_desc else 'ASC'}"
+    else:
+        sql += " ORDER BY q.id ASC"
+
+    conn.close()
+    return sql, params
+
+
+@app.post("/api/questions/search")
+def question_search(req: QuestionSearchReq):
+    try:
+        sql, params = build_question_search_sql(req)
+        conn = get_db()
+        cur = conn.cursor()
+        cur.execute(sql, params)
+        rows = [row_to_dict(r) for r in cur.fetchall()]
+
+        # 所属试卷ID (render_as_table)
+        for r in rows:
+            cur.execute("SELECT paper_id FROM paper_questions WHERE question_id = ?", (r["id"],))
+            r["paper_ids"] = ",".join(str(x[0]) for x in cur.fetchall())
+        conn.close()
+        return {"count": len(rows), "rows": rows}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+class IdsReq(BaseModel):
+    ids: List[int]
+
+
+@app.post("/api/questions/delete")
+def question_delete(req: IdsReq):
+    """QuestionListView.perform_deletion"""
+    qids = req.ids
+    if not qids:
+        raise HTTPException(status_code=400, detail="请勾选题目")
+    try:
+        conn = get_db()
+        cur = conn.cursor()
+        for qid in qids:
+            cur.execute("SELECT media_path FROM questions WHERE id = ?", (qid,))
+            res = cur.fetchone()
+            if res and res[0]:
+                p = os.path.normpath(os.path.join(BASE_DIR, res[0]))
+                if os.path.exists(p):
+                    shutil.rmtree(p)
+        ps = ','.join(['?'] * len(qids))
+        cur.execute(f"DELETE FROM paper_questions WHERE question_id IN ({ps})", qids)
+        cur.execute(f"DELETE FROM questions WHERE id IN ({ps})", qids)
+        cur.execute(f"DELETE FROM question_kp_relation WHERE question_id IN ({ps})", qids)
+        conn.commit()
+        conn.close()
+        return {"deleted": len(qids)}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.post("/api/questions/bulk-archive")
+def question_bulk_archive(req: IdsReq):
+    """QuestionListView.bulk_archive_selected"""
+    selected_ids = req.ids
+    if not selected_ids:
+        raise HTTPException(status_code=400, detail="请选择要归档的题目")
+    try:
+        conn = get_db()
+        cur = conn.cursor()
+        cur.execute("SELECT ID FROM Question_Status WHERE StatusName = '已归档' AND Enabled = 1")
+        archive_status_id_row = cur.fetchone()
+        if not archive_status_id_row:
+            conn.close()
+            raise HTTPException(status_code=400, detail="未找到“已归档”状态配置")
+        archive_status_id = archive_status_id_row[0]
+        placeholders = ','.join(['?'] * len(selected_ids))
+        cur.execute(f"UPDATE questions SET status_id = ? WHERE id IN ({placeholders})",
+                    tuple([archive_status_id] + selected_ids))
+        conn.commit()
+        conn.close()
+        return {"archived": len(selected_ids)}
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+def _xlsx_bytes(dataframe, sheet_name, kp_col_name=None):
+    """Original used pandas + xlsxwriter (with a wrap format on the KP
+    column); falls back to openpyxl when xlsxwriter is unavailable."""
+    buf = io.BytesIO()
+    try:
+        import xlsxwriter
+        with __import__("pandas").ExcelWriter(buf, engine='xlsxwriter') as writer:
+            dataframe.to_excel(writer, index=False, sheet_name=sheet_name)
+            workbook, worksheet = writer.book, writer.sheets[sheet_name]
+            wrap_format = workbook.add_format({'text_wrap': True, 'valign': 'top'})
+            if kp_col_name and kp_col_name in dataframe.columns:
+                try:
+                    kp_idx = list(dataframe.columns).index(kp_col_name)
+                    worksheet.set_column(kp_idx, kp_idx, 40, wrap_format)
+                except ValueError:
+                    pass
+    except ImportError:
+        with __import__("pandas").ExcelWriter(buf, engine='openpyxl') as writer:
+            dataframe.to_excel(writer, index=False, sheet_name=sheet_name)
+    buf.seek(0)
+    return buf
+
+
+QUESTION_TABLE_COLUMNS = ["科目", "试题ID", "掌握状态", "题目类型", "题目知识点", "答题时间",
+                          "录入时间", "组卷次数", "正确次数", "错误次数", "部分正确次数", "最晚正确时间", "所属试卷ID"]
+
+
+def _question_table_rows(req: QuestionSearchReq):
+    req.mode = "table"
+    sql, params = build_question_search_sql(req)
+    conn = get_db()
+    cur = conn.cursor()
+    cur.execute(sql, params)
+    rows = cur.fetchall()
+    conn.close()
+    st_map = {'right': '正确', 'wrong': '错误', 'half_right': '部分正确'}
+    data = []
+    for row in rows:
+        qid = row["id"]
+        conn = get_db()
+        cur = conn.cursor()
+        cur.execute("SELECT paper_id FROM paper_questions WHERE question_id = ?", (qid,))
+        p_ids = [str(r[0]) for r in cur.fetchall()]
+        conn.close()
+        data.append([
+            row["Subject"], qid, st_map.get(row["latest_status"], '未完成'),
+            row["question_type"], row["knowledge_points_desc"], row["answer_time"],
+            row["input_time"], row["paper_count"], row["right_times"],
+            row["wrong_times"], row["half_right_times"], row["last_right_time"],
+            ",".join(p_ids)
+        ])
+    return data
+
+
+@app.post("/api/questions/export-excel")
+def question_export_excel(req: QuestionSearchReq):
+    import pandas as pd
+    data = _question_table_rows(req)
+    if not data:
+        raise HTTPException(status_code=400, detail="当前列表没有数据可以导出")
+    df = pd.DataFrame(data, columns=QUESTION_TABLE_COLUMNS)
+    buf = _xlsx_bytes(df, '筛选结果', kp_col_name="题目知识点")
+    filename = f"题目筛选结果_{datetime.datetime.now().strftime('%Y%m%d_%H%M')}.xlsx"
+    return StreamingResponse(
+        buf,
+        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+        headers={"Content-Disposition": f"attachment; filename*=UTF-8''{__import__('urllib.parse', fromlist=['quote']).quote(filename)}"})
+
+
+@app.get("/api/questions/validate-ids")
+def questions_validate_ids(ids: str):
+    """PapersCreateByIdView.generate_paper — ID validation part"""
+    id_strings = re.split(r'[,,\s\n]+', ids.strip())
+    selected_ids = []
+    for id_str in id_strings:
+        if id_str:
+            try:
+                selected_ids.append(int(id_str))
+            except ValueError:
+                raise HTTPException(status_code=400, detail=f"ID '{id_str}' 无效,请确保所有ID都是数字。")
+    if not selected_ids:
+        raise HTTPException(status_code=400, detail="未检测到有效试题ID,请重新输入。")
+
+    conn = get_db()
+    cur = conn.cursor()
+    placeholders = ','.join(['?'] * len(selected_ids))
+    cur.execute(f"SELECT id FROM questions WHERE id IN ({placeholders})", tuple(selected_ids))
+    valid_ids = [row[0] for row in cur.fetchall()]
+    conn.close()
+
+    if not valid_ids:
+        raise HTTPException(status_code=400, detail="输入的试题ID均不存在于数据库中,请检查。")
+
+    missing_ids = sorted(set(selected_ids) - set(valid_ids))
+    return {"valid_ids": valid_ids, "missing_ids": missing_ids}
+
+
+# ============================================================
+# 题目详情 (QuestionDetailView)
+# ============================================================
+
+@app.get("/api/questions/{question_id}")
+def question_detail(question_id: int):
+    conn = get_db()
+    cur = conn.cursor()
+    sql = """
+        SELECT q.*, s.Subject, qs.source_description, qst.StatusName, ec.Cause, t.term_name, st.stage_name, media_path,
+        (SELECT GROUP_CONCAT(kp.content, '、') FROM question_kp_relation qkr
+         JOIN knowledge_point kp ON qkr.kp_id = kp.id WHERE qkr.question_id = q.id) AS knowledge_points_desc
+        FROM questions q
+        LEFT JOIN Subject s ON q.subject_id = s.ID
+        LEFT JOIN question_source qs ON q.source_id = qs.id
+        LEFT JOIN Question_Status qst ON q.status_id = qst.ID
+        LEFT JOIN Error_Cause ec ON q.error_cause_id = ec.ID
+        LEFT JOIN term t ON q.term_id = t.ID
+        LEFT JOIN stage st ON q.stage_id = st.ID
+        WHERE q.id = ?
+    """
+    cur.execute(sql, (question_id,))
+    row = cur.fetchone()
+    conn.close()
+    if row is None:
+        raise HTTPException(status_code=404, detail="题目不存在")
+    data = row_to_dict(row)
+    # 编辑模式下科目下拉选项
+    conn = get_db()
+    cur = conn.cursor()
+    cur.execute("SELECT Subject FROM Subject WHERE enabled = 1")
+    data["subject_options"] = [r[0] for r in cur.fetchall()]
+    conn.close()
+    return data
+
+
+class QuestionUpdateReq(BaseModel):
+    subject: str
+    question_type: str
+    answer_time: str
+    is_subjective: bool
+    content_raw: str
+    analysis_raw: str
+
+
+@app.put("/api/questions/{question_id}")
+def question_update(question_id: int, req: QuestionUpdateReq):
+    """QuestionDetailView.save_changes"""
+    try:
+        conn = get_db()
+        cur = conn.cursor()
+        cur.execute("SELECT ID FROM Subject WHERE Subject = ?", (req.subject,))
+        res = cur.fetchone()
+        if not res:
+            conn.close()
+            raise HTTPException(status_code=400, detail=f"科目 {req.subject} 不存在")
+        sid = res[0]
+
+        counters = {}
+        stem, s_path = media_utils.process_content_for_save(conn, MEDIA_BASE_DIR, req.content_raw, question_id, counters)
+        ana, a_path = media_utils.process_content_for_save(conn, MEDIA_BASE_DIR, req.analysis_raw, question_id, counters)
+
+        cur.execute("""
+            UPDATE questions SET subject_id=?, question_type=?, answer_time=?, is_subjective=?, content=?, analysis=?, media_path=?
+            WHERE id=?
+        """, (sid, req.question_type, req.answer_time, 1 if req.is_subjective else 0,
+              stem, ana, s_path or a_path, question_id))
+        conn.commit()
+        conn.close()
+        return {"ok": True}
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+# ============================================================
+# 文件导入 (FileImportView + JsonAddToQuestionView + manuallyadd)
+# ============================================================
+
+@app.get("/api/import/options")
+def import_options():
+    """FileImportView.load_all_db_options — defaults included."""
+    conn = get_db()
+    cur = conn.cursor()
+
+    def load(table, name_col, default_col, enabled_col):
+        if table in ("term", "stage", "question_origin"):
+            cur.execute(f"SELECT {name_col}, {default_col} FROM {table} WHERE enabled = 1 ORDER BY ID")
+            rows = cur.fetchall()
+            names = [r[0] for r in rows]
+            default = next((r[0] for r in rows if r[1] == 1), None)
+            return names, (default or (names[0] if names else ""))
+        else:  # Subject / question_source
+            cur.execute(f"SELECT {name_col} FROM {table} WHERE {enabled_col} = 1 ORDER BY ID")
+            names = [r[0] for r in cur.fetchall()]
+            return names, (names[0] if names else "")
+
+    subject, subject_d = load("Subject", "Subject", None, "enabled")
+    source, source_d = load("question_source", "source_description", None, "is_enabled")
+    term, term_d = load("term", "term_name", "is_default", None)
+    stage, stage_d = load("stage", "stage_name", "is_default", None)
+    origin, origin_d = load("question_origin", "origin_name", "is_default", None)
+    conn.close()
+
+    return {
+        "subject": {"options": subject, "default": subject_d},
+        "source": {"options": source, "default": source_d},
+        "term": {"options": term, "default": term_d},
+        "stage": {"options": stage, "default": stage_d},
+        "origin": {"options": origin, "default": origin_d},
+        "type": {"options": ["错题", "推题", "补充提高"], "default": "错题"},
+    }
+
+
+@app.post("/api/import/parse")
+async def import_parse(
+    file: UploadFile = File(...),
+    subject: str = Form(...),
+    source: str = Form(...),
+    q_type: str = Form(...),
+    term: str = Form(...),
+    stage: str = Form(...),
+    origin: str = Form(...),
+):
+    """FileImportView.run_parsing"""
+    if not all([subject, source, q_type, term, stage, origin]):
+        raise HTTPException(status_code=400, detail="请确保所有导入参数都已选择!")
+
+    meta_data = {
+        "subject": subject,
+        "source": source,
+        "type": q_type,
+        "import_time": now_str(),
+        "term": term,
+        "stage": stage,
+        "origin": origin,
+    }
+
+    # save upload to temp file
+    suffix = os.path.splitext(file.filename or "")[1] or ".docx"
+    with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
+        content = await file.read()
+        tmp.write(content)
+        tmp_path = tmp.name
+
+    log_lines = []
+
+    def log_cb(msg):
+        log_lines.append(f"[{datetime.datetime.now().strftime('%H:%M:%S')}] {msg}")
+
+    try:
+        src = meta_data.get("source", "")
+        parser = None
+        if src == "智学网":
+            parser = ZhixuewangParser(tmp_path, meta_data, log_callback=log_cb, media_base_dir=MEDIA_BASE_DIR)
+        elif src == "组卷网":
+            parser = ZujuanwangParser(tmp_path, meta_data, log_callback=log_cb, media_base_dir=MEDIA_BASE_DIR)
+        elif src.lower() == "自组word":
+            parser = ZizuWordParser(tmp_path, meta_data, log_callback=log_cb, media_base_dir=MEDIA_BASE_DIR)
+        else:
+            log_cb(f"未知的题目来源:{src}")
+            return {"log": log_lines, "questions": None, "error": f"未知的题目来源:{src}"}
+
+        questions_list, error = parser.parse()
+
+        if error:
+            log_cb(f"解析失败: {error}")
+            return {"log": log_lines, "questions": None, "error": error}
+        if not questions_list:
+            log_cb("解析完成,但未发现题目数据。")
+            return {"log": log_lines, "questions": None, "error": None}
+
+        log_cb(f"解析成功!共提取 {len(questions_list)} 道题目。")
+        log_cb("\n--- JSON 预览数据 ---")
+        import json as _json
+        log_cb(_json.dumps(questions_list, indent=4, ensure_ascii=False))
+        log_cb("--- 预览结束 ---\n")
+        return {"log": log_lines, "questions": questions_list, "error": None}
+    except Exception as e:
+        log_cb(f"解析过程中发生未预期错误: {e}")
+        return {"log": log_lines, "questions": None, "error": str(e)}
+    finally:
+        try:
+            os.unlink(tmp_path)
+        except Exception:
+            pass
+
+
+@app.post("/api/import/upload-image")
+async def import_upload_image(file: UploadFile = File(...)):
+    """Web equivalent of clipboard paste / local image pick: the uploaded
+    image is stored in Media/_clipboard_temp and its path is returned to be
+    inserted into the text as a [[IMG:...]] token."""
+    clipboard_temp_dir = media_utils.ensure_media_base(MEDIA_BASE_DIR)
+    data = await file.read()
+    if not data:
+        raise HTTPException(status_code=400, detail="空的图片文件")
+    path = media_utils.save_clipboard_upload(clipboard_temp_dir, file.filename or "image.png", data)
+    return {"path": path}
+
+
+class ManualAddReq(BaseModel):
+    """manuallyadd._validate_and_build_question_json"""
+    category: str
+    q_type: str
+    kp_text: str
+    answer_time: str
+    stem_raw: str
+    analysis_raw: str
+    question_no: int
+    session_timestamp: str
+    meta: Dict[str, Any] = {}
+    force: bool = False  # 用户确认按原样保存非常规日期格式
+
+
+@app.post("/api/import/manual-add")
+def import_manual_add(req: ManualAddReq):
+    # 校验(与原版一致)
+    if not req.category:
+        raise HTTPException(status_code=400, detail="请先选择题目分类(客观题或主观题)。")
+    if not req.kp_text:
+        raise HTTPException(status_code=400, detail="题目知识点为必填项。")
+    if not req.stem_raw:
+        raise HTTPException(status_code=400, detail="题干内容不能为空。")
+    if not req.analysis_raw:
+        raise HTTPException(status_code=400, detail="答案解析不能为空。")
+
+    answer_time_str = req.answer_time.strip()
+    if not req.force and answer_time_str and len(answer_time_str) == 10:
+        try:
+            datetime.datetime.strptime(answer_time_str, "%Y-%m-%d")
+        except Exception:
+            raise HTTPException(status_code=422, detail="答题时间格式不是标准日期(YYYY-MM-DD),仍要按原样保存吗?")
+
+    counters = {}
+    stem_processed, stem_dir = media_utils.collect_images_for_json(
+        MEDIA_BASE_DIR, req.stem_raw, req.session_timestamp, req.question_no, counters)
+    analysis_processed, analysis_dir = media_utils.collect_images_for_json(
+        MEDIA_BASE_DIR, req.analysis_raw, req.session_timestamp, req.question_no, counters)
+
+    image_dir = stem_dir or analysis_dir
+
+    meta = req.meta or {}
+    q_json = {
+        "题号": str(req.question_no),
+        "科目": meta.get("subject", ""),
+        "题目来源": meta.get("source", ""),
+        "题目类型": req.q_type or "错题",
+        "输入时间": now_str(),
+        "答题时间": answer_time_str,
+        "题目知识点": req.kp_text,
+        "题目分类": req.category,
+        "题干内容": stem_processed,
+        "答案解析": analysis_processed,
+        "学期": meta.get("term", ""),
+        "阶段": meta.get("stage", ""),
+        "题目出处": "手动创建",
+        "图片目录": image_dir or ""
+    }
+    return q_json
+
+
+class ImportSaveReq(BaseModel):
+    questions: List[Dict[str, Any]]
+    meta: Dict[str, Any] = {}
+
+
+@app.post("/api/import/save")
+def import_save(req: ImportSaveReq):
+    """JsonAddToQuestionView.save_to_db"""
+    if not req.questions:
+        raise HTTPException(status_code=400, detail="没有题目可以入库。")
+
+    # 知识点缺失检查
+    missing_kp = []
+    for item in req.questions:
+        if not (item.get("题目知识点") or "").strip():
+            missing_kp.append(str(item.get("题号", "")))
+    if missing_kp:
+        raise HTTPException(status_code=400, detail=(
+            f"以下题目缺少知识点信息,无法保存整组题目:\n题号: {', '.join(missing_kp)}\n请补充完整后再保存。"))
+
+    conn = None
+    try:
+        conn = get_db()
+        cur = conn.cursor()
+        cur.execute("BEGIN")
+
+        def get_id(table, col, val, en_col="enabled"):
+            if not val:
+                return None
+            cur.execute(f"SELECT ID FROM {table} WHERE {col} = ?", (val,))
+            res = cur.fetchone()
+            if res:
+                return res[0]
+            if table == "question_source":
+                cur.execute(f"INSERT INTO {table} ({col}, is_enabled) VALUES (?, 1)", (val,))
+            else:
+                cur.execute(f"INSERT INTO {table} ({col}, {en_col}) VALUES (?, 1)", (val,))
+            return cur.lastrowid
+
+        for item in req.questions:
+            meta = req.meta
+            effective_subject = meta.get("subject") if meta else item.get("科目")
+            effective_source = meta.get("source") if meta else item.get("题目来源")
+            effective_type = meta.get("type") if meta else item.get("题目类型", "错题")
+            effective_term = meta.get("term") if meta else item.get("学期")
+            effective_stage = meta.get("stage") if meta else item.get("阶段")
+            effective_origin = meta.get("origin") if meta else item.get("题目出处")
+            effective_import_time = meta.get("import_time") if meta else item.get(
+                "输入时间", datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
+
+            sid = get_id("Subject", "Subject", effective_subject)
+            src_id = get_id("question_source", "source_description", effective_source)
+            tid = get_id("term", "term_name", effective_term)
+            stgid = get_id("stage", "stage_name", effective_stage)
+            orig_id = get_id("question_origin", "origin_name", effective_origin)
+
+            sql = '''
+                INSERT INTO questions (
+                    subject_id, status_id, error_cause_id, source_id, question_type,
+                    content, analysis, score, input_time, answer_time,
+                    has_subquestions, subquestion_count, is_subjective,
+                    term_id, stage_id, origin_id, media_path
+                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            '''
+            cur.execute(sql, (
+                sid, None, None, src_id, effective_type,
+                item.get("题干内容"), item.get("答案解析"), item.get("分值", 0),
+                effective_import_time,
+                item.get("答题时间"),
+                1 if item.get("是否有小题") else 0, item.get("小题个数", 0),
+                1 if item.get("题目分类") == "主观题" else 0,
+                tid, stgid, orig_id, item.get("图片目录")
+            ))
+
+            new_qid = cur.lastrowid
+
+            kp_text = (item.get("题目知识点") or "").strip()
+            if kp_text:
+                kp_names = [n.strip() for n in re.split(r'[,,、\s]+', str(kp_text)) if n.strip()]
+                for name in kp_names:
+                    cur.execute("SELECT id FROM knowledge_point WHERE content = ?", (name,))
+                    kp_res = cur.fetchone()
+                    if kp_res:
+                        kp_id = kp_res[0]
+                    else:
+                        cur.execute("INSERT INTO knowledge_point (content, subject_id, is_enabled) VALUES (?, ?, 1)", (name, sid))
+                        kp_id = cur.lastrowid
+                    cur.execute("INSERT OR IGNORE INTO question_kp_relation (question_id, kp_id) VALUES (?, ?)", (new_qid, kp_id))
+
+        conn.commit()
+        conn.close()
+        return {"inserted": len(req.questions)}
+    except Exception as e:
+        if conn:
+            conn.rollback()
+        raise HTTPException(status_code=500, detail=f"入库失败 错误详情: {e}")
+
+
+# ============================================================
+# 试卷管理 (PapersListView / PapersPreviewView / PapersResultView)
+# ============================================================
+
+PAPER_SORT_WHITELIST = {"p.id", "p.paper_name", "p.subject", "question_count", "p.create_time", "ps.status_name"}
+
+
+@app.get("/api/papers/list")
+def papers_list(status: str = "未完成", paper_id: str = "", sort_col: str = "p.create_time", sort_dir: str = "DESC"):
+    """PapersListView.load_papers_data"""
+    conn = get_db()
+    cur = conn.cursor()
+
+    sql = """
+        SELECT p.id, p.paper_name, p.subject, ps.status_name,
+        (SELECT COUNT(*) FROM paper_questions WHERE paper_id = p.id) AS question_count,
+        p.create_time
+        FROM papers p
+        LEFT JOIN paper_status ps ON p.status_id = ps.id
+        WHERE p.enabled = 1
+    """
+    params = []
+
+    paper_id_input = (paper_id or "").strip()
+    if paper_id_input:
+        ids = []
+        for s_id in paper_id_input.replace(',', ',').split(','):
+            s_id = s_id.strip()
+            if s_id.isdigit():
+                ids.append(int(s_id))
+        if ids:
+            placeholders = ','.join(['?'] * len(ids))
+            sql += f" AND p.id IN ({placeholders})"
+            params.extend(ids)
+
+    status_text_to_id_map = {"未完成": 1, "已完成": 2, "部分完成": 3, "归档": 4}
+    if not paper_id_input and status != "全部":
+        target_status_id = status_text_to_id_map.get(status)
+        if target_status_id is not None:
+            sql += " AND p.status_id = ?"
+            params.append(target_status_id)
+
+    if sort_col in PAPER_SORT_WHITELIST and sort_dir in ("ASC", "DESC"):
+        sql += f" ORDER BY {sort_col} {sort_dir}"
+
+    cur.execute(sql, params)
+    rows = cur.fetchall()
+    conn.close()
+
+    return {"rows": [{
+        "ID": r["id"],
+        "试卷名称": r["paper_name"],
+        "科目": r["subject"] or "-",
+        "题数": r["question_count"],
+        "创建时间": (r["create_time"][:19] if r["create_time"] else ""),
+        "状态": r["status_name"] or "-"
+    } for r in rows]}
+
+
+class PaperActionReq(BaseModel):
+    status: str = "未完成"
+    paper_id: str = ""
+    sort_col: str = "p.create_time"
+    sort_dir: str = "DESC"
+
+
+@app.post("/api/papers/list/export-excel")
+def papers_export_excel(req: PaperActionReq):
+    import pandas as pd
+    data = papers_list(status=req.status, paper_id=req.paper_id, sort_col=req.sort_col, sort_dir=req.sort_dir)
+    rows = data["rows"]
+    if not rows:
+        raise HTTPException(status_code=400, detail="当前列表没有数据可以导出")
+    df = pd.DataFrame(rows, columns=["ID", "试卷名称", "科目", "题数", "创建时间", "状态"])
+    buf = _xlsx_bytes(df, '试卷列表')
+    filename = f"试卷列表_{datetime.datetime.now().strftime('%Y%m%d_%H%M')}.xlsx"
+    from urllib.parse import quote
+    return StreamingResponse(
+        buf,
+        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+        headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"})
+
+
+@app.get("/api/papers/{paper_id}/basic")
+def paper_basic(paper_id: int):
+    conn = get_db()
+    cur = conn.cursor()
+    cur.execute("SELECT paper_name FROM papers WHERE id = ?", (paper_id,))
+    row = cur.fetchone()
+    paper_name = row[0] if row else f"试卷{paper_id}"
+    cur.execute("SELECT question_id FROM paper_questions WHERE paper_id = ? ORDER BY question_id", (paper_id,))
+    qids = [r[0] for r in cur.fetchall()]
+    conn.close()
+    return {"paper_name": paper_name, "question_ids": qids}
+
+
+@app.delete("/api/papers/{paper_id}")
+def paper_delete(paper_id: int):
+    """PapersListView.delete_paper"""
+    conn = get_db()
+    cur = conn.cursor()
+    cur.execute("""
+        SELECT p.paper_name, (SELECT COUNT(*) FROM paper_questions WHERE paper_id = p.id) as q_count
+        FROM papers p WHERE p.id = ?
+    """, (paper_id,))
+    res = cur.fetchone()
+    paper_name = res[0] if res else "未知"
+    q_count = res[1] if res else 0
+
+    cur.execute("SELECT question_id, result_type FROM paper_questions WHERE paper_id = ?", (paper_id,))
+    results = cur.fetchall()
+    for q_id, res_type in results:
+        cur.execute("UPDATE questions SET paper_count = MAX(0, paper_count - 1) WHERE id = ?", (q_id,))
+        if res_type is not None:
+            if res_type == 1:
+                cur.execute("UPDATE questions SET right_times = MAX(0, right_times - 1) WHERE id = ?", (q_id,))
+            elif res_type == 0:
+                cur.execute("UPDATE questions SET wrong_times = MAX(0, wrong_times - 1) WHERE id = ?", (q_id,))
+            elif res_type == 2:
+                cur.execute("UPDATE questions SET half_right_times = MAX(0, half_right_times - 1) WHERE id = ?", (q_id,))
+            cur.execute("UPDATE questions SET review_times = MAX(0, review_times - 1) WHERE id = ?", (q_id,))
+
+    cur.execute("DELETE FROM paper_questions WHERE paper_id = ?", (paper_id,))
+    cur.execute("DELETE FROM papers WHERE id = ?", (paper_id,))
+    conn.commit()
+    conn.close()
+    return {"deleted": paper_id, "paper_name": paper_name, "question_count": q_count}
+
+
+@app.get("/api/papers/{paper_id}/download")
+def paper_download(paper_id: int):
+    """PapersListView.download_paper"""
+    conn = get_db()
+    cur = conn.cursor()
+    cur.execute("SELECT question_id FROM paper_questions WHERE paper_id = ? ORDER BY question_id", (paper_id,))
+    qids = [r[0] for r in cur.fetchall()]
+    cur.execute("SELECT paper_name FROM papers WHERE id = ?", (paper_id,))
+    row = cur.fetchone()
+    paper_name = row[0] if row else f"试卷{paper_id}"
+    conn.close()
+
+    if not qids:
+        raise HTTPException(status_code=400, detail=f"试卷 {paper_id} 暂无题目,无法下载。")
+
+    try:
+        # generate_docx 与原版一致:写入 BASE_DIR/temp_preview.docx(原函数无返回值)
+        create_word.generate_docx(
+            qids, BASE_DIR, get_db,
+            title=paper_name,
+            paper_id_for_header=paper_id
+        )
+        out_path = os.path.join(BASE_DIR, "temp_preview.docx")
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"下载失败: {e}")
+
+    default_filename = f"{paper_name}_{paper_id}.docx".replace("/", "_").replace("\\", "_")
+    from urllib.parse import quote
+    return FileResponse(
+        out_path,
+        filename=default_filename,
+        headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(default_filename)}"})
+
+
+# ---- 试卷预览 (PapersPreviewView) ----
+
+class PaperPreviewInfoReq(BaseModel):
+    selected_ids: Optional[List[int]] = None
+    paper_id: Optional[int] = None
+
+
+@app.post("/api/papers/preview-info")
+def papers_preview_info(req: PaperPreviewInfoReq):
+    """init_data_and_render: title + selected ids"""
+    if req.paper_id is not None:
+        basic = paper_basic(req.paper_id)
+        return {"paper_id": req.paper_id, "title": basic["paper_name"],
+                "selected_ids": basic["question_ids"]}
+    return {"paper_id": None, "title": _generate_default_title(req.selected_ids or []),
+            "selected_ids": req.selected_ids or []}
+
+
+def _generate_default_title(selected_ids):
+    """PapersPreviewView.generate_default_title"""
+    subjects, answer_times = set(), []
+    try:
+        conn = get_db()
+        cur = conn.cursor()
+
+        if not selected_ids:
+            conn.close()
+            return f"综合练习 {datetime.datetime.now().strftime('%Y%m%d')}"
+
+        placeholders = ','.join(['?'] * len(selected_ids))
+        query = f"""
+        SELECT s.Subject, q.answer_time
+        FROM questions q
+        LEFT JOIN Subject s ON q.subject_id = s.ID
+        WHERE q.id IN ({placeholders})
+        """
+        cur.execute(query, tuple(sorted(selected_ids)))
+        for row in cur.fetchall():
+            if row["Subject"]:
+                subjects.add(row["Subject"])
+            if row["answer_time"]:
+                try:
+                    dt_obj = datetime.datetime.strptime(row["answer_time"], "%Y-%m-%d %H:%M:%S")
+                except ValueError:
+                    try:
+                        dt_obj = datetime.datetime.strptime(row["answer_time"], "%Y-%m-%d")
+                    except ValueError:
+                        dt_obj = None
+                if dt_obj:
+                    answer_times.append(dt_obj)
+        conn.close()
+
+        if not subjects:
+            return f"综合练习 {datetime.datetime.now().strftime('%Y%m%d')}"
+
+        if answer_times:
+            min_date = min(answer_times).strftime("%Y-%m-%d")
+            max_date = max(answer_times).strftime("%Y-%m-%d")
+            date_range = f"{min_date}至{max_date}" if min_date != max_date else min_date
+        else:
+            date_range = datetime.datetime.now().strftime("%Y-%m-%d")
+
+        subject_str = ' + '.join(sorted(subjects))
+        return f"{subject_str} {date_range}"
+    except Exception:
+        return "新试卷"
+
+
+class PaperDocxReq(BaseModel):
+    selected_ids: List[int]
+    title: Optional[str] = None
+    paper_id: Optional[int] = None
+
+
+@app.post("/api/papers/preview-docx")
+def papers_preview_docx(req: PaperDocxReq):
+    """PapersPreviewView.render_docx_preview_placeholder"""
+    if not req.selected_ids:
+        raise HTTPException(status_code=400, detail="未选择题目,无法预览。")
+    try:
+        out_path = os.path.join(BASE_DIR, "temp_preview.docx")
+        create_word.generate_docx(
+            req.selected_ids, BASE_DIR, get_db,
+            title=req.title,
+            paper_id_for_header=req.paper_id
+        )
+        return {"ok": True, "path": out_path, "download_url": "/StudyBase/api/papers/temp-file?name=preview"}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"生成预览失败: {e}")
+
+
+@app.post("/api/papers/review-docx")
+def papers_review_docx(req: PaperDocxReq):
+    """PapersPreviewView.generate_review_paper"""
+    if not req.selected_ids:
+        raise HTTPException(status_code=400, detail="未选择题目,无法生成复习卷。")
+    try:
+        out_path = create_word.generate_review_docx(
+            req.selected_ids, BASE_DIR, get_db,
+            title=req.title,
+            paper_id_for_header=req.paper_id
+        )
+        return {"ok": True, "path": out_path, "download_url": "/StudyBase/api/papers/temp-file?name=review"}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"生成复习卷失败: {e}")
+
+
+@app.get("/api/papers/temp-file")
+def papers_temp_file(name: str):
+    """打开文档 / 打开复习卷"""
+    if name == "preview":
+        path = os.path.join(BASE_DIR, "temp_preview.docx")
+    elif name == "review":
+        path = os.path.join(BASE_DIR, "temp_review_preview.docx")
+    else:
+        raise HTTPException(status_code=400, detail="未知文件")
+    if not os.path.exists(path):
+        raise HTTPException(status_code=404, detail="文档未找到,请确认已生成预览。")
+    fname = "temp_preview.docx" if name == "preview" else "temp_review_preview.docx"
+    return FileResponse(path, filename=fname)
+
+
+class PaperSaveReq(BaseModel):
+    selected_ids: List[int]
+    title: str
+
+
+@app.post("/api/papers/save")
+def papers_save(req: PaperSaveReq):
+    """PapersPreviewView.on_save_and_generate"""
+    if not req.selected_ids:
+        raise HTTPException(status_code=400, detail="未选择题目,无法生成试卷。")
+    try:
+        conn = get_db()
+        cur = conn.cursor()
+        paper_name = req.title
+        create_time = now_str()
+
+        subjects = set()
+        for qid in req.selected_ids:
+            cur.execute("""
+                SELECT s.Subject FROM questions q
+                JOIN Subject s ON q.subject_id = s.ID
+                WHERE q.id = ?
+            """, (qid,))
+            res = cur.fetchone()
+            if res:
+                subjects.add(res[0])
+        subjects_str = ', '.join(sorted(list(subjects))) if subjects else '未指定'
+
+        cur.execute("""
+            INSERT INTO papers (paper_name, create_time, status_id, enabled, subject)
+            VALUES (?, ?, ?, ?, ?)
+        """, (paper_name, create_time, 1, 1, subjects_str))
+
+        paper_id = cur.lastrowid
+
+        for qid in req.selected_ids:
+            cur.execute("SELECT subject_id FROM questions WHERE id = ?", (qid,))
+            subject_id_row = cur.fetchone()
+            subject_id = subject_id_row[0] if subject_id_row else None
+
+            cur.execute("""
+                INSERT INTO paper_questions (paper_id, question_id, subject_id)
+                VALUES (?, ?, ?)
+            """, (paper_id, qid, subject_id))
+
+            cur.execute("UPDATE questions SET paper_count = paper_count + 1 WHERE id = ?", (qid,))
+
+        conn.commit()
+        conn.close()
+        return {"paper_id": paper_id}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"数据库操作失败: {e}")
+
+
+# ---- 试卷结果反馈 (PapersResultView) ----
+
+@app.get("/api/papers/{paper_id}/result")
+def papers_result(paper_id: int):
+    conn = get_db()
+    cur = conn.cursor()
+    cur.execute("""
+        SELECT q.id, s.Subject, pq.result_type, pq.error_cause_id
+        FROM paper_questions pq
+        JOIN questions q ON pq.question_id = q.id
+        LEFT JOIN Subject s ON q.subject_id = s.ID
+        WHERE pq.paper_id = ?
+    """, (paper_id,))
+    questions = []
+    for question_id, subject, result_type, error_cause_id in cur.fetchall():
+        questions.append({
+            "question_id": question_id, "subject": subject,
+            "result_type": result_type, "error_cause_id": error_cause_id})
+
+    cur.execute("SELECT Cause FROM Error_Cause WHERE Enabled = 1")
+    causes = [r[0] for r in cur.fetchall()]
+
+    # 已判定题目的错误原因文本
+    for q in questions:
+        if q["error_cause_id"]:
+            cur.execute("SELECT Cause FROM Error_Cause WHERE ID = ?", (q["error_cause_id"],))
+            r = cur.fetchone()
+            q["error_cause_text"] = r[0] if r else "无"
+        else:
+            q["error_cause_text"] = "无"
+    conn.close()
+
+    has_pending = any(q["result_type"] == 0 for q in questions)
+    return {"questions": questions, "causes": causes, "has_pending": has_pending}
+
+
+class ResultConfirmReq(BaseModel):
+    results: List[Dict[str, Any]]  # [{question_id, result, error_cause, partial_desc}]
+
+
+@app.post("/api/papers/{paper_id}/result/confirm")
+def papers_result_confirm(paper_id: int, req: ResultConfirmReq):
+    """PapersResultView.confirm_results"""
+    try:
+        conn = get_db()
+        cur = conn.cursor()
+
+        for q_state in req.results:
+            question_id = q_state["question_id"]
+            current_val = q_state.get("result", "无")
+            error_var = q_state.get("error_cause", "无")
+            error_sub_description = q_state.get("partial_desc", "") if current_val == "部分错误" else ""
+
+            updated_pq_result_type = 0
+
+            if current_val == "正确":
+                updated_pq_result_type = 1
+                cur.execute("""
+                    UPDATE questions
+                    SET review_times = COALESCE(review_times, 0) + 1,
+                        right_times = COALESCE(right_times, 0) + 1,
+                        last_right_time = ?,
+                        latest_status = 'right',
+                        half_right_description = NULL
+                    WHERE id = ?
+                """, (now_str(), question_id))
+            elif current_val == "错误":
+                updated_pq_result_type = 3
+                cur.execute("""
+                    UPDATE questions
+                    SET review_times = COALESCE(review_times, 0) + 1,
+                        wrong_times = COALESCE(wrong_times, 0) + 1,
+                        last_wrong_time = ?,
+                        latest_status = 'wrong',
+                        half_right_description = NULL
+                    WHERE id = ?
+                """, (now_str(), question_id))
+            elif current_val == "部分错误":
+                updated_pq_result_type = 2
+                cur.execute("""
+                    UPDATE questions
+                    SET review_times = COALESCE(review_times, 0) + 1,
+                        half_right_times = COALESCE(half_right_times, 0) + 1,
+                        last_half_right_time = ?,
+                        latest_status = 'half_right',
+                        half_right_description = ?
+                    WHERE id = ?
+                """, (now_str(), error_sub_description, question_id))
+            elif current_val == "未完成":
+                updated_pq_result_type = 0
+
+            error_cause_id = None
+            if error_var != "无":
+                cur.execute("SELECT ID FROM Error_Cause WHERE Cause = ?", (error_var,))
+                row = cur.fetchone()
+                if row is not None:
+                    error_cause_id = row[0]
+
+            cur.execute("""
+                UPDATE paper_questions
+                SET error_cause_id = ?, result_type = ?, error_sub_description = ?, record_time = ?
+                WHERE paper_id = ? AND question_id = ?
+            """, (error_cause_id, updated_pq_result_type, error_sub_description, now_str(), paper_id, question_id))
+
+        # 重新评估试卷状态
+        cur.execute("SELECT result_type FROM paper_questions WHERE paper_id = ?", (paper_id,))
+        all_question_results = cur.fetchall()
+
+        total_questions_in_paper = len(all_question_results)
+        num_pending_questions = sum(1 for row in all_question_results if row[0] == 0)
+
+        new_paper_status_id = 1
+        if num_pending_questions == 0:
+            new_paper_status_id = 2
+        elif num_pending_questions < total_questions_in_paper:
+            new_paper_status_id = 3
+
+        cur.execute("UPDATE papers SET status_id = ? WHERE id = ?", (new_paper_status_id, paper_id))
+
+        conn.commit()
+        conn.close()
+        return {"ok": True}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"结果保存失败: {e}")
+
+
+@app.post("/api/papers/{paper_id}/archive")
+def papers_archive(paper_id: int):
+    """PapersResultView.archive_paper"""
+    try:
+        conn = get_db()
+        cur = conn.cursor()
+        cur.execute("UPDATE papers SET status_id = 4 WHERE id = ?", (paper_id,))
+        conn.commit()
+        conn.close()
+        return {"ok": True}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"操作失败: {e}")
+
+
+# ============================================================
+# 分析 (章节分析 / 知识点分析 / 遗留分析)
+# ============================================================
+
+class ChapterReq(BaseModel):
+    subject: str = "全部"
+    correct_count: str = ""
+    not_correct_count: str = ""
+    chapter_desc: str = ""
+    sort_column: str = "not_correct_questions"
+    sort_direction: str = "DESC"
+
+
+@app.post("/api/analysis/chapters")
+def analysis_chapters(req: ChapterReq):
+    """SubjectChapterListView.load_chapter_data"""
+    conn = get_db()
+    cur = conn.cursor()
+    inner_where = " WHERE 1=1"
+    params = []
+    if req.subject and req.subject != "全部":
+        inner_where += " AND s.Subject = ?"
+        params.append(req.subject)
+    chapter_desc_filter = (req.chapter_desc or "").strip()
+    if chapter_desc_filter:
+        for keyword in chapter_desc_filter.split():
+            inner_where += " AND c.chapter_desc LIKE ?"
+            params.append(f"%{keyword}%")
+
+    sql_query = f"""
+    WITH ChapterCounts AS (
+        SELECT
+            c.id AS chapter_id,
+            s.Subject AS SubjectName,
+            c.volume_name AS VolumeName,
+            c.chapter_desc AS ChapterDesc,
+            (
+                SELECT COUNT(DISTINCT q.id)
+                FROM knowledge_point kp
+                JOIN question_kp_relation qkr ON kp.id = qkr.kp_id
+                JOIN questions q ON qkr.question_id = q.id
+                WHERE kp.chapter_id = c.id AND q.question_type = '错题'
+            ) AS total_wrong_questions,
+            (
+                SELECT COUNT(DISTINCT q.id)
+                FROM knowledge_point kp
+                JOIN question_kp_relation qkr ON kp.id = qkr.kp_id
+                JOIN questions q ON qkr.question_id = q.id
+                WHERE kp.chapter_id = c.id AND q.question_type = '错题' AND q.latest_status = 'right'
+            ) AS correct_questions,
+            (
+                SELECT COUNT(DISTINCT q.id)
+                FROM knowledge_point kp
+                JOIN question_kp_relation qkr ON kp.id = qkr.kp_id
+                JOIN questions q ON qkr.question_id = q.id
+                WHERE kp.chapter_id = c.id AND q.question_type = '错题' AND (q.latest_status != 'right' OR q.latest_status IS NULL)
+            ) AS not_correct_questions
+        FROM
+            chapters c
+        LEFT JOIN
+            Subject s ON c.subject_id = s.ID
+        {inner_where}
+    )
+    SELECT * FROM ChapterCounts
+    WHERE (total_wrong_questions != 0 OR correct_questions != 0 OR not_correct_questions != 0)
+    """
+    if (req.correct_count or "").strip().isdigit():
+        sql_query += " AND correct_questions = ?"
+        params.append(int(req.correct_count.strip()))
+    if (req.not_correct_count or "").strip().isdigit():
+        sql_query += " AND not_correct_questions = ?"
+        params.append(int(req.not_correct_count.strip()))
+
+    sort_column_map = {
+        "id": "chapter_id", "subject": "SubjectName", "volume_name": "VolumeName",
+        "chapter_desc": "ChapterDesc", "total_wrong_questions": "total_wrong_questions",
+        "correct_questions": "correct_questions", "not_correct_questions": "not_correct_questions"
+    }
+    db_sort_column = sort_column_map.get(req.sort_column, "chapter_id")
+    dir_ = "ASC" if req.sort_direction == "ASC" else "DESC"
+    sql_query += f" ORDER BY {db_sort_column} {dir_}, chapter_id ASC"
+
+    cur.execute(sql_query, params)
+    rows = [{
+        "chapter_id": r["chapter_id"],
+        "subject": r["SubjectName"] if r["SubjectName"] else "-",
+        "volume_name": r["VolumeName"] if r["VolumeName"] else "-",
+        "chapter_desc": r["ChapterDesc"] if r["ChapterDesc"] else "-",
+        "total_wrong_questions": r["total_wrong_questions"],
+        "correct_questions": r["correct_questions"],
+        "not_correct_questions": r["not_correct_questions"],
+    } for r in cur.fetchall()]
+    conn.close()
+    return {"rows": rows}
+
+
+@app.post("/api/analysis/chapters/export-excel")
+def analysis_chapters_export(req: ChapterReq):
+    import pandas as pd
+    data = analysis_chapters(req)
+    rows = data["rows"]
+    if not rows:
+        raise HTTPException(status_code=400, detail="当前列表没有数据可以导出")
+    df = pd.DataFrame([[r["chapter_id"], r["subject"], r["volume_name"], r["chapter_desc"],
+                        r["total_wrong_questions"], r["correct_questions"], r["not_correct_questions"]]
+                       for r in rows],
+                      columns=["ID", "科目", "册", "章节", "错题数量", "已正确题目数", "尚未正确题目数"])
+    buf = _xlsx_bytes(df, '章节分析')
+    filename = f"章节分析-{datetime.datetime.now().strftime('%Y%m%d')}.xlsx"
+    from urllib.parse import quote
+    return StreamingResponse(
+        buf,
+        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+        headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"})
+
+
+class KnowledgeReq(BaseModel):
+    subject: str = "全部"
+    correct_count: str = ""
+    not_correct_count: str = ""
+    kp_content: str = ""
+    chapter_id_filter: Optional[int] = None
+    sort_column: str = "not_correct_questions"
+    sort_direction: str = "DESC"
+
+
+@app.post("/api/analysis/knowledge")
+def analysis_knowledge(req: KnowledgeReq):
+    """SubjectKnowledgeListView.load_knowledge_data"""
+    conn = get_db()
+    cur = conn.cursor()
+    params = []
+    inner_where = " WHERE 1=1"
+
+    if req.chapter_id_filter:
+        inner_where += " AND kp.chapter_id = ?"
+        params.append(req.chapter_id_filter)
+
+    if req.subject and req.subject != "全部":
+        inner_where += " AND s.Subject = ?"
+        params.append(req.subject)
+
+    kp_content_filter = (req.kp_content or "").strip()
+    if kp_content_filter:
+        for keyword in kp_content_filter.split():
+            inner_where += " AND kp.content LIKE ?"
+            params.append(f"%{keyword}%")
+
+    sql_query = f"""
+    WITH KnowledgeCounts AS (
+        SELECT
+            kp.id AS kp_id,
+            s.Subject AS SubjectName,
+            COALESCE(c.volume_name, '') || ':' ||
+            CASE WHEN p.chapter_desc IS NOT NULL THEN p.chapter_desc || ':' ELSE '' END ||
+            COALESCE(c.chapter_desc, '') AS ChapterFullInfo,
+            kp.content AS KnowledgeContent,
+            (SELECT COUNT(qkr.question_id) FROM question_kp_relation qkr WHERE qkr.kp_id = kp.id) AS total_questions,
+            (SELECT COUNT(DISTINCT qkr_right.question_id)
+             FROM question_kp_relation qkr_right
+             JOIN questions q_right ON qkr_right.question_id = q_right.id
+             WHERE qkr_right.kp_id = kp.id AND q_right.right_times >= 1) AS correct_questions,
+            (SELECT COUNT(DISTINCT qkr_not_correct.question_id)
+             FROM question_kp_relation qkr_not_correct
+             JOIN questions q_not_correct ON qkr_not_correct.question_id = q_not_correct.id
+             WHERE qkr_not_correct.kp_id = kp.id AND q_not_correct.right_times = 0) AS not_correct_questions
+        FROM
+            knowledge_point kp
+        JOIN
+            Subject s ON kp.subject_id = s.ID
+        LEFT JOIN
+            chapters c ON kp.chapter_id = c.id
+        LEFT JOIN
+            chapters p ON c.parent_id = p.id
+        {inner_where}
+    )
+    SELECT * FROM KnowledgeCounts WHERE 1=1
+    """
+    if (req.correct_count or "").strip().isdigit():
+        sql_query += " AND correct_questions = ?"
+        params.append(int(req.correct_count.strip()))
+    if (req.not_correct_count or "").strip().isdigit():
+        sql_query += " AND not_correct_questions = ?"
+        params.append(int(req.not_correct_count.strip()))
+
+    sort_column_map = {
+        "id": "kp_id", "subject": "SubjectName", "knowledge_content": "KnowledgeContent",
+        "chapter_info": "ChapterFullInfo",
+        "total_questions": "total_questions", "correct_questions": "correct_questions",
+        "not_correct_questions": "not_correct_questions"
+    }
+    db_sort_column = sort_column_map.get(req.sort_column, "not_correct_questions")
+    dir_ = "ASC" if req.sort_direction == "ASC" else "DESC"
+    sql_query += f" ORDER BY {db_sort_column} {dir_}, kp_id ASC"
+
+    cur.execute(sql_query, params)
+    rows = [{
+        "kp_id": r["kp_id"],
+        "subject": r["SubjectName"],
+        "chapter_info": r["ChapterFullInfo"] if r["ChapterFullInfo"] else "-",
+        "knowledge_content": r["KnowledgeContent"],
+        "total_questions": r["total_questions"],
+        "correct_questions": r["correct_questions"],
+        "not_correct_questions": r["not_correct_questions"],
+    } for r in cur.fetchall()]
+    conn.close()
+    return {"rows": rows}
+
+
+@app.post("/api/analysis/knowledge/export-excel")
+def analysis_knowledge_export(req: KnowledgeReq):
+    import pandas as pd
+    data = analysis_knowledge(req)
+    rows = data["rows"]
+    if not rows:
+        raise HTTPException(status_code=400, detail="当前列表没有数据可以导出")
+    df = pd.DataFrame([[r["kp_id"], r["subject"], r["chapter_info"], r["knowledge_content"],
+                        r["total_questions"], r["correct_questions"], r["not_correct_questions"]]
+                       for r in rows],
+                      columns=["ID", "科目", "章节", "知识点", "试题数量", "已正确题目数", "尚未正确题目数"])
+    buf = _xlsx_bytes(df, '知识点分析')
+    filename = f"知识点分析-{datetime.datetime.now().strftime('%Y%m%d')}.xlsx"
+    from urllib.parse import quote
+    return StreamingResponse(
+        buf,
+        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+        headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"})
+
+
+@app.get("/api/analysis/pending")
+def analysis_pending(term: str = "", mode: str = "legacy"):
+    """PendingAnalysisView: term options + stats matrix"""
+    conn = get_db()
+    cur = conn.cursor()
+    cur.execute("SELECT ID, Subject FROM Subject WHERE enabled = 1 ORDER BY ID")
+    subjects = cur.fetchall()
+    cur.execute("SELECT ID, stage_name FROM stage WHERE enabled = 1 ORDER BY ID")
+    stages = cur.fetchall()
+    cur.execute("SELECT ID, term_name, is_default FROM term WHERE enabled = 1")
+    terms = cur.fetchall()
+    term_names = [t[1] for t in terms]
+    default_term = next((t[1] for t in terms if t[2] == 1), None)
+    if not term and default_term:
+        term = default_term
+    elif not term and term_names:
+        term = term_names[0]
+
+    cur.execute("SELECT ID FROM term WHERE term_name = ?", (term,))
+    res = cur.fetchone()
+    curr_term_id = res[0] if res else None
+
+    status_condition_sql = "AND (right_times = 0 OR right_times IS NULL)" if mode == "legacy" else ""
+
+    def count_q(sub_id=None, st_id=None, extra=""):
+        sql = f"""
+            SELECT COUNT(*) FROM questions
+            WHERE question_type = '错题'
+            {status_condition_sql}
+            {extra}
+        """
+        params = []
+        if sub_id is not None:
+            sql += " AND subject_id = ?"
+            params.append(sub_id)
+        if st_id is not None:
+            sql += " AND stage_id = ?"
+            params.append(st_id)
+        if curr_term_id is not None:
+            sql += " AND term_id = ?"
+            params.append(curr_term_id)
+        cur.execute(sql, params)
+        return cur.fetchone()[0]
+
+    stage_rows = []
+    subject_totals = {s[0]: 0 for s in subjects}
+    for st_id, st_name in stages:
+        row = {"label": st_name, "cells": [], "sum": 0}
+        for sub_id, _ in subjects:
+            c = count_q(sub_id=sub_id, st_id=st_id)
+            row["cells"].append({"sub_id": sub_id, "st_id": st_id, "count": c})
+            row["sum"] += c
+            subject_totals[sub_id] += c
+        stage_rows.append(row)
+
+    total_label = "总计遗留" if mode == "legacy" else "总计错题"
+    total_row = {"label": total_label, "cells": [], "sum": 0}
+    for sub_id, _ in subjects:
+        total_row["cells"].append({"sub_id": sub_id, "st_id": None, "count": subject_totals[sub_id]})
+        total_row["sum"] += subject_totals[sub_id]
+
+    paper_rows = []
+    paper_counts = [
+        ("组卷次数0次", "AND paper_count = 0", "0"),
+        ("组卷次数1次", "AND paper_count = 1", "1"),
+        ("组卷次数2次", "AND paper_count = 2", "2"),
+        ("组卷次数3次", "AND paper_count = 3", "3"),
+        ("组卷次数4次及以上", "AND paper_count >= 4", "4次及以上")
+    ]
+    for label, sql_cond, p_val in paper_counts:
+        row = {"label": label, "cells": [], "sum": 0, "paper_count": p_val}
+        for sub_id, _ in subjects:
+            c = count_q(sub_id=sub_id, extra=sql_cond)
+            row["cells"].append({"sub_id": sub_id, "st_id": None, "count": c, "paper_count": p_val})
+            row["sum"] += c
+        paper_rows.append(row)
+
+    conn.close()
+
+    return {
+        "terms": term_names,
+        "current_term": term,
+        "mode": mode,
+        "subjects": [{"id": s[0], "name": s[1]} for s in subjects],
+        "stages": [{"id": s[0], "name": s[1]} for s in stages],
+        "stage_rows": stage_rows,
+        "total_row": total_row,
+        "paper_rows": paper_rows,
+    }
+
+
+@app.get("/api/analysis/pending/export-excel")
+def analysis_pending_export(term: str = "", mode: str = "legacy"):
+    import pandas as pd
+    data = analysis_pending(term=term, mode=mode)
+    headers = ["阶段"] + [s["name"] for s in data["subjects"]] + ["总数"]
+    rows = []
+    for r in data["stage_rows"] + [data["total_row"]] + data["paper_rows"]:
+        rows.append([r["label"]] + [c["count"] for c in r["cells"]] + [r["sum"]])
+    if not rows:
+        raise HTTPException(status_code=400, detail="当前没有数据可供导出")
+    df = pd.DataFrame(rows, columns=headers)
+    mode_name = "遗留错题" if mode == "legacy" else "全部错题"
+    filename = f"遗留分析汇总_{mode_name}_{term}_{datetime.datetime.now().strftime('%Y%m%d')}.xlsx"
+    buf = _xlsx_bytes(df, '遗留分析')
+    from urllib.parse import quote
+    return StreamingResponse(
+        buf,
+        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+        headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"})
+
+
+# ============================================================
+# 系统设定 (setting views)
+# ============================================================
+
+@app.get("/api/settings/subjects")
+def settings_subjects():
+    conn = get_db()
+    cur = conn.cursor()
+    cur.execute("SELECT ID, Subject, enabled, CreateTime FROM Subject ORDER BY ID DESC")
+    rows = [{"id": r[0], "subject": r[1], "enabled": bool(r[2] in (1, True, '1', 'true')), "create_time": r[3]}
+            for r in cur.fetchall()]
+    conn.close()
+    return {"rows": rows}
+
+
+class NameReq(BaseModel):
+    name: str
+
+
+@app.post("/api/settings/subjects")
+def settings_subjects_add(req: NameReq):
+    """SubjectManagerView.add_subject"""
+    name = req.name
+    if len(name) > 10:
+        raise HTTPException(status_code=400, detail="学科名称不能超过10个字符")
+    try:
+        conn = get_db()
+        cur = conn.cursor()
+        now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+        cur.execute("INSERT INTO Subject (Subject, CreateTime, enabled) VALUES (?, ?, ?)", (name, now, 1))
+        conn.commit()
+        conn.close()
+        return {"ok": True}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.put("/api/settings/subjects/{record_id}")
+def settings_subjects_edit(record_id: int, req: NameReq):
+    if len(req.name) > 10:
+        raise HTTPException(status_code=400, detail="不能超过10个字符")
+    try:
+        conn = get_db()
+        cur = conn.cursor()
+        cur.execute("UPDATE Subject SET Subject = ? WHERE ID = ?", (req.name, record_id))
+        conn.commit()
+        conn.close()
+        return {"ok": True}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.post("/api/settings/subjects/{record_id}/toggle")
+def settings_subjects_toggle(record_id: int):
+    try:
+        conn = get_db()
+        cur = conn.cursor()
+        cur.execute("SELECT enabled FROM Subject WHERE ID = ?", (record_id,))
+        r = cur.fetchone()
+        new_status = 0 if (r and r[0] in (1, True, '1', 'true')) else 1
+        cur.execute("UPDATE Subject SET enabled = ? WHERE ID = ?", (new_status, record_id))
+        conn.commit()
+        conn.close()
+        return {"ok": True, "enabled": bool(new_status)}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+# ---- 错误原因管理 (ErrorCauseManagerView) ----
+
+@app.get("/api/settings/error-causes")
+def settings_error_causes():
+    conn = get_db()
+    cur = conn.cursor()
+    cur.execute("SELECT ID, Cause, Enabled, ParentID FROM Error_Cause ORDER BY ID")
+    rows = [{"id": r[0], "cause": r[1], "enabled": bool(r[2] in (1, True, '1', 'true')), "parent_id": r[3]}
+            for r in cur.fetchall()]
+    conn.close()
+    return {"rows": rows}
+
+
+class CauseReq(BaseModel):
+    name: str
+    parent_id: Optional[int] = None
+
+
+@app.post("/api/settings/error-causes")
+def settings_error_causes_add(req: CauseReq):
+    """ErrorCauseManagerView._add_cause_logic (5个中文字符限制)"""
+    name = req.name.strip()
+    if not name:
+        raise HTTPException(status_code=400, detail="名称不能为空")
+    if len(re.findall(r'[\u4e00-\u9fff]', name)) > 5:
+        raise HTTPException(status_code=400, detail="名称不能超过5个中文字符。")
+    try:
+        conn = get_db()
+        cur = conn.cursor()
+        cur.execute("INSERT INTO Error_Cause (Cause, Enabled, ParentID) VALUES (?, ?, ?)",
+                    (name, 1, req.parent_id))
+        conn.commit()
+        conn.close()
+        return {"ok": True}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.put("/api/settings/error-causes/{record_id}")
+def settings_error_causes_edit(record_id: int, req: NameReq):
+    new_name = req.name.strip()
+    if len(re.findall(r'[\u4e00-\u9fff]', new_name)) > 5:
+        raise HTTPException(status_code=400, detail="名称不能超过5个中文字符。")
+    try:
+        conn = get_db()
+        cur = conn.cursor()
+        cur.execute("UPDATE Error_Cause SET Cause = ? WHERE ID = ?", (new_name, record_id))
+        conn.commit()
+        conn.close()
+        return {"ok": True}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.post("/api/settings/error-causes/{record_id}/toggle")
+def settings_error_causes_toggle(record_id: int):
+    """ErrorCauseManagerView.toggle_status — 含父级限制校验"""
+    conn = get_db()
+    cur = conn.cursor()
+    cur.execute("SELECT ID, Cause, Enabled, ParentID FROM Error_Cause WHERE ID = ?", (record_id,))
+    row = cur.fetchone()
+    if not row:
+        conn.close()
+        raise HTTPException(status_code=404, detail="记录不存在")
+    r_id, r_cause, r_enabled, r_parent = row
+    current_status = 1 if r_enabled in (1, True, '1', 'true') else 0
+    new_status = 0 if current_status == 1 else 1
+
+    if new_status == 1 and r_parent is not None:
+        cur.execute("SELECT Enabled FROM Error_Cause WHERE ID = ?", (r_parent,))
+        p = cur.fetchone()
+        if p and not (p[0] in (1, True, '1', 'true')):
+            conn.close()
+            raise HTTPException(status_code=400, detail="父级原因已停用,无法单独启用此子原因。")
+
+    if new_status == 0:
+        cur.execute("SELECT COUNT(*) FROM Error_Cause WHERE ParentID = ?", (record_id,))
+        if cur.fetchone()[0] > 0:
+            conn.close()
+            # 原版此处有 askyesno 确认,由前端确认后带 force=1 重发
+            raise HTTPException(status_code=422, detail=(
+                f"【{r_cause}】包含子原因。\n停用父级在逻辑上通常意味着子级也不可用。\n确定要继续吗?"))
+
+    try:
+        cur.execute("UPDATE Error_Cause SET Enabled = ? WHERE ID = ?", (new_status, record_id))
+        conn.commit()
+        conn.close()
+        return {"ok": True, "enabled": bool(new_status)}
+    except Exception as e:
+        conn.close()
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.post("/api/settings/error-causes/{record_id}/toggle-force")
+def settings_error_causes_toggle_force(record_id: int):
+    """用户确认停用含子原因的父级后调用"""
+    conn = get_db()
+    cur = conn.cursor()
+    cur.execute("SELECT Enabled FROM Error_Cause WHERE ID = ?", (record_id,))
+    row = cur.fetchone()
+    if not row:
+        conn.close()
+        raise HTTPException(status_code=404, detail="记录不存在")
+    current_status = 1 if row[0] in (1, True, '1', 'true') else 0
+    new_status = 0 if current_status == 1 else 1
+    cur.execute("UPDATE Error_Cause SET Enabled = ? WHERE ID = ?", (new_status, record_id))
+    conn.commit()
+    conn.close()
+    return {"ok": True, "enabled": bool(new_status)}
+
+
+# ---- 题目状态管理 (QuestionStatusManagerView) ----
+
+@app.get("/api/settings/question-statuses")
+def settings_question_statuses():
+    conn = get_db()
+    cur = conn.cursor()
+    cur.execute("SELECT ID, StatusName, Description, Enabled FROM Question_Status ORDER BY ID")
+    rows = [{"id": r[0], "name": r[1], "description": r[2], "enabled": bool(r[3] in (1, True, '1', 'true'))}
+            for r in cur.fetchall()]
+    conn.close()
+    return {"rows": rows}
+
+
+class StatusReq(BaseModel):
+    name: str
+    description: str = ""
+
+
+@app.post("/api/settings/question-statuses")
+def settings_question_statuses_add(req: StatusReq):
+    name = req.name.strip()
+    desc = req.description.strip()
+    if not name:
+        raise HTTPException(status_code=400, detail="状态名称不能为空")
+    if len(name) > 20:
+        raise HTTPException(status_code=400, detail="状态名称过长(建议20字以内)")
+    try:
+        conn = get_db()
+        cur = conn.cursor()
+        cur.execute("INSERT INTO Question_Status (StatusName, Description, Enabled) VALUES (?, ?, ?)",
+                    (name, desc, 1))
+        conn.commit()
+        conn.close()
+        return {"ok": True}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.put("/api/settings/question-statuses/{record_id}")
+def settings_question_statuses_edit(record_id: int, req: StatusReq):
+    name = req.name.strip()
+    desc = req.description.strip()
+    if not name:
+        raise HTTPException(status_code=400, detail="状态名称不能为空")
+    try:
+        conn = get_db()
+        cur = conn.cursor()
+        cur.execute("UPDATE Question_Status SET StatusName = ?, Description = ? WHERE ID = ?",
+                    (name, desc, record_id))
+        conn.commit()
+        conn.close()
+        return {"ok": True}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.post("/api/settings/question-statuses/{record_id}/toggle")
+def settings_question_statuses_toggle(record_id: int):
+    conn = get_db()
+    cur = conn.cursor()
+    cur.execute("SELECT Enabled FROM Question_Status WHERE ID = ?", (record_id,))
+    row = cur.fetchone()
+    if not row:
+        conn.close()
+        raise HTTPException(status_code=404, detail="记录不存在")
+    current = 1 if row[0] in (1, True, '1', 'true') else 0
+    new_val = 0 if current else 1
+    cur.execute("UPDATE Question_Status SET Enabled = ? WHERE ID = ?", (new_val, record_id))
+    conn.commit()
+    conn.close()
+    return {"ok": True, "enabled": bool(new_val)}
+
+
+# ---- 默认参数设定 (DefaultParamsSettingView) ----
+
+PARAM_TABLES = {
+    "term": "term_name",
+    "stage": "stage_name",
+    "question_origin": "origin_name",
+}
+
+
+@app.get("/api/settings/default-params")
+def settings_default_params():
+    conn = get_db()
+    cur = conn.cursor()
+    result = {}
+    for table, name_col in PARAM_TABLES.items():
+        cur.execute(f"SELECT ID, {name_col}, is_default FROM {table} WHERE enabled = 1 ORDER BY ID")
+        rows = cur.fetchall()
+        names = [r[1] for r in rows]
+        default_name = next((r[1] for r in rows if r[2] == 1), None)
+        result[table] = {"options": names, "default": default_name,
+                         "default_display": default_name if default_name
+                         else (f"{names[0]} (未设置默认值)" if names else "无 (无可用选项)")}
+    conn.close()
+    return result
+
+
+@app.post("/api/settings/default-params/{table}")
+def settings_default_params_set(table: str, req: NameReq):
+    """DefaultParamsSettingView._save_default_param"""
+    if table not in PARAM_TABLES:
+        raise HTTPException(status_code=404, detail="未知参数表")
+    name_col = PARAM_TABLES[table]
+    selected_name = req.name
+    if not selected_name:
+        raise HTTPException(status_code=400, detail="请选择一个选项!")
+
+    conn = get_db()
+    cur = conn.cursor()
+    cur.execute(f"SELECT ID FROM {table} WHERE {name_col} = ?", (selected_name,))
+    row = cur.fetchone()
+    if not row:
+        conn.close()
+        raise HTTPException(status_code=400, detail=f"未找到 '{selected_name}' 对应的ID,请刷新页面。")
+    selected_id = row[0]
+
+    try:
+        cur.execute(f"UPDATE {table} SET is_default = 0 WHERE enabled = 1")
+        cur.execute(f"UPDATE {table} SET is_default = 1 WHERE ID = ?", (selected_id,))
+        conn.commit()
+        conn.close()
+        return {"ok": True}
+    except Exception as e:
+        conn.rollback()
+        conn.close()
+        raise HTTPException(status_code=500, detail=f"保存默认{table}失败: {e}")
+
+
+# ============================================================
+# 科目/章节下拉辅助
+# ============================================================
+
+@app.get("/api/meta/subjects")
+def meta_subjects():
+    conn = get_db()
+    cur = conn.cursor()
+    cur.execute("SELECT DISTINCT Subject FROM Subject WHERE Subject IS NOT NULL ORDER BY Subject")
+    subjects = [r[0] for r in cur.fetchall()]
+    conn.close()
+    return {"subjects": subjects}
+
+
+if __name__ == "__main__":
+    import uvicorn
+    uvicorn.run(app, host="127.0.0.1", port=1065)
diff --git a/backend/media_utils.py b/backend/media_utils.py
new file mode 100644 (file)
index 0000000..89132e2
--- /dev/null
@@ -0,0 +1,164 @@
+# ===================== media_utils.py =====================
+# Image-token handling ported from the original app:
+#  - manuallyadd.py: _save_image_for_question / _collect_images_for_json
+#  - question_detail.py: _ensure_question_folder_for_save /
+#    _save_image_for_new_or_edited_content / _process_content_for_save
+# The [[IMG:...]] pattern regex is taken from the intact version used by
+# question_list.py / manuallyadd.py (in question_detail.py of the zip the
+# same regex was corrupted by a find-replace accident: "(" had become "$").
+import os
+import re
+import shutil
+import datetime
+from PIL import Image
+
+
+def ensure_media_base(media_base_dir):
+    os.makedirs(media_base_dir, exist_ok=True)
+    clipboard_temp = os.path.join(media_base_dir, "_clipboard_temp")
+    os.makedirs(clipboard_temp, exist_ok=True)
+    return clipboard_temp
+
+
+def save_clipboard_upload(clipboard_temp_dir, filename, data: bytes):
+    """Save an uploaded (pasted / picked) image into the clipboard temp dir
+    and return its absolute path (to be embedded as [[IMG:<path>]])."""
+    ts = datetime.datetime.now().strftime("%Y%m%d%H%M%S%f")
+    ext = os.path.splitext(filename)[1] or ".png"
+    temp_path = os.path.join(clipboard_temp_dir, f"clip_{ts}{ext}")
+    with open(temp_path, "wb") as f:
+        f.write(data)
+    return temp_path
+
+
+# ---------------- manuallyadd.py logic ----------------
+
+def ensure_question_folder_manually(media_base_dir, session_timestamp, question_no):
+    """Media/{timestamp}-{question_no}  (manuallyadd._ensure_question_folder)"""
+    folder_name = f"{session_timestamp}-{question_no}"
+    folder_path = os.path.join(media_base_dir, folder_name)
+    os.makedirs(folder_path, exist_ok=True)
+    return folder_name, folder_path
+
+
+def collect_images_for_json(media_base_dir, raw_text, session_timestamp, question_no, image_counters):
+    """manuallyadd._collect_images_for_json + _save_image_for_question.
+    Replaces [[IMG:...]] tokens with markdown image marks."""
+    text = raw_text or ""
+    pattern = re.compile(r"\[\[IMG:(.*?)\]\]")
+    matches = list(pattern.finditer(text))
+
+    if not matches:
+        return text, None
+
+    md_tags = []
+    image_dir = None
+
+    for m in matches:
+        src_path = m.group(1).strip()
+        if not src_path:
+            continue
+        md_tag, rel_dir = _save_image_for_question_impl(
+            media_base_dir, src_path, session_timestamp, question_no, image_counters)
+        if md_tag:
+            md_tags.append(md_tag)
+            image_dir = rel_dir
+
+    def repl(_match):
+        return md_tags.pop(0) if md_tags else ""
+
+    processed = pattern.sub(repl, text)
+    return processed, image_dir
+
+
+def _save_image_for_question_impl(media_base_dir, src_path, session_timestamp, question_no, image_counters):
+    if not os.path.exists(src_path):
+        return None, None
+
+    folder_name, folder_path = ensure_question_folder_manually(
+        media_base_dir, session_timestamp, question_no)
+
+    current_count = image_counters.get(question_no, 0) + 1
+    image_counters[question_no] = current_count
+
+    ext = os.path.splitext(src_path)[1]
+    if not ext:
+        ext = ".png"
+    dest_filename = f"image{current_count}{ext}"
+    dest_full_path = os.path.join(folder_path, dest_filename)
+
+    try:
+        shutil.copy2(src_path, dest_full_path)
+    except Exception:
+        return None, None
+
+    try:
+        img = Image.open(dest_full_path)
+        width_px, height_px = img.size
+    except Exception:
+        return None, None
+
+    # 估算 EMU:1px ≈ 9525 EMU
+    w_emu = int(width_px * 9525)
+    h_emu = int(height_px * 9525)
+
+    max_w_emu = 6_000_000
+    max_h_emu = 7_000_000
+
+    if w_emu > max_w_emu or h_emu > max_h_emu:
+        scale = min(max_w_emu / float(w_emu), max_h_emu / float(h_emu))
+        w_emu = int(w_emu * scale)
+        h_emu = int(h_emu * scale)
+
+    rel_dir = os.path.join("Media", folder_name).replace(os.sep, "/")
+    rel_path = f"{rel_dir}/{dest_filename}"
+
+    md_tag = f" ![]( {rel_path}?w={w_emu}&h={h_emu} ) "
+    return md_tag, rel_dir
+
+
+# ---------------- question_detail.py logic ----------------
+
+def ensure_question_folder_for_save(db_conn, media_base_dir, question_id):
+    """question_detail._ensure_question_folder_for_save"""
+    cursor = db_conn.cursor()
+    cursor.execute("SELECT media_path FROM questions WHERE id = ?", (question_id,))
+    res = cursor.fetchone()
+    folder_name = os.path.basename(res[0]) if res and res[0] else \
+        f"{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}-edit-{question_id}"
+    folder_path = os.path.join(media_base_dir, folder_name)
+    os.makedirs(folder_path, exist_ok=True)
+    return folder_name, folder_path
+
+
+def save_image_for_new_or_edited_content(media_base_dir, src_path, question_id, folder_name, image_counters):
+    """question_detail._save_image_for_new_or_edited_content"""
+    dest_dir = os.path.join(media_base_dir, folder_name)
+    if question_id not in image_counters:
+        image_counters[question_id] = len(os.listdir(dest_dir))
+    image_counters[question_id] += 1
+    dest_filename = f"image{image_counters[question_id]}{os.path.splitext(src_path)[1] or '.png'}"
+    dest_path = os.path.join(dest_dir, dest_filename)
+    shutil.copy2(src_path, dest_path)
+    img = Image.open(dest_path)
+    w_emu, h_emu = int(img.width * 9525), int(img.height * 9525)
+    rel_path = f"Media/{folder_name}/{dest_filename}"
+    return f" ![]( {rel_path}?w={w_emu}&h={h_emu} ) "
+
+
+def process_content_for_save(db_conn, media_base_dir, raw_text, question_id, image_counters):
+    """question_detail._process_content_for_save (regex fixed to the intact
+    version: (\\[\\[IMG:(.+?)\\]\\])|(\\!\\[\\]\\s*\\(\\s*(.+?)\\s*\\)))"""
+    text = raw_text or ""
+    folder_name, _ = ensure_question_folder_for_save(db_conn, media_base_dir, question_id)
+    pattern = re.compile(r'(\[\[IMG:(.+?)\]\])|(\!\[\]\s*\(\s*(.+?)\s*\))')
+
+    def repl(m):
+        if m.group(1):
+            src = m.group(2).strip()
+            if os.path.isabs(src) and os.path.exists(src):
+                return save_image_for_new_or_edited_content(
+                    media_base_dir, src, question_id, folder_name, image_counters)
+        return m.group(0)
+
+    return pattern.sub(repl, text), f"Media/{folder_name}"
diff --git a/backend/parsers/__init__.py b/backend/parsers/__init__.py
new file mode 100644 (file)
index 0000000..f6a49b3
--- /dev/null
@@ -0,0 +1,3 @@
+# Core parsers ported 1:1 from the original desktop app
+# (py/question/file_parsing/*.py in the original zip).
+# The only changes: no tkinter; logs collected into a list.
diff --git a/backend/parsers/zhixuewang_file_parsing.py b/backend/parsers/zhixuewang_file_parsing.py
new file mode 100644 (file)
index 0000000..ee68ca8
--- /dev/null
@@ -0,0 +1,402 @@
+# ===================== zhixuewang_file_parsing.py =====================
+# Ported 1:1 from original py/question/file_parsing/zhixuewang_file_parsing.py
+# Changes: media_base_dir points to BASE_DIR (project root); no other logic changes.
+import os
+import re
+import datetime
+from docx import Document
+from docx.table import Table
+from docx.text.paragraph import Paragraph
+
+
+class ZhixuewangParser:
+    def __init__(self, file_path, meta_data, log_callback=None, media_base_dir=None):
+        super().__init__()
+        self.file_path = file_path
+        self.meta_data = meta_data
+        self.log_callback = log_callback
+
+        self.media_base_dir = media_base_dir or os.path.abspath(
+            os.path.join(os.path.dirname(__file__), "..", "..", "Media")
+        )
+
+        if not os.path.exists(self.media_base_dir):
+            os.makedirs(self.media_base_dir)
+
+    def log(self, msg):
+        if self.log_callback:
+            self.log_callback(msg)
+
+    def get_para_mixed_content(self, para, image_index):
+        text_content = ""
+        for run in para.runs:
+            run_text = run.text
+
+            # --- 增强的下划线处理逻辑 ---
+            has_underline = False
+            if run.underline is True:
+                has_underline = True
+            elif run.underline is not None and run.underline != False and run.underline != 0:
+                has_underline = True
+
+            if has_underline and run_text:
+                processed_text = ""
+                for char in run_text:
+                    if char == ' ':
+                        processed_text += '_'
+                    elif char == '\t':
+                        processed_text += '____'
+                    elif char == '\u00A0':  # 不间断空格
+                        processed_text += '_'
+                    else:
+                        processed_text += char
+                run_text = processed_text
+
+            # --- 处理下标格式 ---
+            if run.font.subscript:
+                subscript_map = {
+                    '0': '₀', '1': '₁', '2': '₂', '3': '₃', '4': '₄',
+                    '5': '₅', '6': '₆', '7': '₇', '8': '₈', '9': '₉',
+                    '+': '₊', '-': '₋', '=': '₌', '(': '₍', ')': '₎',
+                    'a': 'ₐ', 'e': 'ₑ', 'o': 'ₒ', 'x': 'ₓ'
+                }
+                processed_sub = ""
+                for char in run_text:
+                    processed_sub += subscript_map.get(char, char)
+                run_text = processed_sub
+
+            # --- 处理上标格式 ---
+            if run.font.superscript:
+                if run_text == '2':
+                    run_text = '²'
+                elif run_text == '3':
+                    run_text = '³'
+                # 可以根据实际情况添加更多上标字符的映射
+
+            if run_text:
+                text_content += run_text
+
+            xml_str = run._element.xml
+            rids = re.findall(r'embed="(rId\d+)"', xml_str) + re.findall(r'id="(rId\d+)"', xml_str)
+            size_match = re.search(r'extent cx="(\d+)" cy="(\d+)"', xml_str)
+
+            width_emu = size_match.group(1) if size_match else 0
+            height_emu = size_match.group(2) if size_match else 0
+
+            for rid in rids:
+                if rid in image_index:
+                    path = image_index[rid]
+                    if width_emu and height_emu:
+                        text_content += f" ![]( {path}?w={width_emu}&h={height_emu} ) "
+                    else:
+                        text_content += f" ![]( {path} ) "
+        return text_content
+
+    def parse(self):
+        if not os.path.exists(self.file_path):
+            return None, "文件不存在"
+
+        try:
+            doc = Document(self.file_path)
+        except Exception as e:
+            return None, f"Word打开失败: {e}"
+
+        all_image_blobs = {}
+        for rel in doc.part.rels.values():
+            if "image" in rel.target_ref:
+                img_name = os.path.basename(rel.target_ref)
+                all_image_blobs[rel.rId] = (img_name, rel.target_part.blob)
+
+        self.log("正在预解析答案区域...")
+        answers_info = {}
+        is_in_answer_zone = False
+        current_ans_id = None
+        ans_temp_paras = []
+
+        answer_anchor_match_regex = re.compile(r'^([\d\.]+)\s*【(?:原错题|变式题)参考答案\s*】')
+
+        for para in doc.paragraphs:
+            p_text = para.text.strip()
+            answer_anchor_match = answer_anchor_match_regex.match(p_text)
+
+            if answer_anchor_match:
+                if current_ans_id:  # 保存上一段收集到的答案
+                    answers_info[current_ans_id] = {
+                        "content_paras": ans_temp_paras,
+                    }
+                current_ans_id = answer_anchor_match.group(1)  # 获取题号 (如 "1" 或 "1.1")
+                is_in_answer_zone = True
+                ans_temp_paras = [para]  # 重置答案段落缓存,并将当前锚点段落加入
+            elif is_in_answer_zone:
+                ans_temp_paras.append(para)
+
+        if current_ans_id:  # 循环结束后,保存最后一个答案
+            answers_info[current_ans_id] = {
+                "content_paras": ans_temp_paras,
+            }
+
+        self.log("开始拆解题目主体...")
+        questions_list = []
+        now_ts = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
+
+        current_qid = None
+        collecting_q = False
+        q_temp_paras = []
+        this_q_image_index = {}
+
+        # 题目信息变量,用于存储当前题目的元数据
+        ans_time = ""
+        kn_info = ""
+        current_json_type = self.meta_data["type"]
+        q_category = "客观题"  # Default category
+        current_image_folder = ""
+
+        last_primary_kn_info = ""
+
+        question_start_pattern = re.compile(r'^(?:变式题\s*)?([\d\.]+)\s*[\((]\s*(主观题|客观题|巩固题|0)\s*[\))]')
+
+        for child in doc.element.body:
+            element_text = ""
+            element_is_table = False
+            table_col_count = 0
+            cells = None
+            current_paragraph_obj = None
+
+            if child.tag.endswith('tbl'):
+                table = Table(child, doc)
+                cells = table.rows[0].cells
+                table_col_count = len(cells)
+                if table_col_count < 2:
+                    self.log(f"DEBUG: 跳过列数不足2的表格 (len={table_col_count}).")
+                    continue
+                element_text = cells[1].text.strip().replace('\xa0', ' ')
+                element_is_table = True
+            elif child.tag.endswith('p'):
+                para = Paragraph(child, doc)
+                element_text = para.text.strip()
+                current_paragraph_obj = para
+            else:
+                continue
+
+            question_match = question_start_pattern.match(element_text)
+
+            if question_match:
+                self.log(f"--- Detected NEW QUESTION START: '{element_text}' ---")
+
+                if current_qid and q_temp_paras:
+                    self.process_and_append(
+                        questions_list,
+                        current_qid,
+                        q_temp_paras,
+                        answers_info,
+                        this_q_image_index,
+                        kn_info,
+                        now_ts,
+                        q_category,
+                        ans_time,
+                        current_image_folder,
+                        current_json_type
+                    )
+
+                current_qid = question_match.group(1)
+                question_type_label_raw = question_match.group(2)
+
+                if question_type_label_raw == "0":
+                    actual_question_type_label = "巩固题"
+                else:
+                    actual_question_type_label = question_type_label_raw
+
+                current_json_type = "推题" if actual_question_type_label == "巩固题" else self.meta_data["type"]
+                q_category = "主观题" if actual_question_type_label == "主观题" else "客观题"
+
+                ans_time = ""
+                kn_info = ""
+
+                if element_is_table and table_col_count >= 3:
+                    ans_time = cells[2].text.replace("答题时间:", "").strip()
+
+                    full_kn_text = element_text
+
+                    # 1. 提取类型括号后的主体部分
+                    after_bracket_match = re.search(r'[))](.*)', full_kn_text)
+                    if after_bracket_match:
+                        kn = after_bracket_match.group(1).strip()
+                    else:
+                        kn = full_kn_text
+
+                    # 2. 移除开头的 "作业 n"
+                    kn = re.sub(r'^(作业|练习)\s*\d*\s*', '', kn).strip()
+
+                    # 3. 移除知识点第一个文字前的数字(如 8.4, 8.2 等)
+                    kn = re.sub(r'^[\d\.\s]+', '', kn).strip()
+
+                    # 4. 移除绝对位于末尾的括号内容 (xxx)
+                    kn = re.sub(r'[((][^(())]+[))]$', '', kn).strip()
+
+                    # 5. 移除班级信息
+                    kn_info = re.split(r'\d+-\d+班', kn)[0].strip()
+
+                    last_primary_kn_info = kn_info
+
+                elif actual_question_type_label == "巩固题":
+                    kn_info = last_primary_kn_info
+                    self.log(f"DEBUG: 巩固题 '{current_qid}' 继承知识点: '{kn_info}'")
+
+                folder_name = f"{now_ts}-{current_qid}"
+                current_image_folder = folder_name
+                target_media_dir = os.path.join(self.media_base_dir, folder_name)
+                os.makedirs(target_media_dir, exist_ok=True)
+
+                this_q_image_index = {}
+                for rid, (name, blob) in all_image_blobs.items():
+                    img_save_path = os.path.join(target_media_dir, name)
+                    with open(img_save_path, "wb") as f:
+                        f.write(blob)
+                    this_q_image_index[rid] = os.path.join("Media", folder_name, name)
+
+                q_temp_paras = []
+                collecting_q = True
+                self.log(f"解析到题目: {current_qid} (类型: {current_json_type}, 分类: {q_category}) - 源: {'表格' if element_is_table else '段落'}")
+                if actual_question_type_label == "巩固题":
+                    self.log(f"---识别到巩固题: {current_qid} (成功匹配到正则) ---")
+
+                continue
+
+            elif collecting_q and element_is_table:
+                q_temp_paras.append(table)
+
+            elif collecting_q and current_paragraph_obj:
+                if ("参考答案" in element_text and len(element_text) < 15) or \
+                   answer_anchor_match_regex.match(element_text):
+                    self.log(f"--- 停止收集 {current_qid} 的题干,检测到答案标记: '{element_text}' ---")
+                    collecting_q = False
+                    continue
+                q_temp_paras.append(current_paragraph_obj)
+
+        if current_qid and q_temp_paras:
+            self.process_and_append(
+                questions_list,
+                current_qid,
+                q_temp_paras,
+                answers_info,
+                this_q_image_index,
+                kn_info,
+                now_ts,
+                q_category,
+                ans_time,
+                current_image_folder,
+                current_json_type
+            )
+
+        return questions_list, None
+
+    def process_and_append(self, q_list, qid, q_paras, answers_info_map, img_idx, kn, ts, qt, at, img_folder_name, custom_question_type):
+        """
+        处理单道题目的数据并添加到列表。
+        新增逻辑:根据答案内容判断客观题是单选还是多选,并修改 q_content。
+        """
+        # 1. 构建题目内容 (q_content)
+        content_parts = []
+        for p in q_paras:
+            if isinstance(p, Table):
+                for row in p.rows:
+                    row_text_parts = []
+                    for cell in row.cells:
+                        for para in cell.paragraphs:
+                            cell_text = self.get_para_mixed_content(para, img_idx).strip()
+                            if cell_text:
+                                row_text_parts.append(cell_text)
+                    if row_text_parts:
+                        content_parts.append(" | ".join(row_text_parts))
+            else:
+                content_parts.append(self.get_para_mixed_content(p, img_idx))
+        q_content = "\n".join(content_parts).strip()
+
+        # 2. 构建答案内容 (ans_content)
+        ans_content = ""
+        answer_data = answers_info_map.get(qid)
+
+        # 定义一个变量来存储答案选项行,用于后续判断单选/多选
+        answer_option_line_text = ""
+
+        if answer_data and answer_data["content_paras"]:
+            ans_content_paras = answer_data["content_paras"]
+
+            # 从原始段落列表中构建 ans_content
+            ans_content = "\n".join([self.get_para_mixed_content(p, img_idx) for p in ans_content_paras]).strip()
+
+            # --- 修正逻辑:查找实际的答案选项行 ---
+            answer_anchor_match_regex = re.compile(r'^[\d\.]+\s*【(?:原错题|变式题)参考答案\s*】')
+
+            first_effective_para_index = -1
+            for i, para in enumerate(ans_content_paras):
+                para_text_raw = para.text.strip()
+                if not para_text_raw:  # 跳过空段落
+                    continue
+                if answer_anchor_match_regex.match(para_text_raw):  # 跳过锚点段落
+                    self.log(f"DEBUG: QID {qid}: 跳过答案锚点行: '{para_text_raw}'")
+                    continue
+
+                first_effective_para_index = i
+                answer_option_line_text = self.get_para_mixed_content(para, img_idx).strip()
+                self.log(f"DEBUG: QID {qid}: 找到答案选项行内容: '{answer_option_line_text}' (来自段落索引 {i})")
+                break
+
+            if first_effective_para_index == -1 and ans_content_paras:
+                self.log(f"DEBUG: QID {qid}: 未找到明确的答案选项段落,回退到使用ans_content第一行尝试匹配。")
+                first_significant_ans_line_from_content = ""
+                for line in ans_content.split('\n'):
+                    stripped_line = line.strip()
+                    if stripped_line:
+                        first_significant_ans_line_from_content = stripped_line
+                        break
+                answer_option_line_text = first_significant_ans_line_from_content
+
+        else:
+            self.log(f"DEBUG: 题号 '{qid}' 未在答案字典中找到对应答案或答案内容为空。")
+
+        # 3. 客观题的单选/多选判断
+        objective_type_prefix = ""
+        if qt == "客观题":  # 如果是客观题
+            self.log(f"DEBUG: QID {qid} 是客观题,尝试判断单选/多选。")
+            if answer_option_line_text:
+                options_match = re.match(r'^([A-D]+)(?:\s*\.|\s|$)', answer_option_line_text, re.IGNORECASE)
+
+                if options_match:
+                    options_string = options_match.group(1).upper()
+                    if len(options_string) == 1:
+                        objective_type_prefix = "【单选题】"
+                        self.log(f"DEBUG: QID {qid}: 识别为单选题 ({options_string}).")
+                    elif len(options_string) > 1:
+                        objective_type_prefix = "【多选题】"
+                        self.log(f"DEBUG: QID {qid}: 识别为多选题 ({options_string}).")
+                    else:
+                        self.log(f"DEBUG: QID {qid}: 选项字符串长度为0,未识别。")
+                else:
+                    self.log(f"DEBUG: QID {qid}: 答案选项行 '{answer_option_line_text}' 未以有效选项 (A-D) 开头,未识别单选/多选。")
+            else:
+                self.log(f"DEBUG: QID {qid}: 答案选项行内容为空,无法识别单选/多选。")
+        else:
+            self.log(f"DEBUG: QID {qid} 为 '{qt}',跳过单选/多选判断。")
+
+        # 4. 将确定的客观题类型前缀加入题干内容
+        q_content = objective_type_prefix + q_content
+        self.log(f"DEBUG: QID {qid} 最终题干前缀: '{objective_type_prefix}'.")
+
+        # 5. 追加到 questions_list
+        q_list.append({
+            "题号": qid,
+            "科目": self.meta_data["subject"],
+            "题目来源": self.meta_data["source"],
+            "题目类型": custom_question_type,
+            "输入时间": self.meta_data["import_time"],
+            "答题时间": at,
+            "题目知识点": kn,
+            "题目分类": qt,  # qt 仍然是 "客观题" 或 "主观题"
+            "题干内容": q_content,  # 已经加入了单选/多选前缀
+            "答案解析": ans_content,
+            "学期": self.meta_data.get("term"),
+            "阶段": self.meta_data.get("stage"),
+            "题目出处": self.meta_data.get("origin"),
+            "图片目录": os.path.join("Media", img_folder_name)
+        })
diff --git a/backend/parsers/zizuword_file_parsing.py b/backend/parsers/zizuword_file_parsing.py
new file mode 100644 (file)
index 0000000..befb14b
--- /dev/null
@@ -0,0 +1,329 @@
+# ===================== zizuword_file_parsing.py =====================
+# Ported 1:1 from original py/question/file_parsing/zizuword_file_parsing.py
+# Changes: media_base_dir points to BASE_DIR (project root); no other logic changes.
+import os
+import re
+import datetime
+from docx import Document
+from docx.text.paragraph import Paragraph
+
+
+class ZizuWordParser:
+    """
+    自组WORD解析器
+
+    设计目标:
+    - 初始化、日志、打开 Word、Media 目录处理、JSON 结构字段命名等,
+      与 ZhixuewangParser 保持一致风格;
+    - 内容解析规则按题述的自组 Word 规范实现。
+    """
+
+    def __init__(self, file_path, meta_data, log_callback=None, media_base_dir=None):
+        super().__init__()
+        self.file_path = file_path
+        self.meta_data = meta_data
+        self.log_callback = log_callback
+
+        self.media_base_dir = media_base_dir or os.path.abspath(
+            os.path.join(os.path.dirname(__file__), "..", "..", "Media")
+        )
+
+        if not os.path.exists(self.media_base_dir):
+            os.makedirs(self.media_base_dir)
+
+    # -------------------- 日志 --------------------
+
+    def log(self, msg):
+        if self.log_callback:
+            self.log_callback(msg)
+
+    # -------------------- 段落文本 + 图片混合内容提取 --------------------
+
+    def get_para_mixed_content(self, para, image_index):
+        """
+        参考 zhixuewang_file_parsing.py 的实现:
+        - 处理下划线(填空题);
+        - 将段落中的内嵌图片转为 Markdown 标记:![]( path?w=...&h=... )。
+        """
+        text_content = ""
+        for run in para.runs:
+            run_text = run.text
+
+            # --- 下划线处理逻辑(与 ZhixuewangParser 一致) ---
+            has_underline = False
+            if run.underline is True:
+                has_underline = True
+            elif run.underline is not None and run.underline != False and run.underline != 0:
+                has_underline = True
+
+            if has_underline and run_text:
+                processed_text = ""
+                for char in run_text:
+                    if char == ' ':
+                        processed_text += '_'
+                    elif char == '\t':
+                        processed_text += '____'
+                    elif char == '\u00A0':  # 不间断空格
+                        processed_text += '_'
+                    else:
+                        processed_text += char
+                run_text = processed_text
+
+            if run_text:
+                text_content += run_text
+
+            # --- 图片信息提取 ---
+            xml_str = run._element.xml
+            rids = re.findall(r'embed="(rId\d+)"', xml_str) + re.findall(r'id="(rId\d+)"', xml_str)
+            size_match = re.search(r'extent cx="(\d+)" cy="(\d+)"', xml_str)
+
+            width_emu = size_match.group(1) if size_match else 0
+            height_emu = size_match.group(2) if size_match else 0
+
+            for rid in rids:
+                if rid in image_index:
+                    path = image_index[rid]
+                    if width_emu and height_emu:
+                        text_content += f" ![]( {path}?w={width_emu}&h={height_emu} ) "
+                    else:
+                        text_content += f" ![]( {path} ) "
+        return text_content
+
+    # -------------------- 图片目录处理 --------------------
+
+    def _build_image_index_for_question(self, all_image_blobs, now_ts, qid_str):
+        """
+        为每道题目复制当前文档所有图片到 Media/{now_ts}-{qid}/ 目录,
+        并返回 image_index 映射和 folder_name。
+        """
+        folder_name = f"{now_ts}-{qid_str}"
+        target_media_dir = os.path.join(self.media_base_dir, folder_name)
+        os.makedirs(target_media_dir, exist_ok=True)
+
+        image_index = {}
+        for rid, (img_name, blob) in all_image_blobs.items():
+            img_save_path = os.path.join(target_media_dir, img_name)
+            with open(img_save_path, "wb") as f:
+                f.write(blob)
+            image_index[rid] = os.path.join("Media", folder_name, img_name)
+
+        return image_index, folder_name
+
+    def _append_question_json(
+        self,
+        questions_list,
+        qid_str,
+        q_type,
+        q_category,
+        kp_text,
+        stem_lines,
+        ans_lines,
+        folder_name
+    ):
+        """
+        将一题的解析结果按照统一 JSON 结构追加到 questions_list 中。
+        """
+        q_content = "\n".join(stem_lines).strip()
+        ans_content = "\n".join(ans_lines).strip()
+
+        now_input_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+        questions_list.append({
+            "题号": qid_str,
+            "科目": self.meta_data.get("subject", ""),
+            "题目来源": "自组WORD",
+            "题目类型": q_type,
+            "输入时间": now_input_time,
+            "答题时间": "",
+            "题目知识点": kp_text,
+            "题目分类": q_category,
+            "题干内容": q_content,
+            "答案解析": ans_content,
+            "学期": self.meta_data.get("term"),
+            "阶段": self.meta_data.get("stage"),
+            "题目出处": self.meta_data.get("origin"),
+            "图片目录": os.path.join("Media", folder_name)
+        })
+
+    # -------------------- 主解析逻辑 --------------------
+
+    def parse(self):
+        """
+        解析自组 Word 文档:
+        - 当段落中出现“【错题】”或“【推题】”时识别为一道新题目;
+        - 结构分块:
+            【错题】/【推题】 [【主观题】/【客观题】]       # 题目头行
+            【知识点】...                                  # 知识点行
+            【题干】...                                    # 题干起始行
+            ...                                            # 题干后续行
+            【答案】...                                    # 答案起始行
+            ...                                            # 答案后续行,直到下一个题目头或文档结束
+        """
+        if not os.path.exists(self.file_path):
+            return None, "文件不存在"
+
+        try:
+            doc = Document(self.file_path)
+        except Exception as e:
+            return None, f"Word打开失败: {e}"
+
+        # 收集文档中所有图片的 blob,与 rId 对应(与 zhixuewang 相同)
+        all_image_blobs = {}
+        for rel in doc.part.rels.values():
+            if "image" in rel.target_ref:
+                img_name = os.path.basename(rel.target_ref)
+                all_image_blobs[rel.rId] = (img_name, rel.target_part.blob)
+
+        self.log("开始拆解自组WORD题目...")
+        questions_list = []
+        now_ts = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
+
+        # 状态变量
+        current_qid_int = 0         # 题号(从1开始自增)
+        current_q_type = None       # "错题" / "推题"
+        current_category = None     # "主观题" / "客观题"
+        current_kp_text = ""        # 知识点
+        current_stem_lines = []     # 题干内容行(已含图片标记)
+        current_ans_lines = []      # 答案内容行(已含图片标记)
+        current_image_index = {}    # 当前题目的 rId->路径 映射
+        current_folder_name = ""    # Media 子目录名
+
+        in_stem = False             # 当前是否在题干区域
+        in_answer = False           # 当前是否在答案区域
+
+        def flush_current_question():
+            """结束当前题目时,将其追加到 questions_list。"""
+            nonlocal current_qid_int, current_q_type, current_category
+            nonlocal current_kp_text, current_stem_lines, current_ans_lines
+            nonlocal current_folder_name
+
+            if current_qid_int == 0:
+                return
+            qid_str = str(current_qid_int)
+            if not current_q_type:
+                # 未找到【错题】或【推题】类型,则跳过
+                return
+            if not current_category:
+                # 未找到主观/客观分类,则默认为客观题
+                current_category_local = "客观题"
+            else:
+                current_category_local = current_category
+
+            self._append_question_json(
+                questions_list,
+                qid_str=qid_str,
+                q_type=current_q_type,
+                q_category=current_category_local,
+                kp_text=current_kp_text,
+                stem_lines=current_stem_lines,
+                ans_lines=current_ans_lines,
+                folder_name=current_folder_name
+            )
+
+        # 正则:题目头:“【错题】”或“【推题】”,可选地跟一个“【主观题】/【客观题】”
+        header_pattern = re.compile(r'^(.*?)(【错题】|【推题】)(.*)$')
+        category_pattern = re.compile(r'【(主观题|客观题)】')
+
+        for para in doc.paragraphs:
+            if not isinstance(para, Paragraph):
+                continue
+
+            raw_text = para.text or ""
+            p_text = raw_text.strip()
+
+            # 是否为题目头?
+            header_match = header_pattern.search(p_text)
+            if header_match:
+                # 刷新上一题
+                flush_current_question()
+
+                # 新题号
+                current_qid_int += 1
+                qid_str = str(current_qid_int)
+
+                # 为本题建立图片目录和 image_index
+                current_image_index, current_folder_name = self._build_image_index_for_question(
+                    all_image_blobs, now_ts, qid_str
+                )
+
+                # 重置题内容
+                current_stem_lines = []
+                current_ans_lines = []
+                current_kp_text = ""
+                in_stem = False
+                in_answer = False
+
+                # 题目类型:错题/推题
+                type_token = header_match.group(2)
+                if "错题" in type_token:
+                    current_q_type = "错题"
+                else:
+                    current_q_type = "推题"
+
+                # 题目分类:主观题/客观题(若存在)
+                cat_match = category_pattern.search(p_text)
+                if cat_match:
+                    current_category = cat_match.group(1)
+                else:
+                    current_category = None  # 若未写,则后续 flush 时默认客观题
+
+                self.log(f"检测到新题目: 题号={qid_str}, 类型={current_q_type}, 分类={current_category or '未显式标注'}")
+                # 题目头本身不计入题干,继续下一段
+                continue
+
+            # 若尚未进入任何题目(还没遇到第一个【错题】/【推题】),则跳过
+            if current_qid_int == 0:
+                continue
+
+            # 处理知识点、题干、答案区
+            # 优先检测【知识点】
+            if p_text.startswith("【知识点】"):
+                kp_raw = p_text.replace("【知识点】", "", 1).strip()
+                if kp_raw:
+                    current_kp_text = kp_raw
+                else:
+                    current_kp_text = ""
+                continue
+
+            # 再检测【题干】
+            if p_text.startswith("【题干】"):
+                in_stem = True
+                in_answer = False
+                after = p_text.replace("【题干】", "", 1).strip()
+                if after:
+                    mixed = self.get_para_mixed_content(para, current_image_index)
+                    if mixed.startswith("【题干】"):
+                        mixed = mixed.replace("【题干】", "", 1).strip()
+                    current_stem_lines.append(mixed)
+                continue
+
+            # 再检测【答案】
+            if p_text.startswith("【答案】"):
+                in_stem = False
+                in_answer = True
+                after = p_text.replace("【答案】", "", 1).strip()
+                if after:
+                    mixed = self.get_para_mixed_content(para, current_image_index)
+                    if mixed.startswith("【答案】"):
+                        mixed = mixed.replace("【答案】", "", 1).strip()
+                    current_ans_lines.append(mixed)
+                continue
+
+            # 其他普通段落,根据当前状态追加到题干或答案
+            mixed = self.get_para_mixed_content(para, current_image_index)
+            mixed = mixed.strip()
+            if not mixed:
+                continue
+
+            if in_answer:
+                current_ans_lines.append(mixed)
+            elif in_stem:
+                current_stem_lines.append(mixed)
+            else:
+                # 若尚未进入题干/答案区,但已经在题目下,可以选择默认进入题干
+                current_stem_lines.append(mixed)
+
+        # 文档结束后,刷新最后一题
+        flush_current_question()
+
+        return questions_list, None
diff --git a/backend/parsers/zujuanwang_file_parsing.py b/backend/parsers/zujuanwang_file_parsing.py
new file mode 100644 (file)
index 0000000..ba80ad3
--- /dev/null
@@ -0,0 +1,439 @@
+# ===================== zujuanwang_file_parsing.py =====================
+# Ported 1:1 from original py/question/file_parsing/zujuanwang_file_parsing.py
+# Changes:
+#  - no tkinter / messagebox (the format notice is shown by the web frontend);
+#  - WMF->PNG conversion: original used macOS `sips`; on Linux we try
+#    `soffice`/`convert` if available, otherwise keep the .wmf exactly like
+#    the original's fallback branch.
+import os
+import re
+import datetime
+import shutil
+import subprocess
+from docx import Document
+from docx.text.paragraph import Paragraph
+
+
+def _try_convert_wmf_to_png(wmf_path, png_path):
+    """Linux replacement for macOS `sips -s format png` (best effort)."""
+    try:
+        if shutil.which("soffice"):
+            subprocess.run(
+                ["soffice", "--headless", "--convert-to", "png", "--outdir",
+                 os.path.dirname(png_path), wmf_path],
+                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30)
+            converted = os.path.splitext(wmf_path)[0] + ".png"
+            if os.path.exists(converted) and os.path.abspath(converted) != os.path.abspath(png_path):
+                shutil.move(converted, png_path)
+            if os.path.exists(png_path):
+                return True
+        if shutil.which("convert"):
+            subprocess.run(["convert", wmf_path, png_path],
+                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30)
+            if os.path.exists(png_path):
+                return True
+    except Exception:
+        pass
+    return False
+
+
+class ZujuanwangParser:
+    def __init__(self, file_path, meta_data, log_callback=None, media_base_dir=None):
+        super().__init__()
+        self.file_path = file_path
+        self.meta_data = meta_data
+        self.log_callback = log_callback
+
+        self.media_base_dir = media_base_dir or os.path.abspath(
+            os.path.join(os.path.dirname(__file__), "..", "..", "Media")
+        )
+
+        if not os.path.exists(self.media_base_dir):
+            os.makedirs(self.media_base_dir)
+
+    def log(self, msg):
+        if self.log_callback:
+            self.log_callback(msg)
+
+    def get_para_mixed_content(self, para, image_index, trim_leading_text_length=0):
+        """
+        从段落中提取文本内容,包括图片占位符和下划线处理,并支持裁剪开头的纯文本。
+        trim_leading_text_length: 从段落开头裁剪掉的纯文本字符数。
+        """
+        content_parts = []
+        current_pure_text_pos = 0  # Tracks the length of pure text encountered so far (original text)
+        para_images_added = set()  # Record rIds of images already added for this paragraph to prevent duplicates
+
+        W_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
+        A_NAMESPACE = "http://schemas.openxmlformats.org/drawingml/2006/main"
+        WP_NAMESPACE = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
+        R_NAMESPACE_ATTR = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
+        V_NAMESPACE = "urn:schemas-microsoft-com:vml"
+
+        for run in para.runs:
+            original_run_text = run.text
+
+            # --- 1. Process Text Content with Trimming and Underline ---
+            processed_text_for_run = ""
+            if current_pure_text_pos < trim_leading_text_length:
+                remaining_trim_for_this_run = trim_leading_text_length - current_pure_text_pos
+
+                if len(original_run_text) <= remaining_trim_for_this_run:
+                    processed_text_for_run = ""
+                else:
+                    processed_text_for_run = original_run_text[remaining_trim_for_this_run:]
+            else:
+                processed_text_for_run = original_run_text
+
+            current_pure_text_pos += len(original_run_text)
+
+            # Apply underline logic to the processed text (after trimming)
+            has_underline = False
+            if run.underline is True:
+                has_underline = True
+            elif run.underline is not None and run.underline != False and run.underline != 0:
+                has_underline = True
+
+            if has_underline and processed_text_for_run:
+                temp_text_with_underline = ""
+                for char in processed_text_for_run:
+                    if char == ' ':
+                        temp_text_with_underline += '_'
+                    elif char == '\t':
+                        temp_text_with_underline += '____'
+                    elif char == '\u00A0':  # Non-breaking space
+                        temp_text_with_underline += '_'
+                    else:
+                        temp_text_with_underline += char
+                processed_text_for_run = temp_text_with_underline
+
+            if processed_text_for_run:
+                content_parts.append(processed_text_for_run)
+
+            # --- 2. Process Image Content (Independent of text trimming) ---
+
+            # A. Find <w:drawing> elements
+            for drawing in run._element.findall(f'.//{{{W_NAMESPACE}}}drawing'):
+                for blip in drawing.findall(f'.//{{{A_NAMESPACE}}}blip'):
+                    r_id = blip.get(f'{{{R_NAMESPACE_ATTR}}}embed') or blip.get(f'{{{R_NAMESPACE_ATTR}}}id')
+                    if r_id and r_id in image_index and r_id not in para_images_added:
+                        path = image_index[r_id]
+                        extent_element = drawing.find(f'.//{{{WP_NAMESPACE}}}extent')
+
+                        width_emu = 0
+                        height_emu = 0
+                        if extent_element is not None:
+                            width_emu = int(extent_element.get('cx', '0'))
+                            height_emu = int(extent_element.get('cy', '0'))
+
+                        if width_emu and height_emu:
+                            content_parts.append(f" ![]( {path}?w={width_emu}&h={height_emu} ) ")
+                        else:
+                            content_parts.append(f" ![]( {path} ) ")
+                        para_images_added.add(r_id)
+
+            # B. Find <w:pict> or <v:imagedata> elements (Critical for X, Y, Z images)
+            for imagedata in run._element.findall(f'.//{{{V_NAMESPACE}}}imagedata'):
+                r_id = imagedata.get(f'{{{R_NAMESPACE_ATTR}}}id') or imagedata.get(f'{{{R_NAMESPACE_ATTR}}}relid')
+                if r_id and r_id in image_index and r_id not in para_images_added:
+                    path = image_index[r_id]
+                    content_parts.append(f" ![]( {path} ) ")
+                    para_images_added.add(r_id)
+
+        return "".join(content_parts).strip()
+
+    def parse(self):
+        if not os.path.exists(self.file_path):
+            return None, "文件不存在"
+
+        try:
+            doc = Document(self.file_path)
+        except Exception as e:
+            return None, f"Word打开失败: {e}"
+
+        all_image_blobs = {}
+        for rel in doc.part.rels.values():
+            if "image" in rel.target_ref:
+                img_name = os.path.basename(rel.target_ref)
+                all_image_blobs[rel.rId] = (img_name, rel.target_part.blob)
+
+        # -------------------------------------------------------
+        # 第一阶段:预解析所有答案区域,收集答案内容和知识点
+        # -------------------------------------------------------
+        self.log("正在预解析答案区域,收集答案内容和知识点...")
+        answers_info = {}  # {qid: {"content_paras": [para_objects], "knowledge_points": "..."}}
+
+        ans_start_pattern = re.compile(r'^【答案】(\d+(?:\.\d+)*)\s*(.*)')
+
+        current_ans_qid = None
+        current_ans_content_paras = []
+        current_knowledge_points = []
+
+        end_of_doc_summary_pattern = re.compile(r'^《.*》参考答案$')
+
+        for para in doc.paragraphs:
+            p_text = para.text.strip()
+
+            if end_of_doc_summary_pattern.match(p_text):
+                if current_ans_qid:
+                    answers_info[current_ans_qid] = {
+                        "content_paras": list(current_ans_content_paras),
+                        "knowledge_points": ", ".join(current_knowledge_points).strip()
+                    }
+                current_ans_qid = None
+                current_ans_content_paras = []
+                current_knowledge_points = []
+                continue
+
+            ans_match = ans_start_pattern.match(p_text)
+
+            if ans_match:
+                if current_ans_qid:
+                    answers_info[current_ans_qid] = {
+                        "content_paras": list(current_ans_content_paras),
+                        "knowledge_points": ", ".join(current_knowledge_points).strip()
+                    }
+
+                current_ans_qid = ans_match.group(1)
+
+                current_ans_content_paras = [para]
+                current_knowledge_points = []
+
+                kn_match = re.search(r'【知识点】\s*(.*)', p_text)
+                if kn_match:
+                    kps = [kp.strip() for kp in kn_match.group(1).split(',') if kp.strip()]
+                    current_knowledge_points.extend(kps)
+
+            elif current_ans_qid:
+                if re.match(r'^(【错题】|【推题】)\s*(?:【(主观题|客观题)】\s*)?\d+(?:\.\d+)*', p_text):
+                    if current_ans_qid:
+                        answers_info[current_ans_qid] = {
+                            "content_paras": list(current_ans_content_paras),
+                            "knowledge_points": ", ".join(current_knowledge_points).strip()
+                        }
+                    current_ans_qid = None
+                    current_ans_content_paras = []
+                    current_knowledge_points = []
+                    continue
+
+                current_ans_content_paras.append(para)
+
+                kn_match = re.search(r'【知识点】\s*(.*)', p_text)
+                if kn_match:
+                    kps = [kp.strip() for kp in kn_match.group(1).split(',') if kp.strip()]
+                    current_knowledge_points.extend(kps)
+
+        if current_ans_qid:
+            answers_info[current_ans_qid] = {
+                "content_paras": list(current_ans_content_paras),
+                "knowledge_points": ", ".join(current_knowledge_points).strip()
+            }
+
+        self.log(f"答案预解析完成。找到 {len(answers_info)} 个答案块。")
+
+        # -------------------------------------------------------
+        # 第二阶段:遍历文档,提取题目信息
+        # -------------------------------------------------------
+        self.log("开始拆解题目主体...")
+        questions_list = []
+        now_ts = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
+
+        current_qid = None
+        current_q_type = None  # "错题" or "推题"
+        current_q_category = None  # "主观题" or "客观题"
+        collecting_q_body = False
+        q_temp_paras = []
+        current_image_folder = ""
+        this_q_image_index = {}
+
+        question_start_pattern = re.compile(r'^(【错题】|【推题】)\s*(?:【(主观题|客观题)】\s*)?(\d+(?:\.\d+)*)\s*(.*)')
+
+        for para in doc.paragraphs:
+            p_text = para.text.strip()
+
+            if end_of_doc_summary_pattern.match(p_text):
+                if collecting_q_body and current_qid and q_temp_paras:
+                    self._process_and_append_question(
+                        questions_list,
+                        current_qid,
+                        q_temp_paras,
+                        answers_info.get(current_qid, {"content_paras": [], "knowledge_points": ""}),
+                        this_q_image_index,
+                        now_ts,
+                        current_q_type,
+                        current_q_category,
+                        current_image_folder
+                    )
+                current_qid = None
+                current_q_type = None
+                current_q_category = None
+                collecting_q_body = False
+                q_temp_paras = []
+                continue
+
+            q_start_match = question_start_pattern.match(p_text)
+
+            if q_start_match:
+                self.log(f"--- Detected NEW QUESTION START: '{p_text}' ---")
+
+                if collecting_q_body and current_qid and q_temp_paras:
+                    self._process_and_append_question(
+                        questions_list,
+                        current_qid,
+                        q_temp_paras,
+                        answers_info.get(current_qid, {"content_paras": [], "knowledge_points": ""}),
+                        this_q_image_index,
+                        now_ts,
+                        current_q_type,
+                        current_q_category,
+                        current_image_folder
+                    )
+
+                current_q_type_tag = q_start_match.group(1)
+                current_q_category_tag = q_start_match.group(2)
+                current_qid = q_start_match.group(3)
+
+                current_q_type = current_q_type_tag.strip('【】')
+                current_q_category = current_q_category_tag.strip('【】') if current_q_category_tag else ""
+
+                folder_name = f"{now_ts}-{current_qid}"
+                current_image_folder = folder_name
+                target_media_dir = os.path.join(self.media_base_dir, folder_name)
+                os.makedirs(target_media_dir, exist_ok=True)
+
+                this_q_image_index = {}
+                for rid, (name, blob) in all_image_blobs.items():
+                    base_name, ext = os.path.splitext(name)
+                    img_save_path = os.path.join(target_media_dir, name)
+
+                    if ext.lower() == '.wmf':
+                        # 1. 写原始 wmf
+                        with open(img_save_path, "wb") as f:
+                            f.write(blob)
+
+                        png_name = base_name + ".png"
+                        png_save_path = os.path.join(target_media_dir, png_name)
+
+                        # 2. 尝试转换(Linux 下尝试 soffice/convert,失败回退 wmf)
+                        converted = _try_convert_wmf_to_png(img_save_path, png_save_path)
+
+                        # 3. 如果 png 生成成功,索引必须指向 png
+                        if converted and os.path.exists(png_save_path):
+                            this_q_image_index[rid] = os.path.join("Media", folder_name, png_name).replace("\\", "/")
+                        else:
+                            this_q_image_index[rid] = os.path.join("Media", folder_name, name).replace("\\", "/")
+                    else:
+                        with open(img_save_path, "wb") as f:
+                            f.write(blob)
+                        this_q_image_index[rid] = os.path.join("Media", folder_name, name).replace("\\", "/")
+
+                q_temp_paras = [para]
+                collecting_q_body = True
+
+                self.log(f"解析到题目: {current_qid} (类型: {current_q_type}, 分类: {current_q_category})")
+
+            elif collecting_q_body:
+                ans_match = re.match(r'^【答案】(\d+(?:\.\d+)*)', p_text)
+                if ans_match:
+                    self.log(f"--- Stopping collection for QID {current_qid}, detected answer tag: '{p_text}' ---")
+                    if current_qid and q_temp_paras:
+                        self._process_and_append_question(
+                            questions_list,
+                            current_qid,
+                            q_temp_paras,
+                            answers_info.get(current_qid, {"content_paras": [], "knowledge_points": ""}),
+                            this_q_image_index,
+                            now_ts,
+                            current_q_type,
+                            current_q_category,
+                            current_image_folder
+                        )
+                    current_qid = None
+                    current_q_type = None
+                    current_q_category = None
+                    collecting_q_body = False
+                    q_temp_paras = []
+                    continue
+
+                q_temp_paras.append(para)
+
+        if collecting_q_body and current_qid and q_temp_paras:
+            self._process_and_append_question(
+                questions_list,
+                current_qid,
+                q_temp_paras,
+                answers_info.get(current_qid, {"content_paras": [], "knowledge_points": ""}),
+                this_q_image_index,
+                now_ts,
+                current_q_type,
+                current_q_category,
+                current_image_folder
+            )
+
+        return questions_list, None
+
+    def _process_and_append_question(self, q_list, qid, q_paras_raw, ans_data, img_idx, ts, q_type, q_category, img_folder_name):
+        question_content_parts = []
+
+        if q_paras_raw:
+            first_para = q_paras_raw[0]
+            p_text = first_para.text
+            prefix_match = re.match(r'^(【错题】|【推题】)\s*(?:【(主观题|客观题)】\s*)?(\d+(?:\.\d+)*)\s*', p_text)
+            trim_length = 0
+            if prefix_match:
+                trim_length = len(prefix_match.group(0))
+
+            content_after_prefix = self.get_para_mixed_content(first_para, img_idx, trim_leading_text_length=trim_length)
+            if content_after_prefix:
+                question_content_parts.append(content_after_prefix)
+
+        for p_idx, p in enumerate(q_paras_raw):
+            if p_idx == 0:
+                continue
+            content = self.get_para_mixed_content(p, img_idx)
+            if content:
+                question_content_parts.append(content)
+
+        question_content = "\n".join(filter(None, question_content_parts)).strip()
+
+        answer_content_parts = []
+        answer_paras = ans_data.get("content_paras", [])
+        knowledge_points = ans_data.get("knowledge_points", "")
+
+        if answer_paras:
+            first_ans_para = answer_paras[0]
+            ans_text = first_ans_para.text
+            ans_prefix_match = re.match(r'^【答案】\d+(?:\.\d+)*\s*', ans_text)
+            ans_trim_length = 0
+            if ans_prefix_match:
+                ans_trim_length = len(ans_prefix_match.group(0))
+
+            content_after_ans_prefix = self.get_para_mixed_content(first_ans_para, img_idx, trim_leading_text_length=ans_trim_length)
+            if content_after_ans_prefix:
+                answer_content_parts.append(content_after_ans_prefix)
+
+            for p_idx, p in enumerate(answer_paras):
+                if p_idx == 0:
+                    continue
+                content = self.get_para_mixed_content(p, img_idx)
+                if content:
+                    answer_content_parts.append(content)
+
+        answer_content = "\n".join(filter(None, answer_content_parts)).strip()
+
+        q_list.append({
+            "题号": qid,
+            "科目": self.meta_data["subject"],
+            "题目来源": "组卷网",  # Fixed as "组卷网"
+            "题目类型": q_type,  # "错题" or "推题"
+            "输入时间": self.meta_data["import_time"],
+            "答题时间": "",  # Empty
+            "题目知识点": knowledge_points,
+            "题目分类": q_category,  # "主观题" or "客观题"
+            "题干内容": question_content,
+            "答案解析": answer_content,
+            "学期": self.meta_data.get("term"),
+            "阶段": self.meta_data.get("stage"),
+            "题目出处": self.meta_data.get("origin"),
+            "图片目录": os.path.join("Media", img_folder_name).replace("\\", "/")
+        })
diff --git a/custom/conf/app.ini b/custom/conf/app.ini
deleted file mode 100644 (file)
index b3e5be5..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-[server]
-LOCAL_ROOT_URL = http://localhost:4999/
diff --git a/database.py b/database.py
deleted file mode 100644 (file)
index ec5e7c7..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-import sqlite3
-import os
-
-# 数据库文件的绝对路径
-DB_PATH = os.path.join(os.path.dirname(__file__), 's', 'studybase.db')
-
-def get_db_connection():
-    """获取数据库连接,并将结果集设置为字典模式(方便前端调用)"""
-    conn = sqlite3.connect(DB_PATH)
-    # 这行代码能让你通过 row['column_name'] 访问数据,而不是 row[0]
-    conn.row_factory = sqlite3.Row
-    return conn
-
-def query_db(query, args=(), one=False):
-    """通用的查询函数"""
-    conn = get_db_connection()
-    cur = conn.execute(query, args)
-    rv = cur.fetchall()
-    conn.close()
-    return (rv[0] if rv else None) if one else rv
-
-def execute_db(query, args=()):
-    """通用的增删改函数"""
-    conn = get_db_connection()
-    cur = conn.execute(query, args)
-    conn.commit()
-    conn.close()
-    return cur
diff --git a/frontend/dist/assets/AnalysisChapters-DTn9LWoa.css b/frontend/dist/assets/AnalysisChapters-DTn9LWoa.css
new file mode 100644 (file)
index 0000000..31284c5
--- /dev/null
@@ -0,0 +1 @@
+.center[data-v-ecc11515]{text-align:center}.link[data-v-ecc11515]{color:#1890ff;cursor:pointer}
diff --git a/frontend/dist/assets/AnalysisChapters-rXnOtC4r.js b/frontend/dist/assets/AnalysisChapters-rXnOtC4r.js
new file mode 100644 (file)
index 0000000..a4b2eb3
--- /dev/null
@@ -0,0 +1 @@
+import{_ as $,b as A,c as a,a as e,w as p,v as E,F as m,r as h,g as f,d as o,l as B,o as u,t as l}from"./index-Dc_QwQl6.js";import{a as U,b as I,d as M}from"./api-gDOaCrl1.js";const F={class:"page-header"},K={class:"filter-panel"},G={class:"row"},L=["value"],N={class:"row"},P={class:"content-area"},Q={class:"card",style:{padding:"0",overflow:"auto"}},R={class:"data-table"},z=["onClick"],H={class:"center"},J={class:"center"},O={class:"center"},T=["onClick"],W=["onClick"],X=["onClick"],Y=["onClick"],Z={__name:"AnalysisChapters",setup(tt){const g=B(),x=o([]),_=o("全部"),v=o(""),y=o(""),k=o(""),b=o([]),r=o("not_correct_questions"),i=o("DESC"),D=[{key:"id",label:"ID"},{key:"subject",label:"科目"},{key:"volume_name",label:"册"},{key:"chapter_desc",label:"章节"},{key:"total_wrong_questions",label:"错题数量"},{key:"correct_questions",label:"已正确题目数"},{key:"not_correct_questions",label:"尚未正确题目数"}];function V(n){return n!==r.value?"":i.value==="ASC"?" ▲":" ▼"}function j(n){r.value===n?i.value=i.value==="ASC"?"DESC":"ASC":(r.value=n,i.value="ASC"),c()}function q(){return{subject:_.value,correct_count:v.value,not_correct_count:y.value,chapter_desc:k.value,sort_column:r.value,sort_direction:i.value}}async function c(){const n=await I("/analysis/chapters",q());b.value=n.rows}function w(n){g.push({path:"/analysis/knowledge",query:{chapter_id:n}})}function C(n,s){const t={chapter_id:n};s&&(t.right_times=s),g.push({path:"/questions",query:t})}async function S(){b.value.length&&await M("/analysis/chapters/export-excel",q())}return A(async()=>{const n=await U("/meta/subjects");x.value=n.subjects,await c()}),(n,s)=>(u(),a("div",null,[e("div",F,[s[5]||(s[5]=e("span",{class:"title"},"章节分析",-1)),e("button",{class:"btn",style:{"margin-left":"16px"},onClick:s[0]||(s[0]=t=>n.$router.push("/subject-portal"))},"返回科目首页"),e("button",{class:"btn orange",onClick:S},"📥 导出Excel")]),e("div",K,[e("div",G,[s[7]||(s[7]=e("label",null,"科目:",-1)),p(e("select",{"onUpdate:modelValue":s[1]||(s[1]=t=>_.value=t),onChange:c},[s[6]||(s[6]=e("option",null,"全部",-1)),(u(!0),a(m,null,h(x.value,t=>(u(),a("option",{key:t,value:t},l(t),9,L))),128))],544),[[E,_.value]]),s[8]||(s[8]=e("label",{style:{"margin-left":"10px"}},"已正确题目数:",-1)),p(e("input",{type:"text","onUpdate:modelValue":s[2]||(s[2]=t=>v.value=t),style:{width:"70px"}},null,512),[[f,v.value]]),s[9]||(s[9]=e("label",{style:{"margin-left":"10px"}},"尚未正确题目数:",-1)),p(e("input",{type:"text","onUpdate:modelValue":s[3]||(s[3]=t=>y.value=t),style:{width:"70px"}},null,512),[[f,y.value]])]),e("div",N,[s[10]||(s[10]=e("label",null,"章节名称:",-1)),p(e("input",{type:"text","onUpdate:modelValue":s[4]||(s[4]=t=>k.value=t),style:{width:"340px"}},null,512),[[f,k.value]]),e("button",{class:"btn primary",style:{"margin-left":"auto"},onClick:c},"🔍 开始搜索")])]),e("div",P,[e("div",Q,[e("table",R,[e("thead",null,[e("tr",null,[(u(),a(m,null,h(D,t=>e("th",{key:t.key,onClick:d=>j(t.key)},l(t.label)+l(V(t.key)),9,z)),64))])]),e("tbody",null,[(u(!0),a(m,null,h(b.value,t=>(u(),a("tr",{key:t.chapter_id},[e("td",H,l(t.chapter_id),1),e("td",J,l(t.subject),1),e("td",O,l(t.volume_name),1),e("td",{class:"link",onClick:d=>w(t.chapter_id)},l(t.chapter_desc),9,T),e("td",{class:"center link",onClick:d=>C(t.chapter_id,null)},l(t.total_wrong_questions),9,W),e("td",{class:"center link",onClick:d=>C(t.chapter_id,">=1")},l(t.correct_questions),9,X),e("td",{class:"center link",onClick:d=>C(t.chapter_id,"=0")},l(t.not_correct_questions),9,Y)]))),128))])])])])]))}},nt=$(Z,[["__scopeId","data-v-ecc11515"]]);export{nt as default};
diff --git a/frontend/dist/assets/AnalysisKnowledge-5QPt6Ak_.css b/frontend/dist/assets/AnalysisKnowledge-5QPt6Ak_.css
new file mode 100644 (file)
index 0000000..90146ae
--- /dev/null
@@ -0,0 +1 @@
+.center[data-v-2b42a9e8]{text-align:center}.link[data-v-2b42a9e8]{color:#1890ff;cursor:pointer}
diff --git a/frontend/dist/assets/AnalysisKnowledge-D1yJ4mIN.js b/frontend/dist/assets/AnalysisKnowledge-D1yJ4mIN.js
new file mode 100644 (file)
index 0000000..a20079e
--- /dev/null
@@ -0,0 +1 @@
+import{_ as I,b as $,c as a,a as e,w as p,v as E,F as m,r as g,g as x,f as B,t as s,i as U,j as K,d as o,l as M,o as u}from"./index-Dc_QwQl6.js";import{a as N,b as R,d as z}from"./api-gDOaCrl1.js";const G={class:"page-header"},L={class:"filter-panel"},P={class:"row"},Q=["value"],T={class:"row"},H={key:0,class:"row",style:{color:"#1890FF","font-size":"13px"}},J={class:"content-area"},O={class:"card",style:{padding:"0",overflow:"auto"}},W={class:"data-table"},X=["onClick"],Y={class:"center"},Z={class:"center"},tt=["onClick"],et=["onClick"],lt=["onClick"],nt={__name:"AnalysisKnowledge",setup(st){const h=K(),V=M(),q=o([]),v=o("全部"),_=o(""),k=o(""),y=o(""),b=o([]),i=o(null),d=o("not_correct_questions"),r=o("DESC"),j=[{key:"id",label:"ID"},{key:"subject",label:"科目"},{key:"chapter_info",label:"章节"},{key:"knowledge_content",label:"知识点"},{key:"total_questions",label:"试题数量"},{key:"correct_questions",label:"已正确题目数"},{key:"not_correct_questions",label:"尚未正确题目数"}];function D(n){return n!==d.value?"":r.value==="ASC"?" ▲":" ▼"}function S(n){d.value===n?r.value=r.value==="ASC"?"DESC":"ASC":(d.value=n,r.value="ASC"),c()}function w(){return{subject:v.value,correct_count:_.value,not_correct_count:k.value,kp_content:y.value,chapter_id_filter:i.value,sort_column:d.value,sort_direction:r.value}}async function c(){const n=await R("/analysis/knowledge",w());b.value=n.rows}function A(){i.value=null,c()}function f(n,l){const t={kp_id:n};l&&(t.right_times=l),V.push({path:"/questions",query:t})}async function F(){b.value.length&&await z("/analysis/knowledge/export-excel",w())}return $(async()=>{h.query.chapter_id&&(i.value=parseInt(h.query.chapter_id));const n=await N("/meta/subjects");q.value=n.subjects,await c()}),(n,l)=>(u(),a("div",null,[e("div",G,[l[5]||(l[5]=e("span",{class:"title"},"科目知识列表",-1)),e("button",{class:"btn",style:{"margin-left":"16px"},onClick:l[0]||(l[0]=t=>n.$router.push("/subject-portal"))},"返回科目首页"),e("button",{class:"btn orange",onClick:F},"📥 导出Excel")]),e("div",L,[e("div",P,[l[7]||(l[7]=e("label",null,"科目:",-1)),p(e("select",{"onUpdate:modelValue":l[1]||(l[1]=t=>v.value=t),onChange:c},[l[6]||(l[6]=e("option",null,"全部",-1)),(u(!0),a(m,null,g(q.value,t=>(u(),a("option",{key:t,value:t},s(t),9,Q))),128))],544),[[E,v.value]]),l[8]||(l[8]=e("label",{style:{"margin-left":"10px"}},"已正确题目数:",-1)),p(e("input",{type:"text","onUpdate:modelValue":l[2]||(l[2]=t=>_.value=t),style:{width:"70px"}},null,512),[[x,_.value]]),l[9]||(l[9]=e("label",{style:{"margin-left":"10px"}},"尚未正确题目数:",-1)),p(e("input",{type:"text","onUpdate:modelValue":l[3]||(l[3]=t=>k.value=t),style:{width:"70px"}},null,512),[[x,k.value]])]),e("div",T,[l[10]||(l[10]=e("label",null,"知识点:",-1)),p(e("input",{type:"text","onUpdate:modelValue":l[4]||(l[4]=t=>y.value=t),style:{width:"340px"}},null,512),[[x,y.value]]),e("button",{class:"btn primary",style:{"margin-left":"auto"},onClick:c},"🔍 开始搜索")]),i.value?(u(),a("div",H,[B(" 当前按章节过滤:章节ID "+s(i.value)+" ",1),e("button",{class:"btn small",onClick:A},"清除章节过滤")])):U("",!0)]),e("div",J,[e("div",O,[e("table",W,[e("thead",null,[e("tr",null,[(u(),a(m,null,g(j,t=>e("th",{key:t.key,onClick:C=>S(t.key)},s(t.label)+s(D(t.key)),9,X)),64))])]),e("tbody",null,[(u(!0),a(m,null,g(b.value,t=>(u(),a("tr",{key:t.kp_id},[e("td",Y,s(t.kp_id),1),e("td",Z,s(t.subject),1),e("td",null,s(t.chapter_info),1),e("td",null,s(t.knowledge_content),1),e("td",{class:"center link",onClick:C=>f(t.kp_id,null)},s(t.total_questions),9,tt),e("td",{class:"center link",onClick:C=>f(t.kp_id,">=1")},s(t.correct_questions),9,et),e("td",{class:"center link",onClick:C=>f(t.kp_id,"=0")},s(t.not_correct_questions),9,lt)]))),128))])])])])]))}},ut=I(nt,[["__scopeId","data-v-2b42a9e8"]]);export{ut as default};
diff --git a/frontend/dist/assets/AnalysisPending-BfBXB5e_.js b/frontend/dist/assets/AnalysisPending-BfBXB5e_.js
new file mode 100644 (file)
index 0000000..7c51a5e
--- /dev/null
@@ -0,0 +1 @@
+import{_ as V,b as j,c as s,a as t,w as f,v as U,F as c,r as v,h as C,f as h,t as i,i as B,d as y,x as E,l as P,o as a}from"./index-Dc_QwQl6.js";import{a as w}from"./api-gDOaCrl1.js";const q={class:"page-header"},L={class:"filter-panel"},M=["value"],N={class:"content-area"},T={key:0,class:"card",style:{overflow:"auto",padding:"0"}},A={class:"data-table",style:{"min-width":"760px"}},D={class:"stage-cell"},F=["onClick"],R=["onClick"],G={class:"total-cell"},I=["onClick"],z={class:"paper-cell"},H=["onClick"],J=["onClick"],K={__name:"AnalysisPending",setup(O){const x=P(),g=y([]),p=y(""),r=y("legacy"),o=y(null),S=E(()=>o.value?["阶段",...o.value.subjects.map(u=>u.name),"总数"]:[]);async function b(){const u=new URLSearchParams({term:p.value,mode:r.value});o.value=await w("/analysis/pending?"+u.toString())}function m(u,l,e){const n={question_nature:"错题",term:p.value,status:"全部",archive_status:"全部"},d=o.value.subjects.find(k=>k.id===u);d&&(n.subject=d.name);const _=o.value.stages.find(k=>k.id===l);_&&(n.stage=_.name),e!=null&&(n.paper_count=e),r.value==="legacy"&&(n.right_times="=0"),x.push({path:"/questions",query:n})}async function $(){if(!o.value)return;const l="/StudyBase/api/analysis/pending/export-excel?"+new URLSearchParams({term:p.value,mode:r.value}).toString(),e=document.createElement("a");e.href=l,e.download="",document.body.appendChild(e),e.click(),e.remove()}return j(async()=>{const u=await w("/analysis/pending?mode=legacy");g.value=u.terms,p.value=u.current_term,o.value=u}),(u,l)=>(a(),s("div",null,[t("div",q,[l[5]||(l[5]=t("span",{class:"title"},"遗留分析汇总",-1)),t("button",{class:"btn",style:{"margin-left":"16px"},onClick:l[0]||(l[0]=e=>u.$router.push("/subject-portal"))},"返回科目首页"),t("button",{class:"btn orange",onClick:$},"📥 导出Excel")]),t("div",L,[l[8]||(l[8]=t("label",null,"选择学期:",-1)),f(t("select",{"onUpdate:modelValue":l[1]||(l[1]=e=>p.value=e),onChange:b},[(a(!0),s(c,null,v(g.value,e=>(a(),s("option",{key:e,value:e},i(e),9,M))),128))],544),[[U,p.value]]),l[9]||(l[9]=t("label",{style:{"margin-left":"25px"}},"统计范围:",-1)),t("label",null,[f(t("input",{type:"radio",value:"legacy","onUpdate:modelValue":l[2]||(l[2]=e=>r.value=e),onChange:b},null,544),[[C,r.value]]),l[6]||(l[6]=h(" 遗留错题",-1))]),t("label",null,[f(t("input",{type:"radio",value:"all","onUpdate:modelValue":l[3]||(l[3]=e=>r.value=e),onChange:b},null,544),[[C,r.value]]),l[7]||(l[7]=h(" 全部错题",-1))])]),t("div",N,[o.value?(a(),s("div",T,[t("table",A,[t("thead",null,[t("tr",null,[(a(!0),s(c,null,v(S.value,e=>(a(),s("th",{key:e},i(e),1))),128))])]),t("tbody",null,[(a(!0),s(c,null,v(o.value.stage_rows,e=>(a(),s("tr",{key:e.label},[t("td",D,i(e.label),1),(a(!0),s(c,null,v(e.cells,(n,d)=>(a(),s("td",{key:d,class:"link",onClick:_=>m(n.sub_id,n.st_id,null)},i(n.count),9,F))),128)),t("td",{class:"link bold",onClick:n=>m(null,e.cells[0]?e.cells[0].st_id:null,null)},i(e.sum),9,R)]))),128)),t("tr",null,[t("td",G,i(o.value.total_row.label),1),(a(!0),s(c,null,v(o.value.total_row.cells,(e,n)=>(a(),s("td",{key:n,class:"link total-cell bold",onClick:d=>m(e.sub_id,null,null)},i(e.count),9,I))),128)),t("td",{class:"link total-cell bold",onClick:l[4]||(l[4]=e=>m(null,null,null))},i(o.value.total_row.sum),1)]),(a(!0),s(c,null,v(o.value.paper_rows,e=>(a(),s("tr",{key:e.label},[t("td",z,i(e.label),1),(a(!0),s(c,null,v(e.cells,(n,d)=>(a(),s("td",{key:d,class:"link",onClick:_=>m(n.sub_id,null,e.paper_count)},i(n.count),9,H))),128)),t("td",{class:"link bold",onClick:n=>m(null,null,e.paper_count)},i(e.sum),9,J)]))),128))])])])):B("",!0)])]))}},X=V(K,[["__scopeId","data-v-9df751f3"]]);export{X as default};
diff --git a/frontend/dist/assets/AnalysisPending-C5SzviRH.css b/frontend/dist/assets/AnalysisPending-C5SzviRH.css
new file mode 100644 (file)
index 0000000..c59c5ec
--- /dev/null
@@ -0,0 +1 @@
+.link[data-v-9df751f3]{color:#1890ff;cursor:pointer;text-align:center}.bold[data-v-9df751f3]{font-weight:700}.stage-cell[data-v-9df751f3]{background:#f9f0ff}.total-cell[data-v-9df751f3]{background:#fff7e6;font-weight:700}.paper-cell[data-v-9df751f3]{background:#f6ffed}
diff --git a/frontend/dist/assets/FileImport-B8PhhnGN.css b/frontend/dist/assets/FileImport-B8PhhnGN.css
new file mode 100644 (file)
index 0000000..ea01dbb
--- /dev/null
@@ -0,0 +1 @@
+.modal-mask[data-v-a85a16b8]{position:fixed;top:0;right:0;bottom:0;left:0;background:#00000059;display:flex;align-items:center;justify-content:center;z-index:100}.modal[data-v-a85a16b8]{background:#f0f2f5;width:760px;max-height:90vh;overflow:auto;border-radius:6px;box-shadow:0 4px 20px #00000040}.modal-header[data-v-a85a16b8]{background:#4a90e2;color:#000;font-size:17px;font-weight:700;padding:10px 16px}.modal-body[data-v-a85a16b8]{padding:14px 18px}.modal-footer[data-v-a85a16b8]{display:flex;justify-content:space-between;padding:12px 40px 18px}.form-row[data-v-a85a16b8]{display:flex;align-items:center;gap:8px;margin:8px 0}.form-row label[data-v-a85a16b8]{white-space:nowrap;font-size:13px}.section-label[data-v-a85a16b8]{font-weight:700;font-size:13px;margin:12px 0 4px}.toolbar[data-v-a85a16b8]{margin-bottom:4px}
diff --git a/frontend/dist/assets/FileImport-BF8-5MOQ.js b/frontend/dist/assets/FileImport-BF8-5MOQ.js
new file mode 100644 (file)
index 0000000..0472a9c
--- /dev/null
@@ -0,0 +1,21 @@
+import{_ as ae,b as se,c as i,a as l,w as p,v as x,F as $,r as T,t as g,p as ne,g as B,i as oe,d as s,q as ue,l as ie,o as r}from"./index-Dc_QwQl6.js";import{a as re,t as f,c as Z,b as H}from"./api-gDOaCrl1.js";const ve={class:"content-area"},de={class:"card"},pe={style:{display:"grid","grid-template-columns":"110px 240px 110px 240px",gap:"10px 14px","align-items":"center"}},me=["value"],fe=["value"],ce=["value"],ge=["value"],ye=["value"],be=["value"],we={class:"card",style:{display:"flex",gap:"18px","align-items":"center","flex-wrap":"wrap"}},ke=["disabled"],xe=["disabled"],Se=["disabled"],Ce={key:0,class:"modal-mask"},$e={class:"modal"},Te={class:"modal-body"},Ve={class:"form-row"},De={class:"form-row"},Ue={class:"form-row"},qe={class:"form-row"},Ie={class:"toolbar"},je={class:"toolbar"},Fe=`智学网文件解析请注意以下格式:
+!!请确定组卷网下载文件包括了【知识点】信息!!
+1. 题目开始格式:
+   【错题】/【推题】【客观题】/【主观题】[题号]
+    
+    例如:
+   【错题】【客观题】1.豌豆(雌雄同花)和玉米(雌雄同株异花)都是良好的遗传学材料。利用这两种材料进行遗传学实验时,操作正确的是(  )
+
+2. 答案开始格式:
+   【答案】[题号]
+   
+   例如:
+   【答案】2.B
+   【知识点】孟德尔一对相对性状的杂交实验
+   【分析】孟德尔一对相对性状的杂交试验中,实现3:1的分离比必须同时满足的条件是:F1形成的配子数目相等且生活力相同,雌、雄配子结合的机会相等;F2不同的基因型的个体的存活率相等;等位基因间的显隐性关系是完全的;观察的子代样本数目足够多。
+
+请点击“确定”开始解析。
+`,Me={__name:"FileImport",setup(Oe){const K=ie(),v=s({subject:{options:[],default:""},source:{options:[],default:""},term:{options:[],default:""},stage:{options:[],default:""},origin:{options:[],default:""},type:{options:["错题","推题","补充提高"],default:"错题"}}),q=s(""),V=s(""),I=s("错题"),j=s(""),F=s(""),M=s(""),O=s(""),E=s(null),D=s(!1),m=s(null),N=s(!1),S=s(""),U=s("错题"),y=s(""),b=s(""),w=s(""),k=s(""),P=s(null),_=s(null),C=s(0),R=s("");function u(a){O.value+=`[${new Date().toTimeString().slice(0,8)}] ${a}
+`,ue(()=>{E.value&&(E.value.scrollTop=E.value.scrollHeight)})}function A(){return{subject:q.value,source:V.value,type:I.value,term:j.value,stage:F.value,origin:M.value}}function Q(){const a=A();if(!Object.values(a).every(Boolean)){f("请确保所有导入参数都已选择!");return}if(V.value==="组卷网"&&!Z(Fe))return;const e=document.createElement("input");e.type="file",e.accept=".docx",e.onchange=async()=>{const t=e.files[0];if(!t)return;O.value="",D.value=!0,m.value=null;const n=new FormData;n.append("file",t);for(const[d,o]of Object.entries(a))n.append(d,o);try{const o=await(await fetch("/StudyBase/api/import/parse",{method:"POST",body:n})).json();for(const J of o.log||[])u(J.replace(/^\[\d{2}:\d{2}:\d{2}\]\s?/,""));o.error?m.value=null:o.questions?m.value=o.questions:m.value=null}catch(d){u(`解析过程中发生未预期错误: ${d.message}`),m.value=null}finally{D.value=!1}},e.click()}function X(){O.value="",u("正在手动添加试题..."),N.value=!0,S.value="",U.value="错题",y.value="",b.value="",w.value="",k.value="",C.value=0,R.value=new Date().toISOString().replace(/[-:TZ.]/g,"").slice(0,14)}function h(){const a=new Date,e=t=>String(t).padStart(2,"0");b.value=`${a.getFullYear()}-${e(a.getMonth()+1)}-${e(a.getDate())}`}async function ee(){if(!S.value){f("请先选择题目分类(客观题或主观题)。");return}if(!y.value.trim()){f("题目知识点为必填项。");return}if(!w.value.trim()){f("题干内容不能为空。");return}if(!k.value.trim()){f("答案解析不能为空。");return}const a=C.value+1;try{const e=await H("/import/manual-add",{category:S.value,q_type:U.value,kp_text:y.value.trim(),answer_time:b.value,stem_raw:w.value,analysis_raw:k.value,question_no:a,session_timestamp:R.value,meta:A()});c||(c=[]),c.push(e),C.value=a,u(`添加一条试题。当前已添加 ${C.value} 条。`),f("当前试题已保存,可以继续添加下一题。"),y.value="",b.value="",w.value="",k.value=""}catch(e){if(e.needConfirm){if(Z("答题时间格式不是标准日期(YYYY-MM-DD),仍要按原样保存吗?"))try{const t=await H("/import/manual-add",{category:S.value,q_type:U.value,kp_text:y.value.trim(),answer_time:b.value,stem_raw:w.value,analysis_raw:k.value,question_no:a,session_timestamp:R.value,meta:A(),force:!0});c.push(t),C.value=a,u(`添加一条试题。当前已添加 ${C.value} 条。`),f("当前试题已保存,可以继续添加下一题。"),y.value="",b.value="",w.value="",k.value=""}catch(t){f(t.message)}}else f(e.message)}}let c=[];function te(){u("手动添加试题窗口已关闭。"),N.value=!1,c&&c.length?(m.value=c,u(`手动添加已完成。共添加 ${c.length} 道题目。`),u(`
+--- 手动添加题目预览 ---`),u(JSON.stringify(c,null,4)),u(`--- 预览结束 ---
+`)):(m.value=null,u("手动添加已取消或未添加任何题目。")),c=[]}async function G(a){const e=new FormData;e.append("file",a,a.name||"pasted.png");const t=await fetch("/StudyBase/api/import/upload-image",{method:"POST",body:e});if(!t.ok){let d="上传失败";try{d=(await t.json()).detail||d}catch{}throw new Error(d)}return(await t.json()).path}function z(a,e){if(a&&a.selectionStart!=null){const t=a.selectionStart,n=a.value;return a.value=n.slice(0,t)+e+n.slice(a.selectionEnd),a.value}return null}function L(a){const e=document.createElement("input");e.type="file",e.accept="image/png,image/jpeg,image/bmp,image/gif",e.onchange=async()=>{const t=e.files[0];if(t)try{const n=await G(t),d=`[[IMG:${n}]]`,o=a;o&&(z(o,d),o.dispatchEvent(new Event("input"))),u(`已选取图片: ${n}`)}catch(n){f(n.message)}},e.click()}async function W(a,e){const t=a.clipboardData&&a.clipboardData.items;if(t){for(const n of t)if(n.type&&n.type.startsWith("image/")){a.preventDefault();const d=n.getAsFile();try{const o=await G(d),J=`[[IMG:${o}]]`,Y=e==="stem"?P.value:_.value;Y&&(z(Y,J),Y.dispatchEvent(new Event("input"))),u(`从剪贴板插入图片: ${o}`)}catch(o){u(`保存剪贴板图片失败: ${o.message}`)}return}}}function le(){m.value?(sessionStorage.setItem("sb_import_data",JSON.stringify(m.value)),sessionStorage.setItem("sb_import_meta",JSON.stringify({...A(),import_time:new Date().toLocaleString("sv-PR")})),K.push("/questions/import/preview")):f("请先解析文件或选择手动添加,没有可预览的数据。")}return se(async()=>{v.value=await re("/import/options"),q.value=v.value.subject.default,V.value=v.value.source.default,j.value=v.value.term.default,F.value=v.value.stage.default,M.value=v.value.origin.default,I.value=v.value.type.default}),(a,e)=>(r(),i("div",null,[e[34]||(e[34]=l("div",{class:"page-header"},[l("span",{class:"title"},"文件导入题目")],-1)),l("div",ve,[l("div",de,[e[23]||(e[23]=l("div",{style:{"font-weight":"bold","margin-bottom":"10px"}},"导入参数设置",-1)),l("div",pe,[e[17]||(e[17]=l("label",null,"所属科目:",-1)),p(l("select",{"onUpdate:modelValue":e[0]||(e[0]=t=>q.value=t)},[(r(!0),i($,null,T(v.value.subject.options,t=>(r(),i("option",{key:t,value:t},g(t),9,me))),128))],512),[[x,q.value]]),e[18]||(e[18]=l("label",null,"学期:",-1)),p(l("select",{"onUpdate:modelValue":e[1]||(e[1]=t=>j.value=t)},[(r(!0),i($,null,T(v.value.term.options,t=>(r(),i("option",{key:t,value:t},g(t),9,fe))),128))],512),[[x,j.value]]),e[19]||(e[19]=l("label",null,"题目来源:",-1)),p(l("select",{"onUpdate:modelValue":e[2]||(e[2]=t=>V.value=t)},[(r(!0),i($,null,T(v.value.source.options,t=>(r(),i("option",{key:t,value:t},g(t),9,ce))),128))],512),[[x,V.value]]),e[20]||(e[20]=l("label",null,"阶段:",-1)),p(l("select",{"onUpdate:modelValue":e[3]||(e[3]=t=>F.value=t)},[(r(!0),i($,null,T(v.value.stage.options,t=>(r(),i("option",{key:t,value:t},g(t),9,ge))),128))],512),[[x,F.value]]),e[21]||(e[21]=l("label",null,"题目类型:",-1)),p(l("select",{"onUpdate:modelValue":e[4]||(e[4]=t=>I.value=t)},[(r(!0),i($,null,T(v.value.type.options,t=>(r(),i("option",{key:t,value:t},g(t),9,ye))),128))],512),[[x,I.value]]),e[22]||(e[22]=l("label",null,"题目出处:",-1)),p(l("select",{"onUpdate:modelValue":e[5]||(e[5]=t=>M.value=t)},[(r(!0),i($,null,T(v.value.origin.options,t=>(r(),i("option",{key:t,value:t},g(t),9,be))),128))],512),[[x,M.value]])])]),l("div",we,[l("button",{class:"btn",disabled:D.value,style:{"font-weight":"bold"},onClick:Q},g(D.value?"解析中...":"第一步:选择文件并解析"),9,ke),l("button",{class:"btn orange",disabled:D.value,style:{"font-weight":"bold"},onClick:X},g(N.value?"手动添加中...":"第一步:手动添加试题"),9,xe),l("button",{class:"btn",disabled:!m.value,style:ne([{"font-weight":"bold"},m.value?"":"color:#999;"]),onClick:le}," 第二步:预览并确定导入 ",12,Se),l("button",{class:"btn",style:{"margin-left":"auto"},onClick:e[6]||(e[6]=t=>a.$router.push("/questions/add"))},"返回")]),e[24]||(e[24]=l("div",{style:{"font-size":"13px",margin:"6px 2px"}},"解析进度与JSON预览:",-1)),l("div",{class:"log-box",ref_key:"logBox",ref:E},g(O.value),513)]),N.value?(r(),i("div",Ce,[l("div",$e,[e[33]||(e[33]=l("div",{class:"modal-header"},"手动添加试题",-1)),l("div",Te,[l("div",Ve,[e[26]||(e[26]=l("label",null,"题目分类(必选):",-1)),p(l("select",{"onUpdate:modelValue":e[7]||(e[7]=t=>S.value=t)},[...e[25]||(e[25]=[l("option",{value:"",disabled:""},"请选择",-1),l("option",null,"客观题",-1),l("option",null,"主观题",-1)])],512),[[x,S.value]])]),l("div",De,[e[28]||(e[28]=l("label",null,"题目类型(必选):",-1)),p(l("select",{"onUpdate:modelValue":e[8]||(e[8]=t=>U.value=t)},[...e[27]||(e[27]=[l("option",null,"错题",-1),l("option",null,"推题",-1)])],512),[[x,U.value]])]),l("div",Ue,[e[29]||(e[29]=l("label",null,"题目知识点(必填):",-1)),p(l("input",{type:"text","onUpdate:modelValue":e[9]||(e[9]=t=>y.value=t),style:{width:"320px"}},null,512),[[B,y.value]])]),l("div",qe,[e[30]||(e[30]=l("label",null,"答题时间(选填,例:2026-03-16):",-1)),p(l("input",{type:"text","onUpdate:modelValue":e[10]||(e[10]=t=>b.value=t),style:{width:"150px"}},null,512),[[B,b.value]]),l("button",{class:"btn small",onClick:h},"填入今天")]),e[31]||(e[31]=l("div",{class:"section-label"},"题干内容(必填,支持插入图片;可使用 Ctrl+V 粘贴)",-1)),l("div",Ie,[l("button",{class:"btn small",onClick:e[11]||(e[11]=t=>L(P.value))},"从本地选择图片")]),p(l("textarea",{ref_key:"mStemRef",ref:P,"onUpdate:modelValue":e[12]||(e[12]=t=>w.value=t),rows:"6",style:{width:"100%"},onPaste:e[13]||(e[13]=t=>W(t,"stem"))},null,544),[[B,w.value]]),e[32]||(e[32]=l("div",{class:"section-label"},"答案解析(必填,支持插入图片;可使用 Ctrl+V 粘贴)",-1)),l("div",je,[l("button",{class:"btn small",onClick:e[14]||(e[14]=t=>L(_.value))},"从本地选择图片")]),p(l("textarea",{ref_key:"mAnalysisRef",ref:_,"onUpdate:modelValue":e[15]||(e[15]=t=>k.value=t),rows:"5",style:{width:"100%"},onPaste:e[16]||(e[16]=t=>W(t,"analysis"))},null,544),[[B,k.value]])]),l("div",{class:"modal-footer"},[l("button",{class:"btn",onClick:ee},"继续添加"),l("button",{class:"btn primary",onClick:te},"确定返回")])])])):oe("",!0)]))}},Ae=ae(Me,[["__scopeId","data-v-a85a16b8"]]);export{Ae as default};
diff --git a/frontend/dist/assets/Home-BS2dZmih.css b/frontend/dist/assets/Home-BS2dZmih.css
new file mode 100644 (file)
index 0000000..f7525f8
--- /dev/null
@@ -0,0 +1 @@
+.home[data-v-0c1f45b2]{height:100%;display:flex;flex-direction:column;background:#f0f0f0}.header[data-v-0c1f45b2]{background:#4a90e2;height:100px;display:flex;align-items:center;justify-content:center}.header .title[data-v-0c1f45b2]{color:#fff;font-size:28px;font-weight:700}.btn-frame[data-v-0c1f45b2]{flex:1;display:flex;align-items:center;justify-content:center;gap:30px;flex-wrap:wrap}.main-btn[data-v-0c1f45b2]{width:220px;height:62px;font-size:16px;font-weight:700;background:#fff;border:none;cursor:pointer;border-radius:4px;box-shadow:0 1px 3px #00000014;font-family:inherit}.main-btn[data-v-0c1f45b2]:hover{background:#f5f8fd}
diff --git a/frontend/dist/assets/Home-DWL4BmBg.js b/frontend/dist/assets/Home-DWL4BmBg.js
new file mode 100644 (file)
index 0000000..4feb6ea
--- /dev/null
@@ -0,0 +1 @@
+import{_ as n,c as r,a as t,o as l}from"./index-Dc_QwQl6.js";const a={},i={class:"home"},u={class:"btn-frame"};function p(o,s){return l(),r("div",i,[s[4]||(s[4]=t("div",{class:"header"},[t("span",{class:"title"},"错题智能整理系统")],-1)),t("div",u,[t("button",{class:"main-btn",style:{color:"#1A73E8"},onClick:s[0]||(s[0]=e=>o.$router.push("/subject-portal"))},"① 科目面板"),t("button",{class:"main-btn",style:{color:"#28A745"},onClick:s[1]||(s[1]=e=>o.$router.push("/questions"))},"② 题目管理"),t("button",{class:"main-btn",style:{color:"#E67E22"},onClick:s[2]||(s[2]=e=>o.$router.push("/papers"))},"③ 试卷管理"),t("button",{class:"main-btn",style:{color:"#6C757D"},onClick:s[3]||(s[3]=e=>o.$router.push("/settings"))},"④ 系统设定")])])}const b=n(a,[["render",p],["__scopeId","data-v-0c1f45b2"]]);export{b as default};
diff --git a/frontend/dist/assets/ImportPreview-CFWTYbgQ.js b/frontend/dist/assets/ImportPreview-CFWTYbgQ.js
new file mode 100644 (file)
index 0000000..1dbeb71
--- /dev/null
@@ -0,0 +1 @@
+import{_ as A,b as C,c as l,a as t,i as c,F as I,r as N,d as m,l as S,o as r,t as n,w as g,g as b,m as f}from"./index-Dc_QwQl6.js";import{c as x,t as u,b as V}from"./api-gDOaCrl1.js";import{R as y}from"./RichContent-vEKXmEUi.js";const z={class:"content-area",style:{"padding-bottom":"90px"}},D={key:0,class:"card",style:{"text-align":"center",padding:"50px"}},E={style:{"font-weight":"bold",color:"#1976D2","font-size":"13px","border-bottom":"1px solid #E8E8E8","padding-bottom":"6px","margin-bottom":"8px"}},B={class:"meta-grid"},U={style:{display:"flex",gap:"8px","align-items":"flex-start",margin:"10px 0"}},q=["onUpdate:modelValue"],P={style:{background:"#FAFAFA",padding:"10px","border-radius":"3px",margin:"4px 0"}},R={key:0,style:{color:"#999"}},$={style:{background:"#FAFAFA",padding:"10px","border-radius":"3px",margin:"4px 0"}},J={key:0,style:{color:"#999"}},M={style:{display:"flex","align-items":"center",gap:"8px","margin-top":"10px"}},O=["onUpdate:modelValue"],T=["onClick"],L=["onClick"],Q={__name:"ImportPreview",setup(j){const v=S(),a=m([]),_=m({}),p=m({});C(()=>{try{a.value=JSON.parse(sessionStorage.getItem("sb_import_data")||"[]"),_.value=JSON.parse(sessionStorage.getItem("sb_import_meta")||"{}")}catch{a.value=[]}});function h(o){const s=(p.value[o]||"").trim();if(!s)return;const e=parseInt(s);if(isNaN(e)){u("请输入整数");return}a.value[o].是否有小题=e>0,a.value[o].小题个数=e,p.value[o]=""}function k(o){x("确定删除此题吗?")&&a.value.splice(o,1)}function w(){x("确定返回吗?所有未入库的修改将丢失!")&&v.push("/questions/import")}async function F(){if(!a.value.length){u("没有题目可以入库。");return}try{const o=await V("/import/save",{questions:a.value,meta:_.value});u(`成功入库 ${o.inserted} 道题目并建立知识点关联`),sessionStorage.removeItem("sb_import_data"),sessionStorage.removeItem("sb_import_meta"),v.push("/questions")}catch(o){u(o.message)}}return(o,s)=>(r(),l("div",null,[s[4]||(s[4]=t("div",{class:"page-header"},[t("span",{class:"title"},"导入题目预览与确认")],-1)),t("div",z,[a.value.length?c("",!0):(r(),l("div",D,"暂无题目数据")),(r(!0),l(I,null,N(a.value,(e,i)=>(r(),l("div",{key:i,class:"card"},[t("div",E," 题号: "+n(e.题号),1),t("div",B,[t("span",null,"科目: "+n(e.科目),1),t("span",null,"类型: "+n(e.题目类型),1),t("span",null,"来源: "+n(e.题目来源),1),t("span",null,"答题时间: "+n(e.答题时间),1),t("span",null,"科目分类: "+n(e.题目分类),1),t("span",null,"输入时间: "+n(e.输入时间),1),t("span",null,"有无小题: "+n(e.是否有小题?"是":"否"),1),t("span",null,"小题个数: "+n(e.小题个数||0),1),t("span",null,"题目分值: "+n(e.分值!==void 0?e.分值:"未设置"),1)]),t("div",U,[s[0]||(s[0]=t("label",{style:{"font-weight":"bold","font-size":"13px","white-space":"nowrap","margin-top":"4px"}},"知识点:",-1)),g(t("textarea",{"onUpdate:modelValue":d=>e.题目知识点=d,rows:"2",style:{flex:"1",background:"#FAFAFA",border:"1px solid #eee"}},null,8,q),[[b,e.题目知识点]])]),s[2]||(s[2]=t("div",{style:{"font-weight":"bold","font-size":"13px"}},"【题干内容】",-1)),t("div",P,[f(y,{content:e.题干内容||"","max-width":750},null,8,["content"]),e.题干内容?c("",!0):(r(),l("div",R,"(无内容)"))]),s[3]||(s[3]=t("div",{style:{"font-weight":"bold","font-size":"13px",color:"#2E7D32"}},"【答案解析】",-1)),t("div",$,[f(y,{content:e.答案解析||"","max-width":750},null,8,["content"]),e.答案解析?c("",!0):(r(),l("div",J,"(无内容)"))]),t("div",M,[s[1]||(s[1]=t("label",{style:{"font-size":"13px"}},"小题个数:",-1)),g(t("input",{type:"text","onUpdate:modelValue":d=>p.value[i]=d,style:{width:"60px"}},null,8,O),[[b,p.value[i]]]),t("button",{class:"btn small",onClick:d=>h(i)},"确认修改",8,T),t("button",{class:"btn small",style:{color:"red"},onClick:d=>k(i)},"删除此题",8,L)])]))),128))]),t("div",{class:"bottom-bar"},[t("button",{class:"btn",onClick:w},"返回修改"),t("button",{class:"btn primary",style:{"font-weight":"bold"},onClick:F},"确认全部入库")])]))}},W=A(Q,[["__scopeId","data-v-2c16a3ae"]]);export{W as default};
diff --git a/frontend/dist/assets/ImportPreview-Dx1LXj5L.css b/frontend/dist/assets/ImportPreview-Dx1LXj5L.css
new file mode 100644 (file)
index 0000000..05d9593
--- /dev/null
@@ -0,0 +1 @@
+.meta-grid[data-v-2c16a3ae]{display:grid;grid-template-columns:repeat(5,1fr);gap:4px 10px;color:#555;font-size:12px;background:#f9f9f9;padding:8px;border-radius:3px}.bottom-bar[data-v-2c16a3ae]{position:fixed;bottom:0;left:0;right:0;height:64px;background:#fff;border-top:1px solid #d9d9d9;display:flex;align-items:center;justify-content:flex-end;gap:14px;padding:0 30px}
diff --git a/frontend/dist/assets/PapersCreateById-CT-UILr2.js b/frontend/dist/assets/PapersCreateById-CT-UILr2.js
new file mode 100644 (file)
index 0000000..bcd6f5f
--- /dev/null
@@ -0,0 +1 @@
+import{c as p,a as e,w as d,g as u,d as m,l as c,o as f}from"./index-Dc_QwQl6.js";import{t as o,a as g,c as v}from"./api-gDOaCrl1.js";const x={class:"page-header"},y={class:"content-area",style:{"padding-top":"16px"}},_={__name:"PapersCreateById",setup(I){const r=c(),i=m("");async function l(){const s=i.value.trim();if(!s){o("请输入至少一个试题ID。");return}let t;try{const n=new URLSearchParams({ids:s});t=await g("/questions/validate-ids?"+n.toString())}catch(n){o(n.message);return}const a=t.valid_ids;t.missing_ids.length&&!v(`部分试题ID (${t.missing_ids.join(", ")}) 不存在于数据库中,是否继续只使用有效ID生成试卷?`)||(sessionStorage.setItem("sb_selected_ids",JSON.stringify(a)),r.push("/papers/preview"))}return(s,t)=>(f(),p("div",null,[e("div",x,[t[2]||(t[2]=e("span",{class:"title"},"根据ID生成试卷",-1)),e("button",{class:"btn",style:{"margin-left":"16px"},onClick:t[0]||(t[0]=a=>s.$router.push("/papers"))},"返回")]),e("div",y,[t[3]||(t[3]=e("div",{style:{"margin-bottom":"10px","font-size":"14px"}},"请输入试题ID(多个ID请用逗号、空格或换行符分隔):",-1)),d(e("textarea",{"onUpdate:modelValue":t[1]||(t[1]=a=>i.value=a),style:{width:"100%",height:"320px","font-family":"Consolas,monospace","font-size":"13px"}},null,512),[[u,i.value]]),e("div",{style:{"margin-top":"16px"}},[e("button",{class:"btn green",style:{"font-weight":"bold","font-size":"14px",padding:"10px 22px"},onClick:l},"生成试卷")])])]))}};export{_ as default};
diff --git a/frontend/dist/assets/PapersDetail-CpYT1ySO.js b/frontend/dist/assets/PapersDetail-CpYT1ySO.js
new file mode 100644 (file)
index 0000000..8e7c7cb
--- /dev/null
@@ -0,0 +1 @@
+import{b as r,c as i,a as s,t as l,u as d,j as u,d as c,o as m}from"./index-Dc_QwQl6.js";import{a as x}from"./api-gDOaCrl1.js";const g={class:"page-header"},b={class:"title"},f={class:"content-area",style:{"padding-top":"30px","text-align":"center"}},$={__name:"PapersDetail",setup(v){const p=u(),a=parseInt(p.params.id),n=c("未命名");return r(async()=>{const e=await x(`/papers/${a}/basic`);n.value=e.paper_name}),(e,t)=>(m(),i("div",null,[s("div",g,[s("span",b,"试卷详情: "+l(n.value),1)]),s("div",f,[t[2]||(t[2]=s("p",{style:{margin:"30px 0","font-size":"15px"}},"这里是试卷题目配置与执行区域",-1)),s("button",{class:"btn",style:{width:"200px",padding:"10px",margin:"10px"},onClick:t[0]||(t[0]=o=>e.$router.push(`/papers/${d(a)}/result`))},"生成并查看结果反馈"),s("button",{class:"btn",style:{width:"200px",padding:"10px",margin:"10px"},onClick:t[1]||(t[1]=o=>e.$router.push("/papers"))},"返回列表")])]))}};export{$ as default};
diff --git a/frontend/dist/assets/PapersList-CRTR-6gM.js b/frontend/dist/assets/PapersList-CRTR-6gM.js
new file mode 100644 (file)
index 0000000..2b628e8
--- /dev/null
@@ -0,0 +1,7 @@
+import{_ as q,b as A,c as d,a as t,w as k,v as V,g as B,s as F,F as f,r as y,d as c,l as L,o as b,t as l}from"./index-Dc_QwQl6.js";import{a as _,t as C,d as M,c as U,f as j}from"./api-gDOaCrl1.js";const K={class:"page-header"},R={class:"filter-panel"},G={class:"content-area"},N={class:"card",style:{padding:"0",overflow:"auto"}},T={class:"data-table"},z=["onClick"],H={class:"center"},J=["onDblclick"],O={class:"center"},Q={class:"center"},W={class:"center"},X={class:"center"},Y={class:"center nowrap"},Z=["onClick"],tt=["onClick"],et=["onClick"],st=["onClick"],nt={__name:"PapersList",setup(at){const m=L(),p=c("未完成"),i=c(""),v=c([]),u=c("p.create_time"),o=c("DESC"),$=[{key:"id",label:"ID",db:"p.id"},{key:"name",label:"试卷名称",db:"p.paper_name"},{key:"subject",label:"科目",db:"p.subject"},{key:"count",label:"题数",db:"question_count"},{key:"time",label:"创建时间",db:"p.create_time"},{key:"status",label:"状态",db:"ps.status_name"},{key:"actions",label:"操作",db:null}];function g(n){return!n||n!==u.value?"":o.value==="ASC"?" ▲":" ▼"}function h(n){u.value===n?o.value=o.value==="ASC"?"DESC":"ASC":(u.value=n,o.value="ASC"),r()}function D(){i.value="",r()}async function r(){const n=new URLSearchParams({status:p.value,paper_id:i.value,sort_col:u.value,sort_dir:o.value}),s=await _("/papers/list?"+n.toString());v.value=s.rows}function I(n){m.push("/papers/preview?paper_id="+n)}function x(n){m.push(`/papers/${n}/result`)}async function w(n){const s=await _(`/papers/${n}/basic`),e=s.question_ids,a=s.paper_name,E=`确定要永久删除该试卷吗?
+
+试卷ID:${n}
+试卷名称:${a}
+题目数量:${e.length} 道
+
+注意:删除后将同步扣减相关题目的组卷及对错统计次数!`;U(E)&&(await j(`/papers/${n}`),await r())}async function S(n){const s=await _(`/papers/${n}/basic`);if(!s.question_ids.length){C(`试卷 ${n} 暂无题目,无法下载。`);return}const e=`/StudyBase/api/papers/${n}/download`,a=document.createElement("a");a.href=e,a.download=`${s.paper_name}_${n}.docx`.replace(/\//g,"_"),document.body.appendChild(a),a.click(),a.remove()}async function P(){if(!v.value.length){C("当前列表没有数据可以导出");return}await M("/papers/list/export-excel",{status:p.value,paper_id:i.value,sort_col:u.value,sort_dir:o.value})}return A(r),(n,s)=>(b(),d("div",null,[t("div",K,[s[5]||(s[5]=t("span",{class:"title"},"试卷管理列表",-1)),t("button",{class:"btn",onClick:s[0]||(s[0]=e=>n.$router.push("/"))},"返回主页"),t("button",{class:"btn orange",style:{"margin-left":"10px"},onClick:s[1]||(s[1]=e=>n.$router.push("/papers/create-by-id"))},"💡 根据ID生成试卷"),t("button",{class:"btn green",style:{"margin-left":"10px"},onClick:s[2]||(s[2]=e=>n.$router.push("/questions?from_paper_creation=1"))},"➕ 进入题目列表选题")]),t("div",R,[s[7]||(s[7]=t("label",null,"试卷状态:",-1)),k(t("select",{"onUpdate:modelValue":s[3]||(s[3]=e=>p.value=e),onChange:D},[...s[6]||(s[6]=[t("option",null,"全部",-1),t("option",null,"未完成",-1),t("option",null,"已完成",-1),t("option",null,"部分完成",-1),t("option",null,"归档",-1)])],544),[[V,p.value]]),s[8]||(s[8]=t("label",{style:{"margin-left":"20px"}},"试卷ID:",-1)),k(t("input",{type:"text","onUpdate:modelValue":s[4]||(s[4]=e=>i.value=e),style:{width:"150px"},onKeyup:F(r,["enter"])},null,544),[[B,i.value]]),t("button",{class:"btn primary",style:{"margin-left":"10px"},onClick:r},"🔍 搜索"),t("button",{class:"btn orange",style:{"margin-left":"6px"},onClick:P},"📥 导出Excel")]),t("div",G,[t("div",N,[t("table",T,[t("thead",null,[t("tr",null,[(b(),d(f,null,y($,e=>t("th",{key:e.key,onClick:a=>e.db&&h(e.db)},l(e.label)+l(g(e.db)),9,z)),64))])]),t("tbody",null,[(b(!0),d(f,null,y(v.value,e=>(b(),d("tr",{key:e.ID},[t("td",H,l(e.ID),1),t("td",{onDblclick:a=>n.$router.push(`/papers/${e.ID}/detail`),style:{cursor:"pointer"}},l(e.试卷名称),41,J),t("td",O,l(e.科目),1),t("td",Q,l(e.题数),1),t("td",W,l((e.创建时间||"").slice(0,10)||"-"),1),t("td",X,l(e.状态),1),t("td",Y,[t("button",{class:"btn small",onClick:a=>I(e.ID)},"预览",8,Z),t("button",{class:"btn small",onClick:a=>x(e.ID)},"反馈",8,tt),t("button",{class:"btn small",onClick:a=>w(e.ID)},"删除",8,et),t("button",{class:"btn small",onClick:a=>S(e.ID)},"下载",8,st)])]))),128))])])])])]))}},rt=q(nt,[["__scopeId","data-v-baad42d8"]]);export{rt as default};
diff --git a/frontend/dist/assets/PapersList-CnsFf_No.css b/frontend/dist/assets/PapersList-CnsFf_No.css
new file mode 100644 (file)
index 0000000..9b7ae09
--- /dev/null
@@ -0,0 +1 @@
+.center[data-v-baad42d8]{text-align:center}.nowrap[data-v-baad42d8]{white-space:nowrap}
diff --git a/frontend/dist/assets/PapersPreview-ffjysDLy.js b/frontend/dist/assets/PapersPreview-ffjysDLy.js
new file mode 100644 (file)
index 0000000..78b8723
--- /dev/null
@@ -0,0 +1,5 @@
+import{b as D,c as r,a,w as R,g as B,t as o,F as I,r as P,i as V,j,d as u,l as N,o as d,m as T}from"./index-Dc_QwQl6.js";import{b as p,t as v}from"./api-gDOaCrl1.js";import{R as E}from"./RichContent-vEKXmEUi.js";const Q={class:"page-header"},z=["disabled"],A={class:"content-area",style:{"padding-top":"12px"}},F={class:"card",style:{display:"flex","align-items":"center",gap:"8px"}},L={class:"card"},M={style:{"font-weight":"bold","margin-bottom":"8px"}},G={style:{"font-size":"12px",color:"#555","margin-bottom":"4px"}},J={key:0,style:{color:"#999"}},O={class:"log-box",style:{height:"120px"}},U={style:{display:"flex","justify-content":"flex-end",gap:"12px",margin:"12px 0 30px"}},H=["disabled"],Z={__name:"PapersPreview",setup(K){const _=j(),w=N(),i=u(null),n=u([]),l=u(""),c=u(""),y=u(!1),m=u([]);function f(){return new Date().toTimeString().slice(0,8)}function h(){w.push("/questions?from_paper_creation=1")}async function b(){if(!n.value.length){m.value=[];return}const t=await p("/questions/search",{filters:{},mode:"preview",id_input:n.value.join(" ")}),e={};for(const s of t.rows)e[s.id]=s;m.value=n.value.map(s=>e[s]).filter(Boolean)}async function g(){if(!n.value.length){c.value+=`[${f()}] 未选择题目,无法预览。
+`;return}try{const t=await p("/papers/preview-docx",{selected_ids:n.value,title:l.value,paper_id:i.value});c.value+=`[${f()}] 预览已生成:${t.path}
+`}catch(t){c.value+=`[${f()}] 生成预览失败: ${t.message}
+`}}async function x(){if(!n.value.length){v("未选择题目,无法生成复习卷。");return}try{const t=await p("/papers/review-docx",{selected_ids:n.value,title:l.value,paper_id:i.value});y.value=!0,c.value+=`[${f()}] 复习卷已生成:${t.path}
+`}catch(t){v("生成复习卷失败:"+t.message)}}function k(){const t="/StudyBase/api/papers/temp-file?name=preview",e=document.createElement("a");e.href=t,e.download="temp_preview.docx",document.body.appendChild(e),e.click(),e.remove()}function C(){const t="/StudyBase/api/papers/temp-file?name=review",e=document.createElement("a");e.href=t,e.download="temp_review_preview.docx",document.body.appendChild(e),e.click(),e.remove()}async function $(){if(!n.value.length){v("未选择题目,无法生成试卷。");return}if(i.value===null)try{const t=await p("/papers/save",{selected_ids:n.value,title:l.value});i.value=t.paper_id,v(`试卷已成功保存!ID: ${t.paper_id}`),await g()}catch(t){v("保存失败:"+t.message)}}return D(async()=>{if(_.query.paper_id){i.value=parseInt(_.query.paper_id);const t=await p("/papers/preview-info",{paper_id:i.value});l.value=t.title,n.value=t.selected_ids}else{try{n.value=JSON.parse(sessionStorage.getItem("sb_selected_ids")||"[]")}catch{n.value=[]}const t=await p("/papers/preview-info",{selected_ids:n.value});l.value=t.title}await b(),await g()}),(t,e)=>(d(),r("div",null,[a("div",Q,[e[2]||(e[2]=a("span",{class:"title"},"试卷预览",-1)),i.value!==null?(d(),r("button",{key:0,class:"btn",style:{"margin-left":"16px"},onClick:e[0]||(e[0]=s=>t.$router.push("/papers"))},"返回试卷列表")):(d(),r("button",{key:1,class:"btn",style:{"margin-left":"16px"},onClick:h},"返回选题列表")),a("button",{class:"btn",onClick:k},"打开文档"),a("button",{class:"btn green",style:{"font-weight":"bold"},disabled:i.value!==null,onClick:$},"💾 确认生成试卷",8,z)]),a("div",A,[a("div",F,[e[3]||(e[3]=a("label",{style:{"font-weight":"bold"}},"试卷标题:",-1)),R(a("input",{type:"text","onUpdate:modelValue":e[1]||(e[1]=s=>l.value=s),style:{width:"420px"}},null,512),[[B,l.value]]),e[4]||(e[4]=a("span",{style:{color:"gray","font-size":"12px"}},"(可手动修改标题)",-1))]),a("div",L,[a("div",M,"试卷内容预览(共 "+o(n.value.length)+" 道题)",1),(d(!0),r(I,null,P(m.value,(s,S)=>(d(),r("div",{key:s.id,style:{"border-bottom":"1px solid #eee",padding:"10px 0"}},[a("div",G,o(S+1)+". ID:"+o(s.id)+" | "+o(s.question_type||"题目")+" | "+o(s.Subject)+" | "+o(s.source_description)+" | 组卷次数:"+o(s.paper_count)+" | 正确次数:"+o(s.right_times)+" | 错误次数:"+o(s.wrong_times)+" | 部分正确次数:"+o(s.half_right_times),1),T(E,{content:s.content,"max-width":800},null,8,["content"])]))),128)),m.value.length?V("",!0):(d(),r("div",J,"未选择题目,无法预览。"))]),a("div",O,o(c.value),1),a("div",U,[a("button",{class:"btn",disabled:!y.value,onClick:C},"打开复习卷",8,H),a("button",{class:"btn green",style:{"font-weight":"bold"},onClick:x},"生成复习卷")])])]))}};export{Z as default};
diff --git a/frontend/dist/assets/PapersResult-BN_2zJ7H.js b/frontend/dist/assets/PapersResult-BN_2zJ7H.js
new file mode 100644 (file)
index 0000000..0220ff4
--- /dev/null
@@ -0,0 +1 @@
+import{b as R,c as o,a as t,F as u,r as b,j as C,d as c,l as $,o as l,t as r,w as i,h as d,f as p,g as P,i as j}from"./index-Dc_QwQl6.js";import{a as B,b as g,t as _,c as D}from"./api-gDOaCrl1.js";const M={class:"content-area",style:{"padding-top":"16px"}},N={style:{"font-weight":"bold",color:"blue","font-size":"13px"}},T={style:{display:"flex","align-items":"center",gap:"14px","flex-wrap":"wrap",margin:"8px 0"}},F=["name","onUpdate:modelValue","value"],I={style:{display:"flex","align-items":"center",gap:"14px","flex-wrap":"wrap"}},A={style:{"font-size":"13px"}},E=["name","onUpdate:modelValue"],G={style:{"font-size":"13px"}},L=["name","onUpdate:modelValue"],S={style:{"font-size":"13px"}},H=["name","onUpdate:modelValue"],J={style:{"font-size":"13px"}},K=["name","onUpdate:modelValue"],O=["onUpdate:modelValue"],Q={style:{display:"flex","align-items":"center",gap:"8px",margin:"8px 0"}},W={style:{"font-size":"13px"}},X={style:{display:"flex","align-items":"center",gap:"8px"}},Y={style:{"font-size":"13px"}},Z={style:{display:"flex",gap:"12px","justify-content":"center",margin:"20px 0 40px"}},q=["disabled"],ee=["disabled"],ne={__name:"PapersResult",setup(te){const h=C(),x=$(),m=parseInt(h.params.id),y=c([]),v=c([]),f=c(!1);function w(a){return a===1?"正确":a===0?"未判定/未选择":a===2?"部分错误":a===3?"错误":""}async function V(){const a=await B(`/papers/${m}/result`);v.value=a.causes,f.value=a.has_pending,y.value=a.questions.map(s=>({...s,_result:"无",_error:"无",_partial:""}))}async function k(){const s=y.value.filter(e=>e.result_type===0).map(e=>({question_id:e.question_id,result:e._result,error_cause:e._error,partial_desc:e._partial}));try{await g(`/papers/${m}/result/confirm`,{results:s}),_("结果已保存到数据库中!"),x.push("/papers")}catch(e){_("保存失败:"+e.message)}}async function z(){if(D("确定要归档此试卷吗?"))try{await g(`/papers/${m}/archive`),_("试卷已归档!"),x.push("/papers")}catch(a){_("归档失败:"+a.message)}}return R(V),(a,s)=>(l(),o("div",null,[s[9]||(s[9]=t("div",{class:"page-header"},[t("span",{class:"title"},"试卷结果反馈")],-1)),t("div",M,[(l(!0),o(u,null,b(y.value,e=>(l(),o("div",{key:e.question_id,class:"card"},[t("div",N," 试题ID: "+r(e.question_id)+", 科目: "+r(e.subject),1),e.result_type===0?(l(),o(u,{key:0},[t("div",T,[s[1]||(s[1]=t("span",{style:{"font-weight":"bold","font-size":"13px"}},"错误原因反馈:",-1)),(l(!0),o(u,null,b(v.value,n=>(l(),o("label",{key:n,style:{"font-size":"13px"}},[i(t("input",{type:"radio",name:"err_"+e.question_id,"onUpdate:modelValue":U=>e._error=U,value:n},null,8,F),[[d,e._error]]),p(" "+r(n),1)]))),128))]),t("div",I,[s[6]||(s[6]=t("span",{style:{"font-weight":"bold","font-size":"13px"}},"试题结果:",-1)),t("label",A,[i(t("input",{type:"radio",name:"res_"+e.question_id,"onUpdate:modelValue":n=>e._result=n,value:"正确"},null,8,E),[[d,e._result]]),s[2]||(s[2]=p(" 正确",-1))]),t("label",G,[i(t("input",{type:"radio",name:"res_"+e.question_id,"onUpdate:modelValue":n=>e._result=n,value:"错误"},null,8,L),[[d,e._result]]),s[3]||(s[3]=p(" 错误",-1))]),t("label",S,[i(t("input",{type:"radio",name:"res_"+e.question_id,"onUpdate:modelValue":n=>e._result=n,value:"未完成"},null,8,H),[[d,e._result]]),s[4]||(s[4]=p(" 未完成",-1))]),t("label",J,[i(t("input",{type:"radio",name:"res_"+e.question_id,"onUpdate:modelValue":n=>e._result=n,value:"部分错误"},null,8,K),[[d,e._result]]),s[5]||(s[5]=p(" 部分错误",-1))]),e._result==="部分错误"?i((l(),o("input",{key:0,type:"text","onUpdate:modelValue":n=>e._partial=n,placeholder:"错误小题描述",style:{width:"260px"}},null,8,O)),[[P,e._partial]]):j("",!0)])],64)):(l(),o(u,{key:1},[t("div",Q,[s[7]||(s[7]=t("span",{style:{"font-weight":"bold","font-size":"13px"}},"错误原因反馈:",-1)),t("span",W,r(e.error_cause_text),1)]),t("div",X,[s[8]||(s[8]=t("span",{style:{"font-weight":"bold","font-size":"13px"}},"试题结果:",-1)),t("span",Y,r(w(e.result_type)),1)])],64))]))),128)),t("div",Z,[t("button",{class:"btn",style:{width:"130px"},disabled:!f.value,onClick:k},"确定结果",8,q),t("button",{class:"btn",style:{width:"130px"},disabled:!f.value,onClick:z},"试卷归档",8,ee),t("button",{class:"btn",style:{width:"130px"},onClick:s[0]||(s[0]=e=>a.$router.push("/papers"))},"返回试卷列表")])])]))}};export{ne as default};
diff --git a/frontend/dist/assets/QuestionAdd-MfLONVTZ.js b/frontend/dist/assets/QuestionAdd-MfLONVTZ.js
new file mode 100644 (file)
index 0000000..f765995
--- /dev/null
@@ -0,0 +1 @@
+import{_ as o,c as i,a as t,o as l}from"./index-Dc_QwQl6.js";const d={},p={class:"nav-bar"},a={style:{padding:"20px"}},r={class:"card",style:{"max-width":"560px"}},u={style:{display:"flex","flex-direction":"column",gap:"14px","margin-top":"24px"}};function x(n,s){return l(),i("div",null,[t("div",p,[t("button",{class:"btn",onClick:s[0]||(s[0]=e=>n.$router.push("/questions"))},"< 返回"),s[3]||(s[3]=t("span",{class:"nav-title"},"新增题目",-1))]),t("div",a,[t("div",r,[s[4]||(s[4]=t("div",{style:{display:"flex","align-items":"center",gap:"10px",margin:"10px 0"}},[t("label",null,"题目内容:"),t("input",{type:"text",style:{flex:"1"}})],-1)),t("div",u,[t("button",{class:"btn",style:{background:"#E1E1E1",padding:"8px",width:"180px"},onClick:s[1]||(s[1]=e=>n.$router.push("/questions/import"))},"文件导入"),t("button",{class:"btn",style:{padding:"8px",width:"180px"},onClick:s[2]||(s[2]=e=>n.$router.push("/questions"))},"取消返回")])])])])}const f=o(d,[["render",x]]);export{f as default};
diff --git a/frontend/dist/assets/QuestionDetail-iaLTMiSx.css b/frontend/dist/assets/QuestionDetail-iaLTMiSx.css
new file mode 100644 (file)
index 0000000..56d79bc
--- /dev/null
@@ -0,0 +1 @@
+.form-row[data-v-88827f0e]{display:flex;align-items:center;gap:10px;margin:8px 0}.form-row label[data-v-88827f0e]{white-space:nowrap;font-size:13px;min-width:190px;text-align:right}
diff --git a/frontend/dist/assets/QuestionDetail-s29u4nvx.js b/frontend/dist/assets/QuestionDetail-s29u4nvx.js
new file mode 100644 (file)
index 0000000..e6b501e
--- /dev/null
@@ -0,0 +1 @@
+import{_ as A,b as Q,c as u,a as e,t as j,u as L,w as i,v as E,F as C,r as O,g as y,m as I,i as W,j as Y,d as a,o as d}from"./index-Dc_QwQl6.js";import{a as z,t as b,c as H,e as J}from"./api-gDOaCrl1.js";import{R as U}from"./RichContent-vEKXmEUi.js";const K={class:"page-header"},X={class:"title"},Z={class:"content-area",style:{"padding-top":"14px","max-width":"980px"}},tt={class:"card"},et={class:"form-row"},st=["disabled"],nt=["value"],lt={class:"form-row"},ot=["disabled"],at={class:"form-row"},it=["disabled"],ut={class:"form-row"},dt=["disabled"],rt={class:"form-row"},vt=["disabled"],pt=["disabled"],mt={class:"card"},ct={class:"card"},ft={class:"card"},yt={style:{"margin-bottom":"6px"}},bt={class:"card"},gt={style:{"margin-bottom":"6px"}},wt={style:{display:"flex","justify-content":"flex-end",gap:"10px",margin:"14px 0 30px"}},xt={__name:"QuestionDetail",setup(_t){const F=Y(),g=parseInt(F.params.id),w=a({}),V=a([]),v=a(""),p=a("客观题"),m=a("错题"),x=a(""),r=a(""),c=a(""),f=a(""),o=a(!1),_=a(null),k=a(null);async function D(){const n=await z(`/questions/${g}`);w.value=n,V.value=n.subject_options||[],v.value=n.Subject||"",p.value=n.is_subjective===1?"主观题":"客观题",m.value=n.question_type||"错题",x.value=n.knowledge_points_desc||"",r.value=n.answer_time||"",c.value=n.content||"",f.value=n.analysis||""}function T(){o.value=!0}async function P(){H("确定取消编辑吗?")&&(await D(),o.value=!1)}function B(){const n=new Date,t=s=>String(s).padStart(2,"0");r.value=`${n.getFullYear()}-${t(n.getMonth()+1)}-${t(n.getDate())}`}async function G(){try{await J(`/questions/${g}`,{subject:v.value,question_type:m.value,answer_time:r.value,is_subjective:p.value==="主观题",content_raw:c.value.trim(),analysis_raw:f.value.trim()}),b("已更新!"),await D(),o.value=!1}catch(n){b("错误:"+n.message)}}async function R(n){const t=new FormData;t.append("file",n,n.name||"pasted.png");const s=await fetch("/StudyBase/api/import/upload-image",{method:"POST",body:t});if(!s.ok){let l="上传失败";try{l=(await s.json()).detail||l}catch{}throw new Error(l)}return(await s.json()).path}function S(n,t){if(n&&n.selectionStart!=null){const s=n.selectionStart,l=n.value;n.value=l.slice(0,s)+t+l.slice(n.selectionEnd),n.dispatchEvent(new Event("input"))}else n&&(n.value+=t,n.dispatchEvent(new Event("input")))}function M(n){const t=document.createElement("input");t.type="file",t.accept="image/png,image/jpeg,image/bmp,image/gif",t.onchange=async()=>{const s=t.files[0];if(s)try{const l=await R(s);S(n.value||n,`[[IMG:${l}]]`)}catch(l){b(l.message)}},t.click()}async function h(n,t){const s=n.clipboardData&&n.clipboardData.items;if(s){for(const l of s)if(l.type&&l.type.startsWith("image/")){n.preventDefault();const q=l.getAsFile();try{const $=await R(q),N=t==="stem"?_.value:k.value;S(N,`[[IMG:${$}]]`)}catch($){b($.message)}return}}}return Q(D),(n,t)=>(d(),u("div",null,[e("div",K,[e("span",X,"修改题目信息 (ID: "+j(L(g))+")",1)]),e("div",Z,[e("div",tt,[e("div",et,[t[13]||(t[13]=e("label",null,"所属科目:",-1)),i(e("select",{"onUpdate:modelValue":t[0]||(t[0]=s=>v.value=s),disabled:!o.value},[(d(!0),u(C,null,O(V.value,s=>(d(),u("option",{key:s,value:s},j(s),9,nt))),128))],8,st),[[E,v.value]])]),e("div",lt,[t[15]||(t[15]=e("label",null,"题目分类(必选):",-1)),i(e("select",{"onUpdate:modelValue":t[1]||(t[1]=s=>p.value=s),disabled:!o.value},[...t[14]||(t[14]=[e("option",null,"客观题",-1),e("option",null,"主观题",-1)])],8,ot),[[E,p.value]])]),e("div",at,[t[17]||(t[17]=e("label",null,"题目类型(必选):",-1)),i(e("select",{"onUpdate:modelValue":t[2]||(t[2]=s=>m.value=s),disabled:!o.value},[...t[16]||(t[16]=[e("option",null,"错题",-1),e("option",null,"推题",-1)])],8,it),[[E,m.value]])]),e("div",ut,[t[18]||(t[18]=e("label",null,"题目知识点(必填):",-1)),i(e("input",{type:"text","onUpdate:modelValue":t[3]||(t[3]=s=>x.value=s),style:{width:"420px"},disabled:!o.value},null,8,dt),[[y,x.value]])]),e("div",rt,[t[19]||(t[19]=e("label",null,"答题时间(选填,例:2026-03-16):",-1)),i(e("input",{type:"text","onUpdate:modelValue":t[4]||(t[4]=s=>r.value=s),style:{width:"150px"},disabled:!o.value},null,8,vt),[[y,r.value]]),e("button",{class:"btn small",disabled:!o.value,onClick:B},"填入今天",8,pt)])]),o.value?(d(),u(C,{key:1},[e("div",ft,[t[22]||(t[22]=e("div",{style:{"font-weight":"bold","margin-bottom":"6px"}},"题干内容",-1)),e("div",yt,[e("button",{class:"btn small",onClick:t[5]||(t[5]=s=>M(_.value))},"从本地选择图片")]),i(e("textarea",{ref_key:"stemRef",ref:_,"onUpdate:modelValue":t[6]||(t[6]=s=>c.value=s),rows:"8",style:{width:"100%"},onPaste:t[7]||(t[7]=s=>h(s,"stem"))},null,544),[[y,c.value]])]),e("div",bt,[t[23]||(t[23]=e("div",{style:{"font-weight":"bold","margin-bottom":"6px"}},"答案解析",-1)),e("div",gt,[e("button",{class:"btn small",onClick:t[8]||(t[8]=s=>M(k.value))},"从本地选择图片")]),i(e("textarea",{ref_key:"analysisRef",ref:k,"onUpdate:modelValue":t[9]||(t[9]=s=>f.value=s),rows:"6",style:{width:"100%"},onPaste:t[10]||(t[10]=s=>h(s,"analysis"))},null,544),[[y,f.value]])])],64)):(d(),u(C,{key:0},[e("div",mt,[t[20]||(t[20]=e("div",{style:{"font-weight":"bold","margin-bottom":"6px"}},"【题干】",-1)),I(U,{content:w.value.content,"max-width":850},null,8,["content"])]),e("div",ct,[t[21]||(t[21]=e("div",{style:{"font-weight":"bold","margin-bottom":"6px",color:"#2E7D32"}},"【答案解析】",-1)),I(U,{content:w.value.analysis,"max-width":850},null,8,["content"])])],64)),e("div",wt,[o.value?(d(),u("button",{key:0,class:"btn",onClick:P},"取消")):W("",!0),e("button",{class:"btn primary",onClick:t[11]||(t[11]=s=>o.value?G():T())},j(o.value?"确定保存":"编辑"),1),e("button",{class:"btn",onClick:t[12]||(t[12]=s=>n.$router.push("/questions"))},"返回列表")])])]))}},jt=A(xt,[["__scopeId","data-v-88827f0e"]]);export{jt as default};
diff --git a/frontend/dist/assets/QuestionList-Dv5J_SvP.css b/frontend/dist/assets/QuestionList-Dv5J_SvP.css
new file mode 100644 (file)
index 0000000..0eab71e
--- /dev/null
@@ -0,0 +1 @@
+.center[data-v-fb1ccf35]{text-align:center}
diff --git a/frontend/dist/assets/QuestionList-cbNQnKl-.js b/frontend/dist/assets/QuestionList-cbNQnKl-.js
new file mode 100644 (file)
index 0000000..1b17265
--- /dev/null
@@ -0,0 +1 @@
+import{_ as ie,b as ae,c as a,a as s,t as n,u as M,F as v,r as x,w as k,g as N,h as R,f as E,i as A,j as oe,k as V,d as p,l as re,o,v as ue,n as de,e as ce,m as B}from"./index-Dc_QwQl6.js";import{a as pe,b as O,t as _,c as L,d as _e}from"./api-gDOaCrl1.js";import{R as T}from"./RichContent-vEKXmEUi.js";const be={class:"page-header"},fe={class:"title"},ye={class:"filter-panel"},ve={class:"row"},he=["onUpdate:modelValue"],ke=["value"],me={class:"row"},ge={class:"row",style:{"justify-content":"space-between"}},xe={style:{display:"flex","align-items":"center",gap:"14px"}},Ce={style:{display:"flex","align-items":"center",gap:"10px"}},we={style:{color:"#E64A19","font-weight":"bold","font-size":"13px"}},qe={class:"content-area"},Ie={key:0,class:"card",style:{padding:"0",overflow:"auto"}},De={class:"data-table"},je=["onClick"],Fe=["onClick","onDblclick"],Se={class:"center"},Ve={class:"center"},Oe={class:"center"},$e={class:"center"},Ue={class:"center"},ze={class:"center"},Me={class:"center"},Ne={class:"center"},Re={class:"center"},Ee={class:"center"},Ae={class:"center"},Be={class:"center"},Le={key:1},Te={style:{display:"flex"}},Ke={style:{display:"flex","flex-direction":"column","justify-content":"space-between","align-items":"center",padding:"0 10px"}},Pe=["onUpdate:modelValue"],Qe=["onClick"],Ge={style:{flex:"1","min-width":"0"}},Je={style:{color:"#1890FF","font-weight":"bold","font-size":"13px","margin-bottom":"6px","word-break":"break-all"}},He={__name:"QuestionList",setup(We){const b=oe(),K=re(),P=b.query.from_paper_creation==="1"||b.query.from_paper_creation==="true",C=["全部","0","1","2","3","4次及以上"],$=[{key:"subject",label:"科目",type:"db"},{key:"status",label:"状态",type:"fixed",options:["全部","未完成","正确","错误","部分正确"]},{key:"archive_status",label:"归档",type:"fixed",options:["全部","未归档","已归档"]},{key:"cause",label:"原因",type:"db"},{key:"source",label:"来源",type:"db"},{key:"term",label:"学期",type:"db"},{key:"stage",label:"阶段",type:"db"},{key:"question_nature",label:"试题类型",type:"fixed",options:["全部","错题","推题"]},{key:"paper_count",label:"组卷次数",type:"fixed",options:C},{key:"right_times",label:"正确次数",type:"fixed",options:C},{key:"wrong_times",label:"错误次数",type:"fixed",options:C},{key:"half_right_times",label:"部分正确次数",type:"fixed",options:C}],Q=$,r=V({});for(const e of $)r[e.key]="全部";r.archive_status="未归档";const U=p({}),w=p(""),q=p(""),d=p("table"),c=p([]),I=p(0),f=V({}),i=V(new Set),D=p(null),j=p(null),F=p(null),m=p("q.id"),h=p(!1),S=[{key:"subject",label:"科目",db:"subject_id"},{key:"id",label:"试题ID",db:"q.id"},{key:"latest_status",label:"掌握状态",db:"latest_status"},{key:"question_type",label:"题目类型",db:"question_type"},{key:"kp",label:"题目知识点",db:"knowledge_points_desc"},{key:"answer_time",label:"答题时间",db:"answer_time"},{key:"input_time",label:"录入时间",db:"input_time"},{key:"p_count",label:"组卷次数",db:"paper_count"},{key:"r_count",label:"正确次数",db:"right_times"},{key:"w_count",label:"错误次数",db:"wrong_times"},{key:"h_count",label:"部分正确次数",db:"half_right_times"},{key:"last_r_time",label:"最晚正确时间",db:"last_right_time"},{key:"paper_ids",label:"所属试卷ID",db:null}];function G(e){return e.type==="fixed"?e.options:["全部",...U.value[e.key]||[]]}function z(e){return{right:"正确",wrong:"错误",half_right:"部分正确"}[e]||"未完成"}function g(){return d.value==="table"?[...i]:Object.entries(f).filter(([,e])=>e).map(([e])=>parseInt(e))}function J(e,l){if(l&&(l.ctrlKey||l.metaKey||l.shiftKey))i.has(e)?i.delete(e):i.add(e);else{const t=i.size===1&&i.has(e);i.clear(),t||i.add(e)}}function H(e){const l=S.find(t=>t.key===e);!l||!l.db||(m.value===l.db?h.value=!h.value:(m.value=l.db,h.value=!1),y())}function W(e){const l=S.find(t=>t.key===e);return!l||l.db!==m.value?"":h.value?" ▼":" ▲"}async function y(){const e={filters:{...r},id_input:w.value,kp_input:q.value,chapter_id_filter:D.value,kp_id_filter:j.value,right_times_filter:F.value,mode:d.value,sort_col:m.value,sort_desc:h.value},l=await O("/questions/search",e);c.value=l.rows,I.value=l.count,i.clear();for(const t of Object.keys(f))delete f[t]}function X(){if(d.value==="table")for(const e of c.value)i.add(e.id);else for(const e of c.value)f[e.id]=!0}function Y(){if(d.value==="table")for(const e of c.value)i.has(e.id)?i.delete(e.id):i.add(e.id);else for(const e of c.value)f[e.id]=!f[e.id]}function Z(){const e=g();if(!e.length){_("请至少选择一道题目");return}sessionStorage.setItem("sb_selected_ids",JSON.stringify(e)),K.push("/papers/preview")}async function ee(){if(d.value==="table"){_("表格模式下不支持批量删除,请切换到预览模式通过复选框操作。");return}const e=g();if(!e.length){_("请勾选题目");return}L(`确定删除选中的 ${e.length} 道题吗?`)&&(await O("/questions/delete",{ids:e}),await y())}async function te(){const e=g();if(!e.length){_("请选择要归档的题目");return}L("确定要将选中的题目归档吗?")&&(await O("/questions/bulk-archive",{ids:e}),_(`成功归档 ${e.length} 道题目。`),await y())}async function se(){const e=g();if(!e.length){_("请选择题目");return}try{await navigator.clipboard.writeText(e.join(",")),_("ID已复制到剪贴板")}catch(l){_("复制失败:"+l.message)}}function le(){const e=new Set(g());if(!e.size){_("请先选择要从列表中移除的题目");return}c.value=c.value.filter(l=>!e.has(l.id)),I.value=c.value.length,i.clear();for(const l of e)delete f[l]}async function ne(){if(!c.value.length){_("当前列表没有数据可以导出");return}await _e("/questions/export-excel",{filters:{...r},id_input:w.value,kp_input:q.value,chapter_id_filter:D.value,kp_id_filter:j.value,right_times_filter:F.value,mode:"table",sort_col:m.value,sort_desc:h.value})}return ae(async()=>{U.value=await pe("/questions/filter-options"),b.query.chapter_id&&(D.value=parseInt(b.query.chapter_id)),b.query.kp_id&&(j.value=parseInt(b.query.kp_id)),b.query.right_times&&(F.value=b.query.right_times);const e=b.query;e.subject&&(r.subject=e.subject),e.stage&&(r.stage=e.stage),e.term&&(r.term=e.term),e.status&&(r.status=e.status),e.archive_status&&(r.archive_status=e.archive_status),e.question_nature&&(r.question_nature=e.question_nature),e.paper_count&&(r.paper_count=e.paper_count),await y()}),(e,l)=>(o(),a("div",null,[s("div",be,[s("span",fe,n(M(P)?"选择题目创建试卷":"题目列表管理"),1),s("button",{class:"btn",style:{"margin-left":"16px"},onClick:l[0]||(l[0]=t=>e.$router.push("/"))},"返回主页"),s("button",{class:"btn green",style:{"margin-left":"10px"},onClick:Z},"➕ 创建试卷"),s("button",{class:"btn green",style:{"margin-left":"10px"},onClick:l[1]||(l[1]=t=>e.$router.push("/questions/add"))},"➕ 新增题目")]),s("div",{style:{padding:"10px 20px 0",display:"flex",gap:"8px","align-items":"center","flex-wrap":"wrap"}},[s("button",{class:"btn small",onClick:X},"全选"),s("button",{class:"btn small",onClick:Y},"反选"),s("button",{class:"btn small",style:{color:"red","font-weight":"bold"},onClick:ee},"❗️ 从数据库中删除"),s("button",{class:"btn small",style:{color:"#F57C00","font-weight":"bold"},onClick:te},"📦 批量归档"),s("button",{class:"btn small",style:{color:"#1890FF","font-weight":"bold"},onClick:se},"📋 复制选中题目ID"),s("button",{class:"btn small",style:{color:"#1b5e20","font-weight":"bold"},onClick:le},"❎ 从列表中移除")]),s("div",ye,[s("div",ve,[(o(!0),a(v,null,x(M(Q),t=>(o(),a(v,{key:t.key},[s("label",null,n(t.label)+":",1),k(s("select",{"onUpdate:modelValue":u=>r[t.key]=u},[(o(!0),a(v,null,x(G(t),u=>(o(),a("option",{key:u,value:u},n(u),9,ke))),128))],8,he),[[ue,r[t.key]]])],64))),128))]),s("div",me,[l[6]||(l[6]=s("label",null,"ID:",-1)),k(s("input",{type:"text","onUpdate:modelValue":l[2]||(l[2]=t=>w.value=t),style:{width:"150px"}},null,512),[[N,w.value]]),l[7]||(l[7]=s("label",{style:{"margin-left":"10px"}},"知识点:",-1)),k(s("input",{type:"text","onUpdate:modelValue":l[3]||(l[3]=t=>q.value=t),style:{width:"200px"}},null,512),[[N,q.value]])]),s("div",ge,[s("div",xe,[s("label",null,[k(s("input",{type:"radio",value:"table","onUpdate:modelValue":l[4]||(l[4]=t=>d.value=t),onChange:y},null,544),[[R,d.value]]),l[8]||(l[8]=E(" 表格模式",-1))]),s("label",null,[k(s("input",{type:"radio",value:"preview","onUpdate:modelValue":l[5]||(l[5]=t=>d.value=t),onChange:y},null,544),[[R,d.value]]),l[9]||(l[9]=E(" 预览模式",-1))])]),s("div",Ce,[s("span",we,"共找到 "+n(I.value)+" 道题目",1),s("button",{class:"btn primary",onClick:y},"🔍 开始搜索"),d.value==="table"?(o(),a("button",{key:0,class:"btn orange",onClick:ne},"📥 导出Excel")):A("",!0)])])]),s("div",qe,[d.value==="table"?(o(),a("div",Ie,[s("table",De,[s("thead",null,[s("tr",null,[(o(),a(v,null,x(S,t=>s("th",{key:t.key,onClick:u=>H(t.key)},n(t.label)+n(W(t.key)),9,je)),64))])]),s("tbody",null,[(o(!0),a(v,null,x(c.value,t=>(o(),a("tr",{key:t.id,class:de(["clickable",{selected:i.has(t.id)}]),onClick:u=>J(t.id,u),onDblclick:u=>e.$router.push("/questions/"+t.id)},[s("td",Se,n(t.Subject),1),s("td",Ve,n(t.id),1),s("td",Oe,n(z(t.latest_status)),1),s("td",$e,n(t.question_type),1),s("td",null,n(t.knowledge_points_desc),1),s("td",Ue,n(t.answer_time),1),s("td",ze,n(t.input_time),1),s("td",Me,n(t.paper_count),1),s("td",Ne,n(t.right_times),1),s("td",Re,n(t.wrong_times),1),s("td",Ee,n(t.half_right_times),1),s("td",Ae,n(t.last_right_time),1),s("td",Be,n(t.paper_ids),1)],42,Fe))),128))])])])):(o(),a("div",Le,[(o(!0),a(v,null,x(c.value,t=>(o(),a("div",{class:"card",key:t.id},[s("div",Te,[s("div",Ke,[k(s("input",{type:"checkbox","onUpdate:modelValue":u=>f[t.id]=u},null,8,Pe),[[ce,f[t.id]]]),s("button",{class:"btn small",onClick:u=>e.$router.push("/questions/"+t.id)},"详情",8,Qe)]),s("div",Ge,[s("div",Je," ID: "+n(t.id)+" | 科目: "+n(t.Subject)+" | 状态: "+n(z(t.latest_status))+" | 归档: "+n(t.StatusName||"未归档")+" | 知识点: "+n(t.knowledge_points_desc),1),l[11]||(l[11]=s("div",{style:{"font-weight":"bold","font-size":"13px",margin:"4px 0 2px"}},"【题干】",-1)),B(T,{content:t.content,"max-width":800},null,8,["content"]),t.analysis?(o(),a(v,{key:0},[l[10]||(l[10]=s("div",{style:{"font-weight":"bold","font-size":"13px",margin:"6px 0 2px",color:"#2E7D32"}},"【解析】",-1)),B(T,{content:t.analysis,"max-width":800},null,8,["content"])],64)):A("",!0)])])]))),128))]))])]))}},lt=ie(He,[["__scopeId","data-v-fb1ccf35"]]);export{lt as default};
diff --git a/frontend/dist/assets/RichContent-i88-frEH.css b/frontend/dist/assets/RichContent-i88-frEH.css
new file mode 100644 (file)
index 0000000..f18bce3
--- /dev/null
@@ -0,0 +1 @@
+.img-error[data-v-d3039138]{color:#ff4d4f;font-size:13px;font-family:Microsoft YaHei,sans-serif}
diff --git a/frontend/dist/assets/RichContent-vEKXmEUi.js b/frontend/dist/assets/RichContent-vEKXmEUi.js
new file mode 100644 (file)
index 0000000..6a5749e
--- /dev/null
@@ -0,0 +1 @@
+import{_ as k,o as n,c as i,F as f,r as v,p as S,i as h,t as W,x as E,k as R}from"./index-Dc_QwQl6.js";const B={class:"rich-text"},C=["src","onError"],I={key:1,class:"img-error"},M={key:2},P={__name:"RichContent",props:{content:{type:String,default:""},maxWidth:{type:Number,default:800}},setup(m){const a=m;function _(p){const r=String(p||""),e=[],l=/!\[\]\s*\(\s*(.*?)\s*\)/g;let t=0,s;for(;(s=l.exec(r))!==null;){s.index>t&&e.push({type:"text",text:r.slice(t,s.index)});const d=s[1].trim();let c=d,u=null;if(d.includes("?")){const[g,w]=d.split("?",2);c=g;const x=w.match(/w=(\d+)/);x&&(u=parseInt(x[1]))}c=c.replace(/\\/g,"/").replace(/ /g,"");let o=null;u&&(o=Math.max(20,Math.round(u/9525)),o>a.maxWidth&&(o=a.maxWidth)),e.push(R({type:"img",src:"/StudyBase/"+encodeURI(c),width:o,failed:!1})),t=s.index+s[0].length}return t<r.length&&e.push({type:"text",text:r.slice(t)}),e}const y=E(()=>_(a.content));return(p,r)=>(n(),i("div",B,[(n(!0),i(f,null,v(y.value,(e,l)=>(n(),i(f,{key:l},[e.type==="img"&&!e.failed?(n(),i("img",{key:0,src:e.src,style:S({width:e.width?e.width+"px":void 0,maxWidth:m.maxWidth+"px"}),onError:t=>e.failed=!0},null,44,C)):h("",!0),e.type==="img"&&e.failed?(n(),i("span",I,"[图片加载错误]")):h("",!0),e.type==="text"?(n(),i("span",M,W(e.text),1)):h("",!0)],64))),128))]))}},N=k(P,[["__scopeId","data-v-d3039138"]]);export{N as R};
diff --git a/frontend/dist/assets/Settings-CzRRQaVw.js b/frontend/dist/assets/Settings-CzRRQaVw.js
new file mode 100644 (file)
index 0000000..e045c7c
--- /dev/null
@@ -0,0 +1 @@
+import{_ as e,c as r,a as s,o as i}from"./index-Dc_QwQl6.js";const l={},d={class:"nav-bar"},u={class:"content-area",style:{padding:"26px 50px"}};function b(n,t){return i(),r("div",null,[s("div",d,[s("button",{class:"btn",onClick:t[0]||(t[0]=o=>n.$router.push("/"))},"< 返回主页"),t[5]||(t[5]=s("span",{class:"nav-title"},"系统设定",-1))]),s("div",u,[t[6]||(t[6]=s("div",{style:{"font-weight":"bold","font-size":"14px","margin-bottom":"14px"}},"基础数据管理",-1)),s("button",{class:"btn setting-btn",onClick:t[1]||(t[1]=o=>n.$router.push("/settings/subjects"))},"科目管理"),s("button",{class:"btn setting-btn",onClick:t[2]||(t[2]=o=>n.$router.push("/settings/error-causes"))},"错误原因管理"),s("button",{class:"btn setting-btn",onClick:t[3]||(t[3]=o=>n.$router.push("/settings/question-statuses"))},"题目状态管理"),t[7]||(t[7]=s("hr",{style:{border:"none","border-top":"1px solid #ddd",margin:"22px 0"}},null,-1)),t[8]||(t[8]=s("div",{style:{"font-weight":"bold","font-size":"14px","margin-bottom":"14px"}},"应用设定",-1)),s("button",{class:"btn setting-btn",onClick:t[4]||(t[4]=o=>n.$router.push("/settings/default-params"))},"默认参数设定")])])}const a=e(l,[["render",b],["__scopeId","data-v-dc043075"]]);export{a as default};
diff --git a/frontend/dist/assets/Settings-pzGFQctT.css b/frontend/dist/assets/Settings-pzGFQctT.css
new file mode 100644 (file)
index 0000000..b001fef
--- /dev/null
@@ -0,0 +1 @@
+.setting-btn[data-v-dc043075]{display:block;width:100%;text-align:left;padding:9px 12px;margin:6px 0;font-size:13px}
diff --git a/frontend/dist/assets/SettingsDefaultParams-Bwa8C6ww.js b/frontend/dist/assets/SettingsDefaultParams-Bwa8C6ww.js
new file mode 100644 (file)
index 0000000..bdb5465
--- /dev/null
@@ -0,0 +1 @@
+import{b as g,c as n,a as e,F as p,r as c,x as y,d as _,o,t as i,w as v,v as b,p as k}from"./index-Dc_QwQl6.js";import{a as x,t as d,b as h}from"./api-gDOaCrl1.js";const w={class:"nav-bar"},D={class:"content-area",style:{padding:"26px 40px","max-width":"760px"}},$={style:{"font-weight":"bold","margin-bottom":"10px"}},q={style:{display:"flex","align-items":"center",gap:"10px"}},S=["onUpdate:modelValue"],B=["value"],C=["onClick"],A={__name:"SettingsDefaultParams",setup(M){const r=_(null),m=y(()=>r.value?[{key:"term",table:"term",title:"默认学期设定",label:"默认学期"},{key:"stage",table:"stage",title:"默认阶段设定",label:"默认阶段"},{key:"question_origin",table:"question_origin",title:"默认题目出处设定",label:"默认出处"}].map(a=>{const t=r.value[a.key];return{...a,options:t.options,selected:t.default||t.options[0]||"",display:t.default_display,hasDefault:!!t.default}}):[]);async function u(){r.value=await x("/settings/default-params")}async function f(s){if(!s.selected){d("请选择一个选项!");return}const a={term:"默认term",stage:"默认stage",question_origin:"默认question_origin"};try{await h(`/settings/default-params/${s.table}`,{name:s.selected}),d(`成功:${a[s.table]}已设置为 '${s.selected}'`),await u()}catch(t){d(t.message)}}return g(u),(s,a)=>(o(),n("div",null,[e("div",w,[e("button",{class:"btn",onClick:a[0]||(a[0]=t=>s.$router.push("/settings"))},"< 返回系统设定"),a[1]||(a[1]=e("span",{class:"nav-title"},"默认参数设定",-1))]),e("div",D,[(o(!0),n(p,null,c(m.value,t=>(o(),n("div",{class:"card",key:t.key},[e("div",$,i(t.title),1),e("div",q,[e("label",null,"选择"+i(t.label)+":",1),v(e("select",{"onUpdate:modelValue":l=>t.selected=l,style:{flex:"1"}},[(o(!0),n(p,null,c(t.options,l=>(o(),n("option",{key:l,value:l},i(l),9,B))),128))],8,S),[[b,t.selected]]),e("button",{class:"btn",onClick:l=>f(t)},"确定",8,C)]),e("div",{style:k([{"margin-top":"10px","font-size":"13px"},{color:t.hasDefault?"green":"gray"}])}," 当前默认:"+i(t.display),5)]))),128))])]))}};export{A as default};
diff --git a/frontend/dist/assets/SettingsErrorCauses-2WPrQUsX.js b/frontend/dist/assets/SettingsErrorCauses-2WPrQUsX.js
new file mode 100644 (file)
index 0000000..91d971f
--- /dev/null
@@ -0,0 +1,2 @@
+import{a as B,t as i,g as _,e as P,b as v,c as z}from"./api-gDOaCrl1.js";import{_ as D,y as F,b as V,c as o,a as n,F as C,r as y,t as r,x as k,d as m,o as u,n as w,i as A}from"./index-Dc_QwQl6.js";const G={class:"nav-bar"},I={class:"content-area",style:{padding:"20px",display:"flex",gap:"14px"}},L={class:"card",style:{flex:"1","min-height":"400px"}},M={class:"cause-tree"},R=["onClick"],T={key:0},j=["onClick"],q={style:{padding:"0 20px",color:"#555","font-size":"12px"}},H={__name:"SettingsErrorCauses",setup(J){const p=m([]),a=m(null),f=m("当前状态: -"),x=k(()=>{const t={},s=[];for(const e of p.value)e.parent_id===null?s.push(e):(t[e.parent_id]||(t[e.parent_id]=[]),t[e.parent_id].push(e));return s.map(e=>({...e,children:t[e.id]||[]}))}),d=k(()=>p.value.find(t=>t.id===a.value));function h(){const t=d.value;if(t){const s=t.parent_id===null?"顶级原因":"子原因";f.value=`选中: ${t.cause}  |  类型: ${s}  |  状态: ${t.enabled?"启用":"停用"}`}else f.value="当前状态: -"}async function c(){const t=await B("/settings/error-causes");p.value=t.rows}async function $(){await b(null)}async function S(){if(a.value===null){i("请先选择一个【顶级原因】作为父级。");return}const t=d.value;if(t.parent_id!==null){i(`最多只支持两级错误原因。
+您不能在子原因下继续添加。`);return}if(!t.enabled){i("该父级原因已停用,无法添加子原因。");return}await b(t.id)}async function b(t){const e=_(t===null?"新增顶级原因":"新增子原因","请输入原因名称 (最多5个中文字符):");if(e===null)return;const l=e.trim();if(l)try{await v("/settings/error-causes",{name:l,parent_id:t}),await c()}catch(g){i(g.message)}}async function E(){if(a.value===null)return;const t=d.value,s=_("编辑名称","修改名称:",t.cause);if(s===null)return;const e=s.trim();if(e)try{await P(`/settings/error-causes/${a.value}`,{name:e}),await c()}catch(l){i(l.message)}}async function N(){if(a.value===null)return;d.value.enabled;try{await v(`/settings/error-causes/${a.value}/toggle`),await c()}catch(s){s.needConfirm?z(s.message)&&(await v(`/settings/error-causes/${a.value}/toggle-force`),await c()):i(s.message)}}return F(a,h),V(c),(t,s)=>(u(),o("div",null,[n("div",G,[n("button",{class:"btn",onClick:s[0]||(s[0]=e=>t.$router.push("/settings"))},"< 返回设置"),s[1]||(s[1]=n("span",{class:"nav-title"},"错误原因管理",-1))]),n("div",I,[n("div",{style:{width:"180px","flex-shrink":"0"}},[s[2]||(s[2]=n("div",{style:{"font-weight":"bold","margin-bottom":"12px"}},"操作菜单",-1)),n("button",{class:"btn side-btn",onClick:$},"📂 新增顶级原因"),n("button",{class:"btn side-btn",onClick:S},"↳ 新增子原因"),s[3]||(s[3]=n("hr",{style:{border:"none","border-top":"1px solid #CCCCCC",margin:"14px 0"}},null,-1)),n("button",{class:"btn side-btn",onClick:E},"✎ 编辑名称"),n("button",{class:"btn side-btn",onClick:N},"✅ 启用/停用")]),n("div",L,[n("ul",M,[(u(!0),o(C,null,y(x.value,e=>(u(),o("li",{key:e.id},[n("div",{class:w(["node",{selected:a.value===e.id,disabled:!e.enabled}]),onClick:l=>a.value=e.id},r(e.parent_id===null?"📂":"📄")+" "+r(e.cause)+r(e.enabled?"":" (已停用)"),11,R),e.children&&e.children.length?(u(),o("ul",T,[(u(!0),o(C,null,y(e.children,l=>(u(),o("li",{key:l.id},[n("div",{class:w(["node",{selected:a.value===l.id,disabled:!l.enabled}]),onClick:g=>a.value=l.id}," 📄 "+r(l.cause)+r(l.enabled?"":" (已停用)"),11,j)]))),128))])):A("",!0)]))),128))])])]),n("div",q,r(f.value),1)]))}},Q=D(H,[["__scopeId","data-v-cd6f6757"]]);export{Q as default};
diff --git a/frontend/dist/assets/SettingsErrorCauses-BA8Zr_cv.css b/frontend/dist/assets/SettingsErrorCauses-BA8Zr_cv.css
new file mode 100644 (file)
index 0000000..2570ae2
--- /dev/null
@@ -0,0 +1 @@
+.side-btn[data-v-cd6f6757]{display:block;width:100%;margin:5px 0;font-size:12px}.cause-tree[data-v-cd6f6757]{list-style:none;padding-left:10px}.cause-tree ul[data-v-cd6f6757]{list-style:none;padding-left:26px}.node[data-v-cd6f6757]{padding:5px 8px;cursor:pointer;border-radius:3px;font-size:13px}.node[data-v-cd6f6757]:hover{background:#f0f6ff}.node.selected[data-v-cd6f6757]{background:#b0d0f5}.node.disabled[data-v-cd6f6757]{color:gray}
diff --git a/frontend/dist/assets/SettingsStatuses-C1SsLo53.js b/frontend/dist/assets/SettingsStatuses-C1SsLo53.js
new file mode 100644 (file)
index 0000000..7b0d313
--- /dev/null
@@ -0,0 +1 @@
+import{_ as $,b as S,c as f,a as t,F as D,r as q,t as i,w as g,g as _,i as V,d,o as b,n as B}from"./index-Dc_QwQl6.js";import{a as I,t as o,c as M,b as x,e as N}from"./api-gDOaCrl1.js";const T={class:"nav-bar"},z={class:"content-area",style:{padding:"20px",display:"flex",gap:"14px"}},A={class:"card",style:{flex:"1"}},E={class:"data-table"},F=["onClick"],P={class:"center"},U={class:"center"},G={style:{padding:"0 20px",color:"#555","font-size":"12px"}},L={key:0,class:"modal-mask"},O={class:"modal"},j={class:"modal-header"},H={class:"modal-body"},J={class:"form-row"},K={class:"form-row"},Q={class:"modal-footer2"},R={__name:"SettingsStatuses",setup(W){const c=d([]),n=d(null),m=d("就绪"),r=d(!1),v=d("add"),l=d(""),u=d("");async function p(){const a=await I("/settings/question-statuses");c.value=a.rows}function w(){v.value="add",l.value="",u.value="",r.value=!0}async function k(){if(!l.value.trim()){o("状态名称不能为空");return}if(l.value.trim().length>20){o("状态名称过长(建议20字以内)");return}try{await x("/settings/question-statuses",{name:l.value.trim(),description:u.value.trim()}),r.value=!1,m.value=`成功添加状态: ${l.value.trim()}`,await p()}catch(a){o(a.message)}}function y(){if(n.value===null){o("请先选择一行数据");return}const a=c.value.find(e=>e.id===n.value);v.value="edit",l.value=a.name,u.value=a.description||"",r.value=!0}async function C(){if(!l.value.trim()){o("状态名称不能为空");return}try{await N(`/settings/question-statuses/${n.value}`,{name:l.value.trim(),description:u.value.trim()}),r.value=!1,m.value=`成功修改状态: ${l.value.trim()}`,await p()}catch(a){o(a.message)}}async function h(){if(n.value===null){o("请先选择一行数据");return}const a=c.value.find(s=>s.id===n.value),e=a.enabled?"停用":"启用";if(M(`确定要 ${e} 状态【${a.name}】吗?`))try{await x(`/settings/question-statuses/${n.value}/toggle`),m.value=`已${e}: ${a.name}`,await p()}catch(s){o(s.message)}}return S(p),(a,e)=>(b(),f("div",null,[t("div",T,[t("button",{class:"btn",onClick:e[0]||(e[0]=s=>a.$router.push("/settings"))},"< 返回设置"),e[4]||(e[4]=t("span",{class:"nav-title"},"题目状态管理",-1))]),t("div",z,[t("div",{style:{width:"180px","flex-shrink":"0"}},[e[5]||(e[5]=t("div",{style:{"font-weight":"bold","margin-bottom":"12px"}},"操作菜单",-1)),t("button",{class:"btn side-btn",onClick:w},"✚ 新增状态"),t("button",{class:"btn side-btn",onClick:y},"✎ 编辑选中"),e[6]||(e[6]=t("hr",{style:{border:"none","border-top":"1px solid #CCCCCC",margin:"14px 0"}},null,-1)),t("button",{class:"btn side-btn",onClick:h},"✅ 启用/停用")]),t("div",A,[t("table",E,[e[7]||(e[7]=t("thead",null,[t("tr",null,[t("th",{style:{cursor:"default"}},"ID"),t("th",{style:{cursor:"default"}},"状态名称"),t("th",{style:{cursor:"default"}},"状态描述"),t("th",{style:{cursor:"default"}},"当前状态")])],-1)),t("tbody",null,[(b(!0),f(D,null,q(c.value,s=>(b(),f("tr",{key:s.id,class:B(["clickable",{selected:n.value===s.id}]),onClick:X=>n.value=s.id,onDblclick:y},[t("td",P,i(s.id),1),t("td",null,i(s.name),1),t("td",null,i(s.description),1),t("td",U,i(s.enabled?"✅ 启用":"❌ 停用"),1)],42,F))),128))])])])]),t("div",G,i(m.value),1),r.value?(b(),f("div",L,[t("div",O,[t("div",j,i(v.value==="add"?"新增题目状态":"编辑题目状态"),1),t("div",H,[t("div",J,[e[8]||(e[8]=t("label",null,"状态名称:",-1)),g(t("input",{type:"text","onUpdate:modelValue":e[1]||(e[1]=s=>l.value=s),style:{width:"220px"}},null,512),[[_,l.value]])]),t("div",K,[e[9]||(e[9]=t("label",null,"状态描述:",-1)),g(t("input",{type:"text","onUpdate:modelValue":e[2]||(e[2]=s=>u.value=s),style:{width:"220px"}},null,512),[[_,u.value]])])]),t("div",Q,[t("button",{class:"btn primary",onClick:e[3]||(e[3]=s=>v.value==="add"?k():C())},i(v.value==="add"?"保存":"更新"),1)])])])):V("",!0)]))}},tt=$(R,[["__scopeId","data-v-f1401540"]]);export{tt as default};
diff --git a/frontend/dist/assets/SettingsStatuses-XHEFsW5N.css b/frontend/dist/assets/SettingsStatuses-XHEFsW5N.css
new file mode 100644 (file)
index 0000000..6a115b0
--- /dev/null
@@ -0,0 +1 @@
+.center[data-v-f1401540]{text-align:center}.side-btn[data-v-f1401540]{display:block;width:100%;margin:5px 0;font-size:12px}.modal-mask[data-v-f1401540]{position:fixed;top:0;right:0;bottom:0;left:0;background:#00000059;display:flex;align-items:center;justify-content:center;z-index:100}.modal[data-v-f1401540]{background:#fff;width:400px;border-radius:6px;box-shadow:0 4px 20px #00000040}.modal-header[data-v-f1401540]{padding:12px 16px;border-bottom:1px solid #eee;font-weight:700}.modal-body[data-v-f1401540]{padding:16px}.modal-footer2[data-v-f1401540]{padding:0 16px 16px;text-align:center}.form-row[data-v-f1401540]{display:flex;align-items:center;gap:10px;margin:10px 0}
diff --git a/frontend/dist/assets/SettingsSubjects-BOWGx4MO.js b/frontend/dist/assets/SettingsSubjects-BOWGx4MO.js
new file mode 100644 (file)
index 0000000..d029ca1
--- /dev/null
@@ -0,0 +1 @@
+import{a as g,g as d,b,t as l,e as m,c as y}from"./api-gDOaCrl1.js";import{_ as k,b as h,c as u,a as t,F as w,r as j,d as f,o as r,n as C,t as i}from"./index-Dc_QwQl6.js";const S={class:"nav-bar"},x={class:"content-area",style:{padding:"20px"}},$={class:"card"},B={class:"data-table"},D=["onClick"],I={class:"center"},P={class:"center"},F={class:"center"},N={__name:"SettingsSubjects",setup(z){const c=f([]),n=f(null);async function o(){const a=await g("/settings/subjects");c.value=a.rows}async function _(){const a=d("新增学科","请输入学科名称 (最多10字):");if(!(a===null||a===""))try{await b("/settings/subjects",{name:a}),await o()}catch(e){l(e.message)}}async function p(){if(n.value===null){l("请先选择一行数据");return}const a=c.value.find(s=>s.id===n.value),e=d("编辑学科","修改学科名称:",a.subject);if(!(e===null||e===""))try{await m(`/settings/subjects/${n.value}`,{name:e}),await o()}catch(s){l(s.message)}}async function v(){if(n.value===null){l("请先选择一行数据");return}const a=c.value.find(s=>s.id===n.value),e=a.enabled?"停用":"启用";if(y(`确定要 ${e} 学科【${a.subject}】吗?`))try{await b(`/settings/subjects/${n.value}/toggle`),await o()}catch(s){l(s.message)}}return h(o),(a,e)=>(r(),u("div",null,[t("div",S,[t("button",{class:"btn",onClick:e[0]||(e[0]=s=>a.$router.push("/settings"))},"< 返回设置"),e[1]||(e[1]=t("span",{class:"nav-title"},"学科管理",-1))]),t("div",x,[t("div",$,[t("div",{style:{display:"flex",gap:"10px","margin-bottom":"12px"}},[t("button",{class:"btn",onClick:_},"+ 新增学科"),t("button",{class:"btn",onClick:p},"编辑名称"),t("button",{class:"btn",onClick:v},"启用/停用")]),t("table",B,[e[2]||(e[2]=t("thead",null,[t("tr",null,[t("th",{style:{cursor:"default"}},"ID"),t("th",{style:{cursor:"default"}},"学科名称"),t("th",{style:{cursor:"default"}},"状态"),t("th",{style:{cursor:"default"}},"创建时间")])],-1)),t("tbody",null,[(r(!0),u(w,null,j(c.value,s=>(r(),u("tr",{key:s.id,class:C(["clickable",{selected:n.value===s.id}]),onClick:A=>n.value=s.id},[t("td",I,i(s.id),1),t("td",null,i(s.subject),1),t("td",P,i(s.enabled?"✅ 启用":"❌ 停用"),1),t("td",F,i(s.create_time),1)],10,D))),128))])])])])]))}},L=k(N,[["__scopeId","data-v-f827c0e4"]]);export{L as default};
diff --git a/frontend/dist/assets/SettingsSubjects-bGGsFjfA.css b/frontend/dist/assets/SettingsSubjects-bGGsFjfA.css
new file mode 100644 (file)
index 0000000..b846e4e
--- /dev/null
@@ -0,0 +1 @@
+.center[data-v-f827c0e4]{text-align:center}
diff --git a/frontend/dist/assets/SubjectPortal-C7-krIg3.js b/frontend/dist/assets/SubjectPortal-C7-krIg3.js
new file mode 100644 (file)
index 0000000..50a5abc
--- /dev/null
@@ -0,0 +1 @@
+import{_ as y,b as x,c as d,a as s,w as b,v as w,F as _,r as g,t as e,d as r,o as p,e as m,f as h}from"./index-Dc_QwQl6.js";import{a as k,b as F}from"./api-gDOaCrl1.js";const C={class:"page-header"},D={class:"content-area"},j={class:"card"},$={class:"row",style:{display:"flex","align-items":"center",gap:"10px","flex-wrap":"wrap"}},S=["value"],V=["onUpdate:modelValue"],z={style:{display:"flex",gap:"20px","flex-wrap":"wrap"}},B={class:"card stat-card",style:{flex:"1","min-width":"360px"}},E={class:"stat-line"},I={class:"stat-val",style:{color:"#333"}},M={class:"stat-line"},P={class:"stat-val",style:{color:"#2E7D32"}},U={class:"stat-line"},A={class:"stat-val big",style:{color:"#1976D2"}},N={class:"stat-line"},G={class:"stat-val",style:{color:"#D32F2F"}},L={class:"card stat-card",style:{flex:"1","min-width":"360px"}},O={class:"stat-line"},T={class:"stat-val",style:{color:"#333"}},q={class:"stat-line"},H={class:"stat-val",style:{color:"#2E7D32"}},J={class:"stat-line"},K={class:"stat-val big",style:{color:"#F57C00"}},Q={class:"stat-line"},R={class:"stat-val",style:{color:"#D32F2F"}},W={style:{display:"flex",gap:"15px","margin-top":"20px","justify-content":"center","flex-wrap":"wrap"}},X={__name:"SubjectPortal",setup(Y){const n=r(["数学"]),c=r([]),u=r({}),i=r("数学"),o=r({wrong_total:0,wrong_fixed:0,wrong_ratio:"0%",wrong_remained:0,push_total:0,push_fixed:0,push_ratio:"0%",push_remained:0});async function v(){const l=Object.entries(u.value).filter(([,t])=>t).map(([t])=>parseInt(t));o.value=await F("/portal/stats",{subject:i.value,stage_ids:l})}return x(async()=>{const l=await k("/portal/options");n.value=l.subjects,n.value.length&&!n.value.includes(i.value)&&(i.value=n.value[0]),c.value=l.stages;const t={};for(const a of l.stages)t[a.id]=!0;u.value=t,await v()}),(l,t)=>(p(),d("div",null,[s("div",C,[t[5]||(t[5]=s("span",{class:"title"},"科目概要面板",-1)),s("button",{class:"btn",onClick:t[0]||(t[0]=a=>l.$router.push("/"))},"返回首页")]),s("div",D,[s("div",j,[s("div",$,[t[6]||(t[6]=s("label",null,"当前科目:",-1)),b(s("select",{"onUpdate:modelValue":t[1]||(t[1]=a=>i.value=a),onChange:v},[(p(!0),d(_,null,g(n.value,a=>(p(),d("option",{key:a,value:a},e(a),9,S))),128))],544),[[w,i.value]]),t[7]||(t[7]=s("label",{style:{"margin-left":"20px"}},"统计阶段:",-1)),(p(!0),d(_,null,g(c.value,a=>(p(),d("label",{key:a.id,style:{"font-size":"13px"}},[b(s("input",{type:"checkbox","onUpdate:modelValue":f=>u.value[a.id]=f},null,8,V),[[m,u.value[a.id]]]),h(" "+e(a.name),1)]))),128)),s("button",{class:"btn",onClick:v},"确定统计")])]),s("div",z,[s("div",B,[t[12]||(t[12]=s("div",{class:"stat-title",style:{color:"#E64A19"}},"错题情况回顾",-1)),s("div",E,[t[8]||(t[8]=s("span",{class:"stat-label"},"错题总数:",-1)),s("span",I,e(o.value.wrong_total),1)]),s("div",M,[t[9]||(t[9]=s("span",{class:"stat-label"},"已解决数:",-1)),s("span",P,e(o.value.wrong_fixed),1)]),s("div",U,[t[10]||(t[10]=s("span",{class:"stat-label"},"解决比例:",-1)),s("span",A,e(o.value.wrong_ratio),1)]),s("div",N,[t[11]||(t[11]=s("span",{class:"stat-label"},"遗留题目数:",-1)),s("span",G,e(o.value.wrong_remained),1)])]),s("div",L,[t[17]||(t[17]=s("div",{class:"stat-title",style:{color:"#00796B"}},"强化推题追踪",-1)),s("div",O,[t[13]||(t[13]=s("span",{class:"stat-label"},"推题总数:",-1)),s("span",T,e(o.value.push_total),1)]),s("div",q,[t[14]||(t[14]=s("span",{class:"stat-label"},"已完成数:",-1)),s("span",H,e(o.value.push_fixed),1)]),s("div",J,[t[15]||(t[15]=s("span",{class:"stat-label"},"完成比例:",-1)),s("span",K,e(o.value.push_ratio),1)]),s("div",Q,[t[16]||(t[16]=s("span",{class:"stat-label"},"遗留题目数:",-1)),s("span",R,e(o.value.push_remained),1)])])]),s("div",W,[s("button",{class:"btn",style:{background:"#FF4D4F",color:"#000","font-weight":"bold",padding:"10px 30px","font-size":"14px"},onClick:t[2]||(t[2]=a=>l.$router.push("/analysis/pending"))},"遗留分析"),s("button",{class:"btn",style:{background:"#1890FF",color:"#000","font-weight":"bold",padding:"10px 30px","font-size":"14px"},onClick:t[3]||(t[3]=a=>l.$router.push("/analysis/chapters"))},"章节分析"),s("button",{class:"btn",style:{background:"#52C41A",color:"#000","font-weight":"bold",padding:"10px 30px","font-size":"14px"},onClick:t[4]||(t[4]=a=>l.$router.push("/analysis/knowledge"))},"知识点分析")])])]))}},ts=y(X,[["__scopeId","data-v-3b1b110d"]]);export{ts as default};
diff --git a/frontend/dist/assets/SubjectPortal-yfCLF9Wp.css b/frontend/dist/assets/SubjectPortal-yfCLF9Wp.css
new file mode 100644 (file)
index 0000000..11c4ab4
--- /dev/null
@@ -0,0 +1 @@
+.stat-title[data-v-3b1b110d]{font-size:14px;font-weight:700;margin-bottom:10px}.stat-line[data-v-3b1b110d]{display:flex;align-items:center;margin:8px 0}.stat-label[data-v-3b1b110d]{color:#666;font-size:13px}.stat-val[data-v-3b1b110d]{font-size:24px;font-weight:700;font-family:Helvetica,sans-serif;margin-left:10px}.stat-val.big[data-v-3b1b110d]{font-size:32px}
diff --git a/frontend/dist/assets/api-gDOaCrl1.js b/frontend/dist/assets/api-gDOaCrl1.js
new file mode 100644 (file)
index 0000000..253caa3
--- /dev/null
@@ -0,0 +1,2 @@
+const s="/StudyBase/api";async function c(t){if(t.status===422){const n=await t.json(),o=new Error(n.detail||"需要确认");throw o.needConfirm=!0,o}if(!t.ok){let n=`HTTP ${t.status}`;try{n=(await t.json()).detail||n}catch{}throw new Error(n)}return t}async function u(t){const n=await fetch(s+t);return c(n).then(o=>o.json())}async function h(t,n){const o=await fetch(s+t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n||{})});return c(o).then(e=>e.json())}async function p(t,n){const o=await fetch(s+t,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(n||{})});return c(o).then(e=>e.json())}async function w(t){const n=await fetch(s+t,{method:"DELETE"});return c(n).then(o=>o.json())}async function m(t,n,o){const e=await fetch(s+t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n||{})});await c(e);const f=await e.blob(),l=e.headers.get("Content-Disposition")||"";let i="download.xlsx";const r=l.match(/filename\*=UTF-8''([^;]+)/);r&&(i=decodeURIComponent(r[1]));const d=URL.createObjectURL(f),a=document.createElement("a");a.href=d,a.download=i,document.body.appendChild(a),a.click(),a.remove(),URL.revokeObjectURL(d)}function y(t){return window.confirm(t)}function b(t,n,o){const e=window.prompt((t?t+`
+`:"")+n,o||"");return e===null?null:e}function j(t){window.alert(t)}export{u as a,h as b,y as c,m as d,p as e,w as f,b as g,j as t};
diff --git a/frontend/dist/assets/index-D5_k2e2e.css b/frontend/dist/assets/index-D5_k2e2e.css
new file mode 100644 (file)
index 0000000..3306b88
--- /dev/null
@@ -0,0 +1 @@
+*{box-sizing:border-box;margin:0;padding:0}html,body,#app{height:100%}body{font-family:Microsoft YaHei,PingFang SC,Helvetica,sans-serif;background:#f0f2f5;color:#333;font-size:14px}a{color:#1890ff;text-decoration:none}.page-header{background:#4a90e2;height:56px;display:flex;align-items:center;padding:0 20px}.page-header .title{color:#000;font-size:20px;font-weight:700;margin-right:auto}.page-header .spacer{margin-left:auto}.btn{display:inline-block;border:1px solid #d9d9d9;background:#fff;color:#333;padding:5px 14px;font-size:13px;cursor:pointer;border-radius:3px;font-family:inherit}.btn:hover{background:#f5f5f5}.btn:disabled{color:#999;cursor:not-allowed;background:#f0f0f0}.btn.primary{background:#4a90e2;border-color:#4a90e2;color:#fff}.btn.primary:hover{background:#3a7ecb}.btn.green{background:#52c41a;border-color:#52c41a;color:#000}.btn.orange{background:#ffc107;border-color:#ffc107;color:#000}.btn.red{color:#fff;background:#ff4d4f;border-color:#ff4d4f}.btn.small{padding:2px 8px;font-size:12px}.card{background:#fff;border:1px solid #E8E8E8;border-radius:4px;padding:14px 18px;margin-bottom:12px}.filter-panel{background:#fff;border:1px solid #E8E8E8;border-radius:4px;padding:14px 18px;margin:10px 20px}.filter-panel .row{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:6px 0}.filter-panel label{font-size:13px}select,input[type=text],textarea{border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px;font-size:13px;font-family:inherit;background:#fff}select:focus,input:focus,textarea:focus{outline:1px solid #4A90E2}table.data-table{width:100%;border-collapse:collapse;background:#fff}table.data-table th{background:#4a90e2;color:#000;font-size:13px;font-weight:700;padding:7px 8px;border:1px solid #3a7ecb;cursor:pointer;white-space:nowrap}table.data-table td{padding:6px 8px;border:1px solid #E8E8E8;font-size:13px;vertical-align:top}table.data-table tr.clickable{cursor:pointer}table.data-table tr.selected td{background:#b0d0f5}.status-ok{color:#2e7d32}.status-off{color:#999}.content-area{padding:0 20px 20px}.nav-bar{background:#e0e0e0;height:46px;display:flex;align-items:center;padding:0 12px}.nav-bar .nav-title{font-size:16px;font-weight:700;margin-left:18px}.rich-text{line-height:1.7;font-family:SimSun,Songti SC,serif;font-size:15px;white-space:pre-wrap;word-break:break-word}.rich-text img{max-width:100%;vertical-align:middle;margin:2px 0}.log-box{background:#fff;border:1px solid #d9d9d9;font-family:Consolas,Monaco,monospace;font-size:12px;padding:8px;white-space:pre-wrap;overflow:auto;height:300px}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-thumb{background:#c4c4c4;border-radius:5px}::-webkit-scrollbar-track{background:#f0f0f0}
diff --git a/frontend/dist/assets/index-Dc_QwQl6.js b/frontend/dist/assets/index-Dc_QwQl6.js
new file mode 100644 (file)
index 0000000..63b34ac
--- /dev/null
@@ -0,0 +1,27 @@
+const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/Home-DWL4BmBg.js","assets/Home-BS2dZmih.css","assets/SubjectPortal-C7-krIg3.js","assets/api-gDOaCrl1.js","assets/SubjectPortal-yfCLF9Wp.css","assets/QuestionList-cbNQnKl-.js","assets/RichContent-vEKXmEUi.js","assets/RichContent-i88-frEH.css","assets/QuestionList-Dv5J_SvP.css","assets/FileImport-BF8-5MOQ.js","assets/FileImport-B8PhhnGN.css","assets/ImportPreview-CFWTYbgQ.js","assets/ImportPreview-Dx1LXj5L.css","assets/QuestionDetail-s29u4nvx.js","assets/QuestionDetail-iaLTMiSx.css","assets/PapersList-CRTR-6gM.js","assets/PapersList-CnsFf_No.css","assets/PapersCreateById-CT-UILr2.js","assets/PapersPreview-ffjysDLy.js","assets/PapersDetail-CpYT1ySO.js","assets/PapersResult-BN_2zJ7H.js","assets/AnalysisChapters-rXnOtC4r.js","assets/AnalysisChapters-DTn9LWoa.css","assets/AnalysisKnowledge-D1yJ4mIN.js","assets/AnalysisKnowledge-5QPt6Ak_.css","assets/AnalysisPending-BfBXB5e_.js","assets/AnalysisPending-C5SzviRH.css","assets/Settings-CzRRQaVw.js","assets/Settings-pzGFQctT.css","assets/SettingsSubjects-BOWGx4MO.js","assets/SettingsSubjects-bGGsFjfA.css","assets/SettingsErrorCauses-2WPrQUsX.js","assets/SettingsErrorCauses-BA8Zr_cv.css","assets/SettingsStatuses-C1SsLo53.js","assets/SettingsStatuses-XHEFsW5N.css","assets/SettingsDefaultParams-Bwa8C6ww.js"])))=>i.map(i=>d[i]);
+(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))s(r);new MutationObserver(r=>{for(const i of r)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&s(o)}).observe(document,{childList:!0,subtree:!0});function n(r){const i={};return r.integrity&&(i.integrity=r.integrity),r.referrerPolicy&&(i.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?i.credentials="include":r.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function s(r){if(r.ep)return;r.ep=!0;const i=n(r);fetch(r.href,i)}})();const ao="modulepreload",ho=function(e){return"/StudyBase/"+e},$s={},re=function(t,n,s){let r=Promise.resolve();if(n&&n.length>0){document.getElementsByTagName("link");const o=document.querySelector("meta[property=csp-nonce]"),l=(o==null?void 0:o.nonce)||(o==null?void 0:o.getAttribute("nonce"));r=Promise.allSettled(n.map(c=>{if(c=ho(c),c in $s)return;$s[c]=!0;const d=c.endsWith(".css"),f=d?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${c}"]${f}`))return;const h=document.createElement("link");if(h.rel=d?"stylesheet":ao,d||(h.as="script"),h.crossOrigin="",h.href=c,l&&h.setAttribute("nonce",l),document.head.appendChild(h),d)return new Promise((g,m)=>{h.addEventListener("load",g),h.addEventListener("error",()=>m(new Error(`Unable to preload CSS for ${c}`)))})}))}function i(o){const l=new Event("vite:preloadError",{cancelable:!0});if(l.payload=o,window.dispatchEvent(l),!l.defaultPrevented)throw o}return r.then(o=>{for(const l of o||[])l.status==="rejected"&&i(l.reason);return t().catch(i)})};/**
+* @vue/shared v3.5.42
+* (c) 2018-present Yuxi (Evan) You and Vue contributors
+* @license MIT
+**/function Rs(e){const t=Object.create(null);for(const n of e.split(","))t[n]=1;return n=>n in t}const X={},Dt=[],qe=()=>{},Kr=()=>!1,In=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),Nn=e=>e.startsWith("onUpdate:"),fe=Object.assign,xs=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},po=Object.prototype.hasOwnProperty,q=(e,t)=>po.call(e,t),L=Array.isArray,gt=e=>fn(e)==="[object Map]",st=e=>fn(e)==="[object Set]",zs=e=>fn(e)==="[object Date]",B=e=>typeof e=="function",ne=e=>typeof e=="string",$e=e=>typeof e=="symbol",$=e=>e!==null&&typeof e=="object",Gr=e=>($(e)||B(e))&&B(e.then)&&B(e.catch),Wr=Object.prototype.toString,fn=e=>Wr.call(e),go=e=>fn(e).slice(8,-1),kr=e=>fn(e)==="[object Object]",Ss=e=>ne(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,Jt=Rs(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),Dn=e=>{const t=Object.create(null);return n=>t[n]||(t[n]=e(n))},mo=/-\w/g,_e=Dn(e=>e.replace(mo,t=>t.slice(1).toUpperCase())),_o=/\B([A-Z])/g,_t=Dn(e=>e.replace(_o,"-$1").toLowerCase()),Mn=Dn(e=>e.charAt(0).toUpperCase()+e.slice(1)),$n=Dn(e=>e?`on${Mn(e)}`:""),ke=(e,t)=>!Object.is(e,t),yn=(e,...t)=>{for(let n=0;n<e.length;n++)e[n](...t)},qr=(e,t,n,s=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:s,value:n})},Ln=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let Js;const Vn=()=>Js||(Js=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function ws(e){if(L(e)){const t={};for(let n=0;n<e.length;n++){const s=e[n],r=ne(s)?Eo(s):ws(s);if(r)for(const i in r)t[i]=r[i]}return t}else if(ne(e)||$(e))return e}const yo=/;(?![^(]*\))/g,vo=/:([^]+)/,bo=/\/\*[^]*?\*\//g;function Eo(e){const t={};return e.replace(bo,"").split(yo).forEach(n=>{if(n){const s=n.split(vo);s.length>1&&(t[s[0].trim()]=s[1].trim())}}),t}function Cs(e){let t="";if(ne(e))t=e;else if(L(e))for(let n=0;n<e.length;n++){const s=Cs(e[n]);s&&(t+=s+" ")}else if($(e))for(const n in e)e[n]&&(t+=n+" ");return t.trim()}const Ao="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly",Ro=Rs(Ao);function $r(e){return!!e||e===""}function xo(e,t){if(e.length!==t.length)return!1;let n=!0;for(let s=0;n&&s<e.length;s++)n=Te(e[s],t[s]);return n}function Qs(e,t){if(e.size!==t.size)return!1;const n=Array.from(t),s=new Uint8Array(n.length);for(const r of e){let i=-1;for(let o=0;o<n.length;o++)if(!s[o]&&Te(r,n[o])){i=o;break}if(i<0)return!1;s[i]=1}return!0}function Te(e,t){if(e===t)return!0;let n=zs(e),s=zs(t);if(n||s)return n&&s?e.getTime()===t.getTime():!1;if(n=$e(e),s=$e(t),n||s)return e===t;if(n=L(e),s=L(t),n||s)return n&&s?xo(e,t):!1;if(n=$(e),s=$(t),n||s){if(!n||!s)return!1;if(n=gt(e),s=gt(t),n||s||(n=st(e),s=st(t),n||s))return n&&s?Qs(e,t):!1;const r=Object.keys(e).length,i=Object.keys(t).length;if(r!==i)return!1;for(const o in e){const l=e.hasOwnProperty(o),c=t.hasOwnProperty(o);if(l&&!c||!l&&c||!Te(e[o],t[o]))return!1}}return String(e)===String(t)}function Os(e,t){return e.findIndex(n=>Te(n,t))}const zr=e=>!!(e&&e.__v_isRef===!0),So=e=>ne(e)?e:e==null?"":L(e)||$(e)&&(e.toString===Wr||!B(e.toString))?zr(e)?So(e.value):JSON.stringify(e,Jr,2):String(e),Jr=(e,t)=>zr(t)?Jr(e,t.value):gt(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((n,[s,r],i)=>(n[zn(s,i)+" =>"]=r,n),{})}:st(t)?{[`Set(${t.size})`]:[...t.values()].map(n=>zn(n))}:$e(t)?zn(t):$(t)&&!L(t)&&!kr(t)?String(t):t,zn=(e,t="")=>{var n;return $e(e)?`Symbol(${(n=e.description)!=null?n:t})`:e};/**
+* @vue/reactivity v3.5.42
+* (c) 2018-present Yuxi (Evan) You and Vue contributors
+* @license MIT
+**/let ue;class wo{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&ue&&(ue.active?(this.parent=ue,this.index=(ue.scopes||(ue.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,n;if(this.scopes){const s=this.scopes.slice();for(t=0,n=s.length;t<n;t++)s[t].pause()}for(t=0,n=this.effects.length;t<n;t++)this.effects[t].pause()}}resume(){if(this._active&&this._isPaused){this._isPaused=!1;let t,n;if(this.scopes){const r=this.scopes.slice();for(t=0,n=r.length;t<n;t++)r[t].resume()}const s=this.effects.slice();for(t=0,n=s.length;t<n;t++)s[t].resume()}}run(t){if(this._active){const n=ue;try{return ue=this,t()}finally{ue=n}}}on(){++this._on===1&&(this.prevScope=ue,ue=this)}off(){if(this._on>0&&--this._on===0){if(ue===this)ue=this.prevScope;else{let t=ue;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let n,s;for(n=0,s=this.effects.length;n<s;n++)this.effects[n].stop();for(this.effects.length=0,n=0,s=this.cleanups.length;n<s;n++)this.cleanups[n]();if(this.cleanups.length=0,this.scopes){const r=this.scopes.slice();for(n=0,s=r.length;n<s;n++)r[n].stop(!0);this.scopes.length=0}if(!this.detached&&this.parent&&!t){const r=this.parent.scopes.pop();r&&r!==this&&(this.parent.scopes[this.index]=r,r.index=this.index)}this.parent=void 0}}}function Co(){return ue}let Z;const Jn=new WeakSet;class Qr{constructor(t){this.fn=t,this.deps=void 0,this.depsTail=void 0,this.flags=5,this.next=void 0,this.cleanup=void 0,this.scheduler=void 0,ue&&(ue.active?ue.effects.push(this):this.flags&=-2)}pause(){this.flags|=64}resume(){this.flags&64&&(this.flags&=-65,Jn.has(this)&&(Jn.delete(this),this.trigger()))}notify(){this.flags&2&&!(this.flags&32)||this.flags&8||Xr(this)}run(){if(!(this.flags&1))return this.fn();this.flags|=2,Ys(this),Zr(this);const t=Z,n=Oe;Z=this,Oe=!0;try{return this.fn()}finally{ei(this),Z=t,Oe=n,this.flags&=-3}}stop(){if(this.flags&1){for(let t=this.deps;t;t=t.nextDep)Is(t);this.deps=this.depsTail=void 0,Ys(this),this.onStop&&this.onStop(),this.flags&=-2}}trigger(){this.flags&64?Jn.add(this):this.scheduler?this.scheduler():this.runIfDirty()}runIfDirty(){us(this)&&this.run()}get dirty(){return us(this)}}let Yr=0,Qt,Yt;function Xr(e,t=!1){if(e.flags|=8,t){e.next=Yt,Yt=e;return}e.next=Qt,Qt=e}function Ps(){Yr++}function Ts(){if(--Yr>0)return;if(Yt){let t=Yt;for(Yt=void 0;t;){const n=t.next;t.next=void 0,t.flags&=-9,t=n}}let e;for(;Qt;){let t=Qt;for(Qt=void 0;t;){const n=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(s){e||(e=s)}t=n}}if(e)throw e}function Zr(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function ei(e){let t,n=e.depsTail,s=n;for(;s;){const r=s.prevDep;s.version===-1?(s===n&&(n=r),Is(s),Oo(s)):t=s,s.dep.activeLink=s.prevActiveLink,s.prevActiveLink=void 0,s=r}e.deps=t,e.depsTail=n}function us(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(ti(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function ti(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===nn)||(e.globalVersion=nn,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!us(e))))return;e.flags|=2;const t=e.dep,n=Z,s=Oe;Z=e,Oe=!0;try{Zr(e);const r=e.fn(e._value);(t.version===0||ke(r,e._value))&&(e.flags|=128,e._value=r,t.version++)}catch(r){throw t.version++,r}finally{Z=n,Oe=s,ei(e),e.flags&=-3}}function Is(e,t=!1){const{dep:n,prevSub:s,nextSub:r}=e;if(s&&(s.nextSub=r,e.prevSub=void 0),r&&(r.prevSub=s,e.nextSub=void 0),n.subs===e&&(n.subs=s,!s&&n.computed)){n.computed.flags&=-5;for(let i=n.computed.deps;i;i=i.nextDep)Is(i,!0)}!t&&!--n.sc&&n.map&&n.map.delete(n.key)}function Oo(e){const{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}let Oe=!0;const ni=[];function rt(){ni.push(Oe),Oe=!1}function it(){const e=ni.pop();Oe=e===void 0?!0:e}function Ys(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const n=Z;Z=void 0;try{t()}finally{Z=n}}}let nn=0;class Po{constructor(t,n){this.sub=t,this.dep=n,this.version=n.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class Ns{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!Z||!Oe||Z===this.computed)return;let n=this.activeLink;if(n===void 0||n.sub!==Z)n=this.activeLink=new Po(Z,this),Z.deps?(n.prevDep=Z.depsTail,Z.depsTail.nextDep=n,Z.depsTail=n):Z.deps=Z.depsTail=n,si(n);else if(n.version===-1&&(n.version=this.version,n.nextDep)){const s=n.nextDep;s.prevDep=n.prevDep,n.prevDep&&(n.prevDep.nextDep=s),n.prevDep=Z.depsTail,n.nextDep=void 0,Z.depsTail.nextDep=n,Z.depsTail=n,Z.deps===n&&(Z.deps=s)}return n}trigger(t){this.version++,nn++,this.notify(t)}notify(t){Ps();try{for(let n=this.subs;n;n=n.prevSub)n.sub.notify()&&n.sub.dep.notify()}finally{Ts()}}}function si(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let s=t.deps;s;s=s.nextDep)si(s)}const n=e.dep.subs;n!==e&&(e.prevSub=n,n&&(n.nextSub=e)),e.dep.subs=e}}const fs=new WeakMap,xt=Symbol(""),as=Symbol(""),sn=Symbol("");function ae(e,t,n){if(Oe&&Z){let s=fs.get(e);s||fs.set(e,s=new Map);let r=s.get(n);r||(s.set(n,r=new Ns),r.map=s,r.key=n),r.track()}}function et(e,t,n,s,r,i){const o=fs.get(e);if(!o){nn++;return}const l=c=>{c&&c.trigger()};if(Ps(),t==="clear")o.forEach(l);else{const c=L(e),d=c&&Ss(n);if(c&&n==="length"){const f=Number(s);o.forEach((h,g)=>{(g==="length"||g===sn||!$e(g)&&g>=f)&&l(h)})}else switch((n!==void 0||o.has(void 0))&&l(o.get(n)),d&&l(o.get(sn)),t){case"add":c?d&&l(o.get("length")):(l(o.get(xt)),gt(e)&&l(o.get(as)));break;case"delete":c||(l(o.get(xt)),gt(e)&&l(o.get(as)));break;case"set":gt(e)&&l(o.get(xt));break}}Ts()}function Tt(e){const t=k(e);return t===e?t:(ae(t,"iterate",sn),we(e)?t:t.map(Ie))}function Fn(e){return ae(e=k(e),"iterate",sn),e}function Ge(e,t){return ot(e)?Ft(St(e)?Ie(t):t):Ie(t)}const To={__proto__:null,[Symbol.iterator](){return Qn(this,Symbol.iterator,e=>Ge(this,e))},concat(...e){return Tt(this).concat(...e.map(t=>L(t)?Tt(t):t))},entries(){return Qn(this,"entries",e=>(e[1]=Ge(this,e[1]),e))},every(e,t){return Je(this,"every",e,t,void 0,arguments)},filter(e,t){return Je(this,"filter",e,t,n=>n.map(s=>Ge(this,s)),arguments)},find(e,t){return Je(this,"find",e,t,n=>Ge(this,n),arguments)},findIndex(e,t){return Je(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return Je(this,"findLast",e,t,n=>Ge(this,n),arguments)},findLastIndex(e,t){return Je(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return Je(this,"forEach",e,t,void 0,arguments)},includes(...e){return Yn(this,"includes",e)},indexOf(...e){return Yn(this,"indexOf",e)},join(e){return Tt(this).join(e)},lastIndexOf(...e){return Yn(this,"lastIndexOf",e)},map(e,t){return Je(this,"map",e,t,void 0,arguments)},pop(){return Wt(this,"pop")},push(...e){return Wt(this,"push",e)},reduce(e,...t){return Xs(this,"reduce",e,t)},reduceRight(e,...t){return Xs(this,"reduceRight",e,t)},shift(){return Wt(this,"shift")},some(e,t){return Je(this,"some",e,t,void 0,arguments)},splice(...e){return Wt(this,"splice",e)},toReversed(){return Tt(this).toReversed()},toSorted(e){return Tt(this).toSorted(e)},toSpliced(...e){return Tt(this).toSpliced(...e)},unshift(...e){return Wt(this,"unshift",e)},values(){return Qn(this,"values",e=>Ge(this,e))}};function Qn(e,t,n){const s=Fn(e),r=s[t]();return s!==e&&!we(e)&&(r._next=r.next,r.next=()=>{const i=r._next();return i.done||(i.value=n(i.value)),i}),r}const Io=Array.prototype;function Je(e,t,n,s,r,i){const o=Fn(e),l=o!==e&&!we(e),c=o[t];if(c!==Io[t]){const h=c.apply(e,i);return l?Ie(h):h}let d=n;o!==e&&(l?d=function(h,g){return n.call(this,Ge(e,h),g,e)}:n.length>2&&(d=function(h,g){return n.call(this,h,g,e)}));const f=c.call(o,d,s);return l&&r?r(f):f}function Xs(e,t,n,s){const r=Fn(e),i=r!==e&&!we(e);let o=n,l=!1;r!==e&&(i?(l=s.length===0,o=function(d,f,h){return l&&(l=!1,d=Ge(e,d)),n.call(this,d,Ge(e,f),h,e)}):n.length>3&&(o=function(d,f,h){return n.call(this,d,f,h,e)}));const c=r[t](o,...s);return l?Ge(e,c):c}function Yn(e,t,n){const s=k(e);ae(s,"iterate",sn);const r=s[t](...n);return(r===-1||r===!1)&&Ls(n[0])?(n[0]=k(n[0]),s[t](...n)):r}function Wt(e,t,n=[]){rt(),Ps();const s=k(e)[t].apply(e,n);return Ts(),it(),s}const No=Rs("__proto__,__v_isRef,__isVue"),ri=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter($e));function Do(e){$e(e)||(e=String(e));const t=k(this);return ae(t,"has",e),t.hasOwnProperty(e)}class ii{constructor(t=!1,n=!1){this._isReadonly=t,this._isShallow=n}get(t,n,s){if(n==="__v_skip")return t.__v_skip;const r=this._isReadonly,i=this._isShallow;if(n==="__v_isReactive")return!r;if(n==="__v_isReadonly")return r;if(n==="__v_isShallow")return i;if(n==="__v_raw")return s===(r?i?Go:ui:i?ci:li).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(s)?t:void 0;const o=L(t);if(!r){let c;if(o&&(c=To[n]))return c;if(n==="hasOwnProperty")return Do}const l=Reflect.get(t,n,he(t)?t:s);if(($e(n)?ri.has(n):No(n))||(r||ae(t,"get",n),i))return l;if(he(l)){const c=o&&Ss(n)?l:l.value;return r&&$(c)?hs(c):c}return $(l)?r?hs(l):jn(l):l}}class oi extends ii{constructor(t=!1){super(!1,t)}set(t,n,s,r){let i=t[n];const o=L(t)&&Ss(n);if(!this._isShallow){const d=ot(i);if(!we(s)&&!ot(s)&&(i=k(i),s=k(s)),!o&&he(i)&&!he(s))return d||(i.value=s),!0}const l=o?Number(n)<t.length:q(t,n),c=Reflect.set(t,n,s,he(t)?t:r);return t===k(r)&&c&&(l?ke(s,i)&&et(t,"set",n,s):et(t,"add",n,s)),c}deleteProperty(t,n){const s=q(t,n);t[n];const r=Reflect.deleteProperty(t,n);return r&&s&&et(t,"delete",n,void 0),r}has(t,n){const s=Reflect.has(t,n);return(!$e(n)||!ri.has(n))&&ae(t,"has",n),s}ownKeys(t){return ae(t,"iterate",L(t)?"length":xt),Reflect.ownKeys(t)}}class Mo extends ii{constructor(t=!1){super(!0,t)}set(t,n){return!0}deleteProperty(t,n){return!0}}const Lo=new oi,Vo=new Mo,Fo=new oi(!0);const ds=e=>e,hn=e=>Reflect.getPrototypeOf(e);function jo(e,t,n){return function(...s){const r=this.__v_raw,i=k(r),o=gt(i),l=e==="entries"||e===Symbol.iterator&&o,c=e==="keys"&&o,d=r[e](...s),f=n?ds:t?Ft:Ie;return!t&&ae(i,"iterate",c?as:xt),fe(Object.create(d),{next(){const{value:h,done:g}=d.next();return g?{value:h,done:g}:{value:l?[f(h[0]),f(h[1])]:f(h),done:g}}})}}function pn(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function Ho(e,t){const n={get(r){const i=this.__v_raw,o=k(i),l=k(r);e||(ke(r,l)&&ae(o,"get",r),ae(o,"get",l));const{has:c}=hn(o),d=t?ds:e?Ft:Ie;if(c.call(o,r))return d(i.get(r));if(c.call(o,l))return d(i.get(l));i!==o&&i.get(r)},get size(){const r=this.__v_raw;return!e&&ae(k(r),"iterate",xt),r.size},has(r){const i=this.__v_raw,o=k(i),l=k(r);return e||(ke(r,l)&&ae(o,"has",r),ae(o,"has",l)),r===l?i.has(r):i.has(r)||i.has(l)},forEach(r,i){const o=this,l=o.__v_raw,c=k(l),d=t?ds:e?Ft:Ie;return!e&&ae(c,"iterate",xt),l.forEach((f,h)=>r.call(i,d(f),d(h),o))}};return fe(n,e?{add:pn("add"),set:pn("set"),delete:pn("delete"),clear:pn("clear")}:{add(r){const i=k(this),o=hn(i),l=k(r),c=!t&&!we(r)&&!ot(r)?l:r;return o.has.call(i,c)||ke(r,c)&&o.has.call(i,r)||ke(l,c)&&o.has.call(i,l)||(i.add(c),et(i,"add",c,c)),this},set(r,i){!t&&!we(i)&&!ot(i)&&(i=k(i));const o=k(this),{has:l,get:c}=hn(o);let d=l.call(o,r);d||(r=k(r),d=l.call(o,r));const f=c.call(o,r);return o.set(r,i),d?ke(i,f)&&et(o,"set",r,i):et(o,"add",r,i),this},delete(r){const i=k(this),{has:o,get:l}=hn(i);let c=o.call(i,r);c||(r=k(r),c=o.call(i,r)),l&&l.call(i,r);const d=i.delete(r);return c&&et(i,"delete",r,void 0),d},clear(){const r=k(this),i=r.size!==0,o=r.clear();return i&&et(r,"clear",void 0,void 0),o}}),["keys","values","entries",Symbol.iterator].forEach(r=>{n[r]=jo(r,e,t)}),n}function Ds(e,t){const n=Ho(e,t);return(s,r,i)=>r==="__v_isReactive"?!e:r==="__v_isReadonly"?e:r==="__v_raw"?s:Reflect.get(q(n,r)&&r in s?n:s,r,i)}const Bo={get:Ds(!1,!1)},Uo={get:Ds(!1,!0)},Ko={get:Ds(!0,!1)};const li=new WeakMap,ci=new WeakMap,ui=new WeakMap,Go=new WeakMap;function Wo(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function jn(e){return ot(e)?e:Ms(e,!1,Lo,Bo,li)}function fi(e){return Ms(e,!1,Fo,Uo,ci)}function hs(e){return Ms(e,!0,Vo,Ko,ui)}function Ms(e,t,n,s,r){if(!$(e)||e.__v_raw&&!(t&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;const i=r.get(e);if(i)return i;const o=Wo(go(e));if(o===0)return e;const l=new Proxy(e,o===2?s:n);return r.set(e,l),l}function St(e){return ot(e)?St(e.__v_raw):!!(e&&e.__v_isReactive)}function ot(e){return!!(e&&e.__v_isReadonly)}function we(e){return!!(e&&e.__v_isShallow)}function Ls(e){return e?!!e.__v_raw:!1}function k(e){const t=e&&e.__v_raw;return t?k(t):e}function ko(e){return!q(e,"__v_skip")&&Object.isExtensible(e)&&qr(e,"__v_skip",!0),e}const Ie=e=>$(e)?jn(e):e,Ft=e=>$(e)?hs(e):e;function he(e){return e?e.__v_isRef===!0:!1}function qo(e){return ai(e,!1)}function $o(e){return ai(e,!0)}function ai(e,t){return he(e)?e:new zo(e,t)}class zo{constructor(t,n){this.dep=new Ns,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=n?t:k(t),this._value=n?t:Ie(t),this.__v_isShallow=n}get value(){return this.dep.track(),this._value}set value(t){const n=this._rawValue,s=this.__v_isShallow||we(t)||ot(t);t=s?t:k(t),ke(t,n)&&(this._rawValue=t,this._value=s?t:Ie(t),this.dep.trigger())}}function Mt(e){return he(e)?e.value:e}const Jo={get:(e,t,n)=>t==="__v_raw"?e:Mt(Reflect.get(e,t,n)),set:(e,t,n,s)=>{const r=e[t];return he(r)&&!he(n)?(r.value=n,!0):Reflect.set(e,t,n,s)}};function di(e){return St(e)?e:new Proxy(e,Jo)}class Qo{constructor(t,n,s){this.fn=t,this.setter=n,this._value=void 0,this.dep=new Ns(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=nn-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!n,this.isSSR=s}notify(){if(this.flags|=16,!(this.flags&8)&&Z!==this)return Xr(this,!0),!0}get value(){const t=this.dep.track();return ti(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function Yo(e,t,n=!1){let s,r;return B(e)?s=e:(s=e.get,r=e.set),new Qo(s,r,n)}const gn={},An=new WeakMap;let At;function Xo(e,t=!1,n=At){if(n){let s=An.get(n);s||An.set(n,s=[]),s.push(e)}}function Zo(e,t,n=X){const{immediate:s,deep:r,once:i,scheduler:o,augmentJob:l,call:c}=n,d=T=>r?T:we(T)||r===!1||r===0?tt(T,1):tt(T);let f,h,g,m,I=!1,S=!1;if(he(e)?(h=()=>e.value,I=we(e)):St(e)?(h=()=>d(e),I=!0):L(e)?(S=!0,I=e.some(T=>St(T)||we(T)),h=()=>e.map(T=>{if(he(T))return T.value;if(St(T))return d(T);if(B(T))return c?c(T,2):T()})):B(e)?t?h=c?()=>c(e,2):e:h=()=>{if(g){rt();try{g()}finally{it()}}const T=At;At=f;try{return c?c(e,3,[m]):e(m)}finally{At=T}}:h=qe,t&&r){const T=h,Q=r===!0?1/0:r;h=()=>tt(T(),Q)}const H=Co(),F=()=>{f.stop(),H&&H.active&&xs(H.effects,f)};if(i&&t){const T=t;t=(...Q)=>{const ie=T(...Q);return F(),ie}}let O=S?new Array(e.length).fill(gn):gn;const N=T=>{if(!(!(f.flags&1)||!f.dirty&&!T))if(t){const Q=f.run();if(T||r||I||(S?Q.some((ie,ee)=>ke(ie,O[ee])):ke(Q,O))){g&&g();const ie=At;At=f;try{const ee=[Q,O===gn?void 0:S&&O[0]===gn?[]:O,m];O=Q,c?c(t,3,ee):t(...ee)}finally{At=ie}}}else f.run()};return l&&l(N),f=new Qr(h),f.scheduler=o?()=>o(N,!1):N,m=T=>Xo(T,!1,f),g=f.onStop=()=>{const T=An.get(f);if(T){if(c)c(T,4);else for(const Q of T)Q();An.delete(f)}},t?s?N(!0):O=f.run():o?o(N.bind(null,!0),!0):f.run(),F.pause=f.pause.bind(f),F.resume=f.resume.bind(f),F.stop=F,F}function tt(e,t=1/0,n){if(t<=0||!$(e)||e.__v_skip||(n=n||new Map,(n.get(e)||0)>=t))return e;if(n.set(e,t),t--,he(e))tt(e.value,t,n);else if(L(e))for(let s=0;s<e.length;s++)tt(e[s],t,n);else if(st(e)||gt(e))e.forEach(s=>{tt(s,t,n)});else if(kr(e)){for(const s in e)tt(e[s],t,n);for(const s of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,s)&&tt(e[s],t,n)}return e}/**
+* @vue/runtime-core v3.5.42
+* (c) 2018-present Yuxi (Evan) You and Vue contributors
+* @license MIT
+**/function an(e,t,n,s){try{return s?e(...s):e()}catch(r){Hn(r,t,n)}}function Ne(e,t,n,s){if(B(e)){const r=an(e,t,n,s);return r&&Gr(r)&&r.catch(i=>{Hn(i,t,n)}),r}if(L(e)){const r=[];for(let i=0;i<e.length;i++)r.push(Ne(e[i],t,n,s));return r}}function Hn(e,t,n,s=!0){const r=t?t.vnode:null,{errorHandler:i,throwUnhandledErrorInProduction:o}=t&&t.appContext.config||X;if(t){let l=t.parent;const c=t.proxy,d=`https://vuejs.org/error-reference/#runtime-${n}`;for(;l;){const f=l.ec;if(f){for(let h=0;h<f.length;h++)if(f[h](e,c,d)===!1)return}l=l.parent}if(i){rt(),an(i,null,10,[e,c,d]),it();return}}el(e,n,r,s,o)}function el(e,t,n,s=!0,r=!1){if(r)throw e;console.error(e)}const me=[];let Ke=-1;const Lt=[];let ht=null,It=0;const hi=Promise.resolve();let Rn=null;function Vs(e){const t=Rn||hi;return e?t.then(this?e.bind(this):e):t}function tl(e){let t=Ke+1,n=me.length;for(;t<n;){const s=t+n>>>1,r=me[s],i=rn(r);i<e||i===e&&r.flags&2?t=s+1:n=s}return t}function Fs(e){if(!(e.flags&1)){const t=rn(e),n=me[me.length-1];!n||!(e.flags&2)&&t>=rn(n)?me.push(e):me.splice(tl(t),0,e),e.flags|=1,pi()}}function pi(){Rn||(Rn=hi.then(mi))}function nl(e){if(!L(e))ht&&e.id===-1?ht.splice(It+1,0,e):e.flags&1||(Lt.push(e),e.flags|=1);else for(let t=0;t<e.length;t++)Lt.push(e[t]);pi()}function Zs(e,t,n=Ke+1){for(;n<me.length;n++){const s=me[n];if(s&&s.flags&2){if(e&&s.id!==e.uid)continue;me.splice(n,1),n--,s.flags&4&&(s.flags&=-2),s(),s.flags&4||(s.flags&=-2)}}}function gi(e){if(Lt.length){const t=[...new Set(Lt)].sort((n,s)=>rn(n)-rn(s));if(Lt.length=0,ht){for(let n=0;n<t.length;n++)ht.push(t[n]);return}for(ht=t,It=0;It<ht.length;It++){const n=ht[It];n.flags&4&&(n.flags&=-2),n.flags&8||n(),n.flags&=-2}ht=null,It=0}}const rn=e=>e.id==null?e.flags&2?-1:1/0:e.id;function mi(e){try{for(Ke=0;Ke<me.length;Ke++){const t=me[Ke];t&&!(t.flags&8)&&(t.flags&4&&(t.flags&=-2),an(t,t.i,t.i?15:14),t.flags&4||(t.flags&=-2))}}finally{for(;Ke<me.length;Ke++){const t=me[Ke];t&&(t.flags&=-2)}Ke=-1,me.length=0,gi(),Rn=null,(me.length||Lt.length)&&mi()}}let Ee=null,_i=null;function xn(e){const t=Ee;return Ee=e,_i=e&&e.type.__scopeId||null,t}function sl(e,t=Ee,n){if(!t||e._n)return e;const s=(...r)=>{s._d&&Cn(-1);const i=xn(t),o=wt.length;let l;try{l=e(...r)}finally{for(let c=wt.length;c>o;c--)Gi();xn(i),s._d&&Cn(1)}return l};return s._n=!0,s._c=!0,s._d=!0,s}function ef(e,t){if(Ee===null)return e;const n=Wn(Ee),s=e.dirs||(e.dirs=[]);for(let r=0;r<t.length;r++){let[i,o,l,c=X]=t[r];i&&(B(i)&&(i={mounted:i,updated:i}),i.deep&&tt(o),s.push({dir:i,instance:n,value:o,oldValue:void 0,arg:l,modifiers:c}))}return e}function bt(e,t,n,s){const r=e.dirs,i=t&&t.dirs;for(let o=0;o<r.length;o++){const l=r[o];i&&(l.oldValue=i[o].value);let c=l.dir[s];c&&(rt(),Ne(c,n,8,[e.el,l,e,t]),it())}}function vn(e,t){if(de){let n=de.provides;const s=de.parent&&de.parent.provides;s===n&&(n=de.provides=Object.create(s)),n[e]=t}}function Pe(e,t,n=!1){const s=rc();if(s||Vt){let r=Vt?Vt._context.provides:s?s.parent==null||s.ce?s.vnode.appContext&&s.vnode.appContext.provides:s.parent.provides:void 0;if(r&&e in r)return r[e];if(arguments.length>1)return n&&B(t)?t.call(s&&s.proxy):t}}const rl=Symbol.for("v-scx"),il=()=>Pe(rl);function bn(e,t,n){return yi(e,t,n)}function yi(e,t,n=X){const{immediate:s,deep:r,flush:i,once:o}=n,l=fe({},n),c=t&&s||!t&&i!=="post";let d;if(cn){if(i==="sync"){const m=il();d=m.__watcherHandles||(m.__watcherHandles=[])}else if(!c){const m=()=>{};return m.stop=qe,m.resume=qe,m.pause=qe,m}}const f=de;l.call=(m,I,S)=>Ne(m,f,I,S);let h=!1;i==="post"?l.scheduler=m=>{ve(m,f&&f.suspense)}:i!=="sync"&&(h=!0,l.scheduler=(m,I)=>{I?m():Fs(m)}),l.augmentJob=m=>{t&&(m.flags|=4),h&&(m.flags|=2,f&&(m.id=f.uid,m.i=f))};const g=Zo(e,t,l);return cn&&(d?d.push(g):c&&g()),g}function ol(e,t,n){const s=this.proxy,r=ne(e)?e.includes(".")?vi(s,e):()=>s[e]:e.bind(s,s);let i;B(t)?i=t:(i=t.handler,n=t);const o=dn(this),l=yi(r,i.bind(s),n);return o(),l}function vi(e,t){const n=t.split(".");return()=>{let s=e;for(let r=0;r<n.length&&s;r++)s=s[n[r]];return s}}const ll=Symbol("_vte"),Bn=e=>e.__isTeleport,Xn=Symbol("_leaveCb");function cl(e){let t=e[0];if(e.length>1){for(const n of e)if(n.type!==lt){t=n;break}}return t}function bi(e){if(!Hs(e))return Bn(e.type)&&e.children?cl(e.children):e;if(e.component)return e.component.subTree;const{shapeFlag:t,children:n}=e;if(n){if(t&16)return n[0];if(t&32&&B(n.default))return n.default()}}function js(e,t){if(e.shapeFlag&6&&e.component){e.transition=t;const n=e.component.subTree;js(Bn(n.type)&&bi(n)||n,t)}else e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Ei(e,t){return B(e)?fe({name:e.name},t,{setup:e}):e}function Ai(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function er(e,t){let n;return!!((n=Object.getOwnPropertyDescriptor(e,t))&&!n.configurable)}const Sn=new WeakMap;function Xt(e,t,n,s,r=!1){if(L(e)){e.forEach((S,H)=>Xt(S,t&&(L(t)?t[H]:t),n,s,r));return}if(Zt(s)&&!r){s.shapeFlag&512&&s.type.__asyncResolved&&s.component.subTree.component&&Xt(e,t,n,s.component.subTree);return}const i=s.shapeFlag&4?Wn(s.component):s.el,o=r?null:i,{i:l,r:c}=e,d=t&&t.r,f=l.refs===X?l.refs={}:l.refs,h=l.setupState,g=k(h),m=h===X?Kr:S=>er(f,S)?!1:q(g,S),I=(S,H)=>!(H&&er(f,H));if(d!=null&&d!==c){if(tr(t),ne(d))f[d]=null,m(d)&&(h[d]=null);else if(he(d)){const S=t;I(d,S.k)&&(d.value=null),S.k&&(f[S.k]=null)}}if(B(c))an(c,l,12,[o,f]);else{const S=ne(c),H=he(c);if(S||H){const F=()=>{if(e.f){const O=S?m(c)?h[c]:f[c]:I()||!e.k?c.value:f[e.k];if(r)L(O)&&xs(O,i);else if(L(O))O.includes(i)||O.push(i);else if(S)f[c]=[i],m(c)&&(h[c]=f[c]);else{const N=[i];I(c,e.k)&&(c.value=N),e.k&&(f[e.k]=N)}}else S?(f[c]=o,m(c)&&(h[c]=o)):H&&(I(c,e.k)&&(c.value=o),e.k&&(f[e.k]=o))};if(o){const O=()=>{F(),Sn.delete(e)};O.id=-1,Sn.set(e,O),ve(O,n)}else tr(e),F()}}}function tr(e){const t=Sn.get(e);t&&(t.flags|=8,Sn.delete(e))}Vn().requestIdleCallback;Vn().cancelIdleCallback;const Zt=e=>!!e.type.__asyncLoader,Hs=e=>e.type.__isKeepAlive;function ul(e,t){Ri(e,"a",t)}function fl(e,t){Ri(e,"da",t)}function Ri(e,t,n=de){const s=e.__wdc||(e.__wdc=()=>{let r=n;for(;r;){if(r.isDeactivated)return;r=r.parent}return e()});if(Un(t,s,n),n){let r=n.parent;for(;r&&r.parent;)Hs(r.parent.vnode)&&al(s,t,n,r),r=r.parent}}function al(e,t,n,s){const r=Un(t,e,s,!0);xi(()=>{xs(s[t],r)},n)}function Un(e,t,n=de,s=!1){if(n){const r=n[e]||(n[e]=[]),i=t.__weh||(t.__weh=(...o)=>{rt();const l=dn(n),c=Ne(t,n,e,o);return l(),it(),c});return s?r.unshift(i):r.push(i),i}}const ct=e=>(t,n=de)=>{(!cn||e==="sp")&&Un(e,(...s)=>t(...s),n)},dl=ct("bm"),hl=ct("m"),pl=ct("bu"),gl=ct("u"),ml=ct("bum"),xi=ct("um"),_l=ct("sp"),yl=ct("rtg"),vl=ct("rtc");function bl(e,t=de){Un("ec",e,t)}const El="components";function Al(e,t){return xl(El,e,!0,t)||e}const Rl=Symbol.for("v-ndc");function xl(e,t,n=!0,s=!1){const r=Ee||de;if(r){const i=r.type;{const l=uc(i,!1);if(l&&(l===t||l===_e(t)||l===Mn(_e(t))))return i}const o=nr(r[e]||i[e],t)||nr(r.appContext[e],t);return!o&&s?i:o}}function nr(e,t){return e&&(e[t]||e[_e(t)]||e[Mn(_e(t))])}function tf(e,t,n,s){let r;const i=n,o=L(e);if(o||ne(e)){const l=o&&St(e);let c=!1,d=!1;l&&(c=!we(e),d=ot(e),e=Fn(e)),r=new Array(e.length);for(let f=0,h=e.length;f<h;f++)r[f]=t(c?d?Ft(Ie(e[f])):Ie(e[f]):e[f],f,void 0,i)}else if(typeof e=="number"){r=new Array(e);for(let l=0;l<e;l++)r[l]=t(l+1,l,void 0,i)}else if($(e))if(e[Symbol.iterator])r=Array.from(e,(l,c)=>t(l,c,void 0,i));else{const l=Object.keys(e);r=new Array(l.length);for(let c=0,d=l.length;c<d;c++){const f=l[c];r[c]=t(e[f],f,c,i)}}else r=[];return r}const ps=e=>e?zi(e)?Wn(e):ps(e.parent):null,en=fe(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>ps(e.parent),$root:e=>ps(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>wi(e),$forceUpdate:e=>e.f||(e.f=()=>{Fs(e.update)}),$nextTick:e=>e.n||(e.n=Vs.bind(e.proxy)),$watch:e=>ol.bind(e)}),Zn=(e,t)=>e!==X&&!e.__isScriptSetup&&q(e,t),Sl={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:n,setupState:s,data:r,props:i,accessCache:o,type:l,appContext:c}=e;if(t[0]!=="$"){const g=o[t];if(g!==void 0)switch(g){case 1:return s[t];case 2:return r[t];case 4:return n[t];case 3:return i[t]}else{if(Zn(s,t))return o[t]=1,s[t];if(r!==X&&q(r,t))return o[t]=2,r[t];if(q(i,t))return o[t]=3,i[t];if(n!==X&&q(n,t))return o[t]=4,n[t];gs&&(o[t]=0)}}const d=en[t];let f,h;if(d)return t==="$attrs"&&ae(e.attrs,"get",""),d(e);if((f=l.__cssModules)&&(f=f[t]))return f;if(n!==X&&q(n,t))return o[t]=4,n[t];if(h=c.config.globalProperties,q(h,t))return h[t]},set({_:e},t,n){const{data:s,setupState:r,ctx:i}=e;return Zn(r,t)?(r[t]=n,!0):s!==X&&q(s,t)?(s[t]=n,!0):q(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(i[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:s,appContext:r,props:i,type:o}},l){let c;return!!(n[l]||e!==X&&l[0]!=="$"&&q(e,l)||Zn(t,l)||q(i,l)||q(s,l)||q(en,l)||q(r.config.globalProperties,l)||(c=o.__cssModules)&&c[l])},defineProperty(e,t,n){return n.get!=null?e._.accessCache[t]=0:q(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}};function sr(e){return L(e)?e.reduce((t,n)=>(t[n]=null,t),{}):e}let gs=!0;function wl(e){const t=wi(e),n=e.proxy,s=e.ctx;gs=!1,t.beforeCreate&&rr(t.beforeCreate,e,"bc");const{data:r,computed:i,methods:o,watch:l,provide:c,inject:d,created:f,beforeMount:h,mounted:g,beforeUpdate:m,updated:I,activated:S,deactivated:H,beforeDestroy:F,beforeUnmount:O,destroyed:N,unmounted:T,render:Q,renderTracked:ie,renderTriggered:ee,errorCaptured:Me,serverPrefetch:ut,expose:Le,inheritAttrs:ft,components:yt,directives:Ve,filters:Kt}=t;if(d&&Cl(d,s,null),o)for(const z in o){const G=o[z];B(G)&&(s[z]=G.bind(n))}if(r){const z=r.call(n,n);$(z)&&(e.data=jn(z))}if(gs=!0,i)for(const z in i){const G=i[z],ze=B(G)?G.bind(n,n):B(G.get)?G.get.bind(n,n):qe,at=!B(G)&&B(G.set)?G.set.bind(n):qe,Fe=Ce({get:ze,set:at});Object.defineProperty(s,z,{enumerable:!0,configurable:!0,get:()=>Fe.value,set:ye=>Fe.value=ye})}if(l)for(const z in l)Si(l[z],s,n,z);if(c){const z=B(c)?c.call(n):c;Reflect.ownKeys(z).forEach(G=>{vn(G,z[G])})}f&&rr(f,e,"c");function le(z,G){L(G)?G.forEach(ze=>z(ze.bind(n))):G&&z(G.bind(n))}if(le(dl,h),le(hl,g),le(pl,m),le(gl,I),le(ul,S),le(fl,H),le(bl,Me),le(vl,ie),le(yl,ee),le(ml,O),le(xi,T),le(_l,ut),L(Le))if(Le.length){const z=e.exposed||(e.exposed={});Le.forEach(G=>{Object.defineProperty(z,G,{get:()=>n[G],set:ze=>n[G]=ze,enumerable:!0})})}else e.exposed||(e.exposed={});Q&&e.render===qe&&(e.render=Q),ft!=null&&(e.inheritAttrs=ft),yt&&(e.components=yt),Ve&&(e.directives=Ve),ut&&Ai(e)}function Cl(e,t,n=qe){L(e)&&(e=ms(e));for(const s in e){const r=e[s];let i;$(r)?"default"in r?i=Pe(r.from||s,r.default,!0):i=Pe(r.from||s):i=Pe(r),he(i)?Object.defineProperty(t,s,{enumerable:!0,configurable:!0,get:()=>i.value,set:o=>i.value=o}):t[s]=i}}function rr(e,t,n){Ne(L(e)?e.map(s=>s.bind(t.proxy)):e.bind(t.proxy),t,n)}function Si(e,t,n,s){let r=s.includes(".")?vi(n,s):()=>n[s];if(ne(e)){const i=t[e];B(i)&&bn(r,i)}else if(B(e))bn(r,e.bind(n));else if($(e))if(L(e))e.forEach(i=>Si(i,t,n,s));else{const i=B(e.handler)?e.handler.bind(n):t[e.handler];B(i)&&bn(r,i,e)}}function wi(e){const t=e.type,{mixins:n,extends:s}=t,{mixins:r,optionsCache:i,config:{optionMergeStrategies:o}}=e.appContext,l=i.get(t);let c;return l?c=l:!r.length&&!n&&!s?c=t:(c={},r.length&&r.forEach(d=>wn(c,d,o,!0)),wn(c,t,o)),$(t)&&i.set(t,c),c}function wn(e,t,n,s=!1){const{mixins:r,extends:i}=t;i&&wn(e,i,n,!0),r&&r.forEach(o=>wn(e,o,n,!0));for(const o in t)if(!(s&&o==="expose")){const l=Ol[o]||n&&n[o];e[o]=l?l(e[o],t[o]):t[o]}return e}const Ol={data:ir,props:or,emits:or,methods:$t,computed:$t,beforeCreate:pe,created:pe,beforeMount:pe,mounted:pe,beforeUpdate:pe,updated:pe,beforeDestroy:pe,beforeUnmount:pe,destroyed:pe,unmounted:pe,activated:pe,deactivated:pe,errorCaptured:pe,serverPrefetch:pe,components:$t,directives:$t,watch:Tl,provide:ir,inject:Pl};function ir(e,t){return t?e?function(){return fe(B(e)?e.call(this,this):e,B(t)?t.call(this,this):t)}:t:e}function Pl(e,t){return $t(ms(e),ms(t))}function ms(e){if(L(e)){const t={};for(let n=0;n<e.length;n++)t[e[n]]=e[n];return t}return e}function pe(e,t){return e?[...new Set([].concat(e,t))]:t}function $t(e,t){return e?fe(Object.create(null),e,t):t}function or(e,t){return e?L(e)&&L(t)?[...new Set([...e,...t])]:fe(Object.create(null),sr(e),sr(t??{})):t}function Tl(e,t){if(!e)return t;if(!t)return e;const n=fe(Object.create(null),e);for(const s in t)n[s]=pe(e[s],t[s]);return n}function Ci(){return{app:null,config:{isNativeTag:Kr,performance:!1,globalProperties:{},optionMergeStrategies:{},errorHandler:void 0,warnHandler:void 0,compilerOptions:{}},mixins:[],components:{},directives:{},provides:Object.create(null),optionsCache:new WeakMap,propsCache:new WeakMap,emitsCache:new WeakMap}}let Il=0;function Nl(e,t){return function(s,r=null){B(s)||(s=fe({},s)),r!=null&&!$(r)&&(r=null);const i=Ci(),o=new WeakSet,l=[];let c=!1;const d=i.app={_uid:Il++,_component:s,_props:r,_container:null,_context:i,_instance:null,version:ac,get config(){return i.config},set config(f){},use(f,...h){return o.has(f)||(f&&B(f.install)?(o.add(f),f.install(d,...h)):B(f)&&(o.add(f),f(d,...h))),d},mixin(f){return i.mixins.includes(f)||i.mixins.push(f),d},component(f,h){return h?(i.components[f]=h,d):i.components[f]},directive(f,h){return h?(i.directives[f]=h,d):i.directives[f]},mount(f,h,g){if(!c){const m=d._ceVNode||Re(s,r);return m.appContext=i,g===!0?g="svg":g===!1&&(g=void 0),e(m,f,g),c=!0,d._container=f,f.__vue_app__=d,Wn(m.component)}},onUnmount(f){l.push(f)},unmount(){c&&(Ne(l,d._instance,16),e(null,d._container),delete d._container.__vue_app__)},provide(f,h){return i.provides[f]=h,d},runWithContext(f){const h=Vt;Vt=d;try{return f()}finally{Vt=h}}};return d}}let Vt=null;const Dl=(e,t)=>t==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${_e(t)}Modifiers`]||e[`${_t(t)}Modifiers`];function Ml(e,t,...n){if(e.isUnmounted)return;const s=e.vnode.props||X;let r=n;const i=t.startsWith("update:"),o=i&&Dl(s,t.slice(7));o&&(o.trim&&(r=n.map(f=>ne(f)?f.trim():f)),o.number&&(r=r.map(Ln)));let l,c=s[l=$n(t)]||s[l=$n(_e(t))];!c&&i&&(c=s[l=$n(_t(t))]),c&&Ne(c,e,6,r);const d=s[l+"Once"];if(d){if(!e.emitted)e.emitted={};else if(e.emitted[l])return;e.emitted[l]=!0,Ne(d,e,6,r)}}const Ll=new WeakMap;function Oi(e,t,n=!1){const s=n?Ll:t.emitsCache,r=s.get(e);if(r!==void 0)return r;const i=e.emits;let o={},l=!1;if(!B(e)){const c=d=>{const f=Oi(d,t,!0);f&&(l=!0,fe(o,f))};!n&&t.mixins.length&&t.mixins.forEach(c),e.extends&&c(e.extends),e.mixins&&e.mixins.forEach(c)}return!i&&!l?($(e)&&s.set(e,null),null):(L(i)?i.forEach(c=>o[c]=null):fe(o,i),$(e)&&s.set(e,o),o)}function Kn(e,t){return!e||!In(t)?!1:(t=t.slice(2),t=t==="Once"?t:t.replace(/Once$/,""),q(e,t[0].toLowerCase()+t.slice(1))||q(e,_t(t))||q(e,t))}function lr(e){const{type:t,vnode:n,proxy:s,withProxy:r,propsOptions:[i],slots:o,attrs:l,emit:c,render:d,renderCache:f,props:h,data:g,setupState:m,ctx:I,inheritAttrs:S}=e,H=xn(e);let F,O;try{if(n.shapeFlag&4){const T=r||s,Q=T;F=We(d.call(Q,T,f,h,m,g,I)),O=l}else{const T=t;F=We(T.length>1?T(h,{attrs:l,slots:o,emit:c}):T(h,null)),O=t.props?l:Vl(l)}}catch(T){wt.length=0,Hn(T,e,1),F=Re(lt)}let N=F;if(O&&S!==!1){const T=Object.keys(O),{shapeFlag:Q}=N;T.length&&Q&7&&(i&&T.some(Nn)&&(O=Fl(O,i)),N=jt(N,O,!1,!0))}if(n.dirs&&(N=jt(N,null,!1,!0),N.dirs=N.dirs?N.dirs.concat(n.dirs):n.dirs),n.transition){const T=Bn(N.type)&&bi(N)||N;js(T,n.transition)}return F=N,xn(H),F}const Vl=e=>{let t;for(const n in e)(n==="class"||n==="style"||In(n))&&((t||(t={}))[n]=e[n]);return t},Fl=(e,t)=>{const n={};for(const s in e)(!Nn(s)||!(s.slice(9)in t))&&(n[s]=e[s]);return n};function jl(e,t,n){const{props:s,children:r,component:i}=e,{props:o,children:l,patchFlag:c}=t,d=i.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&c>=0){if(c&1024)return!0;if(c&16)return s?cr(s,o,d):!!o;if(c&8){const f=t.dynamicProps;for(let h=0;h<f.length;h++){const g=f[h];if(Pi(o,s,g)&&!Kn(d,g))return!0}}}else return(r||l)&&(!l||!l.$stable)?!0:s===o?!1:s?o?cr(s,o,d):!0:!!o;return!1}function cr(e,t,n){const s=Object.keys(t);if(s.length!==Object.keys(e).length)return!0;for(let r=0;r<s.length;r++){const i=s[r];if(Pi(t,e,i)&&!Kn(n,i))return!0}return!1}function Pi(e,t,n){const s=e[n],r=t[n];return n==="style"&&$(s)&&$(r)?!Te(s,r):s!==r}function Hl({vnode:e,parent:t,suspense:n},s){for(;t;){const r=t.subTree;if(r.suspense&&r.suspense.activeBranch===e&&(r.suspense.vnode.el=r.el=s,e=r),r===e)(e=t.vnode).el=s,t=t.parent;else break}n&&n.activeBranch===e&&(n.vnode.el=s)}const Ti={},Ii=()=>Object.create(Ti),Ni=e=>Object.getPrototypeOf(e)===Ti;function Bl(e,t,n,s=!1){const r={},i=Ii();e.propsDefaults=Object.create(null),Di(e,t,r,i);for(const o in e.propsOptions[0])o in r||(r[o]=void 0);n?e.props=s?r:fi(r):e.type.props?e.props=r:e.props=i,e.attrs=i}function Ul(e,t,n,s){const{props:r,attrs:i,vnode:{patchFlag:o}}=e,l=k(r),[c]=e.propsOptions;let d=!1;if((s||o>0)&&!(o&16)){if(o&8){const f=e.vnode.dynamicProps;for(let h=0;h<f.length;h++){let g=f[h];if(Kn(e.emitsOptions,g))continue;const m=t[g];if(c)if(q(i,g))m!==i[g]&&(i[g]=m,d=!0);else{const I=_e(g);r[I]=_s(c,l,I,m,e,!1)}else m!==i[g]&&(i[g]=m,d=!0)}}}else{Di(e,t,r,i)&&(d=!0);let f;for(const h in l)(!t||!q(t,h)&&((f=_t(h))===h||!q(t,f)))&&(c?n&&(n[h]!==void 0||n[f]!==void 0)&&(r[h]=_s(c,l,h,void 0,e,!0)):delete r[h]);if(i!==l)for(const h in i)(!t||!q(t,h))&&(delete i[h],d=!0)}d&&et(e.attrs,"set","")}function Di(e,t,n,s){const[r,i]=e.propsOptions;let o=!1,l;if(t)for(let c in t){if(Jt(c))continue;const d=t[c];let f;r&&q(r,f=_e(c))?!i||!i.includes(f)?n[f]=d:(l||(l={}))[f]=d:Kn(e.emitsOptions,c)||(!(c in s)||d!==s[c])&&(s[c]=d,o=!0)}if(i){const c=k(n),d=l||X;for(let f=0;f<i.length;f++){const h=i[f];n[h]=_s(r,c,h,d[h],e,!q(d,h))}}return o}function _s(e,t,n,s,r,i){const o=e[n];if(o!=null){const l=q(o,"default");if(l&&s===void 0){const c=o.default;if(o.type!==Function&&!o.skipFactory&&B(c)){const{propsDefaults:d}=r;if(n in d)s=d[n];else{const f=dn(r);s=d[n]=c.call(null,t),f()}}else s=c;r.ce&&r.ce._setProp(n,s)}o[0]&&(i&&!l?s=!1:o[1]&&(s===""||s===_t(n))&&(s=!0))}return s}const Kl=new WeakMap;function Mi(e,t,n=!1){const s=n?Kl:t.propsCache,r=s.get(e);if(r)return r;const i=e.props,o={},l=[];let c=!1;if(!B(e)){const f=h=>{c=!0;const[g,m]=Mi(h,t,!0);fe(o,g),m&&l.push(...m)};!n&&t.mixins.length&&t.mixins.forEach(f),e.extends&&f(e.extends),e.mixins&&e.mixins.forEach(f)}if(!i&&!c)return $(e)&&s.set(e,Dt),Dt;if(L(i))for(let f=0;f<i.length;f++){const h=_e(i[f]);ur(h)&&(o[h]=X)}else if(i)for(const f in i){const h=_e(f);if(ur(h)){const g=i[f],m=o[h]=L(g)||B(g)?{type:g}:fe({},g),I=m.type;let S=!1,H=!0;if(L(I))for(let F=0;F<I.length;++F){const O=I[F],N=B(O)&&O.name;if(N==="Boolean"){S=!0;break}else N==="String"&&(H=!1)}else S=B(I)&&I.name==="Boolean";m[0]=S,m[1]=H,(S||q(m,"default"))&&l.push(h)}}const d=[o,l];return $(e)&&s.set(e,d),d}function ur(e){return e[0]!=="$"&&!Jt(e)}const Bs=e=>e==="_"||e==="_ctx"||e==="$stable",Us=e=>L(e)?e.map(We):[We(e)],Gl=(e,t,n)=>{if(t._n)return t;const s=sl((...r)=>Us(t(...r)),n);return s._c=!1,s},Li=(e,t,n)=>{const s=e._ctx;for(const r in e){if(Bs(r))continue;const i=e[r];if(B(i))t[r]=Gl(r,i,s);else if(i!=null){const o=Us(i);t[r]=()=>o}}},Vi=(e,t)=>{const n=Us(t);e.slots.default=()=>n},Fi=(e,t,n)=>{for(const s in t)(n||!Bs(s))&&(e[s]=t[s])},Wl=(e,t,n)=>{const s=e.slots=Ii();if(e.vnode.shapeFlag&32){const r=t._;r?(Fi(s,t,n),n&&qr(s,"_",r,!0)):Li(t,s)}else t&&Vi(e,t)},kl=(e,t,n)=>{const{vnode:s,slots:r}=e;let i=!0,o=X;if(s.shapeFlag&32){const l=t._;l?n&&l===1?i=!1:Fi(r,t,n):(i=!t.$stable,Li(t,r)),o=t}else t&&(Vi(e,t),o={default:1});if(i)for(const l in r)!Bs(l)&&o[l]==null&&delete r[l]},ve=Ql;function ql(e){return $l(e)}function $l(e,t){const n=Vn();n.__VUE__=!0;const{insert:s,remove:r,patchProp:i,createElement:o,createText:l,createComment:c,setText:d,setElementText:f,parentNode:h,nextSibling:g,setScopeId:m=qe,insertStaticContent:I}=e,S=(u,a,p,y=null,b=null,_=null,x=void 0,R=null,A=!!a.dynamicChildren)=>{if(u===a)return;u&&!kt(u,a)&&(y=v(u),ye(u,b,_,!0),u=null),a.patchFlag===-2&&(A=!1,a.dynamicChildren=null);const{type:E,ref:V,shapeFlag:C}=a;switch(E){case Gn:H(u,a,p,y);break;case lt:F(u,a,p,y);break;case ts:u==null&&O(a,p,y,x);break;case Xe:yt(u,a,p,y,b,_,x,R,A);break;default:C&1?Q(u,a,p,y,b,_,x,R,A):C&6?Ve(u,a,p,y,b,_,x,R,A):(C&64||C&128)&&E.process(u,a,p,y,b,_,x,R,A,D)}V!=null&&b?Xt(V,u&&u.ref,_,a||u,!a):V==null&&u&&u.ref!=null&&Xt(u.ref,null,_,u,!0)},H=(u,a,p,y)=>{if(u==null)s(a.el=l(a.children),p,y);else{const b=a.el=u.el;a.children!==u.children&&d(b,a.children)}},F=(u,a,p,y)=>{u==null?s(a.el=c(a.children||""),p,y):a.el=u.el},O=(u,a,p,y)=>{[u.el,u.anchor]=I(u.children,a,p,y,u.el,u.anchor)},N=({el:u,anchor:a},p,y)=>{let b;for(;u&&u!==a;)b=g(u),s(u,p,y),u=b;s(a,p,y)},T=({el:u,anchor:a})=>{let p;for(;u&&u!==a;)p=g(u),r(u),u=p;r(a)},Q=(u,a,p,y,b,_,x,R,A)=>{if(a.type==="svg"?x="svg":a.type==="math"&&(x="mathml"),u==null)ie(a,p,y,b,_,x,R,A);else{const E=u.el&&u.el._isVueCE?u.el:null;try{E&&E._beginPatch(),ut(u,a,b,_,x,R,A)}finally{E&&E._endPatch()}}},ie=(u,a,p,y,b,_,x,R)=>{let A,E;const{props:V,shapeFlag:C,transition:M,dirs:j}=u;if(A=u.el=o(u.type,_,V&&V.is,V),C&8?f(A,u.children):C&16&&Me(u.children,A,null,y,b,es(u,_),x,R),j&&bt(u,null,y,"created"),ee(A,u,u.scopeId,x,y),V){for(const Y in V)Y!=="value"&&!Jt(Y)&&i(A,Y,null,V[Y],_,y);"value"in V&&i(A,"value",null,V.value,_),(E=V.onVnodeBeforeMount)&&Ue(E,y,u)}j&&bt(u,null,y,"beforeMount");const K=zl(b,M);K&&M.beforeEnter(A),s(A,a,p),((E=V&&V.onVnodeMounted)||K||j)&&ve(()=>{try{E&&Ue(E,y,u),K&&M.enter(A),j&&bt(u,null,y,"mounted")}finally{}},b)},ee=(u,a,p,y,b)=>{if(p&&m(u,p),y)for(let _=0;_<y.length;_++)m(u,y[_]);if(b){let _=b.subTree;if(a===_||Ui(_.type)&&(_.ssContent===a||_.ssFallback===a)){const x=b.vnode;ee(u,x,x.scopeId,x.slotScopeIds,b.parent)}}},Me=(u,a,p,y,b,_,x,R,A=0)=>{for(let E=A;E<u.length;E++){const V=u[E]=R?Ze(u[E]):We(u[E]);S(null,V,a,p,y,b,_,x,R)}},ut=(u,a,p,y,b,_,x)=>{const R=a.el=u.el;let{patchFlag:A,dynamicChildren:E,dirs:V}=a;A|=u.patchFlag&16;const C=u.props||X,M=a.props||X;let j;if(p&&Et(p,!1),(j=M.onVnodeBeforeUpdate)&&Ue(j,p,a,u),V&&bt(a,u,p,"beforeUpdate"),p&&Et(p,!0),E&&(!u.dynamicChildren||u.dynamicChildren.length!==E.length)&&(A=0,x=!1,E=null),(C.innerHTML&&M.innerHTML==null||C.textContent&&M.textContent==null)&&f(R,""),E?Le(u.dynamicChildren,E,R,p,y,es(a,b),_):x||G(u,a,R,null,p,y,es(a,b),_,!1),A>0){if(A&16)ft(R,C,M,p,b);else if(A&2&&C.class!==M.class&&i(R,"class",null,M.class,b),A&4&&i(R,"style",C.style,M.style,b),A&8){const K=a.dynamicProps;for(let Y=0;Y<K.length;Y++){const J=K[Y],se=C[J],ce=M[J];(ce!==se||J==="value")&&i(R,J,se,ce,b,p)}}A&1&&u.children!==a.children&&f(R,a.children)}else!x&&E==null&&ft(R,C,M,p,b);((j=M.onVnodeUpdated)||V)&&ve(()=>{j&&Ue(j,p,a,u),V&&bt(a,u,p,"updated")},y)},Le=(u,a,p,y,b,_,x)=>{for(let R=0;R<a.length;R++){const A=u[R],E=a[R],V=A.el&&(A.type===Xe||!kt(A,E)||A.shapeFlag&198)?h(A.el):p;S(A,E,V,null,y,b,_,x,!0)}},ft=(u,a,p,y,b)=>{if(a!==p){if(a!==X)for(const _ in a)!Jt(_)&&!(_ in p)&&i(u,_,a[_],null,b,y);for(const _ in p){if(Jt(_))continue;const x=p[_],R=a[_];x!==R&&_!=="value"&&i(u,_,R,x,b,y)}"value"in p&&i(u,"value",a.value,p.value,b)}},yt=(u,a,p,y,b,_,x,R,A)=>{const E=a.el=u?u.el:l(""),V=a.anchor=u?u.anchor:l("");let{patchFlag:C,dynamicChildren:M,slotScopeIds:j}=a;j&&(R=R?R.concat(j):j),u==null?(s(E,p,y),s(V,p,y),Me(a.children||[],p,V,b,_,x,R,A)):C>0&&C&64&&M&&u.dynamicChildren&&u.dynamicChildren.length===M.length?(Le(u.dynamicChildren,M,p,b,_,x,R),(a.key!=null||b&&a===b.subTree)&&ji(u,a,!0)):G(u,a,p,V,b,_,x,R,A)},Ve=(u,a,p,y,b,_,x,R,A)=>{a.slotScopeIds=R,u==null?a.shapeFlag&512?b.ctx.activate(a,p,y,x,A):Kt(a,p,y,b,_,x,A):Ct(u,a,A)},Kt=(u,a,p,y,b,_,x)=>{const R=u.component=sc(u,y,b);if(Hs(u)&&(R.ctx.renderer=D),ic(R,!1,x),R.asyncDep){if(b&&b.registerDep(R,le,x),!u.el){const A=R.subTree=Re(lt);F(null,A,a,p),u.placeholder=A.el}}else le(R,u,a,p,b,_,x)},Ct=(u,a,p)=>{const y=a.component=u.component;if(jl(u,a,p))if(y.asyncDep&&!y.asyncResolved){z(y,a,p);return}else y.next=a,y.update();else a.el=u.el,y.vnode=a},le=(u,a,p,y,b,_,x)=>{const R=()=>{if(u.isMounted){let{next:C,bu:M,u:j,parent:K,vnode:Y}=u;{const He=Hi(u);if(He){C&&(C.el=Y.el,z(u,C,x)),He.asyncDep.then(()=>{ve(()=>{u.isUnmounted||E()},b)});return}}let J=C,se;Et(u,!1),C?(C.el=Y.el,z(u,C,x)):C=Y,M&&yn(M),(se=C.props&&C.props.onVnodeBeforeUpdate)&&Ue(se,K,C,Y),Et(u,!0);const ce=lr(u),je=u.subTree;u.subTree=ce,S(je,ce,h(je.el),v(je),u,b,_),C.el=ce.el,J===null&&Hl(u,ce.el),j&&ve(j,b),(se=C.props&&C.props.onVnodeUpdated)&&ve(()=>Ue(se,K,C,Y),b)}else{let C;const{el:M,props:j}=a,{bm:K,m:Y,parent:J,root:se,type:ce}=u,je=Zt(a);Et(u,!1),K&&yn(K),!je&&(C=j&&j.onVnodeBeforeMount)&&Ue(C,J,a),Et(u,!0);{se.ce&&se.ce._hasShadowRoot()&&se.ce._injectChildStyle(ce,u.parent?u.parent.type:void 0);const He=u.subTree=lr(u);S(null,He,p,y,u,b,_),a.el=He.el}if(Y&&ve(Y,b),!je&&(C=j&&j.onVnodeMounted)){const He=a;ve(()=>Ue(C,J,He),b)}(a.shapeFlag&256||J&&Zt(J.vnode)&&J.vnode.shapeFlag&256)&&u.a&&ve(u.a,b),u.isMounted=!0,a=p=y=null}};u.scope.on();const A=u.effect=new Qr(R);u.scope.off();const E=u.update=A.run.bind(A),V=u.job=A.runIfDirty.bind(A);V.i=u,V.id=u.uid,A.scheduler=()=>Fs(V),Et(u,!0),E()},z=(u,a,p)=>{a.component=u;const y=u.vnode.props;u.vnode=a,u.next=null,Ul(u,a.props,y,p),kl(u,a.children,p),rt(),Zs(u),it()},G=(u,a,p,y,b,_,x,R,A=!1)=>{const E=u&&u.children,V=u?u.shapeFlag:0,C=a.children,{patchFlag:M,shapeFlag:j}=a;if(M>0){if(M&128){at(E,C,p,y,b,_,x,R,A);return}else if(M&256){ze(E,C,p,y,b,_,x,R,A);return}}j&8?(V&16&&Se(E,b,_),C!==E&&f(p,C)):V&16?j&16?at(E,C,p,y,b,_,x,R,A):Se(E,b,_,!0):(V&8&&f(p,""),j&16&&Me(C,p,y,b,_,x,R,A))},ze=(u,a,p,y,b,_,x,R,A)=>{u=u||Dt,a=a||Dt;const E=u.length,V=a.length,C=Math.min(E,V);let M;for(M=0;M<C;M++){const j=a[M]=A?Ze(a[M]):We(a[M]);S(u[M],j,p,null,b,_,x,R,A)}E>V?Se(u,b,_,!0,!1,C):Me(a,p,y,b,_,x,R,A,C)},at=(u,a,p,y,b,_,x,R,A)=>{let E=0;const V=a.length;let C=u.length-1,M=V-1;for(;E<=C&&E<=M;){const j=u[E],K=a[E]=A?Ze(a[E]):We(a[E]);if(kt(j,K))S(j,K,p,null,b,_,x,R,A);else break;E++}for(;E<=C&&E<=M;){const j=u[C],K=a[M]=A?Ze(a[M]):We(a[M]);if(kt(j,K))S(j,K,p,null,b,_,x,R,A);else break;C--,M--}if(E>C){if(E<=M){const j=M+1,K=j<V?a[j].el:y;for(;E<=M;)S(null,a[E]=A?Ze(a[E]):We(a[E]),p,K,b,_,x,R,A),E++}}else if(E>M)for(;E<=C;)ye(u[E],b,_,!0),E++;else{const j=E,K=E,Y=new Map;for(E=K;E<=M;E++){const be=a[E]=A?Ze(a[E]):We(a[E]);be.key!=null&&Y.set(be.key,E)}let J,se=0;const ce=M-K+1;let je=!1,He=0;const Gt=new Array(ce);for(E=0;E<ce;E++)Gt[E]=0;for(E=j;E<=C;E++){const be=u[E];if(se>=ce){ye(be,b,_,!0);continue}let Be;if(be.key!=null)Be=Y.get(be.key);else for(J=K;J<=M;J++)if(Gt[J-K]===0&&kt(be,a[J])){Be=J;break}Be===void 0?ye(be,b,_,!0):(Gt[Be-K]=E+1,Be>=He?He=Be:je=!0,S(be,a[Be],p,null,b,_,x,R,A),se++)}const Ws=je?Jl(Gt):Dt;for(J=Ws.length-1,E=ce-1;E>=0;E--){const be=K+E,Be=a[be],ks=a[be+1],qs=be+1<V?ks.el||Bi(ks):y;Gt[E]===0?S(null,Be,p,qs,b,_,x,R,A):je&&(J<0||E!==Ws[J]?Fe(Be,p,qs,2):J--)}}},Fe=(u,a,p,y,b=null)=>{const{el:_,type:x,transition:R,children:A,shapeFlag:E}=u;if(E&6){Fe(u.component.subTree,a,p,y);return}if(E&128){u.suspense.move(a,p,y);return}if(E&64){x.move(u,a,p,D);return}if(x===Xe){s(_,a,p);for(let C=0;C<A.length;C++)Fe(A[C],a,p,y);s(u.anchor,a,p);return}if(x===ts){N(u,a,p);return}if(y!==2&&E&1&&R)if(y===0)R.persisted&&!_[Xn]?s(_,a,p):(R.beforeEnter(_),s(_,a,p),ve(()=>R.enter(_),b));else{const{leave:C,delayLeave:M,afterLeave:j}=R,K=()=>{u.ctx.isUnmounted?r(_):s(_,a,p)},Y=()=>{const J=_._isLeaving||!!_[Xn];_._isLeaving&&_[Xn](!0),R.persisted&&!J?K():C(_,()=>{K(),j&&j()})};M?M(_,K,Y):Y()}else s(_,a,p)},ye=(u,a,p,y=!1,b=!1)=>{const{type:_,props:x,ref:R,children:A,dynamicChildren:E,shapeFlag:V,patchFlag:C,dirs:M,cacheIndex:j,memo:K}=u;if(C===-2&&(b=!1),R!=null&&(rt(),Xt(R,null,p,u,!0),it()),j!=null&&(a.renderCache[j]=void 0),V&256){a.ctx.deactivate(u);return}const Y=V&1&&M,J=!Zt(u);let se;if(J&&(se=x&&x.onVnodeBeforeUnmount)&&Ue(se,a,u),V&6)vt(u.component,p,y);else{if(V&128){u.suspense.unmount(p,y);return}Y&&bt(u,null,a,"beforeUnmount"),V&64?u.type.remove(u,a,p,D,y):E&&!E.hasOnce&&(_!==Xe||C>0&&C&64)?Se(E,a,p,!1,!0):(_===Xe&&C&384||!b&&V&16)&&Se(A,a,p),y&&Ot(u)}const ce=K!=null&&j==null;(J&&(se=x&&x.onVnodeUnmounted)||Y||ce)&&ve(()=>{se&&Ue(se,a,u),Y&&bt(u,null,a,"unmounted"),ce&&(u.el=null)},p)},Ot=u=>{const{type:a,el:p,anchor:y,transition:b}=u;if(a===Xe){Pt(p,y);return}if(a===ts){T(u);return}const _=()=>{r(p),b&&!b.persisted&&b.afterLeave&&b.afterLeave()};if(u.shapeFlag&1&&b&&!b.persisted){const{leave:x,delayLeave:R}=b,A=()=>x(p,_);R?R(u.el,_,A):A()}else _()},Pt=(u,a)=>{let p;for(;u!==a;)p=g(u),r(u),u=p;r(a)},vt=(u,a,p)=>{const{bum:y,scope:b,job:_,subTree:x,um:R,m:A,a:E}=u;fr(A),fr(E),y&&yn(y),b.stop(),_&&(_.flags|=8,ye(x,u,a,p)),R&&ve(R,a),ve(()=>{u.isUnmounted=!0},a)},Se=(u,a,p,y=!1,b=!1,_=0)=>{for(let x=_;x<u.length;x++)ye(u[x],a,p,y,b)},v=u=>{if(u.shapeFlag&6)return v(u.component.subTree);if(u.shapeFlag&128)return u.suspense.next();const a=g(u.anchor||u.el),p=a&&a[ll];return p?g(p):a};let P=!1;const w=(u,a,p)=>{let y;u==null?a._vnode&&(ye(a._vnode,null,null,!0),y=a._vnode.component):S(a._vnode||null,u,a,null,null,null,p),a._vnode=u,P||(P=!0,Zs(y),gi(),P=!1)},D={p:S,um:ye,m:Fe,r:Ot,mt:Kt,mc:Me,pc:G,pbc:Le,n:v,o:e};return{render:w,hydrate:void 0,createApp:Nl(w)}}function es({type:e,props:t},n){return n==="svg"&&e==="foreignObject"||n==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:n}function Et({effect:e,job:t},n){n?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function zl(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function ji(e,t,n=!1){const s=e.children,r=t.children;if(L(s)&&L(r))for(let i=0;i<s.length;i++){const o=s[i];let l=r[i];l.shapeFlag&1&&!l.dynamicChildren&&((l.patchFlag<=0||l.patchFlag===32)&&(l=r[i]=Ze(r[i]),l.el=o.el),!n&&l.patchFlag!==-2&&ji(o,l)),l.type===Gn&&(l.patchFlag===-1&&(l=r[i]=Ze(l)),l.el=o.el),l.type===lt&&!l.el&&(l.el=o.el)}}function Jl(e){const t=e.slice(),n=[0];let s,r,i,o,l;const c=e.length;for(s=0;s<c;s++){const d=e[s];if(d!==0){if(r=n[n.length-1],e[r]<d){t[s]=r,n.push(s);continue}for(i=0,o=n.length-1;i<o;)l=i+o>>1,e[n[l]]<d?i=l+1:o=l;d<e[n[i]]&&(i>0&&(t[s]=n[i-1]),n[i]=s)}}for(i=n.length,o=n[i-1];i-- >0;)n[i]=o,o=t[o];return n}function Hi(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:Hi(t)}function fr(e){if(e)for(let t=0;t<e.length;t++)e[t].flags|=8}function Bi(e){if(e.placeholder)return e.placeholder;const t=e.component;return t?Bi(t.subTree):null}const Ui=e=>e.__isSuspense;function Ql(e,t){t&&t.pendingBranch?L(e)?t.effects.push(...e):t.effects.push(e):nl(e)}const Xe=Symbol.for("v-fgt"),Gn=Symbol.for("v-txt"),lt=Symbol.for("v-cmt"),ts=Symbol.for("v-stc"),wt=[];let Ae=null;function Ki(e=!1){wt.push(Ae=e?null:[])}function Gi(){wt.pop(),Ae=wt[wt.length-1]||null}let on=1;function Cn(e,t=!1){on+=e,e<0&&Ae&&t&&(Ae.hasOnce=!0)}function Wi(e){return e.dynamicChildren=on>0?Ae||Dt:null,Gi(),on>0&&Ae&&Ae.push(e),e}function nf(e,t,n,s,r,i){return Wi($i(e,t,n,s,r,i,!0))}function ki(e,t,n,s,r){return Wi(Re(e,t,n,s,r,!0))}function On(e){return e?e.__v_isVNode===!0:!1}function kt(e,t){return e.type===t.type&&e.key===t.key}const qi=({key:e})=>e??null,En=({ref:e,ref_key:t,ref_for:n})=>(typeof e=="number"&&(e=""+e),e!=null?ne(e)||he(e)||B(e)?{i:Ee,r:e,k:t,f:!!n}:e:null);function $i(e,t=null,n=null,s=0,r=null,i=e===Xe?0:1,o=!1,l=!1){const c={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&qi(t),ref:t&&En(t),scopeId:_i,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:i,patchFlag:s,dynamicProps:r,dynamicChildren:null,appContext:null,ctx:Ee};return l?(Pn(c,n),i&128&&e.normalize(c)):n&&(c.shapeFlag|=ne(n)?8:16),on>0&&!o&&Ae&&(c.patchFlag>0||i&6)&&c.patchFlag!==32&&Ae.push(c),c}const Re=Yl;function Yl(e,t=null,n=null,s=0,r=null,i=!1){if((!e||e===Rl)&&(e=lt),On(e)){const l=jt(e,t,!0);return n&&Pn(l,n),on>0&&!i&&Ae&&(l.shapeFlag&6?Ae[Ae.indexOf(e)]=l:Ae.push(l)),l.patchFlag=-2,l}if(fc(e)&&(e=e.__vccOpts),t){t=Xl(t);let{class:l,style:c}=t;l&&!ne(l)&&(t.class=Cs(l)),$(c)&&(Ls(c)&&!L(c)&&(c=fe({},c)),t.style=ws(c))}const o=ne(e)?1:Ui(e)?128:Bn(e)?64:$(e)?4:B(e)?2:0;return $i(e,t,n,s,r,o,i,!0)}function Xl(e){return e?Ls(e)||Ni(e)?fe({},e):e:null}function jt(e,t,n=!1,s=!1){const{props:r,ref:i,patchFlag:o,children:l,transition:c}=e,d=t?ec(r||{},t):r,f={__v_isVNode:!0,__v_skip:!0,type:e.type,props:d,key:d&&qi(d),ref:t&&t.ref?n&&i?L(i)?i.concat(En(t)):[i,En(t)]:En(t):i,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:l,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==Xe?o===-1?16:o|16:o,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:c,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&jt(e.ssContent),ssFallback:e.ssFallback&&jt(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return c&&s&&js(f,c.clone(f)),f}function Zl(e=" ",t=0){return Re(Gn,null,e,t)}function sf(e="",t=!1){return t?(Ki(),ki(lt,null,e)):Re(lt,null,e)}function We(e){return e==null||typeof e=="boolean"?Re(lt):L(e)?Re(Xe,null,e.slice()):On(e)?Ze(e):Re(Gn,null,String(e))}function Ze(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:jt(e)}function Pn(e,t){let n=0;const{shapeFlag:s}=e;if(t==null)t=null;else if(L(t))n=16;else if(typeof t=="object")if(s&65){const r=t.default;r&&(r._c&&(r._d=!1),Pn(e,r()),r._c&&(r._d=!0));return}else{n=32;const r=t._;!r&&!Ni(t)?t._ctx=Ee:r===3&&Ee&&(Ee.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else if(B(t)){if(s&65){Pn(e,{default:t});return}t={default:t,_ctx:Ee},n=32}else t=String(t),s&64?(n=16,t=[Zl(t)]):n=8;e.children=t,e.shapeFlag|=n}function ec(...e){const t={};for(let n=0;n<e.length;n++){const s=e[n];for(const r in s)if(r==="class")t.class!==s.class&&(t.class=Cs([t.class,s.class]));else if(r==="style")t.style=ws([t.style,s.style]);else if(In(r)){const i=t[r],o=s[r];o&&i!==o&&!(L(i)&&i.includes(o))?t[r]=i?[].concat(i,o):o:o==null&&i==null&&!Nn(r)&&(t[r]=o)}else r!==""&&(t[r]=s[r])}return t}function Ue(e,t,n,s=null){Ne(e,t,7,[n,s])}const tc=Ci();let nc=0;function sc(e,t,n){const s=e.type,r=(t?t.appContext:e.appContext)||tc,i={uid:nc++,vnode:e,type:s,parent:t,appContext:r,root:null,next:null,subTree:null,effect:null,update:null,job:null,scope:new wo(!0),render:null,proxy:null,exposed:null,exposeProxy:null,withProxy:null,provides:t?t.provides:Object.create(r.provides),ids:t?t.ids:["",0,0],accessCache:null,renderCache:[],components:null,directives:null,propsOptions:Mi(s,r),emitsOptions:Oi(s,r),emit:null,emitted:null,propsDefaults:X,inheritAttrs:s.inheritAttrs,ctx:X,data:X,props:X,attrs:X,slots:X,refs:X,setupState:X,setupContext:null,suspense:n,suspenseId:n?n.pendingId:0,asyncDep:null,asyncResolved:!1,isMounted:!1,isUnmounted:!1,isDeactivated:!1,bc:null,c:null,bm:null,m:null,bu:null,u:null,um:null,bum:null,da:null,a:null,rtg:null,rtc:null,ec:null,sp:null};return i.ctx={_:i},i.root=t?t.root:i,i.emit=Ml.bind(null,i),e.ce&&e.ce(i),i}let de=null;const rc=()=>de||Ee;let Tn,ln;{const e=Vn(),t=(n,s)=>{let r;return(r=e[n])||(r=e[n]=[]),r.push(s),i=>{r.length>1?r.forEach(o=>o(i)):r[0](i)}};Tn=t("__VUE_INSTANCE_SETTERS__",n=>de=n),ln=t("__VUE_SSR_SETTERS__",n=>cn=n)}const dn=e=>{const t=de;return Tn(e),e.scope.on(),()=>{e.scope.off(),Tn(t)}},ar=()=>{de&&de.scope.off(),Tn(null)};function zi(e){return e.vnode.shapeFlag&4}let cn=!1;function ic(e,t=!1,n=!1){t&&ln(t);const{props:s,children:r}=e.vnode,i=zi(e);Bl(e,s,i,t),Wl(e,r,n||t);const o=i?oc(e,t):void 0;return t&&ln(!1),o}function oc(e,t){const n=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,Sl);const{setup:s}=n;if(s){rt();const r=e.setupContext=s.length>1?cc(e):null,i=dn(e),o=an(s,e,0,[e.props,r]),l=Gr(o);if(it(),i(),(l||e.sp)&&!Zt(e)&&Ai(e),l){if(o.then(ar,ar),t)return o.then(c=>{ln(!0);try{dr(e,c,t)}finally{ln(!1)}}).catch(c=>{Hn(c,e,0)});e.asyncDep=o}else dr(e,o)}else Ji(e)}function dr(e,t,n){B(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:$(t)&&(e.setupState=di(t)),Ji(e)}function Ji(e,t,n){const s=e.type;e.render||(e.render=s.render||qe);{const r=dn(e);rt();try{wl(e)}finally{it(),r()}}}const lc={get(e,t){return ae(e,"get",""),e[t]}};function cc(e){const t=n=>{e.exposed=n||{}};return{attrs:new Proxy(e.attrs,lc),slots:e.slots,emit:e.emit,expose:t}}function Wn(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(di(ko(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in en)return en[n](e)},has(t,n){return n in t||n in en}})):e.proxy}function uc(e,t=!0){return B(e)?e.displayName||e.name:e.name||t&&e.__name}function fc(e){return B(e)&&"__vccOpts"in e}const Ce=(e,t)=>Yo(e,t,cn);function Qi(e,t,n){try{Cn(-1);const s=arguments.length;return s===2?$(t)&&!L(t)?On(t)?Re(e,null,[t]):Re(e,t):Re(e,null,t):(s>3?n=Array.prototype.slice.call(arguments,2):s===3&&On(n)&&(n=[n]),Re(e,t,n))}finally{Cn(1)}}const ac="3.5.42";/**
+* @vue/runtime-dom v3.5.42
+* (c) 2018-present Yuxi (Evan) You and Vue contributors
+* @license MIT
+**/let ys;const hr=typeof window<"u"&&window.trustedTypes;if(hr)try{ys=hr.createPolicy("vue",{createHTML:e=>e})}catch{}const Yi=ys?e=>ys.createHTML(e):e=>e,dc="http://www.w3.org/2000/svg",hc="http://www.w3.org/1998/Math/MathML",Ye=typeof document<"u"?document:null,pr=Ye&&Ye.createElement("template"),pc={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,s)=>{const r=t==="svg"?Ye.createElementNS(dc,e):t==="mathml"?Ye.createElementNS(hc,e):n?Ye.createElement(e,{is:n}):Ye.createElement(e);return e==="select"&&s&&s.multiple!=null&&r.setAttribute("multiple",s.multiple),r},createText:e=>Ye.createTextNode(e),createComment:e=>Ye.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Ye.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,n,s,r,i){const o=n?n.previousSibling:t.lastChild;if(r&&(r===i||r.nextSibling))for(;t.insertBefore(r.cloneNode(!0),n),!(r===i||!(r=r.nextSibling)););else{pr.innerHTML=Yi(s==="svg"?`<svg>${e}</svg>`:s==="mathml"?`<math>${e}</math>`:e);const l=pr.content;if(s==="svg"||s==="mathml"){const c=l.firstChild;for(;c.firstChild;)l.appendChild(c.firstChild);l.removeChild(c)}t.insertBefore(l,n)}return[o?o.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},gc=Symbol("_vtc");function mc(e,t,n){const s=e[gc];s&&(t=(t?[t,...s]:[...s]).join(" ")),t==null?e.removeAttribute("class"):n?e.setAttribute("class",t):e.className=t}const gr=Symbol("_vod"),_c=Symbol("_vsh"),yc=Symbol(""),vc=/(?:^|;)\s*display\s*:/;function bc(e,t,n){const s=e.style,r=ne(n);let i=!1;if(n&&!r){if(t)if(ne(t))for(const o of t.split(";")){const l=o.slice(0,o.indexOf(":")).trim();n[l]==null&&zt(s,l,"")}else for(const o in t)n[o]==null&&zt(s,o,"");for(const o in n){o==="display"&&(i=!0);const l=n[o];l!=null?Ac(e,o,!ne(t)&&t?t[o]:void 0,l)||zt(s,o,l):zt(s,o,"")}}else if(r){if(t!==n){const o=s[yc];o&&(n+=";"+o),s.cssText=n,i=vc.test(n)}}else t&&e.removeAttribute("style");gr in e&&(e[gr]=i?s.display:"",e[_c]&&(s.display="none"))}const mn=/\s*!important$/;function zt(e,t,n){if(L(n))n.forEach(s=>zt(e,t,s));else if(n==null&&(n=""),t.startsWith("--"))mn.test(n)?e.setProperty(t,n.replace(mn,""),"important"):e.setProperty(t,n);else{const s=Ec(e,t);mn.test(n)?e.setProperty(_t(s),n.replace(mn,""),"important"):e[s]=n}}const mr=["Webkit","Moz","ms"],ns={};function Ec(e,t){const n=ns[t];if(n)return n;let s=_e(t);if(s!=="filter"&&s in e)return ns[t]=s;s=Mn(s);for(let r=0;r<mr.length;r++){const i=mr[r]+s;if(i in e)return ns[t]=i}return t}function Ac(e,t,n,s){return e.tagName==="TEXTAREA"&&(t==="width"||t==="height")&&ne(s)&&n===s}const _r="http://www.w3.org/1999/xlink";function yr(e,t,n,s,r,i=Ro(t)){s&&t.startsWith("xlink:")?n==null?e.removeAttributeNS(_r,t.slice(6,t.length)):e.setAttributeNS(_r,t,n):n==null||i&&!$r(n)?e.removeAttribute(t):e.setAttribute(t,i?"":$e(n)?String(n):n)}function vr(e,t,n,s,r){if(t==="innerHTML"||t==="textContent"){n!=null&&(e[t]=t==="innerHTML"?Yi(n):n);return}const i=e.tagName;if(t==="value"&&i!=="PROGRESS"&&!i.includes("-")){const l=i==="OPTION"?e.getAttribute("value")||"":e.value,c=n==null?e.type==="checkbox"?"on":"":String(n);(l!==c||!("_value"in e))&&(e.value=c),n==null&&e.removeAttribute(t),e._value=n;return}let o=!1;if(n===""||n==null){const l=typeof e[t];l==="boolean"?n=$r(n):n==null&&l==="string"?(n="",o=!0):l==="number"&&(n=0,o=!0)}try{e[t]=n}catch{}o&&e.removeAttribute(r||t)}function nt(e,t,n,s){e.addEventListener(t,n,s)}function Rc(e,t,n,s){e.removeEventListener(t,n,s)}const br=Symbol("_vei");function xc(e,t,n,s,r=null){const i=e[br]||(e[br]={}),o=i[t];if(s&&o)o.value=s;else{const[l,c]=Cc(t);if(s){const d=i[t]=Tc(s,r);nt(e,l,d,c)}else o&&(Rc(e,l,o,c),i[t]=void 0)}}const Sc=/(Once|Passive|Capture)$/,wc=/^on:?(?:Once|Passive|Capture)$/;function Cc(e){let t,n;for(;(n=e.match(Sc))&&!wc.test(e);)t||(t={}),e=e.slice(0,e.length-n[1].length),t[n[1].toLowerCase()]=!0;return[e[2]===":"?e.slice(3):_t(e.slice(2)),t]}let ss=0;const Oc=Promise.resolve(),Pc=()=>ss||(Oc.then(()=>ss=0),ss=Date.now());function Tc(e,t){const n=s=>{if(!s._vts)s._vts=Date.now();else if(s._vts<=n.attached)return;const r=n.value;if(L(r)){const i=s.stopImmediatePropagation;s.stopImmediatePropagation=()=>{i.call(s),s._stopped=!0};const o=r.slice(),l=[s];for(let c=0;c<o.length&&!s._stopped;c++){const d=o[c];d&&Ne(d,t,5,l)}}else Ne(r,t,5,[s])};return n.value=e,n.attached=Pc(),n}const Er=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Ic=(e,t,n,s,r,i)=>{const o=r==="svg";t==="class"?mc(e,s,o):t==="style"?bc(e,n,s):In(t)?Nn(t)||xc(e,t,n,s,i):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):Nc(e,t,s,o))?(vr(e,t,s),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&yr(e,t,s,o,i,t!=="value")):e._isVueCE&&(Dc(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!ne(s)))?vr(e,_e(t),s,i,t):(t==="true-value"?e._trueValue=s:t==="false-value"&&(e._falseValue=s),yr(e,t,s,o))};function Nc(e,t,n,s){if(s)return!!(t==="innerHTML"||t==="textContent"||t in e&&Er(t)&&B(n));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const r=e.tagName;if(r==="IMG"||r==="VIDEO"||r==="CANVAS"||r==="SOURCE")return!1}return Er(t)&&ne(n)?!1:t in e}function Dc(e,t){const n=e._def.props;if(!n)return!1;const s=_e(t);return Array.isArray(n)?n.some(r=>_e(r)===s):Object.keys(n).some(r=>_e(r)===s)}const mt=e=>{const t=e.props["onUpdate:modelValue"]||!1;return L(t)?n=>yn(t,n):t};function Mc(e){e.target.composing=!0}function Ar(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const xe=Symbol("_assign"),_n=Symbol("_initialValue");function rs(e,t,n){return t&&(e=e.trim()),n&&(e=Ln(e)),e}const rf={created(e,{modifiers:{lazy:t,trim:n,number:s}},r){e.parentNode&&(e.type==="text"?e[_n]=e.defaultValue.replace(/[\r\n]/g,""):e.type==="textarea"&&(e[_n]=e.defaultValue.replace(/\r\n?/g,`
+`))),e[xe]=mt(r);const i=s||r.props&&r.props.type==="number";nt(e,t?"change":"input",o=>{o.target.composing||e[xe](rs(e.value,n,i))}),(n||i)&&nt(e,"change",()=>{e.value=rs(e.value,n,i)}),t||(nt(e,"compositionstart",Mc),nt(e,"compositionend",Ar),nt(e,"change",Ar))},mounted(e,{value:t,modifiers:{trim:n,number:s}}){const r=t??"",i=e[_n];delete e[_n],i!==void 0&&(e.type==="text"||e.type==="textarea")&&e.value!==i?e[xe](rs(e.value,n,s)):e.value=r},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:s,trim:r,number:i}},o){if(e[xe]=mt(o),e.composing)return;const l=(i||e.type==="number")&&!/^0\d/.test(e.value)?Ln(e.value):e.value,c=t??"";if(l===c)return;const d=e.getRootNode();(d instanceof Document||d instanceof ShadowRoot)&&d.activeElement===e&&e.type!=="range"&&(s&&t===n||r&&e.value.trim()===c)||(e.value=c)}},of={deep:!0,created(e,t,n){e[xe]=mt(n),nt(e,"change",()=>{const s=e._modelValue,r=Ht(e),i=e.checked,o=e[xe];if(L(s)){const l=Os(s,r),c=l!==-1;if(i&&!c)o(s.concat(r));else if(!i&&c){const d=[...s];d.splice(l,1),o(d)}}else if(st(s)){const l=new Set(s);i?l.add(r):l.delete(r),o(l)}else o(Xi(e,i))})},mounted:Rr,beforeUpdate(e,t,n){e[xe]=mt(n),Rr(e,t,n)}};function Rr(e,{value:t,oldValue:n},s){e._modelValue=t;let r;if(L(t))r=Os(t,s.props.value)>-1;else if(st(t))r=t.has(s.props.value);else{if(t===n)return;r=Te(t,Xi(e,!0))}e.checked!==r&&(e.checked=r)}const lf={created(e,{value:t},n){e.checked=Te(t,n.props.value),e[xe]=mt(n),nt(e,"change",()=>{e[xe](Ht(e))})},beforeUpdate(e,{value:t,oldValue:n},s){e[xe]=mt(s),t!==n&&(e.checked=Te(t,s.props.value))}},cf={deep:!0,created(e,{value:t,modifiers:{number:n}},s){e._modelValue=t,nt(e,"change",()=>{const r=Array.prototype.filter.call(e.options,c=>c.selected).map(c=>n?Ln(Ht(c)):Ht(c)),i=e.multiple,o=i?st(e._modelValue)?new Set(r):r:r[0],l=e._pendingValue=[i,i?L(o)?r.slice():r:o];try{e[xe](o)}finally{Vs(()=>{e._pendingValue===l&&(e._pendingValue=void 0)})}}),e[xe]=mt(s)},mounted(e,{value:t}){xr(e,t)},beforeUpdate(e,{value:t},n){e._modelValue=t,e[xe]=mt(n)},updated(e,{value:t}){const n=e._pendingValue;e._pendingValue=void 0,(!n||n[0]!==e.multiple||!Lc(t,n[1],n[0]))&&xr(e,t)}};function Lc(e,t,n){if(!n||L(e))return Te(e,t);if(st(e)){if(e.size!==t.length)return!1;for(const s of t)if(!e.has(s))return!1;return!0}return!1}function xr(e,t){const n=e.multiple,s=L(t);if(!(n&&!s&&!st(t))){for(let r=0,i=e.options.length;r<i;r++){const o=e.options[r],l=Ht(o);if(n)if(s){const c=typeof l;c==="string"||c==="number"?o.selected=t.some(d=>String(d)===String(l)):o.selected=Os(t,l)>-1}else o.selected=t.has(l);else if(Te(Ht(o),t)){e.selectedIndex!==r&&(e.selectedIndex=r);return}}!n&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Ht(e){return"_value"in e?e._value:e.value}function Xi(e,t){const n=t?"_trueValue":"_falseValue";return n in e?e[n]:t}const Vc={esc:"escape",space:" ",up:"arrow-up",left:"arrow-left",right:"arrow-right",down:"arrow-down",delete:"backspace"},uf=(e,t)=>{const n=e._withKeys||(e._withKeys={}),s=t.join(".");return n[s]||(n[s]=r=>{if(!("key"in r))return;const i=_t(r.key);if(t.some(o=>o===i||Vc[o]===i))return e(r)})},Fc=fe({patchProp:Ic},pc);let Sr;function jc(){return Sr||(Sr=ql(Fc))}const Hc=(...e)=>{const t=jc().createApp(...e),{mount:n}=t;return t.mount=s=>{const r=Uc(s);if(!r)return;const i=t._component;!B(i)&&!i.render&&!i.template&&(i.template=r.innerHTML),r.nodeType===1&&(r.textContent="");const o=n(r,!1,Bc(r));return r instanceof Element&&(r.removeAttribute("v-cloak"),r.setAttribute("data-v-app","")),o},t};function Bc(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function Uc(e){return ne(e)?document.querySelector(e):e}/*!
+ * vue-router v4.6.4
+ * (c) 2025 Eduardo San Martin Morote
+ * @license MIT
+ */const Nt=typeof document<"u";function Zi(e){return typeof e=="object"||"displayName"in e||"props"in e||"__vccOpts"in e}function Kc(e){return e.__esModule||e[Symbol.toStringTag]==="Module"||e.default&&Zi(e.default)}const W=Object.assign;function is(e,t){const n={};for(const s in t){const r=t[s];n[s]=De(r)?r.map(e):e(r)}return n}const tn=()=>{},De=Array.isArray;function wr(e,t){const n={};for(const s in e)n[s]=s in t?t[s]:e[s];return n}const eo=/#/g,Gc=/&/g,Wc=/\//g,kc=/=/g,qc=/\?/g,to=/\+/g,$c=/%5B/g,zc=/%5D/g,no=/%5E/g,Jc=/%60/g,so=/%7B/g,Qc=/%7C/g,ro=/%7D/g,Yc=/%20/g;function Ks(e){return e==null?"":encodeURI(""+e).replace(Qc,"|").replace($c,"[").replace(zc,"]")}function Xc(e){return Ks(e).replace(so,"{").replace(ro,"}").replace(no,"^")}function vs(e){return Ks(e).replace(to,"%2B").replace(Yc,"+").replace(eo,"%23").replace(Gc,"%26").replace(Jc,"`").replace(so,"{").replace(ro,"}").replace(no,"^")}function Zc(e){return vs(e).replace(kc,"%3D")}function eu(e){return Ks(e).replace(eo,"%23").replace(qc,"%3F")}function tu(e){return eu(e).replace(Wc,"%2F")}function un(e){if(e==null)return null;try{return decodeURIComponent(""+e)}catch{}return""+e}const nu=/\/$/,su=e=>e.replace(nu,"");function os(e,t,n="/"){let s,r={},i="",o="";const l=t.indexOf("#");let c=t.indexOf("?");return c=l>=0&&c>l?-1:c,c>=0&&(s=t.slice(0,c),i=t.slice(c,l>0?l:t.length),r=e(i.slice(1))),l>=0&&(s=s||t.slice(0,l),o=t.slice(l,t.length)),s=lu(s??t,n),{fullPath:s+i+o,path:s,query:r,hash:un(o)}}function ru(e,t){const n=t.query?e(t.query):"";return t.path+(n&&"?")+n+(t.hash||"")}function Cr(e,t){return!t||!e.toLowerCase().startsWith(t.toLowerCase())?e:e.slice(t.length)||"/"}function iu(e,t,n){const s=t.matched.length-1,r=n.matched.length-1;return s>-1&&s===r&&Bt(t.matched[s],n.matched[r])&&io(t.params,n.params)&&e(t.query)===e(n.query)&&t.hash===n.hash}function Bt(e,t){return(e.aliasOf||e)===(t.aliasOf||t)}function io(e,t){if(Object.keys(e).length!==Object.keys(t).length)return!1;for(var n in e)if(!ou(e[n],t[n]))return!1;return!0}function ou(e,t){return De(e)?Or(e,t):De(t)?Or(t,e):(e==null?void 0:e.valueOf())===(t==null?void 0:t.valueOf())}function Or(e,t){return De(t)?e.length===t.length&&e.every((n,s)=>n===t[s]):e.length===1&&e[0]===t}function lu(e,t){if(e.startsWith("/"))return e;if(!e)return t;const n=t.split("/"),s=e.split("/"),r=s[s.length-1];(r===".."||r===".")&&s.push("");let i=n.length-1,o,l;for(o=0;o<s.length;o++)if(l=s[o],l!==".")if(l==="..")i>1&&i--;else break;return n.slice(0,i).join("/")+"/"+s.slice(o).join("/")}const dt={path:"/",name:void 0,params:{},query:{},hash:"",fullPath:"/",matched:[],meta:{},redirectedFrom:void 0};let bs=function(e){return e.pop="pop",e.push="push",e}({}),ls=function(e){return e.back="back",e.forward="forward",e.unknown="",e}({});function cu(e){if(!e)if(Nt){const t=document.querySelector("base");e=t&&t.getAttribute("href")||"/",e=e.replace(/^\w+:\/\/[^\/]+/,"")}else e="/";return e[0]!=="/"&&e[0]!=="#"&&(e="/"+e),su(e)}const uu=/^[^#]+#/;function fu(e,t){return e.replace(uu,"#")+t}function au(e,t){const n=document.documentElement.getBoundingClientRect(),s=e.getBoundingClientRect();return{behavior:t.behavior,left:s.left-n.left-(t.left||0),top:s.top-n.top-(t.top||0)}}const kn=()=>({left:window.scrollX,top:window.scrollY});function du(e){let t;if("el"in e){const n=e.el,s=typeof n=="string"&&n.startsWith("#"),r=typeof n=="string"?s?document.getElementById(n.slice(1)):document.querySelector(n):n;if(!r)return;t=au(r,e)}else t=e;"scrollBehavior"in document.documentElement.style?window.scrollTo(t):window.scrollTo(t.left!=null?t.left:window.scrollX,t.top!=null?t.top:window.scrollY)}function Pr(e,t){return(history.state?history.state.position-t:-1)+e}const Es=new Map;function hu(e,t){Es.set(e,t)}function pu(e){const t=Es.get(e);return Es.delete(e),t}function gu(e){return typeof e=="string"||e&&typeof e=="object"}function oo(e){return typeof e=="string"||typeof e=="symbol"}let te=function(e){return e[e.MATCHER_NOT_FOUND=1]="MATCHER_NOT_FOUND",e[e.NAVIGATION_GUARD_REDIRECT=2]="NAVIGATION_GUARD_REDIRECT",e[e.NAVIGATION_ABORTED=4]="NAVIGATION_ABORTED",e[e.NAVIGATION_CANCELLED=8]="NAVIGATION_CANCELLED",e[e.NAVIGATION_DUPLICATED=16]="NAVIGATION_DUPLICATED",e}({});const lo=Symbol("");te.MATCHER_NOT_FOUND+"",te.NAVIGATION_GUARD_REDIRECT+"",te.NAVIGATION_ABORTED+"",te.NAVIGATION_CANCELLED+"",te.NAVIGATION_DUPLICATED+"";function Ut(e,t){return W(new Error,{type:e,[lo]:!0},t)}function Qe(e,t){return e instanceof Error&&lo in e&&(t==null||!!(e.type&t))}const mu=["params","query","hash"];function _u(e){if(typeof e=="string")return e;if(e.path!=null)return e.path;const t={};for(const n of mu)n in e&&(t[n]=e[n]);return JSON.stringify(t,null,2)}function yu(e){const t={};if(e===""||e==="?")return t;const n=(e[0]==="?"?e.slice(1):e).split("&");for(let s=0;s<n.length;++s){const r=n[s].replace(to," "),i=r.indexOf("="),o=un(i<0?r:r.slice(0,i)),l=i<0?null:un(r.slice(i+1));if(o in t){let c=t[o];De(c)||(c=t[o]=[c]),c.push(l)}else t[o]=l}return t}function Tr(e){let t="";for(let n in e){const s=e[n];if(n=Zc(n),s==null){s!==void 0&&(t+=(t.length?"&":"")+n);continue}(De(s)?s.map(r=>r&&vs(r)):[s&&vs(s)]).forEach(r=>{r!==void 0&&(t+=(t.length?"&":"")+n,r!=null&&(t+="="+r))})}return t}function vu(e){const t={};for(const n in e){const s=e[n];s!==void 0&&(t[n]=De(s)?s.map(r=>r==null?null:""+r):s==null?s:""+s)}return t}const bu=Symbol(""),Ir=Symbol(""),qn=Symbol(""),Gs=Symbol(""),As=Symbol("");function qt(){let e=[];function t(s){return e.push(s),()=>{const r=e.indexOf(s);r>-1&&e.splice(r,1)}}function n(){e=[]}return{add:t,list:()=>e.slice(),reset:n}}function pt(e,t,n,s,r,i=o=>o()){const o=s&&(s.enterCallbacks[r]=s.enterCallbacks[r]||[]);return()=>new Promise((l,c)=>{const d=g=>{g===!1?c(Ut(te.NAVIGATION_ABORTED,{from:n,to:t})):g instanceof Error?c(g):gu(g)?c(Ut(te.NAVIGATION_GUARD_REDIRECT,{from:t,to:g})):(o&&s.enterCallbacks[r]===o&&typeof g=="function"&&o.push(g),l())},f=i(()=>e.call(s&&s.instances[r],t,n,d));let h=Promise.resolve(f);e.length<3&&(h=h.then(d)),h.catch(g=>c(g))})}function cs(e,t,n,s,r=i=>i()){const i=[];for(const o of e)for(const l in o.components){let c=o.components[l];if(!(t!=="beforeRouteEnter"&&!o.instances[l]))if(Zi(c)){const d=(c.__vccOpts||c)[t];d&&i.push(pt(d,n,s,o,l,r))}else{let d=c();i.push(()=>d.then(f=>{if(!f)throw new Error(`Couldn't resolve component "${l}" at "${o.path}"`);const h=Kc(f)?f.default:f;o.mods[l]=f,o.components[l]=h;const g=(h.__vccOpts||h)[t];return g&&pt(g,n,s,o,l,r)()}))}}return i}function Eu(e,t){const n=[],s=[],r=[],i=Math.max(t.matched.length,e.matched.length);for(let o=0;o<i;o++){const l=t.matched[o];l&&(e.matched.find(d=>Bt(d,l))?s.push(l):n.push(l));const c=e.matched[o];c&&(t.matched.find(d=>Bt(d,c))||r.push(c))}return[n,s,r]}/*!
+ * vue-router v4.6.4
+ * (c) 2025 Eduardo San Martin Morote
+ * @license MIT
+ */let Au=()=>location.protocol+"//"+location.host;function co(e,t){const{pathname:n,search:s,hash:r}=t,i=e.indexOf("#");if(i>-1){let o=r.includes(e.slice(i))?e.slice(i).length:1,l=r.slice(o);return l[0]!=="/"&&(l="/"+l),Cr(l,"")}return Cr(n,e)+s+r}function Ru(e,t,n,s){let r=[],i=[],o=null;const l=({state:g})=>{const m=co(e,location),I=n.value,S=t.value;let H=0;if(g){if(n.value=m,t.value=g,o&&o===I){o=null;return}H=S?g.position-S.position:0}else s(m);r.forEach(F=>{F(n.value,I,{delta:H,type:bs.pop,direction:H?H>0?ls.forward:ls.back:ls.unknown})})};function c(){o=n.value}function d(g){r.push(g);const m=()=>{const I=r.indexOf(g);I>-1&&r.splice(I,1)};return i.push(m),m}function f(){if(document.visibilityState==="hidden"){const{history:g}=window;if(!g.state)return;g.replaceState(W({},g.state,{scroll:kn()}),"")}}function h(){for(const g of i)g();i=[],window.removeEventListener("popstate",l),window.removeEventListener("pagehide",f),document.removeEventListener("visibilitychange",f)}return window.addEventListener("popstate",l),window.addEventListener("pagehide",f),document.addEventListener("visibilitychange",f),{pauseListeners:c,listen:d,destroy:h}}function Nr(e,t,n,s=!1,r=!1){return{back:e,current:t,forward:n,replaced:s,position:window.history.length,scroll:r?kn():null}}function xu(e){const{history:t,location:n}=window,s={value:co(e,n)},r={value:t.state};r.value||i(s.value,{back:null,current:s.value,forward:null,position:t.length-1,replaced:!0,scroll:null},!0);function i(c,d,f){const h=e.indexOf("#"),g=h>-1?(n.host&&document.querySelector("base")?e:e.slice(h))+c:Au()+e+c;try{t[f?"replaceState":"pushState"](d,"",g),r.value=d}catch(m){console.error(m),n[f?"replace":"assign"](g)}}function o(c,d){i(c,W({},t.state,Nr(r.value.back,c,r.value.forward,!0),d,{position:r.value.position}),!0),s.value=c}function l(c,d){const f=W({},r.value,t.state,{forward:c,scroll:kn()});i(f.current,f,!0),i(c,W({},Nr(s.value,c,null),{position:f.position+1},d),!1),s.value=c}return{location:s,state:r,push:l,replace:o}}function Su(e){e=cu(e);const t=xu(e),n=Ru(e,t.state,t.location,t.replace);function s(i,o=!0){o||n.pauseListeners(),history.go(i)}const r=W({location:"",base:e,go:s,createHref:fu.bind(null,e)},t,n);return Object.defineProperty(r,"location",{enumerable:!0,get:()=>t.location.value}),Object.defineProperty(r,"state",{enumerable:!0,get:()=>t.state.value}),r}let Rt=function(e){return e[e.Static=0]="Static",e[e.Param=1]="Param",e[e.Group=2]="Group",e}({});var oe=function(e){return e[e.Static=0]="Static",e[e.Param=1]="Param",e[e.ParamRegExp=2]="ParamRegExp",e[e.ParamRegExpEnd=3]="ParamRegExpEnd",e[e.EscapeNext=4]="EscapeNext",e}(oe||{});const wu={type:Rt.Static,value:""},Cu=/[a-zA-Z0-9_]/;function Ou(e){if(!e)return[[]];if(e==="/")return[[wu]];if(!e.startsWith("/"))throw new Error(`Invalid path "${e}"`);function t(m){throw new Error(`ERR (${n})/"${d}": ${m}`)}let n=oe.Static,s=n;const r=[];let i;function o(){i&&r.push(i),i=[]}let l=0,c,d="",f="";function h(){d&&(n===oe.Static?i.push({type:Rt.Static,value:d}):n===oe.Param||n===oe.ParamRegExp||n===oe.ParamRegExpEnd?(i.length>1&&(c==="*"||c==="+")&&t(`A repeatable param (${d}) must be alone in its segment. eg: '/:ids+.`),i.push({type:Rt.Param,value:d,regexp:f,repeatable:c==="*"||c==="+",optional:c==="*"||c==="?"})):t("Invalid state to consume buffer"),d="")}function g(){d+=c}for(;l<e.length;){if(c=e[l++],c==="\\"&&n!==oe.ParamRegExp){s=n,n=oe.EscapeNext;continue}switch(n){case oe.Static:c==="/"?(d&&h(),o()):c===":"?(h(),n=oe.Param):g();break;case oe.EscapeNext:g(),n=s;break;case oe.Param:c==="("?n=oe.ParamRegExp:Cu.test(c)?g():(h(),n=oe.Static,c!=="*"&&c!=="?"&&c!=="+"&&l--);break;case oe.ParamRegExp:c===")"?f[f.length-1]=="\\"?f=f.slice(0,-1)+c:n=oe.ParamRegExpEnd:f+=c;break;case oe.ParamRegExpEnd:h(),n=oe.Static,c!=="*"&&c!=="?"&&c!=="+"&&l--,f="";break;default:t("Unknown state");break}}return n===oe.ParamRegExp&&t(`Unfinished custom RegExp for param "${d}"`),h(),o(),r}const Dr="[^/]+?",Pu={sensitive:!1,strict:!1,start:!0,end:!0};var ge=function(e){return e[e._multiplier=10]="_multiplier",e[e.Root=90]="Root",e[e.Segment=40]="Segment",e[e.SubSegment=30]="SubSegment",e[e.Static=40]="Static",e[e.Dynamic=20]="Dynamic",e[e.BonusCustomRegExp=10]="BonusCustomRegExp",e[e.BonusWildcard=-50]="BonusWildcard",e[e.BonusRepeatable=-20]="BonusRepeatable",e[e.BonusOptional=-8]="BonusOptional",e[e.BonusStrict=.7000000000000001]="BonusStrict",e[e.BonusCaseSensitive=.25]="BonusCaseSensitive",e}(ge||{});const Tu=/[.+*?^${}()[\]/\\]/g;function Iu(e,t){const n=W({},Pu,t),s=[];let r=n.start?"^":"";const i=[];for(const d of e){const f=d.length?[]:[ge.Root];n.strict&&!d.length&&(r+="/");for(let h=0;h<d.length;h++){const g=d[h];let m=ge.Segment+(n.sensitive?ge.BonusCaseSensitive:0);if(g.type===Rt.Static)h||(r+="/"),r+=g.value.replace(Tu,"\\$&"),m+=ge.Static;else if(g.type===Rt.Param){const{value:I,repeatable:S,optional:H,regexp:F}=g;i.push({name:I,repeatable:S,optional:H});const O=F||Dr;if(O!==Dr){m+=ge.BonusCustomRegExp;try{`${O}`}catch(T){throw new Error(`Invalid custom RegExp for param "${I}" (${O}): `+T.message)}}let N=S?`((?:${O})(?:/(?:${O}))*)`:`(${O})`;h||(N=H&&d.length<2?`(?:/${N})`:"/"+N),H&&(N+="?"),r+=N,m+=ge.Dynamic,H&&(m+=ge.BonusOptional),S&&(m+=ge.BonusRepeatable),O===".*"&&(m+=ge.BonusWildcard)}f.push(m)}s.push(f)}if(n.strict&&n.end){const d=s.length-1;s[d][s[d].length-1]+=ge.BonusStrict}n.strict||(r+="/?"),n.end?r+="$":n.strict&&!r.endsWith("/")&&(r+="(?:/|$)");const o=new RegExp(r,n.sensitive?"":"i");function l(d){const f=d.match(o),h={};if(!f)return null;for(let g=1;g<f.length;g++){const m=f[g]||"",I=i[g-1];h[I.name]=m&&I.repeatable?m.split("/"):m}return h}function c(d){let f="",h=!1;for(const g of e){(!h||!f.endsWith("/"))&&(f+="/"),h=!1;for(const m of g)if(m.type===Rt.Static)f+=m.value;else if(m.type===Rt.Param){const{value:I,repeatable:S,optional:H}=m,F=I in d?d[I]:"";if(De(F)&&!S)throw new Error(`Provided param "${I}" is an array but it is not repeatable (* or + modifiers)`);const O=De(F)?F.join("/"):F;if(!O)if(H)g.length<2&&(f.endsWith("/")?f=f.slice(0,-1):h=!0);else throw new Error(`Missing required param "${I}"`);f+=O}}return f||"/"}return{re:o,score:s,keys:i,parse:l,stringify:c}}function Nu(e,t){let n=0;for(;n<e.length&&n<t.length;){const s=t[n]-e[n];if(s)return s;n++}return e.length<t.length?e.length===1&&e[0]===ge.Static+ge.Segment?-1:1:e.length>t.length?t.length===1&&t[0]===ge.Static+ge.Segment?1:-1:0}function uo(e,t){let n=0;const s=e.score,r=t.score;for(;n<s.length&&n<r.length;){const i=Nu(s[n],r[n]);if(i)return i;n++}if(Math.abs(r.length-s.length)===1){if(Mr(s))return 1;if(Mr(r))return-1}return r.length-s.length}function Mr(e){const t=e[e.length-1];return e.length>0&&t[t.length-1]<0}const Du={strict:!1,end:!0,sensitive:!1};function Mu(e,t,n){const s=Iu(Ou(e.path),n),r=W(s,{record:e,parent:t,children:[],alias:[]});return t&&!r.record.aliasOf==!t.record.aliasOf&&t.children.push(r),r}function Lu(e,t){const n=[],s=new Map;t=wr(Du,t);function r(h){return s.get(h)}function i(h,g,m){const I=!m,S=Vr(h);S.aliasOf=m&&m.record;const H=wr(t,h),F=[S];if("alias"in h){const T=typeof h.alias=="string"?[h.alias]:h.alias;for(const Q of T)F.push(Vr(W({},S,{components:m?m.record.components:S.components,path:Q,aliasOf:m?m.record:S})))}let O,N;for(const T of F){const{path:Q}=T;if(g&&Q[0]!=="/"){const ie=g.record.path,ee=ie[ie.length-1]==="/"?"":"/";T.path=g.record.path+(Q&&ee+Q)}if(O=Mu(T,g,H),m?m.alias.push(O):(N=N||O,N!==O&&N.alias.push(O),I&&h.name&&!Fr(O)&&o(h.name)),fo(O)&&c(O),S.children){const ie=S.children;for(let ee=0;ee<ie.length;ee++)i(ie[ee],O,m&&m.children[ee])}m=m||O}return N?()=>{o(N)}:tn}function o(h){if(oo(h)){const g=s.get(h);g&&(s.delete(h),n.splice(n.indexOf(g),1),g.children.forEach(o),g.alias.forEach(o))}else{const g=n.indexOf(h);g>-1&&(n.splice(g,1),h.record.name&&s.delete(h.record.name),h.children.forEach(o),h.alias.forEach(o))}}function l(){return n}function c(h){const g=ju(h,n);n.splice(g,0,h),h.record.name&&!Fr(h)&&s.set(h.record.name,h)}function d(h,g){let m,I={},S,H;if("name"in h&&h.name){if(m=s.get(h.name),!m)throw Ut(te.MATCHER_NOT_FOUND,{location:h});H=m.record.name,I=W(Lr(g.params,m.keys.filter(N=>!N.optional).concat(m.parent?m.parent.keys.filter(N=>N.optional):[]).map(N=>N.name)),h.params&&Lr(h.params,m.keys.map(N=>N.name))),S=m.stringify(I)}else if(h.path!=null)S=h.path,m=n.find(N=>N.re.test(S)),m&&(I=m.parse(S),H=m.record.name);else{if(m=g.name?s.get(g.name):n.find(N=>N.re.test(g.path)),!m)throw Ut(te.MATCHER_NOT_FOUND,{location:h,currentLocation:g});H=m.record.name,I=W({},g.params,h.params),S=m.stringify(I)}const F=[];let O=m;for(;O;)F.unshift(O.record),O=O.parent;return{name:H,path:S,params:I,matched:F,meta:Fu(F)}}e.forEach(h=>i(h));function f(){n.length=0,s.clear()}return{addRoute:i,resolve:d,removeRoute:o,clearRoutes:f,getRoutes:l,getRecordMatcher:r}}function Lr(e,t){const n={};for(const s of t)s in e&&(n[s]=e[s]);return n}function Vr(e){const t={path:e.path,redirect:e.redirect,name:e.name,meta:e.meta||{},aliasOf:e.aliasOf,beforeEnter:e.beforeEnter,props:Vu(e),children:e.children||[],instances:{},leaveGuards:new Set,updateGuards:new Set,enterCallbacks:{},components:"components"in e?e.components||null:e.component&&{default:e.component}};return Object.defineProperty(t,"mods",{value:{}}),t}function Vu(e){const t={},n=e.props||!1;if("component"in e)t.default=n;else for(const s in e.components)t[s]=typeof n=="object"?n[s]:n;return t}function Fr(e){for(;e;){if(e.record.aliasOf)return!0;e=e.parent}return!1}function Fu(e){return e.reduce((t,n)=>W(t,n.meta),{})}function ju(e,t){let n=0,s=t.length;for(;n!==s;){const i=n+s>>1;uo(e,t[i])<0?s=i:n=i+1}const r=Hu(e);return r&&(s=t.lastIndexOf(r,s-1)),s}function Hu(e){let t=e;for(;t=t.parent;)if(fo(t)&&uo(e,t)===0)return t}function fo({record:e}){return!!(e.name||e.components&&Object.keys(e.components).length||e.redirect)}function jr(e){const t=Pe(qn),n=Pe(Gs),s=Ce(()=>{const c=Mt(e.to);return t.resolve(c)}),r=Ce(()=>{const{matched:c}=s.value,{length:d}=c,f=c[d-1],h=n.matched;if(!f||!h.length)return-1;const g=h.findIndex(Bt.bind(null,f));if(g>-1)return g;const m=Hr(c[d-2]);return d>1&&Hr(f)===m&&h[h.length-1].path!==m?h.findIndex(Bt.bind(null,c[d-2])):g}),i=Ce(()=>r.value>-1&&Wu(n.params,s.value.params)),o=Ce(()=>r.value>-1&&r.value===n.matched.length-1&&io(n.params,s.value.params));function l(c={}){if(Gu(c)){const d=t[Mt(e.replace)?"replace":"push"](Mt(e.to)).catch(tn);return e.viewTransition&&typeof document<"u"&&"startViewTransition"in document&&document.startViewTransition(()=>d),d}return Promise.resolve()}return{route:s,href:Ce(()=>s.value.href),isActive:i,isExactActive:o,navigate:l}}function Bu(e){return e.length===1?e[0]:e}const Uu=Ei({name:"RouterLink",compatConfig:{MODE:3},props:{to:{type:[String,Object],required:!0},replace:Boolean,activeClass:String,exactActiveClass:String,custom:Boolean,ariaCurrentValue:{type:String,default:"page"},viewTransition:Boolean},useLink:jr,setup(e,{slots:t}){const n=jn(jr(e)),{options:s}=Pe(qn),r=Ce(()=>({[Br(e.activeClass,s.linkActiveClass,"router-link-active")]:n.isActive,[Br(e.exactActiveClass,s.linkExactActiveClass,"router-link-exact-active")]:n.isExactActive}));return()=>{const i=t.default&&Bu(t.default(n));return e.custom?i:Qi("a",{"aria-current":n.isExactActive?e.ariaCurrentValue:null,href:n.href,onClick:n.navigate,class:r.value},i)}}}),Ku=Uu;function Gu(e){if(!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)&&!e.defaultPrevented&&!(e.button!==void 0&&e.button!==0)){if(e.currentTarget&&e.currentTarget.getAttribute){const t=e.currentTarget.getAttribute("target");if(/\b_blank\b/i.test(t))return}return e.preventDefault&&e.preventDefault(),!0}}function Wu(e,t){for(const n in t){const s=t[n],r=e[n];if(typeof s=="string"){if(s!==r)return!1}else if(!De(r)||r.length!==s.length||s.some((i,o)=>i.valueOf()!==r[o].valueOf()))return!1}return!0}function Hr(e){return e?e.aliasOf?e.aliasOf.path:e.path:""}const Br=(e,t,n)=>e??t??n,ku=Ei({name:"RouterView",inheritAttrs:!1,props:{name:{type:String,default:"default"},route:Object},compatConfig:{MODE:3},setup(e,{attrs:t,slots:n}){const s=Pe(As),r=Ce(()=>e.route||s.value),i=Pe(Ir,0),o=Ce(()=>{let d=Mt(i);const{matched:f}=r.value;let h;for(;(h=f[d])&&!h.components;)d++;return d}),l=Ce(()=>r.value.matched[o.value]);vn(Ir,Ce(()=>o.value+1)),vn(bu,l),vn(As,r);const c=qo();return bn(()=>[c.value,l.value,e.name],([d,f,h],[g,m,I])=>{f&&(f.instances[h]=d,m&&m!==f&&d&&d===g&&(f.leaveGuards.size||(f.leaveGuards=m.leaveGuards),f.updateGuards.size||(f.updateGuards=m.updateGuards))),d&&f&&(!m||!Bt(f,m)||!g)&&(f.enterCallbacks[h]||[]).forEach(S=>S(d))},{flush:"post"}),()=>{const d=r.value,f=e.name,h=l.value,g=h&&h.components[f];if(!g)return Ur(n.default,{Component:g,route:d});const m=h.props[f],I=m?m===!0?d.params:typeof m=="function"?m(d):m:null,H=Qi(g,W({},I,t,{onVnodeUnmounted:F=>{F.component.isUnmounted&&(h.instances[f]=null)},ref:c}));return Ur(n.default,{Component:H,route:d})||H}}});function Ur(e,t){if(!e)return null;const n=e(t);return n.length===1?n[0]:n}const qu=ku;function $u(e){const t=Lu(e.routes,e),n=e.parseQuery||yu,s=e.stringifyQuery||Tr,r=e.history,i=qt(),o=qt(),l=qt(),c=$o(dt);let d=dt;Nt&&e.scrollBehavior&&"scrollRestoration"in history&&(history.scrollRestoration="manual");const f=is.bind(null,v=>""+v),h=is.bind(null,tu),g=is.bind(null,un);function m(v,P){let w,D;return oo(v)?(w=t.getRecordMatcher(v),D=P):D=v,t.addRoute(D,w)}function I(v){const P=t.getRecordMatcher(v);P&&t.removeRoute(P)}function S(){return t.getRoutes().map(v=>v.record)}function H(v){return!!t.getRecordMatcher(v)}function F(v,P){if(P=W({},P||c.value),typeof v=="string"){const p=os(n,v,P.path),y=t.resolve({path:p.path},P),b=r.createHref(p.fullPath);return W(p,y,{params:g(y.params),hash:un(p.hash),redirectedFrom:void 0,href:b})}let w;if(v.path!=null)w=W({},v,{path:os(n,v.path,P.path).path});else{const p=W({},v.params);for(const y in p)p[y]==null&&delete p[y];w=W({},v,{params:h(p)}),P.params=h(P.params)}const D=t.resolve(w,P),U=v.hash||"";D.params=f(g(D.params));const u=ru(s,W({},v,{hash:Xc(U),path:D.path})),a=r.createHref(u);return W({fullPath:u,hash:U,query:s===Tr?vu(v.query):v.query||{}},D,{redirectedFrom:void 0,href:a})}function O(v){return typeof v=="string"?os(n,v,c.value.path):W({},v)}function N(v,P){if(d!==v)return Ut(te.NAVIGATION_CANCELLED,{from:P,to:v})}function T(v){return ee(v)}function Q(v){return T(W(O(v),{replace:!0}))}function ie(v,P){const w=v.matched[v.matched.length-1];if(w&&w.redirect){const{redirect:D}=w;let U=typeof D=="function"?D(v,P):D;return typeof U=="string"&&(U=U.includes("?")||U.includes("#")?U=O(U):{path:U},U.params={}),W({query:v.query,hash:v.hash,params:U.path!=null?{}:v.params},U)}}function ee(v,P){const w=d=F(v),D=c.value,U=v.state,u=v.force,a=v.replace===!0,p=ie(w,D);if(p)return ee(W(O(p),{state:typeof p=="object"?W({},U,p.state):U,force:u,replace:a}),P||w);const y=w;y.redirectedFrom=P;let b;return!u&&iu(s,D,w)&&(b=Ut(te.NAVIGATION_DUPLICATED,{to:y,from:D}),Fe(D,D,!0,!1)),(b?Promise.resolve(b):Le(y,D)).catch(_=>Qe(_)?Qe(_,te.NAVIGATION_GUARD_REDIRECT)?_:at(_):G(_,y,D)).then(_=>{if(_){if(Qe(_,te.NAVIGATION_GUARD_REDIRECT))return ee(W({replace:a},O(_.to),{state:typeof _.to=="object"?W({},U,_.to.state):U,force:u}),P||y)}else _=yt(y,D,!0,a,U);return ft(y,D,_),_})}function Me(v,P){const w=N(v,P);return w?Promise.reject(w):Promise.resolve()}function ut(v){const P=Pt.values().next().value;return P&&typeof P.runWithContext=="function"?P.runWithContext(v):v()}function Le(v,P){let w;const[D,U,u]=Eu(v,P);w=cs(D.reverse(),"beforeRouteLeave",v,P);for(const p of D)p.leaveGuards.forEach(y=>{w.push(pt(y,v,P))});const a=Me.bind(null,v,P);return w.push(a),Se(w).then(()=>{w=[];for(const p of i.list())w.push(pt(p,v,P));return w.push(a),Se(w)}).then(()=>{w=cs(U,"beforeRouteUpdate",v,P);for(const p of U)p.updateGuards.forEach(y=>{w.push(pt(y,v,P))});return w.push(a),Se(w)}).then(()=>{w=[];for(const p of u)if(p.beforeEnter)if(De(p.beforeEnter))for(const y of p.beforeEnter)w.push(pt(y,v,P));else w.push(pt(p.beforeEnter,v,P));return w.push(a),Se(w)}).then(()=>(v.matched.forEach(p=>p.enterCallbacks={}),w=cs(u,"beforeRouteEnter",v,P,ut),w.push(a),Se(w))).then(()=>{w=[];for(const p of o.list())w.push(pt(p,v,P));return w.push(a),Se(w)}).catch(p=>Qe(p,te.NAVIGATION_CANCELLED)?p:Promise.reject(p))}function ft(v,P,w){l.list().forEach(D=>ut(()=>D(v,P,w)))}function yt(v,P,w,D,U){const u=N(v,P);if(u)return u;const a=P===dt,p=Nt?history.state:{};w&&(D||a?r.replace(v.fullPath,W({scroll:a&&p&&p.scroll},U)):r.push(v.fullPath,U)),c.value=v,Fe(v,P,w,a),at()}let Ve;function Kt(){Ve||(Ve=r.listen((v,P,w)=>{if(!vt.listening)return;const D=F(v),U=ie(D,vt.currentRoute.value);if(U){ee(W(U,{replace:!0,force:!0}),D).catch(tn);return}d=D;const u=c.value;Nt&&hu(Pr(u.fullPath,w.delta),kn()),Le(D,u).catch(a=>Qe(a,te.NAVIGATION_ABORTED|te.NAVIGATION_CANCELLED)?a:Qe(a,te.NAVIGATION_GUARD_REDIRECT)?(ee(W(O(a.to),{force:!0}),D).then(p=>{Qe(p,te.NAVIGATION_ABORTED|te.NAVIGATION_DUPLICATED)&&!w.delta&&w.type===bs.pop&&r.go(-1,!1)}).catch(tn),Promise.reject()):(w.delta&&r.go(-w.delta,!1),G(a,D,u))).then(a=>{a=a||yt(D,u,!1),a&&(w.delta&&!Qe(a,te.NAVIGATION_CANCELLED)?r.go(-w.delta,!1):w.type===bs.pop&&Qe(a,te.NAVIGATION_ABORTED|te.NAVIGATION_DUPLICATED)&&r.go(-1,!1)),ft(D,u,a)}).catch(tn)}))}let Ct=qt(),le=qt(),z;function G(v,P,w){at(v);const D=le.list();return D.length?D.forEach(U=>U(v,P,w)):console.error(v),Promise.reject(v)}function ze(){return z&&c.value!==dt?Promise.resolve():new Promise((v,P)=>{Ct.add([v,P])})}function at(v){return z||(z=!v,Kt(),Ct.list().forEach(([P,w])=>v?w(v):P()),Ct.reset()),v}function Fe(v,P,w,D){const{scrollBehavior:U}=e;if(!Nt||!U)return Promise.resolve();const u=!w&&pu(Pr(v.fullPath,0))||(D||!w)&&history.state&&history.state.scroll||null;return Vs().then(()=>U(v,P,u)).then(a=>a&&du(a)).catch(a=>G(a,v,P))}const ye=v=>r.go(v);let Ot;const Pt=new Set,vt={currentRoute:c,listening:!0,addRoute:m,removeRoute:I,clearRoutes:t.clearRoutes,hasRoute:H,getRoutes:S,resolve:F,options:e,push:T,replace:Q,go:ye,back:()=>ye(-1),forward:()=>ye(1),beforeEach:i.add,beforeResolve:o.add,afterEach:l.add,onError:le.add,isReady:ze,install(v){v.component("RouterLink",Ku),v.component("RouterView",qu),v.config.globalProperties.$router=vt,Object.defineProperty(v.config.globalProperties,"$route",{enumerable:!0,get:()=>Mt(c)}),Nt&&!Ot&&c.value===dt&&(Ot=!0,T(r.location).catch(D=>{}));const P={};for(const D in dt)Object.defineProperty(P,D,{get:()=>c.value[D],enumerable:!0});v.provide(qn,vt),v.provide(Gs,fi(P)),v.provide(As,c);const w=v.unmount;Pt.add(v),v.unmount=function(){Pt.delete(v),Pt.size<1&&(d=dt,Ve&&Ve(),Ve=null,c.value=dt,Ot=!1,z=!1),w()}}};function Se(v){return v.reduce((P,w)=>P.then(()=>ut(w)),Promise.resolve())}return vt}function ff(){return Pe(qn)}function af(e){return Pe(Gs)}const zu=(e,t)=>{const n=e.__vccOpts||e;for(const[s,r]of t)n[s]=r;return n},Ju={};function Qu(e,t){const n=Al("router-view");return Ki(),ki(n)}const Yu=zu(Ju,[["render",Qu]]),Xu=[{path:"/",name:"home",component:()=>re(()=>import("./Home-DWL4BmBg.js"),__vite__mapDeps([0,1]))},{path:"/subject-portal",name:"subjectPortal",component:()=>re(()=>import("./SubjectPortal-C7-krIg3.js"),__vite__mapDeps([2,3,4]))},{path:"/questions",name:"questionList",component:()=>re(()=>import("./QuestionList-cbNQnKl-.js"),__vite__mapDeps([5,3,6,7,8]))},{path:"/questions/add",name:"questionAdd",component:()=>re(()=>import("./QuestionAdd-MfLONVTZ.js"),[])},{path:"/questions/import",name:"fileImport",component:()=>re(()=>import("./FileImport-BF8-5MOQ.js"),__vite__mapDeps([9,3,10]))},{path:"/questions/import/preview",name:"importPreview",component:()=>re(()=>import("./ImportPreview-CFWTYbgQ.js"),__vite__mapDeps([11,3,6,7,12]))},{path:"/questions/:id(\\d+)",name:"questionDetail",component:()=>re(()=>import("./QuestionDetail-s29u4nvx.js"),__vite__mapDeps([13,3,6,7,14]))},{path:"/papers",name:"papersList",component:()=>re(()=>import("./PapersList-CRTR-6gM.js"),__vite__mapDeps([15,3,16]))},{path:"/papers/create-by-id",name:"papersCreateById",component:()=>re(()=>import("./PapersCreateById-CT-UILr2.js"),__vite__mapDeps([17,3]))},{path:"/papers/preview",name:"papersPreview",component:()=>re(()=>import("./PapersPreview-ffjysDLy.js"),__vite__mapDeps([18,3,6,7]))},{path:"/papers/:id(\\d+)/detail",name:"papersDetail",component:()=>re(()=>import("./PapersDetail-CpYT1ySO.js"),__vite__mapDeps([19,3]))},{path:"/papers/:id(\\d+)/result",name:"papersResult",component:()=>re(()=>import("./PapersResult-BN_2zJ7H.js"),__vite__mapDeps([20,3]))},{path:"/analysis/chapters",name:"chapters",component:()=>re(()=>import("./AnalysisChapters-rXnOtC4r.js"),__vite__mapDeps([21,3,22]))},{path:"/analysis/knowledge",name:"knowledge",component:()=>re(()=>import("./AnalysisKnowledge-D1yJ4mIN.js"),__vite__mapDeps([23,3,24]))},{path:"/analysis/pending",name:"pending",component:()=>re(()=>import("./AnalysisPending-BfBXB5e_.js"),__vite__mapDeps([25,3,26]))},{path:"/settings",name:"settings",component:()=>re(()=>import("./Settings-CzRRQaVw.js"),__vite__mapDeps([27,28]))},{path:"/settings/subjects",name:"settingsSubjects",component:()=>re(()=>import("./SettingsSubjects-BOWGx4MO.js"),__vite__mapDeps([29,3,30]))},{path:"/settings/error-causes",name:"settingsErrorCauses",component:()=>re(()=>import("./SettingsErrorCauses-2WPrQUsX.js"),__vite__mapDeps([31,3,32]))},{path:"/settings/question-statuses",name:"settingsStatuses",component:()=>re(()=>import("./SettingsStatuses-C1SsLo53.js"),__vite__mapDeps([33,3,34]))},{path:"/settings/default-params",name:"settingsDefaults",component:()=>re(()=>import("./SettingsDefaultParams-Bwa8C6ww.js"),__vite__mapDeps([35,3]))}],Zu=$u({history:Su("/StudyBase/"),routes:Xu});Hc(Yu).use(Zu).mount("#app");export{Xe as F,zu as _,$i as a,hl as b,nf as c,qo as d,of as e,Zl as f,rf as g,lf as h,sf as i,af as j,jn as k,ff as l,Re as m,Cs as n,Ki as o,ws as p,Vs as q,tf as r,uf as s,So as t,Mt as u,cf as v,ef as w,Ce as x,bn as y};
diff --git a/frontend/dist/index.html b/frontend/dist/index.html
new file mode 100644 (file)
index 0000000..6f76fbd
--- /dev/null
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect x='4' y='4' width='56' height='56' rx='8' ry='8' fill='%23ffffff' stroke='%234A90E2' stroke-width='6'/><text x='32' y='44' font-family='Helvetica,Arial,sans-serif' font-size='36' font-weight='bold' fill='%234A90E2' text-anchor='middle'>B</text></svg>" />
+  <title>Base</title>
+  <script type="module" crossorigin src="/StudyBase/assets/index-Dc_QwQl6.js"></script>
+  <link rel="stylesheet" crossorigin href="/StudyBase/assets/index-D5_k2e2e.css">
+</head>
+<body>
+  <div id="app"></div>
+</body>
+</html>
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644 (file)
index 0000000..24e9a88
--- /dev/null
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect x='4' y='4' width='56' height='56' rx='8' ry='8' fill='%23ffffff' stroke='%234A90E2' stroke-width='6'/><text x='32' y='44' font-family='Helvetica,Arial,sans-serif' font-size='36' font-weight='bold' fill='%234A90E2' text-anchor='middle'>B</text></svg>" />
+  <title>Base</title>
+</head>
+<body>
+  <div id="app"></div>
+  <script type="module" src="/src/main.js"></script>
+</body>
+</html>
\ No newline at end of file
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644 (file)
index 0000000..064e891
--- /dev/null
@@ -0,0 +1,1242 @@
+{
+  "name": "studybase-frontend",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "studybase-frontend",
+      "version": "1.0.0",
+      "dependencies": {
+        "vue": "^3.4.0",
+        "vue-router": "^4.3.0"
+      },
+      "devDependencies": {
+        "@vitejs/plugin-vue": "^5.0.0",
+        "vite": "^5.2.0"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.29.7",
+      "resolved": "http://mirrors.tencent.com/npm/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+      "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.29.7",
+      "resolved": "http://mirrors.tencent.com/npm/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+      "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.29.8",
+      "resolved": "http://mirrors.tencent.com/npm/@babel/parser/-/parser-7.29.8.tgz",
+      "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.29.8"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.29.8",
+      "resolved": "http://mirrors.tencent.com/npm/@babel/types/-/types-7.29.8.tgz",
+      "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.29.7",
+        "@babel/helper-validator-identifier": "^7.29.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.6.0",
+      "resolved": "http://mirrors.tencent.com/npm/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+      "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+      "license": "MIT"
+    },
+    "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+      "version": "1.5.1",
+      "resolved": "http://mirrors.tencent.com/npm/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+      "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^22.20 || ^24.12 || >=25"
+      }
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz",
+      "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz",
+      "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz",
+      "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz",
+      "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz",
+      "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz",
+      "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz",
+      "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz",
+      "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz",
+      "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz",
+      "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz",
+      "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz",
+      "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz",
+      "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz",
+      "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz",
+      "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz",
+      "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz",
+      "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz",
+      "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz",
+      "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz",
+      "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz",
+      "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz",
+      "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz",
+      "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz",
+      "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz",
+      "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.9",
+      "resolved": "http://mirrors.tencent.com/npm/@types/estree/-/estree-1.0.9.tgz",
+      "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "5.2.4",
+      "resolved": "http://mirrors.tencent.com/npm/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+      "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^5.0.0 || ^6.0.0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.42",
+      "resolved": "http://mirrors.tencent.com/npm/@vue/compiler-core/-/compiler-core-3.5.42.tgz",
+      "integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.8",
+        "@vue/shared": "3.5.42",
+        "entities": "^7.0.1",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.42",
+      "resolved": "http://mirrors.tencent.com/npm/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz",
+      "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.42",
+        "@vue/shared": "3.5.42"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.42",
+      "resolved": "http://mirrors.tencent.com/npm/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz",
+      "integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.8",
+        "@vue/compiler-core": "3.5.42",
+        "@vue/compiler-dom": "3.5.42",
+        "@vue/compiler-ssr": "3.5.42",
+        "@vue/shared": "3.5.42",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.21",
+        "postcss": "^8.5.19",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.42",
+      "resolved": "http://mirrors.tencent.com/npm/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz",
+      "integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.42",
+        "@vue/shared": "3.5.42"
+      }
+    },
+    "node_modules/@vue/devtools-api": {
+      "version": "6.6.4",
+      "resolved": "http://mirrors.tencent.com/npm/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+      "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+      "license": "MIT"
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.42",
+      "resolved": "http://mirrors.tencent.com/npm/@vue/reactivity/-/reactivity-3.5.42.tgz",
+      "integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/shared": "3.5.42"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.42",
+      "resolved": "http://mirrors.tencent.com/npm/@vue/runtime-core/-/runtime-core-3.5.42.tgz",
+      "integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.42",
+        "@vue/shared": "3.5.42"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.42",
+      "resolved": "http://mirrors.tencent.com/npm/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz",
+      "integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.42",
+        "@vue/runtime-core": "3.5.42",
+        "@vue/shared": "3.5.42",
+        "csstype": "^3.2.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.42",
+      "resolved": "http://mirrors.tencent.com/npm/@vue/server-renderer/-/server-renderer-3.5.42.tgz",
+      "integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.42",
+        "@vue/runtime-dom": "3.5.42",
+        "@vue/shared": "3.5.42"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.42",
+      "resolved": "http://mirrors.tencent.com/npm/@vue/shared/-/shared-3.5.42.tgz",
+      "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==",
+      "license": "MIT"
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "http://mirrors.tencent.com/npm/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
+    },
+    "node_modules/entities": {
+      "version": "7.0.1",
+      "resolved": "http://mirrors.tencent.com/npm/entities/-/entities-7.0.1.tgz",
+      "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.21.5",
+      "resolved": "http://mirrors.tencent.com/npm/esbuild/-/esbuild-0.21.5.tgz",
+      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.21.5",
+        "@esbuild/android-arm": "0.21.5",
+        "@esbuild/android-arm64": "0.21.5",
+        "@esbuild/android-x64": "0.21.5",
+        "@esbuild/darwin-arm64": "0.21.5",
+        "@esbuild/darwin-x64": "0.21.5",
+        "@esbuild/freebsd-arm64": "0.21.5",
+        "@esbuild/freebsd-x64": "0.21.5",
+        "@esbuild/linux-arm": "0.21.5",
+        "@esbuild/linux-arm64": "0.21.5",
+        "@esbuild/linux-ia32": "0.21.5",
+        "@esbuild/linux-loong64": "0.21.5",
+        "@esbuild/linux-mips64el": "0.21.5",
+        "@esbuild/linux-ppc64": "0.21.5",
+        "@esbuild/linux-riscv64": "0.21.5",
+        "@esbuild/linux-s390x": "0.21.5",
+        "@esbuild/linux-x64": "0.21.5",
+        "@esbuild/netbsd-x64": "0.21.5",
+        "@esbuild/openbsd-x64": "0.21.5",
+        "@esbuild/sunos-x64": "0.21.5",
+        "@esbuild/win32-arm64": "0.21.5",
+        "@esbuild/win32-ia32": "0.21.5",
+        "@esbuild/win32-x64": "0.21.5"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "http://mirrors.tencent.com/npm/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+      "license": "MIT"
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "http://mirrors.tencent.com/npm/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "http://mirrors.tencent.com/npm/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.19",
+      "resolved": "http://mirrors.tencent.com/npm/nanoid/-/nanoid-3.3.19.tgz",
+      "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "http://mirrors.tencent.com/npm/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/postcss": {
+      "version": "8.5.28",
+      "resolved": "http://mirrors.tencent.com/npm/postcss/-/postcss-8.5.28.tgz",
+      "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "dependencies": {
+        "nanoid": "^3.3.18",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.63.1",
+      "resolved": "http://mirrors.tencent.com/npm/rollup/-/rollup-4.63.1.tgz",
+      "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.9"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+        "@rollup/rollup-android-arm-eabi": "4.63.1",
+        "@rollup/rollup-android-arm64": "4.63.1",
+        "@rollup/rollup-darwin-arm64": "4.63.1",
+        "@rollup/rollup-darwin-x64": "4.63.1",
+        "@rollup/rollup-freebsd-arm64": "4.63.1",
+        "@rollup/rollup-freebsd-x64": "4.63.1",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.63.1",
+        "@rollup/rollup-linux-arm-musleabihf": "4.63.1",
+        "@rollup/rollup-linux-arm64-gnu": "4.63.1",
+        "@rollup/rollup-linux-arm64-musl": "4.63.1",
+        "@rollup/rollup-linux-loong64-gnu": "4.63.1",
+        "@rollup/rollup-linux-loong64-musl": "4.63.1",
+        "@rollup/rollup-linux-ppc64-gnu": "4.63.1",
+        "@rollup/rollup-linux-ppc64-musl": "4.63.1",
+        "@rollup/rollup-linux-riscv64-gnu": "4.63.1",
+        "@rollup/rollup-linux-riscv64-musl": "4.63.1",
+        "@rollup/rollup-linux-s390x-gnu": "4.63.1",
+        "@rollup/rollup-linux-x64-gnu": "4.63.1",
+        "@rollup/rollup-linux-x64-musl": "4.63.1",
+        "@rollup/rollup-openbsd-x64": "4.63.1",
+        "@rollup/rollup-openharmony-arm64": "4.63.1",
+        "@rollup/rollup-win32-arm64-msvc": "4.63.1",
+        "@rollup/rollup-win32-ia32-msvc": "4.63.1",
+        "@rollup/rollup-win32-x64-gnu": "4.63.1",
+        "@rollup/rollup-win32-x64-msvc": "4.63.1",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "http://mirrors.tencent.com/npm/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/vite": {
+      "version": "5.4.21",
+      "resolved": "http://mirrors.tencent.com/npm/vite/-/vite-5.4.21.tgz",
+      "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+      "dev": true,
+      "dependencies": {
+        "esbuild": "^0.21.3",
+        "postcss": "^8.4.43",
+        "rollup": "^4.20.0"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.4.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue": {
+      "version": "3.5.42",
+      "resolved": "http://mirrors.tencent.com/npm/vue/-/vue-3.5.42.tgz",
+      "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.42",
+        "@vue/compiler-sfc": "3.5.42",
+        "@vue/runtime-dom": "3.5.42",
+        "@vue/server-renderer": "3.5.42",
+        "@vue/shared": "3.5.42"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-router": {
+      "version": "4.6.4",
+      "resolved": "http://mirrors.tencent.com/npm/vue-router/-/vue-router-4.6.4.tgz",
+      "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    }
+  }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644 (file)
index 0000000..8dea078
--- /dev/null
@@ -0,0 +1,19 @@
+{
+  "name": "studybase-frontend",
+  "version": "1.0.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "vue": "^3.4.0",
+    "vue-router": "^4.3.0"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^5.0.0",
+    "vite": "^5.2.0"
+  }
+}
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
new file mode 100644 (file)
index 0000000..0e92665
--- /dev/null
@@ -0,0 +1,138 @@
+<template>
+  <router-view />
+</template>
+
+<script setup>
+</script>
+
+<style>
+/* 全局样式 — 素净风格,配色/字体沿用原桌面版 */
+* { box-sizing: border-box; margin: 0; padding: 0; }
+html, body, #app { height: 100%; }
+body {
+  font-family: "Microsoft YaHei", "PingFang SC", "Helvetica", sans-serif;
+  background: #F0F2F5;
+  color: #333333;
+  font-size: 14px;
+}
+a { color: #1890FF; text-decoration: none; }
+
+.page-header {
+  background: #4A90E2;
+  height: 56px;
+  display: flex;
+  align-items: center;
+  padding: 0 20px;
+}
+.page-header .title {
+  color: black;
+  font-size: 20px;
+  font-weight: bold;
+  margin-right: auto;
+}
+.page-header .spacer { margin-left: auto; }
+
+.btn {
+  display: inline-block;
+  border: 1px solid #d9d9d9;
+  background: #fff;
+  color: #333;
+  padding: 5px 14px;
+  font-size: 13px;
+  cursor: pointer;
+  border-radius: 3px;
+  font-family: inherit;
+}
+.btn:hover { background: #f5f5f5; }
+.btn:disabled { color: #999; cursor: not-allowed; background: #f0f0f0; }
+.btn.primary { background: #4A90E2; border-color: #4A90E2; color: #fff; }
+.btn.primary:hover { background: #3a7ecb; }
+.btn.green { background: #52C41A; border-color: #52C41A; color: #000; }
+.btn.orange { background: #FFC107; border-color: #FFC107; color: #000; }
+.btn.red { color: #fff; background: #FF4D4F; border-color: #FF4D4F; }
+.btn.small { padding: 2px 8px; font-size: 12px; }
+
+.card {
+  background: #fff;
+  border: 1px solid #E8E8E8;
+  border-radius: 4px;
+  padding: 14px 18px;
+  margin-bottom: 12px;
+}
+
+.filter-panel {
+  background: #fff;
+  border: 1px solid #E8E8E8;
+  border-radius: 4px;
+  padding: 14px 18px;
+  margin: 10px 20px;
+}
+.filter-panel .row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 6px 0; }
+.filter-panel label { font-size: 13px; }
+
+select, input[type=text], textarea {
+  border: 1px solid #d9d9d9;
+  border-radius: 3px;
+  padding: 4px 8px;
+  font-size: 13px;
+  font-family: inherit;
+  background: #fff;
+}
+select:focus, input:focus, textarea:focus { outline: 1px solid #4A90E2; }
+
+table.data-table {
+  width: 100%;
+  border-collapse: collapse;
+  background: #fff;
+}
+table.data-table th {
+  background: #4A90E2;
+  color: black;
+  font-size: 13px;
+  font-weight: bold;
+  padding: 7px 8px;
+  border: 1px solid #3a7ecb;
+  cursor: pointer;
+  white-space: nowrap;
+}
+table.data-table td {
+  padding: 6px 8px;
+  border: 1px solid #E8E8E8;
+  font-size: 13px;
+  vertical-align: top;
+}
+table.data-table tr.clickable { cursor: pointer; }
+table.data-table tr.selected td { background: #B0D0F5; }
+
+.status-ok { color: #2E7D32; }
+.status-off { color: #999; }
+
+.content-area { padding: 0 20px 20px; }
+
+.nav-bar {
+  background: #E0E0E0;
+  height: 46px;
+  display: flex;
+  align-items: center;
+  padding: 0 12px;
+}
+.nav-bar .nav-title { font-size: 16px; font-weight: bold; margin-left: 18px; }
+
+.rich-text { line-height: 1.7; font-family: "SimSun", "Songti SC", serif; font-size: 15px; white-space: pre-wrap; word-break: break-word; }
+.rich-text img { max-width: 100%; vertical-align: middle; margin: 2px 0; }
+
+.log-box {
+  background: #fff;
+  border: 1px solid #d9d9d9;
+  font-family: Consolas, Monaco, monospace;
+  font-size: 12px;
+  padding: 8px;
+  white-space: pre-wrap;
+  overflow: auto;
+  height: 300px;
+}
+
+::-webkit-scrollbar { width: 10px; height: 10px; }
+::-webkit-scrollbar-thumb { background: #c4c4c4; border-radius: 5px; }
+::-webkit-scrollbar-track { background: #f0f0f0; }
+</style>
diff --git a/frontend/src/api.js b/frontend/src/api.js
new file mode 100644 (file)
index 0000000..a71e089
--- /dev/null
@@ -0,0 +1,85 @@
+// API helpers — all calls go to /StudyBase/api/* (nginx proxies to FastAPI)
+const BASE = '/StudyBase/api'
+
+async function handle(res) {
+  if (res.status === 422) {
+    const d = await res.json()
+    const err = new Error(d.detail || '需要确认')
+    err.needConfirm = true
+    throw err
+  }
+  if (!res.ok) {
+    let msg = `HTTP ${res.status}`
+    try {
+      const d = await res.json()
+      msg = d.detail || msg
+    } catch (e) { /* ignore */ }
+    throw new Error(msg)
+  }
+  return res
+}
+
+export async function apiGet(path) {
+  const res = await fetch(BASE + path)
+  return handle(res).then(r => r.json())
+}
+
+export async function apiPost(path, body) {
+  const res = await fetch(BASE + path, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body || {})
+  })
+  return handle(res).then(r => r.json())
+}
+
+export async function apiPut(path, body) {
+  const res = await fetch(BASE + path, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body || {})
+  })
+  return handle(res).then(r => r.json())
+}
+
+export async function apiDelete(path) {
+  const res = await fetch(BASE + path, { method: 'DELETE' })
+  return handle(res).then(r => r.json())
+}
+
+// POST returning a file (triggers browser download)
+export async function apiDownload(path, body, fallbackName) {
+  const res = await fetch(BASE + path, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body || {})
+  })
+  await handle(res)
+  const blob = await res.blob()
+  const cd = res.headers.get('Content-Disposition') || ''
+  let name = fallbackName || 'download.xlsx'
+  const m = cd.match(/filename\*=UTF-8''([^;]+)/)
+  if (m) name = decodeURIComponent(m[1])
+  const url = URL.createObjectURL(blob)
+  const a = document.createElement('a')
+  a.href = url
+  a.download = name
+  document.body.appendChild(a)
+  a.click()
+  a.remove()
+  URL.revokeObjectURL(url)
+}
+
+// simple confirm/prompt/toast shims replacing tkinter messagebox/simpledialog
+export function tkConfirm(message) {
+  return window.confirm(message)
+}
+
+export function tkPrompt(title, prompt, initial) {
+  const v = window.prompt((title ? title + '\n' : '') + prompt, initial || '')
+  return v === null ? null : v
+}
+
+export function tkAlert(message) {
+  window.alert(message)
+}
diff --git a/frontend/src/components/RichContent.vue b/frontend/src/components/RichContent.vue
new file mode 100644 (file)
index 0000000..2c0187b
--- /dev/null
@@ -0,0 +1,63 @@
+<template>
+  <div class="rich-text">
+    <template v-for="(part, i) in parts" :key="i">
+      <img v-if="part.type === 'img' && !part.failed"
+           :src="part.src"
+           :style="{ width: part.width ? part.width + 'px' : undefined, maxWidth: maxWidth + 'px' }"
+           @error="part.failed = true" />
+      <span v-if="part.type === 'img' && part.failed" class="img-error">[图片加载错误]</span>
+      <span v-if="part.type === 'text'">{{ part.text }}</span>
+    </template>
+  </div>
+</template>
+
+<script setup>
+// 渲染题目内容:解析 ![]( path?w=EMU&h=EMU ) 图片标记(与原版 insert_text 一致)
+import { computed, reactive } from 'vue'
+
+const props = defineProps({
+  content: { type: String, default: '' },
+  maxWidth: { type: Number, default: 800 }
+})
+
+function parseParts(content) {
+  const s = String(content || '')
+  const parts = []
+  const re = /!\[\]\s*\(\s*(.*?)\s*\)/g
+  let cursor = 0
+  let m
+  while ((m = re.exec(s)) !== null) {
+    if (m.index > cursor) parts.push({ type: 'text', text: s.slice(cursor, m.index) })
+    const info = m[1].trim()
+    let relPath = info
+    let wEmu = null
+    if (info.includes('?')) {
+      const [p, params] = info.split('?', 2)
+      relPath = p
+      const wM = params.match(/w=(\d+)/)
+      if (wM) wEmu = parseInt(wM[1])
+    }
+    relPath = relPath.replace(/\\/g, '/').replace(/ /g, '')
+    let widthPx = null
+    if (wEmu) {
+      widthPx = Math.max(20, Math.round(wEmu / 9525))
+      if (widthPx > props.maxWidth) widthPx = props.maxWidth
+    }
+    parts.push(reactive({
+      type: 'img',
+      src: '/StudyBase/' + encodeURI(relPath),
+      width: widthPx,
+      failed: false
+    }))
+    cursor = m.index + m[0].length
+  }
+  if (cursor < s.length) parts.push({ type: 'text', text: s.slice(cursor) })
+  return parts
+}
+
+const parts = computed(() => parseParts(props.content))
+</script>
+
+<style scoped>
+.img-error { color: #FF4D4F; font-size: 13px; font-family: "Microsoft YaHei", sans-serif; }
+</style>
diff --git a/frontend/src/main.js b/frontend/src/main.js
new file mode 100644 (file)
index 0000000..b4481bf
--- /dev/null
@@ -0,0 +1,33 @@
+import { createApp } from 'vue'
+import { createRouter, createWebHistory } from 'vue-router'
+import App from './App.vue'
+
+const routes = [
+  { path: '/', name: 'home', component: () => import('./views/Home.vue') },
+  { path: '/subject-portal', name: 'subjectPortal', component: () => import('./views/SubjectPortal.vue') },
+  { path: '/questions', name: 'questionList', component: () => import('./views/QuestionList.vue') },
+  { path: '/questions/add', name: 'questionAdd', component: () => import('./views/QuestionAdd.vue') },
+  { path: '/questions/import', name: 'fileImport', component: () => import('./views/FileImport.vue') },
+  { path: '/questions/import/preview', name: 'importPreview', component: () => import('./views/ImportPreview.vue') },
+  { path: '/questions/:id(\\d+)', name: 'questionDetail', component: () => import('./views/QuestionDetail.vue') },
+  { path: '/papers', name: 'papersList', component: () => import('./views/PapersList.vue') },
+  { path: '/papers/create-by-id', name: 'papersCreateById', component: () => import('./views/PapersCreateById.vue') },
+  { path: '/papers/preview', name: 'papersPreview', component: () => import('./views/PapersPreview.vue') },
+  { path: '/papers/:id(\\d+)/detail', name: 'papersDetail', component: () => import('./views/PapersDetail.vue') },
+  { path: '/papers/:id(\\d+)/result', name: 'papersResult', component: () => import('./views/PapersResult.vue') },
+  { path: '/analysis/chapters', name: 'chapters', component: () => import('./views/AnalysisChapters.vue') },
+  { path: '/analysis/knowledge', name: 'knowledge', component: () => import('./views/AnalysisKnowledge.vue') },
+  { path: '/analysis/pending', name: 'pending', component: () => import('./views/AnalysisPending.vue') },
+  { path: '/settings', name: 'settings', component: () => import('./views/Settings.vue') },
+  { path: '/settings/subjects', name: 'settingsSubjects', component: () => import('./views/SettingsSubjects.vue') },
+  { path: '/settings/error-causes', name: 'settingsErrorCauses', component: () => import('./views/SettingsErrorCauses.vue') },
+  { path: '/settings/question-statuses', name: 'settingsStatuses', component: () => import('./views/SettingsStatuses.vue') },
+  { path: '/settings/default-params', name: 'settingsDefaults', component: () => import('./views/SettingsDefaultParams.vue') },
+]
+
+const router = createRouter({
+  history: createWebHistory('/StudyBase/'),
+  routes
+})
+
+createApp(App).use(router).mount('#app')
diff --git a/frontend/src/views/AnalysisChapters.vue b/frontend/src/views/AnalysisChapters.vue
new file mode 100644 (file)
index 0000000..4dd60e7
--- /dev/null
@@ -0,0 +1,132 @@
+<template>
+  <div>
+    <div class="page-header">
+      <span class="title">章节分析</span>
+      <button class="btn" style="margin-left:16px;" @click="$router.push('/subject-portal')">返回科目首页</button>
+      <button class="btn orange" @click="exportExcel">📥 导出Excel</button>
+    </div>
+
+    <div class="filter-panel">
+      <div class="row">
+        <label>科目:</label>
+        <select v-model="subjectVar" @change="loadData">
+          <option>全部</option>
+          <option v-for="s in subjects" :key="s" :value="s">{{ s }}</option>
+        </select>
+        <label style="margin-left:10px;">已正确题目数:</label>
+        <input type="text" v-model="correctCountVar" style="width:70px;" />
+        <label style="margin-left:10px;">尚未正确题目数:</label>
+        <input type="text" v-model="notCorrectCountVar" style="width:70px;" />
+      </div>
+      <div class="row">
+        <label>章节名称:</label>
+        <input type="text" v-model="chapterDescVar" style="width:340px;" />
+        <button class="btn primary" style="margin-left:auto;" @click="loadData">🔍 开始搜索</button>
+      </div>
+    </div>
+
+    <div class="content-area">
+      <div class="card" style="padding:0;overflow:auto;">
+        <table class="data-table">
+          <thead>
+            <tr>
+              <th v-for="col in columns" :key="col.key" @click="sortColumn(col.key)">
+                {{ col.label }}{{ sortIndicator(col.key) }}
+              </th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="row in rows" :key="row.chapter_id">
+              <td class="center">{{ row.chapter_id }}</td>
+              <td class="center">{{ row.subject }}</td>
+              <td class="center">{{ row.volume_name }}</td>
+              <td class="link" @click="gotoKnowledge(row.chapter_id)">{{ row.chapter_desc }}</td>
+              <td class="center link" @click="gotoQuestions(row.chapter_id, null)">{{ row.total_wrong_questions }}</td>
+              <td class="center link" @click="gotoQuestions(row.chapter_id, '>=1')">{{ row.correct_questions }}</td>
+              <td class="center link" @click="gotoQuestions(row.chapter_id, '=0')">{{ row.not_correct_questions }}</td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { apiGet, apiPost, apiDownload, tkAlert } from '../api'
+
+const router = useRouter()
+const subjects = ref([])
+const subjectVar = ref('全部')
+const correctCountVar = ref('')
+const notCorrectCountVar = ref('')
+const chapterDescVar = ref('')
+const rows = ref([])
+const sortColumnKey = ref('not_correct_questions')
+const sortDirection = ref('DESC')
+
+const columns = [
+  { key: 'id', label: 'ID' },
+  { key: 'subject', label: '科目' },
+  { key: 'volume_name', label: '册' },
+  { key: 'chapter_desc', label: '章节' },
+  { key: 'total_wrong_questions', label: '错题数量' },
+  { key: 'correct_questions', label: '已正确题目数' },
+  { key: 'not_correct_questions', label: '尚未正确题目数' },
+]
+
+function sortIndicator(key) {
+  if (key !== sortColumnKey.value) return ''
+  return sortDirection.value === 'ASC' ? ' ▲' : ' ▼'
+}
+
+function sortColumn(key) {
+  if (sortColumnKey.value === key) sortDirection.value = sortDirection.value === 'ASC' ? 'DESC' : 'ASC'
+  else { sortColumnKey.value = key; sortDirection.value = 'ASC' }
+  loadData()
+}
+
+function requestBody() {
+  return {
+    subject: subjectVar.value,
+    correct_count: correctCountVar.value,
+    not_correct_count: notCorrectCountVar.value,
+    chapter_desc: chapterDescVar.value,
+    sort_column: sortColumnKey.value,
+    sort_direction: sortDirection.value,
+  }
+}
+
+async function loadData() {
+  const data = await apiPost('/analysis/chapters', requestBody())
+  rows.value = data.rows
+}
+
+function gotoKnowledge(chapterId) {
+  router.push({ path: '/analysis/knowledge', query: { chapter_id: chapterId } })
+}
+
+function gotoQuestions(chapterId, rightTimes) {
+  const query = { chapter_id: chapterId }
+  if (rightTimes) query.right_times = rightTimes
+  router.push({ path: '/questions', query })
+}
+
+async function exportExcel() {
+  if (!rows.value.length) return
+  await apiDownload('/analysis/chapters/export-excel', requestBody())
+}
+
+onMounted(async () => {
+  const data = await apiGet('/meta/subjects')
+  subjects.value = data.subjects
+  await loadData()
+})
+</script>
+
+<style scoped>
+.center { text-align: center; }
+.link { color: #1890FF; cursor: pointer; }
+</style>
diff --git a/frontend/src/views/AnalysisKnowledge.vue b/frontend/src/views/AnalysisKnowledge.vue
new file mode 100644 (file)
index 0000000..3467539
--- /dev/null
@@ -0,0 +1,141 @@
+<template>
+  <div>
+    <div class="page-header">
+      <span class="title">科目知识列表</span>
+      <button class="btn" style="margin-left:16px;" @click="$router.push('/subject-portal')">返回科目首页</button>
+      <button class="btn orange" @click="exportExcel">📥 导出Excel</button>
+    </div>
+
+    <div class="filter-panel">
+      <div class="row">
+        <label>科目:</label>
+        <select v-model="subjectVar" @change="loadData">
+          <option>全部</option>
+          <option v-for="s in subjects" :key="s" :value="s">{{ s }}</option>
+        </select>
+        <label style="margin-left:10px;">已正确题目数:</label>
+        <input type="text" v-model="correctCountVar" style="width:70px;" />
+        <label style="margin-left:10px;">尚未正确题目数:</label>
+        <input type="text" v-model="notCorrectCountVar" style="width:70px;" />
+      </div>
+      <div class="row">
+        <label>知识点:</label>
+        <input type="text" v-model="kpContentVar" style="width:340px;" />
+        <button class="btn primary" style="margin-left:auto;" @click="loadData">🔍 开始搜索</button>
+      </div>
+      <div v-if="chapterIdFilter" class="row" style="color:#1890FF;font-size:13px;">
+        当前按章节过滤:章节ID {{ chapterIdFilter }}
+        <button class="btn small" @click="clearChapterFilter">清除章节过滤</button>
+      </div>
+    </div>
+
+    <div class="content-area">
+      <div class="card" style="padding:0;overflow:auto;">
+        <table class="data-table">
+          <thead>
+            <tr>
+              <th v-for="col in columns" :key="col.key" @click="sortColumn(col.key)">
+                {{ col.label }}{{ sortIndicator(col.key) }}
+              </th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="row in rows" :key="row.kp_id">
+              <td class="center">{{ row.kp_id }}</td>
+              <td class="center">{{ row.subject }}</td>
+              <td>{{ row.chapter_info }}</td>
+              <td>{{ row.knowledge_content }}</td>
+              <td class="center link" @click="gotoQuestions(row.kp_id, null)">{{ row.total_questions }}</td>
+              <td class="center link" @click="gotoQuestions(row.kp_id, '>=1')">{{ row.correct_questions }}</td>
+              <td class="center link" @click="gotoQuestions(row.kp_id, '=0')">{{ row.not_correct_questions }}</td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { apiGet, apiPost, apiDownload } from '../api'
+
+const route = useRoute()
+const router = useRouter()
+const subjects = ref([])
+const subjectVar = ref('全部')
+const correctCountVar = ref('')
+const notCorrectCountVar = ref('')
+const kpContentVar = ref('')
+const rows = ref([])
+const chapterIdFilter = ref(null)
+const sortColumnKey = ref('not_correct_questions')
+const sortDirection = ref('DESC')
+
+const columns = [
+  { key: 'id', label: 'ID' },
+  { key: 'subject', label: '科目' },
+  { key: 'chapter_info', label: '章节' },
+  { key: 'knowledge_content', label: '知识点' },
+  { key: 'total_questions', label: '试题数量' },
+  { key: 'correct_questions', label: '已正确题目数' },
+  { key: 'not_correct_questions', label: '尚未正确题目数' },
+]
+
+function sortIndicator(key) {
+  if (key !== sortColumnKey.value) return ''
+  return sortDirection.value === 'ASC' ? ' ▲' : ' ▼'
+}
+
+function sortColumn(key) {
+  if (sortColumnKey.value === key) sortDirection.value = sortDirection.value === 'ASC' ? 'DESC' : 'ASC'
+  else { sortColumnKey.value = key; sortDirection.value = 'ASC' }
+  loadData()
+}
+
+function requestBody() {
+  return {
+    subject: subjectVar.value,
+    correct_count: correctCountVar.value,
+    not_correct_count: notCorrectCountVar.value,
+    kp_content: kpContentVar.value,
+    chapter_id_filter: chapterIdFilter.value,
+    sort_column: sortColumnKey.value,
+    sort_direction: sortDirection.value,
+  }
+}
+
+async function loadData() {
+  const data = await apiPost('/analysis/knowledge', requestBody())
+  rows.value = data.rows
+}
+
+function clearChapterFilter() {
+  chapterIdFilter.value = null
+  loadData()
+}
+
+function gotoQuestions(kpId, rightTimes) {
+  const query = { kp_id: kpId }
+  if (rightTimes) query.right_times = rightTimes
+  router.push({ path: '/questions', query })
+}
+
+async function exportExcel() {
+  if (!rows.value.length) return
+  await apiDownload('/analysis/knowledge/export-excel', requestBody())
+}
+
+onMounted(async () => {
+  if (route.query.chapter_id) chapterIdFilter.value = parseInt(route.query.chapter_id)
+  const data = await apiGet('/meta/subjects')
+  subjects.value = data.subjects
+  await loadData()
+})
+</script>
+
+<style scoped>
+.center { text-align: center; }
+.link { color: #1890FF; cursor: pointer; }
+</style>
diff --git a/frontend/src/views/AnalysisPending.vue b/frontend/src/views/AnalysisPending.vue
new file mode 100644 (file)
index 0000000..69d098e
--- /dev/null
@@ -0,0 +1,114 @@
+<template>
+  <div>
+    <div class="page-header">
+      <span class="title">遗留分析汇总</span>
+      <button class="btn" style="margin-left:16px;" @click="$router.push('/subject-portal')">返回科目首页</button>
+      <button class="btn orange" @click="exportExcel">📥 导出Excel</button>
+    </div>
+
+    <div class="filter-panel">
+      <label>选择学期:</label>
+      <select v-model="currentTerm" @change="refreshStats">
+        <option v-for="t in terms" :key="t" :value="t">{{ t }}</option>
+      </select>
+      <label style="margin-left:25px;">统计范围:</label>
+      <label><input type="radio" value="legacy" v-model="mode" @change="refreshStats" /> 遗留错题</label>
+      <label><input type="radio" value="all" v-model="mode" @change="refreshStats" /> 全部错题</label>
+    </div>
+
+    <div class="content-area">
+      <div class="card" style="overflow:auto;padding:0;" v-if="data">
+        <table class="data-table" style="min-width:760px;">
+          <thead>
+            <tr>
+              <th v-for="h in headers" :key="h">{{ h }}</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="r in data.stage_rows" :key="r.label">
+              <td class="stage-cell">{{ r.label }}</td>
+              <td v-for="(c, i) in r.cells" :key="i" class="link" @click="jumpTo(c.sub_id, c.st_id, null)">{{ c.count }}</td>
+              <td class="link bold" @click="jumpTo(null, r.cells[0] ? r.cells[0].st_id : null, null)">{{ r.sum }}</td>
+            </tr>
+            <tr>
+              <td class="total-cell">{{ data.total_row.label }}</td>
+              <td v-for="(c, i) in data.total_row.cells" :key="i" class="link total-cell bold" @click="jumpTo(c.sub_id, null, null)">{{ c.count }}</td>
+              <td class="link total-cell bold" @click="jumpTo(null, null, null)">{{ data.total_row.sum }}</td>
+            </tr>
+            <tr v-for="r in data.paper_rows" :key="r.label">
+              <td class="paper-cell">{{ r.label }}</td>
+              <td v-for="(c, i) in r.cells" :key="i" class="link" @click="jumpTo(c.sub_id, null, r.paper_count)">{{ c.count }}</td>
+              <td class="link bold" @click="jumpTo(null, null, r.paper_count)">{{ r.sum }}</td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { apiGet } from '../api'
+
+const router = useRouter()
+const terms = ref([])
+const currentTerm = ref('')
+const mode = ref('legacy')
+const data = ref(null)
+
+const headers = computed(() => {
+  if (!data.value) return []
+  return ['阶段', ...data.value.subjects.map(s => s.name), '总数']
+})
+
+async function refreshStats() {
+  const params = new URLSearchParams({ term: currentTerm.value, mode: mode.value })
+  data.value = await apiGet('/analysis/pending?' + params.toString())
+}
+
+// PendingAnalysisView.jump_to_list:跳转题目列表并带上 initial_filters
+function jumpTo(subId, stId, paperCountRange) {
+  const query = {
+    question_nature: '错题',
+    term: currentTerm.value,
+    status: '全部',
+    archive_status: '全部',
+  }
+  const sub = data.value.subjects.find(s => s.id === subId)
+  if (sub) query.subject = sub.name
+  const st = data.value.stages.find(s => s.id === stId)
+  if (st) query.stage = st.name
+  if (paperCountRange !== null && paperCountRange !== undefined) query.paper_count = paperCountRange
+  if (mode.value === 'legacy') query.right_times = '=0'
+  router.push({ path: '/questions', query })
+}
+
+async function exportExcel() {
+  if (!data.value) return
+  const params = new URLSearchParams({ term: currentTerm.value, mode: mode.value })
+  const url = '/StudyBase/api/analysis/pending/export-excel?' + params.toString()
+  const a = document.createElement('a')
+  a.href = url
+  a.download = ''
+  document.body.appendChild(a)
+  a.click()
+  a.remove()
+}
+
+onMounted(async () => {
+  const d = await apiGet('/analysis/pending?mode=legacy')
+  terms.value = d.terms
+  currentTerm.value = d.current_term
+  data.value = d
+})
+</script>
+
+<style scoped>
+.link { color: #1890FF; cursor: pointer; text-align: center; }
+.bold { font-weight: bold; }
+.stage-cell { background: #F9F0FF; }
+.total-cell { background: #FFF7E6; font-weight: bold; }
+.paper-cell { background: #F6FFED; }
+</style>
diff --git a/frontend/src/views/FileImport.vue b/frontend/src/views/FileImport.vue
new file mode 100644 (file)
index 0000000..2fcbd2d
--- /dev/null
@@ -0,0 +1,417 @@
+<template>
+  <div>
+    <div class="page-header">
+      <span class="title">文件导入题目</span>
+    </div>
+
+    <div class="content-area">
+      <div class="card">
+        <div style="font-weight:bold;margin-bottom:10px;">导入参数设置</div>
+        <div style="display:grid;grid-template-columns:110px 240px 110px 240px;gap:10px 14px;align-items:center;">
+          <label>所属科目:</label>
+          <select v-model="subject"><option v-for="o in opts.subject.options" :key="o" :value="o">{{ o }}</option></select>
+          <label>学期:</label>
+          <select v-model="term"><option v-for="o in opts.term.options" :key="o" :value="o">{{ o }}</option></select>
+
+          <label>题目来源:</label>
+          <select v-model="source"><option v-for="o in opts.source.options" :key="o" :value="o">{{ o }}</option></select>
+          <label>阶段:</label>
+          <select v-model="stage"><option v-for="o in opts.stage.options" :key="o" :value="o">{{ o }}</option></select>
+
+          <label>题目类型:</label>
+          <select v-model="qType"><option v-for="o in opts.type.options" :key="o" :value="o">{{ o }}</option></select>
+          <label>题目出处:</label>
+          <select v-model="origin"><option v-for="o in opts.origin.options" :key="o" :value="o">{{ o }}</option></select>
+        </div>
+      </div>
+
+      <div class="card" style="display:flex;gap:18px;align-items:center;flex-wrap:wrap;">
+        <button class="btn" :disabled="parsing" style="font-weight:bold;" @click="startImport">
+          {{ parsing ? '解析中...' : '第一步:选择文件并解析' }}
+        </button>
+        <button class="btn orange" :disabled="parsing" style="font-weight:bold;" @click="startManualInput">
+          {{ manualOpen ? '手动添加中...' : '第一步:手动添加试题' }}
+        </button>
+        <button class="btn" :disabled="!lastParsedData" style="font-weight:bold;"
+                :style="!lastParsedData ? 'color:#999;' : ''" @click="goToConfirmPage">
+          第二步:预览并确定导入
+        </button>
+        <button class="btn" style="margin-left:auto;" @click="$router.push('/questions/add')">返回</button>
+      </div>
+
+      <div style="font-size:13px;margin:6px 2px;">解析进度与JSON预览:</div>
+      <div class="log-box" ref="logBox">{{ logText }}</div>
+    </div>
+
+    <!-- 手动添加试题 弹窗 -->
+    <div v-if="manualOpen" class="modal-mask">
+      <div class="modal">
+        <div class="modal-header">手动添加试题</div>
+        <div class="modal-body">
+          <div class="form-row">
+            <label>题目分类(必选):</label>
+            <select v-model="mCategory">
+              <option value="" disabled>请选择</option>
+              <option>客观题</option>
+              <option>主观题</option>
+            </select>
+          </div>
+          <div class="form-row">
+            <label>题目类型(必选):</label>
+            <select v-model="mType"><option>错题</option><option>推题</option></select>
+          </div>
+          <div class="form-row">
+            <label>题目知识点(必填):</label>
+            <input type="text" v-model="mKp" style="width:320px;" />
+          </div>
+          <div class="form-row">
+            <label>答题时间(选填,例:2026-03-16):</label>
+            <input type="text" v-model="mAnswerTime" style="width:150px;" />
+            <button class="btn small" @click="fillToday">填入今天</button>
+          </div>
+
+          <div class="section-label">题干内容(必填,支持插入图片;可使用 Ctrl+V 粘贴)</div>
+          <div class="toolbar"><button class="btn small" @click="pickImage(mStemRef)">从本地选择图片</button></div>
+          <textarea ref="mStemRef" v-model="mStem" rows="6" style="width:100%;"
+                    @paste="onPaste($event, 'stem')"></textarea>
+
+          <div class="section-label">答案解析(必填,支持插入图片;可使用 Ctrl+V 粘贴)</div>
+          <div class="toolbar"><button class="btn small" @click="pickImage(mAnalysisRef)">从本地选择图片</button></div>
+          <textarea ref="mAnalysisRef" v-model="mAnalysis" rows="5" style="width:100%;"
+                    @paste="onPaste($event, 'analysis')"></textarea>
+        </div>
+        <div class="modal-footer">
+          <button class="btn" @click="addAndContinue">继续添加</button>
+          <button class="btn primary" @click="closeManual">确定返回</button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, nextTick, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { apiGet, apiPost, tkAlert, tkConfirm } from '../api'
+
+const router = useRouter()
+
+const opts = ref({
+  subject: { options: [], default: '' },
+  source: { options: [], default: '' },
+  term: { options: [], default: '' },
+  stage: { options: [], default: '' },
+  origin: { options: [], default: '' },
+  type: { options: ['错题', '推题', '补充提高'], default: '错题' },
+})
+const subject = ref('')
+const source = ref('')
+const qType = ref('错题')
+const term = ref('')
+const stage = ref('')
+const origin = ref('')
+
+const logText = ref('')
+const logBox = ref(null)
+const parsing = ref(false)
+const lastParsedData = ref(null)
+
+// 手动添加状态
+const manualOpen = ref(false)
+const mCategory = ref('')
+const mType = ref('错题')
+const mKp = ref('')
+const mAnswerTime = ref('')
+const mStem = ref('')
+const mAnalysis = ref('')
+const mStemRef = ref(null)
+const mAnalysisRef = ref(null)
+const addedCount = ref(0)
+const sessionTs = ref('')
+
+const ZUJUANWANG_NOTICE = `智学网文件解析请注意以下格式:
+!!请确定组卷网下载文件包括了【知识点】信息!!
+1. 题目开始格式:
+   【错题】/【推题】【客观题】/【主观题】[题号]
+    
+    例如:
+   【错题】【客观题】1.豌豆(雌雄同花)和玉米(雌雄同株异花)都是良好的遗传学材料。利用这两种材料进行遗传学实验时,操作正确的是(  )
+
+2. 答案开始格式:
+   【答案】[题号]
+   
+   例如:
+   【答案】2.B
+   【知识点】孟德尔一对相对性状的杂交实验
+   【分析】孟德尔一对相对性状的杂交试验中,实现3:1的分离比必须同时满足的条件是:F1形成的配子数目相等且生活力相同,雌、雄配子结合的机会相等;F2不同的基因型的个体的存活率相等;等位基因间的显隐性关系是完全的;观察的子代样本数目足够多。
+
+请点击“确定”开始解析。
+`
+
+function log(msg) {
+  logText.value += `[${new Date().toTimeString().slice(0, 8)}] ${msg}\n`
+  nextTick(() => { if (logBox.value) logBox.value.scrollTop = logBox.value.scrollHeight })
+}
+
+function currentMeta() {
+  return {
+    subject: subject.value, source: source.value, type: qType.value,
+    term: term.value, stage: stage.value, origin: origin.value,
+  }
+}
+
+function startImport() {
+  const meta = currentMeta()
+  if (!Object.values(meta).every(Boolean)) {
+    tkAlert('请确保所有导入参数都已选择!')
+    return
+  }
+  if (source.value === '组卷网') {
+    if (!tkConfirm(ZUJUANWANG_NOTICE)) return
+  }
+  const input = document.createElement('input')
+  input.type = 'file'
+  input.accept = '.docx'
+  input.onchange = async () => {
+    const file = input.files[0]
+    if (!file) return
+    logText.value = ''
+    parsing.value = true
+    lastParsedData.value = null
+    const fd = new FormData()
+    fd.append('file', file)
+    for (const [k, v] of Object.entries(meta)) fd.append(k, v)
+    try {
+      const res = await fetch('/StudyBase/api/import/parse', { method: 'POST', body: fd })
+      const data = await res.json()
+      for (const line of data.log || []) log(line.replace(/^\[\d{2}:\d{2}:\d{2}\]\s?/, ''))
+      if (data.error) {
+        lastParsedData.value = null
+      } else if (data.questions) {
+        lastParsedData.value = data.questions
+      } else {
+        lastParsedData.value = null
+      }
+    } catch (e) {
+      log(`解析过程中发生未预期错误: ${e.message}`)
+      lastParsedData.value = null
+    } finally {
+      parsing.value = false
+    }
+  }
+  input.click()
+}
+
+// ---------- 手动添加 ----------
+
+function startManualInput() {
+  logText.value = ''
+  log('正在手动添加试题...')
+  manualOpen.value = true
+  mCategory.value = ''
+  mType.value = '错题'
+  mKp.value = ''
+  mAnswerTime.value = ''
+  mStem.value = ''
+  mAnalysis.value = ''
+  addedCount.value = 0
+  sessionTs.value = new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14)
+}
+
+function fillToday() {
+  const d = new Date()
+  const p = n => String(n).padStart(2, '0')
+  mAnswerTime.value = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
+}
+
+async function addAndContinue() {
+  if (!mCategory.value) { tkAlert('请先选择题目分类(客观题或主观题)。'); return }
+  if (!mKp.value.trim()) { tkAlert('题目知识点为必填项。'); return }
+  if (!mStem.value.trim()) { tkAlert('题干内容不能为空。'); return }
+  if (!mAnalysis.value.trim()) { tkAlert('答案解析不能为空。'); return }
+
+  const questionNo = addedCount.value + 1
+  try {
+    const q = await apiPost('/import/manual-add', {
+      category: mCategory.value,
+      q_type: mType.value,
+      kp_text: mKp.value.trim(),
+      answer_time: mAnswerTime.value,
+      stem_raw: mStem.value,
+      analysis_raw: mAnalysis.value,
+      question_no: questionNo,
+      session_timestamp: sessionTs.value,
+      meta: currentMeta(),
+    })
+    if (!manualQuestions) manualQuestions = []
+    manualQuestions.push(q)
+    addedCount.value = questionNo
+    log(`添加一条试题。当前已添加 ${addedCount.value} 条。`)
+    tkAlert('当前试题已保存,可以继续添加下一题。')
+    mKp.value = ''
+    mAnswerTime.value = ''
+    mStem.value = ''
+    mAnalysis.value = ''
+  } catch (e) {
+    if (e.needConfirm) {
+      if (tkConfirm('答题时间格式不是标准日期(YYYY-MM-DD),仍要按原样保存吗?')) {
+        try {
+          const q = await apiPost('/import/manual-add', {
+            category: mCategory.value, q_type: mType.value, kp_text: mKp.value.trim(),
+            answer_time: mAnswerTime.value,
+            stem_raw: mStem.value, analysis_raw: mAnalysis.value,
+            question_no: questionNo, session_timestamp: sessionTs.value, meta: currentMeta(),
+            force: true,
+          })
+          manualQuestions.push(q)
+          addedCount.value = questionNo
+          log(`添加一条试题。当前已添加 ${addedCount.value} 条。`)
+          tkAlert('当前试题已保存,可以继续添加下一题。')
+          mKp.value = ''; mAnswerTime.value = ''; mStem.value = ''; mAnalysis.value = ''
+        } catch (e2) { tkAlert(e2.message) }
+      }
+    } else {
+      tkAlert(e.message)
+    }
+  }
+}
+
+let manualQuestions = []
+
+function closeManual() {
+  log('手动添加试题窗口已关闭。')
+  manualOpen.value = false
+  if (manualQuestions && manualQuestions.length) {
+    lastParsedData.value = manualQuestions
+    log(`手动添加已完成。共添加 ${manualQuestions.length} 道题目。`)
+    log('\n--- 手动添加题目预览 ---')
+    log(JSON.stringify(manualQuestions, null, 4))
+    log('--- 预览结束 ---\n')
+  } else {
+    lastParsedData.value = null
+    log('手动添加已取消或未添加任何题目。')
+  }
+  manualQuestions = []
+}
+
+// ---------- 图片插入([[IMG:...]] 占位符) ----------
+
+async function uploadImageFile(file) {
+  const fd = new FormData()
+  fd.append('file', file, file.name || 'pasted.png')
+  const res = await fetch('/StudyBase/api/import/upload-image', { method: 'POST', body: fd })
+  if (!res.ok) {
+    let msg = '上传失败'
+    try { msg = (await res.json()).detail || msg } catch (e) { }
+    throw new Error(msg)
+  }
+  const data = await res.json()
+  return data.path
+}
+
+function insertToken(textarea, token) {
+  if (textarea && textarea.selectionStart != null) {
+    const s = textarea.selectionStart
+    const v = textarea.value
+    textarea.value = v.slice(0, s) + token + v.slice(textarea.selectionEnd)
+    return textarea.value
+  }
+  return null
+}
+
+function pickImage(refObj) {
+  const input = document.createElement('input')
+  input.type = 'file'
+  input.accept = 'image/png,image/jpeg,image/bmp,image/gif'
+  input.onchange = async () => {
+    const file = input.files[0]
+    if (!file) return
+    try {
+      const path = await uploadImageFile(file)
+      const token = `[[IMG:${path}]]`
+      const ta = refObj
+      if (ta) {
+        insertToken(ta, token)
+        // 同步回对应 model(通过事件)
+        ta.dispatchEvent(new Event('input'))
+      }
+      log(`已选取图片: ${path}`)
+    } catch (e) {
+      tkAlert(e.message)
+    }
+  }
+  input.click()
+}
+
+async function onPaste(ev, which) {
+  const items = ev.clipboardData && ev.clipboardData.items
+  if (!items) return
+  for (const item of items) {
+    if (item.type && item.type.startsWith('image/')) {
+      ev.preventDefault()
+      const file = item.getAsFile()
+      try {
+        const path = await uploadImageFile(file)
+        const token = `[[IMG:${path}]]`
+        const ta = which === 'stem' ? mStemRef.value : mAnalysisRef.value
+        if (ta) {
+          insertToken(ta, token)
+          ta.dispatchEvent(new Event('input'))
+        }
+        log(`从剪贴板插入图片: ${path}`)
+      } catch (e) {
+        log(`保存剪贴板图片失败: ${e.message}`)
+      }
+      return
+    }
+  }
+}
+
+// ---------- 第二步 ----------
+
+function goToConfirmPage() {
+  if (lastParsedData.value) {
+    sessionStorage.setItem('sb_import_data', JSON.stringify(lastParsedData.value))
+    sessionStorage.setItem('sb_import_meta', JSON.stringify({
+      ...currentMeta(),
+      import_time: new Date().toLocaleString('sv-PR'),
+    }))
+    router.push('/questions/import/preview')
+  } else {
+    tkAlert('请先解析文件或选择手动添加,没有可预览的数据。')
+  }
+}
+
+onMounted(async () => {
+  opts.value = await apiGet('/import/options')
+  subject.value = opts.value.subject.default
+  source.value = opts.value.source.default
+  term.value = opts.value.term.default
+  stage.value = opts.value.stage.default
+  origin.value = opts.value.origin.default
+  qType.value = opts.value.type.default
+})
+</script>
+
+<style scoped>
+.modal-mask {
+  position: fixed; inset: 0; background: rgba(0,0,0,0.35);
+  display: flex; align-items: center; justify-content: center; z-index: 100;
+}
+.modal {
+  background: #F0F2F5; width: 760px; max-height: 90vh; overflow: auto;
+  border-radius: 6px; box-shadow: 0 4px 20px rgba(0,0,0,0.25);
+}
+.modal-header {
+  background: #4A90E2; color: black; font-size: 17px; font-weight: bold;
+  padding: 10px 16px;
+}
+.modal-body { padding: 14px 18px; }
+.modal-footer {
+  display: flex; justify-content: space-between; padding: 12px 40px 18px;
+}
+.form-row { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
+.form-row label { white-space: nowrap; font-size: 13px; }
+.section-label { font-weight: bold; font-size: 13px; margin: 12px 0 4px; }
+.toolbar { margin-bottom: 4px; }
+</style>
diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue
new file mode 100644 (file)
index 0000000..8313138
--- /dev/null
@@ -0,0 +1,31 @@
+<template>
+  <div class="home">
+    <div class="header">
+      <span class="title">错题智能整理系统</span>
+    </div>
+    <div class="btn-frame">
+      <button class="main-btn" style="color:#1A73E8" @click="$router.push('/subject-portal')">① 科目面板</button>
+      <button class="main-btn" style="color:#28A745" @click="$router.push('/questions')">② 题目管理</button>
+      <button class="main-btn" style="color:#E67E22" @click="$router.push('/papers')">③ 试卷管理</button>
+      <button class="main-btn" style="color:#6C757D" @click="$router.push('/settings')">④ 系统设定</button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+</script>
+
+<style scoped>
+.home { height: 100%; display: flex; flex-direction: column; background: #F0F0F0; }
+.header {
+  background: #4A90E2; height: 100px; display: flex; align-items: center; justify-content: center;
+}
+.header .title { color: #fff; font-size: 28px; font-weight: bold; }
+.btn-frame { flex: 1; display: flex; align-items: center; justify-content: center; gap: 30px; flex-wrap: wrap; }
+.main-btn {
+  width: 220px; height: 62px; font-size: 16px; font-weight: bold;
+  background: #fff; border: none; cursor: pointer; border-radius: 4px;
+  box-shadow: 0 1px 3px rgba(0,0,0,0.08); font-family: inherit;
+}
+.main-btn:hover { background: #F5F8FD; }
+</style>
diff --git a/frontend/src/views/ImportPreview.vue b/frontend/src/views/ImportPreview.vue
new file mode 100644 (file)
index 0000000..e6de5c7
--- /dev/null
@@ -0,0 +1,123 @@
+<template>
+  <div>
+    <div class="page-header">
+      <span class="title">导入题目预览与确认</span>
+    </div>
+
+    <div class="content-area" style="padding-bottom:90px;">
+      <div v-if="!data.length" class="card" style="text-align:center;padding:50px;">暂无题目数据</div>
+
+      <div v-for="(item, idx) in data" :key="idx" class="card">
+        <div style="font-weight:bold;color:#1976D2;font-size:13px;border-bottom:1px solid #E8E8E8;padding-bottom:6px;margin-bottom:8px;">
+          题号: {{ item['题号'] }}
+        </div>
+        <div class="meta-grid">
+          <span>科目: {{ item['科目'] }}</span>
+          <span>类型: {{ item['题目类型'] }}</span>
+          <span>来源: {{ item['题目来源'] }}</span>
+          <span>答题时间: {{ item['答题时间'] }}</span>
+          <span>科目分类: {{ item['题目分类'] }}</span>
+          <span>输入时间: {{ item['输入时间'] }}</span>
+          <span>有无小题: {{ item['是否有小题'] ? '是' : '否' }}</span>
+          <span>小题个数: {{ item['小题个数'] || 0 }}</span>
+          <span>题目分值: {{ item['分值'] !== undefined ? item['分值'] : '未设置' }}</span>
+        </div>
+
+        <div style="display:flex;gap:8px;align-items:flex-start;margin:10px 0;">
+          <label style="font-weight:bold;font-size:13px;white-space:nowrap;margin-top:4px;">知识点:</label>
+          <textarea v-model="item['题目知识点']" rows="2" style="flex:1;background:#FAFAFA;border:1px solid #eee;"></textarea>
+        </div>
+
+        <div style="font-weight:bold;font-size:13px;">【题干内容】</div>
+        <div style="background:#FAFAFA;padding:10px;border-radius:3px;margin:4px 0;">
+          <RichContent :content="item['题干内容'] || ''" :max-width="750" />
+          <div v-if="!item['题干内容']" style="color:#999;">(无内容)</div>
+        </div>
+
+        <div style="font-weight:bold;font-size:13px;color:#2E7D32;">【答案解析】</div>
+        <div style="background:#FAFAFA;padding:10px;border-radius:3px;margin:4px 0;">
+          <RichContent :content="item['答案解析'] || ''" :max-width="750" />
+          <div v-if="!item['答案解析']" style="color:#999;">(无内容)</div>
+        </div>
+
+        <div style="display:flex;align-items:center;gap:8px;margin-top:10px;">
+          <label style="font-size:13px;">小题个数:</label>
+          <input type="text" v-model="subNumInput[idx]" style="width:60px;" />
+          <button class="btn small" @click="updateSubInfo(idx)">确认修改</button>
+          <button class="btn small" style="color:red;" @click="deleteQuestion(idx)">删除此题</button>
+        </div>
+      </div>
+    </div>
+
+    <div class="bottom-bar">
+      <button class="btn" @click="onBackClick">返回修改</button>
+      <button class="btn primary" style="font-weight:bold;" @click="saveToDb">确认全部入库</button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { apiPost, tkAlert, tkConfirm } from '../api'
+import RichContent from '../components/RichContent.vue'
+
+const router = useRouter()
+const data = ref([])
+const meta = ref({})
+const subNumInput = ref({})
+
+onMounted(() => {
+  try {
+    data.value = JSON.parse(sessionStorage.getItem('sb_import_data') || '[]')
+    meta.value = JSON.parse(sessionStorage.getItem('sb_import_meta') || '{}')
+  } catch (e) {
+    data.value = []
+  }
+})
+
+function updateSubInfo(idx) {
+  const val = (subNumInput.value[idx] || '').trim()
+  if (!val) return
+  const num = parseInt(val)
+  if (isNaN(num)) { tkAlert('请输入整数'); return }
+  data.value[idx]['是否有小题'] = num > 0
+  data.value[idx]['小题个数'] = num
+  subNumInput.value[idx] = ''
+}
+
+function deleteQuestion(idx) {
+  if (!tkConfirm('确定删除此题吗?')) return
+  data.value.splice(idx, 1)
+}
+
+function onBackClick() {
+  if (!tkConfirm('确定返回吗?所有未入库的修改将丢失!')) return
+  router.push('/questions/import')
+}
+
+async function saveToDb() {
+  if (!data.value.length) { tkAlert('没有题目可以入库。'); return }
+  try {
+    const res = await apiPost('/import/save', { questions: data.value, meta: meta.value })
+    tkAlert(`成功入库 ${res.inserted} 道题目并建立知识点关联`)
+    sessionStorage.removeItem('sb_import_data')
+    sessionStorage.removeItem('sb_import_meta')
+    router.push('/questions')
+  } catch (e) {
+    tkAlert(e.message)
+  }
+}
+</script>
+
+<style scoped>
+.meta-grid {
+  display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px 10px;
+  color: #555; font-size: 12px; background: #F9F9F9; padding: 8px; border-radius: 3px;
+}
+.bottom-bar {
+  position: fixed; bottom: 0; left: 0; right: 0; height: 64px;
+  background: #fff; border-top: 1px solid #d9d9d9;
+  display: flex; align-items: center; justify-content: flex-end; gap: 14px; padding: 0 30px;
+}
+</style>
diff --git a/frontend/src/views/PapersCreateById.vue b/frontend/src/views/PapersCreateById.vue
new file mode 100644 (file)
index 0000000..89abb0b
--- /dev/null
@@ -0,0 +1,48 @@
+<template>
+  <div>
+    <div class="page-header">
+      <span class="title">根据ID生成试卷</span>
+      <button class="btn" style="margin-left:16px;" @click="$router.push('/papers')">返回</button>
+    </div>
+
+    <div class="content-area" style="padding-top:16px;">
+      <div style="margin-bottom:10px;font-size:14px;">请输入试题ID(多个ID请用逗号、空格或换行符分隔):</div>
+      <textarea v-model="idInput" style="width:100%;height:320px;font-family:Consolas,monospace;font-size:13px;"></textarea>
+      <div style="margin-top:16px;">
+        <button class="btn green" style="font-weight:bold;font-size:14px;padding:10px 22px;" @click="generatePaper">生成试卷</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { useRouter } from 'vue-router'
+import { apiGet, tkAlert, tkConfirm } from '../api'
+
+const router = useRouter()
+const idInput = ref('')
+
+async function generatePaper() {
+  const raw = idInput.value.trim()
+  if (!raw) { tkAlert('请输入至少一个试题ID。'); return }
+
+  let data
+  try {
+    const params = new URLSearchParams({ ids: raw })
+    data = await apiGet('/questions/validate-ids?' + params.toString())
+  } catch (e) {
+    tkAlert(e.message)
+    return
+  }
+
+  const validIds = data.valid_ids
+  if (data.missing_ids.length) {
+    const ok = tkConfirm(`部分试题ID (${data.missing_ids.join(', ')}) 不存在于数据库中,是否继续只使用有效ID生成试卷?`)
+    if (!ok) return
+  }
+
+  sessionStorage.setItem('sb_selected_ids', JSON.stringify(validIds))
+  router.push('/papers/preview')
+}
+</script>
diff --git a/frontend/src/views/PapersDetail.vue b/frontend/src/views/PapersDetail.vue
new file mode 100644 (file)
index 0000000..fc66b40
--- /dev/null
@@ -0,0 +1,27 @@
+<template>
+  <div>
+    <div class="page-header">
+      <span class="title">试卷详情: {{ paperName }}</span>
+    </div>
+    <div class="content-area" style="padding-top:30px;text-align:center;">
+      <p style="margin:30px 0;font-size:15px;">这里是试卷题目配置与执行区域</p>
+      <button class="btn" style="width:200px;padding:10px;margin:10px;" @click="$router.push(`/papers/${pid}/result`)">生成并查看结果反馈</button>
+      <button class="btn" style="width:200px;padding:10px;margin:10px;" @click="$router.push('/papers')">返回列表</button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRoute } from 'vue-router'
+import { apiGet } from '../api'
+
+const route = useRoute()
+const pid = parseInt(route.params.id)
+const paperName = ref('未命名')
+
+onMounted(async () => {
+  const basic = await apiGet(`/papers/${pid}/basic`)
+  paperName.value = basic.paper_name
+})
+</script>
diff --git a/frontend/src/views/PapersList.vue b/frontend/src/views/PapersList.vue
new file mode 100644 (file)
index 0000000..a57b325
--- /dev/null
@@ -0,0 +1,151 @@
+<template>
+  <div>
+    <div class="page-header">
+      <span class="title">试卷管理列表</span>
+      <button class="btn" @click="$router.push('/')">返回主页</button>
+      <button class="btn orange" style="margin-left:10px;" @click="$router.push('/papers/create-by-id')">💡 根据ID生成试卷</button>
+      <button class="btn green" style="margin-left:10px;" @click="$router.push('/questions?from_paper_creation=1')">➕ 进入题目列表选题</button>
+    </div>
+
+    <div class="filter-panel">
+      <label>试卷状态:</label>
+      <select v-model="statusVar" @change="onFilterChange">
+        <option>全部</option><option>未完成</option><option>已完成</option><option>部分完成</option><option>归档</option>
+      </select>
+      <label style="margin-left:20px;">试卷ID:</label>
+      <input type="text" v-model="paperIdVar" style="width:150px;" @keyup.enter="loadPapersData" />
+      <button class="btn primary" style="margin-left:10px;" @click="loadPapersData">🔍 搜索</button>
+      <button class="btn orange" style="margin-left:6px;" @click="exportExcel">📥 导出Excel</button>
+    </div>
+
+    <div class="content-area">
+      <div class="card" style="padding:0;overflow:auto;">
+        <table class="data-table">
+          <thead>
+            <tr>
+              <th v-for="col in columns" :key="col.key" @click="col.db && sortColumn(col.db)">
+                {{ col.label }}{{ sortIndicator(col.db) }}
+              </th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="row in rows" :key="row.ID">
+              <td class="center">{{ row.ID }}</td>
+              <td @dblclick="$router.push(`/papers/${row.ID}/detail`)" style="cursor:pointer;">{{ row['试卷名称'] }}</td>
+              <td class="center">{{ row['科目'] }}</td>
+              <td class="center">{{ row['题数'] }}</td>
+              <td class="center">{{ (row['创建时间'] || '').slice(0, 10) || '-' }}</td>
+              <td class="center">{{ row['状态'] }}</td>
+              <td class="center nowrap">
+                <button class="btn small" @click="previewPaper(row.ID)">预览</button>
+                <button class="btn small" @click="showFeedback(row.ID)">反馈</button>
+                <button class="btn small" @click="deletePaper(row.ID)">删除</button>
+                <button class="btn small" @click="downloadPaper(row.ID)">下载</button>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { apiGet, apiDelete, apiDownload, tkConfirm, tkAlert } from '../api'
+
+const router = useRouter()
+const statusVar = ref('未完成')
+const paperIdVar = ref('')
+const rows = ref([])
+const sortCol = ref('p.create_time')
+const sortDir = ref('DESC')
+
+const columns = [
+  { key: 'id', label: 'ID', db: 'p.id' },
+  { key: 'name', label: '试卷名称', db: 'p.paper_name' },
+  { key: 'subject', label: '科目', db: 'p.subject' },
+  { key: 'count', label: '题数', db: 'question_count' },
+  { key: 'time', label: '创建时间', db: 'p.create_time' },
+  { key: 'status', label: '状态', db: 'ps.status_name' },
+  { key: 'actions', label: '操作', db: null },
+]
+
+function sortIndicator(db) {
+  if (!db || db !== sortCol.value) return ''
+  return sortDir.value === 'ASC' ? ' ▲' : ' ▼'
+}
+
+function sortColumn(db) {
+  if (sortCol.value === db) sortDir.value = sortDir.value === 'ASC' ? 'DESC' : 'ASC'
+  else { sortCol.value = db; sortDir.value = 'ASC' }
+  loadPapersData()
+}
+
+function onFilterChange() {
+  paperIdVar.value = ''
+  loadPapersData()
+}
+
+async function loadPapersData() {
+  const params = new URLSearchParams({
+    status: statusVar.value, paper_id: paperIdVar.value,
+    sort_col: sortCol.value, sort_dir: sortDir.value
+  })
+  const data = await apiGet('/papers/list?' + params.toString())
+  rows.value = data.rows
+}
+
+function previewPaper(pid) {
+  router.push('/papers/preview?paper_id=' + pid)
+}
+
+function showFeedback(pid) {
+  router.push(`/papers/${pid}/result`)
+}
+
+async function deletePaper(pid) {
+  // 先查试卷信息(原版 delete_paper 先查询再确认)
+  const basic = await apiGet(`/papers/${pid}/basic`)
+  const qids = basic.question_ids
+  const name = basic.paper_name
+  const confirmMsg =
+    `确定要永久删除该试卷吗?\n\n` +
+    `试卷ID:${pid}\n试卷名称:${name}\n题目数量:${qids.length} 道\n\n` +
+    `注意:删除后将同步扣减相关题目的组卷及对错统计次数!`
+  if (!tkConfirm(confirmMsg)) return
+  await apiDelete(`/papers/${pid}`)
+  await loadPapersData()
+}
+
+async function downloadPaper(pid) {
+  const basic = await apiGet(`/papers/${pid}/basic`)
+  if (!basic.question_ids.length) {
+    tkAlert(`试卷 ${pid} 暂无题目,无法下载。`)
+    return
+  }
+  const url = `/StudyBase/api/papers/${pid}/download`
+  const a = document.createElement('a')
+  a.href = url
+  a.download = `${basic.paper_name}_${pid}.docx`.replace(/\//g, '_')
+  document.body.appendChild(a)
+  a.click()
+  a.remove()
+}
+
+async function exportExcel() {
+  if (!rows.value.length) { tkAlert('当前列表没有数据可以导出'); return }
+  await apiDownload('/papers/list/export-excel', {
+    status: statusVar.value, paper_id: paperIdVar.value,
+    sort_col: sortCol.value, sort_dir: sortDir.value
+  })
+}
+
+onMounted(loadPapersData)
+</script>
+
+<style scoped>
+.center { text-align: center; }
+.nowrap { white-space: nowrap; }
+</style>
diff --git a/frontend/src/views/PapersPreview.vue b/frontend/src/views/PapersPreview.vue
new file mode 100644 (file)
index 0000000..043f397
--- /dev/null
@@ -0,0 +1,153 @@
+<template>
+  <div>
+    <div class="page-header">
+      <span class="title">试卷预览</span>
+      <button v-if="paperId !== null" class="btn" style="margin-left:16px;" @click="$router.push('/papers')">返回试卷列表</button>
+      <button v-else class="btn" style="margin-left:16px;" @click="returnToQuestionList">返回选题列表</button>
+      <button class="btn" @click="openDocument">打开文档</button>
+      <button class="btn green" style="font-weight:bold;" :disabled="paperId !== null" @click="onSaveAndGenerate">💾 确认生成试卷</button>
+    </div>
+
+    <div class="content-area" style="padding-top:12px;">
+      <div class="card" style="display:flex;align-items:center;gap:8px;">
+        <label style="font-weight:bold;">试卷标题:</label>
+        <input type="text" v-model="titleVar" style="width:420px;" />
+        <span style="color:gray;font-size:12px;">(可手动修改标题)</span>
+      </div>
+
+      <div class="card">
+        <div style="font-weight:bold;margin-bottom:8px;">试卷内容预览(共 {{ selectedIds.length }} 道题)</div>
+        <div v-for="(q, idx) in previewQuestions" :key="q.id" style="border-bottom:1px solid #eee;padding:10px 0;">
+          <div style="font-size:12px;color:#555;margin-bottom:4px;">
+            {{ idx + 1 }}. ID:{{ q.id }} | {{ q.question_type || '题目' }} | {{ q.Subject }} | {{ q.source_description }}
+            | 组卷次数:{{ q.paper_count }} | 正确次数:{{ q.right_times }} | 错误次数:{{ q.wrong_times }} | 部分正确次数:{{ q.half_right_times }}
+          </div>
+          <RichContent :content="q.content" :max-width="800" />
+        </div>
+        <div v-if="!previewQuestions.length" style="color:#999;">未选择题目,无法预览。</div>
+      </div>
+
+      <div class="log-box" style="height:120px;">{{ logText }}</div>
+
+      <div style="display:flex;justify-content:flex-end;gap:12px;margin:12px 0 30px;">
+        <button class="btn" :disabled="!reviewReady" @click="openReviewDocument">打开复习卷</button>
+        <button class="btn green" style="font-weight:bold;" @click="generateReviewPaper">生成复习卷</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { apiGet, apiPost, tkAlert } from '../api'
+import RichContent from '../components/RichContent.vue'
+
+const route = useRoute()
+const router = useRouter()
+
+const paperId = ref(null)
+const selectedIds = ref([])
+const titleVar = ref('')
+const logText = ref('')
+const reviewReady = ref(false)
+const previewQuestions = ref([])
+
+function ts() { return new Date().toTimeString().slice(0, 8) }
+
+function returnToQuestionList() {
+  router.push('/questions?from_paper_creation=1')
+}
+
+async function loadPreviewQuestions() {
+  if (!selectedIds.value.length) { previewQuestions.value = []; return }
+  const data = await apiPost('/questions/search', {
+    filters: {}, mode: 'preview',
+    id_input: selectedIds.value.join(' '),
+  })
+  // 保持 selectedIds 顺序(原 docx 生成时按 subject_id, id 排序,此处预览按传入顺序)
+  const map = {}
+  for (const r of data.rows) map[r.id] = r
+  previewQuestions.value = selectedIds.value.map(id => map[id]).filter(Boolean)
+}
+
+async function generatePreview() {
+  if (!selectedIds.value.length) {
+    logText.value += `[${ts()}] 未选择题目,无法预览。\n`
+    return
+  }
+  try {
+    const res = await apiPost('/papers/preview-docx', {
+      selected_ids: selectedIds.value, title: titleVar.value, paper_id: paperId.value
+    })
+    logText.value += `[${ts()}] 预览已生成:${res.path}\n`
+  } catch (e) {
+    logText.value += `[${ts()}] 生成预览失败: ${e.message}\n`
+  }
+}
+
+async function generateReviewPaper() {
+  if (!selectedIds.value.length) { tkAlert('未选择题目,无法生成复习卷。'); return }
+  try {
+    const res = await apiPost('/papers/review-docx', {
+      selected_ids: selectedIds.value, title: titleVar.value, paper_id: paperId.value
+    })
+    reviewReady.value = true
+    logText.value += `[${ts()}] 复习卷已生成:${res.path}\n`
+  } catch (e) {
+    tkAlert('生成复习卷失败:' + e.message)
+  }
+}
+
+function openDocument() {
+  const url = '/StudyBase/api/papers/temp-file?name=preview'
+  const a = document.createElement('a')
+  a.href = url
+  a.download = 'temp_preview.docx'
+  document.body.appendChild(a)
+  a.click()
+  a.remove()
+}
+
+function openReviewDocument() {
+  const url = '/StudyBase/api/papers/temp-file?name=review'
+  const a = document.createElement('a')
+  a.href = url
+  a.download = 'temp_review_preview.docx'
+  document.body.appendChild(a)
+  a.click()
+  a.remove()
+}
+
+async function onSaveAndGenerate() {
+  if (!selectedIds.value.length) { tkAlert('未选择题目,无法生成试卷。'); return }
+  if (paperId.value !== null) return
+  try {
+    const res = await apiPost('/papers/save', {
+      selected_ids: selectedIds.value, title: titleVar.value
+    })
+    paperId.value = res.paper_id
+    tkAlert(`试卷已成功保存!ID: ${res.paper_id}`)
+    await generatePreview()
+  } catch (e) {
+    tkAlert('保存失败:' + e.message)
+  }
+}
+
+onMounted(async () => {
+  if (route.query.paper_id) {
+    paperId.value = parseInt(route.query.paper_id)
+    const info = await apiPost('/papers/preview-info', { paper_id: paperId.value })
+    titleVar.value = info.title
+    selectedIds.value = info.selected_ids
+  } else {
+    try {
+      selectedIds.value = JSON.parse(sessionStorage.getItem('sb_selected_ids') || '[]')
+    } catch (e) { selectedIds.value = [] }
+    const info = await apiPost('/papers/preview-info', { selected_ids: selectedIds.value })
+    titleVar.value = info.title
+  }
+  await loadPreviewQuestions()
+  await generatePreview()
+})
+</script>
diff --git a/frontend/src/views/PapersResult.vue b/frontend/src/views/PapersResult.vue
new file mode 100644 (file)
index 0000000..5396bca
--- /dev/null
@@ -0,0 +1,113 @@
+<template>
+  <div>
+    <div class="page-header">
+      <span class="title">试卷结果反馈</span>
+    </div>
+
+    <div class="content-area" style="padding-top:16px;">
+      <div v-for="q in questions" :key="q.question_id" class="card">
+        <div style="font-weight:bold;color:blue;font-size:13px;">
+          试题ID: {{ q.question_id }}, 科目: {{ q.subject }}
+        </div>
+
+        <template v-if="q.result_type === 0">
+          <!-- 可编辑 -->
+          <div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin:8px 0;">
+            <span style="font-weight:bold;font-size:13px;">错误原因反馈:</span>
+            <label v-for="c in causes" :key="c" style="font-size:13px;">
+              <input type="radio" :name="'err_' + q.question_id" v-model="q._error" :value="c" /> {{ c }}
+            </label>
+          </div>
+          <div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;">
+            <span style="font-weight:bold;font-size:13px;">试题结果:</span>
+            <label style="font-size:13px;"><input type="radio" :name="'res_' + q.question_id" v-model="q._result" value="正确" /> 正确</label>
+            <label style="font-size:13px;"><input type="radio" :name="'res_' + q.question_id" v-model="q._result" value="错误" /> 错误</label>
+            <label style="font-size:13px;"><input type="radio" :name="'res_' + q.question_id" v-model="q._result" value="未完成" /> 未完成</label>
+            <label style="font-size:13px;"><input type="radio" :name="'res_' + q.question_id" v-model="q._result" value="部分错误" /> 部分错误</label>
+            <input v-if="q._result === '部分错误'" type="text" v-model="q._partial" placeholder="错误小题描述" style="width:260px;" />
+          </div>
+        </template>
+
+        <template v-else>
+          <!-- 只读 -->
+          <div style="display:flex;align-items:center;gap:8px;margin:8px 0;">
+            <span style="font-weight:bold;font-size:13px;">错误原因反馈:</span>
+            <span style="font-size:13px;">{{ q.error_cause_text }}</span>
+          </div>
+          <div style="display:flex;align-items:center;gap:8px;">
+            <span style="font-weight:bold;font-size:13px;">试题结果:</span>
+            <span style="font-size:13px;">{{ readonlyResultText(q.result_type) }}</span>
+          </div>
+        </template>
+      </div>
+
+      <div style="display:flex;gap:12px;justify-content:center;margin:20px 0 40px;">
+        <button class="btn" style="width:130px;" :disabled="!hasPending" @click="confirmResults">确定结果</button>
+        <button class="btn" style="width:130px;" :disabled="!hasPending" @click="archivePaper">试卷归档</button>
+        <button class="btn" style="width:130px;" @click="$router.push('/papers')">返回试卷列表</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { apiGet, apiPost, tkAlert, tkConfirm } from '../api'
+
+const route = useRoute()
+const router = useRouter()
+const pid = parseInt(route.params.id)
+
+const questions = ref([])
+const causes = ref([])
+const hasPending = ref(false)
+
+function readonlyResultText(result_type) {
+  if (result_type === 1) return '正确'
+  if (result_type === 0) return '未判定/未选择'
+  if (result_type === 2) return '部分错误'
+  if (result_type === 3) return '错误'
+  return ''
+}
+
+async function load() {
+  const data = await apiGet(`/papers/${pid}/result`)
+  causes.value = data.causes
+  hasPending.value = data.has_pending
+  questions.value = data.questions.map(q => ({
+    ...q,
+    _result: '无', _error: '无', _partial: ''
+  }))
+}
+
+async function confirmResults() {
+  const editable = questions.value.filter(q => q.result_type === 0)
+  const results = editable.map(q => ({
+    question_id: q.question_id,
+    result: q._result,
+    error_cause: q._error,
+    partial_desc: q._partial,
+  }))
+  try {
+    await apiPost(`/papers/${pid}/result/confirm`, { results })
+    tkAlert('结果已保存到数据库中!')
+    router.push('/papers')
+  } catch (e) {
+    tkAlert('保存失败:' + e.message)
+  }
+}
+
+async function archivePaper() {
+  if (!tkConfirm('确定要归档此试卷吗?')) return
+  try {
+    await apiPost(`/papers/${pid}/archive`)
+    tkAlert('试卷已归档!')
+    router.push('/papers')
+  } catch (e) {
+    tkAlert('归档失败:' + e.message)
+  }
+}
+
+onMounted(load)
+</script>
diff --git a/frontend/src/views/QuestionAdd.vue b/frontend/src/views/QuestionAdd.vue
new file mode 100644 (file)
index 0000000..32421ac
--- /dev/null
@@ -0,0 +1,23 @@
+<template>
+  <div>
+    <div class="nav-bar">
+      <button class="btn" @click="$router.push('/questions')">&lt; 返回</button>
+      <span class="nav-title">新增题目</span>
+    </div>
+    <div style="padding:20px;">
+      <div class="card" style="max-width:560px;">
+        <div style="display:flex;align-items:center;gap:10px;margin:10px 0;">
+          <label>题目内容:</label>
+          <input type="text" style="flex:1;" />
+        </div>
+        <div style="display:flex;flex-direction:column;gap:14px;margin-top:24px;">
+          <button class="btn" style="background:#E1E1E1;padding:8px;width:180px;" @click="$router.push('/questions/import')">文件导入</button>
+          <button class="btn" style="padding:8px;width:180px;" @click="$router.push('/questions')">取消返回</button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+</script>
diff --git a/frontend/src/views/QuestionDetail.vue b/frontend/src/views/QuestionDetail.vue
new file mode 100644 (file)
index 0000000..68e2573
--- /dev/null
@@ -0,0 +1,210 @@
+<template>
+  <div>
+    <div class="page-header">
+      <span class="title">修改题目信息 (ID: {{ qid }})</span>
+    </div>
+
+    <div class="content-area" style="padding-top:14px;max-width:980px;">
+      <div class="card">
+        <div class="form-row">
+          <label>所属科目:</label>
+          <select v-model="subject" :disabled="!editMode">
+            <option v-for="s in subjectOptions" :key="s" :value="s">{{ s }}</option>
+          </select>
+        </div>
+        <div class="form-row">
+          <label>题目分类(必选):</label>
+          <select v-model="category" :disabled="!editMode">
+            <option>客观题</option><option>主观题</option>
+          </select>
+        </div>
+        <div class="form-row">
+          <label>题目类型(必选):</label>
+          <select v-model="qType" :disabled="!editMode">
+            <option>错题</option><option>推题</option>
+          </select>
+        </div>
+        <div class="form-row">
+          <label>题目知识点(必填):</label>
+          <input type="text" v-model="kp" style="width:420px;" :disabled="!editMode" />
+        </div>
+        <div class="form-row">
+          <label>答题时间(选填,例:2026-03-16):</label>
+          <input type="text" v-model="answerTime" style="width:150px;" :disabled="!editMode" />
+          <button class="btn small" :disabled="!editMode" @click="fillToday">填入今天</button>
+        </div>
+      </div>
+
+      <!-- 查看模式 -->
+      <template v-if="!editMode">
+        <div class="card">
+          <div style="font-weight:bold;margin-bottom:6px;">【题干】</div>
+          <RichContent :content="original.content" :max-width="850" />
+        </div>
+        <div class="card">
+          <div style="font-weight:bold;margin-bottom:6px;color:#2E7D32;">【答案解析】</div>
+          <RichContent :content="original.analysis" :max-width="850" />
+        </div>
+      </template>
+
+      <!-- 编辑模式 -->
+      <template v-else>
+        <div class="card">
+          <div style="font-weight:bold;margin-bottom:6px;">题干内容</div>
+          <div style="margin-bottom:6px;"><button class="btn small" @click="pickImage(stemRef)">从本地选择图片</button></div>
+          <textarea ref="stemRef" v-model="stemRaw" rows="8" style="width:100%;" @paste="onPaste($event, 'stem')"></textarea>
+        </div>
+        <div class="card">
+          <div style="font-weight:bold;margin-bottom:6px;">答案解析</div>
+          <div style="margin-bottom:6px;"><button class="btn small" @click="pickImage(analysisRef)">从本地选择图片</button></div>
+          <textarea ref="analysisRef" v-model="analysisRaw" rows="6" style="width:100%;" @paste="onPaste($event, 'analysis')"></textarea>
+        </div>
+      </template>
+
+      <div style="display:flex;justify-content:flex-end;gap:10px;margin:14px 0 30px;">
+        <button v-if="editMode" class="btn" @click="cancelEdit">取消</button>
+        <button class="btn primary" @click="editMode ? saveChanges() : toggleEditMode()">
+          {{ editMode ? '确定保存' : '编辑' }}
+        </button>
+        <button class="btn" @click="$router.push('/questions')">返回列表</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRoute } from 'vue-router'
+import { apiGet, apiPut, tkAlert, tkConfirm } from '../api'
+import RichContent from '../components/RichContent.vue'
+
+const route = useRoute()
+const qid = parseInt(route.params.id)
+
+const original = ref({})
+const subjectOptions = ref([])
+const subject = ref('')
+const category = ref('客观题')
+const qType = ref('错题')
+const kp = ref('')
+const answerTime = ref('')
+const stemRaw = ref('')
+const analysisRaw = ref('')
+const editMode = ref(false)
+const stemRef = ref(null)
+const analysisRef = ref(null)
+
+async function load() {
+  const d = await apiGet(`/questions/${qid}`)
+  original.value = d
+  subjectOptions.value = d.subject_options || []
+  subject.value = d.Subject || ''
+  category.value = d.is_subjective === 1 ? '主观题' : '客观题'
+  qType.value = d.question_type || '错题'
+  kp.value = d.knowledge_points_desc || ''
+  answerTime.value = d.answer_time || ''
+  stemRaw.value = d.content || ''
+  analysisRaw.value = d.analysis || ''
+}
+
+function toggleEditMode() {
+  editMode.value = true
+}
+
+async function cancelEdit() {
+  if (!tkConfirm('确定取消编辑吗?')) return
+  await load()
+  editMode.value = false
+}
+
+function fillToday() {
+  const d = new Date()
+  const p = n => String(n).padStart(2, '0')
+  answerTime.value = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
+}
+
+async function saveChanges() {
+  try {
+    await apiPut(`/questions/${qid}`, {
+      subject: subject.value,
+      question_type: qType.value,
+      answer_time: answerTime.value,
+      is_subjective: category.value === '主观题',
+      content_raw: stemRaw.value.trim(),
+      analysis_raw: analysisRaw.value.trim(),
+    })
+    tkAlert('已更新!')
+    await load()
+    editMode.value = false
+  } catch (e) {
+    tkAlert('错误:' + e.message)
+  }
+}
+
+async function uploadImageFile(file) {
+  const fd = new FormData()
+  fd.append('file', file, file.name || 'pasted.png')
+  const res = await fetch('/StudyBase/api/import/upload-image', { method: 'POST', body: fd })
+  if (!res.ok) {
+    let msg = '上传失败'
+    try { msg = (await res.json()).detail || msg } catch (e) { }
+    throw new Error(msg)
+  }
+  return (await res.json()).path
+}
+
+function insertToken(ta, token) {
+  if (ta && ta.selectionStart != null) {
+    const s = ta.selectionStart
+    const v = ta.value
+    ta.value = v.slice(0, s) + token + v.slice(ta.selectionEnd)
+    ta.dispatchEvent(new Event('input'))
+  } else if (ta) {
+    ta.value += token
+    ta.dispatchEvent(new Event('input'))
+  }
+}
+
+function pickImage(refObj) {
+  const input = document.createElement('input')
+  input.type = 'file'
+  input.accept = 'image/png,image/jpeg,image/bmp,image/gif'
+  input.onchange = async () => {
+    const file = input.files[0]
+    if (!file) return
+    try {
+      const path = await uploadImageFile(file)
+      insertToken(refObj.value || refObj, `[[IMG:${path}]]`)
+    } catch (e) {
+      tkAlert(e.message)
+    }
+  }
+  input.click()
+}
+
+async function onPaste(ev, which) {
+  const items = ev.clipboardData && ev.clipboardData.items
+  if (!items) return
+  for (const item of items) {
+    if (item.type && item.type.startsWith('image/')) {
+      ev.preventDefault()
+      const file = item.getAsFile()
+      try {
+        const path = await uploadImageFile(file)
+        const ta = which === 'stem' ? stemRef.value : analysisRef.value
+        insertToken(ta, `[[IMG:${path}]]`)
+      } catch (e) {
+        tkAlert(e.message)
+      }
+      return
+    }
+  }
+}
+
+onMounted(load)
+</script>
+
+<style scoped>
+.form-row { display: flex; align-items: center; gap: 10px; margin: 8px 0; }
+.form-row label { white-space: nowrap; font-size: 13px; min-width: 190px; text-align: right; }
+</style>
diff --git a/frontend/src/views/QuestionList.vue b/frontend/src/views/QuestionList.vue
new file mode 100644 (file)
index 0000000..981c436
--- /dev/null
@@ -0,0 +1,340 @@
+<template>
+  <div>
+    <div class="page-header">
+      <span class="title">{{ fromPaperCreation ? '选择题目创建试卷' : '题目列表管理' }}</span>
+      <button class="btn" style="margin-left:16px;" @click="$router.push('/')">返回主页</button>
+      <button class="btn green" style="margin-left:10px;" @click="createPaper">➕ 创建试卷</button>
+      <button class="btn green" style="margin-left:10px;" @click="$router.push('/questions/add')">➕ 新增题目</button>
+    </div>
+
+    <div style="padding:10px 20px 0;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
+      <button class="btn small" @click="selectAll">全选</button>
+      <button class="btn small" @click="reverseSelection">反选</button>
+      <button class="btn small" style="color:red;font-weight:bold;" @click="deleteSelected">❗️ 从数据库中删除</button>
+      <button class="btn small" style="color:#F57C00;font-weight:bold;" @click="bulkArchiveSelected">📦 批量归档</button>
+      <button class="btn small" style="color:#1890FF;font-weight:bold;" @click="copySelectedIds">📋 复制选中题目ID</button>
+      <button class="btn small" style="color:#1b5e20;font-weight:bold;" @click="removeFromList">❎ 从列表中移除</button>
+    </div>
+
+    <div class="filter-panel">
+      <div class="row">
+        <template v-for="cfg in visibleFilters" :key="cfg.key">
+          <label>{{ cfg.label }}:</label>
+          <select v-model="filters[cfg.key]">
+            <option v-for="o in optionsFor(cfg)" :key="o" :value="o">{{ o }}</option>
+          </select>
+        </template>
+      </div>
+      <div class="row">
+        <label>ID:</label>
+        <input type="text" v-model="idInput" style="width:150px;" />
+        <label style="margin-left:10px;">知识点:</label>
+        <input type="text" v-model="kpInput" style="width:200px;" />
+      </div>
+      <div class="row" style="justify-content:space-between;">
+        <div style="display:flex;align-items:center;gap:14px;">
+          <label><input type="radio" value="table" v-model="viewMode" @change="loadFilteredData" /> 表格模式</label>
+          <label><input type="radio" value="preview" v-model="viewMode" @change="loadFilteredData" /> 预览模式</label>
+        </div>
+        <div style="display:flex;align-items:center;gap:10px;">
+          <span style="color:#E64A19;font-weight:bold;font-size:13px;">共找到 {{ currentCount }} 道题目</span>
+          <button class="btn primary" @click="loadFilteredData">🔍 开始搜索</button>
+          <button v-if="viewMode === 'table'" class="btn orange" @click="exportExcel">📥 导出Excel</button>
+        </div>
+      </div>
+    </div>
+
+    <div class="content-area">
+      <!-- 表格模式 -->
+      <div v-if="viewMode === 'table'" class="card" style="padding:0;overflow:auto;">
+        <table class="data-table">
+          <thead>
+            <tr>
+              <th v-for="col in tableColumns" :key="col.key" @click="sortBy(col.key)">
+                {{ col.label }}{{ sortIndicator(col.key) }}
+              </th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="row in rows" :key="row.id" class="clickable"
+                :class="{ selected: selectedIds.has(row.id) }"
+                @click="toggleRow(row.id, $event)"
+                @dblclick="$router.push('/questions/' + row.id)">
+              <td class="center">{{ row.Subject }}</td>
+              <td class="center">{{ row.id }}</td>
+              <td class="center">{{ statusDisplay(row.latest_status) }}</td>
+              <td class="center">{{ row.question_type }}</td>
+              <td>{{ row.knowledge_points_desc }}</td>
+              <td class="center">{{ row.answer_time }}</td>
+              <td class="center">{{ row.input_time }}</td>
+              <td class="center">{{ row.paper_count }}</td>
+              <td class="center">{{ row.right_times }}</td>
+              <td class="center">{{ row.wrong_times }}</td>
+              <td class="center">{{ row.half_right_times }}</td>
+              <td class="center">{{ row.last_right_time }}</td>
+              <td class="center">{{ row.paper_ids }}</td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+
+      <!-- 预览模式 -->
+      <div v-else>
+        <div class="card" v-for="row in rows" :key="row.id">
+          <div style="display:flex;">
+            <div style="display:flex;flex-direction:column;justify-content:space-between;align-items:center;padding:0 10px;">
+              <input type="checkbox" v-model="checked[row.id]" />
+              <button class="btn small" @click="$router.push('/questions/' + row.id)">详情</button>
+            </div>
+            <div style="flex:1;min-width:0;">
+              <div style="color:#1890FF;font-weight:bold;font-size:13px;margin-bottom:6px;word-break:break-all;">
+                ID: {{ row.id }} | 科目: {{ row.Subject }} | 状态: {{ statusDisplay(row.latest_status) }} |
+                归档: {{ row.StatusName || '未归档' }} | 知识点: {{ row.knowledge_points_desc }}
+              </div>
+              <div style="font-weight:bold;font-size:13px;margin:4px 0 2px;">【题干】</div>
+              <RichContent :content="row.content" :max-width="800" />
+              <template v-if="row.analysis">
+                <div style="font-weight:bold;font-size:13px;margin:6px 0 2px;color:#2E7D32;">【解析】</div>
+                <RichContent :content="row.analysis" :max-width="800" />
+              </template>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { apiGet, apiPost, apiDownload, tkConfirm, tkAlert } from '../api'
+import RichContent from '../components/RichContent.vue'
+
+const route = useRoute()
+const router = useRouter()
+
+const fromPaperCreation = route.query.from_paper_creation === '1' || route.query.from_paper_creation === 'true'
+
+const countOptions = ['全部', '0', '1', '2', '3', '4次及以上']
+const natureOptions = ['全部', '错题', '推题']
+const statusOptions = ['全部', '未完成', '正确', '错误', '部分正确']
+const archiveOptions = ['全部', '未归档', '已归档']
+
+const filterConfigs = [
+  { key: 'subject', label: '科目', type: 'db' },
+  { key: 'status', label: '状态', type: 'fixed', options: statusOptions },
+  { key: 'archive_status', label: '归档', type: 'fixed', options: archiveOptions },
+  { key: 'cause', label: '原因', type: 'db' },
+  { key: 'source', label: '来源', type: 'db' },
+  { key: 'term', label: '学期', type: 'db' },
+  { key: 'stage', label: '阶段', type: 'db' },
+  { key: 'question_nature', label: '试题类型', type: 'fixed', options: natureOptions },
+  { key: 'paper_count', label: '组卷次数', type: 'fixed', options: countOptions },
+  { key: 'right_times', label: '正确次数', type: 'fixed', options: countOptions },
+  { key: 'wrong_times', label: '错误次数', type: 'fixed', options: countOptions },
+  { key: 'half_right_times', label: '部分正确次数', type: 'fixed', options: countOptions },
+]
+const visibleFilters = filterConfigs
+
+const filters = reactive({})
+for (const cfg of filterConfigs) filters[cfg.key] = '全部'
+filters.archive_status = '未归档'  // 默认(原版 _initialize_default_term_stage_archive_filters)
+
+const dbOptions = ref({})
+const idInput = ref('')
+const kpInput = ref('')
+const viewMode = ref('table')
+const rows = ref([])
+const currentCount = ref(0)
+const checked = reactive({})
+const selectedIds = reactive(new Set())
+
+const chapterIdFilter = ref(null)
+const kpIdFilter = ref(null)
+const rightTimesFilter = ref(null)
+
+const sortCol = ref('q.id')
+const sortDesc = ref(false)
+
+const tableColumns = [
+  { key: 'subject', label: '科目', db: 'subject_id' },
+  { key: 'id', label: '试题ID', db: 'q.id' },
+  { key: 'latest_status', label: '掌握状态', db: 'latest_status' },
+  { key: 'question_type', label: '题目类型', db: 'question_type' },
+  { key: 'kp', label: '题目知识点', db: 'knowledge_points_desc' },
+  { key: 'answer_time', label: '答题时间', db: 'answer_time' },
+  { key: 'input_time', label: '录入时间', db: 'input_time' },
+  { key: 'p_count', label: '组卷次数', db: 'paper_count' },
+  { key: 'r_count', label: '正确次数', db: 'right_times' },
+  { key: 'w_count', label: '错误次数', db: 'wrong_times' },
+  { key: 'h_count', label: '部分正确次数', db: 'half_right_times' },
+  { key: 'last_r_time', label: '最晚正确时间', db: 'last_right_time' },
+  { key: 'paper_ids', label: '所属试卷ID', db: null },
+]
+
+function optionsFor(cfg) {
+  if (cfg.type === 'fixed') return cfg.options
+  return ['全部', ...(dbOptions.value[cfg.key] || [])]
+}
+
+function statusDisplay(s) {
+  return { right: '正确', wrong: '错误', half_right: '部分正确' }[s] || '未完成'
+}
+
+function getSelectedIds() {
+  if (viewMode.value === 'table') return [...selectedIds]
+  return Object.entries(checked).filter(([, v]) => v).map(([k]) => parseInt(k))
+}
+
+function toggleRow(id, ev) {
+  if (ev && (ev.ctrlKey || ev.metaKey || ev.shiftKey)) {
+    if (selectedIds.has(id)) selectedIds.delete(id)
+    else selectedIds.add(id)
+  } else {
+    const only = selectedIds.size === 1 && selectedIds.has(id)
+    selectedIds.clear()
+    if (!only) selectedIds.add(id)
+  }
+}
+
+function sortBy(colKey) {
+  const col = tableColumns.find(c => c.key === colKey)
+  if (!col || !col.db) return
+  if (sortCol.value === col.db) sortDesc.value = !sortDesc.value
+  else { sortCol.value = col.db; sortDesc.value = false }
+  loadFilteredData()
+}
+
+function sortIndicator(colKey) {
+  const col = tableColumns.find(c => c.key === colKey)
+  if (!col || col.db !== sortCol.value) return ''
+  return sortDesc.value ? ' ▼' : ' ▲'
+}
+
+async function loadFilteredData() {
+  const body = {
+    filters: { ...filters },
+    id_input: idInput.value,
+    kp_input: kpInput.value,
+    chapter_id_filter: chapterIdFilter.value,
+    kp_id_filter: kpIdFilter.value,
+    right_times_filter: rightTimesFilter.value,
+    mode: viewMode.value,
+    sort_col: sortCol.value,
+    sort_desc: sortDesc.value,
+  }
+  const data = await apiPost('/questions/search', body)
+  rows.value = data.rows
+  currentCount.value = data.count
+  selectedIds.clear()
+  for (const k of Object.keys(checked)) delete checked[k]
+}
+
+function selectAll() {
+  if (viewMode.value === 'table') {
+    for (const r of rows.value) selectedIds.add(r.id)
+  } else {
+    for (const r of rows.value) checked[r.id] = true
+  }
+}
+
+function reverseSelection() {
+  if (viewMode.value === 'table') {
+    for (const r of rows.value) {
+      if (selectedIds.has(r.id)) selectedIds.delete(r.id)
+      else selectedIds.add(r.id)
+    }
+  } else {
+    for (const r of rows.value) checked[r.id] = !checked[r.id]
+  }
+}
+
+function createPaper() {
+  const ids = getSelectedIds()
+  if (!ids.length) { tkAlert('请至少选择一道题目'); return }
+  sessionStorage.setItem('sb_selected_ids', JSON.stringify(ids))
+  router.push('/papers/preview')
+}
+
+async function deleteSelected() {
+  if (viewMode.value === 'table') {
+    tkAlert('表格模式下不支持批量删除,请切换到预览模式通过复选框操作。')
+    return
+  }
+  const ids = getSelectedIds()
+  if (!ids.length) { tkAlert('请勾选题目'); return }
+  if (!tkConfirm(`确定删除选中的 ${ids.length} 道题吗?`)) return
+  await apiPost('/questions/delete', { ids })
+  await loadFilteredData()
+}
+
+async function bulkArchiveSelected() {
+  const ids = getSelectedIds()
+  if (!ids.length) { tkAlert('请选择要归档的题目'); return }
+  if (!tkConfirm('确定要将选中的题目归档吗?')) return
+  await apiPost('/questions/bulk-archive', { ids })
+  tkAlert(`成功归档 ${ids.length} 道题目。`)
+  await loadFilteredData()
+}
+
+async function copySelectedIds() {
+  const ids = getSelectedIds()
+  if (!ids.length) { tkAlert('请选择题目'); return }
+  try {
+    await navigator.clipboard.writeText(ids.join(','))
+    tkAlert('ID已复制到剪贴板')
+  } catch (e) {
+    tkAlert('复制失败:' + e.message)
+  }
+}
+
+function removeFromList() {
+  const ids = new Set(getSelectedIds())
+  if (!ids.size) { tkAlert('请先选择要从列表中移除的题目'); return }
+  rows.value = rows.value.filter(r => !ids.has(r.id))
+  currentCount.value = rows.value.length
+  selectedIds.clear()
+  for (const id of ids) delete checked[id]
+}
+
+async function exportExcel() {
+  if (!rows.value.length) { tkAlert('当前列表没有数据可以导出'); return }
+  await apiDownload('/questions/export-excel', {
+    filters: { ...filters },
+    id_input: idInput.value,
+    kp_input: kpInput.value,
+    chapter_id_filter: chapterIdFilter.value,
+    kp_id_filter: kpIdFilter.value,
+    right_times_filter: rightTimesFilter.value,
+    mode: 'table',
+    sort_col: sortCol.value,
+    sort_desc: sortDesc.value,
+  })
+}
+
+onMounted(async () => {
+  dbOptions.value = await apiGet('/questions/filter-options')
+
+  // 跳转参数(章节/知识点分析页跳入)
+  if (route.query.chapter_id) chapterIdFilter.value = parseInt(route.query.chapter_id)
+  if (route.query.kp_id) kpIdFilter.value = parseInt(route.query.kp_id)
+  if (route.query.right_times) rightTimesFilter.value = route.query.right_times
+
+  // initial_filters(遗留分析页跳入)
+  const f = route.query
+  if (f.subject) filters.subject = f.subject
+  if (f.stage) filters.stage = f.stage
+  if (f.term) filters.term = f.term
+  if (f.status) filters.status = f.status
+  if (f.archive_status) filters.archive_status = f.archive_status
+  if (f.question_nature) filters.question_nature = f.question_nature
+  if (f.paper_count) filters.paper_count = f.paper_count
+
+  await loadFilteredData()
+})
+</script>
+
+<style scoped>
+.center { text-align: center; }
+</style>
diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue
new file mode 100644 (file)
index 0000000..96bca15
--- /dev/null
@@ -0,0 +1,30 @@
+<template>
+  <div>
+    <div class="nav-bar">
+      <button class="btn" @click="$router.push('/')">&lt; 返回主页</button>
+      <span class="nav-title">系统设定</span>
+    </div>
+
+    <div class="content-area" style="padding:26px 50px;">
+      <div style="font-weight:bold;font-size:14px;margin-bottom:14px;">基础数据管理</div>
+      <button class="btn setting-btn" @click="$router.push('/settings/subjects')">科目管理</button>
+      <button class="btn setting-btn" @click="$router.push('/settings/error-causes')">错误原因管理</button>
+      <button class="btn setting-btn" @click="$router.push('/settings/question-statuses')">题目状态管理</button>
+
+      <hr style="border:none;border-top:1px solid #ddd;margin:22px 0;" />
+
+      <div style="font-weight:bold;font-size:14px;margin-bottom:14px;">应用设定</div>
+      <button class="btn setting-btn" @click="$router.push('/settings/default-params')">默认参数设定</button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+</script>
+
+<style scoped>
+.setting-btn {
+  display: block; width: 100%; text-align: left; padding: 9px 12px; margin: 6px 0;
+  font-size: 13px;
+}
+</style>
diff --git a/frontend/src/views/SettingsDefaultParams.vue b/frontend/src/views/SettingsDefaultParams.vue
new file mode 100644 (file)
index 0000000..bcfe8fb
--- /dev/null
@@ -0,0 +1,68 @@
+<template>
+  <div>
+    <div class="nav-bar">
+      <button class="btn" @click="$router.push('/settings')">&lt; 返回系统设定</button>
+      <span class="nav-title">默认参数设定</span>
+    </div>
+
+    <div class="content-area" style="padding:26px 40px;max-width:760px;">
+      <div class="card" v-for="sec in sections" :key="sec.key">
+        <div style="font-weight:bold;margin-bottom:10px;">{{ sec.title }}</div>
+        <div style="display:flex;align-items:center;gap:10px;">
+          <label>选择{{ sec.label }}:</label>
+          <select v-model="sec.selected" style="flex:1;">
+            <option v-for="o in sec.options" :key="o" :value="o">{{ o }}</option>
+          </select>
+          <button class="btn" @click="saveDefault(sec)">确定</button>
+        </div>
+        <div style="margin-top:10px;font-size:13px;" :style="{color: sec.hasDefault ? 'green' : 'gray'}">
+          当前默认:{{ sec.display }}
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { apiGet, apiPost, tkAlert } from '../api'
+
+const params = ref(null)
+
+const sections = computed(() => {
+  if (!params.value) return []
+  const defs = [
+    { key: 'term', table: 'term', title: '默认学期设定', label: '默认学期' },
+    { key: 'stage', table: 'stage', title: '默认阶段设定', label: '默认阶段' },
+    { key: 'question_origin', table: 'question_origin', title: '默认题目出处设定', label: '默认出处' },
+  ]
+  return defs.map(d => {
+    const p = params.value[d.key]
+    return {
+      ...d,
+      options: p.options,
+      selected: p.default || (p.options[0] || ''),
+      display: p.default_display,
+      hasDefault: !!p.default,
+    }
+  })
+})
+
+async function loadAll() {
+  params.value = await apiGet('/settings/default-params')
+}
+
+async function saveDefault(sec) {
+  if (!sec.selected) { tkAlert('请选择一个选项!'); return }
+  const labelMap = { term: '默认term', stage: '默认stage', question_origin: '默认question_origin' }
+  try {
+    await apiPost(`/settings/default-params/${sec.table}`, { name: sec.selected })
+    tkAlert(`成功:${labelMap[sec.table]}已设置为 '${sec.selected}'`)
+    await loadAll()
+  } catch (e) {
+    tkAlert(e.message)
+  }
+}
+
+onMounted(loadAll)
+</script>
diff --git a/frontend/src/views/SettingsErrorCauses.vue b/frontend/src/views/SettingsErrorCauses.vue
new file mode 100644 (file)
index 0000000..8c057e6
--- /dev/null
@@ -0,0 +1,153 @@
+<template>
+  <div>
+    <div class="nav-bar">
+      <button class="btn" @click="$router.push('/settings')">&lt; 返回设置</button>
+      <span class="nav-title">错误原因管理</span>
+    </div>
+
+    <div class="content-area" style="padding:20px;display:flex;gap:14px;">
+      <!-- 左侧操作菜单 -->
+      <div style="width:180px;flex-shrink:0;">
+        <div style="font-weight:bold;margin-bottom:12px;">操作菜单</div>
+        <button class="btn side-btn" @click="addRootCause">📂 新增顶级原因</button>
+        <button class="btn side-btn" @click="addSubCause">↳ 新增子原因</button>
+        <hr style="border:none;border-top:1px solid #CCCCCC;margin:14px 0;" />
+        <button class="btn side-btn" @click="editErrorCause">✎ 编辑名称</button>
+        <button class="btn side-btn" @click="toggleStatus">✅ 启用/停用</button>
+      </div>
+
+      <!-- 右侧树 -->
+      <div class="card" style="flex:1;min-height:400px;">
+        <ul class="cause-tree">
+          <li v-for="node in tree" :key="node.id">
+            <div class="node" :class="{ selected: selectedId === node.id, disabled: !node.enabled }"
+                 @click="selectedId = node.id">
+              {{ node.parent_id === null ? '📂' : '📄' }} {{ node.cause }}{{ node.enabled ? '' : ' (已停用)' }}
+            </div>
+            <ul v-if="node.children && node.children.length">
+              <li v-for="child in node.children" :key="child.id">
+                <div class="node" :class="{ selected: selectedId === child.id, disabled: !child.enabled }"
+                     @click="selectedId = child.id">
+                  📄 {{ child.cause }}{{ child.enabled ? '' : ' (已停用)' }}
+                </div>
+              </li>
+            </ul>
+          </li>
+        </ul>
+      </div>
+    </div>
+
+    <div style="padding:0 20px;color:#555;font-size:12px;">{{ statusText }}</div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { apiGet, apiPost, apiPut, tkPrompt, tkConfirm, tkAlert } from '../api'
+
+const rows = ref([])
+const selectedId = ref(null)
+const statusText = ref('当前状态: -')
+
+const tree = computed(() => {
+  const children = {}
+  const roots = []
+  for (const r of rows.value) {
+    if (r.parent_id === null) roots.push(r)
+    else {
+      if (!children[r.parent_id]) children[r.parent_id] = []
+      children[r.parent_id].push(r)
+    }
+  }
+  return roots.map(r => ({ ...r, children: children[r.id] || [] }))
+})
+
+const selectedNode = computed(() => rows.value.find(r => r.id === selectedId.value))
+
+function onSel() {
+  const n = selectedNode.value
+  if (n) {
+    const level = n.parent_id === null ? '顶级原因' : '子原因'
+    statusText.value = `选中: ${n.cause}  |  类型: ${level}  |  状态: ${n.enabled ? '启用' : '停用'}`
+  } else {
+    statusText.value = '当前状态: -'
+  }
+}
+
+async function loadData() {
+  const data = await apiGet('/settings/error-causes')
+  rows.value = data.rows
+}
+
+async function addRootCause() { await addCause(null) }
+
+async function addSubCause() {
+  if (selectedId.value === null) { tkAlert('请先选择一个【顶级原因】作为父级。'); return }
+  const node = selectedNode.value
+  if (node.parent_id !== null) {
+    tkAlert('最多只支持两级错误原因。\n您不能在子原因下继续添加。')
+    return
+  }
+  if (!node.enabled) { tkAlert('该父级原因已停用,无法添加子原因。'); return }
+  await addCause(node.id)
+}
+
+async function addCause(parentId) {
+  const title = parentId === null ? '新增顶级原因' : '新增子原因'
+  const name = tkPrompt(title, '请输入原因名称 (最多5个中文字符):')
+  if (name === null) return
+  const trimmed = name.trim()
+  if (!trimmed) return
+  try {
+    await apiPost('/settings/error-causes', { name: trimmed, parent_id: parentId })
+    await loadData()
+  } catch (e) { tkAlert(e.message) }
+}
+
+async function editErrorCause() {
+  if (selectedId.value === null) return
+  const node = selectedNode.value
+  const newName = tkPrompt('编辑名称', '修改名称:', node.cause)
+  if (newName === null) return
+  const trimmed = newName.trim()
+  if (!trimmed) return
+  try {
+    await apiPut(`/settings/error-causes/${selectedId.value}`, { name: trimmed })
+    await loadData()
+  } catch (e) { tkAlert(e.message) }
+}
+
+async function toggleStatus() {
+  if (selectedId.value === null) return
+  const node = selectedNode.value
+  const actionText = node.enabled ? '停用' : '启用'
+  try {
+    await apiPost(`/settings/error-causes/${selectedId.value}/toggle`)
+    await loadData()
+  } catch (e) {
+    if (e.needConfirm) {
+      if (tkConfirm(e.message)) {
+        await apiPost(`/settings/error-causes/${selectedId.value}/toggle-force`)
+        await loadData()
+      }
+    } else {
+      tkAlert(e.message)
+    }
+  }
+}
+
+import { watch } from 'vue'
+watch(selectedId, onSel)
+
+onMounted(loadData)
+</script>
+
+<style scoped>
+.side-btn { display: block; width: 100%; margin: 5px 0; font-size: 12px; }
+.cause-tree { list-style: none; padding-left: 10px; }
+.cause-tree ul { list-style: none; padding-left: 26px; }
+.node { padding: 5px 8px; cursor: pointer; border-radius: 3px; font-size: 13px; }
+.node:hover { background: #f0f6ff; }
+.node.selected { background: #B0D0F5; }
+.node.disabled { color: gray; }
+</style>
diff --git a/frontend/src/views/SettingsStatuses.vue b/frontend/src/views/SettingsStatuses.vue
new file mode 100644 (file)
index 0000000..978cba3
--- /dev/null
@@ -0,0 +1,137 @@
+<template>
+  <div>
+    <div class="nav-bar">
+      <button class="btn" @click="$router.push('/settings')">&lt; 返回设置</button>
+      <span class="nav-title">题目状态管理</span>
+    </div>
+
+    <div class="content-area" style="padding:20px;display:flex;gap:14px;">
+      <div style="width:180px;flex-shrink:0;">
+        <div style="font-weight:bold;margin-bottom:12px;">操作菜单</div>
+        <button class="btn side-btn" @click="addStatus">✚ 新增状态</button>
+        <button class="btn side-btn" @click="editStatus">✎ 编辑选中</button>
+        <hr style="border:none;border-top:1px solid #CCCCCC;margin:14px 0;" />
+        <button class="btn side-btn" @click="toggleStatus">✅ 启用/停用</button>
+      </div>
+
+      <div class="card" style="flex:1;">
+        <table class="data-table">
+          <thead>
+            <tr>
+              <th style="cursor:default;">ID</th>
+              <th style="cursor:default;">状态名称</th>
+              <th style="cursor:default;">状态描述</th>
+              <th style="cursor:default;">当前状态</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="r in rows" :key="r.id" class="clickable" :class="{selected: selectedId === r.id}" @click="selectedId = r.id" @dblclick="editStatus">
+              <td class="center">{{ r.id }}</td>
+              <td>{{ r.name }}</td>
+              <td>{{ r.description }}</td>
+              <td class="center">{{ r.enabled ? '✅ 启用' : '❌ 停用' }}</td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    <div style="padding:0 20px;color:#555;font-size:12px;">{{ infoText }}</div>
+
+    <!-- 新增/编辑 弹窗 -->
+    <div v-if="dialogOpen" class="modal-mask">
+      <div class="modal">
+        <div class="modal-header">{{ dialogMode === 'add' ? '新增题目状态' : '编辑题目状态' }}</div>
+        <div class="modal-body">
+          <div class="form-row"><label>状态名称:</label><input type="text" v-model="dName" style="width:220px;" /></div>
+          <div class="form-row"><label>状态描述:</label><input type="text" v-model="dDesc" style="width:220px;" /></div>
+        </div>
+        <div class="modal-footer2">
+          <button class="btn primary" @click="dialogMode === 'add' ? saveAdd() : saveEdit()">
+            {{ dialogMode === 'add' ? '保存' : '更新' }}
+          </button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { apiGet, apiPost, apiPut, tkConfirm, tkAlert } from '../api'
+
+const rows = ref([])
+const selectedId = ref(null)
+const infoText = ref('就绪')
+const dialogOpen = ref(false)
+const dialogMode = ref('add')
+const dName = ref('')
+const dDesc = ref('')
+
+async function loadData() {
+  const data = await apiGet('/settings/question-statuses')
+  rows.value = data.rows
+}
+
+function addStatus() {
+  dialogMode.value = 'add'
+  dName.value = ''
+  dDesc.value = ''
+  dialogOpen.value = true
+}
+
+async function saveAdd() {
+  if (!dName.value.trim()) { tkAlert('状态名称不能为空'); return }
+  if (dName.value.trim().length > 20) { tkAlert('状态名称过长(建议20字以内)'); return }
+  try {
+    await apiPost('/settings/question-statuses', { name: dName.value.trim(), description: dDesc.value.trim() })
+    dialogOpen.value = false
+    infoText.value = `成功添加状态: ${dName.value.trim()}`
+    await loadData()
+  } catch (e) { tkAlert(e.message) }
+}
+
+function editStatus() {
+  if (selectedId.value === null) { tkAlert('请先选择一行数据'); return }
+  const row = rows.value.find(r => r.id === selectedId.value)
+  dialogMode.value = 'edit'
+  dName.value = row.name
+  dDesc.value = row.description || ''
+  dialogOpen.value = true
+}
+
+async function saveEdit() {
+  if (!dName.value.trim()) { tkAlert('状态名称不能为空'); return }
+  try {
+    await apiPut(`/settings/question-statuses/${selectedId.value}`, { name: dName.value.trim(), description: dDesc.value.trim() })
+    dialogOpen.value = false
+    infoText.value = `成功修改状态: ${dName.value.trim()}`
+    await loadData()
+  } catch (e) { tkAlert(e.message) }
+}
+
+async function toggleStatus() {
+  if (selectedId.value === null) { tkAlert('请先选择一行数据'); return }
+  const row = rows.value.find(r => r.id === selectedId.value)
+  const actionText = row.enabled ? '停用' : '启用'
+  if (!tkConfirm(`确定要 ${actionText} 状态【${row.name}】吗?`)) return
+  try {
+    await apiPost(`/settings/question-statuses/${selectedId.value}/toggle`)
+    infoText.value = `已${actionText}: ${row.name}`
+    await loadData()
+  } catch (e) { tkAlert(e.message) }
+}
+
+onMounted(loadData)
+</script>
+
+<style scoped>
+.center { text-align: center; }
+.side-btn { display: block; width: 100%; margin: 5px 0; font-size: 12px; }
+.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,0.35); display: flex; align-items: center; justify-content: center; z-index: 100; }
+.modal { background: #fff; width: 400px; border-radius: 6px; box-shadow: 0 4px 20px rgba(0,0,0,0.25); }
+.modal-header { padding: 12px 16px; border-bottom: 1px solid #eee; font-weight: bold; }
+.modal-body { padding: 16px; }
+.modal-footer2 { padding: 0 16px 16px; text-align: center; }
+.form-row { display: flex; align-items: center; gap: 10px; margin: 10px 0; }
+</style>
diff --git a/frontend/src/views/SettingsSubjects.vue b/frontend/src/views/SettingsSubjects.vue
new file mode 100644 (file)
index 0000000..0300d58
--- /dev/null
@@ -0,0 +1,86 @@
+<template>
+  <div>
+    <div class="nav-bar">
+      <button class="btn" @click="$router.push('/settings')">&lt; 返回设置</button>
+      <span class="nav-title">学科管理</span>
+    </div>
+
+    <div class="content-area" style="padding:20px;">
+      <div class="card">
+        <div style="display:flex;gap:10px;margin-bottom:12px;">
+          <button class="btn" @click="addSubject">+ 新增学科</button>
+          <button class="btn" @click="editSubject">编辑名称</button>
+          <button class="btn" @click="toggleStatus">启用/停用</button>
+        </div>
+        <table class="data-table">
+          <thead>
+            <tr>
+              <th style="cursor:default;">ID</th>
+              <th style="cursor:default;">学科名称</th>
+              <th style="cursor:default;">状态</th>
+              <th style="cursor:default;">创建时间</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="r in rows" :key="r.id" class="clickable" :class="{selected: selectedId === r.id}" @click="selectedId = r.id">
+              <td class="center">{{ r.id }}</td>
+              <td>{{ r.subject }}</td>
+              <td class="center">{{ r.enabled ? '✅ 启用' : '❌ 停用' }}</td>
+              <td class="center">{{ r.create_time }}</td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { apiGet, apiPost, apiPut, tkPrompt, tkConfirm, tkAlert } from '../api'
+
+const rows = ref([])
+const selectedId = ref(null)
+
+async function loadData() {
+  const data = await apiGet('/settings/subjects')
+  rows.value = data.rows
+}
+
+async function addSubject() {
+  const name = tkPrompt('新增学科', '请输入学科名称 (最多10字):')
+  if (name === null || name === '') return
+  try {
+    await apiPost('/settings/subjects', { name })
+    await loadData()
+  } catch (e) { tkAlert(e.message) }
+}
+
+async function editSubject() {
+  if (selectedId.value === null) { tkAlert('请先选择一行数据'); return }
+  const row = rows.value.find(r => r.id === selectedId.value)
+  const newName = tkPrompt('编辑学科', '修改学科名称:', row.subject)
+  if (newName === null || newName === '') return
+  try {
+    await apiPut(`/settings/subjects/${selectedId.value}`, { name: newName })
+    await loadData()
+  } catch (e) { tkAlert(e.message) }
+}
+
+async function toggleStatus() {
+  if (selectedId.value === null) { tkAlert('请先选择一行数据'); return }
+  const row = rows.value.find(r => r.id === selectedId.value)
+  const actionText = row.enabled ? '停用' : '启用'
+  if (!tkConfirm(`确定要 ${actionText} 学科【${row.subject}】吗?`)) return
+  try {
+    await apiPost(`/settings/subjects/${selectedId.value}/toggle`)
+    await loadData()
+  } catch (e) { tkAlert(e.message) }
+}
+
+onMounted(loadData)
+</script>
+
+<style scoped>
+.center { text-align: center; }
+</style>
diff --git a/frontend/src/views/SubjectPortal.vue b/frontend/src/views/SubjectPortal.vue
new file mode 100644 (file)
index 0000000..586961f
--- /dev/null
@@ -0,0 +1,91 @@
+<template>
+  <div>
+    <div class="page-header">
+      <span class="title">科目概要面板</span>
+      <button class="btn" @click="$router.push('/')">返回首页</button>
+    </div>
+
+    <div class="content-area">
+      <div class="card">
+        <div class="row" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
+          <label>当前科目:</label>
+          <select v-model="currentSubject" @change="refreshData">
+            <option v-for="s in subjects" :key="s" :value="s">{{ s }}</option>
+          </select>
+
+          <label style="margin-left:20px;">统计阶段:</label>
+          <label v-for="st in stages" :key="st.id" style="font-size:13px;">
+            <input type="checkbox" v-model="stageSelected[st.id]" /> {{ st.name }}
+          </label>
+          <button class="btn" @click="refreshData">确定统计</button>
+        </div>
+      </div>
+
+      <div style="display:flex; gap:20px; flex-wrap:wrap;">
+        <div class="card stat-card" style="flex:1;min-width:360px;">
+          <div class="stat-title" style="color:#E64A19;">错题情况回顾</div>
+          <div class="stat-line"><span class="stat-label">错题总数:</span><span class="stat-val" style="color:#333;">{{ stats.wrong_total }}</span></div>
+          <div class="stat-line"><span class="stat-label">已解决数:</span><span class="stat-val" style="color:#2E7D32;">{{ stats.wrong_fixed }}</span></div>
+          <div class="stat-line"><span class="stat-label">解决比例:</span><span class="stat-val big" style="color:#1976D2;">{{ stats.wrong_ratio }}</span></div>
+          <div class="stat-line"><span class="stat-label">遗留题目数:</span><span class="stat-val" style="color:#D32F2F;">{{ stats.wrong_remained }}</span></div>
+        </div>
+        <div class="card stat-card" style="flex:1;min-width:360px;">
+          <div class="stat-title" style="color:#00796B;">强化推题追踪</div>
+          <div class="stat-line"><span class="stat-label">推题总数:</span><span class="stat-val" style="color:#333;">{{ stats.push_total }}</span></div>
+          <div class="stat-line"><span class="stat-label">已完成数:</span><span class="stat-val" style="color:#2E7D32;">{{ stats.push_fixed }}</span></div>
+          <div class="stat-line"><span class="stat-label">完成比例:</span><span class="stat-val big" style="color:#F57C00;">{{ stats.push_ratio }}</span></div>
+          <div class="stat-line"><span class="stat-label">遗留题目数:</span><span class="stat-val" style="color:#D32F2F;">{{ stats.push_remained }}</span></div>
+        </div>
+      </div>
+
+      <div style="display:flex;gap:15px;margin-top:20px;justify-content:center;flex-wrap:wrap;">
+        <button class="btn" style="background:#FF4D4F;color:#000;font-weight:bold;padding:10px 30px;font-size:14px;" @click="$router.push('/analysis/pending')">遗留分析</button>
+        <button class="btn" style="background:#1890FF;color:#000;font-weight:bold;padding:10px 30px;font-size:14px;" @click="$router.push('/analysis/chapters')">章节分析</button>
+        <button class="btn" style="background:#52C41A;color:#000;font-weight:bold;padding:10px 30px;font-size:14px;" @click="$router.push('/analysis/knowledge')">知识点分析</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { apiGet, apiPost } from '../api'
+
+const subjects = ref(['数学'])
+const stages = ref([])
+const stageSelected = ref({})
+const currentSubject = ref('数学')
+const stats = ref({
+  wrong_total: 0, wrong_fixed: 0, wrong_ratio: '0%', wrong_remained: 0,
+  push_total: 0, push_fixed: 0, push_ratio: '0%', push_remained: 0
+})
+
+async function refreshData() {
+  const stageIds = Object.entries(stageSelected.value)
+    .filter(([, v]) => v).map(([k]) => parseInt(k))
+  stats.value = await apiPost('/portal/stats', {
+    subject: currentSubject.value, stage_ids: stageIds
+  })
+}
+
+onMounted(async () => {
+  const opts = await apiGet('/portal/options')
+  subjects.value = opts.subjects
+  if (subjects.value.length && !subjects.value.includes(currentSubject.value)) {
+    currentSubject.value = subjects.value[0]
+  }
+  stages.value = opts.stages
+  const sel = {}
+  for (const st of opts.stages) sel[st.id] = true  // 默认全选
+  stageSelected.value = sel
+  await refreshData()
+})
+</script>
+
+<style scoped>
+.stat-title { font-size: 14px; font-weight: bold; margin-bottom: 10px; }
+.stat-line { display: flex; align-items: center; margin: 8px 0; }
+.stat-label { color: #666; font-size: 13px; }
+.stat-val { font-size: 24px; font-weight: bold; font-family: Helvetica, sans-serif; margin-left: 10px; }
+.stat-val.big { font-size: 32px; }
+</style>
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644 (file)
index 0000000..ddf2bf7
--- /dev/null
@@ -0,0 +1,20 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+export default defineConfig({
+  plugins: [vue()],
+  base: '/StudyBase/',
+  server: {
+    port: 5180,
+    proxy: {
+      '/StudyBase/api': {
+        target: 'http://127.0.0.1:1065',
+        changeOrigin: true
+      }
+    }
+  },
+  build: {
+    outDir: 'dist',
+    chunkSizeWarningLimit: 1500
+  }
+})
diff --git a/generate_report.py b/generate_report.py
deleted file mode 100644 (file)
index b21b5c4..0000000
+++ /dev/null
@@ -1,141 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-
-import os
-import sys
-import io
-
-# ================= 配置区域 =================
-# 输出文件名
-OUTPUT_FILENAME = "project_all_in_one.txt"
-
-# 需要排除的目录名(完全匹配),例如:['.git','gitea', 'node_modules', 'ZZ Old Version']
-EXCLUDE_DIRS = {".git", "__pycache__", "ZZ Old Version", "Media"}
-
-# 需要排除的文件后缀(不区分大小写),例如:['.exe', '.pyc', '.png', '.jpg']
-EXCLUDE_EXTS = {".exe", ".pyc", ".png", ".jpg", ".jpeg", ".gif", ".pdf", ".zip", ".tar", ".gz"}
-
-# 需要排除的具体文件名
-EXCLUDE_FILES = {OUTPUT_FILENAME, ".DS_Store", "gitea"}
-# ===========================================
-
-ROOT_DIR = os.path.abspath(os.path.dirname(__file__))
-
-def write_line(f, line=""):
-    f.write(line + "\n")
-
-def should_exclude(path):
-    """检查路径中是否包含任何被排除的目录"""
-    parts = path.split(os.sep)
-    for d in EXCLUDE_DIRS:
-        if d in parts:
-            return True
-    return False
-
-def collect_tree(root):
-    """生成目录结构树"""
-    lines = []
-    for dirpath, dirnames, filenames in os.walk(root):
-        # 过滤掉排除目录,修改 dirnames 会影响 os.walk 的后续遍历
-        dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
-        
-        rel = os.path.relpath(dirpath, root)
-        if rel == ".":
-            indent_level = 0
-            display_path = "."
-        else:
-            indent_level = rel.count(os.sep) + 1
-            display_path = rel.replace(os.sep, "/")
-
-        indent = "    " * indent_level
-        lines.append(f"{indent}{display_path}/")
-
-        for fn in sorted(filenames):
-            if fn in EXCLUDE_FILES:
-                continue
-            _, ext = os.path.splitext(fn)
-            if ext.lower() in EXCLUDE_EXTS:
-                continue
-            lines.append(f"{indent}    {fn}")
-    return lines
-
-def gather_all_files(root):
-    """收集所有符合条件的文件路径"""
-    matches = []
-    for dirpath, dirnames, filenames in os.walk(root):
-        # 过滤排除目录
-        dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
-        
-        for fn in filenames:
-            # 过滤排除的文件名和后缀
-            if fn in EXCLUDE_FILES:
-                continue
-            _, ext = os.path.splitext(fn)
-            if ext.lower() in EXCLUDE_EXTS:
-                continue
-                
-            full_path = os.path.join(dirpath, fn)
-            rel_path = os.path.relpath(full_path, root).replace(os.sep, "/")
-            matches.append((rel_path, full_path))
-            
-    matches.sort()
-    return matches
-
-def is_text_file(filepath):
-    """检查文件是否为文本文件"""
-    text_extensions = ['.txt', '.py', '.html', '.css', '.js', '.md']  # 可根据需要扩展
-    _, ext = os.path.splitext(filepath)
-    return ext.lower() in text_extensions
-
-def read_file_content(path):
-    """尝试多种编码读取文件内容,非文本或无法读取则跳过"""
-    encodings = ["utf-8-sig", "utf-8", "gbk", "latin-1"]  # 优先使用 utf-8-sig
-    for enc in encodings:
-        try:
-            with io.open(path, "r", encoding=enc, errors="strict") as f:
-                return f.read()
-        except (UnicodeDecodeError, Exception):
-            continue
-    return "[警告:该文件编码不兼容或为二进制格式,已跳过内容提取]"
-
-def generate_report(root, out_path):
-    with io.open(out_path, "w", encoding="utf-8-sig") as out:  # 使用 utf-8-sig 输出
-        # 第一部分:目录结构
-        write_line(out, "==================================================")
-        write_line(out, "第一部分:项目目录结构")
-        write_line(out, "==================================================")
-        tree_lines = collect_tree(root)
-        for ln in tree_lines:
-            write_line(out, ln)
-        write_line(out, "\n")
-
-        # 第二部分:文件内容整合
-        write_line(out, "==================================================")
-        write_line(out, "第二部分:文件内容整合")
-        write_line(out, "==================================================")
-        
-        all_files = gather_all_files(root)
-        if not all_files:
-            write_line(out, "[未找到符合条件的可处理文件]")
-            
-        for rel_path, full_path in all_files:
-            if is_text_file(full_path):  # 仅处理文本文件
-                write_line(out, f"\n>>> FILE START: {rel_path} <<<")
-                write_line(out, "=" * 40)
-                
-                content = read_file_content(full_path)
-                out.write(content)
-                
-                if not content.endswith("\n"):
-                    out.write("\n")
-                write_line(out, "=" * 40)
-                write_line(out, f">>> FILE END: {rel_path} <<<\n")
-
-if __name__ == "__main__":
-    try:
-        output_full_path = os.path.join(ROOT_DIR, OUTPUT_FILENAME)
-        generate_report(ROOT_DIR, output_full_path)
-        print(f"成功!整合后的文件已保存至:{OUTPUT_FILENAME}")
-    except Exception as e:
-        print(f"程序运行出错:{e}")
-        sys.exit(1)
diff --git a/project_all_in_one.txt b/project_all_in_one.txt
deleted file mode 100644 (file)
index 3045718..0000000
+++ /dev/null
@@ -1,1181 +0,0 @@
-==================================================
-第一部分:项目目录结构
-==================================================
-./
-    .gitignore
-    app.log
-    app.py
-    database.py
-    generate_report.py
-    custom/
-        custom/conf/
-            app.ini
-    templates/
-        index.html
-        templates/settings/
-            index.html
-        templates/questions/
-            index.html
-            question_list.html
-        templates/papers/
-            index.html
-        templates/subject/
-            chapters.html
-            index.html
-            knowledge.html
-    _clipboard_temp/
-    static/
-        static/css/
-            style.css
-    s/
-        schema.sql
-        studybase.db
-        studybase.sqbpro
-        workmode-studybase.sql
-
-
-==================================================
-第二部分:文件内容整合
-==================================================
-
->>> FILE START: app.py <<<
-========================================
-from flask import Flask, render_template, request, jsonify
-from database import query_db
-
-app = Flask(__name__)
-
-# ==================== 1. 核心页面路由 ====================
-
-@app.route('/')
-def index():
-    return render_template('index.html')
-
-# --- 题目列表模块 ---
-@app.route('/questions/')
-def questions_list():
-    return render_template('questions/index.html')
-
-# --- 试卷列表模块 ---
-@app.route('/papers/')
-def papers_list():
-    return render_template('papers/index.html')
-
-# --- 系统设定模块 ---
-@app.route('/settings/')
-def settings_page():
-    return render_template('settings/index.html')
-
-# --- 错题分析模块 (刚才修复的部分) ---
-@app.route('/subject/')
-def subject_portal():
-    subjects = query_db("SELECT ID, Subject as name FROM Subject WHERE enabled = 1")
-    stages = query_db("SELECT ID, stage_name FROM stage WHERE enabled = 1 ORDER BY ID")
-    default_id = subjects[0]['ID'] if subjects else None
-    return render_template('subject/index.html', subjects=subjects, stages=stages, default_id=default_id)
-
-@app.route('/subject/chapters')
-def subject_chapters():
-    subjects = query_db("SELECT DISTINCT Subject FROM Subject ORDER BY Subject")
-    return render_template('subject/chapters.html', subjects=subjects)
-
-@app.route('/subject/knowledge')
-def subject_knowledge():
-    subjects = query_db("SELECT DISTINCT Subject FROM Subject ORDER BY Subject")
-    chapter_id = request.args.get('chapter_id', '')
-    return render_template('subject/knowledge.html', subjects=subjects, chapter_id=chapter_id)
-
-
-# ==================== 2. API 数据接口 ====================
-
-# 科目概览统计
-@app.route('/api/subject_stats')
-def get_subject_stats():
-    sub_id = request.args.get('subject_id')
-    stage_ids_raw = request.args.get('stage_ids', '')
-    stage_ids = [sid for sid in stage_ids_raw.split(',') if sid]
-    
-    if not sub_id or not stage_ids:
-        return jsonify({"wrong": {"total": 0, "fixed": 0, "ratio": "0%", "remained": 0},
-                        "push": {"total": 0, "fixed": 0, "ratio": "0%", "remained": 0}})
-
-    placeholders = ','.join(['?'] * len(stage_ids))
-    def get_count(q_type, extra_sql=""):
-        sql = f"SELECT COUNT(*) as c FROM questions WHERE subject_id = ? AND stage_id IN ({placeholders}) AND question_type = ? {extra_sql}"
-        res = query_db(sql, [sub_id] + stage_ids + [q_type], one=True)
-        return res['c'] if res else 0
-
-    try:
-        w_total = get_count('错题'); w_fixed = get_count('错题', 'AND right_times > 0')
-        p_total = get_count('推题'); p_fixed = get_count('推题', 'AND right_times > 0')
-        return jsonify({
-            "wrong": {"total": w_total, "fixed": w_fixed, "ratio": f"{(w_fixed/w_total*100):.1f}%" if w_total>0 else "0%", "remained": w_total-w_fixed},
-            "push": {"total": p_total, "fixed": p_fixed, "ratio": f"{(p_fixed/p_total*100):.1f}%" if p_total>0 else "0%", "remained": p_total-p_fixed}
-        })
-    except Exception as e:
-        return jsonify({"error": str(e)}), 500
-
-# 章节数据接口 (已适配 chapter_desc)
-@app.route('/api/chapters')
-def get_chapters():
-    subject_name = request.args.get('subject', '全部')
-    keyword = request.args.get('keyword', '')
-    params = []
-    where = " WHERE 1=1"
-    if subject_name != '全部':
-        where += " AND s.Subject = ?"; params.append(subject_name)
-    if keyword:
-        where += " AND c.chapter_desc LIKE ?"; params.append(f"%{keyword}%")
-
-    sql = f"""
-    SELECT c.id AS chapter_id, s.Subject AS SubjectName, c.chapter_desc AS ChapterDesc,
-        (SELECT COUNT(DISTINCT r.question_id) FROM question_kp_relation r JOIN knowledge_point kp ON r.kp_id = kp.id WHERE kp.chapter_id = c.id) AS total,
-        (SELECT COUNT(DISTINCT r.question_id) FROM question_kp_relation r JOIN knowledge_point kp ON r.kp_id = kp.id JOIN questions q ON r.question_id = q.id WHERE kp.chapter_id = c.id AND q.right_times > 0) AS correct
-    FROM chapters c JOIN Subject s ON c.subject_id = s.ID {where}
-    """
-    try:
-        rows = query_db(sql, params)
-        result = [dict(row) for row in rows]
-        for item in result:
-            item['not_correct'] = (item['total'] or 0) - (item['correct'] or 0)
-        return jsonify([i for i in result if i['total'] > 0])
-    except Exception as e:
-        return jsonify({"error": str(e)}), 500
-
-# 知识点数据接口
-@app.route('/api/knowledge')
-def get_knowledge():
-    subject_name = request.args.get('subject', '全部')
-    keyword = request.args.get('keyword', '')
-    chapter_id = request.args.get('chapter_id', '')
-    params = []
-    where = " WHERE kp.is_enabled = 1"
-    if chapter_id:
-        where += " AND kp.chapter_id = ?"; params.append(chapter_id)
-    if subject_name != '全部':
-        where += " AND s.Subject = ?"; params.append(subject_name)
-    if keyword:
-        where += " AND kp.content LIKE ?"; params.append(f"%{keyword}%")
-
-    sql = f"""
-    SELECT kp.id, s.Subject as subject_name, kp.content,
-        (SELECT COUNT(*) FROM question_kp_relation r WHERE r.kp_id = kp.id) as total,
-        (SELECT COUNT(DISTINCT r.question_id) FROM question_kp_relation r JOIN questions q ON r.question_id = q.id WHERE r.kp_id = kp.id AND q.right_times >= 1) as correct
-    FROM knowledge_point kp JOIN Subject s ON kp.subject_id = s.ID {where}
-    """
-    try:
-        rows = query_db(sql, params)
-        result = [dict(row) for row in rows]
-        for item in result:
-            item['not_correct'] = (item['total'] or 0) - (item['correct'] or 0)
-        return jsonify(result)
-    except Exception as e:
-        return jsonify({"error": str(e)}), 500
-
-if __name__ == '__main__':
-    app.run(host='0.0.0.0', port=5000, debug=True)
-========================================
->>> FILE END: app.py <<<
-
-
->>> FILE START: database.py <<<
-========================================
-import sqlite3
-import os
-
-# 数据库文件的绝对路径
-DB_PATH = os.path.join(os.path.dirname(__file__), 's', 'studybase.db')
-
-def get_db_connection():
-    """获取数据库连接,并将结果集设置为字典模式(方便前端调用)"""
-    conn = sqlite3.connect(DB_PATH)
-    # 这行代码能让你通过 row['column_name'] 访问数据,而不是 row[0]
-    conn.row_factory = sqlite3.Row
-    return conn
-
-def query_db(query, args=(), one=False):
-    """通用的查询函数"""
-    conn = get_db_connection()
-    cur = conn.execute(query, args)
-    rv = cur.fetchall()
-    conn.close()
-    return (rv[0] if rv else None) if one else rv
-
-def execute_db(query, args=()):
-    """通用的增删改函数"""
-    conn = get_db_connection()
-    cur = conn.execute(query, args)
-    conn.commit()
-    conn.close()
-    return cur
-========================================
->>> FILE END: database.py <<<
-
-
->>> FILE START: generate_report.py <<<
-========================================
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-
-import os
-import sys
-import io
-
-# ================= 配置区域 =================
-# 输出文件名
-OUTPUT_FILENAME = "project_all_in_one.txt"
-
-# 需要排除的目录名(完全匹配),例如:['.git','gitea', 'node_modules', 'ZZ Old Version']
-EXCLUDE_DIRS = {".git", "__pycache__", "ZZ Old Version", "Media"}
-
-# 需要排除的文件后缀(不区分大小写),例如:['.exe', '.pyc', '.png', '.jpg']
-EXCLUDE_EXTS = {".exe", ".pyc", ".png", ".jpg", ".jpeg", ".gif", ".pdf", ".zip", ".tar", ".gz"}
-
-# 需要排除的具体文件名
-EXCLUDE_FILES = {OUTPUT_FILENAME, ".DS_Store", "gitea"}
-# ===========================================
-
-ROOT_DIR = os.path.abspath(os.path.dirname(__file__))
-
-def write_line(f, line=""):
-    f.write(line + "\n")
-
-def should_exclude(path):
-    """检查路径中是否包含任何被排除的目录"""
-    parts = path.split(os.sep)
-    for d in EXCLUDE_DIRS:
-        if d in parts:
-            return True
-    return False
-
-def collect_tree(root):
-    """生成目录结构树"""
-    lines = []
-    for dirpath, dirnames, filenames in os.walk(root):
-        # 过滤掉排除目录,修改 dirnames 会影响 os.walk 的后续遍历
-        dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
-        
-        rel = os.path.relpath(dirpath, root)
-        if rel == ".":
-            indent_level = 0
-            display_path = "."
-        else:
-            indent_level = rel.count(os.sep) + 1
-            display_path = rel.replace(os.sep, "/")
-
-        indent = "    " * indent_level
-        lines.append(f"{indent}{display_path}/")
-
-        for fn in sorted(filenames):
-            if fn in EXCLUDE_FILES:
-                continue
-            _, ext = os.path.splitext(fn)
-            if ext.lower() in EXCLUDE_EXTS:
-                continue
-            lines.append(f"{indent}    {fn}")
-    return lines
-
-def gather_all_files(root):
-    """收集所有符合条件的文件路径"""
-    matches = []
-    for dirpath, dirnames, filenames in os.walk(root):
-        # 过滤排除目录
-        dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
-        
-        for fn in filenames:
-            # 过滤排除的文件名和后缀
-            if fn in EXCLUDE_FILES:
-                continue
-            _, ext = os.path.splitext(fn)
-            if ext.lower() in EXCLUDE_EXTS:
-                continue
-                
-            full_path = os.path.join(dirpath, fn)
-            rel_path = os.path.relpath(full_path, root).replace(os.sep, "/")
-            matches.append((rel_path, full_path))
-            
-    matches.sort()
-    return matches
-
-def is_text_file(filepath):
-    """检查文件是否为文本文件"""
-    text_extensions = ['.txt', '.py', '.html', '.css', '.js', '.md']  # 可根据需要扩展
-    _, ext = os.path.splitext(filepath)
-    return ext.lower() in text_extensions
-
-def read_file_content(path):
-    """尝试多种编码读取文件内容,非文本或无法读取则跳过"""
-    encodings = ["utf-8-sig", "utf-8", "gbk", "latin-1"]  # 优先使用 utf-8-sig
-    for enc in encodings:
-        try:
-            with io.open(path, "r", encoding=enc, errors="strict") as f:
-                return f.read()
-        except (UnicodeDecodeError, Exception):
-            continue
-    return "[警告:该文件编码不兼容或为二进制格式,已跳过内容提取]"
-
-def generate_report(root, out_path):
-    with io.open(out_path, "w", encoding="utf-8-sig") as out:  # 使用 utf-8-sig 输出
-        # 第一部分:目录结构
-        write_line(out, "==================================================")
-        write_line(out, "第一部分:项目目录结构")
-        write_line(out, "==================================================")
-        tree_lines = collect_tree(root)
-        for ln in tree_lines:
-            write_line(out, ln)
-        write_line(out, "\n")
-
-        # 第二部分:文件内容整合
-        write_line(out, "==================================================")
-        write_line(out, "第二部分:文件内容整合")
-        write_line(out, "==================================================")
-        
-        all_files = gather_all_files(root)
-        if not all_files:
-            write_line(out, "[未找到符合条件的可处理文件]")
-            
-        for rel_path, full_path in all_files:
-            if is_text_file(full_path):  # 仅处理文本文件
-                write_line(out, f"\n>>> FILE START: {rel_path} <<<")
-                write_line(out, "=" * 40)
-                
-                content = read_file_content(full_path)
-                out.write(content)
-                
-                if not content.endswith("\n"):
-                    out.write("\n")
-                write_line(out, "=" * 40)
-                write_line(out, f">>> FILE END: {rel_path} <<<\n")
-
-if __name__ == "__main__":
-    try:
-        output_full_path = os.path.join(ROOT_DIR, OUTPUT_FILENAME)
-        generate_report(ROOT_DIR, output_full_path)
-        print(f"成功!整合后的文件已保存至:{OUTPUT_FILENAME}")
-    except Exception as e:
-        print(f"程序运行出错:{e}")
-        sys.exit(1)
-========================================
->>> FILE END: generate_report.py <<<
-
-
->>> FILE START: static/css/style.css <<<
-========================================
-/* 全局基础样式 */
-body {
-    margin: 0;
-    padding: 0;
-    background-color: #f0f2f5;
-    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-    -webkit-font-smoothing: antialiased;
-}
-
-/* 顶部导航栏基础样式 */
-.navbar {
-    height: 60px;
-    background: linear-gradient(135deg, #4088d8 0%, #2a69b5 100%);
-    display: flex;
-    justify-content: center;
-    align-items: center;
-    color: white;
-    font-size: 22px;
-    font-weight: bold;
-    box-shadow: 0 2px 10px rgba(0,0,0,0.1);
-    position: sticky;
-    top: 0;
-    z-index: 1000;
-}
-
-/* 子页面特定的 Header 颜色 - 对应首页按钮色系 */
-.header-subject { background: linear-gradient(135deg, #0066cc 0%, #004499 100%) !important; }
-.header-question { background: linear-gradient(135deg, #009944 0%, #007733 100%) !important; }
-.header-paper { background: linear-gradient(135deg, #e67e22 0%, #d35400 100%) !important; }
-.header-setting { background: linear-gradient(135deg, #8e44ad 0%, #732d91 100%) !important; }
-
-/* 主体容器 - 响应式 Flex 布局 */
-.container {
-    display: flex;
-    flex-wrap: wrap;      /* 核心:允许在空间不足时换行 */
-    flex-direction: row; 
-    justify-content: center;
-    align-items: center;
-    gap: 20px;
-    padding: 40px 20px;
-    max-width: 1200px;
-    margin: 0 auto;
-}
-
-/* 详情页容器 */
-.sub-container {
-    padding: 30px 20px;
-    text-align: center;
-    max-width: 800px;
-    margin: 0 auto;
-}
-
-/* 卡片按钮基础样式 */
-.card {
-    flex: 1;               /* 自动伸缩 */
-    min-width: 240px;      /* 保证电脑端不会太窄 */
-    max-width: 280px;      /* 保证电脑端不会太宽 */
-    height: 85px;
-    background-color: white;
-    border-radius: 15px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    font-size: 18px;
-    font-weight: 600;
-    cursor: pointer;
-    transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
-    text-decoration: none;
-    box-shadow: 0 4px 12px rgba(0,0,0,0.08);
-    border: 1px solid rgba(0,0,0,0.05);
-}
-
-.card:hover {
-    transform: translateY(-5px);
-    box-shadow: 0 12px 20px rgba(0,0,0,0.15);
-}
-
-/* 四个模块的丰富配色方案 */
-.card-subject { color: #0066cc; border-left: 8px solid #0066cc; }
-.card-subject:hover { background-color: #f0f7ff; }
-
-.card-question { color: #009944; border-left: 8px solid #009944; }
-.card-question:hover { background-color: #f0fff4; }
-
-.card-paper { color: #e67e22; border-left: 8px solid #e67e22; }
-.card-paper:hover { background-color: #fffaf5; }
-
-.card-setting { color: #8e44ad; border-left: 8px solid #8e44ad; }
-.card-setting:hover { background-color: #f9f5fb; }
-
-/* 图标/数字样式 */
-.icon-num {
-    margin-right: 12px;
-    font-size: 22px;
-    font-weight: bold;
-    opacity: 0.7;
-}
-
-/* 返回按钮样式 */
-.back-btn {
-    display: inline-block;
-    margin-top: 30px;
-    padding: 12px 30px;
-    background: #666;
-    color: white;
-    text-decoration: none;
-    border-radius: 25px;
-    font-weight: 500;
-    transition: background 0.3s;
-}
-.back-btn:hover {
-    background: #333;
-}
-
-/* === 移动端适配 (iPhone 等) === */
-@media (max-width: 600px) {
-    .navbar {
-        font-size: 18px;
-        height: 50px;
-    }
-
-    .container {
-        padding: 20px 15px;
-        gap: 15px;
-    }
-
-    .card {
-        /* 在手机上,让按钮占据几乎全部宽度,防止并排重叠 */
-        min-width: 100%;
-        max-width: 100%;
-        height: 75px;
-        font-size: 17px;
-        border-left-width: 10px; /* 手机上加粗色块更醒目 */
-    }
-
-    .sub-container h2 {
-        font-size: 20px;
-    }
-}
-========================================
->>> FILE END: static/css/style.css <<<
-
-
->>> FILE START: templates/index.html <<<
-========================================
-<!DOCTYPE html>
-<html lang="zh">
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>错题智能整理系统</title>
-    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
-</head>
-<body>
-    <div class="navbar">错题智能整理系统</div>
-
-    <div class="container">
-        <a href="/subject/" class="card card-subject"><span class="icon-num">①</span> 科目面板</a>
-        <a href="/questions/" class="card card-question"><span class="icon-num">②</span> 题目管理</a>
-        <a href="/papers/" class="card card-paper"><span class="icon-num">③</span> 试卷管理</a>
-        <a href="/settings/" class="card card-setting"><span class="icon-num">④</span> 系统设定</a>
-    </div>
-</body>
-</html>
-========================================
->>> FILE END: templates/index.html <<<
-
-
->>> FILE START: templates/papers/index.html <<<
-========================================
-<!DOCTYPE html>
-<html lang="zh">
-<head>
-    <meta charset="UTF-8">
-    <title>试卷管理 - 错题系统</title>
-    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
-    <style>
-        .sub-container { padding: 50px; text-align: center; }
-        .back-btn { display: inline-block; margin-top: 20px; padding: 10px 20px; background: #666; color: white; text-decoration: none; border-radius: 5px; }
-    </style>
-</head>
-<body>
-    <div class="navbar header-paper">试卷管理</div>
-    <div class="sub-container">
-        <h2>这里是试卷管理内容区域</h2>
-        <p>正在从原 Python 程序迁移逻辑...</p>
-        <a href="/" class="back-btn">返回首页</a>
-    </div>
-</body>
-</html>
-========================================
->>> FILE END: templates/papers/index.html <<<
-
-
->>> FILE START: templates/questions/index.html <<<
-========================================
-<!DOCTYPE html>
-<html lang="zh">
-<head>
-    <meta charset="UTF-8">
-    <title>科目面板 - 错题系统</title>
-    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
-    <style>
-        .sub-container { padding: 50px; text-align: center; }
-        .back-btn { display: inline-block; margin-top: 20px; padding: 10px 20px; background: #666; color: white; text-decoration: none; border-radius: 5px; }
-    </style>
-</head>
-<body>
-    <div class="navbar header-question">科目面板</div>
-    <div class="sub-container">
-        <h2>这里是科目面板内容区域</h2>
-        <p>正在从原 Python 程序迁移逻辑...</p>
-        <a href="/" class="back-btn">返回首页</a>
-    </div>
-</body>
-</html>
-========================================
->>> FILE END: templates/questions/index.html <<<
-
-
->>> FILE START: templates/questions/question_list.html <<<
-========================================
-<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>试题列表</title>
-    <link rel="stylesheet" href="/static/css/style.css">
-    <style>
-        body {
-            font-family: 'Microsoft YaHei', sans-serif;
-            background-color: #F0F0F0;
-        }
-        .header {
-            background-color: #4A90E2;
-            color: white;
-            padding: 15px;
-            text-align: center;
-            font-size: 24px;
-        }
-        .filter-panel {
-            margin: 20px;
-            background: white;
-            padding: 15px;
-            border-radius: 8px;
-            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
-        }
-        .filter-panel label {
-            margin-right: 10px;
-        }
-        .table-container {
-            margin: 20px;
-            background: white;
-            border-radius: 8px;
-            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
-        }
-        table {
-            width: 100%;
-            border-collapse: collapse;
-        }
-        th, td {
-            padding: 12px;
-            text-align: left;
-            border-bottom: 1px solid #ddd;
-        }
-        th {
-            background-color: #f2f2f2;
-        }
-    </style>
-</head>
-<body>
-    <div class="header">试题列表</div>
-    <div class="filter-panel">
-        <label for="subjectFilter">科目:</label>
-        <select id="subjectFilter">
-            <option value="全部">全部</option>
-            <!-- 动态加载科目选项 -->
-            {% for subject in subjects %}
-            <option value="{{ subject }}">{{ subject }}</option>
-            {% endfor %}
-        </select>
-        
-        <label for="questionTypeFilter">题目类型:</label>
-        <select id="questionTypeFilter">
-            <option value="全部">全部</option>
-            <option value="错题">错题</option>
-            <option value="推题">推题</option>
-        </select>
-        
-        <label for="answerStatusFilter">答题状态:</label>
-        <select id="answerStatusFilter">
-            <option value="全部">全部</option>
-            <option value="未完成">未完成</option>
-            <option value="正确">正确</option>
-            <option value="错误">错误</option>
-            <option value="部分正确">部分正确</option>
-        </select>
-        
-        <button id="searchButton">搜索</button>
-    </div>
-
-    <div class="table-container">
-        <table id="questionTable">
-            <thead>
-                <tr>
-                    <th>ID</th>
-                    <th>科目</th>
-                    <th>题目类型</th>
-                    <th>题干内容</th>
-                    <th>答案解析</th>
-                    <th>答题时间</th>
-                    <th>输入时间</th>
-                    <th>题目知识点</th>
-                </tr>
-            </thead>
-            <tbody id="questionBody">
-                <tr>
-                    <td colspan="8">正在加载数据...</td>
-                </tr>
-            </tbody>
-        </table>
-    </div>
-
-    <script>
-        async function loadQuestionData() {
-            const subject = document.getElementById('subjectFilter').value;
-            const questionType = document.getElementById('questionTypeFilter').value;
-            const answerStatus = document.getElementById('answerStatusFilter').value;
-
-            const response = await fetch(`/api/question_list?subject=${subject}&question_type=${questionType}&answer_status=${answerStatus}`);
-            const data = await response.json();
-
-            const questionBody = document.getElementById('questionBody');
-            questionBody.innerHTML = '';
-
-            if (data.error) {
-                questionBody.innerHTML = '<tr><td colspan="8">加载失败: ' + data.error + '</td></tr>';
-                return;
-            }
-
-            data.forEach(question => {
-                const row = document.createElement('tr');
-                row.innerHTML = `
-                    <td>${question.id}</td>
-                    <td>${question.Subject || '-'}</td>
-                    <td>${question.question_type || '-'}</td>
-                    <td>${question.content || '-'}</td>
-                    <td>${question.analysis || '-'}</td>
-                    <td>${question.answer_time || '-'}</td>
-                    <td>${question.input_time || '-'}</td>
-                    <td>${question.knowledge_points || '-'}</td>
-                `;
-                questionBody.appendChild(row);
-            });
-        }
-
-        document.getElementById('searchButton').addEventListener('click', loadQuestionData);
-        document.addEventListener('DOMContentLoaded', loadQuestionData);
-    </script>
-</body>
-</html>
-========================================
->>> FILE END: templates/questions/question_list.html <<<
-
-
->>> FILE START: templates/settings/index.html <<<
-========================================
-<!DOCTYPE html>
-<html lang="zh">
-<head>
-    <meta charset="UTF-8">
-    <title>系统设定 - 错题系统</title>
-    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
-    <style>
-        .sub-container { padding: 50px; text-align: center; }
-        .back-btn { display: inline-block; margin-top: 20px; padding: 10px 20px; background: #666; color: white; text-decoration: none; border-radius: 5px; }
-    </style>
-</head>
-<body>
-    <div class="navbar header-setting">系统设定</div>
-    <div class="sub-container">
-        <h2>这里是系统设定内容区域</h2>
-        <p>正在从原 Python 程序迁移逻辑...</p>
-        <a href="/" class="back-btn">返回首页</a>
-    </div>
-</body>
-</html>
-========================================
->>> FILE END: templates/settings/index.html <<<
-
-
->>> FILE START: templates/subject/chapters.html <<<
-========================================
-<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
-    <title>章节分析</title>
-    <link rel="stylesheet" href="/static/css/style.css">
-    <style>
-        .filter-bar { background: white; padding: 12px; margin-bottom: 15px; border-radius: 12px; display: flex; gap: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); align-items: center; }
-        /* 缩短科目选择框 */
-        #subjectFilter { width: 90px; flex: none; padding: 8px 4px; border: 1px solid #ddd; border-radius: 6px; }
-        #keyword { flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 6px; min-width: 0; }
-        .btn-query { background: #4A90E2; color: white; border: none; padding: 8px 15px; border-radius: 6px; cursor: pointer; white-space: nowrap; }
-        /* 新增:筛选栏中的返回按钮 */
-        .btn-back-nav { background: #f0f0f0; color: #666; border: none; padding: 8px 12px; border-radius: 6px; cursor: pointer; white-space: nowrap; text-decoration: none; font-size: 14px; }
-        
-        .table-container { background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.05); margin-bottom: 50px; }
-        table { width: 100%; border-collapse: collapse; }
-        th, td { padding: 12px 10px; text-align: left; border-bottom: 1px solid #f0f0f0; font-size: 14px; }
-        th { background-color: #f8f9fa; color: #666; font-weight: 600; cursor: pointer; position: relative; }
-        th:after { content: '↕'; font-size: 10px; margin-left: 4px; color: #ccc; }
-        .clickable { color: #4A90E2; cursor: pointer; font-weight: bold; }
-        .num-badge { font-weight: bold; color: #d32f2f; background: #fff1f0; padding: 2px 6px; border-radius: 4px; }
-    </style>
-</head>
-<body>
-    <div class="navbar header-subject"><span class="nav-title">章节分析</span></div>
-    <div class="sub-container">
-        <div class="filter-bar">
-            <select id="subjectFilter">
-                <option value="全部">全部</option>
-                {% for sub in subjects %}<option value="{{ sub.Subject }}">{{ sub.Subject }}</option>{% endfor %}
-            </select>
-            <input type="text" id="keyword" placeholder="搜索章节...">
-            <button class="btn-query" onclick="loadData()">查询</button>
-            <a href="/subject/" class="btn-back-nav">返回</a>
-        </div>
-
-        <div class="table-container">
-            <table>
-                <thead>
-                    <tr>
-                        <th onclick="sortTable('SubjectName')">科目</th>
-                        <th onclick="sortTable('ChapterDesc')">章节</th>
-                        <th onclick="sortTable('total')">总数</th>
-                        <th onclick="sortTable('not_correct')">待完成</th>
-                    </tr>
-                </thead>
-                <tbody id="chapterBody"></tbody>
-            </table>
-        </div>
-    </div>
-
-    <script>
-        let currentData = [];
-        let sortDirection = -1;
-
-        async function loadData() {
-            const sub = document.getElementById('subjectFilter').value;
-            const key = document.getElementById('keyword').value;
-            const tbody = document.getElementById('chapterBody');
-            tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;padding:20px;">加载中...</td></tr>';
-            try {
-                const response = await fetch('/api/chapters?subject=' + encodeURIComponent(sub) + '&keyword=' + encodeURIComponent(key));
-                currentData = await response.json();
-                currentData.sort((a, b) => b.not_correct - a.not_correct);
-                renderTable(currentData);
-            } catch (err) {
-                tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:red;">加载失败</td></tr>';
-            }
-        }
-
-        function sortTable(column) {
-            sortDirection *= -1;
-            currentData.sort((a, b) => {
-                let valA = a[column] || '';
-                let valB = b[column] || '';
-                return typeof valA === 'string' 
-                    ? sortDirection === 1 ? valA.localeCompare(valB) : valB.localeCompare(valA)
-                    : sortDirection === 1 ? valA - valB : valB - valA;
-            });
-            renderTable(currentData);
-        }
-
-        function renderTable(data) {
-            const tbody = document.getElementById('chapterBody');
-            if (!data || data.length === 0) {
-                tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;padding:20px;">暂无数据</td></tr>';
-                return;
-            }
-            tbody.innerHTML = data.map(row => `
-                <tr>
-                    <td>${row.SubjectName || '-'}</td>
-                    <td class="clickable" onclick="location.href='/subject/knowledge?chapter_id=${row.chapter_id}'">${row.ChapterDesc || '未命名'}</td>
-                    <td>${row.total || 0}</td>
-                    <td><span class="num-badge">${row.not_correct || 0}</span></td>
-                </tr>
-            `).join('');
-        }
-        window.onload = loadData;
-    </script>
-</body>
-</html>
-========================================
->>> FILE END: templates/subject/chapters.html <<<
-
-
->>> FILE START: templates/subject/index.html <<<
-========================================
-<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
-    <title>科目概要面板</title>
-    <link rel="stylesheet" href="/static/css/style.css">
-    <style>
-        /* 针对统计页面的微调 */
-        .stats-grid {
-            display: grid;
-            grid-template-columns: 1fr 1fr;
-            gap: 15px;
-            margin-bottom: 20px;
-        }
-        @media (max-width: 600px) {
-            .stats-grid { grid-template-columns: 1fr; }
-        }
-        .stat-card {
-            background: white;
-            padding: 15px;
-            border-radius: 12px;
-            box-shadow: 0 4px 10px rgba(0,0,0,0.05);
-            border-top: 4px solid #4A90E2;
-        }
-        .stat-card.push { border-top-color: #52C41A; }
-        .stat-card h3 { margin: 0 0 10px 0; font-size: 16px; color: #333; }
-        .stat-line { display: flex; justify-content: space-between; margin: 8px 0; font-size: 14px; color: #666; }
-        .stat-value { font-weight: bold; color: #333; }
-        .stat-ratio { 
-            font-size: 24px; 
-            font-weight: bold; 
-            color: #4A90E2; 
-            text-align: center; 
-            margin: 10px 0;
-            display: block;
-        }
-        .push .stat-ratio { color: #52C41A; }
-        
-        .filter-section {
-            background: #fff;
-            padding: 15px;
-            border-radius: 12px;
-            margin-bottom: 20px;
-        }
-        .stage-chips {
-            display: flex;
-            flex-wrap: wrap;
-            gap: 8px;
-            margin-top: 10px;
-        }
-        .stage-chip {
-            padding: 5px 12px;
-            border-radius: 15px;
-            background: #f0f2f5;
-            font-size: 13px;
-            cursor: pointer;
-            border: 1px solid #ddd;
-        }
-        .stage-chip.active {
-            background: #4A90E2;
-            color: white;
-            border-color: #4A90E2;
-        }
-        .analysis-btn-group {
-            display: flex;
-            gap: 15px;
-            margin: 20px 0;
-        }
-        .analysis-btn-group .card {
-            flex: 1;
-            height: 70px;
-            margin: 0;
-            display: flex;
-            align-items: center;
-            justify-content: center;
-            font-size: 16px;
-            font-weight: bold;
-        }
-    </style>
-</head>
-<body>
-    <!-- 顶部导航 -->
-    <div class="navbar header-subject">
-        <span class="nav-title">科目概要面板</span>
-    </div>
-
-    <div class="sub-container">
-        <!-- 1. 筛选区 -->
-        <div class="filter-section">
-            <div style="margin-bottom: 15px;">
-                <label style="font-weight: bold;">当前科目:</label>
-                <select id="subjectSelect" style="width: 100%; padding: 10px; margin-top: 5px; border-radius: 8px; border: 1px solid #ddd;">
-                    {% for sub in subjects %}
-                    <option value="{{ sub.ID }}" {% if sub.ID == default_id %}selected{% endif %}>{{ sub.name }}</option>
-                    {% endfor %}
-                </select>
-            </div>
-            
-            <label style="font-weight: bold;">统计阶段:</label>
-            <div class="stage-chips" id="stageContainer">
-                {% for stage in stages %}
-                <div class="stage-chip active" data-id="{{ stage.ID }}" onclick="toggleStage(this)">
-                    {{ stage.stage_name }}
-                </div>
-                {% endfor %}
-            </div>
-        </div>
-
-        <!-- 2. 统计卡片区 -->
-        <div class="stats-grid">
-            <!-- 错题卡片 -->
-            <div class="stat-card">
-                <h3>错题情况回顾</h3>
-                <span class="stat-ratio" id="wrong_ratio">0%</span>
-                <div class="stat-line"><span>总数</span><span class="stat-value" id="wrong_total">0</span></div>
-                <div class="stat-line"><span>已解决</span><span class="stat-value" id="wrong_fixed" style="color: #2E7D32;">0</span></div>
-                <div class="stat-line"><span>遗留</span><span class="stat-value" id="wrong_remained" style="color: #D32F2F;">0</span></div>
-            </div>
-
-            <!-- 推题卡片 -->
-            <div class="stat-card push">
-                <h3>强化推题追踪</h3>
-                <span class="stat-ratio" id="push_ratio">0%</span>
-                <div class="stat-line"><span>总数</span><span class="stat-value" id="push_total">0</span></div>
-                <div class="stat-line"><span>已完成</span><span class="stat-value" id="push_fixed" style="color: #2E7D32;">0</span></div>
-                <div class="stat-line"><span>遗留</span><span class="stat-value" id="push_remained" style="color: #D32F2F;">0</span></div>
-            </div>
-        </div>
-
-        <!-- 3. 深入分析按钮区 -->
-        <div class="analysis-btn-group">
-            <a href="/subject/chapters" class="card card-subject">
-                📊 章节分析
-            </a>
-            <a href="/subject/knowledge" class="card card-question">
-                💡 知识点分析
-            </a>
-        </div>
-
-        <!-- 4. 返回按钮 -->
-        <div style="text-align: center; margin-top: 30px;">
-            <a href="/" class="back-btn">返回首页</a>
-        </div>
-    </div>
-
-    <script>
-        // 切换阶段选中状态
-        function toggleStage(el) {
-            el.classList.toggle('active');
-            refreshStats();
-        }
-
-        // 获取当前选中的阶段ID数组
-        function getSelectedStages() {
-            const actives = document.querySelectorAll('.stage-chip.active');
-            return Array.from(actives).map(el => el.dataset.id);
-        }
-
-        // 异步刷新数据
-        async function refreshStats() {
-            const subId = document.getElementById('subjectSelect').value;
-            const stages = getSelectedStages().join(',');
-            
-            if (!stages) {
-                updateUI(null);
-                return;
-            }
-
-            try {
-                const response = await fetch(`/api/subject_stats?subject_id=${subId}&stage_ids=${stages}`);
-                const data = await response.json();
-                updateUI(data);
-            } catch (error) {
-                console.error("加载数据失败:", error);
-            }
-        }
-
-        function updateUI(data) {
-            if (!data) {
-                const keys = ['wrong_total', 'wrong_fixed', 'wrong_remained', 'wrong_ratio', 
-                             'push_total', 'push_fixed', 'push_remained', 'push_ratio'];
-                keys.forEach(k => document.getElementById(k).innerText = k.includes('ratio') ? '0%' : '0');
-                return;
-            }
-            
-            document.getElementById('wrong_total').innerText = data.wrong.total;
-            document.getElementById('wrong_fixed').innerText = data.wrong.fixed;
-            document.getElementById('wrong_remained').innerText = data.wrong.remained;
-            document.getElementById('wrong_ratio').innerText = data.wrong.ratio;
-
-            document.getElementById('push_total').innerText = data.push.total;
-            document.getElementById('push_fixed').innerText = data.push.fixed;
-            document.getElementById('push_remained').innerText = data.push.remained;
-            document.getElementById('push_ratio').innerText = data.push.ratio;
-        }
-
-        // 监听科目切换
-        document.getElementById('subjectSelect').addEventListener('change', refreshStats);
-
-        // 页面加载时自动执行一次
-        window.onload = refreshStats;
-    </script>
-</body>
-</html>
-========================================
->>> FILE END: templates/subject/index.html <<<
-
-
->>> FILE START: templates/subject/knowledge.html <<<
-========================================
-<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
-    <title>知识点分析</title>
-    <link rel="stylesheet" href="/static/css/style.css">
-    <style>
-        .filter-bar { background: white; padding: 12px; margin-bottom: 15px; border-radius: 12px; display: flex; gap: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); align-items: center; }
-        #subjectFilter { width: 90px; flex: none; padding: 8px 4px; border: 1px solid #ddd; border-radius: 6px; }
-        #keyword { flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 6px; min-width: 0; }
-        .btn-query { background: #4A90E2; color: white; border: none; padding: 8px 15px; border-radius: 6px; cursor: pointer; white-space: nowrap; }
-        .btn-back-nav { background: #f0f0f0; color: #666; border: none; padding: 8px 12px; border-radius: 6px; cursor: pointer; white-space: nowrap; text-decoration: none; font-size: 14px; }
-        
-        .table-container { background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.05); margin-bottom: 50px; }
-        table { width: 100%; border-collapse: collapse; }
-        th, td { padding: 12px 10px; text-align: left; border-bottom: 1px solid #f0f0f0; font-size: 14px; }
-        th { background-color: #f8f9fa; color: #666; font-weight: 600; cursor: pointer; position: relative; }
-        th:after { content: '↕'; font-size: 10px; margin-left: 4px; color: #ccc; }
-        .num-badge { font-weight: bold; color: #d32f2f; background: #fff1f0; padding: 2px 6px; border-radius: 4px; }
-    </style>
-</head>
-<body>
-    <div class="navbar header-subject"><span class="nav-title">知识点分析</span></div>
-    <div class="sub-container">
-        <div class="filter-bar">
-            <select id="subjectFilter">
-                <option value="全部">全部</option>
-                {% for sub in subjects %}<option value="{{ sub.Subject }}">{{ sub.Subject }}</option>{% endfor %}
-            </select>
-            <input type="text" id="keyword" placeholder="搜索知识点...">
-            <button class="btn-query" onclick="loadData()">查询</button>
-            <a href="/subject/chapters" class="btn-back-nav">返回</a>
-        </div>
-
-        <div class="table-container">
-            <table>
-                <thead>
-                    <tr>
-                        <th onclick="sortTable('subject_name')">科目</th>
-                        <th onclick="sortTable('content')">知识点内容</th>
-                        <th onclick="sortTable('total')">总数</th>
-                        <th onclick="sortTable('not_correct')">待完成</th>
-                    </tr>
-                </thead>
-                <tbody id="knowledgeBody"></tbody>
-            </table>
-        </div>
-    </div>
-
-    <script>
-        let currentData = [];
-        let sortDirection = -1;
-        const chapterId = "{{ chapter_id }}";
-
-        async function loadData() {
-            const sub = document.getElementById('subjectFilter').value;
-            const key = document.getElementById('keyword').value;
-            const tbody = document.getElementById('knowledgeBody');
-            tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;padding:20px;">加载中...</td></tr>';
-            try {
-                const response = await fetch(`/api/knowledge?subject=${encodeURIComponent(sub)}&keyword=${encodeURIComponent(key)}&chapter_id=${chapterId}`);
-                currentData = await response.json();
-                currentData.sort((a, b) => b.not_correct - a.not_correct);
-                renderTable(currentData);
-            } catch (err) {
-                tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:red;">加载失败</td></tr>';
-            }
-        }
-
-        function sortTable(column) {
-            sortDirection *= -1;
-            currentData.sort((a, b) => {
-                let valA = a[column] || '';
-                let valB = b[column] || '';
-                return typeof valA === 'string'
-                    ? sortDirection === 1 ? valA.localeCompare(valB) : valB.localeCompare(valA)
-                    : sortDirection === 1 ? valA - valB : valB - valA;
-            });
-            renderTable(currentData);
-        }
-
-        function renderTable(data) {
-            const tbody = document.getElementById('knowledgeBody');
-            if (!data || data.length === 0) {
-                tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;padding:20px;">暂无数据</td></tr>';
-                return;
-            }
-            tbody.innerHTML = data.map(row => `
-                <tr>
-                    <td>${row.subject_name || '-'}</td>
-                    <td>${row.content || '-'}</td>
-                    <td>${row.total || 0}</td>
-                    <td><span class="num-badge">${row.not_correct || 0}</span></td>
-                </tr>
-            `).join('');
-        }
-        window.onload = loadData;
-    </script>
-</body>
-</html>
-========================================
->>> FILE END: templates/subject/knowledge.html <<<
-
diff --git a/s/schema.sql b/s/schema.sql
deleted file mode 100755 (executable)
index 825d267..0000000
+++ /dev/null
@@ -1,139 +0,0 @@
-CREATE TABLE Subject (
-  ID INTEGER PRIMARY KEY,
-  Subject TEXT NOT NULL CHECK(length(Subject) <= 10),
-  CreateTime DATETIME DEFAULT (datetime('now','localtime'))
-, enabled BOOLEAN DEFAULT 1);
-CREATE TABLE Error_Cause (
-    ID INTEGER PRIMARY KEY AUTOINCREMENT,
-    Cause TEXT NOT NULL,
-    Enabled BOOLEAN DEFAULT 1, -- 默认为启用状态
-    ParentID INTEGER,
-    FOREIGN KEY (ParentID) REFERENCES Error_Cause(ID) ON DELETE CASCADE
-);
-CREATE TABLE sqlite_sequence(name,seq);
-CREATE TABLE Question_Status (
-                    ID INTEGER PRIMARY KEY AUTOINCREMENT,
-                    StatusName TEXT NOT NULL,
-                    Description TEXT,
-                    Enabled BOOLEAN DEFAULT 1
-                );
-CREATE TABLE question_source (
-    id INTEGER PRIMARY KEY AUTOINCREMENT,      -- 来源ID,自动递增
-    source_description TEXT NOT NULL,          -- 来源描述
-    is_enabled BOOLEAN DEFAULT 1              -- 是否启用,默认为 1 (true)
-);
-CREATE TABLE questions_new (
-    id INTEGER PRIMARY KEY AUTOINCREMENT,
-    subject_id INTEGER,
-    status_id INTEGER,
-    error_cause_id INTEGER,
-    source_id INTEGER,
-    question_type TEXT,
-    content TEXT NOT NULL,
-    analysis TEXT,
-    score REAL,                                  -- 修改点:允许为空,无默认值
-    is_subjective INTEGER DEFAULT 0,
-    has_subquestions BOOLEAN DEFAULT 0,
-    subquestion_count INTEGER DEFAULT 0,
-    subquestion_description TEXT DEFAULT '',
-    subquestion_correct_count INTEGER DEFAULT 0,
-    input_time DATETIME DEFAULT CURRENT_TIMESTAMP,
-    answer_time DATETIME,
-    FOREIGN KEY (subject_id) REFERENCES subject(id),
-    FOREIGN KEY (status_id) REFERENCES Question_status(id),
-    FOREIGN KEY (error_cause_id) REFERENCES Error_cause(id),
-    FOREIGN KEY (source_id) REFERENCES question_source(id)
-);
-CREATE TABLE IF NOT EXISTS "questions" (
-       "id"    INTEGER,
-       "subject_id"    INTEGER,
-       "status_id"     INTEGER,
-       "error_cause_id"        INTEGER,
-       "source_id"     INTEGER,
-       "question_type" TEXT,
-       "content"       TEXT NOT NULL,
-       "analysis"      TEXT,
-       "score" REAL,
-       "input_time"    DATETIME DEFAULT CURRENT_TIMESTAMP,
-       "answer_time"   DATETIME,
-       "has_subquestions"      BOOLEAN DEFAULT 0,
-       "subquestion_count"     INTEGER DEFAULT 0,
-       "subquestion_description"       TEXT DEFAULT '',
-       "subquestion_correct_count"     INTEGER DEFAULT 0,
-       "is_subjective" INTEGER DEFAULT 0, term_id INTEGER, stage_id INTEGER, origin_id INTEGER, media_path TEXT, paper_count INTEGER DEFAULT 0, review_times INTEGER DEFAULT 0, right_times INTEGER DEFAULT 0, last_right_time DATETIME DEFAULT NULL, wrong_times INTEGER DEFAULT 0, last_wrong_time DATETIME DEFAULT NULL, half_right_times INTEGER DEFAULT 0, last_half_right_time DATETIME DEFAULT NULL,
-       PRIMARY KEY("id" AUTOINCREMENT),
-       FOREIGN KEY("error_cause_id") REFERENCES "Error_cause"("id"),
-       FOREIGN KEY("source_id") REFERENCES "question_source"("id"),
-       FOREIGN KEY("status_id") REFERENCES "Question_status"("id"),
-       FOREIGN KEY("subject_id") REFERENCES "subject"("id")
-);
-CREATE TABLE IF NOT EXISTS "question_kp_relation" (
-       "question_id"   INTEGER,
-       "kp_id" INTEGER,
-       PRIMARY KEY("question_id","kp_id"),
-       FOREIGN KEY("kp_id") REFERENCES "knowledge_point"("id") ON DELETE CASCADE,
-       FOREIGN KEY("question_id") REFERENCES "questions"("id") ON DELETE CASCADE
-);
-CREATE TABLE IF NOT EXISTS "knowledge_point" (
-       "id"    INTEGER,
-       "content"       TEXT NOT NULL,
-       "parent_id"     INTEGER,
-       "is_enabled"    BOOLEAN DEFAULT 1,
-       "subject_id"    INTEGER,
-       PRIMARY KEY("id" AUTOINCREMENT),
-       FOREIGN KEY("parent_id") REFERENCES "knowledge_point"("id")
-);
-CREATE TABLE schema_comments (
-  object_type TEXT,   -- 'table' 或 'column'
-  table_name  TEXT NOT NULL,
-  column_name TEXT,   -- table 类型时可为 NULL
-  description TEXT,
-  updated_at  DATETIME DEFAULT CURRENT_TIMESTAMP,
-  PRIMARY KEY(object_type, table_name, column_name)
-);
-CREATE TABLE term (
-  ID INTEGER PRIMARY KEY AUTOINCREMENT,
-  term_name TEXT NOT NULL,
-  enabled INTEGER NOT NULL DEFAULT 1
-, is_default INTEGER NOT NULL DEFAULT 0);
-CREATE TABLE stage (
-  ID INTEGER PRIMARY KEY AUTOINCREMENT,
-  stage_name TEXT NOT NULL,
-  enabled INTEGER NOT NULL DEFAULT 1
-, is_default INTEGER NOT NULL DEFAULT 0);
-CREATE TABLE question_origin (
-  ID INTEGER PRIMARY KEY AUTOINCREMENT,
-  origin_name TEXT NOT NULL,
-  enabled INTEGER NOT NULL DEFAULT 1
-, is_default INTEGER NOT NULL DEFAULT 0);
-CREATE TABLE paper_status (
-    ID INTEGER PRIMARY KEY AUTOINCREMENT,
-    status_name TEXT NOT NULL,
-    description TEXT,
-    enabled INTEGER NOT NULL DEFAULT 1
-);
-CREATE TABLE paper_questions (
-    paper_id INTEGER NOT NULL,        -- 试卷ID
-    question_id INTEGER NOT NULL,     -- 题目ID
-    error_cause_id INTEGER,           -- 错误原因ID (来自 Error_Cause)
-    result_type INTEGER DEFAULT 0,    -- 结果判定: 0=错误; 1=正确; 2=部分正确
-    correct_sub_count INTEGER DEFAULT 0, -- 正确小题个数
-    accuracy REAL DEFAULT 0.0,        -- 正确率 (数值)
-    error_sub_description TEXT, record_time TEXT,       -- 错误小题描述
-    PRIMARY KEY (paper_id, question_id)
-);
-CREATE TABLE papers_new (
-  id INTEGER PRIMARY KEY,
-  title TEXT,
-  author TEXT,
-  subject TEXT
-);
-CREATE TABLE IF NOT EXISTS "papers" (
-       "ID"    INTEGER,
-       "paper_name"    TEXT NOT NULL,
-       "create_time"   DATETIME DEFAULT CURRENT_TIMESTAMP,
-       "status_id"     INTEGER,
-       "enabled"       INTEGER NOT NULL DEFAULT 1,
-       "subject"       TEXT,
-       PRIMARY KEY("ID" AUTOINCREMENT)
-);
diff --git a/s/studybase.sqbpro b/s/studybase.sqbpro
deleted file mode 100755 (executable)
index c18e5f7..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?><sqlb_project><db path="studybase.db" readonly="0" foreign_keys="1" case_sensitive_like="0" temp_store="0" wal_autocheckpoint="1000" synchronous="2"/><attached/><window><main_tabs open="structure browser pragmas query" current="3"/></window><tab_structure><column_width id="0" width="300"/><column_width id="1" width="0"/><column_width id="2" width="100"/><column_width id="3" width="6283"/><column_width id="4" width="0"/><expanded_item id="0" parent="1"/><expanded_item id="1" parent="1"/><expanded_item id="2" parent="1"/><expanded_item id="3" parent="1"/></tab_structure><tab_browse><table title="questions" custom_title="0" dock_id="1" table="4,9:mainquestions"/><dock_state state="000000ff00000000fd00000001000000020000057900000381fc0100000001fb000000160064006f0063006b00420072006f00770073006500310100000000000005790000010300ffffff0000029c0000000000000004000000040000000800000008fc00000000"/><default_encoding codec=""/><browse_table_settings><table schema="main" name="Error_Cause" show_row_id="0" encoding="" plot_x_axis="" unlock_view_pk="_rowid_" freeze_columns="0"><sort/><column_widths><column index="1" value="22"/><column index="2" value="61"/><column index="3" value="60"/><column index="4" value="66"/></column_widths><filter_values/><conditional_formats/><row_id_formats/><display_formats/><hidden_columns/><plot_y_axes/><global_filter/></table><table schema="main" name="questions" show_row_id="0" encoding="" plot_x_axis="" unlock_view_pk="_rowid_" freeze_columns="0"><sort><column index="10" mode="0"/></sort><column_widths><column index="1" value="31"/><column index="2" value="78"/><column index="3" value="71"/><column index="4" value="107"/><column index="5" value="74"/><column index="6" value="102"/><column index="7" value="300"/><column index="8" value="300"/><column index="9" value="44"/><column index="10" value="115"/><column index="11" value="91"/><column index="12" value="127"/><column index="13" value="133"/><column index="14" value="170"/><column index="15" value="188"/><column index="16" value="94"/><column index="17" value="59"/><column index="18" value="64"/><column index="19" value="66"/><column index="20" value="158"/><column index="21" value="90"/><column index="22" value="94"/><column index="23" value="83"/><column index="24" value="153"/><column index="25" value="93"/><column index="26" value="153"/><column index="27" value="116"/><column index="28" value="153"/><column index="29" value="94"/><column index="30" value="153"/></column_widths><filter_values><column index="1" value="2"/></filter_values><conditional_formats/><row_id_formats/><display_formats/><hidden_columns/><plot_y_axes/><global_filter/></table><table schema="main" name="stage" show_row_id="0" encoding="" plot_x_axis="" unlock_view_pk="_rowid_" freeze_columns="0"><sort/><column_widths><column index="1" value="22"/><column index="2" value="88"/><column index="3" value="60"/><column index="4" value="73"/></column_widths><filter_values/><conditional_formats/><row_id_formats/><display_formats/><hidden_columns/><plot_y_axes/><global_filter/></table></browse_table_settings></tab_browse><tab_sql><sql name="SQL 1*">UPDATE questions
-SET stage_id = 2
-WHERE subject_id = 2
-  AND answer_time= '2026-04-01'</sql><current_tab id="0"/></tab_sql></sqlb_project>
diff --git a/s/workmode-studybase.sql b/s/workmode-studybase.sql
deleted file mode 100755 (executable)
index 60c28b7..0000000
+++ /dev/null
@@ -1,139 +0,0 @@
-CREATE TABLE Subject (
-  ID INTEGER PRIMARY KEY,
-  Subject TEXT NOT NULL CHECK(length(Subject) <= 10),
-  CreateTime DATETIME DEFAULT (datetime('now','localtime'))
-, enabled BOOLEAN DEFAULT 1);
-CREATE TABLE Error_Cause (
-    ID INTEGER PRIMARY KEY AUTOINCREMENT,
-    Cause TEXT NOT NULL,
-    Enabled BOOLEAN DEFAULT 1, -- 默认为启用状态
-    ParentID INTEGER,
-    FOREIGN KEY (ParentID) REFERENCES Error_Cause(ID) ON DELETE CASCADE
-);
-CREATE TABLE sqlite_sequence(name,seq);
-CREATE TABLE Question_Status (
-                    ID INTEGER PRIMARY KEY AUTOINCREMENT,
-                    StatusName TEXT NOT NULL,
-                    Description TEXT,
-                    Enabled BOOLEAN DEFAULT 1
-                );
-CREATE TABLE question_source (
-    id INTEGER PRIMARY KEY AUTOINCREMENT,      -- 来源ID,自动递增
-    source_description TEXT NOT NULL,          -- 来源描述
-    is_enabled BOOLEAN DEFAULT 1              -- 是否启用,默认为 1 (true)
-);
-CREATE TABLE questions_new (
-    id INTEGER PRIMARY KEY AUTOINCREMENT,
-    subject_id INTEGER,
-    status_id INTEGER,
-    error_cause_id INTEGER,
-    source_id INTEGER,
-    question_type TEXT,
-    content TEXT NOT NULL,
-    analysis TEXT,
-    score REAL,                                  -- 修改点:允许为空,无默认值
-    is_subjective INTEGER DEFAULT 0,
-    has_subquestions BOOLEAN DEFAULT 0,
-    subquestion_count INTEGER DEFAULT 0,
-    subquestion_description TEXT DEFAULT '',
-    subquestion_correct_count INTEGER DEFAULT 0,
-    input_time DATETIME DEFAULT CURRENT_TIMESTAMP,
-    answer_time DATETIME,
-    FOREIGN KEY (subject_id) REFERENCES subject(id),
-    FOREIGN KEY (status_id) REFERENCES Question_status(id),
-    FOREIGN KEY (error_cause_id) REFERENCES Error_cause(id),
-    FOREIGN KEY (source_id) REFERENCES question_source(id)
-);
-CREATE TABLE IF NOT EXISTS "questions" (
-       "id"    INTEGER,
-       "subject_id"    INTEGER,
-       "status_id"     INTEGER,
-       "error_cause_id"        INTEGER,
-       "source_id"     INTEGER,
-       "question_type" TEXT,
-       "content"       TEXT NOT NULL,
-       "analysis"      TEXT,
-       "score" REAL,
-       "input_time"    DATETIME DEFAULT CURRENT_TIMESTAMP,
-       "answer_time"   DATETIME,
-       "has_subquestions"      BOOLEAN DEFAULT 0,
-       "subquestion_count"     INTEGER DEFAULT 0,
-       "subquestion_description"       TEXT DEFAULT '',
-       "subquestion_correct_count"     INTEGER DEFAULT 0,
-       "is_subjective" INTEGER DEFAULT 0, term_id INTEGER, stage_id INTEGER, origin_id INTEGER, media_path TEXT, paper_count INTEGER DEFAULT 0, review_times INTEGER DEFAULT 0, right_times INTEGER DEFAULT 0, last_right_time DATETIME DEFAULT NULL, wrong_times INTEGER DEFAULT 0, last_wrong_time DATETIME DEFAULT NULL, half_right_times INTEGER DEFAULT 0, last_half_right_time DATETIME DEFAULT NULL, latest_status TEXT DEFAULT NULL, half_right_description TEXT DEFAULT NULL,
-       PRIMARY KEY("id" AUTOINCREMENT),
-       FOREIGN KEY("error_cause_id") REFERENCES "Error_cause"("id"),
-       FOREIGN KEY("source_id") REFERENCES "question_source"("id"),
-       FOREIGN KEY("status_id") REFERENCES "Question_status"("id"),
-       FOREIGN KEY("subject_id") REFERENCES "subject"("id")
-);
-CREATE TABLE IF NOT EXISTS "question_kp_relation" (
-       "question_id"   INTEGER,
-       "kp_id" INTEGER,
-       PRIMARY KEY("question_id","kp_id"),
-       FOREIGN KEY("kp_id") REFERENCES "knowledge_point"("id") ON DELETE CASCADE,
-       FOREIGN KEY("question_id") REFERENCES "questions"("id") ON DELETE CASCADE
-);
-CREATE TABLE IF NOT EXISTS "knowledge_point" (
-       "id"    INTEGER,
-       "content"       TEXT NOT NULL,
-       "parent_id"     INTEGER,
-       "is_enabled"    BOOLEAN DEFAULT 1,
-       "subject_id"    INTEGER,
-       PRIMARY KEY("id" AUTOINCREMENT),
-       FOREIGN KEY("parent_id") REFERENCES "knowledge_point"("id")
-);
-CREATE TABLE schema_comments (
-  object_type TEXT,   -- 'table' 或 'column'
-  table_name  TEXT NOT NULL,
-  column_name TEXT,   -- table 类型时可为 NULL
-  description TEXT,
-  updated_at  DATETIME DEFAULT CURRENT_TIMESTAMP,
-  PRIMARY KEY(object_type, table_name, column_name)
-);
-CREATE TABLE term (
-  ID INTEGER PRIMARY KEY AUTOINCREMENT,
-  term_name TEXT NOT NULL,
-  enabled INTEGER NOT NULL DEFAULT 1
-, is_default INTEGER NOT NULL DEFAULT 0);
-CREATE TABLE stage (
-  ID INTEGER PRIMARY KEY AUTOINCREMENT,
-  stage_name TEXT NOT NULL,
-  enabled INTEGER NOT NULL DEFAULT 1
-, is_default INTEGER NOT NULL DEFAULT 0);
-CREATE TABLE question_origin (
-  ID INTEGER PRIMARY KEY AUTOINCREMENT,
-  origin_name TEXT NOT NULL,
-  enabled INTEGER NOT NULL DEFAULT 1
-, is_default INTEGER NOT NULL DEFAULT 0);
-CREATE TABLE paper_status (
-    ID INTEGER PRIMARY KEY AUTOINCREMENT,
-    status_name TEXT NOT NULL,
-    description TEXT,
-    enabled INTEGER NOT NULL DEFAULT 1
-);
-CREATE TABLE paper_questions (
-    paper_id INTEGER NOT NULL,        -- 试卷ID
-    question_id INTEGER NOT NULL,     -- 题目ID
-    error_cause_id INTEGER,           -- 错误原因ID (来自 Error_Cause)
-    result_type INTEGER DEFAULT 0,    -- 结果判定: 0=错误; 1=正确; 2=部分正确
-    correct_sub_count INTEGER DEFAULT 0, -- 正确小题个数
-    accuracy REAL DEFAULT 0.0,        -- 正确率 (数值)
-    error_sub_description TEXT, record_time TEXT, subject_id INTEGER,       -- 错误小题描述
-    PRIMARY KEY (paper_id, question_id)
-);
-CREATE TABLE papers_new (
-  id INTEGER PRIMARY KEY,
-  title TEXT,
-  author TEXT,
-  subject TEXT
-);
-CREATE TABLE IF NOT EXISTS "papers" (
-       "ID"    INTEGER,
-       "paper_name"    TEXT NOT NULL,
-       "create_time"   DATETIME DEFAULT CURRENT_TIMESTAMP,
-       "status_id"     INTEGER,
-       "enabled"       INTEGER NOT NULL DEFAULT 1,
-       "subject"       TEXT,
-       PRIMARY KEY("ID" AUTOINCREMENT)
-);
diff --git a/static/css/style.css b/static/css/style.css
deleted file mode 100644 (file)
index 144ce60..0000000
+++ /dev/null
@@ -1,139 +0,0 @@
-/* 全局基础样式 */
-body {
-    margin: 0;
-    padding: 0;
-    background-color: #f0f2f5;
-    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-    -webkit-font-smoothing: antialiased;
-}
-
-/* 顶部导航栏基础样式 */
-.navbar {
-    height: 60px;
-    background: linear-gradient(135deg, #4088d8 0%, #2a69b5 100%);
-    display: flex;
-    justify-content: center;
-    align-items: center;
-    color: white;
-    font-size: 22px;
-    font-weight: bold;
-    box-shadow: 0 2px 10px rgba(0,0,0,0.1);
-    position: sticky;
-    top: 0;
-    z-index: 1000;
-}
-
-/* 子页面特定的 Header 颜色 - 对应首页按钮色系 */
-.header-subject { background: linear-gradient(135deg, #0066cc 0%, #004499 100%) !important; }
-.header-question { background: linear-gradient(135deg, #009944 0%, #007733 100%) !important; }
-.header-paper { background: linear-gradient(135deg, #e67e22 0%, #d35400 100%) !important; }
-.header-setting { background: linear-gradient(135deg, #8e44ad 0%, #732d91 100%) !important; }
-
-/* 主体容器 - 响应式 Flex 布局 */
-.container {
-    display: flex;
-    flex-wrap: wrap;      /* 核心:允许在空间不足时换行 */
-    flex-direction: row; 
-    justify-content: center;
-    align-items: center;
-    gap: 20px;
-    padding: 40px 20px;
-    max-width: 1200px;
-    margin: 0 auto;
-}
-
-/* 详情页容器 */
-.sub-container {
-    padding: 30px 20px;
-    text-align: center;
-    max-width: 800px;
-    margin: 0 auto;
-}
-
-/* 卡片按钮基础样式 */
-.card {
-    flex: 1;               /* 自动伸缩 */
-    min-width: 240px;      /* 保证电脑端不会太窄 */
-    max-width: 280px;      /* 保证电脑端不会太宽 */
-    height: 85px;
-    background-color: white;
-    border-radius: 15px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    font-size: 18px;
-    font-weight: 600;
-    cursor: pointer;
-    transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
-    text-decoration: none;
-    box-shadow: 0 4px 12px rgba(0,0,0,0.08);
-    border: 1px solid rgba(0,0,0,0.05);
-}
-
-.card:hover {
-    transform: translateY(-5px);
-    box-shadow: 0 12px 20px rgba(0,0,0,0.15);
-}
-
-/* 四个模块的丰富配色方案 */
-.card-subject { color: #0066cc; border-left: 8px solid #0066cc; }
-.card-subject:hover { background-color: #f0f7ff; }
-
-.card-question { color: #009944; border-left: 8px solid #009944; }
-.card-question:hover { background-color: #f0fff4; }
-
-.card-paper { color: #e67e22; border-left: 8px solid #e67e22; }
-.card-paper:hover { background-color: #fffaf5; }
-
-.card-setting { color: #8e44ad; border-left: 8px solid #8e44ad; }
-.card-setting:hover { background-color: #f9f5fb; }
-
-/* 图标/数字样式 */
-.icon-num {
-    margin-right: 12px;
-    font-size: 22px;
-    font-weight: bold;
-    opacity: 0.7;
-}
-
-/* 返回按钮样式 */
-.back-btn {
-    display: inline-block;
-    margin-top: 30px;
-    padding: 12px 30px;
-    background: #666;
-    color: white;
-    text-decoration: none;
-    border-radius: 25px;
-    font-weight: 500;
-    transition: background 0.3s;
-}
-.back-btn:hover {
-    background: #333;
-}
-
-/* === 移动端适配 (iPhone 等) === */
-@media (max-width: 600px) {
-    .navbar {
-        font-size: 18px;
-        height: 50px;
-    }
-
-    .container {
-        padding: 20px 15px;
-        gap: 15px;
-    }
-
-    .card {
-        /* 在手机上,让按钮占据几乎全部宽度,防止并排重叠 */
-        min-width: 100%;
-        max-width: 100%;
-        height: 75px;
-        font-size: 17px;
-        border-left-width: 10px; /* 手机上加粗色块更醒目 */
-    }
-
-    .sub-container h2 {
-        font-size: 20px;
-    }
-}
diff --git a/templates/index.html b/templates/index.html
deleted file mode 100644 (file)
index 1e60134..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-<!DOCTYPE html>
-<html lang="zh">
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>错题智能整理系统</title>
-    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
-</head>
-<body>
-    <div class="navbar">错题智能整理系统</div>
-
-    <div class="container">
-        <a href="/subject/" class="card card-subject"><span class="icon-num">①</span> 科目面板</a>
-        <a href="/questions/" class="card card-question"><span class="icon-num">②</span> 题目管理</a>
-        <a href="/papers/" class="card card-paper"><span class="icon-num">③</span> 试卷管理</a>
-        <a href="/settings/" class="card card-setting"><span class="icon-num">④</span> 系统设定</a>
-    </div>
-</body>
-</html>
diff --git a/templates/papers/index.html b/templates/papers/index.html
deleted file mode 100644 (file)
index 7d676ea..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-<!DOCTYPE html>
-<html lang="zh">
-<head>
-    <meta charset="UTF-8">
-    <title>试卷管理 - 错题系统</title>
-    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
-    <style>
-        .sub-container { padding: 50px; text-align: center; }
-        .back-btn { display: inline-block; margin-top: 20px; padding: 10px 20px; background: #666; color: white; text-decoration: none; border-radius: 5px; }
-    </style>
-</head>
-<body>
-    <div class="navbar header-paper">试卷管理</div>
-    <div class="sub-container">
-        <h2>这里是试卷管理内容区域</h2>
-        <p>正在从原 Python 程序迁移逻辑...</p>
-        <a href="/" class="back-btn">返回首页</a>
-    </div>
-</body>
-</html>
diff --git a/templates/questions/index.html b/templates/questions/index.html
deleted file mode 100644 (file)
index 5c868b5..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-<!DOCTYPE html>
-<html lang="zh">
-<head>
-    <meta charset="UTF-8">
-    <title>科目面板 - 错题系统</title>
-    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
-    <style>
-        .sub-container { padding: 50px; text-align: center; }
-        .back-btn { display: inline-block; margin-top: 20px; padding: 10px 20px; background: #666; color: white; text-decoration: none; border-radius: 5px; }
-    </style>
-</head>
-<body>
-    <div class="navbar header-question">科目面板</div>
-    <div class="sub-container">
-        <h2>这里是科目面板内容区域</h2>
-        <p>正在从原 Python 程序迁移逻辑...</p>
-        <a href="/" class="back-btn">返回首页</a>
-    </div>
-</body>
-</html>
diff --git a/templates/questions/question_list.html b/templates/questions/question_list.html
deleted file mode 100644 (file)
index a76aea1..0000000
+++ /dev/null
@@ -1,140 +0,0 @@
-<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>试题列表</title>
-    <link rel="stylesheet" href="/static/css/style.css">
-    <style>
-        body {
-            font-family: 'Microsoft YaHei', sans-serif;
-            background-color: #F0F0F0;
-        }
-        .header {
-            background-color: #4A90E2;
-            color: white;
-            padding: 15px;
-            text-align: center;
-            font-size: 24px;
-        }
-        .filter-panel {
-            margin: 20px;
-            background: white;
-            padding: 15px;
-            border-radius: 8px;
-            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
-        }
-        .filter-panel label {
-            margin-right: 10px;
-        }
-        .table-container {
-            margin: 20px;
-            background: white;
-            border-radius: 8px;
-            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
-        }
-        table {
-            width: 100%;
-            border-collapse: collapse;
-        }
-        th, td {
-            padding: 12px;
-            text-align: left;
-            border-bottom: 1px solid #ddd;
-        }
-        th {
-            background-color: #f2f2f2;
-        }
-    </style>
-</head>
-<body>
-    <div class="header">试题列表</div>
-    <div class="filter-panel">
-        <label for="subjectFilter">科目:</label>
-        <select id="subjectFilter">
-            <option value="全部">全部</option>
-            <!-- 动态加载科目选项 -->
-            {% for subject in subjects %}
-            <option value="{{ subject }}">{{ subject }}</option>
-            {% endfor %}
-        </select>
-        
-        <label for="questionTypeFilter">题目类型:</label>
-        <select id="questionTypeFilter">
-            <option value="全部">全部</option>
-            <option value="错题">错题</option>
-            <option value="推题">推题</option>
-        </select>
-        
-        <label for="answerStatusFilter">答题状态:</label>
-        <select id="answerStatusFilter">
-            <option value="全部">全部</option>
-            <option value="未完成">未完成</option>
-            <option value="正确">正确</option>
-            <option value="错误">错误</option>
-            <option value="部分正确">部分正确</option>
-        </select>
-        
-        <button id="searchButton">搜索</button>
-    </div>
-
-    <div class="table-container">
-        <table id="questionTable">
-            <thead>
-                <tr>
-                    <th>ID</th>
-                    <th>科目</th>
-                    <th>题目类型</th>
-                    <th>题干内容</th>
-                    <th>答案解析</th>
-                    <th>答题时间</th>
-                    <th>输入时间</th>
-                    <th>题目知识点</th>
-                </tr>
-            </thead>
-            <tbody id="questionBody">
-                <tr>
-                    <td colspan="8">正在加载数据...</td>
-                </tr>
-            </tbody>
-        </table>
-    </div>
-
-    <script>
-        async function loadQuestionData() {
-            const subject = document.getElementById('subjectFilter').value;
-            const questionType = document.getElementById('questionTypeFilter').value;
-            const answerStatus = document.getElementById('answerStatusFilter').value;
-
-            const response = await fetch(`/api/question_list?subject=${subject}&question_type=${questionType}&answer_status=${answerStatus}`);
-            const data = await response.json();
-
-            const questionBody = document.getElementById('questionBody');
-            questionBody.innerHTML = '';
-
-            if (data.error) {
-                questionBody.innerHTML = '<tr><td colspan="8">加载失败: ' + data.error + '</td></tr>';
-                return;
-            }
-
-            data.forEach(question => {
-                const row = document.createElement('tr');
-                row.innerHTML = `
-                    <td>${question.id}</td>
-                    <td>${question.Subject || '-'}</td>
-                    <td>${question.question_type || '-'}</td>
-                    <td>${question.content || '-'}</td>
-                    <td>${question.analysis || '-'}</td>
-                    <td>${question.answer_time || '-'}</td>
-                    <td>${question.input_time || '-'}</td>
-                    <td>${question.knowledge_points || '-'}</td>
-                `;
-                questionBody.appendChild(row);
-            });
-        }
-
-        document.getElementById('searchButton').addEventListener('click', loadQuestionData);
-        document.addEventListener('DOMContentLoaded', loadQuestionData);
-    </script>
-</body>
-</html>
diff --git a/templates/settings/index.html b/templates/settings/index.html
deleted file mode 100644 (file)
index b80b2d5..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-<!DOCTYPE html>
-<html lang="zh">
-<head>
-    <meta charset="UTF-8">
-    <title>系统设定 - 错题系统</title>
-    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
-    <style>
-        .sub-container { padding: 50px; text-align: center; }
-        .back-btn { display: inline-block; margin-top: 20px; padding: 10px 20px; background: #666; color: white; text-decoration: none; border-radius: 5px; }
-    </style>
-</head>
-<body>
-    <div class="navbar header-setting">系统设定</div>
-    <div class="sub-container">
-        <h2>这里是系统设定内容区域</h2>
-        <p>正在从原 Python 程序迁移逻辑...</p>
-        <a href="/" class="back-btn">返回首页</a>
-    </div>
-</body>
-</html>
diff --git a/templates/subject/chapters.html b/templates/subject/chapters.html
deleted file mode 100644 (file)
index 75a0eca..0000000
+++ /dev/null
@@ -1,103 +0,0 @@
-<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
-    <title>章节分析</title>
-    <link rel="stylesheet" href="/static/css/style.css">
-    <style>
-        .filter-bar { background: white; padding: 12px; margin-bottom: 15px; border-radius: 12px; display: flex; gap: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); align-items: center; }
-        /* 缩短科目选择框 */
-        #subjectFilter { width: 90px; flex: none; padding: 8px 4px; border: 1px solid #ddd; border-radius: 6px; }
-        #keyword { flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 6px; min-width: 0; }
-        .btn-query { background: #4A90E2; color: white; border: none; padding: 8px 15px; border-radius: 6px; cursor: pointer; white-space: nowrap; }
-        /* 新增:筛选栏中的返回按钮 */
-        .btn-back-nav { background: #f0f0f0; color: #666; border: none; padding: 8px 12px; border-radius: 6px; cursor: pointer; white-space: nowrap; text-decoration: none; font-size: 14px; }
-        
-        .table-container { background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.05); margin-bottom: 50px; }
-        table { width: 100%; border-collapse: collapse; }
-        th, td { padding: 12px 10px; text-align: left; border-bottom: 1px solid #f0f0f0; font-size: 14px; }
-        th { background-color: #f8f9fa; color: #666; font-weight: 600; cursor: pointer; position: relative; }
-        th:after { content: '↕'; font-size: 10px; margin-left: 4px; color: #ccc; }
-        .clickable { color: #4A90E2; cursor: pointer; font-weight: bold; }
-        .num-badge { font-weight: bold; color: #d32f2f; background: #fff1f0; padding: 2px 6px; border-radius: 4px; }
-    </style>
-</head>
-<body>
-    <div class="navbar header-subject"><span class="nav-title">章节分析</span></div>
-    <div class="sub-container">
-        <div class="filter-bar">
-            <select id="subjectFilter">
-                <option value="全部">全部</option>
-                {% for sub in subjects %}<option value="{{ sub.Subject }}">{{ sub.Subject }}</option>{% endfor %}
-            </select>
-            <input type="text" id="keyword" placeholder="搜索章节...">
-            <button class="btn-query" onclick="loadData()">查询</button>
-            <a href="/subject/" class="btn-back-nav">返回</a>
-        </div>
-
-        <div class="table-container">
-            <table>
-                <thead>
-                    <tr>
-                        <th onclick="sortTable('SubjectName')">科目</th>
-                        <th onclick="sortTable('ChapterDesc')">章节</th>
-                        <th onclick="sortTable('total')">总数</th>
-                        <th onclick="sortTable('not_correct')">待完成</th>
-                    </tr>
-                </thead>
-                <tbody id="chapterBody"></tbody>
-            </table>
-        </div>
-    </div>
-
-    <script>
-        let currentData = [];
-        let sortDirection = -1;
-
-        async function loadData() {
-            const sub = document.getElementById('subjectFilter').value;
-            const key = document.getElementById('keyword').value;
-            const tbody = document.getElementById('chapterBody');
-            tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;padding:20px;">加载中...</td></tr>';
-            try {
-                const response = await fetch('/api/chapters?subject=' + encodeURIComponent(sub) + '&keyword=' + encodeURIComponent(key));
-                currentData = await response.json();
-                currentData.sort((a, b) => b.not_correct - a.not_correct);
-                renderTable(currentData);
-            } catch (err) {
-                tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:red;">加载失败</td></tr>';
-            }
-        }
-
-        function sortTable(column) {
-            sortDirection *= -1;
-            currentData.sort((a, b) => {
-                let valA = a[column] || '';
-                let valB = b[column] || '';
-                return typeof valA === 'string' 
-                    ? sortDirection === 1 ? valA.localeCompare(valB) : valB.localeCompare(valA)
-                    : sortDirection === 1 ? valA - valB : valB - valA;
-            });
-            renderTable(currentData);
-        }
-
-        function renderTable(data) {
-            const tbody = document.getElementById('chapterBody');
-            if (!data || data.length === 0) {
-                tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;padding:20px;">暂无数据</td></tr>';
-                return;
-            }
-            tbody.innerHTML = data.map(row => `
-                <tr>
-                    <td>${row.SubjectName || '-'}</td>
-                    <td class="clickable" onclick="location.href='/subject/knowledge?chapter_id=${row.chapter_id}'">${row.ChapterDesc || '未命名'}</td>
-                    <td>${row.total || 0}</td>
-                    <td><span class="num-badge">${row.not_correct || 0}</span></td>
-                </tr>
-            `).join('');
-        }
-        window.onload = loadData;
-    </script>
-</body>
-</html>
diff --git a/templates/subject/index.html b/templates/subject/index.html
deleted file mode 100644 (file)
index 8ec125e..0000000
+++ /dev/null
@@ -1,205 +0,0 @@
-<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
-    <title>科目概要面板</title>
-    <link rel="stylesheet" href="/static/css/style.css">
-    <style>
-        /* 针对统计页面的微调 */
-        .stats-grid {
-            display: grid;
-            grid-template-columns: 1fr 1fr;
-            gap: 15px;
-            margin-bottom: 20px;
-        }
-        @media (max-width: 600px) {
-            .stats-grid { grid-template-columns: 1fr; }
-        }
-        .stat-card {
-            background: white;
-            padding: 15px;
-            border-radius: 12px;
-            box-shadow: 0 4px 10px rgba(0,0,0,0.05);
-            border-top: 4px solid #4A90E2;
-        }
-        .stat-card.push { border-top-color: #52C41A; }
-        .stat-card h3 { margin: 0 0 10px 0; font-size: 16px; color: #333; }
-        .stat-line { display: flex; justify-content: space-between; margin: 8px 0; font-size: 14px; color: #666; }
-        .stat-value { font-weight: bold; color: #333; }
-        .stat-ratio { 
-            font-size: 24px; 
-            font-weight: bold; 
-            color: #4A90E2; 
-            text-align: center; 
-            margin: 10px 0;
-            display: block;
-        }
-        .push .stat-ratio { color: #52C41A; }
-        
-        .filter-section {
-            background: #fff;
-            padding: 15px;
-            border-radius: 12px;
-            margin-bottom: 20px;
-        }
-        .stage-chips {
-            display: flex;
-            flex-wrap: wrap;
-            gap: 8px;
-            margin-top: 10px;
-        }
-        .stage-chip {
-            padding: 5px 12px;
-            border-radius: 15px;
-            background: #f0f2f5;
-            font-size: 13px;
-            cursor: pointer;
-            border: 1px solid #ddd;
-        }
-        .stage-chip.active {
-            background: #4A90E2;
-            color: white;
-            border-color: #4A90E2;
-        }
-        .analysis-btn-group {
-            display: flex;
-            gap: 15px;
-            margin: 20px 0;
-        }
-        .analysis-btn-group .card {
-            flex: 1;
-            height: 70px;
-            margin: 0;
-            display: flex;
-            align-items: center;
-            justify-content: center;
-            font-size: 16px;
-            font-weight: bold;
-        }
-    </style>
-</head>
-<body>
-    <!-- 顶部导航 -->
-    <div class="navbar header-subject">
-        <span class="nav-title">科目概要面板</span>
-    </div>
-
-    <div class="sub-container">
-        <!-- 1. 筛选区 -->
-        <div class="filter-section">
-            <div style="margin-bottom: 15px;">
-                <label style="font-weight: bold;">当前科目:</label>
-                <select id="subjectSelect" style="width: 100%; padding: 10px; margin-top: 5px; border-radius: 8px; border: 1px solid #ddd;">
-                    {% for sub in subjects %}
-                    <option value="{{ sub.ID }}" {% if sub.ID == default_id %}selected{% endif %}>{{ sub.name }}</option>
-                    {% endfor %}
-                </select>
-            </div>
-            
-            <label style="font-weight: bold;">统计阶段:</label>
-            <div class="stage-chips" id="stageContainer">
-                {% for stage in stages %}
-                <div class="stage-chip active" data-id="{{ stage.ID }}" onclick="toggleStage(this)">
-                    {{ stage.stage_name }}
-                </div>
-                {% endfor %}
-            </div>
-        </div>
-
-        <!-- 2. 统计卡片区 -->
-        <div class="stats-grid">
-            <!-- 错题卡片 -->
-            <div class="stat-card">
-                <h3>错题情况回顾</h3>
-                <span class="stat-ratio" id="wrong_ratio">0%</span>
-                <div class="stat-line"><span>总数</span><span class="stat-value" id="wrong_total">0</span></div>
-                <div class="stat-line"><span>已解决</span><span class="stat-value" id="wrong_fixed" style="color: #2E7D32;">0</span></div>
-                <div class="stat-line"><span>遗留</span><span class="stat-value" id="wrong_remained" style="color: #D32F2F;">0</span></div>
-            </div>
-
-            <!-- 推题卡片 -->
-            <div class="stat-card push">
-                <h3>强化推题追踪</h3>
-                <span class="stat-ratio" id="push_ratio">0%</span>
-                <div class="stat-line"><span>总数</span><span class="stat-value" id="push_total">0</span></div>
-                <div class="stat-line"><span>已完成</span><span class="stat-value" id="push_fixed" style="color: #2E7D32;">0</span></div>
-                <div class="stat-line"><span>遗留</span><span class="stat-value" id="push_remained" style="color: #D32F2F;">0</span></div>
-            </div>
-        </div>
-
-        <!-- 3. 深入分析按钮区 -->
-        <div class="analysis-btn-group">
-            <a href="/subject/chapters" class="card card-subject">
-                📊 章节分析
-            </a>
-            <a href="/subject/knowledge" class="card card-question">
-                💡 知识点分析
-            </a>
-        </div>
-
-        <!-- 4. 返回按钮 -->
-        <div style="text-align: center; margin-top: 30px;">
-            <a href="/" class="back-btn">返回首页</a>
-        </div>
-    </div>
-
-    <script>
-        // 切换阶段选中状态
-        function toggleStage(el) {
-            el.classList.toggle('active');
-            refreshStats();
-        }
-
-        // 获取当前选中的阶段ID数组
-        function getSelectedStages() {
-            const actives = document.querySelectorAll('.stage-chip.active');
-            return Array.from(actives).map(el => el.dataset.id);
-        }
-
-        // 异步刷新数据
-        async function refreshStats() {
-            const subId = document.getElementById('subjectSelect').value;
-            const stages = getSelectedStages().join(',');
-            
-            if (!stages) {
-                updateUI(null);
-                return;
-            }
-
-            try {
-                const response = await fetch(`/api/subject_stats?subject_id=${subId}&stage_ids=${stages}`);
-                const data = await response.json();
-                updateUI(data);
-            } catch (error) {
-                console.error("加载数据失败:", error);
-            }
-        }
-
-        function updateUI(data) {
-            if (!data) {
-                const keys = ['wrong_total', 'wrong_fixed', 'wrong_remained', 'wrong_ratio', 
-                             'push_total', 'push_fixed', 'push_remained', 'push_ratio'];
-                keys.forEach(k => document.getElementById(k).innerText = k.includes('ratio') ? '0%' : '0');
-                return;
-            }
-            
-            document.getElementById('wrong_total').innerText = data.wrong.total;
-            document.getElementById('wrong_fixed').innerText = data.wrong.fixed;
-            document.getElementById('wrong_remained').innerText = data.wrong.remained;
-            document.getElementById('wrong_ratio').innerText = data.wrong.ratio;
-
-            document.getElementById('push_total').innerText = data.push.total;
-            document.getElementById('push_fixed').innerText = data.push.fixed;
-            document.getElementById('push_remained').innerText = data.push.remained;
-            document.getElementById('push_ratio').innerText = data.push.ratio;
-        }
-
-        // 监听科目切换
-        document.getElementById('subjectSelect').addEventListener('change', refreshStats);
-
-        // 页面加载时自动执行一次
-        window.onload = refreshStats;
-    </script>
-</body>
-</html>
diff --git a/templates/subject/knowledge.html b/templates/subject/knowledge.html
deleted file mode 100644 (file)
index e2197cc..0000000
+++ /dev/null
@@ -1,101 +0,0 @@
-<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
-    <title>知识点分析</title>
-    <link rel="stylesheet" href="/static/css/style.css">
-    <style>
-        .filter-bar { background: white; padding: 12px; margin-bottom: 15px; border-radius: 12px; display: flex; gap: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); align-items: center; }
-        #subjectFilter { width: 90px; flex: none; padding: 8px 4px; border: 1px solid #ddd; border-radius: 6px; }
-        #keyword { flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 6px; min-width: 0; }
-        .btn-query { background: #4A90E2; color: white; border: none; padding: 8px 15px; border-radius: 6px; cursor: pointer; white-space: nowrap; }
-        .btn-back-nav { background: #f0f0f0; color: #666; border: none; padding: 8px 12px; border-radius: 6px; cursor: pointer; white-space: nowrap; text-decoration: none; font-size: 14px; }
-        
-        .table-container { background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.05); margin-bottom: 50px; }
-        table { width: 100%; border-collapse: collapse; }
-        th, td { padding: 12px 10px; text-align: left; border-bottom: 1px solid #f0f0f0; font-size: 14px; }
-        th { background-color: #f8f9fa; color: #666; font-weight: 600; cursor: pointer; position: relative; }
-        th:after { content: '↕'; font-size: 10px; margin-left: 4px; color: #ccc; }
-        .num-badge { font-weight: bold; color: #d32f2f; background: #fff1f0; padding: 2px 6px; border-radius: 4px; }
-    </style>
-</head>
-<body>
-    <div class="navbar header-subject"><span class="nav-title">知识点分析</span></div>
-    <div class="sub-container">
-        <div class="filter-bar">
-            <select id="subjectFilter">
-                <option value="全部">全部</option>
-                {% for sub in subjects %}<option value="{{ sub.Subject }}">{{ sub.Subject }}</option>{% endfor %}
-            </select>
-            <input type="text" id="keyword" placeholder="搜索知识点...">
-            <button class="btn-query" onclick="loadData()">查询</button>
-            <a href="/subject/chapters" class="btn-back-nav">返回</a>
-        </div>
-
-        <div class="table-container">
-            <table>
-                <thead>
-                    <tr>
-                        <th onclick="sortTable('subject_name')">科目</th>
-                        <th onclick="sortTable('content')">知识点内容</th>
-                        <th onclick="sortTable('total')">总数</th>
-                        <th onclick="sortTable('not_correct')">待完成</th>
-                    </tr>
-                </thead>
-                <tbody id="knowledgeBody"></tbody>
-            </table>
-        </div>
-    </div>
-
-    <script>
-        let currentData = [];
-        let sortDirection = -1;
-        const chapterId = "{{ chapter_id }}";
-
-        async function loadData() {
-            const sub = document.getElementById('subjectFilter').value;
-            const key = document.getElementById('keyword').value;
-            const tbody = document.getElementById('knowledgeBody');
-            tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;padding:20px;">加载中...</td></tr>';
-            try {
-                const response = await fetch(`/api/knowledge?subject=${encodeURIComponent(sub)}&keyword=${encodeURIComponent(key)}&chapter_id=${chapterId}`);
-                currentData = await response.json();
-                currentData.sort((a, b) => b.not_correct - a.not_correct);
-                renderTable(currentData);
-            } catch (err) {
-                tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:red;">加载失败</td></tr>';
-            }
-        }
-
-        function sortTable(column) {
-            sortDirection *= -1;
-            currentData.sort((a, b) => {
-                let valA = a[column] || '';
-                let valB = b[column] || '';
-                return typeof valA === 'string'
-                    ? sortDirection === 1 ? valA.localeCompare(valB) : valB.localeCompare(valA)
-                    : sortDirection === 1 ? valA - valB : valB - valA;
-            });
-            renderTable(currentData);
-        }
-
-        function renderTable(data) {
-            const tbody = document.getElementById('knowledgeBody');
-            if (!data || data.length === 0) {
-                tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;padding:20px;">暂无数据</td></tr>';
-                return;
-            }
-            tbody.innerHTML = data.map(row => `
-                <tr>
-                    <td>${row.subject_name || '-'}</td>
-                    <td>${row.content || '-'}</td>
-                    <td>${row.total || 0}</td>
-                    <td><span class="num-badge">${row.not_correct || 0}</span></td>
-                </tr>
-            `).join('');
-        }
-        window.onload = loadData;
-    </script>
-</body>
-</html>