feat: 企业微信改为 Modal 展示二维码,修复 PageDispatcher hooks 错误

将"企业微信"导航项从 external(跳转浏览器)改为 page(Modal 展示),
  二维码图片数据来源于 Banner API(/api/v1/creatives,category=promotion)。

  - 新增 WechatWorkPage:调用 fetchBanners,筛选 category==='promotion',
    展示 image_url 二维码 + 扫码提示 + 刷新按钮,加载/空态/错误三态覆盖
  - nav-config:wechat-work 从 action:'external' 改为 action:'page',
    modalType:'modal',target:'/wechat-work'
  - PageDispatcher:注册 /wechat-work 映射,Modal 新增 centered 居中
  - 修复 PageDispatcher 致命 bug:PAGE_MAP[...]() 函数调用改为
    JSX <SingletonPage />,避免子组件 hooks 误跑在父组件链上导致崩溃
  - PAGE_MAP 类型从 Record<string, () => ReactNode> 修正为
    Record<string, ComponentType>
This commit is contained in:
2026-06-08 17:27:24 +08:00
parent ada915f298
commit a2741edf01
3 changed files with 132 additions and 28 deletions

View File

@@ -9,18 +9,19 @@
// 支持多面板共存floating 类型可同时打开多个) // 支持多面板共存floating 类型可同时打开多个)
// ============================================================ // ============================================================
import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react'; import {useState, useEffect, useCallback, useRef, type ComponentType} from 'react';
import { Modal, Drawer } from 'antd'; import {Modal, Drawer} from 'antd';
import { on, off, EVENTS } from '@/utils/event-bus'; import {on, off, EVENTS} from '@/utils/event-bus';
import { FloatingPanel } from '@/components/common/FloatingPanel'; import {FloatingPanel} from '@/components/common/FloatingPanel';
import { ComplianceAssetsPage } from '@/pages/compliance-assets/ComplianceAssetsPage'; import {ComplianceAssetsPage} from '@/pages/compliance-assets/ComplianceAssetsPage';
import { AccountPage } from '@/pages/account/AccountPage'; import {AccountPage} from '@/pages/account/AccountPage';
import { VipPage } from '@/pages/vip/VipPage'; import {VipPage} from '@/pages/vip/VipPage';
import { RechargePage } from '@/pages/recharge/RechargePage'; import {RechargePage} from '@/pages/recharge/RechargePage';
import { BillingPage } from '@/pages/billing/BillingPage'; import {BillingPage} from '@/pages/billing/BillingPage';
import { OrdersPage } from '@/pages/orders/OrdersPage'; import {OrdersPage} from '@/pages/orders/OrdersPage';
import { ProjectsPage } from '@/pages/projects/ProjectsPage'; import {ProjectsPage} from '@/pages/projects/ProjectsPage';
import { QuotaPage } from '@/pages/quota/QuotaPage'; import {QuotaPage} from '@/pages/quota/QuotaPage';
import {WechatWorkPage} from '@/pages/wechat-work/WechatWorkPage';
// ---------- 类型 ---------- // ---------- 类型 ----------
@@ -40,7 +41,7 @@ interface ActivePanel {
// ---------- target → 页面组件映射 ---------- // ---------- target → 页面组件映射 ----------
const PAGE_MAP: Record<string, () => ReactNode> = { const PAGE_MAP: Record<string, ComponentType> = {
'/compliance-assets': ComplianceAssetsPage, '/compliance-assets': ComplianceAssetsPage,
'/account': AccountPage, '/account': AccountPage,
'/vip': VipPage, '/vip': VipPage,
@@ -49,6 +50,7 @@ const PAGE_MAP: Record<string, () => ReactNode> = {
'/orders': OrdersPage, '/orders': OrdersPage,
'/projects': ProjectsPage, '/projects': ProjectsPage,
'/quota': QuotaPage, '/quota': QuotaPage,
'/wechat-work': WechatWorkPage,
}; };
// ---------- 组件 ---------- // ---------- 组件 ----------
@@ -64,7 +66,7 @@ export function PageDispatcher() {
// 监听 OPEN_PAGE 事件 // 监听 OPEN_PAGE 事件
useEffect(() => { useEffect(() => {
const handler = (payload: unknown) => { const handler = (payload: unknown) => {
const { target, modalType, label } = payload as PageEventPayload; const {target, modalType, label} = payload as PageEventPayload;
if (!target || !PAGE_MAP[target]) { if (!target || !PAGE_MAP[target]) {
console.warn(`[PageDispatcher] 未找到页面组件: ${target}`); console.warn(`[PageDispatcher] 未找到页面组件: ${target}`);
@@ -74,10 +76,10 @@ export function PageDispatcher() {
if (modalType === 'floating') { if (modalType === 'floating') {
// 浮动面板:追加到列表 // 浮动面板:追加到列表
const id = `fp-${nextIdRef.current++}`; const id = `fp-${nextIdRef.current++}`;
setFloatingPanels((prev) => [...prev, { id, target, label }]); setFloatingPanels((prev) => [...prev, {id, target, label}]);
} else { } else {
// modal / drawer单实例覆盖之前的 // modal / drawer单实例覆盖之前的
setSingleton({ target, modalType, label }); setSingleton({target, modalType, label});
} }
}; };
@@ -95,36 +97,39 @@ export function PageDispatcher() {
setFloatingPanels((prev) => prev.filter((p) => p.id !== id)); setFloatingPanels((prev) => prev.filter((p) => p.id !== id));
}, []); }, []);
// 渲染单实例内容 // 单实例对应的页面组件
const singletonContent = singleton && PAGE_MAP[singleton.target] ? PAGE_MAP[singleton.target]() : null; const SingletonPage = singleton ? PAGE_MAP[singleton.target] : null;
return ( return (
<> <>
{/* ======== Modal ======== */} {/* ======== Modal ======== */}
{singleton?.modalType === 'modal' && ( {singleton?.modalType === 'modal' && SingletonPage && (
<Modal <Modal
open open
centered
title={singleton.label} title={singleton.label}
onCancel={closeSingleton} onCancel={closeSingleton}
footer={null} footer={null}
destroyOnClose destroyOnHidden={true}
maskClosable mask={{
closable: true,
}}
width={640} width={640}
> >
{singletonContent} <SingletonPage />
</Modal> </Modal>
)} )}
{/* ======== Drawer ======== */} {/* ======== Drawer ======== */}
{singleton?.modalType === 'drawer' && ( {singleton?.modalType === 'drawer' && SingletonPage && (
<Drawer <Drawer
open open
title={singleton.label} title={singleton.label}
onClose={closeSingleton} onClose={closeSingleton}
destroyOnClose destroyOnHidden={true}
width={480} size={480}
> >
{singletonContent} <SingletonPage />
</Drawer> </Drawer>
)} )}

View File

@@ -97,8 +97,9 @@ const personalRight: NavItemConfig[] = [
key: 'wechat-work', key: 'wechat-work',
label: '企业微信', label: '企业微信',
iconSrc: wechatWorkSvg, iconSrc: wechatWorkSvg,
action: 'external', action: 'page',
target: 'https://work.weixin.qq.com/', target: '/wechat-work',
modalType: 'modal',
position: 'right', position: 'right',
}, },
{ {

View File

@@ -0,0 +1,98 @@
// ============================================================
// WechatWorkPage — 企业微信二维码 Modal 内容
//
// 点击导航栏"企业微信"后以 Modal 形式展示二维码。
// 数据来源GET /api/v1/creativesBanner API
//
// 识别逻辑Banner 中 category === 'promotion' 的条目,
// 其 image_url 为企业微信二维码图片
// ============================================================
import { useEffect, useState, useCallback } from 'react';
import { Spin, Empty } from 'antd';
import { WechatOutlined } from '@ant-design/icons';
import { fetchBanners, type Banner } from '@/services/modules';
export function WechatWorkPage() {
const [banner, setBanner] = useState<Banner | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const list = await fetchBanners();
// category === 'promotion' → 企业微信二维码
const promotion = list.find(
(item) => item.is_active && item.category === 'promotion',
);
setBanner(promotion ?? null);
} catch (err) {
setError((err as Error).message || '加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
// 加载态
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}>
<Spin description="加载中..." />
</div>
);
}
// 无数据 / 错误
if (error || !banner || !banner.image_url) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}>
<Empty
image={<WechatOutlined style={{ fontSize: 48, color: '#9CA3AF' }} />}
description={error || '暂未获取到企业微信二维码,请联系管理员'}
/>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16, padding: 8 }}>
{/* 二维码图片 */}
<div
style={{
width: 240,
height: 240,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
background: '#fff',
border: '1px solid #E5E7EB',
overflow: 'hidden',
}}
>
<img
src={banner.image_url}
alt="企业微信二维码"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</div>
{/* 标题(如果 Banner 有标题则显示) */}
{banner.title && (
<p style={{ margin: 0, fontSize: 14, fontWeight: 500, color: 'inherit' }}>
{banner.title}
</p>
)}
{/* 提示文字 */}
<p style={{ margin: 0, fontSize: 13, color: 'inherit', opacity: 0.5 }}>
使
</p>
</div>
);
}