Files
admin/www/app/components/SupportFeedback.vue
T
eafonyang f60255574e feat(customer-service): 新增客户服务问题反馈管理功能
- 重命名语言文件中相关关键字,统一改为 customer-service_feedbacks
- 新增客户服务反馈页面路由及组件导入,移除旧的 www_support_feedbacks 相关路由
- 实现客户服务反馈列表及详情查看,支持状态筛选、分页查询和消息对话展示
- 支持反馈状态变更,包括待处理、处理中和已解决状态切换
- 支持在对话中发送回复消息,且针对已解决状态自动停止轮询
- 优化状态标签样式,采用更易区分的颜色与视觉样式
- 组件及类型定义中新增 NCollapse 和 NCollapseItem 支持
- 移除旧的 www 支持反馈组件代码,清理无用路由及类型定义相关项
2026-08-25 14:18:47 +08:00

552 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { Product, SupportFeedbackItem } from '~/types/site';
import { ApiError } from '~/composables/useRequest';
const { t, locale } = useI18n();
const { getProducts, submitSupportFeedback, getSupportFeedbacks } = useSiteApi();
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const FEEDBACK_NO_RE = /^FB\d{8}[0-9A-Z]{6}$/i;
const MAX_LOG_SIZE = 20 * 1024 * 1024; // 与后端 multer limits 一致
// ===== 型号选项:来自产品列表(可见产品,按语言取名称) =====
const products = ref<Product[]>([]);
onMounted(async () => {
try {
const data = await getProducts();
products.value = (data?.series || []).flatMap(s => s.products || []);
} catch {
products.value = [];
}
});
const productOptions = computed(() =>
products.value.map(p => ({ id: p.id, label: locale.value === 'en' ? p.name_en : p.name_zh }))
);
// ===== 提交表单状态 =====
const productId = ref('');
const description = ref('');
const email = ref('');
const logFile = ref<File | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const errors = ref<Record<string, string>>({});
const submitting = ref(false);
const notice = ref<{ ok: boolean; text?: string; feedbackNo?: string } | null>(null);
const copiedNo = ref('');
const { copy, copied, isSupported } = useClipboard();
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement;
const f = input.files?.[0] || null;
if (f && f.size > MAX_LOG_SIZE) {
notice.value = { ok: false, text: t('support.fileTooLarge') };
logFile.value = null;
input.value = '';
return;
}
logFile.value = f;
}
function formatFileSize(size: number): string {
if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
if (size >= 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${size} B`;
}
async function handleSubmit() {
if (submitting.value) return;
notice.value = null;
const errs: Record<string, string> = {};
if (!productId.value) errs.product = t('form.required');
if (!description.value.trim()) errs.description = t('form.required');
const emailTrim = email.value.trim();
if (!emailTrim) errs.email = t('form.required');
else if (!EMAIL_RE.test(emailTrim)) errs.email = t('form.invalidEmail');
errors.value = errs;
if (Object.keys(errs).length > 0) return;
const fd = new FormData();
fd.append('product_id', String(productId.value));
fd.append('description', description.value.trim());
fd.append('email', emailTrim);
if (logFile.value) fd.append('log_file', logFile.value, logFile.value.name);
submitting.value = true;
try {
const data = await submitSupportFeedback(fd);
notice.value = { ok: true, feedbackNo: data?.feedback_no || '' };
productId.value = '';
description.value = '';
email.value = '';
logFile.value = null;
if (fileInput.value) fileInput.value.value = '';
// 查询区若已展示该邮箱的反馈,静默刷新
if (queried.value && queryInput.value.trim().toLowerCase() === emailTrim.toLowerCase()) {
loadFeedbacks({ email: emailTrim });
}
} catch (e) {
notice.value = { ok: false, text: e instanceof ApiError ? e.message : t('support.feedbackFailed') };
} finally {
submitting.value = false;
}
}
async function copyFeedbackNo(no: string) {
if (!no) return;
if (isSupported.value) {
await copy(no);
copiedNo.value = no;
} else {
try {
await navigator.clipboard.writeText(no);
copiedNo.value = no;
} catch {
/* ignore */
}
}
}
// ===== 我的反馈查询 =====
const queryInput = ref('');
const queryError = ref('');
const querying = ref(false);
const queried = ref(false);
const feedbacks = ref<SupportFeedbackItem[]>([]);
const lastQueryEmail = ref<string | null>(null);
const expandedFeedbackNo = ref<string | null>(null);
const verifiedEmails = ref<Record<string, string>>({});
function parseQueryInput(value: string): { email?: string; feedback_no?: string } | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (FEEDBACK_NO_RE.test(trimmed)) {
return { feedback_no: trimmed.toUpperCase() };
}
if (EMAIL_RE.test(trimmed)) {
return { email: trimmed };
}
return null;
}
async function loadFeedbacks(params: { email?: string; feedback_no?: string }) {
try {
const data = await getSupportFeedbacks(params);
feedbacks.value = data?.items || [];
} catch {
feedbacks.value = [];
}
}
async function handleQuery() {
const value = queryInput.value.trim();
if (!value || querying.value) return;
const params = parseQueryInput(value);
if (!params) {
queryError.value = t('support.queryInvalid');
queried.value = false;
feedbacks.value = [];
return;
}
queryError.value = '';
querying.value = true;
lastQueryEmail.value = params.email || null;
expandedFeedbackNo.value = null;
verifiedEmails.value = {};
await loadFeedbacks(params);
queried.value = true;
querying.value = false;
}
function productName(item: SupportFeedbackItem): string {
return locale.value === 'en' ? item.product_name_en : item.product_name_zh;
}
function statusLabel(status: string): string {
if (status === 'processing') return t('support.statusProcessing');
if (status === 'resolved') return t('support.statusResolved');
return t('support.statusPending');
}
function statusMeta(status: string): { badge: string; accent: string; dot: string } {
if (status === 'resolved') {
return {
badge: 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200/80 dark:bg-emerald-950/40 dark:text-emerald-300 dark:ring-emerald-800/60',
accent: 'bg-emerald-500',
dot: 'bg-emerald-500',
};
}
if (status === 'processing') {
return {
badge: 'bg-sky-50 text-sky-700 ring-1 ring-sky-200/80 dark:bg-sky-950/40 dark:text-sky-300 dark:ring-sky-800/60',
accent: 'bg-sky-500',
dot: 'bg-sky-500',
};
}
return {
badge: 'bg-amber-50 text-amber-700 ring-1 ring-amber-200/80 dark:bg-amber-950/40 dark:text-amber-300 dark:ring-amber-800/60',
accent: 'bg-amber-500',
dot: 'bg-amber-500',
};
}
function isExpanded(item: SupportFeedbackItem): boolean {
return expandedFeedbackNo.value === item.feedback_no;
}
function formatTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function verifiedEmailFor(item: SupportFeedbackItem): string | null {
if (verifiedEmails.value[item.feedback_no]) {
return verifiedEmails.value[item.feedback_no];
}
if (lastQueryEmail.value) {
return lastQueryEmail.value;
}
return null;
}
function toggleExpand(item: SupportFeedbackItem) {
expandedFeedbackNo.value = expandedFeedbackNo.value === item.feedback_no ? null : item.feedback_no;
}
function handleEmailVerified(feedbackNo: string, email: string) {
if (!email) {
const next = { ...verifiedEmails.value };
delete next[feedbackNo];
verifiedEmails.value = next;
return;
}
verifiedEmails.value = { ...verifiedEmails.value, [feedbackNo]: email };
}
function handleStatusUpdate(feedbackNo: string, status: string) {
const item = feedbacks.value.find(f => f.feedback_no === feedbackNo);
if (item && item.status !== status) {
item.status = status;
}
}
</script>
<template>
<section>
<h2 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-8px">{{ t('support.feedback') }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-32px">{{ t('support.feedbackSubtitle') }}</p>
<!-- 上下布局=提交表单=我的反馈宽度与 contact 页一致 -->
<div class="flex flex-col gap-32px mx-auto w-full">
<!-- 提交表单 -->
<form
class="flex flex-col gap-20px p-24px rounded-12px border border-gray-100 dark:border-dark-700 bg-white dark:bg-dark-800"
@submit.prevent="handleSubmit"
>
<div
v-if="notice"
class="px-16px py-12px rounded-8px text-sm leading-relaxed"
:class="notice.ok
? 'bg-green-50 text-green-700 border border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800'
: 'bg-red-50 text-red-600 border border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800'"
>
<template v-if="notice.ok && notice.feedbackNo">
{{ t('support.feedbackSuccess') }}
<button
type="button"
class="font-mono font-semibold underline underline-offset-2 hover:opacity-80 transition-opacity"
:title="t('support.feedbackNoCopied')"
@click="copyFeedbackNo(notice.feedbackNo)"
>
{{ notice.feedbackNo }}
</button>
<span v-if="copied && copiedNo === notice.feedbackNo" class="ml-8px text-xs opacity-80">
{{ t('support.feedbackNoCopied') }}
</span>
</template>
<template v-else>
{{ notice.text }}
</template>
</div>
<!-- 型号 -->
<div class="flex flex-col gap-8px">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">
{{ t('support.model') }}
<span class="text-red-500 ml-4px">*</span>
</label>
<select
v-model="productId"
class="px-16px py-12px rounded-8px border text-sm bg-white dark:bg-dark-800 dark:text-gray-100 outline-none transition-colors"
:class="errors.product ? 'border-red-400' : 'border-gray-200 dark:border-dark-600 focus:border-primary'"
>
<option value="">{{ t('support.selectModel') }}</option>
<option v-for="opt in productOptions" :key="opt.id" :value="opt.id">
{{ opt.label }}
</option>
</select>
<p v-if="errors.product" class="text-xs text-red-500">{{ errors.product }}</p>
</div>
<!-- 问题描述 -->
<div class="flex flex-col gap-8px">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">
{{ t('support.description') }}
<span class="text-red-500 ml-4px">*</span>
</label>
<textarea
v-model="description"
rows="5"
maxlength="2000"
:placeholder="t('support.descriptionPlaceholder')"
class="px-16px py-12px rounded-8px border text-sm bg-white dark:bg-dark-800 dark:text-gray-100 outline-none transition-colors resize-y"
:class="errors.description ? 'border-red-400' : 'border-gray-200 dark:border-dark-600 focus:border-primary'"
></textarea>
<p v-if="errors.description" class="text-xs text-red-500">{{ errors.description }}</p>
</div>
<!-- 邮箱 -->
<div class="flex flex-col gap-8px">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">
{{ t('support.email') }}
<span class="text-red-500 ml-4px">*</span>
</label>
<input
v-model="email"
type="email"
:placeholder="t('support.emailPlaceholder')"
class="px-16px py-12px rounded-8px border text-sm bg-white dark:bg-dark-800 dark:text-gray-100 outline-none transition-colors"
:class="errors.email ? 'border-red-400' : 'border-gray-200 dark:border-dark-600 focus:border-primary'"
>
<p v-if="errors.email" class="text-xs text-red-500">{{ errors.email }}</p>
</div>
<!-- 日志文件可选 -->
<div class="flex flex-col gap-8px">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">{{ t('support.logFile') }}</label>
<div class="flex items-center gap-12px">
<input ref="fileInput" type="file" class="hidden" @change="onFileChange">
<button
type="button"
class="px-20px py-10px rounded-8px border border-gray-200 dark:border-dark-600 text-sm text-gray-700 dark:text-gray-200 hover:border-primary hover:text-primary transition-colors"
@click="fileInput?.click()"
>
{{ t('support.chooseFile') }}
</button>
<span v-if="logFile" class="text-xs text-gray-500 dark:text-gray-400 truncate">
{{ logFile.name }}{{ formatFileSize(logFile.size) }}
</span>
<span v-else class="text-xs text-gray-400">{{ t('support.noFile') }}</span>
</div>
</div>
<button
type="submit"
:disabled="submitting"
class="mt-4px px-32px py-12px rounded-8px bg-primary text-white text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed self-center"
>
{{ submitting ? t('common.loading') : t('support.submitFeedback') }}
</button>
</form>
<!-- 我的反馈查询 -->
<div class="feedback-panel flex flex-col gap-20px p-24px md:p-28px rounded-16px border border-gray-200/80 dark:border-dark-600/80 bg-white dark:bg-dark-800 shadow-sm">
<div class="flex flex-col gap-6px">
<h3 class="text-lg md:text-xl font-semibold text-gray-900 dark:text-white tracking-tight">
{{ t('support.myFeedback') }}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('support.queryPlaceholder') }}
</p>
</div>
<div class="flex flex-col sm:flex-row gap-10px">
<div class="relative flex-1 min-w-0">
<span class="feedback-search-icon pointer-events-none absolute left-14px top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-18px h-18px">
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11ZM2 9a7 7 0 1 1 12.452 4.391l3.328 3.329a.75.75 0 1 1-1.06 1.06l-3.329-3.328A7 7 0 0 1 2 9Z" clip-rule="evenodd" />
</svg>
</span>
<input
v-model="queryInput"
type="text"
:placeholder="t('support.queryPlaceholder')"
class="feedback-search-input w-full pl-42px pr-16px py-13px rounded-12px border text-sm bg-gray-50/80 dark:bg-dark-900/50 dark:text-gray-100 outline-none transition-all placeholder:text-gray-400 dark:placeholder:text-gray-500"
:class="queryError
? 'border-red-400 focus:border-red-400 focus:ring-2 focus:ring-red-400/20'
: 'border-gray-200 dark:border-dark-600 focus:border-primary focus:ring-2 focus:ring-primary/15'"
@keyup.enter="handleQuery"
@input="queryError = ''"
>
</div>
<button
type="button"
:disabled="querying"
class="feedback-query-btn shrink-0 px-28px py-13px rounded-12px bg-primary text-white text-sm font-medium shadow-sm hover:shadow-md hover:opacity-95 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
@click="handleQuery"
>
{{ querying ? t('common.loading') : t('support.query') }}
</button>
</div>
<p v-if="queryError" class="text-xs text-red-500 -mt-8px">{{ queryError }}</p>
<div
v-if="queried && !querying && feedbacks.length === 0"
class="feedback-empty flex flex-col items-center justify-center gap-12px py-48px px-24px rounded-12px border border-dashed border-gray-200 dark:border-dark-600 bg-gray-50/50 dark:bg-dark-900/30"
>
<div class="w-48px h-48px rounded-full bg-gray-100 dark:bg-dark-700 flex items-center justify-center text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-24px h-24px">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
</svg>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('support.noFeedback') }}</p>
</div>
<div v-else-if="feedbacks.length > 0" class="flex flex-col gap-14px">
<article
v-for="item in feedbacks"
:key="item.id"
class="feedback-card group relative overflow-hidden rounded-14px border transition-all duration-200"
:class="isExpanded(item)
? 'border-primary/40 dark:border-primary/30 bg-white dark:bg-dark-800 shadow-md ring-1 ring-primary/10'
: 'border-gray-200/90 dark:border-dark-600/80 bg-white dark:bg-dark-800/90 hover:border-gray-300 dark:hover:border-dark-500 hover:shadow-sm'"
>
<span
class="feedback-card-accent absolute left-0 top-0 bottom-0 w-3px"
:class="statusMeta(item.status).accent"
aria-hidden="true"
/>
<div class="p-18px md:p-20px pl-22px md:pl-24px flex flex-col gap-14px">
<div class="flex items-start justify-between gap-16px">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-10px mb-8px">
<span class="feedback-product-icon shrink-0 w-36px h-36px rounded-10px bg-primary/10 text-primary flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-18px h-18px">
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z" clip-rule="evenodd" />
</svg>
</span>
<h4 class="font-semibold text-base text-gray-900 dark:text-white truncate">
{{ productName(item) }}
</h4>
</div>
<div class="flex flex-wrap items-center gap-x-10px gap-y-6px text-xs text-gray-500 dark:text-gray-400">
<time>{{ formatTime(item.created_at) }}</time>
<template v-if="item.feedback_no">
<span class="text-gray-300 dark:text-gray-600">|</span>
<button
type="button"
class="feedback-no-chip inline-flex items-center gap-6px font-mono text-primary hover:text-primary/80 transition-colors"
:title="t('support.feedbackNoCopied')"
@click="copyFeedbackNo(item.feedback_no)"
>
{{ item.feedback_no }}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-14px h-14px shrink-0 text-primary">
<path d="M7 3.5A1.5 1.5 0 0 1 8.5 2h3.879a1.5 1.5 0 0 1 1.06.44l3.122 3.12A1.5 1.5 0 0 1 17 6.622V12.5a1.5 1.5 0 0 1-1.5 1.5h-1v-3.379a3 3 0 0 0-.879-2.121L10.5 5.379A3 3 0 0 0 8.379 4.5H7v-1Z" />
<path d="M4.5 6A1.5 1.5 0 0 0 3 7.5v9A1.5 1.5 0 0 0 4.5 18h7a1.5 1.5 0 0 0 1.5-1.5v-5.879a1.5 1.5 0 0 0-.44-1.06L9.44 6.439A1.5 1.5 0 0 0 8.378 6H4.5Z" />
</svg>
</button>
<span
v-if="copied && copiedNo === item.feedback_no"
class="text-emerald-600 dark:text-emerald-400"
>
{{ t('support.feedbackNoCopied') }}
</span>
</template>
</div>
</div>
<span
class="feedback-status shrink-0 inline-flex items-center gap-6px px-10px py-4px rounded-full text-xs font-medium"
:class="statusMeta(item.status).badge"
>
<span class="w-6px h-6px rounded-full" :class="statusMeta(item.status).dot" />
{{ statusLabel(item.status) }}
</span>
</div>
<div class="rounded-10px bg-gray-50/90 dark:bg-dark-900/45 px-14px py-12px border border-gray-100/80 dark:border-dark-600/60 flex items-start gap-12px">
<p
class="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-line break-words"
:class="isExpanded(item) ? '' : 'line-clamp-2'"
>
{{ item.description }}
</p>
<button
type="button"
class="feedback-toggle-btn shrink-0 self-center inline-flex items-center gap-4px px-12px py-7px rounded-8px text-xs font-medium transition-all"
:class="isExpanded(item)
? 'bg-primary/10 text-primary'
: 'bg-white text-gray-700 ring-1 ring-gray-200/90 hover:bg-primary/10 hover:text-primary hover:ring-primary/20 dark:bg-dark-700 dark:text-gray-200 dark:ring-dark-500 dark:hover:bg-primary/15 dark:hover:text-primary'"
@click="toggleExpand(item)"
>
{{ isExpanded(item) ? t('support.collapseChat') : t('support.expandChat') }}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-14px h-14px transition-transform duration-200"
:class="isExpanded(item) ? 'rotate-180' : ''"
>
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.94a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd" />
</svg>
</button>
</div>
<a
v-if="item.log_file_path"
:href="item.log_file_path"
target="_blank"
rel="noopener noreferrer"
class="feedback-attachment inline-flex items-center gap-6px max-w-full self-start px-10px py-6px rounded-8px text-xs text-gray-600 dark:text-gray-300 bg-gray-100/80 dark:bg-dark-700/80 hover:bg-gray-200/80 dark:hover:bg-dark-600/80 transition-colors truncate"
@click.stop
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-14px h-14px shrink-0 text-gray-400">
<path fill-rule="evenodd" d="m15.988 3.012-8.25 8.25a2.25 2.25 0 1 1-3.182-3.182l8.25-8.25a3.75 3.75 0 0 1 5.303 5.303l-8.25 8.25a5.25 5.25 0 1 1-7.424-7.424l8.25-8.25a.75.75 0 1 1 1.06 1.06l-8.25 8.25a3.75 3.75 0 0 0 5.303 5.303l8.25-8.25a2.25 2.25 0 0 0-3.182-3.182l-8.25 8.25a.75.75 0 1 1-1.06-1.06l8.25-8.25a3.75 3.75 0 0 0-5.303-5.303Z" clip-rule="evenodd" />
</svg>
<span class="truncate">{{ item.log_file_name }}</span>
</a>
<SupportFeedbackChat
v-if="isExpanded(item)"
:item="item"
:verified-email="verifiedEmailFor(item)"
@verified="(email) => handleEmailVerified(item.feedback_no, email)"
@status-update="(status) => handleStatusUpdate(item.feedback_no, status)"
/>
</div>
</article>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.feedback-panel {
background-image: linear-gradient(180deg, rgb(255 255 255 / 1) 0%, rgb(249 250 251 / 0.55) 100%);
}
:root.dark .feedback-panel,
.dark .feedback-panel {
background-image: linear-gradient(180deg, rgb(30 30 35 / 1) 0%, rgb(24 24 28 / 0.92) 100%);
}
.feedback-card-accent {
opacity: 0.92;
}
.feedback-no-chip:focus-visible,
.feedback-toggle-btn:focus-visible,
.feedback-query-btn:focus-visible,
.feedback-search-input:focus-visible {
outline: none;
}
</style>