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
+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>