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:
@@ -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 = () => <PlaceholderPage label={label} />;
|
||||
return Placeholder;
|
||||
/** 为每个占位页预创建稳定组件引用 */
|
||||
function createPlaceholder(label: string): ComponentType {
|
||||
const C = () => <PlaceholderPage label={label} />;
|
||||
C.displayName = `Placeholder_${label}`;
|
||||
return C;
|
||||
}
|
||||
|
||||
// ---------- target → 页面组件映射 ----------
|
||||
//
|
||||
// 新增页面只需在此加一行 ——
|
||||
// 有自定义组件:{ component: XxxPage, label: '页面标题' }
|
||||
// 占位页面: { label: '页面标题' } ← 自动渲染 "页面标题 — 页面开发中"
|
||||
|
||||
const PAGE_MAP: Record<string, PageConfig> = {
|
||||
'/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<string, PageConfig> = {
|
||||
'/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 {
|
||||
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<PageEventPayload | null>(null);
|
||||
|
||||
// 浮动面板列表(支持多个同时打开)
|
||||
const [floatingPanels, setFloatingPanels] = useState<ActivePanel[]>([]);
|
||||
const [activePanel, setActivePanel] = useState<ActivePanel | null>(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<ComponentType | null>(
|
||||
() => (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 && (
|
||||
<Modal
|
||||
open
|
||||
centered
|
||||
title={singleton.label}
|
||||
onCancel={closeSingleton}
|
||||
title={activePanel.label}
|
||||
onCancel={closePanel}
|
||||
footer={null}
|
||||
destroyOnHidden={true}
|
||||
mask={{ closable: true }}
|
||||
width={640}
|
||||
width={panelWidth ?? defaultModalWidth}
|
||||
>
|
||||
<SingletonPage />
|
||||
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||
<PanelPage />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* ======== Drawer ======== */}
|
||||
{singleton?.modalType === 'drawer' && SingletonPage && (
|
||||
{activePanel?.modalType === 'drawer' && PanelPage && (
|
||||
<Drawer
|
||||
open
|
||||
title={singleton.label}
|
||||
onClose={closeSingleton}
|
||||
title={activePanel.label}
|
||||
onClose={closePanel}
|
||||
destroyOnHidden={true}
|
||||
size={480}
|
||||
size={panelWidth ?? defaultDrawerWidth}
|
||||
>
|
||||
<SingletonPage />
|
||||
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||
<PanelPage />
|
||||
</Drawer>
|
||||
)}
|
||||
|
||||
{/* ======== Floating Panels ======== */}
|
||||
{floatingPanels.map((panel) => {
|
||||
const PageContent = resolvePage(panel.target);
|
||||
if (!PageContent) return null;
|
||||
|
||||
return (
|
||||
<FloatingPanel
|
||||
key={panel.id}
|
||||
open
|
||||
title={panel.label}
|
||||
onClose={() => closeFloating(panel.id)}
|
||||
>
|
||||
<PageContent />
|
||||
</FloatingPanel>
|
||||
);
|
||||
})}
|
||||
{/* ======== Floating Panel ======== */}
|
||||
{activePanel?.modalType === 'floating' && PanelPage && (
|
||||
<FloatingPanel
|
||||
key={activePanel.id}
|
||||
open
|
||||
title={activePanel.label}
|
||||
onClose={closePanel}
|
||||
width={panelWidth ?? defaultDrawerWidth}
|
||||
height={panelHeight}
|
||||
>
|
||||
<PanelPage />
|
||||
</FloatingPanel>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
9
src/pages/headers/Test1.tsx
Normal file
9
src/pages/headers/Test1.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import {Component} from "react";
|
||||
|
||||
export class Test1 extends Component {
|
||||
render() {
|
||||
return (
|
||||
<></>
|
||||
);
|
||||
}
|
||||
}
|
||||
210
src/pages/headers/Test2.tsx
Normal file
210
src/pages/headers/Test2.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/pages/headers/Test3.tsx
Normal file
5
src/pages/headers/Test3.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
export function Test3() {
|
||||
return (
|
||||
<></>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user