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

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