feat: bind AgentBus work to account workers

This commit is contained in:
inman committed 2026-09-02 15:09:07 +08:00
1 parent d034f649c4
commit 6f9fd0f0bd
31 files changed
+1119 -257

No files matched your search

+11 -3
View File
@@ -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
View File
@@ -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(() => {});
});
+5 -3
View File
@@ -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>