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

759 lines
24 KiB
JavaScript
Raw Normal View History

2026-04-03 09:46:58 +00:00
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",
2026-04-15 08:21:52 +00:00
stageCode: "已签单",
stage: "已签单",
2026-04-03 09:46:58 +00:00
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;
});