继续收口详情页头部动作组合

沉淀 PlatformDetailTopbar 与 PlatformDetailShareActions 共享骨架
接入 RPG 世界详情与公开作品详情的返回复制分享动作组合
补充测试护栏与文档决策记录
This commit is contained in:
2026-06-11 06:32:20 +08:00
parent d08842b576
commit 7c47ad3358
8 changed files with 420 additions and 117 deletions

View File

@@ -0,0 +1,52 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformDetailShareActions } from './PlatformDetailShareActions';
test('renders overlay detail share actions with copied share state', () => {
render(
<PlatformDetailShareActions
workCode="CW-001"
copyState="idle"
onCopyWorkCode={vi.fn()}
shareState="copied"
onShare={vi.fn()}
shareAriaLabel="分享作品 测试世界"
leading={<span></span>}
variant="overlay"
/>,
);
const codeButton = screen.getByRole('button', { name: '复制作品号 CW-001' });
const shareButton = screen.getByRole('button', { name: '分享作品 测试世界' });
expect(screen.getByText('已发布')).toBeTruthy();
expect(codeButton.className).toContain('bg-white/72');
expect(codeButton.className).toContain('tracking-[0.18em]');
expect(shareButton.className).toContain('bg-white/72');
expect(screen.getByText('已复制')).toBeTruthy();
});
test('renders solid detail share actions with compact work code chip', () => {
render(
<PlatformDetailShareActions
workCode="PZ-001"
copyState="idle"
onCopyWorkCode={vi.fn()}
shareState="idle"
onShare={vi.fn()}
shareAriaLabel="分享作品 拼图世界"
leading={<span></span>}
variant="solid"
/>,
);
const codeButton = screen.getByRole('button', { name: 'PZ-001' });
const shareButton = screen.getByRole('button', { name: '分享作品 拼图世界' });
expect(codeButton.className).toContain('bg-[var(--platform-neutral-bg)]');
expect(shareButton.className).toContain('bg-[var(--platform-neutral-bg)]');
expect(screen.getByText('已发布')).toBeTruthy();
});

View File

