feat(web): 搭建 Web 壳层与 UI 包基线

React 19 + Vite 6 + Router 7 + TanStack Query + Zustand;Semi 二次封装与 Remix 图标统一出口;顶部模块 + 左侧功能菜单布局、Cloud Blue 主题、登录页与 401/403/404/500 页面;请求层含 token 注入、单飞刷新与统一错误提示。
This commit is contained in:
Stev_Wang committed 2026-11-02 17:21:16 +08:00
1 parent 23067edb8f
commit 073d210a92
51 files changed
+4452

No files matched your search

+373
View File
@@ -0,0 +1,373 @@
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>
);
}