收口前端平台组件能力

新增 PlatformAsyncStatePanel 统一 profile 异步状态骨架
扩展 PlatformSegmentedTabs 支持滚动 tab 并接入创作入口与发现页
统一 PixelCloseButton 复用 PlatformModalCloseButton 像素关闭能力
抽取平台入口泥点前置提示弹层并收紧阻断语义
补充组件收口文档与共享决策记录
This commit is contained in:
2026-06-11 01:06:31 +08:00
parent edf37d97a7
commit 94122583ac
22 changed files with 897 additions and 445 deletions

View File

@@ -1,6 +1,7 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import type { CharacterChatModalState } from '../hooks/rpg-runtime-story';
@@ -110,3 +111,27 @@ test('角色聊天状态、空态和建议复用暗色 UI Kit chrome', () => {
expect(draftTextarea.className).toContain('platform-text-field--editor-dark');
expect(draftTextarea.className).toContain('focus:border-sky-300/35');
});
test('角色聊天标题栏内联关闭按钮保持共享关闭行为', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(
<CharacterChatModal
modal={createModalState()}
onClose={onClose}
onDraftChange={vi.fn()}
onUseSuggestion={vi.fn()}
onRefreshSuggestions={vi.fn()}
onSendDraft={vi.fn()}
/>,
);
const closeButton = screen.getByRole('button', { name: '关闭角色聊天' });
await user.click(closeButton);
expect(closeButton.className).toContain('relative');
expect(closeButton.className).toContain('shrink-0');
expect(closeButton.getAttribute('title')).toBe('关闭角色聊天');
expect(onClose).toHaveBeenCalledTimes(1);
});

View File

@@ -105,3 +105,30 @@ test('目标场景确认面板复用暗色琥珀 PlatformSubpanel 和胶囊标
);
expect(confirmButton.className).toContain('bg-amber-500/20');
});
test('地图右上关闭按钮复用共享像素关闭按钮能力', () => {
const currentScene = getWorldCampScenePreset(WorldType.WUXIA);
if (!currentScene) {
throw new Error('测试需要武侠营地场景');
}
const onClose = vi.fn();
render(
<MapModal
isOpen
currentScenePreset={currentScene}
worldType={WorldType.WUXIA}
onClose={onClose}
onTravelToScene={vi.fn()}
/>,
);
const closeButton = screen.getByRole('button', { name: '关闭地图' });
fireEvent.click(closeButton);
expect(closeButton.className).toContain('absolute');
expect(closeButton.className).toContain('right-4');
expect(closeButton.getAttribute('title')).toBe('关闭地图');
expect(onClose).toHaveBeenCalledTimes(1);
});

View File

