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

671 lines
22 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="currentTool.placeholder"
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 v-if="showQuerySql">
<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 v-if="showSplitOutput" 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 }}
</div>
</div>
<div class="grid grid-rows-3 gap-3 flex-1 min-h-0">
<div
v-for="section in splitOutputSections"
:key="section.key"
class="flex flex-col min-h-0 border border-gray-200 rounded-lg overflow-hidden bg-gray-50"
>
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 bg-white">
<div class="text-sm font-semibold text-gray-900">{{ section.label }}</div>
<button
@click="copySplitOutput(section.key)"
:disabled="!splitOutputSql[section.key]"
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"
>
复制{{ section.label }}
</button>
</div>
<textarea
v-model="splitOutputSql[section.key]"
readonly
class="w-full flex-1 min-h-[120px] px-3 py-2 border-0 text-sm font-mono bg-gray-50 resize-none focus:ring-0"
></textarea>
</div>
</div>
<div class="flex items-center justify-between mt-2">
<p class="text-xs text-gray-400">结果仅用于复制执行请确认无误后使用</p>
</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 v-else 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: '',
splitOutputSql: {
sp: '',
ppCn: '',
ppUs: ''
},
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。',
placeholder: '示例: X0X60F 17141\nC01008446046, 11894'
},
{
value: 'new-factory-return-redelivery',
label: '新区工厂退库重新出库',
description: '每行输入加工单、病例号、正确运单,只读取前三列;根据 CRM 加工单关联地址国家拆分 PP-CN / PP-US SQL。',
placeholder: '示例: M20260508006905 225KF9 SF123456789\nM20260509005949 C01005934247 UPS987654321'
}
]
}
},
computed: {
currentTool() {
return this.toolOptions.find((tool) => tool.value === this.selectedTool) || this.toolOptions[0];
},
showSplitOutput() {
return this.selectedTool === 'new-factory-return-redelivery';
},
showQuerySql() {
return !this.showSplitOutput;
},
splitOutputSections() {
return [
{ key: 'sp', label: 'SP' },
{ key: 'ppCn', label: 'PP-CN' },
{ key: 'ppUs', label: 'PP-US' }
];
}
},
methods: {
clearInput() {
this.inputText = '';
this.outputSql = '';
this.resetSplitOutputSql();
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.resetSplitOutputSql();
this.querySql = '';
this.warnings = [];
this.resetCopyStatus();
this.resetStats();
if (this.selectedTool === 'ob-external-id') {
await this.generateObExternalIdSql();
return;
}
if (this.selectedTool === 'new-factory-return-redelivery') {
await this.generateNewFactoryReturnRedeliverySql();
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;
}
},
parseNewFactoryReturnRedeliveryInput() {
const lines = this.inputText.split(/\r?\n/);
const errors = [];
const rows = [];
lines.forEach((line, index) => {
const trimmed = line.trim();
if (!trimmed) {
return;
}
const parts = trimmed.split(/[\s,]+/).filter(Boolean);
if (parts.length < 3) {
errors.push(`${index + 1} 行格式不正确,请提供加工单、病例号、正确运单三列`);
return;
}
rows.push({
productionCode: parts[0],
caseCode: parts[1],
expressNo: parts[2],
lineNumber: index + 1
});
});
if (rows.length === 0 && errors.length === 0) {
errors.push('请输入至少一行加工单、病例号、正确运单。');
}
return {
rows,
errors
};
},
async generateNewFactoryReturnRedeliverySql() {
const { rows, errors } = this.parseNewFactoryReturnRedeliveryInput();
if (errors.length) {
this.errors = errors;
return;
}
this.loading = true;
try {
const productionCodes = [...new Set(rows.map((row) => row.productionCode))];
this.querySql = this.buildProductionCountriesSelect(productionCodes);
const response = await fetch('/api/sql-generator/production-countries/check', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({
production_codes: productionCodes
})
});
const data = await response.json();
if (!response.ok || !data.success) {
this.errors = [data.message || '查询 CRM 加工单国家失败,请稍后重试。'];
return;
}
const productionCountries = data.data.production_countries || {};
const missingRows = rows.filter((row) => !productionCountries[row.productionCode] || productionCountries[row.productionCode].length === 0);
if (missingRows.length) {
this.errors = missingRows.map((row) => `${row.lineNumber} 行加工单 ${row.productionCode} 未在 CRM 查询到地址国家`);
return;
}
const spSql = [];
const ppCnSql = [];
const ppUsSql = [];
rows.forEach((row) => {
const productionCode = this.escapeSqlValue(row.productionCode);
const expressNo = this.escapeSqlValue(row.expressNo);
const ppSql = this.isUsProductionCountry(productionCountries[row.productionCode]) ? ppUsSql : ppCnSql;
spSql.push(`update case_deliveries set express_no = '${expressNo}' where production_code = '${productionCode}';`);
ppSql.push(`update delivery_records set express_no = '${expressNo}' where production_code = '${productionCode}';`);
ppSql.push(`update receives set express_no = '${expressNo}' where production_code = '${productionCode}';`);
});
const sections = [
this.buildSqlSection('SP', spSql),
this.buildSqlSection('PP-CN', ppCnSql),
this.buildSqlSection('PP-US', ppUsSql)
].filter(Boolean);
this.stats.update = spSql.length + ppCnSql.length + ppUsSql.length;
this.stats.total = this.stats.update;
this.splitOutputSql = {
sp: spSql.join('\n'),
ppCn: ppCnSql.join('\n'),
ppUs: ppUsSql.join('\n')
};
this.outputSql = sections.join('\n\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})`;
},
buildProductionCountriesSelect(productionCodes) {
if (!productionCodes.length) {
return '';
}
const inValues = productionCodes.map((productionCode) => `'${this.escapeSqlValue(productionCode)}'`).join(', ');
return [
'select ep.name, epc.ea_case_id_c, epc.ea_businessorder_id_c, epc.ea_salesorder_id_c',
'from ea_production ep',
'join ea_production_cstm epc on ep.id = epc.id_c',
`where ep.deleted = 0 and ep.name in (${inValues})`
].join('\n');
},
isUsProductionCountry(countryCodes) {
return (countryCodes || []).some((countryCode) => ['US', 'GU', 'PR'].includes(String(countryCode).toUpperCase()));
},
buildSqlSection(title, sqlLines) {
if (!sqlLines.length) {
return '';
}
return [`-- ${title}`, ...sqlLines].join('\n');
},
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'
};
},
resetSplitOutputSql() {
this.splitOutputSql = {
sp: '',
ppCn: '',
ppUs: ''
};
},
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 copySplitOutput(key) {
await this.copyToClipboard(this.splitOutputSql[key], '复制失败,请手动复制结果。');
},
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>