| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620 |
- <script setup lang="ts">
- import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
- import {
- createDemandFeedback,
- fetchDemandFeedback,
- } from '../api/videoDiscovery'
- import type {
- DemandFeedbackItem,
- DemandFeedbackTarget,
- FeedbackAction,
- FeedbackSummary,
- } from '../types/videoDiscovery'
- const props = defineProps<{
- open: boolean
- target: DemandFeedbackTarget | null
- }>()
- const emit = defineEmits<{
- close: []
- submitted: [summary: FeedbackSummary]
- }>()
- const ACTIONS: Array<{ value: FeedbackAction; label: string }> = [
- { value: 'support', label: '认可' },
- { value: 'oppose', label: '不认可' },
- { value: 'correct', label: '纠错' },
- { value: 'supplement', label: '补充' },
- ]
- const REASONS: Record<string, Array<{ value: string; label: string }>> = {
- demand: [
- { value: 'not_a_demand', label: '需求不成立' },
- { value: 'wrong_grade', label: '等级不合适' },
- { value: 'wrong_category', label: '分类不准确' },
- { value: 'unclear_wording', label: '需求表述不清' },
- { value: 'wrong_reason', label: '判断原因不准确' },
- { value: 'missing_demand', label: '缺少需求' },
- { value: 'other', label: '其他' },
- ],
- video: [
- { value: 'irrelevant', label: '与需求不相关' },
- { value: 'weak_relevance', label: '相关性弱' },
- { value: 'unavailable', label: '视频失效' },
- { value: 'duplicate', label: '视频重复' },
- { value: 'wrong_info', label: '标题或信息错误' },
- { value: 'missing_video', label: '缺少相关视频' },
- { value: 'other', label: '其他' },
- ],
- hit_content: [
- { value: 'not_hit', label: '内容未命中' },
- { value: 'wrong_point_type', label: '点位类型错误' },
- { value: 'inaccurate_wording', label: '表述不准确' },
- { value: 'wrong_reason', label: '命中原因不准确' },
- { value: 'duplicate', label: '内容重复' },
- { value: 'missing_content', label: '缺少命中内容' },
- { value: 'other', label: '其他' },
- ],
- }
- const action = ref<FeedbackAction | null>(null)
- const reasonCode = ref('')
- const content = ref('')
- const submitting = ref(false)
- const submitError = ref<string | null>(null)
- const history = ref<DemandFeedbackItem[]>([])
- const historyTotal = ref(0)
- const historyLoading = ref(false)
- const historyError = ref<string | null>(null)
- const targetLabel = computed(() => {
- if (props.target?.target_type === 'demand') return '需求'
- if (props.target?.target_type === 'video') return '视频'
- return '命中内容'
- })
- const reasonOptions = computed(() => REASONS[props.target?.target_type ?? 'demand'])
- const dirty = computed(() => Boolean(action.value || reasonCode.value || content.value.trim()))
- const canSubmit = computed(() => {
- if (!action.value || !props.target || submitting.value) return false
- if (action.value === 'oppose' && !reasonCode.value) return false
- if (
- (action.value === 'correct' || action.value === 'supplement')
- && !content.value.trim()
- ) return false
- return true
- })
- watch(
- [() => props.open, () => props.target],
- ([open]) => {
- if (!open || !props.target) return
- action.value = null
- reasonCode.value = ''
- content.value = ''
- submitError.value = null
- void loadHistory()
- },
- )
- watch(action, (value) => {
- if (value === 'support') reasonCode.value = ''
- })
- async function loadHistory() {
- if (!props.target) return
- historyLoading.value = true
- historyError.value = null
- try {
- const response = await fetchDemandFeedback(props.target)
- history.value = response.items ?? []
- historyTotal.value = response.total ?? 0
- } catch (error) {
- historyError.value = error instanceof Error ? error.message : String(error)
- } finally {
- historyLoading.value = false
- }
- }
- function requestId(): string {
- if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
- return `${Date.now()}-${Math.random().toString(16).slice(2)}`
- }
- async function submit() {
- if (!canSubmit.value || !props.target || !action.value) return
- submitting.value = true
- submitError.value = null
- try {
- const response = await createDemandFeedback({
- client_request_id: requestId(),
- target_type: props.target.target_type,
- demand_grade_id: props.target.demand_grade_id,
- video_id: props.target.video_id,
- demand_video_expansion_id: props.target.demand_video_expansion_id,
- feedback_action: action.value,
- reason_code: reasonCode.value || undefined,
- content: content.value.trim() || undefined,
- })
- emit('submitted', response.feedback_summary)
- emit('close')
- } catch (error) {
- submitError.value = error instanceof Error ? error.message : String(error)
- } finally {
- submitting.value = false
- }
- }
- function close() {
- if (submitting.value) return
- if (dirty.value && !window.confirm('反馈尚未提交,确定关闭吗?')) return
- emit('close')
- }
- function onKeydown(event: KeyboardEvent) {
- if (event.key === 'Escape' && props.open) close()
- }
- function actionLabel(value: FeedbackAction): string {
- return ACTIONS.find((item) => item.value === value)?.label ?? value
- }
- function reasonLabel(value: string | null): string {
- if (!value) return ''
- return reasonOptions.value.find((item) => item.value === value)?.label ?? value
- }
- function formatTime(value: string | null): string {
- if (!value) return '—'
- const date = new Date(value)
- if (Number.isNaN(date.getTime())) return value
- return new Intl.DateTimeFormat('zh-CN', {
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- }).format(date)
- }
- onMounted(() => window.addEventListener('keydown', onKeydown))
- onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
- </script>
- <template>
- <Teleport to="body">
- <Transition name="feedback-fade">
- <div v-if="open && target" class="feedback-overlay" @click.self="close">
- <aside class="feedback-drawer" role="dialog" aria-modal="true" :aria-label="`${targetLabel}反馈`">
- <header class="drawer-head">
- <div>
- <span>反馈 · {{ targetLabel }}</span>
- <h2>{{ target.title }}</h2>
- </div>
- <button type="button" aria-label="关闭反馈" @click="close">×</button>
- </header>
- <div class="drawer-body">
- <section class="target-context" aria-label="反馈对象">
- <div v-for="item in target.context" :key="item.label">
- <span>{{ item.label }}</span>
- <strong>{{ item.value }}</strong>
- </div>
- </section>
- <section class="form-section">
- <div class="section-title">
- <strong>你的判断</strong>
- <span>必填</span>
- </div>
- <div class="action-options">
- <button
- v-for="item in ACTIONS"
- :key="item.value"
- type="button"
- :class="{ active: action === item.value }"
- @click="action = item.value"
- >
- {{ item.label }}
- </button>
- </div>
- </section>
- <section v-if="action && action !== 'support'" class="form-section">
- <div class="section-title">
- <strong>问题原因</strong>
- <span>{{ action === 'oppose' ? '必填' : '选填' }}</span>
- </div>
- <div class="reason-options">
- <button
- v-for="item in reasonOptions"
- :key="item.value"
- type="button"
- :class="{ active: reasonCode === item.value }"
- @click="reasonCode = reasonCode === item.value ? '' : item.value"
- >
- {{ item.label }}
- </button>
- </div>
- </section>
- <section class="form-section">
- <div class="section-title">
- <strong>补充说明</strong>
- <span>{{ action === 'correct' || action === 'supplement' ? '必填' : '选填' }}</span>
- </div>
- <textarea
- v-model="content"
- maxlength="1000"
- rows="4"
- placeholder="请说明判断依据或建议的正确内容"
- />
- <div class="text-count">{{ content.length }} / 1000</div>
- </section>
- <p v-if="submitError" class="feedback-error">{{ submitError }}</p>
- <section class="history-section">
- <div class="section-title">
- <strong>反馈记录</strong>
- <span>{{ historyTotal }} 条</span>
- </div>
- <div v-if="historyLoading" class="history-state">正在加载反馈记录…</div>
- <div v-else-if="historyError" class="history-state error">{{ historyError }}</div>
- <div v-else-if="history.length" class="history-list">
- <article v-for="item in history" :key="item.id">
- <header>
- <strong>
- {{ item.feedback_user.display_name }}
- <small>@{{ item.feedback_user.username }}</small>
- </strong>
- <time>{{ formatTime(item.created_at) }}</time>
- </header>
- <div class="history-tags">
- <span>{{ actionLabel(item.feedback_action) }}</span>
- <span v-if="item.reason_code">{{ reasonLabel(item.reason_code) }}</span>
- </div>
- <p v-if="item.content">{{ item.content }}</p>
- </article>
- </div>
- <div v-else class="history-state">暂无反馈记录</div>
- </section>
- </div>
- <footer class="drawer-actions">
- <button type="button" class="cancel" @click="close">取消</button>
- <button type="button" class="submit" :disabled="!canSubmit" @click="submit">
- {{ submitting ? '提交中…' : '提交反馈' }}
- </button>
- </footer>
- </aside>
- </div>
- </Transition>
- </Teleport>
- </template>
- <style scoped>
- .feedback-overlay {
- position: fixed;
- z-index: 1000;
- inset: 0;
- display: flex;
- justify-content: flex-end;
- background: rgba(18, 28, 22, 0.34);
- backdrop-filter: blur(2px);
- }
- .feedback-drawer {
- width: min(470px, 100vw);
- height: 100%;
- display: flex;
- flex-direction: column;
- background: #fff;
- color: #17211b;
- box-shadow: -18px 0 48px rgba(18, 38, 25, 0.16);
- }
- .drawer-head {
- min-height: 82px;
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 16px;
- padding: 18px 20px 15px;
- border-bottom: 1px solid #e5eae6;
- }
- .drawer-head span {
- color: #397354;
- font-size: 10px;
- font-weight: 800;
- letter-spacing: 0.12em;
- }
- .drawer-head h2 {
- margin: 5px 0 0;
- font-family: Georgia, 'Songti SC', serif;
- font-size: 18px;
- line-height: 1.35;
- }
- .drawer-head button {
- width: 30px;
- height: 30px;
- flex: 0 0 auto;
- border: 1px solid #dce3dd;
- border-radius: 8px;
- background: #f8faf8;
- color: #68736b;
- font-size: 19px;
- cursor: pointer;
- }
- .drawer-body {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- padding: 16px 20px 24px;
- }
- .target-context {
- display: grid;
- gap: 7px;
- padding: 12px 13px;
- border: 1px solid #dce6de;
- border-radius: 10px;
- background: #f4f8f5;
- }
- .target-context div {
- display: grid;
- grid-template-columns: 64px minmax(0, 1fr);
- gap: 8px;
- }
- .target-context span {
- color: #829087;
- font-size: 10px;
- }
- .target-context strong {
- overflow-wrap: anywhere;
- color: #334239;
- font-size: 11px;
- line-height: 1.45;
- }
- .form-section,
- .history-section {
- margin-top: 18px;
- }
- .section-title {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 9px;
- }
- .section-title strong {
- font-size: 12px;
- }
- .section-title span {
- color: #8a958d;
- font-size: 9px;
- }
- .action-options {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 7px;
- }
- .action-options button,
- .reason-options button {
- border: 1px solid #d8dfd9;
- border-radius: 8px;
- background: #fff;
- color: #58645c;
- font-size: 11px;
- cursor: pointer;
- }
- .action-options button {
- height: 36px;
- font-weight: 700;
- }
- .reason-options {
- display: flex;
- flex-wrap: wrap;
- gap: 7px;
- }
- .reason-options button {
- min-height: 30px;
- padding: 5px 9px;
- }
- .action-options button:hover,
- .reason-options button:hover,
- .action-options button.active,
- .reason-options button.active {
- border-color: #3d7656;
- background: #e9f2ec;
- color: #275b40;
- }
- textarea {
- width: 100%;
- box-sizing: border-box;
- resize: vertical;
- padding: 10px 11px;
- border: 1px solid #d8dfd9;
- border-radius: 9px;
- outline: none;
- color: #26332a;
- font: inherit;
- font-size: 12px;
- line-height: 1.55;
- }
- textarea:focus {
- border-color: #6b957c;
- box-shadow: 0 0 0 3px rgba(53, 110, 78, 0.1);
- }
- .text-count {
- margin-top: 4px;
- color: #98a19a;
- font-size: 9px;
- text-align: right;
- }
- .feedback-error {
- margin: 12px 0 0;
- padding: 9px 10px;
- border-radius: 8px;
- background: #fff0ec;
- color: #a8462c;
- font-size: 11px;
- }
- .history-section {
- padding-top: 17px;
- border-top: 1px solid #e8ece9;
- }
- .history-list {
- display: grid;
- gap: 8px;
- }
- .history-list article {
- padding: 10px 11px;
- border: 1px solid #e1e6e2;
- border-radius: 9px;
- background: #fbfcfb;
- }
- .history-list header {
- display: flex;
- justify-content: space-between;
- gap: 8px;
- }
- .history-list header strong {
- font-size: 11px;
- }
- .history-list header small {
- margin-left: 5px;
- color: #8b958e;
- font-size: 9px;
- font-weight: 500;
- }
- .history-list time {
- color: #919a93;
- font-size: 9px;
- }
- .history-tags {
- display: flex;
- gap: 5px;
- margin-top: 7px;
- }
- .history-tags span {
- padding: 2px 6px;
- border-radius: 999px;
- background: #e7efe9;
- color: #39604a;
- font-size: 9px;
- font-weight: 700;
- }
- .history-list p {
- margin: 7px 0 0;
- color: #5f6b63;
- font-size: 11px;
- line-height: 1.5;
- white-space: pre-wrap;
- }
- .history-state {
- padding: 12px;
- border-radius: 8px;
- background: #f7f9f7;
- color: #89928b;
- font-size: 10px;
- text-align: center;
- }
- .history-state.error {
- color: #a8462c;
- }
- .drawer-actions {
- display: flex;
- justify-content: flex-end;
- gap: 9px;
- padding: 13px 20px;
- border-top: 1px solid #e3e8e4;
- background: #fbfcfb;
- }
- .drawer-actions button {
- height: 36px;
- padding: 0 16px;
- border-radius: 8px;
- font-size: 11px;
- font-weight: 800;
- cursor: pointer;
- }
- .drawer-actions .cancel {
- border: 1px solid #d4dcd6;
- background: #fff;
- color: #68736b;
- }
- .drawer-actions .submit {
- border: 1px solid #315f47;
- background: #315f47;
- color: #fff;
- }
- .drawer-actions .submit:disabled {
- border-color: #cdd5cf;
- background: #cdd5cf;
- cursor: not-allowed;
- }
- .feedback-fade-enter-active,
- .feedback-fade-leave-active {
- transition: opacity 0.18s ease;
- }
- .feedback-fade-enter-active .feedback-drawer,
- .feedback-fade-leave-active .feedback-drawer {
- transition: transform 0.18s ease;
- }
- .feedback-fade-enter-from,
- .feedback-fade-leave-to {
- opacity: 0;
- }
- .feedback-fade-enter-from .feedback-drawer,
- .feedback-fade-leave-to .feedback-drawer {
- transform: translateX(24px);
- }
- @media (max-width: 640px) {
- .feedback-drawer {
- width: 100%;
- }
- }
- </style>
|