Files
DadaoTask/web/e2e/shots-rich.mjs
dadao fbdf54cb4c
CI / backend (push) Canceled after 0s
feat(web): 富文本编辑器换成 Vditor,不再自研轮子
上一版我自己写了 textarea + 正则工具栏,看着能用,一到粘贴图片、
中文输入法、撤销栈就露馅——这几样正是成熟组件积累的部分。按要求改成品。

- 编辑器开关 def: RichTextEditor = Vditor 4.0.1(MIT,ir 模式)
  * 运行时资源自托管:web/scripts/vendor-vditor.mjs 把 lute/i18n/icons
    同步到 public/vditor,predev/prebuild 自动执行,不走 unpkg CDN
    (内网会一直卡在加载)
- 只读渲染换 markdown-it + dompurify,删掉自写的正则渲染器
- 附件直链的令牌只在内存里存在:落库前 stripAttachmentTokens,
  渲染/装载前补回(<img> 带不了 Authorization 头,raw?token= 是必要折中)
- Vditor 不会替我们插入 upload.handler 的返回值(只当提示语 tip.show),
  所以上传完自己 insertMD

验证:pytest 70 passed;e2e richtext 13 项、smoke 7、tickets 8、
knowledge 7 全绿;tsc 0 错;主包 299 kB/gzip 75 kB,Vditor 独立
chunk 385 kB 按需加载。
2026-10-29 02:30:10 +08:00

102 lines
3.5 KiB
JavaScript

/**
* 截图:详情抽屉里的富文本编辑器(Vditor,边写边看)。
* 只用于留存界面现状,不做断言。
*/
import { chromium } from "playwright-core";
const API = "http://127.0.0.1:8100/api/v1";
const WEB = "http://127.0.0.1:5180";
const stamp = String(Date.now()).slice(-7);
const phone = `137${stamp}0`;
async function call(method, path, body, token) {
const headers = { "content-type": "application/json" };
if (token) headers.authorization = `Bearer ${token}`;
if (method !== "GET") headers["idempotency-key"] = `shot-${Math.random()}`;
const res = await fetch(`${API}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const json = JSON.parse(await res.text());
if (json.code !== 0) throw new Error(`${path} ${json.code} ${json.message}`);
return json.data;
}
const PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAAXklEQVR42u3BAQ0AAADCoPdPbQ8H" +
"FAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4G8wAAAB" +
"T2nSywAAAABJRU5ErkJggg==",
"base64",
);
await call("POST", "/auth/sms/send", { phone });
const login = await call("POST", "/auth/login", { phone, code: "123456" });
const token = login.tokens.access_token;
const ws = await call("POST", "/workspaces", { name: "远航工作室" }, token);
const body = [
"**快递只收顺丰**,其它快递到不了前台。",
"",
"寄之前先拍一张照片存档,并在下面写上单号。",
"",
"1. 抬头写「远航科技(上海)有限公司」",
"2. 备注「报销 - 十月」",
"3. 底单留在文件袋里",
].join("\n");
const task = await call(
"POST",
`/workspaces/${ws.id}/items`,
{ title: "把十月报销单寄给财务", body_md: body, priority: 2 },
token,
);
const form = new FormData();
form.append("file", new Blob([PNG], { type: "image/png" }), "底单照片.png");
const uploaded = await fetch(
`${API}/workspaces/${ws.id}/items/${task.id}/attachments`,
{ method: "POST", headers: { authorization: `Bearer ${token}` }, body: form },
);
const att = (await uploaded.json()).data;
await call(
"PATCH",
`/workspaces/${ws.id}/items/${task.id}`,
{
version: task.version,
body_md: `${body}\n\n![底单照片.png](/api/v1/workspaces/${ws.id}/attachments/${att.id}/raw?token=${token})`,
},
token,
);
const browser = await chromium.launch({
executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
await page.goto(`${WEB}/login`);
await page.evaluate(
([access, refresh]) => {
localStorage.setItem("dadao.access", access);
localStorage.setItem("dadao.refresh", refresh);
},
[token, login.tokens.refresh_token],
);
await page.goto(`${WEB}/w/${ws.id}`);
await page.getByRole("heading", { name: "任务" }).waitFor();
await page.getByRole("button", { name: "全部", exact: true }).click();
await page.getByRole("button", { name: "把十月报销单寄给财务", exact: true }).click();
const drawer = page.getByRole("dialog");
await drawer.waitFor();
// Vditor 是"边写边看",没有单独的预览态:等它把 lute 与正文渲染出来再截图
await page.waitForSelector(".vditor-ir strong", { timeout: 20000 });
await page.waitForTimeout(1200);
await page.screenshot({ path: "../2.Design/screens/web-editor-write.png" });
await context.close();
await browser.close();
console.log("shots saved");