diff --git a/e2e/overview-api.spec.ts b/e2e/overview-api.spec.ts index 19d4d7cd..e6a7d398 100644 --- a/e2e/overview-api.spec.ts +++ b/e2e/overview-api.spec.ts @@ -17,7 +17,7 @@ test.describe("项目 - 概述与接口", () => { }); test("接口日志页:展示标题与日志表格", async ({mockPage: page}) => { - await page.goto(`${PROJECT}/api/log`); + await page.goto(`${PROJECT}/api/logs`); // PageContainer 未设 title,通过列头确认页面已渲染 await expect(page.getByRole("columnheader", {name: "请求方法"})).toBeVisible(); // 日志数据中的请求方法标签 @@ -28,7 +28,7 @@ test.describe("项目 - 概述与接口", () => { }); test("接口日志页:点击行打开详情抽屉", async ({mockPage: page}) => { - await page.goto(`${PROJECT}/api/log`); + await page.goto(`${PROJECT}/api/logs`); await expect(page.getByText("GET").first()).toBeVisible(); // 虚拟表格行以 div.ant-table-row 渲染,点击第一行打开 Drawer const row = page.locator(".ant-table-row").first(); diff --git a/e2e/scheduling.spec.ts b/e2e/scheduling.spec.ts index 3bd19353..53b28b0d 100644 --- a/e2e/scheduling.spec.ts +++ b/e2e/scheduling.spec.ts @@ -1,7 +1,7 @@ import {test, expect} from "./fixtures"; const TRIGGER = "/project/demo/scheduling/trigger"; -const JOB_LOG = "/project/demo/scheduling/job-execution-log"; +const JOB_LOG = "/project/demo/scheduling/job-execution-logs"; test.describe("项目 - 任务调度", () => { test("触发器列表:展示触发器与创建按钮", async ({mockPage: page}) => { diff --git a/src/components/common/PageContainer.tsx b/src/components/common/PageContainer.tsx index 274e36bf..e7c48b3c 100644 --- a/src/components/common/PageContainer.tsx +++ b/src/components/common/PageContainer.tsx @@ -46,13 +46,13 @@ const PageContainer: React.FC = ({ paddingBottom: 0, display: 'flex', justifyContent: 'space-between', - alignItems: 'center', + alignItems: 'flex-start', flexShrink: 0, ...headerStyle, }} > {title && ( - + {typeof title === 'string' ? title : title} )} diff --git a/src/hooks/useConsoleLogs.ts b/src/hooks/useConsoleLogs.ts index 3c1faad6..09b8ae65 100644 --- a/src/hooks/useConsoleLogs.ts +++ b/src/hooks/useConsoleLogs.ts @@ -79,7 +79,7 @@ export const useConsoleLogs = (options: UseConsoleLogsOptions = {}): UseConsoleL setConnectionState(connected ? 'open' : 'closed'); }, 2000); - const channel = realtimeClient.subscribe(['f_api_request_log', 'f_function_log', 'f_job_execution_log'], (payload: RealtimePayload) => { + const channel = realtimeClient.subscribe(['f_span', 'f_function_log'], (payload: RealtimePayload) => { if (payload.event === 'INSERT' || payload.event === 'UPDATE') { setLogs(prev => { const next = [...prev, mapRealtimeToLogEntry(payload)]; diff --git a/src/locales/en.json b/src/locales/en.json index 497860d4..5c032b27 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1,4 +1,4 @@ -{ +{ "api": "API", "apis": { "name": "API Name", @@ -46,6 +46,19 @@ "open_api": "OpenAPI", "open_api_try_it_out": "Try It Out", "api_log": "API Log", + "log.function_logs": "Logs", + "log.api_logs": "API Logs", + "log.job_execution_log": "Job Execution Logs", + "log.node_instance_logs": "Node Instance Logs", + "log.audit_logs": "Audit Logs", + "trace_id": "Trace ID", + "ok": "OK", + "back": "Back", + "function.logTitle": "Logs", + "function.logList": "Function Log List", + "function.logDetail": "Function Log Detail", + "function.logLevel": "Level", + "function.logMessage": "Message", "custom_api": "Custom API", "graphql_api": "GraphQL", "graphql_endpoint": "Endpoint", @@ -170,6 +183,64 @@ "search_keywords": "Search Keywords", "search": "Search", "reset": "Reset", + "more_filters": "More Filters", + "detail": "Detail", + "start_time": "Start Time", + "end_time": "End Time", + "job_name": "Job Name", + "job_type": "Job Type", + "execution_status": "Execution Status", + "job_id": "Job ID", + "trigger_id": "Trigger ID", + "action_type": "Action Type", + "resource_type": "Resource Type", + "operator": "Operator", + "select_status": "Select status", + "select_action_type": "Select action type", + "input_resource_type": "Enter resource type", + "input_operator_id": "Enter operator ID", + "input_job_id": "Enter job ID", + "input_trigger_id": "Enter trigger ID", + "input_trace_id": "Enter Trace ID", + "operation": "Action", + "job_group": "Job Group", + "execution_duration": "Duration", + "execution_status_detail": "Status Detail", + "resource_name": "Resource Name", + "resource_id": "Resource ID", + "operation_time": "Operation Time", + "before_change": "Before Change", + "after_change": "After Change", + "input_data": "Input Data", + "output_data": "Output Data", + "request_body": "Request Body", + "request_headers": "Request Headers", + "retry_count": "Retry Count", + "max_retry_count": "Max Retry Count", + "scheduler_name": "Scheduler Name", + "instance_name": "Instance Name", + "fired_time": "Fired Time", + "scheduled_time": "Scheduled Time", + "error_stack_trace": "Error Stack Trace", + "node_instance_id": "Node Instance ID", + "flow_instance_id": "Flow Instance ID", + "instance_data_id": "Instance Data ID", + "node_key": "Node Key", + "node_type": "Operation Type", + "node_status": "Status", + "created_by": "Created By", + "updated_by": "Updated By", + "updated_at": "Updated At", + "running": "Running", + "unknown": "Unknown", + "archive": "Archive Status", + "deleted": "Deleted", + "normal": "Normal", + "job_execution_log_detail": "Job Execution Log Detail", + "audit_log_detail": "Audit Log Detail", + "change_diff": "Change Diff", + "load_job_execution_log_failed": "Failed to load job execution logs", + "load_audit_log_failed": "Failed to load audit logs", "collapse": "Collapse", "expand": "Expand", "level": "Level", @@ -324,6 +395,7 @@ "record": "Record", "unique": "Unique", "nullable": "Nullable", + "text": "Unlimited text", "comment": "Comment", "table_selection_delete_text": "Are you sure to delete this?", "new_field": "New Field", @@ -1098,5 +1170,13 @@ "pages.setProductionFailed": "Failed to switch deployment", "pages.preview": "Preview", "pages.refreshPreview": "Refresh", - "pages.openInNewTab": "Open in new tab" + "pages.openInNewTab": "Open in new tab", + "project_log_settings": "Log Settings", + "project_log_settings_log_retention_days": "Log retention days", + "project_log_settings_log_retention_days_desc": "API logs older than this will be purged automatically", + "project_log_settings_audit_resources": "Audit resources", + "project_log_settings_audit_resources_desc": "Audit logs are recorded only for inserts/updates/deletes on these configuration tables. Type a table name and press Enter to add", + "project_log_settings_audit_resources_placeholder": "Enter resource table name, e.g. f_trigger", + "project_log_settings_audit_resources_required": "Audit resources cannot be empty", + "project_log_settings_hint": "When audit resources are empty, the default whitelist is used: f_trigger, f_em_flow_definition, f_em_flow_deployment, f_function, f_bucket, f_auth_provider_config" } diff --git a/src/locales/zh.json b/src/locales/zh.json index 5ef3f48f..c2b2cd83 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1,4 +1,4 @@ -{ +{ "api": "接口", "apis": { "name": "接口名称", @@ -47,6 +47,19 @@ "open_api": "OpenAPI", "open_api_try_it_out": "新窗口打开", "api_log": "接口日志", + "log.function_logs": "日志", + "log.api_logs": "接口日志", + "log.job_execution_log": "任务执行日志", + "log.node_instance_logs": "节点执行日志", + "log.audit_logs": "审计日志", + "trace_id": "Trace ID", + "ok": "成功", + "back": "返回", + "function.logTitle": "日志", + "function.logList": "日志列表", + "function.logDetail": "日志详情", + "function.logLevel": "级别", + "function.logMessage": "消息", "custom_api": "定制接口", "graphql_api": "GraphQL", "graphql_endpoint": "端点", @@ -170,6 +183,64 @@ "search_keywords": "搜索关键字", "search": "搜索", "reset": "重置", + "more_filters": "更多筛选", + "detail": "详情", + "start_time": "开始时间", + "end_time": "结束时间", + "job_name": "任务名称", + "job_type": "任务类型", + "execution_status": "执行状态", + "job_id": "任务ID", + "trigger_id": "触发器ID", + "action_type": "操作类型", + "resource_type": "资源类型", + "operator": "操作人", + "select_status": "选择状态", + "select_action_type": "选择操作类型", + "input_resource_type": "输入资源类型", + "input_operator_id": "输入操作人ID", + "input_job_id": "输入任务ID", + "input_trigger_id": "输入触发器ID", + "input_trace_id": "输入 Trace ID", + "operation": "操作", + "job_group": "任务组", + "execution_duration": "执行时长", + "execution_status_detail": "执行状态详情", + "resource_name": "资源名称", + "resource_id": "资源ID", + "operation_time": "操作时间", + "before_change": "变更前数据", + "after_change": "变更后数据", + "input_data": "输入数据", + "output_data": "输出数据", + "request_body": "请求体", + "request_headers": "请求头", + "retry_count": "重试次数", + "max_retry_count": "最大重试次数", + "scheduler_name": "调度器名称", + "instance_name": "调度实例名称", + "fired_time": "实际触发时间", + "scheduled_time": "计划执行时间", + "error_stack_trace": "错误堆栈", + "node_instance_id": "节点实例 ID", + "flow_instance_id": "流程实例 ID", + "instance_data_id": "数据实例 ID", + "node_key": "节点标识", + "node_type": "操作类型", + "node_status": "状态", + "created_by": "创建人", + "updated_by": "更新人", + "updated_at": "更新时间", + "running": "运行中", + "unknown": "未知", + "archive": "归档状态", + "deleted": "已删除", + "normal": "正常", + "job_execution_log_detail": "任务执行日志详情", + "audit_log_detail": "审计日志详情", + "change_diff": "变更对比", + "load_job_execution_log_failed": "加载任务执行日志失败", + "load_audit_log_failed": "加载审计日志失败", "collapse": "折叠", "expand": "展开", "level": "级别", @@ -324,6 +395,7 @@ "record": "记录", "unique": "唯一值", "nullable": "可为空", + "text": "无限文本", "comment": "注释", "table_selection_delete_text": "确定删除此项?", "new_field": "新建字段", @@ -1101,5 +1173,13 @@ "pages.setProductionFailed": "切换部署失败", "pages.preview": "页面预览", "pages.refreshPreview": "刷新", - "pages.openInNewTab": "新窗口打开" + "pages.openInNewTab": "新窗口打开", + "project_log_settings": "日志设置", + "project_log_settings_log_retention_days": "日志保留天数", + "project_log_settings_log_retention_days_desc": "超过该天数的 API 日志将被自动清理", + "project_log_settings_audit_resources": "审计资源", + "project_log_settings_audit_resources_desc": "仅对这些配置定义表的增删改记录审计日志,输入资源表名后回车添加", + "project_log_settings_audit_resources_placeholder": "输入资源表名,如 f_trigger", + "project_log_settings_audit_resources_required": "审计资源不能为空", + "project_log_settings_hint": "审计资源为空时将回退到默认白名单:f_trigger、f_em_flow_definition、f_em_flow_deployment、f_function、f_bucket、f_auth_provider_config" } diff --git a/src/pages/APILog/index.tsx b/src/pages/APILog/index.tsx index 37024bbf..7e5f9efd 100644 --- a/src/pages/APILog/index.tsx +++ b/src/pages/APILog/index.tsx @@ -1,5 +1,5 @@ import React, {useEffect, useRef, useState} from "react"; -import {Button, Col, DatePicker, Descriptions, Drawer, Form, Input, Row, Select, Space, Table, Tag, theme,} from "antd"; +import {Button, DatePicker, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, theme,} from "antd"; import PageContainer from "@/components/common/PageContainer"; import {DownOutlined, SearchOutlined, SettingOutlined, UpOutlined,} from "@ant-design/icons"; import {getApiLogs, getApiLogStat} from "@/services/api-log.ts"; @@ -16,7 +16,7 @@ const LogViewer: React.FC = () => { const { t } = useTranslation(); const { currentProject } = useProject(); const projectId = currentProject?.id || ''; - + const [tableData, setTableData] = useState<{ list: ApiLog[]; total: number }>({ list: [], total: 0 }); const [log, setLog] = useState(null); const [drawerVisible, setDrawerVisible] = useState(false); @@ -150,6 +150,18 @@ const LogViewer: React.FC = () => { dataIndex: "createdAt", width: 140, }, + { + title: t("trace_id"), + dataIndex: "traceId", + width: 140, + ellipsis: true, + render: (traceId: string) => + traceId ? ( + e.stopPropagation()}> + {traceId.slice(0, 8)}… + + ) : null, + }, { title: t("is_success"), dataIndex: "isSuccess", @@ -166,79 +178,50 @@ const LogViewer: React.FC = () => { ]; return ( - + +
+ + + + + + + + + + +
+ {expand && ( +
+ + + + + + +
+ )} + + } + >
- {/* 搜索表单区域 */} -
-
- {expand && ( - - - - - - - - - - - - - )} -
-
- - - -
-
- - - -
-
-
-
- {/* 图表区域 */}
{ {log?.statusCode} {log?.responseTime}ms {log?.clientIp} + {log?.traceId} {log?.createdAt} {log?.isSuccess ? t("yes") : t("no")} {log?.errorMessage} diff --git a/src/pages/AuditLog/components/AuditLogList.tsx b/src/pages/AuditLog/components/AuditLogList.tsx new file mode 100644 index 00000000..6c421819 --- /dev/null +++ b/src/pages/AuditLog/components/AuditLogList.tsx @@ -0,0 +1,326 @@ +import React, {useEffect, useState} from 'react'; +import { + Button, + Descriptions, + Form, + Input, + message, + Modal, + Select, + Space, + Table, + Tag, + Typography, +} from 'antd'; +import {EyeOutlined, ReloadOutlined, SearchOutlined} from '@ant-design/icons'; +import {DownOutlined, UpOutlined} from '@ant-design/icons'; +import {useTranslation} from 'react-i18next'; +import PageContainer from '@/components/common/PageContainer'; +import {getAuditLogs, AuditLogParams} from '@/services/audit-log'; +import type {AuditLog} from '@/types/log'; +import {useProject} from '@/store/appStore'; +import {DiffEditor} from '@monaco-editor/react'; +import {getDarkModeFromStorage} from '@/utils/darkMode'; +import {useTableScrollHeight} from '@/hooks/useTableScrollHeight'; + +const {Text} = Typography; + +// 操作类型 -> 标签颜色 +const actionTag = (action: string) => { + switch (action) { + case 'INSERTED': + return {action}; + case 'UPDATED': + return {action}; + case 'DELETED': + return {action}; + default: + return {action}; + } +}; + +// 后端 oldData/newData 以 JSON 字符串存储,尝试美化输出 +const prettyJson = (raw?: string) => { + if (!raw) return ''; + try { + return JSON.stringify(JSON.parse(raw), null, 2); + } catch { + return raw; + } +}; + +const AuditLogList: React.FC = () => { + const {t} = useTranslation(); + const {currentProject} = useProject(); + const projectId = currentProject?.id || ''; + const [form] = Form.useForm(); + + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [total, setTotal] = useState(0); + const [detailVisible, setDetailVisible] = useState(false); + const [selectedLog, setSelectedLog] = useState(null); + const [showAdvanced, setShowAdvanced] = useState(false); + const {containerRef, scrollY} = useTableScrollHeight(); + + const loadLogs = async (params?: AuditLogParams) => { + if (!projectId) return; + setLoading(true); + try { + const res = await getAuditLogs(projectId, { + page: currentPage, + size: pageSize, + ...params, + }); + setLogs(res.list); + setTotal(res.total); + } catch (error) { + message.error(t('load_audit_log_failed')); + console.error('Failed to load audit logs:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadLogs(); + }, [currentPage, pageSize]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleSearch = (values: any) => { + const params: AuditLogParams = {}; + if (values.action) params.action = values.action; + if (values.resourceType) params.resourceType = values.resourceType; + if (values.userId) params.userId = values.userId; + if (values.traceId) params.traceId = values.traceId; + setCurrentPage(1); + loadLogs({...params, page: 1, size: pageSize}); + }; + + const handleReset = () => { + form.resetFields(); + setCurrentPage(1); + loadLogs({page: 1, size: pageSize}); + }; + + const handlePageChange = (page: number, size?: number) => { + setCurrentPage(page); + if (size) setPageSize(size); + }; + + const handleViewDetail = (log: AuditLog) => { + setSelectedLog(log); + setDetailVisible(true); + }; + + const columns = [ + { + title: t('action_type'), + dataIndex: 'action', + key: 'action', + width: 110, + render: (action: string) => actionTag(action), + }, + { + title: t('resource_type'), + dataIndex: 'resourceType', + key: 'resourceType', + width: 180, + ellipsis: true, + }, + { + title: t('resource_name'), + dataIndex: 'resourceName', + key: 'resourceName', + width: 180, + ellipsis: true, + render: (name?: string) => name || '-', + }, + { + title: t('resource_id'), + dataIndex: 'resourceId', + key: 'resourceId', + width: 140, + ellipsis: true, + }, + { + title: t('operator'), + dataIndex: 'userId', + key: 'userId', + width: 120, + ellipsis: true, + render: (userId?: string) => userId || '-', + }, + { + title: t('operation_time'), + dataIndex: 'createdAt', + key: 'createdAt', + width: 170, + }, + { + title: t('trace_id'), + dataIndex: 'traceId', + key: 'traceId', + width: 140, + ellipsis: true, + render: (traceId?: string) => + traceId ? ( + + {traceId.slice(0, 8)}… + + ) : null, + }, + { + title: t('operation'), + key: 'action_col', + width: 90, + render: (_: any, record: AuditLog) => ( + + ), + }, + ]; + + return ( + +
+ + + + + + + + + + + + + + +
+ {showAdvanced && ( +
+ + + + + + +
+ )} + + } + > +
+
+ + t('pagination_total_text', {start: range[0], end: range[1], total}), + onChange: handlePageChange, + onShowSizeChange: (_current, size) => { + setCurrentPage(1); + setPageSize(size); + }, + }} + /> + + + + setDetailVisible(false)} + footer={[ + , + ]} + width={800} + > + {selectedLog && ( + + + {actionTag(selectedLog.action)} + + + {selectedLog.resourceType} + + + {selectedLog.resourceName || '-'} + + + {selectedLog.resourceId} + + + {selectedLog.userId || '-'} + + + {selectedLog.createdAt} + + + {selectedLog.traceId ? ( + + {selectedLog.traceId} + + ) : '-'} + + {selectedLog.errorMessage && ( + + {selectedLog.errorMessage} + + )} + {(selectedLog.oldData || selectedLog.newData) && ( + +
+ +
+
+ )} +
+ )} +
+ + ); +}; + +export default AuditLogList; diff --git a/src/pages/DataModeling/components/FieldForm.tsx b/src/pages/DataModeling/components/FieldForm.tsx index 502184a4..df36ff7b 100644 --- a/src/pages/DataModeling/components/FieldForm.tsx +++ b/src/pages/DataModeling/components/FieldForm.tsx @@ -49,6 +49,7 @@ const FieldForm = ({ comment: "", defaultValue: { type: "fixed", value: null }, length: 255, + text: false, precision: 20, scale: 2, multiple: false, @@ -100,6 +101,7 @@ const FieldForm = ({ type: "ModelRef", from: value.replace("ModelRef:", ""), multiple: false, + text: false, defaultValue: { type: "fixed", value: null }, }); } else if (value.startsWith("Enum")) { @@ -107,6 +109,7 @@ const FieldForm = ({ ...FieldInitialValues["ENUM"], type: "EnumRef", from: value.replace("Enum:", ""), + text: false, defaultValue: { type: "fixed", value: null }, }) } else { @@ -114,6 +117,7 @@ const FieldForm = ({ ...FieldInitialValues[value.toUpperCase()], type: value, multiple: false, + text: false, defaultValue: { type: "fixed", value: null }, }); } @@ -238,9 +242,14 @@ const FieldForm = ({ {form.getFieldValue("tmpType") === "String" && ( - - - + <> + + + + + + + )} {form.getFieldValue("tmpType") === "Decimal" && ( diff --git a/src/pages/DataModeling/components/RecordForm.tsx b/src/pages/DataModeling/components/RecordForm.tsx index a64029de..27e95221 100644 --- a/src/pages/DataModeling/components/RecordForm.tsx +++ b/src/pages/DataModeling/components/RecordForm.tsx @@ -76,6 +76,9 @@ const RecordForm: React.FC = ({ switch (field.type) { case 'String': + if (field.text) { + return ; + } return ; case 'Text': case 'JSON': diff --git a/src/pages/DataModeling/components/fieldFormConstants.ts b/src/pages/DataModeling/components/fieldFormConstants.ts index b63f83d4..3df1e757 100644 --- a/src/pages/DataModeling/components/fieldFormConstants.ts +++ b/src/pages/DataModeling/components/fieldFormConstants.ts @@ -41,6 +41,7 @@ export const FieldInitialValues: any = { STRING: { type: 'String', length: 255, + text: false, unique: false, nullable: true, identity: false, diff --git a/src/pages/Flow/components/ElementInstancesDrawer.tsx b/src/pages/Flow/components/ElementInstancesDrawer.tsx new file mode 100644 index 00000000..d49380f3 --- /dev/null +++ b/src/pages/Flow/components/ElementInstancesDrawer.tsx @@ -0,0 +1,407 @@ +import React, {useState} from 'react'; +import {Button, Drawer, message, Modal, Space, Spin, Steps, Tag, theme} from 'antd'; +import { + CheckCircleOutlined, + ClockCircleOutlined, + CloseCircleOutlined, + EyeOutlined, + MinusCircleOutlined, + NodeIndexOutlined, + PlayCircleOutlined, + RollbackOutlined, + SendOutlined, +} from '@ant-design/icons'; +import Editor from '@monaco-editor/react'; +import {getDarkModeFromStorage} from '@/utils/darkMode'; +import dayjs from 'dayjs'; +import { + commitTask, + CommitTaskRequest, + FlowInstance, + FlowInstanceStatus, + getInstanceData, + isSuccess, + NodeInstance, + NodeInstanceStatus, + rollbackTask, + RollbackTaskRequest, +} from '@/services/flow'; +import {FlowElementType} from '@/pages/FlowDesign/types/flow.ts'; + +interface ElementInstancesDrawerProps { + visible: boolean; + loading: boolean; + currentFlowInstance: FlowInstance | null; + elementInstances: NodeInstance[]; + projectId: string; + onClose: () => void; + onCommitted?: () => void; +} + +const ElementInstancesDrawer: React.FC = ({ + visible, + loading, + currentFlowInstance, + elementInstances, + projectId, + onClose, + onCommitted, + }) => { + const {token} = theme.useToken(); + const [instanceDataModalVisible, setInstanceDataModalVisible] = useState(false); + const [instanceDataLoading, setInstanceDataLoading] = useState(false); + const [instanceDataText, setInstanceDataText] = useState('{}'); + const [commitLoading, setCommitLoading] = useState(false); + const [rollbackLoading, setRollbackLoading] = useState(false); + + // 获取元素状态标签 + const getStatusTag = (status: number) => { + const statusMap = { + [NodeInstanceStatus.DEFAULT]: {text: '未处理', color: token.colorTextSecondary}, + [NodeInstanceStatus.COMPLETED]: {text: '已完成', color: token.colorSuccess}, + [NodeInstanceStatus.ACTIVE]: {text: '处理中', color: token.colorInfo}, + [NodeInstanceStatus.FAILED]: {text: '失败', color: token.colorError}, + [NodeInstanceStatus.DISABLED]: {text: '撤销', color: token.colorTextTertiary}, + }; + const statusInfo = statusMap[status as keyof typeof statusMap] || { + text: '未知', + color: token.colorTextSecondary + }; + return {statusInfo.text}; + }; + + // 获取步骤状态和图标 + const getStepStatusAndIcon = (status: number) => { + switch (status) { + case NodeInstanceStatus.COMPLETED: + return { + status: 'finish' as const, + icon: + }; + case NodeInstanceStatus.FAILED: + return { + status: 'error' as const, + icon: + }; + case NodeInstanceStatus.DISABLED: + return { + status: 'error' as const, + icon: + }; + case NodeInstanceStatus.ACTIVE: + return { + status: 'process' as const, + icon: + }; + default: + return { + status: 'wait' as const, + icon: + }; + } + }; + + // 查看元素实例数据 + const handleViewInstanceData = async (element: NodeInstance) => { + if (!currentFlowInstance) { + message.warning('流程实例信息缺失'); + return; + } + if (!element.instanceDataId) { + message.warning('当前元素无实例数据ID'); + return; + } + setInstanceDataModalVisible(true); + setInstanceDataLoading(true); + try { + const {errCode, errMsg, variables} = await getInstanceData( + projectId, + currentFlowInstance.flowInstanceId, + element.instanceDataId + ); + if (!isSuccess(errCode)) { + message.warning(errMsg); + setInstanceDataText('{}'); + return; + } + setInstanceDataText(JSON.stringify(variables ?? {}, null, 2)); + } catch (e) { + console.error('获取元素实例数据失败:', e); + message.error('获取元素实例数据失败'); + setInstanceDataText('{}'); + } finally { + setInstanceDataLoading(false); + } + }; + + // 提交任务 + const handleCommitTask = async (task: NodeInstance) => { + let variablesText = '{}'; + const isDark = getDarkModeFromStorage(); + + Modal.confirm({ + width: 500, + title: '提交任务', + content: ( +
+
请输入任务变量(JSON)
+
+ { + variablesText = (value || '').toString(); + }} + /> +
+
+ ), + okText: '提交', + cancelText: '取消', + onOk: async () => { + let variables: Record = {}; + const text = (variablesText || '').trim(); + if (text.length === 0) { + variables = {}; + } else { + try { + variables = JSON.parse(text); + } catch (e) { + message.error('变量需为合法的 JSON'); + throw e as Error; + } + } + + if (!currentFlowInstance) { + message.error('流程实例信息缺失'); + return; + } + + setCommitLoading(true); + try { + const commitData: CommitTaskRequest = { + flowInstanceId: currentFlowInstance.flowInstanceId, + taskInstanceId: task.nodeInstanceId, + variables: variables, + }; + + const {errCode, errMsg} = await commitTask(projectId, currentFlowInstance.flowInstanceId, commitData); + if (!isSuccess(errCode)) { + message.warning(errMsg); + return; + } + message.success('任务提交成功'); + if (onCommitted) { + onCommitted(); + } + } catch (error) { + console.error('提交任务失败:', error); + message.error('提交任务失败'); + } finally { + setCommitLoading(false); + } + }, + }); + }; + + // 退回任务 + const handleRollbackTask = async (task: NodeInstance) => { + if (!currentFlowInstance) { + message.error('流程实例信息缺失'); + return; + } + + Modal.confirm({ + width: 380, + title: '退回任务', + content: ( +
+ 将从【{task.name || task.key}】开始退回,退回到上一个用户任务节点(若已是第一个用户任务节点则退回失败) +
+ ), + okText: '退回', + cancelText: '取消', + onOk: async () => { + setRollbackLoading(true); + try { + const data: RollbackTaskRequest = { + flowInstanceId: currentFlowInstance.flowInstanceId, + taskInstanceId: task.nodeInstanceId, + }; + const {errCode, errMsg} = await rollbackTask(projectId, currentFlowInstance.flowInstanceId, data); + if (!isSuccess(errCode)) { + message.warning(errMsg); + return; + } + message.success('任务已退回'); + if (onCommitted) onCommitted(); + } catch (e) { + console.error('退回任务失败:', e); + message.error('退回任务失败'); + } finally { + setRollbackLoading(false); + } + } + }); + }; + + // 渲染操作按钮 + const renderActionButtons = (element: NodeInstance) => { + const isUserTask = element.type === FlowElementType.USER_TASK; + const canCommit = isUserTask + && currentFlowInstance?.status === FlowInstanceStatus.RUNNING + && element.status === NodeInstanceStatus.ACTIVE; + if (!canCommit && !element.instanceDataId) { + return null; + } + return ( + + {canCommit && ( + + )} + {canCommit && ( + + )} + + + ); + }; + + return ( + <> + + + 历史元素列表 + {currentFlowInstance && ( +
+ {currentFlowInstance.flowName} ({currentFlowInstance.flowInstanceId}) +
+ )} + + } + width={600} + open={visible} + onClose={onClose} + extra={ + + } + > + {loading ? ( +
+
加载中...
+
+ ) : elementInstances.length === 0 ? ( +
+ 暂无元素实例 +
+ ) : ( + { + const {status: stepStatus, icon} = getStepStatusAndIcon(element.status); + return { + title: ( +
+
+ {element.name || element.key} + {getStatusTag(element.status)} +
+ {renderActionButtons(element)} +
+ ), + description: ( +
+
+ 元素ID: {element.nodeInstanceId} +
+
+ 节点键: {element.key} +
+ {element.name && ( +
+ 节点名称: {element.name} +
+ )} + {element.createdAt && ( +
+ 创建时间: {dayjs(element.createdAt).format('YYYY-MM-DD HH:mm:ss')} +
+ )} +
+ ), + status: stepStatus, + icon: icon, + }; + })} + /> + )} +
+ setInstanceDataModalVisible(false)} + footer={[ + + ]} + > + +
+ +
+
+
+ + ); +}; + +export default ElementInstancesDrawer; diff --git a/src/pages/Flow/components/FlowInstanceList.tsx b/src/pages/Flow/components/FlowInstanceList.tsx index 743c5c8d..f872fd92 100644 --- a/src/pages/Flow/components/FlowInstanceList.tsx +++ b/src/pages/Flow/components/FlowInstanceList.tsx @@ -1,8 +1,17 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'; import {Button, Input, message, Popconfirm, Select, Space, Table, Tag, Tooltip} from 'antd'; -import {EyeOutlined, HistoryOutlined, SearchOutlined, StopOutlined} from '@ant-design/icons'; +import { + EyeOutlined, + HistoryOutlined, + NodeIndexOutlined, + PlusOutlined, + SearchOutlined, + StopOutlined +} from '@ant-design/icons'; import PageContainer from '@/components/common/PageContainer'; import UserTasksDrawer from './UserTasksDrawer.tsx'; +import ElementInstancesDrawer from './ElementInstancesDrawer.tsx'; +import StartFlowModal from './StartFlowModal.tsx'; import {useNavigate} from 'react-router-dom'; import { FlowInstance, @@ -10,6 +19,7 @@ import { getFlowInstance, getFlowInstanceList, getFlowUserTasks, + getElementInstances, NodeInstance, terminateFlowInstance } from '@/services/flow'; @@ -31,7 +41,7 @@ const FlowInstanceList: React.FC = () => { size: 20 }); const tableContainerRef = useRef(null); - const [tableScrollY, setTableScrollY] = useState(0); + const [tableScrollY, setTableScrollY] = useState(300); // 用户任务相关状态 const [historyDrawerVisible, setHistoryDrawerVisible] = useState(false); @@ -39,6 +49,14 @@ const FlowInstanceList: React.FC = () => { const [currentFlowInstance, setCurrentFlowInstance] = useState(null); const [userTasks, setUserTasks] = useState([]); + // 历史元素相关状态 + const [elementsDrawerVisible, setElementsDrawerVisible] = useState(false); + const [elementsLoading, setElementsLoading] = useState(false); + const [elementInstances, setElementInstances] = useState([]); + + // 发起流程相关状态 + const [startFlowModalVisible, setStartFlowModalVisible] = useState(false); + // 获取流程实例列表 const fetchFlowInstanceList = useCallback(async () => { setLoading(true); @@ -58,25 +76,61 @@ const FlowInstanceList: React.FC = () => { fetchFlowInstanceList(); }, [fetchFlowInstanceList]); - useEffect(() => { - const container = tableContainerRef.current; - if (!container) return; + // 计算表格表体可用高度:用 table 顶部到 wrapper 底部的距离作为 table 的可用 + // 高度(flex:1 使 table 填满到 wrapper 底部),再减去表头、分页等占位。 + const updateTableHeight = useCallback(() => { + const wrapper = tableContainerRef.current; + if (!wrapper) return; - const updateHeight = () => { - setTableScrollY(container.clientHeight - 80); - }; + const wrapperRect = wrapper.getBoundingClientRect(); + const table = wrapper.querySelector('.ant-table'); + if (!table) return; - updateHeight(); + const tableRect = table.getBoundingClientRect(); + // table 顶部到 wrapper 底部的高度 + const tableAvailable = wrapperRect.bottom - tableRect.top; - const ro = new ResizeObserver(() => updateHeight()); - ro.observe(container); + // 表头高度:滚动模式下为 .ant-table-header,否则取 .ant-table-thead + const header = + wrapper.querySelector('.ant-table-header') || + wrapper.querySelector('.ant-table-thead'); + const headerHeight = header ? header.getBoundingClientRect().height : 39; + + // 分页高度(含上下 margin,否则 .ant-table-pagination 的外边距会漏算导致表体过高、 + // 分页器被外层 overflow:hidden 裁掉底边) + const pagination = wrapper.querySelector('.ant-table-pagination'); + let paginationHeight = 0; + if (pagination) { + const pr = pagination.getBoundingClientRect(); + const cs = getComputedStyle(pagination); + paginationHeight = + pr.height + parseFloat(cs.marginTop || '0') + parseFloat(cs.marginBottom || '0'); + } + + // 表格内部边距/边框等冗余间距 + const extraSpacing = 8; + + const available = tableAvailable - headerHeight - paginationHeight - extraSpacing; + setTableScrollY(Math.max(available, 150)); + }, []); - window.addEventListener('resize', updateHeight); + useLayoutEffect(() => { + updateTableHeight(); + const ro = new ResizeObserver(updateTableHeight); + if (tableContainerRef.current) { + ro.observe(tableContainerRef.current); + } + window.addEventListener('resize', updateTableHeight); return () => { ro.disconnect(); - window.removeEventListener('resize', updateHeight); + window.removeEventListener('resize', updateTableHeight); }; - }, []); + }, [updateTableHeight]); + + // 数据加载完成后、表头/分页高度可能变化,重新计算 + useEffect(() => { + updateTableHeight(); + }, [flowInstanceList, total, loading, updateTableHeight]); // 终止流程实例 const handleTerminateFlowInstance = async (flowInstanceId: string) => { @@ -114,6 +168,22 @@ const FlowInstanceList: React.FC = () => { } }; + // 获取历史元素列表 + const handleShowElements = async (record: FlowInstance) => { + setCurrentFlowInstance(record); + setElementsDrawerVisible(true); + setElementsLoading(true); + try { + const instances = await getElementInstances(projectId, record.flowInstanceId); + setElementInstances(instances || []); + } catch (error) { + console.error('获取历史元素列表失败:', error); + message.error('获取历史元素列表失败'); + } finally { + setElementsLoading(false); + } + }; + // 获取状态标签 const getStatusTag = (status: number) => { const statusMap = { @@ -183,12 +253,6 @@ const FlowInstanceList: React.FC = () => { key: 'initiator', width: 120, }, - { - title: '创建人', - dataIndex: 'createdBy', - key: 'createdBy', - width: 120, - }, { title: '创建时间', dataIndex: 'createdAt', @@ -228,6 +292,14 @@ const FlowInstanceList: React.FC = () => { onClick={() => handleShowHistory(record)} /> + + ]} > +
t("pagination_total_text", { start: range[0], @@ -313,6 +398,7 @@ const FlowInstanceList: React.FC = () => { } }} /> + {/* 用户任务 Drawer */} { fetchFlowInstanceList(); }} /> + {/* 历史元素 Drawer */} + setElementsDrawerVisible(false)} + onCommitted={async () => { + if (!currentFlowInstance) return; + setElementsLoading(true); + try { + const instances = await getElementInstances(projectId, currentFlowInstance.flowInstanceId); + setElementInstances(instances || []); + } catch (error) { + console.error('刷新历史元素列表失败:', error); + } finally { + setElementsLoading(false); + } + }} + /> + {/* 发起流程 Modal */} + setStartFlowModalVisible(false)} + onStarted={() => fetchFlowInstanceList()} + /> ); }; diff --git a/src/pages/Flow/components/StartFlowModal.tsx b/src/pages/Flow/components/StartFlowModal.tsx new file mode 100644 index 00000000..a374be69 --- /dev/null +++ b/src/pages/Flow/components/StartFlowModal.tsx @@ -0,0 +1,135 @@ +import React, {useEffect, useState} from 'react'; +import {Form, Input, message, Modal, Select, Spin} from 'antd'; +import Editor from '@monaco-editor/react'; +import {getDarkModeFromStorage} from '@/utils/darkMode'; +import {FlowModule, getFlowList, isSuccess, startProcess} from '@/services/flow'; + +interface StartFlowModalProps { + visible: boolean; + projectId: string; + onClose: () => void; + onStarted?: (flowInstanceId: string) => void; +} + +const StartFlowModal: React.FC = ({visible, projectId, onClose, onStarted}) => { + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [flowList, setFlowList] = useState([]); + const [flowModuleId, setFlowModuleId] = useState(); + const [initiator, setInitiator] = useState(''); + const [variablesText, setVariablesText] = useState('{}'); + const [form] = Form.useForm(); + + useEffect(() => { + if (!visible || !projectId) return; + setLoading(true); + getFlowList(projectId, {page: 1, size: 100}) + .then(res => setFlowList(res.list || [])) + .catch(e => { + console.error('获取流程列表失败:', e); + message.error('获取流程列表失败'); + }) + .finally(() => setLoading(false)); + }, [visible, projectId]); + + const handleOk = async () => { + try { + await form.validateFields(); + } catch { + return; + } + let variables: Record = {}; + const text = (variablesText || '').trim(); + if (text.length > 0) { + try { + variables = JSON.parse(text); + } catch { + message.error('变量需为合法的 JSON'); + return; + } + } + setSubmitting(true); + try { + const {errCode, errMsg, flowInstanceId} = await startProcess(projectId, { + flowModuleId: flowModuleId, + initiator: initiator || undefined, + variables: variables, + }); + if (!isSuccess(errCode)) { + message.warning(errMsg); + return; + } + message.success('流程发起成功'); + if (onStarted) onStarted(flowInstanceId); + handleReset(); + onClose(); + } catch (e) { + console.error('发起流程失败:', e); + message.error('发起流程失败'); + } finally { + setSubmitting(false); + } + }; + + const handleReset = () => { + form.resetFields(); + setFlowModuleId(undefined); + setInitiator(''); + setVariablesText('{}'); + }; + + const isDark = getDarkModeFromStorage(); + + return ( + + +
+ + setInitiator(e.target.value)} + allowClear/> + + +
+ setVariablesText((value || '').toString())} + /> +
+
+ +
+
+ ); +}; + +export default StartFlowModal; diff --git a/src/pages/FlowDetail/components/NodeDetailDrawer.tsx b/src/pages/FlowDetail/components/NodeDetailDrawer.tsx new file mode 100644 index 00000000..eb28d591 --- /dev/null +++ b/src/pages/FlowDetail/components/NodeDetailDrawer.tsx @@ -0,0 +1,298 @@ +import React, {useEffect, useState} from 'react'; +import {Button, Descriptions, Drawer, message, Modal, Space, Spin, Tag, theme} from 'antd'; +import {SendOutlined, RollbackOutlined} from '@ant-design/icons'; +import {Node} from '@xyflow/react'; +import Editor from '@monaco-editor/react'; +import {getDarkModeFromStorage} from '@/utils/darkMode'; +import {FlowElementType} from '@/pages/FlowDesign/types/flow.ts'; +import { + commitTask, + CommitTaskRequest, + FlowInstanceStatus, + getInstanceData, + isSuccess, + NodeInstanceStatus, + rollbackTask, + RollbackTaskRequest +} from '@/services/flow'; + +const TYPE_LABEL: Record = { + [FlowElementType.START_EVENT]: '开始事件', + [FlowElementType.END_EVENT]: '结束事件', + [FlowElementType.USER_TASK]: '用户任务', + [FlowElementType.SERVICE_TASK]: '服务任务', + [FlowElementType.EXCLUSIVE_GATEWAY]: '排他网关', + [FlowElementType.PARALLEL_GATEWAY]: '并行网关', + [FlowElementType.INCLUSIVE_GATEWAY]: '包容网关', + [FlowElementType.CALL_ACTIVITY]: '调用活动', + [FlowElementType.SEQUENCE_FLOW]: '顺序流', +}; + +interface NodeDetailDrawerProps { + node: Node | null; + projectId: string; + flowInstanceId: string; + flowStatus: number | undefined; + onClose: () => void; + onCommitted?: () => void; +} + +const NodeDetailDrawer: React.FC = ({ + node, + projectId, + flowInstanceId, + flowStatus, + onClose, + onCommitted + }) => { + const {token} = theme.useToken(); + const [commitLoading, setCommitLoading] = useState(false); + const [rollbackLoading, setRollbackLoading] = useState(false); + const [variablesLoading, setVariablesLoading] = useState(false); + const [variablesText, setVariablesText] = useState('{}'); + + const d: any = node?.data || {}; + const instanceDataId: string | undefined = d.instanceDataId; + + // 选中节点变化时加载节点变量 + useEffect(() => { + if (!instanceDataId || !projectId || !flowInstanceId) { + setVariablesText('{}'); + return; + } + let cancelled = false; + setVariablesLoading(true); + getInstanceData(projectId, flowInstanceId, instanceDataId) + .then(({errCode, errMsg, variables}) => { + if (cancelled) return; + if (!isSuccess(errCode)) { + message.warning(errMsg); + setVariablesText('{}'); + return; + } + setVariablesText(JSON.stringify(variables ?? {}, null, 2)); + }) + .catch(e => { + if (cancelled) return; + console.error('获取节点变量失败:', e); + message.error('获取节点变量失败'); + setVariablesText('{}'); + }) + .finally(() => { + if (!cancelled) setVariablesLoading(false); + }); + return () => { + cancelled = true; + }; + }, [node?.id, instanceDataId, projectId, flowInstanceId]); + + if (!node) return null; + const name = (d.name && String(d.name).trim()) ? d.name : (d.key || '未命名'); + const typeLabel = TYPE_LABEL[d.flowElementType] || '未知'; + const isUserTask = d.flowElementType === FlowElementType.USER_TASK; + const canCommit = isUserTask && flowStatus === FlowInstanceStatus.RUNNING && d.status === NodeInstanceStatus.ACTIVE; + + const statusInfo = (() => { + switch (d.status) { + case NodeInstanceStatus.COMPLETED: + return {text: '已完成', color: token.colorSuccess}; + case NodeInstanceStatus.ACTIVE: + return {text: '处理中', color: token.colorInfo}; + case NodeInstanceStatus.FAILED: + return {text: '失败', color: token.colorError}; + case NodeInstanceStatus.DISABLED: + return {text: '撤销', color: token.colorTextTertiary}; + default: + return {text: '未处理', color: token.colorTextSecondary}; + } + })(); + + const excludeKeys = new Set(['name', 'key', 'flowElementType', 'status', 'nodeInstanceId', 'instanceDataId', '__selected']); + const properties: Record = {}; + Object.keys(d).forEach(k => { + if (!excludeKeys.has(k)) properties[k] = d[k]; + }); + const hasProperties = Object.keys(properties).length > 0; + const borderStyle = '1px solid ' + token.colorBorderSecondary; + const isDark = getDarkModeFromStorage(); + + // 提交任务 + const handleCommitTask = async () => { + let variablesInput = '{}'; + Modal.confirm({ + width: 500, + title: '提交任务', + content: ( +
+
请输入任务变量(JSON)
+
+ { + variablesInput = (value || '').toString(); + }} + /> +
+
+ ), + okText: '提交', + cancelText: '取消', + onOk: async () => { + let variables: Record = {}; + const text = (variablesInput || '').trim(); + if (text.length === 0) { + variables = {}; + } else { + try { + variables = JSON.parse(text); + } catch (e) { + message.error('变量需为合法的 JSON'); + throw e as Error; + } + } + setCommitLoading(true); + try { + const commitData: CommitTaskRequest = { + flowInstanceId: flowInstanceId, + taskInstanceId: d.nodeInstanceId, + variables: variables, + }; + const {errCode, errMsg} = await commitTask(projectId, flowInstanceId, commitData); + if (!isSuccess(errCode)) { + message.warning(errMsg); + return; + } + message.success('任务提交成功'); + if (onCommitted) onCommitted(); + } catch (error) { + console.error('提交任务失败:', error); + message.error('提交任务失败'); + } finally { + setCommitLoading(false); + } + }, + }); + }; + + // 退回任务 + const handleRollbackTask = async () => { + Modal.confirm({ + width: 380, + title: '退回任务', + content: ( +
+ 将从【{name}】开始退回,退回到上一个用户任务节点(若已是第一个用户任务节点则退回失败) +
+ ), + okText: '退回', + cancelText: '取消', + onOk: async () => { + setRollbackLoading(true); + try { + const data: RollbackTaskRequest = { + flowInstanceId: flowInstanceId, + taskInstanceId: d.nodeInstanceId, + }; + const {errCode, errMsg} = await rollbackTask(projectId, flowInstanceId, data); + if (!isSuccess(errCode)) { + message.warning(errMsg); + return; + } + message.success('任务已退回'); + if (onCommitted) onCommitted(); + } catch (e) { + console.error('退回任务失败:', e); + message.error('退回任务失败'); + } finally { + setRollbackLoading(false); + } + }, + }); + }; + + return ( + + + + + ) : undefined + } + > + + {name} + {d.key ?? '-'} + {typeLabel} + + {statusInfo.text} + + {d.nodeInstanceId ?? '-'} + {d.instanceDataId ?? '-'} + + + {/* 节点变量 */} +
节点变量
+ +
+ +
+
+ + {hasProperties && ( + <> +
节点属性
+
+ +
+ + )} +
+ ); +}; + +export default NodeDetailDrawer; diff --git a/src/pages/FlowDetail/components/NodeSummaryToolbar.tsx b/src/pages/FlowDetail/components/NodeSummaryToolbar.tsx new file mode 100644 index 00000000..0580a428 --- /dev/null +++ b/src/pages/FlowDetail/components/NodeSummaryToolbar.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import {Tag, Space, theme, Typography, Divider} from 'antd'; +import {Node} from '@xyflow/react'; +import {FlowElementType} from '@/pages/FlowDesign/types/flow.ts'; +import {FlowInstance, FlowInstanceStatus, NodeInstanceStatus} from '@/services/flow'; +import {InfoCircleOutlined} from '@ant-design/icons'; +import dayjs from 'dayjs'; + +const TYPE_LABEL: Record = { + [FlowElementType.START_EVENT]: '开始事件', + [FlowElementType.END_EVENT]: '结束事件', + [FlowElementType.USER_TASK]: '用户任务', + [FlowElementType.SERVICE_TASK]: '服务任务', + [FlowElementType.EXCLUSIVE_GATEWAY]: '排他网关', + [FlowElementType.PARALLEL_GATEWAY]: '并行网关', + [FlowElementType.INCLUSIVE_GATEWAY]: '包容网关', + [FlowElementType.CALL_ACTIVITY]: '调用活动', + [FlowElementType.SEQUENCE_FLOW]: '顺序流', +}; + +const STATUS_TAG: Record = { + [NodeInstanceStatus.COMPLETED]: {text: '已完成', color: 'colorSuccess'}, + [NodeInstanceStatus.ACTIVE]: {text: '处理中', color: 'colorInfo'}, + [NodeInstanceStatus.FAILED]: {text: '失败', color: 'colorError'}, + [NodeInstanceStatus.DISABLED]: {text: '撤销', color: 'colorTextTertiary'}, + [NodeInstanceStatus.DEFAULT]: {text: '未处理', color: 'colorTextSecondary'}, +}; + +const FLOW_STATUS_TAG: Record = { + [FlowInstanceStatus.ACTIVE]: {text: '已完成', color: 'colorSuccess'}, + [FlowInstanceStatus.RUNNING]: {text: '运行中', color: 'colorInfo'}, + [FlowInstanceStatus.TERMINATED]: {text: '已终止', color: 'colorWarning'}, + [FlowInstanceStatus.END]: {text: '已结束', color: 'colorPrimary'}, + [FlowInstanceStatus.DEFAULT]: {text: '初始化', color: 'colorError'}, +}; + +interface NodeSummaryToolbarProps { + node: Node | null; + flowInstance: FlowInstance | null; +} + +const NodeSummaryToolbar: React.FC = ({node, flowInstance}) => { + const {token} = theme.useToken(); + const {Text} = Typography; + const border = '1px solid ' + token.colorBorderSecondary; + const bg = token.colorFillQuaternary; + const itemStyle: React.CSSProperties = {display: 'flex', alignItems: 'center', gap: 6}; + const labelStyle: React.CSSProperties = {color: token.colorTextTertiary, fontSize: 12}; + const valueStyle: React.CSSProperties = {fontSize: 13}; + + // 第一列:流程信息 + const flowName = flowInstance?.flowName || '-'; + const flowStatusInfo = FLOW_STATUS_TAG[flowInstance?.status ?? -1] || {text: '未知', color: 'colorTextSecondary'}; + const flowStatusColor = (token as any)[flowStatusInfo.color] || token.colorTextSecondary; + const flowStatus = flowInstance ? + {flowStatusInfo.text} : '-'; + const initiator = flowInstance?.initiator || '-'; + const created = flowInstance?.createdAt ? dayjs(flowInstance.createdAt).format('YYYY-MM-DD HH:mm') : '-'; + + // 第二列:节点摘要 + const nodeSummary = (() => { + if (!node) { + return ( + + + 点击节点查看详情 + + ); + } + const d: any = node.data || {}; + const name = (d.name && String(d.name).trim()) ? d.name : (d.key || '未命名'); + const typeLabel = TYPE_LABEL[d.flowElementType] || '未知'; + const statusInfo = STATUS_TAG[d.status] || {text: '未处理', color: 'colorTextSecondary'}; + const statusColor = (token as any)[statusInfo.color] || token.colorTextSecondary; + return ( + +
+ 名称 + {name} +
+
+ 节点键 + {d.key ?? '-'} +
+
+ 类型 + {typeLabel} +
+
+ 状态 + {statusInfo.text} +
+
+ 实例ID + {d.nodeInstanceId ?? '-'} +
+
+ ); + })(); + + return ( +
+ {/* 第一列:流程信息 */} + +
+ 流程 + {flowName} +
+
+ 状态 + {flowStatus} +
+
+ 发起人 + {initiator} +
+
+ 创建时间 + {created} +
+
+ + {/* 第二列:节点摘要 */} +
+ {nodeSummary} +
+
+ ); +}; + +export default NodeSummaryToolbar; diff --git a/src/pages/FlowDetail/hooks/useElementInstanceMerger.ts b/src/pages/FlowDetail/hooks/useElementInstanceMerger.ts index fb126ab5..ab4e342d 100644 --- a/src/pages/FlowDetail/hooks/useElementInstanceMerger.ts +++ b/src/pages/FlowDetail/hooks/useElementInstanceMerger.ts @@ -1,39 +1,49 @@ import {useCallback} from 'react'; -import {Node} from '@xyflow/react'; -import {NodeInstance} from '@/services/flow.ts'; +import {Edge, Node} from '@xyflow/react'; +import {ElementInstance} from '@/services/flow.ts'; +import {FlowElementType} from '@/pages/FlowDesign/types/flow.ts'; interface UseElementInstanceMergerProps { setNodes: (updater: (nodes: Node[]) => Node[]) => void; + setEdges: (updater: (edges: Edge[]) => Edge[]) => void; } -export const useElementInstanceMerger = ({ setNodes }: UseElementInstanceMergerProps) => { - const mergeElementInstances = useCallback((instances: NodeInstance[]) => { +export const useElementInstanceMerger = ({setNodes, setEdges}: UseElementInstanceMergerProps) => { + const mergeElementInstances = useCallback((instances: ElementInstance[]) => { if (!instances || instances.length === 0) return; - const byKey = new Map(); - const byNodeKey = new Map(); + const nodeInstances = instances.filter(ins => ins.type !== FlowElementType.SEQUENCE_FLOW); + const edgeInstances = instances.filter(ins => ins.type === FlowElementType.SEQUENCE_FLOW); - instances.forEach(ins => { - if ((ins as any).key) { - byKey.set((ins as any).key as string, ins); - } - if ((ins as any).nodeKey) { - byNodeKey.set((ins as any).nodeKey as string, ins); - } + const byKey = new Map(); + const byNodeKey = new Map(); + nodeInstances.forEach(ins => { + if (ins.key) byKey.set(ins.key, ins); + if ((ins as any).nodeKey) byNodeKey.set((ins as any).nodeKey, ins); }); - setNodes(prev => prev.map(n => { const match = byKey.get(n.id) || byNodeKey.get(n.id); if (!match) return n; - return { - ...n, + return {...n, data: { ...n.data, status: match.status, + nodeInstanceId: match.nodeInstanceId, + instanceDataId: match.instanceDataId } } as Node; })); - }, [setNodes]); + + const edgeByKey = new Map(); + edgeInstances.forEach(ins => { + if (ins.key) edgeByKey.set(ins.key, ins); + }); + setEdges(prev => prev.map(e => { + const match = edgeByKey.get(e.id); + if (!match) return e; + return {...e, data: {...(e.data || {}), status: match.status}} as Edge; + })); + }, [setNodes, setEdges]); return { mergeElementInstances }; }; diff --git a/src/pages/FlowDetail/index.tsx b/src/pages/FlowDetail/index.tsx index aed15b33..2a6027e9 100644 --- a/src/pages/FlowDetail/index.tsx +++ b/src/pages/FlowDetail/index.tsx @@ -1,9 +1,18 @@ import React, {useCallback, useEffect, useState} from 'react'; import PageContainer from '@/components/common/PageContainer.tsx'; import {useNavigate, useParams} from 'react-router-dom'; -import {Button, message, Space, theme} from 'antd'; -import {ArrowLeftOutlined} from '@ant-design/icons'; -import {FlowModuleDetail, getElementInstances, getFlowInstance, getFlowModule, NodeInstance,} from '@/services/flow.ts'; +import {Button, message, Popconfirm, Space, theme} from 'antd'; +import {ArrowLeftOutlined, StopOutlined} from '@ant-design/icons'; +import { + FlowModuleDetail, + FlowInstanceStatus, + getElementInstances, + getFlowInstance, + getFlowModule, + FlowInstance, + NodeInstance, + terminateFlowInstance, +} from '@/services/flow.ts'; import { Background, Edge, @@ -16,6 +25,8 @@ import { } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import FlowNode from './components/FlowNode'; +import NodeDetailDrawer from './components/NodeDetailDrawer'; +import NodeSummaryToolbar from './components/NodeSummaryToolbar'; import {useFlowParser} from './hooks/useFlowParser'; import {useElementInstanceMerger} from './hooks/useElementInstanceMerger'; import {useProject} from "@/store/appStore"; @@ -37,26 +48,40 @@ const FlowDetail: React.FC = () => { const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [, setReactFlowInstance] = useState | null>(null); const [elementInstances, setElementInstances] = useState(null); - const [selectedNode, setSelectedNode] = useState(null); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [flowInstance, setFlowInstance] = useState(null); - useEffect(() => { - if (selectedNode) { - const { nodeInstanceId } = selectedNode.data; - if (nodeInstanceId) { - message.info(`节点实例ID: ${nodeInstanceId}`); - } + // 节点详情通过 NodeDetailDrawer 展示,点击节点时打开 + const selectedNode = selectedNodeId ? nodes.find(n => n.id === selectedNodeId) ?? null : null; + + const [terminating, setTerminating] = useState(false); + + // 终止流程实例 + const handleTerminate = async () => { + if (!flowInstanceId) return; + setTerminating(true); + try { + await terminateFlowInstance(projectId, flowInstanceId); + message.success('流程实例终止成功'); + loadData(); + } catch (error) { + console.error('终止流程实例失败:', error); + message.error('终止流程实例失败'); + } finally { + setTerminating(false); } - }, [selectedNode]); + }; - // 使用提取的 hooks + // 浣跨敤鎻愬彇鐨?hooks const { parseFlowModel } = useFlowParser({ setNodes, setEdges }); - const { mergeElementInstances } = useElementInstanceMerger({ setNodes }); + const {mergeElementInstances} = useElementInstanceMerger({setNodes, setEdges}); const loadData = useCallback(async () => { - if (!flowInstanceId) return; + if (!flowInstanceId || !projectId) return; setLoading(true); try { const instance = await getFlowInstance(projectId, flowInstanceId); + setFlowInstance(instance); const moduleDetail: FlowModuleDetail = await getFlowModule(projectId, instance.flowModuleId, instance.flowDeployId); setTitle(moduleDetail.flowName + ` (${instance.flowInstanceId})`); if (moduleDetail?.flowModel) { @@ -65,8 +90,8 @@ const FlowDetail: React.FC = () => { const instances = await getElementInstances(projectId, flowInstanceId); setElementInstances(instances || []); } catch (e) { - console.error('加载流程实例详情失败', e); - message.error('加载流程实例详情失败'); + console.error('鍔犺浇娴佺▼瀹炰緥璇︽儏澶辫触', e); + message.error('鍔犺浇娴佺▼瀹炰緥璇︽儏澶辫触'); } finally { setLoading(false); } @@ -76,7 +101,7 @@ const FlowDetail: React.FC = () => { loadData(); }, [loadData]); - // 在定义图渲染完成后再叠加状态,避免竞态 + // 鍦ㄥ畾涔夊浘娓叉煋瀹屾垚鍚庡啀鍙犲姞鐘舵€侊紝閬垮厤绔炴€? useEffect(() => { if (elementInstances && nodes.length > 0) { mergeElementInstances(elementInstances); @@ -95,7 +120,21 @@ const FlowDetail: React.FC = () => { {title} } + extra={ + flowInstance?.status === FlowInstanceStatus.RUNNING ? ( + + + + ) : undefined + } > + { onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onPaneClick={() => { - setSelectedNode(null); - // 同步清除节点的选中态 + setSelectedNodeId(null); + // 鍚屾娓呴櫎鑺傜偣鐨勯€変腑鎬? setNodes((prev) => prev.map((n) => ({ ...n, data: { ...n.data, __selected: false } })) as any); }} onNodeClick={(_, node) => { - setSelectedNode(node); - // 同步更新节点的选中态(仅单选) + setSelectedNodeId(node.id); + // 鍚屾鏇存柊鑺傜偣鐨勯€変腑鎬侊紙浠呭崟閫夛級 setNodes((prev) => prev.map((n) => ({ ...n, data: { ...n.data, __selected: n.id === node.id } })) as any); }} onConnect={() => { @@ -129,6 +168,14 @@ const FlowDetail: React.FC = () => { + setSelectedNodeId(null)} + onCommitted={loadData} + /> ); }; diff --git a/src/pages/Functions/components/FunctionDetail.tsx b/src/pages/Functions/components/FunctionDetail.tsx index 68a28e01..61910eff 100644 --- a/src/pages/Functions/components/FunctionDetail.tsx +++ b/src/pages/Functions/components/FunctionDetail.tsx @@ -1,9 +1,10 @@ import React, {useState} from "react"; import {Descriptions, Spin, Tabs, Tag, theme,} from "antd"; -import {FunctionOutlined, InfoCircleOutlined, PlayCircleOutlined,} from "@ant-design/icons"; +import {FunctionOutlined, FileTextOutlined, InfoCircleOutlined, PlayCircleOutlined,} from "@ant-design/icons"; import {useTranslation} from "react-i18next"; import ScriptEditor from "@/components/common/ScriptEditor"; import FunctionInvokePanel from "./FunctionInvokePanel"; +import FunctionLogPanel from "./FunctionLogPanel"; import type {FunctionResponse} from "@/services/function"; interface FunctionDetailProps { @@ -93,6 +94,11 @@ const FunctionDetail: React.FC = ({ ); + // ---- Logs Tab ---- + const logsTab = ( + + ); + const tabItems = [ { key: "overview", @@ -109,6 +115,11 @@ const FunctionDetail: React.FC = ({ label: {t("function.tabTest")}, children: testTab, }, + { + key: "logs", + label: {t("function.logTitle")}, + children: logsTab, + }, ]; return ( diff --git a/src/pages/Functions/components/FunctionLogPanel.tsx b/src/pages/Functions/components/FunctionLogPanel.tsx new file mode 100644 index 00000000..e51b46d7 --- /dev/null +++ b/src/pages/Functions/components/FunctionLogPanel.tsx @@ -0,0 +1,189 @@ +import React, {useCallback, useEffect, useState} from 'react'; +import {Button, DatePicker, Drawer, Form, Input, Select, Space, Table, theme} from 'antd'; +import {DownOutlined, ReloadOutlined, SearchOutlined, UpOutlined} from '@ant-design/icons'; +import {useTranslation} from 'react-i18next'; +import {getFunctionLogs} from '@/services/function-log'; +import type {FunctionLog} from '@/types/log'; + +const {RangePicker} = DatePicker; + +interface FunctionLogPanelProps { + projectId: string; + functionName: string; +} + +const FunctionLogPanel: React.FC = ({projectId, functionName}) => { + const {token} = theme.useToken(); + const {t} = useTranslation(); + + const [tableData, setTableData] = useState<{ list: FunctionLog[]; total: number }>({list: [], total: 0}); + const [log, setLog] = useState(null); + const [drawerVisible, setDrawerVisible] = useState(false); + const [loading, setLoading] = useState(false); + const [query, setQuery] = useState({page: 1, size: 20}); + const [form] = Form.useForm(); + const [showAdvanced, setShowAdvanced] = useState(false); + + const fetchData = useCallback(async () => { + if (!projectId || !functionName) return; + setLoading(true); + try { + const filter = form.getFieldsValue(); + const dateRange = filter?.dateRange + ?.map((d: any) => d?.format('YYYY-MM-DD HH:mm:ss')) + ?.join(','); + const res = await getFunctionLogs(projectId, { + ...query, + functionName, + level: filter?.level, + dateRange, + traceId: filter?.traceId, + keyword: filter?.keyword, + }); + setTableData({list: res.list, total: res.total}); + } finally { + setLoading(false); + } + }, [projectId, functionName, query, form]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const search = () => { + setQuery({page: 1, size: 20}); + }; + + const levelColor = (level: string) => { + if (level === 'error') return token.colorError; + if (level === 'warn') return token.colorWarning; + return token.colorText; + }; + + const columns = [ + { + title: t('function.logLevel', '级别'), + dataIndex: 'level', + width: 70, + render: (level: string) => ( + + {level.toUpperCase()} + + ), + }, + { + title: t('function.logMessage', '消息'), + dataIndex: 'message', + ellipsis: true, + }, + { + title: t('trace_id', 'Trace ID'), + dataIndex: 'traceId', + width: 110, + ellipsis: true, + render: (traceId: string) => (traceId ? {traceId.slice(0, 8)}… : null), + }, + { + title: t('created_at'), + dataIndex: 'createdAt', + width: 160, + }, + ]; + + return ( +
+
+
+ + + + + + + + + + + + + +
+ {showAdvanced && ( +
+ + + + + + +
+ )} + + +
'cursor-pointer'} + onRow={(record) => ({ + onClick: () => { + setLog(record); + setDrawerVisible(true); + }, + })} + pagination={{ + current: query.page, + pageSize: query.size, + total: tableData.total, + showSizeChanger: true, + size: 'small', + showTotal: (total: number, range: [number, number]) => + t('pagination_total_text', {start: range[0], end: range[1], total}), + onChange: (page, size) => setQuery({page, size}), + }} + /> + + setDrawerVisible(false)} + open={drawerVisible} + > + {log && ( +
+

ID: {log.id}

+

Function: {log.functionName}

+

Level: {log.level.toUpperCase()}

+

Trace ID: {log.traceId}

+

Time: {log.createdAt}

+

Message:

+
+ {log.message} +
+
+ )} +
+ + ); +}; + +export default FunctionLogPanel; diff --git a/src/pages/Overview/components/metrics/TrendAnalysis.tsx b/src/pages/Overview/components/metrics/TrendAnalysis.tsx index dc87da13..66bf599b 100644 --- a/src/pages/Overview/components/metrics/TrendAnalysis.tsx +++ b/src/pages/Overview/components/metrics/TrendAnalysis.tsx @@ -1,5 +1,5 @@ import React, {useState} from "react"; -import {Badge, Button, Card, Col, DatePicker, Row, Space, theme} from "antd"; +import {Badge, Button, Card, Col, DatePicker, Row, Space, Spin, theme} from "antd"; import ReactECharts from "echarts-for-react"; import echarts from '@/utils/echarts'; import dayjs, {Dayjs} from "dayjs"; @@ -11,6 +11,7 @@ const { RangePicker } = DatePicker; interface TrendAnalysisProps { apiStat: ApiStat; rankingData: RankingData[]; + loading?: boolean; dateRange: [Dayjs, Dayjs]; onDateRangeChange: (dateRange: [Dayjs, Dayjs]) => void; } @@ -18,6 +19,7 @@ interface TrendAnalysisProps { const TrendAnalysis: React.FC = ({ apiStat, rankingData, + loading = false, dateRange, onDateRangeChange }) => { @@ -231,36 +233,39 @@ const TrendAnalysis: React.FC = ({ } > -
-
- -
-
-
- {t("api_ranking")} + +
+
+
-
- {rankingData.map((item, index) => ( -
-
- - {" "} - {item.name} - - {item.total} +
+
+ {t("api_ranking")} +
+
+ {rankingData.map((item, index) => ( +
+
+ + {" "} + {item.name} + + {item.total} +
-
- ))} + ))} +
-
+ diff --git a/src/pages/Overview/index.tsx b/src/pages/Overview/index.tsx index a7ab252e..2c2442bc 100644 --- a/src/pages/Overview/index.tsx +++ b/src/pages/Overview/index.tsx @@ -30,6 +30,7 @@ const StatisticsPage: React.FC = () => { failData: [], }); const [rankingData, setRankingData] = useState([]); + const [apiStatLoading, setApiStatLoading] = useState(true); // 默认日期范围设置为本周 const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([ @@ -48,13 +49,18 @@ const StatisticsPage: React.FC = () => { useEffect(() => { if (!projectId) return; const loadData = async () => { - const data: ApiLogStatSchema = await getApiLogStat(projectId, { - dateRange: dateRange - .map((date: any) => date?.format("YYYY-MM-DD HH:mm:ss")) - ?.join(","), - }); - if (data.apiChart) setApiStat(data.apiChart); - if (data.apiRankingList) setRankingData(data.apiRankingList); + setApiStatLoading(true); + try { + const data: ApiLogStatSchema = await getApiLogStat(projectId, { + dateRange: dateRange + .map((date: any) => date?.format("YYYY-MM-DD HH:mm:ss")) + ?.join(","), + }); + if (data.apiChart) setApiStat(data.apiChart); + if (data.apiRankingList) setRankingData(data.apiRankingList); + } finally { + setApiStatLoading(false); + } }; loadData(); }, [projectId, dateRange]); @@ -81,6 +87,7 @@ const StatisticsPage: React.FC = () => { diff --git a/src/pages/ProjectSettings/components/LogSettingsTab.tsx b/src/pages/ProjectSettings/components/LogSettingsTab.tsx new file mode 100644 index 00000000..8eb56433 --- /dev/null +++ b/src/pages/ProjectSettings/components/LogSettingsTab.tsx @@ -0,0 +1,119 @@ +import React, {useEffect, useState} from 'react'; +import {Button, Form, InputNumber, message, Select, Spin, Typography} from 'antd'; +import {useTranslation} from 'react-i18next'; +import {getProject, patchProject} from '@/services/project'; +import {getModelList} from '@/services/model'; +import type {EntitySchema} from '@/types/data-modeling'; + +const DEFAULT_AUDIT_RESOURCES = [ + 'f_trigger', + 'f_em_flow_definition', + 'f_em_flow_deployment', + 'f_function', + 'f_bucket', + 'f_auth_provider_config', +]; +const DEFAULT_LOG_RETENTION_DAYS = 7; + +interface LogSettingsTabProps { + projectId: string; + disabled?: boolean; +} + +interface FormValues { + logRetentionDays: number; + auditResources: string[]; +} + +const LogSettingsTab: React.FC = ({projectId, disabled}) => { + const {t} = useTranslation(); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [metadata, setMetadata] = useState>({}); + const [modelOptions, setModelOptions] = useState<{ label: string; value: string }[]>([]); + + useEffect(() => { + if (!projectId) return; + setLoading(true); + // 同时加载项目设置与模型列表,审计资源从实体表中选取 + Promise.all([ + getProject(projectId), + getModelList(projectId), + ]) + .then(([project, models]) => { + const settings = (project.metadata?.logSettings ?? {}) as Record; + setMetadata(project.metadata ?? {}); + form.setFieldsValue({ + logRetentionDays: settings.logRetentionDays ?? DEFAULT_LOG_RETENTION_DAYS, + auditResources: settings.auditResources ?? [...DEFAULT_AUDIT_RESOURCES], + }); + // 仅实体(表)可选为审计资源,枚举/本地查询不参与审计 + setModelOptions( + (models as EntitySchema[]) + .filter((m) => m.type === 'Entity') + .map((m) => ({label: m.name, value: m.name})) + ); + }) + .finally(() => setLoading(false)); + }, [projectId, form]); + + const handleSave = async () => { + if (!projectId) return; + const values = await form.validateFields(); + setSaving(true); + try { + const updated = await patchProject(projectId, { + metadata: { + ...metadata, + logSettings: { + logRetentionDays: values.logRetentionDays, + auditResources: values.auditResources, + }, + }, + }); + setMetadata(updated.metadata ?? {}); + message.success(t('form_save_success')); + } finally { + setSaving(false); + } + }; + + return ( + +
+ + + + + - + + - - - + + + @@ -114,31 +116,33 @@ const ProjectSettings: React.FC = () => { /> - {!isDefaultProject && ( - - - - )} + {!isDefaultProject && ( + + + + )} - {isDefaultProject && ( - {t('project.defaultProjectNotEditable')} - )} - -
+ {isDefaultProject && ( + {t('project.defaultProjectNotEditable')} + )} + +
{t('project.projectId')}: {projectId}
{currentProject?.databaseName && ( -
+
{t('project.databaseName')}: {currentProject.databaseName}
)}
- + ); case 'auth': - return ; + return ; + case 'logs': + return projectId ? : null; case 'pages': return projectId ? : null; default: @@ -162,7 +166,7 @@ const ProjectSettings: React.FC = () => { className="h-full" mode="inline" selectedKeys={[selectKey]} - onClick={({ key }) => setSelectKey(key as ProjectSettingsTabKey)} + onClick={({key}) => setSelectKey(key as ProjectSettingsTabKey)} items={menuItems} />
diff --git a/src/pages/Scheduling/components/JobExecutionLogList.tsx b/src/pages/Scheduling/components/JobExecutionLogList.tsx index 9bd92ebe..00d3322c 100644 --- a/src/pages/Scheduling/components/JobExecutionLogList.tsx +++ b/src/pages/Scheduling/components/JobExecutionLogList.tsx @@ -13,7 +13,7 @@ import { Tag, Typography } from 'antd'; -import {EyeOutlined, ReloadOutlined, SearchOutlined} from '@ant-design/icons'; +import {DownOutlined, EyeOutlined, ReloadOutlined, SearchOutlined, UpOutlined} from '@ant-design/icons'; import {useTranslation} from 'react-i18next'; import PageContainer from '@/components/common/PageContainer'; import {getJobExecutionLogs, JobExecutionLog, JobExecutionLogParams} from '@/services/job'; @@ -36,6 +36,7 @@ const JobExecutionLogList: React.FC = () => { const [pageSize, setPageSize] = useState(20); const [total, setTotal] = useState(0); const [detailModalVisible, setDetailModalVisible] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); const [selectedLog, setSelectedLog] = useState(null); const tableWrapperRef = useRef(null); const [tableScrollY, setTableScrollY] = useState(300); @@ -113,7 +114,7 @@ const JobExecutionLogList: React.FC = () => { setLogs(response.list); setTotal(response.total); } catch (error) { - message.error('加载任务执行日志失败'); + message.error(t('load_job_execution_log_failed')); console.error('Failed to load job execution logs:', error); } finally { setLoading(false); @@ -176,13 +177,13 @@ const JobExecutionLogList: React.FC = () => { const getStatusTag = (executionStatus: string | undefined) => { if (executionStatus === 'SUCCESS') { - return 成功; + return {t('success')}; } else if (executionStatus === 'RUNNING') { - return 运行中; + return {t('running')}; } else if (executionStatus === 'FAILED') { - return 失败; + return {t('fail')}; } else { - return 未知; + return {t('unknown')}; } }; @@ -202,45 +203,58 @@ const JobExecutionLogList: React.FC = () => { const columns = [ { - title: '任务名称', + title: t('job_name'), dataIndex: 'jobName', key: 'jobName', width: 200, }, { - title: '任务ID', + title: t('job_id'), dataIndex: 'jobId', key: 'jobId', width: 120, }, { - title: '触发器ID', + title: t('trigger_id'), dataIndex: 'triggerId', key: 'triggerId', width: 120, }, { - title: '执行状态', + title: t('execution_status'), key: 'executionStatus', width: 100, render: (_: any, record: JobExecutionLog) => getStatusTag(record.executionStatus), }, { - title: '开始时间', + title: t('start_time'), dataIndex: 'startTime', key: 'startTime', width: 180, render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss'), }, { - title: '执行时长', + title: t('trace_id'), + dataIndex: 'traceId', + key: 'traceId', + width: 140, + ellipsis: true, + render: (traceId: string) => + traceId ? ( + + {traceId.slice(0, 8)}… + + ) : null, + }, + { + title: t('execution_duration'), dataIndex: 'executionDuration', key: 'executionDuration', width: 100, render: (duration: number) => formatDuration(duration), }, { - title: '操作', + title: t('operation'), key: 'action', width: 100, render: (_: any, record: JobExecutionLog) => ( @@ -250,7 +264,7 @@ const JobExecutionLogList: React.FC = () => { icon={} onClick={() => handleViewDetail(record)} > - 详情 + {t('detail')} ), @@ -260,10 +274,58 @@ const JobExecutionLogList: React.FC = () => { return ( +
+ + + + + + + + + + + + + + +
+ {showAdvanced && ( +
+ + + + + + +
+ )} + + } >
{ overflow: 'hidden', }} > -
-
- - - - - - - - - - - - - - - - - - - - - - - - -
-
{ setDetailModalVisible(false)} footer={[ ]} width={800} > {selectedLog && ( - + {selectedLog.jobName} - + {selectedLog.jobId} - + {selectedLog.jobGroup} - + {selectedLog.jobType} - + {selectedLog.triggerId} - + {getStatusTag(selectedLog.executionStatus)} - + {dayjs(selectedLog.startTime).format('YYYY-MM-DD HH:mm:ss')} - + {selectedLog.endTime ? dayjs(selectedLog.endTime).format('YYYY-MM-DD HH:mm:ss') : '-'} - + {formatDuration(selectedLog.executionDuration)} - + {selectedLog.executionStatus} + + {selectedLog.traceId || '-'} + {selectedLog.errorMessage && ( - + {selectedLog.errorMessage} )} {selectedLog.inputData && ( - +