@@ -1,6 +1,5 @@
import type { MouseEvent } from 'react';
import { CHROME_ICONS } from '../uiAssets';
import { PlatformModalCloseButton } from './common/PlatformModalCloseButton';
import { PixelIcon } from './PixelIcon';
type PixelCloseButtonProps = {
@@ -12,7 +11,7 @@ type PixelCloseButtonProps = {
/**
* RPG 像素风弹窗右上关闭按钮。
* 统一拦截点击冒泡,避免历史手写 overlay / panel 的点击处理影响关闭行为
* 这里只保留 RPG 语义层封装,底层样式与行为统一复用共享 close button
*/
export function PixelCloseButton({
onClick,
@@ -20,26 +19,16 @@ export function PixelCloseButton({
placement = 'absolute',
className = '',
}: PixelCloseButtonProps) {
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onClick();
};
const placementClassName =
placement === 'absolute'
? 'absolute right-4 top-3 sm:right-5 sm:top-4'
: 'relative shrink-0';
return (
<button
type="button"
aria-label={label}
<PlatformModalCloseButton
label={label}
title={label}
onClick={handleClick}
className={`${placementClassName} z-20 flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/30 p-0 text-zinc-400 shadow-[0_8px_18px_rgba(0,0,0,0.28)] transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200/70 ${className}`.trim()}
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
variant="pixel"
placement={placement}
stopPropagation
onClick={() => onClick()}
className={['z-20', className].filter(Boolean).join(' ')}
icon={<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />}
/>
);
}

View File

@@ -0,0 +1,61 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import { PlatformAsyncStatePanel } from './PlatformAsyncStatePanel';
describe('PlatformAsyncStatePanel', () => {
test('prefers error state over loading and content', () => {
render(
<PlatformAsyncStatePanel
errorState={<div></div>}
isLoading
loadingState={<div></div>}
>
<div></div>
</PlatformAsyncStatePanel>,
);
expect(screen.getByText('出错了')).toBeTruthy();
expect(screen.queryByText('读取中')).toBeNull();
expect(screen.queryByText('内容')).toBeNull();
});
test('renders loading state before empty state', () => {
render(
<PlatformAsyncStatePanel
isLoading
isEmpty
loadingState={<div></div>}
emptyState={<div></div>}
>
<div></div>
</PlatformAsyncStatePanel>,
);
expect(screen.getByText('读取中')).toBeTruthy();
expect(screen.queryByText('暂无内容')).toBeNull();
});
test('renders empty state when requested', () => {
render(
<PlatformAsyncStatePanel isEmpty emptyState={<div></div>}>
<div></div>
</PlatformAsyncStatePanel>,
);
expect(screen.getByText('暂无内容')).toBeTruthy();
expect(screen.queryByText('内容')).toBeNull();
});
test('falls back to content when no async state is active', () => {
render(
<PlatformAsyncStatePanel>
<div></div>
</PlatformAsyncStatePanel>,
);
expect(screen.getByText('内容')).toBeTruthy();
});
});

View File

@@ -0,0 +1,37 @@
import type { ReactNode } from 'react';
type PlatformAsyncStatePanelProps = {
errorState?: ReactNode;
isLoading?: boolean;
loadingState?: ReactNode;
isEmpty?: boolean;
emptyState?: ReactNode;
children: ReactNode;
};
/**
* 平台异步状态面板骨架。
* 只负责在错误、读取、空态和内容之间切换,具体文案与外观继续交给调用方传入 slot。
*/
export function PlatformAsyncStatePanel({
errorState,
isLoading = false,
loadingState = null,
isEmpty = false,
emptyState = null,
children,
}: PlatformAsyncStatePanelProps) {
if (errorState !== undefined && errorState !== null) {
return <>{errorState}</>;
}
if (isLoading) {
return <>{loadingState}</>;
}
if (isEmpty) {
return <>{emptyState}</>;
}
return <>{children}</>;
}

View File

@@ -1,7 +1,8 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { PlatformModalCloseButton } from './PlatformModalCloseButton';
@@ -80,9 +81,37 @@ test('supports pixel close button', () => {
const button = screen.getByRole('button', { name: '关闭像素弹窗' });
expect(button.className).toContain('bg-black/20');
expect(button.className).toContain('bg-black/30');
expect(button.className).toContain('text-zinc-400');
expect(button.className).toContain('absolute');
expect(button.className).toContain('disabled:opacity-45');
expect(button.getAttribute('title')).toBe('关闭像素弹窗');
});
test('supports inline pixel placement and intercepted click behavior', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
const onHeaderClick = vi.fn();
render(
<div onClick={onHeaderClick}>
<PlatformModalCloseButton
label="关闭像素标题栏"
variant="pixel"
placement="inline"
stopPropagation
onClick={onClick}
/>
</div>,
);
const button = screen.getByRole('button', { name: '关闭像素标题栏' });
await user.click(button);
expect(button.className).toContain('relative');
expect(button.className).toContain('shrink-0');
expect(onClick).toHaveBeenCalledTimes(1);
expect(onHeaderClick).not.toHaveBeenCalled();
});
test('supports editor dark close button', () => {

View File

@@ -1,5 +1,9 @@
import { X } from 'lucide-react';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import type {
ButtonHTMLAttributes,
MouseEvent as ReactMouseEvent,
ReactNode,
} from 'react';
type PlatformModalCloseButtonVariant =
| 'profile'
@@ -10,6 +14,8 @@ type PlatformModalCloseButtonVariant =
| 'pixel'
| 'editorDark';
type PlatformModalCloseButtonPlacement = 'absolute' | 'inline';
type PlatformModalCloseButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children'
@@ -17,6 +23,8 @@ type PlatformModalCloseButtonProps = Omit<
label: string;
variant?: PlatformModalCloseButtonVariant;
icon?: ReactNode;
placement?: PlatformModalCloseButtonPlacement;
stopPropagation?: boolean;
};
const PLATFORM_MODAL_CLOSE_BUTTON_CLASS_BY_VARIANT: Record<
@@ -33,11 +41,19 @@ const PLATFORM_MODAL_CLOSE_BUTTON_CLASS_BY_VARIANT: Record<
'absolute right-3 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-full text-[#ff4056]',
platformIcon: 'platform-icon-button disabled:cursor-not-allowed disabled:opacity-45',
pixel:
'rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-45',
'flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/30 p-0 text-zinc-400 shadow-[0_8px_18px_rgba(0,0,0,0.28)] transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200/70 disabled:cursor-not-allowed disabled:opacity-45',
editorDark:
'platform-modal-close-button--editor-dark rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white',
};
const PLATFORM_MODAL_CLOSE_BUTTON_PIXEL_PLACEMENT_CLASS_BY_PLACEMENT: Record<
PlatformModalCloseButtonPlacement,
string
> = {
absolute: 'absolute right-4 top-3 sm:right-5 sm:top-4',
inline: 'relative shrink-0',
};
/**
* 平台弹窗关闭按钮。
* 收口个人中心和平台浮层里重复的关闭 aria、尺寸和视觉样式。
@@ -48,15 +64,35 @@ export function PlatformModalCloseButton({
icon = <X className="h-4 w-4" />,
className,
type = 'button',
placement = 'absolute',
stopPropagation = false,
onClick,
title,
...buttonProps
}: PlatformModalCloseButtonProps) {
const handleClick = (event: ReactMouseEvent<HTMLButtonElement>) => {
if (stopPropagation) {
event.preventDefault();
event.stopPropagation();
}
onClick?.(event);
};
return (
<button
{...buttonProps}
type={type}
aria-label={label}
title={title ?? label}
onClick={handleClick}
className={[
PLATFORM_MODAL_CLOSE_BUTTON_CLASS_BY_VARIANT[variant],
variant === 'pixel'
? PLATFORM_MODAL_CLOSE_BUTTON_PIXEL_PLACEMENT_CLASS_BY_PLACEMENT[
placement
]
: null,
className,
]
.filter(Boolean)

View File

@@ -162,3 +162,31 @@ test('supports auth-style tab semantics with underline tone', () => {
expect(onChange).toHaveBeenCalledWith('password');
});
test('supports scroll layout for horizontal tabs', () => {
render(
<PlatformSegmentedTabs
items={[
{ id: 'recent', label: '最近创作' },
{ id: 'rpg', label: '文字冒险' },
{ id: 'jump-hop', label: '跳一跳' },
]}
activeId="recent"
onChange={vi.fn()}
layout="scroll"
semantics="tabs"
ariaLabel="创作入口页签"
frame="bare"
surface="transparent"
className="pb-1"
/>,
);
const tablist = screen.getByRole('tablist', { name: '创作入口页签' });
expect(tablist.className).toContain('flex');
expect(tablist.className).toContain('overflow-x-auto');
expect(tablist.className).toContain('scrollbar-hide');
expect(tablist.className).not.toContain('grid-cols-2');
expect(tablist.className).toContain('pb-1');
});

View File

@@ -13,6 +13,7 @@ type PlatformSegmentedTabsSurface = 'default' | 'soft' | 'transparent';
type PlatformSegmentedTabsTone = 'neutral' | 'warm' | 'rose' | 'underline';
type PlatformSegmentedTabsFrame = 'panel' | 'bare';
type PlatformSegmentedTabsSemantics = 'segment' | 'tabs';
type PlatformSegmentedTabsLayout = 'grid' | 'scroll';
export type PlatformSegmentedTabItem<TId extends string> = {
id: TId;
@@ -33,6 +34,7 @@ type PlatformSegmentedTabsProps<TId extends string> = {
tone?: PlatformSegmentedTabsTone;
frame?: PlatformSegmentedTabsFrame;
semantics?: PlatformSegmentedTabsSemantics;
layout?: PlatformSegmentedTabsLayout;
ariaLabel?: string;
truncateLabels?: boolean;
disabled?: boolean;
@@ -171,6 +173,7 @@ export function PlatformSegmentedTabs<TId extends string>({
tone = 'neutral',
frame = 'panel',
semantics = 'segment',
layout = 'grid',
ariaLabel,
truncateLabels = false,
disabled = false,
@@ -182,10 +185,12 @@ export function PlatformSegmentedTabs<TId extends string>({
role={semantics === 'tabs' ? 'tablist' : undefined}
aria-label={semantics === 'tabs' ? ariaLabel : undefined}
className={[
'grid',
layout === 'scroll'
? 'flex min-w-0 items-center overflow-x-auto scrollbar-hide'
: 'grid',
PLATFORM_SEGMENTED_TABS_FRAME_CLASS[frame],
PLATFORM_SEGMENTED_TABS_COLUMNS_CLASS[columns],
PLATFORM_SEGMENTED_TABS_GAP_CLASS[gap],
layout === 'grid' ? PLATFORM_SEGMENTED_TABS_COLUMNS_CLASS[columns] : null,
PLATFORM_SEGMENTED_TABS_RADIUS_CLASS[radius],
PLATFORM_SEGMENTED_TABS_SURFACE_CLASS[surface],
className,

View File

@@ -5,6 +5,7 @@ import type {
CreationEntryConfig,
CreationEntryEventBannerConfig,
} from '../../services/creationEntryConfigService';
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs';
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,28 @@ 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>
<PlatformSegmentedTabs
items={categoryTabs}
activeId={activeTabId ?? ''}
onChange={setActiveCategoryId}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
semantics="tabs"
ariaLabel="创作入口页签"
className="-mx-0.5 snap-x px-0.5 pb-1 scroll-px-2 sm:!gap-3"
itemClassName={(_, active) =>
[
"relative shrink-0 snap-start !min-h-8 !rounded-full !border-0 !bg-transparent !px-2.5 !text-xs !font-black !shadow-none sm:!min-h-9 sm:!px-3.5 sm:!text-sm",
active
? "!text-[#6f2f21] after:absolute after:bottom-0 after:left-3 after:right-3 after:h-1 after:rounded-full after:bg-[#d9793f] after:content-['']"
: '!text-[#7a6558] hover:!bg-transparent hover:!text-[#6f2f21]',
].join(' ')
}
/>
{isRecentTabActive ? (
<div className="creation-template-list__recent-window mt-2 text-[11px] font-bold leading-4 text-[#8b6654] sm:text-xs">

View File

@@ -1,3 +1,5 @@
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs';
export type CustomWorldWorkFilter = 'all' | 'draft' | 'published';
const FILTER_OPTIONS: Array<{
@@ -22,33 +24,42 @@ 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 (
<PlatformSegmentedTabs
items={filterTabs}
activeId={activeFilter}
onChange={onChange}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
semantics="tabs"
ariaLabel="作品筛选"
className="pb-1 !gap-4 xl:pb-0"
itemClassName={(_, active) =>
[
'platform-mobile-home-channel shrink-0 !min-h-8 !rounded-none !border-0 !bg-transparent !px-0 !shadow-none hover:!bg-transparent',
active ? 'platform-mobile-home-channel--active' : null,
]
.filter(Boolean)
.join(' ')
}
/>
);
}

View File

@@ -0,0 +1,49 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import {
PlatformDraftGenerationPointNoticeDialog,
} from './PlatformDraftGenerationPointNoticeDialog';
test('renders the insufficient-points notice with the shared blocking copy', () => {
const onClose = vi.fn();
render(
<PlatformDraftGenerationPointNoticeDialog
notice={{
kind: 'insufficient-points',
requiredPoints: 30,
currentPoints: 12,
}}
onClose={onClose}
/>,
);
expect(screen.getByRole('dialog', { name: '泥点不足' })).toBeTruthy();
expect(screen.getByText('本次需要 30 泥点,当前 12 泥点。')).toBeTruthy();
expect(
screen.getByText('当前表单不会丢失,关闭后可继续编辑或补足泥点再继续。'),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '知道了' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
test('renders the balance-load-failed notice without the amber icon override', () => {
render(
<PlatformDraftGenerationPointNoticeDialog
notice={{ kind: 'balance-load-failed' }}
onClose={() => {}}
/>,
);
const dialog = screen.getByRole('dialog', { name: '读取泥点余额失败' });
expect(screen.getByText('请稍后重试。')).toBeTruthy();
expect(
screen.getByText('当前表单不会丢失,关闭后可继续编辑,稍后再试。'),
).toBeTruthy();
expect(dialog.innerHTML).not.toContain('bg-amber-100/80');
});

View File

@@ -0,0 +1,79 @@
import { PlatformAcknowledgeStatusDialog } from '../common/PlatformAcknowledgeStatusDialog';
export type DraftGenerationPointNotice =
| {
kind: 'insufficient-points';
requiredPoints: number;
currentPoints: number;
}
| {
kind: 'balance-load-failed';
};
type PlatformDraftGenerationPointNoticeDialogProps = {
notice: DraftGenerationPointNotice | null;
onClose: () => void;
overlayClassName?: string;
panelClassName?: string;
zIndexClassName?: string;
};
function resolveDraftGenerationPointNoticeTitle(
notice: DraftGenerationPointNotice,
) {
return notice.kind === 'balance-load-failed' ? '读取泥点余额失败' : '泥点不足';
}
function resolveDraftGenerationPointNoticeDescription(
notice: DraftGenerationPointNotice,
) {
return notice.kind === 'balance-load-failed'
? '当前表单不会丢失,关闭后可继续编辑,稍后再试。'
: '当前表单不会丢失,关闭后可继续编辑或补足泥点再继续。';
}
function resolveDraftGenerationPointNoticeMessage(
notice: DraftGenerationPointNotice,
) {
return notice.kind === 'balance-load-failed'
? '请稍后重试。'
: `本次需要 ${notice.requiredPoints} 泥点,当前 ${notice.currentPoints} 泥点。`;
}
/**
* 创作前置泥点提示弹层。
* 只承接平台入口里“泥点不足 / 读取余额失败”这类阻断提示,避免 FlowShell 直接拼底层状态弹窗。
*/
export function PlatformDraftGenerationPointNoticeDialog({
notice,
onClose,
overlayClassName,
panelClassName,
zIndexClassName,
}: PlatformDraftGenerationPointNoticeDialogProps) {
if (!notice) {
return null;
}
return (
<PlatformAcknowledgeStatusDialog
status="error"
title={resolveDraftGenerationPointNoticeTitle(notice)}
description={resolveDraftGenerationPointNoticeDescription(notice)}
onClose={onClose}
showHeader
showCloseButton
closeOnBackdrop
overlayClassName={overlayClassName}
panelClassName={panelClassName}
zIndexClassName={zIndexClassName}
iconClassName={
notice.kind === 'balance-load-failed'
? undefined
: 'bg-amber-100/80 text-amber-600'
}
>
{resolveDraftGenerationPointNoticeMessage(notice)}
</PlatformAcknowledgeStatusDialog>
);
}

View File

@@ -498,6 +498,10 @@ import {
EDUTAINMENT_HIDDEN_MESSAGE,
filterGeneralPublicWorks,
} from './platformEdutainmentVisibility';
import {
PlatformDraftGenerationPointNoticeDialog,
type DraftGenerationPointNotice,
} from './PlatformDraftGenerationPointNoticeDialog';
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
import {
@@ -1419,10 +1423,8 @@ export function PlatformEntryFlowShellImpl({
: 'platform-theme--light';
const isDesktopLayout = usePlatformDesktopLayout();
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
const [draftGenerationPointNotice, setDraftGenerationPointNotice] = useState<{
title: string;
message: string;
} | null>(null);
const [draftGenerationPointNotice, setDraftGenerationPointNotice] =
useState<DraftGenerationPointNotice | null>(null);
const [selectedDetailEntry, setSelectedDetailEntry] =
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
const [selectedPublicWorkDetail, setSelectedPublicWorkDetail] =
@@ -2246,14 +2248,14 @@ export function PlatformEntryFlowShellImpl({
}
setDraftGenerationPointNotice({
title: '泥点不足',
message: `本次需要 ${pointsCost} 泥点,当前 ${walletBalance} 泥点。`,
kind: 'insufficient-points',
requiredPoints: pointsCost,
currentPoints: walletBalance,
});
return false;
} catch {
setDraftGenerationPointNotice({
title: '读取泥点余额失败',
message: '请稍后重试。',
kind: 'balance-load-failed',
});
return false;
}
@@ -4364,11 +4366,6 @@ export function PlatformEntryFlowShellImpl({
barkBattleDraftGenerationPointCost,
ensureEnoughDraftGenerationPointsFromServer,
]);
const draftGenerationPointNoticeDescription = draftGenerationPointNotice
? draftGenerationPointNotice.title === '读取泥点余额失败'
? '当前表单不会丢失,关闭后可继续编辑,稍后再试。'
: '当前表单不会丢失,关闭后可继续编辑或补足泥点再继续。'
: undefined;
const recoverCompletedPuzzleDraftGeneration = useCallback(
async ({
sessionId,
@@ -16997,25 +16994,12 @@ export function PlatformEntryFlowShellImpl({
}}
/>
) : null}
<PlatformAcknowledgeStatusDialog
open={Boolean(draftGenerationPointNotice)}
status="error"
title={draftGenerationPointNotice?.title ?? '泥点提示'}
description={draftGenerationPointNoticeDescription}
<PlatformDraftGenerationPointNoticeDialog
notice={draftGenerationPointNotice}
onClose={() => setDraftGenerationPointNotice(null)}
showHeader
showCloseButton
closeOnBackdrop
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
panelClassName="platform-remap-surface rounded-[1.75rem]"
iconClassName={
draftGenerationPointNotice?.title === '读取泥点余额失败'
? undefined
: 'bg-amber-100/80 text-amber-600'
}
>
{draftGenerationPointNotice?.message}
</PlatformAcknowledgeStatusDialog>
/>
<PublishShareModal
open={Boolean(publishSharePayload)}
payload={publishSharePayload}

View File

@@ -5,6 +5,7 @@ import type {
ProfilePlayStatsResponse,
ProfileSaveArchiveSummary,
} from '../../../packages/shared/src/contracts/runtime';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
@@ -167,13 +168,30 @@ export function PlatformProfilePlayedWorksModal({
</PlatformStatusMessage>
) : null}
{isLoading ? (
<div className="mt-5 space-y-3">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-20 animate-pulse rounded-xl bg-zinc-100" />
))}
</div>
) : hasArchiveEntries || hasPlayedWorks ? (
<PlatformAsyncStatePanel
isLoading={isLoading}
loadingState={
<div className="mt-5 space-y-3">
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="h-20 animate-pulse rounded-xl bg-zinc-100"
/>
))}
</div>
}
isEmpty={!hasArchiveEntries && !hasPlayedWorks}
emptyState={
<PlatformEmptyState
surface="subpanel"
size="inline"
tone="base"
className="mt-5 text-left"
>
</PlatformEmptyState>
}
>
<div className="mt-5 space-y-5">
{hasArchiveEntries ? (
<section>
@@ -245,16 +263,7 @@ export function PlatformProfilePlayedWorksModal({
</section>
) : null}
</div>
) : (
<PlatformEmptyState
surface="subpanel"
size="inline"
tone="base"
className="mt-5 text-left"
>
</PlatformEmptyState>
)}
</PlatformAsyncStatePanel>
</PlatformProfileSecondaryModalShell>
);
}

