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 = ` 2468 `.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; });