daily-question 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -89,6 +89,7 @@ window.__ModuleLoader__.load({
89
89
  const value = String(method || '')
90
90
  if (!value) return '独立校验'
91
91
  if (value.startsWith('two-step')) return '两步:出题 → 独立视角校验'
92
+ if (value.startsWith('blind-solve')) return '盲解 → 比对 → 分歧主裁'
92
93
  if (value === 'import-parse') return '题库导入解析'
93
94
  if (value === 'online-search') return '官方渠道联网核验'
94
95
  if (value === 'catalog') return '内置官方日期表'
@@ -608,6 +609,7 @@ window.__ModuleLoader__.load({
608
609
  .kd-dot { width:6px; height:6px; border-radius:50%; background:var(--kd-state-success-primary); }
609
610
  .kd-dot-idle { background:var(--kd-state-idle-primary); }
610
611
  .kd-nav-spacer { flex:1 1 auto; }
612
+ .kd-update { display:flex; flex-direction:column; gap:4px; }
611
613
  .kd-main { flex:1 1 auto; min-width:0; overflow:hidden; display:flex; flex-direction:column; }
612
614
  .kd-main-toolbar { flex:none; padding:24px 28px 20px; display:flex; align-items:center; gap:12px; }
613
615
  .kd-main-toolbar > .kd-btn { flex:none; align-self:stretch; aspect-ratio:1; padding:0; min-height:0; white-space:nowrap; border-radius:12px; }
@@ -690,7 +692,7 @@ window.__ModuleLoader__.load({
690
692
  background:color-mix(in srgb, var(--kd-state-success-primary) 8%, transparent); }
691
693
  .kd-option.is-wrong { border-color:var(--kd-state-error-primary);
692
694
  background:color-mix(in srgb, var(--kd-state-error-primary) 8%, transparent); }
693
- .kd-option-key { flex:0 0 auto; width:24px; height:24px; border-radius:8px; display:inline-flex;
695
+ .kd-option-key { flex:0 0 auto; align-self:center; width:24px; height:24px; border-radius:8px; display:inline-flex;
694
696
  align-items:center; justify-content:center; font-size:12px; font-weight:600;
695
697
  border:1px solid var(--kd-border-l2); background:var(--kd-bg-base); }
696
698
  .kd-option.is-selected .kd-option-key { color:var(--kd-brand-primary); border-color:var(--kd-brand-primary); }
@@ -926,10 +928,12 @@ window.__ModuleLoader__.load({
926
928
  try {
927
929
  response = await fetch('/api/daily-question/rpc', {
928
930
  method: 'POST',
931
+ signal: AbortSignal.timeout(op === 'ensureDaily' || op === 'revise' ? 16*60*1000 : op === 'followup' ? 6*60*1000 : 60_000),
929
932
  headers: { 'content-type': 'application/json' },
930
933
  body: JSON.stringify({ op, args: args || {} }),
931
934
  })
932
935
  } catch (error) {
936
+ if (error.name === 'TimeoutError') throw new Error('等待服务响应超时,请重试')
933
937
  throw new Error(`无法连接插件后端(${error.message})。请确认「考研每日一题」插件已启用。`)
934
938
  }
935
939
  let payload = null
@@ -1128,6 +1132,39 @@ window.__ModuleLoader__.load({
1128
1132
  }
1129
1133
 
1130
1134
  /* ============================== 导航 ============================== */
1135
+ function UpdateCheck({ currentVersion }) {
1136
+ const [state,setState]=useState({busy:true,result:null,error:''})
1137
+ const checking=useRef(false)
1138
+ const active=useRef(true)
1139
+ const check=async(force=false)=>{
1140
+ if(checking.current)return
1141
+ checking.current=true
1142
+ setState((value)=>({...value,busy:true,error:''}))
1143
+ try {
1144
+ const result=await rpc('checkUpdates',{force})
1145
+ if(active.current)setState({busy:false,result,error:''})
1146
+ } catch(error) {
1147
+ if(active.current)setState((value)=>({...value,busy:false,error:error.message}))
1148
+ } finally {checking.current=false}
1149
+ }
1150
+ useEffect(()=>{
1151
+ active.current=true
1152
+ const automatic=()=>{if(!window.document.hidden)check(false)}
1153
+ automatic()
1154
+ const timer=window.setInterval(automatic,6*60*60*1000)
1155
+ window.addEventListener('focus',automatic)
1156
+ return ()=>{active.current=false;window.clearInterval(timer);window.removeEventListener('focus',automatic)}
1157
+ },[])
1158
+ return h('div',{className:'kd-update'},
1159
+ h(Btn,{variant:'ghost',disabled:state.busy,onClick:()=>check(true)},
1160
+ state.busy?h('span',{className:'kd-spinner'}):h(Icon,{name:'refresh'}),state.busy?'正在检查…':'检查更新'),
1161
+ h('div',{className:'kd-msg-meta',style:{padding:'0 8px'}},`当前版本 v${currentVersion||state.result?.currentVersion||'—'}`),
1162
+ h('div',{className:'kd-msg-meta',role:'status','aria-live':'polite',title:state.error||'',style:{padding:'4px 8px',overflowWrap:'anywhere'}},
1163
+ state.error?'暂时无法检查,请重试':state.result?.updateAvailable
1164
+ ? h('a',{className:'kd-link',href:state.result.packageUrl,target:'_blank',rel:'noreferrer noopener'},`发现新版本 v${state.result.latestVersion} ↗`)
1165
+ : state.result?'已是最新版本':null))
1166
+ }
1167
+
1131
1168
  function SubjectNav({ boot, subjectId, onPick, onManage, onBanks }) {
1132
1169
  const enabled = (boot.subjects || []).filter((subject) => subject.enabled)
1133
1170
  return h('div', { className: 'kd-nav' },
@@ -1153,7 +1190,8 @@ window.__ModuleLoader__.load({
1153
1190
  h('div', { className: 'kd-nav-group' },
1154
1191
  h('div', { className: 'kd-nav-group-label' }, '设置'),
1155
1192
  h(Btn, { variant: 'ghost', onClick: onManage }, h(Icon, { name: 'plus' }), T.manageSubjects),
1156
- h(Btn, { variant: 'ghost', onClick: onBanks }, h(Icon, { name: 'book' }), T.manageBanks)))
1193
+ h(Btn, { variant: 'ghost', onClick: onBanks }, h(Icon, { name: 'book' }), T.manageBanks),
1194
+ h(UpdateCheck,{currentVersion:boot.pluginVersion})))
1157
1195
  }
1158
1196
 
1159
1197
  function TabBar({ kind, counts, onPick }) {
@@ -1273,6 +1311,7 @@ window.__ModuleLoader__.load({
1273
1311
  function QuestionPane({
1274
1312
  q, busy, choice, onSelect, onReveal, onJudge, onFavorite, onRedo, localRedo,
1275
1313
  onRevise, revising, onToggleThread, threadOpen, onRemoveWrong,
1314
+ onCancel,
1276
1315
  }) {
1277
1316
  if (!q) return null
1278
1317
  const revealed = !localRedo && q.answerState === 'revealed' && q.attemptCount > 0
@@ -1286,9 +1325,9 @@ window.__ModuleLoader__.load({
1286
1325
  h('div', { className: 'kd-chips' },
1287
1326
  h('span', { className: 'kd-tag kd-tag-brand' }, q.typeLabel),
1288
1327
  h('span', { className: 'kd-tag' }, q.subjectName),
1289
- h('span', { className: 'kd-tag' }, `难度 ${q.difficulty}/5`),
1328
+ h('span', { className: 'kd-tag', title: q.examAlignment ? '独立评审参考历年真题给出的难度,不是考生正确率统计' : '原始难度标签,尚无真题对照评审' }, `难度 ${q.difficulty}/5`),
1290
1329
  h('span', { className: 'kd-tag' }, formatDate(q.assignedDate)),
1291
- h('span', { className: 'kd-tag' }, `作答 ${q.attemptCount} 次`),
1330
+ h('span', { className: 'kd-tag' }, `累计作答 ${q.attemptCount} 次`),
1292
1331
  h('span', { className: 'kd-tag' }, q.source?.kind === 'bank' ? q.source.label : '在线出题'),
1293
1332
  q.answerStatus === 'verified'
1294
1333
  ? h(Badge, { tone: 'ok' }, T.verified)
@@ -1300,6 +1339,17 @@ window.__ModuleLoader__.load({
1300
1339
  ? h('div', { className: 'kd-meta', style: { marginTop: '10px' } },
1301
1340
  h('span', null, `${T.knowledge}:${q.knowledge.join('、')}`))
1302
1341
  : null,
1342
+ q.examAlignment ? h('details', { className: 'kd-verify' },
1343
+ h('summary', null, `范围与难度依据 · 大纲 ${q.examAlignment.outlineYear} · 独立真题对照`),
1344
+ h('div', null, q.examAlignment.note),
1345
+ h('div', null, `参考考试:${q.examAlignment.basisSubject?.name || q.subjectName} · 检索时间:${formatDateTime(q.examAlignment.checkedAt)}`),
1346
+ ...(q.examAlignment.sources || []).map((source) => h('div', { key: source.id },
1347
+ h('a', { className: 'kd-link', href: source.url, target: '_blank', rel: 'noreferrer noopener' },
1348
+ `${source.year} ${source.title}(${source.official ? '官方来源' : source.kind === 'outline' ? source.verificationLabel || '非官方大纲转载' : '非官方真题转载'})`),
1349
+ source.coverage ? h('div', null, source.coverage) : null,
1350
+ source.publisherUrl ? h('a', { className:'kd-link', href:source.publisherUrl, target:'_blank', rel:'noreferrer noopener' }, `出版社版本记录 · ISBN ${source.isbn}`) : null)),
1351
+ revealed ? h('div', null, q.examAlignment.rationale,
1352
+ ...(q.examAlignment.warnings || []).map((warning,i)=>h('div',{key:i,className:'kd-meta'},warning))) : null) : null,
1303
1353
  h('div', { className: 'kd-stem' }, h(Markdown, { text: q.stem })),
1304
1354
  isChoice && q.options?.length
1305
1355
  ? h('div', { className: 'kd-options' }, q.options.map((option) => {
@@ -1345,6 +1395,7 @@ window.__ModuleLoader__.load({
1345
1395
  : null,
1346
1396
  revealed ? h(Btn, { variant: 'ghost', onClick: onToggleThread }, h(Icon, { name: 'book' }), threadOpen ? '收起追问' : T.followup) : null),
1347
1397
  revealed ? h(AnswerBlock, { question: q, onRevise, revising, onRemoveWrong }) : null,
1398
+ revising && onCancel ? h(Btn,{variant:'ghost',onClick:onCancel},'取消修订') : null,
1348
1399
  revealed && last?.result === 'pending'
1349
1400
  ? h('div', { className: 'kd-actions', style: { marginTop: '12px' } },
1350
1401
  h('span', { className: 'kd-meta' }, '这道题由你自己判定(系统不会替你把“作答完毕”当成答对):'),
@@ -1416,7 +1467,7 @@ window.__ModuleLoader__.load({
1416
1467
  return h('div', { ref, className: 'kd-list-summary' + (truncated ? ' is-truncated' : '') }, h(Markdown, { text }))
1417
1468
  }
1418
1469
 
1419
- function ListPane({ list, kind, subjectName, onOpen, managing, selected, onManage, onToggle, onSelectAll, onBulk, busy }) {
1470
+ function ListPane({ list, kind, subjectName, onOpen, managing, selected, onManage, onToggle, onSelectAll, onBulk, onPage, busy }) {
1420
1471
  if (!list) return h('div', { className: 'kd-loading' }, h('span', { className: 'kd-spinner' }), '加载中…')
1421
1472
  if (!list.items.length) {
1422
1473
  const icon = kind === 'favorite' ? 'star' : kind === 'wrong' ? 'check' : kind === 'all' ? 'book' : 'spark'
@@ -1428,8 +1479,12 @@ window.__ModuleLoader__.load({
1428
1479
  }
1429
1480
  return h('div', null,
1430
1481
  h('div', { className: 'kd-meta', style: { marginBottom: '10px' } },
1431
- h('span', null, `${subjectName} · ${T.tabs[kind]} · 共 ${list.items.length} 道`),
1482
+ h('span', null, `${subjectName} · ${T.tabs[kind]} · 共 ${list.total ?? list.items.length} 道`),
1432
1483
  h(Btn, { small: true, onClick: onManage, disabled: busy }, managing ? '完成管理' : '批量管理')),
1484
+ list.totalPages>1?h('div',{className:'kd-row',style:{marginBottom:'12px'},'aria-label':'题目分页'},
1485
+ h(Btn,{small:true,disabled:busy||list.page<=1,onClick:()=>onPage(list.page-1)},'上一页'),
1486
+ h('span',{className:'kd-meta'},`第 ${list.page} / ${list.totalPages} 页`),
1487
+ h(Btn,{small:true,disabled:busy||list.page>=list.totalPages,onClick:()=>onPage(list.page+1)},'下一页')):null,
1433
1488
  managing ? h('div', { className: 'kd-bulkbar', 'aria-label': '批量管理' },
1434
1489
  h('div', { className: 'kd-row' },
1435
1490
  h('strong', { 'aria-live': 'polite' }, `已选 ${selected.length} 道`),
@@ -1464,13 +1519,14 @@ window.__ModuleLoader__.load({
1464
1519
  }
1465
1520
 
1466
1521
  /* ============================== 追问侧栏 ============================== */
1467
- function ThreadPane({ thread, questionId, busy, onSend, onClear, onClose, narrow, ratio = 33, onRatioChange }) {
1522
+ function ThreadPane({ thread, questionId, busy, onSend, onClear, onClose, onCancel, narrow, ratio = 33, onRatioChange }) {
1468
1523
  const [draft, setDraft] = useState('')
1469
1524
  const [sending, setSending] = useState(false)
1470
1525
  const [localError, setLocalError] = useState('')
1471
1526
  const [pending, setPending] = useState(null)
1472
1527
  const scrollRef = useRef(null)
1473
1528
  const sendingRef = useRef(false)
1529
+ const sendingMessage = useRef(null)
1474
1530
  const messages = thread?.messages || []
1475
1531
 
1476
1532
  useEffect(() => {
@@ -1482,6 +1538,7 @@ window.__ModuleLoader__.load({
1482
1538
  const value = String(text || '').trim()
1483
1539
  if (!value || sendingRef.current) return
1484
1540
  sendingRef.current = true
1541
+ sendingMessage.current = messageId
1485
1542
  setSending(true)
1486
1543
  setLocalError('')
1487
1544
  try {
@@ -1493,6 +1550,7 @@ window.__ModuleLoader__.load({
1493
1550
  setPending({ id: messageId, text: value })
1494
1551
  } finally {
1495
1552
  sendingRef.current = false
1553
+ sendingMessage.current = null
1496
1554
  setSending(false)
1497
1555
  }
1498
1556
  }, [onSend])
@@ -1581,6 +1639,7 @@ window.__ModuleLoader__.load({
1581
1639
  },
1582
1640
  }),
1583
1641
  h('div', { className: 'kd-row', style: { justifyContent: 'flex-end', marginTop: '2px' } },
1642
+ sending && onCancel ? h(Btn,{small:true,variant:'ghost',onClick:()=>onCancel(sendingMessage.current)},'取消回复') : null,
1584
1643
  h(Btn, {
1585
1644
  variant: 'primary',
1586
1645
  small: true,
@@ -1599,9 +1658,27 @@ window.__ModuleLoader__.load({
1599
1658
  const [types, setTypes] = useState([])
1600
1659
  const [customTypes, setCustomTypes] = useState([])
1601
1660
  const [typeDraft, setTypeDraft] = useState('')
1661
+ const [referenceSubjectId, setReferenceSubjectId] = useState('auto')
1662
+ const [referenceExamName, setReferenceExamName] = useState('')
1663
+ const [referenceExamCode, setReferenceExamCode] = useState('')
1664
+ const [subjectCode, setSubjectCode] = useState('')
1665
+ const referenceExams = boot.referenceExams || (boot.subjects || []).filter((s) => !s.custom)
1666
+ const referenceEditor = (value, change, label) => h('div', { className:'kd-field', style:{ marginBottom:0 } },
1667
+ h('select', { className:'kd-select', 'aria-label':label, value:value.referenceSubjectId || 'auto',
1668
+ onChange:(event)=>change({referenceSubjectId:event.target.value}) },
1669
+ h('option', {value:'auto'}, '自动检索与核验(按科目名称和代码)'),
1670
+ ...referenceExams.map((exam)=>h('option',{key:exam.id,value:exam.id},`${exam.name}${exam.code ? `(${exam.code})` : ''}`)),
1671
+ h('option',{value:'independent'},'另行指定参考考试名称…')),
1672
+ !value.referenceSubjectId || value.referenceSubjectId === 'auto' ? h('input',{className:'kd-input','aria-label':`${label}代码(考试科目或专业)`,
1673
+ placeholder:'考试科目或专业代码(选填)',value:value.code || '',maxLength:20,onChange:(event)=>change({code:event.target.value})}) : null,
1674
+ value.referenceSubjectId === 'independent' ? h('div',{className:'kd-row'},
1675
+ h('input',{className:'kd-input','aria-label':`${label}名称`,placeholder:'考试名称,院校自命题请包含院校',value:value.referenceExamName || '',maxLength:100,
1676
+ onChange:(event)=>change({referenceExamName:event.target.value})}),
1677
+ h('input',{className:'kd-input','aria-label':`${label}代码`,placeholder:'科目代码(选填)',value:value.referenceExamCode || '',maxLength:20,style:{width:'150px'},
1678
+ onChange:(event)=>change({referenceExamCode:event.target.value})})) : null)
1602
1679
  const enabled = new Set(draft.subjects)
1603
1680
  const subjects = [...(boot.subjects || []), ...draft.customSubjects.filter((item) => !(boot.subjects || []).some((s) => s.id === item.id)).map((item) => ({
1604
- ...item, typeLabels: item.types.map((type) => ({ single: '单项选择题', multiple: '多项选择题', fill: '填空题', solution: '解答题' })[type] || type), bankItemCount: 0,
1681
+ ...item, custom: true, typeLabels: item.types.map((type) => ({ single: '单项选择题', multiple: '多项选择题', fill: '填空题', solution: '解答题' })[type] || type), bankItemCount: 0,
1605
1682
  }))]
1606
1683
  const close = () => { if (!saving) onClose() }
1607
1684
  const toggleSubject = (id) => setDraft((current) => ({ ...current,
@@ -1621,18 +1698,22 @@ window.__ModuleLoader__.load({
1621
1698
  setTypes((current) => current.filter((item) => item !== value))
1622
1699
  }
1623
1700
  const selectedTypes = [...new Set([...types, ...(typeDraft.trim() ? [typeDraft.trim().slice(0, 24)] : [])])]
1624
- const canAdd = Boolean(name.trim()) && selectedTypes.length > 0
1701
+ const canAdd = Boolean(name.trim()) && selectedTypes.length > 0 && (referenceSubjectId !== 'independent' || Boolean(referenceExamName.trim()))
1625
1702
  const withCustomSubject = (current) => {
1626
- const subject = { id: 'custom_' + newAttemptId(), name: name.trim(), types: selectedTypes }
1703
+ const subject = { id: 'custom_' + newAttemptId(), name: name.trim(), code:subjectCode.trim(), types: selectedTypes, referenceSubjectId, referenceExamName:referenceExamName.trim(), referenceExamCode:referenceExamCode.trim() }
1627
1704
  return { ...current, subjects: [...current.subjects, subject.id], customSubjects: [...current.customSubjects, subject] }
1628
1705
  }
1629
1706
  const finish = async () => {
1630
1707
  if (saving) return
1631
- if ((name.trim() || types.length || typeDraft.trim()) && !canAdd) {
1632
- setSaveError('请填写自定义科目名称并至少选择一个题型,或清空未完成的输入。')
1708
+ if ((name.trim() || types.length || typeDraft.trim() || (referenceSubjectId && referenceSubjectId !== 'auto') || referenceExamName.trim() || referenceExamCode.trim() || subjectCode.trim()) && !canAdd) {
1709
+ setSaveError('请填写自定义科目名称并至少选择一个题型;选择独立参考考试时,还需填写考试名称。或清空未完成的输入。')
1633
1710
  return
1634
1711
  }
1635
1712
  const next = canAdd ? withCustomSubject(draft) : draft
1713
+ if (next.customSubjects.some((s)=>s.referenceSubjectId === 'independent' && !s.referenceExamName?.trim())) {
1714
+ setSaveError('独立参考考试请填写准确的考试名称(院校自命题请包含院校名称)。')
1715
+ return
1716
+ }
1636
1717
  setSaving(true)
1637
1718
  setSaveError('')
1638
1719
  try { await onDone(next) }
@@ -1646,6 +1727,10 @@ window.__ModuleLoader__.load({
1646
1727
  setTypes([])
1647
1728
  setCustomTypes([])
1648
1729
  setTypeDraft('')
1730
+ setReferenceSubjectId('auto')
1731
+ setReferenceExamName('')
1732
+ setReferenceExamCode('')
1733
+ setSubjectCode('')
1649
1734
  }
1650
1735
  return h(Modal, {
1651
1736
  title: T.manageSubjects,
@@ -1676,6 +1761,8 @@ window.__ModuleLoader__.load({
1676
1761
  h('div', { className: 'kd-msg-meta' }, subject.typeLabels.join(' / ')))),
1677
1762
  enabled.has(subject.id)
1678
1763
  ? h('div', { className: 'kd-row' },
1764
+ subject.custom ? referenceEditor(draft.customSubjects.find((s)=>s.id === subject.id) || subject,
1765
+ (patch)=>setDraft((current)=>({...current,customSubjects:current.customSubjects.map((s)=>s.id === subject.id ? {...s,...patch} : s)})),`${subject.name} 参考考试科目`) : null,
1679
1766
  h('select', {
1680
1767
  className: 'kd-select',
1681
1768
  value: draft.subjectSource[subject.id] || 'auto',
@@ -1692,6 +1779,13 @@ window.__ModuleLoader__.load({
1692
1779
  h('div', { className: 'kd-divider' }),
1693
1780
  h('div', { className: 'kd-field kd-custom-subject' },
1694
1781
  h('div', { className: 'kd-field-label' }, '添加自定义科目'),
1782
+ referenceEditor({referenceSubjectId,referenceExamName,referenceExamCode,code:subjectCode},(patch)=>{
1783
+ if (patch.referenceSubjectId !== undefined) setReferenceSubjectId(patch.referenceSubjectId)
1784
+ if (patch.referenceExamName !== undefined) setReferenceExamName(patch.referenceExamName)
1785
+ if (patch.referenceExamCode !== undefined) setReferenceExamCode(patch.referenceExamCode)
1786
+ if (patch.code !== undefined) setSubjectCode(patch.code)
1787
+ },'自定义科目的参考考试'),
1788
+ h('div',{className:'kd-meta'},'默认由模型按名称和代码自主检索、独立核验大纲及两年真题。专业代码不等于考试科目代码;信息不足时可在「另行指定参考考试名称」补充院校和准确考试名称,资料不足不会发布。'),
1695
1789
  h('div', { className: 'kd-row' },
1696
1790
  h('input', {
1697
1791
  className: 'kd-input',
@@ -1856,6 +1950,35 @@ window.__ModuleLoader__.load({
1856
1950
  }
1857
1951
 
1858
1952
  /* ============================== 主页面 ============================== */
1953
+ function watchDayChange(snapshot, onSnapshot) {
1954
+ let timer, stopped = false, checking = false
1955
+ const schedule = (retry = false) => {
1956
+ const delay = Date.parse(snapshot.nextDayAt) - Date.parse(snapshot.now)
1957
+ timer = window.setTimeout(check, retry || !Number.isFinite(delay) ? 60_000 : Math.max(1000, delay))
1958
+ }
1959
+ const check = async () => {
1960
+ if (stopped || checking) return
1961
+ checking = true
1962
+ window.clearTimeout(timer)
1963
+ let retry = false
1964
+ try {
1965
+ const fresh = await rpc('bootstrap')
1966
+ if (!stopped) { snapshot = fresh; onSnapshot(fresh) }
1967
+ } catch { retry = true }
1968
+ finally { checking = false; if (!stopped) schedule(retry) }
1969
+ }
1970
+ const onVisible = () => { if (window.document.visibilityState === 'visible') check() }
1971
+ schedule()
1972
+ window.addEventListener('focus', check)
1973
+ window.document.addEventListener('visibilitychange', onVisible)
1974
+ return () => {
1975
+ stopped = true
1976
+ window.clearTimeout(timer)
1977
+ window.removeEventListener('focus', check)
1978
+ window.document.removeEventListener('visibilitychange', onVisible)
1979
+ }
1980
+ }
1981
+
1859
1982
  function Page() {
1860
1983
  const [boot, setBoot] = useState(null)
1861
1984
  const [loading, setLoading] = useState(true)
@@ -1883,6 +2006,13 @@ window.__ModuleLoader__.load({
1883
2006
  const mainScroll = useRef(null)
1884
2007
  const listScroll = useRef(0)
1885
2008
  const redoQuestions = useRef(new Set())
2009
+ const dailyRequest = useRef(0)
2010
+ const lastDay = useRef(null)
2011
+ const currentView = useRef(null)
2012
+ const bootRequest = useRef(0)
2013
+ const threadRequest = useRef(0)
2014
+ const selectionRequest = useRef(0)
2015
+ currentView.current = { subjectId, kind, view, page:list?.page||1, loading:busy==='list' }
1886
2016
 
1887
2017
  useEffect(() => {
1888
2018
  if (view === 'list' && list && mainScroll.current) mainScroll.current.scrollTop = listScroll.current
@@ -1898,9 +2028,10 @@ window.__ModuleLoader__.load({
1898
2028
  useEffect(() => () => { if (toastTimer.current) clearTimeout(toastTimer.current) }, [])
1899
2029
 
1900
2030
  const refreshBoot = useCallback(async () => {
2031
+ const request = ++bootRequest.current
1901
2032
  try {
1902
2033
  const result = await rpc('bootstrap')
1903
- setBoot(result)
2034
+ if (request === bootRequest.current) setBoot(result)
1904
2035
  return result
1905
2036
  } catch {
1906
2037
  return null
@@ -1909,6 +2040,7 @@ window.__ModuleLoader__.load({
1909
2040
 
1910
2041
  const loadToday = useCallback(async (sid) => {
1911
2042
  if (!sid) return
2043
+ const request = ++dailyRequest.current
1912
2044
  // 高亮必须跟着视图走:从「全部 / 收藏 / 错题本」切回今日题目时要一起复位
1913
2045
  setKind('today')
1914
2046
  setThreadOpen(false)
@@ -1922,31 +2054,67 @@ window.__ModuleLoader__.load({
1922
2054
  attemptRef.current = newAttemptId()
1923
2055
  try {
1924
2056
  const result = await rpc('ensureDaily', { subjectId: sid })
1925
- setQuestion(result.question)
1926
- setLocalRedo(redoQuestions.current.has(result.question.id))
1927
- setThread(result.thread || null)
2057
+ if (request === dailyRequest.current) {
2058
+ setQuestion(result.question)
2059
+ setLocalRedo(redoQuestions.current.has(result.question.id))
2060
+ setThread(result.thread || null)
2061
+ }
2062
+ await refreshBoot()
1928
2063
  } catch (issue) {
1929
- setFail(issue.message)
2064
+ if (request === dailyRequest.current) setFail(issue.message)
1930
2065
  } finally {
1931
- setBusy('')
2066
+ if (request === dailyRequest.current) setBusy('')
1932
2067
  }
1933
- }, [])
2068
+ }, [refreshBoot])
2069
+
2070
+ useEffect(() => {
2071
+ if (!boot?.today) return undefined
2072
+ return watchDayChange(boot, setBoot)
2073
+ }, [boot?.today])
2074
+
2075
+ useEffect(() => {
2076
+ if (!boot?.today || lastDay.current === boot.today) return undefined
2077
+ const previous = lastDay.current
2078
+ lastDay.current = boot.today
2079
+ if (!previous) return undefined
2080
+ redoQuestions.current.clear()
2081
+ const { subjectId: sid, kind: activeKind } = currentView.current
2082
+ if (!sid) return undefined
2083
+ if (activeKind === 'today') { loadToday(sid); return undefined }
2084
+ let cancelled = false
2085
+ const request=dailyRequest.current
2086
+ rpc('ensureDaily', { subjectId: sid }).then(async () => {
2087
+ if (cancelled) return
2088
+ await refreshBoot()
2089
+ const current = currentView.current
2090
+ if (cancelled || request!==dailyRequest.current || current.loading || current.subjectId !== sid || current.kind === 'today' || current.view !== 'list') return
2091
+ const result = await rpc('listQuestions', { subjectId: sid, kind: current.kind, page:current.page })
2092
+ const latest = currentView.current
2093
+ if (!cancelled && request===dailyRequest.current && !latest.loading && latest.page===current.page && latest.subjectId === sid && latest.kind === current.kind && latest.view === 'list') {
2094
+ listScroll.current = mainScroll.current?.scrollTop || 0
2095
+ setList(result)
2096
+ }
2097
+ }).catch((issue) => { if (!cancelled) notify(`今日题目更新失败:${issue.message}`, 'warn') })
2098
+ return () => { cancelled = true }
2099
+ }, [boot?.today, loadToday, refreshBoot, notify])
1934
2100
 
1935
- const loadList = useCallback(async (sid, nextKind) => {
2101
+ const loadList = useCallback(async (sid, nextKind, page = 1) => {
1936
2102
  if (!sid) return
2103
+ const request = ++dailyRequest.current
1937
2104
  listScroll.current = 0
1938
2105
  setKind(nextKind)
1939
2106
  setThreadOpen(false)
1940
2107
  setView('list')
2108
+ setFail('')
1941
2109
  setBusy('list')
1942
2110
  setList(null)
1943
2111
  try {
1944
- const result = await rpc('listQuestions', { subjectId: sid, kind: nextKind })
1945
- setList(result)
2112
+ const result = await rpc('listQuestions', { subjectId: sid, kind: nextKind, page })
2113
+ if (request === dailyRequest.current) setList(result)
1946
2114
  } catch (issue) {
1947
- notify(issue.message, 'bad')
2115
+ if (request === dailyRequest.current) notify(issue.message, 'bad')
1948
2116
  } finally {
1949
- setBusy('')
2117
+ if (request === dailyRequest.current) setBusy('')
1950
2118
  }
1951
2119
  }, [notify])
1952
2120
 
@@ -1961,10 +2129,12 @@ window.__ModuleLoader__.load({
1961
2129
  }, [subjectId, loadToday])
1962
2130
 
1963
2131
  const openQuestion = useCallback(async (qid) => {
2132
+ const request = ++dailyRequest.current
1964
2133
  listScroll.current = mainScroll.current?.scrollTop || 0
1965
2134
  setBusy('question')
1966
2135
  try {
1967
2136
  const result = await rpc('getQuestion', { qid })
2137
+ if (request !== dailyRequest.current) return
1968
2138
  const canResume = result.isToday && result.question.answerState === 'revealed'
1969
2139
  && result.question.attemptCount > 0 && !redoQuestions.current.has(qid)
1970
2140
  if (!canResume) redoQuestions.current.add(qid)
@@ -1976,19 +2146,20 @@ window.__ModuleLoader__.load({
1976
2146
  setThreadOpen(false)
1977
2147
  attemptRef.current = newAttemptId()
1978
2148
  } catch (issue) {
1979
- notify(issue.message, 'bad')
2149
+ if (request === dailyRequest.current) notify(issue.message, 'bad')
1980
2150
  } finally {
1981
- setBusy('')
2151
+ if (request === dailyRequest.current) setBusy('')
1982
2152
  }
1983
2153
  }, [notify])
1984
2154
 
1985
2155
  const backToList = async () => {
2156
+ const request = ++dailyRequest.current
1986
2157
  setThreadOpen(false)
1987
2158
  setView('list')
1988
2159
  try {
1989
- const result = await rpc('listQuestions', { subjectId, kind })
1990
- setList(result)
1991
- } catch (issue) { notify(issue.message, 'bad') }
2160
+ const result = await rpc('listQuestions', { subjectId, kind, page:list?.page||1 })
2161
+ if (request === dailyRequest.current) setList(result)
2162
+ } catch (issue) { if (request === dailyRequest.current) notify(issue.message, 'bad') }
1992
2163
  }
1993
2164
 
1994
2165
  useEffect(() => {
@@ -2017,6 +2188,7 @@ window.__ModuleLoader__.load({
2017
2188
 
2018
2189
  const reveal = useCallback(async (mode) => {
2019
2190
  if (!question) return
2191
+ const request = dailyRequest.current
2020
2192
  setBusy('reveal')
2021
2193
  try {
2022
2194
  const result = await rpc('reveal', {
@@ -2025,49 +2197,58 @@ window.__ModuleLoader__.load({
2025
2197
  choice: mode === 'submit' ? choice : '',
2026
2198
  attemptId: attemptRef.current,
2027
2199
  })
2028
- setQuestion(result.question)
2029
- redoQuestions.current.delete(question.id)
2030
- setLocalRedo(false)
2200
+ if (request === dailyRequest.current) {
2201
+ setQuestion(result.question)
2202
+ redoQuestions.current.delete(question.id)
2203
+ setLocalRedo(false)
2204
+ }
2031
2205
  refreshBoot()
2032
2206
  } catch (issue) {
2033
2207
  notify(issue.message, 'bad')
2034
2208
  } finally {
2035
- setBusy('')
2209
+ if (request === dailyRequest.current) setBusy('')
2036
2210
  }
2037
2211
  }, [question, choice, notify, refreshBoot])
2038
2212
 
2039
2213
  const judge = useCallback(async (verdict) => {
2040
2214
  if (!question) return
2215
+ if (!question.lastAttempt?.id) { notify('缺少本次作答记录,请重新打开题目后判定','bad'); return }
2216
+ const request = dailyRequest.current
2041
2217
  setBusy('judge')
2042
2218
  try {
2043
- const result = await rpc('judge', { qid: question.id, verdict })
2044
- setQuestion(result.question)
2219
+ const result = await rpc('judge', { qid: question.id, verdict, attemptId:question.lastAttempt.id })
2220
+ if (request === dailyRequest.current) setQuestion(result.question)
2045
2221
  refreshBoot()
2046
2222
  } catch (issue) {
2047
- notify(issue.message, 'bad')
2223
+ if (request === dailyRequest.current) notify(issue.message, 'bad')
2048
2224
  } finally {
2049
- setBusy('')
2225
+ if (request === dailyRequest.current) setBusy('')
2050
2226
  }
2051
2227
  }, [question, notify, refreshBoot])
2052
2228
 
2053
2229
  const favorite = useCallback(async () => {
2054
2230
  if (!question) return
2231
+ const request = dailyRequest.current
2055
2232
  setBusy('favorite')
2056
2233
  try {
2057
2234
  const result = await rpc('favorite', { qid: question.id, value: !question.favorite })
2058
- setQuestion(result.question)
2059
- notify(question.favorite ? '已取消收藏' : '已加入收藏')
2235
+ if (request === dailyRequest.current) {
2236
+ setQuestion(result.question)
2237
+ notify(question.favorite ? '已取消收藏' : '已加入收藏')
2238
+ }
2060
2239
  refreshBoot()
2061
2240
  } catch (issue) {
2062
- notify(issue.message, 'bad')
2241
+ if (request === dailyRequest.current) notify(issue.message, 'bad')
2063
2242
  } finally {
2064
- setBusy('')
2243
+ if (request === dailyRequest.current) setBusy('')
2065
2244
  }
2066
2245
  }, [question, notify, refreshBoot])
2067
2246
 
2068
2247
  const sendFollowup = useCallback(async (text, messageId) => { if (!question) throw new Error('没有打开任何题目')
2248
+ const request = dailyRequest.current
2249
+ const threadVersion = ++threadRequest.current
2069
2250
  const result = await rpc('followup', { qid: question.id, message: text, clientMessageId: messageId })
2070
- setThread(result.thread)
2251
+ if (request === dailyRequest.current && threadVersion === threadRequest.current) setThread(result.thread)
2071
2252
  }, [question])
2072
2253
 
2073
2254
  const clearThread = useCallback(async () => {
@@ -2079,11 +2260,15 @@ window.__ModuleLoader__.load({
2079
2260
  confirmLabel: '清空',
2080
2261
  danger: true,
2081
2262
  onConfirm: async () => {
2263
+ const request = dailyRequest.current
2264
+ const threadVersion = ++threadRequest.current
2082
2265
  setModal(null)
2083
2266
  try {
2084
2267
  const result = await rpc('clearThread', { qid: question.id })
2085
- setThread(result.thread)
2086
- notify('本题追问已清空')
2268
+ if (request === dailyRequest.current && threadVersion === threadRequest.current) {
2269
+ setThread(result.thread)
2270
+ notify('本题追问已清空')
2271
+ }
2087
2272
  } catch (issue) {
2088
2273
  notify(issue.message, 'bad')
2089
2274
  }
@@ -2091,47 +2276,58 @@ window.__ModuleLoader__.load({
2091
2276
  })
2092
2277
  }, [question, notify])
2093
2278
 
2279
+ const cancelWork = async (kind, qid, clientMessageId) => {
2280
+ try { await rpc('cancelTask',{kind,subjectId,qid,clientMessageId}) }
2281
+ catch (issue) { notify(issue.message,'bad') }
2282
+ }
2283
+
2094
2284
  const revise = useCallback(async () => {
2095
2285
  if (!question) return
2286
+ const request = dailyRequest.current
2096
2287
  setBusy('revise')
2097
2288
  try {
2098
2289
  const result = await rpc('revise', { qid: question.id })
2290
+ if (request !== dailyRequest.current) return
2099
2291
  setQuestion(result.question)
2100
2292
  setModal({ kind: 'revision' })
2101
2293
  } catch (issue) {
2102
- notify(issue.message, 'bad')
2294
+ if (request === dailyRequest.current) notify(issue.message, 'bad')
2103
2295
  } finally {
2104
- setBusy('')
2296
+ if (request === dailyRequest.current) setBusy('')
2105
2297
  }
2106
2298
  }, [question, notify])
2107
2299
 
2108
2300
  const confirmRevision = useCallback(async () => {
2109
2301
  if (!question?.pendingRevision) return
2302
+ const request = dailyRequest.current
2110
2303
  setBusy('revise')
2111
2304
  try {
2112
2305
  const result = await rpc('confirmRevision', {
2113
2306
  qid: question.id,
2114
2307
  candidateId: question.pendingRevision.candidateId,
2115
2308
  })
2309
+ if (request !== dailyRequest.current) return
2116
2310
  setQuestion(result.question)
2117
2311
  setModal(null)
2118
2312
  notify('已替换为通过校验的新版本,旧版本已保留')
2119
2313
  } catch (issue) {
2120
- notify(issue.message, 'bad')
2314
+ if (request === dailyRequest.current) notify(issue.message, 'bad')
2121
2315
  } finally {
2122
- setBusy('')
2316
+ if (request === dailyRequest.current) setBusy('')
2123
2317
  }
2124
2318
  }, [question, notify])
2125
2319
 
2126
2320
  const discardRevision = useCallback(async () => {
2127
2321
  if (!question) return
2322
+ const request = dailyRequest.current
2128
2323
  setModal(null)
2129
2324
  try {
2130
2325
  const result = await rpc('discardRevision', { qid: question.id })
2326
+ if (request !== dailyRequest.current) return
2131
2327
  setQuestion(result.question)
2132
2328
  notify('已放弃候选版本,正式答案保持不变')
2133
2329
  } catch (issue) {
2134
- notify(issue.message, 'bad')
2330
+ if (request === dailyRequest.current) notify(issue.message, 'bad')
2135
2331
  }
2136
2332
  }, [question, notify])
2137
2333
 
@@ -2245,13 +2441,15 @@ window.__ModuleLoader__.load({
2245
2441
 
2246
2442
  const bulkQuestions = async (action) => {
2247
2443
  if (bulkLock.current || !selected.length) return
2444
+ const request = dailyRequest.current
2248
2445
  const qids = [...selected]
2249
2446
  const execute = async (confirmProtected = false) => {
2250
- if (bulkLock.current) return
2447
+ if (bulkLock.current || request !== dailyRequest.current) return
2251
2448
  bulkLock.current = true
2252
2449
  setBusy('bulk')
2253
2450
  try {
2254
2451
  const result = await rpc('bulkQuestions', { qids, action, confirmProtected })
2452
+ if (request !== dailyRequest.current) { refreshBoot(); return }
2255
2453
  if (result.requiresConfirmation) {
2256
2454
  setModal({ kind: 'confirm', title: '再次确认删除收藏或错题', danger: true,
2257
2455
  message: `选中的题目中有 ${result.protectedCount} 道已收藏或在错题本中。继续将永久删除所选 ${qids.length} 道题及其作答和追问,无法恢复。确定仍要删除吗?`,
@@ -2260,13 +2458,14 @@ window.__ModuleLoader__.load({
2260
2458
  }
2261
2459
  setModal(null)
2262
2460
  setSelected([])
2263
- await loadList(subjectId, kind)
2461
+ const loading = loadList(subjectId, kind, list?.page||1)
2462
+ const listRequest = dailyRequest.current
2463
+ await loading
2264
2464
  await refreshBoot()
2265
- notify(`已处理 ${result.affected} 道题目`)
2465
+ if (listRequest === dailyRequest.current) notify(`已处理 ${result.affected} 道题目`)
2266
2466
  } catch (issue) {
2267
- setModal(null)
2268
- notify(issue.message, 'bad')
2269
- } finally { bulkLock.current = false; setBusy('') }
2467
+ if (request === dailyRequest.current) { setModal(null); notify(issue.message, 'bad') }
2468
+ } finally { bulkLock.current = false; if (request === dailyRequest.current) setBusy('') }
2270
2469
  }
2271
2470
  if (action === 'delete') {
2272
2471
  setModal({ kind: 'confirm', title: '删除所选题目', danger: true,
@@ -2276,6 +2475,18 @@ window.__ModuleLoader__.load({
2276
2475
  }
2277
2476
 
2278
2477
  const counts = subjectId ? boot?.counts?.[subjectId] : null
2478
+ const selectAllQuestions = async (value) => {
2479
+ if(bulkLock.current)return
2480
+ const request=dailyRequest.current,selection=++selectionRequest.current
2481
+ const isCurrent=()=>request===dailyRequest.current&&selection===selectionRequest.current
2482
+ if(!value){setSelected([]);setBusy(current=>current==='selection'?'':current);return}
2483
+ setBusy('selection')
2484
+ try {
2485
+ const result=await rpc('listQuestions',{subjectId,kind,idsOnly:true})
2486
+ if(isCurrent())setSelected(result.ids)
2487
+ } catch(issue){if(isCurrent())notify(issue.message,'bad')}
2488
+ finally{if(isCurrent())setBusy(current=>current==='selection'?'':current)}
2489
+ }
2279
2490
  const narrow = useMediaQuery('(max-width: 1180px)')
2280
2491
 
2281
2492
  const mainContent = (() => {
@@ -2308,7 +2519,8 @@ window.__ModuleLoader__.load({
2308
2519
  h('span', { className: 'kd-spinner' }),
2309
2520
  T.generating,
2310
2521
  h('div', { className: 'kd-msg-meta', style: { marginTop: '8px' } },
2311
- '题目会先生成,再由另一个独立视角重新求解并校验;校验不通过就不会发布。首次出题可能需要几十秒。'))
2522
+ '先出题,再仅凭题干和选项盲解并比对答案;分歧交由主裁,仍无法确定就重新出题或阻止发布。首次出题可能需要较长时间。'),
2523
+ h(Btn,{variant:'ghost',onClick:()=>cancelWork('daily')},'取消生成'))
2312
2524
  }
2313
2525
  if (fail) {
2314
2526
  return h(EmptyState, {
@@ -2339,10 +2551,11 @@ window.__ModuleLoader__.load({
2339
2551
  kind,
2340
2552
  subjectName: currentSubject?.name || '',
2341
2553
  onOpen: openQuestion,
2342
- managing, selected, busy: busy === 'bulk',
2554
+ managing, selected, busy: busy === 'bulk' || busy === 'selection',
2343
2555
  onManage: () => { setManaging(!managing); setSelected([]) },
2344
2556
  onToggle: (id) => setSelected((ids) => ids.includes(id) ? ids.filter((value) => value !== id) : [...ids, id]),
2345
- onSelectAll: (value) => setSelected(value ? list.items.map((item) => item.id) : []),
2557
+ onSelectAll: selectAllQuestions,
2558
+ onPage: (page) => loadList(subjectId,kind,page),
2346
2559
  onBulk: bulkQuestions,
2347
2560
  })
2348
2561
  }
@@ -2366,6 +2579,8 @@ window.__ModuleLoader__.load({
2366
2579
  onJudge: judge,
2367
2580
  onFavorite: favorite,
2368
2581
  onRedo: () => {
2582
+ dailyRequest.current++
2583
+ setBusy('')
2369
2584
  redoQuestions.current.add(question.id)
2370
2585
  setLocalRedo(true)
2371
2586
  setThreadOpen(false)
@@ -2373,17 +2588,21 @@ window.__ModuleLoader__.load({
2373
2588
  attemptRef.current = newAttemptId()
2374
2589
  },
2375
2590
  onRevise: revise,
2591
+ onCancel: () => cancelWork('revise',question.id),
2376
2592
  revising: busy === 'revise',
2377
2593
  onToggleThread: () => setThreadOpen((value) => !value),
2378
2594
  onRemoveWrong: async () => {
2379
2595
  if (!question) return
2596
+ const request = dailyRequest.current
2380
2597
  try {
2381
2598
  const result = await rpc('removeFromWrongBook', { qid: question.id })
2382
- setQuestion(result.question)
2383
- notify('已移出错题本,历史作答记录仍然保留')
2599
+ if (request === dailyRequest.current) {
2600
+ setQuestion(result.question)
2601
+ notify('已移出错题本,历史作答记录仍然保留')
2602
+ }
2384
2603
  refreshBoot()
2385
2604
  } catch (issue) {
2386
- notify(issue.message, 'bad')
2605
+ if (request === dailyRequest.current) notify(issue.message, 'bad')
2387
2606
  }
2388
2607
  },
2389
2608
  })
@@ -2459,6 +2678,7 @@ window.__ModuleLoader__.load({
2459
2678
  onRatioChange: setThreadRatio,
2460
2679
  onSend: sendFollowup,
2461
2680
  onClear: clearThread,
2681
+ onCancel: (messageId) => cancelWork('followup',question.id,messageId),
2462
2682
  onClose: () => setThreadOpen(false),
2463
2683
  })
2464
2684
  : null)),