吸收数字员工产物生命周期治理

This commit is contained in:
baiyanyun
2026-06-10 10:23:47 +08:00
parent 362f397e37
commit ed2f19d257
15 changed files with 739 additions and 164 deletions

View File

@@ -2176,6 +2176,8 @@
.de-artifact-source-row span { color: var(--de-text-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }
.de-artifact-source-row code, .de-artifact-source-row em { grid-column: 1 / -1; min-width: 0; color: var(--de-text-body); font-size: 12px; font-style: normal; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.de-artifact-source-actions { display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px; }
.de-artifact-source-meta { grid-column: 1 / -1; display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; }
.de-artifact-source-meta span { width: auto; max-width: 160px; padding: 2px 6px; border-radius: 6px; background: rgba(79,172,254,0.08); color: #315a78; text-align: left; }
.de-icon-btn { width: 28px; height: 28px; display: inline-grid; place-items: center; border: 1px solid rgba(79,172,254,0.18); border-radius: 6px; color: #275a82; background: rgba(255,255,255,0.78); cursor: pointer; }
.de-icon-btn:hover:not(:disabled) { border-color: rgba(79,172,254,0.45); background: #fff; }
.de-icon-btn:disabled { cursor: not-allowed; opacity: 0.42; }
@@ -2183,6 +2185,12 @@
.de-artifact-preview { margin-top: 10px; border: 1px solid rgba(79,172,254,0.14); border-radius: 8px; background: rgba(255,255,255,0.78); overflow: hidden; }
.de-artifact-preview > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 10px; border-bottom: 1px solid rgba(79,172,254,0.1); }
.de-artifact-preview pre { max-height: 220px; margin: 0; padding: 10px; overflow: auto; white-space: pre-wrap; word-break: break-word; color: var(--de-text-body); font-size: 12px; line-height: 1.55; }
.de-artifact-retention-actions { justify-content: flex-start !important; flex-wrap: wrap; }
.de-artifact-version-list { display: grid !important; gap: 6px; align-items: stretch !important; justify-content: stretch !important; border-bottom: 0 !important; }
.de-artifact-version-list div { display: grid; grid-template-columns: minmax(72px, 120px) minmax(90px, 1fr); gap: 2px 10px; align-items: center; padding: 8px; border-radius: 6px; background: rgba(244,250,255,0.78); }
.de-artifact-version-list strong { font-size: 12px; color: var(--de-text-primary); }
.de-artifact-version-list span, .de-artifact-version-list em { min-width: 0; color: var(--de-text-muted); font-size: 12px; font-style: normal; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.de-artifact-version-list em { grid-column: 1 / -1; }
.de-decision-card { background: rgba(247,251,255,0.96); }
.de-decision-chain-note {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;

View File

@@ -739,6 +739,27 @@ export function getArtifactAccessEvents(params: Record<string, string | number |
return apiFetch(`/api/digital-employee/artifact-access-events${suffix}`);
}
export function getArtifactDetail(artifactId: string): Promise<any> {
return apiFetch(`/api/digital-employee/artifacts/${encodeURIComponent(artifactId)}`)
.then((data: any) => data.item ?? null);
}
export function getArtifactVersions(artifactId: string): Promise<any[]> {
return apiFetch(`/api/digital-employee/artifacts/${encodeURIComponent(artifactId)}/versions`)
.then((data: any) => data.items ?? []);
}
export function setArtifactRetention(
artifactId: string,
action: 'mark_keep' | 'mark_expire' | 'mark_review' | 'clear_retention',
options: Record<string, string | null | undefined> = {},
): Promise<any> {
return apiFetch(`/api/digital-employee/artifacts/${encodeURIComponent(artifactId)}/retention`, {
method: 'POST',
body: JSON.stringify({ action, ...options }),
});
}
export function getDebugConversations(): Promise<any[]> {
return apiFetch<{ conversations: any[] }>('/api/debug/conversations')
.then((data) => data.conversations ?? []);

View File

@@ -64,6 +64,7 @@ declare global {
recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>;
recordDailyReport?: (input: QimingclawDailyReportRecordInput) => Promise<QimingclawDailyReportRecordResult | null>;
accessArtifact?: (artifactId: string, action: 'preview' | 'download' | 'open_location', options?: { actor?: string | null; source?: string | null }) => Promise<QimingclawArtifactAccessResponse>;
recordArtifactLifecycle?: (input: QimingclawArtifactLifecycleInput) => Promise<QimingclawArtifactLifecycleResult | null>;
restartManagedService?: (serviceId: string, options?: { reason?: string | null; actor?: string | null }) => Promise<QimingclawManagedServiceActionResponse>;
respondPermission?: (sessionId: string, permissionId: string, response: 'once' | 'always' | 'reject') => Promise<{ success?: boolean; error?: string }>;
};
@@ -249,6 +250,27 @@ interface QimingclawArtifactAccessResponse {
event_id?: string | null;
}
interface QimingclawArtifactLifecycleInput {
artifactId?: string | null;
action: string;
reason?: string | null;
retentionUntil?: string | null;
versionSummary?: Record<string, unknown> | null;
retentionSummary?: Record<string, unknown> | null;
deliverySummary?: Record<string, unknown> | null;
actor?: string | null;
source?: string | null;
planId?: string | null;
taskId?: string | null;
runId?: string | null;
}
interface QimingclawArtifactLifecycleResult {
eventId: string;
kind: string;
payload: Record<string, unknown>;
}
interface QimingclawManagedServiceActionResponse {
ok: boolean;
service_id: string;
@@ -680,6 +702,24 @@ export async function handleQimingclawDigitalApi<T>(
const artifactId = decodeURIComponent(artifactAccessMatch[1] || '');
return await handleArtifactAccess(artifactId, parseOptionalJsonBody(options.body)) as T;
}
const artifactRetentionMatch = pathname.match(/^\/api\/digital-employee\/artifacts\/([^/]+)\/retention$/);
if (method === 'POST' && artifactRetentionMatch) {
const artifactId = decodeURIComponent(artifactRetentionMatch[1] || '');
return await handleArtifactRetention(artifactId, parseOptionalJsonBody(options.body), await readQimingclawSnapshot()) as T;
}
const artifactVersionsMatch = pathname.match(/^\/api\/digital-employee\/artifacts\/([^/]+)\/versions$/);
if (method === 'GET' && artifactVersionsMatch) {
const artifactId = decodeURIComponent(artifactVersionsMatch[1] || '');
const snapshot = await readQimingclawSnapshot();
return { items: artifactVersionRows(snapshot, artifactId), source: BUSINESS_VIEW_SOURCE } as T;
}
const artifactDetailMatch = pathname.match(/^\/api\/digital-employee\/artifacts\/([^/]+)$/);
if (method === 'GET' && artifactDetailMatch) {
const artifactId = decodeURIComponent(artifactDetailMatch[1] || '');
const snapshot = await readQimingclawSnapshot();
const item = artifactDetailRow(snapshot, artifactId);
return { item, source: BUSINESS_VIEW_SOURCE } as T;
}
const businessPlanDetailMatch = pathname.match(/^\/api\/digital-employee\/plans\/([^/]+)$/);
if (method === 'GET' && businessPlanDetailMatch) {
const planId = decodeURIComponent(businessPlanDetailMatch[1] || '');
@@ -1410,6 +1450,41 @@ function normalizeArtifactAccessAction(value: string): 'preview' | 'download' |
return 'preview';
}
async function handleArtifactRetention(
artifactId: string,
body: Record<string, unknown>,
snapshot: QimingclawSnapshot | null,
): Promise<Record<string, unknown>> {
const bridge = window.QimingClawBridge?.digital;
const action = stringValue(body.action) || 'mark_review';
const artifact = artifactDetailRow(snapshot, artifactId);
if (!bridge?.recordArtifactLifecycle) {
return { ok: false, artifact_id: artifactId, action, status: 'rejected', error: 'bridge_unavailable' };
}
const result = await bridge.recordArtifactLifecycle({
artifactId,
action,
reason: stringValue(body.reason) || null,
retentionUntil: stringValue(body.retention_until ?? body.retentionUntil) || null,
versionSummary: artifact ? artifactVersionSummary(artifact) : null,
retentionSummary: artifact ? artifactRetentionSummaryForAction(artifact, action, body) : null,
deliverySummary: artifact ? artifactDeliverySummary(artifact) : null,
actor: stringValue(body.actor) || 'digital_employee_ui',
source: stringValue(body.source) || 'sgrobot-digital-adapter',
planId: artifact ? stringValue(artifact.plan_id) || null : null,
taskId: artifact ? stringValue(artifact.task_id) || null : null,
runId: artifact ? stringValue(artifact.run_id) || null : null,
});
return {
ok: Boolean(result && result.kind !== 'artifact_retention_rejected'),
artifact_id: artifactId,
action,
status: result?.kind === 'artifact_retention_rejected' ? 'rejected' : 'recorded',
event_id: result?.eventId ?? null,
item: artifact,
};
}
function syncLine(snapshot: QimingclawSnapshot | null): string {
const sync = snapshot?.sync;
if (!sync) return '管理端同步:未连接';
@@ -2343,7 +2418,8 @@ function businessEventRows(snapshot: QimingclawSnapshot | null): BusinessViewRow
function businessArtifactRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
const runtimeById = new Map(runtimeArtifacts(snapshot).map((artifact) => [artifact.id, artifact]));
return buildArtifacts(snapshot).map((artifact) => {
const artifacts = buildArtifacts(snapshot);
return artifacts.map((artifact) => {
const artifactId = stringValue(artifact.artifact_id) || stringValue(artifact.subject_id);
const runtime = runtimeById.get(artifactId);
const entity = businessEntityFromRefs({
@@ -2353,6 +2429,9 @@ function businessArtifactRows(snapshot: QimingclawSnapshot | null): BusinessView
fallback_type: 'artifact',
fallback_id: artifact.subject_id ?? artifactId,
});
const lifecycle = artifactLifecycleSummary(snapshot, artifactId);
const version = artifactVersionMeta(artifacts, artifact);
const delivery = artifactDeliverySummary(artifact);
return {
...artifact,
artifact_id: artifactId,
@@ -2360,10 +2439,53 @@ function businessArtifactRows(snapshot: QimingclawSnapshot | null): BusinessView
entity_type: entity.entity_type,
entity_id: entity.entity_id,
sync_status: (runtime?.syncStatus ?? stringValue(artifact.sync_status)) || null,
version: version.version,
version_key: version.version_key,
version_rank: version.version_rank,
retention_status: lifecycle.retention_status,
retention_until: lifecycle.retention_until,
last_retention_action: lifecycle.last_retention_action,
last_access_status: stringValue(asRecord(artifact.access)?.last_access_status) || null,
delivery_summary: delivery,
};
});
}
function artifactDetailRow(snapshot: QimingclawSnapshot | null, artifactId: string): BusinessViewRow | null {
const row = businessArtifactRows(snapshot)
.find((artifact) => stringValue(artifact.artifact_id) === artifactId || stringValue(artifact.remote_id) === artifactId) ?? null;
if (!row) return null;
return {
...row,
versions: artifactVersionRows(snapshot, stringValue(row.artifact_id)),
lifecycle_events: artifactLifecycleRows(snapshot).filter((event) => stringValue(event.artifact_id) === stringValue(row.artifact_id)).slice(0, 20),
};
}
function artifactVersionRows(snapshot: QimingclawSnapshot | null, artifactId: string): BusinessViewRow[] {
const artifacts = buildArtifacts(snapshot);
const target = artifacts.find((artifact) => stringValue(artifact.artifact_id) === artifactId || stringValue(artifact.subject_id) === artifactId);
if (!target) return [];
const groupKey = artifactVersionGroupKey(target);
return artifacts
.filter((artifact) => artifactVersionGroupKey(artifact) === groupKey)
.sort((left, right) => stringValue(left.created_at).localeCompare(stringValue(right.created_at)))
.map((artifact, index) => {
const meta = artifactVersionMeta(artifacts, artifact);
return {
artifact_id: stringValue(artifact.artifact_id) || stringValue(artifact.subject_id),
name: stringValue(artifact.name) || null,
version: meta.version || `local-v${index + 1}`,
version_key: meta.version_key,
version_rank: index + 1,
delivery_summary: artifactDeliverySummary(artifact),
retention_status: artifactLifecycleSummary(snapshot, stringValue(artifact.artifact_id)).retention_status,
created_at: stringValue(artifact.created_at) || null,
source_label: stringValue(artifact.source_label) || null,
};
});
}
function businessApprovalRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
const runtimeById = new Map(runtimeApprovals(snapshot).map((approval) => [approval.id, approval]));
return buildApprovals(snapshot).map((approval) => {
@@ -2464,6 +2586,117 @@ function filterArtifactAccessRows(rows: BusinessViewRow[], searchParams: URLSear
.filter((row) => !status || stringValue(row.status) === status);
}
function artifactLifecycleRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
return runtimeEvents(snapshot)
.filter((event) => ['artifact_retention_marked', 'artifact_retention_rejected', 'artifact_version_observed'].includes(event.kind))
.map((event) => {
const payload = asRecord(event.payload) ?? {};
return {
lifecycle_id: event.id,
event_id: event.id,
kind: event.kind,
artifact_id: stringValue(payload.artifact_id) || null,
action: stringValue(payload.action) || null,
status: stringValue(payload.status) || null,
retention_status: stringValue(payload.retention_status) || null,
retention_until: stringValue(payload.retention_until) || null,
reason: stringValue(payload.reason) || null,
actor: stringValue(payload.actor) || null,
source: stringValue(payload.source) || null,
plan_id: (event.planId ?? stringValue(payload.plan_id)) || null,
task_id: (event.taskId ?? stringValue(payload.task_id)) || null,
run_id: (event.runId ?? stringValue(payload.run_id)) || null,
message: event.message,
occurred_at: event.occurredAt,
sync_status: event.syncStatus ?? null,
payload,
};
})
.sort((left, right) => stringValue(right.occurred_at).localeCompare(stringValue(left.occurred_at)));
}
function artifactLifecycleSummary(snapshot: QimingclawSnapshot | null, artifactId: string): Record<string, unknown> {
const latest = artifactLifecycleRows(snapshot)
.find((event) => stringValue(event.artifact_id) === artifactId && stringValue(event.kind) === 'artifact_retention_marked');
return {
retention_status: stringValue(latest?.retention_status) || 'unmanaged',
retention_until: stringValue(latest?.retention_until) || null,
last_retention_action: stringValue(latest?.action) || null,
last_retention_at: stringValue(latest?.occurred_at) || null,
};
}
function artifactRetentionSummaryForAction(
artifact: BusinessViewRow,
action: string,
body: Record<string, unknown>,
): Record<string, unknown> {
return {
previous_status: stringValue(artifact.retention_status) || 'unmanaged',
retention_status: retentionStatusForAction(action),
retention_until: stringValue(body.retention_until ?? body.retentionUntil) || null,
reason: stringValue(body.reason) || null,
};
}
function retentionStatusForAction(action: string): string | null {
if (action === 'mark_keep') return 'keep';
if (action === 'mark_expire') return 'expire_candidate';
if (action === 'mark_review') return 'review_required';
if (action === 'clear_retention') return 'unmanaged';
return null;
}
function artifactVersionSummary(artifact: BusinessViewRow): Record<string, unknown> {
return {
version: stringValue(artifact.version) || null,
version_key: stringValue(artifact.version_key) || null,
version_rank: numberValue(artifact.version_rank),
file_id: stringValue(artifact.file_id) || null,
workspace_id: stringValue(artifact.workspace_id) || null,
project_id: stringValue(artifact.project_id) || null,
created_at: stringValue(artifact.created_at) || null,
name: stringValue(artifact.name) || null,
};
}
function artifactDeliverySummary(artifact: Record<string, unknown>): Record<string, unknown> {
return {
delivery_status: stringValue(artifact.delivery_status) || null,
delivery_stage: stringValue(artifact.delivery_stage) || null,
workspace_id: stringValue(artifact.workspace_id) || null,
project_id: stringValue(artifact.project_id) || null,
file_id: stringValue(artifact.file_id) || null,
version: stringValue(artifact.version) || null,
file_size: numberValue(artifact.file_size),
source_tool: stringValue(artifact.source_tool) || null,
};
}
function artifactVersionMeta(artifacts: ArtifactEntry[], artifact: Record<string, unknown>): Record<string, unknown> {
const groupKey = artifactVersionGroupKey(artifact);
const group = artifacts
.filter((item) => artifactVersionGroupKey(item) === groupKey)
.sort((left, right) => stringValue(left.created_at).localeCompare(stringValue(right.created_at)));
const artifactId = stringValue(artifact.artifact_id) || stringValue(artifact.subject_id);
const index = Math.max(0, group.findIndex((item) => stringValue(item.artifact_id) === artifactId || stringValue(item.subject_id) === artifactId));
return {
version: stringValue(artifact.version) || `local-v${index + 1}`,
version_key: `${groupKey}:v${stringValue(artifact.version) || index + 1}`,
version_rank: index + 1,
};
}
function artifactVersionGroupKey(artifact: Record<string, unknown>): string {
const fileId = stringValue(artifact.file_id);
const workspaceId = stringValue(artifact.workspace_id);
const name = stringValue(artifact.name);
if (fileId) return `file:${workspaceId}:${fileId}:${name}`;
const artifactId = stringValue(artifact.artifact_id) || stringValue(artifact.subject_id);
if (artifactId) return `artifact:${artifactId}`;
return `fallback:${workspaceId}:${stringValue(artifact.project_id)}:${name || stringValue(artifact.uri)}`;
}
function dailyReportRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
return buildArtifacts(snapshot)
.filter(isDailyReportArtifact)
@@ -3077,18 +3310,28 @@ function buildArtifacts(snapshot: QimingclawSnapshot | null): ArtifactEntry[] {
runId: event.runId,
})),
];
return dedupeArtifacts(artifacts).map((artifact) => withArtifactAccessSummary(artifact, snapshot));
const deduped = dedupeArtifacts(artifacts);
return deduped.map((artifact) => withArtifactAccessSummary(artifact, snapshot, deduped));
}
function withArtifactAccessSummary(artifact: ArtifactEntry, snapshot: QimingclawSnapshot | null): ArtifactEntry {
function withArtifactAccessSummary(artifact: ArtifactEntry, snapshot: QimingclawSnapshot | null, artifacts: ArtifactEntry[]): ArtifactEntry {
const artifactId = stringValue(artifact.artifact_id) || stringValue(artifact.subject_id);
const lastAccess = artifactAccessRows(snapshot).find((row) => stringValue(row.artifact_id) === artifactId) ?? null;
const hasLocalUri = isLocalArtifactUri(stringValue(artifact.uri));
const hasDailyText = isDailyReportArtifact(artifact) && Boolean(stringValue(asRecord(artifact.payload)?.text_content));
const formalRuntimeArtifact = runtimeArtifacts(snapshot).some((runtime) => runtime.id === artifactId || runtime.remoteId === artifactId);
const previewable = formalRuntimeArtifact && (hasDailyText || hasLocalUri);
const lifecycle = artifactLifecycleSummary(snapshot, artifactId);
const version = artifactVersionMeta(artifacts, artifact);
return {
...artifact,
version: version.version,
version_key: version.version_key,
version_rank: version.version_rank,
retention_status: lifecycle.retention_status,
retention_until: lifecycle.retention_until,
last_retention_action: lifecycle.last_retention_action,
delivery_summary: artifactDeliverySummary(artifact),
access: {
previewable,
downloadable: formalRuntimeArtifact && hasLocalUri,
@@ -3733,6 +3976,11 @@ function reportArtifactSources(artifacts: ArtifactEntry[]) {
project_id: stringValue(artifact.project_id) || null,
file_id: stringValue(artifact.file_id) || null,
version: stringValue(artifact.version) || null,
version_key: stringValue(artifact.version_key) || null,
version_rank: numberValue(artifact.version_rank),
retention_status: stringValue(artifact.retention_status) || null,
retention_until: stringValue(artifact.retention_until) || null,
last_retention_action: stringValue(artifact.last_retention_action) || null,
file_size: typeof artifact.file_size === 'number' ? artifact.file_size : null,
source_tool: stringValue(artifact.source_tool) || null,
source_label: stringValue(artifact.source_label) || null,
@@ -3743,6 +3991,7 @@ function reportArtifactSources(artifacts: ArtifactEntry[]) {
run_id: stringValue(artifact.run_id) || null,
created_at: stringValue(artifact.created_at) || null,
access: artifact.access,
delivery_summary: artifact.delivery_summary ?? null,
}));
}

View File

@@ -1,11 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { Download, Eye, FolderOpen } from 'lucide-react';
import { Download, Eye, FolderOpen, History } from 'lucide-react';
import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import {
getDigitalEmployeeWorkday,
postDigitalEmployeeWorkdayAction,
accessArtifact,
getArtifactDetail,
getArtifactVersions,
setArtifactRetention,
} from '@/lib/api';
import type {
DigitalEmployeeDailyReport,
@@ -185,6 +188,7 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
const [busyKey, setBusyKey] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [preview, setPreview] = useState<{ title: string; text: string } | null>(null);
const [detail, setDetail] = useState<{ artifact: DigitalEmployeeReportArtifactSource; item: any; versions: any[] } | null>(null);
if (artifacts.length === 0) {
return <p className="de-empty-text" style={{ padding: '18px 0 6px' }}></p>;
@@ -217,6 +221,47 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
}
};
const loadDetail = async (artifact: DigitalEmployeeReportArtifactSource) => {
if (!artifact.id || busyKey) return;
setBusyKey(`${artifact.id}:detail`);
setNotice(null);
try {
const [item, versions] = await Promise.all([
getArtifactDetail(artifact.id),
getArtifactVersions(artifact.id),
]);
setDetail({ artifact, item, versions });
} catch (error) {
setNotice(error instanceof Error ? error.message : '读取产物详情失败');
} finally {
setBusyKey(null);
}
};
const runRetention = async (action: 'mark_keep' | 'mark_expire' | 'mark_review' | 'clear_retention') => {
const artifact = detail?.artifact;
if (!artifact?.id || busyKey) return;
setBusyKey(`${artifact.id}:${action}`);
setNotice(null);
try {
const result = await setArtifactRetention(artifact.id, action, { reason: 'digital_employee_artifact_governance' });
if (!result?.ok) {
setNotice(result?.error || '保留策略写入失败');
return;
}
const [item, versions] = await Promise.all([
getArtifactDetail(artifact.id),
getArtifactVersions(artifact.id),
]);
setDetail({ artifact: { ...artifact, retention_status: String(item?.retention_status || artifact.retention_status || 'unmanaged') }, item, versions });
setNotice('保留策略已记录');
} catch (error) {
setNotice(error instanceof Error ? error.message : '保留策略写入失败');
} finally {
setBusyKey(null);
}
};
return (
<>
<div className="de-artifact-source-list" aria-label="日报产物来源">
@@ -232,6 +277,13 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
<button type="button" className="de-icon-btn" disabled={!previewable || busyKey !== null} onClick={() => runAccess(artifact, 'preview')} title="预览" aria-label="预览"><Eye size={15} /></button>
<button type="button" className="de-icon-btn" disabled={!downloadable || busyKey !== null} onClick={() => runAccess(artifact, 'download')} title="下载" aria-label="下载"><Download size={15} /></button>
<button type="button" className="de-icon-btn" disabled={!openable || busyKey !== null} onClick={() => runAccess(artifact, 'open_location')} title="位置" aria-label="位置"><FolderOpen size={15} /></button>
<button type="button" className="de-icon-btn" disabled={busyKey !== null} onClick={() => loadDetail(artifact)} title="版本" aria-label="版本"><History size={15} /></button>
</div>
<div className="de-artifact-source-meta">
<span>{artifact.version || 'local-v1'}</span>
<span>{artifact.retention_status || 'unmanaged'}</span>
{artifact.delivery_status ? <span>{artifact.delivery_status}</span> : null}
{artifact.access?.last_access_status ? <span>{artifact.access.last_access_status}</span> : null}
</div>
{artifact.uri ? <code>{artifact.uri}</code> : <em>{artifact.kind || 'artifact'}</em>}
</div>
@@ -245,6 +297,26 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
<pre>{preview.text}</pre>
</div>
) : null}
{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-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>
</div>
<div className="de-artifact-version-list">
{(detail.versions.length > 0 ? detail.versions : [detail.item]).filter(Boolean).map((item) => (
<div key={String(item.artifact_id || item.version_key || item.created_at)}>
<strong>{String(item.version || 'local-v1')}</strong>
<span>{String(item.delivery_summary?.delivery_status || item.source_label || '未记录')}</span>
<em>{String(item.created_at || '')}</em>
</div>
))}
</div>
</div>
) : null}
</>
);
}

View File

@@ -756,6 +756,11 @@ export interface DigitalEmployeeReportArtifactSource {
project_id?: string | null;
file_id?: string | null;
version?: string | null;
version_key?: string | null;
version_rank?: number | null;
retention_status?: string | null;
retention_until?: string | null;
last_retention_action?: string | null;
file_size?: number | null;
source_tool?: string | null;
source_label?: string | null;
@@ -766,6 +771,7 @@ export interface DigitalEmployeeReportArtifactSource {
run_id?: string | null;
created_at?: string | null;
access?: ArtifactEntry['access'];
delivery_summary?: Record<string, unknown> | null;
}
export interface DigitalEmployeeDelivery {