#feature: add ip operation log & sql generator

This commit is contained in:
2025-12-25 14:25:57 +08:00
parent 79889e1040
commit 3bcbd0661f
21 changed files with 1751 additions and 21 deletions

View File

@@ -0,0 +1,447 @@
<template>
<div class="sql-generator h-full flex flex-col p-4 box-border gap-4">
<div class="flex flex-col gap-4 lg:grid lg:grid-cols-2 lg:items-stretch lg:flex-1 min-h-0">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 flex flex-col min-h-0">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm lg:text-base font-semibold text-gray-900 flex items-center">
<svg class="w-4 h-4 text-blue-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
生成SQL
</h2>
<span class="text-xs text-gray-500">选择功能后粘贴内容生成SQL</span>
</div>
<div class="flex flex-col gap-3 flex-1 min-h-0">
<div class="flex-shrink-0">
<label class="block text-xs font-medium text-gray-600 mb-2">功能选择</label>
<select
v-model="selectedTool"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
>
<option v-for="tool in toolOptions" :key="tool.value" :value="tool.value">
{{ tool.label }}
</option>
</select>
<p class="text-xs text-gray-500 mt-2">
{{ currentTool.description }}
</p>
</div>
<div class="flex flex-col flex-1 min-h-0">
<label class="block text-xs font-medium text-gray-600 mb-2">文本内容</label>
<textarea
v-model="inputText"
rows="8"
placeholder="示例: X0X60F 17141\nC01008446046, 11894"
class="w-full flex-1 min-h-[200px] px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm font-mono"
></textarea>
<div class="flex items-center justify-between mt-2">
<p class="text-xs text-gray-500">支持换行分隔符逗号 / 空格 / 制表符</p>
<div class="flex items-center space-x-2">
<button
@click="clearInput"
class="px-3 py-1.5 text-xs text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
type="button"
>
清空
</button>
<button
@click="generateSql"
:disabled="loading"
class="px-3 py-1.5 text-xs text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-60"
type="button"
>
<span v-if="loading">生成中...</span>
<span v-else>生成SQL</span>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 flex flex-col min-h-0">
<div class="flex flex-col gap-4 h-full min-h-0">
<div>
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm lg:text-base font-semibold text-gray-900">查询SQL</h3>
<button
@click="copyQuery"
:disabled="!querySql"
class="px-3 py-1.5 text-xs text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-60"
type="button"
>
复制查询SQL
</button>
</div>
<textarea
v-model="querySql"
readonly
ref="queryTextarea"
class="w-full min-h-[100px] px-3 py-2 border border-gray-200 rounded-lg text-sm font-mono bg-gray-50"
></textarea>
</div>
<div class="flex flex-col flex-1 min-h-0">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm lg:text-base font-semibold text-gray-900">生成结果</h3>
<div v-if="stats.total" class="text-xs text-gray-500">
{{ stats.total }} 更新 {{ stats.update }} 新增 {{ stats.insert }}
</div>
</div>
<textarea
v-model="outputSql"
readonly
ref="outputTextarea"
class="w-full flex-1 min-h-[200px] px-3 py-2 border border-gray-200 rounded-lg text-sm font-mono bg-gray-50"
></textarea>
<div class="flex items-center justify-between mt-2">
<p class="text-xs text-gray-400">结果仅用于复制执行请确认无误后使用</p>
<button
@click="copyOutput"
:disabled="!outputSql"
class="px-3 py-1.5 text-xs text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-60"
type="button"
>
复制结果
</button>
</div>
<div
v-if="copyStatus.message"
class="mt-2 text-xs"
:class="copyStatus.type === 'success' ? 'text-green-600' : 'text-red-600'"
>
{{ copyStatus.message }}
</div>
</div>
</div>
</div>
</div>
<div v-if="errors.length" class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">
<div class="font-medium mb-1">输入格式问题</div>
<ul class="list-disc list-inside space-y-0.5">
<li v-for="(error, index) in errors" :key="index">{{ error }}</li>
</ul>
</div>
<div v-if="warnings.length" class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm text-yellow-800">
<div class="font-medium mb-1">发现重复 case_id</div>
<ul class="list-disc list-inside space-y-0.5">
<li v-for="(warning, index) in warnings" :key="index">{{ warning }}</li>
</ul>
</div>
</div>
</template>
<script>
export default {
name: 'SqlGenerator',
data() {
return {
selectedTool: 'ob-external-id',
inputText: '',
outputSql: '',
querySql: '',
loading: false,
errors: [],
warnings: [],
copyStatus: {
message: '',
type: 'success'
},
copyStatusTimer: null,
stats: {
total: 0,
update: 0,
insert: 0
},
toolOptions: [
{
value: 'ob-external-id',
label: 'OB外部ID',
description: '每行输入 case_id 与 ob_id系统会判断是否生成更新或插入 SQL。'
}
]
}
},
computed: {
currentTool() {
return this.toolOptions.find((tool) => tool.value === this.selectedTool) || this.toolOptions[0];
}
},
methods: {
clearInput() {
this.inputText = '';
this.outputSql = '';
this.querySql = '';
this.errors = [];
this.warnings = [];
this.resetCopyStatus();
this.resetStats();
},
resetStats() {
this.stats = {
total: 0,
update: 0,
insert: 0
};
},
parseObExternalIdInput() {
const lines = this.inputText.split(/\r?\n/);
const errors = [];
const duplicates = new Map();
const caseMap = new Map();
lines.forEach((line, index) => {
const trimmed = line.trim();
if (!trimmed) {
return;
}
const parts = trimmed.split(/[\s,]+/).filter(Boolean);
if (parts.length !== 2) {
errors.push(`${index + 1} 行格式不正确,请提供 case_id 与 ob_id 两列`);
return;
}
const [caseCode, obId] = parts;
if (!caseCode || !obId) {
errors.push(`${index + 1} 行缺少 case_id 或 ob_id`);
return;
}
const lineNumber = index + 1;
if (caseMap.has(caseCode)) {
const existing = caseMap.get(caseCode);
if (!duplicates.has(caseCode)) {
duplicates.set(caseCode, {
caseCode,
lineNumbers: [existing.lineNumber]
});
}
duplicates.get(caseCode).lineNumbers.push(lineNumber);
}
caseMap.set(caseCode, {
caseCode,
obId,
lineNumber
});
});
const pairs = Array.from(caseMap.values());
if (pairs.length === 0 && errors.length === 0) {
errors.push('请输入至少一行 case_id 与 ob_id。');
}
return {
pairs,
errors,
duplicates: Array.from(duplicates.values())
};
},
async generateSql() {
this.errors = [];
this.outputSql = '';
this.querySql = '';
this.warnings = [];
this.resetCopyStatus();
this.resetStats();
if (this.selectedTool === 'ob-external-id') {
await this.generateObExternalIdSql();
return;
}
this.errors = ['未识别的功能类型,请重新选择。'];
},
async generateObExternalIdSql() {
const { pairs, errors, duplicates } = this.parseObExternalIdInput();
this.warnings = this.buildDuplicateWarnings(duplicates);
if (errors.length) {
this.errors = errors;
return;
}
this.loading = true;
try {
const caseCodes = pairs.map((pair) => pair.caseCode);
this.querySql = this.buildCaseExtrasSelect(caseCodes);
const response = await fetch('/api/sql-generator/ob-external-id/check', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({
case_codes: caseCodes
})
});
const data = await response.json();
if (!response.ok || !data.success) {
this.errors = [data.message || '查询 case_extras 失败,请稍后重试。'];
return;
}
const existingCaseCodes = new Set(data.data.existing_case_codes || []);
const sqlLines = [];
pairs.forEach((pair) => {
const caseCode = this.escapeSqlValue(pair.caseCode);
const obId = this.escapeSqlValue(pair.obId);
if (existingCaseCodes.has(pair.caseCode)) {
sqlLines.push(
`update case_extras set value = '${obId}' where case_code = '${caseCode}' and source = 'ob' and field = 'OB Case ID'`
);
this.stats.update += 1;
} else {
sqlLines.push(
`insert into case_extras(case_code, source, field, value) VALUES ('${caseCode}', 'ob', 'OB Case ID', '${obId}')`
);
this.stats.insert += 1;
}
});
this.stats.total = sqlLines.length;
this.outputSql = sqlLines.join('\n');
} catch (error) {
this.errors = ['网络请求失败: ' + error.message];
} finally {
this.loading = false;
}
},
escapeSqlValue(value) {
return String(value).replace(/'/g, "''");
},
buildCaseExtrasSelect(caseCodes) {
if (!caseCodes.length) {
return '';
}
const inValues = caseCodes.map((caseCode) => `'${this.escapeSqlValue(caseCode)}'`).join(', ');
return `select * from case_extras where case_code in (${inValues})`;
},
buildDuplicateWarnings(duplicates) {
return duplicates.map((duplicate) => {
const lines = duplicate.lineNumbers.join('、');
return `case_id ${duplicate.caseCode} 重复出现在第 ${lines} 行,已以最后一行的 ob_id 为准。`;
});
},
resetCopyStatus() {
this.copyStatus = {
message: '',
type: 'success'
};
},
setCopyStatus(message, type) {
this.copyStatus = {
message,
type
};
window.clearTimeout(this.copyStatusTimer);
this.copyStatusTimer = window.setTimeout(() => {
this.resetCopyStatus();
}, 3000);
},
execCommandCopy(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'absolute';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
let success = false;
try {
success = document.execCommand('copy');
} catch (error) {
success = false;
}
document.body.removeChild(textarea);
return success;
},
execCommandCopyFromRef(refName) {
const target = this.$refs[refName];
if (!target) {
return false;
}
target.focus();
target.select();
target.setSelectionRange(0, target.value.length);
let success = false;
try {
success = document.execCommand('copy');
} catch (error) {
success = false;
}
target.blur();
return success;
},
async copyToClipboard(text, fallbackMessage, refName) {
if (!text) {
return;
}
this.resetCopyStatus();
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
this.setCopyStatus('已复制到剪贴板。', 'success');
return;
} catch (error) {
// fallback to execCommand
}
}
const success = refName
? this.execCommandCopyFromRef(refName)
: this.execCommandCopy(text);
if (success) {
this.setCopyStatus('已复制到剪贴板。', 'success');
return;
}
this.setCopyStatus(fallbackMessage, 'error');
},
async copyOutput() {
await this.copyToClipboard(this.outputSql, '复制失败,请手动复制结果。', 'outputTextarea');
},
async copyQuery() {
await this.copyToClipboard(this.querySql, '复制失败请手动复制查询SQL。', 'queryTextarea');
}
}
}
</script>
<style scoped>
.sql-generator {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
</style>