question-tem-quality-review.blade.php 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. @php
  2. $rules = \App\Services\QuestionQualityCheckService::RULES;
  3. $btnSm = \Filament\Support\Enums\Size::Small;
  4. @endphp
  5. <x-filament::page>
  6. {{-- 勾选题目用 Alpine 即时高亮 + $wire.toggleTemQuestion,勿把 toggleTemQuestion 放进 target,避免出现全屏大图标一闪 --}}
  7. <div
  8. wire:loading.delay.shortest
  9. wire:target="selectKp, updatedSelectedKpCode, importSelected, importSelectedTemIdsFast, importAllCurrentKpToQuestions, importAssemblyQueueToQuestions, addSelectionToAssemblyQueue, clearAssemblyQueue, clearTemSelection, removeFromAssemblyQueue, generateTrialGradingPdf"
  10. class="fixed inset-0 z-[130] flex items-center justify-center bg-white/70 dark:bg-gray-950/70"
  11. >
  12. <div class="qtr-page-loading-card">
  13. <div class="qtr-page-loading-spinner" role="status" aria-hidden="true"></div>
  14. <span class="qtr-page-loading-text">{{ __('加载中…') }}</span>
  15. </div>
  16. </div>
  17. {{-- Filament 主题自带 fi-* 样式;此处用内联布局避免依赖 Tailwind 工具类是否被打进 app.css --}}
  18. <style>
  19. .qtr-page-loading-card {
  20. display: flex;
  21. flex-direction: column;
  22. align-items: center;
  23. gap: 0.5rem;
  24. padding: 1rem 1.5rem;
  25. border-radius: 0.5rem;
  26. border: 1px solid rgb(226 232 240);
  27. background: #fff;
  28. box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.08);
  29. }
  30. .dark .qtr-page-loading-card {
  31. border-color: rgb(55 65 81);
  32. background: rgb(17 24 39);
  33. box-shadow: none;
  34. }
  35. .qtr-page-loading-text {
  36. font-size: 0.875rem;
  37. line-height: 1.25;
  38. color: #475569;
  39. margin: 0;
  40. }
  41. .dark .qtr-page-loading-text {
  42. color: #94a3b8;
  43. }
  44. /* 纯 CSS 小环,固定 1.5rem;勿用依赖 Tailwind 的 SVG */
  45. .qtr-page-loading-spinner {
  46. box-sizing: border-box;
  47. width: 1.5rem;
  48. height: 1.5rem;
  49. flex-shrink: 0;
  50. border: 2px solid rgba(100, 116, 139, 0.25);
  51. border-top-color: rgb(100, 116, 139);
  52. border-radius: 50%;
  53. animation: qtr-spin 0.7s linear infinite;
  54. }
  55. @@keyframes qtr-spin {
  56. to { transform: rotate(360deg); }
  57. }
  58. .qtr-shell {
  59. width: 100%;
  60. display: grid;
  61. grid-template-columns: minmax(13rem, 16rem) minmax(0, 1fr) minmax(15rem, 22rem);
  62. gap: 1rem;
  63. align-items: start;
  64. }
  65. /* 左右栏:滚动主内容时保持吸顶悬浮(中间栏正常滚动) */
  66. .qtr-sticky-side {
  67. position: sticky;
  68. top: 0.75rem;
  69. align-self: start;
  70. max-height: calc(100vh - 1.25rem);
  71. overflow-y: auto;
  72. overflow-x: hidden;
  73. z-index: 2;
  74. -webkit-overflow-scrolling: touch;
  75. }
  76. @@media (max-width: 1024px) {
  77. .qtr-shell {
  78. grid-template-columns: 1fr;
  79. }
  80. .qtr-sticky-side {
  81. position: static;
  82. max-height: none;
  83. overflow: visible;
  84. }
  85. }
  86. .qtr-kp-scroll {
  87. display: flex;
  88. flex-direction: column;
  89. gap: 0.375rem;
  90. }
  91. </style>
  92. <div class="qtr-shell">
  93. {{-- 左:知识点 --}}
  94. <div class="qtr-sticky-side">
  95. <x-filament::section
  96. heading="知识点"
  97. description="按 questions 正式题量升序;仅含 questions_tem 出现过的 KP。数字含义:表内=questions_tem 本 KP 总行数;可入库=去掉与正式库同题干重复后、与中间列表一致的可提交条数"
  98. :compact="true"
  99. >
  100. <div class="mb-3 space-y-2">
  101. <x-filament::input.wrapper
  102. inline-prefix
  103. prefix-icon="heroicon-m-magnifying-glass"
  104. >
  105. <x-filament::input
  106. type="search"
  107. wire:model.live.debounce.300ms="kpSearch"
  108. placeholder="搜索代码或名称…"
  109. autocomplete="off"
  110. />
  111. </x-filament::input.wrapper>
  112. @if (filled($this->kpSearch))
  113. <p class="text-xs text-gray-500 dark:text-gray-400">
  114. 显示 {{ count($this->filteredKpRows) }} / 共 {{ count($this->kpRows) }} 个知识点
  115. </p>
  116. @endif
  117. </div>
  118. <div class="qtr-kp-scroll">
  119. @forelse($this->filteredKpRows as $row)
  120. <div
  121. class="w-full"
  122. wire:key="kp-row-{{ $row['kp_code'] }}"
  123. x-data="{ kp: @js($row['kp_code']) }"
  124. >
  125. <x-filament::button
  126. x-on:click.prevent="$wire.selectKp(kp)"
  127. :outlined="$this->selectedKpCode !== $row['kp_code']"
  128. :color="$this->selectedKpCode === $row['kp_code'] ? 'primary' : 'gray'"
  129. :size="$btnSm"
  130. class="w-full"
  131. style="justify-content: flex-start;"
  132. >
  133. <div class="flex w-full flex-col items-stretch gap-1 text-left">
  134. @if (! empty($row['kp_name'] ?? ''))
  135. <span class="text-sm font-medium leading-snug line-clamp-2">{{ $row['kp_name'] }}</span>
  136. @endif
  137. <span class="font-mono text-xs break-all opacity-90">{{ $row['kp_code'] }}</span>
  138. <span class="text-xs opacity-80">
  139. 正式 {{ $row['questions_count'] }}
  140. · 表内 {{ $row['tem_count'] }}
  141. · 可入库 {{ $row['tem_importable_count'] ?? 0 }}
  142. </span>
  143. </div>
  144. </x-filament::button>
  145. </div>
  146. @empty
  147. <p class="text-sm text-gray-600 dark:text-gray-400">暂无数据(检查 questions_tem)</p>
  148. @endforelse
  149. </div>
  150. </x-filament::section>
  151. </div>
  152. {{-- 中:与判卷页同源 components.exam.paper-body(一行一题,含选项布局/答案/解题思路) --}}
  153. <x-filament::section
  154. heading="待审题目(判卷页版式)"
  155. description="与 pdf.exam-grading 同源;列表仅展示「可入库」题目=questions_tem 本 KP 去掉与正式库同题干重复后的条目(与左侧「可入库」同数)。点击题目勾选;右侧可批量入库,精细质检在「高级」。"
  156. :compact="true"
  157. >
  158. @if (! $this->selectedKpCode)
  159. <p class="text-sm text-gray-600 dark:text-gray-400">请先在左侧选择一个知识点。</p>
  160. @else
  161. @php
  162. $qtrKpMeta = collect($this->kpRows)->firstWhere('kp_code', $this->selectedKpCode);
  163. @endphp
  164. <div class="mb-3 flex flex-wrap items-center gap-2 text-sm">
  165. <span>当前 KP</span>
  166. <code class="rounded bg-gray-100 px-2 py-0.5 text-xs dark:bg-white/10">{{ $this->selectedKpCode }}</code>
  167. <span class="text-xs text-gray-500">
  168. 本列表 <span class="font-semibold text-gray-700 dark:text-gray-200">{{ count($this->temQuestions) }}</span> 道可入库预览
  169. @if ($qtrKpMeta)
  170. <span class="text-gray-400 dark:text-gray-500">
  171. (表内 {{ (int) ($qtrKpMeta['tem_count'] ?? 0) }} 道,已与正式库题干重复而隐藏
  172. {{ max(0, (int) ($qtrKpMeta['tem_count'] ?? 0) - (int) ($qtrKpMeta['tem_importable_count'] ?? 0)) }}
  173. 道)
  174. </span>
  175. @endif
  176. </span>
  177. </div>
  178. {{-- KP 切换时整栏重绘;仅换选中题时 wire:ignore 阻止中间 DOM morph,右侧照常更新 --}}
  179. <div wire:key="qtr-paper-kp-{{ $this->selectedKpCode }}">
  180. <div wire:ignore>
  181. @include('filament.pages.partials.question-tem-paper-body', [
  182. 'questions' => $this->groupedPaperBodyQuestions,
  183. 'selectedTemId' => $this->selectedTemId,
  184. 'selectedTemIdsForMulti' => $this->selectedTemIds,
  185. ])
  186. </div>
  187. </div>
  188. @endif
  189. </x-filament::section>
  190. {{-- 右:快速批量入库 → 可选高级质检/单题入库 → 待组卷 / 批量 --}}
  191. <div
  192. class="qtr-sticky-side"
  193. wire:key="qtr-right-stack"
  194. wire:loading.class="opacity-70"
  195. wire:target="importSelectedTemIdsFast, importSelected, importAllCurrentKpToQuestions, importAssemblyQueueToQuestions, selectKp, updatedSelectedKpCode, addSelectionToAssemblyQueue, clearAssemblyQueue, clearTemSelection, removeFromAssemblyQueue, generateTrialGradingPdf"
  196. >
  197. <x-filament::section
  198. heading="快速入库"
  199. description="勾选中间题目后此处列出 tem 编号;不跑页面质检,与批量入库相同服务端规则(重复题跳过)"
  200. :compact="true"
  201. >
  202. @if ($this->selectedTemIds === [])
  203. <p class="text-sm text-gray-600 dark:text-gray-400">在中间列表点击题目加入勾选。</p>
  204. @else
  205. <div class="mb-2 text-xs text-gray-500">
  206. 已选 <span class="font-semibold text-gray-800 dark:text-gray-200">{{ count($this->selectedTemIds) }}</span> 道 ·
  207. 最后聚焦 tem #<span class="font-mono">{{ $this->selectedTemId ?? '—' }}</span>
  208. </div>
  209. <div class="mb-3 max-h-36 overflow-y-auto rounded-lg border border-gray-100 p-2 font-mono text-xs dark:border-white/10">
  210. {{ implode(', ', array_map('intval', $this->selectedTemIds)) }}
  211. </div>
  212. @endif
  213. <div class="flex flex-wrap gap-2">
  214. <x-filament::button
  215. color="success"
  216. wire:click="importSelectedTemIdsFast"
  217. wire:loading.attr="disabled"
  218. :disabled="$this->selectedTemIds === []"
  219. >
  220. 一键入库已选题目
  221. </x-filament::button>
  222. <x-filament::button
  223. color="gray"
  224. wire:click="clearTemSelection"
  225. wire:loading.attr="disabled"
  226. :disabled="$this->selectedTemIds === []"
  227. >
  228. 清空勾选
  229. </x-filament::button>
  230. </div>
  231. </x-filament::section>
  232. <x-filament::section heading="入库后调难度" :compact="true" class="mt-4">
  233. <p class="text-xs text-gray-600 dark:text-gray-400">
  234. 自本会话起成功写入 <span class="font-mono">questions</span> 的题目会进入列表,可集中修改难度系数。
  235. </p>
  236. <a
  237. href="{{ \App\Filament\Pages\QuestionImportedDifficultyTune::getUrl() }}"
  238. class="mt-2 inline-flex text-sm font-medium text-primary-600 underline"
  239. >
  240. 打开「已入库题目 · 难度调整」
  241. </a>
  242. </x-filament::section>
  243. <x-filament::section
  244. heading="高级:质检与单题入库"
  245. description="需要逐题看清规则、自定义难度再入库时展开;展开后会为当前聚焦的题目跑质检"
  246. :compact="true"
  247. class="mt-4"
  248. >
  249. @if (! $this->qcPanelExpanded)
  250. <x-filament::button color="gray" wire:click="$set('qcPanelExpanded', true)">
  251. 展开质检与单题入库
  252. </x-filament::button>
  253. @else
  254. <div class="mb-3">
  255. <x-filament::button color="gray" size="sm" wire:click="$set('qcPanelExpanded', false)">
  256. 收起
  257. </x-filament::button>
  258. </div>
  259. {{-- 质检 --}}
  260. <div class="rounded-lg border border-gray-100 p-3 dark:border-white/10">
  261. <p class="mb-2 text-xs font-medium text-gray-700 dark:text-gray-200">质检结果</p>
  262. @if (! $this->selectedTemId)
  263. <p class="text-sm text-gray-600 dark:text-gray-400">请先在中间点击一道题作为当前聚焦。</p>
  264. @else
  265. @php $qc = $this->qcResult; @endphp
  266. <div class="mb-2 text-xs text-gray-500">
  267. questions_tem.id = <span class="font-mono">{{ $this->selectedTemId }}</span>
  268. </div>
  269. @if ($this->duplicateHint)
  270. <div class="mb-3 rounded-lg bg-amber-50 p-3 text-xs text-amber-900 dark:bg-amber-950/40 dark:text-amber-100">
  271. {{ $this->duplicateHint }}
  272. </div>
  273. @endif
  274. @if ($qc)
  275. <div
  276. class="mb-3 rounded-lg border p-3 {{ $qc['passed'] ? 'border-success-600/40 bg-success-50 dark:bg-success-950/30' : 'border-danger-600/40 bg-danger-50 dark:bg-danger-950/30' }}"
  277. >
  278. <div class="text-sm font-medium">
  279. {{ $qc['passed'] ? '质检通过' : '质检未通过' }}
  280. </div>
  281. @if (! empty($qc['errors']))
  282. <ul class="mt-2 list-inside list-disc text-xs">
  283. @foreach ($qc['errors'] as $code)
  284. <li>{{ $rules[$code]['name'] ?? $code }}</li>
  285. @endforeach
  286. </ul>
  287. @endif
  288. </div>
  289. <div class="max-h-48 overflow-y-auto rounded-lg border border-gray-100 p-2 text-xs dark:border-white/10">
  290. @foreach ($qc['results'] as $r)
  291. @if (($r['auto_result'] ?? '') !== 'skip')
  292. <div class="flex justify-between gap-2 border-b border-gray-100 py-1 last:border-0 dark:border-white/10">
  293. <span>{{ $r['rule_name'] ?? $r['rule_code'] }}</span>
  294. <span class="{{ ($r['passed'] ?? false) ? 'text-success-600' : 'text-danger-600' }}">
  295. {{ ($r['passed'] ?? false) ? 'OK' : '×' }}
  296. </span>
  297. </div>
  298. @endif
  299. @endforeach
  300. </div>
  301. @endif
  302. @endif
  303. </div>
  304. {{-- 入库难度 --}}
  305. <div class="mt-3 rounded-lg border border-gray-100 p-3 dark:border-white/10">
  306. <p class="mb-2 text-xs font-medium text-gray-700 dark:text-gray-200">单题入库难度</p>
  307. @if (! $this->selectedTemId)
  308. <p class="text-xs text-gray-500">先选择题目标</p>
  309. @else
  310. <x-filament::input.wrapper>
  311. <x-filament::input
  312. type="number"
  313. min="0"
  314. max="0.9"
  315. step="0.01"
  316. placeholder="例如 0.35"
  317. wire:model.live.debounce.400ms="importDifficultyInput"
  318. />
  319. </x-filament::input.wrapper>
  320. <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
  321. 将写入 <span class="font-mono">questions.difficulty</span>;批量/快速入库使用各题 tem 原始值(规整到 0~0.90)。
  322. </p>
  323. @endif
  324. </div>
  325. {{-- 单题入库 --}}
  326. <div class="mt-3 rounded-lg border border-gray-100 p-3 dark:border-white/10">
  327. <p class="mb-2 text-xs font-medium text-gray-700 dark:text-gray-200">单题入库</p>
  328. @if (! $this->selectedTemId)
  329. <p class="text-xs text-gray-500">先选择题目标</p>
  330. @else
  331. @php
  332. $qc = $this->qcResult;
  333. $canImport = $qc && ($qc['passed'] ?? false) && ! $this->duplicateHint;
  334. @endphp
  335. <x-filament::button
  336. color="success"
  337. wire:click="importSelected"
  338. wire:loading.attr="disabled"
  339. :disabled="! $canImport"
  340. >
  341. 入库到 questions
  342. </x-filament::button>
  343. @if (! $canImport && $qc)
  344. <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
  345. @if ($this->duplicateHint)
  346. 与正式库重复时不可入库。
  347. @elseif (! ($qc['passed'] ?? false))
  348. 质检未通过时不可入库,请先修正题目数据。
  349. @endif
  350. </p>
  351. @else
  352. <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
  353. audit_status=0(若表含该字段);同 KP + 同题干重复会拒绝。
  354. </p>
  355. @endif
  356. @endif
  357. </div>
  358. @endif
  359. </x-filament::section>
  360. {{-- 待组卷与判卷 PDF --}}
  361. <x-filament::section
  362. heading="待组卷验 PDF"
  363. description="将题目加入队列后生成临时试卷;判卷页与 PDF 同源"
  364. :compact="true"
  365. class="mt-4"
  366. >
  367. <div class="mb-3 space-y-2 text-xs text-gray-600 dark:text-gray-400">
  368. @if ($this->selectedTemIds !== [])
  369. <p>当前勾选:<span class="font-mono">{{ count($this->selectedTemIds) }} 道 tem</span></p>
  370. @endif
  371. <p class="text-gray-500">队列共 {{ count($this->assemblyQueueRows) }} 道</p>
  372. </div>
  373. @if (count($this->assemblyQueueRows) > 0)
  374. <ul class="mb-3 max-h-32 list-inside list-decimal overflow-y-auto rounded border border-gray-100 p-2 text-xs dark:border-white/10">
  375. @foreach ($this->assemblyQueueRows as $qr)
  376. <li class="flex items-center justify-between gap-2 py-0.5">
  377. <span class="truncate">tem #{{ (int) ($qr->id ?? 0) }}</span>
  378. <button
  379. type="button"
  380. class="shrink-0 text-danger-600 underline"
  381. wire:click="removeFromAssemblyQueue({{ (int) ($qr->id ?? 0) }})"
  382. >
  383. 移除
  384. </button>
  385. </li>
  386. @endforeach
  387. </ul>
  388. @endif
  389. <div class="flex flex-col gap-2">
  390. <x-filament::button
  391. color="gray"
  392. wire:click="addSelectionToAssemblyQueue"
  393. :disabled="$this->selectedTemIds === []"
  394. >
  395. 将当前勾选加入待组卷
  396. </x-filament::button>
  397. <x-filament::button
  398. color="warning"
  399. wire:click="clearAssemblyQueue"
  400. :disabled="count($this->assemblyQueueRows) === 0"
  401. >
  402. 清空队列
  403. </x-filament::button>
  404. <x-filament::button
  405. color="primary"
  406. wire:click="generateTrialGradingPdf"
  407. :disabled="count($this->assemblyQueueRows) === 0"
  408. >
  409. 生成完整卷 PDF 并打开判卷页
  410. </x-filament::button>
  411. @if ($this->trialGradingUrl)
  412. <a
  413. href="{{ $this->trialGradingUrl }}"
  414. target="_blank"
  415. rel="noopener noreferrer"
  416. class="text-sm text-primary-600 underline"
  417. >
  418. 判卷页预览(新标签)
  419. </a>
  420. @endif
  421. @if ($this->trialGradingPdfUrl)
  422. <a
  423. href="{{ $this->trialGradingPdfUrl }}"
  424. target="_blank"
  425. rel="noopener noreferrer"
  426. class="text-sm text-primary-600 underline"
  427. download
  428. >
  429. 下载完整卷 PDF
  430. </a>
  431. @endif
  432. </div>
  433. </x-filament::section>
  434. {{-- 5. 批量:questions_tem → questions(与单题入库同一套规则,不经 JSON) --}}
  435. <x-filament::section
  436. heading="批量入库"
  437. description="直接从 questions_tem 写入 questions;规则与单题「入库」一致(同 KP + 同题干已存在则跳过)"
  438. :compact="true"
  439. class="mt-4"
  440. >
  441. <div class="flex flex-col gap-2">
  442. <x-filament::button
  443. color="gray"
  444. wire:click="importAllCurrentKpToQuestions"
  445. wire:loading.attr="disabled"
  446. wire:target="importAllCurrentKpToQuestions"
  447. wire:confirm="确定将当前知识点下列表中的全部题目写入 questions?(正式库已存在的同题干会跳过)"
  448. :disabled="! $this->selectedKpCode || count($this->temQuestions) === 0"
  449. >
  450. 一键入库当前知识点全部
  451. </x-filament::button>
  452. <x-filament::button
  453. color="gray"
  454. wire:click="importAssemblyQueueToQuestions"
  455. wire:loading.attr="disabled"
  456. wire:target="importAssemblyQueueToQuestions"
  457. wire:confirm="确定将上方待组卷队列中的全部题目写入 questions?(已存在的同题干会跳过)"
  458. :disabled="count($this->assemblyQueueRows) === 0"
  459. >
  460. 一键入库待组卷队列全部
  461. </x-filament::button>
  462. </div>
  463. <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
  464. 与单题入库相同的数据写入逻辑;批量时不强制质检通过,仅做重复与必填判断。
  465. </p>
  466. </x-filament::section>
  467. </div>
  468. </div>
  469. </x-filament::page>