From 3702ad4163cef7066654991b9f530cd770825a67 Mon Sep 17 00:00:00 2001 From: eafonyang Date: Thu, 20 Aug 2026 16:42:01 +0800 Subject: [PATCH] =?UTF-8?q?www=EF=BC=9A=E6=96=B0=E5=A2=9E=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E5=8F=8D=E9=A6=88=E5=8A=9F=E8=83=BD=EF=BC=9Bdashboard?= =?UTF-8?q?=EF=BC=9A=E9=80=82=E9=85=8D=E9=97=AE=E9=A2=98=E5=8F=8D=E9=A6=88?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=8F=AF=E4=BB=A5=E5=9C=A8=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E7=B3=BB=E7=BB=9F=E5=92=8C=E7=94=A8=E6=88=B7=E6=B2=9F?= =?UTF-8?q?=E9=80=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + dashboard/backend/src/routes/index.ts | 2 + dashboard/backend/src/routes/site.ts | 259 ++++++++++- dashboard/backend/src/routes/www/index.ts | 1 + .../backend/src/routes/www/supportFeedback.ts | 197 ++++++++ dashboard/backend/src/schemas/www/index.ts | 2 + .../src/schemas/www/supportFeedback.ts | 19 + .../src/schemas/www/supportFeedbackMessage.ts | 12 + .../src/services/supportFeedbackMessages.ts | 105 +++++ dashboard/frontend/src/locales/langs/en-us.ts | 2 + dashboard/frontend/src/locales/langs/zh-cn.ts | 2 + .../frontend/src/router/elegant/imports.ts | 1 + .../frontend/src/router/elegant/routes.ts | 29 +- .../frontend/src/router/elegant/transform.ts | 4 +- dashboard/frontend/src/service/api/index.ts | 1 + .../src/service/api/www-support-feedback.ts | 44 ++ dashboard/frontend/src/typings/api/www.d.ts | 27 ++ .../frontend/src/typings/elegant-router.d.ts | 4 + .../src/views/www/support/feedbacks/index.vue | 379 ++++++++++++++++ db/09_support_feedback.sql | 35 ++ db/09_support_feedback_add_no.sql | 11 + db/10_support_feedback_messages.sql | 23 + www/app/components/SupportFeedback.vue | 424 ++++++++++++++++++ www/app/components/SupportFeedbackChat.vue | 234 ++++++++++ www/app/composables/useRequest.ts | 7 +- www/app/composables/useSiteApi.ts | 32 +- www/app/pages/about.vue | 5 +- www/app/pages/support.vue | 6 +- www/app/types/site.ts | 25 ++ www/i18n/locales/en.json | 37 +- www/i18n/locales/zh.json | 37 +- 31 files changed, 1956 insertions(+), 11 deletions(-) create mode 100644 dashboard/backend/src/routes/www/supportFeedback.ts create mode 100644 dashboard/backend/src/schemas/www/supportFeedback.ts create mode 100644 dashboard/backend/src/schemas/www/supportFeedbackMessage.ts create mode 100644 dashboard/backend/src/services/supportFeedbackMessages.ts create mode 100644 dashboard/frontend/src/service/api/www-support-feedback.ts create mode 100644 dashboard/frontend/src/views/www/support/feedbacks/index.vue create mode 100644 db/09_support_feedback.sql create mode 100644 db/09_support_feedback_add_no.sql create mode 100644 db/10_support_feedback_messages.sql create mode 100644 www/app/components/SupportFeedback.vue create mode 100644 www/app/components/SupportFeedbackChat.vue diff --git a/.gitignore b/.gitignore index 34cf5f6..7bddc5d 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ autoeq/ *~ .DS_Store .qoder +.qoder-tmp # Environment .env diff --git a/dashboard/backend/src/routes/index.ts b/dashboard/backend/src/routes/index.ts index 4121b71..e019943 100644 --- a/dashboard/backend/src/routes/index.ts +++ b/dashboard/backend/src/routes/index.ts @@ -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; diff --git a/dashboard/backend/src/routes/site.ts b/dashboard/backend/src/routes/site.ts index 0023b26..48d126e 100644 --- a/dashboard/backend/src/routes/site.ts +++ b/dashboard/backend/src/routes/site.ts @@ -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; diff --git a/dashboard/backend/src/routes/www/index.ts b/dashboard/backend/src/routes/www/index.ts index 1b74ab4..5cf941a 100644 --- a/dashboard/backend/src/routes/www/index.ts +++ b/dashboard/backend/src/routes/www/index.ts @@ -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'; diff --git a/dashboard/backend/src/routes/www/supportFeedback.ts b/dashboard/backend/src/routes/www/supportFeedback.ts new file mode 100644 index 0000000..2f10b96 --- /dev/null +++ b/dashboard/backend/src/routes/www/supportFeedback.ts @@ -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; diff --git a/dashboard/backend/src/schemas/www/index.ts b/dashboard/backend/src/schemas/www/index.ts index d03fa95..ad89a7c 100644 --- a/dashboard/backend/src/schemas/www/index.ts +++ b/dashboard/backend/src/schemas/www/index.ts @@ -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'; diff --git a/dashboard/backend/src/schemas/www/supportFeedback.ts b/dashboard/backend/src/schemas/www/supportFeedback.ts new file mode 100644 index 0000000..2b0c625 --- /dev/null +++ b/dashboard/backend/src/schemas/www/supportFeedback.ts @@ -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`), +}); diff --git a/dashboard/backend/src/schemas/www/supportFeedbackMessage.ts b/dashboard/backend/src/schemas/www/supportFeedbackMessage.ts new file mode 100644 index 0000000..c924e72 --- /dev/null +++ b/dashboard/backend/src/schemas/www/supportFeedbackMessage.ts @@ -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`), +}); diff --git a/dashboard/backend/src/services/supportFeedbackMessages.ts b/dashboard/backend/src/services/supportFeedbackMessages.ts new file mode 100644 index 0000000..00c9139 --- /dev/null +++ b/dashboard/backend/src/services/supportFeedbackMessages.ts @@ -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(); +} diff --git a/dashboard/frontend/src/locales/langs/en-us.ts b/dashboard/frontend/src/locales/langs/en-us.ts index 4c05225..a558c01 100644 --- a/dashboard/frontend/src/locales/langs/en-us.ts +++ b/dashboard/frontend/src/locales/langs/en-us.ts @@ -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: { diff --git a/dashboard/frontend/src/locales/langs/zh-cn.ts b/dashboard/frontend/src/locales/langs/zh-cn.ts index 705a78b..942d6c8 100644 --- a/dashboard/frontend/src/locales/langs/zh-cn.ts +++ b/dashboard/frontend/src/locales/langs/zh-cn.ts @@ -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: { diff --git a/dashboard/frontend/src/router/elegant/imports.ts b/dashboard/frontend/src/router/elegant/imports.ts index 2aefdb8..0cb3c38 100644 --- a/dashboard/frontend/src/router/elegant/imports.ts +++ b/dashboard/frontend/src/router/elegant/imports.ts @@ -51,4 +51,5 @@ export const views: Record Promise 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"), }; diff --git a/dashboard/frontend/src/router/elegant/routes.ts b/dashboard/frontend/src/router/elegant/routes.ts index 4cda9b6..5ed2dd2 100644 --- a/dashboard/frontend/src/router/elegant/routes.ts +++ b/dashboard/frontend/src/router/elegant/routes.ts @@ -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 + } } ] } diff --git a/dashboard/frontend/src/router/elegant/transform.ts b/dashboard/frontend/src/router/elegant/transform.ts index e038bd6..f3a7b8a 100644 --- a/dashboard/frontend/src/router/elegant/transform.ts +++ b/dashboard/frontend/src/router/elegant/transform.ts @@ -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" }; /** diff --git a/dashboard/frontend/src/service/api/index.ts b/dashboard/frontend/src/service/api/index.ts index ed57413..b52ce2f 100644 --- a/dashboard/frontend/src/service/api/index.ts +++ b/dashboard/frontend/src/service/api/index.ts @@ -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'; diff --git a/dashboard/frontend/src/service/api/www-support-feedback.ts b/dashboard/frontend/src/service/api/www-support-feedback.ts new file mode 100644 index 0000000..96cc9d8 --- /dev/null +++ b/dashboard/frontend/src/service/api/www-support-feedback.ts @@ -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>({ + url: '/www/support/feedbacks', + method: 'get', + params, + }); +} + +export function fetchGetSupportFeedback(id: number) { + return request({ url: `/www/support/feedbacks/${id}`, method: 'get' }); +} + +export function fetchUpdateSupportFeedbackStatus(id: number, status: string) { + return request({ + 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 }, + }); +} diff --git a/dashboard/frontend/src/typings/api/www.d.ts b/dashboard/frontend/src/typings/api/www.d.ts index 233968b..fb31310 100644 --- a/dashboard/frontend/src/typings/api/www.d.ts +++ b/dashboard/frontend/src/typings/api/www.d.ts @@ -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; diff --git a/dashboard/frontend/src/typings/elegant-router.d.ts b/dashboard/frontend/src/typings/elegant-router.d.ts index 537b1c9..3f6aef8 100644 --- a/dashboard/frontend/src/typings/elegant-router.d.ts +++ b/dashboard/frontend/src/typings/elegant-router.d.ts @@ -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" >; /** diff --git a/dashboard/frontend/src/views/www/support/feedbacks/index.vue b/dashboard/frontend/src/views/www/support/feedbacks/index.vue new file mode 100644 index 0000000..fc5ea15 --- /dev/null +++ b/dashboard/frontend/src/views/www/support/feedbacks/index.vue @@ -0,0 +1,379 @@ + + + + + diff --git a/db/09_support_feedback.sql b/db/09_support_feedback.sql new file mode 100644 index 0000000..3466515 --- /dev/null +++ b/db/09_support_feedback.sql @@ -0,0 +1,35 @@ +-- ============================================================ +-- 09_support_feedback.sql +-- 官网问题反馈(support 页面用户提交) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `www_support_feedbacks` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `feedback_no` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '反馈编号(对外展示,如 FB20260818A3F2B1)', + `product_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '关联产品 ID(www_products.id)', + `product_name_zh` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '提交时产品中文名快照', + `product_name_en` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '提交时产品英文名快照', + `description` TEXT NOT NULL COMMENT '问题描述', + `email` VARCHAR(100) NOT NULL COMMENT '联系邮箱', + `log_file_path` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '日志文件相对路径(/uploads/support-logs/...,空表示未上传)', + `log_file_name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '日志原始文件名', + `log_file_size` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '日志文件大小(字节)', + `status` VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '处理状态: pending / processing / resolved', + `visitor_ip` VARCHAR(45) NOT NULL DEFAULT '' COMMENT '访客 IP', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_feedback_no` (`feedback_no`), + KEY `idx_email` (`email`), + KEY `idx_created` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='官网问题反馈'; + +-- 若表已存在(无 feedback_no 列),按顺序执行以下迁移: +-- ALTER TABLE `www_support_feedbacks` +-- ADD COLUMN `feedback_no` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '反馈编号(对外展示)' AFTER `id`; +-- UPDATE `www_support_feedbacks` +-- SET `feedback_no` = CONCAT('FB', DATE_FORMAT(`created_at`, '%Y%m%d'), UPPER(SUBSTRING(MD5(CONCAT(`id`, `created_at`)), 1, 6))) +-- WHERE `feedback_no` = ''; +-- ALTER TABLE `www_support_feedbacks` +-- ADD UNIQUE KEY `uk_feedback_no` (`feedback_no`); diff --git a/db/09_support_feedback_add_no.sql b/db/09_support_feedback_add_no.sql new file mode 100644 index 0000000..f9664ff --- /dev/null +++ b/db/09_support_feedback_add_no.sql @@ -0,0 +1,11 @@ +-- 为已存在的 www_support_feedbacks 表添加 feedback_no 列(分步执行,避免空值 UNIQUE 冲突) + +ALTER TABLE `www_support_feedbacks` + ADD COLUMN `feedback_no` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '反馈编号(对外展示)' AFTER `id`; + +UPDATE `www_support_feedbacks` + SET `feedback_no` = CONCAT('FB', DATE_FORMAT(`created_at`, '%Y%m%d'), UPPER(SUBSTRING(MD5(CONCAT(`id`, `created_at`)), 1, 6))) + WHERE `feedback_no` = ''; + +ALTER TABLE `www_support_feedbacks` + ADD UNIQUE KEY `uk_feedback_no` (`feedback_no`); diff --git a/db/10_support_feedback_messages.sql b/db/10_support_feedback_messages.sql new file mode 100644 index 0000000..c79291b --- /dev/null +++ b/db/10_support_feedback_messages.sql @@ -0,0 +1,23 @@ +-- ============================================================ +-- 10_support_feedback_messages.sql +-- 问题反馈对话消息 + feedbacks.updated_at +-- ============================================================ + +-- 工单表增加最后更新时间(列表排序、对话活跃时间) +ALTER TABLE `www_support_feedbacks` + ADD COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER `created_at`; + +-- 对话消息表 +CREATE TABLE IF NOT EXISTS `www_support_feedback_messages` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `feedback_id` BIGINT UNSIGNED NOT NULL COMMENT '关联 www_support_feedbacks.id', + `sender_type` VARCHAR(10) NOT NULL COMMENT 'user / staff', + `sender_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'staff 时为 dashboard_user.id', + `sender_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '显示名快照', + `content` TEXT NOT NULL COMMENT '消息内容', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_feedback_created` (`feedback_id`, `created_at`), + KEY `idx_feedback_id` (`feedback_id`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='官网问题反馈对话消息'; diff --git a/www/app/components/SupportFeedback.vue b/www/app/components/SupportFeedback.vue new file mode 100644 index 0000000..55c032d --- /dev/null +++ b/www/app/components/SupportFeedback.vue @@ -0,0 +1,424 @@ + + + diff --git a/www/app/components/SupportFeedbackChat.vue b/www/app/components/SupportFeedbackChat.vue new file mode 100644 index 0000000..b3498e4 --- /dev/null +++ b/www/app/components/SupportFeedbackChat.vue @@ -0,0 +1,234 @@ + + +