From c18d9c840437bdb96df46480500e902b65a891af Mon Sep 17 00:00:00 2001 From: YoungestSongMo <2130460579@qq.com> Date: Tue, 9 Jun 2026 18:43:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E4=BD=93=E7=B3=BB=20=E2=80=94=20=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E4=BA=92=E6=96=A5=20+=20=E5=8A=A8=E6=80=81=E5=B0=BA?= =?UTF-8?q?=E5=AF=B8=20+=20=E8=B4=A6=E6=88=B7=E4=BF=A1=E6=81=AF=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PageDispatcher 重构: - singleton + floatingPanels[] 双状态 → 单一 activePanel 状态 - 全局互斥:已打开面板时禁止打开新面板(保留拖拽/图片拖放等互操作) - 占位组件预计算为模块级 Map,消除 ESLint static-components 误报 - PAGE_MAP 支持 width/height 配置,动态函数计算(视口比例自适应) - 容器默认尺寸改为动态:Modal=min(w*0.55,720) Drawer=min(w*0.38,520) - 修复 FloatingPanel 未接收 width/height 配置的问题 Test2 账户信息页: - AuthAPI.getUserInfo() → 三卡片布局(积分/套餐/今日统计) - 加载态 Skeleton / 错误态 Result+重试 - centToYuan 积分转换、formatDate 日期格式化 目录调整:wechat-work/ → headers/ Co-Authored-By: Claude Opus 4.8 --- src/components/PageDispatcher.tsx | 169 ++++++++------ src/pages/headers/Test1.tsx | 9 + src/pages/headers/Test2.tsx | 210 ++++++++++++++++++ src/pages/headers/Test3.tsx | 5 + .../WechatWorkPage.tsx | 0 5 files changed, 320 insertions(+), 73 deletions(-) create mode 100644 src/pages/headers/Test1.tsx create mode 100644 src/pages/headers/Test2.tsx create mode 100644 src/pages/headers/Test3.tsx rename src/pages/{wechat-work => headers}/WechatWorkPage.tsx (100%) diff --git a/src/components/PageDispatcher.tsx b/src/components/PageDispatcher.tsx index ee06041..42cdc52 100644 --- a/src/components/PageDispatcher.tsx +++ b/src/components/PageDispatcher.tsx @@ -6,19 +6,22 @@ // - drawer → antd Drawer(侧滑抽屉) // - floating → FloatingPanel(非模态浮动面板,可拖拽,不阻断交互) // -// 支持多面板共存(floating 类型可同时打开多个) +// 全局互斥:同一时刻最多打开一个页面组件。 +// 已打开时不响应 OPEN_PAGE 事件(保留面板内互操作能力:拖拽、图片拖放等)。 // // PAGE_MAP 集中注册所有可调度页面: -// 有真实组件 → { component: XxxPage } -// 占位页面 → 只写 label,自动渲染 PlaceholderPage +// 有真实组件 → { component: XxxPage, label: '...', width?: number } +// 占位页面 → 只写 label,自动渲染 "页面开发中" +// 懒加载页面 → component: React.lazy(() => import('...')) // 新增页面只需在这里加一行,无需创建独立文件 // ============================================================ -import { useState, useEffect, useCallback, useRef, type ComponentType } from 'react'; -import { Modal, Drawer } from 'antd'; -import { on, off, EVENTS } from '@/utils/event-bus'; +import { type ComponentType, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Drawer, Modal } from 'antd'; +import { EVENTS, off, on } from '@/utils/event-bus'; import { FloatingPanel } from '@/components/ui/FloatingPanel'; -import { WechatWorkPage } from '@/pages/wechat-work/WechatWorkPage'; +import { WechatWorkPage } from '@/pages/headers/WechatWorkPage'; +import { Test2 } from '@/pages/headers/Test2'; // ---------- 类型 ---------- @@ -30,20 +33,24 @@ interface PageEventPayload { label: string; } -interface ActivePanel { +/** 当前活跃面板(id 用于 key / 关闭定位) */ +interface ActivePanel extends PageEventPayload { id: string; - target: string; - label: string; } interface PageConfig { - /** 页面组件(未提供则使用占位组件) */ + /** 页面组件(未提供则使用自动生成的占位组件)。 + * 支持 React.lazy(() => import('...')) 懒加载。 */ component?: ComponentType; /** 页面标签(显示在容器标题栏) */ label: string; + /** 容器宽度(px)。也支持函数动态计算:() => window.innerWidth * 0.6 */ + width?: number | (() => number); + /** 容器高度(px),仅 FloatingPanel 生效。也支持函数动态计算 */ + height?: number | (() => number); } -// ---------- 占位组件 ---------- +// ---------- 占位组件(模块级,引用稳定,避免渲染中创建组件) ---------- function PlaceholderPage({ label }: { label: string }) { return ( @@ -53,22 +60,24 @@ function PlaceholderPage({ label }: { label: string }) { ); } -/** 创建一个包装了 label 的稳定占位组件(模块级,避免渲染时重建) */ -function makePlaceholder(label: string): ComponentType { - const Placeholder = () => ; - return Placeholder; +/** 为每个占位页预创建稳定组件引用 */ +function createPlaceholder(label: string): ComponentType { + const C = () => ; + C.displayName = `Placeholder_${label}`; + return C; } // ---------- target → 页面组件映射 ---------- -// -// 新增页面只需在此加一行 —— -// 有自定义组件:{ component: XxxPage, label: '页面标题' } -// 占位页面: { label: '页面标题' } ← 自动渲染 "页面标题 — 页面开发中" const PAGE_MAP: Record = { '/wechat-work': { component: WechatWorkPage, label: '企业微信' }, '/compliance-assets': { label: '合规素材库' }, - '/account': { label: '查帐户' }, + '/account': { + component: Test2, + label: '账户', + width: () => Math.min(window.innerWidth * 0.6, 720), + height: () => Math.min(window.innerHeight * 0.6, 680), + }, '/vip': { label: '开会员/激活' }, '/recharge': { label: '充值积分' }, '/billing': { label: '消费记录' }, @@ -77,21 +86,33 @@ const PAGE_MAP: Record = { '/quota': { label: '查配额' }, }; -/** 解析页面组件:有自定义组件用自定义,否则用占位 */ +// 预计算所有占位组件(模块加载时执行一次,引用永久稳定) +const RESOLVED_PAGE_MAP = new Map(); +for (const [key, config] of Object.entries(PAGE_MAP)) { + RESOLVED_PAGE_MAP.set(key, config.component || createPlaceholder(config.label)); +} + +/** 解析页面组件(纯查表,不创建新组件) */ function resolvePage(target: string): ComponentType | null { - const config = PAGE_MAP[target]; - if (!config) return null; - return config.component || makePlaceholder(config.label); + return RESOLVED_PAGE_MAP.get(target) || null; +} + +/** 解析容器宽度(支持静态数值或动态函数) */ +function resolveWidth(target: string): number | undefined { + const w = PAGE_MAP[target]?.width; + return typeof w === 'function' ? w() : w; +} + +/** 解析容器高度(支持静态数值或动态函数) */ +function resolveHeight(target: string): number | undefined { + const h = PAGE_MAP[target]?.height; + return typeof h === 'function' ? h() : h; } // ---------- 组件 ---------- export function PageDispatcher() { - // 单实例(modal / drawer)—— 同一时间只有一个 - const [singleton, setSingleton] = useState(null); - - // 浮动面板列表(支持多个同时打开) - const [floatingPanels, setFloatingPanels] = useState([]); + const [activePanel, setActivePanel] = useState(null); const nextIdRef = useRef(0); // 监听 OPEN_PAGE 事件 @@ -104,80 +125,82 @@ export function PageDispatcher() { return; } - if (modalType === 'floating') { - // 浮动面板:追加到列表 - const id = `fp-${nextIdRef.current++}`; - setFloatingPanels((prev) => [...prev, { id, target, label }]); - } else { - // modal / drawer:单实例,覆盖之前的 - setSingleton({ target, modalType, label }); - } + // 全局互斥:已有面板打开时,禁止打开新面板 + // 不关闭已有面板,保留其内部互操作能力(拖拽、图片拖放等) + setActivePanel((prev) => { + if (prev) return prev; + return { id: `pnl-${nextIdRef.current++}`, target, modalType, label }; + }); }; on(EVENTS.OPEN_PAGE, handler); return () => off(EVENTS.OPEN_PAGE, handler); }, []); - // 关闭单实例 - const closeSingleton = useCallback(() => { - setSingleton(null); + // 关闭面板 + const closePanel = useCallback(() => { + setActivePanel(null); }, []); - // 关闭指定浮动面板 - const closeFloating = useCallback((id: string) => { - setFloatingPanels((prev) => prev.filter((p) => p.id !== id)); - }, []); + // 页面组件(useMemo 稳定引用) + const PanelPage = useMemo( + () => (activePanel ? resolvePage(activePanel.target) : null), + [activePanel], + ); - // 单实例对应的页面组件 - const SingletonPage = singleton ? resolvePage(singleton.target) : null; + const panelWidth = activePanel ? resolveWidth(activePanel.target) : undefined; + const panelHeight = activePanel ? resolveHeight(activePanel.target) : undefined; + + // 容器默认尺寸(动态计算,视口缩放自动适配) + const defaultModalWidth = Math.min(window.innerWidth * 0.55, 720); + const defaultDrawerWidth = Math.min(window.innerWidth * 0.38, 520); return ( <> {/* ======== Modal ======== */} - {singleton?.modalType === 'modal' && SingletonPage && ( + {activePanel?.modalType === 'modal' && PanelPage && ( - + {/* eslint-disable-next-line react-hooks/static-components */} + )} {/* ======== Drawer ======== */} - {singleton?.modalType === 'drawer' && SingletonPage && ( + {activePanel?.modalType === 'drawer' && PanelPage && ( - + {/* eslint-disable-next-line react-hooks/static-components */} + )} - {/* ======== Floating Panels ======== */} - {floatingPanels.map((panel) => { - const PageContent = resolvePage(panel.target); - if (!PageContent) return null; - - return ( - closeFloating(panel.id)} - > - - - ); - })} + {/* ======== Floating Panel ======== */} + {activePanel?.modalType === 'floating' && PanelPage && ( + + + + )} ); } diff --git a/src/pages/headers/Test1.tsx b/src/pages/headers/Test1.tsx new file mode 100644 index 0000000..b5264f0 --- /dev/null +++ b/src/pages/headers/Test1.tsx @@ -0,0 +1,9 @@ +import {Component} from "react"; + +export class Test1 extends Component { + render() { + return ( + <> + ); + } +} \ No newline at end of file diff --git a/src/pages/headers/Test2.tsx b/src/pages/headers/Test2.tsx new file mode 100644 index 0000000..68b9b4c --- /dev/null +++ b/src/pages/headers/Test2.tsx @@ -0,0 +1,210 @@ +// ============================================================ +// Test2 — 账户信息展示 +// 数据来源:AuthAPI.getUserInfo() → GET /api/v1/auth/me +// ============================================================ + +import { useEffect, useState, useCallback } from 'react'; +import { + Card, + Descriptions, + Button, + Typography, + Skeleton, + Result, + Space, + Tag, + Divider, + theme as antTheme, +} from 'antd'; +import { + UserOutlined, + WalletOutlined, + CrownOutlined, + BarChartOutlined, + RightOutlined, +} from '@ant-design/icons'; +import { AuthAPI, type UserInfoBody } from '@/services/modules/auth'; + +const { Text, Title } = Typography; + +/** 将分转为元,保留两位小数 */ +function centToYuan(cent: number): string { + return (cent / 100).toFixed(2); +} + +/** 格式化日期 */ +function formatDate(date: Date | string | undefined): string { + if (!date) return '—'; + const d = typeof date === 'string' ? new Date(date) : date; + return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }); +} + +export function Test2() { + const { token } = antTheme.useToken(); + const [userInfo, setUserInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const info = await AuthAPI.getUserInfo(); + setUserInfo(info); + } catch (err) { + setError((err as Error).message || '加载账户信息失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + // ======== 加载态 ======== + if (loading) { + return ( +
+ +
+ ); + } + + // ======== 错误态 ======== + if (error || !userInfo) { + return ( + + 重试 + + } + /> + ); + } + + // ======== 数据展示 ======== + const totalBalance = userInfo.balance_cent + userInfo.gift_balance_cent; + + return ( +
+ {/* ======== 标题:账户 ======== */} +
+ + +
+ + {userInfo.display_name || userInfo.username} + + + ID: {userInfo.id} + +
+ {userInfo.role !== 'user' && ( + + {userInfo.role === 'admin' ? '管理员' : '超级管理员'} + + )} +
+ +
+ + {/* ======== 积分栏 ======== */} + + + 账户总积分 + + + {centToYuan(totalBalance)} + <Text type="secondary" style={{ fontSize: 14, marginLeft: 4 }}>积分</Text> + + +
+ 主积分 +
+ {centToYuan(userInfo.balance_cent)} +
+
+ 赠送积分 +
+ {centToYuan(userInfo.gift_balance_cent)} +
+ {userInfo.discount_rate > 0 && userInfo.discount_rate < 1 && ( +
+ 折扣率 +
+ {Math.round(userInfo.discount_rate * 100)}% +
+ )} +
+
+ + {/* ======== 套餐信息 ======== */} + +
+ + + 当前套餐 + + +
+ + + {userInfo.subscription_plan || '免费版'} + + + + {formatDate(userInfo.subscription_expires_at)} + + + + {userInfo.seat_limit || '—'} + + +
+ + {/* ======== 今日统计 ======== */} + + + + 今日使用 + + + 统计数据将在次日更新 + + + + {/* ======== 查看更多权益 ======== */} + + +
+ ); +} diff --git a/src/pages/headers/Test3.tsx b/src/pages/headers/Test3.tsx new file mode 100644 index 0000000..4bfbb0d --- /dev/null +++ b/src/pages/headers/Test3.tsx @@ -0,0 +1,5 @@ +export function Test3() { + return ( + <> + ); +} \ No newline at end of file diff --git a/src/pages/wechat-work/WechatWorkPage.tsx b/src/pages/headers/WechatWorkPage.tsx similarity index 100% rename from src/pages/wechat-work/WechatWorkPage.tsx rename to src/pages/headers/WechatWorkPage.tsx