- {tab === "draft"
- ? "还没有待核对的知识。把工单验收通过,或者在这里「记一条」。"
- : "这里还空着。"}
+ {error && (
+
+ {error}
)}
-
- {docs.map((d) => (
- -
-
+ {loading && docs.length === 0 ? (
+
+
+
+ ) : docs.length === 0 ? (
+
+ }
+ title={
+ tab === "draft" ? "还没有待核对的知识" : "这里还空着"
+ }
+ hint={
+ tab === "draft"
+ ? "把一张工单验收通过,系统会自动整理一份草稿;或者在这里「记一条」。"
+ : "换个筛选条件看看,或者先记一条。"
+ }
+ />
+
+ ) : (
+
+ {docs.map((d) => (
+ -
-
-
- ))}
-
+
+
+ ))}
+
+ )}
{cursor && (
- void load(cursor)}>
+ void load(cursor)}>
{loading ? "加载中…" : "加载更多"}
-
+
)}
@@ -213,7 +279,7 @@ export function KnowledgePage() {
onChanged={() => void load(null)}
/>
)}
-
+
);
}
@@ -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 (
-
-
+ )}
+
);
}
diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx
index b692f66..639f444 100644
--- a/web/src/pages/Login.tsx
+++ b/web/src/pages/Login.tsx
@@ -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:
, title: "一件事,从头到尾", desc: "收下、开工、完成,状态一路可见" },
+ {
+ icon:
,
+ title: "卡住就变成工单",
+ desc: "原任务自动挂着等,办完再回来,不丢线",
+ },
+ {
+ icon:
,
+ title: "办完自动留档",
+ desc: "验收通过生成知识草稿,核对后进团队库",
+ },
+];
+
export function LoginPage() {
const { reload } = useAuth();
const navigate = useNavigate();
@@ -66,66 +81,124 @@ export function LoginPage() {
}
return (
-
-
-
-
登录 / 注册
-
手机号验证码登录,未注册会自动创建账号。
+
+ {/* 左:品牌页 */}
+
+
+
+
-
+
+
+ 登录即表示同意《服务条款》与《隐私政策》。我们不会把你的内容用于训练模型。
+
-
-
+
+
);
}
diff --git a/web/src/pages/Placeholder.tsx b/web/src/pages/Placeholder.tsx
deleted file mode 100644
index d182fd1..0000000
--- a/web/src/pages/Placeholder.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { Layout } from "../components/Layout";
-
-export function PagePlaceholder({ title, hint }: { title: string; hint?: string }) {
- return (
-
-
-
{title}
-
{hint ?? "M1 开发中"}
-
-
- );
-}
diff --git a/web/src/pages/Tasks.tsx b/web/src/pages/Tasks.tsx
index 5d5e9ca..e60a666 100644
--- a/web/src/pages/Tasks.tsx
+++ b/web/src/pages/Tasks.tsx
@@ -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
= {
- 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 (
-
-
-
-
任务
-
- {current?.hint}
- |
- j/k 选择 · Enter 完成 · e 编辑 · Esc 关闭
-
-
-
+
+ 按 j / k 移动,Enter 完成,e 打开详情>}
+ actions={
+
+
+
+
+ setQuery(e.target.value)}
+ />
+
+ }
+ />
+
+
+ {VIEWS.map((v) => {
+ const active = view === v.key;
+ return (
+ 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}
+
+ );
+ })}
-
- {VIEWS.map((v) => (
- 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}
-
- ))}
- setQuery(e.target.value)}
- />
-
-
-
-
+
- {error &&
{error}
}
+ {error && (
+
+ {error}
+
+ )}
-
- {items.length === 0 && !loading && (
-
- {view === "today" ? "今天没有到期的事,挺好。" : "这里还空着。"}
-
- )}
+
+ {loading && items.length === 0 ? (
+
+ ) : items.length === 0 ? (
+
}
+ 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 (
+
setActiveIdx(index)}
+ >
+ {canComplete ? (
+
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"
+ }`}
+ >
+
+
+ ) : (
+
+ )}
- {items.map((item, index) => {
- const due = dueLabel(item.due_at);
- const done = item.status === "done";
- // 收件箱里的东西还没"收下",不给完成勾选框——先归类,再完成。
- const canComplete = item.status !== "inbox" && item.status !== "canceled";
- return (
-
setActiveIdx(index)}
- >
- {canComplete ? (
-
void changeStatus(item, done ? "todo" : "done")}
- aria-label={done ? "标记未完成" : "标记完成"}
- />
- ) : (
-
- )}
-
-
setOpenId(item.id)}
- >
- {item.title}
-
-
-
- {STATUS_LABEL[item.status]}
-
- {item.priority > 0 && (
-
优先级 {PRIORITY_LABEL[item.priority]}
+
+
setOpenId(item.id)}
+ >
+ {item.title}
+
+
+
+ {item.priority > 0 && (
+
+
+ 优先级 {PRIORITY_LABEL[item.priority]}
+
+ )}
+ {due && (
+
+ 截止 {due.text}
+ {due.overdue && !done ? "(逾期)" : ""}
+
+ )}
+ {item.tags.map((tag) => (
+
+ #{tag.name}
+
+ ))}
+
+
+
+
+ {item.status === "inbox" && (
+ void changeStatus(item, "todo")}>
+ 收下
+
)}
- {due && (
-
- 截止 {due.text}
- {due.overdue && !done ? "(逾期)" : ""}
-
+ {(item.status === "todo" || item.status === "blocked") && (
+ void changeStatus(item, "doing")}>
+ 开工
+
+ )}
+ {item.status === "doing" && (
+ void changeStatus(item, "blocked")}
+ >
+ 阻塞
+
)}
- {item.tags.map((tag) => (
-
- #{tag.name}
-
- ))}
-
-
- {item.status === "inbox" && (
- void changeStatus(item, "todo")}>
- 收下
-
- )}
- {(item.status === "todo" || item.status === "blocked") && (
- void changeStatus(item, "doing")}>
- 开工
-
- )}
- {item.status === "doing" && (
- void changeStatus(item, "blocked")}>
- 阻塞
-
- )}
-
-
- );
- })}
+ );
+ })
+ )}
{hasMore && (
- void load(cursor)}>
+ void load(cursor)}>
{loading ? "加载中…" : "加载更多"}
-
+
)}
- {loading && items.length === 0 &&
加载中…
}
{openId && wsId && (
setItems((prev) => prev.filter((it) => it.id !== id))}
/>
)}
-
+
);
}
diff --git a/web/src/pages/Tickets.tsx b/web/src/pages/Tickets.tsx
index 1c3d5a2..ae775f1 100644
--- a/web/src/pages/Tickets.tsx
+++ b/web/src/pages/Tickets.tsx
@@ -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(null);
const [openId, setOpenId] = useState(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 (
-
-
-
-
工单
-
- 待接 → 办理中 → 待验收 → 完成;验收通过后,当初卡住的那条任务会自动回到待办
-
-
-
-
-
-
- {ROLES.map((r) => (
-
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"
- }`}
+
+ 需要别人搭把手的事,都在这里>}
+ actions={
+
- ))}
-
+ {STATUS_FILTERS.map((s) => (
+
+ ))}
+
+ }
+ />
+
+
+ {ROLES.map((r) => {
+ const active = role === r.key;
+ return (
+ 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}
+
+ );
+ })}
- {error &&
{error}
}
-
- {tickets.length === 0 && !loading && (
-
- 这里还空着。任务卡在别人手里时,在任务详情里点「升级成工单」就会出现在这儿。
+ {error && (
+
+ {error}
)}
-
- {tickets.map((t) => (
- -
-
-
setOpenId(t.id)}>
- {t.title}
-
- {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")}`
- : ""}
-
-
-
- {t.status === "open" && (
-
void quickAction(t, "accept")}>
- 接收
-
- )}
- {t.status === "accepted" && (
-
void quickAction(t, "start")}>
- 开始
-
- )}
- {t.status === "pending_verify" && (
-
void quickAction(t, "verify", { approve: true })}>
- 通过
-
- )}
-
setOpenId(t.id)}>
- 详情
+ {loading && tickets.length === 0 ? (
+
+
+
+ ) : tickets.length === 0 ? (
+
+ }
+ title={role === "assigned" ? "没有等你的工单" : "这里还空着"}
+ hint={
+ role === "assigned"
+ ? "别人把事交给你办时,这里会出现;要主动找人帮忙,去任务详情点「升级成工单」。"
+ : currentRole?.desc
+ }
+ />
+
+ ) : (
+
+ {tickets.map((t) => (
+ -
+
+
setOpenId(t.id)}>
+
+
+ {t.ticket.reject_count > 0 && (
+
+ 退回 {t.ticket.reject_count} 次
+
+ )}
+
+
+ {t.title}
+
+
+ 更新 {new Date(t.updated_at).toLocaleString("zh-CN")}
+ {t.due_at ? (
+ 截止 {new Date(t.due_at).toLocaleDateString("zh-CN")}
+ ) : null}
+ {t.tags.map((tag) => (
+ #{tag.name}
+ ))}
+
+
+
+ {t.status === "open" && (
+ void quickAction(t, "accept")}>
+ 接收
+
+ )}
+ {t.status === "accepted" && (
+ void quickAction(t, "start")}>
+ 开始
+
+ )}
+ {t.status === "pending_verify" && (
+ void quickAction(t, "verify", { approve: true })}
+ >
+ 通过
+
+ )}
+ setOpenId(t.id)}>
+ 详情
+
+
+
-
-
- ))}
-
+
+ ))}
+
+ )}
{cursor && (
- void load(cursor)}>
+ void load(cursor)}>
{loading ? "加载中…" : "加载更多"}
-
+
)}
@@ -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);
}}
/>
)}
-
+
);
}
diff --git a/web/src/pages/Workspace.tsx b/web/src/pages/Workspace.tsx
index 6d67ae9..dcae29a 100644
--- a/web/src/pages/Workspace.tsx
+++ b/web/src/pages/Workspace.tsx
@@ -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
= {
guest: "访客",
};
+const ROLE_HINT: Record = {
+ 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(null);
const [members, setMembers] = useState([]);
const [invitation, setInvitation] = useState(null);
const [error, setError] = useState(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(`/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(`/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 (
-
-
-
- ← 任务
-
- {ws && {ROLE_LABEL[ws.role]}}
-
+
+ 谁在这个工作区里,各自能做什么>}
+ />
- {error && {error}
}
+ {error && (
+
+ {error}
+
+ )}
-
-
- {ws?.name ?? "加载中…"}
-
- {ws ? `${ws.slug} · ${ws.plan === "free" ? "免费版" : ws.plan}` : ""}
-
-
- 成员({members.length})
-
-
-
-
下一步(M1)
-
- 任务列表、看板、工单升级、知识沉淀会依次落在这里。骨架先把身份、租户、成员跑通。
-
+
+
+
+
+
+ 成员
+ {members.length}
+
+ 席位按成员计费,访客不计
+
+ {loading && members.length === 0 ? (
+
+ ) : members.length === 0 ? (
+ } title="还没有成员" hint="生成一个邀请码,发给同事。" />
+ ) : (
+
+ {members.map((m) => (
+ -
+
+ {initials(m.nickname || m.phone)}
+
+
+
+ {m.nickname || "未命名"}
+
+
{maskPhone(m.phone)}
+
+
+
+ {ROLE_LABEL[m.role] ?? m.role}
+
+
{ROLE_HINT[m.role] ?? ""}
+
+
+ ))}
+
+ )}