This commit is contained in:
@@ -106,6 +106,13 @@ export const Search = (p: IconProps) => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const Refresh = (p: IconProps) => (
|
||||||
|
<svg {...base(p)}>
|
||||||
|
<path d="M20 12a8 8 0 1 1-2.6-5.9" />
|
||||||
|
<path d="M20 4v4h-4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export const Plus = (p: IconProps) => (
|
export const Plus = (p: IconProps) => (
|
||||||
<svg {...base(p)}>
|
<svg {...base(p)}>
|
||||||
<path d="M12 5v14M5 12h14" />
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
|||||||
+288
-5
@@ -1,10 +1,24 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useAuth } from "../App";
|
||||||
import { AppShell } from "../components/AppShell";
|
import { AppShell } from "../components/AppShell";
|
||||||
import { AttachmentPanel } from "../components/AttachmentPanel";
|
import { AttachmentPanel } from "../components/AttachmentPanel";
|
||||||
import { Markdown } from "../components/Markdown";
|
import { Markdown } from "../components/Markdown";
|
||||||
import { RichTextEditor } from "../components/RichTextEditor";
|
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 {
|
import {
|
||||||
Button,
|
Button,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
@@ -13,6 +27,7 @@ import {
|
|||||||
Sheet,
|
Sheet,
|
||||||
SkeletonRows,
|
SkeletonRows,
|
||||||
StatusPill,
|
StatusPill,
|
||||||
|
useToast,
|
||||||
} from "../components/ui";
|
} from "../components/ui";
|
||||||
import { ApiError, api } from "../lib/client";
|
import { ApiError, api } from "../lib/client";
|
||||||
import {
|
import {
|
||||||
@@ -81,6 +96,11 @@ export function KnowledgePage() {
|
|||||||
const [tags, setTags] = useState<KnowledgeTagCount[]>([]);
|
const [tags, setTags] = useState<KnowledgeTagCount[]>([]);
|
||||||
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
||||||
const [catBusy, setCatBusy] = useState(false);
|
const [catBusy, setCatBusy] = useState(false);
|
||||||
|
/** 批量移动:先进入选择模式,再挑目标工作区/目录 */
|
||||||
|
const [selecting, setSelecting] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [moving, setMoving] = useState(false);
|
||||||
|
const { push: toast } = useToast();
|
||||||
const reqId = useRef(0);
|
const reqId = useRef(0);
|
||||||
|
|
||||||
const loadCategories = useCallback(async () => {
|
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 pendingCount = docs.filter((d) => d.knowledge.status_hint !== "published").length;
|
||||||
const activeCategory = categories.find((c) => c.id === catFilter) ?? null;
|
const activeCategory = categories.find((c) => c.id === catFilter) ?? null;
|
||||||
|
|
||||||
@@ -366,17 +431,64 @@ export function KnowledgePage() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<span className="ml-auto inline-flex items-center gap-1.5 text-2xs text-ink-500">
|
<span className="inline-flex items-center gap-1.5 text-2xs text-ink-500">
|
||||||
<Spinner size={12} />
|
<Spinner size={12} />
|
||||||
载入中…
|
载入中…
|
||||||
</span>
|
</span>
|
||||||
) : tab === "draft" && pendingCount > 0 ? (
|
) : tab === "draft" && pendingCount > 0 ? (
|
||||||
<span className="ml-auto hidden text-2xs text-ink-400 sm:block">
|
<span className="hidden text-2xs text-ink-400 sm:block">
|
||||||
{pendingCount} 份草稿等你核对
|
{pendingCount} 份草稿等你核对
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={loading || searching}
|
||||||
|
onClick={() => void refresh()}
|
||||||
|
title="重新加载列表、分类和标签"
|
||||||
|
>
|
||||||
|
<Refresh size={13} />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={selecting ? "subtle" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => (selecting ? cancelSelect() : startSelect())}
|
||||||
|
title="选中多篇知识,一起移动到目录或其它工作区"
|
||||||
|
>
|
||||||
|
<Check size={13} />
|
||||||
|
{selecting ? "退出批量" : "批量移动"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selecting && hits === null ? (
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-xl border border-primary-200 bg-primary-50/60 px-3 py-2">
|
||||||
|
<span className="text-xs font-medium text-primary-800">
|
||||||
|
已选 {selected.size} / {docs.length} 篇
|
||||||
|
</span>
|
||||||
|
<button className="link text-2xs" onClick={toggleSelectAll}>
|
||||||
|
{selected.size === docs.length && docs.length > 0 ? "取消全选" : "全选列表"}
|
||||||
|
</button>
|
||||||
|
<span className="text-2xs text-ink-400">点卡片选中或取消</span>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={cancelSelect}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
disabled={selected.size === 0}
|
||||||
|
onClick={() => setMoving(true)}
|
||||||
|
>
|
||||||
|
<ArrowRight size={13} />
|
||||||
|
移动到…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
@@ -483,9 +595,30 @@ export function KnowledgePage() {
|
|||||||
? { borderTopWidth: 3, borderTopColor: categoryColor(d.category) }
|
? { borderTopWidth: 3, borderTopColor: categoryColor(d.category) }
|
||||||
: undefined
|
: 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" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selecting ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={selected.has(d.id)}
|
||||||
|
aria-label={`选择 ${d.title}`}
|
||||||
|
onClick={() => toggleSelect(d.id)}
|
||||||
|
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border transition ${
|
||||||
|
selected.has(d.id)
|
||||||
|
? "border-primary-600 bg-primary-600 text-white"
|
||||||
|
: "border-line-300 bg-white text-transparent hover:border-primary-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Check size={12} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="min-w-0 flex-1 text-left"
|
||||||
|
onClick={() => (selecting ? toggleSelect(d.id) : openDoc(d.id))}
|
||||||
>
|
>
|
||||||
<button className="min-w-0 flex-1 text-left" onClick={() => openDoc(d.id)}>
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="chip bg-primary-50 text-primary-700">
|
<span className="chip bg-primary-50 text-primary-700">
|
||||||
{DOC_TYPE_LABEL[d.knowledge.doc_type] ?? d.knowledge.doc_type}
|
{DOC_TYPE_LABEL[d.knowledge.doc_type] ?? d.knowledge.doc_type}
|
||||||
@@ -535,6 +668,7 @@ export function KnowledgePage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
|
{selecting ? null : (
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<Button variant="ghost" size="sm" onClick={() => openDoc(d.id)}>
|
<Button variant="ghost" size="sm" onClick={() => openDoc(d.id)}>
|
||||||
打开
|
打开
|
||||||
@@ -549,6 +683,7 @@ export function KnowledgePage() {
|
|||||||
<Trash size={14} />
|
<Trash size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -575,10 +710,158 @@ export function KnowledgePage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{moving && (
|
||||||
|
<MoveKnowledgeSheet
|
||||||
|
wsId={wsId}
|
||||||
|
itemIds={[...selected]}
|
||||||
|
onClose={() => setMoving(false)}
|
||||||
|
onMoved={onMoved}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量移动:选目标工作区 + 目标目录。
|
||||||
|
* 跨工作区时标签、评论、附件一起搬走;工单来源记成手动创建,免得目标库里挂一条看不见的工单。
|
||||||
|
*/
|
||||||
|
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<KnowledgeCategoryNode[]>([]);
|
||||||
|
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 (
|
||||||
|
<Sheet
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
size="md"
|
||||||
|
title="移动知识"
|
||||||
|
subtitle={`共 ${itemIds.length} 篇`}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => void submit()}
|
||||||
|
disabled={busy || loading || itemIds.length === 0}
|
||||||
|
>
|
||||||
|
{busy ? "移动中…" : "确认移动"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{error ? <InlineAlert>{error}</InlineAlert> : null}
|
||||||
|
<label className="block">
|
||||||
|
<span className="label">目标工作区</span>
|
||||||
|
<select
|
||||||
|
className="input mt-1 w-full"
|
||||||
|
value={targetWs}
|
||||||
|
onChange={(e) => setTargetWs(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{targets.map((w) => (
|
||||||
|
<option key={w.id} value={w.id}>
|
||||||
|
{w.name}
|
||||||
|
{w.id === wsId ? "(当前)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="label">目标目录</span>
|
||||||
|
<select
|
||||||
|
className="input mt-1 w-full"
|
||||||
|
value={catId}
|
||||||
|
onChange={(e) => setCatId(e.target.value)}
|
||||||
|
disabled={busy || loading}
|
||||||
|
>
|
||||||
|
<option value="">未分类</option>
|
||||||
|
{cats.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{"\u3000".repeat(Math.max(0, c.depth - 1))}
|
||||||
|
{c.full_path || c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{targetWs !== wsId ? (
|
||||||
|
<InlineAlert tone="info">
|
||||||
|
会连同标签、评论、附件一起搬到目标工作区;原工单来源会记为手动创建,原工作区的通知与关联不再保留。
|
||||||
|
</InlineAlert>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** 分类色块:选中的那个带描边和钩,不用猜“现在到底是哪个色” */
|
/** 分类色块:选中的那个带描边和钩,不用猜“现在到底是哪个色” */
|
||||||
function ColorSwatch({
|
function ColorSwatch({
|
||||||
value,
|
value,
|
||||||
|
|||||||
Reference in New Issue
Block a user