Files
lingxi/apps/web/src/pages/home/HomePage.tsx
T
Stev_Wang 073d210a92 feat(web): 搭建 Web 壳层与 UI 包基线
React 19 + Vite 6 + Router 7 + TanStack Query + Zustand;Semi 二次封装与 Remix 图标统一出口;顶部模块 + 左侧功能菜单布局、Cloud Blue 主题、登录页与 401/403/404/500 页面;请求层含 token 注入、单飞刷新与统一错误提示。
2026-11-02 17:21:16 +08:00

374 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Button } from '@douyinfe/semi-ui-19';
import {
IconChart,
IconChevronRight,
IconClock,
IconDownload,
IconExport,
IconImageAdd,
IconPlay,
IconScript,
IconTask,
} from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useNavigate } from 'react-router';
import { useAuthStore } from '@/stores/auth-store';
interface ProductionTask {
id: string;
name: string;
kind: string;
progress: number;
status: 'running' | 'review' | 'done';
updatedAt: string;
}
const TASKS: readonly ProductionTask[] = [
{
id: 'task-1',
name: '夏日防晒 · 图片合成批次',
kind: '图片合成',
progress: 68,
status: 'running',
updatedAt: '12 分钟前',
},
{
id: 'task-2',
name: '通勤穿搭 · 视频逐镜生成',
kind: '视频生产',
progress: 42,
status: 'running',
updatedAt: '26 分钟前',
},
{
id: 'task-3',
name: '会员日 · 脚本批量生成',
kind: '脚本',
progress: 100,
status: 'review',
updatedAt: '1 小时前',
},
{
id: 'task-4',
name: '春季新品 · 快筛候选集',
kind: '快筛',
progress: 100,
status: 'done',
updatedAt: '今天 10:24',
},
];
const STATUS_LABEL: Record<ProductionTask['status'], string> = {
running: '进行中',
review: '待审核',
done: '已完成',
};
const QUICK_ACTIONS = [
{
key: 'script',
label: '新建脚本',
hint: '从要素卡开始',
path: '/scripts/batch',
icon: IconScript,
},
{
key: 'image',
label: '图片合成',
hint: 'PSD 套版与抠图',
path: '/material/image-compose',
icon: IconImageAdd,
},
{
key: 'video',
label: '视频生产',
hint: '逐镜生成成片',
path: '/material/video',
icon: IconPlay,
},
{
key: 'export',
label: '导出素材',
hint: '勾选后导出并归档',
path: '/delivery/export',
icon: IconExport,
},
] as const;
const RECENT_ASSETS = [
{ id: 'a1', name: '防晒喷雾 · 主图 01', spec: '1080 × 1920', tone: 1 },
{ id: 'a2', name: '通勤穿搭 · 封面', spec: '1080 × 1920', tone: 2 },
{ id: 'a3', name: '会员日 · 利益点', spec: '1080 × 1920', tone: 3 },
{ id: 'a4', name: '春季新品 · 场景 A', spec: '1080 × 1920', tone: 4 },
{ id: 'a5', name: '防晒喷雾 · 细节', spec: '1080 × 1080', tone: 5 },
{ id: 'a6', name: '通勤穿搭 · 结尾卡', spec: '1080 × 1920', tone: 6 },
] as const;
function HomeHero({ userName }: { userName: string }): ReactNode {
const navigate = useNavigate();
return (
<header className="lx-home__hero">
<div className="lx-home__hero-copy">
<h1>工作台</h1>
<p className="lx-home__hero-meta">
<span>{userName},欢迎回来</span>
<span className="lx-home__hero-dot" aria-hidden="true" />
<span>2026 年 11 月 2 日 周一</span>
<span className="lx-home__hero-dot" aria-hidden="true" />
<span>今日已产出 18 条素材</span>
</p>
</div>
<div className="lx-home__hero-actions">
<Button
icon={<IconExport size={16} />}
onClick={() => {
void navigate('/delivery/export');
}}
>
导出报表
</Button>
<Button
theme="solid"
type="primary"
icon={<IconImageAdd size={16} />}
onClick={() => {
void navigate('/material/image-compose');
}}
>
新建生产任务
</Button>
</div>
</header>
);
}
const METRICS = [
{
key: 'output',
label: '今日产出',
value: '128',
unit: '条',
foot: '较昨日 +12.4%',
trend: '+12.4%',
icon: IconChart,
},
{
key: 'running',
label: '进行中任务',
value: '6',
unit: '个',
foot: '2 个排队中 · 预计 4:12 完成',
trend: '',
icon: IconTask,
},
{
key: 'review',
label: '待审核',
value: '24',
unit: '条',
foot: '8 条已超 2 小时',
trend: '',
icon: IconClock,
},
{
key: 'export',
label: '本周导出',
value: '512',
unit: '条',
foot: '较上周 +8.1%',
trend: '+8.1%',
icon: IconDownload,
},
] as const;
function HomeMetrics(): ReactNode {
return (
<section className="lx-metrics" aria-label="生产概览">
{METRICS.map((metric) => {
const Icon = metric.icon;
return (
<article className="lx-metric" key={metric.key}>
<div className="lx-metric__head">
<span>{metric.label}</span>
<span className="lx-metric__icon">
<Icon size={16} />
</span>
</div>
<p className="lx-metric__value">
{metric.value}
<span className="lx-metric__unit">{metric.unit}</span>
</p>
<p className="lx-metric__foot">
{metric.trend === '' ? (
metric.foot
) : (
<span className="lx-metric__trend">{metric.trend}</span>
)}
{metric.trend === '' ? null : ` · ${metric.foot}`}
</p>
</article>
);
})}
</section>
);
}
function TaskRow({ task }: { task: ProductionTask }): ReactNode {
return (
<div className="lx-table__row" role="row">
<span className="lx-table__name" role="cell">
{task.name}
</span>
<span className="lx-table__muted" role="cell">
{task.kind}
</span>
<span className="lx-table__progress" role="cell">
<span className="lx-progress" aria-hidden="true">
<span className="lx-progress__bar" style={{ width: `${task.progress}%` }} />
</span>
<span className="lx-progress__value">{task.progress}%</span>
</span>
<span role="cell">
<span className={`lx-chip lx-chip--${task.status}`}>{STATUS_LABEL[task.status]}</span>
</span>
<span className="lx-table__muted" role="cell">
{task.updatedAt}
</span>
</div>
);
}
function TaskPanel(): ReactNode {
const navigate = useNavigate();
return (
<section className="lx-panel lx-home__tasks" aria-labelledby="home-tasks-title">
<header className="lx-panel__head">
<div>
<h2 id="home-tasks-title">生产任务</h2>
<p>按最近更新时间排序</p>
</div>
<button
type="button"
className="lx-link-button"
onClick={() => {
void navigate('/material/tasks');
}}
>
查看全部
<IconChevronRight size={14} />
</button>
</header>
<div className="lx-table" role="table" aria-label="进行中的任务">
<div className="lx-table__head" role="row">
<span role="columnheader">任务</span>
<span role="columnheader">类型</span>
<span role="columnheader">进度</span>
<span role="columnheader">状态</span>
<span role="columnheader">更新时间</span>
</div>
{TASKS.map((task) => (
<TaskRow key={task.id} task={task} />
))}
</div>
</section>
);
}
function QuickPanel(): ReactNode {
const navigate = useNavigate();
return (
<aside className="lx-panel lx-home__quick" aria-labelledby="home-quick-title">
<header className="lx-panel__head">
<div>
<h2 id="home-quick-title">快速开始</h2>
<p>常用生产入口</p>
</div>
</header>
<div className="lx-quick-list">
{QUICK_ACTIONS.map((action) => {
const Icon = action.icon;
return (
<button
key={action.key}
type="button"
className="lx-quick-item"
onClick={() => {
void navigate(action.path);
}}
>
<span className="lx-quick-item__icon">
<Icon size={18} />
</span>
<span className="lx-quick-item__copy">
<span className="lx-quick-item__label">{action.label}</span>
<span className="lx-quick-item__hint">{action.hint}</span>
</span>
<IconChevronRight size={16} className="lx-quick-item__arrow" />
</button>
);
})}
</div>
</aside>
);
}
function RecentAssetsPanel(): ReactNode {
const navigate = useNavigate();
return (
<section className="lx-panel lx-home__recent" aria-labelledby="home-recent-title">
<header className="lx-panel__head">
<div>
<h2 id="home-recent-title">最近素材</h2>
<p>今天产出的图片与视频封面</p>
</div>
<button
type="button"
className="lx-link-button"
onClick={() => {
void navigate('/assets/library');
}}
>
打开资产库
<IconChevronRight size={14} />
</button>
</header>
<div className="lx-asset-grid">
{RECENT_ASSETS.map((asset) => (
<button
key={asset.id}
type="button"
className="lx-asset-tile"
onClick={() => {
void navigate('/assets/library');
}}
>
<span className={`lx-asset-tile__preview lx-asset-tile__preview--${asset.tone}`} />
<span className="lx-asset-tile__meta">
<span className="lx-asset-tile__name">{asset.name}</span>
<span className="lx-asset-tile__spec">{asset.spec}</span>
</span>
</button>
))}
</div>
</section>
);
}
export default function HomePage(): ReactNode {
const user = useAuthStore((state) => state.user);
return (
<div className="lx-home">
<HomeHero userName={user?.name ?? '管理员'} />
<HomeMetrics />
<div className="lx-home__grid">
<TaskPanel />
<QuickPanel />
</div>
<RecentAssetsPanel />
</div>
);
}