add skill files

This commit is contained in:
2026-05-31 19:47:50 +08:00
parent 86df2672d3
commit 3d8df9b51f
3 changed files with 1304 additions and 0 deletions
+949
View File
@@ -0,0 +1,949 @@
---
name: fanwei-e10-api-doc
description: 泛微OA E10 系统接口文档撰写 — 自动登录、浏览 API 文档、发现流程字段、生成 MD 文档。
---
# 泛微OA E10 接口文档撰写
## ⛔ 硬性约束:只读操作
**绝对禁止对 E10 系统进行任何修改操作。** 包括但不限于:
- 不创建、编辑、删除流程定义
- 不修改表单字段配置
- 不新增、编辑、删除任何系统数据
- 不调用任何写操作的 APIPOST/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 |
|------|-------|---------|
| `<CHROME>` | `google-chrome` | `"C:\Program Files\Google\Chrome\Application\chrome.exe"` |
| `<TEMP_DIR>` | `/tmp/bh-e10` | `%TEMP%\bh-e10` |
| `<SCREENSHOT_DIR>` | `/tmp` | `%TEMP%` |
| `<KILL_DAEMON>` | `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 跨命令持久存活,残留的会连到用户浏览器)
<KILL_DAEMON>
# 2. 启动独立 headless Chrome
<CHROME> --headless=new --remote-debugging-port=9223 --user-data-dir=<TEMP_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://<E10_BASE>/login")
wait_for_load()
capture_screenshot("<SCREENSHOT_DIR>/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("<SCREENSHOT_DIR>/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("<SCREENSHOT_DIR>/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"] + '", "<USERNAME>")')
click_at_xy(p["x"], p["y"]); wait(0.2)
js('window.__fi("' + p["dataId"] + '", "<PASSWORD>")')
click_at_xy(c["x"], c["y"]); wait(0.2)
js('window.__fi("' + c["dataId"] + '", "<CAPTCHA>")')
# 验证字段都有值再登录
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://<E10_BASE>/sp/openapi/base/doc/index")
wait(5)
wait_for_load()
wait(3)
capture_screenshot("/tmp/e10_api_doc_home.png")
PY
```
### 获取所有 API 文档链接
文档首页使用 `<A>` 标签组成的网格。用 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("<TARGET_HREF>") # 如 /sp/opendoc/freepass/10.0.2508.01/zh_cn/840714220321120257
wait(8) # React SPA 需要更长时间渲染
wait_for_load()
wait(3)
PY
```
> **注意**:这些文档页是 React SPA`curl` 只能拿到空壳 HTML`<div id="root">`)。必须用浏览器渲染后通过 `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://<E10_BASE>/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/<WORKFLOW_ID>/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_<formId>_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: "<FORMID>" } })
});
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: "<FORMID>",
formTableId: "<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 拦截获取 formTableIdFallback**
```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://<E10_BASE>`**禁止出现真实 IP 地址**
- `access_token` 等其他敏感值也使用占位符 `"<ACCESS_TOKEN>"``"<USERID>"`
**禁止出现的内容**
- 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://<E10_BASE>/papi/openapi/xxx`
- **Content-Type**: `application/json`
## 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| access_token | String | 是 | 接口调用凭证(请求体参数,不是 Header) |
| ... | ... | ... | ... |
## 请求示例
### 最小请求
```bash
curl -X POST "https://<E10_BASE>/papi/openapi/xxx" \
-H "Content-Type: application/json" \
-d '{ ... }'
```
### 完整请求
```bash
curl -X POST "https://<E10_BASE>/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 文档链接在 `<A>` 标签 href 中**`/sp/openapi/base/doc/index` 的每个分类入口是 `<a href="/sp/opendoc/...">`。用 `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**:工作流 IDpathSetId)和表单 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"`),不要出现 `<USERID>` 这样的占位符。
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`。
+98
View File
@@ -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_<formId>_checkNewForm
```
示例: `Form_0_1185512764483297325_checkNewForm` → formId = `1185512764483297325`
### 方法 2: XHR 拦截
注入 `XMLHttpRequest` 钩子,然后在页面上切换 主表/明细表 tab 触发 API 调用,从捕获的请求体中提取。
### 方法 3: 页面 URL 提取
流程详情页 URL: `/info/engine_wf/pathdef/list/company/pathset/<WORKFLOW_ID>/base`
## 已知示例
来自"06.用车审批流程"(浙江聿见纺织科技 E10 v10.0.2508.01:
| 属性 | 值 |
|------|-----|
| formId | 1185512764483297325 |
| formTableId (主表) | 1185512764483297435 |
| workflowId | 1185512781721886720 |
| 数据库表 | fl_ycsplc |
| 主表字段数 | 13 |
| 明细表字段数 | 0(无明细表) |
+257
View File
@@ -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` — workflowIdpathSetId),用于后续查询
- `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_<formId>_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。