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)
This commit is contained in:
Logan.Li committed 2026-10-26 19:06:09 +08:00
1 parent f6d636fa44
commit a4090123ba
10 files changed
+1303 -602

No files matched your search

@@ -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<RedisKeyDetailPanelHandle, Props>(function RedisKeyDetailPanel(
{ instanceId, db, onKeyDeleted, onRefreshBrowser },
ref,
) {
const [selectedKey, setSelectedKey] = useState<RedisKeyDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [valueMode, setValueMode] = useState<ValueMode>('table');
const [stringFormat, setStringFormat] = useState<StringFormat>('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<RedisLineEditorState>({
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 (
<div className="dg-card min-w-0 overflow-hidden">
{!selectedKey ? (
<div className="flex h-full min-h-[520px] flex-col items-center justify-center text-center">
<FileText className="mb-3 h-10 w-10 text-[var(--dg-text-muted)]" />
<div className="text-sm font-medium">选择一个 Key 查看详情</div>
<div className="mt-1 text-xs text-[var(--dg-text-muted)]">支持结构化查看、格式化 JSON、编辑值和 TTL</div>
</div>
) : (
<div className="flex h-full min-h-[620px] flex-col">
<div className="border-b border-[var(--dg-border)] p-4">
<div className="flex items-center gap-3">
<span className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-2 text-xs font-semibold uppercase text-cyan-400">
{selectedKey.type}
</span>
<input
readOnly
value={selectedKey.key || selectedKey.name || ''}
className="h-10 min-w-0 flex-1 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 font-mono text-sm text-[var(--dg-text-primary)]"
/>
<div className="flex h-10 overflow-hidden rounded-md border border-[var(--dg-border)]">
<span className="flex items-center border-r border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 text-xs text-[var(--dg-text-muted)]">
TTL
</span>
<input
type="number"
value={editTtl}
onChange={(e) => setEditTtl(Number(e.target.value))}
className="w-24 bg-[var(--dg-bg-primary)] px-3 text-sm outline-none"
/>
</div>
<button
onClick={handleCopyValue}
className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]"
title="复制值"
>
<Copy className="h-4 w-4" />
</button>
<button
onClick={handleSaveKey}
disabled={savingKey}
className="inline-flex h-10 items-center gap-2 rounded-md bg-[var(--dg-blue)] px-4 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
>
<Save className="h-4 w-4" />
保存
</button>
<button
onClick={() => handleDeleteKey(selectedKey.key || selectedKey.name || '')}
className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20"
title="删除 Key"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex items-center justify-between border-b border-[var(--dg-border)] px-4 py-3">
<div className="flex items-center gap-2">
<div className="flex rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-0.5">
{[
{ 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 (
<button
key={mode.key}
onClick={() => {
setValueMode(mode.key);
if (mode.key !== 'json') setInspectValue(null);
}}
className={
'inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-xs transition-colors ' +
(valueMode === mode.key
? 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)]'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]')
}
>
<Icon className="h-3.5 w-3.5" />
{mode.label}
</button>
);
})}
</div>
{selectedKey.type === 'string' && valueMode === 'json' && (
<select
value={stringFormat}
onChange={(e) => setStringFormat(e.target.value as StringFormat)}
className="h-8 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 text-xs text-[var(--dg-text-secondary)]"
>
{STRING_FORMAT_OPTIONS.map((format) => (
<option key={format} value={format}>
{format}
</option>
))}
</select>
)}
{inspectValue && valueMode === 'json' && (
<button
onClick={() => setInspectValue(null)}
className="h-8 rounded-md border border-[var(--dg-border)] px-2 text-xs text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)]"
>
返回 Key 格式化
</button>
)}
</div>
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--dg-text-muted)]" />
<input
value={valueSearch}
onChange={(e) => 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="搜索字段或值"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto p-4 scrollbar-thin">
{detailLoading ? (
<div className="h-72 animate-pulse rounded-lg bg-[var(--dg-bg-tertiary)]" />
) : valueMode === 'raw' ? (
<textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
spellCheck={false}
className="min-h-[480px] w-full resize-none rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-4 font-mono text-xs leading-relaxed outline-none focus:border-[var(--dg-blue)]"
/>
) : valueMode === 'json' ? (
<div>
{inspectValue && (
<div className="mb-2 flex items-center justify-between rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-xs">
<span className="truncate font-mono text-[var(--dg-text-secondary)]">
正在查看字段:{inspectValue?.title}
</span>
<button
onClick={() => {
const iv = inspectValue;
if (!iv) return;
copyText(typeof iv.value === 'string' ? iv.value : JSON.stringify(iv.value), '已复制字段值');
}}
className="inline-flex items-center gap-1.5 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
>
<Copy className="h-3.5 w-3.5" />
复制
</button>
</div>
)}
<pre className="min-h-[480px] whitespace-pre-wrap break-all rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-4 font-mono text-xs leading-relaxed text-[var(--dg-text-secondary)]">
{renderFormattedValue()}
</pre>
</div>
) : (
<div className="overflow-hidden rounded-lg border border-[var(--dg-border)]">
<RedisStructuredValue
type={selectedKey.type}
value={selectedKey.value}
redisKey={selectedKey.key || selectedKey.name || ''}
search={valueSearch}
onCopy={copyText}
onInspect={inspectCellValue}
onEditHashLine={openHashLineEditor}
onDeleteHashLine={handleDeleteHashLine}
/>
</div>
)}
</div>
</div>
)}
<RedisLineEditorDialog
state={lineEditor}
saving={savingKey}
onChange={setLineEditor}
onClose={() => setLineEditor({ open: false, originalField: '', field: '', value: '', format: 'Json' })}
onConfirm={handleSaveHashLine}
onCopy={(value) => copyText(value, '已复制字段值')}
/>
<ConfirmDialog
open={!!deleteConfirm}
title={deleteConfirm?.msg || '确认操作'}
variant="danger"
confirmText="确定"
onConfirm={() => {
deleteConfirm?.onConfirm();
setDeleteConfirm(null);
}}
onCancel={() => setDeleteConfirm(null)}
/>
</div>
);
});
export default RedisKeyDetailPanel;
@@ -1,7 +1,4 @@
import {
Clock, CheckCircle, XCircle, Play, RotateCcw, FileCode,
Shield, User, Calendar,
} from 'lucide-react';
import { Clock, CheckCircle, XCircle, Play, RotateCcw, FileCode, Shield, User, Calendar } from 'lucide-react';
import type { TimelineEntry } from './DetailTypes';
interface DetailTimelineProps {
@@ -10,13 +7,16 @@ interface DetailTimelineProps {
}
/** 时间线动作图标配置 */
/** 键与后端 timeline action 实际值对齐(created/pending_review/approve/reject/executed/...) */
const ACTION_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
CREATE_TICKET: { icon: FileCode, color: 'text-blue-400', bg: 'bg-blue-500/15' },
REVIEW_TICKET: { icon: Shield, color: 'text-green-400', bg: 'bg-green-500/15' },
EXECUTE_TICKET: { icon: Play, color: 'text-cyan-400', bg: 'bg-cyan-500/15' },
CANCEL_TICKET: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/15' },
ROLLBACK_TICKET: { icon: RotateCcw, color: 'text-orange-400', bg: 'bg-orange-500/15' },
REJECT_TICKET: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/15' },
created: { icon: FileCode, color: 'text-blue-400', bg: 'bg-blue-500/15' },
pending_review: { icon: Shield, color: 'text-amber-400', bg: 'bg-amber-500/15' },
approve: { icon: Shield, color: 'text-green-400', bg: 'bg-green-500/15' },
reject: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/15' },
executed: { icon: Play, color: 'text-cyan-400', bg: 'bg-cyan-500/15' },
execution_failed: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/15' },
cancel: { icon: XCircle, color: 'text-amber-400', bg: 'bg-amber-500/15' },
rollback: { icon: RotateCcw, color: 'text-orange-400', bg: 'bg-orange-500/15' },
};
/** 工单详情:操作时间线 */
@@ -45,7 +45,9 @@ export default function DetailTimeline({ timeline }: DetailTimelineProps) {
// 时间线无独立 id,用「动作 + 时间」组合作为稳定 key(无时间时退化为索引)
<div key={`${entry.action}-${entry.time ?? idx}`} className={`relative ${isLast ? '' : 'mb-6'}`}>
{/* Icon node */}
<div className={`absolute -left-8 top-0.5 w-6 h-6 rounded-full ${config.bg} border-2 border-[var(--dg-border)] flex items-center justify-center z-10`}>
<div
className={`absolute -left-8 top-0.5 w-6 h-6 rounded-full ${config.bg} border-2 border-[var(--dg-border)] flex items-center justify-center z-10`}
>
<Icon className={`w-3.5 h-3.5 ${config.color}`} />
</div>
@@ -0,0 +1,76 @@
/**
* 快捷审批弹窗(列表行内通过/拒绝):可选评论 + 确认;拒绝时建议填写原因。
* 必需 props:ticket/action/onConfirm/onClose;已知限制:一次一单,不做批量。
*/
import { useState } from 'react';
import { CheckCircle, XCircle } from 'lucide-react';
import { FormDialog } from '@/components/FormDialog';
import type { TicketItem } from '../types';
interface Props {
ticket: TicketItem;
action: 'approve' | 'reject';
onConfirm: (comment: string) => void;
onClose: () => void;
}
export function QuickReviewDialog({ ticket, action, onConfirm, onClose }: Props) {
const [comment, setComment] = useState('');
const isApprove = action === 'approve';
const footer = (
<>
<button type="button" onClick={onClose} className="dg-btn dg-btn-secondary dg-btn-sm">
取消
</button>
<button
type="button"
onClick={() => onConfirm(comment.trim() || (isApprove ? '通过' : '拒绝'))}
className={`dg-btn dg-btn-sm ${isApprove ? 'dg-btn-primary' : 'dg-btn-danger'}`}
>
{isApprove ? '确认通过' : '确认拒绝'}
</button>
</>
);
return (
<FormDialog
open
title={`${isApprove ? '通过' : '拒绝'}工单 #${ticket.id}`}
onClose={onClose}
footer={footer}
width="max-w-md"
>
<div className="space-y-3">
<div className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-2 text-xs">
<div className="truncate font-medium text-[var(--dg-text-primary)]">{ticket.title}</div>
<div className="mt-1 dg-text-muted">
{ticket.applicant_name || `用户 ${ticket.applicant_id}`} · {ticket.db_type} · {ticket.target_database}
</div>
</div>
<div>
<label className="mb-1 block text-xs dg-text-muted">评论{isApprove ? '(可选)' : '(建议填写拒绝原因)'}</label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={3}
placeholder={isApprove ? '补充说明(将记录到审批时间线)' : '请说明拒绝原因'}
className="dg-input w-full rounded-md px-3 py-2 text-sm"
/>
</div>
{!isApprove && !comment.trim() && (
<div className="flex items-center gap-1.5 text-xs text-amber-400">
<XCircle className="h-3.5 w-3.5" />
拒绝为终态操作,任一审批人拒绝后工单立即终止
</div>
)}
{isApprove && (
<div className="flex items-center gap-1.5 text-xs dg-text-muted">
<CheckCircle className="h-3.5 w-3.5 text-green-400" />
多人审批时任一人通过即完成本步
</div>
)}
</div>
</FormDialog>
);
}
@@ -3,11 +3,10 @@
* 打开时拉取完整详情;审批/执行完成后经 onActionDone 通知页面刷新
*/
import { useState, useEffect, useCallback } from 'react';
import {
Eye, CheckCircle, XCircle, Play,
FileCode, Clock, X, Loader2, AlertTriangle,
} from 'lucide-react';
import { Eye, CheckCircle, XCircle, Play, FileCode, Clock, X, Loader2, AlertTriangle } from 'lucide-react';
import { ticketApi } from '@/api/ticket';
import { QuickReviewDialog } from './QuickReviewDialog';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ModalOverlay } from '@/components/ModalOverlay';
import { getStatusIndex, parseExecuteResult } from '../utils';
import type { ApprovalRecord, TicketItem } from '../types';
@@ -21,8 +20,16 @@ interface TicketDetailDialogProps {
onActionDone: () => void;
}
export function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExecute, onActionDone }: TicketDetailDialogProps) {
export function TicketDetailDialog({
ticket: initialTicket,
onClose,
canApprove,
canExecute,
onActionDone,
}: TicketDetailDialogProps) {
const [loading, setLoading] = useState(false);
const [reviewAction, setReviewAction] = useState<'approve' | 'reject' | null>(null);
const [confirmingExecute, setConfirmingExecute] = useState(false);
const [ticket, setTicket] = useState<TicketItem>(initialTicket);
const [approvals, setApprovals] = useState<ApprovalRecord[]>([]);
@@ -30,10 +37,12 @@ export function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove,
useEffect(() => {
(async () => {
try {
const detail = await ticketApi.get(ticket.id) as any;
setTicket(prev => ({ ...prev, ...detail }));
const detail = (await ticketApi.get(ticket.id)) as any;
setTicket((prev) => ({ ...prev, ...detail }));
setApprovals(detail.approvals || []);
} catch { /* ignore */ }
} catch {
/* ignore */
}
})();
}, [ticket.id]);
@@ -48,31 +57,61 @@ export function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove,
{ key: 'success', label: '完成', icon: CheckCircle },
];
const handleReview = useCallback(async (action: 'approve' | 'reject') => {
// 通过/拒绝:弹快捷审批(可选评论),与列表页行为一致
const handleReview = useCallback((action: 'approve' | 'reject') => {
setReviewAction(action);
}, []);
const confirmReview = useCallback(
async (comment: string) => {
if (!reviewAction) return;
setLoading(true);
try {
await ticketApi.review(ticket.id, { action, comment: action === 'approve' ? '审批通过' : '拒绝' });
await ticketApi.review(ticket.id, { action: reviewAction, comment });
setReviewAction(null);
onActionDone();
} catch { /* ignore */ } finally { setLoading(false); }
}, [ticket.id, onActionDone]);
} catch {
/* ignore */
} finally {
setLoading(false);
}
},
[reviewAction, ticket.id, onActionDone],
);
const handleExecute = useCallback(async () => {
// 执行:影响面复述确认(opsx-sec 模式)
const handleExecute = useCallback(() => {
setConfirmingExecute(true);
}, []);
const confirmExecute = useCallback(async () => {
setConfirmingExecute(false);
setLoading(true);
try {
await ticketApi.execute(ticket.id);
onActionDone();
} catch { /* ignore */ } finally { setLoading(false); }
} catch {
/* ignore */
} finally {
setLoading(false);
}
}, [ticket.id, onActionDone]);
return (
<ModalOverlay open={true} onClose={onClose} width="max-w-3xl" className="border border-[var(--dg-border)] overflow-hidden flex flex-col">
<div
className="w-full max-h-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col"
<>
<ModalOverlay
open={true}
onClose={onClose}
width="max-w-3xl"
className="border border-[var(--dg-border)] overflow-hidden flex flex-col"
>
<div className="w-full max-h-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 px-4 md:px-6 py-4 border-b border-[var(--dg-border)]">
<div className="flex items-center gap-3">
<span className="font-mono text-xs text-[var(--dg-text-muted)]">{ticket.ticket_no || `TK-${ticket.id}`}</span>
<span className="font-mono text-xs text-[var(--dg-text-muted)]">
{ticket.ticket_no || `TK-${ticket.id}`}
</span>
<TicketStatusBadge status={ticket.status} />
</div>
<div className="flex items-center gap-2 flex-wrap">
@@ -103,7 +142,10 @@ export function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove,
<Play className="w-3.5 h-3.5" /> 执行
</button>
)}
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors ml-2">
<button
onClick={onClose}
className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors ml-2"
>
<X className="w-5 h-5" />
</button>
</div>
@@ -111,7 +153,6 @@ export function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove,
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-5">
{/* Status Flow */}
<div className="flex items-center justify-between overflow-x-auto pb-1">
{statusDisplay.map((step, idx) => {
@@ -123,27 +164,39 @@ export function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove,
return (
<div key={step.key} className="flex items-center flex-1">
<div className="flex flex-col items-center gap-1.5">
<div className={`w-8 h-8 rounded-full flex items-center justify-center transition-all ${
showFailed ? 'bg-red-500/15 text-red-400' :
isCurrent ? 'bg-blue-500/15 text-blue-400 ring-2 ring-blue-500/30' :
isCompleted ? 'bg-green-500/15 text-green-400' :
'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'
}`}>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center transition-all ${
showFailed
? 'bg-red-500/15 text-red-400'
: isCurrent
? 'bg-blue-500/15 text-blue-400 ring-2 ring-blue-500/30'
: isCompleted
? 'bg-green-500/15 text-green-400'
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'
}`}
>
{showFailed ? <XCircle className="w-4 h-4" /> : <StepIcon className="w-4 h-4" />}
</div>
<span className={`text-[10px] font-medium ${
showFailed ? 'text-red-400' :
isCurrent ? 'text-blue-400' :
isCompleted ? 'text-green-400' :
'text-[var(--dg-text-muted)]'
}`}>
<span
className={`text-[10px] font-medium ${
showFailed
? 'text-red-400'
: isCurrent
? 'text-blue-400'
: isCompleted
? 'text-green-400'
: 'text-[var(--dg-text-muted)]'
}`}
>
{showFailed ? (ticket.status === 'rejected' ? '已拒绝' : '失败') : step.label}
</span>
</div>
{idx < statusDisplay.length - 1 && (
<div className={`flex-1 h-[2px] mx-2 rounded-full transition-all ${
<div
className={`flex-1 h-[2px] mx-2 rounded-full transition-all ${
idx < currentStepIndex && currentStepIndex >= 0 ? 'bg-green-400/50' : 'bg-[var(--dg-border)]'
}`} />
}`}
/>
)}
</div>
);
@@ -154,7 +207,10 @@ export function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove,
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3">
{[
{ label: '提交人', value: ticket.applicant_name || '-' },
{ label: '当前审核人', value: ticket.current_approver_name || (ticket.status === 'approved' ? '待执行' : '-') },
{
label: '当前审核人',
value: ticket.current_approver_name || (ticket.status === 'approved' ? '待执行' : '-'),
},
{ label: '实例', value: ticket.instance_name || '-' },
{ label: '目标库', value: ticket.target_database },
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
@@ -188,7 +244,9 @@ export function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove,
<div className="grid grid-cols-3 gap-3 mb-3">
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-center">
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">状态</div>
<div className={`text-sm font-medium ${execResult.status === 'success' ? 'text-green-400' : 'text-red-400'}`}>
<div
className={`text-sm font-medium ${execResult.status === 'success' ? 'text-green-400' : 'text-red-400'}`}
>
{execResult.status === 'success' ? '执行成功' : '执行失败'}
</div>
</div>
@@ -221,7 +279,9 @@ export function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove,
<tr key={`${idx}-${item.sql}`} className="border-t border-[var(--dg-border)]">
<td className="py-2 pr-3 text-[var(--dg-text-muted)]">{idx + 1}</td>
<td className="py-2 pr-3">
<pre className="font-mono text-xs whitespace-pre-wrap max-h-[60px] overflow-y-auto">{item.sql}</pre>
<pre className="font-mono text-xs whitespace-pre-wrap max-h-[60px] overflow-y-auto">
{item.sql}
</pre>
</td>
<td className="py-2 pr-3">
<span className={item.status === 'success' ? 'text-green-400' : 'text-red-400'}>
@@ -232,7 +292,9 @@ export function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove,
<td className="py-2 pr-3">{item.duration_ms}ms</td>
<td className="py-2">
{item.error && (
<span className={item.status === 'fail' ? 'text-red-400' : 'text-[var(--dg-text-muted)]'}>
<span
className={item.status === 'fail' ? 'text-red-400' : 'text-[var(--dg-text-muted)]'}
>
{item.error.length > 80 ? item.error.substring(0, 80) + '...' : item.error}
</span>
)}
@@ -258,9 +320,13 @@ export function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove,
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
<Play className="w-4 h-4 text-cyan-400" /> 执行结果
</h3>
<div className={`p-3 rounded-lg text-xs font-mono whitespace-pre-wrap ${
ticket.status === 'failed' ? 'bg-red-500/5 border border-red-500/20 text-red-400' : 'bg-[var(--dg-bg-tertiary)]'
}`}>
<div
className={`p-3 rounded-lg text-xs font-mono whitespace-pre-wrap ${
ticket.status === 'failed'
? 'bg-red-500/5 border border-red-500/20 text-red-400'
: 'bg-[var(--dg-bg-tertiary)]'
}`}
>
{ticket.execute_result}
</div>
{ticket.execute_time && (
@@ -301,56 +367,80 @@ export function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove,
<div className="flex-1 pb-3">
<div className="text-xs font-medium text-green-400">提交申请</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
{ticket.applicant_name || `用户#${ticket.applicant_id}`} · {new Date(ticket.create_time).toLocaleString()}
{ticket.applicant_name || `用户#${ticket.applicant_id}`} ·{' '}
{new Date(ticket.create_time).toLocaleString()}
</div>
{ticket.reason && (
<div className="text-xs text-[var(--dg-text-secondary)] mt-1 bg-[var(--dg-bg-tertiary)] rounded px-2 py-1">{ticket.reason}</div>
<div className="text-xs text-[var(--dg-text-secondary)] mt-1 bg-[var(--dg-bg-tertiary)] rounded px-2 py-1">
{ticket.reason}
</div>
)}
</div>
</div>
{/* Group approvals by step_number */}
{Object.values(
approvals.reduce((acc, a) => {
approvals.reduce(
(acc, a) => {
const key = `${a.step_number}_${a.step_name || ''}`;
if (!acc[key]) acc[key] = { step: a, users: [] };
acc[key].users.push(a);
return acc;
}, {} as Record<string, { step: ApprovalRecord; users: ApprovalRecord[] }>)
},
{} as Record<string, { step: ApprovalRecord; users: ApprovalRecord[] }>,
),
).map(({ step, users }, idx, arr) => {
const isCompleted = users.some(u => u.action);
const actor = users.find(u => u.actual_approver_id);
const isCompleted = users.some((u) => u.action);
const actor = users.find((u) => u.actual_approver_id);
const StepIcon = step.step_type === 'execute' ? Play : Eye;
const isLast = idx === arr.length - 1;
return (
<div key={step.id} className="flex gap-3">
<div className="flex flex-col items-center">
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${
isCompleted ? 'bg-green-500/15 text-green-400' :
step.action === 'reject' ? 'bg-red-500/15 text-red-400' :
'bg-blue-500/15 text-blue-400'
}`}>
<div
className={`w-6 h-6 rounded-full flex items-center justify-center ${
isCompleted
? 'bg-green-500/15 text-green-400'
: step.action === 'reject'
? 'bg-red-500/15 text-red-400'
: 'bg-blue-500/15 text-blue-400'
}`}
>
<StepIcon className="w-3 h-3" />
</div>
{!isLast && <div className="w-[2px] flex-1 bg-[var(--dg-border)]" />}
</div>
<div className={`flex-1 ${!isLast ? 'pb-3' : ''}`}>
<div className={`text-xs font-medium ${
isCompleted ? 'text-green-400' :
step.action === 'reject' ? 'text-red-400' :
'text-blue-400'
}`}>
<div
className={`text-xs font-medium ${
isCompleted
? 'text-green-400'
: step.action === 'reject'
? 'text-red-400'
: 'text-blue-400'
}`}
>
{step.step_name || (step.step_type === 'execute' ? '执行变更' : '审批')}
{!isCompleted && <span className="text-[var(--dg-text-muted)] font-normal ml-1">(待处理)</span>}
{!isCompleted && (
<span className="text-[var(--dg-text-muted)] font-normal ml-1">(待处理)</span>
)}
</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
指派人: {users.map(u => u.approver_name).filter(Boolean).join(', ') || '-'}
指派人:{' '}
{users
.map((u) => u.approver_name)
.filter(Boolean)
.join(', ') || '-'}
</div>
{actor && actor.completed_at && (
<div className="text-xs text-[var(--dg-text-secondary)] mt-0.5">
{actor.actual_approver_name} · {actor.action === 'approve' ? '通过' : actor.action === 'reject' ? '拒绝' : '执行'} · {new Date(actor.completed_at).toLocaleString()}
{actor.comment && <span className="ml-1 text-[var(--dg-text-muted)]">({actor.comment})</span>}
{actor.actual_approver_name} ·{' '}
{actor.action === 'approve' ? '通过' : actor.action === 'reject' ? '拒绝' : '执行'} ·{' '}
{new Date(actor.completed_at).toLocaleString()}
{actor.comment && (
<span className="ml-1 text-[var(--dg-text-muted)]">({actor.comment})</span>
)}
</div>
)}
</div>
@@ -363,5 +453,26 @@ export function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove,
</div>
</div>
</ModalOverlay>
{reviewAction && (
<QuickReviewDialog
ticket={ticket}
action={reviewAction}
onConfirm={confirmReview}
onClose={() => setReviewAction(null)}
/>
)}
{confirmingExecute && (
<ConfirmDialog
open
title="确认执行工单"
description={`将对实例 ${ticket.instance_name || ticket.db_type} 的库 ${ticket.target_database} 执行 #${ticket.id} 的变更语句(事务运行,失败自动回滚)。确认执行?`}
confirmText="确认执行"
variant="danger"
onConfirm={confirmExecute}
onCancel={() => setConfirmingExecute(false)}
/>
)}
</>
);
}
@@ -0,0 +1,118 @@
/**
* 工单阶段步骤条(opsx-sec 模式:active 由工单 status 推导,数据驱动而非可点击回跳)。
* 必需 props:status;已知限制:审批段不细分到每个 workflow step(细粒度见审批流卡片)。
*/
import { CheckCircle, Circle, XCircle, AlertTriangle, RotateCcw, Ban } from 'lucide-react';
export type TicketTerminalKind = 'rejected' | 'failed' | 'cancelled' | 'rollback' | null;
interface Props {
status: string;
/** 审批步数(默认 1,用于展示「多级审批」) */
approvalStepCount?: number;
className?: string;
}
/** 由 status 推导:各阶段完成态 + 终态错误信息 */
export function ticketStageState(status: string): {
active: number;
terminal: TicketTerminalKind;
terminalLabel: string;
} {
switch (status) {
case 'pending':
return { active: 1, terminal: null, terminalLabel: '' };
case 'reviewing':
return { active: 2, terminal: null, terminalLabel: '' };
case 'approved':
return { active: 3, terminal: null, terminalLabel: '' };
case 'executing':
return { active: 3, terminal: null, terminalLabel: '' };
case 'success':
case 'completed':
return { active: 4, terminal: null, terminalLabel: '' };
case 'rejected':
return { active: 2, terminal: 'rejected', terminalLabel: '已驳回' };
case 'failed':
return { active: 3, terminal: 'failed', terminalLabel: '执行失败' };
case 'cancelled':
return { active: 1, terminal: 'cancelled', terminalLabel: '已撤销' };
case 'rollback':
return { active: 4, terminal: 'rollback', terminalLabel: '已回滚' };
default:
return { active: 1, terminal: null, terminalLabel: '' };
}
}
const TERMINAL_ICON: Record<Exclude<TicketTerminalKind, null>, React.ElementType> = {
rejected: XCircle,
failed: AlertTriangle,
cancelled: Ban,
rollback: RotateCcw,
};
const TERMINAL_COLOR: Record<Exclude<TicketTerminalKind, null>, string> = {
rejected: 'text-red-400 border-red-500/40 bg-red-500/10',
failed: 'text-red-400 border-red-500/40 bg-red-500/10',
cancelled: 'text-amber-400 border-amber-500/40 bg-amber-500/10',
rollback: 'text-orange-400 border-orange-500/40 bg-orange-500/10',
};
export function TicketStageSteps({ status, approvalStepCount = 1, className = '' }: Props) {
const { active, terminal, terminalLabel } = ticketStageState(status);
const approvalLabel = approvalStepCount > 1 ? `审批(×${approvalStepCount})` : '审批';
const stages = ['提交', approvalLabel, '执行', '完成'];
return (
<div className={`flex items-center gap-1 overflow-x-auto ${className}`}>
{stages.map((label, i) => {
const index = i + 1;
const done = active > index || (active === 4 && index === 4);
const current = active === index && !terminal;
// 终态时:当前段标错误色
const failedStage = terminal !== null && active === index;
return (
<div key={label} className="flex items-center gap-1">
{i > 0 && <div className={`h-px w-5 ${done || current ? 'bg-blue-400/60' : 'bg-[var(--dg-border)]'}`} />}
<div
className={`flex min-w-max items-center gap-1.5 rounded-md px-2.5 py-1 text-xs transition-colors ${
failedStage
? TERMINAL_COLOR[terminal as Exclude<TicketTerminalKind, null>]
: current
? 'border border-blue-500/40 bg-blue-500/10 font-medium text-blue-400'
: done
? 'text-green-400'
: 'text-[var(--dg-text-muted)]'
}`}
>
{failedStage ? (
<>
{(() => {
const Icon = TERMINAL_ICON[terminal as Exclude<TicketTerminalKind, null>];
return <Icon className="h-3.5 w-3.5" />;
})()}
{terminalLabel}
</>
) : done ? (
<>
<CheckCircle className="h-3.5 w-3.5" />
{label}
</>
) : current ? (
<>
<Circle className="h-3 w-3 fill-current" />
{label}
</>
) : (
<>
<Circle className="h-3 w-3" />
{label}
</>
)}
</div>
</div>
);
})}
</div>
);
}
@@ -10,18 +10,33 @@ import { TicketRowActions, type TicketRowActionProps } from './TicketRowActions'
import { TicketStatusBadge } from './TicketStatusBadge';
import { TicketMobileList } from './TicketMobileList';
import { TicketPagination } from './TicketPagination';
import { Plus } from 'lucide-react';
/** 表格行加载骨架(静态序列) */
function TableRowSkeleton() {
return (
<tr className="border-b border-[var(--dg-border-light)]">
<td className="px-4 py-3.5"><div className="h-4 w-32 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-4 py-3.5"><div className="h-4 w-24 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-4 py-3.5"><div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-4 py-3.5"><div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-4 py-3.5"><div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-4 py-3.5"><div className="h-4 w-24 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-4 py-3.5"><div className="h-4 w-20 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-4 py-3.5">
<div className="h-4 w-32 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" />
</td>
<td className="px-4 py-3.5">
<div className="h-4 w-24 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" />
</td>
<td className="px-4 py-3.5">
<div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" />
</td>
<td className="px-4 py-3.5">
<div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" />
</td>
<td className="px-4 py-3.5">
<div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" />
</td>
<td className="px-4 py-3.5">
<div className="h-4 w-24 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" />
</td>
<td className="px-4 py-3.5">
<div className="h-4 w-20 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" />
</td>
</tr>
);
}
@@ -56,11 +71,20 @@ interface TicketTableProps extends TicketRowActionProps {
totalPages: number;
onToggleSort: () => void;
onPageChange: (page: number) => void;
/** 空态引导:跳创建页(可选) */
onCreate?: () => void;
}
export function TicketTable({
tickets, loading, page, total, totalPages,
onToggleSort, onPageChange, ...actionProps
tickets,
loading,
page,
total,
totalPages,
onToggleSort,
onPageChange,
onCreate,
...actionProps
}: TicketTableProps) {
return (
<div className="dg-card overflow-hidden">
@@ -70,27 +94,38 @@ export function TicketTable({
<table className="w-full text-sm">
<TableHeader onToggleSort={onToggleSort} />
<tbody>
{Array.from({ length: 5 }).map((_, i) => <TableRowSkeleton key={i} />)}
{Array.from({ length: 5 }).map((_, i) => (
<TableRowSkeleton key={i} />
))}
</tbody>
</table>
</div>
) : tickets.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-14">
<EmptyState type="search" />
{onCreate && (
<button onClick={onCreate} className="dg-btn dg-btn-primary dg-btn-sm">
<Plus className="w-3.5 h-3.5" />
创建变更工单
</button>
)}
</div>
) : (
<div className="hidden md:block overflow-x-auto">
<table className="w-full text-sm">
<TableHeader onToggleSort={onToggleSort} />
<tbody>
{tickets.map(ticket => (
<tr
key={ticket.id}
className="dg-table-row"
>
{tickets.map((ticket) => (
<tr key={ticket.id} className="dg-table-row">
{/* 工单编号 */}
<td className="px-4 py-3.5">
<div className="flex flex-col">
<span className="font-mono text-xs text-[var(--dg-text-primary)]">{ticket.ticket_no || `TK-${ticket.id}`}</span>
<span className="text-[11px] text-[var(--dg-text-muted)] mt-0.5 max-w-[180px] truncate">{ticket.title}</span>
<span className="font-mono text-xs text-[var(--dg-text-primary)]">
{ticket.ticket_no || `TK-${ticket.id}`}
</span>
<span className="text-[11px] text-[var(--dg-text-muted)] mt-0.5 max-w-[180px] truncate">
{ticket.title}
</span>
</div>
</td>
@@ -119,7 +154,9 @@ export function TicketTable({
{ticket.pending_steps.map((ps) => (
<div key={ps.step_number} className="flex items-center gap-1.5 text-xs">
<span className="text-[var(--dg-text-muted)] shrink-0">{ps.step_name}:</span>
<span className="text-[var(--dg-text-primary)] font-medium">{ps.approver_names.join(', ')}</span>
<span className="text-[var(--dg-text-primary)] font-medium">
{ps.approver_names.join(', ')}
</span>
</div>
))}
</div>
@@ -151,9 +188,7 @@ export function TicketTable({
</div>
)}
{!loading && tickets.length > 0 && (
<TicketMobileList tickets={tickets} {...actionProps} />
)}
{!loading && tickets.length > 0 && <TicketMobileList tickets={tickets} {...actionProps} />}
{totalPages > 1 && (
<TicketPagination page={page} total={total} totalPages={totalPages} onPageChange={onPageChange} />
+17 -354
View File
@@ -22,6 +22,7 @@ import { FormDialog } from '@/components/FormDialog';
import { toast } from '@/hooks/useToast';
import { RedisLineEditorDialog } from '@/features/redis/components/RedisLineEditorDialog';
import { RedisKeyBrowser, type RedisKeyBrowserHandle } from '@/features/redis/keyBrowser/RedisKeyBrowser';
import RedisKeyDetailPanel, { type RedisKeyDetailPanelHandle } from '@/features/redis/components/RedisKeyDetailPanel';
import { useRedisDbs } from '@/features/redis/keyBrowser/useRedisKeyBrowser';
import { RedisStructuredValue } from '@/features/redis/components/RedisStructuredValue';
import { STRING_FORMAT_OPTIONS, type RedisLineEditorState, type StringFormat } from '@/features/redis/types';
@@ -57,6 +58,8 @@ export default function RedisDetailPage() {
const [activeTab, setActiveTab] = useState<TabKey>('keys');
const [selectedDb, setSelectedDb] = useState(0);
const keyBrowserRef = useRef<RedisKeyBrowserHandle>(null);
const keyDetailRef = useRef<RedisKeyDetailPanelHandle>(null);
const [selectedKeyName, setSelectedKeyName] = useState<string | null>(null);
const dbsInfo = useRedisDbs(instanceId);
const [selectedKey, setSelectedKey] = useState<RedisKeyDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
@@ -107,28 +110,6 @@ export default function RedisDetailPage() {
[instanceId],
);
const loadKeyDetail = function (keyName: string) {
setDetailLoading(true);
redisApi
.getKeyDetail(instanceId, keyName, selectedDb)
.then(function (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(function () {
toast('error', '加载 Redis Key 详情失败');
})
.finally(function () {
setDetailLoading(false);
});
};
const handleAddKey = function () {
let parsedValue: any;
try {
@@ -154,119 +135,6 @@ export default function RedisDetailPage() {
.catch(function () {});
};
const handleDeleteKey = function (keyName: string) {
setDeleteConfirm({
msg: '确定删除该键?',
onConfirm: async () => {
redisApi
.deleteKey(instanceId, keyName, selectedDb)
.then(function () {
if ((selectedKey?.key || selectedKey?.name) === keyName) setSelectedKey(null);
keyBrowserRef.current?.removeKey(keyName);
toast('success', 'Key 已删除');
})
.catch(function () {});
},
});
return;
};
const handleCopyValue = function () {
if (!selectedKey) return;
copyText(editValue, '已复制当前值');
};
var copyText = function (text: string, message: string) {
navigator.clipboard
?.writeText(text)
.then(function () {
toast('success', message);
})
.catch(function () {
toast('warning', '复制失败,请手动复制');
});
};
const inspectCellValue = function (title: string, value: any) {
setInspectValue({ title: title, value: value });
setValueMode('json');
};
const openHashLineEditor = function (field: string, value: any) {
setLineEditor({ open: true, originalField: field, field: field, value: String(value ?? ''), format: 'Json' });
};
/** Hash 单字段编辑:走行级 HSET/HDEL,不整键重写、天然保留其余字段与 TTL */
const saveHashField = function (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: selectedDb, op, field, value })
.then(function () {
toast('success', successMessage);
setLineEditor({ open: false, originalField: '', field: '', value: '', format: 'Json' });
loadKeyDetail(keyName);
})
.catch(function () {})
.finally(function () {
setSavingKey(false);
});
};
const handleSaveHashLine = function () {
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 = function (field: string) {
setDeleteConfirm({
msg: '确定删除字段 ' + field + '?',
onConfirm: async () => {
saveHashField('del', field, '', 'Hash 字段已删除');
},
});
};
const handleSaveKey = function () {
if (!selectedKey) return;
const keyName = selectedKey.key || selectedKey.name || '';
let parsedValue: any;
try {
parsedValue = parseEditorValue(selectedKey.type, editValue);
} catch (e: any) {
toast('error', '值不是合法的 ' + (selectedKey.type === 'hash' ? 'JSON Object' : 'JSON Array'));
return;
}
setSavingKey(true);
redisApi
.updateKey(instanceId, keyName, {
db: selectedDb,
type: selectedKey.type,
value: parsedValue,
ttl: editTtl > 0 ? editTtl : undefined,
})
.then(function () {
toast('success', 'Key 已保存');
loadKeyDetail(keyName);
keyBrowserRef.current?.refresh();
})
.catch(function () {})
.finally(function () {
setSavingKey(false);
});
};
const handleCommand = function () {
if (!cmdInput.trim()) return;
const cmd = cmdInput;
@@ -326,17 +194,6 @@ export default function RedisDetailPage() {
[activeTab, instanceId],
);
const renderFormattedValue = function () {
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);
};
const getInfoValue = function (key: string, fallback: string = '-') {
const value = info[key];
return value == null || value === '' ? fallback : String(value);
@@ -491,9 +348,10 @@ export default function RedisDetailPage() {
ref={keyBrowserRef}
instanceId={instanceId}
db={selectedDb}
selectedKey={selectedKey?.key || selectedKey?.name || null}
selectedKey={selectedKeyName}
onKeySelect={function (keyName: string) {
loadKeyDetail(keyName);
setSelectedKeyName(keyName);
keyDetailRef.current?.openKey(keyName);
}}
toolbar={
<div className="mb-2 grid grid-cols-[1fr_auto] gap-2">
@@ -527,194 +385,18 @@ export default function RedisDetailPage() {
/>
</div>
<div className="dg-card min-w-0 overflow-hidden">
{!selectedKey ? (
<div className="flex h-full min-h-[520px] flex-col items-center justify-center text-center">
<FileText className="mb-3 h-10 w-10 text-[var(--dg-text-muted)]" />
<div className="text-sm font-medium">选择一个 Key 查看详情</div>
<div className="mt-1 text-xs text-[var(--dg-text-muted)]">
支持结构化查看、格式化 JSON、编辑值和 TTL
</div>
</div>
) : (
<div className="flex h-full min-h-[620px] flex-col">
<div className="border-b border-[var(--dg-border)] p-4">
<div className="flex items-center gap-3">
<span className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-2 text-xs font-semibold uppercase text-cyan-400">
{selectedKey.type}
</span>
<input
readOnly
value={selectedKey.key || selectedKey.name || ''}
className="h-10 min-w-0 flex-1 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 font-mono text-sm text-[var(--dg-text-primary)]"
<RedisKeyDetailPanel
ref={keyDetailRef}
instanceId={instanceId}
db={selectedDb}
onKeyDeleted={function (keyName: string) {
keyBrowserRef.current?.removeKey(keyName);
setSelectedKeyName(null);
}}
onRefreshBrowser={function () {
keyBrowserRef.current?.refresh();
}}
/>
<div className="flex h-10 overflow-hidden rounded-md border border-[var(--dg-border)]">
<span className="flex items-center border-r border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 text-xs text-[var(--dg-text-muted)]">
TTL
</span>
<input
type="number"
value={editTtl}
onChange={function (e) {
setEditTtl(Number(e.target.value));
}}
className="w-24 bg-[var(--dg-bg-primary)] px-3 text-sm outline-none"
/>
</div>
<button
onClick={handleCopyValue}
className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]"
title="复制值"
>
<Copy className="h-4 w-4" />
</button>
<button
onClick={handleSaveKey}
disabled={savingKey}
className="inline-flex h-10 items-center gap-2 rounded-md bg-[var(--dg-blue)] px-4 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
>
<Save className="h-4 w-4" />
保存
</button>
<button
onClick={function () {
handleDeleteKey(selectedKey?.key || selectedKey?.name || '');
}}
className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20"
title="删除 Key"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex items-center justify-between border-b border-[var(--dg-border)] px-4 py-3">
<div className="flex items-center gap-2">
<div className="flex rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-0.5">
{[
{ key: 'table' as ValueMode, label: '结构', icon: Table2 },
{ key: 'json' as ValueMode, label: '格式化', icon: Code2 },
{ key: 'raw' as ValueMode, label: '编辑', icon: FileText },
].map(function (mode) {
const Icon = mode.icon;
return (
<button
key={mode.key}
onClick={function () {
setValueMode(mode.key);
if (mode.key !== 'json') setInspectValue(null);
}}
className={
'inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-xs transition-colors ' +
(valueMode === mode.key
? 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)]'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]')
}
>
<Icon className="h-3.5 w-3.5" />
{mode.label}
</button>
);
})}
</div>
{selectedKey.type === 'string' && valueMode === 'json' && (
<select
value={stringFormat}
onChange={function (e) {
setStringFormat(e.target.value as StringFormat);
}}
className="h-8 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 text-xs text-[var(--dg-text-secondary)]"
>
{STRING_FORMAT_OPTIONS.map(function (format) {
return (
<option key={format} value={format}>
{format}
</option>
);
})}
</select>
)}
{inspectValue && valueMode === 'json' && (
<button
onClick={function () {
setInspectValue(null);
}}
className="h-8 rounded-md border border-[var(--dg-border)] px-2 text-xs text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)]"
>
返回 Key 格式化
</button>
)}
</div>
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--dg-text-muted)]" />
<input
value={valueSearch}
onChange={function (e) {
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="搜索字段或值"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto p-4 scrollbar-thin">
{detailLoading ? (
<div className="h-72 animate-pulse rounded-lg bg-[var(--dg-bg-tertiary)]" />
) : valueMode === 'raw' ? (
<textarea
value={editValue}
onChange={function (e) {
setEditValue(e.target.value);
}}
spellCheck={false}
className="min-h-[480px] w-full resize-none rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-4 font-mono text-xs leading-relaxed outline-none focus:border-[var(--dg-blue)]"
/>
) : valueMode === 'json' ? (
<div>
{inspectValue && (
<div className="mb-2 flex items-center justify-between rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-xs">
<span className="truncate font-mono text-[var(--dg-text-secondary)]">
正在查看字段:{inspectValue?.title}
</span>
<button
onClick={function () {
const iv = inspectValue;
if (!iv) return;
copyText(
typeof iv.value === 'string' ? iv.value : JSON.stringify(iv.value),
'已复制字段值',
);
}}
className="inline-flex items-center gap-1.5 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
>
<Copy className="h-3.5 w-3.5" />
复制
</button>
</div>
)}
<pre className="min-h-[480px] whitespace-pre-wrap break-all rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-4 font-mono text-xs leading-relaxed text-[var(--dg-text-secondary)]">
{renderFormattedValue()}
</pre>
</div>
) : (
<div className="overflow-hidden rounded-lg border border-[var(--dg-border)]">
<RedisStructuredValue
type={selectedKey.type}
value={selectedKey.value}
redisKey={selectedKey.key || selectedKey.name || ''}
search={valueSearch}
onCopy={copyText}
onInspect={inspectCellValue}
onEditHashLine={openHashLineEditor}
onDeleteHashLine={handleDeleteHashLine}
/>
</div>
)}
</div>
</div>
)}
</div>
</div>
)}
@@ -971,25 +653,6 @@ export default function RedisDetailPage() {
</div>
</div>
</FormDialog>
<RedisLineEditorDialog
state={lineEditor}
saving={savingKey}
onChange={setLineEditor}
onClose={() => setLineEditor({ open: false, originalField: '', field: '', value: '', format: 'Json' })}
onConfirm={handleSaveHashLine}
onCopy={(value) => copyText(value, '已复制字段值')}
/>
<ConfirmDialog
open={!!deleteConfirm}
title={deleteConfirm?.msg || '确认操作'}
variant="danger"
confirmText="确定"
onConfirm={() => {
deleteConfirm?.onConfirm();
setDeleteConfirm(null);
}}
onCancel={() => setDeleteConfirm(null)}
/>
</div>
);
}
+290 -94
View File
@@ -1,10 +1,8 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ChevronRight, Check, AlertTriangle, Shield, Database,
Loader2, Clock, Sparkles,
} from 'lucide-react';
import { ChevronRight, Check, AlertTriangle, Shield, Database, Loader2, Clock, Sparkles } from 'lucide-react';
import { ticketApi } from '@/api/ticket';
import { workflowApi } from '@/api/system/workflow';
import { instanceApi } from '@/api/instance';
import SqlEditor, { formatSQL, type SqlEditorHandle } from '@/components/SqlEditor';
import SchemaSidebar from '@/components/SchemaSidebar';
@@ -20,6 +18,7 @@ interface InstanceItem {
port: number;
db_type: string;
cluster_mode?: boolean;
workflow_id?: number | null;
}
interface AuditFinding {
@@ -52,7 +51,11 @@ const LEVEL_LABEL: Record<string, string> = {
};
const DB_TYPE_LABELS: Record<string, string> = {
mysql: 'MySQL', redis: 'Redis', postgres: 'PostgreSQL', postgresql: 'PostgreSQL', mongodb: 'MongoDB',
mysql: 'MySQL',
redis: 'Redis',
postgres: 'PostgreSQL',
postgresql: 'PostgreSQL',
mongodb: 'MongoDB',
};
/* ================================================================== */
@@ -68,11 +71,15 @@ export default function TicketCreatePage() {
const [database, setDatabase] = useState('');
const [sqlContent, setSqlContent] = useState('');
const [reason, setReason] = useState('');
const [scheduledAt, setScheduledAt] = useState('');
const [enableSchedule, setEnableSchedule] = useState(false);
// -- Data / Loading --
const [instances, setInstances] = useState<InstanceItem[]>([]);
const [rollbackSql, setRollbackSql] = useState('');
const [priority, setPriority] = useState<'high' | 'medium' | 'low'>('medium');
const [workflowPreview, setWorkflowPreview] = useState<{
name: string;
steps: Array<{ step_type: string; approvers: string[] }>;
} | null>(null);
const [databases, setDatabases] = useState<string[]>([]);
const [loadingInstances, setLoadingInstances] = useState(false);
const [loadingDatabases, setLoadingDatabases] = useState(false);
@@ -93,28 +100,74 @@ export default function TicketCreatePage() {
useEffect(() => {
setLoadingInstances(true);
instanceApi.accessible('write')
instanceApi
.accessible('write')
.then((res: unknown) => {
const data = (res as { items?: unknown[] })?.items ?? res;
setInstances(Array.isArray(data) ? data as InstanceItem[] : []);
setInstances(Array.isArray(data) ? (data as InstanceItem[]) : []);
})
.catch(() => setInstances([]))
.finally(() => setLoadingInstances(false));
}, []);
// 审批流预览:实例选定后解析其绑定的 workflow(实例 workflow_id > 默认 workflow)
useEffect(() => {
if (!instanceId) {
setWorkflowPreview(null);
return;
}
let cancelled = false;
workflowApi
.list()
.then((list: any[]) => {
if (cancelled) return;
const arr: any[] = Array.isArray(list) ? list : (list as { items?: any[] })?.items || [];
const inst = instances.find((i) => i.id === Number(instanceId));
const wf = arr.find((w: any) => w.id === inst?.workflow_id) || arr.find((w: any) => w.is_default);
if (!wf || !Array.isArray(wf.steps) || wf.steps.length === 0) {
setWorkflowPreview(null);
return;
}
setWorkflowPreview({
name: wf.name,
steps: wf.steps.map((st: any) => ({
step_type: st.step_type,
approvers: Array.isArray(st.approver_names)
? st.approver_names
: Array.isArray(st.approvers)
? st.approvers
: [],
})),
});
})
.catch(() => {
if (!cancelled) setWorkflowPreview(null);
});
return () => {
cancelled = true;
};
}, [instanceId, instances]);
const selectedInstance = useMemo(() => instances.find((i) => i.id === Number(instanceId)), [instances, instanceId]);
const dbType = selectedInstance?.db_type || '';
useEffect(() => {
if (!instanceId) { setDatabases([]); setDatabase(''); return; }
setDatabase(''); setDatabases([]);
const inst = instances.find(i => i.id === Number(instanceId));
if (!instanceId) {
setDatabases([]);
setDatabase('');
return;
}
setDatabase('');
setDatabases([]);
const inst = instances.find((i) => i.id === Number(instanceId));
if (!inst) return;
setLoadingDatabases(true);
instanceApi.listDatabases(Number(instanceId))
instanceApi
.listDatabases(Number(instanceId))
.then((res: unknown) => {
const data = (res as { databases?: string[] })?.databases || (res as string[]) || [];
const list = Array.isArray(data) ? data as string[] : [];
const list = Array.isArray(data) ? (data as string[]) : [];
setDatabases(list);
if (inst.db_type === 'redis') {
setDatabase('db0');
@@ -135,7 +188,8 @@ export default function TicketCreatePage() {
* - If database is NOT selected, keep the full "db.table" format
* - Use editorRef.insertText for proper cursor positioning
*/
const handleSchemaInsert = useCallback((text: string) => {
const handleSchemaInsert = useCallback(
(text: string) => {
let insertText = text;
if (database) {
// Database already selected — strip "db." prefix if present
@@ -152,12 +206,14 @@ export default function TicketCreatePage() {
if (editorRef.current) {
editorRef.current.insertText(insertText + ' ');
} else {
setSqlContent(prev => {
setSqlContent((prev) => {
const sep = prev.trim() ? (prev.trimEnd().endsWith('\n') ? '' : ' ') : '';
return prev + sep + insertText + ' ';
});
}
}, [database]);
},
[database],
);
const handleFormatSQL = useCallback(() => {
if (!sqlContent.trim()) return;
@@ -165,35 +221,51 @@ export default function TicketCreatePage() {
}, [sqlContent]);
// SQL 内容变化时清除上次审核结果
const handleSqlChange = useCallback((val: string) => {
const handleSqlChange = useCallback(
(val: string) => {
setSqlContent(val);
if (auditResult) setAuditResult(null);
if (validated) setValidated(false);
}, [auditResult, validated]);
},
[auditResult, validated],
);
/* ---------------------------------------------------------------- */
/* Validate: SQL 检测(不提交) */
/* ---------------------------------------------------------------- */
const handleValidate = useCallback(async () => {
if (!sqlContent.trim()) { setError('请填写 SQL 语句'); return; }
if (!instanceId) { setError('请选择目标实例'); return; }
if (!database) { setError('请选择数据库'); return; }
if (!sqlContent.trim()) {
setError('请填写 SQL 语句');
return;
}
if (!instanceId) {
setError('请选择目标实例');
return;
}
if (!database) {
setError('请选择数据库');
return;
}
// 前端拦截只读语句
const upperSQL = sqlContent.trim().toUpperCase();
const readOnlyPrefixes = ['SELECT', 'SHOW', 'DESC', 'DESCRIBE', 'EXPLAIN'];
if (readOnlyPrefixes.some(p => upperSQL.startsWith(p))) {
if (readOnlyPrefixes.some((p) => upperSQL.startsWith(p))) {
setError('数据变更工单不支持 SELECT/SHOW/DESC 等只读语句,请使用查询数据功能');
setValidated(false);
return;
}
setError(''); setAuditResult(null); setValidating(true);
setError('');
setAuditResult(null);
setValidating(true);
try {
const result = await ticketApi.audit({
sql_content: sqlContent, instance_id: Number(instanceId), database,
}) as unknown as AuditResult;
const result = (await ticketApi.audit({
sql_content: sqlContent,
instance_id: Number(instanceId),
database,
})) as unknown as AuditResult;
setAuditResult(result);
const hasErrors = result.findings?.some((f) => f.level === 'error');
@@ -207,7 +279,9 @@ export default function TicketCreatePage() {
const err = e as { response?: { data?: { detail?: string } } };
setError(err?.response?.data?.detail || 'SQL 检测失败');
setValidated(false);
} finally { setValidating(false); }
} finally {
setValidating(false);
}
}, [sqlContent, instanceId, database]);
/* ---------------------------------------------------------------- */
@@ -215,21 +289,37 @@ export default function TicketCreatePage() {
/* ---------------------------------------------------------------- */
const handleSubmit = useCallback(async () => {
if (!title.trim()) { setError('请填写工单标题'); return; }
if (!instanceId) { setError('请选择目标实例'); return; }
if (!database) { setError('请选择数据库'); return; }
if (!sqlContent.trim()) { setError('请填写 SQL 语句'); return; }
if (!reason.trim()) { setError('请填写变更原因'); return; }
if (!title.trim()) {
setError('请填写工单标题');
return;
}
if (!instanceId) {
setError('请选择目标实例');
return;
}
if (!database) {
setError('请选择数据库');
return;
}
if (!sqlContent.trim()) {
setError('请填写 SQL 语句');
return;
}
if (!reason.trim()) {
setError('请填写变更原因');
return;
}
// 前端拦截只读语句
const upperSQL = sqlContent.trim().toUpperCase();
const readOnlyPrefixes = ['SELECT', 'SHOW', 'DESC', 'DESCRIBE', 'EXPLAIN'];
if (readOnlyPrefixes.some(p => upperSQL.startsWith(p))) {
if (readOnlyPrefixes.some((p) => upperSQL.startsWith(p))) {
setError('数据变更工单不支持 SELECT/SHOW/DESC 等只读语句,请使用查询数据功能');
return;
}
setError(''); setSubmitting(true);
setError('');
setSubmitting(true);
try {
const inst = instances.find((i) => i.id === Number(instanceId));
@@ -240,29 +330,34 @@ export default function TicketCreatePage() {
target_database: database,
sql_content: sqlContent,
reason: reason.trim(),
priority: 'medium',
scheduled_at: enableSchedule && scheduledAt ? new Date(scheduledAt).toISOString() : undefined,
priority,
rollback_sql: rollbackSql.trim() || undefined,
});
navigate('/tickets');
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string | Array<{ msg: string }> } } };
const raw = err?.response?.data?.detail;
const msg = Array.isArray(raw) ? raw.map((v) => v.msg).join('; ') : (raw || '提交失败,请稍后重试');
const msg = Array.isArray(raw) ? raw.map((v) => v.msg).join('; ') : raw || '提交失败,请稍后重试';
setError(msg);
} finally { setSubmitting(false); }
}, [title, sqlContent, instanceId, database, reason, instances, navigate, enableSchedule, scheduledAt]);
} finally {
setSubmitting(false);
}
}, [title, sqlContent, instanceId, database, reason, instances, navigate, priority, rollbackSql]);
/* ---------------------------------------------------------------- */
/* Derived values */
/* ---------------------------------------------------------------- */
const isRedis = selectedInstance?.db_type === 'redis';
const isSQL = selectedInstance?.db_type === 'mysql' || selectedInstance?.db_type === 'postgresql' || selectedInstance?.db_type === 'postgres';
const isSQL =
selectedInstance?.db_type === 'mysql' ||
selectedInstance?.db_type === 'postgresql' ||
selectedInstance?.db_type === 'postgres';
const isMongo = selectedInstance?.db_type === 'mongo';
const errorCount = auditResult?.findings?.filter(f => f.level === 'error').length || 0;
const warnCount = auditResult?.findings?.filter(f => f.level === 'warning').length || 0;
const infoCount = auditResult?.findings?.filter(f => f.level === 'info').length || 0;
const errorCount = auditResult?.findings?.filter((f) => f.level === 'error').length || 0;
const warnCount = auditResult?.findings?.filter((f) => f.level === 'warning').length || 0;
const infoCount = auditResult?.findings?.filter((f) => f.level === 'info').length || 0;
/* ================================================================== */
/* MAIN RENDER */
@@ -273,24 +368,32 @@ export default function TicketCreatePage() {
return (
<div className="min-h-[calc(100vh-3rem)] overflow-y-auto">
<div className="mx-auto max-w-6xl px-3 py-4 space-y-4 sm:px-5 lg:px-6">
{/* ====== 顶部:返回 + 工单标题 ====== */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<button onClick={() => navigate('/tickets')}
<button
onClick={() => navigate('/tickets')}
className="w-8 h-8 shrink-0 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
title="返回工单列表">
title="返回工单列表"
>
<ChevronRight className="w-4 h-4 rotate-180" />
</button>
<input value={title} onChange={(e) => setTitle(e.target.value)}
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full min-w-0 flex-1 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium focus:border-blue-400 focus:outline-none transition-colors"
placeholder="输入工单标题" />
placeholder="输入工单标题"
/>
</div>
{/* ====== 工具栏:实例 + 数据库 + 操作 ====== */}
<div className="grid grid-cols-1 gap-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-3 md:grid-cols-[minmax(180px,1fr)_minmax(140px,220px)_auto_auto] md:items-center">
{/* 实例 */}
<select value={instanceId} onChange={(e) => setInstanceId(e.target.value)} disabled={loadingInstances}
className="h-10 w-full min-w-0 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none disabled:opacity-50">
<select
value={instanceId}
onChange={(e) => setInstanceId(e.target.value)}
disabled={loadingInstances}
className="h-10 w-full min-w-0 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none disabled:opacity-50"
>
<option value="">{loadingInstances ? '加载中...' : '选择实例'}</option>
{instances.map((inst) => (
<option key={inst.id} value={String(inst.id)}>
@@ -301,50 +404,72 @@ export default function TicketCreatePage() {
{/* 数据库 */}
{isRedis ? (
<select value={database} onChange={(e) => setDatabase(e.target.value)}
className="h-10 w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
{databases.length > 0 ? databases.map((db) => <option key={db} value={db}>{db}</option>) : <option value="db0">db0</option>}
<select
value={database}
onChange={(e) => setDatabase(e.target.value)}
className="h-10 w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
>
{databases.length > 0 ? (
databases.map((db) => (
<option key={db} value={db}>
{db}
</option>
))
) : (
<option value="db0">db0</option>
)}
</select>
) : databases.length > 0 ? (
<select value={database} onChange={(e) => setDatabase(e.target.value)} disabled={loadingDatabases}
className="h-10 w-full min-w-0 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none disabled:opacity-50">
<select
value={database}
onChange={(e) => setDatabase(e.target.value)}
disabled={loadingDatabases}
className="h-10 w-full min-w-0 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none disabled:opacity-50"
>
<option value="">{loadingDatabases ? '加载中...' : '选择数据库 *'}</option>
{databases.map((db) => <option key={db} value={db}>{db}</option>)}
{databases.map((db) => (
<option key={db} value={db}>
{db}
</option>
))}
</select>
) : (
<input value={database} onChange={(e) => setDatabase(e.target.value)} disabled={!instanceId}
<input
value={database}
onChange={(e) => setDatabase(e.target.value)}
disabled={!instanceId}
className="h-10 w-full min-w-0 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none disabled:opacity-50"
placeholder={instanceId ? '数据库名' : '先选实例'} />
placeholder={instanceId ? '数据库名' : '先选实例'}
/>
)}
{/* 定时执行 */}
<label className="flex h-10 items-center gap-1.5 cursor-pointer select-none rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 text-xs text-[var(--dg-text-muted)]">
<input type="checkbox" checked={enableSchedule} onChange={(e) => setEnableSchedule(e.target.checked)}
className="w-3.5 h-3.5 rounded border-[var(--dg-border)] bg-[var(--dg-bg-primary)] text-blue-500 focus:ring-blue-500 focus:ring-offset-0" />
<Clock className="w-3.5 h-3.5" />
定时
</label>
{enableSchedule && (
<input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)}
step="1" min={new Date().toISOString().slice(0, 16)}
className="h-10 w-full px-2 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-xs focus:border-blue-400 focus:outline-none md:col-span-2" />
)}
{enableSchedule && scheduledAt && (
<span className="text-xs font-semibold text-red-500">
{new Date(scheduledAt).toLocaleString('zh-CN')}
</span>
)}
{/* 优先级 */}
<select
value={priority}
onChange={(e) => setPriority(e.target.value as 'high' | 'medium' | 'low')}
className="h-10 w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
title="工单优先级"
>
<option value="high">高优先级</option>
<option value="medium">中优先级</option>
<option value="low">低优先级</option>
</select>
<div className="flex flex-wrap gap-2 md:col-span-4">
{instanceId && (
<button onClick={() => setShowBrowser(!showBrowser)}
className={`dg-btn dg-btn-sm ${showBrowser ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : 'dg-btn-ghost'}`}>
<button
onClick={() => setShowBrowser(!showBrowser)}
className={`dg-btn dg-btn-sm ${showBrowser ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : 'dg-btn-ghost'}`}
>
<Database className="w-3.5 h-3.5" />
{showBrowser ? '隐藏浏览' : '库表浏览'}
</button>
)}
<button onClick={handleFormatSQL} disabled={!sqlContent.trim()}
className="dg-btn dg-btn-sm dg-btn-ghost text-blue-400 disabled:opacity-30">
<button
onClick={handleFormatSQL}
disabled={!sqlContent.trim()}
className="dg-btn dg-btn-sm dg-btn-ghost text-blue-400 disabled:opacity-30"
>
<Sparkles className="w-3 h-3" /> 格式化
</button>
</div>
@@ -353,7 +478,8 @@ export default function TicketCreatePage() {
{/* ====== 错误提示 ====== */}
{error && (
<div className="flex items-center gap-2 text-sm text-red-400 bg-red-500/10 px-4 py-2.5 rounded-lg">
<AlertTriangle className="w-4 h-4 shrink-0" />{error}
<AlertTriangle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
@@ -362,9 +488,14 @@ export default function TicketCreatePage() {
{/* 浏览面板 */}
{showBrowser && instanceId && (
<div className="min-h-[260px] xl:w-[300px] xl:shrink-0">
<SchemaSidebar instanceId={instanceId} database={database} dbType={dbType}
<SchemaSidebar
instanceId={instanceId}
database={database}
dbType={dbType}
clusterMode={selectedInstance?.cluster_mode}
onInsert={handleSchemaInsert} singleDb />
onInsert={handleSchemaInsert}
singleDb
/>
</div>
)}
@@ -374,16 +505,23 @@ export default function TicketCreatePage() {
{/* 编辑器顶栏:标题 + SQL检测按钮 */}
<div className="flex flex-col gap-2 px-3 py-2 bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)] sm:flex-row sm:items-center sm:justify-between">
<span className="text-xs font-medium text-[var(--dg-text-muted)]">SQL 变更语句</span>
<button onClick={handleValidate}
<button
onClick={handleValidate}
disabled={validating || !sqlContent.trim() || !instanceId || !database}
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-blue-400 bg-blue-500/10 rounded-md hover:bg-blue-500/20 transition-colors disabled:opacity-30">
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-blue-400 bg-blue-500/10 rounded-md hover:bg-blue-500/20 transition-colors disabled:opacity-30"
>
{validating ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Shield className="w-3.5 h-3.5" />}
SQL 检测
</button>
</div>
<SqlEditor ref={editorRef} value={sqlContent} onChange={handleSqlChange} language="sql"
<SqlEditor
ref={editorRef}
value={sqlContent}
onChange={handleSqlChange}
language="sql"
height="320px"
placeholder="请输入 SQL 变更语句,支持多条 SQL(以分号分隔)..." />
placeholder="请输入 SQL 变更语句,支持多条 SQL(以分号分隔)..."
/>
{/* 编辑器底栏 */}
<div className="flex flex-col gap-1 px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-[11px] text-[var(--dg-text-muted)] sm:flex-row sm:items-center sm:justify-between">
<span>
@@ -391,7 +529,12 @@ export default function TicketCreatePage() {
{sqlContent.length} 字符
</span>
<div className="flex flex-wrap items-center gap-2">
{validated && <span className="flex items-center gap-1 text-green-400"><Check className="w-3 h-3" />检测通过</span>}
{validated && (
<span className="flex items-center gap-1 text-green-400">
<Check className="w-3 h-3" />
检测通过
</span>
)}
<span>Ctrl+Enter 格式化</span>
</div>
</div>
@@ -408,13 +551,21 @@ export default function TicketCreatePage() {
{warnCount > 0 && <span className="text-amber-400 font-medium">{warnCount} 警告</span>}
{infoCount > 0 && <span className="text-blue-400 font-medium">{infoCount} 提示</span>}
{errorCount === 0 && warnCount === 0 && infoCount === 0 && (
<span className="text-green-400 font-medium flex items-center gap-1"><Check className="w-3.5 h-3.5" /> 通过</span>
<span className="text-green-400 font-medium flex items-center gap-1">
<Check className="w-3.5 h-3.5" /> 通过
</span>
)}
</div>
{auditResult.findings && auditResult.findings.length > 0 && (
<div className="space-y-1.5">
{auditResult.findings.map((f, idx) => (
<div key={idx} className={'flex flex-col gap-1 px-3 py-2 rounded-lg text-xs sm:flex-row sm:items-center sm:gap-2 ' + (LEVEL_COLOR[f.level] || '')}>
<div
key={idx}
className={
'flex flex-col gap-1 px-3 py-2 rounded-lg text-xs sm:flex-row sm:items-center sm:gap-2 ' +
(LEVEL_COLOR[f.level] || '')
}
>
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
<span className="font-medium">{LEVEL_LABEL[f.level] || f.level}</span>
<span className="opacity-60">·</span>
@@ -435,16 +586,61 @@ export default function TicketCreatePage() {
<label className="block text-sm font-medium mb-1.5">
变更原因 <span className="text-red-400">*</span>
</label>
<textarea value={reason} onChange={(e) => setReason(e.target.value)}
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm min-h-[72px] resize-y focus:outline-none focus:border-blue-400 transition-colors"
placeholder="请描述变更原因、影响范围、回滚方案等" />
placeholder="请描述变更原因、影响范围、回滚方案等"
/>
</div>
{/* ====== 回滚 SQL(可选,执行后可一键回滚) ====== */}
{isSQL && (
<div>
<label className="block text-sm font-medium mb-1.5">
回滚 SQL <span className="text-xs dg-text-muted font-normal">(可选,执行成功后可在详情页发起回滚)</span>
</label>
<textarea
value={rollbackSql}
onChange={(e) => setRollbackSql(e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm min-h-[64px] resize-y font-mono focus:outline-none focus:border-blue-400 transition-colors"
placeholder={'-- 例:ALTER TABLE t DROP COLUMN new_col; 或 INSERT 回补数据的语句'}
/>
</div>
)}
{/* ====== 审批流预览(选定实例后可见谁会审) ====== */}
{instanceId && (
<div className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-4 py-3">
<div className="mb-2 text-sm font-medium">审批流预览</div>
{workflowPreview === null ? (
<div className="text-xs dg-text-muted">该实例未配置专属审批流,将走默认审批(第一位管理员初审)</div>
) : (
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="rounded-md bg-blue-500/10 px-2 py-1 font-medium text-blue-400">
{workflowPreview.name}
</span>
{workflowPreview.steps.map((st, i) => (
<span key={i} className="flex items-center gap-1.5">
{i > 0 && <span className="dg-text-muted">→</span>}
<span className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-2 py-1">
{st.step_type === 'audit' ? '审核' : st.step_type === 'execute' ? '执行' : '提交'}:
<span className="text-[var(--dg-text-secondary)]">{st.approvers.join(' / ') || '待定'}</span>
</span>
</span>
))}
</div>
)}
</div>
)}
{/* ====== 提交审批 ====== */}
<div className="flex items-center justify-end">
<button onClick={handleSubmit}
<button
onClick={handleSubmit}
disabled={submitting || !validated}
className="flex w-full items-center justify-center gap-1.5 px-6 py-2.5 text-sm font-medium bg-[var(--dg-blue)] rounded-lg text-white hover:opacity-90 disabled:opacity-40 transition-all sm:w-auto">
className="flex w-full items-center justify-center gap-1.5 px-6 py-2.5 text-sm font-medium bg-[var(--dg-blue)] rounded-lg text-white hover:opacity-90 disabled:opacity-40 transition-all sm:w-auto"
>
{submitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
提交审批
</button>
+31 -15
View File
@@ -7,6 +7,7 @@ import { useAuth } from '@/hooks/useAuth';
import type { TicketData, TimelineEntry, ApprovalStep, RelatedTicket } from '@/features/ticket/components/DetailTypes';
import DetailApprovalPanel from '@/features/ticket/components/DetailApprovalPanel';
import DetailApprovalFlow from '@/features/ticket/components/DetailApprovalFlow';
import { TicketStageSteps } from '@/features/ticket/components/TicketStageSteps';
import DetailTimeline from '@/features/ticket/components/DetailTimeline';
import DetailAuditResult from '@/features/ticket/components/DetailAuditResult';
import DetailExecuteResult from '@/features/ticket/components/DetailExecuteResult';
@@ -47,7 +48,7 @@ export default function TicketDetailPage() {
const fetchTicket = useCallback(async () => {
try {
const data = await ticketApi.get(ticketId) as unknown as TicketData;
const data = (await ticketApi.get(ticketId)) as unknown as TicketData;
setTicket(data);
setError('');
} catch {
@@ -60,7 +61,7 @@ export default function TicketDetailPage() {
const fetchTimeline = useCallback(async () => {
try {
const res = await ticketApi.timeline(ticketId) as { timeline?: TimelineEntry[] };
const res = (await ticketApi.timeline(ticketId)) as { timeline?: TimelineEntry[] };
setTimeline(res?.timeline || []);
} catch {
setTimeline([]);
@@ -79,7 +80,7 @@ export default function TicketDetailPage() {
// Fetch related tickets from API
const fetchRelated = useCallback(async () => {
try {
const res = await ticketApi.list({ instance_id: ticket?.instance_id, limit: 5 }) as { items?: any[] };
const res = (await ticketApi.list({ instance_id: ticket?.instance_id, limit: 5 })) as { items?: any[] };
const items = (res?.items || [])
.filter((t: any) => t.id !== ticketId)
.slice(0, 5)
@@ -110,11 +111,13 @@ export default function TicketDetailPage() {
/* Action handler */
/* ---------------------------------------------------------------- */
const handleAction = useCallback(async (action: string) => {
const handleAction = useCallback(
async (action: string) => {
if (!ticket) return;
// Reset comment state
const actionComment = comment.trim() || (action === 'reject' ? '审批拒绝' : action === 'approve' ? '审批通过' : '');
const actionComment =
comment.trim() || (action === 'reject' ? '审批拒绝' : action === 'approve' ? '审批通过' : '');
setActionLoading(action);
setError('');
@@ -152,7 +155,9 @@ export default function TicketDetailPage() {
setActionLoading(null);
setPendingAction(null);
}
}, [ticket, ticketId, comment, fetchTicket, fetchTimeline]);
},
[ticket, ticketId, comment, fetchTicket, fetchTimeline],
);
/* ---------------------------------------------------------------- */
/* Permission checks */
@@ -166,10 +171,13 @@ export default function TicketDetailPage() {
// 角色权限统一走 hasActionPermission(actionPermissionMap 单源),此处只组合工单状态
const applicant = ticket.applicant_id === auth.user.id;
return {
canApprove: hasActionPermission('ticket.approve') && (ticket.status === 'pending' || ticket.status === 'reviewing'),
canApprove:
hasActionPermission('ticket.approve') && (ticket.status === 'pending' || ticket.status === 'reviewing'),
canExecute: hasActionPermission('ticket.execute') && ticket.status === 'approved',
canRollback: hasActionPermission('ticket.rollback') && (ticket.status === 'success' || ticket.status === 'failed'),
canCancel: applicant && (ticket.status === 'pending' || ticket.status === 'reviewing' || ticket.status === 'approved'),
canRollback:
hasActionPermission('ticket.rollback') && (ticket.status === 'success' || ticket.status === 'failed'),
canCancel:
applicant && (ticket.status === 'pending' || ticket.status === 'reviewing' || ticket.status === 'approved'),
};
}, [ticket, auth.user.id, hasActionPermission]);
@@ -207,6 +215,10 @@ export default function TicketDetailPage() {
return (
<div className="space-y-5 max-w-5xl">
{/* 阶段步骤条:由工单状态驱动(提交→审批→执行→完成) */}
<div className="dg-card px-4 py-3">
<TicketStageSteps status={ticket.status || 'pending'} approvalStepCount={Math.max(1, approvalSteps.length)} />
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 text-sm text-red-400 bg-red-500/10 px-4 py-3 rounded-lg border border-red-500/20">
@@ -250,8 +262,15 @@ export default function TicketDetailPage() {
comment={comment}
rollbackConfirmVisible={showRollbackConfirm}
onAction={handleAction}
onStartComment={(action) => { setPendingAction(action); setShowCommentInput(true); }}
onCancelComment={() => { setShowCommentInput(false); setComment(''); setPendingAction(null); }}
onStartComment={(action) => {
setPendingAction(action);
setShowCommentInput(true);
}}
onCancelComment={() => {
setShowCommentInput(false);
setComment('');
setPendingAction(null);
}}
onCommentChange={setComment}
onRequestRollback={() => setShowRollbackConfirm(true)}
onCancelRollback={() => setShowRollbackConfirm(false)}
@@ -278,10 +297,7 @@ export default function TicketDetailPage() {
{/* 审批流程 / 时间线 / 关联工单 */}
<DetailApprovalFlow steps={approvalSteps} />
<DetailTimeline timeline={timeline} />
<DetailRelatedTickets
tickets={relatedTickets}
onSelect={(id) => navigate(`/tickets/${id}`)}
/>
<DetailRelatedTickets tickets={relatedTickets} onSelect={(id) => navigate(`/tickets/${id}`)} />
</div>
);
}
+118 -46
View File
@@ -13,6 +13,8 @@ import { TicketStatsCards } from '@/features/ticket/components/TicketStatsCards'
import { TicketFilterBar } from '@/features/ticket/components/TicketFilterBar';
import { TicketTable } from '@/features/ticket/components/TicketTable';
import { TicketDetailDialog } from '@/features/ticket/components/TicketDetailDialog';
import { QuickReviewDialog } from '@/features/ticket/components/QuickReviewDialog';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import type { TicketItem, TicketListResponse, TicketStatistics } from '@/features/ticket/types';
export default function TicketsPage() {
@@ -23,10 +25,8 @@ export default function TicketsPage() {
const canExecute = useActionPermission('ticket.execute');
// 筛选/分页/排序状态全部由 URL query 驱动(q / status / page / sort)
const {
search, statusFilter, page, sortAsc,
changeSearch, changeStatus, toggleSort, changePage,
} = useTicketListParams();
const { search, statusFilter, page, sortAsc, changeSearch, changeStatus, toggleSort, changePage } =
useTicketListParams();
const pageSize = TICKET_PAGE_SIZE;
const [tickets, setTickets] = useState<TicketItem[]>([]);
@@ -36,15 +36,19 @@ export default function TicketsPage() {
// Detail dialog
const [detailTicket, setDetailTicket] = useState<TicketItem | null>(null);
// 快捷审批(评论+确认)与执行确认(影响面复述)
const [reviewTarget, setReviewTarget] = useState<{ ticket: TicketItem; action: 'approve' | 'reject' } | null>(null);
const [executeTarget, setExecuteTarget] = useState<TicketItem | null>(null);
const fetchTickets = useCallback(async () => {
setLoading(true);
try {
const res = await ticketApi.list({
page, page_size: pageSize,
const res = (await ticketApi.list({
page,
page_size: pageSize,
status: statusFilter || undefined,
keyword: search || undefined,
}) as unknown as TicketListResponse;
})) as unknown as TicketListResponse;
setTickets(res.items || []);
setTotal(res.total);
} catch {
@@ -56,22 +60,29 @@ export default function TicketsPage() {
const fetchStats = useCallback(async () => {
try {
const res = await ticketApi.statistics() as unknown as TicketStatistics;
const res = (await ticketApi.statistics()) as unknown as TicketStatistics;
setStats(res);
} catch {
/* ignore */
}
}, []);
useEffect(() => { fetchTickets(); }, [fetchTickets]);
useEffect(() => { fetchStats(); }, [fetchStats]);
useEffect(() => {
fetchTickets();
}, [fetchTickets]);
useEffect(() => {
fetchStats();
}, [fetchStats]);
// executing 状态工单自动轮询(3秒)
// 与拆分前完全一致:interval 仅在存在 executing 工单时创建,依赖变化时清理
const hasExecuting = tickets.some(t => t.status === 'executing');
const hasExecuting = tickets.some((t) => t.status === 'executing');
useEffect(() => {
if (!hasExecuting) return;
const timer = setInterval(() => { fetchTickets(); fetchStats(); }, 3000);
const timer = setInterval(() => {
fetchTickets();
fetchStats();
}, 3000);
return () => clearInterval(timer);
}, [hasExecuting, fetchTickets, fetchStats]);
@@ -89,63 +100,99 @@ export default function TicketsPage() {
}, [tickets, sortAsc]);
/* ---- 统计卡片点击:再点同卡取消筛选(与拆分前 toggle 语义一致) ---- */
const handleSelectStatus = useCallback((status: string) => {
const handleSelectStatus = useCallback(
(status: string) => {
changeStatus(statusFilter === status ? '' : status);
}, [changeStatus, statusFilter]);
},
[changeStatus, statusFilter],
);
/* ---- Open detail dialog ---- */
const openDetail = useCallback(async (ticketId: number) => {
try {
const detail = await ticketApi.get(ticketId) as unknown as TicketItem;
const detail = (await ticketApi.get(ticketId)) as unknown as TicketItem;
setDetailTicket(detail);
} catch { /* ignore */ }
} catch {
/* ignore */
}
}, []);
/* ---- 行内操作:调用 API 后刷新列表与统计(与拆分前内联逻辑一致) ---- */
const handleViewDetail = useCallback((ticket: TicketItem) => { openDetail(ticket.id); }, [openDetail]);
const handleViewDetail = useCallback(
(ticket: TicketItem) => {
openDetail(ticket.id);
},
[openDetail],
);
const handleApprove = useCallback(async (ticket: TicketItem) => {
// 行内通过/拒绝:先弹快捷审批(可选评论),错误提示由请求层拦截器统一 toast
const handleApprove = useCallback((ticket: TicketItem) => {
setReviewTarget({ ticket, action: 'approve' });
}, []);
const handleReject = useCallback((ticket: TicketItem) => {
setReviewTarget({ ticket, action: 'reject' });
}, []);
const confirmReview = useCallback(
async (comment: string) => {
if (!reviewTarget) return;
try {
await ticketApi.review(ticket.id, { action: 'approve', comment: '通过' });
fetchTickets(); fetchStats();
} catch (e: any) {
const msg = e?.response?.data?.detail || e?.message || '审批失败';
alert(msg);
await ticketApi.review(reviewTarget.ticket.id, { action: reviewTarget.action, comment });
fetchTickets();
fetchStats();
} catch {
/* 拦截器已 toast */
}
}, [fetchTickets, fetchStats]);
setReviewTarget(null);
},
[reviewTarget, fetchTickets, fetchStats],
);
const handleReject = useCallback(async (ticket: TicketItem) => {
// 执行为高危操作:先弹影响面确认(opsx-sec 带摘要的确认模式)
const handleExecute = useCallback((ticket: TicketItem) => {
setExecuteTarget(ticket);
}, []);
const confirmExecute = useCallback(async () => {
if (!executeTarget) return;
try {
await ticketApi.review(ticket.id, { action: 'reject', comment: '拒绝' });
fetchTickets(); fetchStats();
} catch (e: any) {
const msg = e?.response?.data?.detail || e?.message || '操作失败';
alert(msg);
await ticketApi.execute(executeTarget.id);
fetchTickets();
fetchStats();
} catch {
/* 拦截器已 toast */
}
}, [fetchTickets, fetchStats]);
setExecuteTarget(null);
}, [executeTarget, fetchTickets, fetchStats]);
const handleExecute = useCallback(async (ticket: TicketItem) => {
try {
await ticketApi.execute(ticket.id);
fetchTickets(); fetchStats();
} catch { /* ignore */ }
}, [fetchTickets, fetchStats]);
const handleCancel = useCallback(async (ticket: TicketItem) => {
const handleCancel = useCallback(
async (ticket: TicketItem) => {
try {
await ticketApi.cancel(ticket.id);
fetchTickets(); fetchStats();
} catch { /* ignore */ }
}, [fetchTickets, fetchStats]);
fetchTickets();
fetchStats();
} catch {
/* ignore */
}
},
[fetchTickets, fetchStats],
);
return (
<div className="space-y-5">
<PageHeader
title="数据变更工单"
description="管理 SQL 变更工单,提交审核、查看审批进度与执行结果"
action={canCreate ? {
label: '新建工单', icon: Plus, onClick: () => navigate('/tickets/create'), permission: 'ticket.create'
} : undefined}
action={
canCreate
? {
label: '新建工单',
icon: Plus,
onClick: () => navigate('/tickets/create'),
permission: 'ticket.create',
}
: undefined
}
/>
{/* Status Summary Cards */}
@@ -187,7 +234,32 @@ export default function TicketsPage() {
onClose={() => setDetailTicket(null)}
canApprove={canApprove}
canExecute={canExecute}
onActionDone={() => { setDetailTicket(null); fetchTickets(); fetchStats(); }}
onActionDone={() => {
setDetailTicket(null);
fetchTickets();
fetchStats();
}}
/>
)}
{reviewTarget && (
<QuickReviewDialog
ticket={reviewTarget.ticket}
action={reviewTarget.action}
onConfirm={confirmReview}
onClose={() => setReviewTarget(null)}
/>
)}
{executeTarget && (
<ConfirmDialog
open
title="确认执行工单"
description={`将对实例 ${executeTarget.instance_name || executeTarget.db_type} 的库 ${executeTarget.target_database} 执行 #${executeTarget.id} 的变更语句(执行在事务中运行,失败自动回滚)。确认执行?`}
confirmText="确认执行"
variant="danger"
onConfirm={confirmExecute}
onCancel={() => setExecuteTarget(null)}
/>
)}
</div>