Files
DadaoTask/web/src/components/AppShell.tsx
T
dadao c94e0424ed
CI / backend (push) Canceled after 0s
fix(web): 宽屏下侧栏贴边而内容固定居中,中间空出一大块
问题:侧栏贴左边,内容区却用 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。
2026-10-28 22:49:49 +08:00

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>
);
}