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:
1 parent
f6d636fa44
commit
a4090123ba
10 files changed
+1604
-903
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 {
|
import { Clock, CheckCircle, XCircle, Play, RotateCcw, FileCode, Shield, User, Calendar } from 'lucide-react';
|
||||||
Clock, CheckCircle, XCircle, Play, RotateCcw, FileCode,
|
|
||||||
Shield, User, Calendar,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import type { TimelineEntry } from './DetailTypes';
|
import type { TimelineEntry } from './DetailTypes';
|
||||||
|
|
||||||
interface DetailTimelineProps {
|
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 }> = {
|
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' },
|
created: { icon: FileCode, color: 'text-blue-400', bg: 'bg-blue-500/15' },
|
||||||
REVIEW_TICKET: { icon: Shield, color: 'text-green-400', bg: 'bg-green-500/15' },
|
pending_review: { icon: Shield, color: 'text-amber-400', bg: 'bg-amber-500/15' },
|
||||||
EXECUTE_TICKET: { icon: Play, color: 'text-cyan-400', bg: 'bg-cyan-500/15' },
|
approve: { icon: Shield, color: 'text-green-400', bg: 'bg-green-500/15' },
|
||||||
CANCEL_TICKET: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/15' },
|
reject: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/15' },
|
||||||
ROLLBACK_TICKET: { icon: RotateCcw, color: 'text-orange-400', bg: 'bg-orange-500/15' },
|
executed: { icon: Play, color: 'text-cyan-400', bg: 'bg-cyan-500/15' },
|
||||||
REJECT_TICKET: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-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(无时间时退化为索引)
|
// 时间线无独立 id,用「动作 + 时间」组合作为稳定 key(无时间时退化为索引)
|
||||||
<div key={`${entry.action}-${entry.time ?? idx}`} className={`relative ${isLast ? '' : 'mb-6'}`}>
|
<div key={`${entry.action}-${entry.time ?? idx}`} className={`relative ${isLast ? '' : 'mb-6'}`}>
|
||||||
{/* Icon node */}
|
{/* 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}`} />
|
<Icon className={`w-3.5 h-3.5 ${config.color}`} />
|
||||||
</div>
|
</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 通知页面刷新
|
* 打开时拉取完整详情;审批/执行完成后经 onActionDone 通知页面刷新
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import { Eye, CheckCircle, XCircle, Play, FileCode, Clock, X, Loader2, AlertTriangle } from 'lucide-react';
|
||||||
Eye, CheckCircle, XCircle, Play,
|
|
||||||
FileCode, Clock, X, Loader2, AlertTriangle,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { ticketApi } from '@/api/ticket';
|
import { ticketApi } from '@/api/ticket';
|
||||||
|
import { QuickReviewDialog } from './QuickReviewDialog';
|
||||||
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||||
import { getStatusIndex, parseExecuteResult } from '../utils';
|
import { getStatusIndex, parseExecuteResult } from '../utils';
|
||||||
import type { ApprovalRecord, TicketItem } from '../types';
|
import type { ApprovalRecord, TicketItem } from '../types';
|
||||||
@@ -21,8 +20,16 @@ interface TicketDetailDialogProps {
|
|||||||
onActionDone: () => void;
|
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 [loading, setLoading] = useState(false);
|
||||||
|
const [reviewAction, setReviewAction] = useState<'approve' | 'reject' | null>(null);
|
||||||
|
const [confirmingExecute, setConfirmingExecute] = useState(false);
|
||||||
const [ticket, setTicket] = useState<TicketItem>(initialTicket);
|
const [ticket, setTicket] = useState<TicketItem>(initialTicket);
|
||||||
const [approvals, setApprovals] = useState<ApprovalRecord[]>([]);
|
const [approvals, setApprovals] = useState<ApprovalRecord[]>([]);
|
||||||
|
|
||||||
@@ -30,10 +37,12 @@ export function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove,
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const detail = await ticketApi.get(ticket.id) as any;
|
const detail = (await ticketApi.get(ticket.id)) as any;
|
||||||
setTicket(prev => ({ ...prev, ...detail }));
|
setTicket((prev) => ({ ...prev, ...detail }));
|
||||||
setApprovals(detail.approvals || []);
|
setApprovals(detail.approvals || []);
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
}, [ticket.id]);
|
}, [ticket.id]);
|
||||||
|
|
||||||
@@ -48,320 +57,422 @@ export function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove,
|
|||||||
{ key: 'success', label: '完成', icon: CheckCircle },
|
{ key: 'success', label: '完成', icon: CheckCircle },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleReview = useCallback(async (action: 'approve' | 'reject') => {
|
// 通过/拒绝:弹快捷审批(可选评论),与列表页行为一致
|
||||||
setLoading(true);
|
const handleReview = useCallback((action: 'approve' | 'reject') => {
|
||||||
try {
|
setReviewAction(action);
|
||||||
await ticketApi.review(ticket.id, { action, comment: action === 'approve' ? '审批通过' : '拒绝' });
|
}, []);
|
||||||
onActionDone();
|
|
||||||
} catch { /* ignore */ } finally { setLoading(false); }
|
|
||||||
}, [ticket.id, onActionDone]);
|
|
||||||
|
|
||||||
const handleExecute = useCallback(async () => {
|
const confirmReview = useCallback(
|
||||||
|
async (comment: string) => {
|
||||||
|
if (!reviewAction) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await ticketApi.review(ticket.id, { action: reviewAction, comment });
|
||||||
|
setReviewAction(null);
|
||||||
|
onActionDone();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[reviewAction, ticket.id, onActionDone],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 执行:影响面复述确认(opsx-sec 模式)
|
||||||
|
const handleExecute = useCallback(() => {
|
||||||
|
setConfirmingExecute(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmExecute = useCallback(async () => {
|
||||||
|
setConfirmingExecute(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await ticketApi.execute(ticket.id);
|
await ticketApi.execute(ticket.id);
|
||||||
onActionDone();
|
onActionDone();
|
||||||
} catch { /* ignore */ } finally { setLoading(false); }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}, [ticket.id, onActionDone]);
|
}, [ticket.id, onActionDone]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalOverlay open={true} onClose={onClose} width="max-w-3xl" className="border border-[var(--dg-border)] overflow-hidden flex flex-col">
|
<>
|
||||||
<div
|
<ModalOverlay
|
||||||
className="w-full max-h-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col"
|
open={true}
|
||||||
|
onClose={onClose}
|
||||||
|
width="max-w-3xl"
|
||||||
|
className="border border-[var(--dg-border)] overflow-hidden flex flex-col"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
<div className="w-full max-h-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col">
|
||||||
<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)]">
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3">
|
<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)]">
|
||||||
<span className="font-mono text-xs text-[var(--dg-text-muted)]">{ticket.ticket_no || `TK-${ticket.id}`}</span>
|
<div className="flex items-center gap-3">
|
||||||
<TicketStatusBadge status={ticket.status} />
|
<span className="font-mono text-xs text-[var(--dg-text-muted)]">
|
||||||
</div>
|
{ticket.ticket_no || `TK-${ticket.id}`}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
</span>
|
||||||
{canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
<TicketStatusBadge status={ticket.status} />
|
||||||
<>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleReview('approve')}
|
||||||
|
disabled={loading}
|
||||||
|
className="dg-btn dg-btn-sm bg-green-500/15 text-green-400 border border-green-500/30 hover:bg-green-500/25 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" /> 通过
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleReview('reject')}
|
||||||
|
disabled={loading}
|
||||||
|
className="dg-btn dg-btn-sm bg-red-500/15 text-red-400 border border-red-500/30 hover:bg-red-500/25 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<XCircle className="w-3.5 h-3.5" /> 拒绝
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{canExecute && ticket.is_executor && ticket.status === 'approved' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleReview('approve')}
|
onClick={handleExecute}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="dg-btn dg-btn-sm bg-green-500/15 text-green-400 border border-green-500/30 hover:bg-green-500/25 disabled:opacity-50"
|
className="dg-btn dg-btn-sm bg-cyan-500/15 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/25 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<CheckCircle className="w-3.5 h-3.5" /> 通过
|
<Play className="w-3.5 h-3.5" /> 执行
|
||||||
</button>
|
</button>
|
||||||
<button
|
)}
|
||||||
onClick={() => handleReview('reject')}
|
|
||||||
disabled={loading}
|
|
||||||
className="dg-btn dg-btn-sm bg-red-500/15 text-red-400 border border-red-500/30 hover:bg-red-500/25 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<XCircle className="w-3.5 h-3.5" /> 拒绝
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{canExecute && ticket.is_executor && ticket.status === 'approved' && (
|
|
||||||
<button
|
<button
|
||||||
onClick={handleExecute}
|
onClick={onClose}
|
||||||
disabled={loading}
|
className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors ml-2"
|
||||||
className="dg-btn dg-btn-sm bg-cyan-500/15 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/25 disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
<Play className="w-3.5 h-3.5" /> 执行
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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) => {
|
|
||||||
const StepIcon = step.icon;
|
|
||||||
const isCompleted = idx <= currentStepIndex && currentStepIndex >= 0;
|
|
||||||
const isCurrent = idx === currentStepIndex;
|
|
||||||
const isFailed = ticket.status === 'failed' || ticket.status === 'rejected';
|
|
||||||
const showFailed = isFailed && idx === currentStepIndex;
|
|
||||||
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)]'
|
|
||||||
}`}>
|
|
||||||
{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)]'
|
|
||||||
}`}>
|
|
||||||
{showFailed ? (ticket.status === 'rejected' ? '已拒绝' : '失败') : step.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{idx < statusDisplay.length - 1 && (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info Grid */}
|
|
||||||
<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.instance_name || '-' },
|
|
||||||
{ label: '目标库', value: ticket.target_database },
|
|
||||||
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
|
|
||||||
{ label: '创建时间', value: new Date(ticket.create_time).toLocaleString() },
|
|
||||||
].map((item) => (
|
|
||||||
<div key={item.label} className="flex justify-between gap-3 py-1.5">
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">{item.label}</span>
|
|
||||||
<span className="text-xs font-medium text-right break-words">{item.value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SQL Content */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
|
||||||
<FileCode className="w-4 h-4 text-blue-400" /> SQL 语句
|
|
||||||
</h3>
|
|
||||||
<pre className="p-4 bg-[var(--dg-bg-tertiary)] rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap max-h-[200px] overflow-y-auto">
|
|
||||||
{ticket.sql_content}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Execute Result — 结构化表格展示 */}
|
|
||||||
{execResult && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
|
||||||
<Play className="w-4 h-4 text-cyan-400" /> 执行结果
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Summary row */}
|
|
||||||
<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'}`}>
|
|
||||||
{execResult.status === 'success' ? '执行成功' : '执行失败'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<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.total_ms}ms</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-center">
|
|
||||||
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">SQL 数量</div>
|
|
||||||
<div className="text-sm font-medium">{execResult.items?.length || 0}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Per-SQL result table */}
|
|
||||||
{execResult.items && execResult.items.length > 0 && (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-[var(--dg-text-muted)] text-left">
|
|
||||||
<th className="pb-2 pr-3 font-medium w-6">#</th>
|
|
||||||
<th className="pb-2 pr-3 font-medium">SQL 语句</th>
|
|
||||||
<th className="pb-2 pr-3 font-medium w-16">执行结果</th>
|
|
||||||
<th className="pb-2 pr-3 font-medium w-16">影响行数</th>
|
|
||||||
<th className="pb-2 pr-3 font-medium w-20">执行时间</th>
|
|
||||||
<th className="pb-2 font-medium">错误信息</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{execResult.items.map((item, idx) => (
|
|
||||||
<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>
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-3">
|
|
||||||
<span className={item.status === 'success' ? 'text-green-400' : 'text-red-400'}>
|
|
||||||
{item.status === 'success' ? '成功' : '失败'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-3">{item.affected_rows}</td>
|
|
||||||
<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)]'}>
|
|
||||||
{item.error.length > 80 ? item.error.substring(0, 80) + '...' : item.error}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ticket.execute_time && (
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mt-3">
|
|
||||||
执行时间: {new Date(ticket.execute_time).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Execute Result — plain text fallback for non-JSON results */}
|
{/* Scrollable Content */}
|
||||||
{!execResult && ticket.execute_result && (
|
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-5">
|
||||||
<div>
|
{/* Status Flow */}
|
||||||
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
<div className="flex items-center justify-between overflow-x-auto pb-1">
|
||||||
<Play className="w-4 h-4 text-cyan-400" /> 执行结果
|
{statusDisplay.map((step, idx) => {
|
||||||
</h3>
|
const StepIcon = step.icon;
|
||||||
<div className={`p-3 rounded-lg text-xs font-mono whitespace-pre-wrap ${
|
const isCompleted = idx <= currentStepIndex && currentStepIndex >= 0;
|
||||||
ticket.status === 'failed' ? 'bg-red-500/5 border border-red-500/20 text-red-400' : 'bg-[var(--dg-bg-tertiary)]'
|
const isCurrent = idx === currentStepIndex;
|
||||||
}`}>
|
const isFailed = ticket.status === 'failed' || ticket.status === 'rejected';
|
||||||
{ticket.execute_result}
|
const showFailed = isFailed && idx === currentStepIndex;
|
||||||
</div>
|
return (
|
||||||
{ticket.execute_time && (
|
<div key={step.key} className="flex items-center flex-1">
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mt-2">
|
<div className="flex flex-col items-center gap-1.5">
|
||||||
执行时间: {new Date(ticket.execute_time).toLocaleString()}
|
<div
|
||||||
</div>
|
className={`w-8 h-8 rounded-full flex items-center justify-center transition-all ${
|
||||||
)}
|
showFailed
|
||||||
</div>
|
? 'bg-red-500/15 text-red-400'
|
||||||
)}
|
: isCurrent
|
||||||
|
? 'bg-blue-500/15 text-blue-400 ring-2 ring-blue-500/30'
|
||||||
{/* Failure reason for failed/rejected tickets without execute_result */}
|
: isCompleted
|
||||||
{(ticket.status === 'failed' || ticket.status === 'rejected') && !ticket.execute_result && (
|
? 'bg-green-500/15 text-green-400'
|
||||||
<div>
|
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'
|
||||||
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
}`}
|
||||||
<AlertTriangle className="w-4 h-4 text-red-400" /> 失败原因
|
>
|
||||||
</h3>
|
{showFailed ? <XCircle className="w-4 h-4" /> : <StepIcon className="w-4 h-4" />}
|
||||||
<div className="p-3 bg-red-500/5 border border-red-500/20 rounded-lg text-xs text-red-400">
|
</div>
|
||||||
{ticket.status === 'rejected' ? '工单已被拒绝' : '执行失败,请查看审批记录了解详情'}
|
<span
|
||||||
</div>
|
className={`text-[10px] font-medium ${
|
||||||
</div>
|
showFailed
|
||||||
)}
|
? 'text-red-400'
|
||||||
|
: isCurrent
|
||||||
{/* Timeline — approval history */}
|
? 'text-blue-400'
|
||||||
{approvals.length > 0 && (
|
: isCompleted
|
||||||
<div>
|
? 'text-green-400'
|
||||||
<h3 className="text-sm font-medium mb-3 flex items-center gap-1.5">
|
: 'text-[var(--dg-text-muted)]'
|
||||||
<Clock className="w-4 h-4 text-blue-400" /> 操作时间线
|
}`}
|
||||||
</h3>
|
>
|
||||||
<div className="space-y-0">
|
{showFailed ? (ticket.status === 'rejected' ? '已拒绝' : '失败') : step.label}
|
||||||
{/* Submit step */}
|
</span>
|
||||||
<div className="flex gap-3 pb-3">
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<div className="w-6 h-6 rounded-full bg-green-500/15 text-green-400 flex items-center justify-center">
|
|
||||||
<FileCode className="w-3 h-3" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[2px] flex-1 bg-[var(--dg-border)]" />
|
{idx < statusDisplay.length - 1 && (
|
||||||
</div>
|
<div
|
||||||
<div className="flex-1 pb-3">
|
className={`flex-1 h-[2px] mx-2 rounded-full transition-all ${
|
||||||
<div className="text-xs font-medium text-green-400">提交申请</div>
|
idx < currentStepIndex && currentStepIndex >= 0 ? 'bg-green-400/50' : 'bg-[var(--dg-border)]'
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
|
}`}
|
||||||
{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>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Grid */}
|
||||||
|
<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.instance_name || '-' },
|
||||||
|
{ label: '目标库', value: ticket.target_database },
|
||||||
|
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
|
||||||
|
{ label: '创建时间', value: new Date(ticket.create_time).toLocaleString() },
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.label} className="flex justify-between gap-3 py-1.5">
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">{item.label}</span>
|
||||||
|
<span className="text-xs font-medium text-right break-words">{item.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SQL Content */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
||||||
|
<FileCode className="w-4 h-4 text-blue-400" /> SQL 语句
|
||||||
|
</h3>
|
||||||
|
<pre className="p-4 bg-[var(--dg-bg-tertiary)] rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap max-h-[200px] overflow-y-auto">
|
||||||
|
{ticket.sql_content}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Execute Result — 结构化表格展示 */}
|
||||||
|
{execResult && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
||||||
|
<Play className="w-4 h-4 text-cyan-400" /> 执行结果
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Summary row */}
|
||||||
|
<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'}`}
|
||||||
|
>
|
||||||
|
{execResult.status === 'success' ? '执行成功' : '执行失败'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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.total_ms}ms</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-center">
|
||||||
|
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">SQL 数量</div>
|
||||||
|
<div className="text-sm font-medium">{execResult.items?.length || 0}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Group approvals by step_number */}
|
{/* Per-SQL result table */}
|
||||||
{Object.values(
|
{execResult.items && execResult.items.length > 0 && (
|
||||||
approvals.reduce((acc, a) => {
|
<div className="overflow-x-auto">
|
||||||
const key = `${a.step_number}_${a.step_name || ''}`;
|
<table className="w-full text-xs">
|
||||||
if (!acc[key]) acc[key] = { step: a, users: [] };
|
<thead>
|
||||||
acc[key].users.push(a);
|
<tr className="text-[var(--dg-text-muted)] text-left">
|
||||||
return acc;
|
<th className="pb-2 pr-3 font-medium w-6">#</th>
|
||||||
}, {} as Record<string, { step: ApprovalRecord; users: ApprovalRecord[] }>)
|
<th className="pb-2 pr-3 font-medium">SQL 语句</th>
|
||||||
).map(({ step, users }, idx, arr) => {
|
<th className="pb-2 pr-3 font-medium w-16">执行结果</th>
|
||||||
const isCompleted = users.some(u => u.action);
|
<th className="pb-2 pr-3 font-medium w-16">影响行数</th>
|
||||||
const actor = users.find(u => u.actual_approver_id);
|
<th className="pb-2 pr-3 font-medium w-20">执行时间</th>
|
||||||
const StepIcon = step.step_type === 'execute' ? Play : Eye;
|
<th className="pb-2 font-medium">错误信息</th>
|
||||||
const isLast = idx === arr.length - 1;
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{execResult.items.map((item, idx) => (
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<span className={item.status === 'success' ? 'text-green-400' : 'text-red-400'}>
|
||||||
|
{item.status === 'success' ? '成功' : '失败'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">{item.affected_rows}</td>
|
||||||
|
<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)]'}
|
||||||
|
>
|
||||||
|
{item.error.length > 80 ? item.error.substring(0, 80) + '...' : item.error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
return (
|
{ticket.execute_time && (
|
||||||
<div key={step.id} className="flex gap-3">
|
<div className="text-xs text-[var(--dg-text-muted)] mt-3">
|
||||||
<div className="flex flex-col items-center">
|
执行时间: {new Date(ticket.execute_time).toLocaleString()}
|
||||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${
|
</div>
|
||||||
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'
|
|
||||||
}`}>
|
|
||||||
{step.step_name || (step.step_type === 'execute' ? '执行变更' : '审批')}
|
|
||||||
{!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(', ') || '-'}
|
|
||||||
</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>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
{/* Execute Result — plain text fallback for non-JSON results */}
|
||||||
|
{!execResult && ticket.execute_result && (
|
||||||
|
<div>
|
||||||
|
<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)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ticket.execute_result}
|
||||||
|
</div>
|
||||||
|
{ticket.execute_time && (
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mt-2">
|
||||||
|
执行时间: {new Date(ticket.execute_time).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Failure reason for failed/rejected tickets without execute_result */}
|
||||||
|
{(ticket.status === 'failed' || ticket.status === 'rejected') && !ticket.execute_result && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-red-400" /> 失败原因
|
||||||
|
</h3>
|
||||||
|
<div className="p-3 bg-red-500/5 border border-red-500/20 rounded-lg text-xs text-red-400">
|
||||||
|
{ticket.status === 'rejected' ? '工单已被拒绝' : '执行失败,请查看审批记录了解详情'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timeline — approval history */}
|
||||||
|
{approvals.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-3 flex items-center gap-1.5">
|
||||||
|
<Clock className="w-4 h-4 text-blue-400" /> 操作时间线
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-0">
|
||||||
|
{/* Submit step */}
|
||||||
|
<div className="flex gap-3 pb-3">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-green-500/15 text-green-400 flex items-center justify-center">
|
||||||
|
<FileCode className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
<div className="w-[2px] flex-1 bg-[var(--dg-border)]" />
|
||||||
|
</div>
|
||||||
|
<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()}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Group approvals by step_number */}
|
||||||
|
{Object.values(
|
||||||
|
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[] }>,
|
||||||
|
),
|
||||||
|
).map(({ step, users }, idx, arr) => {
|
||||||
|
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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step.step_name || (step.step_type === 'execute' ? '执行变更' : '审批')}
|
||||||
|
{!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(', ') || '-'}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalOverlay>
|
||||||
</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 { TicketStatusBadge } from './TicketStatusBadge';
|
||||||
import { TicketMobileList } from './TicketMobileList';
|
import { TicketMobileList } from './TicketMobileList';
|
||||||
import { TicketPagination } from './TicketPagination';
|
import { TicketPagination } from './TicketPagination';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
/** 表格行加载骨架(静态序列) */
|
/** 表格行加载骨架(静态序列) */
|
||||||
function TableRowSkeleton() {
|
function TableRowSkeleton() {
|
||||||
return (
|
return (
|
||||||
<tr className="border-b border-[var(--dg-border-light)]">
|
<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">
|
||||||
<td className="px-4 py-3.5"><div className="h-4 w-24 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
|
<div className="h-4 w-32 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" />
|
||||||
<td className="px-4 py-3.5"><div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
|
</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">
|
||||||
<td className="px-4 py-3.5"><div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
|
<div className="h-4 w-24 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" />
|
||||||
<td className="px-4 py-3.5"><div className="h-4 w-24 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
|
</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-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>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -56,11 +71,20 @@ interface TicketTableProps extends TicketRowActionProps {
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
onToggleSort: () => void;
|
onToggleSort: () => void;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
|
/** 空态引导:跳创建页(可选) */
|
||||||
|
onCreate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketTable({
|
export function TicketTable({
|
||||||
tickets, loading, page, total, totalPages,
|
tickets,
|
||||||
onToggleSort, onPageChange, ...actionProps
|
loading,
|
||||||
|
page,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
onToggleSort,
|
||||||
|
onPageChange,
|
||||||
|
onCreate,
|
||||||
|
...actionProps
|
||||||
}: TicketTableProps) {
|
}: TicketTableProps) {
|
||||||
return (
|
return (
|
||||||
<div className="dg-card overflow-hidden">
|
<div className="dg-card overflow-hidden">
|
||||||
@@ -70,27 +94,38 @@ export function TicketTable({
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<TableHeader onToggleSort={onToggleSort} />
|
<TableHeader onToggleSort={onToggleSort} />
|
||||||
<tbody>
|
<tbody>
|
||||||
{Array.from({ length: 5 }).map((_, i) => <TableRowSkeleton key={i} />)}
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<TableRowSkeleton key={i} />
|
||||||
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : tickets.length === 0 ? (
|
) : tickets.length === 0 ? (
|
||||||
<EmptyState type="search" />
|
<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">
|
<div className="hidden md:block overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<TableHeader onToggleSort={onToggleSort} />
|
<TableHeader onToggleSort={onToggleSort} />
|
||||||
<tbody>
|
<tbody>
|
||||||
{tickets.map(ticket => (
|
{tickets.map((ticket) => (
|
||||||
<tr
|
<tr key={ticket.id} className="dg-table-row">
|
||||||
key={ticket.id}
|
|
||||||
className="dg-table-row"
|
|
||||||
>
|
|
||||||
{/* 工单编号 */}
|
{/* 工单编号 */}
|
||||||
<td className="px-4 py-3.5">
|
<td className="px-4 py-3.5">
|
||||||
<div className="flex flex-col">
|
<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="font-mono text-xs text-[var(--dg-text-primary)]">
|
||||||
<span className="text-[11px] text-[var(--dg-text-muted)] mt-0.5 max-w-[180px] truncate">{ticket.title}</span>
|
{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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@@ -119,7 +154,9 @@ export function TicketTable({
|
|||||||
{ticket.pending_steps.map((ps) => (
|
{ticket.pending_steps.map((ps) => (
|
||||||
<div key={ps.step_number} className="flex items-center gap-1.5 text-xs">
|
<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-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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -151,9 +188,7 @@ export function TicketTable({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && tickets.length > 0 && (
|
{!loading && tickets.length > 0 && <TicketMobileList tickets={tickets} {...actionProps} />}
|
||||||
<TicketMobileList tickets={tickets} {...actionProps} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<TicketPagination page={page} total={total} totalPages={totalPages} onPageChange={onPageChange} />
|
<TicketPagination page={page} total={total} totalPages={totalPages} onPageChange={onPageChange} />
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { FormDialog } from '@/components/FormDialog';
|
|||||||
import { toast } from '@/hooks/useToast';
|
import { toast } from '@/hooks/useToast';
|
||||||
import { RedisLineEditorDialog } from '@/features/redis/components/RedisLineEditorDialog';
|
import { RedisLineEditorDialog } from '@/features/redis/components/RedisLineEditorDialog';
|
||||||
import { RedisKeyBrowser, type RedisKeyBrowserHandle } from '@/features/redis/keyBrowser/RedisKeyBrowser';
|
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 { useRedisDbs } from '@/features/redis/keyBrowser/useRedisKeyBrowser';
|
||||||
import { RedisStructuredValue } from '@/features/redis/components/RedisStructuredValue';
|
import { RedisStructuredValue } from '@/features/redis/components/RedisStructuredValue';
|
||||||
import { STRING_FORMAT_OPTIONS, type RedisLineEditorState, type StringFormat } from '@/features/redis/types';
|
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 [activeTab, setActiveTab] = useState<TabKey>('keys');
|
||||||
const [selectedDb, setSelectedDb] = useState(0);
|
const [selectedDb, setSelectedDb] = useState(0);
|
||||||
const keyBrowserRef = useRef<RedisKeyBrowserHandle>(null);
|
const keyBrowserRef = useRef<RedisKeyBrowserHandle>(null);
|
||||||
|
const keyDetailRef = useRef<RedisKeyDetailPanelHandle>(null);
|
||||||
|
const [selectedKeyName, setSelectedKeyName] = useState<string | null>(null);
|
||||||
const dbsInfo = useRedisDbs(instanceId);
|
const dbsInfo = useRedisDbs(instanceId);
|
||||||
const [selectedKey, setSelectedKey] = useState<RedisKeyDetail | null>(null);
|
const [selectedKey, setSelectedKey] = useState<RedisKeyDetail | null>(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
@@ -107,28 +110,6 @@ export default function RedisDetailPage() {
|
|||||||
[instanceId],
|
[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 () {
|
const handleAddKey = function () {
|
||||||
let parsedValue: any;
|
let parsedValue: any;
|
||||||
try {
|
try {
|
||||||
@@ -154,119 +135,6 @@ export default function RedisDetailPage() {
|
|||||||
.catch(function () {});
|
.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 () {
|
const handleCommand = function () {
|
||||||
if (!cmdInput.trim()) return;
|
if (!cmdInput.trim()) return;
|
||||||
const cmd = cmdInput;
|
const cmd = cmdInput;
|
||||||
@@ -326,17 +194,6 @@ export default function RedisDetailPage() {
|
|||||||
[activeTab, instanceId],
|
[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 getInfoValue = function (key: string, fallback: string = '-') {
|
||||||
const value = info[key];
|
const value = info[key];
|
||||||
return value == null || value === '' ? fallback : String(value);
|
return value == null || value === '' ? fallback : String(value);
|
||||||
@@ -491,9 +348,10 @@ export default function RedisDetailPage() {
|
|||||||
ref={keyBrowserRef}
|
ref={keyBrowserRef}
|
||||||
instanceId={instanceId}
|
instanceId={instanceId}
|
||||||
db={selectedDb}
|
db={selectedDb}
|
||||||
selectedKey={selectedKey?.key || selectedKey?.name || null}
|
selectedKey={selectedKeyName}
|
||||||
onKeySelect={function (keyName: string) {
|
onKeySelect={function (keyName: string) {
|
||||||
loadKeyDetail(keyName);
|
setSelectedKeyName(keyName);
|
||||||
|
keyDetailRef.current?.openKey(keyName);
|
||||||
}}
|
}}
|
||||||
toolbar={
|
toolbar={
|
||||||
<div className="mb-2 grid grid-cols-[1fr_auto] gap-2">
|
<div className="mb-2 grid grid-cols-[1fr_auto] gap-2">
|
||||||
@@ -527,194 +385,18 @@ export default function RedisDetailPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dg-card min-w-0 overflow-hidden">
|
<RedisKeyDetailPanel
|
||||||
{!selectedKey ? (
|
ref={keyDetailRef}
|
||||||
<div className="flex h-full min-h-[520px] flex-col items-center justify-center text-center">
|
instanceId={instanceId}
|
||||||
<FileText className="mb-3 h-10 w-10 text-[var(--dg-text-muted)]" />
|
db={selectedDb}
|
||||||
<div className="text-sm font-medium">选择一个 Key 查看详情</div>
|
onKeyDeleted={function (keyName: string) {
|
||||||
<div className="mt-1 text-xs text-[var(--dg-text-muted)]">
|
keyBrowserRef.current?.removeKey(keyName);
|
||||||
支持结构化查看、格式化 JSON、编辑值和 TTL
|
setSelectedKeyName(null);
|
||||||
</div>
|
}}
|
||||||
</div>
|
onRefreshBrowser={function () {
|
||||||
) : (
|
keyBrowserRef.current?.refresh();
|
||||||
<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={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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -971,25 +653,6 @@ export default function RedisDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FormDialog>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import { ChevronRight, Check, AlertTriangle, Shield, Database, Loader2, Clock, Sparkles } from 'lucide-react';
|
||||||
ChevronRight, Check, AlertTriangle, Shield, Database,
|
|
||||||
Loader2, Clock, Sparkles,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { ticketApi } from '@/api/ticket';
|
import { ticketApi } from '@/api/ticket';
|
||||||
|
import { workflowApi } from '@/api/system/workflow';
|
||||||
import { instanceApi } from '@/api/instance';
|
import { instanceApi } from '@/api/instance';
|
||||||
import SqlEditor, { formatSQL, type SqlEditorHandle } from '@/components/SqlEditor';
|
import SqlEditor, { formatSQL, type SqlEditorHandle } from '@/components/SqlEditor';
|
||||||
import SchemaSidebar from '@/components/SchemaSidebar';
|
import SchemaSidebar from '@/components/SchemaSidebar';
|
||||||
@@ -20,6 +18,7 @@ interface InstanceItem {
|
|||||||
port: number;
|
port: number;
|
||||||
db_type: string;
|
db_type: string;
|
||||||
cluster_mode?: boolean;
|
cluster_mode?: boolean;
|
||||||
|
workflow_id?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuditFinding {
|
interface AuditFinding {
|
||||||
@@ -52,7 +51,11 @@ const LEVEL_LABEL: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DB_TYPE_LABELS: 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 [database, setDatabase] = useState('');
|
||||||
const [sqlContent, setSqlContent] = useState('');
|
const [sqlContent, setSqlContent] = useState('');
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
const [scheduledAt, setScheduledAt] = useState('');
|
|
||||||
const [enableSchedule, setEnableSchedule] = useState(false);
|
|
||||||
|
|
||||||
// -- Data / Loading --
|
// -- Data / Loading --
|
||||||
const [instances, setInstances] = useState<InstanceItem[]>([]);
|
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 [databases, setDatabases] = useState<string[]>([]);
|
||||||
const [loadingInstances, setLoadingInstances] = useState(false);
|
const [loadingInstances, setLoadingInstances] = useState(false);
|
||||||
const [loadingDatabases, setLoadingDatabases] = useState(false);
|
const [loadingDatabases, setLoadingDatabases] = useState(false);
|
||||||
@@ -93,28 +100,74 @@ export default function TicketCreatePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoadingInstances(true);
|
setLoadingInstances(true);
|
||||||
instanceApi.accessible('write')
|
instanceApi
|
||||||
|
.accessible('write')
|
||||||
.then((res: unknown) => {
|
.then((res: unknown) => {
|
||||||
const data = (res as { items?: unknown[] })?.items ?? res;
|
const data = (res as { items?: unknown[] })?.items ?? res;
|
||||||
setInstances(Array.isArray(data) ? data as InstanceItem[] : []);
|
setInstances(Array.isArray(data) ? (data as InstanceItem[]) : []);
|
||||||
})
|
})
|
||||||
.catch(() => setInstances([]))
|
.catch(() => setInstances([]))
|
||||||
|
|
||||||
.finally(() => setLoadingInstances(false));
|
.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 selectedInstance = useMemo(() => instances.find((i) => i.id === Number(instanceId)), [instances, instanceId]);
|
||||||
const dbType = selectedInstance?.db_type || '';
|
const dbType = selectedInstance?.db_type || '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId) { setDatabases([]); setDatabase(''); return; }
|
if (!instanceId) {
|
||||||
setDatabase(''); setDatabases([]);
|
setDatabases([]);
|
||||||
const inst = instances.find(i => i.id === Number(instanceId));
|
setDatabase('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDatabase('');
|
||||||
|
setDatabases([]);
|
||||||
|
const inst = instances.find((i) => i.id === Number(instanceId));
|
||||||
if (!inst) return;
|
if (!inst) return;
|
||||||
setLoadingDatabases(true);
|
setLoadingDatabases(true);
|
||||||
instanceApi.listDatabases(Number(instanceId))
|
instanceApi
|
||||||
|
.listDatabases(Number(instanceId))
|
||||||
.then((res: unknown) => {
|
.then((res: unknown) => {
|
||||||
const data = (res as { databases?: string[] })?.databases || (res as string[]) || [];
|
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);
|
setDatabases(list);
|
||||||
if (inst.db_type === 'redis') {
|
if (inst.db_type === 'redis') {
|
||||||
setDatabase('db0');
|
setDatabase('db0');
|
||||||
@@ -135,29 +188,32 @@ export default function TicketCreatePage() {
|
|||||||
* - If database is NOT selected, keep the full "db.table" format
|
* - If database is NOT selected, keep the full "db.table" format
|
||||||
* - Use editorRef.insertText for proper cursor positioning
|
* - Use editorRef.insertText for proper cursor positioning
|
||||||
*/
|
*/
|
||||||
const handleSchemaInsert = useCallback((text: string) => {
|
const handleSchemaInsert = useCallback(
|
||||||
let insertText = text;
|
(text: string) => {
|
||||||
if (database) {
|
let insertText = text;
|
||||||
// Database already selected — strip "db." prefix if present
|
if (database) {
|
||||||
const dotIdx = insertText.indexOf('.');
|
// Database already selected — strip "db." prefix if present
|
||||||
if (dotIdx > 0) {
|
const dotIdx = insertText.indexOf('.');
|
||||||
// Check if the part before dot matches our selected database
|
if (dotIdx > 0) {
|
||||||
const prefix = insertText.substring(0, dotIdx);
|
// Check if the part before dot matches our selected database
|
||||||
if (prefix === database) {
|
const prefix = insertText.substring(0, dotIdx);
|
||||||
insertText = insertText.substring(dotIdx + 1);
|
if (prefix === database) {
|
||||||
|
insertText = insertText.substring(dotIdx + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
// Use imperative handle for cursor positioning, fallback to setState
|
||||||
// Use imperative handle for cursor positioning, fallback to setState
|
if (editorRef.current) {
|
||||||
if (editorRef.current) {
|
editorRef.current.insertText(insertText + ' ');
|
||||||
editorRef.current.insertText(insertText + ' ');
|
} else {
|
||||||
} else {
|
setSqlContent((prev) => {
|
||||||
setSqlContent(prev => {
|
const sep = prev.trim() ? (prev.trimEnd().endsWith('\n') ? '' : ' ') : '';
|
||||||
const sep = prev.trim() ? (prev.trimEnd().endsWith('\n') ? '' : ' ') : '';
|
return prev + sep + insertText + ' ';
|
||||||
return prev + sep + insertText + ' ';
|
});
|
||||||
});
|
}
|
||||||
}
|
},
|
||||||
}, [database]);
|
[database],
|
||||||
|
);
|
||||||
|
|
||||||
const handleFormatSQL = useCallback(() => {
|
const handleFormatSQL = useCallback(() => {
|
||||||
if (!sqlContent.trim()) return;
|
if (!sqlContent.trim()) return;
|
||||||
@@ -165,35 +221,51 @@ export default function TicketCreatePage() {
|
|||||||
}, [sqlContent]);
|
}, [sqlContent]);
|
||||||
|
|
||||||
// SQL 内容变化时清除上次审核结果
|
// SQL 内容变化时清除上次审核结果
|
||||||
const handleSqlChange = useCallback((val: string) => {
|
const handleSqlChange = useCallback(
|
||||||
setSqlContent(val);
|
(val: string) => {
|
||||||
if (auditResult) setAuditResult(null);
|
setSqlContent(val);
|
||||||
if (validated) setValidated(false);
|
if (auditResult) setAuditResult(null);
|
||||||
}, [auditResult, validated]);
|
if (validated) setValidated(false);
|
||||||
|
},
|
||||||
|
[auditResult, validated],
|
||||||
|
);
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
/* Validate: SQL 检测(不提交) */
|
/* Validate: SQL 检测(不提交) */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
const handleValidate = useCallback(async () => {
|
const handleValidate = useCallback(async () => {
|
||||||
if (!sqlContent.trim()) { setError('请填写 SQL 语句'); return; }
|
if (!sqlContent.trim()) {
|
||||||
if (!instanceId) { setError('请选择目标实例'); return; }
|
setError('请填写 SQL 语句');
|
||||||
if (!database) { setError('请选择数据库'); return; }
|
return;
|
||||||
|
}
|
||||||
|
if (!instanceId) {
|
||||||
|
setError('请选择目标实例');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!database) {
|
||||||
|
setError('请选择数据库');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 前端拦截只读语句
|
// 前端拦截只读语句
|
||||||
const upperSQL = sqlContent.trim().toUpperCase();
|
const upperSQL = sqlContent.trim().toUpperCase();
|
||||||
const readOnlyPrefixes = ['SELECT', 'SHOW', 'DESC', 'DESCRIBE', 'EXPLAIN'];
|
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 等只读语句,请使用查询数据功能');
|
setError('数据变更工单不支持 SELECT/SHOW/DESC 等只读语句,请使用查询数据功能');
|
||||||
setValidated(false);
|
setValidated(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(''); setAuditResult(null); setValidating(true);
|
setError('');
|
||||||
|
setAuditResult(null);
|
||||||
|
setValidating(true);
|
||||||
try {
|
try {
|
||||||
const result = await ticketApi.audit({
|
const result = (await ticketApi.audit({
|
||||||
sql_content: sqlContent, instance_id: Number(instanceId), database,
|
sql_content: sqlContent,
|
||||||
}) as unknown as AuditResult;
|
instance_id: Number(instanceId),
|
||||||
|
database,
|
||||||
|
})) as unknown as AuditResult;
|
||||||
setAuditResult(result);
|
setAuditResult(result);
|
||||||
|
|
||||||
const hasErrors = result.findings?.some((f) => f.level === 'error');
|
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 } } };
|
const err = e as { response?: { data?: { detail?: string } } };
|
||||||
setError(err?.response?.data?.detail || 'SQL 检测失败');
|
setError(err?.response?.data?.detail || 'SQL 检测失败');
|
||||||
setValidated(false);
|
setValidated(false);
|
||||||
} finally { setValidating(false); }
|
} finally {
|
||||||
|
setValidating(false);
|
||||||
|
}
|
||||||
}, [sqlContent, instanceId, database]);
|
}, [sqlContent, instanceId, database]);
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
@@ -215,21 +289,37 @@ export default function TicketCreatePage() {
|
|||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!title.trim()) { setError('请填写工单标题'); return; }
|
if (!title.trim()) {
|
||||||
if (!instanceId) { setError('请选择目标实例'); return; }
|
setError('请填写工单标题');
|
||||||
if (!database) { setError('请选择数据库'); return; }
|
return;
|
||||||
if (!sqlContent.trim()) { setError('请填写 SQL 语句'); return; }
|
}
|
||||||
if (!reason.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 upperSQL = sqlContent.trim().toUpperCase();
|
||||||
const readOnlyPrefixes = ['SELECT', 'SHOW', 'DESC', 'DESCRIBE', 'EXPLAIN'];
|
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 等只读语句,请使用查询数据功能');
|
setError('数据变更工单不支持 SELECT/SHOW/DESC 等只读语句,请使用查询数据功能');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(''); setSubmitting(true);
|
setError('');
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const inst = instances.find((i) => i.id === Number(instanceId));
|
const inst = instances.find((i) => i.id === Number(instanceId));
|
||||||
@@ -240,29 +330,34 @@ export default function TicketCreatePage() {
|
|||||||
target_database: database,
|
target_database: database,
|
||||||
sql_content: sqlContent,
|
sql_content: sqlContent,
|
||||||
reason: reason.trim(),
|
reason: reason.trim(),
|
||||||
priority: 'medium',
|
priority,
|
||||||
scheduled_at: enableSchedule && scheduledAt ? new Date(scheduledAt).toISOString() : undefined,
|
rollback_sql: rollbackSql.trim() || undefined,
|
||||||
});
|
});
|
||||||
navigate('/tickets');
|
navigate('/tickets');
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { detail?: string | Array<{ msg: string }> } } };
|
const err = e as { response?: { data?: { detail?: string | Array<{ msg: string }> } } };
|
||||||
const raw = err?.response?.data?.detail;
|
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);
|
setError(msg);
|
||||||
} finally { setSubmitting(false); }
|
} finally {
|
||||||
}, [title, sqlContent, instanceId, database, reason, instances, navigate, enableSchedule, scheduledAt]);
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [title, sqlContent, instanceId, database, reason, instances, navigate, priority, rollbackSql]);
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
/* Derived values */
|
/* Derived values */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
const isRedis = selectedInstance?.db_type === 'redis';
|
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 isMongo = selectedInstance?.db_type === 'mongo';
|
||||||
|
|
||||||
const errorCount = auditResult?.findings?.filter(f => f.level === 'error').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 warnCount = auditResult?.findings?.filter((f) => f.level === 'warning').length || 0;
|
||||||
const infoCount = auditResult?.findings?.filter(f => f.level === 'info').length || 0;
|
const infoCount = auditResult?.findings?.filter((f) => f.level === 'info').length || 0;
|
||||||
|
|
||||||
/* ================================================================== */
|
/* ================================================================== */
|
||||||
/* MAIN RENDER */
|
/* MAIN RENDER */
|
||||||
@@ -273,24 +368,32 @@ export default function TicketCreatePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-3rem)] overflow-y-auto">
|
<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="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">
|
<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"
|
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" />
|
<ChevronRight className="w-4 h-4 rotate-180" />
|
||||||
</button>
|
</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"
|
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>
|
||||||
|
|
||||||
{/* ====== 工具栏:实例 + 数据库 + 操作 ====== */}
|
{/* ====== 工具栏:实例 + 数据库 + 操作 ====== */}
|
||||||
<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">
|
<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}
|
<select
|
||||||
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">
|
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>
|
<option value="">{loadingInstances ? '加载中...' : '选择实例'}</option>
|
||||||
{instances.map((inst) => (
|
{instances.map((inst) => (
|
||||||
<option key={inst.id} value={String(inst.id)}>
|
<option key={inst.id} value={String(inst.id)}>
|
||||||
@@ -301,50 +404,72 @@ export default function TicketCreatePage() {
|
|||||||
|
|
||||||
{/* 数据库 */}
|
{/* 数据库 */}
|
||||||
{isRedis ? (
|
{isRedis ? (
|
||||||
<select value={database} onChange={(e) => setDatabase(e.target.value)}
|
<select
|
||||||
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">
|
value={database}
|
||||||
{databases.length > 0 ? databases.map((db) => <option key={db} value={db}>{db}</option>) : <option value="db0">db0</option>}
|
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>
|
</select>
|
||||||
) : databases.length > 0 ? (
|
) : databases.length > 0 ? (
|
||||||
<select value={database} onChange={(e) => setDatabase(e.target.value)} disabled={loadingDatabases}
|
<select
|
||||||
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">
|
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>
|
<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>
|
</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"
|
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)]">
|
<select
|
||||||
<input type="checkbox" checked={enableSchedule} onChange={(e) => setEnableSchedule(e.target.checked)}
|
value={priority}
|
||||||
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" />
|
onChange={(e) => setPriority(e.target.value as 'high' | 'medium' | 'low')}
|
||||||
<Clock className="w-3.5 h-3.5" />
|
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="工单优先级"
|
||||||
</label>
|
>
|
||||||
{enableSchedule && (
|
<option value="high">高优先级</option>
|
||||||
<input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)}
|
<option value="medium">中优先级</option>
|
||||||
step="1" min={new Date().toISOString().slice(0, 16)}
|
<option value="low">低优先级</option>
|
||||||
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" />
|
</select>
|
||||||
)}
|
|
||||||
{enableSchedule && scheduledAt && (
|
|
||||||
<span className="text-xs font-semibold text-red-500">
|
|
||||||
{new Date(scheduledAt).toLocaleString('zh-CN')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 md:col-span-4">
|
<div className="flex flex-wrap gap-2 md:col-span-4">
|
||||||
{instanceId && (
|
{instanceId && (
|
||||||
<button onClick={() => setShowBrowser(!showBrowser)}
|
<button
|
||||||
className={`dg-btn dg-btn-sm ${showBrowser ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : 'dg-btn-ghost'}`}>
|
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" />
|
<Database className="w-3.5 h-3.5" />
|
||||||
{showBrowser ? '隐藏浏览' : '库表浏览'}
|
{showBrowser ? '隐藏浏览' : '库表浏览'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={handleFormatSQL} disabled={!sqlContent.trim()}
|
<button
|
||||||
className="dg-btn dg-btn-sm dg-btn-ghost text-blue-400 disabled:opacity-30">
|
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" /> 格式化
|
<Sparkles className="w-3 h-3" /> 格式化
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -353,7 +478,8 @@ export default function TicketCreatePage() {
|
|||||||
{/* ====== 错误提示 ====== */}
|
{/* ====== 错误提示 ====== */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-2 text-sm text-red-400 bg-red-500/10 px-4 py-2.5 rounded-lg">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -362,9 +488,14 @@ export default function TicketCreatePage() {
|
|||||||
{/* 浏览面板 */}
|
{/* 浏览面板 */}
|
||||||
{showBrowser && instanceId && (
|
{showBrowser && instanceId && (
|
||||||
<div className="min-h-[260px] xl:w-[300px] xl:shrink-0">
|
<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}
|
clusterMode={selectedInstance?.cluster_mode}
|
||||||
onInsert={handleSchemaInsert} singleDb />
|
onInsert={handleSchemaInsert}
|
||||||
|
singleDb
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -374,16 +505,23 @@ export default function TicketCreatePage() {
|
|||||||
{/* 编辑器顶栏:标题 + SQL检测按钮 */}
|
{/* 编辑器顶栏:标题 + 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">
|
<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>
|
<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}
|
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" />}
|
{validating ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Shield className="w-3.5 h-3.5" />}
|
||||||
SQL 检测
|
SQL 检测
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<SqlEditor ref={editorRef} value={sqlContent} onChange={handleSqlChange} language="sql"
|
<SqlEditor
|
||||||
|
ref={editorRef}
|
||||||
|
value={sqlContent}
|
||||||
|
onChange={handleSqlChange}
|
||||||
|
language="sql"
|
||||||
height="320px"
|
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">
|
<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>
|
<span>
|
||||||
@@ -391,7 +529,12 @@ export default function TicketCreatePage() {
|
|||||||
{sqlContent.length} 字符
|
{sqlContent.length} 字符
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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>
|
<span>Ctrl+Enter 格式化</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -408,13 +551,21 @@ export default function TicketCreatePage() {
|
|||||||
{warnCount > 0 && <span className="text-amber-400 font-medium">{warnCount} 警告</span>}
|
{warnCount > 0 && <span className="text-amber-400 font-medium">{warnCount} 警告</span>}
|
||||||
{infoCount > 0 && <span className="text-blue-400 font-medium">{infoCount} 提示</span>}
|
{infoCount > 0 && <span className="text-blue-400 font-medium">{infoCount} 提示</span>}
|
||||||
{errorCount === 0 && warnCount === 0 && infoCount === 0 && (
|
{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>
|
</div>
|
||||||
{auditResult.findings && auditResult.findings.length > 0 && (
|
{auditResult.findings && auditResult.findings.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{auditResult.findings.map((f, idx) => (
|
{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" />
|
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
||||||
<span className="font-medium">{LEVEL_LABEL[f.level] || f.level}</span>
|
<span className="font-medium">{LEVEL_LABEL[f.level] || f.level}</span>
|
||||||
<span className="opacity-60">·</span>
|
<span className="opacity-60">·</span>
|
||||||
@@ -435,16 +586,61 @@ export default function TicketCreatePage() {
|
|||||||
<label className="block text-sm font-medium mb-1.5">
|
<label className="block text-sm font-medium mb-1.5">
|
||||||
变更原因 <span className="text-red-400">*</span>
|
变更原因 <span className="text-red-400">*</span>
|
||||||
</label>
|
</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"
|
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>
|
</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">
|
<div className="flex items-center justify-end">
|
||||||
<button onClick={handleSubmit}
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
disabled={submitting || !validated}
|
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" />}
|
{submitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||||
提交审批
|
提交审批
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAuth } from '@/hooks/useAuth';
|
|||||||
import type { TicketData, TimelineEntry, ApprovalStep, RelatedTicket } from '@/features/ticket/components/DetailTypes';
|
import type { TicketData, TimelineEntry, ApprovalStep, RelatedTicket } from '@/features/ticket/components/DetailTypes';
|
||||||
import DetailApprovalPanel from '@/features/ticket/components/DetailApprovalPanel';
|
import DetailApprovalPanel from '@/features/ticket/components/DetailApprovalPanel';
|
||||||
import DetailApprovalFlow from '@/features/ticket/components/DetailApprovalFlow';
|
import DetailApprovalFlow from '@/features/ticket/components/DetailApprovalFlow';
|
||||||
|
import { TicketStageSteps } from '@/features/ticket/components/TicketStageSteps';
|
||||||
import DetailTimeline from '@/features/ticket/components/DetailTimeline';
|
import DetailTimeline from '@/features/ticket/components/DetailTimeline';
|
||||||
import DetailAuditResult from '@/features/ticket/components/DetailAuditResult';
|
import DetailAuditResult from '@/features/ticket/components/DetailAuditResult';
|
||||||
import DetailExecuteResult from '@/features/ticket/components/DetailExecuteResult';
|
import DetailExecuteResult from '@/features/ticket/components/DetailExecuteResult';
|
||||||
@@ -47,7 +48,7 @@ export default function TicketDetailPage() {
|
|||||||
|
|
||||||
const fetchTicket = useCallback(async () => {
|
const fetchTicket = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await ticketApi.get(ticketId) as unknown as TicketData;
|
const data = (await ticketApi.get(ticketId)) as unknown as TicketData;
|
||||||
setTicket(data);
|
setTicket(data);
|
||||||
setError('');
|
setError('');
|
||||||
} catch {
|
} catch {
|
||||||
@@ -60,7 +61,7 @@ export default function TicketDetailPage() {
|
|||||||
|
|
||||||
const fetchTimeline = useCallback(async () => {
|
const fetchTimeline = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await ticketApi.timeline(ticketId) as { timeline?: TimelineEntry[] };
|
const res = (await ticketApi.timeline(ticketId)) as { timeline?: TimelineEntry[] };
|
||||||
setTimeline(res?.timeline || []);
|
setTimeline(res?.timeline || []);
|
||||||
} catch {
|
} catch {
|
||||||
setTimeline([]);
|
setTimeline([]);
|
||||||
@@ -79,7 +80,7 @@ export default function TicketDetailPage() {
|
|||||||
// Fetch related tickets from API
|
// Fetch related tickets from API
|
||||||
const fetchRelated = useCallback(async () => {
|
const fetchRelated = useCallback(async () => {
|
||||||
try {
|
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 || [])
|
const items = (res?.items || [])
|
||||||
.filter((t: any) => t.id !== ticketId)
|
.filter((t: any) => t.id !== ticketId)
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
@@ -110,49 +111,53 @@ export default function TicketDetailPage() {
|
|||||||
/* Action handler */
|
/* Action handler */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
const handleAction = useCallback(async (action: string) => {
|
const handleAction = useCallback(
|
||||||
if (!ticket) return;
|
async (action: string) => {
|
||||||
|
if (!ticket) return;
|
||||||
|
|
||||||
// Reset comment state
|
// Reset comment state
|
||||||
const actionComment = comment.trim() || (action === 'reject' ? '审批拒绝' : action === 'approve' ? '审批通过' : '');
|
const actionComment =
|
||||||
|
comment.trim() || (action === 'reject' ? '审批拒绝' : action === 'approve' ? '审批通过' : '');
|
||||||
|
|
||||||
setActionLoading(action);
|
setActionLoading(action);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'approve':
|
case 'approve':
|
||||||
await ticketApi.review(ticketId, { action: 'approve', comment: actionComment });
|
await ticketApi.review(ticketId, { action: 'approve', comment: actionComment });
|
||||||
break;
|
break;
|
||||||
case 'reject':
|
case 'reject':
|
||||||
await ticketApi.review(ticketId, { action: 'reject', comment: actionComment });
|
await ticketApi.review(ticketId, { action: 'reject', comment: actionComment });
|
||||||
setShowCommentInput(false);
|
setShowCommentInput(false);
|
||||||
setComment('');
|
setComment('');
|
||||||
break;
|
break;
|
||||||
case 'execute':
|
case 'execute':
|
||||||
await ticketApi.execute(ticketId);
|
await ticketApi.execute(ticketId);
|
||||||
break;
|
break;
|
||||||
case 'rollback':
|
case 'rollback':
|
||||||
await ticketApi.rollback(ticketId);
|
await ticketApi.rollback(ticketId);
|
||||||
setShowRollbackConfirm(false);
|
setShowRollbackConfirm(false);
|
||||||
break;
|
break;
|
||||||
case 'cancel':
|
case 'cancel':
|
||||||
await ticketApi.cancel(ticketId);
|
await ticketApi.cancel(ticketId);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
fetchTicket();
|
||||||
|
fetchTimeline();
|
||||||
|
fetchApprovals();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { detail?: string } } };
|
||||||
|
setError(err?.response?.data?.detail || `操作失败: ${action}`);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
setPendingAction(null);
|
||||||
}
|
}
|
||||||
fetchTicket();
|
},
|
||||||
fetchTimeline();
|
[ticket, ticketId, comment, fetchTicket, fetchTimeline],
|
||||||
fetchApprovals();
|
);
|
||||||
} catch (e: unknown) {
|
|
||||||
const err = e as { response?: { data?: { detail?: string } } };
|
|
||||||
setError(err?.response?.data?.detail || `操作失败: ${action}`);
|
|
||||||
} finally {
|
|
||||||
setActionLoading(null);
|
|
||||||
setPendingAction(null);
|
|
||||||
}
|
|
||||||
}, [ticket, ticketId, comment, fetchTicket, fetchTimeline]);
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
/* Permission checks */
|
/* Permission checks */
|
||||||
@@ -166,10 +171,13 @@ export default function TicketDetailPage() {
|
|||||||
// 角色权限统一走 hasActionPermission(actionPermissionMap 单源),此处只组合工单状态
|
// 角色权限统一走 hasActionPermission(actionPermissionMap 单源),此处只组合工单状态
|
||||||
const applicant = ticket.applicant_id === auth.user.id;
|
const applicant = ticket.applicant_id === auth.user.id;
|
||||||
return {
|
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',
|
canExecute: hasActionPermission('ticket.execute') && ticket.status === 'approved',
|
||||||
canRollback: hasActionPermission('ticket.rollback') && (ticket.status === 'success' || ticket.status === 'failed'),
|
canRollback:
|
||||||
canCancel: applicant && (ticket.status === 'pending' || ticket.status === 'reviewing' || ticket.status === 'approved'),
|
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]);
|
}, [ticket, auth.user.id, hasActionPermission]);
|
||||||
|
|
||||||
@@ -207,6 +215,10 @@ export default function TicketDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5 max-w-5xl">
|
<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 */}
|
||||||
{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">
|
<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}
|
comment={comment}
|
||||||
rollbackConfirmVisible={showRollbackConfirm}
|
rollbackConfirmVisible={showRollbackConfirm}
|
||||||
onAction={handleAction}
|
onAction={handleAction}
|
||||||
onStartComment={(action) => { setPendingAction(action); setShowCommentInput(true); }}
|
onStartComment={(action) => {
|
||||||
onCancelComment={() => { setShowCommentInput(false); setComment(''); setPendingAction(null); }}
|
setPendingAction(action);
|
||||||
|
setShowCommentInput(true);
|
||||||
|
}}
|
||||||
|
onCancelComment={() => {
|
||||||
|
setShowCommentInput(false);
|
||||||
|
setComment('');
|
||||||
|
setPendingAction(null);
|
||||||
|
}}
|
||||||
onCommentChange={setComment}
|
onCommentChange={setComment}
|
||||||
onRequestRollback={() => setShowRollbackConfirm(true)}
|
onRequestRollback={() => setShowRollbackConfirm(true)}
|
||||||
onCancelRollback={() => setShowRollbackConfirm(false)}
|
onCancelRollback={() => setShowRollbackConfirm(false)}
|
||||||
@@ -278,10 +297,7 @@ export default function TicketDetailPage() {
|
|||||||
{/* 审批流程 / 时间线 / 关联工单 */}
|
{/* 审批流程 / 时间线 / 关联工单 */}
|
||||||
<DetailApprovalFlow steps={approvalSteps} />
|
<DetailApprovalFlow steps={approvalSteps} />
|
||||||
<DetailTimeline timeline={timeline} />
|
<DetailTimeline timeline={timeline} />
|
||||||
<DetailRelatedTickets
|
<DetailRelatedTickets tickets={relatedTickets} onSelect={(id) => navigate(`/tickets/${id}`)} />
|
||||||
tickets={relatedTickets}
|
|
||||||
onSelect={(id) => navigate(`/tickets/${id}`)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,8 @@ import { TicketStatsCards } from '@/features/ticket/components/TicketStatsCards'
|
|||||||
import { TicketFilterBar } from '@/features/ticket/components/TicketFilterBar';
|
import { TicketFilterBar } from '@/features/ticket/components/TicketFilterBar';
|
||||||
import { TicketTable } from '@/features/ticket/components/TicketTable';
|
import { TicketTable } from '@/features/ticket/components/TicketTable';
|
||||||
import { TicketDetailDialog } from '@/features/ticket/components/TicketDetailDialog';
|
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';
|
import type { TicketItem, TicketListResponse, TicketStatistics } from '@/features/ticket/types';
|
||||||
|
|
||||||
export default function TicketsPage() {
|
export default function TicketsPage() {
|
||||||
@@ -23,10 +25,8 @@ export default function TicketsPage() {
|
|||||||
const canExecute = useActionPermission('ticket.execute');
|
const canExecute = useActionPermission('ticket.execute');
|
||||||
|
|
||||||
// 筛选/分页/排序状态全部由 URL query 驱动(q / status / page / sort)
|
// 筛选/分页/排序状态全部由 URL query 驱动(q / status / page / sort)
|
||||||
const {
|
const { search, statusFilter, page, sortAsc, changeSearch, changeStatus, toggleSort, changePage } =
|
||||||
search, statusFilter, page, sortAsc,
|
useTicketListParams();
|
||||||
changeSearch, changeStatus, toggleSort, changePage,
|
|
||||||
} = useTicketListParams();
|
|
||||||
const pageSize = TICKET_PAGE_SIZE;
|
const pageSize = TICKET_PAGE_SIZE;
|
||||||
|
|
||||||
const [tickets, setTickets] = useState<TicketItem[]>([]);
|
const [tickets, setTickets] = useState<TicketItem[]>([]);
|
||||||
@@ -36,15 +36,19 @@ export default function TicketsPage() {
|
|||||||
|
|
||||||
// Detail dialog
|
// Detail dialog
|
||||||
const [detailTicket, setDetailTicket] = useState<TicketItem | null>(null);
|
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 () => {
|
const fetchTickets = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await ticketApi.list({
|
const res = (await ticketApi.list({
|
||||||
page, page_size: pageSize,
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
status: statusFilter || undefined,
|
status: statusFilter || undefined,
|
||||||
keyword: search || undefined,
|
keyword: search || undefined,
|
||||||
}) as unknown as TicketListResponse;
|
})) as unknown as TicketListResponse;
|
||||||
setTickets(res.items || []);
|
setTickets(res.items || []);
|
||||||
setTotal(res.total);
|
setTotal(res.total);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -56,22 +60,29 @@ export default function TicketsPage() {
|
|||||||
|
|
||||||
const fetchStats = useCallback(async () => {
|
const fetchStats = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await ticketApi.statistics() as unknown as TicketStatistics;
|
const res = (await ticketApi.statistics()) as unknown as TicketStatistics;
|
||||||
setStats(res);
|
setStats(res);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { fetchTickets(); }, [fetchTickets]);
|
useEffect(() => {
|
||||||
useEffect(() => { fetchStats(); }, [fetchStats]);
|
fetchTickets();
|
||||||
|
}, [fetchTickets]);
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, [fetchStats]);
|
||||||
|
|
||||||
// executing 状态工单自动轮询(3秒)
|
// executing 状态工单自动轮询(3秒)
|
||||||
// 与拆分前完全一致:interval 仅在存在 executing 工单时创建,依赖变化时清理
|
// 与拆分前完全一致:interval 仅在存在 executing 工单时创建,依赖变化时清理
|
||||||
const hasExecuting = tickets.some(t => t.status === 'executing');
|
const hasExecuting = tickets.some((t) => t.status === 'executing');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasExecuting) return;
|
if (!hasExecuting) return;
|
||||||
const timer = setInterval(() => { fetchTickets(); fetchStats(); }, 3000);
|
const timer = setInterval(() => {
|
||||||
|
fetchTickets();
|
||||||
|
fetchStats();
|
||||||
|
}, 3000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [hasExecuting, fetchTickets, fetchStats]);
|
}, [hasExecuting, fetchTickets, fetchStats]);
|
||||||
|
|
||||||
@@ -89,63 +100,99 @@ export default function TicketsPage() {
|
|||||||
}, [tickets, sortAsc]);
|
}, [tickets, sortAsc]);
|
||||||
|
|
||||||
/* ---- 统计卡片点击:再点同卡取消筛选(与拆分前 toggle 语义一致) ---- */
|
/* ---- 统计卡片点击:再点同卡取消筛选(与拆分前 toggle 语义一致) ---- */
|
||||||
const handleSelectStatus = useCallback((status: string) => {
|
const handleSelectStatus = useCallback(
|
||||||
changeStatus(statusFilter === status ? '' : status);
|
(status: string) => {
|
||||||
}, [changeStatus, statusFilter]);
|
changeStatus(statusFilter === status ? '' : status);
|
||||||
|
},
|
||||||
|
[changeStatus, statusFilter],
|
||||||
|
);
|
||||||
|
|
||||||
/* ---- Open detail dialog ---- */
|
/* ---- Open detail dialog ---- */
|
||||||
const openDetail = useCallback(async (ticketId: number) => {
|
const openDetail = useCallback(async (ticketId: number) => {
|
||||||
try {
|
try {
|
||||||
const detail = await ticketApi.get(ticketId) as unknown as TicketItem;
|
const detail = (await ticketApi.get(ticketId)) as unknown as TicketItem;
|
||||||
setDetailTicket(detail);
|
setDetailTicket(detail);
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/* ---- 行内操作:调用 API 后刷新列表与统计(与拆分前内联逻辑一致) ---- */
|
/* ---- 行内操作:调用 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(reviewTarget.ticket.id, { action: reviewTarget.action, comment });
|
||||||
|
fetchTickets();
|
||||||
|
fetchStats();
|
||||||
|
} catch {
|
||||||
|
/* 拦截器已 toast */
|
||||||
|
}
|
||||||
|
setReviewTarget(null);
|
||||||
|
},
|
||||||
|
[reviewTarget, fetchTickets, fetchStats],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 执行为高危操作:先弹影响面确认(opsx-sec 带摘要的确认模式)
|
||||||
|
const handleExecute = useCallback((ticket: TicketItem) => {
|
||||||
|
setExecuteTarget(ticket);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmExecute = useCallback(async () => {
|
||||||
|
if (!executeTarget) return;
|
||||||
try {
|
try {
|
||||||
await ticketApi.review(ticket.id, { action: 'approve', comment: '通过' });
|
await ticketApi.execute(executeTarget.id);
|
||||||
fetchTickets(); fetchStats();
|
fetchTickets();
|
||||||
} catch (e: any) {
|
fetchStats();
|
||||||
const msg = e?.response?.data?.detail || e?.message || '审批失败';
|
} catch {
|
||||||
alert(msg);
|
/* 拦截器已 toast */
|
||||||
}
|
}
|
||||||
}, [fetchTickets, fetchStats]);
|
setExecuteTarget(null);
|
||||||
|
}, [executeTarget, fetchTickets, fetchStats]);
|
||||||
|
|
||||||
const handleReject = useCallback(async (ticket: TicketItem) => {
|
const handleCancel = useCallback(
|
||||||
try {
|
async (ticket: TicketItem) => {
|
||||||
await ticketApi.review(ticket.id, { action: 'reject', comment: '拒绝' });
|
try {
|
||||||
fetchTickets(); fetchStats();
|
await ticketApi.cancel(ticket.id);
|
||||||
} catch (e: any) {
|
fetchTickets();
|
||||||
const msg = e?.response?.data?.detail || e?.message || '操作失败';
|
fetchStats();
|
||||||
alert(msg);
|
} catch {
|
||||||
}
|
/* ignore */
|
||||||
}, [fetchTickets, fetchStats]);
|
}
|
||||||
|
},
|
||||||
const handleExecute = useCallback(async (ticket: TicketItem) => {
|
[fetchTickets, fetchStats],
|
||||||
try {
|
);
|
||||||
await ticketApi.execute(ticket.id);
|
|
||||||
fetchTickets(); fetchStats();
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, [fetchTickets, fetchStats]);
|
|
||||||
|
|
||||||
const handleCancel = useCallback(async (ticket: TicketItem) => {
|
|
||||||
try {
|
|
||||||
await ticketApi.cancel(ticket.id);
|
|
||||||
fetchTickets(); fetchStats();
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, [fetchTickets, fetchStats]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="数据变更工单"
|
title="数据变更工单"
|
||||||
description="管理 SQL 变更工单,提交审核、查看审批进度与执行结果"
|
description="管理 SQL 变更工单,提交审核、查看审批进度与执行结果"
|
||||||
action={canCreate ? {
|
action={
|
||||||
label: '新建工单', icon: Plus, onClick: () => navigate('/tickets/create'), permission: 'ticket.create'
|
canCreate
|
||||||
} : undefined}
|
? {
|
||||||
|
label: '新建工单',
|
||||||
|
icon: Plus,
|
||||||
|
onClick: () => navigate('/tickets/create'),
|
||||||
|
permission: 'ticket.create',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Status Summary Cards */}
|
{/* Status Summary Cards */}
|
||||||
@@ -187,7 +234,32 @@ export default function TicketsPage() {
|
|||||||
onClose={() => setDetailTicket(null)}
|
onClose={() => setDetailTicket(null)}
|
||||||
canApprove={canApprove}
|
canApprove={canApprove}
|
||||||
canExecute={canExecute}
|
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>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user