diff --git a/.project-docs/30-worklog/tasks/20260901-account-system-impl-d4e7a2.md b/.project-docs/30-worklog/tasks/20260901-account-system-impl-d4e7a2.md new file mode 100644 index 0000000..e57b3b5 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-account-system-impl-d4e7a2.md @@ -0,0 +1,104 @@ +# Task: Implement account isolation, roles, and audit + +## Identity + +- Task ID: 20260901-account-system-impl-d4e7a2 +- Mode: Feature +- Branch: codex/20260901-account-system-impl-d4e7a2-account-system-impl-d4e7a2 +- Worktree: /Users/inmanx/Documents/lwltAPI-account-system-impl-d4e7a2 +- Base commit: ffa33408997c3e822dd8293fbca0739c72ff3d01 +- Owner: codex +- Status: Ready for integration + +## Scope + +- Implement fixed-single-organization `admin`, `team_lead`, and `user` roles without adding organization selection or tenant administration. +- Add administrator account lifecycle APIs and UI, self-service password change, session revocation, and last-administrator safety. +- Enforce administrator-or-owner authorization for manual tasks, messages, events, artifacts, SSE, and browser execution paths. +- Preserve creator and encrypted initial/supplemental input attribution, expose authorized audit history, and replace routine task deletion with archive/restore. +- Add role-aware operator UI and table-driven security/regression coverage. +- Add a team-lead/administrator-only, read-only operations dashboard for cross-user manual instruction history, work-status summaries, multi-dimensional business drill-through, and “who/instruction/result” detail. +- Add administrator-managed per-account task-type authorization for all 18 registered manual business routes, with server-side fail-closed enforcement and denial audit. +- Do not deploy, restart the existing 8786 service, access the real database/ERP, mutate real runtime tasks, or send data externally. A separately isolated local preview on port 8876 and PostgreSQL port 55432 is permitted by the user's explicit preview request. + +## Intent And Constraints + +- Existing `organization_id` remains an internal fixed deployment scope and compatibility boundary; it is not exposed as a product concept. +- Existing users migrate as administrators. Administrators always retain all 18 manual business routes. New ordinary users and team leads start with no manual task-type authorization and can operate only explicitly granted routes for tasks they create; AgentBus/system tasks remain administrator-only and keep their separate channel authorization boundary. +- Team leads gain cross-user visibility only through dedicated read-only dashboard APIs. That capability does not extend normal task mutation, artifact download, SSE, ERP execution, account administration, global audit, automation, parser, or AgentBus permissions. +- Authorization is enforced in server/service/database access paths, never only by navigation visibility. +- Unauthorized resource identifiers fail without revealing cross-user existence. +- Initial and supplemental input remain encrypted at rest; password material and raw business input never enter audit or operational logs. +- Organization-wide ERP execution serialization, confirmation, idempotency, uncertain-write, and requery safety gates remain unchanged. +- Routine deletion becomes reversible archive/restore. Physical purge remains unavailable until an explicit retention policy is authorized. +- Task-type authorization is re-read from PostgreSQL at every creation/continuation and before confirmation, automatic confirmation, and browser claim, so revocation takes effect without waiting for session expiry. A non-administrator instruction that cannot resolve to exactly one registered route fails closed. +- The semantics and ERP contracts of the 18 registered business routes remain unchanged; this feature changes only which signed-in accounts may invoke each route. + +## Permission Contract + +| Surface | Administrator | Team lead | Ordinary user | +|---|---|---|---| +| Login, logout, current user, CSRF, own password | Allowed | Allowed | Allowed | +| Account list/create/status/role/reset/session revocation | Allowed | Denied | Denied | +| Manual task-type authorization | All 18 routes, fixed | Explicit administrator grants only; default none | Explicit administrator grants only; default none | +| Automation, parser routing/review/reparse, AgentBus channels, global audit | Allowed | Denied | Denied | +| Normal task list/search/detail/events/input history | All fixed-scope tasks | Own non-AgentBus tasks only | Own non-AgentBus tasks only | +| Create task/message/confirm/claim/result/cancel/archive/restore | Any authorized task | Own task only | Own task only | +| Artifact download and SSE | All fixed-scope tasks | Own task only | Own task only | +| Operations dashboard summaries and user directory | All manual account tasks | Read-only, all manual account tasks | Denied | +| Operations dashboard instruction/attachment-name/business-result detail | Read-only, all manual account tasks | Read-only, all manual account tasks | Denied | +| Browser heartbeat/connection | Own authenticated connection | Own authenticated connection | Own authenticated connection | +| AgentBus/system tasks | Allowed | Not visible | Not visible | +| Physical task purge | Not exposed | Not exposed | Not exposed | + +Unauthorized task and artifact identifiers use the existing not-found response to avoid enumeration. Administrator-only settings and account endpoints use an explicit forbidden response. + +## Outcome + +- Added migration `015_account_roles_and_task_audit`: existing accounts remain administrators; new `admin`/`user` roles, forced-password lifecycle fields, message/attachment actor attribution, reversible task archive fields, and account-scoped manual idempotency keys are persisted. No organization selector or tenant-management concept was added. +- Added migration `016_team_lead_operations_dashboard`: the fixed role constraint now accepts `team_lead`, and bounded manual-task indexes support time-, actor-, status-, and business-type-oriented dashboard reads. +- Added migration `017_user_business_route_authorizations`: durable fixed-scope per-user allowlists cover all 18 registered manual business routes, carry administrator attribution and optimistic authorization revisions, and enforce same-organization user/granter references. Schema readiness now requires migration 017. +- Implemented administrator account management for list/create, enable/disable, role changes, password reset, and session revocation. Passwords remain Argon2id hashes, reset accounts can be forced to change password, role/status changes revoke sessions, and self-lockout plus removal of the last active administrator are blocked transactionally. +- Enforced owner-restricted access for both ordinary users and team leads on task list/search/detail, messages, input history, artifacts, lifecycle mutations, browser claim/result, archive/restore, historical events, and live SSE. Both roles can access only their own manual tasks through normal business APIs; AgentBus/system tasks remain administrator-only. Browser connection IDs are user-bound and idempotency keys no longer collide across manual accounts. +- Added authorized creator/original-input audit: task creator, every newly attributed user turn, attachment actor, lifecycle event actor, and account/task audit events are available to the authorized operator. Input contents and file names remain encrypted at rest; passwords and plaintext business input are not copied into the global audit log. +- Replaced routine task deletion with archive/restore across the API, UI, retention job, and compatibility routes. Physical task/audit purge code is no longer exposed or retained in the task service. +- Added role-aware platform pages for account management and global audit, mandatory/self-service password change, administrator-only settings navigation, creator/input history, per-user browser connection identity, and active/archived task views. Administrators can assign the new 组长 role. +- Added an administrator-only task-permission panel in account management with all 18 business types, selected counts, select-all/clear, optimistic save protection, and effective authorization display. Administrators are permanently shown as fully authorized; new non-administrator accounts are clearly created with no task permissions until an administrator grants them. +- Enforced the allowlist in the task service before initial parsing/plugin dispatch, on supplemental messages and passenger attachments, inside creation transactions, before manual and automatic confirmation, and before browser/ERP claim. Confirmation and claim evaluate the task creator rather than the acting administrator, so a revoked creator cannot be bypassed by an administrator. Non-administrators receive `business_not_authorized` for a known denied route or `business_type_unresolved` when the route is unknown/non-unique; both paths stop execution and return a business-readable Chinese prompt. Denial audit stores actor, route/revision, phase, and an input hash only—never the plaintext instruction—and explicitly records that parsing, plugin dispatch, and ERP writes did not run. +- Added `/operations-dashboard` and leadership-gated GET APIs with a default 30-day window, a 366-day maximum, status/person/Shanghai-business-day/business-type drill-through, aggregate KPIs, per-user work summaries, bounded pagination, and on-demand business detail. Keyword query matches the creator, full initial and supplemental instructions, business type, readable business result, task record number, group number, and order number after structured filters; decrypted matching is bounded to 2,000 candidates. Dashboard queries include only manual tasks. Its dedicated list/detail contracts expose only who acted, what was instructed, what business outcome was recorded, business type, timestamps, and simplified input attachment attribution; lifecycle events, parser/executor JSON, technical stages/errors, and artifact access data are not returned. No dashboard mutation route exists. +- Updated `control-plane/README.md` to describe the three-role account model, owner isolation, read-only operations dashboard, administrator-managed task-type allowlists, fail-closed enforcement, archive semantics, migrations 015/016/017, and the latest schema readiness gate. +- Added table-driven three-role access-policy tests plus migration, authorization-path, dashboard, UI, audit, idempotency, and no-purge regression assertions. The 18 business routes and ERP serialization/write-safety contracts were not changed. +- Upgraded only the isolated local preview on `127.0.0.1:8876`: applied migration 017 to its dedicated PostgreSQL database, restarted only the preview process, assigned small demonstrative allowlists to the preview lead/Alice/Bob accounts, and verified a denied instruction did not create a task. The existing service on port 8786 remained on its original PID and was not touched. +- No deployment, real-service restart, real database migration, ERP access, real runtime task mutation, secret read, or external message occurred. + +## Verification + +- `node --run check:repo`: 10/10 passed. +- `node --run check`: passed. +- `node --run test:control-plane`: 153/153 passed. +- `node --run test:legacy`: 256/256 passed. +- `node --run build`: passed. +- `node --check LianSyn-platform/app.js`: passed. +- `git diff --check`: passed. +- Focused authorization regression (`node --test --import tsx control-plane/test/account-authorization.test.ts control-plane/test/control-plane.test.ts`): 60/60 passed. +- Disposable PostgreSQL API smoke: new non-administrator denied by default; one granted route accepted; another ungranted route denied; only the authorized task persisted; two denials were durably audited. +- Fresh disposable PostgreSQL migration: migrations 001–017 applied successfully, schema head was `017_user_business_route_authorizations`, and the disposable database was removed afterward. +- Isolated preview readiness: `/health/ready` reported database/schema ready with required migration 017; the account catalog returned all 18 task types; Alice, Bob, and the team lead received 2, 1, and 3 demonstrative grants respectively; Bob's ungranted “安排导游” instruction returned HTTP 403 `business_not_authorized` with the expected Chinese prompt. +- `check_project_docs.py`: passed. +- `check_doc_drift.py --task-id 20260901-account-system-impl-d4e7a2`: passed. + +## Follow-ups + +- Integrate this feature worktree before deployment, then apply migrations `015_account_roles_and_task_audit`, `016_team_lead_operations_dashboard`, and `017_user_business_route_authorizations` and restart the control plane only under separate authorization. +- In an authorized staging/runtime window, create one team-lead and two ordinary test accounts. Verify forced password change, default-deny task permissions, per-route grant/revoke without re-login, known-route and unresolved-route denial prompts, no parsing/plugin/ERP side effects on denial, creator-based revocation at confirmation/claim, own-task visibility, cross-user 404 behavior on normal task/artifact/SSE paths, AgentBus invisibility, dashboard denial for ordinary users, and team-lead cross-user drill-through by status/person/date/business plus keyword hits in supplemental instructions and completed business results. Also verify the dashboard response contains no lifecycle/parser/executor/artifact-access data, then cover account session revocation, creator/input audit, and archive/restore against PostgreSQL and independent browser sessions. +- Keep physical purge unavailable unless a separately approved retention design defines legal/audit retention periods and privileged irreversible-purge controls. + +## Promotion Candidates + +- Target canonical architecture/security/current-state memory during a later Integration task. +- Proposal: record the fixed-scope account model as `admin`, `team_lead`, and `user`, with no product-visible organization concept. Team leads retain ordinary-user ownership boundaries for business operations but receive dedicated cross-user, manual-task-only, read-only operations-dashboard access; administrators retain global settings, AgentBus/system tasks, account lifecycle, and global audit. +- Proposal: record the operations dashboard as a business-facing projection rather than a privileged task-debug surface: status/person/Shanghai-day/business drill-through and bounded plaintext matching are permitted, while lifecycle events, parser/executor payloads, technical stages/codes, and artifact access remain outside its response contract. +- Proposal: record creator/input attribution, account-scoped idempotency, user-bound browser connections, forced password change/session revocation, last-administrator protection, and archive/restore as security invariants. +- Proposal: record manual business eligibility as an administrator-managed allowlist over the 18 registered routes: administrators are always fully authorized, team leads/users default to none and require explicit grants, unknown/non-unique routes fail closed, revocation is checked at each state transition, and AgentBus keeps its independent channel boundary. +- Evidence: `control-plane/migrations/015_account_roles_and_task_audit.sql`, `control-plane/migrations/016_team_lead_operations_dashboard.sql`, `control-plane/migrations/017_user_business_route_authorizations.sql`, `control-plane/src/auth.ts`, `control-plane/src/task-service.ts`, `control-plane/src/server.ts`, `LianSyn-platform/index.html`, `LianSyn-platform/app.js`, `LianSyn-platform/styles.css`, `control-plane/README.md`, and `control-plane/test/account-authorization.test.ts` plus the full verification above. +- Human confirmation required: already received for the single-organization three-role ownership/audit/dashboard behavior; integration, database migration, deployment, restart, and live smoke testing remain separately authorized. diff --git a/LianSyn-platform/app.js b/LianSyn-platform/app.js index 70a1846..5137db8 100644 --- a/LianSyn-platform/app.js +++ b/LianSyn-platform/app.js @@ -6,8 +6,15 @@ const CURRENT_PAGE = window.location.pathname.replace(/\/+$/, '') || '/'; const IS_HISTORY_PAGE = CURRENT_PAGE === '/history'; const IS_CHANNELS_PAGE = CURRENT_PAGE === '/channels'; const IS_PARSER_ROUTING_PAGE = CURRENT_PAGE === '/parser-routing'; +const IS_ACCOUNTS_PAGE = CURRENT_PAGE === '/accounts'; +const IS_AUDIT_PAGE = CURRENT_PAGE === '/audit'; +const IS_OPERATIONS_DASHBOARD_PAGE = CURRENT_PAGE === '/operations-dashboard'; +const IS_ADMIN_PAGE = IS_CHANNELS_PAGE || IS_PARSER_ROUTING_PAGE || IS_ACCOUNTS_PAGE || IS_AUDIT_PAGE; +const IS_MANAGEMENT_PAGE = IS_ADMIN_PAGE || IS_OPERATIONS_DASHBOARD_PAGE; +const IS_TASK_PAGE = !IS_MANAGEMENT_PAGE; const HOME_TASK_LIMIT = 10; const HISTORY_TASK_PAGE_SIZE = 24; +const OPERATIONS_DASHBOARD_PAGE_SIZE = 50; const DEFAULT_API_TIMEOUT_MS = 15_000; const AUTH_API_TIMEOUT_MS = 10_000; const RESULT_API_TIMEOUT_MS = 20_000; @@ -29,6 +36,8 @@ let remoteSyncInProgress = false; let syncRequested = false; let csrfToken = ''; let authUser = null; +let passwordChangeForced = false; +let browserConnectionId = ''; let organizationAutomationEnabled = false; let automationSettingsLoaded = false; let automationSettingsBusy = false; @@ -38,12 +47,26 @@ let channelList = []; let channelSettingsBusy = false; let parserRoutingRows = []; let parserRoutingBusy = false; +let accountList = []; +let accountTaskTypes = []; +let accountSettingsBusy = false; +let accountAuthorizationTargetId = ''; +let accountAuthorizationDraft = new Set(); +let auditEvents = []; +let auditBusy = false; +let operationsDashboardData = null; +let operationsDashboardBusy = false; +let operationsDashboardOffset = 0; +let operationsDashboardSelectedTaskId = ''; +let operationsDashboardDetail = null; let autoHandoffInProgress = false; let taskStore = []; const runtimeTaskStore = new Map(); const remoteTaskStore = new Map(); const taskDetailStore = new Map(); const taskDetailRequests = new Map(); +const taskInputHistoryStore = new Map(); +const taskInputHistoryRequests = new Map(); const localTaskOverlayStore = new Map(); let pollInProgress = false; let backgroundRefreshInProgress = false; @@ -55,6 +78,7 @@ let taskListMeta = { }; let historySearch = ''; let historyStatus = ''; +let historyArchive = 'active'; let historyOffset = 0; const lifecycleScrollPositions = new Map(); const taskDeleteStates = new Map(); @@ -74,7 +98,6 @@ const persistedExtensionResultVersions = new Map(); let taskCreateInProgress = false; const AUTO_HANDOFF_RETRY_MS = 30_000; -const BROWSER_CONNECTION_ID = `administrator-browser:${location.origin}`; const REQUIRED_EXTENSION_VERSION = '0.5.163'; const MANUAL_HANDOFF_LABEL = '确认并提交到 ERP 插件'; const RETRY_HANDOFF_LABEL = '继续提交到 ERP 插件'; @@ -130,6 +153,31 @@ function $(selector) { return document.querySelector(selector); } +function isAdministrator() { + return authUser?.role === 'admin'; +} + +function isTeamLeader() { + return authUser?.role === 'team_lead'; +} + +function canViewOperationsDashboard() { + return isAdministrator() || isTeamLeader(); +} + +function connectionIdForUser(user) { + if (!user?.id) return ''; + const storageKey = `liansyn_platform_browser_connection_id:${user.id}`; + let connectionId = localStorage.getItem(storageKey) || ''; + if (!/^platform-browser:[a-f0-9-]{16,80}$/i.test(connectionId)) { + const identifier = crypto.randomUUID?.() + || Array.from(crypto.getRandomValues(new Uint8Array(16)), (value) => value.toString(16).padStart(2, '0')).join(''); + connectionId = `platform-browser:${identifier}`; + localStorage.setItem(storageKey, connectionId); + } + return connectionId; +} + function rosterAttachmentError(code, message) { const error = new Error(message); error.code = code; @@ -221,6 +269,7 @@ async function apiRequest(path, options = {}) { function showLoginPanel(message = '') { authUser = null; + browserConnectionId = ''; csrfToken = ''; taskAttachmentDrafts.clear(); const rosterAttachmentInput = $('#rosterAttachment'); @@ -239,16 +288,26 @@ function showLoginPanel(message = '') { const workbench = $('#workbench'); const channelsPage = $('#channelsPage'); const parserRoutingPage = $('#parserRoutingPage'); + const accountsPage = $('#accountsPage'); + const auditPage = $('#auditPage'); + const operationsDashboardPage = $('#operationsDashboardPage'); + const passwordPanel = $('#passwordChangePanel'); const authState = $('#authState'); const automationButton = $('#automationToggleButton'); const logoutButton = $('#logoutButton'); + const changePasswordButton = $('#changePasswordButton'); if (panel) panel.hidden = false; if (workbench) workbench.hidden = true; if (channelsPage) channelsPage.hidden = true; if (parserRoutingPage) parserRoutingPage.hidden = true; + if (accountsPage) accountsPage.hidden = true; + if (auditPage) auditPage.hidden = true; + if (operationsDashboardPage) operationsDashboardPage.hidden = true; + if (passwordPanel) passwordPanel.hidden = true; if (authState) authState.hidden = true; if (automationButton) automationButton.hidden = true; if (logoutButton) logoutButton.hidden = true; + if (changePasswordButton) changePasswordButton.hidden = true; const submitButton = $('#loginForm button[type="submit"]'); if (submitButton) submitButton.disabled = false; const error = $('#loginError'); @@ -269,16 +328,26 @@ function showAuthChecking() { const workbench = $('#workbench'); const channelsPage = $('#channelsPage'); const parserRoutingPage = $('#parserRoutingPage'); + const accountsPage = $('#accountsPage'); + const auditPage = $('#auditPage'); + const operationsDashboardPage = $('#operationsDashboardPage'); + const passwordPanel = $('#passwordChangePanel'); const authState = $('#authState'); const automationButton = $('#automationToggleButton'); const logoutButton = $('#logoutButton'); + const changePasswordButton = $('#changePasswordButton'); if (panel) panel.hidden = false; if (workbench) workbench.hidden = true; if (channelsPage) channelsPage.hidden = true; if (parserRoutingPage) parserRoutingPage.hidden = true; + if (accountsPage) accountsPage.hidden = true; + if (auditPage) auditPage.hidden = true; + if (operationsDashboardPage) operationsDashboardPage.hidden = true; + if (passwordPanel) passwordPanel.hidden = true; if (authState) authState.hidden = true; if (automationButton) automationButton.hidden = true; if (logoutButton) logoutButton.hidden = true; + if (changePasswordButton) changePasswordButton.hidden = true; const submitButton = $('#loginForm button[type="submit"]'); if (submitButton) submitButton.disabled = true; const error = $('#loginError'); @@ -287,34 +356,82 @@ function showAuthChecking() { function showAuthenticatedApp(user) { authUser = user; + passwordChangeForced = Boolean(user?.must_change_password); + browserConnectionId = connectionIdForUser(user); + if (!isAdministrator() && IS_ADMIN_PAGE) { + window.location.replace('/'); + return; + } + if (IS_OPERATIONS_DASHBOARD_PAGE && !canViewOperationsDashboard()) { + window.location.replace('/'); + return; + } const panel = $('#loginPanel'); const workbench = $('#workbench'); const channelsPage = $('#channelsPage'); const parserRoutingPage = $('#parserRoutingPage'); + const accountsPage = $('#accountsPage'); + const auditPage = $('#auditPage'); + const operationsDashboardPage = $('#operationsDashboardPage'); + const passwordPanel = $('#passwordChangePanel'); const authState = $('#authState'); const automationButton = $('#automationToggleButton'); const logoutButton = $('#logoutButton'); + const changePasswordButton = $('#changePasswordButton'); if (panel) panel.hidden = true; - if (workbench) workbench.hidden = IS_CHANNELS_PAGE || IS_PARSER_ROUTING_PAGE; - if (channelsPage) channelsPage.hidden = !IS_CHANNELS_PAGE; - if (parserRoutingPage) parserRoutingPage.hidden = !IS_PARSER_ROUTING_PAGE; + if (passwordPanel) passwordPanel.hidden = true; + if (workbench) workbench.hidden = IS_MANAGEMENT_PAGE; + if (channelsPage) channelsPage.hidden = !IS_CHANNELS_PAGE || !isAdministrator(); + if (parserRoutingPage) parserRoutingPage.hidden = !IS_PARSER_ROUTING_PAGE || !isAdministrator(); + if (accountsPage) accountsPage.hidden = !IS_ACCOUNTS_PAGE || !isAdministrator(); + if (auditPage) auditPage.hidden = !IS_AUDIT_PAGE || !isAdministrator(); + if (operationsDashboardPage) { + operationsDashboardPage.hidden = !IS_OPERATIONS_DASHBOARD_PAGE || !canViewOperationsDashboard(); + } if (authState) { authState.hidden = false; - authState.textContent = `${user.username} · 管理员`; + authState.textContent = `${user.username} · ${accountRoleLabel(user.role)}`; } if (logoutButton) logoutButton.hidden = false; + if (changePasswordButton) changePasswordButton.hidden = false; + for (const selector of ['#channelsNav', '#parserRoutingNav', '#accountsNav', '#auditNav']) { + const nav = $(selector); + if (nav) nav.hidden = !isAdministrator(); + } + const operationsDashboardNav = $('#operationsDashboardNav'); + if (operationsDashboardNav) operationsDashboardNav.hidden = !canViewOperationsDashboard(); if (automationButton) { - automationButton.hidden = IS_CHANNELS_PAGE || IS_PARSER_ROUTING_PAGE; + automationButton.hidden = !isAdministrator() || IS_MANAGEMENT_PAGE; renderAutomationToggle(); } const submitButton = $('#loginForm button[type="submit"]'); if (submitButton) submitButton.disabled = false; + if (user.must_change_password) showPasswordChangePanel(true); +} + +function showPasswordChangePanel(forced = false) { + if (!authUser) return; + passwordChangeForced = forced; + for (const selector of ['#loginPanel', '#workbench', '#channelsPage', '#parserRoutingPage', '#accountsPage', '#auditPage', '#operationsDashboardPage']) { + const page = $(selector); + if (page) page.hidden = true; + } + const panel = $('#passwordChangePanel'); + if (panel) panel.hidden = false; + $('#passwordChangeTitle').textContent = forced ? '请先设置新密码' : '修改密码'; + $('#passwordChangeHint').textContent = forced + ? '管理员已重置你的密码。继续使用平台前,请设置至少 12 个字符的新密码。' + : '新密码至少 12 个字符。修改后,其他已登录会话将失效。'; + $('#passwordChangeCancel').hidden = forced; + $('#passwordChangeError').textContent = ''; + $('#changePasswordButton').hidden = true; + $('#automationToggleButton').hidden = true; } function renderAutomationToggle() { const button = $('#automationToggleButton'); if (!button) return; - button.hidden = !authUser || IS_CHANNELS_PAGE || IS_PARSER_ROUTING_PAGE; + button.hidden = !isAdministrator() || IS_MANAGEMENT_PAGE || passwordChangeForced; button.disabled = !authUser || automationSettingsBusy || !automationSettingsLoaded || Boolean(automationSettingsError); button.setAttribute('aria-pressed', organizationAutomationEnabled ? 'true' : 'false'); button.classList.toggle('is-enabled', organizationAutomationEnabled); @@ -333,7 +450,7 @@ function renderAutomationToggle() { } async function syncAutomationSettings({ background = false } = {}) { - if (!authUser) return null; + if (!isAdministrator()) return null; if (automationSettingsSyncInFlight) return automationSettingsSyncInFlight; if (automationSettingsBusy) return null; const showBusy = !background || !automationSettingsLoaded; @@ -364,7 +481,7 @@ async function syncAutomationSettings({ background = false } = {}) { } async function toggleAutomationSetting() { - if (!authUser) return; + if (!isAdministrator()) return; if (automationSettingsSyncInFlight) { try { await automationSettingsSyncInFlight; @@ -450,8 +567,8 @@ function renderParserRouting() { if (notice) { notice.className = `parser-automation-notice ${organizationAutomationEnabled ? 'is-enabled' : ''}`; notice.textContent = organizationAutomationEnabled - ? '组织全自动化已开启:新任务切入 Auto / Program 后,解析通过可能直接进入 ERP,不附加人工确认期。' - : '组织全自动化已关闭:解析模式变化不会改变当前人工确认设置。'; + ? '平台全自动化已开启:新任务切入 Auto / Program 后,解析通过可能直接进入 ERP,不附加人工确认期。' + : '平台全自动化已关闭:解析模式变化不会改变当前人工确认设置。'; } if (emergency) emergency.disabled = parserRoutingBusy || !parserRoutingRows.length; body.replaceChildren(); @@ -519,7 +636,7 @@ function renderParserRouting() { } async function syncParserRouting() { - if (!authUser) return; + if (!isAdministrator()) return; parserRoutingBusy = true; const message = $('#parserRoutingMessage'); if (message) message.textContent = '正在读取解析策略…'; @@ -544,7 +661,7 @@ async function changeParserRoutingMode(routeId, mode) { : PARSER_MODE_ORDER; if (!route || route.mode_locked || !allowedModes.includes(mode) || parserRoutingBusy || route.mode === mode) return; if (organizationAutomationEnabled && ['auto', 'program'].includes(mode)) { - const confirmed = window.confirm(`组织全自动化已开启。将“${route.directive}”切换到 ${PARSER_MODE_LABELS[mode]} 后,新任务解析通过可能直接进入 ERP。确认继续?`); + const confirmed = window.confirm(`平台全自动化已开启。将“${route.directive}”切换到 ${PARSER_MODE_LABELS[mode]} 后,新任务解析通过可能直接进入 ERP。确认继续?`); if (!confirmed) { renderParserRouting(); return; @@ -657,7 +774,7 @@ function renderChannelList() { } async function syncChannels() { - if (!authUser || IS_HISTORY_PAGE) return null; + if (!isAdministrator()) return null; const result = await apiRequest('/api/channels'); channelList = Array.isArray(result.channels) ? result.channels : []; renderChannelList(); @@ -768,6 +885,621 @@ async function deleteChannel(channelId) { if (message) message.textContent = `“${channel.display_name || '未命名渠道'}”已删除。`; } +function accountRoleLabel(role) { + if (role === 'admin') return '管理员'; + if (role === 'team_lead') return '组长'; + return '普通用户'; +} + +function renderAccounts() { + const container = $('#accountList'); + if (!container) return; + container.replaceChildren(); + if (!accountList.length) { + accountAuthorizationTargetId = ''; + accountAuthorizationDraft = new Set(); + renderAccountAuthorizationPanel(); + container.append(el('p', 'muted channel-empty', '还没有可显示的平台账号。')); + return; + } + for (const account of accountList) { + const row = el('article', 'channel-row'); + const main = el('div', 'channel-row-main'); + const heading = el('div', 'channel-row-heading'); + heading.append(el('strong', '', account.username)); + 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)}${account.must_change_password ? ' · 待修改密码' : ''}`)); + const authorizedCount = Array.isArray(account.authorized_business_route_ids) + ? account.authorized_business_route_ids.length + : 0; + main.append(el( + 'p', + 'muted account-authorization-summary', + account.role === 'admin' + ? `任务权限:全部 ${accountTaskTypes.length || 18} 类业务` + : `任务权限:已授权 ${authorizedCount}/${accountTaskTypes.length || 18} 类业务` + )); + main.append(el('p', 'muted', `最近登录:${account.last_login_at ? new Date(account.last_login_at).toLocaleString('zh-CN') : '尚未登录'}`)); + const actions = el('div', 'channel-row-actions'); + const role = document.createElement('select'); + role.dataset.accountAction = 'role'; + role.dataset.accountId = account.id; + role.innerHTML = ''; + role.value = account.role; + role.disabled = accountSettingsBusy || account.id === authUser?.id; + const toggle = el('button', 'secondary-button', account.is_active ? '停用' : '启用'); + toggle.type = 'button'; + toggle.dataset.accountAction = 'toggle'; + toggle.dataset.accountId = account.id; + toggle.disabled = accountSettingsBusy || account.id === authUser?.id; + const reset = el('button', 'secondary-button', '重置密码'); + reset.type = 'button'; + reset.dataset.accountAction = 'reset-password'; + reset.dataset.accountId = account.id; + reset.disabled = accountSettingsBusy; + const revoke = el('button', 'secondary-button', '撤销会话'); + revoke.type = 'button'; + revoke.dataset.accountAction = 'revoke-sessions'; + revoke.dataset.accountId = account.id; + revoke.disabled = accountSettingsBusy; + const authorizations = el('button', 'secondary-button', account.role === 'admin' ? '全部任务' : '任务权限'); + authorizations.type = 'button'; + authorizations.dataset.accountAction = 'business-authorizations'; + authorizations.dataset.accountId = account.id; + authorizations.disabled = accountSettingsBusy || account.role === 'admin'; + actions.append(role, authorizations, toggle, reset, revoke); + row.append(main, actions); + container.append(row); + } + renderAccountAuthorizationPanel(); +} + +function renderAccountAuthorizationPanel() { + const panel = $('#accountAuthorizationPanel'); + const container = $('#accountAuthorizationTypes'); + if (!panel || !container) return; + const account = accountList.find((item) => item.id === accountAuthorizationTargetId); + if (!account || account.role === 'admin') { + panel.hidden = true; + container.replaceChildren(); + accountAuthorizationTargetId = ''; + accountAuthorizationDraft = new Set(); + return; + } + panel.hidden = false; + $('#accountAuthorizationTitle').textContent = `${account.username} · 任务类型授权`; + $('#accountAuthorizationHint').textContent = '仅勾选的业务可以接收指令、补充信息和名单附件;取消授权后,后续确认与 ERP 领取也会被阻止。'; + container.replaceChildren(); + for (const taskType of accountTaskTypes) { + const label = el('label', `account-authorization-type${accountAuthorizationDraft.has(taskType.route_id) ? ' is-selected' : ''}`); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = accountAuthorizationDraft.has(taskType.route_id); + checkbox.disabled = accountSettingsBusy; + checkbox.dataset.accountAuthorizationRoute = taskType.route_id; + const copy = el('span', 'account-authorization-type-copy'); + copy.append(el('strong', '', taskType.directive || taskType.route_id)); + copy.append(el('small', 'muted', taskType.route_id)); + label.append(checkbox, copy); + container.append(label); + } + const count = accountAuthorizationDraft.size; + $('#accountAuthorizationCount').textContent = `已选择 ${count}/${accountTaskTypes.length} 类业务${count ? '' : ';该账号将不能提交任何业务指令'}`; + $('#accountAuthorizationSelectAll').disabled = accountSettingsBusy || !accountTaskTypes.length; + $('#accountAuthorizationClear').disabled = accountSettingsBusy || !accountAuthorizationDraft.size; + $('#accountAuthorizationSave').disabled = accountSettingsBusy; + $('#accountAuthorizationCancel').disabled = accountSettingsBusy; +} + +function openAccountAuthorizationEditor(accountId) { + const account = accountList.find((item) => item.id === accountId); + if (!account || account.role === 'admin') return; + accountAuthorizationTargetId = account.id; + accountAuthorizationDraft = new Set( + Array.isArray(account.authorized_business_route_ids) ? account.authorized_business_route_ids : [] + ); + renderAccountAuthorizationPanel(); + $('#accountAuthorizationPanel')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); +} + +function closeAccountAuthorizationEditor() { + accountAuthorizationTargetId = ''; + accountAuthorizationDraft = new Set(); + renderAccountAuthorizationPanel(); +} + +async function saveAccountBusinessAuthorizations() { + if (!isAdministrator() || accountSettingsBusy || !accountAuthorizationTargetId) return; + const account = accountList.find((item) => item.id === accountAuthorizationTargetId); + if (!account || account.role === 'admin') return; + accountSettingsBusy = true; + renderAccounts(); + const message = $('#accountMessage'); + try { + const result = await apiRequest(`/api/accounts/${encodeURIComponent(account.id)}/business-authorizations`, { + method: 'PUT', + body: { + business_route_ids: accountTaskTypes + .map((taskType) => taskType.route_id) + .filter((routeId) => accountAuthorizationDraft.has(routeId)), + expected_revision: Number(account.business_authorization_revision || 0) + } + }); + if (result.account) { + accountList = accountList.map((item) => item.id === account.id ? result.account : item); + } + if (message) message.textContent = `“${account.username}”的任务类型授权已更新,并立即应用于后续指令。`; + accountAuthorizationTargetId = ''; + accountAuthorizationDraft = new Set(); + } finally { + accountSettingsBusy = false; + renderAccounts(); + } +} + +async function syncAccounts() { + if (!isAdministrator()) return; + const result = await apiRequest('/api/accounts'); + accountList = Array.isArray(result.accounts) ? result.accounts : []; + accountTaskTypes = Array.isArray(result.task_types) ? result.task_types : []; + renderAccounts(); +} + +async function createAccountFromForm() { + if (!isAdministrator() || accountSettingsBusy) return; + accountSettingsBusy = true; + renderAccounts(); + const message = $('#accountMessage'); + try { + const result = await apiRequest('/api/accounts', { + method: 'POST', + body: { + username: String($('#accountUsername').value || '').trim(), + password: String($('#accountPassword').value || ''), + role: $('#accountRole').value, + must_change_password: $('#accountMustChangePassword').checked, + business_route_ids: [] + } + }); + if (result.account) accountList = [...accountList.filter((item) => item.id !== result.account.id), result.account] + .sort((left, right) => left.username.localeCompare(right.username, 'zh-CN')); + $('#accountUsername').value = ''; + $('#accountPassword').value = ''; + if (message) { + message.textContent = result.account?.role === 'admin' + ? '管理员账号已创建;该角色固定拥有全部任务权限,初始密码不会再次显示。' + : '账号已创建,当前默认不能执行任何业务;请点击“任务权限”完成授权。'; + } + } finally { + accountSettingsBusy = false; + renderAccounts(); + } +} + +async function updateManagedAccount(accountId, patch) { + if (!isAdministrator() || accountSettingsBusy) return; + accountSettingsBusy = true; + renderAccounts(); + try { + const result = await apiRequest(`/api/accounts/${encodeURIComponent(accountId)}`, { method: 'PATCH', body: patch }); + if (result.account) accountList = accountList.map((item) => item.id === accountId ? result.account : item); + } finally { + accountSettingsBusy = false; + renderAccounts(); + } +} + +async function resetManagedAccountPassword(accountId) { + if (!isAdministrator() || accountSettingsBusy) return; + const password = window.prompt('请输入至少 12 个字符的新密码。账号下次登录时必须修改此密码:'); + if (password == null) return; + accountSettingsBusy = true; + renderAccounts(); + try { + await apiRequest(`/api/accounts/${encodeURIComponent(accountId)}/reset-password`, { + method: 'POST', + body: { password, must_change_password: true } + }); + const message = $('#accountMessage'); + if (message) message.textContent = '密码已重置,该账号的现有会话已全部撤销。'; + await syncAccounts(); + } finally { + accountSettingsBusy = false; + renderAccounts(); + } +} + +async function revokeManagedAccountSessions(accountId) { + if (!isAdministrator() || accountSettingsBusy) return; + if (!window.confirm('确认撤销该账号的全部登录会话?')) return; + accountSettingsBusy = true; + renderAccounts(); + try { + const result = await apiRequest(`/api/accounts/${encodeURIComponent(accountId)}/revoke-sessions`, { + method: 'POST', body: {} + }); + const message = $('#accountMessage'); + if (message) message.textContent = `已撤销 ${Number(result.sessions_revoked || 0)} 个会话。`; + } finally { + accountSettingsBusy = false; + renderAccounts(); + } +} + +function renderAuditEvents() { + const container = $('#auditList'); + if (!container) return; + container.replaceChildren(); + if (!auditEvents.length) { + container.append(el('p', 'muted channel-empty', '暂无符合条件的审计记录。')); + return; + } + for (const event of auditEvents) { + const row = el('article', 'channel-row'); + const main = el('div', 'channel-row-main'); + const heading = el('div', 'channel-row-heading'); + heading.append(el('strong', '', event.event_type || 'unknown')); + heading.append(el('span', 'state state-ok', event.actor?.username || '系统')); + main.append(heading); + main.append(el('p', 'muted', `${event.entity_type || 'entity'} · ${event.entity_id || '—'} · ${new Date(event.created_at).toLocaleString('zh-CN')}`)); + const metadata = el('pre', 'task-lifecycle-console', JSON.stringify(event.metadata || {}, null, 2)); + row.append(main, metadata); + container.append(row); + } +} + +async function syncAuditEvents() { + if (!isAdministrator() || auditBusy) return; + auditBusy = true; + const params = new URLSearchParams({ limit: '100', offset: '0' }); + const eventType = String($('#auditEventType')?.value || '').trim(); + const entityType = String($('#auditEntityType')?.value || '').trim(); + if (eventType) params.set('event_type', eventType); + if (entityType) params.set('entity_type', entityType); + try { + const result = await apiRequest(`/api/audit?${params.toString()}`); + auditEvents = Array.isArray(result.events) ? result.events : []; + renderAuditEvents(); + const message = $('#auditMessage'); + if (message) message.textContent = `共 ${Number(result.total || auditEvents.length)} 条,当前显示 ${auditEvents.length} 条。`; + } finally { + auditBusy = false; + } +} + +function operationsDashboardDateValue(date) { + const parts = Object.fromEntries(new Intl.DateTimeFormat('en', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).formatToParts(date).map((part) => [part.type, part.value])); + return `${parts.year}-${parts.month}-${parts.day}`; +} + +function resetOperationsDashboardFilters() { + const today = new Date(); + const from = new Date(today.getTime() - 29 * 24 * 60 * 60 * 1_000); + $('#operationsDashboardFrom').value = operationsDashboardDateValue(from); + $('#operationsDashboardTo').value = operationsDashboardDateValue(today); + $('#operationsDashboardActor').value = ''; + $('#operationsDashboardBusiness').value = ''; + $('#operationsDashboardStatus').value = 'all'; + $('#operationsDashboardSearch').value = ''; + operationsDashboardOffset = 0; +} + +function operationsDashboardRange() { + const fromValue = String($('#operationsDashboardFrom')?.value || ''); + const toValue = String($('#operationsDashboardTo')?.value || ''); + const from = new Date(`${fromValue}T00:00:00+08:00`); + const toInclusive = new Date(`${toValue}T00:00:00+08:00`); + if (!fromValue || !toValue || Number.isNaN(from.getTime()) || Number.isNaN(toInclusive.getTime())) { + throw new Error('请选择有效的开始与结束日期。'); + } + if (toInclusive.getTime() < from.getTime()) throw new Error('结束日期不能早于开始日期。'); + const toExclusive = new Date(toInclusive); + toExclusive.setDate(toExclusive.getDate() + 1); + return { from: from.toISOString(), to: toExclusive.toISOString() }; +} + +function operationsDashboardStatusAppearance(outcomeKind) { + if (outcomeKind === 'failed') return 'state-bad'; + if (outcomeKind === 'completed') return 'state-ok'; + return 'state-warn'; +} + +function renderOperationsDashboardActorOptions(actors) { + const select = $('#operationsDashboardActor'); + if (!select) return; + const selected = select.value; + select.replaceChildren(new Option('全部账号', '')); + for (const actor of actors || []) { + const state = actor.is_active ? '' : ' · 已停用'; + select.append(new Option(`${actor.username} · ${accountRoleLabel(actor.role)}${state}`, actor.id)); + } + if ([...select.options].some((option) => option.value === selected)) select.value = selected; +} + +function renderOperationsDashboardBusinessOptions(options) { + const select = $('#operationsDashboardBusiness'); + if (!select) return; + const selected = select.value; + select.replaceChildren(new Option('全部业务', '')); + for (const option of options || []) { + select.append(new Option(option.label || option.key, option.key)); + } + if ([...select.options].some((option) => option.value === selected)) select.value = selected; +} + +function renderOperationsDashboardActiveFilters() { + const container = $('#operationsDashboardActiveFilters'); + if (!container) return; + const actor = $('#operationsDashboardActor')?.selectedOptions?.[0]?.textContent || '全部账号'; + const business = $('#operationsDashboardBusiness')?.selectedOptions?.[0]?.textContent || '全部业务'; + const status = $('#operationsDashboardStatus')?.selectedOptions?.[0]?.textContent || '全部状态'; + const from = String($('#operationsDashboardFrom')?.value || ''); + const to = String($('#operationsDashboardTo')?.value || ''); + const search = String($('#operationsDashboardSearch')?.value || '').trim(); + container.textContent = `当前范围:${from} 至 ${to} · ${actor} · ${business} · ${status}${search ? ` · 关键词“${search}”` : ''}`; +} + +function renderOperationsDashboardSummary() { + const container = $('#operationsDashboardSummary'); + if (!container) return; + container.replaceChildren(); + const summary = operationsDashboardData?.summary || {}; + const cards = [ + ['总指令', summary.total || 0, '查看全部结果', 'all'], + ['进行中', summary.active || 0, '仍在处理', 'active'], + ['已完成', summary.completed || 0, '已有业务结果', 'completed'], + ['待处理', summary.attention || 0, '待补充、确认或核验', 'attention'], + ['异常', summary.failed || 0, '未正常完成', 'failed'], + ['已取消', summary.cancelled || 0, '主动取消的指令', 'cancelled'], + ['已归档', summary.archived || 0, '历史归档记录', 'archived'], + ['涉及用户', summary.user_count || 0, '有操作记录的账号', ''] + ]; + const selectedStatus = String($('#operationsDashboardStatus')?.value || 'all'); + for (const [label, value, hint, status] of cards) { + const card = el(status ? 'button' : 'article', `operations-dashboard-stat${status && selectedStatus === status ? ' is-active' : ''}`); + if (status) { + card.type = 'button'; + card.dataset.operationsStatus = status; + card.setAttribute('aria-label', `查看${label}指令`); + } + card.append(el('span', '', label)); + card.append(el('strong', '', String(value))); + card.append(el('small', '', hint)); + container.append(card); + } +} + +function renderOperationsDashboardBreakdownList(containerSelector, values, datasetKey, emptyText) { + const container = $(containerSelector); + if (!container) return; + container.replaceChildren(); + if (!(values || []).length) { + container.append(el('p', 'muted channel-empty', emptyText)); + return; + } + for (const item of values || []) { + const button = el('button', 'operations-dashboard-breakdown-row'); + button.type = 'button'; + button.dataset[datasetKey] = item.key; + const heading = el('span', 'operations-dashboard-breakdown-heading'); + heading.append(el('strong', '', item.label), el('b', '', String(item.total))); + const metrics = el('small', 'muted', `完成 ${item.completed} · 待处理 ${item.attention} · 异常 ${item.failed} · 取消 ${item.cancelled || 0}`); + button.append(heading, metrics); + container.append(button); + } +} + +function renderOperationsDashboardBreakdowns() { + renderOperationsDashboardBreakdownList( + '#operationsDashboardDays', + operationsDashboardData?.days, + 'operationsDay', + '当前范围内没有可按日期查看的数据。' + ); + renderOperationsDashboardBreakdownList( + '#operationsDashboardBusinesses', + operationsDashboardData?.businesses, + 'operationsBusiness', + '当前范围内没有可按业务查看的数据。' + ); +} + +function renderOperationsDashboardUsers() { + const container = $('#operationsDashboardUsers'); + if (!container) return; + container.replaceChildren(); + const users = operationsDashboardData?.users || []; + $('#operationsDashboardUserCount').textContent = `${users.length} 个有操作记录的账号`; + if (!users.length) { + container.append(el('p', 'muted channel-empty', '当前筛选范围内没有用户操作记录。')); + return; + } + for (const item of users) { + const button = el('button', 'operations-dashboard-user-row'); + button.type = 'button'; + button.dataset.operationsActorId = item.actor.id; + const heading = el('div', 'operations-dashboard-row-heading'); + heading.append(el('strong', '', item.actor.username)); + heading.append(el('span', 'muted', accountRoleLabel(item.actor.role))); + const metrics = el('div', 'operations-dashboard-user-metrics'); + metrics.append( + el('span', '', `总计 ${item.total}`), + el('span', '', `完成 ${item.completed}`), + el('span', '', `待处理 ${item.attention}`), + el('span', item.failed ? 'is-error' : '', `异常 ${item.failed}`), + el('span', '', `取消 ${item.cancelled || 0}`) + ); + button.append(heading, metrics, el('small', 'muted', `最近活动:${formatDateTime(item.last_activity_at)}`)); + container.append(button); + } +} + +function renderOperationsDashboardTasks() { + const container = $('#operationsDashboardTasks'); + if (!container) return; + container.replaceChildren(); + const tasks = operationsDashboardData?.tasks || []; + const total = Number(operationsDashboardData?.total || 0); + $('#operationsDashboardTaskCount').textContent = `共 ${total} 条`; + if (!tasks.length) { + container.append(el('p', 'muted channel-empty', '当前筛选范围内没有指令操作记录。')); + } + for (const task of tasks) { + const button = el('button', `operations-dashboard-task-row${operationsDashboardSelectedTaskId === task.task_id ? ' is-selected' : ''}`); + button.type = 'button'; + button.dataset.operationsTaskId = task.task_id; + const heading = el('div', 'operations-dashboard-row-heading'); + heading.append(el('strong', '', `${task.creator.username} · ${task.business_route_label}`)); + heading.append(el('span', `state ${operationsDashboardStatusAppearance(task.outcome_kind)}`, task.outcome_label)); + const meta = el('p', 'muted', `${formatDateTime(task.created_at)} · ${task.input_turn_count} 次指令${task.archived_at ? ' · 已归档' : ''} · 记录号 ${task.task_id}`); + const instruction = el('p', 'operations-dashboard-instruction', `指令:${task.instruction_preview || '(内容为空)'}`); + const result = el('p', 'operations-dashboard-result', `结果:${task.result_summary || '暂无业务结果'}`); + button.append(heading, meta, instruction, result); + container.append(button); + } + const start = total ? operationsDashboardOffset + 1 : 0; + const end = operationsDashboardOffset + tasks.length; + $('#operationsDashboardPageInfo').textContent = total ? `${start}–${end} / ${total}` : '0 条'; + $('#operationsDashboardPrevious').disabled = operationsDashboardBusy || operationsDashboardOffset <= 0; + $('#operationsDashboardNext').disabled = operationsDashboardBusy || !operationsDashboardData?.has_more; +} + +function renderOperationsDashboard() { + renderOperationsDashboardActiveFilters(); + renderOperationsDashboardSummary(); + renderOperationsDashboardBreakdowns(); + renderOperationsDashboardUsers(); + renderOperationsDashboardTasks(); +} + +function appendOperationsDashboardDetailSection(container, title, content) { + const section = el('section', 'operations-dashboard-detail-section'); + section.append(el('h3', '', title)); + section.append(content); + container.append(section); +} + +function renderOperationsDashboardDetail() { + const panel = $('#operationsDashboardDetail'); + const container = $('#operationsDashboardDetailBody'); + if (!panel || !container) return; + container.replaceChildren(); + const detail = operationsDashboardDetail; + if (!detail?.task_id) { + panel.hidden = true; + return; + } + panel.hidden = false; + $('#operationsDashboardDetailTitle').textContent = `指令业务详情 · ${detail.creator?.username || '未知账号'}`; + + const facts = el('div', 'operations-dashboard-detail-facts'); + appendField(facts, '操作人', detail.creator?.username || '未知账号'); + appendField(facts, '业务类型', detail.business_route_label || '未识别业务'); + appendField(facts, '提交时间', formatDateTime(detail.created_at)); + appendField(facts, '业务结果状态', detail.outcome_label || '处理中'); + appendField(facts, '结果记录时间', formatDateTime(detail.result_recorded_at)); + appendField(facts, '归档状态', detail.archived_at ? `已归档 · ${formatDateTime(detail.archived_at)}` : '未归档'); + appendOperationsDashboardDetailSection(container, '业务概况', facts); + + const inputs = el('div', 'operations-dashboard-input-history'); + for (const message of detail.instructions || []) { + const item = el('article', 'operations-dashboard-input-item'); + item.append(el('strong', '', `第 ${message.turn_no} 次指令 · ${message.actor?.username || detail.creator?.username || '历史账号'}`)); + item.append(el('span', 'muted', formatDateTime(message.created_at))); + item.append(el('pre', '', message.content || '(内容为空)')); + inputs.append(item); + } + if (!inputs.childElementCount) inputs.append(el('p', 'muted', '没有可显示的指令内容。')); + appendOperationsDashboardDetailSection(container, '指令内容', inputs); + + const attachments = el('div', 'operations-dashboard-attachments'); + for (const attachment of detail.attachments || []) { + const item = el('article', 'operations-dashboard-attachment-row'); + item.append(el('strong', '', attachment.file_name || '未命名附件')); + item.append(el('span', 'muted', `${attachment.actor?.username || detail.creator?.username || '历史账号'} · ${formatDateTime(attachment.created_at)}${attachment.row_count == null ? '' : ` · ${attachment.row_count} 行数据`}`)); + attachments.append(item); + } + if (attachments.childElementCount) { + appendOperationsDashboardDetailSection(container, '随指令提交的附件', attachments); + } + appendOperationsDashboardDetailSection( + container, + '完成结果', + el('pre', 'operations-dashboard-result-detail', detail.result_summary || '暂无业务结果。') + ); +} + +async function syncOperationsDashboard() { + if (!canViewOperationsDashboard() || operationsDashboardBusy) return; + operationsDashboardBusy = true; + renderOperationsDashboardTasks(); + const message = $('#operationsDashboardMessage'); + if (message) message.textContent = '正在读取操作数据…'; + try { + const range = operationsDashboardRange(); + const params = new URLSearchParams({ + from: range.from, + to: range.to, + status: String($('#operationsDashboardStatus')?.value || 'all'), + limit: String(OPERATIONS_DASHBOARD_PAGE_SIZE), + offset: String(operationsDashboardOffset) + }); + const actorUserId = String($('#operationsDashboardActor')?.value || ''); + const businessRouteId = String($('#operationsDashboardBusiness')?.value || ''); + const search = String($('#operationsDashboardSearch')?.value || '').trim(); + if (actorUserId) params.set('actor_user_id', actorUserId); + if (businessRouteId) params.set('business_route_id', businessRouteId); + if (search) params.set('search', search); + const result = await apiRequest(`/api/operations-dashboard?${params.toString()}`); + operationsDashboardData = result; + renderOperationsDashboardActorOptions(result.actors || []); + renderOperationsDashboardBusinessOptions(result.business_options || []); + renderOperationsDashboard(); + if (message) message.textContent = `查询完成:${Number(result.total || 0)} 条人工指令记录。可继续按状态、人员、日期或业务穿透,点击记录查看“谁、指令、结果”。`; + } finally { + operationsDashboardBusy = false; + renderOperationsDashboardTasks(); + } +} + +async function loadOperationsDashboardTask(taskId) { + if (!canViewOperationsDashboard() || !taskId) return; + operationsDashboardSelectedTaskId = taskId; + operationsDashboardDetail = null; + renderOperationsDashboardTasks(); + const panel = $('#operationsDashboardDetail'); + const container = $('#operationsDashboardDetailBody'); + panel.hidden = false; + container.replaceChildren(el('p', 'muted', '正在读取指令与业务结果…')); + $('#operationsDashboardDetailTitle').textContent = '指令业务详情'; + const result = await apiRequest(`/api/operations-dashboard/tasks/${encodeURIComponent(taskId)}`); + if (operationsDashboardSelectedTaskId !== taskId) return; + operationsDashboardDetail = result; + renderOperationsDashboardDetail(); + panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); +} + +function closeOperationsDashboardDetail() { + operationsDashboardSelectedTaskId = ''; + operationsDashboardDetail = null; + $('#operationsDashboardDetail').hidden = true; + renderOperationsDashboardTasks(); +} + +function refreshOperationsDashboardAfterDrilldown() { + operationsDashboardOffset = 0; + closeOperationsDashboardDetail(); + void syncOperationsDashboard().catch((error) => { + $('#operationsDashboardMessage').textContent = error.message || String(error); + }); +} + async function refreshCsrfToken() { const result = await apiRequest('/api/auth/csrf'); csrfToken = result.csrf_token || ''; @@ -790,6 +1522,7 @@ async function syncRemoteTasks() { }); if (IS_HISTORY_PAGE && historySearch) params.set('search', historySearch); if (IS_HISTORY_PAGE && historyStatus) params.set('status', historyStatus); + if (IS_HISTORY_PAGE) params.set('archive', historyArchive); const result = await apiRequest(`/api/tasks?${params.toString()}`); // Keep the server snapshot authoritative, but reject older event/timestamp // versions and preserve a short-lived local overlay until the result is @@ -797,8 +1530,9 @@ async function syncRemoteTasks() { taskStore = (Array.isArray(result.tasks) ? result.tasks : []) .map(mergeRemoteTask) .filter((task) => ( - task?.status !== 'cancelled' - && task?.result?.status !== 'cancelled' + (historyArchive === 'archived' || ( + task?.status !== 'cancelled' && task?.result?.status !== 'cancelled' + )) && !locallyDeletedTaskIds.has(task?.task_id) )); const pagination = result.pagination || {}; @@ -1026,13 +1760,35 @@ async function ensureTaskDetails(taskId, { force = false } = {}) { return request; } +async function ensureTaskInputHistory(taskId, { force = false } = {}) { + if (!force && taskInputHistoryStore.has(taskId)) return taskInputHistoryStore.get(taskId); + const existingRequest = taskInputHistoryRequests.get(taskId); + if (existingRequest) return existingRequest; + const request = apiRequest(`/api/tasks/${encodeURIComponent(taskId)}/input-history`, { timeoutMs: RESULT_API_TIMEOUT_MS }) + .then((result) => { + const history = { + messages: Array.isArray(result.messages) ? result.messages : [], + attachments: Array.isArray(result.attachments) ? result.attachments : [] + }; + taskInputHistoryStore.set(taskId, history); + if (currentTaskId === taskId) renderTaskDetail(); + return history; + }) + .finally(() => taskInputHistoryRequests.delete(taskId)); + taskInputHistoryRequests.set(taskId, request); + return request; +} + function loadSelectedTaskDetails(task) { - if (!authUser || !task || isDetailedTask(task)) return; - const lifecycle = $('#taskLifecycle'); - if (lifecycle) lifecycle.textContent = '正在加载任务详情…'; - void ensureTaskDetails(task.task_id).catch((error) => { - if (lifecycle?.isConnected) lifecycle.textContent = `任务详情加载失败:${error.message || String(error)}`; - }); + if (!authUser || !task) return; + if (!isDetailedTask(task)) { + const lifecycle = $('#taskLifecycle'); + if (lifecycle) lifecycle.textContent = '正在加载任务详情…'; + void ensureTaskDetails(task.task_id).catch((error) => { + if (lifecycle?.isConnected) lifecycle.textContent = `任务详情加载失败:${error.message || String(error)}`; + }); + } + void ensureTaskInputHistory(task.task_id).catch(() => {}); } function upsertLocalTask(task, { forceVisible = true, source = 'server' } = {}) { @@ -1503,7 +2259,12 @@ function isTaskPollable(task) { } function canDeleteTask(task) { - return Boolean(task); + if (!task) return false; + if (task.archived_at) return true; + return ![ + 'parse_queued', 'parse_running', 'confirmed', 'queued', 'accepted', 'running' + ].includes(String(task.status || '')) + && !['awaiting_handoff', 'accepted', 'running'].includes(String(task.handoff_status || '')); } function canStartConfirmedTask(task) { @@ -1549,16 +2310,13 @@ async function autoDispatchReadyTasks({ force = false } = {}) { if (!authUser || autoHandoffInProgress) return; autoHandoffInProgress = true; try { - let candidates = taskStore; - if (organizationAutomationEnabled) { - 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); - cacheRuntimeTask(task); - } - candidates = [...byId.values()]; + 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); + 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; @@ -2441,6 +3199,7 @@ function parserDifferenceNode(entry) { function parserReviewNode(decisionId, reviewStatus) { const review = el('div', 'task-parser-review'); review.append(el('span', 'muted', `人工判定:${reviewStatus || 'pending'}`)); + if (!isAdministrator()) return review; const actions = el('div', 'task-parser-review-actions'); const options = [ ['equivalent', '等价'], @@ -2495,7 +3254,8 @@ function renderTaskParserPanel(task) { } const failure = taskFailureInfo(task); - const canReparse = task.status === 'parse_failed' + const canReparse = isAdministrator() + && task.status === 'parse_failed' && Number(parser.reparse_count || 0) < 1 && failure?.no_plugin_dispatch === true && failure?.no_erp_write === true; @@ -2547,6 +3307,42 @@ async function reparseTaskWithAi(taskId, button) { } } +function renderTaskInputAudit(task) { + if (!task?.task_id) return null; + 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 / 系统' : '历史记录未知')}`)); + const history = taskInputHistoryStore.get(task.task_id); + if (!history) { + panel.append(el('p', 'muted', '正在加载加密输入历史…')); + return panel; + } + for (const message of history.messages) { + const item = el('article', 'detail-block'); + const sourceLabel = message.source === 'manual' ? '人工输入' + : message.source === 'agentbus' ? 'AgentBus' + : message.source === 'reparse' ? '管理员重解析' : '历史/系统'; + item.append(el( + 'p', + 'muted', + `第 ${message.turn_no} 轮 · ${message.actor?.username || '未知操作人'} · ${sourceLabel} · ${new Date(message.created_at).toLocaleString('zh-CN')}` + )); + item.append(el('pre', 'task-lifecycle-console', message.content || '')); + panel.append(item); + } + for (const attachment of history.attachments) { + panel.append(el( + 'p', + 'muted', + `附件:${attachment.file_name || '未命名'} · ${attachment.actor?.username || '未知操作人'} · ${attachment.status} · SHA-256 ${String(attachment.sha256 || '').slice(0, 16)}…` + )); + } + if (!history.messages.length && !history.attachments.length) { + panel.append(el('p', 'muted', '没有可显示的输入审计记录。')); + } + return panel; +} + function renderTaskDetail() { const container = $('#taskDetail'); if (!container) return; @@ -2585,9 +3381,9 @@ function renderTaskDetail() { if (deleteButton) { const deleteState = task ? taskDeleteStates.get(task.task_id) : ''; deleteButton.disabled = !canDeleteTask(task) || deleteState === 'deleting'; - if (deleteState === 'deleting') deleteButton.textContent = '删除中…'; - else if (deleteState === 'failed') deleteButton.textContent = '重试删除'; - else deleteButton.textContent = '删除任务'; + if (deleteState === 'deleting') deleteButton.textContent = task?.archived_at ? '恢复中…' : '归档中…'; + else if (deleteState === 'failed') deleteButton.textContent = task?.archived_at ? '重试恢复' : '重试归档'; + else deleteButton.textContent = task?.archived_at ? '恢复任务' : '归档任务'; } if (taskState) { taskState.textContent = task ? taskStatusLabel(task) : '未创建'; @@ -2613,6 +3409,8 @@ function renderTaskDetail() { const panels = []; if (importantMessagePanel) panels.push(importantMessagePanel); + const inputAuditPanel = renderTaskInputAudit(task); + if (inputAuditPanel) panels.push(inputAuditPanel); const parserPanel = renderTaskParserPanel(task); if (parserPanel) panels.push(parserPanel); const operationReviewPanel = renderOperationReview(task); @@ -2851,10 +3649,16 @@ function configurePageMode() { document.body.classList.toggle('history-page', IS_HISTORY_PAGE); document.body.classList.toggle('channels-page-active', IS_CHANNELS_PAGE); document.body.classList.toggle('parser-routing-page-active', IS_PARSER_ROUTING_PAGE); + document.body.classList.toggle('accounts-page-active', IS_ACCOUNTS_PAGE); + document.body.classList.toggle('audit-page-active', IS_AUDIT_PAGE); + document.body.classList.toggle('operations-dashboard-page-active', IS_OPERATIONS_DASHBOARD_PAGE); const instructionPanel = document.querySelector('.instruction-panel'); const historyFilters = $('#historyFilters'); const channelsPage = $('#channelsPage'); const parserRoutingPage = $('#parserRoutingPage'); + const accountsPage = $('#accountsPage'); + const auditPage = $('#auditPage'); + const operationsDashboardPage = $('#operationsDashboardPage'); const title = $('#taskListTitle'); const hint = $('#taskListHint'); const historyLink = $('#historyLink'); @@ -2862,19 +3666,27 @@ function configurePageMode() { const historyNav = $('#historyNav'); const channelsNav = $('#channelsNav'); const parserRoutingNav = $('#parserRoutingNav'); + const accountsNav = $('#accountsNav'); + const auditNav = $('#auditNav'); + const operationsDashboardNav = $('#operationsDashboardNav'); if (instructionPanel) instructionPanel.hidden = IS_HISTORY_PAGE; if (channelsPage) channelsPage.hidden = !IS_CHANNELS_PAGE; if (parserRoutingPage) parserRoutingPage.hidden = !IS_PARSER_ROUTING_PAGE; + if (accountsPage) accountsPage.hidden = !IS_ACCOUNTS_PAGE; + if (auditPage) auditPage.hidden = !IS_AUDIT_PAGE; + if (operationsDashboardPage) operationsDashboardPage.hidden = !IS_OPERATIONS_DASHBOARD_PAGE; if (historyFilters) historyFilters.hidden = !IS_HISTORY_PAGE; if (title) title.textContent = IS_HISTORY_PAGE ? '历史任务目录' : '任务卡片'; - if (hint) hint.textContent = IS_HISTORY_PAGE ? '全部未删除任务' : '最近任务'; + if (hint) hint.textContent = IS_HISTORY_PAGE + ? (historyArchive === 'archived' ? '已归档任务' : '未归档任务') + : '最近任务'; if (historyLink) { historyLink.href = IS_HISTORY_PAGE ? '/' : '/history'; historyLink.textContent = IS_HISTORY_PAGE ? '返回 AI 操作台' : '查看更多'; historyLink.hidden = !IS_HISTORY_PAGE && taskListMeta.total <= HOME_TASK_LIMIT; } if (workbenchNav) { - const workbenchActive = !IS_HISTORY_PAGE && !IS_CHANNELS_PAGE && !IS_PARSER_ROUTING_PAGE; + const workbenchActive = !IS_HISTORY_PAGE && !IS_MANAGEMENT_PAGE; workbenchNav.classList.toggle('is-active', workbenchActive); if (!workbenchActive) workbenchNav.removeAttribute('aria-current'); else workbenchNav.setAttribute('aria-current', 'page'); @@ -2894,13 +3706,33 @@ function configurePageMode() { if (IS_PARSER_ROUTING_PAGE) parserRoutingNav.setAttribute('aria-current', 'page'); else parserRoutingNav.removeAttribute('aria-current'); } - if (IS_PARSER_ROUTING_PAGE) document.title = '解析策略 · LianSyn-platform'; + if (accountsNav) { + accountsNav.classList.toggle('is-active', IS_ACCOUNTS_PAGE); + if (IS_ACCOUNTS_PAGE) accountsNav.setAttribute('aria-current', 'page'); + else accountsNav.removeAttribute('aria-current'); + } + if (auditNav) { + auditNav.classList.toggle('is-active', IS_AUDIT_PAGE); + if (IS_AUDIT_PAGE) auditNav.setAttribute('aria-current', 'page'); + else auditNav.removeAttribute('aria-current'); + } + if (operationsDashboardNav) { + operationsDashboardNav.classList.toggle('is-active', IS_OPERATIONS_DASHBOARD_PAGE); + if (IS_OPERATIONS_DASHBOARD_PAGE) operationsDashboardNav.setAttribute('aria-current', 'page'); + else operationsDashboardNav.removeAttribute('aria-current'); + } + if (IS_ACCOUNTS_PAGE) document.title = '账号管理 · LianSyn-platform'; + else if (IS_AUDIT_PAGE) document.title = '审计记录 · LianSyn-platform'; + else if (IS_OPERATIONS_DASHBOARD_PAGE) document.title = '操作数据看板 · LianSyn-platform'; + else if (IS_PARSER_ROUTING_PAGE) document.title = '解析策略 · LianSyn-platform'; else if (IS_CHANNELS_PAGE) document.title = 'AgentBus 渠道 · LianSyn-platform'; else if (IS_HISTORY_PAGE) document.title = '历史任务 · LianSyn-platform'; const searchInput = $('#historySearchInput'); const statusInput = $('#historyStatusInput'); + const archiveInput = $('#historyArchiveInput'); if (searchInput && searchInput.value !== historySearch) searchInput.value = historySearch; if (statusInput && statusInput.value !== historyStatus) statusInput.value = historyStatus; + if (archiveInput && archiveInput.value !== historyArchive) archiveInput.value = historyArchive; } function renderTaskListHeader() { @@ -2946,8 +3778,8 @@ function resetHistoryTaskSelection({ clearMessage = true } = {}) { function renderHistoryBatchActions(visibleTasks = []) { const actions = $('#historyBatchActions'); if (!actions) return; - actions.hidden = !IS_HISTORY_PAGE; - if (!IS_HISTORY_PAGE) return; + actions.hidden = !IS_HISTORY_PAGE || historyArchive === 'archived'; + if (!IS_HISTORY_PAGE || historyArchive === 'archived') return; const visibleTaskIds = new Set(visibleTasks.map((task) => task?.task_id).filter(Boolean)); for (const taskId of [...historySelectedTaskIds]) { @@ -2973,10 +3805,10 @@ function renderHistoryBatchActions(visibleTasks = []) { if (deleteButton) { deleteButton.disabled = historyBulkDeleteBusy || selectedCount === 0; deleteButton.textContent = historyBulkDeleteBusy - ? `正在删除 ${selectedCount} 个…` + ? `正在归档 ${selectedCount} 个…` : selectedCount > 0 - ? `删除所选 (${selectedCount})` - : '删除所选'; + ? `归档所选 (${selectedCount})` + : '归档所选'; } actions.setAttribute('aria-busy', historyBulkDeleteBusy ? 'true' : 'false'); } @@ -3050,17 +3882,19 @@ function renderTaskCards() { card.querySelector('.task-card-status .task-pill').textContent = taskStatusLabel(task); if (IS_HISTORY_PAGE) { card.classList.add('has-history-actions'); - const selection = document.createElement('label'); - selection.className = 'task-card-select'; - selection.title = `选择任务 ${task.task_id}`; - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.dataset.historyTaskSelect = task.task_id; - checkbox.checked = historySelectedTaskIds.has(task.task_id); - checkbox.disabled = historyBulkDeleteBusy || deleteState === 'deleting'; - checkbox.setAttribute('aria-label', `选择任务 ${task.task_id}`); - selection.append(checkbox); - card.prepend(selection); + if (historyArchive !== 'archived') { + const selection = document.createElement('label'); + selection.className = 'task-card-select'; + selection.title = `选择任务 ${task.task_id}`; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.dataset.historyTaskSelect = task.task_id; + checkbox.checked = historySelectedTaskIds.has(task.task_id); + checkbox.disabled = historyBulkDeleteBusy || deleteState === 'deleting'; + checkbox.setAttribute('aria-label', `选择任务 ${task.task_id}`); + selection.append(checkbox); + card.prepend(selection); + } const deleteButton = document.createElement('button'); deleteButton.type = 'button'; @@ -3068,11 +3902,11 @@ function renderTaskCards() { deleteButton.dataset.historyTaskDelete = task.task_id; deleteButton.disabled = historyBulkDeleteBusy || deleteState === 'deleting'; deleteButton.textContent = deleteState === 'deleting' - ? '删除中…' + ? (task.archived_at ? '恢复中…' : '归档中…') : deleteState === 'failed' - ? '重试删除' - : '删除'; - deleteButton.setAttribute('aria-label', `彻底删除任务 ${task.task_id}`); + ? (task.archived_at ? '重试恢复' : '重试归档') + : (task.archived_at ? '恢复' : '归档'); + deleteButton.setAttribute('aria-label', `${task.archived_at ? '恢复' : '归档'}任务 ${task.task_id}`); card.append(deleteButton); } container.appendChild(card); @@ -3623,7 +4457,7 @@ async function handoffTaskToExtension(task) { try { claim = await apiRequest(`/api/tasks/${encodeURIComponent(task.task_id)}/claim`, { method: 'POST', - body: { connection_id: BROWSER_CONNECTION_ID } + body: { connection_id: browserConnectionId } }); if (claim.task) upsertLocalTask(claim.task); if (!claim.claimed) { @@ -3692,7 +4526,7 @@ async function handoffTaskToExtension(task) { const persisted = await apiRequest(`/api/tasks/${encodeURIComponent(task.task_id)}/result`, { method: 'POST', body: { - connection_id: BROWSER_CONNECTION_ID, + connection_id: browserConnectionId, execution_id: claim.execution_id, result: uncertainResult } @@ -3753,7 +4587,7 @@ async function pingBridge() { await apiRequest('/api/connections/heartbeat', { method: 'POST', body: { - connection_id: `administrator-browser:${location.origin}`, + connection_id: browserConnectionId, extension_version: result.version || '', metadata: { bridge_installed_at: result.bridge_installed_at || '' } } @@ -3834,12 +4668,10 @@ async function refreshBackgroundState() { if (!authUser || backgroundRefreshInProgress) return; backgroundRefreshInProgress = true; try { - await Promise.allSettled([ - pingAi(), - pingBridge(), - syncAutomationSettings({ background: true }), - syncRemoteTasks() - ]); + const operations = [pingAi(), pingBridge()]; + if (isAdministrator()) operations.push(syncAutomationSettings({ background: true })); + if (IS_TASK_PAGE) operations.push(syncRemoteTasks()); + await Promise.allSettled(operations); } finally { backgroundRefreshInProgress = false; } @@ -3858,39 +4690,19 @@ async function pollResult() { }); } -function taskNeedsPluginCancellation(task) { - return Boolean( - task?.confirmed_at - || ['accepted', 'syncing', 'waiting_extension', 'paused'].includes(task?.handoff_status) - ); -} - -function requestPluginTaskStop(task) { - if (!taskNeedsPluginCancellation(task)) return; - // Platform deletion is authoritative. Do not make the operator wait for - // an extension response; stopping the extension is best effort only and - // a write already in flight cannot be rolled back here. - void sendToExtension('DELETE_TASK', { task_id: task.task_id }, 4000).catch(() => {}); -} - -function confirmTaskHardDelete(tasks) { +function confirmTaskArchive(tasks) { const selectedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : []; if (!selectedTasks.length) return false; const scope = selectedTasks.length === 1 ? `任务 ${selectedTasks[0].task_id}` : `所选 ${selectedTasks.length} 个任务`; - const pluginTaskCount = selectedTasks.filter(taskNeedsPluginCancellation).length; - const pluginWarning = pluginTaskCount > 0 - ? `\n\n其中 ${pluginTaskCount} 个任务已确认或进入插件流程;平台只会尽力通知插件停止,已经发生的 ERP 写入不会被撤回。` - : ''; return window.confirm( - `确认彻底删除${scope}?\n\n任务记录、生命周期、附件和持久化回执将不可恢复。${pluginWarning}` + `确认归档${scope}?\n\n归档后任务不会出现在日常列表中,但创建人、原始输入、生命周期、附件和回执都会保留,可从“已归档”范围恢复。` ); } function removeDeletedTaskLocally(taskId) { const wasVisible = taskStore.some((item) => item.task_id === taskId); - locallyDeletedTaskIds.add(taskId); taskDeleteStates.delete(taskId); historySelectedTaskIds.delete(taskId); taskStore = taskStore.filter((item) => item.task_id !== taskId); @@ -3898,6 +4710,8 @@ function removeDeletedTaskLocally(taskId) { remoteTaskStore.delete(taskId); taskDetailStore.delete(taskId); taskDetailRequests.delete(taskId); + taskInputHistoryStore.delete(taskId); + taskInputHistoryRequests.delete(taskId); localTaskOverlayStore.delete(taskId); pendingExtensionResults.delete(taskId); persistedExtensionResultVersions.delete(taskId); @@ -3931,14 +4745,18 @@ async function deleteTask(taskId) { renderTaskCards(); try { - requestPluginTaskStop(task); - await apiRequest(`/api/tasks/${encodeURIComponent(taskId)}`, { - method: 'DELETE', + const restoring = Boolean(task.archived_at); + await apiRequest( + restoring + ? `/api/tasks/${encodeURIComponent(taskId)}/restore` + : `/api/tasks/${encodeURIComponent(taskId)}/archive`, { + method: 'POST', + body: {}, timeoutMs: TASK_DELETE_API_TIMEOUT_MS }); removeDeletedTaskLocally(taskId); if (IS_HISTORY_PAGE) { - historyBatchMessage = `任务 ${taskId} 已彻底删除。`; + historyBatchMessage = `任务 ${taskId} 已${restoring ? '恢复' : '归档'}。`; historyBatchMessageKind = 'success'; } finishLocalTaskDeletions(); @@ -3946,17 +4764,17 @@ async function deleteTask(taskId) { } catch (error) { taskDeleteStates.set(taskId, 'failed'); if (IS_HISTORY_PAGE) { - historyBatchMessage = `未删除任务 ${taskId}:${error.message || String(error)}`; + historyBatchMessage = `任务 ${taskId} 操作失败:${error.message || String(error)}`; historyBatchMessageKind = 'error'; } - if (currentTaskId === taskId) setTaskState('删除失败'); + if (currentTaskId === taskId) setTaskState('归档/恢复失败'); setOutput({ - status: 'delete_error', + status: 'archive_error', task_id: taskId, - message: `未删除任务:${error.message || String(error)}` + message: `任务归档/恢复失败:${error.message || String(error)}` }); renderTaskCards(); - if (currentTaskId === taskId) setTaskState('删除失败'); + if (currentTaskId === taskId) setTaskState('归档/恢复失败'); throw error; } } @@ -3964,36 +4782,35 @@ async function deleteTask(taskId) { async function deleteHistorySelectedTasks() { if (!IS_HISTORY_PAGE || historyBulkDeleteBusy) return; const selectedTasks = taskStore.filter((task) => historySelectedTaskIds.has(task.task_id)); - if (!selectedTasks.length || !confirmTaskHardDelete(selectedTasks)) return; + if (!selectedTasks.length || !confirmTaskArchive(selectedTasks)) return; const taskIds = selectedTasks.map((task) => task.task_id); historyBulkDeleteBusy = true; - historyBatchMessage = `正在彻底删除 ${taskIds.length} 个任务…`; + historyBatchMessage = `正在归档 ${taskIds.length} 个任务…`; historyBatchMessageKind = ''; for (const task of selectedTasks) { taskDeleteStates.set(task.task_id, 'deleting'); - requestPluginTaskStop(task); } renderTaskCards(); try { - await apiRequest('/api/tasks/bulk-delete', { + await apiRequest('/api/tasks/bulk-archive', { method: 'POST', body: { task_ids: taskIds }, timeoutMs: TASK_DELETE_API_TIMEOUT_MS }); for (const taskId of taskIds) removeDeletedTaskLocally(taskId); resetHistoryTaskSelection({ clearMessage: false }); - historyBatchMessage = `已彻底删除 ${taskIds.length} 个任务。`; + historyBatchMessage = `已归档 ${taskIds.length} 个任务。`; historyBatchMessageKind = 'success'; finishLocalTaskDeletions(); void syncRemoteTasks().catch(() => {}); } catch (error) { for (const taskId of taskIds) taskDeleteStates.set(taskId, 'failed'); - historyBatchMessage = `批量删除失败,未删除所选任务:${error.message || String(error)}`; + historyBatchMessage = `批量归档失败,所选任务均未变更:${error.message || String(error)}`; historyBatchMessageKind = 'error'; - setTaskState('批量删除失败'); + setTaskState('批量归档失败'); setOutput({ - status: 'bulk_delete_error', + status: 'bulk_archive_error', task_ids: taskIds, message: historyBatchMessage }); @@ -4028,7 +4845,7 @@ async function persistExtensionTaskResult(taskId, result) { const persisted = await apiRequest(`/api/tasks/${encodeURIComponent(taskId)}/result`, { method: 'POST', body: { - connection_id: BROWSER_CONNECTION_ID, + connection_id: browserConnectionId, execution_id: executionId, result } @@ -4199,38 +5016,57 @@ async function initializeSession() { } showAuthenticatedApp(me.user); - try { - await syncAutomationSettings(); - } catch (error) { - setTaskState('全自动化设置读取失败'); - } - if (IS_PARSER_ROUTING_PAGE) { + if (me.user?.must_change_password) return true; + if (isAdministrator()) { try { - await syncParserRouting(); + await syncAutomationSettings(); } catch (error) { - // The parser page remains available and displays its own load error. + setTaskState('全自动化设置读取失败'); } } - try { - await syncChannels(); - } catch (error) { - const message = $('#channelMessage'); - if (message) message.textContent = `渠道读取失败:${error.message || String(error)}`; + if (IS_PARSER_ROUTING_PAGE && isAdministrator()) { + await syncParserRouting().catch(() => {}); } - try { - await syncRemoteTasks(); - } catch (error) { - // A task-list/database failure must not erase a valid administrator - // session. Keep the workbench available and surface the sync error. - setTaskState('任务同步失败'); - setOutput({ status: 'sync_error', message: error.message || String(error) }); + if (IS_CHANNELS_PAGE && isAdministrator()) { + await syncChannels().catch((error) => { + const message = $('#channelMessage'); + if (message) message.textContent = `渠道读取失败:${error.message || String(error)}`; + }); + } + if (IS_ACCOUNTS_PAGE && isAdministrator()) { + await syncAccounts().catch((error) => { + const message = $('#accountMessage'); + if (message) message.textContent = `账号读取失败:${error.message || String(error)}`; + }); + } + if (IS_AUDIT_PAGE && isAdministrator()) { + await syncAuditEvents().catch((error) => { + const message = $('#auditMessage'); + if (message) message.textContent = `审计读取失败:${error.message || String(error)}`; + }); + } + if (IS_OPERATIONS_DASHBOARD_PAGE && canViewOperationsDashboard()) { + await syncOperationsDashboard().catch((error) => { + const message = $('#operationsDashboardMessage'); + if (message) message.textContent = `看板读取失败:${error.message || String(error)}`; + }); + } + if (IS_TASK_PAGE) { + try { + await syncRemoteTasks(); + } catch (error) { + // A task-list/database failure must not erase a valid session. + setTaskState('任务同步失败'); + setOutput({ status: 'sync_error', message: error.message || String(error) }); + } + startRemoteEventStream(); } - startRemoteEventStream(); return true; } document.addEventListener('DOMContentLoaded', async () => { configurePageMode(); + if (IS_OPERATIONS_DASHBOARD_PAGE) resetOperationsDashboardFilters(); // Remove credentials/configuration left by the retired browser-side parser. localStorage.removeItem('liansyn_platform_agent_config'); sessionStorage.removeItem('liansyn_platform_agent_api_key'); @@ -4281,20 +5117,21 @@ document.addEventListener('DOMContentLoaded', async () => { csrfToken = result.csrf_token || ''; showAuthenticatedApp(result.user); $('#loginPassword').value = ''; - try { - await syncAutomationSettings(); - } catch (error) { - setTaskState('全自动化设置读取失败'); + if (result.user?.must_change_password) return; + if (isAdministrator()) { + await syncAutomationSettings().catch(() => setTaskState('全自动化设置读取失败')); } - if (IS_PARSER_ROUTING_PAGE) await syncParserRouting().catch(() => {}); - try { - await syncChannels(); - } catch (error) { + 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_ACCOUNTS_PAGE && isAdministrator()) await syncAccounts(); + if (IS_AUDIT_PAGE && isAdministrator()) await syncAuditEvents(); + if (IS_TASK_PAGE) { + await syncRemoteTasks(); + startRemoteEventStream(); } - await syncRemoteTasks(); - startRemoteEventStream(); await pingAi(); await pingBridge(); } catch (error) { @@ -4303,6 +5140,40 @@ document.addEventListener('DOMContentLoaded', async () => { if (submitButton) submitButton.disabled = false; } }); + $('#changePasswordButton').addEventListener('click', () => showPasswordChangePanel(false)); + $('#passwordChangeCancel').addEventListener('click', () => { + if (passwordChangeForced || !authUser) return; + showAuthenticatedApp(authUser); + }); + $('#passwordChangeForm').addEventListener('submit', async (event) => { + event.preventDefault(); + const errorNode = $('#passwordChangeError'); + const currentPassword = String($('#currentPassword').value || ''); + const newPassword = String($('#newPassword').value || ''); + const confirmation = String($('#confirmNewPassword').value || ''); + if (newPassword !== confirmation) { + errorNode.textContent = '两次输入的新密码不一致。'; + return; + } + const submit = $('#passwordChangeForm button[type="submit"]'); + submit.disabled = true; + errorNode.textContent = ''; + try { + await apiRequest('/api/auth/password', { + method: 'PUT', + body: { current_password: currentPassword, new_password: newPassword } + }); + $('#currentPassword').value = ''; + $('#newPassword').value = ''; + $('#confirmNewPassword').value = ''; + passwordChangeForced = false; + await initializeSession(); + } catch (error) { + errorNode.textContent = error.message || String(error); + } finally { + submit.disabled = false; + } + }); $('#logoutButton').addEventListener('click', async () => { try { await apiRequest('/api/auth/logout', { method: 'POST', body: {} }); @@ -4314,6 +5185,8 @@ document.addEventListener('DOMContentLoaded', async () => { remoteTaskStore.clear(); taskDetailStore.clear(); taskDetailRequests.clear(); + taskInputHistoryStore.clear(); + taskInputHistoryRequests.clear(); localTaskOverlayStore.clear(); extensionResultPersistQueues.clear(); pendingExtensionResults.clear(); @@ -4322,11 +5195,16 @@ document.addEventListener('DOMContentLoaded', async () => { taskReplyStates.clear(); taskReplyDrafts.clear(); taskAttachmentDrafts.clear(); + accountList = []; + accountTaskTypes = []; + accountAuthorizationTargetId = ''; + accountAuthorizationDraft = new Set(); historySelectedTaskIds.clear(); locallyDeletedTaskIds.clear(); historyBatchMessage = ''; historyBatchMessageKind = ''; currentTaskId = ''; + browserConnectionId = ''; sessionStorage.removeItem('liansyn_platform_current_task_id'); $('#rosterAttachment').value = ''; $('#rosterAttachmentStatus').textContent = ''; @@ -4349,9 +5227,11 @@ document.addEventListener('DOMContentLoaded', async () => { event.preventDefault(); historySearch = normalizeText($('#historySearchInput').value).slice(0, 200); historyStatus = normalizeText($('#historyStatusInput').value).slice(0, 80); + historyArchive = $('#historyArchiveInput').value === 'archived' ? 'archived' : 'active'; historyOffset = 0; currentTaskId = ''; resetHistoryTaskSelection(); + configurePageMode(); syncRemoteTasks().catch((error) => { setTaskState('历史任务筛选失败'); setOutput({ status: 'history_sync_error', message: error.message || String(error) }); @@ -4360,6 +5240,7 @@ document.addEventListener('DOMContentLoaded', async () => { $('#historyResetButton').addEventListener('click', () => { historySearch = ''; historyStatus = ''; + historyArchive = 'active'; historyOffset = 0; currentTaskId = ''; resetHistoryTaskSelection(); @@ -4402,6 +5283,144 @@ document.addEventListener('DOMContentLoaded', async () => { setOutput({ status: 'automation_settings_error', message: error.message || String(error) }); }); }); + $('#accountForm')?.addEventListener('submit', (event) => { + event.preventDefault(); + createAccountFromForm().catch((error) => { + const message = $('#accountMessage'); + if (message) message.textContent = error.message || String(error); + }); + }); + $('#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 message = $('#accountMessage'); + if (message) message.textContent = error.message || String(error); + void syncAccounts(); + }); + }); + $('#accountList')?.addEventListener('click', (event) => { + const button = event.target.closest('button[data-account-action][data-account-id]'); + if (!button) return; + const account = accountList.find((item) => item.id === button.dataset.accountId); + if (!account) return; + const action = button.dataset.accountAction; + let operation; + if (action === 'business-authorizations') { + openAccountAuthorizationEditor(account.id); + return; + } + 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; + operation.catch((error) => { + const message = $('#accountMessage'); + if (message) message.textContent = error.message || String(error); + }); + }); + $('#accountAuthorizationTypes')?.addEventListener('change', (event) => { + const checkbox = event.target.closest('input[data-account-authorization-route]'); + if (!checkbox || accountSettingsBusy) return; + const routeId = checkbox.dataset.accountAuthorizationRoute; + if (checkbox.checked) accountAuthorizationDraft.add(routeId); + else accountAuthorizationDraft.delete(routeId); + renderAccountAuthorizationPanel(); + }); + $('#accountAuthorizationSelectAll')?.addEventListener('click', () => { + if (accountSettingsBusy) return; + accountAuthorizationDraft = new Set(accountTaskTypes.map((taskType) => taskType.route_id)); + renderAccountAuthorizationPanel(); + }); + $('#accountAuthorizationClear')?.addEventListener('click', () => { + if (accountSettingsBusy) return; + accountAuthorizationDraft = new Set(); + renderAccountAuthorizationPanel(); + }); + $('#accountAuthorizationSave')?.addEventListener('click', () => { + void saveAccountBusinessAuthorizations().catch((error) => { + const message = $('#accountMessage'); + if (message) message.textContent = error.message || String(error); + }); + }); + $('#accountAuthorizationCancel')?.addEventListener('click', closeAccountAuthorizationEditor); + $('#auditFilters')?.addEventListener('submit', (event) => { + event.preventDefault(); + void syncAuditEvents().catch((error) => { + const message = $('#auditMessage'); + if (message) message.textContent = error.message || String(error); + }); + }); + $('#auditResetButton')?.addEventListener('click', () => { + $('#auditEventType').value = ''; + $('#auditEntityType').value = ''; + void syncAuditEvents(); + }); + $('#operationsDashboardFilters')?.addEventListener('submit', (event) => { + event.preventDefault(); + operationsDashboardOffset = 0; + closeOperationsDashboardDetail(); + void syncOperationsDashboard().catch((error) => { + $('#operationsDashboardMessage').textContent = error.message || String(error); + }); + }); + $('#operationsDashboardReset')?.addEventListener('click', () => { + resetOperationsDashboardFilters(); + closeOperationsDashboardDetail(); + void syncOperationsDashboard().catch((error) => { + $('#operationsDashboardMessage').textContent = error.message || String(error); + }); + }); + $('#operationsDashboardUsers')?.addEventListener('click', (event) => { + const button = event.target.closest('[data-operations-actor-id]'); + if (!button) return; + $('#operationsDashboardActor').value = button.dataset.operationsActorId || ''; + refreshOperationsDashboardAfterDrilldown(); + }); + $('#operationsDashboardSummary')?.addEventListener('click', (event) => { + const button = event.target.closest('[data-operations-status]'); + if (!button) return; + $('#operationsDashboardStatus').value = button.dataset.operationsStatus || 'all'; + refreshOperationsDashboardAfterDrilldown(); + }); + $('#operationsDashboardDays')?.addEventListener('click', (event) => { + const button = event.target.closest('[data-operations-day]'); + if (!button?.dataset.operationsDay) return; + $('#operationsDashboardFrom').value = button.dataset.operationsDay; + $('#operationsDashboardTo').value = button.dataset.operationsDay; + refreshOperationsDashboardAfterDrilldown(); + }); + $('#operationsDashboardBusinesses')?.addEventListener('click', (event) => { + const button = event.target.closest('[data-operations-business]'); + if (!button?.dataset.operationsBusiness) return; + $('#operationsDashboardBusiness').value = button.dataset.operationsBusiness; + refreshOperationsDashboardAfterDrilldown(); + }); + $('#operationsDashboardTasks')?.addEventListener('click', (event) => { + const button = event.target.closest('[data-operations-task-id]'); + if (!button) return; + void loadOperationsDashboardTask(button.dataset.operationsTaskId).catch((error) => { + $('#operationsDashboardMessage').textContent = `详情读取失败:${error.message || String(error)}`; + closeOperationsDashboardDetail(); + }); + }); + $('#operationsDashboardPrevious')?.addEventListener('click', () => { + if (operationsDashboardOffset <= 0 || operationsDashboardBusy) return; + operationsDashboardOffset = Math.max(0, operationsDashboardOffset - OPERATIONS_DASHBOARD_PAGE_SIZE); + closeOperationsDashboardDetail(); + void syncOperationsDashboard().catch((error) => { + $('#operationsDashboardMessage').textContent = error.message || String(error); + }); + }); + $('#operationsDashboardNext')?.addEventListener('click', () => { + if (!operationsDashboardData?.has_more || operationsDashboardBusy) return; + operationsDashboardOffset += OPERATIONS_DASHBOARD_PAGE_SIZE; + closeOperationsDashboardDetail(); + void syncOperationsDashboard().catch((error) => { + $('#operationsDashboardMessage').textContent = error.message || String(error); + }); + }); + $('#operationsDashboardDetailClose')?.addEventListener('click', closeOperationsDashboardDetail); $('#parserRoutingRows')?.addEventListener('change', (event) => { const select = event.target.closest('select[data-parser-mode-route]'); if (!select) return; @@ -4478,9 +5497,9 @@ document.addEventListener('DOMContentLoaded', async () => { $('#deleteTaskButton').addEventListener('click', () => { const task = selectedTask(); if (!task) return; - if (!confirmTaskHardDelete([task])) return; + if (!task.archived_at && !confirmTaskArchive([task])) return; deleteSelectedTask().catch((error) => setOutput({ - status: 'delete_error', + status: 'archive_error', task_id: task.task_id, message: error.message || String(error) })); @@ -4500,7 +5519,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (deleteButton) { const taskId = deleteButton.dataset.historyTaskDelete; const task = taskStore.find((item) => item.task_id === taskId); - if (task && confirmTaskHardDelete([task])) { + if (task && (task.archived_at || confirmTaskArchive([task]))) { void deleteTask(taskId).catch(() => {}); } return; @@ -4535,7 +5554,8 @@ document.addEventListener('DOMContentLoaded', async () => { void copyTaskLifecycle(button); }); if (await initializeSession()) { - renderTaskCards(); + if (authUser?.must_change_password) return; + if (IS_TASK_PAGE) renderTaskCards(); pingAi().catch(() => {}); pingBridge().catch(() => {}); // The control plane caches this health probe; keep the browser refresh @@ -4554,7 +5574,7 @@ document.addEventListener('DOMContentLoaded', async () => { window.addEventListener('focus', () => { refreshBackgroundState().catch(() => {}); }); - if (currentTaskId) renderTaskDetail(); - if (hasPollableRuntimeTasks()) startPolling(); + if (IS_TASK_PAGE && currentTaskId) renderTaskDetail(); + if (IS_TASK_PAGE && hasPollableRuntimeTasks()) startPolling(); } }); diff --git a/LianSyn-platform/index.html b/LianSyn-platform/index.html index d5c7c05..70f275e 100644 --- a/LianSyn-platform/index.html +++ b/LianSyn-platform/index.html @@ -5,7 +5,7 @@