# -*- coding: utf-8 -*-
"""「组卷台」网页：按胡小群课程「册→节(知识点)」选题 → 加入试卷 → 预览 → 打印PDF/下载Word(专题练习模板样式)。纯前端，零答案。"""
import json, os, html, base64, shutil
BUILD='2026-09-10.1'   # 前后端握手号（组卷台AI助手.py 的 BUILD 同步改）；也用作静态资源缓存戳 ?v=
from collections import OrderedDict, defaultdict
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
b = json.load(open(os.path.join(HERE, '胡小群题库_结构化题目.json')))
items = b if isinstance(b, list) else b.get('items', b)

import re
# ── 胡小群课程 crosswalk：讲义(按学期分册)与课后习题对到同一条「课程节」脊 ──
# 脊=课后习题的节序列（L1-2 自成一脊；L3-L6 各30节）；讲义节按规范标题归到脊上
GRADE_NAME={'L1-2':'一·二年级','L3':'三年级','L4':'四年级','L5':'五年级','L6':'六年级',
            'L7B':'初一·基础','L7T':'初一·拓展','L8B':'初二·基础','L8T':'初二·拓展',
            'L9B':'初三·基础','L9T':'初三·拓展','ZHT':'初中·综合'}
GRADE_ORDER=['L1-2','L3','L4','L5','L6','L7B','L7T','L8B','L8T','L9B','L9T','ZHT']
# 初中(2026-08-26 录入)：基础篇/拓展篇是**两条独立的讲序列**，所以年级×篇各算一个 lv，
# 不能像小学那样一个年级一条脊 —— 合在一起讲号会撞。
JR_LV={('L7','基础篇'):'L7B',('L7','拓展篇'):'L7T',('L8','基础篇'):'L8B',('L8','拓展篇'):'L8T',
       ('L9','基础篇'):'L9B',('L9','拓展篇'):'L9T',('综合','拓展篇'):'ZHT'}
BOOK_LV={'三上':'L3','三下':'L3','四上':'L4','四下':'L4','五上':'L5','五下':'L5','六上':'L6','六下':'L6'}
# 4 处人工核定兜底（讲义把首半学期的「（一）」省了数字 / 多一个"的"）
OVERRIDE={('三上',15):'周期问题（一）',('四上',14):'行程进阶（一）',('五上',15):'最值原理进阶（一）',('五下',12):'分数概念与性质'}
def norm(t):
    t=re.sub(r'[：:].*$','',str(t)); t=t.replace('与','及'); return re.sub(r'\s+','',t).strip()
def sec_int(q):
    try: return int(str(q.get('sec','99')))
    except: return 99
def lv_of(q):
    bk=q.get('book','')
    jr=JR_LV.get((q.get('grade'),bk))
    if jr: return jr                            # 初中：年级×篇
    if bk=='1-2年级课后习题': return 'L1-2'
    if bk=='': return q.get('grade')          # L3..L6 课后
    return BOOK_LV.get(bk)                      # 讲义
def book_label(q):
    bk=q.get('book','')
    if JR_LV.get((q.get('grade'),bk)):
        return f"{GRADE_NAME[JR_LV[(q['grade'],bk)]]}·{'讲义' if q.get('category')=='讲义练习' else '课后'}"
    if bk=='1-2年级课后习题': return '1-2年级课后'
    if bk=='': return f"{q.get('grade','?')}课后"
    return f"{bk}讲义"

# 课后脊：lv -> {规范标题: (csec, 原标题)} 和 csec->标题
spine={}; spine_title={}
for q in items:
    if q.get('source')=='胡小群讲义' or q.get('category')=='讲义练习': continue
    lv=lv_of(q)
    if not lv: continue
    s=sec_int(q); t=q.get('sec_title','')
    spine.setdefault(lv,{}).setdefault(norm(t),(s,t)); spine_title.setdefault(lv,{})[s]=t

# 把每题映射到课程节(csec)，并采集讲义后缀（如「整数加减法巧算」）丰富节名
suffix={}; unmatched=[]
def course_sec(q):
    lv=lv_of(q)
    if JR_LV.get((q.get('grade'),q.get('book',''))):
        return lv, sec_int(q)                  # 初中：例题和习题共用讲号，不需要配对
    if q.get('source')!='胡小群讲义':
        return lv, sec_int(q)
    bk=q.get('book',''); s=sec_int(q); t=q.get('sec_title','')
    cn=norm(OVERRIDE.get((bk,s), t))
    if lv not in spine or cn not in spine[lv]:
        unmatched.append((bk,s,t)); return lv, None
    csec=spine[lv][cn][0]
    m=re.search(r'[：:](.+)$', str(t))
    if m: suffix.setdefault((lv,csec), m.group(1).strip())
    return lv, csec

# 第一遍：算 csec + 收集后缀
qmap=[]
for q in items:
    lv,csec=course_sec(q); qmap.append((q,lv,csec))
# 课程节显示名 = 课后基名 + 讲义后缀
JR_TITLE={}
for q in items:
    lv=lv_of(q)
    if JR_LV.get((q.get('grade'),q.get('book',''))) and q.get('sec_title'):
        JR_TITLE.setdefault((lv,sec_int(q)), q['sec_title'])
def disp_of(lv,csec):
    base=spine_title.get(lv,{}).get(csec) or JR_TITLE.get((lv,csec)) or f'第{csec}节'
    suf=suffix.get((lv,csec))
    return base+('·'+suf if suf else '')

data=[]
for q,lv,csec in qmap:
    if lv is None or csec is None: continue
    data.append({'id':q.get('id',''),'tb':'胡小群课程','gg':GRADE_NAME.get(lv,lv),'lv':lv,
                 'cs':csec,'cd':disp_of(lv,csec),'src':book_label(q),
                 's':'讲义' if (q.get('source')=='胡小群讲义'
                                or q.get('category')=='讲义练习') else '课后','n':q.get('num',''),
                 'q':q.get('stem','') or '','t':q.get('qtype_norm','其他'),'d':q.get('difficulty') or 0,
                 'f':q.get('figure_crop') or '','mf':[],'p':q.get('page_images') or [],'k':q.get('knowledge') or []})
go={g:i for i,g in enumerate(GRADE_ORDER)}
def numkey(v):
    """题号排序键。⚠ 必须按**数字**排，不能 str() 了事。

    原来写的是 str(x['n'])，胡小群的 num 本来就是 int，被转成字符串后
    "12" < "8"（逐字符比 '1' < '8'），一节里的题就排成 10,11,12,8,9
    —— 用户看到「第12题排在第8题前面」。

    但也不能直接 int()：一课一练的题号是 "1(1)" 这种带小问的（16951 条全是 str）。
    所以拆成 (主号, 小问号)，认不出的丢到最后而不是抛异常。
    """
    m = re.match(r'^\s*(\d+)(?:\s*[（(]\s*(\d+)\s*[）)])?', str(v))
    if not m:
        return (10**9, 10**9, str(v))
    return (int(m.group(1)), int(m.group(2) or 0), '')


data.sort(key=lambda x:(go.get(x['lv'],99), x['cs'], 0 if x['s']=='讲义' else 1, numkey(x['n'])))
print('crosswalk: 未配讲义节',len(unmatched), unmatched if unmatched else '')

# 课程结构（嵌套）：教材 -> 年级 -> [ [节号, 显示名, 题数] ]
course=OrderedDict(); course['胡小群课程']=OrderedDict()
for lv in GRADE_ORDER:
    g=GRADE_NAME[lv]; nodes=OrderedDict()
    for d in data:
        if d['tb']!='胡小群课程' or d['lv']!=lv: continue
        nodes.setdefault(d['cs'], [d['cd'],0]); nodes[d['cs']][1]+=1
    if nodes: course['胡小群课程'][g]=[[s,nd[0],nd[1]] for s,nd in sorted(nodes.items())]

# ── 原书页映射（「对照原书」功能：讲→物理页范围→随站页图，offset全0，左右各缓冲1页）──
_PGN={'一年级':1,'二年级':2,'三年级':3,'四年级':4,'五年级':5,'六年级':6}
_prp=os.path.join(HERE,'..','..','组卷系统','原书页','page_ranges.json')
_PR=json.load(open(_prp,encoding='utf-8')) if os.path.exists(_prp) else {'ranges':{},'navail':{}}
def pg_urls(tbkey, grade, lesson):
    g=_PGN.get(grade)
    if g is None: return [], []
    r=_PR.get('ranges',{}).get(tbkey,{}).get(str(g),{}).get(str(lesson))
    if not r: return [], []
    mx=_PR.get('navail',{}).get(tbkey,{}).get(str(g),9999)
    nm=_PR.get('nmin',{}).get(tbkey,{}).get(str(g),1)
    X,Y=r; lo=max(nm,X-1); hi=min(mx,Y+1)      # 下界夹到实际最小页, 避免缓冲破图
    return [f'原书页/{tbkey}/{g}/p{n:03d}.jpg' for n in range(lo,hi+1)], [X,Y]

# ── 并入《举一反三》结构化题（六册全量；同一套 教材→年级→专题 轴）──
SEC_LABEL={'例':'典型例题','尝':'尝试应用','举':'举一反三'}
import glob as _glob
jyf_paths=sorted(_glob.glob(os.path.join(HERE,'..','举一反三题库','举一反三_结构化题目_*.json')))
jyf_figs=set()
for jp in jyf_paths:
    if not os.path.exists(jp): continue
    for q in json.load(open(jp)):
        figs=[os.path.normpath(os.path.join(HERE,'..',f.replace('成果/',''))) for f in q.get('figures',[])]
        figs=[os.path.relpath(f,HERE) for f in figs]   # 相对生成器目录
        for f in figs: jyf_figs.add(f)
        _pg,_pr=pg_urls('jyf', q['grade'], q['topic_no'])
        data.append({'id':q['id'],'tb':'举一反三','gg':q['grade'],'lv':'JYF',
                     'cs':q['topic_no'],'cd':f"专题{q['topic_no']}·{q['topic']}",'src':SEC_LABEL.get(q['sec_kind'],q['sec_kind']),
                     's':SEC_LABEL.get(q['sec_kind'],q['sec_kind']),'n':q['num'],
                     'q':q['stem'] or '','t':'其他','d':0,
                     'f':figs[0] if figs else '','mf':figs[1:],'p':[],'k':[],'pg':_pg,'pr':_pr})
# 举一反三 课程结构（年级按一二三四五六排序，中文 sorted 会乱）
course['举一反三']=OrderedDict()
JYF_GRADE_ORDER=['一年级','二年级','三年级','四年级','五年级','六年级']
_present=set(d['gg'] for d in data if d['tb']=='举一反三')
for g in [x for x in JYF_GRADE_ORDER if x in _present]:
    nodes=OrderedDict()
    for d in data:
        if d['tb']!='举一反三' or d['gg']!=g: continue
        nodes.setdefault(d['cs'], [d['cd'],0]); nodes[d['cs']][1]+=1
    course['举一反三'][g]=[[s,nd[0],nd[1]] for s,nd in sorted(nodes.items())]

# ── 并入《动脑筋》结构化题（六册全量；按讲）──
SEC_LABEL_DNJ={'例':'典型例题','练':'练习','挑':'自我挑战','测':'测试'}
dnj_paths=sorted(_glob.glob(os.path.join(HERE,'..','动脑筋题库','动脑筋_结构化题目_*.json')))
for jp in dnj_paths:
    if not os.path.exists(jp): continue
    for q in json.load(open(jp)):
        figs=[os.path.normpath(os.path.join(HERE,'..',f.replace('成果/',''))) for f in q.get('figures',[])]
        figs=[os.path.relpath(f,HERE) for f in figs]
        for f in figs: jyf_figs.add(f)
        _pg,_pr=pg_urls('dnj', q['grade'], q['topic_no'])
        data.append({'id':q['id'],'tb':'动脑筋','gg':q['grade'],'lv':'DNJ',
                     'cs':q['topic_no'],'cd':f"第{q['topic_no']}讲·{q['topic']}",'src':SEC_LABEL_DNJ.get(q['sec_kind'],q['sec_kind']),
                     's':SEC_LABEL_DNJ.get(q['sec_kind'],q['sec_kind']),'n':q['num'],
                     'q':q['stem'] or '','t':'其他','d':0,
                     'f':figs[0] if figs else '','mf':figs[1:],'p':[],'k':[],'pg':_pg,'pr':_pr})
course['动脑筋']=OrderedDict()
_pd=set(d['gg'] for d in data if d['tb']=='动脑筋')
for g in [x for x in JYF_GRADE_ORDER if x in _pd]:
    nodes=OrderedDict()
    for d in data:
        if d['tb']!='动脑筋' or d['gg']!=g: continue
        nodes.setdefault(d['cs'], [d['cd'],0]); nodes[d['cs']][1]+=1
    course['动脑筋'][g]=[[s,nd[0],nd[1]] for s,nd in sorted(nodes.items())]

# ── 并入《一课一练》全系(沪教配套华东师大版,SoM流水线,零答案) ──
# (子目录, 题库文件, 页图路由key, 题目页范围) —— 年级标签由记录的 grade/edition/year 字段拼出
YKL_BOOKS=[('一年级上普通2024秋','一课一练_结构化题目.json','1s',7,142),
           ('一年级上增强2024秋','一课一练_结构化题目.json','1sz',7,107),
           ('二年级上普通','一课一练_结构化题目.json','2s',4,149),
           ('二年级上增强','一课一练_结构化题目.json','2sz',4,117),
           ('三年级上','一课一练_结构化题目_三年级上_v2.json','3s',2,185),
           ('三年级上增强2022秋','一课一练_结构化题目.json','3sz',6,127),
           ('三年级下普通2022秋','一课一练_结构化题目.json','3x',7,168),
           ('三年级下增强2022秋','一课一练_结构化题目.json','3xz',6,123),
           ('四年级上普通2026秋','一课一练_结构化题目.json','4s',4,173),
           ('四年级上增强2026秋','一课一练_结构化题目.json','4sz',4,132),
           ('四年级下普通2023春','一课一练_结构化题目.json','4x',7,156),
           ('四年级下增强2023春','一课一练_结构化题目.json','4xz',6,151),
           ('五年级上普通2026秋','一课一练_结构化题目.json','5s',4,188),
           ('五年级上增强2026秋','一课一练_结构化题目.json','5sz',8,123),
           ('五年级下普通2023春','一课一练_结构化题目.json','5x',7,160),
           ('五年级下增强2023春','一课一练_结构化题目.json','5xz',6,118),
           ('六年级上普通2024秋','一课一练_结构化题目.json','6s',7,156),
           ('八年级上普通2025秋','一课一练_结构化题目.json','8s',7,157)]      # 2026-09-08 数学资料批：配上海新教材，1,178 题
def ingest_som_bank(books, tb, lv, lib_dir, route, use_year=True, gg_fn=None, node_key='lesson_key', d_fn=None, keep_en=False):
    """SoM 流水线产出的教辅题库（一课一练 / 新思路 / 袋鼠 同构）并入 data + course。
    books=[(子目录, 题库文件, 页图路由key, 页起, 页止)]；页止给 None 时按 pages/ 目录实际张数算。
    gg 默认 grade·edition(+year)，gg_fn(q) 可换（袋鼠=年份轴「2024年」）；节点 = lesson 原文，按书页序首现编号，
    node_key 默认 lesson_key（袋鼠要用 lesson：Level A/B 同年份 lesson_key 都是年份，按它会并成一节）；
    d_fn(q) 给难度（袋鼠按 3/4/5 分档推 2/3/4，非原卷声明）；keep_en=True 时把 stem_en 存进 qe（不显示不导出，同 AMC8）。
    ⚠ 新增一册是**两处**：这里的 books 表 + 组卷台AI助手.py 的页图路由字典（少了后端那处「对照原书」404 且页面不报错）。"""
    for _dir,_bank,_rk,_pmin,_pmax in books:
        bp=os.path.join(HERE,'..',lib_dir,_dir,_bank)
        if not os.path.exists(bp): continue
        if _pmax is None:
            _pd=os.path.join(HERE,'..',lib_dir,_dir,'pages')
            _pmax=len([f for f in os.listdir(_pd) if f.endswith('.png')]) if os.path.isdir(_pd) else 10**6
        cs_of={}                                   # 课次→顺序号(按书页序首现,每本书独立)
        for q in json.load(open(bp,encoding='utf-8')):
            if gg_fn is None and (not q.get('grade') or not q.get('edition')):     # 标识来源的字段不给默认值，缺了就当场炸（默认值陷阱）
                raise SystemExit('✗ [%s] %s/%s 题 %s 缺 grade/edition' % (tb,_dir,_bank,q.get('id')))
            gg=gg_fn(q) if gg_fn else f"{q['grade']}·{q['edition']}"+((q.get('year') or '') if use_year else '')
            if not gg: raise SystemExit('✗ [%s] %s 题 %s 的 gg 取不到' % (tb,_dir,q.get('id')))
            lk=q.get(node_key) or '未知'
            if lk not in cs_of: cs_of[lk]=len(cs_of)+1
            figs=[os.path.normpath(os.path.join(HERE,'..',f.replace('成果/',''))) for f in q.get('figures',[])]
            figs=[os.path.relpath(f,HERE) for f in figs]
            for f in figs: jyf_figs.add(f)
            les=(q.get('lesson') or '').strip() or lk
            pgn=int(str(q.get('page','p000'))[1:])
            _pg=[f'原书页/{route}/{_rk}/p{n:03d}.png' for n in range(max(_pmin,pgn-1),min(_pmax,pgn+1)+1)]
            rec={'id':q['id'],'tb':tb,'gg':gg,'lv':lv,
                 'cs':cs_of[lk],'cd':les,'src':q.get('sec') or tb,
                 's':q.get('sec') or '','n':q.get('num',''),
                 'q':q.get('stem','') or '','t':q.get('qtype','其他'),'d':(d_fn(q) if d_fn else 0),
                 'f':figs[0] if figs else '','mf':figs[1:],'p':[],'k':[],
                 'pg':_pg,'pr':[pgn,pgn]}
            if keep_en and q.get('stem_en'): rec['qe']=q['stem_en']
            data.append(rec)
    if any(d['tb']==tb for d in data):
        course[tb]=OrderedDict()
        nodes=OrderedDict()                       # gg → {课次序号:[课名,题数]}
        for d in data:
            if d['tb']!=tb: continue
            nd=nodes.setdefault(d['gg'],OrderedDict())
            nd.setdefault(d['cs'],[d['cd'],0]); nd[d['cs']][1]+=1
        for gg,nd in nodes.items():
            course[tb][gg]=[[s,x[0],x[1]] for s,x in sorted(nd.items())]
ingest_som_bank(YKL_BOOKS,'一课一练','YKL','一课一练题库','ykl')

# ── 并入《新思路辅导与训练·数学》第三版（上海科技出版社，六上~八下，SoM 管线同一课一练；零答案）──
# year 字段里是「第三版(版次年份未核)」这类备注，不进 gg（gg=「六年级上·第三版」）。八上/八下定稿后加行。
XSL_BOOKS=[('六年级上第三版','一课一练_结构化题目.json','xsl6a',9,138),
           ('六年级下第三版','一课一练_结构化题目.json','xsl6b',5,158),
           ('七年级上第三版','一课一练_结构化题目.json','xsl7a',9,192),
           ('七年级下第三版','一课一练_结构化题目.json','xsl7b',8,215),
           ('八年级上第三版','一课一练_结构化题目.json','xsl8a',8,207),      # 2026-09-08 定稿 1,152 题
           ('八年级下第三版','一课一练_结构化题目.json','xsl8b',9,208)]      # 2026-09-08 定稿 960 题，六册齐
ingest_som_bank(XSL_BOOKS,'新思路','XSL','新思路题库','xsl',use_year=False)

# ── 并入《袋鼠数学 Level A / B 历年真题》（2026-09-10，录入窗口 SoM 库；陆老师只要 A、B）──
# 接法照 AMC8：gg=年份轴「2024年」，cd=「2024年 Level A」/「2024年 Level B」（同年份两节，节键用 lesson），
# 难度按 sec 3分题/4分题/5分题 → 2/3/4（按题号分档推的，不是原卷声明，与 AMC8 同口径），qe 存英文原题不显示。
# 页图路由 /原书页/mk/(mka|mkb)/pNNN.png → 成果/袋鼠题库/Level{A,B}/pages/，页止按 pages/ 实际张数。
MK_BOOKS=[('LevelA','一课一练_结构化题目.json','mka',1,None),
          ('LevelB','一课一练_结构化题目.json','mkb',1,None)]
def _mk_d(q):
    s=q.get('sec') or ''
    return 2 if s.startswith('3') else 3 if s.startswith('4') else 4 if s.startswith('5') else 0
ingest_som_bank(MK_BOOKS,'袋鼠真题','MK','袋鼠题库','mk',
                gg_fn=lambda q:(str(q.get('year') or '').strip()+'年') if str(q.get('year') or '').strip() else '',
                node_key='lesson',d_fn=_mk_d,keep_en=True)
if '袋鼠真题' in course:     # 年份轴倒序，最近的年份在上（同 AMC8）
    course['袋鼠真题']=OrderedDict(sorted(course['袋鼠真题'].items(),key=lambda kv:kv[0],reverse=True))

# ── 并入《初中培优讲义》(v2 录入,沪教版六上~九下,381专题,零答案) ──
# 逻辑全在 zujuan_v2 模块；配图已 base64，稍后 FIG.update 合入。
import zujuan_v2
# ── 并入《上海中考真题》(batch_exams_final: 中考+一模+二模, 零答案) ──
# 轴是 年份 → 单卷（节点带第4位分组名=考次，前端渲成 optgroup）。
# 配图**内联在题干里**：8000+ 道题的公式本身就是一张图，挪到题干下面就读不懂了。
import zujuan_exam
# ── 并入《全国中考真题》(data/approved/cn_exams_*: 31 省 2229 卷, 零答案, 已审核) ──
# 轴是 省份 → 单卷（节点第4位=年份，前端渲成 optgroup）。配图同样内联在题干里。
import zujuan_cn
# ── 并入《AMC8真题》(2000-2025, 25 套 625 题, 零答案；数据在 AMC8考情分析/题库/) ──
# 轴：年代段 → 年份卷。题面单语（有中译用中译），英文原题另存 qe 字段备用。
import zujuan_amc8
# ── 并入《上海校本卷》(各校月考/期中/期末整卷, 2026-09-07 起录；数据在 数学学习系统/data/drafts/batch_school_*) ──
# 轴：年级学期 → 单卷（节点第4位=学年 → optgroup）。中考类教材（EXAM_TBS），资产前缀 xb/。
import zujuan_school
_xb_data,_xb_course,_xb_fig=zujuan_school.load(HERE)
_zk_data,_zk_course,_zk_fig=zujuan_exam.load(HERE)
_cn_data,_cn_course,_cn_fig=zujuan_cn.load(HERE)
_am_data,_am_course,_am_fig=zujuan_amc8.load(HERE)
_v2_data,_v2_course,_v2_fig=zujuan_v2.load(HERE)
if _v2_data:
    data+=_v2_data
    course['初中培优讲义']=_v2_course
if _zk_data:
    data+=_zk_data
    course['上海中考真题']=_zk_course
if _cn_data:
    data+=_cn_data
    course['全国中考真题']=_cn_course
