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