www:新增问题反馈功能;dashboard:适配问题反馈功能,可以在后台系统和用户沟通
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user