View File

@@ -6,6 +6,7 @@ import type {
ProfileRechargeProduct,
} from '../../../packages/shared/src/contracts/runtime';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
@@ -183,35 +184,49 @@ export function PlatformProfileRechargeModal({
</button>
</div>
{error ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="mt-4 rounded-2xl font-semibold"
>
<div>{error}</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-3"
onClick={onRetry}
<PlatformAsyncStatePanel
errorState={
error ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="mt-4 rounded-2xl font-semibold"
>
<div>{error}</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-3"
onClick={onRetry}
>
</PlatformActionButton>
</PlatformStatusMessage>
) : null
}
isLoading={isLoading}
loadingState={
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="h-28 animate-pulse rounded-[1.15rem] bg-white/10"
/>
))}
</div>
}
isEmpty={products.length === 0}
emptyState={
<PlatformEmptyState
surface="subpanel"
size="inline"
className="mt-4"
>
</PlatformActionButton>
</PlatformStatusMessage>
) : null}
{isLoading ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="h-28 animate-pulse rounded-[1.15rem] bg-white/10"
/>
))}
</div>
) : products.length > 0 ? (
</PlatformEmptyState>
}
>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{products.map((product) => (
<RechargeProductCard
@@ -222,15 +237,7 @@ export function PlatformProfileRechargeModal({
/>
))}
</div>
) : (
<PlatformEmptyState
surface="subpanel"
size="inline"
className="mt-4"
>
</PlatformEmptyState>
)}
</PlatformAsyncStatePanel>
{nativePayment ? (
<PlatformSubpanel

View File

@@ -4,6 +4,7 @@ import type { ReactNode } from 'react';
import communityQqQrImage from '../../../media/social-media-group/qq.png';
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
@@ -134,144 +135,154 @@ export function PlatformProfileReferralModal({
</div>
);
} else if (panel === 'redeem') {
content = isLoading ? (
<div className="mt-5 space-y-3">
<div className="h-12 animate-pulse rounded-xl bg-zinc-100" />
<div className="h-11 animate-pulse rounded-xl bg-zinc-100" />
</div>
) : center?.hasRedeemedCode ? (
<PlatformEmptyState
surface="subpanel"
size="inline"
tone="base"
className="mt-5"
>
</PlatformEmptyState>
) : (
<form
className="mt-5 space-y-3"
onSubmit={(event) => {
event.preventDefault();
onSubmitRedeemCode();
}}
>
<PlatformTextField
value={redeemCode}
onChange={(event) => onRedeemCodeChange(event.target.value)}
size="lg"
density="roomy"
tone="rose"
className="rounded-xl text-center font-black uppercase tracking-[0.16em]"
placeholder="邀请码"
aria-label="邀请码"
autoComplete="off"
autoFocus
/>
<PlatformActionButton
type="submit"
surface="profile"
fullWidth
size="md"
className="rounded-xl"
disabled={isSubmittingRedeem || !normalizedRedeemCode}
>
{isSubmittingRedeem ? '提交中' : '提交'}
</PlatformActionButton>
</form>
);
} else if (isLoading) {
content = (
<div className="mt-5 space-y-3">
<div className="h-20 animate-pulse rounded-xl bg-zinc-100" />
<div className="h-10 animate-pulse rounded-xl bg-zinc-100" />
</div>
<PlatformAsyncStatePanel
isLoading={isLoading}
loadingState={
<div className="mt-5 space-y-3">
<div className="h-12 animate-pulse rounded-xl bg-zinc-100" />
<div className="h-11 animate-pulse rounded-xl bg-zinc-100" />
</div>
}
isEmpty={Boolean(center?.hasRedeemedCode)}
emptyState={
<PlatformEmptyState
surface="subpanel"
size="inline"
tone="base"
className="mt-5"
>
</PlatformEmptyState>
}
>
<form
className="mt-5 space-y-3"
onSubmit={(event) => {
event.preventDefault();
onSubmitRedeemCode();
}}
>
<PlatformTextField
value={redeemCode}
onChange={(event) => onRedeemCodeChange(event.target.value)}
size="lg"
density="roomy"
tone="rose"
className="rounded-xl text-center font-black uppercase tracking-[0.16em]"
placeholder="邀请码"
aria-label="邀请码"
autoComplete="off"
autoFocus
/>
<PlatformActionButton
type="submit"
surface="profile"
fullWidth
size="md"
className="rounded-xl"
disabled={isSubmittingRedeem || !normalizedRedeemCode}
>
{isSubmittingRedeem ? '提交中' : '提交'}
</PlatformActionButton>
</form>
</PlatformAsyncStatePanel>
);
} else {
content = (
<div className="mt-5 space-y-3">
<PlatformSubpanel
as="div"
surface="flat"
radius="xs"
padding="md"
className="text-center"
>
<PlatformFieldLabel
variant="section"
className="block text-[11px] text-zinc-500"
>
</PlatformFieldLabel>
<div className="mt-1 text-3xl font-black tracking-[0.16em] text-[#ff4056]">
{center?.inviteCode ?? '--------'}
<PlatformAsyncStatePanel
isLoading={isLoading}
loadingState={
<div className="mt-5 space-y-3">
<div className="h-20 animate-pulse rounded-xl bg-zinc-100" />
<div className="h-10 animate-pulse rounded-xl bg-zinc-100" />
</div>
</PlatformSubpanel>
<PlatformStatusMessage
tone="warning"
surface="profile"
size="md"
className="space-y-0.5 px-3.5 font-semibold"
>
<div>
{`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}泥点。`}
</div>
<div></div>
</PlatformStatusMessage>
<CopyFeedbackButton
state={copyInviteState}
onClick={onCopyInvite}
disabled={!center?.inviteCode}
idleLabel="复制邀请"
copiedLabel="已复制"
failedLabel="复制失败"
idleIcon={<Copy className="h-4 w-4" />}
actionSurface="profile"
actionSize="md"
actionFullWidth
className="gap-2 rounded-xl"
/>
<PlatformSubpanel as="div" surface="flat" radius="xs" padding="sm">
<PlatformFieldLabel
variant="section"
className="block text-zinc-900"
}
>
<div className="mt-5 space-y-3">
<PlatformSubpanel
as="div"
surface="flat"
radius="xs"
padding="md"
className="text-center"
>
</PlatformFieldLabel>
{center?.invitedUsers?.length ? (
<div className="mt-3 max-h-44 space-y-2 overflow-y-auto pr-1">
{center.invitedUsers.map((user) => (
<PlatformSubpanel
as="div"
key={`${user.userId}-${user.boundAt}`}
surface="soft"
radius="xs"
padding="row"
className="flex items-center gap-3"
>
<ProfileReferralUserAvatar
name={user.displayName}
avatarUrl={user.avatarUrl}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-zinc-900">
{user.displayName || '玩家'}
</div>
</div>
</PlatformSubpanel>
))}
</div>
) : (
<PlatformEmptyState
surface="subpanel"
size="compact"
className="mt-3 text-center text-xs font-semibold leading-normal"
<PlatformFieldLabel
variant="section"
className="block text-[11px] text-zinc-500"
>
</PlatformEmptyState>
)}
</PlatformSubpanel>
</div>
</PlatformFieldLabel>
<div className="mt-1 text-3xl font-black tracking-[0.16em] text-[#ff4056]">
{center?.inviteCode ?? '--------'}
</div>
</PlatformSubpanel>
<PlatformStatusMessage
tone="warning"
surface="profile"
size="md"
className="space-y-0.5 px-3.5 font-semibold"
>
<div>
{`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}泥点。`}
</div>
<div></div>
</PlatformStatusMessage>
<CopyFeedbackButton
state={copyInviteState}
onClick={onCopyInvite}
disabled={!center?.inviteCode}
idleLabel="复制邀请"
copiedLabel="已复制"
failedLabel="复制失败"
idleIcon={<Copy className="h-4 w-4" />}
actionSurface="profile"
actionSize="md"
actionFullWidth
className="gap-2 rounded-xl"
/>
<PlatformSubpanel as="div" surface="flat" radius="xs" padding="sm">
<PlatformFieldLabel
variant="section"
className="block text-zinc-900"
>
</PlatformFieldLabel>
{center?.invitedUsers?.length ? (
<div className="mt-3 max-h-44 space-y-2 overflow-y-auto pr-1">
{center.invitedUsers.map((user) => (
<PlatformSubpanel
as="div"
key={`${user.userId}-${user.boundAt}`}
surface="soft"
radius="xs"
padding="row"
className="flex items-center gap-3"
>
<ProfileReferralUserAvatar
name={user.displayName}
avatarUrl={user.avatarUrl}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-zinc-900">
{user.displayName || '玩家'}
</div>
</div>
</PlatformSubpanel>
))}
</div>
) : (
<PlatformEmptyState
surface="subpanel"
size="compact"
className="mt-3 text-center text-xs font-semibold leading-normal"
>
</PlatformEmptyState>
)}
</PlatformSubpanel>
</div>
</PlatformAsyncStatePanel>
);
}

View File

@@ -1,5 +1,6 @@
import type { ProfileTaskCenterResponse } from '../../../packages/shared/src/contracts/runtime';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
@@ -50,24 +51,6 @@ export function PlatformProfileTaskCenterModal({
panelClassName="platform-recharge-modal !max-w-md rounded-[1.4rem]"
bodyClassName="space-y-3 px-5 py-5"
>
{error ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="rounded-2xl font-semibold"
>
<div>{error}</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-3"
onClick={onRetry}
>
</PlatformActionButton>
</PlatformStatusMessage>
) : null}
{success ? (
<PlatformStatusMessage
tone="success"
@@ -78,20 +61,45 @@ export function PlatformProfileTaskCenterModal({
{success}
</PlatformStatusMessage>
) : null}
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 2 }).map((_, index) => (
<div
key={index}
className="h-20 animate-pulse rounded-2xl bg-white/10"
/>
))}
</div>
) : tasks.length === 0 ? (
<PlatformEmptyState surface="subpanel" size="inline">
</PlatformEmptyState>
) : (
<PlatformAsyncStatePanel
errorState={
error ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="rounded-2xl font-semibold"
>
<div>{error}</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-3"
onClick={onRetry}
>
</PlatformActionButton>
</PlatformStatusMessage>
) : null
}
isLoading={isLoading}
loadingState={
<div className="space-y-3">
{Array.from({ length: 2 }).map((_, index) => (
<div
key={index}
className="h-20 animate-pulse rounded-2xl bg-white/10"
/>
))}
</div>
}
isEmpty={tasks.length === 0}
emptyState={
<PlatformEmptyState surface="subpanel" size="inline">
</PlatformEmptyState>
}
>
<div className="space-y-3">
{tasks.map((task) => {
const isClaimable = task.status === 'claimable';
@@ -137,7 +145,7 @@ export function PlatformProfileTaskCenterModal({
);
})}
</div>
)}
</PlatformAsyncStatePanel>
</PlatformProfileModalShell>
);
}

