Files
toolbox/resources/js/components/tools/SqlGenerator.vue

448 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>