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:
1 parent
23067edb8f
commit
073d210a92
51 files changed
+4452
No files matched your search
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user