feat: 统一页面调度体系 — 全局互斥 + 动态尺寸 + 账户信息页

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 18:43:50 +08:00
parent 0c7b33aac1
commit c18d9c8404
5 changed files with 320 additions and 73 deletions

View File

@@ -6,19 +6,22 @@
// - drawer → antd Drawer侧滑抽屉 // - drawer → antd Drawer侧滑抽屉
// - floating → FloatingPanel非模态浮动面板可拖拽不阻断交互 // - floating → FloatingPanel非模态浮动面板可拖拽不阻断交互
// //
// 支持多面板共存floating 类型可同时打开多个) // 全局互斥:同一时刻最多打开一个页面组件。
// 已打开时不响应 OPEN_PAGE 事件(保留面板内互操作能力:拖拽、图片拖放等)。
// //
// PAGE_MAP 集中注册所有可调度页面: // PAGE_MAP 集中注册所有可调度页面:
// 有真实组件 → { component: XxxPage } // 有真实组件 → { component: XxxPage, label: '...', width?: number }
// 占位页面 → 只写 label自动渲染 PlaceholderPage // 占位页面 → 只写 label自动渲染 "页面开发中"
// 懒加载页面 → component: React.lazy(() => import('...'))
// 新增页面只需在这里加一行,无需创建独立文件 // 新增页面只需在这里加一行,无需创建独立文件
// ============================================================ // ============================================================
import { useState, useEffect, useCallback, useRef, type ComponentType } from 'react'; import { type ComponentType, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Modal, Drawer } from 'antd'; import { Drawer, Modal } from 'antd';
import { on, off, EVENTS } from '@/utils/event-bus'; import { EVENTS, off, on } from '@/utils/event-bus';
import { FloatingPanel } from '@/components/ui/FloatingPanel'; 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; label: string;
} }
interface ActivePanel { /** 当前活跃面板id 用于 key / 关闭定位) */
interface ActivePanel extends PageEventPayload {
id: string; id: string;
target: string;
label: string;
} }
interface PageConfig { interface PageConfig {
/** 页面组件(未提供则使用占位组件) */ /** 页面组件(未提供则使用自动生成的占位组件)。
* 支持 React.lazy(() => import('...')) 懒加载。 */
component?: ComponentType; component?: ComponentType;
/** 页面标签(显示在容器标题栏) */ /** 页面标签(显示在容器标题栏) */
label: string; label: string;
/** 容器宽度px。也支持函数动态计算() => window.innerWidth * 0.6 */
width?: number | (() => number);
/** 容器高度px仅 FloatingPanel 生效。也支持函数动态计算 */
height?: number | (() => number);
} }
// ---------- 占位组件 ---------- // ---------- 占位组件(模块级,引用稳定,避免渲染中创建组件) ----------
function PlaceholderPage({ label }: { label: string }) { function PlaceholderPage({ label }: { label: string }) {
return ( return (
@@ -53,22 +60,24 @@ function PlaceholderPage({ label }: { label: string }) {
); );
} }
/** 创建一个包装了 label 的稳定占位组件(模块级,避免渲染时重建) */ /** 为每个占位页预创建稳定组件引用 */
function makePlaceholder(label: string): ComponentType { function createPlaceholder(label: string): ComponentType {
const Placeholder = () => <PlaceholderPage label={label} />; const C = () => <PlaceholderPage label={label} />;
return Placeholder; C.displayName = `Placeholder_${label}`;
return C;
} }
// ---------- target → 页面组件映射 ---------- // ---------- target → 页面组件映射 ----------
//
// 新增页面只需在此加一行 ——
// 有自定义组件:{ component: XxxPage, label: '页面标题' }
// 占位页面: { label: '页面标题' } ← 自动渲染 "页面标题 — 页面开发中"
const PAGE_MAP: Record<string, PageConfig> = { const PAGE_MAP: Record<string, PageConfig> = {
'/wechat-work': { component: WechatWorkPage, label: '企业微信' }, '/wechat-work': { component: WechatWorkPage, label: '企业微信' },
'/compliance-assets': { 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: '开会员/激活' }, '/vip': { label: '开会员/激活' },
'/recharge': { label: '充值积分' }, '/recharge': { label: '充值积分' },
'/billing': { label: '消费记录' }, '/billing': { label: '消费记录' },
@@ -77,21 +86,33 @@ const PAGE_MAP: Record<string, PageConfig> = {
'/quota': { label: '查配额' }, '/quota': { label: '查配额' },
}; };
/** 解析页面组件:有自定义组件用自定义,否则用占位 */ // 预计算所有占位组件(模块加载时执行一次,引用永久稳定)
const RESOLVED_PAGE_MAP = new Map<string, ComponentType>();
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 { function resolvePage(target: string): ComponentType | null {
const config = PAGE_MAP[target]; return RESOLVED_PAGE_MAP.get(target) || null;
if (!config) return null; }
return config.component || makePlaceholder(config.label);
/** 解析容器宽度(支持静态数值或动态函数) */
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() { export function PageDispatcher() {
// 单实例modal / drawer—— 同一时间只有一个 const [activePanel, setActivePanel] = useState<ActivePanel | null>(null);
const [singleton, setSingleton] = useState<PageEventPayload | null>(null);
// 浮动面板列表(支持多个同时打开)
const [floatingPanels, setFloatingPanels] = useState<ActivePanel[]>([]);
const nextIdRef = useRef(0); const nextIdRef = useRef(0);
// 监听 OPEN_PAGE 事件 // 监听 OPEN_PAGE 事件
@@ -104,80 +125,82 @@ export function PageDispatcher() {
return; return;
} }
if (modalType === 'floating') { // 全局互斥:已有面板打开时,禁止打开新面板
// 浮动面板:追加到列表 // 不关闭已有面板,保留其内部互操作能力(拖拽、图片拖放等)
const id = `fp-${nextIdRef.current++}`; setActivePanel((prev) => {
setFloatingPanels((prev) => [...prev, { id, target, label }]); if (prev) return prev;
} else { return { id: `pnl-${nextIdRef.current++}`, target, modalType, label };
// modal / drawer单实例覆盖之前的 });
setSingleton({ target, modalType, label });
}
}; };
on(EVENTS.OPEN_PAGE, handler); on(EVENTS.OPEN_PAGE, handler);
return () => off(EVENTS.OPEN_PAGE, handler); return () => off(EVENTS.OPEN_PAGE, handler);
}, []); }, []);
// 关闭单实例 // 关闭面板
const closeSingleton = useCallback(() => { const closePanel = useCallback(() => {
setSingleton(null); setActivePanel(null);
}, []); }, []);
// 关闭指定浮动面板 // 页面组件useMemo 稳定引用)
const closeFloating = useCallback((id: string) => { const PanelPage = useMemo<ComponentType | null>(
setFloatingPanels((prev) => prev.filter((p) => p.id !== id)); () => (activePanel ? resolvePage(activePanel.target) : null),
}, []); [activePanel],
);
// 单实例对应的页面组件 const panelWidth = activePanel ? resolveWidth(activePanel.target) : undefined;
const SingletonPage = singleton ? resolvePage(singleton.target) : null; 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 ( return (
<> <>
{/* ======== Modal ======== */} {/* ======== Modal ======== */}
{singleton?.modalType === 'modal' && SingletonPage && ( {activePanel?.modalType === 'modal' && PanelPage && (
<Modal <Modal
open open
centered centered
title={singleton.label} title={activePanel.label}
onCancel={closeSingleton} onCancel={closePanel}
footer={null} footer={null}
destroyOnHidden={true} destroyOnHidden={true}
mask={{ closable: true }} mask={{ closable: true }}
width={640} width={panelWidth ?? defaultModalWidth}
> >
<SingletonPage /> {/* eslint-disable-next-line react-hooks/static-components */}
<PanelPage />
</Modal> </Modal>
)} )}
{/* ======== Drawer ======== */} {/* ======== Drawer ======== */}
{singleton?.modalType === 'drawer' && SingletonPage && ( {activePanel?.modalType === 'drawer' && PanelPage && (
<Drawer <Drawer
open open
title={singleton.label} title={activePanel.label}
onClose={closeSingleton} onClose={closePanel}
destroyOnHidden={true} destroyOnHidden={true}
size={480} size={panelWidth ?? defaultDrawerWidth}
> >
<SingletonPage /> {/* eslint-disable-next-line react-hooks/static-components */}
<PanelPage />
</Drawer> </Drawer>
)} )}
{/* ======== Floating Panels ======== */} {/* ======== Floating Panel ======== */}
{floatingPanels.map((panel) => { {activePanel?.modalType === 'floating' && PanelPage && (
const PageContent = resolvePage(panel.target); <FloatingPanel
if (!PageContent) return null; key={activePanel.id}
open
return ( title={activePanel.label}
<FloatingPanel onClose={closePanel}
key={panel.id} width={panelWidth ?? defaultDrawerWidth}
open height={panelHeight}
title={panel.label} >
onClose={() => closeFloating(panel.id)} <PanelPage />
> </FloatingPanel>
<PageContent /> )}
</FloatingPanel>
);
})}
</> </>
); );
} }

