From 823d1cb6305077e1743f8783176d2cae3b5aec39 Mon Sep 17 00:00:00 2001 From: inman Date: Tue, 1 Sep 2026 19:52:56 +0800 Subject: [PATCH] feat: redesign leadership operations dashboard --- .../20260901-leadership-dashboard-c4b9e1.md | 65 ++ ...d-c4b9e1__leadership-dashboard-boundary.md | 23 + LianSyn-platform/app.js | 353 +++++-- LianSyn-platform/index.html | 152 +-- LianSyn-platform/styles.css | 985 ++++++++++++++---- control-plane/src/task-service.ts | 68 +- .../test/account-authorization.test.ts | 40 +- 7 files changed, 1320 insertions(+), 366 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260901-leadership-dashboard-c4b9e1.md create mode 100644 .project-docs/60-reflection/cases/20260901-leadership-dashboard-c4b9e1__leadership-dashboard-boundary.md diff --git a/.project-docs/30-worklog/tasks/20260901-leadership-dashboard-c4b9e1.md b/.project-docs/30-worklog/tasks/20260901-leadership-dashboard-c4b9e1.md new file mode 100644 index 0000000..ba3a6d2 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-leadership-dashboard-c4b9e1.md @@ -0,0 +1,65 @@ +# Task: Redesign leadership operations dashboard + +## Identity + +- Task ID: 20260901-leadership-dashboard-c4b9e1 +- Mode: Feature +- Branch: main +- Worktree: /Users/inmanx/Documents/lwltAPI +- Base commit: 08725b2de9af2b927772689610e2b538c5a4ff2a +- Owner: codex +- Status: Ready for integration + +## Scope + +- Reframe `/operations-dashboard` from an audit/history-oriented view into an executive platform-operations dashboard. +- Make task volume, people, original input, final output, time, business type, and completion state the primary dimensions. +- Preserve leadership-only read access and clickable drill-through without exposing task mutation or technical debugging context. +- Keep the existing stack and ERP/business execution behavior unchanged. + +## Intent And Constraints + +- The dashboard is for leaders, not engineers. Visible copy must not include parser/plugin/ERP internals, codes, JSON, stack traces, record identifiers, or technical failure strings. +- Every summary dimension should lead to a filtered task view, and each task should show who acted, when, the task type, original instruction, and final business-facing outcome. +- Reuse the existing leadership projection and authorization boundary where possible; narrow its result language if technical text can leak into the executive view. +- Do not add organization concepts, change task authorization, access ERP, or send data externally. + +## Outcome + +- Renamed the leadership surface to “平台运行看板” and rebuilt it around an executive “平台运行全景” rather than audit/history terminology. +- Added an aggregate-first layout with task volume, completion rate, active work, follow-up work, unfinished work, participating people, time trend, ranked task types, people performance, and a task flow from original input to final output. +- Preserved and clarified drill-through: status cards, dates, task types, people, pagination, keyword/date filters, and individual task rows all lead to a narrower business-facing view. +- Replaced technical/history-oriented task rows with who/time/type plus original-input and final-output columns. Internal record identifiers are no longer rendered. +- Rebuilt task detail as a read-only leadership drawer showing person, task type, time, progress, original input turns, submitted business materials, and final business outcome. +- Added leadership-safe input and result projections in both the control-plane service and browser presentation. Technical payloads, parser/plugin/ERP terms, internal codes, and historical machine JSON are replaced by concise business-readable explanations in this dashboard only. +- Kept the existing `admin`/`team_lead` access gate, manual-task scope, task authorization, normal task ownership, audit administration, and ERP execution behavior unchanged. +- The current static operator page reads the updated source immediately from the existing 8786 service. No service restart, database migration, ERP access, external send, or runtime task mutation was performed. + +## Verification + +- `node --check LianSyn-platform/app.js`: passed using the bundled Node runtime. +- Focused account/leadership regression: 8/8 passed. +- `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. +- `git diff --check`: passed. +- Authenticated browser verification on `http://127.0.0.1:8786/operations-dashboard` loaded 359 current tasks, 8 active dates, 19 task-type buckets, 1 participating account, and 50 paginated task rows with no dashboard-visible technical result text. +- Browser drill-through verification: a date selected `2026-08-11` and narrowed the view to 174 tasks; the top task type narrowed the view to 306 tasks; unfinished status narrowed to 94 tasks; a task drawer showed person, time, type, original input, submitted material, and final business-facing result. +- Browser content checks found zero code-like input blocks and zero plugin/ERP/internal-code output blocks across the loaded page after the leadership presentation filters were applied. + +## Follow-ups + +- The standard database still has only one administrator account, so the people-performance area currently contains one person. Create team-lead and ordinary-user accounts through the existing administrator workflow to make cross-person comparison meaningful. +- The control-plane TypeScript projection will be picked up on the next separately authorized service restart; the browser presentation layer already enforces the same leadership-safe wording on the running page. + +## Promotion Candidates + +- Target: `AUTH-001`, `.project-docs/20-architecture/data-flow.md`, `.project-docs/20-architecture/system-overview.md`, and `.project-docs/40-domain/business-rules.md` during a later Integration Gate. +- Proposal: define the leadership dashboard as an aggregate-first platform-operations view for leaders, not an audit log or debugging surface. Its primary dimensions are task, person, original input, final output, time, task type, and business completion state; every aggregate may drill into the same business-facing task projection. +- Proposal: raw technical result strings and historical machine-shaped inputs are never shown on the leadership surface. They remain available only through appropriate engineering/audit diagnostics, while the leadership dashboard renders a bounded business explanation. +- Evidence: `LianSyn-platform/index.html`, `LianSyn-platform/app.js`, `LianSyn-platform/styles.css`, `control-plane/src/task-service.ts`, `control-plane/test/account-authorization.test.ts`, the full test suite, and authenticated browser verification summarized above. +- Future impact: dashboard acceptance criteria, operations API projections, visual hierarchy, error copy, and browser regression tests should start from audience and decision questions before implementation details. +- Semantic conflict: this narrows and clarifies the prior “who/instruction/result history” implementation; it does not change the accepted read-only leadership authorization boundary. +- Human confirmation required: received in this task through the explicit correction that the dashboard is for leadership and must not expose technical language. diff --git a/.project-docs/60-reflection/cases/20260901-leadership-dashboard-c4b9e1__leadership-dashboard-boundary.md b/.project-docs/60-reflection/cases/20260901-leadership-dashboard-c4b9e1__leadership-dashboard-boundary.md new file mode 100644 index 0000000..9fd82a1 --- /dev/null +++ b/.project-docs/60-reflection/cases/20260901-leadership-dashboard-c4b9e1__leadership-dashboard-boundary.md @@ -0,0 +1,23 @@ +# Reflection: Leadership dashboard is not an audit log + +## Trigger + +The first operations-dashboard implementation met the read-only who/instruction/result data contract but organized the page as a searchable instruction history. The user corrected that framing: leaders need an aggregate platform-running view with touchable business dimensions, not technical audit history. + +## Lesson + +- Start dashboard work by defining the audience, decisions, and primary questions before selecting tables or detail fields. +- A leadership operations dashboard is aggregate-first: task volume, people, time, type, progress, original input, and final output. Detail exists to explain an aggregate, not to reproduce a task log. +- Keep engineering/audit diagnostics as a separate surface even when both read the same durable task records. +- “Original input” does not always mean displaying bytes verbatim. Historical machine-shaped payloads should be replaced by an explicit no-business-instruction placeholder on a leadership surface, while the underlying audit evidence remains unchanged. +- Use both a server projection and a presentation-layer guard when an already-running deployment may temporarily serve an older projection during a frontend-only rollout. + +## Evidence + +- User correction on 2026-09-01. +- Before/after authenticated browser verification of `/operations-dashboard`. +- Task record `20260901-leadership-dashboard-c4b9e1` and its full regression results. + +## Candidate + +Add an audience-and-decision-questions checkpoint to future dashboard design acceptance criteria before implementation begins. diff --git a/LianSyn-platform/app.js b/LianSyn-platform/app.js index 5137db8..7e953af 100644 --- a/LianSyn-platform/app.js +++ b/LianSyn-platform/app.js @@ -1178,11 +1178,19 @@ function operationsDashboardDateValue(date) { return `${parts.year}-${parts.month}-${parts.day}`; } -function resetOperationsDashboardFilters() { +function setOperationsDashboardRange(days = 30) { + const normalizedDays = Math.max(1, Math.min(366, Number(days) || 30)); const today = new Date(); - const from = new Date(today.getTime() - 29 * 24 * 60 * 60 * 1_000); + const from = new Date(today.getTime() - (normalizedDays - 1) * 24 * 60 * 60 * 1_000); $('#operationsDashboardFrom').value = operationsDashboardDateValue(from); $('#operationsDashboardTo').value = operationsDashboardDateValue(today); + for (const button of document.querySelectorAll('[data-operations-range-days]')) { + button.classList.toggle('is-active', Number(button.dataset.operationsRangeDays) === normalizedDays); + } +} + +function resetOperationsDashboardFilters() { + setOperationsDashboardRange(30); $('#operationsDashboardActor').value = ''; $('#operationsDashboardBusiness').value = ''; $('#operationsDashboardStatus').value = 'all'; @@ -1207,14 +1215,78 @@ function operationsDashboardRange() { function operationsDashboardStatusAppearance(outcomeKind) { if (outcomeKind === 'failed') return 'state-bad'; if (outcomeKind === 'completed') return 'state-ok'; + if (outcomeKind === 'active') return 'state-process'; return 'state-warn'; } +function operationsDashboardOutcomeLabel(outcomeKind, fallback = '') { + if (outcomeKind === 'completed') return '已完成'; + if (outcomeKind === 'attention') return '待跟进'; + if (outcomeKind === 'failed') return '未完成'; + if (outcomeKind === 'cancelled') return '已取消'; + if (outcomeKind === 'active') return '进行中'; + return String(fallback || '进行中'); +} + +function operationsDashboardReadableResult(value, outcomeKind) { + const raw = String(value || '').trim(); + const technicalLanguage = /(?:\b(?:agent|api|dom|frame|iframe|json|native|operation|parser|payload|plugin|projection|requery|schema|stack|trace|write)\b|[a-z][a-z0-9]*(?:_[a-z0-9]+)+|data\.[a-z]|插件|解析服务|技术上下文|错误码|接口|ERP)/i; + if (!raw) { + if (outcomeKind === 'completed') return '工作已经完成。'; + if (outcomeKind === 'attention') return '正在等待补充信息或确认。'; + if (outcomeKind === 'failed') return '本次工作未完成,需要检查后重新处理。'; + if (outcomeKind === 'cancelled') return '这项工作已取消。'; + return '正在处理中。'; + } + if (!technicalLanguage.test(raw)) return raw; + if (/不确定|核验|请勿重复/.test(raw)) return '当前结果需要人工确认,请勿重复提交。'; + if (/未找到|不存在|不匹配|mismatch/i.test(raw)) return '未完成:没有找到符合指令条件的业务记录。'; + if (/登录|连接|网络|超时/i.test(raw)) return '未完成:业务系统暂时无法完成处理,请稍后再试。'; + if (/补充|缺少|必填|日期|年份/.test(raw)) return '待跟进:需要补充完整的业务信息。'; + if (/不支持|超出.*范围/.test(raw)) return '未完成:这项要求暂不在当前业务处理范围内。'; + if (outcomeKind === 'completed') return '工作已经完成,结果已记录。'; + if (outcomeKind === 'attention') return '正在等待补充信息或确认。'; + if (outcomeKind === 'cancelled') return '这项工作已取消。'; + return '未完成:当前业务条件未满足,需要检查后重新处理。'; +} + +function operationsDashboardSafeError(error, fallback = '看板暂时无法更新,请稍后再试。') { + const message = String(error?.message || '').trim(); + if (/^(请选择有效的开始与结束日期|结束日期不能早于开始日期|单次最多查询|关键词查询范围)/.test(message)) { + return message; + } + return fallback; +} + +function operationsDashboardCompletionRate(completed, total) { + if (!Number(total)) return 0; + return Math.round((Number(completed || 0) / Number(total)) * 1_000) / 10; +} + +function operationsDashboardShortDay(value) { + const date = new Date(`${value}T00:00:00+08:00`); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat('zh-CN', { month: 'numeric', day: 'numeric' }).format(date); +} + +function operationsDashboardBusinessLabel(value) { + const label = String(value || '').trim(); + return !label || label === '未识别业务' ? '其他任务' : label; +} + +function operationsDashboardReadableInput(value) { + const input = String(value || '').trim(); + if (!input) return '未记录输入内容'; + const looksLikeTechnicalPayload = /^[\[{]/.test(input) + || /(?:"(?:action|operation|payload|schema|submit_mode)"\s*:|[a-z][a-z0-9]*(?:_[a-z0-9]+){2,}|data\.[a-z])/i.test(input); + return looksLikeTechnicalPayload ? '该历史任务没有可展示的业务指令。' : input; +} + function renderOperationsDashboardActorOptions(actors) { const select = $('#operationsDashboardActor'); if (!select) return; const selected = select.value; - select.replaceChildren(new Option('全部账号', '')); + 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)); @@ -1226,9 +1298,9 @@ function renderOperationsDashboardBusinessOptions(options) { const select = $('#operationsDashboardBusiness'); if (!select) return; const selected = select.value; - select.replaceChildren(new Option('全部业务', '')); + select.replaceChildren(new Option('全部类型', '')); for (const option of options || []) { - select.append(new Option(option.label || option.key, option.key)); + select.append(new Option(operationsDashboardBusinessLabel(option.label || option.key), option.key)); } if ([...select.options].some((option) => option.value === selected)) select.value = selected; } @@ -1236,13 +1308,18 @@ function renderOperationsDashboardBusinessOptions(options) { 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 || '全部状态'; + container.replaceChildren(); + 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}”` : ''}`; + container.append(el('span', 'operations-dashboard-filter-label', '当前查看')); + for (const value of [`${from.replaceAll('-', '/')} — ${to.replaceAll('-', '/')}`, actor, business, status]) { + container.append(el('span', 'operations-dashboard-filter-chip', value)); + } + if (search) container.append(el('span', 'operations-dashboard-filter-chip', `包含“${search}”`)); } function renderOperationsDashboardSummary() { @@ -1250,64 +1327,93 @@ function renderOperationsDashboardSummary() { if (!container) return; container.replaceChildren(); const summary = operationsDashboardData?.summary || {}; + const completionRate = operationsDashboardCompletionRate(summary.completed, summary.total); 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, '有操作记录的账号', ''] + { label: '任务总量', value: summary.total || 0, hint: '点击查看全部工作', status: 'all', tone: 'primary' }, + { label: '已完成', value: summary.completed || 0, hint: `完成率 ${completionRate}%`, status: 'completed', tone: 'success' }, + { label: '进行中', value: summary.active || 0, hint: '正在推进的工作', status: 'active', tone: 'active' }, + { label: '待跟进', value: summary.attention || 0, hint: '等待补充或确认', status: 'attention', tone: 'attention' }, + { label: '未完成', value: summary.failed || 0, hint: '需要检查和复盘', status: 'failed', tone: 'failed' }, + { label: '参与人员', value: summary.user_count || 0, hint: '下方可查看个人表现', status: '', tone: 'people' } ]; 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) { + for (const item of cards) { + const card = el(item.status ? 'button' : 'article', `operations-dashboard-stat operations-dashboard-stat-${item.tone}${item.status && selectedStatus === item.status ? ' is-active' : ''}`); + if (item.status) { card.type = 'button'; - card.dataset.operationsStatus = status; - card.setAttribute('aria-label', `查看${label}指令`); + card.dataset.operationsStatus = item.status; + card.setAttribute('aria-label', `查看${item.label}任务`); } - card.append(el('span', '', label)); - card.append(el('strong', '', String(value))); - card.append(el('small', '', hint)); + card.append(el('span', '', item.label)); + card.append(el('strong', '', String(item.value))); + card.append(el('small', '', item.hint)); container.append(card); } } -function renderOperationsDashboardBreakdownList(containerSelector, values, datasetKey, emptyText) { - const container = $(containerSelector); +function renderOperationsDashboardDays() { + const container = $('#operationsDashboardDays'); if (!container) return; container.replaceChildren(); - if (!(values || []).length) { - container.append(el('p', 'muted channel-empty', emptyText)); + const days = [...(operationsDashboardData?.days || [])].sort((left, right) => left.key.localeCompare(right.key)); + const todayKey = operationsDashboardDateValue(new Date()); + const today = days.find((item) => item.key === todayKey); + $('#operationsDashboardTodayCount').textContent = `今天 ${today?.total || 0} 项`; + if (!days.length) { + container.append(el('p', 'operations-dashboard-empty', '当前范围内还没有任务数据。')); return; } - for (const item of values || []) { - const button = el('button', 'operations-dashboard-breakdown-row'); + const visibleDays = days.slice(-31); + const maxTotal = Math.max(...visibleDays.map((item) => Number(item.total || 0)), 1); + for (const item of visibleDays) { + const button = el('button', 'operations-dashboard-day-column'); 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); + button.dataset.operationsDay = item.key; + button.setAttribute('aria-label', `${item.label},${item.total} 项任务,完成 ${item.completed} 项`); + const bars = el('span', 'operations-dashboard-day-bars'); + const completedBar = el('i', 'is-completed'); + const remainingBar = el('i', 'is-remaining'); + completedBar.style.height = `${Math.max(0, (Number(item.completed || 0) / maxTotal) * 100)}%`; + remainingBar.style.height = `${Math.max(0, ((Number(item.total || 0) - Number(item.completed || 0)) / maxTotal) * 100)}%`; + bars.append(remainingBar, completedBar); + button.append( + el('strong', '', String(item.total)), + bars, + el('span', '', operationsDashboardShortDay(item.key)) + ); + container.append(button); + } +} + +function renderOperationsDashboardBusinesses() { + const container = $('#operationsDashboardBusinesses'); + if (!container) return; + container.replaceChildren(); + const businesses = operationsDashboardData?.businesses || []; + if (!businesses.length) { + container.append(el('p', 'operations-dashboard-empty', '当前范围内还没有任务类型数据。')); + return; + } + const maxTotal = Math.max(...businesses.map((item) => Number(item.total || 0)), 1); + for (const item of businesses) { + const button = el('button', 'operations-dashboard-business-row'); + button.type = 'button'; + button.dataset.operationsBusiness = item.key; + const heading = el('span', 'operations-dashboard-business-heading'); + heading.append(el('strong', '', operationsDashboardBusinessLabel(item.label)), el('b', '', String(item.total))); + const track = el('span', 'operations-dashboard-business-track'); + const fill = el('i', ''); + fill.style.width = `${Math.max(4, (Number(item.total || 0) / maxTotal) * 100)}%`; + track.append(fill); + const rate = operationsDashboardCompletionRate(item.completed, item.total); + button.append(heading, track, el('small', '', `已完成 ${item.completed} 项 · 完成率 ${rate}%`)); container.append(button); } } function renderOperationsDashboardBreakdowns() { - renderOperationsDashboardBreakdownList( - '#operationsDashboardDays', - operationsDashboardData?.days, - 'operationsDay', - '当前范围内没有可按日期查看的数据。' - ); - renderOperationsDashboardBreakdownList( - '#operationsDashboardBusinesses', - operationsDashboardData?.businesses, - 'operationsBusiness', - '当前范围内没有可按业务查看的数据。' - ); + renderOperationsDashboardDays(); + renderOperationsDashboardBusinesses(); } function renderOperationsDashboardUsers() { @@ -1315,27 +1421,37 @@ function renderOperationsDashboardUsers() { if (!container) return; container.replaceChildren(); const users = operationsDashboardData?.users || []; - $('#operationsDashboardUserCount').textContent = `${users.length} 个有操作记录的账号`; + $('#operationsDashboardUserCount').textContent = `${users.length} 人参与`; if (!users.length) { - container.append(el('p', 'muted channel-empty', '当前筛选范围内没有用户操作记录。')); + container.append(el('p', 'operations-dashboard-empty', '当前范围内还没有人员工作数据。')); return; } + const maxTotal = Math.max(...users.map((item) => Number(item.total || 0)), 1); 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 identity = el('div', 'operations-dashboard-user-identity'); + identity.append( + el('span', 'operations-dashboard-avatar', String(item.actor.username || '人').slice(0, 1).toUpperCase()), + el('span', 'operations-dashboard-user-name') + ); + identity.lastElementChild.append( + el('strong', '', item.actor.username), + el('small', '', accountRoleLabel(item.actor.role)) + ); + const completionRate = operationsDashboardCompletionRate(item.completed, item.total); 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}`) + el('span', '', `任务 ${item.total}`), + el('span', '', `完成率 ${completionRate}%`), + el('span', item.attention || item.failed ? 'needs-attention' : '', `待关注 ${Number(item.attention || 0) + Number(item.failed || 0)}`) ); - button.append(heading, metrics, el('small', 'muted', `最近活动:${formatDateTime(item.last_activity_at)}`)); + const contribution = el('span', 'operations-dashboard-user-contribution'); + const contributionFill = el('i', ''); + contributionFill.style.width = `${Math.max(5, (Number(item.total || 0) / maxTotal) * 100)}%`; + contribution.append(contributionFill); + button.append(identity, metrics, contribution, el('small', 'operations-dashboard-user-last', `最近工作 ${formatDateTime(item.last_activity_at)}`)); container.append(button); } } @@ -1346,21 +1462,39 @@ function renderOperationsDashboardTasks() { container.replaceChildren(); const tasks = operationsDashboardData?.tasks || []; const total = Number(operationsDashboardData?.total || 0); - $('#operationsDashboardTaskCount').textContent = `共 ${total} 条`; + $('#operationsDashboardTaskCount').textContent = `${total} 项工作`; if (!tasks.length) { - container.append(el('p', 'muted channel-empty', '当前筛选范围内没有指令操作记录。')); + container.append(el('p', 'operations-dashboard-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); + const identity = el('span', 'operations-dashboard-task-identity'); + const person = el('span', 'operations-dashboard-task-person'); + person.append( + el('span', 'operations-dashboard-avatar', String(task.creator.username || '人').slice(0, 1).toUpperCase()), + el('strong', '', task.creator.username) + ); + identity.append( + person, + el('time', '', formatDateTime(task.created_at)), + el('span', 'operations-dashboard-task-type', operationsDashboardBusinessLabel(task.business_route_label)), + el('span', `state ${operationsDashboardStatusAppearance(task.outcome_kind)}`, operationsDashboardOutcomeLabel(task.outcome_kind, task.outcome_label)) + ); + const flow = el('span', 'operations-dashboard-task-flow'); + const instruction = el('span', 'operations-dashboard-flow-block is-input'); + instruction.append( + el('small', '', `原始输入${Number(task.input_turn_count || 0) > 1 ? ` · ${task.input_turn_count} 次沟通` : ''}`), + el('span', '', operationsDashboardReadableInput(task.instruction_preview)) + ); + const result = el('span', 'operations-dashboard-flow-block is-output'); + result.append( + el('small', '', '最终输出'), + el('span', '', operationsDashboardReadableResult(task.result_summary, task.outcome_kind)) + ); + flow.append(instruction, el('span', 'operations-dashboard-flow-arrow', '→'), result); + button.append(identity, flow); container.append(button); } const start = total ? operationsDashboardOffset + 1 : 0; @@ -1396,42 +1530,41 @@ function renderOperationsDashboardDetail() { return; } panel.hidden = false; - $('#operationsDashboardDetailTitle').textContent = `指令业务详情 · ${detail.creator?.username || '未知账号'}`; + $('#operationsDashboardDetailTitle').textContent = `${detail.creator?.username || '人员'} · ${operationsDashboardBusinessLabel(detail.business_route_label)}`; 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); + appendField(facts, '人员', detail.creator?.username || '未记录'); + appendField(facts, '任务类型', operationsDashboardBusinessLabel(detail.business_route_label)); + appendField(facts, '开始时间', formatDateTime(detail.created_at)); + appendField(facts, '完成情况', operationsDashboardOutcomeLabel(detail.outcome_kind, detail.outcome_label)); + 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 || '(内容为空)')); + const label = Number(message.turn_no) <= 1 ? '首次输入' : `第 ${message.turn_no} 次补充`; + item.append(el('strong', '', `${label} · ${message.actor?.username || detail.creator?.username || '人员'}`)); + item.append(el('span', '', formatDateTime(message.created_at))); + item.append(el('p', '', operationsDashboardReadableInput(message.content))); inputs.append(item); } - if (!inputs.childElementCount) inputs.append(el('p', 'muted', '没有可显示的指令内容。')); - appendOperationsDashboardDetailSection(container, '指令内容', inputs); + if (!inputs.childElementCount) inputs.append(el('p', 'operations-dashboard-empty', '没有可显示的输入内容。')); + 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} 行数据`}`)); + item.append(el('span', '', `${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, '随任务提交的资料', attachments); } appendOperationsDashboardDetailSection( container, - '完成结果', - el('pre', 'operations-dashboard-result-detail', detail.result_summary || '暂无业务结果。') + '最终输出', + el('p', 'operations-dashboard-result-detail', operationsDashboardReadableResult(detail.result_summary, detail.outcome_kind)) ); } @@ -1440,7 +1573,9 @@ async function syncOperationsDashboard() { operationsDashboardBusy = true; renderOperationsDashboardTasks(); const message = $('#operationsDashboardMessage'); - if (message) message.textContent = '正在读取操作数据…'; + const refreshButton = $('#operationsDashboardRefresh'); + if (refreshButton) refreshButton.disabled = true; + if (message) message.textContent = '正在汇总平台运行数据…'; try { const range = operationsDashboardRange(); const params = new URLSearchParams({ @@ -1461,9 +1596,12 @@ async function syncOperationsDashboard() { renderOperationsDashboardActorOptions(result.actors || []); renderOperationsDashboardBusinessOptions(result.business_options || []); renderOperationsDashboard(); - if (message) message.textContent = `查询完成:${Number(result.total || 0)} 条人工指令记录。可继续按状态、人员、日期或业务穿透,点击记录查看“谁、指令、结果”。`; + const freshness = $('#operationsDashboardFreshness'); + if (freshness) freshness.textContent = `更新于 ${new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(new Date())}`; + if (message) message.textContent = ''; } finally { operationsDashboardBusy = false; + if (refreshButton) refreshButton.disabled = false; renderOperationsDashboardTasks(); } } @@ -1476,13 +1614,12 @@ async function loadOperationsDashboardTask(taskId) { const panel = $('#operationsDashboardDetail'); const container = $('#operationsDashboardDetailBody'); panel.hidden = false; - container.replaceChildren(el('p', 'muted', '正在读取指令与业务结果…')); - $('#operationsDashboardDetailTitle').textContent = '指令业务详情'; + container.replaceChildren(el('p', 'operations-dashboard-empty', '正在打开工作详情…')); + $('#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() { @@ -1496,7 +1633,7 @@ function refreshOperationsDashboardAfterDrilldown() { operationsDashboardOffset = 0; closeOperationsDashboardDetail(); void syncOperationsDashboard().catch((error) => { - $('#operationsDashboardMessage').textContent = error.message || String(error); + $('#operationsDashboardMessage').textContent = operationsDashboardSafeError(error); }); } @@ -3723,7 +3860,7 @@ function configurePageMode() { } 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_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'; @@ -5048,7 +5185,7 @@ async function initializeSession() { if (IS_OPERATIONS_DASHBOARD_PAGE && canViewOperationsDashboard()) { await syncOperationsDashboard().catch((error) => { const message = $('#operationsDashboardMessage'); - if (message) message.textContent = `看板读取失败:${error.message || String(error)}`; + if (message) message.textContent = operationsDashboardSafeError(error); }); } if (IS_TASK_PAGE) { @@ -5361,14 +5498,29 @@ document.addEventListener('DOMContentLoaded', async () => { operationsDashboardOffset = 0; closeOperationsDashboardDetail(); void syncOperationsDashboard().catch((error) => { - $('#operationsDashboardMessage').textContent = error.message || String(error); + $('#operationsDashboardMessage').textContent = operationsDashboardSafeError(error); }); }); - $('#operationsDashboardReset')?.addEventListener('click', () => { - resetOperationsDashboardFilters(); + $('#operationsDashboardRangePresets')?.addEventListener('click', (event) => { + const button = event.target.closest('[data-operations-range-days]'); + if (!button) return; + setOperationsDashboardRange(Number(button.dataset.operationsRangeDays)); + operationsDashboardOffset = 0; closeOperationsDashboardDetail(); void syncOperationsDashboard().catch((error) => { - $('#operationsDashboardMessage').textContent = error.message || String(error); + $('#operationsDashboardMessage').textContent = operationsDashboardSafeError(error); + }); + }); + for (const input of [$('#operationsDashboardFrom'), $('#operationsDashboardTo')]) { + input?.addEventListener('change', () => { + for (const button of document.querySelectorAll('[data-operations-range-days]')) { + button.classList.remove('is-active'); + } + }); + } + $('#operationsDashboardRefresh')?.addEventListener('click', () => { + void syncOperationsDashboard().catch((error) => { + $('#operationsDashboardMessage').textContent = operationsDashboardSafeError(error); }); }); $('#operationsDashboardUsers')?.addEventListener('click', (event) => { @@ -5388,6 +5540,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (!button?.dataset.operationsDay) return; $('#operationsDashboardFrom').value = button.dataset.operationsDay; $('#operationsDashboardTo').value = button.dataset.operationsDay; + for (const preset of document.querySelectorAll('[data-operations-range-days]')) preset.classList.remove('is-active'); refreshOperationsDashboardAfterDrilldown(); }); $('#operationsDashboardBusinesses')?.addEventListener('click', (event) => { @@ -5400,7 +5553,7 @@ document.addEventListener('DOMContentLoaded', async () => { 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)}`; + $('#operationsDashboardMessage').textContent = operationsDashboardSafeError(error, '工作详情暂时无法打开,请稍后再试。'); closeOperationsDashboardDetail(); }); }); @@ -5409,7 +5562,7 @@ document.addEventListener('DOMContentLoaded', async () => { operationsDashboardOffset = Math.max(0, operationsDashboardOffset - OPERATIONS_DASHBOARD_PAGE_SIZE); closeOperationsDashboardDetail(); void syncOperationsDashboard().catch((error) => { - $('#operationsDashboardMessage').textContent = error.message || String(error); + $('#operationsDashboardMessage').textContent = operationsDashboardSafeError(error); }); }); $('#operationsDashboardNext')?.addEventListener('click', () => { @@ -5417,7 +5570,7 @@ document.addEventListener('DOMContentLoaded', async () => { operationsDashboardOffset += OPERATIONS_DASHBOARD_PAGE_SIZE; closeOperationsDashboardDetail(); void syncOperationsDashboard().catch((error) => { - $('#operationsDashboardMessage').textContent = error.message || String(error); + $('#operationsDashboardMessage').textContent = operationsDashboardSafeError(error); }); }); $('#operationsDashboardDetailClose')?.addEventListener('click', closeOperationsDashboardDetail); diff --git a/LianSyn-platform/index.html b/LianSyn-platform/index.html index 70f275e..8ebbd34 100644 --- a/LianSyn-platform/index.html +++ b/LianSyn-platform/index.html @@ -16,7 +16,7 @@