合并泥点弹窗透明修复

# Conflicts:
#	src/components/common/PublishShareModal.test.tsx
#	src/components/common/PublishShareModal.tsx
#	src/index.test.ts
This commit is contained in:
2026-06-12 15:35:19 +08:00
324 changed files with 36177 additions and 12743 deletions

View File

@@ -547,7 +547,14 @@ test('creation hub shows puzzle point incentive and claims without opening card'
expect(screen.getByLabelText('积分激励总数 2.5 泥点')).toBeTruthy();
expect(screen.getByLabelText('待领取积分 1 泥点')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '领取积分' }));
const claimButton = screen.getByRole('button', { name: '领取积分' });
expect(claimButton.className).toContain('platform-button');
expect(claimButton.className).toContain(
'creation-work-card-incentive__button',
);
await user.click(claimButton);
expect(onClaimPuzzlePointIncentive).toHaveBeenCalledWith(
expect.objectContaining({ profileId: 'puzzle-profile-incentive' }),
@@ -840,6 +847,31 @@ test('creation hub works-only tab filters bark battle draft and published works'
expect(onOpenBarkBattleDetail).toHaveBeenCalledWith(barkBattlePublishedItem);
});
test('creation hub keeps filtered empty copy when selected tab has no works', async () => {
const user = userEvent.setup();
render(
<CustomWorldCreationHub
mode="works-only"
items={[]}
barkBattleItems={[barkBattlePublishedItem]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
await user.click(screen.getByRole('tab', { name: '草稿 0' }));
expect(screen.getByText('当前筛选下没有内容')).toBeTruthy();
expect(screen.queryByText('还没有作品')).toBeNull();
});
test('creation hub published work delete action stays in revealed side actions', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();
@@ -1135,7 +1167,13 @@ test('creation hub published share icon is shown directly on the card header', (
/>,
);
expect(screen.getByRole('button', { name: '分享' })).toBeTruthy();
const shareButton = screen.getByRole('button', { name: '分享' });
expect(shareButton).toBeTruthy();
expect(shareButton.className).toContain('platform-icon-button');
expect(shareButton.className).toContain(
'creation-work-card__quick-action-button',
);
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
});

View File

@@ -191,6 +191,14 @@ test('creation hub draft card renders compiled work summary fields', () => {
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
getWorkState={(item) =>
item.kind === 'rpg'
? {
hasGenerationFailure: true,
generationFailureSummary: '生成失败',
}
: null
}
/>,
);
@@ -960,9 +968,128 @@ test('creation hub published work uses unified list card layout', () => {
expect(html).toContain('creation-work-list');
expect(html).toContain('platform-category-game-item');
expect(html).toContain('creation-work-card__side-cover');
expect(html).toContain('creation-work-card__badge');
expect(html).toContain('border-[var(--platform-subpanel-border)]');
expect(html).not.toContain('platform-pill');
expect(html).not.toContain('col-span-2 sm:col-span-1');
});
test('creation hub failed draft badge reuses PlatformPillBadge danger chrome', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
mode="works-only"
shelfItems={[
{
id: 'failed-card',
kind: 'bark-battle',
status: 'draft',
hasGenerationFailure: true,
generationFailureSummary: '生成失败',
title: '失败但仍可恢复的草稿',
summary: '失败草稿也来自真实作品架摘要。',
authorDisplayName: '测试作者',
updatedAt: buildUpdatedAtDaysAgo(1),
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode: null,
sharePath: null,
openActionLabel: '继续创作',
canDelete: false,
canShare: false,
badges: [
{ id: 'status', label: '草稿', tone: 'neutral' },
{ id: 'type', label: '汪汪', tone: 'neutral' },
],
metrics: [],
actions: { open: () => {} },
source: {
kind: 'bark-battle',
item: {
workId: 'failed-card',
draftId: 'failed-draft',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
title: '失败但仍可恢复的草稿',
summary: '失败草稿也来自真实作品架摘要。',
themeDescription: '公园舞台',
playerImageDescription: '柯基选手',
opponentImageDescription: '哈士奇对手',
playerCharacterImageSrc: null,
opponentCharacterImageSrc: null,
uiBackgroundImageSrc: null,
difficultyPreset: 'normal',
status: 'draft',
generationStatus: 'failed',
publishReady: false,
playCount: 0,
updatedAt: buildUpdatedAtDaysAgo(1),
publishedAt: null,
},
},
},
]}
items={[]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
expect(html).toContain('creation-work-card__failure-status');
expect(html).toContain('border-[var(--platform-button-danger-border)]');
expect(html).toContain('失败草稿也来自真实作品架摘要。');
});
test('creation hub empty shelf reuses PlatformEmptyState chrome', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
mode="works-only"
items={[]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
expect(html).toContain('还没有作品');
expect(html).toContain('platform-surface platform-surface--soft');
expect(html).toContain('min-h-[14rem]');
expect(html).not.toContain('platform-subpanel flex min-h-[14rem]');
});
test('creation hub loading shelf reuses PlatformSubpanel skeleton cards', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
mode="works-only"
items={[]}
loading
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
expect(html.match(/platform-subpanel/g)?.length).toBe(3);
expect(html).toContain('min-h-[10.5rem]');
expect(html).toContain('rounded-[1.25rem]');
expect(html).not.toContain('rounded-[1.2rem]');
});
test('creation hub draft cards use cover background and hide updated time', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub

View File

@@ -2,6 +2,10 @@ import { useEffect, useMemo, useState } from 'react';
import { resolveSelectionStageFromPath } from '../../routing/appPageRoutes';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import type { PublishShareModalPayload } from '../common/publishShareModalModel';
import type {
PlatformCreationTypeCard,
@@ -12,9 +16,7 @@ import {
type CreationWorkShelfMetricId,
getCreationWorkShelfItemTime,
} from './creationWorkShelf';
import {
CustomWorldCreationStartCard,
} from './CustomWorldCreationStartCard';
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
import {
type CustomWorldWorkFilter,
@@ -53,11 +55,14 @@ type CustomWorldCreationHubProps = {
function EmptyState({ title }: { title: string }) {
return (
<div className="platform-subpanel flex min-h-[14rem] flex-col items-center justify-center rounded-[1.6rem] px-6 py-8 text-center">
<div className="text-lg font-semibold text-[var(--platform-text-strong)]">
{title}
</div>
</div>
<PlatformEmptyState
surface="soft"
size="panel"
tone="base"
className="font-semibold text-[var(--platform-text-strong)]"
>
{title}
</PlatformEmptyState>
);
}
@@ -202,6 +207,7 @@ export function CustomWorldCreationHub({
const recentCreationTypeIds = [
...new Set(recentWorkItems.map((item) => item.kind)),
];
const isWorkShelfEmpty = !loading && filteredItems.length === 0;
function handleOpenShelfItem(item: CreationWorkShelfItem) {
onOpenShelfItem?.(item);
@@ -234,6 +240,55 @@ export function CustomWorldCreationHub({
const showStartCard = mode !== 'works-only';
const showWorkShelf = mode !== 'start-only';
const workShelfLoadingState = (
<div className={WORK_GRID_CLASS}>
{Array.from({ length: 3 }).map((_, index) => (
<PlatformSubpanel
as="div"
key={`skeleton-${index}`}
padding="sm"
radius="md"
className="min-h-[10.5rem] sm:min-h-[12rem] sm:p-5"
>
<div className="h-4 w-20 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-5 h-6 w-24 rounded-full bg-[var(--platform-track-fill)] sm:mt-6 sm:h-8 sm:w-36" />
<div className="mt-3 h-3 w-full rounded-full bg-[var(--platform-track-fill)] sm:mt-4 sm:h-4" />
<div className="mt-2 h-4 w-4/5 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-6 flex flex-col gap-2 sm:mt-8 sm:flex-row">
<div className="h-6 w-16 rounded-full bg-[var(--platform-track-fill)] sm:h-7 sm:w-20" />
<div className="h-6 w-16 rounded-full bg-[var(--platform-track-fill)] sm:h-7 sm:w-20" />
</div>
</PlatformSubpanel>
))}
</div>
);
const workShelfEmptyState = (
<EmptyState
title={shelfItems.length === 0 ? '还没有作品' : '当前筛选下没有内容'}
/>
);
const workShelfContent = (
<div className={WORK_GRID_CLASS}>
{filteredItems.map((item) => (
<CustomWorldWorkCard
key={`${item.kind}-${item.id}`}
item={item}
previousMetricValues={metricSnapshot[buildWorkMetricCacheItemKey(item)]}
onOpen={() => {
handleOpenShelfItem(item);
}}
onDelete={buildDeleteAction(item)}
deleteBusy={deletingWorkId === item.id}
onShare={buildShareAction(item)}
onClaimPointIncentive={buildPointIncentiveAction(item)}
pointIncentiveBusy={
item.source.kind === 'puzzle' &&
claimingPuzzleProfileId === item.source.item.profileId
}
/>
))}
</div>
);
return (
<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">
@@ -260,63 +315,26 @@ export function CustomWorldCreationHub({
{showWorkShelf && error ? (
<div className="flex justify-end">
<button
type="button"
<PlatformActionButton
onClick={onRetry}
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
tone="ghost"
shape="pill"
className="min-h-0 py-2"
>
</button>
</PlatformActionButton>
</div>
) : null}
{showWorkShelf ? (
loading ? (
<div className={WORK_GRID_CLASS}>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={`skeleton-${index}`}
className="platform-subpanel min-h-[10.5rem] rounded-[1.2rem] p-3 sm:min-h-[12rem] sm:rounded-[1.6rem] sm:p-5"
>
<div className="h-4 w-20 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-5 h-6 w-24 rounded-full bg-[var(--platform-track-fill)] sm:mt-6 sm:h-8 sm:w-36" />
<div className="mt-3 h-3 w-full rounded-full bg-[var(--platform-track-fill)] sm:mt-4 sm:h-4" />
<div className="mt-2 h-4 w-4/5 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-6 flex flex-col gap-2 sm:mt-8 sm:flex-row">
<div className="h-6 w-16 rounded-full bg-[var(--platform-track-fill)] sm:h-7 sm:w-20" />
<div className="h-6 w-16 rounded-full bg-[var(--platform-track-fill)] sm:h-7 sm:w-20" />
</div>
</div>
))}
</div>
) : filteredItems.length > 0 ? (
<div className={WORK_GRID_CLASS}>
{filteredItems.map((item) => (
<CustomWorldWorkCard
key={`${item.kind}-${item.id}`}
item={item}
previousMetricValues={
metricSnapshot[buildWorkMetricCacheItemKey(item)]
}
onOpen={() => {
handleOpenShelfItem(item);
}}
onDelete={buildDeleteAction(item)}
deleteBusy={deletingWorkId === item.id}
onShare={buildShareAction(item)}
onClaimPointIncentive={buildPointIncentiveAction(item)}
pointIncentiveBusy={
item.source.kind === 'puzzle' &&
claimingPuzzleProfileId === item.source.item.profileId
}
/>
))}
</div>
) : shelfItems.length === 0 ? (
<EmptyState title="还没有作品" />
) : (
<EmptyState title="当前筛选下没有内容" />
)
<PlatformAsyncStatePanel
isLoading={loading}
loadingState={workShelfLoadingState}
isEmpty={isWorkShelfEmpty}
emptyState={workShelfEmptyState}
>
{workShelfContent}
</PlatformAsyncStatePanel>
) : null}
</div>
</div>

View File

@@ -5,6 +5,7 @@ import type {
CreationEntryConfig,
CreationEntryEventBannerConfig,
} from '../../services/creationEntryConfigService';
import { PlatformPillTabRail } from '../common/PlatformSegmentedTabPresets';
import {
groupVisiblePlatformCreationTypes,
type PlatformCreationTypeCard,
@@ -111,6 +112,23 @@ export function CustomWorldCreationStartCard({
const visibleCreationTypes = isRecentTabActive
? recentCreationTypes
: (activeGroup?.items ?? []);
const categoryTabs = useMemo(
() => [
...(hasRecentCreationTypes
? [
{
id: CREATION_ENTRY_RECENT_TAB_ID,
label: '最近创作',
},
]
: []),
...creationTypeGroups.map((group) => ({
id: group.id,
label: group.label,
})),
],
[creationTypeGroups, hasRecentCreationTypes],
);
const eventBanners = useMemo(
() => resolveCreationEntryEventBanners(entryConfig),
[entryConfig],
@@ -262,52 +280,12 @@ export function CustomWorldCreationStartCard({
</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="创作入口页签"
>
{hasRecentCreationTypes ? (
<button
type="button"
role="tab"
aria-selected={isRecentTabActive}
onClick={() => setActiveCategoryId(CREATION_ENTRY_RECENT_TAB_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 ${
isRecentTabActive
? 'text-[#6f2f21]'
: 'text-[#7a6558] hover:text-[#6f2f21]'
}`}
>
<span></span>
{isRecentTabActive ? (
<span className="absolute bottom-0 left-3 right-3 h-1 rounded-full bg-[#d9793f]" />
) : null}
</button>
) : null}
{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>
<PlatformPillTabRail
items={categoryTabs}
activeId={activeTabId ?? ''}
onChange={setActiveCategoryId}
ariaLabel="创作入口页签"
/>
{isRecentTabActive ? (
<div className="creation-template-list__recent-window mt-2 text-[11px] font-bold leading-4 text-[#8b6654] sm:text-xs">

View File

@@ -18,6 +18,9 @@ import {
useState,
} from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import {
formatPlatformWorkDisplayName,
@@ -44,10 +47,17 @@ type CustomWorldWorkCardProps = {
pointIncentiveBusy?: boolean;
};
const BADGE_TONE_CLASS: Record<CreationWorkShelfBadgeTone, string> = {
warm: 'platform-pill--warm',
success: 'platform-pill--success',
neutral: 'platform-pill--neutral',
type WorkCardPillBadgeTone = React.ComponentProps<
typeof PlatformPillBadge
>['tone'];
const BADGE_TONE_CLASS: Record<
CreationWorkShelfBadgeTone,
NonNullable<WorkCardPillBadgeTone>
> = {
warm: 'warning',
success: 'success',
neutral: 'neutral',
};
const METRIC_ANIMATION_DURATION_MS = 820;
@@ -627,8 +637,9 @@ export function CustomWorldWorkCard({
</div>
{canUseShareAction ? (
<div className="creation-work-card__quick-actions">
<button
type="button"
<PlatformIconButton
label="分享"
icon={<Share2 aria-hidden="true" className="h-4 w-4" />}
onClick={(event) => {
event.stopPropagation();
suppressOpenRef.current = false;
@@ -645,23 +656,22 @@ export function CustomWorldWorkCard({
event.stopPropagation();
}}
title="分享作品"
aria-label="分享"
className="creation-work-card__quick-action-button"
>
<Share2 aria-hidden="true" className="h-4 w-4" />
</button>
/>
</div>
) : null}
</div>
<div className="creation-work-card__meta platform-category-game-item__meta">
{item.badges.slice(1).map((badge) => (
<span
<PlatformPillBadge
key={`${item.id}-${badge.id}`}
className={`creation-work-card__badge platform-pill ${BADGE_TONE_CLASS[badge.tone]}`}
tone={BADGE_TONE_CLASS[badge.tone]}
size="xs"
className="creation-work-card__badge"
>
{formatPlatformWorkDisplayTag(badge.label)}
</span>
</PlatformPillBadge>
))}
</div>
@@ -670,13 +680,15 @@ export function CustomWorldWorkCard({
</div>
{item.hasGenerationFailure ? (
<div
<PlatformPillBadge
tone="danger"
size="xs"
aria-label={item.generationFailureSummary ?? '生成失败'}
className="creation-work-card__failure-status"
icon={<CircleAlert aria-hidden="true" className="h-3.5 w-3.5" />}
>
<CircleAlert aria-hidden="true" className="h-3.5 w-3.5" />
<span>{item.generationFailureSummary ?? '生成失败'}</span>
</div>
</PlatformPillBadge>
) : null}
{isPublished ? (
@@ -709,8 +721,9 @@ export function CustomWorldWorkCard({
)}
</span>
</div>
<button
type="button"
<PlatformActionButton
tone="secondary"
size="xxs"
disabled={!canClaimPointIncentive || pointIncentiveBusy}
onClick={(event) => {
event.stopPropagation();
@@ -722,7 +735,7 @@ export function CustomWorldWorkCard({
className="creation-work-card-incentive__button"
>
{pointIncentiveBusy ? '领取中' : '领取积分'}
</button>
</PlatformActionButton>
</div>
) : null}

View File

@@ -1,3 +1,5 @@
import { PlatformUnderlineTabRail } from '../common/PlatformSegmentedTabPresets';
export type CustomWorldWorkFilter = 'all' | 'draft' | 'published';
const FILTER_OPTIONS: Array<{
@@ -22,33 +24,27 @@ export function CustomWorldWorkTabs({
publishedCount,
onChange,
}: CustomWorldWorkTabsProps) {
return (
<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'
? draftCount
: option.id === 'published'
? publishedCount
: draftCount + publishedCount;
const filterTabs = FILTER_OPTIONS.map((option) => {
const count =
option.id === 'draft'
? draftCount
: option.id === 'published'
? publishedCount
: draftCount + publishedCount;
return (
<button
key={option.id}
type="button"
role="tab"
aria-selected={activeFilter === option.id}
onClick={() => onChange(option.id)}
className={`platform-mobile-home-channel shrink-0 ${activeFilter === option.id ? 'platform-mobile-home-channel--active' : ''}`}
>
{option.label} {count}
</button>
);
})}
</div>
return {
id: option.id,
label: `${option.label} ${count}`,
};
});
return (
<PlatformUnderlineTabRail
items={filterTabs}
activeId={activeFilter}
onChange={onChange}
ariaLabel="作品筛选"
className="pb-1 !gap-4 xl:pb-0"
/>
);
}