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

+40
View File
@@ -0,0 +1,40 @@
import { IconAlert } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
export interface ErrorLayoutProps {
code: string;
title: string;
description: string;
traceId?: string;
actions?: ReactNode;
}
export function ErrorLayout({
code,
title,
description,
traceId,
actions,
}: ErrorLayoutProps): ReactNode {
return (
<main className="lx-error-page" data-testid={`error-${code}`}>
<div className="lx-error-page__code" aria-hidden="true">
{code}
</div>
<div className="lx-error-page__body">
<span className="lx-error-page__icon">
<IconAlert size={22} />
</span>
<p className="lx-eyebrow">灵犀 · 访问异常</p>
<h1 className="lx-error-page__title">{title}</h1>
<p className="lx-error-page__description">{description}</p>
{traceId !== undefined ? (
<p className="lx-error-page__trace">
trace_id:<code>{traceId}</code>
</p>
) : null}
<div className="lx-error-page__actions">{actions}</div>
</div>
</main>
);
}
@@ -0,0 +1,38 @@
import { Button } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
import { useNavigate } from 'react-router';
import { ROUTES } from '@/routes';
import { ErrorLayout } from './ErrorLayout';
export function ForbiddenPage(): ReactNode {
const navigate = useNavigate();
return (
<ErrorLayout
code="403"
title="没有访问权限"
description="你的账号没有该页面的权限点。如需开通,请联系平台管理员调整角色。"
actions={
<>
<Button
theme="solid"
type="primary"
onClick={() => {
void navigate(ROUTES.home);
}}
>
返回工作台
</Button>
<Button
onClick={() => {
void navigate(-1);
}}
>
返回上一页
</Button>
</>
}
/>
);
}
@@ -0,0 +1,29 @@
import { Button } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
import { useNavigate } from 'react-router';
import { ROUTES } from '@/routes';
import { ErrorLayout } from './ErrorLayout';
export function NotFoundPage(): ReactNode {
const navigate = useNavigate();
return (
<ErrorLayout
code="404"
title="页面不存在"
description="地址可能已变更或输入有误。可以回到工作台,从顶部模块菜单重新进入。"
actions={
<Button
theme="solid"
type="primary"
onClick={() => {
void navigate(ROUTES.home);
}}
>
返回工作台
</Button>
}
/>
);
}
@@ -0,0 +1,41 @@
import { Button } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { ROUTES } from '@/routes';
import { ErrorLayout } from './ErrorLayout';
export function ServerErrorPage(): ReactNode {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const traceId = searchParams.get('trace_id') ?? undefined;
return (
<ErrorLayout
code="500"
title="服务暂时不可用"
description="请求处理失败,请稍后重试。若问题持续出现,请把 trace_id 提供给平台管理员排查。"
traceId={traceId}
actions={
<>
<Button
theme="solid"
type="primary"
onClick={() => {
void navigate(0);
}}
>
重新加载
</Button>
<Button
onClick={() => {
void navigate(ROUTES.home);
}}
>
返回工作台
</Button>
</>
}
/>
);
}
@@ -0,0 +1,29 @@
import { Button } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
import { useNavigate } from 'react-router';
import { ROUTES } from '@/routes';
import { ErrorLayout } from './ErrorLayout';
export function UnauthorizedPage(): ReactNode {
const navigate = useNavigate();
return (
<ErrorLayout
code="401"
title="请先登录"
description="当前页面需要登录后访问。登录成功后会自动回到工作台。"
actions={
<Button
theme="solid"
type="primary"
onClick={() => {
void navigate(ROUTES.login);
}}
>
去登录
</Button>
}
/>
);
}
+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>
);
}
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react';
export function LoginBrandPanel(): ReactNode {
return (
<header className="lx-login__topbar">
<div className="lx-login__brand">
<span className="lx-login__mark">灵</span>
<span className="lx-login__wordmark">灵犀</span>
<span className="lx-login__brand-sub">素材生产平台 · LINGXI</span>
</div>
<span className="lx-login__badge">内部系统 · 仅限授权成员</span>
</header>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { Button, Checkbox, Input } from '@douyinfe/semi-ui-19';
import { IconLock, IconMail } from '@lingxi/ui/icons';
import type { ReactNode, SyntheticEvent } from 'react';
export interface LoginFormCardProps {
account: string;
password: string;
submitting: boolean;
onAccountChange: (value: string) => void;
onPasswordChange: (value: string) => void;
onSubmit: (event: SyntheticEvent<HTMLFormElement>) => void;
}
function LoginFormHeader(): ReactNode {
return (
<header className="lx-login__form-head">
<h2>登录灵犀</h2>
<p>使用企业账号登录素材生产平台</p>
</header>
);
}
function LoginFormFooter(): ReactNode {
return (
<footer className="lx-login__form-foot">
<span className="lx-login__form-foot-dot" aria-hidden="true" />
账号由平台管理员开通,如需协助请联系 IT 服务台
</footer>
);
}
function LoginFields({
account,
password,
onAccountChange,
onPasswordChange,
}: Pick<
LoginFormCardProps,
'account' | 'password' | 'onAccountChange' | 'onPasswordChange'
>): ReactNode {
return (
<>
<label className="lx-field">
<span className="lx-field__label">账号</span>
<Input
size="large"
value={account}
onChange={onAccountChange}
placeholder="请输入账号或邮箱"
prefix={<IconMail size={16} />}
autoComplete="username"
/>
</label>
<label className="lx-field">
<span className="lx-field__label">密码</span>
<Input
size="large"
type="password"
value={password}
onChange={onPasswordChange}
placeholder="请输入密码"
prefix={<IconLock size={16} />}
autoComplete="current-password"
/>
</label>
<div className="lx-login__form-meta">
<Checkbox defaultChecked>记住我(7 天)</Checkbox>
<button type="button" className="lx-link-button">
忘记密码?
</button>
</div>
</>
);
}
export function LoginFormCard({
account,
password,
submitting,
onAccountChange,
onPasswordChange,
onSubmit,
}: LoginFormCardProps): ReactNode {
return (
<div className="lx-login__form">
<LoginFormHeader />
<form onSubmit={onSubmit} data-testid="login-form">
<LoginFields
account={account}
password={password}
onAccountChange={onAccountChange}
onPasswordChange={onPasswordChange}
/>
<Button
htmlType="submit"
theme="solid"
type="primary"
size="large"
block
loading={submitting}
data-testid="login-submit"
>
登录
</Button>
</form>
<LoginFormFooter />
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { ReactNode } from 'react';
import { LoginBrandPanel } from './LoginBrandPanel';
import { LoginFormCard } from './LoginFormCard';
import { useLoginForm } from './use-login-form';
export default function LoginPage(): ReactNode {
const { account, password, submitting, setAccount, setPassword, handleSubmit } = useLoginForm();
return (
<main className="lx-login">
<LoginBrandPanel />
<section className="lx-login__panel">
<LoginFormCard
account={account}
password={password}
submitting={submitting}
onAccountChange={setAccount}
onPasswordChange={setPassword}
onSubmit={(event) => {
void handleSubmit(event);
}}
/>
</section>
<footer className="lx-login__footer">
<span>© 2026 灵犀 · 内部系统</span>
<span className="lx-login__footer-links">
<span>隐私政策</span>
<span>服务条款</span>
<span>华东一区</span>
</span>
</footer>
</main>
);
}
@@ -0,0 +1,70 @@
import { notifyError, notifyWarning } from '@lingxi/ui';
import { type SyntheticEvent, useState } from 'react';
import { useNavigate } from 'react-router';
import { ApiError, request } from '@/api/client';
import { ROUTES } from '@/routes';
import type { AuthUser } from '@/stores/auth-store';
import { useAuthStore } from '@/stores/auth-store';
interface LoginResponse {
accessToken: string;
refreshToken?: string;
user: AuthUser;
permissions?: string[];
}
const demoEnabled = import.meta.env.VITE_PUBLIC_DEMO_SESSION !== 'false';
export function useLoginForm(): {
account: string;
password: string;
submitting: boolean;
setAccount: (value: string) => void;
setPassword: (value: string) => void;
handleSubmit: (event: SyntheticEvent<HTMLFormElement>) => Promise<void>;
} {
const navigate = useNavigate();
const signInDemo = useAuthStore((state) => state.signInDemo);
const setSession = useAuthStore((state) => state.setSession);
const [account, setAccount] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (event: SyntheticEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
if (account.trim() === '' || password === '') {
notifyWarning('请输入账号和密码');
return;
}
setSubmitting(true);
try {
if (demoEnabled) {
signInDemo();
await navigate(ROUTES.home, { replace: true });
return;
}
const session = await request<LoginResponse>('/auth/login', {
method: 'POST',
auth: false,
body: { email: account.trim(), password },
});
setSession({
accessToken: session.accessToken,
refreshToken: session.refreshToken ?? null,
user: session.user,
permissions: session.permissions,
});
await navigate(ROUTES.home, { replace: true });
} catch (error) {
notifyError(
error instanceof Error ? error.message : '登录失败,请稍后重试',
error instanceof ApiError ? error.traceId : undefined,
);
} finally {
setSubmitting(false);
}
};
return { account, password, submitting, setAccount, setPassword, handleSubmit };
}
@@ -0,0 +1,63 @@
import { PageEmpty } from '@lingxi/ui';
import type { ReactNode } from 'react';
import { Link, useParams } from 'react-router';
import {
filterNavByPermissions,
findNavModule,
NAV_MODULES,
type NavItem,
} from '@/navigation/nav-config';
import { ROUTES } from '@/routes';
import { useAuthStore } from '@/stores/auth-store';
function resolvePage(
moduleKey: string | undefined,
itemKey: string | undefined,
): { moduleLabel: string; item: NavItem } | null {
if (moduleKey === undefined) {
return null;
}
const permissions = new Set(useAuthStore.getState().permissions);
const modules = filterNavByPermissions(NAV_MODULES, permissions);
const module = findNavModule(modules, moduleKey);
if (module === undefined) {
return null;
}
const item = module.items.find((candidate) => candidate.key === itemKey) ?? module.items[0];
return item === undefined ? null : { moduleLabel: module.label, item };
}
export default function WorkspacePage(): ReactNode {
const { moduleKey, itemKey } = useParams<{ moduleKey: string; itemKey?: string }>();
const page = resolvePage(moduleKey, itemKey);
if (page === null) {
return (
<section className="lx-panel lx-workspace-missing">
<p className="lx-eyebrow">404</p>
<h1>页面不存在</h1>
<p>该功能可能已调整或你没有访问权限。</p>
<Link to={ROUTES.home} className="lx-link-button">
返回工作台
</Link>
</section>
);
}
return (
<section className="lx-workspace-page">
<header className="lx-page-header">
<div>
<p className="lx-eyebrow">{page.moduleLabel}</p>
<h1>{page.item.label}</h1>
<p className="lx-page-header__description">{page.item.description}</p>
</div>
</header>
<div className="lx-panel lx-panel--empty">
<PageEmpty />
</div>
</section>
);
}