if _am_data:
    data+=_am_data
    course['AMC8真题']=_am_course
if _xb_data:
    data+=_xb_data
    course['上海校本卷']=_xb_course

# ── 知识点侧车合并（2026-09-02 KP 标注）──────────────────────────
# labels/{tb}.jsonl 由 知识点标注/tag_kp.py 生成（胡小群 733 KP 词表）。
# 只灌 k 为空的题；胡小群人工金标不覆盖，但**归一到 KP 正名**（alias→name），
# 否则全库词表是 1368 个自由词 + 733 个正名两张皮。培优的「知识点N」占位伪标先清掉。
_KP_ROOT = os.path.join(HERE, '..', '..')
_a2n = {}
try:
    for _fp in sorted(_glob.glob(os.path.join(_KP_ROOT, 'merged_kg', '*.json'))):
        for _p in json.load(open(_fp, encoding='utf-8')).get('knowledge_points', []):
            _a2n[_p['name']] = _p['name']
            for _al in _p.get('alias_tags') or []:
                _a2n.setdefault(_al, _p['name'])
except Exception as _e:
    print('  [KP] merged_kg 读取失败(跳过别名归一):', _e)
_lab = {}
_lb_dir = os.path.join(_KP_ROOT, '知识点标注', 'labels')
if os.path.isdir(_lb_dir):
    for _fp in sorted(_glob.glob(os.path.join(_lb_dir, '*.jsonl'))):
        for _ln in open(_fp, encoding='utf-8'):
            try:
                _r = json.loads(_ln)
            except Exception:
                continue
            if _r.get('kp'):
                _lab[_r['id']] = _r['kp']
_fakekp = re.compile(r'^知识点\d+$')
_kpst = {}
for _d in data:
    if _d['tb'] == '初中培优讲义' and _d['k']:
        _d['k'] = [x for x in _d['k'] if not _fakekp.match(x)]
    if _d['k']:
        _d['k'] = sorted(set(_a2n.get(x, x) for x in _d['k']))
    elif _d['id'] in _lab:
        _d['k'] = _lab[_d['id']]
    if _d['k']:
        _kpst[_d['tb']] = _kpst.get(_d['tb'], 0) + 1
print('  [KP] 知识点覆盖: ' + (', '.join('%s %d' % kv for kv in sorted(_kpst.items())) or '无') +
      ' | 侧车 %d 条' % len(_lab))

COURSE_JS=json.dumps(course, ensure_ascii=False)

# 配图 base64 自包含：只嵌紧致裁剪图 figure_crop（240张约6.7MB），整页扫描 page_images 不进卷面
import io as _io
def fig_b64(fp, maxdim=680, colors=48):
    """降采样≤maxdim + 调色板量化(线图少色)，取原图/处理后更小者；返回(dataURI,w,h)"""
    orig=open(fp,'rb').read()
    with Image.open(fp) as im:
        if im.mode not in ('RGB','RGBA','L','P'): im=im.convert('RGB')
        W,H=im.size; sc=min(1.0, maxdim/max(W,H))
        im2=im.resize((max(1,round(W*sc)),max(1,round(H*sc))), Image.LANCZOS) if sc<1.0 else im
        W2,H2=im2.size
        try: q=im2.convert('RGB').quantize(colors=colors, method=Image.FASTOCTREE)
        except Exception: q=im2.convert('RGB')
        buf=_io.BytesIO(); q.save(buf,'PNG',optimize=True); proc=buf.getvalue()
    if sc<1.0 or len(proc)<len(orig):
        return 'data:image/png;base64,'+base64.b64encode(proc).decode(), W2, H2
    return 'data:image/png;base64,'+base64.b64encode(orig).decode(), W, H

FIG={}
for q in items:
    fc=q.get('figure_crop')
    if not fc or fc in FIG: continue
    fp=os.path.join(HERE, fc)
    if not os.path.exists(fp): continue
    try:
        d,W,H=fig_b64(fp); FIG[fc]={'d':d,'w':W,'h':H}
    except Exception as e:
        print('skip fig',fc,e)
# 举一反三/动脑筋 配图（相对生成器目录的路径）
for rel in sorted(jyf_figs):
    if rel in FIG: continue
    fp=os.path.join(HERE, rel)
    if not os.path.exists(fp): continue
    try:
        d,W,H=fig_b64(fp); FIG[rel]={'d':d,'w':W,'h':H}
    except Exception as e:
        print('skip jyf fig',rel,e)
FIG.update(_v2_fig)                          # 并入初中培优讲义配图（已 base64）
FIG.update(_zk_fig)                          # 并入上海中考真题内联图（已 base64，key 前缀 ex/）
FIG.update(_cn_fig)                          # 并入全国中考真题内联图（key 前缀 cn/，与 ex/ 不撞）
FIG.update(_am_fig)                          # 并入 AMC8 配图（key 前缀 am/）
FIG.update(_xb_fig)                          # 并入上海校本卷内联图（key 前缀 xb/）
# ══ 分片落盘（2026-09-09 起）══════════════════════════════════════
# html 里不再内嵌题库和配图：
#   zj_assets/data/NN_k.js   ZJ.data("教材",[...])   按教材切、单文件 ≤ 12MB（Gitee 单文件 100MB 上限，gzip 后 ≈1/5）
#   zj_assets/figm.js        ZJ.figm({key:[w,h,分片id]})  配图元数据，启动时同步加载（渲染时要 w/h 判 inl/blk）
#   zj_assets/fig/sNNNN.js   ZJ.fig("sNNNN",{key:dataURI})  按题序切 ≈1.2MB 一片，**看到哪题拉哪片**（懒加载）
#   zj_assets/vendor/*.js    MathJax / docx 外链（原来内嵌 3.2MB、随 no-cache 的 html 每次重下）
# 目录固定不带版本号，URL 用 ?v=BUILD 破缓存；服务端对 /zj_assets/ 给长缓存。
# 部署侧：fig 分片里的 dataURI 可整体换成 COS URL（运行时只当 img src 用；导出要字节时自动 fetch 转 base64），
#          或把 fig/ 整个放 COS 并在页面前注入 window.ZJ_ASSET_BASE。
ASSET_DIR=os.path.normpath(os.path.join(HERE,'..','..','组卷系统','zj_assets'))
for _sub in ('data','fig','vendor'): os.makedirs(os.path.join(ASSET_DIR,_sub),exist_ok=True)
_written=set()
def _wjs(rel, text):
    fp=os.path.join(ASSET_DIR,rel); open(fp,'w',encoding='utf-8').write(text); _written.add(rel); return len(text.encode())
# 1) 题库：按教材切，再按体积切块
DATA_LIMIT=12*1024*1024
data_files=[]; _tb_order=list(course.keys())
for _ti,_tb in enumerate(_tb_order):
    _rows=[d for d in data if d['tb']==_tb]; _chunk=[]; _size=2; _k=0
    def _flush():
        global _k
        if not _chunk: return
        rel='data/%02d_%d.js'%(_ti,_k); _wjs(rel,'ZJ.data(%s,%s);'%(json.dumps(_tb,ensure_ascii=False),json.dumps(_chunk,ensure_ascii=False)))
        data_files.append(rel); _k+=1
    for d in _rows:
        js=json.dumps(d,ensure_ascii=False);
        if _chunk and _size+len(js.encode())>DATA_LIMIT: _flush(); _chunk=[]; _size=2
        _chunk.append(d); _size+=len(js.encode())+1
    _flush()
_orphan=[d for d in data if d['tb'] not in course]
assert not _orphan, '有题不在任何教材目录里: %d' % len(_orphan)
# 2) 配图：按题序分片，FIGM 记 [w,h,分片id]
FIG_LIMIT=int(1.2*1024*1024)
_asset_re=re.compile(r'!\[[^\]]*\]\(asset://([A-Za-z0-9_\-]+)\)')
_FIG_PFX=('ex/','cn/','am/','xb/')
FIGM={}; _shard={}; _ssize=0; _sid=0; fig_files=[]
def _fflush():
    global _shard,_ssize,_sid
    if not _shard: return
    name='s%04d'%_sid; rel='fig/%s.js'%name
    _wjs(rel,'ZJ.fig(%s,%s);'%(json.dumps(name),json.dumps(_shard,ensure_ascii=False)))
    for k in _shard: FIGM[k][2]=name
    fig_files.append(rel); _shard={}; _ssize=0; _sid+=1
def _take(k):
    global _ssize
    if k in FIGM or k not in FIG: return
    v=FIG[k]; FIGM[k]=[v['w'],v['h'],None]; _shard[k]=v['d']; _ssize+=len(v['d'])
    if _ssize>=FIG_LIMIT: _fflush()
for d in data:
    ks=([d['f']] if d.get('f') else [])+list(d.get('mf') or [])
    for aid in _asset_re.findall(d.get('q') or ''):
        for p in _FIG_PFX:
            if p+aid in FIG: ks.append(p+aid); break
    for k in ks: _take(k)
for k in FIG: _take(k)          # 没被任何题引用的孤儿图也落盘，别丢
_fflush()
assert all(v[2] for v in FIGM.values()) and len(FIGM)==len(FIG), 'FIGM 与 FIG 不等长'
_wjs('figm.js','ZJ.figm(%s);'%json.dumps(FIGM,ensure_ascii=False))
# 3) vendor
for _fn in ('tex-svg.js','docx.iife.js'):
    shutil.copyfile(os.path.join(HERE,'vendor',_fn),os.path.join(ASSET_DIR,'vendor',_fn)); _written.add('vendor/'+_fn)
# 4) 清掉上一版留下、这版没再写的分片（只动 data/ fig/ 两个生成器自己的目录）
_stale=0
for _sub in ('data','fig'):
    for _f in os.listdir(os.path.join(ASSET_DIR,_sub)):
        if _sub+'/'+_f not in _written: os.remove(os.path.join(ASSET_DIR,_sub,_f)); _stale+=1
_tot=lambda rels: sum(os.path.getsize(os.path.join(ASSET_DIR,r)) for r in rels)
print('分片: 题库 %d 个文件 %.1f MB | 配图 %d 张 → %d 片 %.1f MB | figm %.1f MB | 清理旧片 %d'%(
    len(data_files),_tot(data_files)/1e6,len(FIG),len(fig_files),_tot(fig_files)/1e6,os.path.getsize(os.path.join(ASSET_DIR,'figm.js'))/1e6,_stale))
# 每个分片 <script> 后面跟一个 ZJ._mark：记下此刻已装载的分片数。分片被截断时是 SyntaxError（不触发 onerror），
# 只有靠「mark 前后计数没涨」才知道是哪一片坏了，再对那一片同步重拉（见 __LOADERS__ 后的重试段）。
LOADERS=''.join('<script src="zj_assets/%s?v=%s"></script><script>ZJ._mark("%s")</script>\n'%(r,BUILD,r) for r in ['figm.js']+data_files)
qtypes=sorted(set(d['t'] for d in data))
OPT_TB=''.join(f'<option value="{html.escape(tb)}">{html.escape(tb)}</option>' for tb in course)
OPT_T=''.join(f'<option value="{html.escape(t)}">{html.escape(t)}</option>' for t in qtypes)

PAGE = r'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>DIFrobot数学组卷台</title>
<script>/* ⚠ 显式指定 inlineMath 会**替换**掉 MathJax 的默认定界符，\( \) 就不认了。
   胡小群/举一反三用 $…$，上海中考真题全部用 \(…\)，两套都得列上。*/
