fix(wechat): 公众号新版页面抓不到正文,改从 cgiDataNew 里读
CI / backend (push) Canceled after 0s

用户发来 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:
Steven
2026-11-06 02:08:36 +08:00
parent 626f83fb9c
commit 690779c5fb
10 changed files with 720 additions and 15 deletions
+160
View File
@@ -0,0 +1,160 @@
/**
* 知识库的两件小事:分类下面的标签清单能点着筛,文章能删。
*
* 为什么单独写一条:标签统计和删除都是"后端好了但界面没入口"的典型,
* 看不见就等于没有;所以这里从界面点,最后用接口回查确认真的生效。
*
* 用法:
* node web/e2e/knowledge-tags-delete.mjs # 默认 dev 5180
* WEB=http://192.168.8.188:8200 PHONE=13605513683 PASSWORD=... node web/e2e/knowledge-tags-delete.mjs
*/
import { readFile } from "node:fs/promises";
import { chromium } from "playwright-core";
const WEB = process.env.WEB ?? "http://localhost:5180";
const API = process.env.API ?? `${WEB}/api/v1`;
const PHONE = process.env.PHONE ?? "13900000001";
const CHROME =
process.env.CHROME ??
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`;
let pass = 0;
let fail = 0;
function check(name, ok, extra = "") {
if (ok) {
pass += 1;
console.log(`PASS ${name}`);
} else {
fail += 1;
console.log(`FAIL ${name} ${extra}`);
}
}
async function readPassword() {
if (process.env.PASSWORD) return process.env.PASSWORD;
try {
const file = new URL("../../.local/e2e-account-password.txt", import.meta.url);
const text = (await readFile(file, "utf8")).trim();
return text || null;
} catch {
return null;
}
}
async function call(path, { method = "GET", body, token } = {}) {
const headers = { "Content-Type": "application/json" };
if (token) headers.Authorization = `Bearer ${token}`;
const r = await fetch(`${API}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
return r.json();
}
async function main() {
const password = await readPassword();
if (!password) throw new Error("没有密码:配 PASSWORD 或写 .local/e2e-account-password.txt");
const login = await call("/auth/login/password", {
method: "POST",
body: { phone: PHONE, password },
});
if (login.code !== 0) throw new Error(`登录失败:${login.message}`);
const token = login.data.tokens.access_token;
const spaces = (await call("/workspaces", { token })).data?.items ?? [];
if (!spaces.length) throw new Error("这个账号没有工作区");
const ws = spaces[0].id;
// 自己造数据:标签名带时间戳,避免和库里已有的同名标签混在一起数不准
const stamp = Date.now().toString().slice(-6);
const tag = `核对${stamp}`;
const created = await call(`/workspaces/${ws}/knowledge`, {
method: "POST",
token,
body: { title: `临时核对:标签与删除 ${stamp}`, body_md: "这条是 e2e 建的,跑完会删掉。", tag_names: [tag] },
});
if (created.code !== 0) throw new Error(`建知识失败:${created.message}`);
const doc = created.data;
await call(`/workspaces/${ws}/knowledge/${doc.id}/status`, {
method: "POST",
token,
body: { action: "publish", version: doc.version },
});
const browser = await chromium.launch({ executablePath: CHROME });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
await context.addInitScript(
([access, refresh]) => {
localStorage.setItem("dadao.access", access);
localStorage.setItem("dadao.refresh", refresh);
},
[token, login.data.tokens.refresh_token],
);
const page = await context.newPage();
const errors = [];
page.on("pageerror", (e) => errors.push(String(e)));
page.on("dialog", (d) => void d.accept());
await page.goto(`${WEB}/w/${ws}/knowledge`, { waitUntil: "networkidle" });
// ① 分类下面有「标签」块,且自己建的那个标签在里面
const tagBox = page.locator("section", { hasText: "标签" }).last();
const tagBtn = page.getByRole("button", { name: new RegExp(`#${tag}`) }).first();
await tagBtn.waitFor({ timeout: 10000 });
check("分类下面是标签清单", (await tagBox.count()) > 0);
check("刚建的标签出现在侧栏(带条数)", (await tagBtn.count()) > 0);
// ② 点标签 → 列表只剩这一类
await tagBtn.click();
await page.waitForTimeout(800);
const titles = await page.locator("li p.truncate").allInnerTexts();
check(
"按标签筛出的正好是自己那条",
titles.filter((t) => t.includes(stamp)).length === 1 && titles.length >= 1,
`titles=${titles.length}`,
);
check("标签被选中", (await page.getByRole("button", { name: new RegExp(`#${tag}`) }).first().getAttribute("aria-pressed")) === "true");
// ③ 再点一次取消筛选
await tagBtn.click();
await page.waitForTimeout(600);
check(
"再点一次取消标签筛选",
(await page.getByRole("button", { name: new RegExp(`#${tag}`) }).first().getAttribute("aria-pressed")) === "false",
);
// ④ 卡片上的删除:确认框点"确定",这条从列表消失
const row = page.locator("li").filter({ hasText: stamp }).first();
await row.hover();
await page.getByRole("button", { name: `删除 临时核对:标签与删除 ${stamp}` }).first().click();
await page.waitForTimeout(1200);
check("列表里已经看不到这条", (await page.locator("li").filter({ hasText: stamp }).count()) === 0);
const afterDelete = await call(`/workspaces/${ws}/knowledge/${doc.id}`, { token });
check("接口回查:这条确实没了", afterDelete.code !== 0, `code=${afterDelete.code}`);
// ⑤ 阅读页也有删除入口(换一条已存在的打开看看)
const page2 = await call(`/workspaces/${ws}/knowledge?limit=1`, { token });
const anyDoc = page2.data?.items?.[0];
if (anyDoc) {
await page.goto(`${WEB}/w/${ws}/knowledge/${anyDoc.id}`, { waitUntil: "networkidle" });
check("阅读页有「删除」按钮", (await page.getByRole("button", { name: "删除" }).count()) > 0);
}
check("控制台零报错", errors.length === 0, errors.slice(0, 2).join(" | "));
await page.goto(`${WEB}/w/${ws}/knowledge`, { waitUntil: "networkidle" });
await page.screenshot({ path: "e2e/artifacts/knowledge-tags.png" });
await browser.close();
// 收尾:万一界面那步没删掉,这里补一刀,别给下次跑留垃圾
await call(`/workspaces/${ws}/knowledge/${doc.id}?confirm=true`, { method: "DELETE", token });
console.log(`\n${fail === 0 ? "ALL PASS" : "FAILED"} — pass ${pass}, fail ${fail}`);
if (fail > 0) process.exitCode = 1;
}
main().catch((err) => {
console.error("FAIL 脚本异常 —", err);
process.exitCode = 1;
});
+6
View File
@@ -191,6 +191,12 @@ export interface KnowledgeCategoryNode extends KnowledgeCategory {
doc_count: number;
}
/** 侧栏那串标签:`count` 是"有多少条知识用了它",不是引用次数。 */
export interface KnowledgeTagCount {
name: string;
count: number;
}
export interface Knowledge extends Item {
knowledge: KnowledgeInfo;
category: KnowledgeCategory | null;
+107 -5
View File
@@ -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>
);
}
+22 -1
View File
@@ -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">