收口前端平台组件能力
新增 PlatformAsyncStatePanel 统一 profile 异步状态骨架 扩展 PlatformSegmentedTabs 支持滚动 tab 并接入创作入口与发现页 统一 PixelCloseButton 复用 PlatformModalCloseButton 像素关闭能力 抽取平台入口泥点前置提示弹层并收紧阻断语义 补充组件收口文档与共享决策记录
This commit is contained in:
@@ -2065,6 +2065,15 @@
|
|||||||
- 验证方式:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml work_author`、`npm run test -- scripts/rebind-orphan-work-owners.test.ts`。
|
- 验证方式:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml work_author`、`npm run test -- scripts/rebind-orphan-work-owners.test.ts`。
|
||||||
- 关联文档:`server-rs/crates/module-auth/src/domain.rs`、`server-rs/crates/module-auth/src/lib.rs`、`server-rs/crates/api-server/src/work_author.rs`、`scripts/rebind-orphan-work-owners.mjs`。
|
- 关联文档:`server-rs/crates/module-auth/src/domain.rs`、`server-rs/crates/module-auth/src/lib.rs`、`server-rs/crates/api-server/src/work_author.rs`、`scripts/rebind-orphan-work-owners.mjs`。
|
||||||
|
|
||||||
|
## 2026-06-11 前端组件收口补记
|
||||||
|
|
||||||
|
- 背景:个人中心 profile 弹层已抽成独立组件,但 `error / loading / empty / content` 仍在多个 modal 中重复分支,继续沿业务页各写一套会让后续 profile 面板收口越来越碎。
|
||||||
|
- 决策:新增 `src/components/common/PlatformAsyncStatePanel.tsx` 作为互斥异步状态骨架,只承接 `errorState / loadingState / emptyState / children` 四类 slot 的优先级切换;`PlatformProfileWalletLedgerModal.tsx`、`PlatformProfileTaskCenterModal.tsx`、`PlatformProfileRechargeModal.tsx`、`PlatformProfilePlayedWorksModal.tsx` 与 `PlatformProfileReferralModal.tsx` 已接入。若错误或成功提示需要与内容并存,继续留在业务组件外层,不把 `PlatformAsyncStatePanel` 扩成全能状态机。
|
||||||
|
- 决策:`src/components/common/PlatformSegmentedTabs.tsx` 支持 `layout="scroll"`,用于横向可滚动 tab rail;`CustomWorldCreationStartCard.tsx`、`CustomWorldWorkTabs.tsx` 以及 `RpgEntryHomeView.tsx` 的排行 / 分类筛选已接入。共享组件只负责 tab 语义、滚动容器和基础交互,业务页通过 `itemClassName` 保留本地皮肤,不额外抽“频道 tab”视觉 preset。
|
||||||
|
- 决策:`src/components/PixelCloseButton.tsx` 保持为 RPG 语义薄封装,底层统一复用 `src/components/common/PlatformModalCloseButton.tsx` 的 `variant="pixel"`;共享 close button 现在负责 `absolute / inline` placement、默认 `title=label` 和可选 `stopPropagation` 点击拦截,业务 importer 不再各自维护像素风关闭按钮壳和冒泡控制。
|
||||||
|
- 决策:平台入口的创作前置泥点阻断提示只在 `platform-entry` 局部抽成 `src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx`,并使用 `DraftGenerationPointNotice` union(`insufficient-points` / `balance-load-failed`)承接业务真相;不要在 `common/` 再抽一个泛化 `BlockingNoticeDialog`,否则会把 `PlatformAcknowledgeStatusDialog` 的样式透传再包装一层而不缩小调用面。
|
||||||
|
- 验证方式:`npm run test -- src/components/common/PlatformAsyncStatePanel.test.tsx src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/common/PlatformSegmentedTabs.test.tsx src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run test -- src/components/common/PlatformModalCloseButton.test.tsx src/components/PixelCloseButton.test.tsx src/components/CharacterChatModal.test.tsx src/components/MapModal.test.tsx`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile game category filter dialog filters by play type|bottom category tab becomes ranking and switches ranking metrics|ranking rows limit displayed work name and show two short tags on the third line"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "searching unmatched public work code shows not-found search result dialog|public code search shows public user summary in shared search result modal and clears it on close|bark battle form checks mud points before creating image assets|puzzle form checks mud points before creating a draft|match3d form checks mud points before creating a draft"`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
|
||||||
## 2026-05-26 敲木鱼发布后作品架与推荐流刷新口径
|
## 2026-05-26 敲木鱼发布后作品架与推荐流刷新口径
|
||||||
|
|
||||||
- 背景:敲木鱼已具备公开广场投影,但草稿 Tab 的作品架没有当前用户作品列表接口,导致已发布作品在发布后不能立即出现在“已发布”筛选和推荐流里。
|
- 背景:敲木鱼已具备公开广场投影,但草稿 Tab 的作品架没有当前用户作品列表接口,导致已发布作品在发布后不能立即出现在“已发布”筛选和推荐流里。
|
||||||
|
|||||||
@@ -247,6 +247,10 @@
|
|||||||
19.3.23. 平台危险确认弹窗收口到 `src/components/common/PlatformDangerConfirmDialog.tsx`;组件固定承接“确认 / 取消 + 危险主动作”的标准骨架,并透传忙碌态、遮罩关闭策略、按钮文案和局部面板样式。`PlatformEntryFlowShellImpl.tsx` 的删除作品确认、`RpgCreationResultViewImpl.tsx` 的重新生成确认,以及 `CustomWorldEntityCatalog.tsx` 的删除角色 / 批量删除确认已迁移;业务页继续保留标题、说明文案和确认回调,不再各自拼接 `UnifiedConfirmDialog` 的危险按钮配置。后续删除、覆盖、清空等危险动作优先复用该 Module,再按需要补充更窄的语义 wrapper。
|
19.3.23. 平台危险确认弹窗收口到 `src/components/common/PlatformDangerConfirmDialog.tsx`;组件固定承接“确认 / 取消 + 危险主动作”的标准骨架,并透传忙碌态、遮罩关闭策略、按钮文案和局部面板样式。`PlatformEntryFlowShellImpl.tsx` 的删除作品确认、`RpgCreationResultViewImpl.tsx` 的重新生成确认,以及 `CustomWorldEntityCatalog.tsx` 的删除角色 / 批量删除确认已迁移;业务页继续保留标题、说明文案和确认回调,不再各自拼接 `UnifiedConfirmDialog` 的危险按钮配置。后续删除、覆盖、清空等危险动作优先复用该 Module,再按需要补充更窄的语义 wrapper。
|
||||||
19.3.24. 平台未保存离开确认弹窗收口到 `src/components/common/PlatformUnsavedLeaveConfirmDialog.tsx`;组件固定承接“继续编辑 + 确认离开”的标准骨架,并按 `platform / pixel` 两类确认风格兜底默认遮罩和面板样式。`RpgCreationEntityEditorShared.tsx` 中的关闭未保存修改确认、生成结果未保存退出确认和普通结果未保存退出确认已迁移;业务页只保留标题、确认按钮文案和未保存提示内容,不再各自拼接 `UnifiedConfirmDialog` 的 cancel/confirm 组合和重复壳层 class。
|
19.3.24. 平台未保存离开确认弹窗收口到 `src/components/common/PlatformUnsavedLeaveConfirmDialog.tsx`;组件固定承接“继续编辑 + 确认离开”的标准骨架,并按 `platform / pixel` 两类确认风格兜底默认遮罩和面板样式。`RpgCreationEntityEditorShared.tsx` 中的关闭未保存修改确认、生成结果未保存退出确认和普通结果未保存退出确认已迁移;业务页只保留标题、确认按钮文案和未保存提示内容,不再各自拼接 `UnifiedConfirmDialog` 的 cancel/confirm 组合和重复壳层 class。
|
||||||
19.3.25. 平台单按钮已读状态弹窗收口到 `src/components/common/PlatformAcknowledgeStatusDialog.tsx`;组件固定承接“状态提示 + 知道了”这一类单按钮确认已读语义,并透传 action surface / size / fullWidth / class、header、关闭路径和局部 panel 覆写。`BigFishResultView.tsx` 的发布失败提示、`RpgEntryHomeView.tsx` 的支付结果提示、`RpgCreationEntityEditorShared.tsx` 的编辑器 notice、`PlatformEntryFlowShellImpl.tsx` 的泥点提示 / 作品不可用 / 搜索未命中提示,以及 `CustomWorldEntityCatalog.tsx` 的“无法删除”阻断提示已迁移;业务页继续保留 status、标题、说明和关闭回调,不再各自手写 `PlatformStatusDialog` 的 `action={{ label: '知道了', onClick: onClose }}` 结构。
|
19.3.25. 平台单按钮已读状态弹窗收口到 `src/components/common/PlatformAcknowledgeStatusDialog.tsx`;组件固定承接“状态提示 + 知道了”这一类单按钮确认已读语义,并透传 action surface / size / fullWidth / class、header、关闭路径和局部 panel 覆写。`BigFishResultView.tsx` 的发布失败提示、`RpgEntryHomeView.tsx` 的支付结果提示、`RpgCreationEntityEditorShared.tsx` 的编辑器 notice、`PlatformEntryFlowShellImpl.tsx` 的泥点提示 / 作品不可用 / 搜索未命中提示,以及 `CustomWorldEntityCatalog.tsx` 的“无法删除”阻断提示已迁移;业务页继续保留 status、标题、说明和关闭回调,不再各自手写 `PlatformStatusDialog` 的 `action={{ label: '知道了', onClick: onClose }}` 结构。
|
||||||
|
19.3.26. profile 侧重复的 `error / loading / empty / content` 分支统一收口到 `src/components/common/PlatformAsyncStatePanel.tsx`;该 Module 只承接互斥状态切换,不承接需要和内容并存的 success / error banner。`PlatformProfileReferralModal.tsx`、`PlatformProfileWalletLedgerModal.tsx`、`PlatformProfilePlayedWorksModal.tsx`、`PlatformProfileTaskCenterModal.tsx` 与 `PlatformProfileRechargeModal.tsx` 已接入。后续 profile 或白底 panel 侧若只是同形态互斥异步状态,优先传 slot 复用该骨架,不再把 `loading skeleton` / `empty state` / `retry error` 直接写回业务页。
|
||||||
|
19.3.27. `PlatformSegmentedTabs` 支持 `layout="scroll"` 承接横向可滚动 tab rail;`CustomWorldCreationStartCard.tsx`、`CustomWorldWorkTabs.tsx` 和 `RpgEntryHomeView.tsx` 的排行 tab、分类筛选项已接入。共享组件只统一 `tablist/tab` 语义、滚动容器和基础交互,业务视觉仍通过 `itemClassName` 保留本地样式,不在 `common/` 新增“频道 tab”皮肤 preset;后续同类横向 tab 优先扩展这套 `scroll + itemClassName` 组合。
|
||||||
|
19.3.28. `PixelCloseButton.tsx` 保持为 RPG 语义薄封装,底层改为复用 `src/components/common/PlatformModalCloseButton.tsx` 的 `variant="pixel"`;共享 close button 统一承接像素风基础 chrome、`absolute / inline` placement、默认 `title=label` 和可选 `stopPropagation` 冒泡拦截,`CharacterChatModal.tsx` 与 `MapModal.tsx` 的 inline / absolute 真实 importer 已补测试。后续需要像素风关闭按钮时优先使用 `PlatformModalCloseButton variant="pixel"` 或继续复用 `PixelCloseButton` 语义壳,不再手写本地 close button。
|
||||||
|
19.3.29. 平台入口创作前置泥点阻断提示抽到 `src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx`,并用 `DraftGenerationPointNotice` union(`insufficient-points` / `balance-load-failed`)承接业务真相;`PlatformEntryFlowShellImpl.tsx` 不再直接拼 `PlatformAcknowledgeStatusDialog` 的标题、说明和 amber icon 条件分支。后续若只是平台入口里的泥点前置检查提示,优先继续扩展这个局部语义 wrapper;不要急着在 `common/` 抽泛化 `BlockingNoticeDialog`,避免把底层状态弹窗的样式透传再次包装一层。
|
||||||
19.3. creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`;首页继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏、抽屉和品牌化胶囊视觉,不把视觉回收和语义收口绑成一次大改。`Beta` 徽标和历史记录纯文本行暂保留本地实现,等出现更多同构轻量列表行后再评估是否抽新的共享 row primitive。
|
19.3. creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`;首页继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏、抽屉和品牌化胶囊视觉,不把视觉回收和语义收口绑成一次大改。`Beta` 徽标和历史记录纯文本行暂保留本地实现,等出现更多同构轻量列表行后再评估是否抽新的共享 row primitive。
|
||||||
19.4. 大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton variant="darkMini"`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布提交和文案状态语义,不再手写 hero 顶栏按钮壳。
|
19.4. 大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton variant="darkMini"`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布提交和文案状态语义,不再手写 hero 顶栏按钮壳。
|
||||||
19.4.1. 大鱼吃小鱼结果页的发布失败弹层迁移到 `src/components/common/PlatformStatusDialog.tsx`;`PlatformStatusDialog` 补充自定义图标、可访问标签和动作按钮样式透传后,`BigFishResultView` 不再保留 `BigFishResultErrorModal` 内联的 `UnifiedConfirmDialog + PlatformIconBadge` 组合。结果页只保留失败文案和关闭回调,发布失败的状态图标、遮罩、白底面板和“知道了”主动作统一由共享状态弹层承接。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`、`npm run typecheck`。
|
19.4.1. 大鱼吃小鱼结果页的发布失败弹层迁移到 `src/components/common/PlatformStatusDialog.tsx`;`PlatformStatusDialog` 补充自定义图标、可访问标签和动作按钮样式透传后,`BigFishResultView` 不再保留 `BigFishResultErrorModal` 内联的 `UnifiedConfirmDialog + PlatformIconBadge` 组合。结果页只保留失败文案和关闭回调,发布失败的状态图标、遮罩、白底面板和“知道了”主动作统一由共享状态弹层承接。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`、`npm run typecheck`。
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* @vitest-environment jsdom */
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import { expect, test, vi } from 'vitest';
|
import { expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import type { CharacterChatModalState } from '../hooks/rpg-runtime-story';
|
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('platform-text-field--editor-dark');
|
||||||
expect(draftTextarea.className).toContain('focus:border-sky-300/35');
|
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');
|
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 { CHROME_ICONS } from '../uiAssets';
|
||||||
|
import { PlatformModalCloseButton } from './common/PlatformModalCloseButton';
|
||||||
import { PixelIcon } from './PixelIcon';
|
import { PixelIcon } from './PixelIcon';
|
||||||
|
|
||||||
type PixelCloseButtonProps = {
|
type PixelCloseButtonProps = {
|
||||||
@@ -12,7 +11,7 @@ type PixelCloseButtonProps = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* RPG 像素风弹窗右上关闭按钮。
|
* RPG 像素风弹窗右上关闭按钮。
|
||||||
* 统一拦截点击冒泡,避免历史手写 overlay / panel 的点击处理影响关闭行为。
|
* 这里只保留 RPG 语义层封装,底层样式与行为统一复用共享 close button。
|
||||||
*/
|
*/
|
||||||
export function PixelCloseButton({
|
export function PixelCloseButton({
|
||||||
onClick,
|
onClick,
|
||||||
@@ -20,26 +19,16 @@ export function PixelCloseButton({
|
|||||||
placement = 'absolute',
|
placement = 'absolute',
|
||||||
className = '',
|
className = '',
|
||||||
}: PixelCloseButtonProps) {
|
}: 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 (
|
return (
|
||||||
<button
|
<PlatformModalCloseButton
|
||||||
type="button"
|
label={label}
|
||||||
aria-label={label}
|
|
||||||
title={label}
|
title={label}
|
||||||
onClick={handleClick}
|
variant="pixel"
|
||||||
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()}
|
placement={placement}
|
||||||
>
|
stopPropagation
|
||||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
onClick={() => onClick()}
|
||||||
</button>
|
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 */
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
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';
|
import { PlatformModalCloseButton } from './PlatformModalCloseButton';
|
||||||
|
|
||||||
@@ -80,9 +81,37 @@ test('supports pixel close button', () => {
|
|||||||
|
|
||||||
const button = screen.getByRole('button', { name: '关闭像素弹窗' });
|
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('text-zinc-400');
|
||||||
|
expect(button.className).toContain('absolute');
|
||||||
expect(button.className).toContain('disabled:opacity-45');
|
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', () => {
|
test('supports editor dark close button', () => {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
import type {
|
||||||
|
ButtonHTMLAttributes,
|
||||||
|
MouseEvent as ReactMouseEvent,
|
||||||
|
ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
type PlatformModalCloseButtonVariant =
|
type PlatformModalCloseButtonVariant =
|
||||||
| 'profile'
|
| 'profile'
|
||||||
@@ -10,6 +14,8 @@ type PlatformModalCloseButtonVariant =
|
|||||||
| 'pixel'
|
| 'pixel'
|
||||||
| 'editorDark';
|
| 'editorDark';
|
||||||
|
|
||||||
|
type PlatformModalCloseButtonPlacement = 'absolute' | 'inline';
|
||||||
|
|
||||||
type PlatformModalCloseButtonProps = Omit<
|
type PlatformModalCloseButtonProps = Omit<
|
||||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
'children'
|
'children'
|
||||||
@@ -17,6 +23,8 @@ type PlatformModalCloseButtonProps = Omit<
|
|||||||
label: string;
|
label: string;
|
||||||
variant?: PlatformModalCloseButtonVariant;
|
variant?: PlatformModalCloseButtonVariant;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
|
placement?: PlatformModalCloseButtonPlacement;
|
||||||
|
stopPropagation?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PLATFORM_MODAL_CLOSE_BUTTON_CLASS_BY_VARIANT: Record<
|
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]',
|
'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',
|
platformIcon: 'platform-icon-button disabled:cursor-not-allowed disabled:opacity-45',
|
||||||
pixel:
|
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:
|
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',
|
'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、尺寸和视觉样式。
|
* 收口个人中心和平台浮层里重复的关闭 aria、尺寸和视觉样式。
|
||||||
@@ -48,15 +64,35 @@ export function PlatformModalCloseButton({
|
|||||||
icon = <X className="h-4 w-4" />,
|
icon = <X className="h-4 w-4" />,
|
||||||
className,
|
className,
|
||||||
type = 'button',
|
type = 'button',
|
||||||
|
placement = 'absolute',
|
||||||
|
stopPropagation = false,
|
||||||
|
onClick,
|
||||||
|
title,
|
||||||
...buttonProps
|
...buttonProps
|
||||||
}: PlatformModalCloseButtonProps) {
|
}: PlatformModalCloseButtonProps) {
|
||||||
|
const handleClick = (event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (stopPropagation) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
type={type}
|
type={type}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
title={title ?? label}
|
||||||
|
onClick={handleClick}
|
||||||
className={[
|
className={[
|
||||||
PLATFORM_MODAL_CLOSE_BUTTON_CLASS_BY_VARIANT[variant],
|
PLATFORM_MODAL_CLOSE_BUTTON_CLASS_BY_VARIANT[variant],
|
||||||
|
variant === 'pixel'
|
||||||
|
? PLATFORM_MODAL_CLOSE_BUTTON_PIXEL_PLACEMENT_CLASS_BY_PLACEMENT[
|
||||||
|
placement
|
||||||
|
]
|
||||||
|
: null,
|
||||||
className,
|
className,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
@@ -162,3 +162,31 @@ test('supports auth-style tab semantics with underline tone', () => {
|
|||||||
|
|
||||||
expect(onChange).toHaveBeenCalledWith('password');
|
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 PlatformSegmentedTabsTone = 'neutral' | 'warm' | 'rose' | 'underline';
|
||||||
type PlatformSegmentedTabsFrame = 'panel' | 'bare';
|
type PlatformSegmentedTabsFrame = 'panel' | 'bare';
|
||||||
type PlatformSegmentedTabsSemantics = 'segment' | 'tabs';
|
type PlatformSegmentedTabsSemantics = 'segment' | 'tabs';
|
||||||
|
type PlatformSegmentedTabsLayout = 'grid' | 'scroll';
|
||||||
|
|
||||||
export type PlatformSegmentedTabItem<TId extends string> = {
|
export type PlatformSegmentedTabItem<TId extends string> = {
|
||||||
id: TId;
|
id: TId;
|
||||||
@@ -33,6 +34,7 @@ type PlatformSegmentedTabsProps<TId extends string> = {
|
|||||||
tone?: PlatformSegmentedTabsTone;
|
tone?: PlatformSegmentedTabsTone;
|
||||||
frame?: PlatformSegmentedTabsFrame;
|
frame?: PlatformSegmentedTabsFrame;
|
||||||
semantics?: PlatformSegmentedTabsSemantics;
|
semantics?: PlatformSegmentedTabsSemantics;
|
||||||
|
layout?: PlatformSegmentedTabsLayout;
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
truncateLabels?: boolean;
|
truncateLabels?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -171,6 +173,7 @@ export function PlatformSegmentedTabs<TId extends string>({
|
|||||||
tone = 'neutral',
|
tone = 'neutral',
|
||||||
frame = 'panel',
|
frame = 'panel',
|
||||||
semantics = 'segment',
|
semantics = 'segment',
|
||||||
|
layout = 'grid',
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
truncateLabels = false,
|
truncateLabels = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -182,10 +185,12 @@ export function PlatformSegmentedTabs<TId extends string>({
|
|||||||
role={semantics === 'tabs' ? 'tablist' : undefined}
|
role={semantics === 'tabs' ? 'tablist' : undefined}
|
||||||
aria-label={semantics === 'tabs' ? ariaLabel : undefined}
|
aria-label={semantics === 'tabs' ? ariaLabel : undefined}
|
||||||
className={[
|
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_FRAME_CLASS[frame],
|
||||||
PLATFORM_SEGMENTED_TABS_COLUMNS_CLASS[columns],
|
|
||||||
PLATFORM_SEGMENTED_TABS_GAP_CLASS[gap],
|
PLATFORM_SEGMENTED_TABS_GAP_CLASS[gap],
|
||||||
|
layout === 'grid' ? PLATFORM_SEGMENTED_TABS_COLUMNS_CLASS[columns] : null,
|
||||||
PLATFORM_SEGMENTED_TABS_RADIUS_CLASS[radius],
|
PLATFORM_SEGMENTED_TABS_RADIUS_CLASS[radius],
|
||||||
PLATFORM_SEGMENTED_TABS_SURFACE_CLASS[surface],
|
PLATFORM_SEGMENTED_TABS_SURFACE_CLASS[surface],
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
CreationEntryConfig,
|
CreationEntryConfig,
|
||||||
CreationEntryEventBannerConfig,
|
CreationEntryEventBannerConfig,
|
||||||
} from '../../services/creationEntryConfigService';
|
} from '../../services/creationEntryConfigService';
|
||||||
|
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs';
|
||||||
import {
|
import {
|
||||||
groupVisiblePlatformCreationTypes,
|
groupVisiblePlatformCreationTypes,
|
||||||
type PlatformCreationTypeCard,
|
type PlatformCreationTypeCard,
|
||||||
@@ -111,6 +112,23 @@ export function CustomWorldCreationStartCard({
|
|||||||
const visibleCreationTypes = isRecentTabActive
|
const visibleCreationTypes = isRecentTabActive
|
||||||
? recentCreationTypes
|
? recentCreationTypes
|
||||||
: (activeGroup?.items ?? []);
|
: (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(
|
const eventBanners = useMemo(
|
||||||
() => resolveCreationEntryEventBanners(entryConfig),
|
() => resolveCreationEntryEventBanners(entryConfig),
|
||||||
[entryConfig],
|
[entryConfig],
|
||||||
@@ -262,52 +280,28 @@ export function CustomWorldCreationStartCard({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="creation-template-list -mx-1 px-1 sm:-mx-2 sm:px-2">
|
<section className="creation-template-list -mx-1 px-1 sm:-mx-2 sm:px-2">
|
||||||
<div
|
<PlatformSegmentedTabs
|
||||||
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"
|
items={categoryTabs}
|
||||||
role="tablist"
|
activeId={activeTabId ?? ''}
|
||||||
aria-label="创作入口页签"
|
onChange={setActiveCategoryId}
|
||||||
>
|
layout="scroll"
|
||||||
{hasRecentCreationTypes ? (
|
gap="md"
|
||||||
<button
|
frame="bare"
|
||||||
type="button"
|
surface="transparent"
|
||||||
role="tab"
|
size="sm"
|
||||||
aria-selected={isRecentTabActive}
|
tone="neutral"
|
||||||
onClick={() => setActiveCategoryId(CREATION_ENTRY_RECENT_TAB_ID)}
|
semantics="tabs"
|
||||||
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 ${
|
ariaLabel="创作入口页签"
|
||||||
isRecentTabActive
|
className="-mx-0.5 snap-x px-0.5 pb-1 scroll-px-2 sm:!gap-3"
|
||||||
? 'text-[#6f2f21]'
|
itemClassName={(_, active) =>
|
||||||
: 'text-[#7a6558] hover:text-[#6f2f21]'
|
[
|
||||||
}`}
|
"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
|
||||||
<span>最近创作</span>
|
? "!text-[#6f2f21] after:absolute after:bottom-0 after:left-3 after:right-3 after:h-1 after:rounded-full after:bg-[#d9793f] after:content-['']"
|
||||||
{isRecentTabActive ? (
|
: '!text-[#7a6558] hover:!bg-transparent hover:!text-[#6f2f21]',
|
||||||
<span className="absolute bottom-0 left-3 right-3 h-1 rounded-full bg-[#d9793f]" />
|
].join(' ')
|
||||||
) : 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>
|
|
||||||
|
|
||||||
{isRecentTabActive ? (
|
{isRecentTabActive ? (
|
||||||
<div className="creation-template-list__recent-window mt-2 text-[11px] font-bold leading-4 text-[#8b6654] sm:text-xs">
|
<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';
|
export type CustomWorldWorkFilter = 'all' | 'draft' | 'published';
|
||||||
|
|
||||||
const FILTER_OPTIONS: Array<{
|
const FILTER_OPTIONS: Array<{
|
||||||
@@ -22,33 +24,42 @@ export function CustomWorldWorkTabs({
|
|||||||
publishedCount,
|
publishedCount,
|
||||||
onChange,
|
onChange,
|
||||||
}: CustomWorldWorkTabsProps) {
|
}: CustomWorldWorkTabsProps) {
|
||||||
return (
|
const filterTabs = FILTER_OPTIONS.map((option) => {
|
||||||
<div
|
const count =
|
||||||
className="flex min-w-0 items-center gap-4 overflow-x-auto pb-1 scrollbar-hide xl:pb-0"
|
option.id === 'draft'
|
||||||
role="tablist"
|
? draftCount
|
||||||
aria-label="作品筛选"
|
: option.id === 'published'
|
||||||
>
|
? publishedCount
|
||||||
{FILTER_OPTIONS.map((option) => {
|
: draftCount + publishedCount;
|
||||||
const count =
|
|
||||||
option.id === 'draft'
|
|
||||||
? draftCount
|
|
||||||
: option.id === 'published'
|
|
||||||
? publishedCount
|
|
||||||
: draftCount + publishedCount;
|
|
||||||
|
|
||||||
return (
|
return {
|
||||||
<button
|
id: option.id,
|
||||||
key={option.id}
|
label: `${option.label} ${count}`,
|
||||||
type="button"
|
};
|
||||||
role="tab"
|
});
|
||||||
aria-selected={activeFilter === option.id}
|
|
||||||
onClick={() => onChange(option.id)}
|
return (
|
||||||
className={`platform-mobile-home-channel shrink-0 ${activeFilter === option.id ? 'platform-mobile-home-channel--active' : ''}`}
|
<PlatformSegmentedTabs
|
||||||
>
|
items={filterTabs}
|
||||||
{option.label} {count}
|
activeId={activeFilter}
|
||||||
</button>
|
onChange={onChange}
|
||||||
);
|
layout="scroll"
|
||||||
})}
|
gap="md"
|
||||||
</div>
|
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,
|
EDUTAINMENT_HIDDEN_MESSAGE,
|
||||||
filterGeneralPublicWorks,
|
filterGeneralPublicWorks,
|
||||||
} from './platformEdutainmentVisibility';
|
} from './platformEdutainmentVisibility';
|
||||||
|
import {
|
||||||
|
PlatformDraftGenerationPointNoticeDialog,
|
||||||
|
type DraftGenerationPointNotice,
|
||||||
|
} from './PlatformDraftGenerationPointNoticeDialog';
|
||||||
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
|
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
|
||||||
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
||||||
import {
|
import {
|
||||||
@@ -1419,10 +1423,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
: 'platform-theme--light';
|
: 'platform-theme--light';
|
||||||
const isDesktopLayout = usePlatformDesktopLayout();
|
const isDesktopLayout = usePlatformDesktopLayout();
|
||||||
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
|
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
|
||||||
const [draftGenerationPointNotice, setDraftGenerationPointNotice] = useState<{
|
const [draftGenerationPointNotice, setDraftGenerationPointNotice] =
|
||||||
title: string;
|
useState<DraftGenerationPointNotice | null>(null);
|
||||||
message: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [selectedDetailEntry, setSelectedDetailEntry] =
|
const [selectedDetailEntry, setSelectedDetailEntry] =
|
||||||
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
|
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
|
||||||
const [selectedPublicWorkDetail, setSelectedPublicWorkDetail] =
|
const [selectedPublicWorkDetail, setSelectedPublicWorkDetail] =
|
||||||
@@ -2246,14 +2248,14 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setDraftGenerationPointNotice({
|
setDraftGenerationPointNotice({
|
||||||
title: '泥点不足',
|
kind: 'insufficient-points',
|
||||||
message: `本次需要 ${pointsCost} 泥点,当前 ${walletBalance} 泥点。`,
|
requiredPoints: pointsCost,
|
||||||
|
currentPoints: walletBalance,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
} catch {
|
} catch {
|
||||||
setDraftGenerationPointNotice({
|
setDraftGenerationPointNotice({
|
||||||
title: '读取泥点余额失败',
|
kind: 'balance-load-failed',
|
||||||
message: '请稍后重试。',
|
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -4364,11 +4366,6 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
barkBattleDraftGenerationPointCost,
|
barkBattleDraftGenerationPointCost,
|
||||||
ensureEnoughDraftGenerationPointsFromServer,
|
ensureEnoughDraftGenerationPointsFromServer,
|
||||||
]);
|
]);
|
||||||
const draftGenerationPointNoticeDescription = draftGenerationPointNotice
|
|
||||||
? draftGenerationPointNotice.title === '读取泥点余额失败'
|
|
||||||
? '当前表单不会丢失,关闭后可继续编辑,稍后再试。'
|
|
||||||
: '当前表单不会丢失,关闭后可继续编辑或补足泥点再继续。'
|
|
||||||
: undefined;
|
|
||||||
const recoverCompletedPuzzleDraftGeneration = useCallback(
|
const recoverCompletedPuzzleDraftGeneration = useCallback(
|
||||||
async ({
|
async ({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -16997,25 +16994,12 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<PlatformAcknowledgeStatusDialog
|
<PlatformDraftGenerationPointNoticeDialog
|
||||||
open={Boolean(draftGenerationPointNotice)}
|
notice={draftGenerationPointNotice}
|
||||||
status="error"
|
|
||||||
title={draftGenerationPointNotice?.title ?? '泥点提示'}
|
|
||||||
description={draftGenerationPointNoticeDescription}
|
|
||||||
onClose={() => setDraftGenerationPointNotice(null)}
|
onClose={() => setDraftGenerationPointNotice(null)}
|
||||||
showHeader
|
|
||||||
showCloseButton
|
|
||||||
closeOnBackdrop
|
|
||||||
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
|
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
|
||||||
panelClassName="platform-remap-surface rounded-[1.75rem]"
|
panelClassName="platform-remap-surface rounded-[1.75rem]"
|
||||||
iconClassName={
|
/>
|
||||||
draftGenerationPointNotice?.title === '读取泥点余额失败'
|
|
||||||
? undefined
|
|
||||||
: 'bg-amber-100/80 text-amber-600'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{draftGenerationPointNotice?.message}
|
|
||||||
</PlatformAcknowledgeStatusDialog>
|
|
||||||
<PublishShareModal
|
<PublishShareModal
|
||||||
open={Boolean(publishSharePayload)}
|
open={Boolean(publishSharePayload)}
|
||||||
payload={publishSharePayload}
|
payload={publishSharePayload}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
ProfilePlayStatsResponse,
|
ProfilePlayStatsResponse,
|
||||||
ProfileSaveArchiveSummary,
|
ProfileSaveArchiveSummary,
|
||||||
} from '../../../packages/shared/src/contracts/runtime';
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
|
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
|
||||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||||
@@ -167,13 +168,30 @@ export function PlatformProfilePlayedWorksModal({
|
|||||||
</PlatformStatusMessage>
|
</PlatformStatusMessage>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isLoading ? (
|
<PlatformAsyncStatePanel
|
||||||
<div className="mt-5 space-y-3">
|
isLoading={isLoading}
|
||||||
{Array.from({ length: 4 }).map((_, index) => (
|
loadingState={
|
||||||
<div key={index} className="h-20 animate-pulse rounded-xl bg-zinc-100" />
|
<div className="mt-5 space-y-3">
|
||||||
))}
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
</div>
|
<div
|
||||||
) : hasArchiveEntries || hasPlayedWorks ? (
|
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">
|
<div className="mt-5 space-y-5">
|
||||||
{hasArchiveEntries ? (
|
{hasArchiveEntries ? (
|
||||||
<section>
|
<section>
|
||||||
@@ -245,16 +263,7 @@ export function PlatformProfilePlayedWorksModal({
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</PlatformAsyncStatePanel>
|
||||||
<PlatformEmptyState
|
|
||||||
surface="subpanel"
|
|
||||||
size="inline"
|
|
||||||
tone="base"
|
|
||||||
className="mt-5 text-left"
|
|
||||||
>
|
|
||||||
暂无玩过
|
|
||||||
</PlatformEmptyState>
|
|
||||||
)}
|
|
||||||
</PlatformProfileSecondaryModalShell>
|
</PlatformProfileSecondaryModalShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
ProfileRechargeProduct,
|
ProfileRechargeProduct,
|
||||||
} from '../../../packages/shared/src/contracts/runtime';
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||||
|
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
|
||||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||||
@@ -183,35 +184,49 @@ export function PlatformProfileRechargeModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
<PlatformAsyncStatePanel
|
||||||
<PlatformStatusMessage
|
errorState={
|
||||||
tone="error"
|
error ? (
|
||||||
surface="profile"
|
<PlatformStatusMessage
|
||||||
size="xs"
|
tone="error"
|
||||||
className="mt-4 rounded-2xl font-semibold"
|
surface="profile"
|
||||||
>
|
size="xs"
|
||||||
<div>{error}</div>
|
className="mt-4 rounded-2xl font-semibold"
|
||||||
<PlatformActionButton
|
>
|
||||||
surface="profile"
|
<div>{error}</div>
|
||||||
size="xs"
|
<PlatformActionButton
|
||||||
className="mt-3"
|
surface="profile"
|
||||||
onClick={onRetry}
|
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>
|
</PlatformEmptyState>
|
||||||
</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 ? (
|
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
<RechargeProductCard
|
<RechargeProductCard
|
||||||
@@ -222,15 +237,7 @@ export function PlatformProfileRechargeModal({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</PlatformAsyncStatePanel>
|
||||||
<PlatformEmptyState
|
|
||||||
surface="subpanel"
|
|
||||||
size="inline"
|
|
||||||
className="mt-4"
|
|
||||||
>
|
|
||||||
暂无可购买套餐
|
|
||||||
</PlatformEmptyState>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{nativePayment ? (
|
{nativePayment ? (
|
||||||
<PlatformSubpanel
|
<PlatformSubpanel
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ReactNode } from 'react';
|
|||||||
import communityQqQrImage from '../../../media/social-media-group/qq.png';
|
import communityQqQrImage from '../../../media/social-media-group/qq.png';
|
||||||
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
|
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
|
||||||
import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
|
import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
|
||||||
|
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
|
||||||
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
|
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
|
||||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||||
@@ -134,144 +135,154 @@ export function PlatformProfileReferralModal({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (panel === 'redeem') {
|
} 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 = (
|
content = (
|
||||||
<div className="mt-5 space-y-3">
|
<PlatformAsyncStatePanel
|
||||||
<div className="h-20 animate-pulse rounded-xl bg-zinc-100" />
|
isLoading={isLoading}
|
||||||
<div className="h-10 animate-pulse rounded-xl bg-zinc-100" />
|
loadingState={
|
||||||
</div>
|
<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 {
|
} else {
|
||||||
content = (
|
content = (
|
||||||
<div className="mt-5 space-y-3">
|
<PlatformAsyncStatePanel
|
||||||
<PlatformSubpanel
|
isLoading={isLoading}
|
||||||
as="div"
|
loadingState={
|
||||||
surface="flat"
|
<div className="mt-5 space-y-3">
|
||||||
radius="xs"
|
<div className="h-20 animate-pulse rounded-xl bg-zinc-100" />
|
||||||
padding="md"
|
<div className="h-10 animate-pulse rounded-xl bg-zinc-100" />
|
||||||
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 ?? '--------'}
|
|
||||||
</div>
|
</div>
|
||||||
</PlatformSubpanel>
|
}
|
||||||
<PlatformStatusMessage
|
>
|
||||||
tone="warning"
|
<div className="mt-5 space-y-3">
|
||||||
surface="profile"
|
<PlatformSubpanel
|
||||||
size="md"
|
as="div"
|
||||||
className="space-y-0.5 px-3.5 font-semibold"
|
surface="flat"
|
||||||
>
|
radius="xs"
|
||||||
<div>
|
padding="md"
|
||||||
{`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}泥点。`}
|
className="text-center"
|
||||||
</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
|
||||||
</PlatformFieldLabel>
|
variant="section"
|
||||||
{center?.invitedUsers?.length ? (
|
className="block text-[11px] text-zinc-500"
|
||||||
<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>
|
</PlatformFieldLabel>
|
||||||
)}
|
<div className="mt-1 text-3xl font-black tracking-[0.16em] text-[#ff4056]">
|
||||||
</PlatformSubpanel>
|
{center?.inviteCode ?? '--------'}
|
||||||
</div>
|
</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 type { ProfileTaskCenterResponse } from '../../../packages/shared/src/contracts/runtime';
|
||||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||||
|
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
|
||||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||||
@@ -50,24 +51,6 @@ export function PlatformProfileTaskCenterModal({
|
|||||||
panelClassName="platform-recharge-modal !max-w-md rounded-[1.4rem]"
|
panelClassName="platform-recharge-modal !max-w-md rounded-[1.4rem]"
|
||||||
bodyClassName="space-y-3 px-5 py-5"
|
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 ? (
|
{success ? (
|
||||||
<PlatformStatusMessage
|
<PlatformStatusMessage
|
||||||
tone="success"
|
tone="success"
|
||||||
@@ -78,20 +61,45 @@ export function PlatformProfileTaskCenterModal({
|
|||||||
{success}
|
{success}
|
||||||
</PlatformStatusMessage>
|
</PlatformStatusMessage>
|
||||||
) : null}
|
) : null}
|
||||||
{isLoading ? (
|
<PlatformAsyncStatePanel
|
||||||
<div className="space-y-3">
|
errorState={
|
||||||
{Array.from({ length: 2 }).map((_, index) => (
|
error ? (
|
||||||
<div
|
<PlatformStatusMessage
|
||||||
key={index}
|
tone="error"
|
||||||
className="h-20 animate-pulse rounded-2xl bg-white/10"
|
surface="profile"
|
||||||
/>
|
size="xs"
|
||||||
))}
|
className="rounded-2xl font-semibold"
|
||||||
</div>
|
>
|
||||||
) : tasks.length === 0 ? (
|
<div>{error}</div>
|
||||||
<PlatformEmptyState surface="subpanel" size="inline">
|
<PlatformActionButton
|
||||||
暂无任务
|
surface="profile"
|
||||||
</PlatformEmptyState>
|
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">
|
<div className="space-y-3">
|
||||||
{tasks.map((task) => {
|
{tasks.map((task) => {
|
||||||
const isClaimable = task.status === 'claimable';
|
const isClaimable = task.status === 'claimable';
|
||||||
@@ -137,7 +145,7 @@ export function PlatformProfileTaskCenterModal({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</PlatformAsyncStatePanel>
|
||||||
</PlatformProfileModalShell>
|
</PlatformProfileModalShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Coins } from 'lucide-react';
|
|||||||
|
|
||||||
import type { ProfileWalletLedgerResponse } from '../../../packages/shared/src/contracts/runtime';
|
import type { ProfileWalletLedgerResponse } from '../../../packages/shared/src/contracts/runtime';
|
||||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||||
|
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
|
||||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||||
@@ -60,37 +61,48 @@ export function PlatformProfileWalletLedgerModal({
|
|||||||
</PlatformPillBadge>
|
</PlatformPillBadge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
<PlatformAsyncStatePanel
|
||||||
<PlatformStatusMessage tone="error" className="mt-4 rounded-xl py-3">
|
errorState={
|
||||||
<div>{error}</div>
|
error ? (
|
||||||
<PlatformActionButton
|
<PlatformStatusMessage
|
||||||
surface="profile"
|
tone="error"
|
||||||
shape="pill"
|
className="mt-4 rounded-xl py-3"
|
||||||
size="xs"
|
>
|
||||||
className="mt-3"
|
<div>{error}</div>
|
||||||
onClick={onRetry}
|
<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>
|
</PlatformEmptyState>
|
||||||
</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>
|
|
||||||
) : (
|
|
||||||
<div className="mt-5 space-y-2.5">
|
<div className="mt-5 space-y-2.5">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<PlatformSubpanel
|
<PlatformSubpanel
|
||||||
@@ -124,7 +136,7 @@ export function PlatformProfileWalletLedgerModal({
|
|||||||
</PlatformSubpanel>
|
</PlatformSubpanel>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</PlatformAsyncStatePanel>
|
||||||
</PlatformProfileSecondaryModalShell>
|
</PlatformProfileSecondaryModalShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ import { PlatformIconBadge } from '../common/PlatformIconBadge';
|
|||||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||||
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
|
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
|
||||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||||
|
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs';
|
||||||
import { PlatformStatusDialog } from '../common/PlatformStatusDialog';
|
import { PlatformStatusDialog } from '../common/PlatformStatusDialog';
|
||||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||||
@@ -1000,14 +1001,16 @@ function PublicCodeSearchBar({
|
|||||||
placeholder="搜索作品号、名称、作者、描述"
|
placeholder="搜索作品号、名称、作者、描述"
|
||||||
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
|
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
|
||||||
/>
|
/>
|
||||||
<button
|
<PlatformActionButton
|
||||||
type="button"
|
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
disabled={!value.trim() || isSearching}
|
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 ? '搜索中' : '搜索'}
|
{isSearching ? '搜索中' : '搜索'}
|
||||||
</button>
|
</PlatformActionButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2127,6 +2130,23 @@ function PlatformCategoryFilterDialog({
|
|||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
onClose: () => 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 (
|
return (
|
||||||
<UnifiedModal
|
<UnifiedModal
|
||||||
open
|
open
|
||||||
@@ -2150,61 +2170,76 @@ function PlatformCategoryFilterDialog({
|
|||||||
<div className="text-xs font-black text-[var(--platform-text-soft)]">
|
<div className="text-xs font-black text-[var(--platform-text-soft)]">
|
||||||
玩法
|
玩法
|
||||||
</div>
|
</div>
|
||||||
<div className="platform-category-filter-dialog__options">
|
<PlatformSegmentedTabs
|
||||||
{PLATFORM_CATEGORY_KIND_FILTERS.map((option) => {
|
items={kindFilterTabs}
|
||||||
const active = option.id === kindFilter;
|
activeId={kindFilter}
|
||||||
|
onChange={onKindFilterChange}
|
||||||
return (
|
columns="two"
|
||||||
<button
|
gap="md"
|
||||||
key={option.id}
|
frame="bare"
|
||||||
type="button"
|
surface="transparent"
|
||||||
onClick={() => onKindFilterChange(option.id)}
|
size="sm"
|
||||||
className={`platform-category-filter-dialog__option ${active ? 'platform-category-filter-dialog__option--active' : ''}`}
|
semantics="segment"
|
||||||
>
|
className="platform-category-filter-dialog__options"
|
||||||
{option.label}
|
itemClassName={(_, active) =>
|
||||||
</button>
|
[
|
||||||
);
|
'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
|
||||||
</div>
|
? '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>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<div className="text-xs font-black text-[var(--platform-text-soft)]">
|
<div className="text-xs font-black text-[var(--platform-text-soft)]">
|
||||||
排序
|
排序
|
||||||
</div>
|
</div>
|
||||||
<div className="platform-category-filter-dialog__options">
|
<PlatformSegmentedTabs
|
||||||
{PLATFORM_CATEGORY_SORT_OPTIONS.map((option) => {
|
items={sortModeTabs}
|
||||||
const active = option.id === sortMode;
|
activeId={sortMode}
|
||||||
|
onChange={onSortModeChange}
|
||||||
return (
|
columns="two"
|
||||||
<button
|
gap="md"
|
||||||
key={option.id}
|
frame="bare"
|
||||||
type="button"
|
surface="transparent"
|
||||||
onClick={() => onSortModeChange(option.id)}
|
size="sm"
|
||||||
className={`platform-category-filter-dialog__option ${active ? 'platform-category-filter-dialog__option--active' : ''}`}
|
semantics="segment"
|
||||||
>
|
className="platform-category-filter-dialog__options"
|
||||||
{option.label}
|
itemClassName={(_, active) =>
|
||||||
</button>
|
[
|
||||||
);
|
'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
|
||||||
</div>
|
? '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>
|
||||||
|
|
||||||
<div className="platform-category-filter-dialog__actions">
|
<div className="platform-category-filter-dialog__actions">
|
||||||
<button
|
<PlatformActionButton
|
||||||
type="button"
|
|
||||||
onClick={onReset}
|
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>
|
</PlatformActionButton>
|
||||||
<button
|
<PlatformActionButton
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
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>
|
</div>
|
||||||
</UnifiedModal>
|
</UnifiedModal>
|
||||||
);
|
);
|
||||||
@@ -3455,28 +3490,31 @@ export function RpgEntryHomeView({
|
|||||||
<section
|
<section
|
||||||
className={`${PANEL_SURFACE_CLASS} platform-ranking-panel px-4 py-3.5 sm:px-5 sm:py-4`}
|
className={`${PANEL_SURFACE_CLASS} platform-ranking-panel px-4 py-3.5 sm:px-5 sm:py-4`}
|
||||||
>
|
>
|
||||||
<div
|
<PlatformSegmentedTabs
|
||||||
className="platform-ranking-tabs flex min-w-0 gap-2 overflow-x-auto pb-1 scrollbar-hide"
|
items={PLATFORM_RANKING_TABS.map((tab) => ({
|
||||||
role="tablist"
|
id: tab.id,
|
||||||
aria-label="作品排行"
|
label: tab.label,
|
||||||
>
|
}))}
|
||||||
{PLATFORM_RANKING_TABS.map((tab) => {
|
activeId={activeRankingTab}
|
||||||
const active = tab.id === activeRankingTab;
|
onChange={setActiveRankingTab}
|
||||||
|
layout="scroll"
|
||||||
return (
|
gap="md"
|
||||||
<button
|
frame="bare"
|
||||||
key={tab.id}
|
surface="transparent"
|
||||||
type="button"
|
size="sm"
|
||||||
role="tab"
|
tone="neutral"
|
||||||
aria-selected={active}
|
semantics="tabs"
|
||||||
onClick={() => setActiveRankingTab(tab.id)}
|
ariaLabel="作品排行"
|
||||||
className={`platform-ranking-tab shrink-0 ${active ? 'platform-ranking-tab--active' : ''}`}
|
className="platform-ranking-tabs pb-1"
|
||||||
>
|
itemClassName={(_, active) =>
|
||||||
{tab.label}
|
[
|
||||||
</button>
|
'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,
|
||||||
})}
|
]
|
||||||
</div>
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{isLoadingPlatform ? (
|
{isLoadingPlatform ? (
|
||||||
<PlatformEmptyState>正在读取公开作品...</PlatformEmptyState>
|
<PlatformEmptyState>正在读取公开作品...</PlatformEmptyState>
|
||||||
|
|||||||
Reference in New Issue
Block a user