fix: 完善作品号展示与复制入口

This commit is contained in:
2026-04-26 14:13:34 +08:00
parent 0a0f3f1bd8
commit 874e10383b
17 changed files with 951 additions and 253 deletions

View File

@@ -0,0 +1,21 @@
# 公开作品号移动端分享入口修复 2026-04-25
## 背景
公开编号设计已要求广场作品卡和详情页展示 `CW / PZ` 作品号,并支持通过首页搜索入口打开公开作品。但当前移动端首页只有桌面端顶部搜索框,竖屏无法输入 `SY / CW / PZ` 编号;同时首页“最新发布”和桌面趋势卡片把发布时间放在显眼 badge 位置,异常时间字符串会被误认为作品号;创作页“我的作品”卡只展示作者和游玩数,没有可复制、可搜索的公开作品号。
## 落地规则
1. 移动端首页在 Logo 下方提供紧凑搜索条,复用现有 `onSearchPublicCode` 行为,不新增页面或新系统。
2. 广场作品卡的辅助 badge 优先展示作品号,点击作品号只复制,不打开详情;没有公开作品号时展示作品类型,不再用发布时间充当主 badge。
3. RPG 与拼图详情页在已发布作品的辅助信息里展示作品号,并提供复制动作。
4. 创作页作品卡在已发布作品上展示作品号RPG 使用后端 `publicWorkCode`;拼图当前没有独立公开号时,使用 `PZ-` + `profileId` 后 8 位作为前端展示与复制标识,后续若补后端拼图公开号再替换来源。
5. 所有入口保持轻量 UI不写规则说明文案不改变发布、下架、进入游戏的后端语义。
## 验收
1. 399px 竖屏首页能直接看到并使用搜索入口。
2. 首页公开作品卡左上角不再出现发布时间样式的疑似作品号。
3. RPG 详情页能看到 `作品号 CW...` 并可复制,拼图详情页能看到 `作品号 PZ...` 并可复制。
4. 创作页“我的作品”已发布卡能看到作品号,拼图卡不会只显示作者和游玩数。
5. 桌面右侧趋势列表只显示排序和作品号或作品类型,不再显示 `1777110165.990127Z` 这类原始时间字符串。

View File

