feat(web): 重做设计系统与全部页面,前端观感对齐当下审美
用户反馈"UI 太丑、太简陋"。不是换配色,而是先立 token 再让页面去用: - tailwind.config.js:纸白 paper / 墨绿 primary / 陶土橙 clay + 语义色 sage/wheat/rust/sky,字号阶、圆角阶、投影、动效 - index.css:卡片、按钮五种变体、输入、pill/chip、导航、骨架屏 - 新增 icons.tsx(线性图标集)与 ui.tsx(Button/Sheet/Toast/空态/骨架/PageHeader) - 新增 AppShell 取代 Layout + WorkspaceNav:桌面侧栏 + 移动顶部横条,工单未读角标 - 六个页面全部重写:登录、首页、任务、看板、工单、知识、成员、邀请落地 - 详情抽屉改用 Sheet,工单态补齐需求人/办理人/退回次数/动作按钮 e2e 因 DOM 升级同步调整(抽屉按 role=dialog 定位、下拉改用 aria-label), 三套用例(7 + 8 + 7 项)全部重跑通过;tsc 0 错,构建 45 modules / gzip 76.70 kB。
This commit is contained in:
@@ -0,0 +1,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
@@ -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
@@ -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(() => "");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../App";
|
||||
import { api } from "../lib/client";
|
||||
import type { Workspace } from "../lib/types";
|
||||
import {
|
||||
Book,
|
||||
ChevronDown,
|
||||
Columns,
|
||||
Inbox,
|
||||
Logo,
|
||||
Logout,
|
||||
TicketIcon,
|
||||
Users,
|
||||
X,
|
||||
} from "./icons";
|
||||
|
||||
export type Section = "tasks" | "board" | "tickets" | "knowledge" | "members";
|
||||
|
||||
const NAV: { key: Section; label: string; to: (id: string) => string; icon: ReactNode }[] = [
|
||||
{ key: "tasks", label: "任务", to: (id) => `/w/${id}`, icon: <Inbox size={16} /> },
|
||||
{ key: "board", label: "看板", to: (id) => `/w/${id}/board`, icon: <Columns size={16} /> },
|
||||
{ key: "tickets", label: "工单", to: (id) => `/w/${id}/tickets`, icon: <TicketIcon size={16} /> },
|
||||
{ key: "knowledge", label: "知识", to: (id) => `/w/${id}/knowledge`, icon: <Book size={16} /> },
|
||||
{ key: "members", label: "成员", to: (id) => `/w/${id}/members`, icon: <Users size={16} /> },
|
||||
];
|
||||
|
||||
function initial(name: string) {
|
||||
return name.trim().slice(0, 1).toUpperCase() || "W";
|
||||
}
|
||||
|
||||
const ROLE_TEXT: Record<string, string> = {
|
||||
owner: "所有者",
|
||||
admin: "管理员",
|
||||
member: "成员",
|
||||
guest: "访客",
|
||||
};
|
||||
|
||||
export function AppShell({
|
||||
wsId,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
wsId: string;
|
||||
active: Section;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { user, workspaces, signOut } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [fetched, setFetched] = useState<Workspace | null>(null);
|
||||
const [unread, setUnread] = useState(0);
|
||||
const [switcher, setSwitcher] = useState(false);
|
||||
|
||||
// 深链直接进来时 workspaces 还没加载好,单独取一次工作区信息
|
||||
const ws = workspaces.find((w) => w.id === wsId) ?? fetched;
|
||||
|
||||
useEffect(() => {
|
||||
if (ws || !wsId) return;
|
||||
let alive = true;
|
||||
api
|
||||
.get<Workspace>(`/workspaces/${wsId}`)
|
||||
.then((w) => {
|
||||
if (alive) setFetched(w);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [ws, wsId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wsId) return;
|
||||
let alive = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const r = await api.get<{ count: number }>(`/workspaces/${wsId}/notifications/unread-count`);
|
||||
if (alive) setUnread(r.count);
|
||||
} catch {
|
||||
/* 未读数失败不该影响主流程 */
|
||||
}
|
||||
};
|
||||
void load();
|
||||
const timer = window.setInterval(load, 30_000);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [wsId]);
|
||||
|
||||
if (!ws) {
|
||||
return (
|
||||
<div className="flex min-h-full items-center justify-center bg-paper-100">
|
||||
<div className="flex items-center gap-2 text-sm text-ink-400">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-primary-400" />
|
||||
正在打开工作区…
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const phoneMasked = user?.phone
|
||||
? user.phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1 **** $2")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col bg-paper-100 lg:flex-row">
|
||||
{/* —— 侧栏(桌面)—— */}
|
||||
<aside className="sticky top-0 z-20 hidden h-screen w-60 shrink-0 flex-col border-r border-line-200 bg-paper-50 lg:flex">
|
||||
<div className="px-5 pb-4 pt-5">
|
||||
<Link to="/" className="flex items-center gap-2.5">
|
||||
<Logo size={30} />
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm font-semibold tracking-tight text-ink-900">达道Task</div>
|
||||
<div className="text-2xs text-ink-400">任务 · 工单 · 知识</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="relative px-3">
|
||||
<button
|
||||
onClick={() => setSwitcher((v) => !v)}
|
||||
className="flex w-full items-center gap-2 rounded-xl border border-line-200 bg-white px-2.5 py-2 text-left transition hover:border-line-300"
|
||||
>
|
||||
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-primary-900 text-2xs font-semibold text-paper-50">
|
||||
{initial(ws.name)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-medium text-ink-800">{ws.name}</span>
|
||||
<span className="block text-2xs text-ink-400">{ROLE_TEXT[ws.role] ?? ws.role}</span>
|
||||
</span>
|
||||
<ChevronDown size={14} className="shrink-0 text-ink-400" />
|
||||
</button>
|
||||
|
||||
{switcher ? (
|
||||
<>
|
||||
<div className="fixed inset-0 z-20" onClick={() => setSwitcher(false)} />
|
||||
<div className="absolute left-3 right-3 top-[calc(100%+4px)] z-30 animate-slide-up overflow-hidden rounded-xl border border-line-200 bg-white p-1 shadow-pop">
|
||||
<div className="px-2 py-1 text-2xs font-medium text-ink-400">切换工作区</div>
|
||||
{workspaces.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
onClick={() => {
|
||||
setSwitcher(false);
|
||||
const nav = NAV.find((n) => n.key === active);
|
||||
navigate(nav ? nav.to(w.id) : `/w/${w.id}`);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition hover:bg-paper-100 ${
|
||||
w.id === ws.id ? "bg-paper-100 font-medium text-ink-900" : "text-ink-600"
|
||||
}`}
|
||||
>
|
||||
<span className="grid h-5 w-5 place-items-center rounded bg-line-100 text-2xs font-semibold text-ink-600">
|
||||
{initial(w.name)}
|
||||
</span>
|
||||
<span className="truncate">{w.name}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="my-1 h-px bg-line-100" />
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setSwitcher(false)}
|
||||
className="flex w-full items-center rounded-lg px-2 py-1.5 text-xs text-ink-500 hover:bg-paper-100 hover:text-ink-800"
|
||||
>
|
||||
全部工作区
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<nav className="mt-6 flex flex-1 flex-col gap-0.5 px-3">
|
||||
{NAV.map((n) => {
|
||||
const isActive = n.key === active;
|
||||
return (
|
||||
<Link key={n.key} to={n.to(ws.id)} className={`nav-item ${isActive ? "nav-item-active" : ""}`}>
|
||||
<span className={isActive ? "text-primary-200" : "text-ink-400"}>{n.icon}</span>
|
||||
{n.label}
|
||||
{n.key === "tickets" && unread > 0 ? (
|
||||
<span
|
||||
className={`ml-auto rounded-full px-1.5 py-px text-2xs font-semibold tabular-nums ${
|
||||
isActive ? "bg-clay-500 text-white" : "bg-clay-500/12 text-clay-600"
|
||||
}`}
|
||||
>
|
||||
{unread > 99 ? "99+" : unread}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-line-200 px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-full bg-ink-200 text-2xs font-semibold text-ink-700">
|
||||
{initial(user?.nickname ?? "我")}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium text-ink-800">{user?.nickname}</div>
|
||||
<div className="truncate text-2xs text-ink-400">{phoneMasked}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={signOut}
|
||||
title="退出登录"
|
||||
className="rounded-md p-1.5 text-ink-400 transition hover:bg-ink-100 hover:text-ink-700"
|
||||
>
|
||||
<Logout size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* —— 顶部条(移动端)—— */}
|
||||
<header className="sticky top-0 z-20 border-b border-line-200 bg-paper-50/95 backdrop-blur lg:hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2.5">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<Logo size={24} />
|
||||
<span className="max-w-[42vw] truncate text-sm font-semibold text-ink-900">{ws.name}</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-1">
|
||||
{unread > 0 ? (
|
||||
<span className="rounded-full bg-clay-500 px-1.5 py-px text-2xs font-semibold text-white">
|
||||
{unread}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="rounded-md p-1.5 text-ink-400 hover:bg-ink-100"
|
||||
aria-label="退出登录"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="no-scrollbar flex gap-1 overflow-x-auto px-3 pb-2">
|
||||
{NAV.map((n) => (
|
||||
<Link
|
||||
key={n.key}
|
||||
to={n.to(ws.id)}
|
||||
className={`whitespace-nowrap rounded-md px-2.5 py-1 text-xs font-medium ${
|
||||
n.key === active ? "bg-primary-900 text-paper-50" : "text-ink-500"
|
||||
}`}
|
||||
>
|
||||
{n.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="min-w-0 flex-1">
|
||||
<div className="mx-auto w-full max-w-6xl px-5 py-6 lg:px-9 lg:py-8">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../App";
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { user, signOut } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-full max-w-5xl flex-col px-6 py-6">
|
||||
<header className="mb-6 flex items-center justify-between">
|
||||
<Link to="/" className="flex items-baseline gap-2">
|
||||
<span className="text-xl font-semibold tracking-tight">达道Task</span>
|
||||
<span className="text-xs text-ink-500">任务 → 工单 → 知识,一条流</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 text-sm text-ink-500">
|
||||
{user && <span>{user.nickname}</span>}
|
||||
{user ? (
|
||||
<button className="btn-ghost" onClick={signOut}>
|
||||
退出
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn-ghost" onClick={() => navigate("/login")}>
|
||||
登录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1">{children}</main>
|
||||
<footer className="mt-8 text-xs text-ink-500">
|
||||
达道 · 从任务到知识,一条道走到底
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { api } from "../lib/client";
|
||||
|
||||
const TABS = [
|
||||
{ path: "", label: "任务" },
|
||||
{ path: "/board", label: "看板" },
|
||||
{ path: "/tickets", label: "工单" },
|
||||
{ path: "/knowledge", label: "知识" },
|
||||
{ path: "/members", label: "成员" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 工作区内的导航条。
|
||||
*
|
||||
* 未读数为什么放在这里:用户在哪个页面都可能被指派工单,
|
||||
* 角标必须跟着走,所以让它自己拉一次、再每 30 秒对齐一次。
|
||||
* `refreshKey` 用来在"刚处理完一条工单"后立刻刷新角标。
|
||||
*/
|
||||
export function WorkspaceNav({
|
||||
wsId,
|
||||
refreshKey = 0,
|
||||
}: {
|
||||
wsId: string;
|
||||
refreshKey?: number;
|
||||
}) {
|
||||
const { pathname } = useLocation();
|
||||
const [unread, setUnread] = useState(0);
|
||||
const base = `/w/${wsId}`;
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => {
|
||||
api
|
||||
.get<{ unread: number }>(`/workspaces/${wsId}/notifications/unread-count`)
|
||||
.then((r) => {
|
||||
if (alive) setUnread(r.unread);
|
||||
})
|
||||
.catch(() => {
|
||||
/* 角标失败不打扰用户 */
|
||||
});
|
||||
};
|
||||
void load();
|
||||
const timer = window.setInterval(load, 30_000);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [wsId, refreshKey]);
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-4 text-sm">
|
||||
{TABS.map((tab) => {
|
||||
const active =
|
||||
tab.path === "" ? pathname === base : pathname.startsWith(base + tab.path);
|
||||
return (
|
||||
<Link
|
||||
key={tab.label}
|
||||
to={base + tab.path}
|
||||
className={
|
||||
active
|
||||
? "font-medium text-ink-900"
|
||||
: "text-ink-500 transition hover:text-ink-900"
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.path === "/tickets" && unread > 0 && (
|
||||
<span className="ml-1 rounded-full bg-accent-500 px-1.5 text-[10px] leading-4 text-white">
|
||||
{unread > 99 ? "99+" : unread}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<Link to="/" className="text-ink-500 transition hover:text-ink-900">
|
||||
切换
|
||||
</Link>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
type IconProps = SVGProps<SVGSVGElement> & { size?: number };
|
||||
|
||||
function base({ size = 16, ...rest }: IconProps) {
|
||||
return {
|
||||
width: size,
|
||||
height: size,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 1.7,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
"aria-hidden": true,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
export const Check = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CheckCircle = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="m8.5 12 2.5 2.5 4.5-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Circle = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Search = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<circle cx="11" cy="11" r="6.5" />
|
||||
<path d="m16 16 4 4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Plus = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const X = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ChevronDown = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ChevronRight = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="m9 6 6 6-6 6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ChevronLeft = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="m15 6-6 6 6 6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ArrowRight = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M5 12h14M13 6l6 6-6 6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Columns = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<rect x="3.5" y="4" width="17" height="16" rx="2.5" />
|
||||
<path d="M9.5 4v16M15 4v16" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TicketIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M3.5 9.5V7a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2v2.5a2.5 2.5 0 0 0 0 5V17a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2v-2.5a2.5 2.5 0 0 0 0-5Z" />
|
||||
<path d="M12 7v2M12 15v2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Book = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H19v15.5H6.5A2.5 2.5 0 0 0 4 21Z" />
|
||||
<path d="M4 18.5V5.5M8.5 7.5h6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Users = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<circle cx="9" cy="8.5" r="3.2" />
|
||||
<path d="M3.5 19.5a5.5 5.5 0 0 1 11 0" />
|
||||
<path d="M16 6.2a3.2 3.2 0 0 1 0 6M17.5 15.2a5.5 5.5 0 0 1 3 4.3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Inbox = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M3.5 13.5h4l1.2 2.2h6.6l1.2-2.2h4" />
|
||||
<path d="M5.6 4.5h12.8l2.1 9v4.3a2 2 0 0 1-2 2H5.5a2 2 0 0 1-2-2V13.5Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Sparkle = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M12 3.5 13.6 9l5.4 1.6-5.4 1.6L12 17.5l-1.6-5.3L5 10.6 10.4 9Z" />
|
||||
<path d="M18.5 16.5 19.2 19l2.3.7-2.3.8-.7 2.4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Clock = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M12 7.5V12l3 2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Trash = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M4.5 7h15M9.5 7V5.5A1.5 1.5 0 0 1 11 4h2a1.5 1.5 0 0 1 1.5 1.5V7" />
|
||||
<path d="M6.5 7l.8 11.2A2 2 0 0 0 9.3 20h5.4a2 2 0 0 0 2-1.8L17.5 7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Lock = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<rect x="4.5" y="10.5" width="15" height="10" rx="2.2" />
|
||||
<path d="M8 10.5V8a4 4 0 0 1 8 0v2.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Logout = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M14.5 4.5H7a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h7.5" />
|
||||
<path d="M18 12H10M15.5 8.5 19 12l-3.5 3.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Flag = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M6 21V4.5M6 5h10.5l-1.7 3.6L16.5 12H6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Link2 = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M9.5 14.5 14.5 9.5" />
|
||||
<path d="M11 6.8 12.6 5.2a3.7 3.7 0 0 1 5.2 5.2l-1.6 1.6" />
|
||||
<path d="M13 17.2 11.4 18.8a3.7 3.7 0 0 1-5.2-5.2l1.6-1.6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const More = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<circle cx="6" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
||||
<circle cx="18" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Spinner = ({ size = 16, className = "" }: { size?: number; className?: string }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
className={`animate-spin ${className}`}
|
||||
aria-hidden
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.6" opacity="0.2" fill="none" />
|
||||
<path
|
||||
d="M21 12a9 9 0 0 0-9-9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.6"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Logo = ({ size = 28 }: { size?: number }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden>
|
||||
<rect width="32" height="32" rx="9" fill="#2F6054" />
|
||||
<path
|
||||
d="M9 11.5h14M9 16h9.5M9 20.5h6"
|
||||
stroke="#EFF5F3"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx="23" cy="20.5" r="3" fill="#C2703D" />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,319 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Check, X } from "./icons";
|
||||
|
||||
/* ——————————————————————————— 按钮 ——————————————————————————— */
|
||||
|
||||
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: "primary" | "ghost" | "subtle" | "clay" | "danger";
|
||||
size?: "sm" | "md" | "lg";
|
||||
};
|
||||
|
||||
export function Button({ variant = "ghost", size = "md", className = "", ...rest }: ButtonProps) {
|
||||
const variantClass = {
|
||||
primary: "btn-primary",
|
||||
ghost: "btn-ghost",
|
||||
subtle: "btn-subtle",
|
||||
clay: "btn-clay",
|
||||
danger: "btn-danger",
|
||||
}[variant];
|
||||
const sizeClass = { sm: "btn-sm", md: "", lg: "btn-lg" }[size];
|
||||
return <button className={`${variantClass} ${sizeClass} ${className}`} {...rest} />;
|
||||
}
|
||||
|
||||
/* ——————————————————————————— 表单 ——————————————————————————— */
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="label">{label}</span>
|
||||
{children}
|
||||
{hint ? <span className="mt-1 block text-2xs text-ink-400">{hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Segmented<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
options: { value: T; label: string; count?: number }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-0.5 rounded-lg border border-line-200 bg-paper-50 p-0.5">
|
||||
{options.map((o) => {
|
||||
const active = o.value === value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={`rounded-[7px] px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
active
|
||||
? "bg-white text-ink-900 shadow-sm ring-1 ring-line-200"
|
||||
: "text-ink-500 hover:text-ink-800"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
{typeof o.count === "number" ? (
|
||||
<span className={`ml-1 tabular-nums ${active ? "text-ink-400" : "text-ink-300"}`}>
|
||||
{o.count}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ——————————————————————————— 状态与优先级 ——————————————————————————— */
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
inbox: "bg-ink-100 text-ink-600",
|
||||
todo: "bg-sky-50 text-sky-500",
|
||||
doing: "bg-primary-50 text-primary-600",
|
||||
blocked: "bg-wheat-50 text-wheat-500",
|
||||
done: "bg-sage-50 text-sage-500",
|
||||
canceled: "bg-ink-100 text-ink-400",
|
||||
open: "bg-clay-50 text-clay-600",
|
||||
accepted: "bg-sky-50 text-sky-500",
|
||||
in_progress: "bg-primary-50 text-primary-600",
|
||||
pending_verify: "bg-wheat-50 text-wheat-500",
|
||||
draft: "bg-ink-100 text-ink-600",
|
||||
in_review: "bg-wheat-50 text-wheat-500",
|
||||
published: "bg-sage-50 text-sage-500",
|
||||
archived: "bg-ink-100 text-ink-400",
|
||||
};
|
||||
|
||||
export function StatusPill({ status, label }: { status: string; label: string }) {
|
||||
return (
|
||||
<span className={`pill ${STATUS_STYLE[status] ?? "bg-ink-100 text-ink-600"}`}>{label}</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PriorityDot({ level }: { level: number }) {
|
||||
if (!level) return null;
|
||||
const cls = level >= 3 ? "bg-rust-500" : level === 2 ? "bg-wheat-500" : "bg-ink-300";
|
||||
const title = level >= 3 ? "高优先级" : level === 2 ? "中优先级" : "低优先级";
|
||||
return <span title={title} className={`h-1.5 w-1.5 shrink-0 rounded-full ${cls}`} />;
|
||||
}
|
||||
|
||||
/* ——————————————————————————— 空态与骨架 ——————————————————————————— */
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
hint,
|
||||
action,
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
hint?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||
{icon ? (
|
||||
<div className="mb-3 grid h-11 w-11 place-items-center rounded-xl border border-line-200 bg-paper-50 text-ink-300">
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-base font-medium text-ink-700">{title}</p>
|
||||
{hint ? <p className="mt-1 max-w-sm text-sm text-ink-400">{hint}</p> : null}
|
||||
{action ? <div className="mt-4">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonRows({ rows = 4 }: { rows?: number }) {
|
||||
return (
|
||||
<div className="divide-y divide-line-100">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3.5">
|
||||
<div className="skeleton h-4 w-4 rounded-full" />
|
||||
<div className="skeleton h-3.5 flex-1" style={{ maxWidth: `${60 - i * 6}%` }} />
|
||||
<div className="skeleton h-3 w-14" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ——————————————————————————— 页头 ——————————————————————————— */
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
desc,
|
||||
actions,
|
||||
eyebrow,
|
||||
}: {
|
||||
title: string;
|
||||
desc?: string;
|
||||
actions?: ReactNode;
|
||||
eyebrow?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="mb-6 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
{eyebrow ? <div className="mb-1 text-2xs font-medium text-ink-400">{eyebrow}</div> : null}
|
||||
<h1 className="text-2xl font-semibold">{title}</h1>
|
||||
{desc ? <p className="mt-1 text-sm text-ink-500">{desc}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/* ——————————————————————————— 提示条 ——————————————————————————— */
|
||||
|
||||
export function InlineAlert({
|
||||
tone = "error",
|
||||
children,
|
||||
}: {
|
||||
tone?: "error" | "info" | "success";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const cls = {
|
||||
error: "border-rust-500/20 bg-rust-50 text-rust-500",
|
||||
info: "border-sky-500/20 bg-sky-50 text-sky-500",
|
||||
success: "border-sage-500/20 bg-sage-50 text-sage-500",
|
||||
}[tone];
|
||||
return (
|
||||
<div className={`animate-fade-in rounded-lg border px-3 py-2 text-sm ${cls}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ——————————————————————————— Toast ——————————————————————————— */
|
||||
|
||||
type Toast = { id: number; text: string; tone: "info" | "success" | "error" };
|
||||
const ToastContext = createContext<{ push: (text: string, tone?: Toast["tone"]) => void } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error("useToast 必须在 ToastProvider 内使用");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<Toast[]>([]);
|
||||
const seq = useRef(0);
|
||||
|
||||
const push = useCallback((text: string, tone: Toast["tone"] = "info") => {
|
||||
const id = ++seq.current;
|
||||
setItems((prev) => [...prev.slice(-2), { id, text, tone }]);
|
||||
window.setTimeout(() => setItems((prev) => prev.filter((t) => t.id !== id)), 3200);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ push }), [push]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed bottom-6 left-1/2 z-[60] flex -translate-x-1/2 flex-col items-center gap-2">
|
||||
{items.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="animate-slide-up pointer-events-auto flex items-center gap-2 rounded-xl border border-line-200 bg-ink-900 px-3.5 py-2 text-sm text-paper-50 shadow-pop"
|
||||
>
|
||||
<span
|
||||
className={
|
||||
t.tone === "error"
|
||||
? "text-rust-500"
|
||||
: t.tone === "success"
|
||||
? "text-sage-500"
|
||||
: "text-primary-300"
|
||||
}
|
||||
>
|
||||
<Check size={14} />
|
||||
</span>
|
||||
{t.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/* ——————————————————————————— 抽屉 ——————————————————————————— */
|
||||
|
||||
export function Sheet({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
footer,
|
||||
width = "max-w-xl",
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
width?: string;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end">
|
||||
<div
|
||||
className="absolute inset-0 animate-fade-in bg-ink-900/25 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={typeof title === "string" ? title : "详情"}
|
||||
className={`relative flex h-full w-full ${width} animate-slide-in-right flex-col border-l border-line-200 bg-white shadow-pop`}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-3 border-b border-line-100 px-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-semibold text-ink-900">{title}</div>
|
||||
{subtitle ? <div className="mt-0.5 text-xs text-ink-400">{subtitle}</div> : null}
|
||||
</div>
|
||||
<Button variant="subtle" size="sm" onClick={onClose} aria-label="关闭">
|
||||
<X size={15} />
|
||||
关闭
|
||||
</Button>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">{children}</div>
|
||||
{footer ? (
|
||||
<footer className="border-t border-line-100 bg-paper-50 px-6 py-3">{footer}</footer>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+136
-9
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user