diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 3b4cf7c4d..3dbd5cd07 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -46,6 +46,7 @@ module.exports = { { files: [ 'apps/admin-web/src/pages/*.tsx', + 'src/components/platform-entry/PlatformActiveMobileWelcomeDialog.tsx', 'src/components/platform-entry/PlatformMobileHomeWelcomeDialog.tsx', ], rules: { @@ -72,6 +73,7 @@ module.exports = { 'src/components/image-editor/**/*.{ts,tsx}', 'src/components/project/**/*.{ts,tsx}', 'src/components/platform-entry/PlatformActiveProfileView*.tsx', + 'src/components/platform-entry/PlatformActiveMobileWelcomeDialog*.tsx', 'src/components/platform-entry/PlatformEntryActiveFlowShell*.tsx', 'src/components/platform-entry/PlatformEntryFlowShell.tsx', 'src/components/platform-entry/PlatformProfileApiKeysModal.tsx', diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index 2c846bb1a..322461402 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -21,7 +21,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台,把 A - 小程序 WebView 外壳:`miniprogram/`。 - 法律文本:`media/files/user_agreement.md`、`media/files/privacy_policy.md`、`media/files/disclaimer.md`。 -桌面端侧边栏的一级入口为 `创作 / 项目 / 我的`;移动端底部 dock 只保留 `我的`。`/creation` 是桌面端独立创作工具主页,`/project` 是桌面端画布项目入口,`/profile` 是桌面端和移动端共用的“我的”稳定路由,继续承载账号、钱包、统计和通用设置等平台公共能力。移动端直达 `/creation`、`/project` 或 `/editor/canvas`,以及从首页触发项目 / 画布动作时,只显示桌面端创作提示,不挂载对应工具页面。 +桌面端侧边栏的一级入口为 `创作 / 项目 / 我的`;移动端底部 dock 只保留 `我的`,根入口默认展示“我的”并保持该 Tab 选中。移动端每次进入站点壳时先显示原有 IP 欢迎遮罩,明确“移动端仅支持作品展示”,遮罩只能通过“好”按钮关闭,关闭后本次页面生命周期内不再重复,刷新后重新显示。`/creation` 是桌面端独立创作工具主页,`/project` 是桌面端画布项目入口,`/profile` 是桌面端和移动端共用的“我的”稳定路由,继续承载账号、钱包、统计和通用设置等平台公共能力。移动端直达 `/creation`、`/project` 或 `/editor/canvas`,以及从首页触发项目 / 画布动作时,只显示与移除前一致的桌面端创作主页提示,不挂载对应工具页面;提示页底部继续保留唯一的“我的”Tab 作为返回入口。 ## 当前后端路线 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 875a7a0ea..3a548f4c4 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -12,7 +12,7 @@ - `/project` 展示当前账号的图片编辑器项目,项目卡继续进入 `/editor/canvas`。 - `/profile` 是“我的”稳定路由,保留头像与昵称编辑、陶泥号复制、泥点余额与账单、累计统计、泥点充值、兑换码、玩家社区、反馈与建议、通用设置、开发者 API Key 和法律信息等平台公共能力。 - 桌面顶栏保留现役项目 / 素材搜索、泥点入口和账号胶囊。搜索只筛选当前编辑器项目与已读取的公开编辑器素材,不恢复旧公开作品号搜索、旧广场、旧作品详情或旧运行态。 -- 桌面端公共侧边栏固定显示“创作 / 项目 / 我的”;移动端底部 dock 只保留“我的”,不暴露“创作 / 项目”。移动端直达 `/creation`、`/project` 或 `/editor/canvas` 时显示桌面端创作提示,不挂载创作主页、项目列表或图片画布;从移动端首页触发项目或画布动作时也只显示同一提示。 +- 桌面端公共侧边栏固定显示“创作 / 项目 / 我的”;移动端底部 dock 只保留“我的”,不暴露“创作 / 项目”,根入口默认展示“我的”并保持该 Tab 选中。移动端每次进入站点壳时先显示移除前的 IP 欢迎遮罩,明确“移动端仅支持作品展示”;遮罩不允许通过背景或 Escape 关闭,只能点击“好”,关闭后本次页面生命周期内不再重复,刷新后重新显示。移动端直达 `/creation`、`/project` 或 `/editor/canvas` 时显示移除前的桌面端创作主页提示,不挂载创作主页、项目列表或图片画布;提示页底部继续保留唯一的“我的”Tab 作为返回入口。三类工具统一阻断沿用 2026-08-03 的现役产品边界,不回退为旧源码只拦截 `/creation` 的较弱行为。 现役入口和公共资料能力只能依赖 `creation-home`、`project`、`image-editor`、公共组件及 `services/platform-entry` 等现役模块。Vite 模块门禁会拒绝 `components/rpg-entry`、`services/rpg-entry`、旧玩法目录和旧平台业务模块进入依赖图;Tailwind `@source`、TypeScript `include`、ESLint ignore 或 Vite watch ignore 都不能替代这条运行时依赖门禁。 diff --git a/scripts/vite-retired-css-plugin.test.ts b/scripts/vite-retired-css-plugin.test.ts index cabf5f58f..2bc063111 100644 --- a/scripts/vite-retired-css-plugin.test.ts +++ b/scripts/vite-retired-css-plugin.test.ts @@ -311,5 +311,10 @@ describe('retired creation template asset boundary', () => { isForbiddenPublicAssetPath('/generated-editor-images/asset.png'), ).toBe(true); expect(isForbiddenPublicAssetPath('/creation-home/logo.png')).toBe(false); + expect( + isForbiddenPublicAssetPath( + '/branding/mobile-home-welcome-taonier-ip.png', + ), + ).toBe(false); }); }); diff --git a/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.test.tsx b/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.test.tsx new file mode 100644 index 000000000..fcde1abd6 --- /dev/null +++ b/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.test.tsx @@ -0,0 +1,97 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { expect, test, vi } from 'vitest'; + +import { + PlatformActiveMobileWelcomeDialog, + shouldOpenActiveMobileWelcomeDialog, + shouldShowActiveMobileWelcomeDialog, +} from './PlatformActiveMobileWelcomeDialog'; + +test('shows the active mobile welcome when entering the mobile site shell', () => { + expect( + shouldShowActiveMobileWelcomeDialog({ + isDesktopLayout: false, + selectionStage: 'platform', + platformTab: 'platform', + pathname: '/', + }), + ).toBe(true); + expect( + shouldShowActiveMobileWelcomeDialog({ + isDesktopLayout: true, + selectionStage: 'platform', + platformTab: 'platform', + pathname: '/', + }), + ).toBe(false); + expect( + shouldShowActiveMobileWelcomeDialog({ + isDesktopLayout: false, + selectionStage: 'profile', + platformTab: 'profile', + pathname: '/profile', + }), + ).toBe(true); + expect( + shouldShowActiveMobileWelcomeDialog({ + isDesktopLayout: false, + selectionStage: 'creation-home', + platformTab: 'creation-home', + pathname: '/creation', + }), + ).toBe(true); +}); + +test('keeps the active mobile welcome dismissed across site navigation', () => { + expect( + shouldOpenActiveMobileWelcomeDialog({ + isDesktopLayout: false, + selectionStage: 'platform', + platformTab: 'platform', + pathname: '/', + isDismissed: false, + }), + ).toBe(true); + expect( + shouldOpenActiveMobileWelcomeDialog({ + isDesktopLayout: false, + selectionStage: 'profile', + platformTab: 'profile', + pathname: '/profile', + isDismissed: true, + }), + ).toBe(false); +}); + +test('renders the restored copy and closes with the acknowledge button', () => { + const onClose = vi.fn(); + + render( + , + ); + + expect(screen.getByRole('dialog', { name: '欢迎' })).toBeTruthy(); + expect( + screen.getByRole('heading', { level: 2, name: '欢迎' }), + ).toBeTruthy(); + expect( + document.querySelector('.platform-mobile-home-welcome-dialog__copy') + ?.textContent, + ).toBe( + '尼嚎,新陶泥er!移动端仅支持作品展示体验创作工具请使用电脑端访问该地址', + ); + expect( + document + .querySelector('.platform-mobile-home-welcome-dialog__icon') + ?.getAttribute('src'), + ).toBe('/branding/mobile-home-welcome-taonier-ip.png'); + + fireEvent.click(screen.getByRole('button', { name: '好' })); + expect(onClose).toHaveBeenCalledTimes(1); +}); diff --git a/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.tsx b/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.tsx new file mode 100644 index 000000000..16aef64b9 --- /dev/null +++ b/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.tsx @@ -0,0 +1,82 @@ +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { UnifiedModal } from '../common/UnifiedModal'; + +type MobileHomeWelcomeDecisionInput = { + isDesktopLayout: boolean; + selectionStage: string; + platformTab: string; + pathname: string; +}; + +type MobileHomeWelcomeOpenInput = MobileHomeWelcomeDecisionInput & { + isDismissed: boolean; +}; + +type PlatformActiveMobileWelcomeDialogProps = { + open: boolean; + platformThemeClass: string; + onClose: () => void; +}; + +export function shouldShowActiveMobileWelcomeDialog({ + isDesktopLayout, +}: MobileHomeWelcomeDecisionInput) { + return !isDesktopLayout; +} + +export function shouldOpenActiveMobileWelcomeDialog({ + isDismissed, + ...decisionInput +}: MobileHomeWelcomeOpenInput) { + return ( + !isDismissed && shouldShowActiveMobileWelcomeDialog(decisionInput) + ); +} + +export function PlatformActiveMobileWelcomeDialog({ + open, + platformThemeClass, + onClose, +}: PlatformActiveMobileWelcomeDialogProps) { + return ( + + 好 + + } + > + +

欢迎

+

+ 尼嚎,新陶泥er! +
+ 移动端仅支持作品展示 +
+ 体验创作工具请使用电脑端访问该地址 +

+
+ ); +} diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index eef0b0e1d..1b6e2e872 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -483,6 +483,15 @@ describe('PlatformEntryActiveFlowShell', () => { .getAllByRole('button') .map((button) => button.getAttribute('aria-label')), ).toEqual(['我的']); + expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy(); + expect( + within(navigation) + .getByRole('button', { name: '我的' }) + .getAttribute('aria-current'), + ).toBe('page'); + expect( + screen.queryByRole('main', { name: '陶泥儿创作主页' }), + ).toBeNull(); fireEvent.click(within(navigation).getByRole('button', { name: '我的' })); expect(window.location.pathname).toBe('/profile'); @@ -491,6 +500,48 @@ describe('PlatformEntryActiveFlowShell', () => { }); }); + it('restores the original blocking welcome on mobile until acknowledged', async () => { + responsiveMock.isDesktopLayout = false; + const { rerender } = render( + , + ); + + expect(screen.getByRole('dialog', { name: '欢迎' })).toBeTruthy(); + expect( + screen.getByText( + '尼嚎,新陶泥er!移动端仅支持作品展示体验创作工具请使用电脑端访问该地址', + ), + ).toBeTruthy(); + expect( + document + .querySelector('.platform-mobile-home-welcome-dialog__icon') + ?.getAttribute('src'), + ).toBe('/branding/mobile-home-welcome-taonier-ip.png'); + + fireEvent.click(screen.getByRole('button', { name: '好' })); + expect(screen.queryByRole('dialog', { name: '欢迎' })).toBeNull(); + expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy(); + const navigation = screen.getByRole('navigation', { + name: '移动平台导航', + }); + expect( + within(navigation) + .getByRole('button', { name: '我的' }) + .getAttribute('aria-current'), + ).toBe('page'); + + rerender( + , + ); + expect(screen.queryByRole('dialog', { name: '欢迎' })).toBeNull(); + }); + it.each(['creation-home', 'project', 'image-editor'] as const)( 'shows the desktop guide instead of mounting the %s page on mobile', async (selectionStage) => { @@ -506,25 +557,52 @@ describe('PlatformEntryActiveFlowShell', () => { expect( await screen.findByRole('main', { name: '桌面端创作提示' }), ).toBeTruthy(); - expect(screen.queryByRole('main', { name: '陶泥儿创作主页' })).toBeNull(); + expect(screen.getByText('请在桌面端打开创作主页')).toBeTruthy(); + expect( + screen.queryByRole('main', { name: '陶泥儿创作主页' }), + ).toBeNull(); expect(screen.queryByRole('main', { name: '项目' })).toBeNull(); - expect(screen.queryByRole('main', { name: '图片画布编辑器' })).toBeNull(); + expect( + screen.queryByRole('main', { name: '图片画布编辑器' }), + ).toBeNull(); + const navigation = screen.getByRole('navigation', { + name: '移动平台导航', + }); + expect( + within(navigation) + .getAllByRole('button') + .map((button) => button.getAttribute('aria-label')), + ).toEqual(['我的']); + expect( + within(navigation) + .getByRole('button', { name: '我的' }) + .getAttribute('aria-current'), + ).toBeNull(); }, ); - it('shows the desktop guide instead of the editor after opening a canvas tool from the mobile root', async () => { + it('returns from the mobile tool guide through the only profile tab', async () => { + responsiveMock.isDesktopLayout = false; + render(); + + fireEvent.click(screen.getByRole('button', { name: '好' })); + const navigation = screen.getByRole('navigation', { + name: '移动平台导航', + }); + fireEvent.click(within(navigation).getByRole('button', { name: '我的' })); + + expect(window.location.pathname).toBe('/profile'); + expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy(); + }); + + it('does not mount creation actions on the mobile root', async () => { responsiveMock.isDesktopLayout = false; render(); - fireEvent.click( - await screen.findByRole('button', { name: '打开音乐工具' }), - ); - - expect( - await screen.findByRole('main', { name: '桌面端创作提示' }), - ).toBeTruthy(); - expect(screen.queryByRole('main', { name: '图片画布编辑器' })).toBeNull(); + expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: '打开音乐工具' })).toBeNull(); + expect(screen.queryByRole('main', { name: '陶泥儿创作主页' })).toBeNull(); }); it('switches between creation and projects and passes search to the active page', async () => { diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index 32310a11d..dd9511914 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -30,6 +30,10 @@ import { useAuthUi } from '../auth/AuthUiContext'; import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformSubpanel } from '../common/PlatformSubpanel'; +import { + PlatformActiveMobileWelcomeDialog, + shouldOpenActiveMobileWelcomeDialog, +} from './PlatformActiveMobileWelcomeDialog'; import { resolveActivePublicUserCode, resolveActiveUserAvatarLabel, @@ -137,6 +141,30 @@ function ActiveBottomNavButton({ ); } +function MobileProfileDock({ + active, + onOpenProfile, +}: { + active: boolean; + onOpenProfile: () => void; +}) { + return ( +
+ +
+ ); +} + function ActivePlatformBrand() { return (

- 请在桌面端打开创作工具 + 请在桌面端打开创作主页

图片画布、项目管理和素材库需要更大的操作空间。 @@ -224,7 +252,21 @@ export function PlatformEntryFlowShellImpl({ const [activeSearchKeyword, setActiveSearchKeyword] = useState(''); const [isMobileDesktopGuideOpen, setIsMobileDesktopGuideOpen] = useState(false); + const [isMobileHomeWelcomeDismissed, setIsMobileHomeWelcomeDismissed] = + useState(false); const isDesktopLayout = usePlatformDesktopLayout(); + const platformThemeClass = + authUi?.platformTheme === 'dark' + ? 'platform-theme--dark' + : 'platform-theme--light'; + const shouldOpenMobileHomeWelcome = shouldOpenActiveMobileWelcomeDialog({ + isDesktopLayout, + selectionStage, + platformTab: selectionStage, + pathname: + typeof window === 'undefined' ? '/' : window.location.pathname, + isDismissed: isMobileHomeWelcomeDismissed, + }); const currentWalletOwnerUserId = authUi?.canAccessProtectedData && authUi.user?.id ? authUi.user.id : null; const walletOwnerUserId = usePlatformWalletStore( @@ -276,7 +318,9 @@ export function PlatformEntryFlowShellImpl({ void refreshDashboard(); }, [refreshDashboard]); - const isProfileStage = selectionStage === 'profile'; + const isProfileStage = + selectionStage === 'profile' || + (!isDesktopLayout && selectionStage === 'platform'); const profileCenter = usePlatformProfileCenterController({ activeTab: isProfileStage ? 'profile' : 'project', @@ -348,15 +392,23 @@ export function PlatformEntryFlowShellImpl({ if ((!isDesktopLayout && isMobileDesktopGuideOpen) || isMobileToolStage) { return ( - { - setIsMobileDesktopGuideOpen(false); - if (selectionStage === 'platform') { - return; - } - setSelectionStage('platform', { path: '/' }); - }} - /> + <> + { + setIsMobileDesktopGuideOpen(false); + if (selectionStage === 'platform') { + return; + } + setSelectionStage('platform', { path: '/' }); + }} + /> + + setIsMobileHomeWelcomeDismissed(true)} + /> + ); } @@ -580,19 +632,10 @@ export function PlatformEntryFlowShellImpl({ -

- -
+ @@ -661,6 +704,11 @@ export function PlatformEntryFlowShellImpl({ orderId={profileCenter.wechatRechargeOrderConfirmationState.orderId} /> ) : null} + setIsMobileHomeWelcomeDismissed(true)} + /> ); } diff --git a/vite.config.ts b/vite.config.ts index 2db56028a..b0d0a6c17 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -190,7 +190,6 @@ const ACTIVE_TAILWIND_SOURCES = `@import 'tailwindcss' source(none); const RETIRED_PUBLIC_ASSET_PATHS = [ '/audio', '/branding/jump-hop-taonier-character.png', - '/branding/mobile-home-welcome-taonier-ip.png', '/anthro-cat-illustrations', '/bark-battle-assets', '/character', diff --git a/vitest.config.ts b/vitest.config.ts index 9ee1ef4ba..dc7fa68f8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -65,6 +65,7 @@ export default defineConfig({ 'src/components/image-editor/**/*.test.tsx', 'src/components/project/**/*.test.ts', 'src/components/project/**/*.test.tsx', + 'src/components/platform-entry/PlatformActiveMobileWelcomeDialog.test.tsx', 'src/components/platform-entry/PlatformActiveProfileView.test.tsx', 'src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx', 'src/components/platform-entry/PlatformProfileModalShell.test.tsx',