@@ -72,7 +72,9 @@ test('creation hub reflects updated draft title summary and counts after rerende
);
expect(screen.getByText('潮雾列岛·回潮版')).toBeTruthy();
expect(screen.getByText('世界总卡和角色网已经继续长出了新的支线。')).toBeTruthy();
expect(
screen.getByText('世界总卡和角色网已经继续长出了新的支线。'),
).toBeTruthy();
expect(screen.getByText('角色 5')).toBeTruthy();
expect(screen.getByText('地点 6')).toBeTruthy();
});
@@ -110,10 +112,59 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to
expect(screen.getByText('潮雾列岛')).toBeTruthy();
expect(screen.getByText('沉钟拼图')).toBeTruthy();
expect(screen.getByText('PZ-PROFILE1')).toBeTruthy();
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
expect(screen.queryByText('我的拼图作品')).toBeNull();
});
test('creation hub shows RPG public work code from published library entry', () => {
render(
<CustomWorldCreationHub
items={[
{
...baseDraftItem,
workId: 'published:world-public-1',
sourceType: 'published_profile',
status: 'published',
title: '潮雾列岛已发布版',
profileId: 'world-public-1',
canResume: false,
canEnterWorld: true,
},
]}
rpgLibraryEntries={[
{
ownerUserId: 'user-1',
profileId: 'world-public-1',
publicWorkCode: 'CW-00000001',
authorPublicUserCode: 'SY-00000001',
profile: {} as never,
visibility: 'published',
publishedAt: '2026-04-20T10:00:00.000Z',
updatedAt: '2026-04-20T10:00:00.000Z',
authorDisplayName: '测试玩家',
worldName: '潮雾列岛已发布版',
subtitle: '旧灯塔与失控航路',
summaryText: '已经发布的群岛世界作品。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 3,
landmarkCount: 4,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
/>,
);
expect(screen.getByText('潮雾列岛已发布版')).toBeTruthy();
expect(screen.getByText('CW-00000001')).toBeTruthy();
});
test('creation hub shows delete action for persisted rpg drafts', () => {
render(
<CustomWorldCreationHub
@@ -157,7 +208,9 @@ test('creation hub opens persisted rpg drafts by card click', async () => {
/>,
);
await user.click(screen.getByRole('button', { name: /稿/u }));
await user.click(
screen.getByRole('button', { name: /稿/u }),
);
expect(openedItems).toEqual([persistedDraft]);
});

View File

@@ -80,5 +80,7 @@ test('creation hub renders puzzle works in the same unified list with puzzle tag
expect(html).toContain('潮雾拼图');
expect(html).toContain('拼图');
expect(html).toContain('作品号');
expect(html).toContain('PZ-PROFILE1');
expect(html).not.toContain('我的拼图作品');
});

View File

@@ -3,6 +3,8 @@
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { CustomWorldProfile } from '../../types';
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
import {
@@ -28,6 +30,7 @@ type CustomWorldCreationHubProps = {
onDeletePublished?: ((item: CustomWorldWorkSummary) => void) | null;
deletingWorkId?: string | null;
onExperienceRpg?: ((item: CustomWorldWorkSummary) => void) | null;
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
bigFishItems?: BigFishWorkSummary[];
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
onExperienceBigFish?: ((item: BigFishWorkSummary) => void) | null;
@@ -61,6 +64,7 @@ export function CustomWorldCreationHub({
onDeletePublished = null,
deletingWorkId = null,
onExperienceRpg = null,
rpgLibraryEntries = [],
bigFishItems = [],
onOpenBigFishDetail,
onExperienceBigFish = null,
@@ -76,6 +80,7 @@ export function CustomWorldCreationHub({
() =>
buildCreationWorkShelfItems({
rpgItems: items,
rpgLibraryEntries,
bigFishItems,
puzzleItems,
canDeleteRpg: Boolean(onDeletePublished),
@@ -89,9 +94,12 @@ export function CustomWorldCreationHub({
onDeletePublished,
onDeletePuzzle,
puzzleItems,
rpgLibraryEntries,
],
);
const draftCount = shelfItems.filter((entry) => entry.status === 'draft').length;
const draftCount = shelfItems.filter(
(entry) => entry.status === 'draft',
).length;
const publishedCount = shelfItems.filter(
(entry) => entry.status === 'published',
).length;

View File

@@ -1,6 +1,16 @@
import { Copy } from 'lucide-react';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import type { CreationWorkShelfItem } from './creationWorkShelf';
function copyText(value: string) {
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
return;
}
void navigator.clipboard.writeText(value);
}
function formatUpdatedAt(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
@@ -23,7 +33,10 @@ type CustomWorldWorkCardProps = {
deleteBusy?: boolean;
};
const BADGE_TONE_CLASS: Record<CreationWorkShelfItem['badges'][number]['tone'], string> = {
const BADGE_TONE_CLASS: Record<
CreationWorkShelfItem['badges'][number]['tone'],
string
> = {
warm: 'platform-pill--warm',
success: 'platform-pill--success',
neutral: 'platform-pill--neutral',
@@ -127,15 +140,33 @@ export function CustomWorldWorkCard({
</div>
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-end sm:justify-between xl:gap-2 xl:pt-3">
<div className="flex flex-wrap gap-2">
{item.metrics.map((metric) => (
<span
key={`${item.id}-${metric.id}`}
className={`platform-pill ${BADGE_TONE_CLASS[metric.tone ?? 'neutral']} px-3 py-1 text-[10px]`}
<div className="min-w-0 space-y-2">
{item.publicWorkCode ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
copyText(item.publicWorkCode ?? '');
}}
className="platform-pill platform-pill--neutral pointer-events-auto relative z-30 inline-flex max-w-full items-center gap-1.5 px-3 py-1 text-[10px]"
aria-label={`复制作品号 ${item.publicWorkCode}`}
title="复制作品号"
>
{metric.label}
</span>
))}
<span className="shrink-0"></span>
<span className="min-w-0 truncate">{item.publicWorkCode}</span>
<Copy className="h-3 w-3 shrink-0" />
</button>
) : null}
<div className="flex flex-wrap gap-2">
{item.metrics.map((metric) => (
<span
key={`${item.id}-${metric.id}`}
className={`platform-pill ${BADGE_TONE_CLASS[metric.tone ?? 'neutral']} px-3 py-1 text-[10px]`}
>
{metric.label}
</span>
))}
</div>
</div>
<div className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5">
{onExperience ? (

View File

@@ -1,6 +1,9 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
export type CreationWorkShelfKind = 'rpg' | 'big-fish' | 'puzzle';
export type CreationWorkShelfStatus = 'draft' | 'published';
@@ -44,6 +47,7 @@ export type CreationWorkShelfItem = {
coverImageSrc: string | null;
coverRenderMode: 'image' | 'scene_with_roles';
coverCharacterImageSrcs: string[];
publicWorkCode: string | null;
typeLabel: string;
openActionLabel: string;
canExperience: boolean;
@@ -55,6 +59,7 @@ export type CreationWorkShelfItem = {
export function buildCreationWorkShelfItems(params: {
rpgItems: CustomWorldWorkSummary[];
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
bigFishItems: BigFishWorkSummary[];
puzzleItems: PuzzleWorkSummary[];
canDeleteRpg?: boolean;
@@ -63,6 +68,7 @@ export function buildCreationWorkShelfItems(params: {
}) {
const {
rpgItems,
rpgLibraryEntries = [],
bigFishItems,
puzzleItems,
canDeleteRpg = false,
@@ -71,11 +77,15 @@ export function buildCreationWorkShelfItems(params: {
} = params;
return [
...rpgItems.map((item) => mapRpgWorkToShelfItem(item, canDeleteRpg)),
...rpgItems.map((item) =>
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries),
),
...bigFishItems.map((item) =>
mapBigFishWorkToShelfItem(item, canDeleteBigFish),
),
...puzzleItems.map((item) => mapPuzzleWorkToShelfItem(item, canDeletePuzzle)),
...puzzleItems.map((item) =>
mapPuzzleWorkToShelfItem(item, canDeletePuzzle),
),
].sort(
(left, right) =>
getShelfItemTime(right.updatedAt) - getShelfItemTime(left.updatedAt),
@@ -85,8 +95,12 @@ export function buildCreationWorkShelfItems(params: {
function mapRpgWorkToShelfItem(
item: CustomWorldWorkSummary,
canDelete: boolean,
libraryEntries: CustomWorldLibraryEntry<CustomWorldProfile>[],
): CreationWorkShelfItem {
const isDraft = item.status === 'draft';
const libraryEntry = item.profileId
? libraryEntries.find((entry) => entry.profileId === item.profileId)
: null;
const badges: CreationWorkShelfBadge[] = [
buildStatusBadge(item.status),
{ id: 'type', label: 'RPG', tone: 'neutral' },
@@ -134,6 +148,10 @@ function mapRpgWorkToShelfItem(
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: item.coverRenderMode ?? 'image',
coverCharacterImageSrcs: item.coverCharacterImageSrcs ?? [],
publicWorkCode:
item.status === 'published'
? (libraryEntry?.publicWorkCode ?? null)
: null,
typeLabel: 'RPG',
openActionLabel: isDraft
? item.playableNpcCount > 0 || item.landmarkCount > 0
@@ -163,6 +181,7 @@ function mapBigFishWorkToShelfItem(
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode: null,
typeLabel: '大鱼',
openActionLabel: item.status === 'draft' ? '继续创作' : '查看详情',
canExperience: item.status === 'published',
@@ -212,8 +231,11 @@ function mapPuzzleWorkToShelfItem(
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode:
status === 'published' ? buildPuzzlePublicWorkCode(item.profileId) : null,
typeLabel: '拼图',
openActionLabel: status === 'draft' ? '继续创作' : '查看详情',
openActionLabel:
status === 'published' && !item.sourceSessionId ? '查看详情' : '继续创作',
canExperience: status === 'published',
canDelete,
badges: [
@@ -233,7 +255,9 @@ function mapPuzzleWorkToShelfItem(
};
}
function buildStatusBadge(status: CreationWorkShelfStatus): CreationWorkShelfBadge {
function buildStatusBadge(
status: CreationWorkShelfStatus,
): CreationWorkShelfBadge {
return {
id: 'status',
label: status === 'draft' ? '草稿' : '已发布',

View File

@@ -67,7 +67,10 @@ import {
getPuzzleAgentSession,
streamPuzzleAgentMessage,
} from '../../services/puzzle-agent';
import { getPuzzleGalleryDetail, listPuzzleGallery } from '../../services/puzzle-gallery';
import {
getPuzzleGalleryDetail,
listPuzzleGallery,
} from '../../services/puzzle-gallery';
import { advanceLocalPuzzleNextLevel } from '../../services/puzzle-runtime';
import {
dragLocalPuzzlePiece,
@@ -75,6 +78,7 @@ import {
swapLocalPuzzlePieces,
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works';
import { isSamePuzzlePublicWorkCode } from '../../services/publicWorkCode';
import { deleteRpgCreationAgentSession } from '../../services/rpg-creation';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import { deleteRpgEntryWorldProfile } from '../../services/rpg-entry';
@@ -267,8 +271,7 @@ function buildAgentResultPublishGateView(
const blockers = fallbackBlockers
.filter(
(entry) =>
!isAgentResultStructuralBlockerResolved(profile, entry.code),
(entry) => !isAgentResultStructuralBlockerResolved(profile, entry.code),
)
.map((entry) => entry.message);
@@ -367,7 +370,9 @@ export function PlatformEntryFlowShellImpl({
const [isPuzzleNextLevelGenerating, setIsPuzzleNextLevelGenerating] =
useState(false);
const [isSearchingPublicCode, setIsSearchingPublicCode] = useState(false);
const [publicSearchError, setPublicSearchError] = useState<string | null>(null);
const [publicSearchError, setPublicSearchError] = useState<string | null>(
null,
);
const [searchedPublicUser, setSearchedPublicUser] =
useState<PublicUserSummary | null>(null);
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
@@ -491,9 +496,11 @@ export function PlatformEntryFlowShellImpl({
agentSession: sessionController.agentSession,
handleCustomWorldSelect,
executePublishWorld: async () => {
const latestSession = await autosaveCoordinator.executeAgentActionAndWait({
action: 'publish_world',
});
const latestSession = await autosaveCoordinator.executeAgentActionAndWait(
{
action: 'publish_world',
},
);
// 发布动作会在后端同步 gallery 投影;前端发布完成后立即刷新首页/分类页共用的公开作品列表。
await Promise.allSettled([
platformBootstrap.refreshPublishedGallery(),
@@ -551,18 +558,15 @@ export function PlatformEntryFlowShellImpl({
return '服务端预览';
}, [agentResultPreview]);
const featuredGalleryEntries = useMemo(
() => {
const puzzlePublicEntries = puzzleGalleryEntries.map(
mapPuzzleWorkToPlatformGalleryCard,
);
return mergePlatformPublicGalleryEntries(
platformBootstrap.publishedGalleryEntries,
puzzlePublicEntries,
).slice(0, 6);
},
[platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries],
);
const featuredGalleryEntries = useMemo(() => {
const puzzlePublicEntries = puzzleGalleryEntries.map(
mapPuzzleWorkToPlatformGalleryCard,
);
return mergePlatformPublicGalleryEntries(
platformBootstrap.publishedGalleryEntries,
puzzlePublicEntries,
).slice(0, 6);
}, [platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries]);
const latestGalleryEntries = useMemo(
() =>
mergePlatformPublicGalleryEntries(
@@ -608,86 +612,6 @@ export function PlatformEntryFlowShellImpl({
[authUi],
);
const handlePublicCodeSearch = useCallback(
async (keyword: string) => {
const normalizedKeyword = keyword.trim();
if (!normalizedKeyword) {
return;
}
setIsSearchingPublicCode(true);
setPublicSearchError(null);
setSearchedPublicUser(null);
const upperKeyword = normalizedKeyword.toUpperCase();
const shouldSearchUserIdFirst = /^user[_-][a-z0-9_-]+$/iu.test(normalizedKeyword);
const shouldSearchWorkFirst =
!shouldSearchUserIdFirst &&
(upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword));
const shouldSearchUserFirst =
shouldSearchUserIdFirst || upperKeyword.startsWith('SY') || !shouldSearchWorkFirst;
const tryOpenGalleryEntry = async () => {
const entry = await getRpgEntryWorldGalleryDetailByCode(normalizedKeyword);
await detailNavigation.openGalleryDetail({
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
publicWorkCode: entry.publicWorkCode,
authorPublicUserCode: entry.authorPublicUserCode,
visibility: 'published',
publishedAt: entry.publishedAt,
updatedAt: entry.updatedAt,
authorDisplayName: entry.authorDisplayName,
worldName: entry.worldName,
subtitle: entry.subtitle,
summaryText: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
themeMode: entry.themeMode,
playableNpcCount: entry.playableNpcCount,
landmarkCount: entry.landmarkCount,
} satisfies CustomWorldGalleryCard);
};
try {
if (shouldSearchUserIdFirst) {
const user = await getPublicAuthUserById(normalizedKeyword);
setSearchedPublicUser(user);
return;
}
if (shouldSearchWorkFirst) {
try {
await tryOpenGalleryEntry();
return;
} catch {}
}
if (shouldSearchUserFirst) {
try {
const user = await getPublicAuthUserByCode(normalizedKeyword);
setSearchedPublicUser(user);
return;
} catch {}
}
if (!shouldSearchWorkFirst) {
await tryOpenGalleryEntry();
return;
}
const user = await getPublicAuthUserByCode(normalizedKeyword);
setSearchedPublicUser(user);
} catch (error) {
setPublicSearchError(
resolveRpgCreationErrorMessage(error, '未找到对应的叙世号或作品号。'),
);
} finally {
setIsSearchingPublicCode(false);
}
},
[detailNavigation],
);
const prepareCreationLaunch = useCallback(() => {
if (sessionController.isCreatingAgentSession) {
return false;
@@ -748,9 +672,7 @@ export function PlatformEntryFlowShellImpl({
return galleryResponse.items;
} catch (error) {
setPuzzleGalleryEntries([]);
setPuzzleError(
resolvePuzzleErrorMessage(error, '读取拼图广场失败。'),
);
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图广场失败。'));
return [];
}
}, [resolvePuzzleErrorMessage]);
@@ -835,7 +757,10 @@ export function PlatformEntryFlowShellImpl({
{ session: PuzzleAgentSessionSnapshot },
SendPuzzleAgentMessageRequest,
PuzzleAgentActionRequest,
{ operation: PuzzleAgentOperationRecord; session: PuzzleAgentSessionSnapshot }
{
operation: PuzzleAgentOperationRecord;
session: PuzzleAgentSessionSnapshot;
}
>({
client: {
createSession: createPuzzleAgentSession,
@@ -1165,11 +1090,7 @@ export function PlatformEntryFlowShellImpl({
);
const dragPuzzlePiece = useCallback(
(payload: {
pieceId: string;
targetRow: number;
targetCol: number;
}) => {
(payload: { pieceId: string; targetRow: number; targetCol: number }) => {
if (!puzzleRun || isPuzzleBusy) {
return;
}
@@ -1198,7 +1119,9 @@ export function PlatformEntryFlowShellImpl({
const { run } = await advanceLocalPuzzleNextLevel({
run: puzzleRun,
sourceSessionId:
selectedPuzzleDetail?.sourceSessionId ?? puzzleSession?.sessionId ?? null,
selectedPuzzleDetail?.sourceSessionId ??
puzzleSession?.sessionId ??
null,
});
setPuzzleRun(run);
} catch (error) {
@@ -1293,7 +1216,9 @@ export function PlatformEntryFlowShellImpl({
(entry) => entry.profileId === work.profileId,
);
if (!matchedEntry) {
platformBootstrap.setPlatformError('未找到可体验的作品,请刷新后重试。');
platformBootstrap.setPlatformError(
'未找到可体验的作品,请刷新后重试。',
);
return;
}
@@ -1362,10 +1287,14 @@ export function PlatformEntryFlowShellImpl({
const deleteTask =
work.sourceType === 'published_profile' && work.profileId
? deleteRpgEntryWorldProfile(work.profileId).then(async (entries) => {
platformBootstrap.setSavedCustomWorldEntries(entries);
await platformBootstrap.refreshCustomWorldWorks().catch(() => []);
})
? deleteRpgEntryWorldProfile(work.profileId).then(
async (entries) => {
platformBootstrap.setSavedCustomWorldEntries(entries);
await platformBootstrap
.refreshCustomWorldWorks()
.catch(() => []);
},
)
: work.sourceType === 'agent_session' && work.sessionId
? deleteRpgCreationAgentSession(work.sessionId).then((items) => {
platformBootstrap.setCustomWorldWorkEntries(items);
@@ -1446,7 +1375,9 @@ export function PlatformEntryFlowShellImpl({
void refreshPuzzleGallery();
})
.catch((error) => {
setPuzzleError(resolvePuzzleErrorMessage(error, '删除拼图作品失败。'));
setPuzzleError(
resolvePuzzleErrorMessage(error, '删除拼图作品失败。'),
);
})
.finally(() => {
setDeletingCreationWorkId(null);
@@ -1485,12 +1416,136 @@ export function PlatformEntryFlowShellImpl({
setPuzzleOperation(null);
setPuzzleRun(null);
setSelectedPuzzleDetail(null);
const restoredSession = await puzzleFlow.restoreDraft(item.sourceSessionId);
if (!item.sourceSessionId?.trim()) {
if (item.publicationStatus === 'published') {
await openPuzzleDetail(item.profileId);
return;
}
setPuzzleError('这份拼图草稿缺少会话信息,请重新开始创作。');
return;
}
const restoredSession = await puzzleFlow.restoreDraft(
item.sourceSessionId,
);
if (!restoredSession) {
await refreshPuzzleShelf().catch(() => undefined);
}
},
[puzzleFlow, refreshPuzzleShelf],
[openPuzzleDetail, puzzleFlow, refreshPuzzleShelf, setPuzzleError],
);
const handlePublicCodeSearch = useCallback(
async (keyword: string) => {
const normalizedKeyword = keyword.trim();
if (!normalizedKeyword) {
return;
}
setIsSearchingPublicCode(true);
setPublicSearchError(null);
setSearchedPublicUser(null);
const upperKeyword = normalizedKeyword.toUpperCase();
const shouldSearchUserIdFirst = /^user[_-][a-z0-9_-]+$/iu.test(
normalizedKeyword,
);
const shouldSearchPuzzleFirst = upperKeyword.startsWith('PZ');
const shouldSearchWorkFirst =
!shouldSearchUserIdFirst &&
!shouldSearchPuzzleFirst &&
(upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword));
const shouldSearchUserFirst =
shouldSearchUserIdFirst ||
upperKeyword.startsWith('SY') ||
(!shouldSearchWorkFirst && !shouldSearchPuzzleFirst);
const tryOpenGalleryEntry = async () => {
const entry =
await getRpgEntryWorldGalleryDetailByCode(normalizedKeyword);
await detailNavigation.openGalleryDetail({
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
publicWorkCode: entry.publicWorkCode,
authorPublicUserCode: entry.authorPublicUserCode,
visibility: 'published',
publishedAt: entry.publishedAt,
updatedAt: entry.updatedAt,
authorDisplayName: entry.authorDisplayName,
worldName: entry.worldName,
subtitle: entry.subtitle,
summaryText: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
themeMode: entry.themeMode,
playableNpcCount: entry.playableNpcCount,
landmarkCount: entry.landmarkCount,
} satisfies CustomWorldGalleryCard);
};
const tryOpenPuzzleGalleryEntry = async () => {
const entries =
puzzleGalleryEntries.length > 0
? puzzleGalleryEntries
: await refreshPuzzleGallery();
const matchedEntry = entries.find((entry) =>
isSamePuzzlePublicWorkCode(normalizedKeyword, entry.profileId),
);
if (!matchedEntry) {
throw new Error('未找到拼图作品。');
}
await openPuzzleDetail(matchedEntry.profileId);
};
try {
if (shouldSearchUserIdFirst) {
const user = await getPublicAuthUserById(normalizedKeyword);
setSearchedPublicUser(user);
return;
}
if (shouldSearchPuzzleFirst) {
await tryOpenPuzzleGalleryEntry();
return;
}
if (shouldSearchWorkFirst) {
try {
await tryOpenGalleryEntry();
return;
} catch {}
}
if (shouldSearchUserFirst) {
try {
const user = await getPublicAuthUserByCode(normalizedKeyword);
setSearchedPublicUser(user);
return;
} catch {}
}
if (!shouldSearchWorkFirst) {
await tryOpenGalleryEntry();
return;
}
const user = await getPublicAuthUserByCode(normalizedKeyword);
setSearchedPublicUser(user);
} catch (error) {
setPublicSearchError(
resolveRpgCreationErrorMessage(error, '未找到对应的叙世号或作品号。'),
);
} finally {
setIsSearchingPublicCode(false);
}
},
[
detailNavigation,
openPuzzleDetail,
puzzleGalleryEntries,
refreshPuzzleGallery,
],
);
const openBigFishDraft = useCallback(
@@ -1632,6 +1687,7 @@ export function PlatformEntryFlowShellImpl({
onExperienceRpg={(item) => {
handleExperienceRpgWork(item);
}}
rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries}
bigFishItems={bigFishWorks}
onOpenBigFishDetail={(item) => {
runProtectedAction(() => {
@@ -1649,11 +1705,7 @@ export function PlatformEntryFlowShellImpl({
puzzleItems={puzzleWorks}
onOpenPuzzleDetail={(item) => {
runProtectedAction(() => {
if (item.publicationStatus === 'draft') {
void openPuzzleDraft(item);
return;
}
void openPuzzleDetail(item.profileId);
void openPuzzleDraft(item);
});
}}
onExperiencePuzzle={(profileId) => {
@@ -1894,13 +1946,17 @@ export function PlatformEntryFlowShellImpl({
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载大鱼吃小鱼生成面板..." />}
fallback={
<LazyPanelFallback label="正在加载大鱼吃小鱼生成面板..." />
}
>
<CustomWorldGenerationView
settingText={
bigFishSession?.lastAssistantReply ?? '正在整理当前玩法草稿。'
}
anchorEntries={buildBigFishGenerationAnchorEntries(bigFishSession)}
anchorEntries={buildBigFishGenerationAnchorEntries(
bigFishSession,
)}
progress={buildMiniGameDraftGenerationProgress(
bigFishGenerationState,
)}
@@ -1911,7 +1967,9 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('big-fish-agent-workspace');
}}
onRetry={() => {
void executeBigFishAction({ action: 'big_fish_compile_draft' });
void executeBigFishAction({
action: 'big_fish_compile_draft',
});
}}
onInterrupt={undefined}
backLabel="返回创作中心"
@@ -2026,7 +2084,9 @@ export function PlatformEntryFlowShellImpl({
settingText={
puzzleSession?.lastAssistantReply ?? '正在整理当前拼图草稿。'
}
anchorEntries={buildPuzzleGenerationAnchorEntries(puzzleSession)}
anchorEntries={buildPuzzleGenerationAnchorEntries(
puzzleSession,
)}
progress={buildMiniGameDraftGenerationProgress(
puzzleGenerationState,
)}
@@ -2093,6 +2153,16 @@ export function PlatformEntryFlowShellImpl({
enterCreateTab();
setSelectionStage('platform');
}}
onEdit={
selectedPuzzleDetail.ownerUserId === authUi?.user?.id &&
Boolean(selectedPuzzleDetail.sourceSessionId?.trim())
? () => {
runProtectedAction(() => {
void openPuzzleDraft(selectedPuzzleDetail);
});
}
: null
}
onStartGame={() => {
void startPuzzleRunFromProfile(selectedPuzzleDetail.profileId);
}}
@@ -2271,15 +2341,17 @@ export function PlatformEntryFlowShellImpl({
? 'generate_landmarks'
: 'generate_characters';
const latestSession =
await autosaveCoordinator.executeAgentActionAndWait({
action,
count: 1,
...(kind === 'playable'
? { roleType: 'playable' as const }
: kind === 'story'
? { roleType: 'story' as const }
: {}),
});
await autosaveCoordinator.executeAgentActionAndWait(
{
action,
count: 1,
...(kind === 'playable'
? { roleType: 'playable' as const }
: kind === 'story'
? { roleType: 'story' as const }
: {}),
},
);
const latestProfile = latestSession
? rpgCreationPreviewAdapter.buildPreviewFromSession(
latestSession,

View File

@@ -0,0 +1,66 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { PuzzleGalleryDetailView } from './PuzzleGalleryDetailView';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: () => null,
}));
const originalClipboard = navigator.clipboard;
const detailItem = {
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
ownerUserId: 'user-2',
sourceSessionId: 'puzzle-session-1',
authorDisplayName: '拼图玩家',
levelName: '奇幻拼图',
summary: '一张用于公开分享的拼图作品。',
themeTags: ['奇幻'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T10:00:00.000Z',
publishedAt: '2026-04-25T10:00:00.000Z',
playCount: 7,
publishReady: true,
} satisfies PuzzleWorkSummary;
afterEach(() => {
vi.clearAllMocks();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: originalClipboard,
});
});
test('shows and copies puzzle public work code in detail view', async () => {
const user = userEvent.setup();
const writeText = vi.fn(async () => undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
render(
<PuzzleGalleryDetailView
item={detailItem}
onBack={vi.fn()}
onStartGame={vi.fn()}
/>,
);
expect(screen.getByText('作品号')).toBeTruthy();
expect(screen.getByText('PZ-EPUBLIC1')).toBeTruthy();
await user.click(
screen.getByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
);
expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1');
});

View File

@@ -1,6 +1,7 @@
import { ArrowLeft, Play, UserRound } from 'lucide-react';
import { ArrowLeft, Copy, Pencil, Play, UserRound } from 'lucide-react';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleGalleryDetailViewProps = {
@@ -8,9 +9,18 @@ type PuzzleGalleryDetailViewProps = {
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onEdit?: (() => void) | null;
onStartGame: () => void;
};
function copyText(value: string) {
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
return;
}
void navigator.clipboard.writeText(value);
}
/**
* 拼图广场详情页。
* 展示最小信息并提供进入游戏动作,不扩展评论、收藏等非本轮需求。
@@ -20,8 +30,11 @@ export function PuzzleGalleryDetailView({
isBusy = false,
error = null,
onBack,
onEdit = null,
onStartGame,
}: PuzzleGalleryDetailViewProps) {
const publicWorkCode = buildPuzzlePublicWorkCode(item.profileId);
return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
<div className="relative overflow-hidden rounded-[1.8rem] border border-amber-100/16 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_32%),linear-gradient(135deg,rgba(76,29,19,0.98),rgba(15,23,42,0.98))] px-4 py-4 text-white sm:px-5">
@@ -33,15 +46,28 @@ export function PuzzleGalleryDetailView({
>
<ArrowLeft className="h-4 w-4" />
</button>
<button
type="button"
disabled={isBusy}
onClick={onStartGame}
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
>
<Play className="h-4 w-4" />
1
</button>
<div className="flex flex-wrap justify-end gap-2">
{onEdit ? (
<button
type="button"
disabled={isBusy}
onClick={onEdit}
className="inline-flex items-center gap-2 rounded-full bg-white/12 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
>
<Pencil className="h-4 w-4" />
</button>
) : null}
<button
type="button"
disabled={isBusy}
onClick={onStartGame}
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
>
<Play className="h-4 w-4" />
1
</button>
</div>
</div>
<div className="mt-6">
@@ -54,6 +80,17 @@ export function PuzzleGalleryDetailView({
{item.authorDisplayName}
</span>
<span>{item.playCount} </span>
<button
type="button"
onClick={() => copyText(publicWorkCode)}
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-white/14 bg-white/10 px-3 py-1 text-sm text-amber-50/86"
aria-label={`复制作品号 ${publicWorkCode}`}
title="复制作品号"
>
<span className="shrink-0"></span>
<span className="min-w-0 truncate">{publicWorkCode}</span>
<Copy className="h-3.5 w-3.5 shrink-0" />
</button>
</div>
</div>
</div>

View File

@@ -47,6 +47,7 @@ import {
listPuzzleGallery,
} from '../../services/puzzle-gallery';
import { listPuzzleWorks } from '../../services/puzzle-works';
import { getRpgEntryWorldGalleryDetailByCode } from '../../services/rpg-entry/rpgEntryLibraryClient';
import type { GameState } from '../../types';
import {
AuthUiContext,
@@ -128,6 +129,10 @@ vi.mock('../../services/puzzle-gallery', () => ({
listPuzzleGallery: vi.fn(),
}));
vi.mock('../../services/rpg-entry/rpgEntryLibraryClient', () => ({
getRpgEntryWorldGalleryDetailByCode: vi.fn(),
}));
vi.mock('../../services/big-fish-creation', () => ({
createBigFishCreationSession: vi.fn(),
executeBigFishCreationAction: vi.fn(),
@@ -575,6 +580,9 @@ beforeEach(() => {
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
vi.mocked(getRpgEntryWorldGalleryDetailByCode).mockRejectedValue(
new Error('未找到公开作品'),
);
vi.mocked(upsertRpgWorldProfile).mockResolvedValue({
entry: {
ownerUserId: 'user-1',
@@ -1047,9 +1055,7 @@ test('opening RPG agent workspace does not refetch session snapshot in a render
expect(getRpgCreationSession).toHaveBeenCalledTimes(1);
});
test(
'create tab opens compiled agent draft in result refinement page',
async () => {
test('create tab opens compiled agent draft in result refinement page', async () => {
const user = userEvent.setup();
vi.mocked(listRpgCreationWorks).mockResolvedValue([
@@ -1102,9 +1108,7 @@ test(
screen.queryByText('Agent工作区custom-world-agent-session-1'),
).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
},
10000,
);
}, 10000);
test('create tab resumes agent workspace when draft has no compiled result yet', async () => {
const user = userEvent.setup();
@@ -1432,7 +1436,9 @@ test('published puzzle works appear on home and category public shelves', async
await user.click(screen.getByRole('button', { name: '分类' }));
const categoryPanel = getPlatformTabPanel('category');
expect(within(categoryPanel).getAllByText('星桥机关').length).toBeGreaterThan(0);
expect(within(categoryPanel).getAllByText('星桥机关').length).toBeGreaterThan(
0,
);
expect(
within(categoryPanel).getAllByRole('button', { name: //u }).length,
).toBeGreaterThan(0);
@@ -1497,8 +1503,9 @@ test('restoring an agent workspace ignores a stored session owned by another use
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(window.sessionStorage.getItem('genarrative.custom-world-agent-ui.v1'))
.toBeNull();
expect(
window.sessionStorage.getItem('genarrative.custom-world-agent-ui.v1'),
).toBeNull();
});
expect(getRpgCreationSession).not.toHaveBeenCalled();
@@ -1633,6 +1640,93 @@ test('puzzle draft card restores the bound agent session and opens the result vi
expect(screen.queryByText('拼图玩法共创')).toBeNull();
});
test('published puzzle work card restores its source session for editing', async () => {
const user = userEvent.setup();
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [
{
workId: 'puzzle-work-session-1',
profileId: 'puzzle-profile-session-1',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-1',
authorDisplayName: '测试玩家',
levelName: '雨夜猫塔',
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
themeTags: ['雨夜', '猫咪', '遗迹'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T12:10:00.000Z',
publishedAt: '2026-04-25T12:10:00.000Z',
playCount: 8,
publishReady: true,
},
],
});
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
await waitFor(() => {
expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1');
});
expect(getPuzzleGalleryDetail).not.toHaveBeenCalledWith(
'puzzle-profile-session-1',
);
expect(await screen.findByText('拼图结果页')).toBeTruthy();
expect(screen.getByDisplayValue('雨夜猫塔')).toBeTruthy();
});
test('public code search opens a published puzzle by PZ code', async () => {
const user = userEvent.setup();
const puzzleWork: PuzzleWorkSummary = {
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
ownerUserId: 'user-2',
sourceSessionId: null,
authorDisplayName: '拼图作者',
levelName: '雨夜猫塔',
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
themeTags: ['雨夜', '猫咪', '遗迹'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T12:10:00.000Z',
publishedAt: '2026-04-25T12:10:00.000Z',
playCount: 8,
publishReady: true,
};
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [puzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: puzzleWork,
});
render(<TestWrapper withAuth />);
const searchInput =
await screen.findByPlaceholderText('输入 SY / CW / PZ 编号');
await user.type(searchInput, 'PZ-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
await waitFor(() => {
expect(getPuzzleGalleryDetail).toHaveBeenCalledWith(
'puzzle-profile-public-1',
);
});
expect(await screen.findByText('进入第 1 关')).toBeTruthy();
expect(screen.getByText('雨夜猫塔')).toBeTruthy();
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
});
test('big fish draft card restores the bound agent session and opens the result view', async () => {
const user = userEvent.setup();
@@ -1679,11 +1773,11 @@ test('big fish draft card restores the bound agent session and opens the result
await user.click(screen.getByRole('button', { name: '返回' }));
expect(await screen.findByText('大鱼吃小鱼工作区big-fish-session-1')).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
expect(
screen.getByText('我想做机械深海里微生物互相吞并进化。'),
await screen.findByText('大鱼吃小鱼工作区big-fish-session-1'),
).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
expect(screen.getByText('我想做机械深海里微生物互相吞并进化。')).toBeTruthy();
});
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
@@ -1882,7 +1976,9 @@ test('agent result view shows publish blocker dialog before publish action when
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'publish_world',
).length;
expect(publishWorldCallCountAfterClick).toBe(publishWorldCallCountBeforeClick);
expect(publishWorldCallCountAfterClick).toBe(
publishWorldCallCountBeforeClick,
);
});
test('agent draft result publishes to gallery from publish panel', async () => {
@@ -1918,6 +2014,9 @@ test('agent draft result publishes to gallery from publish panel', async () => {
} satisfies CustomWorldAgentSessionSnapshot;
let hasPublishedWorld = false;
vi.mocked(createRpgCreationSession).mockResolvedValue({
session: publishReadyDraftSession,
});
vi.mocked(getRpgCreationOperation).mockResolvedValueOnce({
operationId: 'operation-publish-world-1',
type: 'publish_world',
@@ -1972,9 +2071,13 @@ test('agent draft result publishes to gallery from publish panel', async () => {
await openNewRpgCreation(user);
const actionButton = await screen.findByRole('button', {
name: '发布',
});
const actionButton = await screen.findByRole(
'button',
{
name: '发布',
},
{ timeout: 5000 },
);
await user.click(actionButton);
await user.click(await screen.findByRole('button', { name: '发布到广场' }));
@@ -2569,8 +2672,8 @@ test('agent draft result can open from server result preview without embedded le
expect(await screen.findByText('世界档案')).toBeTruthy();
expect(screen.getByText('潮雾列岛·服务端预览')).toBeTruthy();
expect(
screen.getByText('结果页改为优先消费 session.resultPreview'),
).toBeTruthy();
screen.getAllByText('结果页改为优先消费 session.resultPreview').length,
).toBeGreaterThan(0);
},
{ timeout: 2500 },
);
@@ -2900,9 +3003,7 @@ test('creation hub published work experience button enters world directly', asyn
},
]);
render(
<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />,
);
render(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
await openCreationHub(user);
await user.click(await screen.findByRole('button', { name: '体验' }));

View File

@@ -5,7 +5,8 @@ import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import { AuthUiContext } from '../auth/AuthUiContext';
import { RpgEntryHomeView } from './RpgEntryHomeView';
import { RpgEntryHomeView, type RpgEntryHomeViewProps } from './RpgEntryHomeView';
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
getRpgProfileRechargeCenter: vi.fn(async () => ({
@@ -93,6 +94,43 @@ vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: () => null,
}));
const originalMatchMedia = window.matchMedia;
const originalClipboard = navigator.clipboard;
const puzzlePublicEntry = {
sourceType: 'puzzle',
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
publicWorkCode: 'PZ-EPUBLIC1',
ownerUserId: 'user-2',
authorDisplayName: '拼图玩家',
worldName: '奇幻拼图',
subtitle: '拼图关卡',
summaryText: '一张用于公开分享的拼图作品。',
coverImageSrc: null,
themeTags: ['奇幻'],
visibility: 'published',
publishedAt: '1777110165.990127Z',
updatedAt: '2026-04-25T10:00:00.000Z',
} satisfies PlatformPublicGalleryCard;
function mockDesktopLayout() {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: vi.fn().mockImplementation(() => ({
matches: true,
media: '(min-width: 1024px)',
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
function renderProfileView(onRechargeSuccess = vi.fn()) {
return render(
<AuthUiContext.Provider
@@ -157,7 +195,18 @@ function renderProfileView(onRechargeSuccess = vi.fn()) {
);
}
function renderLoggedOutHomeView(openLoginModal = vi.fn()) {
function renderLoggedOutHomeView(
openLoginModal = vi.fn(),
overrides: Partial<
Pick<
RpgEntryHomeViewProps,
| 'featuredEntries'
| 'latestEntries'
| 'onOpenGalleryDetail'
| 'onSearchPublicCode'
>
> = {},
) {
return render(
<AuthUiContext.Provider
value={{
@@ -176,6 +225,96 @@ function renderLoggedOutHomeView(openLoginModal = vi.fn()) {
isPersistingSettings: false,
settingsError: null,
}}
>
<RpgEntryHomeView
activeTab="home"
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={overrides.featuredEntries ?? []}
latestEntries={overrides.latestEntries ?? []}
myEntries={[]}
historyEntries={[]}
profileDashboard={null}
isLoadingPlatform={false}
isLoadingDashboard={false}
isResumingSaveWorldKey={null}
platformError={null}
dashboardError={null}
onContinueGame={vi.fn()}
onResumeSave={vi.fn()}
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
/>
</AuthUiContext.Provider>,
);
}
afterEach(() => {
vi.clearAllMocks();
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: originalMatchMedia,
});
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: originalClipboard,
});
});
test('opens recharge modal and submits points product', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
renderProfileView(onRechargeSuccess);
await user.click(screen.getByText('会员充值'));
expect(await screen.findByText('账户充值')).toBeTruthy();
expect(await screen.findByText('10积分')).toBeTruthy();
await user.click(screen.getByText('首充送19积分'));
await waitFor(() => expect(onRechargeSuccess).toHaveBeenCalledTimes(1));
});
test('shows a reachable login entry in logged out mobile shell', async () => {
const user = userEvent.setup();
const openLoginModal = vi.fn();
renderLoggedOutHomeView(openLoginModal);
await user.click(screen.getByRole('button', { name: '登录' }));
expect(openLoginModal).toHaveBeenCalledTimes(1);
});
test('mobile home search submits public work code', async () => {
const user = userEvent.setup();
const onSearchPublicCode = vi.fn();
render(
<AuthUiContext.Provider
value={{
user: null,
canAccessProtectedData: false,
openLoginModal: vi.fn(),
requireAuth: vi.fn(),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
logout: vi.fn(async () => undefined),
musicVolume: 0.42,
setMusicVolume: vi.fn(),
platformTheme: 'light',
setPlatformTheme: vi.fn(),
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
}}
>
<RpgEntryHomeView
activeTab="home"
@@ -200,37 +339,46 @@ function renderLoggedOutHomeView(openLoginModal = vi.fn()) {
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={vi.fn()}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={vi.fn()}
onSearchPublicCode={onSearchPublicCode}
/>
</AuthUiContext.Provider>,
);
}
afterEach(() => {
vi.clearAllMocks();
const searchInput = screen.getByPlaceholderText('输入 SY / CW / PZ 编号');
await user.type(searchInput, 'PZ-PROFILE1{enter}');
expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1');
});
test('opens recharge modal and submits points product', async () => {
test('public work code badge copies without opening gallery detail', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
const writeText = vi.fn(async () => undefined);
const onOpenGalleryDetail = vi.fn();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
renderProfileView(onRechargeSuccess);
await user.click(screen.getByText('会员充值'));
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
onOpenGalleryDetail,
});
expect(await screen.findByText('账户充值')).toBeTruthy();
expect(await screen.findByText('10积分')).toBeTruthy();
await user.click(
screen.getByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
);
await user.click(screen.getByText('首充送19积分'));
await waitFor(() => expect(onRechargeSuccess).toHaveBeenCalledTimes(1));
expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1');
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
});
test('shows a reachable login entry in logged out mobile shell', async () => {
const user = userEvent.setup();
const openLoginModal = vi.fn();
test('desktop trending list shows public code instead of timestamp text', () => {
mockDesktopLayout();
renderLoggedOutHomeView(openLoginModal);
await user.click(screen.getByRole('button', { name: '登录' }));
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
});
expect(openLoginModal).toHaveBeenCalledTimes(1);
expect(screen.getAllByText('PZ-EPUBLIC1').length).toBeGreaterThan(0);
expect(screen.queryByText('1777110165.990127Z')).toBeNull();
});

View File

@@ -23,6 +23,7 @@ import {
} from 'lucide-react';
import {
type ComponentType,
type KeyboardEvent,
type ReactNode,
useEffect,
useMemo,
@@ -53,6 +54,7 @@ import {
describePlatformThemeLabel,
formatPlatformWorldTime,
isPuzzleGalleryEntry,
resolvePlatformPublicWorkCode,
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
resolvePlatformWorldCoverImage,
@@ -191,6 +193,48 @@ function SectionHeader({ title, detail }: { title: string; detail: string }) {
);
}
function PublicCodeSearchBar({
value,
onChange,
onSubmit,
isSearching,
className,
}: {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
isSearching: boolean;
className?: string;
}) {
return (
<div
className={`platform-desktop-search flex min-w-0 items-center gap-3 px-4 py-3 text-[var(--platform-text-soft)] ${className ?? ''}`}
>
<Search className="h-4 w-4 shrink-0" />
<input
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
onSubmit();
}
}}
placeholder="输入 SY / CW / PZ 编号"
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
/>
<button
type="button"
onClick={onSubmit}
disabled={!value.trim() || isSearching}
className="shrink-0 text-xs font-semibold text-[var(--platform-text-soft)] disabled:opacity-50"
>
{isSearching ? '搜索中' : '搜索'}
</button>
</div>
);
}
function EmptyShelf({ text }: { text: string }) {
return (
<div
@@ -249,6 +293,8 @@ function WorldCard({
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
const badgeLabel = publicWorkCode ?? badge;
const tags = [
...new Set(
buildPlatformWorldTags(entry)
@@ -257,10 +303,25 @@ function WorldCard({
),
].slice(0, 3);
const openCardByKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.target !== event.currentTarget) {
return;
}
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
onClick();
};
return (
<button
type="button"
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={openCardByKeyboard}
className={`platform-surface platform-interactive-card relative flex h-[15rem] w-[min(15.25rem,78vw)] shrink-0 flex-col overflow-hidden px-3.5 py-3.5 text-left ${className ?? ''}`}
>
{coverImage ? (
@@ -281,7 +342,25 @@ function WorldCard({
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="relative z-10 flex h-full flex-col">
<div className="flex items-start justify-between gap-3">
<span className="platform-pill platform-pill--warm">{badge}</span>
{publicWorkCode ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
copyText(publicWorkCode);
}}
className="platform-pill platform-pill--warm flex max-w-[10rem] items-center gap-1.5 px-3 py-1"
aria-label={`复制作品号 ${publicWorkCode}`}
title="复制作品号"
>
<span className="min-w-0 truncate">{publicWorkCode}</span>
<Copy className="h-3 w-3 shrink-0" />
</button>
) : (
<span className="platform-pill platform-pill--warm max-w-[8.5rem] truncate">
{badgeLabel}
</span>
)}
<span className="platform-pill platform-pill--neutral px-2.5">
{metaLabel}
</span>
@@ -316,7 +395,7 @@ function WorldCard({
</div>
</div>
</div>
</button>
</div>
);
}
@@ -572,6 +651,7 @@ function DesktopTrendingItem({
onClick: () => void;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
const tags = buildPlatformWorldTags(entry).filter(Boolean).slice(0, 2);
return (
@@ -594,7 +674,9 @@ function DesktopTrendingItem({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
<span>{`${rank}`.padStart(2, '0')}</span>
<span>{formatPlatformWorldTime(entry.publishedAt)}</span>
<span className="truncate">
{publicWorkCode ?? describePublicGalleryCardKind(entry)}
</span>
</div>
<div className="mt-2 line-clamp-1 text-lg font-semibold text-[var(--platform-text-strong)]">
{entry.worldName}
@@ -1050,6 +1132,7 @@ export function RpgEntryHomeView({
}: RpgEntryHomeViewProps) {
const authUi = useAuthUi();
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
const [isRechargeOpen, setIsRechargeOpen] = useState(false);
const [rechargeTab, setRechargeTab] = useState<'points' | 'membership'>(
'points',
@@ -1171,6 +1254,14 @@ export function RpgEntryHomeView({
void onSearchPublicCode(keyword);
};
const submitMobileSearch = () => {
const keyword = mobileSearchKeyword.trim();
if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
return;
}
void onSearchPublicCode(keyword);
};
const desktopHeroEntry =
featuredShelf[0] ?? latestEntries[0] ?? myEntries[0] ?? null;
const desktopHeroCover = desktopHeroEntry
@@ -1198,6 +1289,13 @@ export function RpgEntryHomeView({
const mobileHomeContent: ReactNode = (
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage`}>
<PublicCodeSearchBar
value={mobileSearchKeyword}
onChange={setMobileSearchKeyword}
onSubmit={submitMobileSearch}
isSearching={isSearchingPublicCode}
/>
<button
type="button"
onClick={openLeadPublicEntry}
@@ -1269,7 +1367,7 @@ export function RpgEntryHomeView({
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:latest`}
entry={entry}
badge={formatPlatformWorldTime(entry.publishedAt)}
badge={describePublicGalleryCardKind(entry)}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
/>
@@ -1865,7 +1963,7 @@ export function RpgEntryHomeView({
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-latest`}
entry={entry}
badge={formatPlatformWorldTime(entry.publishedAt)}
badge={describePublicGalleryCardKind(entry)}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
className="h-[17rem] w-full min-w-0"
@@ -1960,35 +2058,15 @@ export function RpgEntryHomeView({
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
<div className="flex min-w-0 flex-1 items-center gap-5">
<RpgEntryBrandLogo className="shrink-0" decorative />
<div className="platform-desktop-search flex min-w-0 max-w-[34rem] flex-1 items-center gap-3 px-4 py-3 text-[var(--platform-text-soft)]">
<Search className="h-4 w-4 shrink-0" />
<input
value={desktopSearchKeyword}
onChange={(event) =>
setDesktopSearchKeyword(event.target.value)
}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submitDesktopSearch();
}
}}
placeholder="输入 SY 或 CW 编号"
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
/>
<button
type="button"
onClick={submitDesktopSearch}
disabled={
!desktopSearchKeyword.trim() ||
!onSearchPublicCode ||
isSearchingPublicCode
}
className="shrink-0 text-xs font-semibold text-[var(--platform-text-soft)] disabled:opacity-50"
>
{isSearchingPublicCode ? '搜索中' : '搜索'}
</button>
</div>
<PublicCodeSearchBar
value={desktopSearchKeyword}
onChange={setDesktopSearchKeyword}
onSubmit={submitDesktopSearch}
isSearching={
!onSearchPublicCode || Boolean(isSearchingPublicCode)
}
className="max-w-[34rem] flex-1"
/>
</div>
<div className="flex items-center gap-3">

View File

@@ -1,4 +1,4 @@
import { ArrowLeft } from 'lucide-react';
import { ArrowLeft, Copy } from 'lucide-react';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
@@ -8,6 +8,7 @@ import {
buildPlatformWorldTags,
describePlatformThemeLabel,
formatPlatformWorldTime,
resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverImage,
resolvePlatformWorldLeadPortrait,
} from './rpgEntryWorldPresentation';
@@ -24,6 +25,14 @@ export interface RpgEntryWorldDetailViewProps {
onUnpublish?: (() => void) | null;
}
function copyText(value: string) {
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
return;
}
void navigator.clipboard.writeText(value);
}
function ActionButton({
label,
onClick,
@@ -67,6 +76,7 @@ export function RpgEntryWorldDetailView({
}: RpgEntryWorldDetailViewProps) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
const canStartGame = entry.visibility === 'published';
const previewCharacters = buildCustomWorldPlayableCharacters(
entry.profile,
@@ -128,6 +138,16 @@ export function RpgEntryWorldDetailView({
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
: '仅自己可见'}
</span>
{publicWorkCode ? (
<button
type="button"
onClick={() => copyText(publicWorkCode)}
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
>
<span> {publicWorkCode}</span>
<Copy className="h-3 w-3" />
</button>
) : null}
</div>
<div className="mt-4 text-3xl font-black text-white">
{entry.worldName}

View File

@@ -5,6 +5,7 @@ import type {
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
export type PlatformWorldCardLike =
@@ -16,6 +17,7 @@ export type PlatformPuzzleGalleryCard = {
sourceType: 'puzzle';
workId: string;
profileId: string;
publicWorkCode: string;
ownerUserId: string;
authorDisplayName: string;
worldName: string;
@@ -51,6 +53,7 @@ export function mapPuzzleWorkToPlatformGalleryCard(
sourceType: 'puzzle',
workId: work.workId,
profileId: work.profileId,
publicWorkCode: buildPuzzlePublicWorkCode(work.profileId),
ownerUserId: work.ownerUserId,
authorDisplayName: work.authorDisplayName,
worldName: work.levelName,
@@ -122,6 +125,16 @@ export function formatPlatformWorldTime(value: string | null) {
});
}
export function resolvePlatformPublicWorkCode(
entry: PlatformWorldCardLike,
): string | null {
if (isPuzzleGalleryEntry(entry)) {
return entry.publicWorkCode;
}
return entry.publicWorkCode;
}
export function describePlatformThemeLabel(
themeMode: CustomWorldGalleryCard['themeMode'],
) {

View File

@@ -0,0 +1,24 @@
export function normalizePublicCodeText(value: string) {
return value
.trim()
.replace(/[^a-zA-Z0-9]/gu, '')
.toUpperCase();
}
export function buildPuzzlePublicWorkCode(profileId: string) {
const normalized = normalizePublicCodeText(profileId);
const fallback = normalized || '00000000';
const suffix = fallback.slice(-8).padStart(8, '0');
return `PZ-${suffix}`;
}
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
const normalizedKeyword = normalizePublicCodeText(keyword);
return (
normalizedKeyword ===
normalizePublicCodeText(buildPuzzlePublicWorkCode(profileId)) ||
normalizedKeyword === normalizePublicCodeText(profileId)
);
}

View File

@@ -127,14 +127,12 @@ test('buildRpgCreationPreviewFromResultPreview normalizes server preview envelop
expect(profile?.settingText).toBe('被海雾吞没的旧航路群岛');
});
test('buildRpgCreationPreviewFromSession reads draftProfile directly', () => {
test('buildRpgCreationPreviewFromSession prefers server result preview', () => {
const profile = buildRpgCreationPreviewFromSession(sessionWithPreview);
expect(profile?.name).toBe('只作为 fallback 的本地草稿名');
expect(profile?.name).not.toBe('服务端结果预览');
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
'/generated-characters/draft-playable-1/portrait.png',
);
expect(profile?.name).toBe('服务端结果预览');
expect(profile?.summary).toBe('结果页应该优先消费 session.resultPreview。');
expect(profile?.id).toBe('preview-profile-1');
});
test('buildRpgCreationPreviewFromSession does not require resultPreview', () => {

View File

@@ -3,24 +3,26 @@ import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary
import type { CustomWorldProfile } from '../../types';
export function buildCustomWorldProfileFromResultPreview(
resultPreview: CustomWorldAgentSessionSnapshot['resultPreview'] | null | undefined,
resultPreview:
| CustomWorldAgentSessionSnapshot['resultPreview']
| null
| undefined,
): CustomWorldProfile | null {
return normalizeCustomWorldProfileRecord(resultPreview?.preview ?? null);
}
/**
* RPG 运行时直接读取 Agent session 的 draftProfile。
* resultPreview 只作为质量/发布信息外壳,不再参与进入游戏 profile 的数据转换。
*/
export function buildCustomWorldProfileFromAgentSession(
session: CustomWorldAgentSessionSnapshot | null | undefined,
): CustomWorldProfile | null {
return normalizeCustomWorldProfileRecord(session?.draftProfile ?? null);
return (
buildCustomWorldProfileFromResultPreview(session?.resultPreview) ??
normalizeCustomWorldProfileRecord(session?.draftProfile ?? null)
);
}
/**
* 这是工作包 A 提供的新命名兼容层。
* 主入口保持命名稳定,但数据来源已经收敛为 draftProfile 单一真相源
* 主入口保持命名稳定,优先消费服务端 resultPreview缺失时回退到 draftProfile。
*/
export const rpgCreationPreviewAdapter = {
buildPreviewFromSession: buildCustomWorldProfileFromAgentSession,