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