feat(client): project digital employee runtime into UI
This commit is contained in:
@@ -613,10 +613,6 @@ export function postDigitalEmployeeWorkdayAction(
|
||||
});
|
||||
}
|
||||
|
||||
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',
|
||||
|
||||
@@ -432,6 +432,18 @@ function categoryFor(skillId: string): string {
|
||||
return '综合';
|
||||
}
|
||||
|
||||
function capabilityLevelFor(value: unknown): SkillSummary['capabilityLevel'] {
|
||||
if (
|
||||
value === 'core' ||
|
||||
value === 'standard' ||
|
||||
value === 'specialized' ||
|
||||
value === 'experimental'
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return 'standard';
|
||||
}
|
||||
|
||||
function zhDescription(skillId: string, rawDescription: unknown, name: string): string {
|
||||
const description = str(rawDescription).trim();
|
||||
if (description && hasChinese(description)) return description;
|
||||
@@ -450,8 +462,8 @@ export function adaptSkillSummaries(skillsData: Obj[] | null): SkillSummary[] {
|
||||
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,
|
||||
category: s.category ? str(s.category, categoryFor(sid)) : categoryFor(sid),
|
||||
capabilityLevel: capabilityLevelFor(s.capability_level ?? s.capabilityLevel),
|
||||
source: 'skill-api' as const,
|
||||
};
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,6 @@ import { Clipboard, Clock3, Download, Eye, PlayCircle, RefreshCw } from 'lucide-
|
||||
import Avatar3D from '@/components/digital/Avatar3D';
|
||||
import { basePath } from '@/lib/basePath';
|
||||
import {
|
||||
digitalEmployeeReportPdfUrl,
|
||||
dispatchPlanNow,
|
||||
getDigitalEmployeeWorkday,
|
||||
getSchedulerJobs,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import type {
|
||||
CronJob,
|
||||
DigitalEmployeeDailyReport,
|
||||
DigitalEmployeeDecision,
|
||||
DigitalEmployeeDuty,
|
||||
DigitalEmployeeRunDetail,
|
||||
@@ -590,8 +590,8 @@ function syncFailureDiagnosticFileName(failure: ManagementSyncFailure): string {
|
||||
return `qimingclaw-sync-failure-${entity || failure.id || 'diagnostic'}.json`;
|
||||
}
|
||||
|
||||
function downloadTextFile(filename: string, content: string): void {
|
||||
const blob = new Blob([content], { type: 'application/json;charset=utf-8' });
|
||||
function downloadTextFile(filename: string, content: string, mimeType = 'application/json;charset=utf-8'): void {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
@@ -602,6 +602,47 @@ function downloadTextFile(filename: string, content: string): void {
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
function dailyReportFileName(date: string): string {
|
||||
return `qimingclaw-digital-report-${date}.txt`;
|
||||
}
|
||||
|
||||
function buildDailyReportDownloadText(
|
||||
dateLabel: string,
|
||||
report: DigitalEmployeeDailyReport,
|
||||
summaries: DigitalEmployeeTaskExecutionSummary[],
|
||||
details: DigitalEmployeeRunDetail[],
|
||||
): string {
|
||||
return [
|
||||
report.title || `${dateLabel} 数字员工日报`,
|
||||
`日期:${dateLabel}`,
|
||||
`生成时间:${report.generated_at ? formatBeijingDateTime(report.generated_at) : '暂未生成'}`,
|
||||
`生成来源:${report.generated_by === 'ai' ? 'AI' : 'qimingclaw adapter'}`,
|
||||
'',
|
||||
'## 摘要',
|
||||
report.summary || '暂无摘要。',
|
||||
'',
|
||||
'## 指标',
|
||||
`任务总数:${report.totals.task_count}`,
|
||||
`执行次数:${report.totals.execution_count}`,
|
||||
`成功次数:${report.totals.success_count}`,
|
||||
`失败次数:${report.totals.failure_count}`,
|
||||
`运行中:${report.totals.running_count ?? 0}`,
|
||||
'',
|
||||
'## 任务汇总',
|
||||
...(summaries.length > 0
|
||||
? summaries.map((summary) => `- ${summary.title}:${summary.latest_status_label},执行 ${summary.total_count} 次,成功 ${summary.success_count},失败 ${summary.failure_count}。${summary.ai_summary || summary.business_summary || ''}`)
|
||||
: ['暂无任务汇总。']),
|
||||
'',
|
||||
'## 运行明细',
|
||||
...(details.length > 0
|
||||
? details.map((detail) => `- ${detail.time || '--:--'} ${detail.task_name} / ${detail.step_name || detail.operation_name || '任务执行'}:${detail.status_label},${detail.business_detail}`)
|
||||
: ['暂无运行明细。']),
|
||||
'',
|
||||
'## 详情',
|
||||
...((report.detail_lines ?? []).length > 0 ? report.detail_lines : ['暂无详情。']),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function readManagementSyncStatus(): Promise<ManagementSyncStatus | null> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
if (!bridge?.getSyncStatus) return null;
|
||||
@@ -734,8 +775,6 @@ function resolveEmployeeVisualState(args: {
|
||||
return 'skill';
|
||||
}
|
||||
|
||||
const DECISION_CHAIN_TODO_TEXT = '真实审批链路待接入';
|
||||
|
||||
function readGuideDismissedDate(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
@@ -1207,7 +1246,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
: await getDigitalEmployeeWorkday(selectedDate);
|
||||
if (!mountedRef.current) return;
|
||||
setWorkdayProjection(latestProjection);
|
||||
setActionNotice('日报已生成,可下载 PDF。');
|
||||
setActionNotice('日报已生成,可下载本地文本。');
|
||||
if (latestProjection.daily_report?.status === 'ready') {
|
||||
setReportPreviewOpen(true);
|
||||
}
|
||||
@@ -1220,18 +1259,31 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
}
|
||||
}, [reportScheduleTime, selectedDate, selectedDutyIds, selectedSubmissionDutyIds]);
|
||||
|
||||
const downloadDailyReportPdf = useCallback(() => {
|
||||
window.open(digitalEmployeeReportPdfUrl(selectedDate), '_blank', 'noopener,noreferrer');
|
||||
}, [selectedDate]);
|
||||
const downloadDailyReport = useCallback(() => {
|
||||
if (!dailyReport || dailyReport.status !== 'ready') return;
|
||||
downloadTextFile(
|
||||
dailyReportFileName(selectedDate),
|
||||
buildDailyReportDownloadText(selectedDateLabel, dailyReport, taskSummaryCards, visibleRunDetails),
|
||||
'text/plain;charset=utf-8',
|
||||
);
|
||||
setActionNotice('日报文本已下载。');
|
||||
}, [dailyReport, selectedDate, selectedDateLabel, taskSummaryCards, visibleRunDetails]);
|
||||
|
||||
const resolveDecision = useCallback(async (decision: DigitalEmployeeDecision, actionId: string) => {
|
||||
setActionBusy(`${decision.id}:${actionId}`);
|
||||
setActionError(null);
|
||||
setActionNotice(null);
|
||||
try {
|
||||
const projection = await postDigitalEmployeeWorkdayAction({
|
||||
action: 'decide_approval',
|
||||
date: selectedDate,
|
||||
decision_id: decision.id,
|
||||
decision: actionId,
|
||||
});
|
||||
if (!mountedRef.current) return;
|
||||
const actionLabel = decision.actions.find((action) => action.id === actionId)?.label ?? actionId;
|
||||
setActionNotice(`待实现:已触发“${actionLabel}”演示效果,${DECISION_CHAIN_TODO_TEXT},未改动后端审批或执行状态。`);
|
||||
setWorkdayProjection(projection);
|
||||
setActionNotice(`已记录“${actionLabel}”处理结果,并写入 qimingclaw 本地运行状态。`);
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
setActionError(error instanceof Error ? error.message : '处理决策失败');
|
||||
@@ -1239,7 +1291,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
} finally {
|
||||
if (mountedRef.current) setActionBusy(null);
|
||||
}
|
||||
}, []);
|
||||
}, [selectedDate]);
|
||||
|
||||
const openDuty = useCallback((planItem: DigitalPlanItem) => {
|
||||
onNavigate({ tab: 'missions', date: selectedDate, missionId: planItem.conversationId || planItem.id });
|
||||
@@ -1804,8 +1856,8 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm de-report-download-btn"
|
||||
disabled={!dailyReport?.pdf_available}
|
||||
onClick={downloadDailyReportPdf}
|
||||
title={dailyReport?.pdf_available ? '下载日报 PDF' : '生成日报后可下载日报 PDF'}
|
||||
onClick={downloadDailyReport}
|
||||
title={dailyReport?.pdf_available ? '下载本地日报文本' : '生成日报后可下载本地日报'}
|
||||
>
|
||||
<Download size={14} aria-hidden="true" />
|
||||
下载日报
|
||||
@@ -1859,7 +1911,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm de-report-download-btn"
|
||||
disabled={!dailyReport.pdf_available}
|
||||
onClick={downloadDailyReportPdf}
|
||||
onClick={downloadDailyReport}
|
||||
>
|
||||
<Download size={14} aria-hidden="true" />
|
||||
下载日报
|
||||
@@ -2392,8 +2444,8 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
</div>
|
||||
<p>{decision.scenario}</p>
|
||||
<div className="de-decision-chain-note">
|
||||
<span className="de-badge de-badge-warning">待实现</span>
|
||||
<span>{DECISION_CHAIN_TODO_TEXT}</span>
|
||||
<span className="de-badge de-badge-success">本地记录</span>
|
||||
<span>{isDecisionOpen(decision.status) ? '处理结果会写入 qimingclaw runtime event。' : '该事项已在本地状态中处理。'}</span>
|
||||
</div>
|
||||
<div className="de-workbench-actions">
|
||||
{decision.actions.map((action) => (
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Download } from 'lucide-react';
|
||||
import GlassCard from '@/components/digital/GlassCard';
|
||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||
import {
|
||||
digitalEmployeeReportPdfUrl,
|
||||
getDigitalEmployeeWorkday,
|
||||
postDigitalEmployeeWorkdayAction,
|
||||
} from '@/lib/api';
|
||||
@@ -74,6 +73,59 @@ function formatGeneratedAt(value?: string | null): string {
|
||||
});
|
||||
}
|
||||
|
||||
function downloadTextFile(filename: string, content: string): void {
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
function reportFileName(date: string): string {
|
||||
return `qimingclaw-digital-report-${date}.txt`;
|
||||
}
|
||||
|
||||
function buildReportDownloadText(
|
||||
date: string,
|
||||
report: DigitalEmployeeDailyReport,
|
||||
sections: DigitalEmployeeTaskExecutionSummary[],
|
||||
details: DigitalEmployeeRunDetail[],
|
||||
): string {
|
||||
return [
|
||||
report.title || `${date} 数字员工日报`,
|
||||
`日期:${date}`,
|
||||
`生成时间:${formatGeneratedAt(report.generated_at)}`,
|
||||
`生成来源:${report.generated_by === 'ai' ? 'AI' : 'qimingclaw adapter'}`,
|
||||
'',
|
||||
'## 摘要',
|
||||
report.summary || '暂无摘要。',
|
||||
'',
|
||||
'## 指标',
|
||||
`任务总数:${report.totals.task_count}`,
|
||||
`执行次数:${report.totals.execution_count}`,
|
||||
`成功次数:${report.totals.success_count}`,
|
||||
`失败次数:${report.totals.failure_count}`,
|
||||
`运行中:${report.totals.running_count ?? 0}`,
|
||||
'',
|
||||
'## 任务汇总',
|
||||
...(sections.length > 0
|
||||
? sections.map((section) => `- ${section.title}:${section.latest_status_label},执行 ${section.total_count} 次,成功 ${section.success_count},失败 ${section.failure_count}。${section.ai_summary || section.business_summary || ''}`)
|
||||
: ['暂无任务汇总。']),
|
||||
'',
|
||||
'## 运行明细',
|
||||
...(details.length > 0
|
||||
? details.map((detail) => `- ${detail.time || '--:--'} ${detail.task_name} / ${detail.step_name || detail.operation_name || '任务执行'}:${detail.status_label},${detail.business_detail}`)
|
||||
: ['暂无运行明细。']),
|
||||
'',
|
||||
'## 详情',
|
||||
...((report.detail_lines ?? []).length > 0 ? report.detail_lines : ['暂无详情。']),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function TaskSectionList({ sections }: { sections: DigitalEmployeeTaskExecutionSummary[] }) {
|
||||
if (sections.length === 0) {
|
||||
return <p className="de-empty-text" style={{ padding: '18px 0 6px' }}>今天还没有可汇总的任务。</p>;
|
||||
@@ -209,7 +261,7 @@ export default function DailyReport({ focusSection }: { focusSection?: string |
|
||||
if (!mountedRef.current) return;
|
||||
setProjection(data);
|
||||
setReportScheduleTime(data.source === 'live' ? (data.daily_report?.report_schedule_time || reportScheduleTime) : reportScheduleTime);
|
||||
setMessage('日报已生成,可下载 PDF。');
|
||||
setMessage('日报已生成,可下载本地文本。');
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
setMessage(error instanceof Error ? error.message : '日报生成失败');
|
||||
@@ -218,9 +270,10 @@ export default function DailyReport({ focusSection }: { focusSection?: string |
|
||||
}
|
||||
}, [isLiveProjection, projection, reportScheduleTime, today]);
|
||||
|
||||
const downloadDailyReportPdf = useCallback(() => {
|
||||
window.open(digitalEmployeeReportPdfUrl(today), '_blank', 'noopener,noreferrer');
|
||||
}, [today]);
|
||||
const downloadDailyReport = useCallback(() => {
|
||||
if (!report || report.status !== 'ready') return;
|
||||
downloadTextFile(reportFileName(today), buildReportDownloadText(today, report, taskSections, runDetails));
|
||||
}, [report, runDetails, taskSections, today]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '14px 18px' }}>
|
||||
@@ -271,8 +324,8 @@ export default function DailyReport({ focusSection }: { focusSection?: string |
|
||||
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'}
|
||||
onClick={downloadDailyReport}
|
||||
title={report?.pdf_available ? '下载本地日报文本' : '生成日报后可下载本地日报'}
|
||||
>
|
||||
<Download size={14} aria-hidden="true" />
|
||||
下载日报
|
||||
|
||||
@@ -15,18 +15,9 @@ import {
|
||||
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',
|
||||
'qimingclaw-client-readiness',
|
||||
'qimingclaw-remote-task-ingress',
|
||||
'qimingclaw-daily-report',
|
||||
];
|
||||
|
||||
const DELEGATION_FILTERS = [
|
||||
@@ -332,34 +323,7 @@ export default function MissionCenter({
|
||||
.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]);
|
||||
const nextMissions = adaptMissions(orderedPlans);
|
||||
setDemoMode(false);
|
||||
setMissions(nextMissions);
|
||||
return nextMissions;
|
||||
@@ -625,7 +589,7 @@ export default function MissionCenter({
|
||||
<div className="de-card-body">
|
||||
{demoMode && (
|
||||
<div className="de-schedule-result de-schedule-result--success" style={{ marginBottom: 12 }}>
|
||||
当前展示为客户演示场景。真实任务未加载时,页面会自动使用“一天工作流”示例数据。
|
||||
当前没有加载到 qimingclaw 任务记录,页面暂时展示本地示例数据。
|
||||
</div>
|
||||
)}
|
||||
{actionError && (
|
||||
@@ -679,13 +643,13 @@ export default function MissionCenter({
|
||||
) : displayMissions.length === 0 ? (
|
||||
<div className="de-mission-empty-panel">
|
||||
<strong>当前日期暂无任务</strong>
|
||||
<p>这个日期没有匹配到任务记录,演示时可以切回演示日期,或临时查看全部任务。</p>
|
||||
<p>这个日期没有匹配到 qimingclaw 任务记录,可以临时查看全部任务。</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>
|
||||
@@ -717,7 +681,7 @@ export default function MissionCenter({
|
||||
<div className="de-card-body">
|
||||
<div className="de-mission-empty-panel de-mission-empty-panel--detail">
|
||||
<strong>选择一个任务后,右侧会显示执行步骤、运行记录和交付结果。</strong>
|
||||
<p>后天演示建议先从左侧选择“自动外呼现场人员”或“接收约时工单”,让领导看到数字员工不是静态页面,而是在按流程接活、执行、反馈。</p>
|
||||
<p>任务详情会优先展示 qimingclaw 本地 Plan / Task / Run / Event 记录,以及从执行 payload 中整理出的产物线索。</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
Reference in New Issue
Block a user