Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/white-book/00-必读/best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 22 additions & 10 deletions e2e/ecosystem-miniapp.mock.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import { test, expect } from '@playwright/test'
import { test, expect, type Page } from '@playwright/test'

/**
* Bio 小程序生态 E2E 截图测试
*
* 测试用户故事并生成截图验证 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: [
{
Expand Down Expand Up @@ -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')
Expand All @@ -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
Expand All @@ -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)

// 右键菜单 -> 打开
Expand All @@ -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)

// 右键菜单 -> 详情
Expand Down Expand Up @@ -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)

// 右键菜单 -> 移除
Expand Down
25 changes: 24 additions & 1 deletion src/StackflowApp.tsx
Original file line number Diff line number Diff line change
@@ -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 <Stack />;
const isStackViewOpen = useStore(
miniappRuntimeStore,
miniappRuntimeSelectors.isStackViewOpen
);

return (
<>
<Stack />
{/* 小程序窗口 - 全局 Popover 层 */}
<MiniappWindow />
{/* 层叠视图 - 多应用管理 */}
<MiniappStackView
visible={isStackViewOpen}
onClose={closeStackView}
/>
</>
);
}
43 changes: 43 additions & 0 deletions src/components/ecosystem/app-stack-page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={cn(
'relative h-full w-full',
// 背景板样式 - 透明,让 Parallax 壁纸显示
'bg-transparent',
className
)}
data-testid="app-stack-page"
data-has-apps={hasRunningApps}
>
{/* 空状态提示(调试用,生产环境可移除) */}
{!hasRunningApps && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0">
<p className="text-muted-foreground text-sm">应用堆栈</p>
</div>
)}
</div>
)
}

export default AppStackPage
77 changes: 77 additions & 0 deletions src/components/ecosystem/ecosystem-tab-indicator.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
108 changes: 108 additions & 0 deletions src/components/ecosystem/ecosystem-tab-indicator.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
type="button"
onClick={handleClick}
className={cn(styles.indicator, className)}
aria-label={`当前:${label},点击切换`}
data-testid="ecosystem-tab-indicator"
>
{/* 图标容器 - 带 crossfade 动画 */}
<div className={styles.iconWrapper}>
<Icon className={styles.icon} stroke={1.5} />
</div>

{/* 页面点指示器 */}
<div className={styles.dots}>
{availablePages.map((page, index) => (
<span
key={page}
className={cn(
styles.dot,
index === activeIndex && styles.dotActive
)}
/>
))}
</div>
</button>
)
}

export default EcosystemTabIndicator
37 changes: 37 additions & 0 deletions src/components/ecosystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Loading