899 lines
33 KiB
JavaScript
899 lines
33 KiB
JavaScript
(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();
|
||
})();
|