feat(ui): 实现页面头部组件 PageHead
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled

- 新增 PageHead 组件:页面标题、面包屑导航、操作按钮区域
- 面包屑根据路由路径自动生成,支持手动覆盖
- 响应式适配:移动端隐藏面包屑,操作区换行
- 复用 global.css 中 .xx-page-head 基础样式
- 集成到所有页面:Dashboard、素材库、配音库、标题库、
  任务历史、成品库、模板库、一键生成、订阅页
This commit is contained in:
CI Bot
2026-06-30 21:33:02 +08:00
parent 381f38bd52
commit 8d185492d7
11 changed files with 427 additions and 124 deletions
+161
View File
@@ -0,0 +1,161 @@
/* ============================================================
PageHead.css - 页面头部扩展样式(Task 1.4)
基础样式复用 global.css 中的 .xx-page-head,
本文件补充面包屑、标题区、操作区等扩展样式。
结构:
.xx-page-head 头部容器(global.css 定义)
├── .xx-page-head-left 左侧区域
│ ├── .xx-page-breadcrumb 面包屑导航
│ │ └── .xx-page-breadcrumb-item 面包屑项
│ │ ├── .xx-page-breadcrumb-separator 分隔符
│ │ ├── .xx-page-breadcrumb-link 可点击链接
│ │ ├── .xx-page-breadcrumb-current 当前页(不可点击)
│ │ └── .xx-page-breadcrumb-home 首页图标
│ └── .xx-page-head-title 标题 + 描述
└── .xx-page-head-actions 右侧操作按钮区
============================================================ */
/* ── 左侧区域 ─────────────────────────────────────────────── */
.xx-page-head-left {
display: flex;
flex-direction: column;
gap: var(--space-xs);
min-width: 0; /* 允许内容收缩,防止溢出 */
}
/* ── 面包屑导航 ───────────────────────────────────────────── */
.xx-page-breadcrumb {
margin: 0;
padding: 0;
}
.xx-page-breadcrumb ol {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-xxs);
margin: 0;
padding: 0;
list-style: none;
}
.xx-page-breadcrumb-item {
display: flex;
align-items: center;
gap: var(--space-xxs);
font-size: var(--font-size-sm);
color: var(--text-tertiary);
line-height: var(--line-height-tight);
}
/* 分隔符 */
.xx-page-breadcrumb-separator {
font-size: 10px;
color: var(--text-quaternary);
flex-shrink: 0;
}
/* 可点击链接 */
.xx-page-breadcrumb-link {
display: inline-flex;
align-items: center;
gap: var(--space-xxs);
color: var(--text-secondary);
text-decoration: none;
transition: var(--transition-colors);
border-radius: var(--radius-xs);
padding: 2px 4px;
margin: -2px -4px;
}
.xx-page-breadcrumb-link:hover {
color: var(--primary-color);
background: var(--primary-soft);
}
/* 当前页(不可点击) */
.xx-page-breadcrumb-current {
display: inline-flex;
align-items: center;
gap: var(--space-xxs);
color: var(--text-primary);
font-weight: var(--font-weight-medium);
}
/* 首页图标 */
.xx-page-breadcrumb-home {
font-size: 12px;
}
/* ── 标题区 ───────────────────────────────────────────────── */
.xx-page-head-title h2 {
margin: 0;
}
.xx-page-head-title p {
margin: var(--space-xxs) 0 0;
}
/* ── 右侧操作区 ───────────────────────────────────────────── */
.xx-page-head-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-shrink: 0;
margin-left: var(--space-lg);
}
/* ============================================================
响应式 —— 平板(≤1024px)
操作区按钮紧凑排列
============================================================ */
@media (max-width: 1024px) {
.xx-page-head-actions {
gap: var(--space-xs);
margin-left: var(--space-md);
}
}
/* ============================================================
响应式 —— 移动端(≤768px)
隐藏面包屑,简化布局
============================================================ */
@media (max-width: 768px) {
/* 隐藏面包屑 */
.xx-page-breadcrumb {
display: none;
}
/* 标题区占满宽度 */
.xx-page-head-left {
flex: 1;
}
/* 操作区换行显示 */
.xx-page-head {
flex-wrap: wrap;
gap: var(--space-sm);
}
.xx-page-head-actions {
margin-left: 0;
width: 100%;
justify-content: flex-start;
}
}
/* ============================================================
响应式 —— 小屏手机(≤480px)
标题缩小,操作区堆叠
============================================================ */
@media (max-width: 480px) {
.xx-page-head-title h2 {
font-size: 20px;
}
.xx-page-head-actions {
flex-wrap: wrap;
}
}
+185
View File
@@ -0,0 +1,185 @@
/**
* PageHead - 页面头部组件(Task 1.4)
*
* 功能:
* - 页面标题展示
* - 面包屑导航(自动根据路由生成,也支持手动传入)
* - 右侧操作按钮区(slot,由页面自行填充)
* - 响应式:移动端简化布局(隐藏面包屑,缩小标题)
*
* 复用 global.css 中已有的 .xx-page-head 基础样式,
* 补充面包屑、操作区等扩展样式。
*/
import React from "react";
import { useLocation, useNavigate, Link } from "react-router-dom";
import { RightOutlined, HomeOutlined } from "@ant-design/icons";
import "./PageHead.css";
/* ── 类型定义 ─────────────────────────────────────────────── */
/** 面包屑项 */
export interface BreadcrumbItem {
/** 显示文字 */
label: string;
/** 路由路径,不传则为当前页(不可点击) */
path?: string;
}
/** PageHead 组件属性 */
export interface PageHeadProps {
/** 页面标题 */
title: string;
/** 页面描述(可选,显示在标题下方) */
description?: string;
/** 面包屑项(可选,不传则自动根据路由生成) */
breadcrumb?: BreadcrumbItem[];
/** 右侧操作区内容(按钮等) */
actions?: React.ReactNode;
/** 是否隐藏面包屑 */
hideBreadcrumb?: boolean;
}
/* ── 路由 → 标题映射(用于自动生成面包屑) ────────────────── */
const ROUTE_TITLE_MAP: Record<string, string> = {
"/dashboard": "首页",
"/generate": "一键生成",
"/assets": "素材库",
"/voices": "配音库",
"/titles": "标题库",
"/products": "成片库",
"/templates": "模板库",
"/history": "任务历史",
"/admin": "控制台",
"/admin/users": "用户管理",
"/admin/analytics": "数据分析",
"/admin/monitor": "系统监控",
"/admin/logs": "系统日志",
"/subscription": "订阅管理",
"/subscription/upgrade": "升级订阅",
"/subscription/billing": "账单管理",
"/profile": "个人设置",
"/editing-planner": "剪辑规划",
"/my-templates": "我的模板",
"/duplication": "查重",
"/duplication/results": "查重结果",
};
/* ── 自动生成面包屑 ─────────────────────────────────────── */
/** 根据当前路径生成面包屑 */
const generateBreadcrumb = (pathname: string): BreadcrumbItem[] => {
const items: BreadcrumbItem[] = [{ label: "首页", path: "/dashboard" }];
// 首页本身不需要面包屑
if (pathname === "/" || pathname === "/dashboard") {
return items;
}
// 逐级拆分路径,生成中间层级
const segments = pathname.split("/").filter(Boolean);
let currentPath = "";
for (let i = 0; i < segments.length; i++) {
currentPath += `/${segments[i]}`;
const title = ROUTE_TITLE_MAP[currentPath];
if (title) {
// 最后一级不带 path(当前页面,不可点击)
const isLast = i === segments.length - 1;
items.push({
label: title,
path: isLast ? undefined : currentPath,
});
} else {
// 动态路由段(如 :id),用路径片段做 label
const isLast = i === segments.length - 1;
items.push({
label: segments[i],
path: isLast ? undefined : currentPath,
});
}
}
return items;
};
/* ── 组件 ───────────────────────────────────────────────── */
const PageHead: React.FC<PageHeadProps> = ({
title,
description,
breadcrumb,
actions,
hideBreadcrumb = false,
}) => {
const location = useLocation();
const navigate = useNavigate();
// 使用传入的面包屑或自动生成
const breadcrumbItems = breadcrumb ?? generateBreadcrumb(location.pathname);
// 首页不显示面包屑
const showBreadcrumb =
!hideBreadcrumb &&
breadcrumbItems.length > 1 &&
location.pathname !== "/" &&
location.pathname !== "/dashboard";
return (
<header className="xx-page-head">
<div className="xx-page-head-left">
{/* 面包屑导航 */}
{showBreadcrumb && (
<nav className="xx-page-breadcrumb" aria-label="面包屑导航">
<ol>
{breadcrumbItems.map((item, index) => {
const isLast = index === breadcrumbItems.length - 1;
return (
<li key={`${item.label}-${index}`} className="xx-page-breadcrumb-item">
{index > 0 && (
<RightOutlined className="xx-page-breadcrumb-separator" />
)}
{item.path && !isLast ? (
<Link
to={item.path}
className="xx-page-breadcrumb-link"
onClick={(e) => {
e.preventDefault();
navigate(item.path!);
}}
>
{index === 0 ? (
<HomeOutlined className="xx-page-breadcrumb-home" />
) : null}
<span>{item.label}</span>
</Link>
) : (
<span className="xx-page-breadcrumb-current" aria-current="page">
{index === 0 ? (
<HomeOutlined className="xx-page-breadcrumb-home" />
) : null}
<span>{item.label}</span>
</span>
)}
</li>
);
})}
</ol>
</nav>
)}
{/* 标题 + 描述 */}
<div className="xx-page-head-title">
<h2>{title}</h2>
{description && <p>{description}</p>}
</div>
</div>
{/* 右侧操作区 */}
{actions && <div className="xx-page-head-actions">{actions}</div>}
</header>
);
};
export default PageHead;
+14 -22
View File
@@ -40,8 +40,9 @@ import {
uploadAssetDirect,
} from "@/api/assets";
import { getOrCreateDefaultProject } from "@/api/projects";
import PageHead from "@/components/layout/PageHead";
const { Title, Text } = Typography;
const { Text } = Typography;
const { Dragger } = Upload;
/** 最大文件大小:2048MB(与后端 OSS_DIRECT_UPLOAD_MAX_MB=2000 对齐,留少量余量) */
@@ -193,27 +194,18 @@ const AssetLibrary: React.FC = () => {
return (
<div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
flexWrap: "wrap",
gap: 12,
}}
>
<Title level={3} style={{ margin: 0 }}>
素材库
</Title>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalOpen(true)}
>
新建素材库
</Button>
</div>
<PageHead
title="素材库"
actions={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalOpen(true)}
>
新建素材库
</Button>
}
/>
{libsLoading ? (
<div style={{ textAlign: "center", padding: 60 }}>
+2 -6
View File
@@ -11,7 +11,6 @@ import {
Statistic,
Table,
Tag,
Typography,
Spin,
Button,
Progress,
@@ -31,8 +30,7 @@ import {
import { useNavigate } from "react-router-dom";
import { getDashboardOverview } from "@/api/dashboard";
import type { ColumnsType } from "antd/es/table";
const { Title } = Typography;
import PageHead from "@/components/layout/PageHead";
/** 格式化文件大小 */
const formatFileSize = (bytes: number): string => {
@@ -126,9 +124,7 @@ const Dashboard: React.FC = () => {
return (
<div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<Title level={3} style={{ marginBottom: 24 }}>
概览
</Title>
<PageHead title="概览" />
{/* 用量统计卡片 */}
<Row gutter={[16, 16]}>
+3 -4
View File
@@ -32,8 +32,9 @@ import { getAssetLibraries, getAssets, type AssetItem } from "@/api/assets";
import { getTitles } from "@/api/titles";
import { getVoices } from "@/api/voices";
import { createGenerationTask } from "@/api/tasks";
import PageHead from "@/components/layout/PageHead";
const { Title, Text } = Typography;
const { Text } = Typography;
const GeneratePage: React.FC = () => {
const navigate = useNavigate();
@@ -325,9 +326,7 @@ const GeneratePage: React.FC = () => {
return (
<div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<Title level={3} style={{ marginBottom: 24 }}>
一键生成
</Title>
<PageHead title="一键生成" />
<Steps
current={currentStep}
+19 -30
View File
@@ -6,7 +6,6 @@ import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Button,
Typography,
Table,
Tag,
Empty,
@@ -25,8 +24,7 @@ import {
} from "@ant-design/icons";
import { getUserTasks, retryTask, type TaskItem } from "@/api/tasks";
import type { ColumnsType } from "antd/es/table";
const { Title } = Typography;
import PageHead from "@/components/layout/PageHead";
/** 任务状态标签 */
const StatusTag: React.FC<{ status: string }> = ({ status }) => {
@@ -152,33 +150,24 @@ const TaskHistory: React.FC = () => {
return (
<div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
flexWrap: "wrap",
gap: 12,
}}
>
<Title level={3} style={{ margin: 0 }}>
任务历史
</Title>
<Select
placeholder="按状态筛选"
value={statusFilter || undefined}
onChange={setStatusFilter}
allowClear
style={{ width: 150 }}
options={[
{ value: "pending", label: "等待中" },
{ value: "processing", label: "处理中" },
{ value: "completed", label: "已完成" },
{ value: "failed", label: "失败" },
]}
/>
</div>
<PageHead
title="任务历史"
actions={
<Select
placeholder="按状态筛选"
value={statusFilter || undefined}
onChange={setStatusFilter}
allowClear
style={{ width: 150 }}
options={[
{ value: "pending", label: "等待中" },
{ value: "processing", label: "处理中" },
{ value: "completed", label: "已完成" },
{ value: "failed", label: "失败" },
]}
/>
}
/>
{isLoading ? (
<div style={{ textAlign: "center", padding: 60 }}>
@@ -32,8 +32,9 @@ import {
getProductDownloadUrl,
type ProductItem,
} from "@/api/products";
import PageHead from "@/components/layout/PageHead";
const { Title, Text } = Typography;
const { Text } = Typography;
/** 状态标签 */
const StatusTag: React.FC<{ status: string }> = ({ status }) => {
@@ -84,9 +85,7 @@ const ProductLibrary: React.FC = () => {
return (
<div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<Title level={3} style={{ marginBottom: 24 }}>
成品库
</Title>
<PageHead title="成品库" />
{isLoading ? (
<div style={{ textAlign: "center", padding: 60 }}>
+5 -4
View File
@@ -3,6 +3,7 @@
*/
import React, { useState } from "react";
import { message } from "antd";
import PageHead from "@/components/layout/PageHead";
import "./Plans.css";
const PLANS = [
@@ -76,10 +77,10 @@ const Plans: React.FC = () => {
return (
<div className="xx-plans-page">
<div className="xx-page-head">
<h2>选择适合您的方案</h2>
<p>灵活定价,满足不同规模的创作需求</p>
</div>
<PageHead
title="选择适合您的方案"
description="灵活定价,满足不同规模的创作需求"
/>
<div className="xx-plans-grid">
{PLANS.map((plan) => (
@@ -31,8 +31,9 @@ import {
toggleFavoriteTemplate,
type TemplateItem,
} from "@/api/templates";
import PageHead from "@/components/layout/PageHead";
const { Title, Paragraph } = Typography;
const { Paragraph } = Typography;
const TemplateLibrary: React.FC = () => {
const queryClient = useQueryClient();
@@ -78,9 +79,7 @@ const TemplateLibrary: React.FC = () => {
return (
<div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<Title level={3} style={{ marginBottom: 24 }}>
模板库
</Title>
<PageHead title="模板库" />
{/* 搜索和筛选 */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
+17 -27
View File
@@ -6,7 +6,6 @@ import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Button,
Typography,
Space,
Table,
Empty,
@@ -33,8 +32,8 @@ import {
type TitleItem,
} from "@/api/titles";
import type { ColumnsType } from "antd/es/table";
import PageHead from "@/components/layout/PageHead";
const { Title } = Typography;
const { TextArea } = Input;
const TitleLibrary: React.FC = () => {
@@ -203,31 +202,22 @@ const TitleLibrary: React.FC = () => {
return (
<div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
flexWrap: "wrap",
gap: 12,
}}
>
<Title level={3} style={{ margin: 0 }}>
标题库
</Title>
<Space wrap>
<Button
icon={<ImportOutlined />}
onClick={() => setImportModalOpen(true)}
>
批量导入
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新建标题
</Button>
</Space>
</div>
<PageHead
title="标题库"
actions={
<Space wrap>
<Button
icon={<ImportOutlined />}
onClick={() => setImportModalOpen(true)}
>
批量导入
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新建标题
</Button>
</Space>
}
/>
{/* 搜索 */}
<Input
+15 -23
View File
@@ -34,8 +34,9 @@ import {
type VoiceItem,
} from "@/api/voices";
import type { ColumnsType } from "antd/es/table";
import PageHead from "@/components/layout/PageHead";
const { Title, Text } = Typography;
const { Text } = Typography;
/** 配音状态标签 */
const StatusTag: React.FC<{ status?: string }> = ({ status }) => {
@@ -230,28 +231,19 @@ const VoiceLibrary: React.FC = () => {
return (
<div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
flexWrap: "wrap",
gap: 12,
}}
>
<Title level={3} style={{ margin: 0 }}>
配音库
</Title>
<Space wrap>
<Button icon={<RobotOutlined />} onClick={() => setAiModalOpen(true)}>
AI 生成
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新建配音
</Button>
</Space>
</div>
<PageHead
title="配音库"
actions={
<Space wrap>
<Button icon={<RobotOutlined />} onClick={() => setAiModalOpen(true)}>
AI 生成
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新建配音
</Button>
</Space>
}
/>
{isLoading ? (
<div style={{ textAlign: "center", padding: 60 }}>