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:
1 parent
ffb3d24e2a
commit
a8c92fe7ae
3 files changed
+207
-72
No files matched your search
@@ -77,8 +77,20 @@ async def lifespan(app: FastAPI):
|
|||||||
await init_db()
|
await init_db()
|
||||||
await seed_data()
|
await seed_data()
|
||||||
await seed_workflows()
|
await seed_workflows()
|
||||||
|
|
||||||
|
# 启动 APScheduler + 恢复定时工单
|
||||||
|
from app.services.ticket_scheduler import start_scheduler, recover_scheduled_tickets, shutdown_scheduler
|
||||||
|
start_scheduler()
|
||||||
|
recovered = await recover_scheduled_tickets()
|
||||||
|
if recovered > 0:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).info("恢复了 %d 个定时工单", recovered)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
# Shutdown
|
||||||
|
shutdown_scheduler()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="SQL-Platform API",
|
title="SQL-Platform API",
|
||||||
|
|||||||
@@ -1,49 +1,97 @@
|
|||||||
"""工单定时执行调度器 — 基于 asyncio 的轻量级定时任务。"""
|
"""工单定时执行调度器 — 基于 APScheduler + SQLite 持久化。
|
||||||
|
|
||||||
|
优势(对比纯 asyncio):
|
||||||
|
1. 服务重启后定时任务不丢失(SQLite job store 持久化)
|
||||||
|
2. 启动时自动恢复 scheduled 状态的工单
|
||||||
|
3. 支持任务重试、misfire_grace_time 等生产级特性
|
||||||
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict
|
from typing import Optional
|
||||||
|
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
||||||
|
from apscheduler.triggers.date import DateTrigger
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 内存中保存活跃的定时任务句柄
|
# 全局调度器实例(延迟初始化)
|
||||||
_active_tasks: Dict[int, asyncio.Task] = {}
|
_scheduler: Optional[AsyncIOScheduler] = None
|
||||||
|
|
||||||
|
DB_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), '..', 'apscheduler.db')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_scheduler() -> AsyncIOScheduler:
|
||||||
|
"""获取或创建 APScheduler 实例(单例)。"""
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler is not None and _scheduler.running:
|
||||||
|
return _scheduler
|
||||||
|
|
||||||
|
jobstore_url = 'sqlite:///' + os.path.abspath(DB_DIR)
|
||||||
|
_scheduler = AsyncIOScheduler(
|
||||||
|
jobstores={'default': SQLAlchemyJobStore(url=jobstore_url)},
|
||||||
|
job_defaults={
|
||||||
|
'coalesce': True, # 同一 job 只执行一次
|
||||||
|
'max_instances': 1, # 不并发
|
||||||
|
'misfire_grace_time': 300, # 5 分钟内迟到仍执行
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return _scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler() -> None:
|
||||||
|
"""启动调度器(应用启动时调用)。"""
|
||||||
|
scheduler = _get_scheduler()
|
||||||
|
if not scheduler.running:
|
||||||
|
scheduler.start()
|
||||||
|
logger.info("APScheduler 已启动 (SQLite 持久化)")
|
||||||
|
|
||||||
|
|
||||||
|
def shutdown_scheduler() -> None:
|
||||||
|
"""关闭调度器。"""
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler and _scheduler.running:
|
||||||
|
_scheduler.shutdown(wait=False)
|
||||||
|
logger.info("APScheduler 已关闭")
|
||||||
|
|
||||||
|
|
||||||
async def schedule_ticket_execution(ticket_id: int, scheduled_at: datetime) -> None:
|
async def schedule_ticket_execution(ticket_id: int, scheduled_at: datetime) -> None:
|
||||||
"""注册一个定时执行任务。
|
"""注册一个定时执行任务。
|
||||||
|
|
||||||
使用 asyncio.create_task 创建后台等待任务,到达 scheduled_at 后自动执行工单。
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ticket_id: 工单 ID
|
ticket_id: 工单 ID
|
||||||
scheduled_at: 定时执行时间 (UTC)
|
scheduled_at: 定时执行时间 (UTC)
|
||||||
"""
|
"""
|
||||||
now = datetime.now(timezone.utc)
|
scheduler = _get_scheduler()
|
||||||
delay = (scheduled_at - now).total_seconds()
|
job_id = f"ticket_{ticket_id}"
|
||||||
|
|
||||||
if delay <= 0:
|
now = datetime.now(timezone.utc)
|
||||||
|
run_date = scheduled_at if scheduled_at.tzinfo else scheduled_at.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
if run_date <= now:
|
||||||
# 已到时间,立即执行
|
# 已到时间,立即执行
|
||||||
logger.info("工单 %d 定时时间已到,立即执行", ticket_id)
|
logger.info("工单 %d 定时时间已到,立即执行", ticket_id)
|
||||||
task = asyncio.create_task(_execute_scheduled_ticket(ticket_id))
|
|
||||||
else:
|
|
||||||
logger.info("工单 %d 定时执行: %s (%.1f 秒后)", ticket_id, scheduled_at, delay)
|
|
||||||
task = asyncio.create_task(_wait_and_execute(ticket_id, delay))
|
|
||||||
|
|
||||||
_active_tasks[ticket_id] = task
|
|
||||||
|
|
||||||
|
|
||||||
async def _wait_and_execute(ticket_id: int, delay: float) -> None:
|
|
||||||
"""等待 delay 秒后执行工单。"""
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
await _execute_scheduled_ticket(ticket_id)
|
await _execute_scheduled_ticket(ticket_id)
|
||||||
except asyncio.CancelledError:
|
return
|
||||||
logger.info("工单 %d 定时执行已取消", ticket_id)
|
|
||||||
except Exception:
|
delay = (run_date - now).total_seconds()
|
||||||
logger.exception("工单 %d 定时执行异常", ticket_id)
|
logger.info("工单 %d 定时执行: %s (%.1f 秒后)", ticket_id, scheduled_at, delay)
|
||||||
finally:
|
|
||||||
_active_tasks.pop(ticket_id, None)
|
scheduler.add_job(
|
||||||
|
_run_async_execution,
|
||||||
|
trigger=DateTrigger(run_date=run_date),
|
||||||
|
args=[ticket_id],
|
||||||
|
id=job_id,
|
||||||
|
replace_existing=True,
|
||||||
|
name=f"定时执行工单 #{ticket_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_async_execution(ticket_id: int) -> None:
|
||||||
|
"""APScheduler 调用的入口(同步 wrapper → 异步执行)。"""
|
||||||
|
await _execute_scheduled_ticket(ticket_id)
|
||||||
|
|
||||||
|
|
||||||
async def _execute_scheduled_ticket(ticket_id: int) -> None:
|
async def _execute_scheduled_ticket(ticket_id: int) -> None:
|
||||||
@@ -52,7 +100,6 @@ async def _execute_scheduled_ticket(ticket_id: int) -> None:
|
|||||||
from app.models.ticket import Ticket as TicketModel
|
from app.models.ticket import Ticket as TicketModel
|
||||||
from app.models.instance import Instance
|
from app.models.instance import Instance
|
||||||
from app.services.ticket_executor import TicketExecutor
|
from app.services.ticket_executor import TicketExecutor
|
||||||
from app.services.audit_log import AuditLogService
|
|
||||||
from app.core.security import decrypt_password
|
from app.core.security import decrypt_password
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
import json
|
import json
|
||||||
@@ -61,24 +108,20 @@ async def _execute_scheduled_ticket(ticket_id: int) -> None:
|
|||||||
|
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
try:
|
try:
|
||||||
# 查询工单
|
|
||||||
result = await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))
|
result = await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))
|
||||||
ticket = result.scalar_one_or_none()
|
ticket = result.scalar_one_or_none()
|
||||||
if not ticket:
|
if not ticket:
|
||||||
logger.error("定时工单 %d 不存在", ticket_id)
|
logger.error("定时工单 %d 不存在", ticket_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 状态检查
|
|
||||||
if ticket.status != "scheduled":
|
if ticket.status != "scheduled":
|
||||||
logger.warning("工单 %d 状态已变更 (%s),跳过执行", ticket_id, ticket.status)
|
logger.warning("工单 %d 状态已变更 (%s),跳过执行", ticket_id, ticket.status)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 更新状态为 executing
|
|
||||||
ticket.status = "executing"
|
ticket.status = "executing"
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(ticket)
|
await db.refresh(ticket)
|
||||||
|
|
||||||
# 查询实例
|
|
||||||
inst_result = await db.execute(select(Instance).where(Instance.id == ticket.instance_id))
|
inst_result = await db.execute(select(Instance).where(Instance.id == ticket.instance_id))
|
||||||
instance = inst_result.scalar_one_or_none()
|
instance = inst_result.scalar_one_or_none()
|
||||||
if not instance:
|
if not instance:
|
||||||
@@ -88,14 +131,10 @@ async def _execute_scheduled_ticket(ticket_id: int) -> None:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
return
|
return
|
||||||
|
|
||||||
# 解密密码
|
|
||||||
instance.password = decrypt_password(instance.password)
|
instance.password = decrypt_password(instance.password)
|
||||||
|
|
||||||
# 执行
|
|
||||||
executor = TicketExecutor()
|
executor = TicketExecutor()
|
||||||
report = await executor.execute(ticket, instance)
|
report = await executor.execute(ticket, instance)
|
||||||
|
|
||||||
# 更新结果
|
|
||||||
ticket.status = "success" if report.status == "success" else "failed"
|
ticket.status = "success" if report.status == "success" else "failed"
|
||||||
ticket.execute_result = report.to_json()
|
ticket.execute_result = report.to_json()
|
||||||
ticket.execute_time = datetime.now(timezone.utc)
|
ticket.execute_time = datetime.now(timezone.utc)
|
||||||
@@ -115,8 +154,6 @@ async def _execute_scheduled_ticket(ticket_id: int) -> None:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("更新定时工单 %d 失败状态也失败", ticket_id)
|
logger.exception("更新定时工单 %d 失败状态也失败", ticket_id)
|
||||||
finally:
|
|
||||||
_active_tasks.pop(ticket_id, None)
|
|
||||||
|
|
||||||
|
|
||||||
def cancel_ticket_execution(ticket_id: int) -> bool:
|
def cancel_ticket_execution(ticket_id: int) -> bool:
|
||||||
@@ -128,20 +165,78 @@ def cancel_ticket_execution(ticket_id: int) -> bool:
|
|||||||
Returns:
|
Returns:
|
||||||
是否成功取消
|
是否成功取消
|
||||||
"""
|
"""
|
||||||
task = _active_tasks.get(ticket_id)
|
scheduler = _get_scheduler()
|
||||||
if task and not task.done():
|
job_id = f"ticket_{ticket_id}"
|
||||||
task.cancel()
|
try:
|
||||||
_active_tasks.pop(ticket_id, None)
|
scheduler.remove_job(job_id)
|
||||||
logger.info("已取消工单 %d 的定时执行", ticket_id)
|
logger.info("已取消工单 %d 的定时执行", ticket_id)
|
||||||
return True
|
return True
|
||||||
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def recover_scheduled_tickets() -> int:
|
||||||
|
"""启动时恢复所有 scheduled 状态的工单。
|
||||||
|
|
||||||
|
扫描 DB 中 status=scheduled 的工单,重新注册到 APScheduler。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
恢复的任务数量
|
||||||
|
"""
|
||||||
|
from app.core.database import async_session
|
||||||
|
from app.models.ticket import Ticket as TicketModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
async with async_session() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(TicketModel).where(TicketModel.status == "scheduled")
|
||||||
|
)
|
||||||
|
tickets = result.scalars().all()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for ticket in tickets:
|
||||||
|
if not ticket.scheduled_at:
|
||||||
|
continue
|
||||||
|
|
||||||
|
scheduled = ticket.scheduled_at
|
||||||
|
if scheduled.tzinfo is None:
|
||||||
|
scheduled = scheduled.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
if scheduled <= now:
|
||||||
|
# 已过期,立即执行
|
||||||
|
logger.info("恢复过期定时工单 %d,立即执行", ticket.id)
|
||||||
|
asyncio.create_task(_execute_scheduled_ticket(ticket.id))
|
||||||
|
else:
|
||||||
|
await schedule_ticket_execution(ticket.id, scheduled)
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
|
logger.info("恢复了 %d 个定时执行工单", count)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
def get_active_scheduled_count() -> int:
|
def get_active_scheduled_count() -> int:
|
||||||
"""返回当前活跃的定时任务数量。"""
|
"""返回当前 APScheduler 中的定时任务数量。"""
|
||||||
return len(_active_tasks)
|
scheduler = _get_scheduler()
|
||||||
|
if not scheduler.running:
|
||||||
|
return 0
|
||||||
|
jobs = scheduler.get_jobs()
|
||||||
|
return len([j for j in jobs if j.id.startswith("ticket_")])
|
||||||
|
|
||||||
|
|
||||||
def get_active_scheduled_ids() -> list[int]:
|
def get_active_scheduled_ids() -> list[int]:
|
||||||
"""返回当前活跃的定时工单 ID 列表。"""
|
"""返回当前定时工单 ID 列表。"""
|
||||||
return list(_active_tasks.keys())
|
scheduler = _get_scheduler()
|
||||||
|
if not scheduler.running:
|
||||||
|
return []
|
||||||
|
jobs = scheduler.get_jobs()
|
||||||
|
ids = []
|
||||||
|
for j in jobs:
|
||||||
|
if j.id.startswith("ticket_"):
|
||||||
|
try:
|
||||||
|
ids.append(int(j.id.split("_")[1]))
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
return ids
|
||||||
@@ -111,6 +111,8 @@ export default function TicketCreatePage() {
|
|||||||
|
|
||||||
// -- Inline audit result --
|
// -- Inline audit result --
|
||||||
const [auditResult, setAuditResult] = useState<AuditResult | null>(null);
|
const [auditResult, setAuditResult] = useState<AuditResult | null>(null);
|
||||||
|
const [validating, setValidating] = useState(false);
|
||||||
|
const [validated, setValidated] = useState(false);
|
||||||
|
|
||||||
// -- Schema Browser State --
|
// -- Schema Browser State --
|
||||||
const [tables, setTables] = useState<TableMeta[]>([]);
|
const [tables, setTables] = useState<TableMeta[]>([]);
|
||||||
@@ -249,37 +251,53 @@ export default function TicketCreatePage() {
|
|||||||
const handleSqlChange = useCallback((val: string) => {
|
const handleSqlChange = useCallback((val: string) => {
|
||||||
setSqlContent(val);
|
setSqlContent(val);
|
||||||
if (auditResult) setAuditResult(null);
|
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 () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
// 1. 表单校验
|
|
||||||
if (!title.trim()) { setError('请填写工单标题'); return; }
|
if (!title.trim()) { setError('请填写工单标题'); return; }
|
||||||
if (!instanceId) { setError('请选择目标实例'); return; }
|
if (!instanceId) { setError('请选择目标实例'); return; }
|
||||||
if (!database) { setError('请选择数据库'); return; }
|
if (!database) { setError('请选择数据库'); return; }
|
||||||
if (!sqlContent.trim()) { setError('请填写 SQL 语句'); return; }
|
if (!sqlContent.trim()) { setError('请填写 SQL 语句'); return; }
|
||||||
if (!reason.trim()) { setError('请填写变更原因'); return; }
|
if (!reason.trim()) { setError('请填写变更原因'); return; }
|
||||||
|
|
||||||
setError(''); setAuditResult(null); setSubmitting(true);
|
setError(''); setSubmitting(true);
|
||||||
|
|
||||||
try {
|
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));
|
const inst = instances.find((i) => i.id === Number(instanceId));
|
||||||
await ticketApi.create({
|
await ticketApi.create({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
@@ -298,7 +316,7 @@ export default function TicketCreatePage() {
|
|||||||
const msg = Array.isArray(raw) ? raw.map((v) => v.msg).join('; ') : (raw || '提交失败,请稍后重试');
|
const msg = Array.isArray(raw) ? raw.map((v) => v.msg).join('; ') : (raw || '提交失败,请稍后重试');
|
||||||
setError(msg);
|
setError(msg);
|
||||||
} finally { setSubmitting(false); }
|
} finally { setSubmitting(false); }
|
||||||
}, [title, sqlContent, instanceId, database, reason, instances, navigate]);
|
}, [title, sqlContent, instanceId, database, reason, instances, navigate, enableSchedule, scheduledAt]);
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
/* Derived values */
|
/* Derived values */
|
||||||
@@ -718,7 +736,7 @@ export default function TicketCreatePage() {
|
|||||||
min={new Date().toISOString().slice(0, 16)} />
|
min={new Date().toISOString().slice(0, 16)} />
|
||||||
)}
|
)}
|
||||||
{enableSchedule && scheduledAt && (
|
{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')} 执行
|
将于 {new Date(scheduledAt).toLocaleString('zh-CN')} 执行
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -795,13 +813,23 @@ export default function TicketCreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* === Submit Button === */}
|
{/* === Bottom Actions === */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end items-center gap-3">
|
||||||
<button onClick={handleSubmit} disabled={submitting}
|
<button onClick={handleValidate}
|
||||||
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">
|
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
|
{submitting
|
||||||
? <><Loader2 className="w-4 h-4 animate-spin" /> 审核提交中...</>
|
? <><Loader2 className="w-4 h-4 animate-spin" /> 提交中...</>
|
||||||
: <><Shield className="w-4 h-4" /> 提交工单</>}
|
: validated
|
||||||
|
? <><Check className="w-4 h-4" /> 提交工单</>
|
||||||
|
: <><Check className="w-4 h-4" /> 请先通过 SQL 检测</>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user