提交
This commit is contained in:
1 parent
dacfdc9ff6
commit
f9676f8759
906 files changed
+533306
-12946
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface QuoteQuery {
|
||||
quoteType?: string
|
||||
ownerUserId?: number | string
|
||||
approvedByUserId?: number | string
|
||||
pendingApprovalUserId?: number | string
|
||||
minAmount?: number
|
||||
maxAmount?: number
|
||||
isLatest?: number
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user