|
|
@@ -2,7 +2,7 @@
|
|
|
|
|
|
namespace App\Filament\Pages;
|
|
|
|
|
|
-use App\Http\Controllers\ExamPdfController;
|
|
|
+use App\Jobs\ImportTemToQuestionsJob;
|
|
|
use App\Services\ExamPdfExportService;
|
|
|
use App\Services\QuestionQualityCheckService;
|
|
|
use App\Services\QuestionsTemAssemblyService;
|
|
|
@@ -12,9 +12,11 @@ use Filament\Notifications\Notification;
|
|
|
use Filament\Pages\Page;
|
|
|
use Filament\Support\Enums\Width;
|
|
|
use Illuminate\Support\Facades\Auth;
|
|
|
+use Illuminate\Support\Facades\Cache;
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
use Livewire\Attributes\Computed;
|
|
|
+use Livewire\Attributes\Renderless;
|
|
|
use UnitEnum;
|
|
|
|
|
|
class QuestionTemQualityReview extends Page
|
|
|
@@ -40,12 +42,21 @@ class QuestionTemQualityReview extends Page
|
|
|
|
|
|
/** 左侧知识点列表搜索(匹配 kp_code、kp_name,不区分大小写) */
|
|
|
public string $kpSearch = '';
|
|
|
+ /** 左侧按年级筛选;空字符串=全部 */
|
|
|
+ public string $gradeFilter = '';
|
|
|
+ /** 左侧按学期筛选;空字符串=全部 */
|
|
|
+ public string $semesterFilter = '';
|
|
|
+ /** 中间卡片首屏渲染数量(性能优先,按需加载更多) */
|
|
|
+ public int $cardRenderLimit = 8;
|
|
|
|
|
|
public ?int $selectedTemId = null;
|
|
|
|
|
|
/** 中间区多选:questions_tem.id,点击题目切换勾选 */
|
|
|
public array $selectedTemIds = [];
|
|
|
|
|
|
+ /** 逐题审核后加入「待入库」清单的 tem id 列表 */
|
|
|
+ public array $pendingImportTemIds = [];
|
|
|
+
|
|
|
/** 为 true 时才计算/展示高级区质检与重复提示(避免每次点击跑质检) */
|
|
|
public bool $qcPanelExpanded = false;
|
|
|
|
|
|
@@ -72,15 +83,70 @@ class QuestionTemQualityReview extends Page
|
|
|
{
|
|
|
$this->selectedTemId = null;
|
|
|
$this->selectedTemIds = [];
|
|
|
+ $this->pendingImportTemIds = [];
|
|
|
+ $this->cardRenderLimit = 8;
|
|
|
$this->importDifficultyInput = '0.50';
|
|
|
$this->qcPanelExpanded = false;
|
|
|
- $this->syncTemMultiSelectionJs();
|
|
|
}
|
|
|
|
|
|
- #[Computed(cache: false)]
|
|
|
+ public function updatedGradeFilter(): void
|
|
|
+ {
|
|
|
+ $this->semesterFilter = '';
|
|
|
+ $this->selectedKpCode = null;
|
|
|
+ $this->selectedTemId = null;
|
|
|
+ $this->selectedTemIds = [];
|
|
|
+ $this->pendingImportTemIds = [];
|
|
|
+ $this->cardRenderLimit = 8;
|
|
|
+ $this->importDifficultyInput = '0.50';
|
|
|
+ $this->qcPanelExpanded = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ public function updatedSemesterFilter(): void
|
|
|
+ {
|
|
|
+ $this->selectedKpCode = null;
|
|
|
+ $this->selectedTemId = null;
|
|
|
+ $this->selectedTemIds = [];
|
|
|
+ $this->pendingImportTemIds = [];
|
|
|
+ $this->cardRenderLimit = 8;
|
|
|
+ $this->importDifficultyInput = '0.50';
|
|
|
+ $this->qcPanelExpanded = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ #[Computed]
|
|
|
public function kpRows(): array
|
|
|
{
|
|
|
- return app(QuestionTemReviewService::class)->listKnowledgePointsByQuestionsAsc(null);
|
|
|
+ return app(QuestionTemReviewService::class)->listKnowledgePointsByQuestionsAsc(
|
|
|
+ null,
|
|
|
+ $this->parseGradeFilter(),
|
|
|
+ $this->parseSemesterFilter()
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @return list<array{value:string,label:string}> */
|
|
|
+ #[Computed]
|
|
|
+ public function gradeOptions(): array
|
|
|
+ {
|
|
|
+ $vals = app(QuestionTemReviewService::class)->catalogGradeOptions($this->parseSemesterFilter());
|
|
|
+
|
|
|
+ $out = [];
|
|
|
+ foreach ($vals as $g) {
|
|
|
+ $v = (string) ((int) $g);
|
|
|
+ $out[] = ['value' => $v, 'label' => $this->formatGradeLabel((int) $g)];
|
|
|
+ }
|
|
|
+ return $out;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @return list<array{value:string,label:string}> */
|
|
|
+ #[Computed]
|
|
|
+ public function semesterOptions(): array
|
|
|
+ {
|
|
|
+ $vals = app(QuestionTemReviewService::class)->catalogSemesterOptions($this->parseGradeFilter());
|
|
|
+ $out = [];
|
|
|
+ foreach ($vals as $s) {
|
|
|
+ $v = (string) ((int) $s);
|
|
|
+ $out[] = ['value' => $v, 'label' => $this->formatSemesterLabel((int) $s)];
|
|
|
+ }
|
|
|
+ return $out;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -88,10 +154,13 @@ class QuestionTemQualityReview extends Page
|
|
|
*
|
|
|
* @return list<array{kp_code: string, kp_name: string, questions_count: int, tem_count: int, tem_importable_count: int}>
|
|
|
*/
|
|
|
- #[Computed(cache: false)]
|
|
|
+ #[Computed]
|
|
|
public function filteredKpRows(): array
|
|
|
{
|
|
|
- $rows = $this->kpRows;
|
|
|
+ $rows = array_values(array_filter(
|
|
|
+ $this->kpRows,
|
|
|
+ static fn (array $row): bool => (int) ($row['tem_importable_count'] ?? 0) > 0
|
|
|
+ ));
|
|
|
$raw = trim($this->kpSearch);
|
|
|
if ($raw === '') {
|
|
|
return $rows;
|
|
|
@@ -108,14 +177,61 @@ class QuestionTemQualityReview extends Page
|
|
|
}));
|
|
|
}
|
|
|
|
|
|
- #[Computed(cache: false)]
|
|
|
+ #[Computed]
|
|
|
public function temQuestions(): array
|
|
|
{
|
|
|
if (! $this->selectedKpCode) {
|
|
|
return [];
|
|
|
}
|
|
|
|
|
|
- return app(QuestionTemReviewService::class)->listTemQuestionsForKp($this->selectedKpCode, 300);
|
|
|
+ $key = $this->buildPageCacheKey('tem_questions');
|
|
|
+ return Cache::remember($key, now()->addMinutes(3), function (): array {
|
|
|
+ $rows = app(QuestionTemReviewService::class)->listTemQuestionsForKp(
|
|
|
+ $this->selectedKpCode,
|
|
|
+ 300,
|
|
|
+ true,
|
|
|
+ $this->parseGradeFilter()
|
|
|
+ );
|
|
|
+ return $this->sortRowsByQuestionType($rows);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 独立审核卡片模型:清洗题干、给出三项快检,并附上该题完整渲染所需分组数据。
|
|
|
+ *
|
|
|
+ * @return list<array{id:int, stem_preview:string, checks:array{stem:bool,answer:bool,solution:bool}, grouped_questions:array{choice:array<int,object>,fill:array<int,object>,answer:array<int,object>}}>
|
|
|
+ */
|
|
|
+ #[Computed]
|
|
|
+ public function temQuestionCards(): array
|
|
|
+ {
|
|
|
+ $key = $this->buildPageCacheKey('tem_cards');
|
|
|
+ return Cache::remember($key, now()->addMinutes(3), function (): array {
|
|
|
+ $cards = [];
|
|
|
+ foreach ($this->temQuestions as $row) {
|
|
|
+ $arr = is_array($row) ? $row : (array) $row;
|
|
|
+ $id = (int) ($arr['id'] ?? 0);
|
|
|
+ if ($id <= 0) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ $stem = $this->normalizeStemPreview((string) ($arr['stem'] ?? ''));
|
|
|
+ $answer = trim((string) ($arr['answer'] ?? $arr['correct_answer'] ?? ''));
|
|
|
+ $solution = trim((string) ($arr['solution'] ?? ''));
|
|
|
+
|
|
|
+ $cards[] = [
|
|
|
+ 'id' => $id,
|
|
|
+ 'stem_preview' => $stem,
|
|
|
+ 'checks' => [
|
|
|
+ 'stem' => $stem !== '',
|
|
|
+ 'answer' => $answer !== '',
|
|
|
+ 'solution' => $solution !== '',
|
|
|
+ ],
|
|
|
+ 'grouped_questions' => $this->buildPreviewGroupedQuestionsFromTemRows([$arr]),
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ return $cards;
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -123,21 +239,18 @@ class QuestionTemQualityReview extends Page
|
|
|
*
|
|
|
* @return array{choice: array, fill: array, answer: array}
|
|
|
*/
|
|
|
- #[Computed(cache: false)]
|
|
|
+ #[Computed]
|
|
|
public function groupedPaperBodyQuestions(): array
|
|
|
{
|
|
|
if (! $this->selectedKpCode) {
|
|
|
return ['choice' => [], 'fill' => [], 'answer' => []];
|
|
|
}
|
|
|
|
|
|
- $pdf = app(ExamPdfController::class);
|
|
|
- $data = $pdf->prepareQuestionsDataFromTemRows($this->temQuestions);
|
|
|
-
|
|
|
- return $pdf->buildGroupedQuestionsForPaperBody($data, null);
|
|
|
+ return $this->buildPreviewGroupedQuestionsFromTemRows($this->temQuestions);
|
|
|
}
|
|
|
|
|
|
/** @return list<object> */
|
|
|
- #[Computed(cache: false)]
|
|
|
+ #[Computed]
|
|
|
public function assemblyQueueRows(): array
|
|
|
{
|
|
|
$uid = Auth::id();
|
|
|
@@ -148,7 +261,7 @@ class QuestionTemQualityReview extends Page
|
|
|
return app(QuestionsTemAssemblyService::class)->queueForUser((int) $uid);
|
|
|
}
|
|
|
|
|
|
- #[Computed(cache: false)]
|
|
|
+ #[Computed]
|
|
|
public function selectedRow(): ?object
|
|
|
{
|
|
|
if (! $this->selectedTemId) {
|
|
|
@@ -161,7 +274,7 @@ class QuestionTemQualityReview extends Page
|
|
|
/**
|
|
|
* @return array{passed: bool, errors: array, results: array}|null
|
|
|
*/
|
|
|
- #[Computed(cache: false)]
|
|
|
+ #[Computed]
|
|
|
public function qcResult(): ?array
|
|
|
{
|
|
|
if (! $this->qcPanelExpanded) {
|
|
|
@@ -183,7 +296,7 @@ class QuestionTemQualityReview extends Page
|
|
|
];
|
|
|
}
|
|
|
|
|
|
- #[Computed(cache: false)]
|
|
|
+ #[Computed]
|
|
|
public function duplicateHint(): ?string
|
|
|
{
|
|
|
if (! $this->qcPanelExpanded) {
|
|
|
@@ -212,43 +325,253 @@ class QuestionTemQualityReview extends Page
|
|
|
$this->selectedKpCode = $kpCode;
|
|
|
$this->selectedTemId = null;
|
|
|
$this->selectedTemIds = [];
|
|
|
+ $this->pendingImportTemIds = [];
|
|
|
+ $this->cardRenderLimit = 8;
|
|
|
$this->importDifficultyInput = '0.50';
|
|
|
$this->qcPanelExpanded = false;
|
|
|
- $this->syncTemMultiSelectionJs();
|
|
|
+ }
|
|
|
+
|
|
|
+ public function loadMoreCards(): void
|
|
|
+ {
|
|
|
+ $this->cardRenderLimit += 8;
|
|
|
+ $this->dispatch('qtr-scroll-top');
|
|
|
+ }
|
|
|
+
|
|
|
+ #[Computed]
|
|
|
+ public function visibleTemQuestionCards(): array
|
|
|
+ {
|
|
|
+ return array_slice($this->temQuestionCards, 0, max(1, $this->cardRenderLimit));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @return list<object> */
|
|
|
+ #[Computed]
|
|
|
+ public function pendingImportRows(): array
|
|
|
+ {
|
|
|
+ if ($this->pendingImportTemIds === []) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ $rows = DB::table('questions_tem')
|
|
|
+ ->whereIn('id', $this->pendingImportTemIds)
|
|
|
+ ->get()
|
|
|
+ ->keyBy('id');
|
|
|
+
|
|
|
+ $ordered = [];
|
|
|
+ foreach ($this->pendingImportTemIds as $id) {
|
|
|
+ $row = $rows->get((int) $id);
|
|
|
+ if ($row) {
|
|
|
+ $ordered[] = $row;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return $this->sortRowsByQuestionType($ordered);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 中间题目区使用 wire:ignore,多选高亮由脚本根据 selectedTemIds 同步。
|
|
|
+ * 右侧人工判重参考:当前选中知识点在正式库 questions 的题目(编号+题干)。
|
|
|
+ *
|
|
|
+ * @return list<object>
|
|
|
*/
|
|
|
+ #[Computed]
|
|
|
+ public function currentKpQuestionStemRows(): array
|
|
|
+ {
|
|
|
+ if (! $this->selectedKpCode || ! Schema::hasTable('questions')) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ $key = $this->buildPageCacheKey('kp_question_stems_v2');
|
|
|
+ return Cache::remember($key, now()->addMinutes(2), function (): array {
|
|
|
+ $q = DB::table('questions')
|
|
|
+ ->where('kp_code', $this->selectedKpCode)
|
|
|
+ ->select(['id', 'stem', 'question_type'])
|
|
|
+ ->orderByRaw("
|
|
|
+ CASE
|
|
|
+ WHEN LOWER(COALESCE(question_type, '')) LIKE '%choice%' OR question_type LIKE '%选择%' THEN 1
|
|
|
+ WHEN LOWER(COALESCE(question_type, '')) LIKE '%fill%' OR LOWER(COALESCE(question_type, '')) LIKE '%blank%' OR question_type LIKE '%填空%' THEN 2
|
|
|
+ ELSE 3
|
|
|
+ END ASC
|
|
|
+ ")
|
|
|
+ ->orderBy('id');
|
|
|
+
|
|
|
+ // 右侧人工判重列表仅按当前选中知识点展示:
|
|
|
+ // 年级/学期筛选已在左侧知识点范围内收敛,避免对 questions.grade 再次过滤导致误空。
|
|
|
+
|
|
|
+ return $q->limit(300)->get()->all();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
public function updatedSelectedTemId(mixed $value): void
|
|
|
{
|
|
|
if ($this->selectedTemId) {
|
|
|
$this->syncImportDifficultyFromSelectedRow();
|
|
|
+ if (! in_array((int) $this->selectedTemId, $this->selectedTemIds, true)) {
|
|
|
+ $this->selectedTemIds[] = (int) $this->selectedTemId;
|
|
|
+ }
|
|
|
} else {
|
|
|
$this->importDifficultyInput = '0.50';
|
|
|
}
|
|
|
+ $this->selectedTemIds = array_values(array_unique(array_map('intval', $this->selectedTemIds)));
|
|
|
+ }
|
|
|
+
|
|
|
+ public function updatedSelectedTemIds(): void
|
|
|
+ {
|
|
|
+ $this->selectedTemIds = array_values(array_unique(array_filter(
|
|
|
+ array_map('intval', $this->selectedTemIds),
|
|
|
+ static fn (int $id) => $id > 0
|
|
|
+ )));
|
|
|
|
|
|
- $this->syncTemMultiSelectionJs();
|
|
|
+ if ($this->selectedTemId && ! in_array((int) $this->selectedTemId, $this->selectedTemIds, true)) {
|
|
|
+ $this->selectedTemId = $this->selectedTemIds[0] ?? null;
|
|
|
+ }
|
|
|
+ if (! $this->selectedTemId && $this->selectedTemIds !== []) {
|
|
|
+ $this->selectedTemId = $this->selectedTemIds[0];
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($this->selectedTemId) {
|
|
|
+ $this->syncImportDifficultyFromSelectedRow();
|
|
|
+ } else {
|
|
|
+ $this->importDifficultyInput = '0.50';
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- public function toggleTemQuestion(int $id): void
|
|
|
+ public function focusTemQuestion(int $id): void
|
|
|
{
|
|
|
if ($id <= 0) {
|
|
|
return;
|
|
|
}
|
|
|
-
|
|
|
+ $this->selectedTemId = $id;
|
|
|
+ if (! in_array($id, $this->selectedTemIds, true)) {
|
|
|
+ $this->selectedTemIds[] = $id;
|
|
|
+ $this->selectedTemIds = array_values(array_unique(array_map('intval', $this->selectedTemIds)));
|
|
|
+ }
|
|
|
$this->qcPanelExpanded = false;
|
|
|
+ }
|
|
|
|
|
|
- if (in_array($id, $this->selectedTemIds, true)) {
|
|
|
- $this->selectedTemIds = array_values(array_filter($this->selectedTemIds, fn ($x) => (int) $x !== $id));
|
|
|
- } else {
|
|
|
- $this->selectedTemIds[] = $id;
|
|
|
+ public function addToPendingImport(int $id): void
|
|
|
+ {
|
|
|
+ if ($id <= 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (! in_array($id, $this->pendingImportTemIds, true)) {
|
|
|
+ $this->pendingImportTemIds[] = $id;
|
|
|
+ $this->pendingImportTemIds = array_values(array_unique(array_map('intval', $this->pendingImportTemIds)));
|
|
|
+ }
|
|
|
+ if ((int) ($this->selectedTemId ?? 0) === $id) {
|
|
|
+ $this->selectedTemId = null;
|
|
|
+ $this->importDifficultyInput = '0.50';
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ #[Renderless]
|
|
|
+ public function queueImportTem(int $id): void
|
|
|
+ {
|
|
|
+ if ($id <= 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $status = Cache::get(ImportTemToQuestionsJob::statusKey($id));
|
|
|
+ $state = (string) ($status['state'] ?? '');
|
|
|
+ if (in_array($state, ['queued', 'running'], true)) {
|
|
|
+ return;
|
|
|
}
|
|
|
- $this->selectedTemIds = array_values(array_unique(array_map('intval', $this->selectedTemIds)));
|
|
|
|
|
|
- $this->selectedTemId = in_array($id, $this->selectedTemIds, true)
|
|
|
- ? $id
|
|
|
- : ($this->selectedTemIds[count($this->selectedTemIds) - 1] ?? null);
|
|
|
+ Cache::put(ImportTemToQuestionsJob::statusKey($id), [
|
|
|
+ 'state' => 'queued',
|
|
|
+ 'message' => '已加入队列,等待处理',
|
|
|
+ 'at' => now()->toDateTimeString(),
|
|
|
+ ], now()->addMinutes(30));
|
|
|
+
|
|
|
+ ImportTemToQuestionsJob::dispatch($id, Auth::id() ? (int) Auth::id() : null, $this->selectedKpCode);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function removeFromPendingImport(int $id): void
|
|
|
+ {
|
|
|
+ $this->pendingImportTemIds = array_values(array_filter(
|
|
|
+ $this->pendingImportTemIds,
|
|
|
+ static fn ($x) => (int) $x !== $id
|
|
|
+ ));
|
|
|
+ }
|
|
|
+
|
|
|
+ public function clearPendingImport(): void
|
|
|
+ {
|
|
|
+ $this->pendingImportTemIds = [];
|
|
|
+ }
|
|
|
+
|
|
|
+ public function importPendingTem(int $id): void
|
|
|
+ {
|
|
|
+ $this->queueImportTem($id);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function importPendingAll(): void
|
|
|
+ {
|
|
|
+ if ($this->pendingImportTemIds === []) {
|
|
|
+ Notification::make()->title('待入库为空')->warning()->send();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ foreach ($this->pendingImportTemIds as $id) {
|
|
|
+ $this->queueImportTem((int) $id);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ public function syncAsyncImportStatuses(): void
|
|
|
+ {
|
|
|
+ if ($this->pendingImportTemIds === []) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $remaining = [];
|
|
|
+ $doneFound = false;
|
|
|
+ foreach ($this->pendingImportTemIds as $id) {
|
|
|
+ $status = Cache::get(ImportTemToQuestionsJob::statusKey((int) $id));
|
|
|
+ if (($status['state'] ?? null) === 'done') {
|
|
|
+ $doneFound = true;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $remaining[] = (int) $id;
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($doneFound) {
|
|
|
+ $this->pendingImportTemIds = array_values(array_unique($remaining));
|
|
|
+ $this->forgetCurrentKpCaches();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @return array<int, array{state?:string,message?:string,question_id?:int,at?:string}> */
|
|
|
+ #[Computed]
|
|
|
+ public function importStatusMap(): array
|
|
|
+ {
|
|
|
+ $ids = [];
|
|
|
+ foreach ($this->temQuestionCards as $card) {
|
|
|
+ $id = (int) ($card['id'] ?? 0);
|
|
|
+ if ($id > 0) {
|
|
|
+ $ids[] = $id;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ foreach ($this->pendingImportTemIds as $id) {
|
|
|
+ $id = (int) $id;
|
|
|
+ if ($id > 0) {
|
|
|
+ $ids[] = $id;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ $ids = array_values(array_unique($ids));
|
|
|
+ if ($ids === []) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ $keys = [];
|
|
|
+ foreach ($ids as $id) {
|
|
|
+ $keys[$id] = ImportTemToQuestionsJob::statusKey($id);
|
|
|
+ }
|
|
|
+ $many = Cache::many(array_values($keys));
|
|
|
+ $map = [];
|
|
|
+ foreach ($keys as $id => $key) {
|
|
|
+ $v = $many[$key] ?? null;
|
|
|
+ if (is_array($v) && isset($v['state'])) {
|
|
|
+ $map[(int) $id] = $v;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return $map;
|
|
|
}
|
|
|
|
|
|
public function clearTemSelection(): void
|
|
|
@@ -257,7 +580,6 @@ class QuestionTemQualityReview extends Page
|
|
|
$this->selectedTemId = null;
|
|
|
$this->importDifficultyInput = '0.50';
|
|
|
$this->qcPanelExpanded = false;
|
|
|
- $this->syncTemMultiSelectionJs();
|
|
|
}
|
|
|
|
|
|
public function importSelectedTemIdsFast(): void
|
|
|
@@ -276,22 +598,9 @@ class QuestionTemQualityReview extends Page
|
|
|
$this->selectedTemIds = [];
|
|
|
$this->selectedTemId = null;
|
|
|
$this->qcPanelExpanded = false;
|
|
|
- $this->syncTemMultiSelectionJs();
|
|
|
$this->dispatch('$refresh');
|
|
|
}
|
|
|
|
|
|
- private function syncTemMultiSelectionJs(): void
|
|
|
- {
|
|
|
- $idsJson = json_encode(array_values($this->selectedTemIds));
|
|
|
- $this->js(<<<JS
|
|
|
- const set = new Set({$idsJson});
|
|
|
- document.querySelectorAll('.qtr-paper-shell .qtr-selectable').forEach((el) => {
|
|
|
- const tid = parseInt(el.getAttribute('data-tem-id') || '0', 10);
|
|
|
- el.classList.toggle('qtr-is-selected', set.has(tid));
|
|
|
- });
|
|
|
- JS);
|
|
|
- }
|
|
|
-
|
|
|
public function addSelectionToAssemblyQueue(): void
|
|
|
{
|
|
|
if ($this->selectedTemIds === []) {
|
|
|
@@ -499,7 +808,6 @@ class QuestionTemQualityReview extends Page
|
|
|
} else {
|
|
|
$this->importDifficultyInput = '0.50';
|
|
|
}
|
|
|
- $this->syncTemMultiSelectionJs();
|
|
|
$this->dispatch('$refresh');
|
|
|
} else {
|
|
|
Notification::make()->title($result['message'])->danger()->send();
|
|
|
@@ -560,6 +868,76 @@ class QuestionTemQualityReview extends Page
|
|
|
return $value;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 仅用于质检页面预览:不改组卷主链路的控制器/视图逻辑。
|
|
|
+ *
|
|
|
+ * @param array<int, object|array> $rows
|
|
|
+ * @return array{choice: array<int, object>, fill: array<int, object>, answer: array<int, object>}
|
|
|
+ */
|
|
|
+ private function buildPreviewGroupedQuestionsFromTemRows(array $rows): array
|
|
|
+ {
|
|
|
+ $grouped = ['choice' => [], 'fill' => [], 'answer' => []];
|
|
|
+
|
|
|
+ foreach (array_values($rows) as $index => $row) {
|
|
|
+ $arr = is_array($row) ? $row : (array) $row;
|
|
|
+ $type = $this->normalizePreviewQuestionType((string) ($arr['question_type'] ?? $arr['tags'] ?? 'answer'));
|
|
|
+ $options = $this->normalizePreviewOptions($arr['options'] ?? null);
|
|
|
+
|
|
|
+ $questionData = [
|
|
|
+ 'id' => isset($arr['id']) ? -abs((int) $arr['id']) : null,
|
|
|
+ 'question_number' => $index + 1,
|
|
|
+ 'content' => (string) ($arr['stem'] ?? ''),
|
|
|
+ 'stem' => (string) ($arr['stem'] ?? ''),
|
|
|
+ 'answer' => (string) ($arr['answer'] ?? $arr['correct_answer'] ?? ''),
|
|
|
+ 'solution' => (string) ($arr['solution'] ?? ''),
|
|
|
+ 'difficulty' => isset($arr['difficulty']) ? (float) $arr['difficulty'] : 0.5,
|
|
|
+ 'kp_code' => (string) ($arr['kp_code'] ?? ''),
|
|
|
+ 'tags' => is_string($arr['tags'] ?? null) ? $arr['tags'] : '',
|
|
|
+ 'options' => $options,
|
|
|
+ 'score' => $type === 'answer' ? 10 : 5,
|
|
|
+ 'question_type' => $type,
|
|
|
+ ];
|
|
|
+ $grouped[$type][] = (object) $questionData;
|
|
|
+ }
|
|
|
+
|
|
|
+ return $grouped;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function normalizePreviewQuestionType(string $raw): string
|
|
|
+ {
|
|
|
+ $t = mb_strtolower(trim($raw));
|
|
|
+ return match (true) {
|
|
|
+ str_contains($t, 'choice'), str_contains($t, '选择') => 'choice',
|
|
|
+ str_contains($t, 'fill'), str_contains($t, 'blank'), str_contains($t, '填空') => 'fill',
|
|
|
+ default => 'answer',
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @return array<int, string>|null
|
|
|
+ */
|
|
|
+ private function normalizePreviewOptions(mixed $raw): ?array
|
|
|
+ {
|
|
|
+ if (is_string($raw)) {
|
|
|
+ $decoded = json_decode($raw, true);
|
|
|
+ $raw = is_array($decoded) ? $decoded : null;
|
|
|
+ }
|
|
|
+ if (! is_array($raw) || $raw === []) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (! isset($raw[0])) {
|
|
|
+ return array_values(array_map(static fn ($v) => (string) $v, $raw));
|
|
|
+ }
|
|
|
+ if (isset($raw[0]) && is_array($raw[0])) {
|
|
|
+ $out = [];
|
|
|
+ foreach ($raw as $opt) {
|
|
|
+ $out[] = (string) (($opt['content'] ?? $opt['text'] ?? $opt['value'] ?? ''));
|
|
|
+ }
|
|
|
+ return $out;
|
|
|
+ }
|
|
|
+ return array_values(array_map(static fn ($v) => (string) $v, $raw));
|
|
|
+ }
|
|
|
+
|
|
|
private function mapQuestionRowForQc(array $row): array
|
|
|
{
|
|
|
$stem = trim((string) ($row['stem'] ?? ''));
|
|
|
@@ -591,4 +969,108 @@ class QuestionTemQualityReview extends Page
|
|
|
'options' => $options,
|
|
|
];
|
|
|
}
|
|
|
+
|
|
|
+ private function normalizeStemPreview(string $stem): string
|
|
|
+ {
|
|
|
+ // 先去掉图片标签,避免界面出现一大串 <img ...>
|
|
|
+ $text = preg_replace('/<img\b[^>]*>/iu', ' [图] ', $stem) ?? $stem;
|
|
|
+ $text = strip_tags($text);
|
|
|
+ $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
|
+ $text = preg_replace('/\s+/u', ' ', $text) ?? $text;
|
|
|
+ return trim($text);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function buildPageCacheKey(string $suffix): string
|
|
|
+ {
|
|
|
+ $uid = Auth::id() ?: 'guest';
|
|
|
+ $kp = $this->selectedKpCode ?: 'none';
|
|
|
+ $grade = $this->parseGradeFilter() ?: 'all';
|
|
|
+ $semester = $this->parseSemesterFilter() ?: 'all';
|
|
|
+ $version = (int) Cache::get("qtr:version:u{$uid}:kp{$kp}", 1);
|
|
|
+ return "qtr:page:{$suffix}:u{$uid}:kp{$kp}:g{$grade}:s{$semester}:v{$version}";
|
|
|
+ }
|
|
|
+
|
|
|
+ private function forgetCurrentKpCaches(): void
|
|
|
+ {
|
|
|
+ foreach (['tem_questions', 'tem_cards', 'kp_question_stems'] as $suffix) {
|
|
|
+ Cache::forget($this->buildPageCacheKey($suffix));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @param array<int, object|array<string,mixed>> $rows
|
|
|
+ * @return array<int, object|array<string,mixed>>
|
|
|
+ */
|
|
|
+ private function sortRowsByQuestionType(array $rows): array
|
|
|
+ {
|
|
|
+ usort($rows, function ($a, $b): int {
|
|
|
+ $ar = is_array($a) ? $a : (array) $a;
|
|
|
+ $br = is_array($b) ? $b : (array) $b;
|
|
|
+
|
|
|
+ $aRank = $this->questionTypeRank((string) ($ar['question_type'] ?? $ar['tags'] ?? ''));
|
|
|
+ $bRank = $this->questionTypeRank((string) ($br['question_type'] ?? $br['tags'] ?? ''));
|
|
|
+ if ($aRank !== $bRank) {
|
|
|
+ return $aRank <=> $bRank;
|
|
|
+ }
|
|
|
+
|
|
|
+ return ((int) ($ar['id'] ?? 0)) <=> ((int) ($br['id'] ?? 0));
|
|
|
+ });
|
|
|
+
|
|
|
+ return $rows;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function questionTypeRank(string $raw): int
|
|
|
+ {
|
|
|
+ $t = mb_strtolower(trim($raw));
|
|
|
+ return match (true) {
|
|
|
+ str_contains($t, 'choice'), str_contains($t, '选择') => 1,
|
|
|
+ str_contains($t, 'fill'), str_contains($t, 'blank'), str_contains($t, '填空') => 2,
|
|
|
+ default => 3,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private function parseGradeFilter(): ?int
|
|
|
+ {
|
|
|
+ $v = trim($this->gradeFilter);
|
|
|
+ if ($v === '' || ! is_numeric($v)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ $i = (int) $v;
|
|
|
+ return $i > 0 ? $i : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function parseSemesterFilter(): ?int
|
|
|
+ {
|
|
|
+ $v = trim($this->semesterFilter);
|
|
|
+ if ($v === '' || ! is_numeric($v)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ $i = (int) $v;
|
|
|
+ return $i > 0 ? $i : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function formatGradeLabel(int $grade): string
|
|
|
+ {
|
|
|
+ return match (true) {
|
|
|
+ $grade >= 1 && $grade <= 6 => "小学{$grade}年级",
|
|
|
+ $grade === 7 => '初一',
|
|
|
+ $grade === 8 => '初二',
|
|
|
+ $grade === 9 => '初三',
|
|
|
+ $grade === 10 => '高一',
|
|
|
+ $grade === 11 => '高二',
|
|
|
+ $grade === 12 => '高三',
|
|
|
+ $grade === 2 => '初中(学段)',
|
|
|
+ $grade === 3 => '高中(学段)',
|
|
|
+ default => "年级{$grade}",
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private function formatSemesterLabel(int $semester): string
|
|
|
+ {
|
|
|
+ return match ($semester) {
|
|
|
+ 1 => '上学期',
|
|
|
+ 2 => '下学期',
|
|
|
+ default => "学期{$semester}",
|
|
|
+ };
|
|
|
+ }
|
|
|
}
|