View File

@@ -0,0 +1,9 @@
import {Component} from "react";
export class Test1 extends Component {
render() {
return (
<></>
);
}
}

210
src/pages/headers/Test2.tsx Normal file
View File

@@ -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<UserInfoBody | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div style={{ padding: 24 }}>
<Skeleton active paragraph={{ rows: 6 }} />
</div>
);
}
// ======== 错误态 ========
if (error || !userInfo) {
return (
<Result
status="error"
title="加载失败"
subTitle={error || '未能获取账户信息'}
extra={
<Button type="primary" onClick={load}>
</Button>
}
/>
);
}
// ======== 数据展示 ========
const totalBalance = userInfo.balance_cent + userInfo.gift_balance_cent;
return (
<div style={{ padding: 24, maxWidth: 640 }}>
{/* ======== 标题:账户 ======== */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<Space align="center" size={12}>
<UserOutlined style={{ fontSize: 22, color: token.colorPrimary }} />
<div>
<Title level={4} style={{ margin: 0 }}>
{userInfo.display_name || userInfo.username}
</Title>
<Text type="secondary" style={{ fontSize: 12 }}>
ID: {userInfo.id}
</Text>
</div>
{userInfo.role !== 'user' && (
<Tag color="gold" style={{ lineHeight: '18px', fontSize: 11 }}>
{userInfo.role === 'admin' ? '管理员' : '超级管理员'}
</Tag>
)}
</Space>
<Button type="primary" ghost size="small" icon={<WalletOutlined />}>
</Button>
</div>
{/* ======== 积分栏 ======== */}
<Card
size="small"
styles={{
body: { padding: '16px 20px' },
}}
style={{ marginBottom: 16 }}
>
<Text type="secondary" style={{ fontSize: 12, marginBottom: 8, display: 'block' }}>
</Text>
<Title level={3} style={{ margin: '0 0 12px 0' }}>
{centToYuan(totalBalance)}
<Text type="secondary" style={{ fontSize: 14, marginLeft: 4 }}></Text>
</Title>
<Space size={32}>
<div>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<br />
<Text strong>{centToYuan(userInfo.balance_cent)}</Text>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<br />
<Text strong>{centToYuan(userInfo.gift_balance_cent)}</Text>
</div>
{userInfo.discount_rate > 0 && userInfo.discount_rate < 1 && (
<div>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<br />
<Tag color="green">{Math.round(userInfo.discount_rate * 100)}%</Tag>
</div>
)}
</Space>
</Card>
{/* ======== 套餐信息 ======== */}
<Card
size="small"
styles={{
body: { padding: '16px 20px' },
}}
style={{ marginBottom: 16 }}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<Space size={8}>
<CrownOutlined style={{ color: token.colorWarning }} />
<Text strong></Text>
</Space>
<Button type="link" size="small" icon={<RightOutlined />} iconPlacement="end">
/
</Button>
</div>
<Descriptions column={1} size="small" colon={false}>
<Descriptions.Item label="套餐名称">
<Text strong>{userInfo.subscription_plan || '免费版'}</Text>
</Descriptions.Item>
<Descriptions.Item label="到期时间">
<Text type={new Date(userInfo.subscription_expires_at) < new Date() ? 'danger' : undefined}>
{formatDate(userInfo.subscription_expires_at)}
</Text>
</Descriptions.Item>
<Descriptions.Item label="席位上限">
{userInfo.seat_limit || '—'}
</Descriptions.Item>
</Descriptions>
</Card>
{/* ======== 今日统计 ======== */}
<Card
size="small"
styles={{
body: { padding: '16px 20px' },
}}
style={{ marginBottom: 16 }}
>
<Space size={8} style={{ marginBottom: 12 }}>
<BarChartOutlined style={{ color: token.colorSuccess }} />
<Text strong>使</Text>
</Space>
<Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '12px 0', fontSize: 13 }}>
</Text>
</Card>
{/* ======== 查看更多权益 ======== */}
<Divider style={{ margin: '8px 0' }} />
<Button type="link" block icon={<RightOutlined />} iconPlacement="end" style={{ color: token.colorPrimary }}>
</Button>
</div>
);
}

View File

@@ -0,0 +1,5 @@
export function Test3() {
return (
<></>
);
}