feat(client): vendor digital employee source
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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, '_');
|
||||
}
|
||||
Reference in New Issue
Block a user