View File

@@ -2,6 +2,7 @@ import { Coins } from 'lucide-react';
import type { ProfileWalletLedgerResponse } from '../../../packages/shared/src/contracts/runtime';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
@@ -60,37 +61,48 @@ export function PlatformProfileWalletLedgerModal({
</PlatformPillBadge>
</div>
{error ? (
<PlatformStatusMessage tone="error" className="mt-4 rounded-xl py-3">
<div>{error}</div>
<PlatformActionButton
surface="profile"
shape="pill"
size="xs"
className="mt-3"
onClick={onRetry}
<PlatformAsyncStatePanel
errorState={
error ? (
<PlatformStatusMessage
tone="error"
className="mt-4 rounded-xl py-3"
>
<div>{error}</div>
<PlatformActionButton
surface="profile"
shape="pill"
size="xs"
className="mt-3"
onClick={onRetry}
>
</PlatformActionButton>
</PlatformStatusMessage>
) : null
}
isLoading={isLoading}
loadingState={
<div className="mt-5 space-y-3">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className="h-16 animate-pulse rounded-xl bg-zinc-100"
/>
))}
</div>
}
isEmpty={entries.length === 0}
emptyState={
<PlatformEmptyState
surface="subpanel"
size="inline"
className="mt-5 py-8"
>
</PlatformActionButton>
</PlatformStatusMessage>
) : isLoading ? (
<div className="mt-5 space-y-3">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className="h-16 animate-pulse rounded-xl bg-zinc-100"
/>
))}
</div>
) : entries.length === 0 ? (
<PlatformEmptyState
surface="subpanel"
size="inline"
className="mt-5 py-8"
>
</PlatformEmptyState>
) : (
</PlatformEmptyState>
}
>
<div className="mt-5 space-y-2.5">
{entries.map((entry) => (
<PlatformSubpanel
@@ -124,7 +136,7 @@ export function PlatformProfileWalletLedgerModal({
</PlatformSubpanel>
))}
</div>
)}
</PlatformAsyncStatePanel>
</PlatformProfileSecondaryModalShell>
);
}

