收口前端平台组件库能力

新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
2026-06-10 10:24:18 +08:00
parent a4ee6ff698
commit 1ad25e30f8
226 changed files with 23364 additions and 7825 deletions

View File

@@ -11,6 +11,28 @@ vi.mock('../../services/jump-hop/useJumpHopLeaderboard', () => ({
useJumpHopLeaderboard: vi.fn(),
}));
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
refreshKey,
}: {
src?: string | null;
alt?: string;
className?: string;
refreshKey?: string | number | null;
}) =>
src ? (
<img
src={src}
alt={alt}
className={className}
data-refresh-key={refreshKey ?? undefined}
/>
) : null,
}));
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useJumpHopLeaderboard).mockReturnValue({
@@ -62,7 +84,17 @@ test('跳一跳结果页展示排行榜列表', () => {
);
expect(screen.getByText('排行榜')).toBeTruthy();
expect(screen.getByText('排行榜').closest('div.bg-white\\/70')).toBeTruthy();
expect(
screen.getByText('排行榜').closest('div.bg-white\\/70')?.className,
).toContain('border-[var(--platform-subpanel-border)]');
expect(screen.getByText('陶泥儿玩家')).toBeTruthy();
const leaderboardEntry = screen.getAllByTestId(
'jump-hop-leaderboard-entry',
)[0];
expect(leaderboardEntry?.className).toContain('bg-white/72');
expect(leaderboardEntry?.className).toContain('rounded-[1rem]');
expect(leaderboardEntry?.className).toContain('p-3');
expect(screen.queryByText('user-secret-1')).toBeNull();
expect(screen.getByText('12 跳')).toBeTruthy();
expect(screen.getByText('00:40')).toBeTruthy();
@@ -70,6 +102,27 @@ test('跳一跳结果页展示排行榜列表', () => {
expect(screen.queryByText('user-secret-2')).toBeNull();
});
test('跳一跳排行榜空态使用平台空态外壳', () => {
render(
<JumpHopResultView
profile={buildProfile({ publicationStatus: 'published' })}
onBack={() => {}}
onEdit={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
onRegenerateTiles={() => {}}
/>,
);
const emptyState = screen.getByTestId('jump-hop-leaderboard-empty');
expect(emptyState.textContent).toContain('暂无成绩');
expect(emptyState.className).toContain('bg-white/74');
expect(emptyState.className).toContain('rounded-[1rem]');
expect(emptyState.className).toContain('px-2');
expect(emptyState.className).toContain('py-2');
});
test('跳一跳结果页默认角色预览使用陶泥儿透明 logo', () => {
render(
<JumpHopResultView
@@ -82,9 +135,125 @@ test('跳一跳结果页默认角色预览使用陶泥儿透明 logo', () => {
/>,
);
expect(screen.getByTestId('jump-hop-result-character-logo').getAttribute('src')).toBe(
'/branding/jump-hop-taonier-character.png',
expect(
screen.getByTestId('jump-hop-result-character-logo').getAttribute('src'),
).toBe('/branding/jump-hop-taonier-character.png');
});
test('跳一跳结果页标准白底面板使用 PlatformSubpanel 外壳', () => {
const { container } = render(
<JumpHopResultView
profile={buildProfile()}
onBack={() => {}}
onEdit={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
onRegenerateTiles={() => {}}
/>,
);
const panels = Array.from(
container.querySelectorAll('section.platform-subpanel'),
);
const mediaFrames = Array.from(
container.querySelectorAll('div.bg-white\\/80'),
);
const actionPanel = screen.getByText('结果操作').closest('section');
expect(panels).toHaveLength(2);
for (const panel of panels) {
expect(panel.className).toContain('rounded-[1.25rem]');
expect(panel.className).toContain('p-4');
}
expect(mediaFrames).toHaveLength(3);
for (const frame of mediaFrames) {
expect(frame.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(frame.className).toContain('rounded-[1rem]');
}
expect(actionPanel?.className).toContain('flex');
expect(screen.getByText('结果操作').className).toContain('tracking-[0.18em]');
});
test('跳一跳结果页图集整图预览使用 PlatformMediaFrame', () => {
const profile = buildProfile({ tileAtlasImageSrc: '/jump-hop/atlas.png' });
const { container } = render(
<JumpHopResultView
profile={profile}
onBack={() => {}}
onEdit={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
onRegenerateTiles={() => {}}
/>,
);
const atlasImage = container.querySelector('img[src="/jump-hop/atlas.png"]');
const mediaFrame = atlasImage?.closest('div.relative');
expect(atlasImage).toBeTruthy();
expect(atlasImage?.getAttribute('data-refresh-key')).toBe('asset-atlas');
expect(mediaFrame?.className).toContain('aspect-square');
expect(mediaFrame?.className).toContain('rounded-none');
expect(mediaFrame?.className).toContain('bg-white/78');
expect(mediaFrame?.className).not.toContain(
'bg-[var(--platform-subpanel-fill)]',
);
});
test('跳一跳结果页地块池预览使用 PlatformMediaTileGrid', () => {
const { container } = render(
<JumpHopResultView
profile={buildProfile({
tileAssets: [
{
tileId: 'tile-1',
tileType: 'normal',
imageSrc: '/jump-hop/tile-1.png',
imageObjectKey: '',
assetObjectId: 'asset-tile-1',
sourceAtlasCell: '0:0:0',
visualWidth: 1,
visualHeight: 1,
topSurfaceRadius: 0.42,
landingRadius: 0.36,
},
{
tileId: 'tile-2',
tileType: 'bonus',
imageSrc: '',
imageObjectKey: '',
assetObjectId: 'asset-tile-2',
sourceAtlasCell: '1:0:0',
visualWidth: 1,
visualHeight: 1,
topSurfaceRadius: 0.42,
landingRadius: 0.36,
},
],
})}
onBack={() => {}}
onEdit={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
onRegenerateTiles={() => {}}
/>,
);
const grid = container.querySelector('.platform-media-tile-grid');
const image = container.querySelector('img[src="/jump-hop/tile-1.png"]');
const tileItems = container.querySelectorAll(
'.platform-media-tile-grid__item',
);
expect(grid?.className).toContain('grid-cols-5');
expect(grid?.className).toContain('aspect-[1/1]');
expect(grid?.className).toContain('bg-white/78');
expect(image?.getAttribute('data-refresh-key')).toBe('asset-tile-1');
expect(image?.className).toContain('object-contain');
expect(tileItems).toHaveLength(2);
});
test('跳一跳结果页根容器允许移动端向下滚动到操作按钮', () => {
@@ -124,8 +293,12 @@ test('跳一跳草稿结果页不请求公开排行榜', () => {
function buildProfile(
options: {
publicationStatus?: JumpHopWorkProfileResponse['summary']['publicationStatus'];
tileAtlasImageSrc?: string | null;
tileAssets?: JumpHopWorkProfileResponse['tileAssets'];
} = {},
): JumpHopWorkProfileResponse {
const tileAtlasImageSrc =
options.tileAtlasImageSrc ?? 'builtin://jump-hop/default-character';
return {
summary: {
runtimeKind: 'jump-hop',
@@ -178,16 +351,16 @@ function buildProfile(
height: 0,
},
tileAtlasAsset: {
assetId: 'builtin',
imageSrc: 'builtin://jump-hop/default-character',
assetId: 'asset-atlas',
imageSrc: tileAtlasImageSrc,
imageObjectKey: '',
assetObjectId: 'builtin',
assetObjectId: 'asset-atlas',
generationProvider: 'builtin-three',
prompt: '默认角色',
width: 0,
height: 0,
},
tileAssets: [],
tileAssets: options.tileAssets ?? [],
path: null,
coverComposite: null,
backButtonAsset: null,
@@ -212,16 +385,16 @@ function buildProfile(
height: 0,
},
tileAtlasAsset: {
assetId: 'builtin',
imageSrc: 'builtin://jump-hop/default-character',
assetId: 'asset-atlas',
imageSrc: tileAtlasImageSrc,
imageObjectKey: '',
assetObjectId: 'builtin',
assetObjectId: 'asset-atlas',
generationProvider: 'builtin-three',
prompt: '默认角色',
width: 0,
height: 0,
},
tileAssets: [],
tileAssets: options.tileAssets ?? [],
backButtonAsset: null,
};
}

View File

@@ -1,10 +1,4 @@
import {
ArrowLeft,
Loader2,
Play,
Send,
Shuffle,
} from 'lucide-react';
import { ArrowLeft, Loader2, Play, Send, Shuffle } from 'lucide-react';
import { type CSSProperties, useState } from 'react';
import type {
@@ -18,6 +12,12 @@ import {
selectJumpHopTileAsset,
} from '../../services/jump-hop/jumpHopRuntimeModel';
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformMediaTileGrid } from '../common/PlatformMediaTileGrid';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type JumpHopResultViewProps = {
@@ -89,58 +89,69 @@ function JumpHopTilePoolPreview({
const atlasRefreshKey = tileAtlasAsset?.assetObjectId || atlasSrc;
if (visibleTiles.length > 0) {
return (
<div className="grid aspect-[1/1] grid-cols-5 gap-1 bg-white/78 p-2">
{visibleTiles.map((tile, index) => (
<div
key={tile.tileId ?? `${tile.sourceAtlasCell}-${index}`}
className="grid min-h-0 place-items-center overflow-hidden rounded-[0.45rem] border border-white/80 bg-slate-50"
>
{tile.imageSrc ? (
<ResolvedAssetImage
src={tile.imageSrc}
refreshKey={tile.assetObjectId}
alt=""
className="h-full w-full object-contain"
/>
) : (
<span
className="h-4 w-4 rounded-full"
style={{
background:
tileToneByType[tile.tileType] ?? tileToneByType.normal,
}}
/>
)}
</div>
))}
</div>
<PlatformMediaTileGrid
columns="five"
gap="xs"
aspect="square"
surface="soft"
tileSurface="slate"
imageClassName="h-full w-full object-contain"
items={visibleTiles.map((tile, index) => ({
id: tile.tileId ?? `${tile.sourceAtlasCell}-${index}`,
src: tile.imageSrc,
refreshKey: tile.assetObjectId,
fallbackLabel: '地块',
fallbackContent: (
<span
className="h-4 w-4 rounded-full"
style={{
background:
tileToneByType[tile.tileType] ?? tileToneByType.normal,
}}
/>
),
}))}
/>
);
}
if (atlasSrc) {
return (
<ResolvedAssetImage
<PlatformMediaFrame
src={atlasSrc}
refreshKey={atlasRefreshKey}
alt=""
className="aspect-[1/1] w-full object-cover"
fallbackLabel="地块图集"
aspect="square"
surface="none"
className="rounded-none bg-white/78"
/>
);
}
return (
<div className="grid aspect-[1/1] grid-cols-5 gap-1 bg-white/78 p-2">
{Array.from({ length: 25 }).map((_, index) => (
<span
key={index}
className="rounded-[0.45rem] border border-white/80"
style={{
background:
Object.values(tileToneByType)[index % Object.values(tileToneByType).length],
}}
/>
))}
</div>
<PlatformMediaTileGrid
columns="five"
gap="xs"
aspect="square"
surface="soft"
tileSurface="bare"
items={Array.from({ length: 25 }).map((_, index) => ({
id: `fallback-${index}`,
fallbackLabel: '地块',
fallbackContent: (
<span
className="h-full w-full"
style={{
background:
Object.values(tileToneByType)[
index % Object.values(tileToneByType).length
],
}}
/>
),
}))}
/>
);
}
@@ -213,7 +224,13 @@ function JumpHopResultLeaderboard({
const items = leaderboard?.items ?? [];
return (
<div className="mt-4 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3">
<PlatformSubpanel
as="div"
surface="flat"
radius="sm"
padding="sm"
className="mt-4 bg-white/70"
>
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
@@ -224,9 +241,14 @@ function JumpHopResultLeaderboard({
</div>
<div className="mt-3 grid gap-2">
{items.slice(0, 5).map((entry) => (
<div
<PlatformSubpanel
as="div"
key={`${entry.rank}-${entry.playerId}`}
className="grid grid-cols-[1.8rem_minmax(0,1fr)_auto_auto] items-center gap-2 rounded-[0.75rem] bg-white/70 px-2 py-2 text-xs font-bold text-[var(--platform-text-base)]"
surface="flat"
radius="sm"
padding="sm"
className="grid grid-cols-[1.8rem_minmax(0,1fr)_auto_auto] items-center gap-2 text-xs font-bold text-[var(--platform-text-base)]"
data-testid="jump-hop-leaderboard-entry"
>
<span className="text-[var(--platform-text-soft)]">
{entry.rank}
@@ -236,15 +258,20 @@ function JumpHopResultLeaderboard({
</span>
<span>{entry.successfulJumpCount} </span>
<span>{formatJumpHopDurationLabel(entry.durationMs)}</span>
</div>
</PlatformSubpanel>
))}
{items.length === 0 ? (
<div className="rounded-[0.75rem] bg-white/60 px-2 py-2 text-xs font-bold text-[var(--platform-text-soft)]">
<PlatformEmptyState
surface="subpanel"
size="compact"
className="px-2 py-2 text-left text-xs font-bold"
data-testid="jump-hop-leaderboard-empty"
>
{error ?? '暂无成绩'}
</div>
</PlatformEmptyState>
) : null}
</div>
</div>
</PlatformSubpanel>
);
}
@@ -289,7 +316,7 @@ export function JumpHopResultView({
profile.pathPreviewImageSrc?.trim() ||
tileAtlasAsset?.imageSrc?.trim() ||
tileAssets.length > 0 ||
path?.platforms.length,
Boolean(path?.platforms.length),
);
const handlePublish = async () => {
@@ -304,29 +331,31 @@ export function JumpHopResultView({
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-y-auto overscroll-contain px-3 pb-[max(1.5rem,env(safe-area-inset-bottom))] pt-3 sm:px-4 sm:pt-4">
<div className="mb-3 flex items-center justify-between gap-3">
<button
type="button"
<PlatformActionButton
onClick={onBack}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
tone="ghost"
size="xs"
className="min-h-0 gap-2 py-2 text-sm"
>
<ArrowLeft className="h-4 w-4" />
</button>
</PlatformActionButton>
<div className="flex gap-2">
<button
type="button"
<PlatformActionButton
onClick={onRegenerateTiles}
disabled={isBusy}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
tone="ghost"
size="xs"
className="min-h-0 gap-2 py-2 text-sm"
>
<Shuffle className="h-4 w-4" />
</button>
</PlatformActionButton>
</div>
</div>
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
<section className="platform-subpanel rounded-[1.25rem] p-4">
<PlatformSubpanel>
<div className="text-2xl font-black text-[var(--platform-text-strong)]">
{title}
</div>
@@ -336,73 +365,99 @@ export function JumpHopResultView({
</div>
) : null}
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
<PlatformSubpanel
as="div"
surface="flat"
radius="sm"
padding="none"
className="overflow-hidden bg-white/80"
>
<JumpHopDefaultCharacterPreview />
</div>
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
</PlatformSubpanel>
<PlatformSubpanel
as="div"
surface="flat"
radius="sm"
padding="none"
className="overflow-hidden bg-white/80"
>
<JumpHopTilePoolPreview
tileAssets={tileAssets}
tileAtlasAsset={tileAtlasAsset}
tileAtlasFallbackSrc={
('tileAtlasImageSrc' in profile
? profile.tileAtlasImageSrc
: null) ??
null
: null) ?? null
}
/>
</div>
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
</PlatformSubpanel>
<PlatformSubpanel
as="div"
surface="flat"
radius="sm"
padding="none"
className="overflow-hidden bg-white/80"
>
<JumpHopFirstPlatformsPreview
path={path}
tileAssets={tileAssets}
/>
</div>
</PlatformSubpanel>
</div>
{!hasAssets ? (
<div className="platform-banner platform-banner--neutral mt-3 rounded-2xl text-sm leading-6">
<PlatformStatusMessage
tone="neutral"
surface="platform"
size="md"
className="mt-3 rounded-2xl"
>
</div>
</PlatformStatusMessage>
) : null}
</section>
</PlatformSubpanel>
<section className="platform-subpanel flex flex-col rounded-[1.25rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<PlatformSubpanel title="结果操作" className="flex flex-col">
{canShowLeaderboard ? (
<JumpHopResultLeaderboard profileId={profileId} />
) : null}
{error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
<PlatformStatusMessage
tone="error"
surface="platform"
size="md"
className="mt-3"
>
{error}
</div>
</PlatformStatusMessage>
) : null}
<div className="mt-auto grid gap-2 pt-3">
<button
type="button"
<PlatformActionButton
onClick={onEdit}
disabled={isBusy}
className="platform-button platform-button--ghost min-h-11 justify-center gap-2 px-4 py-3 text-sm"
tone="ghost"
size="md"
className="min-h-11 gap-2"
>
<ArrowLeft className="h-4 w-4" />
</button>
<button
type="button"
</PlatformActionButton>
<PlatformActionButton
onClick={onStartTestRun}
disabled={isBusy}
className="platform-button platform-button--secondary min-h-11 justify-center gap-2 px-4 py-3 text-sm"
tone="secondary"
size="md"
className="min-h-11 gap-2"
>
<Play className="h-4 w-4" />
</button>
<button
type="button"
</PlatformActionButton>
<PlatformActionButton
onClick={() => {
void handlePublish();
}}
disabled={isBusy || isPublishing}
className="platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 text-sm"
size="md"
className="min-h-11 gap-2"
>
{isPublishing ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -410,9 +465,9 @@ export function JumpHopResultView({
<Send className="h-4 w-4" />
)}
</button>
</PlatformActionButton>
</div>
</section>
</PlatformSubpanel>
</div>
</div>
);