This commit is contained in:
zlwu committed 2026-08-08 20:43:48 +08:00
1 parent 2df2ef0808
commit 683f14828f
388 files changed
+2738488 -81306

No files matched your search

+3
View File
@@ -47,6 +47,9 @@ function mapListQuery(query: ContractQuery = {}): Record<string, any> {
if (val != null && val !== '') params[key] = String(val)
}
)
if (query.includeProducts) {
params.includeProducts = true
}
return params
}
+22 -1
View File
@@ -28,6 +28,8 @@ export interface ContractQuery {
signDateEnd?: string
beginTime?: string
endTime?: string
/** 列表是否附带合同产品明细(选择合同弹窗等场景) */
includeProducts?: boolean
}
export interface ContractProductPriceTierForm {
@@ -92,6 +94,10 @@ export interface ContractForm {
currency?: string
taxRate?: number
amountWithTax?: number
/** 运费(元),选报价单时可从报价带入 */
freight?: number
/** 桶押金(元),选报价单时可从报价带入 */
barrelDeposit?: number
signDate?: string
actualSignDate?: string
startDate?: string
@@ -152,6 +158,10 @@ export interface ContractVO {
currency?: string
taxRate?: number
amountWithTax?: number
/** 运费(元) */
freight?: number
/** 桶押金(元) */
barrelDeposit?: number
signDate: string
actualSignDate?: string
startDate: string
@@ -377,7 +387,18 @@ export interface ApprovalVO {
/** 报价转合同 — 最小提交体(createFromQuote) */
export type CreateFromQuoteForm = Pick<
ContractForm,
'contractNo' | 'contractName' | 'customerId' | 'contractType' | 'amount' | 'signDate' | 'startDate' | 'endDate' | 'remark' | 'products'
| 'contractNo'
| 'contractName'
| 'customerId'
| 'contractType'
| 'amount'
| 'freight'
| 'barrelDeposit'
| 'signDate'
| 'startDate'
| 'endDate'
| 'remark'
| 'products'
>
/** 合同审批人设置 */
+4
View File
@@ -91,6 +91,8 @@ export interface QuoteForm {
paymentTerms?: string
deliveryTerms?: string
freight?: number
/** 桶押金(元) */
barrelDeposit?: number
attachments?: string
templateContent?: string
remark?: string
@@ -140,6 +142,8 @@ export interface QuoteVO {
paymentTerms?: string
deliveryTerms?: string
freight?: number
/** 桶押金(元) */
barrelDeposit?: number
attachments?: string
createTime: string
updateTime?: string
+8
View File
@@ -26,3 +26,11 @@ export function fetchRemoveMenuFavorite(path: string) {
data: { path }
})
}
/** 按 paths 完整顺序重排收藏(须与当前收藏集合一致) */
export function fetchReorderMenuFavorites(paths: string[]) {
return request.put<MenuFavoriteItem[]>({
url: '/web/admin/menu-favorites/order',
data: { paths }
})
}
+15 -2
View File
@@ -1613,7 +1613,7 @@ body {
.app-layout-portal #app-worktab .worktab-strip {
margin-bottom: 0 !important;
padding: 0 24px 4px !important;
padding: 0 10px !important;
}
/* 弹窗 / 抽屉内键值展示:上下布局、深色正文(对齐 zkxw Token 链) */
@@ -1863,16 +1863,29 @@ body {
height: auto !important;
}
/* label 与输入左右布局:label 右侧留间距 */
/* 筛选区左右布局:label 等宽且右对齐(默认约 4 个汉字;更长文案页用 --filter-label-width 覆盖) */
.page-container .filter-form:not(.el-form--label-top),
.console-container .filter-form:not(.el-form--label-top),
.el-dialog .filter-form:not(.el-form--label-top) {
--filter-label-width: 70px;
}
/* label 与输入左右布局:固定宽度、右对齐、右侧留间距 */
.page-container .filter-form:not(.el-form--label-top) .el-form-item__label,
.console-container .filter-form:not(.el-form--label-top) .el-form-item__label,
.el-dialog .filter-form:not(.el-form--label-top) .el-form-item__label,
.page-container .filter-block:not(:has(.el-form--label-top)) .el-form-item__label,
.console-container .filter-block:not(:has(.el-form--label-top)) .el-form-item__label,
.el-dialog .filter-block:not(:has(.el-form--label-top)) .el-form-item__label {
width: var(--filter-label-width, 70px) !important;
flex: 0 0 var(--filter-label-width, 70px) !important;
justify-content: flex-end !important;
text-align: right !important;
white-space: nowrap;
padding: 0 8px 0 0 !important;
line-height: 36px !important;
height: 36px !important;
box-sizing: border-box;
}
/* 筛选栅格:表单项撑满列宽,控件全宽 */
+121 -71
View File
@@ -9,34 +9,28 @@
:close-on-click-modal="false"
append-to-body
>
<!-- 查询表单 -->
<el-form :model="queryParams" ref="queryFormRef" :inline="true" class="mb-3 search-form-inline">
<el-form-item label="客户名称" prop="customerName">
<el-input v-model="queryParams.customerName" placeholder="请输入客户名称" clearable @keyup.enter="handleQuery" style="width: 180px" />
</el-form-item>
<el-form-item label="客户编号" prop="customerNo">
<el-input v-model="queryParams.customerNo" placeholder="请输入客户编号" clearable @keyup.enter="handleQuery" style="width: 150px" />
</el-form-item>
<el-form-item label="客户类型" prop="customerType">
<el-select v-model="queryParams.customerType" placeholder="请选择客户类型" clearable style="width: 150px">
<el-option v-for="dict in dictStore.getOptions('crm_customer_type')" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item label="手机号码" prop="mobile">
<el-input v-model="queryParams.mobile" placeholder="请输入手机号码" clearable @keyup.enter="handleQuery" style="width: 150px" />
</el-form-item>
<el-form-item class="form-buttons-right">
<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-form-item>
</el-form>
<!-- 客户列表 -->
<div class="panel dialog-list-panel">
<div class="list-toolbar">
<div class="list-toolbar-row customer-selector-toolbar-row">
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="客户名称/拼音首字母"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button class="btn-main" type="primary" @click="handleAddCustomer">
<el-icon><Plus /></el-icon>新增客户
</el-button>
<el-button class="btn-ghost" :disabled="!selectedRow" @click="handleEditCustomer">
<el-icon><Edit /></el-icon>编辑客户
</el-button>
<el-button class="btn-ghost" :disabled="!selectedRow" @click="handleMaintainInvoice">
<el-icon><Stamp /></el-icon>维护开票信息
</el-button>
</div>
</div>
<div class="list-body list-wrap dialog-list-body-only">
<el-table
ref="tableRef"
@@ -179,14 +173,41 @@
</div>
</template>
</el-dialog>
<CustomerFormDialog ref="customerFormRef" @success="handleCustomerFormSuccess" />
<!-- 维护开票信息(一客户多条 + 默认;关闭后不自动回填外层,需重新「确定」选用客户) -->
<el-dialog
v-model="invoiceDialogVisible"
:title="invoiceDialogTitle"
width="1200px"
align-center
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
append-to-body
destroy-on-close
>
<InvoiceInfo v-if="invoiceCustomerId" :customer-id="invoiceCustomerId" />
<template #footer>
<div class="dialog-footer">
<el-button class="btn-ghost" @click="invoiceDialogVisible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="CustomerSelector">
import { useDictStore } from '@/store/modules/dict'
import DictTag from '@/components/DictTag/index.vue'
import ListPagination from '@/components/ListPagination/index.vue'
import { Check, Close, Edit, Refresh, Search } from '@element-plus/icons-vue'
import { ref, watch } from 'vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import CustomerFormDialog from '@/views/crm/customer/CustomerFormDialog.vue'
import InvoiceInfo from '@/views/crm/customer/components/InvoiceInfo.vue'
import { Check, Close, Edit, Plus, Stamp } from '@element-plus/icons-vue'
import { computed, nextTick, ref, watch } from 'vue'
import type { ElFormInstance, FormItemRule } from 'element-plus'
import { fetchCustomerList, fetchUpdateCustomer } from '@/api/crm/customer'
@@ -207,6 +228,11 @@ watch(
dialogVisible.value = val
if (val) {
await dictStore.loadAllDict()
quickSearchKeyword.value = ''
queryParams.value.pageNum = 1
queryParams.value.keyword = undefined
selectedRow.value = null
currentCustomer.value = null
getList()
}
}
@@ -219,18 +245,19 @@ watch(dialogVisible, (val) => {
const queryParams = ref<Record<string, any>>({
pageNum: 1,
pageSize: 20,
customerName: undefined,
customerNo: undefined,
customerType: undefined,
mobile: undefined
keyword: undefined as string | undefined
})
const quickSearchKeyword = ref('')
const customerList = ref<any[]>([])
const loading = ref(false)
const total = ref(0)
/** 表格当前高亮行(可无简称,用于编辑) */
const selectedRow = ref<any | null>(null)
/** 可确认选用的客户(须已填简称) */
const currentCustomer = ref<any | null>(null)
const queryFormRef = ref<ElFormInstance>()
const tableRef = ref<any>()
const customerFormRef = ref<InstanceType<typeof CustomerFormDialog>>()
const shortNameDialogVisible = ref(false)
const shortNameSaving = ref(false)
@@ -252,10 +279,7 @@ const getList = async () => {
const res = await fetchCustomerList({
page: queryParams.value.pageNum,
pageSize: queryParams.value.pageSize,
customerName: queryParams.value.customerName,
customerNo: queryParams.value.customerNo,
customerType: queryParams.value.customerType,
mobile: queryParams.value.mobile
keyword: queryParams.value.keyword || undefined
})
customerList.value = res.list || res.rows || []
total.value = res.total ?? 0
@@ -266,38 +290,61 @@ const getList = async () => {
}
}
const handleQuery = () => {
const handleQuickSearch = () => {
queryParams.value.pageNum = 1
queryParams.value.keyword = quickSearchKeyword.value?.trim() || undefined
getList()
}
const resetQuery = () => {
queryFormRef.value?.resetFields()
handleQuery()
const handleQuickSearchClear = () => {
quickSearchKeyword.value = ''
queryParams.value.pageNum = 1
queryParams.value.keyword = undefined
getList()
}
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 handleAddCustomer = () => {
customerFormRef.value?.open()
}
const handleEditCustomer = () => {
if (!selectedRow.value?.id) {
ElMessage.warning('请先选择一条客户记录')
return
}
customerFormRef.value?.open(selectedRow.value.id)
}
const invoiceDialogVisible = ref(false)
const invoiceCustomerId = ref<string | number>('')
const invoiceCustomerName = ref('')
const invoiceDialogTitle = computed(() =>
invoiceCustomerName.value
? `维护开票信息 — ${invoiceCustomerName.value}`
: '维护开票信息'
)
const handleMaintainInvoice = () => {
if (!selectedRow.value?.id) {
ElMessage.warning('请先选择一条客户记录')
return
}
invoiceCustomerId.value = selectedRow.value.id
invoiceCustomerName.value = selectedRow.value.customerName || ''
invoiceDialogVisible.value = true
}
const handleCustomerFormSuccess = () => {
getList()
}
const handleCurrentChange = (row: any) => {
selectedRow.value = row || null
if (!row) {
currentCustomer.value = null
return
}
if (!hasShortName(row)) {
ElMessage.warning('该客户尚未填写简称,请先填写客户简称后再选择')
tableRef.value?.setCurrentRow(undefined)
currentCustomer.value = null
return
}
@@ -332,8 +379,11 @@ const handleSaveShortName = async () => {
if (updatedRow) {
updatedRow.shortName = trimmedShortName
}
if (currentCustomer.value?.id === targetId) {
currentCustomer.value = { ...currentCustomer.value, shortName: trimmedShortName }
if (selectedRow.value?.id === targetId) {
selectedRow.value = { ...selectedRow.value, shortName: trimmedShortName }
}
if (currentCustomer.value?.id === targetId || (selectedRow.value?.id === targetId && hasShortName(selectedRow.value))) {
currentCustomer.value = { ...(selectedRow.value || currentCustomer.value), shortName: trimmedShortName }
}
shortNameDialogVisible.value = false
@@ -346,7 +396,12 @@ const handleSaveShortName = async () => {
}
const handleConfirm = () => {
if (!currentCustomer.value) return
if (!currentCustomer.value) {
if (selectedRow.value && !hasShortName(selectedRow.value)) {
ElMessage.warning('该客户尚未填写简称,请先填写客户简称后再选择')
}
return
}
if (!hasShortName(currentCustomer.value)) {
ElMessage.warning('该客户尚未填写简称,请先填写客户简称后再选择')
return
@@ -356,6 +411,7 @@ const handleConfirm = () => {
}
const handleClose = () => {
selectedRow.value = null
currentCustomer.value = null
dialogVisible.value = false
}
@@ -368,20 +424,14 @@ const handleClose = () => {
border-radius: 4px;
}
.mb-3 {
margin-bottom: 16px;
/* 弹窗 append-to-body 后不在 .page-container 内,需自行限制检索框宽度,避免独占一行 */
.customer-selector-toolbar-row {
flex-wrap: nowrap;
}
.mt-3 {
margin-top: 16px;
}
.search-form-inline {
display: flex;
flex-wrap: wrap;
}
.form-buttons-right {
margin-left: auto !important;
.customer-selector-toolbar-row :deep(.quick-search-input) {
width: 280px;
max-width: 280px;
flex: 0 0 280px;
}
</style>
@@ -93,15 +93,19 @@
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="contractName"
label="合同名称"
min-width="180"
align="center"
header-align="center"
show-overflow-tooltip
/>
>
<template #default="{ row }">
<el-link
v-if="row.contractNo"
type="primary"
:underline="false"
@click.stop="handleViewContract(row)"
>
{{ row.contractNo }}
</el-link>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column
prop="customerName"
label="客户名称"
@@ -110,6 +114,20 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="产品信息" min-width="280" align="left" header-align="center">
<template #default="{ row }">
<div v-if="row.products?.length" class="contract-selector-products">
<div
v-for="(product, index) in row.products"
:key="product.id || `${row.id}-${index}`"
class="contract-selector-products__line"
>
{{ formatContractProductLine(product) }}
</div>
</div>
<span v-else class="contract-selector-products__empty">—</span>
</template>
</el-table-column>
<el-table-column prop="contractType" label="合同类型" min-width="110" align="center" header-align="center">
<template #default="{ row }">
<DictTag dict-type="crm_contract_type" :value="row.contractType" />
@@ -167,18 +185,25 @@
</div>
</template>
</el-dialog>
<ContractDetailDialog v-if="contractDetailDialogActive" ref="contractDetailRef" hide-product-price />
</template>
<script setup lang="ts">
import { Check, Close, RefreshLeft, Search } from '@element-plus/icons-vue'
import type { FormInstance, TableInstance } from 'element-plus'
import { getContract, listContract } from '@/api/crm/contract'
import type { ContractQuery, ContractVO } from '@/api/crm/contract/types'
import type { ContractProductForm, ContractQuery, ContractVO } from '@/api/crm/contract/types'
import { planApi } from '@/api/pl'
import DictTag from '@/components/DictTag/index.vue'
import ListPagination from '@/components/ListPagination/index.vue'
import { useDictStore } from '@/store/modules/dict'
import { formatMoneyDisplay } from '@/utils/money-format'
import { formatPlanProductListText } from '@/utils/pl-display'
const ContractDetailDialog = defineAsyncComponent(
() => import('@/views/crm/contract/components/ContractDetailDialog.vue')
)
const props = withDefaults(
defineProps<{
@@ -214,6 +239,8 @@ const contractList = ref<ContractVO[]>([])
const currentRow = ref<ContractVO | null>(null)
const selectedId = ref('')
const planCountMap = ref<Record<string, number>>({})
const contractDetailDialogActive = ref(false)
const contractDetailRef = ref<InstanceType<typeof ContractDetailDialog>>()
const queryParams = reactive<ContractQuery>({
pageNum: 1,
@@ -221,7 +248,8 @@ const queryParams = reactive<ContractQuery>({
contractNo: '',
contractName: '',
customerName: '',
excludeStatus: 'invalid'
excludeStatus: 'invalid',
includeProducts: true
})
const selectableStatusHint = computed(() => {
@@ -233,6 +261,30 @@ const selectableStatusHint = computed(() => {
return `仅「${labels.join('」「')}」状态的合同可被选择,其他状态仅展示不可选`
})
function formatContractProductLine(product: ContractProductForm) {
return formatPlanProductListText(
{
productName: product.productName,
model: product.model || product.productModel,
unit: product.unit,
quantity: product.quantity
},
unit => dictStore.getLabel('crm_product_unit', unit),
{ usePlannedQuantity: true }
)
}
async function handleViewContract(row: ContractVO) {
const contractId = row?.id
if (!contractId) {
ElMessage.warning('合同信息无效')
return
}
contractDetailDialogActive.value = true
await nextTick()
contractDetailRef.value?.open(contractId)
}
function isContractSelectable(row: ContractVO): boolean {
if (!props.selectableStatuses?.length) return true
return props.selectableStatuses.includes(row.status)
@@ -477,6 +529,30 @@ async function handleConfirm() {
}
}
.contract-selector-products {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
width: 100%;
text-align: left;
}
.contract-selector-products__line {
width: 100%;
min-width: 0;
font-size: 13px;
line-height: 1.5;
color: var(--text, #1e293b);
white-space: normal;
word-break: break-word;
overflow-wrap: anywhere;
}
.contract-selector-products__empty {
color: var(--mint-text-muted, #94a3b8);
}
.sr-only {
position: absolute;
width: 1px;
@@ -7,26 +7,37 @@
:title="item.fullLabel"
>
<div v-if="item.url" class="pl-carrier-list-attachment-cell__thumb">
<el-image
v-if="item.isImage"
class="pl-carrier-list-attachment-cell__image"
fit="cover"
lazy
:src="displaySrc(item.url)"
:preview-src-list="mediaActive ? [resolvePhotoDisplayUrl(item.url)] : []"
preview-teleported
hide-on-click-modal
<div
class="pl-carrier-list-attachment-cell__preview"
:title="item.isImage || item.isPdf ? '点击在新标签页查看' : '点击查看'"
@click.stop="handlePreview(item)"
>
<template #error>
<div class="pl-carrier-list-attachment-cell__placeholder">
<el-icon :size="20" color="#94a3b8"><Document /></el-icon>
</div>
</template>
</el-image>
<div v-else class="pl-carrier-list-attachment-cell__placeholder">
<el-icon :size="20" color="#94a3b8"><Document /></el-icon>
<span v-if="item.ext" class="pl-carrier-list-attachment-cell__ext">{{ item.ext }}</span>
<el-image
v-if="item.isImage"
class="pl-carrier-list-attachment-cell__image pl-carrier-list-attachment-cell__image--passive"
fit="cover"
lazy
:src="displaySrc(item.url)"
:preview-src-list="[]"
>
<template #error>
<div class="pl-carrier-list-attachment-cell__placeholder">
<el-icon :size="20" color="#94a3b8"><Document /></el-icon>
</div>
</template>
</el-image>
<div v-else class="pl-carrier-list-attachment-cell__placeholder is-clickable">
<el-icon :size="20" color="#0284c7"><Document /></el-icon>
<span v-if="item.ext" class="pl-carrier-list-attachment-cell__ext">{{ item.ext }}</span>
</div>
</div>
<el-icon
class="pl-carrier-list-attachment-cell__download"
title="下载"
@click.stop="handleDownload(item)"
>
<Download />
</el-icon>
<span class="pl-carrier-list-attachment-cell__caption">{{ item.label }}</span>
</div>
<div v-else class="pl-carrier-list-attachment-cell__empty">
@@ -39,9 +50,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Document } from '@element-plus/icons-vue'
import { Document, Download } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { Carrier } from '@/api/pl'
import { normalizeAttachmentUrls } from '@/utils/download-attachment'
import {
downloadAttachmentFile,
normalizeAttachmentUrls,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
const props = withDefaults(
@@ -65,6 +81,7 @@
fullLabel: string
url: string
isImage: boolean
isPdf: boolean
ext: string
}
@@ -84,6 +101,13 @@
return /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(path)
}
function isPdfUrl(url: string) {
const path = String(url || '')
.split('?')[0]
.toLowerCase()
return /\.pdf$/i.test(path)
}
function getFileExt(url: string) {
const name =
String(url || '')
@@ -95,11 +119,47 @@
return name.slice(lastDot + 1).toUpperCase()
}
function fileNameFromUrl(url: string) {
return (
String(url || '')
.split('?')[0]
.split('/')
.pop() || '附件'
)
}
function displaySrc(url: string) {
if (!props.mediaActive) return ''
return resolvePhotoDisplayUrl(url)
}
async function handlePreview(item: ItemDef) {
if (!item.url) return
await openAttachmentPreviewInNewTab(
{ fileUrl: item.url, fileName: fileNameFromUrl(item.url) },
{
fullFileUrl: resolvePhotoDisplayUrl,
kind: item.isImage ? 'image' : item.isPdf ? 'pdf' : 'other'
}
)
}
async function handleDownload(item: ItemDef) {
if (!item.url) {
ElMessage.error('无法获取文件下载地址')
return
}
try {
await downloadAttachmentFile(
{ fileUrl: item.url, fileName: fileNameFromUrl(item.url) },
{ fullFileUrl: resolvePhotoDisplayUrl }
)
ElMessage.success('下载成功')
} catch {
ElMessage.error('下载失败,请稍后重试')
}
}
const items = computed<ItemDef[]>(() => {
const defs = [
{
@@ -132,6 +192,7 @@
fullLabel: def.fullLabel,
url: '',
isImage: false,
isPdf: false,
ext: ''
})
continue
@@ -143,6 +204,7 @@
fullLabel: def.fullLabel,
url,
isImage: isImageUrl(url),
isPdf: isPdfUrl(url),
ext: getFileExt(url)
})
})
@@ -186,10 +248,20 @@
font-size: 12px;
}
.pl-carrier-list-attachment-cell__preview {
width: 100%;
height: 100%;
cursor: pointer;
}
.pl-carrier-list-attachment-cell__image {
width: 100%;
height: 100%;
cursor: pointer;
&--passive {
pointer-events: none;
}
}
.pl-carrier-list-attachment-cell__placeholder {
@@ -201,12 +273,37 @@
width: 100%;
height: 100%;
margin-bottom: 14px;
&.is-clickable {
background: #e0f2fe;
}
}
.pl-carrier-list-attachment-cell__ext {
font-size: 10px;
line-height: 1.2;
color: #64748b;
font-weight: 600;
color: #0284c7;
}
.pl-carrier-list-attachment-cell__download {
position: absolute;
right: 2px;
top: 2px;
z-index: 2;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
border: 1px solid #e2e8f0;
color: #1e293b;
cursor: pointer;
&:hover {
color: #1ca851;
border-color: #1ca851;
background: #e6f8ed;
}
}
.pl-carrier-list-attachment-cell__empty-text {
@@ -7,26 +7,37 @@
:title="item.fullLabel"
>
<div v-if="item.url" class="pl-cert-list-attachment-cell__thumb">
<el-image
v-if="item.isImage"
class="pl-cert-list-attachment-cell__image"
fit="cover"
lazy
:src="displaySrc(item.url)"
:preview-src-list="mediaActive ? [resolvePhotoDisplayUrl(item.url)] : []"
preview-teleported
hide-on-click-modal
<div
class="pl-cert-list-attachment-cell__preview"
:title="item.isImage || item.isPdf ? '点击在新标签页查看' : '点击查看'"
@click.stop="handlePreview(item)"
>
<template #error>
<div class="pl-cert-list-attachment-cell__placeholder">
<el-icon :size="20" color="#94a3b8"><Document /></el-icon>
</div>
</template>
</el-image>
<div v-else class="pl-cert-list-attachment-cell__placeholder">
<el-icon :size="20" color="#94a3b8"><Document /></el-icon>
<span v-if="item.ext" class="pl-cert-list-attachment-cell__ext">{{ item.ext }}</span>
<el-image
v-if="item.isImage"
class="pl-cert-list-attachment-cell__image pl-cert-list-attachment-cell__image--passive"
fit="cover"
lazy
:src="displaySrc(item.url)"
:preview-src-list="[]"
>
<template #error>
<div class="pl-cert-list-attachment-cell__placeholder">
<el-icon :size="20" color="#94a3b8"><Document /></el-icon>
</div>
</template>
</el-image>
<div v-else class="pl-cert-list-attachment-cell__placeholder is-clickable">
<el-icon :size="20" color="#0284c7"><Document /></el-icon>
<span v-if="item.ext" class="pl-cert-list-attachment-cell__ext">{{ item.ext }}</span>
</div>
</div>
<el-icon
class="pl-cert-list-attachment-cell__download"
title="下载"
@click.stop="handleDownload(item)"
>
<Download />
</el-icon>
<span class="pl-cert-list-attachment-cell__caption">{{ item.label }}</span>
</div>
<div v-else class="pl-cert-list-attachment-cell__empty">
@@ -40,9 +51,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Document } from '@element-plus/icons-vue'
import { Document, Download } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { CertRead } from '@/api/pl'
import { normalizeAttachmentUrls } from '@/utils/download-attachment'
import {
downloadAttachmentFile,
normalizeAttachmentUrls,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
import { PL_VEHICLE_CERT_PART_TRAILER, normalizeVehicleCertPart } from '@/utils/pl-vehicle-cert'
@@ -67,6 +83,7 @@
fullLabel: string
url: string
isImage: boolean
isPdf: boolean
ext: string
}
@@ -93,6 +110,13 @@
return /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(path)
}
function isPdfUrl(url: string) {
const path = String(url || '')
.split('?')[0]
.toLowerCase()
return /\.pdf$/i.test(path)
}
function getFileExt(url: string) {
const name =
String(url || '')
@@ -104,11 +128,47 @@
return name.slice(lastDot + 1).toUpperCase()
}
function fileNameFromUrl(url: string) {
return (
String(url || '')
.split('?')[0]
.split('/')
.pop() || '附件'
)
}
function displaySrc(url: string) {
if (!props.mediaActive) return ''
return resolvePhotoDisplayUrl(url)
}
async function handlePreview(item: ItemDef) {
if (!item.url) return
await openAttachmentPreviewInNewTab(
{ fileUrl: item.url, fileName: fileNameFromUrl(item.url) },
{
fullFileUrl: resolvePhotoDisplayUrl,
kind: item.isImage ? 'image' : item.isPdf ? 'pdf' : 'other'
}
)
}
async function handleDownload(item: ItemDef) {
if (!item.url) {
ElMessage.error('无法获取文件下载地址')
return
}
try {
await downloadAttachmentFile(
{ fileUrl: item.url, fileName: fileNameFromUrl(item.url) },
{ fullFileUrl: resolvePhotoDisplayUrl }
)
ElMessage.success('下载成功')
} catch {
ElMessage.error('下载失败,请稍后重试')
}
}
const items = computed<ItemDef[]>(() => {
const certs = props.certList || []
if (!certs.length) return []
@@ -134,6 +194,7 @@
fullLabel,
url: '',
isImage: false,
isPdf: false,
ext: ''
})
return
@@ -146,6 +207,7 @@
fullLabel,
url,
isImage: isImageUrl(url),
isPdf: isPdfUrl(url),
ext: getFileExt(url)
})
})
@@ -189,10 +251,20 @@
font-size: 12px;
}
.pl-cert-list-attachment-cell__preview {
width: 100%;
height: 100%;
cursor: pointer;
}
.pl-cert-list-attachment-cell__image {
width: 100%;
height: 100%;
cursor: pointer;
&--passive {
pointer-events: none;
}
}
.pl-cert-list-attachment-cell__placeholder {
@@ -204,12 +276,37 @@
width: 100%;
height: 100%;
margin-bottom: 14px;
&.is-clickable {
background: #e0f2fe;
}
}
.pl-cert-list-attachment-cell__ext {
font-size: 10px;
line-height: 1.2;
color: #64748b;
font-weight: 600;
color: #0284c7;
}
.pl-cert-list-attachment-cell__download {
position: absolute;
right: 2px;
top: 2px;
z-index: 2;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
border: 1px solid #e2e8f0;
color: #1e293b;
cursor: pointer;
&:hover {
color: #1ca851;
border-color: #1ca851;
background: #e6f8ed;
}
}
.pl-cert-list-attachment-cell__empty-text {
@@ -12,47 +12,69 @@
</el-table-column>
<el-table-column label="别名" min-width="140" align="center" show-overflow-tooltip>
<template #default="{ row }">
<el-select
v-if="editableAliasBrand && (row.aliasOptions || []).length"
v-model="row.aliasName"
class="pl-plan-product-cell-control"
placeholder="不选则用产品名称"
clearable
filterable
style="width: 100%"
popper-class="pl-plan-alias-brand-select-dropdown"
@change="() => handleAliasChange(row)"
>
<el-option
v-for="name in row.aliasOptions"
:key="name"
:label="name"
:value="name"
/>
</el-select>
<div v-if="editableAliasBrand" class="line-meta-cell">
<el-select
v-if="(row.aliasOptions || []).length"
v-model="row.aliasName"
class="pl-plan-product-cell-control line-meta-cell__control"
placeholder="不选则用产品名称"
clearable
filterable
popper-class="pl-plan-alias-brand-select-dropdown"
@change="() => handleAliasChange(row)"
>
<el-option
v-for="name in row.aliasOptions"
:key="name"
:label="name"
:value="name"
/>
</el-select>
<span v-else class="text-muted line-meta-cell__empty">—</span>
<el-button
v-if="row.productId"
link
type="primary"
class="line-meta-cell__btn"
@click="openAliasBrandManage(row, 'alias')"
>
<el-icon><Plus /></el-icon>
</el-button>
</div>
<span v-else>{{ row.aliasName || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="品牌" min-width="140" align="center" show-overflow-tooltip>
<template #default="{ row }">
<el-select
v-if="editableAliasBrand && (row.brandOptions || []).length"
v-model="row.brandId"
class="pl-plan-product-cell-control"
placeholder="不限品牌"
clearable
filterable
style="width: 100%"
popper-class="pl-plan-alias-brand-select-dropdown"
@change="(val) => handleBrandChange(row, val)"
>
<el-option
v-for="b in row.brandOptions"
:key="b.id"
:label="b.brandName"
:value="b.id"
/>
</el-select>
<div v-if="editableAliasBrand" class="line-meta-cell">
<el-select
v-if="(row.brandOptions || []).length"
v-model="row.brandId"
class="pl-plan-product-cell-control line-meta-cell__control"
placeholder="不限品牌"
clearable
filterable
popper-class="pl-plan-alias-brand-select-dropdown"
@change="(val) => handleBrandChange(row, val)"
>
<el-option
v-for="b in row.brandOptions"
:key="b.id"
:label="b.brandName"
:value="b.id"
/>
</el-select>
<span v-else class="text-muted line-meta-cell__empty">—</span>
<el-button
v-if="row.productId"
link
type="primary"
class="line-meta-cell__btn"
@click="openAliasBrandManage(row, 'brand')"
>
<el-icon><Plus /></el-icon>
</el-button>
</div>
<span v-else>{{ formatBrandConstraintLabel(row.brandMode, row.brandName) }}</span>
</template>
</el-table-column>
@@ -142,6 +164,15 @@
</el-table-column>
</ListTable>
</div>
<ProductAliasBrandDialog
v-if="editableAliasBrand"
v-model="aliasBrandManageVisible"
:product-id="aliasBrandManageProductId"
:product-name="aliasBrandManageProductName"
:mode="aliasBrandManageMode"
@saved="handleAliasBrandManaged"
/>
</div>
</template>
@@ -151,11 +182,13 @@ import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { getDeliveryMethodLabel } from '@/composables/usePlContractToPlan'
import {
applyBrandSelectChange,
applyManagedProductMetaToLines,
formatBrandConstraintLabel,
resolveLineProductName
} from '@/utils/crm-product-brand'
import { formatBrandQtySummary } from '@/utils/pl-display'
import PlProductNameHazardCell from '@/components/business/pl-product-name-hazard-cell/index.vue'
import ProductAliasBrandDialog from '@/components/business/product-alias-brand-dialog/index.vue'
import DictTag from '@/components/DictTag/index.vue'
const props = defineProps<{
@@ -196,6 +229,32 @@ function handleAliasChange(row: PlanFormProductRow) {
function handleBrandChange(row: PlanFormProductRow, val: string | undefined | null) {
applyBrandSelectChange(row, val, row.brandOptions || [])
}
const aliasBrandManageVisible = ref(false)
const aliasBrandManageProductId = ref('')
const aliasBrandManageProductName = ref('')
const aliasBrandManageMode = ref<'alias' | 'brand'>('alias')
function openAliasBrandManage(row: PlanFormProductRow, mode: 'alias' | 'brand') {
if (!row.productId) return
aliasBrandManageProductId.value = String(row.productId)
aliasBrandManageProductName.value = String(row.standardProductName || row.productName || '')
aliasBrandManageMode.value = mode
aliasBrandManageVisible.value = true
}
function handleAliasBrandManaged(product: Record<string, any>) {
applyManagedProductMetaToLines(
props.products || [],
aliasBrandManageProductId.value,
product
)
const pid = aliasBrandManageProductId.value
for (const line of props.products || []) {
if (String(line.productId || '') !== pid) continue
line.productName = resolveLineProductName(line.standardProductName, line.aliasName)
}
}
</script>
<style scoped lang="scss">
@@ -218,6 +277,28 @@ function handleBrandChange(row: PlanFormProductRow, val: string | undefined | nu
font-size: 14px;
}
}
.line-meta-cell {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.line-meta-cell__control {
flex: 1;
min-width: 0;
}
.line-meta-cell__empty {
flex: 1;
min-width: 0;
}
.line-meta-cell__btn {
flex-shrink: 0;
padding: 4px;
}
}
</style>
@@ -106,8 +106,8 @@
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">运输信息</h3>
<el-form :model="formData" label-position="right" label-width="100px" class="pl-dialog-form">
<div class="form-grid-4">
<el-form-item label="车牌号">
<div class="pl-plan-vehicle-carrier-row">
<el-form-item label="车牌号" class="pl-plan-vehicle-carrier-row__vehicle">
<div class="pl-plan-vehicle-with-library">
<el-select
v-model="formData.vehicleId"
@@ -136,7 +136,7 @@
</el-button>
</div>
</el-form-item>
<el-form-item label="运输公司">
<el-form-item label="运输公司" class="pl-plan-vehicle-carrier-row__carrier">
<div class="pl-plan-carrier-with-library">
<el-select
v-model="formData.carrierId"
@@ -1237,6 +1237,18 @@ async function handleSubmit(mode: 'save' | 'saveAndArrange' = 'save') {
</script>
<style scoped lang="scss">
.pl-plan-vehicle-carrier-row {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 12px 20px;
align-items: start;
}
.pl-plan-vehicle-carrier-row__vehicle {
width: max-content;
max-width: 100%;
}
.pl-plan-vehicle-with-library,
.pl-plan-carrier-with-library {
display: flex;
@@ -1245,9 +1257,15 @@ async function handleSubmit(mode: 'save' | 'saveAndArrange' = 'save') {
width: 100%;
}
.pl-plan-vehicle-with-library__select,
.pl-plan-vehicle-with-library__select {
flex: 0 0 140px;
width: 140px;
}
.pl-plan-carrier-with-library__select {
flex: 1 1 auto;
flex: 0 1 50%;
width: 50%;
max-width: 50%;
min-width: 0;
}
@@ -103,33 +103,49 @@
v-if="getPrimaryProductCertUrl(product, slot.key)"
class="pl-plan-list-cert-cell__thumb pl-plan-list-cert-cell__thumb--filled"
>
<el-image
v-if="isCertImageUrl(getPrimaryProductCertUrl(product, slot.key))"
class="pl-plan-list-cert-cell__image"
fit="cover"
lazy
:src="displayCertSrc(getPrimaryProductCertUrl(product, slot.key))"
:preview-src-list="mediaActive ? getCertPreviewSrcList(product, slot.key) : []"
preview-teleported
hide-on-click-modal
>
<template #error>
<div class="pl-plan-list-cert-cell__thumb-icon">
<el-icon :size="20"><Document /></el-icon>
</div>
</template>
</el-image>
<div
v-else
class="pl-plan-list-cert-cell__thumb-icon"
:class="
class="pl-plan-list-cert-cell__preview-hit"
:title="
isCertImageUrl(getPrimaryProductCertUrl(product, slot.key)) ||
isCertPdfUrl(getPrimaryProductCertUrl(product, slot.key))
? 'pl-plan-list-cert-cell__thumb-icon--pdf'
: 'pl-plan-list-cert-cell__thumb-icon--doc'
? '点击在新标签页查看'
: '点击查看'
"
@click.stop="handlePreviewCert(getPrimaryProductCertUrl(product, slot.key))"
>
<el-icon :size="20"><Document /></el-icon>
<el-image
v-if="isCertImageUrl(getPrimaryProductCertUrl(product, slot.key))"
class="pl-plan-list-cert-cell__image pl-plan-list-cert-cell__image--passive"
fit="cover"
lazy
:src="displayCertSrc(getPrimaryProductCertUrl(product, slot.key))"
:preview-src-list="[]"
>
<template #error>
<div class="pl-plan-list-cert-cell__thumb-icon">
<el-icon :size="20"><Document /></el-icon>
</div>
</template>
</el-image>
<div
v-else
class="pl-plan-list-cert-cell__thumb-icon"
:class="
isCertPdfUrl(getPrimaryProductCertUrl(product, slot.key))
? 'pl-plan-list-cert-cell__thumb-icon--pdf'
: 'pl-plan-list-cert-cell__thumb-icon--doc'
"
>
<el-icon :size="20"><Document /></el-icon>
</div>
</div>
<el-icon
class="pl-plan-list-cert-cell__download"
title="下载"
@click.stop="handleDownloadCert(getPrimaryProductCertUrl(product, slot.key))"
>
<Download />
</el-icon>
<span
v-if="getCertUrlCount(product, slot.key) > 1"
class="pl-plan-list-cert-cell__cert-count"
@@ -148,7 +164,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Document, Upload } from '@element-plus/icons-vue'
import { Document, Download, Upload } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { PlanProduct } from '@/api/pl'
import {
getPrimaryProductCertUrl,
@@ -159,6 +176,10 @@
splitProductCertUrls,
type HazardCertSlotKey
} from '@/composables/usePlToxicProductCertCheck'
import {
downloadAttachmentFile,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
const props = withDefaults(
defineProps<{
@@ -245,6 +266,35 @@
if (props.clickableDisabled) return
emit('click')
}
async function handlePreviewCert(url: string) {
const fileUrl = String(url || '').trim()
if (!fileUrl) return
await openAttachmentPreviewInNewTab(
{ fileUrl, fileName: getCertFileName(fileUrl) },
{
fullFileUrl: resolveCertFileUrl,
kind: isCertImageUrl(fileUrl) ? 'image' : isCertPdfUrl(fileUrl) ? 'pdf' : 'other'
}
)
}
async function handleDownloadCert(url: string) {
const fileUrl = String(url || '').trim()
if (!fileUrl) {
ElMessage.error('无法获取文件下载地址')
return
}
try {
await downloadAttachmentFile(
{ fileUrl, fileName: getCertFileName(fileUrl) || '附件' },
{ fullFileUrl: resolveCertFileUrl }
)
ElMessage.success('下载成功')
} catch {
ElMessage.error('下载失败,请稍后重试')
}
}
</script>
<style scoped lang="scss">
@@ -346,6 +396,36 @@
align-items: stretch;
}
.pl-plan-list-cert-cell__preview-hit {
width: 100%;
height: 100%;
cursor: pointer;
}
.pl-plan-list-cert-cell__image--passive {
pointer-events: none;
}
.pl-plan-list-cert-cell__download {
position: absolute;
top: 2px;
right: 2px;
z-index: 2;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
border: 1px solid #e2e8f0;
color: #1e293b;
cursor: pointer;
&:hover {
color: #1ca851;
border-color: #1ca851;
background: #e6f8ed;
}
}
.pl-plan-list-cert-cell__cert-caption {
position: absolute;
right: 0;
@@ -0,0 +1,67 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<el-dialog
v-model="visible"
title="产品分类"
width="100%"
top="50px"
class="dialog-form-layout product-category-maintain-dialog"
append-to-body
destroy-on-close
:close-on-click-modal="false"
modal-class="order-module-dialog-mask"
style="backdrop-filter: none"
@closed="emit('closed')"
>
<ProductCategoryMaintainPanel v-if="visible" @changed="emit('changed')" />
<template #footer>
<el-button class="btn-ghost" @click="visible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Close } from '@element-plus/icons-vue'
import ProductCategoryMaintainPanel from '@/components/business/product-category-maintain-panel/index.vue'
const visible = defineModel<boolean>({ default: false })
const emit = defineEmits<{
changed: []
closed: []
}>()
</script>
<style lang="scss">
/* append-to-body:非 scoped,保证最大化宽高生效 */
.el-dialog.product-category-maintain-dialog {
width: calc(100vw - 40px) !important;
max-width: calc(100vw - 40px);
height: calc(100vh - 100px) !important;
max-height: calc(100vh - 100px);
margin-top: 50px !important;
display: flex;
flex-direction: column;
}
.el-dialog.product-category-maintain-dialog .el-dialog__body {
flex: 1 1 auto;
min-height: 0;
max-height: none;
overflow: hidden;
display: flex;
flex-direction: column;
}
.el-dialog.product-category-maintain-dialog .el-dialog__body > .product-category-maintain-panel {
flex: 1 1 auto;
min-height: 0;
}
.el-dialog.product-category-maintain-dialog .el-dialog__header,
.el-dialog.product-category-maintain-dialog .el-dialog__footer {
flex-shrink: 0;
}
</style>
@@ -0,0 +1,535 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="product-category-maintain-panel dialog-list-panel">
<!-- 工具栏:快捷检索与操作按钮同一行 -->
<div class="list-toolbar">
<div class="list-toolbar-row product-category-maintain-panel__toolbar-row">
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="分类名称"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button class="btn-main" type="primary" @click="handleAdd()">
<el-icon><Plus /></el-icon>
新增
</el-button>
<el-button class="btn-ghost" @click="toggleExpandAll">
{{ isExpandAll ? '全部折叠' : '全部展开' }}
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedId"
@click="handleUpdate()"
>
<el-icon><Edit /></el-icon>
修改
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedId"
@click="handleAddChild()"
>
<el-icon><Plus /></el-icon>
新增子分类
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedId || !canDeleteSelectedCategory"
:title="canDeleteSelectedCategory ? '' : CREATOR_DELETE_TIP"
@click="handleDelete()"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="getList"
@reset-columns="resetToDefault"
/>
</div>
</div>
<!-- 树形表格 -->
<div class="list-body list-wrap">
<ListTable
table-key="crm-product-category"
v-if="refreshTable"
ref="tableRef"
class="list-table"
:data="categoryList"
v-loading="loading"
stripe
row-key="id"
:default-expand-all="isExpandAll"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
highlight-current-row
@selection-change="handleSelectionChange"
@row-dblclick="handleRowEdit"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
prop="categoryName"
label="分类名称"
min-width="200"
align="left"
header-align="left"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('orderNum')"
prop="orderNum"
label="排序"
min-width="80"
align="center"
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('status')"
prop="status"
label="状态"
min-width="80"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('sys_normal_disable', row.status)"
size="small"
>
{{ dictStore.getLabel('sys_normal_disable', row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('createTime')"
prop="createTime"
label="创建时间"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
/>
</ListTable>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
</div>
<!-- 新增/编辑分类弹窗(内联) -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="640px"
class="dialog-form-layout"
align-center
append-to-body
destroy-on-close
:close-on-click-modal="false"
modal-class="order-module-dialog-mask"
style="backdrop-filter: none"
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="crm-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="上级分类" style="grid-column: 1 / -1">
<el-tree-select
v-model="formData.parentId"
:data="categoryTreeOptions"
node-key="id"
:props="{ label: 'categoryName', children: 'children' }"
value-key="id"
check-strictly
filterable
clearable
placeholder="选择上级分类(留空为顶级)"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="分类名称" prop="categoryName">
<el-input v-model="formData.categoryName" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="排序号">
<el-input-number
v-model="formData.orderNum"
controls-position="right"
:min="0"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="formData.status" placeholder="请选择状态" style="width: 100%">
<el-option
v-for="opt in dictStore.getOptions('sys_normal_disable')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="formData.remark"
type="textarea"
placeholder="请输入备注"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="submitForm">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { ref, reactive, onMounted, nextTick, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import {
Plus,
Edit,
Delete,
Close,
Check,
} from '@element-plus/icons-vue'
import { useDictStore } from '@/store/modules/dict'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
fetchCategoryTree,
fetchCreateCategory,
fetchUpdateCategory,
fetchDeleteCategory,
} from '@/api/crm/product'
const emit = defineEmits<{
changed: []
}>()
const dictStore = useDictStore()
const listColumnDefs = [
{ prop: 'orderNum', label: '排序' },
{ prop: 'status', label: '状态' },
{ prop: 'createTime', label: '创建时间' }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('crm_product_category_visible_columns', listColumnDefs)
// ── 列表数据 ──
const categoryList = ref<any[]>([])
const loading = ref(false)
const isExpandAll = ref(true)
const refreshTable = ref(true)
const selectedId = ref<string>()
const selectedRows = ref<any[]>([])
const canDeleteSelectedCategory = computed(() => {
if (!selectedId.value) return false
const node = findNodeById(categoryList.value, selectedId.value)
return canDeleteByCreator(node)
})
// ── 弹窗数据 ──
const dialogVisible = ref(false)
const dialogTitle = ref('')
const submitLoading = ref(false)
const formData = reactive({
id: '',
parentId: '',
categoryName: '',
orderNum: 0,
status: '0',
remark: '',
})
const formRules = {
categoryName: [{ required: true, message: '分类名称不能为空', trigger: 'blur' }],
}
const formRef = ref<FormInstance>()
const categoryTreeOptions = ref<any[]>([])
const quickSearchKeyword = ref('')
// ── 获取列表(树形) ──
async function getList() {
loading.value = true
try {
const params: Record<string, any> = {}
const quickKeyword = quickSearchKeyword.value?.trim()
if (quickKeyword) params.categoryName = quickKeyword
const res = await fetchCategoryTree(params)
categoryList.value = (res as any)?.data || res || []
} finally {
loading.value = false
}
}
// ── 获取上级分类下拉树 ──
async function getCategoryTreeSelect() {
const res = await fetchCategoryTree({})
const data = (res as any)?.data || res || []
// 加一个顶级分类虚拟节点
categoryTreeOptions.value = [
{
id: '0',
categoryName: '顶级分类',
parentId: '0',
children: data,
},
]
}
function handleQuickSearch() {
getList()
}
function handleQuickSearchClear() {
quickSearchKeyword.value = ''
getList()
}
// ── 表格行选中 ──
function handleSelectionChange(rows: any[]) {
selectedRows.value = rows
selectedId.value = rows.length === 1 ? rows[0]?.id : undefined
}
// ── 展开/折叠 ──
function toggleExpandAll() {
refreshTable.value = false
isExpandAll.value = !isExpandAll.value
nextTick(() => {
refreshTable.value = true
})
}
// ── 新增 ──
function handleAdd(parentRow?: any) {
resetForm()
getCategoryTreeSelect()
if (parentRow?.id) {
formData.parentId = parentRow.id
}
dialogTitle.value = '新增产品分类'
dialogVisible.value = true
}
// ── 新增子分类 ──
function handleAddChild() {
if (!selectedId.value) {
ElMessage.warning('请先选择一条分类')
return
}
const parentRow = findNodeById(categoryList.value, selectedId.value)
handleAdd(parentRow)
}
// ── 修改 ──
async function handleUpdate() {
await openEditForRow()
}
async function handleRowEdit(row: any) {
await openEditForRow(row)
}
async function openEditForRow(row?: any) {
const targetId = row?.id ?? selectedId.value
if (!targetId) {
ElMessage.warning('请选择一条数据进行修改')
return
}
resetForm()
await getCategoryTreeSelect()
const node = row ?? findNodeById(categoryList.value, targetId)
if (!node) {
ElMessage.warning('未找到选中的数据')
return
}
selectedId.value = node.id
formData.id = node.id
formData.parentId = node.parentId || ''
formData.categoryName = node.categoryName
formData.orderNum = node.orderNum ?? 0
formData.status = node.status ?? '0'
formData.remark = node.remark ?? ''
dialogTitle.value = '修改产品分类'
dialogVisible.value = true
}
// ── 删除 ──
async function handleDelete() {
if (!selectedId.value) {
ElMessage.warning('请选择一条数据进行删除')
return
}
const node = findNodeById(categoryList.value, selectedId.value)
if (!canDeleteByCreator(node)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm('是否确认删除该产品分类?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await fetchDeleteCategory({ id: selectedId.value })
ElMessage.success('删除成功')
selectedId.value = undefined
await getList()
emit('changed')
}
// ── 提交表单 ──
async function submitForm() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
if (formData.id) {
await fetchUpdateCategory({
id: formData.id,
parentId: formData.parentId || '0',
categoryName: formData.categoryName,
orderNum: formData.orderNum,
status: formData.status,
remark: formData.remark,
})
ElMessage.success('修改成功')
} else {
await fetchCreateCategory({
parentId: formData.parentId || '0',
categoryName: formData.categoryName,
orderNum: formData.orderNum,
status: formData.status,
remark: formData.remark,
})
ElMessage.success('新增成功')
}
dialogVisible.value = false
await getList()
emit('changed')
} finally {
submitLoading.value = false
}
}
// ── 弹窗关闭 ──
function handleDialogClose() {
resetForm()
}
// ── 重置表单 ──
function resetForm() {
formData.id = ''
formData.parentId = ''
formData.categoryName = ''
formData.orderNum = 0
formData.status = '0'
formData.remark = ''
formRef.value?.resetFields()
}
// ── 工具函数:在树中查找节点 ──
function findNodeById(tree: any[], id: string): any {
for (const node of tree) {
if (node.id === id) return node
if (node.children?.length) {
const found = findNodeById(node.children, id)
if (found) return found
}
}
return null
}
onMounted(() => {
getList()
})
</script>
<style scoped lang="scss">
.product-category-maintain-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
/* 弹窗内需自带 flex;并固定检索框宽度,避免与按钮之间出现异常空隙 */
.product-category-maintain-panel__toolbar-row {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
:deep(.quick-search-input.el-input) {
width: 280px;
max-width: 280px;
flex: 0 0 280px;
margin-right: 0;
}
:deep(.quick-search-input),
:deep(.el-button),
:deep(.list-toolbar-end) {
flex-shrink: 0;
}
:deep(.el-button + .el-button) {
margin-left: 0 !important;
}
:deep(.list-toolbar-end) {
margin-left: auto;
padding-left: 8px;
}
}
.product-category-maintain-panel :deep(.list-body.list-wrap) {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
.product-category-maintain-panel :deep(.list-toolbar) {
flex-shrink: 0;
}
.product-category-maintain-panel :deep(.list-pager-bar) {
flex-shrink: 0;
}
</style>
@@ -26,9 +26,10 @@
type="primary"
size="small"
class="view-kv-field-link"
:title="viewBtnTitle"
@click="handleView"
>
<el-icon><View /></el-icon>查看
<el-icon :size="16"><View /></el-icon>
</el-button>
</div>
</template>
@@ -60,6 +61,13 @@
return true
})
const viewBtnTitle = computed(() => {
if (props.field.viewTarget === 'customer') return '查看客户'
if (props.field.viewTarget === 'opportunity') return '查看商机'
if (props.field.viewTarget === 'quote') return '查看报价单'
return '查看'
})
const amapLocationUrl = computed(() => {
if (!props.field.showMapLink) return null
return buildAmapLocationUrl({
@@ -64,6 +64,7 @@
v-if="group.fields.length && group.fieldLayout === 'table'"
:fields="group.fields"
:mode="group.tableMode || 'triple'"
@field-view="emit('field-view', $event)"
/>
<ViewKvGrid
v-else-if="group.fields.length"
@@ -77,6 +78,7 @@
v-else-if="section.fields.length && section.fieldLayout === 'table'"
:fields="section.fields"
:mode="section.tableMode || 'triple'"
@field-view="emit('field-view', $event)"
/>
<ViewKvGrid
v-else-if="section.fields.length"
@@ -152,6 +154,7 @@
v-if="group.fields.length && group.fieldLayout === 'table'"
:fields="group.fields"
:mode="group.tableMode || 'triple'"
@field-view="emit('field-view', $event)"
/>
<ViewKvGrid
v-else-if="group.fields.length"
@@ -165,6 +168,7 @@
v-else-if="section.fields.length && section.fieldLayout === 'table'"
:fields="section.fields"
:mode="section.tableMode || 'triple'"
@field-view="emit('field-view', $event)"
/>
<ViewKvGrid
v-else-if="section.fields.length"
@@ -288,6 +292,7 @@
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
(e: 'update:modelValue', val: boolean): void
(e: 'field-view', field: ViewField): void
}>()
const innerVisible = computed({
@@ -20,7 +20,7 @@ export interface ViewField {
money?: boolean
/** 红色加粗强调展示(如结算方式等关键业务字段) */
danger?: boolean
/** 值后展示「查看」按钮,点击时 ViewKvTable 抛出 field-view */
/** 值后展示眼睛图标,点击时 ViewKvTable / SystemViewDialog 抛出 field-view */
viewTarget?: 'customer' | 'opportunity' | 'quote'
viewId?: string | number | null
/** 为 true 时,值后展示地图图标跳转高德查看位置(须配合 mapLng/mapLat 或地址文案) */
@@ -8,7 +8,11 @@
>
<div
class="attachment-preview"
:class="{ 'is-thumb': isBxImageFile(file) && !!file.previewUrl }"
:class="{
'is-thumb': isBxImageFile(file) && !!file.previewUrl,
'is-clickable': canBxPreview(file)
}"
:title="canBxPreview(file) ? '点击预览' : undefined"
@click="canBxPreview(file) ? handlePreview(file) : undefined"
>
<template v-if="isBxImageFile(file) && file.previewUrl">
@@ -24,6 +28,12 @@
<el-icon :size="28"><VideoPlay /></el-icon>
</div>
</template>
<template v-else-if="isBxPdfFile(file)">
<div class="attachment-preview__icon attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
<span class="attachment-preview__ext">PDF</span>
</div>
</template>
<template v-else>
<div class="attachment-preview__icon attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
@@ -33,6 +43,14 @@
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<div class="attachment-actions">
<el-icon
v-if="canBxPreview(file)"
class="action-btn"
title="预览"
@click="handlePreview(file)"
>
<ZoomIn />
</el-icon>
<el-icon class="action-btn" title="下载" @click="handleDownload(file)">
<Download />
</el-icon>
@@ -43,11 +61,12 @@
<el-dialog
v-model="previewVisible"
title="附件预览"
width="800px"
:title="previewFileName || '附件预览'"
:width="previewMode === 'pdf' ? '860px' : '800px'"
align-center
destroy-on-close
append-to-body
modal-class="order-module-dialog-mask"
>
<img
v-if="previewMode === 'image' && previewUrl"
@@ -61,17 +80,24 @@
controls
class="bx-attachment-preview-media"
/>
<iframe
v-else-if="previewMode === 'pdf' && previewUrl"
:src="previewUrl"
class="bx-attachment-preview-media bx-attachment-preview-media--iframe"
title="PDF 预览"
/>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { VideoPlay, Document, Download, Picture } from '@element-plus/icons-vue'
import { VideoPlay, Document, Download, Picture, ZoomIn } from '@element-plus/icons-vue'
import {
type BxAttachmentItem,
isBxImageFile,
isBxVideoFile,
isBxPdfFile,
canBxPreview,
downloadBxAttachment,
previewBxAttachment,
@@ -86,7 +112,7 @@ const displayFiles = ref<BxAttachmentItem[]>([])
const previewVisible = ref(false)
const previewUrl = ref('')
const previewFileName = ref('')
const previewMode = ref<'image' | 'video'>('image')
const previewMode = ref<'image' | 'video' | 'pdf'>('image')
watch(
() => props.files,
@@ -121,8 +147,28 @@ async function handlePreview(file: BxAttachmentItem) {
background: #0f172a0a;
}
.attachment-preview.is-clickable {
cursor: pointer;
}
.attachment-preview__icon--image {
background: #ecfdf5;
color: #059669;
}
.attachment-preview__ext {
margin-top: 4px;
font-size: 11px;
font-weight: 600;
color: #1e293b;
}
.bx-attachment-preview-media--iframe {
width: 100%;
min-height: 480px;
height: calc(100vh - 240px);
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
</style>
@@ -36,6 +36,8 @@
>
<div
class="attachment-preview"
:class="{ 'is-clickable': canBxPreview(file) }"
:title="canBxPreview(file) ? '点击预览' : undefined"
@click="canBxPreview(file) ? handlePreview(file) : undefined"
>
<template v-if="isBxImageFile(file) && (file.previewUrl || file._uploading)">
@@ -50,6 +52,12 @@
<el-icon :size="28"><VideoPlay /></el-icon>
</div>
</template>
<template v-else-if="isBxPdfFile(file)">
<div class="attachment-preview__icon attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
<span class="attachment-preview__ext">PDF</span>
</div>
</template>
<template v-else>
<div class="attachment-preview__icon attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
@@ -59,6 +67,14 @@
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<div class="attachment-actions">
<el-icon
v-if="canBxPreview(file)"
class="action-btn"
title="预览"
@click="handlePreview(file)"
>
<ZoomIn />
</el-icon>
<el-icon class="action-btn" title="下载" @click="handleDownload(file)">
<Download />
</el-icon>
@@ -77,11 +93,12 @@
<el-dialog
v-model="previewVisible"
title="附件预览"
width="800px"
:title="previewFileName || '附件预览'"
:width="previewMode === 'pdf' ? '860px' : '800px'"
align-center
destroy-on-close
append-to-body
modal-class="order-module-dialog-mask"
>
<img
v-if="previewMode === 'image' && previewUrl"
@@ -95,6 +112,12 @@
controls
class="bx-attachment-preview-media"
/>
<iframe
v-else-if="previewMode === 'pdf' && previewUrl"
:src="previewUrl"
class="bx-attachment-preview-media bx-attachment-preview-media--iframe"
title="PDF 预览"
/>
</el-dialog>
</div>
</template>
@@ -107,7 +130,8 @@
VideoPlay,
Document,
Download,
Delete
Delete,
ZoomIn
} from '@element-plus/icons-vue'
import type { UploadFile, UploadInstance } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -116,6 +140,7 @@
type BxAttachmentItem,
isBxImageFile,
isBxVideoFile,
isBxPdfFile,
canBxPreview,
downloadBxAttachment,
previewBxAttachment
@@ -148,7 +173,7 @@
const previewVisible = ref(false)
const previewUrl = ref('')
const previewFileName = ref('')
const previewMode = ref<'image' | 'video'>('image')
const previewMode = ref<'image' | 'video' | 'pdf'>('image')
const isPointerInside = ref(false)
const isDragOver = ref(false)
let dragEnterDepth = 0
@@ -369,4 +394,24 @@
}
}
}
.attachment-preview.is-clickable {
cursor: pointer;
}
.attachment-preview__ext {
margin-top: 4px;
font-size: 11px;
font-weight: 600;
color: #1e293b;
}
.bx-attachment-preview-media--iframe {
width: 100%;
min-height: 480px;
height: calc(100vh - 240px);
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
</style>
@@ -1,81 +1,118 @@
<!-- 门户顶栏:一级横排 + 方案 A 大面板(左右分行二级/三级文字链) -->
<!-- 门户顶栏:当前一级下的二级按宽度平铺 +「更多」+ 三级下拉(下拉 Teleport 到 body,避免被 overflow 裁切) -->
<template>
<div class="portal-mega-menu" ref="rootRef">
<nav class="portal-mega-l1" aria-label="一级模块">
<div class="portal-flat-nav" ref="rootRef">
<!-- 离屏测量:用于按宽度计算可见项,不参与布局 -->
<div ref="measureRef" class="portal-flat-nav__measure" aria-hidden="true">
<button
v-for="item in moduleList"
:key="item.path"
v-for="item in l2Items"
:key="`m-${item.path || item.meta?.title}`"
type="button"
class="portal-mega-l1__item"
:class="{
'is-selected': isL1Selected(item)
}"
@click="toggleModule(item)"
class="portal-flat-nav__item portal-flat-nav__measure-item"
>
<span>{{ item.formattedTitle }}</span>
<span class="portal-mega-l1__caret">{{ openModulePath === item.path ? '▴' : '▾' }}</span>
<span>{{ formatMenuTitle(item.meta.title) }}</span>
<span v-if="l2HasExpandableChildren(item)" class="portal-flat-nav__caret">▾</span>
</button>
<button type="button" class="portal-flat-nav__item portal-flat-nav__measure-more">
<span>更多</span>
<span class="portal-flat-nav__caret">▾</span>
</button>
</div>
<nav v-if="l2Items.length" class="portal-flat-nav__bar" aria-label="二级菜单">
<div
v-for="item in visibleItems"
:key="item.path || item.meta?.title"
class="portal-flat-nav__item-wrap"
>
<button
:ref="(el) => setL2TriggerRef(itemKey(item), el)"
type="button"
class="portal-flat-nav__item"
:class="{
'is-active': isL2Active(item),
'is-open': openL2Key === itemKey(item)
}"
@click="handleL2Click(item)"
>
<span>{{ formatMenuTitle(item.meta.title) }}</span>
<span v-if="l2HasExpandableChildren(item)" class="portal-flat-nav__caret">
{{ openL2Key === itemKey(item) ? '▴' : '▾' }}
</span>
</button>
</div>
<div v-if="overflowItems.length" class="portal-flat-nav__item-wrap">
<button
ref="moreTriggerRef"
type="button"
class="portal-flat-nav__item"
:class="{
'is-active': overflowHasActive,
'is-open': moreOpen
}"
@click="toggleMore"
>
<span>更多</span>
<span class="portal-flat-nav__caret">{{ moreOpen ? '▴' : '▾' }}</span>
</button>
</div>
</nav>
<p v-else class="portal-flat-nav__empty">暂无菜单</p>
<Teleport to="body">
<div
v-if="openModule"
class="portal-mega-overlay"
@click="closePanel"
/>
<div
v-if="openModule"
class="portal-mega-panel"
role="dialog"
aria-label="子菜单"
:style="panelStyle"
v-if="openL2Item"
ref="l2FlyoutRef"
class="portal-flat-nav-flyout"
role="menu"
:style="l2FlyoutStyle"
>
<div class="portal-mega-panel__body">
<div class="portal-mega-split" :class="{ 'is-single': isSingleColumn }">
<div class="portal-mega-pane">
<div
v-for="row in columns.left"
:key="row.l2.path || row.l2.meta?.title"
class="portal-mega-row"
>
<div class="portal-mega-row__l2">{{ formatMenuTitle(row.l2.meta.title) }}</div>
<div class="portal-mega-row__l3">
<button
v-for="link in row.l3Links"
:key="link.path || link.meta?.title"
type="button"
class="portal-mega-link"
:class="{ 'is-active': isLinkActive(link) }"
@click="handleLinkClick(link)"
>
{{ formatMenuTitle(link.meta.title) }}
</button>
</div>
</div>
<p v-if="!columns.left.length" class="portal-mega-empty">暂无菜单</p>
</div>
<div v-if="!isSingleColumn" class="portal-mega-pane portal-mega-pane--right">
<div
v-for="row in columns.right"
:key="row.l2.path || row.l2.meta?.title"
class="portal-mega-row"
>
<div class="portal-mega-row__l2">{{ formatMenuTitle(row.l2.meta.title) }}</div>
<div class="portal-mega-row__l3">
<button
v-for="link in row.l3Links"
:key="link.path || link.meta?.title"
type="button"
class="portal-mega-link"
:class="{ 'is-active': isLinkActive(link) }"
@click="handleLinkClick(link)"
>
{{ formatMenuTitle(link.meta.title) }}
</button>
</div>
</div>
</div>
</div>
<button
v-for="link in resolveMegaPanelL3Links(openL2Item)"
:key="link.path || link.meta?.title"
type="button"
class="portal-flat-nav-flyout__opt"
:class="{ 'is-active': isLinkActive(link) }"
@click.stop="handleLinkClick(link)"
>
{{ formatMenuTitle(link.meta.title) }}
</button>
</div>
<div
v-if="moreOpen"
ref="moreFlyoutRef"
class="portal-flat-nav-flyout portal-flat-nav-flyout--more"
role="menu"
:style="moreFlyoutStyle"
>
<div
v-for="item in overflowItems"
:key="item.path || item.meta?.title"
class="portal-flat-nav-flyout__block"
>
<template v-if="l2HasExpandableChildren(item)">
<div class="portal-flat-nav-flyout__group">{{ formatMenuTitle(item.meta.title) }}</div>
<button
v-for="link in resolveMegaPanelL3Links(item)"
:key="link.path || link.meta?.title"
type="button"
class="portal-flat-nav-flyout__opt portal-flat-nav-flyout__opt--sub"
:class="{ 'is-active': isLinkActive(link) }"
@click.stop="handleLinkClick(link)"
>
{{ formatMenuTitle(link.meta.title) }}
</button>
</template>
<button
v-else
type="button"
class="portal-flat-nav-flyout__opt"
:class="{ 'is-active': isL2Active(item) }"
@click.stop="handleLeafL2Click(item)"
>
{{ formatMenuTitle(item.meta.title) }}
</button>
</div>
</div>
</Teleport>
@@ -83,145 +120,255 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
type ComponentPublicInstance
} from 'vue'
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { onClickOutside } from '@vueuse/core'
import { onClickOutside, useResizeObserver } from '@vueuse/core'
import { formatMenuTitle } from '@/utils/router'
import { handleMenuJump } from '@/utils/navigation'
import { useMenuStore } from '@/store/modules/menu'
import {
buildMegaPanelColumns,
calcPortalFlatNavVisibleCount,
filterPortalTopModules,
isMegaPanelLinkActive,
isPortalModuleActive,
type PortalMegaPanelColumns
isPortalL2Active,
l2HasExpandableChildren,
listPortalFlatNavL2Items,
PORTAL_DEFAULT_MODULE_PATH,
resolveActivePortalModule,
resolveMegaPanelL3Links
} from '@/utils/router/portal-menu'
import type { AppRouteRecord } from '@/types/router'
import type { CSSProperties } from 'vue'
defineOptions({ name: 'ArtPortalMegaMenu' })
interface PortalModuleItem extends AppRouteRecord {
isRouteActive: boolean
formattedTitle: string
}
const route = useRoute()
const menuStore = useMenuStore()
const { menuList } = storeToRefs(menuStore)
const rootRef = ref<HTMLElement | null>(null)
const openModulePath = ref<string | null>(null)
const panelStyle = ref<CSSProperties>({})
const measureRef = ref<HTMLElement | null>(null)
const moreTriggerRef = ref<HTMLElement | null>(null)
const l2FlyoutRef = ref<HTMLElement | null>(null)
const moreFlyoutRef = ref<HTMLElement | null>(null)
const l2TriggerRefs = ref<Record<string, HTMLElement | null>>({})
const openL2Key = ref<string | null>(null)
const moreOpen = ref(false)
const visibleCount = ref(0)
const l2FlyoutStyle = ref<CSSProperties>({})
const moreFlyoutStyle = ref<CSSProperties>({})
const currentRoutePath = computed(() => String(route.meta.activePath || route.path))
const moduleList = computed<PortalModuleItem[]>(() => {
return filterPortalTopModules(menuList.value).map((item) => ({
...item,
isRouteActive: isPortalModuleActive(item, currentRoutePath.value),
formattedTitle: formatMenuTitle(item.meta.title)
}))
const activeModule = computed(() => {
const matched = resolveActivePortalModule(menuList.value, currentRoutePath.value)
if (matched) {
return matched
}
const tops = filterPortalTopModules(menuList.value)
return tops.find((item) => item.path === PORTAL_DEFAULT_MODULE_PATH) || tops[0]
})
const openModule = computed(() => {
if (!openModulePath.value) return undefined
return moduleList.value.find((item) => item.path === openModulePath.value)
})
const l2Items = computed(() => listPortalFlatNavL2Items(activeModule.value))
const columns = computed<PortalMegaPanelColumns>(() =>
buildMegaPanelColumns(openModule.value)
const visibleItems = computed(() => l2Items.value.slice(0, visibleCount.value))
const overflowItems = computed(() => l2Items.value.slice(visibleCount.value))
const overflowHasActive = computed(() =>
overflowItems.value.some((item) => isPortalL2Active(item, currentRoutePath.value))
)
const isSingleColumn = computed(() => columns.value.right.length === 0)
const openL2Item = computed(() => {
if (!openL2Key.value) return undefined
return visibleItems.value.find((item) => itemKey(item) === openL2Key.value)
})
/** 与顶栏中间槽上限一致;一级较少时保证两栏二级可读 */
const PANEL_MIN_WIDTH = 720
const PANEL_EDGE_PAD = 16
const itemKey = (item: AppRouteRecord): string => String(item.path || item.meta?.title || '')
const syncPanelPosition = (): void => {
const el = rootRef.value
if (!el) return
const rect = el.getBoundingClientRect()
const viewportW = window.innerWidth
const minWidth = Math.min(PANEL_MIN_WIDTH, Math.max(0, viewportW - PANEL_EDGE_PAD * 2))
const width = Math.max(rect.width, minWidth)
const centerX = rect.left + rect.width / 2
let left = centerX - width / 2
left = Math.max(PANEL_EDGE_PAD, Math.min(left, viewportW - width - PANEL_EDGE_PAD))
panelStyle.value = {
top: `${rect.bottom}px`,
left: `${left}px`,
width: `${width}px`
const setL2TriggerRef = (key: string, el: Element | ComponentPublicInstance | null): void => {
if (!el) {
delete l2TriggerRefs.value[key]
return
}
l2TriggerRefs.value[key] = el as HTMLElement
}
const closePanel = (): void => {
openModulePath.value = null
}
const toggleModule = async (item: PortalModuleItem): Promise<void> => {
const path = String(item.path || '')
if (!path) return
openModulePath.value = openModulePath.value === path ? null : path
if (openModulePath.value) {
await nextTick()
syncPanelPosition()
}
}
/** 顶栏同时只高亮一个一级:面板展开时跟展开项,否则跟当前路由 */
const isL1Selected = (item: PortalModuleItem): boolean => {
if (openModulePath.value) {
return openModulePath.value === item.path
}
return item.isRouteActive
const isL2Active = (item: AppRouteRecord): boolean => {
return isPortalL2Active(item, currentRoutePath.value)
}
const isLinkActive = (link: AppRouteRecord): boolean => {
return isMegaPanelLinkActive(link, currentRoutePath.value)
}
const closePopovers = (): void => {
openL2Key.value = null
moreOpen.value = false
}
const clampFlyoutLeft = (left: number, width: number): number => {
const pad = 8
const maxLeft = Math.max(pad, window.innerWidth - width - pad)
return Math.min(Math.max(left, pad), maxLeft)
}
const syncL2FlyoutPosition = async (): Promise<void> => {
const key = openL2Key.value
if (!key) return
await nextTick()
const trigger = l2TriggerRefs.value[key]
const flyout = l2FlyoutRef.value
if (!trigger || !flyout) return
const rect = trigger.getBoundingClientRect()
const flyoutWidth = flyout.offsetWidth || 160
const left = clampFlyoutLeft(rect.left + rect.width / 2 - flyoutWidth / 2, flyoutWidth)
l2FlyoutStyle.value = {
top: `${rect.bottom - 2}px`,
left: `${left}px`
}
}
const syncMoreFlyoutPosition = async (): Promise<void> => {
if (!moreOpen.value) return
await nextTick()
const trigger = moreTriggerRef.value
const flyout = moreFlyoutRef.value
if (!trigger || !flyout) return
const rect = trigger.getBoundingClientRect()
const flyoutWidth = flyout.offsetWidth || 180
const left = clampFlyoutLeft(rect.right - flyoutWidth, flyoutWidth)
moreFlyoutStyle.value = {
top: `${rect.bottom - 2}px`,
left: `${left}px`
}
}
const toggleMore = async (): Promise<void> => {
moreOpen.value = !moreOpen.value
openL2Key.value = null
if (moreOpen.value) {
await syncMoreFlyoutPosition()
}
}
const recomputeVisibleCount = (): void => {
const root = rootRef.value
const measure = measureRef.value
const items = l2Items.value
if (!root || !measure || !items.length) {
visibleCount.value = 0
return
}
const availableWidth = root.clientWidth
const itemEls = measure.querySelectorAll<HTMLElement>('.portal-flat-nav__measure-item')
const moreEl = measure.querySelector<HTMLElement>('.portal-flat-nav__measure-more')
if (!itemEls.length || !moreEl) {
visibleCount.value = items.length
return
}
const itemWidths = Array.from(itemEls).map((el) => el.getBoundingClientRect().width)
const moreWidth = moreEl.getBoundingClientRect().width
visibleCount.value = calcPortalFlatNavVisibleCount(itemWidths, moreWidth, availableWidth)
}
/** 有三级:只展开下拉;无三级:直接跳转 */
const handleL2Click = async (item: AppRouteRecord): Promise<void> => {
if (l2HasExpandableChildren(item)) {
const key = itemKey(item)
openL2Key.value = openL2Key.value === key ? null : key
moreOpen.value = false
if (openL2Key.value) {
await syncL2FlyoutPosition()
}
return
}
closePopovers()
handleMenuJump(item)
}
const handleLeafL2Click = (item: AppRouteRecord): void => {
closePopovers()
handleMenuJump(item)
}
const handleLinkClick = (link: AppRouteRecord): void => {
closePanel()
closePopovers()
handleMenuJump(link)
}
watch(
() => route.fullPath,
() => {
closePanel()
closePopovers()
}
)
watch(openModulePath, async (path) => {
if (!path) return
await nextTick()
syncPanelPosition()
watch(
l2Items,
async () => {
closePopovers()
await nextTick()
recomputeVisibleCount()
},
{ deep: true }
)
useResizeObserver(rootRef, () => {
recomputeVisibleCount()
if (openL2Key.value) {
void syncL2FlyoutPosition()
}
if (moreOpen.value) {
void syncMoreFlyoutPosition()
}
})
onMounted(() => {
window.addEventListener('resize', syncPanelPosition)
window.addEventListener('scroll', syncPanelPosition, true)
const syncOpenFlyoutPositions = (): void => {
if (openL2Key.value) void syncL2FlyoutPosition()
if (moreOpen.value) void syncMoreFlyoutPosition()
}
onMounted(async () => {
await nextTick()
recomputeVisibleCount()
window.addEventListener('resize', syncOpenFlyoutPositions)
window.addEventListener('scroll', syncOpenFlyoutPositions, true)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', syncPanelPosition)
window.removeEventListener('scroll', syncPanelPosition, true)
window.removeEventListener('resize', syncOpenFlyoutPositions)
window.removeEventListener('scroll', syncOpenFlyoutPositions, true)
})
onClickOutside(rootRef, (event) => {
const target = event.target as HTMLElement | null
if (target?.closest?.('.portal-mega-panel')) {
return
onClickOutside(
rootRef,
() => {
closePopovers()
},
{
ignore: [l2FlyoutRef, moreFlyoutRef]
}
closePanel()
})
)
</script>
<style lang="scss" scoped>
.portal-mega-menu {
.portal-flat-nav {
position: relative;
display: flex;
align-items: stretch;
justify-content: center;
@@ -230,181 +377,147 @@
height: 100%;
}
.portal-mega-l1 {
.portal-flat-nav__measure {
position: absolute;
top: 0;
left: 0;
z-index: -1;
display: flex;
gap: 2px;
align-items: stretch;
height: 56px;
pointer-events: none;
visibility: hidden;
white-space: nowrap;
}
.portal-flat-nav__bar {
display: flex;
flex-wrap: nowrap;
gap: 2px;
align-items: stretch;
justify-content: center;
min-width: 0;
max-height: 100%;
/* 仅允许横向溢出;若只写 overflow-x:auto,overflow-y 会被算成 auto,易出现竖向滚动条 */
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
max-width: 100%;
height: 100%;
overflow: hidden;
}
.portal-mega-l1__item {
.portal-flat-nav__item-wrap {
position: relative;
display: flex;
flex-shrink: 0;
align-items: stretch;
height: 100%;
}
.portal-flat-nav__item {
box-sizing: border-box;
display: inline-flex;
flex-shrink: 0;
gap: 4px;
align-items: center;
height: 100%;
max-height: 56px;
padding: 0 14px;
padding: 0 12px;
margin: 0;
font-size: 14px;
font-size: 13px;
font-weight: 500;
line-height: 1.2;
color: #fff;
color: rgba(255, 255, 255, 0.92);
white-space: nowrap;
cursor: pointer;
background: transparent;
border: none;
border-bottom: 4px solid transparent;
border-bottom: 2px solid transparent;
transition:
background-color 0.15s ease,
color 0.15s ease,
border-color 0.15s ease;
&:hover {
color: #fff;
background: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.12);
}
&.is-selected {
&.is-active,
&.is-open {
font-weight: 600;
color: #fff;
background: rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.16);
border-bottom-color: #fff;
}
}
.portal-mega-l1__caret {
.portal-flat-nav__caret {
font-size: 10px;
opacity: 0.75;
opacity: 0.8;
}
.portal-flat-nav__empty {
display: flex;
align-items: center;
margin: 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.72);
}
</style>
<style lang="scss">
.portal-mega-overlay {
.portal-flat-nav-flyout {
position: fixed;
inset: 56px 0 0 0;
z-index: 190;
background: rgba(15, 23, 42, 0.25);
}
.portal-mega-panel {
position: fixed;
z-index: 200;
z-index: 3200;
box-sizing: border-box;
max-height: min(720px, calc(100vh - 72px));
min-width: 160px;
max-height: min(360px, calc(100vh - 72px));
padding: 6px;
overflow: auto;
background: #fff;
border: 1px solid var(--mint-border, #e2e8f0);
border-top: none;
border-radius: 0 0 10px 10px;
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.12);
}
border-radius: 10px;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
.portal-mega-panel__body {
padding: 12px 16px 18px;
}
.portal-mega-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
align-items: stretch;
&.is-single {
grid-template-columns: 1fr;
}
}
.portal-mega-pane {
min-height: 100%;
}
.portal-mega-pane--right {
padding-left: 16px;
border-left: 1px solid var(--mint-border, #e2e8f0);
}
.portal-mega-row {
display: grid;
grid-template-columns: 110px 1fr;
gap: 8px 14px;
align-items: start;
padding: 12px 0;
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: 0;
}
}
.portal-mega-row__l2 {
padding-top: 6px;
font-size: 12px;
font-weight: 500;
line-height: 1.35;
color: #666666;
}
.portal-mega-row__l3 {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 4px;
align-items: stretch;
}
.portal-mega-link {
display: block;
width: 100%;
box-sizing: border-box;
padding: 4px 0;
margin: 0;
font-size: 13px;
font-weight: 500;
line-height: 1.45;
color: #0f172a;
text-align: left;
cursor: pointer;
background: transparent;
border: none;
border-radius: 0;
transition: color 0.15s ease;
&:hover {
color: var(--primary-dark, #059669);
&--more {
min-width: 180px;
}
&.is-active {
font-weight: 600;
color: var(--primary-dark, #059669);
}
}
.portal-mega-empty {
margin: 12px 0;
font-size: 13px;
color: #94a3b8;
}
@media only screen and (width <= 900px) {
.portal-mega-split {
grid-template-columns: 1fr;
&__group {
padding: 6px 10px 4px;
font-size: 11px;
font-weight: 700;
color: var(--mint-text-muted, #64748b);
letter-spacing: 0.02em;
}
.portal-mega-pane--right {
padding-left: 0;
border-left: none;
}
&__opt {
display: block;
box-sizing: border-box;
width: 100%;
padding: 8px 10px;
margin: 0;
font-size: 13px;
font-weight: 500;
line-height: 1.35;
color: #1e293b;
text-align: left;
white-space: nowrap;
cursor: pointer;
background: transparent;
border: none;
border-radius: 8px;
.portal-mega-row {
grid-template-columns: 96px 1fr;
&:hover {
color: var(--primary-dark, #059669);
background: var(--primary-bg, #ecfdf5);
}
&.is-active {
font-weight: 600;
color: var(--primary-dark, #059669);
background: var(--primary-bg, #ecfdf5);
}
&--sub {
padding-left: 18px;
color: #475569;
}
}
}
</style>
@@ -1,18 +1,14 @@
<!-- 门户顶栏一级模块下拉 -->
<!-- 门户顶栏:品牌旁一级模块下拉 -->
<template>
<div class="portal-module-select">
<ElDropdown
v-if="moduleList.length"
trigger="click"
teleported
popper-class="portal-module-select-popper"
@command="handleModuleSelect"
>
<button type="button" class="portal-module-select-trigger">
<ArtSvgIcon
v-if="activeModule?.meta?.icon"
:icon="activeModule.meta.icon"
class="portal-module-select-trigger__icon"
/>
<span class="portal-module-select-trigger__label">{{ triggerLabel }}</span>
<ElIcon class="portal-module-select-trigger__arrow"><ArrowDown /></ElIcon>
</button>
@@ -24,11 +20,6 @@
:command="item.path"
:class="{ 'is-active': item.isActive }"
>
<ArtSvgIcon
v-if="item.meta.icon"
:icon="item.meta.icon"
class="portal-module-select-item__icon"
/>
<span>{{ item.formattedTitle }}</span>
</ElDropdownItem>
</ElDropdownMenu>
@@ -83,7 +74,10 @@
if (matched) {
return matched
}
return moduleList.value.find((item) => item.path === PORTAL_DEFAULT_MODULE_PATH) || moduleList.value[0]
return (
moduleList.value.find((item) => item.path === PORTAL_DEFAULT_MODULE_PATH) ||
moduleList.value[0]
)
})
const triggerLabel = computed(() => {
@@ -116,49 +110,46 @@
gap: 6px;
align-items: center;
max-width: 180px;
height: 36px;
padding: 0 12px;
height: 32px;
padding: 0 10px 0 12px;
cursor: pointer;
background: transparent;
border: none;
color: #fff;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 8px;
&__icon {
flex-shrink: 0;
font-size: 18px;
color: var(--primary, #1ca851);
}
&__label {
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
font-size: 13px;
font-weight: 600;
line-height: 1.2;
color: var(--mint-text, #1e293b);
color: #fff;
white-space: nowrap;
}
&__arrow {
flex-shrink: 0;
font-size: 14px;
color: var(--mint-text-muted, #64748b);
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
}
&:hover:not(.is-disabled) {
background: transparent;
background: rgba(255, 255, 255, 0.22);
border-color: rgba(255, 255, 255, 0.55);
}
&.is-disabled {
cursor: not-allowed;
background: transparent;
opacity: 0.72;
}
}
</style>
<style lang="scss">
/* 高于顶栏中间二级(含 Teleport 下拉),避免一级面板被挡住 */
.portal-module-select-popper.el-popper {
z-index: 3300 !important;
backdrop-filter: none;
}
@@ -167,17 +158,13 @@
display: flex;
gap: 8px;
align-items: center;
font-size: 15px;
font-size: 14px;
&.is-active {
color: var(--primary, #1ca851);
background: var(--primary-bg, #e6f8ed);
font-weight: 600;
color: var(--primary-dark, #059669);
background: var(--primary-bg, #ecfdf5);
}
}
.portal-module-select-item__icon {
flex-shrink: 0;
font-size: 16px;
}
}
</style>
@@ -2,11 +2,11 @@
<template>
<div
v-if="showWorkTab"
class="worktab-strip box-border flex w-full items-center px-5 mb-3 select-none max-sm:px-[15px]"
class="worktab-strip worktab-strip--capsule box-border flex w-full items-center select-none"
>
<div class="worktab-scroll-wrap min-w-0 flex-1" ref="scrollRef">
<ul
class="float-left whitespace-nowrap !bg-transparent flex"
class="worktab-list float-left whitespace-nowrap !bg-transparent flex"
:class="[tabStyle === 'tab-google' ? 'pl-1' : '']"
ref="tabsRef"
:style="{
@@ -15,8 +15,11 @@
}"
>
<li
class="worktab-chip inline-flex flex-cc mr-1.5 text-xs c-p group"
:class="[item.path === activeTab ? 'is-active' : '', item.fixedTab ? 'is-fixed' : '']"
class="worktab-chip inline-flex flex-cc text-xs c-p group"
:class="[
item.path === activeTab ? 'is-active' : '',
item.fixedTab ? 'is-fixed is-home' : ''
]"
v-for="(item, index) in list"
:key="item.path"
:ref="item.path"
@@ -24,24 +27,26 @@
@click="clickTab(item)"
@contextmenu.prevent="(e) => showMenu(e, item.path)"
>
{{ item.customTitle || formatMenuTitle(item.title) }}
<span class="worktab-chip__label">{{
item.customTitle || formatMenuTitle(item.title)
}}</span>
<span
v-if="list.length > 1 && !item.fixedTab"
class="worktab-close inline-flex flex-cc relative ml-0.5 p-1 rounded-full tad-200"
class="worktab-close inline-flex flex-cc relative rounded-full tad-200"
@click.stop="closeWorktab('current', item.path)"
>
<ArtSvgIcon icon="close-bold" class="text-[10px] text-inherit" />
<ArtSvgIcon icon="close-bold" class="worktab-close__icon text-[10px]" />
</span>
</li>
</ul>
</div>
<div class="flex shrink-0">
<div class="worktab-menu-wrap flex shrink-0">
<div
class="worktab-menu-btn flex-cc relative top-0 size-8 leading-8 text-center c-p tad-200"
class="worktab-menu-btn flex-cc relative top-0 leading-none text-center c-p tad-200"
@click="(e) => showMenu(e, activeTab)"
>
<ArtSvgIcon icon="arrow-down" class="text-2xl text-g-700" />
<ArtSvgIcon icon="arrow-down" class="text-xl text-inherit" />
</div>
</div>
@@ -508,93 +513,167 @@
</script>
<style scoped>
.worktab-strip {
/* 方案 B′:浅底胶囊居中 + 未激活白底描边(docs/portal-worktab-capsule-v2-demo.html) */
.worktab-strip--capsule {
position: relative;
padding: 0 24px 4px !important;
background: var(--mint-surface);
gap: 8px;
align-items: center;
height: 48px;
min-height: 48px;
padding: 0 14px !important;
margin: 0 !important;
background: #f1f5f9;
border-bottom: 1px solid var(--mint-border, #e2e8f0);
overflow: visible !important;
}
.worktab-scroll-wrap {
display: flex;
align-items: center;
min-height: 100%;
overflow-x: clip;
overflow-y: visible;
}
.worktab-list {
gap: 6px;
align-items: center;
margin: 0;
padding: 0;
}
.worktab-chip {
position: relative;
z-index: 1;
max-width: 220px;
padding: 10px 16px !important;
margin-bottom: -7px;
display: inline-flex;
gap: 6px;
align-items: center;
max-width: 160px;
height: 32px;
min-height: 32px;
padding: 0 8px 0 12px !important;
margin: 0 !important;
overflow: hidden;
font-size: 13px;
font-weight: 600;
color: var(--mint-text-muted);
font-weight: 500;
line-height: 1;
color: #475569;
white-space: nowrap;
cursor: pointer;
background: #fff;
border: 1px solid #dbe3ec;
border-radius: 8px !important;
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.02);
transition:
background 0.15s ease,
color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.worktab-chip__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: var(--mint-surface);
border: 1px solid var(--mint-border);
border-radius: 8px 8px 0 0 !important;
transform: translateY(1px);
}
.worktab-chip.is-fixed {
padding-right: 12px !important;
padding-left: 12px !important;
}
.worktab-chip:hover {
color: var(--mint-text);
background: var(--mint-row-alt);
color: var(--mint-text, #1e293b);
background: #fff;
border-color: #cbd5e1;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.worktab-chip.is-active {
z-index: 2;
color: var(--mint-primary-dark);
border-bottom-color: var(--mint-surface);
box-shadow: none;
font-weight: 600;
color: var(--mint-primary-dark, #059669);
background: #fff;
border-color: #6ee7b7;
box-shadow:
0 0 0 1px rgba(16, 185, 129, 0.12),
0 1px 2px rgba(16, 185, 129, 0.1);
}
.worktab-chip.is-active::after {
content: '';
position: absolute;
z-index: 3;
right: 0;
bottom: 1px;
left: 0;
height: 2px;
background: var(--mint-primary);
pointer-events: none;
.worktab-chip.is-home.is-active {
background: var(--mint-primary-bg, #ecfdf5);
border-color: #6ee7b7;
}
.worktab-close {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
margin: 0;
color: #64748b;
border-radius: 999px;
opacity: 1;
transition:
background 0.15s ease,
color 0.15s ease;
}
.worktab-close__icon {
color: #64748b;
}
.worktab-close:hover {
background: var(--mint-row-alt);
color: var(--mint-text, #1e293b);
background: #f1f5f9;
}
.worktab-close:hover .worktab-close__icon {
color: var(--mint-text, #1e293b);
}
.worktab-chip.is-active .worktab-close,
.worktab-chip.is-active .worktab-close__icon {
color: #64748b;
}
.worktab-menu-wrap {
display: inline-flex;
align-items: center;
}
.worktab-menu-btn {
margin-left: 6px;
color: var(--mint-text-muted);
border: 1px solid var(--mint-border);
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: var(--mint-text-muted, #64748b);
background: #fff;
border: 1px solid #dbe3ec;
border-radius: 8px;
background: var(--mint-surface);
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.02);
}
.worktab-menu-btn:hover {
color: var(--mint-text);
background: var(--mint-row-alt);
color: var(--mint-primary-dark, #059669);
background: var(--mint-primary-bg, #ecfdf5);
border-color: #a7f3d0;
}
@media only screen and (width <= 768px) {
.worktab-strip {
padding-right: 0.625rem;
padding-left: 0.625rem;
.worktab-strip--capsule {
padding-right: 12px !important;
padding-left: 12px !important;
}
}
@media only screen and (width <= 640px) {
.worktab-strip {
padding-right: 0.9375rem;
padding-left: 0.9375rem;
.worktab-strip--capsule {
padding-right: 10px !important;
padding-left: 10px !important;
}
}
</style>
@@ -1,7 +1,10 @@
<template>
<div
class="filter-date-shortcuts list-toolbar-date-shortcuts"
:class="{ 'is-select': variant === 'select' }"
:class="{
'is-select': variant === 'select',
'is-pills': variant === 'pills'
}"
role="group"
:aria-label="ariaLabel"
>
@@ -20,6 +23,22 @@
:value="item.type"
/>
</el-select>
<div v-else-if="variant === 'pills'" class="panel-pill-tabs panel-pill-tabs--start list-toolbar-date-pills">
<div class="panel-pill-tabs__track" role="tablist" :aria-label="ariaLabel">
<button
v-for="item in shortcutItems"
:key="item.type"
type="button"
role="tab"
class="panel-pill-tabs__btn"
:class="{ 'is-active': active === item.type }"
:aria-selected="active === item.type"
@click="emit('change', item.type)"
>
{{ item.label }}
</button>
</div>
</div>
<template v-else>
<el-button
v-for="item in shortcutItems"
@@ -42,8 +61,8 @@
defineProps<{
active: DateShortcutType | ''
ariaLabel?: string
/** buttons=五个描边按钮;select=下拉选择(列表工具栏默认) */
variant?: 'buttons' | 'select'
/** buttons=描边按钮;select=下拉;pills=胶囊分段 Tab(列表工具栏推荐) */
variant?: 'buttons' | 'select' | 'pills'
}>(),
{
ariaLabel: '日期快捷筛选',
@@ -87,6 +106,15 @@
margin-right: 8px;
}
.list-toolbar-date-shortcuts.is-pills {
display: inline-flex;
align-items: center;
flex-shrink: 0;
padding-right: 8px;
margin-right: 4px;
border-right: 1px solid var(--mint-border, #d9e8e2);
}
.date-shortcut-select {
width: 112px;
}
@@ -94,4 +122,22 @@
.date-shortcut-select :deep(.el-select__wrapper) {
min-height: 36px;
}
/* 工具栏内胶囊 Tab:紧凑尺寸,左对齐,无面板级外边距 */
.list-toolbar-date-pills.panel-pill-tabs {
margin: 0;
justify-content: flex-start;
}
.list-toolbar-date-pills .panel-pill-tabs__track {
gap: 2px;
padding: 3px;
}
.list-toolbar-date-pills .panel-pill-tabs__btn {
min-width: 0;
height: 30px;
padding: 0 12px;
font-size: 13px;
}
</style>
+10 -10
View File
@@ -329,12 +329,12 @@ async function handleDynamicRoutes(
// 7. 验证工作标签页
useWorktabStore().validateWorktabs(router)
// 7.5 冷登录:按收藏预开 Tab,落点首页
// 7.5 冷登录:按收藏预开 Tab;有收藏则落点第一个收藏页,否则首页
const userStore = useUserStore()
const userId = String(userStore.info?.userId || '')
let didPreopenFavorites = false
const userId = String(userStore.info?.userId || userStore.info?.id || '')
let preopenLandingPath: string | null = null
try {
didPreopenFavorites = await preopenFavoriteTabsIfNeeded(userId)
preopenLandingPath = await preopenFavoriteTabsIfNeeded(userId)
} catch (e) {
console.warn('[RouteGuard] 预开收藏菜单失败:', e)
}
@@ -346,9 +346,9 @@ async function handleDynamicRoutes(
if (isStaticRoute(to.path)) {
routeInitInProgress = false
next({
path: didPreopenFavorites ? portalHome : to.path,
query: didPreopenFavorites ? undefined : to.query,
hash: didPreopenFavorites ? undefined : to.hash,
path: preopenLandingPath || to.path,
query: preopenLandingPath ? undefined : to.query,
hash: preopenLandingPath ? undefined : to.hash,
replace: true
})
return
@@ -364,9 +364,9 @@ async function handleDynamicRoutes(
// 初始化成功,重置进行中标记
routeInitInProgress = false
// 9. 重新导航到目标路由(冷登录预开后强制首页)
if (didPreopenFavorites) {
next({ path: portalHome, replace: true })
// 9. 重新导航到目标路由(冷登录预开后落点收藏首项或首页)
if (preopenLandingPath) {
next({ path: preopenLandingPath, replace: true })
return
}
+20 -4
View File
@@ -310,6 +310,13 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
component: '/crm/quote/index',
meta: { title: '报价单管理', keepAlive: true, menuCode: 'crm:quote:list' }
},
{
id: 'CRM3000000000000000000000000016',
name: 'CrmQuoteApproval',
path: 'quote-approval',
component: '/crm/quote/approval/index',
meta: { title: '报价单审批', keepAlive: true, menuCode: 'crm:quote:approve' }
},
{
id: 'CRM3000000000000000000000000015',
name: 'CrmQuoteApproverSetting',
@@ -322,6 +329,7 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
menuCode: 'crm:quote:approverSetting'
}
},
{
id: 'CRM3000000000000000000000000011',
name: 'CrmQuoteForm',
@@ -365,6 +373,13 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
component: '/crm/contract/index',
meta: { title: '合同管理', keepAlive: true, menuCode: 'crm:contract:list' }
},
{
id: 'CRM3000000000000000000000000021',
name: 'CrmContractApproval',
path: 'contract-approval',
component: '/crm/contract/approval/index',
meta: { title: '合同审批', keepAlive: true, menuCode: 'crm:contract:approve' }
},
{
id: 'CRM3000000000000000000000000026',
name: 'CrmContractApproverSetting',
@@ -377,6 +392,7 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
menuCode: 'crm:contract:approverSetting'
}
},
{
id: 'CRM3000000000000000000000000030',
name: 'CrmPriceLetter',
@@ -385,7 +401,7 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
meta: { title: '调价函管理', keepAlive: true, menuCode: 'crm:priceLetter:list' }
},
{
id: 'CRM3000000000000000000000000021',
id: 'CRM3000000000000000000000000027',
name: 'CrmContractForm',
path: 'contract/form',
component: '/crm/contract/form',
@@ -434,11 +450,11 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
},
{
id: 'CRM3000000000000000000000000025',
name: 'CrmContractApproval',
name: 'CrmContractApprovalRecord',
path: 'contract/approval',
component: '/crm/contract/approval/index',
component: '/crm/contract/approval-record/index',
meta: {
title: '合同审批',
title: '合同审批记录',
keepAlive: true,
isHide: true,
menuCode: 'crm:contract:approval:list'
@@ -5,6 +5,7 @@ import {
fetchAddMenuFavorite,
fetchMenuFavorites,
fetchRemoveMenuFavorite,
fetchReorderMenuFavorites,
type MenuFavoriteItem
} from '@/api/menuFavorite'
@@ -42,6 +43,33 @@ export const useMenuFavoriteStore = defineStore('menuFavoriteStore', () => {
items.value = [...items.value, row]
}
/** 按 path 列表重排并落库;失败时重新拉取以回滚本地顺序 */
async function reorderFavorites(orderedPaths: string[]) {
const prev = [...items.value]
const pathSet = new Set(prev.map((i) => i.path))
if (
orderedPaths.length !== prev.length ||
orderedPaths.some((p) => !pathSet.has(p)) ||
new Set(orderedPaths).size !== orderedPaths.length
) {
await loadFavorites()
return
}
// 乐观更新本地顺序
const byPath = new Map(prev.map((i) => [i.path, i]))
items.value = orderedPaths.map((p, idx) => ({
...byPath.get(p)!,
orderNum: idx + 1
}))
try {
const list = await fetchReorderMenuFavorites(orderedPaths)
items.value = Array.isArray(list) ? list : items.value
} catch (e) {
items.value = prev
throw e
}
}
function clear() {
items.value = []
loaded.value = false
@@ -54,6 +82,7 @@ export const useMenuFavoriteStore = defineStore('menuFavoriteStore', () => {
loadFavorites,
toggleFavorite,
removeFavorite,
reorderFavorites,
clear
}
})
+9 -1
View File
@@ -61,11 +61,17 @@ export const useUserStore = defineStore(
*/
const setUserInfo = (newInfo: Partial<Api.Auth.UserInfo> & Record<string, any>) => {
const webRole = String(newInfo?.role ?? '').trim()
// 后端 current-user / login 返回 id,统一映射为 userId(预开收藏、换用户清 Tab 等依赖)
const resolvedUserId = String(
newInfo?.userId ?? newInfo?.id ?? info.value?.userId ?? info.value?.id ?? ''
).trim()
info.value = {
...(info.value || {}),
...(newInfo || {}),
buttons: newInfo?.buttons ?? info.value?.buttons ?? [],
userName: newInfo?.userName ?? newInfo?.username ?? info.value?.userName ?? ''
userName: newInfo?.userName ?? newInfo?.username ?? info.value?.userName ?? '',
userId: resolvedUserId || undefined,
id: resolvedUserId || newInfo?.id || info.value?.id
} as Api.Auth.UserInfo
// 后端 role(admin/super_admin)为权威来源,同步到 roles,避免持久化旧 demo roles 导致菜单被滤空
if (webRole) {
@@ -141,6 +147,8 @@ export const useUserStore = defineStore(
lockPassword.value = ''
// 移除iframe路由缓存
sessionStorage.removeItem('iframeRoutes')
// 退出登录:清空会话 Tab(冷登录再按收藏预开)
useWorktabStore().clearAll()
// 清空主页路径
useMenuStore().setHomePath('')
useMenuBadgeStore().clearCounts()
+18 -2
View File
@@ -13,6 +13,7 @@ export type BxAttachmentItem = {
const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|svg)$/i
const VIDEO_EXT = /\.(mp4|webm|ogg|mov|avi)$/i
const PDF_EXT = /\.pdf$/i
export function isBxImageFile(file: { fileName?: string; fileType?: string }) {
const name = String(file.fileName || '').toLowerCase()
@@ -26,9 +27,15 @@ export function isBxVideoFile(file: { fileName?: string; fileType?: string }) {
return type.startsWith('video/') || VIDEO_EXT.test(name)
}
export function isBxPdfFile(file: { fileName?: string; fileType?: string }) {
const name = String(file.fileName || '').toLowerCase()
const type = String(file.fileType || '').toLowerCase()
return type === 'application/pdf' || type.includes('pdf') || PDF_EXT.test(name)
}
export function canBxPreview(file: BxAttachmentItem) {
if (file._uploading) return false
return isBxImageFile(file) || isBxVideoFile(file)
return isBxImageFile(file) || isBxVideoFile(file) || isBxPdfFile(file)
}
export async function fillBxAttachmentPreviews(
@@ -102,6 +109,15 @@ export async function previewBxAttachment(file: BxAttachmentItem) {
throw error
}
}
await downloadBxAttachment(file)
if (isBxPdfFile(file)) {
try {
const res: any = await attachmentApi.preview(file.id)
return { type: 'pdf' as const, url: res.data?.url || '', fileName: file.fileName }
} catch (error: any) {
ElMessage.error(error?.message || '预览失败')
throw error
}
}
ElMessage.warning('该文件类型暂不支持在线预览,请使用下载查看')
return null
}
+27 -2
View File
@@ -1,7 +1,10 @@
/**
* CRM 附件解析与下载 — 门户 MinIO(fileUrl 即存储键/URL,不再走 CRM /resource/oss/listByIds)
*/
import { downloadAttachmentFile } from '@/utils/download-attachment'
import {
downloadAttachmentFile,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
export type CrmAttachmentRef = {
ossId: string
@@ -178,7 +181,29 @@ export async function downloadCrmAttachment(file: {
const fileUrl = String(file.url || file.ossId || '').trim()
if (!fileUrl) return
const fileName = String(file.fileName || file.name || '').trim() || crmAttachmentDisplayName(fileUrl)
await downloadAttachmentFile({ fileUrl, fileName })
await downloadAttachmentFile({ fileUrl, fileName }, { fullFileUrl: resolveCrmAttachmentUrl })
}
/** CRM 附件在新标签页预览(图片 HTML 内嵌 / PDF 预签名直开) */
export async function openCrmAttachmentPreviewInNewTab(file: {
ossId?: string
url?: string
name?: string
fileName?: string
}) {
const fileUrl = String(file.url || file.ossId || '').trim()
if (!fileUrl) return false
const fileName =
String(file.fileName || file.name || '').trim() || crmAttachmentDisplayName(fileUrl)
const kind = isCrmImageFile({ name: fileName, url: fileUrl, ossId: fileUrl })
? 'image'
: isCrmPdfFile({ name: fileName, url: fileUrl, ossId: fileUrl })
? 'pdf'
: 'other'
return openAttachmentPreviewInNewTab(
{ fileUrl, fileName },
{ fullFileUrl: resolveCrmAttachmentUrl, kind }
)
}
/** el-upload 回显:从 attachmentFiles 字符串构建 file-list */
@@ -0,0 +1,24 @@
/** 报价单 / 合同负责人候选(按真实姓名匹配用户列表) */
export const CRM_OWNER_CANDIDATE_NAMES = ['方金强', '吴九华'] as const
export const CRM_DEFAULT_OWNER_NAME = '方金强'
type OwnerLike = { nickName?: string }
/** 按固定顺序筛选负责人候选 */
export function filterOwnerCandidates<T extends OwnerLike>(users: T[]): T[] {
const byName = new Map<string, T>()
for (const user of users) {
const name = (user.nickName || '').trim()
if (name && !byName.has(name)) {
byName.set(name, user)
}
}
return CRM_OWNER_CANDIDATE_NAMES.map((name) => byName.get(name)).filter(Boolean) as T[]
}
/** 默认负责人:方金强 */
export function findDefaultOwnerUser<T extends OwnerLike>(users: T[]): T | undefined {
const name = CRM_DEFAULT_OWNER_NAME
return users.find((user) => (user.nickName || '').trim() === name)
}
+102
View File
@@ -6,6 +6,8 @@ export type AttachmentDownloadItem = {
fileName?: string
}
export type AttachmentPreviewKind = 'image' | 'pdf' | 'other'
/** 将 JSON 数组或逗号分隔的附件 URL 统一为逗号分隔字符串 */
export function normalizeAttachmentUrls(raw?: string | null): string {
if (!raw) return ''
@@ -34,6 +36,44 @@ function triggerAnchorDownload(href: string, fileName: string) {
document.body.removeChild(a)
}
function escapeHtmlAttr(value: string) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
/** 根据文件名/URL 推断预览类型 */
export function detectAttachmentPreviewKind(file: AttachmentDownloadItem): AttachmentPreviewKind {
const name = String(file.fileName || '').toLowerCase()
const url = String(file.fileUrl || '')
.split('?')[0]
.toLowerCase()
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(name) || /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(url)) {
return 'image'
}
if (/\.pdf$/i.test(name) || /\.pdf$/i.test(url)) {
return 'pdf'
}
return 'other'
}
/** 图片用 HTML 内嵌展示,避免 Content-Type 为 octet-stream 时顶级导航触发下载 */
function openImagePreviewDocument(previewWindow: Window | null, href: string, title: string) {
const safeSrc = escapeHtmlAttr(href)
const safeTitle = escapeHtmlAttr(title || '图片预览')
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${safeTitle}</title><style>html,body{margin:0;height:100%;background:#0f172a}body{display:flex;align-items:center;justify-content:center}img{max-width:100%;max-height:100vh;object-fit:contain}</style></head><body><img src="${safeSrc}" alt="${safeTitle}"/></body></html>`
if (previewWindow) {
previewWindow.document.open()
previewWindow.document.write(html)
previewWindow.document.close()
return
}
const blobUrl = URL.createObjectURL(new Blob([html], { type: 'text/html;charset=utf-8' }))
window.open(blobUrl, '_blank', 'noopener,noreferrer')
}
/** 附件预览:优先走后端预签名 inline URL,供 PDF iframe 等内联展示。 */
export async function resolveAttachmentPreviewUrl(
file: AttachmentDownloadItem,
@@ -53,6 +93,68 @@ export async function resolveAttachmentPreviewUrl(
return resolveUrl(fileUrl)
}
/**
* 在浏览器新标签页打开附件预览(图片 HTML 内嵌 / PDF 直开)。
* 不支持在线预览的类型会提示并返回 false。
*/
export async function openAttachmentPreviewInNewTab(
file: AttachmentDownloadItem,
options?: {
fullFileUrl?: (url: string) => string
kind?: AttachmentPreviewKind
}
): Promise<boolean> {
const fileUrl = String(file.fileUrl || '').trim()
if (!fileUrl) {
ElMessage.error('无法获取文件预览地址')
return false
}
const kind = options?.kind || detectAttachmentPreviewKind(file)
if (kind === 'other') {
ElMessage.warning('该文件类型暂不支持在线预览,请使用下载查看')
return false
}
const previewWindow = window.open('about:blank', '_blank')
try {
const previewUrl = await resolveAttachmentPreviewUrl(file, {
fullFileUrl: options?.fullFileUrl
})
const href =
previewUrl || (options?.fullFileUrl ? options.fullFileUrl(fileUrl) : fileUrl)
if (!href) {
previewWindow?.close()
ElMessage.error('无法获取文件预览地址')
return false
}
if (kind === 'image') {
openImagePreviewDocument(previewWindow, href, file.fileName || '图片预览')
return true
}
if (previewWindow) {
previewWindow.location.href = href
} else {
window.open(href, '_blank', 'noopener,noreferrer')
}
return true
} catch (error) {
console.error('打开附件预览失败:', error)
const fallback = options?.fullFileUrl ? options.fullFileUrl(fileUrl) : fileUrl
if (fallback && kind === 'image') {
openImagePreviewDocument(previewWindow, fallback, file.fileName || '图片预览')
return true
}
if (fallback && previewWindow) {
previewWindow.location.href = fallback
return true
}
previewWindow?.close()
ElMessage.error('打开预览失败,请稍后重试或使用下载')
return false
}
}
/** 附件下载:优先走后端预签名 URL,保证跨域场景下保留原始文件名与后缀。 */
export async function downloadAttachmentFile(
file: AttachmentDownloadItem,
@@ -7,12 +7,15 @@ import { collectPortalHomeSections, findHomeCardByPath } from '@/utils/router/po
const PREOPEN_FLAG_KEY = 'portal-favorites-preopened-user'
/**
* 冷登录:按收藏预开 Tab,当前仍停在首页。
* 冷登录:按收藏预开 Tab。
* 有有效收藏时激活第一个收藏页;否则落点首页。
* 同用户刷新且已有业务 Tab 时跳过(依赖 worktab 本地持久化)。
*
* @returns 落点 path;未执行预开则返回 null
*/
/** @returns 是否执行了冷登录预开(调用方应落点首页) */
export async function preopenFavoriteTabsIfNeeded(userId: string): Promise<boolean> {
if (!userId) return false
export async function preopenFavoriteTabsIfNeeded(userId: string): Promise<string | null> {
const uid = String(userId || '').trim()
if (!uid) return null
const home = HOME_PAGE_PATH || '/home'
const worktab = useWorktabStore()
@@ -21,12 +24,12 @@ export async function preopenFavoriteTabsIfNeeded(userId: string): Promise<boole
)
// 刷新恢复:已有业务 Tab 则不再按收藏重开
if (hasBusinessTabs) {
sessionStorage.setItem(PREOPEN_FLAG_KEY, userId)
return false
sessionStorage.setItem(PREOPEN_FLAG_KEY, uid)
return null
}
// 同会话已预开过(仅首页等)则跳过
if (sessionStorage.getItem(PREOPEN_FLAG_KEY) === userId) {
return false
if (sessionStorage.getItem(PREOPEN_FLAG_KEY) === uid) {
return null
}
const favStore = useMenuFavoriteStore()
@@ -45,6 +48,7 @@ export async function preopenFavoriteTabsIfNeeded(userId: string): Promise<boole
{ activate: true }
)
let firstFavPath: string | null = null
for (const item of [...favStore.items]) {
const card = findHomeCardByPath(sections, item.path)
if (!card) {
@@ -59,6 +63,10 @@ export async function preopenFavoriteTabsIfNeeded(userId: string): Promise<boole
if (!resolved.matched.length) {
continue
}
const isFirst = firstFavPath == null
if (isFirst) {
firstFavPath = card.path
}
worktab.openTab(
{
path: card.path,
@@ -67,12 +75,12 @@ export async function preopenFavoriteTabsIfNeeded(userId: string): Promise<boole
fixedTab: false,
keepAlive: true
},
{ activate: false }
{ activate: isFirst }
)
}
sessionStorage.setItem(PREOPEN_FLAG_KEY, userId)
return true
sessionStorage.setItem(PREOPEN_FLAG_KEY, uid)
return firstFavPath || home
}
export function clearFavoritePreopenFlag(): void {
+46 -1
View File
@@ -1,4 +1,9 @@
import { normalizeAttachmentUrls } from '@/utils/download-attachment'
import { ElMessage } from 'element-plus'
import {
downloadAttachmentFile,
normalizeAttachmentUrls,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
/** 取逗号分隔附件中的第一张 URL(相对或绝对) */
export function firstPhotoUrl(raw?: string | null): string {
@@ -15,3 +20,43 @@ export function resolvePhotoDisplayUrl(url?: string | null): string {
const base = String(import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
return base ? `${base}/${trimmed.replace(/^\//, '')}` : trimmed
}
function photoFileName(url: string) {
return (
String(url || '')
.split('?')[0]
.split('/')
.pop() || '照片'
)
}
/** 列表照片列:新标签页预览首图 */
export async function previewListPhoto(raw?: string | null) {
const fileUrl = firstPhotoUrl(raw)
if (!fileUrl) {
ElMessage.error('无法获取照片地址')
return
}
await openAttachmentPreviewInNewTab(
{ fileUrl, fileName: photoFileName(fileUrl) },
{ fullFileUrl: resolvePhotoDisplayUrl, kind: 'image' }
)
}
/** 列表照片列:下载首图 */
export async function downloadListPhoto(raw?: string | null) {
const fileUrl = firstPhotoUrl(raw)
if (!fileUrl) {
ElMessage.error('无法获取照片地址')
return
}
try {
await downloadAttachmentFile(
{ fileUrl, fileName: photoFileName(fileUrl) },
{ fullFileUrl: resolvePhotoDisplayUrl }
)
ElMessage.success('下载成功')
} catch {
ElMessage.error('下载失败,请稍后重试')
}
}
+65 -6
View File
@@ -1,9 +1,6 @@
import type { AppRouteRecord } from '@/types/router'
import { getFirstMenuPath, getFirstSecondLevelMenuPath, isIframe } from '@/utils/navigation/route'
import {
filterSidebarRoutes,
isSidebarNavigableRoute
} from '@/utils/router/sidebar-menu-filter'
import { filterSidebarRoutes, isSidebarNavigableRoute } from '@/utils/router/sidebar-menu-filter'
/** 大面板一行:二级标题 + 三级可点链接 */
export interface PortalMegaPanelRow {
@@ -167,8 +164,7 @@ export function resolvePortalSubMenus(
/** 门户默认首页:优先 CRM 管理模块首个可访问页 */
export function getPortalDefaultHomePath(menuList: AppRouteRecord[]): string {
const tops = filterPortalTopModules(menuList)
const defaultModule =
tops.find((item) => item.path === PORTAL_DEFAULT_MODULE_PATH) || tops[0]
const defaultModule = tops.find((item) => item.path === PORTAL_DEFAULT_MODULE_PATH) || tops[0]
if (defaultModule) {
return getFirstAccessiblePath(defaultModule)
}
@@ -280,3 +276,66 @@ export function isMegaPanelLinkActive(item: AppRouteRecord, routePath: string):
}
return activePath === itemPath || activePath.startsWith(`${itemPath}/`)
}
/** 顶栏二级平铺项间距(与 `.portal-flat-nav__bar` gap 一致) */
export const PORTAL_FLAT_NAV_ITEM_GAP = 2
/**
* 按可用宽度计算可平铺的二级数量;其余应收入「更多」。
* @param itemWidths 各二级项实测宽度(含 caret)
* @param moreWidth 「更多」按钮宽度
* @param availableWidth 中间导航槽可用宽度
*/
export function calcPortalFlatNavVisibleCount(
itemWidths: number[],
moreWidth: number,
availableWidth: number,
gap: number = PORTAL_FLAT_NAV_ITEM_GAP
): number {
if (!itemWidths.length || availableWidth <= 0) {
return 0
}
const total = itemWidths.reduce((sum, width, index) => sum + width + (index ? gap : 0), 0)
if (total <= availableWidth) {
return itemWidths.length
}
let used = 0
let count = 0
for (let i = 0; i < itemWidths.length; i++) {
const next = used + (count ? gap : 0) + itemWidths[i]
const needMore = i < itemWidths.length - 1
const limit = needMore ? availableWidth - moreWidth - gap : availableWidth
if (next <= limit + 0.5) {
used = next
count++
} else {
break
}
}
return Math.max(0, count)
}
/** 二级是否存在真实子级(需展开下拉,不可直接跳转) */
export function l2HasExpandableChildren(l2: AppRouteRecord): boolean {
return filterSidebarRoutes(l2.children || []).length > 0
}
/**
* 当前一级模块下可展示的二级列表。
* 仅保留至少有一个可导航目标(真实三级或虚拟自指)的二级。
*/
export function listPortalFlatNavL2Items(l1Module: AppRouteRecord | undefined): AppRouteRecord[] {
if (!l1Module) {
return []
}
const l2List = filterSidebarRoutes(l1Module.children || [])
return l2List.filter((l2) => resolveMegaPanelL3Links(l2).length > 0)
}
/** 二级(含其下三级)是否匹配当前路由 */
export function isPortalL2Active(l2: AppRouteRecord, routePath: string): boolean {
return resolveMegaPanelL3Links(l2).some((link) => isMegaPanelLinkActive(link, routePath))
}
@@ -3,12 +3,8 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">备用金申请列表</h2>
</div>
<div class="filter-block">
<el-form :model="filterForm" class="filter-form" @submit.prevent>
<el-form :model="filterForm" class="filter-form" @submit.prevent style="--filter-label-width: 130px">
<div class="filter-grid">
<el-form-item label="申请单号">
<el-input v-model="filterForm.keyword" placeholder="请输入申请单号" clearable />
@@ -3,10 +3,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">备用金审批</h2>
</div>
<el-tabs
v-model="activeTab"
class="panel-tabs"
@@ -3,10 +3,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">备用金发放</h2>
</div>
<el-tabs
v-model="activeTab"
class="panel-tabs"
@@ -3,10 +3,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">费用项目列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
@@ -2,10 +2,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">发票夹管理</h2>
</div>
<div class="filter-block">
<el-form :model="filterForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
@@ -2,9 +2,8 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">我的发票夹</h2>
<p v-if="summary" class="panel-top-meta">
<div v-if="summary" class="panel-top">
<p class="panel-top-meta">
总额 {{ formatMoneyYuan(summary.totalAmount) }} 元 · 已报销占用
{{ formatMoneyYuan(summary.usedAmount) }} 元 · 余额
<b class="money-em">{{ formatMoneyYuan(summary.balance) }}</b> 元 · 发票
-4
View File
@@ -3,10 +3,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">支付管理</h2>
</div>
<el-tabs
v-model="activeTab"
class="panel-tabs"
@@ -3,10 +3,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">报销申请列表</h2>
</div>
<div class="filter-block">
<el-form :model="filterForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
@@ -3,10 +3,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">报销审批</h2>
</div>
<el-tabs
v-model="activeTab"
class="panel-tabs"
@@ -15,7 +11,7 @@
<!-- 待我审批 -->
<el-tab-pane label="待我审批" name="pending">
<div class="filter-block">
<el-form :model="pendingFilterForm" class="filter-form" @submit.prevent>
<el-form :model="pendingFilterForm" class="filter-form" @submit.prevent style="--filter-label-width: 110px">
<div class="filter-grid">
<el-form-item label="报销单号">
<el-input v-model="pendingFilterForm.code" placeholder="请输入报销单号" clearable />
@@ -238,7 +234,7 @@
<!-- 我已审批 -->
<el-tab-pane label="我已审批" name="approved">
<div class="filter-block">
<el-form :model="approvedFilterForm" class="filter-form" @submit.prevent>
<el-form :model="approvedFilterForm" class="filter-form" @submit.prevent style="--filter-label-width: 110px">
<div class="filter-grid">
<el-form-item label="报销单号">
<el-input v-model="approvedFilterForm.code" placeholder="请输入报销单号" clearable />
+1 -5
View File
@@ -3,12 +3,8 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">全部申请单</h2>
</div>
<div class="filter-block">
<el-form :model="filterForm" class="filter-form" @submit.prevent>
<el-form :model="filterForm" class="filter-form" @submit.prevent style="--filter-label-width: 110px">
<div class="filter-grid">
<el-form-item label="单号">
<el-input v-model="filterForm.code" placeholder="请输入单号" clearable />
+1 -5
View File
@@ -3,12 +3,8 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">我的申请单</h2>
</div>
<div class="filter-block">
<el-form :model="filterForm" class="filter-form" @submit.prevent>
<el-form :model="filterForm" class="filter-form" @submit.prevent style="--filter-label-width: 110px">
<div class="filter-grid">
<el-form-item label="单号">
<el-input v-model="filterForm.code" placeholder="请输入单号" clearable />
+1 -5
View File
@@ -3,12 +3,8 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">待我审批</h2>
</div>
<div class="filter-block">
<el-form :model="filterForm" class="filter-form" @submit.prevent>
<el-form :model="filterForm" class="filter-form" @submit.prevent style="--filter-label-width: 110px">
<div class="filter-grid">
<el-form-item label="单号">
<el-input v-model="filterForm.code" placeholder="请输入单号" clearable />
@@ -3,10 +3,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">审批流程节点</h2>
</div>
<div class="wf-info-bar">
<span><b>流程名称:</b>{{ wfDef.wfName || '-' }}</span>
<span>
@@ -3,10 +3,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">审批流程节点</h2>
</div>
<div class="wf-info-bar">
<span><b>流程名称:</b>{{ wfDef.wfName || '-' }}</span>
<span>
+1 -4
View File
@@ -2,9 +2,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">线索详情</h2>
</div>
<el-button class="btn-ghost" @click="goBack">
<el-icon><ArrowLeft /></el-icon>返回列表
</el-button>
@@ -23,7 +20,7 @@
const detailDrawerRef = ref<InstanceType<typeof DetailDrawer>>()
function goBack() {
router.push({ path: '/crm/clue' })
router.push({ path: '/system/clue' })
}
onMounted(() => {
-4
View File
@@ -2,10 +2,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">线索管理</h2>
</div>
<el-tabs
v-model="activeView"
class="panel-tabs"
@@ -1,10 +1,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">线索统计</h2>
</div>
<div class="filter-block">
<el-form class="filter-form" @submit.prevent>
<div class="filter-grid">
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,596 @@
<!-- 合同审批记录 -->
<template>
<div class="page-container">
<section class="panel">
<div class="filter-block">
<el-form ref="queryFormRef" :model="queryParams" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="合同编号" prop="contractNo">
<el-input
v-model="queryParams.contractNo"
placeholder="合同编号"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="合同名称" prop="contractName">
<el-input
v-model="queryParams.contractName"
placeholder="合同名称"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="审批人" prop="approverName">
<el-input
v-model="queryParams.approverName"
placeholder="审批人"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="审批结果" prop="approvalResult">
<el-select v-model="queryParams.approvalResult" placeholder="全部" clearable>
<el-option label="通过" value="approved" />
<el-option label="驳回" value="rejected" />
<el-option label="待审批" value="pending" />
</el-select>
</el-form-item>
<el-form-item label="审批时间" class="form-col-span-2">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">按合同、审批人、结果及时间筛选。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="resetQuery">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleQuery">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="合同编号/合同名称/审批人"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button
class="btn-ghost"
:loading="exportLoading"
@click="handleExport"
>
<el-icon><Download /></el-icon>导出
</el-button>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="getList"
@reset-columns="resetToDefault"
/>
</div>
</div>
<div class="list-body list-wrap">
<ListTable table-key="crm-contract-approval"
class="list-table"
v-loading="loading"
:data="approvalList"
stripe
row-key="id"
@row-dblclick="handleView"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (queryParams.pageNum - 1) * queryParams.pageSize + index + 1"
/>
<el-table-column
prop="contractNo"
label="合同编号"
min-width="150"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-link type="primary" @click="handleViewContract(row)">{{
row.contractNo
}}</el-link>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('contractName')"
prop="contractName"
label="合同名称"
min-width="240"
align="left"
header-align="left"
class-name="col-name-wrap"
label-class-name="col-name-wrap"
/>
<el-table-column label="审批级别" v-if="isColumnVisible('step')" min-width="100" align="center" header-align="center">
<template #default="{ row }">第{{ row.step }}级</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('approverName')"
prop="approverName"
label="审批人"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column label="审批结果" v-if="isColumnVisible('approvalResult')" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag v-if="row.approvalResult === 'approved'" type="success" size="small"
>通过</el-tag
>
<el-tag v-else-if="row.approvalResult === 'rejected'" type="danger" size="small"
>驳回</el-tag
>
<el-tag v-else type="warning" size="small">待审批</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('approvalOpinion')"
prop="approvalOpinion"
label="审批意见"
min-width="200"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="审批时间" v-if="isColumnVisible('approvalTime')" min-width="160" align="center" header-align="center">
<template #default="{ row }">{{ parseTime(row.approvalTime) }}</template>
</el-table-column>
</ListTable>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 30, 50]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="getList"
@current-change="getList"
/>
</div>
</div>
<SystemViewDialog
v-model:visible="viewDialog.visible"
title="审批详情"
:icon="Document"
:code="viewDialog.data.contractNo"
code-label="合同编号"
:meta="viewDialog.data.contractName"
:sections="approvalViewSections"
append-to-body
>
<template #hero-actions>
<div class="approval-view-hero-actions">
<el-button
v-if="viewDialog.data.approvalResult === 'pending'"
class="btn-main"
type="primary"
@click="handleApprove(viewDialog.data)"
>
<el-icon><EditPen /></el-icon>审批
</el-button>
</div>
</template>
</SystemViewDialog>
<el-dialog
v-model="approvalDialog.visible"
title="审批"
width="600px"
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
align-center
destroy-on-close
:close-on-click-modal="false"
append-to-body
>
<el-form
ref="approvalFormRef"
:model="approvalForm"
:rules="approvalRules"
label-position="right"
label-width="100px"
class="crm-dialog-form"
scroll-to-error
>
<div class="form-grid-2">
<el-form-item label="审批结果" prop="approvalResult" class="form-col-span-2">
<el-radio-group v-model="approvalForm.approvalResult">
<el-radio value="approved">通过</el-radio>
<el-radio value="rejected">驳回</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="审批意见" prop="approvalOpinion" class="form-col-span-2">
<el-input
v-model="approvalForm.approvalOpinion"
type="textarea"
:rows="5"
:placeholder="
approvalForm.approvalResult === 'rejected'
? '请输入驳回原因'
: '请输入审批意见(可选)'
"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="approvalDialog.visible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="submitLoading"
@click="submitApproval"
>
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
</section>
</div>
</template>
<script setup lang="ts" name="ContractApproval">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { useRouter } from 'vue-router'
import {
Document,
Download,
Search,
RefreshLeft,
Close,
Check,
EditPen
} from '@element-plus/icons-vue'
import SystemViewDialog, {
type ViewField,
type ViewSection
} from '@/components/business/system-view-dialog/index.vue'
import { crmField, crmLongField, crmTagField } from '@/utils/crm/view-fields'
import {
listApproval,
getApproval,
approvePass,
approveReject,
exportApproval,
type ApprovalQuery,
type ApprovalVO
} from '@/api/crm/contract/approval'
import { parseTime } from '@/utils/format'
import { $modal } from '@/utils/modal'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
const router = useRouter()
const quickSearchKeyword = ref('')
const listColumnDefs = [
{ prop: 'contractName', label: '合同名称' },
{ prop: 'step', label: '审批级别' },
{ prop: 'approverName', label: '审批人' },
{ prop: 'approvalResult', label: '审批结果' },
{ prop: 'approvalOpinion', label: '审批意见' },
{ prop: 'approvalTime', label: '审批时间' }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('crm_contract_approval_visible_columns', listColumnDefs)
const queryParams = ref<ApprovalQuery>({
pageNum: 1,
pageSize: 20,
contractNo: undefined,
contractName: undefined,
approverName: undefined,
approvalResult: undefined,
beginTime: undefined,
endTime: undefined
})
const approvalForm = ref({
id: undefined as number | undefined,
approvalResult: 'approved',
approvalOpinion: ''
})
const approvalRules = ref({
approvalResult: [{ required: true, message: '请选择审批结果', trigger: 'change' }],
approvalOpinion: [
{
validator: (_rule: unknown, value: string, callback: (err?: Error) => void) => {
if (approvalForm.value.approvalResult === 'rejected' && !value) {
callback(new Error('驳回时必须填写驳回原因'))
} else {
callback()
}
},
trigger: 'blur'
}
]
})
const approvalList = ref<ApprovalVO[]>([])
const loading = ref(true)
const selectedRows = ref<ApprovalVO[]>([])
const submitLoading = ref(false)
const exportLoading = ref(false)
const total = ref(0)
const dateRange = ref<[string, string]>()
const viewDialog = reactive({
visible: false,
data: {} as ApprovalVO
})
function approvalResultTag(result?: string) {
if (result === 'approved') return { label: '通过', type: 'success' as const }
if (result === 'rejected') return { label: '驳回', type: 'danger' as const }
return { label: '待审批', type: 'warning' as const }
}
const approvalViewSections = computed<ViewSection[]>(() => {
const d = viewDialog.data
if (!d?.id && !d?.contractNo) return []
const result = approvalResultTag(d.approvalResult)
const basicFields: ViewField[] = [
crmField('合同名称', d.contractName),
crmField('审批流程 ID', d.flowId),
crmField('审批级别', d.step != null ? `第${d.step}级` : undefined),
crmField('步骤名称', d.stepName),
crmField('审批人部门', d.approverDept),
crmField('审批人', d.approverName),
crmTagField('审批结果', result.label, result.type)
]
const detailFields: ViewField[] = [
crmLongField('审批意见', d.approvalOpinion, { fullSpan: true }),
crmField('审批时间', parseTime(d.approvalTime)),
crmField('创建时间', parseTime(d.createTime)),
crmLongField('备注', d.remark, { fullSpan: true })
]
return [
{ title: '基本信息', fields: basicFields },
{ title: '审批详情', fields: detailFields }
]
})
const approvalDialog = reactive({
visible: false
})
const queryFormRef = ref<ElFormInstance>()
const approvalFormRef = ref<ElFormInstance>()
const getList = async () => {
loading.value = true
try {
if (dateRange.value && dateRange.value.length === 2) {
queryParams.value.beginTime = dateRange.value[0]
queryParams.value.endTime = dateRange.value[1]
} else {
queryParams.value.beginTime = undefined
queryParams.value.endTime = undefined
}
const res = await listApproval(queryParams.value)
approvalList.value = res.rows
total.value = res.total
} finally {
loading.value = false
}
}
const handleQuery = () => {
quickSearchKeyword.value = ''
queryParams.value.pageNum = 1
getList()
}
const resetQuery = () => {
dateRange.value = undefined
quickSearchKeyword.value = ''
queryFormRef.value?.resetFields()
handleQuery()
}
const handleQuickSearch = () => {
const keyword = quickSearchKeyword.value?.trim()
if (keyword) {
queryParams.value.contractNo = keyword
queryParams.value.contractName = keyword
queryParams.value.approverName = keyword
}
queryParams.value.pageNum = 1
getList()
}
const handleQuickSearchClear = () => {
quickSearchKeyword.value = ''
queryParams.value.contractNo = undefined
queryParams.value.contractName = undefined
queryParams.value.approverName = undefined
queryParams.value.pageNum = 1
getList()
}
const handleSelectionChange = (rows: ApprovalVO[]) => {
selectedRows.value = rows
}
const handleView = async (row: ApprovalVO) => {
const res = await getApproval(row.id)
viewDialog.data = res.data
viewDialog.visible = true
}
const handleViewContract = (row: ApprovalVO) => {
router.push({
path: '/crm/contract/detail',
query: { id: row.contractId }
})
}
const DEFAULT_OPINIONS = ['审批通过', '审批驳回'] as const
const defaultOpinion = (result: string) => (result === 'rejected' ? '审批驳回' : '审批通过')
watch(
() => approvalForm.value.approvalResult,
(result) => {
if (
!approvalForm.value.approvalOpinion.trim() ||
DEFAULT_OPINIONS.includes(
approvalForm.value.approvalOpinion as (typeof DEFAULT_OPINIONS)[number]
)
) {
approvalForm.value.approvalOpinion = defaultOpinion(result)
}
}
)
const handleApprove = (row: ApprovalVO) => {
approvalForm.value = {
id: row.id,
approvalResult: 'approved',
approvalOpinion: defaultOpinion('approved')
}
approvalDialog.visible = true
}
const submitApproval = () => {
approvalFormRef.value?.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
if (approvalForm.value.approvalResult === 'approved') {
await approvePass({
id: approvalForm.value.id!,
approvalOpinion: approvalForm.value.approvalOpinion
})
$modal.msgSuccess('审批通过')
} else {
await approveReject({
id: approvalForm.value.id!,
approvalOpinion: approvalForm.value.approvalOpinion
})
$modal.msgSuccess('已驳回')
}
approvalDialog.visible = false
await getList()
await refreshViewDialogIfOpen()
} catch (error) {
console.error(error)
} finally {
submitLoading.value = false
}
})
}
async function refreshViewDialogIfOpen() {
if (!viewDialog.visible || !viewDialog.data?.id) return
const res = await getApproval(viewDialog.data.id)
viewDialog.data = res.data
}
const handleExport = async () => {
if (dateRange.value && dateRange.value.length === 2) {
queryParams.value.beginTime = dateRange.value[0]
queryParams.value.endTime = dateRange.value[1]
} else {
queryParams.value.beginTime = undefined
queryParams.value.endTime = undefined
}
exportLoading.value = true
try {
const res = await exportApproval(queryParams.value)
const blob =
res instanceof Blob
? res
: new Blob([res as BlobPart], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `contract_approval_${new Date().getTime()}.xlsx`
link.click()
URL.revokeObjectURL(url)
} catch (error) {
console.error(error)
$modal.msgError('导出失败')
} finally {
exportLoading.value = false
}
}
onMounted(() => {
getList()
})
watchDebounced(
() => {
const snapshot: Record<string, unknown> = {
approvalResult: queryParams.value.approvalResult,
dateRange: dateRange.value
}
if (!quickSearchKeyword.value.trim()) {
snapshot.contractNo = queryParams.value.contractNo
snapshot.contractName = queryParams.value.contractName
snapshot.approverName = queryParams.value.approverName
}
return snapshot
},
() => {
quickSearchKeyword.value = ''
queryParams.value.pageNum = 1
getList()
},
{ debounce: 300, deep: true }
)
</script>
@@ -1,600 +1,7 @@
<!-- 合同审批记录 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">合同审批记录</h2>
</div>
<div class="filter-block">
<el-form ref="queryFormRef" :model="queryParams" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="合同编号" prop="contractNo">
<el-input
v-model="queryParams.contractNo"
placeholder="合同编号"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="合同名称" prop="contractName">
<el-input
v-model="queryParams.contractName"
placeholder="合同名称"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="审批人" prop="approverName">
<el-input
v-model="queryParams.approverName"
placeholder="审批人"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="审批结果" prop="approvalResult">
<el-select v-model="queryParams.approvalResult" placeholder="全部" clearable>
<el-option label="通过" value="approved" />
<el-option label="驳回" value="rejected" />
<el-option label="待审批" value="pending" />
</el-select>
</el-form-item>
<el-form-item label="审批时间" class="form-col-span-2">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">按合同、审批人、结果及时间筛选。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="resetQuery">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleQuery">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="合同编号/合同名称/审批人"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button
class="btn-ghost"
:loading="exportLoading"
@click="handleExport"
>
<el-icon><Download /></el-icon>导出
</el-button>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="getList"
@reset-columns="resetToDefault"
/>
</div>
</div>
<div class="list-body list-wrap">
<ListTable table-key="crm-contract-approval"
class="list-table"
v-loading="loading"
:data="approvalList"
stripe
row-key="id"
@row-dblclick="handleView"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (queryParams.pageNum - 1) * queryParams.pageSize + index + 1"
/>
<el-table-column
prop="contractNo"
label="合同编号"
min-width="150"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-link type="primary" @click="handleViewContract(row)">{{
row.contractNo
}}</el-link>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('contractName')"
prop="contractName"
label="合同名称"
min-width="240"
align="left"
header-align="left"
class-name="col-name-wrap"
label-class-name="col-name-wrap"
/>
<el-table-column label="审批级别" v-if="isColumnVisible('step')" min-width="100" align="center" header-align="center">
<template #default="{ row }">第{{ row.step }}级</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('approverName')"
prop="approverName"
label="审批人"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column label="审批结果" v-if="isColumnVisible('approvalResult')" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag v-if="row.approvalResult === 'approved'" type="success" size="small"
>通过</el-tag
>
<el-tag v-else-if="row.approvalResult === 'rejected'" type="danger" size="small"
>驳回</el-tag
>
<el-tag v-else type="warning" size="small">待审批</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('approvalOpinion')"
prop="approvalOpinion"
label="审批意见"
min-width="200"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="审批时间" v-if="isColumnVisible('approvalTime')" min-width="160" align="center" header-align="center">
<template #default="{ row }">{{ parseTime(row.approvalTime) }}</template>
</el-table-column>
</ListTable>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 30, 50]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="getList"
@current-change="getList"
/>
</div>
</div>
<SystemViewDialog
v-model:visible="viewDialog.visible"
title="审批详情"
:icon="Document"
:code="viewDialog.data.contractNo"
code-label="合同编号"
:meta="viewDialog.data.contractName"
:sections="approvalViewSections"
append-to-body
>
<template #hero-actions>
<div class="approval-view-hero-actions">
<el-button
v-if="viewDialog.data.approvalResult === 'pending'"
class="btn-main"
type="primary"
@click="handleApprove(viewDialog.data)"
>
<el-icon><EditPen /></el-icon>审批
</el-button>
</div>
</template>
</SystemViewDialog>
<el-dialog
v-model="approvalDialog.visible"
title="审批"
width="600px"
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
align-center
destroy-on-close
:close-on-click-modal="false"
append-to-body
>
<el-form
ref="approvalFormRef"
:model="approvalForm"
:rules="approvalRules"
label-position="right"
label-width="100px"
class="crm-dialog-form"
scroll-to-error
>
<div class="form-grid-2">
<el-form-item label="审批结果" prop="approvalResult" class="form-col-span-2">
<el-radio-group v-model="approvalForm.approvalResult">
<el-radio value="approved">通过</el-radio>
<el-radio value="rejected">驳回</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="审批意见" prop="approvalOpinion" class="form-col-span-2">
<el-input
v-model="approvalForm.approvalOpinion"
type="textarea"
:rows="5"
:placeholder="
approvalForm.approvalResult === 'rejected'
? '请输入驳回原因'
: '请输入审批意见(可选)'
"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="approvalDialog.visible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="submitLoading"
@click="submitApproval"
>
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
</section>
</div>
<ContractListPage mode="approve" />
</template>
<script setup lang="ts" name="ContractApproval">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { useRouter } from 'vue-router'
import {
Document,
Download,
Search,
RefreshLeft,
Close,
Check,
EditPen
} from '@element-plus/icons-vue'
import SystemViewDialog, {
type ViewField,
type ViewSection
} from '@/components/business/system-view-dialog/index.vue'
import { crmField, crmLongField, crmTagField } from '@/utils/crm/view-fields'
import {
listApproval,
getApproval,
approvePass,
approveReject,
exportApproval,
type ApprovalQuery,
type ApprovalVO
} from '@/api/crm/contract/approval'
import { parseTime } from '@/utils/format'
import { $modal } from '@/utils/modal'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
const router = useRouter()
const quickSearchKeyword = ref('')
const listColumnDefs = [
{ prop: 'contractName', label: '合同名称' },
{ prop: 'step', label: '审批级别' },
{ prop: 'approverName', label: '审批人' },
{ prop: 'approvalResult', label: '审批结果' },
{ prop: 'approvalOpinion', label: '审批意见' },
{ prop: 'approvalTime', label: '审批时间' }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('crm_contract_approval_visible_columns', listColumnDefs)
const queryParams = ref<ApprovalQuery>({
pageNum: 1,
pageSize: 20,
contractNo: undefined,
contractName: undefined,
approverName: undefined,
approvalResult: undefined,
beginTime: undefined,
endTime: undefined
})
const approvalForm = ref({
id: undefined as number | undefined,
approvalResult: 'approved',
approvalOpinion: ''
})
const approvalRules = ref({
approvalResult: [{ required: true, message: '请选择审批结果', trigger: 'change' }],
approvalOpinion: [
{
validator: (_rule: unknown, value: string, callback: (err?: Error) => void) => {
if (approvalForm.value.approvalResult === 'rejected' && !value) {
callback(new Error('驳回时必须填写驳回原因'))
} else {
callback()
}
},
trigger: 'blur'
}
]
})
const approvalList = ref<ApprovalVO[]>([])
const loading = ref(true)
const selectedRows = ref<ApprovalVO[]>([])
const submitLoading = ref(false)
const exportLoading = ref(false)
const total = ref(0)
const dateRange = ref<[string, string]>()
const viewDialog = reactive({
visible: false,
data: {} as ApprovalVO
})
function approvalResultTag(result?: string) {
if (result === 'approved') return { label: '通过', type: 'success' as const }
if (result === 'rejected') return { label: '驳回', type: 'danger' as const }
return { label: '待审批', type: 'warning' as const }
}
const approvalViewSections = computed<ViewSection[]>(() => {
const d = viewDialog.data
if (!d?.id && !d?.contractNo) return []
const result = approvalResultTag(d.approvalResult)
const basicFields: ViewField[] = [
crmField('合同名称', d.contractName),
crmField('审批流程 ID', d.flowId),
crmField('审批级别', d.step != null ? `第${d.step}级` : undefined),
crmField('步骤名称', d.stepName),
crmField('审批人部门', d.approverDept),
crmField('审批人', d.approverName),
crmTagField('审批结果', result.label, result.type)
]
const detailFields: ViewField[] = [
crmLongField('审批意见', d.approvalOpinion, { fullSpan: true }),
crmField('审批时间', parseTime(d.approvalTime)),
crmField('创建时间', parseTime(d.createTime)),
crmLongField('备注', d.remark, { fullSpan: true })
]
return [
{ title: '基本信息', fields: basicFields },
{ title: '审批详情', fields: detailFields }
]
})
const approvalDialog = reactive({
visible: false
})
const queryFormRef = ref<ElFormInstance>()
const approvalFormRef = ref<ElFormInstance>()
const getList = async () => {
loading.value = true
try {
if (dateRange.value && dateRange.value.length === 2) {
queryParams.value.beginTime = dateRange.value[0]
queryParams.value.endTime = dateRange.value[1]
} else {
queryParams.value.beginTime = undefined
queryParams.value.endTime = undefined
}
const res = await listApproval(queryParams.value)
approvalList.value = res.rows
total.value = res.total
} finally {
loading.value = false
}
}
const handleQuery = () => {
quickSearchKeyword.value = ''
queryParams.value.pageNum = 1
getList()
}
const resetQuery = () => {
dateRange.value = undefined
quickSearchKeyword.value = ''
queryFormRef.value?.resetFields()
handleQuery()
}
const handleQuickSearch = () => {
const keyword = quickSearchKeyword.value?.trim()
if (keyword) {
queryParams.value.contractNo = keyword
queryParams.value.contractName = keyword
queryParams.value.approverName = keyword
}
queryParams.value.pageNum = 1
getList()
}
const handleQuickSearchClear = () => {
quickSearchKeyword.value = ''
queryParams.value.contractNo = undefined
queryParams.value.contractName = undefined
queryParams.value.approverName = undefined
queryParams.value.pageNum = 1
getList()
}
const handleSelectionChange = (rows: ApprovalVO[]) => {
selectedRows.value = rows
}
const handleView = async (row: ApprovalVO) => {
const res = await getApproval(row.id)
viewDialog.data = res.data
viewDialog.visible = true
}
const handleViewContract = (row: ApprovalVO) => {
router.push({
path: '/crm/contract/detail',
query: { id: row.contractId }
})
}
const DEFAULT_OPINIONS = ['审批通过', '审批驳回'] as const
const defaultOpinion = (result: string) => (result === 'rejected' ? '审批驳回' : '审批通过')
watch(
() => approvalForm.value.approvalResult,
(result) => {
if (
!approvalForm.value.approvalOpinion.trim() ||
DEFAULT_OPINIONS.includes(
approvalForm.value.approvalOpinion as (typeof DEFAULT_OPINIONS)[number]
)
) {
approvalForm.value.approvalOpinion = defaultOpinion(result)
}
}
)
const handleApprove = (row: ApprovalVO) => {
approvalForm.value = {
id: row.id,
approvalResult: 'approved',
approvalOpinion: defaultOpinion('approved')
}
approvalDialog.visible = true
}
const submitApproval = () => {
approvalFormRef.value?.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
if (approvalForm.value.approvalResult === 'approved') {
await approvePass({
id: approvalForm.value.id!,
approvalOpinion: approvalForm.value.approvalOpinion
})
$modal.msgSuccess('审批通过')
} else {
await approveReject({
id: approvalForm.value.id!,
approvalOpinion: approvalForm.value.approvalOpinion
})
$modal.msgSuccess('已驳回')
}
approvalDialog.visible = false
await getList()
await refreshViewDialogIfOpen()
} catch (error) {
console.error(error)
} finally {
submitLoading.value = false
}
})
}
async function refreshViewDialogIfOpen() {
if (!viewDialog.visible || !viewDialog.data?.id) return
const res = await getApproval(viewDialog.data.id)
viewDialog.data = res.data
}
const handleExport = async () => {
if (dateRange.value && dateRange.value.length === 2) {
queryParams.value.beginTime = dateRange.value[0]
queryParams.value.endTime = dateRange.value[1]
} else {
queryParams.value.beginTime = undefined
queryParams.value.endTime = undefined
}
exportLoading.value = true
try {
const res = await exportApproval(queryParams.value)
const blob =
res instanceof Blob
? res
: new Blob([res as BlobPart], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `contract_approval_${new Date().getTime()}.xlsx`
link.click()
URL.revokeObjectURL(url)
} catch (error) {
console.error(error)
$modal.msgError('导出失败')
} finally {
exportLoading.value = false
}
}
onMounted(() => {
getList()
})
watchDebounced(
() => {
const snapshot: Record<string, unknown> = {
approvalResult: queryParams.value.approvalResult,
dateRange: dateRange.value
}
if (!quickSearchKeyword.value.trim()) {
snapshot.contractNo = queryParams.value.contractNo
snapshot.contractName = queryParams.value.contractName
snapshot.approverName = queryParams.value.approverName
}
return snapshot
},
() => {
quickSearchKeyword.value = ''
queryParams.value.pageNum = 1
getList()
},
{ debounce: 300, deep: true }
)
<script setup lang="ts" name="CrmContractApproval">
import ContractListPage from '../ContractListPage.vue'
</script>
@@ -1,9 +1,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">合同审批人设置</h2>
</div>
<div class="panel-body contract-approver-setting-body">
<el-form ref="formRef" :model="form" label-position="top" class="crm-dialog-form">
<div class="form-grid-2">
@@ -2,10 +2,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">合同变更</h2>
</div>
<div class="filter-block">
<el-form ref="queryFormRef" :model="queryParams" class="filter-form" @submit.prevent>
<div class="filter-grid">
@@ -121,8 +121,10 @@ import {
downloadCrmAttachment,
isCrmImageFile,
isCrmPdfFile,
openCrmAttachmentPreviewInNewTab,
resolveCrmAttachmentUrl
} from '@/utils/crm-attachment'
import { resolveAttachmentPreviewUrl } from '@/utils/download-attachment'
import { buildAttachmentUploadFormData, type CrmUploadBizType } from '@/utils/crm-upload'
const props = withDefaults(
@@ -263,13 +265,30 @@ const handleAttachmentUpload = async (options: UploadRequestOptions) => {
}
}
const handleFilePreview = (file: ContractAttachmentItem) => {
const handleFilePreview = async (file: ContractAttachmentItem) => {
if (!canPreviewAttachment(file)) {
$modal.msgWarning('文件尚未上传完成')
return
}
// 本地未上传完的图片仍用弹窗;已落库附件走新标签页(与列表/详情一致)
if (file.localPreview && !getAttachmentOssId(file)) {
attachmentPreviewFile.value = file
attachmentPreviewSrc.value = file.localPreview
attachmentPreviewVisible.value = true
return
}
if (getAttachmentOssId(file) && (isCrmImageFile(file) || isCrmPdfFile(file))) {
await openCrmAttachmentPreviewInNewTab(file)
return
}
attachmentPreviewFile.value = file
attachmentPreviewSrc.value = resolveAttachmentPreviewSrc(file)
const ossId = getAttachmentOssId(file)
attachmentPreviewSrc.value = ossId
? await resolveAttachmentPreviewUrl(
{ fileUrl: ossId, fileName: file.name },
{ fullFileUrl: resolveCrmAttachmentUrl }
)
: resolveAttachmentPreviewSrc(file)
attachmentPreviewVisible.value = true
}
@@ -31,45 +31,18 @@
</div>
<div class="detail-actions">
<el-button
v-if="isContractEditableStatus(detailContract?.status)"
v-if="manageActions && isContractEditableStatus(detailContract?.status)"
class="btn-ghost"
@click="handleDetailEdit"
>
<el-icon><Edit /></el-icon>修改
</el-button>
<el-button
v-if="
detailContract?.status === 'draft' &&
canDeleteByCreator(detailContract)
"
class="btn-ghost"
@click="handleDetailDelete"
:disabled="detailContract?.contractTemplate === 'customer'"
@click="handleDetailPrint"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button
v-if="detailContract?.status === 'draft'"
class="btn-ghost"
@click="handleDetailSubmit"
>
<el-icon><Promotion /></el-icon>提交审批
</el-button>
<el-button
v-if="
detailContract?.status === 'approved' &&
detailContract?.approvalStatus === 'approved'
"
class="btn-ghost"
@click="handleDetailExecute"
>
<el-icon><VideoPlay /></el-icon>开始执行
</el-button>
<el-button
v-if="detailContract?.status === 'executing'"
class="btn-ghost"
@click="handleDetailComplete"
>
<el-icon><CircleCheck /></el-icon>完成合同
<el-icon><Printer /></el-icon>打印
</el-button>
<el-button
v-if="
@@ -81,11 +54,40 @@
<el-icon><CircleClose /></el-icon>作废
</el-button>
<el-button
v-if="
manageActions &&
detailContract?.status === 'approved' &&
detailContract?.approvalStatus === 'approved'
"
class="btn-ghost"
:disabled="detailContract?.contractTemplate === 'customer'"
@click="handleDetailPrint"
@click="handleDetailExecute"
>
<el-icon><Printer /></el-icon>打印
<el-icon><VideoPlay /></el-icon>开始执行
</el-button>
<el-button
v-if="
manageActions &&
detailContract?.status === 'draft' &&
canDeleteByCreator(detailContract)
"
class="btn-ghost"
@click="handleDetailDelete"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button
v-if="manageActions && detailContract?.status === 'draft'"
class="btn-ghost"
@click="handleDetailSubmit"
>
<el-icon><Promotion /></el-icon>提交审批
</el-button>
<el-button
v-if="manageActions && detailContract?.status === 'executing'"
class="btn-ghost"
@click="handleDetailComplete"
>
<el-icon><CircleCheck /></el-icon>完成合同
</el-button>
</div>
</div>
@@ -98,7 +100,7 @@
<h3>基本信息</h3>
<ViewKvTable
:fields="detailBasicViewFields"
mode="triple"
mode="quad"
@field-view="handleBasicFieldView"
/>
<p v-if="detailContract.contractTemplate === 'customer'" class="contract-template-hint">
@@ -183,12 +185,12 @@
<span>{{ row.standardProductName || row.productName || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="别名" min-width="120" :show-overflow-tooltip="true">
<el-table-column label="别名" min-width="80" :show-overflow-tooltip="true">
<template #default="{ row }">
<span>{{ row.aliasName || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="品牌" min-width="100" :show-overflow-tooltip="true">
<el-table-column label="品牌" min-width="72" :show-overflow-tooltip="true">
<template #default="{ row }">
<span>{{ formatBrandConstraintLabel(row.brandMode, row.brandName) }}</span>
</template>
@@ -199,17 +201,17 @@
</template>
</el-table-column>
<el-table-column label="产品分类" prop="categoryName" min-width="110" :show-overflow-tooltip="true" />
<el-table-column label="税率(%)" prop="taxRate" width="110" align="center">
<el-table-column label="税率(%)" prop="taxRate" min-width="72" align="center">
<template #default="scope">
<span>{{ scope.row.taxRate || 0 }}%</span>
</template>
</el-table-column>
<el-table-column label="单位" prop="unit" width="80" align="center">
<el-table-column label="单位" prop="unit" min-width="56" align="center">
<template #default="scope">
<DictTag dict-type="crm_product_unit" :value="scope.row.unit" />
</template>
</el-table-column>
<el-table-column label="数量" prop="quantity" width="110" align="right" />
<el-table-column label="数量" prop="quantity" min-width="72" align="right" />
<el-table-column
v-if="!hideProductPrice"
label="含税单价(元)"
@@ -313,23 +315,11 @@
</div>
</template>
<!-- 合计金额 -->
<!-- 合计金额:合计总金额 = 合计金额 + 运费 + 桶押金 -->
<template v-if="!hideProductPrice">
<el-divider />
<div class="total-section">
<el-row :gutter="20">
<el-col :span="16"></el-col>
<el-col :span="8">
<div class="total-item">
<span class="total-label">合计金额(元):</span>
<span class="total-value money-display"
>¥{{
parseFloat(detailContract.amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}}</span
>
</div>
</el-col>
</el-row>
<ViewKvTable :fields="detailTotalSummaryFields" mode="quad" />
</div>
<!-- 金额统计 -->
@@ -748,11 +738,15 @@
</template>
<div v-if="detailReadonlyAttachmentList.length > 0" class="detail-attachment-list">
<div v-for="(file, index) in detailReadonlyAttachmentList" :key="`${file.ossId}-${index}`" class="detail-attachment-item">
<div class="detail-attachment-preview">
<div
class="detail-attachment-preview"
:title="isDetailImageFile(file) || isDetailPdfFile(file) ? '点击在新标签页查看' : '点击查看'"
@click.stop="handleDetailPreviewFile(file)"
>
<ImagePreview
v-if="isDetailImageFile(file)"
:src="file.ossId"
class="detail-attachment-thumb-img"
class="detail-attachment-thumb-img detail-attachment-thumb-img--passive"
:isOssId="true"
/>
<div v-else class="detail-attachment-preview__icon detail-attachment-preview__icon--doc">
@@ -764,6 +758,10 @@
<span class="detail-attachment-name" :title="getDetailDisplayFileName(file)">{{ getDetailShortFileName(getDetailDisplayFileName(file)) }}</span>
<div class="detail-attachment-category">{{ getDetailAttachmentCategoryLabel(file.category) }}</div>
<div class="detail-attachment-actions">
<el-button type="primary" size="small" @click.stop="handleDetailPreviewFile(file)">
<el-icon><View /></el-icon>
查看
</el-button>
<el-button type="success" size="small" @click.stop="handleDetailDownloadFile(file)">
<el-icon><Download /></el-icon>
下载
@@ -916,6 +914,8 @@ import { fitRichTextTablesToMaxWidth } from '@/utils/tinymce-table-excel-resize'
import {
downloadCrmAttachment,
getCrmAttachmentCategoryLabel,
isCrmPdfFile,
openCrmAttachmentPreviewInNewTab,
resolveCrmAttachmentFiles
} from '@/utils/crm-attachment';
@@ -925,9 +925,12 @@ const router = useRouter();
const props = withDefaults(
defineProps<{
hideProductPrice?: boolean
/** 是否展示制单/履约类操作(修改、删除、提交审批、执行等) */
manageActions?: boolean
}>(),
{
hideProductPrice: false
hideProductPrice: false,
manageActions: true
}
)
@@ -1332,8 +1335,6 @@ const handleDetailPrint = async () => {
console.warn('⚠️ 获取最新合同数据失败,使用详情抽屉中的数据:', fetchError);
}
const quoteFreight = await fetchQuoteFreight(contractData.quoteId);
// 先尝试从数据库获取已保存的模板
let htmlContent = '';
try {
@@ -1358,7 +1359,7 @@ const handleDetailPrint = async () => {
// 统一用数据库最新数据替换占位符(无论模板来自数据库还是新生成的,都可能含占位符)
try {
htmlContent = replacePlaceholders(htmlContent, contractData, quoteFreight);
htmlContent = replacePlaceholders(htmlContent, contractData);
console.log('占位符替换完成');
} catch (replaceError) {
console.error('替换占位符失败,使用原始内容:', replaceError);
@@ -1427,6 +1428,22 @@ const formatDetailAmount = (amount: number | undefined): string => {
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
/** 产品明细下方合计:合计总金额 = 合计金额(产品)+ 运费 + 桶押金 */
const detailTotalSummaryFields = computed<ViewField[]>(() => {
const c = detailContract.value
if (!c) return []
const productAmount = Number(c.amount) || 0
const freight = Number(c.freight) || 0
const barrelDeposit = Number(c.barrelDeposit) || 0
const grandTotal = productAmount + freight + barrelDeposit
return [
{ label: '合计总金额(元)', value: `¥${formatDetailAmount(grandTotal)}`, money: true },
{ label: '合计金额(元)', value: `¥${formatDetailAmount(productAmount)}`, money: true },
{ label: '运费(元)', value: `¥${formatDetailAmount(freight)}`, money: true },
{ label: '桶押金(元)', value: `¥${formatDetailAmount(barrelDeposit)}`, money: true }
]
})
function detailTagField(
label: string,
dictType: string,
@@ -1475,7 +1492,11 @@ const detailBasicViewFields = computed<ViewField[]>(() => {
{ label: '负责人', value: c.ownerUserName },
...(props.hideProductPrice
? []
: [{ label: '合同金额(元)', value: `¥${formatDetailAmount(c.amount)}`, money: true }]),
: [
{ label: '合同金额(元)', value: `¥${formatDetailAmount(c.amount)}`, money: true },
{ label: '运费(元)', value: `¥${formatDetailAmount(c.freight)}`, money: true },
{ label: '桶押金(元)', value: `¥${formatDetailAmount(c.barrelDeposit)}`, money: true }
]),
{ label: '交易主体', value: c.supplierName },
{
label: '结算方式',
@@ -1591,6 +1612,13 @@ const isDetailImageFile = (file: any) => {
return imageExts.includes(ext);
};
const isDetailPdfFile = (file: any) =>
isCrmPdfFile({
name: getDetailDisplayFileName(file),
ossId: file?.ossId,
url: file?.url
});
/** 详情抽屉 - 获取文件扩展名 */
const getDetailFileExt = (fileName: string) => {
if (!fileName) return 'FILE';
@@ -1650,6 +1678,14 @@ const handleDetailDownloadTierLetter = (ossId: string | number | undefined) => {
handleDetailDownloadFile({ ossId: String(ossId), name: '调价函' });
};
/** 详情抽屉 - 新标签页预览 */
const handleDetailPreviewFile = async (file: any) => {
await openCrmAttachmentPreviewInNewTab({
...file,
name: getDetailDisplayFileName(file)
});
};
/** 详情抽屉 - 下载文件 */
const handleDetailDownloadFile = async (file: any) => {
if (!file.ossId && !file.url) {
@@ -1666,25 +1702,12 @@ const handleDetailDownloadFile = async (file: any) => {
}
};
/** 获取合同关联报价单的运费(无关联或获取失败时返回 0) */
const fetchQuoteFreight = async (quoteId?: number): Promise<number> => {
if (!quoteId) return 0;
try {
const quoteRes = await getQuote(quoteId);
const freight = Number(quoteRes?.data?.freight);
return Number.isFinite(freight) && freight > 0 ? freight : 0;
} catch (error) {
console.warn('获取报价单运费失败:', error);
return 0;
}
};
/** 产品明细中是否含「自提」送货方式(字典 crm_shfs) */
const hasSelfPickupProduct = (products?: Array<{ deliveryMethod?: string }> | null): boolean =>
!!products?.some((p) => String(p.deliveryMethod || '').trim() === '自提');
/** 替换模板中的占位符为数据库最新数据 */
const replacePlaceholders = (htmlContent: string, contractData: ContractVO | undefined, quoteFreight = 0): string => {
/** 替换模板中的占位符为数据库最新数据(运费取合同自身 freight) */
const replacePlaceholders = (htmlContent: string, contractData: ContractVO | undefined): string => {
if (!htmlContent || !contractData) return htmlContent;
let result = htmlContent;
@@ -1936,21 +1959,31 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
}
// 替换产品合计行(左侧运费,右侧合计=产品金额+运费)
const freightAmount = Number(quoteFreight) || 0;
const totalWithFreight = productTotal + freightAmount;
const totalWithFreightChinese = numberToChinese(totalWithFreight);
// 合计区(11 列):第 1 行左右对半运费/桶押金;第 2 行合计;第 3 行提示
const freightRaw = Number(contractData.freight);
const freightAmount = Number.isFinite(freightRaw) && freightRaw > 0 ? freightRaw : 0;
const depositRaw = Number(contractData.barrelDeposit);
const barrelDepositAmount = Number.isFinite(depositRaw) && depositRaw > 0 ? depositRaw : 0;
const totalWithExtras = productTotal + freightAmount + barrelDepositAmount;
const totalWithExtrasChinese = numberToChinese(totalWithExtras);
const freightText = freightAmount > 0 ? `¥${formatNumber(freightAmount)}` : '-';
const totalWithFreightText = isFrameworkContract ? '随行就市' : `${totalWithFreightChinese}(¥${formatNumber(totalWithFreight)})`;
const barrelDepositText = barrelDepositAmount > 0 ? `¥${formatNumber(barrelDepositAmount)}` : '-';
const totalWithExtrasText = isFrameworkContract
? '随行就市'
: `${totalWithExtrasChinese}(¥${formatNumber(totalWithExtras)})`;
const tipRowText = hasSelfPickupProduct(contractData.products)
? '含税、客户自提结算以实际到货吨位为准'
: '含税、含运费、装卸费(一票制)&nbsp;&nbsp;&nbsp;&nbsp;结算以实际到货吨位为准';
const productTotalRow = `
<tr style="background-color: #fff; font-weight: bold;">
<td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">运费</td>
<td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${freightText}</td>
<td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">运费(元)</td>
<td colspan="4" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${freightText}</td>
<td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">桶押金(元)</td>
<td colspan="3" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${barrelDepositText}</td>
</tr>
<tr style="background-color: #fff; font-weight: bold;">
<td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">合计(人民币)</td>
<td colspan="5" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${totalWithFreightText}</td>
<td colspan="9" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${totalWithExtrasText}</td>
</tr>
<tr>
<td colspan="11" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 11px; background-color: #fff;">
@@ -2460,8 +2493,10 @@ const handleSaveTemplate = async () => {
const productTableMatch = templateWithPlaceholders.match(/<tbody>[\s\S]*?<\/tbody>/);
const productTableHtml = productTableMatch ? productTableMatch[0] : '';
// 2. 提取合计行
const totalRowMatch = templateWithPlaceholders.match(/<tr style="background-color: #fff; font-weight: bold;">[\s\S]*?<\/tr>/);
// 2. 提取运费/桶押金行 + 合计行(连续两行加粗)
const totalRowMatch = templateWithPlaceholders.match(
/(?:<tr style="background-color: #fff; font-weight: bold;">[\s\S]*?<\/tr>\s*){2}/
);
const totalRowHtml = totalRowMatch ? totalRowMatch[0] : '';
// 3. 提取提示行(兼容一票制 / 客户自提文案,以及 colspan 10/11)
@@ -2479,9 +2514,10 @@ const handleSaveTemplate = async () => {
);
}
// 替换合计行(如果存在)
// 替换运费/桶押金行 + 合计行(兼容旧版单行合计)
if (totalRowHtml) {
const totalRowPattern = /<tr style="background-color: #fff; font-weight: bold;">[\s\S]*?<\/tr>/;
const totalRowPattern =
/(?:<tr style="background-color: #fff; font-weight: bold;">[\s\S]*?<\/tr>\s*){1,2}/;
if (totalRowPattern.test(finalTemplate)) {
finalTemplate = finalTemplate.replace(totalRowPattern, totalRowHtml);
}
@@ -2857,8 +2893,7 @@ const handleGeneratePdfFromDialog = async () => {
if (htmlContent.includes('{{') || htmlContent.includes('SETTLEMENT_METHOD_CONTENT')) {
// 重新获取最新合同数据来替换占位符
const res = await getContractDetail(detailContract.value.id);
const quoteFreight = await fetchQuoteFreight(res.data?.quoteId);
htmlContent = replacePlaceholders(htmlContent, res.data, quoteFreight);
htmlContent = replacePlaceholders(htmlContent, res.data);
}
// 1. 创建临时容器(border-box:794 含 padding)
@@ -3303,23 +3338,7 @@ onMounted(async () => {
}
.total-section {
padding: 20px 0;
.total-item {
display: flex;
justify-content: flex-end;
align-items: center;
font-size: 16px;
.total-label {
font-weight: bold;
margin-right: 10px;
}
.total-value {
font-size: 24px;
}
}
padding: 12px 0;
}
.amount-cards {
@@ -3834,12 +3853,17 @@ onMounted(async () => {
justify-content: center;
background: #f8fafc;
overflow: hidden;
cursor: pointer;
}
.detail-attachment-thumb-img {
width: 100%;
height: 100%;
display: block;
&--passive {
pointer-events: none;
}
}
.detail-attachment-preview__icon {
@@ -27,7 +27,7 @@
>
<!-- 合同基础信息(一行 4 列) -->
<div class="contract-basic-info">
<el-form-item label="合同编号" prop="contractNo">
<el-form-item v-if="isEditMode" label="合同编号" prop="contractNo">
<el-input v-model="form.contractNo" placeholder="系统自动生成,格式:SX/X-JT20250926002" maxlength="50" />
</el-form-item>
<el-form-item label="合同名称" prop="contractName">
@@ -49,13 +49,35 @@
</el-select>
</el-form-item>
<el-form-item label="客户名称" prop="customerId" required>
<el-input v-model="form.customerName" placeholder="请选择客户" readonly>
<template #append>
<el-button text @click="handleSelectCustomer">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
<div class="customer-select-row">
<el-select
v-model="form.customerId"
class="customer-select-row__select"
filterable
remote
clearable
:remote-method="searchCustomerOptions"
:loading="customerOptionsLoading"
placeholder="请输入客户名称/拼音首字母搜索"
@focus="handleCustomerSelectFocus"
@change="handleCustomerSelectChange"
>
<el-option
v-for="item in customerOptions"
:key="item.id"
:label="item.customerName"
:value="item.id"
>
<div class="customer-option-row">
<span>{{ item.customerName }}</span>
<span v-if="item.customerNo" class="customer-option-row__meta">{{ item.customerNo }}</span>
</div>
</el-option>
</el-select>
<el-button text class="customer-select-row__search" @click="handleSelectCustomer">
<el-icon><Search /></el-icon>
</el-button>
</div>
</el-form-item>
<el-form-item label="关联报价单" prop="quoteId">
<el-input v-model="form.quoteNo" placeholder="请选择报价单(可选)" readonly clearable>
@@ -67,13 +89,42 @@
</el-input>
</el-form-item>
<el-form-item label="联系人" prop="contactId" required>
<el-input v-model="form.contactsName" placeholder="请选择联系人" maxlength="50">
<template #append>
<el-button text @click="handleSelectContact" :disabled="!form.customerId">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
<div class="customer-select-row">
<el-select
v-model="form.contactId"
class="customer-select-row__select"
filterable
clearable
:disabled="!form.customerId"
:loading="contactOptionsLoading"
:placeholder="form.customerId ? '请选择联系人' : '请先选择客户'"
@visible-change="handleContactSelectVisible"
@change="handleContactSelectChange"
>
<el-option
v-for="item in contactOptions"
:key="item.id"
:label="item.contactName"
:value="item.id"
>
<div class="customer-option-row">
<span>
{{ item.contactName }}
<el-tag v-if="item.isPrimary === 1" type="success" size="small" style="margin-left: 6px">主要</el-tag>
</span>
<span v-if="item.mobile" class="customer-option-row__meta">{{ item.mobile }}</span>
</div>
</el-option>
</el-select>
<el-button
text
class="customer-select-row__search"
:disabled="!form.customerId"
@click="handleSelectContact"
>
<el-icon><Search /></el-icon>
</el-button>
</div>
</el-form-item>
<el-form-item label="联系电话" prop="contactsPhone">
<el-input v-model="form.contactsPhone" placeholder="从客户/联系人带出" maxlength="20" />
@@ -97,7 +148,12 @@
style="width: 100%"
@change="handleOwnerChange"
>
<el-option v-for="user in userList" :key="user.userId" :label="user.nickName" :value="user.userId" />
<el-option
v-for="user in ownerUserList"
:key="user.userId"
:label="user.nickName"
:value="user.userId"
/>
</el-select>
</el-form-item>
<el-form-item label="合同金额(元)" prop="amount">
@@ -110,6 +166,26 @@
class="money-field-input"
/>
</el-form-item>
<el-form-item label="运费(元)" prop="freight">
<el-input-number
v-model="form.freight"
:precision="2"
:min="0"
placeholder="可选,选择报价单后可自动带入"
style="width: 100%"
class="money-field-input"
/>
</el-form-item>
<el-form-item label="桶押金(元)" prop="barrelDeposit">
<el-input-number
v-model="form.barrelDeposit"
:precision="2"
:min="0"
placeholder="可选,选择报价单后可自动带入"
style="width: 100%"
class="money-field-input"
/>
</el-form-item>
<el-form-item label="合同期限" prop="startDate" required>
<el-date-picker
v-model="contractDateRange"
@@ -127,7 +203,7 @@
<el-option v-for="supplier in supplierList" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" />
</el-select>
</el-form-item>
<el-form-item label="结算方式" prop="settlementMethod">
<el-form-item label="结算方式" prop="settlementMethod" required>
<el-select v-model="form.settlementMethod" placeholder="请选择结算方式" clearable style="width: 100%">
<el-option v-for="dict in dictStore.getOptions('crm_settlement_method')" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
@@ -300,7 +376,7 @@
class="line-meta-cell__btn"
@click="openAliasBrandManage(scope.row, 'alias')"
>
<el-icon><Setting /></el-icon>
<el-icon><Plus /></el-icon>
</el-button>
</div>
</template>
@@ -333,7 +409,7 @@
class="line-meta-cell__btn"
@click="openAliasBrandManage(scope.row, 'brand')"
>
<el-icon><Setting /></el-icon>
<el-icon><Plus /></el-icon>
</el-button>
</div>
</template>
@@ -579,7 +655,55 @@
/>
<!-- 客户选择器 -->
<CustomerSelector :visible="customerSelectorVisible" @update:visible="customerSelectorVisible = $event" @confirm="handleCustomerConfirm" />
<CustomerSelector
:visible="customerSelectorVisible"
@update:visible="customerSelectorVisible = $event"
@confirm="handleCustomerSelectorConfirm"
/>
<!-- 下拉选客户时补填简称 -->
<el-dialog
v-model="shortNameDialogVisible"
title="填写客户简称"
width="480px"
align-center
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
append-to-body
@closed="handleShortNameDialogClosed"
>
<el-form
ref="shortNameFormRef"
:model="shortNameForm"
:rules="shortNameRules"
label-position="top"
class="crm-dialog-form"
>
<el-form-item label="客户名称">
<el-input :model-value="shortNameForm.customerName" disabled />
</el-form-item>
<el-form-item label="客户简称" prop="shortName">
<el-input
v-model="shortNameForm.shortName"
placeholder="请输入客户简称"
maxlength="100"
show-word-limit
clearable
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button class="btn-ghost" @click="shortNameDialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="shortNameSaving" @click="handleSaveShortName">
<el-icon><Check /></el-icon>确定
</el-button>
</div>
</template>
</el-dialog>
<!-- 联系人选择器 -->
<ContactSelector
@@ -589,6 +713,9 @@
@confirm="handleContactConfirm"
/>
<!-- 下拉无联系人时补充 -->
<ContactFormDialog ref="contactFormRef" @success="handleContactFormSuccess" />
<!-- 报价单选择器 -->
<QuoteSelector
:visible="quoteSelectorVisible"
@@ -606,7 +733,7 @@
<script setup lang="ts">
import { ref, reactive, computed, nextTick, watch } from 'vue'
import type { FormInstance, UploadRequestOptions } from 'element-plus'
import type { FormInstance, FormItemRule, UploadRequestOptions } from 'element-plus'
import { ElMessage } from 'element-plus'
import {
getContract,
@@ -627,12 +754,12 @@ import {
Plus,
Search,
Close,
Check,
FolderChecked,
Promotion,
Loading,
ArrowDown,
ArrowUp,
Setting
ArrowUp
} from '@element-plus/icons-vue'
import {
buildUploadFileListFromField,
@@ -644,11 +771,13 @@ import { buildAttachmentUploadFormData, CRM_UPLOAD_BIZ_TYPE } from '@/utils/crm-
import ContractAttachmentPanel from './ContractAttachmentPanel.vue'
import { getOpportunity } from '@/api/crm/opportunity'
import { getQuote } from '@/api/crm/quote'
import ProductSelector from './ProductSelector.vue'
import ProductSelector from '@/components/business/product-selector/index.vue'
import ProductAliasBrandDialog from '@/components/business/product-alias-brand-dialog/index.vue'
import CustomerSelector from '@/components/CustomerSelector.vue'
import ContactSelector from '@/components/ContactSelector.vue'
import ContactFormDialog from '@/views/crm/customer/components/ContactFormDialog.vue'
import QuoteSelector from '@/components/QuoteSelector.vue'
import { fetchContactsByCustomerId } from '@/api/crm/contact'
import { removeProductsNotAllowedByHazard } from '@/utils/crm-supplier-hazard'
import {
applyBrandSelectChange,
@@ -660,11 +789,16 @@ import {
import { fetchProductDetail } from '@/api/crm/product'
import ApprovalDialog from './ApprovalDialog.vue'
import { omitEmptyExceptPartyFields } from '../partyFields'
import { getContactByCustomerId, fetchCustomerDetail } from '@/api/crm/customer'
import {
fetchCustomerList,
fetchCustomerDetail,
fetchUpdateCustomer
} from '@/api/crm/customer'
import type { CustomerVO, ContactVO } from '@/api/crm/customer/types'
import { getDefaultInvoiceInfoByCustomerId } from '@/api/crm/invoiceInfo'
import { listAllSupplierInfo, type SupplierInfoVO } from '@/api/crm/supplierInfo'
import { fetchUploadAttachment, fetchUserOptions } from '@/api/admin'
import { filterOwnerCandidates, findDefaultOwnerUser } from '@/utils/crm-owner-candidates'
interface UserVO {
userId: string | number
@@ -720,6 +854,9 @@ const formTitle = computed(() => {
return form.value.id ? '编辑合同' : '新增合同'
})
/** 编辑模式(有 editId);新增时隐藏合同编号 */
const isEditMode = computed(() => !!props.editId)
// 合同期限日期范围
const contractDateRange = ref<[string, string]>([])
@@ -730,7 +867,7 @@ const form = ref<ExtendedContractForm>({
contractName: '',
contractType: 'sale',
contractTemplate: 'shanxia',
customerId: 0,
customerId: undefined,
customerName: '',
contactId: undefined,
opportunityId: undefined,
@@ -745,6 +882,8 @@ const form = ref<ExtendedContractForm>({
ownerUserId: undefined,
ownerUserName: '',
amount: 0,
freight: 0,
barrelDeposit: 0,
supplierId: undefined,
settlementMethod: undefined,
partyA: '',
@@ -809,6 +948,7 @@ const rules = ref({
signDate: [{ required: true, message: '签订日期不能为空', trigger: 'change' }],
ownerUserId: [{ required: true, message: '负责人不能为空', trigger: 'change' }],
supplierId: [{ required: true, message: '交易主体不能为空', trigger: 'change' }],
settlementMethod: [{ required: true, message: '结算方式不能为空', trigger: 'change' }],
startDate: [
{
validator: (_rule: any, _value: any, callback: any) => {
@@ -858,8 +998,227 @@ const quoteSelectorVisible = ref(false)
const userSelectorVisible = ref(false)
const productSelectorVisible = ref(false)
// 用户列表(负责人下拉)
// 客户下拉(远程模糊检索)
const customerOptions = ref<CustomerVO[]>([])
const customerOptionsLoading = ref(false)
/** 已确认选定的客户(用于简称弹窗取消时回滚下拉值) */
const confirmedCustomerId = ref<string | number | undefined>(undefined)
const shortNameDialogVisible = ref(false)
const shortNameSaving = ref(false)
const shortNameFormRef = ref<FormInstance>()
const shortNameForm = ref({
id: '' as string | number,
customerName: '',
shortName: ''
})
const shortNameRules: Record<string, FormItemRule[]> = {
shortName: [{ required: true, message: '客户简称不能为空', trigger: 'blur' }]
}
const pendingCustomerForShortName = ref<CustomerVO | null>(null)
const shortNameSavedSuccessfully = ref(false)
// 联系人下拉
const contactOptions = ref<ContactVO[]>([])
const contactOptionsLoading = ref(false)
const contactFormRef = ref<InstanceType<typeof ContactFormDialog>>()
/** 避免 visible-change 重复弹出补充联系人弹窗 */
const contactEmptyPrompted = ref(false)
const hasCustomerShortName = (row?: { shortName?: string } | null) =>
Boolean(String(row?.shortName || '').trim())
const ensureCustomerOption = (customer?: Partial<CustomerVO> | null) => {
if (!customer?.id) return
const exists = customerOptions.value.some((item) => String(item.id) === String(customer.id))
if (exists) {
customerOptions.value = customerOptions.value.map((item) =>
String(item.id) === String(customer.id)
? {
...item,
customerName: customer.customerName || item.customerName,
customerNo: customer.customerNo ?? item.customerNo,
shortName: customer.shortName ?? item.shortName
}
: item
)
return
}
customerOptions.value = [
{
id: customer.id,
customerName: customer.customerName || '',
customerNo: customer.customerNo,
shortName: customer.shortName
} as CustomerVO,
...customerOptions.value
]
}
const extractCustomerList = (res: unknown): CustomerVO[] => {
const data = (res as { data?: unknown })?.data ?? res ?? {}
const list = (data as { list?: CustomerVO[]; rows?: CustomerVO[] }).list
|| (data as { rows?: CustomerVO[] }).rows
|| []
return Array.isArray(list) ? list : []
}
const loadDefaultCustomerOptions = async () => {
customerOptionsLoading.value = true
try {
const res = await fetchCustomerList({ page: 1, pageSize: 20 })
customerOptions.value = extractCustomerList(res)
if (form.value.customerId) {
ensureCustomerOption({
id: form.value.customerId,
customerName: form.value.customerName,
shortName: customerShortName.value
})
}
} catch (error) {
console.error('加载客户列表失败:', error)
customerOptions.value = []
} finally {
customerOptionsLoading.value = false
}
}
const searchCustomerOptions = async (query: string) => {
const keyword = query?.trim()
if (!keyword) {
await loadDefaultCustomerOptions()
return
}
customerOptionsLoading.value = true
try {
const res = await fetchCustomerList({
customerName: keyword,
page: 1,
pageSize: 50
})
customerOptions.value = extractCustomerList(res)
if (form.value.customerId) {
ensureCustomerOption({
id: form.value.customerId,
customerName: form.value.customerName,
shortName: customerShortName.value
})
}
} catch (error) {
console.error('搜索客户失败:', error)
customerOptions.value = []
} finally {
customerOptionsLoading.value = false
}
}
const handleCustomerSelectFocus = () => {
if (customerOptions.value.length === 0) {
loadDefaultCustomerOptions()
}
}
const extractContactList = (res: unknown): ContactVO[] => {
const unwrapped = (res as { data?: unknown })?.data ?? res ?? []
if (Array.isArray(unwrapped)) return unwrapped as ContactVO[]
const payload = (unwrapped as { list?: ContactVO[]; rows?: ContactVO[] }) || {}
return payload.list || payload.rows || []
}
const ensureContactOption = (contact?: Partial<ContactVO> | null) => {
if (!contact?.id) return
const exists = contactOptions.value.some((item) => String(item.id) === String(contact.id))
if (exists) {
contactOptions.value = contactOptions.value.map((item) =>
String(item.id) === String(contact.id)
? {
...item,
contactName: contact.contactName || item.contactName,
mobile: contact.mobile ?? item.mobile,
isPrimary: contact.isPrimary ?? item.isPrimary
}
: item
)
return
}
contactOptions.value = [
{
id: contact.id,
contactName: contact.contactName || '',
mobile: contact.mobile,
isPrimary: contact.isPrimary
} as ContactVO,
...contactOptions.value
]
}
const loadContactOptions = async (customerId?: string | number) => {
if (!customerId) {
contactOptions.value = []
return []
}
contactOptionsLoading.value = true
try {
const res = await fetchContactsByCustomerId({ customerId })
contactOptions.value = extractContactList(res)
if (form.value.contactId) {
ensureContactOption({
id: form.value.contactId,
contactName: form.value.contactsName
})
}
return contactOptions.value
} catch (error) {
console.error('加载联系人列表失败:', error)
contactOptions.value = []
return []
} finally {
contactOptionsLoading.value = false
}
}
const openContactFormDialog = () => {
if (!form.value.customerId) {
$modal.msgWarning('请先选择客户')
return
}
contactFormRef.value?.open(undefined, form.value.customerId)
}
const openShortNameDialog = (customer: CustomerVO) => {
pendingCustomerForShortName.value = customer
shortNameSavedSuccessfully.value = false
shortNameForm.value = {
id: customer.id,
customerName: customer.customerName || '',
shortName: customer.shortName || ''
}
shortNameDialogVisible.value = true
nextTick(() => shortNameFormRef.value?.clearValidate())
}
const clearCustomerSelection = () => {
form.value.customerId = undefined
form.value.customerName = ''
customerShortName.value = ''
customerInvoiceName.value = ''
confirmedCustomerId.value = undefined
form.value.contactId = undefined
form.value.contactsName = ''
form.value.contactsPhone = ''
contactOptions.value = []
contactEmptyPrompted.value = false
form.value.opportunityId = undefined
form.value.opportunityName = ''
form.value.quoteId = undefined
form.value.quoteNo = ''
form.value.freight = 0
form.value.barrelDeposit = 0
rebuildContractName()
}
// 用户列表(负责人下拉仅方金强 / 吴九华)
const userList = ref<any[]>([])
const ownerUserList = computed(() => filterOwnerCandidates(userList.value))
// 选中的产品列表
const selectedProducts = ref<ExtendedContractProductForm[]>([])
@@ -900,6 +1259,7 @@ const handleOpened = async () => {
resetForm()
form.value.signDate = new Date().toISOString().split('T')[0]
handleSignDateChange(form.value.signDate)
applyDefaultOwnerUser()
// 设置默认供方主体为"SX"
const defaultSupplier = supplierList.value.find((supplier) => supplier.supplierCode === 'SX')
@@ -927,7 +1287,7 @@ const resetForm = () => {
contractName: '',
contractType: 'sale',
contractTemplate: 'shanxia',
customerId: 0,
customerId: undefined,
customerName: '',
contactId: undefined,
opportunityId: undefined,
@@ -942,6 +1302,8 @@ const resetForm = () => {
ownerUserId: undefined,
ownerUserName: '',
amount: 0,
freight: 0,
barrelDeposit: 0,
supplierId: undefined,
settlementMethod: undefined,
partyA: '',
@@ -973,6 +1335,13 @@ const resetForm = () => {
originalTemplateContent.value = ''
customerShortName.value = ''
customerInvoiceName.value = ''
confirmedCustomerId.value = undefined
customerOptions.value = []
contactOptions.value = []
contactEmptyPrompted.value = false
pendingCustomerForShortName.value = null
shortNameDialogVisible.value = false
shortNameSavedSuccessfully.value = false
contractDateRange.value = [] as any
attachmentList.value = []
selectedProducts.value = []
@@ -1079,6 +1448,8 @@ const loadContractDetail = async (id: number | string) => {
customerShortName.value = ''
customerInvoiceName.value = ''
confirmedCustomerId.value = form.value.customerId || undefined
contactEmptyPrompted.value = false
if (form.value.customerId) {
try {
const [invoiceRes, customerRes] = await Promise.all([
@@ -1090,9 +1461,31 @@ const loadContractDetail = async (id: number | string) => {
if (!form.value.customerName && customerRes?.data?.customerName) {
form.value.customerName = customerRes.data.customerName
}
ensureCustomerOption({
id: form.value.customerId,
customerName: form.value.customerName || customerRes?.data?.customerName,
shortName: customerShortName.value,
customerNo: customerRes?.data?.customerNo
})
} catch {
customerInvoiceName.value = ''
ensureCustomerOption({
id: form.value.customerId,
customerName: form.value.customerName,
shortName: customerShortName.value
})
}
await loadContactOptions(form.value.customerId)
if (form.value.contactId) {
ensureContactOption({
id: form.value.contactId,
contactName: form.value.contactsName,
mobile: form.value.contactsPhone
})
}
} else {
customerOptions.value = []
contactOptions.value = []
}
syncPartiesSwappedFlagFromPanels()
rebuildContractName()
@@ -1103,11 +1496,62 @@ const handleSelectCustomer = () => {
customerSelectorVisible.value = true
}
/** 下拉选择客户 */
const handleCustomerSelectChange = async (customerId?: string | number) => {
if (!customerId) {
clearCustomerSelection()
return
}
if (
confirmedCustomerId.value !== undefined
&& confirmedCustomerId.value !== null
&& String(customerId) === String(confirmedCustomerId.value)
) {
return
}
const customer = customerOptions.value.find((item) => String(item.id) === String(customerId))
if (!customer) return
if (!hasCustomerShortName(customer)) {
ElMessage.warning('该客户尚未配置简称,请先填写客户简称后再继续')
form.value.customerId = confirmedCustomerId.value as any
openShortNameDialog(customer)
return
}
await handleCustomerConfirm(customer)
}
/** 客户选择器确认(无简称时先补填) */
const handleCustomerSelectorConfirm = async (customer: CustomerVO) => {
let enriched: CustomerVO = { ...customer }
if (!hasCustomerShortName(enriched)) {
try {
const customerRes = await fetchCustomerDetail({ id: String(customer.id) })
enriched = {
...enriched,
customerName: customerRes?.data?.customerName || enriched.customerName,
shortName: customerRes?.data?.shortName || enriched.shortName
}
} catch {
// ignore,后续走简称弹窗
}
}
if (!hasCustomerShortName(enriched)) {
ElMessage.warning('该客户尚未配置简称,请先填写客户简称后再继续')
openShortNameDialog(enriched)
return
}
await handleCustomerConfirm(enriched)
}
/** 客户选择确认 */
const handleCustomerConfirm = async (customer: CustomerVO) => {
if (customer) {
ensureCustomerOption(customer)
form.value.customerId = customer.id
form.value.customerName = customer.customerName
confirmedCustomerId.value = customer.id
const addressParts: string[] = []
if (customer.province) addressParts.push(customer.province)
@@ -1117,10 +1561,15 @@ const handleCustomerConfirm = async (customer: CustomerVO) => {
const fullAddress = addressParts.join('')
form.value.contactId = undefined
form.value.contactsName = ''
contactOptions.value = []
contactEmptyPrompted.value = false
form.value.opportunityId = undefined
form.value.opportunityName = ''
form.value.quoteId = undefined
form.value.quoteNo = ''
form.value.freight = 0
form.value.barrelDeposit = 0
try {
const [invoiceRes, customerRes] = await Promise.all([
@@ -1134,6 +1583,12 @@ const handleCustomerConfirm = async (customer: CustomerVO) => {
if (customerRes?.data?.customerName) {
form.value.customerName = customerRes.data.customerName
}
ensureCustomerOption({
id: customer.id,
customerName: form.value.customerName,
shortName: customerShortName.value,
customerNo: customer.customerNo
})
customerInvoiceName.value = invoiceData?.companyName || ''
if (invoiceData && invoiceData.companyName) {
@@ -1176,6 +1631,7 @@ const handleCustomerConfirm = async (customer: CustomerVO) => {
} catch (error) {
console.error('获取客户开票信息失败:', error)
customerInvoiceName.value = ''
customerShortName.value = (customer.shortName || '').trim()
if (isPartiesSwapped.value) {
form.value.partyA = customer.customerName
form.value.partyAAddress = fullAddress
@@ -1205,26 +1661,18 @@ const handleCustomerConfirm = async (customer: CustomerVO) => {
form.value.contactsName = ''
form.value.contactsPhone = customer.mobile || customer.telephone || ''
try {
const res = await getContactByCustomerId(customer.id)
if (res.data && res.data.length > 0) {
const primaryContact = res.data.find((contact: any) => contact.isPrimary === 1)
const contact = primaryContact || res.data[0]
form.value.contactId = contact.id
form.value.contactsName = contact.contactName
form.value.contactsPhone = contact.mobile || contact.telephone || ''
if (isPartiesSwapped.value) {
form.value.partyAContact = contact.contactName
form.value.partyAMobile = contact.mobile || contact.telephone || ''
} else {
form.value.partyBContact = contact.contactName
form.value.partyBMobile = contact.mobile || contact.telephone || ''
}
// 自动加载该客户的联系人并设置默认联系人;无联系人时立即引导补充
const contacts = await loadContactOptions(customer.id)
if (contacts.length > 0) {
const primaryContact = contacts.find((c) => c.isPrimary === 1)
const contact = primaryContact || contacts[0]
if (contact) {
handleContactConfirm(contact)
}
} catch (error) {
console.error('获取客户联系人失败:', error)
} else {
contactEmptyPrompted.value = true
ElMessage.warning('该客户暂无联系人,请先补充联系人信息')
nextTick(() => openContactFormDialog())
}
if (!customerShortName.value) {
@@ -1234,6 +1682,46 @@ const handleCustomerConfirm = async (customer: CustomerVO) => {
}
}
const handleSaveShortName = async () => {
const valid = await shortNameFormRef.value?.validate().catch(() => false)
if (!valid) return
shortNameSaving.value = true
try {
const trimmedShortName = shortNameForm.value.shortName.trim()
await fetchUpdateCustomer({
id: shortNameForm.value.id,
shortName: trimmedShortName
})
ElMessage.success('客户简称保存成功')
const pending = pendingCustomerForShortName.value
const customer: CustomerVO = {
id: shortNameForm.value.id,
customerName: shortNameForm.value.customerName || pending?.customerName || '',
customerNo: pending?.customerNo,
shortName: trimmedShortName
} as CustomerVO
ensureCustomerOption(customer)
shortNameSavedSuccessfully.value = true
shortNameDialogVisible.value = false
await handleCustomerConfirm(customer)
} catch (error) {
console.error('保存客户简称失败:', error)
ElMessage.error('保存客户简称失败')
} finally {
shortNameSaving.value = false
}
}
const handleShortNameDialogClosed = () => {
pendingCustomerForShortName.value = null
if (!shortNameSavedSuccessfully.value) {
form.value.customerId = confirmedCustomerId.value as any
}
shortNameSavedSuccessfully.value = false
}
/** 选择联系人 */
const handleSelectContact = () => {
if (!form.value.customerId) {
@@ -1243,9 +1731,38 @@ const handleSelectContact = () => {
contactSelectorVisible.value = true
}
const handleContactSelectVisible = async (visible: boolean) => {
if (!visible) {
contactEmptyPrompted.value = false
return
}
if (!form.value.customerId) return
const list = await loadContactOptions(form.value.customerId)
if (list.length === 0 && !contactEmptyPrompted.value) {
contactEmptyPrompted.value = true
ElMessage.warning('该客户暂无联系人,请先补充联系人信息')
nextTick(() => openContactFormDialog())
}
}
const handleContactSelectChange = (contactId?: string | number) => {
if (!contactId) {
form.value.contactId = undefined
form.value.contactsName = ''
form.value.contactsPhone = ''
return
}
const contact = contactOptions.value.find((item) => String(item.id) === String(contactId))
if (contact) {
handleContactConfirm(contact)
}
}
/** 联系人选择确认 */
const handleContactConfirm = (contact: ContactVO) => {
if (contact) {
ensureContactOption(contact)
form.value.contactId = contact.id
form.value.contactsName = contact.contactName
form.value.contactsPhone = contact.mobile || contact.telephone || ''
@@ -1262,6 +1779,22 @@ const handleContactConfirm = (contact: ContactVO) => {
}
}
const handleContactFormSuccess = async (payload?: { id?: string | number }) => {
contactEmptyPrompted.value = false
const list = await loadContactOptions(form.value.customerId)
const createdId = payload?.id
if (createdId) {
const created = list.find((item) => String(item.id) === String(createdId))
if (created) {
handleContactConfirm(created)
return
}
}
if (list.length === 1) {
handleContactConfirm(list[0])
}
}
/** 选择商机 */
const handleSelectOpportunity = () => {
opportunitySelectorVisible.value = true
@@ -1290,6 +1823,8 @@ const handleQuoteConfirm = (quote: QuoteVO) => {
if (quote) {
form.value.quoteId = quote.id
form.value.quoteNo = quote.quoteNo
form.value.freight = Number(quote.freight) || 0
form.value.barrelDeposit = Number(quote.barrelDeposit) || 0
if (quote.customerId && !form.value.customerId) {
form.value.customerId = quote.customerId
@@ -1361,9 +1896,17 @@ const loadUserList = async () => {
}
}
/** 新增时默认负责人为方金强 */
const applyDefaultOwnerUser = () => {
const matched = findDefaultOwnerUser(ownerUserList.value)
if (!matched) return
form.value.ownerUserId = matched.userId as ContractForm['ownerUserId']
form.value.ownerUserName = matched.nickName || ''
}
/** 负责人变化 */
const handleOwnerChange = (userId: string | number) => {
const user = userList.value.find((item: any) => String(item.userId) === String(userId))
const user = ownerUserList.value.find((item: any) => String(item.userId) === String(userId))
form.value.ownerUserName = user?.nickName || ''
}
@@ -2052,7 +2595,7 @@ const handleAttachmentData = async () => {
/** 保存草稿 */
const handleSaveDraft = async () => {
if (!form.value.customerId || form.value.customerId === 0) {
if (!form.value.customerId) {
$modal.msgWarning('客户名称不能为空')
return
}
@@ -2672,4 +3215,35 @@ const loadSupplierList = async () => {
flex-shrink: 0;
padding: 4px;
}
.customer-select-row {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
}
.customer-select-row__select {
flex: 1;
min-width: 0;
}
.customer-select-row__search {
flex-shrink: 0;
padding: 0 8px;
}
.customer-option-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
min-width: 0;
}
.customer-option-row__meta {
flex-shrink: 0;
font-size: 12px;
color: var(--mint-text-muted, #8492a6);
}
</style>
@@ -1,373 +0,0 @@
<template>
<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">
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<el-form-item label="产品名称" prop="productName">
<el-input v-model="queryParams.productName" placeholder="请输入产品名称" clearable @keyup.enter="handleQuery" />
</el-form-item>
</el-col>
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<el-form-item label="产品分类" prop="categoryId">
<el-tree-select
v-model="queryParams.categoryId"
:data="categoryTree"
placeholder="请选择产品分类"
clearable
check-strictly
:render-after-expand="false"
style="width: 100%"
/>
</el-form-item>
</el-col>
<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
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" :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>
<!-- 产品列表 -->
<div class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<ListTable table-key="crm-contract-components-product-selector"
ref="tableRef"
v-loading="loading"
:data="productList"
class="list-table"
stripe
@selection-change="handleSelectionChange"
: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="240"
align="left"
header-align="left"
class-name="col-name-wrap"
label-class-name="col-name-wrap"
/>
<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>
</ListTable>
<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>
<div class="selected-products">
<el-tag v-for="product in selectedProducts" :key="product.id" closable @close="handleRemoveSelection(product)" class="mr-2 mb-2">
{{ product.productName }}
</el-tag>
<span v-if="selectedProducts.length === 0" class="text-muted">暂无选择</span>
</div>
<template #footer>
<div class="dialog-footer">
<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>
</template>
<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
taxRate?: number
status?: string
}
interface ProductCategoryVO {
id: string | number
categoryName: string
children?: ProductCategoryVO[]
}
// 定义组件属性
interface Props {
visible: boolean
supplierId?: string | number
}
const props = defineProps<Props>();
// 定义事件
const emit = defineEmits(['update:visible', 'confirm']);
// 对话框显示状态
const dialogVisible = ref(false);
// 监听visible变化
watch(
() => props.visible,
(val) => {
dialogVisible.value = val;
if (val) {
// 打开对话框时加载数据
getList();
loadCategoryTree();
}
}
);
// 监听dialogVisible变化
watch(dialogVisible, (val) => {
emit('update:visible', val);
});
// 查询参数
const queryParams = ref({
pageNum: 1,
pageSize: 20,
productName: undefined,
categoryId: undefined,
status: 'on' // on-上架 off-下架
});
// 产品列表
const productList = ref<ProductVO[]>([]);
const loading = ref(false);
const total = ref(0);
// 产品分类树
const categoryTree = ref<any[]>([]);
// 已选产品
const selectedProducts = ref<ProductVO[]>([]);
const queryFormRef = ref<ElFormInstance>();
const tableRef = ref<any>();
/** 查询产品列表 */
const getList = async () => {
loading.value = true;
try {
const res = await fetchProductList({
page: queryParams.value.pageNum,
pageSize: queryParams.value.pageSize,
keyword: queryParams.value.productName,
categoryId: queryParams.value.categoryId,
status: queryParams.value.status,
supplierId: props.supplierId != null && props.supplierId !== '' ? String(props.supplierId) : undefined
});
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 {
loading.value = false;
}
};
/** 加载产品分类树 */
const loadCategoryTree = async () => {
try {
const res = await fetchCategoryTree();
const tree = (res as any)?.data || res || [];
categoryTree.value = convertTreeData(tree);
// 分类树加载后,补全已有产品的 categoryName
resolveCategoryNames()
} catch (error) {
console.error('获取产品分类失败:', error);
}
};
/** 转换树形数据格式 */
const convertTreeData = (data: ProductCategoryVO[]): any[] => {
return data.map((item) => ({
value: item.id,
label: item.categoryName,
children: item.children ? convertTreeData(item.children) : undefined
}));
};
/** 从分类树中解析分类名称 */
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;
getList();
};
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value?.resetFields();
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 handleRemoveSelection = (product: ProductVO) => {
const index = selectedProducts.value.findIndex((p) => p.id === product.id);
if (index > -1) {
selectedProducts.value.splice(index, 1);
}
};
/** 确认选择 */
const handleConfirm = () => {
emit('confirm', selectedProducts.value);
handleClose();
};
/** 关闭对话框 */
const handleClose = () => {
selectedProducts.value = [];
dialogVisible.value = false;
};
</script>
<style scoped lang="scss">
.selected-products {
min-height: 40px;
padding: 10px;
background-color: #f5f7fa;
border-radius: 4px;
}
.text-primary {
color: #409eff;
font-weight: 500;
}
.text-muted {
color: #1e293b;
}
.mr-2 {
margin-right: 8px;
}
.mb-2 {
margin-bottom: 8px;
}
.mt-3 {
margin-top: 16px;
}
.mb-3 {
margin-bottom: 16px;
}
</style>
+122 -21
View File
@@ -3,7 +3,6 @@
<section class="panel" v-loading="loading">
<div class="panel-top panel-top-with-actions">
<div class="panel-top-main">
<h2 class="title-bar-heading">合同详情</h2>
<div v-if="contract" class="header-left header-tags">
<el-tag
v-if="contract?.status"
@@ -769,8 +768,17 @@
<div class="attachment-list">
<div v-for="(file, index) in contractFileList" :key="index" class="upload-file-card">
<!-- 缩略图区域 -->
<div class="upload-file-thumb">
<ImagePreview v-if="isImageFile(file)" :src="file.ossId" class="upload-thumb-image" :isOssId="true" />
<div
class="upload-file-thumb"
:title="isImageFile(file) || isPdfFile(file) ? '点击在新标签页查看' : '点击查看'"
@click.stop="handlePreviewFile(file)"
>
<ImagePreview
v-if="isImageFile(file)"
:src="file.ossId"
class="upload-thumb-image upload-thumb-image--passive"
:isOssId="true"
/>
<div v-else class="upload-file-placeholder">
<el-icon><Document /></el-icon>
<span class="file-ext">{{ getFileExt(file.name) }}</span>
@@ -782,8 +790,12 @@
{{ getShortFileName(file.name) }}
</div>
<!-- 下载按钮 -->
<!-- 查看 / 下载 -->
<div class="upload-file-card-actions">
<el-button type="primary" size="small" @click.stop="handlePreviewFile(file)">
<el-icon><View /></el-icon>
查看
</el-button>
<el-button type="success" size="small" @click.stop="handleDownloadFile(file)">
<el-icon><Download /></el-icon>
下载
@@ -810,8 +822,17 @@
</div>
<div class="attachment-list">
<div v-for="(file, index) in getAttachmentsByCategory('single_seal')" :key="index" class="upload-file-card">
<div class="upload-file-thumb">
<ImagePreview v-if="isImageFile(file)" :src="file.ossId" class="upload-thumb-image" :isOssId="true" />
<div
class="upload-file-thumb"
:title="isImageFile(file) || isPdfFile(file) ? '点击在新标签页查看' : '点击查看'"
@click.stop="handlePreviewFile(file)"
>
<ImagePreview
v-if="isImageFile(file)"
:src="file.ossId"
class="upload-thumb-image upload-thumb-image--passive"
:isOssId="true"
/>
<div v-else class="upload-file-placeholder">
<el-icon><Document /></el-icon>
<span class="file-ext">{{ getFileExt(file.name) }}</span>
@@ -821,6 +842,10 @@
{{ getShortFileName(file.name) }}
</div>
<div class="upload-file-card-actions">
<el-button type="primary" size="small" @click.stop="handlePreviewFile(file)">
<el-icon><View /></el-icon>
查看
</el-button>
<el-button type="success" size="small" @click.stop="handleDownloadFile(file)">
<el-icon><Download /></el-icon>
下载
@@ -845,8 +870,17 @@
</div>
<div class="attachment-list">
<div v-for="(file, index) in getAttachmentsByCategory('double_seal')" :key="index" class="upload-file-card">
<div class="upload-file-thumb">
<ImagePreview v-if="isImageFile(file)" :src="file.ossId" class="upload-thumb-image" :isOssId="true" />
<div
class="upload-file-thumb"
:title="isImageFile(file) || isPdfFile(file) ? '点击在新标签页查看' : '点击查看'"
@click.stop="handlePreviewFile(file)"
>
<ImagePreview
v-if="isImageFile(file)"
:src="file.ossId"
class="upload-thumb-image upload-thumb-image--passive"
:isOssId="true"
/>
<div v-else class="upload-file-placeholder">
<el-icon><Document /></el-icon>
<span class="file-ext">{{ getFileExt(file.name) }}</span>
@@ -856,6 +890,10 @@
{{ getShortFileName(file.name) }}
</div>
<div class="upload-file-card-actions">
<el-button type="primary" size="small" @click.stop="handlePreviewFile(file)">
<el-icon><View /></el-icon>
查看
</el-button>
<el-button type="success" size="small" @click.stop="handleDownloadFile(file)">
<el-icon><Download /></el-icon>
下载
@@ -882,8 +920,17 @@
</div>
<div class="attachment-list">
<div v-for="(file, index) in getAttachmentsByCategory('other')" :key="index" class="upload-file-card">
<div class="upload-file-thumb">
<ImagePreview v-if="isImageFile(file)" :src="file.ossId" class="upload-thumb-image" :isOssId="true" />
<div
class="upload-file-thumb"
:title="isImageFile(file) || isPdfFile(file) ? '点击在新标签页查看' : '点击查看'"
@click.stop="handlePreviewFile(file)"
>
<ImagePreview
v-if="isImageFile(file)"
:src="file.ossId"
class="upload-thumb-image upload-thumb-image--passive"
:isOssId="true"
/>
<div v-else class="upload-file-placeholder">
<el-icon><Document /></el-icon>
<span class="file-ext">{{ getFileExt(file.name) }}</span>
@@ -893,6 +940,10 @@
{{ getShortFileName(file.name) }}
</div>
<div class="upload-file-card-actions">
<el-button type="primary" size="small" @click.stop="handlePreviewFile(file)">
<el-icon><View /></el-icon>
查看
</el-button>
<el-button type="success" size="small" @click.stop="handleDownloadFile(file)">
<el-icon><Download /></el-icon>
下载
@@ -1037,7 +1088,9 @@ import { isFrameworkContractType, isContractEditableStatus, getContractReeditCon
import {
parseCrmAttachmentField,
resolveCrmAttachmentFiles,
downloadCrmAttachment
downloadCrmAttachment,
isCrmPdfFile,
openCrmAttachmentPreviewInNewTab
} from '@/utils/crm-attachment';
import { canDeleteByCreator } from '@/utils/creator-delete';
import html2canvas from 'html2canvas';
@@ -1634,14 +1687,32 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
)
}
// 替换产品合计行
// 合计区(11 列):第 1 行左右对半运费/桶押金;第 2 行合计;第 3 行提示
const freightRaw = Number(contractData.freight);
const freightAmount = Number.isFinite(freightRaw) && freightRaw > 0 ? freightRaw : 0;
const depositRaw = Number(contractData.barrelDeposit);
const barrelDepositAmount = Number.isFinite(depositRaw) && depositRaw > 0 ? depositRaw : 0;
const totalWithExtras = productTotal + freightAmount + barrelDepositAmount;
const totalWithExtrasChinese = numberToChinese(totalWithExtras);
const freightText = freightAmount > 0 ? `¥${formatNumber(freightAmount)}` : '-';
const barrelDepositText = barrelDepositAmount > 0 ? `¥${formatNumber(barrelDepositAmount)}` : '-';
const isFrameworkContract = isFrameworkContractType(contractData.contractType);
const totalWithExtrasText = isFrameworkContract
? '随行就市'
: `${totalWithExtrasChinese}(¥${formatNumber(totalWithExtras)})`;
const tipRowText = hasSelfPickupProduct(contractData.products)
? '含税、客户自提结算以实际到货吨位为准'
: '含税、含运费、装卸费(一票制)&nbsp;&nbsp;&nbsp;&nbsp;结算以实际到货吨位为准';
const productTotalRow = `
<tr style="background-color: #fff; font-weight: bold;">
<td colspan="4" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">合计(人民币)</td>
<td colspan="7" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${productTotalChinese}(¥${formatNumber(productTotal)})</td>
<td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">运费(元)</td>
<td colspan="4" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${freightText}</td>
<td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">桶押金(元)</td>
<td colspan="3" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${barrelDepositText}</td>
</tr>
<tr style="background-color: #fff; font-weight: bold;">
<td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">合计(人民币)</td>
<td colspan="9" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${totalWithExtrasText}</td>
</tr>
<tr>
<td colspan="11" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 11px; background-color: #fff;">
@@ -1857,9 +1928,21 @@ const generateContractTemplate = (contractData: ContractVO | undefined): string
return sum + (isNaN(taxAmount) ? 0 : taxAmount);
}, 0) || 0;
// 计算产品总金额(含税)
// 计算产品总金额(含税)与合同运费、桶押金
const productTotal = parseFloat(String(contractData.amount || 0));
const productTotalChinese = numberToChinese(productTotal);
const freightRaw = Number(contractData.freight);
const freightAmount = Number.isFinite(freightRaw) && freightRaw > 0 ? freightRaw : 0;
const depositRaw = Number(contractData.barrelDeposit);
const barrelDepositAmount = Number.isFinite(depositRaw) && depositRaw > 0 ? depositRaw : 0;
const totalWithExtras = productTotal + freightAmount + barrelDepositAmount;
const totalWithExtrasChinese = numberToChinese(totalWithExtras);
const freightText = freightAmount > 0 ? `¥${formatNumber(freightAmount)}` : '-';
const barrelDepositText = barrelDepositAmount > 0 ? `¥${formatNumber(barrelDepositAmount)}` : '-';
const isFrameworkContract = isFrameworkContractType(contractData.contractType);
const totalWithExtrasText = isFrameworkContract
? '随行就市'
: `${totalWithExtrasChinese}(¥${formatNumber(totalWithExtras)})`;
// 构建产品明细表格行
const productRows =
@@ -1938,10 +2021,16 @@ const generateContractTemplate = (contractData: ContractVO | undefined): string
</thead>
<tbody>
${productRows}
<!-- 合计行 -->
<!-- 运费/桶押金一行(10 列对半),合计单独一行 -->
<tr style="background-color: #fff; font-weight: bold;">
<td colspan="4" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">合计(人民币)</td>
<td colspan="6" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${productTotalChinese}(¥${formatNumber(productTotal)})</td>
<td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">运费(元)</td>
<td colspan="3" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${freightText}</td>
<td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">桶押金(元)</td>
<td colspan="3" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${barrelDepositText}</td>
</tr>
<tr style="background-color: #fff; font-weight: bold;">
<td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">合计(人民币)</td>
<td colspan="8" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${totalWithExtrasText}</td>
</tr>
<!-- 提示行 -->
<tr>
@@ -2147,7 +2236,7 @@ const handleViewCustomer = () => {
/** 查看商机 */
const handleViewOpportunity = () => {
router.push({
path: '/crm/opportunity/detail',
path: '/system/opportunity/detail',
query: { id: contract.value?.opportunityId }
});
};
@@ -2192,9 +2281,14 @@ const handleViewInvoice = (row: any) => {
});
};
/** 预览附件 */
/** 预览附件(新标签页) */
const handlePreviewFile = async (file: any) => {
await openCrmAttachmentPreviewInNewTab(file);
};
/** @deprecated 兼容旧调用 */
const handlePreview = (file: any) => {
window.open(file.url, '_blank');
handlePreviewFile(file);
};
/** 查看原合同 */
@@ -2349,6 +2443,8 @@ const isImageFile = (file: any) => {
return imageExts.includes(ext);
};
const isPdfFile = (file: any) => isCrmPdfFile({ name: file?.name, ossId: file?.ossId, url: file?.url });
/** 获取文件扩展名 */
const getFileExt = (fileName: string) => {
if (!fileName) return 'FILE';
@@ -3046,12 +3142,17 @@ const handleGeneratePdfFile = async () => {
align-items: center;
justify-content: center;
overflow: hidden;
cursor: pointer;
}
.upload-thumb-image {
width: 100%;
height: 100%;
object-fit: cover;
&--passive {
pointer-events: none;
}
}
.upload-file-placeholder {
+17 -7
View File
@@ -2,7 +2,6 @@
<div class="page-container contract-form-page">
<section class="panel">
<div class="panel-top panel-top-with-actions">
<h2 class="title-bar-heading">{{ formTitle }}</h2>
<el-button class="btn-ghost" @click="handleBack">
<el-icon><Back /></el-icon>返回
</el-button>
@@ -107,6 +106,16 @@
<el-input-number v-model="form.amount" :precision="2" :min="0" placeholder="可选填,留空则从产品明细自动计算" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="运费(元)" prop="freight">
<el-input-number v-model="form.freight" :precision="2" :min="0" placeholder="可选,选择报价单后可自动带入" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="桶押金(元)" prop="barrelDeposit">
<el-input-number v-model="form.barrelDeposit" :precision="2" :min="0" placeholder="可选,选择报价单后可自动带入" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<!-- 第三行:合同期限(单独一行) -->
@@ -600,7 +609,7 @@ import {
} from '@/utils/crm-attachment';
import { getOpportunity } from '@/api/crm/opportunity';
import { getQuote } from '@/api/crm/quote';
import ProductSelector from './components/ProductSelector.vue';
import ProductSelector from '@/components/business/product-selector/index.vue';
import CustomerSelector from '@/components/CustomerSelector.vue';
import ContactSelector from '@/components/ContactSelector.vue';
import QuoteSelector from '@/components/QuoteSelector.vue';
@@ -649,11 +658,6 @@ const route = useRoute();
const dictStore = useDictStore();
const userStore = useUserStore();
// 表单标题
const formTitle = computed(() => {
return form.value.id ? '编辑合同' : '新增合同';
});
// 合同期限日期范围
const contractDateRange = ref<[string, string]>([]);
@@ -679,6 +683,8 @@ const form = ref<ExtendedContractForm>({
ownerUserId: undefined,
ownerUserName: '',
amount: 0,
freight: 0,
barrelDeposit: 0,
supplierId: undefined,
settlementMethod: undefined,
partyA: '',
@@ -933,6 +939,8 @@ const handleCustomerConfirm = async (customer: CustomerVO) => {
form.value.opportunityName = '';
form.value.quoteId = undefined;
form.value.quoteNo = '';
form.value.freight = 0;
form.value.barrelDeposit = 0;
// 自动获取客户的默认开票信息并填充到需方信息(乙方)
try {
@@ -1165,6 +1173,8 @@ const handleQuoteConfirm = (quote: QuoteVO) => {
if (quote) {
form.value.quoteId = quote.id;
form.value.quoteNo = quote.quoteNo;
form.value.freight = Number(quote.freight) || 0;
form.value.barrelDeposit = Number(quote.barrelDeposit) || 0;
// 自动带出客户信息
if (quote.customerId && !form.value.customerId) {
File diff suppressed because it is too large. Load diff
@@ -2,10 +2,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">合同模板</h2>
</div>
<div class="filter-block">
<el-form ref="queryFormRef" :model="queryParams" class="filter-form" @submit.prevent>
<div class="filter-grid">
@@ -6,6 +6,8 @@
class="dialog-form-layout"
align-center
destroy-on-close
append-to-body
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
@close="reset"
>
@@ -20,11 +22,11 @@
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<el-divider content-position="left">基本信息</el-divider>
<div class="form-grid-4">
<div class="form-grid-2">
<el-form-item label="客户名称" prop="customerName">
<el-input v-model="form.customerName" placeholder="请输入客户名称" maxlength="200" />
</el-form-item>
<el-form-item label="拼音首字母" class="form-field-span-2">
<el-form-item label="拼音首字母">
<div class="pinyin-abbr-row">
<el-input
v-model="form.namePinyinAbbr"
@@ -38,6 +40,8 @@
</el-button>
</div>
</el-form-item>
</div>
<div class="form-grid-4">
<el-form-item label="客户简称" prop="shortName">
<el-input v-model="form.shortName" placeholder="请输入客户简称" maxlength="100" />
</el-form-item>
@@ -64,6 +68,8 @@
<el-form-item label="手机号" prop="mobile">
<el-input v-model="form.mobile" placeholder="请输入手机号" maxlength="11" />
</el-form-item>
</div>
<div class="form-grid-4">
<el-form-item label="固定电话" prop="telephone">
<el-input v-model="form.telephone" placeholder="请输入固定电话" maxlength="30" />
</el-form-item>
@@ -11,12 +11,20 @@
</div>
<div class="list-body list-wrap">
<el-alert
v-if="!loading && invoiceInfoList.length === 0"
class="invoice-empty-alert"
type="info"
:closable="false"
show-icon
title="暂无开票信息,请点击上方「添加开票信息」进行维护;可维护多条并设置默认,合同等业务将按默认开票信息带出。"
/>
<ListTable table-key="crm-customer-components-invoice-info"
:data="invoiceInfoList"
v-loading="loading"
stripe
class="list-table"
empty-text=""
empty-text="暂无开票信息,请点击「添加开票信息」维护"
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
@@ -114,8 +122,18 @@
</ListTable>
</div>
<el-dialog :title="dialog.title" v-model="dialog.visible" width="800px" class="dialog-form-layout" align-center append-to-body @close="handleClose">
<el-form ref="formRef" :model="form" :rules="rules" label-position="right" label-width="100px" class="crm-dialog-form">
<el-dialog
:title="dialog.title"
v-model="dialog.visible"
width="800px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="crm-dialog-form">
<div class="form-grid-2">
<el-form-item label="单位名称(章)" prop="companyName" class="form-col-span-2">
<el-input v-model="form.companyName" placeholder="请输入单位名称" maxlength="200" />
@@ -153,12 +171,14 @@
</div>
</el-form>
<template #footer>
<el-button @click="handleClose">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitForm">
<el-icon><Check /></el-icon>确定
</el-button>
<div class="dialog-footer">
<el-button class="btn-ghost" @click="handleClose">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="submitForm">
<el-icon><Check /></el-icon>确定
</el-button>
</div>
</template>
</el-dialog>
</div>
@@ -360,4 +380,8 @@ defineExpose({ getList })
.invoice-info-container {
margin: 0;
}
.invoice-empty-alert {
margin-bottom: 12px;
}
</style>
+2 -6
View File
@@ -2,17 +2,13 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">客户列表</h2>
</div>
<el-tabs
v-model="activeView"
class="panel-tabs"
@tab-change="handleViewChange"
>
<el-tab-pane label="我的客户" name="mine" />
<el-tab-pane label="全部客户" name="all" />
<el-tab-pane label="我的客户" name="mine" />
<el-tab-pane label="待跟进" name="toFollow" />
<el-tab-pane label="重点客户" name="key" />
</el-tabs>
@@ -707,7 +703,7 @@ const dateRange = ref<[string, string]>()
const quickSearchKeyword = ref('')
// 视图类型
const activeView = ref('mine')
const activeView = ref('all')
// 数据列表
const customerList = ref<any[]>([])
@@ -2,10 +2,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">公海池规则</h2>
</div>
<!-- 筛选区 -->
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
@@ -2,10 +2,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">公海池</h2>
</div>
<!-- 筛选区 -->
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
@@ -2,9 +2,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">商机详情</h2>
</div>
<el-button class="btn-ghost" @click="goBack">
<el-icon><ArrowLeft /></el-icon>返回列表
</el-button>
@@ -26,7 +23,7 @@
const opportunityId = ref<string>('')
function goBack() {
router.push({ path: '/crm/opportunity' })
router.push({ path: '/system/opportunity' })
}
onMounted(() => {
@@ -1,10 +1,6 @@
<template>
<div class="page-container opportunity-funnel-page">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">销售漏斗</h2>
</div>
<div class="filter-block">
<el-form ref="queryFormRef" class="filter-form" :model="queryParams" @submit.prevent>
<div class="filter-grid">
@@ -2,10 +2,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">商机管理</h2>
</div>
<el-tabs
v-model="activeView"
class="panel-tabs"
@@ -1033,7 +1029,7 @@
/** 切换视图模式 */
const switchView = (mode: 'list' | 'kanban') => {
if (mode === 'kanban') {
router.push('/crm/opportunity/kanban')
router.push('/system/opportunity/kanban')
} else {
viewMode.value = mode
}
@@ -1041,7 +1037,7 @@
/** 跳转到漏斗分析 */
const goToFunnel = () => {
router.push('/crm/opportunity/funnel')
router.push('/system/opportunity/funnel')
}
watchDebounced(
@@ -1,10 +1,6 @@
<template>
<div class="page-container opportunity-kanban-page">
<section class="panel kanban-panel">
<div class="panel-top">
<h2 class="title-bar-heading">商机看板</h2>
</div>
<div class="filter-block">
<el-form ref="queryFormRef" class="filter-form" :model="queryParams" @submit.prevent>
<div class="filter-grid">
@@ -189,13 +185,13 @@ const resetQuery = () => {
}
const handleViewDetail = (id: number) => {
router.push({ path: '/crm/opportunity/detail', query: { id } })
router.push({ path: '/system/opportunity/detail', query: { id } })
}
const handleCardAction = (command: string, opp: OpportunityVO) => {
switch (command) {
case 'edit':
router.push({ path: '/crm/opportunity', query: { id: opp.id, action: 'edit' } })
router.push({ path: '/system/opportunity', query: { id: opp.id, action: 'edit' } })
break
case 'stage':
changeStageDialogRef.value?.open(opp.id)
@@ -2,7 +2,6 @@
<div class="page-container opportunity-statistics-page">
<section class="panel">
<div class="panel-top panel-top-with-actions">
<h2 class="title-bar-heading">商机统计分析</h2>
<el-button class="btn-ghost" @click="goBack">
<el-icon><Back /></el-icon>返回列表
</el-button>
@@ -240,7 +239,7 @@ let winRateChart: echarts.ECharts | null = null
let ownerChart: echarts.ECharts | null = null
const goBack = () => {
router.push('/crm/opportunity')
router.push('/system/opportunity')
}
const handleQuery = () => {
+317 -63
View File
@@ -3,10 +3,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">调价函管理</h2>
</div>
<el-tabs v-model="activeView" class="panel-tabs" @tab-change="handleTabChange">
<el-tab-pane label="全部" name="all" />
<el-tab-pane label="已作废" name="invalid" />
@@ -54,7 +50,6 @@
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">独立档案,不关联合同/报价单,不回写产品价格。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
@@ -70,7 +65,7 @@
<div class="list-toolbar">
<div class="list-toolbar-row price-letter-list-toolbar-row">
<ListToolbarDateShortcuts
variant="select"
variant="pills"
:active="activeDateShortcut"
aria-label="调价日期快捷筛选"
@change="handleDateShortcut"
@@ -268,7 +263,7 @@
<el-dialog
v-model="formDialogVisible"
:title="isEdit ? '编辑调价函' : '新增调价函'"
:width="formData.letterType === 'send' ? '1380px' : '720px'"
:width="formData.letterType === 'send' ? '1380px' : '960px'"
align-center
destroy-on-close
class="dialog-form-layout price-letter-form-dialog"
@@ -292,66 +287,181 @@
<el-radio value="send">发出调价函</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="调价日期" prop="adjustDate">
<el-date-picker
v-model="formData.adjustDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择调价日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="供应商名称(客户)" prop="customerId">
<el-input v-model="formData.customerName" placeholder="请选择客户" readonly>
<template #append>
<el-button @click="customerSelectorVisible = true">
<!-- 接受:日期+客户一行;产品+现金+汇票一行 -->
<template v-if="formData.letterType === 'receive'">
<div class="form-grid-2">
<el-form-item label="调价日期" prop="adjustDate">
<el-date-picker
v-model="formData.adjustDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择调价日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="供应商名称(客户)" prop="customerId">
<div class="customer-select-row">
<el-select
v-model="formData.customerId"
class="customer-select-row__select"
filterable
remote
clearable
:remote-method="searchCustomerOptions"
:loading="customerOptionsLoading"
placeholder="请输入客户名称/拼音首字母搜索"
@focus="handleCustomerSelectFocus"
@change="handleCustomerSelectChange"
>
<el-option
v-for="item in customerOptions"
:key="item.id"
:label="item.customerName"
:value="item.id"
>
<div class="customer-option-row">
<span>{{ item.customerName }}</span>
<span v-if="item.customerNo" class="customer-option-row__meta">{{ item.customerNo }}</span>
</div>
</el-option>
</el-select>
<el-button text class="customer-select-row__search" @click="customerSelectorVisible = true">
<el-icon><Search /></el-icon>
</el-button>
</div>
</el-form-item>
</div>
<div class="form-grid-3">
<el-form-item label="产品名称" prop="productId">
<el-select
v-model="formData.productId"
filterable
remote
clearable
placeholder="请选择产品"
:remote-method="searchProducts"
:loading="productLoading"
style="width: 100%"
@change="handleProductChange"
>
<el-option
v-for="p in productOptions"
:key="p.id"
:label="p.label || p.productName"
:value="p.id"
/>
</el-select>
</el-form-item>
<el-form-item label="调整后现金价格(元)" prop="price">
<el-input-number
v-model="formData.price"
:min="0"
:precision="2"
:step="0.01"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="调整后汇票价格(元)" prop="billPrice">
<el-input-number
v-model="formData.billPrice"
:min="0"
:precision="2"
:step="0.01"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</div>
</template>
<!-- 发出:保持原纵向布局 -->
<template v-else>
<el-form-item label="调价日期" prop="adjustDate">
<el-date-picker
v-model="formData.adjustDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择调价日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="供应商名称(客户)" prop="customerId">
<div class="customer-select-row">
<el-select
v-model="formData.customerId"
class="customer-select-row__select"
filterable
remote
clearable
:remote-method="searchCustomerOptions"
:loading="customerOptionsLoading"
placeholder="请输入客户名称/拼音首字母搜索"
@focus="handleCustomerSelectFocus"
@change="handleCustomerSelectChange"
>
<el-option
v-for="item in customerOptions"
:key="item.id"
:label="item.customerName"
:value="item.id"
>
<div class="customer-option-row">
<span>{{ item.customerName }}</span>
<span v-if="item.customerNo" class="customer-option-row__meta">{{ item.customerNo }}</span>
</div>
</el-option>
</el-select>
<el-button text class="customer-select-row__search" @click="customerSelectorVisible = true">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="产品名称" prop="productId">
<el-select
v-model="formData.productId"
filterable
remote
clearable
placeholder="请选择产品"
:remote-method="searchProducts"
:loading="productLoading"
style="width: 100%"
@change="handleProductChange"
>
<el-option
v-for="p in productOptions"
:key="p.id"
:label="p.label || p.productName"
:value="p.id"
/>
</el-select>
</el-form-item>
<div class="price-letter-price-row">
<el-form-item label="调整后现金价格(元)" prop="price">
<el-input-number
v-model="formData.price"
:min="0"
:precision="2"
:step="0.01"
controls-position="right"
style="width: 100%"
/>
</div>
</el-form-item>
<el-form-item label="调整后汇票价格(元)" prop="billPrice">
<el-input-number
v-model="formData.billPrice"
:min="0"
:precision="2"
:step="0.01"
controls-position="right"
<el-form-item label="产品名称" prop="productId">
<el-select
v-model="formData.productId"
filterable
remote
clearable
placeholder="请选择产品"
:remote-method="searchProducts"
:loading="productLoading"
style="width: 100%"
/>
@change="handleProductChange"
>
<el-option
v-for="p in productOptions"
:key="p.id"
:label="p.label || p.productName"
:value="p.id"
/>
</el-select>
</el-form-item>
</div>
<div class="price-letter-price-row">
<el-form-item label="调整后现金价格(元)" prop="price">
<el-input-number
v-model="formData.price"
:min="0"
:precision="2"
:step="0.01"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="调整后汇票价格(元)" prop="billPrice">
<el-input-number
v-model="formData.billPrice"
:min="0"
:precision="2"
:step="0.01"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</div>
</template>
<el-form-item
:label="formData.letterType === 'receive' ? '调价函附件' : '附件(可选,如盖章扫描件)'"
prop="attachmentUrls"
@@ -505,7 +615,7 @@
import { formatMoneyDisplay } from '@/utils/money-format'
import { $modal } from '@/utils/modal'
import { fetchProductOptions } from '@/api/crm/product'
import { fetchCustomerDetail } from '@/api/crm/customer'
import { fetchCustomerDetail, fetchCustomerList } from '@/api/crm/customer'
import {
fetchPriceLetterList,
fetchPriceLetterDetail,
@@ -601,6 +711,104 @@
const productOptions = ref<any[]>([])
const productLoading = ref(false)
const customerSelectorVisible = ref(false)
const customerOptions = ref<Array<{ id: string; customerName: string; customerNo?: string }>>([])
const customerOptionsLoading = ref(false)
function extractCustomerList(res: unknown): Array<{ id: string; customerName: string; customerNo?: string }> {
const data = (res as { data?: unknown })?.data ?? res ?? {}
const list =
(data as { list?: any[]; rows?: any[] }).list || (data as { rows?: any[] }).rows || []
return Array.isArray(list) ? list : []
}
function ensureCustomerOption(customer?: { id?: string; customerName?: string; customerNo?: string } | null) {
if (!customer?.id) return
const exists = customerOptions.value.some((item) => String(item.id) === String(customer.id))
if (exists) {
customerOptions.value = customerOptions.value.map((item) =>
String(item.id) === String(customer.id)
? {
...item,
customerName: customer.customerName || item.customerName,
customerNo: customer.customerNo ?? item.customerNo
}
: item
)
return
}
customerOptions.value = [
{
id: customer.id,
customerName: customer.customerName || '',
customerNo: customer.customerNo
},
...customerOptions.value
]
}
async function loadDefaultCustomerOptions() {
customerOptionsLoading.value = true
try {
const res = await fetchCustomerList({ page: 1, pageSize: 20 })
customerOptions.value = extractCustomerList(res)
if (formData.customerId) {
ensureCustomerOption({
id: formData.customerId,
customerName: formData.customerName
})
}
} catch (error) {
console.error('加载客户列表失败:', error)
customerOptions.value = []
} finally {
customerOptionsLoading.value = false
}
}
async function searchCustomerOptions(query: string) {
const keyword = query?.trim()
if (!keyword) {
await loadDefaultCustomerOptions()
return
}
customerOptionsLoading.value = true
try {
const res = await fetchCustomerList({
keyword,
page: 1,
pageSize: 50
})
customerOptions.value = extractCustomerList(res)
if (formData.customerId) {
ensureCustomerOption({
id: formData.customerId,
customerName: formData.customerName
})
}
} catch (error) {
console.error('搜索客户失败:', error)
customerOptions.value = []
} finally {
customerOptionsLoading.value = false
}
}
function handleCustomerSelectFocus() {
if (customerOptions.value.length === 0) {
loadDefaultCustomerOptions()
}
}
function handleCustomerSelectChange(customerId?: string) {
if (!customerId) {
formData.customerId = ''
formData.customerName = ''
return
}
const customer = customerOptions.value.find((item) => String(item.id) === String(customerId))
formData.customerId = customerId
formData.customerName = customer?.customerName || formData.customerName || ''
}
const detailDialogVisible = ref(false)
const detailData = ref<any>(null)
@@ -843,7 +1051,9 @@
function handleAdd() {
isEdit.value = false
resetForm()
customerOptions.value = []
formDialogVisible.value = true
loadDefaultCustomerOptions()
}
async function openEdit(row: any) {
@@ -870,7 +1080,15 @@
{ id: res.productId, productName: res.productName, label: res.productName }
]
}
customerOptions.value = []
if (res.customerId) {
ensureCustomerOption({
id: res.customerId,
customerName: res.customerName || ''
})
}
formDialogVisible.value = true
loadDefaultCustomerOptions()
}
function handleToolbarEdit() {
@@ -896,6 +1114,11 @@
if (!customer) return
formData.customerId = customer.id
formData.customerName = customer.customerName || ''
ensureCustomerOption({
id: customer.id,
customerName: customer.customerName || '',
customerNo: customer.customerNo
})
}
async function searchProducts(keyword: string) {
@@ -1175,6 +1398,37 @@ ${preparedHtml}
gap: 12px 16px;
}
.customer-select-row {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
}
.customer-select-row__select {
flex: 1;
min-width: 0;
}
.customer-select-row__search {
flex-shrink: 0;
padding: 0 8px;
}
.customer-option-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
min-width: 0;
}
.customer-option-row__meta {
flex-shrink: 0;
font-size: 12px;
color: var(--mint-text-muted, #8492a6);
}
.price-letter-list-toolbar-row {
flex-wrap: nowrap;
overflow-x: auto;
@@ -1,549 +1,12 @@
<!-- 产品分类管理 -->
<!-- eslint-disable vue/no-v-model-argument -->
<!-- 产品分类管理(侧栏已软隐藏;路由直链仍可用) -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">产品分类</h2>
</div>
<!-- 筛选区 -->
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="分类名称">
<el-input
v-model="searchForm.categoryName"
placeholder="分类名称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="全部" clearable>
<el-option
v-for="opt in dictStore.getOptions('sys_normal_disable')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">按分类名称模糊查询。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<!-- 工具栏 -->
<div class="list-toolbar">
<div class="list-toolbar-row">
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="分类名称"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button class="btn-main" type="primary" @click="handleAdd()">
<el-icon><Plus /></el-icon>
新增
</el-button>
<el-button class="btn-ghost" @click="toggleExpandAll">
{{ isExpandAll ? '全部折叠' : '全部展开' }}
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedId"
@click="handleUpdate()"
>
<el-icon><Edit /></el-icon>
修改
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedId"
@click="handleAddChild()"
>
<el-icon><Plus /></el-icon>
新增子分类
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedId || !canDeleteSelectedCategory"
:title="canDeleteSelectedCategory ? '' : CREATOR_DELETE_TIP"
@click="handleDelete()"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="getList"
@reset-columns="resetToDefault"
/>
</div>
</div>
<!-- 树形表格 -->
<div class="list-body list-wrap">
<ListTable table-key="crm-product-category"
v-if="refreshTable"
ref="tableRef"
class="list-table"
:data="categoryList"
v-loading="loading"
stripe
row-key="id"
:default-expand-all="isExpandAll"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
highlight-current-row
@selection-change="handleSelectionChange"
@row-dblclick="handleRowEdit"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
prop="categoryName"
label="分类名称"
min-width="200"
align="left"
header-align="left"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('orderNum')"
prop="orderNum"
label="排序"
min-width="80"
align="center"
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('status')"
prop="status"
label="状态"
min-width="80"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('sys_normal_disable', row.status)"
size="small"
>
{{ dictStore.getLabel('sys_normal_disable', row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('createTime')"
prop="createTime"
label="创建时间"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
/>
</ListTable>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
</div>
<!-- 新增/编辑分类弹窗(内联) -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="640px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="right"
label-width="100px"
class="crm-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="上级分类" style="grid-column: 1 / -1">
<el-tree-select
v-model="formData.parentId"
:data="categoryTreeOptions"
node-key="id"
:props="{ label: 'categoryName', children: 'children' }"
value-key="id"
check-strictly
filterable
clearable
placeholder="选择上级分类(留空为顶级)"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="分类名称" prop="categoryName">
<el-input v-model="formData.categoryName" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="排序号">
<el-input-number
v-model="formData.orderNum"
controls-position="right"
:min="0"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="formData.status" placeholder="请选择状态" style="width: 100%">
<el-option
v-for="opt in dictStore.getOptions('sys_normal_disable')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="formData.remark"
type="textarea"
placeholder="请输入备注"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="submitForm">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
<ProductCategoryMaintainPanel />
</section>
</div>
</template>
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { ref, reactive, onMounted, nextTick, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import {
Plus,
Edit,
Delete,
Close,
Check,
Search,
RefreshLeft,
} from '@element-plus/icons-vue'
import { useDictStore } from '@/store/modules/dict'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
fetchCategoryTree,
fetchCreateCategory,
fetchUpdateCategory,
fetchDeleteCategory,
} from '@/api/crm/product'
const dictStore = useDictStore()
const listColumnDefs = [
{ prop: 'orderNum', label: '排序' },
{ prop: 'status', label: '状态' },
{ prop: 'createTime', label: '创建时间' }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('crm_product_category_visible_columns', listColumnDefs)
// ── 列表数据 ──
const categoryList = ref<any[]>([])
const loading = ref(false)
const isExpandAll = ref(true)
const refreshTable = ref(true)
const selectedId = ref<string>()
const selectedRows = ref<any[]>([])
const canDeleteSelectedCategory = computed(() => {
if (!selectedId.value) return false
const node = findNodeById(categoryList.value, selectedId.value)
return canDeleteByCreator(node)
})
// ── 弹窗数据 ──
const dialogVisible = ref(false)
const dialogTitle = ref('')
const submitLoading = ref(false)
const formData = reactive({
id: '',
parentId: '',
categoryName: '',
orderNum: 0,
status: '0',
remark: '',
})
const formRules = {
categoryName: [{ required: true, message: '分类名称不能为空', trigger: 'blur' }],
}
const formRef = ref<FormInstance>()
const categoryTreeOptions = ref<any[]>([])
// ── 筛选 ──
const searchForm = reactive({
categoryName: '',
status: '',
})
const quickSearchKeyword = ref('')
// ── 获取列表(树形) ──
async function getList() {
loading.value = true
try {
const params: Record<string, any> = {}
const quickKeyword = quickSearchKeyword.value?.trim()
if (quickKeyword) {
params.categoryName = quickKeyword
} else if (searchForm.categoryName) {
params.categoryName = searchForm.categoryName
}
if (searchForm.status) params.status = searchForm.status
const res = await fetchCategoryTree(params)
categoryList.value = (res as any)?.data || res || []
} finally {
loading.value = false
}
}
// ── 获取上级分类下拉树 ──
async function getCategoryTreeSelect() {
const res = await fetchCategoryTree({})
const data = (res as any)?.data || res || []
// 加一个顶级分类虚拟节点
categoryTreeOptions.value = [
{
id: '0',
categoryName: '顶级分类',
parentId: '0',
children: data,
},
]
}
// ── 筛选操作 ──
function handleSearch() {
quickSearchKeyword.value = ''
getList()
}
function handleReset() {
searchForm.categoryName = ''
searchForm.status = ''
quickSearchKeyword.value = ''
getList()
}
function handleQuickSearch() {
getList()
}
function handleQuickSearchClear() {
quickSearchKeyword.value = ''
getList()
}
// ── 表格行选中 ──
function handleSelectionChange(rows: any[]) {
selectedRows.value = rows
selectedId.value = rows.length === 1 ? rows[0]?.id : undefined
}
// ── 展开/折叠 ──
function toggleExpandAll() {
refreshTable.value = false
isExpandAll.value = !isExpandAll.value
nextTick(() => {
refreshTable.value = true
})
}
// ── 新增 ──
function handleAdd(parentRow?: any) {
resetForm()
getCategoryTreeSelect()
if (parentRow?.id) {
formData.parentId = parentRow.id
}
dialogTitle.value = '新增产品分类'
dialogVisible.value = true
}
// ── 新增子分类 ──
function handleAddChild() {
if (!selectedId.value) {
ElMessage.warning('请先选择一条分类')
return
}
const parentRow = findNodeById(categoryList.value, selectedId.value)
handleAdd(parentRow)
}
// ── 修改 ──
async function handleUpdate() {
await openEditForRow()
}
async function handleRowEdit(row: any) {
await openEditForRow(row)
}
async function openEditForRow(row?: any) {
const targetId = row?.id ?? selectedId.value
if (!targetId) {
ElMessage.warning('请选择一条数据进行修改')
return
}
resetForm()
await getCategoryTreeSelect()
const node = row ?? findNodeById(categoryList.value, targetId)
if (!node) {
ElMessage.warning('未找到选中的数据')
return
}
selectedId.value = node.id
formData.id = node.id
formData.parentId = node.parentId || ''
formData.categoryName = node.categoryName
formData.orderNum = node.orderNum ?? 0
formData.status = node.status ?? '0'
formData.remark = node.remark ?? ''
dialogTitle.value = '修改产品分类'
dialogVisible.value = true
}
// ── 删除 ──
async function handleDelete() {
if (!selectedId.value) {
ElMessage.warning('请选择一条数据进行删除')
return
}
const node = findNodeById(categoryList.value, selectedId.value)
if (!canDeleteByCreator(node)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm('是否确认删除该产品分类?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await fetchDeleteCategory({ id: selectedId.value })
ElMessage.success('删除成功')
selectedId.value = undefined
await getList()
}
// ── 提交表单 ──
async function submitForm() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
if (formData.id) {
await fetchUpdateCategory({
id: formData.id,
parentId: formData.parentId || '0',
categoryName: formData.categoryName,
orderNum: formData.orderNum,
status: formData.status,
remark: formData.remark,
})
ElMessage.success('修改成功')
} else {
await fetchCreateCategory({
parentId: formData.parentId || '0',
categoryName: formData.categoryName,
orderNum: formData.orderNum,
status: formData.status,
remark: formData.remark,
})
ElMessage.success('新增成功')
}
dialogVisible.value = false
await getList()
} finally {
submitLoading.value = false
}
}
// ── 弹窗关闭 ──
function handleDialogClose() {
resetForm()
}
// ── 重置表单 ──
function resetForm() {
formData.id = ''
formData.parentId = ''
formData.categoryName = ''
formData.orderNum = 0
formData.status = '0'
formData.remark = ''
formRef.value?.resetFields()
}
// ── 工具函数:在树中查找节点 ──
function findNodeById(tree: any[], id: string): any {
for (const node of tree) {
if (node.id === id) return node
if (node.children?.length) {
const found = findNodeById(node.children, id)
if (found) return found
}
}
return null
}
// 输入检索条件时自动搜索(防抖 300ms,对齐租户/菜单管理)
watchDebounced(
searchForm,
() => {
quickSearchKeyword.value = ''
getList()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
getList()
})
import ProductCategoryMaintainPanel from '@/components/business/product-category-maintain-panel/index.vue'
</script>
+34 -5
View File
@@ -3,10 +3,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">产品管理</h2>
</div>
<!-- 筛选区 -->
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
@@ -109,6 +105,10 @@
<el-icon><Download /></el-icon>
导出
</el-button>
<el-button class="btn-ghost" @click="categoryMaintainVisible = true">
<el-icon><Folder /></el-icon>
产品分类
</el-button>
<ListToolbarEndActions
:loading="loading"
@@ -289,6 +289,11 @@
@success="handleFormSuccess"
/>
<ProductCategoryMaintainDialog
v-model="categoryMaintainVisible"
@closed="handleCategoryMaintainClosed"
/>
<!-- 详情弹窗 -->
<SystemViewDialog
v-model:visible="detailDialogVisible"
@@ -398,13 +403,15 @@
Upload,
Top,
Bottom,
Box
Box,
Folder
} from '@element-plus/icons-vue'
import SystemViewDialog, {
type ViewField,
type ViewSection
} from '@/components/business/system-view-dialog/index.vue'
import ProductFormDialog from '@/components/business/product-form-dialog/index.vue'
import ProductCategoryMaintainDialog from '@/components/business/product-category-maintain-dialog/index.vue'
import { crmDictField, crmField, crmLongField, crmTagField } from '@/utils/crm/view-fields'
import { useDictStore } from '@/store/modules/dict'
import { formatMoneyDisplay } from '@/utils/money-format'
@@ -459,6 +466,7 @@
const productList = ref<any[]>([])
const categoryOptions = ref<any[]>([])
const loading = ref(false)
const categoryMaintainVisible = ref(false)
const listColumnDefs = [
{ prop: 'productNo', label: '产品编号' },
@@ -577,6 +585,27 @@
categoryOptions.value = (res as any)?.data || res || []
}
function findCategoryNodeById(tree: any[], id: string): any {
for (const node of tree) {
if (node.id === id) return node
if (node.children?.length) {
const found = findCategoryNodeById(node.children, id)
if (found) return found
}
}
return null
}
async function handleCategoryMaintainClosed() {
await getCategoryOptions()
const selectedId = searchForm.categoryId
if (selectedId && !findCategoryNodeById(categoryOptions.value, selectedId)) {
searchForm.categoryId = ''
pagination.page = 1
await getList()
}
}
function handleSearch() {
quickSearchKeyword.value = ''
pagination.page = 1
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,7 @@
<template>
<QuoteListPage mode="approve" />
</template>
<script setup lang="ts" name="CrmQuoteApproval">
import QuoteListPage from '../QuoteListPage.vue'
</script>
@@ -1,9 +1,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">报价单审批人设置</h2>
</div>
<div class="panel-body quote-approver-setting-body">
<el-form ref="formRef" :model="form" label-position="top" class="crm-dialog-form">
<div class="form-grid-2">
@@ -1,331 +0,0 @@
<template>
<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">
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<el-form-item label="产品名称" prop="productName">
<el-input v-model="queryParams.productName" placeholder="请输入产品名称" clearable @keyup.enter="handleQuery" />
</el-form-item>
</el-col>
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<el-form-item label="产品分类" prop="categoryId">
<el-tree-select
v-model="queryParams.categoryId"
:data="categoryTree"
placeholder="请选择产品分类"
clearable
check-strictly
:render-after-expand="false"
style="width: 100%"
/>
</el-form-item>
</el-col>
<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
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" :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>
<!-- 产品列表 -->
<div class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<ListTable table-key="crm-quote-components-product-selector"
ref="tableRef"
v-loading="loading"
:data="productList"
class="list-table"
stripe
@selection-change="handleSelectionChange"
: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="240"
align="left"
header-align="left"
class-name="col-name-wrap"
label-class-name="col-name-wrap"
/>
<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>
</ListTable>
<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>
<div class="selected-products">
<el-tag v-for="product in selectedProducts" :key="product.id" closable @close="handleRemoveSelection(product)" class="mr-2 mb-2">
{{ product.productName }}
</el-tag>
<span v-if="selectedProducts.length === 0" class="text-muted">暂无选择</span>
</div>
<template #footer>
<div class="dialog-footer">
<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>
</template>
<script setup lang="ts" name="ProductSelector">
import { useDictStore } from '@/store/modules/dict'
const dictStore = useDictStore()
import DictTag from '@/components/DictTag/index.vue'
import ListPagination from '@/components/ListPagination/index.vue'
import { $modal } from '@/utils/modal'
import { Check, Close, Refresh, Search } from '@element-plus/icons-vue'
import { ref, watch } from 'vue';
import type { ElFormInstance } from 'element-plus';
import { fetchProductList, fetchCategoryTree } from '@/api/crm/product';
// 定义组件属性
interface Props {
visible: boolean
supplierId?: string | number
}
const props = defineProps<Props>();
// 定义事件
const emit = defineEmits(['update:visible', 'confirm']);
// 对话框显示状态
const dialogVisible = ref(false);
// 监听visible变化
watch(
() => props.visible,
(val) => {
dialogVisible.value = val;
if (val) {
// 打开对话框时加载数据
getList();
loadCategoryTree();
}
}
);
// 监听dialogVisible变化
watch(dialogVisible, (val) => {
emit('update:visible', val);
});
// 查询参数
const queryParams = ref({
pageNum: 1,
pageSize: 20,
productName: undefined,
categoryId: undefined,
status: 'on' // on-上架 off-下架
});
// 产品列表
const productList = ref<any[]>([]);
const loading = ref(false);
const total = ref(0);
// 产品分类树
const categoryTree = ref<any[]>([]);
// 已选产品
const selectedProducts = ref<any[]>([]);
const queryFormRef = ref<ElFormInstance>();
const tableRef = ref<any>();
/** 查询产品列表 */
const getList = async () => {
loading.value = true;
try {
const res = await fetchProductList({
page: queryParams.value.pageNum,
pageSize: queryParams.value.pageSize,
keyword: queryParams.value.productName,
categoryId: queryParams.value.categoryId,
status: queryParams.value.status,
supplierId: props.supplierId != null && props.supplierId !== '' ? String(props.supplierId) : undefined
});
const data = (res as any)?.data || res || {};
productList.value = data.list || data.rows || [];
total.value = data.total ?? 0;
} catch (error) {
console.error('获取产品列表失败:', error);
} finally {
loading.value = false;
}
};
/** 加载产品分类树 */
const loadCategoryTree = async () => {
try {
const res = await fetchCategoryTree({});
const treeData = (res as any)?.data ?? res ?? [];
categoryTree.value = convertTreeData(treeData);
} catch (error) {
console.error('获取产品分类失败:', error);
}
};
/** 转换树形数据格式 */
const convertTreeData = (data: any[]): any[] => {
return data.map((item) => ({
value: item.id,
label: item.categoryName,
children: item.children ? convertTreeData(item.children) : undefined
}));
};
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.value.pageNum = 1;
getList();
};
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value?.resetFields();
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 handleRemoveSelection = (product: ProductVO) => {
const index = selectedProducts.value.findIndex((p) => p.id === product.id);
if (index > -1) {
selectedProducts.value.splice(index, 1);
}
};
/** 确认选择 */
const handleConfirm = () => {
emit('confirm', selectedProducts.value);
handleClose();
};
/** 关闭对话框 */
const handleClose = () => {
selectedProducts.value = [];
dialogVisible.value = false;
};
</script>
<style scoped lang="scss">
.selected-products {
min-height: 40px;
padding: 10px;
background-color: #f5f7fa;
border-radius: 4px;
}
.text-primary {
color: #409eff;
font-weight: 500;
}
.text-muted {
color: #1e293b;
}
.mr-2 {
margin-right: 8px;
}
.mb-2 {
margin-bottom: 8px;
}
.mt-3 {
margin-top: 16px;
}
.mb-3 {
margin-bottom: 16px;
}
.black-icon-btn :deep(.el-icon) {
color: #000 !important;
}
</style>
@@ -12,31 +12,22 @@
dialog-class="quote-view-dialog"
modal-class="order-module-dialog-mask"
append-to-body
@field-view="handleFieldView"
>
<template #hero-actions>
<div class="quote-view-hero-actions">
<el-button
class="btn-ghost"
@click="handleGenerate"
>
<el-button class="btn-ghost" @click="handleGenerate">
<el-icon><Document /></el-icon>生成报价单
</el-button>
<el-button
v-if="quoteInfo.customerId"
class="btn-ghost"
@click="handleViewCustomer"
>
<el-icon><User /></el-icon>查看客户
</el-button>
<el-button
v-if="quoteInfo.status === 'draft'"
v-if="manageActions && isQuoteEditableStatus(quoteInfo.status)"
class="btn-ghost"
@click="handleEdit"
>
<el-icon><Edit /></el-icon>修改
</el-button>
<el-button
v-if="quoteInfo.status === 'draft'"
v-if="manageActions && quoteInfo.status === 'draft'"
class="btn-main"
type="primary"
@click="handleSubmit"
@@ -51,15 +42,6 @@
>
<el-icon><CircleClose /></el-icon>作废
</el-button>
<el-button
v-if="quoteInfo.status !== 'invalid' && canDeleteByCreator(quoteInfo)"
class="btn-ghost"
type="danger"
:title="canDeleteByCreator(quoteInfo) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</template>
@@ -151,20 +133,6 @@
</ListTable>
</div>
</div>
<div v-if="(quoteInfo.productList?.length || 0) > 0" class="quote-product-summary">
<div class="quote-product-summary-item">
<span class="quote-product-summary-label">产品数量</span>
<strong>{{ quoteInfo.productList?.length || 0 }} 个</strong>
</div>
<div class="quote-product-summary-item">
<span class="quote-product-summary-label">产品总金额</span>
<strong class="money-display">¥{{ formatAmount(quoteInfo.totalAmount) }}</strong>
</div>
<div class="quote-product-summary-item">
<span class="quote-product-summary-label">最终金额</span>
<strong class="money-display">¥{{ formatAmount(quoteInfo.finalAmount) }}</strong>
</div>
</div>
</template>
<template #attachments>
@@ -312,11 +280,9 @@
import { computed, ref, defineAsyncComponent, nextTick } from 'vue'
import {
CircleClose,
Delete,
Document,
DocumentChecked,
Edit,
User
Edit
} from '@element-plus/icons-vue'
import SystemViewDialog, {
type ViewField,
@@ -328,13 +294,12 @@ const CustomerDetailDialog = defineAsyncComponent(
() => import('@/views/crm/customer/CustomerDetailDialog.vue')
)
import { useDictStore } from '@/store/modules/dict'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { $modal } from '@/utils/modal'
import { isQuoteEditableStatus, getQuoteReeditConfirmMessage } from '../quoteStatus'
import { applyAliasDisplayMeta, formatBrandConstraintLabel } from '@/utils/crm-product-brand'
import { parseTime } from '@/utils/format'
import {
getQuote,
delQuote,
invalidQuote,
listApproval,
type QuoteVO,
@@ -342,6 +307,14 @@ import {
} from '@/api/crm/quote'
import { fetchProductDetail } from '@/api/crm/product'
withDefaults(
defineProps<{
/** 是否展示制单类操作(修改/提交审批) */
manageActions?: boolean
}>(),
{ manageActions: true }
)
const quoteTypeMap: Record<string, { label: string; tag: 'success' | 'warning' }> = {
sales: { label: '销售', tag: 'success' },
purchase: { label: '采购', tag: 'warning' }
@@ -442,7 +415,12 @@ const viewSections = computed<ViewSection[]>(() => {
value: getQuoteTypeLabel(q.quoteType),
tagType: getQuoteTypeTag(q.quoteType)
},
{ label: labels.customerName, value: q.customerName },
{
label: labels.customerName,
value: q.customerName,
viewTarget: 'customer',
viewId: q.customerId
},
{ label: labels.contactName, value: q.contactName },
{ label: '联系手机', value: q.contactMobile },
{ label: '联系邮箱', value: q.contactEmail },
@@ -458,15 +436,22 @@ const viewSections = computed<ViewSection[]>(() => {
},
{ label: labels.quoteDate, value: q.quoteDate },
{ label: '有效期至', value: expireText },
{ label: '运费(元)', value: q.freight != null ? `¥${formatAmount(q.freight)}` : '—', money: true },
{ label: labels.ownerUser, value: q.ownerUserName },
{ label: labels.supplier, value: q.supplierName || '—' },
{ label: '创建时间', value: parseTime(q.createTime) }
]
const productTotal = Number(q.totalAmount) || 0
const freightAmount = Number(q.freight) || 0
const barrelDepositAmount = Number(q.barrelDeposit) || 0
// 最终金额 = 产品总金额 + 运费 + 桶押金
const finalAmount = productTotal + freightAmount + barrelDepositAmount
const amountFields: ViewField[] = [
{ label: '产品总金额(元)', value: `¥${formatAmount(q.totalAmount)}`, money: true },
{ label: '最终金额(元)', value: `¥${formatAmount(q.finalAmount)}`, money: true }
{ label: '最终金额(元)', value: `¥${formatAmount(finalAmount)}`, money: true },
{ label: '产品总金额(元)', value: `¥${formatAmount(productTotal)}`, money: true },
{ label: '运费(元)', value: `¥${formatAmount(freightAmount)}`, money: true },
{ label: '桶押金(元)', value: `¥${formatAmount(barrelDepositAmount)}`, money: true }
]
const sections: ViewSection[] = [
@@ -566,15 +551,27 @@ async function refresh() {
await loadDetail(currentQuoteId.value)
}
async function handleViewCustomer() {
if (!quoteInfo.value?.customerId) return
async function handleFieldView(field: ViewField) {
if (field.viewTarget !== 'customer' || !field.viewId) return
customerDialogActive.value = true
await nextTick()
customerDetailRef.value?.open(quoteInfo.value.customerId as number)
await customerDetailRef.value?.open(field.viewId)
}
function handleEdit() {
async function handleEdit() {
if (!quoteInfo.value?.id) return
if (!isQuoteEditableStatus(quoteInfo.value.status)) {
$modal.msgWarning('仅草稿、已提交、审批通过或审批失败的报价单才能修改')
return
}
const confirmMsg = getQuoteReeditConfirmMessage(quoteInfo.value.status)
if (confirmMsg) {
try {
await $modal.confirm(confirmMsg)
} catch {
return
}
}
visible.value = false
emit('edit', quoteInfo.value.id)
}
@@ -587,23 +584,6 @@ function handleSubmit() {
emit('submit', quoteInfo.value)
}
async function handleDelete() {
if (!quoteInfo.value?.id) return
if (quoteInfo.value.status !== 'draft') {
$modal.msgWarning('只能删除草稿状态的报价单')
return
}
try {
await $modal.confirm('是否确认删除该报价单?删除后将无法恢复。')
await delQuote([quoteInfo.value.id])
$modal.msgSuccess('删除成功')
visible.value = false
emit('success')
} catch {
// 用户取消
}
}
async function handleInvalid() {
if (!quoteInfo.value?.id) return
if (quoteInfo.value.status === 'invalid') {
@@ -625,29 +605,6 @@ defineExpose({ open, refresh })
</script>
<style scoped lang="scss">
.quote-product-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 12px;
padding: 12px 14px;
border: 1px solid var(--border, #d9e8e2);
border-radius: 8px;
background: var(--mint-table-head, #f8fafc);
}
.quote-product-summary-item {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: var(--mint-text);
}
.quote-product-summary-label {
font-size: 12px;
}
.quote-approval-alert {
margin-bottom: 12px;
}
@@ -50,22 +50,73 @@
</el-select>
</el-form-item>
<el-form-item :label="basicLabels.customerName" prop="customerId" required>
<el-input v-model="form.customerName" placeholder="请选择客户" readonly>
<template #append>
<el-button text @click="handleSelectCustomer">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
<div class="customer-select-row">
<el-select
v-model="form.customerId"
class="customer-select-row__select"
filterable
remote
clearable
:remote-method="searchCustomerOptions"
:loading="customerOptionsLoading"
:placeholder="`请输入${basicLabels.customerName}/拼音首字母搜索`"
@focus="handleCustomerSelectFocus"
@change="handleCustomerSelectChange"
>
<el-option
v-for="item in customerOptions"
:key="item.id"
:label="item.customerName"
:value="item.id"
>
<div class="customer-option-row">
<span>{{ item.customerName }}</span>
<span v-if="item.customerNo" class="customer-option-row__meta">{{ item.customerNo }}</span>
</div>
</el-option>
</el-select>
<el-button text class="customer-select-row__search" @click="handleSelectCustomer">
<el-icon><Search /></el-icon>
</el-button>
</div>
</el-form-item>
<el-form-item :label="basicLabels.contactName" prop="contactId" required>
<el-input v-model="form.contactName" placeholder="请选择联系人" readonly :disabled="!form.customerId">
<template #append>
<el-button text :disabled="!form.customerId" @click="handleSelectContact">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
<div class="customer-select-row">
<el-select
v-model="form.contactId"
class="customer-select-row__select"
filterable
clearable
:disabled="!form.customerId"
:loading="contactOptionsLoading"
:placeholder="form.customerId ? `请选择${basicLabels.contactName}` : '请先选择客户'"
@visible-change="handleContactSelectVisible"
@change="handleContactSelectChange"
>
<el-option
v-for="item in contactOptions"
:key="item.id"
:label="item.contactName"
:value="item.id"
>
<div class="customer-option-row">
<span>
{{ item.contactName }}
<el-tag v-if="item.isPrimary === 1" type="success" size="small" style="margin-left: 6px">主要</el-tag>
</span>
<span v-if="item.mobile" class="customer-option-row__meta">{{ item.mobile }}</span>
</div>
</el-option>
</el-select>
<el-button
text
class="customer-select-row__search"
:disabled="!form.customerId"
@click="handleSelectContact"
>
<el-icon><Search /></el-icon>
</el-button>
</div>
</el-form-item>
<el-form-item :label="basicLabels.ownerUser" prop="ownerUserId" required>
<el-select
@@ -88,6 +139,9 @@
<el-form-item label="运费(元)" prop="freight">
<el-input-number v-model="form.freight" :min="0" :step="0.01" style="width: 100%" />
</el-form-item>
<el-form-item label="桶押金(元)" prop="barrelDeposit">
<el-input-number v-model="form.barrelDeposit" :min="0" :step="0.01" style="width: 100%" />
</el-form-item>
<el-form-item :label="basicLabels.quoteDate" prop="quoteDate" required>
<el-date-picker
v-model="form.quoteDate"
@@ -171,7 +225,7 @@
class="line-meta-cell__btn"
@click="openAliasBrandManage(scope.row, 'alias')"
>
<el-icon><Setting /></el-icon>
<el-icon><Plus /></el-icon>
</el-button>
</div>
</template>
@@ -204,7 +258,7 @@
class="line-meta-cell__btn"
@click="openAliasBrandManage(scope.row, 'brand')"
>
<el-icon><Setting /></el-icon>
<el-icon><Plus /></el-icon>
</el-button>
</div>
</template>
@@ -459,6 +513,50 @@
<!-- 客户选择器 -->
<CustomerSelector :visible="customerSelectorVisible" @update:visible="customerSelectorVisible = $event" @confirm="handleCustomerConfirm" />
<!-- 下拉选客户时补填简称 -->
<el-dialog
v-model="shortNameDialogVisible"
title="填写客户简称"
width="480px"
align-center
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
append-to-body
@closed="handleShortNameDialogClosed"
>
<el-form
ref="shortNameFormRef"
:model="shortNameForm"
:rules="shortNameRules"
label-position="top"
class="crm-dialog-form"
>
<el-form-item label="客户名称">
<el-input :model-value="shortNameForm.customerName" disabled />
</el-form-item>
<el-form-item label="客户简称" prop="shortName">
<el-input
v-model="shortNameForm.shortName"
placeholder="请输入客户简称"
maxlength="100"
show-word-limit
clearable
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button class="btn-ghost" @click="shortNameDialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="shortNameSaving" @click="handleSaveShortName">
<el-icon><Check /></el-icon>确定
</el-button>
</div>
</template>
</el-dialog>
<!-- 联系人选择器 -->
<ContactSelector
:visible="contactSelectorVisible"
@@ -467,6 +565,9 @@
@confirm="handleContactConfirm"
/>
<!-- 下拉无联系人时补充 -->
<ContactFormDialog ref="contactFormRef" @success="handleContactFormSuccess" />
<!-- 商机选择器 -->
<OpportunitySelector
:visible="opportunitySelectorVisible"
@@ -528,10 +629,10 @@
<script setup lang="ts" name="CrmQuoteFormPanel">
import { useDictStore } from '@/store/modules/dict'
import { $modal } from '@/utils/modal'
import { ref, computed, onMounted, type ComponentInternalInstance } from 'vue';
import { ref, computed, nextTick, onMounted, type ComponentInternalInstance } from 'vue';
import { getQuote, addQuote, updateQuote, submitQuote, getQuoteApproverSetting } from '@/api/crm/quote';
import type { QuoteForm, QuoteProductForm } from '@/api/crm/quote';
import type { FormInstance } from 'element-plus';
import type { FormInstance, FormItemRule } from 'element-plus';
import { useRouter, useRoute } from 'vue-router';
import { useUserStore } from '@/store/modules/user';
import request from '@/utils/http';
@@ -548,7 +649,6 @@ import {
Loading,
Plus,
Search,
Setting,
UploadFilled
} from '@element-plus/icons-vue'
import ImagePreview from '@/components/ImagePreview/index.vue';
@@ -556,9 +656,11 @@ import ProductSelector from '@/components/business/product-selector/index.vue';
import ProductAliasBrandDialog from '@/components/business/product-alias-brand-dialog/index.vue'
import CustomerSelector from '@/components/CustomerSelector.vue';
import ContactSelector from '@/components/ContactSelector.vue';
import ContactFormDialog from '@/views/crm/customer/components/ContactFormDialog.vue'
import OpportunitySelector from '@/components/OpportunitySelector.vue';
import { fetchUserOptions } from '@/api/admin';
import { fetchContactsByCustomerId } from '@/api/crm/contact';
import { fetchCustomerList, fetchUpdateCustomer } from '@/api/crm/customer'
import { listAllSupplierInfo, type SupplierInfoVO } from '@/api/crm/supplierInfo';
import { filterOwnerCandidates, findDefaultOwnerUser } from '@/utils/crm-owner-candidates';
import { removeProductsNotAllowedByHazard } from '@/utils/crm-supplier-hazard'
@@ -592,14 +694,16 @@ const userStore = useUserStore()
// 临时类型声明
interface CustomerVO {
id: number;
id: string | number;
customerName: string;
customerNo?: string;
shortName?: string;
}
interface ContactVO {
id: number;
id: string | number;
contactName: string;
mobile?: string;
isPrimary?: number;
}
@@ -638,6 +742,7 @@ const createDefaultForm = (): QuoteForm => ({
supplierId: undefined,
supplierName: '',
freight: 0,
barrelDeposit: 0,
remark: defaultRemark,
products: []
})
@@ -691,6 +796,23 @@ const rules = ref({
trigger: 'change'
}
],
barrelDeposit: [
{
validator: (_rule: any, value: any, callback: any) => {
if (value === null || value === undefined || value === '') {
callback();
} else {
const numValue = Number(value);
if (isNaN(numValue) || numValue < 0) {
callback(new Error('桶押金需大于等于0'));
} else {
callback();
}
}
},
trigger: 'change'
}
],
ownerUserId: [{ required: true, message: '请选择负责人', trigger: 'change' }],
supplierId: [{ required: true, message: '请选择报价主体', trigger: 'change' }],
quoteType: [{ required: true, message: '请选择报价类型', trigger: 'change' }]
@@ -707,9 +829,286 @@ const selectedProducts = ref<QuoteProductForm[]>([]);
// 客户选择器
const customerSelectorVisible = ref(false);
const customerOptions = ref<CustomerVO[]>([]);
const customerOptionsLoading = ref(false);
/** 已确认选定的客户(用于简称弹窗取消时回滚下拉值) */
const confirmedCustomerId = ref<string | number | undefined>(undefined);
const shortNameDialogVisible = ref(false);
const shortNameSaving = ref(false);
const shortNameFormRef = ref<FormInstance>();
const shortNameForm = ref({
id: '' as string | number,
customerName: '',
shortName: ''
});
const shortNameRules: Record<string, FormItemRule[]> = {
shortName: [{ required: true, message: '客户简称不能为空', trigger: 'blur' }]
};
const pendingCustomerForShortName = ref<CustomerVO | null>(null);
const shortNameSavedSuccessfully = ref(false);
const hasCustomerShortName = (row?: { shortName?: string } | null) =>
Boolean(String(row?.shortName || '').trim());
const ensureCustomerOption = (customer?: Partial<CustomerVO> | null) => {
if (!customer?.id) return;
const exists = customerOptions.value.some((item) => String(item.id) === String(customer.id));
if (exists) {
customerOptions.value = customerOptions.value.map((item) =>
String(item.id) === String(customer.id)
? {
...item,
customerName: customer.customerName || item.customerName,
customerNo: customer.customerNo ?? item.customerNo,
shortName: customer.shortName ?? item.shortName
}
: item
);
return;
}
customerOptions.value = [
{
id: customer.id,
customerName: customer.customerName || '',
customerNo: customer.customerNo,
shortName: customer.shortName
},
...customerOptions.value
];
};
const extractCustomerList = (res: unknown): CustomerVO[] => {
const data = (res as { data?: unknown })?.data ?? res ?? {};
const list = (data as { list?: CustomerVO[]; rows?: CustomerVO[] }).list
|| (data as { rows?: CustomerVO[] }).rows
|| [];
return Array.isArray(list) ? list : [];
};
const loadDefaultCustomerOptions = async () => {
customerOptionsLoading.value = true;
try {
const res = await fetchCustomerList({ page: 1, pageSize: 20 });
customerOptions.value = extractCustomerList(res);
if (form.value.customerId) {
ensureCustomerOption({
id: form.value.customerId,
customerName: form.value.customerName,
shortName: customerShortName.value
});
}
} catch (error) {
console.error('加载客户列表失败:', error);
customerOptions.value = [];
} finally {
customerOptionsLoading.value = false;
}
};
const searchCustomerOptions = async (query: string) => {
const keyword = query?.trim();
if (!keyword) {
await loadDefaultCustomerOptions();
return;
}
customerOptionsLoading.value = true;
try {
const res = await fetchCustomerList({
customerName: keyword,
page: 1,
pageSize: 50
});
customerOptions.value = extractCustomerList(res);
if (form.value.customerId) {
ensureCustomerOption({
id: form.value.customerId,
customerName: form.value.customerName,
shortName: customerShortName.value
});
}
} catch (error) {
console.error('搜索客户失败:', error);
customerOptions.value = [];
} finally {
customerOptionsLoading.value = false;
}
};
const handleCustomerSelectFocus = () => {
if (customerOptions.value.length === 0) {
loadDefaultCustomerOptions();
}
};
const clearCustomerSelection = () => {
form.value.customerId = undefined;
form.value.customerName = '';
customerShortName.value = '';
confirmedCustomerId.value = undefined;
form.value.contactId = undefined;
form.value.contactName = '';
contactOptions.value = [];
form.value.opportunityId = undefined;
opportunityDisplayName.value = '';
updateQuoteTitleAndNo();
};
const openShortNameDialog = (customer: CustomerVO) => {
pendingCustomerForShortName.value = customer;
shortNameSavedSuccessfully.value = false;
shortNameForm.value = {
id: customer.id,
customerName: customer.customerName || '',
shortName: customer.shortName || ''
};
shortNameDialogVisible.value = true;
nextTick(() => shortNameFormRef.value?.clearValidate());
};
const handleCustomerSelectChange = async (customerId?: string | number) => {
if (!customerId) {
clearCustomerSelection();
return;
}
// 回滚到已确认客户时会再次触发 change,直接忽略
if (
confirmedCustomerId.value !== undefined
&& confirmedCustomerId.value !== null
&& String(customerId) === String(confirmedCustomerId.value)
) {
return;
}
const customer = customerOptions.value.find((item) => String(item.id) === String(customerId));
if (!customer) return;
if (!hasCustomerShortName(customer)) {
ElMessage.warning('该客户尚未配置简称,请先填写客户简称后再继续');
// 先回滚到已确认客户,待简称保存成功后再选定
form.value.customerId = confirmedCustomerId.value;
openShortNameDialog(customer);
return;
}
await handleCustomerConfirm(customer);
};
// 联系人选择器
const contactSelectorVisible = ref(false);
const contactOptions = ref<ContactVO[]>([]);
const contactOptionsLoading = ref(false);
const contactFormRef = ref<InstanceType<typeof ContactFormDialog>>();
/** 避免 visible-change 重复弹出补充联系人弹窗 */
const contactEmptyPrompted = ref(false);
const extractContactList = (res: unknown): ContactVO[] => {
const unwrapped = (res as { data?: unknown })?.data ?? res ?? [];
if (Array.isArray(unwrapped)) return unwrapped as ContactVO[];
const payload = (unwrapped as { list?: ContactVO[]; rows?: ContactVO[] }) || {};
return payload.list || payload.rows || [];
};
const ensureContactOption = (contact?: Partial<ContactVO> | null) => {
if (!contact?.id) return;
const exists = contactOptions.value.some((item) => String(item.id) === String(contact.id));
if (exists) {
contactOptions.value = contactOptions.value.map((item) =>
String(item.id) === String(contact.id)
? {
...item,
contactName: contact.contactName || item.contactName,
mobile: contact.mobile ?? item.mobile,
isPrimary: contact.isPrimary ?? item.isPrimary
}
: item
);
return;
}
contactOptions.value = [
{
id: contact.id,
contactName: contact.contactName || '',
mobile: contact.mobile,
isPrimary: contact.isPrimary
},
...contactOptions.value
];
};
const loadContactOptions = async (customerId?: string | number) => {
if (!customerId) {
contactOptions.value = [];
return [];
}
contactOptionsLoading.value = true;
try {
const res = await fetchContactsByCustomerId({ customerId });
contactOptions.value = extractContactList(res);
if (form.value.contactId) {
ensureContactOption({
id: form.value.contactId,
contactName: form.value.contactName
});
}
return contactOptions.value;
} catch (error) {
console.error('加载联系人列表失败:', error);
contactOptions.value = [];
return [];
} finally {
contactOptionsLoading.value = false;
}
};
const openContactFormDialog = () => {
if (!form.value.customerId) {
$modal.msgWarning('请先选择客户');
return;
}
contactFormRef.value?.open(undefined, form.value.customerId);
};
const handleContactSelectVisible = async (visible: boolean) => {
if (!visible) {
contactEmptyPrompted.value = false;
return;
}
if (!form.value.customerId) return;
const list = await loadContactOptions(form.value.customerId);
if (list.length === 0 && !contactEmptyPrompted.value) {
contactEmptyPrompted.value = true;
ElMessage.warning(`该客户暂无${basicLabels.value.contactName},请先补充联系人信息`);
nextTick(() => openContactFormDialog());
}
};
const handleContactSelectChange = (contactId?: string | number) => {
if (!contactId) {
form.value.contactId = undefined;
form.value.contactName = '';
return;
}
const contact = contactOptions.value.find((item) => String(item.id) === String(contactId));
if (contact) {
form.value.contactName = contact.contactName;
}
};
const handleContactFormSuccess = async (payload?: { id?: string | number }) => {
contactEmptyPrompted.value = false;
const list = await loadContactOptions(form.value.customerId);
const createdId = payload?.id;
if (createdId) {
const created = list.find((item) => String(item.id) === String(createdId));
if (created) {
handleContactConfirm(created);
return;
}
}
if (list.length === 1) {
handleContactConfirm(list[0]);
}
};
// 商机选择器
const opportunitySelectorVisible = ref(false);
@@ -851,6 +1250,7 @@ const getQuoteInfo = async (id: string | number) => {
}
// 确保运费是数字类型(后端可能返回字符串或null)
form.value.freight = Number(form.value.freight) || 0;
form.value.barrelDeposit = Number(form.value.barrelDeposit) || 0;
// 加载客户简称(从报价主题中提取,或从客户信息中获取)
// 报价主题格式:供方简称-客户简称(报价单号)
@@ -865,6 +1265,20 @@ const getQuoteInfo = async (id: string | number) => {
customerShortName.value = quoteData.customerName;
}
confirmedCustomerId.value = form.value.customerId;
ensureCustomerOption({
id: form.value.customerId,
customerName: form.value.customerName,
shortName: customerShortName.value
});
if (form.value.customerId) {
await loadContactOptions(form.value.customerId);
ensureContactOption({
id: form.value.contactId,
contactName: form.value.contactName
});
}
// 回显商机名称(如果有关联商机)
if (quoteData.opportunityId) {
// 直接使用后端返回的商机名称
@@ -943,6 +1357,7 @@ const getQuoteInfoForCopy = async (copyId: string | number) => {
form.value.supplierId = quoteData.supplierId;
form.value.supplierName = quoteData.supplierName;
form.value.freight = Number(quoteData.freight) || 0;
form.value.barrelDeposit = Number(quoteData.barrelDeposit) || 0;
form.value.remark = quoteData.remark || '';
form.value.validityDays = quoteData.validityDays || 30;
@@ -957,6 +1372,20 @@ const getQuoteInfoForCopy = async (copyId: string | number) => {
customerShortName.value = quoteData.customerName;
}
confirmedCustomerId.value = form.value.customerId;
ensureCustomerOption({
id: form.value.customerId,
customerName: form.value.customerName,
shortName: customerShortName.value
});
if (form.value.customerId) {
await loadContactOptions(form.value.customerId);
ensureContactOption({
id: form.value.contactId,
contactName: form.value.contactName
});
}
// 复制产品明细 - 将产品数量设置为0,其他信息完全复制
if (quoteData.productList && Array.isArray(quoteData.productList)) {
form.value.products = quoteData.productList.map((item: any) => ({
@@ -1007,32 +1436,33 @@ const handleSelectCustomer = () => {
/** 客户选择确认 */
const handleCustomerConfirm = async (customer: CustomerVO) => {
if (customer) {
ensureCustomerOption(customer);
form.value.customerId = customer.id;
form.value.customerName = customer.customerName;
confirmedCustomerId.value = customer.id;
// 保存客户简称(报价主题依赖简称,不可回退为客户全称)
customerShortName.value = customer.shortName?.trim() || '';
// 清空联系人和商机
form.value.contactId = undefined;
form.value.contactName = '';
contactOptions.value = [];
contactEmptyPrompted.value = false;
form.value.opportunityId = undefined;
opportunityDisplayName.value = '';
// 自动加载该客户的联系人并设置默认联系人
try {
const res = await fetchContactsByCustomerId({ customerId: customer.id });
const contacts = (res as any)?.list ?? (res as any)?.data ?? res ?? [];
if (contacts.length > 0) {
// 优先选择主要联系人,如果没有主要联系人,选择第一个
const primaryContact = contacts.find((c: ContactVO) => c.isPrimary === 1);
const defaultContact = primaryContact || contacts[0];
if (defaultContact) {
form.value.contactId = defaultContact.id;
form.value.contactName = defaultContact.contactName;
}
// 自动加载该客户的联系人并设置默认联系人;无联系人时立即引导补充
const contacts = await loadContactOptions(customer.id);
if (contacts.length > 0) {
const primaryContact = contacts.find((c) => c.isPrimary === 1);
const defaultContact = primaryContact || contacts[0];
if (defaultContact) {
form.value.contactId = defaultContact.id;
form.value.contactName = defaultContact.contactName;
}
} catch (error) {
console.error('加载联系人列表失败:', error);
// 加载失败不影响客户选择,只是不自动填充联系人
} else {
contactEmptyPrompted.value = true;
ElMessage.warning(`该客户暂无${basicLabels.value.contactName},请先补充联系人信息`);
nextTick(() => openContactFormDialog());
}
// 更新报价主题和报价单号
@@ -1040,6 +1470,46 @@ const handleCustomerConfirm = async (customer: CustomerVO) => {
}
};
const handleSaveShortName = async () => {
const valid = await shortNameFormRef.value?.validate().catch(() => false);
if (!valid) return;
shortNameSaving.value = true;
try {
const trimmedShortName = shortNameForm.value.shortName.trim();
await fetchUpdateCustomer({
id: shortNameForm.value.id,
shortName: trimmedShortName
});
ElMessage.success('客户简称保存成功');
const pending = pendingCustomerForShortName.value;
const customer: CustomerVO = {
id: shortNameForm.value.id,
customerName: shortNameForm.value.customerName || pending?.customerName || '',
customerNo: pending?.customerNo,
shortName: trimmedShortName
};
ensureCustomerOption(customer);
shortNameSavedSuccessfully.value = true;
shortNameDialogVisible.value = false;
await handleCustomerConfirm(customer);
} catch (error) {
console.error('保存客户简称失败:', error);
ElMessage.error('保存客户简称失败');
} finally {
shortNameSaving.value = false;
}
};
const handleShortNameDialogClosed = () => {
pendingCustomerForShortName.value = null;
if (!shortNameSavedSuccessfully.value) {
form.value.customerId = confirmedCustomerId.value;
}
shortNameSavedSuccessfully.value = false;
};
/** 打开联系人选择器 */
const handleSelectContact = () => {
if (!form.value.customerId) {
@@ -1052,6 +1522,7 @@ const handleSelectContact = () => {
/** 联系人选择确认 */
const handleContactConfirm = (contact: ContactVO) => {
if (contact) {
ensureContactOption(contact);
form.value.contactId = contact.id;
form.value.contactName = contact.contactName;
}
@@ -1071,6 +1542,11 @@ const handleOpportunityConfirm = (opportunity: OpportunityVO) => {
if (opportunity.customerId && !form.value.customerId) {
form.value.customerId = opportunity.customerId;
form.value.customerName = opportunity.customerName;
confirmedCustomerId.value = opportunity.customerId;
ensureCustomerOption({
id: opportunity.customerId,
customerName: opportunity.customerName || ''
});
}
}
};
@@ -1818,6 +2294,12 @@ const handleConfirmApproval = async () => {
const resetPanelState = () => {
form.value = createDefaultForm();
customerShortName.value = '';
confirmedCustomerId.value = undefined;
customerOptions.value = [];
contactOptions.value = [];
contactEmptyPrompted.value = false;
pendingCustomerForShortName.value = null;
shortNameDialogVisible.value = false;
opportunityDisplayName.value = '';
attachmentList.value = [];
selectedProducts.value = [];
@@ -2281,6 +2763,37 @@ onMounted(async () => {
width: 100%;
}
.customer-select-row {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
}
.customer-select-row__select {
flex: 1;
min-width: 0;
}
.customer-select-row__search {
flex-shrink: 0;
padding: 0 8px;
}
.customer-option-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
min-width: 0;
}
.customer-option-row__meta {
flex-shrink: 0;
font-size: 12px;
color: var(--mint-text-muted, #8492a6);
}
/* 去除输入框后置按钮的边框和背景 */
:deep(.el-input-group__append) {
background: transparent !important;
@@ -207,6 +207,8 @@ const handleSubmit = async () => {
customerId: props.quoteInfo!.customerId,
contractType: 'sale',
amount: props.quoteInfo!.finalAmount || props.quoteInfo!.totalAmount,
freight: Number(props.quoteInfo!.freight) || 0,
barrelDeposit: Number(props.quoteInfo!.barrelDeposit) || 0,
signDate: form.signDate,
startDate: form.startDate,
endDate: form.endDate,
+25 -5
View File
@@ -2,7 +2,6 @@
<div class="page-container">
<section class="panel" v-loading="loading">
<div class="panel-top panel-top-with-actions">
<h2 class="title-bar-heading">报价单详情</h2>
<div class="detail-toolbar">
<el-button link type="primary" @click="goBack">
<el-icon><Back /></el-icon>返回列表
@@ -10,7 +9,12 @@
<el-button link type="primary" @click="handleGenerateQuote">
<el-icon><Document /></el-icon>生成报价单
</el-button>
<el-button v-if="quote?.status === 'draft'" link type="primary" @click="handleEdit">
<el-button
v-if="isQuoteEditableStatus(quote?.status)"
link
type="primary"
@click="handleEdit"
>
<el-icon><Edit /></el-icon>修改
</el-button>
<el-button v-if="quote?.status === 'draft'" link type="primary" @click="handleSubmit">
@@ -64,6 +68,9 @@
<el-descriptions-item label="运费(元)">
<span>¥{{ formatAmount(quote?.freight) }}</span>
</el-descriptions-item>
<el-descriptions-item label="桶押金(元)">
<span>¥{{ formatAmount(quote?.barrelDeposit) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="detailLabels.ownerUser">{{ quote?.ownerUserName }}</el-descriptions-item>
<el-descriptions-item :label="detailLabels.supplier">{{ quote?.supplierName || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ parseTime(quote?.createTime) }}</el-descriptions-item>
@@ -391,6 +398,7 @@ import ListPagination from '@/components/ListPagination/index.vue'
import { $modal } from '@/utils/modal'
import { formatBrandForPrint } from '@/utils/crm-product-brand'
import { canDeleteByCreator } from '@/utils/creator-delete'
import { isQuoteEditableStatus, getQuoteReeditConfirmMessage } from './quoteStatus'
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Back, CircleCheck, Close, Delete, Document, Download, Edit, Printer } from '@element-plus/icons-vue'
@@ -518,12 +526,24 @@ const goBack = () => {
};
/** 编辑 */
const handleEdit = () => {
const handleEdit = async () => {
if (!isQuoteEditableStatus(quote.value?.status)) {
$modal.msgWarning('仅草稿、已提交、审批通过或审批失败的报价单才能修改')
return
}
const confirmMsg = getQuoteReeditConfirmMessage(quote.value?.status)
if (confirmMsg) {
try {
await $modal.confirm(confirmMsg)
} catch {
return
}
}
router.push({
path: '/crm/quote/form',
query: { id: quote.value?.id }
});
};
})
}
/** 删除报价单 */
const handleDelete = async () => {
+2 -10
View File
@@ -2,7 +2,6 @@
<div class="page-container">
<section class="panel">
<div class="panel-top panel-top-with-actions">
<h2 class="title-bar-heading">{{ pageTitle }}</h2>
<el-button class="btn-ghost" @click="handleBack">
<el-icon><Back /></el-icon>返回列表
</el-button>
@@ -13,21 +12,14 @@
</template>
<script setup lang="ts" name="CrmQuoteForm">
import { computed, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { Back } from '@element-plus/icons-vue'
import QuoteFormPanel from './components/QuoteFormPanel.vue'
const router = useRouter()
const route = useRoute()
const panelRef = ref<InstanceType<typeof QuoteFormPanel> | null>(null)
const pageTitle = computed(() => {
if (route.query.id) return '编辑报价单'
if (route.query.copyId) return '复制新增报价单'
return '新建报价单'
})
const handleBack = () => {
router.back()
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,20 @@
/** 允许打开编辑的报价单状态(保存后回草稿) */
export const QUOTE_EDITABLE_STATUSES = ['draft', 'submitted', 'accepted', 'rejected'] as const
export type QuoteEditableStatus = (typeof QUOTE_EDITABLE_STATUSES)[number]
export function isQuoteEditableStatus(status?: string | null): boolean {
return QUOTE_EDITABLE_STATUSES.includes((status || '').trim() as QuoteEditableStatus)
}
/** 已提交 / 审批通过 / 审批失败打开编辑前的确认文案;草稿返回 null(无需确认) */
export function getQuoteReeditConfirmMessage(status?: string | null): string | null {
const s = (status || '').trim()
if (s === 'submitted') {
return '编辑保存后将作废当前审批流,报价单变回草稿,需重新提交审批'
}
if (s === 'accepted' || s === 'rejected') {
return '编辑保存后报价单将变回草稿,需重新提交审批'
}
return null
}
@@ -2,7 +2,6 @@
<div class="page-container">
<section class="panel" v-loading="loading">
<div class="panel-top panel-top-with-actions">
<h2 class="title-bar-heading">报价单统计分析</h2>
<el-button class="btn-ghost" @click="goBack">
<el-icon><Back /></el-icon>返回列表
</el-button>
@@ -2,10 +2,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">主体危化品能力</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
@@ -2,9 +2,6 @@
<template>
<div class="console-container console-lite">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">Demo 模板页</h2>
</div>
<div class="console-lite__body">
<p class="console-lite__hint">
以下页面为 Fresh Mint UI 参考模板,数据为本地 Mock,不影响业务模块。详见
@@ -1,10 +1,6 @@
<template>
<div class="page-container db-page">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">仪表盘</h2>
</div>
<div class="db-dashboard-body" v-loading="loading">
<div class="db-dashboard-stats">
<div v-for="stat in statsList" :key="stat.title" class="db-stat-card">
+1 -5
View File
@@ -1,12 +1,8 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">地磅出入库</h2>
</div>
<div class="filter-block">
<el-form :model="filters" class="filter-form" @submit.prevent>
<el-form :model="filters" class="filter-form" @submit.prevent style="--filter-label-width: 80px">
<div class="filter-grid">
<el-form-item label="日期范围">
<el-date-picker
-4
View File
@@ -1,10 +1,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">地磅计划</h2>
</div>
<div class="filter-block">
<el-form :model="filters" class="filter-form" @submit.prevent>
<div class="filter-grid">
+1 -5
View File
@@ -1,12 +1,8 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">地磅单管理</h2>
</div>
<div class="filter-block">
<el-form :model="filters" class="filter-form" @submit.prevent>
<el-form :model="filters" class="filter-form" @submit.prevent style="--filter-label-width: 80px">
<div class="filter-grid">
<el-form-item label="日期范围">
<el-date-picker
@@ -3,10 +3,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">分类列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
-4
View File
@@ -22,10 +22,6 @@
</div>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">订单列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" label-position="top">
<p class="filter-hint">支持按订单状态、配送状态、支付状态等维度快速筛选。</p>
@@ -3,9 +3,6 @@
<template>
<div class="page-container">
<div class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">商品列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
-4
View File
@@ -3,10 +3,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">单位列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
+3 -1
View File
@@ -3,7 +3,9 @@
<div v-if="isPortalLayout" class="app-layout app-layout-portal" :style="appLayoutStyle">
<div id="app-portal-header">
<ArtPortalHeader />
<ArtWorkTab />
<div id="app-worktab">
<ArtWorkTab />
</div>
</div>
<div id="app-portal-body">
Loaded 100 of 388 files, more files were not shown because too many files have changed in this diff. Show more