Files
klp-oa/klp-ui/src/views/crm/contract/components/ContractExportDialog.vue

602 lines
27 KiB
Vue
Raw Normal View History

<template>
<el-dialog title="导出预览" :visible.sync="dialogVisible" width="95%" top="20px" append-to-body
:close-on-click-modal="false" @open="onDialogOpen">
<div style="display: flex; gap: 16px; height: calc(100vh - 200px);">
<div style="width: 200px; flex-shrink: 0; border: 1px solid #e4e7ed; border-radius: 4px; padding: 12px; overflow-y: auto;">
<div style="font-weight: bold; margin-bottom: 10px; font-size: 14px;">产品表列配置</div>
<el-checkbox v-model="selectAllColumns" :indeterminate="columnIndeterminate" @change="handleSelectAllColumns"
style="margin-bottom: 8px;">全选</el-checkbox>
<div v-for="col in columnConfigs" :key="col.key" style="margin-bottom: 6px;">
<el-checkbox v-model="col.checked" @change="onColumnChange">{{ col.label }}</el-checkbox>
</div>
<el-divider />
<div style="font-weight: bold; margin-bottom: 10px; font-size: 14px;">附加行配置</div>
<el-checkbox v-model="selectAllRows" :indeterminate="rowIndeterminate" @change="handleSelectAllRows"
style="margin-bottom: 8px;">全选</el-checkbox>
<div v-for="rowCfg in rowConfigs" :key="rowCfg.key" style="margin-bottom: 6px;">
<el-checkbox v-model="rowCfg.checked" @change="onRowChange">{{ rowCfg.label }}</el-checkbox>
</div>
<el-divider />
<div style="font-weight: bold; margin-bottom: 10px; font-size: 14px;">附件图片配置</div>
<div v-if="attachmentLoading" style="text-align:center;padding:10px;color:#909399;font-size:12px;">
<i class="el-icon-loading"></i> 加载中...
</div>
<div v-else-if="attachmentConfigs.length === 0" style="color:#909399;font-size:12px;text-align:center;padding:10px;">
无图片附件
</div>
<div v-else>
<el-checkbox v-model="selectAllAttachments" :indeterminate="attachmentIndeterminate" @change="handleSelectAllAttachments"
style="margin-bottom: 8px;">全选</el-checkbox>
<div v-for="(att, index) in attachmentConfigs" :key="att.ossId" style="display:flex;align-items:center;margin-bottom:6px;gap:2px;">
<el-checkbox v-model="att.checked" @change="onAttachmentChange" style="flex:1;min-width:0;">
<span style="font-size:11px;display:inline-block;max-width:90px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;" :title="att.originalName">{{ att.originalName }}</span>
</el-checkbox>
<el-button icon="el-icon-top" size="mini" circle :disabled="index === 0" @click="moveAttachmentUp(index)" style="padding:4px;"></el-button>
<el-button icon="el-icon-bottom" size="mini" circle :disabled="index === attachmentConfigs.length - 1" @click="moveAttachmentDown(index)" style="padding:4px;"></el-button>
</div>
</div>
</div>
<div style="flex: 1; border: 1px solid #e4e7ed; border-radius: 4px; overflow: hidden; background: #f0f0f0;">
<iframe ref="previewFrame" style="width: 100%; height: 100%; border: none; background: #fff;"></iframe>
</div>
</div>
<span slot="footer">
<el-button @click="handleCancel"> </el-button>
<el-button type="primary" :loading="exporting" @click="confirmExport">确认导出</el-button>
</span>
</el-dialog>
</template>
<script>
import contractLogo from '@/assets/images/contractLogo.png';
import {
parseProductContent,
convertToChinese
} from '@/utils/productContent';
import html2canvas from 'html2canvas';
import { jsPDF } from 'jspdf';
import { listByIds } from '@/api/system/oss';
const CHINESE_HEADING_REGEX = /^[零一二三四五六七八九十百千]+、/;
export default {
name: "ContractExportDialog",
props: {
visible: {
type: Boolean,
default: false
},
row: {
type: Object,
default: null
}
},
data() {
return {
exporting: false,
selectAllColumns: true,
columnIndeterminate: false,
columnConfigs: [
{ key: 'spec', label: '规格mm', checked: true },
{ key: 'material', label: '材质', checked: true },
{ key: 'quantity', label: '数量(吨)', checked: true },
{ key: 'taxPrice', label: '含税单价(元/吨)', checked: true },
{ key: 'taxDivisor', label: '税率除数', checked: true },
{ key: 'noTaxPrice', label: '无税单价(元/吨)', checked: true },
{ key: 'taxTotal', label: '含税总额(元)', checked: true },
{ key: 'noTaxTotal', label: '无税总额(元)', checked: true },
{ key: 'taxAmount', label: '税额(元)', checked: true },
{ key: 'remark', label: '备注', checked: true },
],
selectAllRows: true,
rowIndeterminate: false,
rowConfigs: [
{ key: 'totalRow', label: '合计行', checked: true },
{ key: 'amountWordsRow', label: '大写金额行', checked: true },
{ key: 'remarkRow', label: '备注行', checked: true },
],
attachmentConfigs: [],
attachmentLoading: false,
selectAllAttachments: true,
attachmentIndeterminate: false,
};
},
computed: {
dialogVisible: {
get() {
return this.visible;
},
set(val) {
this.$emit('update:visible', val);
}
}
},
methods: {
handleCancel() {
this.$emit('update:visible', false);
},
handleSelectAllColumns(val) {
this.columnConfigs.forEach(col => { col.checked = val; });
this.columnIndeterminate = false;
this.generatePreviewHtml();
},
onColumnChange() {
const checkedCount = this.columnConfigs.filter(c => c.checked).length;
this.selectAllColumns = checkedCount === this.columnConfigs.length;
this.columnIndeterminate = checkedCount > 0 && checkedCount < this.columnConfigs.length;
this.generatePreviewHtml();
},
handleSelectAllRows(val) {
this.rowConfigs.forEach(row => { row.checked = val; });
this.rowIndeterminate = false;
this.generatePreviewHtml();
},
onRowChange() {
const checkedCount = this.rowConfigs.filter(r => r.checked).length;
this.selectAllRows = checkedCount === this.rowConfigs.length;
this.rowIndeterminate = checkedCount > 0 && checkedCount < this.rowConfigs.length;
this.generatePreviewHtml();
},
buildProductTableHtml(productData, products) {
const activeCols = this.columnConfigs.filter(c => c.checked);
const activeRows = this.rowConfigs.filter(r => r.checked);
const hasCol = (key) => activeCols.some(c => c.key === key);
const hasRow = (key) => activeRows.some(r => r.key === key);
let headerCells = '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:30px;">序号</th>';
if (hasCol('spec')) headerCells += '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:80px;">规格(mm)</th>';
if (hasCol('material')) headerCells += '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:60px;">材质</th>';
if (hasCol('quantity')) headerCells += '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:55px;">数量(吨)</th>';
if (hasCol('taxPrice')) headerCells += '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:70px;">含税单价(元/吨)</th>';
if (hasCol('taxDivisor')) headerCells += '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:45px;">税率除数</th>';
if (hasCol('noTaxPrice')) headerCells += '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:70px;">无税单价(元/吨)</th>';
if (hasCol('taxTotal')) headerCells += '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:70px;">含税总额(元)</th>';
if (hasCol('noTaxTotal')) headerCells += '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:70px;">无税总额(元)</th>';
if (hasCol('taxAmount')) headerCells += '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:55px;">税额(元)</th>';
if (hasCol('remark')) headerCells += '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:80px;">备注</th>';
const colCount = activeCols.length + 1;
let bodyRows = '';
products.forEach((product, index) => {
let cells = `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${index + 1}</td>`;
if (hasCol('spec')) cells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${product.spec || ''}</td>`;
if (hasCol('material')) cells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${product.material || ''}</td>`;
if (hasCol('quantity')) cells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${product.quantity || ''}</td>`;
if (hasCol('taxPrice')) cells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${product.taxPrice || ''}</td>`;
if (hasCol('taxDivisor')) cells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${product.taxDivisor || '1.13'}</td>`;
if (hasCol('noTaxPrice')) cells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${(product.noTaxPrice || 0).toFixed(3)}</td>`;
if (hasCol('taxTotal')) cells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${(product.taxTotal || 0).toFixed(3)}</td>`;
if (hasCol('noTaxTotal')) cells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${(product.noTaxTotal || 0).toFixed(3)}</td>`;
if (hasCol('taxAmount')) cells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${(product.taxAmount || 0).toFixed(3)}</td>`;
if (hasCol('remark')) cells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;max-width:100px;word-wrap:break-word;">${product.remark || ''}</td>`;
bodyRows += `<tr>${cells}</tr>`;
});
const totalQty = products.reduce((a, p) => a + (parseFloat(p.quantity) || 0), 0);
const totalTax = products.reduce((a, p) => a + (parseFloat(p.taxTotal) || 0), 0);
const totalNoTax = products.reduce((a, p) => a + (parseFloat(p.noTaxTotal) || 0), 0);
const totalTaxAmt = products.reduce((a, p) => a + (parseFloat(p.taxAmount) || 0), 0);
const totalAmountInWords = productData.totalAmountInWords || convertToChinese(totalTax) || '零元整';
let totalCells = '';
let cellIdx = 0;
activeCols.forEach((col) => {
if (cellIdx < 2) {
if (cellIdx === 0) {
totalCells += `<td colspan="3" style="border:1px solid #000;padding:5px 6px;font-weight:bold;text-align:center;">合&nbsp;&nbsp;计</td>`;
}
} else {
let val = '';
if (col.key === 'quantity') val = totalQty.toFixed(3);
else if (col.key === 'taxTotal') val = totalTax.toFixed(3);
else if (col.key === 'noTaxTotal') val = totalNoTax.toFixed(3);
else if (col.key === 'taxAmount') val = totalTaxAmt.toFixed(3);
totalCells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${val}</td>`;
}
cellIdx++;
});
const html = `
<table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:6px;border:2px solid #000;">
<tr>
<td colspan="4" style="border:1px solid #000;padding:4px 6px;font-weight:bold;text-align:left;">产品名称${productData.productName || ''}</td>
<td colspan="${colCount - 4 > 0 ? colCount - 4 : 1}" style="border:1px solid #000;padding:4px 6px;font-weight:bold;text-align:left;">生产厂家嘉祥科伦普重工有限公司</td>
</tr>
<tr>${headerCells}</tr>
${bodyRows}
${hasRow('totalRow') ? `<tr>${totalCells}</tr>` : ''}
${hasRow('amountWordsRow') ? `<tr><td colspan="${colCount}" style="border:1px solid #000;padding:5px 6px;font-weight:bold;text-align:left;">合计人民币(大写):${totalAmountInWords}</td></tr>` : ''}
${hasRow('remarkRow') && productData.remark ? `<tr><td colspan="${colCount}" style="border:1px solid #000;padding:4px 6px;text-align:left;">备注:${productData.remark}</td></tr>` : ''}
</table>`;
return html;
},
buildContractContentHtml(htmlContent) {
if (!htmlContent) return '';
const pTagRegex = /<p[^>]*>([\s\S]*?)<\/p>/g;
let match;
// Parse into clauses: each heading (Chinese number + 、) starts a new clause
const clauses = [];
let currentClause = null;
while ((match = pTagRegex.exec(htmlContent)) !== null) {
let content = match[1].replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'").trim();
if (!content) continue;
const isHeading = CHINESE_HEADING_REGEX.test(content);
if (isHeading) {
if (currentClause) clauses.push(currentClause);
currentClause = { heading: content, bodies: [] };
} else if (currentClause) {
currentClause.bodies.push(content);
} else {
// Content before any heading: create a headless clause
clauses.push({ heading: null, bodies: [content] });
}
}
if (currentClause) clauses.push(currentClause);
if (clauses.length === 0) {
// Fallback: plain text content without <p> tags
const textContent = htmlContent.replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').trim();
if (textContent) {
clauses.push({ heading: null, bodies: [textContent] });
}
}
if (clauses.length === 0) return '';
let html = '<div style="margin-top:8px;font-size:12px;line-height:1.8;">';
clauses.forEach(clause => {
html += '<div data-clause style="page-break-inside:avoid;padding-top:2px;">';
if (clause.heading) {
html += `<div>${clause.heading}</div>`;
}
clause.bodies.forEach(body => {
html += `<div> ${body}</div>`;
});
html += '</div>';
});
html += '</div>';
return html;
},
generatePreviewHtml() {
const row = this.row;
if (!row) return;
let productData = parseProductContent(row.productContent);
if (!productData.productName && row.productName) {
productData.productName = row.productName;
}
const products = productData.products && productData.products.length > 0 ? productData.products : [];
const productTableHtml = this.buildProductTableHtml(productData, products);
const contractContentHtml = this.buildContractContentHtml(row.contractContent);
const totalTax = products.reduce((a, p) => a + (parseFloat(p.taxTotal) || 0), 0);
const amountWords = productData.totalAmountInWords || convertToChinese(totalTax) || '零元整';
let imagePagesHtml = '';
this.attachmentConfigs.forEach(att => {
if (!att.checked) return;
imagePagesHtml += `
<div class="a4-page" style="display:flex;align-items:center;justify-content:center;background:#fff;">
<img src="${att.url}" style="max-width:90%;max-height:90%;object-fit:contain;" crossorigin="anonymous" />
</div>`;
});
const fullHtml = `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>合同预览</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'SimHei','黑体',sans-serif; font-weight: 600; color: #000; background: #e8e8e8; font-size: 12px; line-height: 1.6; display: flex; flex-direction: column; align-items: center; padding: 20px 0; }
.a4-page { width: 794px; min-height: 1123px; padding: 30px 40px; background: #fff; margin-bottom: 20px; box-shadow: 0 2px 12px rgba(0,0,0,0.15); }
.company-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 6px; }
.contract-title { text-align: center; font-size: 20px; font-weight: bold; letter-spacing: 6px; margin-bottom: 14px; }
.info-row { display: flex; justify-content: space-between; margin-bottom: 4px; font-size: 13px; line-height: 2; }
.section-title { font-size: 13px; font-weight: bold; margin-bottom: 4px; margin-top: 8px; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #000; padding: 4px 6px; text-align: center; font-size: 13px; }
th { font-weight: 700; }
.sign-section { margin-top: 24px; font-size: 12px; line-height: 2.2; }
.sign-section .col { width: 48%; }
.sign-row { display: flex; justify-content: space-between; }
</style>
</head>
<body>
<div class="a4-page" style="position:relative;">
<img src="${contractLogo}" style="position:absolute;left:40px;top:40px;height:80px;" crossorigin="anonymous" />
<div style="text-align:center;padding-top:10px;">
<div style="font-size:20px;font-weight:bold;letter-spacing:2px;">嘉祥科伦普重工有限公司</div>
</div>
<div style="position:relative;margin:12px 0 8px 0;text-align:center;">
<div style="font-size:22px;font-weight:bold;letter-spacing:6px;"> </div>
<div style="position:absolute;right:0;top:50%;transform:translateY(-50%);font-size:11px;">合同编号${row.contractCode || ''}</div>
</div>
<div class="info-row">
<div style="width:55%;">
<div>供方甲方${row.supplier || ''}</div>
<div>需方乙方${row.customer || ''}</div>
</div>
<div style="width:40%;">
<div>签订时间${row.signTime ? this.parseTime(row.signTime, '{y}年{m}月{d}日') : ''}</div>
<div>签订地点${row.signLocation || ''}</div>
</div>
</div>
<div class="section-title">产品内容</div>
${productTableHtml}
${contractContentHtml}
<div class="sign-section" data-clause style="page-break-inside:avoid;">
<div class="sign-row">
<div class="col">
<div>供方甲方${row.supplier || ''}</div>
<div>地址${row.supplierAddress || ''}</div>
<div>电话${row.supplierPhone || ''}</div>
<div>开户行${row.supplierBank || ''}</div>
<div>账号${row.supplierAccount || ''}</div>
<div>税号${row.supplierTaxNo || ''}</div>
</div>
<div class="col">
<div>需方乙方${row.customer || ''}</div>
<div>地址${row.customerAddress || ''}</div>
<div>电话${row.customerPhone || ''}</div>
<div>开户行${row.customerBank || ''}</div>
<div>账号${row.customerAccount || ''}</div>
<div>税号${row.customerTaxNo || ''}</div>
</div>
</div>
</div>
</div>
${imagePagesHtml}
</body>
</html>`;
this.$nextTick(() => {
const iframe = this.$refs.previewFrame;
if (iframe) {
iframe.srcdoc = fullHtml;
}
});
},
getClauseCanvasPositions(iframeDoc, a4Page, canvasWidth) {
const clauseEls = iframeDoc.querySelectorAll('[data-clause]');
const a4Rect = a4Page.getBoundingClientRect();
const scale = canvasWidth / a4Page.offsetWidth;
const positions = [];
clauseEls.forEach(el => {
const rect = el.getBoundingClientRect();
positions.push({
top: (rect.top - a4Rect.top) * scale,
bottom: (rect.bottom - a4Rect.top) * scale
});
});
positions.sort((a, b) => a.top - b.top);
return positions;
},
async confirmExport() {
const iframe = this.$refs.previewFrame;
if (!iframe || !iframe.contentWindow) return;
const iframeDoc = iframe.contentDocument;
const a4Page = iframeDoc.querySelector('.a4-page');
if (!a4Page) return;
this.exporting = true;
try {
const canvas = await html2canvas(a4Page, {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
logging: false
});
const clausePositions = this.getClauseCanvasPositions(iframeDoc, a4Page, canvas.width);
const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const pW = pdf.internal.pageSize.getWidth();
const pH = pdf.internal.pageSize.getHeight();
const mg = 10;
const printW = pW - mg * 2;
const printH = pH - mg * 2;
const ratio = canvas.width / printW;
const footerMargin = 8;
const imagePrintH = printH - footerMargin;
const pageCanvasHeight = imagePrintH * ratio;
let currentY = 0;
const pages = [];
while (currentY < canvas.height) {
let pageBottom = currentY + pageCanvasHeight;
if (pageBottom >= canvas.height) {
pageBottom = canvas.height;
} else {
for (const clause of clausePositions) {
// If a clause starts before the page break and ends after it,
// and the clause fits on a single page, move the break before this clause
if (clause.top < pageBottom && clause.bottom > pageBottom) {
if (clause.bottom - clause.top <= pageCanvasHeight) {
pageBottom = clause.top;
}
break;
}
}
}
if (pageBottom <= currentY) {
pageBottom = currentY + pageCanvasHeight;
}
pages.push({ top: currentY, bottom: pageBottom });
currentY = pageBottom;
}
const checkedAttachmentCount = this.attachmentConfigs.filter(a => a.checked).length;
const totalPages = pages.length + checkedAttachmentCount;
let pageNum = 0;
const fullPageCanvasHeight = Math.round(printH * ratio);
for (let i = 0; i < pages.length; i++) {
pageNum++;
const { top, bottom } = pages[i];
const sliceH = bottom - top;
const sc = document.createElement('canvas');
sc.width = canvas.width;
sc.height = fullPageCanvasHeight;
const sctx = sc.getContext('2d');
sctx.fillStyle = '#ffffff';
sctx.fillRect(0, 0, sc.width, sc.height);
sctx.drawImage(canvas, 0, top, canvas.width, sliceH, 0, 0, canvas.width, sliceH);
sctx.fillStyle = '#000000';
sctx.font = 'bold 24px "SimHei","黑体",sans-serif';
sctx.textAlign = 'center';
sctx.textBaseline = 'bottom';
sctx.fillText(`${pageNum} 页,共 ${totalPages}`, sc.width / 2, sc.height - 4);
pdf.addImage(sc.toDataURL('image/jpeg', 0.95), 'JPEG', mg, mg, printW, printH);
if (i < pages.length - 1) {
pdf.addPage();
}
}
for (let i = 0; i < this.attachmentConfigs.length; i++) {
const att = this.attachmentConfigs[i];
if (!att.checked) continue;
try {
const img = await this.loadImage(att.url);
pdf.addPage();
pageNum++;
const canvasPageW = Math.round(printW * ratio);
const canvasImageH = Math.round(imagePrintH * ratio);
const canvasFooterH = Math.round(footerMargin * ratio);
const canvasTotalH = canvasImageH + canvasFooterH;
const attCanvas = document.createElement('canvas');
attCanvas.width = canvasPageW;
attCanvas.height = canvasTotalH;
const actx = attCanvas.getContext('2d');
actx.fillStyle = '#ffffff';
actx.fillRect(0, 0, canvasPageW, canvasTotalH);
const imgScale = Math.min(canvasPageW / img.width, canvasImageH / img.height, 1);
const imgDrawW = img.width * imgScale;
const imgDrawH = img.height * imgScale;
const imgX = (canvasPageW - imgDrawW) / 2;
const imgY = (canvasImageH - imgDrawH) / 2;
actx.drawImage(img, imgX, imgY, imgDrawW, imgDrawH);
actx.fillStyle = '#000000';
actx.font = 'bold 24px "SimHei","黑体",sans-serif';
actx.textAlign = 'center';
actx.textBaseline = 'bottom';
actx.fillText(`${pageNum} 页,共 ${totalPages}`, canvasPageW / 2, canvasTotalH - 4);
pdf.addImage(attCanvas.toDataURL('image/jpeg', 0.95), 'JPEG', mg, mg, printW, printH);
} catch (e) {
console.error('加载附件图片失败: ' + att.originalName, e);
}
}
const filename = '合同_' + (this.row.contractCode || 'export') + '_' + new Date().getTime() + '.pdf';
pdf.save(filename);
this.$emit('update:visible', false);
} catch (e) {
this.$message.error('PDF导出失败' + e.message);
console.error(e);
} finally {
this.exporting = false;
}
},
async onDialogOpen() {
await this.fetchAttachments();
this.generatePreviewHtml();
},
async fetchAttachments() {
const row = this.row;
if (!row) return;
const fields = ['businessAnnex', 'techAnnex', 'productionSchedule', 'annexFiles'];
const allIds = [];
fields.forEach(field => {
if (row[field]) {
const ids = row[field].split(',').map(id => id.trim()).filter(id => id);
allIds.push(...ids);
}
});
if (allIds.length === 0) {
this.attachmentConfigs = [];
return;
}
this.attachmentLoading = true;
try {
const uniqueIds = [...new Set(allIds)];
const res = await listByIds(uniqueIds.join(','));
const files = res.data || [];
const imageSuffixes = ['jpg', 'jpeg', 'png', 'webp', 'bmp'];
this.attachmentConfigs = files
.filter(f => imageSuffixes.includes((f.fileSuffix || '').toLowerCase().replace(/^\./, '')))
.map(f => ({
ossId: f.ossId,
url: f.url,
originalName: f.originalName,
fileSuffix: f.fileSuffix,
checked: true
}));
this.selectAllAttachments = this.attachmentConfigs.length > 0;
this.attachmentIndeterminate = false;
} catch (e) {
console.error('获取附件列表失败', e);
this.attachmentConfigs = [];
} finally {
this.attachmentLoading = false;
}
},
moveAttachmentUp(index) {
if (index <= 0) return;
const arr = this.attachmentConfigs;
[arr[index - 1], arr[index]] = [arr[index], arr[index - 1]];
this.generatePreviewHtml();
},
moveAttachmentDown(index) {
if (index >= this.attachmentConfigs.length - 1) return;
const arr = this.attachmentConfigs;
[arr[index], arr[index + 1]] = [arr[index + 1], arr[index]];
this.generatePreviewHtml();
},
onAttachmentChange() {
const checkedCount = this.attachmentConfigs.filter(a => a.checked).length;
this.selectAllAttachments = checkedCount === this.attachmentConfigs.length;
this.attachmentIndeterminate = checkedCount > 0 && checkedCount < this.attachmentConfigs.length;
this.generatePreviewHtml();
},
handleSelectAllAttachments(val) {
this.attachmentConfigs.forEach(a => { a.checked = val; });
this.attachmentIndeterminate = false;
this.generatePreviewHtml();
},
loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
},
}
};
</script>