diff --git a/.oxlintrc.json b/.oxlintrc.json index e018a6cf..10c9292d 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -176,7 +176,7 @@ ], "settings": { "react": { - "version": "detect" + "version": "19" } } } diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index de4c3704..3bf66184 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -3,10 +3,21 @@ import type { DecoratorFunction } from 'storybook/internal/types' import { useEffect, useMemo } from 'react' import { I18nextProvider } from 'react-i18next' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { LayoutGroup, MotionConfig } from 'motion/react' import i18n, { languages, getLanguageDirection, type LanguageCode } from '../src/i18n' import { currencies, preferencesActions, preferencesStore, type CurrencyCode } from '../src/stores/preferences' import '../src/styles/globals.css' +const MOTION_DEBUG_SPEED = 0.2 +const MOTION_DEBUG_TRANSITION = { + type: 'spring', + // Slow motion: 0.2x speed => ~5x longer. + // Scale stiffness by speed^2 and damping by speed to keep damping ratio similar. + stiffness: 220 * MOTION_DEBUG_SPEED * MOTION_DEBUG_SPEED, + damping: 28 * MOTION_DEBUG_SPEED, + mass: 0.85, +} as const + const mobileViewports = { iPhoneSE: { name: 'iPhone SE', @@ -124,6 +135,15 @@ const preview: Preview = { }, }, decorators: [ + // Global slow-motion for animation debugging + ((Story) => ( + + + + + + )) as DecoratorFunction, + // i18n + Theme + Direction + QueryClient decorator ((Story, context) => { const locale = (context.globals['locale'] || 'zh-CN') as LanguageCode @@ -179,6 +199,12 @@ const preview: Preview = { // Container size decorator with theme wrapper ((Story, context) => { + // Fullscreen stories should not be wrapped in a resizable container. + // Many app-level screens (e.g. MiniappWindow) rely on fullscreen canvas layout. + if (context.parameters?.layout === 'fullscreen') { + return + } + const containerKey = context.globals['containerSize'] || 'standard' const containerWidth = containerSizes[containerKey as keyof typeof containerSizes]?.width || 360 const theme = context.globals['theme'] || 'light' diff --git "a/docs/white-book/00-\345\277\205\350\257\273/best-practices.md" "b/docs/white-book/00-\345\277\205\350\257\273/best-practices.md" index 5d56b5eb..0fbfd333 100644 --- "a/docs/white-book/00-\345\277\205\350\257\273/best-practices.md" +++ "b/docs/white-book/00-\345\277\205\350\257\273/best-practices.md" @@ -25,3 +25,7 @@ - 紧凑头部效果使用 `animation-range: 0 80px` 限制动画范围 - ⚠️ scroll-driven animations 是渐进增强:初始状态必须是可用的(如 opacity-0),不支持时保持初始状态 - E2E 截图变更后运行 `pnpm e2e:audit` 检查残留截图,详见白皮书 08-测试篇/03-Playwright配置/e2e-best-practices +- 组件专属样式使用 CSS Modules:`component-name.module.css` + `import styles from './xxx.module.css'` +- CSS Modules 适用场景:@keyframes 动画、伪元素(::before/::after)、复杂选择器(:focus-within)、scroll-driven animations +- CSS Modules 与 Tailwind 混用:`className={cn(styles.header, 'sticky top-0 z-10 px-5')}` +- 优先级:CSS Modules > globals.css,组件样式应内聚到组件目录 diff --git a/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/02-ecosystem-tab-content.png b/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/02-ecosystem-tab-content.png index 562bff0b..c8a4d146 100644 Binary files a/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/02-ecosystem-tab-content.png and b/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/02-ecosystem-tab-content.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/02b-ecosystem-my-tab.png b/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/02b-ecosystem-my-tab.png index b4126d54..63f6e134 100644 Binary files a/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/02b-ecosystem-my-tab.png and b/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/02b-ecosystem-my-tab.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/02c-ecosystem-context-menu.png b/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/02c-ecosystem-context-menu.png index d980cc48..9d00f997 100644 Binary files a/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/02c-ecosystem-context-menu.png and b/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/02c-ecosystem-context-menu.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/02e-context-menu-detail.png b/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/02e-context-menu-detail.png index be6f24d8..72f8222b 100644 Binary files a/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/02e-context-menu-detail.png and b/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/02e-context-menu-detail.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/02f-context-menu-remove.png b/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/02f-context-menu-remove.png index 6b3508a5..1bc6d0ec 100644 Binary files a/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/02f-context-menu-remove.png and b/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/02f-context-menu-remove.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/16-miniapp-detail.png b/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/16-miniapp-detail.png index 80a4354b..f2e5c86c 100644 Binary files a/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/16-miniapp-detail.png and b/e2e/__screenshots__/Desktop-Chrome/ecosystem-miniapp.mock.spec.ts/16-miniapp-detail.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/forge-01-connect.png b/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/forge-01-connect.png index 69af7595..36ff3dc5 100644 Binary files a/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/forge-01-connect.png and b/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/forge-01-connect.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/forge-02-connect-dark.png b/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/forge-02-connect-dark.png index 69af7595..c803f2d1 100644 Binary files a/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/forge-02-connect-dark.png and b/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/forge-02-connect-dark.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/forge-03-green-theme.png b/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/forge-03-green-theme.png index 69af7595..744071af 100644 Binary files a/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/forge-03-green-theme.png and b/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/forge-03-green-theme.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/forge-04-dark-purple-theme.png b/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/forge-04-dark-purple-theme.png index 45ff96c5..d324fcff 100644 Binary files a/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/forge-04-dark-purple-theme.png and b/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/forge-04-dark-purple-theme.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/teleport-01-connect.png b/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/teleport-01-connect.png index ba2b7204..36ff3dc5 100644 Binary files a/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/teleport-01-connect.png and b/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/teleport-01-connect.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/teleport-02-connect-dark.png b/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/teleport-02-connect-dark.png index ba2b7204..c803f2d1 100644 Binary files a/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/teleport-02-connect-dark.png and b/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/teleport-02-connect-dark.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/teleport-03-connect-blue-theme.png b/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/teleport-03-connect-blue-theme.png index ba2b7204..6b9ae99b 100644 Binary files a/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/teleport-03-connect-blue-theme.png and b/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/teleport-03-connect-blue-theme.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/teleport-04-green-theme.png b/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/teleport-04-green-theme.png index 5059f7a6..62ab03f5 100644 Binary files a/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/teleport-04-green-theme.png and b/e2e/__screenshots__/Desktop-Chrome/miniapp-ui.mock.spec.ts/teleport-04-green-theme.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02-ecosystem-tab-content.png b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02-ecosystem-tab-content.png index 1c145e9c..92d08fed 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02-ecosystem-tab-content.png and b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02-ecosystem-tab-content.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02b-ecosystem-my-tab.png b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02b-ecosystem-my-tab.png index b870f232..c45f804a 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02b-ecosystem-my-tab.png and b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02b-ecosystem-my-tab.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02c-ecosystem-context-menu.png b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02c-ecosystem-context-menu.png index f6e9b22e..5f8ce362 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02c-ecosystem-context-menu.png and b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02c-ecosystem-context-menu.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02d-context-menu-open.png b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02d-context-menu-open.png index 63617773..b70dcee5 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02d-context-menu-open.png and b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02d-context-menu-open.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02e-context-menu-detail.png b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02e-context-menu-detail.png index 024fbd4c..d05aaebc 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02e-context-menu-detail.png and b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02e-context-menu-detail.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02f-context-menu-remove.png b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02f-context-menu-remove.png index e138c2b6..3297d235 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02f-context-menu-remove.png and b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02f-context-menu-remove.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/16-miniapp-detail.png b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/16-miniapp-detail.png index b121378c..f0a89b9c 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/16-miniapp-detail.png and b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/16-miniapp-detail.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/forge-01-connect.png b/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/forge-01-connect.png index 69af7595..36ff3dc5 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/forge-01-connect.png and b/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/forge-01-connect.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/forge-02-connect-dark.png b/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/forge-02-connect-dark.png index 69af7595..c803f2d1 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/forge-02-connect-dark.png and b/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/forge-02-connect-dark.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/forge-03-green-theme.png b/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/forge-03-green-theme.png index 69af7595..744071af 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/forge-03-green-theme.png and b/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/forge-03-green-theme.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/forge-04-dark-purple-theme.png b/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/forge-04-dark-purple-theme.png index 69af7595..d324fcff 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/forge-04-dark-purple-theme.png and b/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/forge-04-dark-purple-theme.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/teleport-01-connect.png b/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/teleport-01-connect.png index ba2b7204..36ff3dc5 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/teleport-01-connect.png and b/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/teleport-01-connect.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/teleport-02-connect-dark.png b/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/teleport-02-connect-dark.png index ba2b7204..c803f2d1 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/teleport-02-connect-dark.png and b/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/teleport-02-connect-dark.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/teleport-03-connect-blue-theme.png b/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/teleport-03-connect-blue-theme.png index ba2b7204..6b9ae99b 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/teleport-03-connect-blue-theme.png and b/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/teleport-03-connect-blue-theme.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/teleport-04-green-theme.png b/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/teleport-04-green-theme.png index ba2b7204..62ab03f5 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/teleport-04-green-theme.png and b/e2e/__screenshots__/Mobile-Chrome/miniapp-ui.mock.spec.ts/teleport-04-green-theme.png differ diff --git a/e2e/ecosystem-miniapp.mock.spec.ts b/e2e/ecosystem-miniapp.mock.spec.ts index 4a924306..d7dc006a 100644 --- a/e2e/ecosystem-miniapp.mock.spec.ts +++ b/e2e/ecosystem-miniapp.mock.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test' +import { test, expect, type Page } from '@playwright/test' /** * Bio 小程序生态 E2E 截图测试 @@ -6,6 +6,20 @@ import { test, expect } from '@playwright/test' * 测试用户故事并生成截图验证 UI 正确性 */ +/** + * 滑动到"我的"页面 (Swiper 布局:从右向左滑动一次) + * 发现(0) → 我的(1) → 堆栈(2) + */ +async function swipeToMyAppsPage(page: Page) { + const viewport = page.viewportSize()! + // 从中右向中左滑动,距离适中,避免滑过头 + await page.mouse.move(viewport.width * 0.7, viewport.height / 2) + await page.mouse.down() + await page.mouse.move(viewport.width * 0.3, viewport.height / 2, { steps: 20 }) + await page.mouse.up() + await page.waitForTimeout(500) +} + const TEST_WALLET_DATA = { wallets: [ { @@ -112,9 +126,8 @@ test.describe('生态 Tab 截图测试', () => { await ecosystemTab.click() await page.waitForTimeout(500) - // 点击"我的" Tab - const myTab = page.locator('button:has-text("我的")') - await myTab.click() + // 滑动到"我的"页 (从右向左滑) + await swipeToMyAppsPage(page) await page.waitForTimeout(500) await expect(page).toHaveScreenshot('02b-ecosystem-my-tab.png') @@ -138,9 +151,8 @@ test.describe('生态 Tab 截图测试', () => { await ecosystemTab.click() await page.waitForTimeout(500) - // 点击"我的" Tab - const myTab = page.locator('button:has-text("我的")') - await myTab.click() + // 滑动到"我的"页 + await swipeToMyAppsPage(page) await page.waitForTimeout(500) // 右键点击第一个应用图标触发 Context Menu @@ -166,7 +178,7 @@ test.describe('生态 Tab 截图测试', () => { // 进入生态 - 我的 await page.getByTestId('tab-ecosystem').click() await page.waitForTimeout(300) - await page.locator('button:has-text("我的")').click() + await swipeToMyAppsPage(page) await page.waitForTimeout(300) // 右键菜单 -> 打开 @@ -193,7 +205,7 @@ test.describe('生态 Tab 截图测试', () => { // 进入生态 - 我的 await page.getByTestId('tab-ecosystem').click() await page.waitForTimeout(300) - await page.locator('button:has-text("我的")').click() + await swipeToMyAppsPage(page) await page.waitForTimeout(300) // 右键菜单 -> 详情 @@ -221,7 +233,7 @@ test.describe('生态 Tab 截图测试', () => { // 进入生态 - 我的 await page.getByTestId('tab-ecosystem').click() await page.waitForTimeout(300) - await page.locator('button:has-text("我的")').click() + await swipeToMyAppsPage(page) await page.waitForTimeout(300) // 右键菜单 -> 移除 @@ -515,6 +527,58 @@ test.describe('权限请求截图测试', () => { }) }) +// ============================================ +// 小程序权限集成测试(runtime + bridge + sheet) +// ============================================ + +test.describe('小程序权限集成测试', () => { + test.beforeEach(async ({ page }) => { + await injectTestData(page) + }) + + test('Teleport 启动传送门应弹出权限请求', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem( + 'ecosystem_my_apps', + JSON.stringify([{ appId: 'xin.dweb.teleport', installedAt: Date.now() - 3600000, lastUsedAt: Date.now() - 1800000 }]) + ) + }) + + await page.goto('/#/') + await page.waitForLoadState('networkidle') + await page.waitForTimeout(500) + + await page.getByTestId('tab-ecosystem').click() + await page.waitForTimeout(300) + + await swipeToMyAppsPage(page) + await page.waitForTimeout(300) + + // 右键菜单 -> 打开 + await page.locator('[data-testid="ios-app-icon-xin.dweb.teleport"]').click({ button: 'right' }) + await page.waitForTimeout(200) + await page.locator('button:has-text("打开")').click() + + // 等待 iframe 加载并点击“启动传送门” + const teleportFrame = page.frameLocator('iframe[data-app-id="xin.dweb.teleport"]') + const launchButton = teleportFrame.getByRole('button', { name: '启动传送门' }) + await launchButton.waitFor({ state: 'visible', timeout: 15000 }) + + await launchButton.click() + + // 点击后应进入连接中状态 + await expect(teleportFrame.getByRole('button', { name: '连接中...' })).toBeVisible({ timeout: 3000 }) + + // 应出现权限请求 Sheet + await expect(page.getByText('请求以下权限')).toBeVisible({ timeout: 8000 }) + await expect(page.getByText('查看账户')).toBeVisible({ timeout: 8000 }) + + // 允许后应进入钱包选择器 + await page.locator('button:has-text("允许")').click() + await expect(page.getByText('选择钱包')).toBeVisible({ timeout: 8000 }) + }) +}) + // ============================================ // 空状态测试 // ============================================ diff --git a/package.json b/package.json index f36a25a2..a88be533 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,11 @@ "preview": "vite preview", "test": "turbo run test:run --", "test:run": "vitest run --project=unit", - "test:storybook": "vitest run --project=storybook", + "test:storybook": "bun scripts/storybook-clean.ts && vitest run --project=storybook", "test:all": "vitest run", "test:coverage": "vitest run --project=unit --coverage", "test:ui": "vitest run --ui", - "storybook": "storybook dev -p 6006", + "storybook": "bun scripts/storybook-clean.ts && storybook dev -p 6006", "build-storybook": "storybook build", "typecheck": "turbo run typecheck:run --", "typecheck:run": "tsc --build --noEmit", @@ -105,6 +105,7 @@ "idb": "^8.0.3", "jsqr": "^1.4.0", "lodash": "^4.17.21", + "motion": "^12.23.26", "qrcode.react": "^4.2.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 234c6786..69921f72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 + motion: + specifier: ^12.23.26 + version: 12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3) qrcode.react: specifier: ^4.2.0 version: 4.2.0(react@19.2.3) @@ -5619,6 +5622,20 @@ packages: motion-utils@12.23.6: resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + motion@12.23.26: + resolution: {integrity: sha512-Ll8XhVxY8LXMVYTCfme27WH2GjBrCIzY4+ndr5QKxsK+YwCtOi2B/oBi5jcIbik5doXuWT/4KKDOVAZJkeY5VQ==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -7387,6 +7404,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -10300,7 +10318,7 @@ snapshots: '@vitest/browser': 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.16) '@vitest/browser-playwright': 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(playwright@1.57.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.16) '@vitest/runner': 4.0.16 - vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3)) + vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3)) transitivePeerDependencies: - react - react-dom @@ -10741,7 +10759,7 @@ snapshots: '@vitest/mocker': 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)) playwright: 1.57.0 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3)) + vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3)) transitivePeerDependencies: - bufferutil - msw @@ -10757,7 +10775,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3)) + vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3)) ws: 8.18.3 transitivePeerDependencies: - bufferutil @@ -13005,6 +13023,14 @@ snapshots: motion-utils@12.23.6: {} + motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + framer-motion: 12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + mrmime@2.0.1: {} ms@2.0.0: {} diff --git a/scripts/storybook-clean.ts b/scripts/storybook-clean.ts new file mode 100644 index 00000000..295a0922 --- /dev/null +++ b/scripts/storybook-clean.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env bun + +import { rm } from 'node:fs/promises' +import { join } from 'node:path' + +const ROOT = join(import.meta.dirname, '..') + +const PATHS_TO_CLEAN = [ + 'node_modules/.cache/storybook', +] + +async function clean() { + for (const path of PATHS_TO_CLEAN) { + await rm(join(ROOT, path), { recursive: true, force: true }) + } +} + +clean().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/src/StackflowApp.tsx b/src/StackflowApp.tsx index bfb054a3..cef3f271 100644 --- a/src/StackflowApp.tsx +++ b/src/StackflowApp.tsx @@ -1,5 +1,24 @@ -import { Stack } from "./stackflow"; +import { useStore } from '@tanstack/react-store'; +import { LayoutGroup } from 'motion/react'; +import { Stack } from './stackflow'; +import { MiniappWindow, MiniappStackView } from './components/ecosystem'; +import { miniappRuntimeStore, miniappRuntimeSelectors, closeStackView } from './services/miniapp-runtime'; +import { MiniappVisualProvider } from './services/miniapp-runtime/MiniappVisualProvider'; export function StackflowApp() { - return ; + const isStackViewOpen = useStore(miniappRuntimeStore, miniappRuntimeSelectors.isStackViewOpen); + + return ( + + + <> + + {/* 小程序窗口 - 全局 Popover 层 */} + + {/* 层叠视图 - 多应用管理 */} + + + + + ); } diff --git a/src/components/common/swiper-sync-context.tsx b/src/components/common/swiper-sync-context.tsx new file mode 100644 index 00000000..5bdccb19 --- /dev/null +++ b/src/components/common/swiper-sync-context.tsx @@ -0,0 +1,122 @@ +/** + * Swiper 同步 Context + * + * 简单存储 Swiper 实例,让组件使用 Controller 模块自动同步 + */ + +import { + createContext, + useContext, + useRef, + useState, + useCallback, + useEffect, + type ReactNode, + type MutableRefObject, +} from 'react'; +import type { Swiper as SwiperType } from 'swiper'; + +/** Swiper 同步 Context 值 */ +interface SwiperSyncContextValue { + /** Swiper 实例注册表 */ + swipersRef: MutableRefObject>; + /** 监听器注册表 */ + listenersRef: MutableRefObject void>>; + /** 注册 Swiper */ + register: (id: string, swiper: SwiperType) => void; + /** 注销 Swiper */ + unregister: (id: string) => void; + /** 订阅变更 */ + subscribe: (listener: () => void) => () => void; +} + +const SwiperSyncContext = createContext(null); + +interface SwiperSyncProviderProps { + children: ReactNode; +} + +/** + * Swiper 同步 Provider + */ +export function SwiperSyncProvider({ children }: SwiperSyncProviderProps) { + const swipersRef = useRef>(new Map()); + const listenersRef = useRef void>>(new Set()); + + const notify = useCallback(() => { + listenersRef.current.forEach(listener => listener()); + }, []); + + const register = useCallback((id: string, swiper: SwiperType) => { + swipersRef.current.set(id, swiper); + notify(); + }, [notify]); + + const unregister = useCallback((id: string) => { + swipersRef.current.delete(id); + notify(); + }, [notify]); + + const subscribe = useCallback((listener: () => void) => { + listenersRef.current.add(listener); + return () => listenersRef.current.delete(listener); + }, []); + + return ( + + {children} + + ); +} + +/** + * 注册 Swiper 并获取要控制的其他 Swiper + * + * 用法: + * ```tsx + * const { onSwiper, controlledSwiper } = useSwiperMember('main', 'indicator'); + * + * + * ``` + */ +export function useSwiperMember(selfId: string, targetId: string) { + const ctx = useContext(SwiperSyncContext); + if (!ctx) { + throw new Error('useSwiperMember must be used within SwiperSyncProvider'); + } + + const [controlledSwiper, setControlledSwiper] = useState(() => { + const target = ctx.swipersRef.current.get(targetId); + return target && !target.destroyed ? target : undefined; + }); + + const onSwiper = useCallback((swiper: SwiperType) => { + ctx.register(selfId, swiper); + }, [ctx, selfId]); + + // 订阅变更,当目标 Swiper 注册时更新 + useEffect(() => { + const updateTarget = () => { + const target = ctx.swipersRef.current.get(targetId); + const validTarget = target && !target.destroyed ? target : undefined; + setControlledSwiper(prev => prev !== validTarget ? validTarget : prev); + }; + + // 初始检查 + updateTarget(); + + // 订阅变更 + return ctx.subscribe(updateTarget); + }, [ctx, targetId]); + + return { + onSwiper, + controlledSwiper, + }; +} + +export type { SwiperSyncContextValue }; diff --git a/src/components/ecosystem/app-stack-page.tsx b/src/components/ecosystem/app-stack-page.tsx new file mode 100644 index 00000000..4d0f01c7 --- /dev/null +++ b/src/components/ecosystem/app-stack-page.tsx @@ -0,0 +1,58 @@ +/** + * AppStackPage - 应用堆栈页面 + * + * Swiper 的第三页,作为小程序窗口的背景板 + * 当没有激活应用时,此页禁用滑动 + */ + +import { useCallback } from 'react' +import { useStore } from '@tanstack/react-store' +import { cn } from '@/lib/utils' +import { + miniappRuntimeStore, + miniappRuntimeSelectors, + registerStackContainerRef, + unregisterStackContainerRef, +} from '@/services/miniapp-runtime' + +export interface AppStackPageProps { + className?: string +} + +export function AppStackPage({ className }: AppStackPageProps) { + const hasRunningApps = useStore( + miniappRuntimeStore, + miniappRuntimeSelectors.hasRunningApps + ) + + const setStackContainerRef = useCallback((element: HTMLDivElement | null) => { + if (element) { + registerStackContainerRef(element) + return + } + unregisterStackContainerRef() + }, []) + + return ( +
+ {/* 空状态提示(调试用,生产环境可移除) */} + {!hasRunningApps && ( +
+

应用堆栈

+
+ )} +
+ ) +} + +export default AppStackPage diff --git a/src/components/ecosystem/discover-page.module.css b/src/components/ecosystem/discover-page.module.css new file mode 100644 index 00000000..3f60e004 --- /dev/null +++ b/src/components/ecosystem/discover-page.module.css @@ -0,0 +1,68 @@ +/* 发现页滚动驱动动画 */ +.discoverPage { + --header-height: 120px; +} + +/* 默认样式:不支持 animation-timeline 时使用 100% 滚动状态 */ +.discoverHeader { + --header-scroll-percent: 60%; + --header-focus-percent: 0%; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + backdrop-filter: blur(24px); + background: color-mix( + in srgb, + var(--background) calc(var(--header-scroll-percent) + var(--header-focus-percent)), + transparent + ); + transition: --header-focus-percent 0.2s ease; +} + +/* 渐进增强:支持 animation-timeline 时启用滚动驱动动画 */ +@supports (animation-timeline: scroll()) { + /* 注册自定义属性以支持动画插值 */ + @property --header-scroll-percent { + syntax: ''; + inherits: false; + initial-value: 0%; + } + + .discoverPage { + scroll-timeline-name: --discover-scroll; + scroll-timeline-axis: y; + } + + @keyframes header-scroll { + 0% { + padding-top: 3rem; + padding-bottom: 1rem; + --header-scroll-percent: 0%; + backdrop-filter: blur(0); + } + 100% { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + --header-scroll-percent: 60%; + backdrop-filter: blur(24px); + } + } + + .discoverHeader { + --header-scroll-percent: 0%; + padding-top: 3rem; + padding-bottom: 1rem; + backdrop-filter: blur(0); + + animation: header-scroll ease-out forwards; + animation-timeline: --discover-scroll; + animation-duration: 1ms; + animation-range: 0px calc(2 * var(--header-height)); + } +} + +/* 渐进增强:支持 color-mix 时启用 focus-within 叠加效果 */ +@supports (background: color-mix(in srgb, red 50%, blue)) { + .discoverHeader:focus-within { + --header-focus-percent: 20%; + } +} diff --git a/src/components/ecosystem/discover-page.tsx b/src/components/ecosystem/discover-page.tsx index 4b01b572..e6eeb80a 100644 --- a/src/components/ecosystem/discover-page.tsx +++ b/src/components/ecosystem/discover-page.tsx @@ -1,6 +1,7 @@ import { useRef, forwardRef, useImperativeHandle } from 'react'; import { IconSearch, IconSparkles, IconChevronRight, IconApps } from '@tabler/icons-react'; import { cn } from '@/lib/utils'; +import styles from './discover-page.module.css'; import { Button } from '@/components/ui/button'; import { MiniappIcon } from './miniapp-icon'; import type { MiniappManifest } from '@/services/ecosystem'; @@ -203,11 +204,11 @@ export const DiscoverPage = forwardRef(funct })); return ( -
- {/* BigHeader - sticky,scroll-driven animation */} -
+
+ {/* BigHeader - sticky,scroll-driven background */} +
-

{getTodayDate()}

+

{getTodayDate()}

{/* 搜索框 */} diff --git a/src/components/ecosystem/ecosystem-desktop.stories.tsx b/src/components/ecosystem/ecosystem-desktop.stories.tsx new file mode 100644 index 00000000..d3104559 --- /dev/null +++ b/src/components/ecosystem/ecosystem-desktop.stories.tsx @@ -0,0 +1,242 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState, useCallback, useRef } from 'react'; +import { EcosystemDesktop, type EcosystemDesktopHandle } from './ecosystem-desktop'; +import { EcosystemTabIndicator } from './ecosystem-tab-indicator'; +import { SwiperSyncProvider } from '@/components/common/swiper-sync-context'; +import { launchApp } from '@/services/miniapp-runtime'; +import type { MiniappManifest } from '@/services/ecosystem'; + +// Mock 数据 +const mockApps: MiniappManifest[] = [ + { + id: 'forge', + name: '锻造', + description: '铸造和管理你的 NFT 资产', + icon: '/miniapps/forge/logo.webp', + url: 'https://localhost:5182/', + version: '1.0.0', + themeColor: '#FF6B35', + splashScreen: true, + sourceName: 'BioChain', + sourceIcon: '/logo.webp', + }, + { + id: 'teleport', + name: '传送', + description: '跨链资产转移', + icon: '/miniapps/teleport/logo.webp', + url: 'https://localhost:5183/', + version: '1.0.0', + themeColor: '#6366F1', + splashScreen: true, + sourceName: 'BioChain', + sourceIcon: '/logo.webp', + }, + { + id: 'market', + name: '市场', + description: 'NFT 交易市场', + icon: '/miniapps/forge/logo.webp', + url: 'https://example.com/market', + version: '1.0.0', + themeColor: '#10B981', + sourceName: 'Community', + sourceIcon: '/logo.webp', + }, + { + id: 'wallet', + name: '钱包', + description: '数字资产管理', + icon: '/miniapps/teleport/logo.webp', + url: 'https://example.com/wallet', + version: '1.0.0', + themeColor: '#F59E0B', + sourceName: 'BioChain', + sourceIcon: '/logo.webp', + }, +]; + +const meta: Meta = { + title: 'Ecosystem/EcosystemDesktop', + component: EcosystemDesktop, + parameters: { + layout: 'fullscreen', + viewport: { + defaultViewport: 'mobile1', + }, + }, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** 完整桌面(发现页 + 我的页 + 应用堆栈页) */ +export const FullDesktop: Story = { + render: function FullDesktopStory() { + const desktopRef = useRef(null); + + const myApps = mockApps.slice(0, 3).map((app, i) => ({ + app, + lastUsed: Date.now() - i * 1000 * 60 * 60, + })); + + const handleAppOpen = useCallback((app: MiniappManifest) => { + console.log('Open:', app.name); + const manifest: MiniappManifest = { ...app, targetDesktop: 'mine' }; + desktopRef.current?.slideTo('mine'); + requestAnimationFrame(() => launchApp(app.id, manifest)); + }, []); + + return ( + <> + console.log('Detail:', app.name)} + onAppRemove={(id) => console.log('Remove:', id)} + /> + {/* 底部指示器(松耦合,自动从 store 读取状态) */} +
+ desktopRef.current?.slideTo(page)} /> +
+ + ); + }, +}; + +/** 仅我的页(无发现页,无搜索框) */ +export const MyAppsOnly: Story = { + render: function MyAppsOnlyStory() { + const myApps = mockApps.map((app, i) => ({ + app, + lastUsed: Date.now() - i * 1000 * 60 * 60, + })); + + return ( + <> + console.log('Open:', app.name)} + onAppDetail={(app) => console.log('Detail:', app.name)} + onAppRemove={(id) => console.log('Remove:', id)} + /> +
+ +
+ + ); + }, +}; + +/** 可配置桌面 */ +export const Configurable: Story = { + render: function ConfigurableStory() { + const desktopRef = useRef(null); + const [showDiscoverPage, setShowDiscoverPage] = useState(true); + const [showStackPage, setShowStackPage] = useState('auto'); + + const myApps = mockApps.map((app, i) => ({ + app, + lastUsed: Date.now() - i * 1000 * 60 * 60, + })); + + return ( +
+ {/* 控制面板 */} +
+ + +
+ + {/* 桌面 */} +
+ { + console.log('Open:', app.name); + const manifest: MiniappManifest = { ...app, targetDesktop: 'mine' }; + desktopRef.current?.slideTo('mine'); + requestAnimationFrame(() => launchApp(app.id, manifest)); + }} + onAppDetail={(app) => console.log('Detail:', app.name)} + onAppRemove={(id) => console.log('Remove:', id)} + /> +
+ +
+
+
+ ); + }, +}; + +/** 空状态 */ +export const EmptyState: Story = { + render: function EmptyStateStory() { + return ( + <> + console.log('Open:', app.name)} + onAppDetail={(app) => console.log('Detail:', app.name)} + onAppRemove={(id) => console.log('Remove:', id)} + /> +
+ +
+ + ); + }, +}; diff --git a/src/components/ecosystem/ecosystem-desktop.tsx b/src/components/ecosystem/ecosystem-desktop.tsx new file mode 100644 index 00000000..5a16f8d1 --- /dev/null +++ b/src/components/ecosystem/ecosystem-desktop.tsx @@ -0,0 +1,283 @@ +/** + * EcosystemDesktop - 生态系统桌面组件 + * + * 灵活配置的三页式桌面:发现页 | 我的页 | 应用堆栈页 + * 支持动态开关页面,壁纸宽度自适应 + */ +import { useCallback, useRef, useMemo, forwardRef, useImperativeHandle, useEffect } from 'react'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import { Parallax, Controller } from 'swiper/modules'; +import type { Swiper as SwiperType } from 'swiper'; +import 'swiper/css'; +import { useStore } from '@tanstack/react-store'; +import { useSwiperMember } from '@/components/common/swiper-sync-context'; +import { DiscoverPage, MyAppsPage, IOSWallpaper, type DiscoverPageRef } from '@/components/ecosystem'; +import { AppStackPage } from '@/components/ecosystem/app-stack-page'; +import { MiniappWindowStack } from '@/components/ecosystem/miniapp-window-stack'; +import { ecosystemActions, type EcosystemSubPage } from '@/stores/ecosystem'; +import { miniappRuntimeStore, miniappRuntimeSelectors } from '@/services/miniapp-runtime'; +import type { MiniappManifest } from '@/services/ecosystem'; + +/** Parallax 视差系数 */ +const PARALLAX_OFFSET = '-38.2%'; +/** 最大页面数量(用于计算壁纸宽度) */ +const MAX_PAGE_COUNT = 3; +/** Parallax 壁纸宽度:始终按最大页数计算,避免动态变化导致的问题 */ +const PARALLAX_WIDTH = `${100 + (MAX_PAGE_COUNT - 1) * Math.abs(parseFloat(PARALLAX_OFFSET))}%`; + +/** 桌面配置 */ +export interface EcosystemDesktopConfig { + /** 是否显示发现页(默认 true) */ + showDiscoverPage?: boolean; + /** 是否显示应用堆栈页(默认 'auto',根据是否有运行中的应用自动判断) */ + showStackPage?: boolean | 'auto'; + /** 初始页面(默认根据配置自动选择) */ + initialPage?: EcosystemSubPage; +} + +/** 桌面数据 */ +export interface EcosystemDesktopData { + /** 所有应用列表 */ + apps: MiniappManifest[]; + /** 我的应用(带最后使用时间) */ + myApps: Array<{ app: MiniappManifest; lastUsed: number }>; + /** 精选应用 */ + featuredApps?: MiniappManifest[]; + /** 推荐应用 */ + recommendedApps?: MiniappManifest[]; + /** 热门应用 */ + hotApps?: MiniappManifest[]; +} + +/** 桌面回调 */ +export interface EcosystemDesktopCallbacks { + /** 打开应用 */ + onAppOpen: (app: MiniappManifest) => void; + /** 查看应用详情 */ + onAppDetail: (app: MiniappManifest) => void; + /** 移除应用 */ + onAppRemove: (appId: string) => void; +} + +export interface EcosystemDesktopProps extends EcosystemDesktopConfig, EcosystemDesktopData, EcosystemDesktopCallbacks {} + +/** 桌面控制句柄 */ +export interface EcosystemDesktopHandle { + /** 滑动到指定页面 */ + slideTo: (page: EcosystemSubPage) => void; + /** 聚焦搜索框(需要发现页可见) */ + focusSearch: () => void; + /** 获取当前页面 */ + getCurrentPage: () => EcosystemSubPage; + /** 获取 Swiper 实例 */ + getSwiper: () => SwiperType | null; +} + +export const EcosystemDesktop = forwardRef( + function EcosystemDesktop(props, ref) { + const { + // 配置 + showDiscoverPage = true, + showStackPage = 'auto', + initialPage, + // 数据 + apps, + myApps, + featuredApps = [], + recommendedApps = [], + hotApps = [], + // 回调 + onAppOpen, + onAppDetail, + onAppRemove, + } = props; + + const swiperRef = useRef(null); + const discoverPageRef = useRef(null); + const currentPageRef = useRef('mine'); + + // 如果启动了小程序,则强制滑到对应的 targetDesktop(mine/stack) + const forcedSubPage = useStore(miniappRuntimeStore, (state) => { + if (state.presentations.size === 0) return null; + + const visiblePresentations = Array.from(state.presentations.values()).filter((p) => p.state !== 'hidden'); + if (visiblePresentations.length === 0) return null; + + const focusedId = state.focusedAppId ?? state.activeAppId; + const focusedPresentation = focusedId ? state.presentations.get(focusedId) : null; + const targetDesktop = + focusedPresentation?.desktop ?? + (focusedId ? (state.apps.get(focusedId)?.manifest.targetDesktop ?? null) : null); + + const desktopToSubPage = (desktop: string | null | undefined): EcosystemSubPage => { + return desktop === 'stack' ? 'stack' : 'mine'; + }; + + if (targetDesktop) return desktopToSubPage(targetDesktop); + + const topmost = visiblePresentations.reduce((acc, cur) => (cur.zOrder > acc.zOrder ? cur : acc)); + return desktopToSubPage(topmost.desktop); + }); + + // 监听是否有 stack-target 应用(用于决定是否展示 stack 页) + const hasRunningStackApps = useStore(miniappRuntimeStore, miniappRuntimeSelectors.hasRunningStackApps); + + // 计算实际显示的页面 + const actualShowStackPage = showStackPage === 'auto' ? hasRunningStackApps : showStackPage; + + // 可用页面列表 + const availablePages = useMemo(() => { + const pages: EcosystemSubPage[] = []; + if (showDiscoverPage) pages.push('discover'); + pages.push('mine'); + if (actualShowStackPage) pages.push('stack'); + return pages; + }, [showDiscoverPage, actualShowStackPage]); + + const pageCount = availablePages.length; + + // 计算初始滑动索引 + const initialSlideIndex = useMemo(() => { + const page = initialPage ?? (showDiscoverPage ? 'discover' : 'mine'); + const idx = availablePages.indexOf(page); + return idx >= 0 ? idx : 0; + }, [initialPage, showDiscoverPage, availablePages]); + + // 使用 Swiper 同步 hook + const { onSwiper: syncOnSwiper, controlledSwiper } = useSwiperMember('ecosystem-main', 'ecosystem-indicator'); + + // 注册主 Swiper + const handleMainSwiper = useCallback((swiper: SwiperType) => { + swiperRef.current = swiper; + syncOnSwiper(swiper); + + const page = availablePages[swiper.activeIndex] ?? 'mine'; + currentPageRef.current = page; + ecosystemActions.setActiveSubPage(page); + ecosystemActions.setAvailableSubPages(availablePages); + }, [syncOnSwiper, availablePages]); + + // 当可用页面列表变化时,同步到 store,避免指示器与页面不一致 + useEffect(() => { + ecosystemActions.setAvailableSubPages(availablePages); + }, [availablePages]); + + // 当有小程序启动时,强制切到 targetDesktop 所在页 + useEffect(() => { + if (!forcedSubPage) return; + if (currentPageRef.current === forcedSubPage) return; + + const idx = availablePages.indexOf(forcedSubPage); + if (idx < 0) return; + swiperRef.current?.slideTo(idx); + }, [forcedSubPage, availablePages]); + + // 更新进度到 Store + const handleProgress = useCallback((_: SwiperType, progress: number) => { + ecosystemActions.setSwiperProgress(progress * (pageCount - 1)); + }, [pageCount]); + + // Swiper 滑动事件 + const handleSlideChange = useCallback((swiper: SwiperType) => { + const newPage = availablePages[swiper.activeIndex] ?? 'mine'; + currentPageRef.current = newPage; + ecosystemActions.setActiveSubPage(newPage); + }, [availablePages]); + + // 搜索胶囊点击:滑到发现页 + 聚焦搜索框 + const handleSearchClick = useCallback(() => { + if (!showDiscoverPage) return; + const discoverIndex = availablePages.indexOf('discover'); + if (discoverIndex >= 0) { + swiperRef.current?.slideTo(discoverIndex); + setTimeout(() => discoverPageRef.current?.focusSearch(), 300); + } + }, [showDiscoverPage, availablePages]); + + // 暴露控制句柄 + useImperativeHandle(ref, () => ({ + slideTo: (page: EcosystemSubPage) => { + const idx = availablePages.indexOf(page); + if (idx >= 0) swiperRef.current?.slideTo(idx); + }, + focusSearch: () => { + if (showDiscoverPage) { + discoverPageRef.current?.focusSearch(); + } + }, + getCurrentPage: () => currentPageRef.current, + getSwiper: () => swiperRef.current, + }), [availablePages, showDiscoverPage]); + + // 精选应用第一个 + const featuredApp = featuredApps[0]; + + return ( +
+ + {/* Parallax 共享壁纸 */} +
+ +
+ + {/* 发现页 */} + {showDiscoverPage && ( + +
+ +
+
+ )} + + {/* 我的页 */} + +
+ + +
+
+ + {/* 应用堆栈页 */} + {actualShowStackPage && ( + +
+ + +
+
+ )} +
+
+ ); + } +); diff --git a/src/components/ecosystem/ecosystem-tab-indicator.module.css b/src/components/ecosystem/ecosystem-tab-indicator.module.css new file mode 100644 index 00000000..626436ec --- /dev/null +++ b/src/components/ecosystem/ecosystem-tab-indicator.module.css @@ -0,0 +1,77 @@ +/** + * EcosystemTabIndicator 指示器样式 + */ + +.indicator { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 8px 12px; + + background: transparent; + border: none; + cursor: pointer; + + transition: opacity 0.15s ease; +} + +.indicator:active { + opacity: 0.7; +} + +/* 图标容器 */ +.iconWrapper { + position: relative; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.icon { + width: 20px; + height: 20px; + color: var(--foreground); + + /* Crossfade 动画 */ + animation: iconFadeIn 0.2s ease-out; +} + +@keyframes iconFadeIn { + from { + opacity: 0; + transform: scale(0.8); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* 页面点指示器 */ +.dots { + display: flex; + align-items: center; + gap: 4px; +} + +.dot { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--muted-foreground); + opacity: 0.4; + + transition: + opacity 0.2s ease, + transform 0.2s ease, + background 0.2s ease; +} + +.dotActive { + opacity: 1; + transform: scale(1.25); + background: var(--primary); +} diff --git a/src/components/ecosystem/ecosystem-tab-indicator.tsx b/src/components/ecosystem/ecosystem-tab-indicator.tsx new file mode 100644 index 00000000..6035fc4a --- /dev/null +++ b/src/components/ecosystem/ecosystem-tab-indicator.tsx @@ -0,0 +1,117 @@ +/** + * EcosystemTabIndicator - 生态 Tab 页面指示器 + * + * 松耦合设计: + * - 默认从 store 读取状态(无需 props) + * - 支持外部控制(传入 props 覆盖) + */ + +import { useCallback, useMemo } from 'react' +import { useStore } from '@tanstack/react-store' +import { IconApps, IconBrandMiniprogram, IconStack2 } from '@tabler/icons-react' +import { cn } from '@/lib/utils' +import { ecosystemStore, type EcosystemSubPage } from '@/stores/ecosystem' +import { miniappRuntimeStore, miniappRuntimeSelectors } from '@/services/miniapp-runtime' +import styles from './ecosystem-tab-indicator.module.css' + +export interface EcosystemTabIndicatorProps { + /** 当前页面(可选,默认从 store 读取) */ + activePage?: EcosystemSubPage + /** 切换页面回调(可选,用于外部控制) */ + onPageChange?: (page: EcosystemSubPage) => void + /** 是否有运行中的应用(可选,默认从 store 读取) */ + hasRunningApps?: boolean + /** 自定义类名 */ + className?: string +} + +/** 页面顺序 */ +const PAGE_ORDER: EcosystemSubPage[] = ['discover', 'mine', 'stack'] + +/** 页面图标配置 */ +const PAGE_ICONS = { + discover: IconApps, + mine: IconBrandMiniprogram, + stack: IconStack2, +} as const + +/** 页面标签 */ +const PAGE_LABELS = { + discover: '发现', + mine: '我的', + stack: '堆栈', +} as const + +export function EcosystemTabIndicator({ + activePage: activePageProp, + onPageChange, + hasRunningApps: hasRunningAppsProp, + className, +}: EcosystemTabIndicatorProps) { + // 从 store 读取状态(松耦合) + const storeActivePage = useStore(ecosystemStore, (s) => s.activeSubPage) + const storeAvailablePages = useStore(ecosystemStore, (s) => s.availableSubPages) + const storeHasRunningApps = useStore(miniappRuntimeStore, miniappRuntimeSelectors.hasRunningApps) + + // 使用 props 覆盖 store 值(支持受控模式) + const activePage = activePageProp ?? storeActivePage + const hasRunningApps = hasRunningAppsProp ?? storeHasRunningApps + + // 计算可用页面 + const availablePages = useMemo(() => { + if (storeAvailablePages?.length) return storeAvailablePages + if (hasRunningApps) return PAGE_ORDER + return PAGE_ORDER.filter((p) => p !== 'stack') + }, [storeAvailablePages, hasRunningApps]) + + // 当前页面索引 + const activeIndex = availablePages.indexOf(activePage) + + // 获取下一页 + const getNextPage = useCallback(() => { + const nextIndex = (activeIndex + 1) % availablePages.length + return availablePages[nextIndex] + }, [activeIndex, availablePages]) + + // 处理点击 + const handleClick = useCallback(() => { + const nextPage = getNextPage() + if (nextPage) { + onPageChange?.(nextPage) + } + }, [getNextPage, onPageChange]) + + // 当前图标 + const Icon = PAGE_ICONS[activePage] + const label = PAGE_LABELS[activePage] + + return ( + + ) +} + +export default EcosystemTabIndicator diff --git a/src/components/ecosystem/index.ts b/src/components/ecosystem/index.ts index 5d396eb1..5454f607 100644 --- a/src/components/ecosystem/index.ts +++ b/src/components/ecosystem/index.ts @@ -35,3 +35,49 @@ export { IOSWallpaper, type IOSWallpaperProps, } from './ios-wallpaper' + +export { + MiniappSplashScreen, + extractHue, + generateGlowHues, + type MiniappSplashScreenProps, +} from './miniapp-splash-screen' + +export { + AppStackPage, + type AppStackPageProps, +} from './app-stack-page' + +export { + MiniappWindow, + type MiniappWindowProps, +} from './miniapp-window' + +export { + MiniappCapsule, + type MiniappCapsuleProps, +} from './miniapp-capsule' + +export { + MiniappStackCard, + type MiniappStackCardProps, +} from './miniapp-stack-card' + +export { + MiniappStackView, + type MiniappStackViewProps, +} from './miniapp-stack-view' + +export { + EcosystemTabIndicator, + type EcosystemTabIndicatorProps, +} from './ecosystem-tab-indicator' + +export { + EcosystemDesktop, + type EcosystemDesktopProps, + type EcosystemDesktopConfig, + type EcosystemDesktopData, + type EcosystemDesktopCallbacks, + type EcosystemDesktopHandle, +} from './ecosystem-desktop' diff --git a/src/components/ecosystem/ios-wallpaper.tsx b/src/components/ecosystem/ios-wallpaper.tsx index 7a107e51..e86b2b8c 100644 --- a/src/components/ecosystem/ios-wallpaper.tsx +++ b/src/components/ecosystem/ios-wallpaper.tsx @@ -18,7 +18,7 @@ function resolveIsDark(theme: 'light' | 'dark' | 'system'): boolean { } function pickRandom(arr: T[]): T { - return arr[Math.floor(Math.random() * arr.length)]; + return arr[Math.floor(Math.random() * arr.length)]!; } function getMsUntilNextHour(): number { @@ -31,9 +31,10 @@ function getMsUntilNextHour(): number { export interface IOSWallpaperProps { className?: string; children?: React.ReactNode; + variant?: WallpaperVariant; } -export function IOSWallpaper({ className, children }: IOSWallpaperProps) { +export function IOSWallpaper({ className, children, variant: forcedVariant }: IOSWallpaperProps) { const theme = useStore(preferencesStore, (s) => s.theme); const isDark = resolveIsDark(theme); const [isAnimating, setIsAnimating] = useState(false); @@ -44,7 +45,7 @@ export function IOSWallpaper({ className, children }: IOSWallpaperProps) { light: pickRandom(lightWallpapers), }), []); - const variant = isDark ? selectedWallpapers.dark : selectedWallpapers.light; + const variant = forcedVariant ?? (isDark ? selectedWallpapers.dark : selectedWallpapers.light); // 整点报时动画 useEffect(() => { diff --git a/src/components/ecosystem/miniapp-capsule.module.css b/src/components/ecosystem/miniapp-capsule.module.css new file mode 100644 index 00000000..8fd5e463 --- /dev/null +++ b/src/components/ecosystem/miniapp-capsule.module.css @@ -0,0 +1,132 @@ +/** + * MiniappCapsule 胶囊按钮样式 + * + * 模拟微信小程序的胶囊按钮设计 + */ + +.widgetContainer { + position: fixed; + top: max(env(safe-area-inset-top, 0px), 8px); + right: 8px; + z-index: 20; + + /* 确保子元素重叠 */ + display: grid; + place-items: center; +} +.glassBg { + /* 绝对定位铺满 */ + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 8px; + + /* 这里的反转是为了让底板跟背景拉开反差 */ + backdrop-filter: invert(1) grayscale(1); + background: rgba(255, 255, 255, 0.1); /* 极淡的背景 */ + z-index: 0; +} + +.capsule { + display: flex; + align-items: center; + height: 32px; + padding: 0 4px; + + /* 胶囊外观 */ + border: 0.5px solid transparent; + border-radius: 16px; + + /* 阴影 */ + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.themeDark { + color: rgba(255, 255, 255, 0.92); + background: rgba(0, 0, 0, 0.22); + border-color: rgba(0, 0, 0, 0.16); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); +} + +.themeLight { + color: rgba(0, 0, 0, 0.82); + background: rgba(255, 255, 255, 0.36); + border-color: rgba(255, 255, 255, 0.22); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); +} + +.themeAuto { + /* 1. 容器样式:制造一个反色的“玻璃板” */ + backdrop-filter: invert(1) grayscale(1.1) contrast(20); + background: rgba(255, 255, 255, 0.3); /* 保持轻微透明度 */ + border: 1px solid rgba(255, 255, 255, 0.2); /* mix-blend-mode边框建议用中性灰,黑白背景都能看见 */ + + /* 2. 关键点:让文字颜色永远是背景的“反色” */ + /* 这里必须设为纯白,配合 difference 模式 */ + color: #fff; + mix-blend-mode: difference; +} + +.themeAuto > * { + mix-blend-mode: difference; +} + +.button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 24px; + padding: 0; + margin: 0; + + background: transparent; + border: none; + cursor: pointer; + + color: inherit; + transition: opacity 0.15s ease; +} + +.button:active { + opacity: 0.6; +} + +.icon { + width: 18px; + height: 18px; +} + +.divider { + width: 0.5px; + height: 16px; + background: currentColor; + opacity: 0.22; +} + +.themeAuto .divider { + opacity: 0.3; +} + +/* 关闭图标 - 圆圈包围的点 */ +.closeIcon { + position: relative; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + + /* 外圈 */ + border: 1.5px solid currentColor; + border-radius: 50%; +} + +.closeIconInner { + width: 8px; + height: 8px; +} diff --git a/src/components/ecosystem/miniapp-capsule.tsx b/src/components/ecosystem/miniapp-capsule.tsx new file mode 100644 index 00000000..355e0fe1 --- /dev/null +++ b/src/components/ecosystem/miniapp-capsule.tsx @@ -0,0 +1,60 @@ +/** + * MiniappCapsule - 小程序胶囊按钮 + * + * 悬浮在小程序窗口右上角的胶囊形按钮组 + * 包含:多功能按钮(动态图标)+ 关闭按钮 + */ + +import { forwardRef } from 'react'; +import { IconDots, IconPointFilled } from '@tabler/icons-react'; +import { cn } from '@/lib/utils'; +import type { CapsuleTheme } from '@/services/miniapp-runtime'; +import styles from './miniapp-capsule.module.css'; + +export interface MiniappCapsuleProps { + /** 胶囊主题 */ + theme?: CapsuleTheme; + /** 多功能按钮的自定义图标 */ + actionIcon?: React.ReactNode; + /** 多功能按钮点击回调 */ + onAction?: () => void; + /** 关闭按钮点击回调 */ + onClose?: () => void; + /** 是否显示 */ + visible?: boolean; + /** 自定义类名 */ + className?: string; +} + +export const MiniappCapsule = forwardRef(function MiniappCapsule( + { theme = 'auto', actionIcon, onAction, onClose, visible = true, className }, + ref, +) { + if (!visible) return null; + + const themeClassName = theme === 'dark' ? styles.themeDark : theme === 'light' ? styles.themeLight : styles.themeAuto; + + return ( +
+ {/*
*/} +
+ {/* 多功能按钮 */} + + + {/* 分隔线 */} +
+ + {/* 关闭按钮 - 使用 IconPointFilled 模拟国内小程序的关闭图标 */} + +
+
+ ); +}); + +export default MiniappCapsule; diff --git a/src/components/ecosystem/miniapp-icon.tsx b/src/components/ecosystem/miniapp-icon.tsx index bf892182..c7ac06f8 100644 --- a/src/components/ecosystem/miniapp-icon.tsx +++ b/src/components/ecosystem/miniapp-icon.tsx @@ -1,10 +1,10 @@ /** * Miniapp Icon Component - * + * * 统一的小程序图标渲染组件,遵循 iOS App Icon 设计规范 - * + * * ## 图标规格标准 - * + * * | Size | Dimensions | Border Radius | Use Case | * |--------|------------|---------------|-----------------------------| * | xs | 32x32 | 7px | 列表紧凑模式、通知 | @@ -13,10 +13,10 @@ * | lg | 60x60 | 13px | iOS 桌面风格、网格 | * | xl | 80x80 | 18px | 详情页、精选卡片 | * | 2xl | 120x120 | 27px | 大型展示、启动页 | - * + * * ## 圆角计算公式 * borderRadius = size * 0.22 (iOS 标准比例) - * + * * ## 设计规范 * - 图标应为正方形 * - 支持 SVG、PNG、WebP 格式 @@ -24,9 +24,9 @@ * - 背景不透明(iOS 风格) */ -import { forwardRef, useState } from 'react' -import { IconApps, IconSparkles } from '@tabler/icons-react' -import { cn } from '@/lib/utils' +import { forwardRef, useState } from 'react'; +import { IconApps, IconSparkles } from '@tabler/icons-react'; +import { cn } from '@/lib/utils'; // ============================================ // 图标尺寸配置 @@ -38,198 +38,189 @@ export const MINIAPP_ICON_SIZES = { lg: { size: 60, radius: 13 }, xl: { size: 80, radius: 18 }, '2xl': { size: 120, radius: 27 }, -} as const +} as const; -export type MiniappIconSize = keyof typeof MINIAPP_ICON_SIZES +export type MiniappIconSize = keyof typeof MINIAPP_ICON_SIZES; // ============================================ // 徽章类型 // ============================================ -export type MiniappBadge = 'beta' | 'new' | 'update' | 'none' +export type MiniappBadge = 'beta' | 'new' | 'update' | 'none'; // ============================================ // Props // ============================================ export interface MiniappIconProps { /** 图标 URL */ - src?: string | null + src?: string | null; /** 应用名称(用于 alt 和占位符) */ - name?: string + name?: string; /** 图标尺寸 */ - size?: MiniappIconSize + size?: MiniappIconSize; /** 自定义尺寸(覆盖 size) */ - customSize?: number + customSize?: number; /** 徽章类型 */ - badge?: MiniappBadge + badge?: MiniappBadge; /** 是否显示阴影 */ - shadow?: boolean | 'sm' | 'md' | 'lg' + shadow?: boolean | 'sm' | 'md' | 'lg'; /** 是否显示边框 */ - border?: boolean + border?: boolean; /** 是否为玻璃态背景(用于深色/渐变背景上) */ - glass?: boolean + glass?: boolean; /** 加载状态 */ - loading?: boolean + loading?: boolean; /** 禁用状态 */ - disabled?: boolean + disabled?: boolean; /** 自定义类名 */ - className?: string + className?: string; /** 点击事件 */ - onClick?: () => void + onClick?: () => void; } // ============================================ // 组件 // ============================================ -export const MiniappIcon = forwardRef( - function MiniappIcon( - { - src, - name = 'App', - size = 'md', - customSize, - badge = 'none', - shadow = false, - border = true, - glass = false, - loading = false, - disabled = false, - className, - onClick, - }, - ref - ) { - const [imageError, setImageError] = useState(false) - const [imageLoaded, setImageLoaded] = useState(false) +export const MiniappIcon = forwardRef(function MiniappIcon( + { + src, + name = 'App', + size = 'md', + customSize, + badge = 'none', + shadow = false, + border = true, + glass = false, + loading = false, + disabled = false, + className, + onClick, + }, + ref, +) { + const [imageError, setImageError] = useState(false); + const [imageLoaded, setImageLoaded] = useState(false); - // 计算实际尺寸 - const sizeConfig = MINIAPP_ICON_SIZES[size] - const actualSize = customSize ?? sizeConfig.size - const actualRadius = customSize - ? Math.round(customSize * 0.22) - : sizeConfig.radius + // 计算实际尺寸 + const sizeConfig = MINIAPP_ICON_SIZES[size]; + const actualSize = customSize ?? sizeConfig.size; + const actualRadius = customSize ? Math.round(customSize * 0.22) : sizeConfig.radius; - // 阴影类名 - const shadowClass = shadow === true || shadow === 'md' + // 阴影类名 + const shadowClass = + shadow === true || shadow === 'md' ? 'shadow-lg shadow-black/10 dark:shadow-black/30' : shadow === 'sm' - ? 'shadow-md shadow-black/5 dark:shadow-black/20' - : shadow === 'lg' - ? 'shadow-xl shadow-black/15 dark:shadow-black/40' - : '' + ? 'shadow-md shadow-black/5 dark:shadow-black/20' + : shadow === 'lg' + ? 'shadow-xl shadow-black/15 dark:shadow-black/40' + : ''; - // 是否显示占位符 - const showPlaceholder = !src || imageError + // 是否显示占位符 + const showPlaceholder = !src || imageError; - // 徽章位置和大小(基于图标尺寸) - const badgeSize = Math.max(16, actualSize * 0.3) - const badgeOffset = -badgeSize * 0.2 + // 徽章位置和大小(基于图标尺寸) + const badgeSize = Math.max(16, actualSize * 0.3); + const badgeOffset = -badgeSize * 0.2; - return ( + return ( +
+ {/* 主图标容器 */}
- {/* 主图标容器 */} -
- {/* 加载状态 */} - {loading && ( -
-
-
- )} - - {/* 图片 */} - {src && !imageError && ( - {name} setImageLoaded(true)} - onError={() => setImageError(true)} - draggable={false} - /> - )} - - {/* 玻璃态高光覆盖层 */} - {glass && ( -
+ {/* 加载状态 */} + {loading && ( +
+
- )} +
+ )} - {/* 占位符 */} - {showPlaceholder && !loading && ( -
- -
- )} -
+ {/* 图片 */} + {src && !imageError && ( + {name} setImageLoaded(true)} + onError={() => setImageError(true)} + draggable={false} + /> + )} - {/* 徽章 */} - {badge !== 'none' && ( - )} + + {/* 占位符 */} + {showPlaceholder && !loading && ( +
+ +
+ )}
- ) - } -) + + {/* 徽章 */} + {badge !== 'none' && } +
+ ); +}); // ============================================ // 徽章组件 // ============================================ interface BadgeProps { - type: Exclude - size: number - offset: number + type: Exclude; + size: number; + offset: number; } function Badge({ type, size, offset }: BadgeProps) { @@ -249,17 +240,17 @@ function Badge({ type, size, offset }: BadgeProps) { bg: 'bg-red-500', color: 'text-white', }, - } + }; - const config = configs[type] + const config = configs[type]; return (
- {'icon' in config ? ( - - ) : ( - config.label - )} + {'icon' in config ? : config.label}
- ) + ); } // ============================================ @@ -284,13 +271,13 @@ function Badge({ type, size, offset }: BadgeProps) { // ============================================ export interface MiniappIconWithLabelProps extends Omit { /** 图标尺寸,默认 lg */ - size?: MiniappIconSize + size?: MiniappIconSize; /** 应用名称 */ - name: string + name: string; /** 最大行数 */ - maxLines?: 1 | 2 + maxLines?: 1 | 2; /** 是否显示名称 */ - showLabel?: boolean + showLabel?: boolean; } export function MiniappIconWithLabel({ @@ -301,30 +288,17 @@ export function MiniappIconWithLabel({ className, ...iconProps }: MiniappIconWithLabelProps) { - const sizeConfig = MINIAPP_ICON_SIZES[size] - + const sizeConfig = MINIAPP_ICON_SIZES[size]; + // 根据图标尺寸计算字体大小 - const fontSize = Math.max(10, Math.min(13, sizeConfig.size * 0.18)) - + const fontSize = Math.max(10, Math.min(13, sizeConfig.size * 0.18)); + return ( -
- +
+ {showLabel && ( )}
- ) + ); } // ============================================ @@ -342,15 +316,15 @@ export function MiniappIconWithLabel({ // ============================================ export interface MiniappIconGridProps { /** 列数 */ - columns?: 3 | 4 | 5 | 6 + columns?: 3 | 4 | 5 | 6; /** 图标尺寸 */ - iconSize?: MiniappIconSize + iconSize?: MiniappIconSize; /** 间距 */ - gap?: 'sm' | 'md' | 'lg' + gap?: 'sm' | 'md' | 'lg'; /** 子元素 */ - children: React.ReactNode + children: React.ReactNode; /** 自定义类名 */ - className?: string + className?: string; } export function MiniappIconGrid({ @@ -364,20 +338,16 @@ export function MiniappIconGrid({ sm: 'gap-2', md: 'gap-4', lg: 'gap-6', - }[gap] + }[gap]; return (
{children}
- ) + ); } diff --git a/src/components/ecosystem/miniapp-motion-flow.ts b/src/components/ecosystem/miniapp-motion-flow.ts new file mode 100644 index 00000000..086980c0 --- /dev/null +++ b/src/components/ecosystem/miniapp-motion-flow.ts @@ -0,0 +1,130 @@ +import type { MiniappState } from '@/services/miniapp-runtime' + +export const MINIAPP_FLOW = [ + 'closed', + 'opening', + 'splash', + 'opened', + 'backgrounding', + 'backgrounded', + 'foregrounding', + 'closing', +] as const + +export type MiniappFlow = (typeof MINIAPP_FLOW)[number] + +export type WindowContainerVariant = 'open' | 'closed' +export type VisibilityVariant = 'show' | 'hide' +/** 层级变体:top=上层可见,bottom=下层隐藏,gone=display:none */ +export type LayerVariant = 'top' | 'bottom' | 'gone' + +export const flowToWindowContainer: Record = { + closed: 'closed', + opening: 'open', + splash: 'open', + opened: 'open', + backgrounding: 'open', + backgrounded: 'closed', + foregrounding: 'open', + closing: 'open', +} + +/** + * splash-bg 层级(和 iframe 互斥,作为内容层) + * - top: 在 iframe 上方,可见(启动中) + * - bottom: 在 iframe 下方,隐藏(已打开) + * - gone: display:none(后台、已关闭) + */ +export const flowToSplashBgLayer: Record = { + closed: 'gone', + opening: 'top', + splash: 'top', + opened: 'bottom', + backgrounding: 'bottom', + backgrounded: 'gone', + foregrounding: 'bottom', + closing: 'bottom', // 关闭时 splash-bg 不显示,只有 splash-icon 做动画 +} + +/** + * iframe 层级(和 splash-bg 互斥,作为内容层) + * - top: 在 splash-bg 上方,可见(已打开) + * - bottom: 在 splash-bg 下方,隐藏(启动中) + * - gone: display:none(后台、已关闭) + */ +export const flowToIframeLayer: Record = { + closed: 'gone', + opening: 'bottom', + splash: 'bottom', + opened: 'top', + backgrounding: 'top', + backgrounded: 'gone', + foregrounding: 'top', + closing: 'top', // 关闭时 iframe 保持在上层,通过 opacity 淡出 +} + +/** + * splash-icon 层级(独立于内容层,只在 opening/closing 时出现做动画) + */ +export const flowToSplashIconLayer: Record = { + closed: 'gone', + opening: 'top', + splash: 'top', + opened: 'gone', + backgrounding: 'gone', + backgrounded: 'gone', + foregrounding: 'gone', + closing: 'top', +} + +export const flowToCapsule: Record = { + closed: 'hide', + opening: 'show', + splash: 'show', + opened: 'show', + backgrounding: 'show', + backgrounded: 'hide', + foregrounding: 'show', + closing: 'show', +} + +export const flowToCornerBadge: Record = { + closed: 'show', + opening: 'hide', + splash: 'hide', + opened: 'hide', + backgrounding: 'hide', + backgrounded: 'show', + foregrounding: 'hide', + closing: 'hide', +} + +/** + * splash-icon 的 layoutId 是否启用 + * 只在 opening 和 closing 时启用,做 shared layout 动画 + */ +export const flowToSplashIconLayoutId: Record = { + closed: false, + opening: true, + splash: false, + opened: false, + backgrounding: false, + backgrounded: false, + foregrounding: false, + closing: true, +} + +/** + * 将 runtime state 映射到稳定态 flow(不包含方向性瞬时态) + * + * 方向性(opening/backgrounding/foregrounding)由上层根据前后状态推导。 + */ +export function runtimeStateToStableFlow(state: MiniappState | null): MiniappFlow { + if (!state) return 'closed' + if (state === 'preparing') return 'closed' + if (state === 'launching') return 'opening' + if (state === 'splash') return 'splash' + if (state === 'active') return 'opened' + if (state === 'background') return 'backgrounded' + return 'closing' +} diff --git a/src/components/ecosystem/miniapp-splash-screen.module.css b/src/components/ecosystem/miniapp-splash-screen.module.css new file mode 100644 index 00000000..8aab8df2 --- /dev/null +++ b/src/components/ecosystem/miniapp-splash-screen.module.css @@ -0,0 +1,176 @@ +/** + * MiniappSplashScreen 启动屏幕样式 + * + * 使用基于应用 themeColor 的光晕渲染方案 + * 参考 ios-wallpaper--rainbow 的实现 + */ + +.splashScreen { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + overflow: hidden; + z-index: 10; + pointer-events: auto; + + /* 动画状态 */ + opacity: 1; + transition: opacity 0.3s ease-out; +} + +.splashScreen[data-visible="false"] { + opacity: 0; + pointer-events: none; +} + +/* 光晕背景层 - 使用 CSS 变量动态设置颜色 */ +.glowLayer { + position: absolute; + inset: -30%; + pointer-events: none; +} + +/* 主光晕层 - 主色 */ +.glowPrimary { + background: radial-gradient( + ellipse 50% 50% at 30% 35%, + oklch(0.7 0.2 var(--splash-hue-primary) / 0.4), + transparent 70% + ); + mix-blend-mode: normal; + filter: blur(40px); +} + +/* 次光晕层 - 邻近色1 (hue + 30) */ +.glowSecondary { + background: radial-gradient( + ellipse 45% 45% at 70% 65%, + oklch(0.65 0.18 var(--splash-hue-secondary) / 0.35), + transparent 70% + ); + mix-blend-mode: normal; + filter: blur(50px); +} + +/* 第三光晕层 - 邻近色2 (hue - 30) */ +.glowTertiary { + background: radial-gradient( + ellipse 40% 40% at 50% 20%, + oklch(0.75 0.15 var(--splash-hue-tertiary) / 0.3), + transparent 70% + ); + mix-blend-mode: normal; + filter: blur(45px); +} + +/* 深色模式调整 */ +:global(.dark) .splashScreen { + background: oklch(0.15 0.02 var(--splash-hue-primary)); +} + +:global(.dark) .glowPrimary { + background: radial-gradient( + ellipse 50% 50% at 30% 35%, + oklch(0.5 0.25 var(--splash-hue-primary) / 0.5), + transparent 70% + ); + mix-blend-mode: color-dodge; +} + +:global(.dark) .glowSecondary { + background: radial-gradient( + ellipse 45% 45% at 70% 65%, + oklch(0.45 0.22 var(--splash-hue-secondary) / 0.4), + transparent 70% + ); + mix-blend-mode: screen; +} + +:global(.dark) .glowTertiary { + background: radial-gradient( + ellipse 40% 40% at 50% 20%, + oklch(0.55 0.18 var(--splash-hue-tertiary) / 0.35), + transparent 70% + ); + mix-blend-mode: screen; +} + +/* 内容区域 */ +.content { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +/* 应用图标 */ +.appIcon { + width: 5rem; + height: 5rem; + border-radius: 1.125rem; /* 22% of 80px */ + overflow: hidden; + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.15), + 0 0 0 1px rgba(255, 255, 255, 0.1); +} + +.appIcon img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* 加载指示器 */ +.spinner { + width: 1.5rem; + height: 1.5rem; + border: 2px solid oklch(0.8 0.1 var(--splash-hue-primary) / 0.3); + border-top-color: oklch(0.6 0.2 var(--splash-hue-primary)); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +:global(.dark) .spinner { + border-color: oklch(0.4 0.15 var(--splash-hue-primary) / 0.3); + border-top-color: oklch(0.7 0.2 var(--splash-hue-primary)); +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* 呼吸动画 - 整点报时或加载中 */ +.splashScreen[data-animating="true"] .glowPrimary { + animation: glowFloat 5s ease-in-out infinite; +} + +.splashScreen[data-animating="true"] .glowSecondary { + animation: glowFloat 6s ease-in-out infinite reverse; +} + +.splashScreen[data-animating="true"] .glowTertiary { + animation: glowFloat 4.5s ease-in-out infinite; + animation-delay: -1s; +} + +@keyframes glowFloat { + 0%, 100% { + transform: translate(0, 0) scale(1); + } + 25% { + transform: translate(5%, 5%) scale(1.05); + } + 50% { + transform: translate(-3%, 8%) scale(0.98); + } + 75% { + transform: translate(-5%, -3%) scale(1.02); + } +} diff --git a/src/components/ecosystem/miniapp-splash-screen.stories.tsx b/src/components/ecosystem/miniapp-splash-screen.stories.tsx new file mode 100644 index 00000000..947809e5 --- /dev/null +++ b/src/components/ecosystem/miniapp-splash-screen.stories.tsx @@ -0,0 +1,329 @@ +import { useState, useEffect } from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { fn, expect, waitFor, within } from '@storybook/test' +import { MiniappSplashScreen } from './miniapp-splash-screen' + +const meta: Meta = { + title: 'Ecosystem/MiniappSplashScreen', + component: MiniappSplashScreen, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + visible: true, + animating: true, + onClose: fn(), + }, +} + +export default meta +type Story = StoryObj + +// 默认紫色主题 +export const Default: Story = { + args: { + app: { + name: '转账助手', + icon: 'https://picsum.photos/seed/splash1/200', + themeColor: 280, // 紫色 + }, + }, +} + +// 蓝色主题 +export const BlueTheme: Story = { + args: { + app: { + name: 'DeFi 收益', + icon: 'https://picsum.photos/seed/splash2/200', + themeColor: 220, // 蓝色 + }, + }, +} + +// 绿色主题 +export const GreenTheme: Story = { + args: { + app: { + name: '质押挖矿', + icon: 'https://picsum.photos/seed/splash3/200', + themeColor: 145, // 绿色 + }, + }, +} + +// 橙色主题 +export const OrangeTheme: Story = { + args: { + app: { + name: 'NFT 市场', + icon: 'https://picsum.photos/seed/splash4/200', + themeColor: 45, // 橙色 + }, + }, +} + +// 红色主题 +export const RedTheme: Story = { + args: { + app: { + name: '风险提醒', + icon: 'https://picsum.photos/seed/splash5/200', + themeColor: 25, // 红色 + }, + }, +} + +// 使用 hex 颜色 +export const HexColor: Story = { + args: { + app: { + name: '跨链桥', + icon: 'https://picsum.photos/seed/splash6/200', + themeColor: '#6366f1', // Indigo + }, + }, +} + +// 使用 oklch 颜色 +export const OklchColor: Story = { + args: { + app: { + name: '链上投票', + icon: 'https://picsum.photos/seed/splash7/200', + themeColor: 'oklch(0.6 0.2 180)', + }, + }, +} + +// 深色模式 +export const DarkMode: Story = { + args: { + app: { + name: '暗黑钱包', + icon: 'https://picsum.photos/seed/splash8/200', + themeColor: 280, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +// 无动画 +export const NoAnimation: Story = { + args: { + app: { + name: '静态启动', + icon: 'https://picsum.photos/seed/splash9/200', + themeColor: 200, + }, + animating: false, + }, +} + +// 隐藏状态 +export const Hidden: Story = { + args: { + app: { + name: '隐藏的应用', + icon: 'https://picsum.photos/seed/splash10/200', + themeColor: 280, + }, + visible: false, + }, +} + +// 自动关闭演示 +function AutoCloseDemo() { + const [visible, setVisible] = useState(true) + + useEffect(() => { + const timer = setTimeout(() => setVisible(false), 3000) + return () => clearTimeout(timer) + }, []) + + return ( + setVisible(false)} + /> + ) +} + +export const AutoClose: Story = { + render: () => , +} + +// 真实 DOM 测试:渲染验证 +export const RenderTest: Story = { + args: { + app: { + name: '渲染测试', + icon: 'https://picsum.photos/seed/test1/200', + themeColor: 120, + }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('验证组件渲染', async () => { + const splash = canvas.getByTestId('miniapp-splash-screen') + await expect(splash).toBeInTheDocument() + await expect(splash).toHaveAttribute('data-visible', 'true') + }) + + await step('验证图标渲染', async () => { + const icon = canvas.getByAltText('渲染测试') + await expect(icon).toBeInTheDocument() + }) + + await step('验证无障碍属性', async () => { + const splash = canvas.getByTestId('miniapp-splash-screen') + await expect(splash).toHaveAttribute('role', 'status') + await expect(splash).toHaveAttribute('aria-label', '渲染测试 正在加载') + }) + }, +} + +// 真实 DOM 测试:CSS 渐变验证 +export const GradientTest: Story = { + args: { + app: { + name: '渐变测试', + icon: 'https://picsum.photos/seed/test2/200', + themeColor: 180, // Cyan + }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('验证 CSS 变量设置', async () => { + const splash = canvas.getByTestId('miniapp-splash-screen') + const style = splash.style + + // 验证主色 + await expect(style.getPropertyValue('--splash-hue-primary')).toBe('180') + // 验证邻近色1 (+30) + await expect(style.getPropertyValue('--splash-hue-secondary')).toBe('210') + // 验证邻近色2 (-30) + await expect(style.getPropertyValue('--splash-hue-tertiary')).toBe('150') + }) + }, +} + +// 真实 DOM 测试:动画状态 +export const AnimationTest: Story = { + args: { + app: { + name: '动画测试', + icon: 'https://picsum.photos/seed/test3/200', + themeColor: 280, + }, + animating: true, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('验证动画属性启用', async () => { + const splash = canvas.getByTestId('miniapp-splash-screen') + await expect(splash).toHaveAttribute('data-animating', 'true') + }) + }, +} + +// 真实 DOM 测试:可见性切换 +export const VisibilityToggleTest: Story = { + render: function VisibilityToggle() { + const [visible, setVisible] = useState(true) + + return ( +
+ + +
+ ) + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('初始状态应该可见', async () => { + const splash = canvas.getByTestId('miniapp-splash-screen') + await expect(splash).toHaveAttribute('data-visible', 'true') + }) + + await step('点击按钮切换隐藏', async () => { + const btn = canvas.getByTestId('toggle-btn') + btn.click() + + await waitFor(() => { + const splash = canvas.getByTestId('miniapp-splash-screen') + expect(splash).toHaveAttribute('data-visible', 'false') + }) + }) + + await step('再次点击切换显示', async () => { + const btn = canvas.getByTestId('toggle-btn') + btn.click() + + await waitFor(() => { + const splash = canvas.getByTestId('miniapp-splash-screen') + expect(splash).toHaveAttribute('data-visible', 'true') + }) + }) + }, +} + +// 响应式布局测试 +export const ResponsiveTest: Story = { + args: { + app: { + name: '响应式测试', + icon: 'https://picsum.photos/seed/test5/200', + themeColor: 280, + }, + }, + parameters: { + viewport: { + defaultViewport: 'mobile1', + }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('移动端视图验证', async () => { + const splash = canvas.getByTestId('miniapp-splash-screen') + await expect(splash).toBeInTheDocument() + }) + }, +} diff --git a/src/components/ecosystem/miniapp-splash-screen.test.tsx b/src/components/ecosystem/miniapp-splash-screen.test.tsx new file mode 100644 index 00000000..ce9e31f4 --- /dev/null +++ b/src/components/ecosystem/miniapp-splash-screen.test.tsx @@ -0,0 +1,161 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { + MiniappSplashScreen, + extractHue, + generateGlowHues, +} from './miniapp-splash-screen' + +describe('MiniappSplashScreen', () => { + const defaultApp = { + name: 'Test App', + icon: 'https://example.com/icon.png', + themeColor: '#ff0000', + } + + describe('extractHue', () => { + it('returns default hue for undefined', () => { + expect(extractHue(undefined)).toBe(280) + }) + + it('handles number input directly', () => { + expect(extractHue(120)).toBe(120) + expect(extractHue(400)).toBe(40) // normalized + expect(extractHue(-30)).toBe(330) // normalized + }) + + it('extracts hue from hex color', () => { + // Red + expect(extractHue('#ff0000')).toBe(0) + // Green + expect(extractHue('#00ff00')).toBe(120) + // Blue + expect(extractHue('#0000ff')).toBe(240) + }) + + it('extracts hue from rgb color', () => { + expect(extractHue('rgb(255, 0, 0)')).toBe(0) + expect(extractHue('rgb(0, 255, 0)')).toBe(120) + expect(extractHue('rgb(0, 0, 255)')).toBe(240) + }) + + it('extracts hue from oklch color', () => { + expect(extractHue('oklch(0.6 0.2 30)')).toBe(30) + expect(extractHue('oklch(0.5 0.15 280)')).toBe(280) + }) + + it('extracts hue from hsl color', () => { + expect(extractHue('hsl(180, 50%, 50%)')).toBe(180) + expect(extractHue('hsl(45, 100%, 75%)')).toBe(45) + }) + }) + + describe('generateGlowHues', () => { + it('generates three hues with correct offsets', () => { + const [primary, secondary, tertiary] = generateGlowHues(100) + expect(primary).toBe(100) + expect(secondary).toBe(130) // +30 + expect(tertiary).toBe(70) // -30 + }) + + it('normalizes hues correctly', () => { + const [primary, secondary, tertiary] = generateGlowHues(350) + expect(primary).toBe(350) + expect(secondary).toBe(20) // 350 + 30 = 380 -> 20 + expect(tertiary).toBe(320) // 350 - 30 = 320 + }) + + it('handles zero hue', () => { + const [primary, secondary, tertiary] = generateGlowHues(0) + expect(primary).toBe(0) + expect(secondary).toBe(30) + expect(tertiary).toBe(330) // 0 - 30 = -30 -> 330 + }) + }) + + describe('rendering', () => { + it('renders with correct test id', () => { + render() + expect(screen.getByTestId('miniapp-splash-screen')).toBeInTheDocument() + }) + + it('sets visible data attribute correctly', () => { + const { rerender } = render( + + ) + expect(screen.getByTestId('miniapp-splash-screen')).toHaveAttribute( + 'data-visible', + 'true' + ) + + rerender() + expect(screen.getByTestId('miniapp-splash-screen')).toHaveAttribute( + 'data-visible', + 'false' + ) + }) + + it('sets animating data attribute correctly', () => { + const { rerender } = render( + + ) + expect(screen.getByTestId('miniapp-splash-screen')).toHaveAttribute( + 'data-animating', + 'true' + ) + + rerender( + + ) + expect(screen.getByTestId('miniapp-splash-screen')).toHaveAttribute( + 'data-animating', + 'false' + ) + }) + + it('renders app icon image', () => { + render() + const img = screen.getByAltText('Test App') + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', 'https://example.com/icon.png') + }) + + it('has correct accessibility attributes', () => { + render() + const element = screen.getByTestId('miniapp-splash-screen') + expect(element).toHaveAttribute('role', 'status') + expect(element).toHaveAttribute('aria-label', 'Test App 正在加载') + }) + + it('hides from screen readers when not visible', () => { + render() + const element = screen.getByTestId('miniapp-splash-screen') + expect(element).toHaveAttribute('aria-hidden', 'true') + }) + + it('applies custom className', () => { + render( + + ) + expect(screen.getByTestId('miniapp-splash-screen')).toHaveClass( + 'custom-class' + ) + }) + + it('sets CSS variables for glow colors', () => { + const app = { ...defaultApp, themeColor: 100 } // hue = 100 + render() + + const element = screen.getByTestId('miniapp-splash-screen') + const style = element.style + + expect(style.getPropertyValue('--splash-hue-primary')).toBe('100') + expect(style.getPropertyValue('--splash-hue-secondary')).toBe('130') + expect(style.getPropertyValue('--splash-hue-tertiary')).toBe('70') + }) + }) +}) diff --git a/src/components/ecosystem/miniapp-splash-screen.tsx b/src/components/ecosystem/miniapp-splash-screen.tsx new file mode 100644 index 00000000..8de8fd31 --- /dev/null +++ b/src/components/ecosystem/miniapp-splash-screen.tsx @@ -0,0 +1,230 @@ +/** + * MiniappSplashScreen - 小程序启动屏幕 + * + * 使用基于应用 themeColor 的光晕渲染方案 + * 参考 IOSWallpaper 的实现,提供更柔和的启动体验 + */ + +import { useEffect, useMemo, useState } from 'react' +import { motion } from 'motion/react' +import { cn } from '@/lib/utils' +import styles from './miniapp-splash-screen.module.css' + +export interface MiniappSplashScreenProps { + /** 可选:用于埋点/调试/定位元素 */ + appId?: string + /** 应用信息 */ + app: { + name: string + icon: string + /** 主题色,支持 hex、rgb、oklch 或直接传 hue 数值 */ + themeColor?: string | number + } + /** 是否可见 */ + visible: boolean + /** 是否播放呼吸动画 */ + animating?: boolean + /** 关闭回调 */ + onClose?: () => void + /** 可选:共享元素动画 layoutId(用于 icon <-> splash.icon) */ + iconLayoutId?: string + /** 是否渲染图标(默认 true) */ + showIcon?: boolean + /** 是否渲染加载指示器(默认 true) */ + showSpinner?: boolean + /** 自定义类名 */ + className?: string +} + +/** + * 从颜色字符串中提取 hue 值 + * 支持: + * - 纯数字(直接作为 hue) + * - hex: #ff0000 + * - rgb: rgb(255, 0, 0) + * - oklch: oklch(0.6 0.2 30) + */ +export function extractHue(color: string | number | undefined): number { + if (color === undefined) return 280 // 默认紫色 + + // 直接传数字 + if (typeof color === 'number') { + return normalizeHue(color) + } + + const str = color.trim().toLowerCase() + + // oklch(l c h) 格式 + if (str.startsWith('oklch')) { + const match = str.match(/oklch\s*\(\s*[\d.]+\s+[\d.]+\s+([\d.]+)/) + if (match?.[1]) { + return normalizeHue(parseFloat(match[1])) + } + } + + // hsl(h, s%, l%) 格式 + if (str.startsWith('hsl')) { + const match = str.match(/hsl\s*\(\s*([\d.]+)/) + if (match?.[1]) { + return normalizeHue(parseFloat(match[1])) + } + } + + // hex 格式 + if (str.startsWith('#')) { + return hexToHue(str) + } + + // rgb 格式 + if (str.startsWith('rgb')) { + const match = str.match(/rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/) + if (match?.[1] && match[2] && match[3]) { + return rgbToHue( + parseInt(match[1]), + parseInt(match[2]), + parseInt(match[3]) + ) + } + } + + return 280 // 默认 +} + +/** 将 hue 标准化到 0-360 范围 */ +function normalizeHue(hue: number): number { + return ((hue % 360) + 360) % 360 +} + +/** hex 转 hue */ +function hexToHue(hex: string): number { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) + if (!result?.[1] || !result[2] || !result[3]) return 280 + + return rgbToHue( + parseInt(result[1], 16), + parseInt(result[2], 16), + parseInt(result[3], 16) + ) +} + +/** RGB 转 hue */ +function rgbToHue(r: number, g: number, b: number): number { + r /= 255 + g /= 255 + b /= 255 + + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const d = max - min + + if (d === 0) return 0 + + let h = 0 + switch (max) { + case r: + h = ((g - b) / d + (g < b ? 6 : 0)) / 6 + break + case g: + h = ((b - r) / d + 2) / 6 + break + case b: + h = ((r - g) / d + 4) / 6 + break + } + + return Math.round(h * 360) +} + +/** + * 生成三色光晕的 hue 值 + * @param baseHue 基础色相 + * @returns [主色, 邻近色1(+30°), 邻近色2(-30°)] + */ +export function generateGlowHues(baseHue: number): [number, number, number] { + return [ + normalizeHue(baseHue), + normalizeHue(baseHue + 30), + normalizeHue(baseHue - 30), + ] +} + +export function MiniappSplashScreen({ + appId, + app, + visible, + animating = true, + onClose: _onClose, + iconLayoutId, + showIcon = true, + showSpinner = true, + className, +}: MiniappSplashScreenProps) { + const [imageLoaded, setImageLoaded] = useState(false) + const [imageError, setImageError] = useState(false) + + // 计算光晕颜色 + const [huePrimary, hueSecondary, hueTertiary] = useMemo(() => { + const baseHue = extractHue(app.themeColor) + return generateGlowHues(baseHue) + }, [app.themeColor]) + + // 重置图片状态 + useEffect(() => { + setImageLoaded(false) + setImageError(false) + }, [app.icon]) + + // CSS 变量样式 + const cssVars = { + '--splash-hue-primary': huePrimary, + '--splash-hue-secondary': hueSecondary, + '--splash-hue-tertiary': hueTertiary, + } as React.CSSProperties + + return ( +
+ {/* 光晕背景层 */} +
+
+
+ + {/* 内容区域 */} + {(showIcon || showSpinner) && ( +
+ {/* 应用图标 */} + {showIcon && ( + + {!imageError && ( + {app.name} setImageLoaded(true)} + onError={() => setImageError(true)} + style={{ opacity: imageLoaded ? 1 : 0 }} + /> + )} + + )} + + {/* 加载指示器 */} + {showSpinner && + )} +
+ ) +} + +export default MiniappSplashScreen diff --git a/src/components/ecosystem/miniapp-stack-card.module.css b/src/components/ecosystem/miniapp-stack-card.module.css new file mode 100644 index 00000000..d8bac753 --- /dev/null +++ b/src/components/ecosystem/miniapp-stack-card.module.css @@ -0,0 +1,143 @@ +/** + * MiniappStackCard 层叠卡片样式 + */ + +.card { + position: relative; + width: 100%; + height: 100%; + border-radius: 16px; + overflow: hidden; + background: var(--card); + box-shadow: + 0 4px 20px rgba(0, 0, 0, 0.15), + 0 0 0 1px rgba(0, 0, 0, 0.05); + + /* 触摸优化 */ + touch-action: pan-y; + user-select: none; + -webkit-user-select: none; + + /* 过渡 */ + transition: + transform 0.3s cubic-bezier(0.25, 0.1, 0.25, 1), + opacity 0.3s ease, + box-shadow 0.2s ease; +} + +.card.active { + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.2), + 0 0 0 2px var(--primary); +} + +.card.dragging { + cursor: grabbing; +} + +.card.closing { + pointer-events: none; +} + +/* 卡片头部 */ +.header { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--card); + border-bottom: 1px solid var(--border); +} + +.headerInfo { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.appName { + font-size: 14px; + font-weight: 600; + color: var(--foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.appDesc { + font-size: 12px; + color: var(--muted-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 卡片内容 */ +.content { + flex: 1; + position: relative; + background: var(--background); +} + +.iframeWrapper { + position: absolute; + inset: 0; +} + +.iframePlaceholder, +.placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient( + 135deg, + var(--muted) 0%, + var(--background) 100% + ); +} + +/* 上滑提示 */ +.swipeHint { + position: absolute; + bottom: 8px; + left: 50%; + transform: translateX(-50%); + padding: 4px 8px; + opacity: 0.6; +} + +.swipeIndicator { + width: 32px; + height: 4px; + background: var(--muted-foreground); + border-radius: 2px; + animation: swipeHintPulse 2s ease-in-out infinite; +} + +@keyframes swipeHintPulse { + 0%, 100% { + opacity: 0.4; + transform: translateY(0); + } + 50% { + opacity: 0.8; + transform: translateY(-4px); + } +} + +/* 深色模式 */ +:global(.dark) .card { + box-shadow: + 0 4px 20px rgba(0, 0, 0, 0.4), + 0 0 0 1px rgba(255, 255, 255, 0.1); +} + +:global(.dark) .card.active { + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.5), + 0 0 0 2px var(--primary); +} diff --git a/src/components/ecosystem/miniapp-stack-card.tsx b/src/components/ecosystem/miniapp-stack-card.tsx new file mode 100644 index 00000000..e303766d --- /dev/null +++ b/src/components/ecosystem/miniapp-stack-card.tsx @@ -0,0 +1,186 @@ +/** + * MiniappStackCard - 层叠视图中的应用卡片 + * + * 显示单个后台应用的预览卡片 + * 支持上滑关闭手势 + */ + +import { useRef, useState, useCallback } from 'react' +import { cn } from '@/lib/utils' +import { MiniappIcon } from './miniapp-icon' +import type { MiniappInstance } from '@/services/miniapp-runtime' +import styles from './miniapp-stack-card.module.css' + +/** 上滑关闭的阈值(像素) */ +const SWIPE_UP_THRESHOLD = 100 + +/** 上滑关闭的速度阈值(像素/毫秒) */ +const SWIPE_VELOCITY_THRESHOLD = 0.5 + +export interface MiniappStackCardProps { + /** 应用实例 */ + app: MiniappInstance + /** 是否为当前选中的卡片 */ + isActive?: boolean + /** 点击卡片回调 */ + onTap?: () => void + /** 上滑关闭回调 */ + onSwipeUp?: () => void + /** 自定义类名 */ + className?: string +} + +export function MiniappStackCard({ + app, + isActive = false, + onTap, + onSwipeUp, + className, +}: MiniappStackCardProps) { + const cardRef = useRef(null) + const [isDragging, setIsDragging] = useState(false) + const [dragOffset, setDragOffset] = useState(0) + const [isClosing, setIsClosing] = useState(false) + + // 触摸状态 + const touchState = useRef({ + startY: 0, + startTime: 0, + currentY: 0, + }) + + const handleTouchStart = useCallback((e: React.TouchEvent) => { + const touch = e.touches[0] + if (!touch) return + + touchState.current = { + startY: touch.clientY, + startTime: Date.now(), + currentY: touch.clientY, + } + setIsDragging(true) + }, []) + + const handleTouchMove = useCallback((e: React.TouchEvent) => { + if (!isDragging) return + + const touch = e.touches[0] + if (!touch) return + + touchState.current.currentY = touch.clientY + const deltaY = touchState.current.startY - touch.clientY + + // 只允许向上拖动 + if (deltaY > 0) { + setDragOffset(deltaY) + } + }, [isDragging]) + + const handleTouchEnd = useCallback(() => { + if (!isDragging) return + + const deltaY = touchState.current.startY - touchState.current.currentY + const deltaTime = Date.now() - touchState.current.startTime + const velocity = deltaY / deltaTime + + // 判断是否触发关闭 + const shouldClose = deltaY > SWIPE_UP_THRESHOLD || velocity > SWIPE_VELOCITY_THRESHOLD + + if (shouldClose && deltaY > 0) { + setIsClosing(true) + // 播放关闭动画后回调 + setTimeout(() => { + onSwipeUp?.() + }, 200) + } else { + // 回弹 + setDragOffset(0) + } + + setIsDragging(false) + }, [isDragging, onSwipeUp]) + + const handleClick = useCallback(() => { + if (!isDragging && dragOffset === 0) { + onTap?.() + } + }, [isDragging, dragOffset, onTap]) + + // 卡片样式 + const cardStyle: React.CSSProperties = { + transform: isClosing + ? 'translateY(-100vh) scale(0.8)' + : dragOffset > 0 + ? `translateY(-${dragOffset}px) scale(${1 - dragOffset * 0.001})` + : undefined, + opacity: isClosing ? 0 : dragOffset > 0 ? 1 - dragOffset * 0.003 : 1, + transition: isDragging ? 'none' : 'transform 0.3s ease, opacity 0.3s ease', + } + + return ( +
+ {/* 卡片头部 - 应用信息 */} +
+ +
+ {app.manifest.name} + {app.manifest.description} +
+
+ + {/* 卡片内容 - iframe 预览 */} +
+ {app.iframeRef ? ( +
+ {/* iframe 会由 runtime service 管理,这里只是容器 */} +
+ +
+
+ ) : ( +
+ +
+ )} +
+ + {/* 上滑提示 */} + {isActive && ( +
+
+
+ )} +
+ ) +} + +export default MiniappStackCard diff --git a/src/components/ecosystem/miniapp-stack-view.module.css b/src/components/ecosystem/miniapp-stack-view.module.css new file mode 100644 index 00000000..7c80e126 --- /dev/null +++ b/src/components/ecosystem/miniapp-stack-view.module.css @@ -0,0 +1,103 @@ +/** + * MiniappStackView 层叠视图容器样式 + */ + +.container { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + flex-direction: column; + padding-bottom: var(--tab-bar-height); + + /* 进入动画 */ + animation: stackViewEnter 0.3s cubic-bezier(0.25, 0.1, 0.25, 1) forwards; +} + +@keyframes stackViewEnter { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* 背景遮罩 */ +.backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); +} + +/* 标题 */ +.title { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 16px; + padding-top: max(env(safe-area-inset-top, 16px), 16px); + + color: white; + font-size: 16px; + font-weight: 600; +} + +.count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + + background: rgba(255, 255, 255, 0.2); + border-radius: 10px; + + font-size: 12px; + font-weight: 500; +} + +/* Swiper 区域 */ +.swiperWrapper { + position: relative; + z-index: 1; + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.swiper { + width: 100%; + max-width: 320px; + height: 100%; + max-height: 480px; +} + +.slide { + display: flex; + align-items: center; + justify-content: center; + border-radius: 16px; + overflow: hidden; +} + +/* 操作提示 */ +.hints { + position: relative; + z-index: 1; + text-align: center; + padding: 12px 16px; + + color: rgba(255, 255, 255, 0.6); + font-size: 12px; +} + +/* 深色模式 - 已经是深色背景,无需调整 */ diff --git a/src/components/ecosystem/miniapp-stack-view.tsx b/src/components/ecosystem/miniapp-stack-view.tsx new file mode 100644 index 00000000..30c1e83f --- /dev/null +++ b/src/components/ecosystem/miniapp-stack-view.tsx @@ -0,0 +1,144 @@ +/** + * MiniappStackView - 层叠视图容器 + * + * 显示所有运行中的应用卡片 + * 使用 Swiper 实现左右滑动切换 + * 支持上滑关闭应用 + */ + +import { useCallback, useRef, useEffect } from 'react' +import { Swiper, SwiperSlide } from 'swiper/react' +import { EffectCards } from 'swiper/modules' +import type { Swiper as SwiperType } from 'swiper' +import 'swiper/css' +import 'swiper/css/effect-cards' +import { useStore } from '@tanstack/react-store' +import { cn } from '@/lib/utils' +import { + miniappRuntimeStore, + miniappRuntimeSelectors, + activateApp, + closeApp, + closeStackView, +} from '@/services/miniapp-runtime' +import type { MiniappInstance } from '@/services/miniapp-runtime' +import { MiniappStackCard } from './miniapp-stack-card' +import styles from './miniapp-stack-view.module.css' + +export interface MiniappStackViewProps { + /** 是否可见 */ + visible?: boolean + /** 关闭回调(退出层叠视图) */ + onClose?: () => void + /** 自定义类名 */ + className?: string +} + +export function MiniappStackView({ + visible = false, + onClose, + className, +}: MiniappStackViewProps) { + const swiperRef = useRef(null) + + // 获取所有运行中的应用 + const apps = useStore(miniappRuntimeStore, miniappRuntimeSelectors.getApps) as MiniappInstance[] + const activeAppId = useStore(miniappRuntimeStore, (s) => s.activeAppId) + + // 当应用列表变化时,如果没有应用了就关闭视图 + useEffect(() => { + if (visible && apps.length === 0) { + onClose?.() + closeStackView() + } + }, [visible, apps.length, onClose]) + + // 处理卡片点击 - 激活应用并退出层叠视图 + const handleCardTap = useCallback((appId: string) => { + activateApp(appId) + closeStackView() + onClose?.() + }, [onClose]) + + // 处理上滑关闭 + const handleSwipeUp = useCallback((appId: string) => { + closeApp(appId) + + // 如果关闭后还有其他应用,停留在层叠视图 + // 如果没有应用了,useEffect 会自动关闭视图 + }, []) + + // 处理滑动切换 + const handleSlideChange = useCallback((swiper: SwiperType) => { + const currentApp = apps[swiper.activeIndex] + if (currentApp) { + // 只更新选中状态,不激活应用 + // activateApp 会在点击卡片时调用 + } + }, [apps]) + + if (!visible || apps.length === 0) { + return null + } + + // 找到当前激活应用的索引 + const initialIndex = apps.findIndex((app) => app.appId === activeAppId) + + return ( +
+ {/* 背景遮罩 */} +
{ + closeStackView() + onClose?.() + }} + /> + + {/* 标题 */} +
+ 正在运行的应用 + {apps.length} +
+ + {/* 卡片滑动区域 */} +
+ = 0 ? initialIndex : 0} + onSwiper={(swiper) => { swiperRef.current = swiper }} + onSlideChange={handleSlideChange} + cardsEffect={{ + slideShadows: false, + perSlideOffset: 8, + perSlideRotate: 2, + }} + className={styles.swiper} + > + {apps.map((app, index) => ( + + handleCardTap(app.appId)} + onSwipeUp={() => handleSwipeUp(app.appId)} + /> + + ))} + +
+ + {/* 操作提示 */} +
+ 左右滑动切换 · 点击打开 · 上滑关闭 +
+
+ ) +} + +export default MiniappStackView diff --git a/src/components/ecosystem/miniapp-window-stack.tsx b/src/components/ecosystem/miniapp-window-stack.tsx new file mode 100644 index 00000000..fa98026d --- /dev/null +++ b/src/components/ecosystem/miniapp-window-stack.tsx @@ -0,0 +1,109 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Buffer } from 'buffer' +import { useStore } from '@tanstack/react-store' +import { + miniappRuntimeStore, + miniappRuntimeSelectors, + registerDesktopGridHostRef, + unregisterDesktopGridHostRef, + registerDesktopAppSlotRef, + unregisterDesktopAppSlotRef, +} from '@/services/miniapp-runtime' +import type { MiniappTargetDesktop } from '@/services/ecosystem/types' + +function base64UrlEncode(input: string): string { + const bytes = new TextEncoder().encode(input) + let binary = '' + bytes.forEach((b) => { + binary += String.fromCharCode(b) + }) + + const base64 = + typeof globalThis.btoa === 'function' + ? globalThis.btoa(binary) + : Buffer.from(binary, 'binary').toString('base64') + + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') +} + +function isTargetDesktop(value: string | null | undefined): value is MiniappTargetDesktop { + return value === 'mine' || value === 'stack' +} + +/** + * MiniappWindowStack + * + * 一个统一的“窗口堆栈层”覆盖网格: + * - 由 slide 决定布局(本组件只提供 slot 容器) + * - slot 统一叠放在同一 grid cell(1 / 1) + * - 本组件不区分 mine/stack 的 props,通过 DOM 上的 `data-ecosystem-subpage` 自识别 + */ +export function MiniappWindowStack() { + const rootRef = useRef(null) + const [desktop, setDesktop] = useState(null) + + const presentations = useStore(miniappRuntimeStore, miniappRuntimeSelectors.getPresentations) + + // 通过 DOM 祖先自识别该层属于 mine 还是 stack + useEffect(() => { + const root = rootRef.current + if (!root) return + + const page = root.closest('[data-ecosystem-subpage]')?.getAttribute('data-ecosystem-subpage') ?? null + setDesktop(isTargetDesktop(page) ? page : null) + }, []) + + // 注册/注销 grid host ref(便于 runtime 定位) + useEffect(() => { + const root = rootRef.current + if (!root || !desktop) return + + registerDesktopGridHostRef(desktop, root) + return () => unregisterDesktopGridHostRef(desktop) + }, [desktop]) + + const slotPresentations = useMemo(() => { + if (!desktop) return [] + return presentations.filter((p) => p.desktop === desktop && p.state !== 'hidden') + }, [desktop, presentations]) + + return ( +
+ {slotPresentations.map((p) => { + const areaName = base64UrlEncode(p.appId) + const setSlotRef = (el: HTMLDivElement | null) => { + if (!desktop) return + if (el) { + registerDesktopAppSlotRef(desktop, p.appId, el) + return + } + unregisterDesktopAppSlotRef(desktop, p.appId) + } + + return ( +
+ ) + })} +
+ ) +} + +export default MiniappWindowStack diff --git a/src/components/ecosystem/miniapp-window.module.css b/src/components/ecosystem/miniapp-window.module.css new file mode 100644 index 00000000..29ec8feb --- /dev/null +++ b/src/components/ecosystem/miniapp-window.module.css @@ -0,0 +1,82 @@ +/** + * MiniappWindow 小程序窗口样式 + */ + +.window { + position: static; + width: 100%; + height: 100%; +} + +.windowInner { + position: relative; + width: 100%; + height: 100%; + background: var(--background); + overflow: hidden; + + /* 变换原点:居中 */ + transform-origin: center center; + + /* 默认隐藏,避免 layoutId 初次挂载闪现 */ + opacity: 0; +} + +/** + * 内容层容器:包含 splash-bg 和 iframe + * 使用 isolation 隔离混合模式 + */ +.contentLayer { + position: absolute; + inset: 0; + isolation: isolate; +} + +/* 只在动画过程中启用混合模式 */ +.contentLayer.blending [data-animate='top'] { + mix-blend-mode: plus-lighter; +} + +/** + * splash-bg 层(和 iframe 互斥) + */ +.splashBgLayer { + position: absolute; + inset: 0; +} + +/** + * iframe 层(和 splash-bg 互斥) + */ +.iframeLayer { + position: absolute; + inset: 0; +} + +.iframeLayer iframe { + width: 100%; + height: 100%; + border: none; + background: transparent; +} + +.window.animating { + /* 动画期间仅禁用 iframe 交互,保留胶囊按钮可强制关闭 */ +} + +.window.animating .iframeLayer { + pointer-events: none; +} + +/* 胶囊容器层 - 在 iframe 之上 */ +.capsuleLayer { + position: absolute; + inset: 0; + /* 必须高于 splash/launch overlay,确保卡在 splash 也能关闭 */ + z-index: 2000; + pointer-events: none; +} + +.capsuleLayer > * { + pointer-events: auto; +} diff --git a/src/components/ecosystem/miniapp-window.stories.tsx b/src/components/ecosystem/miniapp-window.stories.tsx new file mode 100644 index 00000000..7383a135 --- /dev/null +++ b/src/components/ecosystem/miniapp-window.stories.tsx @@ -0,0 +1,273 @@ +/** + * MiniappWindow Stories + * + * 演示小程序窗口组件和启动动画 + */ + +import type { Meta, StoryObj } from '@storybook/react'; +import { useRef, useEffect, useState } from 'react'; +import { MiniappSplashScreen } from './miniapp-splash-screen'; +import { MiniappCapsule } from './miniapp-capsule'; +import { MiniappWindow } from './miniapp-window'; +import { EcosystemDesktop, type EcosystemDesktopHandle } from './ecosystem-desktop'; +import { SwiperSyncProvider } from '@/components/common/swiper-sync-context'; +import { TabBar } from '@/stackflow/components/TabBar'; +import { + closeAllApps, + launchApp, + resetMiniappVisualConfig, + setMiniappMotionTimeScale, +} from '@/services/miniapp-runtime'; +import { MiniappVisualProvider } from '@/services/miniapp-runtime/MiniappVisualProvider'; +import type { MiniappManifest } from '@/services/ecosystem'; + +const meta: Meta = { + title: 'Ecosystem/MiniappWindow', + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +// Mock 应用数据 +const mockApps: MiniappManifest[] = [ + { + id: 'gaubee', + name: 'Gaubee', + description: 'gaubee.com', + icon: 'https://gaubee.com/icon-192x192.png', + url: 'https://gaubee.com/', + version: '1.0.0', + themeColor: '#4285F4', + splashScreen: true, + capsuleTheme: 'auto', + }, + { + id: 'wikipedia', + name: 'Wikipedia', + description: 'The Free Encyclopedia', + icon: 'https://www.wikipedia.org/static/favicon/wikipedia.ico', + url: 'https://www.wikipedia.org/', + version: '1.0.0', + themeColor: '#111827', + splashScreen: { timeout: 1200 }, + capsuleTheme: 'auto', + }, + { + id: 'kingsword-blog', + name: 'Kingsword Blog', + description: 'blog.kingsword.tech', + icon: 'https://blog.kingsword.tech/favicon.png', + url: 'https://blog.kingsword.tech/', + version: '1.0.0', + themeColor: '#0f172a', + capsuleTheme: 'auto', + }, + { + id: 'openai', + name: 'OpenAI', + description: 'Research (no splash)', + icon: 'https://openai.com/favicon.ico', + url: 'https://openai.com/', + version: '1.0.0', + themeColor: '#10a37f', + }, +]; + +/** 单独测试启动屏幕 */ +export const SplashScreenOnly: Story = { + render: () => ( +
+ +
+ ), +}; + +/** 单独测试胶囊按钮 */ +export const CapsuleOnly: Story = { + render: () => ( +
+ console.log('Action clicked')} + onClose={() => console.log('Close clicked')} + /> +
+ ), +}; + +/** 不同主题色的启动屏幕 */ +export const SplashScreenThemes: Story = { + render: () => ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ), +}; + +/** + * 启动动画演示 + * + * 点击图标启动应用,观察 FLIP 动画效果: + * - 锻造/传送:有 splash screen(路径 1) + * - 市场/钱包:无 splash screen(路径 2) + */ +export const LaunchDemo: Story = { + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], + render: function LaunchDemoStory() { + const desktopRef = useRef(null); + const [timeScale, setTimeScale] = useState(1); + + // 清理旧状态 + useEffect(() => { + // 清理之前可能残留的应用状态 + closeAllApps(); + resetMiniappVisualConfig(); + setMiniappMotionTimeScale(1); + + return () => { + resetMiniappVisualConfig(); + }; + }, []); + + useEffect(() => { + setMiniappMotionTimeScale(timeScale); + }, [timeScale]); + + const myApps = mockApps.map((app, i) => ({ + app, + lastUsed: Date.now() - i * 1000 * 60 * 60, + })); + + const handleAppOpen = (app: MiniappManifest) => { + console.log('[LaunchDemo] Opening app:', app.name); + // 关键:动画目标来自 targetDesktop 对应 slide 的 rect,因此必须先切到该页 + const manifest: MiniappManifest = { ...app, targetDesktop: 'mine' }; + desktopRef.current?.slideTo('mine'); + requestAnimationFrame(() => launchApp(app.id, manifest)); + }; + + return ( + +
+
+ console.log('Detail:', app.name)} + onAppRemove={(id) => console.log('Remove:', id)} + /> + + {/* 窗口层 */} + +
+ + {/* 真实项目底部指示器(TabBar 内置生态 indicator) */} + {}} /> + + {/* 提示 + 速度调控 */} +
+
+
点击图标启动应用 | 锻造/传送有 Splash | 市场/钱包直接打开
+ +
+ +
x{timeScale.toFixed(2)}
+ + +
+
+ + setTimeScale(Number(e.target.value))} + aria-label="Miniapp motion speed" + /> +
+
+
+ ); + }, +}; diff --git a/src/components/ecosystem/miniapp-window.tsx b/src/components/ecosystem/miniapp-window.tsx new file mode 100644 index 00000000..7cf284d5 --- /dev/null +++ b/src/components/ecosystem/miniapp-window.tsx @@ -0,0 +1,332 @@ +/** + * MiniappWindow - 小程序窗口容器 + * + * 作为 stack-slide 的子元素,用于显示小程序内容 + * 使用 portal 渲染到 slide 提供的 slot 容器中(尺寸由 desktop/slide 决定) + * 无 Popover API 依赖 + */ + +import { useEffect, useLayoutEffect, useRef, useCallback, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { useStore } from '@tanstack/react-store'; +import { cn } from '@/lib/utils'; +import { AnimatePresence, motion } from 'motion/react'; +import { + miniappRuntimeStore, + miniappRuntimeSelectors, + type MiniappInstance, + type MiniappPresentation, + registerWindowRef, + registerWindowInnerRef, + getDesktopAppSlotRef, + requestDismiss, + requestDismissSplash, + didDismiss, + didPresent, + settleFlow, +} from '@/services/miniapp-runtime'; +import { getMiniappMotionPresets, type MiniappMotionPresets } from '@/services/miniapp-runtime/visual-config'; +import { MiniappSplashScreen } from './miniapp-splash-screen'; +import { MiniappCapsule } from './miniapp-capsule'; +import { MiniappIcon } from './miniapp-icon'; +import { + flowToCapsule, + flowToSplashBgLayer, + flowToSplashIconLayer, + flowToIframeLayer, + flowToSplashIconLayoutId, + flowToWindowContainer, +} from './miniapp-motion-flow'; +import styles from './miniapp-window.module.css'; + +export interface MiniappWindowProps { + className?: string; +} + +const WINDOW_CONTAINER_VARIANTS = { + open: { opacity: 1, pointerEvents: 'auto' }, + closed: { opacity: 0, pointerEvents: 'none' }, +} as const; + +const VISIBILITY_VARIANTS = { + show: { opacity: 1, pointerEvents: 'auto' }, + hide: { opacity: 0, pointerEvents: 'none' }, +} as const; + +/** + * 层级变体:z-index 和 opacity 联动 + * top: 上层可见,bottom: 下层隐藏,gone: display:none + */ +const LAYER_VARIANTS = { + top: { zIndex: 10, opacity: 1, pointerEvents: 'auto' }, + bottom: { zIndex: 0, opacity: 0, pointerEvents: 'none' }, + gone: { zIndex: 0, opacity: 0, display: 'none', pointerEvents: 'none' }, +}; + +export function MiniappWindow({ className }: MiniappWindowProps) { + const presentations = useStore(miniappRuntimeStore, miniappRuntimeSelectors.getPresentations); + const focusedAppId = useStore(miniappRuntimeStore, miniappRuntimeSelectors.getFocusedAppId); + const visualConfig = useStore(miniappRuntimeStore, miniappRuntimeSelectors.getVisualConfig); + const motionPresets = getMiniappMotionPresets(visualConfig); + + if (presentations.length === 0) return null; + + return ( + <> + {presentations + .filter((p) => p.state !== 'hidden') + .map((p) => ( + + ))} + + ); +} + +function MiniappWindowPortal({ + appId, + presentation, + motionPresets, + className, + isFocused, +}: { + appId: string; + presentation: MiniappPresentation; + motionPresets: MiniappMotionPresets; + className?: string; + isFocused: boolean; +}) { + const windowRef = useRef(null); + const iframeContainerRef = useRef(null); + const [presentApp, setPresentApp] = useState(null); + const exitScheduledRef = useRef(false); + const exitingTransitionIdRef = useRef(null); + + const app = useStore(miniappRuntimeStore, (s) => s.apps.get(appId) ?? null); + const isAnimating = useStore(miniappRuntimeStore, (s) => { + const a = s.apps.get(appId); + return a?.state === 'launching' || a?.state === 'splash' || a?.state === 'closing'; + }); + + const portalHost = getDesktopAppSlotRef(presentation.desktop, appId); + + useLayoutEffect(() => { + if (!app) { + setPresentApp(null); + exitScheduledRef.current = false; + return; + } + + if (app.state === 'preparing') { + setPresentApp(null); + exitScheduledRef.current = false; + return; + } + + if (presentation.state === 'dismissing' || app.state === 'closing') { + if (exitScheduledRef.current) return; + + // 先渲染一帧 closing visuals,再触发 exit(layoutId -> icon) + setPresentApp(app); + exitingTransitionIdRef.current = presentation.transitionId; + exitScheduledRef.current = true; + requestAnimationFrame(() => setPresentApp(null)); + return; + } + + exitScheduledRef.current = false; + setPresentApp(app); + }, [app?.appId, app?.state, presentation.state, presentation.transitionId]); + + useEffect(() => { + if (!presentApp) return; + if (presentation.state !== 'presenting') return; + if (presentation.transitionKind !== 'present') return; + if (!presentation.transitionId) return; + didPresent(appId, presentation.transitionId); + }, [appId, presentApp, presentation.state, presentation.transitionKind, presentation.transitionId]); + + const lastFlowRef = useRef('closed'); + if (presentApp?.flow) { + lastFlowRef.current = presentApp.flow; + } + const flow = presentApp ? presentApp.flow : lastFlowRef.current; + + const handleLayoutAnimationComplete = useCallback(() => { + if (presentApp?.appId) { + settleFlow(presentApp.appId); + } + }, [presentApp?.appId]); + + const windowContainerVariant = flowToWindowContainer[flow]; + const splashBgLayerVariant = flowToSplashBgLayer[flow]; + const splashIconLayerVariant = flowToSplashIconLayer[flow]; + const iframeLayerVariant = flowToIframeLayer[flow]; + const splashIconHasLayoutId = flowToSplashIconLayoutId[flow]; + const capsuleVariant = flowToCapsule[flow]; + const isTransitioning = flow === 'opening' || flow === 'closing'; + + const appDisplay = useMemo(() => { + return { + appId: presentApp?.appId ?? '', + name: presentApp?.manifest.name ?? '', + icon: presentApp?.manifest.icon ?? '', + themeColor: presentApp?.manifest.themeColorFrom ?? 280, + }; + }, [presentApp?.appId, presentApp?.manifest.name, presentApp?.manifest.icon, presentApp?.manifest.themeColorFrom]); + + useEffect(() => { + if (!portalHost) return; + if (windowRef.current) registerWindowRef(windowRef.current); + if (iframeContainerRef.current) registerWindowInnerRef(iframeContainerRef.current); + }, [portalHost]); + + useEffect(() => { + const iframe = presentApp?.iframeRef; + const container = iframeContainerRef.current; + if (!iframe || !container) return; + + if (iframe.parentElement !== container) { + container.appendChild(iframe); + } + + if (iframe.style.display === 'none') { + iframe.style.display = ''; + } + }, [presentApp?.iframeRef, presentApp?.appId, portalHost]); + + const handleClose = useCallback(() => { + requestDismiss(appId); + }, [appId]); + + const handleSplashClose = useCallback(() => { + requestDismissSplash(appId); + }, [appId]); + + if (!portalHost) return null; + + const node = ( + { + const exitingTransitionId = exitingTransitionIdRef.current; + if (exitingTransitionId) { + didDismiss(appId, exitingTransitionId); + } + exitingTransitionIdRef.current = null; + lastFlowRef.current = 'closed'; + }} + > + {presentApp && ( +
+ + + + + + + + + + + + + + + +
+ + { + // TODO: 显示更多操作菜单 + }} + onClose={handleClose} + /> + +
+
+
+ )} +
+ ); + + return createPortal(node, portalHost); +} + +export default MiniappWindow; diff --git a/src/components/ecosystem/my-apps-page.module.css b/src/components/ecosystem/my-apps-page.module.css new file mode 100644 index 00000000..041e992e --- /dev/null +++ b/src/components/ecosystem/my-apps-page.module.css @@ -0,0 +1,105 @@ +/** + * MyAppsPage 组件样式 + * iOS Desktop Icon 相关样式(popover + 菜单) + */ + +/* 占位容器 */ +.iconWrapper { + position: relative; +} + +/* Popover 基础样式(未打开状态 - 强制可见) */ +.iconPopover { + display: block !important; + position: static !important; + inset: auto !important; + margin: 0 !important; + background: transparent; + border: none; + padding: 0; + overflow: visible; +} + +/* 菜单模式:popover 打开时固定定位 */ +.iconPopover:popover-open:not([data-launching]) { + position: fixed; + inset: auto; + top: var(--popover-top); + left: var(--popover-left); + margin: 0; + z-index: 50; +} + +/* 菜单模式:::backdrop 背景模糊 */ +.iconPopover:not([data-launching])::backdrop { + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(40px) saturate(180%); + -webkit-backdrop-filter: blur(40px) saturate(180%); + opacity: var(--backdrop-opacity, 1); + transition: + opacity calc(0.2s / var(--miniapp-motion-time-scale, 1)) ease-out, + backdrop-filter calc(0.2s / var(--miniapp-motion-time-scale, 1)) ease-out, + -webkit-backdrop-filter calc(0.2s / var(--miniapp-motion-time-scale, 1)) ease-out, + display calc(0.2s / var(--miniapp-motion-time-scale, 1)) ease-out allow-discrete, + overlay calc(0.2s / var(--miniapp-motion-time-scale, 1)) ease-out allow-discrete; +} + +@starting-style { + .iconPopover:not([data-launching])::backdrop { + opacity: 0; + backdrop-filter: blur(0) saturate(100%); + -webkit-backdrop-filter: blur(0) saturate(100%); + } +} + +/* 菜单模式:图标浮起效果 */ +.iconPopover:popover-open:not([data-launching]) .iconButton { + transform: scale(1.08) translateY(-4px); + filter: drop-shadow(0 8px 16px rgba(0, 0, 0, 0.3)); +} + +/* 启动模式:隐藏 backdrop */ +.iconPopover[data-launching]::backdrop { + display: none; +} + +/* 启动模式:禁用图标效果(由 Web Animation API 控制) */ +.iconPopover[data-launching] .iconButton { + transform: none !important; + filter: none !important; +} + +/* 图标按钮(用于 CSS 选择器定位) */ +.iconButton { + /* 基础样式由 Tailwind 提供 */ +} + +/* 点击拦截层 */ +.backdropClickArea { + background: transparent; +} + +/* 上下文菜单 */ +.contextMenu { + z-index: 51; + transform-origin: center bottom; + opacity: 1; + transform: scale(1) translateY(0); + transition: + opacity calc(0.2s / var(--miniapp-motion-time-scale, 1)) cubic-bezier(0.34, 1.56, 0.64, 1), + transform calc(0.25s / var(--miniapp-motion-time-scale, 1)) cubic-bezier(0.34, 1.56, 0.64, 1), + display calc(0.25s / var(--miniapp-motion-time-scale, 1)) allow-discrete; +} + +@starting-style { + .contextMenu { + opacity: 0; + transform: scale(0.85) translateY(8px); + } +} + +/* 菜单退出动画 */ +.contextMenuClosing { + opacity: 0; + transform: scale(0.9) translateY(4px); +} diff --git a/src/components/ecosystem/my-apps-page.stories.tsx b/src/components/ecosystem/my-apps-page.stories.tsx index fe9d6857..afb571c9 100644 --- a/src/components/ecosystem/my-apps-page.stories.tsx +++ b/src/components/ecosystem/my-apps-page.stories.tsx @@ -11,6 +11,7 @@ const mockApps: Array<{ app: MiniappManifest; lastUsed: number }> = [ name: '转账助手', description: '快速安全的转账工具', icon: 'https://picsum.photos/seed/app1/200', + url: 'https://example.com/app-1', version: '1.0.0', permissions: [], }, @@ -22,6 +23,7 @@ const mockApps: Array<{ app: MiniappManifest; lastUsed: number }> = [ name: 'DeFi 收益', description: '一站式 DeFi 收益管理', icon: 'https://picsum.photos/seed/app2/200', + url: 'https://example.com/app-2', version: '1.0.0', permissions: [], }, @@ -33,6 +35,7 @@ const mockApps: Array<{ app: MiniappManifest; lastUsed: number }> = [ name: 'NFT 市场', description: '发现和交易 NFT', icon: 'https://picsum.photos/seed/app3/200', + url: 'https://example.com/app-3', version: '1.0.0', permissions: [], }, @@ -44,6 +47,7 @@ const mockApps: Array<{ app: MiniappManifest; lastUsed: number }> = [ name: '链上投票', description: '参与 DAO 治理投票', icon: 'https://picsum.photos/seed/app4/200', + url: 'https://example.com/app-4', version: '1.0.0', permissions: [], }, @@ -55,6 +59,7 @@ const mockApps: Array<{ app: MiniappManifest; lastUsed: number }> = [ name: '跨链桥', description: '多链资产跨链转移', icon: 'https://picsum.photos/seed/app5/200', + url: 'https://example.com/app-5', version: '1.0.0', permissions: [], }, @@ -66,6 +71,7 @@ const mockApps: Array<{ app: MiniappManifest; lastUsed: number }> = [ name: '质押挖矿', description: '质押代币获取收益', icon: 'https://picsum.photos/seed/app6/200', + url: 'https://example.com/app-6', version: '1.0.0', permissions: [], }, diff --git a/src/components/ecosystem/my-apps-page.tsx b/src/components/ecosystem/my-apps-page.tsx index 06ed459d..3014adfc 100644 --- a/src/components/ecosystem/my-apps-page.tsx +++ b/src/components/ecosystem/my-apps-page.tsx @@ -1,15 +1,29 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { useStore } from '@tanstack/react-store'; import { IconDownload, IconPlayerPlay, IconInfoCircle, IconTrash } from '@tabler/icons-react'; import { cn } from '@/lib/utils'; +import { motion, LayoutGroup } from 'motion/react'; import { MiniappIcon } from './miniapp-icon'; import { SourceIcon } from './source-icon'; import { IOSSearchCapsule } from './ios-search-capsule'; -import { IOSWallpaper } from './ios-wallpaper'; import type { MiniappManifest } from '@/services/ecosystem'; +import { flowToCornerBadge, runtimeStateToStableFlow } from './miniapp-motion-flow'; +import { + miniappRuntimeStore, + miniappRuntimeSelectors, + registerDesktopContainerRef, + registerIconRef, + registerIconInnerRef, + unregisterDesktopContainerRef, + unregisterIconRef, +} from '@/services/miniapp-runtime'; +import { getMiniappMotionPresets } from '@/services/miniapp-runtime/visual-config'; +import styles from './my-apps-page.module.css'; // ============================================ // iOS 桌面图标(带 Popover 菜单) // ============================================ + interface IOSDesktopIconProps { app: MiniappManifest; onTap: () => void; @@ -20,30 +34,80 @@ interface IOSDesktopIconProps { function IOSDesktopIcon({ app, onTap, onOpen, onDetail, onRemove }: IOSDesktopIconProps) { const popoverRef = useRef(null); + const iconRef = useRef(null); + const menuRef = useRef(null); const longPressTimer = useRef | null>(null); const didLongPress = useRef(false); const [isOpen, setIsOpen] = useState(false); const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); + const runtimeState = useStore(miniappRuntimeStore, (s) => s.apps.get(app.id)?.state ?? null); + const visualConfig = useStore(miniappRuntimeStore, miniappRuntimeSelectors.getVisualConfig); + const motionPresets = getMiniappMotionPresets(visualConfig); + const focusedAppId = useStore(miniappRuntimeStore, miniappRuntimeSelectors.getFocusedAppId); + const isFocusedApp = focusedAppId === app.id; + const presentationState = useStore( + miniappRuntimeStore, + (s) => s.presentations.get(app.id)?.state ?? null, + ); + const iconFlow = runtimeStateToStableFlow(runtimeState); + const iconStackingVariant = + isFocusedApp && (iconFlow === 'opening' || iconFlow === 'splash') ? 'elevated' : 'normal'; + const cornerBadgeVariant = flowToCornerBadge[iconFlow]; + // icon 只在窗口 transitioning 阶段持有 shared layout(present/dismiss) + const enableSharedLayout = + presentationState === null || presentationState === 'presenting' || presentationState === 'dismissing'; + const sharedLayoutIds = enableSharedLayout + ? { + container: `miniapp:${app.id}:container`, + logo: `miniapp:${app.id}:logo`, + inner: `miniapp:${app.id}:inner`, + } + : null; + + const ICON_STACKING_VARIANTS = { + normal: { zIndex: 0, pointerEvents: 'auto' }, + elevated: { zIndex: 50, pointerEvents: 'none' }, + } as const; + + const CORNER_BADGE_VARIANTS = { + show: { opacity: 1, scale: 1 }, + hide: { opacity: 0, scale: 0.6 }, + } as const; + + // 注册图标 ref 到 runtime(用于 rect 计算 / 共享元素动画) + useEffect(() => { + if (popoverRef.current) { + registerIconRef(app.id, popoverRef.current); + } + if (iconRef.current) { + registerIconInnerRef(app.id, iconRef.current); + } + return () => { + unregisterIconRef(app.id); + }; + }, [app.id]); + + // 动画引用 + const popoverAnimationRef = useRef(null); + const iconAnimationRef = useRef(null); + const menuAnimationRef = useRef(null); + const showMenu = () => { const popover = popoverRef.current; - if (!popover) return; - - // 计算位置(在 showPopover 之前,popover 还在文档流中) + const icon = iconRef.current; + if (!popover || !icon) return; + const rect = popover.getBoundingClientRect(); - - // 设置 CSS 变量(通过 !important 生效) - popover.style.setProperty('--popover-top', `${rect.top}px`); - popover.style.setProperty('--popover-left', `${rect.left}px`); - + // 菜单位置(图标上方) const menuWidth = 224; const menuHeight = 180; const gap = 16; - + let menuTop = rect.top - menuHeight - gap; let menuLeft = rect.left + rect.width / 2 - menuWidth / 2; - + if (menuLeft < 16) menuLeft = 16; if (menuLeft + menuWidth > window.innerWidth - 16) { menuLeft = window.innerWidth - menuWidth - 16; @@ -51,39 +115,118 @@ function IOSDesktopIcon({ app, onTap, onOpen, onDetail, onRemove }: IOSDesktopIc if (menuTop < 16) { menuTop = rect.bottom + gap; } - + setMenuPosition({ top: menuTop, left: menuLeft }); + + // 用 Web Animation API 设置 popover 位置(fill: forwards 保持最终状态) + popoverAnimationRef.current = popover.animate( + [ + { + position: 'fixed', + top: `${rect.top}px`, + left: `${rect.left}px`, + margin: '0', + inset: 'auto', + zIndex: '50', + }, + ], + { + duration: 0, + fill: 'forwards', + }, + ); + + // 图标浮起动画 + iconAnimationRef.current = icon.animate( + [ + { transform: 'scale(1) translateY(0)', filter: 'drop-shadow(0 0 0 transparent)' }, + { transform: 'scale(1.08) translateY(-4px)', filter: 'drop-shadow(0 8px 16px rgba(0, 0, 0, 0.3))' }, + ], + { + duration: 200, + easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)', + fill: 'forwards', + }, + ); + popover.showPopover(); setIsOpen(true); + + requestAnimationFrame(() => { + const menu = menuRef.current; + if (menu) { + menuAnimationRef.current = menu.animate( + [ + { opacity: 0, transform: 'scale(0.85) translateY(8px)' }, + { opacity: 1, transform: 'scale(1) translateY(0)' }, + ], + { + duration: 250, + easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)', + fill: 'forwards', + }, + ); + } + }); }; const hideMenu = () => { const popover = popoverRef.current; + const icon = iconRef.current; + const menu = menuRef.current; if (!popover) return; - - // 触发退出动画 - const menu = popover.querySelector('.ios-context-menu'); - menu?.classList.add('closing'); - popover.style.setProperty('--backdrop-opacity', '0'); - - // 等待动画完成后隐藏 popover + + // 图标恢复动画 + if (icon) { + iconAnimationRef.current?.cancel(); + iconAnimationRef.current = icon.animate( + [ + { transform: 'scale(1.08) translateY(-4px)', filter: 'drop-shadow(0 8px 16px rgba(0, 0, 0, 0.3))' }, + { transform: 'scale(1) translateY(0)', filter: 'drop-shadow(0 0 0 transparent)' }, + ], + { + duration: 150, + easing: 'ease-out', + fill: 'forwards', + }, + ); + } + + // 菜单退出动画 + if (menu) { + menuAnimationRef.current = menu.animate( + [ + { opacity: 1, transform: 'scale(1) translateY(0)' }, + { opacity: 0, transform: 'scale(0.9) translateY(4px)' }, + ], + { + duration: 150, + easing: 'ease-out', + fill: 'forwards', + }, + ); + } + setTimeout(() => { popover.hidePopover(); setIsOpen(false); - menu?.classList.remove('closing'); - popover.style.removeProperty('--backdrop-opacity'); - }, 200); + popoverAnimationRef.current?.cancel(); + iconAnimationRef.current?.cancel(); + menuAnimationRef.current?.cancel(); + popoverAnimationRef.current = null; + iconAnimationRef.current = null; + menuAnimationRef.current = null; + }, 150); }; - // 监听 popover toggle 事件 useEffect(() => { const el = popoverRef.current; if (!el) return; - + const handleToggle = (e: ToggleEvent) => { setIsOpen(e.newState === 'open'); }; - + el.addEventListener('toggle', handleToggle); return () => el.removeEventListener('toggle', handleToggle); }, []); @@ -132,26 +275,18 @@ function IOSDesktopIcon({ app, onTap, onOpen, onDetail, onRemove }: IOSDesktopIc return ( // 占位容器(在网格中保持位置) -
+
{/* Popover(打开时提升到顶层) */} -
+
{/* 点击拦截层(视觉效果由 ::backdrop 提供) */} {isOpen && ( -
+
)} - {/* 图标按钮 */} + {/* 图标按钮(只包含图标,不包含 label;label 必须留在静态布局中) */} {/* 菜单 */} {isOpen && (
-
+

{app.name}

{app.description}

@@ -213,6 +392,11 @@ function IOSDesktopIcon({ app, onTap, onOpen, onDetail, onRemove }: IOSDesktopIc
)}
+ + {/* label:永远处于静态布局(不进入 popover top-layer),避免启动动画把文字一起“拎走” */} + + {app.name} +
); } @@ -237,24 +421,46 @@ function EmptyState() { // ============================================ export interface MyAppsPageProps { apps: Array<{ app: MiniappManifest; lastUsed: number }>; + /** 是否显示搜索框(默认 true,关闭发现页时应设为 false) */ + showSearch?: boolean; onSearchClick: () => void; onAppOpen: (app: MiniappManifest) => void; onAppDetail: (app: MiniappManifest) => void; onAppRemove: (appId: string) => void; } -export function MyAppsPage({ apps, onSearchClick, onAppOpen, onAppDetail, onAppRemove }: MyAppsPageProps) { +export function MyAppsPage({ + apps, + showSearch = true, + onSearchClick, + onAppOpen, + onAppDetail, + onAppRemove, +}: MyAppsPageProps) { const columns = 4; const pageSize = columns * 6; const pages = Math.ceil(apps.length / pageSize); + const mineContainerRef = useRef(null); + + useEffect(() => { + const el = mineContainerRef.current; + if (!el) return; + registerDesktopContainerRef('mine', el); + return () => unregisterDesktopContainerRef('mine'); + }, []); + return ( - +
- {/* 顶部区域 - 搜索胶囊 */} -
- -
+ {/* 顶部区域 - 搜索胶囊(仅在有发现页时显示) */} + {showSearch ? ( +
+ +
+ ) : ( +
+ )} {/* 内容区 */} {apps.length === 0 ? ( @@ -298,6 +504,6 @@ export function MyAppsPage({ apps, onSearchClick, onAppOpen, onAppDetail, onAppR {/* TabBar spacer */}
- +
); } diff --git a/src/components/ecosystem/source-icon.tsx b/src/components/ecosystem/source-icon.tsx index 9697d0c0..537c653d 100644 --- a/src/components/ecosystem/source-icon.tsx +++ b/src/components/ecosystem/source-icon.tsx @@ -1,81 +1,73 @@ /** * Source Icon Component - * + * * 订阅源图标组件 * - 有自定义图标时显示图标 * - 没有图标时显示 HTTPS 锁图标(表示安全来源) */ -import { forwardRef } from 'react' -import { IconLock } from '@tabler/icons-react' -import { cn } from '@/lib/utils' +import { forwardRef } from 'react'; +import { IconLock } from '@tabler/icons-react'; +import { cn } from '@/lib/utils'; export interface SourceIconProps { /** 图标 URL */ - src?: string | null + src?: string | null; /** 订阅源名称 */ - name?: string + name?: string; /** 尺寸 */ - size?: 'sm' | 'md' | 'lg' + size?: 'sm' | 'md' | 'lg'; /** 自定义类名 */ - className?: string + className?: string; } const SIZES = { sm: { icon: 24, lock: 14 }, md: { icon: 32, lock: 18 }, lg: { icon: 40, lock: 22 }, -} - -export const SourceIcon = forwardRef( - function SourceIcon({ src, name = 'Source', size = 'md', className }, ref) { - const { icon: iconSize, lock: lockSize } = SIZES[size] - const radius = Math.round(iconSize * 0.22) +}; - // 有自定义图标 - if (src) { - return ( -
- {name} -
- ) - } +export const SourceIcon = forwardRef(function SourceIcon( + { src, name = 'Source', size = 'md', className }, + ref, +) { + const { icon: iconSize, lock: lockSize } = SIZES[size]; + const radius = Math.round(iconSize * 0.22); - // 默认 HTTPS 锁图标 + // 有自定义图标 + if (src) { return (
- + {name}
- ) + ); } -) + + // 默认 HTTPS 锁图标 + return ( +
+ +
+ ); +}); diff --git a/src/components/ecosystem/swiper-sync-demo.stories.tsx b/src/components/ecosystem/swiper-sync-demo.stories.tsx new file mode 100644 index 00000000..5e40fb67 --- /dev/null +++ b/src/components/ecosystem/swiper-sync-demo.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { SwiperSyncDemo, SwiperSyncDemoContext } from './swiper-sync-demo'; + +const meta: Meta = { + title: 'Ecosystem/SwiperSyncDemo', + component: SwiperSyncDemo, + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +/** Controller 模块原理展示 */ +export const Controller: Story = {}; + +/** Context 封装模式(跨组件同步) */ +export const ContextMode: StoryObj = { + render: () => , +}; diff --git a/src/components/ecosystem/swiper-sync-demo.tsx b/src/components/ecosystem/swiper-sync-demo.tsx new file mode 100644 index 00000000..57f5766c --- /dev/null +++ b/src/components/ecosystem/swiper-sync-demo.tsx @@ -0,0 +1,223 @@ +/** + * Swiper 双向绑定 Demo + * + * - Controller: 原理展示(同组件内直接使用 Controller 模块) + * - ContextMode: 封装展示(跨组件使用 Context + Controller) + */ + +import { useState } from 'react'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import { Controller } from 'swiper/modules'; +import type { Swiper as SwiperType } from 'swiper'; +import { cn } from '@/lib/utils'; +import 'swiper/css'; + +/** 页面配置 */ +const PAGES = ['Page 1', 'Page 2', 'Page 3']; + +/** + * 方案一:Swiper Controller 模块(官方推荐) + */ +export function SwiperSyncDemo() { + // Controller 需要 state 来触发重渲染建立连接 + const [mainSwiper, setMainSwiper] = useState(null); + const [indicatorSwiper, setIndicatorSwiper] = useState(null); + + // 用于 UI 显示的进度(不参与同步逻辑) + const [displayProgress, setDisplayProgress] = useState(0); + + return ( +
+

方案一:Controller 模块(官方推荐)

+ + {/* 调试信息 */} +
+
Progress: {displayProgress.toFixed(3)}
+
Active Index: {Math.round(displayProgress * (PAGES.length - 1))}
+
+ + {/* 主 Swiper */} +
+
主 Swiper
+ setDisplayProgress(p)} + > + {PAGES.map((page) => ( + + {page} + + ))} + +
+ + {/* 指示器 Swiper */} +
+
指示器 Swiper
+
+ + {PAGES.map((page, index) => ( + +
+ {index + 1} +
+
+ ))} +
+
+
+ + {/* 手动控制 */} +
+ {PAGES.map((_, index) => ( + + ))} +
+
+ ); +} + +/** + * 方案三:Context 封装模式(使用 Controller 模块) + */ +import { + SwiperSyncProvider, + useSwiperMember, +} from '@/components/common/swiper-sync-context'; + +/** 主 Swiper 组件 */ +function MainSwiperWithContext() { + // 自己是 'main',要控制 'indicator' + const { onSwiper, controlledSwiper } = useSwiperMember('main', 'indicator'); + const [progress, setProgress] = useState(0); + + return ( +
+
+ 主 Swiper(独立组件) +
+ setProgress(p)} + > + {PAGES.map((page) => ( + + {page} + + ))} + + + {/* 调试信息 */} +
+ Progress: {progress.toFixed(3)} | Index: {Math.round(progress * (PAGES.length - 1))} +
+
+ ); +} + +/** 指示器 Swiper 组件 */ +function IndicatorSwiperWithContext() { + // 自己是 'indicator',要控制 'main' + const { onSwiper, controlledSwiper } = useSwiperMember('indicator', 'main'); + const [progress, setProgress] = useState(0); + const maxIndex = PAGES.length - 1; + + // 计算图标透明度 + const getOpacity = (index: number) => { + const currentIndex = progress * maxIndex; + const distance = Math.abs(currentIndex - index); + return Math.max(0.3, 1 - distance); + }; + + return ( +
+
+ 指示器 Swiper(独立组件) +
+
+ setProgress(p)} + slidesPerView={1} + resistance={true} + resistanceRatio={0.5} + > + {PAGES.map((page, index) => ( + +
+ {index + 1} +
+
+ ))} +
+ + {/* 分页点 */} +
+ {PAGES.map((_, index) => { + const isActive = Math.round(progress * maxIndex) === index; + return ( + + ); + })} +
+
+
+ ); +} + +/** 方案三:Context 封装 Demo */ +export function SwiperSyncDemoContext() { + return ( + +
+

方案三:Context 封装模式

+

+ 使用 SwiperSyncProvider + useSwiperMember + Controller 模块实现跨组件同步 +

+ + + +
+
+ ); +} + +export default SwiperSyncDemo; diff --git a/src/components/layout/swipeable-tabs.test.tsx b/src/components/layout/swipeable-tabs.test.tsx index b02f8773..e1ba6af0 100644 --- a/src/components/layout/swipeable-tabs.test.tsx +++ b/src/components/layout/swipeable-tabs.test.tsx @@ -139,7 +139,7 @@ describe('SwipeableTabs', () => { {(tab) =>
Content: {tab}
}
) - const indicator = container.querySelector('[class*="transition-transform"]') + const indicator = container.querySelector('div[style*="--tab-index"]') expect(indicator).toBeInTheDocument() }) @@ -151,8 +151,9 @@ describe('SwipeableTabs', () => { const historyTab = screen.getByRole('button', { name: /交易/i }) await userEvent.click(historyTab) - const contentContainer = container.querySelector('[class*="transition-transform"]') - expect(contentContainer).toBeInTheDocument() + const indicator = container.querySelector('div[style*="--tab-index"]') as HTMLElement | null + expect(indicator).toBeInTheDocument() + expect(indicator?.style.transform).toContain('translateX') }) it('respects controlled activeTab', () => { diff --git a/src/lib/crypto/bioforest.test.ts b/src/lib/crypto/bioforest.test.ts index 7d9de378..1fa5e035 100644 --- a/src/lib/crypto/bioforest.test.ts +++ b/src/lib/crypto/bioforest.test.ts @@ -298,7 +298,9 @@ describe('BioForest Crypto', () => { expect(derived.address.startsWith('c')).toBe(true) const addresses = deriveBioforestAddressesFromChainConfigs('test', [customConfig]) - expect(addresses).toEqual([{ chainId: 'custom-bioforest', address: derived.address }]) + expect(addresses).toEqual([ + { chainId: 'custom-bioforest', address: derived.address, publicKey: derived.publicKey }, + ]) }) it('should reject non-bioforest configs', () => { diff --git a/src/lib/crypto/bioforest.ts b/src/lib/crypto/bioforest.ts index 329eaf28..ad1e4d72 100644 --- a/src/lib/crypto/bioforest.ts +++ b/src/lib/crypto/bioforest.ts @@ -157,7 +157,10 @@ export function deriveBioforestAddresses( })) } - return deriveBioforestAddressesFromChainConfigs(secret, chainConfigs) + return deriveBioforestAddressesFromChainConfigs(secret, chainConfigs).map(({ chainId, address }) => ({ + chainId, + address, + })) } // ==================== Base58 编码 ==================== diff --git a/src/pages/ecosystem/miniapp.tsx b/src/pages/ecosystem/miniapp.tsx deleted file mode 100644 index 37a86ac8..00000000 --- a/src/pages/ecosystem/miniapp.tsx +++ /dev/null @@ -1,458 +0,0 @@ -/** - * MiniApp Container Page - * - * Runs a miniapp in an iframe with Bio SDK integration - */ - -import { useEffect, useRef, useCallback, useState, useMemo } from 'react' -import { useTranslation } from 'react-i18next' -import { getAppById, getBridge, initBioProvider } from '@/services/ecosystem' -import type { MiniappManifest, BioAccount, TransferParams, BioUnsignedTransaction, BioSignedTransaction } from '@/services/ecosystem' -import { - setWalletPicker, - setGetAccounts, -} from '@/services/ecosystem/handlers/wallet' -import { setSigningDialog } from '@/services/ecosystem/handlers/signing' -import { setTransferDialog } from '@/services/ecosystem/handlers/transfer' -import { setSignTransactionDialog } from '@/services/ecosystem/handlers/transaction' -import { walletStore, walletSelectors, type ChainAddress } from '@/stores' -import { usePreferences } from '@/stores/preferences' -import { getLanguageDirection } from '@/i18n/index' -import { useFlow } from '@/stackflow/stackflow' -import { IconX, IconDots } from '@tabler/icons-react' - -// 获取解析后的主题(system -> light/dark) -function resolveColorMode(theme: 'light' | 'dark' | 'system'): 'light' | 'dark' { - if (theme !== 'system') return theme - if (typeof window === 'undefined') return 'light' - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' -} - -// 默认主题色 hue(粉色) -const DEFAULT_PRIMARY_HUE = 323 -const DEFAULT_PRIMARY_SATURATION = 0.26 - -interface MiniappPageProps { - appId: string - onClose?: () => void -} - -export function MiniappPage({ appId, onClose }: MiniappPageProps) { - const { t, i18n } = useTranslation('common') - const { push } = useFlow() - const iframeRef = useRef(null) - const [app, setApp] = useState(null) - const [loading, setLoading] = useState(true) - const [splashVisible, setSplashVisible] = useState(false) - const [error, setError] = useState(null) - const splashTimeoutRef = useRef | null>(null) - const preferences = usePreferences() - - // 构建 context 状态 - const contextState = useMemo(() => ({ - theme: { - colorMode: resolveColorMode(preferences.theme), - primaryHue: DEFAULT_PRIMARY_HUE, - primarySaturation: DEFAULT_PRIMARY_SATURATION, - }, - locale: { - lang: preferences.language, - rtl: getLanguageDirection(preferences.language) === 'rtl', - }, - env: { - platform: 'web' as const, - version: '1.0.0', - safeAreaInsets: { top: 0, bottom: 0, left: 0, right: 0 }, - }, - a11y: { - fontScale: 1.0, - reduceMotion: false, - }, - }), [preferences.theme, preferences.language]) - - // 构建带 context 参数的 iframe URL - const iframeUrl = useMemo(() => { - if (!app) return '' - const url = new URL(app.url, window.location.origin) - url.searchParams.set('colorMode', contextState.theme.colorMode) - url.searchParams.set('primaryHue', String(contextState.theme.primaryHue)) - url.searchParams.set('primarySaturation', String(contextState.theme.primarySaturation)) - url.searchParams.set('lang', contextState.locale.lang) - url.searchParams.set('rtl', String(contextState.locale.rtl)) - url.searchParams.set('platform', contextState.env.platform) - url.searchParams.set('version', contextState.env.version) - return url.toString() - }, [app, contextState]) - - // 当 context 变化时通知 iframe - useEffect(() => { - if (!iframeRef.current?.contentWindow) return - iframeRef.current.contentWindow.postMessage({ - type: 'keyapp:context-update', - payload: contextState, - }, '*') - }, [contextState]) - - useEffect(() => { - const manifest = getAppById(appId) - if (!manifest) { - setError('小程序不存在') - setLoading(false) - return - } - setApp(manifest) - }, [appId, t]) - - // 钱包选择器 - const showWalletPicker = useCallback( - (opts?: { chain?: string; exclude?: string }): Promise => { - return new Promise((resolve) => { - const handleSelect = (e: Event) => { - const detail = (e as CustomEvent).detail - window.removeEventListener('wallet-picker-select', handleSelect) - window.removeEventListener('wallet-picker-cancel', handleCancel) - resolve({ - address: detail.address, - chain: detail.chain, - name: detail.name, - }) - } - - const handleCancel = () => { - window.removeEventListener('wallet-picker-select', handleSelect) - window.removeEventListener('wallet-picker-cancel', handleCancel) - resolve(null) - } - - window.addEventListener('wallet-picker-select', handleSelect) - window.addEventListener('wallet-picker-cancel', handleCancel) - - const params: Record = {} - if (opts?.chain) params.chain = opts.chain - if (opts?.exclude) params.exclude = opts.exclude - if (app?.name) params.appName = app.name - if (app?.icon) params.appIcon = app.icon - push('WalletPickerJob', params) - }) - }, - [push, app?.name, app?.icon] - ) - - // 获取已连接账户 - const getConnectedAccounts = useCallback((): BioAccount[] => { - const state = walletStore.state - const wallet = walletSelectors.getCurrentWallet(state) - if (!wallet) return [] - - return wallet.chainAddresses.map((ca: ChainAddress) => ({ - address: ca.address, - chain: ca.chain, - name: wallet.name, - })) - }, []) - - // 签名对话框 - 集成真实签名服务 - const showSigningDialog = useCallback( - (params: { message: string; address: string; app: { name: string; icon?: string } }): Promise<{ signature: string; publicKey: string } | null> => { - return new Promise((resolve) => { - const handleConfirm = (e: Event) => { - const detail = (e as CustomEvent).detail - window.removeEventListener('signing-confirm', handleConfirm) - if (detail.confirmed && detail.signature && detail.publicKey) { - // 返回签名和公钥(hex 格式) - resolve({ signature: detail.signature, publicKey: detail.publicKey }) - } else { - resolve(null) - } - } - - window.addEventListener('signing-confirm', handleConfirm) - - // 根据 address 找到对应的链 - const state = walletStore.state - const wallet = walletSelectors.getCurrentWallet(state) - let chainName = 'bioforest' - if (wallet) { - const chainAddr = wallet.chainAddresses.find( - (ca: ChainAddress) => ca.address.toLowerCase() === params.address.toLowerCase() - ) - if (chainAddr) { - chainName = chainAddr.chain - } - } - - push('SigningConfirmJob', { - message: params.message, - address: params.address, - appName: params.app.name, - appIcon: params.app.icon ?? app?.icon ?? '', - chainName, - }) - }) - }, - [push, app?.icon] - ) - - // 转账对话框 - const showTransferDialog = useCallback( - (params: TransferParams & { app: { name: string; icon?: string } }): Promise<{ txHash: string } | null> => { - return new Promise((resolve) => { - const handleResult = (e: Event) => { - const detail = (e as CustomEvent).detail - window.removeEventListener('miniapp-transfer-confirm', handleResult) - if (detail.confirmed) { - resolve({ txHash: detail.txHash }) - } else { - resolve(null) - } - } - - window.addEventListener('miniapp-transfer-confirm', handleResult) - - push('MiniappTransferConfirmJob', { - appName: params.app.name, - appIcon: params.app.icon ?? app?.icon ?? '', - from: params.from, - to: params.to, - amount: params.amount, - chain: params.chain, - ...(params.asset ? { asset: params.asset } : {}), - }) - }) - }, - [push, app?.icon] - ) - - // 交易签名对话框 - const showSignTransactionDialog = useCallback( - (params: { from: string; chain: string; unsignedTx: BioUnsignedTransaction; app: { name: string; icon?: string } }): Promise => { - return new Promise((resolve) => { - const handleResult = (e: Event) => { - const detail = (e as CustomEvent).detail as { confirmed?: boolean; signedTx?: BioSignedTransaction } - window.removeEventListener('miniapp-sign-transaction-confirm', handleResult) - if (detail.confirmed && detail.signedTx) { - resolve(detail.signedTx) - } else { - resolve(null) - } - } - - window.addEventListener('miniapp-sign-transaction-confirm', handleResult) - - push('MiniappSignTransactionJob', { - appName: params.app.name, - appIcon: params.app.icon ?? app?.icon ?? '', - from: params.from, - chain: params.chain, - unsignedTx: JSON.stringify(params.unsignedTx), - }) - }) - }, - [push, app?.icon], - ) - - // 权限请求对话框 - const requestPermission = useCallback( - (_appId: string, appName: string, permissions: string[]): Promise => { - return new Promise((resolve) => { - const handleResult = (e: Event) => { - const detail = (e as CustomEvent).detail - window.removeEventListener('permission-request', handleResult) - resolve(detail.approved === true) - } - - window.addEventListener('permission-request', handleResult) - - push('PermissionRequestJob', { - appName, - appIcon: app?.icon ?? '', - permissions: JSON.stringify(permissions), - }) - }) - }, - [push, app?.icon] - ) - - // 注册回调 - useEffect(() => { - setWalletPicker(showWalletPicker) - setGetAccounts(getConnectedAccounts) - setSigningDialog(showSigningDialog) - setTransferDialog(showTransferDialog) - setSignTransactionDialog(showSignTransactionDialog) - - return () => { - setWalletPicker(null) - setGetAccounts(null) - setSigningDialog(null) - setTransferDialog(null) - setSignTransactionDialog(null) - } - }, [showWalletPicker, getConnectedAccounts, showSigningDialog, showTransferDialog, showSignTransactionDialog]) - - useEffect(() => { - // Initialize provider on mount - initBioProvider() - }, []) - - // 关闭启动屏 - const closeSplashScreen = useCallback(() => { - if (splashTimeoutRef.current) { - clearTimeout(splashTimeoutRef.current) - splashTimeoutRef.current = null - } - setSplashVisible(false) - setLoading(false) - }, []) - - const handleIframeLoad = useCallback(() => { - if (!iframeRef.current || !app) return - - // Attach bridge to iframe - const bridge = getBridge() - bridge.setPermissionRequestCallback(requestPermission) - bridge.attach(iframeRef.current, app.id, app.name, app.permissions ?? []) - - // 检查是否有启动屏配置 - if (app.splashScreen) { - // 有启动屏配置:显示启动屏,等待 closeSplashScreen 调用或超时 - setLoading(false) - setSplashVisible(true) - - // splashScreen 可以是 true 或 { timeout?: number } - const timeout = (typeof app.splashScreen === 'object' ? app.splashScreen.timeout : undefined) ?? 5000 - splashTimeoutRef.current = setTimeout(() => { - console.warn('[MiniappPage] Splash screen timeout, auto-closing') - closeSplashScreen() - }, timeout) - } else { - // 无启动屏配置:直接隐藏 loading - setLoading(false) - } - - // Emit connect event - bridge.emit('connect', { chainId: 'bfmeta' }) - }, [app, requestPermission, closeSplashScreen]) - - // 监听 bio_closeSplashScreen 消息 - useEffect(() => { - const handleMessage = (event: MessageEvent) => { - if (event.data?.type === 'bio_request' && event.data?.method === 'bio_closeSplashScreen') { - closeSplashScreen() - // 发送响应 - if (iframeRef.current?.contentWindow) { - iframeRef.current.contentWindow.postMessage({ - type: 'bio_response', - id: event.data.id, - success: true, - result: undefined, - }, '*') - } - } - } - - window.addEventListener('message', handleMessage) - return () => window.removeEventListener('message', handleMessage) - }, [closeSplashScreen]) - - useEffect(() => { - return () => { - // Cleanup on unmount - getBridge().detach() - if (splashTimeoutRef.current) { - clearTimeout(splashTimeoutRef.current) - } - } - }, []) - - if (error) { - return ( -
-

{error}

- -
- ) - } - - return ( -
- {/* Header */} -
- - -
-

{app?.name ?? '加载中...'}

- {app?.author && ( -

{app.author}

- )} -
- - -
- - {/* Loading overlay */} - {loading && ( -
-
-
-

{t('loading', '加载中...')}

-
-
- )} - - {/* Splash screen overlay */} - {splashVisible && app?.splashScreen && ( -
-
- {/* 启动屏图标 - 使用 app.icon */} -
- {app.name} { - e.currentTarget.style.display = 'none' - }} - /> -
- {/* 加载指示器 */} -
-
-
- )} - - {/* Iframe container */} - {app && iframeUrl && ( -