feat(client): vendor digital employee source
3
qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数字员工</title>
|
||||
<script>
|
||||
window.__QIMINGCLAW_EMBEDDED_DIGITAL__ = true;
|
||||
try {
|
||||
window.localStorage.setItem("zeroclaw_token", "qimingclaw_embedded_digital_employee");
|
||||
window.sessionStorage.setItem("sgrobot_logged_in", "1");
|
||||
} catch (error) {
|
||||
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2579
qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/package-lock.json
generated
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@qiming-ai/sgrobot-digital-embedded",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -b && vite build",
|
||||
"dev": "vite"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.0.7",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# qimingclaw embedded patches
|
||||
|
||||
This directory records qimingclaw-only adaptations around the sgRobot digital employee UI.
|
||||
|
||||
- `src/main.tsx` mounts the digital employee page directly at `#/digital`.
|
||||
- `src/lib/basePath.ts` fixes static assets under `/sgrobot-digital`.
|
||||
- `src/lib/tauri.ts` disables sgRobot/Tauri gateway detection inside the Electron webview.
|
||||
- `index.html` injects a local token so copied sgRobot pages do not show the pairing screen.
|
||||
|
||||
Run `npm run sync:sgrobot-digital` from `crates/agent-electron-client` after changing the upstream sgRobot digital UI.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Digital Employee Static Assets
|
||||
|
||||
This directory is owned by the React digital employee frontend.
|
||||
|
||||
- `avatars/` keeps the original digital employee character images used by the home page `Avatar3D` stage.
|
||||
- Do not place the legacy Vue standalone application bundle here.
|
||||
- If an avatar file is renamed, update `src/pages/digital/CommandDeck.tsx` in the same change.
|
||||
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 483 KiB |
|
After Width: | Height: | Size: 507 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 552 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
@@ -0,0 +1,29 @@
|
||||
import Gem from './Gem';
|
||||
import Stage from './Stage';
|
||||
|
||||
interface Avatar3DProps {
|
||||
avatarSrc: string;
|
||||
altText?: string;
|
||||
statusLabel?: string;
|
||||
}
|
||||
|
||||
export default function Avatar3D({ avatarSrc, altText = '数字员工', statusLabel }: Avatar3DProps) {
|
||||
return (
|
||||
<div className="de-avatar-container" aria-hidden="true">
|
||||
<div className="de-avatar-glass-space">
|
||||
<div className="de-avatar-roof">
|
||||
<div className="de-avatar-roof-face" />
|
||||
<div className="de-avatar-roof-glow" />
|
||||
</div>
|
||||
<div className="de-avatar-glass-left" />
|
||||
<div className="de-avatar-glass-right" />
|
||||
<Gem className="de-gem-1" />
|
||||
<Gem className="de-gem-2" />
|
||||
<Gem className="de-gem-3" />
|
||||
</div>
|
||||
{statusLabel && <div className="de-avatar-state-label">{statusLabel}</div>}
|
||||
<img src={avatarSrc} alt={altText} className="de-avatar-img" />
|
||||
<Stage />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface CardTitleBarProps {
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function CardTitleBar({ title, children }: CardTitleBarProps) {
|
||||
return (
|
||||
<div className="de-card-title-bar">
|
||||
<span className="de-card-title-bar-text">{title}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
buildDemoFlowEvents,
|
||||
buildDemoPlanMessages,
|
||||
buildDemoRuns,
|
||||
buildDemoTasks,
|
||||
getDemoMissions,
|
||||
} from '@/pages/digital/demoScenario';
|
||||
import {
|
||||
displayBackendMessage,
|
||||
displayBusinessText,
|
||||
displayEventKind,
|
||||
displayStatus,
|
||||
type Mission,
|
||||
} from '@/lib/consoleDataAdapter';
|
||||
import { getDebugPlanFlow, getDebugTasksForPlan, getPlanMessages, type PlanMessage } from '@/lib/api';
|
||||
import type { DebugRun, DebugTask, FlowResponse } from '@/types/api';
|
||||
import StatusBadge from './StatusBadge';
|
||||
|
||||
interface FlowDetailPanelProps {
|
||||
mission: Mission;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function stepVariant(status: string): 'info' | 'success' | 'warning' | 'danger' {
|
||||
switch (status) {
|
||||
case 'succeeded': case 'completed': case 'Succeeded': return 'success';
|
||||
case 'failed': return 'danger';
|
||||
case 'Failed': return 'danger';
|
||||
case 'running': case 'active': case 'Running': return 'info';
|
||||
default: return 'warning';
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(ts?: string | null): string {
|
||||
if (!ts) return '未记录';
|
||||
return new Date(ts).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function missionPeriodLabel(mission: Mission): string {
|
||||
const text = `${mission.id} ${mission.title} ${mission.objective}`.toLowerCase();
|
||||
if (text.includes('month') || text.includes('monthly') || text.includes('月')) return '月度任务';
|
||||
if (text.includes('week') || text.includes('weekly') || text.includes('周')) return '周任务';
|
||||
if (text.includes('临时') || text.includes('adhoc') || text.includes('hotlist') || text.includes('monitor')) return '临时任务';
|
||||
return '日常任务';
|
||||
}
|
||||
|
||||
function isDone(status: string): boolean {
|
||||
return ['succeeded', 'success', 'finished', 'complete', 'completed'].includes(status.toLowerCase());
|
||||
}
|
||||
|
||||
function isFailed(status: string): boolean {
|
||||
return ['failed', 'error', 'cancelled', 'canceled', 'aborted', 'rejected', 'deadletter'].includes(status.toLowerCase());
|
||||
}
|
||||
|
||||
function progressText(mission: Mission): string {
|
||||
const total = mission.steps.length;
|
||||
if (total === 0) return displayStatus(mission.status);
|
||||
const done = mission.steps.filter((step) => isDone(step.status)).length;
|
||||
const failed = mission.steps.filter((step) => isFailed(step.status)).length;
|
||||
const running = mission.steps.filter((step) => ['running', 'active', 'queued', 'leased'].includes(step.status.toLowerCase())).length;
|
||||
const parts = [`已完成 ${done}/${total}`];
|
||||
if (failed > 0) parts.push(`失败 ${failed}`);
|
||||
if (running > 0 && failed === 0) parts.push(`处理中 ${running}`);
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
function businessTimelineMessage(message: string): string {
|
||||
return message
|
||||
.replace(/^启动计划:/, '接收委托:')
|
||||
.replace(/^准备步骤:/, '准备工作:')
|
||||
.replace(/^创建任务:/, '安排工作:')
|
||||
.replace(/^任务失败:/, '工作失败:')
|
||||
.replace(/^开始处理:/, '开始处理:')
|
||||
.replace(/^完成任务:/, '完成工作:')
|
||||
.replace(/^调用技能:/, '数字员工处理:')
|
||||
.replace(/^执行开始:/, '开始执行:')
|
||||
.replace(/^执行完成:/, '执行完成:')
|
||||
.replace(/^执行失败:/, '执行失败:')
|
||||
.replace(/,将使用[^,。]+/, '')
|
||||
.replace(/,分配给[^,。]+/, '');
|
||||
}
|
||||
|
||||
function isFailureMessage(message: string): boolean {
|
||||
return /失败|failed|error|timeout|unavailable/i.test(message);
|
||||
}
|
||||
|
||||
function businessKindLabel(kind: string, message = ''): string {
|
||||
if (isFailureMessage(message)) return '失败';
|
||||
if (kind === 'artifact') return '产物';
|
||||
if (kind === 'task_summary') return '结果';
|
||||
if (kind === 'task_running' || kind === 'run') return '处理中';
|
||||
if (kind === 'task_done' || kind === 'run_done') return '完成';
|
||||
return '过程';
|
||||
}
|
||||
|
||||
function messageRoleLabel(role: string): string {
|
||||
if (role === 'user') return '用户';
|
||||
if (role === 'assistant') return '数字员工';
|
||||
if (role === 'system') return '系统';
|
||||
return '消息';
|
||||
}
|
||||
|
||||
type DetailEvent = {
|
||||
event_id: string;
|
||||
kind: string;
|
||||
occurred_at: string | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
function skillLabel(skillId: string | null | undefined): string {
|
||||
return displayBusinessText(skillId || '', skillId || '自动化技能');
|
||||
}
|
||||
|
||||
function toolBusinessMessage(toolName: string, success: string, elapsedMs: string): string {
|
||||
const result = success === 'true' ? '成功' : '失败';
|
||||
if (toolName.includes('zhihu-hotlist') && toolName.includes('extract_hotlist')) {
|
||||
return `调用技能:知乎热榜采集,结果:${result},耗时 ${elapsedMs}ms`;
|
||||
}
|
||||
if (toolName.includes('office-export-xlsx') && toolName.includes('export_xlsx')) {
|
||||
return `调用技能:Excel 导出,结果:${result},耗时 ${elapsedMs}ms`;
|
||||
}
|
||||
return `调用技能:${toolName},结果:${result},耗时 ${elapsedMs}ms`;
|
||||
}
|
||||
|
||||
function valueText(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function eventTime(event: DetailEvent): string {
|
||||
return event.occurred_at || '';
|
||||
}
|
||||
|
||||
function earliestTime(values: Array<string | null | undefined>): string | null {
|
||||
return values
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.sort((a, b) => a.localeCompare(b))[0] || null;
|
||||
}
|
||||
|
||||
function latestByResultSubject(events: DetailEvent[]): DetailEvent[] {
|
||||
const bySubject = new Map<string, DetailEvent>();
|
||||
for (const event of events) {
|
||||
const subject = event.message.split(',结果:')[0]?.split(',状态:')[0] || event.message;
|
||||
const current = bySubject.get(subject);
|
||||
if (!current || eventTime(event).localeCompare(eventTime(current)) > 0) {
|
||||
bySubject.set(subject, event);
|
||||
}
|
||||
}
|
||||
return Array.from(bySubject.values());
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function normalizeOutputPath(value: string): string {
|
||||
return value.replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
function buildOperationalTimeline(
|
||||
mission: Mission,
|
||||
tasks: DebugTask[],
|
||||
runs: DebugRun[],
|
||||
messages: PlanMessage[],
|
||||
flowEvents?: FlowResponse['events'],
|
||||
): DetailEvent[] {
|
||||
const events: DetailEvent[] = [];
|
||||
const add = (kind: string, occurredAt: string | null | undefined, message: string, id: string) => {
|
||||
if (!message.trim()) return;
|
||||
events.push({ event_id: id, kind, occurred_at: occurredAt ?? null, message });
|
||||
};
|
||||
const addOutputFacts = (rawOutput: string, occurredAt: string | null | undefined, idPrefix: string, sourceName: string) => {
|
||||
let parsedOutputPath = false;
|
||||
try {
|
||||
const parsed = objectValue(JSON.parse(rawOutput));
|
||||
if (parsed) {
|
||||
const label = typeof parsed.label === 'string' ? parsed.label.trim() : '';
|
||||
const value = parsed.value;
|
||||
if (label && value !== undefined && value !== null) {
|
||||
add('task_summary', occurredAt, `${label}:${String(value)}`, `metric-${idPrefix}-${sourceName}`);
|
||||
}
|
||||
const outputPath = typeof parsed.output_path === 'string' ? parsed.output_path.trim() : '';
|
||||
if (outputPath) {
|
||||
parsedOutputPath = true;
|
||||
add('artifact', occurredAt, `产物生成:${normalizeOutputPath(outputPath)}`, `artifact-${idPrefix}-${sourceName}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Some execution messages are human-readable text. Regex extraction below handles those.
|
||||
}
|
||||
|
||||
const rowCountMatch = rawOutput.match(/"row_count"\s*:\s*(\d+)/);
|
||||
if (rowCountMatch?.[1]) {
|
||||
add('task_summary', occurredAt, `数据处理完成:共 ${rowCountMatch[1]} 条记录`, `rows-${idPrefix}-${sourceName}`);
|
||||
}
|
||||
const pathMatch = rawOutput.match(/"output_path"\s*:\s*"([^"]+\.(?:xlsx|xls|csv|json|html|pdf|png|jpg|jpeg|txt))"/i);
|
||||
const outputPath = pathMatch?.[1];
|
||||
if (outputPath && !parsedOutputPath) {
|
||||
add('artifact', occurredAt, `产物生成:${normalizeOutputPath(outputPath)}`, `artifact-regex-${idPrefix}-${sourceName}`);
|
||||
}
|
||||
};
|
||||
|
||||
add('plan', mission.createdAt || mission.updatedAt, `启动计划:${mission.title},目标:${mission.objective}`, `plan-${mission.id}`);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
for (const step of mission.steps) {
|
||||
const skills = step.preferredSkills.map(skillLabel).join('、') || '自动化技能';
|
||||
add('task', mission.updatedAt || mission.createdAt, `准备步骤:${step.title},将使用 ${skills}`, `step-${step.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const task of tasks) {
|
||||
const skill = skillLabel(task.assigned_skill_id);
|
||||
const taskRuns = runs.filter((run) => run.task_id === task.task_id);
|
||||
const firstRunAt = earliestTime(taskRuns.map((run) => run.created_at));
|
||||
const taskCreatedAt = earliestTime([task.created_at, task.started_at, firstRunAt]);
|
||||
add('task', taskCreatedAt, `创建任务:${task.title || '子任务'},分配给 ${skill}`, `task-create-${task.task_id}`);
|
||||
if (task.started_at) add('task_running', task.started_at, `开始处理:${task.title || '子任务'}`, `task-start-${task.task_id}`);
|
||||
if (task.finished_at) {
|
||||
const taskDoneVerb = isFailed(task.status) ? '任务失败' : '完成任务';
|
||||
const errorDetail = isFailed(task.status) && task.error_message ? `,原因:${task.error_message}` : '';
|
||||
add('task_done', task.finished_at, `${taskDoneVerb}:${task.title || '子任务'},状态:${displayStatus(task.status)}${errorDetail}`, `task-finish-${task.task_id}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const run of runs) {
|
||||
const task = tasks.find((item) => item.task_id === run.task_id);
|
||||
const taskTitle = task?.title || '任务执行';
|
||||
const runSkill = skillLabel(run.skill_id || task?.assigned_skill_id);
|
||||
const hasSessionToolForTask = messages.some((message) => (message.content || '').includes(`[Task ${run.task_id}] tool_call`));
|
||||
if (!hasSessionToolForTask) {
|
||||
add('skill_tool', run.created_at, `调用技能:${runSkill},处理:${taskTitle}`, `run-skill-${run.run_id}`);
|
||||
}
|
||||
add('run', run.created_at, `执行开始:${taskTitle},第 ${run.attempt_no || 1} 次`, `run-start-${run.run_id}`);
|
||||
if (run.completed_at) {
|
||||
const runDoneVerb = isFailed(run.lifecycle) ? '执行失败' : '执行完成';
|
||||
add('run_done', run.completed_at, `${runDoneVerb}:${taskTitle},结果:${displayStatus(run.lifecycle)}`, `run-done-${run.run_id}`);
|
||||
}
|
||||
for (const [index, runEvent] of (run.events || []).entries()) {
|
||||
addOutputFacts(
|
||||
valueText(runEvent.message),
|
||||
runEvent.occurred_at || run.completed_at || run.created_at,
|
||||
`run-${run.run_id}-${runEvent.event_id || index}`,
|
||||
run.skill_id || task?.assigned_skill_id || 'run',
|
||||
);
|
||||
}
|
||||
for (const [index, artifact] of (run.artifacts || []).entries()) {
|
||||
const uri = valueText(artifact.uri);
|
||||
const name = valueText(artifact.name) || uri;
|
||||
if (uri || name) {
|
||||
add('artifact', run.completed_at || run.created_at, `产物生成:${name}${uri && uri !== name ? `(${uri})` : ''}`, `run-artifact-${run.run_id}-${index}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
const content = message.content || '';
|
||||
const toolMatch = content.match(/\[Task ([^\]]+)\] tool_call name=([^\s]+) success=(true|false) elapsed_ms=(\d+) args=(.*?) output=(.*)$/s);
|
||||
if (toolMatch) {
|
||||
const taskId = toolMatch[1] || '';
|
||||
const toolName = toolMatch[2] || '';
|
||||
const success = toolMatch[3] || 'false';
|
||||
const elapsedMs = toolMatch[4] || '0';
|
||||
const output = toolMatch[6] || '';
|
||||
add('skill_tool', message.created_at, toolBusinessMessage(toolName, success, elapsedMs), `tool-${taskId}-${message.created_at}-${toolName}`);
|
||||
addOutputFacts(output, message.created_at, `message-${message.created_at}`, toolName);
|
||||
continue;
|
||||
}
|
||||
const hotlistCount = content.match(/共采集到\s*\**\s*(\d+)\s*条/);
|
||||
if (hotlistCount?.[1]) {
|
||||
add('task_summary', message.created_at, `采集完成:获得 ${hotlistCount[1]} 条知乎热榜数据`, `summary-hotlist-${message.created_at}`);
|
||||
continue;
|
||||
}
|
||||
if (content.includes('[Task ') && content.includes('Excel')) {
|
||||
add('task_summary', message.created_at, '导出完成:Excel 文件已生成', `summary-excel-${message.created_at}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (events.length <= 1) {
|
||||
for (const event of flowEvents || []) {
|
||||
add(event.kind, event.occurred_at, displayBackendMessage(event.message, event.message), event.event_id);
|
||||
}
|
||||
}
|
||||
|
||||
return events
|
||||
.sort((a, b) => (a.occurred_at || '').localeCompare(b.occurred_at || ''))
|
||||
.slice(-40);
|
||||
}
|
||||
|
||||
export default function FlowDetailPanel({ mission, onClose }: FlowDetailPanelProps) {
|
||||
const [tasks, setTasks] = useState<DebugTask[]>([]);
|
||||
const [runs, setRuns] = useState<DebugRun[]>([]);
|
||||
const [flow, setFlow] = useState<FlowResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sessionMessages, setSessionMessages] = useState<PlanMessage[]>([]);
|
||||
const [sessionOpen, setSessionOpen] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [sessionLoading, setSessionLoading] = useState(false);
|
||||
const [sessionError, setSessionError] = useState<string | null>(null);
|
||||
|
||||
const planId = mission.sourceIds.planId || mission.id;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
Promise.allSettled([
|
||||
getDebugTasksForPlan(planId),
|
||||
getDebugPlanFlow(planId),
|
||||
getPlanMessages(planId),
|
||||
]).then(([tasksResult, flowResult, messagesResult]) => {
|
||||
if (cancelled) return;
|
||||
const demoMission = getDemoMissions().find((item) => item.id === planId);
|
||||
const fallbackTasks = demoMission ? buildDemoTasks([demoMission]) : [];
|
||||
const fallbackRuns = fallbackTasks.length > 0 ? buildDemoRuns(fallbackTasks) : [];
|
||||
if (tasksResult.status === 'fulfilled') {
|
||||
setTasks(tasksResult.value.tasks && tasksResult.value.tasks.length > 0 ? tasksResult.value.tasks : fallbackTasks);
|
||||
setRuns(tasksResult.value.runs && tasksResult.value.runs.length > 0 ? tasksResult.value.runs : fallbackRuns);
|
||||
} else {
|
||||
setTasks(fallbackTasks);
|
||||
setRuns(fallbackRuns);
|
||||
}
|
||||
if (flowResult.status === 'fulfilled' && flowResult.value?.events && flowResult.value.events.length > 0) {
|
||||
setFlow(flowResult.value);
|
||||
} else {
|
||||
setFlow(demoMission ? { events: buildDemoFlowEvents(demoMission) } as FlowResponse : null);
|
||||
}
|
||||
if (messagesResult.status === 'fulfilled' && messagesResult.value.messages.length > 0) {
|
||||
setSessionMessages(messagesResult.value.messages);
|
||||
} else if (demoMission) {
|
||||
setSessionMessages(buildDemoPlanMessages(demoMission));
|
||||
} else {
|
||||
setSessionMessages([]);
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
};
|
||||
refresh();
|
||||
const timer = window.setInterval(refresh, 3000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [planId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSessionMessages([]);
|
||||
setSessionOpen(false);
|
||||
setAdvancedOpen(false);
|
||||
setSessionError(null);
|
||||
}, [planId]);
|
||||
|
||||
const loadSessionMessages = async () => {
|
||||
if (sessionLoading) return;
|
||||
setSessionOpen(true);
|
||||
setSessionLoading(true);
|
||||
setSessionError(null);
|
||||
try {
|
||||
const data = await getPlanMessages(planId);
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
setSessionMessages(data.messages);
|
||||
} else {
|
||||
const demoMission = getDemoMissions().find((item) => item.id === planId);
|
||||
setSessionMessages(demoMission ? buildDemoPlanMessages(demoMission) : []);
|
||||
}
|
||||
} catch (err) {
|
||||
const demoMission = getDemoMissions().find((item) => item.id === planId);
|
||||
if (demoMission) {
|
||||
setSessionMessages(buildDemoPlanMessages(demoMission));
|
||||
setSessionError(null);
|
||||
} else {
|
||||
setSessionMessages([]);
|
||||
setSessionError(err instanceof Error ? err.message : '读取会话失败');
|
||||
}
|
||||
} finally {
|
||||
setSessionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runsByTask = useMemo(() => {
|
||||
const grouped = new Map<string, DebugRun[]>();
|
||||
for (const run of runs) {
|
||||
const list = grouped.get(run.task_id) || [];
|
||||
list.push(run);
|
||||
grouped.set(run.task_id, list);
|
||||
}
|
||||
for (const list of grouped.values()) {
|
||||
list.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
|
||||
}
|
||||
return grouped;
|
||||
}, [runs]);
|
||||
|
||||
const eventTimeline = useMemo(
|
||||
() => buildOperationalTimeline(mission, tasks, runs, sessionMessages, flow?.events),
|
||||
[flow?.events, mission, runs, sessionMessages, tasks],
|
||||
);
|
||||
|
||||
const businessTimeline = useMemo(
|
||||
() => eventTimeline
|
||||
.filter((event) => event.kind !== 'skill_tool')
|
||||
.slice(-12),
|
||||
[eventTimeline],
|
||||
);
|
||||
|
||||
const resultEvents = useMemo(
|
||||
() => {
|
||||
const facts = eventTimeline.filter((event) => ['artifact', 'task_summary'].includes(event.kind));
|
||||
const taskResults = eventTimeline.filter((event) => event.kind === 'task_done');
|
||||
const runResults = latestByResultSubject(eventTimeline.filter((event) => event.kind === 'run_done'));
|
||||
const terminal = taskResults.length > 0 ? taskResults : runResults;
|
||||
return [...facts, ...terminal]
|
||||
.sort((a, b) => eventTime(a).localeCompare(eventTime(b)))
|
||||
.slice(-6);
|
||||
},
|
||||
[eventTimeline],
|
||||
);
|
||||
|
||||
const latestEventAt = useMemo(
|
||||
() => eventTimeline.reduce<string | null>((latest, event) => {
|
||||
if (!event.occurred_at) return latest;
|
||||
if (!latest || event.occurred_at.localeCompare(latest) > 0) return event.occurred_at;
|
||||
return latest;
|
||||
}, null),
|
||||
[eventTimeline],
|
||||
);
|
||||
const displayUpdatedAt = mission.updatedAt || latestEventAt;
|
||||
|
||||
const openAdvanced = () => {
|
||||
const nextOpen = !advancedOpen;
|
||||
setAdvancedOpen(nextOpen);
|
||||
if (nextOpen && !sessionOpen) {
|
||||
void loadSessionMessages();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="de-drawer" role="dialog" aria-label={mission.title}>
|
||||
<div className="de-drawer-head">
|
||||
<div className="de-drawer-title-group">
|
||||
<span className="de-drawer-eyebrow">委托详情</span>
|
||||
<h3>{mission.title}</h3>
|
||||
</div>
|
||||
<button className="de-btn-secondary" onClick={onClose} aria-label="关闭">✕</button>
|
||||
</div>
|
||||
<div className="de-drawer-status-line">
|
||||
<StatusBadge variant={stepVariant(mission.status)} label={displayStatus(mission.status)} />
|
||||
<span className="de-drawer-meta">{missionPeriodLabel(mission)}</span>
|
||||
<span className="de-drawer-meta">{progressText(mission)}</span>
|
||||
</div>
|
||||
<div className="de-drawer-timeline">
|
||||
<section className="de-detail-section">
|
||||
<div className="de-detail-section-head">
|
||||
<h4>委托概览</h4>
|
||||
</div>
|
||||
<div className="de-detail-kv">
|
||||
<span>业务目标</span><strong>{mission.objective || '未填写'}</strong>
|
||||
<span>周期</span><strong>{missionPeriodLabel(mission)}</strong>
|
||||
<span>当前进度</span><strong>{progressText(mission)}</strong>
|
||||
<span>启动时间</span><strong>{fmtTime(mission.createdAt)}</strong>
|
||||
<span>状态</span><strong>{displayStatus(mission.status)}</strong>
|
||||
<span>更新时间</span><strong>{fmtTime(displayUpdatedAt)}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="de-detail-section de-execution-chain">
|
||||
<div className="de-detail-section-head">
|
||||
<h4>业务过程</h4>
|
||||
<span className="de-chain-plan-id">最近更新:{fmtTime(displayUpdatedAt || mission.createdAt)}</span>
|
||||
</div>
|
||||
{loading && businessTimeline.length === 0 ? (
|
||||
<p className="de-empty-text">正在读取业务过程...</p>
|
||||
) : businessTimeline.length === 0 ? (
|
||||
<p className="de-empty-text">暂无业务过程。重新执行本任务后会显示处理进展。</p>
|
||||
) : (
|
||||
<div className="de-chain-list">
|
||||
{businessTimeline.map((event) => (
|
||||
<div key={`chain-${event.event_id}`} className={`de-chain-row de-chain-row--${event.kind} ${isFailureMessage(event.message) ? 'de-chain-row--danger' : ''}`}>
|
||||
<strong>{businessKindLabel(event.kind, event.message)}</strong>
|
||||
<span>{businessTimelineMessage(event.message)}</span>
|
||||
<time>{fmtTime(event.occurred_at)}</time>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="de-detail-section">
|
||||
<h4>结果与产物</h4>
|
||||
{resultEvents.length > 0 ? (
|
||||
<div className="de-event-list">
|
||||
{[...resultEvents].reverse().map((event) => (
|
||||
<div key={`result-${event.event_id}`} className={`de-event-row ${isFailureMessage(event.message) ? 'de-event-row--danger' : ''}`}>
|
||||
<strong>{businessKindLabel(event.kind, event.message)}</strong>
|
||||
<span>{businessTimelineMessage(event.message)}</span>
|
||||
<time>{fmtTime(event.occurred_at)}</time>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="de-empty-text">任务完成后,这里会显示结果摘要和产物入口。</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="de-detail-section">
|
||||
<h4>步骤进展</h4>
|
||||
{loading ? <p className="de-empty-text">正在读取执行情况...</p> : null}
|
||||
{mission.steps.length === 0 ? (
|
||||
<p className="de-empty-text">暂无步骤进展</p>
|
||||
) : (
|
||||
mission.steps.map((step, i) => (
|
||||
<div key={step.id} className="de-step-node">
|
||||
<div className={`de-step-dot de-step-dot--${step.status}`} />
|
||||
{i < mission.steps.length - 1 && <div className="de-step-line" />}
|
||||
<div className="de-step-content">
|
||||
<strong>{step.title}</strong>
|
||||
<span>{displayStatus(step.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="de-detail-section">
|
||||
<div className="de-detail-section-head">
|
||||
<h4>高级详情</h4>
|
||||
<button className="de-btn-secondary de-btn-sm" onClick={openAdvanced} disabled={sessionLoading}>
|
||||
{advancedOpen ? '收起' : '展开'}
|
||||
</button>
|
||||
</div>
|
||||
{advancedOpen ? (
|
||||
<>
|
||||
<section className="de-detail-section">
|
||||
<div className="de-detail-section-head">
|
||||
<h4>技术链路</h4>
|
||||
<span className="de-chain-plan-id">仅用于排障</span>
|
||||
</div>
|
||||
{eventTimeline.length > 0 ? (
|
||||
<div className="de-event-list">
|
||||
{eventTimeline.slice(-20).reverse().map((event) => (
|
||||
<div key={event.event_id} className={`de-event-row ${isFailureMessage(event.message) ? 'de-event-row--danger' : ''}`}>
|
||||
<strong>{displayEventKind(event.kind)}</strong>
|
||||
<span>{event.message}</span>
|
||||
<time>{fmtTime(event.occurred_at)}</time>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="de-empty-text">暂无事件日志</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="de-detail-section">
|
||||
<div className="de-detail-section-head">
|
||||
<h4>会话记录</h4>
|
||||
<button className="de-btn-secondary de-btn-sm" onClick={loadSessionMessages} disabled={sessionLoading}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
{sessionError ? (
|
||||
<p className="de-empty-text">会话读取失败:{sessionError}</p>
|
||||
) : sessionLoading ? (
|
||||
<p className="de-empty-text">正在读取会话...</p>
|
||||
) : sessionMessages.length === 0 ? (
|
||||
<p className="de-empty-text">暂无会话消息。</p>
|
||||
) : (
|
||||
<div className="de-session-message-list">
|
||||
{sessionMessages.map((message, index) => (
|
||||
<div key={`${message.role}-${index}`} className={`de-session-message de-session-message--${message.role === 'user' ? 'user' : 'agent'}`}>
|
||||
<div className="de-session-message-role">{messageRoleLabel(message.role)}</div>
|
||||
<p>{displayBackendMessage(message.content, message.content)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="de-detail-section">
|
||||
<h4>任务与执行记录</h4>
|
||||
{tasks.length === 0 ? (
|
||||
<p className="de-empty-text">暂无原始任务记录</p>
|
||||
) : (
|
||||
tasks.map((task) => {
|
||||
const taskRuns = runsByTask.get(task.task_id) || [];
|
||||
const latestRun = taskRuns[0];
|
||||
return (
|
||||
<div key={task.task_id} className="de-task-run-card">
|
||||
<div className="de-task-run-head">
|
||||
<span>{task.title || '子任务'}</span>
|
||||
<StatusBadge variant={stepVariant(task.status)} label={displayStatus(task.status)} />
|
||||
</div>
|
||||
<div className="de-task-run-meta">
|
||||
<span>skill_id:{task.assigned_skill_id || '未分配'}</span>
|
||||
<span>开始:{fmtTime(task.started_at)}</span>
|
||||
<span>结束:{fmtTime(task.finished_at)}</span>
|
||||
</div>
|
||||
<div className="de-task-run-meta">
|
||||
<span>task_id:{task.task_id}</span>
|
||||
<span>执行记录:{taskRuns.length} 次</span>
|
||||
{latestRun ? <span>最近结果:{displayStatus(latestRun.lifecycle)}</span> : null}
|
||||
</div>
|
||||
{isFailed(task.status) && task.error_message ? (
|
||||
<div className="de-task-run-error">
|
||||
<strong>失败原因:</strong>{task.error_message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<p className="de-empty-text">默认隐藏排障信息;需要排障时可展开。</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
interface GemProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Gem({ className = '' }: GemProps) {
|
||||
return <div className={`de-gem ${className}`} aria-hidden="true" />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode, CSSProperties } from 'react';
|
||||
|
||||
interface GlassCardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function GlassCard({ children, className = '', style }: GlassCardProps) {
|
||||
return (
|
||||
<div className={`de-card ${className}`} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import StatusBadge from './StatusBadge';
|
||||
import { displayStatus, type Mission } from '@/lib/consoleDataAdapter';
|
||||
import { domSafeId } from '@/pages/digital/navigation';
|
||||
|
||||
interface MissionCardProps {
|
||||
mission: Mission;
|
||||
onSelect: (mission: Mission) => void;
|
||||
onRun?: (mission: Mission) => void;
|
||||
onRestart?: (mission: Mission) => void;
|
||||
dateFilter?: string;
|
||||
selected?: boolean;
|
||||
focusLabel?: string | null;
|
||||
running?: boolean;
|
||||
}
|
||||
|
||||
function statusVariant(status: string): 'info' | 'success' | 'warning' | 'danger' {
|
||||
switch (status) {
|
||||
case 'succeeded': case 'completed': return 'success';
|
||||
case 'failed': case 'error': return 'danger';
|
||||
case 'running': case 'active': return 'info';
|
||||
default: return 'warning';
|
||||
}
|
||||
}
|
||||
|
||||
function canRun(status: string): boolean {
|
||||
return ['draft', 'reviewing', 'approved'].includes(status.toLowerCase());
|
||||
}
|
||||
|
||||
function missionPeriodLabel(mission: Mission): string {
|
||||
const text = `${mission.id} ${mission.title} ${mission.objective}`.toLowerCase();
|
||||
if (text.includes('month') || text.includes('monthly') || text.includes('月')) return '月度任务';
|
||||
if (text.includes('week') || text.includes('weekly') || text.includes('周')) return '周任务';
|
||||
if (text.includes('临时') || text.includes('adhoc') || text.includes('hotlist') || text.includes('monitor')) return '临时任务';
|
||||
return '日常任务';
|
||||
}
|
||||
|
||||
function isDone(status: string): boolean {
|
||||
return ['succeeded', 'success', 'finished', 'complete', 'completed'].includes(status.toLowerCase());
|
||||
}
|
||||
|
||||
function isFailed(status: string): boolean {
|
||||
return ['failed', 'error', 'cancelled', 'canceled', 'aborted', 'rejected', 'deadletter'].includes(status.toLowerCase());
|
||||
}
|
||||
|
||||
function progressText(mission: Mission): string {
|
||||
const total = mission.steps.length;
|
||||
if (total === 0) return displayStatus(mission.status);
|
||||
const done = mission.steps.filter((step) => isDone(step.status)).length;
|
||||
const failed = mission.steps.filter((step) => isFailed(step.status)).length;
|
||||
const running = mission.steps.filter((step) => ['running', 'active', 'queued', 'leased'].includes(step.status.toLowerCase())).length;
|
||||
const parts = [`已完成 ${done}/${total}`];
|
||||
if (failed > 0) parts.push(`失败 ${failed}`);
|
||||
if (running > 0 && failed === 0) parts.push(`处理中 ${running}`);
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
function currentWorkText(mission: Mission): string {
|
||||
const failedStep = mission.steps.find((step) => isFailed(step.status));
|
||||
if (failedStep) return `执行失败:${failedStep.title}`;
|
||||
const runningStep = mission.steps.find((step) => ['running', 'active'].includes(step.status.toLowerCase()));
|
||||
if (runningStep) return `正在处理:${runningStep.title}`;
|
||||
const nextStep = mission.steps.find((step) => !isDone(step.status));
|
||||
if (nextStep) return `准备处理:${nextStep.title}`;
|
||||
if (isDone(mission.status)) return '本轮任务已完成,等待你查看结果';
|
||||
if (mission.status.toLowerCase() === 'failed') return '执行遇到异常,等待你检查后重新执行';
|
||||
return '等待你委托数字员工开始处理';
|
||||
}
|
||||
|
||||
function userNextActionText(mission: Mission, runnable: boolean): string {
|
||||
const status = mission.status.toLowerCase();
|
||||
if (status === 'failed' || status === 'error') return '查看详情后重新执行本任务';
|
||||
if (['running', 'active'].includes(status)) return '等待完成或查看过程';
|
||||
if (isDone(status)) return '查看产物,必要时重新执行';
|
||||
if (runnable) return '确认目标后点击委托执行';
|
||||
return '查看详情确认当前状态';
|
||||
}
|
||||
|
||||
function artifactText(mission: Mission): string {
|
||||
const text = `${mission.title} ${mission.objective}`.toLowerCase();
|
||||
if (text.includes('excel') || text.includes('xlsx') || text.includes('导出') || text.includes('报表')) return '导出报表';
|
||||
if (text.includes('brief') || text.includes('简报') || text.includes('日程')) return '工作简报';
|
||||
if (text.includes('snapshot') || text.includes('快照') || text.includes('热榜')) return '结果快照';
|
||||
if (isDone(mission.status)) return '查看完成结果';
|
||||
return '完成后生成';
|
||||
}
|
||||
|
||||
function dateKey(value?: string | null): string {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0'),
|
||||
].join('-');
|
||||
}
|
||||
|
||||
function todayDateKey(): string {
|
||||
return dateKey(new Date().toISOString());
|
||||
}
|
||||
|
||||
function formatCardClock(value?: string | null): string {
|
||||
if (!value) return '时间未记录';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '时间未记录';
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function formatSyncTime(value?: string | null): string {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function plannedClockFromTitle(mission: Mission): string | null {
|
||||
const match = `${mission.title} ${mission.id}`.match(/(?:^|\D)(\d{1,2})[::](\d{2})(?:\D|$)/);
|
||||
if (!match) return null;
|
||||
const hour = Number(match[1]);
|
||||
const minute = Number(match[2]);
|
||||
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour > 23 || minute > 59) return null;
|
||||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function taskExecutionTimes(mission: Mission): string[] {
|
||||
return mission.steps.flatMap((step) => (
|
||||
step.tasks.flatMap((task) => [task.startedAt, task.finishedAt])
|
||||
)).filter((value): value is string => Boolean(value));
|
||||
}
|
||||
|
||||
function actualExecutionTimeForDate(mission: Mission, selectedDate: string): string | null {
|
||||
const times = taskExecutionTimes(mission)
|
||||
.filter((value) => dateKey(value) === selectedDate)
|
||||
.sort((left, right) => right.localeCompare(left));
|
||||
return times[0] ?? null;
|
||||
}
|
||||
|
||||
function missionTimeText(mission: Mission, selectedDate: string): string {
|
||||
const actualTime = actualExecutionTimeForDate(mission, selectedDate);
|
||||
if (actualTime) return `实际执行 ${formatCardClock(actualTime)}`;
|
||||
const pendingText = selectedDate === todayDateKey() ? '今日未执行' : '当日未执行';
|
||||
const plannedClock = plannedClockFromTitle(mission);
|
||||
return `计划执行 ${plannedClock || '待确认'};${pendingText}`;
|
||||
}
|
||||
|
||||
export default function MissionCard({
|
||||
mission,
|
||||
onSelect,
|
||||
onRun,
|
||||
onRestart,
|
||||
dateFilter,
|
||||
selected,
|
||||
focusLabel,
|
||||
running,
|
||||
}: MissionCardProps) {
|
||||
const runnable = canRun(mission.status);
|
||||
const restartMayOverwrite = isDone(mission.status) || mission.steps.some((step) => isDone(step.status));
|
||||
const selectedDate = dateFilter || todayDateKey();
|
||||
const syncTime = formatSyncTime(mission.updatedAt || mission.createdAt);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`mission-card-${domSafeId(mission.id)}`}
|
||||
className={`de-mission-card ${selected ? 'de-mission-card--selected' : ''}`}
|
||||
onClick={() => onSelect(mission)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onSelect(mission);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="de-mission-card-header">
|
||||
<span className="de-mission-card-title">{mission.title}</span>
|
||||
<div className="de-mission-card-badges">
|
||||
{focusLabel && <span className="de-mission-card-focus-chip">{focusLabel}</span>}
|
||||
<StatusBadge variant={statusVariant(mission.status)} label={displayStatus(mission.status)} />
|
||||
</div>
|
||||
</div>
|
||||
{mission.objective && (
|
||||
<p className="de-mission-card-desc">业务目标:{mission.objective}</p>
|
||||
)}
|
||||
<div className="de-mission-card-meta">
|
||||
<span>周期:{missionPeriodLabel(mission)}</span>
|
||||
<span>当前进度:{progressText(mission)}</span>
|
||||
{syncTime && <span>同步于 {syncTime}</span>}
|
||||
</div>
|
||||
<div className="de-mission-card-meta">
|
||||
<span>{currentWorkText(mission)}</span>
|
||||
</div>
|
||||
<div className="de-mission-card-meta">
|
||||
<span>下一步:{userNextActionText(mission, runnable)}</span>
|
||||
<span>产物入口:{artifactText(mission)}</span>
|
||||
</div>
|
||||
<div className="de-mission-card-actions">
|
||||
<span className="de-mission-card-time">
|
||||
{missionTimeText(mission, selectedDate)}
|
||||
{restartMayOverwrite ? ';重新执行可能覆盖本任务结果' : ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-restart"
|
||||
disabled={running}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!running) onRestart?.(mission);
|
||||
}}
|
||||
title={restartMayOverwrite ? '重新执行本任务,可能覆盖已有结果' : '重新执行本任务'}
|
||||
>
|
||||
重新执行本任务
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-run"
|
||||
disabled={!runnable || running}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (runnable && !running) onRun?.(mission);
|
||||
}}
|
||||
>
|
||||
{running ? '执行中...' : runnable ? '委托执行' : displayStatus(mission.status)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface NavSkewButtonProps {
|
||||
active?: boolean;
|
||||
onClick?: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function NavSkewButton({ active, onClick, children }: NavSkewButtonProps) {
|
||||
return (
|
||||
<button className={`de-nav-btn ${active ? 'active' : ''}`} onClick={onClick}>
|
||||
<span>{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export default function Stage() {
|
||||
return (
|
||||
<div className="de-stage" aria-hidden="true">
|
||||
<div className="de-stage-glow" />
|
||||
<div className="de-stage-layer de-stage-layer-1" />
|
||||
<div className="de-stage-layer de-stage-layer-2" />
|
||||
<div className="de-stage-layer de-stage-layer-3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
type BadgeVariant = 'info' | 'success' | 'warning' | 'danger';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
variant: BadgeVariant;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const variantClass: Record<BadgeVariant, string> = {
|
||||
info: 'de-badge-info',
|
||||
success: 'de-badge-success',
|
||||
warning: 'de-badge-warning',
|
||||
danger: 'de-badge-danger',
|
||||
};
|
||||
|
||||
export default function StatusBadge({ variant, label }: StatusBadgeProps) {
|
||||
return <span className={`de-badge ${variantClass[variant]}`}>{label}</span>;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
|
||||
export type ConsoleRole = 'finance' | 'engineering' | 'executive' | 'hr' | 'sales';
|
||||
export type MotionLevel = 'none' | 'minimal';
|
||||
export type Density = 'compact' | 'balanced' | 'presentation';
|
||||
export type Tone = 'strict' | 'technical' | 'executive' | 'warm-professional';
|
||||
|
||||
export interface RoleProfile {
|
||||
id: ConsoleRole;
|
||||
labelKey: string;
|
||||
density: Density;
|
||||
tone: Tone;
|
||||
contentPriority: string[];
|
||||
motion: {
|
||||
defaultLevel: MotionLevel;
|
||||
allowParticles: false;
|
||||
allowCelebrations: false;
|
||||
allowDecorativeBadges: false;
|
||||
};
|
||||
actionPolicy: {
|
||||
requireConfirmationForDestructive: boolean;
|
||||
showReversalHint: boolean;
|
||||
preferOneClickApproval: boolean;
|
||||
};
|
||||
staleDataThresholdMs: number;
|
||||
cssVars: Record<string, string>;
|
||||
}
|
||||
|
||||
export const ROLE_PROFILES: Record<ConsoleRole, RoleProfile> = {
|
||||
finance: {
|
||||
id: 'finance',
|
||||
labelKey: 'console.role.finance',
|
||||
density: 'compact',
|
||||
tone: 'strict',
|
||||
contentPriority: ['audit', 'sources', 'exactNumbers'],
|
||||
motion: { defaultLevel: 'none', allowParticles: false, allowCelebrations: false, allowDecorativeBadges: false },
|
||||
actionPolicy: { requireConfirmationForDestructive: true, showReversalHint: true, preferOneClickApproval: false },
|
||||
staleDataThresholdMs: 30000,
|
||||
cssVars: {
|
||||
'--role-accent': '#2563eb',
|
||||
'--role-accent-soft': 'rgba(37,99,235,0.12)',
|
||||
'--role-surface-tint': 'rgba(248,250,252,0.78)',
|
||||
'--role-border': 'rgba(37,99,235,0.22)',
|
||||
'--role-focus-ring': '#1d4ed8',
|
||||
},
|
||||
},
|
||||
engineering: {
|
||||
id: 'engineering',
|
||||
labelKey: 'console.role.engineering',
|
||||
density: 'compact',
|
||||
tone: 'technical',
|
||||
contentPriority: ['alarms', 'timestamps', 'reliability'],
|
||||
motion: { defaultLevel: 'none', allowParticles: false, allowCelebrations: false, allowDecorativeBadges: false },
|
||||
actionPolicy: { requireConfirmationForDestructive: true, showReversalHint: true, preferOneClickApproval: false },
|
||||
staleDataThresholdMs: 15000,
|
||||
cssVars: {
|
||||
'--role-accent': '#0f766e',
|
||||
'--role-accent-soft': 'rgba(15,118,110,0.12)',
|
||||
'--role-surface-tint': 'rgba(240,253,250,0.72)',
|
||||
'--role-border': 'rgba(15,118,110,0.24)',
|
||||
'--role-focus-ring': '#0f766e',
|
||||
},
|
||||
},
|
||||
executive: {
|
||||
id: 'executive',
|
||||
labelKey: 'console.role.executive',
|
||||
density: 'presentation',
|
||||
tone: 'executive',
|
||||
contentPriority: ['kpi', 'approvals', 'exactNumbers'],
|
||||
motion: { defaultLevel: 'minimal', allowParticles: false, allowCelebrations: false, allowDecorativeBadges: false },
|
||||
actionPolicy: { requireConfirmationForDestructive: true, showReversalHint: true, preferOneClickApproval: true },
|
||||
staleDataThresholdMs: 60000,
|
||||
cssVars: {
|
||||
'--role-accent': '#334155',
|
||||
'--role-accent-soft': 'rgba(51,65,85,0.10)',
|
||||
'--role-surface-tint': 'rgba(248,250,252,0.82)',
|
||||
'--role-border': 'rgba(51,65,85,0.20)',
|
||||
'--role-focus-ring': '#475569',
|
||||
},
|
||||
},
|
||||
hr: {
|
||||
id: 'hr',
|
||||
labelKey: 'console.role.hr',
|
||||
density: 'balanced',
|
||||
tone: 'warm-professional',
|
||||
contentPriority: ['queues', 'timestamps', 'approvals'],
|
||||
motion: { defaultLevel: 'minimal', allowParticles: false, allowCelebrations: false, allowDecorativeBadges: false },
|
||||
actionPolicy: { requireConfirmationForDestructive: true, showReversalHint: true, preferOneClickApproval: false },
|
||||
staleDataThresholdMs: 45000,
|
||||
cssVars: {
|
||||
'--role-accent': '#7c3aed',
|
||||
'--role-accent-soft': 'rgba(124,58,237,0.10)',
|
||||
'--role-surface-tint': 'rgba(250,245,255,0.72)',
|
||||
'--role-border': 'rgba(124,58,237,0.20)',
|
||||
'--role-focus-ring': '#6d28d9',
|
||||
},
|
||||
},
|
||||
sales: {
|
||||
id: 'sales',
|
||||
labelKey: 'console.role.sales',
|
||||
density: 'balanced',
|
||||
tone: 'warm-professional',
|
||||
contentPriority: ['pipeline', 'sources', 'exactNumbers'],
|
||||
motion: { defaultLevel: 'minimal', allowParticles: false, allowCelebrations: false, allowDecorativeBadges: false },
|
||||
actionPolicy: { requireConfirmationForDestructive: true, showReversalHint: true, preferOneClickApproval: false },
|
||||
staleDataThresholdMs: 45000,
|
||||
cssVars: {
|
||||
'--role-accent': '#b45309',
|
||||
'--role-accent-soft': 'rgba(180,83,9,0.10)',
|
||||
'--role-surface-tint': 'rgba(255,251,235,0.70)',
|
||||
'--role-border': 'rgba(180,83,9,0.20)',
|
||||
'--role-focus-ring': '#92400e',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface RoleContextValue {
|
||||
role: ConsoleRole;
|
||||
profile: RoleProfile;
|
||||
setRole: (role: ConsoleRole) => void;
|
||||
}
|
||||
|
||||
const RoleContext = createContext<RoleContextValue | null>(null);
|
||||
|
||||
const STORAGE_KEY = 'sgrobot-console-role';
|
||||
|
||||
function getStoredRole(): ConsoleRole {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored && stored in ROLE_PROFILES) return stored as ConsoleRole;
|
||||
} catch { /* localStorage blocked */ }
|
||||
return 'sales';
|
||||
}
|
||||
|
||||
export function RoleProvider({ children }: { children: ReactNode }) {
|
||||
const [role, setRoleState] = useState<ConsoleRole>(getStoredRole);
|
||||
|
||||
const setRole = useCallback((newRole: ConsoleRole) => {
|
||||
setRoleState(newRole);
|
||||
try { localStorage.setItem(STORAGE_KEY, newRole); } catch { /* noop */ }
|
||||
}, []);
|
||||
|
||||
const profile = ROLE_PROFILES[role];
|
||||
|
||||
return (
|
||||
<RoleContext.Provider value={{ role, profile, setRole }}>
|
||||
<div style={profile.cssVars as React.CSSProperties} data-role={role}>
|
||||
{children}
|
||||
</div>
|
||||
</RoleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRole(): RoleContextValue {
|
||||
const ctx = useContext(RoleContext);
|
||||
if (!ctx) throw new Error('useRole must be used within RoleProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
import type {
|
||||
StatusResponse,
|
||||
ToolSpec,
|
||||
CronJob,
|
||||
CronRun,
|
||||
Integration,
|
||||
DiagResult,
|
||||
MemoryEntry,
|
||||
CostSummary,
|
||||
CliTool,
|
||||
HealthSnapshot,
|
||||
Session,
|
||||
ChannelDetail,
|
||||
BrowserSession,
|
||||
UserNotification,
|
||||
DebugStoreResponse,
|
||||
DebugPlan,
|
||||
DebugTask,
|
||||
DebugRun,
|
||||
DashboardResponse,
|
||||
FlowResponse,
|
||||
SkillSpec,
|
||||
CreatePlanRequest,
|
||||
RouteDecisionTimelineResponse,
|
||||
IngressRouteRequest,
|
||||
IngressRouteResponse,
|
||||
GovernanceCommandRequest,
|
||||
GovernanceCommandResponse,
|
||||
DigitalEmployeeWorkdayProjection,
|
||||
DigitalEmployeeWorkdayActionRequest,
|
||||
PlanDispatchResponse,
|
||||
} from '../types/api';
|
||||
import { clearToken, getToken, setToken } from './auth';
|
||||
import { apiOrigin, apiBase } from './basePath';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base fetch wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const API_FETCH_TIMEOUT_MS = 9000;
|
||||
const DIGITAL_EMPLOYEE_WORKDAY_TIMEOUT_MS = 20000;
|
||||
|
||||
type ApiFetchOptions = RequestInit & {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor() {
|
||||
super('Unauthorized');
|
||||
this.name = 'UnauthorizedError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T = unknown>(
|
||||
path: string,
|
||||
options: ApiFetchOptions = {},
|
||||
): Promise<T> {
|
||||
const { timeoutMs = API_FETCH_TIMEOUT_MS, ...fetchOptions } = options;
|
||||
const token = getToken();
|
||||
const headers = new Headers(fetchOptions.headers);
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const timeout = window.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
const externalSignal = fetchOptions.signal;
|
||||
const abortFromExternal = () => controller.abort();
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
controller.abort();
|
||||
} else {
|
||||
externalSignal.addEventListener('abort', abortFromExternal, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
if (
|
||||
fetchOptions.body &&
|
||||
typeof fetchOptions.body === 'string' &&
|
||||
!headers.has('Content-Type')
|
||||
) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${apiOrigin()}${apiBase}${path}`, {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError' && timedOut) {
|
||||
throw new Error(`API request timed out after ${timeoutMs / 1000}s: ${path}`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
externalSignal?.removeEventListener('abort', abortFromExternal);
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
clearToken();
|
||||
window.dispatchEvent(new Event('zeroclaw-unauthorized'));
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
let message = text || response.statusText;
|
||||
if (text) {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { message?: unknown; error?: unknown };
|
||||
if (typeof parsed.message === 'string' && parsed.message.trim()) {
|
||||
message = parsed.message.trim();
|
||||
} else if (typeof parsed.error === 'string' && parsed.error.trim()) {
|
||||
message = parsed.error.trim();
|
||||
}
|
||||
} catch {
|
||||
message = text;
|
||||
}
|
||||
}
|
||||
throw new Error(`API ${response.status}: ${message}`);
|
||||
}
|
||||
|
||||
// Some endpoints may return 204 No Content
|
||||
if (response.status === 204) {
|
||||
return undefined as unknown as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function unwrapField<T>(value: T | Record<string, T>, key: string): T {
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value) && key in value) {
|
||||
const unwrapped = (value as Record<string, T | undefined>)[key];
|
||||
if (unwrapped !== undefined) {
|
||||
return unwrapped;
|
||||
}
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pairing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function pair(code: string): Promise<{ token: string }> {
|
||||
const response = await fetch(`${apiOrigin()}${apiBase}/pair`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Pairing-Code': code },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`Pairing failed (${response.status}): ${text || response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { token: string };
|
||||
setToken(data.token);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getAdminPairCode(): Promise<{ pairing_code: string | null; pairing_required: boolean }> {
|
||||
const response = await fetch(`${apiOrigin()}${apiBase}/admin/paircode`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch pairing code (${response.status})`);
|
||||
}
|
||||
return response.json() as Promise<{ pairing_code: string | null; pairing_required: boolean }>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public health (no auth required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getPublicHealth(): Promise<{ require_pairing: boolean; paired: boolean }> {
|
||||
const response = await fetch(`${apiOrigin()}${apiBase}/health`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Health check failed (${response.status})`);
|
||||
}
|
||||
return response.json() as Promise<{ require_pairing: boolean; paired: boolean }>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status / Health
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getStatus(): Promise<StatusResponse> {
|
||||
return apiFetch<StatusResponse>('/api/status');
|
||||
}
|
||||
|
||||
export function getHealth(): Promise<HealthSnapshot> {
|
||||
return apiFetch<HealthSnapshot | { health: HealthSnapshot }>('/api/health').then((data) =>
|
||||
unwrapField(data, 'health'),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getConfig(): Promise<string> {
|
||||
return apiFetch<string | { format?: string; content: string }>('/api/config').then((data) =>
|
||||
typeof data === 'string' ? data : data.content,
|
||||
);
|
||||
}
|
||||
|
||||
export function putConfig(content: string, contentType = 'application/json'): Promise<void> {
|
||||
return apiFetch<void>('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': contentType },
|
||||
body: content,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getTools(): Promise<ToolSpec[]> {
|
||||
return apiFetch<ToolSpec[] | { tools: ToolSpec[] }>('/api/tools').then((data) =>
|
||||
unwrapField(data, 'tools'),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cron
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getCronJobs(): Promise<CronJob[]> {
|
||||
return apiFetch<CronJob[] | { jobs: CronJob[] }>('/api/cron').then((data) =>
|
||||
unwrapField(data, 'jobs'),
|
||||
);
|
||||
}
|
||||
|
||||
export function getSchedulerJobs(): Promise<CronJob[]> {
|
||||
return apiFetch<CronJob[] | { jobs: CronJob[] }>('/api/scheduler/jobs').then((data) =>
|
||||
unwrapField(data, 'jobs'),
|
||||
);
|
||||
}
|
||||
|
||||
export function addCronJob(body: {
|
||||
name?: string;
|
||||
command?: string;
|
||||
prompt?: string;
|
||||
job_type?: string;
|
||||
schedule: string;
|
||||
enabled?: boolean;
|
||||
session_target?: string;
|
||||
model?: string;
|
||||
allowed_tools?: string[];
|
||||
delete_after_run?: boolean;
|
||||
}): Promise<CronJob> {
|
||||
return apiFetch<CronJob | { status: string; job: CronJob }>('/api/cron', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}).then((data) => (typeof (data as { job?: CronJob }).job === 'object' ? (data as { job: CronJob }).job : (data as CronJob)));
|
||||
}
|
||||
|
||||
export function deleteCronJob(id: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/cron/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
export function patchCronJob(
|
||||
id: string,
|
||||
patch: { name?: string; schedule?: string; command?: string },
|
||||
): Promise<CronJob> {
|
||||
return apiFetch<CronJob | { status: string; job: CronJob }>(
|
||||
`/api/cron/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
},
|
||||
).then((data) => (typeof (data as { job?: CronJob }).job === 'object' ? (data as { job: CronJob }).job : (data as CronJob)));
|
||||
}
|
||||
|
||||
|
||||
export function getCronRuns(
|
||||
jobId: string,
|
||||
limit: number = 20,
|
||||
): Promise<CronRun[]> {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
return apiFetch<CronRun[] | { runs: CronRun[] }>(
|
||||
`/api/cron/${encodeURIComponent(jobId)}/runs?${params}`,
|
||||
).then((data) => unwrapField(data, 'runs'));
|
||||
}
|
||||
|
||||
export interface CronSettings {
|
||||
enabled: boolean;
|
||||
catch_up_on_startup: boolean;
|
||||
max_run_history: number;
|
||||
}
|
||||
|
||||
export function getCronSettings(): Promise<CronSettings> {
|
||||
return apiFetch<CronSettings>('/api/cron/settings');
|
||||
}
|
||||
|
||||
export function patchCronSettings(
|
||||
patch: Partial<CronSettings>,
|
||||
): Promise<CronSettings> {
|
||||
return apiFetch<CronSettings & { status: string }>('/api/cron/settings', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integrations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getIntegrations(): Promise<Integration[]> {
|
||||
return apiFetch<Integration[] | { integrations: Integration[] }>('/api/integrations').then(
|
||||
(data) => unwrapField(data, 'integrations'),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Doctor / Diagnostics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function runDoctor(): Promise<DiagResult[]> {
|
||||
return apiFetch<DiagResult[] | { results: DiagResult[]; summary?: unknown }>('/api/doctor', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
}).then((data) => (Array.isArray(data) ? data : data.results));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getMemory(
|
||||
query?: string,
|
||||
category?: string,
|
||||
): Promise<MemoryEntry[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (query) params.set('query', query);
|
||||
if (category) params.set('category', category);
|
||||
const qs = params.toString();
|
||||
return apiFetch<MemoryEntry[] | { entries: MemoryEntry[] }>(`/api/memory${qs ? `?${qs}` : ''}`).then(
|
||||
(data) => unwrapField(data, 'entries'),
|
||||
);
|
||||
}
|
||||
|
||||
export function storeMemory(
|
||||
key: string,
|
||||
content: string,
|
||||
category?: string,
|
||||
): Promise<void> {
|
||||
return apiFetch<unknown>('/api/memory', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key, content, category }),
|
||||
}).then(() => undefined);
|
||||
}
|
||||
|
||||
export function deleteMemory(key: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/memory/${encodeURIComponent(key)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cost
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getCost(): Promise<CostSummary> {
|
||||
return apiFetch<CostSummary | { cost: CostSummary }>('/api/cost').then((data) =>
|
||||
unwrapField(data, 'cost'),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sessions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getSessions(): Promise<Session[]> {
|
||||
return apiFetch<{ sessions: Session[] }>('/api/sessions').then((data) =>
|
||||
data.sessions ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
export function getSession(id: string): Promise<Session> {
|
||||
return apiFetch<Session>(`/api/sessions/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export function renameSession(id: string, name: string): Promise<{ session_id: string; name: string }> {
|
||||
return apiFetch<{ session_id: string; name: string }>(`/api/sessions/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channels (detailed)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getChannels(): Promise<ChannelDetail[]> {
|
||||
return apiFetch<ChannelDetail[] | { channels: ChannelDetail[] }>('/api/channels').then((data) =>
|
||||
unwrapField(data, 'channels'),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI Tools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getCliTools(): Promise<CliTool[]> {
|
||||
return apiFetch<CliTool[] | { cli_tools: CliTool[] }>('/api/cli-tools').then((data) =>
|
||||
unwrapField(data, 'cli_tools'),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Browser / SuperRPA
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LaunchBrowserResponse {
|
||||
session_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface BrowserStatusResponse {
|
||||
running: boolean;
|
||||
sessions: BrowserSession[];
|
||||
}
|
||||
|
||||
export function launchBrowser(url?: string, superrpaPath?: string): Promise<LaunchBrowserResponse> {
|
||||
return apiFetch<LaunchBrowserResponse>('/api/browser/launch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url: url || '', superrpa_path: superrpaPath || '' }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getBrowserStatus(): Promise<BrowserStatusResponse> {
|
||||
return apiFetch<BrowserStatusResponse>('/api/browser/status');
|
||||
}
|
||||
|
||||
export function closeBrowserSession(sessionId: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/browser/session/${encodeURIComponent(sessionId)}/close`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getPlans() {
|
||||
return apiFetch<{ plans: import('../types/api').PlanSummary[] }>('/api/debug/plans');
|
||||
}
|
||||
|
||||
export interface PlanMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
export function getSessionMessages(sessionId: string): Promise<{ messages: PlanMessage[] }> {
|
||||
return apiFetch<{ session_id: string; messages: PlanMessage[] }>(
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/messages`,
|
||||
).then((d) => ({ messages: d.messages || [] }));
|
||||
}
|
||||
|
||||
export function getPlanMessages(planId: string): Promise<{ messages: PlanMessage[] }> {
|
||||
return apiFetch<{ plan_id: string; messages: PlanMessage[] }>(
|
||||
`/api/plan/${encodeURIComponent(planId)}/messages`,
|
||||
).then((d) => ({ messages: d.messages || [] }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execution governance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getRouteDecisionTimeline(): Promise<RouteDecisionTimelineResponse> {
|
||||
return apiFetch<RouteDecisionTimelineResponse>('/api/route-decisions');
|
||||
}
|
||||
|
||||
export function routeIngress(
|
||||
body: IngressRouteRequest,
|
||||
): Promise<IngressRouteResponse> {
|
||||
return apiFetch<IngressRouteResponse>('/api/ingress/route', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function governanceCommand(
|
||||
body: GovernanceCommandRequest,
|
||||
): Promise<GovernanceCommandResponse> {
|
||||
return apiFetch<GovernanceCommandResponse>('/api/governance/command', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function runSchedulerNow(): Promise<GovernanceCommandResponse> {
|
||||
const stamp = Date.now();
|
||||
return governanceCommand({
|
||||
command_kind: 'run_schedule_now',
|
||||
context_refs: { schedule_id: 'scheduler-manual-check' },
|
||||
payload: { source: 'scheduler-jobs-page' },
|
||||
correlation_id: `ui-run-schedule-now-${stamp}`,
|
||||
idempotency_key: `ui-run-schedule-now-${stamp}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function getPlanRevisions(planId: string): Promise<any> {
|
||||
return apiFetch(`/api/plan/${encodeURIComponent(planId)}/revisions`);
|
||||
}
|
||||
|
||||
export function getEventLog(params: {
|
||||
correlation_id?: string;
|
||||
plan_id?: string;
|
||||
task_id?: string;
|
||||
run_id?: string;
|
||||
limit?: number;
|
||||
} = {}): Promise<any> {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && `${value}`.trim() !== '') {
|
||||
query.set(key, `${value}`);
|
||||
}
|
||||
});
|
||||
const suffix = query.toString();
|
||||
return apiFetch(`/api/event-log${suffix ? `?${suffix}` : ''}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notifications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { type UserNotification };
|
||||
|
||||
export function getNotifications(
|
||||
status: string = 'unread',
|
||||
): Promise<UserNotification[]> {
|
||||
return apiFetch<{ notifications: UserNotification[] }>(
|
||||
`/api/notifications?status=${encodeURIComponent(status)}`,
|
||||
).then((d) => d.notifications ?? []);
|
||||
}
|
||||
|
||||
export function getUnreadNotificationCount(): Promise<number> {
|
||||
return apiFetch<{ count: number }>('/api/notifications/unread-count').then(
|
||||
(d) => d.count ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function markNotificationRead(id: string): Promise<boolean> {
|
||||
return apiFetch<{ ok: boolean }>(`/api/notifications/${encodeURIComponent(id)}/read`, {
|
||||
method: 'POST',
|
||||
}).then((d) => d.ok ?? false);
|
||||
}
|
||||
|
||||
export function dismissNotification(id: string): Promise<boolean> {
|
||||
return apiFetch<{ ok: boolean }>(
|
||||
`/api/notifications/${encodeURIComponent(id)}/dismiss`,
|
||||
{ method: 'POST' },
|
||||
).then((d) => d.ok ?? false);
|
||||
}
|
||||
|
||||
export function replyToNotification(
|
||||
id: string,
|
||||
content: string,
|
||||
): Promise<{ ok: boolean; task_id?: string; status?: string }> {
|
||||
return apiFetch<{ ok: boolean; task_id?: string; status?: string }>(
|
||||
`/api/notifications/${encodeURIComponent(id)}/reply`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console: Debug Store, Plans, Tasks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getDebugStore(): Promise<DebugStoreResponse> {
|
||||
return apiFetch<DebugStoreResponse>('/api/debug/store?limit=200');
|
||||
}
|
||||
|
||||
export function getDashboardStats(): Promise<DashboardResponse> {
|
||||
return apiFetch<DashboardResponse | { tasks: DashboardResponse['tasks'] }>('/api/dashboard')
|
||||
.then((data) => ('tasks' in data && !('runtime' in data))
|
||||
? { runtime: { active_tasks: 0, failed_tasks: 0, pending_confirmations: 0 }, tasks: data.tasks, approvals: { total: 0, pending: 0 } }
|
||||
: data as DashboardResponse);
|
||||
}
|
||||
|
||||
export function getDigitalEmployeeWorkday(date?: string): Promise<DigitalEmployeeWorkdayProjection> {
|
||||
const query = date ? `?date=${encodeURIComponent(date)}` : '';
|
||||
return apiFetch<DigitalEmployeeWorkdayProjection>(`/api/digital-employee/workday${query}`, {
|
||||
timeoutMs: DIGITAL_EMPLOYEE_WORKDAY_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function postDigitalEmployeeWorkdayAction(
|
||||
body: DigitalEmployeeWorkdayActionRequest,
|
||||
): Promise<DigitalEmployeeWorkdayProjection> {
|
||||
return apiFetch<DigitalEmployeeWorkdayProjection>('/api/digital-employee/workday/actions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function digitalEmployeeReportPdfUrl(date: string): string {
|
||||
return `${apiOrigin()}${apiBase}/api/digital-employee/reports/${encodeURIComponent(date)}/pdf`;
|
||||
}
|
||||
|
||||
export function dispatchPlanNow(planId: string): Promise<PlanDispatchResponse> {
|
||||
return apiFetch<PlanDispatchResponse>(`/api/debug/plan/${encodeURIComponent(planId)}/dispatch`, {
|
||||
method: 'POST',
|
||||
timeoutMs: DIGITAL_EMPLOYEE_WORKDAY_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function getDebugPlans(): Promise<DebugPlan[]> {
|
||||
return apiFetch<{ plans: DebugPlan[] }>('/api/debug/plans?limit=80')
|
||||
.then((data) => data.plans ?? []);
|
||||
}
|
||||
|
||||
export function searchDebugPlans(query: string, limit = 50): Promise<DebugPlan[]> {
|
||||
const params = new URLSearchParams({ limit: String(limit), q: query });
|
||||
return apiFetch<{ plans: DebugPlan[] }>(`/api/debug/plans?${params}`)
|
||||
.then((data) => data.plans ?? []);
|
||||
}
|
||||
|
||||
export function getDebugTasks(): Promise<{ tasks: DebugTask[]; runs: DebugRun[]; total?: number; truncated?: boolean }> {
|
||||
return apiFetch<{ tasks: DebugTask[]; runs: DebugRun[]; total?: number; truncated?: boolean }>('/api/debug/tasks?limit=80')
|
||||
.then((data) => ({ tasks: data.tasks ?? [], runs: data.runs ?? [], total: data.total, truncated: data.truncated }));
|
||||
}
|
||||
|
||||
export function getDebugTasksForPlan(planId: string): Promise<{ tasks: DebugTask[]; runs: DebugRun[]; total?: number; truncated?: boolean }> {
|
||||
const params = new URLSearchParams({ limit: '50', plan_id: planId });
|
||||
return apiFetch<{ tasks: DebugTask[]; runs: DebugRun[]; total?: number; truncated?: boolean }>(`/api/debug/tasks?${params}`)
|
||||
.then((data) => ({ tasks: data.tasks ?? [], runs: data.runs ?? [], total: data.total, truncated: data.truncated }));
|
||||
}
|
||||
|
||||
export function getDebugPlanFlow(planId: string): Promise<FlowResponse> {
|
||||
return apiFetch<FlowResponse>(`/api/debug/plan/${encodeURIComponent(planId)}/flow`);
|
||||
}
|
||||
|
||||
export function getDebugTaskFlow(taskId: string): Promise<FlowResponse> {
|
||||
return apiFetch<FlowResponse>(`/api/debug/task/${encodeURIComponent(taskId)}/flow`);
|
||||
}
|
||||
|
||||
export function getSkills(): Promise<SkillSpec[]> {
|
||||
return apiFetch<{ skills: SkillSpec[] }>('/api/skill')
|
||||
.then((data) => data.skills ?? []);
|
||||
}
|
||||
|
||||
export function setSkillEnabled(skillId: string, enabled: boolean): Promise<{ ok: boolean; skill_id: string; enabled: boolean; error?: string }> {
|
||||
return apiFetch<{ ok: boolean; skill_id: string; enabled: boolean; error?: string }>(
|
||||
`/api/skill/${encodeURIComponent(skillId)}/enabled`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ enabled }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function createPlan(body: CreatePlanRequest): Promise<{ ok?: boolean; plan_id: string; task_count?: number }> {
|
||||
return apiFetch('/api/debug/plan/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function planAction(planId: string, action: string): Promise<any> {
|
||||
const endpoint = action === 'submit'
|
||||
? `/api/plan/${encodeURIComponent(planId)}/submit`
|
||||
: `/api/debug/plan/${encodeURIComponent(planId)}/${action}`;
|
||||
return apiFetch(endpoint, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function taskAction(taskId: string, action: string): Promise<any> {
|
||||
return apiFetch(`/api/task/${encodeURIComponent(taskId)}/${action}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy compatibility only.
|
||||
* Current React pages must trigger scheduler checks through runSchedulerNow()
|
||||
* so backend governance rejection and command errors are surfaced correctly.
|
||||
*/
|
||||
export function triggerCronCheck(): Promise<{ ok?: boolean; activated: number }> {
|
||||
return apiFetch<{ ok?: boolean; activated: number }>('/api/cron/check', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function getAuditTrace(kind: string, id: string): Promise<any> {
|
||||
const params = new URLSearchParams({ kind, id });
|
||||
return apiFetch(`/api/audit?${params}`);
|
||||
}
|
||||
|
||||
export function getArtifacts(): Promise<any[]> {
|
||||
return apiFetch<{ artifacts: any[] }>('/api/artifact')
|
||||
.then((data) => data.artifacts ?? []);
|
||||
}
|
||||
|
||||
export function getDebugConversations(): Promise<any[]> {
|
||||
return apiFetch<{ conversations: any[] }>('/api/debug/conversations')
|
||||
.then((data) => data.conversations ?? []);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
const TOKEN_KEY = 'zeroclaw_token';
|
||||
|
||||
/**
|
||||
* Retrieve the stored authentication token.
|
||||
*/
|
||||
export function getToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an authentication token.
|
||||
*/
|
||||
export function setToken(token: string): void {
|
||||
try {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
} catch {
|
||||
// localStorage may be unavailable (e.g. in some private browsing modes)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the stored authentication token.
|
||||
*/
|
||||
export function clearToken(): void {
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a token is currently stored.
|
||||
*/
|
||||
export function isAuthenticated(): boolean {
|
||||
const token = getToken();
|
||||
return token !== null && token.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
__SGROBOT_BASE__?: string;
|
||||
__QIMINGCLAW_DIGITAL_API_BASE__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export const basePath = '.';
|
||||
|
||||
export const apiBase: string = (() => {
|
||||
const configured = window.__QIMINGCLAW_DIGITAL_API_BASE__?.replace(/\/+$/, '');
|
||||
return configured ?? '';
|
||||
})();
|
||||
|
||||
export const apiOrigin = (): string => '';
|
||||
@@ -0,0 +1,488 @@
|
||||
// consoleDataAdapter.ts — API 类型 → 业务 ViewModel 映射
|
||||
// API 类型参考: src/types/api.ts (DebugStoreResponse, DebugPlan, SkillSpec, etc.)
|
||||
|
||||
// ── View Models ──
|
||||
|
||||
export interface DashboardOverview {
|
||||
generatedAt: string;
|
||||
isPartial: boolean;
|
||||
sources: DataSourceStatus[];
|
||||
taskTotals: { total: number; queued: number; running: number; succeeded: number; failed: number };
|
||||
approvals: { total: number; pending: number };
|
||||
health: { activeTasks: number; failedTasks: number; pendingConfirmations: number };
|
||||
}
|
||||
|
||||
export interface Mission {
|
||||
id: string;
|
||||
title: string;
|
||||
objective: string;
|
||||
status: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
steps: MissionStep[];
|
||||
sourceIds: { planId?: string; taskIds: string[] };
|
||||
}
|
||||
|
||||
export interface MissionStep {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: string;
|
||||
preferredSkills: string[];
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: string;
|
||||
assignedSkillId: string | null;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ApprovalItem {
|
||||
id: string;
|
||||
missionId: string | null;
|
||||
taskId: string | null;
|
||||
status: string;
|
||||
title: string;
|
||||
createdAt: string | null;
|
||||
reversible: boolean;
|
||||
}
|
||||
|
||||
export interface JournalEntry {
|
||||
id: string;
|
||||
kind: string;
|
||||
source: string;
|
||||
message: string;
|
||||
occurredAt: string | null;
|
||||
planId: string | null;
|
||||
taskId: string | null;
|
||||
sourceLink?: string;
|
||||
}
|
||||
|
||||
export interface SkillSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string | null;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
category: string;
|
||||
capabilityLevel: 'core' | 'standard' | 'specialized' | 'experimental';
|
||||
source: 'skill-api';
|
||||
}
|
||||
|
||||
export interface DataSourceStatus {
|
||||
endpoint: string;
|
||||
ok: boolean;
|
||||
loadedAt?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
// ── 内部 helper ──
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
|
||||
function arr(v: unknown): Obj[] {
|
||||
return Array.isArray(v) ? (v as Obj[]) : [];
|
||||
}
|
||||
|
||||
function str(v: unknown, fallback = ''): string {
|
||||
if (typeof v === 'string') return v;
|
||||
if (typeof v === 'number') return String(v);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function hasChinese(v: string): boolean {
|
||||
return /[\u4e00-\u9fff]/.test(v);
|
||||
}
|
||||
|
||||
const STATUS_ZH: Record<string, string> = {
|
||||
queued: '排队中',
|
||||
ready: '就绪',
|
||||
idle: '待执行',
|
||||
pending: '待处理',
|
||||
pending_authorization: '待授权',
|
||||
waiting_input: '待输入',
|
||||
draft: '草稿',
|
||||
reviewing: '审核中',
|
||||
approved: '已批准',
|
||||
preparing: '准备中',
|
||||
running: '执行中',
|
||||
active: '运行中',
|
||||
blocked: '已阻塞',
|
||||
succeeded: '已完成',
|
||||
success: '成功',
|
||||
finished: '已完成',
|
||||
complete: '已完成',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
error: '错误',
|
||||
cancelled: '已取消',
|
||||
canceled: '已取消',
|
||||
aborted: '已终止',
|
||||
rejected: '已拒绝',
|
||||
deferred: '已暂缓',
|
||||
dispatched: '已下发',
|
||||
scheduled: '已设置定时',
|
||||
enabled: '已启用',
|
||||
disabled: '已停用',
|
||||
paused: '已暂停',
|
||||
unknown: '未知',
|
||||
};
|
||||
|
||||
const EVENT_KIND_ZH: Record<string, string> = {
|
||||
plan: '计划',
|
||||
task: '任务',
|
||||
task_running: '处理中',
|
||||
task_done: '任务完成',
|
||||
run: '执行开始',
|
||||
run_done: '执行完成',
|
||||
skill_tool: '技能调用',
|
||||
artifact: '产物',
|
||||
task_summary: '结果',
|
||||
entered_session: '会话',
|
||||
start: '目标',
|
||||
RunStarted: '开始调用',
|
||||
RequestDispatched: '请求已发出',
|
||||
ResponseReceived: '收到返回',
|
||||
RunSkipped: '本次跳过',
|
||||
RunCompleted: '执行完成',
|
||||
waiting_for_authorization: '等待授权',
|
||||
pending_authorization: '等待授权',
|
||||
authorization_approved: '授权通过',
|
||||
authorization_rejected: '授权拒绝',
|
||||
authorization_deferred: '稍后处理',
|
||||
failed: '执行失败',
|
||||
completed: '执行完成',
|
||||
};
|
||||
|
||||
export function displayStatus(status: unknown, fallback = '未知'): string {
|
||||
const raw = str(status).trim();
|
||||
if (!raw) return fallback;
|
||||
const mapped = STATUS_ZH[raw] ?? STATUS_ZH[raw.toLowerCase()];
|
||||
if (mapped) return mapped;
|
||||
return hasChinese(raw) ? raw : fallback;
|
||||
}
|
||||
|
||||
export function displayEventKind(kind: unknown): string {
|
||||
const raw = str(kind).trim();
|
||||
if (!raw) return '事件';
|
||||
return EVENT_KIND_ZH[raw] ?? EVENT_KIND_ZH[raw.toLowerCase()] ?? (hasChinese(raw) ? raw : '事件');
|
||||
}
|
||||
|
||||
export function displayBackendMessage(message: unknown, fallback = '暂无事件内容'): string {
|
||||
const raw = str(message).trim();
|
||||
if (!raw) return fallback;
|
||||
if (hasChinese(raw)) return raw;
|
||||
|
||||
const status = displayStatus(raw, '');
|
||||
if (status) return status;
|
||||
|
||||
const attempt = raw.match(/^(Succeeded|Failed|Running|Completed)\s*\(attempt\s*#(\d+)\)$/i);
|
||||
if (attempt) {
|
||||
return `${displayStatus(attempt[1])}(第 ${attempt[2]} 次)`;
|
||||
}
|
||||
if (/request failed/i.test(raw)) return '请求失败';
|
||||
if (/backend unavailable/i.test(raw)) return '后端服务不可达';
|
||||
if (/not found/i.test(raw)) return '未找到对应资源';
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function displayBusinessText(value: unknown, fallback = '未命名内容'): string {
|
||||
const raw = str(value).trim();
|
||||
if (!raw) return fallback;
|
||||
if (/unnamed/i.test(raw)) return fallback;
|
||||
if (hasChinese(raw)) return raw;
|
||||
const status = displayStatus(raw, '');
|
||||
if (status) return status;
|
||||
const skillName = knownSkillName(raw);
|
||||
if (skillName) return skillName;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function isTerminalDoneStatus(status: string): boolean {
|
||||
return ['succeeded', 'success', 'finished', 'complete', 'completed'].includes(status.toLowerCase());
|
||||
}
|
||||
|
||||
function isTerminalFailedStatus(status: string): boolean {
|
||||
return ['failed', 'error', 'cancelled', 'canceled', 'aborted', 'rejected', 'deadletter'].includes(status.toLowerCase());
|
||||
}
|
||||
|
||||
function deriveStepStatus(rawStatus: unknown, tasks: MissionStep['tasks']): string {
|
||||
const status = str(rawStatus).trim().toLowerCase();
|
||||
if (tasks.length === 0) return status || 'pending';
|
||||
if (tasks.some((task) => isTerminalFailedStatus(task.status))) return 'failed';
|
||||
if (tasks.some((task) => ['running', 'active', 'leased'].includes(task.status.toLowerCase()))) return 'running';
|
||||
if (tasks.some((task) => ['queued', 'ready', 'pending'].includes(task.status.toLowerCase()))) return 'queued';
|
||||
if (tasks.every((task) => isTerminalDoneStatus(task.status))) return 'succeeded';
|
||||
return status || 'pending';
|
||||
}
|
||||
|
||||
function deriveMissionStatus(rawStatus: unknown, steps: MissionStep[]): string {
|
||||
const status = str(rawStatus, 'unknown').trim().toLowerCase();
|
||||
if (isTerminalDoneStatus(status) || isTerminalFailedStatus(status)) return status;
|
||||
if (steps.some((step) => isTerminalFailedStatus(step.status))) return 'failed';
|
||||
if (steps.some((step) => ['running', 'active', 'queued', 'leased'].includes(step.status.toLowerCase()))) return 'running';
|
||||
if (steps.length > 0 && steps.every((step) => isTerminalDoneStatus(step.status))) return 'succeeded';
|
||||
return status || 'unknown';
|
||||
}
|
||||
|
||||
// ── 适配函数 ──
|
||||
|
||||
/**
|
||||
* @param storeData — getDebugStore() 返回值 (DebugStoreResponse)
|
||||
* @param skillsData — getSkills() 返回值 (SkillSpec[])
|
||||
*
|
||||
* DebugStoreResponse 形状: { tables: { tasks, runs, approvals, ... } }
|
||||
* SkillSpec 形状: { skill_id, name, version, enabled, description }
|
||||
* DebugPlan 形状: { plan_id, title, objective, status, created_at, steps }
|
||||
*/
|
||||
export function adaptDashboardOverview(
|
||||
storeData: Obj | null
|
||||
): DashboardOverview {
|
||||
const now = new Date().toISOString();
|
||||
const tables = (storeData?.tables ?? {}) as Obj;
|
||||
const tasks = arr(tables.tasks);
|
||||
const approvals = arr(tables.approvals);
|
||||
|
||||
const taskTotals = {
|
||||
total: tasks.length,
|
||||
queued: tasks.filter(t => t.status === 'queued').length,
|
||||
running: tasks.filter(t => t.status === 'running').length,
|
||||
succeeded: tasks.filter(t => t.status === 'succeeded').length,
|
||||
failed: tasks.filter(t => t.status === 'failed').length,
|
||||
};
|
||||
|
||||
return {
|
||||
generatedAt: now,
|
||||
isPartial: !storeData,
|
||||
sources: [
|
||||
{ endpoint: '/api/debug/store', ok: storeData !== null, loadedAt: now },
|
||||
],
|
||||
taskTotals,
|
||||
approvals: {
|
||||
total: approvals.length,
|
||||
pending: approvals.filter(a => a.status === 'pending').length,
|
||||
},
|
||||
health: {
|
||||
activeTasks: taskTotals.running,
|
||||
failedTasks: taskTotals.failed,
|
||||
pendingConfirmations: approvals.filter(a => a.status === 'pending').length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptMissions(plansData: Obj[] | null): Mission[] {
|
||||
if (!plansData) return [];
|
||||
return plansData.map((p: Obj) => {
|
||||
const planId = str(p.plan_id);
|
||||
const stepsRaw = arr(p.steps);
|
||||
const steps: MissionStep[] = stepsRaw.map((s: Obj) => {
|
||||
const taskList = arr(s.tasks);
|
||||
const tasks = taskList.map((t: Obj) => ({
|
||||
id: str(t.task_id),
|
||||
title: displayBusinessText(t.title, '子任务'),
|
||||
description: str(t.description),
|
||||
status: str(t.status, 'pending'),
|
||||
assignedSkillId: t.assigned_skill_id ? str(t.assigned_skill_id) : null,
|
||||
startedAt: t.started_at ? str(t.started_at) : null,
|
||||
finishedAt: t.finished_at ? str(t.finished_at) : null,
|
||||
}));
|
||||
return {
|
||||
id: str(s.step_id),
|
||||
title: displayBusinessText(s.title, '任务步骤'),
|
||||
description: str(s.description),
|
||||
status: deriveStepStatus(s.status, tasks),
|
||||
preferredSkills: Array.isArray(s.preferred_skills) ? (s.preferred_skills as string[]) : [],
|
||||
tasks,
|
||||
};
|
||||
});
|
||||
|
||||
const taskIds: string[] = [];
|
||||
for (const s of steps) {
|
||||
for (const t of s.tasks) {
|
||||
taskIds.push(t.id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: planId,
|
||||
title: displayBusinessText(p.title, '未命名任务'),
|
||||
objective: displayBusinessText(p.objective || p.description, '暂无任务说明'),
|
||||
status: deriveMissionStatus(p.status, steps),
|
||||
createdAt: p.created_at ? str(p.created_at) : null,
|
||||
updatedAt: p.updated_at ? str(p.updated_at) : null,
|
||||
steps,
|
||||
sourceIds: { planId: planId || undefined, taskIds },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const SKILL_ZH: Record<string, string> = {
|
||||
'decision-analyzer': '决策分析',
|
||||
'git-log-collector': 'Git 日志采集',
|
||||
'number-generator': '数据生成',
|
||||
'office-export-xlsx': 'Excel 导出',
|
||||
'task-tracker': '任务跟踪',
|
||||
'weekly-report-writer': '周报撰写',
|
||||
'zhihu-hotlist-detail': '知乎热榜明细',
|
||||
'zhihu-hotlist': '知乎热榜',
|
||||
'tq-lineloss-report': '台区线损报表',
|
||||
'archive-workorder-grid-push-monitor': '归档工单推送监控',
|
||||
'available-balance-below-zero-monitor': '可用余额监控',
|
||||
'command-center-fee-control-monitor': '费用管控监控',
|
||||
'sgcc-todo-crawler': 'SGCC 待办爬取',
|
||||
'appointment-workorder-monitor': '约时工单监控',
|
||||
'fault-location-monitor': '故障定位监控',
|
||||
'fudian-failure-monitor': '复电失败监控',
|
||||
'important-service-expiry-monitor': '服务到期预警',
|
||||
'by-95598-customer-satisfaction-report': '95598 满意率日报',
|
||||
'by-daily-report-statistics': '日报统计',
|
||||
'by-fire-fault-monthly-report': '火警故障月报',
|
||||
'by-risk-control-next-day-plan-report': '风控次日计划',
|
||||
'by-risk-control-week-plan-report': '风控周计划',
|
||||
'by-supervision-workorder-detail-report': '督办工单明细',
|
||||
'age-file-encryption': '文件加解密',
|
||||
'anonymous-file-upload': '匿名文件上传',
|
||||
'browser-automation-agent': '浏览器自动化',
|
||||
'bulk-github-star': '批量仓库标星',
|
||||
'changelog-generator': '更新日志生成',
|
||||
'code-reviewer': '代码审查',
|
||||
'commit-automation': '自动提交',
|
||||
'crypto-trading-signal': '交易信号分析',
|
||||
'csv-to-json': 'CSV 转 JSON',
|
||||
'data-analysis': '数据分析',
|
||||
'deep-research': '深度调研',
|
||||
'dependency-updater': '依赖更新',
|
||||
'deploy-automation': '自动部署',
|
||||
'dns-lookup': 'DNS 查询',
|
||||
'document-generator': '文档生成',
|
||||
'email-automation': '邮件自动化',
|
||||
'file-converter': '文件格式转换',
|
||||
'git-history-analyzer': 'Git 历史分析',
|
||||
'github-issue-manager': 'Issue 管理',
|
||||
'image-optimizer': '图片优化',
|
||||
'json-schema-validator': 'JSON Schema 校验',
|
||||
'log-analyzer': '日志分析',
|
||||
'markdown-to-pdf': 'Markdown 转 PDF',
|
||||
'meeting-summarizer': '会议纪要',
|
||||
'news-aggregator': '新闻聚合',
|
||||
'pdf-extractor': 'PDF 提取',
|
||||
'performance-profiler': '性能分析',
|
||||
'pr-review-bot': 'PR 审查',
|
||||
'release-notes-generator': '发布说明生成',
|
||||
'screenshot-automation': '截图自动化',
|
||||
'search-engine-scraper': '搜索引擎抓取',
|
||||
'sentiment-analysis': '情感分析',
|
||||
'shell-command-runner': '命令执行',
|
||||
'slack-notification': 'Slack 通知',
|
||||
'spreadsheet-generator': '电子表格生成',
|
||||
'text-summarizer': '文本摘要',
|
||||
'torrent-search': '资源搜索',
|
||||
'translate-text': '文本翻译',
|
||||
'web-scraper': '网页抓取',
|
||||
'website-monitor': '网站监控',
|
||||
'whitepaper-analyzer': '白皮书分析',
|
||||
'xml-to-json': 'XML 转 JSON',
|
||||
};
|
||||
|
||||
function knownSkillName(skillId: string): string | null {
|
||||
if (SKILL_ZH[skillId]) return SKILL_ZH[skillId];
|
||||
for (const [key, val] of Object.entries(SKILL_ZH)) {
|
||||
if (skillId.includes(key) || key.includes(skillId)) return val;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function zhName(skillId: string, fallback: string): string {
|
||||
const known = knownSkillName(skillId);
|
||||
if (known) return known;
|
||||
return hasChinese(fallback) ? fallback : '自动化技能';
|
||||
}
|
||||
|
||||
const SKILL_CATEGORY: Record<string, string> = {
|
||||
'decision-analyzer': '数字化', 'git-log-collector': '数字化', 'number-generator': '数字化',
|
||||
'office-export-xlsx': '办公', 'task-tracker': '办公', 'weekly-report-writer': '报表',
|
||||
'zhihu-hotlist-detail': '数字化', 'zhihu-hotlist': '数字化', 'tq-lineloss-report': '报表',
|
||||
'archive-workorder-grid-push-monitor': '监控巡检', 'available-balance-below-zero-monitor': '监控巡检',
|
||||
'command-center-fee-control-monitor': '监控巡检', 'sgcc-todo-crawler': '监控巡检',
|
||||
'appointment-workorder-monitor': '监控巡检', 'fault-location-monitor': '监控巡检',
|
||||
'fudian-failure-monitor': '监控巡检', 'important-service-expiry-monitor': '监控巡检',
|
||||
'by-95598-customer-satisfaction-report': '报表', 'by-daily-report-statistics': '报表',
|
||||
'by-fire-fault-monthly-report': '报表', 'by-risk-control-next-day-plan-report': '报表',
|
||||
'by-risk-control-week-plan-report': '报表', 'by-supervision-workorder-detail-report': '报表',
|
||||
'age-file-encryption': '数字化', 'anonymous-file-upload': '通信', 'browser-automation-agent': '数字化',
|
||||
'bulk-github-star': '发展', 'changelog-generator': '发展', 'chat-logger': '通信',
|
||||
'check-crypto-address-balance': '财务', 'city-distance': '发展', 'city-tourism-website-builder': '营销',
|
||||
'crawl-websites-at-scale': '数字化', 'csv-data-summarizer': '财务', 'd3js-data-visualization': '数字化',
|
||||
'database-query-and-export': '数字化', 'file-tracker': '综合', 'free-geocoding-and-maps': '发展',
|
||||
'free-translation-api': '综合', 'free-weather-data': '综合', 'generate-asset-price-chart': '财务',
|
||||
'generate-qr-code-natively': '数字化', 'get-crypto-price': '财务', 'git-worktree-cli': '发展',
|
||||
'github-stats': '发展', 'html-to-markdown': '数字化', 'image-compressor': '综合',
|
||||
'json-to-csv': '数字化', 'llm-council': '数字化', 'markdown-table-generator': '综合',
|
||||
'multi-format-converter': '综合', 'npm-package-info': '发展', 'pdf-generator': '综合',
|
||||
'random-number-generator': '综合', 'readability-score': '综合', 'rss-feed-reader': '通信',
|
||||
'sql-formatter': '数字化', 'text-diff': '综合', 'torrent-search': '通信',
|
||||
'unit-converter': '综合', 'url-shortener': '通信', 'youtube-transcript': '通信', 'zip-extractor': '综合',
|
||||
};
|
||||
|
||||
function categoryFor(skillId: string): string {
|
||||
if (SKILL_CATEGORY[skillId]) return SKILL_CATEGORY[skillId];
|
||||
return '综合';
|
||||
}
|
||||
|
||||
function zhDescription(skillId: string, rawDescription: unknown, name: string): string {
|
||||
const description = str(rawDescription).trim();
|
||||
if (description && hasChinese(description)) return description;
|
||||
if (skillId.includes('monitor')) return `${name}能力,用于按排班自动巡检并提示异常。`;
|
||||
if (skillId.includes('report')) return `${name}能力,用于自动生成和整理业务报表。`;
|
||||
return `${name}能力,可由数字员工按需执行或定时处理。`;
|
||||
}
|
||||
|
||||
export function adaptSkillSummaries(skillsData: Obj[] | null): SkillSummary[] {
|
||||
if (!skillsData) return [];
|
||||
return skillsData.map((s: Obj) => {
|
||||
const sid = str(s.skill_id, str(s.id));
|
||||
return {
|
||||
id: sid,
|
||||
name: zhName(sid, str(s.name, sid)),
|
||||
version: s.version ? str(s.version) : null,
|
||||
enabled: Boolean(s.enabled),
|
||||
description: zhDescription(sid, s.description, zhName(sid, str(s.name, sid))),
|
||||
category: categoryFor(sid),
|
||||
capabilityLevel: 'standard' as const,
|
||||
source: 'skill-api' as const,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function adaptJournalEntries(storeData: Obj | null): JournalEntry[] {
|
||||
if (!storeData) return [];
|
||||
const tables = (storeData.tables ?? {}) as Obj;
|
||||
const events = arr(tables.canonical_events);
|
||||
return events.map((e: Obj) => ({
|
||||
id: str(e.event_id, str(e.id)),
|
||||
kind: str(e.event_type ?? e.kind, 'unknown'),
|
||||
source: 'debug-store',
|
||||
message: str(e.message, ''),
|
||||
occurredAt: e.occurred_at ? str(e.occurred_at) : e.timestamp ? str(e.timestamp) : null,
|
||||
planId: e.plan_id ? str(e.plan_id) : null,
|
||||
taskId: e.task_id ? str(e.task_id) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function adaptApprovals(storeData: Obj | null): ApprovalItem[] {
|
||||
if (!storeData) return [];
|
||||
const tables = (storeData.tables ?? {}) as Obj;
|
||||
const approvals = arr(tables.approvals);
|
||||
return approvals.map((a: Obj) => ({
|
||||
id: str(a.approval_id, str(a.id)),
|
||||
missionId: a.plan_id ? str(a.plan_id) : null,
|
||||
taskId: a.task_id ? str(a.task_id) : null,
|
||||
status: str(a.status, 'pending'),
|
||||
title: displayBusinessText(a.title, '待处理授权'),
|
||||
createdAt: a.created_at ? str(a.created_at) : null,
|
||||
reversible: Boolean(a.reversible),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
__TAURI__?: unknown;
|
||||
__ZEROCLAW_GATEWAY__?: string;
|
||||
__TAURI_INTERNALS__?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
export const isTauri = (): boolean => false;
|
||||
|
||||
export const tauriGatewayUrl = (): string => '';
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { HashRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import DigitalEmployeeShell from './pages/digital/DigitalEmployeeShell';
|
||||
import './index.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/digital" element={<DigitalEmployeeShell />} />
|
||||
<Route path="*" element={<Navigate to="/digital" replace />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { Download } from 'lucide-react';
|
||||
import GlassCard from '@/components/digital/GlassCard';
|
||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||
import {
|
||||
digitalEmployeeReportPdfUrl,
|
||||
getDigitalEmployeeWorkday,
|
||||
postDigitalEmployeeWorkdayAction,
|
||||
} from '@/lib/api';
|
||||
import type {
|
||||
DigitalEmployeeDailyReport,
|
||||
DigitalEmployeeRunDetail,
|
||||
DigitalEmployeeTaskExecutionSummary,
|
||||
DigitalEmployeeWorkdayProjection,
|
||||
} from '@/types/api';
|
||||
|
||||
const pageGridStyle: CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const panelStyle: CSSProperties = {
|
||||
border: '1px solid var(--de-glass-border-lighter)',
|
||||
borderRadius: 10,
|
||||
padding: '12px 14px',
|
||||
background: 'linear-gradient(180deg, rgba(244,250,255,0.82), rgba(235,246,255,0.58))',
|
||||
};
|
||||
|
||||
type BusyAction = 'update_report_schedule' | 'generate_daily_report' | null;
|
||||
|
||||
function todayInputDate(): string {
|
||||
const date = new Date();
|
||||
return [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0'),
|
||||
].join('-');
|
||||
}
|
||||
|
||||
function reportStatusLabel(report?: DigitalEmployeeDailyReport | null): string {
|
||||
if (report?.status === 'ready') return '已生成';
|
||||
if (report?.status === 'generating') return '生成中';
|
||||
if (report?.status === 'failed') return '生成失败';
|
||||
return '待生成';
|
||||
}
|
||||
|
||||
function reportStatusClass(report?: DigitalEmployeeDailyReport | null): string {
|
||||
return report?.status === 'ready' ? 'de-report-badge--ready' : 'de-report-badge--waiting';
|
||||
}
|
||||
|
||||
function reportSourceText(report?: DigitalEmployeeDailyReport | null): string {
|
||||
if (!report) return 'AI 将基于当天任务和运行明细生成日报';
|
||||
if (report.generated_by === 'ai') return `AI 已生成日报${report.model ? `:${report.model}` : ''}`;
|
||||
return 'AI 不可用时已使用规则兜底生成日报';
|
||||
}
|
||||
|
||||
function selectedDutyIds(projection: DigitalEmployeeWorkdayProjection | null): string[] {
|
||||
if (!projection) return [];
|
||||
const selected = projection.duties.filter((duty) => duty.selected).map((duty) => duty.id);
|
||||
if (selected.length > 0) return selected;
|
||||
return projection.duties.filter((duty) => duty.implemented).map((duty) => duty.id);
|
||||
}
|
||||
|
||||
function formatGeneratedAt(value?: string | null): string {
|
||||
if (!value) return '暂未生成';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function TaskSectionList({ sections }: { sections: DigitalEmployeeTaskExecutionSummary[] }) {
|
||||
if (sections.length === 0) {
|
||||
return <p className="de-empty-text" style={{ padding: '18px 0 6px' }}>今天还没有可汇总的任务。</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="de-task-summary-grid" aria-label="日报任务汇总">
|
||||
{sections.map((section) => (
|
||||
<div key={section.id} className={`de-task-summary-card de-live-feed-row--${section.latest_status_tone || 'info'}`}>
|
||||
<span className="de-task-summary-title">{section.title}</span>
|
||||
<span className="de-badge de-badge-info">{section.latest_status_label}</span>
|
||||
<strong>执行次数 {section.total_count}</strong>
|
||||
<small>成功次数 {section.success_count} · 失败次数 {section.failure_count}</small>
|
||||
<em>{section.ai_summary || section.business_summary}</em>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailList({ details }: { details: DigitalEmployeeRunDetail[] }) {
|
||||
if (details.length === 0) {
|
||||
return <p className="de-empty-text" style={{ padding: '18px 0 6px' }}>任务执行后,这里会展示时间、任务、步骤和执行情况。</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="de-run-detail-list de-run-detail-list-scroll" aria-label="日报运行明细">
|
||||
<div className="de-run-detail-row de-run-detail-row--head">
|
||||
<span>时间</span>
|
||||
<span>任务名称</span>
|
||||
<span>步骤名称</span>
|
||||
<span>执行状态</span>
|
||||
<span>执行情况</span>
|
||||
</div>
|
||||
{details.map((detail) => (
|
||||
<div key={detail.id} className={`de-run-detail-row de-live-feed-row--${detail.status_tone || 'info'}`}>
|
||||
<span>{detail.time || '--:--'}</span>
|
||||
<strong>{detail.task_name}</strong>
|
||||
<em>{detail.step_name || detail.operation_name || '任务执行'}</em>
|
||||
<i className="de-badge de-badge-info">{detail.status_label}</i>
|
||||
<span>{detail.business_detail}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DailyReport({ focusSection }: { focusSection?: string | null }) {
|
||||
const today = useMemo(() => todayInputDate(), []);
|
||||
const [projection, setProjection] = useState<DigitalEmployeeWorkdayProjection | null>(null);
|
||||
const [reportScheduleTime, setReportScheduleTime] = useState('18:00');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyAction, setBusyAction] = useState<BusyAction>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
const isLiveProjection = projection?.source === 'live';
|
||||
const report = isLiveProjection ? (projection?.daily_report ?? null) : null;
|
||||
const taskSections = isLiveProjection ? (report?.task_sections ?? projection?.execution_summaries ?? []) : [];
|
||||
const runDetails = isLiveProjection ? (projection?.run_details ?? []) : [];
|
||||
const sourceNotice = isLiveProjection ? null : (projection?.demo_reason ?? '当前没有真实运行投影,日报区域暂不展示兜底数据。');
|
||||
const totals = report?.totals ?? {
|
||||
task_count: taskSections.length,
|
||||
execution_count: taskSections.reduce((sum, item) => sum + item.total_count, 0),
|
||||
success_count: taskSections.reduce((sum, item) => sum + item.success_count, 0),
|
||||
failure_count: taskSections.reduce((sum, item) => sum + item.failure_count, 0),
|
||||
running_count: taskSections.reduce((sum, item) => sum + item.running_count, 0),
|
||||
};
|
||||
|
||||
const fetchProjection = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const data = await getDigitalEmployeeWorkday(today);
|
||||
if (!mountedRef.current) return;
|
||||
setProjection(data);
|
||||
setReportScheduleTime(data.source === 'live' ? (data.daily_report?.report_schedule_time || '18:00') : '18:00');
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
setMessage(error instanceof Error ? error.message : '日报数据加载失败');
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false);
|
||||
}
|
||||
}, [today]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void fetchProjection();
|
||||
return () => { mountedRef.current = false; };
|
||||
}, [fetchProjection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusSection || loading) return;
|
||||
window.setTimeout(() => {
|
||||
document.getElementById(`report-section-${focusSection}`)?.scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, 80);
|
||||
}, [focusSection, loading]);
|
||||
|
||||
const saveReportSchedule = useCallback(async () => {
|
||||
setBusyAction('update_report_schedule');
|
||||
setMessage(null);
|
||||
try {
|
||||
const data = await postDigitalEmployeeWorkdayAction({
|
||||
action: 'update_report_schedule',
|
||||
date: today,
|
||||
report_schedule_time: reportScheduleTime,
|
||||
});
|
||||
if (!mountedRef.current) return;
|
||||
setProjection(data);
|
||||
setReportScheduleTime(data.source === 'live' ? (data.daily_report?.report_schedule_time || reportScheduleTime) : reportScheduleTime);
|
||||
setMessage('日报生成时间已更新。');
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
setMessage(error instanceof Error ? error.message : '日报生成时间设置失败');
|
||||
} finally {
|
||||
if (mountedRef.current) setBusyAction(null);
|
||||
}
|
||||
}, [reportScheduleTime, today]);
|
||||
|
||||
const generateDailyReport = useCallback(async () => {
|
||||
setBusyAction('generate_daily_report');
|
||||
setMessage(null);
|
||||
try {
|
||||
const data = await postDigitalEmployeeWorkdayAction({
|
||||
action: 'generate_daily_report',
|
||||
date: today,
|
||||
duty_ids: isLiveProjection ? selectedDutyIds(projection) : [],
|
||||
report_schedule_time: reportScheduleTime,
|
||||
});
|
||||
if (!mountedRef.current) return;
|
||||
setProjection(data);
|
||||
setReportScheduleTime(data.source === 'live' ? (data.daily_report?.report_schedule_time || reportScheduleTime) : reportScheduleTime);
|
||||
setMessage('日报已生成,可下载 PDF。');
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
setMessage(error instanceof Error ? error.message : '日报生成失败');
|
||||
} finally {
|
||||
if (mountedRef.current) setBusyAction(null);
|
||||
}
|
||||
}, [isLiveProjection, projection, reportScheduleTime, today]);
|
||||
|
||||
const downloadDailyReportPdf = useCallback(() => {
|
||||
window.open(digitalEmployeeReportPdfUrl(today), '_blank', 'noopener,noreferrer');
|
||||
}, [today]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '14px 18px' }}>
|
||||
<GlassCard>
|
||||
<CardTitleBar title="日报生成">
|
||||
<span className="de-card-meta-text">{today}</span>
|
||||
</CardTitleBar>
|
||||
<div className="de-card-body">
|
||||
{loading ? (
|
||||
<div className="de-loading-skeleton" style={{ height: 420 }} />
|
||||
) : (
|
||||
<>
|
||||
<section id="report-section-today" className="de-report-summary" style={{ marginBottom: 12 }}>
|
||||
<div className="de-report-summary-head">
|
||||
<div className="de-report-summary-title-row">
|
||||
<h3>{report?.title || '数字员工日报'}</h3>
|
||||
<p>{reportSourceText(report)}</p>
|
||||
</div>
|
||||
<span className={`de-report-badge ${reportStatusClass(report)}`}>{reportStatusLabel(report)}</span>
|
||||
</div>
|
||||
<div className="de-report-summary-body">
|
||||
<div className="de-report-schedule-row">
|
||||
<label>
|
||||
<span>生成时间</span>
|
||||
<input
|
||||
type="time"
|
||||
value={reportScheduleTime}
|
||||
onChange={(event) => setReportScheduleTime(event.target.value || '18:00')}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm"
|
||||
disabled={busyAction === 'update_report_schedule'}
|
||||
onClick={() => { void saveReportSchedule(); }}
|
||||
>
|
||||
设置时间
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-primary de-btn-sm"
|
||||
disabled={busyAction === 'generate_daily_report'}
|
||||
onClick={() => { void generateDailyReport(); }}
|
||||
>
|
||||
{busyAction === 'generate_daily_report' ? '生成中...' : '生成日报'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm de-report-download-btn"
|
||||
disabled={!report?.pdf_available}
|
||||
onClick={downloadDailyReportPdf}
|
||||
title={report?.pdf_available ? '下载日报 PDF' : '生成日报后可下载日报 PDF'}
|
||||
>
|
||||
<Download size={14} aria-hidden="true" />
|
||||
下载日报
|
||||
</button>
|
||||
</div>
|
||||
<div className="de-report-metrics">
|
||||
<div className="de-report-metric"><span>任务总数</span><strong>{totals.task_count}</strong></div>
|
||||
<div className="de-report-metric"><span>执行次数</span><strong>{totals.execution_count}</strong></div>
|
||||
<div className="de-report-metric"><span>成功次数</span><strong>{totals.success_count}</strong></div>
|
||||
<div className="de-report-metric"><span>失败次数</span><strong>{totals.failure_count}</strong></div>
|
||||
</div>
|
||||
<div className="de-report-meta">
|
||||
<p>{report?.summary || '点击生成日报后,AI 会基于当天所有任务写出总分结构日报。'}</p>
|
||||
{sourceNotice ? <p>{sourceNotice}</p> : null}
|
||||
<p>计划生成时间:{reportScheduleTime};最近生成:{formatGeneratedAt(report?.generated_at)}</p>
|
||||
{message ? <p>{message}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style={pageGridStyle}>
|
||||
<section id="report-section-tasks" style={panelStyle}>
|
||||
<h3 className="de-settings-heading" style={{ marginTop: 0 }}>任务汇总</h3>
|
||||
<TaskSectionList sections={taskSections} />
|
||||
</section>
|
||||
<section id="report-section-details" style={panelStyle}>
|
||||
<h3 className="de-settings-heading" style={{ marginTop: 0 }}>运行明细</h3>
|
||||
<DetailList details={runDetails} />
|
||||
</section>
|
||||
<section id="report-section-summary" style={panelStyle}>
|
||||
<h3 className="de-settings-heading" style={{ marginTop: 0 }}>日报内容</h3>
|
||||
{(report?.detail_lines ?? []).length === 0 ? (
|
||||
<p className="de-empty-text" style={{ padding: '18px 0 6px' }}>生成日报后会展示 AI 整理后的分项说明。</p>
|
||||
) : (
|
||||
<div className="de-report-meta">
|
||||
{(report?.detail_lines ?? []).map((line) => <p key={line}>{line}</p>)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Bell, Mail, X } from 'lucide-react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { RoleProvider } from '@/contexts/RoleContext';
|
||||
import CommandDeck from './CommandDeck';
|
||||
import MissionCenter from './MissionCenter';
|
||||
import SkillLibrary from './SkillLibrary';
|
||||
import DailyReport from './DailyReport';
|
||||
import OpsSettings from './OpsSettings';
|
||||
import type { DigitalNavigationTarget, DigitalTab } from './navigation';
|
||||
|
||||
const TABS: { id: DigitalTab; label: string }[] = [
|
||||
{ id: 'home', label: '首页' },
|
||||
{ id: 'missions', label: '任务中心' },
|
||||
{ id: 'skills', label: '技能库' },
|
||||
{ id: 'reports', label: '日报' },
|
||||
{ id: 'settings', label: '设置' },
|
||||
];
|
||||
const DIGITAL_EMPLOYEE_CLOSE_MESSAGE = 'sgrobot:digital-close';
|
||||
|
||||
function isDigitalTab(value: string | null): value is DigitalTab {
|
||||
return Boolean(value && TABS.some(tab => tab.id === value));
|
||||
}
|
||||
|
||||
function tabFromPathname(pathname: string): DigitalTab | null {
|
||||
const parts = pathname.split('/').filter(Boolean);
|
||||
const last = parts.length > 0 ? parts[parts.length - 1]! : null;
|
||||
return isDigitalTab(last) ? last : null;
|
||||
}
|
||||
|
||||
function targetFromLocation(pathname: string, search: string): DigitalNavigationTarget {
|
||||
const params = new URLSearchParams(search);
|
||||
const queryTab = params.get('tab');
|
||||
const tab = isDigitalTab(queryTab) ? queryTab : tabFromPathname(pathname) ?? 'home';
|
||||
return {
|
||||
tab,
|
||||
date: params.get('date'),
|
||||
missionId: params.get('mission'),
|
||||
taskId: params.get('task'),
|
||||
skillId: params.get('skill'),
|
||||
reportSection: params.get('section'),
|
||||
};
|
||||
}
|
||||
|
||||
function TabContent({
|
||||
target,
|
||||
onNavigate,
|
||||
}: {
|
||||
target: DigitalNavigationTarget;
|
||||
onNavigate: (target: DigitalNavigationTarget) => void;
|
||||
}) {
|
||||
const tab = target.tab;
|
||||
switch (tab) {
|
||||
case 'home': return <CommandDeck onNavigate={onNavigate} />;
|
||||
case 'missions': return <MissionCenter focusMissionId={target.missionId} focusTaskId={target.taskId} focusDate={target.date} />;
|
||||
case 'skills': return <SkillLibrary focusSkillId={target.skillId} />;
|
||||
case 'reports': return <DailyReport focusSection={target.reportSection} />;
|
||||
case 'settings': return <OpsSettings />;
|
||||
}
|
||||
}
|
||||
|
||||
function closeDigitalWindow() {
|
||||
if (window.parent && window.parent !== window) {
|
||||
const targetOrigin = window.location.origin === 'null' ? '*' : window.location.origin;
|
||||
window.parent.postMessage({ type: DIGITAL_EMPLOYEE_CLOSE_MESSAGE }, targetOrigin);
|
||||
return;
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
|
||||
export default function DigitalEmployeeShell() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [target, setTarget] = useState<DigitalNavigationTarget>(() => targetFromLocation(location.pathname, location.search));
|
||||
|
||||
useEffect(() => {
|
||||
setTarget(targetFromLocation(location.pathname, location.search));
|
||||
}, [location.pathname, location.search]);
|
||||
|
||||
const navigateDigital = useCallback((nextTarget: DigitalNavigationTarget) => {
|
||||
setTarget(nextTarget);
|
||||
const params = new URLSearchParams();
|
||||
if (nextTarget.tab !== 'home') params.set('tab', nextTarget.tab);
|
||||
if (nextTarget.date) params.set('date', nextTarget.date);
|
||||
if (nextTarget.missionId) params.set('mission', nextTarget.missionId);
|
||||
if (nextTarget.taskId) params.set('task', nextTarget.taskId);
|
||||
if (nextTarget.skillId) params.set('skill', nextTarget.skillId);
|
||||
if (nextTarget.reportSection) params.set('section', nextTarget.reportSection);
|
||||
navigate({
|
||||
pathname: location.pathname,
|
||||
search: params.toString() ? `?${params.toString()}` : '',
|
||||
}, { replace: false });
|
||||
}, [location.pathname, navigate]);
|
||||
|
||||
return (
|
||||
<RoleProvider>
|
||||
<div className="de-shell">
|
||||
<header className="de-header">
|
||||
<div className="de-header-content">
|
||||
<div className="de-header-left">
|
||||
<div className="de-header-accent-bar" />
|
||||
<h1 className="de-header-title">智能数字人系统</h1>
|
||||
</div>
|
||||
<div className="de-header-center">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`de-nav-btn ${target.tab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => navigateDigital({ tab: tab.id })}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="de-header-right">
|
||||
<span className="de-header-welcome">欢迎您,</span>
|
||||
<span className="de-header-username">王琦</span>
|
||||
<button type="button" className="de-header-icon" title="消息">
|
||||
<Mail size={16} />
|
||||
</button>
|
||||
<button type="button" className="de-header-icon" title="通知">
|
||||
<Bell size={16} />
|
||||
</button>
|
||||
<button type="button" className="de-header-icon de-header-close" title="关闭" onClick={closeDigitalWindow}>
|
||||
<X size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<div className="de-shell-body">
|
||||
<TabContent target={target} onNavigate={navigateDigital} />
|
||||
</div>
|
||||
</div>
|
||||
</RoleProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import GlassCard from '@/components/digital/GlassCard';
|
||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||
import MissionCard from '@/components/digital/MissionCard';
|
||||
import FlowDetailPanel from '@/components/digital/FlowDetailPanel';
|
||||
import { adaptMissions, type Mission } from '@/lib/consoleDataAdapter';
|
||||
import { getDebugPlans, governanceCommand, searchDebugPlans } from '@/lib/api';
|
||||
import type { GovernanceCommandKind, GovernanceCommandResponse } from '@/types/api';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
completeDemoMissionOnce,
|
||||
getDemoMissions,
|
||||
runDemoMissionOnce,
|
||||
} from './demoScenario';
|
||||
import { domSafeId } from './navigation';
|
||||
|
||||
const REQUIRED_MISSION_IDS = [
|
||||
'whitecollar-day',
|
||||
'whitecollar-day-0830-browser-portal-check',
|
||||
'whitecollar-day-0900-browser-inbox-triage',
|
||||
'whitecollar-day-0930-calendar-brief',
|
||||
'whitecollar-day-1030-browser-crm-followup',
|
||||
'whitecollar-day-1130-browser-expense-approval',
|
||||
'whitecollar-day-1330-sales-snapshot',
|
||||
'whitecollar-day-1430-report-export',
|
||||
'whitecollar-day-1530-browser-knowledge-check',
|
||||
'whitecollar-day-1630-task-board-sync',
|
||||
'whitecollar-day-1730-browser-day-close',
|
||||
'zhihu-hotlist-monitor',
|
||||
];
|
||||
|
||||
const DELEGATION_FILTERS = [
|
||||
{ key: 'all', label: '全部委托' },
|
||||
{ key: 'daily', label: '日常任务' },
|
||||
{ key: 'weekly', label: '周任务' },
|
||||
{ key: 'monthly', label: '月度任务' },
|
||||
{ key: 'adhoc', label: '临时任务' },
|
||||
];
|
||||
|
||||
type MissionNotice = {
|
||||
kind: 'selected' | 'created' | 'dispatched';
|
||||
missionId: string;
|
||||
title: string;
|
||||
planId?: string;
|
||||
};
|
||||
|
||||
function planSortTime(plan: Record<string, unknown>): number {
|
||||
const value = typeof plan.updated_at === 'string'
|
||||
? plan.updated_at
|
||||
: typeof plan.created_at === 'string'
|
||||
? plan.created_at
|
||||
: '';
|
||||
const time = value ? new Date(value).getTime() : 0;
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
function dateKey(value?: string | null): string {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0'),
|
||||
].join('-');
|
||||
}
|
||||
|
||||
function todayDateKey(): string {
|
||||
return dateKey(new Date().toISOString());
|
||||
}
|
||||
|
||||
function shiftDateKey(value: string, days: number): string {
|
||||
const date = value ? new Date(`${value}T12:00:00`) : new Date();
|
||||
if (Number.isNaN(date.getTime())) return todayDateKey();
|
||||
date.setDate(date.getDate() + days);
|
||||
return dateKey(date.toISOString());
|
||||
}
|
||||
|
||||
function plannedClockFromMission(mission: Mission): { text: string; minutes: number } | null {
|
||||
const match = `${mission.title} ${mission.id}`.match(/(?:^|\D)(\d{1,2})[::](\d{2})(?:\D|$)/);
|
||||
if (!match) return null;
|
||||
const hour = Number(match[1]);
|
||||
const minute = Number(match[2]);
|
||||
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour > 23 || minute > 59) return null;
|
||||
return {
|
||||
text: `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`,
|
||||
minutes: hour * 60 + minute,
|
||||
};
|
||||
}
|
||||
|
||||
function missionTaskTimes(mission: Mission): string[] {
|
||||
return mission.steps.flatMap((step) => (
|
||||
step.tasks.flatMap((task) => [task.startedAt, task.finishedAt])
|
||||
)).filter((value): value is string => Boolean(value));
|
||||
}
|
||||
|
||||
function missionMatchesDate(mission: Mission, selectedDate: string): boolean {
|
||||
const taskDates = missionTaskTimes(mission).map(dateKey).filter(Boolean);
|
||||
if (taskDates.includes(selectedDate)) return true;
|
||||
if (plannedClockFromMission(mission)) return true;
|
||||
return [mission.updatedAt, mission.createdAt].map(dateKey).includes(selectedDate);
|
||||
}
|
||||
|
||||
function missionDateKey(mission: Mission): string {
|
||||
const planned = plannedClockFromMission(mission);
|
||||
if (!planned) return mission.id;
|
||||
return `${mission.title}`.trim().toLowerCase() || mission.id;
|
||||
}
|
||||
|
||||
function missionHasActualWorkOnDate(mission: Mission, selectedDate: string): boolean {
|
||||
return missionTaskTimes(mission).some((value) => dateKey(value) === selectedDate);
|
||||
}
|
||||
|
||||
function missionDedupScore(mission: Mission, selectedDate: string): number {
|
||||
const actualScore = missionHasActualWorkOnDate(mission, selectedDate) ? 10_000 : 0;
|
||||
const requiredScore = REQUIRED_MISSION_IDS.includes(mission.id) ? 1_000 : 0;
|
||||
const selectedSyncScore = [mission.updatedAt, mission.createdAt].map(dateKey).includes(selectedDate) ? 100 : 0;
|
||||
return actualScore + requiredScore + selectedSyncScore + planSortTime({
|
||||
updated_at: mission.updatedAt || '',
|
||||
created_at: mission.createdAt || '',
|
||||
}) / 10_000_000_000_000;
|
||||
}
|
||||
|
||||
function dedupeMissionsForDate(missions: Mission[], selectedDate: string): Mission[] {
|
||||
const byKey = new Map<string, Mission>();
|
||||
for (const mission of missions) {
|
||||
const key = missionDateKey(mission);
|
||||
const previous = byKey.get(key);
|
||||
if (!previous || missionDedupScore(mission, selectedDate) > missionDedupScore(previous, selectedDate)) {
|
||||
byKey.set(key, mission);
|
||||
}
|
||||
}
|
||||
return Array.from(byKey.values());
|
||||
}
|
||||
|
||||
function missionDateSortValue(mission: Mission, selectedDate: string): number {
|
||||
const planned = plannedClockFromMission(mission);
|
||||
if (planned) return planned.minutes;
|
||||
const taskTimes = missionTaskTimes(mission)
|
||||
.filter((value) => dateKey(value) === selectedDate)
|
||||
.sort();
|
||||
if (taskTimes[0]) {
|
||||
const date = new Date(taskTimes[0]);
|
||||
return date.getHours() * 60 + date.getMinutes();
|
||||
}
|
||||
return 24 * 60;
|
||||
}
|
||||
|
||||
function missionDelegationGroup(mission: Mission): string {
|
||||
const text = `${mission.id} ${mission.title} ${mission.objective}`.toLowerCase();
|
||||
if (text.includes('month') || text.includes('monthly') || text.includes('月')) return 'monthly';
|
||||
if (text.includes('week') || text.includes('weekly') || text.includes('周')) return 'weekly';
|
||||
if (text.includes('临时') || text.includes('adhoc') || text.includes('hotlist') || text.includes('monitor')) return 'adhoc';
|
||||
return 'daily';
|
||||
}
|
||||
|
||||
async function sendMissionGovernanceCommand(
|
||||
commandKind: GovernanceCommandKind,
|
||||
contextRefs: Record<string, unknown>,
|
||||
payload: Record<string, unknown> = {},
|
||||
): Promise<GovernanceCommandResponse> {
|
||||
const stamp = Date.now();
|
||||
const response = await governanceCommand({
|
||||
command_kind: commandKind,
|
||||
context_refs: contextRefs,
|
||||
payload: { source: 'digital-mission-center', ...payload },
|
||||
correlation_id: `mission-${commandKind}-${stamp}`,
|
||||
idempotency_key: `mission-${commandKind}-${Object.values(contextRefs).join('-') || 'new'}-${stamp}`,
|
||||
});
|
||||
if (!response.accepted) {
|
||||
throw new Error(response.message || `command 未被接受:${response.command_id || commandKind}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function sendMissionPlanCommand(
|
||||
planId: string,
|
||||
commandKind: 'submit_plan' | 'approve_plan' | 'activate_plan' | 'amend_plan',
|
||||
payload: Record<string, unknown> = {},
|
||||
): Promise<GovernanceCommandResponse> {
|
||||
return sendMissionGovernanceCommand(commandKind, { plan_id: planId }, payload);
|
||||
}
|
||||
|
||||
async function dispatchMissionPlan(mission: Mission): Promise<string | null> {
|
||||
const planId = mission.sourceIds.planId;
|
||||
if (!planId) return null;
|
||||
const status = mission.status.toLowerCase();
|
||||
if (status === 'draft') {
|
||||
await sendMissionPlanCommand(planId, 'submit_plan', { requested_action: 'mission_dispatch' });
|
||||
await sendMissionPlanCommand(planId, 'approve_plan', { requested_action: 'mission_dispatch' });
|
||||
} else if (status === 'reviewing') {
|
||||
await sendMissionPlanCommand(planId, 'approve_plan', { requested_action: 'mission_dispatch' });
|
||||
} else if (!['approved', 'active', 'running'].includes(status)) {
|
||||
await sendMissionPlanCommand(planId, 'amend_plan', {
|
||||
requested_action: 'reopen_for_execution',
|
||||
summary: '数字员工任务中心请求重新执行任务',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
await sendMissionPlanCommand(planId, 'activate_plan', { requested_action: 'mission_dispatch' });
|
||||
return planId;
|
||||
}
|
||||
|
||||
function restartStepsForMission(mission: Mission) {
|
||||
const steps = mission.steps.map((step, index) => {
|
||||
const skills = new Set<string>();
|
||||
step.preferredSkills.forEach((skill) => {
|
||||
if (skill.trim()) skills.add(skill);
|
||||
});
|
||||
step.tasks.forEach((task) => {
|
||||
if (task.assignedSkillId?.trim()) skills.add(task.assignedSkillId);
|
||||
});
|
||||
const description = [
|
||||
step.description,
|
||||
...step.tasks.map((task) => task.description),
|
||||
step.title,
|
||||
mission.objective,
|
||||
mission.title,
|
||||
].find((value) => typeof value === 'string' && value.trim());
|
||||
return {
|
||||
title: step.title || `${mission.title} 第 ${index + 1} 步`,
|
||||
description: description || step.title || mission.objective || mission.title,
|
||||
skills: Array.from(skills),
|
||||
depends_on: index === 0 ? [] : [`step-${index}`],
|
||||
};
|
||||
}).filter((step) => step.skills.length > 0);
|
||||
|
||||
if (steps.length === 0) {
|
||||
throw new Error('当前任务没有可复用的 skill,不能创建真实重新执行计划。');
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
function restartMissionTitle(title: string): string {
|
||||
const base = title.replace(/(\s*重新执行)+$/u, '').trim();
|
||||
return `${base || title} 重新执行`;
|
||||
}
|
||||
|
||||
async function createRestartPlan(mission: Mission): Promise<string> {
|
||||
const response = await sendMissionGovernanceCommand('create_plan', {
|
||||
session_id: 'digital-mission-center',
|
||||
plan_id: mission.sourceIds.planId || mission.id,
|
||||
}, {
|
||||
requested_action: 'mission_restart',
|
||||
title: restartMissionTitle(mission.title),
|
||||
description: mission.objective || mission.title,
|
||||
steps: restartStepsForMission(mission),
|
||||
original_plan_id: mission.sourceIds.planId || mission.id,
|
||||
});
|
||||
const planId = typeof response.result?.plan_id === 'string' ? response.result.plan_id : '';
|
||||
if (!planId) {
|
||||
throw new Error('重新执行计划已提交,但后端没有返回 plan_id。');
|
||||
}
|
||||
const status = typeof response.result?.status === 'string' ? response.result.status.toLowerCase() : '';
|
||||
if (status === 'draft') {
|
||||
await sendMissionPlanCommand(planId, 'submit_plan', { requested_action: 'mission_restart' });
|
||||
await sendMissionPlanCommand(planId, 'approve_plan', { requested_action: 'mission_restart' });
|
||||
await sendMissionPlanCommand(planId, 'activate_plan', { requested_action: 'mission_restart' });
|
||||
} else if (status === 'reviewing') {
|
||||
await sendMissionPlanCommand(planId, 'approve_plan', { requested_action: 'mission_restart' });
|
||||
await sendMissionPlanCommand(planId, 'activate_plan', { requested_action: 'mission_restart' });
|
||||
} else if (status === 'approved') {
|
||||
await sendMissionPlanCommand(planId, 'activate_plan', { requested_action: 'mission_restart' });
|
||||
}
|
||||
return planId;
|
||||
}
|
||||
|
||||
export default function MissionCenter({
|
||||
focusMissionId,
|
||||
focusTaskId,
|
||||
focusDate,
|
||||
}: {
|
||||
focusMissionId?: string | null;
|
||||
focusTaskId?: string | null;
|
||||
focusDate?: string | null;
|
||||
}) {
|
||||
const initialMissionDate = focusDate || todayDateKey();
|
||||
const [missions, setMissions] = useState<Mission[]>([]);
|
||||
const [selected, setSelected] = useState<Mission | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [filter, setFilter] = useState<string>('all');
|
||||
const [dateFilter, setDateFilter] = useState<string>(() => initialMissionDate);
|
||||
const [showAllDates, setShowAllDates] = useState(false);
|
||||
const [runningMissionId, setRunningMissionId] = useState<string | null>(null);
|
||||
const [runningAll, setRunningAll] = useState(false);
|
||||
const [demoMode, setDemoMode] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [recentMissionId, setRecentMissionId] = useState<string | null>(null);
|
||||
const [missionNotice, setMissionNotice] = useState<MissionNotice | null>(null);
|
||||
const detailRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const mountedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => { mountedRef.current = false; };
|
||||
}, []);
|
||||
|
||||
const fetchMissions = useCallback(async (): Promise<Mission[]> => {
|
||||
setLoading(true); setError(false);
|
||||
try {
|
||||
const data = await getDebugPlans();
|
||||
if (!mountedRef.current) return [];
|
||||
const planMap = new Map<string, Record<string, unknown>>();
|
||||
const addPlan = (plan: Record<string, unknown>) => {
|
||||
const id = typeof plan.plan_id === 'string' ? plan.plan_id : '';
|
||||
if (id && !planMap.has(id)) planMap.set(id, plan);
|
||||
};
|
||||
(Array.isArray(data) ? data as Record<string, unknown>[] : []).forEach(addPlan);
|
||||
|
||||
const missing = REQUIRED_MISSION_IDS.filter((id) => !planMap.has(id));
|
||||
if (missing.length > 0) {
|
||||
const extraResults = await Promise.allSettled(
|
||||
missing.map(async (id) => ({ id, plans: await searchDebugPlans(id, 20) })),
|
||||
);
|
||||
for (const result of extraResults) {
|
||||
if (result.status === 'fulfilled') {
|
||||
const exact = result.value.plans.find((plan) => plan.plan_id === result.value.id);
|
||||
if (exact) {
|
||||
addPlan(exact as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requiredPlans = REQUIRED_MISSION_IDS
|
||||
.map((id) => planMap.get(id))
|
||||
.filter((plan): plan is Record<string, unknown> => Boolean(plan));
|
||||
const recentPlans = [...planMap.entries()]
|
||||
.filter(([id]) => !REQUIRED_MISSION_IDS.includes(id))
|
||||
.map(([, plan]) => plan)
|
||||
.sort((a, b) => planSortTime(b) - planSortTime(a));
|
||||
const orderedPlans = [...recentPlans, ...requiredPlans];
|
||||
if (orderedPlans.length > 0) {
|
||||
const existingIds = new Set(orderedPlans.map((plan) => typeof plan.plan_id === 'string' ? plan.plan_id : ''));
|
||||
const demoSupplement = getDemoMissions()
|
||||
.filter((mission) => !existingIds.has(mission.id))
|
||||
.map((mission) => ({
|
||||
plan_id: mission.id,
|
||||
title: mission.title,
|
||||
objective: mission.objective,
|
||||
status: mission.status,
|
||||
created_at: mission.createdAt || new Date().toISOString(),
|
||||
updated_at: mission.updatedAt || mission.createdAt || new Date().toISOString(),
|
||||
steps: mission.steps.map((step) => ({
|
||||
step_id: step.id,
|
||||
title: step.title,
|
||||
description: step.description,
|
||||
status: step.status,
|
||||
preferred_skills: step.preferredSkills,
|
||||
tasks: step.tasks.map((task) => ({
|
||||
task_id: task.id,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
status: task.status,
|
||||
assigned_skill_id: task.assignedSkillId,
|
||||
started_at: task.startedAt,
|
||||
finished_at: task.finishedAt,
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
const nextMissions = adaptMissions([...orderedPlans, ...demoSupplement]);
|
||||
setDemoMode(false);
|
||||
setMissions(nextMissions);
|
||||
return nextMissions;
|
||||
} else {
|
||||
const demoMissions = getDemoMissions();
|
||||
setDemoMode(true);
|
||||
setMissions(demoMissions);
|
||||
return demoMissions;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return [];
|
||||
console.error('[MissionCenter] fetch failed:', err);
|
||||
const demoMissions = getDemoMissions();
|
||||
setDemoMode(true);
|
||||
setMissions(demoMissions);
|
||||
setError(false);
|
||||
return demoMissions;
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchMissions(); }, [fetchMissions]);
|
||||
|
||||
useEffect(() => {
|
||||
setDateFilter(focusDate || todayDateKey());
|
||||
setShowAllDates(false);
|
||||
}, [focusDate]);
|
||||
|
||||
const focusMission = useCallback((mission: Mission) => {
|
||||
setSelected(mission);
|
||||
setRecentMissionId(mission.id);
|
||||
window.setTimeout(() => {
|
||||
detailRef.current?.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'smooth' });
|
||||
}, 40);
|
||||
}, []);
|
||||
|
||||
const selectMission = useCallback((mission: Mission) => {
|
||||
focusMission(mission);
|
||||
setMissionNotice({
|
||||
kind: 'selected',
|
||||
missionId: mission.id,
|
||||
title: mission.title,
|
||||
planId: mission.sourceIds.planId,
|
||||
});
|
||||
}, [focusMission]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusMissionId && !focusTaskId) return;
|
||||
const matched = missions.find((mission) => (
|
||||
mission.id === focusMissionId
|
||||
|| mission.sourceIds.planId === focusMissionId
|
||||
|| (focusTaskId ? mission.sourceIds.taskIds.includes(focusTaskId) : false)
|
||||
));
|
||||
if (!matched) return;
|
||||
setFilter('all');
|
||||
selectMission(matched);
|
||||
window.setTimeout(() => {
|
||||
document.getElementById(`mission-card-${domSafeId(matched.id)}`)?.scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, 80);
|
||||
}, [focusMissionId, focusTaskId, missions, selectMission]);
|
||||
|
||||
const runMission = useCallback(async (mission: Mission) => {
|
||||
setRunningMissionId(mission.id);
|
||||
setError(false);
|
||||
setActionError(null);
|
||||
if (demoMode || !mission.sourceIds.planId) {
|
||||
setMissions((current) => runDemoMissionOnce(current, mission.id));
|
||||
setSelected((current) => {
|
||||
if (current?.id !== mission.id) return current;
|
||||
const next = runDemoMissionOnce([current], mission.id);
|
||||
return next[0] ?? current;
|
||||
});
|
||||
window.setTimeout(() => {
|
||||
if (!mountedRef.current) return;
|
||||
setMissions((current) => completeDemoMissionOnce(current, mission.id));
|
||||
setSelected((current) => {
|
||||
if (current?.id !== mission.id) return current;
|
||||
const next = completeDemoMissionOnce([current], mission.id);
|
||||
return next[0] ?? current;
|
||||
});
|
||||
setRunningMissionId(null);
|
||||
}, 1200);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const newPlanId = await dispatchMissionPlan(mission);
|
||||
const nextMissions = await fetchMissions();
|
||||
setFilter('all');
|
||||
if (newPlanId) {
|
||||
const selectedMission = nextMissions.find((item) => item.id === newPlanId || item.sourceIds.planId === newPlanId);
|
||||
if (selectedMission) {
|
||||
focusMission(selectedMission);
|
||||
setMissionNotice({
|
||||
kind: 'dispatched',
|
||||
missionId: selectedMission.id,
|
||||
title: selectedMission.title,
|
||||
planId: newPlanId,
|
||||
});
|
||||
} else {
|
||||
const plans = await searchDebugPlans(newPlanId, 5);
|
||||
const fallback = adaptMissions(plans as unknown as Record<string, unknown>[]).find((item) => item.id === newPlanId);
|
||||
if (fallback) {
|
||||
focusMission(fallback);
|
||||
setMissionNotice({
|
||||
kind: 'dispatched',
|
||||
missionId: fallback.id,
|
||||
title: fallback.title,
|
||||
planId: newPlanId,
|
||||
});
|
||||
}
|
||||
else setSelected(null);
|
||||
}
|
||||
} else {
|
||||
setSelected((current) => current?.id === mission.id ? null : current);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MissionCenter] dispatch failed:', err);
|
||||
setError(false);
|
||||
setActionError(err instanceof Error ? err.message : '任务委托失败');
|
||||
} finally {
|
||||
setRunningMissionId(null);
|
||||
}
|
||||
}, [demoMode, fetchMissions, focusMission]);
|
||||
|
||||
const restartMission = useCallback(async (mission: Mission) => {
|
||||
setRunningMissionId(mission.id);
|
||||
setError(false);
|
||||
setActionError(null);
|
||||
if (demoMode || !mission.sourceIds.planId) {
|
||||
await runMission(mission);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const newPlanId = await createRestartPlan(mission);
|
||||
const nextMissions = await fetchMissions();
|
||||
setFilter('all');
|
||||
const selectedMission = nextMissions.find((item) => item.id === newPlanId || item.sourceIds.planId === newPlanId);
|
||||
if (selectedMission) {
|
||||
focusMission(selectedMission);
|
||||
setMissionNotice({
|
||||
kind: 'created',
|
||||
missionId: selectedMission.id,
|
||||
title: selectedMission.title,
|
||||
planId: newPlanId,
|
||||
});
|
||||
} else {
|
||||
setRecentMissionId(newPlanId);
|
||||
setMissionNotice({
|
||||
kind: 'created',
|
||||
missionId: newPlanId,
|
||||
title: restartMissionTitle(mission.title),
|
||||
planId: newPlanId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MissionCenter] restart failed:', err);
|
||||
setError(false);
|
||||
setActionError(err instanceof Error ? err.message : '重新执行失败');
|
||||
} finally {
|
||||
setRunningMissionId(null);
|
||||
}
|
||||
}, [demoMode, fetchMissions, focusMission, runMission]);
|
||||
|
||||
const selectedDate = dateFilter || todayDateKey();
|
||||
const filteredByDate = dedupeMissionsForDate(
|
||||
showAllDates ? missions : missions.filter((mission) => missionMatchesDate(mission, selectedDate)),
|
||||
selectedDate,
|
||||
);
|
||||
|
||||
const runnableMissions = filteredByDate.filter((mission) => (
|
||||
mission.sourceIds.planId
|
||||
&& ['draft', 'reviewing', 'approved'].includes(mission.status.toLowerCase())
|
||||
));
|
||||
|
||||
const runAllMissions = useCallback(async () => {
|
||||
if (runnableMissions.length === 0) return;
|
||||
setRunningAll(true);
|
||||
setError(false);
|
||||
if (demoMode) {
|
||||
let delay = 0;
|
||||
for (const mission of runnableMissions.slice(0, 4)) {
|
||||
window.setTimeout(() => {
|
||||
if (!mountedRef.current) return;
|
||||
setRunningMissionId(mission.id);
|
||||
setMissions((current) => runDemoMissionOnce(current, mission.id));
|
||||
}, delay);
|
||||
delay += 400;
|
||||
window.setTimeout(() => {
|
||||
if (!mountedRef.current) return;
|
||||
setMissions((current) => completeDemoMissionOnce(current, mission.id));
|
||||
}, delay + 900);
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
if (!mountedRef.current) return;
|
||||
setRunningMissionId(null);
|
||||
setRunningAll(false);
|
||||
}, delay + 1200);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
for (const mission of runnableMissions) {
|
||||
setRunningMissionId(mission.id);
|
||||
await dispatchMissionPlan(mission);
|
||||
}
|
||||
await fetchMissions();
|
||||
setSelected(null);
|
||||
} catch (err) {
|
||||
console.error('[MissionCenter] batch dispatch failed:', err);
|
||||
setError(false);
|
||||
setActionError(err instanceof Error ? err.message : '批量委托失败');
|
||||
} finally {
|
||||
setRunningMissionId(null);
|
||||
setRunningAll(false);
|
||||
}
|
||||
}, [demoMode, fetchMissions, runnableMissions]);
|
||||
|
||||
const filtered = filter === 'all'
|
||||
? filteredByDate
|
||||
: filteredByDate.filter(m => missionDelegationGroup(m) === filter);
|
||||
const displayMissions = filtered
|
||||
.map((mission, index) => ({ mission, index }))
|
||||
.sort((a, b) => {
|
||||
const priority = (mission: Mission) => (
|
||||
(mission.id === recentMissionId ? 2 : 0)
|
||||
+ (mission.id === selected?.id ? 1 : 0)
|
||||
);
|
||||
return priority(b.mission) - priority(a.mission)
|
||||
|| missionDateSortValue(a.mission, selectedDate) - missionDateSortValue(b.mission, selectedDate)
|
||||
|| a.index - b.index;
|
||||
})
|
||||
.map(({ mission }) => mission);
|
||||
const noticeTitle = missionNotice?.kind === 'created'
|
||||
? '刚创建重新执行任务'
|
||||
: missionNotice?.kind === 'dispatched'
|
||||
? '刚委托执行任务'
|
||||
: '当前查看任务';
|
||||
const focusLabelForMission = (mission: Mission): string | null => {
|
||||
if (mission.id !== recentMissionId) return null;
|
||||
if (missionNotice?.missionId === mission.id && missionNotice.kind === 'created') return '刚创建';
|
||||
if (missionNotice?.missionId === mission.id && missionNotice.kind === 'dispatched') return '刚委托';
|
||||
return '当前查看';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="de-mission-center-page">
|
||||
<div className="de-mission-center-layout">
|
||||
<div className="de-mc-list">
|
||||
<GlassCard>
|
||||
<CardTitleBar title="任务委托">
|
||||
<button
|
||||
onClick={runAllMissions}
|
||||
className="de-btn-primary de-btn-sm"
|
||||
disabled={runningAll || runnableMissions.length === 0}
|
||||
>
|
||||
{runningAll ? '委托中...' : `委托可执行任务(${runnableMissions.length})`}
|
||||
</button>
|
||||
<button onClick={fetchMissions} className="de-btn-secondary de-btn-sm">{t('console.action.refresh')}</button>
|
||||
</CardTitleBar>
|
||||
<div className="de-card-body">
|
||||
{demoMode && (
|
||||
<div className="de-schedule-result de-schedule-result--success" style={{ marginBottom: 12 }}>
|
||||
当前展示为客户演示场景。真实任务未加载时,页面会自动使用“一天工作流”示例数据。
|
||||
</div>
|
||||
)}
|
||||
{actionError && (
|
||||
<div className="de-schedule-result de-schedule-result--error" style={{ marginBottom: 12 }}>
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
{missionNotice && (
|
||||
<div className="de-mission-focus-notice">
|
||||
<strong>{noticeTitle}</strong>
|
||||
<span>{missionNotice.title}</span>
|
||||
{missionNotice.planId && <code>{missionNotice.planId}</code>}
|
||||
</div>
|
||||
)}
|
||||
<div className="de-date-filter-row">
|
||||
<label className="de-date-filter-field">
|
||||
<span>选择日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={(event) => {
|
||||
setShowAllDates(false);
|
||||
setDateFilter(event.target.value || todayDateKey());
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="de-date-filter-actions" aria-label="日期切换">
|
||||
<button type="button" className="de-filter-chip" onClick={() => { setShowAllDates(false); setDateFilter(shiftDateKey(selectedDate, -1)); }}>前一天</button>
|
||||
<button type="button" className="de-filter-chip active" onClick={() => { setShowAllDates(false); setDateFilter(todayDateKey()); }}>今天</button>
|
||||
<button type="button" className="de-filter-chip" onClick={() => { setShowAllDates(false); setDateFilter(shiftDateKey(selectedDate, 1)); }}>后一天</button>
|
||||
</div>
|
||||
<span className="de-date-filter-summary">
|
||||
{showAllDates ? `全部 ${filteredByDate.length} 项任务` : `${filteredByDate.length} 项任务`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="de-filter-row">
|
||||
{DELEGATION_FILTERS.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={`de-filter-chip ${filter === item.key ? 'active' : ''}`}
|
||||
onClick={() => setFilter(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="de-loading-skeleton" style={{ height: 300 }} />
|
||||
) : error ? (
|
||||
<p className="de-empty-text">{t('console.state.backendDown')}</p>
|
||||
) : displayMissions.length === 0 ? (
|
||||
<div className="de-mission-empty-panel">
|
||||
<strong>当前日期暂无任务</strong>
|
||||
<p>这个日期没有匹配到任务记录,演示时可以切回演示日期,或临时查看全部任务。</p>
|
||||
<div className="de-mission-empty-actions">
|
||||
<button type="button" className="de-btn-primary de-btn-sm" onClick={() => { setShowAllDates(true); setFilter('all'); }}>
|
||||
查看全部任务
|
||||
</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => { setShowAllDates(false); setDateFilter(focusDate || todayDateKey()); }}>
|
||||
返回演示日期
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="de-mc-card-list">
|
||||
{displayMissions.map(m => (
|
||||
<MissionCard
|
||||
key={m.id}
|
||||
mission={m}
|
||||
selected={selected?.id === m.id}
|
||||
focusLabel={focusLabelForMission(m)}
|
||||
dateFilter={selectedDate}
|
||||
running={runningMissionId === m.id}
|
||||
onSelect={selectMission}
|
||||
onRun={runMission}
|
||||
onRestart={restartMission}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
<div className="de-mc-detail" ref={detailRef}>
|
||||
{selected ? (
|
||||
<FlowDetailPanel mission={selected} onClose={() => setSelected(null)} />
|
||||
) : (
|
||||
<GlassCard>
|
||||
<div className="de-card-body">
|
||||
<div className="de-mission-empty-panel de-mission-empty-panel--detail">
|
||||
<strong>选择一个任务后,右侧会显示执行步骤、运行记录和交付结果。</strong>
|
||||
<p>后天演示建议先从左侧选择“自动外呼现场人员”或“接收约时工单”,让领导看到数字员工不是静态页面,而是在按流程接活、执行、反馈。</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useState, useEffect, useRef, type CSSProperties } from 'react';
|
||||
import GlassCard from '@/components/digital/GlassCard';
|
||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||
import { useRole, ROLE_PROFILES, type ConsoleRole } from '@/contexts/RoleContext';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
const ROLES: ConsoleRole[] = ['sales', 'engineering', 'hr', 'finance', 'executive'];
|
||||
const STORAGE_KEY = 'sgrobot-role-preferences';
|
||||
|
||||
type RolePreferences = {
|
||||
workStart: string;
|
||||
workEnd: string;
|
||||
notification: 'system' | 'email' | 'silent';
|
||||
reportTime: string;
|
||||
avatarStyle: 'professional' | 'friendly' | 'concise';
|
||||
reminderLevel: 'important' | 'all' | 'quiet';
|
||||
};
|
||||
|
||||
const defaultPreferences: RolePreferences = {
|
||||
workStart: '09:00',
|
||||
workEnd: '18:00',
|
||||
notification: 'system',
|
||||
reportTime: '17:30',
|
||||
avatarStyle: 'professional',
|
||||
reminderLevel: 'important',
|
||||
};
|
||||
|
||||
const densityLabel: Record<string, string> = {
|
||||
compact: '紧凑',
|
||||
balanced: '均衡',
|
||||
presentation: '汇报',
|
||||
};
|
||||
|
||||
const fieldGridStyle: CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const fieldStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
border: '1px solid var(--de-glass-border-lighter)',
|
||||
borderRadius: 10,
|
||||
padding: '12px 14px',
|
||||
background: 'linear-gradient(180deg, rgba(244,250,255,0.82), rgba(235,246,255,0.58))',
|
||||
};
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
minHeight: 34,
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--de-glass-border-light)',
|
||||
background: 'rgba(255,255,255,0.72)',
|
||||
color: 'var(--de-text-primary)',
|
||||
padding: '0 10px',
|
||||
};
|
||||
|
||||
function loadPreferences(): RolePreferences {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return defaultPreferences;
|
||||
return { ...defaultPreferences, ...JSON.parse(raw) };
|
||||
} catch {
|
||||
return defaultPreferences;
|
||||
}
|
||||
}
|
||||
|
||||
export default function OpsSettings() {
|
||||
const { role, profile, setRole } = useRole();
|
||||
const [preferences, setPreferences] = useState<RolePreferences>(loadPreferences);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { if (savedTimerRef.current) clearTimeout(savedTimerRef.current); };
|
||||
}, []);
|
||||
|
||||
const markSaved = () => {
|
||||
setSaved(true);
|
||||
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
|
||||
savedTimerRef.current = setTimeout(() => setSaved(false), 2000);
|
||||
};
|
||||
|
||||
const savePreferences = (next: RolePreferences) => {
|
||||
setPreferences(next);
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch { /* localStorage blocked */ }
|
||||
markSaved();
|
||||
};
|
||||
|
||||
const updatePreference = <K extends keyof RolePreferences>(key: K, value: RolePreferences[K]) => {
|
||||
savePreferences({ ...preferences, [key]: value });
|
||||
};
|
||||
|
||||
const handleRoleChange = (nextRole: ConsoleRole) => {
|
||||
setRole(nextRole);
|
||||
markSaved();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '14px 18px' }}>
|
||||
<GlassCard>
|
||||
<CardTitleBar title="角色偏好">
|
||||
{saved && <span className="de-card-meta-text">已保存</span>}
|
||||
</CardTitleBar>
|
||||
<div className="de-card-body">
|
||||
<section className="de-settings-section">
|
||||
<h3 className="de-settings-heading">数字员工角色</h3>
|
||||
<p className="de-settings-desc">选择日常协作身份,数字员工会按你的岗位重点调整提醒和汇总口径。</p>
|
||||
<div className="de-role-grid">
|
||||
{ROLES.map((item) => (
|
||||
<button key={item} className={`de-role-card ${role === item ? 'active' : ''}`} onClick={() => handleRoleChange(item)}>
|
||||
<span className="de-role-label">{t(ROLE_PROFILES[item].labelKey)}</span>
|
||||
<span className="de-role-density">{densityLabel[ROLE_PROFILES[item].density] ?? '均衡'}视图</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="de-settings-section">
|
||||
<h3 className="de-settings-heading">工作偏好</h3>
|
||||
<div style={fieldGridStyle}>
|
||||
<label style={fieldStyle}>
|
||||
<span className="de-role-label">工作开始时间</span>
|
||||
<input style={inputStyle} type="time" value={preferences.workStart} onChange={(event) => updatePreference('workStart', event.target.value)} />
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span className="de-role-label">工作结束时间</span>
|
||||
<input style={inputStyle} type="time" value={preferences.workEnd} onChange={(event) => updatePreference('workEnd', event.target.value)} />
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span className="de-role-label">默认日报时间</span>
|
||||
<input style={inputStyle} type="time" value={preferences.reportTime} onChange={(event) => updatePreference('reportTime', event.target.value)} />
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span className="de-role-label">通知方式</span>
|
||||
<select style={inputStyle} value={preferences.notification} onChange={(event) => updatePreference('notification', event.target.value as RolePreferences['notification'])}>
|
||||
<option value="system">系统通知</option>
|
||||
<option value="email">邮件提醒</option>
|
||||
<option value="silent">只在页面显示</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span className="de-role-label">数字员工形象偏好</span>
|
||||
<select style={inputStyle} value={preferences.avatarStyle} onChange={(event) => updatePreference('avatarStyle', event.target.value as RolePreferences['avatarStyle'])}>
|
||||
<option value="professional">专业稳重</option>
|
||||
<option value="friendly">亲和协作</option>
|
||||
<option value="concise">简洁高效</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span className="de-role-label">任务提醒偏好</span>
|
||||
<select style={inputStyle} value={preferences.reminderLevel} onChange={(event) => updatePreference('reminderLevel', event.target.value as RolePreferences['reminderLevel'])}>
|
||||
<option value="important">只提醒重要事项</option>
|
||||
<option value="all">所有进展都提醒</option>
|
||||
<option value="quiet">尽量少打扰</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details className="de-settings-section" style={{ marginBottom: 0 }}>
|
||||
<summary className="de-settings-heading" style={{ cursor: 'pointer' }}>高级技术设置</summary>
|
||||
<p className="de-settings-desc">默认隐藏,仅供技术人员排查使用。当前角色刷新阈值:{Math.round(profile.staleDataThresholdMs / 1000)} 秒。</p>
|
||||
</details>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import GlassCard from '@/components/digital/GlassCard';
|
||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||
import { adaptSkillSummaries, type SkillSummary } from '@/lib/consoleDataAdapter';
|
||||
import { getSkills, setSkillEnabled } from '@/lib/api';
|
||||
import { getDemoSkills } from './demoScenario';
|
||||
import { domSafeId } from './navigation';
|
||||
|
||||
type TaskCadence = 'daily' | 'weekly' | 'monthly' | 'adhoc';
|
||||
type SkillSection = {
|
||||
key: string;
|
||||
title: string;
|
||||
hint: string;
|
||||
skills: SkillSummary[];
|
||||
emptyText: string;
|
||||
};
|
||||
|
||||
const cadenceOptions: Array<{ value: 'all' | TaskCadence; label: string }> = [
|
||||
{ value: 'all', label: '全部任务' },
|
||||
{ value: 'daily', label: '日常任务' },
|
||||
{ value: 'weekly', label: '周任务' },
|
||||
{ value: 'monthly', label: '月度任务' },
|
||||
{ value: 'adhoc', label: '临时任务' },
|
||||
];
|
||||
|
||||
const cadenceLabels: Record<TaskCadence, string> = {
|
||||
daily: '日常',
|
||||
weekly: '周',
|
||||
monthly: '月度',
|
||||
adhoc: '临时',
|
||||
};
|
||||
|
||||
function getCadence(skill: SkillSummary): TaskCadence {
|
||||
const text = `${skill.id} ${skill.name} ${skill.description} ${skill.category}`.toLowerCase();
|
||||
if (/weekly|week|周/.test(text)) return 'weekly';
|
||||
if (/monthly|month|月报|月度/.test(text)) return 'monthly';
|
||||
if (/monitor|crawler|巡检|监控|待办|日程|日报|日终|收件箱|工作台|客户跟进/.test(text)) return 'daily';
|
||||
return 'adhoc';
|
||||
}
|
||||
|
||||
function isAdvancedSkill(skill: SkillSummary): boolean {
|
||||
const text = `${skill.id} ${skill.name} ${skill.description} ${skill.category}`.toLowerCase();
|
||||
return (
|
||||
skill.capabilityLevel === 'specialized' ||
|
||||
skill.capabilityLevel === 'experimental' ||
|
||||
/加密|深度|分析|自动化|部署|代码|审查|调研|批量|converter|crypto|github|deploy|review|research/.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function isRecommendedSkill(skill: SkillSummary): boolean {
|
||||
const text = `${skill.id} ${skill.name} ${skill.description} ${skill.category}`.toLowerCase();
|
||||
return /日报|周报|报表|待办|日程|巡检|客户|知识库|经营|费用|收件箱|工作台/.test(text);
|
||||
}
|
||||
|
||||
function getOutcome(skill: SkillSummary): string {
|
||||
const name = skill.name;
|
||||
const text = `${skill.id} ${skill.name} ${skill.description} ${skill.category}`.toLowerCase();
|
||||
if (/report|报表|日报|周报|月报|汇报/.test(text)) return `自动整理${name}材料,减少手工汇总和重复填报。`;
|
||||
if (/monitor|巡检|监控|预警/.test(text)) return `按约定频率完成${name},及时提示异常和遗漏。`;
|
||||
if (/todo|待办|task|看板|日程/.test(text)) return `帮你梳理${name},把后续处理事项集中呈现。`;
|
||||
if (/客户|crm|回访|满意/.test(text)) return `汇总客户相关信息,形成可直接跟进的处理建议。`;
|
||||
if (/知识|文档|制度|模板/.test(text)) return `查找和核对资料内容,减少人工翻找和版本确认。`;
|
||||
return skill.description;
|
||||
}
|
||||
|
||||
function learningStatus(skill: SkillSummary, recommended: boolean, advanced: boolean): string {
|
||||
if (skill.enabled) return '已学习';
|
||||
if (!skill.enabled) return '暂不使用';
|
||||
if (advanced) return '需确认后学习';
|
||||
if (recommended) return '建议学习';
|
||||
return '可学习';
|
||||
}
|
||||
|
||||
function SkillCard({
|
||||
skill,
|
||||
recommended = false,
|
||||
advanced = false,
|
||||
selected = false,
|
||||
updating = false,
|
||||
onSelect,
|
||||
onToggle,
|
||||
}: {
|
||||
skill: SkillSummary;
|
||||
recommended?: boolean;
|
||||
advanced?: boolean;
|
||||
selected?: boolean;
|
||||
updating?: boolean;
|
||||
onSelect?: (skill: SkillSummary) => void;
|
||||
onToggle?: (skill: SkillSummary) => void;
|
||||
}) {
|
||||
const cadence = getCadence(skill);
|
||||
const status = learningStatus(skill, recommended, advanced);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`skill-card-${domSafeId(skill.id)}`}
|
||||
className={`de-skill-card ${selected ? 'de-skill-card--selected' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect?.(skill)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onSelect?.(skill);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="de-skill-card-head">
|
||||
<span className="de-skill-name">{skill.name}</span>
|
||||
<span className={`de-skill-status ${skill.enabled ? 'enabled' : 'disabled'}`}>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="de-skill-desc">{getOutcome(skill)}</p>
|
||||
<div className="de-skill-card-foot">
|
||||
<span className="de-skill-version">
|
||||
适用任务:{cadenceLabels[cadence]}
|
||||
</span>
|
||||
<button
|
||||
className={`de-skill-btn ${skill.enabled ? 'de-skill-btn--danger' : ''}`}
|
||||
disabled={updating}
|
||||
title={skill.enabled ? '暂时停用此技能,后续路由不会选择它。' : '恢复使用此技能。'}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!updating) onToggle?.(skill);
|
||||
}}
|
||||
>
|
||||
{updating ? '处理中...' : skill.enabled ? '暂不使用' : '恢复使用'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string | null }) {
|
||||
const [skills, setSkills] = useState<SkillSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [cadenceFilter, setCadenceFilter] = useState<'all' | TaskCadence>('all');
|
||||
const [selectedSkillId, setSelectedSkillId] = useState<string | null>(focusSkillId ?? null);
|
||||
const [updatingSkillId, setUpdatingSkillId] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const mountedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => { mountedRef.current = false; };
|
||||
}, []);
|
||||
|
||||
const fetchSkills = useCallback(async () => {
|
||||
setLoading(true); setError(false);
|
||||
try {
|
||||
const data = await getSkills();
|
||||
if (!mountedRef.current) return;
|
||||
setSkills(
|
||||
Array.isArray(data) && data.length > 0
|
||||
? adaptSkillSummaries(data as Record<string, unknown>[])
|
||||
: getDemoSkills(),
|
||||
);
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return;
|
||||
console.error('[SkillLibrary] fetch failed:', err);
|
||||
setSkills(getDemoSkills());
|
||||
setError(false);
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchSkills(); }, [fetchSkills]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusSkillId) return;
|
||||
setSelectedSkillId(focusSkillId);
|
||||
setCadenceFilter('all');
|
||||
setSearch('');
|
||||
window.setTimeout(() => {
|
||||
document.getElementById(`skill-card-${domSafeId(focusSkillId)}`)?.scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, 80);
|
||||
}, [focusSkillId, skills.length]);
|
||||
|
||||
const toggleSkill = useCallback(async (skill: SkillSummary) => {
|
||||
const nextEnabled = !skill.enabled;
|
||||
setUpdatingSkillId(skill.id);
|
||||
setActionError(null);
|
||||
try {
|
||||
const result = await setSkillEnabled(skill.id, nextEnabled);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error || '技能状态变更失败');
|
||||
}
|
||||
setSkills((current) => current.map((item) => (
|
||||
item.id === skill.id ? { ...item, enabled: nextEnabled } : item
|
||||
)));
|
||||
setSelectedSkillId(skill.id);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : '技能状态变更失败');
|
||||
} finally {
|
||||
setUpdatingSkillId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const filtered = skills
|
||||
.filter(s => cadenceFilter === 'all' || getCadence(s) === cadenceFilter)
|
||||
.filter(s => !search || s.name.includes(search) || s.description.includes(search) || s.category.includes(search));
|
||||
|
||||
const advancedSkills = filtered.filter(isAdvancedSkill);
|
||||
const advancedIds = new Set(advancedSkills.map(s => s.id));
|
||||
const learnedSkills = filtered.filter(s => s.enabled && !advancedIds.has(s.id));
|
||||
const recommendedPool = filtered.filter(s => !s.enabled && isRecommendedSkill(s) && !isAdvancedSkill(s));
|
||||
const baseLearnableSkills = filtered.filter(s => !s.enabled && !isAdvancedSkill(s));
|
||||
const recommendedSkills = (recommendedPool.length > 0 ? recommendedPool : baseLearnableSkills).slice(0, 4);
|
||||
const recommendedIds = new Set(recommendedSkills.map(s => s.id));
|
||||
const learnableSkills = baseLearnableSkills.filter(s => !recommendedIds.has(s.id));
|
||||
const sections: SkillSection[] = [
|
||||
{
|
||||
key: 'recommended',
|
||||
title: '推荐学习',
|
||||
hint: '优先补齐高频工作能力,适合让数字员工尽快接手重复事务。',
|
||||
skills: recommendedSkills,
|
||||
emptyText: '暂无推荐学习项。',
|
||||
},
|
||||
{
|
||||
key: 'learnable',
|
||||
title: '可学习技能',
|
||||
hint: '可纳入数字员工日常工作的业务能力,学习入口待接入后启用。',
|
||||
skills: learnableSkills,
|
||||
emptyText: '当前没有可学习技能。',
|
||||
},
|
||||
{
|
||||
key: 'learned',
|
||||
title: '已学技能',
|
||||
hint: '数字员工已经掌握并可被业务场景调用的能力。',
|
||||
skills: learnedSkills,
|
||||
emptyText: '当前还没有已学习技能。',
|
||||
},
|
||||
{
|
||||
key: 'advanced',
|
||||
title: '高级技能',
|
||||
hint: '适合有明确边界或需要确认后再交给数字员工处理的能力。',
|
||||
skills: advancedSkills,
|
||||
emptyText: '暂无高级技能。',
|
||||
},
|
||||
];
|
||||
|
||||
const renderSection = (section: SkillSection) => (
|
||||
<section key={section.key} style={{ display: 'grid', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, color: 'var(--de-text-primary)' }}>{section.title}</h3>
|
||||
<p style={{ margin: '4px 0 0', fontSize: 12, color: 'var(--de-text-muted)' }}>{section.hint}</p>
|
||||
</div>
|
||||
<span className="de-capability-badge de-capability-standard">{section.skills.length} 项</span>
|
||||
</div>
|
||||
{section.skills.length === 0 ? (
|
||||
<p className="de-empty-text" style={{ margin: 0 }}>{section.emptyText}</p>
|
||||
) : (
|
||||
<div className="de-skill-grid">
|
||||
{section.skills.map(s => (
|
||||
<SkillCard
|
||||
key={`${section.key}-${s.id}`}
|
||||
skill={s}
|
||||
recommended={section.key === 'recommended'}
|
||||
advanced={section.key === 'advanced'}
|
||||
selected={selectedSkillId === s.id}
|
||||
updating={updatingSkillId === s.id}
|
||||
onSelect={(skill) => setSelectedSkillId(skill.id)}
|
||||
onToggle={toggleSkill}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '14px 18px' }}>
|
||||
<GlassCard>
|
||||
<CardTitleBar title="学习技能">
|
||||
<button onClick={fetchSkills} className="de-btn-secondary de-btn-sm">刷新</button>
|
||||
</CardTitleBar>
|
||||
<div className="de-card-body">
|
||||
{actionError && (
|
||||
<div className="de-schedule-result de-schedule-result--error" style={{ marginBottom: 12 }}>
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
<div className="de-skill-toolbar">
|
||||
<input type="text" className="de-input" placeholder="搜索业务能力..." value={search} onChange={e => setSearch(e.target.value)} />
|
||||
<div className="de-filter-row">
|
||||
{cadenceOptions.map(option => (
|
||||
<button key={option.value} className={`de-filter-chip ${cadenceFilter === option.value ? 'active' : ''}`} onClick={() => setCadenceFilter(option.value)}>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="de-loading-skeleton" style={{ height: 300 }} />
|
||||
) : error ? (
|
||||
<p className="de-empty-text">服务暂不可用,已保留本地示例供预览。</p>
|
||||
) : filtered.length === 0 && skills.length > 0 ? (
|
||||
<p className="de-empty-text">无匹配技能,请调整搜索或筛选条件</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="de-empty-text">暂无可展示的学习技能。</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 18 }}>
|
||||
{sections.map(renderSection)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import type { PlanMessage } from '@/lib/api';
|
||||
import type {
|
||||
DashboardOverview,
|
||||
JournalEntry,
|
||||
Mission,
|
||||
MissionStep,
|
||||
SkillSummary,
|
||||
} from '@/lib/consoleDataAdapter';
|
||||
import type { DebugPlan, DebugRun, DebugTask, FlowResponse } from '@/types/api';
|
||||
|
||||
function todayAt(hour: number, minute: number): string {
|
||||
const date = new Date();
|
||||
date.setHours(hour, minute, 0, 0);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function makeTask(
|
||||
missionId: string,
|
||||
stepId: string,
|
||||
title: string,
|
||||
status: string,
|
||||
skillId: string,
|
||||
startedAt: string | null,
|
||||
finishedAt: string | null,
|
||||
): MissionStep['tasks'][number] {
|
||||
return {
|
||||
id: `${missionId}-${stepId}-task`,
|
||||
title,
|
||||
status,
|
||||
assignedSkillId: skillId,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMission(
|
||||
id: string,
|
||||
title: string,
|
||||
objective: string,
|
||||
status: string,
|
||||
updatedAt: string,
|
||||
steps: MissionStep[],
|
||||
): Mission {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
objective,
|
||||
status,
|
||||
createdAt: todayAt(8, 0),
|
||||
updatedAt,
|
||||
steps,
|
||||
sourceIds: { planId: id, taskIds: steps.flatMap((step) => step.tasks.map((task) => task.id)) },
|
||||
};
|
||||
}
|
||||
|
||||
const DEMO_SKILLS: SkillSummary[] = [
|
||||
{ id: 'whitecollar-day-browser-portal-check', name: '工作台巡检', version: '1.0.0', enabled: true, description: '检查工作台首页、待办数量和重点入口是否可用。', category: '办公', capabilityLevel: 'standard', source: 'skill-api' },
|
||||
{ id: 'whitecollar-day-browser-inbox-triage', name: '收件箱分拣', version: '1.0.0', enabled: true, description: '读取邮件和通知列表,按紧急程度整理待办。', category: '通信', capabilityLevel: 'standard', source: 'skill-api' },
|
||||
{ id: 'whitecollar-day-calendar-brief', name: '日程简报', version: '1.0.0', enabled: true, description: '生成当日会议、回访和报表节点的时间提醒。', category: '办公', capabilityLevel: 'standard', source: 'skill-api' },
|
||||
{ id: 'whitecollar-day-browser-crm-followup', name: '客户跟进', version: '1.0.0', enabled: true, description: '读取客户跟进页面,形成回访摘要和下一步建议。', category: '营销', capabilityLevel: 'specialized', source: 'skill-api' },
|
||||
{ id: 'whitecollar-day-browser-expense-approval', name: '费用审批核对', version: '1.0.0', enabled: true, description: '检查待审批费用单,筛出异常和超限项。', category: '财务', capabilityLevel: 'specialized', source: 'skill-api' },
|
||||
{ id: 'whitecollar-day-sales-snapshot', name: '经营快照', version: '1.0.0', enabled: true, description: '汇总客户、项目和当日经营看板。', category: '报表', capabilityLevel: 'standard', source: 'skill-api' },
|
||||
{ id: 'whitecollar-day-report-export', name: '报表导出', version: '1.0.0', enabled: true, description: '整理并导出面向领导汇报的简版报表。', category: '报表', capabilityLevel: 'standard', source: 'skill-api' },
|
||||
{ id: 'whitecollar-day-browser-knowledge-check', name: '知识库核查', version: '1.0.0', enabled: true, description: '检索知识库资料,确认制度和模板是否最新。', category: '数字化', capabilityLevel: 'standard', source: 'skill-api' },
|
||||
{ id: 'whitecollar-day-task-board-sync', name: '任务看板同步', version: '1.0.0', enabled: true, description: '把今日处理结果同步到任务看板和协同清单。', category: '办公', capabilityLevel: 'standard', source: 'skill-api' },
|
||||
{ id: 'whitecollar-day-browser-day-close', name: '日终收口', version: '1.0.0', enabled: true, description: '汇总今日动作、风险和明日重点。', category: '办公', capabilityLevel: 'specialized', source: 'skill-api' },
|
||||
];
|
||||
|
||||
const DEMO_MISSIONS: Mission[] = [
|
||||
makeMission(
|
||||
'whitecollar-day',
|
||||
'白领一天端到端验收',
|
||||
'模拟业务人员一天内通过数字员工完成网页采集、客户回访、资料整理和日报输出。',
|
||||
'active',
|
||||
todayAt(10, 35),
|
||||
[
|
||||
{ id: 'contact', title: '读取供应商联系人', status: 'completed', preferredSkills: ['whitecollar-day-legacy-contact'], tasks: [makeTask('whitecollar-day', 'contact', '整理联系人名单', 'completed', 'whitecollar-day-legacy-contact', todayAt(8, 30), todayAt(8, 33))] },
|
||||
{ id: 'news', title: '读取行业动态', status: 'completed', preferredSkills: ['whitecollar-day-legacy-news'], tasks: [makeTask('whitecollar-day', 'news', '整理行业动态摘要', 'completed', 'whitecollar-day-legacy-news', todayAt(8, 35), todayAt(8, 41))] },
|
||||
{ id: 'form', title: '预填客户回访登记', status: 'completed', preferredSkills: ['whitecollar-day-legacy-form'], tasks: [makeTask('whitecollar-day', 'form', '填充回访登记表', 'completed', 'whitecollar-day-legacy-form', todayAt(8, 45), todayAt(8, 51))] },
|
||||
{ id: 'compare', title: '比对设备方案', status: 'running', preferredSkills: ['whitecollar-day-legacy-compare'], tasks: [makeTask('whitecollar-day', 'compare', '生成方案差异结论', 'running', 'whitecollar-day-legacy-compare', todayAt(10, 28), null)] },
|
||||
{ id: 'todos', title: '读取待办系统', status: 'pending', preferredSkills: ['whitecollar-day-legacy-todos'], tasks: [makeTask('whitecollar-day', 'todos', '汇总个人待办', 'pending', 'whitecollar-day-legacy-todos', null, null)] },
|
||||
{ id: 'customers', title: '读取客户清单', status: 'pending', preferredSkills: ['whitecollar-day-legacy-customers'], tasks: [makeTask('whitecollar-day', 'customers', '提取重点客户', 'pending', 'whitecollar-day-legacy-customers', null, null)] },
|
||||
{ id: 'download', title: '识别最新资料下载链接', status: 'pending', preferredSkills: ['whitecollar-day-legacy-download'], tasks: [makeTask('whitecollar-day', 'download', '识别最新制度材料', 'pending', 'whitecollar-day-legacy-download', null, null)] },
|
||||
{ id: 'email', title: '生成客户跟进邮件', status: 'pending', preferredSkills: ['whitecollar-day-legacy-email'], tasks: [makeTask('whitecollar-day', 'email', '生成回访邮件草稿', 'pending', 'whitecollar-day-legacy-email', null, null)] },
|
||||
{ id: 'daily-report', title: '生成工作日报', status: 'pending', preferredSkills: ['whitecollar-day-legacy-daily-report'], tasks: [makeTask('whitecollar-day', 'daily-report', '生成工作日报', 'pending', 'whitecollar-day-legacy-daily-report', null, null)] },
|
||||
{ id: 'tomorrow-plan', title: '生成明日计划', status: 'pending', preferredSkills: ['whitecollar-day-legacy-tomorrow-plan'], tasks: [makeTask('whitecollar-day', 'tomorrow-plan', '整理明日计划', 'pending', 'whitecollar-day-legacy-tomorrow-plan', null, null)] },
|
||||
],
|
||||
),
|
||||
makeMission('whitecollar-day-0830-browser-portal-check', '白领一天 08:30 工作台巡检', '巡检工作台首页、待办数量和关键入口状态。', 'completed', todayAt(8, 34), [
|
||||
{ id: 'browser-portal-check', title: '浏览器工作台巡检', status: 'completed', preferredSkills: ['whitecollar-day-browser-portal-check'], tasks: [makeTask('whitecollar-day-0830-browser-portal-check', 'browser-portal-check', '检查门户首页与入口', 'completed', 'whitecollar-day-browser-portal-check', todayAt(8, 30), todayAt(8, 34))] },
|
||||
]),
|
||||
makeMission('whitecollar-day-0900-browser-inbox-triage', '白领一天 09:00 收件箱分拣', '识别高优先级通知并形成今日待办。', 'completed', todayAt(9, 6), [
|
||||
{ id: 'browser-inbox-triage', title: '浏览器收件箱分拣', status: 'completed', preferredSkills: ['whitecollar-day-browser-inbox-triage'], tasks: [makeTask('whitecollar-day-0900-browser-inbox-triage', 'browser-inbox-triage', '分拣通知与邮件', 'completed', 'whitecollar-day-browser-inbox-triage', todayAt(9, 0), todayAt(9, 6))] },
|
||||
]),
|
||||
makeMission('whitecollar-day-0930-calendar-brief', '白领一天 09:30 日程简报', '整理会议、回访与汇报节点,形成上午排程。', 'completed', todayAt(9, 32), [
|
||||
{ id: 'calendar-brief', title: '生成日程简报', status: 'completed', preferredSkills: ['whitecollar-day-calendar-brief'], tasks: [makeTask('whitecollar-day-0930-calendar-brief', 'calendar-brief', '整理今日日程', 'completed', 'whitecollar-day-calendar-brief', todayAt(9, 30), todayAt(9, 32))] },
|
||||
]),
|
||||
makeMission('whitecollar-day-1030-browser-crm-followup', '白领一天 10:30 客户跟进', '汇总重点客户情况,生成下一步回访建议。', 'running', todayAt(10, 35), [
|
||||
{ id: 'browser-crm-followup', title: '浏览器客户跟进', status: 'running', preferredSkills: ['whitecollar-day-browser-crm-followup'], tasks: [makeTask('whitecollar-day-1030-browser-crm-followup', 'browser-crm-followup', '提取客户回访摘要', 'running', 'whitecollar-day-browser-crm-followup', todayAt(10, 30), null)] },
|
||||
]),
|
||||
makeMission('whitecollar-day-1130-browser-expense-approval', '白领一天 11:30 费用审批核对', '检查待审批单据并输出异常说明。', 'approved', todayAt(11, 20), [
|
||||
{ id: 'browser-expense-approval', title: '浏览器费用审批核对', status: 'pending', preferredSkills: ['whitecollar-day-browser-expense-approval'], tasks: [makeTask('whitecollar-day-1130-browser-expense-approval', 'browser-expense-approval', '核对费用审批单', 'pending', 'whitecollar-day-browser-expense-approval', null, null)] },
|
||||
]),
|
||||
makeMission('whitecollar-day-1330-sales-snapshot', '白领一天 13:30 经营快照', '汇总客户、项目和经营看板,形成午后快照。', 'draft', todayAt(13, 20), [
|
||||
{ id: 'sales-snapshot', title: '生成经营快照', status: 'pending', preferredSkills: ['whitecollar-day-sales-snapshot'], tasks: [makeTask('whitecollar-day-1330-sales-snapshot', 'sales-snapshot', '整理经营快照', 'pending', 'whitecollar-day-sales-snapshot', null, null)] },
|
||||
]),
|
||||
makeMission('whitecollar-day-1430-report-export', '白领一天 14:30 报表导出', '导出领导汇报所需的简版统计报表。', 'draft', todayAt(14, 10), [
|
||||
{ id: 'report-export', title: '导出汇报报表', status: 'pending', preferredSkills: ['whitecollar-day-report-export'], tasks: [makeTask('whitecollar-day-1430-report-export', 'report-export', '导出统计报表', 'pending', 'whitecollar-day-report-export', null, null)] },
|
||||
]),
|
||||
makeMission('whitecollar-day-1530-browser-knowledge-check', '白领一天 15:30 知识库核查', '核查制度、模板与常见问题资料是否最新。', 'draft', todayAt(15, 15), [
|
||||
{ id: 'browser-knowledge-check', title: '浏览器知识库核查', status: 'pending', preferredSkills: ['whitecollar-day-browser-knowledge-check'], tasks: [makeTask('whitecollar-day-1530-browser-knowledge-check', 'browser-knowledge-check', '核查知识库资料', 'pending', 'whitecollar-day-browser-knowledge-check', null, null)] },
|
||||
]),
|
||||
makeMission('whitecollar-day-1630-task-board-sync', '白领一天 16:30 任务看板同步', '把今日处理结果同步到看板和协同清单。', 'draft', todayAt(16, 10), [
|
||||
{ id: 'task-board-sync', title: '同步任务看板', status: 'pending', preferredSkills: ['whitecollar-day-task-board-sync'], tasks: [makeTask('whitecollar-day-1630-task-board-sync', 'task-board-sync', '同步任务状态', 'pending', 'whitecollar-day-task-board-sync', null, null)] },
|
||||
]),
|
||||
makeMission('whitecollar-day-1730-browser-day-close', '白领一天 17:30 日终收口', '汇总今日成果、风险和明日重点,形成日终总结。', 'draft', todayAt(17, 10), [
|
||||
{ id: 'task-board-sync', title: '同步任务看板', status: 'pending', preferredSkills: ['whitecollar-day-task-board-sync'], tasks: [makeTask('whitecollar-day-1730-browser-day-close', 'task-board-sync', '同步看板结果', 'pending', 'whitecollar-day-task-board-sync', null, null)] },
|
||||
{ id: 'browser-day-close', title: '浏览器日终收口', status: 'pending', preferredSkills: ['whitecollar-day-browser-day-close'], tasks: [makeTask('whitecollar-day-1730-browser-day-close', 'browser-day-close', '整理日终收口', 'pending', 'whitecollar-day-browser-day-close', null, null)] },
|
||||
]),
|
||||
makeMission('zhihu-hotlist-monitor', '知乎热榜采集', '通过计划模板调用知乎热榜采集技能,导出表格文件并生成当天热点摘要。', 'approved', todayAt(10, 12), [
|
||||
{ id: 'collect-zhihu', title: '采集知乎热榜', status: 'pending', preferredSkills: ['zhihu-hotlist'], tasks: [makeTask('zhihu-hotlist-monitor', 'collect-zhihu', '采集知乎热榜', 'pending', 'zhihu-hotlist', null, null)] },
|
||||
{ id: 'export-xlsx', title: '导出表格文件', status: 'pending', preferredSkills: ['office-export-xlsx'], tasks: [makeTask('zhihu-hotlist-monitor', 'export-xlsx', '导出热榜表格', 'pending', 'office-export-xlsx', null, null)] },
|
||||
]),
|
||||
];
|
||||
|
||||
function cloneMission(mission: Mission): Mission {
|
||||
return {
|
||||
...mission,
|
||||
steps: mission.steps.map((step) => ({
|
||||
...step,
|
||||
preferredSkills: [...step.preferredSkills],
|
||||
tasks: step.tasks.map((task) => ({ ...task })),
|
||||
})),
|
||||
sourceIds: { planId: mission.sourceIds.planId, taskIds: [...mission.sourceIds.taskIds] },
|
||||
};
|
||||
}
|
||||
|
||||
export function getDemoMissions(): Mission[] {
|
||||
return DEMO_MISSIONS.map(cloneMission);
|
||||
}
|
||||
|
||||
export function getDemoSkills(): SkillSummary[] {
|
||||
return DEMO_SKILLS.map((skill) => ({ ...skill }));
|
||||
}
|
||||
|
||||
export function buildDemoTasks(missions: Mission[]): DebugTask[] {
|
||||
return missions.flatMap((mission) => mission.steps.flatMap((step) => step.tasks.map((task) => ({
|
||||
task_id: task.id,
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
plan_id: mission.id,
|
||||
step_id: step.id,
|
||||
assigned_skill_id: task.assignedSkillId || '',
|
||||
description: `${mission.title} · ${step.title}`,
|
||||
created_at: mission.createdAt || todayAt(8, 0),
|
||||
updated_at: mission.updatedAt || mission.createdAt || todayAt(8, 0),
|
||||
started_at: task.startedAt,
|
||||
finished_at: task.finishedAt,
|
||||
}))));
|
||||
}
|
||||
|
||||
export function buildDemoRuns(tasks: DebugTask[]): DebugRun[] {
|
||||
return tasks
|
||||
.filter((task) => ['completed', 'running'].includes(task.status))
|
||||
.map((task, index) => ({
|
||||
run_id: `demo-run-${index + 1}`,
|
||||
task_id: task.task_id,
|
||||
plan_id: task.plan_id,
|
||||
attempt_no: 1,
|
||||
lifecycle: task.status === 'running' ? 'Running' : 'Succeeded',
|
||||
created_at: task.started_at || task.created_at,
|
||||
completed_at: task.finished_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildDemoOverview(tasks: DebugTask[]): DashboardOverview {
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
isPartial: true,
|
||||
sources: [{ endpoint: 'digital-demo', ok: true, loadedAt: new Date().toISOString() }],
|
||||
taskTotals: {
|
||||
total: tasks.length,
|
||||
queued: tasks.filter((task) => task.status === 'pending').length,
|
||||
running: tasks.filter((task) => task.status === 'running').length,
|
||||
succeeded: tasks.filter((task) => task.status === 'completed').length,
|
||||
failed: 0,
|
||||
},
|
||||
approvals: { total: 1, pending: 1 },
|
||||
health: { activeTasks: tasks.filter((task) => task.status === 'running').length, failedTasks: 0, pendingConfirmations: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDemoPlans(missions: Mission[]): DebugPlan[] {
|
||||
return missions.map((mission) => ({
|
||||
plan_id: mission.id,
|
||||
title: mission.title,
|
||||
objective: mission.objective,
|
||||
status: mission.status,
|
||||
created_at: mission.createdAt || todayAt(8, 0),
|
||||
steps: mission.steps.map((step) => ({
|
||||
step_id: step.id,
|
||||
title: step.title,
|
||||
depends_on: [],
|
||||
preferred_skills: step.preferredSkills,
|
||||
tasks: step.tasks.map((task) => ({
|
||||
task_id: task.id,
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildDemoJournalEntries(missions: Mission[]): JournalEntry[] {
|
||||
return missions
|
||||
.slice(0, 8)
|
||||
.map((mission, index) => ({
|
||||
id: `demo-journal-${index + 1}`,
|
||||
kind: mission.status === 'completed' ? 'completed' : mission.status === 'active' || mission.status === 'running' ? 'start' : 'pending_authorization',
|
||||
source: 'digital-demo',
|
||||
message: `${mission.title}:${mission.objective}`,
|
||||
occurredAt: mission.updatedAt,
|
||||
planId: mission.id,
|
||||
taskId: mission.sourceIds.taskIds[0] || null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildDemoFlowEvents(mission: Mission): NonNullable<FlowResponse['events']> {
|
||||
const activeStep = mission.steps.find((step) => step.status === 'running') || mission.steps.find((step) => step.status === 'pending') || mission.steps[0];
|
||||
const events: NonNullable<FlowResponse['events']> = [
|
||||
{ event_id: `${mission.id}-event-1`, kind: 'entered_session', occurred_at: mission.createdAt || todayAt(8, 0), message: `已接收场景:${mission.title}` },
|
||||
{ event_id: `${mission.id}-event-2`, kind: 'start', occurred_at: mission.updatedAt || mission.createdAt || todayAt(8, 0), message: `当前目标:${mission.objective}` },
|
||||
];
|
||||
if (activeStep) {
|
||||
events.push({
|
||||
event_id: `${mission.id}-event-3`,
|
||||
kind: mission.status === 'completed' ? 'completed' : mission.status === 'approved' || mission.status === 'draft' ? 'pending_authorization' : 'RunStarted',
|
||||
occurred_at: mission.updatedAt || mission.createdAt || todayAt(8, 0),
|
||||
message: `${activeStep.title}`,
|
||||
});
|
||||
}
|
||||
if (mission.status === 'completed') {
|
||||
events.push({
|
||||
event_id: `${mission.id}-event-4`,
|
||||
kind: 'RunCompleted',
|
||||
occurred_at: mission.updatedAt || mission.createdAt || todayAt(8, 0),
|
||||
message: '已完成本场景执行并回写结果。',
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
export function buildDemoPlanMessages(mission: Mission): PlanMessage[] {
|
||||
const completedSteps = mission.steps.filter((step) => step.status === 'completed').length;
|
||||
const runningStep = mission.steps.find((step) => step.status === 'running');
|
||||
const closing =
|
||||
mission.status === 'completed'
|
||||
? `当前已完成全部 ${mission.steps.length} 个步骤,结果已整理完毕。`
|
||||
: runningStep
|
||||
? `当前正在处理“${runningStep.title}”,已完成 ${completedSteps} 个步骤。`
|
||||
: `当前场景已准备就绪,可直接发起执行。`;
|
||||
|
||||
return [
|
||||
{ role: 'user', content: `请执行场景:${mission.title}` },
|
||||
{ role: 'assistant', content: `已接收。业务目标:${mission.objective}` },
|
||||
{ role: 'assistant', content: closing },
|
||||
];
|
||||
}
|
||||
|
||||
export function runDemoMissionOnce(missions: Mission[], missionId: string): Mission[] {
|
||||
return missions.map((mission) => {
|
||||
if (mission.id !== missionId) return mission;
|
||||
return {
|
||||
...mission,
|
||||
status: 'running',
|
||||
updatedAt: new Date().toISOString(),
|
||||
steps: mission.steps.map((step, index) => ({
|
||||
...step,
|
||||
status: index === 0 ? 'running' : step.status === 'completed' ? 'completed' : 'pending',
|
||||
tasks: step.tasks.map((task) => ({
|
||||
...task,
|
||||
status: index === 0 ? 'running' : task.status === 'completed' ? 'completed' : 'pending',
|
||||
startedAt: index === 0 ? new Date().toISOString() : task.startedAt,
|
||||
finishedAt: index === 0 ? null : task.finishedAt,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function completeDemoMissionOnce(missions: Mission[], missionId: string): Mission[] {
|
||||
return missions.map((mission) => {
|
||||
if (mission.id !== missionId) return mission;
|
||||
return {
|
||||
...mission,
|
||||
status: 'completed',
|
||||
updatedAt: new Date().toISOString(),
|
||||
steps: mission.steps.map((step) => ({
|
||||
...step,
|
||||
status: 'completed',
|
||||
tasks: step.tasks.map((task) => ({
|
||||
...task,
|
||||
status: 'completed',
|
||||
startedAt: task.startedAt || new Date().toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type DigitalTab = 'home' | 'missions' | 'skills' | 'reports' | 'settings';
|
||||
|
||||
export type DigitalNavigationTarget = {
|
||||
tab: DigitalTab;
|
||||
date?: string | null;
|
||||
missionId?: string | null;
|
||||
taskId?: string | null;
|
||||
skillId?: string | null;
|
||||
reportSection?: string | null;
|
||||
};
|
||||
|
||||
export function domSafeId(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
export interface StatusResponse {
|
||||
provider: string | null;
|
||||
model: string;
|
||||
temperature: number;
|
||||
uptime_seconds: number;
|
||||
gateway_port: number;
|
||||
locale: string;
|
||||
memory_backend: string;
|
||||
paired: boolean;
|
||||
channels: Record<string, boolean>;
|
||||
health: HealthSnapshot;
|
||||
}
|
||||
|
||||
export interface HealthSnapshot {
|
||||
pid: number;
|
||||
updated_at: string;
|
||||
uptime_seconds: number;
|
||||
components: Record<string, ComponentHealth>;
|
||||
}
|
||||
|
||||
export interface ComponentHealth {
|
||||
status: string;
|
||||
updated_at: string;
|
||||
last_ok: string | null;
|
||||
last_error: string | null;
|
||||
restart_count: number;
|
||||
}
|
||||
|
||||
export interface ToolSpec {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: any;
|
||||
}
|
||||
|
||||
export interface CronJob {
|
||||
id: string;
|
||||
name: string | null;
|
||||
expression: string;
|
||||
command: string;
|
||||
prompt: string | null;
|
||||
job_type: string;
|
||||
schedule: unknown;
|
||||
enabled: boolean;
|
||||
delivery: unknown;
|
||||
template_id?: string | null;
|
||||
trigger_kind?: string | null;
|
||||
step_count?: number;
|
||||
skill_ids?: string[];
|
||||
delete_after_run: boolean;
|
||||
created_at: string;
|
||||
next_run: string;
|
||||
last_run: string | null;
|
||||
last_status: string | null;
|
||||
last_output: string | null;
|
||||
}
|
||||
|
||||
export interface CronRun {
|
||||
id: number;
|
||||
job_id: string;
|
||||
started_at: string;
|
||||
finished_at: string;
|
||||
status: string;
|
||||
output: string | null;
|
||||
duration_ms: number | null;
|
||||
}
|
||||
|
||||
export interface Integration {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
status: 'Available' | 'Active' | 'ComingSoon';
|
||||
}
|
||||
|
||||
export interface DiagResult {
|
||||
severity: 'ok' | 'warn' | 'error';
|
||||
category: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface MemoryEntry {
|
||||
id: string;
|
||||
key: string;
|
||||
content: string;
|
||||
category: string;
|
||||
timestamp: string;
|
||||
session_id: string | null;
|
||||
score: number | null;
|
||||
}
|
||||
|
||||
export interface CostSummary {
|
||||
session_cost_usd: number;
|
||||
daily_cost_usd: number;
|
||||
monthly_cost_usd: number;
|
||||
total_tokens: number;
|
||||
request_count: number;
|
||||
by_model: Record<string, ModelStats>;
|
||||
}
|
||||
|
||||
export interface ModelStats {
|
||||
model: string;
|
||||
cost_usd: number;
|
||||
total_tokens: number;
|
||||
request_count: number;
|
||||
}
|
||||
|
||||
export interface CliTool {
|
||||
name: string;
|
||||
path: string;
|
||||
version: string | null;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
session_id: string;
|
||||
kind: 'chat' | 'plan';
|
||||
created_at: string;
|
||||
last_activity: string;
|
||||
message_count: number;
|
||||
plan_id?: string | null;
|
||||
plan_title?: string | null;
|
||||
name?: string | null;
|
||||
id?: string;
|
||||
started_at?: string;
|
||||
channel?: string;
|
||||
status?: 'active' | 'idle' | 'closed';
|
||||
}
|
||||
|
||||
export interface SessionListResponse {
|
||||
sessions: Session[];
|
||||
}
|
||||
|
||||
export interface ChannelDetail {
|
||||
name: string;
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
status: 'active' | 'inactive' | 'error';
|
||||
message_count: number;
|
||||
last_message_at: string | null;
|
||||
health: 'healthy' | 'degraded' | 'down';
|
||||
}
|
||||
|
||||
export interface SSEEvent {
|
||||
type: string;
|
||||
timestamp?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface BrowserSession {
|
||||
session_id: string;
|
||||
target_url: string;
|
||||
status: 'launching' | 'running' | 'idle' | 'stopped' | 'error';
|
||||
started_at: string;
|
||||
pid?: number;
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
export interface UserNotification {
|
||||
notification_id: string;
|
||||
plan_id: string;
|
||||
task_id: string;
|
||||
kind: 'user_message' | 'need_input' | 'need_approval'
|
||||
| 'task_progress' | 'task_failed' | 'task_finished'
|
||||
| 'confirmation_pending' | 'confirmation_resolved';
|
||||
title: string;
|
||||
body: string;
|
||||
level: 'info' | 'warn' | 'error' | 'action';
|
||||
status: 'unread' | 'read' | 'dismissed' | 'resolved';
|
||||
correlation_id: string;
|
||||
created_at: string;
|
||||
read_at?: string | null;
|
||||
dismissed_at?: string | null;
|
||||
}
|
||||
|
||||
export type ChatRouteMode = 'auto' | 'chat' | 'direct' | 'plan';
|
||||
|
||||
export interface WsMessage {
|
||||
type: 'message' | 'chunk' | 'chunk_reset' | 'thinking' | 'tool_call' | 'tool_result' | 'done' | 'error' | 'plan_msg_ack' | 'plan_notification' | 'plan_need_input' | 'plan_need_approval' | 'notification' | 'session_start' | 'session_renamed' | 'connected' | 'plan_created' | 'direct_task_created' | 'ask' | 'governance';
|
||||
content?: string;
|
||||
full_response?: string;
|
||||
name?: string;
|
||||
args?: any;
|
||||
output?: string;
|
||||
message?: string;
|
||||
code?: string;
|
||||
level?: string;
|
||||
question?: string;
|
||||
task_id?: string;
|
||||
approval_id?: string;
|
||||
action?: string;
|
||||
plan_id?: string;
|
||||
session_id?: string;
|
||||
provisional?: boolean;
|
||||
instruction?: string;
|
||||
status?: string;
|
||||
title?: string;
|
||||
task_count?: number;
|
||||
notification?: UserNotification;
|
||||
response_kind?: string;
|
||||
route_decision?: RouteDecisionRecord;
|
||||
command?: unknown;
|
||||
created_plan_id?: string | null;
|
||||
created_schedule_id?: string | null;
|
||||
created_task_ids?: string[];
|
||||
created_run_ids?: string[];
|
||||
projection_name?: string | null;
|
||||
projection_payload?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface PlanTaskSummary {
|
||||
task_id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface PlanStepSummary {
|
||||
step_id: string;
|
||||
title: string;
|
||||
depends_on: string[];
|
||||
preferred_skills: string[];
|
||||
not_before?: string;
|
||||
tasks: PlanTaskSummary[];
|
||||
}
|
||||
|
||||
export interface PlanSummary {
|
||||
plan_id: string;
|
||||
title: string;
|
||||
objective: string;
|
||||
status: string;
|
||||
created_at?: string;
|
||||
steps: PlanStepSummary[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execution governance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type RouteDecisionRouteKind =
|
||||
| 'ChatOnly'
|
||||
| 'AskClarification'
|
||||
| 'ProjectionQuery'
|
||||
| 'PlanControl'
|
||||
| 'DirectRun'
|
||||
| 'PlanExecution'
|
||||
| 'RejectedByPolicy'
|
||||
| (string & {});
|
||||
|
||||
export type RouteDecisionIntentKind =
|
||||
| 'chat'
|
||||
| 'query'
|
||||
| 'control'
|
||||
| 'execute'
|
||||
| 'schedule'
|
||||
| 'amend'
|
||||
| 'approve'
|
||||
| 'recover'
|
||||
| 'reject'
|
||||
| 'Chat'
|
||||
| 'Query'
|
||||
| 'Control'
|
||||
| 'Execute'
|
||||
| 'Schedule'
|
||||
| 'Amend'
|
||||
| 'Approve'
|
||||
| 'Recover'
|
||||
| 'Reject'
|
||||
| (string & {});
|
||||
|
||||
export interface RouteDecisionRecord {
|
||||
id: string;
|
||||
route_kind: RouteDecisionRouteKind;
|
||||
intent_kind: RouteDecisionIntentKind;
|
||||
reason_codes: string[];
|
||||
candidate_skills: string[];
|
||||
context_refs: Record<string, unknown>;
|
||||
created_command_id: string | null;
|
||||
correlation_id: string;
|
||||
creates_plan: boolean;
|
||||
creates_task_run: boolean;
|
||||
approval_mode: string | null;
|
||||
policy_result: string | null;
|
||||
recorded_at: string;
|
||||
entrypoint?: string | null;
|
||||
actor?: string | null;
|
||||
}
|
||||
|
||||
export interface RouteDecisionTimelineResponse {
|
||||
has_route_decision_state: boolean;
|
||||
decisions: RouteDecisionRecord[];
|
||||
}
|
||||
|
||||
export interface IngressRouteRequest {
|
||||
entrypoint?: 'chat' | 'task_center' | 'cron' | 'webhook' | 'legacy_api' | 'browser_event' | 'file_event';
|
||||
actor?: string;
|
||||
text?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
attachments?: string[];
|
||||
timestamp?: string;
|
||||
correlation_id?: string;
|
||||
idempotency_key?: string;
|
||||
context_refs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IngressRouteResponse {
|
||||
accepted: boolean;
|
||||
envelope?: Record<string, unknown>;
|
||||
response_kind?: string;
|
||||
route_decision?: RouteDecisionRecord;
|
||||
command?: unknown;
|
||||
created_plan_id?: string | null;
|
||||
created_schedule_id?: string | null;
|
||||
created_task_ids?: string[];
|
||||
created_run_ids?: string[];
|
||||
projection_name?: string | null;
|
||||
projection_payload?: Record<string, unknown> | null;
|
||||
message?: string;
|
||||
error?: GovernanceCommandErrorCode;
|
||||
}
|
||||
|
||||
export type GovernanceCommandKind =
|
||||
| 'create_goal'
|
||||
| 'create_direct_task'
|
||||
| 'create_plan'
|
||||
| 'submit_plan'
|
||||
| 'approve_plan'
|
||||
| 'reject_plan'
|
||||
| 'activate_plan'
|
||||
| 'pause_plan'
|
||||
| 'resume_plan'
|
||||
| 'amend_plan'
|
||||
| 'cancel_plan'
|
||||
| 'retry_task'
|
||||
| 'skip_task'
|
||||
| 'pause_task'
|
||||
| 'resume_task'
|
||||
| 'provide_task_input'
|
||||
| 'cancel_task'
|
||||
| 'start_run'
|
||||
| 'cancel_run'
|
||||
| 'request_approval'
|
||||
| 'approve_approval'
|
||||
| 'reject_approval'
|
||||
| 'create_schedule'
|
||||
| 'pause_schedule'
|
||||
| 'run_schedule_now';
|
||||
|
||||
export type GovernanceCommandErrorCode =
|
||||
| 'invalid_context'
|
||||
| 'missing_capability'
|
||||
| 'policy_blocked'
|
||||
| 'conflict'
|
||||
| 'execution_failed'
|
||||
| 'unauthorized'
|
||||
| 'invalid_state'
|
||||
| (string & {});
|
||||
|
||||
export interface GovernanceCommandRequest {
|
||||
command_kind: GovernanceCommandKind;
|
||||
context_refs: Record<string, unknown>;
|
||||
payload?: Record<string, unknown>;
|
||||
idempotency_key?: string;
|
||||
correlation_id?: string;
|
||||
}
|
||||
|
||||
export interface GovernanceCommandResponse {
|
||||
command_id: string;
|
||||
accepted: boolean;
|
||||
correlation_id: string;
|
||||
result?: Record<string, unknown>;
|
||||
error?: GovernanceCommandErrorCode;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Console types — Debug API responses, Plan/Task/Run, Swimlane
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export interface DashboardStats {
|
||||
total: number;
|
||||
queued: number;
|
||||
running: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
paused?: number;
|
||||
}
|
||||
|
||||
export interface DebugTask {
|
||||
task_id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
plan_id: string | null;
|
||||
step_id: string;
|
||||
assigned_skill_id: string;
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
trigger_source?: string | null;
|
||||
scheduler_identity?: string | null;
|
||||
cron_job_id?: string | null;
|
||||
timeout_at?: string | null;
|
||||
error_message?: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface DebugRun {
|
||||
run_id: string;
|
||||
task_id: string;
|
||||
plan_id: string | null;
|
||||
skill_id?: string;
|
||||
attempt_no: number;
|
||||
lifecycle: string;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
events?: Array<{
|
||||
event_id?: string;
|
||||
kind?: string;
|
||||
occurred_at?: string;
|
||||
message?: string | null;
|
||||
}>;
|
||||
artifacts?: Array<Record<string, unknown>>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface DebugPlan {
|
||||
plan_id: string;
|
||||
title: string;
|
||||
objective?: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
steps?: PlanStepSummary[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ApprovalEntry {
|
||||
approval_id: string;
|
||||
task_id: string;
|
||||
status: string;
|
||||
created_at?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ArtifactEntry {
|
||||
artifact_id?: string;
|
||||
subject_id: string;
|
||||
kind?: string;
|
||||
name?: string;
|
||||
uri?: string;
|
||||
value?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CanonicalEvent {
|
||||
event_id: string;
|
||||
aggregate_type?: string;
|
||||
aggregate_id?: string;
|
||||
event_type?: string;
|
||||
kind?: string;
|
||||
message?: string;
|
||||
occurred_at?: string;
|
||||
timestamp?: string;
|
||||
plan_id: string | null;
|
||||
task_id: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface DebugStoreResponse {
|
||||
tables: {
|
||||
canonical_events: CanonicalEvent[];
|
||||
tasks: DebugTask[];
|
||||
runs: DebugRun[];
|
||||
plans: DebugPlan[];
|
||||
plan_steps: PlanStepSummary[];
|
||||
approvals: ApprovalEntry[];
|
||||
artifacts: ArtifactEntry[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface FlowStep {
|
||||
seq?: number;
|
||||
lane: string;
|
||||
label: string;
|
||||
sub_label?: string;
|
||||
timestamp: string;
|
||||
color?: string;
|
||||
source_event_id?: string;
|
||||
source_type?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface LaneInfo {
|
||||
key: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface FlowResponse {
|
||||
steps: FlowStep[];
|
||||
lanes: LaneInfo[];
|
||||
task?: Record<string, unknown>;
|
||||
plan_id?: string;
|
||||
plan_title?: string;
|
||||
plan_status?: string;
|
||||
plan_objective?: string;
|
||||
events?: Array<{ event_id: string; kind: string; occurred_at: string; message: string }>;
|
||||
}
|
||||
|
||||
export interface SkillSpec {
|
||||
skill_id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreatePlanRequest {
|
||||
title: string;
|
||||
description: string;
|
||||
steps: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
skills: string[];
|
||||
depends_on: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface DashboardResponse {
|
||||
runtime: {
|
||||
active_tasks: number;
|
||||
failed_tasks: number;
|
||||
pending_confirmations: number;
|
||||
};
|
||||
tasks: DashboardStats;
|
||||
approvals: {
|
||||
total: number;
|
||||
pending: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type DigitalEmployeeProjectionSource = 'live' | 'demo';
|
||||
|
||||
export interface DigitalEmployeeProfile {
|
||||
name: string;
|
||||
title: string;
|
||||
major: string;
|
||||
company: string;
|
||||
position?: string;
|
||||
current_status?: 'working' | 'resting' | string;
|
||||
current_status_label?: string;
|
||||
motto: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeMetric {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeDuty {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
implemented: boolean;
|
||||
selectable: boolean;
|
||||
selected: boolean;
|
||||
status: string;
|
||||
status_label: string;
|
||||
status_tone: 'running' | 'success' | 'danger' | 'warning' | 'waiting' | 'info' | string;
|
||||
schedule_text: string;
|
||||
result_text: string;
|
||||
conversation_id?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeWorkdayState {
|
||||
confirmed: boolean;
|
||||
confirmed_at?: string | null;
|
||||
current_status?: 'working' | 'resting' | string;
|
||||
current_status_label?: string;
|
||||
status_label: string;
|
||||
status_tone: string;
|
||||
summary: string;
|
||||
bubble_text: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeOverview {
|
||||
title: string;
|
||||
status_label: string;
|
||||
status_tone: string;
|
||||
metrics: DigitalEmployeeMetric[];
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeWorkEvent {
|
||||
id: string;
|
||||
time: string;
|
||||
plan_title: string;
|
||||
task_title: string;
|
||||
step_title?: string | null;
|
||||
detail: string;
|
||||
status: string;
|
||||
status_label: string;
|
||||
status_tone: string;
|
||||
updated_at?: string | null;
|
||||
conversation_id?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeTaskExecutionSummary {
|
||||
id: string;
|
||||
plan_id?: string | null;
|
||||
title: string;
|
||||
description: string;
|
||||
selected: boolean;
|
||||
total_count: number;
|
||||
success_count: number;
|
||||
failure_count: number;
|
||||
running_count: number;
|
||||
waiting_count: number;
|
||||
latest_status_label: string;
|
||||
latest_status_tone: string;
|
||||
business_summary: string;
|
||||
ai_summary?: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeRunDetail {
|
||||
id: string;
|
||||
time: string;
|
||||
task_name: string;
|
||||
step_name?: string | null;
|
||||
operation_name?: string | null;
|
||||
status_label: string;
|
||||
status_tone: string;
|
||||
business_detail: string;
|
||||
raw_detail?: string;
|
||||
updated_at?: string | null;
|
||||
conversation_id?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeDecisionAction {
|
||||
id: string;
|
||||
label: string;
|
||||
tone: 'primary' | 'secondary' | string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeDecision {
|
||||
id: string;
|
||||
title: string;
|
||||
scenario: string;
|
||||
status: string;
|
||||
status_label: string;
|
||||
plan_title: string;
|
||||
task_title: string;
|
||||
actions: DigitalEmployeeDecisionAction[];
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeDeliveryArtifact {
|
||||
name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeDelivery {
|
||||
headline: string;
|
||||
summary: string;
|
||||
lines: string[];
|
||||
artifacts: DigitalEmployeeDeliveryArtifact[];
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeDailyReport {
|
||||
date: string;
|
||||
title: string;
|
||||
report_schedule_time: string;
|
||||
status: 'not_generated' | 'generating' | 'ready' | 'failed' | string;
|
||||
generated_at?: string | null;
|
||||
generated_by: 'ai' | 'fallback' | string;
|
||||
model?: string | null;
|
||||
source?: string;
|
||||
summary: string;
|
||||
totals: {
|
||||
task_count: number;
|
||||
execution_count: number;
|
||||
success_count: number;
|
||||
failure_count: number;
|
||||
running_count?: number;
|
||||
};
|
||||
task_sections: DigitalEmployeeTaskExecutionSummary[];
|
||||
detail_lines: string[];
|
||||
pdf_available: boolean;
|
||||
pdf_url?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeWorkdayProjection {
|
||||
source: DigitalEmployeeProjectionSource;
|
||||
demo_reason?: string | null;
|
||||
date: string;
|
||||
employee: DigitalEmployeeProfile;
|
||||
workday: DigitalEmployeeWorkdayState;
|
||||
overview: DigitalEmployeeOverview;
|
||||
duties: DigitalEmployeeDuty[];
|
||||
events: DigitalEmployeeWorkEvent[];
|
||||
execution_summaries?: DigitalEmployeeTaskExecutionSummary[];
|
||||
run_details?: DigitalEmployeeRunDetail[];
|
||||
decisions: DigitalEmployeeDecision[];
|
||||
delivery: DigitalEmployeeDelivery;
|
||||
daily_report?: DigitalEmployeeDailyReport;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeWorkdayActionRequest {
|
||||
action: 'confirm_workday' | 'decide_approval' | 'update_profile' | 'update_report_schedule' | 'generate_daily_report';
|
||||
date?: string;
|
||||
duty_ids?: string[];
|
||||
decision_id?: string;
|
||||
decision?: string;
|
||||
name?: string;
|
||||
company?: string;
|
||||
position?: string;
|
||||
current_status?: 'working' | 'resting' | string;
|
||||
report_schedule_time?: string;
|
||||
}
|
||||
|
||||
export interface PlanDispatchTask {
|
||||
task_id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface PlanDispatchResponse {
|
||||
ok?: boolean;
|
||||
plan_id?: string;
|
||||
source_plan_id?: string | null;
|
||||
dispatched_tasks?: PlanDispatchTask[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Shared type used by PlanSidebar and PlanChat for the selected plan state. */
|
||||
export interface PlanChatPlan {
|
||||
plan_id: string;
|
||||
title: string;
|
||||
objective: string;
|
||||
status: string;
|
||||
created_at?: string | null;
|
||||
steps: Array<{
|
||||
step_id: string;
|
||||
title: string;
|
||||
tasks: Array<{ task_id: string; title: string; status: string }>;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsBuildInfoFile",
|
||||
"incremental": true,
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsBuildInfoFile",
|
||||
"incremental": true,
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [tailwindcss(), react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "../../public/sgrobot-digital",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
@@ -13,6 +13,8 @@
|
||||
"build:main:dev": "dotenv -e .env.development -- cross-env NODE_ENV=development tsc -p tsconfig.main.json && tsc-alias -p tsconfig.main.json",
|
||||
"build:main": "dotenv -e .env.production -- cross-env NODE_ENV=production tsc -p tsconfig.main.json && tsc-alias -p tsconfig.main.json",
|
||||
"build:renderer": "vite build",
|
||||
"sync:sgrobot-digital": "node scripts/sync-sgrobot-digital.js",
|
||||
"build:sgrobot-digital": "node scripts/build-sgrobot-digital.js",
|
||||
"build": "dotenv -e .env.production -- cross-env NODE_ENV=production npm run build:main && npm run build:renderer",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
function t(n,e,i,s){if(typeof e=="function"?n!==e||!s:!e.has(n))throw new TypeError("Cannot read private member from an object whose class did not declare it");return i==="m"?s:i==="a"?s.call(n):s?s.value:e.get(n)}function c(n,e,i,s,_){if(typeof e=="function"?n!==e||!0:!e.has(n))throw new TypeError("Cannot write private member to an object whose class did not declare it");return e.set(n,i),i}var a,r,o,l,u;const d="__TAURI_TO_IPC_KEY__";function w(n,e=!1){return window.__TAURI_INTERNALS__.transformCallback(n,e)}class g{constructor(e){a.set(this,void 0),r.set(this,0),o.set(this,[]),l.set(this,void 0),c(this,a,e||(()=>{})),this.id=w(i=>{const s=i.index;if("end"in i){s==t(this,r,"f")?this.cleanupCallback():c(this,l,s);return}const _=i.message;if(s==t(this,r,"f")){for(t(this,a,"f").call(this,_),c(this,r,t(this,r,"f")+1);t(this,r,"f")in t(this,o,"f");){const p=t(this,o,"f")[t(this,r,"f")];t(this,a,"f").call(this,p),delete t(this,o,"f")[t(this,r,"f")],c(this,r,t(this,r,"f")+1)}t(this,r,"f")===t(this,l,"f")&&this.cleanupCallback()}else t(this,o,"f")[s]=_})}cleanupCallback(){window.__TAURI_INTERNALS__.unregisterCallback(this.id)}set onmessage(e){c(this,a,e)}get onmessage(){return t(this,a,"f")}[(a=new WeakMap,r=new WeakMap,o=new WeakMap,l=new WeakMap,d)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[d]()}}class f{constructor(e,i,s){this.plugin=e,this.event=i,this.channelId=s}async unregister(){return h(`plugin:${this.plugin}|remove_listener`,{event:this.event,channelId:this.channelId})}}async function m(n,e,i){const s=new g(i);try{return await h(`plugin:${n}|register_listener`,{event:e,handler:s}),new f(n,e,s.id)}catch{return await h(`plugin:${n}|registerListener`,{event:e,handler:s}),new f(n,e,s.id)}}async function I(n){return h(`plugin:${n}|check_permissions`)}async function C(n){return h(`plugin:${n}|request_permissions`)}async function h(n,e={},i){return window.__TAURI_INTERNALS__.invoke(n,e,i)}function T(n,e="asset"){return window.__TAURI_INTERNALS__.convertFileSrc(n,e)}class k{get rid(){return t(this,u,"f")}constructor(e){u.set(this,void 0),c(this,u,e)}async close(){return h("plugin:resources|close",{rid:this.rid})}}u=new WeakMap;function E(){return!!(globalThis||window).isTauri}export{g as Channel,f as PluginListener,k as Resource,d as SERIALIZE_TO_IPC_FN,m as addPluginListener,I as checkPermissions,T as convertFileSrc,h as invoke,E as isTauri,C as requestPermissions,w as transformCallback};
|
||||
@@ -1,13 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<link rel="icon" type="image/png" href="/sgrobot-digital/logo.png" />
|
||||
<title>sgRobot</title>
|
||||
<title>数字员工</title>
|
||||
<script>
|
||||
window.__TAURI__ = window.__TAURI__ || {};
|
||||
window.__QIMINGCLAW_EMBEDDED_DIGITAL__ = true;
|
||||
try {
|
||||
window.localStorage.setItem("zeroclaw_token", "qimingclaw_embedded_digital_employee");
|
||||
window.sessionStorage.setItem("sgrobot_logged_in", "1");
|
||||
@@ -15,8 +13,8 @@
|
||||
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
||||
}
|
||||
</script>
|
||||
<script type="module" crossorigin src="/sgrobot-digital/assets/index-DwDxQhF4.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/sgrobot-digital/assets/index-BTJFqpbJ.css">
|
||||
<script type="module" crossorigin src="./assets/index-DX0h8EMp.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DqV9Vw1H.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,660 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>sgRobot 业务流程与架构模块映射</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
.theme-dark-blue { background-color: #0d2758; }
|
||||
.theme-text-dark-blue { color: #0d2758; }
|
||||
.theme-light-blue-bg { background-color: #f0f6fc; }
|
||||
.theme-border-blue { border-color: #aec3db; }
|
||||
.theme-red { color: #d92d20; }
|
||||
|
||||
body {
|
||||
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", "Source Han Sans SC", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.text-xs { font-size: 0.92rem !important; }
|
||||
.text-sm { font-size: 1.08rem !important; }
|
||||
.text-base { font-size: 1.24rem !important; }
|
||||
.text-lg { font-size: 1.4rem !important; }
|
||||
.text-xl { font-size: 1.62rem !important; }
|
||||
.text-3xl { font-size: 2.75rem !important; }
|
||||
.text-\[13px\] { font-size: 1.14rem !important; }
|
||||
|
||||
[data-tip] {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.module-map {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.module-card {
|
||||
border: 1px solid #aec3db;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
box-shadow: 0 6px 16px rgba(13, 39, 88, 0.07);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.module-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
color: white;
|
||||
background: #0d2758;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.module-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
background: #f0f6fc;
|
||||
}
|
||||
|
||||
.module-pill {
|
||||
border: 1px solid #c8d7e8;
|
||||
border-radius: 999px;
|
||||
padding: 3px 9px;
|
||||
background: white;
|
||||
color: #0d2758;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flow-card {
|
||||
border: 1px solid #aec3db;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
box-shadow: 0 8px 20px rgba(13, 39, 88, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.flow-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px 10px 16px;
|
||||
border-bottom: 1px solid #d8e4f2;
|
||||
}
|
||||
|
||||
.flow-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #0d2758;
|
||||
font-weight: 800;
|
||||
font-size: 1.22rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.flow-tag {
|
||||
border: 1px solid #c8d7e8;
|
||||
border-radius: 999px;
|
||||
padding: 3px 10px;
|
||||
color: #5f7082;
|
||||
background: #f8fbff;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.flow-note {
|
||||
padding: 9px 16px;
|
||||
background: #f8fbff;
|
||||
color: #5f7082;
|
||||
border-bottom: 1px solid #d8e4f2;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.swimlane-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.lane {
|
||||
min-width: 1080px;
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
border-bottom: 1px solid #d8e4f2;
|
||||
background: #f0f6fc;
|
||||
}
|
||||
|
||||
.lane:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.lane-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 54px;
|
||||
padding: 10px 12px;
|
||||
background: #0d2758;
|
||||
color: white;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.lane-body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
min-height: 118px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.module-node {
|
||||
min-width: 180px;
|
||||
flex: 1 1 0;
|
||||
border: 1px solid #c8d7e8;
|
||||
border-left: 5px solid #0d2758;
|
||||
border-radius: 7px;
|
||||
background: white;
|
||||
color: #0d2758;
|
||||
padding: 9px 10px 10px 10px;
|
||||
box-shadow: 0 2px 8px rgba(13, 39, 88, 0.06);
|
||||
}
|
||||
|
||||
.module-node.risk {
|
||||
border-left-color: #d92d20;
|
||||
}
|
||||
|
||||
.module-node.memory {
|
||||
border-left-color: #7a5af8;
|
||||
}
|
||||
|
||||
.module-node.service {
|
||||
border-left-color: #00856f;
|
||||
}
|
||||
|
||||
.node-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.node-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
color: white;
|
||||
background: #0d2758;
|
||||
font-weight: 800;
|
||||
font-size: 0.8rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.module-node.risk .node-number {
|
||||
background: #d92d20;
|
||||
}
|
||||
|
||||
.module-node.memory .node-number {
|
||||
background: #7a5af8;
|
||||
}
|
||||
|
||||
.module-node.service .node-number {
|
||||
background: #00856f;
|
||||
}
|
||||
|
||||
.node-module {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
border: 1px solid #c8d7e8;
|
||||
border-radius: 999px;
|
||||
background: #f8fbff;
|
||||
padding: 2px 8px;
|
||||
color: #5f7082;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.node-title {
|
||||
display: block;
|
||||
font-weight: 900;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.node-desc {
|
||||
display: block;
|
||||
color: #5f7082;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border: 1px solid #c8d7e8;
|
||||
border-radius: 999px;
|
||||
background: white;
|
||||
color: #0d2758;
|
||||
padding: 5px 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: #0d2758;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.legend-dot.risk {
|
||||
background: #d92d20;
|
||||
}
|
||||
|
||||
.legend-dot.memory {
|
||||
background: #7a5af8;
|
||||
}
|
||||
|
||||
.legend-dot.service {
|
||||
background: #00856f;
|
||||
}
|
||||
|
||||
#sg-tooltip {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
max-width: 440px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: #0d2758;
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45;
|
||||
box-shadow: 0 12px 32px rgba(13, 39, 88, 0.22);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
#sg-tooltip.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.module-map {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.module-map {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.flow-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.lane {
|
||||
min-width: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.lane-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.module-node {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-white p-4 md:p-8 min-h-screen text-sm select-none">
|
||||
|
||||
<nav class="max-w-[1780px] mx-auto mb-5 flex flex-wrap items-center justify-between gap-3 border border-blue-200 rounded-lg px-4 py-3 shadow-sm bg-white">
|
||||
<div class="flex items-center gap-2 theme-text-dark-blue font-bold text-base" data-tip="汇报专用静态网站,可直接双击 index.html 打开,不需要启动本地服务。">
|
||||
<i class="fa-solid fa-display"></i>
|
||||
<span>sgRobot 汇报静态网站</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="index.html" class="border theme-border-blue theme-text-dark-blue rounded px-4 py-2 font-bold bg-white hover:bg-blue-50" data-tip="返回 sgRobot 的目标态业务架构总览。">业务架构总览</a>
|
||||
<a href="flows.html" class="theme-dark-blue text-white rounded px-4 py-2 font-bold shadow-sm" data-tip="当前页面展示业务流程如何落到架构模块。">业务流程图</a>
|
||||
<a href="roadmap.html" class="border theme-border-blue theme-text-dark-blue rounded px-4 py-2 font-bold bg-white hover:bg-blue-50" data-tip="查看五月底上线倒排的里程碑、分工边界和验收口径。">里程碑与分工</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header class="max-w-[1780px] mx-auto mb-6">
|
||||
<div class="flex items-center mb-2" data-tip="本页展示业务流程与架构模块的对应关系,不再只画孤立步骤。">
|
||||
<div class="w-10 h-10 theme-dark-blue text-white flex items-center justify-center rounded shadow-sm mr-3">
|
||||
<i class="fa-solid fa-route text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold theme-text-dark-blue tracking-wide">sgRobot <span class="font-bold">业务流程与架构模块映射</span></h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-14 text-[13px] font-medium text-gray-700 leading-relaxed" data-tip="每条流程按架构图的上下分层来摆放,节点上的小标签标明它落到哪个具体模块。">
|
||||
读图方式:每张流程图按<span class="font-bold">四个架构分区</span>自上而下展开,节点编号表示业务先后顺序,节点标签表示承接该动作的<span class="theme-red font-bold">具体架构模块</span>。
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-[1780px] mx-auto flex flex-col gap-5">
|
||||
<section class="border-2 theme-border-blue rounded-lg p-4 bg-white shadow-sm" data-tip="这里先把架构图中的四个分区和模块列出来,后续每个流程节点都会引用这些模块。">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<div class="font-bold theme-text-dark-blue text-lg flex items-center gap-2">
|
||||
<i class="fa-solid fa-layer-group"></i>
|
||||
<span>架构模块对照</span>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span class="legend-item" data-tip="蓝色节点表示常规业务动作。"><span class="legend-dot"></span>常规动作</span>
|
||||
<span class="legend-item" data-tip="红色节点表示需要人工判断、风险确认或异常处置。"><span class="legend-dot risk"></span>风险决策</span>
|
||||
<span class="legend-item" data-tip="绿色节点表示按需后台服务、设备连接或外部系统协作。"><span class="legend-dot service"></span>服务协作</span>
|
||||
<span class="legend-item" data-tip="紫色节点表示结果、经验、规则或过程记录沉淀。"><span class="legend-dot memory"></span>记录沉淀</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="module-map">
|
||||
<div class="module-card" data-tip="业务入口与协同负责把人的需求、系统事件和审批动作接进系统。">
|
||||
<div class="module-title"><i class="fa-solid fa-right-to-bracket"></i><span>业务入口与协同</span></div>
|
||||
<div class="module-list">
|
||||
<span class="module-pill" data-tip="接收人工提交、系统接口、计划任务和事件触发。">业务需求入口</span>
|
||||
<span class="module-pill" data-tip="让用户、运维和审批人查看、操作、审批、回放和验收成果。">协同工作台</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="module-card" data-tip="任务治理与风控负责计划拆解、任务执行管理、人工治理和风险规则。">
|
||||
<div class="module-title"><i class="fa-solid fa-shield-halved"></i><span>任务治理与风控</span></div>
|
||||
<div class="module-list">
|
||||
<span class="module-pill" data-tip="把业务目标拆成执行计划和计划步骤。">目标计划管理</span>
|
||||
<span class="module-pill" data-tip="管理工作任务、执行记录、重试、恢复点和超时。">任务执行管理</span>
|
||||
<span class="module-pill" data-tip="承接审批、暂停、恢复、取消、人工接管和重新规划。">人工治理</span>
|
||||
<span class="module-pill" data-tip="管理记忆规则、服务占用、风险等级和健康状态。">风险与规则</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="module-card" data-tip="自动执行与能力负责真正完成业务动作,并管理外部系统、设备和后台服务。">
|
||||
<div class="module-title"><i class="fa-solid fa-gears"></i><span>自动执行与能力</span></div>
|
||||
<div class="module-list">
|
||||
<span class="module-pill" data-tip="登记可被调度使用的自动化能力。">能力目录</span>
|
||||
<span class="module-pill" data-tip="检查前置条件、匹配能力、安排顺序和失败恢复。">调度中枢</span>
|
||||
<span class="module-pill" data-tip="执行规则清楚、结果稳定、可重复的任务。">自动脚本</span>
|
||||
<span class="module-pill" data-tip="把多步骤工作组织成可治理流程。">流程编排</span>
|
||||
<span class="module-pill" data-tip="辅助规划、解释、诊断和修复建议。">智能辅助</span>
|
||||
<span class="module-pill" data-tip="对接网页、业务系统、设备和机器人。">外部系统适配</span>
|
||||
<span class="module-pill" data-tip="按需启动和回收邮件读取、监听器、浏览器环境等后台服务。">按需后台服务</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="module-card" data-tip="运营记录与看板沉淀过程、成果、经验和指标,让管理者看到进度、风险和结果。">
|
||||
<div class="module-title"><i class="fa-solid fa-chart-column"></i><span>运营记录与看板</span></div>
|
||||
<div class="module-list">
|
||||
<span class="module-pill" data-tip="保存计划、任务、执行和审批的当前状态。">业务状态台账</span>
|
||||
<span class="module-pill" data-tip="记录关键过程变化,用于回放、审计和问题追踪。">过程日志</span>
|
||||
<span class="module-pill" data-tip="保存文件、报告、截图和数据结果。">成果资料库</span>
|
||||
<span class="module-pill" data-tip="沉淀项目事实、偏好、关键决策、处理经验和观察摘要。">经验与记忆库</span>
|
||||
<span class="module-pill" data-tip="展示任务看板、审批队列、记忆索引和服务健康。">运营看板</span>
|
||||
<span class="module-pill" data-tip="按时间顺序展示过程回放、诊断和追责。">全程追踪</span>
|
||||
<span class="module-pill" data-tip="统计服务时效、成功率、响应耗时和人工介入率。">运营指标</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="flow-root" class="flex flex-col gap-5"></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const lanes = [
|
||||
{ key: "entry", icon: "fa-right-to-bracket", name: "业务入口与协同", tip: "对应架构图第一分区:业务需求入口和协同工作台。" },
|
||||
{ key: "governance", icon: "fa-shield-halved", name: "任务治理与风控", tip: "对应架构图第二分区:目标计划管理、任务执行管理、人工治理、风险与规则。" },
|
||||
{ key: "execution", icon: "fa-gears", name: "自动执行与能力", tip: "对应架构图第三分区:能力目录、调度中枢、自动脚本、流程编排、智能辅助、外部系统适配、按需后台服务。" },
|
||||
{ key: "records", icon: "fa-chart-column", name: "运营记录与看板", tip: "对应架构图第四分区:业务状态台账、过程日志、成果资料库、经验与记忆库、运营看板、全程追踪、运营指标。" },
|
||||
];
|
||||
|
||||
const flows = [
|
||||
{
|
||||
title: "人工交办任务处理",
|
||||
icon: "fa-user-pen",
|
||||
tag: "用户主动发起",
|
||||
tip: "用户主动提交目标后,流程依次经过需求入口、计划治理、自动执行、成果验收和经验沉淀。",
|
||||
nodes: [
|
||||
{ lane: "entry", step: 1, module: "协同工作台", title: "接收交办", desc: "目标、材料、期望结果", tip: "用户在协同工作台提交业务目标、背景材料和验收要求。" },
|
||||
{ lane: "entry", step: 2, module: "业务需求入口", title: "登记目标", desc: "形成可追踪入口", tip: "业务需求入口把原始需求登记成可追踪的业务目标。" },
|
||||
{ lane: "governance", step: 3, module: "目标计划管理", title: "拆解计划", desc: "步骤、依赖、验收标准", tip: "目标计划管理把业务目标拆成执行计划、计划步骤、依赖和验收标准。" },
|
||||
{ lane: "governance", step: 4, module: "任务执行管理", title: "生成任务", desc: "排队、优先级、恢复点", tip: "任务执行管理把计划步骤转成工作任务,并设置优先级、重试和恢复点。" },
|
||||
{ lane: "execution", step: 5, module: "调度中枢", title: "匹配能力", desc: "脚本、流程、智能辅助", tip: "调度中枢为工作任务选择合适能力,并检查前置条件。" },
|
||||
{ lane: "execution", step: 6, module: "自动脚本 / 流程编排", title: "执行任务", desc: "自动处理并持续回写", tip: "自动执行模块完成实际业务动作,并持续回写过程状态和中间成果。" },
|
||||
{ lane: "governance", step: 7, module: "人工治理", title: "成果验收", desc: "确认、退回、接管", type: "risk", tip: "人工治理负责成果确认、退回重做、暂停或人工接管。" },
|
||||
{ lane: "records", step: 8, module: "成果资料库 / 运营看板", title: "成果呈现", desc: "报告、截图、任务看板", type: "memory", tip: "成果资料库保存交付物,运营看板展示任务状态和验收结果。" },
|
||||
{ lane: "records", step: 9, module: "经验与记忆库", title: "经验沉淀", desc: "偏好、决策、处理经验", type: "memory", tip: "经验与记忆库沉淀用户偏好、关键决策和处理经验,供后续任务复用。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "邮件与消息触发处理",
|
||||
icon: "fa-envelope-open-text",
|
||||
tag: "事件自动触发",
|
||||
tip: "邮件、消息或业务通知到达后,按需服务先把事件读入,再由治理模块判断是否生成任务。",
|
||||
nodes: [
|
||||
{ lane: "execution", step: 1, module: "按需后台服务", title: "启动读取服务", desc: "邮件、监听器、消息", type: "service", tip: "按需后台服务在需要时启动邮件读取或消息监听,不要求所有服务随主程序常驻。" },
|
||||
{ lane: "entry", step: 2, module: "业务需求入口", title: "接收事件", desc: "通知、邮件、业务提醒", tip: "业务需求入口把读取到的消息统一登记为事件或待判断请求。" },
|
||||
{ lane: "governance", step: 3, module: "风险与规则", title: "分类判断", desc: "优先级、风险、是否自动处理", type: "risk", tip: "风险与规则判断消息类别、优先级、影响范围,以及是否需要人工确认。" },
|
||||
{ lane: "governance", step: 4, module: "目标计划管理", title: "生成目标或任务", desc: "纳入统一治理", tip: "符合条件的消息会被转成业务目标、执行计划或工作任务。" },
|
||||
{ lane: "execution", step: 5, module: "调度中枢 / 自动脚本", title: "自动处理", desc: "回复、归档、提取、触发后续流程", tip: "调度中枢选择处理能力,自动完成回复、归档、数据提取或后续流程触发。" },
|
||||
{ lane: "governance", step: 6, module: "人工治理", title: "异常转人工", desc: "审批、补充、接管", type: "risk", tip: "遇到权限、金额、外部影响或识别不确定时,进入人工审批或人工接管。" },
|
||||
{ lane: "records", step: 7, module: "过程日志 / 运营看板", title: "记录处理结果", desc: "结果、风险、处理人", type: "memory", tip: "过程日志记录处理过程,运营看板展示处理结果、风险状态和责任人。" },
|
||||
{ lane: "records", step: 8, module: "经验与记忆库", title: "更新规则经验", desc: "分类经验、处理偏好", type: "memory", tip: "消息分类、人工判断和处理偏好沉淀为后续自动判断依据。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "定时巡检与报告",
|
||||
icon: "fa-calendar-check",
|
||||
tag: "周期运营任务",
|
||||
tip: "周期巡检从计划任务入口进入,经过巡检计划、外部采集、报告生成和运营指标沉淀。",
|
||||
nodes: [
|
||||
{ lane: "entry", step: 1, module: "业务需求入口", title: "排程触发", desc: "日常、周报、月报", tip: "业务需求入口接收计划任务触发,启动周期巡检流程。" },
|
||||
{ lane: "governance", step: 2, module: "目标计划管理", title: "生成巡检计划", desc: "对象、范围、标准", tip: "目标计划管理根据巡检对象、范围、责任人和验收标准生成计划。" },
|
||||
{ lane: "governance", step: 3, module: "任务执行管理", title: "安排巡检任务", desc: "批次、优先级、超时", tip: "任务执行管理把巡检计划拆成工作任务,并配置批次、优先级和超时管控。" },
|
||||
{ lane: "execution", step: 4, module: "外部系统适配", title: "连接巡检对象", desc: "系统、网页、设备", type: "service", tip: "外部系统适配连接业务系统、网页、设备或机器人,准备采集信息。" },
|
||||
{ lane: "execution", step: 5, module: "自动脚本 / 智能辅助", title: "采集并生成报告", desc: "指标、截图、结论", tip: "自动脚本采集数据,智能辅助整理结论、异常和建议。" },
|
||||
{ lane: "records", step: 6, module: "成果资料库", title: "归档报告证据", desc: "报告、截图、数据结果", type: "memory", tip: "成果资料库保存巡检报告、截图和数据结果,支撑验收和复盘。" },
|
||||
{ lane: "records", step: 7, module: "运营指标", title: "更新运营指标", desc: "趋势、成功率、耗时", type: "memory", tip: "运营指标沉淀巡检趋势、执行成功率、响应耗时和服务时效。" },
|
||||
{ lane: "governance", step: 8, module: "人工治理", title: "异常升级", desc: "告警、确认、复盘", type: "risk", tip: "发现异常、超时或失败率升高时,转人工确认、升级和复盘。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "高风险动作审批",
|
||||
icon: "fa-shield-halved",
|
||||
tag: "人控关键节点",
|
||||
tip: "高风险动作通过风险规则识别,在执行前转入人工治理,并在全程追踪中留痕。",
|
||||
nodes: [
|
||||
{ lane: "governance", step: 1, module: "风险与规则", title: "识别风险动作", desc: "权限、影响、不可逆", type: "risk", tip: "风险与规则识别删除、发布、付款、发信、改配置、控制设备等高风险动作。" },
|
||||
{ lane: "governance", step: 2, module: "任务执行管理", title: "暂停在风险点", desc: "停止继续执行", type: "risk", tip: "任务执行管理把流程暂停在风险节点,避免未经确认继续执行。" },
|
||||
{ lane: "entry", step: 3, module: "协同工作台", title: "呈现审批材料", desc: "原因、影响、建议", type: "risk", tip: "协同工作台向审批人展示目标、原因、影响范围、可选方案和建议动作。" },
|
||||
{ lane: "governance", step: 4, module: "人工治理", title: "审批决策", desc: "通过、拒绝、接管、重规划", type: "risk", tip: "人工治理承接审批通过、拒绝、补充材料、人工接管或重新规划。" },
|
||||
{ lane: "execution", step: 5, module: "调度中枢", title: "执行分支", desc: "继续、取消、转人工", tip: "调度中枢根据审批结论继续执行、取消任务或转人工处理。" },
|
||||
{ lane: "records", step: 6, module: "全程追踪", title: "审批留痕", desc: "谁、何时、为何", type: "memory", tip: "全程追踪记录审批人、审批时间、审批理由和最终结果。" },
|
||||
{ lane: "records", step: 7, module: "经验与记忆库", title: "沉淀审批边界", desc: "规则、案例、判断依据", type: "memory", tip: "审批案例会沉淀为后续风险判断、审批边界和培训材料。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "外部系统与设备协作",
|
||||
icon: "fa-plug-circle-bolt",
|
||||
tag: "跨系统协作",
|
||||
tip: "跨系统任务通过服务准备、外部适配、状态回写和成果归档,把网页、设备和机器人纳入统一治理。",
|
||||
nodes: [
|
||||
{ lane: "entry", step: 1, module: "业务需求入口", title: "协作请求进入", desc: "业务系统、人、计划任务", tip: "业务需求入口接收需要外部系统、设备或机器人参与的协作请求。" },
|
||||
{ lane: "governance", step: 2, module: "风险与规则", title: "检查权限与服务占用", desc: "账号、设备、占用关系", type: "risk", tip: "风险与规则检查权限、账号、设备状态和后台服务占用关系。" },
|
||||
{ lane: "execution", step: 3, module: "按需后台服务", title: "准备运行环境", desc: "浏览器环境、连接、服务实例", type: "service", tip: "按需后台服务准备浏览器环境、设备连接或外部服务实例。" },
|
||||
{ lane: "execution", step: 4, module: "外部系统适配", title: "执行外部动作", desc: "网页、设备、机器人", type: "service", tip: "外部系统适配执行网页操作、设备控制或机器人动作。" },
|
||||
{ lane: "records", step: 5, module: "业务状态台账", title: "状态回写", desc: "进度、异常、服务健康", type: "memory", tip: "业务状态台账记录进度、异常和服务健康,供治理流程继续判断。" },
|
||||
{ lane: "governance", step: 6, module: "任务执行管理", title: "异常恢复", desc: "重试、替代、接管", type: "risk", tip: "外部系统不可用、设备异常或页面变化时,任务执行管理负责重试、替代路径或人工接管。" },
|
||||
{ lane: "records", step: 7, module: "成果资料库 / 全程追踪", title: "成果归档", desc: "证据、状态、经验", type: "memory", tip: "最终成果、截图证据、设备状态和处理过程统一归档。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "失败恢复与复盘改进",
|
||||
icon: "fa-screwdriver-wrench",
|
||||
tag: "持续改进闭环",
|
||||
tip: "失败不是只停在报错,而是由记录层提供证据,治理层决策恢复路径,执行层重试,最后沉淀规则。",
|
||||
nodes: [
|
||||
{ lane: "records", step: 1, module: "过程日志", title: "发现失败事实", desc: "失败、超时、不达标", type: "risk", tip: "过程日志记录任务失败、超时、外部异常或成果不达标。" },
|
||||
{ lane: "records", step: 2, module: "全程追踪", title: "汇总证据", desc: "过程、截图、状态", type: "memory", tip: "全程追踪汇总过程记录、截图、服务状态和最近变更。" },
|
||||
{ lane: "execution", step: 3, module: "智能辅助", title: "自动诊断", desc: "原因、影响、建议", tip: "智能辅助根据证据给出可能原因、影响范围和修复建议。" },
|
||||
{ lane: "governance", step: 4, module: "任务执行管理", title: "选择恢复路径", desc: "重试、替代、回退", type: "risk", tip: "任务执行管理根据风险和预算选择自动重试、替代能力或回退路径。" },
|
||||
{ lane: "execution", step: 5, module: "调度中枢", title: "执行恢复方案", desc: "重新调度、替代执行", tip: "调度中枢安排重试、替代能力或补偿步骤。" },
|
||||
{ lane: "governance", step: 6, module: "人工治理", title: "人工介入", desc: "补充、确认、接管", type: "risk", tip: "自动恢复仍不可靠或风险较高时,人工治理负责补充信息、确认方案或接管执行。" },
|
||||
{ lane: "records", step: 7, module: "经验与记忆库", title: "复盘沉淀", desc: "原因、方法、预防", type: "memory", tip: "处理完成后沉淀失败原因、修复方法、适用条件和预防建议。" },
|
||||
{ lane: "governance", step: 8, module: "风险与规则", title: "优化规则", desc: "减少重复失败", type: "memory", tip: "复盘结果转化为规则、能力说明、巡检项或审批边界优化。" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function renderNode(node) {
|
||||
const type = node.type ? ` ${escapeHtml(node.type)}` : "";
|
||||
return `
|
||||
<div class="module-node${type}" data-tip="${escapeHtml(node.tip)}">
|
||||
<div class="node-top">
|
||||
<span class="node-number">${escapeHtml(node.step)}</span>
|
||||
<span class="node-module">${escapeHtml(node.module)}</span>
|
||||
</div>
|
||||
<span class="node-title">${escapeHtml(node.title)}</span>
|
||||
<span class="node-desc">${escapeHtml(node.desc)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderFlow(flow) {
|
||||
const lanesHtml = lanes.map((lane) => {
|
||||
const nodes = flow.nodes
|
||||
.filter((node) => node.lane === lane.key)
|
||||
.sort((a, b) => a.step - b.step)
|
||||
.map(renderNode)
|
||||
.join("");
|
||||
return `
|
||||
<div class="lane" data-tip="${escapeHtml(lane.tip)}">
|
||||
<div class="lane-head">
|
||||
<span><i class="fa-solid ${escapeHtml(lane.icon)} mr-1"></i>${escapeHtml(lane.name)}</span>
|
||||
</div>
|
||||
<div class="lane-body">${nodes}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
return `
|
||||
<section class="flow-card" data-tip="${escapeHtml(flow.tip)}">
|
||||
<div class="flow-head">
|
||||
<div class="flow-title">
|
||||
<i class="fa-solid ${escapeHtml(flow.icon)}"></i>
|
||||
<span>${escapeHtml(flow.title)}</span>
|
||||
</div>
|
||||
<span class="flow-tag" data-tip="${escapeHtml(flow.tip)}">${escapeHtml(flow.tag)}</span>
|
||||
</div>
|
||||
<div class="flow-note" data-tip="节点按数字读,四层顺序与架构图一致;有些流程会在治理、执行和记录之间回流。">
|
||||
节点按数字读;每一层就是架构图中的一个分区,节点标签就是承接该步骤的具体模块。
|
||||
</div>
|
||||
<div class="swimlane-grid">${lanesHtml}</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
document.getElementById("flow-root").innerHTML = flows.map(renderFlow).join("");
|
||||
|
||||
const tooltip = document.createElement("div");
|
||||
tooltip.id = "sg-tooltip";
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
let activeTipTarget = null;
|
||||
|
||||
function positionTooltip(event) {
|
||||
const margin = 14;
|
||||
const offset = 16;
|
||||
const rect = tooltip.getBoundingClientRect();
|
||||
let left = event.clientX + offset;
|
||||
let top = event.clientY + offset;
|
||||
|
||||
if (left + rect.width + margin > window.innerWidth) {
|
||||
left = Math.max(margin, event.clientX - rect.width - offset);
|
||||
}
|
||||
if (top + rect.height + margin > window.innerHeight) {
|
||||
top = Math.max(margin, event.clientY - rect.height - offset);
|
||||
}
|
||||
|
||||
tooltip.style.left = `${left}px`;
|
||||
tooltip.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
function showTooltip(target, event) {
|
||||
const text = target.dataset.tip;
|
||||
if (!text) return;
|
||||
activeTipTarget = target;
|
||||
tooltip.textContent = text;
|
||||
tooltip.classList.add("is-visible");
|
||||
positionTooltip(event);
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
activeTipTarget = null;
|
||||
tooltip.classList.remove("is-visible");
|
||||
}
|
||||
|
||||
document.addEventListener("pointerover", (event) => {
|
||||
const target = event.target.closest("[data-tip]");
|
||||
if (target) showTooltip(target, event);
|
||||
});
|
||||
|
||||
document.addEventListener("pointermove", (event) => {
|
||||
if (activeTipTarget) positionTooltip(event);
|
||||
});
|
||||
|
||||
document.addEventListener("pointerout", (event) => {
|
||||
if (!activeTipTarget) return;
|
||||
const next = event.relatedTarget;
|
||||
if (!next || !activeTipTarget.contains(next)) hideTooltip();
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-tip]").forEach((element) => {
|
||||
element.setAttribute("tabindex", "0");
|
||||
element.setAttribute("aria-label", element.dataset.tip);
|
||||
element.addEventListener("focus", (event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
showTooltip(event.currentTarget, {
|
||||
clientX: rect.left + rect.width / 2,
|
||||
clientY: rect.top + rect.height / 2,
|
||||
});
|
||||
});
|
||||
element.addEventListener("blur", hideTooltip);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,650 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>sgRobot 目标态业务架构</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
/* 自定义一些特定颜色以更贴近原图 */
|
||||
.theme-dark-blue { background-color: #0d2758; }
|
||||
.theme-text-dark-blue { color: #0d2758; }
|
||||
.theme-light-blue-bg { background-color: #f0f6fc; }
|
||||
.theme-border-blue { border-color: #aec3db; }
|
||||
.theme-text-gray { color: #5f7082; }
|
||||
.theme-red { color: #d92d20; }
|
||||
|
||||
/* 连接线箭头样式辅助 */
|
||||
.arrow-right-solid {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 5px solid transparent;
|
||||
border-bottom: 5px solid transparent;
|
||||
border-left: 8px solid #0d2758;
|
||||
}
|
||||
.arrow-down-solid {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 8px solid #0d2758;
|
||||
}
|
||||
|
||||
/* 用于核心闭环的虚线箭头 */
|
||||
.dashed-arrow-line {
|
||||
background-image: linear-gradient(to right, #d92d20 50%, transparent 50%);
|
||||
background-size: 10px 2px;
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.arrow-right-red {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 6px solid #d92d20;
|
||||
}
|
||||
.arrow-left-red {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-right: 6px solid #d92d20;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", "Source Han Sans SC", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* 汇报场景下整体字号再上调一档,兼顾投屏可读性 */
|
||||
.text-xs { font-size: 0.92rem !important; }
|
||||
.text-sm { font-size: 1.08rem !important; }
|
||||
.text-base { font-size: 1.24rem !important; }
|
||||
.text-lg { font-size: 1.4rem !important; }
|
||||
.text-xl { font-size: 1.62rem !important; }
|
||||
.text-3xl { font-size: 2.75rem !important; }
|
||||
.text-\[9px\] { font-size: 0.82rem !important; }
|
||||
.text-\[10px\] { font-size: 0.92rem !important; }
|
||||
.text-\[11px\] { font-size: 1.02rem !important; }
|
||||
.text-\[13px\] { font-size: 1.14rem !important; }
|
||||
|
||||
/* 放大后给各层卡片更多高度,避免文字贴边 */
|
||||
.min-h-\[60px\] { min-height: 86px !important; }
|
||||
.min-h-\[65px\] { min-height: 92px !important; }
|
||||
.min-h-\[70px\] { min-height: 104px !important; }
|
||||
|
||||
[data-tip] {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.term-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px 6px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.term {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid #c8d7e8;
|
||||
border-radius: 999px;
|
||||
background: #f8fbff;
|
||||
padding: 2px 8px;
|
||||
color: #5f7082;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#sg-tooltip {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
max-width: 520px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: #0d2758;
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre-line;
|
||||
box-shadow: 0 12px 32px rgba(13, 39, 88, 0.22);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
#sg-tooltip.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-white p-4 md:p-8 min-h-screen text-sm select-none">
|
||||
|
||||
<nav class="max-w-[1780px] mx-auto mb-5 flex flex-wrap items-center justify-between gap-3 border border-blue-200 rounded-lg px-4 py-3 shadow-sm bg-white">
|
||||
<div class="flex items-center gap-2 theme-text-dark-blue font-bold text-base" data-tip="汇报专用静态网站,可直接双击 index.html 打开,不需要启动本地服务。">
|
||||
<i class="fa-solid fa-display"></i>
|
||||
<span>sgRobot 汇报静态网站</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="index.html" class="theme-dark-blue text-white rounded px-4 py-2 font-bold shadow-sm" data-tip="查看 sgRobot 的目标态业务架构总览。">业务架构总览</a>
|
||||
<a href="flows.html" class="border theme-border-blue theme-text-dark-blue rounded px-4 py-2 font-bold bg-white hover:bg-blue-50" data-tip="查看几类典型业务如何进入、执行、沉淀和被人工治理。">业务流程图</a>
|
||||
<a href="roadmap.html" class="border theme-border-blue theme-text-dark-blue rounded px-4 py-2 font-bold bg-white hover:bg-blue-50" data-tip="查看五月底上线倒排的里程碑、分工边界和验收口径。">里程碑与分工</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="max-w-[1780px] mx-auto mb-6">
|
||||
<div class="flex items-center mb-2">
|
||||
<div class="w-10 h-10 theme-dark-blue text-white flex items-center justify-center rounded shadow-sm mr-3">
|
||||
<i class="fa-solid fa-cube text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold theme-text-dark-blue tracking-wide">sgRobot <span class="font-bold">目标态业务架构</span></h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-14 text-[13px] font-medium text-gray-700 leading-relaxed">
|
||||
围绕<span class="font-bold">业务入口</span>、<span class="font-bold">任务治理</span>、<span class="font-bold">自动执行</span>、<span class="theme-red font-bold">运营看板</span>四条主线,把目标接收、计划拆解、执行过程、人工决策、经验沉淀和后台服务统一纳入可管理的业务闭环。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-[1780px] mx-auto relative">
|
||||
|
||||
<!-- 顶层用户 -->
|
||||
<div class="flex justify-center mb-[-12px] relative z-10">
|
||||
<div class="bg-white border-2 theme-border-blue rounded-full px-10 py-2 flex items-center gap-3 shadow-sm" data-tip="系统的外部参与者。用户提交目标,运维关注健康与风险,审批人处理治理节点,外部业务系统可通过系统接口或业务事件触发任务。">
|
||||
<i class="fa-solid fa-user-group text-blue-600 text-lg"></i>
|
||||
<span class="font-bold theme-text-dark-blue text-base">
|
||||
<span data-tip="用户是目标和需求的主要提出者,可以发起任务、补充输入和接受结果。">用户</span> /
|
||||
<span data-tip="运维关注系统健康、服务状态、告警、失败恢复和上线风险。">运维</span> /
|
||||
<span data-tip="审批人负责处理高风险动作、外部副作用、权限边界和人工治理节点。">审批人</span> /
|
||||
<span data-tip="外部业务系统通过系统接口、消息或业务事件触发 sgRobot 工作流。">外部业务系统</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center mb-2 text-blue-600 text-lg">
|
||||
<i class="fa-solid fa-arrow-down"></i>
|
||||
</div>
|
||||
|
||||
<!-- 主结构外框 -->
|
||||
<div class="border-2 theme-border-blue rounded-lg p-3 pt-6 flex flex-col gap-2 relative bg-white">
|
||||
<div class="absolute right-[-10px] top-[15%] bottom-[15%] w-[20px] border-r-2 border-t-2 border-b-2 theme-border-blue rounded-r-lg z-0 opacity-50 hidden lg:block"></div>
|
||||
|
||||
<!-- Plane 1: 业务入口与协同 -->
|
||||
<div class="flex items-stretch border theme-border-blue rounded bg-white relative z-10">
|
||||
<div class="w-12 md:w-40 theme-dark-blue text-white flex flex-col items-center justify-center p-2 rounded-l flex-shrink-0" data-tip="业务入口与协同负责把人的需求、系统事件和业务审批统一接入,并把任务进展反馈给相关人员。">
|
||||
<div class="bg-white/20 px-2 py-1 rounded text-xs mb-1 font-mono">1</div>
|
||||
<span class="font-bold hidden md:block text-center leading-tight">业务入口与协同</span>
|
||||
<span class="font-bold md:hidden text-center writing-vertical-lr text-xs">入口协同</span>
|
||||
</div>
|
||||
<div class="flex-1 theme-light-blue-bg p-3 grid grid-cols-1 md:grid-cols-2 gap-4 rounded-r">
|
||||
<div class="bg-white border theme-border-blue rounded p-2 flex items-center gap-3 shadow-sm min-h-[60px] w-full" data-tip="业务需求入口把人工提交、接口调用、计划任务和事件触发统一登记为可跟踪的业务目标或处理请求。">
|
||||
<i class="fa-solid fa-arrow-right-to-bracket text-2xl theme-text-dark-blue ml-2"></i>
|
||||
<div>
|
||||
<div class="font-bold theme-text-dark-blue text-sm">业务需求入口</div>
|
||||
<div class="term-list">
|
||||
<span class="term" data-tip="人工提交是用户在控制台或对话入口主动发起的目标。">人工提交</span>
|
||||
<span class="term" data-tip="系统接口让外部业务系统以结构化请求提交目标、状态或审批动作。">系统接口</span>
|
||||
<span class="term" data-tip="计划任务是按时间、周期或排程触发的目标入口。">计划任务</span>
|
||||
<span class="term" data-tip="事件触发来自邮件、系统通知、消息、文件变化或内部业务事件。">事件触发</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white border theme-border-blue rounded p-2 flex items-center gap-3 shadow-sm min-h-[60px] w-full" data-tip="协同工作台让用户、运维和审批人查看进度、处理审批、回放过程和验收成果。">
|
||||
<i class="fa-solid fa-desktop text-2xl theme-text-dark-blue ml-2"></i>
|
||||
<div>
|
||||
<div class="font-bold theme-text-dark-blue text-sm">协同工作台</div>
|
||||
<div class="term-list">
|
||||
<span class="term" data-tip="查看用于读取目标、计划、任务、执行记录、服务状态和经验记忆。">查看</span>
|
||||
<span class="term" data-tip="操作是用户对任务、服务、参数和视图发起的显式动作。">操作</span>
|
||||
<span class="term" data-tip="审批用于确认高风险动作、外部副作用或权限敏感步骤。">审批</span>
|
||||
<span class="term" data-tip="回放按事件时间线复原过程,用于诊断、复盘和追责。">回放</span>
|
||||
<span class="term" data-tip="成果浏览用于查看报告、截图、文件、日志快照和结构化结果。">成果浏览</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plane 2: 任务治理与风控 -->
|
||||
<div class="flex items-stretch border theme-border-blue rounded bg-white relative z-10">
|
||||
<div class="w-12 md:w-40 theme-dark-blue text-white flex flex-col items-center justify-center p-2 rounded-l flex-shrink-0" data-tip="任务治理与风控负责把业务目标拆成计划和任务,并统一管理审批、风险、暂停、恢复、重试和服务占用规则。">
|
||||
<i class="fa-solid fa-sliders text-xl mb-1"></i>
|
||||
<span class="font-bold hidden md:block text-center leading-tight">任务治理与风控</span>
|
||||
<span class="font-bold md:hidden text-center writing-vertical-lr text-xs mt-2">任务风控</span>
|
||||
</div>
|
||||
<div class="flex-1 theme-light-blue-bg p-3 grid grid-cols-1 md:grid-cols-4 gap-3 rounded-r">
|
||||
<div class="bg-white border theme-border-blue rounded p-2 shadow-sm min-h-[70px]" data-tip="目标与计划管理把原始目标转成可审查、可冻结、可重规划的计划结构。">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<i class="fa-solid fa-bullseye theme-text-dark-blue text-lg"></i>
|
||||
<span class="font-bold theme-text-dark-blue text-[13px]">目标计划管理</span>
|
||||
</div>
|
||||
<div class="term-list">
|
||||
<span class="term" data-tip="业务目标是用户或外部系统想达成的结果,也是后续计划拆解的源头。">业务目标</span>
|
||||
<span class="term" data-tip="执行计划描述成功标准、风险、依赖和执行步骤。">执行计划</span>
|
||||
<span class="term" data-tip="计划步骤是计划中的单个动作,用于生成可调度的工作任务。">计划步骤</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white border theme-border-blue rounded p-2 shadow-sm min-h-[70px]" data-tip="任务执行管理负责从步骤生成任务,并管理执行尝试、失败恢复、服务占用和最终状态。">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<i class="fa-solid fa-list-ul theme-text-dark-blue text-lg"></i>
|
||||
<span class="font-bold theme-text-dark-blue text-[13px]">任务执行管理</span>
|
||||
</div>
|
||||
<div class="term-list">
|
||||
<span class="term" data-tip="工作任务是可调度的工作单元,绑定能力、输入、依赖和优先级。">工作任务</span>
|
||||
<span class="term" data-tip="执行记录代表一次任务执行尝试,重试会产生新的执行记录。">执行记录</span>
|
||||
<span class="term" data-tip="自动重试是失败或中断后的再执行策略,受预算和风险约束。">自动重试</span>
|
||||
<span class="term" data-tip="恢复点是长任务的过程锚点,用于失败后恢复和问题诊断。">恢复点</span>
|
||||
<span class="term" data-tip="超时管控防止任务无限运行,超时后进入恢复或人工介入路径。">超时管控</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white border theme-border-blue rounded p-2 shadow-sm min-h-[70px]" data-tip="人工治理把人的介入变成可记录、可回放、可追责的业务操作,而不是后台直接改结果。">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<i class="fa-solid fa-user-check theme-text-dark-blue text-lg"></i>
|
||||
<span class="font-bold theme-text-dark-blue text-[13px]">人工治理</span>
|
||||
</div>
|
||||
<div class="term-list">
|
||||
<span class="term" data-tip="审批通过允许高风险步骤、外部副作用或权限敏感动作继续执行。">审批通过</span>
|
||||
<span class="term" data-tip="暂停让人检查任务状态、参数、风险或依赖。">暂停</span>
|
||||
<span class="term" data-tip="恢复从暂停点继续执行,通常需要保留上下文和服务占用状态。">恢复</span>
|
||||
<span class="term" data-tip="取消终止任务或计划,并释放相关服务占用。">取消</span>
|
||||
<span class="term" data-tip="人工接管把自动执行转交给人工或指定操作者处理。">人工接管</span>
|
||||
<span class="term" data-tip="重新规划在现有目标下重新生成或修改执行路径。">重新规划</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white border theme-border-blue rounded p-2 shadow-sm min-h-[70px]" data-tip="风险与规则规定经验记忆怎么用、后台服务怎么占用、哪些动作必须审批、哪些状态必须进入看板。">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<i class="fa-solid fa-shield-halved theme-text-dark-blue text-lg"></i>
|
||||
<span class="font-bold theme-text-dark-blue text-[13px]">风险与规则</span>
|
||||
</div>
|
||||
<div class="term-list">
|
||||
<span class="term" data-tip="记忆规则规定经验何时召回、写入、遗忘、替代或标记冲突。">记忆规则</span>
|
||||
<span class="term" data-tip="服务占用记录任务对后台服务的使用关系,防止误停和资源泄漏。">服务占用</span>
|
||||
<span class="term" data-tip="风险等级表示动作、任务、服务或记忆使用的风险级别和审批要求。">风险等级</span>
|
||||
<span class="term" data-tip="健康状态汇总任务、服务、通道和看板等关键组件是否正常。">健康状态</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plane 3: 自动执行与能力 -->
|
||||
<div class="flex items-stretch border theme-border-blue rounded bg-white relative z-10">
|
||||
<div class="w-12 md:w-40 theme-dark-blue text-white flex flex-col items-center justify-center p-2 rounded-l flex-shrink-0" data-tip="自动执行与能力负责真正完成业务动作,包括脚本、流程、智能辅助、外部系统适配和按需后台服务。">
|
||||
<i class="fa-solid fa-gears text-xl mb-1"></i>
|
||||
<span class="font-bold hidden md:block text-center leading-tight">自动执行与能力</span>
|
||||
<span class="font-bold md:hidden text-center writing-vertical-lr text-xs mt-2">自动执行</span>
|
||||
</div>
|
||||
<div class="flex-1 theme-light-blue-bg p-3 grid grid-cols-1 md:grid-cols-7 gap-3 rounded-r">
|
||||
<div class="bg-white border theme-border-blue rounded p-2 shadow-sm min-h-[60px]" data-tip="能力目录管理可治理自动化能力的登记、版本、使用约定、策略和健康。">
|
||||
<div class="font-bold theme-text-dark-blue text-[13px]"><i class="fa-solid fa-box mr-1"></i>能力目录</div>
|
||||
<div class="term-list">
|
||||
<span class="term" data-tip="能力登记把脚本、流程、智能辅助或外部适配纳入统一目录。">能力登记</span>
|
||||
<span class="term" data-tip="版本管理用于区分能力说明、实现和兼容边界。">版本管理</span>
|
||||
<span class="term" data-tip="使用约定说明输入、输出、成果和过程记录要求。">使用约定</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white border theme-border-blue rounded p-2 shadow-sm min-h-[60px]" data-tip="调度中枢负责检查前置条件、匹配自动化能力、安排执行顺序并处理失败恢复。">
|
||||
<div class="font-bold theme-text-dark-blue text-[13px]"><i class="fa-solid fa-sitemap mr-1"></i>调度中枢</div>
|
||||
<div class="term-list">
|
||||
<span class="term" data-tip="前置检查判断任务、服务、记忆、成果和审批之间的依赖是否满足。">前置检查</span>
|
||||
<span class="term" data-tip="能力匹配根据使用约定、风险、可用性和上下文选择合适能力。">能力匹配</span>
|
||||
<span class="term" data-tip="失败恢复处理回滚、替代路径、重试和人工兜底。">失败恢复</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white border theme-border-blue rounded p-2 shadow-sm min-h-[60px]" data-tip="自动脚本适合规则清楚、结果稳定、可重试、可审计的任务。">
|
||||
<div class="font-bold theme-text-dark-blue text-[13px]"><i class="fa-solid fa-code mr-1"></i>自动脚本</div>
|
||||
<div class="term-list"><span class="term" data-tip="确定性任务适合明确输入输出,结果更容易测试和审计。">确定性任务</span></div>
|
||||
</div>
|
||||
<div class="bg-white border theme-border-blue rounded p-2 shadow-sm min-h-[60px]" data-tip="流程编排承载组合能力和子流程,把多步骤操作纳入统一治理。">
|
||||
<div class="font-bold theme-text-dark-blue text-[13px]"><i class="fa-solid fa-diagram-project mr-1"></i>流程编排</div>
|
||||
<div class="term-list">
|
||||
<span class="term" data-tip="组合能力把多个原子能力组合为一个可治理流程。">组合能力</span>
|
||||
<span class="term" data-tip="子流程是在一个任务内部继续拆分的执行片段。">子流程</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white border theme-border-blue rounded p-2 shadow-sm min-h-[60px]" data-tip="智能辅助用于规划、选择、解释和修复建议,但任务状态仍由治理流程统一管理。">
|
||||
<div class="font-bold theme-text-dark-blue text-[13px]"><i class="fa-solid fa-brain mr-1"></i>智能辅助</div>
|
||||
<div class="term-list">
|
||||
<span class="term" data-tip="规划用于把目标拆成可审查的计划和步骤。">规划</span>
|
||||
<span class="term" data-tip="修复建议用于失败诊断、替代能力和补偿路径建议。">修复建议</span>
|
||||
<span class="term" data-tip="解释用于说明计划、风险、失败原因和人工介入建议。">解释</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white border theme-border-blue rounded p-2 shadow-sm min-h-[60px]" data-tip="外部系统适配把网页、设备和机器人能力接入统一业务流程,避免各自独立运行、难以管理。">
|
||||
<div class="font-bold theme-text-dark-blue text-[13px]"><i class="fa-solid fa-robot mr-1"></i>外部系统适配</div>
|
||||
<div class="term-list">
|
||||
<span class="term" data-tip="网页操作用于网页、控制台和浏览器自动化能力。">网页操作</span>
|
||||
<span class="term" data-tip="设备接入用于连接硬件、串口、设备接口或外设能力。">设备接入</span>
|
||||
<span class="term" data-tip="机器人控制用于机器人本体能力,如感知、运动和语音。">机器人控制</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white border theme-border-blue rounded p-2 shadow-sm min-h-[60px]" data-tip="按需后台服务负责邮件读取、监听器、浏览器环境、配套后台程序和外部服务进程的启动、占用、健康、日志和空闲回收。">
|
||||
<div class="font-bold theme-text-dark-blue text-[13px]"><i class="fa-solid fa-server mr-1"></i>按需后台服务</div>
|
||||
<div class="term-list">
|
||||
<span class="term" data-tip="服务定义描述后台服务如何启动、停止、授权、检查健康和对外提供能力。">服务定义</span>
|
||||
<span class="term" data-tip="运行实例是服务的一次实际运行,记录访问地址、进程和状态。">运行实例</span>
|
||||
<span class="term" data-tip="占用关系表示某个任务正在依赖服务实例,决定服务是否能回收。">占用关系</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plane 4: 运营记录与看板 -->
|
||||
<div class="flex items-stretch border theme-border-blue rounded bg-white relative z-10">
|
||||
<div class="w-12 md:w-40 theme-dark-blue text-white flex flex-col items-center justify-center p-2 rounded-l flex-shrink-0" data-tip="运营记录与看板沉淀业务过程、成果、经验和服务状态,让管理者能看进度、看风险、看结果、看责任。">
|
||||
<i class="fa-solid fa-database text-xl mb-1"></i>
|
||||
<span class="font-bold hidden md:block text-center leading-tight">运营记录与看板</span>
|
||||
<span class="font-bold md:hidden text-center writing-vertical-lr text-xs mt-2">运营看板</span>
|
||||
</div>
|
||||
<div class="flex-1 theme-light-blue-bg p-3 grid grid-cols-1 md:grid-cols-7 gap-3 rounded-r">
|
||||
<div class="bg-white border theme-border-blue rounded p-2 shadow-sm min-h-[65px]" data-tip="业务状态台账保存计划、任务、执行记录和审批记录的当前状态,是进度和责任判断的依据。">
|
||||
<div class="font-bold theme-text-dark-blue text-[13px]"><i class="fa-solid fa-lock mr-1"></i>业务状态台账</div>
|
||||
<div class="term-list">
|
||||
<span class="term" data-tip="执行计划状态表示计划处于草稿、审核、激活、阻塞、完成或取消等阶段。">执行计划</span>
|
||||
<span class="term" data-tip="工作任务状态表示任务是否待处理、排队、执行中、暂停、失败或完成。">工作任务</span>
|
||||
<span class="term" data-tip="执行记录状态表示一次执行尝试的生命周期和健康信息。">执行记录</span>
|
||||
<span class="term" data-tip="审批记录状态表示审批请求是否待处理、已通过、已拒绝、已过期或已取消。">审批记录</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white border theme-border-blue rounded p-2 shadow-sm min-h-[65px]" data-tip="过程日志记录所有关键变化,是审计、回放、看板生成和问题追踪的基础。">
|
||||
<div class="font-bold theme-text-dark-blue text-[13px]"><i class="fa-regular fa-file-lines mr-1"></i>过程日志</div>
|
||||
<div class="term-list"><span class="term" data-tip="关键过程记录是已经发生的业务变化,如任务开始、执行失败、服务启动或经验更新。">关键过程记录</span></div>
|
||||
</div>
|
||||
<div class="bg-white border theme-border-blue rounded p-2 shadow-sm min-h-[65px]" data-tip="成果资料库存放执行生成的文件、报告、截图和结构化数据结果。">
|
||||
<div class="font-bold theme-text-dark-blue text-[13px]"><i class="fa-solid fa-folder-open mr-1"></i>成果资料库</div>
|
||||
<div class="term-list">
|
||||
<span class="term" data-tip="文件是执行生成或引用的本地或远端文件产物。">文件</span>
|
||||
<span class="term" data-tip="报告是面向人或验收的总结性产物。">报告</span>
|
||||
<span class="term" data-tip="截图用于证明界面、网页、浏览器或视觉状态。">截图</span>
|
||||
<span class="term" data-tip="数据结果是可被系统继续读取的结构化数据、表格、记录或索引。">数据结果</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white border theme-border-blue rounded p-2 shadow-sm min-h-[65px]" data-tip="经验与记忆库存放可复用知识,不代表当前任务状态,必须受来源、置信度、保留和遗忘规则约束。">
|
||||
<div class="font-bold theme-text-dark-blue text-[13px]"><i class="fa-solid fa-brain mr-1"></i>经验与记忆库</div>
|
||||
<div class="term-list">
|
||||
<span class="term" data-tip="项目事实是相对稳定、可引用的信息,但仍应有来源和置信度。">项目事实</span>
|
||||
<span class="term" data-tip="使用偏好记录用户、项目或团队的长期选择倾向。">使用偏好</span>
|
||||
<span class="term" data-tip="关键决策记录已经确定的架构、流程、策略或边界选择。">关键决策</span>
|
||||
<span class="term" data-tip="处理经验记录过去执行中的成功路径、失败模式和修复方法。">处理经验</span>
|
||||
<span class="term" data-tip="观察摘要从邮件、消息、工单、服务日志或运行结果中提炼可复用结论。">观察摘要</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white border theme-border-blue rounded p-2 shadow-sm min-h-[65px]" data-tip="运营看板面向管理、查询和协同,由状态、过程日志、成果、经验记忆和服务健康汇总生成。">
|
||||
<div class="font-bold theme-text-dark-blue text-[13px]"><i class="fa-solid fa-chart-column mr-1"></i>运营看板</div>
|
||||
<div class="term-list">
|
||||
<span class="term" data-tip="任务看板展示任务状态、阻塞、风险和下一步动作。">任务看板</span>
|
||||
<span class="term" data-tip="审批队列集中展示等待人工处理的治理节点。">审批队列</span>
|
||||
<span class="term" data-tip="记忆索引让用户查看、修正、替代或遗忘长期记忆。">记忆索引</span>
|
||||
<span class="term" data-tip="服务健康展示服务实例、占用关系、重启和最近错误。">服务健康</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white border theme-border-blue rounded p-2 shadow-sm min-h-[65px]" data-tip="全程追踪按时间顺序展示计划、任务、执行、审批、服务和经验记忆变化。">
|
||||
<div class="font-bold theme-text-dark-blue text-[13px]"><i class="fa-solid fa-clock-rotate-left mr-1"></i>全程追踪</div>
|
||||
<div class="term-list">
|
||||
<span class="term" data-tip="过程回放按时间顺序复原业务过程和人工操作。">过程回放</span>
|
||||
<span class="term" data-tip="诊断用于定位失败原因、依赖问题、服务退化或策略阻塞。">诊断</span>
|
||||
<span class="term" data-tip="追责用于确认谁在什么时候通过什么操作改变了什么状态。">追责</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white border theme-border-blue rounded p-2 shadow-sm min-h-[65px]" data-tip="运营指标用于验收、运营和持续改进,衡量服务时效、成功率、响应耗时和人工介入率。">
|
||||
<div class="font-bold theme-text-dark-blue text-[13px]"><i class="fa-solid fa-chart-simple mr-1"></i>运营指标</div>
|
||||
<div class="term-list">
|
||||
<span class="term" data-tip="服务时效衡量任务、服务或工作流的服务水平和完成时限。">服务时效</span>
|
||||
<span class="term" data-tip="成功率衡量任务、能力、服务或计划执行成功的比例。">成功率</span>
|
||||
<span class="term" data-tip="响应耗时衡量目标接入、任务排队、执行、看板生成和审批等待时间。">响应耗时</span>
|
||||
<span class="term" data-tip="人工介入率衡量有多少任务需要审批、暂停、接管或人工修复。">人工介入率</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- End of Main Architecture Container -->
|
||||
|
||||
<!-- 核心闭环 -->
|
||||
<div class="mt-4 border border-blue-200 rounded flex overflow-hidden shadow-sm">
|
||||
<div class="w-16 md:w-24 theme-dark-blue text-white flex flex-col items-center justify-center p-2 flex-shrink-0" data-tip="核心闭环展示一次业务目标从接入、计划、执行、沉淀、看板到人工决策的完整路径。">
|
||||
<i class="fa-solid fa-rotate text-xl mb-1"></i>
|
||||
<span class="font-bold text-center text-xs md:text-sm">核心闭环</span>
|
||||
</div>
|
||||
<div class="flex-1 bg-white p-4 relative overflow-x-auto">
|
||||
<div class="flex items-center justify-between min-w-[1260px] mb-4 mt-2 px-4">
|
||||
<div class="flex items-center gap-1 font-bold theme-text-dark-blue" data-tip="业务目标是用户或外部系统想达成的结果,进入系统后会先转成可治理的执行计划。"><i class="fa-solid fa-bullseye mr-1"></i>业务目标</div>
|
||||
<i class="fa-solid fa-arrow-right text-blue-300 text-sm"></i>
|
||||
<div class="flex items-center gap-1 border border-blue-200 px-2 py-1 rounded theme-text-dark-blue" data-tip="执行计划描述为什么做、做成什么、如何拆,并支持人工审查、冻结和重新规划。"><i class="fa-regular fa-file-lines mr-1"></i>执行计划</div>
|
||||
<i class="fa-solid fa-arrow-right text-blue-300 text-sm"></i>
|
||||
<div class="flex items-center gap-1 border border-blue-200 px-2 py-1 rounded theme-text-dark-blue" data-tip="工作任务是由计划步骤拆出来的可调度工作单元,负责绑定能力、输入、优先级和依赖。"><i class="fa-solid fa-list-ul mr-1"></i>工作任务</div>
|
||||
<i class="fa-solid fa-arrow-right text-blue-300 text-sm"></i>
|
||||
<div class="flex items-center gap-1 border border-blue-200 px-2 py-1 rounded theme-text-dark-blue" data-tip="能力匹配为当前任务选择正式自动化能力或业务流程,必须满足版本、策略、权限和输入输出约定。"><i class="fa-solid fa-link mr-1"></i>能力匹配</div>
|
||||
<i class="fa-solid fa-arrow-right text-blue-300 text-sm"></i>
|
||||
<div class="flex items-center gap-1 border border-blue-200 px-2 py-1 rounded theme-text-dark-blue" data-tip="执行前准备需要召回的经验记忆、需要启动或占用的后台服务,例如邮件读取、浏览器环境或外部服务进程。"><i class="fa-solid fa-server mr-1"></i>经验与服务准备</div>
|
||||
<i class="fa-solid fa-arrow-right text-blue-300 text-sm"></i>
|
||||
<div class="flex items-center gap-1 border border-blue-200 px-2 py-1 rounded theme-text-dark-blue" data-tip="执行记录代表任务的一次执行尝试,记录执行人、服务占用、运行状态、恢复点和最终状态。"><i class="fa-regular fa-circle-play mr-1"></i>执行记录</div>
|
||||
<i class="fa-solid fa-arrow-right text-blue-300 text-sm"></i>
|
||||
<div class="flex items-center gap-1 font-bold theme-text-dark-blue" data-tip="自动执行阶段由脚本、流程、智能辅助或外部系统适配真正调用资源,并持续回写过程记录和状态。"><i class="fa-solid fa-gear mr-1"></i>自动执行</div>
|
||||
<i class="fa-solid fa-arrow-right text-blue-300 text-sm"></i>
|
||||
<div class="flex items-center gap-1 border border-blue-200 px-2 py-1 rounded theme-text-dark-blue bg-gray-50" data-tip="执行结果沉淀为业务状态、过程日志、成果资料和可复用经验,后续看板和审计都从这里生成。"><i class="fa-solid fa-database mr-1"></i>过程记录与成果沉淀</div>
|
||||
<i class="fa-solid fa-arrow-right text-blue-300 text-sm"></i>
|
||||
<div class="flex items-center gap-1 font-bold theme-text-dark-blue" data-tip="运营看板面向管理和查询,包括任务看板、全程追踪、审批队列、记忆索引和服务健康。"><i class="fa-solid fa-chart-column mr-1"></i>运营看板</div>
|
||||
<i class="fa-solid fa-arrow-right text-blue-300 text-sm"></i>
|
||||
<div class="flex items-center gap-1 font-bold theme-red border border-red-200 px-2 py-1 rounded" data-tip="人工决策允许审批、暂停、恢复、取消、接管和重新规划,并把每次干预记录下来。"><i class="fa-regular fa-user mr-1"></i>人工决策</div>
|
||||
</div>
|
||||
<!-- 底部虚线回环 -->
|
||||
<div class="relative mt-6 min-w-[1260px] h-4">
|
||||
<div class="absolute w-[80%] left-[10%] border-t border-dashed border-red-500 top-1/2"></div>
|
||||
<div class="absolute left-[10%] top-1/2 -mt-1"><div class="arrow-left-red"></div></div>
|
||||
<div class="absolute w-full flex justify-center -top-3">
|
||||
<span class="bg-white px-2 text-xs font-bold theme-red" data-tip="人工决策会回到计划或任务层,改变后续执行路径,而不是直接绕过业务流程。">审批 / 暂停 / 恢复 / 取消 / 人工接管 / 重新规划</span>
|
||||
</div>
|
||||
<!-- 垂直连接线 -->
|
||||
<div class="absolute left-[10%] bottom-1/2 h-4 border-l border-dashed border-red-500"></div>
|
||||
<div class="absolute right-[10%] bottom-1/2 h-6 border-r border-dashed border-red-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
const tooltip = document.createElement("div");
|
||||
tooltip.id = "sg-tooltip";
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
let activeTipTarget = null;
|
||||
|
||||
const exampleRules = [
|
||||
{ keys: ["汇报专用静态网站"], example: "双击 report-site/index.html 打开后,可在业务架构总览和业务流程图之间切换。" },
|
||||
{ keys: ["业务架构总览"], example: "汇报开场先用这一页讲清楚 sgRobot 的四个业务分区。" },
|
||||
{ keys: ["业务流程图"], example: "讲邮件触发流程时,可以切到流程页看它经过哪些架构模块。" },
|
||||
{ keys: ["外部参与者"], example: "业务人员提交目标,运维看服务状态,审批人处理高风险动作。" },
|
||||
{ keys: ["用户是目标"], example: "用户提出“把今天收到的客户邮件整理成日报”。" },
|
||||
{ keys: ["运维关注"], example: "运维发现邮件读取服务连续失败后,查看健康状态并重启服务。" },
|
||||
{ keys: ["审批人负责"], example: "系统要自动发送外部邮件前,审批人确认内容和收件人。" },
|
||||
{ keys: ["外部业务系统"], example: "工单系统产生新工单后,通过系统接口触发 sgRobot 处理。" },
|
||||
|
||||
{ keys: ["业务入口与协同"], example: "用户提交巡检需求,外部系统推送消息,审批人在工作台确认风险动作。" },
|
||||
{ keys: ["业务需求入口"], example: "每天 9 点的巡检计划、人工提交的日报需求、邮件到达事件都先进入这里。" },
|
||||
{ keys: ["人工提交"], example: "负责人在工作台提交“生成本周设备巡检报告”。" },
|
||||
{ keys: ["系统接口"], example: "CRM 系统把新客户资料同步过来,请求自动补全客户画像。" },
|
||||
{ keys: ["计划任务"], example: "每个工作日 18 点自动汇总当天处理结果。" },
|
||||
{ keys: ["事件触发"], example: "指定邮箱收到带附件的邮件后,自动触发读取和归档流程。" },
|
||||
{ keys: ["协同工作台"], example: "审批人打开工作台查看待审批队列,运维查看失败任务。" },
|
||||
{ keys: ["查看"], example: "查看某个任务当前是排队中、执行中还是等待审批。" },
|
||||
{ keys: ["操作"], example: "用户手动暂停一个执行中的网页采集任务。" },
|
||||
{ keys: ["审批"], example: "自动发送客户邮件前,需要审批人确认内容。" },
|
||||
{ keys: ["回放"], example: "复盘昨天失败的巡检任务,查看它在哪一步超时。" },
|
||||
{ keys: ["成果浏览"], example: "打开自动生成的报告、截图和数据表进行验收。" },
|
||||
|
||||
{ keys: ["任务治理与风控"], example: "把“处理客户邮件”拆成读取、分类、回复、归档,并在发信前要求审批。" },
|
||||
{ keys: ["目标计划管理"], example: "把“生成月度巡检报告”拆成采集数据、生成报告、提交验收。" },
|
||||
{ keys: ["业务目标"], example: "目标可以是“完成今日邮件分类并输出异常清单”。" },
|
||||
{ keys: ["执行计划"], example: "计划中写明先读取邮件,再分类,再生成日报,最后等待验收。" },
|
||||
{ keys: ["计划步骤"], example: "“登录系统下载报表”就是计划里的一个步骤。" },
|
||||
{ keys: ["任务执行管理"], example: "同一计划下的多个采集任务可以排队、重试、暂停或取消。" },
|
||||
{ keys: ["工作任务"], example: "“读取 inbox 中未处理邮件”是一项可调度工作任务。" },
|
||||
{ keys: ["执行记录"], example: "第一次读取邮件失败后,第二次重试会形成新的执行记录。" },
|
||||
{ keys: ["自动重试"], example: "网页临时超时后,系统在 3 分钟后自动再试一次。" },
|
||||
{ keys: ["恢复点"], example: "长流程已经完成数据下载,失败后从报告生成环节继续。" },
|
||||
{ keys: ["超时管控"], example: "浏览器操作超过 10 分钟未完成,就转入失败恢复或人工介入。" },
|
||||
{ keys: ["人工治理"], example: "任务可以被审批、暂停、恢复、取消、接管或重新规划。" },
|
||||
{ keys: ["审批通过"], example: "审批人确认发信内容无误后,允许流程继续发送。" },
|
||||
{ keys: ["暂停"], example: "发现输入资料不完整时,先暂停任务等待补充。" },
|
||||
{ keys: ["恢复"], example: "资料补齐后,从暂停点继续执行,不必从头开始。" },
|
||||
{ keys: ["取消"], example: "业务目标已经撤销,取消计划并释放后台服务。" },
|
||||
{ keys: ["人工接管"], example: "网页验证码无法自动处理时,转给人工完成后再继续。" },
|
||||
{ keys: ["重新规划"], example: "原接口不可用,改走网页下载报表的替代路径。" },
|
||||
{ keys: ["风险与规则"], example: "涉及外部发信、删除文件、控制设备的动作必须进入审批。" },
|
||||
{ keys: ["记忆规则"], example: "用户偏好可以长期保留,但临时验证码不允许写入记忆。" },
|
||||
{ keys: ["服务占用"], example: "某个任务正在使用邮件读取服务时,系统不会把该服务提前回收。" },
|
||||
{ keys: ["风险等级"], example: "读取邮件是低风险,批量发信是高风险,需要审批。" },
|
||||
{ keys: ["健康状态"], example: "邮件服务正常、浏览器环境异常、任务队列积压都能被展示出来。" },
|
||||
|
||||
{ keys: ["自动执行与能力"], example: "系统调用脚本、流程、智能辅助和外部系统适配来完成真实业务动作。" },
|
||||
{ keys: ["能力目录"], example: "“读取邮件”“生成报告”“网页下载报表”都登记成可复用能力。" },
|
||||
{ keys: ["能力登记"], example: "新增一个“登录后台下载销售报表”的自动化能力。" },
|
||||
{ keys: ["版本管理"], example: "报表下载流程改版后,保留 v1 和 v2,避免影响旧任务。" },
|
||||
{ keys: ["使用约定"], example: "规定输入必须包含日期范围,输出必须包含报告文件和数据结果。" },
|
||||
{ keys: ["调度中枢"], example: "先检查账号和服务是否可用,再安排读取邮件、分类和生成报告。" },
|
||||
{ keys: ["前置检查"], example: "执行前确认邮箱权限、浏览器环境、审批状态都满足条件。" },
|
||||
{ keys: ["能力匹配"], example: "同样是取报表,优先走接口,接口失败再走网页自动化。" },
|
||||
{ keys: ["失败恢复"], example: "下载失败后先重试,仍失败则换备用路径或转人工处理。" },
|
||||
{ keys: ["自动脚本"], example: "每天固定导出报表、清洗数据、生成 CSV 文件。" },
|
||||
{ keys: ["确定性任务"], example: "把固定格式表格转换成统一字段就是确定性任务。" },
|
||||
{ keys: ["流程编排"], example: "把邮件读取、附件解析、报告生成、审批发送组合成一条流程。" },
|
||||
{ keys: ["组合能力"], example: "“客户邮件处理”由读取、分类、回复草稿、归档四个能力组合。" },
|
||||
{ keys: ["子流程"], example: "报告生成任务内部再拆成取数、制图、写摘要三个子流程。" },
|
||||
{ keys: ["智能辅助"], example: "根据失败日志判断可能原因,并给出下一步处理建议。" },
|
||||
{ keys: ["规划用于"], example: "把“做一份巡检报告”拆成采集、分析、生成、验收。" },
|
||||
{ keys: ["修复建议"], example: "系统提示“登录页结构变化,建议改用备用选择器或人工接管”。" },
|
||||
{ keys: ["解释用于"], example: "向审批人说明为什么这一步被判定为高风险。" },
|
||||
{ keys: ["外部系统适配"], example: "同一业务流程可以同时操作网页后台、设备接口和机器人。" },
|
||||
{ keys: ["网页操作"], example: "自动登录后台,下载当天订单报表。" },
|
||||
{ keys: ["设备接入"], example: "读取设备状态或通过设备接口下发巡检命令。" },
|
||||
{ keys: ["机器人控制"], example: "让机器人执行移动、拍照、语音播报等动作。" },
|
||||
{ keys: ["按需后台服务"], example: "只有处理邮件任务时才启动邮件读取服务,空闲后自动回收。" },
|
||||
{ keys: ["服务定义"], example: "定义邮件读取服务如何启动、检查健康、停止和授权。" },
|
||||
{ keys: ["运行实例"], example: "当前启动了一个邮件读取实例,状态为运行中。" },
|
||||
{ keys: ["占用关系"], example: "任务 A 正在使用浏览器环境,因此该环境不能被其他任务关闭。" },
|
||||
|
||||
{ keys: ["运营记录与看板"], example: "领导可以看到任务进度、风险节点、成果报告和人工介入情况。" },
|
||||
{ keys: ["业务状态台账"], example: "某计划已激活,三个任务成功,一个任务等待审批。" },
|
||||
{ keys: ["执行计划状态"], example: "计划当前处于“等待审批”或“执行中”。" },
|
||||
{ keys: ["工作任务状态"], example: "任务当前是排队中、执行中、暂停、失败或完成。" },
|
||||
{ keys: ["执行记录状态"], example: "第 2 次执行成功,第 1 次执行因网页超时失败。" },
|
||||
{ keys: ["审批记录状态"], example: "发信审批已通过,删除文件审批被拒绝。" },
|
||||
{ keys: ["过程日志"], example: "记录任务开始、服务启动、审批通过、执行失败等关键变化。" },
|
||||
{ keys: ["关键过程记录"], example: "“09:03 邮件读取服务启动”“09:05 附件解析失败”。" },
|
||||
{ keys: ["成果资料库"], example: "保存巡检报告、页面截图、附件解析结果和导出的表格。" },
|
||||
{ keys: ["文件是"], example: "保存下载的 Excel、PDF 报告或邮件附件。" },
|
||||
{ keys: ["报告是"], example: "自动生成日报、巡检报告或异常汇总。" },
|
||||
{ keys: ["截图用于"], example: "保留网页提交成功页面,作为验收证据。" },
|
||||
{ keys: ["数据结果"], example: "把邮件分类结果保存成结构化表格,供后续统计。" },
|
||||
{ keys: ["经验与记忆库"], example: "记录某项目常用账号、报告格式偏好、失败处理经验。" },
|
||||
{ keys: ["项目事实"], example: "某系统的报表入口固定在“运营中心 / 日报”菜单下。" },
|
||||
{ keys: ["使用偏好"], example: "用户偏好报告用中文摘要,并保留原始数据表。" },
|
||||
{ keys: ["关键决策"], example: "决定所有外部发信必须先进入人工审批。" },
|
||||
{ keys: ["处理经验"], example: "某网页下载失败时,先刷新登录态再重试。" },
|
||||
{ keys: ["观察摘要"], example: "最近三次失败都集中在同一个外部系统登录环节。" },
|
||||
{ keys: ["运营看板"], example: "展示今日任务成功率、待审批数量、失败任务和服务健康。" },
|
||||
{ keys: ["任务看板"], example: "看到哪些任务已完成、哪些阻塞、哪些需要人工介入。" },
|
||||
{ keys: ["审批队列"], example: "所有等待确认的发信、删除、设备控制动作集中展示。" },
|
||||
{ keys: ["记忆索引"], example: "查看并修正“用户偏好报告格式”的长期记忆。" },
|
||||
{ keys: ["服务健康"], example: "邮件读取服务正常,浏览器环境重启 1 次,设备连接异常。" },
|
||||
{ keys: ["全程追踪"], example: "按时间线查看一次任务从提交到验收的完整过程。" },
|
||||
{ keys: ["过程回放"], example: "复盘某次失败,看到它在附件解析步骤出错。" },
|
||||
{ keys: ["诊断"], example: "定位失败原因是账号过期、网页变化还是服务不可用。" },
|
||||
{ keys: ["追责"], example: "确认是谁在什么时间批准了批量发信动作。" },
|
||||
{ keys: ["运营指标"], example: "每周统计成功率、平均耗时和人工介入率。" },
|
||||
{ keys: ["服务时效"], example: "巡检报告要求 30 分钟内完成并提交。" },
|
||||
{ keys: ["成功率"], example: "本周邮件处理任务 100 次,成功 96 次。" },
|
||||
{ keys: ["响应耗时"], example: "从邮件到达到生成处理结果平均耗时 2 分钟。" },
|
||||
{ keys: ["人工介入率"], example: "本月 12% 的任务因审批或异常需要人工介入。" },
|
||||
|
||||
{ keys: ["核心闭环"], example: "一封客户邮件进入系统后,生成任务、自动处理、沉淀成果,再进入看板和复盘。" },
|
||||
{ keys: ["经验与服务准备"], example: "执行前先读取用户偏好,并启动邮件读取或浏览器服务。" },
|
||||
{ keys: ["过程记录与成果沉淀"], example: "任务完成后保存报告、截图、日志和可复用处理经验。" },
|
||||
{ keys: ["人工决策"], example: "审批人确认批量发信、暂停异常任务或要求重新规划。" },
|
||||
{ keys: ["审批 / 暂停"], example: "审批不通过时,流程回到计划层重新规划。" },
|
||||
];
|
||||
|
||||
function normalizeText(value) {
|
||||
return (value || "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function tooltipExample(target) {
|
||||
const label = normalizeText(target.textContent);
|
||||
const base = normalizeText(target.dataset.tip);
|
||||
const haystack = `${label} ${base}`;
|
||||
const rule = exampleRules.find((item) => item.keys.every((key) => haystack.includes(key)));
|
||||
if (rule) return rule.example;
|
||||
return "领导查看该节点时,可以把它对应到一次人工交办、邮件触发、定时巡检或高风险审批中的具体动作。";
|
||||
}
|
||||
|
||||
function tooltipText(target) {
|
||||
const text = normalizeText(target.dataset.tip);
|
||||
if (!text) return "";
|
||||
if (text.includes("例:")) return text;
|
||||
return `${text}\n例:${tooltipExample(target)}`;
|
||||
}
|
||||
|
||||
function positionTooltip(event) {
|
||||
const margin = 14;
|
||||
const offset = 16;
|
||||
const rect = tooltip.getBoundingClientRect();
|
||||
let left = event.clientX + offset;
|
||||
let top = event.clientY + offset;
|
||||
|
||||
if (left + rect.width + margin > window.innerWidth) {
|
||||
left = Math.max(margin, event.clientX - rect.width - offset);
|
||||
}
|
||||
if (top + rect.height + margin > window.innerHeight) {
|
||||
top = Math.max(margin, event.clientY - rect.height - offset);
|
||||
}
|
||||
|
||||
tooltip.style.left = `${left}px`;
|
||||
tooltip.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
function showTooltip(target, event) {
|
||||
const text = tooltipText(target);
|
||||
if (!text) return;
|
||||
activeTipTarget = target;
|
||||
tooltip.textContent = text;
|
||||
tooltip.classList.add("is-visible");
|
||||
positionTooltip(event);
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
activeTipTarget = null;
|
||||
tooltip.classList.remove("is-visible");
|
||||
}
|
||||
|
||||
document.addEventListener("pointerover", (event) => {
|
||||
const target = event.target.closest("[data-tip]");
|
||||
if (target) showTooltip(target, event);
|
||||
});
|
||||
|
||||
document.addEventListener("pointermove", (event) => {
|
||||
if (activeTipTarget) positionTooltip(event);
|
||||
});
|
||||
|
||||
document.addEventListener("pointerout", (event) => {
|
||||
if (!activeTipTarget) return;
|
||||
const next = event.relatedTarget;
|
||||
if (!next || !activeTipTarget.contains(next)) hideTooltip();
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-tip]").forEach((element) => {
|
||||
element.setAttribute("tabindex", "0");
|
||||
element.setAttribute("aria-label", tooltipText(element));
|
||||
element.addEventListener("focus", (event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
showTooltip(event.currentTarget, {
|
||||
clientX: rect.left + rect.width / 2,
|
||||
clientY: rect.top + rect.height / 2,
|
||||
});
|
||||
});
|
||||
element.addEventListener("blur", hideTooltip);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,626 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>sgRobot 五月底上线任务分工表</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
.theme-dark-blue { background-color: #0d2758; }
|
||||
.theme-text-dark-blue { color: #0d2758; }
|
||||
.theme-light-blue-bg { background-color: #f0f6fc; }
|
||||
.theme-border-blue { border-color: #aec3db; }
|
||||
.theme-red { color: #d92d20; }
|
||||
|
||||
body {
|
||||
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", "Source Han Sans SC", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.text-sm { font-size: 1.08rem !important; }
|
||||
.text-base { font-size: 1.24rem !important; }
|
||||
.text-lg { font-size: 1.4rem !important; }
|
||||
.text-xl { font-size: 1.62rem !important; }
|
||||
.text-3xl { font-size: 2.75rem !important; }
|
||||
.text-\[13px\] { font-size: 1.14rem !important; }
|
||||
|
||||
[data-tip] { cursor: help; }
|
||||
|
||||
.panel {
|
||||
border: 1px solid #aec3db;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
box-shadow: 0 8px 20px rgba(13, 39, 88, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px 10px 16px;
|
||||
border-bottom: 1px solid #d8e4f2;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #0d2758;
|
||||
font-weight: 900;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.panel-tag {
|
||||
border: 1px solid #c8d7e8;
|
||||
border-radius: 999px;
|
||||
padding: 3px 10px;
|
||||
color: #5f7082;
|
||||
background: #f8fbff;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
background: #f0f6fc;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
border: 1px solid #aec3db;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
padding: 12px;
|
||||
color: #0d2758;
|
||||
}
|
||||
|
||||
.summary-card strong {
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.summary-card span {
|
||||
display: block;
|
||||
color: #5f7082;
|
||||
font-weight: 800;
|
||||
line-height: 1.32;
|
||||
}
|
||||
|
||||
.task-table-wrap {
|
||||
overflow-x: auto;
|
||||
background: #f0f6fc;
|
||||
}
|
||||
|
||||
.task-table {
|
||||
width: 100%;
|
||||
min-width: 1320px;
|
||||
border-collapse: collapse;
|
||||
color: #0d2758;
|
||||
}
|
||||
|
||||
.task-table th {
|
||||
background: #0d2758;
|
||||
color: white;
|
||||
text-align: left;
|
||||
padding: 11px 12px;
|
||||
font-weight: 900;
|
||||
border-right: 1px solid #375078;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-table td {
|
||||
background: white;
|
||||
padding: 11px 12px;
|
||||
border-right: 1px solid #d8e4f2;
|
||||
border-bottom: 1px solid #d8e4f2;
|
||||
vertical-align: top;
|
||||
font-weight: 760;
|
||||
line-height: 1.38;
|
||||
}
|
||||
|
||||
.task-table tr:nth-child(even) td {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
font-weight: 900;
|
||||
color: #0d2758;
|
||||
}
|
||||
|
||||
.date-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
background: #0d2758;
|
||||
color: white;
|
||||
padding: 4px 10px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.owner-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #c8d7e8;
|
||||
background: white;
|
||||
color: #0d2758;
|
||||
padding: 4px 10px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.boundary-ok {
|
||||
color: #0d2758;
|
||||
}
|
||||
|
||||
.boundary-no {
|
||||
color: #9f1239;
|
||||
}
|
||||
|
||||
.interface-map-wrap {
|
||||
overflow-x: auto;
|
||||
background: #f0f6fc;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.interface-map {
|
||||
min-width: 1320px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border: 1px solid #d8e4f2;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.map-note {
|
||||
padding: 10px 14px 14px 14px;
|
||||
background: #f0f6fc;
|
||||
color: #5f7082;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
background: #f0f6fc;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.time-card {
|
||||
min-width: 170px;
|
||||
border: 1px solid #aec3db;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
padding: 12px;
|
||||
color: #0d2758;
|
||||
}
|
||||
|
||||
.time-date {
|
||||
display: inline-flex;
|
||||
background: #0d2758;
|
||||
color: white;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
font-weight: 900;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.time-title {
|
||||
font-weight: 900;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
color: #5f7082;
|
||||
font-weight: 760;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
#sg-tooltip {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
max-width: 460px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: #0d2758;
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45;
|
||||
box-shadow: 0 12px 32px rgba(13, 39, 88, 0.22);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
#sg-tooltip.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.timeline { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.summary-grid { grid-template-columns: 1fr; }
|
||||
.timeline { grid-template-columns: 1fr; }
|
||||
.panel-head { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-white p-4 md:p-8 min-h-screen text-sm select-none">
|
||||
|
||||
<div class="max-w-[1780px] mx-auto mb-4 border border-amber-300 bg-amber-50 text-amber-900 rounded-lg px-4 py-3 shadow-sm">
|
||||
<div class="font-black text-base">历史汇报版</div>
|
||||
<div class="text-sm leading-relaxed mt-1">
|
||||
本页面保留为早期 10 任务包汇报视图,不再作为团队分工和接口冻结源头。当前分工、接口 owner、状态、Mock 和验收证据以
|
||||
<code>plan/2026-04-26-architecture-team-handoff/</code>
|
||||
及其中的 <code>interfaces/interface-freeze-register.md</code> 为准。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="max-w-[1780px] mx-auto mb-5 flex flex-wrap items-center justify-between gap-3 border border-blue-200 rounded-lg px-4 py-3 shadow-sm bg-white">
|
||||
<div class="flex items-center gap-2 theme-text-dark-blue font-bold text-base" data-tip="汇报专用静态网站,可直接双击 index.html 打开,不需要启动本地服务。">
|
||||
<i class="fa-solid fa-display"></i>
|
||||
<span>sgRobot 汇报静态网站</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="index.html" class="border theme-border-blue theme-text-dark-blue rounded px-4 py-2 font-bold bg-white hover:bg-blue-50" data-tip="返回业务架构总览。">业务架构总览</a>
|
||||
<a href="flows.html" class="border theme-border-blue theme-text-dark-blue rounded px-4 py-2 font-bold bg-white hover:bg-blue-50" data-tip="查看业务流程如何落到架构模块。">业务流程图</a>
|
||||
<a href="roadmap.html" class="theme-dark-blue text-white rounded px-4 py-2 font-bold shadow-sm" data-tip="当前页面只看任务、负责人、日期和边界。">里程碑与分工</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header class="max-w-[1780px] mx-auto mb-6">
|
||||
<div class="flex items-center mb-2" data-tip="这一页只保留领导最需要看的信息:哪个任务、谁负责、什么时候完成、边界是什么。">
|
||||
<div class="w-10 h-10 theme-dark-blue text-white flex items-center justify-center rounded shadow-sm mr-3">
|
||||
<i class="fa-solid fa-list-check text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold theme-text-dark-blue tracking-wide">sgRobot <span class="font-bold">五月底上线任务分工表</span></h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-14 text-[13px] font-medium text-gray-700 leading-relaxed" data-tip="负责人先按角色写,后续可以替换成具体姓名。">
|
||||
原则:<span class="font-bold">一个任务,一个负责人,一个完成日期,一个边界</span>。各模块只按公共接口交付,不互相改代码;5 月 31 日统一验收。
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-[1780px] mx-auto flex flex-col gap-5">
|
||||
<section class="panel" data-tip="这四个数字是领导快速判断范围用的。">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title"><i class="fa-solid fa-circle-info"></i><span>一页结论</span></div>
|
||||
<span class="panel-tag" data-tip="如需具体人名,直接把负责人角色替换为姓名即可。">负责人先按角色占位</span>
|
||||
</div>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card" data-tip="共 10 个任务包;公共接口只做口径冻结,核心控制另行实现,避免一个负责人背所有责任。"><strong>10 个</strong><span>任务包</span></div>
|
||||
<div class="summary-card" data-tip="只设一个最终日期:2026 年 5 月 31 日完成上线验收。"><strong>5/31</strong><span>上线验收收口</span></div>
|
||||
<div class="summary-card" data-tip="模块之间只通过公共接口、测试样例和交付物对接。"><strong>互不改代码</strong><span>只按接口交付</span></div>
|
||||
<div class="summary-card" data-tip="测试和发布有否决权,没证据就不能算完成。"><strong>有证据</strong><span>才算完成</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" data-tip="这是核心表。领导看这一张表就能知道谁负责什么、哪天完成、边界在哪里。">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title"><i class="fa-solid fa-table"></i><span>任务分工总表</span></div>
|
||||
<span class="panel-tag" data-tip="完成日期是建议倒排,可根据真实人员情况微调。">按完成日期倒排</span>
|
||||
</div>
|
||||
<div class="task-table-wrap">
|
||||
<table class="task-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务</th>
|
||||
<th>负责人</th>
|
||||
<th>完成日期</th>
|
||||
<th>交付物</th>
|
||||
<th>边界:只负责</th>
|
||||
<th>边界:不负责</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr data-tip="公共接口不要求 4 月 30 日全部实现,只要求各负责人把字段、调用方向、Mock 数据和验收口径确认下来。">
|
||||
<td><span class="task-name">公共接口口径冻结</span></td>
|
||||
<td><span class="owner-pill">总负责人 + 各模块负责人</span></td>
|
||||
<td><span class="date-pill">4/30</span></td>
|
||||
<td>接口目录、字段草案、Mock 数据、对接方向、验收口径</td>
|
||||
<td class="boundary-ok">只冻结模块之间怎么对接,以及每个模块交付什么数据</td>
|
||||
<td class="boundary-no">不要求一次性完成所有后端实现;不替各模块写内部代码</td>
|
||||
</tr>
|
||||
<tr data-tip="核心后端只负责把主链路跑起来,不再同时承担所有适配模块的接口设计责任。">
|
||||
<td><span class="task-name">核心控制主链路</span></td>
|
||||
<td><span class="owner-pill">核心后端负责人</span></td>
|
||||
<td><span class="date-pill">5/08</span></td>
|
||||
<td>计划、任务、执行记录、审批、服务占用、能力目录最小实现</td>
|
||||
<td class="boundary-ok">核心状态推进、公共接口实现、主链路冒烟</td>
|
||||
<td class="boundary-no">不写 UI;不写浏览器、硬件、本地服务实现</td>
|
||||
</tr>
|
||||
<tr data-tip="解决新增 Skill 时自动写控制脚本的问题。">
|
||||
<td><span class="task-name">Skill 编写助手</span></td>
|
||||
<td><span class="owner-pill">Skill 工具负责人</span></td>
|
||||
<td><span class="date-pill">5/08</span></td>
|
||||
<td>控制脚本生成器、模板、校验器、3 个示例 Skill</td>
|
||||
<td class="boundary-ok">自动生成新 Skill 的控制脚本和校验规则</td>
|
||||
<td class="boundary-no">不做浏览器、硬件、本地服务底层适配</td>
|
||||
</tr>
|
||||
<tr data-tip="把已有自动化脚本批量纳入 Skill 体系。">
|
||||
<td><span class="task-name">自动化脚本批量转 Skill</span></td>
|
||||
<td><span class="owner-pill">脚本迁移负责人</span></td>
|
||||
<td><span class="date-pill">5/12</span></td>
|
||||
<td>转换工具、迁移清单、已转换 Skill 包、回归报告</td>
|
||||
<td class="boundary-ok">旧脚本包装、补输入输出、补日志、登记能力目录</td>
|
||||
<td class="boundary-no">不重写业务逻辑;不改适配器底层能力</td>
|
||||
</tr>
|
||||
<tr data-tip="浏览器适配独立成包,不和 UI 混在一起。">
|
||||
<td><span class="task-name">浏览器适配</span></td>
|
||||
<td><span class="owner-pill">浏览器适配负责人</span></td>
|
||||
<td><span class="date-pill">5/12</span></td>
|
||||
<td>浏览器适配器、示例 Skill、截图证据、失败说明</td>
|
||||
<td class="boundary-ok">打开页面、点击、输入、下载、截图、会话和错误回写</td>
|
||||
<td class="boundary-no">不决定业务审批;不做 UI 页面;不写硬件接口</td>
|
||||
</tr>
|
||||
<tr data-tip="子程序和本地服务单独管理,避免主程序和后台服务绑死。">
|
||||
<td><span class="task-name">本地服务与子程序管理</span></td>
|
||||
<td><span class="owner-pill">本地服务负责人</span></td>
|
||||
<td><span class="date-pill">5/14</span></td>
|
||||
<td>服务管理器、服务清单、健康数据、运维命令</td>
|
||||
<td class="boundary-ok">邮件读取、监听器、后台进程启动停止、健康、日志、回收</td>
|
||||
<td class="boundary-no">不定义业务流程;不直接修改任务结果</td>
|
||||
</tr>
|
||||
<tr data-tip="硬件适配要有安全边界,不能绕过人工治理。">
|
||||
<td><span class="task-name">硬件与机器人适配</span></td>
|
||||
<td><span class="owner-pill">硬件适配负责人</span></td>
|
||||
<td><span class="date-pill">5/14</span></td>
|
||||
<td>硬件适配器、模拟测试、状态回写、安全风险清单</td>
|
||||
<td class="boundary-ok">设备连接、状态读取、基础动作、机器人控制、异常停止</td>
|
||||
<td class="boundary-no">不绕过审批执行高风险动作;不负责浏览器自动化</td>
|
||||
</tr>
|
||||
<tr data-tip="UI 只做操作台,不能自己发明另一套后端状态。">
|
||||
<td><span class="task-name">UI 操作台</span></td>
|
||||
<td><span class="owner-pill">前端负责人</span></td>
|
||||
<td><span class="date-pill">5/18</span></td>
|
||||
<td>任务看板、审批队列、服务健康、成果浏览、过程回放</td>
|
||||
<td class="boundary-ok">用户操作入口和可视化验收页面</td>
|
||||
<td class="boundary-no">不绕过后端改状态;不自建任务模型</td>
|
||||
</tr>
|
||||
<tr data-tip="编译打包独立负责 Windows、信创、ARM,不和业务开发绑在一起。">
|
||||
<td><span class="task-name">浏览器编译与平台打包</span></td>
|
||||
<td><span class="owner-pill">构建发布负责人</span></td>
|
||||
<td><span class="date-pill">5/22</span></td>
|
||||
<td>Windows / 信创 / ARM 构建产物、依赖清单、安装说明</td>
|
||||
<td class="boundary-ok">平台构建、依赖整理、打包、失败归因</td>
|
||||
<td class="boundary-no">不改业务逻辑;不写 Skill;不做 UI 功能</td>
|
||||
</tr>
|
||||
<tr data-tip="测试发布负责最终收口和上线判断。">
|
||||
<td><span class="task-name">测试验收与发布收口</span></td>
|
||||
<td><span class="owner-pill">测试发布负责人</span></td>
|
||||
<td><span class="date-pill">5/31</span></td>
|
||||
<td>测试报告、上线风险、已知问题、发布包、回滚方案</td>
|
||||
<td class="boundary-ok">模块测试、端到端测试、兼容验证、发布门禁</td>
|
||||
<td class="boundary-no">不替功能模块补实现,但可判定是否阻断上线</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" data-tip="这张图只表达接口关系:每个负责人都通过中间的公共接口对接,模块之间不互相改代码。">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title"><i class="fa-solid fa-diagram-project"></i><span>分工接口关系图</span></div>
|
||||
<span class="panel-tag" data-tip="线上的文字就是这个负责人和核心接口之间的交付接口。">看线上的接口名</span>
|
||||
</div>
|
||||
<div class="interface-map-wrap">
|
||||
<svg class="interface-map" viewBox="0 0 1500 720" role="img" aria-label="sgRobot 五月底上线分工接口关系图">
|
||||
<defs>
|
||||
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L0,6 L9,3 z" fill="#6f8faf"></path>
|
||||
</marker>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="4" flood-color="#0d2758" flood-opacity="0.12"></feDropShadow>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect x="40" y="30" width="1420" height="660" rx="10" fill="#f8fbff" stroke="#d8e4f2"></rect>
|
||||
|
||||
<rect x="555" y="64" width="390" height="92" rx="10" fill="#0d2758" filter="url(#shadow)"></rect>
|
||||
<text x="750" y="102" text-anchor="middle" fill="#ffffff" font-size="25" font-weight="900">核心控制主链路</text>
|
||||
<text x="750" y="132" text-anchor="middle" fill="#dbeafe" font-size="18" font-weight="800">核心后端负责人:5/08 实现</text>
|
||||
|
||||
<rect x="565" y="285" width="370" height="118" rx="10" fill="#ffffff" stroke="#aec3db" stroke-width="2" filter="url(#shadow)"></rect>
|
||||
<text x="750" y="325" text-anchor="middle" fill="#0d2758" font-size="24" font-weight="900">公共接口合同</text>
|
||||
<text x="750" y="356" text-anchor="middle" fill="#5f7082" font-size="17" font-weight="800">总负责人 + 各模块负责人共同确认:4/30</text>
|
||||
<text x="750" y="383" text-anchor="middle" fill="#d92d20" font-size="17" font-weight="900">核心后端只实现主链路,不替各模块兜底</text>
|
||||
|
||||
<line x1="750" y1="156" x2="750" y2="285" stroke="#6f8faf" stroke-width="3" marker-end="url(#arrow)"></line>
|
||||
<text x="766" y="226" fill="#5f7082" font-size="16" font-weight="800">核心接口实现</text>
|
||||
|
||||
<rect x="75" y="75" width="290" height="72" rx="8" fill="#ffffff" stroke="#aec3db" stroke-width="2" filter="url(#shadow)"></rect>
|
||||
<text x="220" y="106" text-anchor="middle" fill="#0d2758" font-size="20" font-weight="900">Skill 编写助手</text>
|
||||
<text x="220" y="131" text-anchor="middle" fill="#5f7082" font-size="16" font-weight="800">Skill 工具负责人</text>
|
||||
<line x1="365" y1="111" x2="565" y2="302" stroke="#6f8faf" stroke-width="3" marker-end="url(#arrow)"></line>
|
||||
<rect x="376" y="178" width="172" height="32" rx="16" fill="#ffffff" stroke="#c8d7e8"></rect>
|
||||
<text x="462" y="200" text-anchor="middle" fill="#0d2758" font-size="15" font-weight="900">Skill 生成合同</text>
|
||||
|
||||
<rect x="75" y="185" width="290" height="72" rx="8" fill="#ffffff" stroke="#aec3db" stroke-width="2" filter="url(#shadow)"></rect>
|
||||
<text x="220" y="216" text-anchor="middle" fill="#0d2758" font-size="20" font-weight="900">脚本批量转 Skill</text>
|
||||
<text x="220" y="241" text-anchor="middle" fill="#5f7082" font-size="16" font-weight="800">脚本迁移负责人</text>
|
||||
<line x1="365" y1="221" x2="565" y2="327" stroke="#6f8faf" stroke-width="3" marker-end="url(#arrow)"></line>
|
||||
<rect x="386" y="253" width="160" height="32" rx="16" fill="#ffffff" stroke="#c8d7e8"></rect>
|
||||
<text x="466" y="275" text-anchor="middle" fill="#0d2758" font-size="15" font-weight="900">Skill 包接口</text>
|
||||
|
||||
<rect x="75" y="295" width="290" height="72" rx="8" fill="#ffffff" stroke="#aec3db" stroke-width="2" filter="url(#shadow)"></rect>
|
||||
<text x="220" y="326" text-anchor="middle" fill="#0d2758" font-size="20" font-weight="900">浏览器适配</text>
|
||||
<text x="220" y="351" text-anchor="middle" fill="#5f7082" font-size="16" font-weight="800">浏览器适配负责人</text>
|
||||
<line x1="365" y1="331" x2="565" y2="344" stroke="#6f8faf" stroke-width="3" marker-end="url(#arrow)"></line>
|
||||
<rect x="384" y="333" width="158" height="32" rx="16" fill="#ffffff" stroke="#c8d7e8"></rect>
|
||||
<text x="463" y="355" text-anchor="middle" fill="#0d2758" font-size="15" font-weight="900">浏览器接口</text>
|
||||
|
||||
<rect x="75" y="405" width="290" height="72" rx="8" fill="#ffffff" stroke="#aec3db" stroke-width="2" filter="url(#shadow)"></rect>
|
||||
<text x="220" y="436" text-anchor="middle" fill="#0d2758" font-size="20" font-weight="900">本地服务管理</text>
|
||||
<text x="220" y="461" text-anchor="middle" fill="#5f7082" font-size="16" font-weight="800">本地服务负责人</text>
|
||||
<line x1="365" y1="441" x2="565" y2="366" stroke="#6f8faf" stroke-width="3" marker-end="url(#arrow)"></line>
|
||||
<rect x="392" y="397" width="146" height="32" rx="16" fill="#ffffff" stroke="#c8d7e8"></rect>
|
||||
<text x="465" y="419" text-anchor="middle" fill="#0d2758" font-size="15" font-weight="900">服务合同</text>
|
||||
|
||||
<rect x="75" y="515" width="290" height="72" rx="8" fill="#ffffff" stroke="#aec3db" stroke-width="2" filter="url(#shadow)"></rect>
|
||||
<text x="220" y="546" text-anchor="middle" fill="#0d2758" font-size="20" font-weight="900">硬件与机器人适配</text>
|
||||
<text x="220" y="571" text-anchor="middle" fill="#5f7082" font-size="16" font-weight="800">硬件适配负责人</text>
|
||||
<line x1="365" y1="551" x2="565" y2="388" stroke="#6f8faf" stroke-width="3" marker-end="url(#arrow)"></line>
|
||||
<rect x="382" y="474" width="168" height="32" rx="16" fill="#ffffff" stroke="#c8d7e8"></rect>
|
||||
<text x="466" y="496" text-anchor="middle" fill="#0d2758" font-size="15" font-weight="900">硬件适配接口</text>
|
||||
|
||||
<rect x="1135" y="120" width="290" height="72" rx="8" fill="#ffffff" stroke="#aec3db" stroke-width="2" filter="url(#shadow)"></rect>
|
||||
<text x="1280" y="151" text-anchor="middle" fill="#0d2758" font-size="20" font-weight="900">UI 操作台</text>
|
||||
<text x="1280" y="176" text-anchor="middle" fill="#5f7082" font-size="16" font-weight="800">前端负责人</text>
|
||||
<line x1="1135" y1="156" x2="935" y2="310" stroke="#6f8faf" stroke-width="3" marker-end="url(#arrow)"></line>
|
||||
<rect x="960" y="220" width="152" height="32" rx="16" fill="#ffffff" stroke="#c8d7e8"></rect>
|
||||
<text x="1036" y="242" text-anchor="middle" fill="#0d2758" font-size="15" font-weight="900">UI 数据接口</text>
|
||||
|
||||
<rect x="1135" y="285" width="290" height="72" rx="8" fill="#ffffff" stroke="#aec3db" stroke-width="2" filter="url(#shadow)"></rect>
|
||||
<text x="1280" y="316" text-anchor="middle" fill="#0d2758" font-size="20" font-weight="900">浏览器编译与打包</text>
|
||||
<text x="1280" y="341" text-anchor="middle" fill="#5f7082" font-size="16" font-weight="800">构建发布负责人</text>
|
||||
<line x1="1135" y1="321" x2="935" y2="344" stroke="#6f8faf" stroke-width="3" marker-end="url(#arrow)"></line>
|
||||
<rect x="954" y="311" width="170" height="32" rx="16" fill="#ffffff" stroke="#c8d7e8"></rect>
|
||||
<text x="1039" y="333" text-anchor="middle" fill="#0d2758" font-size="15" font-weight="900">构建产物接口</text>
|
||||
|
||||
<rect x="1135" y="450" width="290" height="72" rx="8" fill="#ffffff" stroke="#aec3db" stroke-width="2" filter="url(#shadow)"></rect>
|
||||
<text x="1280" y="481" text-anchor="middle" fill="#0d2758" font-size="20" font-weight="900">测试验收与发布</text>
|
||||
<text x="1280" y="506" text-anchor="middle" fill="#5f7082" font-size="16" font-weight="800">测试发布负责人</text>
|
||||
<line x1="1135" y1="486" x2="935" y2="378" stroke="#6f8faf" stroke-width="3" marker-end="url(#arrow)"></line>
|
||||
<rect x="958" y="412" width="158" height="32" rx="16" fill="#ffffff" stroke="#c8d7e8"></rect>
|
||||
<text x="1037" y="434" text-anchor="middle" fill="#0d2758" font-size="15" font-weight="900">验收门禁</text>
|
||||
|
||||
<rect x="520" y="560" width="460" height="76" rx="10" fill="#fff7f7" stroke="#fecaca" stroke-width="2"></rect>
|
||||
<text x="750" y="592" text-anchor="middle" fill="#9f1239" font-size="21" font-weight="900">规则:负责人之间不直接改对方模块</text>
|
||||
<text x="750" y="620" text-anchor="middle" fill="#9f1239" font-size="17" font-weight="800">所有协作都落到接口合同、交付包、测试报告</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="map-note" data-tip="这句话是给领导看的解释口径。">
|
||||
读图方式:每个白框是一个负责人,线上的文字是他交给核心控制或从核心控制读取的接口。除测试发布外,其他负责人不直接验收别人内部实现,只按接口和交付物对接。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" data-tip="把里程碑压缩到几个关键日期,领导只需要知道每个日期要看到什么。">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title"><i class="fa-solid fa-calendar-days"></i><span>简版里程碑</span></div>
|
||||
<span class="panel-tag" data-tip="不再画复杂甘特图,只保留关键日期。">哪个任务哪天完成</span>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<div class="time-card" data-tip="这里只冻结口径,不要求完成全部实现。目标是让各模块能并行开工。">
|
||||
<span class="time-date">4/30</span>
|
||||
<div class="time-title">公共接口口径冻结</div>
|
||||
<div class="time-text">接口目录、字段草案、Mock 数据、对接方向确认。</div>
|
||||
</div>
|
||||
<div class="time-card" data-tip="核心后端在这个日期前交付主链路最小实现,不再背所有适配模块的实现。">
|
||||
<span class="time-date">5/08</span>
|
||||
<div class="time-title">核心主链路完成</div>
|
||||
<div class="time-text">计划、任务、执行记录、审批、服务占用可冒烟。</div>
|
||||
</div>
|
||||
<div class="time-card" data-tip="这一阶段拿到 Skill 生产、脚本迁移和浏览器适配首批能力。">
|
||||
<span class="time-date">5/12</span>
|
||||
<div class="time-title">Skill 与浏览器首批完成</div>
|
||||
<div class="time-text">Skill 编写助手、脚本转 Skill、浏览器适配可用。</div>
|
||||
</div>
|
||||
<div class="time-card" data-tip="本地服务和硬件适配要给出可调用版本。">
|
||||
<span class="time-date">5/14</span>
|
||||
<div class="time-title">服务与硬件完成</div>
|
||||
<div class="time-text">本地服务管理、硬件与机器人适配完成首版验收。</div>
|
||||
</div>
|
||||
<div class="time-card" data-tip="UI 在 5 月 18 日前必须从粗略页面变成可操作台。">
|
||||
<span class="time-date">5/18</span>
|
||||
<div class="time-title">UI 操作台完成</div>
|
||||
<div class="time-text">任务、审批、服务、成果、回放都能操作或查看。</div>
|
||||
</div>
|
||||
<div class="time-card" data-tip="五月底只做验收和发布,不再扩功能。">
|
||||
<span class="time-date">5/31</span>
|
||||
<div class="time-title">上线验收完成</div>
|
||||
<div class="time-text">发布包、测试报告、回滚方案、演示材料齐备。</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const tooltip = document.createElement("div");
|
||||
tooltip.id = "sg-tooltip";
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
let activeTipTarget = null;
|
||||
|
||||
function positionTooltip(event) {
|
||||
const margin = 14;
|
||||
const offset = 16;
|
||||
const rect = tooltip.getBoundingClientRect();
|
||||
let left = event.clientX + offset;
|
||||
let top = event.clientY + offset;
|
||||
|
||||
if (left + rect.width + margin > window.innerWidth) {
|
||||
left = Math.max(margin, event.clientX - rect.width - offset);
|
||||
}
|
||||
if (top + rect.height + margin > window.innerHeight) {
|
||||
top = Math.max(margin, event.clientY - rect.height - offset);
|
||||
}
|
||||
|
||||
tooltip.style.left = `${left}px`;
|
||||
tooltip.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
function showTooltip(target, event) {
|
||||
const text = target.dataset.tip;
|
||||
if (!text) return;
|
||||
activeTipTarget = target;
|
||||
tooltip.textContent = text;
|
||||
tooltip.classList.add("is-visible");
|
||||
positionTooltip(event);
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
activeTipTarget = null;
|
||||
tooltip.classList.remove("is-visible");
|
||||
}
|
||||
|
||||
document.addEventListener("pointerover", (event) => {
|
||||
const target = event.target.closest("[data-tip]");
|
||||
if (target) showTooltip(target, event);
|
||||
});
|
||||
|
||||
document.addEventListener("pointermove", (event) => {
|
||||
if (activeTipTarget) positionTooltip(event);
|
||||
});
|
||||
|
||||
document.addEventListener("pointerout", (event) => {
|
||||
if (!activeTipTarget) return;
|
||||
const next = event.relatedTarget;
|
||||
if (!next || !activeTipTarget.contains(next)) hideTooltip();
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-tip]").forEach((element) => {
|
||||
element.setAttribute("tabindex", "0");
|
||||
element.setAttribute("aria-label", element.dataset.tip);
|
||||
element.addEventListener("focus", (event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
showTooltip(event.currentTarget, {
|
||||
clientX: rect.left + rect.width / 2,
|
||||
clientY: rect.top + rect.height / 2,
|
||||
});
|
||||
});
|
||||
element.addEventListener("blur", hideTooltip);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,176 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>白领一天业务测试页</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Microsoft YaHei", Arial, sans-serif;
|
||||
color: #1f2937;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
header {
|
||||
padding: 20px 28px;
|
||||
background: #0f766e;
|
||||
color: white;
|
||||
}
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
section {
|
||||
min-height: 180px;
|
||||
padding: 16px;
|
||||
border: 1px solid #d8dee8;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
}
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
label,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
padding: 8px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
}
|
||||
button {
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
background: #2563eb;
|
||||
cursor: pointer;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
text-align: left;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
.result {
|
||||
min-height: 22px;
|
||||
color: #166534;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>白领一天业务测试页</h1>
|
||||
<p>用于验证数字员工通过 sgBrowser 完成网页采集、填写、比对和下载确认。</p>
|
||||
</header>
|
||||
<main>
|
||||
<section id="company-contact">
|
||||
<h2>供应商联系人</h2>
|
||||
<p data-field="company">白银智维科技有限公司</p>
|
||||
<p data-field="contact">联系人:李敏</p>
|
||||
<p data-field="phone">电话:0931-6201888</p>
|
||||
<p data-field="email">邮箱:limin@example.local</p>
|
||||
</section>
|
||||
|
||||
<section id="industry-news">
|
||||
<h2>行业动态</h2>
|
||||
<ol>
|
||||
<li data-score="96">内网终端安全巡检纳入月度闭环</li>
|
||||
<li data-score="88">营销系统工单响应时限压缩至 2 小时</li>
|
||||
<li data-score="81">国产化办公终端批量适配完成</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section id="customer-form">
|
||||
<h2>客户回访登记</h2>
|
||||
<label>客户名称<input id="customer-name" /></label>
|
||||
<label>联系电话<input id="customer-phone" /></label>
|
||||
<label>回访结论<select id="visit-result"><option>请选择</option><option>已解决</option><option>需跟进</option></select></label>
|
||||
<textarea id="visit-note" rows="3" placeholder="回访备注"></textarea>
|
||||
<button id="prepare-form" type="button">检查表单</button>
|
||||
<p id="form-status" class="result"></p>
|
||||
</section>
|
||||
|
||||
<section id="product-compare">
|
||||
<h2>设备方案比对</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>方案</th><th>价格</th><th>交付天数</th><th>风险</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>基础巡检包</td><td>68000</td><td>12</td><td>低</td></tr>
|
||||
<tr><td>增强巡检包</td><td>82000</td><td>9</td><td>中</td></tr>
|
||||
<tr><td>全量管控包</td><td>98000</td><td>7</td><td>中</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="todo-login">
|
||||
<h2>待办系统</h2>
|
||||
<label>用户名<input id="login-user" /></label>
|
||||
<label>口令<input id="login-pass" type="password" /></label>
|
||||
<button id="login-button" type="button">登录</button>
|
||||
<ul id="todo-list">
|
||||
<li><span class="badge">紧急</span> 完成安全巡检日报</li>
|
||||
<li><span class="badge">普通</span> 核对客户回访清单</li>
|
||||
<li><span class="badge">普通</span> 整理明日会议材料</li>
|
||||
</ul>
|
||||
<p id="login-status" class="result"></p>
|
||||
</section>
|
||||
|
||||
<section id="customer-list">
|
||||
<h2>客户清单</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><td>兰州新区供电服务站</td><td>待回访</td><td>王磊</td></tr>
|
||||
<tr><td>白银银光营业厅</td><td>已完成</td><td>张琴</td></tr>
|
||||
<tr><td>景泰调度中心</td><td>需协同</td><td>马宁</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="download-center">
|
||||
<h2>资料下载</h2>
|
||||
<p>最新资料:2026 年 5 月内网终端巡检模板</p>
|
||||
<a id="latest-download" href="daily-template-202605.xlsx" download>下载巡检模板</a>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
document.getElementById("prepare-form").addEventListener("click", function () {
|
||||
const name = document.getElementById("customer-name").value.trim();
|
||||
const result = document.getElementById("visit-result").value;
|
||||
document.getElementById("form-status").textContent =
|
||||
name && result !== "请选择" ? "表单已检查,等待人工确认提交" : "表单信息不完整";
|
||||
});
|
||||
document.getElementById("login-button").addEventListener("click", function () {
|
||||
document.getElementById("login-status").textContent = "已读取待办,不执行真实登录";
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const clientRoot = path.resolve(__dirname, '..');
|
||||
const embeddedRoot = path.join(clientRoot, 'embedded/sgrobot-digital');
|
||||
const outputRoot = path.join(clientRoot, 'public/sgrobot-digital');
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
stdio: 'inherit',
|
||||
shell: false,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status || 1);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const packageJson = path.join(embeddedRoot, 'package.json');
|
||||
const nodeModules = path.join(embeddedRoot, 'node_modules');
|
||||
if (!fs.existsSync(packageJson)) {
|
||||
throw new Error(`Missing embedded package: ${packageJson}`);
|
||||
}
|
||||
if (!fs.existsSync(nodeModules)) {
|
||||
console.error('Missing embedded dependencies.');
|
||||
console.error(`Run: npm --prefix ${path.relative(clientRoot, embeddedRoot)} install`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
run('npm', ['run', 'build'], { cwd: embeddedRoot });
|
||||
|
||||
if (!fs.existsSync(path.join(outputRoot, 'index.html'))) {
|
||||
throw new Error(`Build did not produce ${path.join(outputRoot, 'index.html')}`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const clientRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(clientRoot, '../..');
|
||||
const defaultSgRobotWeb = path.resolve(repoRoot, '../../sgRobot/crates/sgclaw-core/web');
|
||||
const sourceRoot = path.resolve(process.env.SGROBOT_WEB_DIR || defaultSgRobotWeb);
|
||||
const targetRoot = path.resolve(clientRoot, 'embedded/sgrobot-digital');
|
||||
|
||||
const copies = [
|
||||
['src/pages/digital', 'src/pages/digital'],
|
||||
['src/components/digital', 'src/components/digital'],
|
||||
['src/contexts/RoleContext.tsx', 'src/contexts/RoleContext.tsx'],
|
||||
['src/lib/api.ts', 'src/lib/api.ts'],
|
||||
['src/lib/auth.ts', 'src/lib/auth.ts'],
|
||||
['src/lib/consoleDataAdapter.ts', 'src/lib/consoleDataAdapter.ts'],
|
||||
['src/lib/i18n.ts', 'src/lib/i18n.ts'],
|
||||
['src/types/api.ts', 'src/types/api.ts'],
|
||||
['src/index.css', 'src/index.css'],
|
||||
['public/digital-assets', 'public/digital-assets'],
|
||||
['public/logo.png', 'public/logo.png'],
|
||||
];
|
||||
|
||||
function assertExists(location) {
|
||||
if (!fs.existsSync(location)) {
|
||||
throw new Error(`Missing required path: ${location}`);
|
||||
}
|
||||
}
|
||||
|
||||
function copyPath(from, to) {
|
||||
assertExists(from);
|
||||
fs.rmSync(to, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||
fs.cpSync(from, to, { recursive: true });
|
||||
}
|
||||
|
||||
function stripTauriWindowImport() {
|
||||
const shellPath = path.join(targetRoot, 'src/pages/digital/DigitalEmployeeShell.tsx');
|
||||
let source = fs.readFileSync(shellPath, 'utf8');
|
||||
source = source.replace("import { isTauri } from '@/lib/tauri';\n", '');
|
||||
source = source.replace(
|
||||
/\n if \(isTauri\(\)\) \{\n void import\('@tauri-apps\/api\/window'\)[\s\S]*?\n return;\n \}/,
|
||||
'',
|
||||
);
|
||||
fs.writeFileSync(shellPath, source);
|
||||
}
|
||||
|
||||
function main() {
|
||||
assertExists(sourceRoot);
|
||||
assertExists(targetRoot);
|
||||
|
||||
for (const [fromRel, toRel] of copies) {
|
||||
copyPath(path.join(sourceRoot, fromRel), path.join(targetRoot, toRel));
|
||||
}
|
||||
|
||||
stripTauriWindowImport();
|
||||
|
||||
console.log(`Synced sgRobot digital UI from ${sourceRoot}`);
|
||||
console.log(`Target: ${targetRoot}`);
|
||||
console.log('qimingclaw embedded adapters were preserved.');
|
||||
}
|
||||
|
||||
main();
|
||||
722
qimingclaw/docs/digital-employee-frontend-integration-plan.md
Normal file
@@ -0,0 +1,722 @@
|
||||
# sgRobot 核心能力融合计划
|
||||
|
||||
## 定位
|
||||
|
||||
本计划不是单纯的“前端页面嵌入计划”。
|
||||
|
||||
真正目标是:
|
||||
|
||||
```text
|
||||
以 qimingclaw 为客户端底座,
|
||||
以数字员工页面为主要产品载体,
|
||||
逐步吸收 sgRobot 的核心任务操作系统能力。
|
||||
```
|
||||
|
||||
数字员工页面不是一个展示壳,而是后续承载以下能力的主入口:
|
||||
|
||||
- 今日工作台
|
||||
- 任务中心
|
||||
- 技能库
|
||||
- 执行记录
|
||||
- 审批与人工介入
|
||||
- 运行时间线
|
||||
- 产物与日报
|
||||
- 计划模板与业务流程
|
||||
|
||||
当前 qimingclaw 已经完成一个过渡版本:在“概览”菜单内嵌 sgRobot 数字员工静态页面,不启动 sgRobot 后端也能展示页面。但这只是第一步,后续要把 sgRobot 的核心模型和执行治理能力融合进 qimingclaw。
|
||||
|
||||
## 总体原则
|
||||
|
||||
### 1. qimingclaw 是底座
|
||||
|
||||
保留 qimingclaw 当前已有优势:
|
||||
|
||||
- Electron 客户端
|
||||
- 登录与注册链路
|
||||
- lanproxy 远程接入
|
||||
- `/computer/chat` 本地执行入口
|
||||
- ACP 引擎
|
||||
- MCP 工具接入
|
||||
- 本地 SQLite 配置与状态存储
|
||||
|
||||
不把 sgRobot Rust daemon 原样搬进来,不让 qimingclaw 启动依赖 sgRobot 进程。
|
||||
|
||||
### 2. 数字员工页面是产品载体
|
||||
|
||||
sgRobot 的能力不是以后台模块裸露给用户,而是通过数字员工页面呈现:
|
||||
|
||||
- 用户看到的是数字员工的任务、状态、汇报和介入点。
|
||||
- 后台融合的是 Plan / Task / Run / Skill / Event / Artifact / Approval。
|
||||
- 页面最终从 demo fallback 过渡到 qimingclaw 真实数据。
|
||||
|
||||
### 3. 源码融合,运行隔离
|
||||
|
||||
数字员工前端采用源码级融合,而不是长期维护 `dist` 快照。
|
||||
|
||||
但第一阶段仍保持独立构建,不直接混入 qimingclaw 主 renderer bundle,避免:
|
||||
|
||||
- React 18 / React 19 冲突
|
||||
- Tailwind / Ant Design 样式污染
|
||||
- 路由体系互相影响
|
||||
|
||||
推荐方式:
|
||||
|
||||
```text
|
||||
源码融合
|
||||
-> 独立构建
|
||||
-> 输出静态资源
|
||||
-> qimingclaw webview 加载
|
||||
-> 后续通过 qimingclaw API 接真实数据
|
||||
```
|
||||
|
||||
## 目标架构
|
||||
|
||||
最终目标链路:
|
||||
|
||||
```text
|
||||
用户目标 / 后端远程任务 / 定时任务
|
||||
-> Plan 或 Direct Task
|
||||
-> Task
|
||||
-> Run
|
||||
-> qimingclaw ACP / MCP 执行
|
||||
-> Event / Artifact / Approval
|
||||
-> Projection
|
||||
-> 数字员工页面展示与人工介入
|
||||
```
|
||||
|
||||
核心对象:
|
||||
|
||||
```text
|
||||
Plan
|
||||
PlanStep
|
||||
Task
|
||||
Run
|
||||
Skill
|
||||
Event
|
||||
Artifact
|
||||
Approval
|
||||
MemoryEntry
|
||||
ManagedService
|
||||
```
|
||||
|
||||
第一轮不要求一次性实现全部对象,但数据库和 API 设计要为这些对象预留边界。
|
||||
|
||||
## 当前状态
|
||||
|
||||
已完成的过渡实现:
|
||||
|
||||
```text
|
||||
crates/agent-electron-client/public/sgrobot-digital/
|
||||
crates/agent-electron-client/src/renderer/components/pages/OverviewPage.tsx
|
||||
```
|
||||
|
||||
当前链路:
|
||||
|
||||
```text
|
||||
qimingclaw 左侧菜单“概览”
|
||||
-> OverviewPage
|
||||
-> Electron webview
|
||||
-> sgrobot-digital/index.html#/digital
|
||||
-> sgRobot 数字员工静态页面
|
||||
-> demo fallback 数据
|
||||
```
|
||||
|
||||
当前问题:
|
||||
|
||||
- 数字员工前端还是构建产物快照。
|
||||
- 页面数据主要来自 sgRobot demo fallback。
|
||||
- qimingclaw 没有 Plan / Task / Run / Event / Artifact 的正式状态模型。
|
||||
- qimingclaw 的 ACP 执行链路尚未映射到数字员工任务中心。
|
||||
- Skill、Approval、日报、执行时间线还不是 qimingclaw 真实能力。
|
||||
|
||||
## 分阶段融合计划
|
||||
|
||||
## Phase 1:数字员工前端源码融合(已开始)
|
||||
|
||||
目标:把现在的静态快照变成可维护源码集成。
|
||||
|
||||
### 做法
|
||||
|
||||
在 qimingclaw 中新增独立 embedded 前端源码目录:
|
||||
|
||||
```text
|
||||
crates/agent-electron-client/
|
||||
embedded/
|
||||
sgrobot-digital/
|
||||
package.json
|
||||
vite.config.ts
|
||||
tsconfig.json
|
||||
src/
|
||||
pages/digital/
|
||||
components/digital/
|
||||
contexts/
|
||||
lib/
|
||||
types/
|
||||
index.css
|
||||
main.tsx
|
||||
public/
|
||||
digital-assets/
|
||||
logo.png
|
||||
patches/
|
||||
README.md
|
||||
public/
|
||||
sgrobot-digital/
|
||||
```
|
||||
|
||||
新增脚本:
|
||||
|
||||
```text
|
||||
scripts/sync-sgrobot-digital.js
|
||||
scripts/build-sgrobot-digital.js
|
||||
```
|
||||
|
||||
当前 qimingclaw 已新增:
|
||||
|
||||
```text
|
||||
crates/agent-electron-client/embedded/sgrobot-digital/
|
||||
crates/agent-electron-client/scripts/sync-sgrobot-digital.js
|
||||
crates/agent-electron-client/scripts/build-sgrobot-digital.js
|
||||
```
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
cd crates/agent-electron-client
|
||||
npm run sync:sgrobot-digital
|
||||
npm --prefix embedded/sgrobot-digital install
|
||||
npm run build:sgrobot-digital
|
||||
npm run build:renderer
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `sync:sgrobot-digital` 从 sgRobot 的 web 源码同步数字员工相关源码和资源。
|
||||
- `build:sgrobot-digital` 从 qimingclaw 内的 embedded 源码构建到 `public/sgrobot-digital`。
|
||||
- `build:renderer` 验证 qimingclaw 主客户端构建。
|
||||
|
||||
同步范围优先包含:
|
||||
|
||||
```text
|
||||
sgRobot/crates/sgclaw-core/web/src/pages/digital/
|
||||
sgRobot/crates/sgclaw-core/web/src/components/digital/
|
||||
sgRobot/crates/sgclaw-core/web/src/contexts/RoleContext.tsx
|
||||
sgRobot/crates/sgclaw-core/web/src/lib/api.ts
|
||||
sgRobot/crates/sgclaw-core/web/src/lib/basePath.ts
|
||||
sgRobot/crates/sgclaw-core/web/src/lib/i18n.ts
|
||||
sgRobot/crates/sgclaw-core/web/src/lib/tauri.ts
|
||||
sgRobot/crates/sgclaw-core/web/src/types/api.ts
|
||||
sgRobot/crates/sgclaw-core/web/src/index.css
|
||||
sgRobot/crates/sgclaw-core/web/public/digital-assets/
|
||||
```
|
||||
|
||||
qimingclaw 专用 patch:
|
||||
|
||||
- 直达 `#/digital`
|
||||
- 禁用 sgRobot 配对页
|
||||
- 静态资源使用相对 base path,兼容 Vite dev server 和 Electron `file://` 打包态
|
||||
- API 暂时允许 fallback 到 demo 数据
|
||||
- 关闭 sgRobot 独立窗口行为,交给 qimingclaw 容器处理
|
||||
|
||||
当前边界:
|
||||
|
||||
- 前端已从“纯构建产物快照”推进为“源码同步 + 独立构建 + 静态嵌入”。
|
||||
- 数字员工页面仍主要使用 sgRobot 的 demo fallback 和同源 API 探测。
|
||||
- qimingclaw 还没有正式落地 Plan / Task / Run / Event / Artifact / Approval 数据模型。
|
||||
- 下一阶段应开始建设 qimingclaw 本地数字员工 API 适配层。
|
||||
|
||||
### 产出
|
||||
|
||||
- 数字员工页面可以从源码重新构建。
|
||||
- `public/sgrobot-digital` 不再手工维护。
|
||||
- qimingclaw 不启动 sgRobot 也能打开数字员工页面。
|
||||
|
||||
### 验收
|
||||
|
||||
- `npm run sync:sgrobot-digital`
|
||||
- `npm run build:sgrobot-digital`
|
||||
- `npm run build:renderer`
|
||||
- qimingclaw“概览”直接显示数字员工首页,不出现配对页。
|
||||
|
||||
## Phase 2:数字员工 API 适配层
|
||||
|
||||
目标:让数字员工页面开始读取 qimingclaw 真实数据。
|
||||
|
||||
### 做法
|
||||
|
||||
在 qimingclaw main process 或本地 HTTP 服务中新增数字员工 API 适配层。
|
||||
|
||||
先实现这些 sgRobot 兼容 API:
|
||||
|
||||
```text
|
||||
GET /api/digital-employee/workday
|
||||
POST /api/digital-employee/workday/actions
|
||||
GET /api/debug/plans
|
||||
GET /api/debug/tasks
|
||||
GET /api/skill
|
||||
GET /api/debug/store
|
||||
```
|
||||
|
||||
数据来源先映射 qimingclaw 当前已有能力:
|
||||
|
||||
- 当前用户
|
||||
- 登录状态
|
||||
- 服务状态
|
||||
- Agent 状态
|
||||
- 会话列表
|
||||
- 最近 `/computer/chat` 请求
|
||||
- ACP session 状态
|
||||
- MCP tool 列表
|
||||
|
||||
此阶段不追求完整 Plan/Task/Run,只先让页面从 qimingclaw 取真实状态。
|
||||
|
||||
### 产出
|
||||
|
||||
- 数字员工首页展示 qimingclaw 的真实运行状态。
|
||||
- 技能库可以显示 qimingclaw 当前 MCP/ACP 工具能力。
|
||||
- 任务中心开始展示 qimingclaw 最近执行记录。
|
||||
|
||||
### 验收
|
||||
|
||||
- 停止 sgRobot 后端,数字员工页面仍然工作。
|
||||
- 页面 API 请求命中 qimingclaw。
|
||||
- demo fallback 只作为无数据兜底,不再是主数据源。
|
||||
|
||||
## Phase 3:Run / Event / Artifact 轻量控制平面
|
||||
|
||||
目标:把 qimingclaw 当前执行链路正式记录为可展示、可审计的运行对象。
|
||||
|
||||
### 当前 qimingclaw 执行链路
|
||||
|
||||
```text
|
||||
backend
|
||||
-> lanproxy
|
||||
-> computerServer:60006 /computer/chat
|
||||
-> agentService.ensureEngineForRequest
|
||||
-> AcpEngine.chat
|
||||
-> ACP session / prompt
|
||||
-> /computer/progress/{session_id} SSE
|
||||
```
|
||||
|
||||
### 需要新增的核心表
|
||||
|
||||
先落地最小集合:
|
||||
|
||||
```text
|
||||
runs
|
||||
events
|
||||
artifacts
|
||||
approvals
|
||||
```
|
||||
|
||||
推荐语义:
|
||||
|
||||
- `Run`:一次 qimingclaw 执行尝试。
|
||||
- `Event`:执行过程中的事实记录。
|
||||
- `Artifact`:执行结果、文件、报告、截图、日志摘要。
|
||||
- `Approval`:权限请求、人工确认、风险操作确认。
|
||||
|
||||
### 映射方式
|
||||
|
||||
```text
|
||||
/computer/chat request
|
||||
-> create Run
|
||||
ACP progress event
|
||||
-> append Event
|
||||
permission request
|
||||
-> create Approval
|
||||
tool result / final message
|
||||
-> create Artifact 或 Event
|
||||
session complete
|
||||
-> finish Run
|
||||
```
|
||||
|
||||
### 产出
|
||||
|
||||
- 每次执行都有 Run ID。
|
||||
- 数字员工“今日执行”“运行明细”读取真实 Run/Event。
|
||||
- 后续日报和审计有数据基础。
|
||||
|
||||
### 验收
|
||||
|
||||
- 发起一次远程 `/computer/chat` 后,数字员工页面能看到执行记录。
|
||||
- Run 状态能从 running 进入 succeeded / failed / cancelled。
|
||||
- Event 时间线可追踪主要执行过程。
|
||||
|
||||
## Phase 4:Task / PlanStep / Plan
|
||||
|
||||
目标:吸收 sgRobot 的任务操作系统骨架。
|
||||
|
||||
### 先做 Task
|
||||
|
||||
把一次业务执行抽象成 Task:
|
||||
|
||||
```text
|
||||
Task
|
||||
-> Run attempt 1
|
||||
-> Run attempt 2
|
||||
```
|
||||
|
||||
Task 管业务目标,Run 管执行尝试。
|
||||
|
||||
### 再做 Plan / PlanStep
|
||||
|
||||
当 Task 稳定后,引入:
|
||||
|
||||
```text
|
||||
Plan
|
||||
PlanStep
|
||||
```
|
||||
|
||||
Plan 用于承载多步骤业务流程。
|
||||
|
||||
### 数据关系
|
||||
|
||||
```text
|
||||
Plan
|
||||
-> PlanStep[]
|
||||
-> Task[]
|
||||
-> Run[]
|
||||
-> Event[]
|
||||
-> Artifact[]
|
||||
-> Approval[]
|
||||
```
|
||||
|
||||
### 产出
|
||||
|
||||
- 数字员工任务中心从“执行记录列表”升级为“计划 / 任务 / 步骤”视图。
|
||||
- 支持多步骤任务展示。
|
||||
- 支持失败隔离和重试。
|
||||
|
||||
### 验收
|
||||
|
||||
- 单步任务能以 Task 展示。
|
||||
- 多步任务能以 Plan 展示。
|
||||
- 每个 Task 能看到 Run 历史。
|
||||
|
||||
## Phase 5:PlanTemplate 融合
|
||||
|
||||
目标:吸收 sgRobot 的业务流程模板能力。
|
||||
|
||||
### 新增目录
|
||||
|
||||
```text
|
||||
~/.qimingclaw/plan_templates/
|
||||
```
|
||||
|
||||
支持文件:
|
||||
|
||||
```text
|
||||
PlanTemplate.toml
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
```text
|
||||
GET /api/plan-templates
|
||||
GET /api/plan-templates/:id
|
||||
POST /api/plan-templates/:id/activate
|
||||
```
|
||||
|
||||
### 与数字员工页面关系
|
||||
|
||||
数字员工“任务设置”区域读取 PlanTemplate。
|
||||
|
||||
用户可以:
|
||||
|
||||
- 查看模板
|
||||
- 选择今日任务
|
||||
- 一键执行
|
||||
- 设置定时
|
||||
|
||||
### 产出
|
||||
|
||||
- qimingclaw 具备业务流程沉淀能力。
|
||||
- 数字员工不只是临时聊天入口,而是可选择业务模板执行。
|
||||
|
||||
### 验收
|
||||
|
||||
- 放入一个 `PlanTemplate.toml` 后,数字员工页面能展示。
|
||||
- 点击执行后生成 Plan / Task / Run。
|
||||
|
||||
## Phase 6:Skill Catalog 融合
|
||||
|
||||
目标:把 qimingclaw 的 MCP / ACP 工具治理成 sgRobot 风格 Skill。
|
||||
|
||||
### 数据模型
|
||||
|
||||
```text
|
||||
Skill
|
||||
skill_id
|
||||
name
|
||||
description
|
||||
version
|
||||
kind
|
||||
input_schema
|
||||
output_schema
|
||||
timeout_policy
|
||||
retry_policy
|
||||
approval_policy
|
||||
sandbox_policy
|
||||
enabled
|
||||
```
|
||||
|
||||
### 来源映射
|
||||
|
||||
```text
|
||||
MCP tools
|
||||
ACP commands
|
||||
qimingclaw built-in tools
|
||||
GUI agent tools
|
||||
file server tools
|
||||
```
|
||||
|
||||
### 与数字员工页面关系
|
||||
|
||||
数字员工“技能库”展示 qimingclaw Skill Catalog。
|
||||
|
||||
用户可以:
|
||||
|
||||
- 查看技能
|
||||
- 启用 / 禁用
|
||||
- 查看所属 MCP server
|
||||
- 查看输入输出说明
|
||||
- 查看最近调用记录
|
||||
|
||||
### 产出
|
||||
|
||||
- 工具从“技术配置”变成“数字员工能力”。
|
||||
- PlanTemplate 可以绑定 Skill。
|
||||
|
||||
### 验收
|
||||
|
||||
- MCP tool 能出现在技能库。
|
||||
- 禁用技能后,PlanTemplate 或任务执行不会再选择它。
|
||||
|
||||
## Phase 7:Approval / Human-in-the-loop
|
||||
|
||||
目标:吸收 sgRobot 的人工治理机制。
|
||||
|
||||
### 融合对象
|
||||
|
||||
qimingclaw 已有 ACP permission 和 sandbox permission 概念。
|
||||
|
||||
需要统一成:
|
||||
|
||||
```text
|
||||
Approval
|
||||
```
|
||||
|
||||
来源包括:
|
||||
|
||||
- 文件写入
|
||||
- 命令执行
|
||||
- 外部服务调用
|
||||
- 高风险工具
|
||||
- Plan 执行确认
|
||||
- Task 重试 / 跳过 / 取消
|
||||
|
||||
### 与数字员工页面关系
|
||||
|
||||
数字员工页面展示:
|
||||
|
||||
- 待审批
|
||||
- 已批准
|
||||
- 已拒绝
|
||||
- 等待输入
|
||||
- 风险说明
|
||||
|
||||
用户可操作:
|
||||
|
||||
- approve
|
||||
- reject
|
||||
- approve once
|
||||
- approve always
|
||||
- pause
|
||||
- resume
|
||||
- cancel
|
||||
|
||||
### 产出
|
||||
|
||||
- 人工介入成为主路径能力。
|
||||
- 数字员工不是黑箱自动执行。
|
||||
|
||||
### 验收
|
||||
|
||||
- ACP permission request 能进入数字员工审批区。
|
||||
- 用户在数字员工页面审批后,ACP 执行继续。
|
||||
|
||||
## Phase 8:Artifact / 日报 / 汇报
|
||||
|
||||
目标:把执行结果正式沉淀为产物,并由数字员工生成日报和汇报视图。
|
||||
|
||||
### Artifact 类型
|
||||
|
||||
```text
|
||||
file
|
||||
report
|
||||
screenshot
|
||||
log
|
||||
json
|
||||
markdown
|
||||
diff
|
||||
summary
|
||||
```
|
||||
|
||||
### 日报数据来源
|
||||
|
||||
```text
|
||||
Run
|
||||
Event
|
||||
Artifact
|
||||
Approval
|
||||
Task status
|
||||
Plan status
|
||||
```
|
||||
|
||||
### 与数字员工页面关系
|
||||
|
||||
数字员工“日报”区域展示:
|
||||
|
||||
- 今日执行
|
||||
- 成功 / 失败 / 异常
|
||||
- 人工介入次数
|
||||
- 关键产物
|
||||
- 风险提示
|
||||
- 可下载报告
|
||||
|
||||
### 产出
|
||||
|
||||
- qimingclaw 的执行结果可沉淀、可回看、可汇报。
|
||||
|
||||
### 验收
|
||||
|
||||
- 完成任务后能在日报区域看到执行摘要。
|
||||
- Artifact 可从任务详情进入查看。
|
||||
|
||||
## Phase 9:ManagedService / 常驻能力治理
|
||||
|
||||
目标:吸收 sgRobot 对长期服务能力的治理方式。
|
||||
|
||||
qimingclaw 当前已有多个服务:
|
||||
|
||||
```text
|
||||
computerServer
|
||||
fileServer
|
||||
lanproxy
|
||||
mcpProxy
|
||||
agent
|
||||
guiServer
|
||||
adminServer
|
||||
```
|
||||
|
||||
需要把它们建模为:
|
||||
|
||||
```text
|
||||
ManagedService
|
||||
ServiceInstance
|
||||
ServiceLease
|
||||
```
|
||||
|
||||
### 与数字员工页面关系
|
||||
|
||||
数字员工页面可以展示:
|
||||
|
||||
- 服务健康
|
||||
- 最近错误
|
||||
- 重启次数
|
||||
- 当前依赖它的任务
|
||||
- 是否需要人工处理
|
||||
|
||||
### 产出
|
||||
|
||||
- 服务状态不只在客户端设置页展示,也进入数字员工的运行上下文。
|
||||
|
||||
### 验收
|
||||
|
||||
- 服务异常时,数字员工首页能提示。
|
||||
- 服务恢复后,事件和状态同步更新。
|
||||
|
||||
## Phase 10:Plan Agent / Task Agent
|
||||
|
||||
目标:在 qimingclaw 内形成 sgRobot 的双层 Agent 架构。
|
||||
|
||||
这是后期能力,不建议提前做。
|
||||
|
||||
### Plan Agent
|
||||
|
||||
负责:
|
||||
|
||||
- 计划生命周期
|
||||
- 用户对话
|
||||
- 异常决策
|
||||
- 审批转发
|
||||
- 重试 / 跳过 / 重规划
|
||||
|
||||
### Task Agent
|
||||
|
||||
负责:
|
||||
|
||||
- 单个 Task 的执行
|
||||
- ACP session 调用
|
||||
- MCP tool 调用
|
||||
- 进度上报
|
||||
- 产物回写
|
||||
|
||||
### 推荐链路
|
||||
|
||||
```text
|
||||
User
|
||||
-> Digital Employee
|
||||
-> Plan Agent
|
||||
-> Task Agent
|
||||
-> ACP / MCP
|
||||
-> Event / Artifact / Approval
|
||||
-> Digital Employee Projection
|
||||
```
|
||||
|
||||
### 产出
|
||||
|
||||
- qimingclaw 从“Agent 客户端”升级为“数字员工任务操作系统”。
|
||||
|
||||
## 推荐实施顺序
|
||||
|
||||
按风险和收益排序:
|
||||
|
||||
```text
|
||||
1. 数字员工前端源码融合
|
||||
2. 数字员工 API 适配层
|
||||
3. Run / Event / Artifact 轻量控制平面
|
||||
4. Task / Plan 基础模型
|
||||
5. PlanTemplate
|
||||
6. Skill Catalog
|
||||
7. Approval / Human-in-the-loop
|
||||
8. 日报与 Artifact
|
||||
9. ManagedService
|
||||
10. Plan Agent / Task Agent
|
||||
```
|
||||
|
||||
## 第一阶段验收清单
|
||||
|
||||
第一阶段完成后,应满足:
|
||||
|
||||
- qimingclaw 内有数字员工前端源码目录。
|
||||
- `public/sgrobot-digital` 可由脚本生成。
|
||||
- 不启动 sgRobot 后端也能打开“概览”。
|
||||
- “概览”直接显示数字员工页面。
|
||||
- 不出现 sgRobot 配对页。
|
||||
- 不手工维护压缩后的 `dist/assets/*.js`。
|
||||
- sgRobot 仓库不参与 qimingclaw 提交。
|
||||
- `npm run build:renderer` 通过。
|
||||
|
||||
## 最终验收标准
|
||||
|
||||
整个融合完成后,应满足:
|
||||
|
||||
- 数字员工页面显示 qimingclaw 真实数据。
|
||||
- 远程 `/computer/chat` 请求能生成 Run 和 Event。
|
||||
- 用户能在数字员工页面看到任务进度、失败、产物和审批。
|
||||
- PlanTemplate 能创建可执行计划。
|
||||
- Skill Catalog 能治理 MCP/ACP 工具。
|
||||
- 日报能基于真实 Run/Event/Artifact 生成。
|
||||
- qimingclaw 不依赖 sgRobot daemon。
|
||||
- sgRobot 的核心理念和对象模型被 qimingclaw 吸收,而不是简单嵌入另一个应用。
|
||||