This commit is contained in:
zlwu committed 2026-07-16 08:13:05 +08:00
1 parent 5c6ace1dd3
commit 8be55c08e9
32 files changed
+18546 -4422

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
import request from '@/utils/http'
/** 调价函分页列表 */
export function fetchPriceLetterList(data: Record<string, any>) {
return request.post({ url: '/web/crm/price-letters/list', data })
}
/** 调价函详情 */
export function fetchPriceLetterDetail(data: { id: string }) {
return request.post({ url: '/web/crm/price-letters/detail', data })
}
/** 新增调价函 */
export function fetchCreatePriceLetter(data: Record<string, any>) {
return request.post({ url: '/web/crm/price-letters/add', data })
}
/** 更新调价函 */
export function fetchUpdatePriceLetter(data: Record<string, any>) {
return request.post({ url: '/web/crm/price-letters/update', data })
}
/** 作废调价函 */
export function fetchVoidPriceLetter(data: { id: string; invalidReason: string; remark?: string }) {
return request.post({ url: '/web/crm/price-letters/void', data })
}
@@ -0,0 +1,66 @@
import { ref } from 'vue'
import { fetchGetMenuCodes } from '@/api/auth'
import { useUserStore } from '@/store/modules/user'
const PRICE_LETTER_PERM_MAP: Record<string, string> = {
list: 'crm:priceLetter:list',
add: 'crm:priceLetter:add',
edit: 'crm:priceLetter:edit',
query: 'crm:priceLetter:query',
print: 'crm:priceLetter:print',
void: 'crm:priceLetter:void'
}
let cachedCodes: Set<string> | null = null
let cachedAll = false
let loadingPromise: Promise<void> | null = null
function isSuperAdmin(userStore: ReturnType<typeof useUserStore>) {
const roles = userStore.info?.roles || []
return userStore.info?.role === 'super_admin' || roles.includes('super_admin')
}
export function usePriceLetterPerm() {
const userStore = useUserStore()
const ready = ref(cachedAll || !!cachedCodes)
async function loadPerms() {
if (cachedAll || cachedCodes) {
ready.value = true
return
}
if (isSuperAdmin(userStore)) {
cachedAll = true
ready.value = true
return
}
if (!loadingPromise) {
loadingPromise = fetchGetMenuCodes()
.then((res) => {
if (res?.all || res?.isSuperAdmin) {
cachedAll = true
cachedCodes = null
} else {
cachedCodes = new Set(res?.menuCodes || [])
}
})
.catch(() => {
cachedCodes = new Set()
})
.finally(() => {
ready.value = true
loadingPromise = null
})
}
await loadingPromise
}
function hasPriceLetterPerm(action: keyof typeof PRICE_LETTER_PERM_MAP | string) {
if (cachedAll || isSuperAdmin(userStore)) return true
const code = PRICE_LETTER_PERM_MAP[action] || String(action)
if (!cachedCodes) return true
return cachedCodes.has(code) || cachedCodes.has('crm:priceLetter:list')
}
return { ready, loadPerms, hasPriceLetterPerm }
}
+7
View File
@@ -381,6 +381,13 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
component: '/crm/contract/index',
meta: { title: '合同管理', keepAlive: true, menuCode: 'crm:contract:list' }
},
{
id: 'CRM3000000000000000000000000030',
name: 'CrmPriceLetter',
path: 'price-letter',
component: '/crm/price-letter/index',
meta: { title: '调价函管理', keepAlive: true, menuCode: 'crm:priceLetter:list' }
},
{
id: 'CRM3000000000000000000000000021',
name: 'CrmContractForm',
@@ -0,0 +1,162 @@
/** 公章:public/SXYZ.jpg(绝对路径便于富文本/PDF 渲染) */
const SEAL_SRC = '/SXYZ.jpg'
export type PriceLetterTemplateInput = {
customerName: string
productName: string
adjustDate: string
/** 现金价 */
price: number | string
/** 汇票价 */
billPrice: number | string
}
/**
* 将白底印章图抠成透明 PNG(html2canvas 不支持 mix-blend-mode,导出 PDF 前调用)。
*/
export async function sealImageToTransparentDataUrl(
src: string,
whiteThreshold = 245
): Promise<string> {
const absoluteSrc = /^https?:\/\//i.test(src)
? src
: new URL(src, window.location.origin).href
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const el = new Image()
el.crossOrigin = 'anonymous'
el.onload = () => resolve(el)
el.onerror = () => reject(new Error('印章图片加载失败'))
el.src = absoluteSrc
})
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth || img.width
canvas.height = img.naturalHeight || img.height
const ctx = canvas.getContext('2d')
if (!ctx) return absoluteSrc
ctx.drawImage(img, 0, 0)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const { data } = imageData
for (let i = 0; i < data.length; i += 4) {
const r = data[i]
const g = data[i + 1]
const b = data[i + 2]
// 近白像素透明;偏红印章保留
if (r >= whiteThreshold && g >= whiteThreshold && b >= whiteThreshold) {
data[i + 3] = 0
} else if (r > 180 && g < 140 && b < 140) {
// 红色印章略提透明度,露出底层文字
data[i + 3] = Math.min(data[i + 3], 220)
}
}
ctx.putImageData(imageData, 0, 0)
return canvas.toDataURL('image/png')
}
/** 导出/打印前:容器内 alt=印章 的图替换为透明底,并去掉 mix-blend-mode */
export async function prepareSealImagesForRasterExport(root: HTMLElement): Promise<void> {
const seals = Array.from(root.querySelectorAll('img[alt="印章"]')) as HTMLImageElement[]
await Promise.all(
seals.map(async (img) => {
try {
const dataUrl = await sealImageToTransparentDataUrl(img.getAttribute('src') || SEAL_SRC)
img.src = dataUrl
img.style.mixBlendMode = 'normal'
img.style.opacity = img.style.opacity || '0.92'
} catch {
/* 单张失败不影响整体导出 */
}
})
)
await Promise.all(
seals.map(
(img) =>
new Promise<void>((resolve) => {
if (img.complete) {
resolve()
return
}
img.onload = img.onerror = () => resolve()
})
)
)
}
/** YYYY-MM-DD → 2026年02月26日 */
export function formatChineseDate(dateStr: string, pad = true): string {
const text = String(dateStr || '').trim()
if (!text) return ''
const m = text.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/)
if (!m) return text
const y = m[1]
const mo = pad ? String(m[2]).padStart(2, '0') : String(Number(m[2]))
const d = pad ? String(m[3]).padStart(2, '0') : String(Number(m[3]))
return `${y}年${mo}月${d}日`
}
function escapeHtml(raw: string): string {
return String(raw || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function formatPriceText(price: number | string): string {
const n = Number(price)
if (Number.isNaN(n)) return String(price ?? '')
if (Number.isInteger(n)) return String(n)
return n
.toFixed(2)
.replace(/\.?0+$/, (s) => (s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s))
}
function hasValidPrice(price: unknown): boolean {
return price !== undefined && price !== null && price !== '' && !Number.isNaN(Number(price))
}
/**
* 发出调价函默认正文 HTML(含落款印章)。
* 用户可自由修改;保存后以库内 content 为准。
*/
export function buildSendPriceLetterHtml(input: PriceLetterTemplateInput): string {
const customer = escapeHtml((input.customerName || '').trim() || '(客户名称)')
const product = escapeHtml((input.productName || '').trim() || '(产品名称)')
const year = String(new Date().getFullYear())
const adjustCn = escapeHtml(formatChineseDate(input.adjustDate, false) || '(调价日期)')
const signCn = escapeHtml(formatChineseDate(input.adjustDate, true) || '(调价日期)')
const cashText = escapeHtml(formatPriceText(input.price))
const billText = escapeHtml(formatPriceText(input.billPrice))
const sealSrc = SEAL_SRC
return `<div class="price-letter-doc" style="font-family: SimSun, '宋体', serif; color: #1e293b; line-height: 1.85; font-size: 16px; padding: 8px 12px; max-width: 100%; overflow-x: hidden; box-sizing: border-box;">
<h1 style="text-align: center; font-size: 28px; font-weight: 700; margin: 8px 0 28px; letter-spacing: 0.2em;">调价函</h1>
<p style="margin: 0 0 16px; text-align: left;">致${customer}:</p>
<p style="margin: 0 0 16px; text-indent: 2em; text-align: justify;">首先感谢贵公司对我司${product}供应工作的大力支持,自${year}年度市场原材料价格波动。本着互惠互利,安全及质量第一的原则,我公司经商讨决定自${adjustCn}起供给贵司的${product}调整为:${cashText}元/吨(现金价),${billText}元/吨(汇票价),如在执行过程中有价格波动,我公司将及时进行调整,非常感谢!</p>
<div style="height: 72px;"></div>
<div style="display: flex; justify-content: flex-end; max-width: 100%;">
<div style="position: relative; text-align: center; min-width: 220px; max-width: 100%; padding: 24px 12px 8px; box-sizing: border-box;">
<img src="${sealSrc}" alt="印章" style="position: absolute; left: 50%; top: 42%; transform: translate(-50%, calc(-50% - 30px)); width: 160px; max-width: 100%; height: auto; opacity: 0.92; mix-blend-mode: multiply; pointer-events: none; z-index: 2;" />
<div style="position: relative; z-index: 1; line-height: 1.7;">
<div>安庆市山峡化工有限公司</div>
<div>${signCn}</div>
</div>
</div>
</div>
</div>`
}
/** 字段是否齐全,可生成默认正文 */
export function canBuildSendPriceLetterHtml(input: Partial<PriceLetterTemplateInput>): boolean {
const customer = String(input.customerName || '').trim()
const product = String(input.productName || '').trim()
const adjustDate = String(input.adjustDate || '').trim()
return !!(
customer &&
product &&
adjustDate &&
hasValidPrice(input.price) &&
hasValidPrice(input.billPrice)
)
}
+1
View File
@@ -108,6 +108,7 @@ export const SIDEBAR_MENU_ICON_BY_CODE: Record<string, string> = {
'crm:opportunity:list': 'ri:trophy-line',
'crm:quote:list': 'ri:file-text-line',
'crm:contract:list': 'ri:file-paper-2-line',
'crm:priceLetter:list': 'ri:file-list-3-line',
'crm:customer:list': 'ri:team-line',
'crm:customerPool:list': 'ri:global-line',
'db:dashboard:list': 'ri:dashboard-line',
@@ -0,0 +1,115 @@
<template>
<el-dialog
v-model="dialogVisible"
title="作废调价函"
width="560px"
align-center
append-to-body
destroy-on-close
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
>
<el-alert type="warning" :closable="false" style="margin-bottom: 16px">
<template #default>
<div><strong>注意:</strong>调价函作废后不可恢复,仅保留档案,请谨慎操作。</div>
</template>
</el-alert>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="作废原因" prop="invalidReason" class="form-col-span-2">
<el-input
v-model="form.invalidReason"
type="textarea"
:rows="3"
placeholder="请填写作废原因"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="详细说明" prop="remark" class="form-col-span-2">
<el-input
v-model="form.remark"
type="textarea"
:rows="3"
placeholder="可选,将写入备注"
maxlength="500"
show-word-limit
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="danger" :loading="submitLoading" @click="handleConfirm">
<el-icon><CircleClose /></el-icon>确认作废
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Close, CircleClose } from '@element-plus/icons-vue'
import { fetchVoidPriceLetter } from '@/api/crm/price-letter'
import { $modal } from '@/utils/modal'
const emit = defineEmits(['success'])
const dialogVisible = ref(false)
const submitLoading = ref(false)
const letterId = ref('')
const formRef = ref()
const form = reactive({
invalidReason: '',
remark: ''
})
const rules = {
invalidReason: [{ required: true, message: '请填写作废原因', trigger: 'blur' }]
}
function open(row: { id: string }) {
letterId.value = row.id
form.invalidReason = ''
form.remark = ''
dialogVisible.value = true
}
async function handleConfirm() {
const ok = await formRef.value?.validate().catch(() => false)
if (!ok) return
try {
await $modal.confirm('确认作废该调价函?作废后不可恢复。')
} catch {
return
}
submitLoading.value = true
try {
await fetchVoidPriceLetter({
id: letterId.value,
invalidReason: form.invalidReason,
remark: form.remark || undefined
})
$modal.msgSuccess('作废成功')
dialogVisible.value = false
emit('success')
} catch (e: any) {
$modal.msgError(e?.message || '作废失败')
} finally {
submitLoading.value = false
}
}
defineExpose({ open })
</script>
File diff suppressed because it is too large. Load diff