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..e3afcd64 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,5 @@ - 紧凑头部效果使用 `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 仅用于 Tailwind 无法表达的场景(如 @keyframes、:global()、复杂层叠),简单样式优先用 Tailwind 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..91177a59 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) // 右键菜单 -> 移除 diff --git a/src/StackflowApp.tsx b/src/StackflowApp.tsx index bfb054a3..53d47631 100644 --- a/src/StackflowApp.tsx +++ b/src/StackflowApp.tsx @@ -1,5 +1,28 @@ +import { useStore } from "@tanstack/react-store"; import { Stack } from "./stackflow"; +import { MiniappWindow, MiniappStackView } from "./components/ecosystem"; +import { + miniappRuntimeStore, + miniappRuntimeSelectors, + closeStackView, +} from "./services/miniapp-runtime"; export function StackflowApp() { - return ; + const isStackViewOpen = useStore( + miniappRuntimeStore, + miniappRuntimeSelectors.isStackViewOpen + ); + + return ( + <> + + {/* 小程序窗口 - 全局 Popover 层 */} + + {/* 层叠视图 - 多应用管理 */} + + + ); } diff --git a/src/components/ecosystem/app-stack-page.tsx b/src/components/ecosystem/app-stack-page.tsx new file mode 100644 index 00000000..21ab92da --- /dev/null +++ b/src/components/ecosystem/app-stack-page.tsx @@ -0,0 +1,43 @@ +/** + * AppStackPage - 应用堆栈页面 + * + * Swiper 的第三页,作为小程序窗口的背景板 + * 当没有激活应用时,此页禁用滑动 + */ + +import { useStore } from '@tanstack/react-store' +import { cn } from '@/lib/utils' +import { miniappRuntimeStore, miniappRuntimeSelectors } from '@/services/miniapp-runtime' + +export interface AppStackPageProps { + className?: string +} + +export function AppStackPage({ className }: AppStackPageProps) { + const hasRunningApps = useStore( + miniappRuntimeStore, + miniappRuntimeSelectors.hasRunningApps + ) + + return ( +
+ {/* 空状态提示(调试用,生产环境可移除) */} + {!hasRunningApps && ( +
+

应用堆栈

+
+ )} +
+ ) +} + +export default AppStackPage 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..ea80e391 --- /dev/null +++ b/src/components/ecosystem/ecosystem-tab-indicator.tsx @@ -0,0 +1,108 @@ +/** + * EcosystemTabIndicator - 生态 Tab 页面指示器 + * + * 显示当前页面的图标指示器,支持双向绑定: + * - 滑动页面时更新图标 + * - 点击图标时切换页面 + */ + +import { useCallback, useMemo } from 'react' +import { IconApps, IconBrandMiniprogram, IconStack2 } from '@tabler/icons-react' +import { cn } from '@/lib/utils' +import type { EcosystemSubPage } from '@/stores/ecosystem' +import styles from './ecosystem-tab-indicator.module.css' + +export interface EcosystemTabIndicatorProps { + /** 当前页面 */ + activePage: EcosystemSubPage + /** 切换页面回调 */ + onPageChange?: (page: EcosystemSubPage) => void + /** 是否有运行中的应用(影响 stack 页是否可用) */ + 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, + onPageChange, + hasRunningApps = false, + className, +}: EcosystemTabIndicatorProps) { + // 计算可用页面 + const availablePages = useMemo(() => { + if (hasRunningApps) { + return PAGE_ORDER + } + // 没有运行中的应用时,stack 页不可用 + return PAGE_ORDER.filter((p) => p !== 'stack') + }, [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..7d42da09 100644 --- a/src/components/ecosystem/index.ts +++ b/src/components/ecosystem/index.ts @@ -35,3 +35,40 @@ 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' diff --git a/src/components/ecosystem/miniapp-capsule.module.css b/src/components/ecosystem/miniapp-capsule.module.css new file mode 100644 index 00000000..93a6d6b3 --- /dev/null +++ b/src/components/ecosystem/miniapp-capsule.module.css @@ -0,0 +1,92 @@ +/** + * MiniappCapsule 胶囊按钮样式 + * + * 模拟微信小程序的胶囊按钮设计 + */ + +.capsule { + position: absolute; + top: max(env(safe-area-inset-top, 0px), 8px); + right: 8px; + z-index: 20; + + display: flex; + align-items: center; + height: 32px; + padding: 0 4px; + + /* 胶囊外观 */ + background: rgba(0, 0, 0, 0.15); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border: 0.5px solid rgba(0, 0, 0, 0.08); + border-radius: 16px; + + /* 阴影 */ + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +/* 深色模式 */ +:global(.dark) .capsule { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.1); +} + +.button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 24px; + padding: 0; + margin: 0; + + background: transparent; + border: none; + cursor: pointer; + + color: rgba(0, 0, 0, 0.8); + transition: opacity 0.15s ease; +} + +.button:active { + opacity: 0.6; +} + +:global(.dark) .button { + color: rgba(255, 255, 255, 0.9); +} + +.icon { + width: 18px; + height: 18px; +} + +.divider { + width: 0.5px; + height: 16px; + background: rgba(0, 0, 0, 0.15); +} + +:global(.dark) .divider { + background: rgba(255, 255, 255, 0.2); +} + +/* 关闭图标 - 圆圈包围的点 */ +.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..21429469 --- /dev/null +++ b/src/components/ecosystem/miniapp-capsule.tsx @@ -0,0 +1,74 @@ +/** + * MiniappCapsule - 小程序胶囊按钮 + * + * 悬浮在小程序窗口右上角的胶囊形按钮组 + * 包含:多功能按钮(动态图标)+ 关闭按钮 + */ + +import { forwardRef } from 'react' +import { IconDots, IconPointFilled } from '@tabler/icons-react' +import { cn } from '@/lib/utils' +import styles from './miniapp-capsule.module.css' + +export interface MiniappCapsuleProps { + /** 多功能按钮的自定义图标 */ + actionIcon?: React.ReactNode + /** 多功能按钮点击回调 */ + onAction?: () => void + /** 关闭按钮点击回调 */ + onClose?: () => void + /** 是否显示 */ + visible?: boolean + /** 自定义类名 */ + className?: string +} + +export const MiniappCapsule = forwardRef( + function MiniappCapsule( + { + actionIcon, + onAction, + onClose, + visible = true, + className, + }, + ref + ) { + if (!visible) return null + + return ( +
+ {/* 多功能按钮 */} + + + {/* 分隔线 */} +
+ + {/* 关闭按钮 - 使用 IconPointFilled 模拟国内小程序的关闭图标 */} + +
+ ) + } +) + +export default MiniappCapsule 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..459348d5 --- /dev/null +++ b/src/components/ecosystem/miniapp-splash-screen.module.css @@ -0,0 +1,175 @@ +/** + * 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; + + /* 动画状态 */ + 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..c4462c6a --- /dev/null +++ b/src/components/ecosystem/miniapp-splash-screen.tsx @@ -0,0 +1,209 @@ +/** + * MiniappSplashScreen - 小程序启动屏幕 + * + * 使用基于应用 themeColor 的光晕渲染方案 + * 参考 IOSWallpaper 的实现,提供更柔和的启动体验 + */ + +import { useEffect, useMemo, useState } from 'react' +import { cn } from '@/lib/utils' +import styles from './miniapp-splash-screen.module.css' + +export interface MiniappSplashScreenProps { + /** 应用信息 */ + app: { + name: string + icon: string + /** 主题色,支持 hex、rgb、oklch 或直接传 hue 数值 */ + themeColor?: string | number + } + /** 是否可见 */ + visible: boolean + /** 是否播放呼吸动画 */ + animating?: boolean + /** 关闭回调 */ + onClose?: () => void + /** 自定义类名 */ + 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({ + app, + visible, + animating = true, + onClose: _onClose, + 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 ( +
+ {/* 光晕背景层 */} +
+
+
+ + {/* 内容区域 */} +
+ {/* 应用图标 */} +
+ {!imageError && ( + {app.name} setImageLoaded(true)} + onError={() => setImageError(true)} + style={{ opacity: imageLoaded ? 1 : 0 }} + /> + )} +
+ + {/* 加载指示器 */} + +
+ ) +} + +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.module.css b/src/components/ecosystem/miniapp-window.module.css new file mode 100644 index 00000000..89b1f003 --- /dev/null +++ b/src/components/ecosystem/miniapp-window.module.css @@ -0,0 +1,39 @@ +/** + * MiniappWindow 小程序窗口样式 + */ + +.window { + position: fixed; + top: 0; + left: 0; + right: 0; + /* 底部留出 TabBar 空间 */ + bottom: var(--tab-bar-height); + z-index: 100; + background: var(--background); + overflow: hidden; + + /* 变换原点:居中 */ + transform-origin: center center; + + /* 默认状态 */ + opacity: 1; +} + +.window.animating { + /* 动画期间禁用交互 */ + pointer-events: none; +} + +.iframeContainer { + position: absolute; + inset: 0; + transition: opacity 0.2s ease-out; +} + +.iframeContainer iframe { + width: 100%; + height: 100%; + border: none; + background: transparent; +} diff --git a/src/components/ecosystem/miniapp-window.tsx b/src/components/ecosystem/miniapp-window.tsx new file mode 100644 index 00000000..d92e9bd8 --- /dev/null +++ b/src/components/ecosystem/miniapp-window.tsx @@ -0,0 +1,156 @@ +/** + * MiniappWindow - 小程序窗口容器 + * + * 全局 Popover 组件,用于显示小程序内容 + * 支持 FLIP 动画进行启动/关闭变换 + */ + +import { useEffect, useRef, useCallback, useState } from 'react' +import { useStore } from '@tanstack/react-store' +import { cn } from '@/lib/utils' +import { + miniappRuntimeStore, + miniappRuntimeSelectors, + registerWindowRef, + playLaunchAnimation, + playCloseAnimation, + closeApp, + subscribe, +} from '@/services/miniapp-runtime' +import type { MiniappRuntimeEvent } from '@/services/miniapp-runtime' +import { MiniappSplashScreen } from './miniapp-splash-screen' +import { MiniappCapsule } from './miniapp-capsule' +import styles from './miniapp-window.module.css' + +export interface MiniappWindowProps { + className?: string +} + +export function MiniappWindow({ className }: MiniappWindowProps) { + const windowRef = useRef(null) + const iframeContainerRef = useRef(null) + const [isAnimating, setIsAnimating] = useState(false) + const [showSplash, setShowSplash] = useState(false) + + // 获取当前激活的应用 + const activeApp = useStore(miniappRuntimeStore, miniappRuntimeSelectors.getActiveApp) + const hasRunningApps = useStore(miniappRuntimeStore, miniappRuntimeSelectors.hasRunningApps) + + // 注册窗口 ref + useEffect(() => { + if (windowRef.current) { + registerWindowRef(windowRef.current) + } + }, []) + + // 挂载 iframe 到容器 + useEffect(() => { + if (!activeApp?.iframeRef || !iframeContainerRef.current) return + + const container = iframeContainerRef.current + const iframe = activeApp.iframeRef + + // 如果 iframe 不在容器中,移动过来 + if (iframe.parentElement !== container) { + container.appendChild(iframe) + } + + return () => { + // 组件卸载时不移除 iframe,由 runtime service 管理 + } + }, [activeApp?.iframeRef]) + + // 监听运行时事件 + useEffect(() => { + const unsubscribe = subscribe((event: MiniappRuntimeEvent) => { + switch (event.type) { + case 'app:launch': + setIsAnimating(true) + setShowSplash(true) + // 播放启动动画 + playLaunchAnimation(event.appId, () => { + setIsAnimating(false) + }) + break + + case 'app:close': + // 关闭动画由调用方处理 + break + + case 'app:state-change': + if (event.state === 'active') { + setShowSplash(false) + } + break + } + }) + + return unsubscribe + }, []) + + // 处理关闭 + const handleClose = useCallback(() => { + if (!activeApp) return + + setIsAnimating(true) + playCloseAnimation(activeApp.appId, () => { + setIsAnimating(false) + closeApp(activeApp.appId) + }) + }, [activeApp]) + + // 处理启动屏关闭 + const handleSplashClose = useCallback(() => { + setShowSplash(false) + }, []) + + // 如果没有运行中的应用,不渲染 + if (!hasRunningApps) { + return null + } + + return ( +
+ {/* 启动屏幕 */} + {activeApp && showSplash && ( + + )} + + {/* iframe 容器 */} +
+ + {/* 胶囊按钮 */} + { + // TODO: 显示更多操作菜单 + console.log('[MiniappWindow] Action button clicked') + }} + onClose={handleClose} + /> +
+ ) +} + +export default MiniappWindow diff --git a/src/components/ecosystem/my-apps-page.tsx b/src/components/ecosystem/my-apps-page.tsx index 06ed459d..3e1cfa97 100644 --- a/src/components/ecosystem/my-apps-page.tsx +++ b/src/components/ecosystem/my-apps-page.tsx @@ -4,8 +4,8 @@ import { cn } from '@/lib/utils'; 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 { registerIconRef, unregisterIconRef } from '@/services/miniapp-runtime'; // ============================================ // iOS 桌面图标(带 Popover 菜单) @@ -20,11 +20,22 @@ interface IOSDesktopIconProps { function IOSDesktopIcon({ app, onTap, onOpen, onDetail, onRemove }: IOSDesktopIconProps) { const popoverRef = useRef(null); + const iconRef = useRef(null); const longPressTimer = useRef | null>(null); const didLongPress = useRef(false); const [isOpen, setIsOpen] = useState(false); const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); + // 注册图标 ref 到 runtime service(用于 FLIP 动画) + useEffect(() => { + if (iconRef.current) { + registerIconRef(app.id, iconRef.current); + } + return () => { + unregisterIconRef(app.id); + }; + }, [app.id]); + const showMenu = () => { const popover = popoverRef.current; if (!popover) return; @@ -161,8 +172,9 @@ function IOSDesktopIcon({ app, onTap, onOpen, onDetail, onRemove }: IOSDesktopIc onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} onContextMenu={handleContextMenu} + data-testid={`ios-app-icon-${app.id}`} > -
+
+
{/* 顶部区域 - 搜索胶囊 */}
@@ -298,6 +310,6 @@ export function MyAppsPage({ apps, onSearchClick, onAppOpen, onAppDetail, onAppR {/* TabBar spacer */}
- +
); } diff --git a/src/services/miniapp-runtime/__tests__/flip-animator.test.ts b/src/services/miniapp-runtime/__tests__/flip-animator.test.ts new file mode 100644 index 00000000..50b94880 --- /dev/null +++ b/src/services/miniapp-runtime/__tests__/flip-animator.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest' +import { + getWindowFlipFrame, + computeInvertTransform, + generateKeyframes, +} from '../flip-animator' +import type { FlipFrames } from '../types' + +describe('flip-animator', () => { + describe('getWindowFlipFrame', () => { + it('returns full screen rect without safe area', () => { + const frame = getWindowFlipFrame() + + expect(frame.rect.x).toBe(0) + expect(frame.rect.y).toBe(0) + expect(frame.rect.width).toBe(window.innerWidth) + expect(frame.rect.height).toBe(window.innerHeight) + expect(frame.opacity).toBe(1) + expect(frame.borderRadius).toBe(0) + }) + + it('respects safe area insets', () => { + const insets = { top: 44, bottom: 34, left: 0, right: 0 } + const frame = getWindowFlipFrame(insets) + + expect(frame.rect.x).toBe(0) + expect(frame.rect.y).toBe(44) + expect(frame.rect.width).toBe(window.innerWidth) + expect(frame.rect.height).toBe(window.innerHeight - 44 - 34) + }) + }) + + describe('computeInvertTransform', () => { + it('calculates correct translation and scale', () => { + const frames: FlipFrames = { + first: { + rect: DOMRect.fromRect({ x: 100, y: 200, width: 60, height: 60 }), + opacity: 1, + borderRadius: 13, + }, + last: { + rect: DOMRect.fromRect({ x: 0, y: 0, width: 375, height: 812 }), + opacity: 1, + borderRadius: 0, + }, + duration: 400, + easing: 'ease', + } + + const invert = computeInvertTransform(frames) + + expect(invert.x).toBe(100) // 100 - 0 + expect(invert.y).toBe(200) // 200 - 0 + expect(invert.scaleX).toBeCloseTo(60 / 375) + expect(invert.scaleY).toBeCloseTo(60 / 812) + }) + }) + + describe('generateKeyframes', () => { + it('generates correct keyframes for launch animation', () => { + const frames: FlipFrames = { + first: { + rect: DOMRect.fromRect({ x: 50, y: 100, width: 60, height: 60 }), + opacity: 1, + borderRadius: 13, + }, + last: { + rect: DOMRect.fromRect({ x: 0, y: 0, width: 300, height: 600 }), + opacity: 1, + borderRadius: 0, + }, + duration: 400, + easing: 'ease', + } + + const keyframes = generateKeyframes(frames) + + expect(keyframes).toHaveLength(2) + + // First keyframe (inverted position) + expect(keyframes[0]).toHaveProperty('transform') + expect(keyframes[0]?.borderRadius).toBe('13px') + expect(keyframes[0]?.opacity).toBe(1) + + // Last keyframe (final position) + expect(keyframes[1]?.transform).toBe('translate(0, 0) scale(1, 1)') + expect(keyframes[1]?.borderRadius).toBe('0px') + expect(keyframes[1]?.opacity).toBe(1) + }) + }) +}) diff --git a/src/services/miniapp-runtime/flip-animator.ts b/src/services/miniapp-runtime/flip-animator.ts new file mode 100644 index 00000000..21fce49c --- /dev/null +++ b/src/services/miniapp-runtime/flip-animator.ts @@ -0,0 +1,253 @@ +/** + * FLIP Animator + * + * 计算 FLIP (First, Last, Invert, Play) 动画的帧数据 + * 用于小程序图标到窗口的平滑变换 + */ + +import type { FlipFrame, FlipFrames, AnimationConfig } from './types' +import { DEFAULT_ANIMATION_CONFIG } from './types' + +/** 图标默认圆角比例 (iOS App Icon) */ +const ICON_BORDER_RADIUS_RATIO = 0.22 + +/** + * 从 DOM 元素获取 FLIP 帧数据 + */ +export function getFlipFrame(element: HTMLElement): FlipFrame { + const rect = element.getBoundingClientRect() + const style = getComputedStyle(element) + + return { + rect: DOMRect.fromRect({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }), + opacity: parseFloat(style.opacity) || 1, + borderRadius: parseFloat(style.borderRadius) || 0, + } +} + +/** + * 获取图标元素的 FLIP 帧(带默认圆角) + */ +export function getIconFlipFrame(iconElement: HTMLElement): FlipFrame { + const frame = getFlipFrame(iconElement) + // 如果没有圆角,使用 iOS 默认比例 + if (frame.borderRadius === 0) { + frame.borderRadius = Math.round(frame.rect.width * ICON_BORDER_RADIUS_RATIO) + } + return frame +} + +/** + * 获取 TabBar 高度 + */ +function getTabBarHeight(): number { + if (typeof window === 'undefined') return 52 + const style = getComputedStyle(document.documentElement) + const height = style.getPropertyValue('--tab-bar-height') + // 解析 calc(52px + env(...)) 的结果 + if (!height) return 52 + // 创建临时元素来计算实际高度 + const temp = document.createElement('div') + temp.style.cssText = `position:fixed;height:var(--tab-bar-height);visibility:hidden;` + document.body.appendChild(temp) + const computed = temp.offsetHeight + document.body.removeChild(temp) + return computed || 52 +} + +/** + * 获取窗口目标帧(不覆盖底部 TabBar) + */ +export function getWindowFlipFrame(safeAreaInsets?: { + top: number + bottom: number + left: number + right: number +}): FlipFrame { + const insets = safeAreaInsets ?? { top: 0, bottom: 0, left: 0, right: 0 } + const tabBarHeight = getTabBarHeight() + + return { + rect: DOMRect.fromRect({ + x: insets.left, + y: insets.top, + width: window.innerWidth - insets.left - insets.right, + // 底部减去 tabbar 高度 + height: window.innerHeight - insets.top - tabBarHeight, + }), + opacity: 1, + borderRadius: 0, + } +} + +/** + * 计算启动动画帧 + * 从图标位置变换到全屏窗口 + */ +export function computeLaunchFrames( + iconElement: HTMLElement, + config: Partial = {} +): FlipFrames { + const animConfig = { ...DEFAULT_ANIMATION_CONFIG, ...config } + + return { + first: getIconFlipFrame(iconElement), + last: getWindowFlipFrame(), + duration: animConfig.launchDuration, + easing: animConfig.iosEasing, + } +} + +/** + * 计算关闭动画帧 + * 从全屏窗口变换回图标位置 + */ +export function computeCloseFrames( + iconElement: HTMLElement, + config: Partial = {} +): FlipFrames { + const animConfig = { ...DEFAULT_ANIMATION_CONFIG, ...config } + + return { + first: getWindowFlipFrame(), + last: getIconFlipFrame(iconElement), + duration: animConfig.closeDuration, + easing: animConfig.iosEasing, + } +} + +/** + * 计算 FLIP 反转变换 + * 用于将元素从 last 位置反转到 first 位置的 CSS transform + */ +export function computeInvertTransform(frames: FlipFrames): { + x: number + y: number + scaleX: number + scaleY: number +} { + const { first, last } = frames + + return { + x: first.rect.x - last.rect.x, + y: first.rect.y - last.rect.y, + scaleX: first.rect.width / last.rect.width, + scaleY: first.rect.height / last.rect.height, + } +} + +/** + * 生成 Web Animations API 的 keyframes + */ +export function generateKeyframes(frames: FlipFrames): Keyframe[] { + const invert = computeInvertTransform(frames) + + return [ + { + transform: `translate(${invert.x}px, ${invert.y}px) scale(${invert.scaleX}, ${invert.scaleY})`, + borderRadius: `${frames.first.borderRadius}px`, + opacity: frames.first.opacity, + }, + { + transform: 'translate(0, 0) scale(1, 1)', + borderRadius: `${frames.last.borderRadius}px`, + opacity: frames.last.opacity, + }, + ] +} + +/** + * 生成关闭动画的 keyframes(反向) + */ +export function generateCloseKeyframes(frames: FlipFrames): Keyframe[] { + const invert = computeInvertTransform(frames) + + return [ + { + transform: 'translate(0, 0) scale(1, 1)', + borderRadius: `${frames.first.borderRadius}px`, + opacity: frames.first.opacity, + }, + { + transform: `translate(${-invert.x}px, ${-invert.y}px) scale(${1 / invert.scaleX}, ${1 / invert.scaleY})`, + borderRadius: `${frames.last.borderRadius}px`, + opacity: frames.last.opacity, + }, + ] +} + +/** + * 播放 FLIP 动画 + */ +export function playFlipAnimation( + element: HTMLElement, + frames: FlipFrames, + options?: { + onFinish?: () => void + reverse?: boolean + } +): Animation { + const keyframes = options?.reverse + ? generateCloseKeyframes(frames) + : generateKeyframes(frames) + + const animation = element.animate(keyframes, { + duration: frames.duration, + easing: frames.easing, + fill: 'forwards', + }) + + if (options?.onFinish) { + animation.onfinish = options.onFinish + } + + return animation +} + +/** + * 创建同步的双层动画(窗口 + Swiper) + * 返回控制函数用于手势驱动 + */ +export function createSyncedAnimation( + windowElement: HTMLElement, + frames: FlipFrames +): { + animation: Animation + setProgress: (progress: number) => void + play: () => void + reverse: () => void + finish: () => void +} { + const keyframes = generateKeyframes(frames) + + const animation = windowElement.animate(keyframes, { + duration: frames.duration, + easing: frames.easing, + fill: 'both', + }) + + // 暂停动画,用于手势控制 + animation.pause() + + return { + animation, + setProgress: (progress: number) => { + // progress: 0-1 + animation.currentTime = progress * frames.duration + }, + play: () => { + animation.play() + }, + reverse: () => { + animation.reverse() + }, + finish: () => { + animation.finish() + }, + } +} diff --git a/src/services/miniapp-runtime/iframe-manager.ts b/src/services/miniapp-runtime/iframe-manager.ts new file mode 100644 index 00000000..b4aac79d --- /dev/null +++ b/src/services/miniapp-runtime/iframe-manager.ts @@ -0,0 +1,172 @@ +/** + * Iframe Manager + * + * 管理小程序 iframe 的生命周期 + * - 前台应用:正常渲染 + * - 后台应用:最多 4 个,visibility: hidden + * - 超出限制:从 DOM 移除 + */ + +import type { MiniappInstance } from './types' + +/** 最大后台 iframe 数量 */ +const MAX_BACKGROUND_IFRAMES = 4 + +/** iframe 容器 ID */ +const IFRAME_CONTAINER_ID = 'miniapp-iframe-container' +const HIDDEN_CONTAINER_ID = 'miniapp-hidden-container' + +/** + * 获取或创建 iframe 容器 + */ +function getOrCreateContainer(id: string, hidden: boolean): HTMLElement { + let container = document.getElementById(id) + if (!container) { + container = document.createElement('div') + container.id = id + if (hidden) { + container.style.cssText = ` + position: fixed; + top: -9999px; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; + visibility: hidden; + pointer-events: none; + ` + } + document.body.appendChild(container) + } + return container +} + +/** + * 创建小程序 iframe + */ +export function createIframe( + appId: string, + url: string, + contextParams?: Record +): HTMLIFrameElement { + const iframe = document.createElement('iframe') + iframe.id = `miniapp-iframe-${appId}` + iframe.dataset.appId = appId + + // 构建带参数的 URL + const iframeUrl = new URL(url, window.location.origin) + if (contextParams) { + Object.entries(contextParams).forEach(([key, value]) => { + iframeUrl.searchParams.set(key, value) + }) + } + iframe.src = iframeUrl.toString() + + // 安全沙箱 + iframe.sandbox.add('allow-scripts', 'allow-forms', 'allow-same-origin') + + // 样式 + iframe.style.cssText = ` + width: 100%; + height: 100%; + border: none; + background: transparent; + ` + + return iframe +} + +/** + * 将 iframe 挂载到可见容器 + */ +export function mountIframeVisible(iframe: HTMLIFrameElement): void { + const container = getOrCreateContainer(IFRAME_CONTAINER_ID, false) + container.appendChild(iframe) +} + +/** + * 将 iframe 移到隐藏容器(后台) + */ +export function moveIframeToBackground(iframe: HTMLIFrameElement): void { + const container = getOrCreateContainer(HIDDEN_CONTAINER_ID, true) + container.appendChild(iframe) +} + +/** + * 将 iframe 从隐藏容器移回可见 + */ +export function moveIframeToForeground(iframe: HTMLIFrameElement): void { + const container = getOrCreateContainer(IFRAME_CONTAINER_ID, false) + container.appendChild(iframe) +} + +/** + * 从 DOM 移除 iframe + */ +export function removeIframe(iframe: HTMLIFrameElement): void { + // 先清空 src 以停止加载 + iframe.src = 'about:blank' + iframe.remove() +} + +/** + * 根据 appId 查找 iframe + */ +export function findIframe(appId: string): HTMLIFrameElement | null { + return document.getElementById(`miniapp-iframe-${appId}`) as HTMLIFrameElement | null +} + +/** + * 管理后台 iframe 数量 + * 超出限制时移除最旧的 + */ +export function enforceBackgroundLimit( + apps: Map, + activeAppId: string | null, + maxBackground: number = MAX_BACKGROUND_IFRAMES +): string[] { + const removedAppIds: string[] = [] + + // 获取所有后台应用,按最后激活时间排序 + const backgroundApps = Array.from(apps.values()) + .filter((app) => app.appId !== activeAppId && app.state === 'background') + .sort((a, b) => a.lastActiveAt - b.lastActiveAt) + + // 移除超出限制的 + while (backgroundApps.length > maxBackground) { + const oldest = backgroundApps.shift() + if (oldest?.iframeRef) { + removeIframe(oldest.iframeRef) + oldest.iframeRef = null + removedAppIds.push(oldest.appId) + } + } + + return removedAppIds +} + +/** + * 清理所有 iframe + */ +export function cleanupAllIframes(): void { + const visibleContainer = document.getElementById(IFRAME_CONTAINER_ID) + const hiddenContainer = document.getElementById(HIDDEN_CONTAINER_ID) + + if (visibleContainer) { + visibleContainer.innerHTML = '' + visibleContainer.remove() + } + + if (hiddenContainer) { + hiddenContainer.innerHTML = '' + hiddenContainer.remove() + } +} + +/** + * 获取当前后台 iframe 数量 + */ +export function getBackgroundIframeCount(): number { + const container = document.getElementById(HIDDEN_CONTAINER_ID) + return container?.children.length ?? 0 +} diff --git a/src/services/miniapp-runtime/index.ts b/src/services/miniapp-runtime/index.ts new file mode 100644 index 00000000..1bbd7473 --- /dev/null +++ b/src/services/miniapp-runtime/index.ts @@ -0,0 +1,415 @@ +/** + * Miniapp Runtime Service + * + * 微型操作系统内核,管理小程序的生命周期和动画 + * + * 职责: + * - 管理应用状态(启动、激活、后台、关闭) + * - 管理 iframe 生命周期 + * - 计算 FLIP 动画帧 + * - 提供 DOM ref 管理 + */ + +import { Store } from '@tanstack/react-store' +export type { + MiniappInstance, + MiniappState, + MiniappRuntimeState, + MiniappRuntimeEvent, + MiniappRuntimeListener, + FlipFrames, + AnimationConfig, +} from './types' +import { DEFAULT_ANIMATION_CONFIG } from './types' +import { + computeLaunchFrames, + computeCloseFrames, + playFlipAnimation, + createSyncedAnimation, +} from './flip-animator' +import { + createIframe, + moveIframeToBackground, + moveIframeToForeground, + removeIframe, + enforceBackgroundLimit, + cleanupAllIframes, +} from './iframe-manager' +import type { MiniappManifest } from '../ecosystem/types' + +/** 初始状态 */ +const initialState: MiniappRuntimeState = { + apps: new Map(), + activeAppId: null, + isStackViewOpen: false, + maxBackgroundApps: 4, +} + +/** Store 实例 */ +export const miniappRuntimeStore = new Store(initialState) + +/** 事件监听器 */ +const listeners = new Set() + +/** 图标 ref 注册表 */ +const iconRefs = new Map() + +/** 窗口元素 ref */ +let windowRef: HTMLElement | null = null + +/** 动画配置 */ +let animationConfig: AnimationConfig = DEFAULT_ANIMATION_CONFIG + +/** + * 发送事件 + */ +function emit(event: MiniappRuntimeEvent): void { + listeners.forEach((listener) => listener(event)) +} + +/** + * 更新应用状态 + */ +function updateAppState(appId: string, state: MiniappState): void { + miniappRuntimeStore.setState((s) => { + const app = s.apps.get(appId) + if (!app) return s + + const newApps = new Map(s.apps) + newApps.set(appId, { ...app, state }) + + return { ...s, apps: newApps } + }) + + emit({ type: 'app:state-change', appId, state }) +} + +// ============================================ +// Public API +// ============================================ + +/** + * 注册图标元素引用 + */ +export function registerIconRef(appId: string, element: HTMLElement): void { + iconRefs.set(appId, element) +} + +/** + * 注销图标元素引用 + */ +export function unregisterIconRef(appId: string): void { + iconRefs.delete(appId) +} + +/** + * 获取图标元素引用 + */ +export function getIconRef(appId: string): HTMLElement | null { + return iconRefs.get(appId) ?? null +} + +/** + * 注册窗口元素引用 + */ +export function registerWindowRef(element: HTMLElement): void { + windowRef = element +} + +/** + * 获取窗口元素引用 + */ +export function getWindowRef(): HTMLElement | null { + return windowRef +} + +/** + * 设置动画配置 + */ +export function setAnimationConfig(config: Partial): void { + animationConfig = { ...animationConfig, ...config } +} + +/** + * 启动小程序 + */ +export function launchApp( + appId: string, + manifest: MiniappManifest, + contextParams?: Record +): MiniappInstance { + const state = miniappRuntimeStore.state + const existingApp = state.apps.get(appId) + + // 如果已存在,直接激活 + if (existingApp) { + activateApp(appId) + return existingApp + } + + // 创建新实例 + const instance: MiniappInstance = { + appId, + manifest, + state: 'launching', + launchedAt: Date.now(), + lastActiveAt: Date.now(), + iframeRef: null, + iconRef: iconRefs.get(appId) ?? null, + } + + // 创建 iframe + instance.iframeRef = createIframe(appId, manifest.url, contextParams) + + // 更新状态 + const newApps = new Map(state.apps) + newApps.set(appId, instance) + + miniappRuntimeStore.setState((s) => ({ + ...s, + apps: newApps, + activeAppId: appId, + })) + + emit({ type: 'app:launch', appId, manifest }) + + return instance +} + +/** + * 激活应用(从后台切换到前台) + */ +export function activateApp(appId: string): void { + const state = miniappRuntimeStore.state + const app = state.apps.get(appId) + if (!app) return + + // 当前激活的应用切换到后台 + if (state.activeAppId && state.activeAppId !== appId) { + deactivateApp(state.activeAppId) + } + + // 如果 iframe 在后台,移到前台 + if (app.iframeRef && app.state === 'background') { + moveIframeToForeground(app.iframeRef) + } + + // 更新状态 + updateAppState(appId, 'active') + + miniappRuntimeStore.setState((s) => { + const newApps = new Map(s.apps) + const existingApp = newApps.get(appId) + if (existingApp) { + newApps.set(appId, { ...existingApp, lastActiveAt: Date.now() }) + } + return { ...s, apps: newApps, activeAppId: appId } + }) + + emit({ type: 'app:activate', appId }) +} + +/** + * 将应用切换到后台 + */ +export function deactivateApp(appId: string): void { + const state = miniappRuntimeStore.state + const app = state.apps.get(appId) + if (!app) return + + // 移动 iframe 到后台 + if (app.iframeRef) { + moveIframeToBackground(app.iframeRef) + } + + // 更新状态 + updateAppState(appId, 'background') + + // 检查后台数量限制 + enforceBackgroundLimit( + miniappRuntimeStore.state.apps, + miniappRuntimeStore.state.activeAppId, + state.maxBackgroundApps + ) + + emit({ type: 'app:deactivate', appId }) +} + +/** + * 关闭应用 + */ +export function closeApp(appId: string): void { + const state = miniappRuntimeStore.state + const app = state.apps.get(appId) + if (!app) return + + // 更新状态为关闭中 + updateAppState(appId, 'closing') + + // 移除 iframe + if (app.iframeRef) { + removeIframe(app.iframeRef) + } + + // 从状态中移除 + miniappRuntimeStore.setState((s) => { + const newApps = new Map(s.apps) + newApps.delete(appId) + return { + ...s, + apps: newApps, + activeAppId: s.activeAppId === appId ? null : s.activeAppId, + } + }) + + emit({ type: 'app:close', appId }) +} + +/** + * 关闭所有应用 + */ +export function closeAllApps(): void { + const state = miniappRuntimeStore.state + state.apps.forEach((_, appId) => closeApp(appId)) + cleanupAllIframes() +} + +/** + * 获取图标位置(用于 FLIP 动画) + */ +export function getIconRect(appId: string): DOMRect | null { + const iconRef = iconRefs.get(appId) + return iconRef?.getBoundingClientRect() ?? null +} + +/** + * 获取窗口位置 + */ +export function getWindowRect(): DOMRect | null { + return windowRef?.getBoundingClientRect() ?? null +} + +/** + * 计算启动动画帧 + */ +export function computeAppLaunchFrames(appId: string): FlipFrames | null { + const iconRef = iconRefs.get(appId) + if (!iconRef) return null + + return computeLaunchFrames(iconRef, animationConfig) +} + +/** + * 计算关闭动画帧 + */ +export function computeAppCloseFrames(appId: string): FlipFrames | null { + const iconRef = iconRefs.get(appId) + if (!iconRef) return null + + return computeCloseFrames(iconRef, animationConfig) +} + +/** + * 播放启动动画 + */ +export function playLaunchAnimation( + appId: string, + onFinish?: () => void +): Animation | null { + if (!windowRef) return null + + const frames = computeAppLaunchFrames(appId) + if (!frames) return null + + return playFlipAnimation(windowRef, frames, onFinish ? { onFinish } : undefined) +} + +/** + * 播放关闭动画 + */ +export function playCloseAnimation( + appId: string, + onFinish?: () => void +): Animation | null { + if (!windowRef) return null + + const frames = computeAppCloseFrames(appId) + if (!frames) return null + + return playFlipAnimation(windowRef, frames, { reverse: true, ...(onFinish ? { onFinish } : {}) }) +} + +/** + * 创建可手势控制的动画 + */ +export function createGestureControlledAnimation( + appId: string +): ReturnType | null { + if (!windowRef) return null + + const frames = computeAppLaunchFrames(appId) + if (!frames) return null + + return createSyncedAnimation(windowRef, frames) +} + +/** + * 打开层叠视图 + */ +export function openStackView(): void { + miniappRuntimeStore.setState((s) => ({ ...s, isStackViewOpen: true })) + emit({ type: 'stack-view:open' }) +} + +/** + * 关闭层叠视图 + */ +export function closeStackView(): void { + miniappRuntimeStore.setState((s) => ({ ...s, isStackViewOpen: false })) + emit({ type: 'stack-view:close' }) +} + +/** + * 订阅事件 + */ +export function subscribe(listener: MiniappRuntimeListener): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +/** + * 获取所有运行中的应用 + */ +export function getRunningApps(): MiniappInstance[] { + return Array.from(miniappRuntimeStore.state.apps.values()) +} + +/** + * 获取当前激活的应用 + */ +export function getActiveApp(): MiniappInstance | null { + const state = miniappRuntimeStore.state + if (!state.activeAppId) return null + return state.apps.get(state.activeAppId) ?? null +} + +/** + * 检查是否有运行中的应用 + */ +export function hasRunningApps(): boolean { + return miniappRuntimeStore.state.apps.size > 0 +} + +// ============================================ +// Selectors +// ============================================ + +export const miniappRuntimeSelectors = { + getApps: (state: MiniappRuntimeState) => Array.from(state.apps.values()), + getActiveApp: (state: MiniappRuntimeState) => + state.activeAppId ? state.apps.get(state.activeAppId) ?? null : null, + hasRunningApps: (state: MiniappRuntimeState) => state.apps.size > 0, + isStackViewOpen: (state: MiniappRuntimeState) => state.isStackViewOpen, + getBackgroundApps: (state: MiniappRuntimeState) => + Array.from(state.apps.values()).filter((app) => app.state === 'background'), +} diff --git a/src/services/miniapp-runtime/types.ts b/src/services/miniapp-runtime/types.ts new file mode 100644 index 00000000..cd61e0e2 --- /dev/null +++ b/src/services/miniapp-runtime/types.ts @@ -0,0 +1,114 @@ +/** + * Miniapp Runtime Service Types + * + * 小程序运行时服务的类型定义 + */ + +import type { MiniappManifest } from '../ecosystem/types' + +/** 小程序实例状态 */ +export type MiniappState = 'launching' | 'active' | 'background' | 'closing' + +/** 小程序实例 */ +export interface MiniappInstance { + /** 应用 ID */ + appId: string + /** 应用清单 */ + manifest: MiniappManifest + /** 当前状态 */ + state: MiniappState + /** 启动时间 */ + launchedAt: number + /** 最后激活时间 */ + lastActiveAt: number + /** iframe 元素引用 */ + iframeRef: HTMLIFrameElement | null + /** 图标元素引用(用于 FLIP 动画) */ + iconRef: HTMLElement | null +} + +/** FLIP 动画帧数据 */ +export interface FlipFrame { + /** 元素位置和尺寸 */ + rect: DOMRect + /** 不透明度 */ + opacity: number + /** 圆角 */ + borderRadius: number +} + +/** FLIP 动画帧组 */ +export interface FlipFrames { + /** 起始帧 */ + first: FlipFrame + /** 结束帧 */ + last: FlipFrame + /** 动画持续时间 (ms) */ + duration: number + /** 动画曲线 */ + easing: string +} + +/** 运行时状态 */ +export interface MiniappRuntimeState { + /** 所有运行中的应用 */ + apps: Map + /** 当前激活的应用 ID */ + activeAppId: string | null + /** 是否处于层叠视图 */ + isStackViewOpen: boolean + /** 最大后台应用数 */ + maxBackgroundApps: number +} + +/** 运行时事件 */ +export type MiniappRuntimeEvent = + | { type: 'app:launch'; appId: string; manifest: MiniappManifest } + | { type: 'app:activate'; appId: string } + | { type: 'app:deactivate'; appId: string } + | { type: 'app:close'; appId: string } + | { type: 'app:state-change'; appId: string; state: MiniappState } + | { type: 'stack-view:open' } + | { type: 'stack-view:close' } + +/** 运行时事件监听器 */ +export type MiniappRuntimeListener = (event: MiniappRuntimeEvent) => void + +/** 动画配置 */ +export interface AnimationConfig { + /** 启动动画持续时间 */ + launchDuration: number + /** 关闭动画持续时间 */ + closeDuration: number + /** iOS 动画曲线 */ + iosEasing: string +} + +/** 默认动画配置 */ +export const DEFAULT_ANIMATION_CONFIG: AnimationConfig = { + launchDuration: 400, + closeDuration: 400, + iosEasing: 'cubic-bezier(0.25, 0.1, 0.25, 1.0)', +} + +/** 窗口位置配置 */ +export interface WindowConfig { + /** 窗口到屏幕边缘的边距 */ + margin: number + /** 窗口圆角 */ + borderRadius: number + /** 安全区域 */ + safeAreaInsets: { + top: number + bottom: number + left: number + right: number + } +} + +/** 默认窗口配置 */ +export const DEFAULT_WINDOW_CONFIG: WindowConfig = { + margin: 0, + borderRadius: 0, + safeAreaInsets: { top: 0, bottom: 0, left: 0, right: 0 }, +} diff --git a/src/stackflow/activities/tabs/EcosystemTab.tsx b/src/stackflow/activities/tabs/EcosystemTab.tsx index dd74fcdf..e6a22277 100644 --- a/src/stackflow/activities/tabs/EcosystemTab.tsx +++ b/src/stackflow/activities/tabs/EcosystemTab.tsx @@ -1,7 +1,9 @@ import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; import { Swiper, SwiperSlide } from 'swiper/react'; +import { Parallax } from 'swiper/modules'; import type { Swiper as SwiperType } from 'swiper'; import 'swiper/css'; +import { useStore } from '@tanstack/react-store'; import { useFlow } from '../../stackflow'; import { LoadingSpinner } from '@/components/common/loading-spinner'; import { @@ -15,11 +17,23 @@ import { type MyAppRecord, } from '@/services/ecosystem'; import type { MiniappManifest } from '@/services/ecosystem'; -import { DiscoverPage, MyAppsPage, type DiscoverPageRef } from '@/components/ecosystem'; +import { DiscoverPage, MyAppsPage, IOSWallpaper, EcosystemTabIndicator, type DiscoverPageRef } from '@/components/ecosystem'; +import { AppStackPage } from '@/components/ecosystem/app-stack-page'; import { computeFeaturedScore } from '@/services/ecosystem/scoring'; -import { ecosystemActions } from '@/stores/ecosystem'; +import { + ecosystemActions, + ECOSYSTEM_SUBPAGE_INDEX, + ECOSYSTEM_INDEX_SUBPAGE, + type EcosystemSubPage, +} from '@/stores/ecosystem'; +import { + miniappRuntimeStore, + miniappRuntimeSelectors, + launchApp, +} from '@/services/miniapp-runtime'; -type TabType = 'discover' | 'mine'; +/** Parallax 视差系数 */ +const PARALLAX_OFFSET = '-38.2%'; export function EcosystemTab() { const { push } = useFlow(); @@ -27,12 +41,21 @@ export function EcosystemTab() { const [myAppRecords, setMyAppRecords] = useState([]); const [loading, setLoading] = useState(true); + // 监听是否有运行中的应用(决定第三页是否可滑动) + const hasRunningApps = useStore( + miniappRuntimeStore, + miniappRuntimeSelectors.hasRunningApps + ); + // 从 localStorage 读取上次的 tab - const [activeTab, setActiveTab] = useState(() => { + const [activeTab, setActiveTab] = useState(() => { try { - const saved = localStorage.getItem('ecosystem_active_tab'); + const saved = localStorage.getItem('ecosystem_active_tab') as EcosystemSubPage | null; + // 如果保存的是 'stack' 但没有运行中的应用,回退到 'mine' + if (saved === 'stack') { + return 'mine'; + } const tab = saved === 'mine' ? 'mine' : 'discover'; - // 同步到 store ecosystemActions.setActiveSubPage(tab); return tab; } catch { @@ -67,12 +90,19 @@ export function EcosystemTab() { // Swiper 事件 const handleSlideChange = useCallback((swiper: SwiperType) => { - const newTab = swiper.activeIndex === 0 ? 'discover' : 'mine'; + const newTab = ECOSYSTEM_INDEX_SUBPAGE[swiper.activeIndex] ?? 'discover'; setActiveTab(newTab); - // 同步到 store(用于 TabBar 图标切换) ecosystemActions.setActiveSubPage(newTab); }, []); + // 控制第三页是否可滑动 + const handleSlideChangeTransitionStart = useCallback((swiper: SwiperType) => { + // 如果没有运行中的应用,禁止滑动到第三页 + if (!hasRunningApps && swiper.activeIndex === 2) { + swiper.slideTo(1, 0); + } + }, [hasRunningApps]); + // 搜索胶囊点击:滑到发现页 + 聚焦搜索框 const handleSearchClick = useCallback(() => { swiperRef.current?.slideTo(0); @@ -82,6 +112,12 @@ export function EcosystemTab() { }, 300); }, []); + // 指示器切换页面 + const handleIndicatorPageChange = useCallback((page: EcosystemSubPage) => { + const index = ECOSYSTEM_SUBPAGE_INDEX[page]; + swiperRef.current?.slideTo(index); + }, []); + // App 操作 const handleAppDetail = useCallback( (app: MiniappManifest) => { @@ -95,9 +131,14 @@ export function EcosystemTab() { addToMyApps(app.id); updateLastUsed(app.id); setMyAppRecords(loadMyApps()); - push('MiniappActivity', { appId: app.id }); + + // 使用 runtime service 启动应用 + launchApp(app.id, app); + + // 滑动到应用堆栈页 + swiperRef.current?.slideTo(ECOSYSTEM_SUBPAGE_INDEX.stack); }, - [push], + [], ); const handleAppRemove = useCallback((appId: string) => { @@ -143,39 +184,71 @@ export function EcosystemTab() { } return ( -
+
{ swiperRef.current = swiper; }} onSlideChange={handleSlideChange} + onSlideChangeTransitionStart={handleSlideChangeTransitionStart} resistanceRatio={0.5} + allowSlideNext={activeTab !== 'mine' || hasRunningApps} > + {/* Parallax 共享壁纸 - 三页共享 */} +
+ +
+ {/* 发现页 - 负一屏 */} - +
+ +
{/* 我的页 - iOS 桌面 */} - ({ app, lastUsed }))} - onSearchClick={handleSearchClick} - onAppOpen={handleAppOpen} - onAppDetail={handleAppDetail} - onAppRemove={handleAppRemove} - /> +
+ ({ app, lastUsed: lastUsedAt }))} + onSearchClick={handleSearchClick} + onAppOpen={handleAppOpen} + onAppDetail={handleAppDetail} + onAppRemove={handleAppRemove} + /> +
+
+ + {/* 应用堆栈页 - 第三页 */} + +
+ +
+ + {/* Tab 指示器 - 固定在底部 TabBar 上方 */} +
+ +
); } diff --git a/src/stackflow/components/TabBar.tsx b/src/stackflow/components/TabBar.tsx index bb423bb0..58c8255c 100644 --- a/src/stackflow/components/TabBar.tsx +++ b/src/stackflow/components/TabBar.tsx @@ -1,15 +1,21 @@ import { cn } from "@/lib/utils"; -import { useMemo } from "react"; +import { useMemo, useRef, useCallback } from "react"; import { IconWallet, IconSettings, IconApps, IconBrandMiniprogram, + IconAppWindowFilled, type Icon, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useStore } from "@tanstack/react-store"; import { ecosystemStore } from "@/stores/ecosystem"; +import { + miniappRuntimeStore, + miniappRuntimeSelectors, + openStackView, +} from "@/services/miniapp-runtime"; // 3个tab:钱包、生态、设置 export type TabId = "wallet" | "ecosystem" | "settings"; @@ -29,9 +35,21 @@ interface TabBarProps { export function TabBar({ activeTab, onTabChange, className }: TabBarProps) { const { t } = useTranslation('common'); const ecosystemSubPage = useStore(ecosystemStore, (s) => s.activeSubPage); + const hasRunningApps = useStore(miniappRuntimeStore, (s) => miniappRuntimeSelectors.getApps(s).length > 0); - // 生态 tab 图标:发现用 IconApps,我的用 IconBrandMiniprogram - const ecosystemIcon = ecosystemSubPage === 'mine' ? IconBrandMiniprogram : IconApps; + // 生态 tab 图标: + // - 在"应用堆栈"页或有运行中应用时:IconAppWindowFilled + // - 在"我的"页:IconBrandMiniprogram + // - 在"发现"页:IconApps + const ecosystemIcon = useMemo(() => { + if (ecosystemSubPage === 'stack' || hasRunningApps) { + return IconAppWindowFilled; + } + if (ecosystemSubPage === 'mine') { + return IconBrandMiniprogram; + } + return IconApps; + }, [ecosystemSubPage, hasRunningApps]); const tabConfigs: Tab[] = useMemo(() => [ { id: "wallet", label: t('a11y.tabWallet'), icon: IconWallet }, @@ -39,6 +57,33 @@ export function TabBar({ activeTab, onTabChange, className }: TabBarProps) { { id: "settings", label: t('a11y.tabSettings'), icon: IconSettings }, ], [t, ecosystemIcon]); + // 生态按钮上滑手势检测 + const touchState = useRef({ startY: 0, startTime: 0 }); + const SWIPE_THRESHOLD = 30; + const SWIPE_VELOCITY = 0.3; + + const handleEcosystemTouchStart = useCallback((e: React.TouchEvent) => { + const touch = e.touches[0]; + if (touch) { + touchState.current = { startY: touch.clientY, startTime: Date.now() }; + } + }, []); + + const handleEcosystemTouchEnd = useCallback((e: React.TouchEvent) => { + const touch = e.changedTouches[0]; + if (!touch) return; + + const deltaY = touchState.current.startY - touch.clientY; + const deltaTime = Date.now() - touchState.current.startTime; + const velocity = deltaY / deltaTime; + + // 检测上滑手势:需要有运行中的应用才能打开层叠视图 + if (hasRunningApps && (deltaY > SWIPE_THRESHOLD || velocity > SWIPE_VELOCITY)) { + e.preventDefault(); + openStackView(); + } + }, [hasRunningApps]); + return (
onTabChange(tab.id)} + onTouchStart={isEcosystem ? handleEcosystemTouchStart : undefined} + onTouchEnd={isEcosystem ? handleEcosystemTouchEnd : undefined} data-testid={`tab-${tab.id}`} className={cn( "flex flex-1 flex-col items-center justify-center gap-1 transition-colors", - isActive ? "text-primary" : "text-muted-foreground" + isActive ? "text-primary" : "text-muted-foreground", + // 如果有运行中的应用,生态按钮添加小红点指示 + isEcosystem && hasRunningApps && "relative" )} aria-label={label} aria-current={isActive ? "page" : undefined} > - +
+ + {/* 运行中应用指示器 */} + {isEcosystem && hasRunningApps && ( + + )} +
{label} ); diff --git a/src/stores/ecosystem.ts b/src/stores/ecosystem.ts index 40dae7b0..097ddad1 100644 --- a/src/stores/ecosystem.ts +++ b/src/stores/ecosystem.ts @@ -21,7 +21,17 @@ export interface SourceRecord { } /** Ecosystem 子页面类型 */ -export type EcosystemSubPage = 'discover' | 'mine' +export type EcosystemSubPage = 'discover' | 'mine' | 'stack' + +/** 子页面索引映射 */ +export const ECOSYSTEM_SUBPAGE_INDEX: Record = { + discover: 0, + mine: 1, + stack: 2, +} + +/** 索引到子页面映射 */ +export const ECOSYSTEM_INDEX_SUBPAGE: EcosystemSubPage[] = ['discover', 'mine', 'stack'] /** Ecosystem 状态 */ export interface EcosystemState {