From 0b71fa8eb002831c3164c5b5315463c11c3e3620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8E=86=E5=86=B0=E9=83=81-hermes=E7=89=88?= Date: Fri, 8 May 2026 12:14:49 +0800 Subject: [PATCH 1/7] docs: add profile feedback implementation plan --- ...026-05-08_120646-profile-feedback-entry.md | 584 ++++++++++++++++++ .../profile-feedback-reference-2026-05-08.png | Bin 0 -> 21917 bytes 2 files changed, 584 insertions(+) create mode 100644 .hermes/plans/2026-05-08_120646-profile-feedback-entry.md create mode 100644 .hermes/plans/assets/profile-feedback-reference-2026-05-08.png diff --git a/.hermes/plans/2026-05-08_120646-profile-feedback-entry.md b/.hermes/plans/2026-05-08_120646-profile-feedback-entry.md new file mode 100644 index 00000000..c7cfeece --- /dev/null +++ b/.hermes/plans/2026-05-08_120646-profile-feedback-entry.md @@ -0,0 +1,584 @@ +# 我的页签反馈入口与反馈页 Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** 在平台“我的”页签中新增“反馈”入口,点击后进入独立反馈路由,并按用户提供的参考图落地反馈页面 UI。 + +**Architecture:** 复用现有前端单页路由体系:`SelectionStage` 负责页面阶段,`appPageRoutes.ts` 负责 URL 映射,`PlatformEntryFlowShellImpl` 负责按阶段渲染视图。“我的”页签只增加一个入口回调,不在当前面板下方展开内容;反馈页作为独立页面组件挂到新阶段。首版先做前端静态表单与本地提交成功态,不新增后端表结构或 SpacetimeDB 写入,除非产品补充明确要求持久化反馈。 + +**Tech Stack:** React 19、TypeScript、Tailwind utility class、lucide-react、现有 Genarrative 平台入口组件体系。 + +--- + +## Current context / assumptions + +## Reference image + +![帮助与反馈参考图](assets/profile-feedback-reference-2026-05-08.png) + +参考图是一张移动端“帮助与反馈”页面,视觉和信息结构如下: + +- 页面整体:浅灰背景,白色圆角卡片,黑/深灰标题文字,浅灰 placeholder,蓝色主按钮与蓝色文本链接。 +- 顶部栏:白色导航/header,左侧为小 home 图标,中间标题为“帮助与反馈”,右侧为胶囊形更多/控制区。项目实现时可按现有平台导航规范简化为返回按钮 + 居中标题;若需要完全贴近图片,可使用 home 图标作为返回到“我的”页签的按钮。 +- 内容区 section label:左上灰色文字“反馈问题”。 +- 第一张表单卡:标题“问题描述”,大文本输入区域,placeholder 为“请填写10个字以上的问题描述以便我们提供更好的帮助,温馨提醒您请勿填写身份证号等个人隐私信息。”,右下角字数统计“0/200”。 +- 第二张表单卡:标题“上传凭证(提供问题截图)”,左侧虚线边框上传方块,内含图片/上传 + 加号图标,文字“上传凭证”“(最多四张)”。 +- 第三张表单卡:标题“联系电话”,placeholder 为“选填,如您填写则将会同步开发者与您联系”。 +- 底部操作:大号蓝色圆角按钮“提交”,下方居中蓝色链接“查看反馈与投诉记录”。 + +实现约束: + +- 反馈页面应命名为“帮助与反馈”,但“我的”页签入口可显示为“反馈”或“帮助与反馈”,优先以清爽短入口为准。 +- 问题描述最少 10 个字、最多 200 个字,并实时显示 `当前字数/200`。 +- 上传凭证首版如不接后端,可先支持前端选择/预览最多 4 张图片,提交时仅进入成功态;如无法快速安全实现预览,可先保留上传占位并在文档中标注待接入。 +- 联系电话为选填。 +- “查看反馈与投诉记录”首版无后端记录时可以先禁用、隐藏,或点击后给出轻量提示;若保留可见,应在计划/PRD 标明记录页不在首版范围。 + +1. 当前工作区是 `/home/dsk/workspace/Genarrative/.worktrees/hermes-19e77eb0`,不要额外拼接 `Genarrative/`。 +2. 平台首页复用 `src/components/rpg-entry/RpgEntryHomeView.tsx`;`src/components/platform-entry/PlatformEntryHomeView.tsx` 只是 re-export。 +3. “我的”页签的常用功能区域位于 `src/components/rpg-entry/RpgEntryHomeView.tsx:3958-4000`,现有入口包括“每日任务 / 邀请好友 / 填邀请码 / 玩家社区”。 +4. 当前页面阶段类型位于 `src/components/platform-entry/platformEntryTypes.ts:16-38`;路由映射位于 `src/routing/appPageRoutes.ts:7-27`。 +5. `src/App.tsx:60-63` 调用 `pushAppHistoryPath(resolvePathForSelectionStage(stage))`,所以新增阶段必须同步 `APP_STAGE_ROUTES`。 +6. `PlatformEntryFlowShellImpl` 在 `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx:5081+` 根据 `selectionStage` 渲染不同页面,平台首页在 `selectionStage === 'platform'` 分支。 +7. 参考图片已保存到 `.hermes/plans/assets/profile-feedback-reference-2026-05-08.png`,计划与实现均以该图片内容为主要 UI 依据。 +8. 按项目约束,工程修改需同步文档;若没有更具体 PRD,需要先补一份简洁落地文档到 `docs/`。 + +## Proposed approach + +新增一个轻量前端反馈页面阶段: + +- 路由:`/profile/feedback` +- 阶段:`profile-feedback` +- 组件:`src/components/platform-entry/PlatformFeedbackView.tsx` +- “我的”页签入口:在常用功能区增加“反馈”按钮,点击调用新 prop `onOpenFeedback`。 +- 页面行为: + - 顶部返回按钮返回 `platform` 阶段,并切回 `profile` 页签。 + - 未登录用户点击入口时,优先弹登录;如果产品允许匿名反馈,可改为允许进入。 + - 表单字段首版只在前端维护:问题描述、上传凭证图片、联系电话。 + - 提交后显示成功态,不做 API 请求;后续如要持久化,再补 `shared-contracts + api-server + SpacetimeDB` 方案。 + +## Step-by-step plan + +### Task 1: 补充反馈页落地文档 + +**Objective:** 先把反馈入口和页面边界写清楚,避免编码时需求漂移。 + +**Files:** +- Create: `docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md` + +**Step 1: 新建 PRD 文档** + +写入内容建议包含: + +```markdown +# 我的页签反馈入口 PRD + +## 目标 +- 在“我的”页签提供反馈入口。 +- 点击入口进入独立反馈路由 `/profile/feedback`。 +- 反馈页移动端优先,桌面端居中卡片展示。 + +## 首版范围 +- 前端表单:问题描述、上传凭证占位/前端图片预览、联系电话。 +- 问题描述 10-200 字,显示实时字数统计。 +- 提交后显示成功态。 +- 不新增后端存储,不修改 SpacetimeDB 表结构。 + +## 交互 +- 已登录用户:点击“反馈”进入反馈页。 +- 未登录用户:点击入口触发登录弹窗。 +- 返回:回到平台首页并定位“我的”页签。 + +## UI +- 以 `.hermes/plans/assets/profile-feedback-reference-2026-05-08.png` 为准,落地“帮助与反馈”移动端表单。 +- 不在 UI 中堆叠说明性长文案。 +- 入口是独立页面导航,不在“我的”面板下方展开。 + +## 验收 +- `/profile/feedback` 可被浏览器前进/后退访问。 +- “我的”页签反馈入口可进入该路由。 +- 移动端和桌面端均不溢出。 +- `npm run check:encoding`、`npm run typecheck` 通过。 +``` + +**Step 2: 验证文档编码** + +Run: `npm run check:encoding` + +Expected: PASS,无中文编码错误。 + +**Step 3: Commit** + +```bash +git add docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md +git commit -m "docs: add profile feedback entry prd" +``` + +### Task 2: 扩展页面阶段与路由映射 + +**Objective:** 让 `/profile/feedback` 成为主应用可识别的独立路由。 + +**Files:** +- Modify: `src/components/platform-entry/platformEntryTypes.ts` +- Modify: `src/routing/appPageRoutes.ts` + +**Step 1: 修改 SelectionStage 类型** + +在 `SelectionStage` union 中追加: + +```ts + | 'profile-feedback' +``` + +推荐放在 `'platform'` 附近或末尾,保持字面量清晰。 + +**Step 2: 修改 STAGE_ROUTE_ENTRIES** + +在 `src/routing/appPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES` 中追加: + +```ts + ['profile-feedback', '/profile/feedback'], +``` + +建议放在 `['platform', '/']` 后面,表示平台个人页子路由。 + +**Step 3: 验证类型推导** + +Run: `npm run typecheck` + +Expected: 若还未创建渲染组件,可能只通过路由类型;若出现 exhaustive 相关错误,留到后续任务处理。 + +**Step 4: Commit** + +```bash +git add src/components/platform-entry/platformEntryTypes.ts src/routing/appPageRoutes.ts +git commit -m "feat: add profile feedback route stage" +``` + +### Task 3: 新建反馈页面组件 + +**Objective:** 创建移动端优先的独立反馈页面。 + +**Files:** +- Create: `src/components/platform-entry/PlatformFeedbackView.tsx` + +**Step 1: 创建组件 props** + +组件接口建议: + +```ts +export type PlatformFeedbackViewProps = { + onBack: () => void; + onSubmit?: (payload: PlatformFeedbackPayload) => void | Promise; +}; + +export type PlatformFeedbackPayload = { + description: string; + contactPhone: string; + evidenceFiles: File[]; +}; +``` + +**Step 2: 实现 UI 状态** + +使用 `useState` 管理: + +- `description` +- `contactPhone` +- `evidenceFiles` +- `evidencePreviewUrls` +- `error` +- `isSubmitting` +- `submitted` + +**Step 3: 实现页面结构** + +建议结构: + +```tsx +import { ArrowLeft, CheckCircle2, Home, ImagePlus, Send } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +const MAX_FEEDBACK_DESCRIPTION_LENGTH = 200; +const MIN_FEEDBACK_DESCRIPTION_LENGTH = 10; +const MAX_FEEDBACK_EVIDENCE_COUNT = 4; +``` + +页面外壳建议复用现有视觉变量: + +```tsx +
+
+
+ +

