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