吸收数字员工审批冲突与产物业务闭环

This commit is contained in:
baiyanyun
2026-06-12 16:29:01 +08:00
parent fffa0ebe50
commit 81d2797035
26 changed files with 2963 additions and 241 deletions

View File

@@ -0,0 +1,321 @@
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
import { Eye, RefreshCw, Search, X } from 'lucide-react';
import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import { getDigitalEmployeeBusinessView } from '@/lib/api';
import type {
DigitalEmployeeBusinessViewKind,
DigitalEmployeeBusinessViewQuery,
DigitalEmployeeBusinessViewRow,
} from '@/types/api';
const VIEW_OPTIONS: Array<{ id: DigitalEmployeeBusinessViewKind; label: string }> = [
{ id: 'plans', label: '计划' },
{ id: 'tasks', label: '任务' },
{ id: 'runs', label: '运行' },
{ id: 'events', label: '事件' },
{ id: 'artifacts', label: '产物' },
{ id: 'approvals', label: '审批' },
];
const pageStyle: CSSProperties = {
padding: '14px 18px',
};
const toolbarStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'minmax(180px, 1fr) repeat(4, minmax(120px, 160px)) auto',
gap: 8,
alignItems: 'end',
marginBottom: 12,
};
const contentStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) minmax(280px, 360px)',
gap: 12,
alignItems: 'start',
};
const tableWrapStyle: CSSProperties = {
overflow: 'auto',
border: '1px solid var(--de-glass-border-lighter)',
borderRadius: 8,
};
const tableStyle: CSSProperties = {
width: '100%',
minWidth: 840,
borderCollapse: 'collapse',
};
const detailStyle: CSSProperties = {
border: '1px solid var(--de-glass-border-lighter)',
borderRadius: 8,
background: 'rgba(255,255,255,0.72)',
overflow: 'hidden',
};
const emptyDetailStyle: CSSProperties = {
padding: 18,
color: 'var(--de-text-muted)',
fontSize: 13,
};
type FilterState = {
entity: string;
status: string;
syncStatus: string;
updatedFrom: string;
updatedTo: string;
pageSize: number;
};
const DEFAULT_FILTERS: FilterState = {
entity: '',
status: '',
syncStatus: '',
updatedFrom: '',
updatedTo: '',
pageSize: 20,
};
function normalizeKind(value?: string | null): DigitalEmployeeBusinessViewKind {
return VIEW_OPTIONS.some((item) => item.id === value) ? value as DigitalEmployeeBusinessViewKind : 'plans';
}
function displayText(value: unknown, fallback = '-'): string {
if (value === null || value === undefined || value === '') return fallback;
return String(value);
}
function rowEntityId(row: DigitalEmployeeBusinessViewRow): string {
return displayText(row.entity_id || row.plan_id || row.task_id || row.run_id || row.event_id || row.artifact_id || row.approval_id, 'unknown');
}
function rowTitle(row: DigitalEmployeeBusinessViewRow): string {
return displayText(row.title || row.summary || row.kind || rowEntityId(row), '未命名业务对象');
}
function rowTime(row: DigitalEmployeeBusinessViewRow): string {
return displayText(row.updated_at || row.occurred_at || row.finished_at || row.started_at || row.created_at);
}
function kindExtra(row: DigitalEmployeeBusinessViewRow, kind: DigitalEmployeeBusinessViewKind): string {
if (kind === 'plans') return `任务 ${displayText(row.task_count, '0')} / 运行 ${displayText(row.run_count, '0')}`;
if (kind === 'runs') return displayText(row.last_event_message || row.duration_ms, '无最近事件');
if (kind === 'events') return displayText(row.kind, 'event');
if (kind === 'artifacts') return displayText(row.cleanup_status || row.deleted_at || row.uri || row.kind, 'artifact');
if (kind === 'approvals') return displayText(row.approval_conflict_resolution || row.approval_conflict_resolved_at || row.decision || row.approval_conflict_reason || row.kind, 'approval');
return displayText(row.assigned_skill_id || row.operation || row.kind);
}
function queryFromFilters(filters: FilterState, pageNo: number): DigitalEmployeeBusinessViewQuery {
return {
entity_id: filters.entity.trim() || null,
status: filters.status.trim() || null,
sync_status: filters.syncStatus.trim() || null,
updated_from: filters.updatedFrom || null,
updated_to: filters.updatedTo || null,
page_no: pageNo,
page_size: filters.pageSize,
};
}
function statusClass(value: unknown): string {
const text = String(value || '').toLowerCase();
if (['failed', 'error', 'rejected', 'conflict'].some((key) => text.includes(key))) return 'de-badge de-badge-danger';
if (['pending', 'running', 'syncing', 'ready'].some((key) => text.includes(key))) return 'de-badge de-badge-warning';
return 'de-badge de-badge-info';
}
function DetailPanel({ row, onClose }: { row: DigitalEmployeeBusinessViewRow | null; onClose: () => void }) {
if (!row) {
return <aside style={detailStyle}><div style={emptyDetailStyle}></div></aside>;
}
const jsonPayload = row.business_view || row.payload ? {
business_view: row.business_view ?? null,
payload: row.payload ?? null,
} : null;
return (
<aside style={detailStyle} aria-label="业务视图详情">
<div className="de-artifact-preview">
<div>
<strong>{rowTitle(row)}</strong>
<button type="button" className="de-icon-btn" onClick={onClose} title="关闭" aria-label="关闭"><X size={15} /></button>
</div>
<pre>{[
`实体:${rowEntityId(row)}`,
`状态:${displayText(row.status)}`,
`同步:${displayText(row.sync_status)}`,
`Plan${displayText(row.plan_id)}`,
`Task${displayText(row.task_id)}`,
`Run${displayText(row.run_id)}`,
`时间:${rowTime(row)}`,
row.sync_record_url ? `Sync Record${row.sync_record_url}` : '',
row.summary ? `摘要:${row.summary}` : '',
].filter(Boolean).join('\n')}</pre>
</div>
{jsonPayload ? (
<details className="de-business-json" open>
<summary>JSON </summary>
<pre>{JSON.stringify(jsonPayload, null, 2)}</pre>
</details>
) : null}
</aside>
);
}
export default function BusinessView({ initialKind, focusEntityId }: { initialKind?: string | null; focusEntityId?: string | null }) {
const [kind, setKind] = useState<DigitalEmployeeBusinessViewKind>(() => normalizeKind(initialKind));
const [filters, setFilters] = useState<FilterState>({ ...DEFAULT_FILTERS, entity: focusEntityId || '' });
const [pageNo, setPageNo] = useState(1);
const [rows, setRows] = useState<DigitalEmployeeBusinessViewRow[]>([]);
const [total, setTotal] = useState(0);
const [source, setSource] = useState<string | null>(null);
const [selected, setSelected] = useState<DigitalEmployeeBusinessViewRow | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setKind(normalizeKind(initialKind));
}, [initialKind]);
useEffect(() => {
if (focusEntityId) setFilters((current) => ({ ...current, entity: focusEntityId }));
}, [focusEntityId]);
const loadRows = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getDigitalEmployeeBusinessView(kind, queryFromFilters(filters, pageNo));
setRows(data.items ?? []);
setTotal(data.total ?? 0);
setSource(data.source ?? null);
setSelected((current) => {
if (!current) return null;
return (data.items ?? []).find((item) => rowEntityId(item) === rowEntityId(current)) ?? null;
});
} catch (loadError) {
setRows([]);
setTotal(0);
setError(loadError instanceof Error ? loadError.message : '业务视图加载失败');
} finally {
setLoading(false);
}
}, [filters, kind, pageNo]);
useEffect(() => {
void loadRows();
}, [loadRows]);
const pageCount = useMemo(() => Math.max(1, Math.ceil(total / filters.pageSize)), [filters.pageSize, total]);
const updateFilter = <K extends keyof FilterState>(key: K, value: FilterState[K]) => {
setFilters((current) => ({ ...current, [key]: value }));
setPageNo(1);
};
return (
<div style={pageStyle}>
<GlassCard>
<CardTitleBar title="业务视图">
<span className="de-card-meta-text">{source || 'qimingclaw 本地业务查询'}</span>
</CardTitleBar>
<div className="de-card-body">
<div className="de-segmented-tabs" role="tablist" aria-label="业务视图类型">
{VIEW_OPTIONS.map((item) => (
<button
key={item.id}
type="button"
className={item.id === kind ? 'active' : ''}
onClick={() => { setKind(item.id); setPageNo(1); setSelected(null); }}
>
{item.label}
</button>
))}
</div>
<div style={toolbarStyle} className="de-business-toolbar">
<label>
<span> / ID</span>
<input value={filters.entity} onChange={(event) => updateFilter('entity', event.target.value)} placeholder="plan / task / artifact" />
</label>
<label>
<span></span>
<input value={filters.status} onChange={(event) => updateFilter('status', event.target.value)} placeholder="pending" />
</label>
<label>
<span></span>
<input value={filters.syncStatus} onChange={(event) => updateFilter('syncStatus', event.target.value)} placeholder="synced" />
</label>
<label>
<span></span>
<input type="date" value={filters.updatedFrom} onChange={(event) => updateFilter('updatedFrom', event.target.value)} />
</label>
<label>
<span></span>
<input type="date" value={filters.updatedTo} onChange={(event) => updateFilter('updatedTo', event.target.value)} />
</label>
<div className="de-business-actions">
<button type="button" className="de-icon-btn" onClick={() => { void loadRows(); }} title="搜索" aria-label="搜索"><Search size={15} /></button>
<button type="button" className="de-icon-btn" onClick={() => { setFilters(DEFAULT_FILTERS); setPageNo(1); }} title="重置" aria-label="重置"><RefreshCw size={15} /></button>
</div>
</div>
{error ? <p className="de-inline-notice">{error}</p> : null}
<div style={contentStyle} className="de-business-layout">
<section style={tableWrapStyle} aria-label="业务视图列表">
<table style={tableStyle} className="de-business-table">
<thead>
<tr>
<th> / </th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={7}>{loading ? '加载中...' : '暂无业务对象'}</td></tr>
) : rows.map((row) => (
<tr key={`${kind}:${rowEntityId(row)}:${rowTime(row)}`} className={selected && rowEntityId(selected) === rowEntityId(row) ? 'active' : ''}>
<td><strong>{rowTitle(row)}</strong><code>{rowEntityId(row)}</code></td>
<td><span className={statusClass(row.status)}>{displayText(row.status)}</span></td>
<td><span>{displayText(row.plan_id)}</span><span>{displayText(row.task_id)}</span><span>{displayText(row.run_id)}</span></td>
<td>{kindExtra(row, kind)}</td>
<td>{displayText(row.sync_status)}</td>
<td>{rowTime(row)}</td>
<td><button type="button" className="de-icon-btn" onClick={() => setSelected(row)} title="详情" aria-label="详情"><Eye size={15} /></button></td>
</tr>
))}
</tbody>
</table>
</section>
<DetailPanel row={selected} onClose={() => setSelected(null)} />
</div>
<div className="de-business-pagination">
<span> {total} </span>
<label>
<span></span>
<select value={filters.pageSize} onChange={(event) => updateFilter('pageSize', Number(event.target.value) || 20)}>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
</label>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={pageNo <= 1} onClick={() => setPageNo((page) => Math.max(1, page - 1))}></button>
<span>{pageNo} / {pageCount}</span>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={pageNo >= pageCount} onClick={() => setPageNo((page) => Math.min(pageCount, page + 1))}></button>
</div>
</div>
</GlassCard>
</div>
);
}

