From 07ae18f0336d5c50dfff780f15d2e5a0bc9dc356 Mon Sep 17 00:00:00 2001 From: reputationly <197039020@qq.com> Date: Wed, 10 Jun 2026 12:41:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(feedback):=20=E6=88=91=E7=9A=84=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=E7=8B=AC=E7=AB=8B=E9=A1=B5=E9=9D=A2=20+=20=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E4=BE=A7=E8=84=B1=E6=95=8F=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=20+=20=E6=8B=96=E6=8B=BD=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将「我的工单」从个人设置卡片改为独立的侧边栏页面(分页表格 + 抽屉 详情 + 新建抽屉),归入个人中心区域,可被超级管理员通过侧边栏模块 开关控制显隐;同时补齐管理员区域缺失的「工单管理」开关。 - 后端:GetFeedbackMessages 增加 maskAdmin 参数,用户侧详情不返回 管理员真名、author_id 置 0,避免暴露管理员账号(撞库/猜密码风险); GetUserFeedbackTopics 支持 keyword 标题搜索(参数化)。 - 前端:新增 pages/Feedback/MyFeedback.jsx 与 /console/myfeedback 路由; 移除 PersonalSetting 中的 FeedbackConsult 卡片;侧边栏「工单管理」移到 「用户管理」下方并与「我的工单」共用 Ticket 图标;两端列表新增创建时间列。 - 上传:抽取 encodeFeedbackImageFiles 共享工具,回复框与新建表单均支持 拖拽上传,函数式 setter 封顶保证不超过 3 张。 - 设置页加载复用 mergeAdminConfig,修复新增模块开关「显示关却实际可见」。 - 清理:删除未使用的 UserContext import;补充中英 i18n 文案。 --- controller/feedback.go | 19 +- docs/feedback-consult-design.md | 96 +++- model/feedback.go | 13 +- web/classic/src/App.jsx | 9 + .../components/feedback/FeedbackThread.jsx | 56 +- .../components/feedback/feedbackHelpers.js | 20 + .../src/components/layout/SiderBar.jsx | 20 +- .../components/settings/PersonalSetting.jsx | 4 - .../personal/cards/FeedbackConsult.jsx | 390 ------------- web/classic/src/helpers/render.jsx | 1 + web/classic/src/hooks/common/useSidebar.js | 1 + web/classic/src/i18n/locales/en.json | 5 + web/classic/src/pages/Feedback/MyFeedback.jsx | 541 ++++++++++++++++++ web/classic/src/pages/Feedback/index.jsx | 6 + .../Operation/SettingsSidebarModulesAdmin.jsx | 41 +- .../Personal/SettingsSidebarModulesUser.jsx | 7 +- 16 files changed, 779 insertions(+), 450 deletions(-) delete mode 100644 web/classic/src/components/settings/personal/cards/FeedbackConsult.jsx create mode 100644 web/classic/src/pages/Feedback/MyFeedback.jsx diff --git a/controller/feedback.go b/controller/feedback.go index 1d3c965ffb2..671db2349c5 100644 --- a/controller/feedback.go +++ b/controller/feedback.go @@ -38,9 +38,10 @@ func GetUserFeedbackTopics(c *gin.Context) { userId := c.GetInt("id") status, _ := strconv.Atoi(c.DefaultQuery("status", "0")) category, _ := strconv.Atoi(c.DefaultQuery("category", "0")) + keyword := strings.TrimSpace(c.Query("keyword")) page, pageSize := parsePaging(c, feedbackDefaultPageSize, feedbackMaxTopicPageSize) - topics, total, err := model.GetUserFeedbackTopics(userId, status, category, page, pageSize) + topics, total, err := model.GetUserFeedbackTopics(userId, status, category, keyword, page, pageSize) if err != nil { common.ApiErrorMsg(c, "查询失败") return @@ -102,7 +103,7 @@ func GetUserFeedbackTopicDetail(c *gin.Context) { } model.MarkFeedbackUserRead(id, userId) topic.UserUnread = false - feedbackWriteDetail(c, topic, "") + feedbackWriteDetail(c, topic, "", true) } // ReplyFeedbackTopic POST /api/user/feedback/topics/:id/messages @@ -203,7 +204,7 @@ func AdminGetFeedbackTopicDetail(c *gin.Context) { if names := feedbackUsernames([]int{topic.UserId}); names != nil { username = names[topic.UserId] } - feedbackWriteDetail(c, topic, username) + feedbackWriteDetail(c, topic, username, false) } // AdminReplyFeedbackTopic POST /api/user/feedback/admin/topics/:id/messages @@ -300,16 +301,22 @@ func feedbackAddMessage(c *gin.Context, topicId, authorId, authorRole int) { } // feedbackWriteDetail 读取消息分页并输出工单详情。 -func feedbackWriteDetail(c *gin.Context, topic *model.FeedbackTopic, username string) { +// maskAdmin=true(用户侧)时隐去管理员消息的真名与 user_id,统一「官方客服」。 +func feedbackWriteDetail(c *gin.Context, topic *model.FeedbackTopic, username string, maskAdmin bool) { page, pageSize := parsePaging(c, feedbackMsgPageSize, feedbackMaxMsgPageSize) - messages, total, err := model.GetFeedbackMessages(topic.Id, page, pageSize) + messages, total, err := model.GetFeedbackMessages(topic.Id, page, pageSize, maskAdmin) if err != nil { common.ApiErrorMsg(c, "查询失败") return } items := make([]dto.FeedbackMessageItem, 0, len(messages)) for _, m := range messages { - items = append(items, feedbackMessageToItem(m)) + item := feedbackMessageToItem(m) + // 用户侧脱敏:管理员消息不暴露具体管理员 user_id(前端按空名显示「官方客服」)。 + if maskAdmin && m.AuthorRole == model.FeedbackAuthorAdmin { + item.AuthorId = 0 + } + items = append(items, item) } common.ApiSuccess(c, dto.FeedbackTopicDetailResponse{ Topic: feedbackTopicToItem(topic, username), diff --git a/docs/feedback-consult-design.md b/docs/feedback-consult-design.md index 46ca3199926..2038edf1a32 100644 --- a/docs/feedback-consult-design.md +++ b/docs/feedback-consult-design.md @@ -1,11 +1,11 @@ # 用户工单(Ticket)设计文档 -> **命名**:用户侧菜单/卡片 =「**我的工单**」;管理后台页 =「**工单管理**」。功能内核仍是"用户发帖 + 管理员回复"的轻量工单(原名"建议及咨询/反馈管理",已按需求改名)。文件名保留 `feedback-consult-design.md`。 +> **命名**:用户侧菜单/页面 =「**我的工单**」(v0.5 起为独立页面,原个人设置卡片已废弃);管理后台页 =「**工单管理**」。功能内核仍是"用户发帖 + 管理员回复"的轻量工单(原名"建议及咨询/反馈管理",已按需求改名)。文件名保留 `feedback-consult-design.md`。 -> 版本:v0.4(草案,待评审) +> 版本:v0.5(草案,待评审) > 适用项目:new-api > 模式:**用户发帖 + 管理员回复**(轻量工单 / 站内对话) -> 日期:2026-06-09 +> 日期:2026-06-10 > > **设计基线**:在结构、路由分层、图片处理、权限、生命周期、审计等维度上**复用 KYC / 企业认证已落地的范式**(见 `docs/kyc-design.md`、`docs/enterprise-cert-design.md`)。本文档只描述本功能的**专有设计**与与既有模式的**差异点**,凡未特别说明处,约定与跨库兼容策略与二者一致。 > @@ -14,21 +14,31 @@ > **v0.3 修订**:⑪管理员回复**可区分具体管理员**——消息已存发言者 `UserId`,详情接口补返 `author_id` + `author_name`,气泡显示是哪位管理员(§二、§四、§六);⑫管理员列表**按用户筛选**做实——支持 `user_id` 精确 + `username` 模糊(§四、§六)。 > > **v0.4 修订**:⑬轮询定为 **30s**、明确不做"正在输入";⑭两端列表均按 `last_reply_at DESC`(最新创建/回复置顶);⑮明确**非回合制**,一方可连发多条;⑯新增 **§十 性能与扩展性**——未读轮询的负载画像与 v1 强制三优化(后台暂停轮询 / 无工单不轮询 / Redis 缓存计数,Redis 缺失自动回退 DB)。 +> +> **v0.5 修订**(对已落地 v0.4 实现的产品形态调整,针对工单变多后的管理与安全): +> - **⑰用户侧从「个人设置卡片」改为独立「我的工单」页面**:卡片没有分页、工单一多就难管理。移除 `PersonalSetting.jsx` 中的 `FeedbackConsult` 卡片,新增一个独立的「我的工单」侧边栏页面,复用「工单管理」那套**分页表格 + 抽屉详情**的范式;后端仍走用户侧 API(`user_id` 强制隔离不变),用户**只能看到自己的工单**(§六 6.1)。 +> - **⑱「我的工单」页面纳入超级管理员侧边栏模块开关**:作为一个独立菜单项,在「系统设置 → 侧边栏管理(全局控制)」里可被超管开关显隐,与现有 `console`/`personal` 等模块同一套 `SidebarModulesAdmin` 机制(§六 6.4)。 +> - **⑲两端列表新增「创建时间」列**:「我的工单」与「工单管理」表格都补一列工单**创建时间**(`created_at`,DTO 已含,无需改后端模型)(§四、§六)。 +> - **⑳用户侧隐去具体管理员身份(安全收口,推翻 v0.3 默认)**:§2.2 当时留的「默认对用户也展示管理员真名」的开关,现**定为对终端用户隐藏**——用户侧详情接口对管理员消息**不返回真实用户名与 user_id**,统一显示「官方客服」。理由:暴露管理员账号名会被用于**撞库/猜测密码**等定向攻击。管理员侧仍返回真名,便于内部辨认是哪位同事回的(§2.2、§四 4.1、§八)。 --- ## 一、背景与目标 -平台目前缺少「用户 ↔ 管理员」的轻量沟通通道:用户有建议、咨询、Bug、充值问题时无处反馈,管理员也无法主动跟进。本功能在个人中心新增「我的工单」入口,让每个用户能就某个**主题/工单(Topic)**与管理员进行**多轮对话**。 +平台目前缺少「用户 ↔ 管理员」的轻量沟通通道:用户有建议、咨询、Bug、充值问题时无处反馈,管理员也无法主动跟进。本功能新增「我的工单」入口,让每个用户能就某个**主题/工单(Topic)**与管理员进行**多轮对话**。 本质上这是一个**轻量工单系统**:主题 = 工单,回复 = 工单消息。 +> **v0.5 形态变更**:用户入口由「个人设置中的卡片」改为**独立的「我的工单」侧边栏页面**——卡片无分页、工单一多便不利于用户管理。新页面复用「工单管理」的**分页表格 + 抽屉详情**范式,且是一个可被超级管理员开关显隐的独立菜单项。 + ### 目标 -- 用户可在个人中心「我的工单」卡片中:**新建工单**(标题 + 分类 + 正文 + 可选图片)、查看**自己的工单列表**、点击工单查看**完整对话**、**回复**工单。 -- 用户**只能看到自己的主题**(后端强制隔离,不依赖前端)。 +- 用户在独立的「我的工单」页面中:**新建工单**(标题 + 分类 + 正文 + 可选图片)、**分页**查看**自己的工单列表**、点击工单**在抽屉里查看完整对话**、**回复**工单、关闭工单。 +- 用户**只能看到自己的主题**(后端强制隔离 `user_id = 当前用户`,不依赖前端)。 +- 「我的工单」作为一个独立页面,可在**超级管理员**的「系统设置 → 侧边栏管理(全局控制)」中**开关是否可见**(与现有侧边栏模块同一套机制)。 - 管理员(`role ≥ 10`)在后台「工单管理」页可查看**所有用户的工单**,按用户 / 状态 / 分类筛选并搜索,点击进入对话与用户**多轮讨论**、变更状态、关闭工单。 - 主题带**状态**(待处理 / 处理中 / 已回复 / 已关闭)与**分类**(建议 / 咨询 / Bug / 充值账单 / 其他)。 +- 两端工单列表均含**创建时间**与**最后回复时间**两列。 - **未读红点**:双向。用户侧——管理员回复后未读高亮;管理员侧——有新主题/新回复时高亮。通过轮询拉取未读计数,**不发邮件、不发站内信**(已评审决策)。 ### 设计原则(与既有模式一致) @@ -97,7 +107,9 @@ type FeedbackMessage struct { > > **v0.3:区分是哪个管理员回复**。`UserId` 本就记录了发言者(管理员回复时即该管理员的 user_id),无需新增列;详情接口按发言者 id 批量查 `users` 表补 `author_name`(管理员取 `username`/显示名)。前端管理员气泡显示「客服 · 张三」之类,**多位管理员参与时能逐条看出是谁回的**。 > -> **隐私取舍(待你拍板,默认方案已定)**:默认**对用户也展示回复管理员的显示名**(更透明、有人情味)。若你倾向对终端用户隐去具体管理员、只显示统一「官方客服」,则用户端 `author_name` 返回固定标签、管理员端返回真实用户名即可——一个开关,后端按请求者角色决定返回哪种。当前文档按"都显示真实名"实现,需要隐藏告诉我改。 +> **v0.5 隐私取舍(已拍板):对终端用户隐去具体管理员身份。** v0.3 留的开关现定为「隐藏」——**用户侧**详情接口对 `author_role = 10`(管理员)的消息:①不返回真实 `author_name`(统一显示「官方客服」,由前端固定文案兜底,后端置空或返回固定标签);②`author_id` 置 `0`,**不泄露管理员的 user_id**。**管理员侧**接口仍返回真实 `author_name` / `author_id`,内部能逐条辨认是哪位同事回的。 +> +> **理由**:把管理员账号名暴露给普通用户,会被用于**撞库 / 猜测密码 / 定向钓鱼**等攻击(管理员账号一旦被锁定即成高价值目标)。透明度的收益远小于账号暴露的安全风险,故对外统一为「官方客服」。实现上是**后端按请求者角色决定返回哪种**(用户路由脱敏、管理员路由返真名),前端无需也无法绕过。 > > **v0.2:弃用 `has_images` 布尔**。原设计前端只知"有图"但不知有几张、id 是多少,无法驱动 `/images/:idx` 取图。改为详情接口对每条消息返回 `image_ids: []int`(来自 `feedback_images`),前端按 id 逐张拉取。`Content` 去掉 `not null`,**有图片时允许空正文**(纯图消息),但「正文为空且无图片」的消息必须拒绝。 @@ -181,12 +193,14 @@ type FeedbackImage struct { |------|------|------| | GET | `/feedback/topics` | 我的工单列表(分页:`page`/`page_size`,可按 `status`/`category` 过滤)。**默认按 `last_reply_at DESC` 排序——最新创建或回复的工单置顶**。**强制 `user_id=当前用户`**。 | | POST | `/feedback/topics` | 新建主题(`title`/`category`/`content`/`images[]`)。限流 + 配额校验。 | -| GET | `/feedback/topics/:id` | 主题详情 + 消息**分页**(`page`/`page_size`,默认按 `created_at` 升序,长对话不全量加载;每条消息含 `image_ids` 与 `author_name`,用户可看出是哪位管理员回的)。校验归属。**进入即把 `user_unread` 清零**。 | +| GET | `/feedback/topics/:id` | 主题详情 + 消息**分页**(`page`/`page_size`,默认按 `created_at` 升序,长对话不全量加载;每条消息含 `image_ids`)。**v0.5:管理员消息脱敏**——`author_role=10` 的消息 `author_name` 置空(前端固定显示「官方客服」)、`author_id` 置 `0`,**不向用户暴露具体管理员账号**。校验归属。**进入即把 `user_unread` 清零**。 | | POST | `/feedback/topics/:id/messages` | 回复主题(`content`/`images[]`)。校验归属;触发状态/未读流转。 | | PUT | `/feedback/topics/:id/close` | 用户关闭自己的主题。 | | GET | `/feedback/images/:imageId` | 按**图片主键 id** 拉取图(base64)。校验该图所属主题归属当前用户。 | | GET | `/feedback/unread` | 返回 `{ unread, has_topics }`:`unread`=我的未读未关闭工单数(`count where user_id=me and user_unread and status!=4`),`has_topics`=我是否有过任何工单(供前端决定**是否挂轮询**,见 §十)。**读经 Redis 缓存、缺 Redis 回退直查 DB**(见 §十)。 | +> **v0.5 管理员脱敏的实现点**:现有 `model.GetFeedbackMessages(topicId, page, pageSize)` 对每条消息一律回填真实 `AuthorName`(`m.AuthorName = nameMap[m.UserId]`),用户侧也照单全收。改造:给该方法(或包一层)加一个 `maskAdmin bool` 入参——**用户路由传 `true`**,对 `AuthorRole == FeedbackAuthorAdmin` 的消息**跳过真名回填**(`AuthorName` 留空);控制器 `feedbackMessageToItem` 对被脱敏的消息额外把 `AuthorId` 置 `0`。**管理员路由传 `false`**,行为不变。这样脱敏发生在数据层,前端无从绕过。`created_at` 列两端 DTO(`FeedbackTopicItem.CreatedAt`)本就存在,无需改模型。 + ### 4.2 管理员侧(`/api/feedback/admin/...`,`AdminAuth`,`role ≥ 10`) | 方法 | 路径 | 说明 | @@ -240,28 +254,46 @@ type FeedbackImage struct { ## 六、前端 -### 6.1 用户侧:个人中心卡片 +### 6.1 用户侧:独立「我的工单」页面(v0.5 改版) -- 新增 `web/classic/src/components/settings/personal/cards/FeedbackConsult.jsx`,在 `PersonalSetting.jsx` 中 `import` 并挂到卡片流(紧随 `EnterpriseSetting` 之后),与现有卡片排版一致。 -- **布局**(贴合需求描述): - - **上方**:我的工单列表(标题、分类标签、状态标签、最后回复时间、未读红点、"管理员已回复"提示),**按最后活动时间倒序——最新创建/回复置顶**。点击某行 → 展开/进入该工单对话。 - - **中间**:选中主题的消息流,**微信式「视角相对」气泡对齐**——以当前查看者为基准,**自己发的靠右、对方发的靠左**。在用户端即:本人消息靠右,管理员消息靠左 + 「官方」徽标 + **该管理员的 `author_name`**(多位管理员先后回复时逐条可辨)。气泡含头像/角色标识、时间、文本(`white-space: pre-wrap`)、图片缩略图(点开大图)。底部回复框(文本 + 图片上传,≤3 张)+「关闭主题」按钮。 - - **下方**:新建主题表单(标题 + 分类下拉 + 正文 + 图片上传 + 提交)。 -- 图片上传复用 KYC/企业认证的客户端压缩工具函数(canvas 压缩 → base64)。 +> **变更**:v0.4 落地的个人设置卡片 `web/classic/src/components/settings/personal/cards/FeedbackConsult.jsx` 因无分页、工单多了难管理,**改为独立页面**。 + +- **移除卡片**:删掉 `PersonalSetting.jsx` 中对 `FeedbackConsult` 的 `import` 与挂载(紧随 `EnterpriseSetting` 之后那处),并删除 `cards/FeedbackConsult.jsx`(逻辑迁入新页面)。 +- **新增页面** `web/classic/src/pages/Feedback/MyFeedback.jsx`(与「工单管理」`pages/Feedback/index.jsx` 同目录同范式)。两者高度同构,差异仅在:API 基址用 `USER_FEEDBACK_BASE`、`viewerRole = FEEDBACK_ROLE_USER`、**无「按用户筛选」与「所属用户」列**(用户只有自己的工单)、详情抽屉里把「标记处理中/关闭(管理员)」换成用户的「关闭工单」、并保留「新建工单」入口(表单从卡片迁来,放进新建抽屉/弹窗)。可考虑把 `index.jsx` 与 `MyFeedback.jsx` 的公共列表+抽屉抽成一个受 props 配置的内部组件,避免两份拷贝漂移(非强制,按改动量权衡)。 +- **路由**:`App.jsx` 增加用户页路由(如 `/console/myfeedback`),用普通 ``(非 `AdminRoute`)包裹——任何登录用户可达。 +- **页面形态**(复用 `index.jsx` 的 `CardPro` + `Table` + `createCardProPagination` + `SideSheet` 范式): + - 顶部筛选区:状态、分类、标题关键字、查询/重置(**无用户筛选**)。 + - 主体:分页表格——列含 **ID、标题(带未读红点)、分类、状态、消息数、创建时间、最后回复时间、操作(查看)**。**默认 `last_reply_at DESC`**。 + - 点击「查看」→ `SideSheet` 详情:消息流用 `FeedbackThread`,`viewerRole = FEEDBACK_ROLE_USER`——本人消息靠右,管理员消息靠左 + 「官方」徽标。**v0.5:用户侧不显示具体管理员名**,因后端已把管理员消息 `author_name` 置空,`FeedbackThread` 的客服气泡退化为固定「客服 / 官方客服」文案(现有逻辑 `客服${author_name ? ' · '+author_name : ''}` 天然兼容空名,无需改组件)。底部回复框(文本 + 图片 ≤3 张)+「关闭工单」按钮。 +- 图片上传复用 KYC/企业认证的客户端压缩工具函数(`compressImageToBase64`)。 ### 6.2 管理员侧:后台「工单管理」页 -- 新增页面 `web/classic/src/pages/Feedback/index.jsx`。 -- 路由:`App.jsx` 增加 `/console/feedback`,用 `` 包裹(与 `/console/enterprise` 同款)。 -- 侧边栏:`SiderBar.jsx` 管理分组内新增菜单项 `{ text: t('工单管理'), to: '/console/feedback', className: isAdmin() ? '' : 'tableHiddle' }`(放在「企业认证」之后)。 +- 页面 `web/classic/src/pages/Feedback/index.jsx`(已落地)。 +- 路由:`App.jsx` `/console/feedback`,`` 包裹(与 `/console/enterprise` 同款)。 +- 侧边栏:`SiderBar.jsx` 管理分组菜单项 `工单管理`(已落地,带 `adminUnread` 角标,放在「企业认证」之后)。 - **页面形态**(复用 `User`/`Reconcile` 列表范式): - 顶部筛选区:**按用户筛选**(用户 ID 精确 / 用户名模糊)、状态、分类、标题关键字、查询/重置;列表列含「所属用户」。 - - 主体:分页表格(主题 ID、用户、标题、分类、状态、消息数、最后回复时间、未读标识)。 - - 点击行 → `Drawer`/详情:消息流采用**同一套「视角相对」气泡**,但视角是管理员——**管理员(含其他管理员)的回复靠右、用户消息靠左** + 用户标识。即:同一条消息在用户端和管理员端左右相反,由前端按「`author_role` 是否等于当前查看者角色」决定对齐方向,后端只存 `author_role`、不存对齐。**每条管理员气泡显示 `author_name`**,多位管理员协同时一眼看清是哪位同事回的(包括当前管理员自己 vs 其他管理员)。+ 管理员回复框 + 状态切换(处理中/关闭)。 + - 主体:分页表格(主题 ID、用户、标题、分类、状态、消息数、**创建时间(v0.5 新增列)**、最后回复时间、未读标识)。 + - 点击行 → `SideSheet`/详情:消息流采用**同一套「视角相对」气泡**,视角是管理员——管理员(含其他管理员)的回复靠右、用户消息靠左 + 用户标识。**每条管理员气泡显示 `author_name`**(管理员侧不脱敏),多位管理员协同时一眼看清是哪位同事回的。+ 管理员回复框 + 状态切换(处理中/关闭)。 ### 6.3 i18n -新增中英文案键(`web/classic/src/i18n` 或现有词条文件):菜单「工单管理」、卡片「我的工单」、状态/分类枚举、空态与校验提示。遵循 `docs/translation-glossary.md` 术语,保证 classic 非中文语言下文案切换(参考最近 i18n 修复 commit)。 +新增/复用中英文案键(`web/classic/src/i18n` 或现有词条文件):菜单「工单管理」「我的工单」、列「创建时间」、固定「官方客服」、状态/分类枚举、空态与校验提示。遵循 `docs/translation-glossary.md` 术语,保证 classic 非中文语言下文案切换。 + +### 6.4 「我的工单」纳入侧边栏模块开关(v0.5 新增) + +「我的工单」作为独立菜单项,接入现有 `SidebarModules` 显隐机制(`useSidebar` 的 `isModuleVisible(section, key)`),**超级管理员可全局开关**: + +> **归属区:`personal`(个人中心区域),不是 `console`。** 「我的工单」是「我的 XXX」账户类页面,和「钱包管理」「个人设置」同属个人中心;未读角标本就在 personal 区。URL 仍是 `/console/myfeedback`(路径前缀与 section 无关,token/playground 等用户页也都挂 `/console/`),故改 section 不动路由。 + +- **菜单注册**:在 `SiderBar.jsx` 的 `personal` 区域(`financeItems`,与 钱包管理/个人设置 同组)新增项: + ```js + { text: withUnreadBadge(t('我的工单'), userUnread), itemKey: 'myfeedback', to: '/console/myfeedback' } + ``` + 该区域已有 `items.filter(item => isModuleVisible('personal', item.itemKey))`,自动受开关控制。**未读角标 `userUnread`** 从 `个人设置` 菜单迁来挂到此项(个人设置恢复无角标)。 +- **升级兜底(关键)**:`useSidebar.js` 已有 `DEFAULT_ADMIN_CONFIG` + `mergeAdminConfig(saved)`——后者先 `deepClone` 默认、再覆盖已保存的 section。**只要在 `DEFAULT_ADMIN_CONFIG.personal` 加 `myfeedback: true`**,老站点已保存的 `SidebarModulesAdmin`(没有该键)经 merge 后会保留默认 `true`,升级后**默认可见**,无需超管手动开。`admin.feedback: true` 已是同样的先例。用户侧 `finalConfig` 对缺失键取 `userSection[key] !== false`(`undefined !== false` ⇒ `true`),同样默认可见。 +- **设置页补键**:在 `SettingsSidebarModulesAdmin.jsx`(超管全局)与 `SettingsSidebarModulesUser.jsx`(用户自定义)里同步把 `myfeedback: true` 加进各自默认 `personal` 对象(admin 页三处:`useState` 初值、`resetSidebarModules`、`useEffect` 解析失败兜底;user 页 `defaultConfig.personal`),并在 `SettingsSidebarModulesAdmin.jsx`/`SettingsSidebarModulesUser.jsx` 的 `sectionConfigs` → `personal.modules` 数组追加 `{ key: 'myfeedback', title: t('我的工单'), description: t('用户查看与提交自己的工单') }`,超管即可在「系统设置 → 侧边栏管理(全局控制)」里开关。 --- @@ -283,12 +315,34 @@ type FeedbackImage struct { **验证** 10. 三库(至少 SQLite + 一种)跑 `AutoMigrate` 无误;用户隔离越权用例(A 用户访问 B 工单/图片应 404);配额与限流;连发多条;图片压缩与大小拒绝;管理员全量、按用户筛选与状态流转;红点置位/清零;**Redis 开/关两种部署下 unread 都正确(缓存命中/失效/降级)**。 +### 七·补:v0.5 改动清单(在已落地 v0.4 之上的增量) + +> 1–10 描述首版(已落地)。本节只列 v0.5 的增量改动。 + +**后端** +- A. `model/feedback.go`:`GetFeedbackMessages` 增 `maskAdmin bool` 入参——为 `true` 时跳过管理员消息的真名回填(`AuthorName` 留空)。 +- B. `controller/feedback.go`:用户侧详情走 `maskAdmin=true`,且对被脱敏消息把 `AuthorId` 置 `0`(`feedbackMessageToItem` 或调用处处理);管理员侧 `maskAdmin=false` 不变。`created_at` 已在 DTO,无需改。 + +**前端** +- C. 删除 `cards/FeedbackConsult.jsx` 及其在 `PersonalSetting.jsx` 的 `import`/挂载。 +- D. 新增 `pages/Feedback/MyFeedback.jsx`(用户侧分页表格 + 抽屉详情 + 新建入口,复用 `index.jsx` 范式与 `FeedbackThread`),`App.jsx` 加 `/console/myfeedback`(`PrivateRoute`)。 +- E. `SiderBar.jsx` `personal` 区(`financeItems`,与钱包管理/个人设置同组)新增 `myfeedback` 菜单项(挂 `userUnread` 角标),受 `isModuleVisible('personal','myfeedback')` 控制。 +- F. `useSidebar.js` 的 `DEFAULT_ADMIN_CONFIG.personal` 加 `myfeedback: true`(升级兜底);`SettingsSidebarModulesAdmin.jsx` / `SettingsSidebarModulesUser.jsx` 默认 `personal` 对象补键 + 设置页 `sectionConfigs.personal.modules` 加一项。 +- G. 两端列表(`index.jsx` + `MyFeedback.jsx`)表格新增「创建时间」列(`created_at`,`new Date(v).toLocaleString()`)。 +- H. i18n:「我的工单」「创建时间」「官方客服」「用户查看与提交自己的工单」等键。 + +**验证(v0.5 增量)** +- I. 用户侧详情接口**不回真名也不回管理员 user_id**(抓包确认 `author_name` 空、`author_id=0`);管理员侧仍回真名。 +- J. 超管在侧边栏管理关闭「我的工单」后用户侧菜单与路由不可见;开启后恢复;老站点升级默认可见。 +- K. 「我的工单」分页/筛选/新建/回复/关闭与未读角标全链路;A 用户仍无法看到 B 的工单。 + --- ## 八、安全与边界检查清单 - [ ] 用户侧所有读写**强制 `user_id = c.GetInt("id")`**,越权访问主题/消息/图片返回 404(不泄露存在性)。 - [ ] 管理员接口全部在 `adminRoute`(`AdminAuth`)下,普通用户不可达。 +- [ ] **用户侧脱敏管理员身份(v0.5)**:用户能拿到的任何接口里,管理员消息**不含真实 `author_name`、不含管理员 `author_id`**(统一「官方客服」),防止管理员账号名被用于撞库/猜密码/钓鱼。管理员侧不受影响。 - [ ] 图片接口按 `imageId` 取图并校验「该图所属主题归属请求者」(用户)或管理员身份。 - [ ] **状态不信任前端**:用户提交不接受 `status`,由后端按转移表推导;`category` 校验 ∈ {1..5},管理员 `status` 仅允许 {2,4}。 - [ ] **写操作同事务**:消息插入 + 主题计数/时间/状态/未读更新 + 图片插入原子提交。 diff --git a/model/feedback.go b/model/feedback.go index 32ce520b566..702c7cd2b45 100644 --- a/model/feedback.go +++ b/model/feedback.go @@ -336,7 +336,7 @@ func GetFeedbackTopicById(topicId int) (*FeedbackTopic, error) { } // GetUserFeedbackTopics 我的工单列表,按 last_reply_at DESC(最新置顶)。status/category=0 表示不过滤。 -func GetUserFeedbackTopics(userId, status, category, page, pageSize int) ([]*FeedbackTopic, int64, error) { +func GetUserFeedbackTopics(userId, status, category int, keyword string, page, pageSize int) ([]*FeedbackTopic, int64, error) { query := DB.Model(&FeedbackTopic{}).Where("user_id = ?", userId) if status != 0 { query = query.Where("status = ?", status) @@ -344,6 +344,9 @@ func GetUserFeedbackTopics(userId, status, category, page, pageSize int) ([]*Fee if category != 0 { query = query.Where("category = ?", category) } + if keyword != "" { + query = query.Where("title LIKE ?", "%"+keyword+"%") + } var total int64 if err := query.Count(&total).Error; err != nil { @@ -406,7 +409,9 @@ func GetFeedbackAdminTopics(filterUserId, status, category int, username, keywor } // GetFeedbackMessages 取某工单的消息(分页,created_at ASC),并回填 ImageIds 与 AuthorName。 -func GetFeedbackMessages(topicId, page, pageSize int) ([]*FeedbackMessage, int64, error) { +// maskAdmin=true(用户侧)时不回填管理员消息的真名,避免向终端用户暴露管理员账号 +// (撞库/猜密码风险,见 docs/feedback-consult-design.md §2.2);控制器再把 AuthorId 置 0。 +func GetFeedbackMessages(topicId, page, pageSize int, maskAdmin bool) ([]*FeedbackMessage, int64, error) { query := DB.Model(&FeedbackMessage{}).Where("topic_id = ?", topicId) var total int64 @@ -440,6 +445,10 @@ func GetFeedbackMessages(topicId, page, pageSize int) ([]*FeedbackMessage, int64 nameMap := loadUsernames(authorIds) for _, m := range messages { m.ImageIds = imgMap[m.Id] + // 用户侧脱敏:管理员消息不回填真名(前端固定显示「官方客服」)。 + if maskAdmin && m.AuthorRole == FeedbackAuthorAdmin { + continue + } m.AuthorName = nameMap[m.UserId] } return messages, total, nil diff --git a/web/classic/src/App.jsx b/web/classic/src/App.jsx index 7df20643d81..b6f2d09ce5f 100644 --- a/web/classic/src/App.jsx +++ b/web/classic/src/App.jsx @@ -51,6 +51,7 @@ import PersonalSetting from './components/settings/PersonalSetting'; import KYCPage from './pages/KYC'; import EnterprisePage from './pages/Enterprise'; import FeedbackPage from './pages/Feedback'; +import MyFeedbackPage from './pages/Feedback/MyFeedback'; import Setup from './pages/Setup'; import SetupCheck from './components/layout/SetupCheck'; @@ -207,6 +208,14 @@ function App() { } /> + + + + } + /> { + const { encoded, error } = await encodeFeedbackImageFiles( + fileList, + images.length, + ); + if (error) showError(t(error)); + // 函数式封顶:即使并发拖拽/选择读到的是旧 count,也保证不超过上限。 + if (encoded.length) + setImages((prev) => [...prev, ...encoded].slice(0, FEEDBACK_MAX_IMAGES)); + }; + const handleFiles = async (e) => { - const files = Array.from(e.target.files || []); + const fileList = e.target.files; e.target.value = ''; - if (files.length === 0) return; - const room = FEEDBACK_MAX_IMAGES - images.length; - if (room <= 0) { + await addFiles(fileList); + }; + + const handleDrop = async (e) => { + e.preventDefault(); + setDragging(false); + if (images.length >= FEEDBACK_MAX_IMAGES) { showError(t('最多上传 3 张图片')); return; } - try { - const picked = files.slice(0, room); - const encoded = await Promise.all( - picked.map((f) => compressImageToBase64(f)), - ); - setImages((prev) => [...prev, ...encoded]); - } catch { - showError(t('图片处理失败')); - } + await addFiles(e.dataTransfer.files); }; const submit = async () => { @@ -184,7 +193,24 @@ export default function FeedbackThread({ {!disabled && ( -
+
{ + e.preventDefault(); + if (!dragging) setDragging(true); + }} + onDragLeave={(e) => { + if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false); + }} + onDrop={handleDrop} + > + {dragging && ( +
+ {t('松开鼠标上传图片')} +
+ )} {images.length > 0 && (
{images.map((b64, idx) => ( diff --git a/web/classic/src/components/feedback/feedbackHelpers.js b/web/classic/src/components/feedback/feedbackHelpers.js index cd2a02d46a8..414307a9103 100644 --- a/web/classic/src/components/feedback/feedbackHelpers.js +++ b/web/classic/src/components/feedback/feedbackHelpers.js @@ -32,6 +32,26 @@ export const FEEDBACK_CATEGORY_OPTIONS = Object.entries(FEEDBACK_CATEGORY).map( ([value, { label }]) => ({ value: Number(value), label }), ); +// 把「选择」或「拖拽」进来的文件统一处理成 base64[]:自动过滤非图片、按剩余 +// 配额(FEEDBACK_MAX_IMAGES - currentCount)裁剪、逐张压缩。点击上传与拖拽上传共用。 +// 返回 { encoded, error };error 为待 t() 翻译的文案 key(无错误则 undefined)。 +export async function encodeFeedbackImageFiles(fileList, currentCount) { + const files = Array.from(fileList || []).filter((f) => + f.type ? f.type.startsWith('image/') : true, + ); + if (files.length === 0) return { encoded: [] }; + const room = FEEDBACK_MAX_IMAGES - currentCount; + if (room <= 0) return { encoded: [], error: '最多上传 3 张图片' }; + try { + const encoded = await Promise.all( + files.slice(0, room).map((f) => compressImageToBase64(f)), + ); + return { encoded }; + } catch { + return { encoded: [], error: '图片处理失败' }; + } +} + // 将图片 File 压缩为纯 base64(无 data: 前缀)。与 KYC/企业认证同一套: // 缩放到最长边 2400px、JPEG 0.88,超 1.5MB 再降一档质量重试一次。 export async function compressImageToBase64( diff --git a/web/classic/src/components/layout/SiderBar.jsx b/web/classic/src/components/layout/SiderBar.jsx index 80383fc29af..cfccc7b742b 100644 --- a/web/classic/src/components/layout/SiderBar.jsx +++ b/web/classic/src/components/layout/SiderBar.jsx @@ -54,6 +54,7 @@ const routerMap = { kyc: '/console/kyc', enterprise: '/console/enterprise', feedback: '/console/feedback', + myfeedback: '/console/myfeedback', }; const SiderBar = ({ onNavigate = () => {} }) => { @@ -161,10 +162,15 @@ const SiderBar = ({ onNavigate = () => {} }) => { to: '/topup', }, { - text: withUnreadBadge(t('个人设置'), userUnread), + text: t('个人设置'), itemKey: 'personal', to: '/personal', }, + { + text: withUnreadBadge(t('我的工单'), userUnread), + itemKey: 'myfeedback', + to: '/console/myfeedback', + }, ]; // 根据配置过滤项目 @@ -220,6 +226,12 @@ const SiderBar = ({ onNavigate = () => {} }) => { to: '/user', className: isAdmin() ? '' : 'tableHiddle', }, + { + text: withUnreadBadge(t('工单管理'), adminUnread), + itemKey: 'feedback', + to: '/console/feedback', + className: isAdmin() ? '' : 'tableHiddle', + }, { text: t('实名认证'), itemKey: 'kyc', @@ -232,12 +244,6 @@ const SiderBar = ({ onNavigate = () => {} }) => { to: '/enterprise', className: isAdmin() ? '' : 'tableHiddle', }, - { - text: withUnreadBadge(t('工单管理'), adminUnread), - itemKey: 'feedback', - to: '/console/feedback', - className: isAdmin() ? '' : 'tableHiddle', - }, { text: t('系统设置'), itemKey: 'setting', diff --git a/web/classic/src/components/settings/PersonalSetting.jsx b/web/classic/src/components/settings/PersonalSetting.jsx index e231664764e..0cc6cd9ebea 100644 --- a/web/classic/src/components/settings/PersonalSetting.jsx +++ b/web/classic/src/components/settings/PersonalSetting.jsx @@ -43,7 +43,6 @@ import PreferencesSettings from './personal/cards/PreferencesSettings'; import CheckinCalendar from './personal/cards/CheckinCalendar'; import KYCSetting from './personal/cards/KYCSetting'; import EnterpriseSetting from './personal/cards/EnterpriseSetting'; -import FeedbackConsult from './personal/cards/FeedbackConsult'; import EmailBindModal from './personal/modals/EmailBindModal'; import WeChatBindModal from './personal/modals/WeChatBindModal'; import AccountDeleteModal from './personal/modals/AccountDeleteModal'; @@ -615,9 +614,6 @@ const PersonalSetting = () => { {/* 偏好设置(语言等) */} - - {/* 我的工单(建议及咨询) */} -
{/* 右侧:通知设置 + 实名认证 */} diff --git a/web/classic/src/components/settings/personal/cards/FeedbackConsult.jsx b/web/classic/src/components/settings/personal/cards/FeedbackConsult.jsx deleted file mode 100644 index 258bfab80e4..00000000000 --- a/web/classic/src/components/settings/personal/cards/FeedbackConsult.jsx +++ /dev/null @@ -1,390 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { - Avatar, - Badge, - Button, - Card, - Image, - Input, - Select, - Spin, - Tag, - TextArea, - Typography, -} from '@douyinfe/semi-ui'; -import { - IconArrowLeft, - IconImage, - IconClose, -} from '@douyinfe/semi-icons'; -import { Ticket } from 'lucide-react'; -import { API, showError, showSuccess } from '../../../../helpers'; -import { useTranslation } from 'react-i18next'; -import FeedbackThread from '../../../feedback/FeedbackThread'; -import { - USER_FEEDBACK_BASE, - FEEDBACK_ROLE_USER, - FEEDBACK_STATUS, - FEEDBACK_CATEGORY, - FEEDBACK_CATEGORY_OPTIONS, - FEEDBACK_MAX_IMAGES, - compressImageToBase64, -} from '../../../feedback/feedbackHelpers'; - -const { Text, Title } = Typography; - -export default function FeedbackConsult() { - const { t } = useTranslation(); - const [topics, setTopics] = useState([]); - const [loadingList, setLoadingList] = useState(false); - const [detail, setDetail] = useState(null); // { topic, messages } - const [loadingDetail, setLoadingDetail] = useState(false); - const [sending, setSending] = useState(false); - - // 新建表单 - const [showCreate, setShowCreate] = useState(false); - const [form, setForm] = useState({ category: 2, title: '', content: '' }); - const [createImages, setCreateImages] = useState([]); - const [creating, setCreating] = useState(false); - const createFileRef = useRef(null); - - const loadTopics = async () => { - setLoadingList(true); - try { - const res = await API.get( - `${USER_FEEDBACK_BASE}/topics?page=1&page_size=50`, - ); - if (res.data.success) setTopics(res.data.data || []); - } finally { - setLoadingList(false); - } - }; - - useEffect(() => { - loadTopics(); - }, []); - - const openTopic = async (id) => { - setLoadingDetail(true); - try { - const res = await API.get( - `${USER_FEEDBACK_BASE}/topics/${id}?page=1&page_size=200`, - ); - if (res.data.success) { - setDetail(res.data.data); - loadTopics(); // 刷新卡片内未读红点 - // 通知侧边栏角标即时刷新(新建首单后恢复轮询 + 打开工单清未读) - window.dispatchEvent(new Event('feedback:changed')); - } else { - showError(res.data.message); - } - } finally { - setLoadingDetail(false); - } - }; - - const handleReply = async (content, images) => { - setSending(true); - try { - const res = await API.post( - `${USER_FEEDBACK_BASE}/topics/${detail.topic.id}/messages`, - { content, images }, - ); - if (res.data.success) { - await openTopic(detail.topic.id); - return true; - } - showError(res.data.message); - return false; - } catch (e) { - showError(t('发送失败')); - return false; - } finally { - setSending(false); - } - }; - - const handleClose = async () => { - try { - const res = await API.put( - `${USER_FEEDBACK_BASE}/topics/${detail.topic.id}/close`, - ); - if (res.data.success) { - showSuccess(t('工单已关闭')); - await openTopic(detail.topic.id); - loadTopics(); - } else { - showError(res.data.message); - } - } catch { - showError(t('操作失败')); - } - }; - - const handleCreateFiles = async (e) => { - const files = Array.from(e.target.files || []); - e.target.value = ''; - const room = FEEDBACK_MAX_IMAGES - createImages.length; - if (room <= 0) { - showError(t('最多上传 3 张图片')); - return; - } - try { - const encoded = await Promise.all( - files.slice(0, room).map((f) => compressImageToBase64(f)), - ); - setCreateImages((prev) => [...prev, ...encoded]); - } catch { - showError(t('图片处理失败')); - } - }; - - const submitCreate = async () => { - if (!form.title.trim()) { - showError(t('请填写标题')); - return; - } - if (!form.content.trim() && createImages.length === 0) { - showError(t('请填写内容或上传图片')); - return; - } - setCreating(true); - try { - const res = await API.post(`${USER_FEEDBACK_BASE}/topics`, { - category: form.category, - title: form.title.trim(), - content: form.content.trim(), - images: createImages, - }); - if (res.data.success) { - showSuccess(t('工单已创建')); - setForm({ category: 2, title: '', content: '' }); - setCreateImages([]); - setShowCreate(false); - await loadTopics(); - openTopic(res.data.data.id); - } else { - showError(res.data.message); - } - } finally { - setCreating(false); - } - }; - - // ─── 渲染 ─────────────────────────────────────────────────────────────────── - - const renderTopicRow = (tp) => { - const st = FEEDBACK_STATUS[tp.status] || {}; - const cat = FEEDBACK_CATEGORY[tp.category] || {}; - return ( -
openTopic(tp.id)} - > -
- {tp.user_unread && } - - {tp.title} - - - {t(cat.label)} - -
-
- - {t(st.label)} - - - {new Date(tp.last_reply_at).toLocaleDateString()} - -
-
- ); - }; - - const header = ( -
-
- - - -
- {t('我的工单')} -
- {t('遇到问题或有建议,随时向我们反馈')} -
-
-
- {!detail && !showCreate && ( - - )} -
- ); - - // 详情视图 - if (detail) { - const st = FEEDBACK_STATUS[detail.topic.status] || {}; - const closed = detail.topic.status === 4; - return ( - -
- -
- {t(st.label)} - {!closed && ( - - )} -
-
- - {detail.topic.title} - - -
- ); - } - - // 新建视图 - if (showCreate) { - return ( - -
-
- {t('分类')} - setForm({ ...form, title: v })} - maxLength={128} - placeholder={t('一句话描述你的问题或建议')} - className='mt-1' - /> -
-
- {t('内容')} -