用户发来 https://mp.weixin.qq.com/s/ZEOsi4R4JcmZ_mhS6qpcww,存进库的正文只有 67 个字符 ("原文链接 + 一个句号")。复盘发现公众号换了渲染方式:新版页面把整篇塞进 window.cgiDataNew 这个 JS 对象由前端渲染,页面里根本没有 id="js_content"; 三种 UA 拿到的字节数完全一样,也不是 UA 的问题。 - 从 JS 字符串字面量里读正文(content_noencode / desc),按 \xNN、\uNNNN 反转义, 话题标签是双重转义过的 HTML,先 unescape 再走 DOM。 - 配图字段排在文字之后,所以前后各找 2500 字符合图;剩下没用过的 mmbiz 图按 12 张上限补在末尾(图文型文章靠就近配对会漏)。 - 解析顺序:旧 DOM → 新版 JS 数据 → 通用抽取,一层层退。 实测两篇:2373 字符 + 12 张图、2772 字符 + 13 张图,作者也都取到了。 同时补上用户提的两件: - 知识能删了:DELETE /knowledge/{id}?confirm=true,软删 + 真删检索切片, 只有作者或 owner/admin 能删;列表卡片(悬停出现)和阅读页动作行各一个入口。 删完详情也回 7001,否则收藏的链接还能打开一篇已删除的知识。 - 分类下面有标签清单了:GET /knowledge/tags 按标签数"有多少条知识用了它", 侧栏渲染成 #标签 + 条数,点一下筛选、再点取消。
This commit is contained in:
+107
-5
@@ -28,6 +28,7 @@ import type {
|
||||
Attachment,
|
||||
Knowledge,
|
||||
KnowledgeCategoryNode,
|
||||
KnowledgeTagCount,
|
||||
SearchHit,
|
||||
} from "../lib/types";
|
||||
|
||||
@@ -76,6 +77,9 @@ export function KnowledgePage() {
|
||||
/** null = 不按分类筛(全部);"none" = 未分类;其它是分类 id(含子分类) */
|
||||
const [catFilter, setCatFilter] = useState<string | null>(null);
|
||||
const [categories, setCategories] = useState<KnowledgeCategoryNode[]>([]);
|
||||
/** 分类下面的标签清单:知识库就靠分类 + 标签两条轴找东西 */
|
||||
const [tags, setTags] = useState<KnowledgeTagCount[]>([]);
|
||||
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
||||
const [catBusy, setCatBusy] = useState(false);
|
||||
const reqId = useRef(0);
|
||||
|
||||
@@ -90,10 +94,28 @@ export function KnowledgePage() {
|
||||
}
|
||||
}, [wsId]);
|
||||
|
||||
const loadTags = useCallback(async () => {
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
for (const s of TABS.find((t) => t.key === tab)!.statuses) qs.append("status", s);
|
||||
const page = await api.get<{ items: KnowledgeTagCount[] }>(
|
||||
`/workspaces/${wsId}/knowledge/tags?${qs.toString()}`,
|
||||
);
|
||||
setTags(page.items);
|
||||
} catch {
|
||||
// 标签只是辅助导航,取不到不该挡住列表
|
||||
setTags([]);
|
||||
}
|
||||
}, [wsId, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCategories();
|
||||
}, [loadCategories]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTags();
|
||||
}, [loadTags]);
|
||||
|
||||
async function createCategory(name: string, parentId: string | null, color: string | null = null) {
|
||||
setCatBusy(true);
|
||||
setError(null);
|
||||
@@ -186,6 +208,7 @@ export function KnowledgePage() {
|
||||
if (q.trim()) qs.set("q", q.trim());
|
||||
if (catFilter === "none") qs.set("uncategorized", "true");
|
||||
else if (catFilter) qs.set("category_id", catFilter);
|
||||
if (tagFilter) qs.set("tag", tagFilter);
|
||||
if (nextCursor) qs.set("cursor", nextCursor);
|
||||
const page = await api.get<{
|
||||
items: Knowledge[];
|
||||
@@ -202,7 +225,7 @@ export function KnowledgePage() {
|
||||
if (mine === reqId.current) setLoading(false);
|
||||
}
|
||||
},
|
||||
[wsId, tab, q, catFilter],
|
||||
[wsId, tab, q, catFilter, tagFilter],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -239,11 +262,30 @@ export function KnowledgePage() {
|
||||
setComposing(false);
|
||||
setTab("draft");
|
||||
setCatFilter(null);
|
||||
setTagFilter(null);
|
||||
openDoc(doc.id);
|
||||
void load(null);
|
||||
void loadCategories();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除是软删(后端 `deleted_at`),但界面上一旦删了就当它不在了:
|
||||
* 所以列表重拉、标签计数重算,别留一条需要刷新才消失的幽灵。
|
||||
*/
|
||||
async function removeDoc(doc: Knowledge) {
|
||||
if (!window.confirm(`删除「${doc.title}」?删了不会回来,检索里也会一起消失。`)) return;
|
||||
setError(null);
|
||||
try {
|
||||
await api.del(`/workspaces/${wsId}/knowledge/${doc.id}?confirm=true`);
|
||||
setDocs((prev) => prev.filter((d) => d.id !== doc.id));
|
||||
void load(null);
|
||||
void loadCategories();
|
||||
void loadTags();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
const pendingCount = docs.filter((d) => d.knowledge.status_hint !== "published").length;
|
||||
const activeCategory = categories.find((c) => c.id === catFilter) ?? null;
|
||||
|
||||
@@ -291,6 +333,13 @@ export function KnowledgePage() {
|
||||
loading={loading}
|
||||
onSelect={(value) => {
|
||||
setCatFilter(value);
|
||||
setTagFilter(null);
|
||||
setHits(null);
|
||||
}}
|
||||
tags={tags}
|
||||
tagSelected={tagFilter}
|
||||
onSelectTag={(name) => {
|
||||
setTagFilter((prev) => (prev === name ? null : name));
|
||||
setHits(null);
|
||||
}}
|
||||
busy={catBusy}
|
||||
@@ -486,9 +535,20 @@ export function KnowledgePage() {
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="shrink-0" onClick={() => openDoc(d.id)}>
|
||||
打开
|
||||
</Button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openDoc(d.id)}>
|
||||
打开
|
||||
</Button>
|
||||
{/* 删除只在悬停/聚焦时露出来:这一列动作不该比标题更抢眼 */}
|
||||
<button
|
||||
className="rounded-md p-1.5 text-ink-400 opacity-0 transition hover:bg-red-50 hover:text-red-600 focus:opacity-100 group-hover:opacity-100"
|
||||
title="删除这条知识"
|
||||
aria-label={`删除 ${d.title}`}
|
||||
onClick={() => void removeDoc(d)}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -548,12 +608,15 @@ function ColorSwatch({
|
||||
);
|
||||
}
|
||||
|
||||
/** 左侧分类树:一层一层缩进,点一下就把列表收窄到该分类(含子分类)。 */
|
||||
/** 左侧分类树:一层一层缩进,点一下就把列表收窄到该分类(含子分类);分类下面是标签。 */
|
||||
function CategorySidebar({
|
||||
categories,
|
||||
selected,
|
||||
loading,
|
||||
onSelect,
|
||||
tags,
|
||||
tagSelected,
|
||||
onSelectTag,
|
||||
busy,
|
||||
onCreate,
|
||||
onRename,
|
||||
@@ -564,6 +627,9 @@ function CategorySidebar({
|
||||
selected: string | null;
|
||||
loading: boolean;
|
||||
onSelect: (value: string | null) => void;
|
||||
tags: KnowledgeTagCount[];
|
||||
tagSelected: string | null;
|
||||
onSelectTag: (name: string) => void;
|
||||
busy: boolean;
|
||||
onCreate: (name: string, parentId: string | null, color: string | null) => void;
|
||||
onRename: (cat: KnowledgeCategoryNode, name: string) => void;
|
||||
@@ -686,6 +752,42 @@ function CategorySidebar({
|
||||
还没有分类。先建一层「研发 / 部署」这样的目录,知识才有地方落脚。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* 标签是第二根轴:分类回答"这属于哪一块",标签回答"它跟什么有关"。
|
||||
按条数从多到少排,点一下切换筛选(再点一次取消)。 */}
|
||||
{tags.length > 0 ? (
|
||||
<section className="mt-4 border-t border-line-200 pt-3">
|
||||
<div className="flex items-center justify-between px-1 pb-1">
|
||||
<span className="text-2xs font-medium uppercase tracking-wide text-ink-400">标签</span>
|
||||
{tagSelected ? (
|
||||
<button className="link text-2xs" onClick={() => onSelectTag(tagSelected)}>
|
||||
清除
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 px-1">
|
||||
{tags.map((t) => {
|
||||
const active = tagSelected === t.name;
|
||||
return (
|
||||
<button
|
||||
key={t.name}
|
||||
aria-pressed={active}
|
||||
onClick={() => onSelectTag(t.name)}
|
||||
className={`inline-flex max-w-full items-center gap-1 rounded-full px-2 py-0.5 text-2xs transition-colors ${
|
||||
active
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-primary-50 text-primary-700 hover:bg-primary-100"
|
||||
}`}
|
||||
title={`${t.count} 条知识用了这个标签`}
|
||||
>
|
||||
<span className="truncate">#{t.name}</span>
|
||||
<span className={active ? "text-white/70" : "text-primary-400"}>{t.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { AttachmentPanel } from "../components/AttachmentPanel";
|
||||
import { Markdown } from "../components/Markdown";
|
||||
import { ChevronLeft, Link2, Pencil } from "../components/icons";
|
||||
import { ChevronLeft, Link2, Pencil, Trash } from "../components/icons";
|
||||
import { Button, InlineAlert, SkeletonRows, StatusPill } from "../components/ui";
|
||||
import { ApiError, api } from "../lib/client";
|
||||
import { ACTION_LABEL, DOC_TYPE_LABEL, STATUS_LABEL, categoryColor } from "../lib/knowledge";
|
||||
@@ -92,6 +92,22 @@ export function KnowledgeDocPage() {
|
||||
|
||||
const status = doc?.knowledge.status_hint;
|
||||
const canEdit = status === "draft" || status === "in_review" || status === "published";
|
||||
|
||||
/** 删除:确认后回列表页。后端是软删,但界面按"没了"处理最不容易让人误会。 */
|
||||
async function remove() {
|
||||
if (!doc) return;
|
||||
if (!window.confirm(`删除「${doc.title}」?删了不会回来,检索里也会一起消失。`)) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.del(`/workspaces/${wsId}/knowledge/${itemId}?confirm=true`);
|
||||
navigate(`/w/${wsId}/knowledge`, { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "删除失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
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" },
|
||||
@@ -210,6 +226,11 @@ export function KnowledgeDocPage() {
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
{/* 删除放最后、用弱色 + 垃圾桶:它是不可逆的,不该和"发布"抢注意力 */}
|
||||
<Button variant="ghost" disabled={busy} className="ml-auto text-ink-400 hover:text-red-600" onClick={() => void remove()}>
|
||||
<Trash size={13} />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
|
||||
Reference in New Issue
Block a user