feat: redesign leadership operations dashboard

This commit is contained in:
inman
2026-09-01 19:52:56 +08:00
parent 08725b2de9
commit 823d1cb630
7 changed files with 1320 additions and 366 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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);

View File

@@ -16,7 +16,7 @@
<nav class="top-nav" aria-label="主菜单">
<a id="workbenchNav" class="top-nav-item is-active" aria-current="page" href="/">AI操作台</a>
<a id="historyNav" class="top-nav-item" href="/history">历史任务</a>
<a id="operationsDashboardNav" class="top-nav-item" href="/operations-dashboard" hidden>操作数据看板</a>
<a id="operationsDashboardNav" class="top-nav-item" href="/operations-dashboard" hidden>平台运行看板</a>
<a id="channelsNav" class="top-nav-item" href="/channels" hidden>AgentBus 渠道</a>
<a id="parserRoutingNav" class="top-nav-item" href="/parser-routing" hidden>解析策略</a>
<a id="accountsNav" class="top-nav-item" href="/accounts" hidden>账号管理</a>
@@ -128,84 +128,110 @@
</section>
<section id="operationsDashboardPage" class="operations-dashboard-page" hidden>
<section class="panel operations-dashboard-panel">
<div class="panel-head operations-dashboard-head">
<div>
<p class="eyebrow">OPERATIONS OVERVIEW</p>
<h2>操作数据看板</h2>
<p class="muted">组长与管理员可按人员、日期、业务和结果逐层查看:谁提交了什么指令,最终成了什么。</p>
<section class="operations-dashboard-panel">
<header class="operations-dashboard-hero">
<div class="operations-dashboard-hero-copy">
<p class="operations-dashboard-kicker">团队运行总览</p>
<h1>平台运行全景</h1>
<p>从整体表现进入每一项工作:看任务规模、人员贡献、业务分布,以及每次原始输入最终成了什么结果</p>
</div>
</div>
<div class="operations-dashboard-hero-side">
<span id="operationsDashboardFreshness">数据正在更新</span>
<button id="operationsDashboardRefresh" type="button" class="operations-dashboard-refresh">刷新数据</button>
</div>
</header>
<form id="operationsDashboardFilters" class="operations-dashboard-filters">
<div id="operationsDashboardRangePresets" class="operations-dashboard-range-presets" aria-label="快捷时间范围">
<button type="button" data-operations-range-days="7">近 7 天</button>
<button id="operationsDashboardReset" type="button" data-operations-range-days="30" class="is-active">近 30 天</button>
<button type="button" data-operations-range-days="90">近 90 天</button>
</div>
<label>开始日期<input id="operationsDashboardFrom" type="date" required></label>
<label>结束日期<input id="operationsDashboardTo" type="date" required></label>
<label>账号<select id="operationsDashboardActor"><option value="">全部账号</option></select></label>
<label>务类型<select id="operationsDashboardBusiness"><option value="">全部业务</option></select></label>
<label>状态<select id="operationsDashboardStatus">
<option value="all">全部状态</option>
<label>人员<select id="operationsDashboardActor"><option value="">全部人员</option></select></label>
<label>务类型<select id="operationsDashboardBusiness"><option value="">全部类型</option></select></label>
<label>完成情况<select id="operationsDashboardStatus">
<option value="all">全部进展</option>
<option value="active">进行中</option>
<option value="completed">已完成</option>
<option value="attention">处理</option>
<option value="failed">异常</option>
<option value="attention">跟进</option>
<option value="failed">未完成</option>
<option value="cancelled">已取消</option>
<option value="archived">已归档</option>
</select></label>
<label class="operations-dashboard-search">业务检索<input id="operationsDashboardSearch" maxlength="200" placeholder="姓名、指令内容、完成结果、团号或订单号"></label>
<div class="history-filter-actions">
<button type="submit">查询</button>
<button id="operationsDashboardReset" type="button" class="secondary-button">最近 30 天</button>
</div>
<label class="operations-dashboard-search">查找任务<input id="operationsDashboardSearch" maxlength="200" placeholder="输入人员、原始指令或最终结果"></label>
<button type="submit" class="operations-dashboard-submit">应用筛选</button>
</form>
<div id="operationsDashboardActiveFilters" class="operations-dashboard-active-filters muted" aria-live="polite"></div>
<div id="operationsDashboardActiveFilters" class="operations-dashboard-active-filters" aria-live="polite"></div>
<div id="operationsDashboardSummary" class="operations-dashboard-summary" aria-live="polite"></div>
<div class="operations-dashboard-breakdowns">
<section aria-labelledby="operationsDashboardDaysTitle">
<div class="operations-dashboard-insight-grid">
<section class="operations-dashboard-insight operations-dashboard-trend" aria-labelledby="operationsDashboardDaysTitle">
<div class="operations-dashboard-section-head">
<h3 id="operationsDashboardDaysTitle">按日期穿透</h3>
<span class="muted">选择一天继续查看</span>
</div>
<div id="operationsDashboardDays" class="operations-dashboard-breakdown-list"></div>
</section>
<section aria-labelledby="operationsDashboardBusinessesTitle">
<div class="operations-dashboard-section-head">
<h3 id="operationsDashboardBusinessesTitle">按业务穿透</h3>
<span class="muted">选择业务继续查看</span>
</div>
<div id="operationsDashboardBusinesses" class="operations-dashboard-breakdown-list"></div>
</section>
</div>
<div class="operations-dashboard-grid">
<section class="operations-dashboard-column" aria-labelledby="operationsDashboardUsersTitle">
<div class="operations-dashboard-section-head">
<h3 id="operationsDashboardUsersTitle">用户工作概览</h3>
<span id="operationsDashboardUserCount" class="muted"></span>
</div>
<div id="operationsDashboardUsers" class="operations-dashboard-users"></div>
</section>
<section class="operations-dashboard-column operations-dashboard-task-column" aria-labelledby="operationsDashboardTasksTitle">
<div class="operations-dashboard-section-head">
<h3 id="operationsDashboardTasksTitle">指令操作历史</h3>
<span id="operationsDashboardTaskCount" class="muted"></span>
</div>
<div id="operationsDashboardTasks" class="operations-dashboard-tasks"></div>
<div class="history-pagination operations-dashboard-pagination">
<span id="operationsDashboardPageInfo" class="muted"></span>
<div class="history-pagination-actions">
<button id="operationsDashboardPrevious" type="button" class="secondary-button">上一页</button>
<button id="operationsDashboardNext" type="button" class="secondary-button">下一页</button>
<div>
<p>时间趋势</p>
<h2 id="operationsDashboardDaysTitle">每天完成了多少工作</h2>
</div>
<span id="operationsDashboardTodayCount">今天 0 项</span>
</div>
<div id="operationsDashboardDays" class="operations-dashboard-day-chart" aria-label="任务时间趋势"></div>
</section>
<section class="operations-dashboard-insight operations-dashboard-business" aria-labelledby="operationsDashboardBusinessesTitle">
<div class="operations-dashboard-section-head">
<div>
<p>任务类型</p>
<h2 id="operationsDashboardBusinessesTitle">团队主要在做什么</h2>
</div>
<span>点击类型查看明细</span>
</div>
<div id="operationsDashboardBusinesses" class="operations-dashboard-business-list"></div>
</section>
</div>
<p id="operationsDashboardMessage" class="channel-message" role="status"></p>
</section>
<aside id="operationsDashboardDetail" class="panel operations-dashboard-detail" hidden aria-labelledby="operationsDashboardDetailTitle">
<div class="panel-head">
<div>
<p class="eyebrow">BUSINESS TRACE</p>
<h2 id="operationsDashboardDetailTitle">指令业务详情</h2>
<section class="operations-dashboard-people" aria-labelledby="operationsDashboardUsersTitle">
<div class="operations-dashboard-section-head">
<div>
<p>人员表现</p>
<h2 id="operationsDashboardUsersTitle">谁在推进哪些工作</h2>
</div>
<span id="operationsDashboardUserCount"></span>
</div>
<button id="operationsDashboardDetailClose" type="button" class="secondary-button">关闭详情</button>
<div id="operationsDashboardUsers" class="operations-dashboard-users"></div>
</section>
<section class="operations-dashboard-workstream" aria-labelledby="operationsDashboardTasksTitle">
<div class="operations-dashboard-section-head operations-dashboard-workstream-head">
<div>
<p>工作明细</p>
<h2 id="operationsDashboardTasksTitle">从原始输入到最终输出</h2>
</div>
<span id="operationsDashboardTaskCount"></span>
</div>
<div class="operations-dashboard-flow-labels" aria-hidden="true">
<span>人员与时间</span>
<span>原始输入</span>
<span>最终输出</span>
</div>
<div id="operationsDashboardTasks" class="operations-dashboard-tasks"></div>
<div class="history-pagination operations-dashboard-pagination">
<span id="operationsDashboardPageInfo"></span>
<div class="history-pagination-actions">
<button id="operationsDashboardPrevious" type="button" class="secondary-button">上一页</button>
<button id="operationsDashboardNext" type="button" class="secondary-button">下一页</button>
</div>
</div>
</section>
<p id="operationsDashboardMessage" class="operations-dashboard-message" role="status"></p>
</section>
<aside id="operationsDashboardDetail" class="operations-dashboard-detail" hidden aria-labelledby="operationsDashboardDetailTitle">
<div class="operations-dashboard-detail-head">
<div>
<p>任务详情</p>
<h2 id="operationsDashboardDetailTitle">一次工作的完整过程</h2>
</div>
<button id="operationsDashboardDetailClose" type="button" class="secondary-button">关闭</button>
</div>
<div id="operationsDashboardDetailBody" class="operations-dashboard-detail-body"></div>
</aside>

