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