fix: 完善作品号展示与复制入口
This commit is contained in:
@@ -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` 这类原始时间字符串。
|
||||||
@@ -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('世界总卡和角色网已经继续长出了新的支线。'),
|
||||||
|
).toBeTruthy();
|
||||||
expect(screen.getByText('角色 5')).toBeTruthy();
|
expect(screen.getByText('角色 5')).toBeTruthy();
|
||||||
expect(screen.getByText('地点 6')).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('沉钟拼图')).toBeTruthy();
|
expect(screen.getByText('沉钟拼图')).toBeTruthy();
|
||||||
|
expect(screen.getByText('PZ-PROFILE1')).toBeTruthy();
|
||||||
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
|
||||||
expect(screen.queryByText('我的拼图作品')).toBeNull();
|
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', () => {
|
test('creation hub shows delete action for persisted rpg drafts', () => {
|
||||||
render(
|
render(
|
||||||
<CustomWorldCreationHub
|
<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]);
|
expect(openedItems).toEqual([persistedDraft]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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('拼图');
|
||||||
|
expect(html).toContain('作品号');
|
||||||
|
expect(html).toContain('PZ-PROFILE1');
|
||||||
expect(html).not.toContain('我的拼图作品');
|
expect(html).not.toContain('我的拼图作品');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
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 { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
|
||||||
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
|
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +30,7 @@ type CustomWorldCreationHubProps = {
|
|||||||
onDeletePublished?: ((item: CustomWorldWorkSummary) => void) | null;
|
onDeletePublished?: ((item: CustomWorldWorkSummary) => void) | null;
|
||||||
deletingWorkId?: string | null;
|
deletingWorkId?: string | null;
|
||||||
onExperienceRpg?: ((item: CustomWorldWorkSummary) => void) | null;
|
onExperienceRpg?: ((item: CustomWorldWorkSummary) => void) | null;
|
||||||
|
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||||
bigFishItems?: BigFishWorkSummary[];
|
bigFishItems?: BigFishWorkSummary[];
|
||||||
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
|
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
|
||||||
onExperienceBigFish?: ((item: BigFishWorkSummary) => void) | null;
|
onExperienceBigFish?: ((item: BigFishWorkSummary) => void) | null;
|
||||||
@@ -61,6 +64,7 @@ export function CustomWorldCreationHub({
|
|||||||
onDeletePublished = null,
|
onDeletePublished = null,
|
||||||
deletingWorkId = null,
|
deletingWorkId = null,
|
||||||
onExperienceRpg = null,
|
onExperienceRpg = null,
|
||||||
|
rpgLibraryEntries = [],
|
||||||
bigFishItems = [],
|
bigFishItems = [],
|
||||||
onOpenBigFishDetail,
|
onOpenBigFishDetail,
|
||||||
onExperienceBigFish = null,
|
onExperienceBigFish = null,
|
||||||
@@ -76,6 +80,7 @@ export function CustomWorldCreationHub({
|
|||||||
() =>
|
() =>
|
||||||
buildCreationWorkShelfItems({
|
buildCreationWorkShelfItems({
|
||||||
rpgItems: items,
|
rpgItems: items,
|
||||||
|
rpgLibraryEntries,
|
||||||
bigFishItems,
|
bigFishItems,
|
||||||
puzzleItems,
|
puzzleItems,
|
||||||
canDeleteRpg: Boolean(onDeletePublished),
|
canDeleteRpg: Boolean(onDeletePublished),
|
||||||
@@ -89,9 +94,12 @@ export function CustomWorldCreationHub({
|
|||||||
onDeletePublished,
|
onDeletePublished,
|
||||||
onDeletePuzzle,
|
onDeletePuzzle,
|
||||||
puzzleItems,
|
puzzleItems,
|
||||||
|
rpgLibraryEntries,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const draftCount = shelfItems.filter((entry) => entry.status === 'draft').length;
|
const draftCount = shelfItems.filter(
|
||||||
|
(entry) => entry.status === 'draft',
|
||||||
|
).length;
|
||||||
const publishedCount = shelfItems.filter(
|
const publishedCount = shelfItems.filter(
|
||||||
(entry) => entry.status === 'published',
|
(entry) => entry.status === 'published',
|
||||||
).length;
|
).length;
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
|
import { Copy } from 'lucide-react';
|
||||||
|
|
||||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||||
import type { CreationWorkShelfItem } from './creationWorkShelf';
|
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) {
|
function formatUpdatedAt(value: string) {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
@@ -23,7 +33,10 @@ type CustomWorldWorkCardProps = {
|
|||||||
deleteBusy?: boolean;
|
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',
|
warm: 'platform-pill--warm',
|
||||||
success: 'platform-pill--success',
|
success: 'platform-pill--success',
|
||||||
neutral: 'platform-pill--neutral',
|
neutral: 'platform-pill--neutral',
|
||||||
@@ -127,15 +140,33 @@ export function CustomWorldWorkCard({
|
|||||||
</div>
|
</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="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">
|
<div className="min-w-0 space-y-2">
|
||||||
{item.metrics.map((metric) => (
|
{item.publicWorkCode ? (
|
||||||
<span
|
<button
|
||||||
key={`${item.id}-${metric.id}`}
|
type="button"
|
||||||
className={`platform-pill ${BADGE_TONE_CLASS[metric.tone ?? 'neutral']} px-3 py-1 text-[10px]`}
|
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 className="shrink-0">作品号</span>
|
||||||
</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>
|
||||||
<div className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5">
|
<div className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5">
|
||||||
{onExperience ? (
|
{onExperience ? (
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
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 CreationWorkShelfKind = 'rpg' | 'big-fish' | 'puzzle';
|
||||||
export type CreationWorkShelfStatus = 'draft' | 'published';
|
export type CreationWorkShelfStatus = 'draft' | 'published';
|
||||||
@@ -44,6 +47,7 @@ export type CreationWorkShelfItem = {
|
|||||||
coverImageSrc: string | null;
|
coverImageSrc: string | null;
|
||||||
coverRenderMode: 'image' | 'scene_with_roles';
|
coverRenderMode: 'image' | 'scene_with_roles';
|
||||||
coverCharacterImageSrcs: string[];
|
coverCharacterImageSrcs: string[];
|
||||||
|
publicWorkCode: string | null;
|
||||||
typeLabel: string;
|
typeLabel: string;
|
||||||
openActionLabel: string;
|
openActionLabel: string;
|
||||||
canExperience: boolean;
|
canExperience: boolean;
|
||||||
@@ -55,6 +59,7 @@ export type CreationWorkShelfItem = {
|
|||||||
|
|
||||||
export function buildCreationWorkShelfItems(params: {
|
export function buildCreationWorkShelfItems(params: {
|
||||||
rpgItems: CustomWorldWorkSummary[];
|
rpgItems: CustomWorldWorkSummary[];
|
||||||
|
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||||
bigFishItems: BigFishWorkSummary[];
|
bigFishItems: BigFishWorkSummary[];
|
||||||
puzzleItems: PuzzleWorkSummary[];
|
puzzleItems: PuzzleWorkSummary[];
|
||||||
canDeleteRpg?: boolean;
|
canDeleteRpg?: boolean;
|
||||||
@@ -63,6 +68,7 @@ export function buildCreationWorkShelfItems(params: {
|
|||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
rpgItems,
|
rpgItems,
|
||||||
|
rpgLibraryEntries = [],
|
||||||
bigFishItems,
|
bigFishItems,
|
||||||
puzzleItems,
|
puzzleItems,
|
||||||
canDeleteRpg = false,
|
canDeleteRpg = false,
|
||||||
@@ -71,11 +77,15 @@ export function buildCreationWorkShelfItems(params: {
|
|||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...rpgItems.map((item) => mapRpgWorkToShelfItem(item, canDeleteRpg)),
|
...rpgItems.map((item) =>
|
||||||
|
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries),
|
||||||
|
),
|
||||||
...bigFishItems.map((item) =>
|
...bigFishItems.map((item) =>
|
||||||
mapBigFishWorkToShelfItem(item, canDeleteBigFish),
|
mapBigFishWorkToShelfItem(item, canDeleteBigFish),
|
||||||
),
|
),
|
||||||
...puzzleItems.map((item) => mapPuzzleWorkToShelfItem(item, canDeletePuzzle)),
|
...puzzleItems.map((item) =>
|
||||||
|
mapPuzzleWorkToShelfItem(item, canDeletePuzzle),
|
||||||
|
),
|
||||||
].sort(
|
].sort(
|
||||||
(left, right) =>
|
(left, right) =>
|
||||||
getShelfItemTime(right.updatedAt) - getShelfItemTime(left.updatedAt),
|
getShelfItemTime(right.updatedAt) - getShelfItemTime(left.updatedAt),
|
||||||
@@ -85,8 +95,12 @@ export function buildCreationWorkShelfItems(params: {
|
|||||||
function mapRpgWorkToShelfItem(
|
function mapRpgWorkToShelfItem(
|
||||||
item: CustomWorldWorkSummary,
|
item: CustomWorldWorkSummary,
|
||||||
canDelete: boolean,
|
canDelete: boolean,
|
||||||
|
libraryEntries: CustomWorldLibraryEntry<CustomWorldProfile>[],
|
||||||
): CreationWorkShelfItem {
|
): CreationWorkShelfItem {
|
||||||
const isDraft = item.status === 'draft';
|
const isDraft = item.status === 'draft';
|
||||||
|
const libraryEntry = item.profileId
|
||||||
|
? libraryEntries.find((entry) => entry.profileId === item.profileId)
|
||||||
|
: null;
|
||||||
const badges: CreationWorkShelfBadge[] = [
|
const badges: CreationWorkShelfBadge[] = [
|
||||||
buildStatusBadge(item.status),
|
buildStatusBadge(item.status),
|
||||||
{ id: 'type', label: 'RPG', tone: 'neutral' },
|
{ id: 'type', label: 'RPG', tone: 'neutral' },
|
||||||
@@ -134,6 +148,10 @@ function mapRpgWorkToShelfItem(
|
|||||||
coverImageSrc: item.coverImageSrc ?? null,
|
coverImageSrc: item.coverImageSrc ?? null,
|
||||||
coverRenderMode: item.coverRenderMode ?? 'image',
|
coverRenderMode: item.coverRenderMode ?? 'image',
|
||||||
coverCharacterImageSrcs: item.coverCharacterImageSrcs ?? [],
|
coverCharacterImageSrcs: item.coverCharacterImageSrcs ?? [],
|
||||||
|
publicWorkCode:
|
||||||
|
item.status === 'published'
|
||||||
|
? (libraryEntry?.publicWorkCode ?? null)
|
||||||
|
: null,
|
||||||
typeLabel: 'RPG',
|
typeLabel: 'RPG',
|
||||||
openActionLabel: isDraft
|
openActionLabel: isDraft
|
||||||
? item.playableNpcCount > 0 || item.landmarkCount > 0
|
? item.playableNpcCount > 0 || item.landmarkCount > 0
|
||||||
@@ -163,6 +181,7 @@ function mapBigFishWorkToShelfItem(
|
|||||||
coverImageSrc: item.coverImageSrc ?? null,
|
coverImageSrc: item.coverImageSrc ?? null,
|
||||||
coverRenderMode: 'image',
|
coverRenderMode: 'image',
|
||||||
coverCharacterImageSrcs: [],
|
coverCharacterImageSrcs: [],
|
||||||
|
publicWorkCode: null,
|
||||||
typeLabel: '大鱼',
|
typeLabel: '大鱼',
|
||||||
openActionLabel: item.status === 'draft' ? '继续创作' : '查看详情',
|
openActionLabel: item.status === 'draft' ? '继续创作' : '查看详情',
|
||||||
canExperience: item.status === 'published',
|
canExperience: item.status === 'published',
|
||||||
@@ -212,8 +231,11 @@ function mapPuzzleWorkToShelfItem(
|
|||||||
coverImageSrc: item.coverImageSrc ?? null,
|
coverImageSrc: item.coverImageSrc ?? null,
|
||||||
coverRenderMode: 'image',
|
coverRenderMode: 'image',
|
||||||
coverCharacterImageSrcs: [],
|
coverCharacterImageSrcs: [],
|
||||||
|
publicWorkCode:
|
||||||
|
status === 'published' ? buildPuzzlePublicWorkCode(item.profileId) : null,
|
||||||
typeLabel: '拼图',
|
typeLabel: '拼图',
|
||||||
openActionLabel: status === 'draft' ? '继续创作' : '查看详情',
|
openActionLabel:
|
||||||
|
status === 'published' && !item.sourceSessionId ? '查看详情' : '继续创作',
|
||||||
canExperience: status === 'published',
|
canExperience: status === 'published',
|
||||||
canDelete,
|
canDelete,
|
||||||
badges: [
|
badges: [
|
||||||
@@ -233,7 +255,9 @@ function mapPuzzleWorkToShelfItem(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStatusBadge(status: CreationWorkShelfStatus): CreationWorkShelfBadge {
|
function buildStatusBadge(
|
||||||
|
status: CreationWorkShelfStatus,
|
||||||
|
): CreationWorkShelfBadge {
|
||||||
return {
|
return {
|
||||||
id: 'status',
|
id: 'status',
|
||||||
label: status === 'draft' ? '草稿' : '已发布',
|
label: status === 'draft' ? '草稿' : '已发布',
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ import {
|
|||||||
getPuzzleAgentSession,
|
getPuzzleAgentSession,
|
||||||
streamPuzzleAgentMessage,
|
streamPuzzleAgentMessage,
|
||||||
} from '../../services/puzzle-agent';
|
} 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 { advanceLocalPuzzleNextLevel } from '../../services/puzzle-runtime';
|
||||||
import {
|
import {
|
||||||
dragLocalPuzzlePiece,
|
dragLocalPuzzlePiece,
|
||||||
@@ -75,6 +78,7 @@ import {
|
|||||||
swapLocalPuzzlePieces,
|
swapLocalPuzzlePieces,
|
||||||
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
|
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
|
||||||
import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works';
|
import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works';
|
||||||
|
import { isSamePuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||||
import { deleteRpgCreationAgentSession } from '../../services/rpg-creation';
|
import { deleteRpgCreationAgentSession } from '../../services/rpg-creation';
|
||||||
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
|
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
|
||||||
import { deleteRpgEntryWorldProfile } from '../../services/rpg-entry';
|
import { deleteRpgEntryWorldProfile } from '../../services/rpg-entry';
|
||||||
@@ -267,8 +271,7 @@ function buildAgentResultPublishGateView(
|
|||||||
|
|
||||||
const blockers = fallbackBlockers
|
const blockers = fallbackBlockers
|
||||||
.filter(
|
.filter(
|
||||||
(entry) =>
|
(entry) => !isAgentResultStructuralBlockerResolved(profile, entry.code),
|
||||||
!isAgentResultStructuralBlockerResolved(profile, entry.code),
|
|
||||||
)
|
)
|
||||||
.map((entry) => entry.message);
|
.map((entry) => entry.message);
|
||||||
|
|
||||||
@@ -367,7 +370,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
const [isPuzzleNextLevelGenerating, setIsPuzzleNextLevelGenerating] =
|
const [isPuzzleNextLevelGenerating, setIsPuzzleNextLevelGenerating] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [isSearchingPublicCode, setIsSearchingPublicCode] = useState(false);
|
const [isSearchingPublicCode, setIsSearchingPublicCode] = useState(false);
|
||||||
const [publicSearchError, setPublicSearchError] = useState<string | null>(null);
|
const [publicSearchError, setPublicSearchError] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [searchedPublicUser, setSearchedPublicUser] =
|
const [searchedPublicUser, setSearchedPublicUser] =
|
||||||
useState<PublicUserSummary | null>(null);
|
useState<PublicUserSummary | null>(null);
|
||||||
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
|
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
|
||||||
@@ -491,9 +496,11 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
agentSession: sessionController.agentSession,
|
agentSession: sessionController.agentSession,
|
||||||
handleCustomWorldSelect,
|
handleCustomWorldSelect,
|
||||||
executePublishWorld: async () => {
|
executePublishWorld: async () => {
|
||||||
const latestSession = await autosaveCoordinator.executeAgentActionAndWait({
|
const latestSession = await autosaveCoordinator.executeAgentActionAndWait(
|
||||||
action: 'publish_world',
|
{
|
||||||
});
|
action: 'publish_world',
|
||||||
|
},
|
||||||
|
);
|
||||||
// 发布动作会在后端同步 gallery 投影;前端发布完成后立即刷新首页/分类页共用的公开作品列表。
|
// 发布动作会在后端同步 gallery 投影;前端发布完成后立即刷新首页/分类页共用的公开作品列表。
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
platformBootstrap.refreshPublishedGallery(),
|
platformBootstrap.refreshPublishedGallery(),
|
||||||
@@ -551,18 +558,15 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return '服务端预览';
|
return '服务端预览';
|
||||||
}, [agentResultPreview]);
|
}, [agentResultPreview]);
|
||||||
|
|
||||||
const featuredGalleryEntries = useMemo(
|
const featuredGalleryEntries = useMemo(() => {
|
||||||
() => {
|
const puzzlePublicEntries = puzzleGalleryEntries.map(
|
||||||
const puzzlePublicEntries = puzzleGalleryEntries.map(
|
mapPuzzleWorkToPlatformGalleryCard,
|
||||||
mapPuzzleWorkToPlatformGalleryCard,
|
);
|
||||||
);
|
return mergePlatformPublicGalleryEntries(
|
||||||
return mergePlatformPublicGalleryEntries(
|
platformBootstrap.publishedGalleryEntries,
|
||||||
platformBootstrap.publishedGalleryEntries,
|
puzzlePublicEntries,
|
||||||
puzzlePublicEntries,
|
).slice(0, 6);
|
||||||
).slice(0, 6);
|
}, [platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries]);
|
||||||
},
|
|
||||||
[platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries],
|
|
||||||
);
|
|
||||||
const latestGalleryEntries = useMemo(
|
const latestGalleryEntries = useMemo(
|
||||||
() =>
|
() =>
|
||||||
mergePlatformPublicGalleryEntries(
|
mergePlatformPublicGalleryEntries(
|
||||||
@@ -608,86 +612,6 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
[authUi],
|
[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(() => {
|
const prepareCreationLaunch = useCallback(() => {
|
||||||
if (sessionController.isCreatingAgentSession) {
|
if (sessionController.isCreatingAgentSession) {
|
||||||
return false;
|
return false;
|
||||||
@@ -748,9 +672,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return galleryResponse.items;
|
return galleryResponse.items;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPuzzleGalleryEntries([]);
|
setPuzzleGalleryEntries([]);
|
||||||
setPuzzleError(
|
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图广场失败。'));
|
||||||
resolvePuzzleErrorMessage(error, '读取拼图广场失败。'),
|
|
||||||
);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [resolvePuzzleErrorMessage]);
|
}, [resolvePuzzleErrorMessage]);
|
||||||
@@ -835,7 +757,10 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
{ session: PuzzleAgentSessionSnapshot },
|
{ session: PuzzleAgentSessionSnapshot },
|
||||||
SendPuzzleAgentMessageRequest,
|
SendPuzzleAgentMessageRequest,
|
||||||
PuzzleAgentActionRequest,
|
PuzzleAgentActionRequest,
|
||||||
{ operation: PuzzleAgentOperationRecord; session: PuzzleAgentSessionSnapshot }
|
{
|
||||||
|
operation: PuzzleAgentOperationRecord;
|
||||||
|
session: PuzzleAgentSessionSnapshot;
|
||||||
|
}
|
||||||
>({
|
>({
|
||||||
client: {
|
client: {
|
||||||
createSession: createPuzzleAgentSession,
|
createSession: createPuzzleAgentSession,
|
||||||
@@ -1165,11 +1090,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const dragPuzzlePiece = useCallback(
|
const dragPuzzlePiece = useCallback(
|
||||||
(payload: {
|
(payload: { pieceId: string; targetRow: number; targetCol: number }) => {
|
||||||
pieceId: string;
|
|
||||||
targetRow: number;
|
|
||||||
targetCol: number;
|
|
||||||
}) => {
|
|
||||||
if (!puzzleRun || isPuzzleBusy) {
|
if (!puzzleRun || isPuzzleBusy) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1198,7 +1119,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
const { run } = await advanceLocalPuzzleNextLevel({
|
const { run } = await advanceLocalPuzzleNextLevel({
|
||||||
run: puzzleRun,
|
run: puzzleRun,
|
||||||
sourceSessionId:
|
sourceSessionId:
|
||||||
selectedPuzzleDetail?.sourceSessionId ?? puzzleSession?.sessionId ?? null,
|
selectedPuzzleDetail?.sourceSessionId ??
|
||||||
|
puzzleSession?.sessionId ??
|
||||||
|
null,
|
||||||
});
|
});
|
||||||
setPuzzleRun(run);
|
setPuzzleRun(run);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1293,7 +1216,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
(entry) => entry.profileId === work.profileId,
|
(entry) => entry.profileId === work.profileId,
|
||||||
);
|
);
|
||||||
if (!matchedEntry) {
|
if (!matchedEntry) {
|
||||||
platformBootstrap.setPlatformError('未找到可体验的作品,请刷新后重试。');
|
platformBootstrap.setPlatformError(
|
||||||
|
'未找到可体验的作品,请刷新后重试。',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1362,10 +1287,14 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
|
|
||||||
const deleteTask =
|
const deleteTask =
|
||||||
work.sourceType === 'published_profile' && work.profileId
|
work.sourceType === 'published_profile' && work.profileId
|
||||||
? deleteRpgEntryWorldProfile(work.profileId).then(async (entries) => {
|
? deleteRpgEntryWorldProfile(work.profileId).then(
|
||||||
platformBootstrap.setSavedCustomWorldEntries(entries);
|
async (entries) => {
|
||||||
await platformBootstrap.refreshCustomWorldWorks().catch(() => []);
|
platformBootstrap.setSavedCustomWorldEntries(entries);
|
||||||
})
|
await platformBootstrap
|
||||||
|
.refreshCustomWorldWorks()
|
||||||
|
.catch(() => []);
|
||||||
|
},
|
||||||
|
)
|
||||||
: work.sourceType === 'agent_session' && work.sessionId
|
: work.sourceType === 'agent_session' && work.sessionId
|
||||||
? deleteRpgCreationAgentSession(work.sessionId).then((items) => {
|
? deleteRpgCreationAgentSession(work.sessionId).then((items) => {
|
||||||
platformBootstrap.setCustomWorldWorkEntries(items);
|
platformBootstrap.setCustomWorldWorkEntries(items);
|
||||||
@@ -1446,7 +1375,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
void refreshPuzzleGallery();
|
void refreshPuzzleGallery();
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setPuzzleError(resolvePuzzleErrorMessage(error, '删除拼图作品失败。'));
|
setPuzzleError(
|
||||||
|
resolvePuzzleErrorMessage(error, '删除拼图作品失败。'),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setDeletingCreationWorkId(null);
|
setDeletingCreationWorkId(null);
|
||||||
@@ -1485,12 +1416,136 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setPuzzleOperation(null);
|
setPuzzleOperation(null);
|
||||||
setPuzzleRun(null);
|
setPuzzleRun(null);
|
||||||
setSelectedPuzzleDetail(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) {
|
if (!restoredSession) {
|
||||||
await refreshPuzzleShelf().catch(() => undefined);
|
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(
|
const openBigFishDraft = useCallback(
|
||||||
@@ -1632,6 +1687,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
onExperienceRpg={(item) => {
|
onExperienceRpg={(item) => {
|
||||||
handleExperienceRpgWork(item);
|
handleExperienceRpgWork(item);
|
||||||
}}
|
}}
|
||||||
|
rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries}
|
||||||
bigFishItems={bigFishWorks}
|
bigFishItems={bigFishWorks}
|
||||||
onOpenBigFishDetail={(item) => {
|
onOpenBigFishDetail={(item) => {
|
||||||
runProtectedAction(() => {
|
runProtectedAction(() => {
|
||||||
@@ -1649,11 +1705,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
puzzleItems={puzzleWorks}
|
puzzleItems={puzzleWorks}
|
||||||
onOpenPuzzleDetail={(item) => {
|
onOpenPuzzleDetail={(item) => {
|
||||||
runProtectedAction(() => {
|
runProtectedAction(() => {
|
||||||
if (item.publicationStatus === 'draft') {
|
void openPuzzleDraft(item);
|
||||||
void openPuzzleDraft(item);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void openPuzzleDetail(item.profileId);
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onExperiencePuzzle={(profileId) => {
|
onExperiencePuzzle={(profileId) => {
|
||||||
@@ -1894,13 +1946,17 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
className="flex h-full min-h-0 flex-col"
|
className="flex h-full min-h-0 flex-col"
|
||||||
>
|
>
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={<LazyPanelFallback label="正在加载大鱼吃小鱼生成面板..." />}
|
fallback={
|
||||||
|
<LazyPanelFallback label="正在加载大鱼吃小鱼生成面板..." />
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<CustomWorldGenerationView
|
<CustomWorldGenerationView
|
||||||
settingText={
|
settingText={
|
||||||
bigFishSession?.lastAssistantReply ?? '正在整理当前玩法草稿。'
|
bigFishSession?.lastAssistantReply ?? '正在整理当前玩法草稿。'
|
||||||
}
|
}
|
||||||
anchorEntries={buildBigFishGenerationAnchorEntries(bigFishSession)}
|
anchorEntries={buildBigFishGenerationAnchorEntries(
|
||||||
|
bigFishSession,
|
||||||
|
)}
|
||||||
progress={buildMiniGameDraftGenerationProgress(
|
progress={buildMiniGameDraftGenerationProgress(
|
||||||
bigFishGenerationState,
|
bigFishGenerationState,
|
||||||
)}
|
)}
|
||||||
@@ -1911,7 +1967,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setSelectionStage('big-fish-agent-workspace');
|
setSelectionStage('big-fish-agent-workspace');
|
||||||
}}
|
}}
|
||||||
onRetry={() => {
|
onRetry={() => {
|
||||||
void executeBigFishAction({ action: 'big_fish_compile_draft' });
|
void executeBigFishAction({
|
||||||
|
action: 'big_fish_compile_draft',
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
onInterrupt={undefined}
|
onInterrupt={undefined}
|
||||||
backLabel="返回创作中心"
|
backLabel="返回创作中心"
|
||||||
@@ -2026,7 +2084,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
settingText={
|
settingText={
|
||||||
puzzleSession?.lastAssistantReply ?? '正在整理当前拼图草稿。'
|
puzzleSession?.lastAssistantReply ?? '正在整理当前拼图草稿。'
|
||||||
}
|
}
|
||||||
anchorEntries={buildPuzzleGenerationAnchorEntries(puzzleSession)}
|
anchorEntries={buildPuzzleGenerationAnchorEntries(
|
||||||
|
puzzleSession,
|
||||||
|
)}
|
||||||
progress={buildMiniGameDraftGenerationProgress(
|
progress={buildMiniGameDraftGenerationProgress(
|
||||||
puzzleGenerationState,
|
puzzleGenerationState,
|
||||||
)}
|
)}
|
||||||
@@ -2093,6 +2153,16 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
enterCreateTab();
|
enterCreateTab();
|
||||||
setSelectionStage('platform');
|
setSelectionStage('platform');
|
||||||
}}
|
}}
|
||||||
|
onEdit={
|
||||||
|
selectedPuzzleDetail.ownerUserId === authUi?.user?.id &&
|
||||||
|
Boolean(selectedPuzzleDetail.sourceSessionId?.trim())
|
||||||
|
? () => {
|
||||||
|
runProtectedAction(() => {
|
||||||
|
void openPuzzleDraft(selectedPuzzleDetail);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
onStartGame={() => {
|
onStartGame={() => {
|
||||||
void startPuzzleRunFromProfile(selectedPuzzleDetail.profileId);
|
void startPuzzleRunFromProfile(selectedPuzzleDetail.profileId);
|
||||||
}}
|
}}
|
||||||
@@ -2271,15 +2341,17 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
? 'generate_landmarks'
|
? 'generate_landmarks'
|
||||||
: 'generate_characters';
|
: 'generate_characters';
|
||||||
const latestSession =
|
const latestSession =
|
||||||
await autosaveCoordinator.executeAgentActionAndWait({
|
await autosaveCoordinator.executeAgentActionAndWait(
|
||||||
action,
|
{
|
||||||
count: 1,
|
action,
|
||||||
...(kind === 'playable'
|
count: 1,
|
||||||
? { roleType: 'playable' as const }
|
...(kind === 'playable'
|
||||||
: kind === 'story'
|
? { roleType: 'playable' as const }
|
||||||
? { roleType: 'story' as const }
|
: kind === 'story'
|
||||||
: {}),
|
? { roleType: 'story' as const }
|
||||||
});
|
: {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
const latestProfile = latestSession
|
const latestProfile = latestSession
|
||||||
? rpgCreationPreviewAdapter.buildPreviewFromSession(
|
? rpgCreationPreviewAdapter.buildPreviewFromSession(
|
||||||
latestSession,
|
latestSession,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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 type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||||
|
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
|
|
||||||
type PuzzleGalleryDetailViewProps = {
|
type PuzzleGalleryDetailViewProps = {
|
||||||
@@ -8,9 +9,18 @@ type PuzzleGalleryDetailViewProps = {
|
|||||||
isBusy?: boolean;
|
isBusy?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
onEdit?: (() => void) | null;
|
||||||
onStartGame: () => void;
|
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,
|
isBusy = false,
|
||||||
error = null,
|
error = null,
|
||||||
onBack,
|
onBack,
|
||||||
|
onEdit = null,
|
||||||
onStartGame,
|
onStartGame,
|
||||||
}: PuzzleGalleryDetailViewProps) {
|
}: PuzzleGalleryDetailViewProps) {
|
||||||
|
const publicWorkCode = buildPuzzlePublicWorkCode(item.profileId);
|
||||||
|
|
||||||
return (
|
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="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">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
type="button"
|
{onEdit ? (
|
||||||
disabled={isBusy}
|
<button
|
||||||
onClick={onStartGame}
|
type="button"
|
||||||
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"
|
disabled={isBusy}
|
||||||
>
|
onClick={onEdit}
|
||||||
<Play className="h-4 w-4" />
|
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"
|
||||||
进入第 1 关
|
>
|
||||||
</button>
|
<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>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
@@ -54,6 +80,17 @@ export function PuzzleGalleryDetailView({
|
|||||||
{item.authorDisplayName}
|
{item.authorDisplayName}
|
||||||
</span>
|
</span>
|
||||||
<span>{item.playCount} 次游玩</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
listPuzzleGallery,
|
listPuzzleGallery,
|
||||||
} from '../../services/puzzle-gallery';
|
} from '../../services/puzzle-gallery';
|
||||||
import { listPuzzleWorks } from '../../services/puzzle-works';
|
import { listPuzzleWorks } from '../../services/puzzle-works';
|
||||||
|
import { getRpgEntryWorldGalleryDetailByCode } from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||||
import type { GameState } from '../../types';
|
import type { GameState } from '../../types';
|
||||||
import {
|
import {
|
||||||
AuthUiContext,
|
AuthUiContext,
|
||||||
@@ -128,6 +129,10 @@ vi.mock('../../services/puzzle-gallery', () => ({
|
|||||||
listPuzzleGallery: vi.fn(),
|
listPuzzleGallery: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/rpg-entry/rpgEntryLibraryClient', () => ({
|
||||||
|
getRpgEntryWorldGalleryDetailByCode: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../services/big-fish-creation', () => ({
|
vi.mock('../../services/big-fish-creation', () => ({
|
||||||
createBigFishCreationSession: vi.fn(),
|
createBigFishCreationSession: vi.fn(),
|
||||||
executeBigFishCreationAction: vi.fn(),
|
executeBigFishCreationAction: vi.fn(),
|
||||||
@@ -575,6 +580,9 @@ beforeEach(() => {
|
|||||||
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
|
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
|
||||||
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
||||||
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
|
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
|
||||||
|
vi.mocked(getRpgEntryWorldGalleryDetailByCode).mockRejectedValue(
|
||||||
|
new Error('未找到公开作品'),
|
||||||
|
);
|
||||||
vi.mocked(upsertRpgWorldProfile).mockResolvedValue({
|
vi.mocked(upsertRpgWorldProfile).mockResolvedValue({
|
||||||
entry: {
|
entry: {
|
||||||
ownerUserId: 'user-1',
|
ownerUserId: 'user-1',
|
||||||
@@ -1047,9 +1055,7 @@ test('opening RPG agent workspace does not refetch session snapshot in a render
|
|||||||
expect(getRpgCreationSession).toHaveBeenCalledTimes(1);
|
expect(getRpgCreationSession).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('create tab opens compiled agent draft in result refinement page', async () => {
|
||||||
'create tab opens compiled agent draft in result refinement page',
|
|
||||||
async () => {
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||||||
@@ -1102,9 +1108,7 @@ test(
|
|||||||
screen.queryByText('Agent工作区:custom-world-agent-session-1'),
|
screen.queryByText('Agent工作区:custom-world-agent-session-1'),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
expect(screen.getByRole('button', { name: /返回创作/u })).toBeTruthy();
|
expect(screen.getByRole('button', { name: /返回创作/u })).toBeTruthy();
|
||||||
},
|
}, 10000);
|
||||||
10000,
|
|
||||||
);
|
|
||||||
|
|
||||||
test('create tab resumes agent workspace when draft has no compiled result yet', async () => {
|
test('create tab resumes agent workspace when draft has no compiled result yet', async () => {
|
||||||
const user = userEvent.setup();
|
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: '分类' }));
|
await user.click(screen.getByRole('button', { name: '分类' }));
|
||||||
|
|
||||||
const categoryPanel = getPlatformTabPanel('category');
|
const categoryPanel = getPlatformTabPanel('category');
|
||||||
expect(within(categoryPanel).getAllByText('星桥机关').length).toBeGreaterThan(0);
|
expect(within(categoryPanel).getAllByText('星桥机关').length).toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
within(categoryPanel).getAllByRole('button', { name: /机关/u }).length,
|
within(categoryPanel).getAllByRole('button', { name: /机关/u }).length,
|
||||||
).toBeGreaterThan(0);
|
).toBeGreaterThan(0);
|
||||||
@@ -1497,8 +1503,9 @@ test('restoring an agent workspace ignores a stored session owned by another use
|
|||||||
render(<TestWrapper withAuth />);
|
render(<TestWrapper withAuth />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(window.sessionStorage.getItem('genarrative.custom-world-agent-ui.v1'))
|
expect(
|
||||||
.toBeNull();
|
window.sessionStorage.getItem('genarrative.custom-world-agent-ui.v1'),
|
||||||
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getRpgCreationSession).not.toHaveBeenCalled();
|
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();
|
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 () => {
|
test('big fish draft card restores the bound agent session and opens the result view', async () => {
|
||||||
const user = userEvent.setup();
|
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: '返回' }));
|
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||||
|
|
||||||
expect(await screen.findByText('大鱼吃小鱼工作区:big-fish-session-1')).toBeTruthy();
|
|
||||||
expect(screen.queryByText(/大鱼吃小鱼共创/u)).toBeNull();
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('我想做机械深海里微生物互相吞并进化。'),
|
await screen.findByText('大鱼吃小鱼工作区:big-fish-session-1'),
|
||||||
).toBeTruthy();
|
).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 () => {
|
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' &&
|
sessionId === 'custom-world-agent-session-1' &&
|
||||||
payload?.action === 'publish_world',
|
payload?.action === 'publish_world',
|
||||||
).length;
|
).length;
|
||||||
expect(publishWorldCallCountAfterClick).toBe(publishWorldCallCountBeforeClick);
|
expect(publishWorldCallCountAfterClick).toBe(
|
||||||
|
publishWorldCallCountBeforeClick,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('agent draft result publishes to gallery from publish panel', async () => {
|
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;
|
} satisfies CustomWorldAgentSessionSnapshot;
|
||||||
|
|
||||||
let hasPublishedWorld = false;
|
let hasPublishedWorld = false;
|
||||||
|
vi.mocked(createRpgCreationSession).mockResolvedValue({
|
||||||
|
session: publishReadyDraftSession,
|
||||||
|
});
|
||||||
vi.mocked(getRpgCreationOperation).mockResolvedValueOnce({
|
vi.mocked(getRpgCreationOperation).mockResolvedValueOnce({
|
||||||
operationId: 'operation-publish-world-1',
|
operationId: 'operation-publish-world-1',
|
||||||
type: 'publish_world',
|
type: 'publish_world',
|
||||||
@@ -1972,9 +2071,13 @@ test('agent draft result publishes to gallery from publish panel', async () => {
|
|||||||
|
|
||||||
await openNewRpgCreation(user);
|
await openNewRpgCreation(user);
|
||||||
|
|
||||||
const actionButton = await screen.findByRole('button', {
|
const actionButton = await screen.findByRole(
|
||||||
name: '发布',
|
'button',
|
||||||
});
|
{
|
||||||
|
name: '发布',
|
||||||
|
},
|
||||||
|
{ timeout: 5000 },
|
||||||
|
);
|
||||||
await user.click(actionButton);
|
await user.click(actionButton);
|
||||||
await user.click(await screen.findByRole('button', { name: '发布到广场' }));
|
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(await screen.findByText('世界档案')).toBeTruthy();
|
||||||
expect(screen.getByText('潮雾列岛·服务端预览')).toBeTruthy();
|
expect(screen.getByText('潮雾列岛·服务端预览')).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('结果页改为优先消费 session.resultPreview'),
|
screen.getAllByText('结果页改为优先消费 session.resultPreview').length,
|
||||||
).toBeTruthy();
|
).toBeGreaterThan(0);
|
||||||
},
|
},
|
||||||
{ timeout: 2500 },
|
{ timeout: 2500 },
|
||||||
);
|
);
|
||||||
@@ -2900,9 +3003,7 @@ test('creation hub published work experience button enters world directly', asyn
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
|
||||||
<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
await openCreationHub(user);
|
await openCreationHub(user);
|
||||||
await user.click(await screen.findByRole('button', { name: '体验' }));
|
await user.click(await screen.findByRole('button', { name: '体验' }));
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { afterEach, expect, test, vi } from 'vitest';
|
import { afterEach, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
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', () => ({
|
vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||||
getRpgProfileRechargeCenter: vi.fn(async () => ({
|
getRpgProfileRechargeCenter: vi.fn(async () => ({
|
||||||
@@ -93,6 +94,43 @@ vi.mock('../ResolvedAssetImage', () => ({
|
|||||||
ResolvedAssetImage: () => null,
|
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()) {
|
function renderProfileView(onRechargeSuccess = vi.fn()) {
|
||||||
return render(
|
return render(
|
||||||
<AuthUiContext.Provider
|
<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(
|
return render(
|
||||||
<AuthUiContext.Provider
|
<AuthUiContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -176,6 +225,96 @@ function renderLoggedOutHomeView(openLoginModal = vi.fn()) {
|
|||||||
isPersistingSettings: false,
|
isPersistingSettings: false,
|
||||||
settingsError: null,
|
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
|
<RpgEntryHomeView
|
||||||
activeTab="home"
|
activeTab="home"
|
||||||
@@ -200,37 +339,46 @@ function renderLoggedOutHomeView(openLoginModal = vi.fn()) {
|
|||||||
onOpenCreateTypePicker={vi.fn()}
|
onOpenCreateTypePicker={vi.fn()}
|
||||||
onOpenGalleryDetail={vi.fn()}
|
onOpenGalleryDetail={vi.fn()}
|
||||||
onOpenLibraryDetail={vi.fn()}
|
onOpenLibraryDetail={vi.fn()}
|
||||||
onSearchPublicCode={vi.fn()}
|
onSearchPublicCode={onSearchPublicCode}
|
||||||
/>
|
/>
|
||||||
</AuthUiContext.Provider>,
|
</AuthUiContext.Provider>,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
const searchInput = screen.getByPlaceholderText('输入 SY / CW / PZ 编号');
|
||||||
vi.clearAllMocks();
|
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 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);
|
renderLoggedOutHomeView(vi.fn(), {
|
||||||
await user.click(screen.getByText('会员充值'));
|
latestEntries: [puzzlePublicEntry],
|
||||||
|
onOpenGalleryDetail,
|
||||||
|
});
|
||||||
|
|
||||||
expect(await screen.findByText('账户充值')).toBeTruthy();
|
await user.click(
|
||||||
expect(await screen.findByText('10积分')).toBeTruthy();
|
screen.getByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
|
||||||
|
);
|
||||||
|
|
||||||
await user.click(screen.getByText('首充送19积分'));
|
expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1');
|
||||||
|
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
||||||
await waitFor(() => expect(onRechargeSuccess).toHaveBeenCalledTimes(1));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows a reachable login entry in logged out mobile shell', async () => {
|
test('desktop trending list shows public code instead of timestamp text', () => {
|
||||||
const user = userEvent.setup();
|
mockDesktopLayout();
|
||||||
const openLoginModal = vi.fn();
|
|
||||||
|
|
||||||
renderLoggedOutHomeView(openLoginModal);
|
renderLoggedOutHomeView(vi.fn(), {
|
||||||
await user.click(screen.getByRole('button', { name: '登录' }));
|
latestEntries: [puzzlePublicEntry],
|
||||||
|
});
|
||||||
|
|
||||||
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
expect(screen.getAllByText('PZ-EPUBLIC1').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.queryByText('1777110165.990127Z')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
type ComponentType,
|
type ComponentType,
|
||||||
|
type KeyboardEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -53,6 +54,7 @@ import {
|
|||||||
describePlatformThemeLabel,
|
describePlatformThemeLabel,
|
||||||
formatPlatformWorldTime,
|
formatPlatformWorldTime,
|
||||||
isPuzzleGalleryEntry,
|
isPuzzleGalleryEntry,
|
||||||
|
resolvePlatformPublicWorkCode,
|
||||||
type PlatformPublicGalleryCard,
|
type PlatformPublicGalleryCard,
|
||||||
type PlatformWorldCardLike,
|
type PlatformWorldCardLike,
|
||||||
resolvePlatformWorldCoverImage,
|
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 }) {
|
function EmptyShelf({ text }: { text: string }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -249,6 +293,8 @@ function WorldCard({
|
|||||||
}) {
|
}) {
|
||||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||||||
|
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
|
||||||
|
const badgeLabel = publicWorkCode ?? badge;
|
||||||
const tags = [
|
const tags = [
|
||||||
...new Set(
|
...new Set(
|
||||||
buildPlatformWorldTags(entry)
|
buildPlatformWorldTags(entry)
|
||||||
@@ -257,10 +303,25 @@ function WorldCard({
|
|||||||
),
|
),
|
||||||
].slice(0, 3);
|
].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 (
|
return (
|
||||||
<button
|
<div
|
||||||
type="button"
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={onClick}
|
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 ?? ''}`}
|
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 ? (
|
{coverImage ? (
|
||||||
@@ -281,7 +342,25 @@ function WorldCard({
|
|||||||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
||||||
<div className="relative z-10 flex h-full flex-col">
|
<div className="relative z-10 flex h-full flex-col">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<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">
|
<span className="platform-pill platform-pill--neutral px-2.5">
|
||||||
{metaLabel}
|
{metaLabel}
|
||||||
</span>
|
</span>
|
||||||
@@ -316,7 +395,7 @@ function WorldCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,6 +651,7 @@ function DesktopTrendingItem({
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||||
|
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
|
||||||
const tags = buildPlatformWorldTags(entry).filter(Boolean).slice(0, 2);
|
const tags = buildPlatformWorldTags(entry).filter(Boolean).slice(0, 2);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -594,7 +674,9 @@ function DesktopTrendingItem({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
|
<div className="flex items-center gap-2 text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||||
<span>{`${rank}`.padStart(2, '0')}</span>
|
<span>{`${rank}`.padStart(2, '0')}</span>
|
||||||
<span>{formatPlatformWorldTime(entry.publishedAt)}</span>
|
<span className="truncate">
|
||||||
|
{publicWorkCode ?? describePublicGalleryCardKind(entry)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 line-clamp-1 text-lg font-semibold text-[var(--platform-text-strong)]">
|
<div className="mt-2 line-clamp-1 text-lg font-semibold text-[var(--platform-text-strong)]">
|
||||||
{entry.worldName}
|
{entry.worldName}
|
||||||
@@ -1050,6 +1132,7 @@ export function RpgEntryHomeView({
|
|||||||
}: RpgEntryHomeViewProps) {
|
}: RpgEntryHomeViewProps) {
|
||||||
const authUi = useAuthUi();
|
const authUi = useAuthUi();
|
||||||
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
|
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
|
||||||
|
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
|
||||||
const [isRechargeOpen, setIsRechargeOpen] = useState(false);
|
const [isRechargeOpen, setIsRechargeOpen] = useState(false);
|
||||||
const [rechargeTab, setRechargeTab] = useState<'points' | 'membership'>(
|
const [rechargeTab, setRechargeTab] = useState<'points' | 'membership'>(
|
||||||
'points',
|
'points',
|
||||||
@@ -1171,6 +1254,14 @@ export function RpgEntryHomeView({
|
|||||||
|
|
||||||
void onSearchPublicCode(keyword);
|
void onSearchPublicCode(keyword);
|
||||||
};
|
};
|
||||||
|
const submitMobileSearch = () => {
|
||||||
|
const keyword = mobileSearchKeyword.trim();
|
||||||
|
if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void onSearchPublicCode(keyword);
|
||||||
|
};
|
||||||
const desktopHeroEntry =
|
const desktopHeroEntry =
|
||||||
featuredShelf[0] ?? latestEntries[0] ?? myEntries[0] ?? null;
|
featuredShelf[0] ?? latestEntries[0] ?? myEntries[0] ?? null;
|
||||||
const desktopHeroCover = desktopHeroEntry
|
const desktopHeroCover = desktopHeroEntry
|
||||||
@@ -1198,6 +1289,13 @@ export function RpgEntryHomeView({
|
|||||||
|
|
||||||
const mobileHomeContent: ReactNode = (
|
const mobileHomeContent: ReactNode = (
|
||||||
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage`}>
|
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage`}>
|
||||||
|
<PublicCodeSearchBar
|
||||||
|
value={mobileSearchKeyword}
|
||||||
|
onChange={setMobileSearchKeyword}
|
||||||
|
onSubmit={submitMobileSearch}
|
||||||
|
isSearching={isSearchingPublicCode}
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openLeadPublicEntry}
|
onClick={openLeadPublicEntry}
|
||||||
@@ -1269,7 +1367,7 @@ export function RpgEntryHomeView({
|
|||||||
<WorldCard
|
<WorldCard
|
||||||
key={`${buildPublicGalleryCardKey(entry)}:latest`}
|
key={`${buildPublicGalleryCardKey(entry)}:latest`}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
badge={formatPlatformWorldTime(entry.publishedAt)}
|
badge={describePublicGalleryCardKind(entry)}
|
||||||
metaLabel={entry.authorDisplayName}
|
metaLabel={entry.authorDisplayName}
|
||||||
onClick={() => onOpenGalleryDetail(entry)}
|
onClick={() => onOpenGalleryDetail(entry)}
|
||||||
/>
|
/>
|
||||||
@@ -1865,7 +1963,7 @@ export function RpgEntryHomeView({
|
|||||||
<WorldCard
|
<WorldCard
|
||||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-latest`}
|
key={`${buildPublicGalleryCardKey(entry)}:desktop-latest`}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
badge={formatPlatformWorldTime(entry.publishedAt)}
|
badge={describePublicGalleryCardKind(entry)}
|
||||||
metaLabel={entry.authorDisplayName}
|
metaLabel={entry.authorDisplayName}
|
||||||
onClick={() => onOpenGalleryDetail(entry)}
|
onClick={() => onOpenGalleryDetail(entry)}
|
||||||
className="h-[17rem] w-full min-w-0"
|
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="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-5">
|
<div className="flex min-w-0 flex-1 items-center gap-5">
|
||||||
<RpgEntryBrandLogo className="shrink-0" decorative />
|
<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)]">
|
<PublicCodeSearchBar
|
||||||
<Search className="h-4 w-4 shrink-0" />
|
value={desktopSearchKeyword}
|
||||||
<input
|
onChange={setDesktopSearchKeyword}
|
||||||
value={desktopSearchKeyword}
|
onSubmit={submitDesktopSearch}
|
||||||
onChange={(event) =>
|
isSearching={
|
||||||
setDesktopSearchKeyword(event.target.value)
|
!onSearchPublicCode || Boolean(isSearchingPublicCode)
|
||||||
}
|
}
|
||||||
onKeyDown={(event) => {
|
className="max-w-[34rem] flex-1"
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -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 type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
buildPlatformWorldTags,
|
buildPlatformWorldTags,
|
||||||
describePlatformThemeLabel,
|
describePlatformThemeLabel,
|
||||||
formatPlatformWorldTime,
|
formatPlatformWorldTime,
|
||||||
|
resolvePlatformPublicWorkCode,
|
||||||
resolvePlatformWorldCoverImage,
|
resolvePlatformWorldCoverImage,
|
||||||
resolvePlatformWorldLeadPortrait,
|
resolvePlatformWorldLeadPortrait,
|
||||||
} from './rpgEntryWorldPresentation';
|
} from './rpgEntryWorldPresentation';
|
||||||
@@ -24,6 +25,14 @@ export interface RpgEntryWorldDetailViewProps {
|
|||||||
onUnpublish?: (() => void) | null;
|
onUnpublish?: (() => void) | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyText(value: string) {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void navigator.clipboard.writeText(value);
|
||||||
|
}
|
||||||
|
|
||||||
function ActionButton({
|
function ActionButton({
|
||||||
label,
|
label,
|
||||||
onClick,
|
onClick,
|
||||||
@@ -67,6 +76,7 @@ export function RpgEntryWorldDetailView({
|
|||||||
}: RpgEntryWorldDetailViewProps) {
|
}: RpgEntryWorldDetailViewProps) {
|
||||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||||||
|
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
|
||||||
const canStartGame = entry.visibility === 'published';
|
const canStartGame = entry.visibility === 'published';
|
||||||
const previewCharacters = buildCustomWorldPlayableCharacters(
|
const previewCharacters = buildCustomWorldPlayableCharacters(
|
||||||
entry.profile,
|
entry.profile,
|
||||||
@@ -128,6 +138,16 @@ export function RpgEntryWorldDetailView({
|
|||||||
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
|
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
|
||||||
: '仅自己可见'}
|
: '仅自己可见'}
|
||||||
</span>
|
</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>
|
||||||
<div className="mt-4 text-3xl font-black text-white">
|
<div className="mt-4 text-3xl font-black text-white">
|
||||||
{entry.worldName}
|
{entry.worldName}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||||
|
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||||
import type { CustomWorldProfile } from '../../types';
|
import type { CustomWorldProfile } from '../../types';
|
||||||
|
|
||||||
export type PlatformWorldCardLike =
|
export type PlatformWorldCardLike =
|
||||||
@@ -16,6 +17,7 @@ export type PlatformPuzzleGalleryCard = {
|
|||||||
sourceType: 'puzzle';
|
sourceType: 'puzzle';
|
||||||
workId: string;
|
workId: string;
|
||||||
profileId: string;
|
profileId: string;
|
||||||
|
publicWorkCode: string;
|
||||||
ownerUserId: string;
|
ownerUserId: string;
|
||||||
authorDisplayName: string;
|
authorDisplayName: string;
|
||||||
worldName: string;
|
worldName: string;
|
||||||
@@ -51,6 +53,7 @@ export function mapPuzzleWorkToPlatformGalleryCard(
|
|||||||
sourceType: 'puzzle',
|
sourceType: 'puzzle',
|
||||||
workId: work.workId,
|
workId: work.workId,
|
||||||
profileId: work.profileId,
|
profileId: work.profileId,
|
||||||
|
publicWorkCode: buildPuzzlePublicWorkCode(work.profileId),
|
||||||
ownerUserId: work.ownerUserId,
|
ownerUserId: work.ownerUserId,
|
||||||
authorDisplayName: work.authorDisplayName,
|
authorDisplayName: work.authorDisplayName,
|
||||||
worldName: work.levelName,
|
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(
|
export function describePlatformThemeLabel(
|
||||||
themeMode: CustomWorldGalleryCard['themeMode'],
|
themeMode: CustomWorldGalleryCard['themeMode'],
|
||||||
) {
|
) {
|
||||||
|
|||||||
24
src/services/publicWorkCode.ts
Normal file
24
src/services/publicWorkCode.ts
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -127,14 +127,12 @@ test('buildRpgCreationPreviewFromResultPreview normalizes server preview envelop
|
|||||||
expect(profile?.settingText).toBe('被海雾吞没的旧航路群岛');
|
expect(profile?.settingText).toBe('被海雾吞没的旧航路群岛');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildRpgCreationPreviewFromSession reads draftProfile directly', () => {
|
test('buildRpgCreationPreviewFromSession prefers server result preview', () => {
|
||||||
const profile = buildRpgCreationPreviewFromSession(sessionWithPreview);
|
const profile = buildRpgCreationPreviewFromSession(sessionWithPreview);
|
||||||
|
|
||||||
expect(profile?.name).toBe('只作为 fallback 的本地草稿名');
|
expect(profile?.name).toBe('服务端结果预览');
|
||||||
expect(profile?.name).not.toBe('服务端结果预览');
|
expect(profile?.summary).toBe('结果页应该优先消费 session.resultPreview。');
|
||||||
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
|
expect(profile?.id).toBe('preview-profile-1');
|
||||||
'/generated-characters/draft-playable-1/portrait.png',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildRpgCreationPreviewFromSession does not require resultPreview', () => {
|
test('buildRpgCreationPreviewFromSession does not require resultPreview', () => {
|
||||||
|
|||||||
@@ -3,24 +3,26 @@ import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary
|
|||||||
import type { CustomWorldProfile } from '../../types';
|
import type { CustomWorldProfile } from '../../types';
|
||||||
|
|
||||||
export function buildCustomWorldProfileFromResultPreview(
|
export function buildCustomWorldProfileFromResultPreview(
|
||||||
resultPreview: CustomWorldAgentSessionSnapshot['resultPreview'] | null | undefined,
|
resultPreview:
|
||||||
|
| CustomWorldAgentSessionSnapshot['resultPreview']
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
): CustomWorldProfile | null {
|
): CustomWorldProfile | null {
|
||||||
return normalizeCustomWorldProfileRecord(resultPreview?.preview ?? null);
|
return normalizeCustomWorldProfileRecord(resultPreview?.preview ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* RPG 运行时直接读取 Agent session 的 draftProfile。
|
|
||||||
* resultPreview 只作为质量/发布信息外壳,不再参与进入游戏 profile 的数据转换。
|
|
||||||
*/
|
|
||||||
export function buildCustomWorldProfileFromAgentSession(
|
export function buildCustomWorldProfileFromAgentSession(
|
||||||
session: CustomWorldAgentSessionSnapshot | null | undefined,
|
session: CustomWorldAgentSessionSnapshot | null | undefined,
|
||||||
): CustomWorldProfile | null {
|
): CustomWorldProfile | null {
|
||||||
return normalizeCustomWorldProfileRecord(session?.draftProfile ?? null);
|
return (
|
||||||
|
buildCustomWorldProfileFromResultPreview(session?.resultPreview) ??
|
||||||
|
normalizeCustomWorldProfileRecord(session?.draftProfile ?? null)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 这是工作包 A 提供的新命名兼容层。
|
* 这是工作包 A 提供的新命名兼容层。
|
||||||
* 主入口保持命名稳定,但数据来源已经收敛为 draftProfile 单一真相源。
|
* 主入口保持命名稳定,优先消费服务端 resultPreview,缺失时回退到 draftProfile。
|
||||||
*/
|
*/
|
||||||
export const rpgCreationPreviewAdapter = {
|
export const rpgCreationPreviewAdapter = {
|
||||||
buildPreviewFromSession: buildCustomWorldProfileFromAgentSession,
|
buildPreviewFromSession: buildCustomWorldProfileFromAgentSession,
|
||||||
|
|||||||
Reference in New Issue
Block a user