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('角色 5')).toBeTruthy();
|
||||
expect(screen.getByText('地点 6')).toBeTruthy();
|
||||
});
|
||||
@@ -110,10 +112,59 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to
|
||||
|
||||
expect(screen.getByText('潮雾列岛')).toBeTruthy();
|
||||
expect(screen.getByText('沉钟拼图')).toBeTruthy();
|
||||
expect(screen.getByText('PZ-PROFILE1')).toBeTruthy();
|
||||
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('我的拼图作品')).toBeNull();
|
||||
});
|
||||
|
||||
test('creation hub shows RPG public work code from published library entry', () => {
|
||||
render(
|
||||
<CustomWorldCreationHub
|
||||
items={[
|
||||
{
|
||||
...baseDraftItem,
|
||||
workId: 'published:world-public-1',
|
||||
sourceType: 'published_profile',
|
||||
status: 'published',
|
||||
title: '潮雾列岛已发布版',
|
||||
profileId: 'world-public-1',
|
||||
canResume: false,
|
||||
canEnterWorld: true,
|
||||
},
|
||||
]}
|
||||
rpgLibraryEntries={[
|
||||
{
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'world-public-1',
|
||||
publicWorkCode: 'CW-00000001',
|
||||
authorPublicUserCode: 'SY-00000001',
|
||||
profile: {} as never,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-04-20T10:00:00.000Z',
|
||||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||||
authorDisplayName: '测试玩家',
|
||||
worldName: '潮雾列岛已发布版',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '已经发布的群岛世界作品。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 3,
|
||||
landmarkCount: 4,
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('潮雾列岛已发布版')).toBeTruthy();
|
||||
expect(screen.getByText('CW-00000001')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creation hub shows delete action for persisted rpg drafts', () => {
|
||||
render(
|
||||
<CustomWorldCreationHub
|
||||
@@ -157,7 +208,9 @@ test('creation hub opens persisted rpg drafts by card click', async () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /继续完善《可继续整理的草稿》/u }));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /继续完善《可继续整理的草稿》/u }),
|
||||
);
|
||||
|
||||
expect(openedItems).toEqual([persistedDraft]);
|
||||
});
|
||||
|
||||
@@ -80,5 +80,7 @@ test('creation hub renders puzzle works in the same unified list with puzzle tag
|
||||
|
||||
expect(html).toContain('潮雾拼图');
|
||||
expect(html).toContain('拼图');
|
||||
expect(html).toContain('作品号');
|
||||
expect(html).toContain('PZ-PROFILE1');
|
||||
expect(html).not.toContain('我的拼图作品');
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
|
||||
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
|
||||
import {
|
||||
@@ -28,6 +30,7 @@ type CustomWorldCreationHubProps = {
|
||||
onDeletePublished?: ((item: CustomWorldWorkSummary) => void) | null;
|
||||
deletingWorkId?: string | null;
|
||||
onExperienceRpg?: ((item: CustomWorldWorkSummary) => void) | null;
|
||||
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||
bigFishItems?: BigFishWorkSummary[];
|
||||
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
|
||||
onExperienceBigFish?: ((item: BigFishWorkSummary) => void) | null;
|
||||
@@ -61,6 +64,7 @@ export function CustomWorldCreationHub({
|
||||
onDeletePublished = null,
|
||||
deletingWorkId = null,
|
||||
onExperienceRpg = null,
|
||||
rpgLibraryEntries = [],
|
||||
bigFishItems = [],
|
||||
onOpenBigFishDetail,
|
||||
onExperienceBigFish = null,
|
||||
@@ -76,6 +80,7 @@ export function CustomWorldCreationHub({
|
||||
() =>
|
||||
buildCreationWorkShelfItems({
|
||||
rpgItems: items,
|
||||
rpgLibraryEntries,
|
||||
bigFishItems,
|
||||
puzzleItems,
|
||||
canDeleteRpg: Boolean(onDeletePublished),
|
||||
@@ -89,9 +94,12 @@ export function CustomWorldCreationHub({
|
||||
onDeletePublished,
|
||||
onDeletePuzzle,
|
||||
puzzleItems,
|
||||
rpgLibraryEntries,
|
||||
],
|
||||
);
|
||||
const draftCount = shelfItems.filter((entry) => entry.status === 'draft').length;
|
||||
const draftCount = shelfItems.filter(
|
||||
(entry) => entry.status === 'draft',
|
||||
).length;
|
||||
const publishedCount = shelfItems.filter(
|
||||
(entry) => entry.status === 'published',
|
||||
).length;
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Copy } from 'lucide-react';
|
||||
|
||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||
import type { CreationWorkShelfItem } from './creationWorkShelf';
|
||||
|
||||
function copyText(value: string) {
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
|
||||
return;
|
||||
}
|
||||
|
||||
void navigator.clipboard.writeText(value);
|
||||
}
|
||||
|
||||
function formatUpdatedAt(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
@@ -23,7 +33,10 @@ type CustomWorldWorkCardProps = {
|
||||
deleteBusy?: boolean;
|
||||
};
|
||||
|
||||
const BADGE_TONE_CLASS: Record<CreationWorkShelfItem['badges'][number]['tone'], string> = {
|
||||
const BADGE_TONE_CLASS: Record<
|
||||
CreationWorkShelfItem['badges'][number]['tone'],
|
||||
string
|
||||
> = {
|
||||
warm: 'platform-pill--warm',
|
||||
success: 'platform-pill--success',
|
||||
neutral: 'platform-pill--neutral',
|
||||
@@ -127,15 +140,33 @@ export function CustomWorldWorkCard({
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-end sm:justify-between xl:gap-2 xl:pt-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.metrics.map((metric) => (
|
||||
<span
|
||||
key={`${item.id}-${metric.id}`}
|
||||
className={`platform-pill ${BADGE_TONE_CLASS[metric.tone ?? 'neutral']} px-3 py-1 text-[10px]`}
|
||||
<div className="min-w-0 space-y-2">
|
||||
{item.publicWorkCode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
copyText(item.publicWorkCode ?? '');
|
||||
}}
|
||||
className="platform-pill platform-pill--neutral pointer-events-auto relative z-30 inline-flex max-w-full items-center gap-1.5 px-3 py-1 text-[10px]"
|
||||
aria-label={`复制作品号 ${item.publicWorkCode}`}
|
||||
title="复制作品号"
|
||||
>
|
||||
{metric.label}
|
||||
</span>
|
||||
))}
|
||||
<span className="shrink-0">作品号</span>
|
||||
<span className="min-w-0 truncate">{item.publicWorkCode}</span>
|
||||
<Copy className="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.metrics.map((metric) => (
|
||||
<span
|
||||
key={`${item.id}-${metric.id}`}
|
||||
className={`platform-pill ${BADGE_TONE_CLASS[metric.tone ?? 'neutral']} px-3 py-1 text-[10px]`}
|
||||
>
|
||||
{metric.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5">
|
||||
{onExperience ? (
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type CreationWorkShelfKind = 'rpg' | 'big-fish' | 'puzzle';
|
||||
export type CreationWorkShelfStatus = 'draft' | 'published';
|
||||
@@ -44,6 +47,7 @@ export type CreationWorkShelfItem = {
|
||||
coverImageSrc: string | null;
|
||||
coverRenderMode: 'image' | 'scene_with_roles';
|
||||
coverCharacterImageSrcs: string[];
|
||||
publicWorkCode: string | null;
|
||||
typeLabel: string;
|
||||
openActionLabel: string;
|
||||
canExperience: boolean;
|
||||
@@ -55,6 +59,7 @@ export type CreationWorkShelfItem = {
|
||||
|
||||
export function buildCreationWorkShelfItems(params: {
|
||||
rpgItems: CustomWorldWorkSummary[];
|
||||
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||
bigFishItems: BigFishWorkSummary[];
|
||||
puzzleItems: PuzzleWorkSummary[];
|
||||
canDeleteRpg?: boolean;
|
||||
@@ -63,6 +68,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
}) {
|
||||
const {
|
||||
rpgItems,
|
||||
rpgLibraryEntries = [],
|
||||
bigFishItems,
|
||||
puzzleItems,
|
||||
canDeleteRpg = false,
|
||||
@@ -71,11 +77,15 @@ export function buildCreationWorkShelfItems(params: {
|
||||
} = params;
|
||||
|
||||
return [
|
||||
...rpgItems.map((item) => mapRpgWorkToShelfItem(item, canDeleteRpg)),
|
||||
...rpgItems.map((item) =>
|
||||
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries),
|
||||
),
|
||||
...bigFishItems.map((item) =>
|
||||
mapBigFishWorkToShelfItem(item, canDeleteBigFish),
|
||||
),
|
||||
...puzzleItems.map((item) => mapPuzzleWorkToShelfItem(item, canDeletePuzzle)),
|
||||
...puzzleItems.map((item) =>
|
||||
mapPuzzleWorkToShelfItem(item, canDeletePuzzle),
|
||||
),
|
||||
].sort(
|
||||
(left, right) =>
|
||||
getShelfItemTime(right.updatedAt) - getShelfItemTime(left.updatedAt),
|
||||
@@ -85,8 +95,12 @@ export function buildCreationWorkShelfItems(params: {
|
||||
function mapRpgWorkToShelfItem(
|
||||
item: CustomWorldWorkSummary,
|
||||
canDelete: boolean,
|
||||
libraryEntries: CustomWorldLibraryEntry<CustomWorldProfile>[],
|
||||
): CreationWorkShelfItem {
|
||||
const isDraft = item.status === 'draft';
|
||||
const libraryEntry = item.profileId
|
||||
? libraryEntries.find((entry) => entry.profileId === item.profileId)
|
||||
: null;
|
||||
const badges: CreationWorkShelfBadge[] = [
|
||||
buildStatusBadge(item.status),
|
||||
{ id: 'type', label: 'RPG', tone: 'neutral' },
|
||||
@@ -134,6 +148,10 @@ function mapRpgWorkToShelfItem(
|
||||
coverImageSrc: item.coverImageSrc ?? null,
|
||||
coverRenderMode: item.coverRenderMode ?? 'image',
|
||||
coverCharacterImageSrcs: item.coverCharacterImageSrcs ?? [],
|
||||
publicWorkCode:
|
||||
item.status === 'published'
|
||||
? (libraryEntry?.publicWorkCode ?? null)
|
||||
: null,
|
||||
typeLabel: 'RPG',
|
||||
openActionLabel: isDraft
|
||||
? item.playableNpcCount > 0 || item.landmarkCount > 0
|
||||
@@ -163,6 +181,7 @@ function mapBigFishWorkToShelfItem(
|
||||
coverImageSrc: item.coverImageSrc ?? null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
publicWorkCode: null,
|
||||
typeLabel: '大鱼',
|
||||
openActionLabel: item.status === 'draft' ? '继续创作' : '查看详情',
|
||||
canExperience: item.status === 'published',
|
||||
@@ -212,8 +231,11 @@ function mapPuzzleWorkToShelfItem(
|
||||
coverImageSrc: item.coverImageSrc ?? null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
publicWorkCode:
|
||||
status === 'published' ? buildPuzzlePublicWorkCode(item.profileId) : null,
|
||||
typeLabel: '拼图',
|
||||
openActionLabel: status === 'draft' ? '继续创作' : '查看详情',
|
||||
openActionLabel:
|
||||
status === 'published' && !item.sourceSessionId ? '查看详情' : '继续创作',
|
||||
canExperience: status === 'published',
|
||||
canDelete,
|
||||
badges: [
|
||||
@@ -233,7 +255,9 @@ function mapPuzzleWorkToShelfItem(
|
||||
};
|
||||
}
|
||||
|
||||
function buildStatusBadge(status: CreationWorkShelfStatus): CreationWorkShelfBadge {
|
||||
function buildStatusBadge(
|
||||
status: CreationWorkShelfStatus,
|
||||
): CreationWorkShelfBadge {
|
||||
return {
|
||||
id: 'status',
|
||||
label: status === 'draft' ? '草稿' : '已发布',
|
||||
|
||||
@@ -67,7 +67,10 @@ import {
|
||||
getPuzzleAgentSession,
|
||||
streamPuzzleAgentMessage,
|
||||
} from '../../services/puzzle-agent';
|
||||
import { getPuzzleGalleryDetail, listPuzzleGallery } from '../../services/puzzle-gallery';
|
||||
import {
|
||||
getPuzzleGalleryDetail,
|
||||
listPuzzleGallery,
|
||||
} from '../../services/puzzle-gallery';
|
||||
import { advanceLocalPuzzleNextLevel } from '../../services/puzzle-runtime';
|
||||
import {
|
||||
dragLocalPuzzlePiece,
|
||||
@@ -75,6 +78,7 @@ import {
|
||||
swapLocalPuzzlePieces,
|
||||
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
|
||||
import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works';
|
||||
import { isSamePuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||
import { deleteRpgCreationAgentSession } from '../../services/rpg-creation';
|
||||
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
|
||||
import { deleteRpgEntryWorldProfile } from '../../services/rpg-entry';
|
||||
@@ -267,8 +271,7 @@ function buildAgentResultPublishGateView(
|
||||
|
||||
const blockers = fallbackBlockers
|
||||
.filter(
|
||||
(entry) =>
|
||||
!isAgentResultStructuralBlockerResolved(profile, entry.code),
|
||||
(entry) => !isAgentResultStructuralBlockerResolved(profile, entry.code),
|
||||
)
|
||||
.map((entry) => entry.message);
|
||||
|
||||
@@ -367,7 +370,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
const [isPuzzleNextLevelGenerating, setIsPuzzleNextLevelGenerating] =
|
||||
useState(false);
|
||||
const [isSearchingPublicCode, setIsSearchingPublicCode] = useState(false);
|
||||
const [publicSearchError, setPublicSearchError] = useState<string | null>(null);
|
||||
const [publicSearchError, setPublicSearchError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [searchedPublicUser, setSearchedPublicUser] =
|
||||
useState<PublicUserSummary | null>(null);
|
||||
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
|
||||
@@ -491,9 +496,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
agentSession: sessionController.agentSession,
|
||||
handleCustomWorldSelect,
|
||||
executePublishWorld: async () => {
|
||||
const latestSession = await autosaveCoordinator.executeAgentActionAndWait({
|
||||
action: 'publish_world',
|
||||
});
|
||||
const latestSession = await autosaveCoordinator.executeAgentActionAndWait(
|
||||
{
|
||||
action: 'publish_world',
|
||||
},
|
||||
);
|
||||
// 发布动作会在后端同步 gallery 投影;前端发布完成后立即刷新首页/分类页共用的公开作品列表。
|
||||
await Promise.allSettled([
|
||||
platformBootstrap.refreshPublishedGallery(),
|
||||
@@ -551,18 +558,15 @@ export function PlatformEntryFlowShellImpl({
|
||||
return '服务端预览';
|
||||
}, [agentResultPreview]);
|
||||
|
||||
const featuredGalleryEntries = useMemo(
|
||||
() => {
|
||||
const puzzlePublicEntries = puzzleGalleryEntries.map(
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
);
|
||||
return mergePlatformPublicGalleryEntries(
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
puzzlePublicEntries,
|
||||
).slice(0, 6);
|
||||
},
|
||||
[platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries],
|
||||
);
|
||||
const featuredGalleryEntries = useMemo(() => {
|
||||
const puzzlePublicEntries = puzzleGalleryEntries.map(
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
);
|
||||
return mergePlatformPublicGalleryEntries(
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
puzzlePublicEntries,
|
||||
).slice(0, 6);
|
||||
}, [platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries]);
|
||||
const latestGalleryEntries = useMemo(
|
||||
() =>
|
||||
mergePlatformPublicGalleryEntries(
|
||||
@@ -608,86 +612,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
[authUi],
|
||||
);
|
||||
|
||||
const handlePublicCodeSearch = useCallback(
|
||||
async (keyword: string) => {
|
||||
const normalizedKeyword = keyword.trim();
|
||||
if (!normalizedKeyword) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearchingPublicCode(true);
|
||||
setPublicSearchError(null);
|
||||
setSearchedPublicUser(null);
|
||||
|
||||
const upperKeyword = normalizedKeyword.toUpperCase();
|
||||
const shouldSearchUserIdFirst = /^user[_-][a-z0-9_-]+$/iu.test(normalizedKeyword);
|
||||
const shouldSearchWorkFirst =
|
||||
!shouldSearchUserIdFirst &&
|
||||
(upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword));
|
||||
const shouldSearchUserFirst =
|
||||
shouldSearchUserIdFirst || upperKeyword.startsWith('SY') || !shouldSearchWorkFirst;
|
||||
|
||||
const tryOpenGalleryEntry = async () => {
|
||||
const entry = await getRpgEntryWorldGalleryDetailByCode(normalizedKeyword);
|
||||
await detailNavigation.openGalleryDetail({
|
||||
ownerUserId: entry.ownerUserId,
|
||||
profileId: entry.profileId,
|
||||
publicWorkCode: entry.publicWorkCode,
|
||||
authorPublicUserCode: entry.authorPublicUserCode,
|
||||
visibility: 'published',
|
||||
publishedAt: entry.publishedAt,
|
||||
updatedAt: entry.updatedAt,
|
||||
authorDisplayName: entry.authorDisplayName,
|
||||
worldName: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summaryText: entry.summaryText,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
themeMode: entry.themeMode,
|
||||
playableNpcCount: entry.playableNpcCount,
|
||||
landmarkCount: entry.landmarkCount,
|
||||
} satisfies CustomWorldGalleryCard);
|
||||
};
|
||||
|
||||
try {
|
||||
if (shouldSearchUserIdFirst) {
|
||||
const user = await getPublicAuthUserById(normalizedKeyword);
|
||||
setSearchedPublicUser(user);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldSearchWorkFirst) {
|
||||
try {
|
||||
await tryOpenGalleryEntry();
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (shouldSearchUserFirst) {
|
||||
try {
|
||||
const user = await getPublicAuthUserByCode(normalizedKeyword);
|
||||
setSearchedPublicUser(user);
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!shouldSearchWorkFirst) {
|
||||
await tryOpenGalleryEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await getPublicAuthUserByCode(normalizedKeyword);
|
||||
setSearchedPublicUser(user);
|
||||
} catch (error) {
|
||||
setPublicSearchError(
|
||||
resolveRpgCreationErrorMessage(error, '未找到对应的叙世号或作品号。'),
|
||||
);
|
||||
} finally {
|
||||
setIsSearchingPublicCode(false);
|
||||
}
|
||||
},
|
||||
[detailNavigation],
|
||||
);
|
||||
|
||||
const prepareCreationLaunch = useCallback(() => {
|
||||
if (sessionController.isCreatingAgentSession) {
|
||||
return false;
|
||||
@@ -748,9 +672,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
return galleryResponse.items;
|
||||
} catch (error) {
|
||||
setPuzzleGalleryEntries([]);
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '读取拼图广场失败。'),
|
||||
);
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图广场失败。'));
|
||||
return [];
|
||||
}
|
||||
}, [resolvePuzzleErrorMessage]);
|
||||
@@ -835,7 +757,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
{ session: PuzzleAgentSessionSnapshot },
|
||||
SendPuzzleAgentMessageRequest,
|
||||
PuzzleAgentActionRequest,
|
||||
{ operation: PuzzleAgentOperationRecord; session: PuzzleAgentSessionSnapshot }
|
||||
{
|
||||
operation: PuzzleAgentOperationRecord;
|
||||
session: PuzzleAgentSessionSnapshot;
|
||||
}
|
||||
>({
|
||||
client: {
|
||||
createSession: createPuzzleAgentSession,
|
||||
@@ -1165,11 +1090,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
|
||||
const dragPuzzlePiece = useCallback(
|
||||
(payload: {
|
||||
pieceId: string;
|
||||
targetRow: number;
|
||||
targetCol: number;
|
||||
}) => {
|
||||
(payload: { pieceId: string; targetRow: number; targetCol: number }) => {
|
||||
if (!puzzleRun || isPuzzleBusy) {
|
||||
return;
|
||||
}
|
||||
@@ -1198,7 +1119,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
const { run } = await advanceLocalPuzzleNextLevel({
|
||||
run: puzzleRun,
|
||||
sourceSessionId:
|
||||
selectedPuzzleDetail?.sourceSessionId ?? puzzleSession?.sessionId ?? null,
|
||||
selectedPuzzleDetail?.sourceSessionId ??
|
||||
puzzleSession?.sessionId ??
|
||||
null,
|
||||
});
|
||||
setPuzzleRun(run);
|
||||
} catch (error) {
|
||||
@@ -1293,7 +1216,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
(entry) => entry.profileId === work.profileId,
|
||||
);
|
||||
if (!matchedEntry) {
|
||||
platformBootstrap.setPlatformError('未找到可体验的作品,请刷新后重试。');
|
||||
platformBootstrap.setPlatformError(
|
||||
'未找到可体验的作品,请刷新后重试。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1362,10 +1287,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const deleteTask =
|
||||
work.sourceType === 'published_profile' && work.profileId
|
||||
? deleteRpgEntryWorldProfile(work.profileId).then(async (entries) => {
|
||||
platformBootstrap.setSavedCustomWorldEntries(entries);
|
||||
await platformBootstrap.refreshCustomWorldWorks().catch(() => []);
|
||||
})
|
||||
? deleteRpgEntryWorldProfile(work.profileId).then(
|
||||
async (entries) => {
|
||||
platformBootstrap.setSavedCustomWorldEntries(entries);
|
||||
await platformBootstrap
|
||||
.refreshCustomWorldWorks()
|
||||
.catch(() => []);
|
||||
},
|
||||
)
|
||||
: work.sourceType === 'agent_session' && work.sessionId
|
||||
? deleteRpgCreationAgentSession(work.sessionId).then((items) => {
|
||||
platformBootstrap.setCustomWorldWorkEntries(items);
|
||||
@@ -1446,7 +1375,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
void refreshPuzzleGallery();
|
||||
})
|
||||
.catch((error) => {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '删除拼图作品失败。'));
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '删除拼图作品失败。'),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setDeletingCreationWorkId(null);
|
||||
@@ -1485,12 +1416,136 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleOperation(null);
|
||||
setPuzzleRun(null);
|
||||
setSelectedPuzzleDetail(null);
|
||||
const restoredSession = await puzzleFlow.restoreDraft(item.sourceSessionId);
|
||||
if (!item.sourceSessionId?.trim()) {
|
||||
if (item.publicationStatus === 'published') {
|
||||
await openPuzzleDetail(item.profileId);
|
||||
return;
|
||||
}
|
||||
|
||||
setPuzzleError('这份拼图草稿缺少会话信息,请重新开始创作。');
|
||||
return;
|
||||
}
|
||||
|
||||
const restoredSession = await puzzleFlow.restoreDraft(
|
||||
item.sourceSessionId,
|
||||
);
|
||||
if (!restoredSession) {
|
||||
await refreshPuzzleShelf().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
[puzzleFlow, refreshPuzzleShelf],
|
||||
[openPuzzleDetail, puzzleFlow, refreshPuzzleShelf, setPuzzleError],
|
||||
);
|
||||
|
||||
const handlePublicCodeSearch = useCallback(
|
||||
async (keyword: string) => {
|
||||
const normalizedKeyword = keyword.trim();
|
||||
if (!normalizedKeyword) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearchingPublicCode(true);
|
||||
setPublicSearchError(null);
|
||||
setSearchedPublicUser(null);
|
||||
|
||||
const upperKeyword = normalizedKeyword.toUpperCase();
|
||||
const shouldSearchUserIdFirst = /^user[_-][a-z0-9_-]+$/iu.test(
|
||||
normalizedKeyword,
|
||||
);
|
||||
const shouldSearchPuzzleFirst = upperKeyword.startsWith('PZ');
|
||||
const shouldSearchWorkFirst =
|
||||
!shouldSearchUserIdFirst &&
|
||||
!shouldSearchPuzzleFirst &&
|
||||
(upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword));
|
||||
const shouldSearchUserFirst =
|
||||
shouldSearchUserIdFirst ||
|
||||
upperKeyword.startsWith('SY') ||
|
||||
(!shouldSearchWorkFirst && !shouldSearchPuzzleFirst);
|
||||
|
||||
const tryOpenGalleryEntry = async () => {
|
||||
const entry =
|
||||
await getRpgEntryWorldGalleryDetailByCode(normalizedKeyword);
|
||||
await detailNavigation.openGalleryDetail({
|
||||
ownerUserId: entry.ownerUserId,
|
||||
profileId: entry.profileId,
|
||||
publicWorkCode: entry.publicWorkCode,
|
||||
authorPublicUserCode: entry.authorPublicUserCode,
|
||||
visibility: 'published',
|
||||
publishedAt: entry.publishedAt,
|
||||
updatedAt: entry.updatedAt,
|
||||
authorDisplayName: entry.authorDisplayName,
|
||||
worldName: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summaryText: entry.summaryText,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
themeMode: entry.themeMode,
|
||||
playableNpcCount: entry.playableNpcCount,
|
||||
landmarkCount: entry.landmarkCount,
|
||||
} satisfies CustomWorldGalleryCard);
|
||||
};
|
||||
const tryOpenPuzzleGalleryEntry = async () => {
|
||||
const entries =
|
||||
puzzleGalleryEntries.length > 0
|
||||
? puzzleGalleryEntries
|
||||
: await refreshPuzzleGallery();
|
||||
const matchedEntry = entries.find((entry) =>
|
||||
isSamePuzzlePublicWorkCode(normalizedKeyword, entry.profileId),
|
||||
);
|
||||
|
||||
if (!matchedEntry) {
|
||||
throw new Error('未找到拼图作品。');
|
||||
}
|
||||
|
||||
await openPuzzleDetail(matchedEntry.profileId);
|
||||
};
|
||||
|
||||
try {
|
||||
if (shouldSearchUserIdFirst) {
|
||||
const user = await getPublicAuthUserById(normalizedKeyword);
|
||||
setSearchedPublicUser(user);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldSearchPuzzleFirst) {
|
||||
await tryOpenPuzzleGalleryEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldSearchWorkFirst) {
|
||||
try {
|
||||
await tryOpenGalleryEntry();
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (shouldSearchUserFirst) {
|
||||
try {
|
||||
const user = await getPublicAuthUserByCode(normalizedKeyword);
|
||||
setSearchedPublicUser(user);
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!shouldSearchWorkFirst) {
|
||||
await tryOpenGalleryEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await getPublicAuthUserByCode(normalizedKeyword);
|
||||
setSearchedPublicUser(user);
|
||||
} catch (error) {
|
||||
setPublicSearchError(
|
||||
resolveRpgCreationErrorMessage(error, '未找到对应的叙世号或作品号。'),
|
||||
);
|
||||
} finally {
|
||||
setIsSearchingPublicCode(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
detailNavigation,
|
||||
openPuzzleDetail,
|
||||
puzzleGalleryEntries,
|
||||
refreshPuzzleGallery,
|
||||
],
|
||||
);
|
||||
|
||||
const openBigFishDraft = useCallback(
|
||||
@@ -1632,6 +1687,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
onExperienceRpg={(item) => {
|
||||
handleExperienceRpgWork(item);
|
||||
}}
|
||||
rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries}
|
||||
bigFishItems={bigFishWorks}
|
||||
onOpenBigFishDetail={(item) => {
|
||||
runProtectedAction(() => {
|
||||
@@ -1649,11 +1705,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
puzzleItems={puzzleWorks}
|
||||
onOpenPuzzleDetail={(item) => {
|
||||
runProtectedAction(() => {
|
||||
if (item.publicationStatus === 'draft') {
|
||||
void openPuzzleDraft(item);
|
||||
return;
|
||||
}
|
||||
void openPuzzleDetail(item.profileId);
|
||||
void openPuzzleDraft(item);
|
||||
});
|
||||
}}
|
||||
onExperiencePuzzle={(profileId) => {
|
||||
@@ -1894,13 +1946,17 @@ export function PlatformEntryFlowShellImpl({
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载大鱼吃小鱼生成面板..." />}
|
||||
fallback={
|
||||
<LazyPanelFallback label="正在加载大鱼吃小鱼生成面板..." />
|
||||
}
|
||||
>
|
||||
<CustomWorldGenerationView
|
||||
settingText={
|
||||
bigFishSession?.lastAssistantReply ?? '正在整理当前玩法草稿。'
|
||||
}
|
||||
anchorEntries={buildBigFishGenerationAnchorEntries(bigFishSession)}
|
||||
anchorEntries={buildBigFishGenerationAnchorEntries(
|
||||
bigFishSession,
|
||||
)}
|
||||
progress={buildMiniGameDraftGenerationProgress(
|
||||
bigFishGenerationState,
|
||||
)}
|
||||
@@ -1911,7 +1967,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage('big-fish-agent-workspace');
|
||||
}}
|
||||
onRetry={() => {
|
||||
void executeBigFishAction({ action: 'big_fish_compile_draft' });
|
||||
void executeBigFishAction({
|
||||
action: 'big_fish_compile_draft',
|
||||
});
|
||||
}}
|
||||
onInterrupt={undefined}
|
||||
backLabel="返回创作中心"
|
||||
@@ -2026,7 +2084,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
settingText={
|
||||
puzzleSession?.lastAssistantReply ?? '正在整理当前拼图草稿。'
|
||||
}
|
||||
anchorEntries={buildPuzzleGenerationAnchorEntries(puzzleSession)}
|
||||
anchorEntries={buildPuzzleGenerationAnchorEntries(
|
||||
puzzleSession,
|
||||
)}
|
||||
progress={buildMiniGameDraftGenerationProgress(
|
||||
puzzleGenerationState,
|
||||
)}
|
||||
@@ -2093,6 +2153,16 @@ export function PlatformEntryFlowShellImpl({
|
||||
enterCreateTab();
|
||||
setSelectionStage('platform');
|
||||
}}
|
||||
onEdit={
|
||||
selectedPuzzleDetail.ownerUserId === authUi?.user?.id &&
|
||||
Boolean(selectedPuzzleDetail.sourceSessionId?.trim())
|
||||
? () => {
|
||||
runProtectedAction(() => {
|
||||
void openPuzzleDraft(selectedPuzzleDetail);
|
||||
});
|
||||
}
|
||||
: null
|
||||
}
|
||||
onStartGame={() => {
|
||||
void startPuzzleRunFromProfile(selectedPuzzleDetail.profileId);
|
||||
}}
|
||||
@@ -2271,15 +2341,17 @@ export function PlatformEntryFlowShellImpl({
|
||||
? 'generate_landmarks'
|
||||
: 'generate_characters';
|
||||
const latestSession =
|
||||
await autosaveCoordinator.executeAgentActionAndWait({
|
||||
action,
|
||||
count: 1,
|
||||
...(kind === 'playable'
|
||||
? { roleType: 'playable' as const }
|
||||
: kind === 'story'
|
||||
? { roleType: 'story' as const }
|
||||
: {}),
|
||||
});
|
||||
await autosaveCoordinator.executeAgentActionAndWait(
|
||||
{
|
||||
action,
|
||||
count: 1,
|
||||
...(kind === 'playable'
|
||||
? { roleType: 'playable' as const }
|
||||
: kind === 'story'
|
||||
? { roleType: 'story' as const }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
const latestProfile = latestSession
|
||||
? rpgCreationPreviewAdapter.buildPreviewFromSession(
|
||||
latestSession,
|
||||
|
||||
@@ -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 { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type PuzzleGalleryDetailViewProps = {
|
||||
@@ -8,9 +9,18 @@ type PuzzleGalleryDetailViewProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onEdit?: (() => void) | null;
|
||||
onStartGame: () => void;
|
||||
};
|
||||
|
||||
function copyText(value: string) {
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
|
||||
return;
|
||||
}
|
||||
|
||||
void navigator.clipboard.writeText(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼图广场详情页。
|
||||
* 展示最小信息并提供进入游戏动作,不扩展评论、收藏等非本轮需求。
|
||||
@@ -20,8 +30,11 @@ export function PuzzleGalleryDetailView({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onEdit = null,
|
||||
onStartGame,
|
||||
}: PuzzleGalleryDetailViewProps) {
|
||||
const publicWorkCode = buildPuzzlePublicWorkCode(item.profileId);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
|
||||
<div className="relative overflow-hidden rounded-[1.8rem] border border-amber-100/16 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_32%),linear-gradient(135deg,rgba(76,29,19,0.98),rgba(15,23,42,0.98))] px-4 py-4 text-white sm:px-5">
|
||||
@@ -33,15 +46,28 @@ export function PuzzleGalleryDetailView({
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={onStartGame}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
进入第 1 关
|
||||
</button>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{onEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={onEdit}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-white/12 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
修改作品
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={onStartGame}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
进入第 1 关
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
@@ -54,6 +80,17 @@ export function PuzzleGalleryDetailView({
|
||||
{item.authorDisplayName}
|
||||
</span>
|
||||
<span>{item.playCount} 次游玩</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(publicWorkCode)}
|
||||
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-white/14 bg-white/10 px-3 py-1 text-sm text-amber-50/86"
|
||||
aria-label={`复制作品号 ${publicWorkCode}`}
|
||||
title="复制作品号"
|
||||
>
|
||||
<span className="shrink-0">作品号</span>
|
||||
<span className="min-w-0 truncate">{publicWorkCode}</span>
|
||||
<Copy className="h-3.5 w-3.5 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
listPuzzleGallery,
|
||||
} from '../../services/puzzle-gallery';
|
||||
import { listPuzzleWorks } from '../../services/puzzle-works';
|
||||
import { getRpgEntryWorldGalleryDetailByCode } from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||
import type { GameState } from '../../types';
|
||||
import {
|
||||
AuthUiContext,
|
||||
@@ -128,6 +129,10 @@ vi.mock('../../services/puzzle-gallery', () => ({
|
||||
listPuzzleGallery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/rpg-entry/rpgEntryLibraryClient', () => ({
|
||||
getRpgEntryWorldGalleryDetailByCode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/big-fish-creation', () => ({
|
||||
createBigFishCreationSession: vi.fn(),
|
||||
executeBigFishCreationAction: vi.fn(),
|
||||
@@ -575,6 +580,9 @@ beforeEach(() => {
|
||||
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
|
||||
vi.mocked(getRpgEntryWorldGalleryDetailByCode).mockRejectedValue(
|
||||
new Error('未找到公开作品'),
|
||||
);
|
||||
vi.mocked(upsertRpgWorldProfile).mockResolvedValue({
|
||||
entry: {
|
||||
ownerUserId: 'user-1',
|
||||
@@ -1047,9 +1055,7 @@ test('opening RPG agent workspace does not refetch session snapshot in a render
|
||||
expect(getRpgCreationSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test(
|
||||
'create tab opens compiled agent draft in result refinement page',
|
||||
async () => {
|
||||
test('create tab opens compiled agent draft in result refinement page', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||||
@@ -1102,9 +1108,7 @@ test(
|
||||
screen.queryByText('Agent工作区:custom-world-agent-session-1'),
|
||||
).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /返回创作/u })).toBeTruthy();
|
||||
},
|
||||
10000,
|
||||
);
|
||||
}, 10000);
|
||||
|
||||
test('create tab resumes agent workspace when draft has no compiled result yet', async () => {
|
||||
const user = userEvent.setup();
|
||||
@@ -1432,7 +1436,9 @@ test('published puzzle works appear on home and category public shelves', async
|
||||
await user.click(screen.getByRole('button', { name: '分类' }));
|
||||
|
||||
const categoryPanel = getPlatformTabPanel('category');
|
||||
expect(within(categoryPanel).getAllByText('星桥机关').length).toBeGreaterThan(0);
|
||||
expect(within(categoryPanel).getAllByText('星桥机关').length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
within(categoryPanel).getAllByRole('button', { name: /机关/u }).length,
|
||||
).toBeGreaterThan(0);
|
||||
@@ -1497,8 +1503,9 @@ test('restoring an agent workspace ignores a stored session owned by another use
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.sessionStorage.getItem('genarrative.custom-world-agent-ui.v1'))
|
||||
.toBeNull();
|
||||
expect(
|
||||
window.sessionStorage.getItem('genarrative.custom-world-agent-ui.v1'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
expect(getRpgCreationSession).not.toHaveBeenCalled();
|
||||
@@ -1633,6 +1640,93 @@ test('puzzle draft card restores the bound agent session and opens the result vi
|
||||
expect(screen.queryByText('拼图玩法共创')).toBeNull();
|
||||
});
|
||||
|
||||
test('published puzzle work card restores its source session for editing', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
workId: 'puzzle-work-session-1',
|
||||
profileId: 'puzzle-profile-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session-1',
|
||||
authorDisplayName: '测试玩家',
|
||||
levelName: '雨夜猫塔',
|
||||
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
|
||||
themeTags: ['雨夜', '猫咪', '遗迹'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T12:10:00.000Z',
|
||||
publishedAt: '2026-04-25T12:10:00.000Z',
|
||||
playCount: 8,
|
||||
publishReady: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /继续创作/u })).toBeTruthy();
|
||||
await user.click(await screen.findByRole('button', { name: /继续创作/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1');
|
||||
});
|
||||
|
||||
expect(getPuzzleGalleryDetail).not.toHaveBeenCalledWith(
|
||||
'puzzle-profile-session-1',
|
||||
);
|
||||
expect(await screen.findByText('拼图结果页')).toBeTruthy();
|
||||
expect(screen.getByDisplayValue('雨夜猫塔')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('public code search opens a published puzzle by PZ code', async () => {
|
||||
const user = userEvent.setup();
|
||||
const puzzleWork: PuzzleWorkSummary = {
|
||||
workId: 'puzzle-work-public-1',
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: null,
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '雨夜猫塔',
|
||||
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
|
||||
themeTags: ['雨夜', '猫咪', '遗迹'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T12:10:00.000Z',
|
||||
publishedAt: '2026-04-25T12:10:00.000Z',
|
||||
playCount: 8,
|
||||
publishReady: true,
|
||||
};
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [puzzleWork],
|
||||
});
|
||||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||||
item: puzzleWork,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput =
|
||||
await screen.findByPlaceholderText('输入 SY / CW / PZ 编号');
|
||||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getPuzzleGalleryDetail).toHaveBeenCalledWith(
|
||||
'puzzle-profile-public-1',
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText('进入第 1 关')).toBeTruthy();
|
||||
expect(screen.getByText('雨夜猫塔')).toBeTruthy();
|
||||
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('big fish draft card restores the bound agent session and opens the result view', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -1679,11 +1773,11 @@ test('big fish draft card restores the bound agent session and opens the result
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
expect(await screen.findByText('大鱼吃小鱼工作区:big-fish-session-1')).toBeTruthy();
|
||||
expect(screen.queryByText(/大鱼吃小鱼共创/u)).toBeNull();
|
||||
expect(
|
||||
screen.getByText('我想做机械深海里微生物互相吞并进化。'),
|
||||
await screen.findByText('大鱼吃小鱼工作区:big-fish-session-1'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText(/大鱼吃小鱼共创/u)).toBeNull();
|
||||
expect(screen.getByText('我想做机械深海里微生物互相吞并进化。')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
|
||||
@@ -1882,7 +1976,9 @@ test('agent result view shows publish blocker dialog before publish action when
|
||||
sessionId === 'custom-world-agent-session-1' &&
|
||||
payload?.action === 'publish_world',
|
||||
).length;
|
||||
expect(publishWorldCallCountAfterClick).toBe(publishWorldCallCountBeforeClick);
|
||||
expect(publishWorldCallCountAfterClick).toBe(
|
||||
publishWorldCallCountBeforeClick,
|
||||
);
|
||||
});
|
||||
|
||||
test('agent draft result publishes to gallery from publish panel', async () => {
|
||||
@@ -1918,6 +2014,9 @@ test('agent draft result publishes to gallery from publish panel', async () => {
|
||||
} satisfies CustomWorldAgentSessionSnapshot;
|
||||
|
||||
let hasPublishedWorld = false;
|
||||
vi.mocked(createRpgCreationSession).mockResolvedValue({
|
||||
session: publishReadyDraftSession,
|
||||
});
|
||||
vi.mocked(getRpgCreationOperation).mockResolvedValueOnce({
|
||||
operationId: 'operation-publish-world-1',
|
||||
type: 'publish_world',
|
||||
@@ -1972,9 +2071,13 @@ test('agent draft result publishes to gallery from publish panel', async () => {
|
||||
|
||||
await openNewRpgCreation(user);
|
||||
|
||||
const actionButton = await screen.findByRole('button', {
|
||||
name: '发布',
|
||||
});
|
||||
const actionButton = await screen.findByRole(
|
||||
'button',
|
||||
{
|
||||
name: '发布',
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await user.click(actionButton);
|
||||
await user.click(await screen.findByRole('button', { name: '发布到广场' }));
|
||||
|
||||
@@ -2569,8 +2672,8 @@ test('agent draft result can open from server result preview without embedded le
|
||||
expect(await screen.findByText('世界档案')).toBeTruthy();
|
||||
expect(screen.getByText('潮雾列岛·服务端预览')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('结果页改为优先消费 session.resultPreview'),
|
||||
).toBeTruthy();
|
||||
screen.getAllByText('结果页改为优先消费 session.resultPreview').length,
|
||||
).toBeGreaterThan(0);
|
||||
},
|
||||
{ timeout: 2500 },
|
||||
);
|
||||
@@ -2900,9 +3003,7 @@ test('creation hub published work experience button enters world directly', asyn
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />,
|
||||
);
|
||||
render(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await user.click(await screen.findByRole('button', { name: '体验' }));
|
||||
|
||||
@@ -5,7 +5,8 @@ import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import { RpgEntryHomeView } from './RpgEntryHomeView';
|
||||
import { RpgEntryHomeView, type RpgEntryHomeViewProps } from './RpgEntryHomeView';
|
||||
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
|
||||
|
||||
vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
getRpgProfileRechargeCenter: vi.fn(async () => ({
|
||||
@@ -93,6 +94,43 @@ vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: () => null,
|
||||
}));
|
||||
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
const originalClipboard = navigator.clipboard;
|
||||
|
||||
const puzzlePublicEntry = {
|
||||
sourceType: 'puzzle',
|
||||
workId: 'puzzle-work-public-1',
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
publicWorkCode: 'PZ-EPUBLIC1',
|
||||
ownerUserId: 'user-2',
|
||||
authorDisplayName: '拼图玩家',
|
||||
worldName: '奇幻拼图',
|
||||
subtitle: '拼图关卡',
|
||||
summaryText: '一张用于公开分享的拼图作品。',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['奇幻'],
|
||||
visibility: 'published',
|
||||
publishedAt: '1777110165.990127Z',
|
||||
updatedAt: '2026-04-25T10:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
function mockDesktopLayout() {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(() => ({
|
||||
matches: true,
|
||||
media: '(min-width: 1024px)',
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function renderProfileView(onRechargeSuccess = vi.fn()) {
|
||||
return render(
|
||||
<AuthUiContext.Provider
|
||||
@@ -157,7 +195,18 @@ function renderProfileView(onRechargeSuccess = vi.fn()) {
|
||||
);
|
||||
}
|
||||
|
||||
function renderLoggedOutHomeView(openLoginModal = vi.fn()) {
|
||||
function renderLoggedOutHomeView(
|
||||
openLoginModal = vi.fn(),
|
||||
overrides: Partial<
|
||||
Pick<
|
||||
RpgEntryHomeViewProps,
|
||||
| 'featuredEntries'
|
||||
| 'latestEntries'
|
||||
| 'onOpenGalleryDetail'
|
||||
| 'onSearchPublicCode'
|
||||
>
|
||||
> = {},
|
||||
) {
|
||||
return render(
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
@@ -176,6 +225,96 @@ function renderLoggedOutHomeView(openLoginModal = vi.fn()) {
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
}}
|
||||
>
|
||||
<RpgEntryHomeView
|
||||
activeTab="home"
|
||||
onTabChange={vi.fn()}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
saveEntries={[]}
|
||||
saveError={null}
|
||||
featuredEntries={overrides.featuredEntries ?? []}
|
||||
latestEntries={overrides.latestEntries ?? []}
|
||||
myEntries={[]}
|
||||
historyEntries={[]}
|
||||
profileDashboard={null}
|
||||
isLoadingPlatform={false}
|
||||
isLoadingDashboard={false}
|
||||
isResumingSaveWorldKey={null}
|
||||
platformError={null}
|
||||
dashboardError={null}
|
||||
onContinueGame={vi.fn()}
|
||||
onResumeSave={vi.fn()}
|
||||
onOpenCreateWorld={vi.fn()}
|
||||
onOpenCreateTypePicker={vi.fn()}
|
||||
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalMatchMedia,
|
||||
});
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: originalClipboard,
|
||||
});
|
||||
});
|
||||
|
||||
test('opens recharge modal and submits points product', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
|
||||
renderProfileView(onRechargeSuccess);
|
||||
await user.click(screen.getByText('会员充值'));
|
||||
|
||||
expect(await screen.findByText('账户充值')).toBeTruthy();
|
||||
expect(await screen.findByText('10积分')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByText('首充送19积分'));
|
||||
|
||||
await waitFor(() => expect(onRechargeSuccess).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test('shows a reachable login entry in logged out mobile shell', async () => {
|
||||
const user = userEvent.setup();
|
||||
const openLoginModal = vi.fn();
|
||||
|
||||
renderLoggedOutHomeView(openLoginModal);
|
||||
await user.click(screen.getByRole('button', { name: '登录' }));
|
||||
|
||||
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('mobile home search submits public work code', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchPublicCode = vi.fn();
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: vi.fn(),
|
||||
requireAuth: vi.fn(),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
logout: vi.fn(async () => undefined),
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: vi.fn(),
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: vi.fn(),
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
}}
|
||||
>
|
||||
<RpgEntryHomeView
|
||||
activeTab="home"
|
||||
@@ -200,37 +339,46 @@ function renderLoggedOutHomeView(openLoginModal = vi.fn()) {
|
||||
onOpenCreateTypePicker={vi.fn()}
|
||||
onOpenGalleryDetail={vi.fn()}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={vi.fn()}
|
||||
onSearchPublicCode={onSearchPublicCode}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const searchInput = screen.getByPlaceholderText('输入 SY / CW / PZ 编号');
|
||||
await user.type(searchInput, 'PZ-PROFILE1{enter}');
|
||||
|
||||
expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1');
|
||||
});
|
||||
|
||||
test('opens recharge modal and submits points product', async () => {
|
||||
test('public work code badge copies without opening gallery detail', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
const writeText = vi.fn(async () => undefined);
|
||||
const onOpenGalleryDetail = vi.fn();
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
|
||||
renderProfileView(onRechargeSuccess);
|
||||
await user.click(screen.getByText('会员充值'));
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
onOpenGalleryDetail,
|
||||
});
|
||||
|
||||
expect(await screen.findByText('账户充值')).toBeTruthy();
|
||||
expect(await screen.findByText('10积分')).toBeTruthy();
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('首充送19积分'));
|
||||
|
||||
await waitFor(() => expect(onRechargeSuccess).toHaveBeenCalledTimes(1));
|
||||
expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1');
|
||||
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('shows a reachable login entry in logged out mobile shell', async () => {
|
||||
const user = userEvent.setup();
|
||||
const openLoginModal = vi.fn();
|
||||
test('desktop trending list shows public code instead of timestamp text', () => {
|
||||
mockDesktopLayout();
|
||||
|
||||
renderLoggedOutHomeView(openLoginModal);
|
||||
await user.click(screen.getByRole('button', { name: '登录' }));
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
});
|
||||
|
||||
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getAllByText('PZ-EPUBLIC1').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('1777110165.990127Z')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type ComponentType,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
describePlatformThemeLabel,
|
||||
formatPlatformWorldTime,
|
||||
isPuzzleGalleryEntry,
|
||||
resolvePlatformPublicWorkCode,
|
||||
type PlatformPublicGalleryCard,
|
||||
type PlatformWorldCardLike,
|
||||
resolvePlatformWorldCoverImage,
|
||||
@@ -191,6 +193,48 @@ function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PublicCodeSearchBar({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
isSearching,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
isSearching: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`platform-desktop-search flex min-w-0 items-center gap-3 px-4 py-3 text-[var(--platform-text-soft)] ${className ?? ''}`}
|
||||
>
|
||||
<Search className="h-4 w-4 shrink-0" />
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="输入 SY / CW / PZ 编号"
|
||||
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={!value.trim() || isSearching}
|
||||
className="shrink-0 text-xs font-semibold text-[var(--platform-text-soft)] disabled:opacity-50"
|
||||
>
|
||||
{isSearching ? '搜索中' : '搜索'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyShelf({ text }: { text: string }) {
|
||||
return (
|
||||
<div
|
||||
@@ -249,6 +293,8 @@ function WorldCard({
|
||||
}) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||||
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
|
||||
const badgeLabel = publicWorkCode ?? badge;
|
||||
const tags = [
|
||||
...new Set(
|
||||
buildPlatformWorldTags(entry)
|
||||
@@ -257,10 +303,25 @@ function WorldCard({
|
||||
),
|
||||
].slice(0, 3);
|
||||
|
||||
const openCardByKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={openCardByKeyboard}
|
||||
className={`platform-surface platform-interactive-card relative flex h-[15rem] w-[min(15.25rem,78vw)] shrink-0 flex-col overflow-hidden px-3.5 py-3.5 text-left ${className ?? ''}`}
|
||||
>
|
||||
{coverImage ? (
|
||||
@@ -281,7 +342,25 @@ function WorldCard({
|
||||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="platform-pill platform-pill--warm">{badge}</span>
|
||||
{publicWorkCode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
copyText(publicWorkCode);
|
||||
}}
|
||||
className="platform-pill platform-pill--warm flex max-w-[10rem] items-center gap-1.5 px-3 py-1"
|
||||
aria-label={`复制作品号 ${publicWorkCode}`}
|
||||
title="复制作品号"
|
||||
>
|
||||
<span className="min-w-0 truncate">{publicWorkCode}</span>
|
||||
<Copy className="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="platform-pill platform-pill--warm max-w-[8.5rem] truncate">
|
||||
{badgeLabel}
|
||||
</span>
|
||||
)}
|
||||
<span className="platform-pill platform-pill--neutral px-2.5">
|
||||
{metaLabel}
|
||||
</span>
|
||||
@@ -316,7 +395,7 @@ function WorldCard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -572,6 +651,7 @@ function DesktopTrendingItem({
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
|
||||
const tags = buildPlatformWorldTags(entry).filter(Boolean).slice(0, 2);
|
||||
|
||||
return (
|
||||
@@ -594,7 +674,9 @@ function DesktopTrendingItem({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
<span>{`${rank}`.padStart(2, '0')}</span>
|
||||
<span>{formatPlatformWorldTime(entry.publishedAt)}</span>
|
||||
<span className="truncate">
|
||||
{publicWorkCode ?? describePublicGalleryCardKind(entry)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-1 text-lg font-semibold text-[var(--platform-text-strong)]">
|
||||
{entry.worldName}
|
||||
@@ -1050,6 +1132,7 @@ export function RpgEntryHomeView({
|
||||
}: RpgEntryHomeViewProps) {
|
||||
const authUi = useAuthUi();
|
||||
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
|
||||
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
|
||||
const [isRechargeOpen, setIsRechargeOpen] = useState(false);
|
||||
const [rechargeTab, setRechargeTab] = useState<'points' | 'membership'>(
|
||||
'points',
|
||||
@@ -1171,6 +1254,14 @@ export function RpgEntryHomeView({
|
||||
|
||||
void onSearchPublicCode(keyword);
|
||||
};
|
||||
const submitMobileSearch = () => {
|
||||
const keyword = mobileSearchKeyword.trim();
|
||||
if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
void onSearchPublicCode(keyword);
|
||||
};
|
||||
const desktopHeroEntry =
|
||||
featuredShelf[0] ?? latestEntries[0] ?? myEntries[0] ?? null;
|
||||
const desktopHeroCover = desktopHeroEntry
|
||||
@@ -1198,6 +1289,13 @@ export function RpgEntryHomeView({
|
||||
|
||||
const mobileHomeContent: ReactNode = (
|
||||
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage`}>
|
||||
<PublicCodeSearchBar
|
||||
value={mobileSearchKeyword}
|
||||
onChange={setMobileSearchKeyword}
|
||||
onSubmit={submitMobileSearch}
|
||||
isSearching={isSearchingPublicCode}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openLeadPublicEntry}
|
||||
@@ -1269,7 +1367,7 @@ export function RpgEntryHomeView({
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:latest`}
|
||||
entry={entry}
|
||||
badge={formatPlatformWorldTime(entry.publishedAt)}
|
||||
badge={describePublicGalleryCardKind(entry)}
|
||||
metaLabel={entry.authorDisplayName}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
/>
|
||||
@@ -1865,7 +1963,7 @@ export function RpgEntryHomeView({
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-latest`}
|
||||
entry={entry}
|
||||
badge={formatPlatformWorldTime(entry.publishedAt)}
|
||||
badge={describePublicGalleryCardKind(entry)}
|
||||
metaLabel={entry.authorDisplayName}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
className="h-[17rem] w-full min-w-0"
|
||||
@@ -1960,35 +2058,15 @@ export function RpgEntryHomeView({
|
||||
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-5">
|
||||
<RpgEntryBrandLogo className="shrink-0" decorative />
|
||||
<div className="platform-desktop-search flex min-w-0 max-w-[34rem] flex-1 items-center gap-3 px-4 py-3 text-[var(--platform-text-soft)]">
|
||||
<Search className="h-4 w-4 shrink-0" />
|
||||
<input
|
||||
value={desktopSearchKeyword}
|
||||
onChange={(event) =>
|
||||
setDesktopSearchKeyword(event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
submitDesktopSearch();
|
||||
}
|
||||
}}
|
||||
placeholder="输入 SY 或 CW 编号"
|
||||
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitDesktopSearch}
|
||||
disabled={
|
||||
!desktopSearchKeyword.trim() ||
|
||||
!onSearchPublicCode ||
|
||||
isSearchingPublicCode
|
||||
}
|
||||
className="shrink-0 text-xs font-semibold text-[var(--platform-text-soft)] disabled:opacity-50"
|
||||
>
|
||||
{isSearchingPublicCode ? '搜索中' : '搜索'}
|
||||
</button>
|
||||
</div>
|
||||
<PublicCodeSearchBar
|
||||
value={desktopSearchKeyword}
|
||||
onChange={setDesktopSearchKeyword}
|
||||
onSubmit={submitDesktopSearch}
|
||||
isSearching={
|
||||
!onSearchPublicCode || Boolean(isSearchingPublicCode)
|
||||
}
|
||||
className="max-w-[34rem] flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { ArrowLeft, Copy } from 'lucide-react';
|
||||
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildPlatformWorldTags,
|
||||
describePlatformThemeLabel,
|
||||
formatPlatformWorldTime,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorldCoverImage,
|
||||
resolvePlatformWorldLeadPortrait,
|
||||
} from './rpgEntryWorldPresentation';
|
||||
@@ -24,6 +25,14 @@ export interface RpgEntryWorldDetailViewProps {
|
||||
onUnpublish?: (() => void) | null;
|
||||
}
|
||||
|
||||
function copyText(value: string) {
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
|
||||
return;
|
||||
}
|
||||
|
||||
void navigator.clipboard.writeText(value);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
onClick,
|
||||
@@ -67,6 +76,7 @@ export function RpgEntryWorldDetailView({
|
||||
}: RpgEntryWorldDetailViewProps) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||||
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
|
||||
const canStartGame = entry.visibility === 'published';
|
||||
const previewCharacters = buildCustomWorldPlayableCharacters(
|
||||
entry.profile,
|
||||
@@ -128,6 +138,16 @@ export function RpgEntryWorldDetailView({
|
||||
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
|
||||
: '仅自己可见'}
|
||||
</span>
|
||||
{publicWorkCode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(publicWorkCode)}
|
||||
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
|
||||
>
|
||||
<span>作品号 {publicWorkCode}</span>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4 text-3xl font-black text-white">
|
||||
{entry.worldName}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type PlatformWorldCardLike =
|
||||
@@ -16,6 +17,7 @@ export type PlatformPuzzleGalleryCard = {
|
||||
sourceType: 'puzzle';
|
||||
workId: string;
|
||||
profileId: string;
|
||||
publicWorkCode: string;
|
||||
ownerUserId: string;
|
||||
authorDisplayName: string;
|
||||
worldName: string;
|
||||
@@ -51,6 +53,7 @@ export function mapPuzzleWorkToPlatformGalleryCard(
|
||||
sourceType: 'puzzle',
|
||||
workId: work.workId,
|
||||
profileId: work.profileId,
|
||||
publicWorkCode: buildPuzzlePublicWorkCode(work.profileId),
|
||||
ownerUserId: work.ownerUserId,
|
||||
authorDisplayName: work.authorDisplayName,
|
||||
worldName: work.levelName,
|
||||
@@ -122,6 +125,16 @@ export function formatPlatformWorldTime(value: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
export function resolvePlatformPublicWorkCode(
|
||||
entry: PlatformWorldCardLike,
|
||||
): string | null {
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
export function describePlatformThemeLabel(
|
||||
themeMode: CustomWorldGalleryCard['themeMode'],
|
||||
) {
|
||||
|
||||
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('被海雾吞没的旧航路群岛');
|
||||
});
|
||||
|
||||
test('buildRpgCreationPreviewFromSession reads draftProfile directly', () => {
|
||||
test('buildRpgCreationPreviewFromSession prefers server result preview', () => {
|
||||
const profile = buildRpgCreationPreviewFromSession(sessionWithPreview);
|
||||
|
||||
expect(profile?.name).toBe('只作为 fallback 的本地草稿名');
|
||||
expect(profile?.name).not.toBe('服务端结果预览');
|
||||
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
|
||||
'/generated-characters/draft-playable-1/portrait.png',
|
||||
);
|
||||
expect(profile?.name).toBe('服务端结果预览');
|
||||
expect(profile?.summary).toBe('结果页应该优先消费 session.resultPreview。');
|
||||
expect(profile?.id).toBe('preview-profile-1');
|
||||
});
|
||||
|
||||
test('buildRpgCreationPreviewFromSession does not require resultPreview', () => {
|
||||
|
||||
@@ -3,24 +3,26 @@ import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export function buildCustomWorldProfileFromResultPreview(
|
||||
resultPreview: CustomWorldAgentSessionSnapshot['resultPreview'] | null | undefined,
|
||||
resultPreview:
|
||||
| CustomWorldAgentSessionSnapshot['resultPreview']
|
||||
| null
|
||||
| undefined,
|
||||
): CustomWorldProfile | null {
|
||||
return normalizeCustomWorldProfileRecord(resultPreview?.preview ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG 运行时直接读取 Agent session 的 draftProfile。
|
||||
* resultPreview 只作为质量/发布信息外壳,不再参与进入游戏 profile 的数据转换。
|
||||
*/
|
||||
export function buildCustomWorldProfileFromAgentSession(
|
||||
session: CustomWorldAgentSessionSnapshot | null | undefined,
|
||||
): CustomWorldProfile | null {
|
||||
return normalizeCustomWorldProfileRecord(session?.draftProfile ?? null);
|
||||
return (
|
||||
buildCustomWorldProfileFromResultPreview(session?.resultPreview) ??
|
||||
normalizeCustomWorldProfileRecord(session?.draftProfile ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 这是工作包 A 提供的新命名兼容层。
|
||||
* 主入口保持命名稳定,但数据来源已经收敛为 draftProfile 单一真相源。
|
||||
* 主入口保持命名稳定,优先消费服务端 resultPreview,缺失时回退到 draftProfile。
|
||||
*/
|
||||
export const rpgCreationPreviewAdapter = {
|
||||
buildPreviewFromSession: buildCustomWorldProfileFromAgentSession,
|
||||
|
||||
Reference in New Issue
Block a user