继续收口平台空态与动作按钮

作品架异步状态切换复用 PlatformAsyncStatePanel
复制反馈动作外观改为组合 PlatformActionButton
结果页与调试面板空态继续收口到 PlatformEmptyState
暗色私聊与工坊按钮改为复用 PlatformActionButton
更新 PlatformUiKit 收口文档与团队决策记录
This commit is contained in:
2026-06-11 01:41:15 +08:00
parent 0a4ccdf45c
commit 06bf03a28c
15 changed files with 202 additions and 130 deletions

View File

@@ -397,6 +397,7 @@ test('私聊和队友收束复用暗色 tint PlatformSubpanel chrome', () => {
const companionResolutionEcho = screen.getByTestId(
'companion-resolution-echo',
);
const privateChatButton = screen.getByRole('button', { name: '聊天' });
expect(privateChatPanel.className).toContain('border-sky-400/18');
expect(privateChatPanel.className).toContain('bg-sky-500/8');
@@ -404,6 +405,12 @@ test('私聊和队友收束复用暗色 tint PlatformSubpanel chrome', () => {
expect(companionResolutionEcho.className).toContain('border-emerald-400/18');
expect(companionResolutionEcho.className).toContain('bg-emerald-500/8');
expect(companionResolutionEcho.className).toContain('rounded-xl');
expect(privateChatButton.className).toContain(
'platform-action-button--editor-dark',
);
expect(privateChatButton.className).toContain('rounded-xl');
expect(privateChatButton.className).toContain('bg-sky-400/15');
expect(privateChatButton.className).toContain('disabled:bg-black/20');
});
test('技能详情静态标签复用暗色 PlatformPillBadge chrome', () => {

View File

@@ -78,6 +78,7 @@ import {
StatusRow,
} from './CharacterInfoShared';
import { PlatformEmptyState } from './common/PlatformEmptyState';
import { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import { GENERIC_NPC_SCENE_SCALE } from './game-canvas/GameCanvasShared';
@@ -1106,8 +1107,9 @@ export function AdventureEntityModal({
: `好感达到 ${privateChatUnlockAffinity ?? DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY} 后解锁,当前 ${companionNpcState?.affinity ?? 0}`}
</div>
</div>
<button
type="button"
<PlatformActionButton
surface="editorDark"
tone="ghost"
disabled={
!privateChatUnlocked || !onOpenCharacterChat
}
@@ -1121,16 +1123,12 @@ export function AdventureEntityModal({
onClose();
onOpenCharacterChat(companionChatTarget);
}}
className={`rounded-xl px-4 py-2 text-sm font-medium transition-colors ${
privateChatUnlocked && onOpenCharacterChat
? 'border border-sky-300/40 bg-sky-400/15 text-sky-50 hover:bg-sky-400/22'
: 'cursor-not-allowed border border-white/8 bg-black/20 text-zinc-500'
}`}
className="rounded-xl border-sky-300/40 bg-sky-400/15 text-sky-50 hover:bg-sky-400/22 disabled:cursor-not-allowed disabled:border-white/8 disabled:bg-black/20 disabled:text-zinc-500 disabled:opacity-100"
>
{privateChatUnlocked
? '聊天'
: `聊天(${privateChatUnlockAffinity ?? DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY} 解锁)`}
</button>
</PlatformActionButton>
</div>
</PlatformSubpanel>
</Section>

View File

@@ -83,6 +83,7 @@ test('背包工坊材料需求状态复用暗色平台胶囊标签', () => {
const missingRequirement = screen.getByText('木材 0/1');
const forgePanel = screen.getByText('工坊').closest('section');
const recipePanel = screen.getByText('潮汐护符').closest('section');
const forgeButton = screen.getByRole('button', { name: '锻造' });
expect(metRequirement.className).toContain('rounded-full');
expect(metRequirement.className).toContain('font-black');
@@ -95,6 +96,10 @@ test('背包工坊材料需求状态复用暗色平台胶囊标签', () => {
expect(forgePanel?.className).toContain('bg-black/25');
expect(recipePanel?.className).toContain('border-white/10');
expect(recipePanel?.className).toContain('bg-black/25');
expect(forgeButton.className).toContain('platform-action-button--editor-dark');
expect(forgeButton.className).toContain('rounded-lg');
expect(forgeButton.className).toContain('bg-emerald-500/10');
expect(forgeButton.className).toContain('disabled:bg-black/20');
});
test('背包文书和故事档案区块复用暗色 PlatformSubpanel chrome', () => {

View File

@@ -15,6 +15,7 @@ import {
WorldType,
} from '../types';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import {
@@ -216,8 +217,10 @@ export function InventoryPanel({
{recipe.currencyText}
</div>
</div>
<button
type="button"
<PlatformActionButton
surface="editorDark"
tone="ghost"
size="xxs"
disabled={
!recipe.canCraft ||
!recipe.action.enabled ||
@@ -232,18 +235,14 @@ export function InventoryPanel({
setSelectedItem(null);
}
}}
className={`rounded-lg border px-3 py-1.5 text-xs transition ${
recipe.canCraft && recipe.action.enabled && !inBattle
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100 hover:bg-emerald-500/20'
: 'border-white/8 bg-black/20 text-zinc-500'
}`}
className="rounded-lg border-emerald-400/30 bg-emerald-500/10 text-emerald-100 hover:bg-emerald-500/20 disabled:border-white/8 disabled:bg-black/20 disabled:text-zinc-500 disabled:opacity-100"
>
{forgeActionKey === recipe.id
? '制作中...'
: recipe.kind === 'forge'
? '锻造'
: '合成'}
</button>
</PlatformActionButton>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{recipe.requirements.map((requirement) => {

View File

@@ -105,15 +105,19 @@ test('can opt into platform action button chrome', () => {
actionSurface="platform"
actionShape="pill"
actionFullWidth
aria-label="复制错误详情"
title="复制错误详情"
/>,
);
const button = screen.getByRole('button', { name: '复制错' });
const button = screen.getByRole('button', { name: '复制错误详情' });
expect(button.className).toContain('platform-button--primary');
expect(button.className).toContain('w-full');
expect(button.className).toContain('rounded-full');
expect(button.className).toContain('disabled:cursor-not-allowed');
expect(button.getAttribute('title')).toBe('复制错误详情');
expect(button.textContent).toContain('复制报错');
});
test('can opt into shared pill action chrome', () => {

View File

@@ -2,12 +2,12 @@ import { Check, Copy } from 'lucide-react';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import {
getPlatformActionButtonClassName,
type PlatformActionButtonSize,
type PlatformActionButtonShape,
type PlatformActionButtonSurface,
type PlatformActionButtonTone,
} from './platformActionButtonModel';
import { PlatformActionButton } from './PlatformActionButton';
import {
getPlatformPillBadgeClassName,
type PlatformPillBadgeSize,
@@ -105,38 +105,53 @@ export function CopyFeedbackButton({
: typeof idleLabel === 'string'
? idleLabel
: undefined);
const resolvedAriaLabel = ariaLabel ?? accessibleLabel;
const resolvedTitle =
title ?? (typeof accessibleLabel === 'string' ? accessibleLabel : undefined);
const content = (
<>
{showIcon ? icon : null}
{showLabel ? <span className={labelClassName}>{label}</span> : null}
</>
);
if (actionSurface) {
return (
<PlatformActionButton
surface={actionSurface}
tone={actionTone}
size={actionSize}
shape={actionShape}
fullWidth={actionFullWidth}
className={className}
{...buttonProps}
aria-label={resolvedAriaLabel}
title={resolvedTitle}
>
{content}
</PlatformActionButton>
);
}
return (
<button
type="button"
className={[
actionSurface
? getPlatformActionButtonClassName({
surface: actionSurface,
tone: actionTone,
size: actionSize,
shape: actionShape,
fullWidth: actionFullWidth,
actionAppearance === 'pill'
? getPlatformPillBadgeClassName({
tone: actionPillTone,
size: actionPillSize,
})
: actionAppearance === 'pill'
? getPlatformPillBadgeClassName({
tone: actionPillTone,
size: actionPillSize,
})
: null,
: null,
className,
]
.filter(Boolean)
.join(' ')}
{...buttonProps}
aria-label={ariaLabel ?? accessibleLabel}
title={
title ??
(typeof accessibleLabel === 'string' ? accessibleLabel : undefined)
}
aria-label={resolvedAriaLabel}
title={resolvedTitle}
>
{showIcon ? icon : null}
{showLabel ? <span className={labelClassName}>{label}</span> : null}
{content}
</button>
);
}

View File

@@ -847,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();

View File

@@ -3,6 +3,7 @@ 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';
@@ -206,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);
@@ -238,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">
@@ -276,55 +327,14 @@ export function CustomWorldCreationHub({
) : null}
{showWorkShelf ? (
loading ? (
<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>
) : 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

@@ -1673,30 +1673,28 @@ function Match3DCoverImageEditor({
))}
</div>
) : null}
{sourceAssets.length > 0 ? (
<PlatformAssetPickerGrid
items={sourceAssets}
loadingLabel="读取中..."
emptyLabel="暂无可引用素材"
disabled={isGenerating}
getKey={(asset) => asset.id}
getImageSrc={(asset) => asset.imageSrc}
getImageAlt={() => ''}
getTitle={(asset) => asset.label}
getAriaLabel={(asset) => `引用${asset.label}`}
isSelected={(asset) =>
referenceImages.some(
(reference) => reference.imageSrc === asset.imageSrc,
)
}
onSelect={(asset) => onReferenceSelect(asset.imageSrc)}
gridClassName="grid grid-cols-3 gap-2 sm:grid-cols-4"
cardClassName="bg-white/74"
cardRadiusClassName="rounded-[1rem]"
imageShellClassName="aspect-square"
bodyClassName="truncate px-2 py-2 text-[11px] font-semibold text-[var(--platform-text-base)]"
/>
) : null}
<PlatformAssetPickerGrid
items={sourceAssets}
loadingLabel="读取中..."
emptyLabel="暂无可引用素材"
disabled={isGenerating}
getKey={(asset) => asset.id}
getImageSrc={(asset) => asset.imageSrc}
getImageAlt={() => ''}
getTitle={(asset) => asset.label}
getAriaLabel={(asset) => `引用${asset.label}`}
isSelected={(asset) =>
referenceImages.some(
(reference) => reference.imageSrc === asset.imageSrc,
)
}
onSelect={(asset) => onReferenceSelect(asset.imageSrc)}
gridClassName="grid grid-cols-3 gap-2 sm:grid-cols-4"
cardClassName="bg-white/74"
cardRadiusClassName="rounded-[1rem]"
imageShellClassName="aspect-square"
bodyClassName="truncate px-2 py-2 text-[11px] font-semibold text-[var(--platform-text-base)]"
/>
</div>
) : null}

View File

@@ -81,7 +81,7 @@ function stubReferenceImageUpload(dataUrl: string) {
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
}
test('renders missing draft notice with shared PlatformSubpanel chrome', () => {
test('renders missing draft notice with shared PlatformEmptyState chrome', () => {
render(
<PuzzleResultView
session={{ ...createSession(), draft: null }}
@@ -92,11 +92,12 @@ test('renders missing draft notice with shared PlatformSubpanel chrome', () => {
const noticePanel = screen
.getByText('还没有可编辑的拼图草稿')
.closest('.platform-subpanel');
.closest('.platform-empty-state');
expect(noticePanel?.className).toContain('platform-empty-state');
expect(noticePanel?.className).toContain('rounded-[1rem]');
expect(noticePanel?.className).toContain('sm:p-5');
expect(noticePanel?.className).toContain('text-[var(--platform-text-base)]');
expect(noticePanel?.className).toContain('py-5');
expect(noticePanel?.className).toContain('text-[var(--platform-text-soft)]');
});
function createSession(

View File

@@ -23,6 +23,7 @@ import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenc
import { useAuthUi } from '../auth/AuthUiContext';
import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformIconBadge } from '../common/PlatformIconBadge';
import { PlatformIconButton } from '../common/PlatformIconButton';
@@ -1556,14 +1557,13 @@ export function PuzzleResultView({
if (!draft || !editState || !syncedDraft) {
return (
<div className="flex h-full items-center justify-center">
<PlatformSubpanel
as="div"
radius="sm"
padding="lg"
className="text-sm text-[var(--platform-text-base)]"
<PlatformEmptyState
surface="subpanel"
size="inline"
className="w-full max-w-md"
>
稿
</PlatformSubpanel>
</PlatformEmptyState>
</div>
);
}

View File

@@ -173,7 +173,7 @@ test('RPG asset debug panel uses PlatformSubpanel shells for summary and entries
}
});
test('RPG asset debug panel uses PlatformSubpanel shell for empty state', () => {
test('RPG asset debug panel uses PlatformEmptyState shell for empty state', () => {
const emptyProfile = {
...createProfileWithAssets(),
playableNpcs: [],
@@ -187,14 +187,15 @@ test('RPG asset debug panel uses PlatformSubpanel shell for empty state', () =>
const emptyPanel = screen
.getByText('当前结果页 profile 里没有拿到任何可诊断的图片地址。')
.closest('section');
.closest('.platform-empty-state');
expect(screen.getByText('0项')).toBeTruthy();
expect(emptyPanel?.className).toContain('platform-subpanel');
expect(emptyPanel?.className).toContain('platform-empty-state');
expect(emptyPanel?.className).toContain('rounded-2xl');
expect(emptyPanel?.className).toContain('bg-black/20');
expect(
container.querySelectorAll(
'section.platform-subpanel, div.platform-subpanel',
'section.platform-subpanel, div.platform-subpanel, div.platform-empty-state',
),
).toHaveLength(5);
});

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
import type { CustomWorldProfile } from '../../types';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
@@ -303,12 +304,14 @@ export function RpgCreationAssetDebugPanel({
);
})
) : (
<PlatformSubpanel
padding="none"
className="rounded-2xl px-3 py-3 text-sm text-zinc-400"
<PlatformEmptyState
surface="editorDark"
size="compact"
tone="soft"
className="rounded-2xl px-3 py-3"
>
profile
</PlatformSubpanel>
</PlatformEmptyState>
)}
</div>
</div>