From a4090123ba9c276cb0b4e890d912f430228c8fd1 Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Mon, 26 Oct 2026 19:06:09 +0800 Subject: [PATCH] feat(ticket): stage steps and approval flow conveniences - TicketStageSteps: status-driven stepper (opsx-sec pattern, active derived from ticket status incl. rejected/failed/cancelled/rollback terminal states) at top of ticket detail page - QuickReviewDialog: inline approve/reject with optional comment for list rows and detail dialog (reject prompts reason, notes terminal semantics); replaces fixed-comment direct calls and alert() usage - execute now requires impact-summary confirm (instance + database + statement count) in both list and detail dialog - DetailTimeline action keys aligned with backend values (created/ pending_review/approve/reject/executed/execution_failed) - icons and colors were silently falling back before - create page: removed dead scheduled_at field (backend never accepted it), added rollback SQL collection (backend-supported), real priority select, approval-flow preview showing bound workflow steps and approvers once instance selected - empty tickets state now embeds create CTA button - RedisDetailPage split: key detail panel extracted to feature component (996 -> 658 lines after formatter reflow; panel 377 lines) --- .../redis/components/RedisKeyDetailPanel.tsx | 412 ++++++++++ .../ticket/components/DetailTimeline.tsx | 24 +- .../ticket/components/QuickReviewDialog.tsx | 76 ++ .../ticket/components/TicketDetailDialog.tsx | 705 ++++++++++-------- .../ticket/components/TicketStageSteps.tsx | 118 +++ .../ticket/components/TicketTable.tsx | 79 +- frontend/src/pages/RedisDetailPage.tsx | 373 +-------- frontend/src/pages/TicketCreatePage.tsx | 426 ++++++++--- frontend/src/pages/TicketDetailPage.tsx | 118 +-- frontend/src/pages/TicketsPage.tsx | 176 +++-- 10 files changed, 1604 insertions(+), 903 deletions(-) create mode 100644 frontend/src/features/redis/components/RedisKeyDetailPanel.tsx create mode 100644 frontend/src/features/ticket/components/QuickReviewDialog.tsx create mode 100644 frontend/src/features/ticket/components/TicketStageSteps.tsx diff --git a/frontend/src/features/redis/components/RedisKeyDetailPanel.tsx b/frontend/src/features/redis/components/RedisKeyDetailPanel.tsx new file mode 100644 index 0000000..57f1428 --- /dev/null +++ b/frontend/src/features/redis/components/RedisKeyDetailPanel.tsx @@ -0,0 +1,412 @@ +/** + * Redis 键详情面板:结构化/格式化/编辑三模式查看,TTL 编辑、保存、删除、Hash 行级编辑。 + * 必需 props:instanceId/db/onKeyDeleted/onRefreshBrowser;已知限制:大值不分页,纯展示截断。 + */ +import { forwardRef, useImperativeHandle, useState } from 'react'; +import { Copy, Save, Trash2, Table2, Code2, FileText, Search } from 'lucide-react'; +import { redisApi } from '@/api/redis'; +import { toast } from '@/hooks/useToast'; +import { ConfirmDialog } from '@/components/ConfirmDialog'; +import { RedisStructuredValue } from './RedisStructuredValue'; +import { RedisLineEditorDialog } from './RedisLineEditorDialog'; +import { STRING_FORMAT_OPTIONS, type RedisLineEditorState, type StringFormat } from '../types'; +import { + formatStringByMode, + normalizeValueForEdit, + parseEditorValue, + stringifyValue, + ttlMsToSeconds, + tryFormatJsonText, +} from '../utils'; + +type ValueMode = 'table' | 'json' | 'raw'; + +interface RedisKeyDetail { + key: string; + name?: string; + type: string; + ttl: number; + value: any; +} + +export interface RedisKeyDetailPanelHandle { + openKey: (keyName: string) => void; + clear: () => void; +} + +interface Props { + instanceId: number; + db: number; + /** 删除成功回调(页面负责从键树本地移除) */ + onKeyDeleted: (keyName: string) => void; + /** 整键保存成功后刷新键树 */ + onRefreshBrowser: () => void; +} + +const RedisKeyDetailPanel = forwardRef(function RedisKeyDetailPanel( + { instanceId, db, onKeyDeleted, onRefreshBrowser }, + ref, +) { + const [selectedKey, setSelectedKey] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [valueMode, setValueMode] = useState('table'); + const [stringFormat, setStringFormat] = useState('Json'); + const [inspectValue, setInspectValue] = useState<{ title: string; value: any } | null>(null); + const [valueSearch, setValueSearch] = useState(''); + const [editValue, setEditValue] = useState(''); + const [editTtl, setEditTtl] = useState(-1); + const [savingKey, setSavingKey] = useState(false); + const [lineEditor, setLineEditor] = useState({ + open: false, + originalField: '', + field: '', + value: '', + format: 'Json', + }); + const [deleteConfirm, setDeleteConfirm] = useState<{ msg: string; onConfirm: () => void } | null>(null); + + const loadKeyDetail = (keyName: string) => { + setDetailLoading(true); + redisApi + .getKeyDetail(instanceId, keyName, db) + .then((res: any) => { + const detail = res as RedisKeyDetail; + setSelectedKey(detail); + setEditValue(stringifyValue(detail.type, detail.value)); + setEditTtl(ttlMsToSeconds(detail.ttl)); + setValueMode(detail.type === 'string' ? 'json' : 'table'); + setStringFormat(detail.type === 'string' ? 'Json' : 'String'); + setInspectValue(null); + setValueSearch(''); + }) + .catch(() => { + toast('error', '加载 Redis Key 详情失败'); + }) + .finally(() => setDetailLoading(false)); + }; + + useImperativeHandle( + ref, + () => ({ + openKey: (keyName: string) => loadKeyDetail(keyName), + clear: () => { + setSelectedKey(null); + setInspectValue(null); + setLineEditor({ open: false, originalField: '', field: '', value: '', format: 'Json' }); + }, + }), + [instanceId, db], + ); + + const copyText = (text: string, message: string) => { + navigator.clipboard + ?.writeText(text) + .then(() => { + toast('success', message); + }) + .catch(() => { + toast('warning', '复制失败,请手动复制'); + }); + }; + + const handleCopyValue = () => { + if (!selectedKey) return; + copyText(editValue, '已复制当前值'); + }; + + const inspectCellValue = (title: string, value: any) => { + setInspectValue({ title, value }); + setValueMode('json'); + }; + + const openHashLineEditor = (field: string, value: any) => { + setLineEditor({ open: true, originalField: field, field, value: String(value ?? ''), format: 'Json' }); + }; + + /** Hash 单字段编辑:行级 HSET/HDEL,不整键重写、保留其余字段与 TTL */ + const saveHashField = (op: 'set' | 'del', field: string, value: string, successMessage: string) => { + if (!selectedKey) return; + const keyName = selectedKey.key || selectedKey.name || ''; + setSavingKey(true); + redisApi + .updateKeyField(instanceId, keyName, { db, op, field, value }) + .then(() => { + toast('success', successMessage); + setLineEditor({ open: false, originalField: '', field: '', value: '', format: 'Json' }); + loadKeyDetail(keyName); + }) + .catch(() => {}) + .finally(() => setSavingKey(false)); + }; + + const handleSaveHashLine = () => { + if (!selectedKey || selectedKey.type !== 'hash') return; + const field = lineEditor.field.trim(); + if (!field) { + toast('error', 'Field 不能为空'); + return; + } + const renamed = lineEditor.originalField && lineEditor.originalField !== field; + if (renamed) { + saveHashField('del', lineEditor.originalField!, '', 'Hash 行已保存'); + } + saveHashField('set', field, lineEditor.value, renamed ? 'Hash 行已保存(字段已改名)' : 'Hash 行已保存'); + }; + + const handleDeleteHashLine = (field: string) => { + setDeleteConfirm({ + msg: '确定删除字段 ' + field + '?', + onConfirm: async () => { + saveHashField('del', field, '', 'Hash 字段已删除'); + }, + }); + }; + + const handleSaveKey = () => { + if (!selectedKey) return; + const keyName = selectedKey.key || selectedKey.name || ''; + let parsedValue: any; + try { + parsedValue = parseEditorValue(selectedKey.type, editValue); + } catch { + toast('error', '值不是合法的 ' + (selectedKey.type === 'hash' ? 'JSON Object' : 'JSON Array')); + return; + } + setSavingKey(true); + redisApi + .updateKey(instanceId, keyName, { + db, + type: selectedKey.type, + value: parsedValue, + ttl: editTtl > 0 ? editTtl : undefined, + }) + .then(() => { + toast('success', 'Key 已保存'); + loadKeyDetail(keyName); + onRefreshBrowser(); + }) + .catch(() => {}) + .finally(() => setSavingKey(false)); + }; + + const handleDeleteKey = (keyName: string) => { + setDeleteConfirm({ + msg: '确定删除键 ' + keyName + '?删除后不可恢复', + onConfirm: async () => { + redisApi + .deleteKey(instanceId, keyName, db) + .then(() => { + setSelectedKey(null); + onKeyDeleted(keyName); + toast('success', 'Key 已删除'); + }) + .catch(() => {}); + }, + }); + }; + + const renderFormattedValue = () => { + if (!selectedKey) return ''; + if (inspectValue) { + const inspectedText = + typeof inspectValue.value === 'string' ? inspectValue.value : JSON.stringify(inspectValue.value, null, 2); + return tryFormatJsonText(inspectedText) || inspectedText; + } + if (selectedKey.type === 'string') return formatStringByMode(String(selectedKey.value ?? ''), stringFormat); + return JSON.stringify(normalizeValueForEdit(selectedKey.type, selectedKey.value), null, 2); + }; + + return ( +
+ {!selectedKey ? ( +
+ +
选择一个 Key 查看详情
+
支持结构化查看、格式化 JSON、编辑值和 TTL
+
+ ) : ( +
+
+
+ + {selectedKey.type} + + +
+ + TTL + + setEditTtl(Number(e.target.value))} + className="w-24 bg-[var(--dg-bg-primary)] px-3 text-sm outline-none" + /> +
+ + + +
+
+ +
+
+
+ {[ + { key: 'table' as ValueMode, label: '结构', icon: Table2 }, + { key: 'json' as ValueMode, label: '格式化', icon: Code2 }, + { key: 'raw' as ValueMode, label: '编辑', icon: FileText }, + ].map((mode) => { + const Icon = mode.icon; + return ( + + ); + })} +
+ {selectedKey.type === 'string' && valueMode === 'json' && ( + + )} + {inspectValue && valueMode === 'json' && ( + + )} +
+
+ + setValueSearch(e.target.value)} + className="h-8 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] pl-8 pr-3 text-xs" + placeholder="搜索字段或值" + /> +
+
+ +
+ {detailLoading ? ( +
+ ) : valueMode === 'raw' ? ( +