f60255574e
- 重命名语言文件中相关关键字,统一改为 customer-service_feedbacks - 新增客户服务反馈页面路由及组件导入,移除旧的 www_support_feedbacks 相关路由 - 实现客户服务反馈列表及详情查看,支持状态筛选、分页查询和消息对话展示 - 支持反馈状态变更,包括待处理、处理中和已解决状态切换 - 支持在对话中发送回复消息,且针对已解决状态自动停止轮询 - 优化状态标签样式,采用更易区分的颜色与视觉样式 - 组件及类型定义中新增 NCollapse 和 NCollapseItem 支持 - 移除旧的 www 支持反馈组件代码,清理无用路由及类型定义相关项
552 lines
24 KiB
Vue
552 lines
24 KiB
Vue
<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>
|