f60255574e
- 重命名语言文件中相关关键字,统一改为 customer-service_feedbacks - 新增客户服务反馈页面路由及组件导入,移除旧的 www_support_feedbacks 相关路由 - 实现客户服务反馈列表及详情查看,支持状态筛选、分页查询和消息对话展示 - 支持反馈状态变更,包括待处理、处理中和已解决状态切换 - 支持在对话中发送回复消息,且针对已解决状态自动停止轮询 - 优化状态标签样式,采用更易区分的颜色与视觉样式 - 组件及类型定义中新增 NCollapse 和 NCollapseItem 支持 - 移除旧的 www 支持反馈组件代码,清理无用路由及类型定义相关项
235 lines
8.1 KiB
Vue
235 lines
8.1 KiB
Vue
<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-4px pt-16px 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>
|