knowledge-points.blade.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. @php
  2. // Access properties to trigger lazy loading
  3. $stats = $this->stats;
  4. $points = $this->paginatedPoints['data'];
  5. $pagination = $this->paginatedPoints;
  6. // Explicitly call the getter to ensure selectedPoint is loaded
  7. $selectedPointData = $this->selectedPoint;
  8. $selectedPoint = $selectedPointData;
  9. $selectedSkills = isset($selectedPoint) ? collect($selectedPoint['skills'] ?? []) : collect();
  10. // Get current phase filter
  11. $currentPhase = $this->currentPhase;
  12. @endphp
  13. <x-filament-panels::page>
  14. <div class="space-y-8">
  15. <div class="grid gap-4 md:grid-cols-4">
  16. @foreach($stats as $stat)
  17. <div class="rounded-2xl border border-gray-200/80 bg-white/70 px-5 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/40">
  18. <p class="text-sm text-gray-500">{{ $stat['label'] }}</p>
  19. <p class="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-100">{{ $stat['value'] }}</p>
  20. <p class="text-xs text-gray-400">{{ $stat['hint'] }}</p>
  21. </div>
  22. @endforeach
  23. </div>
  24. <div class="grid gap-6 lg:grid-cols-3">
  25. <div class="lg:col-span-1 space-y-6">
  26. <div class="rounded-2xl border border-gray-200 bg-white/80 px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/40">
  27. <div class="relative">
  28. <span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-gray-400">
  29. @svg('heroicon-m-magnifying-glass', 'h-4 w-4')
  30. </span>
  31. <input
  32. type="text"
  33. id="search-input"
  34. x-on:change="handleSearch($event)"
  35. x-on:keydown.enter="handleSearch($event)"
  36. placeholder="搜索知识点或编号..."
  37. class="w-full rounded-xl border border-gray-200 bg-white/80 py-2 pl-10 pr-3 text-sm placeholder:text-gray-400 focus:border-primary-300 focus:outline-none dark:border-gray-700 dark:bg-gray-900/60"
  38. />
  39. </div>
  40. <div class="mt-4 grid gap-3 md:grid-cols-2">
  41. <div class="relative">
  42. <span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-gray-400">
  43. @svg('heroicon-m-academic-cap', 'h-4 w-4')
  44. </span>
  45. <select
  46. onchange="updateFilter('phase', this.value)"
  47. class="w-full rounded-xl border border-gray-200 bg-white/80 py-2 pl-10 pr-3 text-sm focus:border-primary-300 focus:outline-none dark:border-gray-700 dark:bg-gray-900/60"
  48. >
  49. <option value="">全部学段</option>
  50. @foreach($this->phaseOptions as $value => $label)
  51. <option value="{{ $value }}">{{ $label }}</option>
  52. @endforeach
  53. </select>
  54. </div>
  55. <div class="relative">
  56. <span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-gray-400">
  57. @svg('heroicon-m-tag', 'h-4 w-4')
  58. </span>
  59. <select
  60. onchange="updateFilter('category', this.value)"
  61. class="w-full rounded-xl border border-gray-200 bg-white/80 py-2 pl-10 pr-3 text-sm focus:border-primary-300 focus:outline-none dark:border-gray-700 dark:bg-gray-900/60"
  62. >
  63. <option value="">全部类别</option>
  64. @foreach($this->categoryOptions as $value => $label)
  65. <option value="{{ $value }}">{{ $label }}</option>
  66. @endforeach
  67. </select>
  68. </div>
  69. </div>
  70. </div>
  71. <div class="rounded-2xl border border-gray-200 bg-white/90 px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/40">
  72. <div class="flex items-center justify-between mb-4">
  73. <h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">知识点列表</h3>
  74. <p class="text-xs text-gray-500">{{ $pagination['total'] }} 个结果</p>
  75. </div>
  76. <div class="space-y-3">
  77. @forelse($points as $point)
  78. <button
  79. onclick="selectPoint('{{ $point['kp_code'] }}')"
  80. class="w-full rounded-xl border px-4 py-3 text-left transition @if($selectedPoint && $selectedPoint['kp_code'] === $point['kp_code']) border-primary-500 bg-primary-50/70 dark:bg-primary-500/10 @else border-gray-200 hover:border-primary-200 dark:border-gray-800 hover:shadow-md @endif"
  81. >
  82. <p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ $point['cn_name'] }}</p>
  83. <p class="text-xs text-gray-500">{{ $point['kp_code'] }} · {{ $point['phase'] ?? '未知学段' }}</p>
  84. <div class="mt-2 flex items-center gap-2 text-xs text-gray-500">
  85. <span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 dark:bg-gray-800">重要度 {{ $point['importance'] ?? '-' }}</span>
  86. <span>{{ $point['category'] ?? '未分类' }}</span>
  87. </div>
  88. </button>
  89. @empty
  90. <p class="text-sm text-gray-500">暂无数据</p>
  91. @endforelse
  92. </div>
  93. <div class="mt-4 flex items-center justify-between text-xs text-gray-500">
  94. <button
  95. onclick="navigateToPage({{ $pagination['page'] - 1 }})"
  96. @disabled(!$pagination['has_prev'])
  97. class="rounded-full border px-3 py-1 @if(!$pagination['has_prev']) opacity-40 cursor-not-allowed @endif"
  98. >
  99. 上一页
  100. </button>
  101. <span>第 {{ $pagination['page'] }} / {{ $pagination['total_pages'] }} 页</span>
  102. <button
  103. onclick="navigateToPage({{ $pagination['page'] + 1 }})"
  104. @disabled(!$pagination['has_next'])
  105. class="rounded-full border px-3 py-1 @if(!$pagination['has_next']) opacity-40 cursor-not-allowed @endif"
  106. >
  107. 下一页
  108. </button>
  109. </div>
  110. </div>
  111. </div>
  112. <div class="lg:col-span-2 space-y-6">
  113. @if($selectedPoint)
  114. <div class="rounded-3xl bg-gradient-to-br from-primary-500 via-primary-600 to-indigo-600 px-6 py-6 text-white shadow-xl">
  115. <div class="flex flex-wrap items-start justify-between gap-4">
  116. <div class="space-y-1">
  117. <p class="text-xs uppercase tracking-[0.3em] text-white/80">Knowledge Node</p>
  118. <h1 class="text-3xl font-semibold">{{ $selectedPoint['cn_name'] }} <span class="text-sm font-normal text-white/70">({{ $selectedPoint['kp_code'] }})</span></h1>
  119. <div class="flex flex-wrap gap-2 text-sm text-white/90">
  120. <span class="inline-flex items-center gap-1 rounded-full bg-white/15 px-3 py-1">
  121. {{ $selectedPoint['category'] ?? '未分类' }}
  122. </span>
  123. <span class="inline-flex items-center gap-1 rounded-full bg-white/15 px-3 py-1">
  124. {{ $selectedPoint['phase'] ?? '未知学段' }} @if($selectedPoint['grade']) · {{ $selectedPoint['grade'] }} 年级 @endif
  125. </span>
  126. </div>
  127. </div>
  128. <div class="flex gap-4 text-right">
  129. <div>
  130. <p class="text-xs uppercase tracking-widest text-white/70">重要度</p>
  131. <p class="text-3xl font-semibold">{{ number_format((float) ($selectedPoint['importance'] ?? 0), 1) }}</p>
  132. <p class="text-xs text-white/80">教研权重</p>
  133. </div>
  134. <div>
  135. <p class="text-xs uppercase tracking-widest text-white/70">技能</p>
  136. <p class="text-3xl font-semibold">{{ $selectedSkills->count() }}</p>
  137. <p class="text-xs text-white/80">拆解单元</p>
  138. </div>
  139. </div>
  140. </div>
  141. <div class="mt-6">
  142. <a href="/admin/knowledge-point-detail?kp_code={{ $selectedPoint['kp_code'] }}@if($currentPhase)&phase={{ urlencode($currentPhase) }}@endif"
  143. class="inline-flex items-center gap-2 rounded-full bg-white px-5 py-2.5 text-sm font-semibold text-primary-600 hover:bg-primary-50 transition shadow-sm">
  144. <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
  145. <path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd" />
  146. </svg>
  147. <span>查看完整知识图谱</span>
  148. </a>
  149. </div>
  150. </div>
  151. <div class="rounded-2xl border border-gray-200 bg-white/90 px-5 py-5 shadow-sm dark:border-gray-800 dark:bg-gray-900/50">
  152. <h3 class="text-sm font-semibold text-gray-600 dark:text-gray-300">知识路径</h3>
  153. <p class="mt-2 text-sm text-gray-900 dark:text-gray-100">{{ $selectedPoint['group_path'] ?? '尚未设置路径。' }}</p>
  154. <div class="mt-4 grid gap-4 lg:grid-cols-2">
  155. <div>
  156. <h4 class="text-sm font-semibold text-gray-600 dark:text-gray-300">描述 / 知识意图</h4>
  157. <p class="mt-2 rounded-xl bg-gray-50 px-4 py-3 text-sm text-gray-600 dark:bg-gray-800/40 dark:text-gray-300">
  158. {!! nl2br(e($selectedPoint['description'] ?? '暂无描述,建议补充课堂目标、典型错因等内容。')) !!}
  159. </p>
  160. </div>
  161. <div>
  162. <h4 class="text-sm font-semibold text-gray-600 dark:text-gray-300">先修关系</h4>
  163. <div class="mt-2 rounded-xl border border-gray-200 px-4 py-4 dark:border-gray-700">
  164. @if(!empty($selectedPoint['parent_details']))
  165. <ol class="relative border-l border-dashed border-primary-200 dark:border-primary-400/50 pl-4">
  166. @foreach($selectedPoint['parent_details'] as $parent)
  167. <li class="mb-4 ml-2">
  168. <span class="absolute -left-1.5 mt-1 h-3 w-3 rounded-full border-2 border-white bg-primary-400 dark:border-gray-900"></span>
  169. <a href="/admin/knowledge-point-detail?kp_code={{ $parent['kp_code'] }}@if($currentPhase)&phase={{ urlencode($currentPhase) }}@endif"
  170. class="text-sm font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 hover:underline cursor-pointer">
  171. {{ $parent['cn_name'] }}
  172. </a>
  173. <p class="text-xs text-gray-500">触发该节点之前需掌握</p>
  174. </li>
  175. @endforeach
  176. </ol>
  177. @elseif(!empty($selectedPoint['parents']))
  178. <ol class="relative border-l border-dashed border-primary-200 dark:border-primary-400/50 pl-4">
  179. @foreach($selectedPoint['parents'] as $parent)
  180. <li class="mb-4 ml-2">
  181. <span class="absolute -left-1.5 mt-1 h-3 w-3 rounded-full border-2 border-white bg-primary-400 dark:border-gray-900"></span>
  182. <a href="/admin/knowledge-point-detail?kp_code={{ $parent }}@if($currentPhase)&phase={{ urlencode($currentPhase) }}@endif"
  183. class="text-sm font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 hover:underline cursor-pointer">
  184. {{ $parent }}
  185. </a>
  186. <p class="text-xs text-gray-500">触发该节点之前需掌握</p>
  187. </li>
  188. @endforeach
  189. </ol>
  190. @else
  191. <p class="text-sm text-gray-500">无父节点,可能是一级知识点。</p>
  192. @endif
  193. </div>
  194. </div>
  195. </div>
  196. </div>
  197. <div class="rounded-2xl border border-gray-200 bg-white/90 px-5 py-5 shadow-sm dark:border-gray-800 dark:bg-gray-900/50">
  198. <div class="flex items-center justify-between">
  199. <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">技能拆解</h3>
  200. <span class="text-xs text-gray-500">{{ $selectedSkills->count() }} 项</span>
  201. </div>
  202. @if($selectedSkills->isEmpty())
  203. <p class="mt-4 rounded-xl border border-dashed border-gray-300 px-4 py-6 text-center text-sm text-gray-500 dark:border-gray-700/60">
  204. 该知识点尚未配置技能,建议补充规则步骤、策略和应用场景,便于题库筛选。
  205. </p>
  206. @else
  207. <div class="mt-4 grid gap-4 md:grid-cols-2">
  208. @foreach($selectedSkills as $skill)
  209. <div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-700/60 dark:bg-gray-900/40">
  210. <div class="flex items-start justify-between">
  211. <div>
  212. <p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ $skill['skill_name'] ?? '未命名技能' }}</p>
  213. <p class="text-xs text-gray-500">{{ $skill['skill_type'] ?? '类型未知' }}</p>
  214. </div>
  215. <span class="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2.5 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-500/10 dark:text-primary-300">
  216. 难度 {{ $skill['difficulty'] ?? '-' }}
  217. </span>
  218. </div>
  219. <p class="mt-3 text-sm leading-relaxed text-gray-600 dark:text-gray-300">
  220. {{ $skill['description'] ?? '暂无描述' }}
  221. </p>
  222. @if(!empty($skill['examples']))
  223. <div class="mt-3 rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-500 dark:bg-gray-800/50 dark:text-gray-300">
  224. <p class="font-medium text-gray-600 dark:text-gray-200">例题提示:</p>
  225. {{ $skill['examples'][0]['prompt'] ?? '' }}
  226. </div>
  227. @endif
  228. </div>
  229. @endforeach
  230. </div>
  231. @endif
  232. </div>
  233. @else
  234. <div class="rounded-2xl border border-dashed border-gray-300 px-6 py-12 text-center text-gray-500">
  235. 请选择左侧任意知识点查看详情。
  236. </div>
  237. @endif
  238. </div>
  239. </div>
  240. </div>
  241. <script>
  242. // Initialize search input from URL on page load
  243. document.addEventListener('DOMContentLoaded', () => {
  244. const urlParams = new URLSearchParams(window.location.search);
  245. const searchParam = urlParams.get('search');
  246. const searchInput = document.getElementById('search-input');
  247. if (searchInput && searchParam) {
  248. searchInput.value = searchParam;
  249. }
  250. });
  251. function handleSearch(event) {
  252. const query = event.target.value.trim();
  253. if (query === '') {
  254. // If search is cleared, remove search param
  255. const url = new URL(window.location.href);
  256. url.searchParams.delete('search');
  257. // Reset to page 1
  258. url.searchParams.set('page', 1);
  259. window.location.href = url.toString();
  260. return;
  261. }
  262. // Only update if query changed
  263. const currentUrl = new URL(window.location.href);
  264. const currentSearch = currentUrl.searchParams.get('search') || '';
  265. if (query !== currentSearch) {
  266. const url = new URL(window.location.href);
  267. url.searchParams.set('search', query);
  268. url.searchParams.set('page', 1); // Reset to first page
  269. window.location.href = url.toString();
  270. }
  271. }
  272. function navigateToPage(page) {
  273. const url = new URL(window.location.href);
  274. url.searchParams.set('page', page);
  275. window.location.href = url.toString();
  276. }
  277. function selectPoint(kpCode) {
  278. const url = new URL(window.location.href);
  279. url.searchParams.set('selected', kpCode);
  280. window.location.href = url.toString();
  281. }
  282. function updateFilter(filterName, value) {
  283. const url = new URL(window.location.href);
  284. if (value) {
  285. url.searchParams.set(filterName, value);
  286. } else {
  287. url.searchParams.delete(filterName);
  288. }
  289. // Reset to page 1 when filters change
  290. url.searchParams.set('page', 1);
  291. window.location.href = url.toString();
  292. }
  293. </script>
  294. </x-filament-panels::page>