unis_crm/docs/scripts/capture-manual-screenshots.mjs

759 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import fs from "fs/promises";
import path from "path";
import { chromium } from "playwright";
const baseUrl = process.env.CRM_DOC_BASE_URL || "http://127.0.0.1:3001";
const rootDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", "..");
const outputDir = path.join(rootDir, "docs", "assets", "manual");
const currentUser = {
userId: 1,
tenantId: 1001,
username: "zhangsan",
displayName: "张三",
email: "zhangsan@uniscrm.example",
phone: "13800138000",
roleCodes: ["crm_sales"],
roles: [{ roleCode: "crm_sales", roleName: "销售经理" }],
};
const dashboardHome = {
userId: 1,
realName: "张三",
jobTitle: "销售经理",
deptName: "华东销售一部",
onboardingDays: 386,
stats: [
{ name: "本月新增商机", value: 12, metricKey: "monthlyOpportunities" },
{ name: "已推送OMS项目", value: 5, metricKey: "pushedOmsProjects" },
{ name: "本月新增渠道", value: 8, metricKey: "monthlyChannels" },
{ name: "本月打卡次数", value: 19, metricKey: "monthlyCheckins" },
],
todos: [
{ id: "todo-1", title: "跟进华东教育云桌面项目", status: "pending" },
{ id: "todo-2", title: "补充苏州金桥渠道资料", status: "pending" },
{ id: "todo-3", title: "完成3月销售日报点评回复", status: "done" },
{ id: "todo-4", title: "更新杭州政务项目推进记录", status: "done" },
],
activities: [
{ id: 1, title: "新增商机", content: "华东教育云桌面项目已完成立项登记。", timeText: "今天 09:18" },
{ id: 2, title: "渠道跟进", content: "苏州金桥渠道补充了联系人与办公地址。", timeText: "今天 10:42" },
{ id: 3, title: "日报提交", content: "李四已提交今日销售日报,等待点评。", timeText: "今天 18:11" },
{ id: 4, title: "OMS推送", content: "杭州政务云项目已推送至 OMS。", timeText: "昨天 16:25" },
{ id: 5, title: "打卡完成", content: "张三完成外勤拜访打卡,地点为上海市徐汇区。", timeText: "昨天 14:06" },
{ id: 6, title: "拓展新增", content: "新增销售拓展对象:王磊。", timeText: "昨天 11:30" },
],
};
const profileOverview = {
userId: 1,
monthlyOpportunityCount: 12,
monthlyExpansionCount: 8,
averageScore: 96,
onboardingDays: 386,
realName: "张三",
jobTitle: "销售经理",
deptName: "华东销售一部",
accountStatus: "正常",
};
const expansionOverview = {
salesItems: [
{
id: 11,
ownerUserId: 1,
owner: "张三",
type: "sales",
employeeNo: "S2024001",
name: "王磊",
officeName: "上海代表处",
phone: "13900001111",
dept: "教育行业部",
industry: "教育",
title: "销售总监",
intentLevel: "high",
intent: "高",
hasExp: true,
active: true,
relatedProjects: [
{ opportunityId: 101, opportunityCode: "OPP-2026-001", opportunityName: "华东教育云桌面项目", amount: 860000 },
],
followUps: [
{
id: 1,
date: "2026-04-02",
type: "拜访",
visitStartTime: "14:00",
evaluationContent: "已确认预算窗口与技术负责人。",
nextPlan: "下周安排方案交流。",
},
],
},
{
id: 12,
ownerUserId: 1,
owner: "张三",
type: "sales",
employeeNo: "S2024002",
name: "李敏",
officeName: "杭州办事处",
phone: "13900002222",
dept: "政企行业部",
industry: "政府",
title: "客户经理",
intentLevel: "medium",
intent: "中",
hasExp: false,
active: true,
relatedProjects: [],
followUps: [],
},
],
channelItems: [
{
id: 21,
ownerUserId: 1,
owner: "张三",
type: "channel",
channelCode: "CH-2026-001",
name: "苏州金桥科技有限公司",
province: "江苏省",
city: "苏州市",
officeAddress: "苏州市工业园区星湖街 188 号",
certificationLevel: "银牌",
channelIndustry: "教育、政府",
channelAttribute: "区域渠道",
internalAttribute: "重点合作伙伴",
intentLevel: "high",
intent: "高",
establishedDate: "2026-03-12",
revenue: "3000 万",
size: 120,
hasDesktopExp: true,
contacts: [
{ id: 1, name: "刘晨", mobile: "13700001111", title: "总经理" },
{ id: 2, name: "顾宇", mobile: "13700002222", title: "技术总监" },
],
relatedProjects: [
{ opportunityId: 102, opportunityCode: "OPP-2026-002", opportunityName: "杭州政务云项目", amount: 1250000 },
],
followUps: [
{
id: 11,
date: "2026-04-01",
type: "电话沟通",
visitStartTime: "10:30",
evaluationContent: "已同步合作模式与首批目标客户。",
nextPlan: "4月中旬安排联合拜访。",
},
],
},
{
id: 22,
ownerUserId: 1,
owner: "张三",
type: "channel",
channelCode: "CH-2026-002",
name: "宁波数智集成服务商",
province: "浙江省",
city: "宁波市",
officeAddress: "宁波市高新区研发园 B 座",
certificationLevel: "金牌",
channelIndustry: "制造",
channelAttribute: "行业渠道",
internalAttribute: "储备伙伴",
intentLevel: "medium",
intent: "中",
establishedDate: "2026-02-23",
revenue: "5000 万",
size: 80,
hasDesktopExp: false,
contacts: [{ id: 3, name: "郑航", mobile: "13700003333", title: "商务经理" }],
relatedProjects: [],
followUps: [],
},
],
};
const expansionMeta = {
officeOptions: [
{ label: "上海代表处", value: "上海代表处" },
{ label: "杭州办事处", value: "杭州办事处" },
{ label: "苏州办事处", value: "苏州办事处" },
],
industryOptions: [
{ label: "教育", value: "教育" },
{ label: "政府", value: "政府" },
{ label: "制造", value: "制造" },
{ label: "医疗", value: "医疗" },
],
provinceOptions: [
{ label: "上海市", value: "上海市" },
{ label: "江苏省", value: "江苏省" },
{ label: "浙江省", value: "浙江省" },
],
certificationLevelOptions: [
{ label: "金牌", value: "金牌" },
{ label: "银牌", value: "银牌" },
{ label: "注册", value: "注册" },
],
channelAttributeOptions: [
{ label: "区域渠道", value: "区域渠道" },
{ label: "行业渠道", value: "行业渠道" },
{ label: "其他", value: "其他" },
],
internalAttributeOptions: [
{ label: "重点合作伙伴", value: "重点合作伙伴" },
{ label: "储备伙伴", value: "储备伙伴" },
],
nextChannelCode: "CH-2026-003",
};
const opportunityOverview = {
items: [
{
id: 101,
ownerUserId: 1,
code: "OPP-2026-001",
name: "华东教育云桌面项目",
client: "华东某职业学院",
owner: "张三",
projectLocation: "上海市",
operatorName: "新华三+渠道",
amount: 860000,
date: "2026-05-20",
confidence: "A",
stageCode: "方案交流",
stage: "方案交流",
type: "新建",
archived: false,
pushedToOms: true,
product: "VDI云桌面",
source: "主动开发",
salesExpansionId: 11,
salesExpansionName: "王磊",
channelExpansionId: 21,
channelExpansionName: "苏州金桥科技有限公司",
preSalesId: 2001,
preSalesName: "赵工",
competitorName: "华为、深信服",
latestProgress: "客户已确认方案范围,准备进入报价阶段。",
nextPlan: "下周提交正式报价并安排演示环境。",
notes: "项目纳入二季度重点跟踪清单。",
followUps: [
{
id: 1,
date: "2026-04-02",
type: "拜访",
latestProgress: "完成需求澄清",
communicationContent: "客户希望 5 月底前完成采购流程。",
nextAction: "输出报价清单",
user: "张三",
},
],
},
{
id: 102,
ownerUserId: 1,
code: "OPP-2026-002",
name: "杭州政务云项目",
client: "杭州某政务中心",
owner: "张三",
projectLocation: "浙江省",
operatorName: "渠道",
amount: 1250000,
date: "2026-06-15",
confidence: "B",
stageCode: "商机储备",
stage: "商机储备",
type: "扩容",
archived: false,
pushedToOms: false,
product: "VDI云桌面",
source: "渠道引入",
channelExpansionId: 21,
channelExpansionName: "苏州金桥科技有限公司",
competitorName: "锐捷",
latestProgress: "等待客户确认立项时间。",
nextPlan: "继续维护客户关键人。",
notes: "渠道主导推进。",
followUps: [],
},
{
id: 103,
ownerUserId: 1,
code: "OPP-2026-003",
name: "宁波制造云桌面替换项目",
client: "宁波某制造集团",
owner: "张三",
projectLocation: "浙江省",
operatorName: "新华三",
amount: 530000,
date: "2026-03-18",
confidence: "C",
stageCode: "已签单",
stage: "已签单",
type: "替换",
archived: true,
pushedToOms: false,
product: "VDI云桌面",
source: "主动开发",
salesExpansionId: 12,
salesExpansionName: "李敏",
competitorName: "无",
latestProgress: "客户预算取消,项目归档。",
nextPlan: "",
notes: "保留历史记录。",
followUps: [],
},
],
};
const opportunityMeta = {
stageOptions: [
{ label: "商机储备", value: "商机储备" },
{ label: "方案交流", value: "方案交流" },
{ label: "报价申请", value: "报价申请" },
{ label: "合同审批", value: "合同审批" },
],
operatorOptions: [
{ label: "新华三", value: "新华三" },
{ label: "渠道", value: "渠道" },
{ label: "新华三+渠道", value: "新华三+渠道" },
],
projectLocationOptions: [
{ label: "上海市", value: "上海市" },
{ label: "江苏省", value: "江苏省" },
{ label: "浙江省", value: "浙江省" },
],
opportunityTypeOptions: [
{ label: "新建", value: "新建" },
{ label: "扩容", value: "扩容" },
{ label: "替换", value: "替换" },
],
};
const workOverview = {
todayCheckIn: {
id: 501,
bizType: "opportunity",
bizId: 101,
bizName: "华东教育云桌面项目",
userName: "张三",
deptName: "华东销售一部",
},
todayReport: {
id: 601,
status: "submitted",
workContent: "今日完成客户拜访、商机澄清与渠道协同推进。",
lineItems: [
{
workDate: "2026-04-03",
bizType: "opportunity",
bizId: 101,
bizName: "华东教育云桌面项目",
content: "客户已明确首批终端规模,进入报价准备阶段。",
latestProgress: "方案范围已确认",
nextPlan: "下周提交报价与演示计划",
},
{
workDate: "2026-04-03",
bizType: "channel",
bizId: 21,
bizName: "苏州金桥科技有限公司",
content: "同步联合拓展节奏确认4月联合拜访名单。",
evaluationContent: "渠道配合度较高",
nextPlan: "输出联合拜访安排",
},
],
planItems: [
{ content: "提交华东教育项目报价草案" },
{ content: "完成杭州政务项目客户关系梳理" },
],
tomorrowPlan: "围绕重点项目推进报价与客户触达。",
sourceType: "manual",
score: 98,
comment: "内容完整,继续保持。",
},
};
const checkinHistoryItems = [
{
id: 1,
type: "外勤打卡",
date: "2026-04-03",
time: "14:06",
status: "已提交",
content: "打卡人:张三\n关联对象华东教育云桌面项目\n地址上海市徐汇区桂平路 680 号\n备注已完成现场拜访。",
photoUrls: [],
},
{
id: 2,
type: "外勤打卡",
date: "2026-04-02",
time: "16:20",
status: "已提交",
content: "打卡人:张三\n关联对象苏州金桥科技有限公司\n地址苏州市工业园区星湖街 188 号\n备注渠道联合沟通。",
photoUrls: [],
},
];
const reportHistoryItems = [
{
id: 3,
type: "销售日报",
date: "2026-04-03",
time: "18:11",
status: "已点评",
score: 98,
comment: "内容完整,继续保持。",
content: "提交人:张三\n今日工作推进华东教育项目与渠道协同。\n明日计划准备报价并继续客户触达。",
},
{
id: 4,
type: "销售日报",
date: "2026-04-02",
time: "18:05",
status: "已提交",
score: 95,
comment: "",
content: "提交人:张三\n今日工作商机梳理与外勤拜访。\n明日计划整理客户需求与推进方案。",
},
];
function apiEnvelope(data, msg = "success") {
return {
code: "0",
msg,
data,
};
}
function createToken() {
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
const payload = Buffer.from(JSON.stringify({ userId: currentUser.userId, tenantId: currentUser.tenantId })).toString("base64url");
return `${header}.${payload}.mock-signature`;
}
function createCaptchaSvgBase64() {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="44" viewBox="0 0 120 44">
<rect width="120" height="44" rx="8" fill="#f8fafc"/>
<text x="18" y="29" font-size="22" fill="#6d28d9" font-family="Arial, sans-serif" font-weight="700">2468</text>
<path d="M8 35 C25 10, 40 10, 58 35" stroke="#c4b5fd" stroke-width="2" fill="none"/>
<path d="M62 10 C78 35, 96 35, 112 10" stroke="#a5b4fc" stroke-width="2" fill="none"/>
</svg>
`.trim();
return Buffer.from(svg).toString("base64");
}
async function installApiMocks(page) {
await page.route("**/*", async (route) => {
const requestUrl = route.request().url();
const url = new URL(requestUrl);
if (!url.pathname.startsWith("/api/")) {
if (
requestUrl.startsWith("https://mapapi.qq.com/") ||
requestUrl.startsWith("https://map.qq.com/") ||
requestUrl.startsWith("https://res.wx.qq.com/") ||
requestUrl.startsWith("https://open.work.weixin.qq.com/")
) {
await route.abort();
return;
}
await route.continue();
return;
}
const pathname = url.pathname;
const method = route.request().method().toUpperCase();
if (pathname === "/api/sys/auth/captcha") {
await route.fulfill({ json: apiEnvelope({ captchaId: "mock-captcha-id", imageBase64: createCaptchaSvgBase64() }) });
return;
}
if (pathname === "/api/sys/auth/login" && method === "POST") {
await route.fulfill({
json: apiEnvelope({
accessToken: createToken(),
refreshToken: "mock-refresh-token",
accessExpiresInMinutes: 120,
refreshExpiresInDays: 7,
}),
});
return;
}
if (pathname === "/api/sys/api/params/value") {
await route.fulfill({ json: apiEnvelope("true") });
return;
}
if (pathname === "/api/sys/api/open/platform/config") {
await route.fulfill({
json: apiEnvelope({
projectName: "紫光汇智CRM系统",
systemDescription: "聚焦客户拓展、商机推进与销售协同,让团队每天的工作节奏更清晰。",
}),
});
return;
}
if (pathname === "/api/sys/api/users/me") {
await route.fulfill({ json: apiEnvelope(currentUser) });
return;
}
if (pathname === "/api/dashboard/home") {
await route.fulfill({ json: apiEnvelope(dashboardHome) });
return;
}
if (pathname.startsWith("/api/dashboard/todos/") && pathname.endsWith("/complete")) {
await route.fulfill({ json: apiEnvelope(null) });
return;
}
if (pathname === "/api/profile/overview") {
await route.fulfill({ json: apiEnvelope(profileOverview) });
return;
}
if (pathname === "/api/sys/api/users/profile" && method === "PUT") {
await route.fulfill({ json: apiEnvelope(true) });
return;
}
if (pathname === "/api/sys/api/users/password" && method === "PUT") {
await route.fulfill({ json: apiEnvelope(true) });
return;
}
if (pathname === "/api/work/overview") {
await route.fulfill({ json: apiEnvelope(workOverview) });
return;
}
if (pathname === "/api/work/history") {
const type = url.searchParams.get("type") || "checkin";
const pageNo = Number(url.searchParams.get("page") || "1");
const allItems = type === "report" ? reportHistoryItems : checkinHistoryItems;
const items = pageNo === 1 ? allItems : [];
await route.fulfill({
json: apiEnvelope({
items,
hasMore: false,
page: pageNo,
size: 8,
}),
});
return;
}
if (pathname === "/api/work/reverse-geocode") {
await route.fulfill({ json: apiEnvelope("上海市徐汇区桂平路 680 号 创新大厦") });
return;
}
if (pathname === "/api/work/checkins" && method === "POST") {
await route.fulfill({ json: apiEnvelope(7001) });
return;
}
if (pathname === "/api/work/checkin-photos" && method === "POST") {
await route.fulfill({ json: apiEnvelope("/mock/checkin-photo.png") });
return;
}
if (pathname === "/api/work/daily-reports" && method === "POST") {
await route.fulfill({ json: apiEnvelope(7002) });
return;
}
if (pathname === "/api/expansion/overview") {
await route.fulfill({ json: apiEnvelope(expansionOverview) });
return;
}
if (pathname === "/api/expansion/meta") {
await route.fulfill({ json: apiEnvelope(expansionMeta) });
return;
}
if (pathname === "/api/expansion/areas/cities") {
const provinceName = url.searchParams.get("provinceName");
const cities = provinceName === "江苏省"
? [{ label: "苏州市", value: "苏州市" }, { label: "南京市", value: "南京市" }]
: provinceName === "浙江省"
? [{ label: "杭州市", value: "杭州市" }, { label: "宁波市", value: "宁波市" }]
: [{ label: "上海市", value: "上海市" }];
await route.fulfill({ json: apiEnvelope(cities) });
return;
}
if (
pathname === "/api/expansion/sales/duplicate-check" ||
pathname === "/api/expansion/channel/duplicate-check"
) {
await route.fulfill({ json: apiEnvelope({ duplicated: false, message: "" }) });
return;
}
if (pathname.startsWith("/api/expansion/") && ["POST", "PUT"].includes(method)) {
await route.fulfill({ json: apiEnvelope(1) });
return;
}
if (pathname === "/api/opportunities/overview") {
await route.fulfill({ json: apiEnvelope(opportunityOverview) });
return;
}
if (pathname === "/api/opportunities/meta") {
await route.fulfill({ json: apiEnvelope(opportunityMeta) });
return;
}
if (pathname === "/api/opportunities/oms/pre-sales") {
await route.fulfill({
json: apiEnvelope([
{ userId: 2001, loginName: "zhaogong", userName: "赵工" },
{ userId: 2002, loginName: "wanggong", userName: "王工" },
]),
});
return;
}
if (pathname === "/api/opportunities" && method === "POST") {
await route.fulfill({ json: apiEnvelope(9001) });
return;
}
if (pathname.startsWith("/api/opportunities/") && pathname.endsWith("/push-oms")) {
await route.fulfill({ json: apiEnvelope(1) });
return;
}
if (pathname.startsWith("/api/opportunities/") && ["PUT", "POST"].includes(method)) {
await route.fulfill({ json: apiEnvelope(1) });
return;
}
console.warn(`Unhandled API mock: ${method} ${pathname}`);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(apiEnvelope(null)),
});
});
}
async function seedAuth(page) {
const token = createToken();
await page.addInitScript(
({ user, accessToken }) => {
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("refreshToken", "mock-refresh-token");
localStorage.setItem("username", user.username);
sessionStorage.setItem("userProfile", JSON.stringify(user));
},
{ user: currentUser, accessToken: token },
);
}
async function waitForStablePage(page) {
await page.waitForLoadState("networkidle");
await page.waitForTimeout(800);
}
async function capture(page, pathname, fileName, readyText, options = {}) {
console.log(`Capturing ${fileName} from ${pathname}`);
await page.goto(`${baseUrl}${pathname}`, { waitUntil: "domcontentloaded" });
if (readyText) {
await page.getByText(readyText, { exact: false }).first().waitFor({ state: "visible", timeout: 15000 });
}
await waitForStablePage(page);
await page.screenshot({
path: path.join(outputDir, fileName),
fullPage: true,
...options,
});
}
async function clickButtonByText(page, text) {
const locator = page.getByRole("button", { name: new RegExp(text) }).first();
await locator.waitFor({ state: "visible", timeout: 15000 });
await locator.click();
}
async function main() {
await fs.mkdir(outputDir, { recursive: true });
const browser = await chromium.launch({
channel: "chrome",
headless: true,
});
const loginContext = await browser.newContext({
viewport: { width: 1440, height: 1200 },
deviceScaleFactor: 1,
locale: "zh-CN",
});
const loginPage = await loginContext.newPage();
await installApiMocks(loginPage);
await capture(loginPage, "/login", "01-login.png", "紫光汇智CRM系统");
const appContext = await browser.newContext({
viewport: { width: 1440, height: 1600 },
deviceScaleFactor: 1,
locale: "zh-CN",
colorScheme: "light",
geolocation: { latitude: 31.178744, longitude: 121.410428 },
permissions: ["geolocation"],
});
const appPage = await appContext.newPage();
await installApiMocks(appPage);
await seedAuth(appPage);
await capture(appPage, "/", "02-dashboard.png", "工作台");
await capture(appPage, "/expansion", "03-expansion-sales.png", "销售人员拓展");
await clickButtonByText(appPage, "新增");
await appPage.getByRole("heading", { name: "新增销售人员拓展" }).waitFor({ state: "visible", timeout: 15000 });
await waitForStablePage(appPage);
console.log("Capturing 04-expansion-create.png from modal");
await appPage.screenshot({ path: path.join(outputDir, "04-expansion-create.png"), fullPage: true });
await clickButtonByText(appPage, "取消");
await waitForStablePage(appPage);
await appPage.getByRole("button", { name: "渠道拓展" }).click();
await appPage.getByText("苏州金桥科技有限公司").waitFor({ state: "visible", timeout: 15000 });
await waitForStablePage(appPage);
console.log("Capturing 05-expansion-channel.png from channel tab");
await appPage.screenshot({ path: path.join(outputDir, "05-expansion-channel.png"), fullPage: true });
await capture(appPage, "/opportunities", "06-opportunities.png", "商机");
await clickButtonByText(appPage, "新增商机");
await appPage.getByRole("heading", { name: "新增商机" }).waitFor({ state: "visible", timeout: 15000 });
await waitForStablePage(appPage);
console.log("Capturing 07-opportunity-create.png from modal");
await appPage.screenshot({ path: path.join(outputDir, "07-opportunity-create.png"), fullPage: true });
await clickButtonByText(appPage, "取消");
await waitForStablePage(appPage);
await capture(appPage, "/work/checkin", "08-work-checkin.png", "外勤打卡");
await capture(appPage, "/work/report", "09-work-report.png", "销售日报");
await capture(appPage, "/profile", "10-profile.png", "账号安全");
await loginContext.close();
await appContext.close();
await browser.close();
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});