MarkdownImport.php 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. <?php
  2. namespace App\Models;
  3. use Illuminate\Database\Eloquent\Factories\HasFactory;
  4. use Illuminate\Database\Eloquent\Model;
  5. use Illuminate\Database\Eloquent\Relations\HasMany;
  6. use App\Models\PreQuestionCandidate;
  7. class MarkdownImport extends Model
  8. {
  9. use HasFactory;
  10. protected $fillable = [
  11. 'file_name',
  12. 'remote_url',
  13. 'original_markdown',
  14. 'parsed_json',
  15. 'source_type',
  16. 'source_name',
  17. 'status',
  18. 'error_message',
  19. 'progress_stage',
  20. 'progress_message',
  21. 'progress_current',
  22. 'progress_total',
  23. 'progress_updated_at',
  24. 'processing_started_at',
  25. 'processing_finished_at',
  26. ];
  27. protected $casts = [
  28. 'created_at' => 'datetime',
  29. 'updated_at' => 'datetime',
  30. 'progress_updated_at' => 'datetime',
  31. 'processing_started_at' => 'datetime',
  32. 'processing_finished_at' => 'datetime',
  33. ];
  34. public const STATUS_PENDING = 'pending';
  35. public const STATUS_PARSED = 'parsed';
  36. public const STATUS_REVIEWED = 'reviewed';
  37. public const STATUS_COMPLETED = 'completed';
  38. public const STATUS_PROCESSING = 'processing';
  39. public const STATUS_FAILED = 'failed';
  40. public const STAGE_QUEUED = 'queued';
  41. public const STAGE_SPLITTING = 'splitting';
  42. public const STAGE_AI_PARSING = 'ai_parsing';
  43. public const STAGE_WRITING = 'writing';
  44. public const STAGE_PARSED = 'parsed';
  45. public const STAGE_COMPLETED = 'completed';
  46. public const STAGE_FAILED = 'failed';
  47. public function candidates(): HasMany
  48. {
  49. return $this->hasMany(PreQuestionCandidate::class, 'import_id');
  50. }
  51. public function preQuestions(): HasMany
  52. {
  53. return $this->hasMany(PreQuestion::class, 'import_id');
  54. }
  55. public function getStatusBadgeAttribute(): string
  56. {
  57. $badges = [
  58. self::STATUS_PENDING => 'gray',
  59. self::STATUS_PARSED => 'info',
  60. self::STATUS_REVIEWED => 'warning',
  61. self::STATUS_COMPLETED => 'success',
  62. ];
  63. return $badges[$this->status] ?? 'gray';
  64. }
  65. public function getProgressLabelAttribute(): string
  66. {
  67. $stageLabel = match ($this->progress_stage) {
  68. self::STAGE_QUEUED => '已排队',
  69. self::STAGE_SPLITTING => '拆题中',
  70. self::STAGE_AI_PARSING => 'AI 解析中',
  71. self::STAGE_WRITING => '写入候选库',
  72. self::STAGE_PARSED => '已解析',
  73. self::STAGE_COMPLETED => '已完成',
  74. self::STAGE_FAILED => '失败',
  75. default => $this->progress_stage ?: '—',
  76. };
  77. if (($this->progress_total ?? 0) > 0) {
  78. $total = (int) $this->progress_total;
  79. $current = min((int) ($this->progress_current ?? 0), $total);
  80. return sprintf(
  81. '%s %d/%d',
  82. $stageLabel,
  83. $current,
  84. $total
  85. );
  86. }
  87. return $stageLabel;
  88. }
  89. public function getProgressPercentAttribute(): ?int
  90. {
  91. $total = (int) ($this->progress_total ?? 0);
  92. if ($total <= 0) {
  93. return null;
  94. }
  95. $current = (int) ($this->progress_current ?? 0);
  96. return (int) max(0, min(100, round(($current / $total) * 100)));
  97. }
  98. public function getParsedCountAttribute(): int
  99. {
  100. if (array_key_exists('parsed_count', $this->attributes)) {
  101. return (int) $this->attributes['parsed_count'];
  102. }
  103. return $this->candidates()
  104. ->where('status', '!=', PreQuestionCandidate::STATUS_SUPERSEDED)
  105. ->count();
  106. }
  107. public function getAcceptedCountAttribute(): int
  108. {
  109. if (array_key_exists('accepted_count', $this->attributes)) {
  110. return (int) $this->attributes['accepted_count'];
  111. }
  112. return $this->candidates()
  113. ->where('status', '!=', PreQuestionCandidate::STATUS_SUPERSEDED)
  114. ->where('is_question_candidate', true)
  115. ->count();
  116. }
  117. /**
  118. * 获取切分后的候选题目(新的切分格式)
  119. */
  120. public function getSplitCandidatesAttribute(): array
  121. {
  122. if (!$this->parsed_json) {
  123. return [];
  124. }
  125. $data = json_decode($this->parsed_json, true);
  126. return $data['candidates'] ?? [];
  127. }
  128. /**
  129. * 获取统计信息
  130. */
  131. public function getSplitStatisticsAttribute(): array
  132. {
  133. if (!$this->parsed_json) {
  134. return [];
  135. }
  136. $data = json_decode($this->parsed_json, true);
  137. return $data['statistics'] ?? [];
  138. }
  139. /**
  140. * 检查是否已完成
  141. */
  142. public function isCompleted(): bool
  143. {
  144. return $this->status === self::STATUS_COMPLETED;
  145. }
  146. /**
  147. * 检查是否正在处理
  148. */
  149. public function isProcessing(): bool
  150. {
  151. return $this->status === self::STATUS_PROCESSING;
  152. }
  153. /**
  154. * 检查是否失败
  155. */
  156. public function isFailed(): bool
  157. {
  158. return $this->status === self::STATUS_FAILED;
  159. }
  160. /**
  161. * 获取状态标签
  162. */
  163. public function getStatusLabelAttribute(): string
  164. {
  165. return match ($this->status) {
  166. self::STATUS_PENDING => '等待处理',
  167. self::STATUS_PROCESSING => '处理中',
  168. self::STATUS_COMPLETED => '已完成',
  169. self::STATUS_FAILED => '处理失败',
  170. self::STATUS_PARSED => '已解析',
  171. self::STATUS_REVIEWED => '已审核',
  172. default => '未知',
  173. };
  174. }
  175. /**
  176. * 获取状态颜色
  177. */
  178. public function getSplitStatusColorAttribute(): string
  179. {
  180. return match ($this->status) {
  181. self::STATUS_PENDING => 'gray',
  182. self::STATUS_PROCESSING => 'warning',
  183. self::STATUS_COMPLETED => 'success',
  184. self::STATUS_FAILED => 'danger',
  185. self::STATUS_PARSED => 'info',
  186. self::STATUS_REVIEWED => 'primary',
  187. default => 'gray',
  188. };
  189. }
  190. public function parseFilename(): array
  191. {
  192. if (empty($this->file_name)) {
  193. return [];
  194. }
  195. $base = pathinfo((string) $this->file_name, PATHINFO_FILENAME);
  196. $parts = array_map('trim', explode('_', $base));
  197. if (count($parts) < 4) {
  198. return [];
  199. }
  200. $series = $parts[0] ?? null;
  201. $grade = isset($parts[1]) && is_numeric($parts[1]) ? (int) $parts[1] : null;
  202. $termFlag = isset($parts[2]) && is_numeric($parts[2]) ? (int) $parts[2] : null;
  203. $subject = $parts[3] ?? null;
  204. $name = trim(implode('_', array_slice($parts, 4)));
  205. $term = match ($termFlag) {
  206. 1 => '上册',
  207. 2 => '下册',
  208. 0 => '上下册',
  209. default => null,
  210. };
  211. return [
  212. 'series' => $series,
  213. 'grade' => $grade,
  214. 'term' => $term,
  215. 'subject' => $subject,
  216. 'name' => $name !== '' ? $name : $base,
  217. ];
  218. }
  219. }