448 lines
14 KiB
Vue
448 lines
14 KiB
Vue
<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>
|