View File

@@ -87,6 +87,7 @@ import { PlatformIconBadge } from '../common/PlatformIconBadge';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs';
import { PlatformStatusDialog } from '../common/PlatformStatusDialog';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
@@ -1000,14 +1001,16 @@ function PublicCodeSearchBar({
placeholder="搜索作品号、名称、作者、描述"
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
/>
<button
type="button"
<PlatformActionButton
onClick={onSubmit}
disabled={!value.trim() || isSearching}
className="shrink-0 text-xs font-semibold text-[var(--platform-text-soft)] disabled:opacity-50"
tone="ghost"
size="xxs"
shape="pill"
className="shrink-0"
>
{isSearching ? '搜索中' : '搜索'}
</button>
</PlatformActionButton>
</div>
);
}
@@ -2127,6 +2130,23 @@ function PlatformCategoryFilterDialog({
onReset: () => void;
onClose: () => void;
}) {
const kindFilterTabs = useMemo(
() =>
PLATFORM_CATEGORY_KIND_FILTERS.map((option) => ({
id: option.id,
label: option.label,
})),
[],
);
const sortModeTabs = useMemo(
() =>
PLATFORM_CATEGORY_SORT_OPTIONS.map((option) => ({
id: option.id,
label: option.label,
})),
[],
);
return (
<UnifiedModal
open
@@ -2150,61 +2170,76 @@ function PlatformCategoryFilterDialog({
<div className="text-xs font-black text-[var(--platform-text-soft)]">
</div>
<div className="platform-category-filter-dialog__options">
{PLATFORM_CATEGORY_KIND_FILTERS.map((option) => {
const active = option.id === kindFilter;
return (
<button
key={option.id}
type="button"
onClick={() => onKindFilterChange(option.id)}
className={`platform-category-filter-dialog__option ${active ? 'platform-category-filter-dialog__option--active' : ''}`}
>
{option.label}
</button>
);
})}
</div>
<PlatformSegmentedTabs
items={kindFilterTabs}
activeId={kindFilter}
onChange={onKindFilterChange}
columns="two"
gap="md"
frame="bare"
surface="transparent"
size="sm"
semantics="segment"
className="platform-category-filter-dialog__options"
itemClassName={(_, active) =>
[
'platform-category-filter-dialog__option !min-h-[2.35rem] !rounded-[0.78rem] !border !border-[var(--platform-subpanel-border)] !bg-[var(--platform-subpanel-fill)] !px-3 !text-[0.88rem] !font-black !text-[var(--platform-text-base)] !shadow-none hover:!bg-[var(--platform-subpanel-fill)]',
active
? 'platform-category-filter-dialog__option--active !border-[var(--platform-cool-border)] !bg-[var(--platform-cool-bg)] !text-[var(--platform-cool-text)]'
: null,
]
.filter(Boolean)
.join(' ')
}
/>
</div>
<div className="grid gap-2">
<div className="text-xs font-black text-[var(--platform-text-soft)]">
</div>
<div className="platform-category-filter-dialog__options">
{PLATFORM_CATEGORY_SORT_OPTIONS.map((option) => {
const active = option.id === sortMode;
return (
<button
key={option.id}
type="button"
onClick={() => onSortModeChange(option.id)}
className={`platform-category-filter-dialog__option ${active ? 'platform-category-filter-dialog__option--active' : ''}`}
>
{option.label}
</button>
);
})}
</div>
<PlatformSegmentedTabs
items={sortModeTabs}
activeId={sortMode}
onChange={onSortModeChange}
columns="two"
gap="md"
frame="bare"
surface="transparent"
size="sm"
semantics="segment"
className="platform-category-filter-dialog__options"
itemClassName={(_, active) =>
[
'platform-category-filter-dialog__option !min-h-[2.35rem] !rounded-[0.78rem] !border !border-[var(--platform-subpanel-border)] !bg-[var(--platform-subpanel-fill)] !px-3 !text-[0.88rem] !font-black !text-[var(--platform-text-base)] !shadow-none hover:!bg-[var(--platform-subpanel-fill)]',
active
? 'platform-category-filter-dialog__option--active !border-[var(--platform-cool-border)] !bg-[var(--platform-cool-bg)] !text-[var(--platform-cool-text)]'
: null,
]
.filter(Boolean)
.join(' ')
}
/>
</div>
<div className="platform-category-filter-dialog__actions">
<button
type="button"
<PlatformActionButton
onClick={onReset}
className="platform-category-filter-dialog__action platform-category-filter-dialog__action--secondary"
tone="secondary"
shape="pill"
fullWidth
className="platform-category-filter-dialog__action !min-h-[2.55rem]"
>
</button>
<button
type="button"
</PlatformActionButton>
<PlatformActionButton
onClick={onClose}
className="platform-category-filter-dialog__action platform-category-filter-dialog__action--primary"
shape="pill"
fullWidth
className="platform-category-filter-dialog__action !min-h-[2.55rem]"
>
</button>
</PlatformActionButton>
</div>
</UnifiedModal>
);
@@ -3455,28 +3490,31 @@ export function RpgEntryHomeView({
<section
className={`${PANEL_SURFACE_CLASS} platform-ranking-panel px-4 py-3.5 sm:px-5 sm:py-4`}
>
<div
className="platform-ranking-tabs flex min-w-0 gap-2 overflow-x-auto pb-1 scrollbar-hide"
role="tablist"
aria-label="作品排行"
>
{PLATFORM_RANKING_TABS.map((tab) => {
const active = tab.id === activeRankingTab;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => setActiveRankingTab(tab.id)}
className={`platform-ranking-tab shrink-0 ${active ? 'platform-ranking-tab--active' : ''}`}
>
{tab.label}
</button>
);
})}
</div>
<PlatformSegmentedTabs
items={PLATFORM_RANKING_TABS.map((tab) => ({
id: tab.id,
label: tab.label,
}))}
activeId={activeRankingTab}
onChange={setActiveRankingTab}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
semantics="tabs"
ariaLabel="作品排行"
className="platform-ranking-tabs pb-1"
itemClassName={(_, active) =>
[
'platform-ranking-tab shrink-0 !min-h-[2.35rem] !rounded-none !border-0 !bg-transparent !px-[0.15rem] !shadow-none hover:!bg-transparent',
active ? 'platform-ranking-tab--active' : null,
]
.filter(Boolean)
.join(' ')
}
/>
{isLoadingPlatform ? (
<PlatformEmptyState>...</PlatformEmptyState>