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
@@ -271,6 +271,8 @@ const local: App.I18n.Schema = {
www_contact: 'Contact Form',
www_contact_settings: 'Form Settings',
www_contact_submissions: 'Submissions',
'customer-service': 'Customer Service',
www_support_feedbacks: 'Support Feedback',
'www_page-seo': 'Page SEO'
},
page: {
@@ -268,6 +268,8 @@ const local: App.I18n.Schema = {
www_contact: '联系表单',
www_contact_settings: '表单配置',
www_contact_submissions: '提交记录',
'customer-service': '客户服务',
www_support_feedbacks: '问题反馈',
'www_page-seo': '页面 SEO'
},
page: {
@@ -51,4 +51,5 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
www_product_detail: () => import("@/views/www/product/detail/index.vue"),
www_product_list: () => import("@/views/www/product/list/index.vue"),
www_product_series: () => import("@/views/www/product/series/index.vue"),
www_support_feedbacks: () => import("@/views/www/support/feedbacks/index.vue"),
};
@@ -133,7 +133,7 @@ export const generatedRoutes: GeneratedRoute[] = [
title: 'meilisearch',
i18nKey: 'route.meilisearch',
icon: 'simple-icons:meilisearch',
order: 8,
order: 9,
keepAlive: true
}
},
@@ -271,7 +271,7 @@ export const generatedRoutes: GeneratedRoute[] = [
title: 'www',
i18nKey: 'route.www',
icon: 'iconoir:www',
order: 7
order: 8
},
children: [
{
@@ -585,6 +585,31 @@ export const generatedRoutes: GeneratedRoute[] = [
}
}
]
},
]
},
{
name: 'customer-service',
path: '/www/support',
component: 'layout.base',
meta: {
title: 'customer-service',
i18nKey: 'route.customer-service',
icon: 'mdi:headset',
order: 7
},
children: [
{
name: 'www_support_feedbacks',
path: '/www/support/feedbacks',
component: 'view.www_support_feedbacks',
meta: {
title: 'www_support_feedbacks',
i18nKey: 'route.www_support_feedbacks',
icon: 'mdi:message-alert-outline',
order: 1,
keepAlive: true
}
}
]
}
@@ -210,7 +210,9 @@ const routeMap: RouteMap = {
"www_product": "/www/product",
"www_product_detail": "/www/product/detail",
"www_product_list": "/www/product/list",
"www_product_series": "/www/product/series"
"www_product_series": "/www/product/series",
"customer-service": "/www/support",
"www_support_feedbacks": "/www/support/feedbacks"
};
/**
@@ -21,4 +21,5 @@ export * from './www-news';
export * from './www-media';
export * from './www-i18n';
export * from './www-contact';
export * from './www-support-feedback';
export * from './www-seo';
@@ -0,0 +1,44 @@
import { request } from '../request';
import type { PageParams } from './brand';
export function fetchGetSupportFeedbacks(params?: PageParams & {
status?: string;
email?: string;
feedback_no?: string;
start_date?: string;
end_date?: string;
}) {
return request<Api.Common.PageResult<Api.Www.SupportFeedback>>({
url: '/www/support/feedbacks',
method: 'get',
params,
});
}
export function fetchGetSupportFeedback(id: number) {
return request<Api.Www.SupportFeedback>({ url: `/www/support/feedbacks/${id}`, method: 'get' });
}
export function fetchUpdateSupportFeedbackStatus(id: number, status: string) {
return request<Api.Www.SupportFeedback>({
url: `/www/support/feedbacks/${id}`,
method: 'patch',
data: { status },
});
}
export function fetchGetSupportFeedbackMessages(id: number, sinceId?: number) {
return request<{ items: Api.Www.SupportFeedbackMessage[]; total: number }>({
url: `/www/support/feedbacks/${id}/messages`,
method: 'get',
params: sinceId ? { since_id: sinceId } : undefined,
});
}
export function fetchSendSupportFeedbackMessage(id: number, content: string) {
return request<{ items: Api.Www.SupportFeedbackMessage[]; total: number }>({
url: `/www/support/feedbacks/${id}/messages`,
method: 'post',
data: { content },
});
}
+27
View File
@@ -340,6 +340,33 @@ declare namespace Api {
submitted_at: string;
}
interface SupportFeedback {
id: number;
feedback_no: string;
product_id: number;
product_name_zh: string;
product_name_en: string;
description: string;
email: string;
status: string;
log_file_name: string;
log_file_path: string;
log_file_size: number;
visitor_ip: string;
created_at: string;
updated_at: string;
}
interface SupportFeedbackMessage {
id: number;
feedback_id: number;
sender_type: 'user' | 'staff';
sender_id: number;
sender_name: string;
content: string;
created_at: string;
}
// ===== 10. 页面 SEO =====
interface PageSeo {
id?: number;
+4
View File
@@ -65,6 +65,8 @@ declare module "@elegant-router/types" {
"www_product_detail": "/www/product/detail";
"www_product_list": "/www/product/list";
"www_product_series": "/www/product/series";
"customer-service": "/www/support";
"www_support_feedbacks": "/www/support/feedbacks";
};
/**
@@ -109,6 +111,7 @@ declare module "@elegant-router/types" {
| "toolbox"
| "upgrade"
| "www"
| "customer-service"
>;
/**
@@ -161,6 +164,7 @@ declare module "@elegant-router/types" {
| "www_product_detail"
| "www_product_list"
| "www_product_series"
| "www_support_feedbacks"
>;
/**
@@ -0,0 +1,379 @@
<script setup lang="ts">
import { computed, h, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { NButton, NDrawer, NDrawerContent, NInput, NSelect, NSpace, NTag, type DataTableColumns } from 'naive-ui';
import {
fetchGetSupportFeedbacks,
fetchGetSupportFeedbackMessages,
fetchSendSupportFeedbackMessage,
fetchUpdateSupportFeedbackStatus,
} from '@/service/api';
defineOptions({ name: 'WwwSupportFeedbacks' });
const POLL_INTERVAL_MS = 4000;
const loading = ref(false);
const tableData = ref<Api.Www.SupportFeedback[]>([]);
const drawerVisible = ref(false);
const activeFeedback = ref<Api.Www.SupportFeedback | null>(null);
const messages = ref<Api.Www.SupportFeedbackMessage[]>([]);
const replyContent = ref('');
const sending = ref(false);
const messagesLoading = ref(false);
const messageListRef = ref<HTMLElement | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const isResolved = computed(() => activeFeedback.value?.status === 'resolved');
const searchForm = reactive({
status: null as string | null,
email: '',
feedback_no: '',
});
const pagination = reactive({
page: 1,
pageSize: 10,
itemCount: 0,
pageSizes: [10, 20, 50],
});
const statusOptions = [
{ label: '全部', value: null },
{ label: '待处理', value: 'pending' },
{ label: '处理中', value: 'processing' },
{ label: '已解决', value: 'resolved' },
];
const statusTagMap: Record<string, { type: 'warning' | 'info' | 'success'; label: string }> = {
pending: { type: 'warning', label: '待处理' },
processing: { type: 'info', label: '处理中' },
resolved: { type: 'success', label: '已解决' },
};
const columns = computed<DataTableColumns<Api.Www.SupportFeedback>>(() => [
{ title: 'ID', key: 'id', width: 60 },
{ title: '反馈编号', key: 'feedback_no', width: 170, ellipsis: { tooltip: true } },
{ title: '产品', key: 'product_name_zh', width: 120, ellipsis: { tooltip: true } },
{ title: '邮箱', key: 'email', width: 180, ellipsis: { tooltip: true } },
{
title: '状态',
key: 'status',
width: 90,
render(row) {
const s = statusTagMap[row.status] || statusTagMap.pending;
return h(NTag, { type: s.type, size: 'small' }, { default: () => s.label });
},
},
{ title: '更新时间', key: 'updated_at', width: 170 },
{
title: '操作',
key: 'actions',
width: 120,
fixed: 'right',
render(row) {
return h(
NButton,
{ size: 'small', type: 'primary', onClick: () => openChat(row) },
{ default: () => '查看对话' },
);
},
},
]);
function formatTime(value: string) {
if (!value) return '';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
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())}`;
}
async function loadData() {
loading.value = true;
const { data, error } = await fetchGetSupportFeedbacks({
skip: (pagination.page - 1) * pagination.pageSize,
limit: pagination.pageSize,
status: searchForm.status || undefined,
email: searchForm.email.trim() || undefined,
feedback_no: searchForm.feedback_no.trim() || undefined,
});
loading.value = false;
if (!error && data) {
tableData.value = data.items || [];
pagination.itemCount = data.total || 0;
} else if (!error) {
tableData.value = [];
pagination.itemCount = 0;
}
}
function handleSearch() {
pagination.page = 1;
loadData();
}
function handlePageChange(page: number) {
pagination.page = page;
loadData();
}
function handlePageSizeChange(pageSize: number) {
pagination.pageSize = pageSize;
pagination.page = 1;
loadData();
}
function lastMessageId(): number {
if (messages.value.length === 0) return 0;
return messages.value[messages.value.length - 1].id;
}
async function scrollMessagesToBottom() {
await nextTick();
const el = messageListRef.value;
if (el) el.scrollTop = el.scrollHeight;
}
async function loadMessages(full = false) {
if (!activeFeedback.value) return;
messagesLoading.value = full;
const sinceId = full ? undefined : lastMessageId();
const { data, error } = await fetchGetSupportFeedbackMessages(
activeFeedback.value.id,
sinceId && sinceId > 0 ? sinceId : undefined,
);
messagesLoading.value = false;
if (error || !data) return;
const incoming = data.items || [];
if (full || sinceId === 0) {
messages.value = incoming;
await scrollMessagesToBottom();
return;
}
if (incoming.length > 0) {
messages.value = [...messages.value, ...incoming];
await scrollMessagesToBottom();
}
}
function startPolling() {
stopPolling();
if (isResolved.value) return;
pollTimer = setInterval(() => {
if (drawerVisible.value && document.visibilityState === 'visible' && !isResolved.value) {
loadMessages(false);
}
}, POLL_INTERVAL_MS);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
async function openChat(row: Api.Www.SupportFeedback) {
activeFeedback.value = row;
messages.value = [];
replyContent.value = '';
drawerVisible.value = true;
await loadMessages(true);
if (row.status !== 'resolved') {
startPolling();
}
}
function closeChat() {
drawerVisible.value = false;
stopPolling();
activeFeedback.value = null;
messages.value = [];
replyContent.value = '';
}
async function handleStatusChange(status: string) {
if (!activeFeedback.value) return;
const { data, error } = await fetchUpdateSupportFeedbackStatus(activeFeedback.value.id, status);
if (!error && data) {
activeFeedback.value = { ...activeFeedback.value, status: data.status, updated_at: data.updated_at };
const row = tableData.value.find(r => r.id === activeFeedback.value!.id);
if (row) {
row.status = data.status;
row.updated_at = data.updated_at;
}
window.$message?.success('状态已更新');
if (data.status === 'resolved') {
stopPolling();
replyContent.value = '';
} else {
startPolling();
}
}
}
async function handleSendReply() {
const content = replyContent.value.trim();
if (!content || !activeFeedback.value || sending.value || isResolved.value) return;
sending.value = true;
const { data, error } = await fetchSendSupportFeedbackMessage(activeFeedback.value.id, content);
sending.value = false;
if (!error && data) {
messages.value = data.items || [];
replyContent.value = '';
await scrollMessagesToBottom();
if (activeFeedback.value.status === 'pending') {
activeFeedback.value.status = 'processing';
const row = tableData.value.find(r => r.id === activeFeedback.value!.id);
if (row) row.status = 'processing';
}
}
}
watch(drawerVisible, visible => {
if (!visible) stopPolling();
});
onMounted(loadData);
onUnmounted(stopPolling);
</script>
<template>
<NSpace vertical :size="16">
<NCard :bordered="false" class="card-wrapper" size="small">
<NForm inline :model="searchForm" label-placement="left">
<NFormItem label="状态">
<NSelect
v-model:value="searchForm.status"
:options="statusOptions"
clearable
class="w-140px"
placeholder="全部"
@update:value="handleSearch"
/>
</NFormItem>
<NFormItem label="邮箱">
<NInput v-model:value="searchForm.email" placeholder="邮箱" clearable class="w-200px" @keyup.enter="handleSearch" />
</NFormItem>
<NFormItem label="反馈编号">
<NInput v-model:value="searchForm.feedback_no" placeholder="FB..." clearable class="w-200px" @keyup.enter="handleSearch" />
</NFormItem>
<NFormItem>
<NButton type="primary" @click="handleSearch">查询</NButton>
</NFormItem>
</NForm>
</NCard>
<NCard :bordered="false" class="card-wrapper" title="问题反馈" size="small">
<NDataTable :loading="loading" :columns="columns" :data="tableData" :bordered="false" size="small" />
<div class="mt-16px flex justify-end">
<NPagination
:page="pagination.page"
:page-size="pagination.pageSize"
:item-count="pagination.itemCount"
:page-sizes="pagination.pageSizes"
show-size-picker
:prefix="({ itemCount }) => `共 ${itemCount ?? 0} 条`"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
</NCard>
<NDrawer :show="drawerVisible" :width="520" placement="right" @update:show="val => { if (!val) closeChat(); }">
<NDrawerContent v-if="activeFeedback" closable title="反馈对话">
<template #header>
<div class="flex flex-col gap-4px">
<span class="font-medium">{{ activeFeedback.feedback_no }}</span>
<span class="text-12px text-gray-500 dark:text-gray-400">{{ activeFeedback.product_name_zh }} · {{ activeFeedback.email }}</span>
</div>
</template>
<div class="flex flex-col h-full gap-12px">
<div class="flex items-center gap-12px">
<span class="text-13px text-gray-600 dark:text-gray-400">状态</span>
<NSelect
:value="activeFeedback.status"
:options="[
{ label: '待处理', value: 'pending' },
{ label: '处理中', value: 'processing' },
{ label: '已解决', value: 'resolved' },
]"
class="w-140px"
size="small"
@update:value="handleStatusChange"
/>
</div>
<div
ref="messageListRef"
class="flex-1 min-h-320px max-h-480px overflow-y-auto rounded-8px border border-gray-200 dark:border-#ffffff1a p-12px bg-layout"
>
<div class="mb-12px">
<div class="text-12px text-gray-500 dark:text-gray-400 mb-4px">初始问题描述</div>
<div class="rounded-8px bg-container border border-gray-200 dark:border-#ffffff1a p-10px text-13px text-base-text whitespace-pre-wrap">
{{ activeFeedback.description }}
</div>
<div v-if="activeFeedback.log_file_path" class="mt-6px">
<a :href="activeFeedback.log_file_path" target="_blank" rel="noopener" class="text-12px text-primary">
📎 {{ activeFeedback.log_file_name }}
</a>
</div>
</div>
<div v-if="messagesLoading && messages.length === 0" class="py-24px text-center text-13px text-gray-500 dark:text-gray-400">
加载中...
</div>
<div v-else-if="messages.length === 0" class="py-12px text-center text-13px text-gray-500 dark:text-gray-400">
暂无回复消息
</div>
<div v-for="msg in messages" :key="msg.id" class="mb-10px flex" :class="msg.sender_type === 'staff' ? 'justify-end' : 'justify-start'">
<div
class="max-w-85% rounded-10px px-12px py-8px text-13px leading-relaxed"
:class="msg.sender_type === 'staff'
? 'bg-primary text-white'
: 'bg-container border border-gray-200 dark:border-#ffffff1a text-base-text'"
>
<div v-if="msg.sender_type === 'staff' && msg.sender_name" class="text-11px opacity-80 mb-2px">
{{ msg.sender_name }}
</div>
<div class="whitespace-pre-wrap break-words">{{ msg.content }}</div>
<div class="text-11px mt-4px opacity-70">{{ formatTime(msg.created_at) }}</div>
</div>
</div>
</div>
<div v-if="isResolved" class="rounded-8px bg-layout border border-gray-200 dark:border-#ffffff1a px-12px py-10px text-13px text-gray-500 dark:text-gray-400">
该问题已解决仅可查看历史对话
</div>
<div v-else class="flex flex-col gap-8px">
<NInput
v-model:value="replyContent"
type="textarea"
placeholder="输入回复内容..."
:rows="3"
maxlength="2000"
show-count
@keyup.ctrl.enter="handleSendReply"
/>
<NButton type="primary" :loading="sending" :disabled="!replyContent.trim()" @click="handleSendReply">
发送回复
</NButton>
</div>
</div>
</NDrawerContent>
</NDrawer>
</NSpace>
</template>
<style scoped>
.max-w-85\% {
max-width: 85%;
}
</style>