feat: APScheduler持久化定时任务 + SQL检测按钮 + 定时时间醒目红色

1. ticket_scheduler.py: 纯asyncio→APScheduler+SQLite持久化
   - 服务重启后定时任务不丢失
   - 启动时自动恢复scheduled状态工单
   - shutdown时优雅关闭scheduler

2. TicketCreatePage.tsx:
   - 提交按钮旁新增SQL检测按钮(Shield图标)
   - 验证通过前提交按钮置灰+提示'请先通过SQL检测'
   - SQL内容变化自动重置验证状态
   - 定时时间改为醒目红色字体(text-red-500 font-semibold)

3. main.py: lifespan中启动APScheduler+恢复定时工单
This commit is contained in:
logan committed 2026-05-25 00:08:48 +08:00
1 parent ffb3d24e2a
commit a8c92fe7ae
3 files changed
+208 -73

No files matched your search

+54 -26
View File
@@ -111,6 +111,8 @@ export default function TicketCreatePage() {
// -- Inline audit result --
const [auditResult, setAuditResult] = useState<AuditResult | null>(null);
const [validating, setValidating] = useState(false);
const [validated, setValidated] = useState(false);
// -- Schema Browser State --
const [tables, setTables] = useState<TableMeta[]>([]);
@@ -249,37 +251,53 @@ export default function TicketCreatePage() {
const handleSqlChange = useCallback((val: string) => {
setSqlContent(val);
if (auditResult) setAuditResult(null);
}, [auditResult]);
if (validated) setValidated(false);
}, [auditResult, validated]);
/* ---------------------------------------------------------------- */
/* Submit: audit + create in one click */
/* Validate: SQL 检测(不提交) */
/* ---------------------------------------------------------------- */
const handleValidate = useCallback(async () => {
if (!sqlContent.trim()) { setError('请填写 SQL 语句'); return; }
if (!instanceId) { setError('请选择目标实例'); return; }
if (!database) { setError('请选择数据库'); return; }
setError(''); setAuditResult(null); setValidating(true);
try {
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');
if (hasErrors) {
setError('SQL 审核未通过,请修改后重新检测');
setValidated(false);
} else {
setValidated(true);
}
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } };
setError(err?.response?.data?.detail || 'SQL 检测失败');
setValidated(false);
} finally { setValidating(false); }
}, [sqlContent, instanceId, database]);
/* ---------------------------------------------------------------- */
/* Submit: 验证通过后直接提交工单 */
/* ---------------------------------------------------------------- */
const handleSubmit = useCallback(async () => {
// 1. 表单校验
if (!title.trim()) { setError('请填写工单标题'); return; }
if (!instanceId) { setError('请选择目标实例'); return; }
if (!database) { setError('请选择数据库'); return; }
if (!sqlContent.trim()) { setError('请填写 SQL 语句'); return; }
if (!reason.trim()) { setError('请填写变更原因'); return; }
setError(''); setAuditResult(null); setSubmitting(true);
setError(''); setSubmitting(true);
try {
// 2. 先审核
const result = await ticketApi.audit({
sql_content: sqlContent, instance_id: Number(instanceId), database,
}) as unknown as AuditResult;
setAuditResult(result);
// 3. 有 error 级别违规则阻止提交
const hasErrors = result.findings?.some((f) => f.level === 'error');
if (hasErrors) {
setError('SQL 审核未通过,请修改后重新提交');
return;
}
// 4. 提交工单
const inst = instances.find((i) => i.id === Number(instanceId));
await ticketApi.create({
title: title.trim(),
@@ -298,7 +316,7 @@ export default function TicketCreatePage() {
const msg = Array.isArray(raw) ? raw.map((v) => v.msg).join('; ') : (raw || '提交失败,请稍后重试');
setError(msg);
} finally { setSubmitting(false); }
}, [title, sqlContent, instanceId, database, reason, instances, navigate]);
}, [title, sqlContent, instanceId, database, reason, instances, navigate, enableSchedule, scheduledAt]);
/* ---------------------------------------------------------------- */
/* Derived values */
@@ -718,7 +736,7 @@ export default function TicketCreatePage() {
min={new Date().toISOString().slice(0, 16)} />
)}
{enableSchedule && scheduledAt && (
<span className="text-xs text-[var(--dg-text-muted)]">
<span className="text-sm font-semibold text-red-500">
将于 {new Date(scheduledAt).toLocaleString('zh-CN')} 执行
</span>
)}
@@ -795,13 +813,23 @@ export default function TicketCreatePage() {
</div>
)}
{/* === Submit Button === */}
<div className="flex justify-end">
<button onClick={handleSubmit} disabled={submitting}
className="flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed">
{/* === Bottom Actions === */}
<div className="flex justify-end items-center gap-3">
<button onClick={handleValidate}
disabled={validating || !sqlContent.trim() || !instanceId || !database}
className="flex items-center gap-2 px-5 py-2.5 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] rounded-lg text-sm font-medium transition-all hover:bg-[var(--dg-bg-hover)] disabled:opacity-40 disabled:cursor-not-allowed">
{validating
? <><Loader2 className="w-4 h-4 animate-spin" /> 检测中...</>
: <><Shield className="w-4 h-4" /> SQL 检测</>}
</button>
<button onClick={handleSubmit}
disabled={submitting || !validated}
className="flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-all disabled:opacity-40 disabled:cursor-not-allowed">
{submitting
? <><Loader2 className="w-4 h-4 animate-spin" /> 审核提交中...</>
: <><Shield className="w-4 h-4" /> 提交工单</>}
? <><Loader2 className="w-4 h-4 animate-spin" /> 提交中...</>
: validated
? <><Check className="w-4 h-4" /> 提交工单</>
: <><Check className="w-4 h-4" /> 请先通过 SQL 检测</>}
</button>
</div>
</div>