feat: add ERP agent pet bridge and startup guide
This commit is contained in:
@@ -0,0 +1,898 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const runtime = window.LskjPetRuntime;
|
||||
const bridgeApi = window.LskjBridgeClient;
|
||||
const shell = document.getElementById("pet-shell");
|
||||
const anchor = document.getElementById("pet-anchor");
|
||||
const panel = document.getElementById("assistant-panel");
|
||||
const sprite = document.getElementById("pet-sprite");
|
||||
const status = document.getElementById("pet-status");
|
||||
const connection = document.getElementById("connection-state");
|
||||
const closePetButton = document.getElementById("close-pet");
|
||||
const moduleTitle = document.getElementById("module-title");
|
||||
const contextScope = document.getElementById("context-scope");
|
||||
const conversation = document.getElementById("conversation");
|
||||
const composer = document.getElementById("composer");
|
||||
const input = document.getElementById("message-input");
|
||||
const attachButton = document.getElementById("attach-files");
|
||||
const sendButton = document.getElementById("send-message");
|
||||
const attachmentList = document.getElementById("attachment-list");
|
||||
const planPreview = document.getElementById("plan-preview");
|
||||
const planTitle = document.getElementById("plan-title");
|
||||
const planDetail = document.getElementById("plan-detail");
|
||||
const executeButton = document.getElementById("execute-plan");
|
||||
const quickActionButtons = Array.from(
|
||||
document.querySelectorAll("[data-quick-action]"));
|
||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const previewParameters = new URLSearchParams(window.location.search);
|
||||
const previewMode = previewParameters.get("preview") === "1";
|
||||
if (previewMode) shell.dataset.preview = "enabled";
|
||||
const state = new runtime.PetStateMachine("offline");
|
||||
const animator = new runtime.AtlasAnimator(sprite, { reducedMotion });
|
||||
let bridge = null;
|
||||
let activePlan = null;
|
||||
let activeAssistantMessage = null;
|
||||
let attachments = [];
|
||||
let uploading = false;
|
||||
let chatBusy = false;
|
||||
let assetReady = false;
|
||||
let bridgeConnected = false;
|
||||
let assetLoadGeneration = 0;
|
||||
let contextRefreshGeneration = 0;
|
||||
let hoverOpenTimer = null;
|
||||
let panelOpenedByHover = false;
|
||||
let planReviewRequired = false;
|
||||
let planReviewSatisfied = true;
|
||||
|
||||
state.subscribe(event => {
|
||||
animator.playSemantic(event.state);
|
||||
const labels = {
|
||||
idle: "可以问我",
|
||||
listening: "我在听",
|
||||
thinking: "正在理解",
|
||||
planning: "正在生成预览",
|
||||
awaiting_confirmation: "等待确认",
|
||||
executing: "ERP 正在执行",
|
||||
success: "操作完成",
|
||||
error: "需要检查",
|
||||
offline: "等待 ERP"
|
||||
};
|
||||
status.textContent = labels[event.state] || "处理中";
|
||||
});
|
||||
animator.playSemantic("offline");
|
||||
|
||||
anchor.addEventListener("mouseenter", scheduleHoverOpen);
|
||||
anchor.addEventListener("mouseleave", cancelHoverOpen);
|
||||
anchor.addEventListener("click", () => {
|
||||
cancelHoverOpen();
|
||||
const open = runtime.panelOpenAfterAnchorClick(
|
||||
shell.dataset.panel === "open",
|
||||
panelOpenedByHover);
|
||||
panelOpenedByHover = false;
|
||||
setPanel(open, true);
|
||||
});
|
||||
composer.addEventListener("submit", onSubmit);
|
||||
input.addEventListener("keydown", event => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
composer.requestSubmit();
|
||||
}
|
||||
});
|
||||
executeButton.addEventListener("click", executeActivePlan);
|
||||
planDetail.addEventListener("scroll", updatePlanReviewFromScroll);
|
||||
attachButton.addEventListener("click", pickAttachments);
|
||||
closePetButton.addEventListener("click", closePet);
|
||||
quickActionButtons.forEach(button => {
|
||||
button.addEventListener("click", () => {
|
||||
if (button.disabled) return;
|
||||
let prompt;
|
||||
try {
|
||||
prompt = runtime.quickActionPrompt(button.dataset.quickAction);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
sendChat(prompt, button.textContent.trim());
|
||||
});
|
||||
});
|
||||
|
||||
if (!previewMode && window.chrome && window.chrome.webview) {
|
||||
window.chrome.webview.addEventListener("message", onHostMessage);
|
||||
bridge = new bridgeApi.BridgeClient(new bridgeApi.WebViewTransport(window.chrome.webview));
|
||||
connection.textContent = "正在验证素材";
|
||||
connection.dataset.state = "offline";
|
||||
} else {
|
||||
appendMessage("system", "当前是浏览器预览模式;Windows 宿主启动后会连接 ERP 命令桥和 AstrBot。 ");
|
||||
configureLocalPreview();
|
||||
}
|
||||
|
||||
function configureLocalPreview() {
|
||||
const localPreviewOrigin = window.location.protocol === "file:"
|
||||
|| window.location.hostname === "127.0.0.1"
|
||||
|| window.location.hostname === "localhost";
|
||||
if (!localPreviewOrigin || previewParameters.get("preview") !== "1") return;
|
||||
const previewSprite = previewParameters.get("previewSprite");
|
||||
if (previewSprite) animator.setAsset(previewSprite);
|
||||
applyContext({
|
||||
userId: "DEMO-USER",
|
||||
userName: "演示用户",
|
||||
accountBook: "演示账套",
|
||||
subSystemId: "DEMO-SCM",
|
||||
subSystemName: "采购管理",
|
||||
databaseScopeFingerprint: "a".repeat(64),
|
||||
isAdministrator: false,
|
||||
activeModule: {
|
||||
moduleCode: "CGDD",
|
||||
navigationCode: "NAV-CGDD",
|
||||
moduleName: "采购订单"
|
||||
}
|
||||
});
|
||||
connection.textContent = "原型预览";
|
||||
connection.dataset.state = "online";
|
||||
state.set("awaiting_confirmation");
|
||||
setPanel(true);
|
||||
const lineMatches = [
|
||||
previewPurchaseLine("L1", "MAT-1001", 12, 320, 0.13, "PO-20260811-018", "PO-L1", 30),
|
||||
previewPurchaseLine("L2", "MAT-2048", 5, 680, 0.13, "PO-20260811-018", "PO-L2", 8),
|
||||
previewPurchaseLine("L3", "MAT-3106", 18, 190, 0.06, "PO-20260809-006", "PO-L7", 40)
|
||||
];
|
||||
const preview = {
|
||||
"供应商": "示例供应商",
|
||||
"发票号码": "INV-20260811-018",
|
||||
"发票日期": "2026-08-11",
|
||||
"币种": "CNY",
|
||||
"不含税金额": 10660,
|
||||
"税额": 1146.4,
|
||||
"价税合计": 11806.4,
|
||||
"来源附件": ["invoice.pdf (0123456789ab…)"],
|
||||
"明细汇总不含税": 10660,
|
||||
"明细汇总税额": 1146.4,
|
||||
"明细汇总价税": 11806.4,
|
||||
"发票行数": 3,
|
||||
"确定匹配行数": 3,
|
||||
"来源采购单": ["PO-20260811-018", "PO-20260809-006"],
|
||||
"来源汇率": [1],
|
||||
"重复发票": false
|
||||
};
|
||||
showPlan({
|
||||
planId: "0123456789abcdef0123456789abcdef",
|
||||
commandName: "purchase.invoice.create",
|
||||
moduleCode: "CGDD",
|
||||
risk: "write",
|
||||
valid: true,
|
||||
executionAllowed: true,
|
||||
title: "采购发票识别结果",
|
||||
preview,
|
||||
data: { preview, lineMatches },
|
||||
warnings: []
|
||||
});
|
||||
}
|
||||
|
||||
function previewPurchaseLine(
|
||||
invoiceLineId,
|
||||
materialCode,
|
||||
invoiceQuantity,
|
||||
invoiceUnitPrice,
|
||||
invoiceTaxRate,
|
||||
sourceOrderNumber,
|
||||
sourceLineId,
|
||||
sourceRemainingQuantity) {
|
||||
const invoiceLineAmount = invoiceQuantity * invoiceUnitPrice;
|
||||
const invoiceTaxAmount = Number(
|
||||
(invoiceLineAmount * invoiceTaxRate).toFixed(2));
|
||||
return {
|
||||
invoiceLineId,
|
||||
materialCode,
|
||||
invoiceUnit: "件",
|
||||
invoiceQuantity,
|
||||
invoiceUnitPrice,
|
||||
invoiceTaxRate,
|
||||
invoiceTaxAmount,
|
||||
invoiceLineAmount,
|
||||
status: "exact",
|
||||
sourceOrderNumber,
|
||||
sourceLineId,
|
||||
unit: "件",
|
||||
remainingQuantity: sourceRemainingQuantity,
|
||||
unitPrice: invoiceUnitPrice,
|
||||
taxRate: invoiceTaxRate,
|
||||
exchangeRate: 1,
|
||||
sourceUnit: "件",
|
||||
sourceRemainingQuantity,
|
||||
sourceUnitPrice: invoiceUnitPrice,
|
||||
sourceTaxRate: invoiceTaxRate,
|
||||
sourceExchangeRate: 1,
|
||||
candidateCount: 1,
|
||||
issues: []
|
||||
};
|
||||
}
|
||||
|
||||
function setPanel(open, focusInput) {
|
||||
shell.dataset.panel = open ? "open" : "closed";
|
||||
anchor.setAttribute("aria-expanded", String(open));
|
||||
panel.setAttribute("aria-hidden", String(!open));
|
||||
if (!open) panelOpenedByHover = false;
|
||||
if (open) {
|
||||
refreshActiveContext();
|
||||
if (focusInput === true) setTimeout(() => input.focus(), 180);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleHoverOpen() {
|
||||
if (shell.dataset.panel === "open" || hoverOpenTimer !== null) return;
|
||||
hoverOpenTimer = setTimeout(() => {
|
||||
hoverOpenTimer = null;
|
||||
// 悬停只展开,不抢走 ERP 当前控件的键盘焦点;点击才聚焦输入框。
|
||||
panelOpenedByHover = true;
|
||||
setPanel(true, false);
|
||||
}, 480);
|
||||
}
|
||||
|
||||
function cancelHoverOpen() {
|
||||
if (hoverOpenTimer === null) return;
|
||||
clearTimeout(hoverOpenTimer);
|
||||
hoverOpenTimer = null;
|
||||
}
|
||||
|
||||
async function refreshActiveContext() {
|
||||
if (previewMode) return true;
|
||||
if (!bridge || !bridgeConnected) return false;
|
||||
const generation = ++contextRefreshGeneration;
|
||||
try {
|
||||
const context = await bridge.context();
|
||||
if (generation !== contextRefreshGeneration) return false;
|
||||
return applyContext(context);
|
||||
} catch (_) {
|
||||
// 展开动作不能制造错误气泡;真正发送问题时 AstrBot 会再次实时取上下文,
|
||||
// 桥不可用则沿既有错误通道给出关联 ID。
|
||||
if (generation === contextRefreshGeneration) applyContext(null);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyContext(context) {
|
||||
const scope = runtime.erpSessionScopeSummary(context);
|
||||
quickActionButtons.forEach(button => {
|
||||
button.hidden = !runtime.quickActionVisible(
|
||||
button.dataset.quickAction,
|
||||
context);
|
||||
});
|
||||
const active = context && context.activeModule;
|
||||
moduleTitle.textContent = active && active.moduleName
|
||||
? active.moduleName
|
||||
: context && context.subSystemName
|
||||
? context.subSystemName
|
||||
: "ERP 会话未确认";
|
||||
if (!scope.complete) {
|
||||
contextScope.textContent = "会话范围未确认,执行前必须重新验证";
|
||||
contextScope.title = "";
|
||||
contextScope.dataset.state = "error";
|
||||
return false;
|
||||
}
|
||||
const administrator = scope.isAdministrator ? " · 管理员" : "";
|
||||
contextScope.textContent = `账套 ${scope.accountBook} · ${scope.subSystemName} · ${scope.userName}${administrator} · 库 ${scope.databaseEvidence}`;
|
||||
contextScope.title = `用户 ${scope.userId};子系统 ${scope.subSystemId};数据库作用域证据 ${scope.databaseEvidence}`;
|
||||
contextScope.dataset.state = "bound";
|
||||
return true;
|
||||
}
|
||||
|
||||
function closePet() {
|
||||
if (!previewMode && window.chrome && window.chrome.webview) {
|
||||
window.chrome.webview.postMessage({ type: "lserp.window.close" });
|
||||
return;
|
||||
}
|
||||
setPanel(false);
|
||||
}
|
||||
|
||||
async function connectBridge() {
|
||||
if (!assetReady || !bridge) return;
|
||||
try {
|
||||
await bridge.health();
|
||||
const context = await bridge.context();
|
||||
if (!applyContext(context)) {
|
||||
const scopeError = new Error("当前 ERP 会话范围不完整或格式无效。");
|
||||
scopeError.code = "erp_session_scope_invalid";
|
||||
throw scopeError;
|
||||
}
|
||||
connection.textContent = "已连接";
|
||||
connection.dataset.state = "online";
|
||||
bridgeConnected = true;
|
||||
state.set("idle");
|
||||
updateComposerState();
|
||||
} catch (error) {
|
||||
bridgeConnected = false;
|
||||
applyContext(null);
|
||||
connection.textContent = "连接失败";
|
||||
connection.dataset.state = "error";
|
||||
state.set("offline");
|
||||
appendMessage("error", safeError(error));
|
||||
updateComposerState();
|
||||
}
|
||||
}
|
||||
|
||||
async function configureSprite(assetUrl) {
|
||||
const generation = ++assetLoadGeneration;
|
||||
assetReady = false;
|
||||
bridgeConnected = false;
|
||||
animator.setAsset("");
|
||||
connection.textContent = "正在验证素材";
|
||||
connection.dataset.state = "offline";
|
||||
updateComposerState();
|
||||
try {
|
||||
const loaded = await runtime.loadAtlasAsset(assetUrl);
|
||||
if (generation !== assetLoadGeneration) return;
|
||||
animator.setAsset(loaded.url);
|
||||
assetReady = true;
|
||||
await connectBridge();
|
||||
} catch (error) {
|
||||
if (generation !== assetLoadGeneration) return;
|
||||
connection.textContent = "素材无效";
|
||||
connection.dataset.state = "error";
|
||||
state.set("error");
|
||||
appendMessage("error", "guga 图集无法完整解码或尺寸不符合 1536×1872,桌宠已停止连接 ERP。");
|
||||
updateComposerState();
|
||||
}
|
||||
}
|
||||
|
||||
function onHostMessage(event) {
|
||||
const message = event && event.data;
|
||||
if (!message) return;
|
||||
if (message.type === "lserp.pet.configure" && message.spriteUrl) {
|
||||
configureSprite(message.spriteUrl);
|
||||
return;
|
||||
}
|
||||
if (message.type === "lserp.chat.delta") {
|
||||
appendAssistantDelta(message.text || "");
|
||||
return;
|
||||
}
|
||||
if (message.type === "lserp.chat.result") {
|
||||
chatBusy = false;
|
||||
updateComposerState();
|
||||
state.set("idle");
|
||||
const streamed = finishAssistantStream();
|
||||
if (message.text && !streamed) appendMessage("assistant", message.text);
|
||||
if (message.commandPlan) {
|
||||
const correlatedPlan = Object.assign({}, message.commandPlan);
|
||||
if (typeof message.bridgeCorrelationId === "string"
|
||||
&& /^[A-Za-z0-9_.:-]{8,128}$/.test(message.bridgeCorrelationId)) {
|
||||
correlatedPlan.bridgeCorrelationId = message.bridgeCorrelationId;
|
||||
}
|
||||
showPlan(correlatedPlan);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "lserp.chat.error") {
|
||||
chatBusy = false;
|
||||
updateComposerState();
|
||||
finishAssistantStream();
|
||||
state.set("error");
|
||||
appendMessage("error", appendCorrelation(
|
||||
message.message || "对话服务暂时不可用。",
|
||||
message.correlationId));
|
||||
return;
|
||||
}
|
||||
if (message.type === "lserp.attachments.uploading") {
|
||||
uploading = message.uploading === true;
|
||||
updateComposerState();
|
||||
if (uploading) state.set("thinking");
|
||||
else if (!chatBusy && state.state === "thinking") state.set("idle");
|
||||
return;
|
||||
}
|
||||
if (message.type === "lserp.attachments.changed") {
|
||||
attachments = Array.isArray(message.attachments)
|
||||
? message.attachments.filter(item => item && typeof item.attachmentId === "string")
|
||||
: [];
|
||||
renderAttachments();
|
||||
updateComposerState();
|
||||
return;
|
||||
}
|
||||
if (message.type === "lserp.attachments.error") {
|
||||
appendMessage("error", appendCorrelation(
|
||||
message.message || "附件处理失败。",
|
||||
message.correlationId));
|
||||
state.set("error");
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit(event) {
|
||||
event.preventDefault();
|
||||
const text = input.value.trim();
|
||||
if ((!text && attachments.length === 0)
|
||||
|| uploading || chatBusy || !bridgeConnected) return;
|
||||
sendChat(
|
||||
text,
|
||||
text || `已发送 ${attachments.length} 个附件,请识别并生成业务预览。`);
|
||||
}
|
||||
|
||||
function sendChat(text, visibleText) {
|
||||
if ((!text && attachments.length === 0)
|
||||
|| uploading || chatBusy || !bridgeConnected) return false;
|
||||
appendMessage("user", visibleText || text);
|
||||
finishAssistantStream();
|
||||
activePlan = null;
|
||||
planReviewRequired = false;
|
||||
planReviewSatisfied = true;
|
||||
planPreview.hidden = true;
|
||||
executeButton.disabled = true;
|
||||
input.value = "";
|
||||
resizeInput();
|
||||
chatBusy = true;
|
||||
updateComposerState();
|
||||
state.set("thinking");
|
||||
if (window.chrome && window.chrome.webview) {
|
||||
window.chrome.webview.postMessage({ type: "lserp.chat.send", text });
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
chatBusy = false;
|
||||
updateComposerState();
|
||||
state.set("idle");
|
||||
appendMessage("assistant", "浏览器预览没有连接 AstrBot。正式宿主会把这条消息连同当前 ERP 上下文发送给机器人。");
|
||||
}, 320);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function pickAttachments() {
|
||||
if (uploading || chatBusy || !bridgeConnected) return;
|
||||
if (window.chrome && window.chrome.webview) {
|
||||
window.chrome.webview.postMessage({ type: "lserp.attachment.pick" });
|
||||
return;
|
||||
}
|
||||
appendMessage("system", "浏览器预览不能读取本地附件;Windows 宿主会打开原生选件窗口。");
|
||||
}
|
||||
|
||||
function renderAttachments() {
|
||||
attachmentList.replaceChildren();
|
||||
attachmentList.hidden = attachments.length === 0;
|
||||
attachments.forEach(item => {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "attachment-chip";
|
||||
const name = document.createElement("span");
|
||||
name.className = "attachment-name";
|
||||
name.textContent = item.fileName || "附件";
|
||||
name.title = item.fileName || "附件";
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "attachment-remove";
|
||||
remove.setAttribute("aria-label", "移除附件 " + (item.fileName || ""));
|
||||
remove.textContent = "×";
|
||||
remove.disabled = uploading || chatBusy;
|
||||
remove.addEventListener("click", () => {
|
||||
if (!window.chrome || !window.chrome.webview) return;
|
||||
window.chrome.webview.postMessage({
|
||||
type: "lserp.attachment.remove",
|
||||
attachmentId: item.attachmentId
|
||||
});
|
||||
});
|
||||
chip.append(name, remove);
|
||||
attachmentList.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function updateComposerState() {
|
||||
const unavailable = !previewMode && !bridgeConnected;
|
||||
attachButton.disabled = uploading || chatBusy || unavailable;
|
||||
input.disabled = uploading || chatBusy || unavailable;
|
||||
sendButton.disabled = uploading || chatBusy || unavailable
|
||||
|| (!input.value.trim() && attachments.length === 0);
|
||||
attachButton.textContent = uploading ? "上传中" : "+附件";
|
||||
attachmentList.querySelectorAll(".attachment-remove").forEach(button => {
|
||||
button.disabled = uploading || chatBusy || unavailable;
|
||||
});
|
||||
quickActionButtons.forEach(button => {
|
||||
// 快捷问题永不隐式携带待处理发票;用户必须先发送或移除附件。
|
||||
button.disabled = uploading || chatBusy || unavailable
|
||||
|| attachments.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function showPlan(plan) {
|
||||
if (!plan || !plan.planId) return;
|
||||
const purchasePreview = runtime.purchaseLinePreview(plan);
|
||||
const leavePreview = runtime.leaveConfirmationPreview(plan);
|
||||
const tracePreview = runtime.initializationTracePreview(plan);
|
||||
const dynamicPreview = runtime.dynamicModuleConfirmationPreview(plan);
|
||||
const correlationBound = plan
|
||||
&& typeof plan.bridgeCorrelationId === "string"
|
||||
&& /^[A-Za-z0-9_.:-]{8,128}$/.test(plan.bridgeCorrelationId);
|
||||
const executable = runtime.isExecutablePlan(plan)
|
||||
&& (previewMode || correlationBound);
|
||||
activePlan = executable ? plan : null;
|
||||
planReviewRequired = executable
|
||||
&& ((purchasePreview.required && purchasePreview.complete)
|
||||
|| (dynamicPreview.required && dynamicPreview.complete));
|
||||
planReviewSatisfied = !planReviewRequired;
|
||||
planTitle.textContent = plan.title || plan.commandName || "业务操作预览";
|
||||
const preview = dynamicPreview.required
|
||||
? {
|
||||
"命令": plan.commandName,
|
||||
"模块": plan.moduleCode,
|
||||
"风险": plan.risk,
|
||||
"计划号": plan.planId,
|
||||
"动态配置": dynamicPreview.complete
|
||||
? dynamicPreview.mode === "create"
|
||||
? `${dynamicPreview.totalValues} 个参数,${dynamicPreview.detailRows.length} 行明细`
|
||||
: `${dynamicPreview.changes.length} 个字段将修改`
|
||||
: "确认结构未通过安全校验"
|
||||
}
|
||||
: Object.assign({}, plan.preview || (plan.data && plan.data.preview) || {
|
||||
"命令": plan.commandName,
|
||||
"模块": plan.moduleCode,
|
||||
"风险": plan.risk,
|
||||
"计划号": plan.planId
|
||||
});
|
||||
if (Array.isArray(plan.warnings) && plan.warnings.length) {
|
||||
preview["需处理"] = plan.warnings.slice(0, 5).join(";");
|
||||
}
|
||||
if (purchasePreview.required && !purchasePreview.complete) {
|
||||
preview["安全检查"] = "逐行匹配证据不完整,已禁止执行;请重新生成采购预览。";
|
||||
}
|
||||
if (leavePreview.required && !leavePreview.complete) {
|
||||
preview["安全检查"] = "请假确认信息不完整,已禁止执行;请重新生成请假预览。";
|
||||
}
|
||||
if (tracePreview.required && !tracePreview.complete) {
|
||||
preview["安全检查"] = "初始化诊断的模块范围或风险说明不完整,已禁止执行。";
|
||||
}
|
||||
if (dynamicPreview.required && !dynamicPreview.complete) {
|
||||
preview["安全检查"] = "低代码动态参数、并发快照或适配器证据不完整,已禁止执行。";
|
||||
}
|
||||
const detailNodes = [buildDefinitionList(preview, 24)];
|
||||
if (purchasePreview.complete && purchasePreview.lines.length) {
|
||||
detailNodes.push(buildPurchaseLineList(purchasePreview.lines));
|
||||
}
|
||||
if (dynamicPreview.required && dynamicPreview.complete) {
|
||||
detailNodes.push(buildDynamicModulePreview(dynamicPreview));
|
||||
}
|
||||
planDetail.replaceChildren(...detailNodes);
|
||||
planDetail.scrollTop = 0;
|
||||
planPreview.hidden = false;
|
||||
executeButton.disabled = !executable
|
||||
|| (!previewMode && !bridgeConnected)
|
||||
|| !planReviewSatisfied;
|
||||
executeButton.textContent = executable && !planReviewSatisfied
|
||||
? "请先向下滚动核对全部内容"
|
||||
: executable
|
||||
? "在 ERP 中确认并执行"
|
||||
: purchasePreview.required && !purchasePreview.complete
|
||||
? "逐行匹配证据不完整,不能执行"
|
||||
: leavePreview.required && !leavePreview.complete
|
||||
? "请假确认信息不完整,不能执行"
|
||||
: tracePreview.required && !tracePreview.complete
|
||||
? "诊断范围或风险说明不完整,不能执行"
|
||||
: dynamicPreview.required && !dynamicPreview.complete
|
||||
? "动态参数或并发证据不完整,不能执行"
|
||||
: plan.valid === false
|
||||
? "预览未通过,不能执行"
|
||||
: plan.risk === "read"
|
||||
? "只读结果,无需执行"
|
||||
: "解析预览,无需执行";
|
||||
state.set(executable ? "awaiting_confirmation" : "idle");
|
||||
if (planReviewRequired) {
|
||||
requestAnimationFrame(updatePlanReviewFromScroll);
|
||||
}
|
||||
if (!previewMode) void refreshActiveContext();
|
||||
}
|
||||
|
||||
function updatePlanReviewFromScroll() {
|
||||
if (!planReviewRequired || planReviewSatisfied || !activePlan) return;
|
||||
planReviewSatisfied = runtime.previewReviewComplete(
|
||||
planDetail.scrollTop,
|
||||
planDetail.clientHeight,
|
||||
planDetail.scrollHeight);
|
||||
if (planReviewSatisfied) syncActivePlanButton();
|
||||
}
|
||||
|
||||
function syncActivePlanButton() {
|
||||
if (!activePlan) {
|
||||
executeButton.disabled = true;
|
||||
return;
|
||||
}
|
||||
executeButton.disabled = (!previewMode && !bridgeConnected)
|
||||
|| !planReviewSatisfied;
|
||||
executeButton.textContent = planReviewSatisfied
|
||||
? "在 ERP 中确认并执行"
|
||||
: "请先向下滚动核对全部内容";
|
||||
}
|
||||
|
||||
async function executeActivePlan() {
|
||||
if (!activePlan || !bridge || !bridgeConnected || !planReviewSatisfied) return;
|
||||
executeButton.disabled = true;
|
||||
if (!previewMode && !await refreshActiveContext()) {
|
||||
activePlan = null;
|
||||
planReviewRequired = false;
|
||||
planReviewSatisfied = false;
|
||||
executeButton.disabled = true;
|
||||
executeButton.textContent = "ERP 会话范围已变化,请重新生成预览";
|
||||
appendMessage(
|
||||
"error",
|
||||
"当前 ERP 数据库、用户、账套或子系统范围无法重新确认;旧计划已作废,请确认登录范围后重新发起。"
|
||||
);
|
||||
state.set("error");
|
||||
return;
|
||||
}
|
||||
state.set("executing");
|
||||
try {
|
||||
const data = await bridge.execute(
|
||||
activePlan.planId,
|
||||
activePlan.bridgeCorrelationId);
|
||||
const result = data.result || {};
|
||||
const receipt = result.recordId
|
||||
? (result.message || "ERP 操作已完成。") + "(业务记录:" + result.recordId + ")"
|
||||
: result.message || "ERP 操作已完成。";
|
||||
appendMessage("assistant", appendCorrelation(
|
||||
receipt,
|
||||
data.bridgeCorrelationId));
|
||||
const diagnosticSummary = runtime.diagnosticExecutionSummary(result);
|
||||
if (diagnosticSummary) appendMessage("system", diagnosticSummary);
|
||||
executeButton.textContent = "在 ERP 中确认并执行";
|
||||
if (data.followupPlan) {
|
||||
showPlan(data.followupPlan);
|
||||
appendMessage("system", "下一步仍是独立操作,请核对新预览后再次确认。");
|
||||
} else {
|
||||
if (data.followupCode) {
|
||||
appendMessage(
|
||||
"system",
|
||||
"ERP 操作已完成,但后续操作预览未生成(" + data.followupCode + ")。后续操作尚未执行,请重新询问桌宠。"
|
||||
);
|
||||
}
|
||||
planPreview.hidden = true;
|
||||
activePlan = null;
|
||||
planReviewRequired = false;
|
||||
planReviewSatisfied = true;
|
||||
state.set("success");
|
||||
setTimeout(() => state.set("idle"), 1600);
|
||||
}
|
||||
} catch (error) {
|
||||
const failure = runtime.bridgeFailureSummary(error);
|
||||
appendMessage(
|
||||
failure.code === "user_cancelled" ? "system" : "error",
|
||||
failure.text);
|
||||
if (failure.planInvalidated) {
|
||||
activePlan = null;
|
||||
planReviewRequired = false;
|
||||
planReviewSatisfied = false;
|
||||
planPreview.hidden = true;
|
||||
executeButton.textContent = "请重新生成预览";
|
||||
}
|
||||
state.set(failure.code === "user_cancelled" ? "idle" : "error");
|
||||
} finally {
|
||||
syncActivePlanButton();
|
||||
}
|
||||
}
|
||||
|
||||
function appendMessage(kind, text) {
|
||||
if (!text) return;
|
||||
const paragraph = document.createElement("p");
|
||||
paragraph.className = kind + "-message";
|
||||
paragraph.textContent = text;
|
||||
conversation.appendChild(paragraph);
|
||||
conversation.scrollTop = conversation.scrollHeight;
|
||||
}
|
||||
|
||||
function appendAssistantDelta(text) {
|
||||
if (!text) return;
|
||||
if (!activeAssistantMessage) {
|
||||
activeAssistantMessage = document.createElement("p");
|
||||
activeAssistantMessage.className = "assistant-message";
|
||||
conversation.appendChild(activeAssistantMessage);
|
||||
}
|
||||
activeAssistantMessage.appendChild(document.createTextNode(text));
|
||||
conversation.scrollTop = conversation.scrollHeight;
|
||||
}
|
||||
|
||||
function finishAssistantStream() {
|
||||
const hadStream = !!activeAssistantMessage;
|
||||
activeAssistantMessage = null;
|
||||
return hadStream;
|
||||
}
|
||||
|
||||
function buildDefinitionList(values, maximumEntries) {
|
||||
const list = document.createElement("dl");
|
||||
const entries = Object.entries(values || {});
|
||||
const limit = Number.isInteger(maximumEntries) ? maximumEntries : 24;
|
||||
entries.slice(0, limit).forEach(([key, value]) => {
|
||||
const term = document.createElement("dt");
|
||||
const description = document.createElement("dd");
|
||||
term.textContent = key;
|
||||
description.textContent = value === null || value === undefined
|
||||
? "-"
|
||||
: typeof value === "object" ? JSON.stringify(value) : String(value);
|
||||
list.append(term, description);
|
||||
});
|
||||
if (entries.length > limit) {
|
||||
const term = document.createElement("dt");
|
||||
const description = document.createElement("dd");
|
||||
term.textContent = "更多字段";
|
||||
description.textContent = `还有 ${entries.length - limit} 项,请重新生成更小的操作预览。`;
|
||||
list.append(term, description);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function buildPurchaseLineList(lines) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "purchase-line-preview";
|
||||
const heading = document.createElement("h3");
|
||||
heading.textContent = `逐行来源匹配(${lines.length} 行)`;
|
||||
section.appendChild(heading);
|
||||
lines.forEach((line, index) => {
|
||||
const card = document.createElement("article");
|
||||
card.className = "purchase-line-card";
|
||||
const title = document.createElement("h4");
|
||||
title.textContent = `${line.invoiceLineId || `第 ${index + 1} 行`} · ${line.materialCode}`;
|
||||
const status = document.createElement("span");
|
||||
status.className = "purchase-match-status";
|
||||
status.textContent = "唯一匹配";
|
||||
const header = document.createElement("div");
|
||||
header.className = "purchase-line-header";
|
||||
header.append(title, status);
|
||||
card.appendChild(header);
|
||||
card.appendChild(buildDefinitionList({
|
||||
"本次数量": `${displayNumber(line.invoiceQuantity)} ${line.invoiceUnit}`,
|
||||
"本次单价": displayNumber(line.invoiceUnitPrice),
|
||||
"本次税率": displayPercent(line.invoiceTaxRate),
|
||||
"本次税额": displayNumber(line.invoiceTaxAmount),
|
||||
"本次行金额": displayNumber(line.invoiceLineAmount),
|
||||
"来源采购单": line.sourceOrderNumber,
|
||||
"来源明细": line.sourceLineId,
|
||||
"来源剩余": `${displayNumber(line.sourceRemainingQuantity)} ${line.sourceUnit}`,
|
||||
"来源单价": displayNumber(line.sourceUnitPrice),
|
||||
"来源税率": displayPercent(line.sourceTaxRate),
|
||||
"来源汇率": displayNumber(line.sourceExchangeRate)
|
||||
}, 16));
|
||||
section.appendChild(card);
|
||||
});
|
||||
return section;
|
||||
}
|
||||
|
||||
function buildDynamicModulePreview(preview) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "dynamic-module-preview";
|
||||
const heading = document.createElement("h3");
|
||||
heading.textContent = preview.mode === "create"
|
||||
? "低代码动态参数 · 新增"
|
||||
: "低代码动态参数 · 并发修改";
|
||||
section.appendChild(heading);
|
||||
|
||||
if (preview.mode === "update") {
|
||||
section.appendChild(buildDefinitionList({
|
||||
"目标记录": preview.recordDisplay,
|
||||
"修改字段": preview.changes.length
|
||||
}, 4));
|
||||
const changes = document.createElement("div");
|
||||
changes.className = "dynamic-change-list";
|
||||
preview.changes.forEach(change => {
|
||||
const card = dynamicFieldCard(
|
||||
change.label,
|
||||
change.parameterId,
|
||||
change.valueType);
|
||||
const comparison = document.createElement("div");
|
||||
comparison.className = "dynamic-change-values";
|
||||
comparison.append(
|
||||
dynamicChangeValue("修改前", change.previousValue),
|
||||
dynamicChangeValue("修改后", change.newValue));
|
||||
card.appendChild(comparison);
|
||||
changes.appendChild(card);
|
||||
});
|
||||
section.appendChild(changes);
|
||||
return section;
|
||||
}
|
||||
|
||||
const master = document.createElement("section");
|
||||
master.className = "dynamic-value-group";
|
||||
const masterHeading = document.createElement("h4");
|
||||
masterHeading.textContent = `主表字段(${preview.masterValues.length} 项)`;
|
||||
master.append(masterHeading, buildDynamicValueCards(preview.masterValues));
|
||||
section.appendChild(master);
|
||||
|
||||
const details = document.createElement("section");
|
||||
details.className = "dynamic-detail-preview";
|
||||
const detailHeading = document.createElement("h4");
|
||||
detailHeading.textContent = `明细数据(${preview.detailRows.length} 行)`;
|
||||
details.appendChild(detailHeading);
|
||||
if (preview.detailRows.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "dynamic-empty-value";
|
||||
empty.textContent = "本次新增没有明细行。";
|
||||
details.appendChild(empty);
|
||||
} else {
|
||||
preview.detailRows.forEach(row => {
|
||||
const card = document.createElement("article");
|
||||
card.className = "dynamic-detail-row";
|
||||
const title = document.createElement("h5");
|
||||
title.textContent = `第 ${row.rowNumber} 行 · ${row.values.length} 个字段`;
|
||||
card.append(title, buildDynamicValueCards(row.values));
|
||||
details.appendChild(card);
|
||||
});
|
||||
}
|
||||
section.appendChild(details);
|
||||
return section;
|
||||
}
|
||||
|
||||
function buildDynamicValueCards(values) {
|
||||
const list = document.createElement("div");
|
||||
list.className = "dynamic-value-list";
|
||||
if (values.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "dynamic-empty-value";
|
||||
empty.textContent = "没有需要写入的字段。";
|
||||
list.appendChild(empty);
|
||||
return list;
|
||||
}
|
||||
values.forEach(value => {
|
||||
const card = dynamicFieldCard(
|
||||
value.label,
|
||||
value.parameterId,
|
||||
value.valueType);
|
||||
const content = document.createElement("div");
|
||||
content.className = "dynamic-field-value";
|
||||
content.textContent = value.value === "" ? "(空值)" : value.value;
|
||||
card.appendChild(content);
|
||||
list.appendChild(card);
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
function dynamicFieldCard(label, parameterId, valueType) {
|
||||
const card = document.createElement("article");
|
||||
card.className = "dynamic-field-card";
|
||||
const header = document.createElement("div");
|
||||
header.className = "dynamic-field-header";
|
||||
const title = document.createElement("h5");
|
||||
title.textContent = label;
|
||||
const type = document.createElement("span");
|
||||
type.className = "dynamic-value-type";
|
||||
type.textContent = valueType;
|
||||
header.append(title, type);
|
||||
const identifier = document.createElement("code");
|
||||
identifier.className = "dynamic-parameter-id";
|
||||
identifier.textContent = parameterId;
|
||||
card.append(header, identifier);
|
||||
return card;
|
||||
}
|
||||
|
||||
function dynamicChangeValue(label, value) {
|
||||
const item = document.createElement("div");
|
||||
const heading = document.createElement("span");
|
||||
heading.textContent = label;
|
||||
const content = document.createElement("div");
|
||||
content.textContent = value === "" ? "(空值)" : value;
|
||||
item.append(heading, content);
|
||||
return item;
|
||||
}
|
||||
|
||||
function displayNumber(value) {
|
||||
return new Intl.NumberFormat("zh-CN", {
|
||||
maximumFractionDigits: 8,
|
||||
useGrouping: true
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function displayPercent(value) {
|
||||
return new Intl.NumberFormat("zh-CN", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 6
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function safeError(error) {
|
||||
return runtime.bridgeFailureSummary(error).text;
|
||||
}
|
||||
|
||||
function appendCorrelation(message, correlationId) {
|
||||
return correlationId && /^[A-Za-z0-9_.:-]{8,128}$/.test(correlationId)
|
||||
? message + "(关联 ID:" + correlationId + ")"
|
||||
: message;
|
||||
}
|
||||
|
||||
function resizeInput() {
|
||||
input.style.height = "auto";
|
||||
input.style.height = Math.min(input.scrollHeight, 90) + "px";
|
||||
}
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
resizeInput();
|
||||
updateComposerState();
|
||||
});
|
||||
updateComposerState();
|
||||
})();
|
||||
Reference in New Issue
Block a user