用户反馈"UI 太丑、太简陋"。不是换配色,而是先立 token 再让页面去用: - tailwind.config.js:纸白 paper / 墨绿 primary / 陶土橙 clay + 语义色 sage/wheat/rust/sky,字号阶、圆角阶、投影、动效 - index.css:卡片、按钮五种变体、输入、pill/chip、导航、骨架屏 - 新增 icons.tsx(线性图标集)与 ui.tsx(Button/Sheet/Toast/空态/骨架/PageHeader) - 新增 AppShell 取代 Layout + WorkspaceNav:桌面侧栏 + 移动顶部横条,工单未读角标 - 六个页面全部重写:登录、首页、任务、看板、工单、知识、成员、邀请落地 - 详情抽屉改用 Sheet,工单态补齐需求人/办理人/退回次数/动作按钮 e2e 因 DOM 升级同步调整(抽屉按 role=dialog 定位、下拉改用 aria-label), 三套用例(7 + 8 + 7 项)全部重跑通过;tsc 0 错,构建 45 modules / gzip 76.70 kB。
198 lines
9.2 KiB
JavaScript
198 lines
9.2 KiB
JavaScript
/**
|
||
* M2 工单闭环的真浏览器用例(两个用户,两个浏览器上下文)。
|
||
*
|
||
* A 建任务 → 升级成工单指给 B → B 接收/开始/提交验收 → A 通过验收 → 原任务回流到待办
|
||
*
|
||
* 跑之前先起后端(8100)与前端(5180):
|
||
* node e2e/tickets.mjs
|
||
*/
|
||
import { existsSync, mkdirSync } from "node:fs";
|
||
import { chromium } from "playwright-core";
|
||
|
||
const APP = process.env.APP_URL ?? "http://127.0.0.1:5180";
|
||
const CHROME =
|
||
process.env.CHROME_PATH ??
|
||
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`;
|
||
const SHOT_DIR = new URL("./artifacts/", import.meta.url).pathname.replace(/^\//, "");
|
||
|
||
if (!existsSync(CHROME)) {
|
||
console.error(`找不到 Chromium:${CHROME}\n用 CHROME_PATH 指定可执行文件`);
|
||
process.exit(2);
|
||
}
|
||
mkdirSync(SHOT_DIR, { recursive: true });
|
||
|
||
let step = 0;
|
||
function pass(message) {
|
||
console.log(` ✓ ${++step}. ${message}`);
|
||
}
|
||
function fail(message) {
|
||
console.error(` ✗ ${message}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
const consoleErrors = [];
|
||
function watch(page, tag) {
|
||
page.on("console", (msg) => {
|
||
if (msg.type() === "error") consoleErrors.push(`[${tag}] ${msg.text()}`);
|
||
});
|
||
page.on("pageerror", (err) => consoleErrors.push(`[${tag}] ${String(err)}`));
|
||
page.on("response", (res) => {
|
||
if (res.status() >= 400) consoleErrors.push(`[${tag}] HTTP ${res.status()} ${res.url()}`);
|
||
});
|
||
page.setDefaultTimeout(15_000);
|
||
}
|
||
|
||
async function fillLogin(page, phone) {
|
||
await page.locator("#phone").fill(phone);
|
||
await page.getByRole("button", { name: "获取验证码" }).click();
|
||
const text = await page.locator("text=开发环境验证码").first().innerText();
|
||
const code = text.match(/(\d{6})/)?.[1];
|
||
if (!code) fail(`没读到开发验证码:${text}`);
|
||
await page.locator("#code").fill(code);
|
||
await page.getByRole("main").getByRole("button", { name: "登录", exact: true }).click();
|
||
await page.waitForURL((url) => !url.pathname.startsWith("/login"));
|
||
}
|
||
|
||
async function login(page, phone) {
|
||
await page.goto(`${APP}/login`, { waitUntil: "domcontentloaded" });
|
||
await fillLogin(page, phone);
|
||
await page.waitForURL(`${APP}/`);
|
||
}
|
||
|
||
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
|
||
const ctxA = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||
const ctxB = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||
const pageA = await ctxA.newPage();
|
||
const pageB = await ctxB.newPage();
|
||
watch(pageA, "A");
|
||
watch(pageB, "B");
|
||
|
||
if (process.env.DEBUG_NET) {
|
||
for (const [tag, pg] of [["A", pageA], ["B", pageB]]) {
|
||
pg.on("response", async (res) => {
|
||
if (!res.url().includes("/api/v1/workspaces/")) return;
|
||
const text = await res.text().catch(() => "");
|
||
console.log(` [net ${tag}] ${res.status()} ${res.url().replace(APP, "")} ${text.slice(0, 180)}`);
|
||
});
|
||
}
|
||
}
|
||
|
||
const stamp = String(Date.now()).slice(-8);
|
||
// 两个号后四位必须不同:昵称是"用户{后4位}",撞号会让截图里需求人和办理人看着像同一个人
|
||
const phoneA = `137${stamp}`;
|
||
const phoneB = `136${String((Number(stamp) + 1111) % 100000000).padStart(8, "0")}`;
|
||
|
||
try {
|
||
// 1. A 登录并建工作区
|
||
await login(pageA, phoneA);
|
||
await pageA.getByPlaceholder("例如:达道科技 / 我的事项").fill("E2E 工单组");
|
||
await pageA.getByRole("button", { name: "创建" }).click();
|
||
await pageA.waitForURL(/\/w\/[0-9a-f-]{36}$/);
|
||
const wsId = pageA.url().match(/\/w\/([0-9a-f-]{36})$/)[1];
|
||
await pageA.getByRole("heading", { name: "任务" }).waitFor();
|
||
pass("A 登录并建工作区");
|
||
|
||
// 2. A 建一条会卡住的任务(先切到「全部」,因为「今天」只按到期时间算)
|
||
const composer = pageA.getByPlaceholder(/加一件要做的事/);
|
||
await composer.fill("开通测试账号权限");
|
||
await composer.press("Enter");
|
||
await pageA.getByRole("button", { name: "全部", exact: true }).click();
|
||
await pageA.locator("div.card > div").filter({ hasText: "开通测试账号权限" }).first().waitFor();
|
||
pass("A 新建任务「开通测试账号权限」");
|
||
|
||
// 3. A 生成邀请码,B 落地加入
|
||
await pageA.getByRole("link", { name: "成员" }).click();
|
||
await pageA.getByRole("button", { name: "邀请成员" }).click();
|
||
const inviteText = await pageA.locator("p.break-all").first().innerText();
|
||
if (!inviteText.includes("/join/")) fail(`没拿到邀请链接:${inviteText}`);
|
||
await pageB.goto(inviteText.trim(), { waitUntil: "domcontentloaded" });
|
||
// 未登录会被送到 /login?next=/join/...,登录后要回到邀请页而不是工作区首页
|
||
await pageB.waitForURL(/\/login\?next=/);
|
||
await fillLogin(pageB, phoneB);
|
||
await pageB.waitForURL(new RegExp(`/w/${wsId}`));
|
||
await pageB.getByRole("heading", { name: "任务" }).waitFor();
|
||
pass("B 从邀请链接登录后自动加入工作区");
|
||
|
||
// 4. A 把任务升级成工单,指给 B
|
||
await pageA.goto(`${APP}/w/${wsId}`, { waitUntil: "domcontentloaded" });
|
||
await pageA.getByRole("button", { name: "全部", exact: true }).click();
|
||
await pageA.getByRole("button", { name: "开通测试账号权限" }).click();
|
||
const drawerA = pageA.getByRole("dialog");
|
||
await drawerA.waitFor();
|
||
const memberPicker = drawerA.getByLabel("选择办理人");
|
||
await memberPicker.selectOption({ index: 1 });
|
||
await drawerA.getByRole("button", { name: "升级成工单" }).click();
|
||
await drawerA.locator("text=已把工单交给").first().waitFor();
|
||
await pageA.screenshot({ path: `${SHOT_DIR}/promote.png` });
|
||
await drawerA.getByRole("button", { name: "关闭", exact: true }).click();
|
||
const blockedRow = await pageA
|
||
.locator("div.card > div")
|
||
.filter({ hasText: "开通测试账号权限" })
|
||
.first()
|
||
.innerText();
|
||
if (!blockedRow.includes("被阻塞")) fail(`升级后任务没变成被阻塞:${blockedRow}`);
|
||
pass("任务升级成工单后自身进入「被阻塞」");
|
||
|
||
// 5. B 在工单页接收 → 开始 → 提交验收
|
||
await pageB.goto(`${APP}/w/${wsId}/tickets`, { waitUntil: "domcontentloaded" });
|
||
await pageB.getByRole("heading", { name: "工单" }).waitFor();
|
||
const ticketCard = pageB.locator("li.card").filter({ hasText: "开通测试账号权限" }).first();
|
||
await ticketCard.waitFor();
|
||
await ticketCard.getByRole("button", { name: "接收", exact: true }).click();
|
||
await ticketCard.getByRole("button", { name: "开始", exact: true }).waitFor();
|
||
await ticketCard.getByRole("button", { name: "开始", exact: true }).click();
|
||
await ticketCard.getByRole("button", { name: "详情", exact: true }).click();
|
||
const drawerB = pageB.getByRole("dialog");
|
||
await drawerB.waitFor();
|
||
await drawerB.getByPlaceholder("给对方的说明(提交或验收时一并发过去)").fill("已经开了只读账号");
|
||
await drawerB.getByRole("button", { name: "提交验收" }).click();
|
||
await drawerB.locator("text=待验收").first().waitFor();
|
||
await pageB.screenshot({ path: `${SHOT_DIR}/ticket-submitted.png` });
|
||
pass("B 接收、开始办理并提交验收");
|
||
|
||
// 6. A 在「我发起的」里验收通过
|
||
await pageA.goto(`${APP}/w/${wsId}/tickets`, { waitUntil: "domcontentloaded" });
|
||
await pageA.getByRole("button", { name: "我发起的", exact: true }).click();
|
||
const mineCard = pageA.locator("li.card").filter({ hasText: "开通测试账号权限" }).first();
|
||
await mineCard.waitFor();
|
||
await mineCard.getByRole("button", { name: "详情", exact: true }).click();
|
||
const drawerVerify = pageA.getByRole("dialog");
|
||
await drawerVerify.waitFor();
|
||
await drawerVerify.getByPlaceholder("给对方的说明(提交或验收时一并发过去)").fill("确认可用");
|
||
await drawerVerify.getByRole("button", { name: "通过验收" }).click();
|
||
await drawerVerify.locator("text=已完成").first().waitFor();
|
||
pass("A 验收通过,工单完成");
|
||
await pageA.screenshot({ path: `${SHOT_DIR}/ticket-verified.png` });
|
||
|
||
// 7. 闭环回流:原任务回到待办
|
||
await pageA.getByRole("button", { name: "关闭", exact: true }).click();
|
||
await pageA.goto(`${APP}/w/${wsId}`, { waitUntil: "domcontentloaded" });
|
||
const activeView = pageA.getByRole("button", { name: "进行中" });
|
||
await activeView.click();
|
||
const reflowed = await pageA
|
||
.locator("div.card > div")
|
||
.filter({ hasText: "开通测试账号权限" })
|
||
.first()
|
||
.innerText();
|
||
if (!reflowed.includes("待办")) fail(`工单完成后原任务没回流到待办:${reflowed}`);
|
||
pass("工单验收通过后,原任务自动回流到「待办」");
|
||
|
||
if (consoleErrors.length > 0) {
|
||
fail(`浏览器控制台有报错:\n${consoleErrors.join("\n")}`);
|
||
}
|
||
pass("两个上下文控制台均无报错");
|
||
|
||
console.log(`\n全部通过,截图在 ${SHOT_DIR}`);
|
||
await browser.close();
|
||
} catch (err) {
|
||
await pageA.screenshot({ path: `${SHOT_DIR}/tickets-failure.png` }).catch(() => {});
|
||
const drawerText = await pageA.getByRole("dialog").innerText().catch(() => "");
|
||
if (drawerText) console.error(`\n[抽屉内容]\n${drawerText}`);
|
||
if (consoleErrors.length > 0) console.error(`\n[控制台]\n${consoleErrors.join("\n")}`);
|
||
const bodyText = await pageA.locator("body").innerText().catch(() => "");
|
||
console.error(`\n[页面文本]\n${bodyText.slice(0, 600)}`);
|
||
console.error(`\n失败:${err?.message ?? err}`);
|
||
await browser.close();
|
||
process.exit(1);
|
||
}
|