CI / backend (push) Canceled after 0s
问题:侧栏贴左边,内容区却用 mx-auto max-w-6xl 居中,窗口一宽就出现 "侧栏到内容之间一大片空白、内容右侧又一大片空白"的割裂感。2K 屏上尤其明显。 做法(用户给的两个方向里选"侧栏贴边 + 内容自适应"): - AppShell 内容区去掉居中与固定宽度,改为随窗口铺开,断点加宽内边距 (lg 32 / xl 48 / 2xl 64),侧栏在 xl 从 240 加宽到 264 - 宽了之后不能只是把元素拉长,所以同步改成"用宽度"而不是"被拉长": - 任务行:md 以上标题左、状态/优先级/截止/标签右对齐成一行,窄屏仍折两行 - 知识、工单列表:md 两列 / 2xl 三列,卡片不再横跨整屏 - 看板本来就是四等分列,自适应后自然铺满 - PageHeader 的描述文字限 max-w-3xl,宽屏下不出现过长的行宽 e2e/shots.mjs 支持 VIEWPORT=2560x1400 SUFFIX=-wide 覆盖视口,用来核对宽屏。 三个 e2e 复跑全绿;tsc 0 错,构建 242.02 kB / gzip 76.78 kB。
259 lines
9.7 KiB
TypeScript
259 lines
9.7 KiB
TypeScript
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 xl:w-[264px]">
|
|
<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">
|
|
{/*
|
|
内容区随窗口变宽(不再固定 max-w-6xl 居中):
|
|
侧栏贴左,内容从侧栏右侧一直铺到右边距,宽屏下不留"两边各一大块空白"。
|
|
文字类的阅读宽度靠各自的 max-w-3xl 约束,列表/看板这类适合铺开的结构则吃满。
|
|
*/}
|
|
<div className="w-full px-5 py-6 lg:px-8 lg:py-8 xl:px-12 2xl:px-16">{children}</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|