Merge remote-tracking branch 'origin/master' into feat/recommend-runtime-guest
# Conflicts: # docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
This commit is contained in:
@@ -25,6 +25,14 @@ const testEntryConfig = {
|
||||
title: '选择创作类型',
|
||||
description: '先选玩法类型,再进入对应创作工作台。',
|
||||
},
|
||||
eventBanner: {
|
||||
title: '泥点挑战',
|
||||
description: '创作活动测试横幅。',
|
||||
coverImageSrc: '/creation-type-references/puzzle.webp',
|
||||
prizePoolMudPoints: 1000,
|
||||
startsAtText: '2026-05-01',
|
||||
endsAtText: '2026-05-31',
|
||||
},
|
||||
creationTypes: [
|
||||
{
|
||||
id: 'rpg',
|
||||
@@ -35,6 +43,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -46,6 +57,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 30,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -57,6 +71,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 40,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -68,6 +85,9 @@ const testEntryConfig = {
|
||||
visible: false,
|
||||
open: true,
|
||||
sortOrder: 50,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -79,6 +99,9 @@ const testEntryConfig = {
|
||||
visible: false,
|
||||
open: false,
|
||||
sortOrder: 60,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -90,6 +113,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: false,
|
||||
sortOrder: 70,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
],
|
||||
@@ -665,17 +691,17 @@ test('creation hub works-only tab filters bark battle draft and published works'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '全部 2' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '草稿 1' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '已发布 1' })).toBeTruthy();
|
||||
expect(screen.getByRole('tab', { name: '全部 2' })).toBeTruthy();
|
||||
expect(screen.getByRole('tab', { name: '草稿 1' })).toBeTruthy();
|
||||
expect(screen.getByRole('tab', { name: '已发布 1' })).toBeTruthy();
|
||||
expect(screen.getByText('竖屏声浪草稿')).toBeTruthy();
|
||||
expect(screen.getByText('竖屏声浪已发布')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '草稿 1' }));
|
||||
await user.click(screen.getByRole('tab', { name: '草稿 1' }));
|
||||
expect(screen.getByText('竖屏声浪草稿')).toBeTruthy();
|
||||
expect(screen.queryByText('竖屏声浪已发布')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '已发布 1' }));
|
||||
await user.click(screen.getByRole('tab', { name: '已发布 1' }));
|
||||
expect(screen.queryByText('竖屏声浪草稿')).toBeNull();
|
||||
expect(screen.getByText('竖屏声浪已发布')).toBeTruthy();
|
||||
|
||||
@@ -880,6 +906,38 @@ test('creation hub published share icon is shown directly on the card header', (
|
||||
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
|
||||
});
|
||||
|
||||
test('creation hub shows RPG published share icon without 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={[]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('潮雾列岛已发布版')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '分享' })).toBeTruthy();
|
||||
expect(screen.queryByText('作者:玩家')).toBeNull();
|
||||
});
|
||||
|
||||
test('creation hub left swipe draft reveals delete without opening card', () => {
|
||||
const onDeletePublished = vi.fn();
|
||||
const onOpenDraft = vi.fn();
|
||||
|
||||
@@ -18,6 +18,14 @@ const testEntryConfig = {
|
||||
title: '选择创作类型',
|
||||
description: '先选玩法类型,再进入对应创作工作台。',
|
||||
},
|
||||
eventBanner: {
|
||||
title: '泥点挑战',
|
||||
description: '创作活动测试横幅。',
|
||||
coverImageSrc: '/creation-type-references/puzzle.webp',
|
||||
prizePoolMudPoints: 1000,
|
||||
startsAtText: '2026-05-01',
|
||||
endsAtText: '2026-05-31',
|
||||
},
|
||||
creationTypes: [
|
||||
{
|
||||
id: 'rpg',
|
||||
@@ -28,6 +36,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -39,17 +50,23 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 30,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'match3d',
|
||||
title: '抓大鹅',
|
||||
subtitle: '3D 消除关卡',
|
||||
badge: '可创建',
|
||||
badge: '可创作',
|
||||
imageSrc: '/creation-type-references/match3d.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 40,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -61,6 +78,9 @@ const testEntryConfig = {
|
||||
visible: false,
|
||||
open: true,
|
||||
sortOrder: 50,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -72,6 +92,9 @@ const testEntryConfig = {
|
||||
visible: false,
|
||||
open: false,
|
||||
sortOrder: 60,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -83,6 +106,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: false,
|
||||
sortOrder: 70,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
],
|
||||
@@ -140,6 +166,96 @@ test('creation hub draft card renders compiled work summary fields', () => {
|
||||
expect(html).not.toContain('大鱼吃小鱼');
|
||||
});
|
||||
|
||||
test('creation start card renders reference-aligned banner and template metadata', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
mode="start-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('creation-event-banner');
|
||||
expect(html).toContain('creation-event-banner__track');
|
||||
expect(html).toContain('creation-event-banner__slide');
|
||||
expect(html).toContain('creation-event-banner__timebar');
|
||||
expect(html).toContain('拼图主题创作赛');
|
||||
expect(html).toContain('抓大鹅主题创作赛');
|
||||
expect(html).toContain('1,000');
|
||||
expect(html).toContain('泥点数');
|
||||
expect(html).not.toContain('泥点挑战');
|
||||
expect(html).toMatch(
|
||||
/creation-event-banner__timebar[\s\S]*creation-event-banner__pager[\s\S]*creation-template-card/u,
|
||||
);
|
||||
expect(html).toContain('creation-template-card__body');
|
||||
expect(html).toContain('creation-template-card__cost-badge');
|
||||
expect(html).toContain('拼图关卡创作');
|
||||
expect(html).toContain('10-20泥点数');
|
||||
expect(html).toContain('即将开放');
|
||||
expect(html).not.toContain('可创建');
|
||||
expect(html).not.toContain('可创作');
|
||||
expect(html).not.toContain('creation-event-banner__counter');
|
||||
expect(html).not.toContain('预计消耗 10-20 泥点');
|
||||
expect(html).not.toContain('platform-creation-reference-card');
|
||||
});
|
||||
|
||||
test('creation start card keeps typography in compact UI scale', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
mode="start-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toMatch(/creation-template-card__title[^"]*\btext-sm\b/u);
|
||||
expect(html).toMatch(/creation-template-card__subtitle[^"]*\btext-xs\b/u);
|
||||
expect(html).toMatch(
|
||||
/creation-template-card__cost-badge[^"]*\btext-\[11px\](?:\s|")/u,
|
||||
);
|
||||
expect(html).not.toMatch(
|
||||
/\b(text-lg|text-xl|sm:text-base|sm:text-lg|sm:text-xl|text-\[1\.08rem\])\b/u,
|
||||
);
|
||||
});
|
||||
|
||||
test('creation start card removes the outer template list frame and tightens card grid', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
mode="start-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('creation-template-list');
|
||||
expect(html).toMatch(/creation-template-list__grid[^"]*\bgap-2\b/u);
|
||||
expect(html).toMatch(/creation-template-card[^"]*\bmin-h-\[12\.5rem\]/u);
|
||||
expect(html).not.toMatch(
|
||||
/creation-template-list[^"]*\bborder\b[^"]*\bborder-\[#f0dfd6\]/u,
|
||||
);
|
||||
});
|
||||
|
||||
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
@@ -514,3 +630,64 @@ test('creation hub published card keeps publish info without fixed action text',
|
||||
expect(html).not.toContain('creation-work-card__action');
|
||||
expect(html).not.toContain('>查看详情<');
|
||||
});
|
||||
|
||||
test('creation hub root keeps the remap theme hook without the page card shell', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
mode="works-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('platform-remap-surface');
|
||||
expect(html).not.toContain('platform-page-stage');
|
||||
});
|
||||
|
||||
test('creation hub draft tabs use discover-style channel labels', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
mode="works-only"
|
||||
items={[]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
puzzleItems={[
|
||||
{
|
||||
workId: 'puzzle:works-tab',
|
||||
profileId: 'puzzle-profile-works-tab',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
levelName: '测试草稿',
|
||||
summary: '测试草稿',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
},
|
||||
]}
|
||||
onOpenPuzzleDetail={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('platform-mobile-home-channel');
|
||||
expect(html).toContain('platform-mobile-home-channel--active');
|
||||
expect(html).not.toContain('platform-tab--active');
|
||||
});
|
||||
|
||||
@@ -355,7 +355,7 @@ export function CustomWorldCreationHub({
|
||||
const showWorkShelf = mode !== 'start-only';
|
||||
|
||||
return (
|
||||
<div className="platform-page-stage platform-remap-surface space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-5 xl:pt-5">
|
||||
<div className="platform-remap-surface w-full space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-5 xl:pt-5">
|
||||
<div className="space-y-4 xl:space-y-3">
|
||||
{showStartCard ? (
|
||||
<CustomWorldCreationStartCard
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { Coins, Trophy } from 'lucide-react';
|
||||
import { useMemo, useState, type UIEvent } from 'react';
|
||||
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import {
|
||||
getVisiblePlatformCreationTypes,
|
||||
groupVisiblePlatformCreationTypes,
|
||||
type PlatformCreationTypeCard,
|
||||
type PlatformCreationTypeId,
|
||||
} from '../platform-entry/platformEntryCreationTypes';
|
||||
@@ -15,6 +16,13 @@ type CustomWorldCreationStartCardProps = {
|
||||
onCreateType: (type: PlatformCreationTypeId) => void;
|
||||
};
|
||||
|
||||
type CreationEventBannerCard = CreationEntryConfig['eventBanner'];
|
||||
|
||||
function shouldShowCreationBadge(badge: string) {
|
||||
const normalizedBadge = badge.trim();
|
||||
return normalizedBadge !== '可创建' && normalizedBadge !== '可创作';
|
||||
}
|
||||
|
||||
export function CustomWorldCreationStartCard({
|
||||
busy = false,
|
||||
error = null,
|
||||
@@ -22,30 +30,161 @@ export function CustomWorldCreationStartCard({
|
||||
creationTypes,
|
||||
onCreateType,
|
||||
}: CustomWorldCreationStartCardProps) {
|
||||
// 创作首页首屏卡带与创作类型弹层保持同一份展示口径,
|
||||
// 避免某个玩法只在其中一个入口被隐藏而出现状态漂移。
|
||||
const visibleCreationTypes = getVisiblePlatformCreationTypes(creationTypes);
|
||||
const creationTypeGroups = useMemo(
|
||||
() => groupVisiblePlatformCreationTypes(creationTypes),
|
||||
[creationTypes],
|
||||
);
|
||||
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
|
||||
const activeGroup =
|
||||
creationTypeGroups.find((group) => group.id === activeCategoryId) ??
|
||||
creationTypeGroups[0] ??
|
||||
null;
|
||||
const visibleCreationTypes = activeGroup?.items ?? [];
|
||||
const eventBanners = useMemo<CreationEventBannerCard[]>(
|
||||
() => [
|
||||
{
|
||||
...entryConfig.eventBanner,
|
||||
title: '拼图主题创作赛',
|
||||
description: '用拼图关卡接住本周主题。',
|
||||
coverImageSrc: '/creation-type-references/puzzle.webp',
|
||||
prizePoolMudPoints: 1000,
|
||||
},
|
||||
{
|
||||
...entryConfig.eventBanner,
|
||||
title: '抓大鹅主题创作赛',
|
||||
description: '把抓大鹅关卡做成主题挑战。',
|
||||
coverImageSrc: '/creation-type-references/match3d.webp',
|
||||
prizePoolMudPoints: 1000,
|
||||
},
|
||||
],
|
||||
[entryConfig.eventBanner],
|
||||
);
|
||||
const [activeBannerIndex, setActiveBannerIndex] = useState(0);
|
||||
|
||||
function handleBannerScroll(event: UIEvent<HTMLDivElement>) {
|
||||
const { clientWidth, scrollLeft } = event.currentTarget;
|
||||
if (clientWidth <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIndex = Math.max(
|
||||
0,
|
||||
Math.min(eventBanners.length - 1, Math.round(scrollLeft / clientWidth)),
|
||||
);
|
||||
setActiveBannerIndex((currentIndex) =>
|
||||
currentIndex === nextIndex ? currentIndex : nextIndex,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// 移动端限制模块高度,模板入口改为横向滚动,避免挤占作品列表首屏空间。
|
||||
<div className="platform-surface platform-surface--hero relative max-h-[33svh] overflow-hidden px-3 py-3 sm:max-h-none sm:px-5 sm:py-5 xl:px-5 xl:py-4">
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 space-y-2.5 sm:space-y-4 xl:space-y-3">
|
||||
<div className="flex items-center justify-between gap-3 xl:items-end">
|
||||
<div className="text-xl font-black leading-none text-white sm:text-3xl xl:text-2xl">
|
||||
{entryConfig.startCard.title}
|
||||
</div>
|
||||
<div className="hidden text-sm leading-6 text-zinc-200/88 sm:block xl:text-xs xl:leading-5">
|
||||
{entryConfig.startCard.description}
|
||||
</div>
|
||||
<span className="platform-pill platform-pill--neutral shrink-0 border-white/25 bg-white/14 px-2.5 text-xs text-white sm:hidden">
|
||||
{busy
|
||||
? entryConfig.startCard.busyBadge
|
||||
: entryConfig.startCard.idleBadge}
|
||||
</span>
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-3 sm:gap-5">
|
||||
<section className="creation-event-banner relative overflow-hidden rounded-[1.35rem] border border-[#f0d8ca] bg-[#fff8f3] shadow-[0_16px_36px_rgba(174,111,73,0.12)] sm:rounded-[1.65rem]">
|
||||
<div
|
||||
className="creation-event-banner__track flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain touch-pan-x scrollbar-hide"
|
||||
onScroll={handleBannerScroll}
|
||||
aria-label="创作赛事横幅"
|
||||
>
|
||||
{eventBanners.map((banner, index) => {
|
||||
const prizePoolText =
|
||||
banner.prizePoolMudPoints.toLocaleString('zh-CN');
|
||||
|
||||
return (
|
||||
<article
|
||||
key={`${banner.title}:${index}`}
|
||||
className="creation-event-banner__slide relative w-full shrink-0 snap-center overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={banner.coverImageSrc}
|
||||
alt=""
|
||||
className="absolute inset-y-0 right-0 h-full w-[58%] object-cover object-[70%_center] opacity-95"
|
||||
loading={index === 0 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(90deg,#fff8f3_0%,rgba(255,248,243,0.94)_34%,rgba(255,248,243,0.36)_68%,rgba(255,248,243,0.08)_100%)]" />
|
||||
<div className="relative z-10 flex min-h-[12rem] flex-col justify-between px-4 py-4 sm:min-h-[15rem] sm:px-7 sm:py-6">
|
||||
<div className="w-[68%] min-w-0 sm:w-[56%]">
|
||||
<div className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-[#df7949] bg-white/72 px-2.5 py-1 text-xs font-black text-[#cf6332] shadow-sm">
|
||||
<Trophy className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{banner.title}</span>
|
||||
</div>
|
||||
<div className="mt-3 line-clamp-2 text-sm font-semibold leading-5 text-[#695143] sm:mt-5 sm:leading-6">
|
||||
{banner.description}
|
||||
</div>
|
||||
<div className="mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full bg-white/72 px-2.5 py-1.5 text-xs font-bold text-[#6f5140] shadow-sm">
|
||||
<span className="grid h-5 w-5 place-items-center rounded-full bg-[#ffb64c] text-white">
|
||||
<Coins className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="shrink-0">奖池</span>
|
||||
<span className="text-sm font-black text-[#d36b2f]">
|
||||
{prizePoolText}
|
||||
</span>
|
||||
<span className="shrink-0">泥点数</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<div className="creation-event-banner__timebar flex min-w-0 items-center justify-center gap-1.5 rounded-full border border-[#e9c5b0] bg-white/72 px-2.5 py-1.5 text-center text-[10px] font-bold text-[#9a5a39] shadow-[0_8px_18px_rgba(174,111,73,0.1)] sm:gap-2 sm:px-5 sm:py-2.5 sm:text-[11px]">
|
||||
<span className="min-w-0 truncate">
|
||||
开始时间 {banner.startsAtText}
|
||||
</span>
|
||||
<span className="shrink-0 text-[#c99373]">|</span>
|
||||
<span className="min-w-0 truncate">
|
||||
结束时间 {banner.endsAtText}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="creation-event-banner__pager flex items-center justify-center gap-1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{eventBanners.map((dotBanner, dotIndex) => (
|
||||
<span
|
||||
key={`${dotBanner.title}:dot:${dotIndex}`}
|
||||
className={
|
||||
dotIndex === activeBannerIndex
|
||||
? 'h-1.5 w-5 rounded-full bg-[#d9793f]'
|
||||
: 'h-1.5 w-1.5 rounded-full bg-[#eadfd7]'
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="creation-template-list -mx-1 px-1 sm:-mx-2 sm:px-2">
|
||||
<div
|
||||
className="-mx-0.5 flex snap-x items-center gap-2 overflow-x-auto px-0.5 pb-1 scrollbar-hide scroll-px-2 sm:gap-3"
|
||||
role="tablist"
|
||||
aria-label="玩法模板分类"
|
||||
>
|
||||
{creationTypeGroups.map((group) => {
|
||||
const selected = group.id === activeGroup?.id;
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
onClick={() => setActiveCategoryId(group.id)}
|
||||
className={`relative min-h-8 shrink-0 rounded-full px-2.5 text-xs font-black transition sm:min-h-9 sm:px-3.5 sm:text-sm ${
|
||||
selected
|
||||
? 'text-[#6f2f21]'
|
||||
: 'text-[#7a6558] hover:text-[#6f2f21]'
|
||||
}`}
|
||||
>
|
||||
<span>{group.label}</span>
|
||||
{selected ? (
|
||||
<span className="absolute bottom-0 left-3 right-3 h-1 rounded-full bg-[#d9793f]" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 scrollbar-hide sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-6 xl:gap-2.5">
|
||||
<div className="creation-template-list__grid mt-2 grid grid-cols-2 gap-2 sm:mt-3 sm:gap-3">
|
||||
{visibleCreationTypes.map((item) => {
|
||||
const disabled = item.locked || busy;
|
||||
|
||||
@@ -57,47 +196,35 @@ export function CustomWorldCreationStartCard({
|
||||
onClick={() => {
|
||||
onCreateType(item.id);
|
||||
}}
|
||||
className={`platform-creation-reference-card platform-interactive-card relative flex min-h-[4.6rem] w-[11.25rem] shrink-0 snap-start flex-col overflow-hidden rounded-[1.15rem] border p-0 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] xl:min-h-[6.4rem] ${
|
||||
className={`creation-template-card platform-interactive-card relative flex min-h-[12.5rem] flex-col overflow-hidden rounded-[1rem] border bg-white p-0 text-left transition sm:min-h-[15rem] sm:rounded-[1.2rem] ${
|
||||
item.locked
|
||||
? 'cursor-not-allowed border-white/10 bg-white/8 text-zinc-300/70'
|
||||
: 'border-white/18 bg-white/16 text-white'
|
||||
? 'cursor-not-allowed border-[#eadbd3] text-[#725b4d] opacity-72'
|
||||
: 'border-[#eadbd3] text-[#2f211b] hover:border-[#dc9a72] hover:shadow-[0_16px_34px_rgba(174,111,73,0.14)]'
|
||||
} ${busy && !item.locked ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<img
|
||||
src={item.imageSrc}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
className={`absolute inset-0 ${
|
||||
item.locked
|
||||
? 'bg-[linear-gradient(90deg,rgba(3,7,18,0.58),rgba(3,7,18,0.14)),linear-gradient(180deg,rgba(3,7,18,0.05)_0%,rgba(3,7,18,0.2)_42%,rgba(3,7,18,0.82)_100%)]'
|
||||
: 'bg-[linear-gradient(90deg,rgba(3,7,18,0.54),rgba(3,7,18,0.04)),linear-gradient(180deg,rgba(3,7,18,0.03)_0%,rgba(3,7,18,0.14)_42%,rgba(3,7,18,0.78)_100%)]'
|
||||
}`}
|
||||
/>
|
||||
<div className="relative z-10 flex min-h-5 items-center justify-end gap-2 px-3 pt-2.5 sm:items-start sm:gap-3 sm:px-4 sm:pt-4 xl:px-3.5 xl:pt-3">
|
||||
{item.locked ? (
|
||||
<span className="platform-pill platform-pill--neutral px-2.5 text-xs text-[var(--platform-text-soft)] sm:px-3 sm:text-sm">
|
||||
<div className="creation-template-card__media relative aspect-[1.32/1] w-full overflow-hidden bg-[#f7ebe3]">
|
||||
<img
|
||||
src={item.imageSrc}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{shouldShowCreationBadge(item.badge) ? (
|
||||
<span className="absolute left-2 top-2 max-w-[calc(100%-1rem)] rounded-full bg-[#b66a3e] px-2 py-0.5 text-xs font-black text-white shadow-sm sm:left-3 sm:top-3 sm:px-2.5 sm:py-1">
|
||||
{item.badge}
|
||||
</span>
|
||||
) : null}
|
||||
{item.locked ? (
|
||||
<span className="text-base leading-none text-white/40">·</span>
|
||||
) : (
|
||||
<ArrowRight className="h-4 w-4 text-white/80" />
|
||||
)}
|
||||
<span className="creation-template-card__cost-badge absolute bottom-2 right-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1 rounded-full bg-[#fff7ec]/92 px-2 py-1 text-[11px] font-black leading-4 text-[#b65f2c] shadow-[0_8px_18px_rgba(119,72,44,0.16)]">
|
||||
<Coins className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">10-20泥点数</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-auto px-3 pb-2.5 pt-1.5 text-white [text-shadow:0_1px_8px_rgba(0,0,0,0.76)] sm:px-4 sm:pb-4 sm:pt-4 xl:px-3.5 xl:pb-3 xl:pt-2">
|
||||
<div className="truncate text-base font-black leading-tight text-white sm:text-lg xl:text-base">
|
||||
<div className="creation-template-card__body flex min-h-[4.6rem] flex-1 flex-col bg-white px-2.5 pb-2.5 pt-2.5 text-[#2f211b] sm:min-h-[5.4rem] sm:px-3.5 sm:pb-3.5">
|
||||
<div className="creation-template-card__title line-clamp-1 text-sm font-black leading-5 text-[#2f211b]">
|
||||
{item.title}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-1 truncate text-xs sm:mt-2 sm:text-sm xl:mt-1 xl:text-xs ${
|
||||
item.locked ? 'text-white/72' : 'text-white/88'
|
||||
}`}
|
||||
>
|
||||
<div className="creation-template-card__subtitle mt-1 line-clamp-2 text-xs font-semibold leading-4 text-[#6f5a4c] sm:leading-5">
|
||||
{item.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,11 +234,11 @@ export function CustomWorldCreationStartCard({
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-[1rem] px-3 py-2 text-sm leading-5 sm:rounded-[1.25rem] sm:leading-6">
|
||||
<div className="platform-banner platform-banner--danger mt-4 rounded-[1rem] px-3 py-2 text-sm leading-5 sm:rounded-[1.25rem] sm:leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -729,8 +729,6 @@ export function CustomWorldWorkCard({
|
||||
{item.summary}
|
||||
</div>
|
||||
|
||||
<div className="creation-work-card__author">作者:{item.authorDisplayName}</div>
|
||||
|
||||
{isPublished ? (
|
||||
<div className="creation-work-card__published-info">
|
||||
{item.pointIncentive ? (
|
||||
|
||||
@@ -23,7 +23,11 @@ export function CustomWorldWorkTabs({
|
||||
onChange,
|
||||
}: CustomWorldWorkTabsProps) {
|
||||
return (
|
||||
<div className="platform-remap-surface flex items-center gap-2 overflow-x-auto pb-1 scrollbar-hide xl:pb-0">
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-4 overflow-x-auto pb-1 scrollbar-hide xl:pb-0"
|
||||
role="tablist"
|
||||
aria-label="作品筛选"
|
||||
>
|
||||
{FILTER_OPTIONS.map((option) => {
|
||||
const count =
|
||||
option.id === 'draft'
|
||||
@@ -36,10 +40,10 @@ export function CustomWorldWorkTabs({
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeFilter === option.id}
|
||||
onClick={() => onChange(option.id)}
|
||||
className={`platform-tab shrink-0 px-4 py-2 text-sm xl:px-4 xl:py-1.5 xl:text-xs ${
|
||||
activeFilter === option.id ? 'platform-tab--active' : ''
|
||||
}`}
|
||||
className={`platform-mobile-home-channel shrink-0 ${activeFilter === option.id ? 'platform-mobile-home-channel--active' : ''}`}
|
||||
>
|
||||
{option.label} {count}
|
||||
</button>
|
||||
|
||||
@@ -175,6 +175,39 @@ test('buildCreationWorkShelfItems keeps separate bark battle draft and published
|
||||
);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems falls back to deterministic RPG public work code when library entry is missing', () => {
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [
|
||||
{
|
||||
workId: 'rpg-work-published',
|
||||
sourceType: 'published_profile',
|
||||
status: 'published',
|
||||
title: '潮雾列岛已发布版',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summary: '已经发布的群岛世界作品。',
|
||||
coverImageSrc: null,
|
||||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||||
publishedAt: '2026-04-20T10:00:00.000Z',
|
||||
stage: 'published',
|
||||
stageLabel: '已发布',
|
||||
playableNpcCount: 3,
|
||||
landmarkCount: 4,
|
||||
sessionId: null,
|
||||
profileId: 'world-public-1',
|
||||
canResume: false,
|
||||
canEnterWorld: true,
|
||||
},
|
||||
],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.publicWorkCode).toBe('CW-00000001');
|
||||
expect(items[0]?.sharePath).toContain('/works/detail?work=CW-00000001');
|
||||
expect(items[0]?.canShare).toBe(true);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems gives bark battle draft cover from character or reference fallback', () => {
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
@@ -1009,7 +1042,7 @@ test('bark battle draft generating state follows pending assets or missing three
|
||||
});
|
||||
|
||||
|
||||
test('CustomWorldWorkCard renders author for draft and published works', () => {
|
||||
test('CustomWorldWorkCard hides author on shelf draft and published cards', () => {
|
||||
const buildItem = (
|
||||
status: CreationWorkShelfItem['status'],
|
||||
authorDisplayName: string,
|
||||
@@ -1074,8 +1107,8 @@ test('CustomWorldWorkCard renders author for draft and published works', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(draftHtml).toContain('作者:草稿作者');
|
||||
expect(publishedHtml).toContain('作者:发布作者');
|
||||
expect(draftHtml).not.toContain('作者:草稿作者');
|
||||
expect(publishedHtml).not.toContain('作者:发布作者');
|
||||
});
|
||||
|
||||
test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/co
|
||||
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||
import {
|
||||
buildBabyObjectMatchPublicWorkCode,
|
||||
buildCustomWorldPublicWorkCode,
|
||||
buildBarkBattlePublicWorkCode,
|
||||
buildBigFishPublicWorkCode,
|
||||
buildJumpHopPublicWorkCode,
|
||||
@@ -353,7 +354,10 @@ function mapRpgWorkToShelfItem(
|
||||
? libraryEntries.find((entry) => entry.profileId === item.profileId)
|
||||
: null;
|
||||
const publicWorkCode =
|
||||
item.status === 'published' ? (libraryEntry?.publicWorkCode ?? null) : null;
|
||||
item.status === 'published'
|
||||
? (libraryEntry?.publicWorkCode?.trim() ||
|
||||
(item.profileId ? buildCustomWorldPublicWorkCode(item.profileId) : null))
|
||||
: null;
|
||||
const badges: CreationWorkShelfBadge[] = [
|
||||
buildStatusBadge(item.status),
|
||||
{ id: 'type', label: 'RPG', tone: 'neutral' },
|
||||
|
||||
Reference in New Issue
Block a user