反馈

+
+ ... +
+
+``` + +注意:不要写大段“功能说明类文案”;字段 label 简短即可。 + +**Step 4: 表单校验** + +提交时: + +- `description.trim().length < 10`:提示“请填写10个字以上的问题描述” +- `description.trim().length > 200`:提示“问题描述不能超过 200 字” +- `contactPhone.trim().length > 40`:提示“联系电话不能超过 40 字” +- 上传凭证最多 4 张;超出时提示“最多上传四张凭证” + +**Step 5: 提交行为** + +首版无后端时: + +```ts +await onSubmit?.({ + description: description.trim(), + contactPhone: contactPhone.trim(), + evidenceFiles, +}); +setSubmitted(true); +``` + +如果没有传 `onSubmit`,也显示成功态。代码注释说明: + +```ts +// 中文注释:首版反馈页只完成前端收集与成功态;接入后端时在 onSubmit 中替换为 API 调用。 +``` + +**Step 6: Commit** + +```bash +git add src/components/platform-entry/PlatformFeedbackView.tsx +git commit -m "feat: add platform feedback view" +``` + +### Task 4: 在“我的”页签增加反馈入口 prop + +**Objective:** 让 Profile 页面能触发反馈路由,同时保持组件职责清晰。 + +**Files:** +- Modify: `src/components/rpg-entry/RpgEntryHomeView.tsx` +- Modify: `src/components/platform-entry/PlatformEntryHomeView.tsx`(通常无需改,re-export 类型会自动带出) + +**Step 1: 扩展 Props** + +在 `RpgEntryHomeViewProps` 中新增: + +```ts + onOpenFeedback?: () => void; +``` + +**Step 2: 从 props 解构** + +在 `RpgEntryHomeView` 函数参数解构区新增: + +```ts + onOpenFeedback, +``` + +**Step 3: 增加入口按钮** + +在 `profileContent` 的常用功能 grid 中,建议在“玩家社区”后追加: + +```tsx + +``` + +如果参考图中入口位置不同,按参考图调整;但仍必须进入独立路由。 + +**Step 4: 未提供回调时行为** + +`ProfileShortcutButton` 已允许 `onClick` 为空;此处传 `onOpenFeedback` 即可。若希望按钮始终可点,应在父组件必传。 + +**Step 5: 验证类型** + +Run: `npm run typecheck` + +Expected: PASS 或只剩父组件未传 prop 的问题。 + +**Step 6: Commit** + +```bash +git add src/components/rpg-entry/RpgEntryHomeView.tsx src/components/platform-entry/PlatformEntryHomeView.tsx +git commit -m "feat: add feedback shortcut to profile tab" +``` + +### Task 5: 接入 PlatformEntryFlowShellImpl 渲染与导航 + +**Objective:** 点击“反馈”进入 `/profile/feedback`,返回后回到“我的”页签。 + +**Files:** +- Modify: `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` + +**Step 1: 导入组件** + +在 imports 中新增: + +```ts +import { PlatformFeedbackView } from './PlatformFeedbackView'; +``` + +**Step 2: 创建打开反馈页函数** + +在 `const { setPlatformTab } = platformBootstrap;` 附近新增: + +```ts +const openProfileFeedback = useCallback(() => { + if (!authUi?.user) { + authUi?.openLoginModal(); + return; + } + + setPlatformTab('profile'); + setSelectionStage('profile-feedback'); +}, [authUi, setPlatformTab, setSelectionStage]); +``` + +如产品允许匿名反馈,则移除登录判断。 + +**Step 3: 给首页传入入口回调** + +在 `PlatformEntryHomeView` props 中加入: + +```tsx +onOpenFeedback={openProfileFeedback} +``` + +**Step 4: 增加渲染分支** + +在 `selectionStage === 'platform'` 分支后、详情页分支前新增: + +```tsx +{selectionStage === 'profile-feedback' && ( + + { + setPlatformTab('profile'); + setSelectionStage('platform'); + }} + /> + +)} +``` + +**Step 5: 直接访问路由的 tab 同步** + +为处理用户直接访问 `/profile/feedback` 后返回,返回逻辑已 `setPlatformTab('profile')`。如需要进入反馈页时也设置 tab,可加 effect: + +```ts +useEffect(() => { + if (selectionStage === 'profile-feedback') { + setPlatformTab('profile'); + } +}, [selectionStage, setPlatformTab]); +``` + +**Step 6: Commit** + +```bash +git add src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +git commit -m "feat: wire profile feedback navigation" +``` + +### Task 6: 增加路由与反馈页基础测试 + +**Objective:** 用自动化测试覆盖新路由映射和反馈页核心交互。 + +**Files:** +- Create or Modify: `src/routing/appPageRoutes.test.ts` +- Create: `src/components/platform-entry/PlatformFeedbackView.test.tsx` + +**Step 1: 路由测试** + +如果已有 `appPageRoutes.test.ts`,追加;否则创建: + +```ts +import { describe, expect, it } from 'vitest'; +import { + resolvePathForSelectionStage, + resolveSelectionStageFromPath, +} from './appPageRoutes'; + +describe('appPageRoutes', () => { + it('resolves profile feedback route', () => { + expect(resolveSelectionStageFromPath('/profile/feedback')).toBe('profile-feedback'); + expect(resolvePathForSelectionStage('profile-feedback')).toBe('/profile/feedback'); + }); +}); +``` + +**Step 2: 反馈页测试** + +测试重点: + +- 渲染“帮助与反馈”标题。 +- 问题描述过短时提交显示错误。 +- 输入有效问题描述后提交显示成功态。 +- 字数统计随输入更新。 +- 上传凭证入口最多接受 4 张图片。 +- 点击返回调用 `onBack`。 + +示例: + +```tsx +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { PlatformFeedbackView } from './PlatformFeedbackView'; + +describe('PlatformFeedbackView', () => { + it('validates content before submit', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: '提交' })); + expect(screen.getByText('请填写10个字以上的问题描述')).toBeInTheDocument(); + }); +}); +``` + +注意检查项目当前 test setup 是否已引入 jest-dom matcher;若没有,使用 truthy DOM 节点断言: + +```ts +expect(screen.getByText('请补充反馈内容')).toBeTruthy(); +``` + +**Step 3: 运行定向测试** + +Run: + +```bash +npm run test -- src/routing/appPageRoutes.test.ts src/components/platform-entry/PlatformFeedbackView.test.tsx +``` + +Expected: PASS。 + +**Step 4: Commit** + +```bash +git add src/routing/appPageRoutes.test.ts src/components/platform-entry/PlatformFeedbackView.test.tsx +git commit -m "test: cover profile feedback route and form" +``` + +### Task 7: 全量前端验证与移动端 smoke + +**Objective:** 确认新增页面不破坏编码、类型和基础交互。 + +**Files:** +- No code changes unless validation finds issues. + +**Step 1: 编码检查** + +Run: `npm run check:encoding` + +Expected: PASS。 + +**Step 2: ESLint** + +Run: `npm run lint:eslint` + +Expected: PASS。 + +**Step 3: TypeScript** + +Run: `npm run typecheck` + +Expected: PASS。 + +**Step 4: 测试** + +Run: `npm run test -- src/routing/appPageRoutes.test.ts src/components/platform-entry/PlatformFeedbackView.test.tsx` + +Expected: PASS。 + +**Step 5: 本地页面 smoke** + +Run: `npm run dev:web` + +手动验证: + +1. 打开 `http://127.0.0.1:3000/`。 +2. 登录后进入“我的”页签。 +3. 点击“反馈”。 +4. 地址变为 `/profile/feedback`。 +5. 页面显示反馈表单。 +6. 提交空内容出现错误。 +7. 输入有效内容后显示成功态。 +8. 点击返回后回到首页“我的”页签。 +9. 直接打开 `http://127.0.0.1:3000/profile/feedback` 能显示反馈页。 +10. 使用移动端视口(如 390×844)确认按钮和表单不溢出。 + +**Step 6: Commit validation fixes if any** + +```bash +git add +git commit -m "fix: polish profile feedback validation" +``` + +## Files likely to change + +- `docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md`:新增反馈入口落地文档。 +- `src/components/platform-entry/platformEntryTypes.ts`:新增 `profile-feedback` 阶段。 +- `src/routing/appPageRoutes.ts`:新增 `/profile/feedback` 路由映射。 +- `.hermes/plans/assets/profile-feedback-reference-2026-05-08.png`:反馈页参考图。 +- `src/components/platform-entry/PlatformFeedbackView.tsx`:新增反馈页面。 +- `src/components/rpg-entry/RpgEntryHomeView.tsx`:新增“我的”页签反馈入口和 `onOpenFeedback` prop。 +- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`:接入反馈页打开与返回导航。 +- `src/routing/appPageRoutes.test.ts`:新增路由映射测试。 +- `src/components/platform-entry/PlatformFeedbackView.test.tsx`:新增反馈页交互测试。 + +## Tests / validation + +Minimum required: + +```bash +npm run check:encoding +npm run typecheck +npm run test -- src/routing/appPageRoutes.test.ts src/components/platform-entry/PlatformFeedbackView.test.tsx +``` + +Recommended before merge: + +```bash +npm run lint:eslint +npm run test +npm run build +``` + +Manual smoke: + +- 登录后“我的”页签显示“反馈”入口。 +- 点击入口进入 `/profile/feedback`。 +- 浏览器后退和页面返回按钮行为符合预期。 +- 移动端视口无横向溢出。 +- 页面没有把反馈表单展开在“我的”页签下方。 + +## Risks, tradeoffs, and open questions + +1. **参考图落地风险:** 参考图是浅色移动端表单,而项目现有平台 UI 可能偏游戏化/深色变量;实现时需要优先复刻信息结构与交互,不要为了完全一致而破坏现有主题适配。 +2. **反馈是否需要后端存储:** 本计划首版不新增后端,只做前端收集和成功态。若产品要求真实提交,需要新增后端方案:`shared-contracts` DTO、`api-server` 路由、SpacetimeDB 表/迁移、后台查看入口,并按 SpacetimeDB skills 执行。 +3. **登录要求:** 计划默认未登录用户点击入口弹登录。若希望匿名反馈,应取消该限制,并在 payload 中允许无用户身份。 +4. **入口位置:** 当前建议放在“我的”页签常用功能 grid 中。若参考图明确是列表项或设置区入口,应按图调整,但仍进入独立路由。 +5. **图标复用:** 可先用 `MessageCircle` 或 `MessageSquareText`,避免引入新依赖。 +6. **现有大文件风险:** `RpgEntryHomeView.tsx` 很大,实施时必须局部补丁,避免整文件重写导致中文编码或格式大范围变化。 + +## Implementation notes + +- 所有中文注释和文案保持 UTF-8。 +- 不要新增 `.env.local` 到 `.gitignore`。 +- 不要把反馈页做成“我的”页签内部展开面板。 +- 不要新增后端或数据库,除非用户确认反馈必须持久化。 +- 若后续接入后端,必须先补技术文档,再按 DDD 与 SpacetimeDB 约束落地。 diff --git a/.hermes/plans/assets/profile-feedback-reference-2026-05-08.png b/.hermes/plans/assets/profile-feedback-reference-2026-05-08.png new file mode 100644 index 0000000000000000000000000000000000000000..6ce2e9f350166b30da0d2bcf88772b914a1ea33a GIT binary patch literal 21917 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 Date: Fri, 8 May 2026 12:18:54 +0800 Subject: [PATCH 2/7] docs: add profile feedback entry prd --- .../PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md diff --git a/docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md b/docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md new file mode 100644 index 00000000..d16cc0e6 --- /dev/null +++ b/docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md @@ -0,0 +1,172 @@ +# “我的”页签帮助与反馈入口 PRD + +更新时间:`2026-05-08` + +## 0. 目标 + +在平台“我的”页签新增“反馈”入口。用户点击后进入独立路由 `/profile/feedback`,看到移动端优先的“帮助与反馈”表单页面,用于提交问题描述、上传问题截图凭证并选填联系电话。 + +本次目标是补齐用户反馈入口和前端提交流程,不重新发明新的个人中心系统,不在“我的”页签当前面板下方展开表单。 + +## 1. 参考图 + +计划参考图保存在: + +`../.hermes/plans/assets/profile-feedback-reference-2026-05-08.png` + +页面结构以该参考图为准: + +1. 顶部白色导航栏,标题为“帮助与反馈”。 +2. 内容背景为浅灰色。 +3. 分区标题为“反馈问题”。 +4. 第一张白色圆角卡片为“问题描述”。 +5. 第二张白色圆角卡片为“上传凭证(提供问题截图)”。 +6. 第三张白色圆角卡片为“联系电话”。 +7. 底部为蓝色主按钮“提交”。 +8. 提交按钮下方为蓝色文本入口“查看反馈与投诉记录”。 + +## 2. 首版范围 + +### 2.1 包含 + +- “我的”页签常用功能区新增“反馈”入口。 +- 点击入口进入 `/profile/feedback` 独立路由。 +- 反馈页标题显示“帮助与反馈”。 +- 问题描述输入: + - 最少 10 个字。 + - 最多 200 个字。 + - 实时显示 `当前字数/200`。 + - placeholder:`请填写10个字以上的问题描述以便我们提供更好的帮助,温馨提醒您请勿填写身份证号等个人隐私信息。` +- 上传凭证: + - 展示虚线上传方块。 + - 支持选择图片时,最多 4 张。 + - 前端可预览已选图片。 + - 不接后端时,提交只进入前端成功态。 +- 联系电话: + - 选填。 + - placeholder:`选填,如您填写则将会同步开发者与您联系`。 +- 提交后显示成功态。 +- 返回后回到平台首页并定位“我的”页签。 + +### 2.2 不包含 + +- 不新增后端反馈存储接口。 +- 不新增 SpacetimeDB 表结构和 migration。 +- 不新增后台反馈记录管理页。 +- 不实现真实“反馈与投诉记录”列表。 + +如果后续要求真实存储反馈,需要另起后端 PRD/技术方案,覆盖 `shared-contracts`、`api-server`、SpacetimeDB 表与后台管理入口。 + +## 3. 入口设计 + +### 3.1 入口位置 + +入口放在“我的”页签常用功能区,和“每日任务 / 邀请好友 / 填邀请码 / 玩家社区”同级。 + +入口展示: + +- 主标题:`反馈` +- 副标题:`问题与建议` +- 图标:可复用 `MessageCircle` 或类似消息图标。 + +### 3.2 未登录状态 + +首版默认未登录用户点击入口时触发登录弹窗,不进入反馈页。 + +原因:当前“我的”页签的数据与账号绑定,反馈如果未来接入后端,也应能关联提交账号。 + +## 4. 反馈页 UI + +### 4.1 页面整体 + +- 移动端优先。 +- 背景为浅灰色或与现有平台浅色 surface 接近的背景。 +- 表单容器使用白色圆角卡片。 +- 桌面端居中展示,最大宽度不超过移动表单阅读范围,避免横向拉满。 + +### 4.2 顶部栏 + +- 标题:`帮助与反馈`。 +- 左侧返回/首页图标:点击返回平台首页“我的”页签。 +- 右侧胶囊控制区可不完全复刻;项目内若没有同类控件,保持简洁,不强行新增无实际功能按钮。 + +### 4.3 问题描述卡片 + +- 标题:`问题描述`。 +- 输入框类型:textarea。 +- 最小高度接近参考图的大文本区域。 +- 右下角字数:`0/200`。 +- 校验失败提示靠近卡片或提交按钮上方展示,不弹浏览器 alert。 + +### 4.4 上传凭证卡片 + +- 标题:`上传凭证(提供问题截图)`。 +- 上传入口为虚线边框方块。 +- 文案: + - `上传凭证` + - `(最多四张)` +- 支持图片选择时,只允许 `image/*`。 +- 超过 4 张时提示:`最多上传四张凭证`。 + +### 4.5 联系电话卡片 + +- 标题:`联系电话`。 +- 输入框类型:text 或 tel。 +- 联系电话选填,不阻塞提交。 +- 最长 40 字符。 + +### 4.6 底部操作 + +- 主按钮:`提交`。 +- 按钮为蓝色圆角,宽度接近容器宽度。 +- 二级链接:`查看反馈与投诉记录`。 +- 首版无记录页时,该链接可以: + - 隐藏;或 + - 保留并点击后显示轻量提示“反馈记录暂未开放”。 + +## 5. 路由与状态 + +- 新增页面阶段:`profile-feedback`。 +- 新增路由:`/profile/feedback`。 +- 浏览器直接访问 `/profile/feedback` 时应显示反馈页。 +- 点击页面返回时: + - 设置平台 tab 为 `profile`。 + - 回到 `platform` 阶段。 + +## 6. 表单校验 + +提交时按以下顺序校验: + +1. 问题描述去除首尾空白后少于 10 个字:提示 `请填写10个字以上的问题描述`。 +2. 问题描述超过 200 个字:提示 `问题描述不能超过 200 字`。 +3. 联系电话超过 40 字符:提示 `联系电话不能超过 40 字`。 +4. 上传凭证超过 4 张:提示 `最多上传四张凭证`。 + +校验通过后进入前端成功态。 + +## 7. 验收标准 + +- “我的”页签可看到“反馈”入口。 +- 已登录用户点击后进入 `/profile/feedback`。 +- 未登录用户点击后弹登录。 +- `/profile/feedback` 页面显示“帮助与反馈”。 +- 问题描述字数统计实时变化。 +- 空内容或少于 10 个字提交时显示校验错误。 +- 有效内容提交后显示成功态。 +- 上传凭证最多 4 张。 +- 联系电话为空时可以提交。 +- 返回后回到“我的”页签。 +- 页面在 390×844 移动端视口不横向溢出。 +- `npm run check:encoding`、`npm run typecheck`、定向测试通过。 + +## 8. 文件落点 + +- PRD:`docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md` +- 路由:`src/routing/appPageRoutes.ts` +- 阶段类型:`src/components/platform-entry/platformEntryTypes.ts` +- 反馈页:`src/components/platform-entry/PlatformFeedbackView.tsx` +- 我的页签入口:`src/components/rpg-entry/RpgEntryHomeView.tsx` +- 页面接入:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` +- 测试: + - `src/routing/appPageRoutes.test.ts` + - `src/components/platform-entry/PlatformFeedbackView.test.tsx` From 3b0dd2ebeb0b0e82832e3b2f02f7b9f563e4c2c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8E=86=E5=86=B0=E9=83=81-hermes=E7=89=88?= Date: Fri, 8 May 2026 12:20:32 +0800 Subject: [PATCH 3/7] feat: add profile feedback route stage --- src/components/platform-entry/platformEntryTypes.ts | 1 + src/routing/appPageRoutes.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/components/platform-entry/platformEntryTypes.ts b/src/components/platform-entry/platformEntryTypes.ts index 19821753..c0321217 100644 --- a/src/components/platform-entry/platformEntryTypes.ts +++ b/src/components/platform-entry/platformEntryTypes.ts @@ -15,6 +15,7 @@ export type CustomWorldRuntimeLaunchOptions = { export type SelectionStage = | 'platform' + | 'profile-feedback' | 'work-detail' | 'detail' | 'agent-workspace' diff --git a/src/routing/appPageRoutes.ts b/src/routing/appPageRoutes.ts index 31cc060c..26f8257c 100644 --- a/src/routing/appPageRoutes.ts +++ b/src/routing/appPageRoutes.ts @@ -6,6 +6,7 @@ export const PUBLIC_WORK_QUERY_PARAM = 'work'; const STAGE_ROUTE_ENTRIES = [ ['platform', '/'], + ['profile-feedback', '/profile/feedback'], ['work-detail', '/works/detail'], ['detail', '/worlds/detail'], ['agent-workspace', '/creation/rpg/agent'], From 7eb531ccca1b59f193a1ddaaa4b1d8d001f1831b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8E=86=E5=86=B0=E9=83=81-hermes=E7=89=88?= Date: Fri, 8 May 2026 12:21:55 +0800 Subject: [PATCH 4/7] feat: add platform feedback view --- .../platform-entry/PlatformFeedbackView.tsx | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 src/components/platform-entry/PlatformFeedbackView.tsx diff --git a/src/components/platform-entry/PlatformFeedbackView.tsx b/src/components/platform-entry/PlatformFeedbackView.tsx new file mode 100644 index 00000000..231cd449 --- /dev/null +++ b/src/components/platform-entry/PlatformFeedbackView.tsx @@ -0,0 +1,322 @@ +import { ArrowLeft, CheckCircle2, Home, ImagePlus, Send, X } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +const MIN_FEEDBACK_DESCRIPTION_LENGTH = 10; +const MAX_FEEDBACK_DESCRIPTION_LENGTH = 200; +const MAX_FEEDBACK_EVIDENCE_COUNT = 4; +const MAX_CONTACT_PHONE_LENGTH = 40; + +export type PlatformFeedbackPayload = { + description: string; + contactPhone: string; + evidenceFiles: File[]; +}; + +export type PlatformFeedbackViewProps = { + onBack: () => void; + onSubmit?: (payload: PlatformFeedbackPayload) => void | Promise; +}; + +type EvidencePreview = { + id: string; + file: File; + url: string; +}; + +function buildEvidencePreviewId(file: File, index: number) { + return `${file.name}:${file.size}:${file.lastModified}:${index}`; +} + +export function PlatformFeedbackView({ + onBack, + onSubmit, +}: PlatformFeedbackViewProps) { + const evidenceInputRef = useRef(null); + const [description, setDescription] = useState(''); + const [contactPhone, setContactPhone] = useState(''); + const [evidencePreviews, setEvidencePreviews] = useState([]); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + + const descriptionLength = description.length; + const evidenceFiles = useMemo( + () => evidencePreviews.map((preview) => preview.file), + [evidencePreviews], + ); + + useEffect( + () => () => { + evidencePreviews.forEach((preview) => URL.revokeObjectURL(preview.url)); + }, + [evidencePreviews], + ); + + const showTemporaryNotice = (message: string) => { + setNotice(message); + window.setTimeout(() => setNotice(null), 1600); + }; + + const updateDescription = (value: string) => { + setDescription(value.slice(0, MAX_FEEDBACK_DESCRIPTION_LENGTH)); + setError(null); + setSubmitted(false); + }; + + const updateContactPhone = (value: string) => { + setContactPhone(value.slice(0, MAX_CONTACT_PHONE_LENGTH)); + setError(null); + setSubmitted(false); + }; + + const openEvidencePicker = () => { + evidenceInputRef.current?.click(); + }; + + const addEvidenceFiles = (files: FileList | null) => { + if (evidenceInputRef.current) { + evidenceInputRef.current.value = ''; + } + if (!files || files.length === 0) { + return; + } + + const selectedFiles = Array.from(files).filter((file) => + file.type.startsWith('image/'), + ); + const remainingCount = MAX_FEEDBACK_EVIDENCE_COUNT - evidencePreviews.length; + if (remainingCount <= 0 || selectedFiles.length > remainingCount) { + setError('最多上传四张凭证'); + } + + const nextFiles = selectedFiles.slice(0, Math.max(remainingCount, 0)); + if (nextFiles.length === 0) { + return; + } + + const nextPreviews = nextFiles.map((file, index) => ({ + id: buildEvidencePreviewId(file, evidencePreviews.length + index), + file, + url: URL.createObjectURL(file), + })); + setEvidencePreviews((currentPreviews) => [...currentPreviews, ...nextPreviews]); + setSubmitted(false); + }; + + const removeEvidencePreview = (id: string) => { + setEvidencePreviews((currentPreviews) => { + const previewToRemove = currentPreviews.find((preview) => preview.id === id); + if (previewToRemove) { + URL.revokeObjectURL(previewToRemove.url); + } + return currentPreviews.filter((preview) => preview.id !== id); + }); + setError(null); + setSubmitted(false); + }; + + const submitFeedback = () => { + if (isSubmitting) { + return; + } + + const trimmedDescription = description.trim(); + const trimmedContactPhone = contactPhone.trim(); + if (trimmedDescription.length < MIN_FEEDBACK_DESCRIPTION_LENGTH) { + setError('请填写10个字以上的问题描述'); + setSubmitted(false); + return; + } + if (trimmedDescription.length > MAX_FEEDBACK_DESCRIPTION_LENGTH) { + setError('问题描述不能超过 200 字'); + setSubmitted(false); + return; + } + if (trimmedContactPhone.length > MAX_CONTACT_PHONE_LENGTH) { + setError('联系电话不能超过 40 字'); + setSubmitted(false); + return; + } + if (evidenceFiles.length > MAX_FEEDBACK_EVIDENCE_COUNT) { + setError('最多上传四张凭证'); + setSubmitted(false); + return; + } + + setIsSubmitting(true); + setError(null); + // 中文注释:首版反馈页只完成前端收集与成功态;接入后端时在 onSubmit 中替换为 API 调用。 + void Promise.resolve( + onSubmit?.({ + description: trimmedDescription, + contactPhone: trimmedContactPhone, + evidenceFiles, + }), + ) + .then(() => setSubmitted(true)) + .catch((submitError: unknown) => { + setSubmitted(false); + setError(submitError instanceof Error ? submitError.message : '提交失败'); + }) + .finally(() => setIsSubmitting(false)); + }; + + return ( +
+
+
+
+ +
+ 帮助与反馈 +
+ +
+
+ +
+
反馈问题
+ +
+ +