From ce52bf1912213aaa89dd96f7faee198b7415e6ff Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 20 Jan 2026 23:33:46 +0800 Subject: [PATCH 1/2] fix(build): key-utils as pure TS package + fix remote miniapp icon URLs - Change key-utils to export src/*.ts directly (no build needed) - Fix remote miniapp icon/screenshot URL handling with proper URL check - Absolute URLs (http/https) are preserved, relative paths use new URL() --- packages/key-utils/package.json | 18 ++++++++---------- scripts/vite-plugin-miniapps.ts | 7 ++++--- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/key-utils/package.json b/packages/key-utils/package.json index 43179acbc..7cd87170d 100644 --- a/packages/key-utils/package.json +++ b/packages/key-utils/package.json @@ -3,23 +3,21 @@ "version": "0.1.0", "description": "Utility functions for KeyApp UI components", "type": "module", - "main": "./dist/index.cjs", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", + "main": "./src/index.ts", + "module": "./src/index.ts", + "types": "./src/index.ts", "exports": { ".": { - "import": "./dist/index.js", - "require": "./dist/index.cjs", - "types": "./dist/index.d.ts" + "import": "./src/index.ts", + "types": "./src/index.ts" }, "./*": { - "import": "./dist/*.js", - "require": "./dist/*.cjs", - "types": "./dist/*.d.ts" + "import": "./src/*.ts", + "types": "./src/*.ts" } }, "files": [ - "dist" + "src" ], "scripts": { "build": "vite build", diff --git a/scripts/vite-plugin-miniapps.ts b/scripts/vite-plugin-miniapps.ts index 168dec972..e35e670bf 100644 --- a/scripts/vite-plugin-miniapps.ts +++ b/scripts/vite-plugin-miniapps.ts @@ -305,12 +305,13 @@ function scanRemoteMiniappsForBuild(miniappsPath: string): Array `./${entry.name}/${s}`) ?? [], + url: baseUrl, + icon: manifest.icon.startsWith('http') ? manifest.icon : new URL(manifest.icon, baseUrl).href, + screenshots: manifest.screenshots?.map((s) => (s.startsWith('http') ? s : new URL(s, baseUrl).href)) ?? [], }); } catch { console.warn(`[miniapps] ${entry.name}: invalid remote manifest.json, skipping`); From 4b3591a67aca45626035b16c78d8b0c4c5979a3b Mon Sep 17 00:00:00 2001 From: Gaubee Date: Wed, 21 Jan 2026 01:47:50 +0800 Subject: [PATCH 2/2] fix(ecosystem): restore slider position memory with proper Swiper sync lifecycle - Rewrite swiper-sync-context with ready-based lifecycle management - Controller connection only established after both Swipers are ready - slideTo with runCallbacks=false to prevent triggering slideChange during init - onSlideChange callback only fires after ready state is set - Remove controller prop from Swiper, use native API for connection - Fix setAvailableSubPages to not override activeSubPage - Fix key-ui package to export TS source directly like other key-* packages --- packages/key-ui/package.json | 31 +-- src/components/common/swiper-sync-context.tsx | 162 +++++++------- .../ecosystem/ecosystem-desktop.tsx | 101 +++++---- src/components/ecosystem/swiper-sync-demo.tsx | 68 +++--- src/stackflow/components/TabBar.tsx | 148 ++++++------ src/stores/ecosystem.ts | 210 +++++++++--------- 6 files changed, 363 insertions(+), 357 deletions(-) diff --git a/packages/key-ui/package.json b/packages/key-ui/package.json index 954dca3d5..cbdd231b3 100644 --- a/packages/key-ui/package.json +++ b/packages/key-ui/package.json @@ -3,39 +3,32 @@ "version": "0.1.0", "description": "Shared UI components for KeyApp and MiniApps", "type": "module", - "main": "./dist/index.cjs", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", + "main": "./src/index.ts", + "module": "./src/index.ts", + "types": "./src/index.ts", "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" + "import": "./src/index.ts", + "types": "./src/index.ts" }, "./address-display": { - "types": "./dist/address-display/index.d.ts", - "import": "./dist/address-display/index.js", - "require": "./dist/address-display/index.cjs" + "import": "./src/address-display/index.ts", + "types": "./src/address-display/index.ts" }, "./amount-display": { - "types": "./dist/amount-display/index.d.ts", - "import": "./dist/amount-display/index.js", - "require": "./dist/amount-display/index.cjs" + "import": "./src/amount-display/index.ts", + "types": "./src/amount-display/index.ts" }, "./token-icon": { - "types": "./dist/token-icon/index.d.ts", - "import": "./dist/token-icon/index.js", - "require": "./dist/token-icon/index.cjs" + "import": "./src/token-icon/index.ts", + "types": "./src/token-icon/index.ts" }, "./styles": "./src/styles/base.css" }, "files": [ - "dist", - "src/styles" + "src" ], "scripts": { - "build": "vite build", - "dev": "vite build --watch", "typecheck": "tsc --noEmit", "typecheck:run": "tsc --noEmit", "test": "vitest", diff --git a/src/components/common/swiper-sync-context.tsx b/src/components/common/swiper-sync-context.tsx index 5bdccb190..83adaace9 100644 --- a/src/components/common/swiper-sync-context.tsx +++ b/src/components/common/swiper-sync-context.tsx @@ -1,14 +1,7 @@ -/** - * Swiper 同步 Context - * - * 简单存储 Swiper 实例,让组件使用 Controller 模块自动同步 - */ - import { createContext, useContext, useRef, - useState, useCallback, useEffect, type ReactNode, @@ -16,18 +9,18 @@ import { } from 'react'; import type { Swiper as SwiperType } from 'swiper'; -/** Swiper 同步 Context 值 */ +interface SwiperMemberState { + swiper: SwiperType; + initialIndex: number; +} + interface SwiperSyncContextValue { - /** Swiper 实例注册表 */ - swipersRef: MutableRefObject>; - /** 监听器注册表 */ - listenersRef: MutableRefObject void>>; - /** 注册 Swiper */ - register: (id: string, swiper: SwiperType) => void; - /** 注销 Swiper */ + membersRef: MutableRefObject>; + readyPairsRef: MutableRefObject>; + register: (id: string, state: SwiperMemberState) => void; unregister: (id: string) => void; - /** 订阅变更 */ - subscribe: (listener: () => void) => () => void; + tryConnect: (selfId: string, targetId: string) => void; + isReady: (selfId: string, targetId: string) => boolean; } const SwiperSyncContext = createContext(null); @@ -36,87 +29,100 @@ interface SwiperSyncProviderProps { children: ReactNode; } -/** - * Swiper 同步 Provider - */ +const getPairKey = (id1: string, id2: string) => [id1, id2].sort().join(':'); + export function SwiperSyncProvider({ children }: SwiperSyncProviderProps) { - const swipersRef = useRef>(new Map()); - const listenersRef = useRef void>>(new Set()); + const membersRef = useRef>(new Map()); + const readyPairsRef = useRef>(new Set()); - const notify = useCallback(() => { - listenersRef.current.forEach(listener => listener()); + const register = useCallback((id: string, state: SwiperMemberState) => { + membersRef.current.set(id, state); }, []); - const register = useCallback((id: string, swiper: SwiperType) => { - swipersRef.current.set(id, swiper); - notify(); - }, [notify]); - const unregister = useCallback((id: string) => { - swipersRef.current.delete(id); - notify(); - }, [notify]); + const state = membersRef.current.get(id); + if (state?.swiper && !state.swiper.destroyed && state.swiper.controller) { + state.swiper.controller.control = undefined; + } + membersRef.current.delete(id); + readyPairsRef.current.forEach((key) => { + if (key.includes(id)) readyPairsRef.current.delete(key); + }); + }, []); - const subscribe = useCallback((listener: () => void) => { - listenersRef.current.add(listener); - return () => listenersRef.current.delete(listener); + const tryConnect = useCallback((selfId: string, targetId: string) => { + const pairKey = getPairKey(selfId, targetId); + if (readyPairsRef.current.has(pairKey)) return; + + const self = membersRef.current.get(selfId); + const target = membersRef.current.get(targetId); + + if (!self || !target) return; + if (self.swiper.destroyed || target.swiper.destroyed) return; + + // 先标记 ready,再执行操作,这样 slideTo 触发的 onSlideChange 能通过 isReady 检查 + readyPairsRef.current.add(pairKey); + + self.swiper.controller.control = target.swiper; + target.swiper.controller.control = self.swiper; + + self.swiper.slideTo(self.initialIndex, 0, false); + target.swiper.slideTo(target.initialIndex, 0, false); + }, []); + + const isReady = useCallback((selfId: string, targetId: string) => { + return readyPairsRef.current.has(getPairKey(selfId, targetId)); }, []); return ( - + {children} ); } -/** - * 注册 Swiper 并获取要控制的其他 Swiper - * - * 用法: - * ```tsx - * const { onSwiper, controlledSwiper } = useSwiperMember('main', 'indicator'); - * - * - * ``` - */ -export function useSwiperMember(selfId: string, targetId: string) { +interface UseSwiperMemberOptions { + initialIndex: number; + onSlideChange?: (swiper: SwiperType) => void; +} + +export function useSwiperMember(selfId: string, targetId: string, options: UseSwiperMemberOptions) { const ctx = useContext(SwiperSyncContext); if (!ctx) { throw new Error('useSwiperMember must be used within SwiperSyncProvider'); } - const [controlledSwiper, setControlledSwiper] = useState(() => { - const target = ctx.swipersRef.current.get(targetId); - return target && !target.destroyed ? target : undefined; - }); + const optionsRef = useRef(options); + optionsRef.current = options; - const onSwiper = useCallback((swiper: SwiperType) => { - ctx.register(selfId, swiper); - }, [ctx, selfId]); + const ctxRef = useRef(ctx); + ctxRef.current = ctx; + + const selfIdRef = useRef(selfId); + selfIdRef.current = selfId; + + const onSwiper = useCallback( + (swiper: SwiperType) => { + ctx.register(selfId, { + swiper, + initialIndex: optionsRef.current.initialIndex, + }); + ctx.tryConnect(selfId, targetId); + }, + [ctx, selfId, targetId], + ); + + const onSlideChange = useCallback( + (swiper: SwiperType) => { + if (!ctx.isReady(selfId, targetId)) return; + optionsRef.current.onSlideChange?.(swiper); + }, + [ctx, selfId, targetId], + ); - // 订阅变更,当目标 Swiper 注册时更新 useEffect(() => { - const updateTarget = () => { - const target = ctx.swipersRef.current.get(targetId); - const validTarget = target && !target.destroyed ? target : undefined; - setControlledSwiper(prev => prev !== validTarget ? validTarget : prev); - }; - - // 初始检查 - updateTarget(); - - // 订阅变更 - return ctx.subscribe(updateTarget); - }, [ctx, targetId]); - - return { - onSwiper, - controlledSwiper, - }; -} + return () => ctxRef.current.unregister(selfIdRef.current); + }, []); -export type { SwiperSyncContextValue }; + return { onSwiper, onSlideChange }; +} diff --git a/src/components/ecosystem/ecosystem-desktop.tsx b/src/components/ecosystem/ecosystem-desktop.tsx index 5a16f8d1c..f78075499 100644 --- a/src/components/ecosystem/ecosystem-desktop.tsx +++ b/src/components/ecosystem/ecosystem-desktop.tsx @@ -1,6 +1,6 @@ /** * EcosystemDesktop - 生态系统桌面组件 - * + * * 灵活配置的三页式桌面:发现页 | 我的页 | 应用堆栈页 * 支持动态开关页面,壁纸宽度自适应 */ @@ -14,7 +14,7 @@ import { useSwiperMember } from '@/components/common/swiper-sync-context'; import { DiscoverPage, MyAppsPage, IOSWallpaper, type DiscoverPageRef } from '@/components/ecosystem'; import { AppStackPage } from '@/components/ecosystem/app-stack-page'; import { MiniappWindowStack } from '@/components/ecosystem/miniapp-window-stack'; -import { ecosystemActions, type EcosystemSubPage } from '@/stores/ecosystem'; +import { ecosystemActions, ecosystemStore, type EcosystemSubPage } from '@/stores/ecosystem'; import { miniappRuntimeStore, miniappRuntimeSelectors } from '@/services/miniapp-runtime'; import type { MiniappManifest } from '@/services/ecosystem'; @@ -59,7 +59,8 @@ export interface EcosystemDesktopCallbacks { onAppRemove: (appId: string) => void; } -export interface EcosystemDesktopProps extends EcosystemDesktopConfig, EcosystemDesktopData, EcosystemDesktopCallbacks {} +export interface EcosystemDesktopProps + extends EcosystemDesktopConfig, EcosystemDesktopData, EcosystemDesktopCallbacks {} /** 桌面控制句柄 */ export interface EcosystemDesktopHandle { @@ -136,26 +137,42 @@ export const EcosystemDesktop = forwardRef state.activeSubPage); + + // 计算初始滑动索引(优先级:props > store 保存的 > 默认值) const initialSlideIndex = useMemo(() => { - const page = initialPage ?? (showDiscoverPage ? 'discover' : 'mine'); + const page = initialPage ?? savedActiveSubPage ?? (showDiscoverPage ? 'discover' : 'mine'); const idx = availablePages.indexOf(page); return idx >= 0 ? idx : 0; - }, [initialPage, showDiscoverPage, availablePages]); + }, [initialPage, savedActiveSubPage, showDiscoverPage, availablePages]); // 使用 Swiper 同步 hook - const { onSwiper: syncOnSwiper, controlledSwiper } = useSwiperMember('ecosystem-main', 'ecosystem-indicator'); + const { onSwiper: syncOnSwiper, onSlideChange: syncOnSlideChange } = useSwiperMember( + 'ecosystem-main', + 'ecosystem-indicator', + { + initialIndex: initialSlideIndex, + onSlideChange: (swiper) => { + const newPage = availablePages[swiper.activeIndex] ?? 'mine'; + currentPageRef.current = newPage; + ecosystemActions.setActiveSubPage(newPage); + }, + }, + ); // 注册主 Swiper - const handleMainSwiper = useCallback((swiper: SwiperType) => { - swiperRef.current = swiper; - syncOnSwiper(swiper); - - const page = availablePages[swiper.activeIndex] ?? 'mine'; - currentPageRef.current = page; - ecosystemActions.setActiveSubPage(page); - ecosystemActions.setAvailableSubPages(availablePages); - }, [syncOnSwiper, availablePages]); + const handleMainSwiper = useCallback( + (swiper: SwiperType) => { + swiperRef.current = swiper; + syncOnSwiper(swiper); + + const page = availablePages[initialSlideIndex] ?? 'mine'; + currentPageRef.current = page; + ecosystemActions.setAvailableSubPages(availablePages); + }, + [syncOnSwiper, availablePages, initialSlideIndex], + ); // 当可用页面列表变化时,同步到 store,避免指示器与页面不一致 useEffect(() => { @@ -173,16 +190,12 @@ export const EcosystemDesktop = forwardRef { - ecosystemActions.setSwiperProgress(progress * (pageCount - 1)); - }, [pageCount]); - - // Swiper 滑动事件 - const handleSlideChange = useCallback((swiper: SwiperType) => { - const newPage = availablePages[swiper.activeIndex] ?? 'mine'; - currentPageRef.current = newPage; - ecosystemActions.setActiveSubPage(newPage); - }, [availablePages]); + const handleProgress = useCallback( + (_: SwiperType, progress: number) => { + ecosystemActions.setSwiperProgress(progress * (pageCount - 1)); + }, + [pageCount], + ); // 搜索胶囊点击:滑到发现页 + 聚焦搜索框 const handleSearchClick = useCallback(() => { @@ -195,19 +208,23 @@ export const EcosystemDesktop = forwardRef ({ - slideTo: (page: EcosystemSubPage) => { - const idx = availablePages.indexOf(page); - if (idx >= 0) swiperRef.current?.slideTo(idx); - }, - focusSearch: () => { - if (showDiscoverPage) { - discoverPageRef.current?.focusSearch(); - } - }, - getCurrentPage: () => currentPageRef.current, - getSwiper: () => swiperRef.current, - }), [availablePages, showDiscoverPage]); + useImperativeHandle( + ref, + () => ({ + slideTo: (page: EcosystemSubPage) => { + const idx = availablePages.indexOf(page); + if (idx >= 0) swiperRef.current?.slideTo(idx); + }, + focusSearch: () => { + if (showDiscoverPage) { + discoverPageRef.current?.focusSearch(); + } + }, + getCurrentPage: () => currentPageRef.current, + getSwiper: () => swiperRef.current, + }), + [availablePages, showDiscoverPage], + ); // 精选应用第一个 const featuredApp = featuredApps[0]; @@ -217,11 +234,9 @@ export const EcosystemDesktop = forwardRef @@ -279,5 +294,5 @@ export const EcosystemDesktop = forwardRef ); - } + }, ); diff --git a/src/components/ecosystem/swiper-sync-demo.tsx b/src/components/ecosystem/swiper-sync-demo.tsx index a6d753cff..ca061db2b 100644 --- a/src/components/ecosystem/swiper-sync-demo.tsx +++ b/src/components/ecosystem/swiper-sync-demo.tsx @@ -1,6 +1,6 @@ /** * Swiper 双向绑定 Demo - * + * * - Controller: 原理展示(同组件内直接使用 Controller 模块) * - ContextMode: 封装展示(跨组件使用 Context + Controller) */ @@ -39,7 +39,7 @@ export function SwiperSyncDemo() { {/* 主 Swiper */} -
+
{t('demo.swiper.main')}
( {page} @@ -60,11 +60,11 @@ export function SwiperSyncDemo() {
{/* 指示器 Swiper */} -
+
{t('demo.swiper.indicator')}
{PAGES.map((page, index) => ( -
- {index + 1} +
+ {index + 1}
))} @@ -89,7 +89,7 @@ export function SwiperSyncDemo() { @@ -102,34 +102,30 @@ export function SwiperSyncDemo() { /** * 方案三:Context 封装模式(使用 Controller 模块) */ -import { - SwiperSyncProvider, - useSwiperMember, -} from '@/components/common/swiper-sync-context'; +import { SwiperSyncProvider, useSwiperMember } from '@/components/common/swiper-sync-context'; /** 主 Swiper 组件 */ function MainSwiperWithContext() { const { t } = useTranslation('ecosystem'); - // 自己是 'main',要控制 'indicator' - const { onSwiper, controlledSwiper } = useSwiperMember('main', 'indicator'); const [progress, setProgress] = useState(0); + const { onSwiper, onSlideChange } = useSwiperMember('main', 'indicator', { + initialIndex: 0, + }); return ( -
-
- {t('demo.swiper.mainIndependent')} -
+
+
{t('demo.swiper.mainIndependent')}
setProgress(p)} > {PAGES.map((page) => ( {page} @@ -137,10 +133,10 @@ function MainSwiperWithContext() { {/* 调试信息 */} -
+
{t('demo.swiper.debugCombined', { progress: progress.toFixed(3), - index: Math.round(progress * (PAGES.length - 1)) + index: Math.round(progress * (PAGES.length - 1)), })}
@@ -150,12 +146,12 @@ function MainSwiperWithContext() { /** 指示器 Swiper 组件 */ function IndicatorSwiperWithContext() { const { t } = useTranslation('ecosystem'); - // 自己是 'indicator',要控制 'main' - const { onSwiper, controlledSwiper } = useSwiperMember('indicator', 'main'); const [progress, setProgress] = useState(0); const maxIndex = PAGES.length - 1; + const { onSwiper, onSlideChange } = useSwiperMember('indicator', 'main', { + initialIndex: 0, + }); - // 计算图标透明度 const getOpacity = (index: number) => { const currentIndex = progress * maxIndex; const distance = Math.abs(currentIndex - index); @@ -163,16 +159,14 @@ function IndicatorSwiperWithContext() { }; return ( -
-
- {t('demo.swiper.indicatorIndependent')} -
+
+
{t('demo.swiper.indicatorIndependent')}
setProgress(p)} slidesPerView={1} resistance={true} @@ -181,10 +175,10 @@ function IndicatorSwiperWithContext() { {PAGES.map((page, index) => (
- {index + 1} + {index + 1}
))} @@ -198,8 +192,8 @@ function IndicatorSwiperWithContext() { ); @@ -217,9 +211,7 @@ export function SwiperSyncDemoContext() {

{t('demo.swiper.method3')}

-

- {t('demo.swiper.method3Desc')} -

+

{t('demo.swiper.method3Desc')}

diff --git a/src/stackflow/components/TabBar.tsx b/src/stackflow/components/TabBar.tsx index 8c65a6379..7bdcb06d1 100644 --- a/src/stackflow/components/TabBar.tsx +++ b/src/stackflow/components/TabBar.tsx @@ -1,5 +1,5 @@ -import { cn } from "@/lib/utils"; -import { useMemo, useState, useCallback, useRef, useEffect } from "react"; +import { cn } from '@/lib/utils'; +import { useMemo, useState, useCallback, useRef, useEffect } from 'react'; import { IconWallet, IconSettings, @@ -7,21 +7,17 @@ import { IconBrandMiniprogram, IconAppWindowFilled, type Icon, -} from "@tabler/icons-react"; +} from '@tabler/icons-react'; import { Swiper, SwiperSlide } from 'swiper/react'; import { Controller } from 'swiper/modules'; import 'swiper/css'; -import { useTranslation } from "react-i18next"; -import { useStore } from "@tanstack/react-store"; -import { useSwiperMember } from "@/components/common/swiper-sync-context"; -import { ecosystemStore, type EcosystemSubPage } from "@/stores/ecosystem"; -import { - miniappRuntimeStore, - miniappRuntimeSelectors, - openStackView, -} from "@/services/miniapp-runtime"; -import { usePendingTransactions } from "@/hooks/use-pending-transactions"; -import { useCurrentWallet } from "@/stores"; +import { useTranslation } from 'react-i18next'; +import { useStore } from '@tanstack/react-store'; +import { useSwiperMember } from '@/components/common/swiper-sync-context'; +import { ecosystemStore, type EcosystemSubPage } from '@/stores/ecosystem'; +import { miniappRuntimeStore, miniappRuntimeSelectors, openStackView } from '@/services/miniapp-runtime'; +import { usePendingTransactions } from '@/hooks/use-pending-transactions'; +import { useCurrentWallet } from '@/stores'; /** 生态页面顺序 */ const ECOSYSTEM_PAGE_ORDER: EcosystemSubPage[] = ['discover', 'mine', 'stack']; @@ -35,37 +31,45 @@ const PAGE_ICONS: Record = { /** 生态页面指示器(真实项目使用) - 使用 Controller 模块实现双向绑定 */ function useEcosystemIndicator(availablePages: EcosystemSubPage[], isActive: boolean) { - const pageCount = availablePages.length - const maxIndex = pageCount - 1 + const pageCount = availablePages.length; + const maxIndex = pageCount - 1; + + const swiperRef = useRef(null); - const { onSwiper, controlledSwiper } = useSwiperMember('ecosystem-indicator', 'ecosystem-main') + const initialSlideIndex = useMemo(() => { + const savedPage = ecosystemStore.state.activeSubPage; + const idx = availablePages.indexOf(savedPage); + return idx >= 0 ? idx : 0; + }, [availablePages]); - const swiperRef = useRef(null) + const { onSwiper, onSlideChange } = useSwiperMember('ecosystem-indicator', 'ecosystem-main', { + initialIndex: initialSlideIndex, + }); useEffect(() => { if (swiperRef.current) { - swiperRef.current.allowTouchMove = isActive + swiperRef.current.allowTouchMove = isActive; } - }, [isActive]) + }, [isActive]); - const [progress, setProgress] = useState(0) - const indexProgress = maxIndex > 0 ? progress * maxIndex : 0 + const [progress, setProgress] = useState(0); + const indexProgress = maxIndex > 0 ? progress * maxIndex : 0; const getIconOpacity = (index: number) => { - const distance = Math.abs(indexProgress - index) - return Math.max(0, 1 - distance) - } + const distance = Math.abs(indexProgress - index); + return Math.max(0, 1 - distance); + }; return { icon: ( { - swiperRef.current = swiper - onSwiper(swiper) + swiperRef.current = swiper; + onSwiper(swiper); }} + onSlideChange={onSlideChange} onProgress={(_, p) => setProgress(p)} slidesPerView="auto" centeredSlides={true} @@ -75,44 +79,44 @@ function useEcosystemIndicator(availablePages: EcosystemSubPage[], isActive: boo resistanceRatio={0.5} > {availablePages.map((page, index) => { - const PageIcon = PAGE_ICONS[page] - const opacity = getIconOpacity(index) + const PageIcon = PAGE_ICONS[page]; + const opacity = getIconOpacity(index); return ( - + - ) + ); })} ), label: ( -
+
{availablePages.map((page, index) => { - const isActiveDot = Math.round(indexProgress) === index + const isActiveDot = Math.round(indexProgress) === index; return ( - ) + ); })}
), - } + }; } // 3个tab:钱包、生态、设置 -export type TabId = "wallet" | "ecosystem" | "settings"; +export type TabId = 'wallet' | 'ecosystem' | 'settings'; interface Tab { id: TabId; @@ -144,7 +148,7 @@ export function TabBar({ activeTab, onTabChange, className }: TabBarProps) { // 生态指示器(图标slider + 分页点) const availablePages = useMemo(() => { if (storeAvailablePages?.length) return storeAvailablePages; - return hasRunningStackApps ? ECOSYSTEM_PAGE_ORDER : ECOSYSTEM_PAGE_ORDER.filter(p => p !== 'stack'); + return hasRunningStackApps ? ECOSYSTEM_PAGE_ORDER : ECOSYSTEM_PAGE_ORDER.filter((p) => p !== 'stack'); }, [storeAvailablePages, hasRunningStackApps]); const ecosystemIndicator = useEcosystemIndicator(availablePages, isEcosystemActive); @@ -163,11 +167,14 @@ export function TabBar({ activeTab, onTabChange, className }: TabBarProps) { return IconApps; }, [ecosystemSubPage, hasRunningApps]); - const tabConfigs: Tab[] = useMemo(() => [ - { id: "wallet", label: t('a11y.tabWallet'), icon: IconWallet }, - { id: "ecosystem", label: t('a11y.tabEcosystem'), icon: ecosystemIcon }, - { id: "settings", label: t('a11y.tabSettings'), icon: IconSettings }, - ], [t, ecosystemIcon]); + const tabConfigs: Tab[] = useMemo( + () => [ + { id: 'wallet', label: t('a11y.tabWallet'), icon: IconWallet }, + { id: 'ecosystem', label: t('a11y.tabEcosystem'), icon: ecosystemIcon }, + { id: 'settings', label: t('a11y.tabSettings'), icon: IconSettings }, + ], + [t, ecosystemIcon], + ); // 生态按钮上滑手势检测 const touchState = useRef({ startY: 0, startTime: 0 }); @@ -181,29 +188,32 @@ export function TabBar({ activeTab, onTabChange, className }: TabBarProps) { } }, []); - const handleEcosystemTouchEnd = useCallback((e: React.TouchEvent) => { - const touch = e.changedTouches[0]; - if (!touch) return; + 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; + 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]); + // 检测上滑手势:需要有运行中的应用才能打开层叠视图 + if (hasRunningApps && (deltaY > SWIPE_THRESHOLD || velocity > SWIPE_VELOCITY)) { + e.preventDefault(); + openStackView(); + } + }, + [hasRunningApps], + ); return (
@@ -222,11 +232,11 @@ export function TabBar({ activeTab, onTabChange, className }: TabBarProps) { 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", + 'flex flex-1 flex-col items-center justify-center gap-1 transition-colors', + isActive ? 'text-primary' : 'text-muted-foreground', )} aria-label={label} - aria-current={isActive ? "page" : undefined} + aria-current={isActive ? 'page' : undefined} > {/* 图标区域 */}
@@ -234,15 +244,15 @@ export function TabBar({ activeTab, onTabChange, className }: TabBarProps) { // 生态 Tab 始终使用 Swiper 渲染,减少 DOM 抖动 ecosystemIndicator.icon ) : ( - + )} {/* 运行中应用指示器(红点) */} {isEcosystem && hasRunningApps && !isActive && ( - + )} {/* Pending transactions badge */} {tab.id === 'wallet' && pendingTxCount > 0 && ( - + {pendingTxCount > 9 ? '9+' : pendingTxCount} )} @@ -261,4 +271,4 @@ export function TabBar({ activeTab, onTabChange, className }: TabBarProps) { ); } -export const tabIds: TabId[] = ["wallet", "ecosystem", "settings"]; +export const tabIds: TabId[] = ['wallet', 'ecosystem', 'settings']; diff --git a/src/stores/ecosystem.ts b/src/stores/ecosystem.ts index f58b2b021..6f2155ee1 100644 --- a/src/stores/ecosystem.ts +++ b/src/stores/ecosystem.ts @@ -3,76 +3,81 @@ * 存储小程序生态系统状态:权限、订阅源等 */ -import { Store } from '@tanstack/react-store' -import { type MyAppRecord } from '@/services/ecosystem/types' -import { loadMyApps, normalizeAppId, saveMyApps } from '@/services/ecosystem/my-apps' +import { Store } from '@tanstack/react-store'; +import { type MyAppRecord } from '@/services/ecosystem/types'; +import { loadMyApps, normalizeAppId, saveMyApps } from '@/services/ecosystem/my-apps'; /** 权限记录 */ export interface PermissionRecord { - appId: string - granted: string[] - grantedAt: number + appId: string; + granted: string[]; + grantedAt: number; } /** 订阅源记录 */ export interface SourceRecord { - url: string - name: string - lastUpdated: string - enabled: boolean + url: string; + name: string; + lastUpdated: string; + enabled: boolean; } /** Ecosystem 子页面类型 */ -export type EcosystemSubPage = 'discover' | 'mine' | 'stack' +export type EcosystemSubPage = 'discover' | 'mine' | 'stack'; /** 默认可用子页面(不包含 stack,由桌面根据运行态启用) */ -const DEFAULT_AVAILABLE_SUBPAGES: EcosystemSubPage[] = ['discover', 'mine'] +const DEFAULT_AVAILABLE_SUBPAGES: EcosystemSubPage[] = ['discover', 'mine']; /** 子页面索引映射 */ export const ECOSYSTEM_SUBPAGE_INDEX: Record = { discover: 0, mine: 1, stack: 2, -} +}; /** 索引到子页面映射 */ -export const ECOSYSTEM_INDEX_SUBPAGE: EcosystemSubPage[] = ['discover', 'mine', 'stack'] +export const ECOSYSTEM_INDEX_SUBPAGE: EcosystemSubPage[] = ['discover', 'mine', 'stack']; /** 同步控制源 */ -export type SyncSource = 'swiper' | 'indicator' | null +export type SyncSource = 'swiper' | 'indicator' | null; /** Ecosystem 状态 */ export interface EcosystemState { - permissions: PermissionRecord[] - sources: SourceRecord[] - myApps: MyAppRecord[] + permissions: PermissionRecord[]; + sources: SourceRecord[]; + myApps: MyAppRecord[]; /** 当前可用子页面(由 EcosystemDesktop 根据配置/运行态写入) */ - availableSubPages: EcosystemSubPage[] + availableSubPages: EcosystemSubPage[]; /** 当前子页面(发现/我的) */ - activeSubPage: EcosystemSubPage + activeSubPage: EcosystemSubPage; /** Swiper 滑动进度 (0-2 for 3 pages) */ - swiperProgress: number + swiperProgress: number; /** 当前同步控制源(用于双向绑定) */ - syncSource: SyncSource + syncSource: SyncSource; } -const STORAGE_KEY = 'ecosystem_store' +const STORAGE_KEY = 'ecosystem_store'; + +function arraysEqual(a: T[], b: T[]): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]); +} /** 从 localStorage 加载状态 */ function loadState(): EcosystemState { try { - const stored = localStorage.getItem(STORAGE_KEY) + const stored = localStorage.getItem(STORAGE_KEY); if (stored) { - const parsed = JSON.parse(stored) as Partial + const parsed = JSON.parse(stored) as Partial; - const availableSubPages = Array.isArray(parsed.availableSubPages) && parsed.availableSubPages.length > 0 - ? (parsed.availableSubPages as EcosystemSubPage[]) - : DEFAULT_AVAILABLE_SUBPAGES + const availableSubPages = + Array.isArray(parsed.availableSubPages) && parsed.availableSubPages.length > 0 + ? (parsed.availableSubPages as EcosystemSubPage[]) + : DEFAULT_AVAILABLE_SUBPAGES; - const activeSubPage = (parsed.activeSubPage ?? 'discover') as EcosystemSubPage + const activeSubPage = (parsed.activeSubPage ?? 'discover') as EcosystemSubPage; const fixedAvailableSubPages = availableSubPages.includes(activeSubPage) ? availableSubPages - : [...availableSubPages, activeSubPage] + : [...availableSubPages, activeSubPage]; return { permissions: parsed.permissions ?? [], @@ -89,7 +94,7 @@ function loadState(): EcosystemState { activeSubPage, swiperProgress: 0, syncSource: null, - } + }; } } catch { // ignore @@ -109,128 +114,123 @@ function loadState(): EcosystemState { activeSubPage: 'discover', swiperProgress: 0, syncSource: null, - } + }; } /** 保存状态到 localStorage */ function saveState(state: EcosystemState): void { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify({ - permissions: state.permissions, - sources: state.sources, - availableSubPages: state.availableSubPages, - activeSubPage: state.activeSubPage, - // myApps is saved separately - })) - saveMyApps(state.myApps) + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + permissions: state.permissions, + sources: state.sources, + availableSubPages: state.availableSubPages, + activeSubPage: state.activeSubPage, + // myApps is saved separately + }), + ); + saveMyApps(state.myApps); } catch { // ignore } } /** Store 实例 */ -export const ecosystemStore = new Store(loadState()) +export const ecosystemStore = new Store(loadState()); // 自动保存 ecosystemStore.subscribe(() => { - saveState(ecosystemStore.state) -}) + saveState(ecosystemStore.state); +}); /** Selectors */ export const ecosystemSelectors = { /** 获取应用的已授权权限 */ getGrantedPermissions: (state: EcosystemState, appId: string): string[] => { - return state.permissions.find((p) => p.appId === appId)?.granted ?? [] + return state.permissions.find((p) => p.appId === appId)?.granted ?? []; }, /** 检查是否有特定权限 */ hasPermission: (state: EcosystemState, appId: string, permission: string): boolean => { - const granted = ecosystemSelectors.getGrantedPermissions(state, appId) - return granted.includes(permission) + const granted = ecosystemSelectors.getGrantedPermissions(state, appId); + return granted.includes(permission); }, /** 获取启用的订阅源 */ getEnabledSources: (state: EcosystemState): SourceRecord[] => { - return state.sources.filter((s) => s.enabled) + return state.sources.filter((s) => s.enabled); }, /** 检查应用是否已安装 */ isAppInstalled: (state: EcosystemState, appId: string): boolean => { - const normalized = normalizeAppId(appId) - return state.myApps.some((a) => a.appId === normalized) + const normalized = normalizeAppId(appId); + return state.myApps.some((a) => a.appId === normalized); }, -} +}; /** Actions */ export const ecosystemActions = { /** 安装应用 */ installApp: (appId: string): void => { ecosystemStore.setState((state) => { - const normalized = normalizeAppId(appId) + const normalized = normalizeAppId(appId); if (state.myApps.some((a) => a.appId === normalized)) { - return state // 已安装 + return state; // 已安装 } return { ...state, - myApps: [ - { appId: normalized, installedAt: Date.now(), lastUsedAt: Date.now() }, - ...state.myApps, - ], - } - }) + myApps: [{ appId: normalized, installedAt: Date.now(), lastUsedAt: Date.now() }, ...state.myApps], + }; + }); }, /** 卸载应用 */ uninstallApp: (appId: string): void => { ecosystemStore.setState((state) => { - const normalized = normalizeAppId(appId) + const normalized = normalizeAppId(appId); return { ...state, myApps: state.myApps.filter((a) => a.appId !== normalized), - } - }) + }; + }); }, /** 更新应用最后使用时间 */ updateAppLastUsed: (appId: string): void => { ecosystemStore.setState((state) => { - const normalized = normalizeAppId(appId) - const existing = state.myApps.find((a) => a.appId === normalized) - if (!existing) return state + const normalized = normalizeAppId(appId); + const existing = state.myApps.find((a) => a.appId === normalized); + if (!existing) return state; return { ...state, - myApps: state.myApps.map((a) => - a.appId === normalized ? { ...a, lastUsedAt: Date.now() } : a - ), - } - }) + myApps: state.myApps.map((a) => (a.appId === normalized ? { ...a, lastUsedAt: Date.now() } : a)), + }; + }); }, /** 授予权限 */ grantPermissions: (appId: string, permissions: string[]): void => { ecosystemStore.setState((state) => { - const existing = state.permissions.find((p) => p.appId === appId) + const existing = state.permissions.find((p) => p.appId === appId); if (existing) { // 合并权限 - const newGranted = [...new Set([...existing.granted, ...permissions])] + const newGranted = [...new Set([...existing.granted, ...permissions])]; return { ...state, permissions: state.permissions.map((p) => - p.appId === appId ? { ...p, granted: newGranted, grantedAt: Date.now() } : p + p.appId === appId ? { ...p, granted: newGranted, grantedAt: Date.now() } : p, ), - } + }; } else { // 新增记录 return { ...state, - permissions: [ - ...state.permissions, - { appId, granted: permissions, grantedAt: Date.now() }, - ], - } + permissions: [...state.permissions, { appId, granted: permissions, grantedAt: Date.now() }], + }; } - }) + }); }, /** 撤销权限 */ @@ -241,34 +241,29 @@ export const ecosystemActions = { return { ...state, permissions: state.permissions.filter((p) => p.appId !== appId), - } + }; } // 撤销指定权限 return { ...state, permissions: state.permissions.map((p) => - p.appId === appId - ? { ...p, granted: p.granted.filter((g) => !permissions.includes(g)) } - : p + p.appId === appId ? { ...p, granted: p.granted.filter((g) => !permissions.includes(g)) } : p, ), - } - }) + }; + }); }, /** 添加订阅源 */ addSource: (url: string, name: string): void => { ecosystemStore.setState((state) => { if (state.sources.some((s) => s.url === url)) { - return state // 已存在 + return state; // 已存在 } return { ...state, - sources: [ - ...state.sources, - { url, name, lastUpdated: new Date().toISOString(), enabled: true }, - ], - } - }) + sources: [...state.sources, { url, name, lastUpdated: new Date().toISOString(), enabled: true }], + }; + }); }, /** 移除订阅源 */ @@ -276,27 +271,23 @@ export const ecosystemActions = { ecosystemStore.setState((state) => ({ ...state, sources: state.sources.filter((s) => s.url !== url), - })) + })); }, /** 切换订阅源启用状态 */ toggleSource: (url: string): void => { ecosystemStore.setState((state) => ({ ...state, - sources: state.sources.map((s) => - s.url === url ? { ...s, enabled: !s.enabled } : s - ), - })) + sources: state.sources.map((s) => (s.url === url ? { ...s, enabled: !s.enabled } : s)), + })); }, /** 更新订阅源时间 */ updateSourceTimestamp: (url: string): void => { ecosystemStore.setState((state) => ({ ...state, - sources: state.sources.map((s) => - s.url === url ? { ...s, lastUpdated: new Date().toISOString() } : s - ), - })) + sources: state.sources.map((s) => (s.url === url ? { ...s, lastUpdated: new Date().toISOString() } : s)), + })); }, /** 设置当前子页面 */ @@ -304,20 +295,19 @@ export const ecosystemActions = { ecosystemStore.setState((state) => ({ ...state, activeSubPage: subPage, - })) + })); }, /** 设置当前可用子页面(由桌面配置驱动) */ setAvailableSubPages: (subPages: EcosystemSubPage[]): void => { ecosystemStore.setState((state) => { - const next = subPages.length > 0 ? subPages : DEFAULT_AVAILABLE_SUBPAGES - const activeSubPage = next.includes(state.activeSubPage) ? state.activeSubPage : next[0] ?? 'mine' + const next = subPages.length > 0 ? subPages : DEFAULT_AVAILABLE_SUBPAGES; + if (arraysEqual(state.availableSubPages, next)) return state; return { ...state, availableSubPages: next, - activeSubPage, - } - }) + }; + }); }, /** 更新 Swiper 进度 */ @@ -325,7 +315,7 @@ export const ecosystemActions = { ecosystemStore.setState((state) => ({ ...state, swiperProgress: progress, - })) + })); }, /** 设置同步控制源 */ @@ -333,6 +323,6 @@ export const ecosystemActions = { ecosystemStore.setState((state) => ({ ...state, syncSource: source, - })) + })); }, -} +};