feat(web): 重做设计系统与全部页面,前端观感对齐当下审美

用户反馈"UI 太丑、太简陋"。不是换配色,而是先立 token 再让页面去用:

- tailwind.config.js:纸白 paper / 墨绿 primary / 陶土橙 clay + 语义色
  sage/wheat/rust/sky,字号阶、圆角阶、投影、动效
- index.css:卡片、按钮五种变体、输入、pill/chip、导航、骨架屏
- 新增 icons.tsx(线性图标集)与 ui.tsx(Button/Sheet/Toast/空态/骨架/PageHeader)
- 新增 AppShell 取代 Layout + WorkspaceNav:桌面侧栏 + 移动顶部横条,工单未读角标
- 六个页面全部重写:登录、首页、任务、看板、工单、知识、成员、邀请落地
- 详情抽屉改用 Sheet,工单态补齐需求人/办理人/退回次数/动作按钮

e2e 因 DOM 升级同步调整(抽屉按 role=dialog 定位、下拉改用 aria-label),
三套用例(7 + 8 + 7 项)全部重跑通过;tsc 0 错,构建 45 modules / gzip 76.70 kB。
This commit is contained in:
dadao
2026-10-28 18:37:35 +08:00
parent f683436689
commit 5be55fdccb
21 changed files with 2520 additions and 968 deletions
+146 -103
View File
@@ -1,17 +1,18 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { Layout } from "../components/Layout";
import { AppShell } from "../components/AppShell";
import { TaskDetail } from "../components/TaskDetail";
import { WorkspaceNav } from "../components/WorkspaceNav";
import { Check, Columns } from "../components/icons";
import { Button, EmptyState, InlineAlert, PageHeader, StatusPill } from "../components/ui";
import { ApiError, api } from "../lib/client";
import { PRIORITY_LABEL, STATUS_LABEL, canTransition } from "../lib/parse";
import type { Item, ItemStatus } from "../lib/types";
const COLUMNS: { key: ItemStatus; label: string }[] = [
{ key: "todo", label: "待办" },
{ key: "doing", label: "进行中" },
{ key: "blocked", label: "被阻塞" },
{ key: "done", label: "已完成" },
const COLUMNS: { key: ItemStatus; label: string; dot: string; tint: string }[] = [
{ key: "todo", label: "待办", dot: "bg-sky-500", tint: "bg-sky-50/40" },
{ key: "doing", label: "进行中", dot: "bg-primary-500", tint: "bg-primary-50/40" },
{ key: "blocked", label: "被阻塞", dot: "bg-wheat-500", tint: "bg-wheat-50/40" },
{ key: "done", label: "已完成", dot: "bg-sage-500", tint: "bg-sage-50/40" },
];
interface BoardPayload {
@@ -136,112 +137,154 @@ 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>
)}
<div className="grid gap-3 md:grid-cols-4">
{COLUMNS.map((col) => {
const list = columns[col.key] ?? [];
return (
<section
key={col.key}
onDragOver={(e) => {
e.preventDefault();
setHoverCol(col.key);
}}
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"
}`}
>
<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>
{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}
onDragOver={(e) => {
e.preventDefault();
setHoverCol(col.key);
}}
onDragLeave={() => setHoverCol((prev) => (prev === col.key ? null : prev))}
onDrop={() => void onDrop(col.key)}
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 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">
{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" : ""
}`}
>
<div className="flex items-start gap-2">
<input
type="checkbox"
className="mt-1 h-3.5 w-3.5 accent-ink-900"
checked={selected.has(item.id)}
onChange={() => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id);
else next.add(item.id);
return next;
});
}}
/>
<button
className="min-w-0 flex-1 text-left text-sm"
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">
{item.priority > 0 && (
<span className="rounded bg-accent/15 px-1 text-accent">
{PRIORITY_LABEL[item.priority]}
</span>
)}
{item.due_at && <span>{new Date(item.due_at).toLocaleDateString("zh-CN")}</span>}
{item.tags.map((t) => (
<span key={t.id}>#{t.name}</span>
))}
</div>
</article>
))}
{list.length === 0 && (
<p className="px-1 py-4 text-center text-xs text-ink-500">空</p>
)}
</div>
</section>
);
})}
</div>
<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={`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-0.5 h-3.5 w-3.5 shrink-0 rounded accent-primary-700"
checked={selected.has(item.id)}
onChange={() => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id);
else next.add(item.id);
return next;
});
}}
aria-label={`选择 ${item.title}`}
/>
)}
<button
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-2 flex flex-wrap items-center gap-1 pl-5">
{item.priority > 0 && (
<span className="chip bg-clay-50 text-clay-600">
{PRIORITY_LABEL[item.priority]}
</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} className="text-2xs text-ink-400">
#{t.name}
</span>
))}
</div>
</article>
))}
{list.length === 0 && (
<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>
);
}
+134 -59
View File
@@ -1,12 +1,26 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../App";
import { Layout } from "../components/Layout";
import { ArrowRight, ChevronRight, Logo, Logout, Plus } from "../components/icons";
import { Button, EmptyState, Field, InlineAlert } from "../components/ui";
import { ApiError, api } from "../lib/client";
import type { Workspace } from "../lib/types";
function roleLabel(role: string): string {
return { owner: "所有者", admin: "管理员", member: "成员", guest: "访客" }[role] ?? role;
}
function greeting() {
const h = new Date().getHours();
if (h < 6) return "夜深了";
if (h < 11) return "早上好";
if (h < 14) return "中午好";
if (h < 18) return "下午好";
return "晚上好";
}
export function HomePage() {
const { user, workspaces, loading, reload } = useAuth();
const { user, workspaces, loading, reload, signOut } = useAuth();
const navigate = useNavigate();
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -34,64 +48,125 @@ export function HomePage() {
}
return (
<Layout>
<div className="grid gap-6 md:grid-cols-[1.6fr_1fr]">
<section>
<h2 className="mb-3 text-sm font-medium text-ink-500">我的工作区</h2>
{loading ? (
<p className="text-sm text-ink-500">加载中…</p>
) : workspaces.length === 0 ? (
<div className="card text-sm text-ink-500">
还没有工作区。右侧创建一个,个人用也一样——先把"任务"放进去。
</div>
) : (
<ul className="space-y-3">
{workspaces.map((ws) => (
<li key={ws.id}>
<Link
to={`/w/${ws.id}`}
className="card flex items-center justify-between transition hover:border-ink-500"
>
<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>
</Link>
</li>
))}
</ul>
)}
</section>
<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>
<aside className="card h-fit">
<h2 className="text-sm font-medium">新建工作区</h2>
<form className="mt-3 space-y-3" onSubmit={createWorkspace}>
<input
className="input"
placeholder="例如:达道科技 / 我的事项"
value={name}
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()}>
{busy ? "创建中…" : "创建"}
</button>
</form>
<p className="mt-4 border-t border-ink-100 pt-3 text-xs text-ink-500">
收到邀请链接(/join/…)直接打开即可加入。
<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>
</aside>
</div>
</Layout>
<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>
<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 ? (
<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">
<EmptyState
icon={<Plus size={18} />}
title="还没有工作区"
hint="个人用也一样:先建一个,把「任务」放进去。需要别人帮忙时再升级成工单。"
/>
</div>
) : (
<ul className="grid gap-3 sm:grid-cols-2">
{workspaces.map((ws) => (
<li key={ws.id}>
<Link
to={`/w/${ws.id}`}
className="group card flex items-center gap-3 p-4 transition hover:-translate-y-0.5 hover:border-primary-300 hover:shadow-pop"
>
<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>
))}
</ul>
)}
</section>
<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="例如:达道科技 / 我的事项"
value={name}
maxLength={64}
onChange={(e) => setName(e.target.value)}
/>
</Field>
{error && <InlineAlert>{error}</InlineAlert>}
<Button type="submit" variant="primary" className="w-full" disabled={busy || !name.trim()}>
{busy ? "创建中…" : "创建"}
{busy ? null : <ArrowRight size={14} />}
</Button>
</form>
<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>
</main>
</div>
);
}
function roleLabel(role: string): string {
return { owner: "负责人", admin: "管理员", member: "成员", guest: "访客" }[role] ?? role;
}
+33 -10
View File
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useAuth } from "../App";
import { Layout } from "../components/Layout";
import { Check, Logo, Spinner } from "../components/icons";
import { Button, InlineAlert } from "../components/ui";
import { ApiError, api, session } from "../lib/client";
import type { Workspace } from "../lib/types";
@@ -11,6 +12,7 @@ export function JoinPage() {
const { user, loading, reload } = useAuth();
const navigate = useNavigate();
const [message, setMessage] = useState("正在加入工作区…");
const [done, setDone] = useState(false);
const [failed, setFailed] = useState(false);
const started = useRef(false);
@@ -27,8 +29,9 @@ export function JoinPage() {
try {
const ws = await api.post<Workspace>(`/workspaces/${wsId}/join`, { code });
await reload();
setDone(true);
setMessage(`已加入「${ws.name}」,正在跳转…`);
setTimeout(() => navigate(`/w/${wsId}`, { replace: true }), 600);
window.setTimeout(() => navigate(`/w/${wsId}`, { replace: true }), 700);
} catch (err) {
setFailed(true);
setMessage(err instanceof ApiError ? err.message : "加入失败");
@@ -37,17 +40,37 @@ export function JoinPage() {
}, [loading, user, wsId, code, navigate, reload]);
return (
<Layout>
<div className="mx-auto mt-10 max-w-md">
<div className="card text-center">
<p className={failed ? "text-sm text-red-600" : "text-sm text-ink-700"}>{message}</p>
<main className="grid min-h-full place-items-center bg-paper-100 px-6">
<div className="w-full max-w-sm animate-slide-up">
<div className="mb-6 flex justify-center">
<Logo size={40} />
</div>
<div className="card p-7 text-center">
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-2xl bg-primary-50 text-primary-700">
{failed ? (
<span className="text-xl">!</span>
) : done ? (
<Check size={22} />
) : (
<Spinner size={20} />
)}
</div>
<p className="text-sm leading-relaxed text-ink-700">{message}</p>
<p className="mt-1 text-2xs text-ink-400">
{failed ? "链接可能已失效,或你已经加入过了。" : "邀请码一次有效"}
</p>
{failed && (
<button className="btn-ghost mt-4" onClick={() => navigate("/")}>
返回工作区列表
</button>
<>
<div className="mt-4">
<InlineAlert>可以把邀请码发给同事,让他重新生成一条链接。</InlineAlert>
</div>
<Button variant="ghost" className="mt-4 w-full" onClick={() => navigate("/")}>
返回工作区列表
</Button>
</>
)}
</div>
</div>
</Layout>
</main>
);
}
+217 -144
View File
@@ -1,7 +1,16 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { Layout } from "../components/Layout";
import { WorkspaceNav } from "../components/WorkspaceNav";
import { AppShell } from "../components/AppShell";
import { Book, Link2, Plus, Search, Sparkle } from "../components/icons";
import {
Button,
EmptyState,
InlineAlert,
PageHeader,
Sheet,
SkeletonRows,
StatusPill,
} from "../components/ui";
import { ApiError, api } from "../lib/client";
import type { Knowledge, KnowledgeStatus } from "../lib/types";
@@ -106,102 +115,159 @@ export function KnowledgePage() {
}
}
return (
<Layout>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">知识</h1>
<p className="mt-0.5 text-xs text-ink-500">
工单验收通过会自动整理一份草稿;核对无误再发布,发布后才会进入团队检索
</p>
</div>
<WorkspaceNav wsId={wsId} />
</div>
const pendingCount = docs.filter((d) => d.knowledge.status_hint !== "published").length;
<div className="mb-3 flex flex-wrap items-center gap-2">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`rounded-full px-3 py-1 text-sm transition ${
tab === t.key ? "bg-ink-900 text-white" : "bg-ink-100 text-ink-700 hover:bg-ink-100/70"
}`}
>
{t.label}
</button>
))}
<input
className="input ml-auto w-56"
placeholder="搜标题或正文…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<button className="btn-primary px-3 py-1 text-sm" onClick={() => setAdding((v) => !v)}>
记一条
</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 h-9 w-48 pl-8 text-sm"
placeholder="搜标题或正文…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</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">
手记的知识没有来源,它是你自己的笔记;想沉淀某件事,去那件事的详情里点「沉淀成知识」。
</p>
<div className="flex gap-2">
<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>
<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>
)}
<ul className="space-y-2">
{docs.map((d) => (
<li key={d.id} className="card">
<div className="flex items-start justify-between gap-3">
{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 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>
</li>
))}
</ul>
</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">
{actions
.filter((a) => a.show)
.map((a) => (
<button
key={a.key}
className={a.key === "publish" ? "btn-primary px-3 py-1 text-sm" : "btn-ghost px-3 py-1 text-sm"}
disabled={busy}
onClick={() => void act(a.key)}
>
{a.label}
</button>
))}
</Button>
) : null}
{actions
.filter((a) => a.show)
.map((a) => (
<Button
key={a.key}
variant={a.key === "publish" ? "clay" : "ghost"}
disabled={busy}
onClick={() => void act(a.key)}
>
{a.label}
</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>
) : 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>
)}
</div>
{error && <p className="text-sm text-red-700">{error}</p>}
</>
)}
</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>
);
}
+103 -30
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../App";
import { Layout } from "../components/Layout";
import { Book, Check, Logo, TicketIcon } from "../components/icons";
import { Button, Field, InlineAlert } from "../components/ui";
import { ApiError, api, session, type Tokens } from "../lib/client";
import type { User } from "../lib/types";
@@ -15,6 +16,20 @@ function safeNext(raw: string | null): string | null {
return raw;
}
const HIGHLIGHTS = [
{ icon: <Check size={15} />, title: "一件事,从头到尾", desc: "收下、开工、完成,状态一路可见" },
{
icon: <TicketIcon size={15} />,
title: "卡住就变成工单",
desc: "原任务自动挂着等,办完再回来,不丢线",
},
{
icon: <Book size={15} />,
title: "办完自动留档",
desc: "验收通过生成知识草稿,核对后进团队库",
},
];
export function LoginPage() {
const { reload } = useAuth();
const navigate = useNavigate();
@@ -66,66 +81,124 @@ export function LoginPage() {
}
return (
<Layout>
<div className="mx-auto mt-10 max-w-sm">
<div className="card">
<h1 className="text-lg font-semibold">登录 / 注册</h1>
<p className="mt-1 text-sm text-ink-500">手机号验证码登录,未注册会自动创建账号。</p>
<div className="flex min-h-full flex-col lg:flex-row">
{/* 左:品牌页 */}
<div className="relative hidden flex-1 overflow-hidden bg-primary-900 lg:flex lg:flex-col lg:justify-between lg:p-12">
<div className="grid-lines absolute inset-0 opacity-40" aria-hidden />
<div className="absolute -left-24 -top-24 h-80 w-80 rounded-full bg-primary-700/60 blur-3xl" aria-hidden />
<div className="absolute -bottom-32 right-0 h-96 w-96 rounded-full bg-clay-500/20 blur-3xl" aria-hidden />
<form className="mt-6 space-y-4" onSubmit={submit}>
<div>
<label className="label" htmlFor="phone">
手机号
</label>
<div className="relative flex items-center gap-2.5 text-paper-50">
<Logo size={32} />
<span className="text-lg font-semibold tracking-tight">达道Task</span>
</div>
<div className="relative max-w-md">
<h1 className="font-display text-4xl font-semibold leading-[1.15] text-paper-50">
从任务到知识,
<br />
一条道走到底。
</h1>
<p className="mt-4 text-base leading-relaxed text-paper-200/80">
你自己的待办、需要别人搭把手的时刻、以及干完活留下的经验——本来就是一回事。
</p>
<ul className="mt-10 space-y-5">
{HIGHLIGHTS.map((h) => (
<li key={h.title} className="flex gap-3">
<span className="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-lg border border-paper-50/20 bg-paper-50/10 text-paper-100">
{h.icon}
</span>
<span>
<span className="block text-sm font-medium text-paper-50">{h.title}</span>
<span className="block text-sm text-paper-200/65">{h.desc}</span>
</span>
</li>
))}
</ul>
</div>
<p className="relative text-2xs text-paper-200/45">
个人先用着,团队按席位。数据在你自己的工作区里。
</p>
</div>
{/* 右:表单 */}
<main className="flex flex-1 items-center justify-center bg-paper-100 px-5 py-12">
<div className="w-full max-w-sm">
<div className="mb-8 flex items-center gap-2.5 lg:hidden">
<Logo size={30} />
<div className="leading-tight">
<div className="text-sm font-semibold text-ink-900">达道Task</div>
<div className="text-2xs text-ink-400">任务 · 工单 · 知识</div>
</div>
</div>
<h2 className="text-2xl font-semibold">登录</h2>
<p className="mt-1.5 text-sm text-ink-500">手机号验证码登录,第一次登录会自动创建账号。</p>
<form className="mt-7 space-y-4" onSubmit={submit}>
<Field label="手机号">
<input
id="phone"
className="input"
className="input h-11"
inputMode="numeric"
autoComplete="tel"
placeholder="13800138000"
value={phone}
onChange={(e) => setPhone(e.target.value.trim())}
maxLength={11}
/>
</div>
</Field>
<div>
<label className="label" htmlFor="code">
验证码
</label>
<Field label="验证码">
<div className="flex gap-2">
<input
id="code"
className="input"
className="input h-11 tracking-[0.3em]"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="6 位数字"
value={code}
onChange={(e) => setCode(e.target.value.trim())}
maxLength={6}
/>
<button
<Button
type="button"
className="btn-ghost shrink-0"
variant="ghost"
className="h-11 shrink-0 px-3.5"
disabled={!phoneValid || busy || countdown > 0}
onClick={sendCode}
>
{countdown > 0 ? `${countdown}s` : "获取验证码"}
</button>
</Button>
</div>
</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>
</div>
</Layout>
</main>
</div>
);
}
-12
View File
@@ -1,12 +0,0 @@
import { Layout } from "../components/Layout";
export function PagePlaceholder({ title, hint }: { title: string; hint?: string }) {
return (
<Layout>
<div className="card">
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-2 text-sm text-ink-500">{hint ?? "M1 开发中"}</p>
</div>
</Layout>
);
}
+169 -127
View File
@@ -1,8 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { Layout } from "../components/Layout";
import { AppShell } from "../components/AppShell";
import { TaskDetail } from "../components/TaskDetail";
import { WorkspaceNav } from "../components/WorkspaceNav";
import { Check, Inbox, Plus, Search } from "../components/icons";
import {
Button,
EmptyState,
InlineAlert,
PageHeader,
PriorityDot,
SkeletonRows,
StatusPill,
} from "../components/ui";
import { ApiError, api } from "../lib/client";
import { PRIORITY_LABEL, STATUS_LABEL, parseQuickAdd } from "../lib/parse";
import type { Item, ItemStatus, Page } from "../lib/types";
@@ -15,15 +24,6 @@ const VIEWS: { key: string; label: string; hint: string; createAs: ItemStatus }[
{ key: "all", label: "全部", hint: "含已取消", createAs: "todo" },
];
const STATUS_STYLE: Record<string, string> = {
inbox: "bg-ink-100 text-ink-700",
todo: "bg-ink-100 text-ink-700",
doing: "bg-accent-500/15 text-accent-600",
done: "bg-emerald-100 text-emerald-700",
blocked: "bg-amber-100 text-amber-700",
canceled: "bg-ink-100 text-ink-500 line-through",
};
function dueLabel(iso: string | null): { text: string; overdue: boolean } | null {
if (!iso) return null;
const due = new Date(iso);
@@ -83,7 +83,8 @@ export function TasksPage() {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const typing = ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) || target.isContentEditable;
const typing =
["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) || target.isContentEditable;
if (e.key === "Escape") {
setOpenId(null);
(target as HTMLInputElement).blur?.();
@@ -144,43 +145,52 @@ export function TasksPage() {
}
return (
<Layout>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">任务</h1>
<p className="mt-0.5 text-xs text-ink-500">
{current?.hint}
<span className="ml-2 text-ink-100">|</span>
<span className="ml-2">j/k 选择 · Enter 完成 · e 编辑 · Esc 关闭</span>
</p>
</div>
<WorkspaceNav wsId={wsId} />
<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 h-9 w-56 pl-8 text-sm"
placeholder="搜索标题或正文…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
}
/>
<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="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>
))}
<input
className="input ml-auto w-48"
placeholder="搜索标题或正文…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<div className="card mb-4">
<div className="flex gap-2">
<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,103 +198,135 @@ 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>}
{error && (
<div className="mb-4">
<InlineAlert>{error}</InlineAlert>
</div>
)}
<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>
)}
<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";
// 收件箱里的东西还没"收下",不给完成勾选框——先归类,再完成。
const canComplete = item.status !== "inbox" && item.status !== "canceled";
return (
<div
key={item.id}
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 ? (
<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-0.5 h-[18px] w-[18px] shrink-0 rounded-full border border-dashed border-line-300"
aria-hidden
/>
)}
{items.map((item, index) => {
const due = dueLabel(item.due_at);
const done = item.status === "done";
// 收件箱里的东西还没"收下",不给完成勾选框——先归类,再完成。
const canComplete = item.status !== "inbox" && item.status !== "canceled";
return (
<div
key={item.id}
className={`flex items-start gap-3 px-4 py-3 ${
index === activeIdx ? "bg-ink-50/70" : ""
}`}
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")}
aria-label={done ? "标记未完成" : "标记完成"}
/>
) : (
<span className="mt-1 h-4 w-4 rounded border border-ink-100" aria-hidden />
)}
<div className="min-w-0 flex-1">
<button
className={`block w-full text-left text-sm ${done ? "text-ink-500 line-through" : ""}`}
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>
{item.priority > 0 && (
<span className="text-ink-500">优先级 {PRIORITY_LABEL[item.priority]}</span>
<div className="min-w-0 flex-1">
<button
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.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="chip">
<PriorityDot level={item.priority} />
优先级 {PRIORITY_LABEL[item.priority]}
</span>
)}
{due && (
<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="chip text-ink-500">
#{tag.name}
</span>
))}
</div>
</div>
<div className="flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{item.status === "inbox" && (
<Button variant="ghost" size="sm" onClick={() => void changeStatus(item, "todo")}>
收下
</Button>
)}
{due && (
<span className={due.overdue && !done ? "text-red-600" : "text-ink-500"}>
截止 {due.text}
{due.overdue && !done ? "(逾期)" : ""}
</span>
{(item.status === "todo" || item.status === "blocked") && (
<Button variant="ghost" size="sm" onClick={() => void changeStatus(item, "doing")}>
开工
</Button>
)}
{item.status === "doing" && (
<Button
variant="ghost"
size="sm"
onClick={() => void changeStatus(item, "blocked")}
>
阻塞
</Button>
)}
{item.tags.map((tag) => (
<span key={tag.id} className="rounded bg-ink-50 px-1.5 py-0.5 text-ink-700">
#{tag.name}
</span>
))}
</div>
</div>
<div className="flex shrink-0 gap-1">
{item.status === "inbox" && (
<button className="btn-ghost px-2 py-1 text-xs" onClick={() => void changeStatus(item, "todo")}>
收下
</button>
)}
{(item.status === "todo" || item.status === "blocked") && (
<button className="btn-ghost px-2 py-1 text-xs" onClick={() => void changeStatus(item, "doing")}>
开工
</button>
)}
{item.status === "doing" && (
<button className="btn-ghost px-2 py-1 text-xs" onClick={() => void changeStatus(item, "blocked")}>
阻塞
</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>
);
}
+134 -95
View File
@@ -1,16 +1,17 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { Layout } from "../components/Layout";
import { AppShell } from "../components/AppShell";
import { TaskDetail } from "../components/TaskDetail";
import { WorkspaceNav } from "../components/WorkspaceNav";
import { ChevronRight, TicketIcon } from "../components/icons";
import { Button, EmptyState, InlineAlert, PageHeader, SkeletonRows, StatusPill } from "../components/ui";
import { ApiError, api } from "../lib/client";
import { STATUS_LABEL } from "../lib/parse";
import type { Item, Ticket } from "../lib/types";
const ROLES = [
{ key: "assigned", label: "派给我的" },
{ key: "requested", label: "我发起的" },
{ key: "all", label: "全部" },
{ key: "assigned", label: "派给我的", desc: "别人等你的" },
{ key: "requested", label: "我发起的", desc: "你等别人的" },
{ key: "all", label: "全部", desc: "工作区所有工单" },
] as const;
const STATUS_FILTERS = [
@@ -31,7 +32,6 @@ export function TicketsPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
const [badge, setBadge] = useState(0);
const reqId = useRef(0);
const load = useCallback(
@@ -44,9 +44,11 @@ export function TicketsPage() {
const qs = new URLSearchParams({ role });
if (status) qs.set("status", status);
if (nextCursor) qs.set("cursor", nextCursor);
const page = await api.get<{ tickets: Ticket[]; next_cursor: string | null; has_more: boolean }>(
`/workspaces/${wsId}/tickets?${qs.toString()}`,
);
const page = await api.get<{
tickets: Ticket[];
next_cursor: string | null;
has_more: boolean;
}>(`/workspaces/${wsId}/tickets?${qs.toString()}`);
if (mine !== reqId.current) return;
setTickets((prev) => (nextCursor ? [...prev, ...page.tickets] : page.tickets));
setCursor(page.has_more ? page.next_cursor : null);
@@ -70,109 +72,149 @@ 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 : "操作失败");
}
}
const currentRole = ROLES.find((r) => r.key === role);
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>
<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"
}`}
<AppShell wsId={wsId} active="tickets">
<PageHeader
title="工单"
desc="待接 → 办理中 → 待验收 → 完成。验收通过后,当初卡住的那条任务会自动回到待办。"
eyebrow={<>需要别人搭把手的事,都在这里</>}
actions={
<select
className="input h-9 w-36 text-sm"
value={status}
onChange={(e) => setStatus(e.target.value)}
aria-label="按状态筛选"
>
{r.label}
</button>
))}
<select
className="input ml-auto w-36"
value={status}
onChange={(e) => setStatus(e.target.value)}
>
{STATUS_FILTERS.map((s) => (
<option key={s.key} value={s.key}>
{s.label}
</option>
))}
</select>
{STATUS_FILTERS.map((s) => (
<option key={s.key} value={s.key}>
{s.label}
</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>
)}
<ul className="space-y-2">
{tickets.map((t) => (
<li key={t.id} className="card">
<div className="flex items-start justify-between gap-3">
<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")}`
: ""}
</p>
</button>
<div className="flex shrink-0 gap-2 text-xs">
{t.status === "open" && (
<button className="btn-primary px-2 py-1" onClick={() => void quickAction(t, "accept")}>
接收
</button>
)}
{t.status === "accepted" && (
<button className="btn-primary px-2 py-1" onClick={() => void quickAction(t, "start")}>
开始
</button>
)}
{t.status === "pending_verify" && (
<button className="btn-primary px-2 py-1" onClick={() => void quickAction(t, "verify", { approve: true })}>
通过
</button>
)}
<button className="btn-ghost px-2 py-1" onClick={() => setOpenId(t.id)}>
详情
{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 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)}>
<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 items-center gap-1.5">
{t.status === "open" && (
<Button variant="primary" size="sm" onClick={() => void quickAction(t, "accept")}>
接收
</Button>
)}
{t.status === "accepted" && (
<Button variant="primary" size="sm" onClick={() => void quickAction(t, "start")}>
开始
</Button>
)}
{t.status === "pending_verify" && (
<Button
variant="primary"
size="sm"
onClick={() => void quickAction(t, "verify", { approve: true })}
>
通过
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => setOpenId(t.id)}>
详情
<ChevronRight size={13} />
</Button>
</div>
</div>
</div>
</li>
))}
</ul>
</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>
);
}
+157 -70
View File
@@ -1,7 +1,16 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useParams } from "react-router-dom";
import { useAuth } from "../App";
import { Layout } from "../components/Layout";
import { AppShell } from "../components/AppShell";
import { Check, Link2, Lock, Users } from "../components/icons";
import {
Button,
EmptyState,
InlineAlert,
PageHeader,
SkeletonRows,
useToast,
} from "../components/ui";
import { ApiError, api } from "../lib/client";
import type { Invitation, Member, Page, Workspace } from "../lib/types";
@@ -12,17 +21,38 @@ const ROLE_LABEL: Record<string, string> = {
guest: "访客",
};
const ROLE_HINT: Record<string, string> = {
owner: "全部权限,可发行管理员邀请",
admin: "可邀请成员、发布知识",
member: "建任务、提工单、写知识",
guest: "只读",
};
function maskPhone(phone: string): string {
return phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1 **** $2");
}
function initials(name: string): string {
const n = name.trim();
if (!n) return "?";
return /^[a-zA-Z]/.test(n) ? n.slice(0, 2).toUpperCase() : n.slice(-2);
}
export function WorkspacePage() {
const { wsId } = useParams<{ wsId: string }>();
const { wsId = "" } = useParams();
const { reload } = useAuth();
const { push: toast } = useToast();
const [ws, setWs] = useState<Workspace | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [invitation, setInvitation] = useState<Invitation | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const load = useCallback(async () => {
if (!wsId) return;
setLoading(true);
try {
const [detail, page] = await Promise.all([
api.get<Workspace>(`/workspaces/${wsId}`),
@@ -33,6 +63,8 @@ export function WorkspacePage() {
setError(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
} finally {
setLoading(false);
}
}, [wsId]);
@@ -46,6 +78,7 @@ export function WorkspacePage() {
setError(null);
try {
setInvitation(await api.post<Invitation>(`/workspaces/${wsId}/invitations`, { role }));
setCopied(false);
await load();
await reload();
} catch (err) {
@@ -56,92 +89,146 @@ export function WorkspacePage() {
}
const canInvite = ws?.role === "owner" || ws?.role === "admin";
const inviteLink = invitation
? `${window.location.origin}/join/${wsId}/${invitation.code}`
: "";
async function copyLink() {
try {
await navigator.clipboard.writeText(inviteLink);
setCopied(true);
toast("邀请链接已复制", "success");
window.setTimeout(() => setCopied(false), 2000);
} catch {
setError("浏览器不允许自动复制,请手动选中下面这行链接");
}
}
return (
<Layout>
<div className="mb-4 flex items-center gap-3 text-sm text-ink-500">
<Link to={`/w/${wsId}`} className="hover:text-ink-900">
← 任务
</Link>
{ws && <span className="rounded-full bg-ink-100 px-2 py-0.5 text-xs">{ROLE_LABEL[ws.role]}</span>}
</div>
<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 && <p className="mb-4 text-sm text-red-600">{error}</p>}
{error && (
<div className="mb-4">
<InlineAlert>{error}</InlineAlert>
</div>
)}
<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">
{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>
</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 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>
{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 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>
</li>
))}
</ul>
)}
</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")}
>
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>
<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>
) : (
<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>
</Button>
{ws?.role === "owner" && (
<button
className="btn-ghost flex-1"
<Button
variant="subtle"
className="w-full"
disabled={busy}
onClick={() => createInvite("admin")}
onClick={() => void createInvite("admin")}
>
邀请管理员
</button>
</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}`}
</p>
<button
className="btn-ghost mt-2 w-full"
onClick={() => {
void navigator.clipboard.writeText(
`${window.location.origin}/join/${wsId}/${invitation.code}`,
);
}}
>
复制邀请链接
</button>
</div>
)}
</div>
)
) : (
<p className="mt-2 text-xs text-ink-500">只有负责人和管理员能邀请成员。</p>
<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>
);
}