feat: bind AgentBus work to account workers
This commit is contained in:
1 parent
d034f649c4
commit
6f9fd0f0bd
31 files changed
+1119
-257
No files matched your search
@@ -37,12 +37,18 @@ test('account creation accepts any non-empty password and blocks an empty passwo
|
||||
const empty = validate({
|
||||
username: 'operator',
|
||||
password: '',
|
||||
role: 'user'
|
||||
role: 'user',
|
||||
erpAccount: 'erp-operator'
|
||||
});
|
||||
assert.equal(empty.ok, false);
|
||||
assert.equal(empty.field, 'accountPassword');
|
||||
assert.equal(empty.message, '请输入初始密码。');
|
||||
const oneCharacter = validate({ username: 'operator', password: '1', role: 'user' });
|
||||
const oneCharacter = validate({
|
||||
username: 'operator',
|
||||
password: '1',
|
||||
role: 'user',
|
||||
erpAccount: 'erp-operator'
|
||||
});
|
||||
assert.equal(oneCharacter.ok, true);
|
||||
const createStart = appSource.indexOf('async function createAccountFromForm()');
|
||||
const createEnd = appSource.indexOf('\n}\n\nasync function updateManagedAccount', createStart);
|
||||
@@ -58,12 +64,14 @@ test('account creation normalizes valid form values into the server contract', (
|
||||
const result = validate({
|
||||
username: ' TeamLead ',
|
||||
password: '123456',
|
||||
role: 'team_lead'
|
||||
role: 'team_lead',
|
||||
erpAccount: ' ERP-TeamLead '
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.body.username, 'TeamLead');
|
||||
assert.equal(result.body.password, '123456');
|
||||
assert.equal(result.body.role, 'team_lead');
|
||||
assert.equal(result.body.erp_account, 'ERP-TeamLead');
|
||||
assert.equal(Object.hasOwn(result.body, 'must_change_password'), false);
|
||||
assert.equal(Array.isArray(result.body.business_route_ids), true);
|
||||
assert.equal(result.body.business_route_ids.length, 0);
|
||||
|
||||
+200
-33
@@ -97,7 +97,7 @@ const persistedExtensionResultVersions = new Map();
|
||||
let taskCreateInProgress = false;
|
||||
const AUTO_HANDOFF_RETRY_MS = 30_000;
|
||||
|
||||
const REQUIRED_EXTENSION_VERSION = '0.5.163';
|
||||
const REQUIRED_EXTENSION_VERSION = '0.5.164';
|
||||
const MANUAL_HANDOFF_LABEL = '确认并提交到 ERP 插件';
|
||||
const RETRY_HANDOFF_LABEL = '继续提交到 ERP 插件';
|
||||
const RECONCILE_LABEL = '只读回查 ERP 现有结果';
|
||||
@@ -737,10 +737,33 @@ function renderChannelList() {
|
||||
heading.append(el('span', `state ${channel.status === 'connected' ? 'state-ok' : channel.status === 'error' ? 'state-bad' : 'state-warn'}`, channelStatusLabel(channel.status)));
|
||||
main.append(heading);
|
||||
main.append(el('p', 'muted', channel.external_user_ref ? `外部用户:${channel.external_user_ref}` : '未设置外部用户标识'));
|
||||
main.append(el(
|
||||
'p',
|
||||
channel.routing_ready ? 'muted' : 'channel-error',
|
||||
channel.owner_username
|
||||
? `执行账号:${channel.owner_username} · ERP:${channel.owner_erp_account || '未配置'}`
|
||||
: '未绑定平台员工账号,渠道不会接收任务'
|
||||
));
|
||||
main.append(el('p', 'channel-key-state', 'AgentBus key:已配置(平台不会回显)'));
|
||||
if (channel.last_error) main.append(el('p', 'channel-error', channel.last_error));
|
||||
row.append(main);
|
||||
const actions = el('div', 'channel-row-actions');
|
||||
const owner = document.createElement('select');
|
||||
owner.dataset.channelAction = 'owner';
|
||||
owner.dataset.channelId = channel.id;
|
||||
const availableOwners = accountList.filter((account) => (
|
||||
account.role !== 'admin'
|
||||
&& account.is_active
|
||||
&& account.erp_account
|
||||
&& (account.id === channel.owner_user_id || !channelList.some((item) => item.id !== channel.id && item.owner_user_id === account.id))
|
||||
));
|
||||
owner.append(new Option('选择执行账号', ''));
|
||||
for (const account of availableOwners) {
|
||||
owner.append(new Option(`${account.username} · ${account.erp_account}`, account.id));
|
||||
}
|
||||
owner.value = channel.owner_user_id || '';
|
||||
owner.disabled = channelSettingsBusy;
|
||||
actions.append(owner);
|
||||
const toggle = el('button', 'secondary-button', channel.enabled ? '停用' : '启用');
|
||||
toggle.type = 'button';
|
||||
toggle.dataset.channelAction = 'toggle';
|
||||
@@ -776,9 +799,23 @@ async function syncChannels() {
|
||||
const result = await apiRequest('/api/channels');
|
||||
channelList = Array.isArray(result.channels) ? result.channels : [];
|
||||
renderChannelList();
|
||||
renderChannelOwnerOptions();
|
||||
return channelList;
|
||||
}
|
||||
|
||||
function renderChannelOwnerOptions() {
|
||||
const select = $('#channelOwnerUserId');
|
||||
if (!select) return;
|
||||
const selected = String(select.value || '');
|
||||
select.replaceChildren(new Option('请选择员工账号', ''));
|
||||
const boundIds = new Set(channelList.map((channel) => channel.owner_user_id).filter(Boolean));
|
||||
for (const account of accountList) {
|
||||
if (account.role === 'admin' || !account.is_active || !account.erp_account || boundIds.has(account.id)) continue;
|
||||
select.append(new Option(`${account.username} · ERP ${account.erp_account}`, account.id));
|
||||
}
|
||||
if ([...select.options].some((option) => option.value === selected)) select.value = selected;
|
||||
}
|
||||
|
||||
async function createChannelFromForm() {
|
||||
if (!authUser || channelSettingsBusy) return;
|
||||
const message = $('#channelMessage');
|
||||
@@ -789,6 +826,7 @@ async function createChannelFromForm() {
|
||||
method: 'POST',
|
||||
body: {
|
||||
display_name: normalizeText($('#channelDisplayName')?.value).slice(0, 120),
|
||||
owner_user_id: String($('#channelOwnerUserId')?.value || ''),
|
||||
external_user_ref: normalizeText($('#channelExternalUserRef')?.value).slice(0, 200),
|
||||
agentbus_key: String($('#channelAgentbusKey')?.value || '').trim(),
|
||||
bot_address: normalizeText($('#channelBotAddress')?.value).slice(0, 200),
|
||||
@@ -798,9 +836,11 @@ async function createChannelFromForm() {
|
||||
if (result.channel) channelList = [...channelList.filter((item) => item.id !== result.channel.id), result.channel];
|
||||
$('#channelAgentbusKey').value = '';
|
||||
$('#channelDisplayName').value = '';
|
||||
$('#channelOwnerUserId').value = '';
|
||||
$('#channelExternalUserRef').value = '';
|
||||
$('#channelBotAddress').value = '';
|
||||
renderChannelList();
|
||||
renderChannelOwnerOptions();
|
||||
if (message) message.textContent = '渠道已保存,连接状态会在服务端异步更新。';
|
||||
} finally {
|
||||
channelSettingsBusy = false;
|
||||
@@ -821,6 +861,23 @@ async function updateChannelEnabled(channelId, enabled) {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateChannelOwner(channelId, ownerUserId) {
|
||||
if (!authUser || channelSettingsBusy || !ownerUserId) return;
|
||||
channelSettingsBusy = true;
|
||||
try {
|
||||
await apiRequest(`/api/channels/${encodeURIComponent(channelId)}`, {
|
||||
method: 'PATCH',
|
||||
body: { owner_user_id: ownerUserId }
|
||||
});
|
||||
await syncChannels();
|
||||
renderChannelOwnerOptions();
|
||||
} finally {
|
||||
channelSettingsBusy = false;
|
||||
renderChannelList();
|
||||
renderChannelOwnerOptions();
|
||||
}
|
||||
}
|
||||
|
||||
async function renameChannel(channelId) {
|
||||
if (!authUser || channelSettingsBusy) return;
|
||||
const channel = channelList.find((item) => item.id === channelId);
|
||||
@@ -893,6 +950,7 @@ function validateAccountCreationValues(values = {}) {
|
||||
const username = String(values.username || '').trim();
|
||||
const password = String(values.password || '');
|
||||
const role = String(values.role || '');
|
||||
const erpAccount = String(values.erpAccount || '').trim();
|
||||
if (!username || username.length > 160) {
|
||||
return { ok: false, field: 'accountUsername', message: '账号必须为 1—160 个字符。' };
|
||||
}
|
||||
@@ -902,12 +960,16 @@ function validateAccountCreationValues(values = {}) {
|
||||
if (!['admin', 'team_lead', 'user'].includes(role)) {
|
||||
return { ok: false, field: 'accountRole', message: '请选择有效的账号角色。' };
|
||||
}
|
||||
if (role !== 'admin' && (!erpAccount || erpAccount.length > 200)) {
|
||||
return { ok: false, field: 'accountErpAccount', message: '普通用户或组长必须填写 1—200 个字符的 ERP 账号。' };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
body: {
|
||||
username,
|
||||
password,
|
||||
role,
|
||||
erp_account: role === 'admin' ? '' : erpAccount,
|
||||
business_route_ids: []
|
||||
}
|
||||
};
|
||||
@@ -925,6 +987,9 @@ function accountCreationErrorMessage(error) {
|
||||
if (details.some((path) => path === 'role' || path.startsWith('role.'))) {
|
||||
return '请选择有效的账号角色。';
|
||||
}
|
||||
if (details.some((path) => path === 'erp_account' || path.startsWith('erp_account.'))) {
|
||||
return '普通用户或组长必须填写 ERP 账号。';
|
||||
}
|
||||
}
|
||||
return error?.message || String(error);
|
||||
}
|
||||
@@ -940,7 +1005,7 @@ function showAccountCreationValidationError(validation) {
|
||||
}
|
||||
|
||||
function clearAccountCreationValidationErrors() {
|
||||
for (const fieldId of ['accountUsername', 'accountPassword', 'accountRole']) {
|
||||
for (const fieldId of ['accountUsername', 'accountPassword', 'accountRole', 'accountErpAccount']) {
|
||||
document.getElementById(fieldId)?.removeAttribute('aria-invalid');
|
||||
}
|
||||
}
|
||||
@@ -964,6 +1029,9 @@ function renderAccounts() {
|
||||
heading.append(el('span', `state ${account.is_active ? 'state-ok' : 'state-bad'}`, account.is_active ? '有效' : '已停用'));
|
||||
main.append(heading);
|
||||
main.append(el('p', 'muted', accountRoleLabel(account.role)));
|
||||
main.append(el('p', account.erp_account ? 'muted' : 'channel-error', account.role === 'admin'
|
||||
? 'ERP 执行身份:不绑定'
|
||||
: `ERP 执行身份:${account.erp_account || '未配置(禁止执行)'}`));
|
||||
const authorizedCount = Array.isArray(account.authorized_business_route_ids)
|
||||
? account.authorized_business_route_ids.length
|
||||
: 0;
|
||||
@@ -1002,7 +1070,12 @@ function renderAccounts() {
|
||||
authorizations.dataset.accountAction = 'business-authorizations';
|
||||
authorizations.dataset.accountId = account.id;
|
||||
authorizations.disabled = accountSettingsBusy || account.role === 'admin';
|
||||
actions.append(role, authorizations, toggle, reset, revoke);
|
||||
const erpAccount = el('button', 'secondary-button', 'ERP 账号');
|
||||
erpAccount.type = 'button';
|
||||
erpAccount.dataset.accountAction = 'erp-account';
|
||||
erpAccount.dataset.accountId = account.id;
|
||||
erpAccount.disabled = accountSettingsBusy || account.role === 'admin';
|
||||
actions.append(role, authorizations, erpAccount, toggle, reset, revoke);
|
||||
row.append(main, actions);
|
||||
container.append(row);
|
||||
}
|
||||
@@ -1098,6 +1171,7 @@ async function syncAccounts() {
|
||||
accountList = Array.isArray(result.accounts) ? result.accounts : [];
|
||||
accountTaskTypes = Array.isArray(result.task_types) ? result.task_types : [];
|
||||
renderAccounts();
|
||||
renderChannelOwnerOptions();
|
||||
}
|
||||
|
||||
async function createAccountFromForm() {
|
||||
@@ -1105,7 +1179,8 @@ async function createAccountFromForm() {
|
||||
const validation = validateAccountCreationValues({
|
||||
username: $('#accountUsername').value,
|
||||
password: $('#accountPassword').value,
|
||||
role: $('#accountRole').value
|
||||
role: $('#accountRole').value,
|
||||
erpAccount: $('#accountErpAccount').value
|
||||
});
|
||||
if (!validation.ok) {
|
||||
showAccountCreationValidationError(validation);
|
||||
@@ -1124,6 +1199,7 @@ async function createAccountFromForm() {
|
||||
.sort((left, right) => left.username.localeCompare(right.username, 'zh-CN'));
|
||||
$('#accountUsername').value = '';
|
||||
$('#accountPassword').value = '';
|
||||
$('#accountErpAccount').value = '';
|
||||
if (message) {
|
||||
message.textContent = result.account?.role === 'admin'
|
||||
? '管理员账号已创建;该角色固定拥有全部任务权限,初始密码不会再次显示。'
|
||||
@@ -1135,6 +1211,14 @@ async function createAccountFromForm() {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateManagedAccountErpIdentity(accountId) {
|
||||
const account = accountList.find((item) => item.id === accountId);
|
||||
if (!account || account.role === 'admin') return;
|
||||
const value = window.prompt('请输入 ERP 页面显示的登录账号。修改后该员工的现有平台会话和云电脑执行连接会失效:', account.erp_account || '');
|
||||
if (value === null || !value.trim() || value.trim() === account.erp_account) return;
|
||||
await updateManagedAccount(accountId, { erp_account: value.trim().slice(0, 200) });
|
||||
}
|
||||
|
||||
async function updateManagedAccount(accountId, patch) {
|
||||
if (!isAdministrator() || accountSettingsBusy) return;
|
||||
accountSettingsBusy = true;
|
||||
@@ -1807,12 +1891,12 @@ function runtimeTasks() {
|
||||
}
|
||||
|
||||
function hasPollableRuntimeTasks() {
|
||||
return runtimeTasks().some(isTaskPollable);
|
||||
return runtimeTasks().some((task) => taskAssignedToCurrentAccount(task) && isTaskPollable(task));
|
||||
}
|
||||
|
||||
async function syncRuntimeTasks() {
|
||||
if (!authUser) return;
|
||||
const result = await apiRequest('/api/tasks?status=active&limit=200&include_total=false');
|
||||
const result = await apiRequest('/api/tasks?status=active&limit=200&include_total=false&executable_by=me');
|
||||
const activeTasks = (Array.isArray(result.tasks) ? result.tasks : [])
|
||||
.map(mergeRemoteTask)
|
||||
.filter((task) => !locallyDeletedTaskIds.has(task?.task_id));
|
||||
@@ -2162,6 +2246,10 @@ function requiresManualConfirmation(task) {
|
||||
return ['awaiting_confirmation', 'agent_parse_passed'].includes(status);
|
||||
}
|
||||
|
||||
function taskAssignedToCurrentAccount(task) {
|
||||
return Boolean(authUser?.id && task?.assignee?.id === authUser.id);
|
||||
}
|
||||
|
||||
function taskCanonicalStatus(task) {
|
||||
return String(task?.status || task?.result?.status || 'created');
|
||||
}
|
||||
@@ -2496,13 +2584,11 @@ async function autoDispatchReadyTasks({ force = false } = {}) {
|
||||
if (!authUser || autoHandoffInProgress) return;
|
||||
autoHandoffInProgress = true;
|
||||
try {
|
||||
const result = await apiRequest('/api/tasks?status=confirmed&limit=200&include_total=false');
|
||||
const byId = new Map(taskStore.map((task) => [task.task_id, task]));
|
||||
for (const task of Array.isArray(result.tasks) ? result.tasks : []) {
|
||||
byId.set(task.task_id, task);
|
||||
const result = await apiRequest('/api/tasks?status=confirmed&limit=200&include_total=false&executable_by=me');
|
||||
const candidates = Array.isArray(result.tasks) ? result.tasks : [];
|
||||
for (const task of candidates) {
|
||||
cacheRuntimeTask(task);
|
||||
}
|
||||
const candidates = [...byId.values()];
|
||||
const orderedCandidates = [...candidates].sort((left, right) => {
|
||||
const leftTime = Date.parse(left?.created_at || '') || 0;
|
||||
const rightTime = Date.parse(right?.created_at || '') || 0;
|
||||
@@ -3498,6 +3584,7 @@ function renderTaskInputAudit(task) {
|
||||
const panel = el('section', 'task-output-panel');
|
||||
panel.append(el('div', 'task-output-label', '创建人与原始输入审计'));
|
||||
panel.append(el('p', 'muted', `创建人:${task.creator?.username || (task.source === 'agentbus' ? 'AgentBus / 系统' : '历史记录未知')}`));
|
||||
panel.append(el('p', task.assignee?.username ? 'muted' : 'channel-error', `执行归属:${task.assignee?.username || '未分配(禁止 ERP 执行)'}`));
|
||||
const history = taskInputHistoryStore.get(task.task_id);
|
||||
if (!history) {
|
||||
panel.append(el('p', 'muted', '正在加载加密输入历史…'));
|
||||
@@ -3546,10 +3633,11 @@ function renderTaskDetail() {
|
||||
const importantMessagePanel = renderTaskImportantMessage(task);
|
||||
renderTaskStages(task);
|
||||
if (confirmButton) {
|
||||
const canConfirm = Boolean(task && requiresManualConfirmation(task));
|
||||
const canStart = Boolean(task && !isAutomaticTask(task) && canStartConfirmedTask(task));
|
||||
const canResume = Boolean(task && canResumePrewriteTask(task));
|
||||
const canReconcile = Boolean(task && canReconcileTask(task));
|
||||
const canOperate = Boolean(task && taskAssignedToCurrentAccount(task));
|
||||
const canConfirm = Boolean(canOperate && requiresManualConfirmation(task));
|
||||
const canStart = Boolean(canOperate && !isAutomaticTask(task) && canStartConfirmedTask(task));
|
||||
const canResume = Boolean(canOperate && canResumePrewriteTask(task));
|
||||
const canReconcile = Boolean(canOperate && canReconcileTask(task));
|
||||
const reconciliationBusy = Boolean(task && taskReconciliationStates.get(task.task_id) === 'running');
|
||||
confirmButton.hidden = !canConfirm && !canStart && !canResume && !canReconcile;
|
||||
confirmButton.disabled = (!canConfirm && !canStart && !canResume && !canReconcile) || reconciliationBusy;
|
||||
@@ -4233,6 +4321,9 @@ function renderStatusDetails() {
|
||||
statusDetailRow('ERP 自动化', statusBoolean(status?.automationEnabled, '已开启', '已关闭')),
|
||||
statusDetailRow('ERP 页面权限', statusBoolean(status?.erpHostAccessReady, '可访问', '需在扩展设置中允许')),
|
||||
statusDetailRow('ERP 会话', statusBoolean(status?.erpSessionReady, '正常', '异常或待登录')),
|
||||
statusDetailRow('ERP 账号匹配', authUser?.erp_account
|
||||
? statusBoolean(status?.erpAccountMatched, '与平台绑定一致', `应登录 ${authUser.erp_account}`)
|
||||
: '管理员不绑定员工 ERP 账号'),
|
||||
statusDetailRow('ERP 链路恢复', recoveryStatus || '未触发'),
|
||||
statusDetailRow('安装时间', formatStatusTime(status?.installedAt)),
|
||||
statusDetailRow('最近错误', status?.error || '无'),
|
||||
@@ -4317,8 +4408,10 @@ function applyBridgePayload(payload = {}) {
|
||||
'erp_page_access_denied'
|
||||
]).has(String(erpSession.error_code || ''));
|
||||
erpHostAccessReady = erpSession.host_permission_granted !== false && !erpAccessError;
|
||||
const erpAccountMatched = !authUser?.erp_account || erpSession.account_matched === true;
|
||||
erpSessionReady = erpSession.session_ready !== false
|
||||
&& erpSession.erp_keepalive?.last_status !== 'session_expired';
|
||||
&& erpSession.erp_keepalive?.last_status !== 'session_expired'
|
||||
&& erpAccountMatched;
|
||||
const recoveryStatus = String(erpSession.erp_link_recovery?.status || erpSession.erp_keepalive?.recovery_status || '');
|
||||
const erpLinkRecoveryPending = ['scheduled', 'already_running', 'running'].includes(recoveryStatus);
|
||||
const parseVersion = (value) => String(value || '').split('.').map((part) => Number(part) || 0);
|
||||
@@ -4340,6 +4433,7 @@ function applyBridgePayload(payload = {}) {
|
||||
automationEnabled: erpAutomationEnabled,
|
||||
erpHostAccessReady,
|
||||
erpSessionReady,
|
||||
erpAccountMatched,
|
||||
erpSession,
|
||||
erpLinkRecovery: {
|
||||
status: recoveryStatus || 'not_started',
|
||||
@@ -4353,7 +4447,9 @@ function applyBridgePayload(payload = {}) {
|
||||
? 'ERP 自动化开关已关闭。'
|
||||
: !erpHostAccessReady
|
||||
? erpSession.message || '扩展暂时没有 ERP 页面访问权限。'
|
||||
: !erpSessionReady
|
||||
: !erpAccountMatched
|
||||
? `当前 ERP 登录账号与平台绑定的“${authUser?.erp_account || ''}”不一致。`
|
||||
: !erpSessionReady
|
||||
? erpSession.message || 'ERP 会话异常,插件已尝试刷新现有 ERP 链路;请确认 ERP 已登录。'
|
||||
: erpLinkRecoveryPending
|
||||
? '已检测到 ERP 链路异常,插件正在刷新并进行只读复测。'
|
||||
@@ -4562,6 +4658,9 @@ async function parseRawInstruction(rawText, taskId) {
|
||||
}
|
||||
|
||||
async function handoffTaskToExtension(task) {
|
||||
if (!taskAssignedToCurrentAccount(task)) {
|
||||
throw new Error('该任务分配给其他平台账号,当前云电脑只能查看,不能执行。');
|
||||
}
|
||||
const automaticTask = isAutomaticTask(task);
|
||||
if (!task?.confirmed_at) {
|
||||
throw new Error(automaticTask ? '自动任务缺少确认时间,已停止下发。' : '任务尚未人工确认,不能提交到 ERP 插件。');
|
||||
@@ -4735,6 +4834,9 @@ async function handoffTaskToExtension(task) {
|
||||
async function confirmAndSubmitToErpPlugin(task) {
|
||||
task = await ensureTaskDetails(task?.task_id);
|
||||
if (!requiresManualConfirmation(task)) return;
|
||||
if (!taskAssignedToCurrentAccount(task)) {
|
||||
throw new Error('该任务分配给其他平台账号,当前账号只能查看,不能确认或执行。');
|
||||
}
|
||||
const bridgeReady = await pingBridge();
|
||||
if (!bridgeReady || !extensionCompatible || !erpAutomationEnabled || !erpHostAccessReady || !erpSessionReady || latestBridgeStatus?.erpLinkRecovery?.pending === true) {
|
||||
throw new Error(`插件未连接、版本低于 ${REQUIRED_EXTENSION_VERSION}、ERP 页面/会话不可用或 ERP 操作开关未开启;任务尚未确认,也未提交到插件。`);
|
||||
@@ -4767,19 +4869,49 @@ async function confirmAndSubmitToErpPlugin(task) {
|
||||
|
||||
async function pingBridge() {
|
||||
try {
|
||||
const result = await sendToExtension('PING', {}, 1200);
|
||||
const result = await sendToExtension('PING', {
|
||||
expected_erp_account: authUser?.erp_account || ''
|
||||
}, 1200);
|
||||
applyBridgePayload(result);
|
||||
if (authUser && result?.ok) {
|
||||
await apiRequest('/api/connections/heartbeat', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
connection_id: browserConnectionId,
|
||||
extension_version: result.version || '',
|
||||
metadata: { bridge_installed_at: result.bridge_installed_at || '' }
|
||||
try {
|
||||
const heartbeat = await apiRequest('/api/connections/heartbeat', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
connection_id: browserConnectionId,
|
||||
extension_version: result.version || '',
|
||||
erp_account: authUser.erp_account || '',
|
||||
erp_account_matched: result.erp_session?.account_matched === true,
|
||||
metadata: { bridge_installed_at: result.bridge_installed_at || '' }
|
||||
}
|
||||
});
|
||||
if (heartbeat.execution_ready !== true) {
|
||||
erpSessionReady = false;
|
||||
latestBridgeStatus = {
|
||||
...latestBridgeStatus,
|
||||
erpSessionReady: false,
|
||||
erpAccountMatched: heartbeat.erp_account_matched === true,
|
||||
error: authUser.erp_account
|
||||
? `当前 ERP 登录账号与平台绑定的“${authUser.erp_account}”不一致。`
|
||||
: '当前云电脑尚未满足执行条件。'
|
||||
};
|
||||
setBridgeState('已连接,有告警', 'state-warn');
|
||||
renderStatusDetails();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} catch (heartbeatError) {
|
||||
erpSessionReady = false;
|
||||
latestBridgeStatus = {
|
||||
...latestBridgeStatus,
|
||||
erpSessionReady: false,
|
||||
error: heartbeatError.message || String(heartbeatError)
|
||||
};
|
||||
setBridgeState('已连接,有告警', 'state-warn');
|
||||
renderStatusDetails();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return bridgeConnected;
|
||||
return bridgeConnected && erpSessionReady;
|
||||
} catch (error) {
|
||||
bridgeConnected = false;
|
||||
erpAutomationEnabled = false;
|
||||
@@ -5094,6 +5226,7 @@ async function pollTaskResult(taskId, { allowReconciliation = false } = {}) {
|
||||
|
||||
async function reconcileTaskReceipt(task) {
|
||||
if (!canReconcileTask(task)) throw new Error('当前任务不在可只读回查的生命周期不确定状态。');
|
||||
if (!taskAssignedToCurrentAccount(task)) throw new Error('该任务分配给其他平台账号,当前云电脑不能执行回查。');
|
||||
const bridgeReady = await pingBridge();
|
||||
if (!bridgeReady || !extensionCompatible || !erpHostAccessReady) throw new Error(`插件未连接、版本低于 ${REQUIRED_EXTENSION_VERSION} 或 ERP 页面权限不可用,未执行回查。`);
|
||||
taskReconciliationStates.set(task.task_id, 'running');
|
||||
@@ -5112,6 +5245,7 @@ async function reconcileTaskReceipt(task) {
|
||||
|
||||
async function resumePrewriteTaskExecution(task) {
|
||||
if (!canResumePrewriteTask(task)) throw new Error('当前任务没有可证明的保存前零写状态,禁止恢复。');
|
||||
if (!taskAssignedToCurrentAccount(task)) throw new Error('该任务分配给其他平台账号,当前云电脑不能恢复执行。');
|
||||
const response = await sendToExtension('RESUME_PREWRITE_TASK', { task_id: task.task_id }, 3000);
|
||||
updateLocalTask(task.task_id, {
|
||||
status: 'running',
|
||||
@@ -5128,7 +5262,7 @@ async function pollAllTaskResults() {
|
||||
if (!bridgeConnected || pollInProgress) return;
|
||||
pollInProgress = true;
|
||||
try {
|
||||
const activeTasks = runtimeTasks().filter(isTaskPollable);
|
||||
const activeTasks = runtimeTasks().filter((task) => taskAssignedToCurrentAccount(task) && isTaskPollable(task));
|
||||
for (const task of activeTasks) {
|
||||
try {
|
||||
await pollTaskResult(task.task_id);
|
||||
@@ -5213,6 +5347,7 @@ async function initializeSession() {
|
||||
await syncParserRouting().catch(() => {});
|
||||
}
|
||||
if (IS_CHANNELS_PAGE && isAdministrator()) {
|
||||
await syncAccounts().catch(() => {});
|
||||
await syncChannels().catch((error) => {
|
||||
const message = $('#channelMessage');
|
||||
if (message) message.textContent = `渠道读取失败:${error.message || String(error)}`;
|
||||
@@ -5306,10 +5441,13 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
await syncAutomationSettings().catch(() => setTaskState('全自动化设置读取失败'));
|
||||
}
|
||||
if (IS_PARSER_ROUTING_PAGE && isAdministrator()) await syncParserRouting().catch(() => {});
|
||||
if (IS_CHANNELS_PAGE && isAdministrator()) await syncChannels().catch((error) => {
|
||||
const message = $('#channelMessage');
|
||||
if (message) message.textContent = `渠道读取失败:${error.message || String(error)}`;
|
||||
});
|
||||
if (IS_CHANNELS_PAGE && isAdministrator()) {
|
||||
await syncAccounts().catch(() => {});
|
||||
await syncChannels().catch((error) => {
|
||||
const message = $('#channelMessage');
|
||||
if (message) message.textContent = `渠道读取失败:${error.message || String(error)}`;
|
||||
});
|
||||
}
|
||||
if (IS_ACCOUNTS_PAGE && isAdministrator()) await syncAccounts();
|
||||
if (IS_AUDIT_PAGE && isAdministrator()) await syncAuditEvents();
|
||||
if (IS_TASK_PAGE) {
|
||||
@@ -5480,7 +5618,19 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
$('#accountList')?.addEventListener('change', (event) => {
|
||||
const control = event.target.closest('[data-account-action="role"][data-account-id]');
|
||||
if (!control) return;
|
||||
void updateManagedAccount(control.dataset.accountId, { role: control.value }).catch((error) => {
|
||||
const account = accountList.find((item) => item.id === control.dataset.accountId);
|
||||
if (!account) return;
|
||||
const patch = { role: control.value };
|
||||
if (control.value === 'admin') patch.erp_account = null;
|
||||
if (control.value !== 'admin' && !account.erp_account) {
|
||||
const erpAccount = window.prompt('该员工角色必须绑定 ERP 账号,请输入 ERP 页面显示的登录账号:', '');
|
||||
if (!erpAccount?.trim()) {
|
||||
control.value = account.role;
|
||||
return;
|
||||
}
|
||||
patch.erp_account = erpAccount.trim().slice(0, 200);
|
||||
}
|
||||
void updateManagedAccount(control.dataset.accountId, patch).catch((error) => {
|
||||
const message = $('#accountMessage');
|
||||
if (message) message.textContent = error.message || String(error);
|
||||
void syncAccounts();
|
||||
@@ -5497,7 +5647,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
openAccountAuthorizationEditor(account.id);
|
||||
return;
|
||||
}
|
||||
if (action === 'toggle') operation = updateManagedAccount(account.id, { is_active: !account.is_active });
|
||||
if (action === 'erp-account') operation = updateManagedAccountErpIdentity(account.id);
|
||||
else if (action === 'toggle') operation = updateManagedAccount(account.id, { is_active: !account.is_active });
|
||||
else if (action === 'reset-password') operation = resetManagedAccountPassword(account.id);
|
||||
else if (action === 'revoke-sessions') operation = revokeManagedAccountSessions(account.id);
|
||||
else return;
|
||||
@@ -5656,6 +5807,22 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (message) message.textContent = error.message || String(error);
|
||||
});
|
||||
});
|
||||
$('#channelList')?.addEventListener('change', (event) => {
|
||||
const select = event.target.closest('select[data-channel-action="owner"][data-channel-id]');
|
||||
if (!select?.value) return;
|
||||
void updateChannelOwner(select.dataset.channelId, select.value).catch((error) => {
|
||||
const message = $('#channelMessage');
|
||||
if (message) message.textContent = error.message || String(error);
|
||||
void syncChannels();
|
||||
});
|
||||
});
|
||||
$('#accountRole')?.addEventListener('change', (event) => {
|
||||
const erpInput = $('#accountErpAccount');
|
||||
const admin = event.target.value === 'admin';
|
||||
erpInput.required = !admin;
|
||||
erpInput.disabled = admin;
|
||||
if (admin) erpInput.value = '';
|
||||
});
|
||||
$('#aiState').addEventListener('click', () => {
|
||||
toggleStatusDetails('ai').catch(() => {});
|
||||
});
|
||||
|
||||
@@ -91,13 +91,14 @@
|
||||
<div>
|
||||
<p class="eyebrow">ACCOUNT DIRECTORY</p>
|
||||
<h2>平台账号管理</h2>
|
||||
<p class="muted">管理员可维护账号、密码与可执行任务类型;未授权指令会在解析和 ERP 执行前被阻止。</p>
|
||||
<p class="muted">员工账号必须绑定唯一 ERP 账号;管理员只负责管理和查看,不绑定员工 ERP 执行身份。</p>
|
||||
</div>
|
||||
</div>
|
||||
<form id="accountForm" class="channel-form" novalidate>
|
||||
<label>账号<input id="accountUsername" maxlength="160" autocomplete="off" required></label>
|
||||
<label>初始密码<input id="accountPassword" type="password" autocomplete="new-password" required></label>
|
||||
<label>角色<select id="accountRole"><option value="user">普通用户</option><option value="team_lead">组长</option><option value="admin">管理员</option></select></label>
|
||||
<label>ERP 账号<input id="accountErpAccount" maxlength="200" autocomplete="off" required placeholder="ERP 页面显示的登录账号"></label>
|
||||
<button type="submit">创建账号</button>
|
||||
</form>
|
||||
<section id="accountAuthorizationPanel" class="account-authorization-panel" hidden aria-labelledby="accountAuthorizationTitle">
|
||||
@@ -262,11 +263,12 @@
|
||||
<div>
|
||||
<p class="eyebrow">CHANNEL DIRECTORY</p>
|
||||
<h2>AgentBus 用户渠道</h2>
|
||||
<p class="muted">每个外部用户渠道使用独立 AgentBus key;key 只写入服务端,不会回显,不再使用的渠道可以删除。</p>
|
||||
<p class="muted">每个渠道必须一对一绑定有效员工账号;入站任务只会交给该账号当前唯一在线且 ERP 身份匹配的云电脑。</p>
|
||||
</div>
|
||||
</div>
|
||||
<form id="channelForm" class="channel-form" novalidate>
|
||||
<label>渠道名称<input id="channelDisplayName" maxlength="120" required placeholder="例如:小王"></label>
|
||||
<label>绑定平台账号<select id="channelOwnerUserId" required><option value="">请选择员工账号</option></select></label>
|
||||
<label>外部用户标识<input id="channelExternalUserRef" maxlength="200" placeholder="例如:wx_user_001"></label>
|
||||
<label>AgentBus key<input id="channelAgentbusKey" type="password" maxlength="4000" autocomplete="new-password" required></label>
|
||||
<label>Bot 地址(可选)<input id="channelBotAddress" maxlength="200" placeholder="默认使用全局 bot 地址"></label>
|
||||
@@ -405,6 +407,6 @@
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<script src="app.js?v=20260902-dashboard-mobile-share-1"></script>
|
||||
<script src="app.js?v=20260902-agentbus-account-routing-1"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user