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>
+234
View File
@@ -0,0 +1,234 @@
<script setup lang="ts">
import type { SupportFeedbackItem, SupportFeedbackMessage } from '~/types/site';
import { ApiError } from '~/composables/useRequest';
const props = defineProps<{
item: SupportFeedbackItem;
verifiedEmail: string | null;
}>();
const emit = defineEmits<{
verified: [email: string];
statusUpdate: [status: string];
}>();
const { t } = useI18n();
const { getFeedbackMessages, sendFeedbackMessage } = useSiteApi();
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const POLL_INTERVAL_MS = 4000;
const emailInput = ref('');
const emailError = ref('');
const messages = ref<SupportFeedbackMessage[]>([]);
const replyContent = ref('');
const loading = ref(false);
const sending = ref(false);
const sendError = ref('');
const messageListRef = ref<HTMLElement | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const effectiveEmail = computed(() => props.verifiedEmail || '');
const isResolved = computed(() => props.item.status === 'resolved');
function lastMessageId(): number {
return messages.value.length > 0 ? messages.value[messages.value.length - 1].id : 0;
}
async function scrollToBottom() {
await nextTick();
const el = messageListRef.value;
if (el) el.scrollTop = el.scrollHeight;
}
async function loadMessages(full = false) {
if (!effectiveEmail.value) return;
if (full) loading.value = true;
try {
const sinceId = full ? undefined : lastMessageId();
const data = await getFeedbackMessages(props.item.feedback_no, effectiveEmail.value, sinceId);
if (data?.status && data.status !== props.item.status) {
emit('statusUpdate', data.status);
}
const incoming = data?.items || [];
if (full || sinceId === 0) {
messages.value = incoming;
} else if (incoming.length > 0) {
messages.value = [...messages.value, ...incoming];
}
if (full || incoming.length > 0) await scrollToBottom();
} catch (e) {
if (full) throw e;
} finally {
if (full) loading.value = false;
}
}
function startPolling() {
stopPolling();
if (isResolved.value) return;
pollTimer = setInterval(() => {
if (effectiveEmail.value && document.visibilityState === 'visible' && !isResolved.value) {
loadMessages(false);
}
}, POLL_INTERVAL_MS);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function handleVerifyEmail() {
const email = emailInput.value.trim();
if (!EMAIL_RE.test(email)) {
emailError.value = t('form.invalidEmail');
return;
}
emailError.value = '';
emit('verified', email);
}
async function handleSend() {
const content = replyContent.value.trim();
if (!content || !effectiveEmail.value || sending.value || isResolved.value) return;
sending.value = true;
sendError.value = '';
try {
const data = await sendFeedbackMessage(props.item.feedback_no, effectiveEmail.value, content);
messages.value = data?.items || [];
replyContent.value = '';
await scrollToBottom();
} catch (e) {
sendError.value = e instanceof ApiError ? e.message : t('support.messageFailed');
} finally {
sending.value = false;
}
}
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())}`;
}
watch(() => props.verifiedEmail, async (email, prev) => {
if (email && email !== prev) {
try {
await loadMessages(true);
startPolling();
} catch (e) {
emailError.value = e instanceof ApiError ? e.message : t('support.emailMismatch');
emit('verified', '');
}
} else if (!email) {
stopPolling();
messages.value = [];
}
}, { immediate: true });
watch(isResolved, (resolved) => {
if (resolved) {
stopPolling();
replyContent.value = '';
} else if (effectiveEmail.value) {
startPolling();
}
});
onUnmounted(stopPolling);
</script>
<template>
<div class="mt-12px pt-12px border-t border-gray-100 dark:border-dark-600 flex flex-col gap-12px">
<div v-if="!effectiveEmail" class="flex flex-col gap-8px">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('support.verifyEmailHint') }}</p>
<div class="flex gap-8px">
<input
v-model="emailInput"
type="email"
:placeholder="t('support.emailPlaceholder')"
class="flex-1 min-w-0 px-12px py-10px rounded-8px border text-sm bg-white dark:bg-dark-800 dark:text-gray-100 outline-none transition-colors"
:class="emailError ? 'border-red-400' : 'border-gray-200 dark:border-dark-600 focus:border-primary'"
@keyup.enter="handleVerifyEmail"
>
<button
type="button"
class="px-16px py-10px rounded-8px bg-primary text-white text-sm font-medium hover:opacity-90 transition-opacity shrink-0"
@click="handleVerifyEmail"
>
{{ t('support.verifyEmail') }}
</button>
</div>
<p v-if="emailError" class="text-xs text-red-500">{{ emailError }}</p>
</div>
<template v-else>
<div class="text-xs text-gray-500 dark:text-gray-400">{{ t('support.initialDescription') }}</div>
<div
ref="messageListRef"
class="max-h-280px overflow-y-auto flex flex-col gap-10px p-12px rounded-8px bg-gray-50/80 dark:bg-dark-700/50 border border-gray-100 dark:border-dark-600"
>
<div class="flex justify-end">
<div class="max-w-[85%] rounded-10px px-12px py-8px text-sm bg-primary text-white">
<div class="whitespace-pre-wrap break-words">{{ item.description }}</div>
<div class="text-xs mt-4px opacity-70">{{ formatTime(item.created_at) }}</div>
</div>
</div>
<div v-if="loading && messages.length === 0" class="py-16px text-center text-xs text-gray-400">
{{ t('common.loading') }}
</div>
<div v-else-if="messages.length === 0" class="py-8px text-center text-xs text-gray-400">
{{ t('support.noMessages') }}
</div>
<div
v-for="msg in messages"
:key="msg.id"
class="flex"
:class="msg.sender_type === 'user' ? 'justify-end' : 'justify-start'"
>
<div
class="max-w-[85%] rounded-10px px-12px py-8px text-sm leading-relaxed"
:class="msg.sender_type === 'user'
? 'bg-primary text-white'
: 'bg-white dark:bg-dark-800 border border-gray-100 dark:border-dark-600 text-gray-700 dark:text-gray-200'"
>
<div v-if="msg.sender_type === 'staff' && msg.sender_name" class="text-xs opacity-80 mb-2px">
{{ msg.sender_name }}
</div>
<div class="whitespace-pre-wrap break-words">{{ msg.content }}</div>
<div class="text-xs mt-4px opacity-70">{{ formatTime(msg.created_at) }}</div>
</div>
</div>
</div>
<div v-if="isResolved" class="rounded-8px px-12px py-10px text-xs text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-dark-700/50 border border-gray-100 dark:border-dark-600">
{{ t('support.conversationClosed') }}
</div>
<div v-else class="flex flex-col gap-8px">
<textarea
v-model="replyContent"
rows="3"
maxlength="2000"
:placeholder="t('support.messagePlaceholder')"
class="px-12px py-10px rounded-8px border border-gray-200 dark:border-dark-600 text-sm bg-white dark:bg-dark-800 dark:text-gray-100 outline-none transition-colors resize-y focus:border-primary"
@keydown.ctrl.enter.prevent="handleSend"
/>
<button
type="button"
:disabled="sending || !replyContent.trim()"
class="self-end px-20px py-10px rounded-8px bg-primary text-white text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
@click="handleSend"
>
{{ sending ? t('common.loading') : t('support.sendMessage') }}
</button>
<p v-if="sendError" class="text-xs text-red-500">{{ sendError }}</p>
</div>
</template>
</div>
</template>