feat(web): 重做设计系统与全部页面,前端观感对齐当下审美

用户反馈"UI 太丑、太简陋"。不是换配色,而是先立 token 再让页面去用:

- tailwind.config.js:纸白 paper / 墨绿 primary / 陶土橙 clay + 语义色
  sage/wheat/rust/sky,字号阶、圆角阶、投影、动效
- index.css:卡片、按钮五种变体、输入、pill/chip、导航、骨架屏
- 新增 icons.tsx(线性图标集)与 ui.tsx(Button/Sheet/Toast/空态/骨架/PageHeader)
- 新增 AppShell 取代 Layout + WorkspaceNav:桌面侧栏 + 移动顶部横条,工单未读角标
- 六个页面全部重写:登录、首页、任务、看板、工单、知识、成员、邀请落地
- 详情抽屉改用 Sheet,工单态补齐需求人/办理人/退回次数/动作按钮

e2e 因 DOM 升级同步调整(抽屉按 role=dialog 定位、下拉改用 aria-label),
三套用例(7 + 8 + 7 项)全部重跑通过;tsc 0 错,构建 45 modules / gzip 76.70 kB。
This commit is contained in:
dadao
2026-10-28 18:37:35 +08:00
parent f683436689
commit 5be55fdccb
21 changed files with 2520 additions and 968 deletions
+253
View File
@@ -0,0 +1,253 @@
import { useEffect, useState, type ReactNode } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../App";
import { api } from "../lib/client";
import type { Workspace } from "../lib/types";
import {
Book,
ChevronDown,
Columns,
Inbox,
Logo,
Logout,
TicketIcon,
Users,
X,
} from "./icons";
export type Section = "tasks" | "board" | "tickets" | "knowledge" | "members";
const NAV: { key: Section; label: string; to: (id: string) => string; icon: ReactNode }[] = [
{ key: "tasks", label: "任务", to: (id) => `/w/${id}`, icon: <Inbox size={16} /> },
{ key: "board", label: "看板", to: (id) => `/w/${id}/board`, icon: <Columns size={16} /> },
{ key: "tickets", label: "工单", to: (id) => `/w/${id}/tickets`, icon: <TicketIcon size={16} /> },
{ key: "knowledge", label: "知识", to: (id) => `/w/${id}/knowledge`, icon: <Book size={16} /> },
{ key: "members", label: "成员", to: (id) => `/w/${id}/members`, icon: <Users size={16} /> },
];
function initial(name: string) {
return name.trim().slice(0, 1).toUpperCase() || "W";
}
const ROLE_TEXT: Record<string, string> = {
owner: "所有者",
admin: "管理员",
member: "成员",
guest: "访客",
};
export function AppShell({
wsId,
active,
children,
}: {
wsId: string;
active: Section;
children: ReactNode;
}) {
const { user, workspaces, signOut } = useAuth();
const navigate = useNavigate();
const [fetched, setFetched] = useState<Workspace | null>(null);
const [unread, setUnread] = useState(0);
const [switcher, setSwitcher] = useState(false);
// 深链直接进来时 workspaces 还没加载好,单独取一次工作区信息
const ws = workspaces.find((w) => w.id === wsId) ?? fetched;
useEffect(() => {
if (ws || !wsId) return;
let alive = true;
api
.get<Workspace>(`/workspaces/${wsId}`)
.then((w) => {
if (alive) setFetched(w);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [ws, wsId]);
useEffect(() => {
if (!wsId) return;
let alive = true;
const load = async () => {
try {
const r = await api.get<{ count: number }>(`/workspaces/${wsId}/notifications/unread-count`);
if (alive) setUnread(r.count);
} catch {
/* 未读数失败不该影响主流程 */
}
};
void load();
const timer = window.setInterval(load, 30_000);
return () => {
alive = false;
window.clearInterval(timer);
};
}, [wsId]);
if (!ws) {
return (
<div className="flex min-h-full items-center justify-center bg-paper-100">
<div className="flex items-center gap-2 text-sm text-ink-400">
<span className="h-2 w-2 animate-pulse rounded-full bg-primary-400" />
正在打开工作区…
</div>
</div>
);
}
const phoneMasked = user?.phone
? user.phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1 **** $2")
: "";
return (
<div className="flex min-h-full flex-col bg-paper-100 lg:flex-row">
{/* —— 侧栏(桌面)—— */}
<aside className="sticky top-0 z-20 hidden h-screen w-60 shrink-0 flex-col border-r border-line-200 bg-paper-50 lg:flex">
<div className="px-5 pb-4 pt-5">
<Link to="/" className="flex items-center gap-2.5">
<Logo size={30} />
<div className="leading-tight">
<div className="text-sm font-semibold tracking-tight text-ink-900">达道Task</div>
<div className="text-2xs text-ink-400">任务 · 工单 · 知识</div>
</div>
</Link>
</div>
<div className="relative px-3">
<button
onClick={() => setSwitcher((v) => !v)}
className="flex w-full items-center gap-2 rounded-xl border border-line-200 bg-white px-2.5 py-2 text-left transition hover:border-line-300"
>
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-primary-900 text-2xs font-semibold text-paper-50">
{initial(ws.name)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-ink-800">{ws.name}</span>
<span className="block text-2xs text-ink-400">{ROLE_TEXT[ws.role] ?? ws.role}</span>
</span>
<ChevronDown size={14} className="shrink-0 text-ink-400" />
</button>
{switcher ? (
<>
<div className="fixed inset-0 z-20" onClick={() => setSwitcher(false)} />
<div className="absolute left-3 right-3 top-[calc(100%+4px)] z-30 animate-slide-up overflow-hidden rounded-xl border border-line-200 bg-white p-1 shadow-pop">
<div className="px-2 py-1 text-2xs font-medium text-ink-400">切换工作区</div>
{workspaces.map((w) => (
<button
key={w.id}
onClick={() => {
setSwitcher(false);
const nav = NAV.find((n) => n.key === active);
navigate(nav ? nav.to(w.id) : `/w/${w.id}`);
}}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition hover:bg-paper-100 ${
w.id === ws.id ? "bg-paper-100 font-medium text-ink-900" : "text-ink-600"
}`}
>
<span className="grid h-5 w-5 place-items-center rounded bg-line-100 text-2xs font-semibold text-ink-600">
{initial(w.name)}
</span>
<span className="truncate">{w.name}</span>
</button>
))}
<div className="my-1 h-px bg-line-100" />
<Link
to="/"
onClick={() => setSwitcher(false)}
className="flex w-full items-center rounded-lg px-2 py-1.5 text-xs text-ink-500 hover:bg-paper-100 hover:text-ink-800"
>
全部工作区
</Link>
</div>
</>
) : null}
</div>
<nav className="mt-6 flex flex-1 flex-col gap-0.5 px-3">
{NAV.map((n) => {
const isActive = n.key === active;
return (
<Link key={n.key} to={n.to(ws.id)} className={`nav-item ${isActive ? "nav-item-active" : ""}`}>
<span className={isActive ? "text-primary-200" : "text-ink-400"}>{n.icon}</span>
{n.label}
{n.key === "tickets" && unread > 0 ? (
<span
className={`ml-auto rounded-full px-1.5 py-px text-2xs font-semibold tabular-nums ${
isActive ? "bg-clay-500 text-white" : "bg-clay-500/12 text-clay-600"
}`}
>
{unread > 99 ? "99+" : unread}
</span>
) : null}
</Link>
);
})}
</nav>
<div className="border-t border-line-200 px-3 py-3">
<div className="flex items-center gap-2">
<span className="grid h-7 w-7 place-items-center rounded-full bg-ink-200 text-2xs font-semibold text-ink-700">
{initial(user?.nickname ?? "我")}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-ink-800">{user?.nickname}</div>
<div className="truncate text-2xs text-ink-400">{phoneMasked}</div>
</div>
<button
onClick={signOut}
title="退出登录"
className="rounded-md p-1.5 text-ink-400 transition hover:bg-ink-100 hover:text-ink-700"
>
<Logout size={15} />
</button>
</div>
</div>
</aside>
{/* —— 顶部条(移动端)—— */}
<header className="sticky top-0 z-20 border-b border-line-200 bg-paper-50/95 backdrop-blur lg:hidden">
<div className="flex items-center justify-between px-4 py-2.5">
<Link to="/" className="flex items-center gap-2">
<Logo size={24} />
<span className="max-w-[42vw] truncate text-sm font-semibold text-ink-900">{ws.name}</span>
</Link>
<div className="flex items-center gap-1">
{unread > 0 ? (
<span className="rounded-full bg-clay-500 px-1.5 py-px text-2xs font-semibold text-white">
{unread}
</span>
) : null}
<button
onClick={signOut}
className="rounded-md p-1.5 text-ink-400 hover:bg-ink-100"
aria-label="退出登录"
>
<X size={16} />
</button>
</div>
</div>
<nav className="no-scrollbar flex gap-1 overflow-x-auto px-3 pb-2">
{NAV.map((n) => (
<Link
key={n.key}
to={n.to(ws.id)}
className={`whitespace-nowrap rounded-md px-2.5 py-1 text-xs font-medium ${
n.key === active ? "bg-primary-900 text-paper-50" : "text-ink-500"
}`}
>
{n.label}
</Link>
))}
</nav>
</header>
<main className="min-w-0 flex-1">
<div className="mx-auto w-full max-w-6xl px-5 py-6 lg:px-9 lg:py-8">{children}</div>
</main>
</div>
);
}