feat(external-systems): 优化管理端配置表单
This commit is contained in:
parent
2b4316ed25
commit
8bda11f683
|
|
@ -5,6 +5,10 @@
|
|||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||
|
||||
## 0.63.5 — 2026-08-11
|
||||
|
||||
- 管理员配置外部系统时改用独立弹框,并会根据 OpenAPI、MCP 和认证方式只展示对应配置项;外部系统列表同步显示各连接类型真实的能力模式。
|
||||
|
||||
## 0.63.4 — 2026-08-10
|
||||
|
||||
- 外部系统管理统一为通用 OpenAPI 和 MCP 配置,不再需要选择特定 MES 类型;已有 MES 定义可通过随版本提供的一次性脚本转换,用户连接和加密凭据保持不变。
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||
|
||||
最后更新:2026-08-10(外部系统统一为通用 OpenAPI/MCP,bump 0.63.4)
|
||||
最后更新:2026-08-11(外部系统配置弹框与动态字段,bump 0.63.5)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -21,6 +21,10 @@
|
|||
|
||||
## 已完成关键能力
|
||||
|
||||
### 2026-08-11
|
||||
|
||||
- **08-11 / 0.63.5 / 外部系统配置弹框 + 动态字段**:管理后台外部系统定义由页面平铺表单改为新增/编辑弹框,按 OpenAPI/MCP connector 和认证方式只展示、提交当前适用字段;MCP URL 自动提供认证同源基址,新建时不再重复要求 Base URL,编辑存量定义继续保留既有基址,列表能力模式对 MCP 改为动态 `tools/list`。Web 无 DB 路由 23 项、JavaScript 语法、动态 DOM 引用及 diff 检查通过;当前运行时未发现可连接浏览器,真实点击冒烟待浏览器配置恢复后补验。无 schema、migration、HTTP API、依赖或运行方式变化,未连接生产 DB。
|
||||
|
||||
### 2026-08-10
|
||||
|
||||
- **08-10 / 0.63.4 / 移除 Factory preset + 通用 OpenAPI 契约收敛**:运行态和管理端删除 `factory_mes` provider、兼容包装类及业务默认文案,只保留通用 OpenAPI/MCP definition;新增显式 `ZCBOT_MIGRATION_DB_URL`、默认 dry-run 的一次性脚本,将存量 definition 原子转换为 `generic_openapi + query` 并同步 active connection revision,不解密或重写凭据。OpenAPI 参数直接执行规格中的数值、长度、数组和枚举约束,移除按 `page/page_size/pageoff` 名称猜测分页语义及无效 `max_page_size` 配置。完整 524 项 unittest 全绿(17 skip),外部系统/迁移/无 DB 路由专项 80 项、Python 编译、Ruff 致命规则、JavaScript 语法及 diff 检查通过;迁移脚本仅验证缺少显式 URL 时拒绝运行,未连接或写入任何数据库。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.63.4"
|
||||
__version__ = "0.63.5"
|
||||
|
|
|
|||
|
|
@ -109,10 +109,33 @@
|
|||
width: 100%; padding: 6px 8px; border: 1px solid var(--border);
|
||||
border-radius: var(--r-md); color: var(--text); background: #fff;
|
||||
}
|
||||
#ext-admin-form button, #s-external td button {
|
||||
#ext-admin-form button, #s-external button {
|
||||
font-size: 12px; padding: 4px 9px; border: 1px solid var(--border);
|
||||
border-radius: var(--r-md); background: #fff; cursor: pointer;
|
||||
}
|
||||
#s-external button.primary, #ext-admin-form button.primary {
|
||||
color: #fff; border-color: var(--accent); background: var(--accent);
|
||||
}
|
||||
#external-definition-modal .card {
|
||||
width: min(820px, calc(100vw - 32px)); max-height: calc(100vh - 32px);
|
||||
margin: 0; padding: 0; display: flex; flex-direction: column;
|
||||
}
|
||||
.ext-modal-head {
|
||||
display: flex; align-items: center; gap: 12px; padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.ext-modal-head h3 { flex: 1; margin: 0; font-size: 14px; }
|
||||
.ext-modal-head button { font-size: 18px !important; line-height: 1; border: 0 !important; }
|
||||
.ext-form-body { padding: 16px; overflow-y: auto; }
|
||||
.ext-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 12px; }
|
||||
.ext-form-section {
|
||||
grid-column: 1 / -1; margin: 4px 0 0; padding-top: 10px;
|
||||
border-top: 1px solid var(--border-soft); color: var(--text); font-weight: 600;
|
||||
}
|
||||
.ext-form-actions {
|
||||
display: flex; justify-content: flex-end; gap: 8px; padding: 10px 16px;
|
||||
border-top: 1px solid var(--border); background: #fff;
|
||||
}
|
||||
|
||||
/* 复用主控制台 dialog.js 的单例页内弹框。 */
|
||||
.modal {
|
||||
|
|
@ -156,6 +179,8 @@
|
|||
.card-head { flex-wrap: wrap; }
|
||||
.ctrl { flex-wrap: wrap; }
|
||||
.ctrl select { min-width: 0; }
|
||||
.ext-form-grid { grid-template-columns: 1fr; }
|
||||
.ext-form-grid > label, .ext-form-section { grid-column: 1 !important; }
|
||||
/* 窄屏:目录变顶部横向 chip 条 */
|
||||
#layout { grid-template-columns: 1fr; gap: 10px; }
|
||||
#toc {
|
||||
|
|
|
|||
|
|
@ -161,32 +161,47 @@ function renderByDay(rows) {
|
|||
function renderExternalDefinitions() {
|
||||
const rows = externalDefinitions.map(r => {
|
||||
const cfg = r.config || {};
|
||||
const capability = r.provider === "generic_mcp"
|
||||
? "动态工具(tools/list)"
|
||||
: (cfg.operation_mode === "upstream_managed"
|
||||
? "上游托管"
|
||||
: `查询(${Object.keys(cfg.operation_policies || {}).length} 个 POST)`);
|
||||
return `<tr data-definition-id="${escapeHtml(r.definition_id)}">`
|
||||
+ `<td>${escapeHtml(r.name)} <span class="chip">${escapeHtml(r.provider_title || r.provider)}</span>${r.enabled ? "" : ' <span class="chip">停用</span>'}`
|
||||
+ ` <span class="chip">${r.visibility === "organization" ? "全部用户" : `指定 ${((r.selected_user_ids || []).length)} 人`}</span></td>`
|
||||
+ `<td class="email" title="${escapeHtml(cfg.base_url || "")}">${escapeHtml(r.host || cfg.base_url || "—")}</td>`
|
||||
+ `<td>${cfg.operation_mode === "upstream_managed" ? "上游托管" : `查询(${Object.keys(cfg.operation_policies || {}).length} 个 POST)`}</td>`
|
||||
+ `<td>${capability}</td>`
|
||||
+ `<td><button data-ext-edit>编辑</button> <button data-ext-delete>删除</button></td>`
|
||||
+ `</tr>`;
|
||||
}).join("") || `<tr><td colspan="4" class="empty">尚未配置外部系统</td></tr>`;
|
||||
$("s-external").innerHTML = `<div class="card"><div class="card-head"><h2>外部系统目录</h2>`
|
||||
+ `<span class="sublabel">OpenAPI 与 Streamable HTTP MCP 系统均可配置;用户只提交该系统要求的凭据</span></div>`
|
||||
+ `<form id="ext-admin-form" style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:14px;">`
|
||||
+ `<div><span class="sublabel">OpenAPI 与 Streamable HTTP MCP 系统均可配置;用户只提交该系统要求的凭据</span> `
|
||||
+ `<button id="exa-add" class="primary" type="button">新增外部系统</button></div></div>`
|
||||
+ `<div class="scroll-x"><table><thead><tr><th>系统</th><th>主机</th><th>能力模式</th><th>操作</th></tr></thead>`
|
||||
+ `<tbody>${rows}</tbody></table></div></div>`
|
||||
+ `<div id="external-definition-modal" class="modal">`
|
||||
+ `<div class="card" role="dialog" aria-modal="true" aria-labelledby="exa-dialog-title">`
|
||||
+ `<div class="ext-modal-head"><h3 id="exa-dialog-title">新增外部系统</h3><button id="exa-close" type="button" aria-label="关闭">×</button></div>`
|
||||
+ `<form id="ext-admin-form">`
|
||||
+ `<div class="ext-form-body"><div class="ext-form-grid">`
|
||||
+ `<div class="ext-form-section">连接方式</div>`
|
||||
+ `<label>系统类型<select id="exa-provider"><option value="generic_openapi">通用 OpenAPI 系统</option><option value="generic_mcp">通用 MCP 系统</option></select></label>`
|
||||
+ `<label>认证方式<select id="exa-auth"><option value="password_jwt">用户名密码换取 Token</option><option value="api_key">API Key</option><option value="bearer_token">Bearer Token</option></select></label>`
|
||||
+ `<label id="exa-operation-mode-wrap" style="grid-column:1/-1;">接口执行模式<select id="exa-operation-mode"><option value="upstream_managed">上游托管(开放规格中的全部方法)</option><option value="query">查询模式(GET/HEAD + 允许的只读 POST)</option></select><span id="exa-operation-mode-hint" class="sublabel"></span></label>`
|
||||
+ `<label>系统名称<input id="exa-name" required placeholder="ERP / LIMS / 其他系统"></label>`
|
||||
+ `<label>Base URL<input id="exa-base" required placeholder="https://api.example.com"></label>`
|
||||
+ `<label id="exa-base-wrap">Base URL<input id="exa-base" placeholder="https://api.example.com"></label>`
|
||||
+ `<label id="exa-spec-wrap">Swagger / OpenAPI URL<input id="exa-spec" placeholder="https://api.example.com/openapi.json"></label>`
|
||||
+ `<label id="exa-mcp-wrap">MCP URL<input id="exa-mcp" placeholder="https://api.example.com/mcp"><span class="sublabel">连接后,Server 通过 tools/list 暴露的全部工具均可使用。</span></label>`
|
||||
+ `<label id="exa-mcp-server-wrap">期望的 MCP Server 名称<input id="exa-mcp-server" placeholder="server-name(可选)"></label>`
|
||||
+ `<label id="exa-operation-mode-wrap" style="grid-column:1/-1;">接口执行模式<select id="exa-operation-mode"><option value="upstream_managed">上游托管(开放规格中的全部方法)</option><option value="query">查询模式(GET/HEAD + 允许的只读 POST)</option></select><span id="exa-operation-mode-hint" class="sublabel"></span></label>`
|
||||
+ `<label id="exa-operations-wrap" style="grid-column:1/-1;">允许的只读 POST operationId(逗号分隔;GET/HEAD 默认可查询)<input id="exa-operations" placeholder="query_records"></label>`
|
||||
+ `<div class="ext-form-section">认证参数</div>`
|
||||
+ `<label>登录路径<input id="exa-login" value="/api/auth/token/"></label>`
|
||||
+ `<label>Token 字段路径<input id="exa-token-field" value="access" placeholder="data.access_token"></label>`
|
||||
+ `<label>用户名字段<input id="exa-username-field" value="username"></label>`
|
||||
+ `<label>密码字段<input id="exa-password-field" value="password"></label>`
|
||||
+ `<label>认证 Header<input id="exa-auth-header" value="Authorization"></label>`
|
||||
+ `<label>Header 模板<input id="exa-auth-template" value="Bearer {token}"></label>`
|
||||
+ `<label id="exa-operations-wrap" style="grid-column:1/-1;">允许的只读 POST operationId(逗号分隔;GET/HEAD 默认可查询)<input id="exa-operations" placeholder="query_records"></label>`
|
||||
+ `<div class="ext-form-section">查询与授权</div>`
|
||||
+ `<label style="grid-column:1/-1;">推荐查询入口 operationId(逗号分隔)<input id="exa-recommended" placeholder="list_datasets, query_dataset"></label>`
|
||||
+ `<label style="grid-column:1/-1;">查询规划提示`
|
||||
+ `<input id="exa-guidance" type="hidden">`
|
||||
|
|
@ -199,10 +214,9 @@ function renderExternalDefinitions() {
|
|||
+ `<label id="exa-users-wrap" style="grid-column:1/-1;">授权用户(Ctrl/Command 可多选)<select id="exa-users" multiple size="6">`
|
||||
+ externalUsers.map(u => `<option value="${escapeHtml(u.user_id)}">${escapeHtml(u.label)}${u.email ? " · " + escapeHtml(u.email) : ""}</option>`).join("")
|
||||
+ `</select><span class="sublabel">撤销选择会删除该用户为此外部系统保存的密文凭据。</span></label>`
|
||||
+ `<div style="grid-column:1/-1;display:flex;gap:8px;justify-content:flex-end;">`
|
||||
+ `<button id="exa-cancel" type="button" hidden>取消编辑</button><button type="submit">保存系统定义</button></div></form>`
|
||||
+ `<div class="scroll-x"><table><thead><tr><th>系统</th><th>主机</th><th>执行模式</th><th>操作</th></tr></thead>`
|
||||
+ `<tbody>${rows}</tbody></table></div></div>`;
|
||||
+ `</div></div><div class="ext-form-actions">`
|
||||
+ `<button id="exa-cancel" type="button">取消</button><button class="primary" type="submit">保存系统定义</button>`
|
||||
+ `</div></form></div></div>`;
|
||||
|
||||
$("ext-admin-form").onsubmit = saveExternalDefinition;
|
||||
$("exa-guidance").value = "";
|
||||
|
|
@ -214,7 +228,15 @@ function renderExternalDefinitions() {
|
|||
$("exa-access").onchange = () => {
|
||||
$("exa-users-wrap").hidden = $("exa-access").value !== "selected";
|
||||
};
|
||||
$("exa-cancel").onclick = () => { externalEditingId = ""; renderExternalDefinitions(); };
|
||||
$("exa-add").onclick = () => openExternalDefinitionModal();
|
||||
$("exa-cancel").onclick = closeExternalDefinitionModal;
|
||||
$("exa-close").onclick = closeExternalDefinitionModal;
|
||||
$("external-definition-modal").onclick = event => {
|
||||
if (event.target.id === "external-definition-modal") closeExternalDefinitionModal();
|
||||
};
|
||||
$("external-definition-modal").onkeydown = event => {
|
||||
if (event.key === "Escape") closeExternalDefinitionModal();
|
||||
};
|
||||
updateExternalAuthForm();
|
||||
updateExternalOperationMode();
|
||||
$("s-external").onclick = (e) => {
|
||||
|
|
@ -222,7 +244,7 @@ function renderExternalDefinitions() {
|
|||
if (!tr) return;
|
||||
const row = externalDefinitions.find(x => x.definition_id === tr.dataset.definitionId);
|
||||
if (!row) return;
|
||||
if (e.target.closest("[data-ext-edit]")) fillExternalDefinition(row);
|
||||
if (e.target.closest("[data-ext-edit]")) openExternalDefinitionModal(row);
|
||||
if (e.target.closest("[data-ext-delete]")) deleteExternalDefinition(row);
|
||||
};
|
||||
}
|
||||
|
|
@ -237,11 +259,13 @@ function updateExternalAuthForm() {
|
|||
|
||||
function updateExternalConnectorForm() {
|
||||
const mcp = $("exa-provider").value === "generic_mcp";
|
||||
$("exa-base-wrap").hidden = mcp;
|
||||
$("exa-spec-wrap").hidden = mcp;
|
||||
$("exa-mcp-wrap").hidden = !mcp;
|
||||
$("exa-mcp-server-wrap").hidden = !mcp;
|
||||
$("exa-operation-mode-wrap").hidden = mcp;
|
||||
$("exa-operations-wrap").hidden = mcp || $("exa-operation-mode").value === "upstream_managed";
|
||||
$("exa-base").required = !mcp;
|
||||
$("exa-spec").required = !mcp;
|
||||
$("exa-mcp").required = mcp;
|
||||
}
|
||||
|
|
@ -298,8 +322,29 @@ async function editExternalGuidance() {
|
|||
updateExternalGuidanceSummary();
|
||||
}
|
||||
|
||||
function openExternalDefinitionModal(row = null) {
|
||||
externalEditingId = row ? row.definition_id : "";
|
||||
$("exa-dialog-title").textContent = row ? `编辑外部系统:${row.name}` : "新增外部系统";
|
||||
if (row) {
|
||||
fillExternalDefinition(row);
|
||||
} else {
|
||||
$("ext-admin-form").reset();
|
||||
$("exa-provider").disabled = false;
|
||||
$("exa-provider").value = "generic_openapi";
|
||||
$("exa-access").value = "selected";
|
||||
$("exa-users-wrap").hidden = false;
|
||||
applyExternalProviderDefaults();
|
||||
}
|
||||
$("external-definition-modal").classList.add("show");
|
||||
$("exa-name").focus();
|
||||
}
|
||||
|
||||
function closeExternalDefinitionModal() {
|
||||
externalEditingId = "";
|
||||
$("external-definition-modal").classList.remove("show");
|
||||
}
|
||||
|
||||
function fillExternalDefinition(row) {
|
||||
externalEditingId = row.definition_id;
|
||||
const cfg = row.config || {};
|
||||
$("exa-provider").value = row.provider || "generic_openapi";
|
||||
$("exa-provider").disabled = true;
|
||||
|
|
@ -331,31 +376,21 @@ function fillExternalDefinition(row) {
|
|||
const selected = new Set(row.selected_user_ids || []);
|
||||
Array.from($("exa-users").options).forEach(o => { o.selected = selected.has(o.value); });
|
||||
$("exa-users-wrap").hidden = $("exa-access").value !== "selected";
|
||||
$("exa-cancel").hidden = false;
|
||||
$("exa-name").focus();
|
||||
}
|
||||
|
||||
async function saveExternalDefinition(e) {
|
||||
e.preventDefault();
|
||||
const provider = $("exa-provider").value;
|
||||
const authType = $("exa-auth").value;
|
||||
const mcp = provider === "generic_mcp";
|
||||
const current = externalDefinitions.find(x => x.definition_id === externalEditingId);
|
||||
const currentConfig = (current || {}).config || {};
|
||||
const body = {
|
||||
provider: $("exa-provider").value,
|
||||
provider,
|
||||
name: $("exa-name").value.trim(),
|
||||
base_url: $("exa-base").value.trim(),
|
||||
openapi_url: $("exa-spec").value.trim(),
|
||||
mcp_url: $("exa-mcp").value.trim(),
|
||||
expected_server_name: $("exa-mcp-server").value.trim(),
|
||||
login_path: $("exa-login").value.trim() || "/api/auth/token/",
|
||||
auth_type: $("exa-auth").value,
|
||||
username_field: $("exa-username-field").value.trim() || "username",
|
||||
password_field: $("exa-password-field").value.trim() || "password",
|
||||
token_field: $("exa-token-field").value.trim() || "access",
|
||||
auth_type: authType,
|
||||
auth_header_name: $("exa-auth-header").value.trim() || "Authorization",
|
||||
auth_header_template: $("exa-auth-template").value || "Bearer {token}",
|
||||
operation_mode: $("exa-operation-mode").value,
|
||||
operation_policies: Object.fromEntries(
|
||||
$("exa-operations").value.split(",").map(x => x.trim()).filter(Boolean)
|
||||
.map(operationId => [operationId, "read"]),
|
||||
),
|
||||
recommended_operation_ids: $("exa-recommended").value.split(",").map(x => x.trim()).filter(Boolean),
|
||||
query_guidance: $("exa-guidance").value.trim(),
|
||||
verify_tls: $("exa-tls").checked,
|
||||
|
|
@ -363,15 +398,38 @@ async function saveExternalDefinition(e) {
|
|||
visibility: $("exa-access").value,
|
||||
selected_user_ids: Array.from($("exa-users").selectedOptions).map(o => o.value),
|
||||
};
|
||||
const current = externalDefinitions.find(x => x.definition_id === externalEditingId);
|
||||
body.timeout_seconds = current ? (current.config || {}).timeout_seconds || 15 : 15;
|
||||
body.max_result_bytes = current ? (current.config || {}).max_result_bytes || 65536 : 65536;
|
||||
if (authType === "password_jwt") {
|
||||
Object.assign(body, {
|
||||
login_path: $("exa-login").value.trim() || "/api/auth/token/",
|
||||
username_field: $("exa-username-field").value.trim() || "username",
|
||||
password_field: $("exa-password-field").value.trim() || "password",
|
||||
token_field: $("exa-token-field").value.trim() || "access",
|
||||
});
|
||||
}
|
||||
if (mcp) {
|
||||
Object.assign(body, {
|
||||
// 编辑时保留既有同源认证基址;新建时由后端从 MCP URL 推导 origin。
|
||||
base_url: currentConfig.base_url || "",
|
||||
mcp_url: $("exa-mcp").value.trim(),
|
||||
expected_server_name: $("exa-mcp-server").value.trim(),
|
||||
max_response_bytes: currentConfig.max_response_bytes || 10485760,
|
||||
});
|
||||
} else {
|
||||
Object.assign(body, {
|
||||
base_url: $("exa-base").value.trim(),
|
||||
openapi_url: $("exa-spec").value.trim(),
|
||||
operation_mode: $("exa-operation-mode").value,
|
||||
operation_policies: Object.fromEntries(
|
||||
$("exa-operations").value.split(",").map(x => x.trim()).filter(Boolean)
|
||||
.map(operationId => [operationId, "read"]),
|
||||
),
|
||||
});
|
||||
}
|
||||
body.timeout_seconds = currentConfig.timeout_seconds || 15;
|
||||
body.max_result_bytes = currentConfig.max_result_bytes || 65536;
|
||||
body.max_total_result_bytes = current
|
||||
? (current.config || {}).max_total_result_bytes || 262144
|
||||
? currentConfig.max_total_result_bytes || 262144
|
||||
: 262144;
|
||||
body.max_response_bytes = current
|
||||
? (current.config || {}).max_response_bytes || 10485760
|
||||
: 10485760;
|
||||
try {
|
||||
await apiSend(
|
||||
externalEditingId ? "PUT" : "POST",
|
||||
|
|
|
|||
Loading…
Reference in New Issue