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