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>
);
}
-34
View File
@@ -1,34 +0,0 @@
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../App";
export function Layout({ children }: { children: React.ReactNode }) {
const { user, signOut } = useAuth();
const navigate = useNavigate();
return (
<div className="mx-auto flex min-h-full max-w-5xl flex-col px-6 py-6">
<header className="mb-6 flex items-center justify-between">
<Link to="/" className="flex items-baseline gap-2">
<span className="text-xl font-semibold tracking-tight">达道Task</span>
<span className="text-xs text-ink-500">任务 → 工单 → 知识,一条流</span>
</Link>
<div className="flex items-center gap-3 text-sm text-ink-500">
{user && <span>{user.nickname}</span>}
{user ? (
<button className="btn-ghost" onClick={signOut}>
退出
</button>
) : (
<button className="btn-ghost" onClick={() => navigate("/login")}>
登录
</button>
)}
</div>
</header>
<main className="flex-1">{children}</main>
<footer className="mt-8 text-xs text-ink-500">
达道 · 从任务到知识,一条道走到底
</footer>
</div>
);
}
+303 -165
View File
@@ -3,6 +3,19 @@ import { useAuth } from "../App";
import { ApiError, api } from "../lib/client";
import { PRIORITY_LABEL, STATUS_LABEL } from "../lib/parse";
import type { Activity, Item, ItemDetail, Member, Page, Role, TicketInfo } from "../lib/types";
import {
Book,
Clock,
Flag,
Link2,
Lock,
Sparkle,
TicketIcon,
Trash,
Users,
} from "./icons";
import { Button } from "./ui";
import { Sheet } from "./ui";
interface Comment {
id: string;
@@ -23,6 +36,17 @@ interface Props {
onTicketChanged?: () => void;
}
const ACTION_LABEL: Record<string, string> = {
created: "创建",
status_changed: "状态变更",
promoted_to_ticket: "升级成工单",
ticket_done: "工单验收通过",
comment_added: "评论",
assigned: "指派",
knowledge_status_changed: "知识状态变更",
title_changed: "改标题",
};
function toLocalInput(iso: string | null): string {
if (!iso) return "";
const d = new Date(iso);
@@ -30,6 +54,12 @@ function toLocalInput(iso: string | null): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function initials(name: string): string {
const n = name.trim();
if (!n) return "?";
return /^[a-zA-Z]/.test(n) ? n.slice(0, 2).toUpperCase() : n.slice(-2);
}
export function TaskDetail({
wsId,
itemId,
@@ -249,14 +279,15 @@ export function TaskDetail({
const ticket = item?.kind === "ticket" ? item.ticket : undefined;
const isAssignee = !!ticket && !!user && ticket.assignee_id === user.id;
const isRequester = !!ticket && !!user && ticket.requester_id === user.id;
const pendingVerify = item?.status === "pending_verify";
function actionButtons(t: TicketInfo) {
function actionButtons() {
const buttons: { key: TicketAction | "approve" | "reject"; label: string; show: boolean }[] = [
{ key: "accept", label: "接收", show: t && isAssignee && item?.status === "open" },
{ key: "accept", label: "接收", show: isAssignee && item?.status === "open" },
{ key: "start", label: "开始办理", show: isAssignee && item?.status === "accepted" },
{ key: "submit", label: "提交验收", show: isAssignee && item?.status === "in_progress" },
{ key: "approve", label: "通过验收", show: isRequester || isAdmin },
{ key: "reject", label: "驳回", show: isRequester || isAdmin },
{ key: "approve", label: "通过验收", show: (isRequester || isAdmin) && pendingVerify },
{ key: "reject", label: "驳回", show: (isRequester || isAdmin) && pendingVerify },
{
key: "cancel",
label: "取消工单",
@@ -265,16 +296,16 @@ export function TaskDetail({
["open", "accepted", "in_progress", "pending_verify"].includes(item?.status ?? ""),
},
];
const verifyOnly = item?.status !== "pending_verify";
return (
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap items-center gap-2">
{buttons
.filter((b) => b.show && !(verifyOnly && (b.key === "approve" || b.key === "reject")))
.filter((b) => b.show)
.map((b) => (
<button
<Button
key={b.key}
className={b.key === "cancel" || b.key === "reject" ? "btn-ghost" : "btn-primary"}
variant={b.key === "approve" ? "clay" : b.key === "cancel" || b.key === "reject" ? "ghost" : "primary"}
size="sm"
disabled={busy}
onClick={() => {
if (b.key === "approve") void ticketAction("verify", { approve: true });
@@ -283,69 +314,98 @@ export function TaskDetail({
}}
>
{b.label}
</button>
</Button>
))}
</div>
);
}
const meta = item
? `${item.kind === "ticket" ? "工单" : item.kind === "knowledge" ? "知识" : "任务"} · ${
STATUS_LABEL[item.status]
} · 版本 ${item.version}`
: "加载中…";
return (
<div className="fixed inset-0 z-20 flex justify-end bg-ink-900/20" onClick={onClose}>
<aside
className="flex h-full w-full max-w-xl flex-col overflow-y-auto bg-white p-6 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
{!item ? (
<p className="text-sm text-ink-500">{error ?? "加载中…"}</p>
) : (
<>
<div className="mb-4 flex items-center justify-between text-xs text-ink-500">
<span>
{item.kind === "ticket" ? "工单 · " : ""}
{STATUS_LABEL[item.status]} · 版本 {item.version}
</span>
<div className="flex gap-2">
{/* 工单不提供删除:它该走"取消",否则原任务永远解不开 */}
{item.kind !== "ticket" && (
<button className="btn-ghost px-2 py-1 text-xs text-red-600" onClick={() => void remove()}>
删除
</button>
)}
<button className="btn-ghost px-2 py-1 text-xs" onClick={onClose}>
关闭
</button>
</div>
<Sheet
open
onClose={onClose}
width="max-w-2xl"
title={item ? item.title : "打开中…"}
subtitle={meta}
footer={
item ? (
<div className="flex items-center justify-between gap-2">
<span className="text-2xs text-ink-400">
{item.updated_at ? `最后更新 ${new Date(item.updated_at).toLocaleString("zh-CN")}` : ""}
</span>
<div className="flex items-center gap-2">
{/* 工单不提供删除:它该走"取消",否则原任务永远解不开 */}
{item.kind !== "ticket" && (
<Button variant="danger" size="sm" onClick={() => void remove()}>
<Trash size={13} />
删除
</Button>
)}
</div>
{ticket && (
<div className="mb-4 rounded-lg border border-ink-100 bg-ink-50 p-3 text-xs text-ink-700">
<div className="flex flex-wrap gap-x-4 gap-y-1">
<span>
需求人:{members.find((m) => m.user_id === ticket.requester_id)?.nickname ?? "—"}
</div>
) : null
}
>
{!item ? (
<p className="text-sm text-ink-400">{error ?? "加载中…"}</p>
) : (
<div className="space-y-5">
{ticket ? (
<section className="rounded-xl border border-line-200 bg-paper-50 p-4">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
<span className="inline-flex items-center gap-1.5 text-ink-600">
<Users size={12} className="text-ink-400" />
需求人
<b className="font-medium text-ink-900">
{members.find((m) => m.user_id === ticket.requester_id)?.nickname ?? "—"}
</b>
</span>
<span className="inline-flex items-center gap-1.5 text-ink-600">
办理人
<b className="font-medium text-ink-900">
{members.find((m) => m.user_id === ticket.assignee_id)?.nickname ?? "—"}
</b>
</span>
{ticket.reject_count > 0 ? (
<span className="pill bg-rust-50 text-rust-500">退回 {ticket.reject_count} 次</span>
) : null}
{ticket.parent_item_id ? (
<span className="inline-flex items-center gap-1 text-ink-400">
<Link2 size={11} />
由任务升级而来
</span>
<span>
办理人:{members.find((m) => m.user_id === ticket.assignee_id)?.nickname ?? "—"}
</span>
{ticket.reject_count > 0 && <span>退回 {ticket.reject_count} 次</span>}
{ticket.parent_item_id && <span>由任务升级而来</span>}
</div>
{ticket.resolution_md && (
<p className="mt-2 whitespace-pre-wrap text-ink-900">验收结论:{ticket.resolution_md}</p>
)}
<div className="mt-3 space-y-2">
<input
className="input"
placeholder="给对方的说明(提交或验收时一并发过去)"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
{actionButtons(ticket)}
</div>
) : null}
</div>
)}
{ticket.resolution_md ? (
<div className="mt-3 rounded-lg border border-line-200 bg-white p-3">
<p className="text-2xs text-ink-400">验收结论</p>
<p className="mt-1 whitespace-pre-wrap text-sm text-ink-800">{ticket.resolution_md}</p>
</div>
) : null}
<div className="mt-3 space-y-2">
<input
className="input"
placeholder="给对方的说明(提交或验收时一并发过去)"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
{actionButtons()}
</div>
</section>
) : null}
<div>
<label className="label">标题</label>
<input
className="input text-base font-medium"
className="input-flush text-lg font-medium"
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => void saveTitle()}
@@ -353,114 +413,167 @@ export function TaskDetail({
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
/>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
<label className="block">
<span className="label">优先级</span>
<select
className="input"
value={item.priority}
onChange={(e) => void patch({ priority: Number(e.target.value) }, "优先级")}
>
{[0, 1, 2, 3].map((p) => (
<option key={p} value={p}>
{PRIORITY_LABEL[p]}
</option>
))}
</select>
</label>
<label className="block">
<span className="label">截止时间</span>
<input
type="datetime-local"
className="input"
value={due}
onChange={(e) => setDue(e.target.value)}
onBlur={(e) => void saveDue(e.target.value)}
/>
</label>
</div>
<label className="mt-3 block text-sm">
<span className="label">标签(空格分隔)</span>
<input
<div className="grid grid-cols-2 gap-3">
<label className="block">
<span className="label inline-flex items-center gap-1">
<Flag size={11} />
优先级
</span>
<select
className="input"
value={tags}
onChange={(e) => setTags(e.target.value)}
onBlur={() => void saveTags()}
placeholder="周报 客户"
aria-label="优先级"
value={item.priority}
onChange={(e) => void patch({ priority: Number(e.target.value) }, "优先级")}
>
{[0, 1, 2, 3].map((p) => (
<option key={p} value={p}>
{PRIORITY_LABEL[p]}
</option>
))}
</select>
</label>
<label className="block">
<span className="label inline-flex items-center gap-1">
<Clock size={11} />
截止时间
</span>
<input
type="datetime-local"
className="input"
aria-label="截止时间"
value={due}
onChange={(e) => setDue(e.target.value)}
onBlur={(e) => void saveDue(e.target.value)}
/>
</label>
</div>
<label className="mt-3 block text-sm">
<span className="label">备注</span>
<textarea
className="input min-h-32"
value={body}
onChange={(e) => setBody(e.target.value)}
onBlur={() => void saveBody()}
/>
</label>
<label className="block">
<span className="label">标签(空格分隔)</span>
<input
className="input"
aria-label="标签"
value={tags}
onChange={(e) => setTags(e.target.value)}
onBlur={() => void saveTags()}
placeholder="周报 客户"
/>
{item.tags.length > 0 ? (
<span className="mt-1.5 flex flex-wrap gap-1">
{item.tags.map((t) => (
<span key={t.id} className="chip bg-paper-200 text-ink-600">
#{t.name}
</span>
))}
</span>
) : null}
</label>
{item.kind === "task" && !["done", "canceled"].includes(item.status) && (
<div className="mt-4 rounded-lg border border-ink-100 p-3">
<p className="text-sm font-medium">这件事卡在别人手里?升级成工单</p>
<p className="mt-1 text-xs text-ink-500">
升级后这条任务会变成「被阻塞」,工单办好并验收通过后自动回到待办,不用你自己盯。
</p>
<div className="mt-2 flex gap-2">
<select
className="input flex-1"
value={promoteTo}
onChange={(e) => setPromoteTo(e.target.value)}
>
<option value="">选择找谁办…</option>
{members
.filter((m) => m.status === "active")
.map((m) => (
<option key={m.user_id} value={m.user_id}>
{m.nickname}
{m.role === "guest" ? "(访客)" : ""}
</option>
))}
</select>
<button className="btn-primary" disabled={busy || !promoteTo} onClick={() => void promote()}>
升级成工单
</button>
</div>
<label className="block">
<span className="label">备注</span>
<textarea
className="input min-h-32 resize-y leading-relaxed"
aria-label="备注"
value={body}
onChange={(e) => setBody(e.target.value)}
onBlur={() => void saveBody()}
placeholder="背景、约束、链接…写下来,将来才想得起来"
/>
</label>
{item.kind === "task" && !["done", "canceled"].includes(item.status) ? (
<section className="rounded-xl border border-clay-200 bg-clay-50/50 p-4">
<p className="flex items-center gap-1.5 text-sm font-medium text-ink-900">
<TicketIcon size={15} className="text-clay-600" />
这件事卡在别人手里?升级成工单
</p>
<p className="mt-1 text-xs leading-relaxed text-ink-500">
升级后这条任务会变成「被阻塞」,工单办好并验收通过后自动回到待办,不用你自己盯。
</p>
<div className="mt-3 flex gap-2">
<select
className="input flex-1"
aria-label="选择办理人"
value={promoteTo}
onChange={(e) => setPromoteTo(e.target.value)}
>
<option value="">选择找谁办…</option>
{members
.filter((m) => m.status === "active")
.map((m) => (
<option key={m.user_id} value={m.user_id}>
{m.nickname}
{m.role === "guest" ? "(访客)" : ""}
</option>
))}
</select>
<Button variant="clay" disabled={busy || !promoteTo} onClick={() => void promote()}>
升级成工单
</Button>
</div>
)}
</section>
) : null}
{item.kind !== "knowledge" && item.status !== "canceled" && (
<div className="mt-4 rounded-lg border border-ink-100 p-3">
<p className="text-sm font-medium">这件事值得留下来?沉淀成知识</p>
<p className="mt-1 text-xs text-ink-500">
会把标题、备注、时间线和评论整理成一份草稿,默认进「待核对」,不会直接对外发布。
</p>
<div className="mt-2">
<button className="btn-ghost px-3 py-1 text-sm" disabled={busy} onClick={() => void toKnowledge()}>
{item.kind === "ticket" ? "沉淀成复盘" : "沉淀成知识"}
</button>
</div>
{item.kind !== "knowledge" && item.status !== "canceled" ? (
<section className="rounded-xl border border-line-200 p-4">
<p className="flex items-center gap-1.5 text-sm font-medium text-ink-900">
<Book size={15} className="text-primary-600" />
这件事值得留下来?沉淀成知识
</p>
<p className="mt-1 text-xs leading-relaxed text-ink-500">
会把标题、备注、时间线和评论整理成一份草稿,默认进「待核对」,不会直接对外发布。
</p>
<div className="mt-3">
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void toKnowledge()}>
<Sparkle size={13} className="text-clay-500" />
{item.kind === "ticket" ? "沉淀成复盘" : "沉淀成知识"}
</Button>
</div>
)}
</section>
) : null}
{notice && <p className="mt-3 text-sm text-emerald-700">{notice}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{busy && <p className="mt-3 text-xs text-ink-500">处理中…</p>}
{notice ? (
<div className="rounded-lg border border-sage-500/20 bg-sage-50 px-3 py-2 text-sm text-sage-500">
{notice}
</div>
) : null}
{error ? (
<div className="rounded-lg border border-rust-500/20 bg-rust-50 px-3 py-2 text-sm text-rust-500">
{error}
</div>
) : null}
{busy ? <p className="text-2xs text-ink-400">处理中…</p> : null}
<h3 className="mt-6 text-sm font-medium">评论({comments.length})</h3>
<ul className="mt-2 space-y-2">
<section>
<h3 className="mb-2 flex items-center gap-1.5 text-sm font-medium text-ink-800">
评论
<span className="text-ink-400">{comments.length}</span>
</h3>
<ul className="space-y-2">
{comments.length === 0 ? (
<li className="rounded-lg border border-dashed border-line-200 px-3 py-4 text-center text-xs text-ink-400">
还没有评论。写一句,将来的你会感谢现在记下来的你。
</li>
) : null}
{comments.map((c) => (
<li key={c.id} className="rounded-lg bg-ink-50 p-2 text-sm">
<p className="whitespace-pre-wrap">{c.body_md}</p>
<p className="mt-1 text-xs text-ink-500">
{new Date(c.created_at).toLocaleString("zh-CN")}
</p>
<li key={c.id} className="flex gap-2.5">
<span className="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-full bg-paper-200 text-[10px] font-medium text-ink-600">
{initials(
members.find((m) => m.user_id === c.author_id)?.nickname ?? String(c.author_id),
)}
</span>
<div className="min-w-0 flex-1 rounded-xl rounded-tl-sm bg-paper-100 px-3 py-2">
<p className="whitespace-pre-wrap text-sm text-ink-800">{c.body_md}</p>
<p className="mt-0.5 text-2xs text-ink-400">
{new Date(c.created_at).toLocaleString("zh-CN")}
</p>
</div>
</li>
))}
</ul>
<div className="mt-2 flex gap-2">
<div className="mt-2.5 flex gap-2">
<input
className="input flex-1"
placeholder="补充一句进展…"
@@ -470,28 +583,53 @@ export function TaskDetail({
if (e.key === "Enter") void addComment();
}}
/>
<button className="btn-primary" disabled={!draft.trim()} onClick={() => void addComment()}>
<Button variant="primary" disabled={!draft.trim()} onClick={() => void addComment()}>
发表
</button>
</Button>
</div>
</section>
<h3 className="mt-6 text-sm font-medium">动态</h3>
<ul className="mt-2 space-y-1 text-xs text-ink-500">
<details className="group">
<summary className="flex cursor-pointer list-none items-center gap-1.5 text-sm font-medium text-ink-800">
<Clock size={14} className="text-ink-400" />
动态
<span className="text-ink-400">{item.activities.length}</span>
<span className="ml-auto text-2xs font-normal text-ink-400 group-open:hidden">
展开
</span>
</summary>
<ul className="mt-2 space-y-1.5 border-l border-line-200 pl-3">
{item.activities.slice(0, 30).map((a: Activity) => (
<li key={a.id}>
{new Date(a.created_at).toLocaleString("zh-CN")} · {a.action}
<li key={a.id} className="text-xs text-ink-500">
<span className="text-ink-400">
{new Date(a.created_at).toLocaleString("zh-CN")}
</span>
<span className="mx-1.5 text-ink-300">·</span>
{ACTION_LABEL[a.action] ?? a.action}
{a.action === "status_changed" && a.payload
? ` ${String(a.payload.from ?? "")} → ${String(a.payload.to ?? "")}`
? ` ${STATUS_LABEL[String(a.payload.from ?? "")] ?? String(a.payload.from ?? "")} → ${
STATUS_LABEL[String(a.payload.to ?? "")] ?? String(a.payload.to ?? "")
}`
: ""}
{a.action === "ticket_submit" && a.payload?.note
? ` · ${String(a.payload.note)}`
: ""}
</li>
))}
{item.activities.length === 0 ? (
<li className="text-xs text-ink-400">还没有记录。</li>
) : null}
</ul>
</>
)}
</aside>
</div>
</details>
{ticket ? (
<p className="flex items-start gap-1.5 text-2xs leading-relaxed text-ink-400">
<Lock size={11} className="mt-0.5 shrink-0" />
工单不能删除,只能取消;删除会让当初卡住的那条任务永远解不开。
</p>
) : null}
</div>
)}
</Sheet>
);
}
-80
View File
@@ -1,80 +0,0 @@
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { api } from "../lib/client";
const TABS = [
{ path: "", label: "任务" },
{ path: "/board", label: "看板" },
{ path: "/tickets", label: "工单" },
{ path: "/knowledge", label: "知识" },
{ path: "/members", label: "成员" },
];
/**
* 工作区内的导航条。
*
* 未读数为什么放在这里:用户在哪个页面都可能被指派工单,
* 角标必须跟着走,所以让它自己拉一次、再每 30 秒对齐一次。
* `refreshKey` 用来在"刚处理完一条工单"后立刻刷新角标。
*/
export function WorkspaceNav({
wsId,
refreshKey = 0,
}: {
wsId: string;
refreshKey?: number;
}) {
const { pathname } = useLocation();
const [unread, setUnread] = useState(0);
const base = `/w/${wsId}`;
useEffect(() => {
let alive = true;
const load = () => {
api
.get<{ unread: number }>(`/workspaces/${wsId}/notifications/unread-count`)
.then((r) => {
if (alive) setUnread(r.unread);
})
.catch(() => {
/* 角标失败不打扰用户 */
});
};
void load();
const timer = window.setInterval(load, 30_000);
return () => {
alive = false;
window.clearInterval(timer);
};
}, [wsId, refreshKey]);
return (
<nav className="flex items-center gap-4 text-sm">
{TABS.map((tab) => {
const active =
tab.path === "" ? pathname === base : pathname.startsWith(base + tab.path);
return (
<Link
key={tab.label}
to={base + tab.path}
className={
active
? "font-medium text-ink-900"
: "text-ink-500 transition hover:text-ink-900"
}
>
{tab.label}
{tab.path === "/tickets" && unread > 0 && (
<span className="ml-1 rounded-full bg-accent-500 px-1.5 text-[10px] leading-4 text-white">
{unread > 99 ? "99+" : unread}
</span>
)}
</Link>
);
})}
<Link to="/" className="text-ink-500 transition hover:text-ink-900">
切换
</Link>
</nav>
);
}
+205
View File
@@ -0,0 +1,205 @@
import type { SVGProps } from "react";
type IconProps = SVGProps<SVGSVGElement> & { size?: number };
function base({ size = 16, ...rest }: IconProps) {
return {
width: size,
height: size,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.7,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
...rest,
};
}
export const Check = (p: IconProps) => (
<svg {...base(p)}>
<path d="M20 6 9 17l-5-5" />
</svg>
);
export const CheckCircle = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="12" r="9" />
<path d="m8.5 12 2.5 2.5 4.5-5" />
</svg>
);
export const Circle = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="12" r="8.5" />
</svg>
);
export const Search = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4 4" />
</svg>
);
export const Plus = (p: IconProps) => (
<svg {...base(p)}>
<path d="M12 5v14M5 12h14" />
</svg>
);
export const X = (p: IconProps) => (
<svg {...base(p)}>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
export const ChevronDown = (p: IconProps) => (
<svg {...base(p)}>
<path d="m6 9 6 6 6-6" />
</svg>
);
export const ChevronRight = (p: IconProps) => (
<svg {...base(p)}>
<path d="m9 6 6 6-6 6" />
</svg>
);
export const ChevronLeft = (p: IconProps) => (
<svg {...base(p)}>
<path d="m15 6-6 6 6 6" />
</svg>
);
export const ArrowRight = (p: IconProps) => (
<svg {...base(p)}>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
);
export const Columns = (p: IconProps) => (
<svg {...base(p)}>
<rect x="3.5" y="4" width="17" height="16" rx="2.5" />
<path d="M9.5 4v16M15 4v16" />
</svg>
);
export const TicketIcon = (p: IconProps) => (
<svg {...base(p)}>
<path d="M3.5 9.5V7a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2v2.5a2.5 2.5 0 0 0 0 5V17a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2v-2.5a2.5 2.5 0 0 0 0-5Z" />
<path d="M12 7v2M12 15v2" />
</svg>
);
export const Book = (p: IconProps) => (
<svg {...base(p)}>
<path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H19v15.5H6.5A2.5 2.5 0 0 0 4 21Z" />
<path d="M4 18.5V5.5M8.5 7.5h6" />
</svg>
);
export const Users = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="9" cy="8.5" r="3.2" />
<path d="M3.5 19.5a5.5 5.5 0 0 1 11 0" />
<path d="M16 6.2a3.2 3.2 0 0 1 0 6M17.5 15.2a5.5 5.5 0 0 1 3 4.3" />
</svg>
);
export const Inbox = (p: IconProps) => (
<svg {...base(p)}>
<path d="M3.5 13.5h4l1.2 2.2h6.6l1.2-2.2h4" />
<path d="M5.6 4.5h12.8l2.1 9v4.3a2 2 0 0 1-2 2H5.5a2 2 0 0 1-2-2V13.5Z" />
</svg>
);
export const Sparkle = (p: IconProps) => (
<svg {...base(p)}>
<path d="M12 3.5 13.6 9l5.4 1.6-5.4 1.6L12 17.5l-1.6-5.3L5 10.6 10.4 9Z" />
<path d="M18.5 16.5 19.2 19l2.3.7-2.3.8-.7 2.4" />
</svg>
);
export const Clock = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3 2" />
</svg>
);
export const Trash = (p: IconProps) => (
<svg {...base(p)}>
<path d="M4.5 7h15M9.5 7V5.5A1.5 1.5 0 0 1 11 4h2a1.5 1.5 0 0 1 1.5 1.5V7" />
<path d="M6.5 7l.8 11.2A2 2 0 0 0 9.3 20h5.4a2 2 0 0 0 2-1.8L17.5 7" />
</svg>
);
export const Lock = (p: IconProps) => (
<svg {...base(p)}>
<rect x="4.5" y="10.5" width="15" height="10" rx="2.2" />
<path d="M8 10.5V8a4 4 0 0 1 8 0v2.5" />
</svg>
);
export const Logout = (p: IconProps) => (
<svg {...base(p)}>
<path d="M14.5 4.5H7a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h7.5" />
<path d="M18 12H10M15.5 8.5 19 12l-3.5 3.5" />
</svg>
);
export const Flag = (p: IconProps) => (
<svg {...base(p)}>
<path d="M6 21V4.5M6 5h10.5l-1.7 3.6L16.5 12H6" />
</svg>
);
export const Link2 = (p: IconProps) => (
<svg {...base(p)}>
<path d="M9.5 14.5 14.5 9.5" />
<path d="M11 6.8 12.6 5.2a3.7 3.7 0 0 1 5.2 5.2l-1.6 1.6" />
<path d="M13 17.2 11.4 18.8a3.7 3.7 0 0 1-5.2-5.2l1.6-1.6" />
</svg>
);
export const More = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="6" cy="12" r="1.2" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.2" fill="currentColor" stroke="none" />
<circle cx="18" cy="12" r="1.2" fill="currentColor" stroke="none" />
</svg>
);
export const Spinner = ({ size = 16, className = "" }: { size?: number; className?: string }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
className={`animate-spin ${className}`}
aria-hidden
>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.6" opacity="0.2" fill="none" />
<path
d="M21 12a9 9 0 0 0-9-9"
stroke="currentColor"
strokeWidth="2.6"
strokeLinecap="round"
fill="none"
/>
</svg>
);
export const Logo = ({ size = 28 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden>
<rect width="32" height="32" rx="9" fill="#2F6054" />
<path
d="M9 11.5h14M9 16h9.5M9 20.5h6"
stroke="#EFF5F3"
strokeWidth="2"
strokeLinecap="round"
/>
<circle cx="23" cy="20.5" r="3" fill="#C2703D" />
</svg>
);
+319
View File
@@ -0,0 +1,319 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { Check, X } from "./icons";
/* ——————————————————————————— 按钮 ——————————————————————————— */
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "ghost" | "subtle" | "clay" | "danger";
size?: "sm" | "md" | "lg";
};
export function Button({ variant = "ghost", size = "md", className = "", ...rest }: ButtonProps) {
const variantClass = {
primary: "btn-primary",
ghost: "btn-ghost",
subtle: "btn-subtle",
clay: "btn-clay",
danger: "btn-danger",
}[variant];
const sizeClass = { sm: "btn-sm", md: "", lg: "btn-lg" }[size];
return <button className={`${variantClass} ${sizeClass} ${className}`} {...rest} />;
}
/* ——————————————————————————— 表单 ——————————————————————————— */
export function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: ReactNode;
}) {
return (
<label className="block">
<span className="label">{label}</span>
{children}
{hint ? <span className="mt-1 block text-2xs text-ink-400">{hint}</span> : null}
</label>
);
}
export function Segmented<T extends string>({
value,
onChange,
options,
}: {
value: T;
onChange: (v: T) => void;
options: { value: T; label: string; count?: number }[];
}) {
return (
<div className="inline-flex items-center gap-0.5 rounded-lg border border-line-200 bg-paper-50 p-0.5">
{options.map((o) => {
const active = o.value === value;
return (
<button
key={o.value}
onClick={() => onChange(o.value)}
className={`rounded-[7px] px-2.5 py-1 text-xs font-medium transition-colors ${
active
? "bg-white text-ink-900 shadow-sm ring-1 ring-line-200"
: "text-ink-500 hover:text-ink-800"
}`}
>
{o.label}
{typeof o.count === "number" ? (
<span className={`ml-1 tabular-nums ${active ? "text-ink-400" : "text-ink-300"}`}>
{o.count}
</span>
) : null}
</button>
);
})}
</div>
);
}
/* ——————————————————————————— 状态与优先级 ——————————————————————————— */
const STATUS_STYLE: Record<string, string> = {
inbox: "bg-ink-100 text-ink-600",
todo: "bg-sky-50 text-sky-500",
doing: "bg-primary-50 text-primary-600",
blocked: "bg-wheat-50 text-wheat-500",
done: "bg-sage-50 text-sage-500",
canceled: "bg-ink-100 text-ink-400",
open: "bg-clay-50 text-clay-600",
accepted: "bg-sky-50 text-sky-500",
in_progress: "bg-primary-50 text-primary-600",
pending_verify: "bg-wheat-50 text-wheat-500",
draft: "bg-ink-100 text-ink-600",
in_review: "bg-wheat-50 text-wheat-500",
published: "bg-sage-50 text-sage-500",
archived: "bg-ink-100 text-ink-400",
};
export function StatusPill({ status, label }: { status: string; label: string }) {
return (
<span className={`pill ${STATUS_STYLE[status] ?? "bg-ink-100 text-ink-600"}`}>{label}</span>
);
}
export function PriorityDot({ level }: { level: number }) {
if (!level) return null;
const cls = level >= 3 ? "bg-rust-500" : level === 2 ? "bg-wheat-500" : "bg-ink-300";
const title = level >= 3 ? "高优先级" : level === 2 ? "中优先级" : "低优先级";
return <span title={title} className={`h-1.5 w-1.5 shrink-0 rounded-full ${cls}`} />;
}
/* ——————————————————————————— 空态与骨架 ——————————————————————————— */
export function EmptyState({
icon,
title,
hint,
action,
}: {
icon?: ReactNode;
title: string;
hint?: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
{icon ? (
<div className="mb-3 grid h-11 w-11 place-items-center rounded-xl border border-line-200 bg-paper-50 text-ink-300">
{icon}
</div>
) : null}
<p className="text-base font-medium text-ink-700">{title}</p>
{hint ? <p className="mt-1 max-w-sm text-sm text-ink-400">{hint}</p> : null}
{action ? <div className="mt-4">{action}</div> : null}
</div>
);
}
export function SkeletonRows({ rows = 4 }: { rows?: number }) {
return (
<div className="divide-y divide-line-100">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3.5">
<div className="skeleton h-4 w-4 rounded-full" />
<div className="skeleton h-3.5 flex-1" style={{ maxWidth: `${60 - i * 6}%` }} />
<div className="skeleton h-3 w-14" />
</div>
))}
</div>
);
}
/* ——————————————————————————— 页头 ——————————————————————————— */
export function PageHeader({
title,
desc,
actions,
eyebrow,
}: {
title: string;
desc?: string;
actions?: ReactNode;
eyebrow?: ReactNode;
}) {
return (
<header className="mb-6 flex flex-wrap items-end justify-between gap-3">
<div>
{eyebrow ? <div className="mb-1 text-2xs font-medium text-ink-400">{eyebrow}</div> : null}
<h1 className="text-2xl font-semibold">{title}</h1>
{desc ? <p className="mt-1 text-sm text-ink-500">{desc}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</header>
);
}
/* ——————————————————————————— 提示条 ——————————————————————————— */
export function InlineAlert({
tone = "error",
children,
}: {
tone?: "error" | "info" | "success";
children: ReactNode;
}) {
const cls = {
error: "border-rust-500/20 bg-rust-50 text-rust-500",
info: "border-sky-500/20 bg-sky-50 text-sky-500",
success: "border-sage-500/20 bg-sage-50 text-sage-500",
}[tone];
return (
<div className={`animate-fade-in rounded-lg border px-3 py-2 text-sm ${cls}`}>
{children}
</div>
);
}
/* ——————————————————————————— Toast ——————————————————————————— */
type Toast = { id: number; text: string; tone: "info" | "success" | "error" };
const ToastContext = createContext<{ push: (text: string, tone?: Toast["tone"]) => void } | null>(
null,
);
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast 必须在 ToastProvider 内使用");
return ctx;
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<Toast[]>([]);
const seq = useRef(0);
const push = useCallback((text: string, tone: Toast["tone"] = "info") => {
const id = ++seq.current;
setItems((prev) => [...prev.slice(-2), { id, text, tone }]);
window.setTimeout(() => setItems((prev) => prev.filter((t) => t.id !== id)), 3200);
}, []);
const value = useMemo(() => ({ push }), [push]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="pointer-events-none fixed bottom-6 left-1/2 z-[60] flex -translate-x-1/2 flex-col items-center gap-2">
{items.map((t) => (
<div
key={t.id}
className="animate-slide-up pointer-events-auto flex items-center gap-2 rounded-xl border border-line-200 bg-ink-900 px-3.5 py-2 text-sm text-paper-50 shadow-pop"
>
<span
className={
t.tone === "error"
? "text-rust-500"
: t.tone === "success"
? "text-sage-500"
: "text-primary-300"
}
>
<Check size={14} />
</span>
{t.text}
</div>
))}
</div>
</ToastContext.Provider>
);
}
/* ——————————————————————————— 抽屉 ——————————————————————————— */
export function Sheet({
open,
onClose,
title,
subtitle,
children,
footer,
width = "max-w-xl",
}: {
open: boolean;
onClose: () => void;
title: ReactNode;
subtitle?: ReactNode;
children: ReactNode;
footer?: ReactNode;
width?: string;
}) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex justify-end">
<div
className="absolute inset-0 animate-fade-in bg-ink-900/25 backdrop-blur-[2px]"
onClick={onClose}
/>
<section
role="dialog"
aria-modal="true"
aria-label={typeof title === "string" ? title : "详情"}
className={`relative flex h-full w-full ${width} animate-slide-in-right flex-col border-l border-line-200 bg-white shadow-pop`}
>
<header className="flex items-start justify-between gap-3 border-b border-line-100 px-6 py-4">
<div className="min-w-0">
<div className="text-base font-semibold text-ink-900">{title}</div>
{subtitle ? <div className="mt-0.5 text-xs text-ink-400">{subtitle}</div> : null}
</div>
<Button variant="subtle" size="sm" onClick={onClose} aria-label="关闭">
<X size={15} />
关闭
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">{children}</div>
{footer ? (
<footer className="border-t border-line-100 bg-paper-50 px-6 py-3">{footer}</footer>
) : null}
</section>
</div>
);
}