6 Commits

8 changed files with 609 additions and 65 deletions

View 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`

View File

@@ -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"

View 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);
});

View 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>
);
}

View File

@@ -15,6 +15,7 @@ export type CustomWorldRuntimeLaunchOptions = {
export type SelectionStage =
| 'platform'
| 'profile-feedback'
| 'work-detail'
| 'detail'
| 'agent-workspace'

View File

@@ -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>

View File

@@ -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',
);
});
});

View File

@@ -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'],