React 19 + Vite 6 + Router 7 + TanStack Query + Zustand;Semi 二次封装与 Remix 图标统一出口;顶部模块 + 左侧功能菜单布局、Cloud Blue 主题、登录页与 401/403/404/500 页面;请求层含 token 注入、单飞刷新与统一错误提示。
374 lines
9.9 KiB
TypeScript
374 lines
9.9 KiB
TypeScript
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>
|
||
);
|
||
}
|