fix(web): 侧栏选中精确匹配、页头标题左对齐、添加按钮移至标题行、工作台补内容区 Card
douyin-release-gate / verify (push) Failing after 3m51s

- ProLayout 默认按路径前缀高亮菜单(/accounts 与 /accounts/import 同时选中),改为受控 selectedKeys(metadata.menuSelectedKey 最长前缀精确匹配,详情页回落父级)
- 页面标题/副标题从 PageContainer extra(右上)移到 title 槽(左上),动作按钮经 PageActions Context 渲染到 extra 与标题同行
- 新增 web/src/components/PageActions.tsx;账号导入/我的账号/监控账号/运行环境/网关/网络出口 六处列表页注册各自按钮并移除卡内独占行(重复副标题的小字提示一并移除)
- 运营工作台根节点包 Card variant=outlined,operations 标签页去掉冗余内层 Card
- dashboard 移除内层 PageContainer,避免与全局页头双标题
This commit is contained in:
2026-09-23 11:21:28 +08:00
parent caf4b0fdcb
commit ebd99f91d2
10 changed files with 146 additions and 101 deletions
+17
View File
@@ -0,0 +1,17 @@
import { createContext, useContext, useEffect } from 'react';
import type { ReactNode } from 'react';
// 页头动作槽:页面挂载时把自己的操作按钮注册到布局 PageContainer 的 extra(与标题同一行),
// 卸载时自动清除。Layout 提供 Provider,页面只消费 usePageActions。
const PageActionsContext = createContext<(node: ReactNode) => void>(() => {});
export function usePageActions(node: ReactNode, deps: unknown[] = []) {
const setPageActions = useContext(PageActionsContext);
useEffect(() => {
setPageActions(node);
return () => setPageActions(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
export { PageActionsContext };
@@ -19,6 +19,7 @@ import { PlusOutlined, CloudUploadOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { getList, remove, creatorAction, creatorUpdate } from '@/services/api';
import { conflictMessage, isCollectionAccount, platformLabel } from '@/utils/helpers';
import { usePageActions } from '@/components/PageActions';
// 语义对齐 web.archived/src/features/accounts/AccountsPage.jsx 的 AccountManagementList(mode: owned/monitoring)。
// 仅用 antd 默认组件:Table/Tag/Modal/Popconfirm/Select(tags)。
@@ -223,23 +224,25 @@ export default function AccountManagementList({ mode }: { mode: 'owned' | 'monit
},
];
usePageActions(
isMonitoring ? (
<Button type="primary" icon={<CloudUploadOutlined />} onClick={() => history.push('/accounts/import')}>
导入竞品账号
</Button>
) : (
<Button type="primary" icon={<PlusOutlined />} onClick={() => history.push('/accounts/new')}>
创建账号
</Button>
),
[isMonitoring],
);
return (
<Card variant="outlined">
{contextHolder}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text type="secondary">
共 {rows.length} 个{isMonitoring ? '监控' : '自有'}账号
</Typography.Text>
{isMonitoring ? (
<Button type="primary" icon={<CloudUploadOutlined />} onClick={() => history.push('/accounts/import')}>
导入竞品账号
</Button>
) : (
<Button type="primary" icon={<PlusOutlined />} onClick={() => history.push('/accounts/new')}>
创建账号
</Button>
)}
</div>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
共 {rows.length} 个{isMonitoring ? '监控' : '自有'}账号
</Typography.Text>
{error ? (
<Alert type="error" showIcon style={{ marginBottom: 16 }} message={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
) : null}
+62 -51
View File
@@ -1,67 +1,78 @@
import { useState } from 'react';
import type { ReactNode } from 'react';
import { history, Outlet, useLocation } from '@umijs/max';
import { PageContainer, ProLayout } from '@ant-design/pro-components';
import { Dropdown, Typography } from 'antd';
import { LogoutOutlined } from '@ant-design/icons';
import { menu, pageMetadata } from '@/utils/metadata';
import { PageActionsContext } from '@/components/PageActions';
import { menu, menuSelectedKey, pageMetadata } from '@/utils/metadata';
// 菜单结构对齐 web.archived/src/app/Layout.jsx(运营 / 资源 两组);侧栏仅保留模块链接菜单,标题由 TopNav 呈现。
// 菜单结构对齐 web.archived/src/app/Layout.jsx(运营 / 资源 两组);侧栏仅保留模块链接菜单,标题由内容区页头呈现。
// Umi 4(react-router 6)全局布局:location 用 useLocation(),子路由用 <Outlet/> 渲染
// (渲染器不传 children prop,用 children 会导致整页空白且无任何报错)。
export default function Layout() {
const location = useLocation();
// 页头动作槽:列表页在挂载时把自己的添加类按钮注册进来,与标题渲染在同一行。
const [pageActions, setPageActions] = useState<ReactNode>(null);
if (location.pathname === '/login') return <Outlet />;
const meta = pageMetadata(location.pathname);
return (
<ProLayout
title="CreatorHub"
logo={false}
layout="mix"
fixSiderbar
route={{ routes: menu } as any}
location={location}
// 不渲染侧栏品牌区(logo/标题),折叠菜单仅保留模块链接。
menuHeaderRender={false}
menuItemRender={(item, dom) => (
<a onClick={() => item.path && history.push(item.path)}>{dom}</a>
)}
avatarProps={{
icon: <LogoutOutlined />,
size: 'small',
render: (_props, dom) => (
<Dropdown
menu={{
items: [
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: () => {
localStorage.removeItem('creatorhub.auth');
history.replace('/login');
<PageActionsContext.Provider value={setPageActions}>
<ProLayout
title="CreatorHub"
logo={false}
layout="mix"
fixSiderbar
route={{ routes: menu } as any}
location={location}
// 受控选中态:只精确命中菜单路径,避免 /accounts 与 /accounts/import 同时高亮。
menuProps={{ selectedKeys: [menuSelectedKey(location.pathname)] }}
// 不渲染侧栏品牌区(logo/标题),折叠菜单仅保留模块链接。
menuHeaderRender={false}
menuItemRender={(item, dom) => (
<a onClick={() => item.path && history.push(item.path)}>{dom}</a>
)}
avatarProps={{
icon: <LogoutOutlined />,
size: 'small',
render: (_props, dom) => (
<Dropdown
menu={{
items: [
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: () => {
localStorage.removeItem('creatorhub.auth');
history.replace('/login');
},
},
},
],
}}
>
{dom}
</Dropdown>
),
}}
>
{/* 页面标题在卡片外面(内容区右上角);卡片内不再重复渲染标题。 */}
<PageContainer
ghost
title={false}
subTitle={false}
extra={
<div style={{ textAlign: 'right' }}>
<Typography.Title level={4} style={{ marginBottom: 2 }}>{pageMetadata(location.pathname).title}</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{pageMetadata(location.pathname).subtitle}</Typography.Text>
</div>
}
],
}}
>
{dom}
</Dropdown>
),
}}
>
<Outlet />
</PageContainer>
</ProLayout>
{/* 页面标题在卡片外(内容区左上角),动作按钮与标题同一行(右侧);卡片内不再重复渲染标题。 */}
<PageContainer
ghost
title={
<div>
<Typography.Title level={4} style={{ marginBottom: 2 }}>{meta.title}</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{meta.subtitle}</Typography.Text>
</div>
}
subTitle={false}
extra={pageActions}
>
<Outlet />
</PageContainer>
</ProLayout>
</PageActionsContext.Provider>
);
}
+7 -11
View File
@@ -17,6 +17,7 @@ import {
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { create, getList, creatorAction } from '@/services/api';
import { usePageActions } from '@/components/PageActions';
import { conflictMessage, dateTime, extractShareURL, platformForShareURL, platformLabel, shareJobStatus } from '@/utils/helpers';
interface Job {
@@ -157,20 +158,15 @@ export default function Page() {
},
];
usePageActions(
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
新建导入任务
</Button>,
);
return (
<Card variant="outlined">
{contextHolder}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setCreateOpen(true);
}}
>
新建导入任务
</Button>
</div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<Typography.Title level={5} style={{ marginBottom: 4 }}>分享链接解析任务</Typography.Title>
+7 -6
View File
@@ -6,6 +6,7 @@ import { Alert, Button, Card, Popconfirm, Space, Table, Tag, Typography, message
import { CopyOutlined, PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { browserAction, getList } from '@/services/api';
import { usePageActions } from '@/components/PageActions';
const stateText: Record<string, string> = {
created: '已创建',
@@ -159,15 +160,15 @@ export default function Page() {
},
];
usePageActions(
<Button type="primary" icon={<PlusOutlined />} onClick={() => history.push('/browsers/new')}>
创建环境
</Button>,
);
return (
<Card variant="outlined">
{contextHolder}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text type="secondary">启动、停止并回收隔离的指纹浏览器运行环境</Typography.Text>
<Button type="primary" icon={<PlusOutlined />} onClick={() => history.push('/browsers/new')}>
创建环境
</Button>
</div>
{error ? (
<Alert type="error" showIcon style={{ marginBottom: 16 }} message={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
) : null}
+4 -4
View File
@@ -990,7 +990,7 @@ export default function Page() {
];
const operationsTab = (
<Card variant="outlined">
<>
<Table
rowKey="id"
loading={pending}
@@ -999,11 +999,11 @@ export default function Page() {
columns={operationsColumns}
locale={{ emptyText: '暂无操作记录。' }}
/>
</Card>
</>
);
return (
<div>
<Card variant="outlined">
{contextHolder}
<Tabs
activeKey={tab}
@@ -1064,6 +1064,6 @@ export default function Page() {
>
当前私信文案尚未发送,放弃后再切换吗?
</Modal>
</div>
</Card>
);
}
+4 -3
View File
@@ -1,11 +1,12 @@
// 工作台:对标 pro 官方 workplace 预览的占位页。
// 统计卡片与快捷入口暂不接真实数据(后期规划),仅布局占位;不引入兜底数据。
import { PageContainer } from '@ant-design/pro-components';
import { Card, Col, Empty, Row, Typography } from 'antd';
// 统计卡片与快捷入口暂不接真实数据(后期规划),仅布局占位;不引入兜底数据。
// 页面标题由全局布局的 PageContainer 提供,这里只渲染内容区。
export default function Page() {
return (
<PageContainer ghost={false} title="工作台" subTitle="运营概况与快捷入口">
<>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={6}>
<Card variant="outlined" loading />
@@ -28,6 +29,6 @@ export default function Page() {
>
<Empty description="数据看板规划中,敬请期待。" />
</Card>
</PageContainer>
</>
);
}
+7 -6
View File
@@ -5,6 +5,7 @@ import { CopyOutlined, EyeInvisibleOutlined, EyeOutlined, PlusOutlined } from '@
import type { ColumnsType } from 'antd/es/table';
import { create, getList, remove, update } from '@/services/api';
import { conflictMessage } from '@/utils/helpers';
import { usePageActions } from '@/components/PageActions';
const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const tokenPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{15,127}$/;
@@ -169,15 +170,15 @@ export default function Page() {
},
];
usePageActions(
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
注册网关
</Button>,
);
return (
<Card variant="outlined">
{contextHolder}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text type="secondary">注册宿主机 native browser gateway;令牌须与 gateway 进程的 GATEWAY_TOKEN 一致</Typography.Text>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
注册网关
</Button>
</div>
{created ? (
<Alert
type="success"
+7 -6
View File
@@ -6,6 +6,7 @@ import { PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { create, getOne, getList, networkExitAction, remove, update } from '@/services/api';
import { conflictMessage, dateTime } from '@/utils/helpers';
import { usePageActions } from '@/components/PageActions';
const healthText: Record<string, string> = {
unchecked: '未检测',
@@ -277,15 +278,15 @@ export default function Page() {
},
];
usePageActions(
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
创建网络出口
</Button>,
);
return (
<Card variant="outlined">
{contextHolder}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text type="secondary">维护固定出口、健康观测与账号绑定</Typography.Text>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
创建网络出口
</Button>
</div>
{error ? (
<Alert type="error" showIcon style={{ marginBottom: 16 }} message={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
) : null}
+14
View File
@@ -74,3 +74,17 @@ export function pageMetadata(pathname: string): { title: string; subtitle: strin
}
return { title: 'CreatorHub', subtitle: '运营控制台' };
}
export function menuSelectedKey(pathname: string): string {
const leafPaths = menu.flatMap((group) => (group.children ?? []).map((item) => item.path ?? ''));
const segments = pathname.split('/').filter(Boolean);
for (let i = segments.length; i >= 1; i--) {
const candidate = `/${segments.slice(0, i).join('/')}`;
if (leafPaths.includes(candidate)) return candidate;
}
return pathname;
}
// 侧栏选中态:ProLayout 默认按路径前缀高亮(/accounts 是 /accounts/import 的前缀,两项会同时高亮)。
// 这里改为受控 selectedKeys:从完整路径逐段向前回退,仅精确命中菜单叶路径;
// 详情页(如 /accounts/123、/accounts/new)回落到父级菜单项。