收口前端平台组件能力
新增 PlatformAsyncStatePanel 统一 profile 异步状态骨架 扩展 PlatformSegmentedTabs 支持滚动 tab 并接入创作入口与发现页 统一 PixelCloseButton 复用 PlatformModalCloseButton 像素关闭能力 抽取平台入口泥点前置提示弹层并收紧阻断语义 补充组件收口文档与共享决策记录
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
61
src/components/common/PlatformAsyncStatePanel.test.tsx
Normal file
61
src/components/common/PlatformAsyncStatePanel.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
37
src/components/common/PlatformAsyncStatePanel.tsx
Normal file
37
src/components/common/PlatformAsyncStatePanel.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(' ')
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user