Commit 6d624c29 by niuxing

初始化报表工具

0 parents
node_modules/
dist/
connections.json
sql-scripts.json
smtp-config.json
*.log
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SQL报表工具</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
This diff could not be displayed because it is too large.
{
"name": "sql-report-tool",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:server": "nodemon server/index.js",
"dev:client": "vite",
"build": "vite build",
"start": "node server/index.js"
},
"dependencies": {
"@ant-design/icons": "^5.3.0",
"@codemirror/lang-sql": "^6.7.0",
"@uiw/react-codemirror": "^4.21.0",
"antd": "^5.15.0",
"axios": "^1.6.0",
"cors": "^2.8.5",
"dayjs": "^1.11.10",
"express": "^4.18.0",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.13",
"mysql2": "^3.9.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sql-formatter": "^15.0.0",
"uuid": "^9.0.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"concurrently": "^8.2.0",
"nodemon": "^3.0.0",
"vite": "^5.1.0"
}
}
// 查询结果缓存 - 服务端内存缓存,用于二次查找和分页
const cache = new Map()
// 缓存过期时间(毫秒),默认30分钟
const CACHE_TTL = 30 * 60 * 1000
// 定时清理过期缓存
setInterval(() => {
const now = Date.now()
for (const [key, value] of cache) {
if (now - value.createdAt > CACHE_TTL) {
cache.delete(key)
}
}
}, 60 * 1000)
// 存储查询结果
export function setCache(queryId, data) {
cache.set(queryId, {
data,
columns: Object.keys(data[0] || {}),
createdAt: Date.now(),
totalCount: data.length,
})
}
// 获取缓存的查询结果
export function getCache(queryId) {
const entry = cache.get(queryId)
if (!entry) return null
if (Date.now() - entry.createdAt > CACHE_TTL) {
cache.delete(queryId)
return null
}
return entry
}
// 在缓存中二次查找
export function searchInCache(queryId, keyword) {
const entry = getCache(queryId)
if (!entry) return null
if (!keyword || keyword.trim() === '') {
return entry
}
const lowerKeyword = keyword.toLowerCase()
const filtered = entry.data.filter(row =>
entry.columns.some(col => {
const val = row[col]
if (val === null || val === undefined) return false
return String(val).toLowerCase().includes(lowerKeyword)
})
)
return {
...entry,
data: filtered,
totalCount: filtered.length,
}
}
// 删除缓存
export function deleteCache(queryId) {
cache.delete(queryId)
}
// 清理所有缓存
export function clearAllCache() {
cache.clear()
}
// 条件构建器 - 前后端共享
// 从 ConditionPanel.jsx 抽取的核心逻辑,供服务端定时任务使用
import dayjs from 'dayjs'
function buildReplacement(cond) {
const { type, value, valueStart, valueEnd, importedValues, dictOptions } = cond
switch (type) {
case 'exact':
return value ? `'${value}'` : "''"
case 'fuzzy':
return value ? `'%${value}%'` : "'%%'"
case 'daterange':
return valueStart ? `'${dayjs(valueStart).format('YYYY-MM-DD')}'` : "'2024-01-01'"
case 'datemonth':
return value ? `'${dayjs(value).format('YYYY-MM')}'` : "'2024-01'"
case 'dateyear':
return value ? `'${dayjs(value).format('YYYY')}'` : "'2024'"
case 'dateday':
return value ? `'${dayjs(value).format('YYYY-MM-DD')}'` : "'2024-01-01'"
case 'dictionary':
return value ? `'${value}'` : "''"
case 'excel_import':
if (importedValues && importedValues.length > 0) {
const quoted = importedValues.map(v => `'${v}'`).join(',')
return quoted
}
return "''"
default:
return value ? `'${value}'` : "''"
}
}
/**
* 处理日期区间中的结束日期占位符
* 约定:{{end_xxx}} 对应 xxx 的日期区间结束值
*/
export function buildSqlWithDateRange(sql, conditions) {
let result = sql
for (const cond of conditions) {
const placeholder = `{{${cond.name}}}`
const replacement = buildReplacement(cond)
result = result.replaceAll(placeholder, replacement)
// 处理日期区间结束占位符 {{end_xxx}}
if (cond.type === 'daterange' && cond.valueEnd) {
const endPlaceholder = `{{end_${cond.name}}}`
const endReplacement = `'${dayjs(cond.valueEnd).format('YYYY-MM-DD')}'`
result = result.replaceAll(endPlaceholder, endReplacement)
}
}
return result
}
import mysql from 'mysql2/promise'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const CONFIG_FILE = path.join(__dirname, '..', 'connections.json')
// 连接池缓存 { connId: pool }
const pools = new Map()
// 读取连接配置
function loadConnections() {
try {
if (fs.existsSync(CONFIG_FILE)) {
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'))
}
} catch (e) {
console.error('读取连接配置失败:', e.message)
}
return []
}
// 保存连接配置
function saveConnections(connections) {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(connections, null, 2), 'utf-8')
}
// 获取所有连接
export function getConnections() {
return loadConnections()
}
// 新增连接
export function addConnection(conn) {
const connections = loadConnections()
const id = `conn_${Date.now()}`
const newConn = { id, ...conn, createdAt: new Date().toISOString() }
connections.push(newConn)
saveConnections(connections)
return newConn
}
// 删除连接
export function removeConnection(id) {
const connections = loadConnections()
const idx = connections.findIndex(c => c.id === id)
if (idx >= 0) connections.splice(idx, 1)
saveConnections(connections)
closePool(id)
return true
}
// 测试连接
export async function testConnection(conn) {
let connection
try {
connection = await mysql.createConnection({
host: conn.host,
port: Number(conn.port) || 3306,
user: conn.user,
password: conn.password,
database: conn.database,
connectTimeout: 5000,
})
await connection.ping()
return { success: true, message: '连接成功' }
} catch (e) {
return { success: false, message: e.message }
} finally {
if (connection) await connection.end().catch(() => {})
}
}
// 获取连接池
export function getPool(connId) {
if (pools.has(connId)) return pools.get(connId)
const connections = loadConnections()
const conn = connections.find(c => c.id === connId)
if (!conn) throw new Error('连接配置不存在')
const pool = mysql.createPool({
host: conn.host,
port: Number(conn.port) || 3306,
user: conn.user,
password: conn.password,
database: conn.database,
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0,
connectTimeout: 10000,
})
pools.set(connId, pool)
return pool
}
// 关闭连接池
export function closePool(connId) {
const pool = pools.get(connId)
if (pool) {
pool.end().catch(() => {})
pools.delete(connId)
}
}
// 执行SQL查询
export async function executeQuery(connId, sql, params = []) {
const pool = getPool(connId)
const [rows] = await pool.query(sql, params)
return rows
}
// 执行EXPLAIN
export async function executeExplain(connId, sql) {
const pool = getPool(connId)
try {
const [rows] = await pool.query(`EXPLAIN ${sql}`)
return rows
} catch {
// 某些SQL不支持EXPLAIN,尝试EXPLAIN FORMAT=JSON
try {
const [rows] = await pool.query(`EXPLAIN FORMAT=JSON ${sql}`)
return rows
} catch {
return null
}
}
}
import express from 'express'
import cors from 'cors'
import path from 'path'
import { fileURLToPath } from 'url'
import connectionRouter from './routes/connection.js'
import queryRouter from './routes/query.js'
import exportRouter from './routes/export.js'
import sqlManageRouter from './routes/sql-manage.js'
import schedulerRouter from './routes/scheduler.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const app = express()
const PORT = 4000
const HOSTNAME = "0.0.0.0"
// 中间件
app.use(cors())
app.use(express.json({ limit: '50mb' }))
// API路由
app.use('/api/connection', connectionRouter)
app.use('/api/query', queryRouter)
app.use('/api/export', exportRouter)
app.use('/api/sql-manage', sqlManageRouter)
app.use('/api/scheduler', schedulerRouter)
// 生产模式下服务前端静态文件
const distPath = path.join(__dirname, '..', 'dist')
app.use(express.static(distPath))
app.get('*', (req, res) => {
if (!req.path.startsWith('/api')) {
res.sendFile(path.join(distPath, 'index.html'))
}
})
app.listen(PORT, HOSTNAME,() => {
console.log(`\n SQL报表工具 - 后端服务已启动`)
console.log(` API地址: http://${HOSTNAME}:${PORT}`)
console.log(` 前端地址: http://${HOSTNAME}:3000\n`)
})
import nodemailer from 'nodemailer'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const SMTP_CONFIG_FILE = path.join(__dirname, '..', 'smtp-config.json')
// 读取SMTP配置
export function loadSmtpConfig() {
try {
if (fs.existsSync(SMTP_CONFIG_FILE)) {
return JSON.parse(fs.readFileSync(SMTP_CONFIG_FILE, 'utf-8'))
}
} catch (e) {
console.error('读取SMTP配置失败:', e.message)
}
return null
}
// 保存SMTP配置
export function saveSmtpConfig(config) {
fs.writeFileSync(SMTP_CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8')
}
// 创建邮件传输器
function createTransporter(config) {
return nodemailer.createTransport({
host: config.host,
port: Number(config.port) || 465,
secure: Number(config.port) === 465, // 465为SSL,587为TLS
auth: {
user: config.user,
pass: config.pass,
},
})
}
// 测试SMTP连接
export async function testSmtpConnection(config) {
try {
const transporter = createTransporter(config)
await transporter.verify()
transporter.close()
return { success: true, message: 'SMTP连接测试成功' }
} catch (e) {
return { success: false, message: `SMTP连接失败: ${e.message}` }
}
}
/**
* 发送报表邮件
* @param {Object} options
* @param {string} options.to - 收件人,多个用逗号分隔
* @param {string} options.subject - 邮件主题
* @param {string} options.html - 邮件正文HTML
* @param {Buffer} options.excelBuffer - Excel文件Buffer
* @param {string} options.fileName - 附件文件名
*/
export async function sendReportMail({ to, subject, html, excelBuffer, fileName }) {
const config = loadSmtpConfig()
if (!config) {
throw new Error('未配置SMTP邮件服务,请先在"邮件配置"中设置')
}
const transporter = createTransporter(config)
try {
const result = await transporter.sendMail({
from: `"${config.senderName || 'SQL报表工具'}" <${config.user}>`,
to,
subject,
html,
attachments: [
{
filename: fileName || 'report.xlsx',
content: excelBuffer,
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
},
],
})
return { success: true, messageId: result.messageId }
} catch (e) {
throw new Error(`邮件发送失败: ${e.message}`)
} finally {
transporter.close()
}
}
import { Router } from 'express'
import { getConnections, addConnection, removeConnection, testConnection, getPool } from '../db.js'
const router = Router()
// 获取所有连接
router.get('/', (req, res) => {
const connections = getConnections()
// 隐藏密码
const safe = connections.map(c => ({ ...c, password: '******' }))
res.json({ success: true, data: safe })
})
// 新增连接
router.post('/', async (req, res) => {
try {
const { name, host, port, user, password, database } = req.body
if (!name || !host || !user || !database) {
return res.json({ success: false, message: '缺少必填字段' })
}
// 先测试连接
const testResult = await testConnection({ host, port, user, password, database })
if (!testResult.success) {
return res.json({ success: false, message: `连接测试失败: ${testResult.message}` })
}
const conn = addConnection({ name, host, port, user, password, database })
res.json({ success: true, data: { ...conn, password: '******' } })
} catch (e) {
res.json({ success: false, message: e.message })
}
})
// 测试连接
router.post('/test', async (req, res) => {
try {
const { host, port, user, password, database } = req.body
const result = await testConnection({ host, port, user, password, database })
res.json({ success: result.success, message: result.message })
} catch (e) {
res.json({ success: false, message: e.message })
}
})
// 删除连接
router.delete('/:id', (req, res) => {
removeConnection(req.params.id)
res.json({ success: true })
})
// 获取连接的数据库列表
router.get('/:id/databases', async (req, res) => {
try {
const pool = getPool(req.params.id)
const [rows] = await pool.query('SHOW DATABASES')
res.json({ success: true, data: rows.map(r => r.Database) })
} catch (e) {
res.json({ success: false, message: e.message })
}
})
// 获取连接的表列表
router.get('/:id/tables', async (req, res) => {
try {
const pool = getPool(req.params.id)
const [rows] = await pool.query('SHOW TABLES')
const key = Object.keys(rows[0] || {})[0]
res.json({ success: true, data: rows.map(r => r[key]) })
} catch (e) {
res.json({ success: false, message: e.message })
}
})
export default router
import { Router } from 'express'
import xlsx from 'xlsx'
import { getCache, searchInCache } from '../cache.js'
const router = Router()
/**
* 导出Excel
* GET /api/export/excel/:queryId?keyword=xxx
*/
router.get('/excel/:queryId', (req, res) => {
try {
const { queryId } = req.params
const keyword = req.query.keyword || ''
const fileName = req.query.fileName || `query_result_${Date.now()}`
const entry = keyword ? searchInCache(queryId, keyword) : getCache(queryId)
if (!entry) {
return res.json({ success: false, message: '查询结果已过期,请重新执行' })
}
// 构建Excel
const wb = xlsx.utils.book_new()
const wsData = [entry.columns]
for (const row of entry.data) {
const rowData = entry.columns.map(col => {
const val = row[col]
// 处理Buffer类型(如BLOB字段)
if (Buffer.isBuffer(val)) return val.toString('hex')
// 处理Date类型
if (val instanceof Date) return val.toISOString().replace('T', ' ').replace(/\.\d+Z$/, '')
return val
})
wsData.push(rowData)
}
const ws = xlsx.utils.aoa_to_sheet(wsData)
// 设置列宽
ws['!cols'] = entry.columns.map(col => ({
wch: Math.max(col.length * 2, 12),
}))
xlsx.utils.book_append_sheet(wb, ws, '查询结果')
// 生成Buffer
const buf = xlsx.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename=${encodeURIComponent(fileName)}.xlsx`)
res.send(buf)
} catch (e) {
res.json({ success: false, message: e.message })
}
})
/**
* 导入Excel,返回第一列数据(用于IN条件)
* POST /api/export/import
* multipart/form-data,字段名:file
*/
router.post('/import', (req, res) => {
try {
// 这里使用简单的base64方式上传,避免引入multer
const { fileData, fileName } = req.body
if (!fileData) {
return res.json({ success: false, message: '未接收到文件数据' })
}
// base64转Buffer
const buf = Buffer.from(fileData, 'base64')
const wb = xlsx.read(buf, { type: 'buffer' })
const ws = wb.Sheets[wb.SheetNames[0]]
const data = xlsx.utils.sheet_to_json(ws, { header: 1 })
// 取第一列(跳过表头)
const values = []
for (let i = 1; i < data.length; i++) {
const val = data[i] && data[i][0]
if (val !== undefined && val !== null && String(val).trim() !== '') {
values.push(String(val).trim())
}
}
res.json({ success: true, data: { values, count: values.length } })
} catch (e) {
res.json({ success: false, message: e.message })
}
})
export default router
import { Router } from 'express'
import { v4 as uuidv4 } from 'uuid'
import { executeQuery } from '../db.js'
import { analyzeSql } from '../sql-analyzer.js'
import { setCache, getCache, searchInCache } from '../cache.js'
const router = Router()
/**
* 预检查SQL(语法 + 执行计划)
* POST /api/query/check
*/
router.post('/check', async (req, res) => {
try {
const { connId, sql } = req.body
if (!connId || !sql) {
return res.json({ success: false, message: '缺少参数' })
}
const result = await analyzeSql(connId, sql)
res.json({ success: true, data: result })
} catch (e) {
res.json({ success: false, message: e.message })
}
})
/**
* 执行SQL查询
* POST /api/query/execute
*/
router.post('/execute', async (req, res) => {
try {
const { connId, sql } = req.body
if (!connId || !sql) {
return res.json({ success: false, message: '缺少参数' })
}
const startTime = Date.now()
const data = await executeQuery(connId, sql)
const duration = Date.now() - startTime
// 判断是否为查询结果(SELECT返回数组,DML返回结果对象)
const isResultSet = Array.isArray(data)
let queryId = null
let columns = []
let totalCount = 0
if (isResultSet && data.length > 0) {
queryId = uuidv4()
setCache(queryId, data)
columns = Object.keys(data[0])
totalCount = data.length
} else if (isResultSet) {
queryId = uuidv4()
setCache(queryId, [])
totalCount = 0
}
res.json({
success: true,
data: {
queryId,
isResultSet,
columns,
totalCount,
duration,
// DML返回影响行数
affectedRows: !isResultSet ? data.affectedRows : undefined,
message: !isResultSet
? `执行成功,影响 ${data.affectedRows} 行`
: undefined,
},
})
} catch (e) {
res.json({ success: false, message: e.message })
}
})
/**
* 获取分页数据
* GET /api/query/page/:queryId?page=1&pageSize=20
*/
router.get('/page/:queryId', (req, res) => {
const { queryId } = req.params
const page = Number(req.query.page) || 1
const pageSize = Number(req.query.pageSize) || 20
const keyword = req.query.keyword || ''
const entry = keyword ? searchInCache(queryId, keyword) : getCache(queryId)
if (!entry) {
return res.json({ success: false, message: '查询结果已过期,请重新执行' })
}
const start = (page - 1) * pageSize
const end = start + pageSize
const pageData = entry.data.slice(start, end)
res.json({
success: true,
data: {
columns: entry.columns,
rows: pageData,
totalCount: entry.totalCount,
page,
pageSize,
totalPages: Math.ceil(entry.totalCount / pageSize),
},
})
})
/**
* 获取全部数据(用于导出)
* GET /api/query/all/:queryId
*/
router.get('/all/:queryId', (req, res) => {
const { queryId } = req.params
const keyword = req.query.keyword || ''
const entry = keyword ? searchInCache(queryId, keyword) : getCache(queryId)
if (!entry) {
return res.json({ success: false, message: '查询结果已过期,请重新执行' })
}
res.json({
success: true,
data: {
columns: entry.columns,
rows: entry.data,
totalCount: entry.totalCount,
},
})
})
export default router
import { Router } from 'express'
import { loadSmtpConfig, saveSmtpConfig, testSmtpConnection } from '../mail.js'
const router = Router()
// ====== SMTP 配置(全局配置,所有脚本共用) ======
// 获取SMTP配置
router.get('/smtp', (req, res) => {
const config = loadSmtpConfig()
if (config) {
res.json({ success: true, data: { ...config, pass: '******' } })
} else {
res.json({ success: true, data: null })
}
})
// 保存SMTP配置
router.post('/smtp', async (req, res) => {
try {
const { host, port, user, pass, senderName } = req.body
if (!host || !user || !pass) {
return res.json({ success: false, message: '缺少必填字段' })
}
// 先测试连接
const testResult = await testSmtpConnection({ host, port, user, pass })
if (!testResult.success) {
return res.json({ success: false, message: testResult.message })
}
saveSmtpConfig({ host, port, user, pass, senderName: senderName || '' })
res.json({ success: true, message: 'SMTP配置保存成功' })
} catch (e) {
res.json({ success: false, message: e.message })
}
})
// 测试SMTP连接
router.post('/smtp/test', async (req, res) => {
try {
const { host, port, user, pass } = req.body
const result = await testSmtpConnection({ host, port, user, pass })
res.json(result)
} catch (e) {
res.json({ success: false, message: e.message })
}
})
export default router
\ No newline at end of file
import cron from 'node-cron'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import dayjs from 'dayjs'
import xlsx from 'xlsx'
import { getPool } from './db.js'
import { buildSqlWithDateRange } from './condition-builder.js'
import { sendReportMail } from './mail.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const SCRIPTS_FILE = path.join(__dirname, '..', 'sql-scripts.json')
// 运行中的cron任务 { scriptId: cronTask }
const runningTasks = new Map()
// 读取脚本列表
function loadScripts() {
try {
if (fs.existsSync(SCRIPTS_FILE)) {
return JSON.parse(fs.readFileSync(SCRIPTS_FILE, 'utf-8'))
}
} catch (e) {
console.error('读取脚本配置失败:', e.message)
}
return []
}
// 保存脚本列表(更新执行状态用)
function saveScripts(scripts) {
fs.writeFileSync(SCRIPTS_FILE, JSON.stringify(scripts, null, 2), 'utf-8')
}
// ====== 动态日期变量处理 ======
function resolveDynamicValues(conditions) {
const today = dayjs()
const dynamicMap = {
'TODAY': () => today.format('YYYY-MM-DD'),
'YESTERDAY': () => today.subtract(1, 'day').format('YYYY-MM-DD'),
'THIS_MONTH_START': () => today.startOf('month').format('YYYY-MM-DD'),
'THIS_MONTH_END': () => today.endOf('month').format('YYYY-MM-DD'),
'LAST_MONTH_START': () => today.subtract(1, 'month').startOf('month').format('YYYY-MM-DD'),
'LAST_MONTH_END': () => today.subtract(1, 'month').endOf('month').format('YYYY-MM-DD'),
'THIS_YEAR': () => today.format('YYYY'),
'THIS_YEAR_START': () => today.startOf('year').format('YYYY-MM-DD'),
'LAST_YEAR': () => today.subtract(1, 'year').format('YYYY'),
}
return conditions.map(cond => {
const newCond = { ...cond }
if (newCond.value && typeof newCond.value === 'string' && dynamicMap[newCond.value.toUpperCase()]) {
newCond.value = dynamicMap[newCond.value.toUpperCase()]()
}
if (newCond.valueStart && typeof newCond.valueStart === 'string' && dynamicMap[newCond.valueStart.toUpperCase()]) {
newCond.valueStart = dynamicMap[newCond.valueStart.toUpperCase()]()
}
if (newCond.valueEnd && typeof newCond.valueEnd === 'string' && dynamicMap[newCond.valueEnd.toUpperCase()]) {
newCond.valueEnd = dynamicMap[newCond.valueEnd.toUpperCase()]()
}
return newCond
})
}
// ====== 执行单个脚本任务 ======
export async function executeScriptTask(script) {
console.log(`[定时任务] 开始执行: ${script.name} (${new Date().toLocaleString()})`)
try {
// 1. 解析动态条件
const resolvedConditions = resolveDynamicValues(script.conditions || [])
// 2. 构建最终SQL
let sql = script.sql
if (resolvedConditions.length > 0) {
sql = buildSqlWithDateRange(script.sql, resolvedConditions)
}
// 3. 执行SQL
const pool = getPool(script.connId)
const [rows] = await pool.query(sql)
if (!Array.isArray(rows) || rows.length === 0) {
updateScriptStatus(script.id, 'success', '查询结果为空,未发送邮件')
return
}
// 4. 如果配置了收件人,生成Excel并发邮件
if (script.recipientEmails && script.recipientEmails.trim()) {
const columns = Object.keys(rows[0])
const wb = xlsx.utils.book_new()
const wsData = [columns]
for (const row of rows) {
const rowData = columns.map(col => {
const val = row[col]
if (Buffer.isBuffer(val)) return val.toString('hex')
if (val instanceof Date) return val.toISOString().replace('T', ' ').replace(/\.\d+Z$/, '')
return val
})
wsData.push(rowData)
}
const ws = xlsx.utils.aoa_to_sheet(wsData)
ws['!cols'] = columns.map(col => ({ wch: Math.max(col.length * 2, 12) }))
xlsx.utils.book_append_sheet(wb, ws, '查询结果')
const excelBuffer = xlsx.write(wb, { type: 'buffer', bookType: 'xlsx' })
const fileName = `${script.name}_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`
const html = generateEmailHtml(script, rows.length, dayjs().format('YYYY-MM-DD HH:mm:ss'))
await sendReportMail({
to: script.recipientEmails,
subject: script.emailSubject || `[SQL报表] ${script.name} - ${dayjs().format('YYYY-MM-DD')}`,
html,
excelBuffer,
fileName,
})
updateScriptStatus(script.id, 'success', `成功发送,共${rows.length}条数据`)
} else {
updateScriptStatus(script.id, 'success', `执行成功,${rows.length}条数据(未配置收件人,未发送邮件)`)
}
console.log(`[定时任务] ${script.name}: 执行成功`)
} catch (e) {
updateScriptStatus(script.id, 'failed', e.message)
console.error(`[定时任务] ${script.name}: 执行失败 - ${e.message}`)
}
}
// 生成邮件正文
function generateEmailHtml(script, rowCount, execTime) {
return `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #001529; color: #fff; padding: 20px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">📊 ${script.name}</h2>
</div>
<div style="background: #fff; padding: 20px; border: 1px solid #e8e8e8; border-top: none;">
<table style="width: 100%; border-collapse: collapse;">
<tr><td style="padding: 8px 0; color: #666; width: 100px;">报表名称:</td><td style="padding: 8px 0; font-weight: 600;">${script.name}</td></tr>
<tr><td style="padding: 8px 0; color: #666;">执行时间:</td><td style="padding: 8px 0;">${execTime}</td></tr>
<tr><td style="padding: 8px 0; color: #666;">数据行数:</td><td style="padding: 8px 0; font-weight: 600; color: #1890ff;">${rowCount} 条</td></tr>
${script.purpose ? `<tr><td style="padding: 8px 0; color: #666;">用途说明:</td><td style="padding: 8px 0;">${script.purpose}</td></tr>` : ''}
</table>
<div style="margin-top: 16px; padding: 12px; background: #f6ffed; border: 1px solid #b7eb8f; border-radius: 4px; color: #389e0d; font-size: 13px;">
✅ 附件为Excel报表数据,请查收。
</div>
</div>
<div style="background: #fafafa; padding: 12px 20px; border-radius: 0 0 8px 8px; font-size: 12px; color: #999; text-align: center;">
本邮件由SQL报表工具自动发送,请勿直接回复
</div>
</div>
`
}
// 更新脚本执行状态
function updateScriptStatus(scriptId, status, message) {
const scripts = loadScripts()
const idx = scripts.findIndex(s => s.id === scriptId)
if (idx >= 0) {
scripts[idx].lastRunAt = new Date().toISOString()
scripts[idx].lastRunStatus = status
scripts[idx].lastRunMessage = message || ''
saveScripts(scripts)
}
}
// ====== 启动/停止脚本定时任务 ======
export function startScriptTask(script) {
if (runningTasks.has(script.id)) {
runningTasks.get(script.id).stop()
}
if (!script.schedulerEnabled || !script.cronExpression) return
if (!cron.validate(script.cronExpression)) {
console.error(`[定时任务] ${script.name}: cron表达式无效 - ${script.cronExpression}`)
return
}
const cronTask = cron.schedule(script.cronExpression, () => {
// 每次执行时重新读取最新脚本配置(确保条件值是最新的)
const scripts = loadScripts()
const latest = scripts.find(s => s.id === script.id)
if (latest && latest.schedulerEnabled) {
executeScriptTask(latest)
}
}, { scheduled: true })
runningTasks.set(script.id, cronTask)
console.log(`[定时任务] 已启动: ${script.name} (${script.cronExpression})`)
}
export function stopScriptTask(scriptId) {
const cronTask = runningTasks.get(scriptId)
if (cronTask) {
cronTask.stop()
runningTasks.delete(scriptId)
console.log(`[定时任务] 已停止: ${scriptId}`)
}
}
// 服务启动时,加载所有启用定时的脚本
export function reloadAllScriptTasks() {
// 先清理所有运行中的任务
for (const [id, task] of runningTasks) {
task.stop()
}
runningTasks.clear()
const scripts = loadScripts()
const enabled = scripts.filter(s => s.schedulerEnabled && s.cronExpression)
for (const script of enabled) {
startScriptTask(script)
}
console.log(`[定时任务] 已加载 ${enabled.length} 个脚本定时任务`)
}
export function getRunningScriptIds() {
return Array.from(runningTasks.keys())
}
\ No newline at end of file
// SQL预执行分析器
import { executeExplain } from './db.js'
/**
* 对SQL进行预执行检查
* 1. 语法检查(通过EXPLAIN)
* 2. 执行计划分析(全表扫描、索引使用等)
* 3. 锁风险分析
* 4. 预估影响行数
*/
export async function analyzeSql(connId, sql) {
const result = {
syntaxOk: false,
syntaxError: null,
warnings: [],
risks: [],
explainRows: [],
estimatedRows: 0,
sqlType: detectSqlType(sql),
}
// 检测SQL类型,非SELECT给出风险提示
if (result.sqlType !== 'SELECT') {
result.risks.push({
level: 'high',
type: 'DML_OPERATION',
message: `当前SQL为 ${result.sqlType} 语句,可能造成数据变更和锁表风险`,
})
}
// 语法检查:通过EXPLAIN验证
try {
const explainRows = await executeExplain(connId, sql)
if (explainRows) {
result.syntaxOk = true
result.explainRows = explainRows
// 分析执行计划
for (const row of explainRows) {
const type = row.type || row.access_type
const key = row.key || row.key_name
const extra = row.Extra || row.extra || ''
const rows = row.rows || row.rows_examined || 0
const table = row.table_name || row.table || ''
// 累计预估行数
result.estimatedRows += Number(rows) || 0
// 检测全表扫描
if (type === 'ALL') {
result.warnings.push({
level: 'warn',
type: 'FULL_TABLE_SCAN',
message: `表 ${table} 将进行全表扫描,预估扫描 ${rows} 行`,
table,
})
}
// 检测未使用索引
if (!key || key === 'NULL' || key === null) {
result.warnings.push({
level: 'warn',
type: 'NO_INDEX',
message: `表 ${table} 未使用索引`,
table,
})
}
// 检测filesort
if (extra.includes('Using filesort')) {
result.warnings.push({
level: 'warn',
type: 'FILESORT',
message: `表 ${table} 使用了文件排序(filesort),大数据量下可能较慢`,
table,
})
}
// 检测临时表
if (extra.includes('Using temporary')) {
result.risks.push({
level: 'medium',
type: 'TEMPORARY_TABLE',
message: `表 ${table} 使用了临时表,可能影响性能`,
table,
})
}
}
// 大数据量风险
if (result.estimatedRows > 100000) {
result.risks.push({
level: 'high',
type: 'LARGE_RESULT',
message: `预估扫描行数 ${result.estimatedRows.toLocaleString()} 行,可能消耗大量资源`,
})
} else if (result.estimatedRows > 10000) {
result.warnings.push({
level: 'warn',
type: 'MEDIUM_RESULT',
message: `预估扫描行数 ${result.estimatedRows.toLocaleString()} 行,请关注执行时间`,
})
}
} else {
// EXPLAIN无法执行,但SQL可能是正确的(如某些DDL)
result.syntaxOk = true
result.warnings.push({
level: 'info',
type: 'NO_EXPLAIN',
message: '该SQL类型无法通过EXPLAIN分析,请自行评估风险',
})
}
} catch (e) {
result.syntaxOk = false
result.syntaxError = e.message
}
return result
}
/**
* 检测SQL类型
*/
function detectSqlType(sql) {
const trimmed = sql.trim().toUpperCase()
if (trimmed.startsWith('SELECT')) return 'SELECT'
if (trimmed.startsWith('INSERT')) return 'INSERT'
if (trimmed.startsWith('UPDATE')) return 'UPDATE'
if (trimmed.startsWith('DELETE')) return 'DELETE'
if (trimmed.startsWith('CREATE')) return 'CREATE'
if (trimmed.startsWith('ALTER')) return 'ALTER'
if (trimmed.startsWith('DROP')) return 'DROP'
if (trimmed.startsWith('TRUNCATE')) return 'TRUNCATE'
return 'OTHER'
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
background: #f0f2f5;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 56px;
background: #001529;
color: #fff;
flex-shrink: 0;
}
.app-header .logo {
font-size: 18px;
font-weight: 600;
letter-spacing: 2px;
}
.app-header .conn-info {
display: flex;
align-items: center;
gap: 12px;
}
.app-body {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
}
.left-panel {
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
}
.right-panel {
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
}
/* 拖动分隔条 */
.resize-handle {
width: 12px;
cursor: col-resize;
background: #f0f0f0;
border-left: 1px solid #d9d9d9;
border-right: 1px solid #d9d9d9;
display: flex;
align-items: center;
justify-content: center;
position: relative;
flex-shrink: 0;
transition: background 0.2s;
z-index: 10;
}
.resize-handle:hover {
background: #e0e0e0;
}
.resize-handle:active {
background: #d0d0d0;
}
.collapse-btn {
width: 20px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
border: 1px solid #d9d9d9;
border-radius: 4px;
cursor: pointer;
font-size: 10px;
color: #666;
transition: all 0.2s;
z-index: 11;
}
.collapse-btn:hover {
background: #1890ff;
color: #fff;
border-color: #1890ff;
}
.panel-section {
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-title {
padding: 10px 16px;
font-size: 14px;
font-weight: 600;
background: #fafafa;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.editor-wrapper {
flex: 1;
overflow: auto;
border-bottom: 1px solid #e8e8e8;
position: relative;
}
.editor-wrapper .cm-editor {
height: 100%;
font-size: 14px;
}
.editor-wrapper .cm-editor .cm-scroller {
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
overflow: auto !important;
}
.editor-wrapper .cm-editor .cm-scroll {
overflow: auto !important;
}
.editor-toolbar {
padding: 8px 12px;
background: #fafafa;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
flex-wrap: wrap;
}
.result-section {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.result-toolbar {
padding: 8px 16px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fafafa;
border-bottom: 1px solid #e8e8e8;
flex-shrink: 0;
}
.result-toolbar .left-actions {
display: flex;
align-items: center;
gap: 8px;
}
.result-toolbar .right-actions {
display: flex;
align-items: center;
gap: 8px;
}
.result-table-wrapper {
flex: 1;
overflow: auto;
padding: 0 12px;
}
.result-pagination {
padding: 12px 16px;
display: flex;
justify-content: flex-end;
background: #fafafa;
border-top: 1px solid #e8e8e8;
flex-shrink: 0;
}
.check-modal .check-item {
padding: 8px 12px;
margin-bottom: 8px;
border-radius: 6px;
font-size: 13px;
}
.check-modal .check-item.high {
background: #fff2f0;
border: 1px solid #ffccc7;
color: #cf1322;
}
.check-modal .check-item.medium {
background: #fff7e6;
border: 1px solid #ffd591;
color: #d46b08;
}
.check-modal .check-item.warn {
background: #fffbe6;
border: 1px solid #ffe58f;
color: #ad6800;
}
.check-modal .check-item.info {
background: #e6f7ff;
border: 1px solid #91d5ff;
color: #096dd9;
}
.check-modal .check-item.ok {
background: #f6ffed;
border: 1px solid #b7eb8f;
color: #389e0d;
}
.check-modal .explain-table {
margin-top: 12px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: #999;
}
.ant-table-wrapper {
overflow: auto;
}
This diff is collapsed. Click to expand it.
import React, { useState, useEffect } from 'react'
import { Modal, Form, Input, Select, Button, message, Space, List, Tag, Popconfirm } from 'antd'
import { PlusOutlined, DeleteOutlined, LinkOutlined, DisconnectOutlined } from '@ant-design/icons'
import request from '../utils/request'
export default function ConnectionModal({ open, onClose, onConnect, activeConnId }) {
const [form] = Form.useForm()
const [connections, setConnections] = useState([])
const [loading, setLoading] = useState(false)
const [testLoading, setTestLoading] = useState(false)
const [showForm, setShowForm] = useState(false)
useEffect(() => {
if (open) loadConnections()
}, [open])
const loadConnections = async () => {
const res = await request.get('/connection')
if (res.success) setConnections(res.data)
}
const handleTest = async () => {
try {
const values = await form.validateFields()
setTestLoading(true)
const res = await request.post('/connection/test', values)
if (res.success) {
message.success('连接测试成功')
} else {
message.error(`连接测试失败: ${res.message}`)
}
} catch {
// 表单验证失败
} finally {
setTestLoading(false)
}
}
const handleSave = async () => {
try {
const values = await form.validateFields()
setLoading(true)
const res = await request.post('/connection', values)
if (res.success) {
message.success('连接保存成功')
form.resetFields()
setShowForm(false)
loadConnections()
} else {
message.error(res.message)
}
} catch {
// 表单验证失败
} finally {
setLoading(false)
}
}
const handleDelete = async (id) => {
await request.delete(`/connection/${id}`)
message.success('已删除')
loadConnections()
}
const handleConnect = (conn) => {
onConnect(conn)
onClose()
}
return (
<Modal
title="数据库连接管理"
open={open}
onCancel={onClose}
footer={null}
width={640}
>
<div style={{ marginBottom: 16 }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setShowForm(!showForm)}
>
{showForm ? '收起表单' : '新增连接'}
</Button>
</div>
{showForm && (
<Form form={form} layout="vertical" style={{ marginBottom: 24, padding: 16, background: '#fafafa', borderRadius: 8 }}>
<Form.Item name="name" label="连接名称" rules={[{ required: true, message: '请输入连接名称' }]}>
<Input placeholder="例如:生产环境" />
</Form.Item>
<div style={{ display: 'flex', gap: 12 }}>
<Form.Item name="host" label="主机地址" rules={[{ required: true }]} style={{ flex: 2 }}>
<Input placeholder="192.168.1.100" />
</Form.Item>
<Form.Item name="port" label="端口" initialValue="3306" style={{ flex: 1 }}>
<Input placeholder="3306" />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<Form.Item name="user" label="用户名" rules={[{ required: true }]} style={{ flex: 1 }}>
<Input placeholder="root" />
</Form.Item>
<Form.Item name="password" label="密码" style={{ flex: 1 }}>
<Input.Password placeholder="密码" />
</Form.Item>
</div>
<Form.Item name="database" label="数据库名" rules={[{ required: true }]}>
<Input placeholder="saas_project_v2" />
</Form.Item>
<Form.Item>
<Space>
<Button onClick={handleTest} loading={testLoading} icon={<LinkOutlined />}>
测试连接
</Button>
<Button type="primary" onClick={handleSave} loading={loading}>
保存连接
</Button>
</Space>
</Form.Item>
</Form>
)}
<List
dataSource={connections}
locale={{ emptyText: '暂无连接配置,请点击上方按钮新增' }}
renderItem={item => (
<List.Item
actions={[
<Button
type="link"
size="small"
icon={<LinkOutlined />}
onClick={() => handleConnect(item)}
disabled={activeConnId === item.id}
>
{activeConnId === item.id ? '已连接' : '连接'}
</Button>,
<Popconfirm title="确定删除该连接?" onConfirm={() => handleDelete(item.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
删除
</Button>
</Popconfirm>,
]}
>
<List.Item.Meta
title={
<span>
{item.name}
{activeConnId === item.id && (
<Tag color="green" style={{ marginLeft: 8 }}>当前连接</Tag>
)}
</span>
}
description={`${item.host}:${item.port} / ${item.database} (${item.user})`}
/>
</List.Item>
)}
/>
</Modal>
)
}
import React from 'react'
import { Modal, Descriptions, Tag, Button, Space } from 'antd'
import { CopyOutlined, BugOutlined } from '@ant-design/icons'
/**
* 错误类型映射
*/
function getErrorMeta(error) {
const msg = (error || '').toLowerCase()
if (msg.includes('syntax') || msg.includes('you have an error in your sql')) {
return { type: 'SQL语法错误', color: 'red', level: '严重' }
}
if (msg.includes('timeout') || msg.includes('timed out')) {
return { type: '查询超时', color: 'orange', level: '警告' }
}
if (msg.includes('lock') || msg.includes('deadlock') || msg.includes('lock wait timeout')) {
return { type: '锁等待/死锁', color: 'red', level: '严重' }
}
if (msg.includes('connection') || msg.includes('econnrefused') || msg.includes('lost connection')) {
return { type: '数据库连接异常', color: 'red', level: '严重' }
}
if (msg.includes('access denied') || msg.includes('permission') || msg.includes('command denied')) {
return { type: '权限不足', color: 'volcano', level: '警告' }
}
if (msg.includes('unknown column') || msg.includes('unknown table') || msg.includes('doesn\'t exist')) {
return { type: '对象不存在', color: 'orange', level: '警告' }
}
if (msg.includes('duplicate') || msg.includes('unique') || msg.includes('primary')) {
return { type: '主键/唯一约束冲突', color: 'orange', level: '警告' }
}
if (msg.includes('data too long') || msg.includes('out of range')) {
return { type: '数据溢出', color: 'orange', level: '警告' }
}
return { type: '执行异常', color: 'default', level: '一般' }
}
/**
* 根据错误类型给出修复建议
*/
function getSuggestions(error) {
const msg = (error || '').toLowerCase()
const suggestions = []
if (msg.includes('syntax') || msg.includes('you have an error in your sql')) {
suggestions.push('检查SQL语句的语法是否正确,如引号是否闭合、关键字是否拼写错误')
suggestions.push('注意MySQL保留字,如果使用了保留字作为字段名,需要用反引号 ` 包裹')
suggestions.push('检查是否缺少了必要的逗号或括号')
}
if (msg.includes('timeout') || msg.includes('timed out')) {
suggestions.push('查询可能涉及大量数据,建议添加 WHERE 条件缩小查询范围')
suggestions.push('检查相关字段是否有索引,避免全表扫描')
suggestions.push('如果使用了 JOIN,确保关联字段有索引')
suggestions.push('考虑将复杂查询拆分为多个简单查询')
}
if (msg.includes('lock') || msg.includes('deadlock')) {
suggestions.push('可能存在其他事务正在操作相同的数据,导致锁等待')
suggestions.push('建议在非业务高峰期执行此SQL')
suggestions.push('检查是否有长时间未提交的事务')
}
if (msg.includes('connection')) {
suggestions.push('检查数据库服务是否正常运行')
suggestions.push('检查网络连接是否正常')
suggestions.push('检查数据库连接数是否已达上限')
}
if (msg.includes('access denied') || msg.includes('permission')) {
suggestions.push('检查当前数据库用户是否有执行该操作的权限')
suggestions.push('联系数据库管理员授予相应权限')
}
if (msg.includes('unknown column') || msg.includes('unknown table')) {
suggestions.push('检查表名或字段名是否拼写正确')
suggestions.push('检查是否选对了数据库')
suggestions.push('注意表名和字段名的大小写,Linux下MySQL区分大小写')
}
if (suggestions.length === 0) {
suggestions.push('请仔细阅读上方错误信息,定位问题原因')
suggestions.push('如果无法自行解决,请联系数据库管理员')
}
return suggestions
}
export default function ErrorReportModal({ open, onClose, errorInfo }) {
if (!errorInfo) return null
const { error, sql, phase, timestamp } = errorInfo
const meta = getErrorMeta(error)
const suggestions = getSuggestions(error)
const handleCopyError = () => {
const text = [
`=== SQL执行错误报告 ===`,
`时间: ${timestamp || new Date().toLocaleString()}`,
`阶段: ${phase || '执行阶段'}`,
`错误类型: ${meta.type}`,
`严重程度: ${meta.level}`,
`错误信息: ${error}`,
``,
`=== SQL脚本 ===`,
sql || '无',
``,
`=== 修复建议 ===`,
...suggestions.map((s, i) => `${i + 1}. ${s}`),
].join('\n')
navigator.clipboard.writeText(text).then(() => {
const btn = document.activeElement
btn && btn.click && btn.textContent && (btn.textContent = '已复制')
})
}
return (
<Modal
title={
<span>
<BugOutlined style={{ color: '#ff4d4f', marginRight: 8 }} />
SQL执行错误报告
</span>
}
open={open}
onCancel={onClose}
width={680}
footer={
<Space>
<Button onClick={handleCopyError} icon={<CopyOutlined />}>
复制错误报告
</Button>
<Button type="primary" onClick={onClose}>
我知道了
</Button>
</Space>
}
>
{/* 基本信息 */}
<Descriptions
bordered
size="small"
column={2}
style={{ marginBottom: 16 }}
>
<Descriptions.Item label="错误类型">
<Tag color={meta.color}>{meta.type}</Tag>
</Descriptions.Item>
<Descriptions.Item label="严重程度">
<Tag color={meta.level === '严重' ? 'red' : meta.level === '警告' ? 'orange' : 'blue'}>
{meta.level}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="执行阶段">
{phase || '执行阶段'}
</Descriptions.Item>
<Descriptions.Item label="发生时间">
{timestamp || new Date().toLocaleString()}
</Descriptions.Item>
</Descriptions>
{/* 错误详情 */}
<div style={{ marginBottom: 16 }}>
<h4 style={{ marginBottom: 8, color: '#ff4d4f' }}>错误详情</h4>
<div style={{
background: '#fff2f0',
border: '1px solid #ffccc7',
borderRadius: 6,
padding: '12px 16px',
fontSize: 13,
fontFamily: 'Menlo, Monaco, Consolas, monospace',
wordBreak: 'break-all',
lineHeight: 1.8,
color: '#cf1322',
maxHeight: 120,
overflow: 'auto',
}}>
{error || '未知错误'}
</div>
</div>
{/* 出错的SQL */}
{sql && (
<div style={{ marginBottom: 16 }}>
<h4 style={{ marginBottom: 8 }}>出错的SQL</h4>
<pre style={{
background: '#f5f5f5',
border: '1px solid #d9d9d9',
borderRadius: 6,
padding: '12px 16px',
fontSize: 12,
fontFamily: 'Menlo, Monaco, Consolas, monospace',
maxHeight: 150,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
lineHeight: 1.6,
}}>
{sql}
</pre>
</div>
)}
{/* 修复建议 */}
<div>
<h4 style={{ marginBottom: 8, color: '#1890ff' }}>修复建议</h4>
<div style={{
background: '#e6f7ff',
border: '1px solid #91d5ff',
borderRadius: 6,
padding: '12px 16px',
}}>
{suggestions.map((s, i) => (
<div key={i} style={{ fontSize: 13, lineHeight: 2, color: '#096dd9' }}>
<span style={{ fontWeight: 600, marginRight: 8 }}>{i + 1}.</span>
{s}
</div>
))}
</div>
</div>
</Modal>
)
}
import React from 'react'
import { Modal, Table, Tag, Button, Space, Alert } from 'antd'
import {
CheckCircleOutlined,
WarningOutlined,
CloseCircleOutlined,
InfoCircleOutlined,
ThunderboltOutlined,
} from '@ant-design/icons'
const levelMap = {
high: { color: 'red', icon: <CloseCircleOutlined />, label: '高风险' },
medium: { color: 'orange', icon: <WarningOutlined />, label: '中风险' },
warn: { color: 'gold', icon: <WarningOutlined />, label: '警告' },
info: { color: 'blue', icon: <InfoCircleOutlined />, label: '提示' },
ok: { color: 'green', icon: <CheckCircleOutlined />, label: '正常' },
}
export default function ExecutionCheckModal({ open, onClose, onConfirm, checkResult, sql }) {
if (!checkResult) return null
const { syntaxOk, syntaxError, warnings, risks, estimatedRows, sqlType, explainRows } = checkResult
// 判断是否可以继续执行
const hasHighRisk = risks.some(r => r.level === 'high')
const canExecute = syntaxOk && !syntaxError
const explainColumns = [
{ title: '表', dataIndex: 'table', key: 'table', ellipsis: true, width: 120 },
{ title: '类型', dataIndex: 'type', key: 'type', width: 80 },
{ title: '索引', dataIndex: 'key', key: 'key', ellipsis: true, width: 120 },
{ title: '预估行数', dataIndex: 'rows', key: 'rows', width: 90 },
{ title: '额外信息', dataIndex: 'Extra', key: 'Extra', ellipsis: true },
]
return (
<Modal
title="SQL预执行检查"
open={open}
onCancel={onClose}
width={760}
className="check-modal"
footer={
<Space>
<Button onClick={onClose}>取消</Button>
{canExecute && (
<Button
type="primary"
danger={hasHighRisk}
onClick={onConfirm}
icon={<ThunderboltOutlined />}
>
{hasHighRisk ? '确认执行(存在高风险)' : '确认执行'}
</Button>
)}
</Space>
}
>
{/* 语法检查 */}
<div style={{ marginBottom: 16 }}>
<h4 style={{ marginBottom: 8 }}>语法检查</h4>
{syntaxOk ? (
<div className="check-item ok">
<CheckCircleOutlined style={{ marginRight: 8 }} />
SQL语法检查通过
{sqlType !== 'SELECT' && (
<Tag color="orange" style={{ marginLeft: 8 }}>{sqlType}语句</Tag>
)}
</div>
) : (
<div className="check-item high">
<CloseCircleOutlined style={{ marginRight: 8 }} />
SQL语法错误:{syntaxError}
</div>
)}
</div>
{/* 风险提示 */}
{risks.length > 0 && (
<div style={{ marginBottom: 16 }}>
<h4 style={{ marginBottom: 8 }}>风险提示</h4>
{risks.map((risk, i) => (
<div key={i} className={`check-item ${risk.level}`}>
{levelMap[risk.level]?.icon}
<span style={{ marginLeft: 8 }}>{risk.message}</span>
</div>
))}
</div>
)}
{/* 警告 */}
{warnings.length > 0 && (
<div style={{ marginBottom: 16 }}>
<h4 style={{ marginBottom: 8 }}>性能警告</h4>
{warnings.map((w, i) => (
<div key={i} className={`check-item ${w.level}`}>
{levelMap[w.level]?.icon}
<span style={{ marginLeft: 8 }}>{w.message}</span>
</div>
))}
</div>
)}
{/* 预估行数 */}
<div style={{ marginBottom: 16 }}>
<h4 style={{ marginBottom: 8 }}>预估扫描行数</h4>
<Tag color={estimatedRows > 100000 ? 'red' : estimatedRows > 10000 ? 'orange' : 'green'} style={{ fontSize: 14, padding: '4px 12px' }}>
{estimatedRows.toLocaleString()}
</Tag>
</div>
{/* EXPLAIN结果 */}
{explainRows && explainRows.length > 0 && (
<div className="explain-table">
<h4 style={{ marginBottom: 8 }}>执行计划</h4>
<Table
dataSource={explainRows}
columns={explainColumns}
rowKey={(_, i) => i}
size="small"
pagination={false}
scroll={{ x: 600 }}
bordered
/>
</div>
)}
{/* 待执行的SQL */}
<div style={{ marginTop: 16 }}>
<h4 style={{ marginBottom: 8 }}>待执行的SQL</h4>
<pre style={{
background: '#f5f5f5',
padding: 12,
borderRadius: 6,
fontSize: 12,
maxHeight: 200,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}>
{sql}
</pre>
</div>
</Modal>
)
}
import React, { useState, useEffect } from 'react'
import { Table, Input, Button, Pagination, Space, message, Empty, Tag, Modal, Form } from 'antd'
import { SearchOutlined, DownloadOutlined } from '@ant-design/icons'
import request from '../utils/request'
export default function ResultTable({ queryId, totalCount, columns, duration }) {
const [data, setData] = useState([])
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [total, setTotal] = useState(totalCount || 0)
const [keyword, setKeyword] = useState('')
const [currentColumns, setCurrentColumns] = useState(columns || [])
useEffect(() => {
if (queryId) {
setPage(1)
setKeyword('')
setTotal(totalCount || 0)
setCurrentColumns(columns || [])
fetchPage(1, pageSize, '')
}
}, [queryId])
const fetchPage = async (p, ps, kw) => {
if (!queryId) return
setLoading(true)
try {
const params = `page=${p}&pageSize=${ps}${kw ? `&keyword=${encodeURIComponent(kw)}` : ''}`
const res = await request.get(`/query/page/${queryId}?${params}`)
if (res.success) {
setData(res.data.rows)
setTotal(res.data.totalCount)
if (res.data.columns && res.data.columns.length > 0) {
setCurrentColumns(res.data.columns)
}
} else {
message.error(res.message)
}
} finally {
setLoading(false)
}
}
const handlePageChange = (p, ps) => {
setPage(p)
setPageSize(ps)
fetchPage(p, ps, keyword)
}
const handleSearch = () => {
setPage(1)
fetchPage(1, pageSize, keyword)
}
const [exportModalOpen, setExportModalOpen] = useState(false)
const [exportForm] = Form.useForm()
const handleExportClick = () => {
if (!queryId) return
exportForm.setFieldsValue({ fileName: '' })
setExportModalOpen(true)
}
const handleExportConfirm = () => {
exportForm.validateFields().then(values => {
const fileName = (values.fileName || '').trim()
const kw = keyword ? `&keyword=${encodeURIComponent(keyword)}` : ''
const nameParam = fileName ? `&fileName=${encodeURIComponent(fileName)}` : ''
window.open(`/api/export/excel/${queryId}?${kw}${nameParam}`)
setExportModalOpen(false)
})
}
const handleExportOld = () => {
if (!queryId) return
const kw = keyword ? `?keyword=${encodeURIComponent(keyword)}` : ''
window.open(`/api/export/excel/${queryId}${kw}`)
}
const tableColumns = currentColumns.map(col => ({
title: col,
dataIndex: col,
key: col,
ellipsis: true,
width: 150,
render: (val) => {
if (val === null) return <span style={{ color: '#ccc' }}>NULL</span>
if (val === undefined) return ''
if (typeof val === 'object') return JSON.stringify(val)
// Date对象
if (val instanceof Date) return val.toISOString().replace('T', ' ').replace(/\.\d+Z$/, '')
// 长文本截断
const str = String(val)
return str.length > 100 ? str.substring(0, 100) + '...' : str
},
}))
if (!queryId) {
return (
<div className="result-section">
<div className="panel-title">查询结果</div>
<Empty description="执行SQL后将在此显示结果" style={{ marginTop: 60 }} />
</div>
)
}
return (
<div className="result-section">
<div className="result-toolbar">
<div className="left-actions">
<span style={{ fontSize: 13, color: '#666' }}>
查询结果
<Tag color="blue" style={{ marginLeft: 8 }}>{total}</Tag>
{duration != null && (
<Tag color="default" style={{ marginLeft: 4 }}>{(duration / 1000).toFixed(2)}s</Tag>
)}
</span>
</div>
<div className="right-actions">
<Input
size="small"
placeholder="结果内搜索..."
value={keyword}
onChange={e => setKeyword(e.target.value)}
onPressEnter={handleSearch}
style={{ width: 180 }}
prefix={<SearchOutlined />}
allowClear
/>
<Button size="small" onClick={handleSearch} icon={<SearchOutlined />}>
搜索
</Button>
<Button
size="small"
type="primary"
onClick={handleExportClick}
icon={<DownloadOutlined />}
>
导出Excel
</Button>
</div>
</div>
<div className="result-table-wrapper">
<Table
dataSource={data}
columns={tableColumns}
rowKey={(_, i) => i}
loading={loading}
size="small"
pagination={false}
scroll={{ x: 'max-content', y: 'calc(100vh - 340px)' }}
bordered
/>
</div>
<div className="result-pagination">
<Pagination
current={page}
pageSize={pageSize}
total={total}
onChange={handlePageChange}
showSizeChanger
showQuickJumper
pageSizeOptions={['10', '20', '50', '100', '200']}
showTotal={t => `共 ${t} 条`}
size="small"
/>
</div>
{/* 导出文件名弹窗 */}
<Modal
title="导出Excel"
open={exportModalOpen}
onCancel={() => setExportModalOpen(false)}
onOk={handleExportConfirm}
okText="导出"
cancelText="取消"
width={440}
>
<Form form={exportForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="fileName"
label="文件名"
rules={[{ required: true, message: '请输入导出文件名' }]}
>
<Input placeholder="请输入导出的文件名(不含扩展名)" autoFocus />
</Form.Item>
<p style={{ fontSize: 12, color: '#999', marginTop: -8 }}>
将导出为 .xlsx 格式,共 {total} 条数据
</p>
</Form>
</Modal>
</div>
)
}
import React, { useState, useEffect } from 'react'
import { Modal, Form, Input, Button, Space, message, Tag, Alert } from 'antd'
import { SendOutlined, MailOutlined } from '@ant-design/icons'
import request from '../utils/request'
// 验证逗号分隔的多个邮箱
const validateEmails = (rule, value) => {
if (!value || !value.trim()) return Promise.reject(new Error('请输入收件人邮箱'))
const emails = value.split(',').map(e => e.trim()).filter(e => e)
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const invalid = emails.find(e => !emailRegex.test(e))
if (invalid) return Promise.reject(new Error(`邮箱格式不正确: ${invalid}`))
return Promise.resolve()
}
export default function SendMailModal({ open, onClose, script, onSent }) {
const [form] = Form.useForm()
const [sending, setSending] = useState(false)
useEffect(() => {
if (open && script) {
form.resetFields()
setTimeout(() => {
form.setFieldsValue({
recipientEmails: script.recipientEmails || '',
emailSubject: script.emailSubject || '',
})
}, 0)
}
}, [open, script])
const handleSend = async () => {
try {
const values = await form.validateFields()
setSending(true)
const res = await request.post(`/sql-manage/${script.id}/send-mail`, {
recipientEmails: values.recipientEmails,
emailSubject: values.emailSubject,
})
if (res.success) {
message.success(res.message)
if (onSent) onSent()
onClose()
} else {
message.error(res.message)
}
} catch (e) {
if (e.errorFields) return // 表单验证失败
message.error(e.message || '发送失败')
} finally {
setSending(false)
}
}
if (!script) return null
// 解析收件人列表用于标签展示
const parseEmailTags = (text) => {
if (!text) return []
return text.split(',').map(e => e.trim()).filter(e => e)
}
return (
<Modal
title={
<Space>
<SendOutlined />
<span>手动发送邮件</span>
<Tag color="blue">{script.name}</Tag>
</Space>
}
open={open}
onCancel={onClose}
width={560}
footer={
<Space>
<Button onClick={onClose}>取消</Button>
<Button
type="primary"
icon={<SendOutlined />}
loading={sending}
onClick={handleSend}
>
发送邮件
</Button>
</Space>
}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="手动发送将执行脚本SQL,生成Excel附件并发送邮件到指定收件人。收件人可与脚本配置的不同。"
/>
<Form form={form} layout="vertical">
<Form.Item
name="recipientEmails"
label="收件人邮箱"
tooltip="多个收件人用英文逗号分隔"
rules={[{ validator: validateEmails }]}
>
<Input.TextArea
placeholder="例如:zhangsan@company.com,lisi@company.com"
autoSize={{ minRows: 2, maxRows: 4 }}
onChange={() => {
// 实时更新标签展示
}}
/>
</Form.Item>
<Form.Item shouldUpdate={(prev, cur) => prev.recipientEmails !== cur.recipientEmails}>
{({ getFieldValue }) => {
const emails = parseEmailTags(getFieldValue('recipientEmails'))
if (emails.length === 0) return null
return (
<div style={{ marginTop: -12, marginBottom: 16 }}>
<span style={{ fontSize: 12, color: '#999', marginRight: 8 }}>收件人预览:</span>
<Space size={4} wrap>
{emails.map((email, i) => (
<Tag key={i} color="blue" icon={<MailOutlined />}>{email}</Tag>
))}
</Space>
</div>
)
}}
</Form.Item>
<Form.Item
name="emailSubject"
label="邮件主题"
tooltip="为空则使用脚本配置的主题或自动生成"
>
<Input placeholder="例如:月度终端支付明细报表(自定义)" />
</Form.Item>
<div style={{ padding: '8px 12px', background: '#f6f6f6', borderRadius: 6, fontSize: 12, color: '#888' }}>
<div>邮件将自动附带Excel附件,包含脚本查询结果数据。</div>
{script.purpose && (
<div style={{ marginTop: 4 }}>
<span style={{ color: '#666' }}>脚本用途:</span>{script.purpose}
</div>
)}
</div>
</Form>
</Modal>
)
}
import React, { useState, useEffect } from 'react'
import { Modal, Form, Input, Button, message, Space, Alert } from 'antd'
import { MailOutlined, LinkOutlined } from '@ant-design/icons'
import request from '../utils/request'
export default function SmtpConfigModal({ open, onClose }) {
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [testLoading, setTestLoading] = useState(false)
const [hasConfig, setHasConfig] = useState(false)
useEffect(() => {
if (open) loadConfig()
}, [open])
const loadConfig = async () => {
const res = await request.get('/scheduler/smtp')
if (res.success && res.data) {
form.setFieldsValue({
host: res.data.host,
port: res.data.port || '465',
user: res.data.user,
senderName: res.data.senderName,
})
setHasConfig(true)
} else {
form.resetFields()
form.setFieldsValue({ port: '465' })
setHasConfig(false)
}
}
const handleTest = async () => {
try {
const values = await form.validateFields()
setTestLoading(true)
const res = await request.post('/scheduler/smtp/test', values)
if (res.success) {
message.success('SMTP连接测试成功')
} else {
message.error(res.message)
}
} catch {
// 表单验证失败
} finally {
setTestLoading(false)
}
}
const handleSave = async () => {
try {
const values = await form.validateFields()
setLoading(true)
const res = await request.post('/scheduler/smtp', values)
if (res.success) {
message.success('SMTP配置保存成功')
onClose()
} else {
message.error(res.message)
}
} catch {
// 表单验证失败
} finally {
setLoading(false)
}
}
return (
<Modal
title="邮件发送配置(SMTP)"
open={open}
onCancel={onClose}
width={520}
footer={
<Space>
<Button onClick={handleTest} loading={testLoading} icon={<LinkOutlined />}>
测试连接
</Button>
<Button onClick={onClose}>取消</Button>
<Button type="primary" onClick={handleSave} loading={loading}>
保存配置
</Button>
</Space>
}
>
{hasConfig && (
<Alert
message="已配置SMTP"
description="当前已有SMTP配置,保存将覆盖原配置。密码已隐藏,如需修改请重新输入。"
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
)}
<Form form={form} layout="vertical">
<Form.Item name="host" label="SMTP服务器地址" rules={[{ required: true, message: '请输入SMTP地址' }]}>
<Input placeholder="例如:smtp.qq.com / smtp.163.com / smtp.exmail.qq.com" />
</Form.Item>
<div style={{ display: 'flex', gap: 12 }}>
<Form.Item name="port" label="端口" rules={[{ required: true }]} style={{ flex: 1 }}>
<Input placeholder="465(SSL) / 587(TLS)" />
</Form.Item>
<Form.Item name="senderName" label="发件人名称" style={{ flex: 1 }}>
<Input placeholder="SQL报表工具" />
</Form.Item>
</div>
<Form.Item name="user" label="邮箱账号" rules={[{ required: true, message: '请输入邮箱账号' }]}>
<Input placeholder="your@email.com" />
</Form.Item>
<Form.Item name="pass" label="邮箱密码/授权码" rules={hasConfig ? [] : [{ required: true, message: '请输入密码' }]}>
<Input.Password placeholder={hasConfig ? '如需修改请输入新密码' : 'QQ邮箱请使用授权码'} />
</Form.Item>
</Form>
<div style={{ padding: '8px 12px', background: '#f6f6f6', borderRadius: 6, fontSize: 12, color: '#888', lineHeight: 2 }}>
<div><b>常用SMTP配置参考:</b></div>
<div>QQ邮箱:smtp.qq.com,端口465,密码用授权码(设置→账户→POP3/SMTP服务获取)</div>
<div>163邮箱:smtp.163.com,端口465,密码用授权码</div>
<div>腾讯企业邮箱:smtp.exmail.qq.com,端口465</div>
<div>阿里企业邮箱:smtp.mxhichina.com,端口465</div>
</div>
</Modal>
)
}
import React from 'react'
import CodeMirror from '@uiw/react-codemirror'
import { sql } from '@codemirror/lang-sql'
export default function SqlEditor({ value, onChange }) {
return (
<div className="editor-wrapper">
<CodeMirror
value={value}
height="100%"
theme="light"
extensions={[sql()]}
onChange={onChange}
placeholder={"请输入SQL语句...\n\n提示:使用 {{变量名}} 定义查询条件参数\n例如:SELECT * FROM users WHERE name LIKE {{name}} AND status = {{status}}"}
basicSetup={{
lineNumbers: true,
highlightActiveLine: true,
bracketMatching: true,
autocompletion: true,
foldGutter: true,
indentOnInput: true,
scrollPastEnd: false,
}}
style={{
fontSize: '14px',
width: '100%',
height: '100%',
}}
/>
</div>
)
}
import React from 'react'
import ReactDOM from 'react-dom/client'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import App from './App'
import './App.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<ConfigProvider locale={zhCN}>
<App />
</ConfigProvider>
)
import axios from 'axios'
const request = axios.create({
baseURL: '/api',
timeout: 120000,
})
request.interceptors.response.use(
response => response.data,
error => {
console.error('请求错误:', error)
return Promise.resolve({ success: false, message: error.message || '请求失败' })
}
)
export default request
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:4000',
changeOrigin: true,
},
},
},
})
Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!