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
+84
View File
@@ -0,0 +1,84 @@
import { chromium } from "playwright-core";
const API = "http://127.0.0.1:8100/api/v1";
const WEB = "http://127.0.0.1:5180";
const phone = "1370000" + String(Date.now()).slice(-4);
async function call(path, body, token, method = "POST") {
const res = await fetch(API + path, {
method,
headers: {
"content-type": "application/json",
...(token ? { authorization: "Bearer " + token } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json();
if (json.code !== 0) throw new Error(path + " -> " + JSON.stringify(json).slice(0, 200));
return json.data;
}
const sent = await call("/auth/sms/send", { phone });
const login = await call("/auth/login", { phone, code: sent.dev_code ?? "123456" });
const token = login.tokens.access_token;
const ws = await call("/workspaces", { name: "海图工作室", slug: "haitu-" + Date.now().toString(36) }, token);
const wsId = ws.id;
const tasks = [
["整理发票原件并寄给财务", "todo", 2, "#报销 财务"],
["给客户出第三季度复盘报告", "doing", 3, "#客户 周报"],
["把采集端的日志级别改回 info", "doing", 1, ""],
["和供应商确认二次报价", "blocked", 2, "#采购"],
["入职物料清单更新", "done", 0, ""],
["评审新版落地页文案", "inbox", 1, "#设计"],
];
for (const [title, status, priority, tags] of tasks) {
const item = await call(`/workspaces/${wsId}/items`, { title, status, priority }, token);
if (tags) {
const names = tags.split(" ").filter((t) => t.startsWith("#")).map((t) => t.slice(1));
if (names.length) await call(`/workspaces/${wsId}/items/${item.id}`, { version: item.version, tag_names: names }, token, "PATCH");
}
}
const shop = await call(`/workspaces/${wsId}/items`, { title: "官网改版:首页轮播要换成新版素材", status: "todo", priority: 2 }, token);
await call(`/workspaces/${wsId}/items/${shop.id}/knowledge?doc_type=howto`, {}, token);
await call(`/workspaces/${wsId}/knowledge`, { title: "报销单必须贴发票原件,打印件不算", doc_type: "note" }, token);
const me = await call("/workspaces/" + wsId + "/members", null, token, "GET").catch(() => null);
const browser = await chromium.launch({
executablePath: "C:\\Users\\Steven\\AppData\\Local\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe",
});
const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 });
await page.goto(WEB + "/login");
await page.evaluate(([t, r]) => {
localStorage.setItem("dadao.access", t);
localStorage.setItem("dadao.refresh", r);
}, [login.tokens.access_token, login.tokens.refresh_token]);
const shots = [];
async function shot(url, name, prep) {
await page.goto(WEB + url, { waitUntil: "networkidle" });
if (prep) await prep();
await page.waitForTimeout(600);
const file = `e2e/artifacts/ui-${name}.png`;
await page.screenshot({ path: file, fullPage: false });
shots.push(file);
}
await shot("/", "home");
await shot(`/w/${wsId}`, "tasks");
await shot(`/w/${wsId}/board`, "board");
await shot(`/w/${wsId}/tickets`, "tickets");
await shot(`/w/${wsId}/knowledge`, "knowledge");
await shot(`/w/${wsId}/members`, "members");
await shot(`/w/${wsId}`, "detail", async () => {
await page.getByRole("button", { name: "全部", exact: true }).click();
await page.waitForTimeout(500);
await page.getByRole("button", { name: "把采集端的日志级别改回 info" }).first().click();
await page.waitForTimeout(800);
});
await shot("/login", "login");
console.log(shots.join("\n"));
await browser.close();
+2 -2
View File
@@ -74,10 +74,10 @@ try {
// 4. 详情抽屉可编辑:改优先级 + 加评论
await page.getByRole("button", { name: "写周报初稿" }).click();
const drawer = page.locator("aside");
const drawer = page.getByRole("dialog");
await drawer.waitFor();
// 抽屉里现在有两个 select(优先级 + 升级工单选人),取第一个才是优先级
await drawer.locator("select").first().selectOption("2");
await drawer.getByLabel("优先级").selectOption("2");
await page.waitForTimeout(500);
const commentBox = drawer.getByPlaceholder("补充一句进展…");
await commentBox.fill("E2E:初稿已出,等评审");
+5 -5
View File
@@ -117,9 +117,9 @@ try {
await pageA.goto(`${APP}/w/${wsId}`, { waitUntil: "domcontentloaded" });
await pageA.getByRole("button", { name: "全部", exact: true }).click();
await pageA.getByRole("button", { name: "开通测试账号权限" }).click();
const drawerA = pageA.locator("aside");
const drawerA = pageA.getByRole("dialog");
await drawerA.waitFor();
const memberPicker = drawerA.locator("select").nth(1);
const memberPicker = drawerA.getByLabel("选择办理人");
await memberPicker.selectOption({ index: 1 });
await drawerA.getByRole("button", { name: "升级成工单" }).click();
await drawerA.locator("text=已把工单交给").first().waitFor();
@@ -142,7 +142,7 @@ try {
await ticketCard.getByRole("button", { name: "开始", exact: true }).waitFor();
await ticketCard.getByRole("button", { name: "开始", exact: true }).click();
await ticketCard.getByRole("button", { name: "详情", exact: true }).click();
const drawerB = pageB.locator("aside");
const drawerB = pageB.getByRole("dialog");
await drawerB.waitFor();
await drawerB.getByPlaceholder("给对方的说明(提交或验收时一并发过去)").fill("已经开了只读账号");
await drawerB.getByRole("button", { name: "提交验收" }).click();
@@ -156,7 +156,7 @@ try {
const mineCard = pageA.locator("li.card").filter({ hasText: "开通测试账号权限" }).first();
await mineCard.waitFor();
await mineCard.getByRole("button", { name: "详情", exact: true }).click();
const drawerVerify = pageA.locator("aside");
const drawerVerify = pageA.getByRole("dialog");
await drawerVerify.waitFor();
await drawerVerify.getByPlaceholder("给对方的说明(提交或验收时一并发过去)").fill("确认可用");
await drawerVerify.getByRole("button", { name: "通过验收" }).click();
@@ -186,7 +186,7 @@ try {
await browser.close();
} catch (err) {
await pageA.screenshot({ path: `${SHOT_DIR}/tickets-failure.png` }).catch(() => {});
const drawerText = await pageA.locator("aside").innerText().catch(() => "");
const drawerText = await pageA.getByRole("dialog").innerText().catch(() => "");
if (drawerText) console.error(`\n[抽屉内容]\n${drawerText}`);
if (consoleErrors.length > 0) console.error(`\n[控制台]\n${consoleErrors.join("\n")}`);
const bodyText = await pageA.locator("body").innerText().catch(() => "");
+3
View File
@@ -1,5 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
import { ToastProvider } from "./components/ui";
import { api, authExpired, session } from "./lib/client";
import type { User, Workspace } from "./lib/types";
import { LoginPage } from "./pages/Login";
@@ -80,6 +81,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
export function App() {
return (
<ToastProvider>
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
@@ -93,5 +95,6 @@ export function App() {
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AuthProvider>
</ToastProvider>
);
}
+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>
);
}
+228 -90
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,55 +314,82 @@ 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}
<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 gap-2">
<div className="flex items-center gap-2">
{/* 工单不提供删除:它该走"取消",否则原任务永远解不开 */}
{item.kind !== "ticket" && (
<button className="btn-ghost px-2 py-1 text-xs text-red-600" onClick={() => void remove()}>
<Button variant="danger" size="sm" onClick={() => void remove()}>
<Trash size={13} />
删除
</button>
</Button>
)}
<button className="btn-ghost px-2 py-1 text-xs" onClick={onClose}>
关闭
</button>
</div>
</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>
) : null}
</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 ?? "—"}
</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>}
{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>
{ticket.resolution_md && (
<p className="mt-2 whitespace-pre-wrap text-ink-900">验收结论:{ticket.resolution_md}</p>
)}
) : null}
<div className="mt-3 space-y-2">
<input
className="input"
@@ -339,13 +397,15 @@ export function TaskDetail({
value={note}
onChange={(e) => setNote(e.target.value)}
/>
{actionButtons(ticket)}
{actionButtons()}
</div>
</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,12 +413,17 @@ 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">
<div className="grid grid-cols-2 gap-3">
<label className="block">
<span className="label">优先级</span>
<span className="label inline-flex items-center gap-1">
<Flag size={11} />
优先级
</span>
<select
className="input"
aria-label="优先级"
value={item.priority}
onChange={(e) => void patch({ priority: Number(e.target.value) }, "优先级")}
>
@@ -370,10 +435,14 @@ export function TaskDetail({
</select>
</label>
<label className="block">
<span className="label">截止时间</span>
<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)}
@@ -381,36 +450,52 @@ export function TaskDetail({
</label>
</div>
<label className="mt-3 block text-sm">
<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>
<label className="mt-3 block text-sm">
<label className="block">
<span className="label">备注</span>
<textarea
className="input min-h-32"
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) && (
<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">
{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-2 flex gap-2">
<div className="mt-3 flex gap-2">
<select
className="input flex-1"
aria-label="选择办理人"
value={promoteTo}
onChange={(e) => setPromoteTo(e.target.value)}
>
@@ -424,43 +509,71 @@ export function TaskDetail({
</option>
))}
</select>
<button className="btn-primary" disabled={busy || !promoteTo} onClick={() => void promote()}>
<Button variant="clay" disabled={busy || !promoteTo} onClick={() => void promote()}>
升级成工单
</button>
</Button>
</div>
</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">
{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-2">
<button className="btn-ghost px-3 py-1 text-sm" disabled={busy} onClick={() => void toKnowledge()}>
<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>
</Button>
</div>
</section>
) : null}
{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}
{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>}
<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">
<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>
</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>
);
}
+136 -9
View File
@@ -8,30 +8,157 @@
#root {
height: 100%;
}
body {
@apply bg-ink-50 text-ink-900 antialiased;
@apply bg-paper-100 font-sans text-base text-ink-800 antialiased;
font-feature-settings: "cv05", "ss01";
text-rendering: optimizeLegibility;
}
::selection {
@apply bg-primary-100 text-primary-900;
}
:focus-visible {
@apply outline-none ring-2 ring-primary-400/45 ring-offset-1 ring-offset-paper-100;
}
/* 细滚动条,避免默认滚动条破坏整体观感 */
* {
scrollbar-width: thin;
scrollbar-color: theme("colors.line.300") transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
@apply rounded-full border-4 border-solid border-transparent bg-line-300 bg-clip-content;
}
*::-webkit-scrollbar-thumb:hover {
@apply bg-ink-300 bg-clip-content;
}
h1,
h2,
h3 {
@apply font-display text-ink-900;
text-wrap: balance;
}
input,
textarea,
select {
font: inherit;
}
}
@layer components {
/* —— 容器 —— */
.card {
@apply rounded-xl border border-ink-100 bg-white p-5 shadow-sm;
@apply rounded-2xl border border-line-200 bg-white shadow-card;
}
.card-flat {
@apply rounded-2xl border border-line-200 bg-white;
}
.panel {
@apply rounded-2xl border border-line-200 bg-paper-50;
}
/* —— 按钮 —— */
.btn {
@apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium
transition disabled:cursor-not-allowed disabled:opacity-50;
@apply inline-flex select-none items-center justify-center gap-1.5 whitespace-nowrap
rounded-lg px-3 py-1.5 text-sm font-medium transition-all duration-150
disabled:pointer-events-none disabled:opacity-40;
}
.btn-primary {
@apply btn bg-ink-700 text-white hover:bg-ink-900;
@apply btn bg-primary-700 text-white shadow-sm hover:bg-primary-800 active:translate-y-px;
}
.btn-ghost {
@apply btn border border-ink-100 bg-white text-ink-700 hover:bg-ink-50;
@apply btn border border-line-200 bg-white text-ink-700 hover:border-line-300 hover:bg-paper-50;
}
.btn-subtle {
@apply btn text-ink-600 hover:bg-ink-100/70 hover:text-ink-900;
}
.btn-clay {
@apply btn bg-clay-500 text-white hover:bg-clay-600 active:translate-y-px;
}
.btn-danger {
@apply btn border border-rust-500/25 bg-rust-50 text-rust-500 hover:bg-rust-50/70;
}
.btn-sm {
@apply px-2.5 py-1 text-xs rounded-md;
}
.btn-lg {
@apply px-4 py-2.5 text-base rounded-xl;
}
/* —— 表单 —— */
.input {
@apply w-full rounded-lg border border-ink-100 bg-white px-3 py-2 text-sm
outline-none focus:border-ink-500 focus:ring-2 focus:ring-ink-500/20;
@apply w-full rounded-lg border border-line-200 bg-white px-3 py-2 text-base text-ink-900
placeholder:text-ink-400 transition
focus:border-primary-400 focus:ring-2 focus:ring-primary-400/20;
}
.input-flush {
@apply w-full border-0 bg-transparent p-0 text-base text-ink-900 outline-none
placeholder:text-ink-400;
}
.label {
@apply mb-1 block text-xs font-medium text-ink-500;
@apply mb-1.5 block text-xs font-medium tracking-wide text-ink-500;
}
/* —— 徽标 —— */
.pill {
@apply inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-2xs font-medium;
}
.chip {
@apply inline-flex items-center gap-1 rounded-md bg-paper-200 px-1.5 py-0.5 text-2xs font-medium text-ink-500;
}
/* —— 侧栏 —— */
.nav-item {
@apply flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium text-ink-600
transition-colors hover:bg-ink-100/60 hover:text-ink-900;
}
.nav-item-active {
@apply bg-primary-900 text-paper-50 shadow-sm hover:bg-primary-900 hover:text-paper-50;
}
/* —— 列表行 —— */
.row {
@apply flex items-start gap-3 border-b border-line-100 px-4 py-3 transition-colors last:border-0
hover:bg-paper-50/80;
}
/* —— 骨架屏 —— */
.skeleton {
@apply relative overflow-hidden rounded-md bg-line-100;
}
.skeleton::after {
@apply absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/70 to-transparent;
content: "";
animation: shimmer 1.6s infinite;
}
.link {
@apply text-primary-600 underline decoration-primary-300/60 underline-offset-2 hover:text-primary-700;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.grid-lines {
background-image: linear-gradient(to right, rgba(22, 28, 26, 0.04) 1px, transparent 1px),
linear-gradient(to bottom, rgba(22, 28, 26, 0.04) 1px, transparent 1px);
background-size: 32px 32px;
}
}
+88 -45
View File
@@ -1,17 +1,18 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { Layout } from "../components/Layout";
import { AppShell } from "../components/AppShell";
import { TaskDetail } from "../components/TaskDetail";
import { WorkspaceNav } from "../components/WorkspaceNav";
import { Check, Columns } from "../components/icons";
import { Button, EmptyState, InlineAlert, PageHeader, StatusPill } from "../components/ui";
import { ApiError, api } from "../lib/client";
import { PRIORITY_LABEL, STATUS_LABEL, canTransition } from "../lib/parse";
import type { Item, ItemStatus } from "../lib/types";
const COLUMNS: { key: ItemStatus; label: string }[] = [
{ key: "todo", label: "待办" },
{ key: "doing", label: "进行中" },
{ key: "blocked", label: "被阻塞" },
{ key: "done", label: "已完成" },
const COLUMNS: { key: ItemStatus; label: string; dot: string; tint: string }[] = [
{ key: "todo", label: "待办", dot: "bg-sky-500", tint: "bg-sky-50/40" },
{ key: "doing", label: "进行中", dot: "bg-primary-500", tint: "bg-primary-50/40" },
{ key: "blocked", label: "被阻塞", dot: "bg-wheat-500", tint: "bg-wheat-50/40" },
{ key: "done", label: "已完成", dot: "bg-sage-500", tint: "bg-sage-50/40" },
];
interface BoardPayload {
@@ -136,41 +137,52 @@ export function BoardPage() {
}
return (
<Layout>
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<h1 className="text-lg font-semibold">看板</h1>
<p className="mt-0.5 text-xs text-ink-500">
拖卡片改状态;勾选后可以批量移动。状态机不允许的组合会直接拒绝并说明原因。
</p>
</div>
<WorkspaceNav wsId={wsId} />
</div>
<AppShell wsId={wsId} active="board">
<PageHeader
title="看板"
desc="拖卡片改状态;勾选后可以批量移动。状态机不允许的组合会当场拒绝并说明原因。"
eyebrow={<>收件箱不上看板,先归类再排期</>}
/>
{selectedItems.length > 0 && (
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-lg bg-ink-50 px-3 py-2 text-sm">
<span className="text-ink-700">已选 {selectedItems.length} 条</span>
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-xl border border-primary-200/70 bg-primary-50 px-3 py-2 text-sm">
<span className="font-medium text-primary-800">已选 {selectedItems.length} 条</span>
{COLUMNS.map((col) => (
<button
key={col.key}
className="btn-ghost px-2 py-1 text-xs"
onClick={() => void batchMove(col.key)}
>
<Button key={col.key} variant="ghost" size="sm" onClick={() => void batchMove(col.key)}>
移到{col.label}
</button>
</Button>
))}
<button className="btn-ghost ml-auto px-2 py-1 text-xs" onClick={() => setSelected(new Set())}>
<Button variant="subtle" size="sm" className="ml-auto" onClick={() => setSelected(new Set())}>
取消选择
</button>
</Button>
</div>
)}
{error && <p className="mb-3 text-sm text-amber-700">{error}</p>}
{loading && <p className="text-sm text-ink-500">加载中…</p>}
{error && (
<div className="mb-4">
<InlineAlert tone="info">{error}</InlineAlert>
</div>
)}
{loading && Object.keys(columns).length === 0 ? (
<div className="grid gap-3 md:grid-cols-4">
{COLUMNS.map((c) => (
<div key={c.key} className="h-52 animate-pulse rounded-2xl border border-line-200 bg-white/60" />
))}
</div>
) : allItems.length === 0 ? (
<div className="card">
<EmptyState
icon={<Columns size={18} />}
title="看板还是空的"
hint="任务页收下几条待办,这里就会按状态分列排开。"
/>
</div>
) : (
<div className="grid gap-3 md:grid-cols-4">
{COLUMNS.map((col) => {
const list = columns[col.key] ?? [];
const hot = hoverCol === col.key;
return (
<section
key={col.key}
@@ -180,30 +192,38 @@ export function BoardPage() {
}}
onDragLeave={() => setHoverCol((prev) => (prev === col.key ? null : prev))}
onDrop={() => void onDrop(col.key)}
className={`rounded-xl border p-2 transition ${
hoverCol === col.key ? "border-ink-900 bg-ink-50" : "border-ink-100 bg-white"
className={`rounded-2xl border p-2 transition-all ${
hot
? "border-primary-400 bg-primary-50/60 ring-2 ring-primary-400/25"
: "border-line-200 bg-paper-50/70"
}`}
>
<header className="mb-2 flex items-center justify-between px-1 text-xs text-ink-500">
<span className="font-medium text-ink-700">{col.label}</span>
<span>{list.length}</span>
<header className="mb-2 flex items-center gap-1.5 px-1 py-1">
<span className={`h-1.5 w-1.5 rounded-full ${col.dot}`} />
<span className="text-xs font-medium text-ink-700">{col.label}</span>
<span className="rounded bg-white px-1.5 text-2xs tabular-nums text-ink-400 ring-1 ring-line-200">
{list.length}
</span>
</header>
<div className="space-y-2">
<div className={`space-y-2 rounded-xl ${hot ? "bg-white/40" : ""}`}>
{list.map((item) => (
<article
key={item.id}
draggable
onDragStart={() => setDragId(item.id)}
onDragEnd={() => setDragId(null)}
className={`cursor-grab rounded-lg border border-ink-100 bg-white p-2 shadow-sm ${
dragId === item.id ? "opacity-50" : ""
className={`group cursor-grab rounded-xl border border-line-200 bg-white p-2.5 shadow-card transition hover:-translate-y-px hover:border-line-300 active:cursor-grabbing ${
dragId === item.id ? "rotate-1 opacity-50" : ""
}`}
>
<div className="flex items-start gap-2">
{col.key === "done" ? (
<Check size={14} className="mt-0.5 shrink-0 text-sage-600" />
) : (
<input
type="checkbox"
className="mt-1 h-3.5 w-3.5 accent-ink-900"
className="mt-0.5 h-3.5 w-3.5 shrink-0 rounded accent-primary-700"
checked={selected.has(item.id)}
onChange={() => {
setSelected((prev) => {
@@ -213,35 +233,58 @@ export function BoardPage() {
return next;
});
}}
aria-label={`选择 ${item.title}`}
/>
)}
<button
className="min-w-0 flex-1 text-left text-sm"
className={`min-w-0 flex-1 text-left text-sm leading-snug transition-colors ${
item.status === "done"
? "text-ink-400 line-through"
: "text-ink-900 hover:text-primary-700"
}`}
onClick={() => setOpenId(item.id)}
>
{item.title}
</button>
</div>
<div className="mt-1 flex flex-wrap items-center gap-1 pl-5 text-[11px] text-ink-500">
<div className="mt-2 flex flex-wrap items-center gap-1 pl-5">
{item.priority > 0 && (
<span className="rounded bg-accent/15 px-1 text-accent">
<span className="chip bg-clay-50 text-clay-600">
{PRIORITY_LABEL[item.priority]}
</span>
)}
{item.due_at && <span>{new Date(item.due_at).toLocaleDateString("zh-CN")}</span>}
<StatusPill status={item.status} label={STATUS_LABEL[item.status]} />
{item.due_at && (
<span className="text-2xs text-ink-400">
{new Date(item.due_at).toLocaleDateString("zh-CN", {
month: "numeric",
day: "numeric",
})}
</span>
)}
{item.tags.map((t) => (
<span key={t.id}>#{t.name}</span>
<span key={t.id} className="text-2xs text-ink-400">
#{t.name}
</span>
))}
</div>
</article>
))}
{list.length === 0 && (
<p className="px-1 py-4 text-center text-xs text-ink-500">空</p>
<p
className={`rounded-xl border border-dashed border-line-200 px-2 py-6 text-center text-2xs ${
hot ? "border-primary-300 text-primary-500" : "text-ink-300"
}`}
>
拖到这里
</p>
)}
</div>
</section>
);
})}
</div>
)}
{openId && wsId && (
<TaskDetail
@@ -252,6 +295,6 @@ export function BoardPage() {
onDeleted={() => void load()}
/>
)}
</Layout>
</AppShell>
);
}
+107 -32
View File
@@ -1,12 +1,26 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../App";
import { Layout } from "../components/Layout";
import { ArrowRight, ChevronRight, Logo, Logout, Plus } from "../components/icons";
import { Button, EmptyState, Field, InlineAlert } from "../components/ui";
import { ApiError, api } from "../lib/client";
import type { Workspace } from "../lib/types";
function roleLabel(role: string): string {
return { owner: "所有者", admin: "管理员", member: "成员", guest: "访客" }[role] ?? role;
}
function greeting() {
const h = new Date().getHours();
if (h < 6) return "夜深了";
if (h < 11) return "早上好";
if (h < 14) return "中午好";
if (h < 18) return "下午好";
return "晚上好";
}
export function HomePage() {
const { user, workspaces, loading, reload } = useAuth();
const { user, workspaces, loading, reload, signOut } = useAuth();
const navigate = useNavigate();
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -34,33 +48,83 @@ export function HomePage() {
}
return (
<Layout>
<div className="grid gap-6 md:grid-cols-[1.6fr_1fr]">
<div className="min-h-full bg-paper-100">
<header className="sticky top-0 z-20 border-b border-line-200 bg-paper-50/90 backdrop-blur">
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-5 py-3 lg:px-10">
<div className="flex items-center gap-2.5">
<Logo size={26} />
<span className="text-sm font-semibold tracking-tight text-ink-900">达道Task</span>
</div>
<div className="flex items-center gap-3">
<span className="hidden text-xs text-ink-500 sm:inline">
{user?.nickname}
{user?.phone ? ` · ${user.phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2")}` : ""}
</span>
<Button variant="subtle" size="sm" onClick={signOut} title="退出登录">
<Logout size={14} />
退出
</Button>
</div>
</div>
</header>
<main className="mx-auto w-full max-w-5xl px-5 py-8 lg:px-10 lg:py-12">
<div className="mb-9">
<p className="text-xs font-medium tracking-wide text-clay-600">
{new Date().toLocaleDateString("zh-CN", { month: "long", day: "numeric", weekday: "long" })}
</p>
<h1 className="mt-1.5 font-display text-3xl font-semibold">
{greeting()},{user?.nickname ?? ""}
</h1>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-ink-500">
选一个工作区继续。任务、工单、知识都在里面,他们是同一件事的三个阶段。
</p>
</div>
<div className="grid gap-8 lg:grid-cols-[1.55fr_1fr]">
<section>
<h2 className="mb-3 text-sm font-medium text-ink-500">我的工作区</h2>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-medium text-ink-600">我的工作区</h2>
<span className="text-2xs text-ink-400">{workspaces.length} 个</span>
</div>
{loading ? (
<p className="text-sm text-ink-500">加载中…</p>
<div className="grid gap-3 sm:grid-cols-2">
{[0, 1].map((i) => (
<div key={i} className="card h-[92px] animate-pulse bg-paper-50" />
))}
</div>
) : workspaces.length === 0 ? (
<div className="card text-sm text-ink-500">
还没有工作区。右侧创建一个,个人用也一样——先把"任务"放进去。
<div className="card">
<EmptyState
icon={<Plus size={18} />}
title="还没有工作区"
hint="个人用也一样:先建一个,把「任务」放进去。需要别人帮忙时再升级成工单。"
/>
</div>
) : (
<ul className="space-y-3">
<ul className="grid gap-3 sm:grid-cols-2">
{workspaces.map((ws) => (
<li key={ws.id}>
<Link
to={`/w/${ws.id}`}
className="card flex items-center justify-between transition hover:border-ink-500"
className="group card flex items-center gap-3 p-4 transition hover:-translate-y-0.5 hover:border-primary-300 hover:shadow-pop"
>
<div>
<p className="font-medium">{ws.name}</p>
<p className="mt-1 text-xs text-ink-500">
{ws.slug} · {ws.plan === "free" ? "免费版" : ws.plan}
</p>
</div>
<span className="rounded-full bg-ink-100 px-2 py-1 text-xs text-ink-700">
{roleLabel(ws.role)}
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-primary-900 text-sm font-semibold text-paper-50">
{ws.name.trim().slice(0, 1).toUpperCase()}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-ink-900">
{ws.name}
</span>
<span className="mt-0.5 block text-2xs text-ink-400">
{roleLabel(ws.role)} · {ws.plan === "free" ? "免费版" : ws.plan}
</span>
</span>
<ChevronRight
size={16}
className="shrink-0 text-ink-300 transition group-hover:translate-x-0.5 group-hover:text-primary-500"
/>
</Link>
</li>
))}
@@ -68,9 +132,19 @@ export function HomePage() {
)}
</section>
<aside className="card h-fit">
<h2 className="text-sm font-medium">新建工作区</h2>
<form className="mt-3 space-y-3" onSubmit={createWorkspace}>
<aside className="card h-fit p-5">
<div className="flex items-center gap-2">
<span className="grid h-7 w-7 place-items-center rounded-lg bg-clay-50 text-clay-600">
<Plus size={15} />
</span>
<h2 className="text-sm font-medium text-ink-800">新建工作区</h2>
</div>
<p className="mt-2 text-xs leading-relaxed text-ink-400">
一个工作区对应一支团队或一个生活领域。成员之间共享任务、工单与知识。
</p>
<form className="mt-4 space-y-3" onSubmit={createWorkspace}>
<Field label="名称">
<input
className="input"
placeholder="例如:达道科技 / 我的事项"
@@ -78,20 +152,21 @@ export function HomePage() {
maxLength={64}
onChange={(e) => setName(e.target.value)}
/>
{error && <p className="text-sm text-red-600">{error}</p>}
<button className="btn-primary w-full" disabled={busy || !name.trim()}>
</Field>
{error && <InlineAlert>{error}</InlineAlert>}
<Button type="submit" variant="primary" className="w-full" disabled={busy || !name.trim()}>
{busy ? "创建中…" : "创建"}
</button>
{busy ? null : <ArrowRight size={14} />}
</Button>
</form>
<p className="mt-4 border-t border-ink-100 pt-3 text-xs text-ink-500">
收到邀请链接(/join/…)直接打开即可加入。
</p>
<div className="mt-4 border-t border-line-100 pt-3 text-2xs leading-relaxed text-ink-400">
收到邀请链接(<span className="font-mono">/join/…</span>)直接打开即可加入,
不需要别人手动拉你。
</div>
</aside>
</div>
</Layout>
</main>
</div>
);
}
function roleLabel(role: string): string {
return { owner: "负责人", admin: "管理员", member: "成员", guest: "访客" }[role] ?? role;
}
+32 -9
View File
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useAuth } from "../App";
import { Layout } from "../components/Layout";
import { Check, Logo, Spinner } from "../components/icons";
import { Button, InlineAlert } from "../components/ui";
import { ApiError, api, session } from "../lib/client";
import type { Workspace } from "../lib/types";
@@ -11,6 +12,7 @@ export function JoinPage() {
const { user, loading, reload } = useAuth();
const navigate = useNavigate();
const [message, setMessage] = useState("正在加入工作区…");
const [done, setDone] = useState(false);
const [failed, setFailed] = useState(false);
const started = useRef(false);
@@ -27,8 +29,9 @@ export function JoinPage() {
try {
const ws = await api.post<Workspace>(`/workspaces/${wsId}/join`, { code });
await reload();
setDone(true);
setMessage(`已加入「${ws.name}」,正在跳转…`);
setTimeout(() => navigate(`/w/${wsId}`, { replace: true }), 600);
window.setTimeout(() => navigate(`/w/${wsId}`, { replace: true }), 700);
} catch (err) {
setFailed(true);
setMessage(err instanceof ApiError ? err.message : "加入失败");
@@ -37,17 +40,37 @@ export function JoinPage() {
}, [loading, user, wsId, code, navigate, reload]);
return (
<Layout>
<div className="mx-auto mt-10 max-w-md">
<div className="card text-center">
<p className={failed ? "text-sm text-red-600" : "text-sm text-ink-700"}>{message}</p>
<main className="grid min-h-full place-items-center bg-paper-100 px-6">
<div className="w-full max-w-sm animate-slide-up">
<div className="mb-6 flex justify-center">
<Logo size={40} />
</div>
<div className="card p-7 text-center">
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-2xl bg-primary-50 text-primary-700">
{failed ? (
<span className="text-xl">!</span>
) : done ? (
<Check size={22} />
) : (
<Spinner size={20} />
)}
</div>
<p className="text-sm leading-relaxed text-ink-700">{message}</p>
<p className="mt-1 text-2xs text-ink-400">
{failed ? "链接可能已失效,或你已经加入过了。" : "邀请码一次有效"}
</p>
{failed && (
<button className="btn-ghost mt-4" onClick={() => navigate("/")}>
<>
<div className="mt-4">
<InlineAlert>可以把邀请码发给同事,让他重新生成一条链接。</InlineAlert>
</div>
<Button variant="ghost" className="mt-4 w-full" onClick={() => navigate("/")}>
返回工作区列表
</button>
</Button>
</>
)}
</div>
</div>
</Layout>
</main>
);
}
+192 -119
View File
@@ -1,7 +1,16 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { Layout } from "../components/Layout";
import { WorkspaceNav } from "../components/WorkspaceNav";
import { AppShell } from "../components/AppShell";
import { Book, Link2, Plus, Search, Sparkle } from "../components/icons";
import {
Button,
EmptyState,
InlineAlert,
PageHeader,
Sheet,
SkeletonRows,
StatusPill,
} from "../components/ui";
import { ApiError, api } from "../lib/client";
import type { Knowledge, KnowledgeStatus } from "../lib/types";
@@ -106,102 +115,159 @@ export function KnowledgePage() {
}
}
return (
<Layout>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">知识</h1>
<p className="mt-0.5 text-xs text-ink-500">
工单验收通过会自动整理一份草稿;核对无误再发布,发布后才会进入团队检索
</p>
</div>
<WorkspaceNav wsId={wsId} />
</div>
const pendingCount = docs.filter((d) => d.knowledge.status_hint !== "published").length;
<div className="mb-3 flex flex-wrap items-center gap-2">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`rounded-full px-3 py-1 text-sm transition ${
tab === t.key ? "bg-ink-900 text-white" : "bg-ink-100 text-ink-700 hover:bg-ink-100/70"
}`}
>
{t.label}
</button>
))}
return (
<AppShell wsId={wsId} active="knowledge">
<PageHeader
title="知识"
desc="工单验收通过会自动整理一份草稿;核对无误再发布,发布后才会进入团队检索。"
eyebrow={<>沉淀下来的东西,半年后还认得出来吗</>}
actions={
<>
<div className="relative">
<span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-400">
<Search size={14} />
</span>
<input
className="input ml-auto w-56"
className="input h-9 w-48 pl-8 text-sm"
placeholder="搜标题或正文…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<button className="btn-primary px-3 py-1 text-sm" onClick={() => setAdding((v) => !v)}>
</div>
<Button variant="primary" onClick={() => setAdding((v) => !v)}>
<Plus size={14} />
记一条
</Button>
</>
}
/>
<div className="mb-4 flex flex-wrap items-center gap-1.5">
{TABS.map((t) => {
const active = tab === t.key;
return (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors ${
active
? "bg-white text-ink-900 shadow-sm ring-1 ring-line-200"
: "text-ink-500 hover:bg-white/60 hover:text-ink-800"
}`}
>
{t.label}
</button>
);
})}
{tab === "draft" && pendingCount > 0 ? (
<span className="ml-auto hidden text-2xs text-ink-400 sm:block">
{pendingCount} 份草稿等你核对
</span>
) : null}
</div>
{adding && (
<div className="card mb-3">
<p className="mb-2 text-xs text-ink-500">
<div className="card mb-4 p-4">
<div className="flex items-start gap-2 text-xs text-ink-500">
<span className="mt-0.5 text-clay-500">
<Book size={14} />
</span>
<p className="leading-relaxed">
手记的知识没有来源,它是你自己的笔记;想沉淀某件事,去那件事的详情里点「沉淀成知识」。
</p>
<div className="flex gap-2">
</div>
<div className="mt-3 flex gap-2">
<input
className="input flex-1"
placeholder="一句话标题,比如「报销单要贴发票原件」"
value={draftTitle}
autoFocus
onChange={(e) => setDraftTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addNote();
}}
/>
<button className="btn-primary px-3 py-1 text-sm" onClick={() => void addNote()}>
<Button variant="primary" disabled={!draftTitle.trim()} onClick={() => void addNote()}>
保存
</button>
</Button>
</div>
</div>
)}
{error && <p className="mb-3 rounded-lg bg-red-50 p-2 text-sm text-red-700">{error}</p>}
{docs.length === 0 && !loading && (
<div className="card text-center text-sm text-ink-500">
{tab === "draft"
? "还没有待核对的知识。把工单验收通过,或者在这里「记一条」。"
: "这里还空着。"}
{error && (
<div className="mb-4">
<InlineAlert>{error}</InlineAlert>
</div>
)}
{loading && docs.length === 0 ? (
<div className="card overflow-hidden">
<SkeletonRows rows={3} />
</div>
) : docs.length === 0 ? (
<div className="card">
<EmptyState
icon={<Sparkle size={18} />}
title={
tab === "draft" ? "还没有待核对的知识" : "这里还空着"
}
hint={
tab === "draft"
? "把一张工单验收通过,系统会自动整理一份草稿;或者在这里「记一条」。"
: "换个筛选条件看看,或者先记一条。"
}
/>
</div>
) : (
<ul className="space-y-2">
{docs.map((d) => (
<li key={d.id} className="card">
<div className="flex items-start justify-between gap-3">
<li
key={d.id}
className="card group flex items-start justify-between gap-3 p-4 transition hover:border-line-300 hover:shadow-pop"
>
<button className="min-w-0 flex-1 text-left" onClick={() => setOpenId(d.id)}>
<p className="truncate text-sm font-medium">{d.title}</p>
<p className="mt-1 flex flex-wrap items-center gap-2 text-xs text-ink-500">
<span className="rounded bg-ink-100 px-1.5 py-0.5">
<div className="flex items-center gap-1.5">
<span className="chip bg-primary-50 text-primary-700">
{DOC_TYPE_LABEL[d.knowledge.doc_type] ?? d.knowledge.doc_type}
</span>
<span>{STATUS_LABEL[d.knowledge.status_hint]}</span>
<span>{SOURCE_LABEL[d.knowledge.source_kind] ?? d.knowledge.source_kind}</span>
{d.source && <span className="truncate">「{d.source.title}」</span>}
<StatusPill
status={d.knowledge.status_hint}
label={STATUS_LABEL[d.knowledge.status_hint]}
/>
{d.knowledge.generated_by === "rule" ? (
<span className="pill bg-clay-50 text-clay-600">
<Sparkle size={10} />
自动整理
</span>
) : null}
</div>
<p className="mt-1.5 truncate text-sm font-medium text-ink-900 group-hover:text-primary-700">
{d.title}
</p>
<p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs text-ink-400">
<span className="inline-flex items-center gap-1">
<Link2 size={11} />
{SOURCE_LABEL[d.knowledge.source_kind] ?? d.knowledge.source_kind}
</span>
{d.source ? <span className="max-w-[22rem] truncate">「{d.source.title}」</span> : null}
<span>{new Date(d.updated_at).toLocaleString("zh-CN")}</span>
</p>
</button>
<button className="btn-ghost shrink-0 px-2 py-1 text-xs" onClick={() => setOpenId(d.id)}>
<Button variant="ghost" size="sm" className="shrink-0" onClick={() => setOpenId(d.id)}>
打开
</button>
</div>
</Button>
</li>
))}
</ul>
)}
{cursor && (
<div className="mt-4 text-center">
<button className="btn-ghost" disabled={loading} onClick={() => void load(cursor)}>
<Button variant="ghost" disabled={loading} onClick={() => void load(cursor)}>
{loading ? "加载中…" : "加载更多"}
</button>
</Button>
</div>
)}
@@ -213,7 +279,7 @@ export function KnowledgePage() {
onChanged={() => void load(null)}
/>
)}
</Layout>
</AppShell>
);
}
@@ -298,11 +364,7 @@ function KnowledgeDrawer({
const actions: { key: string; label: string; show: boolean }[] = [
{ key: "submit_review", label: ACTION_LABEL.submit_review, show: status === "draft" },
{ key: "withdraw", label: ACTION_LABEL.withdraw, show: status === "in_review" },
{
key: "publish",
label: ACTION_LABEL.publish,
show: status === "draft" || status === "in_review",
},
{ key: "publish", label: ACTION_LABEL.publish, show: status === "draft" || status === "in_review" },
{
key: "archive",
label: ACTION_LABEL.archive,
@@ -312,79 +374,90 @@ function KnowledgeDrawer({
];
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()}
>
{!doc ? (
<p className="text-sm text-ink-500">{error ?? "加载中…"}</p>
) : (
<>
<div className="mb-4 flex items-center justify-between text-xs text-ink-500">
<span>
{DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type} ·{" "}
{STATUS_LABEL[doc.knowledge.status_hint]} · 版本 {doc.version}
</span>
<button className="btn-ghost px-2 py-1 text-xs" onClick={onClose}>
关闭
</button>
</div>
<div className="mb-4 rounded-lg border border-ink-100 bg-ink-50 p-3 text-xs text-ink-700">
{doc.source ? (
<p>
来源:{doc.source.kind === "ticket" ? "工单" : "任务"}「{doc.source.title}」
</p>
) : (
<p>来源:手记(没有关联的事项)</p>
)}
<p className="mt-1">
{doc.knowledge.generated_by === "rule"
? "这份草稿由系统整理,请核对后再发布。"
: "手动创建。"}
{doc.knowledge.published_at &&
` 发布于 ${new Date(doc.knowledge.published_at).toLocaleString("zh-CN")}。`}
</p>
</div>
<input
className="input mb-3"
value={title}
disabled={!canEdit}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className="input mb-3 min-h-[280px] font-mono text-xs"
value={body}
disabled={!canEdit}
onChange={(e) => setBody(e.target.value)}
/>
{canEdit && (
<button className="btn-primary mb-4 self-start" disabled={busy} onClick={() => void save()}>
<Sheet
open
onClose={onClose}
width="max-w-2xl"
title={doc ? doc.title : "知识"}
subtitle={
doc
? `${DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type} · ${
STATUS_LABEL[doc.knowledge.status_hint]
} · 版本 ${doc.version}`
: "加载中…"
}
footer={
doc ? (
<div className="flex flex-wrap items-center gap-2">
{canEdit ? (
<Button variant="primary" disabled={busy} onClick={() => void save()}>
{busy ? "保存中…" : "保存"}
</button>
)}
<div className="mb-2 flex flex-wrap gap-2">
</Button>
) : null}
{actions
.filter((a) => a.show)
.map((a) => (
<button
<Button
key={a.key}
className={a.key === "publish" ? "btn-primary px-3 py-1 text-sm" : "btn-ghost px-3 py-1 text-sm"}
variant={a.key === "publish" ? "clay" : "ghost"}
disabled={busy}
onClick={() => void act(a.key)}
>
{a.label}
</button>
</Button>
))}
<span className="ml-auto text-2xs text-ink-400">
{doc.knowledge.published_at
? `发布于 ${new Date(doc.knowledge.published_at).toLocaleString("zh-CN")}`
: "尚未发布,只有你能看到"}
</span>
</div>
{error && <p className="text-sm text-red-700">{error}</p>}
</>
) : null
}
>
{!doc ? (
<p className="text-sm text-ink-400">{error ?? "加载中…"}</p>
) : (
<div className="space-y-4">
<div className="rounded-xl border border-line-200 bg-paper-50 p-3.5 text-xs leading-relaxed text-ink-600">
<div className="flex items-center gap-1.5">
<Link2 size={12} className="text-ink-400" />
{doc.source ? (
<span>
来源:{doc.source.kind === "ticket" ? "工单" : "任务"}「{doc.source.title}」
</span>
) : (
<span>来源:手记(没有关联的事项)</span>
)}
</aside>
</div>
<p className="mt-1 text-ink-400">
{doc.knowledge.generated_by === "rule"
? "这份草稿由系统整理,请核对后再发布。"
: "手动创建。"}
{doc.knowledge.published_at
? ` 发布于 ${new Date(doc.knowledge.published_at).toLocaleString("zh-CN")}。`
: ""}
</p>
</div>
<input
className="input text-base font-medium"
value={title}
disabled={!canEdit}
onChange={(e) => setTitle(e.target.value)}
aria-label="标题"
/>
<textarea
className="input min-h-[320px] resize-y font-mono text-xs leading-relaxed"
value={body}
disabled={!canEdit}
onChange={(e) => setBody(e.target.value)}
aria-label="正文"
/>
{error && <InlineAlert>{error}</InlineAlert>}
</div>
)}
</Sheet>
);
}
+102 -29
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../App";
import { Layout } from "../components/Layout";
import { Book, Check, Logo, TicketIcon } from "../components/icons";
import { Button, Field, InlineAlert } from "../components/ui";
import { ApiError, api, session, type Tokens } from "../lib/client";
import type { User } from "../lib/types";
@@ -15,6 +16,20 @@ function safeNext(raw: string | null): string | null {
return raw;
}
const HIGHLIGHTS = [
{ icon: <Check size={15} />, title: "一件事,从头到尾", desc: "收下、开工、完成,状态一路可见" },
{
icon: <TicketIcon size={15} />,
title: "卡住就变成工单",
desc: "原任务自动挂着等,办完再回来,不丢线",
},
{
icon: <Book size={15} />,
title: "办完自动留档",
desc: "验收通过生成知识草稿,核对后进团队库",
},
];
export function LoginPage() {
const { reload } = useAuth();
const navigate = useNavigate();
@@ -66,66 +81,124 @@ export function LoginPage() {
}
return (
<Layout>
<div className="mx-auto mt-10 max-w-sm">
<div className="card">
<h1 className="text-lg font-semibold">登录 / 注册</h1>
<p className="mt-1 text-sm text-ink-500">手机号验证码登录,未注册会自动创建账号。</p>
<div className="flex min-h-full flex-col lg:flex-row">
{/* 左:品牌页 */}
<div className="relative hidden flex-1 overflow-hidden bg-primary-900 lg:flex lg:flex-col lg:justify-between lg:p-12">
<div className="grid-lines absolute inset-0 opacity-40" aria-hidden />
<div className="absolute -left-24 -top-24 h-80 w-80 rounded-full bg-primary-700/60 blur-3xl" aria-hidden />
<div className="absolute -bottom-32 right-0 h-96 w-96 rounded-full bg-clay-500/20 blur-3xl" aria-hidden />
<form className="mt-6 space-y-4" onSubmit={submit}>
<div>
<label className="label" htmlFor="phone">
手机号
</label>
<div className="relative flex items-center gap-2.5 text-paper-50">
<Logo size={32} />
<span className="text-lg font-semibold tracking-tight">达道Task</span>
</div>
<div className="relative max-w-md">
<h1 className="font-display text-4xl font-semibold leading-[1.15] text-paper-50">
从任务到知识,
<br />
一条道走到底。
</h1>
<p className="mt-4 text-base leading-relaxed text-paper-200/80">
你自己的待办、需要别人搭把手的时刻、以及干完活留下的经验——本来就是一回事。
</p>
<ul className="mt-10 space-y-5">
{HIGHLIGHTS.map((h) => (
<li key={h.title} className="flex gap-3">
<span className="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-lg border border-paper-50/20 bg-paper-50/10 text-paper-100">
{h.icon}
</span>
<span>
<span className="block text-sm font-medium text-paper-50">{h.title}</span>
<span className="block text-sm text-paper-200/65">{h.desc}</span>
</span>
</li>
))}
</ul>
</div>
<p className="relative text-2xs text-paper-200/45">
个人先用着,团队按席位。数据在你自己的工作区里。
</p>
</div>
{/* 右:表单 */}
<main className="flex flex-1 items-center justify-center bg-paper-100 px-5 py-12">
<div className="w-full max-w-sm">
<div className="mb-8 flex items-center gap-2.5 lg:hidden">
<Logo size={30} />
<div className="leading-tight">
<div className="text-sm font-semibold text-ink-900">达道Task</div>
<div className="text-2xs text-ink-400">任务 · 工单 · 知识</div>
</div>
</div>
<h2 className="text-2xl font-semibold">登录</h2>
<p className="mt-1.5 text-sm text-ink-500">手机号验证码登录,第一次登录会自动创建账号。</p>
<form className="mt-7 space-y-4" onSubmit={submit}>
<Field label="手机号">
<input
id="phone"
className="input"
className="input h-11"
inputMode="numeric"
autoComplete="tel"
placeholder="13800138000"
value={phone}
onChange={(e) => setPhone(e.target.value.trim())}
maxLength={11}
/>
</div>
</Field>
<div>
<label className="label" htmlFor="code">
验证码
</label>
<Field label="验证码">
<div className="flex gap-2">
<input
id="code"
className="input"
className="input h-11 tracking-[0.3em]"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="6 位数字"
value={code}
onChange={(e) => setCode(e.target.value.trim())}
maxLength={6}
/>
<button
<Button
type="button"
className="btn-ghost shrink-0"
variant="ghost"
className="h-11 shrink-0 px-3.5"
disabled={!phoneValid || busy || countdown > 0}
onClick={sendCode}
>
{countdown > 0 ? `${countdown}s` : "获取验证码"}
</button>
</div>
</Button>
</div>
</Field>
{devCode && (
<p className="rounded-lg bg-accent-500/10 px-3 py-2 text-xs text-accent-600">
开发环境验证码:{devCode}(生产环境会走短信通道,不回显)
</p>
<div className="flex items-start gap-2 rounded-lg border border-clay-500/20 bg-clay-50 px-3 py-2 text-xs text-clay-700">
<span className="mt-px text-clay-500">开发环境验证码:{devCode}</span>
<span className="text-clay-600/70">(生产环境走短信通道,不回显)</span>
</div>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
{error && <InlineAlert>{error}</InlineAlert>}
<button className="btn-primary w-full" disabled={busy || !phoneValid || code.length < 4}>
<Button
type="submit"
variant="primary"
size="lg"
className="mt-2 w-full"
disabled={busy || !phoneValid || code.length < 4}
>
{busy ? "处理中…" : "登录"}
</button>
</Button>
</form>
<p className="mt-8 text-2xs leading-relaxed text-ink-400">
登录即表示同意《服务条款》与《隐私政策》。我们不会把你的内容用于训练模型。
</p>
</div>
</main>
</div>
</Layout>
);
}
-12
View File
@@ -1,12 +0,0 @@
import { Layout } from "../components/Layout";
export function PagePlaceholder({ title, hint }: { title: string; hint?: string }) {
return (
<Layout>
<div className="card">
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-2 text-sm text-ink-500">{hint ?? "M1 开发中"}</p>
</div>
</Layout>
);
}
+122 -80
View File
@@ -1,8 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { Layout } from "../components/Layout";
import { AppShell } from "../components/AppShell";
import { TaskDetail } from "../components/TaskDetail";
import { WorkspaceNav } from "../components/WorkspaceNav";
import { Check, Inbox, Plus, Search } from "../components/icons";
import {
Button,
EmptyState,
InlineAlert,
PageHeader,
PriorityDot,
SkeletonRows,
StatusPill,
} from "../components/ui";
import { ApiError, api } from "../lib/client";
import { PRIORITY_LABEL, STATUS_LABEL, parseQuickAdd } from "../lib/parse";
import type { Item, ItemStatus, Page } from "../lib/types";
@@ -15,15 +24,6 @@ const VIEWS: { key: string; label: string; hint: string; createAs: ItemStatus }[
{ key: "all", label: "全部", hint: "含已取消", createAs: "todo" },
];
const STATUS_STYLE: Record<string, string> = {
inbox: "bg-ink-100 text-ink-700",
todo: "bg-ink-100 text-ink-700",
doing: "bg-accent-500/15 text-accent-600",
done: "bg-emerald-100 text-emerald-700",
blocked: "bg-amber-100 text-amber-700",
canceled: "bg-ink-100 text-ink-500 line-through",
};
function dueLabel(iso: string | null): { text: string; overdue: boolean } | null {
if (!iso) return null;
const due = new Date(iso);
@@ -83,7 +83,8 @@ export function TasksPage() {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const typing = ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) || target.isContentEditable;
const typing =
["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) || target.isContentEditable;
if (e.key === "Escape") {
setOpenId(null);
(target as HTMLInputElement).blur?.();
@@ -144,43 +145,52 @@ export function TasksPage() {
}
return (
<Layout>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">任务</h1>
<p className="mt-0.5 text-xs text-ink-500">
{current?.hint}
<span className="ml-2 text-ink-100">|</span>
<span className="ml-2">j/k 选择 · Enter 完成 · e 编辑 · Esc 关闭</span>
</p>
</div>
<WorkspaceNav wsId={wsId} />
</div>
<div className="mb-4 flex flex-wrap items-center gap-2">
{VIEWS.map((v) => (
<button
key={v.key}
onClick={() => setView(v.key)}
className={`rounded-full px-3 py-1 text-sm transition ${
view === v.key ? "bg-ink-900 text-white" : "bg-ink-100 text-ink-700 hover:bg-ink-100/70"
}`}
>
{v.label}
</button>
))}
<AppShell wsId={wsId} active="tasks">
<PageHeader
title="任务"
desc={current?.hint}
eyebrow={<>按 j / k 移动,Enter 完成,e 打开详情</>}
actions={
<div className="relative">
<span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-400">
<Search size={14} />
</span>
<input
className="input ml-auto w-48"
className="input h-9 w-56 pl-8 text-sm"
placeholder="搜索标题或正文…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
}
/>
<div className="card mb-4">
<div className="flex gap-2">
<div className="mb-4 flex flex-wrap items-center gap-1.5">
{VIEWS.map((v) => {
const active = view === v.key;
return (
<button
key={v.key}
onClick={() => setView(v.key)}
className={`rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors ${
active
? "bg-white text-ink-900 shadow-sm ring-1 ring-line-200"
: "text-ink-500 hover:bg-white/60 hover:text-ink-800"
}`}
>
{v.label}
</button>
);
})}
</div>
<div className="card mb-4 overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2.5">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full border border-dashed border-line-300 text-ink-400">
<Plus size={14} />
</span>
<input
className="input flex-1"
className="input-flush flex-1"
placeholder="加一件要做的事,回车创建。支持 #标签 !高"
value={draft}
onChange={(e) => setDraft(e.target.value)}
@@ -188,22 +198,33 @@ export function TasksPage() {
if (e.key === "Enter") void addTask();
}}
/>
<button className="btn-primary" disabled={!draft.trim()} onClick={() => void addTask()}>
<Button variant="primary" size="sm" disabled={!draft.trim()} onClick={() => void addTask()}>
添加
</button>
</Button>
</div>
</div>
{error && <p className="mb-3 text-sm text-red-600">{error}</p>}
<div className="card divide-y divide-ink-100 p-0">
{items.length === 0 && !loading && (
<p className="p-6 text-center text-sm text-ink-500">
{view === "today" ? "今天没有到期的事,挺好。" : "这里还空着。"}
</p>
{error && (
<div className="mb-4">
<InlineAlert>{error}</InlineAlert>
</div>
)}
{items.map((item, index) => {
<div className="card overflow-hidden p-0">
{loading && items.length === 0 ? (
<SkeletonRows rows={5} />
) : items.length === 0 ? (
<EmptyState
icon={<Inbox size={18} />}
title={view === "today" ? "今天没有到期的事,挺好" : "这里还空着"}
hint={
view === "today"
? "想提前安排?切到「进行中」,或直接在上面加一件。"
: "在上面输入一件要做的事,回车就进列表。"
}
/>
) : (
items.map((item, index) => {
const due = dueLabel(item.due_at);
const done = item.status === "done";
// 收件箱里的东西还没"收下",不给完成勾选框——先归类,再完成。
@@ -211,80 +232,101 @@ export function TasksPage() {
return (
<div
key={item.id}
className={`flex items-start gap-3 px-4 py-3 ${
index === activeIdx ? "bg-ink-50/70" : ""
className={`group flex items-start gap-3 border-b border-line-100 px-4 py-3 transition-colors last:border-0 ${
index === activeIdx ? "bg-primary-50/45" : "hover:bg-paper-50"
}`}
onMouseEnter={() => setActiveIdx(index)}
>
{canComplete ? (
<input
type="checkbox"
className="mt-1 h-4 w-4 accent-ink-900"
checked={done}
onChange={() => void changeStatus(item, done ? "todo" : "done")}
<button
onClick={() => void changeStatus(item, done ? "todo" : "done")}
aria-label={done ? "标记未完成" : "标记完成"}
/>
className={`mt-0.5 grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full border transition ${
done
? "border-sage-500 bg-sage-500 text-white"
: "border-line-300 text-transparent hover:border-primary-500 hover:text-primary-500"
}`}
>
<Check size={11} strokeWidth={2.4} />
</button>
) : (
<span className="mt-1 h-4 w-4 rounded border border-ink-100" aria-hidden />
<span
className="mt-0.5 h-[18px] w-[18px] shrink-0 rounded-full border border-dashed border-line-300"
aria-hidden
/>
)}
<div className="min-w-0 flex-1">
<button
className={`block w-full text-left text-sm ${done ? "text-ink-500 line-through" : ""}`}
className={`block w-full truncate text-left text-sm transition-colors ${
done ? "text-ink-400 line-through" : "text-ink-900 hover:text-primary-700"
}`}
onClick={() => setOpenId(item.id)}
>
{item.title}
</button>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span className={`rounded px-1.5 py-0.5 ${STATUS_STYLE[item.status]}`}>
{STATUS_LABEL[item.status]}
</span>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-2xs">
<StatusPill status={item.status} label={STATUS_LABEL[item.status]} />
{item.priority > 0 && (
<span className="text-ink-500">优先级 {PRIORITY_LABEL[item.priority]}</span>
<span className="chip">
<PriorityDot level={item.priority} />
优先级 {PRIORITY_LABEL[item.priority]}
</span>
)}
{due && (
<span className={due.overdue && !done ? "text-red-600" : "text-ink-500"}>
<span
className={`inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 ${
due.overdue && !done
? "bg-rust-50 font-medium text-rust-500"
: "text-ink-400"
}`}
>
截止 {due.text}
{due.overdue && !done ? "(逾期)" : ""}
</span>
)}
{item.tags.map((tag) => (
<span key={tag.id} className="rounded bg-ink-50 px-1.5 py-0.5 text-ink-700">
<span key={tag.id} className="chip text-ink-500">
#{tag.name}
</span>
))}
</div>
</div>
<div className="flex shrink-0 gap-1">
<div className="flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{item.status === "inbox" && (
<button className="btn-ghost px-2 py-1 text-xs" onClick={() => void changeStatus(item, "todo")}>
<Button variant="ghost" size="sm" onClick={() => void changeStatus(item, "todo")}>
收下
</button>
</Button>
)}
{(item.status === "todo" || item.status === "blocked") && (
<button className="btn-ghost px-2 py-1 text-xs" onClick={() => void changeStatus(item, "doing")}>
<Button variant="ghost" size="sm" onClick={() => void changeStatus(item, "doing")}>
开工
</button>
</Button>
)}
{item.status === "doing" && (
<button className="btn-ghost px-2 py-1 text-xs" onClick={() => void changeStatus(item, "blocked")}>
<Button
variant="ghost"
size="sm"
onClick={() => void changeStatus(item, "blocked")}
>
阻塞
</button>
</Button>
)}
</div>
</div>
);
})}
})
)}
</div>
{hasMore && (
<div className="mt-4 text-center">
<button className="btn-ghost" disabled={loading} onClick={() => void load(cursor)}>
<Button variant="ghost" disabled={loading} onClick={() => void load(cursor)}>
{loading ? "加载中…" : "加载更多"}
</button>
</Button>
</div>
)}
{loading && items.length === 0 && <p className="mt-4 text-center text-sm text-ink-500">加载中…</p>}
{openId && wsId && (
<TaskDetail
@@ -297,6 +339,6 @@ export function TasksPage() {
onDeleted={(id) => setItems((prev) => prev.filter((it) => it.id !== id))}
/>
)}
</Layout>
</AppShell>
);
}
+107 -68
View File
@@ -1,16 +1,17 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { Layout } from "../components/Layout";
import { AppShell } from "../components/AppShell";
import { TaskDetail } from "../components/TaskDetail";
import { WorkspaceNav } from "../components/WorkspaceNav";
import { ChevronRight, TicketIcon } from "../components/icons";
import { Button, EmptyState, InlineAlert, PageHeader, SkeletonRows, StatusPill } from "../components/ui";
import { ApiError, api } from "../lib/client";
import { STATUS_LABEL } from "../lib/parse";
import type { Item, Ticket } from "../lib/types";
const ROLES = [
{ key: "assigned", label: "派给我的" },
{ key: "requested", label: "我发起的" },
{ key: "all", label: "全部" },
{ key: "assigned", label: "派给我的", desc: "别人等你的" },
{ key: "requested", label: "我发起的", desc: "你等别人的" },
{ key: "all", label: "全部", desc: "工作区所有工单" },
] as const;
const STATUS_FILTERS = [
@@ -31,7 +32,6 @@ export function TicketsPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
const [badge, setBadge] = useState(0);
const reqId = useRef(0);
const load = useCallback(
@@ -44,9 +44,11 @@ export function TicketsPage() {
const qs = new URLSearchParams({ role });
if (status) qs.set("status", status);
if (nextCursor) qs.set("cursor", nextCursor);
const page = await api.get<{ tickets: Ticket[]; next_cursor: string | null; has_more: boolean }>(
`/workspaces/${wsId}/tickets?${qs.toString()}`,
);
const page = await api.get<{
tickets: Ticket[];
next_cursor: string | null;
has_more: boolean;
}>(`/workspaces/${wsId}/tickets?${qs.toString()}`);
if (mine !== reqId.current) return;
setTickets((prev) => (nextCursor ? [...prev, ...page.tickets] : page.tickets));
setCursor(page.has_more ? page.next_cursor : null);
@@ -70,48 +72,30 @@ export function TicketsPage() {
version: ticket.version,
...body,
});
setBadge((n) => n + 1);
void load(null);
} catch (err) {
if (err instanceof ApiError && err.code === 4009) {
setError("这条刚被别人改过,已为你刷新列表");
void load(null);
setError("这条刚被别人改过,已为你刷新列表"); void load(null);
return;
}
setError(err instanceof ApiError ? err.message : "操作失败");
}
}
return (
<Layout>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">工单</h1>
<p className="mt-0.5 text-xs text-ink-500">
待接 → 办理中 → 待验收 → 完成;验收通过后,当初卡住的那条任务会自动回到待办
</p>
</div>
<WorkspaceNav wsId={wsId} refreshKey={badge} />
</div>
const currentRole = ROLES.find((r) => r.key === role);
<div className="mb-3 flex flex-wrap items-center gap-2">
{ROLES.map((r) => (
<button
key={r.key}
onClick={() => setRole(r.key)}
className={`rounded-full px-3 py-1 text-sm transition ${
role === r.key
? "bg-ink-900 text-white"
: "bg-ink-100 text-ink-700 hover:bg-ink-100/70"
}`}
>
{r.label}
</button>
))}
return (
<AppShell wsId={wsId} active="tickets">
<PageHeader
title="工单"
desc="待接 → 办理中 → 待验收 → 完成。验收通过后,当初卡住的那条任务会自动回到待办。"
eyebrow={<>需要别人搭把手的事,都在这里</>}
actions={
<select
className="input ml-auto w-36"
className="input h-9 w-36 text-sm"
value={status}
onChange={(e) => setStatus(e.target.value)}
aria-label="按状态筛选"
>
{STATUS_FILTERS.map((s) => (
<option key={s.key} value={s.key}>
@@ -119,60 +103,118 @@ export function TicketsPage() {
</option>
))}
</select>
}
/>
<div className="mb-4 inline-flex items-center gap-0.5 rounded-xl border border-line-200 bg-paper-50 p-0.5">
{ROLES.map((r) => {
const active = role === r.key;
return (
<button
key={r.key}
onClick={() => setRole(r.key)}
title={r.desc}
className={`rounded-[9px] px-3 py-1.5 text-sm font-medium transition-colors ${
active
? "bg-white text-ink-900 shadow-sm ring-1 ring-line-200"
: "text-ink-500 hover:text-ink-800"
}`}
>
{r.label}
</button>
);
})}
</div>
{error && <p className="mb-3 rounded-lg bg-red-50 p-2 text-sm text-red-700">{error}</p>}
{tickets.length === 0 && !loading && (
<div className="card text-center text-sm text-ink-500">
这里还空着。任务卡在别人手里时,在任务详情里点「升级成工单」就会出现在这儿。
{error && (
<div className="mb-4">
<InlineAlert>{error}</InlineAlert>
</div>
)}
{loading && tickets.length === 0 ? (
<div className="card overflow-hidden">
<SkeletonRows rows={3} />
</div>
) : tickets.length === 0 ? (
<div className="card">
<EmptyState
icon={<TicketIcon size={18} />}
title={role === "assigned" ? "没有等你的工单" : "这里还空着"}
hint={
role === "assigned"
? "别人把事交给你办时,这里会出现;要主动找人帮忙,去任务详情点「升级成工单」。"
: currentRole?.desc
}
/>
</div>
) : (
<ul className="space-y-2">
{tickets.map((t) => (
<li key={t.id} className="card">
<div className="flex items-start justify-between gap-3">
<li
key={t.id}
className="card group p-0 transition hover:border-line-300 hover:shadow-pop"
>
<div className="flex items-start justify-between gap-3 p-4">
<button className="min-w-0 flex-1 text-left" onClick={() => setOpenId(t.id)}>
<p className="truncate text-sm font-medium">{t.title}</p>
<p className="mt-1 text-xs text-ink-500">
{STATUS_LABEL[t.status]} · 更新 {new Date(t.updated_at).toLocaleString("zh-CN")}
{t.ticket.reject_count > 0 ? ` · 退回 ${t.ticket.reject_count} 次` : ""}
{t.due_at
? ` · 截止 ${new Date(t.due_at).toLocaleDateString("zh-CN")}`
: ""}
<div className="flex items-center gap-2">
<StatusPill status={t.status} label={STATUS_LABEL[t.status]} />
{t.ticket.reject_count > 0 && (
<span className="pill bg-rust-50 text-rust-500">
退回 {t.ticket.reject_count} 次
</span>
)}
</div>
<p className="mt-1.5 truncate text-sm font-medium text-ink-900 group-hover:text-primary-700">
{t.title}
</p>
<p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs text-ink-400">
<span>更新 {new Date(t.updated_at).toLocaleString("zh-CN")}</span>
{t.due_at ? (
<span>截止 {new Date(t.due_at).toLocaleDateString("zh-CN")}</span>
) : null}
{t.tags.map((tag) => (
<span key={tag.id}>#{tag.name}</span>
))}
</p>
</button>
<div className="flex shrink-0 gap-2 text-xs">
<div className="flex shrink-0 items-center gap-1.5">
{t.status === "open" && (
<button className="btn-primary px-2 py-1" onClick={() => void quickAction(t, "accept")}>
<Button variant="primary" size="sm" onClick={() => void quickAction(t, "accept")}>
接收
</button>
</Button>
)}
{t.status === "accepted" && (
<button className="btn-primary px-2 py-1" onClick={() => void quickAction(t, "start")}>
<Button variant="primary" size="sm" onClick={() => void quickAction(t, "start")}>
开始
</button>
</Button>
)}
{t.status === "pending_verify" && (
<button className="btn-primary px-2 py-1" onClick={() => void quickAction(t, "verify", { approve: true })}>
<Button
variant="primary"
size="sm"
onClick={() => void quickAction(t, "verify", { approve: true })}
>
通过
</button>
</Button>
)}
<button className="btn-ghost px-2 py-1" onClick={() => setOpenId(t.id)}>
<Button variant="ghost" size="sm" onClick={() => setOpenId(t.id)}>
详情
</button>
<ChevronRight size={13} />
</Button>
</div>
</div>
</li>
))}
</ul>
)}
{cursor && (
<div className="mt-4 text-center">
<button className="btn-ghost" disabled={loading} onClick={() => void load(cursor)}>
<Button variant="ghost" disabled={loading} onClick={() => void load(cursor)}>
{loading ? "加载中…" : "加载更多"}
</button>
</Button>
</div>
)}
@@ -182,20 +224,17 @@ export function TicketsPage() {
itemId={openId}
onClose={() => setOpenId(null)}
onChanged={(updated: Item) =>
setTickets((prev) =>
prev.map((t) => (t.id === updated.id ? { ...t, ...updated } : t)),
)
setTickets((prev) => prev.map((t) => (t.id === updated.id ? { ...t, ...updated } : t)))
}
onDeleted={(id) => {
setTickets((prev) => prev.filter((t) => t.id !== id));
setOpenId(null);
}}
onTicketChanged={() => {
setBadge((n) => n + 1);
void load(null);
}}
/>
)}
</Layout>
</AppShell>
);
}
+156 -69
View File
@@ -1,7 +1,16 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useParams } from "react-router-dom";
import { useAuth } from "../App";
import { Layout } from "../components/Layout";
import { AppShell } from "../components/AppShell";
import { Check, Link2, Lock, Users } from "../components/icons";
import {
Button,
EmptyState,
InlineAlert,
PageHeader,
SkeletonRows,
useToast,
} from "../components/ui";
import { ApiError, api } from "../lib/client";
import type { Invitation, Member, Page, Workspace } from "../lib/types";
@@ -12,17 +21,38 @@ const ROLE_LABEL: Record<string, string> = {
guest: "访客",
};
const ROLE_HINT: Record<string, string> = {
owner: "全部权限,可发行管理员邀请",
admin: "可邀请成员、发布知识",
member: "建任务、提工单、写知识",
guest: "只读",
};
function maskPhone(phone: string): string {
return phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1 **** $2");
}
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 WorkspacePage() {
const { wsId } = useParams<{ wsId: string }>();
const { wsId = "" } = useParams();
const { reload } = useAuth();
const { push: toast } = useToast();
const [ws, setWs] = useState<Workspace | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [invitation, setInvitation] = useState<Invitation | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const load = useCallback(async () => {
if (!wsId) return;
setLoading(true);
try {
const [detail, page] = await Promise.all([
api.get<Workspace>(`/workspaces/${wsId}`),
@@ -33,6 +63,8 @@ export function WorkspacePage() {
setError(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
} finally {
setLoading(false);
}
}, [wsId]);
@@ -46,6 +78,7 @@ export function WorkspacePage() {
setError(null);
try {
setInvitation(await api.post<Invitation>(`/workspaces/${wsId}/invitations`, { role }));
setCopied(false);
await load();
await reload();
} catch (err) {
@@ -56,92 +89,146 @@ export function WorkspacePage() {
}
const canInvite = ws?.role === "owner" || ws?.role === "admin";
const inviteLink = invitation
? `${window.location.origin}/join/${wsId}/${invitation.code}`
: "";
async function copyLink() {
try {
await navigator.clipboard.writeText(inviteLink);
setCopied(true);
toast("邀请链接已复制", "success");
window.setTimeout(() => setCopied(false), 2000);
} catch {
setError("浏览器不允许自动复制,请手动选中下面这行链接");
}
}
return (
<Layout>
<div className="mb-4 flex items-center gap-3 text-sm text-ink-500">
<Link to={`/w/${wsId}`} className="hover:text-ink-900">
← 任务
</Link>
{ws && <span className="rounded-full bg-ink-100 px-2 py-0.5 text-xs">{ROLE_LABEL[ws.role]}</span>}
<AppShell wsId={wsId} active="members">
<PageHeader
title={ws?.name ?? "成员"}
desc={
ws
? `${ws.slug} · ${ws.plan === "free" ? "免费版" : ws.plan} · 你的角色是「${
ROLE_LABEL[ws.role] ?? ws.role
}」`
: "正在读取工作区…"
}
eyebrow={<>谁在这个工作区里,各自能做什么</>}
/>
{error && (
<div className="mb-4">
<InlineAlert>{error}</InlineAlert>
</div>
)}
<div className="grid gap-5 lg:grid-cols-[1.6fr_1fr]">
<section className="card overflow-hidden p-0">
<div className="flex items-center justify-between border-b border-line-200 px-5 py-3.5">
<h2 className="flex items-center gap-2 text-sm font-medium text-ink-800">
<Users size={15} className="text-ink-400" />
成员
<span className="text-ink-400">{members.length}</span>
</h2>
<span className="text-2xs text-ink-400">席位按成员计费,访客不计</span>
</div>
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
<div className="grid gap-6 md:grid-cols-[1.6fr_1fr]">
<section className="card">
<h1 className="text-lg font-semibold">{ws?.name ?? "加载中…"}</h1>
<p className="mt-1 text-xs text-ink-500">
{ws ? `${ws.slug} · ${ws.plan === "free" ? "免费版" : ws.plan}` : ""}
</p>
<h2 className="mt-6 text-sm font-medium text-ink-500">成员({members.length})</h2>
<ul className="mt-3 divide-y divide-ink-100">
{loading && members.length === 0 ? (
<SkeletonRows rows={4} />
) : members.length === 0 ? (
<EmptyState icon={<Users size={18} />} title="还没有成员" hint="生成一个邀请码,发给同事。" />
) : (
<ul className="divide-y divide-line-100">
{members.map((m) => (
<li key={m.id} className="flex items-center justify-between py-2 text-sm">
<div>
<span className="font-medium">{m.nickname || "未命名"}</span>
<span className="ml-2 text-xs text-ink-500">{m.phone}</span>
<li key={m.id} className="flex items-center gap-3 px-5 py-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-primary-50 text-xs font-medium text-primary-700">
{initials(m.nickname || m.phone)}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-ink-900">
{m.nickname || "未命名"}
</p>
<p className="text-2xs text-ink-400">{maskPhone(m.phone)}</p>
</div>
<div className="text-right">
<span
className={`pill ${
m.role === "owner"
? "bg-primary-700 text-white"
: m.role === "admin"
? "bg-primary-50 text-primary-700"
: "bg-paper-200 text-ink-600"
}`}
>
{ROLE_LABEL[m.role] ?? m.role}
</span>
<p className="mt-1 text-2xs text-ink-300">{ROLE_HINT[m.role] ?? ""}</p>
</div>
<span className="text-xs text-ink-500">{ROLE_LABEL[m.role] ?? m.role}</span>
</li>
))}
</ul>
<div className="mt-8 rounded-lg border border-dashed border-ink-100 p-4 text-sm text-ink-500">
<p className="font-medium text-ink-700">下一步(M1)</p>
<p className="mt-1">
任务列表、看板、工单升级、知识沉淀会依次落在这里。骨架先把身份、租户、成员跑通。
</p>
</div>
)}
</section>
<aside className="card h-fit">
<h2 className="text-sm font-medium">邀请成员</h2>
<h2 className="flex items-center gap-2 text-sm font-medium text-ink-800">
<Link2 size={15} className="text-ink-400" />
邀请
</h2>
{canInvite ? (
<div className="mt-3 space-y-3">
<div className="flex gap-2">
<button
className="btn-ghost flex-1"
disabled={busy}
onClick={() => createInvite("member")}
>
邀请成员
</button>
{ws?.role === "owner" && (
<button
className="btn-ghost flex-1"
disabled={busy}
onClick={() => createInvite("admin")}
>
邀请管理员
</button>
)}
</div>
{invitation && (
<div className="rounded-lg bg-ink-50 p-3 text-xs">
<p className="font-medium text-ink-700">邀请码 {invitation.code}</p>
<p className="mt-1 break-all text-ink-500">
{`${window.location.origin}/join/${wsId}/${invitation.code}`}
invitation ? (
<div className="mt-3 space-y-3 animate-fade-in">
<div className="rounded-xl border border-dashed border-primary-200 bg-primary-50/60 p-3.5">
<p className="text-2xs text-primary-700/70">邀请码</p>
<p className="mt-0.5 font-mono text-lg tracking-[0.18em] text-primary-800">
{invitation.code}
</p>
<button
className="btn-ghost mt-2 w-full"
onClick={() => {
void navigator.clipboard.writeText(
`${window.location.origin}/join/${wsId}/${invitation.code}`,
);
}}
>
复制邀请链接
</button>
<p className="mt-2 break-all text-2xs leading-relaxed text-ink-500">{inviteLink}</p>
</div>
<Button variant="primary" className="w-full" onClick={() => void copyLink()}>
{copied ? (
<>
<Check size={14} />
已复制
</>
) : (
"复制邀请链接"
)}
</Button>
<p className="text-2xs leading-relaxed text-ink-400">
链接一次有效,对方打开后自动注册并加入。有效期 7 天。
</p>
</div>
) : (
<p className="mt-2 text-xs text-ink-500">只有负责人和管理员能邀请成员。</p>
<div className="mt-3 space-y-2.5">
<p className="text-xs leading-relaxed text-ink-500">
给同事一个邀请链接:他扫码打开,用手机号登录,就进了这个工作区。
</p>
<Button variant="primary" className="w-full" disabled={busy} onClick={() => void createInvite("member")}>
邀请成员
</Button>
{ws?.role === "owner" && (
<Button
variant="subtle"
className="w-full"
disabled={busy}
onClick={() => void createInvite("admin")}
>
邀请管理员
</Button>
)}
</div>
)
) : (
<p className="mt-2 flex items-start gap-1.5 text-xs text-ink-500">
<Lock size={13} className="mt-0.5 shrink-0 text-ink-300" />
只有负责人和管理员能邀请成员。
</p>
)}
</aside>
</div>
</Layout>
</AppShell>
);
}
+104 -10
View File
@@ -4,21 +4,115 @@ export default {
theme: {
extend: {
colors: {
// 达道:主色偏墨青,克制,不做"任务工具"常见的荧光蓝
// 底色:暖白纸面,不用纯白,避免刺眼、也让卡片有层次
paper: {
50: "#FCFCFA",
100: "#F7F7F3",
200: "#F0F0EA",
300: "#E6E6DE",
},
// 分隔线与描边
line: {
100: "#ECECE6",
200: "#E1E1D9",
300: "#D2D2C8",
},
// 文字与中性
ink: {
50: "#f5f7f8",
100: "#e6eaed",
500: "#3f5a68",
700: "#243c48",
900: "#101b21",
50: "#F6F8F7",
100: "#E9EDEB",
200: "#D7DDDA",
300: "#BAC3C0",
400: "#96A19D",
500: "#6D7874",
600: "#4E5956",
700: "#39443F",
800: "#252E2A",
900: "#161C1A",
},
accent: {
500: "#c2703d",
600: "#a35a2c",
// 主色:墨松绿,克制、有"工作台"气质,避开任务工具常见的荧光蓝
primary: {
50: "#EFF5F3",
100: "#DBE9E4",
200: "#B7D2C9",
300: "#8CB6A9",
400: "#619688",
500: "#41796A",
600: "#2F6054",
700: "#264E44",
800: "#1E3E36",
900: "#172F29",
},
// 强调色:陶土橙,只用在"需要人注意"的地方
clay: {
50: "#FBF2EB",
100: "#F5DFCE",
200: "#EBC1A2",
300: "#DFA176",
400: "#D08454",
500: "#C2703D",
600: "#A65B2F",
700: "#854926",
800: "#65381E",
900: "#472716",
},
sage: { 500: "#2F7D5E", 50: "#EAF4EF" },
wheat: { 500: "#A97C25", 50: "#F8F1E2" },
rust: { 500: "#B04A32", 50: "#FAEDE9" },
sky: { 500: "#3E6F91", 50: "#EBF2F7" },
},
fontFamily: {
sans: ["Inter", "PingFang SC", "Microsoft YaHei", "system-ui", "sans-serif"],
sans: [
"Inter",
"-apple-system",
"PingFang SC",
"HarmonyOS Sans SC",
"Microsoft YaHei",
"system-ui",
"sans-serif",
],
display: ["Inter", "PingFang SC", "HarmonyOS Sans SC", "Microsoft YaHei", "sans-serif"],
mono: ["JetBrains Mono", "SFMono-Regular", "Consolas", "monospace"],
},
fontSize: {
"2xs": ["11px", { lineHeight: "16px", letterSpacing: "0.02em" }],
xs: ["12px", { lineHeight: "18px" }],
sm: ["13px", { lineHeight: "20px" }],
base: ["14px", { lineHeight: "22px" }],
lg: ["16px", { lineHeight: "24px" }],
xl: ["18px", { lineHeight: "26px", letterSpacing: "-0.01em" }],
"2xl": ["22px", { lineHeight: "30px", letterSpacing: "-0.02em" }],
"3xl": ["28px", { lineHeight: "36px", letterSpacing: "-0.02em" }],
"4xl": ["36px", { lineHeight: "42px", letterSpacing: "-0.03em" }],
},
borderRadius: {
md: "8px",
lg: "10px",
xl: "14px",
"2xl": "18px",
"3xl": "24px",
},
boxShadow: {
card: "0 1px 2px rgba(22,28,26,0.04), 0 8px 24px -16px rgba(22,28,26,0.35)",
pop: "0 2px 6px rgba(22,28,26,0.06), 0 20px 48px -24px rgba(22,28,26,0.35)",
inset: "inset 0 1px 0 rgba(255,255,255,0.6)",
},
keyframes: {
"fade-in": { from: { opacity: "0" }, to: { opacity: "1" } },
"slide-up": {
from: { opacity: "0", transform: "translateY(6px)" },
to: { opacity: "1", transform: "translateY(0)" },
},
"slide-in-right": {
from: { opacity: "0", transform: "translateX(24px)" },
to: { opacity: "1", transform: "translateX(0)" },
},
shimmer: { "100%": { transform: "translateX(100%)" } },
},
animation: {
"fade-in": "fade-in .18s ease-out both",
"slide-up": "slide-up .22s cubic-bezier(.2,.7,.3,1) both",
"slide-in-right": "slide-in-right .24s cubic-bezier(.2,.7,.3,1) both",
},
},
},