feat: summarize digital sync records
This commit is contained in:
@@ -8,7 +8,7 @@ import type {
|
||||
FormInstance,
|
||||
ProColumns,
|
||||
} from '@ant-design/pro-components';
|
||||
import { Button, Drawer, message, Tag, Typography } from 'antd';
|
||||
import { Button, Drawer, message, Space, Tag, Typography } from 'antd';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useModel, useSearchParams } from 'umi';
|
||||
|
||||
@@ -30,6 +30,29 @@ const ENTITY_TYPE_TONE: Record<string, string> = {
|
||||
approval: 'orange',
|
||||
};
|
||||
|
||||
const STATUS_TONE: Record<string, string> = {
|
||||
running: 'processing',
|
||||
pending: 'warning',
|
||||
completed: 'success',
|
||||
success: 'success',
|
||||
synced: 'success',
|
||||
failed: 'error',
|
||||
error: 'error',
|
||||
cancelled: 'default',
|
||||
};
|
||||
|
||||
interface PayloadObject {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SyncBusinessSummary {
|
||||
title: string;
|
||||
description: string;
|
||||
status?: string;
|
||||
statusTone: string;
|
||||
meta: string[];
|
||||
}
|
||||
|
||||
function formatPayload(payload?: string): string {
|
||||
if (!payload) return '{}';
|
||||
try {
|
||||
@@ -39,6 +62,127 @@ function formatPayload(payload?: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function parsePayload(payload?: string): unknown {
|
||||
if (!payload) return null;
|
||||
try {
|
||||
return JSON.parse(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isPayloadObject(value: unknown): value is PayloadObject {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function valueAsString(value: unknown): string {
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (typeof value === 'number' || typeof value === 'boolean')
|
||||
return String(value);
|
||||
return '';
|
||||
}
|
||||
|
||||
function pickString(payload: PayloadObject, keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = valueAsString(payload[key]);
|
||||
if (value) return value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function compactSummaryText(value: string, maxLength = 96): string {
|
||||
const text = value.replace(/\s+/g, ' ').trim();
|
||||
return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
|
||||
}
|
||||
|
||||
function entityLabel(entityType: string): string {
|
||||
return (
|
||||
ENTITY_TYPE_OPTIONS[entityType as keyof typeof ENTITY_TYPE_OPTIONS]?.text ||
|
||||
entityType ||
|
||||
'Entity'
|
||||
);
|
||||
}
|
||||
|
||||
function statusTone(status?: string): string {
|
||||
return status ? STATUS_TONE[status.toLowerCase()] || 'default' : 'default';
|
||||
}
|
||||
|
||||
function buildBusinessSummary(
|
||||
record: DigitalEmployeeSyncRecordInfo,
|
||||
): SyncBusinessSummary {
|
||||
const payload = parsePayload(record.payload);
|
||||
const entity = entityLabel(record.entity_type);
|
||||
if (!isPayloadObject(payload)) {
|
||||
return {
|
||||
title: `${entity} ${record.entity_id}`,
|
||||
description: 'Payload 暂无法解析,查看详情可读取原始内容。',
|
||||
status: record.operation,
|
||||
statusTone: 'default',
|
||||
meta: [record.outbox_id],
|
||||
};
|
||||
}
|
||||
|
||||
const status = pickString(payload, ['status', 'currentStatus', 'kind']);
|
||||
const title =
|
||||
pickString(payload, ['title', 'name', 'message', 'objective']) ||
|
||||
`${entity} ${record.entity_id}`;
|
||||
const description = compactSummaryText(
|
||||
pickString(payload, ['objective', 'summary', 'message', 'uri']) ||
|
||||
record.outbox_id,
|
||||
);
|
||||
const meta = [
|
||||
pickString(payload, ['planId', 'plan_id']) &&
|
||||
`Plan ${pickString(payload, ['planId', 'plan_id'])}`,
|
||||
pickString(payload, ['taskId', 'task_id']) &&
|
||||
`Task ${pickString(payload, ['taskId', 'task_id'])}`,
|
||||
pickString(payload, ['runId', 'run_id']) &&
|
||||
`Run ${pickString(payload, ['runId', 'run_id'])}`,
|
||||
pickString(payload, ['assignedSkillId']) &&
|
||||
`Skill ${pickString(payload, ['assignedSkillId'])}`,
|
||||
pickString(payload, ['occurred_at', 'started_at', 'finished_at']),
|
||||
].filter((item): item is string => Boolean(item));
|
||||
|
||||
return {
|
||||
title: compactSummaryText(title, 72),
|
||||
description,
|
||||
status: status || record.operation,
|
||||
statusTone: statusTone(status),
|
||||
meta: meta.slice(0, 3),
|
||||
};
|
||||
}
|
||||
|
||||
function renderBusinessSummary(record: DigitalEmployeeSyncRecordInfo) {
|
||||
const summary = buildBusinessSummary(record);
|
||||
return (
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Space size={6} wrap>
|
||||
<Typography.Text strong ellipsis style={{ maxWidth: 260 }}>
|
||||
{summary.title}
|
||||
</Typography.Text>
|
||||
{summary.status && (
|
||||
<Tag color={summary.statusTone}>{summary.status}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
ellipsis
|
||||
style={{ display: 'block', maxWidth: 330, fontSize: 12 }}
|
||||
>
|
||||
{summary.description}
|
||||
</Typography.Text>
|
||||
{summary.meta.length > 0 && (
|
||||
<Space size={[4, 4]} wrap style={{ marginTop: 4 }}>
|
||||
{summary.meta.map((item) => (
|
||||
<Tag key={item} color="default" style={{ marginInlineEnd: 0 }}>
|
||||
{item}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function readSearchValue(
|
||||
params: URLSearchParams,
|
||||
key: string,
|
||||
@@ -139,13 +283,20 @@ const DigitalEmployeeSync: React.FC = () => {
|
||||
{
|
||||
title: '实体 ID',
|
||||
dataIndex: 'entity_id',
|
||||
width: 220,
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
fieldProps: {
|
||||
placeholder: '输入实体 ID',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '业务摘要',
|
||||
dataIndex: 'payload',
|
||||
width: 360,
|
||||
hideInSearch: true,
|
||||
render: (_, record) => renderBusinessSummary(record),
|
||||
},
|
||||
{
|
||||
title: 'Outbox ID',
|
||||
dataIndex: 'outbox_id',
|
||||
@@ -258,12 +409,15 @@ const DigitalEmployeeSync: React.FC = () => {
|
||||
/>
|
||||
<Drawer
|
||||
width={720}
|
||||
title="同步 Payload"
|
||||
title="同步详情"
|
||||
open={Boolean(payloadRecord)}
|
||||
onClose={() => setPayloadRecord(null)}
|
||||
>
|
||||
{payloadRecord && (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{renderBusinessSummary(payloadRecord)}
|
||||
</div>
|
||||
<Typography.Paragraph>
|
||||
<Typography.Text strong>实体:</Typography.Text>
|
||||
{payloadRecord.entity_type} / {payloadRecord.entity_id}
|
||||
|
||||
@@ -486,6 +486,7 @@ GET /api/digital-employee/sync/records
|
||||
- 按当前登录上下文租户隔离,默认每页 20 条,最大每页 100 条。
|
||||
- 当前返回通用同步记录和原始 payload,用于管理端页面或联调工具先查看同步结果;后续再拆分为更丰富的 Plan / Task / Run / Event 查询模型。
|
||||
- 管理端已增加“数字员工同步记录”菜单、权限资源和列表页,可按客户端 Key、实体类型、实体 ID、Outbox ID 查询并查看 payload;URL 查询参数会预填筛选条件,并在命中 Outbox ID 时自动打开 payload 抽屉。
|
||||
- 管理端同步记录页会从通用 payload 中提炼标题、状态、Plan/Task/Run 关联、发生时间等业务摘要,列表和详情抽屉不再只能依赖原始 JSON 排障。
|
||||
|
||||
下一步:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user