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
+2
View File
@@ -22,6 +22,7 @@ import {
wwwI18nRouter,
wwwContactRouter,
wwwPageSeoRouter,
wwwSupportFeedbackRouter,
} from './www/index.js';
const routes: Router[] = [
@@ -51,6 +52,7 @@ const routes: Router[] = [
wwwI18nRouter,
wwwContactRouter,
wwwPageSeoRouter,
wwwSupportFeedbackRouter,
];
export default routes;
+258 -1
View File
@@ -1,5 +1,9 @@
import { Router, type Request, type Response } from 'express';
import { eq, and, desc, count, like, isNull, inArray } from 'drizzle-orm';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { db } from '../config/database.js';
import {
wwwSiteSettings,
@@ -24,16 +28,23 @@ import {
wwwFormSettings,
wwwFormFields,
wwwFormSubmissions,
wwwSupportFeedbacks,
} from '../schemas/index.js';
import logger from '../config/logger.js';
import { ApiResponse } from '../utils/response.js';
import {
getFeedbackByNo,
verifyFeedbackEmail,
listFeedbackMessagesForDisplay,
insertUserMessage,
} from '../services/supportFeedbackMessages.js';
/**
* 官网前台公开只读接口(供 Nuxt 官网消费)
*
* 与 dashboard 管理端接口(/api/www/*,需 JWT 鉴权)完全隔离:
* - 无需登录,公开访问
* - 仅提供 GET 只读能力
* - GET 只读为主,另含联系表单 / 问题反馈等公开提交接口
* - 仅返回对前台可见的内容(is_visible=1、已发布等)
*/
const router = Router();
@@ -384,4 +395,250 @@ router.post('/api/site/contact/submit', async (req: Request, res: Response) => {
}
});
// ===== 问题反馈:日志上传存储(公开) =====
const FEEDBACK_LOG_DIR = path.resolve(process.cwd(), 'uploads/support-logs');
const feedbackUpload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => {
fs.mkdirSync(FEEDBACK_LOG_DIR, { recursive: true });
cb(null, FEEDBACK_LOG_DIR);
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).slice(0, 20);
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
},
}),
limits: { fileSize: 20 * 1024 * 1024 }, // 20MB
});
const FEEDBACK_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const FEEDBACK_NO_RE = /^FB\d{8}[0-9A-Z]{6}$/;
function formatFeedbackNoDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}${m}${day}`;
}
function generateFeedbackNo(): string {
const rand = crypto.randomBytes(3).toString('hex').toUpperCase();
return `FB${formatFeedbackNoDate(new Date())}${rand}`;
}
const feedbackSelectFields = {
id: wwwSupportFeedbacks.id,
feedbackNo: wwwSupportFeedbacks.feedbackNo,
productId: wwwSupportFeedbacks.productId,
productNameZh: wwwSupportFeedbacks.productNameZh,
productNameEn: wwwSupportFeedbacks.productNameEn,
description: wwwSupportFeedbacks.description,
status: wwwSupportFeedbacks.status,
logFileName: wwwSupportFeedbacks.logFileName,
logFilePath: wwwSupportFeedbacks.logFilePath,
logFileSize: wwwSupportFeedbacks.logFileSize,
createdAt: wwwSupportFeedbacks.createdAt,
};
// ===== 问题反馈:提交(公开,multipart/form-data =====
// POST /api/site/support/feedback
// fields: product_id / description / email / log_file(可选)
router.post('/api/site/support/feedback', feedbackUpload.single('log_file'), async (req: Request, res: Response) => {
try {
const b = req.body;
const productId = Number(b.product_id);
const description = String(b.description ?? '').trim();
const email = String(b.email ?? '').trim().slice(0, 100);
if (!Number.isInteger(productId) || productId <= 0) {
res.json(ApiResponse.error('请选择型号'));
return;
}
if (!description || description.length > 2000) {
res.json(ApiResponse.error('问题描述长度需在 1-2000 字之间'));
return;
}
if (!FEEDBACK_EMAIL_RE.test(email)) {
res.json(ApiResponse.error('请输入有效的邮箱地址'));
return;
}
// 型号必须为产品列表中可见的产品
const [product] = await db.select().from(wwwProducts)
.where(and(eq(wwwProducts.id, productId), eq(wwwProducts.isVisible, 1)));
if (!product) {
res.json(ApiResponse.error('所选型号不存在'));
return;
}
// 防滥用:同一 IP 60 秒冷却
const ip = (req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() || req.ip || '';
if (ip) {
const [last] = await db.select().from(wwwSupportFeedbacks)
.where(eq(wwwSupportFeedbacks.visitorIp, ip))
.orderBy(desc(wwwSupportFeedbacks.createdAt))
.limit(1);
if (last) {
const elapsed = (Date.now() - new Date(last.createdAt).getTime()) / 1000;
if (elapsed < 60) {
if (req.file) fs.unlink(req.file.path, () => {});
res.json(ApiResponse.error(`提交过于频繁,请 ${Math.ceil(60 - elapsed)} 秒后再试`));
return;
}
}
}
const file = req.file;
// 生成唯一反馈编号(冲突时重试)
let feedbackNo = generateFeedbackNo();
for (let attempt = 0; attempt < 5; attempt++) {
try {
await db.insert(wwwSupportFeedbacks).values({
feedbackNo,
productId,
productNameZh: product.nameZh,
productNameEn: product.nameEn,
description,
email,
logFilePath: file ? `/uploads/support-logs/${path.basename(file.path)}` : '',
logFileName: file ? file.originalname.slice(0, 255) : '',
logFileSize: file ? file.size : 0,
visitorIp: ip,
});
break;
} catch (insertErr: unknown) {
const msg = insertErr instanceof Error ? insertErr.message : String(insertErr);
if (msg.includes('Duplicate') && msg.includes('feedback_no') && attempt < 4) {
feedbackNo = generateFeedbackNo();
continue;
}
throw insertErr;
}
}
res.json(ApiResponse.success({ feedback_no: feedbackNo }, '反馈提交成功'));
} catch (e: unknown) {
if (e instanceof multer.MulterError && e.code === 'LIMIT_FILE_SIZE') {
res.json(ApiResponse.error('日志文件大小不能超过 20MB'));
return;
}
logger.error(`[site/support/feedback] POST error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('提交失败,请稍后重试'));
}
});
// ===== 问题反馈:按邮箱或编号查询(公开) =====
// GET /api/site/support/feedbacks?email=xxx
// GET /api/site/support/feedbacks?feedback_no=FB20260818A3F2B1
router.get('/api/site/support/feedbacks', async (req: Request, res: Response) => {
try {
const email = String(req.query.email ?? '').trim();
const feedbackNo = String(req.query.feedback_no ?? '').trim().toUpperCase();
if (!email && !feedbackNo) {
res.json(ApiResponse.error('请提供 email 或 feedback_no 参数'));
return;
}
if (email && feedbackNo) {
res.json(ApiResponse.error('email 与 feedback_no 不能同时传入'));
return;
}
if (feedbackNo) {
if (!FEEDBACK_NO_RE.test(feedbackNo)) {
res.json(ApiResponse.error('反馈编号格式不正确'));
return;
}
const rows = await db.select(feedbackSelectFields).from(wwwSupportFeedbacks)
.where(eq(wwwSupportFeedbacks.feedbackNo, feedbackNo))
.limit(1);
res.json(ApiResponse.success({ items: rows, total: rows.length }));
return;
}
const rows = await db.select(feedbackSelectFields).from(wwwSupportFeedbacks)
.where(eq(wwwSupportFeedbacks.email, email))
.orderBy(desc(wwwSupportFeedbacks.createdAt), desc(wwwSupportFeedbacks.id))
.limit(50);
res.json(ApiResponse.success({ items: rows, total: rows.length }));
} catch (e: unknown) {
logger.error(`[site/support/feedbacks] GET error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('获取反馈列表失败'));
}
});
// ===== 问题反馈:对话消息(公开,需 feedback_no + email 校验) =====
// GET /api/site/support/feedbacks/:feedback_no/messages?email=xxx&since_id=
router.get('/api/site/support/feedbacks/:feedback_no/messages', async (req: Request, res: Response) => {
try {
const feedbackNo = String(req.params.feedback_no ?? '').trim().toUpperCase();
const email = String(req.query.email ?? '').trim();
const sinceId = parseInt(req.query.since_id as string || '0', 10);
if (!FEEDBACK_NO_RE.test(feedbackNo)) {
res.json(ApiResponse.error('反馈编号格式不正确'));
return;
}
if (!FEEDBACK_EMAIL_RE.test(email)) {
res.json(ApiResponse.error('请输入有效的邮箱地址'));
return;
}
const feedback = await getFeedbackByNo(feedbackNo);
if (!feedback || !verifyFeedbackEmail(feedback, email)) {
res.json(ApiResponse.error('反馈不存在或邮箱不匹配'));
return;
}
const messages = await listFeedbackMessagesForDisplay(feedback.id, sinceId > 0 ? sinceId : undefined);
res.json(ApiResponse.success({ items: messages, total: messages.length, status: feedback.status }));
} catch (e: unknown) {
logger.error(`[site/support/feedbacks/:feedback_no/messages] GET error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('获取对话消息失败'));
}
});
// POST /api/site/support/feedbacks/:feedback_no/messages
router.post('/api/site/support/feedbacks/:feedback_no/messages', async (req: Request, res: Response) => {
try {
const feedbackNo = String(req.params.feedback_no ?? '').trim().toUpperCase();
const email = String(req.body.email ?? '').trim();
const content = String(req.body.content ?? '').trim();
if (!FEEDBACK_NO_RE.test(feedbackNo)) {
res.json(ApiResponse.error('反馈编号格式不正确'));
return;
}
if (!FEEDBACK_EMAIL_RE.test(email)) {
res.json(ApiResponse.error('请输入有效的邮箱地址'));
return;
}
if (!content || content.length > 2000) {
res.json(ApiResponse.error('消息长度需在 1-2000 字之间'));
return;
}
const feedback = await getFeedbackByNo(feedbackNo);
if (!feedback || !verifyFeedbackEmail(feedback, email)) {
res.json(ApiResponse.error('反馈不存在或邮箱不匹配'));
return;
}
if (feedback.status === 'resolved') {
res.json(ApiResponse.error('该问题已解决,无法继续发送消息'));
return;
}
await insertUserMessage(feedback.id, content);
const messages = await listFeedbackMessagesForDisplay(feedback.id);
res.json(ApiResponse.success({ items: messages, total: messages.length }, '发送成功'));
} catch (e: unknown) {
logger.error(`[site/support/feedbacks/:feedback_no/messages] POST error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('发送失败,请稍后重试'));
}
});
export default router;
@@ -11,3 +11,4 @@ export { default as wwwMediaRouter } from './media.js';
export { default as wwwI18nRouter } from './i18n.js';
export { default as wwwContactRouter } from './contact.js';
export { default as wwwPageSeoRouter } from './pageSeo.js';
export { default as wwwSupportFeedbackRouter } from './supportFeedback.js';
@@ -0,0 +1,197 @@
import { Router, type Request, type Response } from 'express';
import { eq, and, count, desc, gte, lte, like } from 'drizzle-orm';
import { db } from '../../config/database.js';
import { wwwSupportFeedbacks } from '../../schemas/index.js';
import logger from '../../config/logger.js';
import { ApiResponse } from '../../utils/response.js';
import { authMiddleware } from '../../middleware/auth.js';
import {
getFeedbackById,
listFeedbackMessagesForDisplay,
insertStaffMessage,
updateFeedbackStatus,
} from '../../services/supportFeedbackMessages.js';
const router = Router();
router.use(authMiddleware);
const VALID_STATUSES = new Set(['pending', 'processing', 'resolved']);
const MESSAGE_MAX_LEN = 2000;
const feedbackListFields = {
id: wwwSupportFeedbacks.id,
feedbackNo: wwwSupportFeedbacks.feedbackNo,
productId: wwwSupportFeedbacks.productId,
productNameZh: wwwSupportFeedbacks.productNameZh,
productNameEn: wwwSupportFeedbacks.productNameEn,
description: wwwSupportFeedbacks.description,
email: wwwSupportFeedbacks.email,
status: wwwSupportFeedbacks.status,
logFileName: wwwSupportFeedbacks.logFileName,
logFilePath: wwwSupportFeedbacks.logFilePath,
logFileSize: wwwSupportFeedbacks.logFileSize,
visitorIp: wwwSupportFeedbacks.visitorIp,
createdAt: wwwSupportFeedbacks.createdAt,
updatedAt: wwwSupportFeedbacks.updatedAt,
};
// GET /api/www/support/feedbacks
router.get('/api/www/support/feedbacks', async (req: Request, res: Response) => {
try {
const skip = parseInt(req.query.skip as string || '0', 10);
const limit = Math.min(parseInt(req.query.limit as string || '20', 10), 100);
const status = String(req.query.status ?? '').trim();
const email = String(req.query.email ?? '').trim();
const feedbackNo = String(req.query.feedback_no ?? '').trim();
const startDate = req.query.start_date as string | undefined;
const endDate = req.query.end_date as string | undefined;
const conditions = [];
if (status && VALID_STATUSES.has(status)) {
conditions.push(eq(wwwSupportFeedbacks.status, status));
}
if (email) {
conditions.push(like(wwwSupportFeedbacks.email, `%${email}%`));
}
if (feedbackNo) {
conditions.push(like(wwwSupportFeedbacks.feedbackNo, `%${feedbackNo.toUpperCase()}%`));
}
if (startDate) {
conditions.push(gte(wwwSupportFeedbacks.createdAt, new Date(startDate)));
}
if (endDate) {
conditions.push(lte(wwwSupportFeedbacks.createdAt, new Date(`${endDate} 23:59:59`)));
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const [totalResult] = await db.select({ value: count() }).from(wwwSupportFeedbacks).where(whereClause);
const total = totalResult?.value ?? 0;
const rows = await db.select(feedbackListFields).from(wwwSupportFeedbacks)
.where(whereClause)
.orderBy(desc(wwwSupportFeedbacks.updatedAt), desc(wwwSupportFeedbacks.id))
.offset(skip)
.limit(limit);
res.json(ApiResponse.success({ items: rows, total, skip, limit }));
} catch (e: unknown) {
logger.error(`[www/support/feedbacks] GET error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('获取反馈列表失败'));
}
});
// GET /api/www/support/feedbacks/:id
router.get('/api/www/support/feedbacks/:id', async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
res.json(ApiResponse.error('无效的 ID'));
return;
}
const feedback = await getFeedbackById(id);
if (!feedback) {
res.json(ApiResponse.noData('反馈不存在'));
return;
}
res.json(ApiResponse.success(feedback));
} catch (e: unknown) {
logger.error(`[www/support/feedbacks/:id] GET error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('获取反馈详情失败'));
}
});
// PATCH /api/www/support/feedbacks/:id
router.patch('/api/www/support/feedbacks/:id', async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
const status = String(req.body.status ?? '').trim();
if (!Number.isInteger(id) || id <= 0) {
res.json(ApiResponse.error('无效的 ID'));
return;
}
if (!VALID_STATUSES.has(status)) {
res.json(ApiResponse.error('无效的状态值'));
return;
}
const feedback = await getFeedbackById(id);
if (!feedback) {
res.json(ApiResponse.noData('反馈不存在'));
return;
}
await updateFeedbackStatus(id, status);
const updated = await getFeedbackById(id);
res.json(ApiResponse.success(updated, '状态已更新'));
} catch (e: unknown) {
logger.error(`[www/support/feedbacks/:id] PATCH error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('更新状态失败'));
}
});
// GET /api/www/support/feedbacks/:id/messages
router.get('/api/www/support/feedbacks/:id/messages', async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
const sinceId = parseInt(req.query.since_id as string || '0', 10);
if (!Number.isInteger(id) || id <= 0) {
res.json(ApiResponse.error('无效的 ID'));
return;
}
const feedback = await getFeedbackById(id);
if (!feedback) {
res.json(ApiResponse.noData('反馈不存在'));
return;
}
const messages = await listFeedbackMessagesForDisplay(id, sinceId > 0 ? sinceId : undefined);
res.json(ApiResponse.success({ items: messages, total: messages.length }));
} catch (e: unknown) {
logger.error(`[www/support/feedbacks/:id/messages] GET error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('获取对话消息失败'));
}
});
// POST /api/www/support/feedbacks/:id/messages
router.post('/api/www/support/feedbacks/:id/messages', async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
const content = String(req.body.content ?? '').trim();
if (!Number.isInteger(id) || id <= 0) {
res.json(ApiResponse.error('无效的 ID'));
return;
}
if (!content || content.length > MESSAGE_MAX_LEN) {
res.json(ApiResponse.error(`消息长度需在 1-${MESSAGE_MAX_LEN} 字之间`));
return;
}
const feedback = await getFeedbackById(id);
if (!feedback) {
res.json(ApiResponse.noData('反馈不存在'));
return;
}
if (feedback.status === 'resolved') {
res.json(ApiResponse.error('该问题已解决,无法继续发送消息'));
return;
}
const user = req.user!;
await insertStaffMessage(id, content, { id: user.id, username: user.username });
const messages = await listFeedbackMessagesForDisplay(id);
res.json(ApiResponse.success({ items: messages, total: messages.length }, '回复成功'));
} catch (e: unknown) {
logger.error(`[www/support/feedbacks/:id/messages] POST error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('发送回复失败'));
}
});
export default router;
@@ -10,3 +10,5 @@ export * from './media.js';
export * from './i18n.js';
export * from './pages.js';
export * from './contact.js';
export * from './supportFeedback.js';
export * from './supportFeedbackMessage.js';
@@ -0,0 +1,19 @@
import { mysqlTable, bigint, varchar, text, int, datetime } from 'drizzle-orm/mysql-core';
import { sql } from 'drizzle-orm';
export const wwwSupportFeedbacks = mysqlTable('www_support_feedbacks', {
id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(),
feedbackNo: varchar('feedback_no', { length: 20 }).notNull().default(''),
productId: bigint('product_id', { mode: 'number', unsigned: true }).notNull().default(0),
productNameZh: varchar('product_name_zh', { length: 100 }).notNull().default(''),
productNameEn: varchar('product_name_en', { length: 100 }).notNull().default(''),
description: text('description').notNull(),
email: varchar('email', { length: 100 }).notNull(),
logFilePath: varchar('log_file_path', { length: 255 }).notNull().default(''),
logFileName: varchar('log_file_name', { length: 255 }).notNull().default(''),
logFileSize: int('log_file_size', { unsigned: true }).notNull().default(0),
status: varchar('status', { length: 20 }).notNull().default('pending'),
visitorIp: varchar('visitor_ip', { length: 45 }).notNull().default(''),
createdAt: datetime('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: datetime('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
@@ -0,0 +1,12 @@
import { mysqlTable, bigint, varchar, text, datetime } from 'drizzle-orm/mysql-core';
import { sql } from 'drizzle-orm';
export const wwwSupportFeedbackMessages = mysqlTable('www_support_feedback_messages', {
id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(),
feedbackId: bigint('feedback_id', { mode: 'number', unsigned: true }).notNull(),
senderType: varchar('sender_type', { length: 10 }).notNull(),
senderId: bigint('sender_id', { mode: 'number', unsigned: true }).notNull().default(0),
senderName: varchar('sender_name', { length: 100 }).notNull().default(''),
content: text('content').notNull(),
createdAt: datetime('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
@@ -0,0 +1,105 @@
import { eq, and, gt, desc, asc } from 'drizzle-orm';
import { db } from '../config/database.js';
import { wwwSupportFeedbacks, wwwSupportFeedbackMessages } from '../schemas/index.js';
export const messageSelectFields = {
id: wwwSupportFeedbackMessages.id,
feedbackId: wwwSupportFeedbackMessages.feedbackId,
senderType: wwwSupportFeedbackMessages.senderType,
senderId: wwwSupportFeedbackMessages.senderId,
senderName: wwwSupportFeedbackMessages.senderName,
content: wwwSupportFeedbackMessages.content,
createdAt: wwwSupportFeedbackMessages.createdAt,
};
export async function getFeedbackByNo(feedbackNo: string) {
const [row] = await db.select().from(wwwSupportFeedbacks)
.where(eq(wwwSupportFeedbacks.feedbackNo, feedbackNo))
.limit(1);
return row ?? null;
}
export async function getFeedbackById(id: number) {
const [row] = await db.select().from(wwwSupportFeedbacks)
.where(eq(wwwSupportFeedbacks.id, id))
.limit(1);
return row ?? null;
}
export function verifyFeedbackEmail(feedback: { email: string }, email: string): boolean {
return feedback.email.trim().toLowerCase() === email.trim().toLowerCase();
}
export async function listFeedbackMessages(feedbackId: number, sinceId?: number) {
const conditions = [eq(wwwSupportFeedbackMessages.feedbackId, feedbackId)];
if (sinceId && sinceId > 0) {
conditions.push(gt(wwwSupportFeedbackMessages.id, sinceId));
}
const rows = await db.select(messageSelectFields).from(wwwSupportFeedbackMessages)
.where(and(...conditions))
.orderBy(asc(wwwSupportFeedbackMessages.id))
.limit(sinceId ? 50 : 500);
return rows;
}
async function touchFeedbackUpdatedAt(feedbackId: number) {
await db.update(wwwSupportFeedbacks)
.set({ updatedAt: new Date() })
.where(eq(wwwSupportFeedbacks.id, feedbackId));
}
export async function insertUserMessage(feedbackId: number, content: string) {
await db.insert(wwwSupportFeedbackMessages).values({
feedbackId,
senderType: 'user',
senderId: 0,
senderName: '',
content,
});
await touchFeedbackUpdatedAt(feedbackId);
}
export async function insertStaffMessage(
feedbackId: number,
content: string,
staff: { id: number; username: string },
) {
await db.insert(wwwSupportFeedbackMessages).values({
feedbackId,
senderType: 'staff',
senderId: staff.id,
senderName: staff.username.slice(0, 100),
content,
});
const feedback = await getFeedbackById(feedbackId);
if (feedback && feedback.status === 'pending') {
await db.update(wwwSupportFeedbacks)
.set({ status: 'processing', updatedAt: new Date() })
.where(eq(wwwSupportFeedbacks.id, feedbackId));
} else {
await touchFeedbackUpdatedAt(feedbackId);
}
}
export async function updateFeedbackStatus(feedbackId: number, status: string) {
await db.update(wwwSupportFeedbacks)
.set({ status, updatedAt: new Date() })
.where(eq(wwwSupportFeedbacks.id, feedbackId));
}
/** 全量拉取时按 id 倒序取最近消息再正序返回 */
export async function listFeedbackMessagesForDisplay(feedbackId: number, sinceId?: number) {
if (sinceId && sinceId > 0) {
return listFeedbackMessages(feedbackId, sinceId);
}
const rows = await db.select(messageSelectFields).from(wwwSupportFeedbackMessages)
.where(eq(wwwSupportFeedbackMessages.feedbackId, feedbackId))
.orderBy(desc(wwwSupportFeedbackMessages.id))
.limit(500);
return rows.reverse();
}
@@ -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>