Files
admin/www/app/components/SupportFeedback.vue
T

425 lines
16 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 statusClass(status: string): string {
if (status === 'resolved') return 'bg-green-50 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800';
if (status === 'processing') return 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800';
return 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-300 dark:border-amber-800';
}
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="flex flex-col gap-16px p-24px rounded-12px border border-gray-100 dark:border-dark-700 bg-white dark:bg-dark-800">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('support.myFeedback') }}</h3>
<div class="flex gap-8px">
<input
v-model="queryInput"
type="text"
:placeholder="t('support.queryPlaceholder')"
class="flex-1 min-w-0 px-16px py-12px rounded-8px border text-sm bg-white dark:bg-dark-800 dark:text-gray-100 outline-none transition-colors focus:border-primary"
:class="queryError ? 'border-red-400' : 'border-gray-200 dark:border-dark-600'"
@keyup.enter="handleQuery"
@input="queryError = ''"
>
<button
type="button"
:disabled="querying"
class="px-24px py-12px rounded-8px bg-primary text-white text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50 self-start"
@click="handleQuery"
>
{{ querying ? t('common.loading') : t('support.query') }}
</button>
</div>
<p v-if="queryError" class="text-xs text-red-500">{{ queryError }}</p>
<div v-if="queried && !querying && feedbacks.length === 0" class="py-32px text-center text-sm text-gray-400">
{{ t('support.noFeedback') }}
</div>
<div v-else class="flex flex-col gap-12px">
<div
v-for="item in feedbacks"
:key="item.id"
class="flex flex-col gap-8px p-16px rounded-8px border border-gray-100 dark:border-dark-700 bg-gray-50/50 dark:bg-dark-700/40"
>
<button
type="button"
class="flex flex-col gap-8px text-left w-full"
@click="toggleExpand(item)"
>
<div class="flex items-center justify-between gap-12px">
<span class="font-medium text-sm text-gray-900 dark:text-white truncate">{{ productName(item) }}</span>
<span class="shrink-0 px-10px py-2px rounded-full border text-xs" :class="statusClass(item.status)">
{{ statusLabel(item.status) }}
</span>
</div>
<div class="flex items-center gap-8px text-xs text-gray-400">
<span>{{ formatTime(item.created_at) }}</span>
<template v-if="item.feedback_no">
<span>·</span>
<span
class="font-mono text-primary hover:underline"
@click.stop="copyFeedbackNo(item.feedback_no)"
>
{{ item.feedback_no }}
</span>
<span v-if="copied && copiedNo === item.feedback_no" class="text-green-600 dark:text-green-400">
{{ t('support.feedbackNoCopied') }}
</span>
</template>
<span class="ml-auto text-primary">{{ expandedFeedbackNo === item.feedback_no ? t('support.collapseChat') : t('support.expandChat') }}</span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed whitespace-pre-line break-words line-clamp-2">{{ item.description }}</p>
</button>
<a
v-if="item.log_file_path"
:href="item.log_file_path"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary hover:underline self-start"
@click.stop
>
📎 {{ item.log_file_name }}
</a>
<SupportFeedbackChat
v-if="expandedFeedbackNo === item.feedback_no"
:item="item"
:verified-email="verifiedEmailFor(item)"
@verified="(email) => handleEmailVerified(item.feedback_no, email)"
@status-update="(status) => handleStatusUpdate(item.feedback_no, status)"
/>
</div>
</div>
</div>
</div>
</section>
</template>