diff --git a/LianSyn-platform/app-account-form.test.mjs b/LianSyn-platform/app-account-form.test.mjs index 4537997..9cdb5a1 100644 --- a/LianSyn-platform/app-account-form.test.mjs +++ b/LianSyn-platform/app-account-form.test.mjs @@ -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); diff --git a/LianSyn-platform/app.js b/LianSyn-platform/app.js index 5bc8309..a092507 100644 --- a/LianSyn-platform/app.js +++ b/LianSyn-platform/app.js @@ -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; @@ -1819,12 +1903,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)); @@ -2174,6 +2258,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'); } @@ -2508,13 +2596,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; @@ -3510,6 +3596,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', '正在加载加密输入历史…')); @@ -3558,10 +3645,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; @@ -4245,6 +4333,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 || '无'), @@ -4329,8 +4420,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); @@ -4352,6 +4445,7 @@ function applyBridgePayload(payload = {}) { automationEnabled: erpAutomationEnabled, erpHostAccessReady, erpSessionReady, + erpAccountMatched, erpSession, erpLinkRecovery: { status: recoveryStatus || 'not_started', @@ -4365,7 +4459,9 @@ function applyBridgePayload(payload = {}) { ? 'ERP 自动化开关已关闭。' : !erpHostAccessReady ? erpSession.message || '扩展暂时没有 ERP 页面访问权限。' - : !erpSessionReady + : !erpAccountMatched + ? `当前 ERP 登录账号与平台绑定的“${authUser?.erp_account || ''}”不一致。` + : !erpSessionReady ? erpSession.message || 'ERP 会话异常,插件已尝试刷新现有 ERP 链路;请确认 ERP 已登录。' : erpLinkRecoveryPending ? '已检测到 ERP 链路异常,插件正在刷新并进行只读复测。' @@ -4574,6 +4670,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 插件。'); @@ -4747,6 +4846,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 操作开关未开启;任务尚未确认,也未提交到插件。`); @@ -4779,19 +4881,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; @@ -5106,6 +5238,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'); @@ -5124,6 +5257,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', @@ -5140,7 +5274,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); @@ -5225,6 +5359,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)}`; @@ -5318,10 +5453,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) { @@ -5492,7 +5630,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(); @@ -5509,7 +5659,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; @@ -5668,6 +5819,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(() => {}); }); diff --git a/LianSyn-platform/index.html b/LianSyn-platform/index.html index e487b0c..7cac8ac 100644 --- a/LianSyn-platform/index.html +++ b/LianSyn-platform/index.html @@ -91,13 +91,14 @@
ACCOUNT DIRECTORY
管理员可维护账号、密码与可执行任务类型;未授权指令会在解析和 ERP 执行前被阻止。
+员工账号必须绑定唯一 ERP 账号;管理员只负责管理和查看,不绑定员工 ERP 执行身份。
CHANNEL DIRECTORY
每个外部用户渠道使用独立 AgentBus key;key 只写入服务端,不会回显,不再使用的渠道可以删除。
+每个渠道必须一对一绑定有效员工账号;入站任务只会交给该账号当前唯一在线且 ERP 身份匹配的云电脑。