From 3d8df9b51f188c311a1a50fde01d2825a51f4102 Mon Sep 17 00:00:00 2001 From: Chen Zheng Date: Sun, 31 May 2026 19:47:50 +0800 Subject: [PATCH] add skill files --- SKILL.md | 949 +++++++++++++++++++++++++++++++++++ references/form-field-api.md | 98 ++++ references/internal-apis.md | 257 ++++++++++ 3 files changed, 1304 insertions(+) create mode 100644 SKILL.md create mode 100644 references/form-field-api.md create mode 100644 references/internal-apis.md diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..311ae1f --- /dev/null +++ b/SKILL.md @@ -0,0 +1,949 @@ +--- +name: fanwei-e10-api-doc +description: 泛微OA E10 系统接口文档撰写 — 自动登录、浏览 API 文档、发现流程字段、生成 MD 文档。 +--- + +# 泛微OA E10 接口文档撰写 + +## ⛔ 硬性约束:只读操作 + +**绝对禁止对 E10 系统进行任何修改操作。** 包括但不限于: +- 不创建、编辑、删除流程定义 +- 不修改表单字段配置 +- 不新增、编辑、删除任何系统数据 +- 不调用任何写操作的 API(POST/PUT/DELETE 等) +- 浏览器操作仅限于导航、查看、截图、提取文本 + +**允许的操作:** +- 查看页面内容 +- 截图记录 +- 提取 API 文档文本 +- 提取流程字段定义 +- 提取表单结构信息 + +如果某个操作看起来可能会修改数据,必须先向用户确认。 + +## Trigger + +用户说"帮我写 E10 的 xxx 接口文档"或"写一个创建 xxx 流程实例的接口文档"时使用。 + +## Phase 0: 获取凭据 + +**每次都向用户确认以下信息,不依赖任何缓存的凭据:** + +- E10 系统地址 (base URL,如 `https://e10.example.com`) +- 账号 +- 密码 + +不要从 memory 中读取凭据,不要缓存凭据到 memory。即使之前用过同一个系统也要重新确认。 + +## Phase 1: 启动独立浏览器 + 登录 + +### ⚠️ 跨平台兼容说明 + +本 skill 在 Linux 和 Windows 上均可使用。下面用变量表示平台差异,Agent 执行时自动替换: + +| 变量 | Linux | Windows | +|------|-------|---------| +| `` | `google-chrome` | `"C:\Program Files\Google\Chrome\Application\chrome.exe"` | +| `` | `/tmp/bh-e10` | `%TEMP%\bh-e10` | +| `` | `/tmp` | `%TEMP%` | +| `` | `pkill -f browser_harness.daemon 2>/dev/null; rm -f /tmp/bu-*.sock /tmp/bu-*.pid 2>/dev/null` | `taskkill /F /IM python.exe /FI "WINDOWTITLE eq browser_harness*" 2>nul & del /Q %TEMP%\bu-*.sock %TEMP%\bu-*.pid 2>nul` | + +### Step 0: 启动独立 Chrome(必须) + +**绝对不要连接用户的日常浏览器**。每次使用前启动一个独立的 headless Chrome 实例。 + +```bash +# 1. 清理旧 daemon(关键!daemon 跨命令持久存活,残留的会连到用户浏览器) + + +# 2. 启动独立 headless Chrome + --headless=new --remote-debugging-port=9223 --user-data-dir= --no-first-run --no-default-browser-check --window-size=1920,1080 + +# 3. 确认端口就绪 +# Linux/macOS: sleep 3 && curl -s http://127.0.0.1:9223/json/version | grep webSocketDebuggerUrl +# Windows: 等待 3 秒后浏览器访问 http://127.0.0.1:9223/json/version 确认有响应 +``` + +启动 Chrome 用 `terminal(background=true)`,之后**每个** browser-harness 命令都必须: + +```bash +export BU_CDP_URL=http://127.0.0.1:9223 && browser-harness <<'PY' +... +PY +``` + +> **严禁分开执行** `export BU_CDP_URL=...` 然后 `browser-harness` — daemon 是独立进程,不会继承后续 shell 的环境变量。必须 `export && browser-harness` 在同一行。Windows 用 `set BU_CDP_URL=http://127.0.0.1:9223 && browser-harness`。 + +### Step 1: 导航到登录页 + +```bash +export BU_CDP_URL=http://127.0.0.1:9223 && browser-harness <<'PY' +new_tab("https:///login") +wait_for_load() +capture_screenshot("/e10_login.png") + +# 确认在登录页 +url = js("window.location.href") +print("URL:", url) +# 如果 URL 不是 /login 而是其他页面(如 /attend/info),说明之前的 session 还在, +# 这是正常的 — 直接用即可,跳到 Phase 3C +PY +``` + +### Step 2: 识别验证码 + 登录 + +如果 Step 1 已在登录页,继续登录;如果已登录(URL 不含 `/login`),跳到 Phase 3C。 + +**验证码规则**:E10 图形验证码**始终为 4 位数字**,不会出现 3 位或以下。 + +```bash +export BU_CDP_URL=http://127.0.0.1:9223 && browser-harness <<'PY' +# 截图识别验证码 +capture_screenshot("/e10_captcha.png") +PY +``` + +用 `vision_analyze` 识别截图。**如果返回少于 4 位**(识别不全或 headless 渲染空白),则点击验证码图片刷新后重新截图: + +```bash +export BU_CDP_URL=http://127.0.0.1:9223 && browser-harness <<'PY' +import json + +# 点击验证码图片刷新 +info = json.loads(js(''' +var imgs = document.querySelectorAll("img"); +for (var i = 0; i < imgs.length; i++) { + var r = imgs[i].getBoundingClientRect(); + if (r.width > 20 && r.height > 20) { + // 验证码图片通常在输入框右侧,尺寸约 120x30 + return JSON.stringify({x: Math.round(r.left+r.width/2), y: Math.round(r.top+r.height/2)}); + } +} +// fallback: 第三个 input 右侧 180px +var inputs = document.querySelectorAll("input"); +var r = inputs[2].getBoundingClientRect(); +return JSON.stringify({x: Math.round(r.right + 90), y: Math.round(r.top + r.height/2)}); +''')) +click_at_xy(info["x"], info["y"]) +wait(2) +capture_screenshot("/e10_captcha.png") +PY +``` + +拿到 4 位验证码后,用 native setter + 事件派发登录: + +```bash +export BU_CDP_URL=http://127.0.0.1:9223 && browser-harness <<'PY' +import json + +# 获取 input 坐标(每次都重新获取,data-id 在页面刷新后会变) +info = json.loads(js(''' +var inputs = document.querySelectorAll("input"); +var result = []; +for (var i = 0; i < inputs.length; i++) { + var r = inputs[i].getBoundingClientRect(); + result.push({ + dataId: inputs[i].getAttribute("data-id"), + x: Math.round(r.left + r.width/2), + y: Math.round(r.top + r.height/2) + }); +} +var btn = document.querySelector("button"); +var br = btn ? btn.getBoundingClientRect() : null; +return JSON.stringify({inputs: result, btn: br ? {x: Math.round(br.left+br.width/2), y: Math.round(br.top+br.height/2)} : null}); +''')) + +u = info["inputs"][0]; p = info["inputs"][1]; c = info["inputs"][2]; b = info["btn"] + +# 注入 fillInput +js(''' +var ns = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set; +window.__fi = function(dataId, value) { + var el = document.querySelector("[data-id='" + dataId + "']"); + el.focus(); el.value = ""; + ns.call(el, value); + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + el.dispatchEvent(new Event("blur", { bubbles: true })); +}; +''') + +click_at_xy(u["x"], u["y"]); wait(0.2) +js('window.__fi("' + u["dataId"] + '", "")') +click_at_xy(p["x"], p["y"]); wait(0.2) +js('window.__fi("' + p["dataId"] + '", "")') +click_at_xy(c["x"], c["y"]); wait(0.2) +js('window.__fi("' + c["dataId"] + '", "")') + +# 验证字段都有值再登录 +vals = js(''' +var u = document.querySelector("[data-id='" + u["dataId"] + "']"); +var p = document.querySelector("[data-id='" + p["dataId"] + "']"); +var c = document.querySelector("[data-id='" + c["dataId"] + "']"); +return JSON.stringify({user: u?u.value:"?", pass: p?"***":"?", captcha: c?c.value:"?"}); +''') +print("Values:", vals) + +wait(0.3) +click_at_xy(b["x"], b["y"]) +wait(5) +wait_for_load() +url = js("window.location.href") +print("After login:", url) +# 如果仍在 /login,验证码错误或登录失败,重新识别验证码 +PY +``` + +登录成功后,后续所有 `browser-harness` 命令都继续使用 `export BU_CDP_URL=http://127.0.0.1:9223 &&` 前缀。 + +### 清理 + +任务完成后,杀 headless Chrome 释放资源: + +```bash +# Linux/macOS +pkill -f "chrome.*9223" 2>/dev/null +# Windows +taskkill /F /IM chrome.exe 2>nul +``` + +## Phase 2: 确定文档类型 + +### Case A — 通用接口文档 + +用户没提到具体流程名称(如"帮我写 E10 的人员同步接口"),直接跳到 Phase 3A。 + +### Case B — 流程实例接口文档 + +用户提到具体流程名称(如"帮我写创建采购申请流程实例的接口文档"),先执行 Phase 3B 发现流程字段,再执行 Phase 3A 查阅 API 文档。 + +## Phase 3A: 浏览 API 文档主页 + +> 以下所有 `browser-harness` 命令都需要 `export BU_CDP_URL=http://127.0.0.1:9223 &&` 前缀,为简洁省略。 + +导航到 `/sp/openapi/base/doc/index`(React SPA,内容由 JS 动态渲染)。 + +```bash +new_tab("https:///sp/openapi/base/doc/index") +wait(5) +wait_for_load() +wait(3) +capture_screenshot("/tmp/e10_api_doc_home.png") +PY +``` + +### 获取所有 API 文档链接 + +文档首页使用 `` 标签组成的网格。用 JS 提取所有链接,然后直接 `new_tab()` 导航到目标文档页: + +```bash +browser-harness <<'PY' +import json +links = json.loads(js(''' +var all = document.querySelectorAll("a"); +var result = []; +for (var i = 0; i < all.length; i++) { + var a = all[i]; + var txt = a.textContent.trim(); + if (txt.length > 0 && txt.length < 30 && a.href) { + result.push({text: txt, href: a.href}); + } +} +return JSON.stringify(result); +''')) +for l in links: + print(l["text"], "->", l["href"]) +PY +``` + +找到目标分类(如"工作流程")的 href,直接导航: + +```bash +browser-harness <<'PY' +new_tab("") # 如 /sp/opendoc/freepass/10.0.2508.01/zh_cn/840714220321120257 +wait(8) # React SPA 需要更长时间渲染 +wait_for_load() +wait(3) +PY +``` + +> **注意**:这些文档页是 React SPA,`curl` 只能拿到空壳 HTML(`
`)。必须用浏览器渲染后通过 `js("document.body.innerText")` 提取内容。 + +### 提取文档内容 + +文档是纯文本格式,用缩进表示参数层级。直接抓取全部 body 文本: + +```bash +browser-harness <<'PY' +content = js("document.body.innerText.substring(0, 15000)") +print(content[:5000]) +PY +``` + +如果内容被截断,滚动页面再提取后续部分: + +```bash +browser-harness <<'PY' +js("window.scrollBy(0, 800)") +wait(2) +content = js("document.body.innerText.substring(8000, 15000)") +print(content[:5000]) +PY +``` + +**⚠️ 注意接口名称**:流程相关的接口有两个容易混淆: +- **"新增流程"** — 创建流程**定义/模板**(管理员用,不要看这个) +- **"创建流程实例" / "工作流程"** — 创建流程**实例**(外部系统调用,看这个) + +文档分类中"工作流程"下的内容才是外部系统需要的流程实例创建接口。 + +## Phase 3B: 流程发现与字段检查 + +### Step 1: 查看所有流程 + +导航到 `/info/engine_wf/pathdef/list/company`。 + +```bash +browser-harness <<'PY' +new_tab("https:///info/engine_wf/pathdef/list/company") +wait(5) +wait_for_load() +wait(3) +capture_screenshot("/tmp/e10_workflows.png") +PY +``` + +页面展示流程卡片列表。确认目标流程是否存在。 + +### Step 2: 点击目标流程卡片(用坐标点击) + +JS `click()` 在流程卡片上经常失效。用坐标点击代替: + +```bash +browser-harness <<'PY' +import json +# 找到 "06.用车审批流程" 卡片的精确坐标 +info = json.loads(js(''' +var all = document.querySelectorAll("*"); +var result = []; +for (var i = 0; i < all.length; i++) { + var el = all[i]; + var txt = el.textContent.trim(); + // 找叶子节点(children.length === 0)且文本匹配 + if (txt === "06.用车审批流程" && el.children.length === 0) { + // 向上找卡片容器(width > 100, height > 40) + var p = el; + while (p && p.tagName !== "BODY") { + var r = p.getBoundingClientRect(); + if (r.width > 100 && r.height > 40) break; + p = p.parentElement; + } + if (p) { + var r = p.getBoundingClientRect(); + result.push({ + x: Math.round(r.left + r.width/2), + y: Math.round(r.top + r.height/2), + w: Math.round(r.width), h: Math.round(r.height) + }); + } + } +} +return JSON.stringify(result); +''')) +# 用坐标点击 +click_at_xy(info[0]["x"], info[0]["y"]) +wait(5) +wait_for_load() +print("URL:", js("window.location.href")) +capture_screenshot("/tmp/e10_workflow_detail.png") +PY +``` + +成功后将跳转到 `/info/engine_wf/pathdef/list/company/pathset//base`。URL 中的数字即为该流程的 workflowId。 + +### Step 3: 进入字段管理(多级标签页导航) + +流程详情页右上角标签栏结构: +``` +[基础设置] [功能设置] [表单管理] [布局管理] [更多] +``` + +点击 **"表单管理"** 标签(坐标约 y=38),URL 会变为 `.../formManager`。 + +然后在 **"表单管理"** 页面内,还有一组子标签: +``` +[基础信息] [字段设置] [数据设置] [布局设置] [其他设置] +``` + +以及另一组功能标签: +``` +[字段管理] [字段查重校验] [附件设置] [字段显示格式] [自定义e-code开发] [索引管理] +``` + +点击 **"字段管理"**(坐标约 y=74 的第二组标签),才能看到真正的字段列表。 + +> **注意**:左边栏菜单也有一个"字段管理",那是全局入口,坐标在 y≈58。不要点错 — 要点详情页内 y≈74 的那个。 + +```bash +browser-harness <<'PY' +import json +# Step 3a: 点击"表单管理"tab +info = json.loads(js(''' +var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, null, false); +var result = []; +while (walker.nextNode()) { + var el = walker.currentNode; + if (el.textContent.trim() === "表单管理" && el.children.length === 0) { + var r = el.getBoundingClientRect(); + result.push({x: Math.round(r.left + r.width/2), y: Math.round(r.top + r.height/2), w: r.width, h: r.height}); + } +} +return JSON.stringify(result); +''')) +# 选 y 坐标较大的那个(y>100 的是页面标签,y<100 的是侧边栏) +tab = [p for p in info if p["y"] > 30][0] if len(info) > 1 else info[0] +click_at_xy(tab["x"], tab["y"]) +wait(5) +wait_for_load() + +# Step 3b: 点击"字段管理"子标签 +info2 = json.loads(js(''' +// 同上找 y≈74 的元素 +var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, null, false); +var result = []; +while (walker.nextNode()) { + var el = walker.currentNode; + if (el.textContent.trim() === "字段管理" && el.children.length === 0) { + var r = el.getBoundingClientRect(); + result.push({x: Math.round(r.left + r.width/2), y: Math.round(r.top + r.height/2)}); + } +} +return JSON.stringify(result); +''')) +# 选 y 在 60-120 之间的(字段管理子标签),排除侧边栏菜单的 +subtab = [p for p in info2 if 60 < p["y"] < 120][0] +click_at_xy(subtab["x"], subtab["y"]) +wait(5) +capture_screenshot("/tmp/e10_fields_page1.png") +PY +``` + +### Step 4: 提取字段信息 + 切换主表/明细表 + +字段列表有三个子标签:**主表 | 明细表 | 其他**。默认显示主表。 + +**推荐方法:用内部 API 获取字段(Phase 3C)**,避免分页和 DOM 提取问题。DOM 提取方法仅在 Phase 3C 不可用时作为备选。 + +## Phase 3C: 通过内部 API 获取精确字段数据(推荐) + +> 以下所有 `browser-harness` 命令都需要 `export BU_CDP_URL=http://127.0.0.1:9223 &&` 前缀,为简洁省略。登录后 browser session cookie 自动携带。 + +> 完整的 API 列表和响应格式见 `references/internal-apis.md`。 + +### 完整链路(4 步,无需任何 UI 点击) + +登录后直接按以下顺序调用,全部走 `fetch()`: + +#### Step 1: 获取所有流程 → 找到目标 workflowId + +``` +POST /api/bs/workflow/pathdef/baseSet/getBaseInfoListTree +``` + +搜索目标流程名称(如用户指定"用车审批流程"),从返回的树形结构中获取 `workflowId`。 + +```bash +browser-harness <<'PY' +import json + +result = json.loads(js(''' +(async () => { + let resp = await fetch("/api/bs/workflow/pathdef/baseSet/getBaseInfoListTree", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + searchParams: { otherSearchDatas: { workflowType: "", workflowId: "", subCompanyId: "" } }, + isTemplate: 0, + belongType: 1 + }) + }); + return JSON.stringify(await resp.json()); +})(); +''')) + +# 按名称匹配目标流程 +target_name = "用车审批流程" +for group in result["data"]["datas"]: + for child in group["children"]: + if target_name in child["title"]: + wf_id = child["id"] + print(f"找到: {child['title']} | workflowId={wf_id} | 分类={group['typeName']}") +PY +``` + +#### Step 2: 搜索表单 → 获取 formId + +``` +POST /api/workflow/core/form/formmanage/getFormList +``` + +表单名称可能与流程名称不同(如流程叫"06.用车审批流程",表单叫"用车审批流程")。用流程名称的**核心关键词**搜索。 + +```bash +browser-harness <<'PY' +import json + +result = json.loads(js(''' +(async () => { + let resp = await fetch("/api/workflow/core/form/formmanage/getFormList", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ module: "workflow", pageNo: 1, pageSize: 50, name: "用车" }) + }); + return JSON.stringify(await resp.json()); +})(); +''')) + +for f in result["data"]["pageDatas"]["result"]: + if target_name in f.get("name", ""): + form_id = f["id"] + print(f"表单: {f['name']} | formId={form_id}") +PY +``` + +> **备选**:如果 `getFormList` 不可用,可从 `localStorage` 提取 formId。访问表单管理页面后,`localStorage` 会有 key `Form_0__checkNewForm`。 + +#### Step 3: 获取表单详情 → 确认表名和类型 + +``` +POST /api/workflow/core/form/formmanage/getForm +``` + +```bash +browser-harness <<'PY' +import json + +result = json.loads(js(''' +(async () => { + let resp = await fetch("/api/workflow/core/form/formmanage/getForm", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ module: "workflow", form: { id: "" } }) + }); + return JSON.stringify(await resp.json()); +})(); +''')) +data = result["data"] +print(f"表名: {data['tableName']} | 类型: {data['tableType']} | 名称: {data['name']}") +# 检查是否有明细表 +dts = data.get("detailTable", []) +print(f"明细表数量: {len(dts)}") +for dt in dts: + print(f" 明细表: {dt.get('name')} | id: {dt.get('id')}") +PY +``` + +#### Step 4: 获取字段列表 → 全部字段一次拿到 + +``` +POST /api/workflow/core/form/field/manage/getFormFieldPage +``` + +```bash +browser-harness <<'PY' +import json + +result = json.loads(js(''' +(async () => { + let resp = await fetch("/api/workflow/core/form/field/manage/getFormFieldPage", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + pageNo: 1, + pageSize: 20, + module: "workflow", + formFieldSearchEntity: { + isDelete: 0, + status: "enable", + formId: "", + formTableId: "" + } + }) + }); + return JSON.stringify(await resp.json()); +})(); +''')) + +records = result["data"]["pageDatas"]["result"] +print(f"主表字段: {len(records)} 个") +for r in records: + print(f" {r['title']} | key={r['dataKey']} | type={r['type']} | fieldId={r['fieldId']} | sort={r['showOrder']} | group={r['groupName']}") +PY +``` + +> **重要参数细节**:`pageNo` 不是 `page`,`pageSize` 不是 `size`。`formFieldSearchEntity` 是必传嵌套对象,至少含 `formId` 和 `isDelete: 0`。主表字段需额外传 `formTableId`。 + +#### Step 5: 获取 formTableId + 明细表字段 + +**主表 formTableId** 从 Step 4 返回的任意字段中提取(每个字段都有 `formTableId` 属性)。 + +**明细表**:需要获取明细表的 `formTableId`。方法: +- 先通过 UI 点击切换一次 明细表 标签,同时用 XHR 拦截捕获请求体中的 `formTableId`(见下方 Fallback) +- 如果页面显示"您暂时还没有明细表",说明该流程无明细表 + +**XHR 拦截获取 formTableId(Fallback)**: + +```bash +browser-harness <<'PY' +import json + +# 注入 XHR 拦截器 +js(''' +window.__BH_XHR = []; +var OrigXHR = window.XMLHttpRequest; +window.XMLHttpRequest = function() { + var xhr = new OrigXHR(); + var origOpen = xhr.open, origSend = xhr.send; + xhr.open = function(m, u) { this.__url = u; return origOpen.apply(this, arguments); }; + xhr.send = function(b) { window.__BH_XHR.push({url: this.__url, body: b}); return origSend.apply(this, arguments); }; + return xhr; +}; +''') + +# 触发 API 调用:鼠标事件点击明细表标签(MouseEvent 比 click_at_xy 更可靠) +js(''' +var divs = document.querySelectorAll("div"); +for (var i = 0; i < divs.length; i++) { + if (divs[i].textContent.trim() === "明细表" && divs[i].className.indexOf("menu") !== -1) { + divs[i].dispatchEvent(new MouseEvent("click", {bubbles: true})); + break; + } +} +''') +wait(4) + +xhr = json.loads(js('JSON.stringify(window.__BH_XHR || [])')) +for r in xhr: + if "getFormFieldPage" in str(r.get("url", "")): + print("Body:", r["body"]) # 从中提取 formTableId +PY +``` + +得到明细表 `formTableId` 后,用 Step 4 相同的 API(换 `formTableId`)即可获取明细表字段。返回空数组则说明明细表为空。这在文档中注明即可,不要报错。 + +#### UI Fallback(仅在 API 不可用时使用) + +如果上述 API 全部不可用(极少数情况),回退到 Phase 3B 的坐标点击 + DOM 提取方案。但优先尝试 API。 + +#### 备选:DOM 提取字段(不推荐) + +```bash +browser-harness <<'PY' +import json +fields = json.loads(js(''' +var rows = document.querySelectorAll("table tr"); +var result = []; +for (var i = 0; i < rows.length; i++) { + var cells = rows[i].querySelectorAll("td"); + if (cells.length >= 5) { + var rowData = []; + for (var j = 0; j < cells.length; j++) { + rowData.push(cells[j].textContent.trim()); + } + result.push(rowData); + } +} +return JSON.stringify(result); +''')) +print("Total:", len(fields)) +for row in fields: + print("|", " | ".join(row[:8]), "|") +PY +``` + +**切换明细表**:找文本为"明细表"的 SPAN 元素,点击其父 DIV: + +```bash +browser-harness <<'PY' +# 先确保"明细表"tab 不处于 active 状态(避免重复点击) +import json +active = json.loads(js(''' +var el = document.querySelector("[class*='ui-menu-list-item-active']"); +return el ? el.textContent.trim() : "none"; +''')) +# 如果当前是"主表"active,切换到"明细表" +if active == "主表": + js(''' + var divs = document.querySelectorAll("div"); + for (var i = 0; i < divs.length; i++) { + if (divs[i].textContent.trim() === "明细表" && divs[i].className.indexOf("menu") !== -1) { + divs[i].dispatchEvent(new MouseEvent("mousedown", {bubbles: true})); + divs[i].dispatchEvent(new MouseEvent("mouseup", {bubbles: true})); + divs[i].dispatchEvent(new MouseEvent("click", {bubbles: true})); + return true; + } + } + return false; + ''') + wait(3) +capture_screenshot("/tmp/e10_detail_table.png") +# 再次提取字段 +fields = json.loads(js('''...''')) # 同上 +PY +``` + +明细表可能为空(显示"您暂时还没有明细表"),这是正常的。在文档中注明即可。 + +## Phase 4: Token 获取(如 API 需要) + +E10 OpenAPI 使用 **OAuth2 code 换 access_token** 模式(非 Bearer token header)。 + +### Step 1: 获取 code + +- **URL**: `POST /papi/openapi/oauth2/authorize` +- **Method**: POST +- **Content-Type**: application/json + +**请求参数**: + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| corpid | String | 是 | 企业 corpId,开发者资料中查看 | +| response_type | String | 是 | 固定为 `code` | +| state | String | 是 | 自定义参数,a-zA-Z0-9,最长128字节 | + +**响应**: `{"errcode": "0", "code": "..."}` — code 有效期 10 分钟且只能使用一次。 + +### Step 2: code 换 access_token + +access_token 作为请求参数(**不是 Header**)传入 API,例如: +```json +{"access_token": "210b03...7c7a", "userid": 123, "workflowId": "456"} +``` + +Access_token 交换接口在"认证"分类下的独立子页面(文档 ID 与获取 code 不同)。从"对接说明"页面可找到"获取accessToken信息"链接。接口详情: +- **URL**: `POST /papi/openapi/oauth2/access_token` +- **参数**: app_key(应用 key), app_secret(应用密钥), grant_type="authorization_code", code +- **响应**: `{"errcode":"0", "accessToken":"...", "refreshToken":"...", "expires_in":7200}` +- access_token 有效期 2 小时 + +在输出 MD 的"前置条件"章节中写入 OAuth2 两步流程。 + +## Phase 5: 文件上传(如 API 涉及文件) + +如果目标 API 接受文件参数(附件字段),需要查阅文件上传接口。文件上传文档在 **"知识文档" → "文件上传"** 分类下,不是独立的一级入口。 + +从 Phase 3A 获取的链接列表中找"知识文档"或"文件上传"相关链接,直接 `new_tab()` 导航获取上传 API 规范。 + +常见上传模式: +- 先调用文件上传接口获取 `fileId` / `attachmentId` +- 再在业务 API 的 `formData.dataDetails` 中通过 `dataOptions` 引用(带 `uploadParam` 和 `uploadType: "loadUrl"`) + +在输出 MD 中加入"前置条件 — 文件上传"章节。 + +## Phase 6: 生成 MD 文档 + +### 文档定位 + +这份文档 **99% 的读者是外部公司的开发人员**,他们只关心怎么调接口、传什么参数、返回什么。他们不关心也不该看到 E10 系统内部的任何信息。 + +### 文档规范(必读) + +**URL 规范**: +- 所有示例 URL 使用占位符 `https://`,**禁止出现真实 IP 地址** +- `access_token` 等其他敏感值也使用占位符 `""`、`""` 等 + +**禁止出现的内容**: +- E10 系统版本号(如"10.0.2508.01") +- 数据库表名(如 `ft_hysydlc`)— 外部调用者不需要知道 +- 内部 API 路径(如 `/api/bs/workflow/...`)— 这些不是 OpenAPI +- E10 内部管理界面的操作说明(如"登录开放平台") +- "未分组"、"辅助信息" 等 E10 界面的内部分组名 — 字段按逻辑含义分组 +- Python urlencode `+` 号替换 `%20` — 这是内部已知的 quirks,不是 API 规范 +- "数据来源"、"文档生成时间" 等元信息 +- 任何"注意"、"提示"、"陷阱" 等口语化标记 — 用规范的表格或说明代替 +- 任何 E10 内部系统的名词和操作流程 + +**必须包含**: +- 完整的鉴权流程(OAuth2 code → access_token),包括每个步骤的 URL、参数、响应 +- 鉴权方式说明:access_token 作为**请求体参数**传入,不是 HTTP Header +- **身份标识转换章节**(非常关键):外部系统不持有 E10 内部 ID,必须说明如何用工号、部门编号等外部标识替代内部 ID。包括: + - 顶层 `userType` 参数:JOB_NUM / EMAIL / MOBILE / idNos / loginID / account + - 顶层 `deptType` 参数:DEPT_CODE / DEPT_NAME + - 表单 dataOptions 中的 `userType` / `deptType` 用法(Employee/Department 类型字段) +- 每个参数的类型、必填、说明 +- curl 请求示例(至少 2 个:最小请求 + 完整请求),**示例中必须使用 userType/DEPT_CODE 等外部标识而非内部 ID** +- 响应示例和错误码 + +**字段列表规范**: +- 不写内部 API 返回的 groupName(如"未分组"、"基础信息"),按字段的实际业务含义分组 +- 字段表头统一:`| 字段名 | 类型 | 必填 | 说明 |` +- 关联型字段在"说明"列注明取值方式(如"dataOptions 传值,type=resource") +- 如果流程无明细表,直接省略明细表章节,不要写"无明细表" + +**模板**: + +```markdown +# <接口名称> + +## 接口说明 +<一句话描述接口用途> + +## 前置条件 + +### 获取 access_token + +#### Step 1: 获取 code + +| 参数 | 必填 | 类型 | 说明 | +|------|------|------|------| +| ... | ... | ... | ... | + +```json +// 请求 +{ ... } +// 响应 +{ ... } +``` + +#### Step 2: code 换 access_token + +| 参数 | 必填 | 类型 | 说明 | +|------|------|------|------| +| ... | ... | ... | ... | + +(如果有文件上传需求,在此加 "### 文件上传" 章节) + +## 请求信息 + +- **URL**: `POST https:///papi/openapi/xxx` +- **Content-Type**: `application/json` + +## 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| access_token | String | 是 | 接口调用凭证(请求体参数,不是 Header) | +| ... | ... | ... | ... | + +## 请求示例 + +### 最小请求 + +```bash +curl -X POST "https:///papi/openapi/xxx" \ + -H "Content-Type: application/json" \ + -d '{ ... }' +``` + +### 完整请求 + +```bash +curl -X POST "https:///papi/openapi/xxx" \ + -H "Content-Type: application/json" \ + -d '{ ... }' +``` + +## 响应参数 + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| errcode | String | 返回码,"0" 表示成功 | +| ... | ... | ... | + +## 响应示例 + +### 成功 + +```json +{ ... } +``` + +### 失败 + +```json +{ ... } +``` + +## 错误码 + +| 错误码 | 说明 | +|--------|------| +| 0 | 成功 | +| ... | ... | + +## 流程字段说明 + +### <字段分组名> + +| 字段名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| ... | ... | ... | ... | +``` + +文件保存到用户指定的目录,文件名 `E10_<流程名称>_API.md`。 + +## Pitfalls + +0. **API 优先,UI 兜底**:登录后能用内部 API 解决的就别去点 UI。页面有多级标签、坐标点击不稳定、分页控件难操作。推荐链路:`getBaseInfoListTree` → `getFormList` → `getForm` → `getFormFieldPage`。详见 `references/internal-apis.md`。 + +1. **SPA 渲染延迟**:E10 是动态渲染页面,每次导航后必须 `wait(3-5)` 再截图/提取内容。API 文档页是 React SPA,需要 `wait(8)` 以上才能渲染完成。 + +2. **登录必须用 native setter + 事件派发**:E10 表单框架不响应直接 `js().value = "..."` 赋值。必须:点击输入框获得焦点 → `Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set` 设值 → 派发 `input`/`change`/`blur` 事件。 + +3. **用 `data-id` 定位 input**:E10 的 input 元素没有有意义的 name/placeholder,但每个都有唯一的 `data-id`(如 `ui-input-fxv2kf`)。先 `js()` 提取所有 input 的 data-id,再用 `[data-id='xxx']` 选择器。 + +4. **流程卡片点击用坐标不用 JS click**:`el.closest("[class*=card]").click()` 在流程列表卡片上经常失效。用树遍历找到叶子文本节点 → 向上找容器 → `getBoundingClientRect()` → `click_at_xy()`。 + +5. **多级标签页导航容易点错**:流程详情页的"字段管理"入口不在侧边栏菜单,而在页面内部的多级标签中: + - 一级标签:基础设置 | 功能设置 | **表单管理** | 布局管理 | 更多 + - 点击"表单管理"后 → 二级子标签:字段管理 | 字段查重校验 | 附件设置 | ... + - 点击"字段管理"后 → 三级标签:**主表** | **明细表** | 其他 + - 左右两侧都有"表单管理"/"字段管理"菜单,坐标不同,要点右侧详情面板内的 + +6. **"字段设置" ≠ "字段管理"**:两个子标签名称极其相似,但功能完全不同。"字段设置"只有三个开关(系统设置/自定义),"字段管理"才是真正的字段列表。不要点错。 + +7. **分页提取不可靠**:字段列表的分页控件用 JS click 和坐标点击都可能失效。**直接用 API 的 `pageSize=20` 一次拿全部字段,不要跟分页控件较劲。** + +8. **API 文档链接在 `` 标签 href 中**:`/sp/openapi/base/doc/index` 的每个分类入口是 ``。用 `js()` 提取所有链接后 `new_tab()` 直接导航,不要尝试 JS click。 + +9. **文档页是纯文本格式**:提取内容用 `js("document.body.innerText")` 获取全部文本。参数层级用缩进区分(类似 YAML),用 `scrollBy()` 分段提取长文档。 + +10. **OAuth2 文档分两页,不在同一页面**:"认证"分类下有两个独立子页面: + - 获取 code:文档 ID 不同于获取 token 的页面 + - 获取 accessToken:另一个文档 ID + 从"对接说明"页面可找到两个链接。不要以为"认证"一个页面就包含完整流程,找不到第二页就去"对接说明"找链接。两个接口: + - Step 1: `POST /papi/openapi/oauth2/authorize`(code 有效期 10 分钟) + - Step 2: `POST /papi/openapi/oauth2/access_token`(参数 app_key, app_secret, grant_type="authorization_code", code) + Token 是 OAuth2 模式,不是 Bearer。access_token 作为请求参数(不是 Header)传入。 + +11. **登录失败静默检测**:登录后务必检查 URL 是否跳转到主页而非留在 login 页面,否则后续所有操作都会拿到登录页 HTML。 + +11b. **验证码固定 4 位数,少于 4 位就是识别不全**:E10 验证码始终 4 位数字。`vision_analyze` 可能漏读。如果返回少于 4 位,点击验证码图片刷新后重新截图识别。填完所有字段后、点击登录前,用 `js()` 验证三个 input 的 value 都有值。 + +12. **明细表检测优先用 getForm API**:`getForm` 返回的 `data.detailTable` 数组直接告诉你是否有明细表(空数组 = 无明细表)。比 Phase 3B 的 XHR 拦截 + UI 点击方案更简单可靠。XHR 方案仅作为备选。 + +13. **内部 API 用 XHR 不是 fetch**:E10 前端调用内部 API 用的是 `XMLHttpRequest`。如果要拦截请求体,必须 hook `XMLHttpRequest.prototype.send`。 + +14. **内部 API 参数命名不同**:`pageNo`(不是 `page`)、`pageSize`(不是 `size`)。`formFieldSearchEntity` 是必传嵌套对象,至少含 `formId` 和 `isDelete: 0`。 + - **formTableId 陷阱**:主表查询时传 `formTableId` 可能返回 0 条记录,**先不传 formTableId 尝试**。 + +15. **workflowId ≠ formId**:工作流 ID(pathSetId)和表单 ID 是不同的实体。`getBaseInfoListTree` 返回的是 workflowId,`getFormList` 返回的是 formId。两者不能混用。 + +16. **表单名称与流程名称可能不同**:流程名可能带编号前缀(如"06.用车审批流程"),表单名可能不带(如"用车审批流程")。搜索表单时用核心关键词模糊匹配。 + +17. **"新增流程" ≠ "创建流程实例"**:API 文档中这两个是不同的接口。"新增流程"是创建流程定义/模板(管理员用),外部系统调用要看"工作流程"分类下的流程实例创建接口。 + +18. **文件上传文档在"知识文档"分类下**:不是独立的顶级入口。从文档首页链接列表中找到"知识文档"或"文件上传"即可。 + +19. **身份标识转换是必写章节**:外部系统的开发人员不知道 E10 内部的用户 ID / 部门 ID。文档必须包含独立的"身份标识转换"章节,说明 `userType`(JOB_NUM/EMAIL/MOBILE/idNos/loginID/account)和 `deptType`(DEPT_CODE/DEPT_NAME)的用法。请求示例中全部使用外部标识(如工号 `"EMP001"`、部门编号 `"DEPT_TECH"`),不要出现 `` 这样的占位符。 + +20. **daemon 持久存活 + 环境变量陷阱**:`browser-harness` 的 daemon 是独立进程,跨命令存活。`export BU_CDP_URL=...` 然后下一行 `browser-harness` 不会生效。必须写成一行:`export BU_CDP_URL=http://127.0.0.1:9223 && browser-harness`。每次新开任务前清理旧 daemon。 + +21. **headless 截图可能首次空白**:headless Chrome 的 CSS 背景图可能首次渲染不完整,`vision_analyze` 返回"纯白"。此时重新 `capture_screenshot` 再识别即可。 + +22. **CloakBrowser 不兼容**:CloakBrowser 是 Playwright 封装,不走 CDP 协议,无法与 browser-harness 对接。 + +23. **Windows 兼容**:本 skill 分享给同事时,Windows 上需调整:① Chrome 路径 `"C:\Program Files\Google\Chrome\Application\chrome.exe"`,② 临时目录用 `%TEMP%`,③ 环境变量用 `set` 而非 `export`,④ `pkill`/`rm` 换成 `taskkill`/`del`。 diff --git a/references/form-field-api.md b/references/form-field-api.md new file mode 100644 index 0000000..90cf06a --- /dev/null +++ b/references/form-field-api.md @@ -0,0 +1,98 @@ +# E10 内部 API: getFormFieldPage + +## 端点 + +``` +POST /api/workflow/core/form/field/manage/getFormFieldPage +``` + +浏览器内调用,自动带 session cookie 认证。 + +## 请求体 + +```json +{ + "pageNo": 1, + "pageSize": 20, + "module": "workflow", + "formFieldSearchEntity": { + "isDelete": 0, + "status": "enable", + "formId": "1185512764483297325", + "formTableId": "1185512764483297435" + } +} +``` + +| 参数 | 说明 | +|------|------| +| pageNo | 页码(非 `page`) | +| pageSize | 每页条数(非 `size`) | +| module | 固定 `workflow` | +| formFieldSearchEntity.isDelete | 0 | +| formFieldSearchEntity.status | `enable` | +| formFieldSearchEntity.formId | 表单 ID | +| formFieldSearchEntity.formTableId | 表 ID(主表 vs 明细表);主表必传,明细表传对应的 tableId | + +## 响应结构 + +```json +{ + "code": 200, + "msg": "接口返回成功", + "status": true, + "data": { + "pageDatas": { + "pageNo": 1, + "pageSize": 20, + "result": [ + { + "title": "申请人", + "dataKey": "sqr", + "type": "Employee", + "fieldId": "1185512764483297398", + "formId": "1185512764483297325", + "formTableId": "1185512764483297435", + "showOrder": 0, + "groupName": "基础信息", + "columnName": "sqr", + "columnType": "MEDIUMTEXT", + "systemField": false, + "componentKey": "Employee" + } + ] + } + } +} +``` + +## 获取 formId 的方法 + +### 方法 1: localStorage + +``` +localStorage key: Form_0__checkNewForm +``` + +示例: `Form_0_1185512764483297325_checkNewForm` → formId = `1185512764483297325` + +### 方法 2: XHR 拦截 + +注入 `XMLHttpRequest` 钩子,然后在页面上切换 主表/明细表 tab 触发 API 调用,从捕获的请求体中提取。 + +### 方法 3: 页面 URL 提取 + +流程详情页 URL: `/info/engine_wf/pathdef/list/company/pathset//base` + +## 已知示例 + +来自"06.用车审批流程"(浙江聿见纺织科技 E10 v10.0.2508.01): + +| 属性 | 值 | +|------|-----| +| formId | 1185512764483297325 | +| formTableId (主表) | 1185512764483297435 | +| workflowId | 1185512781721886720 | +| 数据库表 | fl_ycsplc | +| 主表字段数 | 13 | +| 明细表字段数 | 0(无明细表) | diff --git a/references/internal-apis.md b/references/internal-apis.md new file mode 100644 index 0000000..7a58031 --- /dev/null +++ b/references/internal-apis.md @@ -0,0 +1,257 @@ +# E10 内部 API 参考 + +这些是 E10 管理后台前端调用的内部 REST API(非 OpenAPI),在浏览器已登录的情况下可直接用 `fetch()` 调用,session cookie 自动携带。适合替代复杂的 UI 点击操作获取数据。 + +> **适用场景**:登录后(Phase 1),所有流程发现、表单查询、字段提取均可通过这些 API 完成,无需逐层点击 UI。 + +--- + +## 1. 获取所有工作流列表 + +**用途**:按分类树形列出所有工作流,找到目标流程的 `workflowId`(也叫 pathSetId)。 + +``` +POST /api/bs/workflow/pathdef/baseSet/getBaseInfoListTree +``` + +**请求体**: + +```json +{ + "searchParams": { + "otherSearchDatas": { + "workflowType": "", + "workflowId": "", + "subCompanyId": "" + } + }, + "isTemplate": 0, + "belongType": 1 +} +``` + +**响应结构**: + +```json +{ + "code": 200, + "data": { + "total": 42, + "dataGroupTitle": [ + {"typeName": "行政类", "count": 9, "id": "1184726218683441153"} + ], + "datas": [ + { + "typeName": "行政类", + "children": [ + { + "id": "1185512781721886720", + "title": "06.用车审批流程", + "defaultWorkflowName": "06.用车审批流程", + "status": "1" + } + ] + } + ] + } +} +``` + +**关键字段**: +- `datas[].children[].id` — workflowId(pathSetId),用于后续查询 +- `datas[].children[].title` — 流程名称 +- `datas[].typeName` — 流程分类(人事类/行政类/财务类/业务类) + +--- + +## 2. 搜索表单(按名称) + +**用途**:根据表单名称搜索,获取 `formId`。名称支持模糊匹配。 + +``` +POST /api/workflow/core/form/formmanage/getFormList +``` + +**请求体**: + +```json +{ + "module": "workflow", + "pageNo": 1, + "pageSize": 50, + "name": "用车" +} +``` + +**响应结构**: + +```json +{ + "code": 200, + "data": { + "pageDatas": { + "result": [ + { + "id": "1185512764483297325", + "name": "用车审批流程" + } + ] + } + } +} +``` + +**关键字段**: +- `data.pageDatas.result[].id` — formId +- `data.pageDatas.result[].name` — 表单名称 + +> **注意**:流程名称(如"06.用车审批流程")与表单名称(如"用车审批流程")可能不同(有无编号前缀)。搜索时去掉编号前缀。 + +--- + +## 3. 获取表单详情 + +**用途**:获取表单的元数据(表名、类型、基础设置)。 + +``` +POST /api/workflow/core/form/formmanage/getForm +``` + +**请求体**: + +```json +{ + "module": "workflow", + "form": { + "id": "1185512764483297325" + } +} +``` + +**响应结构**: + +```json +{ + "code": 200, + "data": { + "id": "1185512764483297325", + "tableName": "ft_ycsplc", + "name": "用车审批流程", + "tableType": "MAIN", + "systemTable": false, + "editTable": true + } +} +``` + +**关键字段**: +- `data.tableName` — 数据库表名 +- `data.tableType` — MAIN(主表)或 DETAIL(明细表) +- `data.name` — 表单名称 + +--- + +## 4. 获取表单字段列表(分页) + +**用途**:获取主表或明细表的所有字段定义(含 fieldId、dataKey、类型、分组)。 + +``` +POST /api/workflow/core/form/field/manage/getFormFieldPage +``` + +**请求体(主表字段)**: + +```json +{ + "pageNo": 1, + "pageSize": 20, + "module": "workflow", + "formFieldSearchEntity": { + "isDelete": 0, + "status": "enable", + "formId": "1185512764483297325", + "formTableId": "1185512764483297435" + } +} +``` + +**请求体(明细表字段)**:同上,`formTableId` 替换为明细表的 table ID。 + +**响应结构**: + +```json +{ + "code": 200, + "data": { + "pageDatas": { + "pageNo": 1, + "pageSize": 20, + "result": [ + { + "title": "申请人", + "dataKey": "sqr", + "type": "Employee", + "fieldId": "1185512764483297398", + "showOrder": 0, + "groupName": "基础信息", + "columnName": "sqr", + "formTableId": "1185512764483297435", + "formId": "1185512764483297325" + } + ] + } + } +} +``` + +**关键字段**: +- `title` — 字段显示名称 +- `dataKey` — 字段编码(API 传参用) +- `fieldId` — 字段 ID(API 传参用,纯数字) +- `type` — 字段类型(Employee/Date/Text/TextArea/Department/SubCompany/File/EBuilder/Flow/Document) +- `showOrder` — 显示顺序 +- `groupName` — 所属分组 +- `columnName` — 数据库列名 + +**常见字段类型映射**: + +| API type | 中文含义 | formData 传值方式 | +|----------|----------|-------------------| +| Employee | 人员选择 | `dataOptions[].type: "resource"` | +| Department | 部门选择 | `dataOptions[].type: "department"` | +| SubCompany | 分部选择 | `dataOptions[].type: "subcompany"` | +| Date | 日期 | `content: "2026-05-27"` | +| Text | 单行文本 | `content: "文本值"` | +| TextArea | 多行文本 | `content: "文本值"` | +| File | 附件 | `dataOptions[].type: "file"` + uploadParam | +| EBuilder | 关联e-builder | 选项型传值 | +| Flow | 关联流程 | 选项型传值 | +| Document | 关联文档 | 选项型传值 | + +--- + +## 5. 获取 formTableId(主表和明细表) + +formTableId 可以从以下途径获取: + +1. **XHR 拦截**:在字段管理页面切换 主表/明细表 标签时,页面会调用 `getFormFieldPage`,其请求体中的 `formTableId` 即为当前表的 ID。 +2. **字段响应反推**:主表任一字段的 `formTableId` 即为主表的 table ID。 +3. **明细表为空时**:不需要 formTableId。 + +--- + +## formId 的其他获取方式 + +除了 `getFormList` API 搜索,还可从以下途径获取 formId: + +1. **localStorage**:访问过表单管理页面后,`localStorage` 中会有 key `Form_0__checkNewForm`,从中提取数字部分。 + + ```js + for (var key in localStorage) { + if (key.startsWith("Form_0_") && key.endsWith("_checkNewForm")) { + return key.replace("Form_0_", "").replace("_checkNewForm", ""); + } + } + ``` + +2. **URL 路径**(不可靠,仅作参考):流程详情页 URL 中的 `pathset/` 后数字是 workflowId,不是 formId。