1672 lines
55 KiB
JavaScript
1672 lines
55 KiB
JavaScript
'use strict';
|
||
|
||
const BUSINESS_SOURCE = 'LTJT_MOCK_BUSINESS';
|
||
const EXTENSION_SOURCE = 'LTJT_ORDER_ASSISTANT_EXTENSION';
|
||
const pending = new Map();
|
||
let currentTaskId = sessionStorage.getItem('ltjt_mock_current_task_id') || '';
|
||
let pollTimer = null;
|
||
let eventStream = null;
|
||
let bridgeConnected = false;
|
||
let erpAutomationEnabled = true;
|
||
let extensionCompatible = false;
|
||
let remoteSyncInProgress = false;
|
||
let syncRequested = false;
|
||
let csrfToken = '';
|
||
let authUser = null;
|
||
let taskStore = [];
|
||
|
||
const MAX_TASK_LOG_ENTRIES = 300;
|
||
const BROWSER_CONNECTION_ID = `administrator-browser:${location.origin}`;
|
||
const REQUIRED_EXTENSION_VERSION = '0.3.1';
|
||
const TERMINAL_TASK_STATUSES = new Set([
|
||
'completed',
|
||
'reconciliation_pending',
|
||
'blocked',
|
||
'failed',
|
||
'cancelled',
|
||
'dry_run',
|
||
'parse_failed'
|
||
]);
|
||
|
||
const SAMPLE_RAW = `下单模式:团队-单个下单
|
||
订单性质:测试
|
||
产品名称:遇见老挝
|
||
出发日期:2026-08-11
|
||
|
||
人数:
|
||
成人:2
|
||
小孩占床:1
|
||
小孩不占床:1
|
||
婴儿:0
|
||
领队:0
|
||
|
||
用房:
|
||
SGL:0
|
||
TWN:0
|
||
TRP:0
|
||
DBL:2
|
||
HNM:0
|
||
TL:0
|
||
|
||
单价:
|
||
成人:520
|
||
小孩占床:220
|
||
小孩不占床:120
|
||
婴儿:0
|
||
领队:0
|
||
|
||
计调OP:测试
|
||
销售人:琳琳
|
||
|
||
备注:笔记本接管重复拦截测试,确认真实保存 ERP。`;
|
||
|
||
function $(selector) {
|
||
return document.querySelector(selector);
|
||
}
|
||
|
||
async function apiRequest(path, options = {}) {
|
||
const method = String(options.method || 'GET').toUpperCase();
|
||
const headers = new Headers(options.headers || {});
|
||
if (options.body !== undefined && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
|
||
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS' && csrfToken) {
|
||
headers.set('X-CSRF-Token', csrfToken);
|
||
}
|
||
const response = await fetch(path, {
|
||
...options,
|
||
method,
|
||
headers,
|
||
credentials: 'same-origin',
|
||
body: options.body !== undefined && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body
|
||
});
|
||
let payload = null;
|
||
try {
|
||
payload = await response.json();
|
||
} catch (error) {
|
||
payload = null;
|
||
}
|
||
if (response.status === 401 && path !== '/api/auth/login') {
|
||
showLoginPanel('登录已过期,请重新登录。');
|
||
}
|
||
if (!response.ok) {
|
||
throw new Error(payload?.message || payload?.error || `请求失败:HTTP ${response.status}`);
|
||
}
|
||
return payload || {};
|
||
}
|
||
|
||
function showLoginPanel(message = '') {
|
||
authUser = null;
|
||
csrfToken = '';
|
||
if (eventStream) {
|
||
eventStream.close();
|
||
eventStream = null;
|
||
}
|
||
const panel = $('#loginPanel');
|
||
const workbench = $('#workbench');
|
||
const authState = $('#authState');
|
||
const logoutButton = $('#logoutButton');
|
||
if (panel) panel.hidden = false;
|
||
if (workbench) workbench.hidden = true;
|
||
if (authState) authState.hidden = true;
|
||
if (logoutButton) logoutButton.hidden = true;
|
||
const error = $('#loginError');
|
||
if (error) error.textContent = message;
|
||
}
|
||
|
||
function showAuthenticatedApp(user) {
|
||
authUser = user;
|
||
const panel = $('#loginPanel');
|
||
const workbench = $('#workbench');
|
||
const authState = $('#authState');
|
||
const logoutButton = $('#logoutButton');
|
||
if (panel) panel.hidden = true;
|
||
if (workbench) workbench.hidden = false;
|
||
if (authState) {
|
||
authState.hidden = false;
|
||
authState.textContent = `${user.username} · 管理员`;
|
||
}
|
||
if (logoutButton) logoutButton.hidden = false;
|
||
}
|
||
|
||
async function refreshCsrfToken() {
|
||
const result = await apiRequest('/api/auth/csrf');
|
||
csrfToken = result.csrf_token || '';
|
||
if (!csrfToken) throw new Error('服务端未返回 CSRF 会话令牌。');
|
||
}
|
||
|
||
async function syncRemoteTasks() {
|
||
if (!authUser) return;
|
||
if (remoteSyncInProgress) {
|
||
syncRequested = true;
|
||
return;
|
||
}
|
||
remoteSyncInProgress = true;
|
||
try {
|
||
do {
|
||
syncRequested = false;
|
||
const result = await apiRequest('/api/tasks');
|
||
// Keep cancelled records durable on the server, but hide them from the
|
||
// active task-card list even if an older server or an SSE race returns one.
|
||
taskStore = (Array.isArray(result.tasks) ? result.tasks : []).filter((task) => (
|
||
task?.status !== 'cancelled' && task?.result?.status !== 'cancelled'
|
||
));
|
||
if (!taskStore.some((task) => task.task_id === currentTaskId)) {
|
||
currentTaskId = taskStore[0]?.task_id || '';
|
||
}
|
||
if (currentTaskId) sessionStorage.setItem('ltjt_mock_current_task_id', currentTaskId);
|
||
else sessionStorage.removeItem('ltjt_mock_current_task_id');
|
||
renderTaskCards();
|
||
} while (syncRequested && authUser);
|
||
} finally {
|
||
remoteSyncInProgress = false;
|
||
if (syncRequested && authUser) {
|
||
syncRequested = false;
|
||
queueMicrotask(() => syncRemoteTasks().catch((error) => {
|
||
setOutput({ status: 'sync_error', message: error.message });
|
||
}));
|
||
}
|
||
}
|
||
}
|
||
|
||
function startRemoteEventStream() {
|
||
if (eventStream) eventStream.close();
|
||
const lastEventId = taskStore.reduce((highest, task) => (
|
||
Math.max(highest, Number(task?.last_event_id || 0))
|
||
), 0);
|
||
eventStream = new EventSource(`/api/events?since=${encodeURIComponent(lastEventId)}`);
|
||
eventStream.addEventListener('task', () => {
|
||
syncRemoteTasks().catch((error) => {
|
||
setOutput({ status: 'sync_error', message: error.message });
|
||
});
|
||
});
|
||
eventStream.onerror = () => {
|
||
// EventSource reconnects automatically; polling remains the fallback authority refresh.
|
||
};
|
||
}
|
||
|
||
function normalizeText(text) {
|
||
return String(text || '').replace(/\r/g, '').trim();
|
||
}
|
||
|
||
function loadTaskStore() {
|
||
return [];
|
||
}
|
||
|
||
function saveTaskStore() {
|
||
// The backend is authoritative. The in-memory array is a render cache only.
|
||
}
|
||
|
||
function normalizeLogMessage(value) {
|
||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function taskLogLevel(status, message) {
|
||
const source = `${status || ''} ${message || ''}`;
|
||
if (/failed|blocked|error|uncertain|失败|阻断|错误|不可用/i.test(source)) return 'ERROR';
|
||
if (/completed|received|accepted|confirmed|success|完成|收到|接单|确认|成功/i.test(source)) return 'OK';
|
||
return 'INFO';
|
||
}
|
||
|
||
function appendTaskLogEntry(task, message, { status = '', level } = {}) {
|
||
const normalizedMessage = normalizeLogMessage(message);
|
||
if (!task || !normalizedMessage) return;
|
||
if (!Array.isArray(task.logs)) task.logs = [];
|
||
const entry = {
|
||
at: new Date().toISOString(),
|
||
level: level || taskLogLevel(status, normalizedMessage),
|
||
status: status || '',
|
||
message: normalizedMessage
|
||
};
|
||
const last = task.logs[task.logs.length - 1];
|
||
if (last && last.message === entry.message && last.status === entry.status) return;
|
||
task.logs.push(entry);
|
||
if (task.logs.length > MAX_TASK_LOG_ENTRIES) {
|
||
task.logs.splice(0, task.logs.length - MAX_TASK_LOG_ENTRIES);
|
||
}
|
||
}
|
||
|
||
function taskSummary(operation) {
|
||
const data = operation?.data || {};
|
||
const counts = data.passenger_counts || {};
|
||
const action = operation?.action || operation?.operation || '';
|
||
const labels = {
|
||
team_order_create: '团队-单个下单',
|
||
team_order_batch_create: '团队-批量下单',
|
||
shared_plan_create: '散拼-创建母团计划',
|
||
shared_child_order_create: '散拼-录入子单',
|
||
order_update: '已有订单更新',
|
||
passenger_list_import: '旅客名单导入',
|
||
confirmation_export: '确认件导出/恢复',
|
||
create_order: 'ERP 创建订单',
|
||
update_order: 'ERP 更新订单',
|
||
export_confirmation: 'ERP 导出恢复'
|
||
};
|
||
return {
|
||
action,
|
||
operation_label: labels[action] || action || '输入解析任务',
|
||
route: operation?.route || '',
|
||
identifier: operation?.identifier || data.existing_refs?.identifier || data.existing_refs?.order_no || '',
|
||
product: data.product?.name || '',
|
||
departure_date: data.departure_dates?.[0] || '',
|
||
test_marker: data.test_marker || '',
|
||
passenger_total: ['adult', 'child_bed', 'child_no_bed', 'infant', 'leader'].reduce((sum, key) => sum + toNumber(counts[key]), 0),
|
||
op_user: data.op_user?.name || '',
|
||
sales_user: data.sales_user?.name || ''
|
||
};
|
||
}
|
||
|
||
function upsertLocalTask(task) {
|
||
const index = taskStore.findIndex((item) => item.task_id === task.task_id);
|
||
if (index >= 0) {
|
||
const existing = taskStore[index];
|
||
const previousMessage = existing.message;
|
||
const previousStatus = existing.status;
|
||
taskStore[index] = { ...existing, ...task, updated_at: new Date().toISOString() };
|
||
if (task.message && (task.message !== previousMessage || task.status !== previousStatus)) {
|
||
appendTaskLogEntry(taskStore[index], task.message, { status: task.status });
|
||
}
|
||
} else {
|
||
const nextTask = { ...task, updated_at: new Date().toISOString() };
|
||
appendTaskLogEntry(nextTask, nextTask.message, { status: nextTask.status });
|
||
taskStore.unshift(nextTask);
|
||
}
|
||
saveTaskStore();
|
||
renderTaskCards();
|
||
}
|
||
|
||
function updateLocalTask(taskId, patch) {
|
||
const existing = taskStore.find((item) => item.task_id === taskId);
|
||
if (!existing) return;
|
||
const previousMessage = existing.message;
|
||
const previousStatus = existing.status;
|
||
Object.assign(existing, patch, { updated_at: new Date().toISOString() });
|
||
const messageChanged = typeof patch.message === 'string' && patch.message !== previousMessage;
|
||
const statusChanged = patch.status && patch.status !== previousStatus;
|
||
if (messageChanged || (statusChanged && !patch.message)) {
|
||
appendTaskLogEntry(existing, patch.message || `状态更新:${patch.status}`, { status: patch.status });
|
||
}
|
||
saveTaskStore();
|
||
renderTaskCards();
|
||
}
|
||
|
||
function selectedTask() {
|
||
return taskStore.find((item) => item.task_id === currentTaskId) || taskStore[0] || null;
|
||
}
|
||
|
||
function taskStatusText(task) {
|
||
if (!task) return '未知';
|
||
const status = task.status || task.result?.status || 'created';
|
||
if (requiresManualConfirmation(task)) return '待确认';
|
||
const stage = task.result?.stage || task.stage || '';
|
||
if (status === 'running' && stage === 'parse') return '发送中 / 解析中';
|
||
if (status === 'parse_queued') return '排队解析';
|
||
if (status === 'parse_running') return '解析中';
|
||
if (['agent_parse_blocked', 'parse_failed', 'parse_blocked'].includes(status)) return '外部解析失败';
|
||
if (status === 'agent_parse_passed') return '外部解析完成';
|
||
if (status === 'parse') return '解析中';
|
||
if (status === 'completed') return '已完成';
|
||
if (status === 'running') return '执行中';
|
||
if (status === 'accepted') return '已接单';
|
||
if (status === 'confirmed') return '已确认,提交中';
|
||
if (status === 'queued') return '排队中';
|
||
if (status === 'paused') return '已暂停';
|
||
if (status === 'awaiting_confirmation') return '等待人工确认';
|
||
if (status === 'confirmed') return '已确认,待提交';
|
||
if (status === 'waiting_extension') return '等待插件连接';
|
||
if (status === 'parse_failed') return '拆解失败';
|
||
if (status === 'parse_blocked') return '拆解失败';
|
||
if (status === 'dry_run') return '已规划,未写入 ERP';
|
||
if (status === 'operation_blocked') return '业务未接入';
|
||
if (status === 'execution_uncertain') return '保存状态待回查';
|
||
if (status === 'reconciliation_pending') return '待回查';
|
||
if (status === 'cancelled') return '已取消';
|
||
if (status === 'post_save_recovery_required') return '需要恢复导出';
|
||
if (status === 'blocked') return '已阻断';
|
||
if (status === 'saved_unverified') return '已提交待回查';
|
||
return stage || status;
|
||
}
|
||
|
||
function statusClassName(status) {
|
||
const normalized = String(status || 'created').replace(/[^a-z0-9_-]/gi, '_');
|
||
return isFailureStatus(normalized) ? `${normalized} is-failure` : normalized;
|
||
}
|
||
|
||
function isFailureStatus(status) {
|
||
const normalized = String(status || '').toLowerCase();
|
||
return /(?:failed|blocked|error|uncertain)/.test(normalized) || normalized === 'saved_unverified';
|
||
}
|
||
|
||
function requiresManualConfirmation(task) {
|
||
if (!task?.operation || task.confirmed_at || task.handoff_status === 'accepted') return false;
|
||
const status = task.status || task.result?.status || 'created';
|
||
return !['parse_failed', 'parse_blocked', 'completed'].includes(status);
|
||
}
|
||
|
||
function taskCanonicalStatus(task) {
|
||
return String(task?.status || task?.result?.status || 'created');
|
||
}
|
||
|
||
function isTaskPollable(task) {
|
||
const status = taskCanonicalStatus(task);
|
||
if (TERMINAL_TASK_STATUSES.has(status)) return false;
|
||
return Boolean(
|
||
task?.confirmed_at
|
||
&& (
|
||
['queued', 'accepted', 'running'].includes(status)
|
||
|| ['accepted', 'running'].includes(task?.handoff_status)
|
||
)
|
||
);
|
||
}
|
||
|
||
function isExecutionClaimed(task) {
|
||
return Boolean(
|
||
task?.confirmed_at
|
||
&& (
|
||
['queued', 'accepted', 'running'].includes(taskCanonicalStatus(task))
|
||
|| ['accepted', 'running', 'reconciliation_pending'].includes(task?.handoff_status)
|
||
)
|
||
);
|
||
}
|
||
|
||
function canStartConfirmedTask(task) {
|
||
return Boolean(
|
||
task?.operation
|
||
&& task?.confirmed_at
|
||
&& task?.status === 'confirmed'
|
||
&& task?.handoff_status === 'awaiting_handoff'
|
||
);
|
||
}
|
||
|
||
function taskStateClass(status) {
|
||
if (['completed', 'parse_response_received', 'task_confirmed'].includes(status)) return 'state-ok';
|
||
if (isFailureStatus(status)) return 'state-bad';
|
||
return 'state-warn';
|
||
}
|
||
|
||
function taskDisplayText(task, output) {
|
||
const status = output?.status;
|
||
if (status === 'parse_sending') return '正在发送到外部解析服务';
|
||
if (status === 'parse_response_received') return '已收到外部解析结果';
|
||
if (status === 'parse_response_blocked') return '外部解析服务已阻断';
|
||
if (status === 'agent_parse_blocked') return '外部 Skill 阻断';
|
||
if (status === 'agent_parse_passed') return '外部解析完成';
|
||
if (status === 'create_error') return '创建失败';
|
||
return taskStatusText(task);
|
||
}
|
||
|
||
function selectedOperation(task) {
|
||
return task?.operation || task?.result?.operation || null;
|
||
}
|
||
|
||
function formatBusinessDate(value) {
|
||
return value ? String(value) : '未填写';
|
||
}
|
||
|
||
function formatDateTime(value) {
|
||
if (!value) return '未记录';
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return String(value);
|
||
return date.toLocaleString('zh-CN', { hour12: false });
|
||
}
|
||
|
||
function formatMoney(value) {
|
||
const amount = toNumber(value);
|
||
return `¥${amount.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
||
}
|
||
|
||
const DISPLAY_REDACT_KEY_PATTERN = /(?:api[_-]?key|authorization|cookie|password|secret|csrf(?:[_-]?token)?)/i;
|
||
const DISPLAY_MASK_KEY_PATTERN = /(?:session[_-]?id|idempotency[_-]?key)/i;
|
||
|
||
function redactJsonForDisplay(value, key = '') {
|
||
if (DISPLAY_REDACT_KEY_PATTERN.test(key)) return '[已隐藏]';
|
||
if (DISPLAY_MASK_KEY_PATTERN.test(key)) return value ? '[已生成]' : '';
|
||
if (Array.isArray(value)) return value.map((item) => redactJsonForDisplay(item, key));
|
||
if (value && typeof value === 'object') {
|
||
return Object.fromEntries(
|
||
Object.entries(value).map(([entryKey, entryValue]) => [
|
||
entryKey,
|
||
redactJsonForDisplay(entryValue, entryKey)
|
||
])
|
||
);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function taskResponsePayload(task) {
|
||
if (!task) return null;
|
||
const parseResponse = task.parse_response || task.parse_result || null;
|
||
return {
|
||
parse_response: parseResponse,
|
||
operation: task.operation || parseResponse?.operation || null,
|
||
handoff_response: task.handoff_response || null,
|
||
executor_response: task.result || null
|
||
};
|
||
}
|
||
|
||
function formatTaskResponse(task) {
|
||
const payload = taskResponsePayload(task);
|
||
if (!payload || Object.values(payload).every((value) => value === null)) {
|
||
return '等待返回 JSON……';
|
||
}
|
||
return JSON.stringify(redactJsonForDisplay(payload), null, 2);
|
||
}
|
||
|
||
function el(tag, className, text) {
|
||
const node = document.createElement(tag);
|
||
if (className) node.className = className;
|
||
if (text !== undefined) node.textContent = text;
|
||
return node;
|
||
}
|
||
|
||
function appendField(container, label, value) {
|
||
const item = el('div', 'detail-field');
|
||
item.append(el('span', '', label));
|
||
const displayValue = value === undefined || value === null || value === '' ? '未填写' : value;
|
||
item.append(el('strong', '', displayValue));
|
||
container.append(item);
|
||
}
|
||
|
||
function appendMetric(container, label, value) {
|
||
const item = el('div', 'metric');
|
||
item.append(el('span', '', label));
|
||
item.append(el('strong', '', value || '未填写'));
|
||
container.append(item);
|
||
}
|
||
|
||
function createDetailBlock(title) {
|
||
const block = el('section', 'detail-block');
|
||
block.append(el('h3', '', title));
|
||
return block;
|
||
}
|
||
|
||
function passengerRows(counts) {
|
||
return [
|
||
['成人', counts.adult],
|
||
['小孩占床', counts.child_bed],
|
||
['小孩不占床', counts.child_no_bed],
|
||
['婴儿', counts.infant],
|
||
['领队', counts.leader]
|
||
].map(([label, value]) => [label, toNumber(value)]);
|
||
}
|
||
|
||
function priceRows(data) {
|
||
const counts = data.passenger_counts || {};
|
||
const prices = data.prices || {};
|
||
return [
|
||
['成人', counts.adult, prices.adult],
|
||
['小孩占床', counts.child_bed, prices.child_bed],
|
||
['小孩不占床', counts.child_no_bed, prices.child_no_bed],
|
||
['婴儿', counts.infant, prices.infant],
|
||
['领队', counts.leader, prices.leader]
|
||
].map(([label, count, price]) => {
|
||
const normalizedCount = toNumber(count);
|
||
const normalizedPrice = toNumber(price);
|
||
return {
|
||
label,
|
||
count: normalizedCount,
|
||
price: normalizedPrice,
|
||
amount: normalizedCount * normalizedPrice
|
||
};
|
||
}).filter((row) => row.count || row.price);
|
||
}
|
||
|
||
function renderMiniTable(rows, columns) {
|
||
const table = el('div', 'mini-table');
|
||
const head = el('div', 'mini-table-row mini-table-head');
|
||
for (const column of columns) head.append(el('span', '', column.label));
|
||
table.append(head);
|
||
for (const row of rows) {
|
||
const line = el('div', 'mini-table-row');
|
||
for (const column of columns) line.append(el('span', '', column.render(row)));
|
||
table.append(line);
|
||
}
|
||
return table;
|
||
}
|
||
|
||
function taskStepState(task, step) {
|
||
const status = task.status || task.result?.status || 'created';
|
||
const handoff = task.handoff_status || '';
|
||
const hasOperation = Boolean(selectedOperation(task));
|
||
if (step === 'parse') {
|
||
if (['parse_failed', 'parse_blocked'].includes(status)) return 'blocked';
|
||
return hasOperation ? 'done' : 'active';
|
||
}
|
||
if (step === 'handoff') {
|
||
if (status === 'waiting_extension') return 'active';
|
||
if (handoff === 'accepted' || ['accepted', 'running', 'dry_run', 'saved_unverified', 'completed', 'blocked'].includes(status)) return 'done';
|
||
return hasOperation ? 'active' : 'wait';
|
||
}
|
||
if (step === 'erp') {
|
||
if (status === 'dry_run') return 'done';
|
||
if (status === 'blocked') return 'blocked';
|
||
if (['saved_unverified', 'completed'].includes(status)) return 'done';
|
||
if (['accepted', 'running', 'queued'].includes(status)) return 'active';
|
||
return 'wait';
|
||
}
|
||
if (step === 'verify') {
|
||
if (status === 'completed') return 'done';
|
||
if (status === 'saved_unverified') return 'active';
|
||
if (status === 'blocked') return 'blocked';
|
||
return 'wait';
|
||
}
|
||
return 'wait';
|
||
}
|
||
|
||
function parseErpReceiptText(value) {
|
||
const raw = String(value || '');
|
||
const text = raw
|
||
.replace(/\r/g, '')
|
||
.replace(/<br\s*\/?>/gi, '\n')
|
||
.replace(/<\/(p|div|li|tr)>/gi, '\n')
|
||
.replace(/<[^>]+>/g, '')
|
||
.replace(/ /gi, ' ')
|
||
.replace(/&/gi, '&')
|
||
.replace(/</gi, '<')
|
||
.replace(/>/gi, '>')
|
||
.trim();
|
||
if (!text) return null;
|
||
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
|
||
const valueAfterLabel = (label) => {
|
||
const pattern = new RegExp(`${label}\\s*[::]\\s*([^\\n]+)`, 'i');
|
||
const line = lines.find((item) => pattern.test(item));
|
||
const source = line || text;
|
||
const match = source.match(pattern);
|
||
const value = match ? match[1].trim() : '';
|
||
return label === '团号' ? value.replace(/[\s。;;,,].*$/, '').trim() : value;
|
||
};
|
||
const groupNumber = valueAfterLabel('团号');
|
||
const product = valueAfterLabel('产品');
|
||
const passengerText = valueAfterLabel('人数');
|
||
const success = /下单成功|成功|success|ສໍາເລັດ/i.test(text);
|
||
if (!groupNumber && !product && !passengerText) return null;
|
||
return {
|
||
status: success ? 'submit_success_receipt' : 'submit_receipt',
|
||
success,
|
||
title: lines[0] || '',
|
||
group_number: groupNumber,
|
||
product,
|
||
passenger_text: passengerText
|
||
};
|
||
}
|
||
|
||
function receiptFromTask(task) {
|
||
const result = task?.result || {};
|
||
const direct = result.erp_receipt || result.report?.erp_receipt;
|
||
if (direct) return direct;
|
||
const alerts = [
|
||
...(Array.isArray(result.alerts) ? result.alerts : []),
|
||
...(Array.isArray(result.report?.alerts) ? result.report.alerts : [])
|
||
];
|
||
const alertReceipt = alerts.map((alert) => alert.erp_receipt || parseErpReceiptText(alert.message)).find(Boolean);
|
||
if (alertReceipt) return alertReceipt;
|
||
return [
|
||
result.message,
|
||
result.report?.message,
|
||
task?.message,
|
||
result.report?.raw_message
|
||
].map(parseErpReceiptText).find(Boolean) || null;
|
||
}
|
||
|
||
function taskStageSnapshot(task) {
|
||
const emptyStage = (note = '等待任务') => ({
|
||
mode: 'pending',
|
||
current: null,
|
||
currentLabel: '',
|
||
note
|
||
});
|
||
if (!task) {
|
||
return {
|
||
agent: emptyStage(),
|
||
business: emptyStage(),
|
||
receipt: emptyStage()
|
||
};
|
||
}
|
||
|
||
const result = task.result || {};
|
||
const status = String(result.status || task.status || 'created');
|
||
const stage = String(result.stage || task.stage || '');
|
||
const operation = selectedOperation(task);
|
||
const receipt = receiptFromTask(task);
|
||
const parseFailed = ['agent_parse_blocked', 'parse_failed', 'parse_blocked'].includes(status)
|
||
|| (stage === 'parse' && /failed|blocked/i.test(status));
|
||
const agentSucceeded = Boolean(operation)
|
||
|| ['agent_parse_passed', 'awaiting_confirmation', 'confirmed', 'queued', 'accepted', 'running', 'paused', 'waiting_extension', 'saved_unverified', 'completed', 'blocked'].includes(status);
|
||
|
||
const agent = parseFailed
|
||
? {
|
||
mode: 'failed',
|
||
current: 'failed',
|
||
currentLabel: 'AI处理失败',
|
||
note: '外部解析未通过,未进入业务系统处理。'
|
||
}
|
||
: agentSucceeded
|
||
? {
|
||
mode: 'complete',
|
||
current: 'success',
|
||
currentLabel: 'AI处理成功',
|
||
note: '已完成指令拆解,等待或已进入业务系统处理。'
|
||
}
|
||
: {
|
||
mode: 'active',
|
||
current: 'submitted',
|
||
currentLabel: 'AI处理中',
|
||
note: '已提交原始指令,正在等待解析结果。'
|
||
};
|
||
|
||
let business = emptyStage('等待 Agent 处理完成');
|
||
if (agentSucceeded) {
|
||
if (status === 'completed' || status === 'batch_fallback_completed') {
|
||
business = {
|
||
mode: 'complete',
|
||
current: 'completed',
|
||
currentLabel: '业务处理已完成',
|
||
note: '业务系统已完成提交和回查流程。'
|
||
};
|
||
} else if (status === 'reconciliation_pending' || status === 'execution_uncertain' || status === 'saved_unverified') {
|
||
business = {
|
||
mode: 'active',
|
||
current: 'processing',
|
||
currentLabel: '业务结果待回查',
|
||
note: 'ERP 可能已写入,自动执行已停止且不会重试。'
|
||
};
|
||
} else if (['blocked', 'cancelled', 'extension_error', 'batch_fallback_incomplete', 'live_submit_blocked', 'preflight_blocked'].includes(status)) {
|
||
business = {
|
||
mode: 'failed',
|
||
current: 'processing',
|
||
currentLabel: '业务处理失败',
|
||
note: '业务系统或插件执行未完成,请查看下方流式日志。'
|
||
};
|
||
} else if (status === 'accepted') {
|
||
business = {
|
||
mode: 'active',
|
||
current: 'accepted',
|
||
currentLabel: '业务系统已接单',
|
||
note: '插件已接单,准备执行 ERP 操作。'
|
||
};
|
||
} else if (status === 'running' || ['browser_execution', 'open_order_form', 'wait_order_form', 'preflight', 'live_submit', 'verification', 'batch_fallback'].includes(stage)) {
|
||
business = {
|
||
mode: 'active',
|
||
current: 'processing',
|
||
currentLabel: '业务系统处理中',
|
||
note: '正在执行 ERP 操作或回查。'
|
||
};
|
||
} else if (['queued', 'confirmed', 'waiting_extension', 'paused'].includes(status)) {
|
||
business = {
|
||
mode: 'active',
|
||
current: null,
|
||
currentLabel: status === 'paused' ? '等待恢复执行' : '等待插件接单',
|
||
note: status === 'paused' ? '插件 ERP 操作已暂停。' : '已确认,等待插件接单。'
|
||
};
|
||
} else {
|
||
business = {
|
||
mode: 'pending',
|
||
current: null,
|
||
currentLabel: '待提交执行',
|
||
note: '等待人工确认后交给业务系统执行。'
|
||
};
|
||
}
|
||
}
|
||
|
||
let receiptStage;
|
||
if (receipt) {
|
||
receiptStage = {
|
||
mode: 'complete',
|
||
current: 'received',
|
||
currentLabel: '已获取回执',
|
||
note: receipt.group_number ? `回执团号:${receipt.group_number}` : '已收到 ERP 返回的保存回执。'
|
||
};
|
||
} else if (status === 'reconciliation_pending' || status === 'execution_uncertain' || status === 'saved_unverified') {
|
||
receiptStage = {
|
||
mode: 'active',
|
||
current: 'pending',
|
||
currentLabel: '待人工回查',
|
||
note: '请按 ERP 团号、订单号或测试标记核对,禁止重新执行。'
|
||
};
|
||
} else if (business.mode === 'failed') {
|
||
receiptStage = {
|
||
mode: 'failed',
|
||
current: 'pending',
|
||
currentLabel: '回执获取失败',
|
||
note: '当前没有可用的 ERP 回执。'
|
||
};
|
||
} else if (business.mode === 'complete') {
|
||
receiptStage = {
|
||
mode: 'active',
|
||
current: 'pending',
|
||
currentLabel: '等待回执',
|
||
note: '业务流程完成,等待 ERP 回执。'
|
||
};
|
||
} else {
|
||
receiptStage = emptyStage('业务处理完成后获取 ERP 回执');
|
||
}
|
||
|
||
return { agent, business, receipt: receiptStage };
|
||
}
|
||
|
||
function renderTaskStages(task) {
|
||
const container = $('#taskStages');
|
||
if (!container) return;
|
||
container.innerHTML = '';
|
||
|
||
const snapshot = taskStageSnapshot(task);
|
||
const stages = [
|
||
{
|
||
key: 'agent',
|
||
index: '01',
|
||
title: 'AI处理',
|
||
options: [
|
||
{ key: 'submitted', label: '已提交' },
|
||
{ key: 'success', label: '已成功' },
|
||
{ key: 'failed', label: '已失败' }
|
||
]
|
||
},
|
||
{
|
||
key: 'business',
|
||
index: '02',
|
||
title: '业务系统处理',
|
||
options: [
|
||
{ key: 'accepted', label: '已接单' },
|
||
{ key: 'processing', label: '处理中' },
|
||
{ key: 'completed', label: '已完成' }
|
||
]
|
||
},
|
||
{
|
||
key: 'receipt',
|
||
index: '03',
|
||
title: '获取回执',
|
||
options: [
|
||
{ key: 'pending', label: '待获取' },
|
||
{ key: 'received', label: '已获取回执' }
|
||
]
|
||
}
|
||
];
|
||
|
||
const flow = el('div', 'task-stage-flow');
|
||
for (const stage of stages) {
|
||
const state = snapshot[stage.key];
|
||
const card = el('article', `task-stage-card is-${state.mode}`);
|
||
const heading = el('div', 'task-stage-heading');
|
||
heading.append(el('span', 'task-stage-index', stage.index));
|
||
const titleBlock = el('div', 'task-stage-title-block');
|
||
titleBlock.append(el('strong', 'task-stage-title', stage.title));
|
||
titleBlock.append(el('span', 'task-stage-current', state.currentLabel || '待开始'));
|
||
heading.append(titleBlock);
|
||
card.append(heading);
|
||
|
||
const statuses = el('div', 'task-stage-statuses');
|
||
statuses.setAttribute('role', 'list');
|
||
const visibleOptions = stage.key === 'agent'
|
||
? stage.options.filter((option) => !['success', 'failed'].includes(option.key) || option.key === state.current)
|
||
: stage.options;
|
||
for (const option of visibleOptions) {
|
||
const statusItem = el('span', `task-stage-status${state.current === option.key ? ' is-current' : ''}`, option.label);
|
||
statusItem.setAttribute('role', 'listitem');
|
||
statuses.append(statusItem);
|
||
}
|
||
card.append(statuses);
|
||
card.append(el('p', 'task-stage-note', state.note));
|
||
flow.append(card);
|
||
}
|
||
container.append(flow);
|
||
}
|
||
|
||
function renderTaskDetail() {
|
||
const container = $('#taskDetail');
|
||
if (!container) return;
|
||
container.innerHTML = '';
|
||
|
||
const task = selectedTask();
|
||
const taskState = $('#taskState');
|
||
const confirmButton = $('#confirmTaskButton');
|
||
const deleteButton = $('#deleteTaskButton');
|
||
const status = task?.status || task?.result?.status || 'created';
|
||
renderTaskStages(task);
|
||
if (confirmButton) {
|
||
const canConfirm = Boolean(task && requiresManualConfirmation(task));
|
||
const canStart = Boolean(task && canStartConfirmedTask(task));
|
||
confirmButton.hidden = !canConfirm && !canStart;
|
||
confirmButton.disabled = !canConfirm && !canStart;
|
||
confirmButton.textContent = canStart ? '开始执行' : '确认执行';
|
||
}
|
||
if (deleteButton) {
|
||
deleteButton.disabled = !task
|
||
|| isExecutionClaimed(task)
|
||
|| TERMINAL_TASK_STATUSES.has(taskCanonicalStatus(task));
|
||
}
|
||
if (taskState) {
|
||
taskState.textContent = task ? taskStatusText(task) : '未创建';
|
||
taskState.className = `state ${task ? taskStateClass(status) : 'state-warn'}`;
|
||
}
|
||
|
||
const fallbackEntries = task
|
||
? [{
|
||
at: task.updated_at || task.created_at || new Date().toISOString(),
|
||
level: taskLogLevel(status, task.message),
|
||
status,
|
||
message: task.message || '任务已创建,等待流程事件。'
|
||
}]
|
||
: [];
|
||
const entries = Array.isArray(task?.logs) && task.logs.length ? task.logs : fallbackEntries;
|
||
const logPanel = el('section', 'task-output-panel');
|
||
logPanel.append(el('div', 'task-output-label', '流程日志'));
|
||
const log = el('pre', 'task-log', entries.length
|
||
? entries.map(formatTaskLogEntry).join('\n')
|
||
: '暂无任务日志。');
|
||
log.id = 'taskLog';
|
||
log.setAttribute('role', 'log');
|
||
log.setAttribute('aria-live', 'polite');
|
||
logPanel.append(log);
|
||
|
||
const responsePanel = el('section', 'task-output-panel');
|
||
responsePanel.append(el('div', 'task-output-label', '返回 JSON'));
|
||
const response = el('pre', 'task-json-output', formatTaskResponse(task));
|
||
response.id = 'taskResponseJson';
|
||
response.setAttribute('aria-live', 'polite');
|
||
responsePanel.append(response);
|
||
|
||
container.append(logPanel, responsePanel);
|
||
log.scrollTop = log.scrollHeight;
|
||
}
|
||
|
||
function formatTaskLogEntry(entry) {
|
||
const date = new Date(entry.at || Date.now());
|
||
const time = Number.isNaN(date.getTime())
|
||
? '--:--:--'
|
||
: date.toLocaleTimeString('zh-CN', { hour12: false });
|
||
const level = String(entry.level || 'INFO').toUpperCase().padEnd(5, ' ');
|
||
const status = entry.status ? `[${entry.status}]` : '[event]';
|
||
return `${time} ${level} ${status} ${entry.message || ''}`;
|
||
}
|
||
|
||
function renderTaskCards() {
|
||
const container = $('#taskCards');
|
||
if (!container) return;
|
||
$('#taskCount').textContent = `${taskStore.length} 个任务`;
|
||
container.innerHTML = '';
|
||
if (!taskStore.length) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'task-empty';
|
||
empty.textContent = '暂无任务。';
|
||
container.appendChild(empty);
|
||
renderTaskDetail();
|
||
return;
|
||
}
|
||
for (const task of taskStore) {
|
||
const card = document.createElement('article');
|
||
card.className = `task-card${task.task_id === currentTaskId ? ' is-selected' : ''}`;
|
||
card.dataset.taskId = task.task_id;
|
||
const summary = task.summary || taskSummary(task.operation);
|
||
const status = task.status || task.result?.status || 'created';
|
||
card.innerHTML = `
|
||
<div class="task-card-head">
|
||
<div class="task-card-title-wrap">
|
||
<strong class="task-card-title"></strong>
|
||
<span class="task-id"></span>
|
||
</div>
|
||
<div class="task-card-status">
|
||
<span class="task-pill ${statusClassName(status)}"></span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
card.querySelector('.task-card-title').textContent = summary.operation_label || '输入解析任务';
|
||
card.querySelector('.task-id').textContent = task.task_id;
|
||
card.querySelector('.task-card-status .task-pill').textContent = taskStatusText(task);
|
||
container.appendChild(card);
|
||
}
|
||
renderTaskDetail();
|
||
}
|
||
|
||
function toNumber(value) {
|
||
const normalized = String(value ?? '').replace(/[,,]/g, '').trim();
|
||
const num = Number(normalized);
|
||
return Number.isFinite(num) ? num : 0;
|
||
}
|
||
|
||
function setBridgeState(text, type) {
|
||
const el = $('#bridgeState');
|
||
if (!el) return;
|
||
const value = el.querySelector('.status-icon-value');
|
||
if (value) value.textContent = text;
|
||
else el.textContent = text;
|
||
el.className = `status-icon ${type || 'state-warn'}`;
|
||
}
|
||
|
||
function setAiState(text, type) {
|
||
const el = $('#aiState');
|
||
if (!el) return;
|
||
const value = el.querySelector('.status-icon-value');
|
||
if (value) value.textContent = text;
|
||
else el.textContent = text;
|
||
el.className = `status-icon ${type || 'state-warn'}`;
|
||
}
|
||
|
||
async function pingAi() {
|
||
try {
|
||
const response = await fetch('/api/status', { cache: 'no-store' });
|
||
const result = await response.json();
|
||
if (!response.ok || result.ai_connected !== true) {
|
||
setAiState('未连接', 'state-bad');
|
||
return false;
|
||
}
|
||
setAiState('已连接', 'state-ok');
|
||
return true;
|
||
} catch (error) {
|
||
setAiState('未连接', 'state-bad');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function applyBridgePayload(payload = {}) {
|
||
bridgeConnected = Boolean(payload.ok);
|
||
if (!bridgeConnected) {
|
||
erpAutomationEnabled = false;
|
||
extensionCompatible = false;
|
||
setBridgeState('未连接', 'state-bad');
|
||
return false;
|
||
}
|
||
|
||
const parseVersion = (value) => String(value || '').split('.').map((part) => Number(part) || 0);
|
||
const current = parseVersion(payload.version);
|
||
const required = parseVersion(REQUIRED_EXTENSION_VERSION);
|
||
let versionComparison = 0;
|
||
for (let index = 0; index < 3; index += 1) {
|
||
const currentPart = current[index] || 0;
|
||
const requiredPart = required[index] || 0;
|
||
if (currentPart === requiredPart) continue;
|
||
versionComparison = currentPart > requiredPart ? 1 : -1;
|
||
break;
|
||
}
|
||
extensionCompatible = versionComparison >= 0;
|
||
if (!extensionCompatible) {
|
||
erpAutomationEnabled = false;
|
||
setBridgeState(`需更新到 ${REQUIRED_EXTENSION_VERSION}`, 'state-bad');
|
||
return true;
|
||
}
|
||
|
||
erpAutomationEnabled = payload.erp_automation_enabled !== false;
|
||
if (!erpAutomationEnabled) {
|
||
setBridgeState('未连接', 'state-bad');
|
||
return true;
|
||
}
|
||
|
||
setBridgeState('已连接', 'state-ok');
|
||
return true;
|
||
}
|
||
|
||
function setTaskState(text) {
|
||
const node = $('#taskState');
|
||
if (!node) return;
|
||
node.textContent = text;
|
||
const normalized = String(text || '');
|
||
const type = /失败|阻断|错误|不可用/.test(normalized)
|
||
? 'state-bad'
|
||
: /完成|收到|确认|成功|已连接/.test(normalized)
|
||
? 'state-ok'
|
||
: 'state-warn';
|
||
node.className = `state ${type}`;
|
||
}
|
||
|
||
function taskLogMessage(output) {
|
||
if (typeof output === 'string') return output;
|
||
if (!output || typeof output !== 'object') return '';
|
||
const blockers = Array.isArray(output.blockers)
|
||
? output.blockers
|
||
: Array.isArray(output.result?.blockers)
|
||
? output.result.blockers
|
||
: [];
|
||
if (blockers.length) return `阻断:${blockers.join(';')}`;
|
||
return output.message
|
||
|| output.next_step
|
||
|| output.error
|
||
|| (output.status ? `状态更新:${output.status}` : '');
|
||
}
|
||
|
||
function setOutput(output) {
|
||
const task = selectedTask();
|
||
if (task && output !== undefined) {
|
||
const status = typeof output === 'object' ? output.status || task.status : task.status;
|
||
appendTaskLogEntry(task, taskLogMessage(output), { status });
|
||
saveTaskStore();
|
||
}
|
||
renderTaskDetail();
|
||
}
|
||
|
||
function makeRequestId() {
|
||
if (crypto.randomUUID) return crypto.randomUUID();
|
||
return `REQ-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||
}
|
||
|
||
function sendToExtension(type, payload = {}, timeoutMs = 2500) {
|
||
const requestId = makeRequestId();
|
||
const promise = new Promise((resolve, reject) => {
|
||
const timer = setTimeout(() => {
|
||
pending.delete(requestId);
|
||
reject(new Error('插件桥接未响应,请确认扩展已安装并刷新本页。'));
|
||
}, timeoutMs);
|
||
pending.set(requestId, { resolve, reject, timer });
|
||
});
|
||
window.postMessage({
|
||
source: BUSINESS_SOURCE,
|
||
requestId,
|
||
type,
|
||
payload
|
||
}, window.location.origin);
|
||
return promise;
|
||
}
|
||
|
||
window.addEventListener('message', (event) => {
|
||
if (event.source !== window) return;
|
||
const message = event.data || {};
|
||
if (message.source !== EXTENSION_SOURCE) return;
|
||
if (message.type === 'BRIDGE_READY') {
|
||
applyBridgePayload(message.payload || { ok: true });
|
||
return;
|
||
}
|
||
const waiter = pending.get(message.requestId);
|
||
if (!waiter) return;
|
||
clearTimeout(waiter.timer);
|
||
pending.delete(message.requestId);
|
||
if (message.type === 'ERROR' || message.payload?.ok === false) {
|
||
waiter.reject(new Error(message.payload?.message || '插件返回错误。'));
|
||
return;
|
||
}
|
||
waiter.resolve(message.payload);
|
||
});
|
||
|
||
async function parseRawInstruction(rawText, taskId) {
|
||
const receivedAt = new Date().toISOString();
|
||
const requestStartedAt = new Date().toISOString();
|
||
const pendingRequest = {
|
||
service: 'external_parse_api',
|
||
transport: 'sse',
|
||
stage: 'sending',
|
||
session_id_present: false,
|
||
event_count: 0,
|
||
updated_at: requestStartedAt
|
||
};
|
||
updateLocalTask(taskId, {
|
||
status: 'running',
|
||
stage: 'parse',
|
||
message: '正在发送到外部解析服务……',
|
||
error: ''
|
||
});
|
||
setTaskState('正在发送到外部解析服务');
|
||
setOutput({
|
||
status: 'parse_sending',
|
||
task_id: taskId,
|
||
message: '业务系统已发起解析请求,正在创建独立 session。',
|
||
external_request: pendingRequest
|
||
});
|
||
|
||
let response;
|
||
try {
|
||
response = await fetch('/api/parse', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
raw_text: rawText,
|
||
task_id: taskId,
|
||
received_at: receivedAt
|
||
})
|
||
});
|
||
} catch (error) {
|
||
const result = {
|
||
status: 'parse_blocked',
|
||
blockers: [`无法连接本地解析服务:${error.message}`],
|
||
operation: null,
|
||
external_request: {
|
||
...pendingRequest,
|
||
stage: 'local_request_failed',
|
||
error_code: 'local_parse_api_unreachable',
|
||
updated_at: new Date().toISOString()
|
||
}
|
||
};
|
||
updateLocalTask(taskId, {
|
||
status: 'parse_failed',
|
||
stage: 'parse',
|
||
message: '无法连接本地解析服务。',
|
||
error: result.blockers.join(';'),
|
||
result,
|
||
parse_response: result
|
||
});
|
||
setTaskState('解析服务连接失败');
|
||
setOutput(result);
|
||
return result;
|
||
}
|
||
|
||
let result;
|
||
try {
|
||
result = await response.json();
|
||
} catch (error) {
|
||
result = {
|
||
status: 'parse_blocked',
|
||
blockers: [`解析服务返回了无效响应:${error.message}`],
|
||
operation: null,
|
||
external_request: {
|
||
...pendingRequest,
|
||
stage: 'invalid_response',
|
||
error_code: 'local_parse_api_invalid_response',
|
||
updated_at: new Date().toISOString()
|
||
}
|
||
};
|
||
}
|
||
if (!response.ok) {
|
||
result = {
|
||
status: 'parse_blocked',
|
||
blockers: [result.message || result.error || `解析服务调用失败:HTTP ${response.status}`],
|
||
operation: null,
|
||
external_request: result.external_request || {
|
||
...pendingRequest,
|
||
stage: 'local_api_error',
|
||
http_status: response.status,
|
||
updated_at: new Date().toISOString()
|
||
}
|
||
};
|
||
}
|
||
const hasBlockers = Array.isArray(result.blockers) && result.blockers.length > 0;
|
||
updateLocalTask(taskId, {
|
||
status: hasBlockers ? 'parse_failed' : 'running',
|
||
stage: 'parse',
|
||
message: hasBlockers ? '外部解析服务已返回阻断结果。' : '外部解析服务已返回结果,正在整理业务字段。',
|
||
error: hasBlockers ? result.blockers.join(';') : '',
|
||
parse_response: result
|
||
});
|
||
setTaskState(hasBlockers ? '外部解析服务已返回阻断' : '已收到外部解析结果');
|
||
setOutput({
|
||
status: hasBlockers ? 'parse_response_blocked' : 'parse_response_received',
|
||
task_id: taskId,
|
||
external_request: result.external_request || {
|
||
...pendingRequest,
|
||
stage: 'response_received',
|
||
updated_at: new Date().toISOString()
|
||
},
|
||
result
|
||
});
|
||
return result;
|
||
}
|
||
|
||
async function handoffTaskToExtension(task) {
|
||
if (!task?.confirmed_at) {
|
||
throw new Error('任务尚未人工确认,不能提交执行。');
|
||
}
|
||
|
||
if (task.status !== 'confirmed' || task.handoff_status !== 'awaiting_handoff') {
|
||
throw new Error('任务已领取或已进入执行流程,系统不会再次下发。');
|
||
}
|
||
|
||
if (!bridgeConnected || !extensionCompatible) {
|
||
updateLocalTask(task.task_id, {
|
||
status: 'waiting_extension',
|
||
stage: 'bridge',
|
||
message: `任务已确认,但插件未连接或版本低于 ${REQUIRED_EXTENSION_VERSION};不会自动提交。`,
|
||
error: '插件不可用。',
|
||
handoff_status: 'waiting_extension'
|
||
});
|
||
throw new Error('插件未连接或版本过低,任务已保留且不会自动补交。');
|
||
}
|
||
|
||
if (!erpAutomationEnabled) {
|
||
updateLocalTask(task.task_id, {
|
||
status: 'paused',
|
||
stage: 'automation_disabled',
|
||
message: '任务已确认,但插件 ERP 操作开关已关闭;不会自动提交。',
|
||
handoff_status: 'paused',
|
||
error: ''
|
||
});
|
||
return {
|
||
ok: true,
|
||
task_id: task.task_id,
|
||
status: 'paused',
|
||
erp_automation_enabled: false,
|
||
message: '插件 ERP 操作开关已关闭,未交给 ERP 自动执行器,也不会自动补交。'
|
||
};
|
||
}
|
||
|
||
updateLocalTask(task.task_id, {
|
||
status: 'queued',
|
||
stage: 'handoff',
|
||
message: '正在向服务端申请唯一 ERP 执行权。',
|
||
handoff_status: 'syncing'
|
||
});
|
||
|
||
let claim;
|
||
try {
|
||
claim = await apiRequest(`/api/tasks/${encodeURIComponent(task.task_id)}/claim`, {
|
||
method: 'POST',
|
||
body: { connection_id: BROWSER_CONNECTION_ID }
|
||
});
|
||
if (claim.task) upsertLocalTask(claim.task);
|
||
if (!claim.claimed) {
|
||
startPolling();
|
||
return {
|
||
ok: true,
|
||
task_id: task.task_id,
|
||
status: 'already_claimed',
|
||
dispatched: false,
|
||
message: '服务端已存在执行记录,本次没有再次发送给插件。'
|
||
};
|
||
}
|
||
|
||
const executionId = claim.execution_id;
|
||
if (!executionId) throw new Error('服务端没有返回唯一执行编号,已停止下发。');
|
||
const result = await sendToExtension('CREATE_TASK', {
|
||
task: claim.task || task,
|
||
execution_id: executionId
|
||
}, 4000);
|
||
if (result?.accepted === false || result?.status === 'duplicate_blocked') {
|
||
throw new Error(result.message || '插件检测到重复任务,已阻止再次执行。');
|
||
}
|
||
currentTaskId = result.task_id || task.task_id;
|
||
sessionStorage.setItem('ltjt_mock_current_task_id', currentTaskId);
|
||
updateLocalTask(task.task_id, {
|
||
status: 'running',
|
||
stage: 'auto_executor',
|
||
message: '插件已接单,后台会自动打开 ERP 表单、预检、保存并回查。',
|
||
handoff_status: 'running',
|
||
handoff_response: { ...result, execution_id: executionId }
|
||
});
|
||
startPolling();
|
||
return { ...result, execution_id: executionId };
|
||
} catch (error) {
|
||
if (claim?.claimed && claim.execution_id) {
|
||
const uncertainResult = {
|
||
task_id: task.task_id,
|
||
execution_id: claim.execution_id,
|
||
status: 'execution_uncertain',
|
||
stage: 'dispatch',
|
||
execution_phase: 'dispatch_uncertain',
|
||
write_attempted: true,
|
||
message: `任务领取后插件响应不确定:${error.message}。已停止自动重试,请回查 ERP。`
|
||
};
|
||
try {
|
||
const persisted = await apiRequest(`/api/tasks/${encodeURIComponent(task.task_id)}/result`, {
|
||
method: 'POST',
|
||
body: {
|
||
connection_id: BROWSER_CONNECTION_ID,
|
||
execution_id: claim.execution_id,
|
||
result: uncertainResult
|
||
}
|
||
});
|
||
if (persisted.task) upsertLocalTask(persisted.task);
|
||
} catch (persistError) {
|
||
updateLocalTask(task.task_id, {
|
||
status: 'reconciliation_pending',
|
||
stage: 'reconciliation',
|
||
handoff_status: 'reconciliation_pending',
|
||
message: uncertainResult.message,
|
||
error: persistError.message
|
||
});
|
||
}
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function confirmTask(task) {
|
||
if (!requiresManualConfirmation(task)) return;
|
||
const bridgeReady = await pingBridge();
|
||
if (!bridgeReady || !extensionCompatible || !erpAutomationEnabled) {
|
||
throw new Error(`插件未连接、版本低于 ${REQUIRED_EXTENSION_VERSION} 或 ERP 操作开关未开启;任务尚未确认,也不会自动补交。`);
|
||
}
|
||
const response = await apiRequest(`/api/tasks/${encodeURIComponent(task.task_id)}/confirm`, { method: 'POST', body: {} });
|
||
const confirmedTask = response.task;
|
||
upsertLocalTask(confirmedTask);
|
||
|
||
try {
|
||
const handoff = await handoffTaskToExtension(confirmedTask);
|
||
if (handoff?.status === 'paused') {
|
||
setTaskState('ERP 操作已关闭');
|
||
setOutput({
|
||
status: 'task_confirmed_paused',
|
||
task_id: task.task_id,
|
||
message: handoff.message
|
||
});
|
||
} else {
|
||
setOutput({ status: 'task_confirmed', task_id: task.task_id, result: handoff });
|
||
}
|
||
} catch (error) {
|
||
setTaskState('已确认,等待插件连接');
|
||
setOutput({
|
||
status: 'task_confirmed_waiting_extension',
|
||
task_id: task.task_id,
|
||
message: error.message
|
||
});
|
||
}
|
||
}
|
||
|
||
async function pingBridge() {
|
||
try {
|
||
const result = await sendToExtension('PING', {}, 1200);
|
||
applyBridgePayload(result);
|
||
if (authUser && result?.ok) {
|
||
await apiRequest('/api/connections/heartbeat', {
|
||
method: 'POST',
|
||
body: {
|
||
connection_id: `administrator-browser:${location.origin}`,
|
||
extension_version: result.version || '',
|
||
metadata: { bridge_installed_at: result.bridge_installed_at || '' }
|
||
}
|
||
});
|
||
}
|
||
return bridgeConnected;
|
||
} catch (error) {
|
||
bridgeConnected = false;
|
||
erpAutomationEnabled = false;
|
||
setBridgeState('未连接', 'state-bad');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function createTask() {
|
||
if (!authUser) {
|
||
showLoginPanel('请先登录。');
|
||
return;
|
||
}
|
||
const rawText = $('#rawInstruction').value;
|
||
const task = await apiRequest('/api/tasks', {
|
||
method: 'POST',
|
||
body: {
|
||
raw_text: rawText,
|
||
idempotency_key: `create:${makeRequestId()}`
|
||
}
|
||
});
|
||
const remoteTask = task.task;
|
||
currentTaskId = remoteTask.task_id;
|
||
sessionStorage.setItem('ltjt_mock_current_task_id', currentTaskId);
|
||
upsertLocalTask(remoteTask);
|
||
setTaskState('任务已入队');
|
||
setOutput({
|
||
status: 'task_created',
|
||
task_id: currentTaskId,
|
||
message: '任务已写入平台数据库,等待服务端解析。'
|
||
});
|
||
startRemoteEventStream();
|
||
}
|
||
|
||
async function pollResult() {
|
||
if (!currentTaskId) {
|
||
setOutput('还没有任务。');
|
||
return;
|
||
}
|
||
const result = await pollTaskResult(currentTaskId);
|
||
setTaskState(result ? `${result.stage} / ${result.status}` : `等待插件反馈:${currentTaskId}`);
|
||
setOutput(result || {
|
||
status: 'waiting',
|
||
task_id: currentTaskId
|
||
});
|
||
}
|
||
|
||
async function deleteSelectedTask() {
|
||
const task = selectedTask();
|
||
if (!task) return;
|
||
const taskId = task.task_id;
|
||
const deleteButton = $('#deleteTaskButton');
|
||
if (deleteButton) deleteButton.disabled = true;
|
||
|
||
appendTaskLogEntry(task, '正在请求取消任务并清理本地记录。', {
|
||
status: 'cancelling',
|
||
level: 'INFO'
|
||
});
|
||
saveTaskStore();
|
||
renderTaskDetail();
|
||
if (deleteButton) deleteButton.disabled = true;
|
||
|
||
try {
|
||
const needsPluginCancellation = Boolean(
|
||
task.confirmed_at
|
||
|| ['accepted', 'syncing', 'waiting_extension', 'paused'].includes(task.handoff_status)
|
||
);
|
||
if (needsPluginCancellation) {
|
||
const cancellation = await sendToExtension('DELETE_TASK', { task_id: taskId }, 4000);
|
||
if (cancellation?.ok === false) {
|
||
throw new Error(cancellation.message || '插件未确认任务已取消。');
|
||
}
|
||
}
|
||
|
||
const cancelled = await apiRequest(`/api/tasks/${encodeURIComponent(taskId)}/cancel`, { method: 'POST', body: {} });
|
||
if (cancelled.task) updateLocalTask(taskId, cancelled.task);
|
||
|
||
taskStore = taskStore.filter((item) => item.task_id !== taskId);
|
||
const nextTask = taskStore[0] || null;
|
||
currentTaskId = nextTask?.task_id || '';
|
||
if (currentTaskId) sessionStorage.setItem('ltjt_mock_current_task_id', currentTaskId);
|
||
else sessionStorage.removeItem('ltjt_mock_current_task_id');
|
||
saveTaskStore();
|
||
if (!taskStore.some(isTaskPollable)) {
|
||
if (pollTimer) clearInterval(pollTimer);
|
||
pollTimer = null;
|
||
}
|
||
renderTaskCards();
|
||
} catch (error) {
|
||
setTaskState('删除失败');
|
||
setOutput({
|
||
status: 'delete_error',
|
||
task_id: taskId,
|
||
message: `未删除任务:${error.message || String(error)}`
|
||
});
|
||
if (deleteButton) deleteButton.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function pollTaskResult(taskId) {
|
||
const result = await sendToExtension('GET_TASK_RESULT', { task_id: taskId }, 3000);
|
||
if (result.result) {
|
||
try {
|
||
const executionId = result.result.execution_id;
|
||
if (!executionId) {
|
||
throw new Error(`插件版本过低或执行记录缺少 execution_id,请更新到 ${REQUIRED_EXTENSION_VERSION};系统不会重复下发。`);
|
||
}
|
||
const persisted = await apiRequest(`/api/tasks/${encodeURIComponent(taskId)}/result`, {
|
||
method: 'POST',
|
||
body: {
|
||
connection_id: BROWSER_CONNECTION_ID,
|
||
execution_id: executionId,
|
||
result: result.result
|
||
}
|
||
});
|
||
if (persisted.task) upsertLocalTask(persisted.task);
|
||
} catch (error) {
|
||
updateLocalTask(taskId, {
|
||
result: result.result,
|
||
status: result.result.status || 'running',
|
||
stage: result.result.stage || '',
|
||
message: `插件已返回结果,但服务端持久化失败:${error.message}`,
|
||
error: error.message
|
||
});
|
||
}
|
||
}
|
||
return result.result || null;
|
||
}
|
||
|
||
async function pollAllTaskResults() {
|
||
if (!bridgeConnected) return;
|
||
const activeTasks = taskStore.filter(isTaskPollable);
|
||
for (const task of activeTasks) {
|
||
try {
|
||
await pollTaskResult(task.task_id);
|
||
} catch (error) {
|
||
updateLocalTask(task.task_id, {
|
||
message: `查询插件反馈失败:${error.message}`,
|
||
error: error.message
|
||
});
|
||
}
|
||
}
|
||
if (!taskStore.some(isTaskPollable) && pollTimer) {
|
||
clearInterval(pollTimer);
|
||
pollTimer = null;
|
||
}
|
||
}
|
||
|
||
function startPolling() {
|
||
if (pollTimer) clearInterval(pollTimer);
|
||
pollAllTaskResults().catch((error) => {
|
||
setTaskState('轮询失败');
|
||
setOutput({ status: 'poll_error', message: error.message });
|
||
});
|
||
pollTimer = setInterval(() => {
|
||
pollAllTaskResults().catch((error) => {
|
||
setTaskState('轮询失败');
|
||
setOutput({ status: 'poll_error', message: error.message });
|
||
});
|
||
}, 2500);
|
||
}
|
||
|
||
async function clearTasks() {
|
||
try {
|
||
await sendToExtension('CLEAR_TASKS', {}, 3000);
|
||
} catch (error) {
|
||
// Local business-system cards can still be cleared when the extension is disconnected.
|
||
}
|
||
currentTaskId = '';
|
||
taskStore = [];
|
||
saveTaskStore();
|
||
sessionStorage.removeItem('ltjt_mock_current_task_id');
|
||
if (pollTimer) clearInterval(pollTimer);
|
||
setTaskState('已清空');
|
||
setOutput('暂无任务。');
|
||
renderTaskCards();
|
||
}
|
||
|
||
async function initializeSession() {
|
||
try {
|
||
const me = await apiRequest('/api/auth/me');
|
||
await refreshCsrfToken();
|
||
showAuthenticatedApp(me.user);
|
||
await syncRemoteTasks();
|
||
startRemoteEventStream();
|
||
return true;
|
||
} catch (error) {
|
||
showLoginPanel('请登录后继续。');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
// Remove credentials/configuration left by the retired browser-side parser.
|
||
localStorage.removeItem('ltjt_mock_agent_config');
|
||
sessionStorage.removeItem('ltjt_mock_agent_api_key');
|
||
$('#rawInstruction').value = sessionStorage.getItem('ltjt_mock_raw_instruction') || SAMPLE_RAW;
|
||
$('#rawInstruction').addEventListener('input', () => {
|
||
sessionStorage.setItem('ltjt_mock_raw_instruction', $('#rawInstruction').value);
|
||
});
|
||
showLoginPanel();
|
||
$('#loginForm').addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
const errorNode = $('#loginError');
|
||
if (errorNode) errorNode.textContent = '';
|
||
const submitButton = $('#loginForm button[type="submit"]');
|
||
if (submitButton) submitButton.disabled = true;
|
||
try {
|
||
const response = await fetch('/api/auth/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({
|
||
username: $('#loginUsername').value,
|
||
password: $('#loginPassword').value
|
||
})
|
||
});
|
||
const result = await response.json();
|
||
if (!response.ok) throw new Error(result.message || '登录失败。');
|
||
csrfToken = result.csrf_token || '';
|
||
showAuthenticatedApp(result.user);
|
||
$('#loginPassword').value = '';
|
||
await syncRemoteTasks();
|
||
startRemoteEventStream();
|
||
await pingAi();
|
||
await pingBridge();
|
||
} catch (error) {
|
||
if (errorNode) errorNode.textContent = error.message || String(error);
|
||
} finally {
|
||
if (submitButton) submitButton.disabled = false;
|
||
}
|
||
});
|
||
$('#logoutButton').addEventListener('click', async () => {
|
||
try {
|
||
await apiRequest('/api/auth/logout', { method: 'POST', body: {} });
|
||
} catch (error) {
|
||
// The server may already have expired the session; clear the local view regardless.
|
||
}
|
||
taskStore = [];
|
||
currentTaskId = '';
|
||
sessionStorage.removeItem('ltjt_mock_current_task_id');
|
||
showLoginPanel('已退出登录。');
|
||
renderTaskCards();
|
||
});
|
||
$('#createTaskButton').addEventListener('click', () => {
|
||
createTask().catch((error) => {
|
||
const result = {
|
||
status: 'create_error',
|
||
task_id: currentTaskId,
|
||
message: error.message
|
||
};
|
||
updateLocalTask(currentTaskId, {
|
||
status: 'parse_failed',
|
||
stage: 'parse',
|
||
message: '创建任务时发生未处理错误。',
|
||
error: error.message,
|
||
result
|
||
});
|
||
setTaskState('创建失败');
|
||
setOutput(result);
|
||
});
|
||
});
|
||
$('#aiState').addEventListener('click', () => {
|
||
pingAi().catch(() => {});
|
||
});
|
||
$('#bridgeState').addEventListener('click', () => {
|
||
pingBridge().catch(() => {});
|
||
});
|
||
$('#confirmTaskButton').addEventListener('click', () => {
|
||
const task = selectedTask();
|
||
if (!task) return;
|
||
const action = requiresManualConfirmation(task)
|
||
? confirmTask(task)
|
||
: canStartConfirmedTask(task)
|
||
? handoffTaskToExtension(task)
|
||
: null;
|
||
if (!action) return;
|
||
action.catch((error) => setOutput({
|
||
status: 'confirmation_error',
|
||
task_id: task.task_id,
|
||
message: error.message
|
||
}));
|
||
});
|
||
$('#deleteTaskButton').addEventListener('click', () => {
|
||
const task = selectedTask();
|
||
if (!task) return;
|
||
if (!window.confirm(`确认删除任务 ${task.task_id}?已提交的插件任务也会请求取消。`)) return;
|
||
deleteSelectedTask().catch((error) => setOutput({
|
||
status: 'delete_error',
|
||
task_id: task.task_id,
|
||
message: error.message || String(error)
|
||
}));
|
||
});
|
||
$('#taskCards').addEventListener('click', (event) => {
|
||
const button = event.target.closest('button[data-task-action]');
|
||
const card = event.target.closest('.task-card[data-task-id]');
|
||
const taskId = button?.dataset.taskId || card?.dataset.taskId;
|
||
if (!taskId) return;
|
||
const task = taskStore.find((item) => item.task_id === taskId);
|
||
if (!task) return;
|
||
currentTaskId = taskId;
|
||
sessionStorage.setItem('ltjt_mock_current_task_id', currentTaskId);
|
||
renderTaskCards();
|
||
});
|
||
if (await initializeSession()) {
|
||
renderTaskCards();
|
||
pingAi().catch(() => {});
|
||
pingBridge().catch(() => {});
|
||
// The control plane caches this health probe; keep the browser refresh
|
||
// interval conservative so status checks cannot pressure the Agent API.
|
||
setInterval(() => {
|
||
pingAi().catch(() => {});
|
||
pingBridge().catch(() => {});
|
||
syncRemoteTasks().catch(() => {});
|
||
}, 30_000);
|
||
if (currentTaskId) renderTaskDetail();
|
||
if (taskStore.some(isTaskPollable)) startPolling();
|
||
}
|
||
});
|