提交
This commit is contained in:
1 parent
7b1d4c8729
commit
ffc89d9b57
75 files changed
+12263
-7950
No files matched your search
@@ -2,7 +2,7 @@
|
||||
<el-dialog
|
||||
title="选择客户"
|
||||
v-model="dialogVisible"
|
||||
width="1280px"
|
||||
width="1500px"
|
||||
align-center
|
||||
class="dialog-form-layout"
|
||||
modal-class="order-module-dialog-mask"
|
||||
@@ -39,6 +39,7 @@
|
||||
<div class="panel dialog-list-panel">
|
||||
<div class="list-body list-wrap dialog-list-body-only">
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
:data="customerList"
|
||||
class="list-table"
|
||||
@@ -48,7 +49,13 @@
|
||||
:max-height="400"
|
||||
>
|
||||
<el-table-column type="index" label="序号" min-width="60" align="center" header-align="center" />
|
||||
<el-table-column label="客户名称" prop="customerName" min-width="230" align="center" header-align="center" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="客户名称" prop="customerName" min-width="200" align="center" header-align="center" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="客户简称" prop="shortName" min-width="120" align="center" header-align="center" :show-overflow-tooltip="true">
|
||||
<template #default="scope">
|
||||
<span v-if="hasShortName(scope.row)">{{ scope.row.shortName }}</span>
|
||||
<el-tag v-else type="warning" size="small">未填写</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户类型" prop="customerType" min-width="150" align="center" header-align="center">
|
||||
<template #default="scope">
|
||||
<DictTag dict-type="crm_customer_type" :value="scope.row.customerType" />
|
||||
@@ -72,6 +79,13 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="负责人" prop="ownerUserName" min-width="100" align="center" header-align="center" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="操作" fixed="right" min-width="120" align="center" header-align="center" class-name="col-actions">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" @click.stop="handleEditShortName(scope.row)">
|
||||
<el-icon><Edit /></el-icon>{{ hasShortName(scope.row) ? '编辑简称' : '填写简称' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<ListPagination
|
||||
@@ -90,6 +104,7 @@
|
||||
<div class="selected-customer">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="客户名称">{{ currentCustomer.customerName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户简称">{{ currentCustomer.shortName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户类型">
|
||||
<DictTag dict-type="crm_customer_type" :value="currentCustomer.customerType" />
|
||||
</el-descriptions-item>
|
||||
@@ -110,7 +125,55 @@
|
||||
<el-button class="btn-ghost" @click="handleClose">
|
||||
<el-icon><Close /></el-icon>取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleConfirm" :disabled="!currentCustomer">
|
||||
<el-button
|
||||
class="btn-main"
|
||||
type="primary"
|
||||
@click="handleConfirm"
|
||||
:disabled="!currentCustomer || !hasShortName(currentCustomer)"
|
||||
>
|
||||
<el-icon><Check /></el-icon>确定
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 填写/编辑客户简称 -->
|
||||
<el-dialog
|
||||
v-model="shortNameDialogVisible"
|
||||
:title="hasShortName(shortNameForm) ? '编辑客户简称' : '填写客户简称'"
|
||||
width="480px"
|
||||
align-center
|
||||
class="dialog-form-layout"
|
||||
modal-class="order-module-dialog-mask"
|
||||
:close-on-click-modal="false"
|
||||
append-to-body
|
||||
>
|
||||
<el-form
|
||||
ref="shortNameFormRef"
|
||||
:model="shortNameForm"
|
||||
:rules="shortNameRules"
|
||||
label-position="top"
|
||||
class="crm-dialog-form"
|
||||
>
|
||||
<el-form-item label="客户名称">
|
||||
<el-input :model-value="shortNameForm.customerName" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="客户简称" prop="shortName">
|
||||
<el-input
|
||||
v-model="shortNameForm.shortName"
|
||||
placeholder="请输入客户简称"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button class="btn-ghost" @click="shortNameDialogVisible = false">
|
||||
<el-icon><Close /></el-icon>取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="shortNameSaving" @click="handleSaveShortName">
|
||||
<el-icon><Check /></el-icon>确定
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -122,10 +185,10 @@
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
import DictTag from '@/components/DictTag/index.vue'
|
||||
import ListPagination from '@/components/ListPagination/index.vue'
|
||||
import { Check, Close, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { Check, Close, Edit, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import type { ElFormInstance } from 'element-plus'
|
||||
import { fetchCustomerList } from '@/api/crm/customer'
|
||||
import type { ElFormInstance, FormItemRule } from 'element-plus'
|
||||
import { fetchCustomerList, fetchUpdateCustomer } from '@/api/crm/customer'
|
||||
|
||||
const dictStore = useDictStore()
|
||||
|
||||
@@ -167,6 +230,21 @@ const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const currentCustomer = ref<any | null>(null)
|
||||
const queryFormRef = ref<ElFormInstance>()
|
||||
const tableRef = ref<any>()
|
||||
|
||||
const shortNameDialogVisible = ref(false)
|
||||
const shortNameSaving = ref(false)
|
||||
const shortNameFormRef = ref<ElFormInstance>()
|
||||
const shortNameForm = ref({
|
||||
id: '' as string | number,
|
||||
customerName: '',
|
||||
shortName: ''
|
||||
})
|
||||
const shortNameRules: Record<string, FormItemRule[]> = {
|
||||
shortName: [{ required: true, message: '客户简称不能为空', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const hasShortName = (row?: { shortName?: string } | null) => Boolean(String(row?.shortName || '').trim())
|
||||
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
@@ -213,10 +291,66 @@ watchDebounced(
|
||||
)
|
||||
|
||||
const handleCurrentChange = (row: any) => {
|
||||
if (!row) {
|
||||
currentCustomer.value = null
|
||||
return
|
||||
}
|
||||
if (!hasShortName(row)) {
|
||||
ElMessage.warning('该客户尚未填写简称,请先填写客户简称后再选择')
|
||||
tableRef.value?.setCurrentRow(undefined)
|
||||
currentCustomer.value = null
|
||||
return
|
||||
}
|
||||
currentCustomer.value = row
|
||||
}
|
||||
|
||||
const handleEditShortName = (row: any) => {
|
||||
shortNameForm.value = {
|
||||
id: row.id,
|
||||
customerName: row.customerName || '',
|
||||
shortName: row.shortName || ''
|
||||
}
|
||||
shortNameDialogVisible.value = true
|
||||
nextTick(() => shortNameFormRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
const handleSaveShortName = async () => {
|
||||
const valid = await shortNameFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
shortNameSaving.value = true
|
||||
try {
|
||||
const trimmedShortName = shortNameForm.value.shortName.trim()
|
||||
await fetchUpdateCustomer({
|
||||
id: shortNameForm.value.id,
|
||||
shortName: trimmedShortName
|
||||
})
|
||||
ElMessage.success('客户简称保存成功')
|
||||
|
||||
const targetId = shortNameForm.value.id
|
||||
const updatedRow = customerList.value.find((item) => item.id === targetId)
|
||||
if (updatedRow) {
|
||||
updatedRow.shortName = trimmedShortName
|
||||
}
|
||||
if (currentCustomer.value?.id === targetId) {
|
||||
currentCustomer.value = { ...currentCustomer.value, shortName: trimmedShortName }
|
||||
}
|
||||
|
||||
shortNameDialogVisible.value = false
|
||||
} catch (error) {
|
||||
console.error('保存客户简称失败:', error)
|
||||
ElMessage.error('保存客户简称失败')
|
||||
} finally {
|
||||
shortNameSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!currentCustomer.value) return
|
||||
if (!hasShortName(currentCustomer.value)) {
|
||||
ElMessage.warning('该客户尚未填写简称,请先填写客户简称后再选择')
|
||||
return
|
||||
}
|
||||
emit('confirm', currentCustomer.value)
|
||||
handleClose()
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<span v-if="getCategoryLabel(file)" class="attachment-preview__category">
|
||||
{{ getCategoryLabel(file) }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="compact && !disabled"
|
||||
@@ -165,6 +168,8 @@ const props = withDefaults(
|
||||
disabled?: boolean
|
||||
/** 紧凑模式:小缩略图,只读时隐藏文件名与操作栏 */
|
||||
compact?: boolean
|
||||
/** 附件 URL → 分类名称,用于查看态在缩略图上展示分类标签 */
|
||||
categoryLabels?: Record<string, string>
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
@@ -173,7 +178,8 @@ const props = withDefaults(
|
||||
accept: 'image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar',
|
||||
hint: '支持图片、视频、PDF、Word、Excel等文件',
|
||||
disabled: false,
|
||||
compact: false
|
||||
compact: false,
|
||||
categoryLabels: () => ({})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -269,6 +275,11 @@ function getFileExtension(fileName: string) {
|
||||
return fileName.substring(lastDotIndex + 1).toUpperCase()
|
||||
}
|
||||
|
||||
function getCategoryLabel(file: AttachmentItem) {
|
||||
const label = props.categoryLabels?.[file.fileUrl]
|
||||
return label ? String(label).trim() : ''
|
||||
}
|
||||
|
||||
function canPreview(file: AttachmentItem) {
|
||||
return Boolean(file.fileUrl) && (isImageFile(file) || isVideoFile(file) || isPdfFile(file))
|
||||
}
|
||||
@@ -420,6 +431,7 @@ defineExpose({ hasUploading })
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
flex: 0 0 auto;
|
||||
width: 140px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
@@ -438,6 +450,7 @@ defineExpose({ hasUploading })
|
||||
}
|
||||
|
||||
.attachment-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
display: flex;
|
||||
@@ -490,6 +503,21 @@ defineExpose({ hasUploading })
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.attachment-preview__category {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
padding: 2px 4px;
|
||||
background: rgba(15, 23, 42, 0.62);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.attachment-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -565,7 +593,8 @@ defineExpose({ hasUploading })
|
||||
}
|
||||
|
||||
:deep(.el-upload) {
|
||||
display: block;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.attachment-upload--compact {
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
调整运输时现场链接将轮换更新,旧现场链接将失效。
|
||||
</template>
|
||||
<template v-else-if="showDibangHint">
|
||||
勾选后将按产品进行地磅单生成(当月已有则复用),并在地磅出入库管理中产生对应记录。
|
||||
勾选后将按产品进行地磅单生成(当月已有则复用);入库单日期取送达时间,出库单日期取取货时间,未填时回退计划日期。
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="view-kv-table-cell">
|
||||
<el-tag v-if="field.tagType" :type="field.tagType" size="small">{{
|
||||
<el-tag v-if="field.tagType" :type="field.tagType" size="small" class="view-kv-tag">{{
|
||||
formatViewValue(field.value)
|
||||
}}</el-tag>
|
||||
<ul v-else-if="field.items?.length" class="view-plain-list">
|
||||
<li v-for="(item, idx) in field.items" :key="idx">{{ item }}</li>
|
||||
</ul>
|
||||
<span v-else :class="{ 'money-display': field.money, 'muted-display': isMuted }">{{
|
||||
<span v-else :class="{ 'money-display': field.money, 'danger-display': field.danger, 'muted-display': isMuted }">{{
|
||||
formatViewValue(field.value)
|
||||
}}</span>
|
||||
<el-button
|
||||
@@ -49,7 +49,7 @@
|
||||
const onFieldView = inject<((field: ViewField) => void) | undefined>(VIEW_KV_FIELD_VIEW_KEY)
|
||||
|
||||
const isMuted = computed(
|
||||
() => !props.field.money && formatViewValue(props.field.value) === '—'
|
||||
() => !props.field.money && !props.field.danger && formatViewValue(props.field.value) === '—'
|
||||
)
|
||||
|
||||
const showViewBtn = computed(() => {
|
||||
@@ -91,6 +91,10 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.view-kv-tag {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.view-kv-field-link {
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
@@ -112,4 +116,9 @@
|
||||
color: var(--text-muted, #64748b);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.danger-display {
|
||||
color: #dc2626;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -18,6 +18,8 @@ export interface ViewField {
|
||||
fullSpan?: boolean
|
||||
tagType?: TagType
|
||||
money?: boolean
|
||||
/** 红色加粗强调展示(如结算方式等关键业务字段) */
|
||||
danger?: boolean
|
||||
/** 值后展示「查看」按钮,点击时 ViewKvTable 抛出 field-view */
|
||||
viewTarget?: 'customer' | 'opportunity' | 'quote'
|
||||
viewId?: string | number | null
|
||||
|
||||
@@ -141,14 +141,16 @@ export function isHazardCertReady(plan: { hazardCertReady?: number } | null | un
|
||||
return Number(plan?.hazardCertReady ?? 0) === 1
|
||||
}
|
||||
|
||||
/** 含危化品且尚未人工确认证书齐备时,阻断安排运输 */
|
||||
/** 含危化品且尚未确认证书齐备(人工确认或全部所需证书已上传)时,阻断安排运输 */
|
||||
export function shouldBlockTransportForHazardCert(
|
||||
plan: { hazardCertReady?: number; products?: PlanProduct[] } | null | undefined,
|
||||
products?: PlanProduct[]
|
||||
) {
|
||||
const prods = products ?? plan?.products
|
||||
if (!planHasHazardProducts(prods)) return false
|
||||
return !isHazardCertReady(plan)
|
||||
if (isHazardCertReady(plan)) return false
|
||||
if (!findMissingHazardCerts(prods ?? []).length) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function buildMissingHazardCertReadyMessage(
|
||||
@@ -156,7 +158,10 @@ export function buildMissingHazardCertReadyMessage(
|
||||
products?: PlanProduct[]
|
||||
) {
|
||||
if (!shouldBlockTransportForHazardCert(plan, products)) return ''
|
||||
return '该计划危化品证书尚未确认齐备'
|
||||
const prods = products ?? plan?.products ?? []
|
||||
const missingCertsMsg = buildMissingHazardCertMessage(prods)
|
||||
if (missingCertsMsg) return missingCertsMsg
|
||||
return '该计划危化品证书尚未确认齐备,请点击「证书齐备」或补全所需证书'
|
||||
}
|
||||
|
||||
function formatMissingCertLabels(product: PlanProduct): string {
|
||||
|
||||
@@ -280,9 +280,29 @@ async function handleDynamicRoutes(
|
||||
// 2. 获取菜单数据
|
||||
const menuList = await menuProcessor.getMenuList()
|
||||
|
||||
// 3. 验证菜单数据
|
||||
// 3. 无菜单权限(如未绑定角色):允许空菜单并完成初始化,跳转 403
|
||||
if (!menuProcessor.validateMenuList(menuList)) {
|
||||
throw new Error('获取菜单列表失败,请重新登录')
|
||||
routeRegistry?.register([])
|
||||
const menuStore = useMenuStore()
|
||||
menuStore.setMenuList([])
|
||||
menuStore.addRemoveRouteFns(routeRegistry?.getRemoveRouteFns() || [])
|
||||
IframeRouteManager.getInstance().save()
|
||||
useWorktabStore().validateWorktabs(router)
|
||||
routeInitInProgress = false
|
||||
closeLoading()
|
||||
|
||||
if (isStaticRoute(to.path)) {
|
||||
next({
|
||||
path: to.path,
|
||||
query: to.query,
|
||||
hash: to.hash,
|
||||
replace: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
next({ path: '/403', replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 注册动态路由
|
||||
|
||||
@@ -193,8 +193,9 @@
|
||||
if (c.mobile) parts.push(c.mobile)
|
||||
if (c.status) {
|
||||
parts.push(dictStore.getLabel('crm_clue_status', c.status))
|
||||
} else if (c.isConverted === 1) {
|
||||
parts.push('已转化')
|
||||
}
|
||||
if (c.isConverted === 1) parts.push('已转化')
|
||||
return parts.join(' · ')
|
||||
})
|
||||
|
||||
@@ -338,7 +339,6 @@
|
||||
}
|
||||
|
||||
const handleInvalidSuccess = async () => {
|
||||
ElMessage.success('标记无效成功')
|
||||
await loadDetail()
|
||||
emit('success')
|
||||
}
|
||||
@@ -350,7 +350,6 @@
|
||||
}
|
||||
|
||||
const handleConvertSuccess = async () => {
|
||||
ElMessage.success('转化成功')
|
||||
await loadDetail()
|
||||
emit('success')
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
v-model="attachmentUrls"
|
||||
folder="crm/clue/follow"
|
||||
:limit="15"
|
||||
hint="支持图片、视频、PDF、Word 等文件,单文件不超过 20MB,最多 15 个"
|
||||
hint="支持图片、视频、PDF、Word 等文件,最多 15 个"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="right"
|
||||
label-width="100px"
|
||||
label-position="top"
|
||||
class="crm-dialog-form"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
|
||||
@@ -1381,7 +1381,11 @@ const detailBasicViewFields = computed<ViewField[]>(() => {
|
||||
? []
|
||||
: [{ label: '合同金额(元)', value: `¥${formatDetailAmount(c.amount)}`, money: true }]),
|
||||
{ label: '交易主体', value: c.supplierName },
|
||||
detailTagField('结算方式', 'crm_settlement_method', c.settlementMethod)
|
||||
{
|
||||
label: '结算方式',
|
||||
value: c.settlementMethod ? dictStore.getLabel('crm_settlement_method', c.settlementMethod) : '—',
|
||||
danger: true
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column label="合同编号" align="center" prop="contractNo" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column label="合同名称" prop="contractName" show-overflow-tooltip min-width="200">
|
||||
<el-table-column label="合同名称" prop="contractName" show-overflow-tooltip min-width="280">
|
||||
<template #default="scope">
|
||||
<el-link type="primary" @click="handleView(scope.row)">{{ scope.row.contractName }}</el-link>
|
||||
</template>
|
||||
@@ -349,14 +349,6 @@
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isColumnVisible('receivedAmount')" label="已回款" align="right" prop="receivedAmount" min-width="130">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.receivedAmount && scope.row.receivedAmount > 0" class="text-success"
|
||||
>¥{{ parseFloat(scope.row.receivedAmount).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}</span
|
||||
>
|
||||
<span v-else class="text-muted">¥0.00</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isColumnVisible('unreceivedAmount')" label="未回款" align="right" prop="unreceivedAmount" min-width="130">
|
||||
<template #default="scope">
|
||||
<span
|
||||
@@ -366,14 +358,6 @@
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isColumnVisible('receivedRate')" label="回款进度" align="center" min-width="120">
|
||||
<template #default="scope">
|
||||
<el-progress
|
||||
:percentage="getReceivedRate(scope.row.contractAmount, scope.row.receivedAmount)"
|
||||
:color="getProgressColor(getReceivedRate(scope.row.contractAmount, scope.row.receivedAmount))"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isColumnVisible('invoiceAmount')" label="已开票金额" align="right" prop="invoiceAmount" min-width="130">
|
||||
<template #default="scope">
|
||||
<span
|
||||
@@ -421,6 +405,22 @@
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isColumnVisible('receivedAmount')" label="已回款" align="right" prop="receivedAmount" min-width="130">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.receivedAmount && scope.row.receivedAmount > 0" class="text-success"
|
||||
>¥{{ parseFloat(scope.row.receivedAmount).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}</span
|
||||
>
|
||||
<span v-else class="text-muted">¥0.00</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isColumnVisible('receivedRate')" label="回款进度" align="center" min-width="120">
|
||||
<template #default="scope">
|
||||
<el-progress
|
||||
:percentage="getReceivedRate(scope.row.contractAmount, scope.row.receivedAmount)"
|
||||
:color="getProgressColor(getReceivedRate(scope.row.contractAmount, scope.row.receivedAmount))"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('ownerUserName')"
|
||||
label="负责人"
|
||||
@@ -438,7 +438,7 @@
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column v-if="isColumnVisible('createByName')" label="创建人" align="center" prop="createByName" min-width="100" />
|
||||
<el-table-column v-if="isColumnVisible('createTime')" label="创建时间" align="center" prop="createTime" min-width="160" show-overflow-tooltip>
|
||||
<el-table-column v-if="isColumnVisible('createTime')" label="创建时间" align="center" prop="createTime" min-width="140" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span>{{ parseTime(scope.row.createTime) }}</span>
|
||||
</template>
|
||||
@@ -581,9 +581,7 @@ const listColumnDefs = [
|
||||
{ prop: 'renewalCount', label: '续签次数' },
|
||||
{ prop: 'attachmentFiles', label: '合同附件' },
|
||||
{ prop: 'amount', label: '合同金额(元)' },
|
||||
{ prop: 'receivedAmount', label: '已回款' },
|
||||
{ prop: 'unreceivedAmount', label: '未回款' },
|
||||
{ prop: 'receivedRate', label: '回款进度' },
|
||||
{ prop: 'invoiceAmount', label: '已开票金额' },
|
||||
{ prop: 'uninvoiceAmount', label: '未开票金额' },
|
||||
{ prop: 'status', label: '合同状态' },
|
||||
@@ -595,6 +593,8 @@ const listColumnDefs = [
|
||||
{ prop: 'partyA', label: '甲方' },
|
||||
{ prop: 'partyB', label: '乙方' },
|
||||
{ prop: 'settlementMethod', label: '结算方式' },
|
||||
{ prop: 'receivedAmount', label: '已回款' },
|
||||
{ prop: 'receivedRate', label: '回款进度' },
|
||||
{ prop: 'ownerUserName', label: '负责人' },
|
||||
{ prop: 'createDeptName', label: '创建部门' },
|
||||
{ prop: 'createByName', label: '创建人' },
|
||||
@@ -1342,4 +1342,106 @@ onMounted(async () => {
|
||||
color: var(--mint-primary, #1ca851);
|
||||
}
|
||||
|
||||
.attachment-list-cell {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
padding: 4px 0;
|
||||
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.attachment-thumb-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
width: 58px;
|
||||
}
|
||||
|
||||
.attachment-preview-wrap {
|
||||
position: relative;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.attachment-thumb-image {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.attachment-file-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
.el-icon {
|
||||
font-size: 22px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #1ca851;
|
||||
background: #e6f8ed;
|
||||
|
||||
.el-icon {
|
||||
color: #1ca851;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-download-btn {
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
color: #1e293b;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
|
||||
&:hover {
|
||||
color: #1ca851;
|
||||
border-color: #1ca851;
|
||||
background: #e6f8ed;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-category-label {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
color: #1e293b;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -15,20 +15,11 @@
|
||||
append-to-body
|
||||
>
|
||||
<template #certificates>
|
||||
<div class="cert-list">
|
||||
<div v-if="customerInfo.businessLicenseImages" class="cert-block">
|
||||
<div class="view-field-label">营业执照</div>
|
||||
<AttachmentUploadField :model-value="customerInfo.businessLicenseImages" disabled />
|
||||
</div>
|
||||
<div v-if="customerInfo.businessPermitImages" class="cert-block">
|
||||
<div class="view-field-label">经营许可证</div>
|
||||
<AttachmentUploadField :model-value="customerInfo.businessPermitImages" disabled />
|
||||
</div>
|
||||
<div v-if="customerInfo.otherCertImages" class="cert-block">
|
||||
<div class="view-field-label">其他证件</div>
|
||||
<AttachmentUploadField :model-value="customerInfo.otherCertImages" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<AttachmentUploadField
|
||||
:model-value="certificateDisplay.modelValue"
|
||||
:category-labels="certificateDisplay.categoryLabels"
|
||||
disabled
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #hero-actions>
|
||||
@@ -547,6 +538,32 @@ const isToday = computed(() => {
|
||||
return today === customerInfo.value.nextTime.split(' ')[0]
|
||||
})
|
||||
|
||||
function appendCertUrls(raw: string | undefined, label: string, urls: string[], labels: Record<string, string>) {
|
||||
if (!raw) return
|
||||
String(raw)
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((url) => {
|
||||
urls.push(url)
|
||||
labels[url] = label
|
||||
})
|
||||
}
|
||||
|
||||
function buildCertDisplay(customer: CustomerVO) {
|
||||
const urls: string[] = []
|
||||
const labels: Record<string, string> = {}
|
||||
appendCertUrls(customer.businessLicenseImages, '营业执照', urls, labels)
|
||||
appendCertUrls(customer.businessPermitImages, '经营许可证', urls, labels)
|
||||
appendCertUrls(customer.otherCertImages, '其他证件', urls, labels)
|
||||
return {
|
||||
modelValue: urls.join(','),
|
||||
categoryLabels: labels
|
||||
}
|
||||
}
|
||||
|
||||
const certificateDisplay = computed(() => buildCertDisplay(customerInfo.value))
|
||||
|
||||
const viewHeroMeta = computed(() => {
|
||||
const c = customerInfo.value
|
||||
const parts: string[] = []
|
||||
@@ -993,18 +1010,6 @@ defineExpose({ open })
|
||||
}
|
||||
}
|
||||
|
||||
.cert-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cert-block {
|
||||
.view-field-label {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-timeline-item__timestamp) {
|
||||
color: #1e293b;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -152,25 +152,25 @@
|
||||
|
||||
<el-divider content-position="left">证件信息</el-divider>
|
||||
<div class="form-grid-4">
|
||||
<el-form-item label="营业执照" prop="businessLicenseImages">
|
||||
<el-form-item label="营业执照" prop="businessLicenseImages" class="form-field-span-2">
|
||||
<AttachmentUploadField
|
||||
ref="businessLicenseUploadRef"
|
||||
v-model="form.businessLicenseImages"
|
||||
:limit="5"
|
||||
accept="image/*,.pdf"
|
||||
folder="crm/customer/license"
|
||||
hint="支持 JPG、PNG、PDF,单文件不超过 20MB,最多 5 个"
|
||||
hint="支持 JPG、PNG、PDF,最多 5 个"
|
||||
@update:model-value="handleCertFieldChange('businessLicenseImages')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="经营许可证" prop="businessPermitImages">
|
||||
<el-form-item label="经营许可证" prop="businessPermitImages" class="form-field-span-2">
|
||||
<AttachmentUploadField
|
||||
ref="businessPermitUploadRef"
|
||||
v-model="form.businessPermitImages"
|
||||
:limit="5"
|
||||
accept="image/*,.pdf"
|
||||
folder="crm/customer/permit"
|
||||
hint="支持 JPG、PNG、PDF,单文件不超过 20MB,最多 5 个"
|
||||
hint="支持 JPG、PNG、PDF,最多 5 个"
|
||||
@update:model-value="handleCertFieldChange('businessPermitImages')"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -181,7 +181,7 @@
|
||||
:limit="10"
|
||||
accept="image/*,.pdf"
|
||||
folder="crm/customer/cert"
|
||||
hint="支持 JPG、PNG、PDF,单文件不超过 20MB,最多 10 个"
|
||||
hint="支持 JPG、PNG、PDF,最多 10 个"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
@@ -519,6 +519,7 @@ const open = async (id?: number | string) => {
|
||||
Object.assign(form.value, data)
|
||||
form.value.businessLicenseImages =
|
||||
(data as any)?.businessLicenseImages || (data as any)?.businessLicenseImg || undefined
|
||||
delete (form.value as Record<string, unknown>).businessLicenseImg
|
||||
form.value.businessPermitImages = (data as any)?.businessPermitImages || undefined
|
||||
form.value.otherCertImages = (data as any)?.otherCertImages || undefined
|
||||
if ((data as any)?.ownerUserId) {
|
||||
@@ -536,6 +537,8 @@ const open = async (id?: number | string) => {
|
||||
/** 构建提交 payload(字段名与后端 Schema 对齐) */
|
||||
function buildSubmitPayload() {
|
||||
const payload: Record<string, any> = { ...form.value }
|
||||
// 详情接口会同时返回 businessLicenseImg(旧字段),避免覆盖新上传的 businessLicenseImages
|
||||
delete payload.businessLicenseImg
|
||||
return payload
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
v-model="attachmentUrls"
|
||||
folder="crm/customer/follow"
|
||||
:limit="15"
|
||||
hint="支持图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB,最多 15 个"
|
||||
hint="支持图片、视频、PDF、Word、Excel等文件,最多 15 个"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
v-model="attachmentUrls"
|
||||
folder="crm/opportunity/follow"
|
||||
:limit="15"
|
||||
hint="支持图片、视频、PDF、Word 等文件,单文件不超过 20MB,最多 15 个"
|
||||
hint="支持图片、视频、PDF、Word 等文件,最多 15 个"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
@@ -201,18 +201,6 @@
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('unit')"
|
||||
prop="unit"
|
||||
label="单位"
|
||||
min-width="80"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ formatProductUnit(row.unit) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('price')"
|
||||
prop="price"
|
||||
@@ -293,7 +281,7 @@
|
||||
>
|
||||
<div class="form-grid-3">
|
||||
<el-form-item label="产品编号">
|
||||
<el-input v-model="formData.productNo" placeholder="留空自动生成" />
|
||||
<el-input v-model="formData.productNo" placeholder="留空自动生成(如 SXWL000001)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="产品名称" prop="productName">
|
||||
<el-input v-model="formData.productName" placeholder="请输入产品名称" />
|
||||
@@ -367,7 +355,7 @@
|
||||
:limit="5"
|
||||
accept="image/*"
|
||||
folder="crm/product"
|
||||
hint="支持 JPG、PNG、WebP 等图片,单文件不超过 20MB,最多 5 张"
|
||||
hint="支持 JPG、PNG、WebP 等图片,最多 5 张"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="产品描述" style="grid-column: 1 / -1">
|
||||
@@ -548,11 +536,6 @@ function getHazardFlagTagType(value: unknown) {
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
function formatProductUnit(unit: string | null | undefined) {
|
||||
if (!unit) return '-'
|
||||
return dictStore.getLabel('crm_product_unit', unit) || unit
|
||||
}
|
||||
|
||||
function formatProductPrice(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
const num = Number(value)
|
||||
@@ -568,7 +551,6 @@ const listColumnDefs = [
|
||||
{ prop: 'productNo', label: '产品编号' },
|
||||
{ prop: 'categoryName', label: '产品分类' },
|
||||
{ prop: 'model', label: '规格型号' },
|
||||
{ prop: 'unit', label: '单位' },
|
||||
{ prop: 'price', label: '标准价(元)' },
|
||||
{ prop: 'cost', label: '成本价(元)', defaultVisible: false },
|
||||
{ prop: 'status', label: '状态' }
|
||||
@@ -648,22 +630,10 @@ const productViewSections = computed<ViewSection[]>(() => {
|
||||
if (!d) return []
|
||||
|
||||
const fields: ViewField[] = [
|
||||
{
|
||||
label: '标准价(元)',
|
||||
value: formatProductPrice(d.price),
|
||||
money: true
|
||||
},
|
||||
{
|
||||
label: '成本价(元)',
|
||||
value: formatProductPrice(d.costPrice ?? d.cost),
|
||||
money: true
|
||||
},
|
||||
crmField('库存数量', `${d.stock ?? 0} ${formatProductUnit(d.unit)}`),
|
||||
crmDictField('状态', 'crm_product_status', d.status, dictStore),
|
||||
crmField('产品分类', d.categoryName),
|
||||
crmField('规格型号', d.model),
|
||||
crmField('条码', d.barcode),
|
||||
crmField('库存预警值', d.lowStock ?? 10),
|
||||
crmField('销售次数', d.saleCount ?? 0),
|
||||
crmField('税率', `${d.taxRate ?? 13}%`),
|
||||
crmTagField('是否易制毒', formatHazardFlag(d.isToxic), getHazardFlagTagType(d.isToxic)),
|
||||
|
||||
@@ -203,47 +203,50 @@
|
||||
|
||||
<div class="quote-approval-flow-scroll">
|
||||
<p v-if="approvalList.length === 0" class="view-empty-hint">暂无审批记录</p>
|
||||
<el-timeline v-else>
|
||||
<el-timeline-item
|
||||
v-for="item in approvalList"
|
||||
:key="item.id"
|
||||
:timestamp="`第${item.step}步 · ${item.createTime || '—'}`"
|
||||
placement="top"
|
||||
:color="getApprovalTimelineColor(item.approvalResult)"
|
||||
:hollow="item.approvalResult === 'pending'"
|
||||
>
|
||||
<div class="quote-approval-card">
|
||||
<div class="quote-approval-card-head">
|
||||
<div class="quote-approval-card-user">
|
||||
<el-avatar :size="28">{{ item.approverName?.substring(0, 1) || '—' }}</el-avatar>
|
||||
<span>{{ item.approverName || '—' }}</span>
|
||||
<div v-else class="quote-approval-flow-track">
|
||||
<template v-for="(item, index) in approvalList" :key="item.id">
|
||||
<div class="quote-approval-flow-step">
|
||||
<p class="quote-approval-step-meta">
|
||||
第{{ item.step }}步 · {{ item.createTime || '—' }}
|
||||
</p>
|
||||
<div class="quote-approval-card">
|
||||
<div class="quote-approval-card-head">
|
||||
<div class="quote-approval-card-user">
|
||||
<el-avatar :size="28">{{ item.approverName?.substring(0, 1) || '—' }}</el-avatar>
|
||||
<span>{{ item.approverName || '—' }}</span>
|
||||
</div>
|
||||
<el-tag
|
||||
v-if="item.approvalResult === 'approved'"
|
||||
type="success"
|
||||
size="small"
|
||||
>
|
||||
已通过
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="item.approvalResult === 'rejected'"
|
||||
type="danger"
|
||||
size="small"
|
||||
>
|
||||
已拒绝
|
||||
</el-tag>
|
||||
<el-tag v-else type="info" size="small">待审批</el-tag>
|
||||
</div>
|
||||
<el-tag
|
||||
v-if="item.approvalResult === 'approved'"
|
||||
type="success"
|
||||
size="small"
|
||||
>
|
||||
已通过
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="item.approvalResult === 'rejected'"
|
||||
type="danger"
|
||||
size="small"
|
||||
>
|
||||
已拒绝
|
||||
</el-tag>
|
||||
<el-tag v-else type="info" size="small">待审批</el-tag>
|
||||
<p v-if="item.approvalOpinion" class="quote-approval-opinion">
|
||||
审批意见:{{ item.approvalOpinion }}
|
||||
</p>
|
||||
<p v-if="item.approvalTime" class="quote-approval-meta">
|
||||
审批时间:{{ item.approvalTime }}
|
||||
</p>
|
||||
<p v-if="item.remark" class="quote-approval-meta">备注:{{ item.remark }}</p>
|
||||
</div>
|
||||
<p v-if="item.approvalOpinion" class="quote-approval-opinion">
|
||||
审批意见:{{ item.approvalOpinion }}
|
||||
</p>
|
||||
<p v-if="item.approvalTime" class="quote-approval-meta">
|
||||
审批时间:{{ item.approvalTime }}
|
||||
</p>
|
||||
<p v-if="item.remark" class="quote-approval-meta">备注:{{ item.remark }}</p>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<div
|
||||
v-if="index < approvalList.length - 1"
|
||||
class="quote-approval-flow-connector"
|
||||
:class="{ 'quote-approval-flow-connector--done': item.approvalResult === 'approved' }"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="approvalList.length > 0" class="quote-approval-stats">
|
||||
@@ -386,12 +389,6 @@ function getDaysRemaining(expireDate?: string) {
|
||||
return `剩余${diff}天`
|
||||
}
|
||||
|
||||
function getApprovalTimelineColor(result?: string) {
|
||||
if (result === 'approved') return '#1ca851'
|
||||
if (result === 'rejected') return '#ef4444'
|
||||
return '#94a3b8'
|
||||
}
|
||||
|
||||
/** 8 列表格区块:禁用长字段独占行,保证每行尽量排满 4 组 */
|
||||
function viewQuadTableSection(title: string, fields: ViewField[]): ViewSection {
|
||||
return {
|
||||
@@ -622,12 +619,62 @@ defineExpose({ open, refresh })
|
||||
}
|
||||
|
||||
.quote-approval-flow-scroll {
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 4px 4px 12px;
|
||||
}
|
||||
|
||||
.quote-approval-flow-track {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
min-width: min-content;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.quote-approval-flow-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 240px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.quote-approval-step-meta {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.quote-approval-flow-connector {
|
||||
width: 48px;
|
||||
height: 2px;
|
||||
margin-top: 52px;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
background: #e2e8f0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.quote-approval-flow-connector--done {
|
||||
background: linear-gradient(90deg, #1ca851, #49be78);
|
||||
}
|
||||
|
||||
.quote-approval-flow-connector--done::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -3px;
|
||||
top: -3px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-top: 2px solid #1ca851;
|
||||
border-right: 2px solid #1ca851;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.quote-approval-card {
|
||||
flex: 1;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border, #d9e8e2);
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -5,14 +5,17 @@
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="right"
|
||||
label-width="100px"
|
||||
label-width="120px"
|
||||
class="crm-dialog-form quote-form-body"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<el-divider content-position="left">基本信息</el-divider>
|
||||
<el-divider content-position="left">
|
||||
<span class="quote-section-title">基本信息</span>
|
||||
</el-divider>
|
||||
<div class="form-grid-4">
|
||||
<el-form-item class="quote-type-item form-col-span-4" label="报价类型" prop="quoteType" required>
|
||||
<!-- 第一行:报价类型、报价单编号、报价主题、关联商机 -->
|
||||
<el-form-item class="quote-type-item" label="报价类型" prop="quoteType" required>
|
||||
<el-radio-group v-model="form.quoteType" @change="handleQuoteTypeChange">
|
||||
<el-radio-button v-for="item in quoteTypeOptions" :key="item.value" :label="item.value">
|
||||
{{ item.label }}
|
||||
@@ -25,6 +28,27 @@
|
||||
<el-form-item :label="basicLabels.quoteTitle" prop="quoteTitle">
|
||||
<el-input v-model="form.quoteTitle" placeholder="可不填,系统将自动生成" maxlength="100" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item :label="basicLabels.opportunity" prop="opportunityId">
|
||||
<el-input v-model="opportunityDisplayName" placeholder="请选择商机(可选)" readonly clearable>
|
||||
<template #append>
|
||||
<el-button text @click="handleSelectOpportunity">
|
||||
<el-icon><Search /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<!-- 第二行:采购主体/报价主体、客户/供货商名称、联系人、负责人 -->
|
||||
<el-form-item :label="basicLabels.supplier" prop="supplierId" required>
|
||||
<el-select
|
||||
v-model="form.supplierId"
|
||||
:placeholder="`请选择${basicLabels.supplier}`"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="handleSupplierChange"
|
||||
>
|
||||
<el-option v-for="supplier in supplierList" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="basicLabels.customerName" prop="customerId" required>
|
||||
<el-input v-model="form.customerName" placeholder="请选择客户" readonly>
|
||||
<template #append>
|
||||
@@ -43,15 +67,6 @@
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item :label="basicLabels.opportunity" prop="opportunityId">
|
||||
<el-input v-model="opportunityDisplayName" placeholder="请选择商机(可选)" readonly clearable>
|
||||
<template #append>
|
||||
<el-button text @click="handleSelectOpportunity">
|
||||
<el-icon><Search /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item :label="basicLabels.ownerUser" prop="ownerUserId" required>
|
||||
<el-select
|
||||
v-model="form.ownerUserId"
|
||||
@@ -64,6 +79,10 @@
|
||||
<el-option v-for="user in userList" :key="user.userId" :label="user.nickName" :value="user.userId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<!-- 第三行:运费、询价/报价日期、有效期(天) -->
|
||||
<el-form-item label="运费(元)" prop="freight">
|
||||
<el-input-number v-model="form.freight" :min="0" :step="0.01" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="basicLabels.quoteDate" prop="quoteDate" required>
|
||||
<el-date-picker
|
||||
v-model="form.quoteDate"
|
||||
@@ -76,25 +95,11 @@
|
||||
<el-form-item :label="basicLabels.validityDays" prop="validityDays" required>
|
||||
<el-input-number v-model="form.validityDays" :min="1" :max="365" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="basicLabels.supplier" prop="supplierId" required>
|
||||
<el-select
|
||||
v-model="form.supplierId"
|
||||
:placeholder="`请选择${basicLabels.supplier}`"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="handleSupplierChange"
|
||||
>
|
||||
<el-option v-for="supplier in supplierList" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="运费(元)" prop="freight">
|
||||
<el-input-number v-model="form.freight" :min="0" :step="0.01" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 产品明细 -->
|
||||
<el-divider content-position="left">
|
||||
<span style="font-size: 16px; font-weight: bold; color: #67c23a">
|
||||
<span class="quote-section-title">
|
||||
<el-icon><GoodsFilled /></el-icon> 产品明细
|
||||
</span>
|
||||
</el-divider>
|
||||
@@ -236,7 +241,7 @@
|
||||
|
||||
<!-- 报价条款 -->
|
||||
<el-divider content-position="left">
|
||||
<span style="font-size: 16px; font-weight: bold; color: #67c23a">
|
||||
<span class="quote-section-title">
|
||||
<el-icon><List /></el-icon> 报价条款
|
||||
</span>
|
||||
</el-divider>
|
||||
@@ -891,8 +896,8 @@ const handleCustomerConfirm = async (customer: CustomerVO) => {
|
||||
if (customer) {
|
||||
form.value.customerId = customer.id;
|
||||
form.value.customerName = customer.customerName;
|
||||
// 保存客户简称
|
||||
customerShortName.value = customer.shortName || customer.customerName || '';
|
||||
// 保存客户简称(报价主题依赖简称,不可回退为客户全称)
|
||||
customerShortName.value = customer.shortName?.trim() || '';
|
||||
// 清空联系人和商机
|
||||
form.value.contactId = undefined;
|
||||
form.value.contactName = '';
|
||||
@@ -963,6 +968,39 @@ const handleOwnerChange = (userId: string | number | undefined) => {
|
||||
form.value.ownerUserName = user?.nickName || ''
|
||||
}
|
||||
|
||||
/** 当前登录用户 ID(后端 current-user 返回 id,兼容历史 userId 字段) */
|
||||
const resolveCurrentUserId = (): string => {
|
||||
const info = userStore.info || ({} as Api.Auth.UserInfo & Record<string, unknown>)
|
||||
const fromInfo = String(info.id || info.userId || '')
|
||||
if (fromInfo) return fromInfo
|
||||
const token = userStore.accessToken
|
||||
if (!token || token.split('.').length < 2) return ''
|
||||
try {
|
||||
const payload = token.split('.')[1]
|
||||
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const pad = '='.repeat((4 - (b64.length % 4)) % 4)
|
||||
const obj = JSON.parse(atob(b64 + pad))
|
||||
return String(obj?.sub || '')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 新增时默认负责人为当前登录人 */
|
||||
const applyDefaultOwnerUser = () => {
|
||||
const currentUserId = resolveCurrentUserId()
|
||||
if (!currentUserId) return
|
||||
const matched = userList.value.find((item) => String(item.userId) === currentUserId)
|
||||
if (matched) {
|
||||
form.value.ownerUserId = matched.userId as QuoteForm['ownerUserId']
|
||||
form.value.ownerUserName = matched.nickName || ''
|
||||
return
|
||||
}
|
||||
form.value.ownerUserId = currentUserId as QuoteForm['ownerUserId']
|
||||
const info = userStore.info as Record<string, string | undefined>
|
||||
form.value.ownerUserName = info?.realName || info?.userName || info?.username || ''
|
||||
}
|
||||
|
||||
const formatApproverOptionLabel = (user: { nickName?: string; userName?: string }) => {
|
||||
const nick = (user.nickName || '').trim()
|
||||
const account = (user.userName || '').trim()
|
||||
@@ -1610,6 +1648,7 @@ const initNew = async () => {
|
||||
form.value.quoteDate = getTodayDate();
|
||||
await dictStore.loadAllDict();
|
||||
await Promise.all([loadSupplierList(), loadUserList()]);
|
||||
applyDefaultOwnerUser();
|
||||
};
|
||||
|
||||
const initEdit = async (id: string | number) => {
|
||||
@@ -1674,11 +1713,27 @@ onMounted(async () => {
|
||||
getQuoteInfoForCopy(copyId);
|
||||
} else {
|
||||
form.value.quoteDate = getTodayDate();
|
||||
applyDefaultOwnerUser();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.quote-form-body {
|
||||
:deep(.el-form-item__label) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.quote-section-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: var(--primary-dark, #059669);
|
||||
}
|
||||
|
||||
.box-card {
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -1708,17 +1763,19 @@ onMounted(async () => {
|
||||
.quote-type-item :deep(.el-form-item__content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.quote-type-item :deep(.el-radio-group) {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.quote-type-item :deep(.el-radio-button__inner) {
|
||||
padding: 14px 26px;
|
||||
font-size: 16px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.quote-type-wrapper {
|
||||
@@ -2027,11 +2084,6 @@ onMounted(async () => {
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.quote-type-item :deep(.el-radio-group) {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.approval-dialog-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -438,8 +438,7 @@
|
||||
ref="submitFormRef"
|
||||
:model="submitForm"
|
||||
:rules="submitRules"
|
||||
label-position="right"
|
||||
label-width="100px"
|
||||
label-position="top"
|
||||
class="crm-dialog-form"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
@@ -1772,8 +1771,34 @@ const DEFAULT_QUOTE_TABLE_REMARK_ROW = `<tr>
|
||||
<td colspan="10" style="padding: 8px; border: 1px solid #000; background-color: #f9f9f9; text-align: center; font-size: 13px;"> </td>
|
||||
</tr>`;
|
||||
|
||||
/** 生成可替换的产品明细区块(产品行 + 合计行,每次从数据库数据渲染) */
|
||||
const buildQuoteDynamicProductSection = (quote: QuoteVO): string => {
|
||||
const QUOTE_TABLE_REMARK_ROW_PATTERN =
|
||||
/<!--\s*备注行\s*-->\s*(<tr[\s\S]*?colspan=["']10["'][\s\S]*?<\/tr>)/i;
|
||||
|
||||
/** 从已保存模板中提取备注行(兼容备注行在合计上方或下方的旧结构) */
|
||||
const extractQuoteTableRemarkRow = (html: string): string => {
|
||||
const withComment = html.match(QUOTE_TABLE_REMARK_ROW_PATTERN);
|
||||
if (withComment?.[1]) return withComment[1];
|
||||
|
||||
const colspan10Rows = [...html.matchAll(/<tr[\s\S]*?colspan=["']10["'][\s\S]*?<\/tr>/gi)].map((m) => m[0]);
|
||||
for (let i = colspan10Rows.length - 1; i >= 0; i--) {
|
||||
const row = colspan10Rows[i];
|
||||
if (!row.includes('合计') && !row.includes('暂无产品信息')) {
|
||||
return row;
|
||||
}
|
||||
}
|
||||
return DEFAULT_QUOTE_TABLE_REMARK_ROW;
|
||||
};
|
||||
|
||||
/** 移除动态区块外重复的备注行(旧模板中备注行在合计下方时) */
|
||||
const removeOrphanRemarkRowAfterDynamicSection = (html: string): string => {
|
||||
return html.replace(
|
||||
/(<!--\s*quote-dynamic-products-end\s*-->)\s*(?:<!--\s*备注行\s*-->\s*)?<tr[\s\S]*?colspan=["']10["'][\s\S]*?<\/tr>/i,
|
||||
'$1'
|
||||
);
|
||||
};
|
||||
|
||||
/** 生成可替换的产品明细区块(产品行 + 备注行 + 合计行,每次从数据库数据渲染) */
|
||||
const buildQuoteDynamicProductSection = (quote: QuoteVO, remarkRow: string = DEFAULT_QUOTE_TABLE_REMARK_ROW): string => {
|
||||
const productRows =
|
||||
quote.productList
|
||||
?.map(
|
||||
@@ -1805,6 +1830,8 @@ const buildQuoteDynamicProductSection = (quote: QuoteVO): string => {
|
||||
|
||||
return `<!-- quote-dynamic-products-start -->
|
||||
${productRows}
|
||||
<!-- 备注行 -->
|
||||
${remarkRow}
|
||||
${totalRow}
|
||||
<!-- quote-dynamic-products-end -->`;
|
||||
};
|
||||
@@ -1815,24 +1842,21 @@ const applyQuoteProductDataToTemplate = (
|
||||
quote: QuoteVO,
|
||||
supplierInfo?: SupplierInfoVO
|
||||
): string => {
|
||||
const dynamicSection = buildQuoteDynamicProductSection(quote);
|
||||
const remarkRow = extractQuoteTableRemarkRow(savedTemplate);
|
||||
const dynamicSection = buildQuoteDynamicProductSection(quote, remarkRow);
|
||||
let result = savedTemplate;
|
||||
if (TEMPLATE_DYNAMIC_PRODUCTS_PATTERN.test(savedTemplate)) {
|
||||
return savedTemplate.replace(TEMPLATE_DYNAMIC_PRODUCTS_PATTERN, dynamicSection);
|
||||
}
|
||||
|
||||
if (!/<tbody[\s\S]*?<\/tbody>/i.test(savedTemplate)) {
|
||||
result = savedTemplate.replace(TEMPLATE_DYNAMIC_PRODUCTS_PATTERN, dynamicSection);
|
||||
} else if (!/<tbody[\s\S]*?<\/tbody>/i.test(savedTemplate)) {
|
||||
return generateQuoteTemplate(quote, supplierInfo);
|
||||
}
|
||||
|
||||
return savedTemplate.replace(/<tbody>([\s\S]*?)<\/tbody>/i, (_match, body: string) => {
|
||||
const remarkRow =
|
||||
body.match(/<tr>[\s\S]*?colspan=["']10["'][\s\S]*?<\/tr>/i)?.[0] || DEFAULT_QUOTE_TABLE_REMARK_ROW;
|
||||
return `<tbody>
|
||||
} else {
|
||||
result = savedTemplate.replace(/<tbody>([\s\S]*?)<\/tbody>/i, () => {
|
||||
return `<tbody>
|
||||
${dynamicSection}
|
||||
<!-- 备注行 -->
|
||||
${remarkRow}
|
||||
</tbody>`;
|
||||
});
|
||||
});
|
||||
}
|
||||
return removeOrphanRemarkRowAfterDynamicSection(result);
|
||||
};
|
||||
|
||||
/** 生成报价单HTML模版 */
|
||||
@@ -1939,8 +1963,6 @@ const generateQuoteTemplate = (quote: QuoteVO, supplierInfo?: SupplierInfoVO): s
|
||||
</thead>
|
||||
<tbody>
|
||||
${dynamicProductSection}
|
||||
<!-- 备注行 -->
|
||||
${DEFAULT_QUOTE_TABLE_REMARK_ROW}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -264,7 +264,7 @@
|
||||
:limit="5"
|
||||
accept="image/*,.pdf"
|
||||
folder="pl/carrier/license"
|
||||
hint="支持 JPG、PNG、PDF,单文件不超过 20MB,最多 5 个"
|
||||
hint="支持 JPG、PNG、PDF,最多 5 个"
|
||||
@update:model-value="handleCertFieldChange('businessLicenseUrls')"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -275,7 +275,7 @@
|
||||
:limit="5"
|
||||
accept="image/*,.pdf"
|
||||
folder="pl/carrier/permit"
|
||||
hint="支持 JPG、PNG、PDF,单文件不超过 20MB,最多 5 个"
|
||||
hint="支持 JPG、PNG、PDF,最多 5 个"
|
||||
@update:model-value="handleCertFieldChange('roadTransportLicenseUrls')"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -286,7 +286,7 @@
|
||||
:limit="5"
|
||||
accept="image/*,.pdf"
|
||||
folder="pl/carrier/agreement"
|
||||
hint="支持 JPG、PNG、PDF,单文件不超过 20MB,最多 5 个"
|
||||
hint="支持 JPG、PNG、PDF,最多 5 个"
|
||||
@update:model-value="handleCertFieldChange('transportAgreementUrls')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
Reference in new issue
Block a user