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