window.MathJax={tex:{inlineMath:[['$','$'],['\\(','\\)']],displayMath:[['$$','$$'],['\\[','\\]']]},svg:{fontCache:'local'},options:{renderActions:{addMenu:[]}}};</script>
<script src="zj_assets/vendor/tex-svg.js?v=__BUILD__"></script>
<script src="zj_assets/vendor/docx.iife.js?v=__BUILD__"></script>
<style>
:root{--paper:#f5f1e8;--ink:#26211b;--ink2:#7d7466;--brown:#b07a34;--seal:#b23a2c;--line:#e3dccd;--card:#fffdf8;--ok:#5a8f3c}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:"PingFang SC","Noto Serif SC",serif;background:var(--paper);color:var(--ink);line-height:1.65}
button{font-family:inherit;cursor:pointer}
.bar{position:sticky;top:0;z-index:30;background:rgba(245,241,232,.97);backdrop-filter:blur(6px);border-bottom:2px solid var(--ink);padding:10px 20px;display:flex;align-items:center;gap:12px}
.bar h1{font-size:17px;display:flex;align-items:center;gap:9px}
.bar .seal{display:inline-grid;place-content:center;width:28px;height:28px;border:2px solid var(--seal);color:var(--seal);border-radius:5px;font-size:14px;font-weight:900;transform:rotate(-5deg)}
.bar small{font-weight:400;color:var(--ink2);font-size:12px}.bar .sp{flex:1}
.btn{padding:7px 15px;border-radius:8px;border:1.5px solid var(--brown);background:var(--brown);color:#fff;font-size:13.5px}
.btn.gh{background:var(--card);color:var(--brown)}.btn.sm{padding:3px 10px;font-size:12px;border-radius:6px}
.layout{display:grid;grid-template-columns:1fr 380px;align-items:start}
@media(max-width:920px){.layout{grid-template-columns:1fr}.cart{position:static!important;border-left:0!important;border-top:2px solid var(--ink);height:auto!important}}
.lib{padding:12px 20px 80px}
.filters{position:sticky;top:50px;background:var(--paper);padding:9px 0;z-index:10;border-bottom:1px dashed var(--line)}
.frow{display:flex;flex-wrap:wrap;gap:7px 12px;align-items:center}
.frow+.frow{margin-top:7px}
.fg{display:flex;align-items:center;gap:5px}.fg label{font-size:11.5px;color:var(--ink2)}
.course{font-weight:600}
select,input[type=text]{font-family:inherit;font-size:12.5px;padding:5px 8px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--ink)}
#fsec{min-width:230px;border-color:var(--brown)}
input.s{min-width:150px}
.chips{display:flex;gap:4px}.chip{font-size:11.5px;padding:2px 9px;border-radius:18px;border:1px solid var(--line);background:var(--card);cursor:pointer;color:var(--ink2)}
.chip.on{background:var(--brown);color:#fff;border-color:var(--brown)}
.ck{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--ink2);cursor:pointer}
.cnt{margin-left:auto;font-size:12.5px;color:var(--ink2)}.cnt b{color:var(--seal)}
.addall{font-size:12px;padding:3px 11px;border-radius:6px;border:1.5px solid var(--ok);background:var(--card);color:var(--ok)}
.q{background:var(--card);border:1px solid var(--line);border-radius:9px;padding:11px 14px;margin:9px 0;position:relative}
.q.added{border-color:var(--ok);box-shadow:inset 3px 0 0 var(--ok)}
.qh{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--ink2);margin-bottom:6px}
.qh .loc{color:var(--brown);font-weight:600}.qh .qt{background:#efe7d6;color:#6b5a3a;padding:0 8px;border-radius:11px;font-size:11px}
/* 题号角标：题号在 d.n 里，题干里已剥掉，这里画出来（不然不知道是原卷第几题） */
.qh .qno{background:#fff;border:1px solid var(--line);color:#6b5a3a;padding:0 7px;border-radius:11px;font-size:11px;font-variant-numeric:tabular-nums}
.qh .src{font-size:10.5px;padding:0 7px;border-radius:10px;font-weight:600}
.qh .src.jy{background:#e3efe0;color:#3f7a2e}.qh .src.kh{background:#e7ecf6;color:#3a5a96}
.qh .df{color:var(--brown);letter-spacing:1px}.qh .id{color:#cbc4b6;font-size:10px;font-family:monospace}
.stem{font-size:14.5px;white-space:pre-line}
/* 题干内联图（上海中考真题）：矮的是公式图，随行走；高的是几何图，独占一行 */
img.inl{vertical-align:middle;max-height:2.3em;max-width:100%}
/* 几何图尺寸对齐胡小群配图（.figs img 的 230x180）—— 原来 100%x320px 太占版面：
   实测 4820 张几何图里 75% 会被渲到 308px 高，一道题就吃掉大半屏。
   缩到 230x180 后中位高 134px。看不清就点开放大（zoom 弹层）。 */
img.blk{display:block;max-width:230px;max-height:180px;margin:5px 0;cursor:zoom-in}
.figs{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
.figs img{max-width:230px;max-height:180px;border:1px solid var(--line);border-radius:6px;background:#fff;cursor:zoom-in}
.figs img.pg{max-width:110px;max-height:150px;opacity:.9}
.stxt{margin:1px 0}
.qtbl{border-collapse:collapse;margin:6px 0}
.qtbl td{border:1px solid #999;padding:2px 10px;text-align:center;font-size:.95em;min-width:1.7em}
#paper .qtbl{margin:6px 0}#paper .qtbl td{border:1px solid #333;padding:3px 11px}
/* AI 助手（出类似题） */
#aiDrawer{position:fixed;top:0;right:-490px;width:470px;max-width:94vw;height:100vh;z-index:61;background:var(--card);border-left:2px solid var(--ink);box-shadow:-8px 0 30px #0003;display:flex;flex-direction:column;transition:right .25s}
#aiDrawer.open{right:0}
.ai-h{padding:11px 15px;border-bottom:1.5px solid var(--ink);display:flex;align-items:center;gap:8px}
.ai-h b{font-size:15px}.ai-h .warn{font-size:10.5px;color:var(--seal);margin-left:auto;font-weight:600}
.ai-h .x{background:none;border:none;font-size:18px;color:var(--ink2);cursor:pointer}
.ai-msgs{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
.ai-msg{padding:9px 12px;border-radius:10px;font-size:13.5px;line-height:1.65;max-width:93%}
.ai-msg.u{align-self:flex-end;background:var(--brown);color:#fff;white-space:pre-wrap}
.ai-msg.a{align-self:flex-start;background:#f3efe6;border:1px solid var(--line);white-space:pre-wrap}
.ai-msg.a .tag{display:block;font-size:10px;color:var(--seal);margin-bottom:4px;font-weight:600}
.ai-msg.sys{align-self:center;color:var(--ink2);font-size:12px;text-align:center;background:#f7f3ea;border-radius:8px}
.ai-in{border-top:1px solid var(--line);padding:10px;display:flex;gap:8px}
.ai-in button{background:var(--brown);color:#fff;border:none;border-radius:8px;padding:9px 16px;font-size:13.5px;cursor:pointer;flex:1}
.ai-in button:disabled{opacity:.5}
.sim-src{border-bottom:1px solid var(--line);padding:11px 15px;background:#faf7f0;font-size:13px;max-height:26vh;overflow-y:auto}
.sim-src .sim-srch{font-size:11px;color:var(--ink2);font-weight:600;margin-bottom:5px}
.sim-src .sim-srcq{line-height:1.6}
.sim-src .sim-srcm{font-size:11px;color:var(--ink2);margin-top:4px}
.sim-item{background:#f3efe6;border:1px solid var(--line);border-radius:10px;padding:11px 13px;margin-bottom:2px}
.sim-type{display:inline-block;font-size:10.5px;color:var(--brown);background:#fff;border:1px solid var(--line);border-radius:5px;padding:1px 7px;margin-bottom:6px}
.sim-tabs{display:flex;gap:6px;padding:8px 13px 0}
.sim-tab{flex:1;font:600 12px/1.4 inherit;color:var(--ink2);background:#faf6ec;border:1px solid var(--line);border-radius:8px;padding:8px 4px;cursor:pointer}
.sim-tab.on{color:#fff;background:var(--brown);border-color:var(--brown)}
.sim-tab.off{opacity:.38;cursor:not-allowed}
.sim-tabtip{font-size:11px;color:var(--ink2);padding:5px 13px 0;min-height:15px}
.sim-xkw{font-size:10.5px;color:#7a6a4d;background:#fdf6e3;border:1px solid #e6d9b8;border-radius:5px;padding:1px 7px;margin:0 0 6px 5px;display:inline-block}
.sim-ans{margin-top:7px}
.sim-ans summary{cursor:pointer;color:var(--brown);font-weight:600;font-size:11.5px}
.sim-ans .ansbody{padding:6px 0 2px;font-size:12.5px;color:var(--ink2)}
.sim-item img{max-width:100%;vertical-align:middle}
.sim-cr{font-size:10px;color:#9a8f7a;margin-top:7px}
.sim-unv{display:inline-block;font-size:10.5px;color:#8a7f6a;background:#faf6ec;border:1px dashed var(--line);border-radius:5px;padding:1px 7px;margin:0 0 6px 5px}
.sim-item .sim-q{font-size:13.5px;line-height:1.66}
.sim-ans{margin:7px 0 0;font-size:12.5px}
.sim-ans summary{cursor:pointer;color:var(--brown);font-size:11.5px}
.sim-ans>div{margin-top:5px;padding:7px 9px;background:#fff;border:1px dashed var(--line);border-radius:7px;color:#555;line-height:1.6}
.sim-add{margin-top:9px;font-size:12px;padding:4px 12px;border-radius:7px;border:1px solid var(--brown);background:var(--card);color:var(--brown);cursor:pointer}
.sim-add.in{background:var(--brown);color:#fff}
.sim-figure-link{display:block;margin-top:9px;padding:7px 9px;border-radius:7px;background:#fffdf8;border:1px dashed #c7b792;color:#705728;font-size:11.5px;font-weight:600;text-decoration:none}
.sim-figure-link:hover{background:#fff7e6;border-style:solid}
.sim-wait{color:var(--ink2);font-size:13px;text-align:center;padding:24px 12px;line-height:1.7}
/* 考评分析：和“出类似题”分工明确，显示的是已审核历史数据而非聊天推测 */
#analysisDrawer{position:fixed;top:0;right:-450px;width:430px;max-width:94vw;height:100vh;z-index:62;background:#fcfaf4;border-left:2px solid var(--ink);box-shadow:-10px 0 34px #0003;display:flex;flex-direction:column;transition:right .25s cubic-bezier(.2,.7,.2,1)}
#analysisDrawer.open{right:0}
.an-h{padding:12px 15px 10px;border-bottom:1.5px solid var(--ink);background:#f0eadb;display:flex;align-items:flex-start;gap:9px}
.an-kicker{font-size:10px;letter-spacing:.13em;color:#897650;font-weight:800;margin-bottom:2px}.an-title{font-size:15px;font-weight:800}.an-meta{font-size:11px;color:var(--ink2);margin-top:3px;max-width:330px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.an-h .x{margin-left:auto;background:none;border:0;color:var(--ink2);font-size:19px;cursor:pointer;line-height:1}
.an-body{flex:1;overflow-y:auto;padding:14px;background:linear-gradient(90deg,rgba(141,111,63,.06) 1px,transparent 1px) 0 0/24px 24px}
.an-state{margin:40px 8px;text-align:center;color:var(--ink2);font-size:13px;line-height:1.8}.an-state b{display:block;color:var(--ink);font-size:14px;margin-bottom:5px}.an-spin{display:inline-block;width:16px;height:16px;border:2px solid #d4c5a6;border-top-color:var(--seal);border-radius:50%;animation:anSpin .8s linear infinite;margin-bottom:8px}@keyframes anSpin{to{transform:rotate(360deg)}}
.an-section{background:#fffdf8;border:1px solid #ded4c0;border-radius:8px;margin-bottom:10px;overflow:hidden;box-shadow:0 2px 0 #eadfca}.an-section h3{font-size:11px;letter-spacing:.08em;color:#806b44;padding:8px 10px 7px;margin:0;border-bottom:1px solid #ede4d4}.an-pad{padding:9px 10px}.an-chips{display:flex;gap:5px;flex-wrap:wrap}.an-chip{font-size:11px;border-radius:13px;padding:3px 8px;background:#f3ede1;color:#4d4332;border:1px solid #e4d9c5}.an-chip.primary{background:#4d6d57;color:#fff;border-color:#4d6d57}
.an-metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:#e2d8c5}.an-metric{background:#fffdf8;padding:8px 7px;text-align:center}.an-metric b{display:block;color:#3e3322;font-size:17px;font-family:Georgia,"Times New Roman",serif}.an-metric span{font-size:10px;color:#867964}
.an-yearline{display:flex;align-items:flex-end;gap:3px;height:39px;margin:10px 1px 0}.an-year{flex:1;min-width:9px;background:#b28a4e;border-radius:2px 2px 0 0;position:relative}.an-year:hover:after{content:attr(data-tip);position:absolute;bottom:100%;left:50%;transform:translateX(-50%);white-space:nowrap;background:#332b20;color:#fff;padding:3px 5px;border-radius:4px;font-size:10px;z-index:2}
.an-list{display:flex;flex-direction:column;gap:7px}.an-sim{display:flex;gap:8px;align-items:baseline;padding-bottom:7px;border-bottom:1px dashed #dfd5c2;font-size:12px}.an-sim:last-child{padding-bottom:0;border:0}.an-score{color:#9d4838;font-weight:800;font-family:Georgia,serif;min-width:34px}.an-sim small{color:var(--ink2);margin-left:auto}.an-note{font-size:10.5px;color:#8b806d;line-height:1.55;padding:2px 3px}.an-foot{padding:9px 14px;border-top:1px solid #d9ceb9;background:#f7f2e7;color:#857961;font-size:10.5px;line-height:1.45}
.open-analysis{font-size:11px;padding:1px 8px;border-radius:6px;border:1px solid #6f8063;background:var(--card);color:#47623d;cursor:pointer}
.open-analysis:hover{background:#edf3e9}
.ask-ai{font-size:11px;padding:1px 8px;border-radius:6px;border:1px solid var(--seal);background:var(--card);color:var(--seal);cursor:pointer}
.report{font-size:11px;padding:1px 8px;border-radius:6px;border:1px solid #d9b7ae;background:var(--card);color:#b5432f;cursor:pointer}
.report:hover{background:#fbeceA}
.report.done{color:#5a8f3c;border-color:#bfe0c0;background:#f0f8f0;cursor:default}
.ask-ai.nofig{border:1px dashed var(--ink2);color:var(--ink2);background:transparent;cursor:default}
.see-src{font-size:11px;padding:1px 8px;border-radius:6px;border:1px solid var(--brown);background:var(--card);color:var(--brown);cursor:pointer}
a.see-src.dl{text-decoration:none;display:inline-block;line-height:1.55}
#srcModal{position:fixed;inset:0;background:rgba(20,16,10,.86);z-index:100;display:none;flex-direction:column}
#srcModal .sm-head{flex:0 0 auto;display:flex;align-items:center;gap:14px;padding:10px 18px;color:#f5f1e8;background:#26211b;font-size:13.5px}
#srcModal .sm-head b{color:#e9c88a}
#srcModal .sm-tip{color:#b8ad9a;font-size:11.5px}
#srcModal .sm-close{margin-left:auto;background:none;border:1px solid #6b6152;color:#f5f1e8;border-radius:6px;width:30px;height:26px;cursor:pointer;font-size:14px}
#srcModal .sm-body{flex:1 1 auto;overflow:auto;text-align:center;padding:16px 10px}
#srcModal .sm-body img{max-width:min(96vw,860px);width:100%;display:block;margin:0 auto 14px;background:#fff;border-radius:4px;box-shadow:0 3px 14px #0006}
#srcModal .sm-pgno{color:#c9beac;font-size:11.5px;margin:2px 0 10px}
#srcModal .sm-miss{color:#e9c88a;font-size:13px;padding:40px 20px;line-height:1.8}
/* 报问题弹窗：点「报问题」先写清楚哪儿不对，再落 问题报告.jsonl。
   原来是一点就发、只带题干前 300 字 —— 事后看报告根本不知道老师指的是什么。 */
#rpModal{position:fixed;inset:0;background:rgba(20,16,10,.7);z-index:120;display:none;align-items:center;justify-content:center;padding:20px}
#rpModal .rp-box{background:var(--card);border:2px solid var(--ink);border-radius:10px;width:min(560px,96vw);max-height:88vh;display:flex;flex-direction:column;box-shadow:0 12px 40px #0005}
#rpModal .rp-h{padding:11px 15px;border-bottom:1.5px solid var(--ink);display:flex;align-items:center;gap:8px}
#rpModal .rp-h b{font-size:15px}#rpModal .rp-h .x{margin-left:auto;background:none;border:none;font-size:18px;color:var(--ink2);cursor:pointer}
#rpModal .rp-body{padding:13px 15px;overflow:auto}
#rpModal .rp-q{background:#f6f1e6;border:1px solid var(--line);border-radius:7px;padding:8px 10px;font-size:12.5px;color:#5c5140;max-height:130px;overflow:auto;line-height:1.6}
#rpModal .rp-q .rp-loc{color:var(--brown);font-weight:600;display:block;margin-bottom:3px}
#rpModal .rp-tags{display:flex;flex-wrap:wrap;gap:6px;margin:11px 0 8px}
#rpModal .rp-tag{background:#efe7d6;border:1px solid var(--line);color:#6b5a3a;border-radius:13px;padding:3px 11px;font-size:12px;cursor:pointer;user-select:none}
#rpModal .rp-tag.on{background:var(--brown);border-color:var(--brown);color:#fff}
#rpModal textarea{width:100%;min-height:88px;font:13.5px/1.65 inherit;padding:9px 10px;border:1px solid var(--line);border-radius:7px;background:#fff;resize:vertical;box-sizing:border-box}
#rpModal .rp-f{padding:10px 15px;border-top:1px solid var(--line);display:flex;align-items:center;gap:9px}
#rpModal .rp-f .hint{font-size:11.5px;color:#8b806d;margin-right:auto}
#rpModal .rp-f button{border:1.5px solid var(--ink);border-radius:7px;padding:6px 16px;font-size:13.5px;cursor:pointer;background:var(--card)}
#rpModal .rp-f .go{background:var(--brown);border-color:var(--brown);color:#fff;font-weight:600}
#rpModal .rp-f .go:disabled{opacity:.45;cursor:not-allowed}
.citem .ops .ask{color:var(--seal);font-size:9px}
.kn{margin-top:8px;display:flex;gap:5px;flex-wrap:wrap}.kn span{font-size:10.5px;color:var(--ink2);background:#f0ece1;border-radius:10px;padding:1px 8px}
.add{position:absolute;right:12px;bottom:11px;padding:4px 13px;font-size:12.5px;border-radius:7px;border:1.5px solid var(--brown);background:var(--card);color:var(--brown)}
.add.in{background:var(--ok);border-color:var(--ok);color:#fff}
.more{display:block;margin:18px auto;padding:9px 24px;font-size:13.5px;border:1.5px solid var(--brown);color:var(--brown);background:var(--card);border-radius:8px}
.empty{text-align:center;color:var(--ink2);padding:40px}
.cart{position:sticky;top:50px;height:calc(100vh - 50px);overflow-y:auto;border-left:2px solid var(--ink);background:#faf6ee;padding:13px 15px}
.cart h2{font-size:15px;margin-bottom:8px;display:flex;align-items:center;gap:8px}
.cart h2 .n{background:var(--seal);color:#fff;border-radius:11px;font-size:12px;padding:1px 9px}
.set{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin-bottom:10px;font-size:12.5px}
.set label{display:block;color:var(--ink2);margin:7px 0 3px}.set input[type=text]{width:100%}
.set .row{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px;align-items:center}
.dist{font-size:11.5px;color:var(--ink2);margin:6px 0 10px;display:flex;gap:8px;flex-wrap:wrap}.dist b{color:var(--ink)}
.citem{background:var(--card);border:1px solid var(--line);border-radius:7px;padding:7px 9px;margin:6px 0;font-size:12.5px;display:flex;gap:8px;align-items:flex-start}
.citem .no{color:var(--seal);font-weight:700;min-width:18px}.citem .ct{flex:1;max-height:3.4em;overflow:hidden;color:#34302a}
.citem .ops{display:flex;flex-direction:column;gap:2px}
.citem .ops button{border:1px solid var(--line);background:var(--card);border-radius:4px;width:22px;height:18px;font-size:11px;line-height:1;color:var(--ink2)}.citem .ops .del{color:var(--seal)}
.citem .ops .cop-sim{color:var(--seal);cursor:pointer}.citem .ops .cop-sim.nofig{border-style:dashed;color:var(--ink2);cursor:default;opacity:.7}
.cempty{color:var(--ink2);font-size:12.5px;text-align:center;padding:24px 0}
.exp{display:flex;gap:8px;margin-top:12px;position:sticky;bottom:0;background:#faf6ee;padding-top:8px}.exp .btn{flex:1;text-align:center}
#preview{position:fixed;inset:0;background:rgba(30,24,16,.55);z-index:50;display:none;overflow:auto;padding:24px 0}
.pv-bar{position:sticky;top:0;display:flex;gap:10px;justify-content:center;margin-bottom:16px;z-index:2}
/* 试卷：专题练习模板样式（仿宋） */
#paper{background:#fff;width:210mm;max-width:94vw;margin:0 auto;padding:16mm 31.7mm;box-shadow:0 8px 40px #0006;color:#111;
  font-family:"FangSong","仿宋","STFangsong","SimSun","宋体",serif;font-size:11.5pt;line-height:2}
.p-head{text-align:center;margin-bottom:14px}
.p-head .pt{font-size:11pt;font-weight:700;letter-spacing:1px}
.p-head .pd{font-size:16pt;font-weight:700;margin-top:2px}
.p-head .psub{font-size:10.5pt;margin-top:4px}
#verbar{position:sticky;top:0;z-index:999;background:#bf4329;color:#fff;padding:9px 16px;font-size:13.5px;text-align:center;line-height:1.6}
.p-sech{font-weight:700;font-size:12pt;margin:12px 0 6px;border-left:4px solid #111;padding-left:8px}
.p-body.cols2{column-count:2;column-gap:12mm}
.pq{break-inside:avoid;margin:0 0 11px}.pq .qn{font-weight:700}
.pq .pfig{margin:5px 0}.pq .pfig img{max-width:72mm;max-height:52mm}
.pqrow{display:flex;gap:6mm;break-inside:avoid;margin:0 0 11px}
.pqrow .pq{flex:1 1 50%;min-width:0;margin:0}
.wspace{break-inside:avoid}
#zoom{position:fixed;inset:0;background:rgba(20,16,10,.82);z-index:99;display:none;place-content:center;cursor:zoom-out}
#zoom img{max-width:94vw;max-height:94vh;background:#fff;border-radius:6px}
@media print{body>.bar,body>.layout,#preview .pv-bar,#zoom{display:none!important}
  #preview{position:static;background:#fff;padding:0;display:block!important}
}
/* 整页出类似题 */
#batchBar{display:none;align-items:center;gap:12px;margin:10px 0 2px}
#batchBar .batch{padding:8px 17px;border-radius:8px;border:1.5px solid var(--seal);background:var(--seal);color:#fff;font-size:13.5px;cursor:pointer;font-weight:600}
#batchBar .batch.ghost{background:transparent;color:var(--seal)}
#batchBar .batch:disabled{opacity:.45;cursor:default}
#batchBar .batch-hint{font-size:12px;color:var(--ink2)}
#batchModal,#composeModal{position:fixed;inset:0;background:rgba(20,16,10,.86);z-index:100;display:none;flex-direction:column}
#batchModal .bm-head,#composeModal .bm-head{flex:0 0 auto;display:flex;align-items:center;gap:13px;padding:10px 18px;color:#f5f1e8;background:#26211b;font-size:13.5px}
#batchModal .bm-head b,#composeModal .bm-head b{color:#e9c88a;font-size:15px}
#batchModal .bm-head .warn,#composeModal .bm-head .warn{color:#e0a2a2;font-size:11px}
#batchModal .bm-prog,#composeModal .bm-prog{color:#c9beac;font-size:12px;margin-left:auto}
#batchModal .bm-close,#composeModal .bm-close{background:none;border:1px solid #6b6152;color:#f5f1e8;border-radius:6px;width:30px;height:26px;cursor:pointer;font-size:14px}
#batchModal .bm-body,#composeModal .bm-body{flex:1 1 auto;overflow:auto;padding:16px;display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:12px;align-content:start}
/* 我的试卷：复用 batchModal 的头/关闭样式，body 换成竖排列表 */
/* AI 组卷：输入卡片 + 预览清单（清单顺序有意义，用单列不用网格） */
#composeModal .bm-body{display:block;max-width:920px;margin:0 auto;width:100%}
#composeModal .cp-und{color:#f0e8d8;font-size:12.5px;max-width:46vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cp-guess{color:#e0a2a2;font-size:11px;border:1px solid #7a4b4b;border-radius:4px;padding:1px 5px;margin-left:6px}
.cp-note{background:#3a3226;color:#e8dcc4;border-radius:8px;padding:10px 14px;margin-bottom:12px;font-size:12.5px;line-height:1.8}
.cp-row{background:var(--card);border:1px solid var(--line);border-radius:9px;padding:10px 14px;margin-bottom:10px}
.cp-tags{display:flex;align-items:center;gap:8px;margin-bottom:6px}
.cp-tags b{color:var(--brown);font-size:13px;min-width:18px}
.cp-tag{font-size:10.5px;color:var(--ink2);border:1px solid var(--line);border-radius:4px;padding:1px 6px}
.cp-tag.gen{color:#7a6c3f;border-color:#c9b26a;background:#faf5e4}
.cp-tag i{color:#b05c5c;font-style:normal;margin-left:3px}
.cp-ops{margin-left:auto;display:flex;gap:6px}
.cp-stem{font-size:13.5px;line-height:1.75}
#composeAsk{position:fixed;inset:0;background:rgba(20,16,10,.6);z-index:110;display:none;align-items:center;justify-content:center}
#composeAsk .ca-card{background:var(--card);border-radius:12px;padding:20px 22px;width:min(560px,92vw);box-shadow:0 18px 60px rgba(0,0,0,.35)}
#composeAsk b{font-size:15px}
#composeAsk .ca-hint{color:var(--ink2);font-size:11.5px;margin:6px 0 10px}
#composeAsk textarea{width:100%;box-sizing:border-box;border:1px solid var(--line);border-radius:8px;padding:9px 11px;font-size:13.5px;font-family:inherit;resize:vertical}
#composeAsk .ca-ex{display:flex;flex-wrap:wrap;gap:6px;margin:9px 0}
#composeAsk .chip{font-size:11px;color:var(--brown);border:1px solid var(--line);border-radius:20px;padding:3px 10px;cursor:pointer}
#composeAsk .chip:hover{background:var(--bg2)}
#composeAsk .ca-err{color:#b05c5c;font-size:12px;min-height:16px;margin:2px 0 6px}
#composeAsk .ca-btns{display:flex;justify-content:flex-end;gap:8px}
#papersModal{position:fixed;inset:0;background:rgba(20,16,10,.86);z-index:100;display:none;flex-direction:column}
#papersModal .bm-head{flex:0 0 auto;display:flex;align-items:center;gap:13px;padding:10px 18px;color:#f5f1e8;background:#26211b;font-size:13.5px}
#papersModal .bm-head b{color:#e9c88a;font-size:15px}
#papersModal .bm-head .warn{color:#c9beac;font-size:11px}
#papersModal .bm-close{margin-left:auto;background:none;border:1px solid #6b6152;color:#f5f1e8;border-radius:6px;width:30px;height:26px;cursor:pointer;font-size:14px}
#papersModal .bm-body{flex:1 1 auto;overflow:auto;padding:18px;max-width:840px;margin:0 auto;width:100%}
.pp{background:var(--card);border:1px solid var(--line);border-radius:9px;padding:11px 14px;margin-bottom:10px;display:flex;align-items:center;gap:12px}
.pp .nm{font-weight:600;font-size:14px}
.pp .meta{color:var(--ink2);font-size:12px;margin-top:2px}
.pp .ops{margin-left:auto;display:flex;gap:7px;flex:0 0 auto}
.pp-empty{color:#b8ad9a;text-align:center;padding:40px 0;font-size:13.5px}
/* 答案索引：告诉老师这份卷的答案分别在哪份源文件里，卷面本身不印任何东西 */
#keyModal{position:fixed;inset:0;background:rgba(20,16,10,.86);z-index:100;display:none;flex-direction:column}
#keyModal .bm-head{flex:0 0 auto;display:flex;align-items:center;gap:13px;padding:10px 18px;color:#f5f1e8;background:#26211b;font-size:13.5px}
#keyModal .bm-head b{color:#e9c88a;font-size:15px}
#keyModal .bm-head .warn{color:#c9beac;font-size:11px}
#keyModal .bm-head .sp{margin-left:auto}
#keyModal .bm-close{background:none;border:1px solid #6b6152;color:#f5f1e8;border-radius:6px;width:30px;height:26px;cursor:pointer;font-size:14px}
#keyModal .bm-body{flex:1 1 auto;overflow:auto;padding:22px 18px}
.kw{max-width:820px;margin:0 auto;background:#fff;border-radius:10px;padding:26px 30px;color:#1d1a15}
.kw h3{font-size:17px;margin-bottom:3px}
.kw .sub{color:#6b6152;font-size:12.5px;margin-bottom:16px}
.kg{border-top:1px solid #e3ddd0;padding:11px 0}
.kg .hd{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
.kg .no{font-weight:700;color:#9a3b30;font-size:14px;flex:0 0 auto}
.kg .loc{font-size:13.5px}
.kg .pgs{color:#6b6152;font-size:12px}
.kg .fl{margin:6px 0 0 0;font-size:13px;padding-left:2px}
.kg .fl a{color:#8a5a20;text-decoration:none;margin-right:12px}
.kg .fl .nm{color:#3d3830}
.kg .none{color:#a08c6a;font-size:12.5px}
.bm-pair{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:11px 13px;display:flex;flex-direction:column}
.bm-pair .bm-orig{font-size:11px;color:var(--ink2);border-left:3px solid var(--line);padding-left:8px;margin-bottom:8px;line-height:1.5;max-height:4.4em;overflow:hidden}
.bm-pair .bm-type{align-self:flex-start;font-size:10.5px;color:var(--seal);border:1px solid var(--seal);border-radius:5px;padding:0 6px;margin-bottom:5px}
.bm-pair .bm-new{font-size:13.5px;line-height:1.62;margin-bottom:9px;flex:1}
.bm-pair .bm-add{padding:6px;border-radius:7px;border:1.5px solid var(--brown);background:var(--card);color:var(--brown);font-size:12.5px;cursor:pointer}
.bm-pair .bm-add.in{background:var(--brown);color:#fff}
.bm-pair.err{opacity:.75}.bm-pair.err .bm-new{color:var(--seal);font-size:12.5px}
.bm-pair .bm-retry{font-size:11.5px;color:var(--seal);background:none;border:none;cursor:pointer;text-decoration:underline;align-self:flex-start}
@media print{#batchModal,#papersModal,#keyModal{display:none!important}
  #paper{box-shadow:none;width:auto;max-width:none;margin:0;padding:0}.pq{break-inside:avoid}}
/* 打印答案索引时只留它（body 上临时加 .pkey） */
@media print{body.pkey #preview{display:none!important}
  body.pkey #keyModal{display:block!important;position:static;background:#fff;padding:0}
  body.pkey #keyModal .bm-head{display:none!important}
  body.pkey #keyModal .bm-body{overflow:visible;padding:0}
  body.pkey .kw{max-width:none;padding:0;border-radius:0}.kg{break-inside:avoid}}
</style></head><body>
<div class="bar">
  <h1><span class="seal">D</span>DIFrobot数学组卷台 <small>按课程节选题</small></h1>
  <span class="sp"></span>
  <button class="btn" id="bCompose">✨ AI 组卷</button>
  <button class="btn gh" id="bPrev">试卷预览</button>
</div>
<div class="layout">
  <div class="lib">
    <div class="filters">
      <div class="frow">
        <div class="fg"><label class="course">📖 教材</label><select id="ftb"><option value="">选择教材</option>__OPT_TB__</select></div>
        <div class="fg"><label class="course">📚 年级</label><select id="fg"><option value="">先选教材</option></select></div>
        <div class="fg"><label class="course">课程节/专题</label><select id="fsec"><option value="">先选年级</option></select><div id="srcBar" style="display:none;margin-top:5px;font-size:12px;line-height:1.95"><span id="srcRows"></span><span id="srcTip" style="color:#7b8794">选中专题后可看原书</span></div></div>
      </div>
      <div class="frow">
        <div class="fg"><label>题型</label><select id="ft"><option value="">全部</option>__OPT_T__</select></div>
        <div class="fg"><label>难度</label><div class="chips" id="fd">
          <span class="chip" data-d="1">★1</span><span class="chip" data-d="2">★2</span><span class="chip" data-d="3">★3</span><span class="chip" data-d="4">★4</span><span class="chip" data-d="5">★5</span></div></div>
        <label class="ck"><input type="checkbox" id="ff"> 仅有图</label>
        <input class="s" type="text" id="fk" placeholder="知识点含…">
        <input class="s" type="text" id="fq" placeholder="搜题干…">
        <div class="cnt" id="cnt"></div>
      </div>
    </div>
    <div id="batchBar"><button class="batch" id="bAddAll">＋ 全部加入试卷篮</button><button class="batch ghost" id="bBatch">🔁 整页各出一道类似题</button><span class="batch-hint" id="batchHint"></span></div>
    <div id="list"></div>
    <button class="more" id="more" style="display:none">加载更多</button>
    <div class="empty" id="empty" style="display:none">请在上方选「教材 → 年级 → 课程节/专题」开始挑题</div>
  </div>
  <div class="cart">
    <h2>试卷篮 <span class="n" id="cn">0</span> 题<button class="btn gh sm" id="bPapers" style="margin-left:auto">📁 我的试卷 <span id="pn">0</span></button></h2>
    <div class="set">
      <label>专题名（页眉《□》专题练习）</label><input type="text" id="pTitle" placeholder="选节后自动填入，可改">
      <label>副标题行</label><input type="text" id="pSub" value="" placeholder="选填(卷头已自带日期/姓名行)，如：三年级下配套">
      <div class="row">
        <label class="ck">例题数 <input type="text" id="oEx" value="0" style="width:42px"></label>
        <label class="ck">版式 <select id="oSpace"><option value="0">不排版·只印题面</option><option value="4">一页 4 栏</option><option value="3" selected>一页 3 栏</option><option value="2">一页 2 栏</option></select></label>
      </div>
      <div class="row">
        <label class="ck"><input type="checkbox" id="oCols"> 双栏</label>
        <label class="ck"><input type="checkbox" id="oId"> 显示出处</label>
      </div>
    </div>
    <div class="dist" id="dist"></div>
    <div id="cart"></div>
    <div class="exp">
      <button class="btn" id="bPrint">打印 / 存PDF</button>
      <button class="btn gh" id="bWord">下载 Word</button>
    </div>
    <div class="exp" style="margin-top:6px">
      <button class="btn gh" id="bSave">💾 存为我的试卷</button>
      <button class="btn gh" id="bKey">📑 答案索引</button>
    </div>
    <div style="text-align:center;margin-top:8px"><button class="btn gh sm" id="bClear">清空</button></div>
  </div>
</div>
<div id="preview"><div class="pv-bar">
  <button class="btn" id="bPrint2">打印 / 存 PDF</button><button class="btn gh" id="bWord2">下载 Word</button><button class="btn gh" id="bClose">关闭</button>
</div><div id="paper"></div></div>
<div id="zoom"><img></div>
<div id="rpModal"><div class="rp-box">
 <div class="rp-h"><b>报告这道题的问题</b><button class="x" id="rpX">✕</button></div>
 <div class="rp-body">
  <div class="rp-q" id="rpQ"></div>
  <div class="rp-tags" id="rpTags"></div>
  <textarea id="rpNote" placeholder="哪儿不对？越具体越好，例如：配图是别的题的／题干缺了「（2）」这一问／根号里的数字看不清"></textarea>
 </div>
 <div class="rp-f"><span class="hint">写完提交 → 落进 问题报告.jsonl</span>
  <button id="rpCancel">取消</button><button class="go" id="rpGo">提交</button></div>
</div></div>
<div id="srcModal"><div class="sm-head"><span id="smTitle"></span><span class="sm-tip">对着原书逐字逐图核验真伪 · 左右各多显示1页备查</span><button class="sm-close" id="smClose">✕</button></div><div class="sm-body" id="smBody"></div></div>
<div id="aiDrawer">
  <div class="ai-h"><b>🔁 AI 出同类练习</b><span class="warn">⚠ AI生成·请核对</span><button class="x" id="aiClose">✕</button></div>
  <div class="sim-src" id="simSrc"></div>
  <div class="sim-tabs" id="simTabs"></div><div class="sim-tabtip" id="simTabTip"></div>
  <div class="ai-msgs" id="simList"></div>
  <div class="ai-in"><button id="simRegen">🔁 再生成一批</button></div>
</div>
<aside id="analysisDrawer" aria-label="初中考评分析">
  <div class="an-h"><div><div class="an-kicker">JUNIOR ASSESSMENT · HISTORICAL DATA</div><div class="an-title">考评分析</div><div class="an-meta" id="anMeta">选择一道题后显示</div></div><button class="x" id="anClose" aria-label="关闭">✕</button></div>
  <div class="an-body" id="anBody"><div class="an-state"><b>从题目卡片开始</b>点击“◫ 分析”，查看已审核的知识点、历史考频与相似真题。</div></div>
  <div class="an-foot">只展示已导入、已确认标注的历史数据；不作未来考试的确定性预测。</div>
</aside>
<div id="papersModal"><div class="bm-head"><b>📁 我的试卷</b><span class="warn">存在这台电脑的浏览器里 · 换电脑/清缓存会没</span><button class="bm-close" id="pmClose">✕</button></div><div class="bm-body" id="pmBody"></div></div>
<div id="keyModal"><div class="bm-head"><b>📑 答案索引</b><span class="warn">只给老师看 · 卷面上不会印任何东西</span><span class="sp"></span><button class="btn gh sm" id="kmPrint">打印 / 存 PDF</button><button class="bm-close" id="kmClose">✕</button></div><div class="bm-body" id="kmBody"></div></div>
<div id="composeAsk"><div class="ca-card"><b>✨ AI 组卷</b><div class="ca-hint">只认：题量 / 年级 / 教材 / 专题(知识点) / 难度 / 题型 / 由易到难；不会现编题，题库不够时用生成器补</div><textarea id="cpText" rows="3" placeholder="例：三年级，和差倍和周期问题各 3 道，中等难度，由易到难"></textarea><div class="ca-ex"><span class="chip">三年级，和差倍和周期问题各 3 道，中等难度，由易到难</span><span class="chip">一课一练四年级上，5 道填空 5 道应用</span><span class="chip">2024 年上海一模填空题 10 道</span></div><div class="ca-err" id="cpErr"></div><div class="ca-btns"><button class="btn gh" id="cpCancel">取消</button><button class="btn" id="cpGo">组卷</button></div></div></div>
<div id="composeModal"><div class="bm-head"><b>✨ AI 组卷</b><span class="cp-und" id="cpUnd"></span><button class="btn gh sm" id="cpEdit">✎ 改一句话</button><span class="bm-prog" id="cpProg"></span><button class="btn gh sm" id="cpAdd" style="display:none">全部加入试卷篮</button><button class="bm-close" id="cpClose">✕</button></div><div class="bm-body" id="cpBody"></div></div>
<div id="batchModal"><div class="bm-head"><b>🔁 整页出类似题</b><span class="warn">每题各1道 · ⚠AI生成·请核对</span><span class="bm-prog" id="bmProg"></span><button class="btn gh sm" id="bmAddAll" style="display:none">全部加入试卷篮</button><button class="bm-close" id="bmClose">✕</button></div><div class="bm-body" id="bmBody"></div></div>
<script>
/* 分片装载器：题库/配图元数据由后面的 <script src=zj_assets/...> 同步灌入，主脚本开始时已就绪（顺序执行，无需异步改造）。
   配图正文 ZJ.fig 在主脚本里重定义为懒加载填充器。部署侧要把 fig 放 COS 时，在本段之前注入 window.ZJ_ASSET_BASE。 */
window.ZJ={_data:[],_figm:null,_files:0,_want:__NLOAD__,_marks:[],_retried:[],_failed:[],
  data(tb,rows){this._data=this._data.concat(rows);this._files++;},
  figm(o){this._figm=o;this._files++;},
  _mark(r){this._marks.push([r,this._files]);},
  _missing(){let p=0,o=[];for(const [r,n] of this._marks){if(n<=p)o.push(r);p=n;}return o;},
  fig(){}};
</script>
__LOADERS__<script>
/* 2026-09-09 分片自动重试：线上实测一次链路瞬断把 6 个分片在同一毫秒截断（脚本 SyntaxError、不报 onerror），
   老师只看到红横幅要手动刷新。这里对没装上的分片带 cache-buster 同步重拉最多 2 次（同步 XHR：主脚本紧接着就要用 DATA，
   此时页面本来就不可交互，阻塞无感），拉到的文本先验尾巴完整再 eval，仍失败才挂横幅并列出文件名。 */
(function(){const miss=ZJ._missing();if(!miss.length)return;
  for(const r of miss){let ok=false;
    for(let a=1;a<=2&&!ok;a++){try{const x=new XMLHttpRequest();x.open('GET','zj_assets/'+r+'?v=__BUILD__&r='+a,false);x.send();
      const t=x.responseText||'';if(x.status===200&&/[\]\}]\);\s*$/.test(t.slice(-12))){(0,eval)(t);ok=true;ZJ._retried.push(r+'#'+a);}}catch(e){}}
    if(!ok)ZJ._failed.push(r);}
})();
if(!ZJ._figm||ZJ._files<ZJ._want){document.body.insertAdjacentHTML('afterbegin','<div id="verbar">⚠️ 题库分片只加载了 '+ZJ._files+'/'+ZJ._want+' 个（重试 2 次仍失败：'+(ZJ._failed.join('、')||'未知')+'），页面数据不全，请刷新；反复出现请报告。</div>');}
const DATA=ZJ._data, COURSE=__COURSE__, FIGM=ZJ._figm||{}, FIG={}, BY={}; DATA.forEach(d=>BY[d.id]=d);
const ZJ_ASSET_BASE=window.ZJ_ASSET_BASE||'zj_assets/';
const BATCH=50,$=id=>document.getElementById(id);
let diffSel=new Set(),shown=0,filtered=[],cart=[];
const esc=s=>(s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const stars=d=>d?'★'.repeat(d):'';
const stripTex=s=>String(s).replace(/!\[[^\]]*\]\(asset:\/\/[A-Za-z0-9_\-]+\)/g,'□')  // 图占位:纯文本导出留个记号,别让公式凭空消失
  .replace(/\$\$?/g,'').replace(/\\[()\[\]]/g,'').replace(/\\times/g,'×').replace(/\\div/g,'÷').replace(/\\cdot/g,'·').replace(/\\(c?dots|ldots)/g,'…').replace(/\\[dtc]?frac\{([^}]*)\}\{([^}]*)\}/g,'$1/$2').replace(/\\[a-zA-Z]+/g,'').replace(/[{}\\]/g,'').replace(/\s+/g,' ').trim();
const stripLeadNo=s=>String(s).replace(/^(\s*)\d+[.)]\s+/,'$1')          // 去题干首行开头的书本题号（仅成卷/导出用，左侧浏览列表不动）
  .replace(/^(\s*)计算[：:。．.，,]\s*/,'$1')                              // 去「计算：」及句号/逗号变体前缀（「用简便方法计算：」是解题要求，不动）
  .split('\n').map(l=>l.replace(/(\$)\s*[。．.;；]+\s*$/,'$1')).join('\n')  // 去行尾公式后紧跟的句号/分号（应用题文字句号不动）
  .replace(/^[ \t]*\n+/,'');                                              // 前缀剥掉后残留的空首行(胡小群"计算。\n$算式$"格式)，不清会误判成多行题
// ── 配图懒加载（2026-09-09）：FIG 起初是空的，FIGM 只有 {key:[w,h,分片id]}。
//    渲染时给占位 <img data-fig=key>，MutationObserver 看到就按分片批量拉、回填 src；
//    需要真字节的地方（docx 导出 / 学科网传图 / 打印预览）显式 await ensureFigs(ids)。
//    FIG[k].d 可能是 dataURI，也可能是部署侧换成的 COS URL —— 当 src 用都行，要字节走 figData()。
const FIG_BLANK='data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
const _shardP={};
function loadShard(sid){
  if(_shardP[sid])return _shardP[sid];
  _shardP[sid]=new Promise((res,rej)=>{const sc=document.createElement('script');
    sc.src=ZJ_ASSET_BASE+'fig/'+sid+'.js?v='+NEED_BUILD;
    sc.onload=()=>res(sid); sc.onerror=()=>{delete _shardP[sid];rej(new Error('配图分片加载失败 '+sid));};
    document.head.appendChild(sc);});
  return _shardP[sid];}
ZJ.fig=(sid,obj)=>{for(const k in obj){const m=FIGM[k]||[0,0,sid];FIG[k]={d:obj[k],w:m[0],h:m[1]};}};
function figKeysOf(d){const ks=[];if(!d)return ks;if(d.f)ks.push(d.f);(d.mf||[]).forEach(k=>ks.push(k));
  String(d.q||'').replace(/!\[[^\]]*\]\(asset:\/\/([A-Za-z0-9_\-]+)\)/g,(m,aid)=>{for(const p of FIG_PFX){if(FIGM[p+aid]){ks.push(p+aid);break;}}return m;});
  return ks;}
function ensureFigs(ids){const sids=new Set();
  ids.forEach(id=>figKeysOf(BY[id]).forEach(k=>{if(!FIG[k]&&FIGM[k])sids.add(FIGM[k][2]);}));
  return Promise.all([...sids].map(loadShard)).catch(e=>console.warn(e));}
const _b64cache={};
async function figData(key){const f=FIG[key];if(!f)return null;if(String(f.d).startsWith('data:'))return f.d;
  if(_b64cache[key])return _b64cache[key];
  try{const r=await fetch(f.d);const b=await r.blob();
    const u=await new Promise(res=>{const fr=new FileReader();fr.onload=()=>res(fr.result);fr.readAsDataURL(b);});
    _b64cache[key]=u;return u;}catch(e){console.warn('取图失败',key,e);return null;}}
let _fillT=null;
function scheduleFill(){if(_fillT)return;_fillT=setTimeout(fillFigs,40);}
async function fillFigs(){_fillT=null;
  const imgs=[...document.querySelectorAll('img[data-fig]:not([data-figerr])')];if(!imgs.length)return;
  const sids=new Set();
  imgs.forEach(im=>{const k=im.dataset.fig;if(FIG[k]){im.src=FIG[k].d;im.removeAttribute('data-fig');}else if(FIGM[k])sids.add(FIGM[k][2]);else im.dataset.figerr='1';});
  if(!sids.size)return;
  const rs=await Promise.allSettled([...sids].map(loadShard));
  const bad=new Set(rs.map((r,i)=>r.status==='rejected'?[...sids][i]:null).filter(Boolean));
  document.querySelectorAll('img[data-fig]:not([data-figerr])').forEach(im=>{const k=im.dataset.fig;
    if(FIG[k]){im.src=FIG[k].d;im.removeAttribute('data-fig');}
    else if(FIGM[k]&&bad.has(FIGM[k][2])){im.dataset.figerr='1';im.alt='图加载失败';im.title='图加载失败，点击重试';
      im.style.cssText+=';min-width:60px;min-height:24px;border:1px dashed #c66;background:#fff5f5';
      im.addEventListener('click',()=>{delete im.dataset.figerr;im.style.border='';scheduleFill();},{once:true});}});}
new MutationObserver(scheduleFill).observe(document.body,{childList:true,subtree:true});
// 配图 <img>：已加载给 dataURI/URL；有元数据未加载给占位+data-fig；都没有回退相对路径（老逻辑）
const figTag=(p,extra)=>FIG[p]?`<img src="${FIG[p].d}"${extra||''}>`:FIGM[p]?`<img src="${FIG_BLANK}" data-fig="${esc(p)}"${extra||''}>`:`<img src="${esc(p)}"${extra||''}>`;
const figSrc=p=>FIG[p]?FIG[p].d:esc(p);   // 只给「已确保加载」的场合用（buildPaper 之后）
// 中考真题类教材（上海批 + 全国批）：题型可信、按卷组织、不出类似题、不进批量。
// ⚠ 新增这类教材时只加进这个数组，别再往各处写死教材名（2026-09-05 并全国卷时统一）。
const EXAM_TBS=['上海中考真题','全国中考真题','上海校本卷'];
const isExamTb=tb=>EXAM_TBS.indexOf(tb)>=0;
// 表格题：markdown 表格 → 真表格（预览 <table> / 导出 docx Table）
const hasTbl=s=>/\n?\s*\|[^\n]*\|/.test(String(s));
function parseCells(line){let s=line.trim();if(s[0]==='|')s=s.slice(1);if(s.slice(-1)==='|')s=s.slice(0,-1);return s.split('|').map(c=>c.trim());}
const isSep=cs=>cs.length&&cs.every(c=>c===''||/^:?-{2,}:?$/.test(c));
function splitBlocks(stem){const lines=String(stem).split('\n'),blocks=[];let buf=[],tbl=[];
  const fp=()=>{if(buf.length){const t=buf.join(' ').trim();if(t)blocks.push({type:'p',text:t});buf=[];}};
  const ft=()=>{if(tbl.length){const rows=tbl.map(parseCells).filter(c=>!isSep(c));if(rows.length)blocks.push({type:'t',rows});tbl=[];}};
  for(const ln of lines){if(/\|/.test(ln)&&ln.trim().startsWith('|')){fp();tbl.push(ln);}else{ft();buf.push(ln);}}
  fp();ft();return blocks;}
// 题干内联图：除原有的 data: 直插图外，再认「上海中考真题」的 asset://<资产id>。
// 这批 8000+ 道题的**公式本身就是一张图**，且长在句子中间（「求 ![](…) 的值」），
// 挪到题干下面就读不懂了，所以必须就地换成 <img>。
// 实测公式图 15~40px 高、几何图 100~2000px 高，两簇分得很开 → 按 60px 卡：
// 矮的当字排（inl，随行走），高的自成一块（blk，独占一行）。
// asset://<id> → FIG 条目。两批中考卷 key 前缀不同（ex/=上海、cn/=全国），
// 资产 id 是 sha256 内容寻址，两边同一张图天然同 id，谁先命中都对。
// ⚠ 新增中考类教材时把前缀加进这里，别再往三处渲染点写死前缀。
// ⚠ 资产 id 的字符集必须含**下划线**：中考批是 `sha256-<hex>`，AMC8 是 `AMC8_2000_04_0`。
//   漏了 `_` 的后果是**静默错题**：五处 asset:// 正则一条都匹配不到 → 图不换、题面里
//   直接露出 markdown 源码，而页面不报错、静态检查照样打勾
//   （2026-09-05 并 AMC8 实测 209 题全瞎，是无头 Chrome 里数 <img> 才抓到的）。
//   这五处（渲染/纯文本导出/md 导出/docx 切段/docx 取图）必须一起改。
const FIG_PFX=['ex/','cn/','am/','xb/'];
const figByAsset=aid=>{for(const p of FIG_PFX){const f=FIG[p+aid];if(f)return f;}return null;};
function inlImg(h){return h.replace(/!\[\]\((data:[^)\s]+)\)/g,function(m,u){return '<img src="'+u+'">';})
  .replace(/!\[[^\]]*\]\(asset:\/\/([A-Za-z0-9_\-]+)\)/g,function(m,aid){
    const f=figByAsset(aid);
    if(f)return '<img class="'+(f.h<=60?'inl':'blk')+'" src="'+f.d+'" loading=lazy>';
    const k=FIG_PFX.map(p=>p+aid).find(x=>FIGM[x]);if(!k)return '';
    return '<img class="'+(FIGM[k][1]<=60?'inl':'blk')+'" src="'+FIG_BLANK+'" data-fig="'+k+'" loading=lazy>';});}
function renderStem(stem){if(!hasTbl(stem))return inlImg(esc(stem));
  return splitBlocks(stem).map(b=>{if(b.type==='p')return `<div class="stxt">${inlImg(esc(b.text))}</div>`;
    const nc=Math.max(...b.rows.map(r=>r.length));
    return '<table class="qtbl">'+b.rows.map(r=>'<tr>'+Array.from({length:nc},(_,i)=>`<td>${inlImg(esc(r[i]||''))}</td>`).join('')+'</tr>').join('')+'</table>';}).join('');}
function typeset(n){if(window.MathJax&&MathJax.startup&&MathJax.startup.promise)MathJax.startup.promise=MathJax.startup.promise.then(()=>MathJax.typesetPromise(n&&n.length?n:undefined)).catch(()=>{});}

// ===== 初中考评分析：独立平台 API；无数据时明确说明，不以 AI 文案代替统计 =====
const ANALYSIS_API=localStorage.getItem('zj.analysisApi')||'http://localhost:8850/api/v1';
let analysisReq=0;
const anOpen=()=>$('analysisDrawer').classList.add('open');
const anState=html=>$('anBody').innerHTML='<div class="an-state">'+html+'</div>';
$('anClose').onclick=()=>$('analysisDrawer').classList.remove('open');
function anTags(rows){
  if(!rows.length)return '<span style="font-size:12px;color:#8b806d">暂无已确认标签</span>';
  return '<div class="an-chips">'+rows.filter(x=>x.annotation_status==='confirmed').map(x=>
    '<span class="an-chip">'+esc(x.name)+'</span>').join('')+'</div>';
}
function anFrequency(f){
  if(!f)return '<div class="an-pad" style="font-size:12px;color:#8b806d">该题的核心知识点尚无可统计的历史样本。</div>';
  const m=f.metrics, years=f.by_year||[], mx=Math.max(1,...years.map(x=>x.question_count||0));
  const bars=years.map(x=>'<span class="an-year" style="height:'+Math.max(4,Math.round((x.question_count||0)/mx*38))+'px" data-tip="'+esc(x.year+'：'+x.question_count+'题')+'"></span>').join('');
  return '<div class="an-pad"><div style="font-size:12px;color:#695d49;margin-bottom:8px"><b>'+esc(f.knowledge_name)+'</b> · 已确认历史覆盖</div>'+
    '<div class="an-metrics"><div class="an-metric"><b>'+m.question_count+'</b><span>题目出现</span></div><div class="an-metric"><b>'+m.paper_count+'</b><span>覆盖试卷</span></div><div class="an-metric"><b>'+m.known_total_score+'</b><span>已知分值</span></div></div>'+
    (years.length?'<div class="an-yearline">'+bars+'</div><div style="font-size:10px;color:#8b806d;display:flex;justify-content:space-between"><span>'+years[0].year+'</span><span>'+years[years.length-1].year+'</span></div>':'')+'</div>';
}
function renderAnalysis(data){
  const q=data.question||{}, knowledge=(data.knowledge||[]).filter(x=>x.annotation_status==='confirmed');
  $('anMeta').textContent=(q.year||'')+' '+(q.district||'上海')+' · 第 '+(q.question_no||'')+' 题';
  const khtml=knowledge.length?'<div class="an-chips">'+knowledge.map(x=>'<span class="an-chip '+(x.relation_type==='primary'?'primary':'')+'">'+esc(x.name)+'</span>').join('')+'</div>':'<span style="font-size:12px;color:#8b806d">知识点仍在审核或尚未标注</span>';
  const similar=(data.similar_questions||[]).slice(0,5);
  const simhtml=similar.length?'<div class="an-pad an-list">'+similar.map(x=>'<div class="an-sim"><span class="an-score">'+Math.round(x.overall_score*100)+'%</span><span>'+esc((x.year||'')+' '+(x.district||'上海')+' '+(x.exam_type||'')+' 第'+(x.question_no||'')+'题')+'</span><small>查看依据</small></div>').join('')+'</div>':'<div class="an-pad" style="font-size:12px;color:#8b806d">相似索引尚未生成。</div>';
  $('anBody').innerHTML='<section class="an-section"><h3>已确认知识点</h3><div class="an-pad">'+khtml+'</div></section>'+
    '<section class="an-section"><h3>题目特征</h3><div class="an-pad">'+anTags(data.tags||[])+'</div></section>'+
    '<section class="an-section"><h3>历史考频</h3>'+anFrequency((data.frequencies||[])[0])+'</section>'+
    '<section class="an-section"><h3>相似真题</h3>'+simhtml+'</section>'+
    '<div class="an-note">'+esc(data.disclaimer||'以下只反映历史数据。')+'</div>';
}
async function openAnalysis(id){
  const d=BY[id];if(!d)return;
  ++analysisReq;const mine=analysisReq;
  $('aiDrawer').classList.remove('open');anOpen();
  $('anMeta').textContent=esc(d.gg)+' · '+esc(d.cd)+' · '+esc(d.id);
  if(!location.protocol.startsWith('http')){anState('<b>需要通过本地服务打开</b>请从 <code>http://localhost:8848/组卷台.html</code> 打开，分析面板才能访问独立数据服务。');return;}
  anState('<span class="an-spin"></span><b>正在读取已审核数据</b>按题目 ID 查询知识点、统计与相似索引…');
  try{
    const r=await fetch(ANALYSIS_API+'/questions/'+encodeURIComponent(id)+'/analysis');
    if(mine!==analysisReq)return;
    if(r.status===404){anState('<b>这道题尚未纳入考评语料</b>组卷功能可照常使用。待中考题录入、审核并导入平台后，此处会自动出现结构化分析。');return;}
    if(!r.ok)throw new Error('HTTP '+r.status);
    const j=await r.json();if(!j||!j.ok)throw new Error((j&&j.error)||'未知响应');
    renderAnalysis(j.data);
  }catch(e){if(mine===analysisReq)anState('<b>分析服务暂不可用</b>请确认初中考评分析平台已启动（默认端口 8850），然后重试。');}
}

// 教材→年级→课程节 联动
$('ftb').addEventListener('change',()=>{
  const tb=$('ftb').value;
  $('fg').innerHTML='<option value="">'+(tb?'选择年级':'先选教材')+'</option>'+(tb?Object.keys(COURSE[tb]||{}).map(g=>`<option value="${esc(g)}">${esc(g)}</option>`).join(''):'');
  $('fsec').innerHTML='<option value="">先选年级</option>';
  // 举一反三等库无题型/难度标签 → 隐掉无意义筛选；
  // ⚠ 上海中考真题**有**题型（原卷大题头，可信）要留着。
  // 知识点 2026-09-02 起全库自动标注（733 KP 词表），#fk 任何教材都常显。
  const hasType = !tb || tb==='胡小群课程' || isExamTb(tb);
  // AMC8 的 d 是**按题号推的**难度（2/3/4），不是原卷声明；但对老师筛「压轴题」有用，
  // 所以难度筛选对它常显（口径见 zujuan_amc8.py 的 docstring）。
  const hasDiff = !tb || tb==='胡小群课程' || tb==='AMC8真题' || tb==='袋鼠真题';   // 袋鼠：3/4/5 分题档 → 2/3/4，同样是推的
  if(!hasType)$('ft').value='';
  if(!hasDiff){diffSel.clear();$('fd').querySelectorAll('.chip.on').forEach(c=>c.classList.remove('on'));}
  const _tf=$('ft').closest('.fg');if(_tf)_tf.style.display=hasType?'':'none';
  const _df=$('fd').closest('.fg');if(_df)_df.style.display=hasDiff?'':'none';
  updateSrcBar();
  renderLib(true);
});
$('fg').addEventListener('change',()=>{
  const tb=$('ftb').value, g=$('fg').value, sel=$('fsec');
  const nodes=(COURSE[tb]||{})[g]||[];
  // 节点第 4 位是分组名（第N章/期中复习/…），有则按 <optgroup> 两级呈现；没有则平铺（老教材）
  let html='<option value="">全部节/专题</option>';
  if(nodes.length && nodes[0].length>3){
    // 同名分组合并（按首次出现的顺序），不要求节点连续：培优「中考·分类汇编」两个版本的专题交错排，
    // 只按相邻合并会出 25 个碎 optgroup（2026-09-07 踩过）
    const groups=[], gpos={};
    nodes.forEach(nd=>{const gname=nd[3]||'其他';
      if(!(gname in gpos)){gpos[gname]=groups.length;groups.push([gname,[]]);}
      groups[gpos[gname]][1].push(nd);});
    html+=groups.map(([gname,items])=>{
      const cnt=items.reduce((a,b)=>a+b[2],0);
      return `<optgroup label="${esc(gname)}（${items.length}个·${cnt}题）">`+
        items.map(([sec,t,n])=>`<option value="${esc(t)}">${esc(t)}（${n}）</option>`).join('')+
        '</optgroup>';}).join('');
  }else{
    html+=nodes.map(([sec,t,n])=>`<option value="${esc(t)}">${sec}. ${esc(t)}（${n}）</option>`).join('');
  }
  sel.innerHTML=html;
  updateSrcBar();
  renderLib(true);
});

// 专题级源文件：预览原书页图 / 下载该专题源文件（五个教材通用，需本地助手在跑）
let srcTok=0;
function updateSrcBar(){
  const bar=document.getElementById('srcBar'); if(!bar) return;
  const served=location.protocol.startsWith('http');
  const tb=$('ftb').value, g=$('fg').value, cd=$('fsec').value;
  bar.style.display = (served&&tb&&g) ? '' : 'none';
  if(!(served&&tb&&g)) return;
  const rows=document.getElementById('srcRows'), tip=document.getElementById('srcTip');
  if(!cd){ rows.innerHTML=''; tip.style.display=''; return; }
  // 该专题所有题的 pg（原书页图路径）并集 → 服务端据此定位页范围
  const pgs=[...new Set(DATA.filter(x=>x.tb===tb&&x.gg===g&&x.cd===cd)
                            .flatMap(x=>x.pg||[]))];
  const one=DATA.find(x=>x.tb===tb&&x.gg===g&&x.cd===cd)||{};
  // ── 上海中考真题：源文件按**卷**组织（每卷 1 份），不走 /api/topic 的专题轴。
  //    但摆在**同一条 bar 上**、用同样的行式 —— 老师不该因为换了套题就换一套操作。
  //    ⚠ 不给「预览」：预览得先转 PDF，而 LibreOffice 会把 MathType 公式转缺
  //      （269 卷源文件还是老 .doc）。宁可只给保真的两条：WPS 打开 / 下原件。
  //    2026-09-09：一卷不止一份 —— 卷商件多是「（原卷版）+（解析版）」成对，老师两份都要
  //      （发学生用原卷、备课用解析）。服务端 /api/paper/files 列出这卷实际有的几份，**有几份渲染几行**，
  //      下载/打开都带 i（第几份），不再写死「一卷一份、含解析」。
  if(isExamTb(tb)){
    tip.style.display='none';
    if(!one.pid){ rows.innerHTML='<span style="color:#7b8794">这一卷没有可下载的源文件</span>'; return; }
    rows.innerHTML='<span style="color:#7b8794">读取源文件…</span>';
    const myTok=++srcTok, pid=one.pid;
    fetch('/api/paper/files?pid='+encodeURIComponent(pid)).then(r=>r.json()).then(j=>{
      if(myTok!==srcTok)return;
      const fs=(j&&j.files)||[];
      if(!fs.length){ rows.innerHTML='<span style="color:#7b8794">这一卷没有可下载的源文件</span>'; return; }
      // 2026-09-09：不再给「用 WPS 打开」（老师电脑未必有 WPS，线上也没放行那个本机接口）。
      //   每份卷一行：📄 预览（走 WPS 批量转出的 PDF 的页图，和教材类预览同一套）| ⬇ Word | ⬇ PDF。
      const gs=(j.groups&&j.groups.length)?j.groups:fs.map(f=>({label:f.label,role:f.role,docx:f.ext==='pdf'?-1:f.i,pdf:f.ext==='pdf'?f.i:-1}));
      const P=encodeURIComponent(pid);
      rows.innerHTML=gs.map(g=>{
        const tea=g.role==='教师版', c=tea?'#c0a062':'#8ab4f8';
        return '<b style="color:#7b8794;font-weight:600">'+esc(g.label)+'</b>'
          +(g.pdf>=0?' <a href="/api/paper/preview?pid='+P+'&i='+g.pdf+'" target="_blank" style="color:'+c+';text-decoration:none;margin:0 10px">📄 预览</a>'
                     :' <span style="color:#7b8794;font-size:11px;margin:0 10px">（无 PDF，暂不能预览）</span>')
          +(g.docx>=0?'<a href="/api/paper?pid='+P+'&i='+g.docx+'" style="color:'+c+';text-decoration:none;margin-right:10px">⬇ Word</a>':'')
          +(g.pdf>=0?'<a href="/api/paper?pid='+P+'&i='+g.pdf+'" style="color:'+c+';text-decoration:none">⬇ PDF</a>':'')
          +(tea?'<span style="color:#9a8f7a;font-size:11px;margin-left:6px">带答案</span>':'');
      }).join('<br>');
    }).catch(()=>{ if(myTok===srcTok)rows.innerHTML='<span style="color:#7b8794">源文件读取失败</span>'; });
    return;
  }
  const qs='tb='+encodeURIComponent(tb)+'&g='+encodeURIComponent(g)
          +'&cd='+encodeURIComponent(cd)+'&src='+encodeURIComponent(one.src||'')
          +'&pg='+encodeURIComponent(pgs.join('|'));
  // ⚠ **有几份就渲染几行**，不能写死两行。各教材份数和轴都不一样：
  //   初中培优讲义 学生版/教师版；胡小群 讲义/课后/课后答案(**3份**)；一课一练 本课/全册答案。
  //   踩过：先前按「学生版/教师版」写死两行，胡小群 120 个专题的课后习题够不着。
  tip.style.display='none';
  rows.innerHTML='<span style="color:#7b8794">读取源文件…</span>';
  const myTok=++srcTok;
  fetch('/api/topic/files?'+qs).then(r=>r.json()).then(j=>{
    if(myTok!==srcTok)return;                       // 已经换了专题，这份是过期结果
    const fs=(j&&j.files)||[];
    if(!fs.length){ rows.innerHTML='<span style="color:#7b8794">这个专题没有可下载的源文件</span>'; return; }
    rows.innerHTML=fs.map(f=>{
      const tea=f.role==='教师版', c=tea?'#c0a062':'#8ab4f8';
      const q2=qs+'&label='+encodeURIComponent(f.label);
      return '<b style="color:#7b8794;font-weight:600">'+esc(f.label)+'</b>'
        +' <a href="/api/topic?'+q2+'" target="_blank" style="color:'+c+';text-decoration:none;margin:0 10px">📄 预览</a>'
        +'<a href="/api/topic/pdf?'+q2+'" style="color:'+c+';text-decoration:none">⬇ 下载</a>'
        +(tea?'<span style="color:#9a8f7a;font-size:11px;margin-left:6px">带答案</span>':'')
        +'<span style="color:#c9c2b4;font-size:11px;margin-left:6px">'+esc(f.ext||'')+'</span>';
    }).join('<br>');
  }).catch(()=>{ if(myTok===srcTok)rows.innerHTML='<span style="color:#7b8794">源文件读取失败</span>'; });
}

document.getElementById('srcRows').addEventListener('click',e=>{
  const w=e.target.closest('[data-word]'); if(w){e.preventDefault();openWord(w.dataset.word,w,w.dataset.wi);}});
$('fsec').addEventListener('change',()=>{
  updateSrcBar();
  const t=$('fsec').value;
  if(t && (!$('pTitle').value || $('pTitle').dataset.auto)){$('pTitle').value=t;$('pTitle').dataset.auto='1';}
  renderLib(true);
});

$('pTitle').addEventListener('input',()=>{delete $('pTitle').dataset.auto;});

function match(d){
  if($('ftb').value&&d.tb!==$('ftb').value)return false;
  if($('fg').value&&d.gg!==$('fg').value)return false;
  if($('fsec').value&&d.cd!==$('fsec').value)return false;
  if($('ft').value&&d.t!==$('ft').value)return false;
  if(diffSel.size&&!diffSel.has(d.d))return false;
  if($('ff').checked&&!(d.f||(d.p&&d.p.length)||(d.im&&d.im.length)))return false;   // im=题干内联图(中考真题)
  const k=$('fk').value.trim();if(k&&!(d.k||[]).some(x=>x.includes(k)))return false;
  const q=$('fq').value.trim();if(q&&!d.q.includes(q))return false;
  return true;
}
// 题号角标：题号一直存在 d.n 里，但以前没画出来 —— 各套教材的题干都把题号剥掉了，
// 于是列表里一道题在原卷/原书里是第几题，只能从 id 尾巴上猜。这里统一画成角标。
// n 的形态各教材不一：中考真题是 `12`、`35(1)`；初中培优讲义是 `第1题`；其余是 `1`、`例1`。
// 纯数字（含小问括号）补成「第N题」，已经带字的原样显示。
function qnoBadge(d){
  const n=String(d.n==null?'':d.n).trim();
  if(!n)return '';
  const t=/^\d+(?:\s*[（(][^）)]*[）)])?$/.test(n)?('第'+n+'题'):n;
  return `<span class="qno">${esc(t)}</span>`;
}
function libCard(d){
  const inc=cart.includes(d.id);
  let figs='';
  if(d.f)figs=figTag(d.f,' loading=lazy')+(d.mf||[]).map(f=>figTag(f,' loading=lazy')).join('');
  else if(d.p&&d.p.length)figs=d.p.map(p=>`<img class=pg src="${esc(p)}" loading=lazy>`).join('');
  const kn=(d.k&&d.k.length)?`<div class=kn>${d.k.map(k=>`<span>${esc(k)}</span>`).join('')}</div>`:'';
  const srcBadge=`<span class="src ${d.s==='讲义'?'jy':'kh'}">${esc(d.s)}</span>`;
  return `<div class="q${inc?' added':''}" data-id="${esc(d.id)}">
    <div class="qh"><span class="loc">${$('ftb').value?'':'<b>'+esc(d.tb)+'</b> · '}${esc(d.gg)} · ${esc(d.cd)}</span>${qnoBadge(d)}${srcBadge}${d.t&&d.t!=='其他'?`<span class="qt">${esc(d.t)}</span>`:''}
      <span class="df">${stars(d.d)}</span><span class="id">${esc(d.id)}</span>
      <button class="report${reported.has(d.id)?' done':''}" data-report="${esc(d.id)}" title="报告这道题有问题（会弹窗让你写清楚哪儿不对）">${reported.has(d.id)?'✓ 已报告':'🚩 报告问题'}</button>
      ${d.pg&&d.pg.length?`<button class="see-src" data-src="${esc(d.id)}">📖 对照原书</button>`:''}
      <button class="open-analysis" data-analysis="${esc(d.id)}">◫ 分析</button>
      ${isExamTb(d.tb)
        ? ''   /* 中考题出不了类似题（提示词是小学奥数版的）→ 直接不给按钮，别拿禁用态占版面 */
        : `<button class="ask-ai" data-sim="${esc(d.id)}"${(d.f||(d.mf&&d.mf.length))?' title="带图题：AI 配不出图，会自动用学科网按题图找真题"':''}>🔁 出类似题</button>`}</div>
    <div class="stem">${renderStem(d.q)}</div>${figs?`<div class="figs">${figs}</div>`:''}${kn}
    <button class="add${inc?' in':''}" data-add="${esc(d.id)}">${inc?'✓ 已加入':'＋ 加入'}</button></div>`;
}
function renderLib(reset){
  if(reset){filtered=DATA.filter(match);shown=0;$('list').innerHTML='';}
  const noFilter=!$('fg').value&&!$('fq').value.trim()&&!$('fk').value.trim()&&!diffSel.size&&!$('ft').value&&!$('ff').checked;
  if(noFilter){filtered=[];$('list').innerHTML='';$('empty').style.display='block';$('empty').textContent='请在上方选「教材 → 年级 → 课程节/专题」开始挑题';$('more').style.display='none';$('cnt').innerHTML='';updateBatchBar();return;}
  const slice=filtered.slice(shown,shown+BATCH),frag=document.createElement('div');
  frag.innerHTML=slice.map(libCard).join('');const nn=[...frag.children];nn.forEach(c=>$('list').appendChild(c));shown+=slice.length;
  $('more').style.display=shown<filtered.length?'block':'none';
  $('empty').style.display=filtered.length?'none':'block';$('empty').textContent='没有符合条件的题目';
  $('cnt').innerHTML=`命中 <b>${filtered.length}</b> 题`+(shown<filtered.length?`（显示 ${shown}）`:'');
  typeset(nn);updateBatchBar();
}
function renderCart(){
  $('cn').textContent=cart.length;
  if(typeof saveCart==='function')saveCart();   // 每次变动落盘，刷新/关页面不丢
  if(typeof updateAddAllBtn==='function')updateAddAllBtn();
  const dd={};cart.forEach(id=>{const d=BY[id].d||0;dd[d]=(dd[d]||0)+1;});
  $('dist').innerHTML=cart.length?('难度 '+[1,2,3,4,5].filter(k=>dd[k]).map(k=>`<span>★${k}·<b>${dd[k]}</b></span>`).join('')):'';
  if(!cart.length){$('cart').innerHTML='<div class=cempty>左侧点题目的「＋ 加入」挑题</div>';return;}
  $('cart').innerHTML=cart.map((id,i)=>{const d=BY[id];
    // 中考真题不给出类似题入口：提示词是小学奥数版的（见 libCard 里的说明）
    const simBtn=(d.ai||isExamTb(d.tb))?'':`<button class="cop-sim" data-csim="${esc(id)}" title="${(d.f||(d.mf&&d.mf.length))?'带图题：自动用学科网按题图找真题':'按此题出类似题'}">🔁</button>`;
    return `<div class="citem"><span class="no">${i+1}</span>
    <span class="ct">${esc(stripTex(d.q).replace(/\|/g,' ').replace(/-{2,}/g,' ').replace(/\s+/g,' ')).slice(0,58)}</span>
    <span class="ops">${simBtn}<button data-up="${i}">▲</button><button data-dn="${i}">▼</button><button class="del" data-rm="${i}">✕</button></span></div>`;}).join('');
}
function syncCards(id){document.querySelectorAll(`[data-id="${CSS.escape(id)}"]`).forEach(c=>{const inc=cart.includes(id);c.classList.toggle('added',inc);const b=c.querySelector('.add');if(b){b.classList.toggle('in',inc);b.textContent=inc?'✓ 已加入':'＋ 加入';}});}
function toggleAdd(id){const i=cart.indexOf(id);if(i<0)cart.push(id);else cart.splice(i,1);renderCart();syncCards(id);}

const cn=['一','二','三','四','五','六','七','八','九','十'];
// ⚠ 版式决策**不在前端做**，向后端 /api/layout 要。
//
//   原本这里有一张 LAY 表和一套 isShortCalc 判定，注释还写着「与后端 export_lib.LAYOUT
//   一致」—— 后端改成七类题型（选择/填空/计算/竖式/解答/多小问/作图）之后就漂了：
//   **预览显示的是旧版式、下载的 Word 是新版式，老师看到的和拿到的不是一回事**。
//
//   在 JS 里把七类逻辑再实现一遍只会再漂一次。改成后端算、前端画：
//   决策只有一处实现（export_lib.classify + scaled），前端退化成执行者。
//
//   拿不到后端时（AI 助手没开）退回这张保底表，并在预览上打横幅说明可能与下载不符。
const LAY_FALLBACK=[[0,0],[2.9,2.9],[4.9,4.9],[9.0,9.0]];
let LAYOUT_CACHE=null;           // {gap_cm, items:[{kind,blank_cm,pair,fig_cm}]}
async function fetchLayout(ids,ex){
  if(!AI_ON)return null;
  try{
    const items=ids.map(id=>{const d=BY[id];return {stem:String(d.q||''),type:d.t||''};});
    const r=await fetch('/api/layout',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({items,bands:+$('oSpace').value,cols:$('oCols').checked})});
    if(!r.ok)return null;
    const j=await r.json();
    return (j&&j.ok)?j:null;
  }catch(e){return null;}
}
// （原 isShortCalc 已删：并排与否现在由后端 /api/layout 的 pair 字段决定，
//   留着会让人以为前端还在自己判定。）
function buildPaper(cb){
  // 先把篮子里的配图分片拉齐（预览/打印/.doc 都直接用 src），再向后端要版式，拿到了再画；拿不到就用保底表并挂横幅
  ensureFigs(cart)
    .then(()=>fetchLayout(cart,Math.max(0,Math.min(cart.length,parseInt($('oEx').value)||0))))
    .then(lay=>{LAYOUT_CACHE=lay;paintPaper(cb);})
    .catch(()=>{LAYOUT_CACHE=null;paintPaper(cb);});
}
function paintPaper(cb){
  const L=LAYOUT_CACHE;
  const fb=LAY_FALLBACK[+$('oSpace').value]||LAY_FALLBACK[2];
  const gapCm=L?L.gap_cm:0.6;
  // 第 i 道题的版式：优先用后端给的，没有就退保底
  const layOf=i=>L&&L.items&&L.items[i]?L.items[i]
    :{kind:'solve',blank_cm:fb[1],pair:false,fig_cm:6};
  const showSrc=$('oId').checked, ex=Math.max(0,Math.min(cart.length,parseInt($('oEx').value)||0)), cols=$('oCols').checked;
  const stemHtml=d=>{let fig='';
    if(d.f)fig=`<div class="pfig">`+figTag(d.f)+(d.mf||[]).map(f=>figTag(f)).join('')+`</div>`;
    const src=showSrc?` <span style="color:#999;font-size:8pt">[${esc(d.src)}·${stars(d.d)}]</span>`:'';
    return renderStem(stripLeadNo(d.q))+src+fig;};
  // base = 这批 id 在整个试卷篮里的起始下标，用来对齐后端返回的逐题版式
  const emitSeq=(ids,isPractice,base)=>{let out='';
    for(let k=0;k<ids.length;k++){
      const d=BY[ids[k]], lo=layOf(base+k);
      if(lo.pair&&k+1<ids.length&&!cols){          // 与下一题并排成一行
        const lo2=layOf(base+k+1);
        const h=isPractice?Math.max(lo.blank_cm,lo2.blank_cm):0;
        out+=`<div class="pqrow"><div class="pq">${stemHtml(d)}</div><div class="pq">${stemHtml(BY[ids[k+1]])}</div></div>`;
        if(h)out+=`<div class="wspace" style="height:${h}cm"></div>`;
        out+=`<div class="wspace" style="height:${gapCm}cm"></div>`;
        k++; continue;
      }
      out+=`<div class="pq">${stemHtml(d)}</div>`;
      if(isPractice&&lo.blank_cm>0)out+=`<div class="wspace" style="height:${lo.blank_cm}cm"></div>`;
      out+=`<div class="wspace" style="height:${gapCm}cm"></div>`;
    }
    return out;};
  let body='';
  if(ex>0){body+='<div class="p-sech">一、例题</div>'+emitSeq(cart.slice(0,ex),false,0);
    body+='<div class="p-sech">二、练习</div>'+emitSeq(cart.slice(ex),true,ex);}
  else{body+=emitSeq(cart,true,0);}
  const warn=LAYOUT_CACHE?'':'<div style="background:#fdf0d5;border:1px solid #d9b160;color:#7a5a12;padding:6px 10px;margin:0 0 10px;font-size:12px;border-radius:5px">⚠ 没连上组卷台助手，这里显示的是<b>近似版式</b>，可能和下载的 Word 不一致。启动助手后重新预览即可。</div>';
  const title=$('pTitle').value||'专题';
  $('paper').innerHTML=`<div class="p-head"><div class="pt">《${esc(title)}》专题练习</div>
    <div class="pd">日期：${'　'.repeat(10)}姓名：${'　'.repeat(5)}</div>
    ${($('pSub').value&&!(/日期/.test($('pSub').value)&&/姓名/.test($('pSub').value)))?`<div class="psub">${esc($('pSub').value)}</div>`:''}</div>
    ${warn}<div class="p-body ${$('oCols').checked?'cols2':''}">${body}</div>`;
  if(window.MathJax&&MathJax.startup&&MathJax.startup.promise)
    MathJax.startup.promise=MathJax.startup.promise.then(()=>MathJax.typesetPromise([$('paper')])).then(()=>cb&&cb()).catch(()=>cb&&cb());
  else if(cb)setTimeout(cb,300);
}
function openPrev(){if(!cart.length){alert('试卷篮是空的');return;}buildPaper(()=>$('preview').style.display='block');}
function doPrint(){if(!cart.length){alert('试卷篮是空的');return;}buildPaper(()=>{$('preview').style.display='block';setTimeout(()=>window.print(),250);});}
function doWord(){if(!cart.length){alert('试卷篮是空的');return;}buildPaper(()=>{
  const css=document.querySelector('style').textContent;
  const h=`<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40"><head><meta charset="utf-8"><style>${css}</style></head><body><div id="paper" style="width:auto;padding:0;box-shadow:none">${$('paper').innerHTML}</div></body></html>`;
  const blob=new Blob(['﻿'+h],{type:'application/msword'});
  const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=(($('pTitle').value||'专题')+'专题练习')+'.doc';a.click();});}
// 下载Word：有本地助手→走后端 pandoc（LaTeX 转 Word 原生真公式）；否则回退客户端纯文本导出
async function doDocx(){
  if(!cart.length){alert('试卷篮是空的');return;}
  if(AI_ON){
    try{ await doDocxServer(); return; }
    catch(e){ console.warn('后端导出失败，回退本地纯文本：',e);
      if(!confirm('后端公式导出失败，改用「纯文本」方式下载 Word？\n（公式会降级成文本。或先确认组卷台AI助手.py在运行）')) return; }
  }
  doDocxLocal();
}
async function doDocxServer(){
  const ex=Math.max(0,Math.min(cart.length,parseInt($('oEx').value)||0));
  await ensureFigs(cart);
  const B64={};                                   // key → dataURI（部署侧换成 COS URL 时这里会拉回字节）
  for(const id of cart)for(const k of figKeysOf(BY[id]))if(!(k in B64)){const u=await figData(k);if(u)B64[k]=u;}
  const items=cart.map(id=>{const d=BY[id];const figs=[];
    if(d.f&&B64[d.f])figs.push(B64[d.f]);
    (d.mf||[]).forEach(f=>{if(B64[f])figs.push(B64[f]);});
    // 题干内联图（中考真题）：`asset://` pandoc 认不了，会**静默丢掉整个公式**。
    // 换成 INLINEFIG_n 占位并把 base64 一起带上，由 export_lib 落成临时文件再替回真路径。
    const inls=[];
    const stem=String(d.q).replace(/!\[[^\]]*\]\(asset:\/\/([A-Za-z0-9_\-]+)\)/g,(m,aid)=>{
      const k=FIG_PFX.map(p=>p+aid).find(x=>B64[x]);if(!k)return '';
      inls.push(B64[k]);return '![](INLINEFIG_'+(inls.length-1)+')';});
    return {stem,type:d.t||'',difficulty:d.d||0,src:d.src||'',figdata:figs,inlinefig:inls};});
  // oSpace 现在存的是**栏数**（0=不排版，2/3/4=一页几栏），不再是老的"答题空"档位
  const payload={title:$('pTitle').value||'专题',sub:$('pSub').value||'',ex,
    bands:+$('oSpace').value,cols:$('oCols').checked,showSrc:$('oId').checked,items};
  const r=await fetch('/api/export_docx',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
  if(!r.ok){let msg='HTTP '+r.status;try{const j=await r.json();if(j.error)msg=j.error;}catch(e){}throw new Error(msg);}
  const blob=await r.blob();
  const a=document.createElement('a');a.href=URL.createObjectURL(blob);
  a.download=(($('pTitle').value||'专题')+'专题练习')+'.docx';document.body.appendChild(a);a.click();a.remove();
}
// A（离线兜底）：客户端生成 .docx（仿宋·专题练习模板；数学走 stripTex 纯文本）
async function doDocxLocal(){
  if(!cart.length){alert('试卷篮是空的');return;}
  if(!window.docx){alert('Word 组件还在加载，请稍候再点');return;}
  await ensureFigs(cart);
  const B64={};
  for(const id of cart)for(const k of figKeysOf(BY[id]))if(!(k in B64)){const u=await figData(k);if(u)B64[k]=u;}
  const D=window.docx, CN={eastAsia:'仿宋',ascii:'Times New Roman',hAnsi:'Times New Roman'};
  const title=$('pTitle').value||'专题', sub=$('pSub').value||'';
  const ex=Math.max(0,Math.min(cart.length,parseInt($('oEx').value)||0));
  const spaceLines=[0,1,2,4][+$('oSpace').value];
  const P=(opts)=>new D.Paragraph(opts), T=(t,o={})=>new D.TextRun(Object.assign({text:t,font:CN,size:24},o));
  let kids=[];
  function blank(n){for(let i=0;i<n;i++)kids.push(P({spacing:{line:360},children:[T('')]}));}
  function b64u8(d){const s=atob(d.split(',')[1]),u=new Uint8Array(s.length);for(let i=0;i<s.length;i++)u[i]=s.charCodeAt(i);return u;}
  function imgPara(path){const f=FIG[path],u=B64[path];if(!f||!u)return null;
    const maxW=200,sc=Math.min(1,maxW/f.w),w=Math.round(f.w*sc),h=Math.round(f.h*sc);
    return P({spacing:{before:40,after:60},children:[new D.ImageRun({type:'png',data:b64u8(u),transformation:{width:w,height:h}})]});}
  function tblDocx(rows){const nc=Math.max(...rows.map(r=>r.length));
    const bd={style:D.BorderStyle.SINGLE,size:4,color:'555555'};
    const cell=t=>new D.TableCell({margins:{top:30,bottom:30,left:90,right:90},
      children:[P({alignment:D.AlignmentType.CENTER,children:[T(stripTex(t)||' ',{size:22})]})]});
    return new D.Table({width:{size:0,type:D.WidthType.AUTO},
      borders:{top:bd,bottom:bd,left:bd,right:bd,insideHorizontal:bd,insideVertical:bd},
      rows:rows.map(r=>new D.TableRow({children:Array.from({length:nc},(_,i)=>cell(r[i]||''))}))});}
  function addQ(id,no,prefix){const d=BY[id];const q=stripLeadNo(d.q);
    const blocks=hasTbl(q)?splitBlocks(q):[{type:'p',text:String(q)}];
    // 题干里的内联图（中考真题的公式图）拆成「文字run + 图run + 文字run」，
    // 直接 stripTex 的话公式会塌成一个 □，题就读不懂了
    const runs=t=>String(t).split(/(!\[[^\]]*\]\(asset:\/\/[A-Za-z0-9_\-]+\))/).map(seg=>{
      const m=/^!\[[^\]]*\]\(asset:\/\/([A-Za-z0-9_\-]+)\)$/.exec(seg);
      if(!m)return seg?T(stripTex(seg)):null;
      const k=FIG_PFX.map(p=>p+m[1]).find(x=>B64[x]);if(!k)return null;const f=FIG[k];
      const sc=Math.min(1,180/f.w);
      return new D.ImageRun({type:'png',data:b64u8(B64[k]),
        transformation:{width:Math.max(8,Math.round(f.w*sc)),height:Math.max(8,Math.round(f.h*sc))}});
    }).filter(Boolean);
    blocks.forEach(b=>{
      if(b.type==='p'){
        kids.push(P({spacing:{after:80,line:360},children:runs(b.text)}));}
      else{
        kids.push(tblDocx(b.rows));kids.push(P({spacing:{after:60},children:[T('')]}));}
    });
    if(d.f&&B64[d.f]){const ip=imgPara(d.f);if(ip)kids.push(ip);}
    if(spaceLines&&!prefix.startsWith('例'))blank(spaceLines);}
  if(ex>0){
    kids.push(P({spacing:{before:120,after:80},children:[T('一、例题',{bold:true})]}));
    cart.slice(0,ex).forEach((id,i)=>addQ(id,i+1,'例'));
    kids.push(P({spacing:{before:160,after:80},children:[T('二、练习',{bold:true})]}));
    cart.slice(ex).forEach((id,i)=>addQ(id,i+1,''));
  }else cart.forEach((id,i)=>addQ(id,i+1,''));
  const doc=new D.Document({
    styles:{default:{document:{run:{font:CN,size:24}}}},
    sections:[{
      properties:{page:{size:{width:11906,height:16838},margin:{top:1418,bottom:1418,left:1418,right:1418}}},
      headers:{default:new D.Header({children:[
        P({alignment:D.AlignmentType.CENTER,spacing:{after:60},children:[T(`《${title}》专题练习`,{bold:true,size:32})]}),
        P({border:{bottom:{style:D.BorderStyle.SINGLE,size:8,color:'000000',space:2}},children:[T(sub,{size:21})]})]})},
      footers:{default:new D.Footer({children:[P({alignment:D.AlignmentType.CENTER,
        children:[T('第 ',{size:18}),new D.TextRun({children:[D.PageNumber.CURRENT],font:CN,size:18}),T(' 页',{size:18})]})]})},
      children:kids}]});
  D.Packer.toBlob(doc).then(blob=>{const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=(title+'专题练习')+'.docx';a.click();});
}

['ft','ff','fk','fq'].forEach(id=>$(id).addEventListener('input',()=>renderLib(true)));
$('fd').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;const d=+c.dataset.d;c.classList.toggle('on');diffSel.has(d)?diffSel.delete(d):diffSel.add(d);renderLib(true);});
$('more').addEventListener('click',()=>renderLib(false));
const reported=new Set(JSON.parse(localStorage.getItem('reportedQ')||'[]'));
// ⚠ 报问题必须**带上描述**才有用：原来一点就发、只带题干前 300 字，
//   事后翻 问题报告.jsonl 只知道"这题有问题"，不知道哪儿有问题，等于还得重看一遍。
//   所以改成先弹窗写清楚（配快捷标签，省打字），再提交。
const RP_TAGS=['配图不对','配图缺了','配图太大/太小','题干缺字漏行','公式错了','选项不全','小问没拆开','混进了答案','其它'];
let rpCur=null, rpBtn=null, rpTagSel=new Set();
function reportProblem(id,btn){
  if(reported.has(id))return;
  const d=BY[id]||{};
  rpCur=id; rpBtn=btn; rpTagSel.clear();
  $('rpQ').innerHTML='<span class="rp-loc">'+esc([d.tb,d.gg,d.cd,d.n?('第'+d.n+'题'):''].filter(Boolean).join(' · '))+'</span>'+esc((d.q||'').slice(0,260));
  $('rpTags').innerHTML=RP_TAGS.map(t=>'<span class="rp-tag" data-t="'+esc(t)+'">'+esc(t)+'</span>').join('');
  $('rpNote').value=''; $('rpGo').disabled=true;
  $('rpModal').style.display='flex'; setTimeout(()=>$('rpNote').focus(),40);
}
function rpSync(){$('rpGo').disabled = !rpTagSel.size && !$('rpNote').value.trim();}
$('rpTags').addEventListener('click',e=>{const t=e.target.closest('.rp-tag');if(!t)return;
  const v=t.dataset.t; t.classList.toggle('on');
  rpTagSel.has(v)?rpTagSel.delete(v):rpTagSel.add(v); rpSync();});
$('rpNote').addEventListener('input',rpSync);
const rpClose=()=>{$('rpModal').style.display='none';rpCur=null;rpBtn=null;};
$('rpX').onclick=rpClose; $('rpCancel').onclick=rpClose;
$('rpModal').addEventListener('click',e=>{if(e.target.id==='rpModal')rpClose();});
$('rpNote').addEventListener('keydown',e=>{if((e.metaKey||e.ctrlKey)&&e.key==='Enter'&&!$('rpGo').disabled)$('rpGo').click();});
$('rpGo').onclick=()=>{
  const id=rpCur, btn=rpBtn, d=BY[id]||{};
  const tags=[...rpTagSel], note=$('rpNote').value.trim();
  rpClose();
  const mark=(txt)=>{reported.add(id);localStorage.setItem('reportedQ',JSON.stringify([...reported]));
    if(btn){btn.classList.add('done');btn.textContent=txt;}};
  // note/tags 是新增字段；后端 _do_report 把整个 body 原样落盘，不用改后端
  fetch('/api/report',{method:'POST',headers:{'Content-Type':'application/json'},
    body:JSON.stringify({id,tb:d.tb,gg:d.gg,cd:d.cd,t:d.t,n:d.n,tags,note,q:(d.q||'').slice(0,300)})})
    .then(r=>r.json()).then(()=>mark('✓ 已报告'))
    .catch(()=>{mark('✓ 已记录(本地)');
      alert('后端没连上，这道题只记在了本机浏览器里。\n用「启动组卷台AI助手.command」打开时提交才会落进 问题报告.jsonl。\n\n你写的描述：\n'+[tags.join('、'),note].filter(Boolean).join('\n'));});
};
$('list').addEventListener('click',e=>{const rp=e.target.closest('[data-report]');if(rp){reportProblem(rp.dataset.report,rp);return;}
  const an=e.target.closest('[data-analysis]');if(an){openAnalysis(an.dataset.analysis);return;}
  const sm=e.target.closest('[data-sim]');if(sm){genSimilar(sm.dataset.sim);return;}
  const ss=e.target.closest('[data-src]');if(ss){openSrc(ss.dataset.src);return;}
  const ow=e.target.closest('[data-word]');if(ow){openWord(ow.dataset.word,ow);return;}
  const a=e.target.closest('[data-add]');if(a){toggleAdd(a.dataset.add);return;}if(e.target.matches('.figs img,.stem img.blk,.stxt img.blk')){$('zoom').querySelector('img').src=e.target.src;$('zoom').style.display='grid';}});
// ── 打开原卷（中考真题）：把卷 id 交给本地助手，由它用系统默认程序打开源 Word。
//    只传 id 不传路径；路径在服务端解析，越界的一律拒。
//    公网静态部署没有这个后端 → 明确提示，而不是静默失败。
function openWord(pid,btn,i){
  const old=btn.textContent; btn.textContent='打开中…'; btn.disabled=true;
  const done=(t)=>{btn.textContent=t;btn.disabled=false;setTimeout(()=>{btn.textContent=old;},2200);};
  fetch('/api/openword',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({pid,i:i||0})})
    .then(r=>r.json()).then(j=>{ if(j.ok){done('✓ 已打开');} else {done('✕ 打不开');alert(j.error||'打不开这份原卷');} })
    .catch(()=>{done('✕ 需本地助手');
      alert('打开原卷需要本地助手在跑（双击 组卷系统/启动组卷台AI助手.command）。\n在线版不提供原卷下载。');});
}
// ── 对照原书：显示该题所在讲的原书页扫描图；随站文件夹缺失时回退到本地助手按需渲染 ──
const HELPER='http://localhost:8848';
function openSrc(id){const d=BY[id];if(!d||!d.pg||!d.pg.length)return;
  const pr=d.pr&&d.pr.length?`原书第 <b>${d.pr[0]}–${d.pr[1]}</b> 页`:'原书页';
  $('smTitle').innerHTML=`${esc(d.gg)} · ${esc(d.cd)} —— ${pr}`;
  const imgs=d.pg.map(u=>{const helper=HELPER+'/'+u;
    return `<img loading=lazy src="${esc(u)}" data-h="${esc(helper)}" onerror="if(!this.dataset.f){this.dataset.f=1;this.src=this.dataset.h;}else{this.replaceWith(Object.assign(document.createElement('div'),{className:'sm-miss',innerHTML:'⚠ 原书页图未找到。<br>静态部署需随站带上「原书页」文件夹；或启动本地助手(启动组卷台AI助手.py)后按需查看。'}));}"><div class="sm-pgno">${esc(u.split('/').pop())}</div>`;}).join('');
  $('smBody').innerHTML=imgs;$('smBody').scrollTop=0;$('srcModal').style.display='flex';}
$('smClose').onclick=()=>$('srcModal').style.display='none';
$('srcModal').addEventListener('click',e=>{if(e.target.id==='srcModal')$('srcModal').style.display='none';});
document.addEventListener('keydown',e=>{if(e.key==='Escape')$('srcModal').style.display='none';});
$('cart').addEventListener('click',e=>{const t=e.target;
  if(t.dataset.csim!=null){genSimilar(t.dataset.csim);return;}
  if(t.dataset.rm!=null)toggleAdd(cart[+t.dataset.rm]);
  else if(t.dataset.up!=null){const i=+t.dataset.up;if(i>0){[cart[i-1],cart[i]]=[cart[i],cart[i-1]];renderCart();}}
  else if(t.dataset.dn!=null){const i=+t.dataset.dn;if(i<cart.length-1){[cart[i+1],cart[i]]=[cart[i],cart[i+1]];renderCart();}}});

// ===== AI 出类似题（基于选中题，经本地代理调 DeepSeek v4-pro）=====
const AI_ON=location.protocol.startsWith('http');
// 版本握手：后端 BUILD 和页面不一致(或后端太旧没这接口) → 顶部横幅提醒重启,根治"改完忘重启导致预览≠导出"
const NEED_BUILD='__BUILD__';
function verWarn(got){if(document.getElementById('verbar'))return;
  const b=document.createElement('div');b.id='verbar';
  b.textContent='⚠️ 后端 AI 助手是旧版本('+got+'，页面需要 '+NEED_BUILD+')：请关掉它的终端窗口，重新双击「启动组卷台AI助手.command」，再刷新本页。否则导出的 Word 会和预览不一致。';
  document.body.prepend(b);}
let SRC_OK={gen:1,ai:1,variants:1,compose:1,xkw:1}, SRC_OFF={};
// 奥数三套（胡小群/动脑筋/举一反三）单独关着：能锁竞赛难度的接口没额度，
// 退回相似题只给得出课内难度的题，点了必然零道还白烧调用 —— 由服务端下发名单
// ⚠ 曾经按教材整体禁用学科网（奥数三套）——**撤了**。10 个专题 100 道实测 58 道
//   能过难度过滤，按教材封杀会误杀。改成**按种子**：哪道种子出不来合格难度，
//   服务端返 exhausted=true，就只把那道种子的学科网页签置灰。
const XKW_DEAD=new Set();      // 种子 id → 学科网对它出不了合格难度（只在本次会话内）
let simIsOly=false, XKW_OLY_WHY='';
if(AI_ON)fetch('/api/version').then(r=>r.json()).then(j=>{
  if(!j||j.build!==NEED_BUILD)verWarn(j&&j.build||'未知');
  if(j&&j.sources){SRC_OK=j.sources; SRC_OFF={xkw:j.xkw_off_reason||''};
    // 上次选的那条要是下线了，回落到 AI，别让老师点了没反应
    if(!SRC_OK[simBy])simBy=SRC_OK.ai?'ai':'gen';
    if(!SRC_OK.compose){const b=document.getElementById('bCompose');if(b){b.disabled=true;b.title='后端未配置 DeepSeek key';}}
    if(document.getElementById('simTabs').children.length)renderTabs();}
}).catch(()=>verWarn('太旧,无版本接口'));
let simSeq=0, simSrcId=null, simIsFig=false, simFigB64='', simCanFigure=false;
// 三选一：题从哪来由老师点选，不再由程序替他决定。记住上次（同一个人通常固定用一条路）。
const SIM_SRC={gen:{label:'\u2699\ufe0f 生成器',tip:'秒出 · 带答案解析 · 只覆盖 58 个题型',fig:false},
               ai :{label:'\ud83e\udd16 AI',   tip:'任意题型 · 出完跑数值核验 · 不带答案',fig:false},
               variants:{label:'\ud83e\uddec 变式组',tip:'举一反三 · 换数字/扩数位/逆向/加干扰/换情境/加一步 各一道 · 跑数值核验',fig:false},
               ai_figure:{label:'\ud83d\udcd0 AI 配图·实验',tip:'圆与扇形试验 · 题干与结构化题图一起生成 · 每次最多 3 道',fig:true,pilot:true},
               xkw:{label:'\ud83d\udcda 学科网',tip:'真题 · 带官方答案解析 · 授权内容不入库',fig:true}};
let simBy=localStorage.getItem('zj.simBy')||'ai';
if(!SIM_SRC[simBy])simBy='ai';
function renderTabs(){
  $('simTabs').innerHTML=Object.keys(SIM_SRC).map(function(k){
    var v=SIM_SRC[k], dead=!SRC_OK[k];
    var oly=(k==='xkw'&&simIsOly);                       // 这道题所属教材学科网给不了合格难度
    var off=dead||oly||(simIsFig&&!v.fig)||(v.pilot&&!simCanFigure);
    var why=dead?(SRC_OFF[k]||'该来源暂时不可用')
            :oly?XKW_OLY_WHY
            :v.pilot?'当前实验只支持胡小群 L6「圆与扇形（一）」的三扇形曲边图'
            :'带图题 AI 配不出图，只有学科网能按题图找真题';
    return '<button class="sim-tab'+(k===simBy?' on':'')+(off?' off':'')+'" data-by="'+k+'"'+
      (off?' disabled title="'+esc(why)+'"':'')+'>'+v.label+
      (dead?' ·已下线':oly?' ·难度不合':'')+'</button>';}).join('');
  $('simTabTip').textContent=SIM_SRC[simBy].tip;
}
const drawerOpen=()=>$('aiDrawer').classList.add('open');
$('aiClose').onclick=()=>$('aiDrawer').classList.remove('open');
function simTip(html){$('simList').innerHTML='<div class="sim-wait">'+html+'</div>';}
function figureEditorUrl(spec){
  // 线上同域走 /geogebra/；本地组卷助手仍在 8848 时，指向本机画板开发服务。
  const local=location.hostname==='127.0.0.1'||location.hostname==='localhost';
  const base=local?'http://127.0.0.1:8787/geogebra/':'/geogebra/';
  return base+'?figure='+encodeURIComponent(JSON.stringify(spec));
}
async function genSimilar(id){
  const d=BY[id];if(!d)return;
  // 带图题不再一刀切禁掉：AI 和生成器配不出图，但学科网能按题图找真题
  // 已下线的来源不能停在选中态（否则点开抽屉出 0 道，还看不出为什么）——每次开抽屉都兜一次
  if(!SRC_OK[simBy])simBy=SRC_OK.ai?'ai':(SRC_OK.gen?'gen':'xkw');
  simIsOly=XKW_DEAD.has(id);        // 这道种子之前试过、学科网出不了合格难度
  simIsFig=!!(d.f||(d.mf&&d.mf.length));
  // 第一版只对已验证过的复杂图形开放，避免“泛化”成无法判定的图形关系。
  simCanFigure=(d.id==='Q-L6-S11-07');
  simFigB64='';
  if(simIsFig&&d.f){await ensureFigs([id]);simFigB64=(await figData(d.f))||'';}
  // 带图题只能走学科网 → 临时切过去；但**别让它污染老师存的偏好**：
  // 下一道普通题要回到 localStorage 里那条（否则看过一道带图题，之后全默认学科网）
  const pref=localStorage.getItem('zj.simBy');
  if(!simIsFig&&SIM_SRC[pref])simBy=pref;
  if(simCanFigure&&SRC_OK.ai_figure)simBy='ai_figure';
  else if(simIsFig&&!SIM_SRC[simBy].fig&&SRC_OK.xkw&&!simIsOly)simBy='xkw';
  if(!simCanFigure&&simBy==='ai_figure')simBy=SRC_OK.ai?'ai':(SRC_OK.gen?'gen':'xkw');
  if(simBy==='xkw'&&simIsOly)simBy=SRC_OK.ai?'ai':'gen';   // 别停在点了必然零道的那条
  simSrcId=id;drawerOpen();
  $('simSrc').innerHTML='<div class="sim-srch">照下面这道题 · 出同类型同难度、换数字的练习</div><div class="sim-srcq">'+renderStem(d.q)+'<div class="sim-srcm">'+esc(d.gg)+' · '+esc(d.cd)+' · '+stars(d.d)+'</div></div>';
  typeset([$('simSrc')]);
  renderTabs();
  if(simIsFig&&!simCanFigure&&(!SRC_OK.xkw||simIsOly)){
    simTip('🖼️ 这是<b>带图题</b>。生成器和 AI 都配不出图，<br>唯一能按题图找真题的<b>学科网'
      +(simIsOly?'对这道题出不了合格难度':'已暂时下线')+'</b>（'
      +esc(simIsOly?XKW_OLY_WHY:(SRC_OFF.xkw||'接口不可用'))+'）。');
    $('simRegen').disabled=true; return;}
  if(!AI_ON){simTip('要用出题功能，请先双击运行「组卷台AI助手.py」，<br>再从 http://localhost:8848/组卷台.html 打开本页。<br>（当前直接打开文件，调不到本地服务）');return;}
  runSim();
}
const SIM_WAIT={gen:'正在用生成器出 10 道…（秒出，带答案解析）',
  ai:'正在照这道题出 10 道…<br><span style="font-size:12px">每道题 AI 都要交出算式，服务端跑一遍核对，算不通的直接丢掉</span>（约 5-20 秒）',
  ai_figure:'正在生成 3 道带题图练习…<br><span style="font-size:12px">题干、半径和弧长关系都会做结构与数值核验</span>（约 5-20 秒）',
  xkw:'正在从学科网找同类真题…<br><span style="font-size:12px">奥数走竞赛分支知识点推题，校内走相似题</span>（约 2-15 秒）'};
let simReq=0;
async function runSim(more){
  const d=BY[simSrcId];if(!d)return;
  const my=++simReq, myBy=simBy;          // 序号守卫：只认最后一次请求的结果
  simTip(SIM_WAIT[simBy]||'正在出题…');
  $('simRegen').disabled=true;
  // ⚠ tb 必须带上：学科网走哪棵课程树由**教材体系**决定，不是年级字样
  //   （胡小群六年级=小学奥数，初中培优讲义六上=五四制初中，两棵树）
  const body={stem:stripTex(d.q),grade:d.gg,topic:d.cd,difficulty:d.d,n:simBy==='ai_figure'?3:(simBy==='variants'?6:10),by:simBy,tb:d.tb||''};
  if(simBy==='xkw'){ if(more)body.mode='more'; if(simFigB64)body.fig=simFigB64; }
  try{
    const r=await fetch('/api/similar',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify(body)});
    const j=await r.json();
    if(my!==simReq)return;                 // 已经换了来源，这份是过期结果，丢掉
    if(!j.ok){
      // 服务端说这道种子学科网出不了合格难度（或已取完）→ 记下来，页签置灰，
      // 别让老师对着同一道题反复点、每点一次都白烧调用
      if(j.exhausted&&myBy==='xkw'){
        XKW_DEAD.add(simSrcId); simIsOly=true;
        if(SRC_OK.ai){simBy='ai';}else if(SRC_OK.gen){simBy='gen';}
        XKW_OLY_WHY=j.error||'';
        renderTabs();
        simTip('📚 '+esc(j.error||'学科网这道题取不到了')+
               '<br><span style="color:#7b8794">已自动切到「'+
               esc(SIM_SRC[simBy].label)+'」，点下方「再生成一批」继续。</span>');
        return;
      }
      simTip('没出成：'+esc(j.error||'未知错误'));return;}
    renderSim(j.items||[],d,j.by||myBy,j.note||'');
  }catch(e){if(my===simReq)simTip('请求失败：'+esc(''+e)+'<br>（确认「组卷台AI助手.py」在运行）');}
  finally{if(my===simReq)$('simRegen').disabled=false;}
}
function renderSim(items,srcD,by,note){
  if(!items.length){simTip('这次没出到题，点下方「再生成一批」重试，或换一个来源。');return;}
  const SRCNAME={gen:'生成器·同类题',ai:'AI生成·类似题',ai_figure:'AI生成·含题图',xkw:'学科网真题'};
  // 实际几道、走了哪条链路，如实说 —— 相似题上限就是 5 道，别让老师以为出了 10 道
  const head=note?'<div style="font-size:11px;color:#7b8794;padding:2px 2px 8px">'+esc(note)+'</div>':'';
  $('simList').innerHTML=items.map(it=>{
    // 试卷篮/导出统一用 it.stem：学科网那份已把公式图内嵌成 ![](data:…)，导出能落到 Word
    const sid=regAi(it,{d:srcD.d||0,src:SRCNAME[by]||'类似题'});
    const inc=cart.includes(sid);
    let tag=(it.type?'<span class="sim-type">'+esc(it.type)+'</span>':'');
    if(by==='xkw')tag+='<span class="sim-xkw">\ud83d\udcda 真题</span>';
    if(it.verified===false)tag+='<span class="sim-unv" title="这类题写不出算式（数图形/逻辑推理/开放题），没做数值核验，请自己过一眼">○ 未核验</span>';
    // 学科网返回的是带版权水印的 HTML，**原样渲染不做任何剥离**（授权内容不去水印）
    const qhtml=(by==='xkw'&&it.stem_html)?it.stem_html:renderStem(it.stem);
    let ans='';
    if(by==='xkw'&&(it.answer_html||it.explanation_html))
      ans='<details class="sim-ans"><summary>看答案 / 解析</summary><div class="ansbody">'+
          (it.answer_html||'')+(it.explanation_html||'')+'</div></details>';
    else if(it.answer)
      ans='<details class="sim-ans"><summary>看答案'+(it.detail?' / 解析':'')+'</summary><div class="ansbody">'+
          renderStem(''+it.answer)+(it.detail?'<div>'+renderStem(''+it.detail)+'</div>':'')+'</div></details>';
    const cr='';   // 用户要求：屏幕上也不显示版权字样
    const figureLink=it.figure?'<a class="sim-figure-link" target="_blank" rel="noopener" href="'+figureEditorUrl(it.figure)+'">📐 在几何画板中查看 / 编辑题图 ↗</a>':'';
    return '<div class="sim-item">'+tag+'<div class="sim-q">'+qhtml+'</div>'+ans+
      figureLink+'<button class="sim-add'+(inc?' in':'')+'" data-addsim="'+sid+'">'+(inc?'✓ 已加入试卷篮':'＋ 加入试卷篮')+'</button>'+cr+'</div>';
  }).join('');
  $('simList').innerHTML=head+$('simList').innerHTML;
  typeset([$('simList')]);
}
$('simList').addEventListener('click',e=>{const b=e.target.closest('[data-addsim]');if(!b)return;
  const sid=b.dataset.addsim;toggleAdd(sid);
  const inc=cart.includes(sid);b.classList.toggle('in',inc);b.textContent=inc?'✓ 已加入试卷篮':'＋ 加入试卷篮';});
$('simRegen').onclick=()=>{if(simSrcId)runSim(simBy==='xkw');};
$('simTabs').addEventListener('click',e=>{const b=e.target.closest('[data-by]');
  if(!b||b.disabled||b.dataset.by===simBy)return;
  simBy=b.dataset.by;localStorage.setItem('zj.simBy',simBy);renderTabs();runSim();});
// 来源下线时把存的偏好也清掉，免得每次开页都要兜一次
if(AI_ON)setTimeout(()=>{const pref=localStorage.getItem('zj.simBy');
  if(pref&&SRC_OK[pref]===false)localStorage.setItem('zj.simBy',simBy);},2000);

// ===== 整页出类似题：当前专题每道题各出 1 道，批量并发 =====
const BATCH_CAP=40;
// 中考真题整体排除：出类似题的提示词是小学奥数版的，套到初三题上必出废品（见 libCard 里的说明）
function batchable(){return filtered.filter(d=>!(d.f||(d.mf&&d.mf.length))&&!d.ai&&!isExamTb(d.tb));}
function updateBatchBar(){
  const bar=$('batchBar');if(!bar)return;
  const nf=batchable(), figN=filtered.length-nf.length;
  if(!filtered.length){bar.style.display='none';return;}
  bar.style.display='flex';
  $('bBatch').disabled=!nf.length;
  $('batchHint').textContent=isExamTb($('ftb').value)
    ?'中考真题暂不出类似题（提示词还是小学奥数版）'
    :(nf.length?('本页 '+nf.length+' 道可出'+(figN?('，'+figN+' 道带图跳过'):'')):'本页都是带图题，AI 无法出图');
  updateAddAllBtn();
}
// 全部加入试卷篮：把当前专题筛选出的整页题一键全推进 cart（含带图题）
function updateAddAllBtn(){
  const b=$('bAddAll');if(!b)return;
  const notIn=filtered.filter(d=>!cart.includes(d.id)).length;
  b.disabled=!filtered.length||!notIn;
  b.textContent=!filtered.length?'＋ 全部加入试卷篮'
    :(notIn?('＋ 全部加入试卷篮（'+notIn+'）'):'✓ 本页已全加入');
}
function addAllToCart(){
  if(!filtered.length)return;
  let added=0;
  filtered.forEach(d=>{if(!cart.includes(d.id)){cart.push(d.id);added++;}});
  renderCart();syncCardsAll();
  $('batchHint').textContent=added?('✓ 已加入 '+added+' 道，试卷篮现共 '+cart.length+' 道'):'本页题目已全部在试卷篮里';
}
async function genBatch(){
  let items=batchable();
  if(!items.length){alert('本页没有可出类似题的题目（带图题 AI 无法生成配图）');return;}
  if(!AI_ON){alert('要用 AI 出题，请先双击运行「组卷台AI助手.py」，再从 http://localhost:8848/组卷台.html 打开本页。\n（当前是直接打开文件，调不到本地 AI）');return;}
  let capped=false;if(items.length>BATCH_CAP){items=items.slice(0,BATCH_CAP);capped=true;}
  $('batchModal').style.display='flex';$('bmAddAll').style.display='none';
  $('bmProg').textContent='正在为 '+items.length+' 道题各出 1 道…（约 '+Math.max(5,Math.ceil(items.length/10)*4)+' 秒）';
  $('bmBody').innerHTML='<div style="grid-column:1/-1;color:#c9beac;text-align:center;padding:44px 20px;line-height:1.9">⏳ 生成中，请稍候…<br>'+(capped?'题目较多，本次先出前 '+BATCH_CAP+' 道':'')+'</div>';
  $('bBatch').disabled=true;
  try{
    const r=await fetch('/api/similar_batch',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({n:1,items:items.map(d=>({id:d.id,stem:stripTex(d.q),grade:d.gg,topic:d.cd,difficulty:d.d}))})});
    const j=await r.json();
    if(!j.ok){$('bmBody').innerHTML='<div style="grid-column:1/-1;color:#e0a2a2;text-align:center;padding:44px">生成失败：'+esc(j.error||'未知错误')+'</div>';return;}
    renderBatch(j.results||[]);
    $('bmProg').textContent='完成 '+(j.ok_n||0)+' / '+(j.total||0)+' 道'+(capped?'（本页共 '+batchable().length+' 道，先出前 '+BATCH_CAP+'）':'');
    $('bmAddAll').style.display='';
  }catch(e){$('bmBody').innerHTML='<div style="grid-column:1/-1;color:#e0a2a2;text-align:center;padding:44px">请求失败：'+esc(''+e)+'<br>（确认「组卷台AI助手.py」在运行）</div>';}
  finally{updateBatchBar();}
}
function renderBatch(results){
  if(!results.length){$('bmBody').innerHTML='<div style="grid-column:1/-1;color:#c9beac;text-align:center;padding:44px">没有结果</div>';return;}
  $('bmBody').innerHTML=results.map(res=>{
    const s=BY[res.id], orig=esc((s?stripTex(s.q):res.stem||'').slice(0,60));
    if(!res.ok||!res.items||!res.items.length)
      return '<div class="bm-pair err"><div class="bm-orig">原题：'+orig+'</div><div class="bm-new">✗ 这道没生成'+(res.error?'（'+esc(res.error)+'）':'')+'</div><button class="bm-retry" data-retry="'+esc(res.id)+'">单独重试</button></div>';
    const it=res.items[0], sid=regAi(it,{d:(s&&s.d)||0});
    const inc=cart.includes(sid);
    return '<div class="bm-pair"><div class="bm-orig">原题：'+orig+'</div>'+(it.type?'<span class="bm-type">'+esc(it.type)+'</span>':'')+
      '<div class="bm-new">'+renderStem(it.stem)+'</div><button class="bm-add'+(inc?' in':'')+'" data-addsim="'+sid+'">'+(inc?'✓ 已加入试卷篮':'＋ 加入试卷篮')+'</button></div>';
  }).join('');
  typeset([$('bmBody')]);
}
$('bBatch').onclick=genBatch;
$('bAddAll').onclick=addAllToCart;
$('bmClose').onclick=()=>$('batchModal').style.display='none';
$('batchModal').addEventListener('click',e=>{if(e.target.id==='batchModal')$('batchModal').style.display='none';});
$('bmAddAll').onclick=()=>$('bmBody').querySelectorAll('.bm-add:not(.in)').forEach(b=>b.click());
$('bmBody').addEventListener('click',e=>{
  const a=e.target.closest('[data-addsim]');
  if(a){const sid=a.dataset.addsim;toggleAdd(sid);const inc=cart.includes(sid);a.classList.toggle('in',inc);a.textContent=inc?'✓ 已加入试卷篮':'＋ 加入试卷篮';return;}
  const rt=e.target.closest('[data-retry]');
  if(rt){$('batchModal').style.display='none';genSimilar(rt.dataset.retry);}
});
$('bPrev').onclick=openPrev;$('bPrint').onclick=doPrint;$('bPrint2').onclick=()=>setTimeout(()=>window.print(),100);
$('bWord').onclick=doDocx;$('bWord2').onclick=doDocx;$('bClose').onclick=()=>$('preview').style.display='none';
$('bClear').onclick=()=>{if(cart.length&&confirm('清空试卷篮？')){cart=[];renderCart();syncCardsAll();}};
function syncCardsAll(){document.querySelectorAll('.q').forEach(c=>{const id=c.dataset.id;const inc=cart.includes(id);c.classList.toggle('added',inc);const b=c.querySelector('.add');if(b){b.classList.toggle('in',inc);b.textContent=inc?'✓ 已加入':'＋ 加入';}});}
$('zoom').onclick=()=>$('zoom').style.display='none';

/* ══════════ AI 组卷：一句话 → 预览清单 → 试卷篮 ══════════
   架构：后端 /api/compose 只做「翻译 + 校验」（cd 全是目录原文，LLM 编不出来）；
   选题在这里做 —— 57k 题就在内存 DATA 里，服务端没有也不该有第二份题库。
   题源：题库真题优先，缺口用确定性生成器补（/api/similar by=gen，带 seed），
   **绝不**用 DeepSeek 现编题顶缺口（陆老师定的）。 */

// ── 题型归一：六套教材的 t 字段各说各话（胡小群 24 种、一课一练 17 种…），
//    收敛成 8 类才能听懂老师说的「5 道填空」。举一反三/动脑筋全是"其他"，
//    题型约束对它们不可判 —— 执行时跳过并如实提示，别装作筛过了。
const TNORM={
  选择:['选择题','选择'], 填空:['填空题','填空'],
  计算:['计算','计算题','口算','竖式','递等式','解方程','简便计算'],
  应用:['应用','应用题','还课题'],
  解答:['解答','解答题','证明题','几何','计数','构造最值','开放探究','推理','概率','周期','数论','数阵填数'],
  作图:['作图','图形'], 判断:['判断'],
  其他:['例题','练习','其他','排序','问答','']};
const T2N={}; Object.keys(TNORM).forEach(k=>TNORM[k].forEach(v=>T2N[v]=k));
function tnorm(t){return T2N[t||'']||'其他';}
const TYPE_RANK={选择:0,填空:1,判断:2,计算:3,作图:4,应用:5,解答:6,其他:7};
// 教材没有可用题型标注的：题型约束跳过
const NO_TYPE_TB=new Set(['举一反三','动脑筋']);

// ── 难度归一（唯一出处）：胡小群有真难度 d；举一反三/动脑筋/一课一练用栏目 s 当粗代理
function diffOf(d){
  if((d.tb==='胡小群课程'||d.tb==='AMC8真题'||d.tb==='袋鼠真题')&&d.d)return d.d<=2?'易':d.d===3?'中':'难';
  if(d.tb==='举一反三')return d.s==='典型例题'?'易':d.s==='尝试应用'?'中':'难';
  if(d.tb==='动脑筋')return d.s==='典型例题'?'易':d.s==='练习'?'中':'难';
  if(d.tb==='一课一练'||d.tb==='新思路'){const s=d.s||'';if(/基础/.test(s))return '易';if(/拓展|提高/.test(s))return '难';return '中';}
  return '中';
}
const DIFF_RANK={易:0,中:1,难:2};

// ── 会话内可复现的洗牌（mulberry32）：换题/重排不跳变
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);
  t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};}
function seededShuffle(arr,seed){const r=mulberry32(seed),a=arr.slice();
  for(let i=a.length-1;i>0;i--){const j=Math.floor(r()*(i+1));[a[i],a[j]]=[a[j],a[i]];}return a;}

// ── AI 题统一注册。id 带时间戳：老写法 'AI-'+simSeq 刷新后从 1 重数，
//    会和「我的试卷」回灌进 BY 的旧 id 撞车（同 id 两道不同的题）。
function regAi(it,meta){
  const sid='AI-'+Date.now().toString(36)+'-'+(++simSeq);
  BY[sid]=Object.assign({id:sid,q:it.stem,d:0,f:null,mf:[],k:[],pg:[],s:'AI',
                         t:'其他',gg:'AI',cd:'类似题',src:'AI生成·类似题',ai:1},meta||{});
  if(it.answer!=null&&it.answer!=='')BY[sid].ans=String(it.answer);
  if(it.detail)BY[sid].det=String(it.detail);
  return sid;
}

// ── 前置缩范围（纯正则，零成本）：认出教材/年级就只带那部分目录给后端，
//    认不出才带全量（37KB，flash 也吃得下）。
function preNarrow(text){
  const TBRE=[[/胡小群/,'胡小群课程'],[/举一反三/,'举一反三'],[/动脑筋/,'动脑筋'],
              [/一课一练|课本|同步|校内/,'一课一练'],[/新思路/,'新思路'],[/培优|初中讲义|分类汇编|汇编/,'初中培优讲义'],
              [/AMC\s*8|美国数学|amc/i,'AMC8真题'],
              [/袋鼠|kangaroo|Level\s*[AB]\b/i,'袋鼠真题'],
              [/中考|一模|二模|三模|真题/,'上海中考真题'],
              // 校本卷：整卷（月考/期中/期末/模拟/押题/学校名）。「期中/期末」也会命中培优的汇编册，两边都进候选让 LLM 按提示词分。
              [/校本|月考|期中|期末|模拟卷|押题|阶段测|南洋模范|张江|市西|市北|建平|进才|上外|华二|复旦|交大附|七宝|延安|格致|大同|位育/,'上海校本卷'],
              // ⚠ 「中考」两个词上海批也命中，这里**再加一条**而不是改上一条：
              //   老师说「中考」多半指本地卷，说到省名/全国/外省才是全国批。
              [/全国|外省|各省|云南|内蒙古|北京|台湾|吉林|天津|宁夏|安徽|山东|山西|广东|广西|新疆|江苏|江西|河北|河南|浙江|海南|湖北|湖南|甘肃|福建|西藏|贵州|辽宁|重庆|陕西|青海|黑龙江|四川/,'全国中考真题']];
  let tbs=TBRE.filter(p=>p[0].test(text)).map(p=>p[1]);
  if(!tbs.length&&/奥数|思维/.test(text))tbs=['举一反三','动脑筋','胡小群课程'];
  const pairs=[];
  Object.keys(COURSE).forEach(tb=>{Object.keys(COURSE[tb]).forEach(gg=>{
    const m=gg.match(/[一二三四五六七八九]年级[上下]?|初[一二三]|^[六七八九][上下]/)||gg.match(/^\d{4}年/);
    const gkey=m?m[0]:gg;
    // 「六年级上」和「六上」互认（一课一练/新思路的 gg 是「六年级上·…」，培优是「六上」，老师两种都会说）
    const normG=x=>x.replace(/([一二三四五六七八九])年级([上下])/g,'$1$2');
    const T=normG(text), K=normG(gkey);
    const ghit=T.includes(K)||(/年级[上下]$/.test(gkey)&&T.includes(gkey.replace(/[上下]$/,'')))||(gg==='一·二年级'&&/[一二]年级/.test(text));
    const thit=tbs.includes(tb);
    // gg 不是「年级/年份」形态的轴（全国批=省份、培优「中考·分类汇编」等）：老师句里的年级/年份字样
    // 和它无关，不能拿 hasG 把它筛掉 —— 教材命中就整轴带上（2026-09-07 修：此前全国批在老师说年份时被上海批挤掉）
    const axisFree=!m;
    if((tbs.length&&thit&&(ghit||!hasG(text)||axisFree))||(!tbs.length&&ghit))pairs.push([tb,gg,thit,ghit]);
  });});
  function hasG(t){return /[一二三四五六七八九]年级|初[一二三]|[六七八九][上下]|\d{4}\s*年|预初/.test(t);}
  if(!pairs.length)return null;
  // 教材和年级都命中的优先；只命中一头就全带该头
  const both=pairs.filter(p=>p[2]&&p[3]);
  return (both.length?both:pairs).map(p=>[p[0],p[1]]);
}
function catalogFor(cands){
  const out={};
  const put=(tb,gg)=>{(out[tb]=out[tb]||{})[gg]=[...new Set(COURSE[tb][gg].map(nd=>nd[1]))];};
  if(cands)cands.forEach(([tb,gg])=>put(tb,gg));
  else Object.keys(COURSE).forEach(tb=>Object.keys(COURSE[tb]).forEach(gg=>put(tb,gg)));
  return out;
}

// ── 惰性索引：IDX[tb][gg][cd] = [id…]，一次遍历
let IDX=null,KIDX=null;
function ensureIdx(){
  if(IDX)return;
  IDX={};KIDX={};
  DATA.forEach(d=>{
    let a=IDX[d.tb];if(!a)a=IDX[d.tb]={};
    let b=a[d.gg];if(!b)b=a[d.gg]={};
    (b[d.cd]||(b[d.cd]=[])).push(d.id);
    (d.k||[]).forEach(kp=>{(KIDX[kp]||(KIDX[kp]=[])).push(d.id);});
  });
}
const CNUM={'一':1,'二':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9};
function gradeNumOf(gg){   // 册次串→年级数；年份等认不出返回 null
  let m=(gg||'').match(/^初([一二三])/);if(m)return CNUM[m[1]]+6;
  m=(gg||'').match(/([一二三四五六七八九])年级/);if(m)return CNUM[m[1]];
  m=(gg||'').match(/^([六七八九])[上下]/);if(m)return CNUM[m[1]];
  return null;
}

// ── 选题引擎 ────────────────────────────────────────────────
let CP=null;   // {spec,understood,fixes,unresolved,rows:[{id,gi,fromGen}],pools,missing,seed,used}
function cpScore(spec,d){
  let s=0;
  if(spec.types&&!NO_TYPE_TB.has(spec.tb)&&!spec.types.includes(tnorm(d.t)))s+=2;
  if(spec.difficulty!=='混合'&&diffOf(d)!==spec.difficulty)s+=1;
  return s;
}
async function runCompose(resp){
  ensureIdx();
  const spec=resp.spec, seed=(Date.now()>>>0)%1e9;
  const used=new Set(), rows=[], pools=[], shortages=[], missing=[];
  spec.groups.forEach((g,gi)=>{
    let pool=[];
    if(g.kps&&g.kps.length){          // 知识点轴：跨教材取池
      const seen=new Set();
      g.kps.forEach(kp=>(KIDX[kp]||[]).forEach(id=>{if(!seen.has(id)){seen.add(id);pool.push(id);}}));
      const gn=spec.guessed_grade?null:gradeNumOf(spec.gg);   // 年级是猜的就别拿它过滤知识点池
      if(gn){const strict=pool.filter(id=>{const n=gradeNumOf(BY[id].gg);
          return n==null?gn>=7:Math.abs(n-gn)<=1;});   // 无年级串的(中考卷)只配初中需求
        // 年级差≤1 的够用就只用它们；不够时把其余(差≤2)补在后面兜底，宁可跨一级也别空手
        const ss=new Set(strict);
        pool=strict.length>=g.n?strict:strict.concat(pool.filter(id=>{if(ss.has(id))return false;
          const n=gradeNumOf(BY[id].gg);return n!=null&&Math.abs(n-gn)<=2;}));}
    }else{
      g.cds.forEach(cd=>{pool=pool.concat(((IDX[spec.tb]||{})[spec.gg]||{})[cd]||[]);});
    }
    pool=pool.filter(id=>!cart.includes(id)&&!used.has(id));
    if(spec.no_figure)pool=pool.filter(id=>{const d=BY[id];return !(d.f||(d.mf&&d.mf.length)||(d.im&&d.im.length));});
    // 软打分：题型不符 +2、难度不符 +1；同分随机（seed 可复现）。
    // 硬过滤会在题不够时空手而归 —— 软打分自动降级补位，行上再打标提示。
    pool=seededShuffle(pool,seed+gi).sort((a,b)=>cpScore(spec,BY[a])-cpScore(spec,BY[b]));
    const take=pool.slice(0,g.n);
    pools[gi]=pool.slice(g.n);
    take.forEach(id=>{used.add(id);rows.push({id,gi,fromGen:false});});
    if(take.length<g.n)shortages.push({gi,want:g.n-take.length});
  });
  // 缺口 → 确定性生成器（不换 DeepSeek 现编）；中考 allow_gen=false 直接跳过
  if(shortages.length&&spec.allow_gen&&SRC_OK.gen){
    await Promise.all(shortages.map(async sh=>{
      const g=spec.groups[sh.gi];
      try{
        const r=await fetch('/api/similar',{method:'POST',headers:{'Content-Type':'application/json'},
          body:JSON.stringify({stem:'',by:'gen',topic:(g.kps&&g.kps[0])||g.cds[0],grade:spec.gg,
                               difficulty:{易:2,中:3,难:4,混合:3}[spec.difficulty]||3,
                               n:sh.want,seed:seed+sh.gi})});
        const j=await r.json();
        if(j.ok&&j.items&&j.items.length){
          j.items.slice(0,sh.want).forEach(it=>{
            const sid=regAi(it,{tb:spec.tb,gg:spec.gg,cd:(g.kps&&g.kps[0])||g.cds[0],src:'生成器·同类题',
                                t:it.type||'其他',d:{易:2,中:3,难:4}[spec.difficulty]||0});
            rows.push({id:sid,gi:sh.gi,fromGen:true});});
          if(j.items.length<sh.want)missing.push({label:g.label,want:sh.want,got:j.items.length,why:'生成器只出得了这么多'});
        }else missing.push({label:g.label,want:sh.want,got:0,why:'生成器没有这个题型'});
      }catch(e){missing.push({label:g.label,want:sh.want,got:0,why:'生成器调用失败'});}
    }));
  }else shortages.forEach(sh=>{
    const g=spec.groups[sh.gi];
    missing.push({label:g.label,want:sh.want,got:0,
                  why:spec.allow_gen?'生成器不可用':'中考真题不做生成补题'});
  });
  sortRows(spec,rows);
  CP={spec,understood:resp.understood,fixes:resp.fixes||[],unresolved:resp.unresolved||[],
      rows,pools,missing,seed,used};
}
function sortRows(spec,rows){
  rows.forEach((r,i)=>r._i=i);
  if(spec.order==='easy_first')
    rows.sort((a,b)=>DIFF_RANK[diffOf(BY[a.id])]-DIFF_RANK[diffOf(BY[b.id])]
      ||TYPE_RANK[tnorm(BY[a.id].t)]-TYPE_RANK[tnorm(BY[b.id].t)]||a.gi-b.gi||a._i-b._i);
  else if(spec.order==='by_type')
    rows.sort((a,b)=>TYPE_RANK[tnorm(BY[a.id].t)]-TYPE_RANK[tnorm(BY[b.id].t)]||a.gi-b.gi||a._i-b._i);
  else rows.sort((a,b)=>a.gi-b.gi||a._i-b._i);
}

// ── 预览渲染 ────────────────────────────────────────────────
function renderCompose(){
  const s=CP.spec;
  $('cpUnd').innerHTML=esc(CP.understood)+(s.guessed_grade?' <span class="cp-guess">年级未说，按此猜</span>':'');
  $('cpProg').textContent='已选 '+CP.rows.length+' / '+s.total;
  $('cpAdd').style.display=CP.rows.length?'':'none';
  $('cpAdd').textContent='全部加入试卷篮（'+CP.rows.filter(r=>!cart.includes(r.id)).length+'）';
  let head='';
  const notes=[];
  CP.fixes.forEach(f=>notes.push('你说的「'+esc(f.asked)+'」我取了「'+esc(f.picked)+'」'));
  CP.unresolved.forEach(u=>notes.push('「'+esc(u)+'」在这套教材里没找到对应专题'));
  CP.missing.forEach(m=>notes.push('「'+esc(m.label)+'」还缺 '+(m.want-m.got)+' 道（'+m.why+'）'));
  if(s.types&&NO_TYPE_TB.has(s.tb))notes.push('该教材没有题型标注，题型要求已忽略');
  if(notes.length)head='<div class="cp-note">'+notes.map(n=>'· '+n).join('<br>')+'</div>';
  $('cpBody').innerHTML=head+(CP.rows.length?CP.rows.map((r,i)=>{
    const d=BY[r.id], badType=s.types&&!NO_TYPE_TB.has(s.tb)&&!s.types.includes(tnorm(d.t));
    return '<div class="cp-row" data-i="'+i+'">'
      +'<div class="cp-tags"><b>'+(i+1)+'</b>'
      +'<span class="cp-tag">'+esc(tnorm(d.t))+(badType?'<i title="题库里这个专题的目标题型不够，用别的题型补了位">·不符</i>':'')+'</span>'
      +'<span class="cp-tag">'+esc(diffOf(d))+'</span>'
      +'<span class="cp-tag '+(r.fromGen?'gen':'')+'">'+(r.fromGen?'⚙ 生成器':'真题')+'</span>'
      +((s.groups[r.gi]&&s.groups[r.gi].kps&&!r.fromGen)?'<span class="cp-tag">'+esc(d.tb||'')+'</span>':'')
      +'<span class="cp-ops"><button class="btn gh sm" data-cpswap="'+i+'">🔁 换一题</button>'
      +'<button class="btn gh sm" data-cpdel="'+i+'">✕</button></span></div>'
      +'<div class="cp-stem">'+renderStem(d.q)+'</div>'
      +(r.fromGen&&d.ans?'<details class="sim-ans"><summary>看答案</summary><div class="ansbody">'
        +esc(d.ans)+(d.det?'<br>'+esc(d.det):'')+'</div></details>':'')
      +'</div>';
  }).join(''):'<div class="cp-note">一道也没凑出来 —— 换个说法试试？</div>');
  typeset([$('cpBody')]);
}
async function cpSwap(i){
  const r=CP.rows[i], g=CP.spec.groups[r.gi], pool=CP.pools[r.gi]||[];
  let nid=null;
  while(pool.length){const c=pool.shift();
    if(!cart.includes(c)&&!CP.used.has(c)){nid=c;break;}}
  if(nid==null&&CP.spec.allow_gen&&SRC_OK.gen){
    try{
      const rr=await fetch('/api/similar',{method:'POST',headers:{'Content-Type':'application/json'},
        body:JSON.stringify({stem:'',by:'gen',topic:(g.kps&&g.kps[0])||g.cds[0],grade:CP.spec.gg,
                             difficulty:3,n:1,seed:(Date.now()>>>0)%1e9})});
      const j=await rr.json();
      if(j.ok&&j.items&&j.items.length){
        nid=regAi(j.items[0],{tb:CP.spec.tb,gg:CP.spec.gg,cd:(g.kps&&g.kps[0])||g.cds[0],src:'生成器·同类题',t:j.items[0].type||'其他'});
        CP.rows[i]={id:nid,gi:r.gi,fromGen:true};CP.used.add(nid);renderCompose();return;
      }
    }catch(e){}
  }
  if(nid==null){alert('「'+g.label+'」没有更多可换的题了');return;}
  CP.used.delete(r.id);CP.used.add(nid);
  CP.pools[r.gi].push(r.id);            // 旧题回池尾，还能换回来
  CP.rows[i]={id:nid,gi:r.gi,fromGen:!!BY[nid].ai};
  renderCompose();
}

// ── 弹层与事件 ──────────────────────────────────────────────
function cpOpenAsk(){$('composeAsk').style.display='flex';$('cpErr').textContent='';setTimeout(()=>$('cpText').focus(),50);}
$('bCompose').onclick=()=>{
  if(!AI_ON){alert('AI 组卷需要经 http 打开：双击「启动组卷台AI助手.command」再用它弹出的网址。');return;}
  cpOpenAsk();
};
$('cpCancel').onclick=()=>$('composeAsk').style.display='none';
$('composeAsk').addEventListener('click',e=>{if(e.target.id==='composeAsk')$('composeAsk').style.display='none';});
document.querySelectorAll('#composeAsk .chip').forEach(c=>c.onclick=()=>{$('cpText').value=c.textContent;});
$('cpGo').onclick=async()=>{
  const text=$('cpText').value.trim();
  if(!text){$('cpErr').textContent='先说一句话';return;}
  $('cpGo').disabled=true;$('cpGo').textContent='正在理解…';$('cpErr').textContent='';
  try{
    const r=await fetch('/api/compose',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({text,catalog:catalogFor(preNarrow(text)),kps:(ensureIdx(),Object.keys(KIDX).sort().map(k=>{const gs=new Set();KIDX[k].forEach(id=>{const n=gradeNumOf(BY[id].gg);if(n)gs.add(n);});
        const a=[...gs].sort((x,y)=>x-y);return a.length?{n:k,g:(a[0]===a[a.length-1]?a[0]:a[0]+'-'+a[a.length-1])+'年级'}:{n:k};}))})});
    const j=await r.json();
    if(!j.ok){$('cpErr').textContent=j.error||'AI 没听懂，换个说法试试';return;}
    $('composeAsk').style.display='none';
    $('composeModal').style.display='flex';
    $('cpUnd').textContent=j.understood;$('cpProg').textContent='';
    $('cpBody').innerHTML='<div class="cp-note">正在挑题…</div>';$('cpAdd').style.display='none';
    await runCompose(j);
    renderCompose();
  }catch(e){$('cpErr').textContent='后端没有响应：'+(e&&e.message||e);}
  finally{$('cpGo').disabled=false;$('cpGo').textContent='组卷';}
};
$('cpText').addEventListener('keydown',e=>{if((e.metaKey||e.ctrlKey)&&e.key==='Enter')$('cpGo').click();});
$('cpClose').onclick=()=>$('composeModal').style.display='none';
$('composeModal').addEventListener('click',e=>{if(e.target.id==='composeModal')$('composeModal').style.display='none';});
$('cpEdit').onclick=()=>{$('composeModal').style.display='none';cpOpenAsk();};
$('cpBody').addEventListener('click',e=>{
  const sw=e.target.closest('[data-cpswap]');
  if(sw){cpSwap(+sw.dataset.cpswap);return;}
  const dl=e.target.closest('[data-cpdel]');
  if(dl){CP.rows.splice(+dl.dataset.cpdel,1);renderCompose();}
});
$('cpAdd').onclick=()=>{
  if(!CP)return;
  let n=0;
  CP.rows.forEach(r=>{if(!cart.includes(r.id)){cart.push(r.id);n++;}});
  renderCart();syncCardsAll();
  $('composeModal').style.display='none';
  const t=$('pTitle');
  if(t&&t.dataset.auto){t.value=CP.understood.split(' · ').slice(0,2).join('·').slice(0,24);}
};

/* ══════════ 试卷存得住 ══════════
   cart 原来是个纯内存数组，刷新页面/关标签页/误点后退，挑好的题就全没了。
   现在两层：① 当前试卷篮每次变动都写 localStorage，开页自动接着上次；
            ② 「我的试卷」把某一份连标题和排版选项一起存下来，可反复打开。
   都只存题目 id，不存题面 —— 体积极小，也不会跟题库版本打架（认不出的 id 直接丢）。 */
const CK='zj.cart', PK='zj.papers';
const OPTIDS=['pTitle','pSub','oEx','oSpace','oCols','oId'];
function snap(){
  const o={ids:cart.slice()};
  OPTIDS.forEach(id=>{const e=$(id);o[id]=e.type==='checkbox'?e.checked:e.value;});
  // AI/生成器现造的题只活在内存 BY 里，光存 id 刷新就丢（applySnap 会把认不出的 id 过滤掉）。
  // 把题体一起存（几 KB），恢复时先回灌 BY。
  const gen=cart.filter(id=>BY[id]&&BY[id].ai).map(id=>{const b=BY[id];
    return {id:b.id,q:b.q,d:b.d||0,t:b.t||'其他',tb:b.tb||'',gg:b.gg||'AI',cd:b.cd||'类似题',
            src:b.src||'AI',ans:b.ans,det:b.det};});
  if(gen.length)o.gen=gen;
  return o;
}
function applySnap(o){
  (o.gen||[]).forEach(g=>{if(!BY[g.id])BY[g.id]=Object.assign({f:null,mf:[],k:[],pg:[],s:'AI',ai:1},g);});
  cart=(o.ids||[]).filter(id=>BY[id]);          // 题库更新过就可能有认不出的 id
  OPTIDS.forEach(id=>{if(o[id]==null)return;const e=$(id);
    if(e.type==='checkbox')e.checked=!!o[id];else e.value=o[id];});
  if(o.pTitle)delete $('pTitle').dataset.auto;  // 存过标题就别再被自动填覆盖
  renderCart();syncCardsAll();
}
// ⚠ 别用 toLocaleString(...).slice(0,16)：中文环境出来是「2026/7/28 22:48:30」，
//   切 16 位正好切在秒中间，尾巴留个冒号。自己拼。
function stamp(){const d=new Date(),z=n=>String(n).padStart(2,'0');
  return `${d.getFullYear()}-${z(d.getMonth()+1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`;}
function saveCart(){try{localStorage.setItem(CK,JSON.stringify(snap()));}catch(e){}}
function loadPapers(){try{return JSON.parse(localStorage.getItem(PK))||[];}catch(e){return [];}}
function savePapers(a){try{localStorage.setItem(PK,JSON.stringify(a));}catch(e){alert('存不下了：浏览器存储已满，先删几份旧试卷');}}
function updPn(){const n=loadPapers().length;$('pn').textContent=n;}

function renderPapers(){
  const ps=loadPapers();
  $('pmBody').innerHTML=ps.length?ps.map((p,i)=>
    `<div class="pp"><div><div class="nm">${esc(p.name)}</div>
      <div class="meta">${p.ids.length} 题 · ${esc(p.when||'')}${p.pTitle?' · '+esc(p.pTitle):''}</div></div>
      <div class="ops"><button class="btn sm" data-open="${i}">打开</button>
      <button class="btn gh sm" data-dup="${i}">复制一份</button>
      <button class="btn gh sm" data-del="${i}">删除</button></div></div>`).join('')
    : '<div class="pp-empty">还没存过试卷。挑好题后点「💾 存为我的试卷」。</div>';
}
$('bPapers').onclick=()=>{renderPapers();$('papersModal').style.display='flex';};
$('pmClose').onclick=()=>$('papersModal').style.display='none';
$('pmBody').addEventListener('click',e=>{
  const b=e.target.closest('button[data-open],button[data-dup],button[data-del]');
  if(!b)return;
  const ps=loadPapers();
  if(b.dataset.open!=null){
    const p=ps[+b.dataset.open];
    if(cart.length&&!confirm('打开「'+p.name+'」会替换掉当前试卷篮的 '+cart.length+' 道题，继续？'))return;
    applySnap(p);$('papersModal').style.display='none';
  }else if(b.dataset.dup!=null){
    const p=ps[+b.dataset.dup], c=JSON.parse(JSON.stringify(p));
    c.name=p.name+' 副本';c.when=stamp();
    ps.splice(+b.dataset.dup+1,0,c);savePapers(ps);renderPapers();updPn();
  }else{
    const p=ps[+b.dataset.del];
    if(!confirm('删掉「'+p.name+'」？删了找不回来'))return;
    ps.splice(+b.dataset.del,1);savePapers(ps);renderPapers();updPn();
  }
});
$('bSave').onclick=()=>{
  if(!cart.length){alert('试卷篮是空的，先挑题');return;}
  const def=$('pTitle').value.trim()||$('fsec').value||'未命名试卷';
  const name=prompt('给这份试卷起个名字：',def);
  if(name==null)return;
  const ps=loadPapers(), o=snap();
  o.name=name.trim()||def;
  o.when=stamp();
  const old=ps.findIndex(p=>p.name===o.name);
  if(old>=0&&!confirm('已经有一份叫「'+o.name+'」的，覆盖它？'))return;
  if(old>=0)ps[old]=o;else ps.unshift(o);
  savePapers(ps);updPn();alert('已存：'+o.name);
};

/* ══════════ 答案索引 ══════════
   卷面保持零答案（学生拿到的那张纸上什么都不多）。这里单独出一页给老师：
   这份卷的每一段题，答案分别在哪个源文件里、原书哪几页 —— 只指路，不复制题面/答案。 */
async function openKeyIndex(){
  if(!cart.length){alert('试卷篮是空的，先挑题');return;}
  $('keyModal').style.display='flex';
  $('kmBody').innerHTML='<div class="kw"><div class="sub">正在核对源文件…</div></div>';
  const groups=[],at={};
  cart.forEach((id,i)=>{
    const d=BY[id]; if(d.ai)return;               // AI/生成器题没有源文件可对照
    const k=[d.tb,d.gg,d.cd].join('|');
    if(at[k]==null){at[k]=groups.length;groups.push({tb:d.tb,gg:d.gg,cd:d.cd,nos:[],srcs:[],pgs:[]});}
    const g=groups[at[k]];
    g.nos.push(i+1);
    if(d.s&&g.srcs.indexOf(d.s)<0)g.srcs.push(d.s);
    (d.pg||[]).forEach(p=>{const m=/p(\d+)\.(?:png|jpg)$/i.exec(p);if(m)g.pgs.push(+m[1]);});
  });
  // 每个专题问一次服务端「实际有哪几份文件」——不猜文件名
  await Promise.all(groups.map(async g=>{
    const qs='tb='+encodeURIComponent(g.tb)+'&g='+encodeURIComponent(g.gg)+'&cd='+encodeURIComponent(g.cd);
    try{const j=await (await fetch('/api/topic/files?'+qs)).json();g.files=(j&&j.files)||[];g.qs=qs;}
    catch(e){g.files=null;g.qs=qs;}
  }));
  const rng=a=>{a=a.slice().sort((x,y)=>x-y);const out=[];let s=a[0],p=a[0];
    for(let i=1;i<=a.length;i++){if(a[i]===p+1){p=a[i];continue;}out.push(s===p?String(s):s+'–'+p);s=p=a[i];}
    return out.join('、');};
  const body=groups.map(g=>{
    // 讲义的题去讲义答案里找、课后的题去课后答案里找；其余给全部教师版
    let fs=(g.files||[]).filter(f=>f.role==='教师版');
    const want=g.srcs.filter(s=>s==='讲义'||s==='课后');
    if(want.length){const pick=fs.filter(f=>want.some(s=>f.label.indexOf(s)===0));if(pick.length)fs=pick;}
    const pg=g.pgs.length?`<span class="pgs">原书 p${rng([...new Set(g.pgs)])}</span>`:'';
    const fl=g.files===null?'<div class="none">源文件读取失败（本地助手没在跑？）</div>'
      :fs.length?fs.map(f=>`<span class="nm">${esc(f.label)}</span>
          <a href="/api/topic?${g.qs}&label=${encodeURIComponent(f.label)}" target="_blank">📄 预览</a>
          <a href="/api/topic/pdf?${g.qs}&label=${encodeURIComponent(f.label)}">⬇ 下载</a>`).join('<br>')
      :'<div class="none">这个专题没有带答案的源文件</div>';
    return `<div class="kg"><div class="hd"><span class="no">第 ${rng(g.nos)} 题</span>
      <span class="loc">${esc(g.tb)} · ${esc(g.gg)} · ${esc(g.cd)}${g.srcs.length?'（'+esc(g.srcs.join('、'))+'）':''}</span>${pg}</div>
      <div class="fl">${fl}</div></div>`;}).join('');
  const t=$('pTitle').value.trim();
  $('kmBody').innerHTML=`<div class="kw"><h3>${esc(t||'本卷')} · 答案索引</h3>
    <div class="sub">共 ${cart.length} 题 / ${groups.length} 个专题 · ${new Date().toLocaleDateString('zh-CN')}
    　答案在下列源文件里，按题序排好了</div>${body}</div>`;
}
$('bKey').onclick=openKeyIndex;
$('kmClose').onclick=()=>$('keyModal').style.display='none';
$('kmPrint').onclick=()=>{document.body.classList.add('pkey');
  setTimeout(()=>{window.print();document.body.classList.remove('pkey');},80);};

// 试卷篮/排版选项一变就落盘
OPTIDS.forEach(id=>$(id).addEventListener('input',saveCart));
OPTIDS.forEach(id=>$(id).addEventListener('change',saveCart));

renderLib(true);
try{const s=JSON.parse(localStorage.getItem(CK));if(s&&s.ids&&s.ids.length)applySnap(s);}catch(e){}
renderCart();updPn();
</script></body></html>'''
PAGE=(PAGE.replace('__OPT_TB__',OPT_TB).replace('__OPT_T__',OPT_T).replace('__COURSE__',COURSE_JS)
         .replace('__LOADERS__',LOADERS).replace('__NLOAD__',str(1+len(data_files))).replace('__BUILD__',BUILD))
assert '__DATA__' not in PAGE and '__FIG__' not in PAGE and '__LOADERS__' not in PAGE
# vendor（MathJax/docx）2026-09-09 起外链到 zj_assets/vendor/，不再内嵌
out=os.path.join(HERE,'组卷台.html')
open(out,'w',encoding='utf-8').write(PAGE)
print('生成:',out,'|',round(os.path.getsize(out)/1024,1),'KB |',len(data),'题 |',sum(len(v) for v in course.values()),'节 | 资源目录',ASSET_DIR)
