提交
This commit is contained in:
1 parent
2df2ef0808
commit
683f14828f
388 files changed
+2738488
-81306
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
>
|
||||
|
||||
/** 合同审批人设置 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* 筛选栅格:表单项撑满列宽,控件全宽 */
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/** 根据文件名/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 {
|
||||
|
||||
@@ -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('下载失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
@@ -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> 元 · 发票
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,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,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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
? '含税、客户自提结算以实际到货吨位为准'
|
||||
: '含税、含运费、装卸费(一票制) 结算以实际到货吨位为准';
|
||||
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>
|
||||
@@ -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)
|
||||
? '含税、客户自提结算以实际到货吨位为准'
|
||||
: '含税、含运费、装卸费(一票制) 结算以实际到货吨位为准';
|
||||
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 {
|
||||
|
||||
@@ -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,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 = () => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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
Reference in new issue
Block a user