From 28eab4ae5f3a881884a38d8c3f42f6e02a8fc81b Mon Sep 17 00:00:00 2001 From: Steven Date: Sat, 7 Nov 2026 01:50:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(knowledge):=20=E7=9F=A5=E8=AF=86=E9=A1=B5?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=88=B7=E6=96=B0=E5=88=97=E8=A1=A8=E4=B8=8E?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E7=A7=BB=E5=8A=A8=E7=9F=A5=E8=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/icons.tsx | 7 + web/src/pages/Knowledge.tsx | 337 ++++++++++++++++++++++++++++++++--- 2 files changed, 317 insertions(+), 27 deletions(-) diff --git a/web/src/components/icons.tsx b/web/src/components/icons.tsx index c233b6c..090800b 100644 --- a/web/src/components/icons.tsx +++ b/web/src/components/icons.tsx @@ -106,6 +106,13 @@ export const Search = (p: IconProps) => ( ); +export const Refresh = (p: IconProps) => ( + + + + +); + export const Plus = (p: IconProps) => ( diff --git a/web/src/pages/Knowledge.tsx b/web/src/pages/Knowledge.tsx index c62ab52..1dff981 100644 --- a/web/src/pages/Knowledge.tsx +++ b/web/src/pages/Knowledge.tsx @@ -1,10 +1,24 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import { useAuth } from "../App"; import { AppShell } from "../components/AppShell"; import { AttachmentPanel } from "../components/AttachmentPanel"; import { Markdown } from "../components/Markdown"; import { RichTextEditor } from "../components/RichTextEditor"; -import { Check, Link2, More, Paperclip, Pencil, Plus, Search, Sparkle, Spinner, Trash } from "../components/icons"; +import { + ArrowRight, + Check, + Link2, + More, + Paperclip, + Pencil, + Plus, + Refresh, + Search, + Sparkle, + Spinner, + Trash, +} from "../components/icons"; import { Button, EmptyState, @@ -13,6 +27,7 @@ import { Sheet, SkeletonRows, StatusPill, + useToast, } from "../components/ui"; import { ApiError, api } from "../lib/client"; import { @@ -81,6 +96,11 @@ export function KnowledgePage() { const [tags, setTags] = useState([]); const [tagFilter, setTagFilter] = useState(null); const [catBusy, setCatBusy] = useState(false); + /** 批量移动:先进入选择模式,再挑目标工作区/目录 */ + const [selecting, setSelecting] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [moving, setMoving] = useState(false); + const { push: toast } = useToast(); const reqId = useRef(0); const loadCategories = useCallback(async () => { @@ -286,6 +306,51 @@ export function KnowledgePage() { } } + /** 刷新:列表、分类、标签一起重拉;检索态下重跑检索 */ + async function refresh() { + if (hits !== null) await runSearch(); + else await load(null); + await Promise.all([loadCategories(), loadTags()]); + } + + function startSelect() { + setHits(null); + setSelecting(true); + setSelected(new Set()); + } + + function cancelSelect() { + setSelecting(false); + setSelected(new Set()); + } + + function toggleSelect(id: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + function toggleSelectAll() { + setSelected((prev) => + prev.size === docs.length && docs.length > 0 ? new Set() : new Set(docs.map((d) => d.id)), + ); + } + + function onMoved(result: { moved: string[]; workspace_id: string; category_id: string | null }) { + setMoving(false); + cancelSelect(); + void load(null); + void loadCategories(); + void loadTags(); + toast( + `已移动 ${result.moved.length} 篇知识到${result.workspace_id === wsId ? "当前工作区" : "另一个工作区"}`, + "success", + ); + } + const pendingCount = docs.filter((d) => d.knowledge.status_hint !== "published").length; const activeCategory = categories.find((c) => c.id === catFilter) ?? null; @@ -366,18 +431,65 @@ export function KnowledgePage() { ); })} - {loading ? ( - - - 载入中… - - ) : tab === "draft" && pendingCount > 0 ? ( - - {pendingCount} 份草稿等你核对 - - ) : null} +
+ {loading ? ( + + + 载入中… + + ) : tab === "draft" && pendingCount > 0 ? ( + + {pendingCount} 份草稿等你核对 + + ) : null} + + +
+ {selecting && hits === null ? ( +
+ + 已选 {selected.size} / {docs.length} 篇 + + + 点卡片选中或取消 +
+ + +
+
+ ) : null} + {error && (
{error} @@ -483,9 +595,30 @@ export function KnowledgePage() { ? { borderTopWidth: 3, borderTopColor: categoryColor(d.category) } : undefined } - className="card group flex items-start justify-between gap-3 p-4 transition hover:border-line-300 hover:shadow-pop" + className={`card group flex items-start justify-between gap-3 p-4 transition hover:border-line-300 hover:shadow-pop ${ + selecting && selected.has(d.id) ? "ring-2 ring-primary-300" : "" + }`} > - + ) : null} + -
- - {/* 删除只在悬停/聚焦时露出来:这一列动作不该比标题更抢眼 */} - -
+ {selecting ? null : ( +
+ + {/* 删除只在悬停/聚焦时露出来:这一列动作不该比标题更抢眼 */} + +
+ )} ))} @@ -575,10 +710,158 @@ export function KnowledgePage() { /> )} + {moving && ( + setMoving(false)} + onMoved={onMoved} + /> + )} + ); } +/** + * 批量移动:选目标工作区 + 目标目录。 + * 跨工作区时标签、评论、附件一起搬走;工单来源记成手动创建,免得目标库里挂一条看不见的工单。 + */ +function MoveKnowledgeSheet({ + wsId, + itemIds, + onClose, + onMoved, +}: { + wsId: string; + itemIds: string[]; + onClose: () => void; + onMoved: (result: { moved: string[]; workspace_id: string; category_id: string | null }) => void; +}) { + const { workspaces } = useAuth(); + const [targetWs, setTargetWs] = useState(wsId); + const [cats, setCats] = useState([]); + const [catId, setCatId] = useState(""); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + // 当前工作区排第一:多数时候只是整理目录,不该先让人跨区找自己 + const targets = useMemo(() => { + const current = workspaces.find((w) => w.id === wsId); + const rest = workspaces.filter((w) => w.id !== wsId && w.role !== "guest"); + return current ? [current, ...rest] : rest; + }, [workspaces, wsId]); + + useEffect(() => { + let alive = true; + setLoading(true); + setCatId(""); + api + .get<{ items: KnowledgeCategoryNode[] }>(`/workspaces/${targetWs}/knowledge/categories`) + .then((data) => { + if (alive) setCats(data.items); + }) + .catch((e: unknown) => { + if (alive) setError(e instanceof ApiError ? e.message : "分类载入失败"); + }) + .finally(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [targetWs]); + + async function submit() { + if (busy || itemIds.length === 0) return; + setBusy(true); + setError(""); + try { + const res = await api.post<{ + moved: string[]; + workspace_id: string; + category_id: string | null; + }>(`/workspaces/${wsId}/knowledge/bulk-move`, { + item_ids: itemIds, + target_workspace_id: targetWs === wsId ? null : targetWs, + category_id: catId || null, + }); + onMoved(res); + } catch (e: unknown) { + setError(e instanceof ApiError ? e.message : "移动失败,请重试"); + } finally { + setBusy(false); + } + } + + return ( + + + + + } + > +
+ {error ? {error} : null} + + + {targetWs !== wsId ? ( + + 会连同标签、评论、附件一起搬到目标工作区;原工单来源会记为手动创建,原工作区的通知与关联不再保留。 + + ) : null} +
+
+ ); +} + /** 分类色块:选中的那个带描边和钩,不用猜“现在到底是哪个色” */ function ColorSwatch({ value,