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