File diff suppressed because it is too large Load Diff

View File

@@ -1836,17 +1836,50 @@ function operationsDashboardOutcomeKind(statusValue: unknown): OperationsDashboa
function operationsDashboardOutcomeLabel(kind: OperationsDashboardOutcomeKind): string {
if (kind === 'completed') return '已完成';
if (kind === 'attention') return '待处理';
if (kind === 'failed') return '异常';
if (kind === 'attention') return '待跟进';
if (kind === 'failed') return '未完成';
if (kind === 'cancelled') return '已取消';
return '处理中';
return '进行中';
}
function operationsDashboardBusiness(routeId: unknown): { id: string | null; label: string } {
const route = businessRouteById(routeId);
return route
? { id: route.routeId, label: route.directive }
: { id: null, label: '未识别业务' };
: { id: null, label: '其他任务' };
}
function operationsDashboardLeadershipResultText(
kind: OperationsDashboardOutcomeKind,
value: unknown
): string {
const result = text(value).trim();
if (!result) {
if (kind === 'completed') return '工作已经完成。';
if (kind === 'attention') return '正在等待补充信息或确认。';
if (kind === 'failed') return '本次工作未完成,需要检查后重新处理。';
if (kind === 'cancelled') return '这项工作已取消。';
return '正在处理中。';
}
const containsTechnicalLanguage = /(?:\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.test(result);
if (!containsTechnicalLanguage) return result;
if (/不确定|核验|请勿重复/.test(result)) return '当前结果需要人工确认,请勿重复提交。';
if (/未找到|不存在|不匹配|mismatch/i.test(result)) return '未完成:没有找到符合指令条件的业务记录。';
if (/登录|连接|网络|超时/i.test(result)) return '未完成:业务系统暂时无法完成处理,请稍后再试。';
if (/补充|缺少|必填|日期|年份/.test(result)) return '待跟进:需要补充完整的业务信息。';
if (/不支持|超出.*范围/.test(result)) return '未完成:这项要求暂不在当前业务处理范围内。';
if (kind === 'completed') return '工作已经完成,结果已记录。';
if (kind === 'attention') return '正在等待补充信息或确认。';
if (kind === 'cancelled') return '这项工作已取消。';
return '未完成:当前业务条件未满足,需要检查后重新处理。';
}
function operationsDashboardLeadershipInstructionText(value: unknown): string {
const instruction = text(value).trim();
if (!instruction) return '未记录输入内容';
const looksLikeTechnicalPayload = /^[\[{]/.test(instruction)
|| /(?:"(?:action|operation|payload|schema|submit_mode)"\s*:|[a-z][a-z0-9]*(?:_[a-z0-9]+){2,}|data\.[a-z])/i.test(instruction);
return looksLikeTechnicalPayload ? '该历史任务没有可展示的业务指令。' : instruction;
}
function operationsDashboardSuccessText(receipt: PublicTaskSuccessReceipt): string {
@@ -1876,11 +1909,11 @@ function operationsDashboardOutcome(row: Record<string, unknown>): {
resultText = operationsDashboardSuccessText(receipt);
recordedAt = receipt.recorded_at;
} else if (status === 'dry_run') {
resultText = '业务方案已完成,未写入 ERP。';
resultText = '业务方案已完成,尚未正式提交。';
}
} else if (kind === 'failed') {
const error = publicErrorSummary(row, null);
resultText = error?.message ? `本次指令未完成:${error.message}` : '本次指令未完成。';
resultText = error?.message ? `本次工作未完成:${error.message}` : '本次工作未完成。';
if (error) recordedAt = error.recorded_at;
} else if (kind === 'attention') {
if (status === 'awaiting_user_input') {
@@ -1890,19 +1923,20 @@ function operationsDashboardOutcome(row: Record<string, unknown>): {
} else if (status === 'awaiting_confirmation') {
resultText = '业务方案已生成,等待提交确认。';
} else {
resultText = '业务结果待核验,请勿重复提交。';
resultText = '当前结果需要人工确认,请勿重复提交。';
}
} else if (kind === 'cancelled') {
resultText = '指令已取消,未形成业务结果。';
resultText = '这项工作已取消。';
} else {
resultText = '正在处理,尚未形成业务结果。';
}
if (!resultText) {
if (kind === 'completed') resultText = '业务处理已完成,未记录可展示的结果摘要。';
if (kind === 'completed') resultText = '工作已经完成。';
else if (kind === 'attention') resultText = '等待业务人员补充或确认。';
else if (kind === 'cancelled') resultText = '任务已取消。';
else if (kind === 'cancelled') resultText = '这项工作已取消。';
else resultText = '正在处理,尚未形成业务结果。';
}
resultText = operationsDashboardLeadershipResultText(kind, resultText);
return {
kind,
label: operationsDashboardOutcomeLabel(kind),
@@ -1921,10 +1955,10 @@ function operationsDashboardTaskProjection(
config: AppConfig,
row: Record<string, unknown>
): OperationsDashboardTaskProjection {
const instruction = decryptText(
const instruction = operationsDashboardLeadershipInstructionText(decryptText(
config,
row.original_text_ciphertext as string | null | undefined
).trim();
));
const outcome = operationsDashboardOutcome(row);
const business = operationsDashboardBusiness(row.business_route_id);
const instructionPreview = instruction.replace(/\s+/g, ' ').trim();
@@ -4592,7 +4626,7 @@ export class TaskService {
const pool = getPool(this.config);
const businessOptions = [
...BUSINESS_ROUTES.map((route) => ({ key: route.routeId, label: route.directive })),
{ key: 'unclassified', label: '未识别业务' }
{ key: 'unclassified', label: '其他任务' }
];
const actorQuery = () => pool.query(
`SELECT id, username, role, is_active
@@ -4655,7 +4689,9 @@ export class TaskService {
const values = searchValuesByTask.get(key) || [];
values.push(
text(row.actor_username),
decryptText(this.config, row.content_ciphertext as string | null | undefined)
operationsDashboardLeadershipInstructionText(
decryptText(this.config, row.content_ciphertext as string | null | undefined)
)
);
searchValuesByTask.set(key, values);
}
@@ -4836,7 +4872,7 @@ export class TaskService {
));
const businesses = (businessResult.rows as Record<string, unknown>[]).map((row) => {
const key = text(row.dimension_key);
return breakdown(row, key === 'unclassified' ? '未识别业务' : operationsDashboardBusiness(key).label);
return breakdown(row, key === 'unclassified' ? '其他任务' : operationsDashboardBusiness(key).label);
});
const tasks = (listResult.rows as Record<string, unknown>[]).map((row) => (
operationsDashboardTaskProjection(this.config, row).task
@@ -4891,7 +4927,7 @@ export class TaskService {
.filter((message) => message.source !== 'reparse')
.map((message) => ({
turn_no: message.turn_no,
content: message.content,
content: operationsDashboardLeadershipInstructionText(message.content),
actor: message.actor,
created_at: message.created_at
} satisfies PublicOperationsDashboardInstruction));

View File

@@ -134,6 +134,12 @@ test('operations dashboard is leadership-gated, business-facing, searchable, and
assert.match(tasks, /operations_dashboard_search_scope_too_large/);
assert.match(tasks, /content_ciphertext[\s\S]+decryptText\(this\.config, row\.content_ciphertext/);
assert.match(tasks, /operationsDashboardSearchMatches[\s\S]+projection\.instruction[\s\S]+projection\.result/);
assert.match(tasks, /function operationsDashboardLeadershipResultText/);
assert.match(tasks, /function operationsDashboardLeadershipInstructionText/);
assert.match(tasks, /待跟进/);
assert.match(tasks, /未完成/);
assert.match(tasks, /containsTechnicalLanguage/);
assert.match(tasks, /当前结果需要人工确认/);
assert.match(tasks, /timezone\('Asia\/Shanghai', t\.created_at\)/);
assert.match(tasks, /days,[\s\S]+businesses,[\s\S]+business_options/);
assert.match(tasks, /instructionPreview\.length > 360/);
@@ -187,7 +193,7 @@ test('ordinary task access is enforced across reads, mutations, artifacts, event
assert.match(server, /tasks\.eventsSince\(session\.user\.organizationId, since, contextFor\(session, request\)\)/);
});
test('operator UI exposes role-aware accounts, business drill-through, original input history, and reversible archive', async () => {
test('operator UI exposes role-aware accounts, executive drill-through, original input, final output, and reversible archive', async () => {
const [app, index, retention] = await Promise.all([
source('../../LianSyn-platform/app.js'),
source('../../LianSyn-platform/index.html'),
@@ -206,8 +212,14 @@ test('operator UI exposes role-aware accounts, business drill-through, original
assert.match(index, /id="operationsDashboardBusiness"/);
assert.match(index, /id="operationsDashboardDays"/);
assert.match(index, /id="operationsDashboardBusinesses"/);
assert.match(index, /姓名、指令内容、完成结果、团号或订单号/);
assert.match(index, /平台运行全景/);
assert.match(index, /任务规模、人员贡献、业务分布/);
assert.match(index, /输入人员、原始指令或最终结果/);
assert.match(index, /id="operationsDashboardRangePresets"/);
assert.match(index, /id="operationsDashboardRefresh"/);
assert.match(index, /从原始输入到最终输出/);
assert.match(index, /id="operationsDashboardDetail"/);
assert.doesNotMatch(index, /OPERATIONS OVERVIEW|BUSINESS TRACE|指令操作历史/);
assert.match(index, /id="historyArchiveInput"/);
assert.match(app, /authUser\?\.role === 'admin'/);
assert.match(app, /crypto\.randomUUID/);
@@ -223,15 +235,29 @@ test('operator UI exposes role-aware accounts, business drill-through, original
assert.match(app, /data-operations-status|dataset\.operationsStatus/);
assert.match(app, /data-operations-day|dataset\.operationsDay/);
assert.match(app, /data-operations-business|dataset\.operationsBusiness/);
assert.match(app, /`指令:\$\{task\.instruction_preview/);
assert.match(app, /`结果:\$\{task\.result_summary/);
assert.match(app, /function operationsDashboardReadableResult/);
assert.match(app, /function operationsDashboardReadableInput/);
assert.match(app, /原始输入/);
assert.match(app, /最终输出/);
const dashboardTaskRenderer = app.slice(
app.indexOf('function renderOperationsDashboardTasks()'),
app.indexOf('function renderOperationsDashboard()')
);
assert.match(dashboardTaskRenderer, /task\.creator\.username/);
assert.match(dashboardTaskRenderer, /task\.created_at/);
assert.match(dashboardTaskRenderer, /task\.business_route_label/);
assert.match(dashboardTaskRenderer, /task\.instruction_preview/);
assert.match(dashboardTaskRenderer, /operationsDashboardReadableInput\(task\.instruction_preview/);
assert.match(dashboardTaskRenderer, /operationsDashboardReadableResult\(task\.result_summary/);
assert.doesNotMatch(dashboardTaskRenderer, /记录号|task\.task_id\}`/);
const dashboardDetailRenderer = app.slice(
app.indexOf('function renderOperationsDashboardDetail()'),
app.indexOf('async function syncOperationsDashboard()')
);
assert.match(dashboardDetailRenderer, /操作人/);
assert.match(dashboardDetailRenderer, /指令内容/);
assert.match(dashboardDetailRenderer, /完成结果/);
assert.match(dashboardDetailRenderer, /人/);
assert.match(dashboardDetailRenderer, /原始输入/);
assert.match(dashboardDetailRenderer, /最终输出/);
assert.match(dashboardDetailRenderer, /operationsDashboardReadableResult/);
assert.doesNotMatch(dashboardDetailRenderer, /任务生命周期|处理结果与技术上下文|renderTaskLifecycle|JSON\.stringify|task\.stage|parse_response|operation:/);
assert.match(app, /\/api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/archive/);
assert.match(app, /\/api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/restore/);