Commit ed210527 by niuxing

初始化报表工具

1 parent 6d624c29
...@@ -11,7 +11,7 @@ import schedulerRouter from './routes/scheduler.js' ...@@ -11,7 +11,7 @@ import schedulerRouter from './routes/scheduler.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const app = express() const app = express()
const PORT = 4000 const PORT = 4000
const HOSTNAME = "0.0.0.0" const HOSTNAME = "localhost"
// 中间件 // 中间件
app.use(cors()) app.use(cors())
......
import { Router } from 'express' import { Router } from 'express'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { executeQuery } from '../db.js' import { executeQuery } from '../db.js'
import { analyzeSql } from '../sql-analyzer.js' import { analyzeSql, validateSelectOnly } from '../sql-analyzer.js'
import { setCache, getCache, searchInCache } from '../cache.js' import { setCache, getCache, searchInCache } from '../cache.js'
const router = Router() const router = Router()
...@@ -16,6 +16,13 @@ router.post('/check', async (req, res) => { ...@@ -16,6 +16,13 @@ router.post('/check', async (req, res) => {
if (!connId || !sql) { if (!connId || !sql) {
return res.json({ success: false, message: '缺少参数' }) return res.json({ success: false, message: '缺少参数' })
} }
// 校验SQL必须为SELECT语句
const sqlCheck = validateSelectOnly(sql)
if (!sqlCheck.allowed) {
return res.json({ success: false, message: sqlCheck.reason })
}
const result = await analyzeSql(connId, sql) const result = await analyzeSql(connId, sql)
res.json({ success: true, data: result }) res.json({ success: true, data: result })
} catch (e) { } catch (e) {
...@@ -34,6 +41,12 @@ router.post('/execute', async (req, res) => { ...@@ -34,6 +41,12 @@ router.post('/execute', async (req, res) => {
return res.json({ success: false, message: '缺少参数' }) return res.json({ success: false, message: '缺少参数' })
} }
// 校验SQL必须为SELECT语句
const sqlCheck = validateSelectOnly(sql)
if (!sqlCheck.allowed) {
return res.json({ success: false, message: sqlCheck.reason })
}
const startTime = Date.now() const startTime = Date.now()
const data = await executeQuery(connId, sql) const data = await executeQuery(connId, sql)
const duration = Date.now() - startTime const duration = Date.now() - startTime
......
...@@ -9,6 +9,7 @@ import { startScriptTask, stopScriptTask, reloadAllScriptTasks, executeScriptTas ...@@ -9,6 +9,7 @@ import { startScriptTask, stopScriptTask, reloadAllScriptTasks, executeScriptTas
import { getPool } from '../db.js' import { getPool } from '../db.js'
import { buildSqlWithDateRange } from '../condition-builder.js' import { buildSqlWithDateRange } from '../condition-builder.js'
import { sendReportMail, loadSmtpConfig } from '../mail.js' import { sendReportMail, loadSmtpConfig } from '../mail.js'
import { validateSelectOnly } from '../sql-analyzer.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const SCRIPTS_FILE = path.join(__dirname, '..', 'sql-scripts.json') const SCRIPTS_FILE = path.join(__dirname, '..', 'sql-scripts.json')
...@@ -60,6 +61,12 @@ router.post('/', (req, res) => { ...@@ -60,6 +61,12 @@ router.post('/', (req, res) => {
return res.json({ success: false, message: '脚本名称和SQL内容不能为空' }) return res.json({ success: false, message: '脚本名称和SQL内容不能为空' })
} }
// 校验SQL必须为SELECT语句
const sqlCheck = validateSelectOnly(sql)
if (!sqlCheck.allowed) {
return res.json({ success: false, message: sqlCheck.reason })
}
const scripts = loadScripts() const scripts = loadScripts()
const id = `script_${Date.now()}` const id = `script_${Date.now()}`
const now = new Date().toISOString() const now = new Date().toISOString()
...@@ -114,6 +121,15 @@ router.put('/:id', (req, res) => { ...@@ -114,6 +121,15 @@ router.put('/:id', (req, res) => {
return res.json({ success: false, message: '脚本不存在' }) return res.json({ success: false, message: '脚本不存在' })
} }
// 校验SQL必须为SELECT语句(仅当sql被更新时校验)
const newSql = sql !== undefined ? sql : scripts[idx].sql
if (newSql) {
const sqlCheck = validateSelectOnly(newSql)
if (!sqlCheck.allowed) {
return res.json({ success: false, message: sqlCheck.reason })
}
}
// 先停止旧的定时任务 // 先停止旧的定时任务
stopScriptTask(id) stopScriptTask(id)
...@@ -168,6 +184,13 @@ router.post('/:id/run', async (req, res) => { ...@@ -168,6 +184,13 @@ router.post('/:id/run', async (req, res) => {
const scripts = loadScripts() const scripts = loadScripts()
const script = scripts.find(s => s.id === req.params.id) const script = scripts.find(s => s.id === req.params.id)
if (!script) return res.json({ success: false, message: '脚本不存在' }) if (!script) return res.json({ success: false, message: '脚本不存在' })
// 校验SQL必须为SELECT语句
const sqlCheck = validateSelectOnly(script.sql)
if (!sqlCheck.allowed) {
return res.json({ success: false, message: sqlCheck.reason })
}
executeScriptTask(script) executeScriptTask(script)
res.json({ success: true, message: '任务已触发执行' }) res.json({ success: true, message: '任务已触发执行' })
} catch (e) { } catch (e) {
...@@ -188,6 +211,12 @@ router.post('/:id/send-mail', async (req, res) => { ...@@ -188,6 +211,12 @@ router.post('/:id/send-mail', async (req, res) => {
return res.json({ success: false, message: '请指定收件人邮箱' }) return res.json({ success: false, message: '请指定收件人邮箱' })
} }
// 校验SQL必须为SELECT语句
const sqlCheck = validateSelectOnly(script.sql)
if (!sqlCheck.allowed) {
return res.json({ success: false, message: sqlCheck.reason })
}
// 验证SMTP配置 // 验证SMTP配置
const smtpConfig = loadSmtpConfig() const smtpConfig = loadSmtpConfig()
if (!smtpConfig) { if (!smtpConfig) {
......
...@@ -7,6 +7,7 @@ import xlsx from 'xlsx' ...@@ -7,6 +7,7 @@ import xlsx from 'xlsx'
import { getPool } from './db.js' import { getPool } from './db.js'
import { buildSqlWithDateRange } from './condition-builder.js' import { buildSqlWithDateRange } from './condition-builder.js'
import { sendReportMail } from './mail.js' import { sendReportMail } from './mail.js'
import { validateSelectOnly } from './sql-analyzer.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const SCRIPTS_FILE = path.join(__dirname, '..', 'sql-scripts.json') const SCRIPTS_FILE = path.join(__dirname, '..', 'sql-scripts.json')
...@@ -66,6 +67,14 @@ export async function executeScriptTask(script) { ...@@ -66,6 +67,14 @@ export async function executeScriptTask(script) {
console.log(`[定时任务] 开始执行: ${script.name} (${new Date().toLocaleString()})`) console.log(`[定时任务] 开始执行: ${script.name} (${new Date().toLocaleString()})`)
try { try {
// 校验SQL必须为SELECT语句
const sqlCheck = validateSelectOnly(script.sql)
if (!sqlCheck.allowed) {
updateScriptStatus(script.id, 'failed', sqlCheck.reason)
console.error(`[定时任务] ${script.name}: SQL校验失败 - ${sqlCheck.reason}`)
return
}
// 1. 解析动态条件 // 1. 解析动态条件
const resolvedConditions = resolveDynamicValues(script.conditions || []) const resolvedConditions = resolveDynamicValues(script.conditions || [])
......
...@@ -122,14 +122,132 @@ export async function analyzeSql(connId, sql) { ...@@ -122,14 +122,132 @@ export async function analyzeSql(connId, sql) {
* 检测SQL类型 * 检测SQL类型
*/ */
function detectSqlType(sql) { function detectSqlType(sql) {
const trimmed = sql.trim().toUpperCase() const normalized = normalizeSqlForDetection(sql)
if (trimmed.startsWith('SELECT')) return 'SELECT' if (/^\s*SELECT\b/i.test(normalized)) return 'SELECT'
if (trimmed.startsWith('INSERT')) return 'INSERT' if (/^\s*WITH\b/i.test(normalized)) return 'SELECT' // CTE + SELECT
if (trimmed.startsWith('UPDATE')) return 'UPDATE' if (/^\s*INSERT\b/i.test(normalized)) return 'INSERT'
if (trimmed.startsWith('DELETE')) return 'DELETE' if (/^\s*UPDATE\b/i.test(normalized)) return 'UPDATE'
if (trimmed.startsWith('CREATE')) return 'CREATE' if (/^\s*DELETE\b/i.test(normalized)) return 'DELETE'
if (trimmed.startsWith('ALTER')) return 'ALTER' if (/^\s*REPLACE\b/i.test(normalized)) return 'REPLACE'
if (trimmed.startsWith('DROP')) return 'DROP' if (/^\s*CREATE\b/i.test(normalized)) return 'CREATE'
if (trimmed.startsWith('TRUNCATE')) return 'TRUNCATE' if (/^\s*ALTER\b/i.test(normalized)) return 'ALTER'
if (/^\s*DROP\b/i.test(normalized)) return 'DROP'
if (/^\s*TRUNCATE\b/i.test(normalized)) return 'TRUNCATE'
if (/^\s*LOAD\s+DATA\b/i.test(normalized)) return 'LOAD_DATA'
if (/^\s*CALL\b/i.test(normalized)) return 'CALL'
if (/^\s*GRANT\b/i.test(normalized)) return 'GRANT'
if (/^\s*REVOKE\b/i.test(normalized)) return 'REVOKE'
return 'OTHER' return 'OTHER'
} }
/**
* 去除SQL前缀注释和空白,用于类型检测
*/
function normalizeSqlForDetection(sql) {
// 去除单行注释 (-- 和 #)
let normalized = sql.replace(/--[^\n]*/g, ' ').replace(/#[^\n]*/g, ' ')
// 去除多行注释
normalized = normalized.replace(/\/\*[\s\S]*?\*\//g, ' ')
return normalized.trim()
}
/**
* 校验SQL是否为只读SELECT语句
* 支持检测多语句场景(分号分隔)
* @param {string} sql
* @returns {{ allowed: boolean, reason: string|null, detectedType: string }}
*/
export function validateSelectOnly(sql) {
if (!sql || !sql.trim()) {
return { allowed: false, reason: 'SQL内容不能为空', detectedType: 'EMPTY' }
}
// 分号分隔的多语句逐条检测
const statements = splitSqlStatements(sql)
for (let i = 0; i < statements.length; i++) {
const stmt = statements[i]
if (!stmt.trim()) continue
const type = detectSqlType(stmt)
if (type === 'SELECT') continue // SELECT允许
// 非SELECT语句,拒绝
const DANGEROUS_LABELS = {
'INSERT': 'INSERT(插入数据)',
'UPDATE': 'UPDATE(更新数据)',
'DELETE': 'DELETE(删除数据)',
'REPLACE': 'REPLACE(替换数据)',
'CREATE': 'CREATE(创建对象)',
'ALTER': 'ALTER(修改结构)',
'DROP': 'DROP(删除对象)',
'TRUNCATE': 'TRUNCATE(清空表)',
'LOAD_DATA': 'LOAD DATA(导入数据)',
'CALL': 'CALL(调用存储过程)',
'GRANT': 'GRANT(授权)',
'REVOKE': 'REVOKE(回收权限)',
'OTHER': '未知类型',
}
const label = DANGEROUS_LABELS[type] || type
return {
allowed: false,
reason: `脚本仅允许SELECT查询语句,检测到第${i + 1}条语句为 ${label},不允许执行`,
detectedType: type,
}
}
return { allowed: true, reason: null, detectedType: 'SELECT' }
}
/**
* 按分号分割SQL语句(忽略引号内的分号)
*/
function splitSqlStatements(sql) {
const statements = []
let current = ''
let inSingleQuote = false
let inDoubleQuote = false
for (let i = 0; i < sql.length; i++) {
const ch = sql[i]
const next = sql[i + 1]
if (ch === "'" && !inDoubleQuote) {
if (inSingleQuote && next === "'") {
// 转义单引号 ''
current += ch + next
i++
continue
}
inSingleQuote = !inSingleQuote
current += ch
continue
}
if (ch === '"' && !inSingleQuote) {
if (inDoubleQuote && next === '"') {
current += ch + next
i++
continue
}
inDoubleQuote = !inDoubleQuote
current += ch
continue
}
if (ch === ';' && !inSingleQuote && !inDoubleQuote) {
statements.push(current)
current = ''
continue
}
current += ch
}
if (current.trim()) {
statements.push(current)
}
return statements
}
...@@ -24,6 +24,42 @@ import SmtpConfigModal from './components/SmtpConfigModal' ...@@ -24,6 +24,42 @@ import SmtpConfigModal from './components/SmtpConfigModal'
import ResultTable from './components/ResultTable' import ResultTable from './components/ResultTable'
import request from './utils/request' import request from './utils/request'
// 前端SQL类型检测(与后端 sql-analyzer.js 保持一致)
const BLOCKED_SQL_TYPES = ['INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'LOAD DATA', 'CALL', 'GRANT', 'REVOKE']
function detectSqlTypeFrontend(sql) {
if (!sql || !sql.trim()) return { type: 'EMPTY', isSelect: true }
const cleaned = sql.replace(/--[^\n]*/g, ' ').replace(/#[^\n]*/g, ' ').replace(/\/\*[\s\S]*?\*\//g, ' ').trim()
const upper = cleaned.toUpperCase()
if (/^\s*SELECT\b/.test(upper)) return { type: 'SELECT', isSelect: true }
if (/^\s*WITH\b/.test(upper)) return { type: 'SELECT', isSelect: true }
for (const kw of BLOCKED_SQL_TYPES) {
const regex = new RegExp(`^\\s*${kw.replace(/\s+/g, '\\s+')}\\b`, 'i')
if (regex.test(upper)) return { type: kw.replace(/\s+/g, '_'), isSelect: false, label: kw }
}
return { type: 'OTHER', isSelect: false, label: '未知类型' }
}
function validateSelectOnlyFrontend(sql) {
if (!sql || !sql.trim()) return { allowed: true, reason: null }
const stmts = sql.split(';').filter(s => s.trim())
for (let i = 0; i < stmts.length; i++) {
const det = detectSqlTypeFrontend(stmts[i])
if (!det.isSelect) {
return { allowed: false, reason: `仅允许SELECT查询语句,检测到第${i + 1}条语句为 ${det.label},不允许执行` }
}
}
return { allowed: true, reason: null }
}
// 清空右侧查询结果
function clearQueryResults(setQueryId, setResultColumns, setResultTotal, setResultDuration) {
setQueryId(null)
setResultColumns([])
setResultTotal(0)
setResultDuration(null)
}
export default function App() { export default function App() {
// 数据库连接状态 // 数据库连接状态
const [connId, setConnId] = useState(null) const [connId, setConnId] = useState(null)
...@@ -137,6 +173,15 @@ export default function App() { ...@@ -137,6 +173,15 @@ export default function App() {
} }
const sql = buildFinalSql() const sql = buildFinalSql()
// 校验SQL必须为SELECT语句
const sqlCheck = validateSelectOnlyFrontend(sql)
if (!sqlCheck.allowed) {
clearQueryResults(setQueryId, setResultColumns, setResultTotal, setResultDuration)
showError(sqlCheck.reason, sql, 'SQL校验')
return
}
setFinalSql(sql) setFinalSql(sql)
try { try {
...@@ -145,9 +190,11 @@ export default function App() { ...@@ -145,9 +190,11 @@ export default function App() {
setCheckResult(res.data) setCheckResult(res.data)
setCheckModalOpen(true) setCheckModalOpen(true)
} else { } else {
clearQueryResults(setQueryId, setResultColumns, setResultTotal, setResultDuration)
showError(res.message, sql, '预检查阶段') showError(res.message, sql, '预检查阶段')
} }
} catch (e) { } catch (e) {
clearQueryResults(setQueryId, setResultColumns, setResultTotal, setResultDuration)
showError(e.message || '检查失败', sql, '预检查阶段') showError(e.message || '检查失败', sql, '预检查阶段')
} }
} }
...@@ -155,6 +202,15 @@ export default function App() { ...@@ -155,6 +202,15 @@ export default function App() {
// 确认执行 // 确认执行
const handleConfirmExecute = async () => { const handleConfirmExecute = async () => {
setCheckModalOpen(false) setCheckModalOpen(false)
// 校验SQL必须为SELECT语句(双保险)
const sqlCheck = validateSelectOnlyFrontend(finalSql)
if (!sqlCheck.allowed) {
clearQueryResults(setQueryId, setResultColumns, setResultTotal, setResultDuration)
showError(sqlCheck.reason, finalSql, 'SQL校验')
return
}
setExecLoading(true) setExecLoading(true)
try { try {
...@@ -173,9 +229,11 @@ export default function App() { ...@@ -173,9 +229,11 @@ export default function App() {
// 执行成功后自动同步参数到脚本 // 执行成功后自动同步参数到脚本
syncConditionsToScript() syncConditionsToScript()
} else { } else {
clearQueryResults(setQueryId, setResultColumns, setResultTotal, setResultDuration)
showError(res.message, finalSql, '确认执行阶段') showError(res.message, finalSql, '确认执行阶段')
} }
} catch (e) { } catch (e) {
clearQueryResults(setQueryId, setResultColumns, setResultTotal, setResultDuration)
showError(e.message || '执行失败', finalSql, '确认执行阶段') showError(e.message || '执行失败', finalSql, '确认执行阶段')
} finally { } finally {
setExecLoading(false) setExecLoading(false)
...@@ -193,6 +251,15 @@ export default function App() { ...@@ -193,6 +251,15 @@ export default function App() {
} }
const sql = buildFinalSql() const sql = buildFinalSql()
// 校验SQL必须为SELECT语句
const sqlCheck = validateSelectOnlyFrontend(sql)
if (!sqlCheck.allowed) {
clearQueryResults(setQueryId, setResultColumns, setResultTotal, setResultDuration)
showError(sqlCheck.reason, sql, 'SQL校验')
return
}
setFinalSql(sql) setFinalSql(sql)
setExecLoading(true) setExecLoading(true)
...@@ -212,9 +279,11 @@ export default function App() { ...@@ -212,9 +279,11 @@ export default function App() {
// 执行成功后自动同步参数到脚本 // 执行成功后自动同步参数到脚本
syncConditionsToScript() syncConditionsToScript()
} else { } else {
clearQueryResults(setQueryId, setResultColumns, setResultTotal, setResultDuration)
showError(res.message, sql, '执行阶段') showError(res.message, sql, '执行阶段')
} }
} catch (e) { } catch (e) {
clearQueryResults(setQueryId, setResultColumns, setResultTotal, setResultDuration)
showError(e.message || '执行失败', sql, '执行阶段') showError(e.message || '执行失败', sql, '执行阶段')
} finally { } finally {
setExecLoading(false) setExecLoading(false)
......
...@@ -8,7 +8,7 @@ import { ...@@ -8,7 +8,7 @@ import {
PlusOutlined, DeleteOutlined, EditOutlined, ImportOutlined, PlusOutlined, DeleteOutlined, EditOutlined, ImportOutlined,
SearchOutlined, SaveOutlined, MailOutlined, ClockCircleOutlined, SearchOutlined, SaveOutlined, MailOutlined, ClockCircleOutlined,
SettingOutlined, PlayCircleOutlined, CheckCircleOutlined, SettingOutlined, PlayCircleOutlined, CheckCircleOutlined,
CloseCircleOutlined, UploadOutlined, CloseCircleOutlined, UploadOutlined, WarningOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import request from '../utils/request' import request from '../utils/request'
...@@ -51,6 +51,36 @@ const validateEmails = (rule, value) => { ...@@ -51,6 +51,36 @@ const validateEmails = (rule, value) => {
return Promise.resolve() return Promise.resolve()
} }
// 前端SQL类型检测(简化版,与后端 sql-analyzer.js 保持一致)
const BLOCKED_SQL_TYPES = ['INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'LOAD DATA', 'CALL', 'GRANT', 'REVOKE']
function detectSqlTypeFrontend(sql) {
if (!sql || !sql.trim()) return { type: 'EMPTY', isSelect: true, label: '' }
// 去除注释
const cleaned = sql.replace(/--[^\n]*/g, ' ').replace(/#[^\n]*/g, ' ').replace(/\/\*[\s\S]*?\*\//g, ' ').trim()
const upper = cleaned.toUpperCase()
if (/^\s*SELECT\b/.test(upper)) return { type: 'SELECT', isSelect: true, label: 'SELECT(查询)' }
if (/^\s*WITH\b/.test(upper)) return { type: 'SELECT', isSelect: true, label: 'CTE + SELECT(查询)' }
for (const kw of BLOCKED_SQL_TYPES) {
const regex = new RegExp(`^\\s*${kw.replace(/\s+/g, '\\s+')}\\b`, 'i')
if (regex.test(upper)) return { type: kw.replace(/\s+/g, '_'), isSelect: false, label: `${kw}` }
}
return { type: 'OTHER', isSelect: false, label: '未知类型' }
}
function validateSelectOnlyFrontend(sql) {
if (!sql || !sql.trim()) return { allowed: true, reason: null }
// 按分号分割(简化版)
const stmts = sql.split(';').filter(s => s.trim())
for (let i = 0; i < stmts.length; i++) {
const det = detectSqlTypeFrontend(stmts[i])
if (!det.isSelect) {
return { allowed: false, reason: `脚本仅允许SELECT查询语句,检测到第${i + 1}条语句为 ${det.label},不允许保存和执行` }
}
}
return { allowed: true, reason: null }
}
export default function SqlManagerModal({ open, onClose, onLoadSql, currentSql, currentConditions, connId, currentScriptId, onScriptIdChange }) { export default function SqlManagerModal({ open, onClose, onLoadSql, currentSql, currentConditions, connId, currentScriptId, onScriptIdChange }) {
const [scripts, setScripts] = useState([]) const [scripts, setScripts] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
...@@ -548,10 +578,27 @@ export default function SqlManagerModal({ open, onClose, onLoadSql, currentSql, ...@@ -548,10 +578,27 @@ export default function SqlManagerModal({ open, onClose, onLoadSql, currentSql,
<Form.Item name="sql" label="SQL内容" rules={[{ required: true, message: 'SQL内容不能为空' }]}> <Form.Item name="sql" label="SQL内容" rules={[{ required: true, message: 'SQL内容不能为空' }]}>
<TextArea <TextArea
rows={6} rows={6}
placeholder="SQL脚本内容" placeholder="仅允许 SELECT 查询语句,不支持 INSERT/UPDATE/DELETE/DDL 等数据变更操作"
style={{ fontFamily: 'Menlo, Monaco, Consolas, monospace', fontSize: 12 }} style={{ fontFamily: 'Menlo, Monaco, Consolas, monospace', fontSize: 12 }}
/> />
</Form.Item> </Form.Item>
<Form.Item shouldUpdate={(prev, cur) => prev.sql !== cur.sql} noStyle>
{({ getFieldValue }) => {
const sqlVal = getFieldValue('sql') || ''
const check = validateSelectOnlyFrontend(sqlVal)
if (sqlVal.trim() && !check.allowed) {
return (
<div style={{
padding: '6px 12px', marginBottom: 12, background: '#fff2f0',
border: '1px solid #ffccc7', borderRadius: 6, fontSize: 12, color: '#cf1322',
}}>
<WarningOutlined style={{ marginRight: 4 }} />{check.reason}
</div>
)
}
return null
}}
</Form.Item>
<div style={{ textAlign: 'right', marginBottom: 8 }}> <div style={{ textAlign: 'right', marginBottom: 8 }}>
<Button size="small" icon={<SearchOutlined />} onClick={handleParseConditionsInDetail}> <Button size="small" icon={<SearchOutlined />} onClick={handleParseConditionsInDetail}>
从SQL解析参数 从SQL解析参数
......
Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!