This commit is contained in:
zlwu committed 2026-06-06 19:44:18 +08:00
1 parent dacfdc9ff6
commit f9676f8759
906 files changed
+533306 -12946

No files matched your search

+3 -2
View File
@@ -21,9 +21,9 @@ export function fetchGetRoleList() {
})
}
/** 流程设计器:用户下拉选项(label=昵称,value=用户ID) */
/** 用户下拉选项(label=昵称,value=用户ID,nickName/userNo 供审批人等场景展示账号) */
export function fetchUserOptions() {
return request.get<Array<{ label: string; value: string }>>({
return request.get<Array<{ label: string; value: string; nickName?: string; userNo?: string }>>({
url: '/web/admin/users/options'
})
}
@@ -562,6 +562,7 @@ export function fetchUploadAttachment(data: FormData) {
fileSize: number
fileSizeDisplay?: string
fileType?: string
bizType?: string
}>({
url: '/web/admin/attachment/upload',
data
+2
View File
@@ -323,6 +323,8 @@ export interface CustomerFollowVO {
followContent: string;
nextTime?: string;
attachments?: string;
ownerUserId?: string;
ownerUserName?: string;
createBy?: string;
createByName?: string;
createTime: string;
@@ -34,6 +34,7 @@ export interface InvoiceInfoForm {
id?: number | string
customerId?: string
companyName: string
taxNo?: string
companyAddress?: string
legalRepresentative?: string
agent?: string
+1
View File
@@ -49,6 +49,7 @@ function mapListQuery(query: QuoteQuery = {}): Record<string, any> {
if (query.opportunityId != null) body.opportunityId = String(query.opportunityId)
if (query.ownerUserId != null) body.ownerUserId = String(query.ownerUserId)
if (query.approvedByUserId != null) body.approvedByUserId = String(query.approvedByUserId)
if (query.pendingApprovalUserId != null) body.pendingApprovalUserId = String(query.pendingApprovalUserId)
return body
}
+1
View File
@@ -14,6 +14,7 @@ export interface QuoteQuery {
quoteType?: string
ownerUserId?: number | string
approvedByUserId?: number | string
pendingApprovalUserId?: number | string
minAmount?: number
maxAmount?: number
isLatest?: number
+110 -6
View File
@@ -27,6 +27,19 @@ body {
--custom-radius: 16px;
}
/* 价格/单价/金额 — 全局(含 append-to-body 弹窗,见 ui-conventions「金额与单价千分位输入与展示」) */
.money-display {
color: #dc2626;
font-weight: 600;
}
.money-field-input .el-input__inner,
.money-field-input .el-input__wrapper input {
color: #dc2626 !important;
-webkit-text-fill-color: #dc2626 !important;
font-weight: 600;
}
// 顶部进度条颜色
#nprogress .bar {
z-index: 2400;
@@ -829,12 +842,6 @@ body {
margin: 0 2px;
}
/* 价格/单价只读展示 — 统一红色(见 ui-conventions「金额与单价千分位输入与展示」) */
.money-display {
color: #dc2626;
font-weight: 600;
}
.list-body {
border-top: 1px solid var(--mint-border);
}
@@ -1066,3 +1073,100 @@ body {
line-height: 56px;
border-bottom: 1px solid var(--mint-border);
}
/* 弹窗 / 查看态内嵌列表面板(CRM 详情 Tab、表单弹窗、选择器等) */
:is(.el-dialog, .system-view-dialog) {
.dialog-list-panel,
.customer-tab-list-panel {
> .list-toolbar:first-child {
border-radius: var(--mint-radius) var(--mint-radius) 0 0;
overflow: hidden;
}
.list-toolbar {
padding: 0;
background: var(--mint-table-head);
border-bottom: none;
}
.list-toolbar-row {
padding: 8px 16px;
gap: 8px;
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-start;
}
.list-toolbar-row .el-button + .el-button {
margin-left: 0 !important;
}
.list-toolbar-selected {
display: inline-flex;
align-items: center;
min-height: 36px;
padding: 0;
margin: 0;
border-right: none;
gap: 8px;
flex-wrap: wrap;
}
.list-body {
border-top: 1px solid var(--mint-border);
}
> .list-body.list-wrap {
overflow: hidden;
border-radius: 0 0 var(--mint-radius) var(--mint-radius);
}
> .list-body.list-wrap.dialog-list-body-only,
> .list-body.list-wrap.customer-tab-list-body-only {
border-top: none;
border-radius: var(--mint-radius);
}
.pagination.list-pager-bar {
margin-top: 0;
padding: 10px 16px 12px;
border-top: none;
background: var(--mint-surface);
border-radius: 0;
display: flex;
justify-content: flex-end;
}
.el-table.list-table {
--el-table-header-bg-color: var(--mint-table-head);
--el-table-border-color: var(--mint-border);
--el-table-row-hover-bg-color: var(--mint-row-hover);
--el-table-text-color: var(--mint-text);
--el-table-header-text-color: var(--mint-text-muted);
font-size: 14px;
width: 100%;
th.el-table__cell {
color: var(--mint-text-muted);
font-size: 12px;
font-weight: 600;
}
td.el-table__cell {
color: var(--mint-text);
font-size: 14px;
font-weight: 500;
.money-display {
color: #dc2626;
font-weight: 600;
}
}
}
.col-actions .cell {
white-space: nowrap;
}
}
}
+25 -6
View File
@@ -291,6 +291,15 @@ html.dark .el-card {
&.el-tag--default {
height: 26px !important;
}
&.el-tag--info {
--el-tag-bg-color: transparent;
--el-tag-border-color: transparent;
--el-tag-text-color: var(--mint-text);
background: transparent !important;
border-color: transparent !important;
color: var(--mint-text) !important;
}
}
.el-checkbox-group {
@@ -636,10 +645,17 @@ body .el-table thead {
border-left: 1px solid var(--el-table-border-color) !important;
}
// 表格单元格内容不换行
// 表格单元格内容不换行;溢出直接截断,不显示省略号「…」(show-overflow-tooltip 悬停仍可看全文)
.el-table .el-table__cell {
text-overflow: clip;
.cell {
white-space: nowrap;
text-overflow: clip;
}
.cell.el-tooltip {
text-overflow: clip;
}
}
@@ -745,13 +761,15 @@ body .el-table thead {
font-weight: 500;
}
// 无操作列主列表:保持与含 btn-op-item 操作列相同的行高;须保留溢出省略(勿用 inline-flex)
// 无操作列主列表:保持与含 btn-op-item 操作列相同的行高;溢出截断勿用 inline-flex
.page-container .list-table.list-table--no-action-col td.el-table__cell > .cell,
.console-container .list-table.list-table--no-action-col td.el-table__cell > .cell {
.console-container .list-table.list-table--no-action-col td.el-table__cell > .cell,
.el-dialog .list-table.list-table--no-action-col td.el-table__cell > .cell,
.system-view-dialog .list-table.list-table--no-action-col td.el-table__cell > .cell {
min-height: 28px;
line-height: 28px;
overflow: hidden;
text-overflow: ellipsis;
text-overflow: clip;
white-space: nowrap;
}
@@ -785,8 +803,9 @@ body .el-table thead {
.page-container .el-tag--info,
.console-container .el-tag--info {
background: #f8fafc !important;
color: #64748b !important;
background: transparent !important;
border-color: transparent !important;
color: var(--mint-text) !important;
}
.page-container .el-tag--primary,
+79 -49
View File
@@ -34,46 +34,47 @@
</el-form>
<!-- 联系人列表 -->
<el-table
v-if="customerId"
v-loading="loading"
:data="contactList"
@current-change="handleCurrentChange"
:max-height="400"
border
highlight-current-row
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="联系人姓名" prop="contactName" min-width="150" :show-overflow-tooltip="true">
<template #default="scope">
<div style="display: flex; align-items: center">
<span style="margin-right: 5px">{{ scope.row.contactName }}</span>
<el-tag v-if="scope.row.isPrimary === 1" type="success" size="small" style="margin-right: 5px">主要</el-tag>
<el-tag v-if="scope.row.isKey === 1" type="warning" size="small">决策人</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="性别" prop="gender" width="80" align="center">
<template #default="scope">
<DictTag dict-type="crm_contact_gender" :value="scope.row.gender" />
</template>
</el-table-column>
<el-table-column label="手机号码" prop="mobile" width="130" />
<el-table-column label="邮箱" prop="email" width="180" :show-overflow-tooltip="true" />
<el-table-column label="部门" prop="department" width="120" :show-overflow-tooltip="true" />
<el-table-column label="职位" prop="position" width="120" :show-overflow-tooltip="true" />
</el-table>
<div v-if="customerId" class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
v-loading="loading"
:data="contactList"
class="list-table"
stripe
highlight-current-row
@current-change="handleCurrentChange"
:max-height="400"
>
<el-table-column type="index" label="序号" min-width="60" align="center" header-align="center" />
<el-table-column label="联系人姓名" prop="contactName" min-width="150" align="center" header-align="center" :show-overflow-tooltip="true">
<template #default="scope">
<div class="contact-name-cell">
<span>{{ scope.row.contactName }}</span>
<el-tag v-if="scope.row.isPrimary === 1" type="success" size="small">主要</el-tag>
<el-tag v-if="scope.row.isKey === 1" type="warning" size="small">决策人</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="性别" prop="gender" min-width="80" align="center" header-align="center">
<template #default="scope">
<DictTag dict-type="crm_contact_gender" :value="scope.row.gender" />
</template>
</el-table-column>
<el-table-column label="手机号码" prop="mobile" min-width="130" align="center" header-align="center" />
<el-table-column label="邮箱" prop="email" min-width="180" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="部门" prop="department" min-width="120" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="职位" prop="position" min-width="120" align="center" header-align="center" :show-overflow-tooltip="true" />
</el-table>
<!-- 分页 -->
<ListPagination
v-if="customerId"
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
class="mt-3"
/>
<ListPagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</div>
</div>
<!-- 当前选中联系人 -->
<template v-if="currentContact">
@@ -132,6 +133,7 @@ watch(
dialogVisible.value = val
if (val && props.customerId) {
await dictStore.loadAllDict()
resetQueryParams()
getList()
}
}
@@ -166,28 +168,34 @@ const total = ref(0)
const currentContact = ref<ContactVO | null>(null)
const queryFormRef = ref<ElFormInstance>()
const resetQueryParams = () => {
queryParams.value.pageNum = 1
queryParams.value.contactName = undefined
queryParams.value.mobile = undefined
queryParams.value.position = undefined
queryFormRef.value?.resetFields()
}
const getList = async () => {
if (!props.customerId) {
contactList.value = []
total.value = 0
return
}
loading.value = true
try {
queryParams.value.customerId = props.customerId
const keyword = [queryParams.value.contactName, queryParams.value.mobile, queryParams.value.position]
.filter(Boolean)
.join(' ')
.trim()
const res = await fetchContactList({
page: queryParams.value.pageNum,
pageSize: queryParams.value.pageSize,
customerId: String(props.customerId),
keyword: keyword || undefined
contactName: queryParams.value.contactName?.trim() || undefined,
mobile: queryParams.value.mobile?.trim() || undefined,
position: queryParams.value.position?.trim() || undefined
})
const data = (res as any)?.list ?? (res as any)?.rows ?? (res as any)?.data?.list ?? []
contactList.value = data
total.value = (res as any)?.total ?? (res as any)?.data?.total ?? data.length
contactList.value = res.list || res.rows || []
total.value = res.total ?? 0
} catch (error) {
console.error('获取联系人列表失败:', error)
} finally {
@@ -201,10 +209,24 @@ const handleQuery = () => {
}
const resetQuery = () => {
queryFormRef.value?.resetFields()
handleQuery()
resetQueryParams()
getList()
}
watchDebounced(
() => ({
contactName: queryParams.value.contactName,
mobile: queryParams.value.mobile,
position: queryParams.value.position
}),
() => {
if (!props.customerId) return
queryParams.value.pageNum = 1
getList()
},
{ debounce: 300, deep: true }
)
const handleCurrentChange = (row: ContactVO) => {
currentContact.value = row
}
@@ -243,4 +265,12 @@ const handleClose = () => {
.form-buttons-right {
margin-left: auto !important;
}
.contact-name-cell {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 5px;
}
</style>
+60 -36
View File
@@ -36,43 +36,53 @@
</el-form>
<!-- 客户列表 -->
<el-table v-loading="loading" :data="customerList" @current-change="handleCurrentChange" :max-height="400" border highlight-current-row>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="客户名称" prop="customerName" min-width="230" :show-overflow-tooltip="true" />
<el-table-column label="客户类型" prop="customerType" width="150" align="center">
<template #default="scope">
<DictTag dict-type="crm_customer_type" :value="scope.row.customerType" />
</template>
</el-table-column>
<el-table-column label="所属行业" prop="industry" width="120" :show-overflow-tooltip="true">
<template #default="scope">
<DictTag dict-type="crm_customer_industry" :value="scope.row.industry" />
</template>
</el-table-column>
<el-table-column label="客户级别" prop="level" width="100" align="center">
<template #default="scope">
<DictTag dict-type="crm_customer_level" :value="scope.row.level" />
</template>
</el-table-column>
<el-table-column label="联系人" prop="contactName" width="100" :show-overflow-tooltip="true" />
<el-table-column label="手机号码" prop="mobile" width="120" />
<el-table-column label="地区" prop="province" width="120">
<template #default="scope">
<span>{{ scope.row.province }} {{ scope.row.city }}</span>
</template>
</el-table-column>
<el-table-column label="负责人" prop="ownerUserName" width="100" :show-overflow-tooltip="true" />
</el-table>
<div class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
v-loading="loading"
:data="customerList"
class="list-table"
stripe
highlight-current-row
@current-change="handleCurrentChange"
: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="customerType" min-width="150" align="center" header-align="center">
<template #default="scope">
<DictTag dict-type="crm_customer_type" :value="scope.row.customerType" />
</template>
</el-table-column>
<el-table-column label="所属行业" prop="industry" min-width="120" align="center" header-align="center" :show-overflow-tooltip="true">
<template #default="scope">
<DictTag dict-type="crm_customer_industry" :value="scope.row.industry" />
</template>
</el-table-column>
<el-table-column label="客户级别" prop="level" min-width="100" align="center" header-align="center">
<template #default="scope">
<DictTag dict-type="crm_customer_level" :value="scope.row.level" />
</template>
</el-table-column>
<el-table-column label="联系人" prop="contactName" min-width="100" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="手机号码" prop="mobile" min-width="120" align="center" header-align="center" />
<el-table-column label="地区" prop="province" min-width="120" align="center" header-align="center">
<template #default="scope">
<span>{{ scope.row.province }} {{ scope.row.city }}</span>
</template>
</el-table-column>
<el-table-column label="负责人" prop="ownerUserName" min-width="100" align="center" header-align="center" :show-overflow-tooltip="true" />
</el-table>
<!-- 分页 -->
<ListPagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
class="mt-3"
/>
<ListPagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</div>
</div>
<!-- 当前选中客户 -->
<template v-if="currentCustomer">
@@ -188,6 +198,20 @@ const resetQuery = () => {
handleQuery()
}
watchDebounced(
() => ({
customerName: queryParams.value.customerName,
customerNo: queryParams.value.customerNo,
customerType: queryParams.value.customerType,
mobile: queryParams.value.mobile
}),
() => {
queryParams.value.pageNum = 1
getList()
},
{ debounce: 300, deep: true }
)
const handleCurrentChange = (row: any) => {
currentCustomer.value = row
}
@@ -22,6 +22,7 @@
import { computed, ref, watch, onBeforeUnmount } from 'vue'
import { PictureFilled } from '@element-plus/icons-vue'
import request from '@/utils/http'
import { resolveCrmAttachmentUrl } from '@/utils/crm-attachment'
const props = defineProps({
src: {
@@ -166,8 +167,15 @@ watch(
}
if (props.isOssId) {
const ossIds = newSrc.split(',').filter((id) => id.trim())
const urls = await getUrlsByOssIds(ossIds)
const ossKeys = newSrc.split(',').filter((id) => id.trim())
// 门户 CRM:MinIO 相对路径(含 /),直接拼公开前缀展示
if (ossKeys.some((key) => key.includes('/'))) {
const urls = ossKeys.map((key) => resolveCrmAttachmentUrl(key)).filter(Boolean)
realSrc.value = urls[0] || ''
realSrcList.value = urls
return
}
const urls = await getUrlsByOssIds(ossKeys)
realSrc.value = urls[0] || ''
realSrcList.value = urls
return
@@ -41,43 +41,53 @@
</el-form>
<!-- 商机列表 -->
<el-table v-loading="loading" :data="opportunityList" @current-change="handleCurrentChange" :max-height="400" border highlight-current-row>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="商机编号" prop="opportunityNo" width="140" align="center" :show-overflow-tooltip="true" />
<el-table-column label="商机名称" prop="opportunityName" min-width="180" :show-overflow-tooltip="true" />
<el-table-column label="客户名称" prop="customerName" width="150" :show-overflow-tooltip="true" />
<el-table-column label="商机阶段" prop="stage" width="120" align="center">
<template #default="scope">
<DictTag dict-type="crm_opportunity_stage" :value="scope.row.stage" />
</template>
</el-table-column>
<el-table-column label="预计成交率" prop="winRate" width="100" align="center">
<template #default="scope">
<el-progress :percentage="scope.row.winRate || 0" :color="getWinRateColor(scope.row.winRate)" />
</template>
</el-table-column>
<el-table-column label="预计成交日期" prop="expectedCloseDate" width="120" align="center">
<template #default="scope">
<span>{{ scope.row.expectedCloseDate || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="负责人" prop="ownerUserName" width="100" :show-overflow-tooltip="true" />
<el-table-column label="状态" prop="status" width="80" align="center">
<template #default="scope">
<DictTag dict-type="crm_opportunity_status" :value="scope.row.status" />
</template>
</el-table-column>
</el-table>
<div class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
v-loading="loading"
:data="opportunityList"
class="list-table"
stripe
highlight-current-row
@current-change="handleCurrentChange"
:max-height="400"
>
<el-table-column type="index" label="序号" min-width="60" align="center" header-align="center" />
<el-table-column label="商机编号" prop="opportunityNo" min-width="140" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="商机名称" prop="opportunityName" min-width="180" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="客户名称" prop="customerName" min-width="150" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="商机阶段" prop="stage" min-width="120" align="center" header-align="center">
<template #default="scope">
<DictTag dict-type="crm_opportunity_stage" :value="scope.row.stage" />
</template>
</el-table-column>
<el-table-column label="预计成交率" prop="winRate" min-width="100" align="center" header-align="center">
<template #default="scope">
<el-progress :percentage="scope.row.winRate || 0" :color="getWinRateColor(scope.row.winRate)" />
</template>
</el-table-column>
<el-table-column label="预计成交日期" prop="expectedCloseDate" min-width="120" align="center" header-align="center">
<template #default="scope">
<span>{{ scope.row.expectedCloseDate || '-' }}</span>
</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="状态" prop="status" min-width="80" align="center" header-align="center">
<template #default="scope">
<DictTag dict-type="crm_opportunity_status" :value="scope.row.status" />
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<ListPagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
class="mt-3"
/>
<ListPagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</div>
</div>
<!-- 当前选中商机 -->
<template v-if="currentOpportunity">
@@ -202,6 +212,20 @@ const resetQuery = () => {
handleQuery()
}
watchDebounced(
() => ({
opportunityName: queryParams.value.opportunityName,
opportunityNo: queryParams.value.opportunityNo,
stage: queryParams.value.stage,
status: queryParams.value.status
}),
() => {
queryParams.value.pageNum = 1
getList()
},
{ debounce: 300, deep: true }
)
const handleCurrentChange = (row: OpportunityVO) => {
currentOpportunity.value = row
}
+68 -34
View File
@@ -39,40 +39,54 @@
</el-form>
<!-- 报价单列表 -->
<el-table v-loading="loading" :data="quoteList" @current-change="handleCurrentChange" :max-height="400" border highlight-current-row>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="报价单编号" prop="quoteNo" width="140" align="center" :show-overflow-tooltip="true" />
<el-table-column label="状态" prop="status" width="100" align="center">
<template #default="scope">
<DictTag dict-type="crm_quote_status" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column label="报价主题" prop="quoteTitle" min-width="180" :show-overflow-tooltip="true" />
<el-table-column label="客户名称" prop="customerName" width="150" :show-overflow-tooltip="true" />
<el-table-column label="合同关联情况" prop="contractLinkedCount" width="120" align="center">
<template #default="scope">
<span>{{ scope.row.contractLinkedCount != null ? `已关联${scope.row.contractLinkedCount}个合同` : '未关联' }}</span>
</template>
</el-table-column>
<el-table-column label="报价金额" prop="finalAmount" width="120" align="right">
<template #default="scope">
<span>{{ scope.row.finalAmount != null ? Number(scope.row.finalAmount).toLocaleString() : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="报价日期" prop="quoteDate" width="120" align="center" />
<el-table-column label="有效期" prop="expireDate" width="120" align="center" />
<el-table-column label="负责人" prop="ownerUserName" width="100" :show-overflow-tooltip="true" />
</el-table>
<div class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
v-loading="loading"
:data="quoteList"
class="list-table"
stripe
highlight-current-row
@current-change="handleCurrentChange"
:max-height="400"
>
<el-table-column type="index" label="序号" min-width="60" align="center" header-align="center" />
<el-table-column label="报价单编号" prop="quoteNo" min-width="140" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="状态" prop="status" min-width="100" align="center" header-align="center">
<template #default="scope">
<DictTag dict-type="crm_quote_status" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column label="报价主题" prop="quoteTitle" min-width="180" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="客户名称" prop="customerName" min-width="150" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="合同关联情况" prop="contractLinkedCount" min-width="120" align="center" header-align="center">
<template #default="scope">
<span>{{ scope.row.contractLinkedCount != null ? `已关联${scope.row.contractLinkedCount}个合同` : '未关联' }}</span>
</template>
</el-table-column>
<el-table-column label="报价金额(元)" prop="finalAmount" min-width="130" align="right" header-align="center">
<template #default="scope">
<span class="money-display">{{
scope.row.finalAmount != null
? `¥${Number(scope.row.finalAmount).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
: '-'
}}</span>
</template>
</el-table-column>
<el-table-column label="报价日期" prop="quoteDate" min-width="120" align="center" header-align="center" />
<el-table-column label="有效期" prop="expireDate" min-width="120" align="center" header-align="center" />
<el-table-column label="负责人" prop="ownerUserName" min-width="100" align="center" header-align="center" :show-overflow-tooltip="true" />
</el-table>
<!-- 分页 -->
<ListPagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
class="mt-3"
/>
<ListPagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</div>
</div>
<!-- 当前选中报价单 -->
<template v-if="currentQuote">
@@ -82,7 +96,13 @@
<el-descriptions-item label="报价标题">{{ currentQuote.quoteTitle }}</el-descriptions-item>
<el-descriptions-item label="报价编号">{{ currentQuote.quoteNo }}</el-descriptions-item>
<el-descriptions-item label="客户名称">{{ currentQuote.customerName }}</el-descriptions-item>
<el-descriptions-item label="报价金额">{{ currentQuote.finalAmount != null ? Number(currentQuote.finalAmount).toLocaleString() : '-' }}</el-descriptions-item>
<el-descriptions-item label="报价金额(元)">
<span class="money-display">{{
currentQuote.finalAmount != null
? `¥${Number(currentQuote.finalAmount).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
: '-'
}}</span>
</el-descriptions-item>
<el-descriptions-item label="报价日期">{{ currentQuote.quoteDate || '-' }}</el-descriptions-item>
<el-descriptions-item label="有效期至">{{ currentQuote.expireDate || '-' }}</el-descriptions-item>
</el-descriptions>
@@ -207,6 +227,20 @@ const resetQuery = () => {
handleQuery()
}
watchDebounced(
() => ({
quoteNo: queryParams.value.quoteNo,
quoteTitle: queryParams.value.quoteTitle,
customerName: queryParams.value.customerName,
status: queryParams.value.status
}),
() => {
queryParams.value.pageNum = 1
getList()
},
{ debounce: 300, deep: true }
)
const handleCurrentChange = async (row: QuoteVO) => {
if (!row) {
currentQuote.value = null
@@ -7,9 +7,42 @@
<script setup lang="ts">
import { ref, watch, computed, onMounted } from 'vue';
import Editor from '@tinymce/tinymce-vue';
import { useUserStore } from '@/store/modules/user';
import { fetchUploadAttachment } from '@/api/admin';
import { resolveCrmAttachmentUrl } from '@/utils/crm-attachment';
import { buildAttachmentUploadFormData, CRM_UPLOAD_BIZ_TYPE } from '@/utils/crm-upload';
const userStore = useUserStore();
/** 须高于 ElConfigProvider 初始 z-index(3000) 及多层 el-dialog 叠加 */
const TINYMCE_Z_INDEX_FLOOR = 10000;
/** 读取当前页面 Element Plus 弹层最高 z-index,保证 TinyMCE 浮层在其上 */
const resolveTinyMceBaseZIndex = (): number => {
let maxZ = 3000;
document.querySelectorAll('.el-overlay, .el-dialog, .el-popper').forEach((el) => {
const z = Number.parseInt(window.getComputedStyle(el).zIndex || '0', 10);
if (!Number.isNaN(z) && z > maxZ) maxZ = z;
});
return Math.max(maxZ + 200, TINYMCE_Z_INDEX_FLOOR);
};
const syncTinyMceFloatingZIndex = (baseZ: number) => {
const z = String(baseZ);
const backdropZ = String(baseZ - 1);
document.querySelectorAll('.tox-tinymce-aux').forEach((el) => {
(el as HTMLElement).style.zIndex = z;
});
document.querySelectorAll('.tox-dialog-wrap').forEach((el) => {
(el as HTMLElement).style.zIndex = z;
});
document.querySelectorAll('.tox-dialog').forEach((el) => {
(el as HTMLElement).style.zIndex = String(baseZ + 1);
});
document.querySelectorAll('.tox-dialog__backdrop').forEach((el) => {
(el as HTMLElement).style.zIndex = backdropZ;
});
document.querySelectorAll('.tox-menu, .tox-pop, .tox-toolbar__overflow, .tox-collection').forEach((el) => {
(el as HTMLElement).style.zIndex = String(baseZ + 2);
});
};
interface Props {
modelValue?: string;
@@ -31,12 +64,6 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits(['update:modelValue']);
// 获取 Token
const getToken = () => userStore.accessToken || '';
// 获取上传 URL
const getUploadUrl = () => '/web/admin/attachment/upload';
// 生成唯一 ID
const editorId = `tinymce-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -82,7 +109,7 @@ const isSXSupplier = computed(() => {
// TinyMCE 配置
const editorInit = computed(() => {
// 根据供方编码决定是否显示背景图片
const baseZIndex = resolveTinyMceBaseZIndex();
const backgroundStyle = isSXSupplier.value
? `
/* 水印背景层 - 使用伪元素覆盖整个编辑器区域 */
@@ -248,66 +275,36 @@ const editorInit = computed(() => {
branding: false,
promotion: false,
// 设置弹窗的 z-index,确保在 Element Plus Dialog 之上
// Element Plus Dialog 默认 z-index 是 2000+,所以设置为 3000
base_z_index: 3000,
// 设置弹窗的 z-index,确保在 Element Plus Dialog 之上(须高于 App.vue ElConfigProvider :z-index="3000")
base_z_index: baseZIndex,
// 图片上传配置
// 图片上传配置(复用门户统一附件接口,与 CRM 其它上传一致)
images_upload_handler: (blobInfo: any, progress: any) => {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('file', blobInfo.blob(), blobInfo.filename());
const xhr = new XMLHttpRequest();
xhr.open('POST', getUploadUrl());
// 设置请求头
const token = getToken();
const clientId = getClientId();
if (token) {
xhr.setRequestHeader('Authorization', 'Bearer ' + token);
}
if (clientId) {
xhr.setRequestHeader('clientid', clientId);
}
// 上传进度
xhr.upload.onprogress = (e) => {
progress((e.loaded / e.total) * 100);
};
// 上传成功
xhr.onload = () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText);
if (json.code === 200) {
// 返回图片URL
resolve(json.data.url);
console.log('图片上传成功:', json.data.url);
} else {
const errorMsg = json.msg || '上传失败';
reject(errorMsg);
console.error('图片上传失败:', errorMsg);
}
} catch (e) {
reject('响应解析失败');
console.error('图片上传失败:响应解析错误');
}
} else {
reject('HTTP错误:' + xhr.status);
console.error('图片上传失败:HTTP ' + xhr.status);
return new Promise(async (resolve, reject) => {
try {
progress(10);
const file = new File([blobInfo.blob()], blobInfo.filename(), {
type: blobInfo.blob().type || 'image/jpeg'
});
const formData = buildAttachmentUploadFormData(file, {
bizType: CRM_UPLOAD_BIZ_TYPE.QUOTE_ATTACHMENT
});
progress(40);
const res = await fetchUploadAttachment(formData);
const fileUrl = res?.fileUrl;
if (!fileUrl) {
reject('上传失败');
return;
}
};
// 上传失败
xhr.onerror = () => {
reject('网络错误');
console.error('图片上传失败:网络错误');
};
xhr.send(formData);
progress(100);
const imageUrl = resolveCrmAttachmentUrl(fileUrl);
resolve(imageUrl);
console.log('图片上传成功:', imageUrl);
} catch (error: any) {
const errorMsg = error?.message || '上传失败';
reject(errorMsg);
console.error('图片上传失败:', errorMsg);
}
});
},
@@ -334,6 +331,20 @@ const editorInit = computed(() => {
// 初始化回调
setup: (editor: any) => {
const refreshFloatingLayers = () => {
const z = resolveTinyMceBaseZIndex();
syncTinyMceFloatingZIndex(z);
};
editor.on('init', () => {
refreshFloatingLayers();
});
editor.on('OpenWindow', refreshFloatingLayers);
editor.on('CloseWindow', refreshFloatingLayers);
editor.on('ShowMenu', refreshFloatingLayers);
editor.on('focus', refreshFloatingLayers);
// 编辑器初始化完成
// 注意:水印已在 content_style 的 body::before 伪元素中设置,无需重复添加
editor.on('init', () => {
@@ -544,20 +555,29 @@ onMounted(() => {
</style>
<style lang="scss">
/* TinyMCE 全局弹窗样式 - 不使用 scoped,确保对所有 TinyMCE 弹窗生效 */
/* TinyMCE 全局浮层:高于 ElConfigProvider z-index(3000) 及嵌套 el-dialog */
.tox-tinymce-aux {
z-index: 3000 !important;
z-index: 10000 !important;
}
.tox-dialog-wrap {
z-index: 3000 !important;
z-index: 10000 !important;
}
.tox-dialog {
z-index: 3001 !important;
z-index: 10001 !important;
}
.tox-dialog__backdrop {
z-index: 2999 !important;
z-index: 9999 !important;
}
.tox-menu,
.tox-pop,
.tox-toolbar__overflow,
.tox-collection,
.tox-swatches,
.tox-selector {
z-index: 10002 !important;
}
</style>
+2
View File
@@ -2,6 +2,7 @@ import './utils/patch-el-select-filterable'
import App from './App.vue'
import { createApp } from 'vue'
import { ElTable } from 'element-plus'
import EmptyAwareElTag from './utils/patch-el-tag-empty'
ElTable.props.border = { type: Boolean, default: true }
@@ -50,6 +51,7 @@ document.addEventListener(
logApiCryptoBootInfo()
const app = createApp(App)
app.component('ElTag', EmptyAwareElTag)
initStore(app)
initRouter(app)
setupGlobDirectives(app)
+58
View File
@@ -95,6 +95,46 @@ export function fillAttachmentInfoCache(
})
}
export type ContractAttachmentItem = {
uid: string
name: string
ossId?: string
url?: string
localPreview?: string
status: 'uploading' | 'success' | 'error'
category: 'single_seal' | 'double_seal' | 'other'
}
/** 合同附件列表 → 保存 API 用的 JSON 字符串 */
export function serializeContractAttachments(items: ContractAttachmentItem[]): string {
const payload = items
.filter((file) => file.status === 'success' && file.ossId)
.map((file) => ({
ossId: String(file.ossId),
category: file.category || 'other'
}))
return payload.length ? JSON.stringify(payload) : ''
}
/** 合同附件列表 → uploadAttachments 接口 payload */
export function mapContractAttachmentsPayload(items: ContractAttachmentItem[]) {
return items
.filter((file) => file.status === 'success' && file.ossId)
.map((file) => ({
ossId: String(file.ossId),
category: file.category || 'other'
}))
}
/** MinIO 相对路径 → 浏览器可访问 URL */
export function resolveCrmAttachmentUrl(fileUrl: string): string {
const trimmed = String(fileUrl || '').trim()
if (!trimmed) return ''
if (/^https?:\/\//i.test(trimmed)) return trimmed
const base = String(import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
return base ? `${base}/${trimmed.replace(/^\//, '')}` : trimmed
}
export function isCrmImageFile(file: { name?: string; url?: string; ossId?: string }): boolean {
const name = file.name || ''
if (/\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(name)) return true
@@ -102,11 +142,29 @@ export function isCrmImageFile(file: { name?: string; url?: string; ossId?: stri
return /\.(jpg|jpeg|png|gif|bmp|webp)(\?|$)/i.test(src)
}
export function isCrmPdfFile(file: { name?: string; url?: string; ossId?: string }): boolean {
const name = file.name || ''
if (/\.pdf$/i.test(name)) return true
const src = file.url || file.ossId || ''
return /\.pdf(\?|$)/i.test(src)
}
export function getCrmFileExt(fileName: string): string {
const ext = fileName.split('.').pop()?.toUpperCase()
return ext || 'FILE'
}
const CRM_ATTACHMENT_CATEGORY_LABELS: Record<string, string> = {
single_seal: '单章附件',
double_seal: '双章附件',
other: '其他附件'
}
/** 附件分类 → 展示文案 */
export function getCrmAttachmentCategoryLabel(category?: string): string {
return CRM_ATTACHMENT_CATEGORY_LABELS[category || 'other'] || CRM_ATTACHMENT_CATEGORY_LABELS.other
}
/** 下载 CRM 附件(fileUrl / ossId 均为 MinIO 路径或完整 URL) */
export async function downloadCrmAttachment(file: {
ossId?: string
+23
View File
@@ -0,0 +1,23 @@
/** CRM 附件上传 bizType(与原 RuoYi /resource/oss/upload 一致) */
export const CRM_UPLOAD_BIZ_TYPE = {
CONTRACT_ATTACHMENT: 'crm_contract_attachment',
CONTRACT_PRICE_LETTER: 'crm_contract_price_letter',
CONTRACT_FILE: 'crm_contract_file',
QUOTE_ATTACHMENT: 'crm_quote_attachment'
} as const
export type CrmUploadBizType = (typeof CRM_UPLOAD_BIZ_TYPE)[keyof typeof CRM_UPLOAD_BIZ_TYPE]
/** 构建门户统一上传 FormData(file + bizType,folder 可选覆盖) */
export function buildAttachmentUploadFormData(
file: File,
options: { bizType: CrmUploadBizType | string; folder?: string }
): FormData {
const formData = new FormData()
formData.append('file', file)
formData.append('bizType', options.bizType)
if (options.folder) {
formData.append('folder', options.folder)
}
return formData
}
+47
View File
@@ -0,0 +1,47 @@
/**
* 全局包装 ElTag:默认插槽无有效内容时不渲染,避免空值时出现带背景色的空 tag。
* 通过 vite Components 自定义 resolver 替换自动导入的 ElTag。
*/
import { ElTag } from 'element-plus'
import { Comment, Text, defineComponent, h, type VNode, type VNodeArrayChildren } from 'vue'
function hasVisibleDefaultContent(vnodes: VNode[] | undefined): boolean {
if (!vnodes?.length) return false
const walk = (nodes: VNodeArrayChildren | undefined): boolean => {
if (!nodes) return false
for (const node of nodes) {
if (node == null || node === false) continue
if (typeof node === 'string' || typeof node === 'number') {
if (String(node).trim()) return true
continue
}
const vnode = node as VNode
if (vnode.type === Comment) continue
if (vnode.type === Text) {
if (String(vnode.children ?? '').trim()) return true
continue
}
if (vnode.type) return true
}
return false
}
return walk(vnodes)
}
const EmptyAwareElTag = defineComponent({
name: 'ElTag',
inheritAttrs: false,
props: ElTag.props,
setup(props, { slots, attrs }) {
return () => {
if (!hasVisibleDefaultContent(slots.default?.())) {
return null
}
return h(ElTag, { ...props, ...attrs }, slots)
}
}
})
export default EmptyAwareElTag
+80 -20
View File
@@ -78,19 +78,27 @@
:type="dictStore.getTagType('crm_follow_type', item.followType)"
size="small"
>
{{ dictStore.getLabel('crm_follow_type', item.followType) }}
{{
dictStore.getLabel('crm_follow_type', item.followType) ||
item.followType ||
'—'
}}
</el-tag>
<span class="follow-creator">{{ item.createByName || '—' }}</span>
<span class="follow-creator">{{
item.createByName || item.createBy || '—'
}}</span>
</div>
<el-button
v-if="hasCluePerm('follow')"
link
type="danger"
size="small"
@click="handleDeleteFollow(item)"
<div
v-if="hasCluePerm('follow') && canManageFollow(item)"
class="follow-actions"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button link type="primary" size="small" @click="handleEditFollow(item)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="danger" size="small" @click="handleDeleteFollow(item)">
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</div>
<div class="follow-content">{{ item.followContent }}</div>
<div v-if="item.nextTime" class="follow-meta follow-meta--warn">
@@ -131,6 +139,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { User, Edit, Delete, Plus, Refresh, CircleClose } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/modules/user'
import SystemViewDialog, {
type ViewField,
type ViewSection
@@ -146,7 +155,9 @@
import InvalidForm from './invalidForm.vue'
const dictStore = useDictStore()
const userStore = useUserStore()
const { loadPerms, hasCluePerm } = useCluePerm()
const currentUserId = computed(() => userStore.info?.userId || '')
const emit = defineEmits(['success'])
const visible = ref(false)
@@ -201,6 +212,17 @@
}
}
function parseClueRemark(remark?: string | null) {
if (!remark?.trim()) return { remarkText: '', invalidReason: '' }
const marker = '无效原因:'
const idx = remark.indexOf(marker)
if (idx === -1) return { remarkText: remark.trim(), invalidReason: '' }
return {
remarkText: remark.slice(0, idx).trim(),
invalidReason: remark.slice(idx + marker.length).trim()
}
}
const viewSections = computed<ViewSection[]>(() => {
const c = clueInfo.value
if (!c?.id) return []
@@ -214,7 +236,7 @@
{ label: '邮箱', value: c.email },
tagField('客户类型', 'crm_customer_type', c.customerType),
{ label: '客户网站', value: c.website, span: true },
tagField('所属行业', 'crm_industry', c.industry),
tagField('所属行业', 'crm_customer_industry', c.industry),
tagField('所在园区', 'crm_customer_park', c.parkArea),
{ label: '地址', value: address, span: true }
]
@@ -250,9 +272,22 @@
})
}
if (c.remark) {
const { remarkText, invalidReason } = parseClueRemark(c.remark)
if (remarkText || invalidReason) {
const remarkFields: ViewField[] = []
if (remarkText) {
remarkFields.push({ label: '备注', value: remarkText, span: true })
}
if (invalidReason) {
remarkFields.push({ label: '无效原因', value: invalidReason, span: true })
}
sections.push({
title: '备注',
title: '备注信息',
fields: remarkFields
})
} else if (c.remark) {
sections.push({
title: '备注信息',
fields: [{ label: '备注', value: c.remark, span: true }]
})
}
@@ -299,10 +334,19 @@
clueFormRef.value?.open(clueId.value)
}
const canManageFollow = (item: ClueFollowVO) => {
if (!item.createBy) return true
return String(item.createBy) === String(currentUserId.value)
}
const handleAddFollow = () => {
followFormRef.value?.open(clueId.value)
}
const handleEditFollow = (item: ClueFollowVO) => {
followFormRef.value?.open(clueId.value, item)
}
const handleConvert = () => {
convertFormRef.value?.open(clueId.value)
}
@@ -397,6 +441,15 @@
align-items: center;
gap: 8px;
flex-wrap: wrap;
min-width: 0;
flex: 1;
}
.follow-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.follow-creator {
@@ -434,23 +487,30 @@
}
.view-split-left .view-hero {
align-items: flex-start;
flex-wrap: wrap;
align-items: center;
}
.view-hero-actions {
margin-left: auto;
flex-shrink: 0;
margin-left: 0;
width: 100%;
flex-basis: 100%;
padding-top: 12px;
margin-top: 4px;
border-top: 1px solid rgba(217, 232, 226, 0.85);
}
.clue-view-hero-actions {
display: flex;
flex-direction: column;
align-items: stretch;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 108px;
min-width: 0;
width: 100%;
.el-button {
width: 100%;
width: auto;
margin: 0;
justify-content: center;
}
+42 -7
View File
@@ -1,7 +1,7 @@
<template>
<el-dialog
v-model="dialog.visible"
title="添加跟进记录"
:title="dialogTitle"
width="720px"
class="dialog-form-layout"
align-center
@@ -82,8 +82,8 @@
<script setup lang="ts">
import { Close, Check } from '@element-plus/icons-vue'
import { addClueFollow } from '@/api/crm/clue'
import type { ClueFollowForm } from '@/api/crm/clue/types'
import { addClueFollow, updateClueFollow, getClueFollow } from '@/api/crm/clue'
import type { ClueFollowForm, ClueFollowVO } from '@/api/crm/clue/types'
import { useDictStore } from '@/store/modules/dict'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
@@ -110,6 +110,8 @@
const formRef = ref()
const attachmentUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const dialogTitle = computed(() => (form.value.id ? '编辑跟进记录' : '添加跟进记录'))
const shortcuts = [
{
text: '今天',
@@ -148,9 +150,36 @@
}
]
const open = async (clueId: string | number) => {
const open = async (clueId: string | number, followData?: ClueFollowVO) => {
reset()
form.value.clueId = clueId as any
if (followData?.id) {
try {
const res = await getClueFollow(followData.id)
const data = res.data || followData
form.value = {
id: data.id,
clueId: data.clueId as any,
followType: data.followType || 'phone',
followContent: data.followContent || '',
nextTime: data.nextTime || undefined,
attachments: data.attachments || undefined,
remark: data.remark || undefined
}
attachmentUrls.value = data.attachments || ''
} catch {
form.value = {
id: followData.id,
clueId: followData.clueId as any,
followType: followData.followType || 'phone',
followContent: followData.followContent || '',
nextTime: followData.nextTime || undefined,
attachments: followData.attachments || undefined,
remark: followData.remark || undefined
}
attachmentUrls.value = followData.attachments || ''
}
}
dialog.visible = true
}
@@ -163,12 +192,18 @@
if (!valid) return
submitting.value = true
try {
await addClueFollow({
const payload = {
...form.value,
clueId: form.value.clueId as any,
attachments: attachmentUrls.value || undefined
})
ElMessage.success('添加成功')
}
if (payload.id) {
await updateClueFollow(payload)
ElMessage.success('修改成功')
} else {
await addClueFollow(payload)
ElMessage.success('添加成功')
}
dialog.visible = false
emit('success')
} catch (error) {
+79 -31
View File
@@ -18,7 +18,7 @@
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<el-divider content-position="left">基本信息</el-divider>
<div class="form-grid-2">
<div class="form-grid-3">
<el-form-item label="线索姓名" prop="clueName">
<el-input v-model="form.clueName" placeholder="请输入线索姓名" maxlength="100" />
</el-form-item>
@@ -50,7 +50,7 @@
</div>
<el-divider content-position="left">详细信息</el-divider>
<div class="form-grid-2">
<div class="form-grid-3">
<el-form-item label="线索来源" prop="source">
<el-select v-model="form.source" placeholder="请选择线索来源" clearable>
<el-option
@@ -74,7 +74,7 @@
<el-form-item label="所属行业" prop="industry">
<el-select v-model="form.industry" placeholder="请选择所属行业" clearable>
<el-option
v-for="opt in dictStore.getOptions('crm_industry')"
v-for="opt in dictStore.getOptions('crm_customer_industry')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
@@ -82,9 +82,16 @@
</el-select>
</el-form-item>
<el-form-item label="所在园区" prop="parkArea">
<el-select v-model="form.parkArea" placeholder="请选择所在园区" clearable filterable>
<el-select
v-model="form.parkArea"
placeholder="请选择或输入所在园区"
clearable
filterable
allow-create
default-first-option
>
<el-option
v-for="opt in dictStore.getOptions('crm_customer_park')"
v-for="opt in parkAreaOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
@@ -100,39 +107,33 @@
<el-form-item label="区县" prop="district">
<el-input v-model="form.district" placeholder="请输入区县" maxlength="50" />
</el-form-item>
<el-form-item label="详细地址" class="form-col-span-2">
<el-input
v-model="form.address"
placeholder="请输入详细地址"
type="textarea"
:rows="2"
maxlength="255"
/>
<el-form-item label="详细地址" class="form-field-span-2">
<el-input v-model="form.address" placeholder="请输入详细地址" maxlength="255" />
</el-form-item>
</div>
<el-divider content-position="left">需求信息</el-divider>
<div class="form-grid-2">
<el-form-item label="意向产品" class="form-col-span-2">
<div class="form-grid-3">
<el-form-item label="意向产品">
<el-input
v-model="form.intentProducts"
placeholder="请输入意向产品,多个用逗号分隔"
maxlength="500"
/>
</el-form-item>
<el-form-item label="需求描述" class="form-col-span-2">
<el-form-item label="需求描述" class="form-field-span-2">
<el-input
v-model="form.requirement"
placeholder="请输入需求描述"
type="textarea"
:rows="3"
:rows="2"
maxlength="500"
/>
</el-form-item>
</div>
<el-divider content-position="left">分配信息</el-divider>
<div class="form-grid-2">
<div class="form-grid-3">
<el-form-item label="负责人" prop="ownerUserId">
<el-select v-model="form.ownerUserId" placeholder="请选择负责人" clearable filterable>
<el-option
@@ -152,14 +153,8 @@
style="width: 100%"
/>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input
v-model="form.remark"
placeholder="请输入备注"
type="textarea"
:rows="3"
maxlength="500"
/>
<el-form-item label="备注">
<el-input v-model="form.remark" placeholder="请输入备注" maxlength="500" />
</el-form-item>
</div>
</el-form>
@@ -179,7 +174,7 @@
<script setup lang="ts">
import { Close, Check } from '@element-plus/icons-vue'
import { getClue, addClue, updateClue, checkDuplicateClue } from '@/api/crm/clue'
import { getClue, addClue, updateClue, checkDuplicateClue, getParkAreaList } from '@/api/crm/clue'
import type { ClueForm as ClueFormType } from '@/api/crm/clue/types'
import { fetchUserOptions } from '@/api/admin'
import { useDictStore } from '@/store/modules/dict'
@@ -227,13 +222,49 @@
})
const userList = ref<any[]>([])
const extraParkAreas = ref<string[]>([])
const parkAreaOptions = computed(() => {
const dictOpts = dictStore.getOptions('crm_customer_park')
const seen = new Set(dictOpts.map((opt) => opt.value))
const options = [...dictOpts]
for (const area of extraParkAreas.value) {
if (area && !seen.has(area)) {
options.push({ label: area, value: area, tagType: 'info' })
seen.add(area)
}
}
const current = form.value.parkArea?.trim()
if (current && !seen.has(current)) {
options.push({ label: current, value: current, tagType: 'info' })
}
return options
})
const dialog = reactive({ visible: false, title: '' })
const submitting = ref(false)
const formRef = ref()
const resolveDefaultOwnerUserId = () => {
const info = userStore.info
const userId = info?.userId ?? info?.id
return userId != null && userId !== '' ? String(userId) : undefined
}
const applyDefaultOwnerUserId = async () => {
const defaultOwnerUserId = resolveDefaultOwnerUserId()
if (!defaultOwnerUserId) return
await nextTick()
form.value.ownerUserId = defaultOwnerUserId
}
const open = async (id?: string | number) => {
reset()
await loadUserList()
dialog.visible = true
await Promise.all([loadUserList(), loadParkAreaList()])
if (id) {
dialog.title = '修改线索'
@@ -245,14 +276,13 @@
form.value.ownerUserId = data.ownerUserId || undefined
} catch (error) {
console.error(error)
dialog.visible = false
return
}
} else {
dialog.title = '新增线索'
form.value.ownerUserId = userStore.info?.userId as any
await applyDefaultOwnerUserId()
}
dialog.visible = true
}
const loadUserList = async () => {
@@ -269,6 +299,17 @@
}
}
const loadParkAreaList = async () => {
try {
const res = await getParkAreaList()
const list = (res as any)?.data ?? res ?? []
extraParkAreas.value = Array.isArray(list) ? list.filter(Boolean) : []
} catch (error) {
console.error(error)
extraParkAreas.value = []
}
}
const submitForm = () => {
formRef.value?.validate(async (valid: boolean) => {
if (!valid) return
@@ -342,8 +383,15 @@
remark: undefined
}
submitting.value = false
extraParkAreas.value = []
formRef.value?.resetFields()
}
defineExpose({ open })
</script>
<style scoped lang="scss">
.form-grid-3 .form-field-span-2 {
grid-column: span 2;
}
</style>
+18 -19
View File
@@ -79,7 +79,7 @@
<el-form-item label="所属行业">
<el-select v-model="searchForm.industry" placeholder="全部" clearable>
<el-option
v-for="opt in dictStore.getOptions('crm_industry')"
v-for="opt in dictStore.getOptions('crm_customer_industry')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
@@ -181,20 +181,6 @@
>
<el-icon><CircleClose /></el-icon>标为无效
</el-button>
<el-button v-if="hasCluePerm('import')" class="btn-ghost" @click="handleImport">
<el-icon><Upload /></el-icon>导入
</el-button>
<el-button
v-if="hasCluePerm('export')"
class="btn-ghost"
:loading="exportLoading"
@click="handleExport"
>
<el-icon><Download /></el-icon>导出
</el-button>
<el-button class="btn-ghost" @click="handleColumnSetting">
<el-icon><Setting /></el-icon>列设置
</el-button>
</div>
</div>
@@ -309,10 +295,10 @@
<template #default="{ row }">
<el-tag
v-if="row.industry"
:type="dictStore.getTagType('crm_industry', row.industry)"
:type="dictStore.getTagType('crm_customer_industry', row.industry)"
size="small"
>
{{ dictStore.getLabel('crm_industry', row.industry) }}
{{ dictStore.getLabel('crm_customer_industry', row.industry) }}
</el-tag>
<span v-else>—</span>
</template>
@@ -551,7 +537,6 @@
RefreshLeft,
View,
User,
Setting,
ArrowUpBold,
ArrowDownBold,
Edit,
@@ -826,7 +811,11 @@
ElMessage.warning('所选线索均不可标记无效')
return
}
invalidFormRef.value?.openBatch(selected.map((item) => item.id))
if (selected.length === 1) {
invalidFormRef.value?.open(selected[0])
return
}
invalidFormRef.value?.openBatch(selected)
}
async function handleDelete(row?: any) {
@@ -978,6 +967,16 @@
}
}
watchDebounced(
() => ({ ...searchForm, dateRange: dateRange.value }),
() => {
quickSearchKeyword.value = ''
pagination.page = 1
getList()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadPerms()
initColumnSetting()
+279 -43
View File
@@ -2,8 +2,8 @@
<el-dialog
v-model="dialogVisible"
title="标记无效线索"
width="600px"
class="dialog-form-layout"
width="640px"
class="dialog-form-layout invalid-clue-dialog"
align-center
destroy-on-close
:close-on-click-modal="false"
@@ -18,15 +18,15 @@
class="mb-4"
/>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="线索信息">
<el-descriptions :column="1" size="small" border>
<el-descriptions-item label="线索姓名">{{ clueInfo.clueName }}</el-descriptions-item>
<el-descriptions-item label="公司名称">{{
clueInfo.company || '—'
}}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ clueInfo.mobile || '—' }}</el-descriptions-item>
<el-descriptions-item label="线索等级">
<section v-if="!isBatch" v-loading="detailLoading" class="invalid-dialog-section">
<h3 class="invalid-section-title">线索信息</h3>
<div class="view-hero invalid-clue-hero">
<div class="view-hero-icon">
<el-icon :size="28"><User /></el-icon>
</div>
<div class="view-hero-main">
<h4 class="view-hero-title">{{ displayName }}</h4>
<div class="view-hero-tags">
<el-tag
v-if="clueInfo.level"
:type="dictStore.getTagType('crm_clue_level', clueInfo.level)"
@@ -34,22 +34,89 @@
>
{{ dictStore.getLabel('crm_clue_level', clueInfo.level) }}
</el-tag>
<span v-else>—</span>
</el-descriptions-item>
</el-descriptions>
</el-form-item>
<el-tag
v-if="clueInfo.status"
:type="dictStore.getTagType('crm_clue_status', clueInfo.status)"
size="small"
>
{{ dictStore.getLabel('crm_clue_status', clueInfo.status) }}
</el-tag>
</div>
<p class="view-hero-meta">{{ heroMeta }}</p>
</div>
</div>
<el-form-item label="详细说明" prop="remark">
<el-input
v-model="form.remark"
type="textarea"
:rows="4"
placeholder="请说明标记无效的原因"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
<dl class="view-kv-grid">
<div class="view-kv">
<dt>公司名称</dt>
<dd>{{ clueInfo.company || '—' }}</dd>
</div>
<div class="view-kv">
<dt>手机号</dt>
<dd>{{ clueInfo.mobile || '—' }}</dd>
</div>
<div class="view-kv">
<dt>线索来源</dt>
<dd>{{ dictStore.getLabel('crm_clue_source', clueInfo.source) || '—' }}</dd>
</div>
<div class="view-kv">
<dt>负责人</dt>
<dd>{{ clueInfo.ownerUserName || '—' }}</dd>
</div>
<div class="view-kv">
<dt>跟进次数</dt>
<dd>{{ clueInfo.followCount ?? 0 }} 次</dd>
</div>
<div class="view-kv">
<dt>最后跟进</dt>
<dd>{{ clueInfo.lastTime || '—' }}</dd>
</div>
</dl>
</section>
<section v-else class="invalid-dialog-section">
<h3 class="invalid-section-title">待标记线索({{ batchClues.length }} 条)</h3>
<el-table :data="batchClues" class="list-table invalid-batch-table" stripe max-height="240">
<el-table-column prop="clueName" label="线索姓名" min-width="120" show-overflow-tooltip />
<el-table-column prop="company" label="公司名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="mobile" label="手机号" min-width="120" />
<el-table-column prop="level" label="线索等级" min-width="100">
<template #default="{ row }">
<el-tag
v-if="row.level"
:type="dictStore.getTagType('crm_clue_level', row.level)"
size="small"
>
{{ dictStore.getLabel('crm_clue_level', row.level) }}
</el-tag>
<span v-else>—</span>
</template>
</el-table-column>
</el-table>
</section>
<section class="invalid-dialog-section">
<h3 class="invalid-section-title">无效说明</h3>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<el-form-item label="详细说明" prop="remark">
<el-input
v-model="form.remark"
type="textarea"
:rows="4"
placeholder="请说明标记无效的原因"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
</section>
<template #footer>
<div class="dialog-footer">
@@ -65,8 +132,9 @@
</template>
<script setup lang="ts">
import { Close, Check } from '@element-plus/icons-vue'
import { markInvalid } from '@/api/crm/clue'
import { Close, Check, User } from '@element-plus/icons-vue'
import { getClue, markInvalid } from '@/api/crm/clue'
import type { ClueVO } from '@/api/crm/clue/types'
import { useDictStore } from '@/store/modules/dict'
const dictStore = useDictStore()
@@ -74,7 +142,10 @@
const dialogVisible = ref(false)
const loading = ref(false)
const clueInfo = ref<any>({})
const detailLoading = ref(false)
const isBatch = ref(false)
const clueInfo = ref<ClueVO>({} as ClueVO)
const batchClues = ref<ClueVO[]>([])
const formRef = ref()
const targetIds = ref<(string | number)[]>([])
@@ -86,32 +157,84 @@
remark: [{ required: true, message: '请填写详细说明', trigger: 'blur' }]
}
const open = (clue: any) => {
clueInfo.value = { ...clue }
targetIds.value = [clue.id]
const displayName = computed(() => clueInfo.value.clueName || '—')
const heroMeta = computed(() => {
const parts = [
clueInfo.value.company,
clueInfo.value.mobile,
clueInfo.value.ownerUserName ? `负责人:${clueInfo.value.ownerUserName}` : ''
].filter(Boolean)
return parts.join(' · ') || '—'
})
const reset = () => {
isBatch.value = false
clueInfo.value = {} as ClueVO
batchClues.value = []
targetIds.value = []
form.remark = ''
dialogVisible.value = true
detailLoading.value = false
loading.value = false
formRef.value?.resetFields()
}
const openBatch = (ids: (string | number)[]) => {
if (!ids?.length) {
const open = async (clue: ClueVO | Record<string, any>) => {
reset()
isBatch.value = false
dialogVisible.value = true
detailLoading.value = true
try {
if (clue?.id) {
const res = await getClue(clue.id)
clueInfo.value = (res.data || clue) as ClueVO
} else {
clueInfo.value = { ...clue } as ClueVO
}
targetIds.value = [clueInfo.value.id]
} catch (error) {
console.error(error)
clueInfo.value = { ...clue } as ClueVO
targetIds.value = clue?.id ? [clue.id] : []
} finally {
detailLoading.value = false
}
}
const openBatch = (clues: ClueVO[] | (string | number)[]) => {
if (!clues?.length) {
ElMessage.warning('请选择要标记无效的线索')
return
}
clueInfo.value = {
clueName: `已选择 ${ids.length} 条线索`,
company: '',
mobile: '',
level: ''
reset()
isBatch.value = true
if (typeof clues[0] === 'string' || typeof clues[0] === 'number') {
batchClues.value = (clues as (string | number)[]).map((id) => ({
id: id as number,
clueName: `线索 #${id}`,
mobile: '',
company: '',
source: '',
status: ''
}))
} else {
batchClues.value = [...(clues as ClueVO[])]
}
targetIds.value = [...ids]
form.remark = ''
targetIds.value = batchClues.value.map((item) => item.id)
dialogVisible.value = true
}
const submitForm = () => {
formRef.value?.validate(async (valid: boolean) => {
if (!valid) return
if (!targetIds.value.length) {
ElMessage.warning('未找到要标记无效的线索')
return
}
loading.value = true
try {
await markInvalid({ ids: targetIds.value, remark: form.remark })
@@ -127,9 +250,122 @@
}
const handleClose = () => {
formRef.value?.resetFields()
dialogVisible.value = false
reset()
}
defineExpose({ open, openBatch })
</script>
<style scoped lang="scss">
.invalid-dialog-section + .invalid-dialog-section {
margin-top: 16px;
}
.invalid-section-title {
margin: 0 0 12px;
font-size: 15px;
font-weight: 600;
color: var(--text, #1e293b);
&::before {
content: '';
display: inline-block;
width: 3px;
height: 14px;
margin-right: 8px;
vertical-align: -2px;
background: var(--primary, #10b981);
border-radius: 2px;
}
}
.invalid-clue-hero {
margin-bottom: 12px;
border: 1px solid var(--border, #d9e8e2);
border-radius: 10px;
overflow: hidden;
}
.view-hero {
display: flex;
gap: 16px;
padding: 16px 18px;
background: linear-gradient(135deg, #ecfdf5 0%, #ffffff 55%);
align-items: center;
}
.view-hero-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
color: var(--primary, #10b981);
background: #fff;
border: 1px solid #a7f3d0;
border-radius: 12px;
}
.view-hero-main {
min-width: 0;
flex: 1;
}
.view-hero-title {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: var(--text, #1e293b);
}
.view-hero-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 6px;
}
.view-hero-meta {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-muted, #64748b);
}
.view-kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 20px;
margin: 0;
}
.view-kv {
min-width: 0;
dt {
margin: 0 0 4px;
font-size: 12px;
color: var(--text-muted, #64748b);
}
dd {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: var(--text, #1e293b);
word-break: break-word;
}
}
.invalid-batch-table {
width: 100%;
}
@media (max-width: 560px) {
.view-kv-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,509 @@
<template>
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
ref="uploadRef"
:http-request="handleAttachmentUpload"
:show-file-list="false"
:before-upload="handleBeforeUpload"
:disabled="disabled"
multiple
:limit="limit"
:accept="accept"
>
<div class="upload-card">
<el-icon :size="28" color="#10b981"><Plus /></el-icon>
<span class="upload-card-text">上传附件</span>
</div>
</el-upload>
<div
v-for="(file, idx) in attachmentList"
:key="file.uid || idx"
class="attachment-item"
:class="{ 'is-uploading': file.status === 'uploading' }"
>
<div
class="attachment-preview"
:class="{ 'is-clickable': canPreviewAttachment(file) }"
@click.stop="canPreviewAttachment(file) ? handleFilePreview(file) : undefined"
>
<img
v-if="isCrmImageFile(file) && getAttachmentThumbSrc(file)"
:src="getAttachmentThumbSrc(file)"
:alt="file.name"
class="attachment-thumb-img"
/>
<template v-else-if="file.status === 'uploading'">
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
</template>
<template v-else>
<div class="attachment-preview__icon attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
<span v-if="getFileExtension(file.name)" class="attachment-preview__ext">
{{ getFileExtension(file.name) }}
</span>
</div>
</template>
<div v-if="file.status === 'uploading'" class="attachment-uploading-mask">
<el-icon class="is-loading" :size="22"><Loading /></el-icon>
</div>
</div>
<div class="attachment-footer">
<span class="attachment-name" :title="file.name">{{ getShortFileName(file.name) }}</span>
<div class="attachment-category">
<el-select
v-model="file.category"
placeholder="请选择分类"
size="small"
style="width: 100%"
:disabled="disabled || !getAttachmentOssId(file)"
>
<el-option label="单章附件" value="single_seal" />
<el-option label="双章附件" value="double_seal" />
<el-option label="其他附件" value="other" />
</el-select>
</div>
<div v-if="getAttachmentOssId(file) && !disabled" class="attachment-actions">
<el-icon class="action-btn" title="预览" @click.stop="handleFilePreview(file)">
<ZoomIn />
</el-icon>
<el-icon class="action-btn" title="下载" @click.stop="handleDownloadAttachment(file)">
<Download />
</el-icon>
<el-icon class="action-btn action-btn--danger" title="删除" @click.stop="handleRemoveAttachmentConfirm(file)">
<Delete />
</el-icon>
</div>
</div>
</div>
</div>
<span v-if="hint" class="upload-hint">{{ hint }}</span>
<el-dialog
v-model="attachmentPreviewVisible"
:title="attachmentPreviewFile?.name || '附件预览'"
width="860px"
align-center
destroy-on-close
append-to-body
modal-class="order-module-dialog-mask"
@closed="handleAttachmentPreviewClosed"
>
<img
v-if="attachmentPreviewFile && isCrmImageFile(attachmentPreviewFile) && attachmentPreviewSrc"
:src="attachmentPreviewSrc"
:alt="attachmentPreviewFile.name"
class="attachment-preview-media"
/>
<iframe
v-else-if="attachmentPreviewFile && isCrmPdfFile(attachmentPreviewFile) && attachmentPreviewSrc"
:src="attachmentPreviewSrc"
class="attachment-preview-media attachment-preview-media--iframe"
title="PDF 预览"
/>
<div v-else class="attachment-preview-fallback">
<el-icon :size="40" color="#94a3b8"><Document /></el-icon>
<p>该文件类型暂不支持在线预览,请使用下载查看。</p>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import type { UploadInstance, UploadRequestOptions } from 'element-plus'
import { ElMessage } from 'element-plus'
import { Plus, Document, Loading, Download, Delete, ZoomIn } from '@element-plus/icons-vue'
import { fetchUploadAttachment } from '@/api/admin'
import { $modal } from '@/utils/modal'
import {
type ContractAttachmentItem,
downloadCrmAttachment,
isCrmImageFile,
isCrmPdfFile,
resolveCrmAttachmentUrl
} from '@/utils/crm-attachment'
import { buildAttachmentUploadFormData, type CrmUploadBizType } from '@/utils/crm-upload'
const props = withDefaults(
defineProps<{
bizType: CrmUploadBizType | string
limit?: number
accept?: string
hint?: string
disabled?: boolean
}>(),
{
limit: 10,
accept: 'image/*,.pdf,.doc,.docx',
hint: '支持上传图片、PDF、Word文档等,最多10个文件',
disabled: false
}
)
const attachmentList = defineModel<ContractAttachmentItem[]>({ default: () => [] })
const uploadRef = ref<UploadInstance>()
const attachmentPreviewVisible = ref(false)
const attachmentPreviewFile = ref<ContractAttachmentItem | null>(null)
const attachmentPreviewSrc = ref('')
const getAttachmentOssId = (file: ContractAttachmentItem) => String(file.ossId || file.url || '').trim()
const getAttachmentThumbSrc = (file: ContractAttachmentItem) => {
if (file.localPreview) return file.localPreview
const ossId = getAttachmentOssId(file)
return ossId ? resolveCrmAttachmentUrl(ossId) : ''
}
const patchAttachmentFile = (uid: string, patch: Partial<ContractAttachmentItem>) => {
const idx = attachmentList.value.findIndex((item) => item.uid === uid)
if (idx < 0) return
const prev = attachmentList.value[idx]
if (prev.localPreview && patch.status === 'success') {
URL.revokeObjectURL(prev.localPreview)
}
attachmentList.value[idx] = {
...prev,
...patch,
localPreview: patch.status === 'success' ? undefined : prev.localPreview
}
}
const canPreviewAttachment = (file: ContractAttachmentItem) =>
Boolean(file.localPreview || getAttachmentOssId(file))
const resolveAttachmentPreviewSrc = (file: ContractAttachmentItem) => {
if (file.localPreview) return file.localPreview
const ossId = getAttachmentOssId(file)
if (!ossId) return ''
if (isCrmImageFile(file) || isCrmPdfFile(file)) {
return resolveCrmAttachmentUrl(ossId)
}
return ''
}
const handleAttachmentPreviewClosed = () => {
attachmentPreviewFile.value = null
attachmentPreviewSrc.value = ''
}
const getFileExtension = (fileName: string) => {
if (!fileName) return ''
const lastDotIndex = fileName.lastIndexOf('.')
if (lastDotIndex === -1) return ''
return fileName.substring(lastDotIndex + 1).toUpperCase()
}
const getShortFileName = (fileName: string) => {
if (!fileName) return ''
const lastDotIndex = fileName.lastIndexOf('.')
if (lastDotIndex === -1) return fileName.length > 12 ? fileName.substring(0, 12) + '...' : fileName
const name = fileName.substring(0, lastDotIndex)
const ext = fileName.substring(lastDotIndex)
if (name.length <= 8) return fileName
return name.substring(0, 8) + '...' + ext
}
const handleBeforeUpload = (file: File) => {
const isValidType = /\.(jpg|jpeg|png|gif|bmp|webp|pdf|doc|docx|txt|mp4|avi|mov)$/i.test(file.name)
if (!isValidType) {
$modal.msgError('上传文件格式不正确!')
return false
}
return true
}
const handleAttachmentUpload = async (options: UploadRequestOptions) => {
if (attachmentList.value.length >= props.limit) {
ElMessage.warning(`最多上传 ${props.limit} 个附件`)
options.onError?.(new Error('超出数量限制'))
return
}
const uid = `upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const localPreview = isCrmImageFile({ name: options.file.name })
? URL.createObjectURL(options.file)
: undefined
attachmentList.value.push({
uid,
name: options.file.name,
status: 'uploading',
category: 'other',
localPreview
})
const formData = buildAttachmentUploadFormData(options.file, { bizType: props.bizType })
try {
const res = await fetchUploadAttachment(formData)
const fileUrl = String(res?.fileUrl || (res as { url?: string })?.url || '').trim()
if (!fileUrl) {
throw new Error('上传失败')
}
patchAttachmentFile(uid, {
ossId: fileUrl,
url: fileUrl,
name: res?.fileName || options.file.name,
status: 'success'
})
options.onSuccess?.(res)
$modal.msgSuccess('文件上传成功')
} catch (error: any) {
const failed = attachmentList.value.find((item) => item.uid === uid)
if (failed?.localPreview) {
URL.revokeObjectURL(failed.localPreview)
}
attachmentList.value = attachmentList.value.filter((item) => item.uid !== uid)
$modal.msgError(error?.message || '文件上传失败')
options.onError?.(error)
} finally {
await nextTick()
uploadRef.value?.clearFiles()
}
}
const handleFilePreview = (file: ContractAttachmentItem) => {
if (!canPreviewAttachment(file)) {
$modal.msgWarning('文件尚未上传完成')
return
}
attachmentPreviewFile.value = file
attachmentPreviewSrc.value = resolveAttachmentPreviewSrc(file)
attachmentPreviewVisible.value = true
}
const handleRemoveAttachmentConfirm = (file: ContractAttachmentItem) => {
$modal
.confirm('确定要删除这个文件吗?')
.then(() => {
const index = attachmentList.value.findIndex((item) => item.uid === file.uid)
if (index > -1) {
const removed = attachmentList.value[index]
if (removed?.localPreview) {
URL.revokeObjectURL(removed.localPreview)
}
attachmentList.value.splice(index, 1)
$modal.msgSuccess('文件删除成功')
}
})
.catch(() => {})
}
const handleDownloadAttachment = async (file: ContractAttachmentItem) => {
if (!getAttachmentOssId(file)) {
$modal.msgError('无法获取文件下载地址')
return
}
try {
await downloadCrmAttachment(file)
} catch (error) {
console.error('下载文件失败:', error)
$modal.msgError('下载文件失败,请稍后重试')
}
}
const hasUploading = () => attachmentList.value.some((item) => item.status === 'uploading')
defineExpose({ hasUploading })
</script>
<style scoped lang="scss">
.attachment-upload {
width: 100%;
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.upload-card {
width: 140px;
height: 96px;
border: 1px dashed #a7f3d0;
border-radius: 8px;
background: #f0fdf4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
&:hover {
border-color: #10b981;
border-style: solid;
background: #ecfdf5;
}
}
.upload-card-text {
font-size: 12px;
color: #10b981;
font-weight: 500;
line-height: 1;
}
.upload-hint {
display: block;
font-size: 11px;
color: #94a3b8;
margin-top: 8px;
line-height: 1;
}
.attachment-item {
width: 148px;
min-height: 200px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
overflow: hidden;
display: flex;
flex-direction: column;
&.is-uploading {
opacity: 0.85;
}
&:hover {
border-color: #10b981;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1);
}
}
.attachment-preview {
position: relative;
width: 100%;
height: 110px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
overflow: hidden;
&.is-clickable {
cursor: zoom-in;
}
}
.attachment-thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
cursor: pointer;
}
.attachment-uploading-mask {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.72);
z-index: 2;
}
.attachment-preview__ext {
margin-top: 4px;
font-size: 11px;
font-weight: 600;
color: #64748b;
}
.attachment-preview__icon {
width: 48px;
min-height: 48px;
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
&--doc {
background: #e0f2fe;
color: #0284c7;
}
}
.attachment-footer {
display: flex;
flex-direction: column;
padding: 6px 8px;
}
.attachment-name {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 11px;
color: #334155;
line-height: 1.4;
word-break: break-all;
}
.attachment-category {
margin-top: 6px;
:deep(.el-select) {
width: 100%;
}
}
.attachment-actions {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 4px;
}
.action-btn {
width: 22px;
height: 22px;
border-radius: 4px;
background: #f1f5f9;
color: #64748b;
cursor: pointer;
&:hover {
background: #10b981;
color: #fff;
}
&--danger:hover {
background: #ef4444;
color: #fff;
}
}
.attachment-preview-media {
display: block;
width: 100%;
max-height: 70vh;
object-fit: contain;
margin: 0 auto;
border-radius: 8px;
background: #f8fafc;
}
.attachment-preview-media--iframe {
height: 70vh;
border: 0;
}
.attachment-preview-fallback {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 240px;
color: #64748b;
font-size: 14px;
}
</style>
@@ -0,0 +1,337 @@
<template>
<SystemViewDialog
v-model:visible="visible"
:title="contractInfo.contractName || '合同详情'"
:icon="Document"
width="calc(100vw - 100px)"
:code="contractInfo.contractNo"
code-label="合同编号"
:meta="viewHeroMeta"
:sections="viewSections"
:loading="loading"
:grid-columns="2"
dialog-class="contract-view-dialog"
modal-class="order-module-dialog-mask"
append-to-body
>
<template #products>
<div class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
:data="contractInfo.products || []"
class="list-table"
stripe
empty-text="暂无产品明细"
>
<el-table-column type="index" label="序号" min-width="60" align="center" header-align="center" />
<el-table-column label="产品名称" prop="productName" min-width="140" align="center" header-align="center" show-overflow-tooltip />
<el-table-column label="型号/规格" prop="productModel" min-width="120" align="center" header-align="center" show-overflow-tooltip>
<template #default="{ row }">{{ row.productModel || row.model || '—' }}</template>
</el-table-column>
<el-table-column label="单位" prop="unit" min-width="80" align="center" header-align="center">
<template #default="{ row }">
<el-tag
v-if="row.unit"
:type="dictStore.getTagType('crm_product_unit', row.unit)"
size="small"
>
{{ dictStore.getLabel('crm_product_unit', row.unit) }}
</el-tag>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column label="数量" prop="quantity" min-width="90" align="right" header-align="right" />
<el-table-column label="单价" min-width="110" align="right" header-align="right">
<template #default="{ row }">¥{{ formatAmount(row.price) }}</template>
</el-table-column>
<el-table-column label="小计" min-width="110" align="right" header-align="right">
<template #default="{ row }">¥{{ formatAmount(row.subtotal) }}</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<template #attachments>
<AttachmentUploadField
v-if="attachmentValue"
:model-value="attachmentValue"
disabled
/>
<p v-else class="view-empty-hint">暂无附件</p>
</template>
<template #approval>
<div v-if="approvalList.length" class="contract-approval-flow-scroll">
<el-timeline>
<el-timeline-item
v-for="item in approvalList"
:key="item.id"
:timestamp="item.approvalTime || '—'"
placement="top"
>
<div class="contract-approval-card">
<div class="contract-approval-card-head">
<span class="contract-approval-card-user">{{ item.approverName || '—' }}</span>
<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-if="item.approvalResult === 'pending'"
type="info"
size="small"
>待审批</el-tag>
</div>
<p v-if="item.approvalOpinion" class="contract-approval-opinion">
{{ item.approvalOpinion }}
</p>
</div>
</el-timeline-item>
</el-timeline>
</div>
<p v-else class="view-empty-hint">暂无审批记录</p>
</template>
</SystemViewDialog>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Document } from '@element-plus/icons-vue'
import SystemViewDialog, {
type ViewField,
type ViewSection
} from '@/components/business/system-view-dialog/index.vue'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { useDictStore } from '@/store/modules/dict'
import { getContract, listContractApproval } from '@/api/crm/contract'
import { getOpportunity } from '@/api/crm/opportunity'
import { getQuote } from '@/api/crm/quote'
import type { ContractApprovalVO, ContractVO } from '@/api/crm/contract/types'
const dictStore = useDictStore()
const visible = ref(false)
const loading = ref(false)
const contractInfo = ref<ContractVO>({} as ContractVO)
const approvalList = ref<ContractApprovalVO[]>([])
function formatAmount(amount?: number | string | null) {
if (amount == null || amount === '') return '0.00'
const num = typeof amount === 'string' ? parseFloat(amount) : amount
if (Number.isNaN(num)) return '0.00'
return num.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
function tagField(
label: string,
dictType: string,
value?: string | null,
extra?: Partial<ViewField>
): ViewField {
return {
label,
value: value ? dictStore.getLabel(dictType, value) : '—',
tagType: value ? dictStore.getTagType(dictType, value) : undefined,
...extra
}
}
const attachmentValue = computed(() => {
const c = contractInfo.value
const parts = [c.contractFile, c.attachmentFiles].filter(Boolean)
return parts.join(',')
})
const viewHeroMeta = computed(() => {
const c = contractInfo.value
const parts: string[] = []
if (c.customerName) parts.push(c.customerName)
if (c.status) parts.push(dictStore.getLabel('crm_contract_status', c.status))
if (c.signDate) parts.push(`签订 ${c.signDate}`)
return parts.join(' · ')
})
const viewSections = computed<ViewSection[]>(() => {
const c = contractInfo.value
if (!c?.id) return []
const basicFields: ViewField[] = [
tagField('合同类型', 'crm_contract_type', c.contractType),
tagField('合同模版', 'crm_contract_template', c.contractTemplate),
{ label: '客户名称', value: c.customerName },
{ label: '关联商机', value: c.opportunityName || '—' },
{ label: '关联报价单', value: c.quoteNo || '—' },
{ label: '联系人', value: c.contactName },
{ label: '联系电话', value: c.contactMobile },
{ label: '签订日期', value: c.signDate },
{ label: '合同期限', value: c.startDate && c.endDate ? `${c.startDate} ~ ${c.endDate}` : '—' },
{ label: '负责人', value: c.ownerUserName },
tagField('合同状态', 'crm_contract_status', c.status),
tagField('审批状态', 'crm_approval_status', c.approvalStatus)
]
const amountFields: ViewField[] = [
{ label: '合同金额', value: `¥${formatAmount(c.amount)}`, money: true },
{ label: '已回款金额', value: `¥${formatAmount(c.receivedAmount)}`, money: true },
{ label: '未回款金额', value: `¥${formatAmount(c.unreceivedAmount)}`, money: true },
{ label: '已开票金额', value: `¥${formatAmount(c.invoiceAmount)}`, money: true }
]
const partyFields: ViewField[] = [
{ label: '甲方', value: c.partyA },
{ label: '甲方联系人', value: c.partyAContact },
{ label: '甲方电话', value: c.partyAMobile },
{ label: '乙方', value: c.partyB },
{ label: '乙方联系人', value: c.partyBContact },
{ label: '乙方电话', value: c.partyBMobile },
tagField('结算方式', 'crm_settlement_method', c.settlementMethod)
]
const sections: ViewSection[] = [
{ title: '基本信息', fields: basicFields },
{ title: '金额信息', fields: amountFields },
{ title: '签约主体', fields: partyFields },
{
title: '产品明细',
fields: [],
slot: 'products'
},
{
title: '合同附件',
fields: [],
slot: 'attachments'
},
{
title: '审批记录',
fields: [],
slot: 'approval'
}
]
if (c.paymentTerms) {
sections.push({
title: '付款条款',
fields: [{ label: '付款条款', value: c.paymentTerms, span: true }]
})
}
if (c.deliveryTerms) {
sections.push({
title: '交付条款',
fields: [{ label: '交付条款', value: c.deliveryTerms, span: true }]
})
}
if (c.remark) {
sections.push({
title: '备注',
fields: [{ label: '备注', value: c.remark, span: true }]
})
}
return sections
})
async function loadDetail(id: string | number) {
loading.value = true
try {
await dictStore.loadAllDict()
const res = await getContract(id)
contractInfo.value = (res as any)?.data || res || ({} as ContractVO)
if (contractInfo.value?.opportunityId) {
try {
const oppRes = await getOpportunity(contractInfo.value.opportunityId)
if (oppRes?.data) {
contractInfo.value.opportunityName = oppRes.data.opportunityName
}
} catch {
// ignore
}
}
if (contractInfo.value?.quoteId) {
try {
const quoteRes = await getQuote(contractInfo.value.quoteId)
if (quoteRes?.data) {
contractInfo.value.quoteNo = quoteRes.data.quoteNo
}
} catch {
// ignore
}
}
try {
const approvalRes = await listContractApproval(id)
approvalList.value = (approvalRes as any)?.data || approvalRes || []
} catch {
approvalList.value = []
}
} catch (error) {
console.error(error)
contractInfo.value = {} as ContractVO
approvalList.value = []
ElMessage.error('加载合同详情失败')
} finally {
loading.value = false
}
}
async function open(id: string | number) {
visible.value = true
contractInfo.value = {} as ContractVO
approvalList.value = []
await loadDetail(id)
}
defineExpose({ open })
</script>
<style scoped lang="scss">
.contract-approval-flow-scroll {
max-height: 420px;
overflow-y: auto;
padding-right: 4px;
}
.contract-approval-card {
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.contract-approval-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.contract-approval-card-user {
font-weight: 600;
color: #1e293b;
}
.contract-approval-opinion {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.5;
color: #64748b;
}
</style>
<style lang="scss">
.contract-view-dialog.system-view-dialog {
&.el-dialog {
max-width: calc(100vw - 100px);
}
}
</style>
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,14 @@
<template>
<el-dialog title="选择产品" v-model="dialogVisible" width="80%" :close-on-click-modal="false" append-to-body>
<el-dialog
title="选择产品"
v-model="dialogVisible"
width="80%"
align-center
append-to-body
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
>
<!-- 查询表单 -->
<el-form :model="queryParams" ref="queryFormRef" label-width="90px" class="mb-3">
<el-row :gutter="20">
@@ -24,64 +33,76 @@
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<el-form-item label="产品状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 100%">
<el-option label="在售" value="on" />
<el-option label="下架" value="off" />
<el-option
v-for="opt in dictStore.getOptions('crm_product_status')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24" style="text-align: right">
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
<el-button type="primary" :loading="loading" @click="handleQuery">
<el-icon><Search /></el-icon>搜索
</el-button>
<el-button :loading="loading" @click="resetQuery">
<el-icon><Refresh /></el-icon>重置
</el-button>
</el-col>
</el-row>
</el-form>
<!-- 产品列表 -->
<el-table
ref="tableRef"
v-loading="loading"
:data="productList"
@selection-change="handleSelectionChange"
@row-dblclick="handleRowDblClick"
:max-height="400"
border
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="产品编号" prop="productNo" width="140" align="center" :show-overflow-tooltip="true" />
<el-table-column label="产品名称" prop="productName" min-width="180" :show-overflow-tooltip="true" />
<el-table-column label="产品分类" prop="categoryName" width="120" />
<el-table-column label="规格型号" prop="model" width="120" :show-overflow-tooltip="true" />
<el-table-column label="计量单位" prop="unit" width="100" align="center">
<template #default="scope">
<DictTag dict-type="crm_product_unit" :value="scope.row.unit" />
</template>
</el-table-column>
<el-table-column label="税率(%)" prop="taxRate" width="90" align="center">
<template #default="scope">
<span>{{ scope.row.taxRate || 0 }}%</span>
</template>
</el-table-column>
<el-table-column label="状态" prop="status" width="90" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 'on' ? 'success' : 'info'">
{{ scope.row.status === 'on' ? '在售' : '下架' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<div class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
ref="tableRef"
v-loading="loading"
:data="productList"
class="list-table"
stripe
@selection-change="handleSelectionChange"
@row-click="handleRowClick"
:max-height="400"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="60" align="center" header-align="center" />
<el-table-column label="产品编号" prop="productNo" min-width="140" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="产品名称" prop="productName" min-width="180" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="产品分类" prop="categoryName" min-width="120" align="center" header-align="center" />
<el-table-column label="规格型号" prop="model" min-width="120" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="计量单位" prop="unit" min-width="100" align="center" header-align="center">
<template #default="scope">
<DictTag dict-type="crm_product_unit" :value="scope.row.unit" />
</template>
</el-table-column>
<el-table-column label="税率(%)" prop="taxRate" min-width="90" align="center" header-align="center">
<template #default="scope">
<span>{{ scope.row.taxRate || 0 }}%</span>
</template>
</el-table-column>
<el-table-column label="状态" prop="status" min-width="90" align="center" header-align="center">
<template #default="scope">
<el-tag :type="dictStore.getTagType('crm_product_status', scope.row.status)" size="small">
{{ dictStore.getLabel('crm_product_status', scope.row.status) }}
</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<pagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
class="mt-3"
/>
<pagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
class="pagination list-pager-bar"
/>
</div>
</div>
<!-- 已选产品 -->
<el-divider content-position="left">已选产品 ({{ selectedProducts.length }})</el-divider>
@@ -94,8 +115,12 @@
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取 消</el-button>
<el-button type="primary" @click="handleConfirm" :disabled="selectedProducts.length === 0"> 确定 ({{ selectedProducts.length }}) </el-button>
<el-button class="btn-ghost" @click="handleClose">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleConfirm" :disabled="selectedProducts.length === 0">
<el-icon><Check /></el-icon>确定 ({{ selectedProducts.length }})
</el-button>
</div>
</template>
</el-dialog>
@@ -104,13 +129,18 @@
<script setup lang="ts" name="ProductSelector">
import { ref, watch } from 'vue'
import type { ElFormInstance } from 'element-plus'
import { Check, Close, Refresh, Search } from '@element-plus/icons-vue'
import { fetchProductList, fetchCategoryTree } from '@/api/crm/product'
import DictTag from '@/components/DictTag/index.vue'
import { useDictStore } from '@/store/modules/dict'
const dictStore = useDictStore()
interface ProductVO {
id: string | number
productNo?: string
productName?: string
categoryId?: string | number
categoryName?: string
model?: string
unit?: string
@@ -192,6 +222,10 @@ const getList = async () => {
const data = (res as any)?.data || res || {};
productList.value = data.list || data.rows || [];
total.value = data.total ?? 0;
// 如果分类树已加载,立即补全 categoryName
if (categoryTree.value.length > 0) {
resolveCategoryNames()
}
} catch (error) {
console.error('获取产品列表失败:', error);
} finally {
@@ -205,6 +239,8 @@ const loadCategoryTree = async () => {
const res = await fetchCategoryTree();
const tree = (res as any)?.data || res || [];
categoryTree.value = convertTreeData(tree);
// 分类树加载后,补全已有产品的 categoryName
resolveCategoryNames()
} catch (error) {
console.error('获取产品分类失败:', error);
}
@@ -219,6 +255,29 @@ const convertTreeData = (data: ProductCategoryVO[]): any[] => {
}));
};
/** 从分类树中解析分类名称 */
const resolveCategoryName = (categoryId: any, tree: any[]): string => {
for (const node of tree) {
if (node.value === categoryId || node.id === categoryId) return node.label || ''
if (node.children) {
const found = resolveCategoryName(categoryId, node.children)
if (found) return found
}
}
return ''
}
/** 补全产品列表中缺失的 categoryName */
const resolveCategoryNames = () => {
if (!categoryTree.value.length) return
productList.value.forEach((product) => {
if (!product.categoryName && product.categoryId != null) {
const catId = (product as any).categoryId
product.categoryName = resolveCategoryName(catId, categoryTree.value)
}
})
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.value.pageNum = 1;
@@ -231,16 +290,30 @@ const resetQuery = () => {
handleQuery();
};
watchDebounced(
() => ({
productName: queryParams.value.productName,
categoryId: queryParams.value.categoryId,
status: queryParams.value.status,
}),
() => {
queryParams.value.pageNum = 1;
getList();
},
{ debounce: 300, deep: true }
);
/** 多选框选中数据 */
const handleSelectionChange = (selection: ProductVO[]) => {
selectedProducts.value = selection;
};
/** 双击行选中/取消选中产品 */
const handleRowDblClick = (row: ProductVO) => {
// 切换行的选中状态
tableRef.value?.toggleRowSelection(row);
};
/** 单击行选中/取消选中产品(点击复选框列时不重复切换) */
const handleRowClick = (row: ProductVO, _column: unknown, event: MouseEvent) => {
const target = event.target as HTMLElement
if (target.closest('.el-checkbox')) return
tableRef.value?.toggleRowSelection(row)
}
/** 移除已选产品 */
const handleRemoveSelection = (product: ProductVO) => {
@@ -6,94 +6,22 @@
align-center
append-to-body
destroy-on-close
class="dialog-form-layout"
class="dialog-form-layout upload-scan-dialog"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
>
<div class="attachment-upload-container">
<el-upload
ref="uploadRef"
:file-list="attachmentList"
class="upload-demo"
drag
list-type="picture-card"
:on-preview="handleFilePreview"
:action="uploadAction"
:headers="uploadHeaders"
:data="uploadData"
:auto-upload="true"
:before-upload="handleBeforeUpload"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:on-remove="handleRemoveAttachmentConfirm"
:on-change="handleFileChange"
multiple
:limit="10"
:show-file-list="true"
accept="image/*,.pdf,.doc,.docx"
>
<template #tip>
<div class="el-upload__tip">支持图片、PDF、Word文档等格式,最多上传10个文件。</div>
</template>
<ContractAttachmentPanel
v-model="attachmentList"
:biz-type="CRM_UPLOAD_BIZ_TYPE.CONTRACT_ATTACHMENT"
hint="支持上传图片、PDF、Word文档等,最多10个文件"
/>
<!-- 调整拖拽上传图标与文字在同一行、居中对齐 -->
<div class="el-upload-dragger1">
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
</div>
<!-- 自定义文件卡片缩略图(含文件名) -->
<template #file="{ file }">
<div class="upload-file-card">
<!-- 缩略图区域 -->
<div class="upload-file-thumb">
<ImagePreview v-if="isImageFile(file) && getFileOssId(file)" :src="getFileOssId(file)" class="upload-thumb-image" :isOssId="true" />
<div v-else class="upload-file-placeholder">
<el-icon><Document /></el-icon>
<span class="file-ext">{{ getFileExt(file.name) }}</span>
</div>
<!-- loading 遮罩只在上传中显示 -->
<div v-if="file.status === 'uploading'" class="upload-file-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span class="loading-text">上传中...</span>
</div>
</div>
<!-- 文件名称 -->
<div class="upload-file-name" :title="file.name">
{{ getShortFileName(file.name) }}
</div>
<!-- 附件分类选择 -->
<div class="upload-file-category" v-if="file.status && file.status !== 'ready'">
<el-select v-model="file.category" placeholder="选择附件分类" size="small" style="width: 100%" @change="handleCategoryChange(file)">
<el-option label="单章附件" value="single_seal" />
<el-option label="双章附件" value="double_seal" />
<el-option label="其他附件" value="other" />
</el-select>
</div>
<!-- 操作按钮区域 - 固定在文件名下方 -->
<div class="upload-file-card-actions" v-if="file.status && file.status !== 'ready'">
<el-button-group size="small">
<el-button type="success" @click.stop="handleDownloadAttachment(file)" title="下载" :disabled="file.status !== 'success'">
<el-icon><Download /></el-icon>
</el-button>
<el-button type="danger" @click.stop="handleRemoveAttachmentConfirm(file)" title="删除">
<el-icon><Delete /></el-icon>
</el-button>
</el-button-group>
</div>
</div>
</template>
</el-upload>
</div>
<template #footer>
<div class="upload-dialog-actions">
<el-button @click="handleClose">
<el-icon><Close /></el-icon>关闭
</el-button>
<el-button type="primary" @click="handleSaveAndClose">
<el-button type="primary" :loading="saveLoading" @click="handleSaveAndClose">
<el-icon><Check /></el-icon>保存并关闭
</el-button>
</div>
@@ -102,480 +30,95 @@
</template>
<script setup lang="ts" name="UploadDialog">
import { ref, computed } from 'vue'
import { UploadFilled, Document, Loading, Download, Delete, Close, Check } from '@element-plus/icons-vue'
import ImagePreview from '@/components/ImagePreview/index.vue'
import { ref } from 'vue'
import { Close, Check } from '@element-plus/icons-vue'
import ContractAttachmentPanel from './ContractAttachmentPanel.vue'
import { uploadAttachments, getContract } from '@/api/crm/contract'
import { $modal } from '@/utils/modal'
import { useUserStore } from '@/store/modules/user'
import {
buildUploadFileListFromField,
downloadCrmAttachment
mapContractAttachmentsPayload,
type ContractAttachmentItem
} from '@/utils/crm-attachment'
import { CRM_UPLOAD_BIZ_TYPE } from '@/utils/crm-upload'
const userStore = useUserStore()
const emit = defineEmits(['success'])
// 定义事件
const emit = defineEmits(['success']);
const dialogVisible = ref(false)
const contractId = ref<number>()
const attachmentList = ref<ContractAttachmentItem[]>([])
const saveLoading = ref(false)
// 对话框显示状态
const dialogVisible = ref(false);
// 合同ID
const contractId = ref<number>();
// 文件列表
const attachmentList = ref<any[]>([]);
// 已上传成功的附件列表(包含ossId和category)
const uploadedAttachments = ref<Array<{ ossId: string; category: string }>>([]);
// 上传地址
const uploadAction = computed(() => '/web/admin/attachment/upload');
// 上传请求头
const uploadHeaders = computed(() => ({
Authorization: `Bearer ${userStore.accessToken || ''}`
}));
// 上传额外数据
const uploadData = computed(() => {
return {
bizType: 'crm_contract_file'
};
});
const uploadRef = ref();
/** 打开对话框 */
const open = async (id: number) => {
contractId.value = id;
dialogVisible.value = true;
attachmentList.value = [];
uploadedAttachments.value = [];
contractId.value = id
dialogVisible.value = true
attachmentList.value = []
await loadExistingAttachments()
}
// 加载已有的附件数据
await loadExistingAttachments();
};
/** 加载已有的附件数据 */
const loadExistingAttachments = async () => {
if (!contractId.value) return;
if (!contractId.value) return
try {
const res = await getContract(contractId.value);
const contract = res.data;
if (!contract?.attachmentFiles) return;
attachmentList.value = buildUploadFileListFromField(contract.attachmentFiles);
uploadedAttachments.value = attachmentList.value
.filter((file) => file.ossId)
.map((file) => ({
ossId: String(file.ossId),
category: String(file.category || 'other')
}));
const res = await getContract(contractId.value)
const contract = res.data
if (!contract?.attachmentFiles) return
attachmentList.value = buildUploadFileListFromField(
contract.attachmentFiles
) as ContractAttachmentItem[]
} catch (error) {
console.error('[附件回显] 加载附件数据失败:', error);
console.error('[附件回显] 加载附件数据失败:', error)
}
};
}
/** 文件类型判断 */
const isImageFile = (file: any) => {
const imageTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/webp'];
// 1. 检查 raw.type(新上传的文件)
if (file.raw?.type && imageTypes.includes(file.raw.type)) {
return true;
const validateAttachments = () => {
if (attachmentList.value.some((file) => file.status === 'uploading')) {
$modal.msgWarning('仍有附件正在上传,请稍候')
return false
}
// 2. 检查文件名扩展名
if (file.name && /\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(file.name)) {
return true;
}
// 3. 检查 URL 扩展名(回显的文件)
if (file.url && /\.(jpg|jpeg|png|gif|bmp|webp)(\?|$)/i.test(file.url)) {
return true;
}
// 4. 如果有 ossId,尝试通过 URL 判断(即使 URL 可能被清空)
if (file.ossId && file.url && file.url.includes('image')) {
return true;
}
return false;
};
/** 获取文件OSS ID */
const getFileOssId = (file: any) => {
return file.ossId || '';
};
/** 获取文件扩展名 */
const getFileExt = (fileName: string) => {
const ext = fileName.split('.').pop()?.toUpperCase();
return ext || 'FILE';
};
/** 获取缩写的文件名 */
const getShortFileName = (fileName: string) => {
if (!fileName) return '';
const lastDotIndex = fileName.lastIndexOf('.');
if (lastDotIndex === -1) {
return fileName.length > 12 ? fileName.substring(0, 12) + '...' : fileName;
}
const name = fileName.substring(0, lastDotIndex);
const ext = fileName.substring(lastDotIndex);
if (name.length <= 8) {
return fileName;
}
return name.substring(0, 8) + '...' + ext;
};
/** 上传前检查 */
const handleBeforeUpload = (file: any) => {
const isValidType = /\.(jpg|jpeg|png|gif|bmp|webp|pdf|doc|docx|txt)$/i.test(file.name);
if (!isValidType) {
$modal.msgError('上传文件格式不正确!')
return false;
}
return true;
};
/** 上传成功 */
const handleUploadSuccess = (response: any, file: any) => {
const payload = response?.data ?? response;
const url = payload?.url || payload?.fileUrl || payload;
if (url) {
file.url = url;
file.ossId = url;
file.status = 'success';
file.category = file.category || 'other';
uploadedAttachments.value.push({
ossId: url,
category: file.category
});
$modal.msgSuccess('文件上传成功');
} else {
file.status = 'error';
$modal.msgError(response?.msg || '文件上传失败');
}
};
/** 附件分类变更 */
const handleCategoryChange = (file: any) => {
// 更新已上传附件列表中的分类
const index = uploadedAttachments.value.findIndex((item) => item.ossId === file.ossId);
if (index !== -1) {
uploadedAttachments.value[index].category = file.category;
}
};
/** 保存附件到数据库 */
const saveAttachments = async () => {
if (!contractId.value) {
return;
}
// 检查是否有附件未选择分类
const unclassifiedFiles = attachmentList.value.filter((file) => file.status === 'success' && !file.category);
if (unclassifiedFiles.length > 0) {
const unclassified = attachmentList.value.filter(
(file) => file.status === 'success' && file.ossId && !file.category
)
if (unclassified.length > 0) {
$modal.msgWarning('请为所有附件选择分类')
return;
return false
}
return true
}
// 即使没有附件也允许保存(用于清空附件的场景)
await uploadAttachments({
id: contractId.value,
attachments: uploadedAttachments.value
});
};
/** 上传失败 */
const handleUploadError = (error: any) => {
console.error('文件上传失败:', error);
$modal.msgError('文件上传失败')
};
/** 文件预览 */
const handleFilePreview = (file: any) => {
if (isImageFile(file)) {
window.open(file.url);
} else {
handleDownloadAttachment(file);
}
};
/** 文件变更 */
const handleFileChange = (file: any, fileList: any) => {
attachmentList.value = fileList;
};
/** 删除附件确认 */
const handleRemoveAttachmentConfirm = (file: any) => {
$modal
.confirm('确定要删除这个文件吗?')
.then(() => {
handleRemoveAttachment(file);
})
.catch(() => {
// 取消删除
});
};
/** 删除附件 */
const handleRemoveAttachment = (file: any) => {
const index = attachmentList.value.findIndex((item) => item.uid === file.uid);
if (index !== -1) {
attachmentList.value.splice(index, 1);
}
// 从已上传附件列表中移除
if (file.ossId) {
const uploadIndex = uploadedAttachments.value.findIndex((item) => item.ossId === file.ossId);
if (uploadIndex !== -1) {
uploadedAttachments.value.splice(uploadIndex, 1);
}
}
};
/** 下载附件 */
const handleDownloadAttachment = async (file: any) => {
if (!file.ossId && !file.url) {
$modal.msgError('文件信息不完整,无法下载');
return;
}
try {
await downloadCrmAttachment(file);
$modal.msgSuccess('下载成功');
} catch (error) {
console.error('下载附件失败:', error);
$modal.msgError('下载失败');
}
};
/** 保存并关闭 */
const handleSaveAndClose = async () => {
// 检查是否有附件未选择分类
const unclassifiedFiles = attachmentList.value.filter((file) => file.status === 'success' && !file.category);
if (unclassifiedFiles.length > 0) {
$modal.msgWarning('请为所有附件选择分类')
return;
}
if (!contractId.value) return
if (!validateAttachments()) return
saveLoading.value = true
try {
await saveAttachments();
await uploadAttachments({
id: contractId.value,
attachments: mapContractAttachmentsPayload(attachmentList.value)
})
$modal.msgSuccess('保存成功')
emit('success');
dialogVisible.value = false;
attachmentList.value = [];
uploadedAttachments.value = [];
emit('success')
handleClose()
} catch (error) {
console.error('保存附件失败:', error);
console.error('保存附件失败:', error)
$modal.msgError('保存附件失败')
} finally {
saveLoading.value = false
}
};
}
/** 关闭对话框 */
const handleClose = () => {
dialogVisible.value = false;
attachmentList.value = [];
uploadedAttachments.value = [];
};
dialogVisible.value = false
attachmentList.value = []
}
// 暴露方法给父组件调用
defineExpose({
open
});
defineExpose({ open })
</script>
<style scoped lang="scss">
/* 上传区域容器样式 */
.attachment-upload-container {
width: 100%;
max-height: calc(100vh - 300px);
min-height: 200px;
overflow-y: auto;
padding-right: 8px;
border: 1px solid var(--border, #d9e8e2);
border-radius: 8px;
background: var(--primary-bg, #ecfdf5);
}
.attachment-upload-container .upload-demo {
padding: 12px;
min-height: 180px;
}
.attachment-upload-container .upload-demo .el-upload {
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: all 0.3s;
}
.attachment-upload-container .upload-demo .el-upload:hover {
border-color: var(--primary, #10b981);
}
.attachment-upload-container .upload-demo .el-upload__tip {
font-size: 12px;
color: var(--text-muted, #64748b);
margin-top: 7px;
}
/* 调整拖拽上传图标与文字在同一行、居中对齐 */
.attachment-upload-container .upload-demo .el-upload-dragger1 {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 15px;
padding: 0px !important;
}
.attachment-upload-container .upload-demo .el-upload-dragger1 .el-icon--upload {
margin: 0;
padding: 0px !important;
}
/* 文件卡片占位封面样式 */
.attachment-upload-container .upload-demo .upload-file-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #909399;
font-size: 14px;
}
.attachment-upload-container .upload-demo .upload-file-placeholder .el-icon {
font-size: 28px;
margin-bottom: 4px;
}
.attachment-upload-container .upload-demo .upload-file-placeholder .file-ext {
font-size: 12px;
text-transform: uppercase;
text-align: center;
}
/* 让附件卡片一行展示 5 个 */
.attachment-upload-container :deep(.el-upload-list--picture-card) {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 9px;
margin-top: 12px;
padding-bottom: 16px;
}
/* 确保卡片宽度自适应,增加高度以容纳分类选择框 */
.attachment-upload-container :deep(.el-upload-list--picture-card) .el-upload-list__item {
margin: 0;
height: 220px !important;
width: 100% !important;
}
:deep(.el-upload-dragger) {
border: 0px !important;
padding: 0px !important;
}
:deep(.el-upload-dragger) .el-upload-dragger1 {
border: 0px !important;
padding: 0px !important;
}
/* 文件卡片样式 */
.upload-file-card {
position: relative;
width: 100%;
height: 100%;
border-radius: 6px;
overflow: hidden;
background: #fff;
transition: all 0.3s;
}
.upload-file-card:hover {
}
.upload-file-thumb {
position: relative;
width: 100%;
height: 110px;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.upload-thumb-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.upload-file-loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #fff;
font-size: 12px;
}
.upload-file-loading .el-icon {
font-size: 24px;
margin-bottom: 4px;
}
.upload-file-loading .loading-text {
margin-top: 4px;
}
.upload-file-name {
padding: 8px;
font-size: 12px;
color: #333;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: #fff;
border-top: 1px solid #e4e7ed;
}
.upload-file-category {
padding: 4px 8px;
background: #fff;
border-top: 1px solid #e4e7ed;
}
.upload-file-card-actions {
padding: 6px 8px;
display: flex;
justify-content: center;
gap: 8px;
background: #fafafa;
border-top: 1px solid #e4e7ed;
transition: all 0.3s;
height: 38px;
}
.upload-file-card-actions .el-button {
padding: 4px 8px;
font-size: 14px;
.upload-scan-dialog {
:deep(.el-dialog__body) {
padding-top: 12px;
}
}
.upload-dialog-actions {
File diff suppressed because it is too large. Load diff
@@ -3,7 +3,7 @@
v-model:visible="visible"
:title="customerInfo.customerName || '客户详情'"
:icon="User"
width="1320px"
width="calc(100vw - 100px)"
layout="split"
:code="customerInfo.customerNo"
code-label="客户编号"
@@ -11,6 +11,8 @@
:sections="viewSections"
:loading="detailLoading"
dialog-class="customer-view-dialog"
modal-class="order-module-dialog-mask"
append-to-body
>
<template #certificates>
<div class="cert-list">
@@ -66,10 +68,13 @@
>
{{ dictStore.getLabel('crm_follow_type', item.followType) }}
</el-tag>
<span class="follow-creator">{{ item.createByName || item.createBy }}</span>
<span class="follow-creator">{{
item.ownerUserName || item.createByName || '—'
}}</span>
</div>
<el-button
v-if="
item.ownerUserName === userStore.info.realName ||
item.createByName === userStore.info.realName ||
userStore.info.roles?.includes('admin')
"
@@ -110,141 +115,246 @@
</el-tab-pane>
<el-tab-pane label="联系人" name="contact">
<div class="tab-toolbar">
<el-button class="btn-main" type="primary" size="small" @click="handleAddContact">
<el-icon><Plus /></el-icon>添加联系人
</el-button>
</div>
<div class="view-subtable-wrap">
<el-table
v-loading="contactLoading"
:data="contactList"
class="view-subtable"
stripe
empty-text=""
>
<el-table-column label="联系人" prop="contactName" min-width="120" />
<el-table-column label="性别" min-width="80">
<template #default="{ row }">
{{
row.gender === 'male' ? '男' : row.gender === 'female' ? '女' : '—'
}}
</template>
</el-table-column>
<el-table-column label="手机号" prop="mobile" min-width="130" />
<el-table-column label="职位" prop="position" min-width="120" />
<el-table-column label="角色" min-width="100">
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('crm_contact_role', row.role)"
size="small"
>
{{ dictStore.getLabel('crm_contact_role', row.role) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="邮箱" prop="email" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" fixed="right" min-width="260" class-name="col-actions">
<template #default="{ row }">
<el-button
v-if="row.isPrimary !== 1"
link
type="primary"
size="small"
@click="handleSetPrimary(row)"
>
<el-icon><Star /></el-icon>设为主要
<div class="panel dialog-list-panel">
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
<el-button class="btn-main" type="primary" @click="handleAddContact">
<el-icon><Plus /></el-icon>添加联系人
</el-button>
<el-button link type="primary" size="small" @click="handleEditContact(row)">
<el-icon><Edit /></el-icon>修改
</el-button>
<el-button link type="danger" size="small" @click="handleDeleteContact(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
<div class="list-body list-wrap">
<el-table
v-loading="contactLoading"
:data="contactList"
class="list-table"
stripe
empty-text=""
>
<el-table-column
label="联系人"
prop="contactName"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column label="性别" min-width="80" align="center" header-align="center">
<template #default="{ row }">
{{
row.gender === 'male' ? '男' : row.gender === 'female' ? '女' : '—'
}}
</template>
</el-table-column>
<el-table-column
label="手机号"
prop="mobile"
min-width="130"
align="center"
header-align="center"
/>
<el-table-column
label="职位"
prop="position"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column label="角色" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('crm_contact_role', row.role)"
size="small"
>
{{ dictStore.getLabel('crm_contact_role', row.role) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="邮箱"
prop="email"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
label="操作"
fixed="right"
min-width="260"
align="center"
header-align="center"
class-name="col-actions"
>
<template #default="{ row }">
<el-button
v-if="row.isPrimary !== 1"
link
type="primary"
@click="handleSetPrimary(row)"
>
<el-icon><Star /></el-icon>设为主要
</el-button>
<el-button link type="primary" @click="handleEditContact(row)">
<el-icon><Edit /></el-icon>修改
</el-button>
<el-button link type="danger" @click="handleDeleteContact(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="商机" name="opportunity">
<div class="view-subtable-wrap">
<el-table
v-loading="opportunityLoading"
:data="opportunityList"
class="view-subtable"
stripe
empty-text=""
>
<el-table-column label="商机名称" prop="opportunityName" min-width="180" show-overflow-tooltip />
<el-table-column label="商机阶段" min-width="120">
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('crm_opportunity_stage', row.stage)"
size="small"
>
{{ dictStore.getLabel('crm_opportunity_stage', row.stage) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="预计成交日期" prop="expectDealDate" min-width="120" />
<el-table-column label="负责人" prop="ownerUserName" min-width="120" />
<el-table-column label="创建时间" prop="createTime" min-width="160" />
<el-table-column label="操作" fixed="right" min-width="120" class-name="col-actions">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleViewOpportunity(row.id)">
<el-icon><View /></el-icon>查看
</el-button>
</template>
</el-table-column>
</el-table>
<div class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
v-loading="opportunityLoading"
:data="opportunityList"
class="list-table"
stripe
empty-text=""
>
<el-table-column
label="商机名称"
prop="opportunityName"
min-width="180"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="商机阶段" min-width="120" align="center" header-align="center">
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('crm_opportunity_stage', row.stage)"
size="small"
>
{{ dictStore.getLabel('crm_opportunity_stage', row.stage) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="预计成交日期"
prop="expectDealDate"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
label="负责人"
prop="ownerUserName"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
label="创建时间"
prop="createTime"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
fixed="right"
min-width="120"
align="center"
header-align="center"
class-name="col-actions"
>
<template #default="{ row }">
<el-button link type="primary" @click="handleViewOpportunity(row.id)">
<el-icon><View /></el-icon>查看
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="合同" name="contract">
<div class="view-subtable-wrap">
<el-table
v-loading="contractLoading"
:data="contractList"
class="view-subtable"
stripe
empty-text=""
>
<el-table-column label="合同编号" prop="contractNo" min-width="150" align="center" header-align="center" />
<el-table-column label="合同名称" prop="contractName" min-width="180" align="center" header-align="center" show-overflow-tooltip />
<el-table-column label="合同类型" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('crm_contract_type', row.contractType)"
size="small"
>
{{ dictStore.getLabel('crm_contract_type', row.contractType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="合同金额" min-width="130" align="right" header-align="right">
<template #default="{ row }">¥{{ formatMoney(row.amount) }}</template>
</el-table-column>
<el-table-column label="合同状态" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('crm_contract_status', row.status)"
size="small"
>
{{ dictStore.getLabel('crm_contract_status', row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="签订日期" prop="signDate" min-width="110" align="center" header-align="center" />
<el-table-column label="负责人" prop="ownerUserName" min-width="100" align="center" header-align="center" />
<el-table-column label="操作" fixed="right" min-width="120" class-name="col-actions">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleViewContract(row.id)">
<el-icon><View /></el-icon>查看
</el-button>
</template>
</el-table-column>
</el-table>
<div class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
v-loading="contractLoading"
:data="contractList"
class="list-table"
stripe
empty-text=""
>
<el-table-column
label="合同编号"
prop="contractNo"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
label="合同名称"
prop="contractName"
min-width="180"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="合同类型" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('crm_contract_type', row.contractType)"
size="small"
>
{{ dictStore.getLabel('crm_contract_type', row.contractType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="合同金额" min-width="130" align="right" header-align="right">
<template #default="{ row }">¥{{ formatMoney(row.amount) }}</template>
</el-table-column>
<el-table-column label="合同状态" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('crm_contract_status', row.status)"
size="small"
>
{{ dictStore.getLabel('crm_contract_status', row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="签订日期"
prop="signDate"
min-width="110"
align="center"
header-align="center"
/>
<el-table-column
label="负责人"
prop="ownerUserName"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
fixed="right"
min-width="120"
align="center"
header-align="center"
class-name="col-actions"
>
<template #default="{ row }">
<el-button link type="primary" @click="handleViewContract(row.id)">
<el-icon><View /></el-icon>查看
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-tab-pane>
@@ -253,51 +363,90 @@
</el-tab-pane>
<el-tab-pane label="操作日志" name="log">
<div class="view-subtable-wrap">
<el-table v-loading="logLoading" :data="operLogList" class="view-subtable" stripe empty-text="">
<el-table-column label="操作时间" prop="operTime" min-width="160" align="center" header-align="center" />
<el-table-column label="操作模块" prop="title" min-width="120" align="center" header-align="center" />
<el-table-column label="操作类型" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('sys_oper_type', row.businessType)"
size="small"
>
{{ dictStore.getLabel('sys_oper_type', row.businessType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作人员" prop="operName" min-width="120" align="center" header-align="center" />
<el-table-column label="操作IP" prop="operIp" min-width="130" align="center" header-align="center" />
<el-table-column label="操作状态" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="row.status === 0 ? 'success' : 'danger'" size="small">
{{ row.status === 0 ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="耗时" min-width="100" align="center" header-align="center">
<template #default="{ row }">{{ row.costTime }}ms</template>
</el-table-column>
<el-table-column label="操作" fixed="right" min-width="120" class-name="col-actions">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleViewLogDetail(row)">
<el-icon><View /></el-icon>详情
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="logTotal > 0" class="tab-pager">
<el-pagination
v-model:current-page="logQuery.pageNum"
v-model:page-size="logQuery.pageSize"
:total="logTotal"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="() => getOperLogList(false)"
@current-change="() => getOperLogList(false)"
/>
<div class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
v-loading="logLoading"
:data="operLogList"
class="list-table"
stripe
empty-text=""
>
<el-table-column
label="操作时间"
prop="operTime"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column
label="操作模块"
prop="title"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column label="操作类型" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('sys_oper_type', row.businessType)"
size="small"
>
{{ dictStore.getLabel('sys_oper_type', row.businessType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="操作人员"
prop="operName"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
label="操作IP"
prop="operIp"
min-width="130"
align="center"
header-align="center"
/>
<el-table-column label="操作状态" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="row.status === 0 ? 'success' : 'danger'" size="small">
{{ row.status === 0 ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="耗时" min-width="100" align="center" header-align="center">
<template #default="{ row }">{{ row.costTime }}ms</template>
</el-table-column>
<el-table-column
label="操作"
fixed="right"
min-width="120"
align="center"
header-align="center"
class-name="col-actions"
>
<template #default="{ row }">
<el-button link type="primary" @click="handleViewLogDetail(row)">
<el-icon><View /></el-icon>详情
</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="logTotal > 0" class="pagination list-pager-bar">
<el-pagination
v-model:current-page="logQuery.pageNum"
v-model:page-size="logQuery.pageSize"
:total="logTotal"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="() => getOperLogList(false)"
@current-change="() => getOperLogList(false)"
/>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
@@ -308,6 +457,8 @@
<CustomerForm ref="customerFormRef" @success="getCustomerInfo" />
<FollowForm ref="followFormRef" @success="handleFollowSuccess" />
<ContactForm ref="contactFormRef" @success="handleContactSuccess" />
<OpportunityDetailDrawer ref="opportunityDetailRef" />
<ContractDetailDialog ref="contractDetailRef" />
</template>
<script setup lang="ts" name="CustomerDetail">
@@ -340,15 +491,16 @@ import CustomerForm from './CustomerFormDialog.vue'
import FollowForm from './FollowFormDialog.vue'
import ContactForm from './components/ContactFormDialog.vue'
import InvoiceInfo from './components/InvoiceInfo.vue'
import OpportunityDetailDrawer from '@/views/crm/opportunity/components/OpportunityDetailDrawer.vue'
import ContractDetailDialog from '@/views/crm/contract/components/ContractDetailDialog.vue'
const dictStore = useDictStore()
const userStore = useUserStore()
const router = useRouter()
const emit = defineEmits(['close'])
const visible = ref(false)
const detailLoading = ref(false)
const customerId = ref<number>()
const customerId = ref<number | string>()
const customerInfo = ref<CustomerVO>({} as CustomerVO)
const followList = ref<CustomerFollowVO[]>([])
@@ -376,6 +528,8 @@ const customerFormRef = ref<InstanceType<typeof CustomerForm>>()
const followFormRef = ref<InstanceType<typeof FollowForm>>()
const contactFormRef = ref<InstanceType<typeof ContactForm>>()
const invoiceInfoRef = ref()
const opportunityDetailRef = ref<InstanceType<typeof OpportunityDetailDrawer>>()
const contractDetailRef = ref<InstanceType<typeof ContractDetailDialog>>()
const isOverdue = computed(() => {
if (!customerInfo.value.nextTime) return false
@@ -644,12 +798,12 @@ function handleViewLogDetail(row: OperLogVO) {
})
}
function handleViewOpportunity(id: number) {
router.push({ path: '/crm/opportunity/detail', query: { id: String(id) } })
function handleViewOpportunity(id: number | string) {
opportunityDetailRef.value?.open(id)
}
function handleViewContract(id: string | number) {
router.push({ path: '/crm/contract/detail', query: { id: String(id) } })
contractDetailRef.value?.open(id)
}
function handleTabChange(name: string) {
@@ -741,7 +895,7 @@ function handleContactSuccess() {
getContactList()
}
function open(id: number) {
function open(id: number | string) {
visible.value = true
customerId.value = id
customerInfo.value = {} as CustomerVO
@@ -793,7 +947,7 @@ defineExpose({ open })
}
.tab-toolbar {
margin-bottom: 12px;
margin-bottom: 10px;
}
.tab-pager {
@@ -867,40 +1021,36 @@ defineExpose({ open })
<style lang="scss">
.customer-view-dialog.system-view-dialog {
&.el-dialog {
max-width: calc(100vw - 32px);
max-width: calc(100vw - 100px);
}
.view-split-left .view-hero {
flex-wrap: wrap;
align-items: flex-start;
}
.view-hero-actions {
margin-left: auto;
flex-shrink: 0;
flex-basis: 100%;
margin-left: 0;
margin-top: 12px;
justify-content: flex-start;
}
.customer-view-hero-actions {
display: flex;
flex-direction: column;
align-items: stretch;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 108px;
width: 100%;
.el-button {
width: 100%;
width: auto;
margin: 0;
justify-content: center;
}
}
.customer-view-tabs-section .view-subtable-wrap {
margin-top: 0;
}
.customer-view-tabs-section .col-actions .cell {
white-space: nowrap;
}
.view-split-right .customer-view-tabs-section {
padding: 0;
}
@@ -2,7 +2,7 @@
<el-dialog
v-model="dialog.visible"
:title="dialog.title"
width="960px"
width="1160px"
class="dialog-form-layout"
align-center
destroy-on-close
@@ -18,7 +18,7 @@
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<el-divider content-position="left">基本信息</el-divider>
<div class="form-grid-2">
<div class="form-grid-3">
<el-form-item label="客户名称" prop="customerName">
<el-input v-model="form.customerName" placeholder="请输入客户名称" maxlength="200" />
</el-form-item>
@@ -60,7 +60,7 @@
</div>
<el-divider content-position="left">详细信息</el-divider>
<div class="form-grid-2">
<div class="form-grid-3">
<el-form-item label="客户来源" prop="source">
<el-select v-model="form.source" placeholder="请选择客户来源" clearable>
<el-option
@@ -82,9 +82,16 @@
</el-select>
</el-form-item>
<el-form-item label="所在园区" prop="parkArea">
<el-select v-model="form.parkArea" placeholder="请选择所在园区" clearable filterable>
<el-select
v-model="form.parkArea"
placeholder="请选择或输入所在园区"
clearable
filterable
allow-create
default-first-option
>
<el-option
v-for="opt in dictStore.getOptions('crm_customer_park')"
v-for="opt in parkAreaOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
@@ -110,19 +117,13 @@
<el-form-item label="区县" prop="district">
<el-input v-model="form.district" placeholder="请输入区县" maxlength="50" />
</el-form-item>
<el-form-item label="详细地址" class="form-col-span-2">
<el-input
v-model="form.address"
placeholder="请输入详细地址"
type="textarea"
:rows="2"
maxlength="255"
/>
<el-form-item label="详细地址" class="form-field-span-2">
<el-input v-model="form.address" placeholder="请输入详细地址" maxlength="255" />
</el-form-item>
</div>
<el-divider content-position="left">分配信息</el-divider>
<div class="form-grid-2">
<div class="form-grid-3">
<el-form-item label="负责人" prop="ownerUserId">
<el-select v-model="form.ownerUserId" placeholder="请选择负责人" clearable filterable>
<el-option
@@ -142,11 +143,14 @@
style="width: 100%"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" placeholder="请输入备注" maxlength="500" />
</el-form-item>
</div>
<el-divider content-position="left">证件信息</el-divider>
<div class="form-grid-2">
<el-form-item label="营业执照" prop="businessLicenseImages" class="form-col-span-2">
<div class="form-grid-3">
<el-form-item label="营业执照" prop="businessLicenseImages">
<AttachmentUploadField
ref="businessLicenseUploadRef"
v-model="form.businessLicenseImages"
@@ -157,7 +161,7 @@
@update:model-value="handleCertFieldChange('businessLicenseImages')"
/>
</el-form-item>
<el-form-item label="经营许可证" prop="businessPermitImages" class="form-col-span-2">
<el-form-item label="经营许可证" prop="businessPermitImages">
<AttachmentUploadField
ref="businessPermitUploadRef"
v-model="form.businessPermitImages"
@@ -180,69 +184,64 @@
</el-form-item>
</div>
<el-divider content-position="left">开票信息</el-divider>
<div class="form-grid-2">
<el-form-item label="单位名称(章)" class="form-col-span-2">
<el-input
v-model="invoiceForm.companyName"
placeholder="选填;填写任意开票项时此项必填"
maxlength="200"
/>
</el-form-item>
<el-form-item label="单位地址" class="form-col-span-2">
<el-input
v-model="invoiceForm.companyAddress"
placeholder="请输入单位地址"
maxlength="500"
/>
</el-form-item>
<el-form-item label="法定代表人">
<el-input
v-model="invoiceForm.legalRepresentative"
placeholder="请输入法定代表人"
maxlength="100"
/>
</el-form-item>
<el-form-item label="代理人">
<el-input v-model="invoiceForm.agent" placeholder="请输入代理人" maxlength="100" />
</el-form-item>
<el-form-item label="电话/传真">
<el-input v-model="invoiceForm.phoneFax" placeholder="请输入电话/传真" maxlength="100" />
</el-form-item>
<el-form-item label="开户银行">
<el-input v-model="invoiceForm.bankName" placeholder="请输入开户银行" maxlength="200" />
</el-form-item>
<el-form-item label="账号" class="form-col-span-2">
<el-input v-model="invoiceForm.bankAccount" placeholder="请输入账号" maxlength="100" />
</el-form-item>
<el-form-item label="是否默认" class="form-col-span-2">
<el-radio-group v-model="invoiceForm.isDefault">
<el-radio label="1">是</el-radio>
<el-radio label="0">否</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="开票备注" class="form-col-span-2">
<el-input
v-model="invoiceForm.remark"
type="textarea"
placeholder="请输入开票相关备注"
maxlength="500"
:rows="2"
/>
</el-form-item>
</div>
<div class="form-grid-2">
<el-form-item label="备注" prop="remark" class="form-col-span-2">
<el-input
v-model="form.remark"
placeholder="请输入备注"
type="textarea"
:rows="3"
maxlength="500"
/>
</el-form-item>
</div>
<template v-if="!form.id">
<el-divider content-position="left">开票信息</el-divider>
<div class="form-grid-3">
<el-form-item label="单位名称(章)">
<el-input
v-model="invoiceForm.companyName"
placeholder="请输入单位名称"
maxlength="200"
/>
</el-form-item>
<el-form-item label="税号">
<el-input v-model="invoiceForm.taxNo" placeholder="请输入税号" maxlength="50" />
</el-form-item>
<el-form-item label="单位地址">
<el-input
v-model="invoiceForm.companyAddress"
placeholder="请输入单位地址"
maxlength="500"
/>
</el-form-item>
<el-form-item label="法定代表人">
<el-input
v-model="invoiceForm.legalRepresentative"
placeholder="请输入法定代表人"
maxlength="100"
/>
</el-form-item>
<el-form-item label="代理人">
<el-input v-model="invoiceForm.agent" placeholder="请输入代理人" maxlength="100" />
</el-form-item>
<el-form-item label="电话/传真">
<el-input v-model="invoiceForm.phoneFax" placeholder="请输入电话/传真" maxlength="100" />
</el-form-item>
<el-form-item label="开户银行">
<el-input v-model="invoiceForm.bankName" placeholder="请输入开户银行" maxlength="200" />
</el-form-item>
<el-form-item label="账号">
<el-input v-model="invoiceForm.bankAccount" placeholder="请输入账号" maxlength="100" />
</el-form-item>
<el-form-item label="开票备注">
<el-input
v-model="invoiceForm.remark"
placeholder="请输入开票信息备注"
maxlength="500"
/>
</el-form-item>
</div>
</template>
<template v-else>
<el-divider content-position="left">开票信息</el-divider>
<el-alert
class="form-col-span-2"
type="info"
:closable="false"
show-icon
title="开票信息请在客户详情的“开票信息”标签页维护(支持一客户多条并设置默认)。"
/>
</template>
</el-form>
<template #footer>
<div class="dialog-footer">
@@ -260,17 +259,18 @@
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ref, reactive, computed, nextTick } from 'vue'
import { Close, Check } from '@element-plus/icons-vue'
import type { FormItemRule } from 'element-plus'
import { ElMessage } from 'element-plus'
import { fetchCustomerDetail, fetchCreateCustomer, fetchUpdateCustomer } from '@/api/crm/customer'
import type { CustomerForm as CustomerFormType } from '@/api/crm/customerTypes'
import {
getInvoiceInfoByCustomerId,
addInvoiceInfo,
updateInvoiceInfo
} from '@/api/crm/invoiceInfo'
fetchCustomerDetail,
fetchCreateCustomer,
fetchUpdateCustomer,
fetchParkAreaList
} from '@/api/crm/customer'
import type { CustomerForm as CustomerFormType } from '@/api/crm/customerTypes'
import { addInvoiceInfo } from '@/api/crm/invoiceInfo'
import type { InvoiceInfoForm } from '@/api/crm/invoiceInfo'
import { useDictStore } from '@/store/modules/dict'
import { useUserStore } from '@/store/modules/user'
@@ -315,104 +315,6 @@ function handleCertFieldChange(field: 'businessLicenseImages' | 'businessPermitI
formRef.value?.validateField(field)
}
/** 弹窗内维护的一条开票信息 */
const invoiceForm = ref({
id: undefined as number | string | undefined,
companyName: '',
companyAddress: '',
legalRepresentative: '',
agent: '',
phoneFax: '',
bankName: '',
bankAccount: '',
isDefault: '1',
remark: ''
})
function resetInvoiceForm() {
invoiceForm.value = {
id: undefined,
companyName: '',
companyAddress: '',
legalRepresentative: '',
agent: '',
phoneFax: '',
bankName: '',
bankAccount: '',
isDefault: '1',
remark: ''
}
}
/** 是否填写了任意开票字段(含单位名称) */
function isInvoiceBlockFilled(): boolean {
const f = invoiceForm.value
const keys = [
'companyName',
'companyAddress',
'legalRepresentative',
'agent',
'phoneFax',
'bankName',
'bankAccount',
'remark'
] as const
return keys.some((k) => {
const v = f[k]
return v != null && String(v).trim() !== ''
})
}
/** 解析新增客户接口返回的客户 ID(兼容 HTTP 解包后的客户对象与原 CRM 仅返回 ID 字符串) */
function resolveNewCustomerId(res: unknown): string | null {
if (res == null) return null
// shanxia2.0:request 已返回 data 字段内的客户对象
if (typeof res === 'object' && 'id' in res) {
const id = (res as { id?: string | number | null }).id
if (id != null && String(id).trim() !== '') {
return String(id)
}
}
const payload = res as { data?: unknown; msg?: string }
const { data, msg } = payload
if (data != null) {
if (typeof data === 'object' && data !== null && 'id' in data) {
const nestedId = (data as { id?: string | number | null }).id
if (nestedId != null && String(nestedId).trim() !== '') {
return String(nestedId)
}
}
const dataStr = String(data).trim()
if (dataStr !== '' && dataStr !== '[object Object]') {
return dataStr
}
}
if (msg != null && /^\d+$/.test(String(msg).trim())) {
return String(msg).trim()
}
return null
}
function buildInvoicePayload(customerId: number | string): InvoiceInfoForm {
const f = invoiceForm.value
const cid = String(customerId)
return {
id: f.id,
customerId: cid,
companyName: f.companyName.trim(),
companyAddress: f.companyAddress?.trim() || undefined,
legalRepresentative: f.legalRepresentative?.trim() || undefined,
agent: f.agent?.trim() || undefined,
phoneFax: f.phoneFax?.trim() || undefined,
bankName: f.bankName?.trim() || undefined,
bankAccount: f.bankAccount?.trim() || undefined,
isDefault: f.isDefault,
remark: f.remark?.trim() || undefined
}
}
function unwrapApiData<T>(res: T | { data?: T }): T {
if (res != null && typeof res === 'object' && 'data' in res && (res as { data?: T }).data !== undefined) {
return (res as { data: T }).data
@@ -420,42 +322,6 @@ function unwrapApiData<T>(res: T | { data?: T }): T {
return res as T
}
async function loadInvoiceForEdit(customerId: number | string) {
resetInvoiceForm()
try {
const res = await getInvoiceInfoByCustomerId(customerId)
const raw = unwrapApiData(res as any[] | { data?: any[] })
const rows = Array.isArray(raw) ? raw : []
const row = rows.find((i) => i.isDefault === '1') || rows[0]
if (row) {
invoiceForm.value = {
id: row.id,
companyName: row.companyName ?? '',
companyAddress: row.companyAddress ?? '',
legalRepresentative: row.legalRepresentative ?? '',
agent: row.agent ?? '',
phoneFax: row.phoneFax ?? '',
bankName: row.bankName ?? '',
bankAccount: row.bankAccount ?? '',
isDefault: row.isDefault ?? '1',
remark: row.remark ?? ''
}
}
} catch (e) {
console.error(e)
}
}
async function syncInvoiceAfterCustomerSave(customerId: number | string) {
if (!isInvoiceBlockFilled()) return
const payload = buildInvoicePayload(customerId)
if (invoiceForm.value.id) {
await updateInvoiceInfo({ ...payload, id: invoiceForm.value.id })
} else {
await addInvoiceInfo({ ...payload, id: undefined })
}
}
// 表单校验规则
const rules = ref<Record<string, FormItemRule[]>>({
customerName: [{ required: true, message: '客户名称不能为空', trigger: 'blur' }],
@@ -496,6 +362,39 @@ const rules = ref<Record<string, FormItemRule[]>>({
// 用户列表
const userList = ref<any[]>([])
const extraParkAreas = ref<string[]>([])
const invoiceForm = ref<InvoiceInfoForm>({
companyName: '',
taxNo: '',
companyAddress: '',
legalRepresentative: '',
agent: '',
phoneFax: '',
bankName: '',
bankAccount: '',
remark: ''
})
const parkAreaOptions = computed(() => {
const dictOpts = dictStore.getOptions('crm_customer_park')
const seen = new Set(dictOpts.map((opt) => opt.value))
const options = [...dictOpts]
for (const area of extraParkAreas.value) {
if (area && !seen.has(area)) {
options.push({ label: area, value: area, tagType: 'info' })
seen.add(area)
}
}
const current = form.value.parkArea?.trim()
if (current && !seen.has(current)) {
options.push({ label: current, value: current, tagType: 'info' })
}
return options
})
// 对话框
const dialog = reactive({
@@ -519,21 +418,96 @@ function hasAttachmentUploading() {
const loadUserList = async () => {
try {
const res = await fetchUserOptions()
userList.value = (res as any).map((item: any) => ({
const options = (res as any)?.data ?? res ?? []
userList.value = (Array.isArray(options) ? options : []).map((item: any) => ({
userId: item.value,
nickName: item.label
}))
} catch (error) {
console.error(error)
userList.value = []
}
}
const resolveDefaultOwnerUserId = () => {
const info = userStore.info
const userId = info?.userId ?? info?.id
return userId != null && userId !== '' ? String(userId) : undefined
}
const applyDefaultOwnerUserId = async () => {
const defaultOwnerUserId = resolveDefaultOwnerUserId()
if (!defaultOwnerUserId) return
await nextTick()
form.value.ownerUserId = defaultOwnerUserId as CustomerFormType['ownerUserId']
}
/** 加载历史园区列表 */
const loadParkAreaList = async () => {
try {
const res = await fetchParkAreaList()
const list = unwrapApiData(res as string[] | { data?: string[] })
extraParkAreas.value = Array.isArray(list) ? list.filter(Boolean) : []
} catch (error) {
console.error(error)
extraParkAreas.value = []
}
}
function hasInvoiceInfoFilled() {
const inv = invoiceForm.value
return Boolean(
inv.companyName?.trim() ||
inv.taxNo?.trim() ||
inv.companyAddress?.trim() ||
inv.legalRepresentative?.trim() ||
inv.agent?.trim() ||
inv.phoneFax?.trim() ||
inv.bankName?.trim() ||
inv.bankAccount?.trim() ||
inv.remark?.trim()
)
}
function resetInvoiceForm() {
invoiceForm.value = {
companyName: '',
taxNo: '',
companyAddress: '',
legalRepresentative: '',
agent: '',
phoneFax: '',
bankName: '',
bankAccount: '',
remark: ''
}
}
async function syncInvoiceInfoAfterCreate(customerId: string | number) {
if (!hasInvoiceInfoFilled()) return
const inv = invoiceForm.value
await addInvoiceInfo({
customerId: String(customerId),
companyName: inv.companyName.trim(),
taxNo: inv.taxNo?.trim() || undefined,
companyAddress: inv.companyAddress?.trim() || undefined,
legalRepresentative: inv.legalRepresentative?.trim() || undefined,
agent: inv.agent?.trim() || undefined,
phoneFax: inv.phoneFax?.trim() || undefined,
bankName: inv.bankName?.trim() || undefined,
bankAccount: inv.bankAccount?.trim() || undefined,
remark: inv.remark?.trim() || undefined,
isDefault: '1'
})
}
/** 打开对话框 */
const open = async (id?: number | string) => {
reset()
dialog.visible = true
await loadUserList()
await Promise.all([loadUserList(), loadParkAreaList()])
if (id) {
dialog.title = '修改客户'
@@ -548,14 +522,12 @@ const open = async (id?: number | string) => {
if ((data as any)?.ownerUserId) {
form.value.ownerUserId = (data as any).ownerUserId
}
await loadInvoiceForEdit(id)
} catch (error) {
console.error(error)
}
} else {
dialog.title = '新增客户'
resetInvoiceForm()
form.value.ownerUserId = userStore.info.userId ? Number(userStore.info.userId) : undefined
await applyDefaultOwnerUserId()
}
}
@@ -573,7 +545,7 @@ const submitForm = () => {
}
formRef.value?.validate(async (valid: boolean) => {
if (!valid) return
if (isInvoiceBlockFilled() && !invoiceForm.value.companyName?.trim()) {
if (!form.value.id && hasInvoiceInfoFilled() && !invoiceForm.value.companyName?.trim()) {
ElMessage.warning('已填写开票信息时,单位名称(章)不能为空')
return
}
@@ -583,17 +555,13 @@ const submitForm = () => {
if (form.value.id) {
await fetchUpdateCustomer(payload)
await syncInvoiceAfterCustomerSave(form.value.id)
ElMessage.success('修改成功')
} else {
const res = await fetchCreateCustomer(payload)
const newId = resolveNewCustomerId(res)
if (isInvoiceBlockFilled()) {
if (newId == null) {
ElMessage.warning('客户已创建,但未能获取客户 ID,开票信息未保存,请在编辑中补充')
} else {
await syncInvoiceAfterCustomerSave(newId)
}
const customerData = unwrapApiData(res as Record<string, unknown> | { data?: Record<string, unknown> })
const customerId = (customerData as { id?: string | number })?.id
if (customerId) {
await syncInvoiceInfoAfterCreate(customerId)
}
ElMessage.success('新增成功')
}
@@ -648,4 +616,8 @@ const reset = () => {
defineExpose({ open })
</script>
<style scoped lang="scss"></style>
<style scoped lang="scss">
.form-grid-3 .form-field-span-2 {
grid-column: span 2;
}
</style>
@@ -1,5 +1,5 @@
<template>
<div class="invoice-info-container">
<div class="panel dialog-list-panel invoice-info-container">
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
@@ -10,34 +10,102 @@
</div>
</div>
<el-table :data="invoiceInfoList" v-loading="loading" stripe class="list-table" empty-text="">
<el-table-column label="单位名称(章)" prop="companyName" min-width="180" align="center" show-overflow-tooltip />
<el-table-column label="单位地址" prop="companyAddress" min-width="200" align="center" show-overflow-tooltip />
<el-table-column label="法定代表人" prop="legalRepresentative" min-width="120" align="center" />
<el-table-column label="代理人" prop="agent" min-width="100" align="center" />
<el-table-column label="电话/传真" prop="phoneFax" min-width="130" align="center" />
<el-table-column label="开户银行" prop="bankName" min-width="150" align="center" show-overflow-tooltip />
<el-table-column label="账号" prop="bankAccount" min-width="200" align="center" />
<el-table-column label="默认" min-width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.isDefault === '1'" type="success" size="small">是</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="260" align="center" fixed="right">
<template #default="{ row }">
<el-button v-if="row.isDefault !== '1'" link type="primary" @click="handleSetDefault(row)">
<el-icon><Star /></el-icon>设为默认
</el-button>
<el-button link type="primary" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="danger" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="list-body list-wrap">
<el-table
:data="invoiceInfoList"
v-loading="loading"
stripe
class="list-table"
empty-text=""
>
<el-table-column
label="单位名称(章)"
prop="companyName"
min-width="180"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
label="税号"
prop="taxNo"
min-width="180"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
label="单位地址"
prop="companyAddress"
min-width="200"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
label="法定代表人"
prop="legalRepresentative"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
label="代理人"
prop="agent"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
label="电话/传真"
prop="phoneFax"
min-width="130"
align="center"
header-align="center"
/>
<el-table-column
label="开户银行"
prop="bankName"
min-width="150"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
label="账号"
prop="bankAccount"
min-width="200"
align="center"
header-align="center"
/>
<el-table-column label="默认" min-width="80" align="center" header-align="center">
<template #default="{ row }">
<el-tag v-if="row.isDefault === '1'" type="success" size="small">是</el-tag>
<el-tag v-else type="info" size="small">否</el-tag>
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="260"
align="center"
header-align="center"
fixed="right"
class-name="col-actions"
>
<template #default="{ row }">
<el-button v-if="row.isDefault !== '1'" link type="primary" @click="handleSetDefault(row)">
<el-icon><Star /></el-icon>设为默认
</el-button>
<el-button link type="primary" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="danger" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-dialog :title="dialog.title" v-model="dialog.visible" width="800px" append-to-body @close="handleClose">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="dialog-form-layout">
@@ -45,6 +113,9 @@
<el-form-item label="单位名称(章)" prop="companyName" class="form-col-span-2">
<el-input v-model="form.companyName" placeholder="请输入单位名称" maxlength="200" />
</el-form-item>
<el-form-item label="税号" prop="taxNo" class="form-col-span-2">
<el-input v-model="form.taxNo" placeholder="请输入税号" maxlength="50" />
</el-form-item>
<el-form-item label="单位地址" prop="companyAddress" class="form-col-span-2">
<el-input v-model="form.companyAddress" placeholder="请输入单位地址" maxlength="500" />
</el-form-item>
@@ -96,6 +167,7 @@ interface InvoiceInfoVO {
id: string | number
customerId: string | number
companyName: string
taxNo?: string
companyAddress?: string
legalRepresentative?: string
agent?: string
@@ -110,6 +182,7 @@ interface InvoiceInfoForm {
id?: string | number
customerId: string | number | undefined
companyName: string
taxNo?: string
companyAddress?: string
legalRepresentative?: string
agent?: string
@@ -138,6 +211,7 @@ const form = ref<InvoiceInfoForm>({
id: undefined,
customerId: '',
companyName: '',
taxNo: '',
companyAddress: '',
legalRepresentative: '',
agent: '',
@@ -186,6 +260,7 @@ const handleEdit = (row: InvoiceInfoVO) => {
id: row.id,
customerId: row.customerId,
companyName: row.companyName,
taxNo: row.taxNo,
companyAddress: row.companyAddress,
legalRepresentative: row.legalRepresentative,
agent: row.agent,
@@ -257,6 +332,7 @@ const reset = () => {
id: undefined,
customerId: props.customerId != null ? String(props.customerId) : '',
companyName: '',
taxNo: '',
companyAddress: '',
legalRepresentative: '',
agent: '',
@@ -274,6 +350,6 @@ defineExpose({ getList })
<style scoped lang="scss">
.invoice-info-container {
padding: 10px 0;
margin: 0;
}
</style>
+27 -51
View File
@@ -162,26 +162,10 @@
<el-icon><Plus /></el-icon>
新增客户
</el-button>
<el-button class="btn-ghost" :disabled="single" @click="handleDeal()">
<el-icon><Check /></el-icon>
成交
</el-button>
<el-button class="btn-ghost" :disabled="multiple" @click="handleTransfer()">
<el-icon><User /></el-icon>
转移
</el-button>
<el-button class="btn-ghost" :disabled="multiple" @click="handleIntoPool()">
<el-icon><DocumentCopy /></el-icon>
放入公海
</el-button>
<el-button class="btn-ghost" @click="handleColumnSetting">
<el-icon><Setting /></el-icon>
列设置
</el-button>
<el-button class="btn-ghost" @click="handleExport">
<el-icon><Download /></el-icon>
导出
</el-button>
</div>
</div>
@@ -609,43 +593,23 @@
/>
<el-table-column
label="操作"
min-width="280"
min-width="320"
fixed="right"
align="center"
header-align="center"
class-name="col-actions"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleFollow(row)">
<el-icon><ChatLineRound /></el-icon>跟进
<div class="col-actions-inner">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item" @click="handleDeal(row)">
<el-icon><Check /></el-icon>成交
<el-button class="btn-op-item" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button class="btn-op-item btn-op-danger" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleMoreCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="transfer">
<el-icon><User /></el-icon>转移
</el-dropdown-item>
<el-dropdown-item command="intoPool">
<el-icon><DocumentCopy /></el-icon>放入公海
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
@@ -732,16 +696,11 @@ import {
Check,
Search,
RefreshLeft,
Download,
View,
More,
User,
DocumentCopy,
Setting,
ArrowUpBold,
ArrowDownBold,
Edit,
ChatLineRound,
} from '@element-plus/icons-vue'
import { useDictStore } from '@/store/modules/dict'
import {
@@ -1109,6 +1068,15 @@ function handleSaveColumnSetting() {
}
}
watchDebounced(
() => ({ ...searchForm, dateRange: dateRange.value }),
() => {
pagination.page = 1
getList()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
initColumnSetting()
getList()
@@ -1144,4 +1112,12 @@ onMounted(() => {
}
}
}
.col-actions-inner {
display: flex;
justify-content: center;
gap: 8px;
flex-wrap: nowrap;
white-space: nowrap;
}
</style>
@@ -109,9 +109,15 @@ const loadUserList = async () => {
try {
const res = await fetchUserList({ page: 1, pageSize: 100 })
const data = (res as any)?.data || res || {}
userList.value = data.list || data.rows || []
const rows = data.list || data.rows || []
userList.value = rows.map((item: any) => ({
id: item.id,
nickName: item.nickName ?? '',
userName: item.userNo ?? item.userName ?? ''
}))
} catch (error) {
console.error(error)
userList.value = []
}
}
@@ -771,13 +771,13 @@ const getList = async () => {
const weekAgoStr = weekAgo.toISOString().split('T')[0]
poolStatistics.totalCount = pagination.total
poolStatistics.todayCount = list.filter(
poolStatistics.todayCount = customerList.value.filter(
(item: any) => item.intoPoolTime && item.intoPoolTime.startsWith(today),
).length
poolStatistics.weekCount = list.filter(
poolStatistics.weekCount = customerList.value.filter(
(item: any) => item.intoPoolTime && item.intoPoolTime >= weekAgoStr,
).length
poolStatistics.keyCount = list.filter((item: any) => item.level === 'key').length
poolStatistics.keyCount = customerList.value.filter((item: any) => item.level === 'key').length
} catch (error) {
console.error(error)
} finally {
@@ -833,6 +833,15 @@ const handleMoreCommand = (cmd: string, row: any) => {
}
}
watchDebounced(
searchForm,
() => {
pagination.page = 1
getList()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
initColumnSetting()
getList()
@@ -3,12 +3,13 @@
v-model:visible="visible"
:title="opportunityInfo.opportunityName || '商机详情'"
:icon="TrendCharts"
width="1200px"
width="calc(100vw - 200px)"
layout="split"
:meta="viewHeroMeta"
:sections="viewSections"
:loading="loading"
dialog-class="opportunity-view-dialog"
append-to-body
>
<template #hero-actions>
<div class="opportunity-view-hero-actions">
@@ -28,14 +29,6 @@
>
<el-icon><Promotion /></el-icon>推进阶段
</el-button>
<el-button
v-if="hasOpportunityPerm('follow')"
class="btn-ghost"
:disabled="!isFollowing"
@click="handleFollow"
>
<el-icon><ChatLineRound /></el-icon>添加跟进
</el-button>
<el-button
v-if="hasOpportunityPerm('remove')"
class="btn-ghost"
@@ -187,7 +180,6 @@ import {
Delete,
Plus,
Promotion,
ChatLineRound,
Right
} from '@element-plus/icons-vue'
import SystemViewDialog, {
@@ -601,27 +593,31 @@ defineExpose({ open })
<style lang="scss">
.opportunity-view-dialog.system-view-dialog {
&.el-dialog {
max-width: calc(100vw - 32px);
max-width: calc(100vw - 200px);
}
.view-split-left .view-hero {
flex-wrap: wrap;
align-items: flex-start;
}
.view-hero-actions {
margin-left: auto;
flex-shrink: 0;
flex-basis: 100%;
margin-left: 0;
margin-top: 12px;
justify-content: flex-start;
}
.opportunity-view-hero-actions {
display: flex;
flex-direction: column;
align-items: stretch;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 108px;
width: 100%;
.el-button {
width: 100%;
width: auto;
margin: 0;
justify-content: center;
}
+17 -59
View File
@@ -127,23 +127,6 @@
>
<el-icon><User /></el-icon>批量分配
</el-button>
<el-button
v-if="hasOpportunityPerm('export')"
class="btn-ghost"
:loading="exportLoading"
@click="handleExport"
>
<el-icon><Download /></el-icon>导出
</el-button>
<el-button class="btn-ghost" @click="switchView('kanban')">
<el-icon><Grid /></el-icon>看板
</el-button>
<el-button class="btn-ghost" @click="goToFunnel">
<el-icon><DataAnalysis /></el-icon>漏斗
</el-button>
<el-button class="btn-ghost" @click="handleColumnSetting">
<el-icon><Setting /></el-icon>列设置
</el-button>
</div>
</div>
@@ -331,7 +314,7 @@
show-overflow-tooltip
/>
<el-table-column v-if="isColumnVisible('remark')" label="备注" align="center" header-align="center" prop="remark" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" min-width="400" align="center" header-align="center" fixed="right">
<el-table-column label="操作" min-width="200" align="center" header-align="center" fixed="right">
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button v-if="hasOpportunityPerm('query')" class="btn-op-item" @click="handleDetail(row)">
@@ -345,38 +328,6 @@
>
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button
v-if="hasOpportunityPerm('edit')"
class="btn-op-item primary"
:disabled="row.status !== 'following'"
@click="handleChangeStage(row)"
>
<el-icon><Promotion /></el-icon>推进
</el-button>
<el-button
v-if="hasOpportunityPerm('follow')"
class="btn-op-item primary"
:disabled="row.status !== 'following'"
@click="handleFollow(row)"
>
<el-icon><ChatLineRound /></el-icon>跟进
</el-button>
<el-button
v-if="hasOpportunityPerm('edit')"
class="btn-op-item primary"
:disabled="row.status !== 'following'"
@click="handleMarkWon(row)"
>
<el-icon><CircleCheck /></el-icon>赢单
</el-button>
<el-button
v-if="hasOpportunityPerm('edit')"
class="btn-op-item danger"
:disabled="row.status !== 'following'"
@click="handleMarkLost(row)"
>
<el-icon><CircleClose /></el-icon>输单
</el-button>
</div>
</template>
</el-table-column>
@@ -500,19 +451,11 @@ import {
Search,
RefreshLeft,
Plus,
CircleCheck,
CircleClose,
User,
Setting,
ArrowUpBold,
ArrowDownBold,
Download,
Grid,
DataAnalysis,
View,
Edit,
Promotion,
ChatLineRound
Edit
} from '@element-plus/icons-vue'
import { useDictStore } from '@/store/modules/dict'
import { useOpportunityPerm } from '@/hooks/business/useOpportunityPerm'
@@ -1038,6 +981,21 @@ const goToFunnel = () => {
router.push('/crm/opportunity/funnel');
};
watchDebounced(
() => ({
opportunityName: queryParams.value.opportunityName,
customerName: queryParams.value.customerName,
stage: queryParams.value.stage,
ownerUserName: queryParams.value.ownerUserName,
dateRange: dateRange.value,
}),
() => {
queryParams.value.pageNum = 1;
getList();
},
{ debounce: 300, deep: true }
);
// 初始化加载数据
onMounted(async () => {
await loadPerms()
@@ -449,6 +449,15 @@ function findNodeById(tree: any[], id: string): any {
return null
}
// 输入检索条件时自动搜索(防抖 300ms,对齐租户/菜单管理)
watchDebounced(
searchForm,
() => {
getList()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
getList()
})
+145 -38
View File
@@ -151,13 +151,24 @@
</el-table-column>
<el-table-column
prop="price"
label="标准价"
min-width="100"
label="标准价(元)"
min-width="120"
align="right"
header-align="right"
>
<template #default="{ row }">
{{ row.price != null ? `¥${Number(row.price).toFixed(2)}` : '-' }}
<span class="money-display">¥{{ formatMoneyDisplay(row.price) }}</span>
</template>
</el-table-column>
<el-table-column
prop="cost"
label="成本价(元)"
min-width="120"
align="right"
header-align="right"
>
<template #default="{ row }">
<span class="money-display">¥{{ formatMoneyDisplay(row.cost) }}</span>
</template>
</el-table-column>
<el-table-column
@@ -217,8 +228,8 @@
:page-sizes="[10, 20, 30, 50]"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
@size-change="handleSearch"
@current-change="handleSearch"
@size-change="handlePageSizeChange"
@current-change="handlePageChange"
/>
</div>
@@ -226,7 +237,7 @@
<el-dialog
v-model="formDialogVisible"
:title="isEdit ? '编辑产品' : '新增产品'"
width="800px"
width="960px"
class="dialog-form-layout"
align-center
destroy-on-close
@@ -241,13 +252,13 @@
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="产品名称" prop="productName">
<el-input v-model="formData.productName" placeholder="请输入产品名称" />
</el-form-item>
<div class="form-grid-3">
<el-form-item label="产品编号">
<el-input v-model="formData.productNo" placeholder="留空自动生成" />
</el-form-item>
<el-form-item label="产品名称" prop="productName">
<el-input v-model="formData.productName" placeholder="请输入产品名称" />
</el-form-item>
<el-form-item label="产品分类">
<el-tree-select
v-model="formData.categoryId"
@@ -272,28 +283,17 @@
/>
</el-select>
</el-form-item>
<el-form-item label="条码">
<el-input v-model="formData.barcode" placeholder="请输入条码" />
</el-form-item>
<el-form-item label="标准价">
<el-input-number
v-model="formData.price"
:precision="2"
:step="0.01"
:min="0"
controls-position="right"
style="width: 100%"
<el-form-item label="条码" prop="barcode">
<el-input
v-model="formData.barcode"
:placeholder="isEdit ? '请输入条码' : '留空自动生成'"
/>
</el-form-item>
<el-form-item label="成本价">
<el-input-number
v-model="formData.cost"
:precision="2"
:step="0.01"
:min="0"
controls-position="right"
style="width: 100%"
/>
<el-form-item label="标准价(元)">
<AmountInput v-model="formData.price" :min="0" class="money-field-input" />
</el-form-item>
<el-form-item label="成本价(元)">
<AmountInput v-model="formData.cost" :min="0" class="money-field-input" />
</el-form-item>
<el-form-item label="税率(%)">
<el-input-number
@@ -305,6 +305,22 @@
style="width: 100%"
/>
</el-form-item>
<el-form-item label="初始库存">
<el-input-number
v-model="formData.stock"
:min="0"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="库存预警值">
<el-input-number
v-model="formData.lowStock"
:min="0"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="formData.status" placeholder="请选择状态">
<el-option
@@ -315,6 +331,16 @@
/>
</el-select>
</el-form-item>
<el-form-item label="产品图片" class="form-col-span-2">
<AttachmentUploadField
ref="productImagesUploadRef"
v-model="formData.images"
:limit="5"
accept="image/*"
folder="crm/product"
hint="支持 JPG、PNG、WebP 等图片,单文件不超过 20MB,最多 5 张"
/>
</el-form-item>
<el-form-item label="产品描述" style="grid-column: 1 / -1">
<el-input
v-model="formData.description"
@@ -362,13 +388,16 @@
{{ dictStore.getLabel('crm_product_unit', detailData.unit) }}
</el-descriptions-item>
<el-descriptions-item label="条码">{{ detailData.barcode || '-' }}</el-descriptions-item>
<el-descriptions-item label="标准价">
{{ detailData.price != null ? `¥${Number(detailData.price).toFixed(2)}` : '-' }}
<el-descriptions-item label="标准价(元)">
<span class="money-display">¥{{ formatMoneyDisplay(detailData.price) }}</span>
</el-descriptions-item>
<el-descriptions-item label="成本价">
{{ detailData.cost != null ? `¥${Number(detailData.cost).toFixed(2)}` : '-' }}
<el-descriptions-item label="成本价(元)">
<span class="money-display">¥{{ formatMoneyDisplay(detailData.costPrice ?? detailData.cost) }}</span>
</el-descriptions-item>
<el-descriptions-item label="税率">{{ detailData.taxRate }}%</el-descriptions-item>
<el-descriptions-item label="库存数量">{{ detailData.stock ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="库存预警值">{{ detailData.lowStock ?? 10 }}</el-descriptions-item>
<el-descriptions-item label="销售次数">{{ detailData.saleCount ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="税率">{{ detailData.taxRate ?? 13 }}%</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag
:type="dictStore.getTagType('crm_product_status', detailData.status)"
@@ -378,6 +407,17 @@
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ detailData.createTime }}</el-descriptions-item>
<el-descriptions-item v-if="detailData.images" label="产品图片" :span="2">
<div class="product-images">
<ImagePreview
v-for="(img, index) in detailData.images.split(',').filter(Boolean)"
:key="`${img}-${index}`"
:src="img"
:width="80"
:height="80"
/>
</div>
</el-descriptions-item>
<el-descriptions-item label="产品描述" :span="2">{{ detailData.description || '-' }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detailData.remark || '-' }}</el-descriptions-item>
</el-descriptions>
@@ -449,6 +489,8 @@ import {
Bottom,
} from '@element-plus/icons-vue'
import { useDictStore } from '@/store/modules/dict'
import { formatMoneyDisplay } from '@/utils/money-format'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import {
fetchCategoryTree,
fetchProductList,
@@ -480,6 +522,7 @@ const formDialogVisible = ref(false)
const isEdit = ref(false)
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
const productImagesUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const formData = reactive({
id: '',
productName: '',
@@ -490,13 +533,32 @@ const formData = reactive({
barcode: '',
price: 0,
cost: 0,
stock: 0,
lowStock: 10,
taxRate: 13,
status: 'on',
description: '',
images: '',
remark: '',
})
const formRules = {
productName: [{ required: true, message: '产品名称不能为空', trigger: 'blur' }],
barcode: [
{
validator: (_rule: any, value: string, callback: (err?: Error) => void) => {
if (!value) {
callback()
return
}
if (!/^\d+$/.test(value)) {
callback(new Error('条码必须为数字'))
return
}
callback()
},
trigger: 'blur',
},
],
}
const detailDialogVisible = ref(false)
@@ -535,6 +597,16 @@ function handleSearch() {
pagination.page = 1
getList()
}
function handlePageSizeChange() {
pagination.page = 1
getList()
}
function handlePageChange() {
getList()
}
function handleReset() {
searchForm.keyword = ''
searchForm.categoryId = ''
@@ -569,10 +641,13 @@ async function handleUpdate(row: any) {
unit: data.unit || 'dun',
barcode: data.barcode || '',
price: data.price ?? 0,
cost: data.cost ?? 0,
cost: data.costPrice ?? data.cost ?? 0,
stock: data.stock ?? 0,
lowStock: data.lowStock ?? 10,
taxRate: data.taxRate ?? 13,
status: data.status || 'on',
description: data.description || '',
images: data.images || '',
remark: data.remark || '',
})
formDialogVisible.value = true
@@ -628,6 +703,10 @@ async function handleBatchStatus(status: string) {
}
async function submitForm() {
if (productImagesUploadRef.value?.hasUploading?.()) {
ElMessage.warning('附件上传中,请稍候')
return
}
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
@@ -639,11 +718,14 @@ async function submitForm() {
model: formData.model || undefined,
unit: formData.unit || undefined,
barcode: formData.barcode || undefined,
price: formData.price,
cost: formData.cost,
price: formData.price ?? 0,
cost: formData.cost ?? 0,
stock: formData.stock ?? 0,
lowStock: formData.lowStock ?? 10,
taxRate: formData.taxRate,
status: formData.status,
description: formData.description || undefined,
images: formData.images || undefined,
remark: formData.remark || undefined,
}
if (isEdit.value) {
@@ -735,9 +817,12 @@ function resetFormData() {
formData.barcode = ''
formData.price = 0
formData.cost = 0
formData.stock = 0
formData.lowStock = 10
formData.taxRate = 13
formData.status = 'on'
formData.description = ''
formData.images = ''
formData.remark = ''
}
@@ -746,8 +831,30 @@ function resetForm() {
formRef.value?.resetFields()
}
watchDebounced(
searchForm,
() => {
pagination.page = 1
getList()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
getCategoryOptions()
getList()
})
</script>
<style scoped lang="scss">
.money-field-input :deep(.el-input__inner) {
color: #dc2626;
font-weight: 600;
}
.product-images {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>
@@ -35,7 +35,7 @@
:loading="loading"
style="width: 100%"
>
<el-option v-for="user in userList" :key="user.userId" :label="`${user.nickName} (${user.userName})`" :value="user.userId">
<el-option v-for="user in userList" :key="user.userId" :label="formatApproverOptionLabel(user)" :value="user.userId">
<span style="float: left">{{ user.nickName }}</span>
<span style="float: right; color: var(--mint-text-muted, #64748b); font-size: 13px">
{{ user.deptName }}
@@ -113,6 +113,13 @@ const handleClose = () => {
emit('close')
}
const formatApproverOptionLabel = (user: { nickName?: string; userName?: string }) => {
const nick = (user.nickName || '').trim()
const account = (user.userName || '').trim()
if (nick && account) return `${nick}(${account})`
return nick || account
}
const loadUsers = async (nickName?: string) => {
loading.value = true
try {
@@ -120,8 +127,8 @@ const loadUsers = async (nickName?: string) => {
const options = (res as any)?.data ?? res ?? []
const mapped = (Array.isArray(options) ? options : []).map((item: any) => ({
userId: item.value ?? item.userId ?? item.id,
nickName: item.label ?? item.nickName ?? item.userName ?? '',
userName: item.userNo ?? item.userName ?? '',
nickName: item.nickName ?? item.label ?? item.userName ?? '',
userName: item.userNo ?? item.user_no ?? item.userName ?? '',
deptName: item.deptName ?? ''
}))
userList.value = nickName
@@ -1,5 +1,14 @@
<template>
<el-dialog title="选择产品" v-model="dialogVisible" width="80%" :close-on-click-modal="false" append-to-body>
<el-dialog
title="选择产品"
v-model="dialogVisible"
width="80%"
align-center
append-to-body
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
>
<!-- 查询表单 -->
<el-form :model="queryParams" ref="queryFormRef" label-width="90px" class="mb-3">
<el-row :gutter="20">
@@ -24,8 +33,12 @@
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<el-form-item label="产品状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 100%">
<el-option label="在售" value="on" />
<el-option label="下架" value="off" />
<el-option
v-for="opt in dictStore.getOptions('crm_product_status')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</el-col>
@@ -43,49 +56,53 @@
</el-form>
<!-- 产品列表 -->
<el-table
ref="tableRef"
v-loading="loading"
:data="productList"
@selection-change="handleSelectionChange"
@row-dblclick="handleRowDblClick"
:max-height="400"
border
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="产品编号" prop="productNo" width="140" align="center" :show-overflow-tooltip="true" />
<el-table-column label="产品名称" prop="productName" min-width="120" :show-overflow-tooltip="true" />
<el-table-column label="产品分类" prop="categoryName" width="120" />
<el-table-column label="规格型号" prop="model" width="120" :show-overflow-tooltip="true" />
<el-table-column label="计量单位" prop="unit" width="100" align="center">
<template #default="scope">
<DictTag dict-type="crm_product_unit" :value="scope.row.unit" />
</template>
</el-table-column>
<el-table-column label="税率(%)" prop="taxRate" width="90" align="center">
<template #default="scope">
<span>{{ scope.row.taxRate || 0 }}%</span>
</template>
</el-table-column>
<el-table-column label="状态" prop="status" width="90" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 'on' ? 'success' : 'info'">
{{ scope.row.status === 'on' ? '在售' : '下架' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<div class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
ref="tableRef"
v-loading="loading"
:data="productList"
class="list-table"
stripe
@selection-change="handleSelectionChange"
@row-click="handleRowClick"
:max-height="400"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="60" align="center" header-align="center" />
<el-table-column label="产品编号" prop="productNo" min-width="140" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="产品名称" prop="productName" min-width="180" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="产品分类" prop="categoryName" min-width="120" align="center" header-align="center" />
<el-table-column label="规格型号" prop="model" min-width="120" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="计量单位" prop="unit" min-width="100" align="center" header-align="center">
<template #default="scope">
<DictTag dict-type="crm_product_unit" :value="scope.row.unit" />
</template>
</el-table-column>
<el-table-column label="税率(%)" prop="taxRate" min-width="90" align="center" header-align="center">
<template #default="scope">
<span>{{ scope.row.taxRate || 0 }}%</span>
</template>
</el-table-column>
<el-table-column label="状态" prop="status" min-width="90" align="center" header-align="center">
<template #default="scope">
<el-tag :type="dictStore.getTagType('crm_product_status', scope.row.status)" size="small">
{{ dictStore.getLabel('crm_product_status', scope.row.status) }}
</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<ListPagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
class="mt-3"
/>
<ListPagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
class="pagination list-pager-bar"
/>
</div>
</div>
<!-- 已选产品 -->
<el-divider content-position="left">已选产品 ({{ selectedProducts.length }})</el-divider>
@@ -181,13 +198,13 @@ const getList = async () => {
const res = await fetchProductList({
page: queryParams.value.pageNum,
pageSize: queryParams.value.pageSize,
productName: queryParams.value.productName,
keyword: queryParams.value.productName,
categoryId: queryParams.value.categoryId,
status: queryParams.value.status
});
const data = (res as any)?.list ?? (res as any)?.rows ?? [];
productList.value = data;
total.value = (res as any)?.total ?? data.length;
const data = (res as any)?.data || res || {};
productList.value = data.list || data.rows || [];
total.value = data.total ?? 0;
} catch (error) {
console.error('获取产品列表失败:', error);
} finally {
@@ -227,16 +244,30 @@ const resetQuery = () => {
handleQuery();
};
watchDebounced(
() => ({
productName: queryParams.value.productName,
categoryId: queryParams.value.categoryId,
status: queryParams.value.status,
}),
() => {
queryParams.value.pageNum = 1;
getList();
},
{ debounce: 300, deep: true }
);
/** 多选框选中数据 */
const handleSelectionChange = (selection: ProductVO[]) => {
selectedProducts.value = selection;
};
/** 双击行选中/取消选中产品 */
const handleRowDblClick = (row: ProductVO) => {
// 切换行的选中状态
tableRef.value?.toggleRowSelection(row);
};
/** 单击行选中/取消选中产品(点击复选框列时不重复切换) */
const handleRowClick = (row: any, _column: unknown, event: MouseEvent) => {
const target = event.target as HTMLElement
if (target.closest('.el-checkbox')) return
tableRef.value?.toggleRowSelection(row)
}
/** 移除已选产品 */
const handleRemoveSelection = (product: ProductVO) => {
@@ -12,6 +12,7 @@
:grid-columns="2"
dialog-class="quote-view-dialog"
modal-class="order-module-dialog-mask"
append-to-body
>
<template #hero-actions>
<div class="quote-view-hero-actions">
@@ -64,14 +65,15 @@
</template>
<template #products>
<div class="view-subtable-wrap">
<el-table
:data="quoteInfo.productList || []"
class="view-subtable"
stripe
empty-text="暂无产品明细"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<div class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
:data="quoteInfo.productList || []"
class="list-table"
stripe
empty-text="暂无产品明细"
>
<el-table-column type="index" label="序号" min-width="60" align="center" header-align="center" />
<el-table-column label="产品名称" prop="productName" min-width="120" align="center" header-align="center" show-overflow-tooltip />
<el-table-column label="型号/规格" prop="model" min-width="130" align="center" header-align="center" show-overflow-tooltip>
<template #default="{ row }">
@@ -134,6 +136,7 @@
</template>
</el-table-column>
</el-table>
</div>
</div>
<div v-if="(quoteInfo.productList?.length || 0) > 0" class="quote-product-summary">
<div class="quote-product-summary-item">
@@ -689,9 +692,5 @@ defineExpose({ open, refresh })
margin: 0;
}
}
.view-section-slot .view-subtable-wrap {
margin-top: 0;
}
}
</style>
@@ -45,11 +45,16 @@ const emit = defineEmits<{
}>()
const visible = ref(false)
const dialogMode = ref<'add' | 'edit'>('add')
const dialogMode = ref<'add' | 'edit' | 'copy'>('add')
const editId = ref<string | number>('')
const copyId = ref<string | number>('')
const panelRef = ref<InstanceType<typeof QuoteFormPanel> | null>(null)
const saveLoading = computed(() => Boolean(panelRef.value?.saveLoading))
const dialogTitle = computed(() => (dialogMode.value === 'edit' ? '编辑报价单' : '新增报价单'))
const dialogTitle = computed(() => {
if (dialogMode.value === 'edit') return '编辑报价单'
if (dialogMode.value === 'copy') return '复制新增报价单'
return '新增报价单'
})
const handleOpened = async () => {
await nextTick()
@@ -57,6 +62,10 @@ const handleOpened = async () => {
await panelRef.value?.initEdit(editId.value)
return
}
if (dialogMode.value === 'copy' && copyId.value) {
await panelRef.value?.initCopy(copyId.value)
return
}
await panelRef.value?.initNew()
}
@@ -76,16 +85,25 @@ const handleSuccess = () => {
const open = () => {
dialogMode.value = 'add'
editId.value = ''
copyId.value = ''
visible.value = true
}
const openEdit = (id: string | number) => {
dialogMode.value = 'edit'
editId.value = id
copyId.value = ''
visible.value = true
}
defineExpose({ open, openEdit })
const openCopy = (id: string | number) => {
dialogMode.value = 'copy'
copyId.value = id
editId.value = ''
visible.value = true
}
defineExpose({ open, openEdit, openCopy })
</script>
<style scoped lang="scss">
@@ -98,19 +98,31 @@
</span>
</el-divider>
<div>
<div class="product-toolbar mb-3">
<el-button class="btn-main" type="primary" @click="handleAddProduct">
<el-icon><Plus /></el-icon>添加产品
</el-button>
<el-button class="btn-ghost" :disabled="selectedProducts.length === 0" @click="handleDeleteProducts">
<el-icon><Delete /></el-icon>删除选中
</el-button>
</div>
<div class="panel dialog-list-panel">
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
<el-button class="btn-main" type="primary" @click="handleAddProduct">
<el-icon><Plus /></el-icon>添加产品
</el-button>
<el-button class="btn-ghost" :disabled="selectedProducts.length === 0" @click="handleDeleteProducts">
<el-icon><Delete /></el-icon>删除选中
</el-button>
</div>
</div>
</div>
<!-- 产品列表 -->
<el-table :data="form.products" border @selection-change="handleProductSelection" :max-height="400" style="width: 100%">
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="60" align="center" />
<div class="list-body list-wrap">
<el-table
:data="form.products"
class="list-table"
stripe
@selection-change="handleProductSelection"
:max-height="400"
style="width: 100%"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="60" align="center" header-align="center" />
<el-table-column label="产品名称" prop="productName" min-width="160" :show-overflow-tooltip="true">
<template #default="scope">
<el-input v-model="scope.row.productName" placeholder="请输入产品名称" clearable readonly />
@@ -152,7 +164,13 @@
<el-table-column prop="price" width="130">
<template #header> <span style="color: #f56c6c">*</span> 含税单价/元</template>
<template #default="scope">
<el-input v-model="scope.row.price" placeholder="含税单价" @input="handlePriceChange(scope.row)" clearable />
<el-input
v-model="scope.row.price"
class="money-field-input"
placeholder="含税单价"
@input="handlePriceChange(scope.row)"
clearable
/>
</template>
</el-table-column>
<el-table-column prop="subtotal" width="130" align="right">
@@ -192,14 +210,16 @@
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" min-width="80" align="center" fixed="right">
<el-table-column label="操作" min-width="80" align="center" header-align="center" fixed="right" class-name="col-actions">
<template #default="scope">
<el-button link type="danger" @click="handleRemoveProduct(scope.$index)">
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-table>
</div>
</div>
<!-- 合计信息 -->
<el-row class="mt-4">
@@ -389,7 +409,7 @@
选择审批人<span class="form-label-tip">可选择多个审批人,按顺序依次审批</span>
</template>
<el-select v-model="submitForm.approverIds" multiple placeholder="请选择审批人" style="width: 100%">
<el-option v-for="user in userList" :key="user.userId" :label="`${user.nickName}(${user.userName})`" :value="user.userId" />
<el-option v-for="user in userList" :key="user.userId" :label="formatApproverOptionLabel(user)" :value="user.userId" />
</el-select>
</el-form-item>
<el-form-item label="备注说明" prop="remark" class="form-col-span-2">
@@ -940,6 +960,13 @@ const handleOwnerChange = (userId: string | number | undefined) => {
form.value.ownerUserName = user?.nickName || ''
}
const formatApproverOptionLabel = (user: { nickName?: string; userName?: string }) => {
const nick = (user.nickName || '').trim()
const account = (user.userName || '').trim()
if (nick && account) return `${nick}(${account})`
return nick || account
}
/** 加载用户列表 */
const loadUserList = async () => {
try {
@@ -947,8 +974,8 @@ const loadUserList = async () => {
const options = (res as any)?.data ?? res ?? [];
userList.value = (Array.isArray(options) ? options : []).map((item: any) => ({
userId: item.value ?? item.userId ?? item.id,
nickName: item.label ?? item.nickName ?? item.userName ?? '',
userName: item.userNo ?? item.userName ?? '',
nickName: item.nickName ?? item.label ?? item.userName ?? '',
userName: item.userNo ?? item.user_no ?? item.userName ?? '',
deptName: item.deptName ?? ''
}));
} catch (error) {
@@ -1079,12 +1106,11 @@ const handleProductConfirm = (products: any[]) => {
categoryName: product.categoryName,
model: product.model, // 型号/规格
unit: product.unit, // 单位
price: product.price || 0,
quantity: 1,
discount: 0,
taxRate: normalizeTaxRateDisplay(product.taxRate, 13), // 税率:整数不显示小数位
taxAmount: 0,
subtotal: product.price || 0,
subtotal: 0,
packageSpec: '',
deliveryMethod: ''
};
@@ -1590,6 +1616,13 @@ const initEdit = async (id: string | number) => {
await getQuoteInfo(id);
};
const initCopy = async (copyId: string | number) => {
resetPanelState();
await dictStore.loadAllDict();
await Promise.all([loadSupplierList(), loadUserList()]);
await getQuoteInfoForCopy(copyId);
};
const finishClose = () => {
if (props.hideFooter) {
emit('close');
@@ -1615,6 +1648,7 @@ defineExpose({
formTitle,
initNew,
initEdit,
initCopy,
handleSaveDraft,
handleSubmit,
saveLoading
+359 -192
View File
@@ -245,7 +245,7 @@
</el-table-column>
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="报价单号" align="center" prop="quoteNo" min-width="150" show-overflow-tooltip />
<el-table-column label="报价主题" prop="quoteTitle" show-overflow-tooltip min-width="200" align="center" header-align="center">
<el-table-column label="报价主题" prop="quoteTitle" show-overflow-tooltip min-width="260" align="center" header-align="center">
<template #default="scope">
<el-link type="primary" @click="handleView(scope.row)">{{ scope.row.quoteTitle }}</el-link>
</template>
@@ -459,7 +459,7 @@
选择审批人<span class="form-label-tip">可选择多个审批人,按顺序依次审批</span>
</template>
<el-select v-model="submitForm.approverIds" multiple placeholder="请选择审批人" style="width: 100%">
<el-option v-for="user in userList" :key="user.userId" :label="`${user.nickName}(${user.userName})`" :value="user.userId" />
<el-option v-for="user in userList" :key="user.userId" :label="formatApproverOptionLabel(user)" :value="user.userId" />
</el-select>
</el-form-item>
<el-form-item label="备注说明" prop="remark" class="form-col-span-2">
@@ -985,7 +985,41 @@ const handleSaveColumnSetting = () => {
}
};
// 查询参数
type QuoteViewName = 'my' | 'all' | 'submitted' | 'accepted'
interface TabFilterState {
queryParams: QuoteQuery
dateRange: string[]
quickSearch: string
activeDateShortcut: 'all' | 'today' | 'tomorrow' | 'yesterday' | 'week'
}
const createDefaultTabFilter = (): TabFilterState => ({
queryParams: {
pageNum: 1,
pageSize: 10,
keyword: undefined,
quoteNo: undefined,
quoteTitle: undefined,
customerName: undefined,
quoteType: undefined,
status: undefined,
quoteDateBegin: undefined,
quoteDateEnd: undefined
},
dateRange: [],
quickSearch: '',
activeDateShortcut: 'all'
})
const tabFilterMap = reactive<Record<QuoteViewName, TabFilterState>>({
my: createDefaultTabFilter(),
all: createDefaultTabFilter(),
submitted: createDefaultTabFilter(),
accepted: createDefaultTabFilter()
})
// 查询参数(当前 tab 的检索条件)
const queryParams = ref<QuoteQuery>({
pageNum: 1,
pageSize: 10,
@@ -1019,7 +1053,84 @@ const quickSearch = ref('');
const activeDateShortcut = ref<'all' | 'today' | 'tomorrow' | 'yesterday' | 'week'>('all');
// 视图切换
const activeView = ref('my');
const activeView = ref<QuoteViewName>('my');
const previousView = ref<QuoteViewName>('my');
/** 保存当前 tab 的检索条件(仅用户可见筛选项,不含视图隐式参数) */
const saveTabFilter = (view: QuoteViewName) => {
tabFilterMap[view] = {
queryParams: {
pageNum: queryParams.value.pageNum,
pageSize: queryParams.value.pageSize,
keyword: queryParams.value.keyword,
quoteNo: queryParams.value.quoteNo,
quoteTitle: queryParams.value.quoteTitle,
customerName: queryParams.value.customerName,
quoteType: queryParams.value.quoteType,
status: queryParams.value.status,
quoteDateBegin: queryParams.value.quoteDateBegin,
quoteDateEnd: queryParams.value.quoteDateEnd
},
dateRange: [...dateRange.value],
quickSearch: quickSearch.value,
activeDateShortcut: activeDateShortcut.value
}
}
/** 恢复目标 tab 的检索条件 */
const restoreTabFilter = (view: QuoteViewName) => {
const saved = tabFilterMap[view]
queryParams.value = { ...saved.queryParams }
dateRange.value = [...saved.dateRange]
quickSearch.value = saved.quickSearch
activeDateShortcut.value = saved.activeDateShortcut
}
/** 组装列表/导出请求参数(视图隐式条件不污染 UI 绑定的 queryParams) */
const buildListQueryParams = (): QuoteQuery => {
const params: QuoteQuery = { ...queryParams.value }
if (dateRange.value && dateRange.value.length === 2) {
params.quoteDateBegin = dateRange.value[0]
params.quoteDateEnd = dateRange.value[1]
} else {
params.quoteDateBegin = undefined
params.quoteDateEnd = undefined
}
const selectedStatus = params.status
if (!selectedStatus) {
params.excludeStatus = 'invalid'
} else {
params.excludeStatus = undefined
}
if (activeView.value === 'my') {
params.ownerUserId = resolveCurrentUserId() as unknown as number
params.status = selectedStatus
params.approvedByUserId = undefined
params.pendingApprovalUserId = undefined
} else if (activeView.value === 'accepted') {
params.ownerUserId = undefined
params.status = selectedStatus
params.approvedByUserId = resolveCurrentUserId() as unknown as number
params.pendingApprovalUserId = undefined
} else if (activeView.value === 'submitted') {
// 待审批:仅展示当前轮到本人审批的报价单
params.ownerUserId = undefined
params.status = selectedStatus || 'submitted'
params.approvedByUserId = undefined
params.pendingApprovalUserId = resolveCurrentUserId() as unknown as number
} else {
params.ownerUserId = undefined
params.status = selectedStatus
params.approvedByUserId = undefined
params.pendingApprovalUserId = undefined
}
return params
}
// 提交审批对话框
const submitDialog = reactive({
@@ -1116,48 +1227,7 @@ const approvalFormRef = ref<FormInstance>();
const getList = async () => {
loading.value = true;
try {
// 处理日期范围
if (dateRange.value && dateRange.value.length === 2) {
queryParams.value.quoteDateBegin = dateRange.value[0];
queryParams.value.quoteDateEnd = dateRange.value[1];
} else {
queryParams.value.quoteDateBegin = undefined;
queryParams.value.quoteDateEnd = undefined;
}
const selectedStatus = queryParams.value.status;
// 默认排除已作废的记录,除非用户显式选择了状态
if (!selectedStatus) {
queryParams.value.excludeStatus = 'invalid';
} else {
queryParams.value.excludeStatus = undefined;
}
// 根据视图设置状态过滤
if (activeView.value === 'my') {
// 只查询当前用户负责的报价单
queryParams.value.ownerUserId = resolveCurrentUserId() as unknown as number;
queryParams.value.status = selectedStatus;
queryParams.value.approvedByUserId = undefined;
} else if (activeView.value === 'accepted') {
// 查询当前用户已审批过的报价单(包括审批通过和审批拒绝)
queryParams.value.ownerUserId = undefined;
queryParams.value.status = selectedStatus;
queryParams.value.approvedByUserId = resolveCurrentUserId() as unknown as number;
} else if (activeView.value === 'submitted') {
// 待审批视图:默认submitted,若用户选择状态则按用户选择
queryParams.value.ownerUserId = undefined;
queryParams.value.status = selectedStatus || activeView.value;
queryParams.value.approvedByUserId = undefined;
} else {
// 查看全部报价单,清空视图相关限制
queryParams.value.ownerUserId = undefined;
queryParams.value.status = selectedStatus;
queryParams.value.approvedByUserId = undefined;
}
const res = await listQuote(queryParams.value);
const res = await listQuote(buildListQueryParams());
quoteList.value = res.rows;
total.value = res.total;
} finally {
@@ -1239,8 +1309,14 @@ const handleDateShortcut = (type: 'all' | 'today' | 'tomorrow' | 'yesterday' | '
/** 视图切换 */
const handleViewChange = () => {
queryParams.value.pageNum = 1;
getList();
const newView = activeView.value
if (previousView.value !== newView) {
saveTabFilter(previousView.value)
restoreTabFilter(newView)
previousView.value = newView
}
queryParams.value.pageNum = 1
getList()
};
/** 多选框选中数据 */
@@ -1287,17 +1363,13 @@ const handleAdd = () => {
};
/** 复制新增按钮操作 */
const handleCopyAdd = async (row?: QuoteVO) => {
const handleCopyAdd = (row?: QuoteVO) => {
if (!row && ids.value.length !== 1) {
$modal.msgWarning('请选择一条数据进行复制新增');
return;
}
const id = row?.id || ids.value[0];
// 跳转到表单页面,并传递copyId参数
router.push({
path: '/crm/quote/form',
query: { copyId: id }
});
quoteFormDialogRef.value?.openCopy(id);
};
/** 修改按钮操作 */
@@ -1369,7 +1441,7 @@ const handleQuickSearchClear = () => {
const handleExport = async () => {
exportLoading.value = true;
try {
const res = await exportQuote(queryParams.value)
const res = await exportQuote(buildListQueryParams())
const blob =
res instanceof Blob
? res
@@ -1394,6 +1466,13 @@ const handleStatistics = () => {
router.push('/crm/quote/statistics');
};
const formatApproverOptionLabel = (user: { nickName?: string; userName?: string }) => {
const nick = (user.nickName || '').trim()
const account = (user.userName || '').trim()
if (nick && account) return `${nick}(${account})`
return nick || account
}
/** 加载用户列表 */
const loadUserList = async () => {
try {
@@ -1401,8 +1480,8 @@ const loadUserList = async () => {
const options = (res as any)?.data ?? res ?? [];
userList.value = (Array.isArray(options) ? options : []).map((item: any) => ({
userId: item.value ?? item.userId ?? item.id,
nickName: item.label ?? item.nickName ?? item.userName ?? '',
userName: item.userNo ?? item.userName ?? '',
nickName: item.nickName ?? item.label ?? item.userName ?? '',
userName: item.userNo ?? item.user_no ?? item.userName ?? '',
deptName: item.deptName ?? ''
}));
} catch (error) {
@@ -1504,10 +1583,7 @@ const handleCopyLink = async () => {
/** 复制报价单 */
const handleCopy = (row: QuoteVO) => {
router.push({
path: '/crm/quote/form',
query: { copyId: row.id }
});
quoteFormDialogRef.value?.openCopy(row.id);
};
/** 查看版本历史 */
@@ -1677,94 +1753,189 @@ const getExpireDays = (expireDate: string): number => {
return Math.ceil(diffTime / (1000 * 3600 * 24));
};
const TEMPLATE_SUPPLIER_MARKER = /<!--\s*quote-template-supplier:([A-Z]+)\s*-->/
const TEMPLATE_DYNAMIC_PRODUCTS_PATTERN =
/<!--\s*quote-dynamic-products-start\s*-->[\s\S]*?<!--\s*quote-dynamic-products-end\s*-->/
/** 从供方名称推断编码(与合同模块一致,供 API 未返回 supplierCode 时兜底) */
const inferSupplierCodeFromName = (name?: string): string => {
if (!name) return 'SX';
if (name.includes('云栋') || name.includes('安徽云栋化工有限公司')) return 'YD';
if (name.includes('山峡') || name.includes('安庆市山峡')) return 'SX';
return 'SX';
};
const resolveSupplierCode = (quote: QuoteVO, supplierInfo?: SupplierInfoVO): string => {
const name = supplierInfo?.supplierName || quote.supplierName || '';
return (supplierInfo?.supplierCode || quote.supplierCode || inferSupplierCodeFromName(name) || 'SX').trim();
};
const extractTemplateSupplierCode = (html: string): string | null => {
const match = html.match(TEMPLATE_SUPPLIER_MARKER);
return match?.[1] ?? null;
};
/** 已保存模板是否与当前报价主体不一致(供方变更时需整页重新生成) */
const isStaleQuoteTemplate = (saved: string, expectedCode: string, expectedSupplyName: string): boolean => {
if (!saved?.trim()) return false;
const markedCode = extractTemplateSupplierCode(saved);
if (markedCode) return markedCode !== expectedCode;
if (expectedCode !== 'SX' && (saved.includes('安庆市山峡化工有限公司') || saved.includes('山峡销售报价单') || saved.includes('山峡采购报价单'))) {
return true;
}
if (expectedSupplyName && saved.includes('安庆市山峡化工有限公司') && expectedSupplyName.includes('云栋')) {
return true;
}
return false;
};
const formatQuoteTemplateNumber = (value: any, decimals: number = 2): string => {
const num = parseFloat(value || 0);
return isNaN(num) ? '0.00' : num.toFixed(decimals);
};
const getQuoteProductUnitLabel = (dictValue: string): string => {
if (!dictValue) return '';
const dict = dictStore.getOptions('crm_product_unit').find((item) => item.value === dictValue);
return dict?.label || dictValue;
};
const calcQuoteProductTotal = (quote: QuoteVO): number =>
quote.productList?.reduce((sum, item) => {
const subtotal = parseFloat(String(item.subtotal || 0));
return sum + (isNaN(subtotal) ? 0 : subtotal);
}, 0) || 0;
/** 将数字转换为人民币大写(适用于展示合计金额) */
const numberToRMBUpper = (n: number | string): string => {
const num = Number(n) || 0;
if (num === 0) return '零元整';
const digit = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'];
const unitMajor = ['', '万', '亿'];
const unitMinor = ['', '拾', '佰', '仟'];
const head = num < 0 ? '负' : '';
const money = Math.abs(Math.round(num * 100));
const integer = Math.floor(money / 100);
const dec = money % 100;
let s = '';
if (integer > 0) {
let intStr = '';
let unitPos = 0;
let temp = integer;
while (temp > 0) {
let section = temp % 10000;
let sectionStr = '';
let pos = 0;
while (section > 0 && pos < 4) {
const v = section % 10;
if (v !== 0) {
sectionStr = digit[v] + (pos ? unitMinor[pos] : '') + sectionStr;
} else if (sectionStr && !sectionStr.startsWith('零')) {
sectionStr = '零' + sectionStr;
}
section = Math.floor(section / 10);
pos++;
}
if (sectionStr) {
const major = unitMajor[unitPos] || '';
intStr = sectionStr + major + intStr;
}
temp = Math.floor(temp / 10000);
unitPos++;
}
s += intStr.replace(/零+/g, '零').replace(/零$/, '');
s += '元';
} else {
s += '零元';
}
if (dec === 0) {
s += '整';
} else {
const jiao = Math.floor(dec / 10);
const fen = dec % 10;
if (jiao > 0) s += digit[jiao] + '角';
if (fen > 0) s += digit[fen] + '分';
}
return head + s;
};
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 productRows =
quote.productList
?.map(
(product, index) => `
<tr>
<td style="padding: 8px; border: 1px solid #000; text-align: center;">${index + 1}</td>
<td style="padding: 8px; border: 1px solid #000;">${product.productName || ''}</td>
<td style="padding: 8px; border: 1px solid #000; text-align: center;">${product.model || ''}</td>
<td style="padding: 8px; border: 1px solid #000; text-align: center;">${getQuoteProductUnitLabel(product.unit || '')}</td>
<td style="padding: 8px; border: 1px solid #000; text-align: center;">${formatQuoteTemplateNumber(product.quantity, 4)}</td>
<td style="padding: 8px; border: 1px solid #000; text-align: right;">${formatQuoteTemplateNumber(product.price)}</td>
<td style="padding: 8px; border: 1px solid #000; text-align: right;">${formatQuoteTemplateNumber(product.taxAmount)}</td>
<td style="padding: 8px; border: 1px solid #000; text-align: right;">${formatQuoteTemplateNumber(product.subtotal)}</td>
<td style="padding: 8px; border: 1px solid #000; text-align: center;">${product.packageSpec || ''}</td>
<td style="padding: 8px; border: 1px solid #000; text-align: center;">${product.deliveryMethod || '自提'}</td>
</tr>
`
)
.join('') || '<tr><td colspan="10" style="padding: 8px; border: 1px solid #000; text-align: center;">暂无产品信息</td></tr>';
const productTotal = calcQuoteProductTotal(quote);
const totalRow = `<!-- 合计行 -->
<tr>
<td colspan="2" style="padding: 8px; border: 1px solid #000; background-color: #fff; text-align: center;"><strong>合计(元):</strong></td>
<td colspan="8" style="padding: 8px; border: 1px solid #000; text-align: center; font-size: 16px;">
${numberToRMBUpper(productTotal)}(¥${formatQuoteTemplateNumber(productTotal)})
</td>
</tr>`;
return `<!-- quote-dynamic-products-start -->
${productRows}
${totalRow}
<!-- quote-dynamic-products-end -->`;
};
/** 将已保存模板中的产品明细区块替换为当前数据库产品数据 */
const applyQuoteProductDataToTemplate = (
savedTemplate: string,
quote: QuoteVO,
supplierInfo?: SupplierInfoVO
): string => {
const dynamicSection = buildQuoteDynamicProductSection(quote);
if (TEMPLATE_DYNAMIC_PRODUCTS_PATTERN.test(savedTemplate)) {
return savedTemplate.replace(TEMPLATE_DYNAMIC_PRODUCTS_PATTERN, dynamicSection);
}
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>
${dynamicSection}
<!-- 备注行 -->
${remarkRow}
</tbody>`;
});
};
/** 生成报价单HTML模版 */
const generateQuoteTemplate = (quote: QuoteVO, supplierInfo?: SupplierInfoVO): string => {
// 辅助函数:安全地将值转换为数字并格式化
const formatNumber = (value: any, decimals: number = 2): string => {
const num = parseFloat(value || 0);
return isNaN(num) ? '0.00' : num.toFixed(decimals);
};
// 字典值转换为标签
const getDictLabel = (dictValue: string): string => {
if (!dictValue) return '';
const dict = dictStore.getOptions('crm_product_unit').find((item) => item.value === dictValue);
return dict?.label || dictValue;
};
// 辅助函数:在每个汉字之间添加空格
const addSpaceBetweenChars = (str: string): string => {
if (!str) return '';
return str.split('').join(' ');
};
// 辅助函数:将数字转换为人民币大写(适用于展示合计金额)
const numberToRMBUpper = (n: number | string): string => {
const num = Number(n) || 0;
if (num === 0) return '零元整';
const digit = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'];
const unitMajor = ['', '万', '亿'];
const unitMinor = ['', '拾', '佰', '仟'];
const head = num < 0 ? '负' : '';
const money = Math.abs(Math.round(num * 100));
const integer = Math.floor(money / 100);
const dec = money % 100;
let s = '';
// integer part
if (integer > 0) {
let intStr = '';
let unitPos = 0;
let temp = integer;
while (temp > 0) {
let section = temp % 10000;
let sectionStr = '';
let pos = 0;
// iterate only while there are digits in this section to avoid leading zeros
while (section > 0 && pos < 4) {
const v = section % 10;
if (v !== 0) {
sectionStr = digit[v] + (pos ? unitMinor[pos] : '') + sectionStr;
} else if (sectionStr && !sectionStr.startsWith('零')) {
sectionStr = '零' + sectionStr;
}
section = Math.floor(section / 10);
pos++;
}
if (sectionStr) {
const major = unitMajor[unitPos] || '';
intStr = sectionStr + major + intStr;
}
temp = Math.floor(temp / 10000);
unitPos++;
}
s += intStr.replace(/零+/g, '零').replace(/零$/, '');
s += '元';
} else {
s += '零元';
}
// decimal part
if (dec === 0) {
s += '整';
} else {
const jiao = Math.floor(dec / 10);
const fen = dec % 10;
if (jiao > 0) s += digit[jiao] + '角';
if (fen > 0) s += digit[fen] + '分';
}
return head + s;
};
// 计算产品总金额
const productTotal =
quote.productList?.reduce((sum, item) => {
const subtotal = parseFloat(String(item.subtotal || 0));
return sum + (isNaN(subtotal) ? 0 : subtotal);
}, 0) || 0;
// 格式化日期(从创建时间获取日期)
const formatDate = (dateStr: string): string => {
if (!dateStr) return '';
const date = new Date(dateStr);
@@ -1774,9 +1945,7 @@ const generateQuoteTemplate = (quote: QuoteVO, supplierInfo?: SupplierInfoVO): s
return `${year}/${month}/${day}`;
};
// 获取供应商编码,默认为SX
const supplierCode = supplierInfo?.supplierCode || quote.supplierCode || 'SX';
// 获取供应商简称和名称
const supplierCode = resolveSupplierCode(quote, supplierInfo);
const supplierShortName = supplierInfo?.shortName || quote.supplierShortName || '';
const supplierName = supplierInfo?.supplierName || quote.supplierName || '安庆市山峡化工有限公司';
@@ -1796,36 +1965,14 @@ const generateQuoteTemplate = (quote: QuoteVO, supplierInfo?: SupplierInfoVO): s
const supplyContactDisplayName = addSpaceBetweenChars(isPurchaseQuote ? customerContactName : supplierContactName);
const supplyContactPhone = isPurchaseQuote ? customerContactPhone : supplierContactPhone;
// 根据供应商编码决定是否显示Logo和背景图片
const showLogo = supplierCode === 'SX';
const brandName = showLogo ? '山峡' : supplierShortName || supplierName || '山峡';
const quoteTitle = `${brandName}${quoteTypeLabel}报价单`;
// 根据供应商编码选择印章图片
const sealImg = supplierCode === 'SX' ? yzImg : supplierCode === 'YD' ? ydImg : null;
const dynamicProductSection = buildQuoteDynamicProductSection(quote);
// 构建产品明细表格行
const productRows =
quote.productList
?.map(
(product, index) => `
<tr>
<td style="padding: 8px; border: 1px solid #000; text-align: center;">${index + 1}</td>
<td style="padding: 8px; border: 1px solid #000;">${product.productName || ''}</td>
<td style="padding: 8px; border: 1px solid #000; text-align: center;">${product.model || ''}</td>
<td style="padding: 8px; border: 1px solid #000; text-align: center;">${getDictLabel(product.unit)}</td>
<td style="padding: 8px; border: 1px solid #000; text-align: center;">${formatNumber(product.quantity, 4)}</td>
<td style="padding: 8px; border: 1px solid #000; text-align: right;">${formatNumber(product.price)}</td>
<td style="padding: 8px; border: 1px solid #000; text-align: right;">${formatNumber(product.taxAmount)}</td>
<td style="padding: 8px; border: 1px solid #000; text-align: right;">${formatNumber(product.subtotal)}</td>
<td style="padding: 8px; border: 1px solid #000; text-align: center;">${product.packageSpec || ''}</td>
<td style="padding: 8px; border: 1px solid #000; text-align: center;">${product.deliveryMethod || '自提'}</td>
</tr>
`
)
.join('') || '<tr><td colspan="10" style="padding: 8px; border: 1px solid #000; text-align: center;">暂无产品信息</td></tr>';
// 注意:水印已在TinyMCE编辑器中统一设置,这里无需重复添加
return `
<!-- quote-template-supplier:${supplierCode} -->
<div style="max-width: 794px; margin: 0 auto; padding: 20px; font-family: Arial, '微软雅黑', sans-serif; font-size: 13px;">
${
showLogo
@@ -1885,18 +2032,9 @@ const generateQuoteTemplate = (quote: QuoteVO, supplierInfo?: SupplierInfoVO): s
</tr>
</thead>
<tbody>
${productRows}
${dynamicProductSection}
<!-- 备注行 -->
<tr>
<td colspan="10" style="padding: 8px; border: 1px solid #000; background-color: #f9f9f9; text-align: center; font-size: 13px;"> </td>
</tr>
<!-- 合计行 -->
<tr>
<td colspan="2" style="padding: 8px; border: 1px solid #000; background-color: #fff; text-align: center;"><strong>合计(元):</strong></td>
<td colspan="8" style="padding: 8px; border: 1px solid #000; text-align: center; font-size: 16px;">
${numberToRMBUpper(productTotal)}(¥${formatNumber(productTotal)})
</td>
</tr>
${DEFAULT_QUOTE_TABLE_REMARK_ROW}
</tbody>
</table>
@@ -1965,23 +2103,32 @@ const handleGenerateQuote = async (row?: QuoteVO) => {
console.log('报价单详情数据:', quoteDetail);
// 获取供应商详细信息
// 获取供应商详细信息(详情接口已带 supplierCode,此处再拉一次供方主数据兜底)
let supplierInfo: SupplierInfoVO | undefined = undefined;
if (quoteDetail.supplierId) {
try {
const supplierRes = await getSupplierInfo(quoteDetail.supplierId);
supplierInfo = supplierRes.data;
console.log('供应商信息:', supplierInfo);
// 设置当前供应商编码,用于控制TinyMCE编辑器的背景图片和Logo显示
currentSupplierCode.value = supplierInfo?.supplierCode || 'SX';
} catch (error) {
console.warn('获取供应商信息失败:', error);
currentSupplierCode.value = 'SX'; // 默认值
console.warn('获取供应商信息失败,将使用报价单详情中的供方字段:', error);
}
} else {
currentSupplierCode.value = 'SX'; // 默认值
}
if (supplierInfo) {
if (supplierInfo.supplierCode) quoteDetail.supplierCode = supplierInfo.supplierCode;
if (supplierInfo.shortName) quoteDetail.supplierShortName = supplierInfo.shortName;
if (supplierInfo.supplierName) quoteDetail.supplierName = supplierInfo.supplierName;
}
const supplierCode = resolveSupplierCode(quoteDetail, supplierInfo);
currentSupplierCode.value = supplierCode;
const quoteType = (quoteDetail.quoteType as string) || 'sales';
const isPurchaseQuote = quoteType === 'purchase';
const supplierName = supplierInfo?.supplierName || quoteDetail.supplierName || '';
const expectedSupplyName = isPurchaseQuote ? quoteDetail.customerName || '' : supplierName;
// 先尝试从数据库获取已保存的模板
let htmlContent = '';
try {
@@ -1989,13 +2136,16 @@ const handleGenerateQuote = async (row?: QuoteVO) => {
const templateRes = await getTemplate(id);
console.log('获取模板响应:', templateRes);
if (templateRes.data && templateRes.data.trim()) {
htmlContent = templateRes.data;
console.log('✅ 从数据库加载模板成功,长度:', htmlContent.length);
const savedTemplate = templateRes.data?.trim() || '';
if (savedTemplate && !isStaleQuoteTemplate(savedTemplate, supplierCode, expectedSupplyName)) {
htmlContent = applyQuoteProductDataToTemplate(savedTemplate, quoteDetail, supplierInfo);
console.log('✅ 已保存模板加载成功,产品明细已按数据库数据替换');
} else {
// 如果没有保存的模板,则生成新的HTML内容
if (savedTemplate) {
console.log('⚠️ 已保存模板与当前报价主体不一致,整页重新生成');
}
htmlContent = generateQuoteTemplate(quoteDetail, supplierInfo);
console.log('❌ 数据库无模板,生成新的HTML内容,长度:', htmlContent.length);
console.log('生成新的HTML内容,长度:', htmlContent.length);
}
} catch (error) {
// 如果获取模板失败,则生成新的HTML内容
@@ -2334,6 +2484,23 @@ const handleInvalid = async (row?: QuoteVO) => {
}
};
watchDebounced(
() => ({
quoteNo: queryParams.value.quoteNo,
quoteTitle: queryParams.value.quoteTitle,
customerName: queryParams.value.customerName,
quoteType: queryParams.value.quoteType,
status: queryParams.value.status,
dateRange: dateRange.value,
}),
() => {
queryParams.value.keyword = undefined;
queryParams.value.pageNum = 1;
getList();
},
{ debounce: 300, deep: true }
);
onMounted(() => {
loadPerms()
initColumnSetting();