This commit is contained in:
zlwu committed 2026-07-26 21:06:09 +08:00
1 parent 70bdd820ce
commit bede140d12
988 files changed
+692310 -775910

No files matched your search

+2
View File
@@ -45,6 +45,8 @@ export interface QuoteProductForm {
description?: string
sort?: number
remark?: string
isToxic?: number
isExplosive?: number
}
export interface QuoteForm {
@@ -16,6 +16,8 @@ export interface SupplierInfoForm {
phoneFax?: string
bankName?: string
bankAccount?: string
allowToxic?: number
allowExplosive?: number
status?: string
remark?: string
}
@@ -31,6 +33,8 @@ export interface SupplierInfoVO {
phoneFax?: string
bankName?: string
bankAccount?: string
allowToxic?: number
allowExplosive?: number
status?: string
remark?: string
createTime?: string
+11
View File
@@ -1437,6 +1437,17 @@ body {
flex-shrink: 0;
}
/* 合同/报价打印弹窗:标题与编辑提示同一行垂直居中,顶栏更紧凑 */
.el-dialog.generate-print-dialog .el-dialog__header {
padding-top: 10px !important;
padding-bottom: 10px !important;
}
.el-dialog.generate-print-dialog .el-dialog__headerbtn {
top: 50%;
transform: translateY(-50%);
}
.el-dialog.dialog-form-layout .el-dialog__headerbtn {
display: flex;
align-items: center;
@@ -33,6 +33,12 @@
<el-button class="btn-ghost" :loading="saveLoading" @click="handleSaveTemplate">
<el-icon><DocumentChecked /></el-icon>保存模板
</el-button>
<el-button class="btn-ghost" @click="toggleRedSeal">
<el-icon>
<component :is="showRedSeal ? Hide : Stamp" />
</el-icon>
{{ showRedSeal ? '隐藏红章' : '显示红章' }}
</el-button>
<el-button class="btn-ghost" @click="handlePrint">
<el-icon><Printer /></el-icon>打印
</el-button>
@@ -45,7 +51,7 @@
</template>
<script setup lang="ts">
import { Close, DocumentChecked, Download, Printer, View } from '@element-plus/icons-vue'
import { Close, DocumentChecked, Download, Hide, Printer, Stamp, View } from '@element-plus/icons-vue'
import html2canvas from 'html2canvas'
import jsPDF from 'jspdf'
import { fetchProductDetail } from '@/api/crm/product'
@@ -53,6 +59,7 @@ import { planApi, type Plan } from '@/api/pl'
import TinyMceEditor from '@/components/TinyMceEditor/index.vue'
import { useDictStore } from '@/store/modules/dict'
import {
buildDeliverySlipCompanySealHtml,
generateDeliverySlipTemplate,
DELIVERY_SLIP_DOC_MAX_WIDTH,
DELIVERY_SLIP_PRINT_CSS,
@@ -62,11 +69,14 @@ import {
setupDeliverySlipEditorTypeCheckboxes,
type DeliverySlipRenderContext
} from '@/utils/pl-delivery-slip-template'
import { injectSlipSealImages, stripSlipSealImages } from '@/utils/pl-slip-red-seal'
import { getPlSlipPdfCanvasScale } from '@/utils/pl-slip-pdf-render'
const visible = ref(false)
const slipContent = ref('')
const isPreviewMode = ref(false)
/** 打印弹窗内红章显隐(仅本次弹窗有效,打开时默认显示) */
const showRedSeal = ref(true)
const saveLoading = ref(false)
const pdfGenerating = ref(false)
const currentPlanId = ref('')
@@ -129,6 +139,7 @@ async function buildSlipRenderContext(plan: Plan): Promise<DeliverySlipRenderCon
async function open(planId: string) {
currentPlanId.value = planId
isPreviewMode.value = false
showRedSeal.value = true
try {
ElMessage.info({ message: '正在生成送(提)货单...', duration: 800 })
const res = await planApi.get(planId)
@@ -162,6 +173,22 @@ function handleClosed() {
currentPlanId.value = ''
currentPlan.value = null
isPreviewMode.value = false
showRedSeal.value = true
}
/** 切换红章显示/隐藏(同步影响编辑器、打印、PDF) */
function toggleRedSeal() {
if (showRedSeal.value) {
slipContent.value = stripSlipSealImages(slipContent.value)
showRedSeal.value = false
return
}
slipContent.value = injectSlipSealImages(
slipContent.value,
buildDeliverySlipCompanySealHtml(),
'delivery-slip-seal-block'
)
showRedSeal.value = true
}
async function refreshPlan() {
@@ -32,6 +32,12 @@
<el-button class="btn-ghost" :loading="saveLoading" @click="handleSaveTemplate">
<el-icon><DocumentChecked /></el-icon>保存模板
</el-button>
<el-button class="btn-ghost" @click="toggleRedSeal">
<el-icon>
<component :is="showRedSeal ? Hide : Stamp" />
</el-icon>
{{ showRedSeal ? '隐藏红章' : '显示红章' }}
</el-button>
<el-button class="btn-ghost" @click="handlePrint">
<el-icon><Printer /></el-icon>打印
</el-button>
@@ -44,7 +50,7 @@
</template>
<script setup lang="ts">
import { Close, DocumentChecked, Download, Printer, View } from '@element-plus/icons-vue'
import { Close, DocumentChecked, Download, Hide, Printer, Stamp, View } from '@element-plus/icons-vue'
import html2canvas from 'html2canvas'
import jsPDF from 'jspdf'
import { fetchProductDetail } from '@/api/crm/product'
@@ -52,6 +58,7 @@ import { planApi, type Plan } from '@/api/pl'
import TinyMceEditor from '@/components/TinyMceEditor/index.vue'
import { useDictStore } from '@/store/modules/dict'
import {
buildInboundSlipCompanySealHtml,
generateInboundSlipTemplate,
INBOUND_SLIP_DOC_MAX_WIDTH,
INBOUND_SLIP_PRINT_CSS,
@@ -60,11 +67,14 @@ import {
restoreInboundSlipPlaceholders,
type InboundSlipRenderContext
} from '@/utils/pl-inbound-slip-template'
import { injectSlipSealImages, stripSlipSealImages } from '@/utils/pl-slip-red-seal'
import { getPlSlipPdfCanvasScale } from '@/utils/pl-slip-pdf-render'
const visible = ref(false)
const slipContent = ref('')
const isPreviewMode = ref(false)
/** 打印弹窗内红章显隐(仅本次弹窗有效,打开时默认显示) */
const showRedSeal = ref(true)
const saveLoading = ref(false)
const pdfGenerating = ref(false)
const currentPlanId = ref('')
@@ -127,6 +137,7 @@ async function buildSlipRenderContext(plan: Plan): Promise<InboundSlipRenderCont
async function open(planId: string) {
currentPlanId.value = planId
isPreviewMode.value = false
showRedSeal.value = true
try {
ElMessage.info({ message: '正在生成入库单...', duration: 800 })
const res = await planApi.get(planId)
@@ -160,6 +171,22 @@ function handleClosed() {
currentPlanId.value = ''
currentPlan.value = null
isPreviewMode.value = false
showRedSeal.value = true
}
/** 切换红章显示/隐藏(同步影响编辑器、打印、PDF) */
function toggleRedSeal() {
if (showRedSeal.value) {
slipContent.value = stripSlipSealImages(slipContent.value)
showRedSeal.value = false
return
}
slipContent.value = injectSlipSealImages(
slipContent.value,
buildInboundSlipCompanySealHtml(),
'inbound-slip-seal-block'
)
showRedSeal.value = true
}
async function refreshPlan() {
@@ -68,7 +68,7 @@
</el-select>
</el-form-item>
<el-form-item label="单据类型" prop="planType">
<el-select v-model="formData.planType" placeholder="请选择单据类型" style="width: 100%">
<el-select v-model="formData.planType" placeholder="请选择单据类型" clearable style="width: 100%">
<el-option label="直销单" value="direct" />
<el-option label="入库单" value="inbound" />
<el-option label="出库单" value="outbound" />
@@ -398,8 +398,7 @@ const formData = reactive({
const basicRules = {
planDate: [{ required: true, message: '请选择计划日期', trigger: 'change' }],
belongsCompany: [{ required: true, message: '请选择归属公司', trigger: 'change' }],
planType: [{ required: true, message: '请选择单据类型', trigger: 'change' }]
belongsCompany: [{ required: true, message: '请选择归属公司', trigger: 'change' }]
}
const productsRef = computed(() => formData.products)
@@ -561,7 +560,7 @@ function buildPlanCommonPayload(options?: { allowClearTransport?: boolean }): Pl
return {
planDate: formData.planDate,
belongsCompany: formData.belongsCompany,
planType: formData.planType,
planType: formData.planType || '',
contractNo: formData.contractNo,
supplierId: formData.supplierId || undefined,
supplierName: formData.supplierName || undefined,
@@ -33,6 +33,12 @@
<el-button class="btn-ghost" :loading="saveLoading" @click="handleSaveTemplate">
<el-icon><DocumentChecked /></el-icon>保存模板
</el-button>
<el-button class="btn-ghost" @click="toggleRedSeal">
<el-icon>
<component :is="showRedSeal ? Hide : Stamp" />
</el-icon>
{{ showRedSeal ? '隐藏红章' : '显示红章' }}
</el-button>
<el-button class="btn-ghost" @click="handlePrint">
<el-icon><Printer /></el-icon>打印
</el-button>
@@ -45,7 +51,7 @@
</template>
<script setup lang="ts">
import { Close, DocumentChecked, Download, Printer, View } from '@element-plus/icons-vue'
import { Close, DocumentChecked, Download, Hide, Printer, Stamp, View } from '@element-plus/icons-vue'
import html2canvas from 'html2canvas'
import jsPDF from 'jspdf'
import { planApi, type Plan } from '@/api/pl'
@@ -53,6 +59,7 @@ import TinyMceEditor from '@/components/TinyMceEditor/index.vue'
import { useDictStore } from '@/store/modules/dict'
import { useUserStore } from '@/store/modules/user'
import {
buildSubpackSlipCompanySealHtml,
formatSubpackSlipDateIso,
generateSubpackSlipTemplate,
SUBPACK_SLIP_DOC_MAX_WIDTH,
@@ -63,11 +70,14 @@ import {
setupSubpackSlipEditorCheckboxes,
type SubpackSlipRenderContext
} from '@/utils/pl-subpack-slip-template'
import { injectSlipSealImages, stripSlipSealImages } from '@/utils/pl-slip-red-seal'
import { getPlSlipPdfCanvasScale } from '@/utils/pl-slip-pdf-render'
const visible = ref(false)
const slipContent = ref('')
const isPreviewMode = ref(false)
/** 打印弹窗内红章显隐(仅本次弹窗有效,打开时默认显示) */
const showRedSeal = ref(true)
const saveLoading = ref(false)
const pdfGenerating = ref(false)
const currentPlanId = ref('')
@@ -120,6 +130,7 @@ async function buildSlipRenderContext(plan: Plan): Promise<SubpackSlipRenderCont
async function open(planId: string) {
currentPlanId.value = planId
isPreviewMode.value = false
showRedSeal.value = true
try {
ElMessage.info({ message: '正在生成分装单...', duration: 800 })
const res = await planApi.get(planId)
@@ -144,6 +155,8 @@ async function open(planId: string) {
const renderCtx = await buildSlipRenderContext(plan)
slipContent.value = replaceSubpackSlipPlaceholders(htmlContent, plan, renderCtx)
// 新模板默认已去掉印章区;若历史模板仍含章,按内容同步按钮态
showRedSeal.value = /<img[^>]*alt=["']印章["'][^>]*>/i.test(slipContent.value)
visible.value = true
} catch (error) {
console.error('打开分装单打印失败', error)
@@ -157,6 +170,24 @@ function handleClosed() {
currentPlan.value = null
currentSubpackSlipNo.value = ''
isPreviewMode.value = false
showRedSeal.value = true
}
/** 切换红章显示/隐藏(同步影响编辑器、打印、PDF) */
function toggleRedSeal() {
if (showRedSeal.value) {
slipContent.value = stripSlipSealImages(slipContent.value)
showRedSeal.value = false
return
}
const sealHtml = buildSubpackSlipCompanySealHtml()
let next = injectSlipSealImages(slipContent.value, sealHtml, 'subpack-slip-seal-block')
// 新模板已去掉印章区时,回退到标题区(position:relative)右上角
if (!/<img[^>]*alt=["']印章["'][^>]*>/i.test(next) && next.includes('subpack-slip-title-section')) {
next = next.replace(/(<div class="subpack-slip-title-section"[^>]*>)/, `$1\n ${sealHtml}\n `)
}
slipContent.value = next
showRedSeal.value = /<img[^>]*alt=["']印章["'][^>]*>/i.test(next)
}
async function refreshPlan() {
@@ -24,11 +24,25 @@ export interface PlanFormProductRow {
legalUseCertUrl?: string
}
export function resolveBelongsCompany(supplierName?: string) {
if (supplierName?.includes('安徽云栋')) return 'yundong'
/** 从合同交易主体/甲乙方名称解析运输计划归属公司 */
export function resolveBelongsCompany(...names: Array<string | undefined | null>) {
const text = names.map((n) => (n || '').trim()).filter(Boolean).join(' ')
// 与合同模板/报价一致:含「云栋」视为安徽云栋,否则默认安庆山峡
if (text.includes('云栋')) return 'yundong'
return 'shanxia'
}
/** 优先取交易主体名称;缺失时从甲/乙方中识别我方主体 */
export function resolveTradingEntityName(contract: Pick<ContractVO, 'supplierName' | 'partyA' | 'partyB'>) {
const supplierName = (contract.supplierName || '').trim()
if (supplierName) return supplierName
for (const name of [contract.partyA, contract.partyB]) {
const n = (name || '').trim()
if (n && (n.includes('山峡') || n.includes('云栋'))) return n
}
return (contract.partyA || '').trim()
}
export function mapContractProductToPlanRow(item: ContractProductForm, index: number): PlanFormProductRow {
const productId = String(item.productId || item.id || index)
return {
@@ -65,7 +79,8 @@ export function applyContractToPlanForm(contract: ContractVO) {
return {
contractId: String(contract.id),
contractNo: contract.contractNo,
belongsCompany: resolveBelongsCompany(contract.supplierName || contract.partyA),
// 归属公司从合同交易主体带出,表单仍允许用户手动改
belongsCompany: resolveBelongsCompany(resolveTradingEntityName(contract)),
supplierId: contract.supplierId != null ? String(contract.supplierId) : '',
supplierName,
destinationId: contract.customerId != null ? String(contract.customerId) : '',
+7
View File
@@ -484,6 +484,13 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
component: '/crm/customer/pool/index',
meta: { title: '公海池', keepAlive: true, menuCode: 'crm:customerPool:list' }
},
{
id: 'CRM3000000000000000000000000040',
name: 'CrmSupplierHazard',
path: 'supplier-hazard',
component: '/crm/supplier-hazard/index',
meta: { title: '主体危化品能力', keepAlive: true, menuCode: 'crm:supplierHazard:list' }
},
{
id: 'CRM1000000000000000000000000004',
name: 'CrmPoolRule',
@@ -0,0 +1,39 @@
/** 主体危化品能力:产品是否允许选择 */
export function productAllowedByHazard(
isToxic: number | undefined,
isExplosive: number | undefined,
allowToxic: number,
allowExplosive: number
): boolean {
const toxic = Number(isToxic || 0) === 1
const explosive = Number(isExplosive || 0) === 1
if (!toxic && !explosive) return true
if (toxic && explosive) return allowToxic === 1 && allowExplosive === 1
if (toxic) return allowToxic === 1
return allowExplosive === 1
}
export function removeProductsNotAllowedByHazard<
T extends {
productId?: string | number
productName?: string
isToxic?: number
isExplosive?: number
}
>(
products: T[],
allowToxic: number,
allowExplosive: number
): { kept: T[]; removedNames: string[] } {
const kept: T[] = []
const removedNames: string[] = []
for (const p of products) {
if (productAllowedByHazard(p.isToxic, p.isExplosive, allowToxic, allowExplosive)) {
kept.push(p)
} else {
removedNames.push(p.productName || String(p.productId || ''))
}
}
return { kept, removedNames }
}
+181 -98
View File
@@ -4,12 +4,14 @@ import { applyPdfStableTableCellBorders } from '@/utils/pl-slip-pdf-render'
/** 入库单打印默认公司信息(可在富文本中修改) */
export const INBOUND_SLIP_DEFAULTS = {
companyName: '安庆市山峡化工有限公司',
receivingUnit: '安庆市山峡化工有限公司',
companyAddress: '安庆市高新区霞虹路5号',
companyPhone: '0556-5999710',
contactPhone: '方总13395560066'
} as const
/** 旧模板曾把送货单位写死为本公司名,保存还原时一并识别 */
const LEGACY_RECEIVING_UNIT = '安庆市山峡化工有限公司'
const DOC_FONT = "SimSun, 'Songti SC', serif"
const FONT_TITLE = 'font-size:26px;font-weight:bold;'
const FONT_BODY = 'font-size:20px;'
@@ -29,17 +31,18 @@ const INFO_COLGROUP = `<colgroup>
<col span="7" style="width:14.28%;" />
</colgroup>`
/**
* 物料表列宽:加宽编号/名称,收窄规格/单位;前 5 列合计供条款区使用。
* 物料编号 / 物料名称 / 规格 / 单位 / 数量 / 产品规格 / 实收数量
* 物料表列宽:单据编号 + 加宽编号/名称,收窄规格/单位。
* 单据编号 / 物料编号 / 物料名称 / 规格 / 单位 / 数量 / 产品规格 / 实收数量
*/
const PRODUCT_COLGROUP = `<colgroup>
<col style="width:18%;" />
<col style="width:24%;" />
<col style="width:14%;" />
<col style="width:14%;" />
<col style="width:20%;" />
<col style="width:8%;" />
<col style="width:6%;" />
<col style="width:12%;" />
<col style="width:16%;" />
<col style="width:16%;" />
<col style="width:10%;" />
<col style="width:14%;" />
<col style="width:14%;" />
</colgroup>`
/** 物料网格 padding(编辑器预览) */
@@ -62,16 +65,12 @@ const PRODUCT_GRID_CELL = `padding:${PRODUCT_GRID_CELL_PADDING};box-sizing:borde
const PRODUCT_CELL = `padding:4px 6px;border:${PRODUCT_TABLE_BORDER} !important;text-align:center;${FONT_BODY}line-height:1.35;background-color:#fff !important;${PRODUCT_CELL_WRAP}`
const PRODUCT_TH = `padding:${PRODUCT_GRID_TH_PADDING};box-sizing:border-box;border:${PRODUCT_GRID_BORDER} !important;text-align:center;${FONT_BODY}line-height:normal;font-weight:bold;background-color:#fff !important;${PRODUCT_CELL_WRAP}`
const PRODUCT_TABLE_CELL_LEFT = `padding:10px 6px 14px 6px;border:${PRODUCT_TABLE_BORDER} !important;text-align:left;${FONT_BODY}line-height:1.35;vertical-align:top;background-color:#fff !important;`
/** 条款单元格:行高随内容,避免被右侧印章区 min-height 撑出底部空白 */
/** 条款单元格样式(兼容已保存模板归一化) */
const PRODUCT_TERMS_CELL_LEFT = `padding:10px 6px 14px 6px;border:${PRODUCT_TABLE_BORDER} !important;text-align:left;${FONT_BODY}line-height:1.35;vertical-align:top;background-color:#fff !important;`
const TERMS_TEXT_STYLE = 'line-height:1.35;'
const SEAL_BLOCK_LINE_STYLE = 'line-height:1.35;'
/** 入库单印章:public/sxshzyz.png,定位在地址单元格右上角(相对原位置向上 66px) */
/** 入库单印章:public/sxshzyz.png(兼容已保存模板的红章开关) */
const INBOUND_SLIP_SEAL_IMAGE_SRC = '/sxshzyz.png'
const INBOUND_SLIP_SEAL_TOP = -66
const INBOUND_SLIP_SEAL_IMAGE_STYLE = `position:absolute;right:4px;top:${INBOUND_SLIP_SEAL_TOP}px;width:140px;height:140px;opacity:0.85;z-index:10;`
const HANDWRITE_BLANK_INNER = '&nbsp;&nbsp;&nbsp;&nbsp;'
const HANDWRITE_BLANK = `<span class="inbound-slip-handwrite-blank">(${HANDWRITE_BLANK_INNER})</span>`
const PLAIN_CELL = `border:none !important;padding:${TABLE_ROW_PADDING};${FONT_BODY}line-height:1.8;vertical-align:top;background:transparent !important;`
const NO_BORDER = `${PLAIN_CELL} vertical-align:bottom;`
@@ -87,9 +86,9 @@ const INFO_VALUE_INLINE_LEFT = `flex:1;min-width:0;box-sizing:border-box;border-
const INFO_VALUE_INLINE_CENTER = `flex:1;min-width:0;box-sizing:border-box;border-bottom:${INFO_UNDERLINE} !important;padding:0 2px 2px;text-align:center;min-height:1.3em;`
const INFO_CELL_LEFT = `${NO_BORDER}text-align:left;`
const INFO_CELL_RIGHT = `${NO_BORDER}text-align:left;`
const INFO_GAP_BEFORE_PRODUCT = 24
const INFO_GAP_BEFORE_PRODUCT = 12
const GAP_BEFORE_PRODUCT_CELL = `border:none !important;padding:0 !important;line-height:0 !important;font-size:0 !important;background:transparent !important;height:${INFO_GAP_BEFORE_PRODUCT}px !important;min-height:${INFO_GAP_BEFORE_PRODUCT}px !important;`
const INFO_GAP_BEFORE_SIGN = 24
const INFO_GAP_BEFORE_SIGN = 12
const GAP_BEFORE_SIGN_CELL = `border:none !important;padding:0 !important;line-height:0 !important;font-size:0 !important;background:transparent !important;height:${INFO_GAP_BEFORE_SIGN}px !important;min-height:${INFO_GAP_BEFORE_SIGN}px !important;`
const SIDE_LABELS_COL_WIDTH = 36
@@ -99,7 +98,7 @@ function buildInboundSlipSignItem(label: string, valueContent: string, minWidth:
function buildInboundSlipSignRowHtml(): string {
return `<tr class="inbound-slip-sign-row">
<td colspan="7" class="inbound-slip-sign-row-cell" style="border:none !important;padding:${TABLE_ROW_PADDING};text-align:center;${FONT_SIGN}background:transparent !important;">
<td colspan="8" class="inbound-slip-sign-row-cell" style="border:none !important;padding:${TABLE_ROW_PADDING};text-align:center;${FONT_SIGN}background:transparent !important;">
<div class="inbound-slip-sign-inner">
${buildInboundSlipSignItem('仓库:', '&nbsp;', '80px')}
${buildInboundSlipSignItem('审核:', '&nbsp;', '80px')}
@@ -149,6 +148,10 @@ function formatQty(value?: number | string | null): string {
return num.toFixed(3)
}
function buildDeliveryUnit(plan: Plan): string {
return plan.customerName?.trim() || plan.destinationName?.trim() || ''
}
function formatInboundSlipProductGridCellContent(content: string): string {
const plain = content
.trim()
@@ -161,15 +164,22 @@ function formatInboundSlipProductGridCellContent(content: string): string {
return plain || '&nbsp;'
}
function buildProductRows(products: PlanProduct[] = [], ctx?: InboundSlipRenderContext): string {
function buildProductRows(
products: PlanProduct[] = [],
planNo = '',
ctx?: InboundSlipRenderContext
): string {
if (!products.length) {
return `<tr><td class="inbound-slip-product-cell" colspan="7" style="${PRODUCT_CELL}">暂无产品信息</td></tr>`
return `<tr><td class="inbound-slip-product-cell" colspan="8" style="${PRODUCT_CELL}">暂无产品信息</td></tr>`
}
const planNoText = formatInboundSlipProductGridCellContent(planNo)
const dataRows = products
.map((product) => {
const qty = product.inboundQty ?? product.inboundEstimateQty ?? product.quantity ?? ''
return `<tr>
<td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${planNoText}</td>
<td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatInboundSlipProductGridCellContent(resolveSlipProductCode(product, ctx))}</td>
<td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatInboundSlipProductGridCellContent(product.productName || '')}</td>
<td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatInboundSlipProductGridCellContent(product.model || '')}</td>
@@ -451,25 +461,8 @@ export function buildInboundSlipCompanySealHtml(): string {
return `<img src="${INBOUND_SLIP_SEAL_IMAGE_SRC}" alt="印章" style="${INBOUND_SLIP_SEAL_IMAGE_STYLE}" />`
}
const SEAL_BLOCK = `<div class="inbound-slip-seal-block" style="position:relative;padding:0;${FONT_BODY}${SEAL_BLOCK_LINE_STYLE}">
<!-- 印章图片 -->
{{companySeal}}
<div class="inbound-slip-seal-lines" style="position:relative;z-index:1;${SEAL_BLOCK_LINE_STYLE}margin:0;padding:0;">
地址:{{companyAddress}}<br />
送货单位固话:{{companyPhone}}<br />
联系人电话:{{contactPhone}}
</div>
</div>`
const SIDE_LABEL_CELL = `border:none !important;padding:0 !important;vertical-align:top;text-align:center;${FONT_SIDE}line-height:1.35;background:transparent !important;`
function buildInboundSlipTermsHtml(): string {
return `<div class="inbound-slip-terms-text" style="${TERMS_TEXT_STYLE}">
1.本送货单如购销双方未签订合同,视为有效;<br />
2.本次送货含桶${HANDWRITE_BLANK}个,回收${HANDWRITE_BLANK}个,结存${HANDWRITE_BLANK}个;
</div>`
}
/** 右侧联次:单列纵向,带圈序号 + 每字一行依次向下排列 */
export function buildInboundSlipSideLabelsHtml(): string {
return `<table class="inbound-slip-side-labels" style="border:none !important;border-collapse:collapse;margin:0 auto;width:auto;">
@@ -502,24 +495,10 @@ export function generateInboundSlipTemplate(_plan?: Plan): string {
<tr>
<td colspan="4" style="${INFO_CELL_LEFT}">
<div class="inbound-slip-info-row" style="${INFO_ROW}">
<span class="inbound-slip-info-label" style="${INFO_LABEL}">收货单位:</span>
<span class="inbound-slip-info-label" style="${INFO_LABEL}">送货单位:</span>
<span class="inbound-slip-info-value-left" style="${INFO_VALUE_INLINE_LEFT}">{{receivingUnit}}&nbsp;</span>
</div>
</td>
<td colspan="3" style="${INFO_CELL_RIGHT}">
<div class="inbound-slip-info-row" style="${INFO_ROW}">
<span class="inbound-slip-info-label" style="${INFO_LABEL}">单据编号:</span>
<span class="inbound-slip-info-value-center" style="${INFO_VALUE_INLINE_CENTER}">{{planNo}}&nbsp;</span>
</div>
</td>
</tr>
<tr>
<td colspan="4" style="${INFO_CELL_LEFT}">
<div class="inbound-slip-info-row" style="${INFO_ROW}">
<span class="inbound-slip-info-label" style="${INFO_LABEL}">收货联系方式:</span>
<span class="inbound-slip-info-value-left" style="${INFO_VALUE_INLINE_LEFT}">{{receiveContact}}&nbsp;</span>
</div>
</td>
<td colspan="3" style="${INFO_CELL_RIGHT}">
<div class="inbound-slip-info-row" style="${INFO_ROW}">
<span class="inbound-slip-info-label" style="${INFO_LABEL}">日&nbsp;期:</span>
@@ -531,10 +510,11 @@ export function generateInboundSlipTemplate(_plan?: Plan): string {
<div class="inbound-slip-section-gap inbound-slip-gap-before-product" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</div>
<!-- 2. 物料明细表(含备注、条款+印章;可拖表头/明细列宽行高) -->
<!-- 2. 物料明细表(含备注;可拖表头/明细列宽行高) -->
<table class="inbound-slip-product-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${PRODUCT_COLGROUP}
<tr class="inbound-slip-product-header">
<th class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_TH}">单据编号</th>
<th class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_TH}">物料编号</th>
<th class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_TH}">物料名称</th>
<th class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_TH}">规格</th>
@@ -545,18 +525,10 @@ export function generateInboundSlipTemplate(_plan?: Plan): string {
</tr>
{{productTable}}
<tr>
<td colspan="7" class="inbound-slip-product-cell" style="${PRODUCT_TABLE_CELL_LEFT}">
<td colspan="8" class="inbound-slip-product-cell" style="${PRODUCT_TABLE_CELL_LEFT}">
备注:{{remark}}
</td>
</tr>
<tr>
<td colspan="5" class="inbound-slip-product-cell inbound-slip-terms-cell" style="${PRODUCT_TERMS_CELL_LEFT}">
${buildInboundSlipTermsHtml()}
</td>
<td colspan="2" class="inbound-slip-product-cell" style="${PRODUCT_TABLE_CELL_LEFT}">
${SEAL_BLOCK}
</td>
</tr>
</table>
<div class="inbound-slip-section-gap inbound-slip-gap-before-sign" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</div>
@@ -655,7 +627,7 @@ function patchInboundSlipProductGridCellAttrs(attrs: string): string {
function normalizeInboundSlipProductGridCell(html: string): string {
if (!html.includes('inbound-slip-doc') || !html.includes('inbound-slip-product-cell')) return html
let result = html.replace(/<tr([^>]*)>\s*(<th[\s\S]*?<\/th>\s*){7}\s*<\/tr>/g, (rowMatch) => {
let result = html.replace(/<tr([^>]*)>\s*(<th[\s\S]*?<\/th>\s*){8}\s*<\/tr>/g, (rowMatch) => {
if (!rowMatch.includes('物料编号') || rowMatch.includes('colspan')) return rowMatch
let row = rowMatch.includes('inbound-slip-product-header')
? rowMatch
@@ -665,7 +637,7 @@ function normalizeInboundSlipProductGridCell(html: string): string {
})
})
result = result.replace(/<tr([^>]*)>\s*(<td(?![^>]*colspan)[^>]*>[\s\S]*?<\/td>\s*){7}\s*<\/tr>/g, (rowMatch) => {
result = result.replace(/<tr([^>]*)>\s*(<td(?![^>]*colspan)[^>]*>[\s\S]*?<\/td>\s*){8}\s*<\/tr>/g, (rowMatch) => {
if (
rowMatch.includes('备注') ||
rowMatch.includes('1.本送货单') ||
@@ -862,9 +834,10 @@ function normalizeInboundSlipProductTableFooter(html: string): string {
let result = html
result = result.replace(/<td([^>]*colspan="7"[^>]*)>(\s*)备注:/g, (_match, attrs, space) => {
result = result.replace(/<td([^>]*colspan="[78]"[^>]*)>(\s*)备注:/g, (_match, attrs, space) => {
const withSpan = String(attrs).replace(/colspan="[78]"/, 'colspan="8"')
const patched = patchProductFooterCellAttrs(
attrs,
withSpan,
'inbound-slip-product-cell',
PRODUCT_TABLE_CELL_LEFT
)
@@ -949,33 +922,44 @@ function normalizeInboundSlipProductColWidths(html: string): string {
)
}
/** 条款区两行间距与括号内手写空白(兼容已保存模板) */
/** 移除条款+地址印章行(兼容已保存模板) */
function normalizeInboundSlipTermsText(html: string): string {
if (!html.includes('inbound-slip-doc') || !html.includes('1.本送货单')) {
if (!html.includes('inbound-slip-doc')) return html
if (
!html.includes('1.本送货单') &&
!html.includes('inbound-slip-terms-text') &&
!html.includes('inbound-slip-seal-block')
) {
return html
}
const termsHtml = buildInboundSlipTermsHtml()
if (html.includes('inbound-slip-terms-text')) {
return html.replace(/<div class="inbound-slip-terms-text"[\s\S]*?<\/div>/, termsHtml.trim())
}
return html.replace(
/1\.本送货单如购销双方未签订合同,视为有效;[\s\S]*?结存\([^)]*\)个;/,
termsHtml.trim()
// 条款+印章整行删除(同一 tr 内左右两格)
let result = html.replace(
/<tr[^>]*>\s*<td[^>]*colspan="[45]"[^>]*>[\s\S]*?(?:inbound-slip-terms-text|1\.本送货单)[\s\S]*?<\/tr>\s*/gi,
''
)
// 兜底:独立印章行
result = result.replace(
/<tr[^>]*>\s*<td[^>]*>[\s\S]*?inbound-slip-seal-block[\s\S]*?<\/tr>\s*/gi,
''
)
return result
}
/** 刷新已有间距行的 inline 样式(TinyMCE 保存后可能丢失) */
function refreshInboundSlipGapBeforeProduct(html: string): string {
return html.replace(
/<tr class="inbound-slip-gap-before-product">[\s\S]*?<\/tr>/,
`<tr class="inbound-slip-gap-before-product">\n <td colspan="7" class="inbound-slip-gap-before-product-cell" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</td>\n </tr>`
)
return html
.replace(
/<tr class="inbound-slip-gap-before-product">[\s\S]*?<\/tr>/,
`<tr class="inbound-slip-gap-before-product">\n <td colspan="8" class="inbound-slip-gap-before-product-cell" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</td>\n </tr>`
)
.replace(
/<div class="inbound-slip-section-gap inbound-slip-gap-before-product"[^>]*>[\s\S]*?<\/div>/,
`<div class="inbound-slip-section-gap inbound-slip-gap-before-product" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</div>`
)
}
/** 在收货联系方式行与物料表之间插入间距行(兼容已保存模板) */
/** 在信息区与物料表之间插入间距(兼容已保存模板) */
function normalizeInboundSlipGapBeforeProduct(html: string): string {
if (!html.includes('inbound-slip-doc')) {
return html
@@ -985,16 +969,21 @@ function normalizeInboundSlipGapBeforeProduct(html: string): string {
return refreshInboundSlipGapBeforeProduct(html)
}
const gapRow = `<tr class="inbound-slip-gap-before-product"><td colspan="7" class="inbound-slip-gap-before-product-cell" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</td></tr>\n `
const gapRow = `<tr class="inbound-slip-gap-before-product"><td colspan="8" class="inbound-slip-gap-before-product-cell" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</td></tr>\n `
const productHeader = `(?:单据编号|物料编号)`
const withComment = html.replace(
/(<span class="inbound-slip-info-label"[^>]*>日(?:&nbsp;)?期:<\/span>[\s\S]*?<\/tr>\s*)(<!--\s*中:产品明细表[\s\S]*?<tr>\s*<th[^>]*>物料编号)/,
new RegExp(
`(<span class="inbound-slip-info-label"[^>]*>日(?:&nbsp;)?期:</span>[\\s\\S]*?</tr>\\s*)(<!--\\s*中:产品明细表[\\s\\S]*?<tr>\\s*<th[^>]*>${productHeader})`
),
`$1${gapRow}$2`
)
if (withComment !== html) return withComment
const withHeader = html.replace(
/(<span class="inbound-slip-info-label"[^>]*>日(?:&nbsp;)?期:<\/span>[\s\S]*?<\/tr>\s*)(<tr>\s*<th[^>]*>物料编号)/,
new RegExp(
`(<span class="inbound-slip-info-label"[^>]*>日(?:&nbsp;)?期:</span>[\\s\\S]*?</tr>\\s*)(<tr>\\s*<th[^>]*>${productHeader})`
),
`$1${gapRow}$2`
)
if (withHeader !== html) return withHeader
@@ -1007,10 +996,15 @@ function normalizeInboundSlipGapBeforeProduct(html: string): string {
/** 刷新签字区上方间距行 inline 样式 */
function refreshInboundSlipGapBeforeSign(html: string): string {
return html.replace(
/<tr class="inbound-slip-gap-before-sign">[\s\S]*?<\/tr>/,
`<tr class="inbound-slip-gap-before-sign">\n <td colspan="7" class="inbound-slip-gap-before-sign-cell" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</td>\n </tr>`
)
return html
.replace(
/<tr class="inbound-slip-gap-before-sign">[\s\S]*?<\/tr>/,
`<tr class="inbound-slip-gap-before-sign">\n <td colspan="8" class="inbound-slip-gap-before-sign-cell" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</td>\n </tr>`
)
.replace(
/<div class="inbound-slip-section-gap inbound-slip-gap-before-sign"[^>]*>[\s\S]*?<\/div>/,
`<div class="inbound-slip-section-gap inbound-slip-gap-before-sign" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</div>`
)
}
/** 在物料表与签字区之间插入间距行(兼容已保存模板) */
@@ -1021,16 +1015,16 @@ function normalizeInboundSlipGapBeforeSign(html: string): string {
return refreshInboundSlipGapBeforeSign(html)
}
const gapRow = `<tr class="inbound-slip-gap-before-sign"><td colspan="7" class="inbound-slip-gap-before-sign-cell" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</td></tr>\n\n `
const gapRow = `<tr class="inbound-slip-gap-before-sign"><td colspan="8" class="inbound-slip-gap-before-sign-cell" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</td></tr>\n\n `
const withComment = html.replace(
/(<\/tr>\s*)(<!--\s*下:签字区[\s\S]*?<tr>\s*<td colspan="7"[^>]*>\s*仓库:)/,
/(<\/tr>\s*)(<!--\s*下:签字区[\s\S]*?<tr>\s*<td colspan="[78]"[^>]*>\s*仓库:)/,
`$1${gapRow}$2`
)
if (withComment !== html) return withComment
return html.replace(
/(inbound-slip-seal-block[\s\S]*?<\/tr>\s*)(<tr>\s*<td colspan="7"[^>]*>\s*仓库:)/,
/(inbound-slip-seal-block[\s\S]*?<\/tr>\s*)(<tr>\s*<td colspan="[78]"[^>]*>\s*仓库:)/,
`$1${gapRow}$2`
)
}
@@ -1065,7 +1059,7 @@ function normalizeInboundSlipSignRow(html: string): string {
if (!html.includes('inbound-slip-doc') || !html.includes('仓库:')) return html
let result = html.replace(
/<tr([^>]*)>\s*<td colspan="7"([^>]*)>([\s\S]*?仓库:[\s\S]*?司机\/送货人\(附车牌\)[^<]*[\s\S]*?)<\/td>\s*<\/tr>/g,
/<tr([^>]*)>\s*<td colspan="[78]"([^>]*)>([\s\S]*?仓库:[\s\S]*?司机\/送货人\(附车牌\)[^<]*[\s\S]*?)<\/td>\s*<\/tr>/g,
(match, trAttrs, tdAttrs, content) => {
if (!content.includes('inbound-slip-sign-value') || content.includes('inbound-slip-sign-inner')) {
return match
@@ -1097,7 +1091,7 @@ function normalizeInboundSlipSignRow(html: string): string {
nextTdAttrs = `${nextTdAttrs} class="inbound-slip-sign-row-cell"`
}
return `<tr${trClassAttr}>\n <td colspan="7"${nextTdAttrs}>\n <div class="inbound-slip-sign-inner">${wrappedContent.trim()}</div>\n </td>\n </tr>`
return `<tr${trClassAttr}>\n <td colspan="8"${nextTdAttrs}>\n <div class="inbound-slip-sign-inner">${wrappedContent.trim()}</div>\n </td>\n </tr>`
}
)
@@ -1109,6 +1103,82 @@ function normalizeInboundSlipSignRow(html: string): string {
return normalizeInboundSlipSignLabelMarkup(result)
}
/** 信息区:送货单位 + 日期(兼容旧「收货单位/单据编号/收货联系方式」布局) */
function normalizeInboundSlipInfoLayout(html: string): string {
if (!html.includes('inbound-slip-info-table')) return html
const infoMatch = html.match(/<table class="inbound-slip-info-table"[^>]*>[\s\S]*?<\/table>/i)
if (!infoMatch) return html
const infoHtml = infoMatch[0]
const needsMigrate =
infoHtml.includes('收货单位:') ||
infoHtml.includes('收货联系方式:') ||
infoHtml.includes('单据编号:') ||
!infoHtml.includes('送货单位:') ||
(infoHtml.match(/<tr[\s>]/g) || []).length > 1
if (!needsMigrate) return html
const newInfoTable = `<table class="inbound-slip-info-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${INFO_COLGROUP}
<tr>
<td colspan="4" style="${INFO_CELL_LEFT}">
<div class="inbound-slip-info-row" style="${INFO_ROW}">
<span class="inbound-slip-info-label" style="${INFO_LABEL}">送货单位:</span>
<span class="inbound-slip-info-value-left" style="${INFO_VALUE_INLINE_LEFT}">{{receivingUnit}}&nbsp;</span>
</div>
</td>
<td colspan="3" style="${INFO_CELL_RIGHT}">
<div class="inbound-slip-info-row" style="${INFO_ROW}">
<span class="inbound-slip-info-label" style="${INFO_LABEL}">日&nbsp;期:</span>
<span class="inbound-slip-info-value-center" style="${INFO_VALUE_INLINE_CENTER}">{{planDateChinese}}&nbsp;</span>
</div>
</td>
</tr>
</table>`
return html.replace(infoMatch[0], newInfoTable)
}
/** 物料表:表头补「单据编号」列,并把旧 7 列数据行升为 8 列(兼容已保存模板) */
function normalizeInboundSlipProductPlanNoColumn(html: string): string {
if (!html.includes('inbound-slip-product-table')) return html
let result = html
if (!/>单据编号<\/th>/.test(result)) {
result = result.replace(
/(<tr[^>]*class="[^"]*inbound-slip-product-header[^"]*"[^>]*>\s*)(<th[^>]*>物料编号<\/th>)/,
`$1<th class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_TH}">单据编号</th>\n $2`
)
result = result.replace(
/(<tr>\s*)(<th[^>]*>物料编号<\/th>)/,
`$1<th class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_TH}">单据编号</th>\n $2`
)
}
// 旧 7 列明细行:在首格前插入单据编号占位
result = result.replace(
/<tr([^>]*)>\s*((?:<td(?![^>]*colspan)[^>]*>[\s\S]*?<\/td>\s*){7})<\/tr>/g,
(rowMatch, trAttrs, cells) => {
if (
rowMatch.includes('备注') ||
rowMatch.includes('1.本送货单') ||
rowMatch.includes('仓库:') ||
rowMatch.includes('暂无产品信息') ||
rowMatch.includes('单据编号')
) {
return rowMatch
}
return `<tr${trAttrs}>\n <td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">{{planNo}}</td>\n ${cells}</tr>`
}
)
result = result.replace(/colspan="7"/g, 'colspan="8"')
return result
}
/**
* 旧版一体 layout-table / 曾拆出的 footer-table → 方案A:
* 信息表 + 物料表(含备注条款) + 签字表。
@@ -1143,6 +1213,7 @@ function migrateInboundSlipSplitTables(html: string): string {
(_full, inner: string) => {
const headerMatch =
inner.match(/<tr[^>]*class="[^"]*inbound-slip-product-header[^"]*"[\s\S]*?<\/tr>/i) ||
inner.match(/<tr>\s*<th[^>]*>单据编号[\s\S]*?<\/tr>/i) ||
inner.match(/<tr>\s*<th[^>]*>物料编号[\s\S]*?<\/tr>/i)
if (!headerMatch) return _full
@@ -1287,12 +1358,12 @@ export function replaceInboundSlipPlaceholders(
const replacements: Record<string, string> = {
'{{companyName}}': INBOUND_SLIP_DEFAULTS.companyName,
'{{receivingUnit}}': INBOUND_SLIP_DEFAULTS.receivingUnit,
'{{receivingUnit}}': buildDeliveryUnit(plan),
'{{planNo}}': plan.planNo || '',
'{{receiveContact}}': '',
'{{driverInfo}}': '',
'{{planDateChinese}}': formatPlanDateChinese(plan.planDate),
'{{productTable}}': buildProductRows(plan.products, ctx),
'{{productTable}}': buildProductRows(plan.products, plan.planNo || '', ctx),
'{{remark}}': plan.remark || '',
'{{companyAddress}}': INBOUND_SLIP_DEFAULTS.companyAddress,
'{{companyPhone}}': INBOUND_SLIP_DEFAULTS.companyPhone,
@@ -1310,7 +1381,13 @@ export function replaceInboundSlipPlaceholders(
normalizeInboundSlipProductTableFooter(
normalizeInboundSlipProductColWidths(
normalizeInboundSlipProductGridCell(
normalizeInboundSlipGapBeforeProduct(normalizeInboundSlipSideLabelsPlaceholder(htmlContent))
normalizeInboundSlipProductPlanNoColumn(
normalizeInboundSlipInfoLayout(
normalizeInboundSlipGapBeforeProduct(
normalizeInboundSlipSideLabelsPlaceholder(htmlContent)
)
)
)
)
)
)
@@ -1330,9 +1407,10 @@ export function replaceInboundSlipPlaceholders(
export function restoreInboundSlipPlaceholders(template: string, plan: Plan): string {
let result = template
const deliveryUnit = buildDeliveryUnit(plan)
const dynamicReplacements: Record<string, string> = {
'{{companyName}}': INBOUND_SLIP_DEFAULTS.companyName,
'{{receivingUnit}}': INBOUND_SLIP_DEFAULTS.receivingUnit,
'{{receivingUnit}}': deliveryUnit,
'{{planNo}}': plan.planNo || '',
'{{planDateChinese}}': formatPlanDateChinese(plan.planDate),
'{{remark}}': plan.remark || '',
@@ -1347,9 +1425,14 @@ export function restoreInboundSlipPlaceholders(template: string, plan: Plan): st
result = result.replace(new RegExp(escaped, 'g'), placeholder)
}
// 旧模板写死的本公司名 → 送货单位占位符
if (deliveryUnit !== LEGACY_RECEIVING_UNIT) {
result = result.replace(new RegExp(LEGACY_RECEIVING_UNIT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '{{receivingUnit}}')
}
// 产品明细行还原为占位符(表头「实收数量」与「备注」行之间)
result = result.replace(
/(<th[^>]*>实收数量<\/th>\s*<\/tr>\s*)([\s\S]*?)(\s*<tr>\s*<td colspan="7"[^>]*>\s*备注:)/,
/(<th[^>]*>实收数量<\/th>\s*<\/tr>\s*)([\s\S]*?)(\s*<tr>\s*<td colspan="[78]"[^>]*>\s*备注:)/,
'$1{{productTable}}$3'
)
+39
View File
@@ -0,0 +1,39 @@
/** 运输单据打印弹窗:红章显隐(本次弹窗有效) */
export const SLIP_SEAL_IMG_RE = /<img[^>]*alt=["']印章["'][^>]*>/gi
export function stripSlipSealImages(html: string): string {
return html.replace(SLIP_SEAL_IMG_RE, '')
}
/**
* 将公司章插回单据 HTML。
* 优先落在「印章图片」注释后;否则插入指定 seal-block 容器开头。
*/
export function injectSlipSealImages(
html: string,
sealHtml: string,
sealBlockClass: string
): string {
let result = stripSlipSealImages(html)
if (!sealHtml) return result
if (/<!--\s*印章图片[^>]*-->/.test(result)) {
let sealCommentIndex = 0
return result.replace(/<!--\s*印章图片[^>]*-->/g, (match) => {
sealCommentIndex += 1
if (sealCommentIndex === 1) return `${match}\n ${sealHtml}`
return match
})
}
if (sealBlockClass && result.includes(sealBlockClass)) {
const escaped = sealBlockClass.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return result.replace(
new RegExp(`(<div class="${escaped}"[^>]*>)`),
`$1\n <!-- 印章图片 -->\n ${sealHtml}\n `
)
}
return result
}
+20 -10
View File
@@ -87,9 +87,9 @@ const INFO_VALUE_INLINE_LEFT = `flex:1;min-width:0;box-sizing:border-box;border-
const INFO_VALUE_INLINE_CENTER = `flex:1;min-width:0;box-sizing:border-box;border-bottom:${INFO_UNDERLINE} !important;padding:0 2px 2px;text-align:center;min-height:1.3em;`
const INFO_CELL_LEFT = `${NO_BORDER}text-align:left;`
const INFO_CELL_RIGHT = `${NO_BORDER}text-align:left;`
const INFO_GAP_BEFORE_PRODUCT = 24
const INFO_GAP_BEFORE_PRODUCT = 12
const GAP_BEFORE_PRODUCT_CELL = `border:none !important;padding:0 !important;line-height:0 !important;font-size:0 !important;background:transparent !important;height:${INFO_GAP_BEFORE_PRODUCT}px !important;min-height:${INFO_GAP_BEFORE_PRODUCT}px !important;`
const INFO_GAP_BEFORE_SIGN = 24
const INFO_GAP_BEFORE_SIGN = 12
const GAP_BEFORE_SIGN_CELL = `border:none !important;padding:0 !important;line-height:0 !important;font-size:0 !important;background:transparent !important;height:${INFO_GAP_BEFORE_SIGN}px !important;min-height:${INFO_GAP_BEFORE_SIGN}px !important;`
const SIDE_LABELS_COL_WIDTH = 36
@@ -1434,10 +1434,15 @@ function normalizeSubpackSlipRemoveTermsRow(html: string): string {
/** 刷新已有间距行的 inline 样式(TinyMCE 保存后可能丢失) */
function refreshSubpackSlipGapBeforeProduct(html: string): string {
return html.replace(
/<tr class="subpack-slip-gap-before-product">[\s\S]*?<\/tr>/,
`<tr class="subpack-slip-gap-before-product">\n <td colspan="7" class="subpack-slip-gap-before-product-cell" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</td>\n </tr>`
)
return html
.replace(
/<tr class="subpack-slip-gap-before-product">[\s\S]*?<\/tr>/,
`<tr class="subpack-slip-gap-before-product">\n <td colspan="7" class="subpack-slip-gap-before-product-cell" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</td>\n </tr>`
)
.replace(
/<div class="subpack-slip-section-gap subpack-slip-gap-before-product"[^>]*>[\s\S]*?<\/div>/,
`<div class="subpack-slip-section-gap subpack-slip-gap-before-product" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</div>`
)
}
/** 在收货联系方式行与物料表之间插入间距行(兼容已保存模板) */
@@ -1472,10 +1477,15 @@ function normalizeSubpackSlipGapBeforeProduct(html: string): string {
/** 刷新签字区上方间距行 inline 样式 */
function refreshSubpackSlipGapBeforeSign(html: string): string {
return html.replace(
/<tr class="subpack-slip-gap-before-sign">[\s\S]*?<\/tr>/,
`<tr class="subpack-slip-gap-before-sign">\n <td colspan="7" class="subpack-slip-gap-before-sign-cell" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</td>\n </tr>`
)
return html
.replace(
/<tr class="subpack-slip-gap-before-sign">[\s\S]*?<\/tr>/,
`<tr class="subpack-slip-gap-before-sign">\n <td colspan="7" class="subpack-slip-gap-before-sign-cell" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</td>\n </tr>`
)
.replace(
/<div class="subpack-slip-section-gap subpack-slip-gap-before-sign"[^>]*>[\s\S]*?<\/div>/,
`<div class="subpack-slip-section-gap subpack-slip-gap-before-sign" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</div>`
)
}
/** 在物料表与签字区之间插入间距行(兼容已保存模板) */
@@ -66,8 +66,8 @@
<el-icon><EditPen /></el-icon>编辑
</el-button>
<el-button
v-if="selectedRows.length === 1 && selectedRows[0]?.level === 1"
class="btn-ghost"
:disabled="selectedRows.length !== 1 || selectedRows[0]?.level !== 1"
:loading="loadingMap.rowAddChild"
@click="handleToolbarAddChild"
>
@@ -773,18 +773,22 @@
</el-dialog>
<!-- 生成合同对话框 -->
<el-dialog
:title="isPreviewMode ? '预览合同' : '编辑合同'"
v-model="generateContractDialog.visible"
width="1200px"
align-center
append-to-body
class="dialog-form-layout"
class="dialog-form-layout generate-print-dialog"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
>
<p v-if="!isPreviewMode" class="generate-contract-edit-hint">
编辑模式:鼠标移入表格后,列/行边界会出现绿色调整条,按住拖动即可调列宽或行高;工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。
</p>
<template #header>
<div class="generate-print-dialog-header">
<span class="generate-print-dialog-title">{{ isPreviewMode ? '预览合同' : '编辑合同' }}</span>
<p v-if="!isPreviewMode" class="generate-print-dialog-edit-hint">
编辑模式:鼠标移入表格后,列/行边界会出现绿色调整条,按住拖动即可调列宽或行高;工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。
</p>
</div>
</template>
<TinyMceEditor
v-model="contractContent"
:height="600"
@@ -803,6 +807,12 @@
<el-button class="btn-ghost" @click="handleSaveTemplate">
<el-icon><DocumentChecked /></el-icon>保存模板
</el-button>
<el-button class="btn-ghost" @click="toggleRedSeal">
<el-icon>
<component :is="showRedSeal ? Hide : Stamp" />
</el-icon>
{{ showRedSeal ? '隐藏红章' : '显示红章' }}
</el-button>
<el-button class="btn-ghost" @click="handlePrintContract">
<el-icon><Printer /></el-icon>打印
</el-button>
@@ -842,10 +852,12 @@ import {
DocumentChecked,
Download,
Edit,
Hide,
InfoFilled,
Plus,
Printer,
Promotion,
Stamp,
Upload,
VideoPlay,
View,
@@ -939,6 +951,8 @@ const generateContractDialog = reactive({
});
const contractContent = ref('');
const isPreviewMode = ref(false);
/** 打印弹窗内红章显隐(仅本次弹窗有效,打开时默认显示) */
const showRedSeal = ref(true);
const pdfGenerating = ref(false);
const currentContractId = ref<string | number>();
@@ -1309,8 +1323,9 @@ const handleDetailPrint = async () => {
console.error('替换占位符失败,使用原始内容:', replaceError);
}
// 先设置内容,再打开对话框
// 先设置内容,再打开对话框(每次打开默认显示红章)
contractContent.value = htmlContent;
showRedSeal.value = true;
// 关闭加载提示
$modal.closeLoading();
@@ -2551,6 +2566,80 @@ const replaceDynamicFields = (template: string, contractData: ContractVO): strin
return result;
};
const SEAL_IMG_RE = /<img[^>]*alt=["']印章["'][^>]*>/gi;
const getSupplierCodeFromContractNo = (contractNo?: string): string => {
if (!contractNo) return 'SX';
const match = contractNo.match(/^([A-Z]+)\/X-JT/);
return match?.[1] || 'SX';
};
const resolveSealImageByCompanyName = (companyName: string | undefined, supplierCode: string): string => {
if (!companyName) return '';
if (companyName.includes('云栋') || companyName.includes('安徽云栋化工有限公司')) {
return '/src/assets/images/yd.png';
}
if (companyName.includes('山峡') || companyName.includes('安庆市山峡化工有限公司')) {
return supplierCode === 'SX' ? '/src/assets/images/yz.png' : '/src/assets/images/yd.png';
}
return '';
};
const buildSealHtml = (companyName: string | undefined, supplierCode: string): string => {
const sealSrc = resolveSealImageByCompanyName(companyName, supplierCode);
if (!sealSrc) return '';
return `<img src="${sealSrc}" alt="印章" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 140px; height: 140px; opacity: 0.85; z-index: 10;" />`;
};
const stripSealImages = (html: string): string => html.replace(SEAL_IMG_RE, '');
const injectSealImages = (html: string, contractData: ContractVO): string => {
let result = stripSealImages(html);
const supplierCode = getSupplierCodeFromContractNo(contractData.contractNo);
const partyASeal = buildSealHtml(contractData.partyA, supplierCode);
const partyBSeal = buildSealHtml(contractData.partyB, supplierCode);
if (/<!--\s*印章图片[^>]*-->/.test(result)) {
let sealCommentIndex = 0;
return result.replace(/<!--\s*印章图片[^>]*-->/g, (match) => {
sealCommentIndex += 1;
if (sealCommentIndex === 1) return `${match}\n ${partyASeal}`;
if (sealCommentIndex === 2) return `${match}\n ${partyBSeal}`;
return match;
});
}
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (partyASeal && contractData.partyA) {
const escaped = escapeRegExp(contractData.partyA);
result = result.replace(
new RegExp(`(<div[^>]*>单位名称(章):\\s*${escaped}</div>)`),
`${partyASeal}\n $1`
);
}
if (partyBSeal && contractData.partyB) {
const escaped = escapeRegExp(contractData.partyB);
result = result.replace(
new RegExp(`(<div[^>]*>单位名称(章):\\s*${escaped}</div>)`),
`${partyBSeal}\n $1`
);
}
return result;
};
/** 切换红章显示/隐藏(同步影响编辑器、打印、PDF) */
const toggleRedSeal = () => {
if (showRedSeal.value) {
contractContent.value = stripSealImages(contractContent.value);
showRedSeal.value = false;
return;
}
if (detailContract.value) {
contractContent.value = injectSealImages(contractContent.value, detailContract.value);
}
showRedSeal.value = true;
};
/** 切换预览/编辑模式 */
const togglePreviewMode = () => {
isPreviewMode.value = !isPreviewMode.value;
@@ -2970,15 +3059,33 @@ onMounted(async () => {
line-height: 1.5;
}
.generate-contract-edit-hint {
margin: 0 0 12px;
padding: 8px 12px;
.generate-print-dialog-header {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
min-height: 24px;
padding-right: 28px;
box-sizing: border-box;
}
.generate-print-dialog-title {
flex-shrink: 0;
font-size: 18px;
font-weight: 600;
line-height: 24px;
color: var(--el-text-color-primary);
}
.generate-print-dialog-edit-hint {
flex: 1;
min-width: 0;
margin: 0;
text-align: center;
font-size: 12px;
line-height: 1.5;
color: #047857;
background: #ecfdf5;
border: 1px solid #a7f3d0;
border-radius: 8px;
font-weight: 400;
line-height: 1.4;
color: #ef4444;
}
.parties-section {
@@ -470,7 +470,12 @@
</template>
<!-- 产品选择器 -->
<ProductSelector :visible="productSelectorVisible" @update:visible="productSelectorVisible = $event" @confirm="handleProductSelected" />
<ProductSelector
:visible="productSelectorVisible"
:supplier-id="form.supplierId"
@update:visible="productSelectorVisible = $event"
@confirm="handleProductSelected"
/>
<!-- 客户选择器 -->
<CustomerSelector :visible="customerSelectorVisible" @update:visible="customerSelectorVisible = $event" @confirm="handleCustomerConfirm" />
@@ -540,6 +545,7 @@ import ProductSelector from './ProductSelector.vue'
import CustomerSelector from '@/components/CustomerSelector.vue'
import ContactSelector from '@/components/ContactSelector.vue'
import QuoteSelector from '@/components/QuoteSelector.vue'
import { removeProductsNotAllowedByHazard } from '@/utils/crm-supplier-hazard'
import ApprovalDialog from './ApprovalDialog.vue'
import { getContactByCustomerId } from '@/api/crm/customer'
import type { CustomerVO, ContactVO } from '@/api/crm/customer/types'
@@ -882,6 +888,8 @@ const loadContractDetail = async (id: number | string) => {
form.value.products = contractData.products.map((product: any) => ({
...product,
productModel: product.model || '',
isToxic: Number(product.isToxic || 0),
isExplosive: Number(product.isExplosive || 0),
priceTiers: (product.priceTiers || []).map((t: any) => {
let ids: string[] = []
if (Array.isArray(t.letterOssIds) && t.letterOssIds.length) {
@@ -1241,6 +1249,20 @@ const handleSupplierChange = async (supplierId: number | undefined) => {
updateContractNoBySupplier(supplier.supplierCode)
rebuildContractName()
const allowToxic = Number((supplier as any).allowToxic || 0)
const allowExplosive = Number((supplier as any).allowExplosive || 0)
const { kept, removedNames } = removeProductsNotAllowedByHazard(
form.value.products || [],
allowToxic,
allowExplosive
)
form.value.products = kept
if (removedNames.length) {
$modal.msgWarning(`当前主体不支持以下产品,已自动移除:${removedNames.join('、')}`)
form.value.products.forEach((p) => calculateProductAmount(p))
calculateTotalAmount()
}
}
}
@@ -1457,6 +1479,10 @@ const handleSignDateChange = (val: string | null) => {
/** 添加产品 */
const handleAddProduct = () => {
if (!form.value.supplierId) {
$modal.msgWarning('请先选择交易主体')
return
}
productSelectorVisible.value = true
}
@@ -1480,6 +1506,8 @@ const handleProductSelected = (products: any[]) => {
deliveryMethod: '',
description: product.description || '',
remark: '',
isToxic: Number(product.isToxic || 0),
isExplosive: Number(product.isExplosive || 0),
priceTiers: isFw ? [] : undefined
})
})
@@ -155,7 +155,8 @@ interface ProductCategoryVO {
// 定义组件属性
interface Props {
visible: boolean;
visible: boolean
supplierId?: string | number
}
const props = defineProps<Props>();
@@ -216,7 +217,8 @@ const getList = async () => {
pageSize: queryParams.value.pageSize,
keyword: queryParams.value.productName,
categoryId: queryParams.value.categoryId,
status: queryParams.value.status
status: queryParams.value.status,
supplierId: props.supplierId != null && props.supplierId !== '' ? String(props.supplierId) : undefined
});
const data = (res as any)?.data || res || {};
productList.value = data.list || data.rows || [];
+120 -14
View File
@@ -978,21 +978,32 @@
<!-- 生成合同对话框 -->
<el-dialog
:title="isPreviewMode ? '预览合同' : '编辑合同'"
v-model="generateContractDialog.visible"
width="1200px"
append-to-body
class="generate-print-dialog"
:close-on-click-modal="false"
>
<p v-if="!isPreviewMode" class="generate-contract-edit-hint">
编辑模式:鼠标移入表格后,列/行边界会出现绿色调整条,按住拖动即可调列宽或行高;工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。
</p>
<template #header>
<div class="generate-print-dialog-header">
<span class="generate-print-dialog-title">{{ isPreviewMode ? '预览合同' : '编辑合同' }}</span>
<p v-if="!isPreviewMode" class="generate-print-dialog-edit-hint">
编辑模式:鼠标移入表格后,列/行边界会出现绿色调整条,按住拖动即可调列宽或行高;工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。
</p>
</div>
</template>
<TinyMceEditor v-model="contractContent" :height="600" :disabled="isPreviewMode" :toolbar="!isPreviewMode" :contractNo="contract?.contractNo" />
<template #footer>
<div class="dialog-footer">
<el-button @click="generateContractDialog.visible = false">关 闭</el-button>
<el-button v-if="hasContractPerm('edit')" type="info" icon="View" @click="togglePreviewMode">{{ isPreviewMode ? '编辑模式' : '预览模式' }}</el-button>
<el-button v-if="hasContractPerm('edit')" type="warning" icon="DocumentChecked" @click="handleSaveTemplate">保存模板</el-button>
<el-button v-if="hasContractPerm('print')" @click="toggleRedSeal">
<el-icon>
<component :is="showRedSeal ? Hide : Stamp" />
</el-icon>
{{ showRedSeal ? '隐藏红章' : '显示红章' }}
</el-button>
<el-button v-if="hasContractPerm('print')" type="success" icon="Printer" @click="handlePrintContract">打印</el-button>
<el-button v-if="hasContractPerm('print')" type="primary" icon="Download" @click="handleGeneratePdfFile" :loading="pdfGenerating">生成PDF</el-button>
</div>
@@ -1012,7 +1023,7 @@ import { $modal } from '@/utils/modal';
import { useDictStore } from '@/store/modules/dict';
import DictTag from '@/components/DictTag/index.vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { CircleCheck, CircleClose, View, ChatLineSquare, Clock, InfoFilled, VideoPlay, Document, Paperclip, Download, Back, Edit, Delete, Promotion, Printer } from '@element-plus/icons-vue';
import { CircleCheck, CircleClose, View, ChatLineSquare, Clock, InfoFilled, VideoPlay, Document, Paperclip, Download, Back, Edit, Delete, Promotion, Printer, Hide, Stamp } from '@element-plus/icons-vue';
import ApprovalDialog from './components/ApprovalDialog.vue';
import ExecuteDialog from './components/ExecuteDialog.vue';
import InvalidDialog from './components/InvalidDialog.vue';
@@ -1085,6 +1096,8 @@ const generateContractDialog = reactive({
});
const contractContent = ref('');
const isPreviewMode = ref(false);
/** 打印弹窗内红章显隐(仅本次弹窗有效,打开时默认显示) */
const showRedSeal = ref(true);
const pdfGenerating = ref(false);
/** 加载合同详情 */
@@ -2071,8 +2084,9 @@ const handlePrint = async () => {
htmlContent = generateContractTemplate(contract.value);
}
// 先设置内容,再打开对话框
// 先设置内容,再打开对话框(每次打开默认显示红章)
contractContent.value = htmlContent;
showRedSeal.value = true;
// 关闭加载提示
$modal.closeLoading();
@@ -2529,6 +2543,80 @@ const handleDownloadHtml = () => {
};
/** 切换预览/编辑模式 */
const SEAL_IMG_RE = /<img[^>]*alt=["']印章["'][^>]*>/gi;
const getSupplierCodeFromContractNo = (contractNo?: string): string => {
if (!contractNo) return 'SX';
const match = contractNo.match(/^([A-Z]+)\/X-JT/);
return match?.[1] || 'SX';
};
const resolveSealImageByCompanyName = (companyName: string | undefined, supplierCode: string): string => {
if (!companyName) return '';
if (companyName.includes('云栋') || companyName.includes('安徽云栋化工有限公司')) {
return '/src/assets/images/yd.png';
}
if (companyName.includes('山峡') || companyName.includes('安庆市山峡化工有限公司')) {
return supplierCode === 'SX' ? '/src/assets/images/yz.png' : '/src/assets/images/yd.png';
}
return '';
};
const buildSealHtml = (companyName: string | undefined, supplierCode: string): string => {
const sealSrc = resolveSealImageByCompanyName(companyName, supplierCode);
if (!sealSrc) return '';
return `<img src="${sealSrc}" alt="印章" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 140px; height: 140px; opacity: 0.85; z-index: 10;" />`;
};
const stripSealImages = (html: string): string => html.replace(SEAL_IMG_RE, '');
const injectSealImages = (html: string, contractData: ContractVO): string => {
let result = stripSealImages(html);
const supplierCode = getSupplierCodeFromContractNo(contractData.contractNo);
const partyASeal = buildSealHtml(contractData.partyA, supplierCode);
const partyBSeal = buildSealHtml(contractData.partyB, supplierCode);
if (/<!--\s*印章图片[^>]*-->/.test(result)) {
let sealCommentIndex = 0;
return result.replace(/<!--\s*印章图片[^>]*-->/g, (match) => {
sealCommentIndex += 1;
if (sealCommentIndex === 1) return `${match}\n ${partyASeal}`;
if (sealCommentIndex === 2) return `${match}\n ${partyBSeal}`;
return match;
});
}
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (partyASeal && contractData.partyA) {
const escaped = escapeRegExp(contractData.partyA);
result = result.replace(
new RegExp(`(<div[^>]*>单位名称(章):\\s*${escaped}</div>)`),
`${partyASeal}\n $1`
);
}
if (partyBSeal && contractData.partyB) {
const escaped = escapeRegExp(contractData.partyB);
result = result.replace(
new RegExp(`(<div[^>]*>单位名称(章):\\s*${escaped}</div>)`),
`${partyBSeal}\n $1`
);
}
return result;
};
/** 切换红章显示/隐藏(同步影响编辑器、打印、PDF) */
const toggleRedSeal = () => {
if (showRedSeal.value) {
contractContent.value = stripSealImages(contractContent.value);
showRedSeal.value = false;
return;
}
if (contract.value) {
contractContent.value = injectSealImages(contractContent.value, contract.value);
}
showRedSeal.value = true;
};
const togglePreviewMode = () => {
isPreviewMode.value = !isPreviewMode.value;
};
@@ -3035,14 +3123,32 @@ const handleGeneratePdfFile = async () => {
font-size: 12px;
}
.generate-contract-edit-hint {
margin: 0 0 12px;
padding: 8px 12px;
.generate-print-dialog-header {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
min-height: 24px;
padding-right: 28px;
box-sizing: border-box;
}
.generate-print-dialog-title {
flex-shrink: 0;
font-size: 18px;
font-weight: 600;
line-height: 24px;
color: var(--el-text-color-primary);
}
.generate-print-dialog-edit-hint {
flex: 1;
min-width: 0;
margin: 0;
text-align: center;
font-size: 12px;
line-height: 1.5;
color: #047857;
background: #ecfdf5;
border: 1px solid #a7f3d0;
border-radius: 8px;
font-weight: 400;
line-height: 1.4;
color: #ef4444;
}
</style>
+29 -1
View File
@@ -536,7 +536,12 @@
</section>
<!-- 产品选择器 -->
<ProductSelector :visible="productSelectorVisible" @update:visible="productSelectorVisible = $event" @confirm="handleProductSelected" />
<ProductSelector
:visible="productSelectorVisible"
:supplier-id="form.supplierId"
@update:visible="productSelectorVisible = $event"
@confirm="handleProductSelected"
/>
<!-- 客户选择器 -->
<CustomerSelector :visible="customerSelectorVisible" @update:visible="customerSelectorVisible = $event" @confirm="handleCustomerConfirm" />
@@ -601,6 +606,7 @@ import type { CustomerVO, ContactVO } from '@/api/crm/customer/types';
import { getDefaultInvoiceInfoByCustomerId } from '@/api/crm/invoiceInfo';
import { listAllSupplierInfo, type SupplierInfoVO } from '@/api/crm/supplierInfo';
import { useContractPerm } from '@/hooks/business/useContractPerm';
import { removeProductsNotAllowedByHazard } from '@/utils/crm-supplier-hazard';
interface UserVO {
userId: string | number
@@ -847,6 +853,8 @@ const loadContractDetail = async (id: number | string) => {
form.value.products = contractData.products.map((product: any) => ({
...product,
productModel: product.model || '',
isToxic: Number(product.isToxic || 0),
isExplosive: Number(product.isExplosive || 0),
priceTiers: (product.priceTiers || []).map((t: any) => {
let ids: string[] = [];
if (Array.isArray(t.letterOssIds) && t.letterOssIds.length) {
@@ -1270,6 +1278,20 @@ const handleSupplierChange = async (supplierId: number | undefined) => {
// 更新合同名称中的供方简称
updateContractNameBySupplier(supplier.shortName);
const allowToxic = Number((supplier as any).allowToxic || 0);
const allowExplosive = Number((supplier as any).allowExplosive || 0);
const { kept, removedNames } = removeProductsNotAllowedByHazard(
form.value.products || [],
allowToxic,
allowExplosive
);
form.value.products = kept;
if (removedNames.length) {
$modal.msgWarning(`当前主体不支持以下产品,已自动移除:${removedNames.join('、')}`);
form.value.products.forEach((p) => calculateProductAmount(p));
calculateTotalAmount();
}
}
};
@@ -1668,6 +1690,10 @@ const handleSignDateChange = (val: string | null) => {
/** 添加产品 */
const handleAddProduct = () => {
if (!form.value.supplierId) {
$modal.msgWarning('请先选择交易主体');
return;
}
productSelectorVisible.value = true;
};
@@ -1692,6 +1718,8 @@ const handleProductSelected = (products: any[]) => {
deliveryMethod: '',
description: product.description || '',
remark: '',
isToxic: Number(product.isToxic || 0),
isExplosive: Number(product.isExplosive || 0),
priceTiers: isFw ? [] : undefined
});
});
@@ -138,7 +138,8 @@ import { fetchProductList, fetchCategoryTree } from '@/api/crm/product';
// 定义组件属性
interface Props {
visible: boolean;
visible: boolean
supplierId?: string | number
}
const props = defineProps<Props>();
@@ -199,7 +200,8 @@ const getList = async () => {
pageSize: queryParams.value.pageSize,
keyword: queryParams.value.productName,
categoryId: queryParams.value.categoryId,
status: queryParams.value.status
status: queryParams.value.status,
supplierId: props.supplierId != null && props.supplierId !== '' ? String(props.supplierId) : undefined
});
const data = (res as any)?.data || res || {};
productList.value = data.list || data.rows || [];
@@ -367,7 +367,12 @@
</el-form>
<!-- 产品选择器对话框 -->
<ProductSelector :visible="productSelectorVisible" @update:visible="productSelectorVisible = $event" @confirm="handleProductConfirm" />
<ProductSelector
:visible="productSelectorVisible"
:supplier-id="form.supplierId"
@update:visible="productSelectorVisible = $event"
@confirm="handleProductConfirm"
/>
<!-- 客户选择器 -->
<CustomerSelector :visible="customerSelectorVisible" @update:visible="customerSelectorVisible = $event" @confirm="handleCustomerConfirm" />
@@ -471,6 +476,7 @@ import OpportunitySelector from '@/components/OpportunitySelector.vue';
import { fetchUserOptions } from '@/api/admin';
import { fetchContactsByCustomerId } from '@/api/crm/contact';
import { listAllSupplierInfo, type SupplierInfoVO } from '@/api/crm/supplierInfo';
import { removeProductsNotAllowedByHazard } from '@/utils/crm-supplier-hazard'
const props = withDefaults(
defineProps<{
@@ -789,7 +795,9 @@ const getQuoteInfo = async (id: string | number) => {
taxRate: parseFloat(item.taxRate || '13'), // 税率(%)
packageSpec: item.packageSpec || '', // 包装规格
deliveryMethod: item.deliveryMethod || '', // 送货方式
description: item.description
description: item.description,
isToxic: Number(item.isToxic || 0),
isExplosive: Number(item.isExplosive || 0)
}));
}
@@ -868,7 +876,9 @@ const getQuoteInfoForCopy = async (copyId: string | number) => {
taxRate: parseFloat(item.taxRate || '13'), // 税率(%)
packageSpec: item.packageSpec || '', // 包装规格
deliveryMethod: item.deliveryMethod || '', // 送货方式
description: item.description
description: item.description,
isToxic: Number(item.isToxic || 0),
isExplosive: Number(item.isExplosive || 0)
}));
}
@@ -1127,11 +1137,26 @@ const handleSupplierChange = (supplierId: number | undefined) => {
form.value.supplierId = supplier.id;
form.value.supplierName = supplier.supplierName || '';
updateQuoteTitleAndNo();
const allowToxic = Number((supplier as any).allowToxic || 0)
const allowExplosive = Number((supplier as any).allowExplosive || 0)
const { kept, removedNames } = removeProductsNotAllowedByHazard(
form.value.products || [],
allowToxic,
allowExplosive
)
form.value.products = kept
if (removedNames.length) {
$modal.msgWarning(`当前主体不支持以下产品,已自动移除:${removedNames.join('、')}`)
}
}
};
/** 添加产品 */
const handleAddProduct = () => {
if (!form.value.supplierId) {
$modal.msgWarning('请先选择报价主体')
return
}
productSelectorVisible.value = true;
};
@@ -1152,7 +1177,9 @@ const handleProductConfirm = (products: any[]) => {
taxAmount: 0,
subtotal: 0,
packageSpec: '',
deliveryMethod: ''
deliveryMethod: '',
isToxic: Number(product.isToxic || 0),
isExplosive: Number(product.isExplosive || 0)
};
form.value.products?.push(productForm);
});
+86 -4
View File
@@ -535,14 +535,21 @@
<!-- 生成报价单对话框 -->
<el-dialog
v-model="generateQuoteDialog.visible"
:title="isPreviewMode ? '预览报价单' : '编辑报价单'"
width="1200px"
align-center
append-to-body
class="dialog-form-layout"
class="dialog-form-layout generate-print-dialog"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
>
<template #header>
<div class="generate-print-dialog-header">
<span class="generate-print-dialog-title">{{ isPreviewMode ? '预览报价单' : '编辑报价单' }}</span>
<p v-if="!isPreviewMode" class="generate-print-dialog-edit-hint">
编辑模式:鼠标移入表格后,列/行边界会出现绿色调整条,按住拖动即可调列宽或行高;工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。
</p>
</div>
</template>
<TinyMceEditor v-model="quoteContent" :height="600" :disabled="isPreviewMode" :toolbar="!isPreviewMode" :supplier-code="currentSupplierCode" />
<template #footer>
<div class="generate-quote-dialog-actions">
@@ -552,6 +559,12 @@
<el-button class="btn-ghost" @click="handleSaveTemplate">
<el-icon><DocumentChecked /></el-icon>保存模板
</el-button>
<el-button class="btn-ghost" @click="toggleRedSeal">
<el-icon>
<component :is="showRedSeal ? Hide : Stamp" />
</el-icon>
{{ showRedSeal ? '隐藏红章' : '显示红章' }}
</el-button>
<el-button class="btn-ghost" @click="handlePrint">
<el-icon><Printer /></el-icon>打印
</el-button>
@@ -723,11 +736,13 @@ import {
DocumentCopy,
Download,
Edit,
Hide,
Plus,
Printer,
RefreshLeft,
Search,
Select
Select,
Stamp
} from '@element-plus/icons-vue'
import { useDictStore } from '@/store/modules/dict'
const dictStore = useDictStore()
@@ -1084,6 +1099,8 @@ const generateQuoteDialog = reactive({
});
const quoteContent = ref('');
const isPreviewMode = ref(false);
/** 打印弹窗内红章显隐(仅本次弹窗有效,打开时默认显示) */
const showRedSeal = ref(true);
const pdfGenerating = ref(false);
const currentQuoteId = ref<string | number>(); // 当前报价单ID,用于保存模板
const currentSupplierCode = ref<string>('SX'); // 当前供应商编码,用于控制背景图片和Logo显示
@@ -2085,8 +2102,9 @@ const handleGenerateQuote = async (row?: QuoteVO) => {
console.log('HTML内容预览:', htmlContent.substring(0, 200));
// 先设置内容,再打开对话框
// 先设置内容,再打开对话框(每次打开默认显示红章)
quoteContent.value = htmlContent;
showRedSeal.value = true;
console.log('quoteContent已设置');
// 关闭加载提示
@@ -2102,6 +2120,41 @@ const handleGenerateQuote = async (row?: QuoteVO) => {
}
};
const SEAL_IMG_RE = /<img[^>]*alt=["']印章["'][^>]*>/gi;
const QUOTE_SEAL_IMG_STYLE =
'position: absolute; left: 50%; top: calc(50% - 50px); transform: translate(-50%, -50%); width: 120px; max-width: 120px; max-height: 120px; height: auto; object-fit: contain; z-index: 10; pointer-events: none; opacity: 0.9;';
const buildQuoteSealHtml = (supplierCode: string): string => {
const sealImg = supplierCode === 'SX' ? yzImg : supplierCode === 'YD' ? ydImg : null;
if (!sealImg) return '';
return `<img src="${sealImg}" alt="印章" style="${QUOTE_SEAL_IMG_STYLE}" />`;
};
const stripSealImages = (html: string): string => html.replace(SEAL_IMG_RE, '');
const injectQuoteSealImages = (html: string, supplierCode: string): string => {
let result = stripSealImages(html);
const sealHtml = buildQuoteSealHtml(supplierCode);
if (!sealHtml) return result;
// 印章落在「送货方式」表头内;兼容 TinyMCE 改写后的空白/换行
if (/<th[^>]*>[\s\S]*?送货方式[\s\S]*?<\/th>/i.test(result)) {
result = result.replace(/(<th[^>]*>[\s\S]*?送货方式)([\s\S]*?)(<\/th>)/i, `$1$2${sealHtml}$3`);
}
return result;
};
/** 切换红章显示/隐藏(同步影响编辑器、打印、PDF) */
const toggleRedSeal = () => {
if (showRedSeal.value) {
quoteContent.value = stripSealImages(quoteContent.value);
showRedSeal.value = false;
return;
}
quoteContent.value = injectQuoteSealImages(quoteContent.value, currentSupplierCode.value);
showRedSeal.value = true;
};
/** 打印报价单 */
const handlePrint = () => {
// 根据供应商编码决定是否显示背景图片
@@ -2742,4 +2795,33 @@ onMounted(() => {
background-color: var(--mint-table-head, #f8fafc);
overflow-x: auto;
}
.generate-print-dialog-header {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
min-height: 24px;
padding-right: 28px;
box-sizing: border-box;
}
.generate-print-dialog-title {
flex-shrink: 0;
font-size: 18px;
font-weight: 600;
line-height: 24px;
color: var(--el-text-color-primary);
}
.generate-print-dialog-edit-hint {
flex: 1;
min-width: 0;
margin: 0;
text-align: center;
font-size: 12px;
font-weight: 400;
line-height: 1.4;
color: #ef4444;
}
</style>
@@ -0,0 +1,177 @@
<!-- 主体危化品能力配置 P01 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">主体危化品能力</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="主体名称">
<el-input
v-model="searchForm.supplierName"
placeholder="请输入主体名称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">配置报价主体/交易主体是否允许易制毒、易制爆产品。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-body list-wrap">
<el-table v-loading="loading" :data="tableData" class="list-table" stripe>
<el-table-column prop="supplierName" label="主体名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="supplierCode" label="编码" min-width="100" show-overflow-tooltip />
<el-table-column label="允许易制毒" min-width="120" align="center">
<template #default="{ row }">
<el-switch
:model-value="Number(row.allowToxic || 0) === 1"
:disabled="!canEdit || savingId === String(row.id)"
:loading="savingId === String(row.id)"
@change="(val: boolean) => handleToggle(row, 'allowToxic', val)"
/>
</template>
</el-table-column>
<el-table-column label="允许易制爆" min-width="120" align="center">
<template #default="{ row }">
<el-switch
:model-value="Number(row.allowExplosive || 0) === 1"
:disabled="!canEdit || savingId === String(row.id)"
:loading="savingId === String(row.id)"
@change="(val: boolean) => handleToggle(row, 'allowExplosive', val)"
/>
</template>
</el-table-column>
<el-table-column label="状态" min-width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === '0' ? 'success' : 'info'" size="small">
{{ row.status === '0' ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="updateTime" label="更新时间" min-width="160" show-overflow-tooltip />
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
background
@size-change="loadData"
@current-change="loadData"
/>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { RefreshLeft, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import {
listSupplierInfo,
updateSupplierInfo,
type SupplierInfoVO
} from '@/api/crm/supplierInfo'
import { useUserStore } from '@/store/modules/user'
defineOptions({ name: 'CrmSupplierHazard' })
const userStore = useUserStore()
const canEdit = computed(() => {
const info = userStore.getUserInfo as any
const buttons: string[] = info?.buttons || info?.permissions || []
if (!Array.isArray(buttons) || buttons.length === 0) return true
return (
buttons.includes('*:*:*') ||
buttons.includes('crm:supplierHazard:edit') ||
buttons.includes('crm:supplierHazard:list')
)
})
const loading = ref(false)
const savingId = ref('')
const tableData = ref<SupplierInfoVO[]>([])
const searchForm = reactive({
supplierName: ''
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
async function loadData() {
loading.value = true
try {
const res = await listSupplierInfo({
pageNum: pagination.page,
pageSize: pagination.pageSize,
supplierName: searchForm.supplierName || undefined,
status: '0'
})
tableData.value = res.rows || []
pagination.total = res.total || 0
} catch (e: any) {
ElMessage.error(e?.message || '加载失败')
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.supplierName = ''
handleSearch()
}
async function handleToggle(row: SupplierInfoVO, field: 'allowToxic' | 'allowExplosive', val: boolean) {
const next = val ? 1 : 0
const prevToxic = Number(row.allowToxic || 0)
const prevExplosive = Number(row.allowExplosive || 0)
const payload = {
id: String(row.id),
supplierName: row.supplierName,
allowToxic: field === 'allowToxic' ? next : prevToxic,
allowExplosive: field === 'allowExplosive' ? next : prevExplosive
}
savingId.value = String(row.id)
try {
await updateSupplierInfo(payload)
row[field] = next
ElMessage.success('已保存')
} catch (e: any) {
ElMessage.error(e?.message || '保存失败')
} finally {
savingId.value = ''
}
}
onMounted(() => {
loadData()
})
</script>
+11 -4
View File
@@ -124,17 +124,24 @@
编辑
</el-button>
<el-button
v-if="selectedRows.length === 1 && selectedRows[0]?.purchasePlanId"
class="btn-ghost"
:disabled="selectedRows[0]?.hasWeighingSlip"
:disabled="
selectedRows.length !== 1 ||
!selectedRows[0]?.purchasePlanId ||
!!selectedRows[0]?.hasWeighingSlip
"
@click="handleGenerateWeighing(selectedRows[0])"
>
<el-icon><Plus /></el-icon>
{{ selectedRows[0]?.hasWeighingSlip ? '已生成地磅单' : '生成地磅单' }}
{{
selectedRows.length === 1 && selectedRows[0]?.hasWeighingSlip
? '已生成地磅单'
: '生成地磅单'
}}
</el-button>
<el-button
v-if="selectedRows.length === 1 && selectedRows[0]?.purchasePlanId"
class="btn-ghost"
:disabled="selectedRows.length !== 1 || !selectedRows[0]?.purchasePlanId"
@click="handleCopyFillLink(selectedRows[0])"
>
<el-icon><Link /></el-icon>
@@ -97,13 +97,13 @@
@clear="handleQuickSearchClear"
/>
<el-button
v-if="selectedPlan && selectedPlan.followStatus !== 'cancelled'"
class="btn-main"
type="primary"
:disabled="selectedRows.length !== 1"
:disabled="!selectedPlan || selectedPlan.followStatus === 'cancelled'"
@click="handleToolbarArchive"
>
<el-icon><FolderChecked /></el-icon>{{ archiveButtonLabel(selectedPlan) }}
<el-icon><FolderChecked /></el-icon>
{{ selectedPlan ? archiveButtonLabel(selectedPlan) : '归档' }}
</el-button>
<ListToolbarEndActions
:loading="loading"
+39 -10
View File
@@ -84,28 +84,57 @@
@clear="handleQuickSearchClear"
/>
<el-button
v-if="selectedPlan && canManageCertReady(selectedPlan) && !isHazardCertReady(selectedPlan)"
class="btn-main"
type="primary"
:disabled="selectedRows.length !== 1"
:class="
selectedPlan &&
canManageCertReady(selectedPlan) &&
!isHazardCertReady(selectedPlan)
? 'btn-main'
: 'btn-ghost'
"
:type="
selectedPlan &&
canManageCertReady(selectedPlan) &&
!isHazardCertReady(selectedPlan)
? 'primary'
: undefined
"
:disabled="
!selectedPlan ||
!canManageCertReady(selectedPlan) ||
isHazardCertReady(selectedPlan)
"
:loading="certReadyLoading"
@click="handleToolbarMarkCertReady"
>
<el-icon><CircleCheck /></el-icon>证书齐备
</el-button>
<el-button
v-if="selectedPlan && canUploadCert(selectedPlan)"
:class="selectedPlan && canManageCertReady(selectedPlan) && !isHazardCertReady(selectedPlan) ? 'btn-ghost' : 'btn-main'"
:type="selectedPlan && canManageCertReady(selectedPlan) && !isHazardCertReady(selectedPlan) ? undefined : 'primary'"
:disabled="selectedRows.length !== 1"
:class="
selectedPlan &&
canManageCertReady(selectedPlan) &&
!isHazardCertReady(selectedPlan)
? 'btn-ghost'
: 'btn-main'
"
:type="
selectedPlan &&
canManageCertReady(selectedPlan) &&
!isHazardCertReady(selectedPlan)
? undefined
: 'primary'
"
:disabled="!selectedPlan || !canUploadCert(selectedPlan)"
@click="handleToolbarUploadCert"
>
<el-icon><Upload /></el-icon>上传证书
</el-button>
<el-button
v-if="selectedPlan && canManageCertReady(selectedPlan) && isHazardCertReady(selectedPlan)"
class="btn-ghost"
:disabled="selectedRows.length !== 1"
:disabled="
!selectedPlan ||
!canManageCertReady(selectedPlan) ||
!isHazardCertReady(selectedPlan)
"
:loading="certReadyLoading"
@click="handleToolbarUnmarkCertReady"
>
-1
View File
@@ -242,7 +242,6 @@ const formData = reactive({
const formRules = {
belongsCompany: [{ required: true, message: '请选择归属公司', trigger: 'change' }],
planType: [{ required: true, message: '请选择单据类型', trigger: 'change' }],
planDate: [{ required: true, message: '请选择计划日期', trigger: 'change' }]
}