Merge pull request '添加反馈入口' (#8) from hermes/hermes-19e77eb0 into master
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
Reviewed-on: http://82.157.175.59:3000/GenarrativeAI/Genarrative/pulls/8
This commit was merged in pull request #8.
This commit is contained in:
584
.hermes/plans/2026-05-08_120646-profile-feedback-entry.md
Normal file
584
.hermes/plans/2026-05-08_120646-profile-feedback-entry.md
Normal file
@@ -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
|
||||
|
||||

|
||||
|
||||
参考图是一张移动端“帮助与反馈”页面,视觉和信息结构如下:
|
||||
|
||||
- 页面整体:浅灰背景,白色圆角卡片,黑/深灰标题文字,浅灰 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<void>;
|
||||
};
|
||||
|
||||
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
|
||||
<div className="platform-page-stage platform-remap-surface min-h-0 min-w-0 overflow-y-auto px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-4">
|
||||
<header className="platform-surface platform-surface--soft rounded-[1.6rem] px-4 py-4">
|
||||
<button type="button" onClick={onBack} ...>
|
||||
<ArrowLeft ... /> 返回
|
||||
</button>
|
||||
<h1>反馈</h1>
|
||||
</header>
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
注意:不要写大段“功能说明类文案”;字段 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
|
||||
<ProfileShortcutButton
|
||||
label="反馈"
|
||||
subLabel="问题与建议"
|
||||
icon={MessageCircle}
|
||||
onClick={onOpenFeedback}
|
||||
/>
|
||||
```
|
||||
|
||||
如果参考图中入口位置不同,按参考图调整;但仍必须进入独立路由。
|
||||
|
||||
**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' && (
|
||||
<motion.div
|
||||
key="platform-profile-feedback"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<PlatformFeedbackView
|
||||
onBack={() => {
|
||||
setPlatformTab('profile');
|
||||
setSelectionStage('platform');
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
```
|
||||
|
||||
**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(<PlatformFeedbackView onBack={vi.fn()} />);
|
||||
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 <fixed-files>
|
||||
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 约束落地。
|
||||
BIN
.hermes/plans/assets/profile-feedback-reference-2026-05-08.png
Normal file
BIN
.hermes/plans/assets/profile-feedback-reference-2026-05-08.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
172
docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md
Normal file
172
docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md
Normal file
@@ -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`
|
||||
@@ -222,6 +222,7 @@ import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld'
|
||||
import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave';
|
||||
import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController';
|
||||
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
|
||||
import { PlatformFeedbackView } from './PlatformFeedbackView';
|
||||
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
||||
import { isPlatformCreationTypeVisible } from './platformEntryCreationTypes';
|
||||
import {
|
||||
@@ -1352,6 +1353,22 @@ export function PlatformEntryFlowShellImpl({
|
||||
});
|
||||
const { setPlatformTab } = platformBootstrap;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionStage === 'profile-feedback') {
|
||||
setPlatformTab('profile');
|
||||
}
|
||||
}, [selectionStage, setPlatformTab]);
|
||||
|
||||
const openProfileFeedback = useCallback(() => {
|
||||
if (!authUi?.user) {
|
||||
authUi?.openLoginModal();
|
||||
return;
|
||||
}
|
||||
|
||||
setPlatformTab('profile');
|
||||
setSelectionStage('profile-feedback');
|
||||
}, [authUi, setPlatformTab, setSelectionStage]);
|
||||
|
||||
const enterCreateTab = useCallback(() => {
|
||||
// 只依赖稳定的 setter,避免把 bootstrap 对象的 render 级引用变化
|
||||
// 传导成 Agent session 恢复 effect 的重复触发。
|
||||
@@ -5465,6 +5482,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsProfilePlayStatsOpen(false);
|
||||
}}
|
||||
onOpenPlayedWork={openPlayedWork}
|
||||
onOpenFeedback={openProfileFeedback}
|
||||
onOpenProfileDashboardCard={(cardKey) => {
|
||||
if (cardKey === 'playedWorks') {
|
||||
openProfilePlayedWorks();
|
||||
@@ -5479,6 +5497,23 @@ export function PlatformEntryFlowShellImpl({
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'profile-feedback' && (
|
||||
<motion.div
|
||||
key="platform-profile-feedback"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<PlatformFeedbackView
|
||||
onBack={() => {
|
||||
setPlatformTab('profile');
|
||||
setSelectionStage('platform');
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'work-detail' && selectedPublicWorkDetail && (
|
||||
<motion.div
|
||||
key="platform-work-detail"
|
||||
|
||||
63
src/components/platform-entry/PlatformFeedbackView.test.tsx
Normal file
63
src/components/platform-entry/PlatformFeedbackView.test.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { PlatformFeedbackView } from './PlatformFeedbackView';
|
||||
|
||||
test('PlatformFeedbackView renders reference feedback fields', () => {
|
||||
render(<PlatformFeedbackView onBack={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('帮助与反馈')).toBeTruthy();
|
||||
expect(screen.getByText('反馈问题')).toBeTruthy();
|
||||
expect(screen.getByLabelText('问题描述')).toBeTruthy();
|
||||
expect(screen.getByText('0/200')).toBeTruthy();
|
||||
expect(screen.getByText('上传凭证(提供问题截图)')).toBeTruthy();
|
||||
expect(screen.getByText('上传凭证')).toBeTruthy();
|
||||
expect(screen.getByLabelText('联系电话')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '提交' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '查看反馈与投诉记录' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('PlatformFeedbackView validates minimum description length before submit', () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(<PlatformFeedbackView onBack={vi.fn()} onSubmit={onSubmit} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('问题描述'), {
|
||||
target: { value: '太短' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }));
|
||||
|
||||
expect(screen.getByText('请填写10个字以上的问题描述')).toBeTruthy();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('PlatformFeedbackView submits trimmed payload', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(<PlatformFeedbackView onBack={vi.fn()} onSubmit={onSubmit} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('问题描述'), {
|
||||
target: { value: ' 这个反馈页面无法正常上传图片 ' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('联系电话'), {
|
||||
target: { value: ' 13800000000 ' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
description: '这个反馈页面无法正常上传图片',
|
||||
contactPhone: '13800000000',
|
||||
evidenceFiles: [],
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('反馈已提交')).toBeTruthy());
|
||||
});
|
||||
|
||||
test('PlatformFeedbackView calls back from header home button', () => {
|
||||
const onBack = vi.fn();
|
||||
render(<PlatformFeedbackView onBack={onBack} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回我的页签' }));
|
||||
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
322
src/components/platform-entry/PlatformFeedbackView.tsx
Normal file
322
src/components/platform-entry/PlatformFeedbackView.tsx
Normal file
@@ -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<void>;
|
||||
};
|
||||
|
||||
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<HTMLInputElement | null>(null);
|
||||
const [description, setDescription] = useState('');
|
||||
const [contactPhone, setContactPhone] = useState('');
|
||||
const [evidencePreviews, setEvidencePreviews] = useState<EvidencePreview[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(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 (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-[#f5f6f8] text-[#202124]">
|
||||
<div className="mx-auto flex min-h-full w-full max-w-[30rem] flex-col pb-8">
|
||||
<header className="sticky top-0 z-10 rounded-b-[1.35rem] bg-white px-4 pb-3 pt-4 shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||
<div className="grid grid-cols-[2.5rem_1fr_5.75rem] items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label="返回我的页签"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-[#24262b] transition hover:bg-slate-100"
|
||||
>
|
||||
<Home className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="text-center text-base font-semibold text-[#1f2329]">
|
||||
帮助与反馈
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 items-center justify-center gap-2 rounded-full border border-[#d8dce3] px-2 text-[#1f2329]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="text-lg leading-none">···</span>
|
||||
<span className="h-4 w-px bg-[#d8dce3]" />
|
||||
<span className="h-0.5 w-3 rounded-full bg-[#1f2329]" />
|
||||
<span className="h-3.5 w-3.5 rounded-full border-2 border-[#1f2329]" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex flex-1 flex-col gap-3 px-4 pt-5">
|
||||
<div className="text-sm font-medium text-[#8b93a1]">反馈问题</div>
|
||||
|
||||
<section className="rounded-2xl bg-white px-4 py-4 shadow-[0_8px_24px_rgba(15,23,42,0.03)]">
|
||||
<label htmlFor="profile-feedback-description" className="block text-base font-semibold text-[#1f2329]">
|
||||
问题描述
|
||||
</label>
|
||||
<textarea
|
||||
id="profile-feedback-description"
|
||||
value={description}
|
||||
maxLength={MAX_FEEDBACK_DESCRIPTION_LENGTH}
|
||||
onChange={(event) => updateDescription(event.target.value)}
|
||||
placeholder="请填写10个字以上的问题描述以便我们提供更好的帮助,温馨提醒您请勿填写身份证号等个人隐私信息。"
|
||||
className="mt-3 min-h-[10.5rem] w-full resize-none border-0 bg-transparent text-sm leading-6 text-[#1f2329] outline-none placeholder:text-[#b4bac4]"
|
||||
/>
|
||||
<div className="text-right text-xs text-[#a5adba]">
|
||||
{descriptionLength}/{MAX_FEEDBACK_DESCRIPTION_LENGTH}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl bg-white px-4 py-4 shadow-[0_8px_24px_rgba(15,23,42,0.03)]">
|
||||
<div className="text-base font-semibold text-[#1f2329]">
|
||||
上传凭证(提供问题截图)
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
{evidencePreviews.map((preview) => (
|
||||
<div
|
||||
key={preview.id}
|
||||
className="relative h-[5.75rem] w-[5.75rem] overflow-hidden rounded-xl border border-[#e3e6eb] bg-[#f7f8fa]"
|
||||
>
|
||||
<img
|
||||
src={preview.url}
|
||||
alt="反馈凭证预览"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEvidencePreview(preview.id)}
|
||||
aria-label="移除上传凭证"
|
||||
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/55 text-white"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{evidencePreviews.length < MAX_FEEDBACK_EVIDENCE_COUNT ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openEvidencePicker}
|
||||
className="flex h-[5.75rem] w-[5.75rem] flex-col items-center justify-center rounded-xl border border-dashed border-[#cdd3dc] bg-[#fbfcfd] text-[#9aa3af] transition hover:border-[#2f7cf6] hover:text-[#2f7cf6]"
|
||||
>
|
||||
<ImagePlus className="h-6 w-6" />
|
||||
<span className="mt-2 text-xs font-medium">上传凭证</span>
|
||||
<span className="mt-0.5 text-[11px]">(最多四张)</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<input
|
||||
ref={evidenceInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => addEvidenceFiles(event.target.files)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl bg-white px-4 py-4 shadow-[0_8px_24px_rgba(15,23,42,0.03)]">
|
||||
<label htmlFor="profile-feedback-phone" className="block text-base font-semibold text-[#1f2329]">
|
||||
联系电话
|
||||
</label>
|
||||
<input
|
||||
id="profile-feedback-phone"
|
||||
type="tel"
|
||||
value={contactPhone}
|
||||
maxLength={MAX_CONTACT_PHONE_LENGTH}
|
||||
onChange={(event) => updateContactPhone(event.target.value)}
|
||||
placeholder="选填,如您填写则将会同步开发者与您联系"
|
||||
className="mt-3 w-full border-0 bg-transparent text-sm leading-6 text-[#1f2329] outline-none placeholder:text-[#b4bac4]"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl bg-rose-50 px-4 py-3 text-sm font-medium text-rose-600">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{submitted ? (
|
||||
<div className="flex items-center gap-2 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
反馈已提交
|
||||
</div>
|
||||
) : null}
|
||||
{notice ? (
|
||||
<div className="rounded-2xl bg-blue-50 px-4 py-3 text-sm font-medium text-[#2f7cf6]">
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitFeedback}
|
||||
disabled={isSubmitting}
|
||||
className="mt-2 flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-[#2f7cf6] text-base font-semibold text-white shadow-[0_10px_22px_rgba(47,124,246,0.26)] transition hover:bg-[#1f6bea] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{isSubmitting ? '提交中' : '提交'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showTemporaryNotice('反馈记录暂未开放')}
|
||||
className="self-center px-3 py-2 text-sm font-medium text-[#2f7cf6]"
|
||||
>
|
||||
查看反馈与投诉记录
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="mt-auto flex items-center justify-center gap-2 self-center px-3 py-2 text-xs font-medium text-[#8b93a1]"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回我的
|
||||
</button>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export type CustomWorldRuntimeLaunchOptions = {
|
||||
|
||||
export type SelectionStage =
|
||||
| 'platform'
|
||||
| 'profile-feedback'
|
||||
| 'work-detail'
|
||||
| 'detail'
|
||||
| 'agent-workspace'
|
||||
|
||||
@@ -134,6 +134,7 @@ export interface RpgEntryHomeViewProps {
|
||||
profilePlayStatsError?: string | null;
|
||||
onCloseProfilePlayStats?: () => void;
|
||||
onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void;
|
||||
onOpenFeedback?: () => void;
|
||||
onRechargeSuccess?: () => void | Promise<void>;
|
||||
createTabContent?: ReactNode;
|
||||
}
|
||||
@@ -2679,6 +2680,7 @@ export function RpgEntryHomeView({
|
||||
profilePlayStatsError = null,
|
||||
onCloseProfilePlayStats,
|
||||
onOpenPlayedWork,
|
||||
onOpenFeedback,
|
||||
onRechargeSuccess,
|
||||
createTabContent,
|
||||
}: RpgEntryHomeViewProps) {
|
||||
@@ -3996,6 +3998,12 @@ export function RpgEntryHomeView({
|
||||
icon={MessageCircle}
|
||||
onClick={() => openProfilePopupPanel('community')}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="反馈"
|
||||
subLabel="问题与建议"
|
||||
icon={MessageCircle}
|
||||
onClick={onOpenFeedback}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,78 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
APP_RUNTIME_ROUTES,
|
||||
buildPublicWorkDetailPath,
|
||||
buildPublicWorkDetailUrl,
|
||||
buildPublicWorkStagePath,
|
||||
isKnownMainAppPagePath,
|
||||
normalizeAppPath,
|
||||
readPublicWorkCodeFromLocationSearch,
|
||||
resolvePathForSelectionStage,
|
||||
resolveSelectionStageFromPath,
|
||||
} from './appPageRoutes';
|
||||
|
||||
describe('appPageRoutes', () => {
|
||||
it('normalizes page paths for stable matching', () => {
|
||||
expect(normalizeAppPath('')).toBe('/');
|
||||
expect(normalizeAppPath('/CREATION/RPG/AGENT/')).toBe(
|
||||
'/creation/rpg/agent',
|
||||
it('resolves profile feedback route', () => {
|
||||
expect(resolveSelectionStageFromPath('/profile/feedback')).toBe(
|
||||
'profile-feedback',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves platform entry stages from independent paths', () => {
|
||||
expect(resolveSelectionStageFromPath('/creation/rpg/agent')).toBe(
|
||||
'agent-workspace',
|
||||
expect(resolveSelectionStageFromPath('/profile/feedback/')).toBe(
|
||||
'profile-feedback',
|
||||
);
|
||||
expect(resolveSelectionStageFromPath('/creation/big-fish/result/')).toBe(
|
||||
'big-fish-result',
|
||||
);
|
||||
expect(resolveSelectionStageFromPath('/creation/match3d/result')).toBe(
|
||||
'match3d-result',
|
||||
);
|
||||
expect(resolveSelectionStageFromPath('/gallery/puzzle/detail')).toBe(
|
||||
'puzzle-gallery-detail',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to platform for unknown paths inside the main app', () => {
|
||||
expect(resolveSelectionStageFromPath('/missing')).toBe('platform');
|
||||
});
|
||||
|
||||
it('resolves paths from selection stages', () => {
|
||||
expect(resolvePathForSelectionStage('custom-world-generating')).toBe(
|
||||
'/creation/rpg/generating',
|
||||
);
|
||||
expect(resolvePathForSelectionStage('puzzle-runtime')).toBe(
|
||||
'/runtime/puzzle',
|
||||
);
|
||||
});
|
||||
|
||||
it('recognizes runtime pages as main app pages', () => {
|
||||
expect(
|
||||
isKnownMainAppPagePath(APP_RUNTIME_ROUTES['rpg-character-select']),
|
||||
).toBe(true);
|
||||
expect(isKnownMainAppPagePath('/runtime/rpg/adventure/')).toBe(true);
|
||||
});
|
||||
|
||||
it('builds and reads public work detail query routes', () => {
|
||||
expect(buildPublicWorkDetailPath('CW-00000001')).toBe(
|
||||
'/works/detail?work=CW-00000001',
|
||||
);
|
||||
expect(
|
||||
buildPublicWorkDetailUrl('CW-00000001', 'https://example.test'),
|
||||
).toBe('https://example.test/works/detail?work=CW-00000001');
|
||||
expect(readPublicWorkCodeFromLocationSearch('?work=CW-00000001')).toBe(
|
||||
'CW-00000001',
|
||||
);
|
||||
expect(
|
||||
buildPublicWorkStagePath('puzzle-gallery-detail', 'PZ-00000002'),
|
||||
).toBe('/gallery/puzzle/detail?work=PZ-00000002');
|
||||
expect(buildPublicWorkStagePath('big-fish-runtime', 'BF-00000003')).toBe(
|
||||
'/runtime/big-fish?work=BF-00000003',
|
||||
);
|
||||
expect(buildPublicWorkStagePath('match3d-runtime', 'M3-00000004')).toBe(
|
||||
'/runtime/match3d?work=M3-00000004',
|
||||
expect(resolvePathForSelectionStage('profile-feedback')).toBe(
|
||||
'/profile/feedback',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user