View File

@@ -17,6 +17,7 @@ import {
pullRiskApprovalPolicy,
pullRouteDecisionPolicy,
recordApprovalAction,
resolveApprovalConflict,
decideRouteIntervention,
restartManagedService,
} from '@/lib/api';
@@ -1736,6 +1737,42 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
}
}, [approvalActionDrafts, selectedDate]);
const resolveApprovalConflictAction = useCallback(async (
decision: DigitalEmployeeDecision,
resolution: 'keep_local' | 'accept_remote' | 'mark_reviewed',
) => {
const actionKey = `approval-conflict:${decision.id}:${resolution}`;
const draft = approvalActionDrafts[decision.id] ?? EMPTY_APPROVAL_ACTION_DRAFT;
setActionBusy(actionKey);
setActionError(null);
setActionNotice(null);
try {
const result = await resolveApprovalConflict(decision.id, {
resolution,
actor: 'digital_employee_operator',
comment: compactText(draft.comment) || null,
});
if (!mountedRef.current) return;
if (!result?.ok) {
throw new Error(result?.error || result?.reason_codes?.join(', ') || '冲突裁决失败');
}
const latestProjection = await getDigitalEmployeeWorkday(selectedDate);
if (!mountedRef.current) return;
setWorkdayProjection(latestProjection);
setApprovalActionDrafts((current) => ({
...current,
[decision.id]: { ...(current[decision.id] ?? EMPTY_APPROVAL_ACTION_DRAFT), comment: '' },
}));
setActionNotice('冲突裁决已记录。');
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '冲突裁决失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [approvalActionDrafts, selectedDate]);
const controlTaskAgent = useCallback(async (
agent: DigitalEmployeeTaskAgentState,
command: TaskAgentControlCommand,
@@ -3434,6 +3471,28 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
</span>
</div>
)}
{approvalConflict && (
<div className="de-workbench-actions de-approval-governance-actions">
{([
['keep_local', '保留本地'],
['accept_remote', '采纳管理端'],
['mark_reviewed', '标记已复核'],
] as const).map(([resolution, label]) => {
const busyKey = `approval-conflict:${decision.id}:${resolution}`;
return (
<button
key={resolution}
type="button"
className={resolution === 'accept_remote' ? 'de-workbench-action-accent' : ''}
disabled={actionBusy === busyKey}
onClick={() => { void resolveApprovalConflictAction(decision, resolution); }}
>
<span>{actionBusy === busyKey ? '记录中...' : label}</span>
</button>
);
})}
</div>
)}
<div className="de-approval-action-fields">
<input
value={draft.comment}

View File

@@ -1,13 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { Download, Eye, FolderOpen, History } from 'lucide-react';
import { CheckCircle, Download, Eye, FileText, FolderOpen, History, Send, ShieldCheck, Trash2 } from 'lucide-react';
import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import {
getDigitalEmployeeWorkday,
postDigitalEmployeeWorkdayAction,
accessArtifact,
cleanupArtifact,
getArtifactDetail,
getArtifactVersions,
recordDailyReportDelivery,
setArtifactRetention,
} from '@/lib/api';
import type {
@@ -31,7 +33,7 @@ const panelStyle: CSSProperties = {
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;
type BusyAction = 'update_report_schedule' | 'generate_daily_report' | 'download_html' | 'download_pdf' | 'send_report' | 'confirm_report' | 'request_approval' | null;
function todayInputDate(): string {
const date = new Date();
@@ -90,6 +92,25 @@ function downloadTextFile(filename: string, content: string): void {
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function downloadBlobFile(filename: string, content: BlobPart, mime: string): void {
const blob = new Blob([content], { type: mime });
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 base64ToBytes(base64: string): Uint8Array {
const binary = window.atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes;
}
function reportFileName(date: string): string {
return `qimingclaw-digital-report-${date}.txt`;
}
@@ -194,6 +215,18 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
return <p className="de-empty-text" style={{ padding: '18px 0 6px' }}></p>;
}
const mergeArtifactDetail = (artifact: DigitalEmployeeReportArtifactSource, item: any): DigitalEmployeeReportArtifactSource => ({
...artifact,
retention_status: String(item?.retention_status || artifact.retention_status || 'unmanaged'),
retention_until: String(item?.retention_until || artifact.retention_until || '') || null,
last_retention_action: String(item?.last_retention_action || artifact.last_retention_action || '') || null,
cleanup_status: String(item?.cleanup_status || artifact.cleanup_status || '') || null,
deleted_at: String(item?.deleted_at || artifact.deleted_at || '') || null,
cleanup_reason: String(item?.cleanup_reason || artifact.cleanup_reason || '') || null,
cleanup_event_id: String(item?.cleanup_event_id || artifact.cleanup_event_id || '') || null,
access: item?.access || artifact.access,
});
const runAccess = async (artifact: DigitalEmployeeReportArtifactSource, action: 'preview' | 'download' | 'open_location') => {
if (!artifact.id || busyKey) return;
const key = `${artifact.id}:${action}`;
@@ -230,7 +263,7 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
getArtifactDetail(artifact.id),
getArtifactVersions(artifact.id),
]);
setDetail({ artifact, item, versions });
setDetail({ artifact: mergeArtifactDetail(artifact, item), item, versions });
} catch (error) {
setNotice(error instanceof Error ? error.message : '读取产物详情失败');
} finally {
@@ -253,7 +286,7 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
getArtifactDetail(artifact.id),
getArtifactVersions(artifact.id),
]);
setDetail({ artifact: { ...artifact, retention_status: String(item?.retention_status || artifact.retention_status || 'unmanaged') }, item, versions });
setDetail({ artifact: mergeArtifactDetail(artifact, item), item, versions });
setNotice('保留策略已记录');
} catch (error) {
setNotice(error instanceof Error ? error.message : '保留策略写入失败');
@@ -262,6 +295,34 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
}
};
const runCleanup = async () => {
const artifact = detail?.artifact;
if (!artifact?.id || busyKey) return;
setBusyKey(`${artifact.id}:cleanup`);
setNotice(null);
try {
const result = await cleanupArtifact(artifact.id, {
reason: 'digital_employee_artifact_cleanup',
retention_status: artifact.retention_status || null,
last_retention_action: artifact.last_retention_action || null,
});
if (!result?.ok) {
setNotice(result?.error || '清理文件失败');
return;
}
const [item, versions] = await Promise.all([
getArtifactDetail(artifact.id),
getArtifactVersions(artifact.id),
]);
setDetail({ artifact: mergeArtifactDetail(artifact, item), item, versions });
setNotice('清理已记录');
} catch (error) {
setNotice(error instanceof Error ? error.message : '清理文件失败');
} finally {
setBusyKey(null);
}
};
return (
<>
<div className="de-artifact-source-list" aria-label="日报产物来源">
@@ -282,6 +343,7 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
<div className="de-artifact-source-meta">
<span>{artifact.version || 'local-v1'}</span>
<span>{artifact.retention_status || 'unmanaged'}</span>
{artifact.cleanup_status ? <span>{artifact.cleanup_status}</span> : null}
{artifact.delivery_status ? <span>{artifact.delivery_status}</span> : null}
{artifact.access?.last_access_status ? <span>{artifact.access.last_access_status}</span> : null}
</div>
@@ -300,11 +362,24 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
{detail ? (
<div className="de-artifact-preview">
<div><strong>{detail.artifact.name}</strong><button type="button" className="de-btn-secondary" onClick={() => setDetail(null)}></button></div>
<div className="de-artifact-source-meta">
<span>{detail.artifact.retention_status || 'unmanaged'}</span>
{detail.artifact.cleanup_status ? <span>{detail.artifact.cleanup_status}</span> : null}
{detail.artifact.deleted_at ? <span>{detail.artifact.deleted_at}</span> : null}
</div>
<div className="de-artifact-retention-actions">
<button type="button" className="de-btn-secondary" disabled={busyKey !== null} onClick={() => runRetention('mark_keep')}></button>
<button type="button" className="de-btn-secondary" disabled={busyKey !== null} onClick={() => runRetention('mark_expire')}></button>
<button type="button" className="de-btn-secondary" disabled={busyKey !== null} onClick={() => runRetention('mark_review')}></button>
<button type="button" className="de-btn-secondary" disabled={busyKey !== null} onClick={() => runRetention('clear_retention')}></button>
<button
type="button"
className="de-btn-secondary"
disabled={busyKey !== null || detail.artifact.cleanup_status === 'deleted' || !(detail.artifact.retention_status === 'expired' || detail.artifact.last_retention_action === 'mark_expire') || !(detail.artifact.access?.downloadable || detail.artifact.access?.openable)}
onClick={() => { void runCleanup(); }}
>
<Trash2 size={14} />
</button>
</div>
<div className="de-artifact-version-list">
{(detail.versions.length > 0 ? detail.versions : [detail.item]).filter(Boolean).map((item) => (
@@ -427,6 +502,70 @@ export default function DailyReport({ focusSection }: { focusSection?: string |
);
}, [artifactSources, report, runDetails, taskSections, today]);
const downloadReportArtifact = useCallback(async (format: 'html' | 'pdf') => {
if (!report || report.status !== 'ready') return;
const artifactId = format === 'html' ? report.html_artifact_id : report.pdf_artifact_id;
if (!artifactId) return;
setBusyAction(format === 'html' ? 'download_html' : 'download_pdf');
setMessage(null);
try {
const result = await accessArtifact(artifactId, 'download');
if (!result?.ok) {
setMessage(result?.error || '日报导出失败');
return;
}
const preview = result.preview || {};
if (format === 'html') {
downloadBlobFile(report.html_filename || reportFileName(today).replace(/\.txt$/, '.html'), String(preview.text || report.html_content || ''), 'text/html;charset=utf-8');
setMessage('HTML 日报已导出。');
} else {
const base64 = String(preview.base64 || '');
if (!base64) {
setMessage('PDF 日报内容不可用');
return;
}
downloadBlobFile(report.pdf_filename || reportFileName(today).replace(/\.txt$/, '.pdf'), base64ToBytes(base64), 'application/pdf');
setMessage('PDF 日报已导出。');
}
} catch (error) {
if (!mountedRef.current) return;
setMessage(error instanceof Error ? error.message : '日报导出失败');
} finally {
if (mountedRef.current) setBusyAction(null);
}
}, [report, today]);
const recordDelivery = useCallback(async (action: 'send' | 'confirm' | 'request_approval') => {
if (!report?.report_id) return;
const busyMap = {
send: 'send_report',
confirm: 'confirm_report',
request_approval: 'request_approval',
} as const;
setBusyAction(busyMap[action]);
setMessage(null);
try {
const result = await recordDailyReportDelivery({
reportId: report.report_id,
action,
status: action === 'send' ? 'sent' : action === 'confirm' ? 'confirmed' : 'pending',
actor: action === 'confirm' ? 'management' : 'digital_employee_operator',
endpoint: 'management-console',
});
if (!result?.eventId && !result?.event_id) {
setMessage('日报交付记录未写入');
return;
}
setMessage(action === 'send' ? '日报发送记录已写入。' : action === 'confirm' ? '管理端确认回执已写入。' : '日报发送审批已创建。');
await fetchProjection();
} catch (error) {
if (!mountedRef.current) return;
setMessage(error instanceof Error ? error.message : '日报交付记录失败');
} finally {
if (mountedRef.current) setBusyAction(null);
}
}, [fetchProjection, report?.report_id]);
return (
<div style={{ padding: '14px 18px' }}>
<GlassCard>
@@ -482,6 +621,56 @@ export default function DailyReport({ focusSection }: { focusSection?: string |
<Download size={14} aria-hidden="true" />
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={!report?.html_artifact_id || busyAction === 'download_html'}
onClick={() => { void downloadReportArtifact('html'); }}
title="导出 HTML 日报"
>
<FileText size={14} aria-hidden="true" />
HTML
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={!report?.pdf_artifact_id || busyAction === 'download_pdf'}
onClick={() => { void downloadReportArtifact('pdf'); }}
title="导出 PDF 日报"
>
<Download size={14} aria-hidden="true" />
PDF
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={!report?.report_id || busyAction === 'send_report'}
onClick={() => { void recordDelivery('send'); }}
title="记录日报发送"
>
<Send size={14} aria-hidden="true" />
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={!report?.report_id || busyAction === 'confirm_report'}
onClick={() => { void recordDelivery('confirm'); }}
title="写入管理端确认"
>
<CheckCircle size={14} aria-hidden="true" />
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={!report?.report_id || busyAction === 'request_approval'}
onClick={() => { void recordDelivery('request_approval'); }}
title="发起日报审批"
>
<ShieldCheck 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>

View File

@@ -23,6 +23,7 @@ import CommandDeck from './CommandDeck';
import MissionCenter from './MissionCenter';
import SkillLibrary from './SkillLibrary';
import DailyReport from './DailyReport';
import BusinessView from './BusinessView';
import OpsSettings from './OpsSettings';
import type { DigitalNavigationTarget, DigitalTab } from './navigation';
@@ -31,6 +32,7 @@ const TABS: { id: DigitalTab; label: string }[] = [
{ id: 'missions', label: '任务中心' },
{ id: 'skills', label: '技能库' },
{ id: 'reports', label: '日报' },
{ id: 'business', label: '业务视图' },
{ id: 'settings', label: '设置' },
];
const DIGITAL_EMPLOYEE_CLOSE_MESSAGE = 'qimingclaw:digital-close';
@@ -87,6 +89,8 @@ function targetFromLocation(pathname: string, search: string): DigitalNavigation
taskId: params.get('task'),
skillId: params.get('skill'),
reportSection: params.get('section'),
businessKind: params.get('kind'),
entityId: params.get('entity'),
};
}
@@ -103,6 +107,7 @@ function TabContent({
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 'business': return <BusinessView initialKind={target.businessKind} focusEntityId={target.entityId} />;
case 'settings': return <OpsSettings />;
}
}
@@ -284,6 +289,8 @@ export default function DigitalEmployeeShell() {
if (nextTarget.taskId) params.set('task', nextTarget.taskId);
if (nextTarget.skillId) params.set('skill', nextTarget.skillId);
if (nextTarget.reportSection) params.set('section', nextTarget.reportSection);
if (nextTarget.businessKind) params.set('kind', nextTarget.businessKind);
if (nextTarget.entityId) params.set('entity', nextTarget.entityId);
navigate({
pathname: location.pathname,
search: params.toString() ? `?${params.toString()}` : '',

View File

@@ -1,4 +1,4 @@
export type DigitalTab = 'home' | 'missions' | 'skills' | 'reports' | 'settings';
export type DigitalTab = 'home' | 'missions' | 'skills' | 'reports' | 'business' | 'settings';
export type DigitalNavigationTarget = {
tab: DigitalTab;
@@ -7,6 +7,8 @@ export type DigitalNavigationTarget = {
taskId?: string | null;
skillId?: string | null;
reportSection?: string | null;
businessKind?: string | null;
entityId?: string | null;
};
export function domSafeId(value: string): string {