提交
This commit is contained in:
1 parent
5c6ace1dd3
commit
8be55c08e9
32 files changed
+18546
-4422
No files matched your search
@@ -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 }
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user