DemandFeedbackDrawer.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  1. <script setup lang="ts">
  2. import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
  3. import {
  4. createDemandFeedback,
  5. fetchDemandFeedback,
  6. } from '../api/videoDiscovery'
  7. import type {
  8. DemandFeedbackItem,
  9. DemandFeedbackTarget,
  10. FeedbackAction,
  11. FeedbackSummary,
  12. } from '../types/videoDiscovery'
  13. const props = defineProps<{
  14. open: boolean
  15. target: DemandFeedbackTarget | null
  16. }>()
  17. const emit = defineEmits<{
  18. close: []
  19. submitted: [summary: FeedbackSummary]
  20. }>()
  21. const ACTIONS: Array<{ value: FeedbackAction; label: string }> = [
  22. { value: 'support', label: '认可' },
  23. { value: 'oppose', label: '不认可' },
  24. { value: 'correct', label: '纠错' },
  25. { value: 'supplement', label: '补充' },
  26. ]
  27. const REASONS: Record<string, Array<{ value: string; label: string }>> = {
  28. demand: [
  29. { value: 'not_a_demand', label: '需求不成立' },
  30. { value: 'wrong_grade', label: '等级不合适' },
  31. { value: 'wrong_category', label: '分类不准确' },
  32. { value: 'unclear_wording', label: '需求表述不清' },
  33. { value: 'wrong_reason', label: '判断原因不准确' },
  34. { value: 'missing_demand', label: '缺少需求' },
  35. { value: 'other', label: '其他' },
  36. ],
  37. video: [
  38. { value: 'irrelevant', label: '与需求不相关' },
  39. { value: 'weak_relevance', label: '相关性弱' },
  40. { value: 'unavailable', label: '视频失效' },
  41. { value: 'duplicate', label: '视频重复' },
  42. { value: 'wrong_info', label: '标题或信息错误' },
  43. { value: 'missing_video', label: '缺少相关视频' },
  44. { value: 'other', label: '其他' },
  45. ],
  46. hit_content: [
  47. { value: 'not_hit', label: '内容未命中' },
  48. { value: 'wrong_point_type', label: '点位类型错误' },
  49. { value: 'inaccurate_wording', label: '表述不准确' },
  50. { value: 'wrong_reason', label: '命中原因不准确' },
  51. { value: 'duplicate', label: '内容重复' },
  52. { value: 'missing_content', label: '缺少命中内容' },
  53. { value: 'other', label: '其他' },
  54. ],
  55. }
  56. const action = ref<FeedbackAction | null>(null)
  57. const reasonCode = ref('')
  58. const content = ref('')
  59. const submitting = ref(false)
  60. const submitError = ref<string | null>(null)
  61. const history = ref<DemandFeedbackItem[]>([])
  62. const historyTotal = ref(0)
  63. const historyLoading = ref(false)
  64. const historyError = ref<string | null>(null)
  65. const targetLabel = computed(() => {
  66. if (props.target?.target_type === 'demand') return '需求'
  67. if (props.target?.target_type === 'video') return '视频'
  68. return '命中内容'
  69. })
  70. const reasonOptions = computed(() => REASONS[props.target?.target_type ?? 'demand'])
  71. const dirty = computed(() => Boolean(action.value || reasonCode.value || content.value.trim()))
  72. const canSubmit = computed(() => {
  73. if (!action.value || !props.target || submitting.value) return false
  74. if (action.value === 'oppose' && !reasonCode.value) return false
  75. if (
  76. (action.value === 'correct' || action.value === 'supplement')
  77. && !content.value.trim()
  78. ) return false
  79. return true
  80. })
  81. watch(
  82. [() => props.open, () => props.target],
  83. ([open]) => {
  84. if (!open || !props.target) return
  85. action.value = null
  86. reasonCode.value = ''
  87. content.value = ''
  88. submitError.value = null
  89. void loadHistory()
  90. },
  91. )
  92. watch(action, (value) => {
  93. if (value === 'support') reasonCode.value = ''
  94. })
  95. async function loadHistory() {
  96. if (!props.target) return
  97. historyLoading.value = true
  98. historyError.value = null
  99. try {
  100. const response = await fetchDemandFeedback(props.target)
  101. history.value = response.items ?? []
  102. historyTotal.value = response.total ?? 0
  103. } catch (error) {
  104. historyError.value = error instanceof Error ? error.message : String(error)
  105. } finally {
  106. historyLoading.value = false
  107. }
  108. }
  109. function requestId(): string {
  110. if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
  111. return `${Date.now()}-${Math.random().toString(16).slice(2)}`
  112. }
  113. async function submit() {
  114. if (!canSubmit.value || !props.target || !action.value) return
  115. submitting.value = true
  116. submitError.value = null
  117. try {
  118. const response = await createDemandFeedback({
  119. client_request_id: requestId(),
  120. target_type: props.target.target_type,
  121. demand_grade_id: props.target.demand_grade_id,
  122. video_id: props.target.video_id,
  123. demand_video_expansion_id: props.target.demand_video_expansion_id,
  124. feedback_action: action.value,
  125. reason_code: reasonCode.value || undefined,
  126. content: content.value.trim() || undefined,
  127. })
  128. emit('submitted', response.feedback_summary)
  129. emit('close')
  130. } catch (error) {
  131. submitError.value = error instanceof Error ? error.message : String(error)
  132. } finally {
  133. submitting.value = false
  134. }
  135. }
  136. function close() {
  137. if (submitting.value) return
  138. if (dirty.value && !window.confirm('反馈尚未提交,确定关闭吗?')) return
  139. emit('close')
  140. }
  141. function onKeydown(event: KeyboardEvent) {
  142. if (event.key === 'Escape' && props.open) close()
  143. }
  144. function actionLabel(value: FeedbackAction): string {
  145. return ACTIONS.find((item) => item.value === value)?.label ?? value
  146. }
  147. function reasonLabel(value: string | null): string {
  148. if (!value) return ''
  149. return reasonOptions.value.find((item) => item.value === value)?.label ?? value
  150. }
  151. function formatTime(value: string | null): string {
  152. if (!value) return '—'
  153. const date = new Date(value)
  154. if (Number.isNaN(date.getTime())) return value
  155. return new Intl.DateTimeFormat('zh-CN', {
  156. month: '2-digit',
  157. day: '2-digit',
  158. hour: '2-digit',
  159. minute: '2-digit',
  160. }).format(date)
  161. }
  162. onMounted(() => window.addEventListener('keydown', onKeydown))
  163. onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
  164. </script>
  165. <template>
  166. <Teleport to="body">
  167. <Transition name="feedback-fade">
  168. <div v-if="open && target" class="feedback-overlay" @click.self="close">
  169. <aside class="feedback-drawer" role="dialog" aria-modal="true" :aria-label="`${targetLabel}反馈`">
  170. <header class="drawer-head">
  171. <div>
  172. <span>反馈 · {{ targetLabel }}</span>
  173. <h2>{{ target.title }}</h2>
  174. </div>
  175. <button type="button" aria-label="关闭反馈" @click="close">×</button>
  176. </header>
  177. <div class="drawer-body">
  178. <section class="target-context" aria-label="反馈对象">
  179. <div v-for="item in target.context" :key="item.label">
  180. <span>{{ item.label }}</span>
  181. <strong>{{ item.value }}</strong>
  182. </div>
  183. </section>
  184. <section class="form-section">
  185. <div class="section-title">
  186. <strong>你的判断</strong>
  187. <span>必填</span>
  188. </div>
  189. <div class="action-options">
  190. <button
  191. v-for="item in ACTIONS"
  192. :key="item.value"
  193. type="button"
  194. :class="{ active: action === item.value }"
  195. @click="action = item.value"
  196. >
  197. {{ item.label }}
  198. </button>
  199. </div>
  200. </section>
  201. <section v-if="action && action !== 'support'" class="form-section">
  202. <div class="section-title">
  203. <strong>问题原因</strong>
  204. <span>{{ action === 'oppose' ? '必填' : '选填' }}</span>
  205. </div>
  206. <div class="reason-options">
  207. <button
  208. v-for="item in reasonOptions"
  209. :key="item.value"
  210. type="button"
  211. :class="{ active: reasonCode === item.value }"
  212. @click="reasonCode = reasonCode === item.value ? '' : item.value"
  213. >
  214. {{ item.label }}
  215. </button>
  216. </div>
  217. </section>
  218. <section class="form-section">
  219. <div class="section-title">
  220. <strong>补充说明</strong>
  221. <span>{{ action === 'correct' || action === 'supplement' ? '必填' : '选填' }}</span>
  222. </div>
  223. <textarea
  224. v-model="content"
  225. maxlength="1000"
  226. rows="4"
  227. placeholder="请说明判断依据或建议的正确内容"
  228. />
  229. <div class="text-count">{{ content.length }} / 1000</div>
  230. </section>
  231. <p v-if="submitError" class="feedback-error">{{ submitError }}</p>
  232. <section class="history-section">
  233. <div class="section-title">
  234. <strong>反馈记录</strong>
  235. <span>{{ historyTotal }} 条</span>
  236. </div>
  237. <div v-if="historyLoading" class="history-state">正在加载反馈记录…</div>
  238. <div v-else-if="historyError" class="history-state error">{{ historyError }}</div>
  239. <div v-else-if="history.length" class="history-list">
  240. <article v-for="item in history" :key="item.id">
  241. <header>
  242. <strong>
  243. {{ item.feedback_user.display_name }}
  244. <small>@{{ item.feedback_user.username }}</small>
  245. </strong>
  246. <time>{{ formatTime(item.created_at) }}</time>
  247. </header>
  248. <div class="history-tags">
  249. <span>{{ actionLabel(item.feedback_action) }}</span>
  250. <span v-if="item.reason_code">{{ reasonLabel(item.reason_code) }}</span>
  251. </div>
  252. <p v-if="item.content">{{ item.content }}</p>
  253. </article>
  254. </div>
  255. <div v-else class="history-state">暂无反馈记录</div>
  256. </section>
  257. </div>
  258. <footer class="drawer-actions">
  259. <button type="button" class="cancel" @click="close">取消</button>
  260. <button type="button" class="submit" :disabled="!canSubmit" @click="submit">
  261. {{ submitting ? '提交中…' : '提交反馈' }}
  262. </button>
  263. </footer>
  264. </aside>
  265. </div>
  266. </Transition>
  267. </Teleport>
  268. </template>
  269. <style scoped>
  270. .feedback-overlay {
  271. position: fixed;
  272. z-index: 1000;
  273. inset: 0;
  274. display: flex;
  275. justify-content: flex-end;
  276. background: rgba(18, 28, 22, 0.34);
  277. backdrop-filter: blur(2px);
  278. }
  279. .feedback-drawer {
  280. width: min(470px, 100vw);
  281. height: 100%;
  282. display: flex;
  283. flex-direction: column;
  284. background: #fff;
  285. color: #17211b;
  286. box-shadow: -18px 0 48px rgba(18, 38, 25, 0.16);
  287. }
  288. .drawer-head {
  289. min-height: 82px;
  290. display: flex;
  291. align-items: flex-start;
  292. justify-content: space-between;
  293. gap: 16px;
  294. padding: 18px 20px 15px;
  295. border-bottom: 1px solid #e5eae6;
  296. }
  297. .drawer-head span {
  298. color: #397354;
  299. font-size: 10px;
  300. font-weight: 800;
  301. letter-spacing: 0.12em;
  302. }
  303. .drawer-head h2 {
  304. margin: 5px 0 0;
  305. font-family: Georgia, 'Songti SC', serif;
  306. font-size: 18px;
  307. line-height: 1.35;
  308. }
  309. .drawer-head button {
  310. width: 30px;
  311. height: 30px;
  312. flex: 0 0 auto;
  313. border: 1px solid #dce3dd;
  314. border-radius: 8px;
  315. background: #f8faf8;
  316. color: #68736b;
  317. font-size: 19px;
  318. cursor: pointer;
  319. }
  320. .drawer-body {
  321. flex: 1;
  322. min-height: 0;
  323. overflow-y: auto;
  324. padding: 16px 20px 24px;
  325. }
  326. .target-context {
  327. display: grid;
  328. gap: 7px;
  329. padding: 12px 13px;
  330. border: 1px solid #dce6de;
  331. border-radius: 10px;
  332. background: #f4f8f5;
  333. }
  334. .target-context div {
  335. display: grid;
  336. grid-template-columns: 64px minmax(0, 1fr);
  337. gap: 8px;
  338. }
  339. .target-context span {
  340. color: #829087;
  341. font-size: 10px;
  342. }
  343. .target-context strong {
  344. overflow-wrap: anywhere;
  345. color: #334239;
  346. font-size: 11px;
  347. line-height: 1.45;
  348. }
  349. .form-section,
  350. .history-section {
  351. margin-top: 18px;
  352. }
  353. .section-title {
  354. display: flex;
  355. align-items: center;
  356. justify-content: space-between;
  357. margin-bottom: 9px;
  358. }
  359. .section-title strong {
  360. font-size: 12px;
  361. }
  362. .section-title span {
  363. color: #8a958d;
  364. font-size: 9px;
  365. }
  366. .action-options {
  367. display: grid;
  368. grid-template-columns: repeat(4, minmax(0, 1fr));
  369. gap: 7px;
  370. }
  371. .action-options button,
  372. .reason-options button {
  373. border: 1px solid #d8dfd9;
  374. border-radius: 8px;
  375. background: #fff;
  376. color: #58645c;
  377. font-size: 11px;
  378. cursor: pointer;
  379. }
  380. .action-options button {
  381. height: 36px;
  382. font-weight: 700;
  383. }
  384. .reason-options {
  385. display: flex;
  386. flex-wrap: wrap;
  387. gap: 7px;
  388. }
  389. .reason-options button {
  390. min-height: 30px;
  391. padding: 5px 9px;
  392. }
  393. .action-options button:hover,
  394. .reason-options button:hover,
  395. .action-options button.active,
  396. .reason-options button.active {
  397. border-color: #3d7656;
  398. background: #e9f2ec;
  399. color: #275b40;
  400. }
  401. textarea {
  402. width: 100%;
  403. box-sizing: border-box;
  404. resize: vertical;
  405. padding: 10px 11px;
  406. border: 1px solid #d8dfd9;
  407. border-radius: 9px;
  408. outline: none;
  409. color: #26332a;
  410. font: inherit;
  411. font-size: 12px;
  412. line-height: 1.55;
  413. }
  414. textarea:focus {
  415. border-color: #6b957c;
  416. box-shadow: 0 0 0 3px rgba(53, 110, 78, 0.1);
  417. }
  418. .text-count {
  419. margin-top: 4px;
  420. color: #98a19a;
  421. font-size: 9px;
  422. text-align: right;
  423. }
  424. .feedback-error {
  425. margin: 12px 0 0;
  426. padding: 9px 10px;
  427. border-radius: 8px;
  428. background: #fff0ec;
  429. color: #a8462c;
  430. font-size: 11px;
  431. }
  432. .history-section {
  433. padding-top: 17px;
  434. border-top: 1px solid #e8ece9;
  435. }
  436. .history-list {
  437. display: grid;
  438. gap: 8px;
  439. }
  440. .history-list article {
  441. padding: 10px 11px;
  442. border: 1px solid #e1e6e2;
  443. border-radius: 9px;
  444. background: #fbfcfb;
  445. }
  446. .history-list header {
  447. display: flex;
  448. justify-content: space-between;
  449. gap: 8px;
  450. }
  451. .history-list header strong {
  452. font-size: 11px;
  453. }
  454. .history-list header small {
  455. margin-left: 5px;
  456. color: #8b958e;
  457. font-size: 9px;
  458. font-weight: 500;
  459. }
  460. .history-list time {
  461. color: #919a93;
  462. font-size: 9px;
  463. }
  464. .history-tags {
  465. display: flex;
  466. gap: 5px;
  467. margin-top: 7px;
  468. }
  469. .history-tags span {
  470. padding: 2px 6px;
  471. border-radius: 999px;
  472. background: #e7efe9;
  473. color: #39604a;
  474. font-size: 9px;
  475. font-weight: 700;
  476. }
  477. .history-list p {
  478. margin: 7px 0 0;
  479. color: #5f6b63;
  480. font-size: 11px;
  481. line-height: 1.5;
  482. white-space: pre-wrap;
  483. }
  484. .history-state {
  485. padding: 12px;
  486. border-radius: 8px;
  487. background: #f7f9f7;
  488. color: #89928b;
  489. font-size: 10px;
  490. text-align: center;
  491. }
  492. .history-state.error {
  493. color: #a8462c;
  494. }
  495. .drawer-actions {
  496. display: flex;
  497. justify-content: flex-end;
  498. gap: 9px;
  499. padding: 13px 20px;
  500. border-top: 1px solid #e3e8e4;
  501. background: #fbfcfb;
  502. }
  503. .drawer-actions button {
  504. height: 36px;
  505. padding: 0 16px;
  506. border-radius: 8px;
  507. font-size: 11px;
  508. font-weight: 800;
  509. cursor: pointer;
  510. }
  511. .drawer-actions .cancel {
  512. border: 1px solid #d4dcd6;
  513. background: #fff;
  514. color: #68736b;
  515. }
  516. .drawer-actions .submit {
  517. border: 1px solid #315f47;
  518. background: #315f47;
  519. color: #fff;
  520. }
  521. .drawer-actions .submit:disabled {
  522. border-color: #cdd5cf;
  523. background: #cdd5cf;
  524. cursor: not-allowed;
  525. }
  526. .feedback-fade-enter-active,
  527. .feedback-fade-leave-active {
  528. transition: opacity 0.18s ease;
  529. }
  530. .feedback-fade-enter-active .feedback-drawer,
  531. .feedback-fade-leave-active .feedback-drawer {
  532. transition: transform 0.18s ease;
  533. }
  534. .feedback-fade-enter-from,
  535. .feedback-fade-leave-to {
  536. opacity: 0;
  537. }
  538. .feedback-fade-enter-from .feedback-drawer,
  539. .feedback-fade-leave-to .feedback-drawer {
  540. transform: translateX(24px);
  541. }
  542. @media (max-width: 640px) {
  543. .feedback-drawer {
  544. width: 100%;
  545. }
  546. }
  547. </style>