www:新增问题反馈功能;dashboard:适配问题反馈功能,可以在后台系统和用户沟通

This commit is contained in:
eafonyang
2026-08-20 16:42:01 +08:00
parent 5ae979f7e1
commit 3702ad4163
31 changed files with 1956 additions and 11 deletions
+424
View File
@@ -0,0 +1,424 @@
<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>