@@ -0,0 +1,143 @@
import { Copy, Share2 } from 'lucide-react';
import type { ReactNode } from 'react';
import { CopyCodeButton } from './CopyCodeButton';
import { CopyFeedbackButton } from './CopyFeedbackButton';
import type { CopyFeedbackState } from './useCopyFeedback';
type PlatformDetailShareActionsProps = {
workCode?: string | null;
copyState: CopyFeedbackState;
onCopyWorkCode?: () => void;
shareState: CopyFeedbackState;
onShare?: () => void;
shareAriaLabel?: string;
shareTitle?: string;
leading?: ReactNode;
showCopyAction?: boolean;
showShareAction?: boolean;
variant?: 'overlay' | 'solid';
className?: string;
copyClassName?: string;
shareClassName?: string;
copyCodeLabel?: ReactNode;
copyAccessibleLabel?: string;
};
const VARIANT_COPY_CLASS = {
overlay: 'px-3 tracking-[0.18em]',
solid: '',
} as const;
const VARIANT_SHARE_CLASS = {
overlay: 'px-3 tracking-[0.18em]',
solid: '',
} as const;
const VARIANT_PILL_TONE = {
overlay: 'neutral',
solid: 'neutralSolid',
} as const;
const VARIANT_PILL_SIZE = {
overlay: 'xxs',
solid: 'sm',
} as const;
const VARIANT_ICON_CLASS = {
overlay: 'h-3 w-3',
solid: 'h-4 w-4',
} as const;
const VARIANT_SUFFIX_CLASS = {
overlay: 'text-xs',
solid: 'text-[11px]',
} as const;
function renderShareLabel(suffix: ReactNode | null, suffixClassName: string) {
return (
<>
<span></span>
{suffix ? <span className={suffixClassName}>{suffix}</span> : null}
</>
);
}
/**
* 详情页作品号 / 分享动作组合。
* 共享层只承接状态 badge 槽位、复制作品号和分享按钮这组稳定骨架。
*/
export function PlatformDetailShareActions({
workCode,
copyState,
onCopyWorkCode,
shareState,
onShare,
shareAriaLabel,
shareTitle = '分享作品',
leading,
showCopyAction = true,
showShareAction = true,
variant = 'overlay',
className,
copyClassName,
shareClassName,
copyCodeLabel,
copyAccessibleLabel,
}: PlatformDetailShareActionsProps) {
const canShowCopyAction = showCopyAction && Boolean(workCode);
const canShowShareAction = showShareAction && Boolean(workCode);
if (!leading && !canShowCopyAction && !canShowShareAction) {
return null;
}
const iconClassName = VARIANT_ICON_CLASS[variant];
const shareSuffixClassName = VARIANT_SUFFIX_CLASS[variant];
const resolvedCopyCodeLabel =
copyCodeLabel ?? (variant === 'solid' ? null : '作品号');
const resolvedCopyAccessibleLabel =
copyAccessibleLabel ?? (variant === 'solid' ? workCode ?? undefined : undefined);
return (
<div className={['flex flex-wrap items-center gap-2', className].filter(Boolean).join(' ')}>
{leading}
{canShowCopyAction ? (
<CopyCodeButton
state={copyState}
code={workCode ?? ''}
codeLabel={resolvedCopyCodeLabel}
accessibleLabel={resolvedCopyAccessibleLabel}
title="复制作品号"
onClick={onCopyWorkCode}
disabled={!onCopyWorkCode}
actionAppearance="pill"
actionPillTone={VARIANT_PILL_TONE[variant]}
actionPillSize={VARIANT_PILL_SIZE[variant]}
className={[VARIANT_COPY_CLASS[variant], copyClassName].filter(Boolean).join(' ')}
idleIcon={<Copy className={iconClassName} />}
copiedIcon={<Copy className={iconClassName} />}
suffixClassName={shareSuffixClassName}
/>
) : null}
{canShowShareAction ? (
<CopyFeedbackButton
state={shareState}
onClick={onShare}
disabled={!onShare}
actionAppearance="pill"
actionPillTone={VARIANT_PILL_TONE[variant]}
actionPillSize={VARIANT_PILL_SIZE[variant]}
className={[VARIANT_SHARE_CLASS[variant], shareClassName].filter(Boolean).join(' ')}
aria-label={shareAriaLabel}
title={shareTitle}
idleLabel={renderShareLabel(null, shareSuffixClassName)}
copiedLabel={renderShareLabel('已复制', shareSuffixClassName)}
failedLabel={renderShareLabel('复制失败', shareSuffixClassName)}
idleIcon={<Share2 className={iconClassName} />}
copiedIcon={<Share2 className={iconClassName} />}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,49 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformDetailTopbar } from './PlatformDetailTopbar';
test('renders pill back action with trailing slot', () => {
const onBack = vi.fn();
render(
<PlatformDetailTopbar
onBack={onBack}
className="grid-cols-[auto,minmax(0,1fr),auto]"
backButtonClassName="px-3"
trailing={<span></span>}
/>,
);
const button = screen.getByRole('button', { name: '返回' });
expect(button.className).toContain('platform-button--ghost');
expect(button.className).toContain('px-3');
expect(screen.getByText('已发布')).toBeTruthy();
fireEvent.click(button);
expect(onBack).toHaveBeenCalledTimes(1);
});
test('renders icon back action and centered title', () => {
render(
<PlatformDetailTopbar
onBack={vi.fn()}
backVariant="icon"
backButtonClassName="detail-icon-back"
title="详情"
titleClassName="detail-topbar-title"
trailing={<span className="invisible"></span>}
/>,
);
const button = screen.getByRole('button', { name: '返回' });
const title = screen.getByText('详情');
expect(button.className).toContain('platform-icon-button');
expect(button.className).toContain('detail-icon-back');
expect(title.className).toContain('detail-topbar-title');
});

View File

@@ -0,0 +1,89 @@
import { ArrowLeft } from 'lucide-react';
import type { ReactNode } from 'react';
import { PlatformBackActionButton } from './PlatformBackActionButton';
import { PlatformIconButton } from './PlatformIconButton';
type PlatformDetailTopbarProps = {
onBack: () => void;
title?: ReactNode;
trailing?: ReactNode;
backVariant?: 'icon' | 'pill';
backLabel?: string;
className?: string;
backButtonClassName?: string;
titleClassName?: string;
trailingClassName?: string;
};
/**
* 详情页顶部动作骨架。
* 只统一返回、标题和右侧动作槽位的布局,不吸收页面自己的标题文案或业务动作。
*/
export function PlatformDetailTopbar({
onBack,
title,
trailing,
backVariant = 'pill',
backLabel = '返回',
className,
backButtonClassName,
titleClassName,
trailingClassName,
}: PlatformDetailTopbarProps) {
const backAction =
backVariant === 'icon' ? (
<PlatformIconButton
label={backLabel}
title={backLabel}
className={backButtonClassName}
onClick={onBack}
icon={<ArrowLeft className="h-6 w-6" />}
/>
) : (
<PlatformBackActionButton
onClick={onBack}
label={backLabel}
className={backButtonClassName}
/>
);
return (
<div
className={[
'grid min-w-0 grid-cols-[auto,minmax(0,1fr),auto] items-center gap-3',
className,
]
.filter(Boolean)
.join(' ')}
>
<div className="min-w-0 justify-self-start">
{backAction}
</div>
{title ? (
<div
className={[
'min-w-0 text-center',
titleClassName,
]
.filter(Boolean)
.join(' ')}
>
{title}
</div>
) : (
<div aria-hidden="true" />
)}
<div
className={[
'min-w-0 justify-self-end',
trailingClassName,
]
.filter(Boolean)
.join(' ')}
>
{trailing}
</div>
</div>
);
}

View File

@@ -1,10 +1,8 @@
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
CircleHelp,
Clock3,
Copy,
Gamepad2,
GitFork,
Heart,
@@ -16,8 +14,9 @@ import { useEffect, useMemo, useState } from 'react';
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import { CopyCodeButton } from '../common/CopyCodeButton';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformDetailShareActions } from '../common/PlatformDetailShareActions';
import { PlatformDetailTopbar } from '../common/PlatformDetailTopbar';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
@@ -254,24 +253,27 @@ export function PlatformWorkDetailView({
return (
<div className="platform-work-detail">
<div className="platform-work-detail__topbar">
<PlatformIconButton
label="返回"
className="platform-work-detail__icon-button"
onClick={onBack}
title="返回"
icon={<ArrowLeft className="h-6 w-6" />}
/>
<div className="platform-work-detail__title"></div>
<PlatformIconButton
label="分享"
className="platform-work-detail__icon-button"
onClick={sharePublicWork}
disabled={!publicWorkCode}
title="分享"
icon={<Share2 className="h-5 w-5" />}
/>
</div>
<PlatformDetailTopbar
onBack={onBack}
backVariant="icon"
title={
<div className="platform-work-detail__title">
</div>
}
className="platform-work-detail__topbar"
backButtonClassName="platform-work-detail__icon-button"
trailing={
<PlatformIconButton
label="分享"
className="platform-work-detail__icon-button"
onClick={sharePublicWork}
disabled={!publicWorkCode}
title="分享"
icon={<Share2 className="h-5 w-5" />}
/>
}
/>
<div className="platform-work-detail__scroll">
<section className="platform-work-detail__cover">
@@ -439,22 +441,18 @@ export function PlatformWorkDetailView({
))}
</div>
<p className="platform-work-detail__copy">{entry.summaryText}</p>
{publicWorkCode ? (
<CopyCodeButton
state={copyState}
code={publicWorkCode}
codeLabel={null}
accessibleLabel={publicWorkCode}
title="复制作品号"
actionAppearance="pill"
actionPillTone="neutralSolid"
actionPillSize="sm"
className="platform-work-detail__code"
onClick={copyPublicWorkCode}
idleIcon={<Copy className="h-4 w-4" />}
copiedIcon={<Copy className="h-4 w-4" />}
/>
) : null}
<PlatformDetailShareActions
workCode={publicWorkCode}
copyState={copyState}
onCopyWorkCode={copyPublicWorkCode}
shareState={shareState}
onShare={sharePublicWork}
shareAriaLabel={`分享作品 ${entry.worldName}`}
leading={null}
showShareAction={false}
variant="solid"
className="platform-work-detail__code"
/>
{shareState !== 'idle' ? (
<PlatformStatusMessage
tone={shareState === 'copied' ? 'success' : 'error'}

View File

@@ -1,13 +1,10 @@
import { Copy, Share2 } from 'lucide-react';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import type { CustomWorldProfile } from '../../types';
import { CopyCodeButton } from '../common/CopyCodeButton';
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBackActionButton } from '../common/PlatformBackActionButton';
import { PlatformDetailShareActions } from '../common/PlatformDetailShareActions';
import { PlatformDetailTopbar } from '../common/PlatformDetailTopbar';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
@@ -80,19 +77,20 @@ export function RpgEntryWorldDetailView({
return (
<div className="flex h-full min-h-0 flex-col">
<div className="mb-4 flex items-center justify-between gap-3">
<PlatformBackActionButton
onClick={onBack}
className="px-3"
/>
<PlatformPillBadge
tone="neutral"
size="xs"
className="px-3 py-1.5 tracking-[0.08em]"
>
{entry.visibility === 'published' ? '已发布' : '草稿'}
</PlatformPillBadge>
</div>
<PlatformDetailTopbar
onBack={onBack}
className="mb-4"
backButtonClassName="px-3"
trailing={
<PlatformPillBadge
tone="neutral"
size="xs"
className="px-3 py-1.5 tracking-[0.08em]"
>
{entry.visibility === 'published' ? '已发布' : '草稿'}
</PlatformPillBadge>
}
/>
<div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide">
<div className="space-y-4 pb-2">
@@ -114,72 +112,44 @@ export function RpgEntryWorldDetailView({
) : null}
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10">
<div className="flex flex-wrap items-center gap-2">
<PlatformPillBadge
tone="warning"
size="xxs"
className="tracking-[0.18em]"
>
{formatPlatformWorkDisplayTag(
describePlatformThemeLabel(entry.themeMode),
)}
</PlatformPillBadge>
<PlatformPillBadge
tone="neutral"
size="xxs"
className="px-3 tracking-[0.18em]"
>
{entry.authorDisplayName}
</PlatformPillBadge>
<PlatformPillBadge
tone="neutral"
size="xxs"
className="px-3 tracking-[0.18em]"
>
{entry.visibility === 'published'
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
: '仅自己可见'}
</PlatformPillBadge>
{publicWorkCode ? (
<PlatformDetailShareActions
workCode={publicWorkCode}
copyState={copyState}
onCopyWorkCode={copyPublicWorkCode}
shareState={shareState}
onShare={sharePublicWork}
shareAriaLabel={`分享作品 ${entry.worldName}`}
leading={
<>
<CopyCodeButton
state={copyState}
code={publicWorkCode}
onClick={copyPublicWorkCode}
actionAppearance="pill"
actionPillSize="xxs"
<PlatformPillBadge
tone="warning"
size="xxs"
className="tracking-[0.18em]"
>
{formatPlatformWorkDisplayTag(
describePlatformThemeLabel(entry.themeMode),
)}
</PlatformPillBadge>
<PlatformPillBadge
tone="neutral"
size="xxs"
className="px-3 tracking-[0.18em]"
idleIcon={<Copy className="h-3 w-3" />}
copiedIcon={<Copy className="h-3 w-3" />}
suffixClassName="text-xs"
/>
<CopyFeedbackButton
state={shareState}
onClick={sharePublicWork}
actionAppearance="pill"
actionPillSize="xxs"
>
{entry.authorDisplayName}
</PlatformPillBadge>
<PlatformPillBadge
tone="neutral"
size="xxs"
className="px-3 tracking-[0.18em]"
aria-label={`分享作品 ${entry.worldName}`}
title="分享作品"
idleLabel="分享作品"
copiedLabel={
<>
<span></span>
<span className="text-xs"></span>
</>
}
failedLabel={
<>
<span></span>
<span className="text-xs"></span>
</>
}
idleIcon={<Share2 className="h-3 w-3" />}
copiedIcon={<Share2 className="h-3 w-3" />}
/>
>
{entry.visibility === 'published'
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
: '仅自己可见'}
</PlatformPillBadge>
</>
) : null}
</div>
}
variant="overlay"
/>
<div className="mt-4 text-3xl font-black text-white">
{displayName}
</div>