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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user