DemandPathPanel.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766
  1. <script setup lang="ts">
  2. import { computed, nextTick, ref, watch } from 'vue'
  3. import { fetchDemandGradeVideos } from '../api/demand'
  4. import type { DemandGradeItem, DemandVideoItem } from '../types/demand'
  5. import { gradeRank, parseStrategies } from '../types/demand'
  6. const props = defineProps<{
  7. open: boolean
  8. categoryName: string
  9. items: DemandGradeItem[]
  10. /** When set, prepend a "当前节点" column. */
  11. nodeName?: string | null
  12. nodePath?: string | null
  13. }>()
  14. const emit = defineEmits<{
  15. close: []
  16. }>()
  17. const rootRef = ref<HTMLElement | null>(null)
  18. const selectedDemandId = ref<number | null>(null)
  19. const videos = ref<DemandVideoItem[]>([])
  20. const videosLoading = ref(false)
  21. const videosError = ref<string | null>(null)
  22. const selectedVid = ref<string | null>(null)
  23. const selectedDemand = computed(
  24. () => props.items.find((item) => item.id === selectedDemandId.value) ?? null,
  25. )
  26. const selectedStrategies = computed(() => parseStrategies(selectedDemand.value?.strategies))
  27. const sortedItems = computed(() =>
  28. [...props.items].sort((a, b) => gradeRank(a.grade) - gradeRank(b.grade)),
  29. )
  30. const selectedVideo = computed(
  31. () => videos.value.find((v) => v.vid === selectedVid.value) ?? null,
  32. )
  33. interface TopicPoint {
  34. title: string
  35. description: string
  36. }
  37. interface TopicSection {
  38. key: string
  39. label: string
  40. accent: string
  41. items: TopicPoint[]
  42. }
  43. function parsePointsJson(raw: string | null | undefined): TopicPoint[] {
  44. if (!raw) return []
  45. try {
  46. const parsed = JSON.parse(raw)
  47. const list = Array.isArray(parsed) ? parsed : [parsed]
  48. return list
  49. .filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
  50. .map((item) => ({
  51. title: String(item['点'] ?? '').trim(),
  52. description: String(item['点描述'] ?? '').trim(),
  53. }))
  54. .filter((item) => item.title || item.description)
  55. } catch {
  56. return []
  57. }
  58. }
  59. const topicSections = computed((): TopicSection[] | null => {
  60. const video = selectedVideo.value
  61. if (!video) return null
  62. const sections: TopicSection[] = [
  63. {
  64. key: 'inspiration',
  65. label: '灵感点',
  66. accent: 'inspiration',
  67. items: parsePointsJson(video.inspiration_points_json),
  68. },
  69. {
  70. key: 'purpose',
  71. label: '目的点',
  72. accent: 'purpose',
  73. items: parsePointsJson(video.purpose_points_json),
  74. },
  75. {
  76. key: 'key',
  77. label: '关键点',
  78. accent: 'key',
  79. items: parsePointsJson(video.key_points_json),
  80. },
  81. ]
  82. if (sections.every((s) => s.items.length === 0)) return null
  83. return sections
  84. })
  85. watch(
  86. () => [props.open, props.categoryName] as const,
  87. async ([open]) => {
  88. selectedDemandId.value = null
  89. videos.value = []
  90. videosError.value = null
  91. selectedVid.value = null
  92. if (open) {
  93. await nextTick()
  94. rootRef.value?.scrollIntoView({ behavior: 'smooth', inline: 'nearest', block: 'nearest' })
  95. }
  96. },
  97. )
  98. function gradeClass(grade: string | null | undefined): string {
  99. const g = (grade || '').toUpperCase()
  100. return g ? `grade-${g}` : 'grade-none'
  101. }
  102. async function selectDemand(item: DemandGradeItem) {
  103. if (selectedDemandId.value === item.id) return
  104. selectedDemandId.value = item.id
  105. selectedVid.value = null
  106. videos.value = []
  107. videosError.value = null
  108. videosLoading.value = true
  109. try {
  110. const res = await fetchDemandGradeVideos(item.id)
  111. videos.value = res.videos ?? []
  112. } catch (e) {
  113. videosError.value = e instanceof Error ? e.message : String(e)
  114. } finally {
  115. videosLoading.value = false
  116. }
  117. }
  118. function selectVideo(video: DemandVideoItem) {
  119. selectedVid.value = video.vid
  120. }
  121. </script>
  122. <template>
  123. <div v-if="open" ref="rootRef" class="path-rail" role="region" :aria-label="`${categoryName} 路径展开`">
  124. <!-- 当前节点 -->
  125. <template v-if="nodeName">
  126. <section class="col col-node">
  127. <div class="col-head">
  128. <h3 class="col-title">当前节点</h3>
  129. <button type="button" class="close-btn" title="收起路径" @click="emit('close')">×</button>
  130. </div>
  131. <div class="card node-card">
  132. <span class="card-label">分类节点</span>
  133. <span class="card-value">{{ nodeName }}</span>
  134. <span v-if="nodePath" class="card-path">{{ nodePath }}</span>
  135. </div>
  136. </section>
  137. <div class="arrow-col" aria-hidden="true">
  138. <div class="arrow arrow-slate">
  139. <span class="arrow-line" />
  140. <span class="arrow-head">▶</span>
  141. </div>
  142. </div>
  143. </template>
  144. <!-- 节点 → 需求词 -->
  145. <div class="arrow-col" aria-hidden="true">
  146. <div class="arrow arrow-blue">
  147. <span class="arrow-line" />
  148. <span class="arrow-head">▶</span>
  149. </div>
  150. </div>
  151. <section class="col col-demand">
  152. <div class="col-head">
  153. <h3 class="col-title">细节元素 · 需求词</h3>
  154. <button
  155. v-if="!nodeName"
  156. type="button"
  157. class="close-btn"
  158. title="收起路径"
  159. @click="emit('close')"
  160. >
  161. ×
  162. </button>
  163. </div>
  164. <div v-if="sortedItems.length" class="card-list">
  165. <button
  166. v-for="item in sortedItems"
  167. :key="item.id"
  168. type="button"
  169. class="card demand-card"
  170. :class="{ active: selectedDemandId === item.id }"
  171. @click="selectDemand(item)"
  172. >
  173. <div class="demand-card-head">
  174. <span class="card-label">需求词</span>
  175. <span class="grade-badge" :class="gradeClass(item.grade)">{{ item.grade || '—' }}</span>
  176. </div>
  177. <span class="card-value">{{ item.demand_name || '—' }}</span>
  178. <div v-if="parseStrategies(item.strategies).length" class="strategy-tags">
  179. <span
  180. v-for="s in parseStrategies(item.strategies)"
  181. :key="s"
  182. class="strategy-tag"
  183. >{{ s }}</span>
  184. </div>
  185. </button>
  186. </div>
  187. <div v-else class="empty">暂无需求词</div>
  188. </section>
  189. <!-- 需求词 → 视频 -->
  190. <div class="arrow-col" aria-hidden="true">
  191. <div v-if="selectedDemand" class="arrow arrow-green">
  192. <span class="arrow-line" />
  193. <span class="arrow-head">▶</span>
  194. </div>
  195. </div>
  196. <section class="col col-video">
  197. <h3 class="col-title">真实视频实例</h3>
  198. <template v-if="selectedDemand">
  199. <div class="detail-meta">
  200. <span class="grade-badge" :class="gradeClass(selectedDemand.grade)">
  201. {{ selectedDemand.grade || '—' }}
  202. </span>
  203. <div v-if="selectedStrategies.length" class="strategy-tags">
  204. <span v-for="s in selectedStrategies" :key="s" class="strategy-tag">{{ s }}</span>
  205. </div>
  206. </div>
  207. <div v-if="videosLoading" class="empty">加载中…</div>
  208. <div v-else-if="videosError" class="empty error">{{ videosError }}</div>
  209. <div v-else-if="videos.length" class="card-list">
  210. <button
  211. v-for="video in videos"
  212. :key="video.vid"
  213. type="button"
  214. class="card video-card"
  215. :class="{ active: selectedVid === video.vid }"
  216. @click="selectVideo(video)"
  217. >
  218. <span class="card-label">Video {{ video.vid }}</span>
  219. <span class="card-value">{{ video.title || '(无标题)' }}</span>
  220. </button>
  221. </div>
  222. <div v-else class="empty">暂无关联视频</div>
  223. </template>
  224. <div v-else class="empty hint">点击需求词展开</div>
  225. </section>
  226. <!-- 视频 → JSON -->
  227. <div class="arrow-col" aria-hidden="true">
  228. <div v-if="selectedVideo" class="arrow arrow-purple">
  229. <span class="arrow-line" />
  230. <span class="arrow-head">▶</span>
  231. </div>
  232. </div>
  233. <section class="col col-topic">
  234. <h3 class="col-title">选题结果</h3>
  235. <template v-if="selectedVideo">
  236. <div v-if="topicSections" class="topic-card">
  237. <div class="topic-meta">Video {{ selectedVideo.vid }}</div>
  238. <div class="topic-body">
  239. <section
  240. v-for="section in topicSections"
  241. :key="section.key"
  242. class="topic-section"
  243. :class="`accent-${section.accent}`"
  244. >
  245. <div class="section-head">
  246. <span class="section-label">{{ section.label }}</span>
  247. <span class="section-count">{{ section.items.length }}</span>
  248. </div>
  249. <div v-if="section.items.length" class="point-list">
  250. <article v-for="(item, idx) in section.items" :key="idx" class="point-item">
  251. <div class="point-title-row">
  252. <span class="point-index">{{ idx + 1 }}</span>
  253. <h4 class="point-title">{{ item.title || '未命名' }}</h4>
  254. </div>
  255. <p v-if="item.description" class="point-desc">{{ item.description }}</p>
  256. </article>
  257. </div>
  258. <div v-else class="section-empty">暂无</div>
  259. </section>
  260. </div>
  261. </div>
  262. <div v-else class="empty">暂无选题结果</div>
  263. </template>
  264. <div v-else class="empty hint">点击视频展开</div>
  265. </section>
  266. </div>
  267. </template>
  268. <style scoped>
  269. .path-rail {
  270. display: flex;
  271. flex-direction: row;
  272. align-items: flex-start;
  273. gap: 0;
  274. padding-left: 0;
  275. min-height: 80px;
  276. }
  277. .col {
  278. width: 220px;
  279. flex-shrink: 0;
  280. display: flex;
  281. flex-direction: column;
  282. gap: 10px;
  283. }
  284. .col-topic {
  285. width: 380px;
  286. }
  287. .col-head {
  288. display: flex;
  289. align-items: center;
  290. justify-content: space-between;
  291. gap: 6px;
  292. }
  293. .col-title {
  294. margin: 0;
  295. font-size: 12px;
  296. font-weight: 700;
  297. letter-spacing: 0.03em;
  298. color: #64748b;
  299. }
  300. .col-demand .col-title {
  301. color: #1d4ed8;
  302. }
  303. .col-node .col-title {
  304. color: #334155;
  305. }
  306. .col-video .col-title {
  307. color: #15803d;
  308. }
  309. .col-topic .col-title {
  310. color: #7e22ce;
  311. }
  312. .close-btn {
  313. width: 22px;
  314. height: 22px;
  315. border: none;
  316. border-radius: 4px;
  317. background: transparent;
  318. color: #94a3b8;
  319. font-size: 16px;
  320. line-height: 1;
  321. cursor: pointer;
  322. flex-shrink: 0;
  323. padding: 0;
  324. }
  325. .close-btn:hover {
  326. background: #f1f5f9;
  327. color: #0f172a;
  328. }
  329. .card-list {
  330. display: flex;
  331. flex-direction: column;
  332. gap: 8px;
  333. max-height: 420px;
  334. overflow: auto;
  335. padding-right: 2px;
  336. }
  337. .card {
  338. display: flex;
  339. flex-direction: column;
  340. align-items: flex-start;
  341. gap: 3px;
  342. width: 100%;
  343. padding: 10px 12px;
  344. border-radius: 10px;
  345. border: 1.5px solid transparent;
  346. background: #fff;
  347. text-align: left;
  348. cursor: pointer;
  349. box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
  350. transition: border-color 0.15s ease, box-shadow 0.15s ease;
  351. }
  352. .card:hover {
  353. box-shadow: 0 3px 10px rgba(15, 23, 42, 0.08);
  354. }
  355. .card-label {
  356. font-size: 11px;
  357. font-weight: 600;
  358. opacity: 0.8;
  359. }
  360. .card-value {
  361. font-size: 13px;
  362. font-weight: 600;
  363. line-height: 1.35;
  364. word-break: break-word;
  365. color: #0f172a;
  366. }
  367. .card-path {
  368. margin-top: 4px;
  369. font-size: 11px;
  370. color: #94a3b8;
  371. line-height: 1.35;
  372. word-break: break-word;
  373. }
  374. .node-card {
  375. border-color: #cbd5e1;
  376. background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
  377. cursor: default;
  378. }
  379. .node-card .card-label {
  380. color: #475569;
  381. }
  382. .demand-card {
  383. border-color: #93c5fd;
  384. background: linear-gradient(180deg, #eff6ff 0%, #fff 100%);
  385. }
  386. .demand-card-head {
  387. display: flex;
  388. align-items: center;
  389. justify-content: space-between;
  390. width: 100%;
  391. gap: 8px;
  392. }
  393. .grade-badge {
  394. display: inline-flex;
  395. align-items: center;
  396. justify-content: center;
  397. min-width: 22px;
  398. height: 20px;
  399. padding: 0 6px;
  400. border-radius: 6px;
  401. font-size: 11px;
  402. font-weight: 700;
  403. letter-spacing: 0.02em;
  404. flex-shrink: 0;
  405. }
  406. .grade-badge.grade-S {
  407. background: #fee2e2;
  408. color: #b91c1c;
  409. }
  410. .grade-badge.grade-A {
  411. background: #ffedd5;
  412. color: #c2410c;
  413. }
  414. .grade-badge.grade-B {
  415. background: #fef9c3;
  416. color: #a16207;
  417. }
  418. .grade-badge.grade-C {
  419. background: #e0f2fe;
  420. color: #0369a1;
  421. }
  422. .grade-badge.grade-D {
  423. background: #f1f5f9;
  424. color: #64748b;
  425. }
  426. .grade-badge.grade-none {
  427. background: #f1f5f9;
  428. color: #94a3b8;
  429. }
  430. .strategy-tags {
  431. display: flex;
  432. flex-wrap: wrap;
  433. gap: 4px;
  434. margin-top: 2px;
  435. }
  436. .strategy-tag {
  437. display: inline-flex;
  438. align-items: center;
  439. height: 18px;
  440. padding: 0 6px;
  441. border-radius: 999px;
  442. background: rgba(99, 102, 241, 0.12);
  443. color: #4338ca;
  444. font-size: 10px;
  445. font-weight: 600;
  446. white-space: nowrap;
  447. }
  448. .detail-meta {
  449. display: flex;
  450. flex-wrap: wrap;
  451. align-items: center;
  452. gap: 8px;
  453. margin-bottom: 6px;
  454. }
  455. .demand-card .card-label {
  456. color: #1d4ed8;
  457. }
  458. .demand-card.active {
  459. border-color: #2563eb;
  460. box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.16);
  461. }
  462. .video-card {
  463. border-color: #86efac;
  464. background: linear-gradient(180deg, #f0fdf4 0%, #fff 100%);
  465. }
  466. .video-card .card-label {
  467. color: #15803d;
  468. font-variant-numeric: tabular-nums;
  469. }
  470. .video-card.active {
  471. border-color: #16a34a;
  472. box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.16);
  473. }
  474. .arrow-col {
  475. width: 40px;
  476. flex-shrink: 0;
  477. display: flex;
  478. align-items: flex-start;
  479. justify-content: center;
  480. padding-top: 42px;
  481. }
  482. .arrow {
  483. display: flex;
  484. align-items: center;
  485. width: 100%;
  486. }
  487. .arrow-line {
  488. flex: 1;
  489. height: 3px;
  490. border-radius: 999px;
  491. }
  492. .arrow-head {
  493. font-size: 11px;
  494. line-height: 1;
  495. margin-left: -1px;
  496. }
  497. .arrow-blue .arrow-line {
  498. background: #3b82f6;
  499. }
  500. .arrow-blue .arrow-head {
  501. color: #3b82f6;
  502. }
  503. .arrow-slate .arrow-line {
  504. background: #94a3b8;
  505. }
  506. .arrow-slate .arrow-head {
  507. color: #94a3b8;
  508. }
  509. .arrow-green .arrow-line {
  510. background: #22c55e;
  511. }
  512. .arrow-green .arrow-head {
  513. color: #22c55e;
  514. }
  515. .arrow-purple .arrow-line {
  516. background: #a855f7;
  517. }
  518. .arrow-purple .arrow-head {
  519. color: #a855f7;
  520. }
  521. .topic-card {
  522. border: 1.5px solid #d8b4fe;
  523. border-radius: 12px;
  524. background: linear-gradient(180deg, #faf5ff 0%, #fff 45%);
  525. overflow: hidden;
  526. max-height: 520px;
  527. display: flex;
  528. flex-direction: column;
  529. }
  530. .topic-meta {
  531. padding: 8px 12px;
  532. border-bottom: 1px solid #f3e8ff;
  533. font-size: 11px;
  534. font-weight: 600;
  535. color: #7e22ce;
  536. font-variant-numeric: tabular-nums;
  537. flex-shrink: 0;
  538. }
  539. .topic-body {
  540. padding: 10px 12px 12px;
  541. overflow: auto;
  542. display: flex;
  543. flex-direction: column;
  544. gap: 14px;
  545. }
  546. .topic-section {
  547. display: flex;
  548. flex-direction: column;
  549. gap: 8px;
  550. }
  551. .section-head {
  552. display: flex;
  553. align-items: center;
  554. gap: 8px;
  555. }
  556. .section-label {
  557. font-size: 12px;
  558. font-weight: 700;
  559. letter-spacing: 0.02em;
  560. }
  561. .section-count {
  562. min-width: 18px;
  563. height: 18px;
  564. padding: 0 5px;
  565. border-radius: 999px;
  566. font-size: 10px;
  567. font-weight: 700;
  568. line-height: 18px;
  569. text-align: center;
  570. font-variant-numeric: tabular-nums;
  571. }
  572. .accent-inspiration .section-label {
  573. color: #c2410c;
  574. }
  575. .accent-inspiration .section-count {
  576. color: #c2410c;
  577. background: #ffedd5;
  578. }
  579. .accent-inspiration .point-index {
  580. background: #ffedd5;
  581. color: #c2410c;
  582. }
  583. .accent-purpose .section-label {
  584. color: #1d4ed8;
  585. }
  586. .accent-purpose .section-count {
  587. color: #1d4ed8;
  588. background: #dbeafe;
  589. }
  590. .accent-purpose .point-index {
  591. background: #dbeafe;
  592. color: #1d4ed8;
  593. }
  594. .accent-key .section-label {
  595. color: #7e22ce;
  596. }
  597. .accent-key .section-count {
  598. color: #7e22ce;
  599. background: #f3e8ff;
  600. }
  601. .accent-key .point-index {
  602. background: #f3e8ff;
  603. color: #7e22ce;
  604. }
  605. .point-list {
  606. display: flex;
  607. flex-direction: column;
  608. gap: 8px;
  609. }
  610. .point-item {
  611. padding: 10px 11px;
  612. border-radius: 10px;
  613. background: #fff;
  614. border: 1px solid #e2e8f0;
  615. box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
  616. }
  617. .point-title-row {
  618. display: flex;
  619. align-items: flex-start;
  620. gap: 8px;
  621. }
  622. .point-index {
  623. flex-shrink: 0;
  624. width: 18px;
  625. height: 18px;
  626. margin-top: 1px;
  627. border-radius: 5px;
  628. font-size: 10px;
  629. font-weight: 700;
  630. line-height: 18px;
  631. text-align: center;
  632. font-variant-numeric: tabular-nums;
  633. }
  634. .point-title {
  635. margin: 0;
  636. font-size: 13px;
  637. font-weight: 700;
  638. line-height: 1.35;
  639. color: #0f172a;
  640. word-break: break-word;
  641. }
  642. .point-desc {
  643. margin: 6px 0 0;
  644. padding-left: 26px;
  645. font-size: 12px;
  646. line-height: 1.55;
  647. color: #475569;
  648. white-space: pre-wrap;
  649. word-break: break-word;
  650. }
  651. .section-empty {
  652. padding: 8px 10px;
  653. font-size: 12px;
  654. color: #94a3b8;
  655. border: 1px dashed #e2e8f0;
  656. border-radius: 8px;
  657. background: rgba(255, 255, 255, 0.6);
  658. }
  659. .empty {
  660. padding: 20px 10px;
  661. text-align: center;
  662. color: #94a3b8;
  663. font-size: 12px;
  664. border: 1px dashed #e2e8f0;
  665. border-radius: 10px;
  666. background: rgba(255, 255, 255, 0.7);
  667. }
  668. .empty.hint {
  669. border-style: solid;
  670. background: transparent;
  671. }
  672. .empty.error {
  673. color: #b91c1c;
  674. border-color: #fecaca;
  675. background: #fef2f2;
  676. }
  677. </style>