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

No files matched your search

+121 -71
View File
@@ -9,34 +9,28 @@
:close-on-click-modal="false"
append-to-body
>
<!-- 查询表单 -->
<el-form :model="queryParams" ref="queryFormRef" :inline="true" class="mb-3 search-form-inline">
<el-form-item label="客户名称" prop="customerName">
<el-input v-model="queryParams.customerName" placeholder="请输入客户名称" clearable @keyup.enter="handleQuery" style="width: 180px" />
</el-form-item>
<el-form-item label="客户编号" prop="customerNo">
<el-input v-model="queryParams.customerNo" placeholder="请输入客户编号" clearable @keyup.enter="handleQuery" style="width: 150px" />
</el-form-item>
<el-form-item label="客户类型" prop="customerType">
<el-select v-model="queryParams.customerType" placeholder="请选择客户类型" clearable style="width: 150px">
<el-option v-for="dict in dictStore.getOptions('crm_customer_type')" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item label="手机号码" prop="mobile">
<el-input v-model="queryParams.mobile" placeholder="请输入手机号码" clearable @keyup.enter="handleQuery" style="width: 150px" />
</el-form-item>
<el-form-item class="form-buttons-right">
<el-button type="primary" :loading="loading" @click="handleQuery">
<el-icon><Search /></el-icon>搜索
</el-button>
<el-button :loading="loading" @click="resetQuery">
<el-icon><Refresh /></el-icon>重置
</el-button>
</el-form-item>
</el-form>
<!-- 客户列表 -->
<div class="panel dialog-list-panel">
<div class="list-toolbar">
<div class="list-toolbar-row customer-selector-toolbar-row">
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="客户名称/拼音首字母"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button class="btn-main" type="primary" @click="handleAddCustomer">
<el-icon><Plus /></el-icon>新增客户
</el-button>
<el-button class="btn-ghost" :disabled="!selectedRow" @click="handleEditCustomer">
<el-icon><Edit /></el-icon>编辑客户
</el-button>
<el-button class="btn-ghost" :disabled="!selectedRow" @click="handleMaintainInvoice">
<el-icon><Stamp /></el-icon>维护开票信息
</el-button>
</div>
</div>
<div class="list-body list-wrap dialog-list-body-only">
<el-table
ref="tableRef"
@@ -179,14 +173,41 @@
</div>
</template>
</el-dialog>
<CustomerFormDialog ref="customerFormRef" @success="handleCustomerFormSuccess" />
<!-- 维护开票信息(一客户多条 + 默认;关闭后不自动回填外层,需重新「确定」选用客户) -->
<el-dialog
v-model="invoiceDialogVisible"
:title="invoiceDialogTitle"
width="1200px"
align-center
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
append-to-body
destroy-on-close
>
<InvoiceInfo v-if="invoiceCustomerId" :customer-id="invoiceCustomerId" />
<template #footer>
<div class="dialog-footer">
<el-button class="btn-ghost" @click="invoiceDialogVisible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="CustomerSelector">
import { useDictStore } from '@/store/modules/dict'
import DictTag from '@/components/DictTag/index.vue'
import ListPagination from '@/components/ListPagination/index.vue'
import { Check, Close, Edit, Refresh, Search } from '@element-plus/icons-vue'
import { ref, watch } from 'vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import CustomerFormDialog from '@/views/crm/customer/CustomerFormDialog.vue'
import InvoiceInfo from '@/views/crm/customer/components/InvoiceInfo.vue'
import { Check, Close, Edit, Plus, Stamp } from '@element-plus/icons-vue'
import { computed, nextTick, ref, watch } from 'vue'
import type { ElFormInstance, FormItemRule } from 'element-plus'
import { fetchCustomerList, fetchUpdateCustomer } from '@/api/crm/customer'
@@ -207,6 +228,11 @@ watch(
dialogVisible.value = val
if (val) {
await dictStore.loadAllDict()
quickSearchKeyword.value = ''
queryParams.value.pageNum = 1
queryParams.value.keyword = undefined
selectedRow.value = null
currentCustomer.value = null
getList()
}
}
@@ -219,18 +245,19 @@ watch(dialogVisible, (val) => {
const queryParams = ref<Record<string, any>>({
pageNum: 1,
pageSize: 20,
customerName: undefined,
customerNo: undefined,
customerType: undefined,
mobile: undefined
keyword: undefined as string | undefined
})
const quickSearchKeyword = ref('')
const customerList = ref<any[]>([])
const loading = ref(false)
const total = ref(0)
/** 表格当前高亮行(可无简称,用于编辑) */
const selectedRow = ref<any | null>(null)
/** 可确认选用的客户(须已填简称) */
const currentCustomer = ref<any | null>(null)
const queryFormRef = ref<ElFormInstance>()
const tableRef = ref<any>()
const customerFormRef = ref<InstanceType<typeof CustomerFormDialog>>()
const shortNameDialogVisible = ref(false)
const shortNameSaving = ref(false)
@@ -252,10 +279,7 @@ const getList = async () => {
const res = await fetchCustomerList({
page: queryParams.value.pageNum,
pageSize: queryParams.value.pageSize,
customerName: queryParams.value.customerName,
customerNo: queryParams.value.customerNo,
customerType: queryParams.value.customerType,
mobile: queryParams.value.mobile
keyword: queryParams.value.keyword || undefined
})
customerList.value = res.list || res.rows || []
total.value = res.total ?? 0
@@ -266,38 +290,61 @@ const getList = async () => {
}
}
const handleQuery = () => {
const handleQuickSearch = () => {
queryParams.value.pageNum = 1
queryParams.value.keyword = quickSearchKeyword.value?.trim() || undefined
getList()
}
const resetQuery = () => {
queryFormRef.value?.resetFields()
handleQuery()
const handleQuickSearchClear = () => {
quickSearchKeyword.value = ''
queryParams.value.pageNum = 1
queryParams.value.keyword = undefined
getList()
}
watchDebounced(
() => ({
customerName: queryParams.value.customerName,
customerNo: queryParams.value.customerNo,
customerType: queryParams.value.customerType,
mobile: queryParams.value.mobile
}),
() => {
queryParams.value.pageNum = 1
getList()
},
{ debounce: 300, deep: true }
const handleAddCustomer = () => {
customerFormRef.value?.open()
}
const handleEditCustomer = () => {
if (!selectedRow.value?.id) {
ElMessage.warning('请先选择一条客户记录')
return
}
customerFormRef.value?.open(selectedRow.value.id)
}
const invoiceDialogVisible = ref(false)
const invoiceCustomerId = ref<string | number>('')
const invoiceCustomerName = ref('')
const invoiceDialogTitle = computed(() =>
invoiceCustomerName.value
? `维护开票信息 — ${invoiceCustomerName.value}`
: '维护开票信息'
)
const handleMaintainInvoice = () => {
if (!selectedRow.value?.id) {
ElMessage.warning('请先选择一条客户记录')
return
}
invoiceCustomerId.value = selectedRow.value.id
invoiceCustomerName.value = selectedRow.value.customerName || ''
invoiceDialogVisible.value = true
}
const handleCustomerFormSuccess = () => {
getList()
}
const handleCurrentChange = (row: any) => {
selectedRow.value = row || null
if (!row) {
currentCustomer.value = null
return
}
if (!hasShortName(row)) {
ElMessage.warning('该客户尚未填写简称,请先填写客户简称后再选择')
tableRef.value?.setCurrentRow(undefined)
currentCustomer.value = null
return
}
@@ -332,8 +379,11 @@ const handleSaveShortName = async () => {
if (updatedRow) {
updatedRow.shortName = trimmedShortName
}
if (currentCustomer.value?.id === targetId) {
currentCustomer.value = { ...currentCustomer.value, shortName: trimmedShortName }
if (selectedRow.value?.id === targetId) {
selectedRow.value = { ...selectedRow.value, shortName: trimmedShortName }
}
if (currentCustomer.value?.id === targetId || (selectedRow.value?.id === targetId && hasShortName(selectedRow.value))) {
currentCustomer.value = { ...(selectedRow.value || currentCustomer.value), shortName: trimmedShortName }
}
shortNameDialogVisible.value = false
@@ -346,7 +396,12 @@ const handleSaveShortName = async () => {
}
const handleConfirm = () => {
if (!currentCustomer.value) return
if (!currentCustomer.value) {
if (selectedRow.value && !hasShortName(selectedRow.value)) {
ElMessage.warning('该客户尚未填写简称,请先填写客户简称后再选择')
}
return
}
if (!hasShortName(currentCustomer.value)) {
ElMessage.warning('该客户尚未填写简称,请先填写客户简称后再选择')
return
@@ -356,6 +411,7 @@ const handleConfirm = () => {
}
const handleClose = () => {
selectedRow.value = null
currentCustomer.value = null
dialogVisible.value = false
}
@@ -368,20 +424,14 @@ const handleClose = () => {
border-radius: 4px;
}
.mb-3 {
margin-bottom: 16px;
/* 弹窗 append-to-body 后不在 .page-container 内,需自行限制检索框宽度,避免独占一行 */
.customer-selector-toolbar-row {
flex-wrap: nowrap;
}
.mt-3 {
margin-top: 16px;
}
.search-form-inline {
display: flex;
flex-wrap: wrap;
}
.form-buttons-right {
margin-left: auto !important;
.customer-selector-toolbar-row :deep(.quick-search-input) {
width: 280px;
max-width: 280px;
flex: 0 0 280px;
}
</style>
@@ -93,15 +93,19 @@
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="contractName"
label="合同名称"
min-width="180"
align="center"
header-align="center"
show-overflow-tooltip
/>
>
<template #default="{ row }">
<el-link
v-if="row.contractNo"
type="primary"
:underline="false"
@click.stop="handleViewContract(row)"
>
{{ row.contractNo }}
</el-link>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column
prop="customerName"
label="客户名称"
@@ -110,6 +114,20 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="产品信息" min-width="280" align="left" header-align="center">
<template #default="{ row }">
<div v-if="row.products?.length" class="contract-selector-products">
<div
v-for="(product, index) in row.products"
:key="product.id || `${row.id}-${index}`"
class="contract-selector-products__line"
>
{{ formatContractProductLine(product) }}
</div>
</div>
<span v-else class="contract-selector-products__empty">—</span>
</template>
</el-table-column>
<el-table-column prop="contractType" label="合同类型" min-width="110" align="center" header-align="center">
<template #default="{ row }">
<DictTag dict-type="crm_contract_type" :value="row.contractType" />
@@ -167,18 +185,25 @@
</div>
</template>
</el-dialog>
<ContractDetailDialog v-if="contractDetailDialogActive" ref="contractDetailRef" hide-product-price />
</template>
<script setup lang="ts">
import { Check, Close, RefreshLeft, Search } from '@element-plus/icons-vue'
import type { FormInstance, TableInstance } from 'element-plus'
import { getContract, listContract } from '@/api/crm/contract'
import type { ContractQuery, ContractVO } from '@/api/crm/contract/types'
import type { ContractProductForm, ContractQuery, ContractVO } from '@/api/crm/contract/types'
import { planApi } from '@/api/pl'
import DictTag from '@/components/DictTag/index.vue'
import ListPagination from '@/components/ListPagination/index.vue'
import { useDictStore } from '@/store/modules/dict'
import { formatMoneyDisplay } from '@/utils/money-format'
import { formatPlanProductListText } from '@/utils/pl-display'
const ContractDetailDialog = defineAsyncComponent(
() => import('@/views/crm/contract/components/ContractDetailDialog.vue')
)
const props = withDefaults(
defineProps<{
@@ -214,6 +239,8 @@ const contractList = ref<ContractVO[]>([])
const currentRow = ref<ContractVO | null>(null)
const selectedId = ref('')
const planCountMap = ref<Record<string, number>>({})
const contractDetailDialogActive = ref(false)
const contractDetailRef = ref<InstanceType<typeof ContractDetailDialog>>()
const queryParams = reactive<ContractQuery>({
pageNum: 1,
@@ -221,7 +248,8 @@ const queryParams = reactive<ContractQuery>({
contractNo: '',
contractName: '',
customerName: '',
excludeStatus: 'invalid'
excludeStatus: 'invalid',
includeProducts: true
})
const selectableStatusHint = computed(() => {
@@ -233,6 +261,30 @@ const selectableStatusHint = computed(() => {
return `仅「${labels.join('」「')}」状态的合同可被选择,其他状态仅展示不可选`
})
function formatContractProductLine(product: ContractProductForm) {
return formatPlanProductListText(
{
productName: product.productName,
model: product.model || product.productModel,
unit: product.unit,
quantity: product.quantity
},
unit => dictStore.getLabel('crm_product_unit', unit),
{ usePlannedQuantity: true }
)
}
async function handleViewContract(row: ContractVO) {
const contractId = row?.id
if (!contractId) {
ElMessage.warning('合同信息无效')
return
}
contractDetailDialogActive.value = true
await nextTick()
contractDetailRef.value?.open(contractId)
}
function isContractSelectable(row: ContractVO): boolean {
if (!props.selectableStatuses?.length) return true
return props.selectableStatuses.includes(row.status)
@@ -477,6 +529,30 @@ async function handleConfirm() {
}
}
.contract-selector-products {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
width: 100%;
text-align: left;
}
.contract-selector-products__line {
width: 100%;
min-width: 0;
font-size: 13px;
line-height: 1.5;
color: var(--text, #1e293b);
white-space: normal;
word-break: break-word;
overflow-wrap: anywhere;
}
.contract-selector-products__empty {
color: var(--mint-text-muted, #94a3b8);
}
.sr-only {
position: absolute;
width: 1px;
@@ -7,26 +7,37 @@
:title="item.fullLabel"
>
<div v-if="item.url" class="pl-carrier-list-attachment-cell__thumb">
<el-image
v-if="item.isImage"
class="pl-carrier-list-attachment-cell__image"
fit="cover"
lazy
:src="displaySrc(item.url)"
:preview-src-list="mediaActive ? [resolvePhotoDisplayUrl(item.url)] : []"
preview-teleported
hide-on-click-modal
<div
class="pl-carrier-list-attachment-cell__preview"
:title="item.isImage || item.isPdf ? '点击在新标签页查看' : '点击查看'"
@click.stop="handlePreview(item)"
>
<template #error>
<div class="pl-carrier-list-attachment-cell__placeholder">
<el-icon :size="20" color="#94a3b8"><Document /></el-icon>
</div>
</template>
</el-image>
<div v-else class="pl-carrier-list-attachment-cell__placeholder">
<el-icon :size="20" color="#94a3b8"><Document /></el-icon>
<span v-if="item.ext" class="pl-carrier-list-attachment-cell__ext">{{ item.ext }}</span>
<el-image
v-if="item.isImage"
class="pl-carrier-list-attachment-cell__image pl-carrier-list-attachment-cell__image--passive"
fit="cover"
lazy
:src="displaySrc(item.url)"
:preview-src-list="[]"
>
<template #error>
<div class="pl-carrier-list-attachment-cell__placeholder">
<el-icon :size="20" color="#94a3b8"><Document /></el-icon>
</div>
</template>
</el-image>
<div v-else class="pl-carrier-list-attachment-cell__placeholder is-clickable">
<el-icon :size="20" color="#0284c7"><Document /></el-icon>
<span v-if="item.ext" class="pl-carrier-list-attachment-cell__ext">{{ item.ext }}</span>
</div>
</div>
<el-icon
class="pl-carrier-list-attachment-cell__download"
title="下载"
@click.stop="handleDownload(item)"
>
<Download />
</el-icon>
<span class="pl-carrier-list-attachment-cell__caption">{{ item.label }}</span>
</div>
<div v-else class="pl-carrier-list-attachment-cell__empty">
@@ -39,9 +50,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Document } from '@element-plus/icons-vue'
import { Document, Download } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { Carrier } from '@/api/pl'
import { normalizeAttachmentUrls } from '@/utils/download-attachment'
import {
downloadAttachmentFile,
normalizeAttachmentUrls,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
const props = withDefaults(
@@ -65,6 +81,7 @@
fullLabel: string
url: string
isImage: boolean
isPdf: boolean
ext: string
}
@@ -84,6 +101,13 @@
return /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(path)
}
function isPdfUrl(url: string) {
const path = String(url || '')
.split('?')[0]
.toLowerCase()
return /\.pdf$/i.test(path)
}
function getFileExt(url: string) {
const name =
String(url || '')
@@ -95,11 +119,47 @@
return name.slice(lastDot + 1).toUpperCase()
}
function fileNameFromUrl(url: string) {
return (
String(url || '')
.split('?')[0]
.split('/')
.pop() || '附件'
)
}
function displaySrc(url: string) {
if (!props.mediaActive) return ''
return resolvePhotoDisplayUrl(url)
}
async function handlePreview(item: ItemDef) {
if (!item.url) return
await openAttachmentPreviewInNewTab(
{ fileUrl: item.url, fileName: fileNameFromUrl(item.url) },
{
fullFileUrl: resolvePhotoDisplayUrl,
kind: item.isImage ? 'image' : item.isPdf ? 'pdf' : 'other'
}
)
}
async function handleDownload(item: ItemDef) {
if (!item.url) {
ElMessage.error('无法获取文件下载地址')
return
}
try {
await downloadAttachmentFile(
{ fileUrl: item.url, fileName: fileNameFromUrl(item.url) },
{ fullFileUrl: resolvePhotoDisplayUrl }
)
ElMessage.success('下载成功')
} catch {
ElMessage.error('下载失败,请稍后重试')
}
}
const items = computed<ItemDef[]>(() => {
const defs = [
{
@@ -132,6 +192,7 @@
fullLabel: def.fullLabel,
url: '',
isImage: false,
isPdf: false,
ext: ''
})
continue
@@ -143,6 +204,7 @@
fullLabel: def.fullLabel,
url,
isImage: isImageUrl(url),
isPdf: isPdfUrl(url),
ext: getFileExt(url)
})
})
@@ -186,10 +248,20 @@
font-size: 12px;
}
.pl-carrier-list-attachment-cell__preview {
width: 100%;
height: 100%;
cursor: pointer;
}
.pl-carrier-list-attachment-cell__image {
width: 100%;
height: 100%;
cursor: pointer;
&--passive {
pointer-events: none;
}
}
.pl-carrier-list-attachment-cell__placeholder {
@@ -201,12 +273,37 @@
width: 100%;
height: 100%;
margin-bottom: 14px;
&.is-clickable {
background: #e0f2fe;
}
}
.pl-carrier-list-attachment-cell__ext {
font-size: 10px;
line-height: 1.2;
color: #64748b;
font-weight: 600;
color: #0284c7;
}
.pl-carrier-list-attachment-cell__download {
position: absolute;
right: 2px;
top: 2px;
z-index: 2;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
border: 1px solid #e2e8f0;
color: #1e293b;
cursor: pointer;
&:hover {
color: #1ca851;
border-color: #1ca851;
background: #e6f8ed;
}
}
.pl-carrier-list-attachment-cell__empty-text {
@@ -7,26 +7,37 @@
:title="item.fullLabel"
>
<div v-if="item.url" class="pl-cert-list-attachment-cell__thumb">
<el-image
v-if="item.isImage"
class="pl-cert-list-attachment-cell__image"
fit="cover"
lazy
:src="displaySrc(item.url)"
:preview-src-list="mediaActive ? [resolvePhotoDisplayUrl(item.url)] : []"
preview-teleported
hide-on-click-modal
<div
class="pl-cert-list-attachment-cell__preview"
:title="item.isImage || item.isPdf ? '点击在新标签页查看' : '点击查看'"
@click.stop="handlePreview(item)"
>
<template #error>
<div class="pl-cert-list-attachment-cell__placeholder">
<el-icon :size="20" color="#94a3b8"><Document /></el-icon>
</div>
</template>
</el-image>
<div v-else class="pl-cert-list-attachment-cell__placeholder">
<el-icon :size="20" color="#94a3b8"><Document /></el-icon>
<span v-if="item.ext" class="pl-cert-list-attachment-cell__ext">{{ item.ext }}</span>
<el-image
v-if="item.isImage"
class="pl-cert-list-attachment-cell__image pl-cert-list-attachment-cell__image--passive"
fit="cover"
lazy
:src="displaySrc(item.url)"
:preview-src-list="[]"
>
<template #error>
<div class="pl-cert-list-attachment-cell__placeholder">
<el-icon :size="20" color="#94a3b8"><Document /></el-icon>
</div>
</template>
</el-image>
<div v-else class="pl-cert-list-attachment-cell__placeholder is-clickable">
<el-icon :size="20" color="#0284c7"><Document /></el-icon>
<span v-if="item.ext" class="pl-cert-list-attachment-cell__ext">{{ item.ext }}</span>
</div>
</div>
<el-icon
class="pl-cert-list-attachment-cell__download"
title="下载"
@click.stop="handleDownload(item)"
>
<Download />
</el-icon>
<span class="pl-cert-list-attachment-cell__caption">{{ item.label }}</span>
</div>
<div v-else class="pl-cert-list-attachment-cell__empty">
@@ -40,9 +51,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Document } from '@element-plus/icons-vue'
import { Document, Download } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { CertRead } from '@/api/pl'
import { normalizeAttachmentUrls } from '@/utils/download-attachment'
import {
downloadAttachmentFile,
normalizeAttachmentUrls,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
import { PL_VEHICLE_CERT_PART_TRAILER, normalizeVehicleCertPart } from '@/utils/pl-vehicle-cert'
@@ -67,6 +83,7 @@
fullLabel: string
url: string
isImage: boolean
isPdf: boolean
ext: string
}
@@ -93,6 +110,13 @@
return /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(path)
}
function isPdfUrl(url: string) {
const path = String(url || '')
.split('?')[0]
.toLowerCase()
return /\.pdf$/i.test(path)
}
function getFileExt(url: string) {
const name =
String(url || '')
@@ -104,11 +128,47 @@
return name.slice(lastDot + 1).toUpperCase()
}
function fileNameFromUrl(url: string) {
return (
String(url || '')
.split('?')[0]
.split('/')
.pop() || '附件'
)
}
function displaySrc(url: string) {
if (!props.mediaActive) return ''
return resolvePhotoDisplayUrl(url)
}
async function handlePreview(item: ItemDef) {
if (!item.url) return
await openAttachmentPreviewInNewTab(
{ fileUrl: item.url, fileName: fileNameFromUrl(item.url) },
{
fullFileUrl: resolvePhotoDisplayUrl,
kind: item.isImage ? 'image' : item.isPdf ? 'pdf' : 'other'
}
)
}
async function handleDownload(item: ItemDef) {
if (!item.url) {
ElMessage.error('无法获取文件下载地址')
return
}
try {
await downloadAttachmentFile(
{ fileUrl: item.url, fileName: fileNameFromUrl(item.url) },
{ fullFileUrl: resolvePhotoDisplayUrl }
)
ElMessage.success('下载成功')
} catch {
ElMessage.error('下载失败,请稍后重试')
}
}
const items = computed<ItemDef[]>(() => {
const certs = props.certList || []
if (!certs.length) return []
@@ -134,6 +194,7 @@
fullLabel,
url: '',
isImage: false,
isPdf: false,
ext: ''
})
return
@@ -146,6 +207,7 @@
fullLabel,
url,
isImage: isImageUrl(url),
isPdf: isPdfUrl(url),
ext: getFileExt(url)
})
})
@@ -189,10 +251,20 @@
font-size: 12px;
}
.pl-cert-list-attachment-cell__preview {
width: 100%;
height: 100%;
cursor: pointer;
}
.pl-cert-list-attachment-cell__image {
width: 100%;
height: 100%;
cursor: pointer;
&--passive {
pointer-events: none;
}
}
.pl-cert-list-attachment-cell__placeholder {
@@ -204,12 +276,37 @@
width: 100%;
height: 100%;
margin-bottom: 14px;
&.is-clickable {
background: #e0f2fe;
}
}
.pl-cert-list-attachment-cell__ext {
font-size: 10px;
line-height: 1.2;
color: #64748b;
font-weight: 600;
color: #0284c7;
}
.pl-cert-list-attachment-cell__download {
position: absolute;
right: 2px;
top: 2px;
z-index: 2;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
border: 1px solid #e2e8f0;
color: #1e293b;
cursor: pointer;
&:hover {
color: #1ca851;
border-color: #1ca851;
background: #e6f8ed;
}
}
.pl-cert-list-attachment-cell__empty-text {
@@ -12,47 +12,69 @@
</el-table-column>
<el-table-column label="别名" min-width="140" align="center" show-overflow-tooltip>
<template #default="{ row }">
<el-select
v-if="editableAliasBrand && (row.aliasOptions || []).length"
v-model="row.aliasName"
class="pl-plan-product-cell-control"
placeholder="不选则用产品名称"
clearable
filterable
style="width: 100%"
popper-class="pl-plan-alias-brand-select-dropdown"
@change="() => handleAliasChange(row)"
>
<el-option
v-for="name in row.aliasOptions"
:key="name"
:label="name"
:value="name"
/>
</el-select>
<div v-if="editableAliasBrand" class="line-meta-cell">
<el-select
v-if="(row.aliasOptions || []).length"
v-model="row.aliasName"
class="pl-plan-product-cell-control line-meta-cell__control"
placeholder="不选则用产品名称"
clearable
filterable
popper-class="pl-plan-alias-brand-select-dropdown"
@change="() => handleAliasChange(row)"
>
<el-option
v-for="name in row.aliasOptions"
:key="name"
:label="name"
:value="name"
/>
</el-select>
<span v-else class="text-muted line-meta-cell__empty">—</span>
<el-button
v-if="row.productId"
link
type="primary"
class="line-meta-cell__btn"
@click="openAliasBrandManage(row, 'alias')"
>
<el-icon><Plus /></el-icon>
</el-button>
</div>
<span v-else>{{ row.aliasName || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="品牌" min-width="140" align="center" show-overflow-tooltip>
<template #default="{ row }">
<el-select
v-if="editableAliasBrand && (row.brandOptions || []).length"
v-model="row.brandId"
class="pl-plan-product-cell-control"
placeholder="不限品牌"
clearable
filterable
style="width: 100%"
popper-class="pl-plan-alias-brand-select-dropdown"
@change="(val) => handleBrandChange(row, val)"
>
<el-option
v-for="b in row.brandOptions"
:key="b.id"
:label="b.brandName"
:value="b.id"
/>
</el-select>
<div v-if="editableAliasBrand" class="line-meta-cell">
<el-select
v-if="(row.brandOptions || []).length"
v-model="row.brandId"
class="pl-plan-product-cell-control line-meta-cell__control"
placeholder="不限品牌"
clearable
filterable
popper-class="pl-plan-alias-brand-select-dropdown"
@change="(val) => handleBrandChange(row, val)"
>
<el-option
v-for="b in row.brandOptions"
:key="b.id"
:label="b.brandName"
:value="b.id"
/>
</el-select>
<span v-else class="text-muted line-meta-cell__empty">—</span>
<el-button
v-if="row.productId"
link
type="primary"
class="line-meta-cell__btn"
@click="openAliasBrandManage(row, 'brand')"
>
<el-icon><Plus /></el-icon>
</el-button>
</div>
<span v-else>{{ formatBrandConstraintLabel(row.brandMode, row.brandName) }}</span>
</template>
</el-table-column>
@@ -142,6 +164,15 @@
</el-table-column>
</ListTable>
</div>
<ProductAliasBrandDialog
v-if="editableAliasBrand"
v-model="aliasBrandManageVisible"
:product-id="aliasBrandManageProductId"
:product-name="aliasBrandManageProductName"
:mode="aliasBrandManageMode"
@saved="handleAliasBrandManaged"
/>
</div>
</template>
@@ -151,11 +182,13 @@ import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { getDeliveryMethodLabel } from '@/composables/usePlContractToPlan'
import {
applyBrandSelectChange,
applyManagedProductMetaToLines,
formatBrandConstraintLabel,
resolveLineProductName
} from '@/utils/crm-product-brand'
import { formatBrandQtySummary } from '@/utils/pl-display'
import PlProductNameHazardCell from '@/components/business/pl-product-name-hazard-cell/index.vue'
import ProductAliasBrandDialog from '@/components/business/product-alias-brand-dialog/index.vue'
import DictTag from '@/components/DictTag/index.vue'
const props = defineProps<{
@@ -196,6 +229,32 @@ function handleAliasChange(row: PlanFormProductRow) {
function handleBrandChange(row: PlanFormProductRow, val: string | undefined | null) {
applyBrandSelectChange(row, val, row.brandOptions || [])
}
const aliasBrandManageVisible = ref(false)
const aliasBrandManageProductId = ref('')
const aliasBrandManageProductName = ref('')
const aliasBrandManageMode = ref<'alias' | 'brand'>('alias')
function openAliasBrandManage(row: PlanFormProductRow, mode: 'alias' | 'brand') {
if (!row.productId) return
aliasBrandManageProductId.value = String(row.productId)
aliasBrandManageProductName.value = String(row.standardProductName || row.productName || '')
aliasBrandManageMode.value = mode
aliasBrandManageVisible.value = true
}
function handleAliasBrandManaged(product: Record<string, any>) {
applyManagedProductMetaToLines(
props.products || [],
aliasBrandManageProductId.value,
product
)
const pid = aliasBrandManageProductId.value
for (const line of props.products || []) {
if (String(line.productId || '') !== pid) continue
line.productName = resolveLineProductName(line.standardProductName, line.aliasName)
}
}
</script>
<style scoped lang="scss">
@@ -218,6 +277,28 @@ function handleBrandChange(row: PlanFormProductRow, val: string | undefined | nu
font-size: 14px;
}
}
.line-meta-cell {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.line-meta-cell__control {
flex: 1;
min-width: 0;
}
.line-meta-cell__empty {
flex: 1;
min-width: 0;
}
.line-meta-cell__btn {
flex-shrink: 0;
padding: 4px;
}
}
</style>
@@ -106,8 +106,8 @@
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">运输信息</h3>
<el-form :model="formData" label-position="right" label-width="100px" class="pl-dialog-form">
<div class="form-grid-4">
<el-form-item label="车牌号">
<div class="pl-plan-vehicle-carrier-row">
<el-form-item label="车牌号" class="pl-plan-vehicle-carrier-row__vehicle">
<div class="pl-plan-vehicle-with-library">
<el-select
v-model="formData.vehicleId"
@@ -136,7 +136,7 @@
</el-button>
</div>
</el-form-item>
<el-form-item label="运输公司">
<el-form-item label="运输公司" class="pl-plan-vehicle-carrier-row__carrier">
<div class="pl-plan-carrier-with-library">
<el-select
v-model="formData.carrierId"
@@ -1237,6 +1237,18 @@ async function handleSubmit(mode: 'save' | 'saveAndArrange' = 'save') {
</script>
<style scoped lang="scss">
.pl-plan-vehicle-carrier-row {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 12px 20px;
align-items: start;
}
.pl-plan-vehicle-carrier-row__vehicle {
width: max-content;
max-width: 100%;
}
.pl-plan-vehicle-with-library,
.pl-plan-carrier-with-library {
display: flex;
@@ -1245,9 +1257,15 @@ async function handleSubmit(mode: 'save' | 'saveAndArrange' = 'save') {
width: 100%;
}
.pl-plan-vehicle-with-library__select,
.pl-plan-vehicle-with-library__select {
flex: 0 0 140px;
width: 140px;
}
.pl-plan-carrier-with-library__select {
flex: 1 1 auto;
flex: 0 1 50%;
width: 50%;
max-width: 50%;
min-width: 0;
}
@@ -103,33 +103,49 @@
v-if="getPrimaryProductCertUrl(product, slot.key)"
class="pl-plan-list-cert-cell__thumb pl-plan-list-cert-cell__thumb--filled"
>
<el-image
v-if="isCertImageUrl(getPrimaryProductCertUrl(product, slot.key))"
class="pl-plan-list-cert-cell__image"
fit="cover"
lazy
:src="displayCertSrc(getPrimaryProductCertUrl(product, slot.key))"
:preview-src-list="mediaActive ? getCertPreviewSrcList(product, slot.key) : []"
preview-teleported
hide-on-click-modal
>
<template #error>
<div class="pl-plan-list-cert-cell__thumb-icon">
<el-icon :size="20"><Document /></el-icon>
</div>
</template>
</el-image>
<div
v-else
class="pl-plan-list-cert-cell__thumb-icon"
:class="
class="pl-plan-list-cert-cell__preview-hit"
:title="
isCertImageUrl(getPrimaryProductCertUrl(product, slot.key)) ||
isCertPdfUrl(getPrimaryProductCertUrl(product, slot.key))
? 'pl-plan-list-cert-cell__thumb-icon--pdf'
: 'pl-plan-list-cert-cell__thumb-icon--doc'
? '点击在新标签页查看'
: '点击查看'
"
@click.stop="handlePreviewCert(getPrimaryProductCertUrl(product, slot.key))"
>
<el-icon :size="20"><Document /></el-icon>
<el-image
v-if="isCertImageUrl(getPrimaryProductCertUrl(product, slot.key))"
class="pl-plan-list-cert-cell__image pl-plan-list-cert-cell__image--passive"
fit="cover"
lazy
:src="displayCertSrc(getPrimaryProductCertUrl(product, slot.key))"
:preview-src-list="[]"
>
<template #error>
<div class="pl-plan-list-cert-cell__thumb-icon">
<el-icon :size="20"><Document /></el-icon>
</div>
</template>
</el-image>
<div
v-else
class="pl-plan-list-cert-cell__thumb-icon"
:class="
isCertPdfUrl(getPrimaryProductCertUrl(product, slot.key))
? 'pl-plan-list-cert-cell__thumb-icon--pdf'
: 'pl-plan-list-cert-cell__thumb-icon--doc'
"
>
<el-icon :size="20"><Document /></el-icon>
</div>
</div>
<el-icon
class="pl-plan-list-cert-cell__download"
title="下载"
@click.stop="handleDownloadCert(getPrimaryProductCertUrl(product, slot.key))"
>
<Download />
</el-icon>
<span
v-if="getCertUrlCount(product, slot.key) > 1"
class="pl-plan-list-cert-cell__cert-count"
@@ -148,7 +164,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Document, Upload } from '@element-plus/icons-vue'
import { Document, Download, Upload } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { PlanProduct } from '@/api/pl'
import {
getPrimaryProductCertUrl,
@@ -159,6 +176,10 @@
splitProductCertUrls,
type HazardCertSlotKey
} from '@/composables/usePlToxicProductCertCheck'
import {
downloadAttachmentFile,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
const props = withDefaults(
defineProps<{
@@ -245,6 +266,35 @@
if (props.clickableDisabled) return
emit('click')
}
async function handlePreviewCert(url: string) {
const fileUrl = String(url || '').trim()
if (!fileUrl) return
await openAttachmentPreviewInNewTab(
{ fileUrl, fileName: getCertFileName(fileUrl) },
{
fullFileUrl: resolveCertFileUrl,
kind: isCertImageUrl(fileUrl) ? 'image' : isCertPdfUrl(fileUrl) ? 'pdf' : 'other'
}
)
}
async function handleDownloadCert(url: string) {
const fileUrl = String(url || '').trim()
if (!fileUrl) {
ElMessage.error('无法获取文件下载地址')
return
}
try {
await downloadAttachmentFile(
{ fileUrl, fileName: getCertFileName(fileUrl) || '附件' },
{ fullFileUrl: resolveCertFileUrl }
)
ElMessage.success('下载成功')
} catch {
ElMessage.error('下载失败,请稍后重试')
}
}
</script>
<style scoped lang="scss">
@@ -346,6 +396,36 @@
align-items: stretch;
}
.pl-plan-list-cert-cell__preview-hit {
width: 100%;
height: 100%;
cursor: pointer;
}
.pl-plan-list-cert-cell__image--passive {
pointer-events: none;
}
.pl-plan-list-cert-cell__download {
position: absolute;
top: 2px;
right: 2px;
z-index: 2;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
border: 1px solid #e2e8f0;
color: #1e293b;
cursor: pointer;
&:hover {
color: #1ca851;
border-color: #1ca851;
background: #e6f8ed;
}
}
.pl-plan-list-cert-cell__cert-caption {
position: absolute;
right: 0;
@@ -0,0 +1,67 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<el-dialog
v-model="visible"
title="产品分类"
width="100%"
top="50px"
class="dialog-form-layout product-category-maintain-dialog"
append-to-body
destroy-on-close
:close-on-click-modal="false"
modal-class="order-module-dialog-mask"
style="backdrop-filter: none"
@closed="emit('closed')"
>
<ProductCategoryMaintainPanel v-if="visible" @changed="emit('changed')" />
<template #footer>
<el-button class="btn-ghost" @click="visible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Close } from '@element-plus/icons-vue'
import ProductCategoryMaintainPanel from '@/components/business/product-category-maintain-panel/index.vue'
const visible = defineModel<boolean>({ default: false })
const emit = defineEmits<{
changed: []
closed: []
}>()
</script>
<style lang="scss">
/* append-to-body:非 scoped,保证最大化宽高生效 */
.el-dialog.product-category-maintain-dialog {
width: calc(100vw - 40px) !important;
max-width: calc(100vw - 40px);
height: calc(100vh - 100px) !important;
max-height: calc(100vh - 100px);
margin-top: 50px !important;
display: flex;
flex-direction: column;
}
.el-dialog.product-category-maintain-dialog .el-dialog__body {
flex: 1 1 auto;
min-height: 0;
max-height: none;
overflow: hidden;
display: flex;
flex-direction: column;
}
.el-dialog.product-category-maintain-dialog .el-dialog__body > .product-category-maintain-panel {
flex: 1 1 auto;
min-height: 0;
}
.el-dialog.product-category-maintain-dialog .el-dialog__header,
.el-dialog.product-category-maintain-dialog .el-dialog__footer {
flex-shrink: 0;
}
</style>
@@ -0,0 +1,535 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="product-category-maintain-panel dialog-list-panel">
<!-- 工具栏:快捷检索与操作按钮同一行 -->
<div class="list-toolbar">
<div class="list-toolbar-row product-category-maintain-panel__toolbar-row">
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="分类名称"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button class="btn-main" type="primary" @click="handleAdd()">
<el-icon><Plus /></el-icon>
新增
</el-button>
<el-button class="btn-ghost" @click="toggleExpandAll">
{{ isExpandAll ? '全部折叠' : '全部展开' }}
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedId"
@click="handleUpdate()"
>
<el-icon><Edit /></el-icon>
修改
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedId"
@click="handleAddChild()"
>
<el-icon><Plus /></el-icon>
新增子分类
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedId || !canDeleteSelectedCategory"
:title="canDeleteSelectedCategory ? '' : CREATOR_DELETE_TIP"
@click="handleDelete()"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="getList"
@reset-columns="resetToDefault"
/>
</div>
</div>
<!-- 树形表格 -->
<div class="list-body list-wrap">
<ListTable
table-key="crm-product-category"
v-if="refreshTable"
ref="tableRef"
class="list-table"
:data="categoryList"
v-loading="loading"
stripe
row-key="id"
:default-expand-all="isExpandAll"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
highlight-current-row
@selection-change="handleSelectionChange"
@row-dblclick="handleRowEdit"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
prop="categoryName"
label="分类名称"
min-width="200"
align="left"
header-align="left"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('orderNum')"
prop="orderNum"
label="排序"
min-width="80"
align="center"
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('status')"
prop="status"
label="状态"
min-width="80"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('sys_normal_disable', row.status)"
size="small"
>
{{ dictStore.getLabel('sys_normal_disable', row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('createTime')"
prop="createTime"
label="创建时间"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
/>
</ListTable>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
</div>
<!-- 新增/编辑分类弹窗(内联) -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="640px"
class="dialog-form-layout"
align-center
append-to-body
destroy-on-close
:close-on-click-modal="false"
modal-class="order-module-dialog-mask"
style="backdrop-filter: none"
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="crm-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="上级分类" style="grid-column: 1 / -1">
<el-tree-select
v-model="formData.parentId"
:data="categoryTreeOptions"
node-key="id"
:props="{ label: 'categoryName', children: 'children' }"
value-key="id"
check-strictly
filterable
clearable
placeholder="选择上级分类(留空为顶级)"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="分类名称" prop="categoryName">
<el-input v-model="formData.categoryName" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="排序号">
<el-input-number
v-model="formData.orderNum"
controls-position="right"
:min="0"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="formData.status" placeholder="请选择状态" style="width: 100%">
<el-option
v-for="opt in dictStore.getOptions('sys_normal_disable')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="formData.remark"
type="textarea"
placeholder="请输入备注"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="submitForm">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { ref, reactive, onMounted, nextTick, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import {
Plus,
Edit,
Delete,
Close,
Check,
} from '@element-plus/icons-vue'
import { useDictStore } from '@/store/modules/dict'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
fetchCategoryTree,
fetchCreateCategory,
fetchUpdateCategory,
fetchDeleteCategory,
} from '@/api/crm/product'
const emit = defineEmits<{
changed: []
}>()
const dictStore = useDictStore()
const listColumnDefs = [
{ prop: 'orderNum', label: '排序' },
{ prop: 'status', label: '状态' },
{ prop: 'createTime', label: '创建时间' }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('crm_product_category_visible_columns', listColumnDefs)
// ── 列表数据 ──
const categoryList = ref<any[]>([])
const loading = ref(false)
const isExpandAll = ref(true)
const refreshTable = ref(true)
const selectedId = ref<string>()
const selectedRows = ref<any[]>([])
const canDeleteSelectedCategory = computed(() => {
if (!selectedId.value) return false
const node = findNodeById(categoryList.value, selectedId.value)
return canDeleteByCreator(node)
})
// ── 弹窗数据 ──
const dialogVisible = ref(false)
const dialogTitle = ref('')
const submitLoading = ref(false)
const formData = reactive({
id: '',
parentId: '',
categoryName: '',
orderNum: 0,
status: '0',
remark: '',
})
const formRules = {
categoryName: [{ required: true, message: '分类名称不能为空', trigger: 'blur' }],
}
const formRef = ref<FormInstance>()
const categoryTreeOptions = ref<any[]>([])
const quickSearchKeyword = ref('')
// ── 获取列表(树形) ──
async function getList() {
loading.value = true
try {
const params: Record<string, any> = {}
const quickKeyword = quickSearchKeyword.value?.trim()
if (quickKeyword) params.categoryName = quickKeyword
const res = await fetchCategoryTree(params)
categoryList.value = (res as any)?.data || res || []
} finally {
loading.value = false
}
}
// ── 获取上级分类下拉树 ──
async function getCategoryTreeSelect() {
const res = await fetchCategoryTree({})
const data = (res as any)?.data || res || []
// 加一个顶级分类虚拟节点
categoryTreeOptions.value = [
{
id: '0',
categoryName: '顶级分类',
parentId: '0',
children: data,
},
]
}
function handleQuickSearch() {
getList()
}
function handleQuickSearchClear() {
quickSearchKeyword.value = ''
getList()
}
// ── 表格行选中 ──
function handleSelectionChange(rows: any[]) {
selectedRows.value = rows
selectedId.value = rows.length === 1 ? rows[0]?.id : undefined
}
// ── 展开/折叠 ──
function toggleExpandAll() {
refreshTable.value = false
isExpandAll.value = !isExpandAll.value
nextTick(() => {
refreshTable.value = true
})
}
// ── 新增 ──
function handleAdd(parentRow?: any) {
resetForm()
getCategoryTreeSelect()
if (parentRow?.id) {
formData.parentId = parentRow.id
}
dialogTitle.value = '新增产品分类'
dialogVisible.value = true
}
// ── 新增子分类 ──
function handleAddChild() {
if (!selectedId.value) {
ElMessage.warning('请先选择一条分类')
return
}
const parentRow = findNodeById(categoryList.value, selectedId.value)
handleAdd(parentRow)
}
// ── 修改 ──
async function handleUpdate() {
await openEditForRow()
}
async function handleRowEdit(row: any) {
await openEditForRow(row)
}
async function openEditForRow(row?: any) {
const targetId = row?.id ?? selectedId.value
if (!targetId) {
ElMessage.warning('请选择一条数据进行修改')
return
}
resetForm()
await getCategoryTreeSelect()
const node = row ?? findNodeById(categoryList.value, targetId)
if (!node) {
ElMessage.warning('未找到选中的数据')
return
}
selectedId.value = node.id
formData.id = node.id
formData.parentId = node.parentId || ''
formData.categoryName = node.categoryName
formData.orderNum = node.orderNum ?? 0
formData.status = node.status ?? '0'
formData.remark = node.remark ?? ''
dialogTitle.value = '修改产品分类'
dialogVisible.value = true
}
// ── 删除 ──
async function handleDelete() {
if (!selectedId.value) {
ElMessage.warning('请选择一条数据进行删除')
return
}
const node = findNodeById(categoryList.value, selectedId.value)
if (!canDeleteByCreator(node)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm('是否确认删除该产品分类?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await fetchDeleteCategory({ id: selectedId.value })
ElMessage.success('删除成功')
selectedId.value = undefined
await getList()
emit('changed')
}
// ── 提交表单 ──
async function submitForm() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
if (formData.id) {
await fetchUpdateCategory({
id: formData.id,
parentId: formData.parentId || '0',
categoryName: formData.categoryName,
orderNum: formData.orderNum,
status: formData.status,
remark: formData.remark,
})
ElMessage.success('修改成功')
} else {
await fetchCreateCategory({
parentId: formData.parentId || '0',
categoryName: formData.categoryName,
orderNum: formData.orderNum,
status: formData.status,
remark: formData.remark,
})
ElMessage.success('新增成功')
}
dialogVisible.value = false
await getList()
emit('changed')
} finally {
submitLoading.value = false
}
}
// ── 弹窗关闭 ──
function handleDialogClose() {
resetForm()
}
// ── 重置表单 ──
function resetForm() {
formData.id = ''
formData.parentId = ''
formData.categoryName = ''
formData.orderNum = 0
formData.status = '0'
formData.remark = ''
formRef.value?.resetFields()
}
// ── 工具函数:在树中查找节点 ──
function findNodeById(tree: any[], id: string): any {
for (const node of tree) {
if (node.id === id) return node
if (node.children?.length) {
const found = findNodeById(node.children, id)
if (found) return found
}
}
return null
}
onMounted(() => {
getList()
})
</script>
<style scoped lang="scss">
.product-category-maintain-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
/* 弹窗内需自带 flex;并固定检索框宽度,避免与按钮之间出现异常空隙 */
.product-category-maintain-panel__toolbar-row {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
:deep(.quick-search-input.el-input) {
width: 280px;
max-width: 280px;
flex: 0 0 280px;
margin-right: 0;
}
:deep(.quick-search-input),
:deep(.el-button),
:deep(.list-toolbar-end) {
flex-shrink: 0;
}
:deep(.el-button + .el-button) {
margin-left: 0 !important;
}
:deep(.list-toolbar-end) {
margin-left: auto;
padding-left: 8px;
}
}
.product-category-maintain-panel :deep(.list-body.list-wrap) {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
.product-category-maintain-panel :deep(.list-toolbar) {
flex-shrink: 0;
}
.product-category-maintain-panel :deep(.list-pager-bar) {
flex-shrink: 0;
}
</style>
@@ -26,9 +26,10 @@
type="primary"
size="small"
class="view-kv-field-link"
:title="viewBtnTitle"
@click="handleView"
>
<el-icon><View /></el-icon>查看
<el-icon :size="16"><View /></el-icon>
</el-button>
</div>
</template>
@@ -60,6 +61,13 @@
return true
})
const viewBtnTitle = computed(() => {
if (props.field.viewTarget === 'customer') return '查看客户'
if (props.field.viewTarget === 'opportunity') return '查看商机'
if (props.field.viewTarget === 'quote') return '查看报价单'
return '查看'
})
const amapLocationUrl = computed(() => {
if (!props.field.showMapLink) return null
return buildAmapLocationUrl({
@@ -64,6 +64,7 @@
v-if="group.fields.length && group.fieldLayout === 'table'"
:fields="group.fields"
:mode="group.tableMode || 'triple'"
@field-view="emit('field-view', $event)"
/>
<ViewKvGrid
v-else-if="group.fields.length"
@@ -77,6 +78,7 @@
v-else-if="section.fields.length && section.fieldLayout === 'table'"
:fields="section.fields"
:mode="section.tableMode || 'triple'"
@field-view="emit('field-view', $event)"
/>
<ViewKvGrid
v-else-if="section.fields.length"
@@ -152,6 +154,7 @@
v-if="group.fields.length && group.fieldLayout === 'table'"
:fields="group.fields"
:mode="group.tableMode || 'triple'"
@field-view="emit('field-view', $event)"
/>
<ViewKvGrid
v-else-if="group.fields.length"
@@ -165,6 +168,7 @@
v-else-if="section.fields.length && section.fieldLayout === 'table'"
:fields="section.fields"
:mode="section.tableMode || 'triple'"
@field-view="emit('field-view', $event)"
/>
<ViewKvGrid
v-else-if="section.fields.length"
@@ -288,6 +292,7 @@
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
(e: 'update:modelValue', val: boolean): void
(e: 'field-view', field: ViewField): void
}>()
const innerVisible = computed({
@@ -20,7 +20,7 @@ export interface ViewField {
money?: boolean
/** 红色加粗强调展示(如结算方式等关键业务字段) */
danger?: boolean
/** 值后展示「查看」按钮,点击时 ViewKvTable 抛出 field-view */
/** 值后展示眼睛图标,点击时 ViewKvTable / SystemViewDialog 抛出 field-view */
viewTarget?: 'customer' | 'opportunity' | 'quote'
viewId?: string | number | null
/** 为 true 时,值后展示地图图标跳转高德查看位置(须配合 mapLng/mapLat 或地址文案) */
@@ -8,7 +8,11 @@
>
<div
class="attachment-preview"
:class="{ 'is-thumb': isBxImageFile(file) && !!file.previewUrl }"
:class="{
'is-thumb': isBxImageFile(file) && !!file.previewUrl,
'is-clickable': canBxPreview(file)
}"
:title="canBxPreview(file) ? '点击预览' : undefined"
@click="canBxPreview(file) ? handlePreview(file) : undefined"
>
<template v-if="isBxImageFile(file) && file.previewUrl">
@@ -24,6 +28,12 @@
<el-icon :size="28"><VideoPlay /></el-icon>
</div>
</template>
<template v-else-if="isBxPdfFile(file)">
<div class="attachment-preview__icon attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
<span class="attachment-preview__ext">PDF</span>
</div>
</template>
<template v-else>
<div class="attachment-preview__icon attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
@@ -33,6 +43,14 @@
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<div class="attachment-actions">
<el-icon
v-if="canBxPreview(file)"
class="action-btn"
title="预览"
@click="handlePreview(file)"
>
<ZoomIn />
</el-icon>
<el-icon class="action-btn" title="下载" @click="handleDownload(file)">
<Download />
</el-icon>
@@ -43,11 +61,12 @@
<el-dialog
v-model="previewVisible"
title="附件预览"
width="800px"
:title="previewFileName || '附件预览'"
:width="previewMode === 'pdf' ? '860px' : '800px'"
align-center
destroy-on-close
append-to-body
modal-class="order-module-dialog-mask"
>
<img
v-if="previewMode === 'image' && previewUrl"
@@ -61,17 +80,24 @@
controls
class="bx-attachment-preview-media"
/>
<iframe
v-else-if="previewMode === 'pdf' && previewUrl"
:src="previewUrl"
class="bx-attachment-preview-media bx-attachment-preview-media--iframe"
title="PDF 预览"
/>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { VideoPlay, Document, Download, Picture } from '@element-plus/icons-vue'
import { VideoPlay, Document, Download, Picture, ZoomIn } from '@element-plus/icons-vue'
import {
type BxAttachmentItem,
isBxImageFile,
isBxVideoFile,
isBxPdfFile,
canBxPreview,
downloadBxAttachment,
previewBxAttachment,
@@ -86,7 +112,7 @@ const displayFiles = ref<BxAttachmentItem[]>([])
const previewVisible = ref(false)
const previewUrl = ref('')
const previewFileName = ref('')
const previewMode = ref<'image' | 'video'>('image')
const previewMode = ref<'image' | 'video' | 'pdf'>('image')
watch(
() => props.files,
@@ -121,8 +147,28 @@ async function handlePreview(file: BxAttachmentItem) {
background: #0f172a0a;
}
.attachment-preview.is-clickable {
cursor: pointer;
}
.attachment-preview__icon--image {
background: #ecfdf5;
color: #059669;
}
.attachment-preview__ext {
margin-top: 4px;
font-size: 11px;
font-weight: 600;
color: #1e293b;
}
.bx-attachment-preview-media--iframe {
width: 100%;
min-height: 480px;
height: calc(100vh - 240px);
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
</style>
@@ -36,6 +36,8 @@
>
<div
class="attachment-preview"
:class="{ 'is-clickable': canBxPreview(file) }"
:title="canBxPreview(file) ? '点击预览' : undefined"
@click="canBxPreview(file) ? handlePreview(file) : undefined"
>
<template v-if="isBxImageFile(file) && (file.previewUrl || file._uploading)">
@@ -50,6 +52,12 @@
<el-icon :size="28"><VideoPlay /></el-icon>
</div>
</template>
<template v-else-if="isBxPdfFile(file)">
<div class="attachment-preview__icon attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
<span class="attachment-preview__ext">PDF</span>
</div>
</template>
<template v-else>
<div class="attachment-preview__icon attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
@@ -59,6 +67,14 @@
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<div class="attachment-actions">
<el-icon
v-if="canBxPreview(file)"
class="action-btn"
title="预览"
@click="handlePreview(file)"
>
<ZoomIn />
</el-icon>
<el-icon class="action-btn" title="下载" @click="handleDownload(file)">
<Download />
</el-icon>
@@ -77,11 +93,12 @@
<el-dialog
v-model="previewVisible"
title="附件预览"
width="800px"
:title="previewFileName || '附件预览'"
:width="previewMode === 'pdf' ? '860px' : '800px'"
align-center
destroy-on-close
append-to-body
modal-class="order-module-dialog-mask"
>
<img
v-if="previewMode === 'image' && previewUrl"
@@ -95,6 +112,12 @@
controls
class="bx-attachment-preview-media"
/>
<iframe
v-else-if="previewMode === 'pdf' && previewUrl"
:src="previewUrl"
class="bx-attachment-preview-media bx-attachment-preview-media--iframe"
title="PDF 预览"
/>
</el-dialog>
</div>
</template>
@@ -107,7 +130,8 @@
VideoPlay,
Document,
Download,
Delete
Delete,
ZoomIn
} from '@element-plus/icons-vue'
import type { UploadFile, UploadInstance } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -116,6 +140,7 @@
type BxAttachmentItem,
isBxImageFile,
isBxVideoFile,
isBxPdfFile,
canBxPreview,
downloadBxAttachment,
previewBxAttachment
@@ -148,7 +173,7 @@
const previewVisible = ref(false)
const previewUrl = ref('')
const previewFileName = ref('')
const previewMode = ref<'image' | 'video'>('image')
const previewMode = ref<'image' | 'video' | 'pdf'>('image')
const isPointerInside = ref(false)
const isDragOver = ref(false)
let dragEnterDepth = 0
@@ -369,4 +394,24 @@
}
}
}
.attachment-preview.is-clickable {
cursor: pointer;
}
.attachment-preview__ext {
margin-top: 4px;
font-size: 11px;
font-weight: 600;
color: #1e293b;
}
.bx-attachment-preview-media--iframe {
width: 100%;
min-height: 480px;
height: calc(100vh - 240px);
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
</style>
@@ -1,81 +1,118 @@
<!-- 门户顶栏:一级横排 + 方案 A 大面板(左右分行二级/三级文字链) -->
<!-- 门户顶栏:当前一级下的二级按宽度平铺 +「更多」+ 三级下拉(下拉 Teleport 到 body,避免被 overflow 裁切) -->
<template>
<div class="portal-mega-menu" ref="rootRef">
<nav class="portal-mega-l1" aria-label="一级模块">
<div class="portal-flat-nav" ref="rootRef">
<!-- 离屏测量:用于按宽度计算可见项,不参与布局 -->
<div ref="measureRef" class="portal-flat-nav__measure" aria-hidden="true">
<button
v-for="item in moduleList"
:key="item.path"
v-for="item in l2Items"
:key="`m-${item.path || item.meta?.title}`"
type="button"
class="portal-mega-l1__item"
:class="{
'is-selected': isL1Selected(item)
}"
@click="toggleModule(item)"
class="portal-flat-nav__item portal-flat-nav__measure-item"
>
<span>{{ item.formattedTitle }}</span>
<span class="portal-mega-l1__caret">{{ openModulePath === item.path ? '▴' : '▾' }}</span>
<span>{{ formatMenuTitle(item.meta.title) }}</span>
<span v-if="l2HasExpandableChildren(item)" class="portal-flat-nav__caret">▾</span>
</button>
<button type="button" class="portal-flat-nav__item portal-flat-nav__measure-more">
<span>更多</span>
<span class="portal-flat-nav__caret">▾</span>
</button>
</div>
<nav v-if="l2Items.length" class="portal-flat-nav__bar" aria-label="二级菜单">
<div
v-for="item in visibleItems"
:key="item.path || item.meta?.title"
class="portal-flat-nav__item-wrap"
>
<button
:ref="(el) => setL2TriggerRef(itemKey(item), el)"
type="button"
class="portal-flat-nav__item"
:class="{
'is-active': isL2Active(item),
'is-open': openL2Key === itemKey(item)
}"
@click="handleL2Click(item)"
>
<span>{{ formatMenuTitle(item.meta.title) }}</span>
<span v-if="l2HasExpandableChildren(item)" class="portal-flat-nav__caret">
{{ openL2Key === itemKey(item) ? '▴' : '▾' }}
</span>
</button>
</div>
<div v-if="overflowItems.length" class="portal-flat-nav__item-wrap">
<button
ref="moreTriggerRef"
type="button"
class="portal-flat-nav__item"
:class="{
'is-active': overflowHasActive,
'is-open': moreOpen
}"
@click="toggleMore"
>
<span>更多</span>
<span class="portal-flat-nav__caret">{{ moreOpen ? '▴' : '▾' }}</span>
</button>
</div>
</nav>
<p v-else class="portal-flat-nav__empty">暂无菜单</p>
<Teleport to="body">
<div
v-if="openModule"
class="portal-mega-overlay"
@click="closePanel"
/>
<div
v-if="openModule"
class="portal-mega-panel"
role="dialog"
aria-label="子菜单"
:style="panelStyle"
v-if="openL2Item"
ref="l2FlyoutRef"
class="portal-flat-nav-flyout"
role="menu"
:style="l2FlyoutStyle"
>
<div class="portal-mega-panel__body">
<div class="portal-mega-split" :class="{ 'is-single': isSingleColumn }">
<div class="portal-mega-pane">
<div
v-for="row in columns.left"
:key="row.l2.path || row.l2.meta?.title"
class="portal-mega-row"
>
<div class="portal-mega-row__l2">{{ formatMenuTitle(row.l2.meta.title) }}</div>
<div class="portal-mega-row__l3">
<button
v-for="link in row.l3Links"
:key="link.path || link.meta?.title"
type="button"
class="portal-mega-link"
:class="{ 'is-active': isLinkActive(link) }"
@click="handleLinkClick(link)"
>
{{ formatMenuTitle(link.meta.title) }}
</button>
</div>
</div>
<p v-if="!columns.left.length" class="portal-mega-empty">暂无菜单</p>
</div>
<div v-if="!isSingleColumn" class="portal-mega-pane portal-mega-pane--right">
<div
v-for="row in columns.right"
:key="row.l2.path || row.l2.meta?.title"
class="portal-mega-row"
>
<div class="portal-mega-row__l2">{{ formatMenuTitle(row.l2.meta.title) }}</div>
<div class="portal-mega-row__l3">
<button
v-for="link in row.l3Links"
:key="link.path || link.meta?.title"
type="button"
class="portal-mega-link"
:class="{ 'is-active': isLinkActive(link) }"
@click="handleLinkClick(link)"
>
{{ formatMenuTitle(link.meta.title) }}
</button>
</div>
</div>
</div>
</div>
<button
v-for="link in resolveMegaPanelL3Links(openL2Item)"
:key="link.path || link.meta?.title"
type="button"
class="portal-flat-nav-flyout__opt"
:class="{ 'is-active': isLinkActive(link) }"
@click.stop="handleLinkClick(link)"
>
{{ formatMenuTitle(link.meta.title) }}
</button>
</div>
<div
v-if="moreOpen"
ref="moreFlyoutRef"
class="portal-flat-nav-flyout portal-flat-nav-flyout--more"
role="menu"
:style="moreFlyoutStyle"
>
<div
v-for="item in overflowItems"
:key="item.path || item.meta?.title"
class="portal-flat-nav-flyout__block"
>
<template v-if="l2HasExpandableChildren(item)">
<div class="portal-flat-nav-flyout__group">{{ formatMenuTitle(item.meta.title) }}</div>
<button
v-for="link in resolveMegaPanelL3Links(item)"
:key="link.path || link.meta?.title"
type="button"
class="portal-flat-nav-flyout__opt portal-flat-nav-flyout__opt--sub"
:class="{ 'is-active': isLinkActive(link) }"
@click.stop="handleLinkClick(link)"
>
{{ formatMenuTitle(link.meta.title) }}
</button>
</template>
<button
v-else
type="button"
class="portal-flat-nav-flyout__opt"
:class="{ 'is-active': isL2Active(item) }"
@click.stop="handleLeafL2Click(item)"
>
{{ formatMenuTitle(item.meta.title) }}
</button>
</div>
</div>
</Teleport>
@@ -83,145 +120,255 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
type ComponentPublicInstance
} from 'vue'
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { onClickOutside } from '@vueuse/core'
import { onClickOutside, useResizeObserver } from '@vueuse/core'
import { formatMenuTitle } from '@/utils/router'
import { handleMenuJump } from '@/utils/navigation'
import { useMenuStore } from '@/store/modules/menu'
import {
buildMegaPanelColumns,
calcPortalFlatNavVisibleCount,
filterPortalTopModules,
isMegaPanelLinkActive,
isPortalModuleActive,
type PortalMegaPanelColumns
isPortalL2Active,
l2HasExpandableChildren,
listPortalFlatNavL2Items,
PORTAL_DEFAULT_MODULE_PATH,
resolveActivePortalModule,
resolveMegaPanelL3Links
} from '@/utils/router/portal-menu'
import type { AppRouteRecord } from '@/types/router'
import type { CSSProperties } from 'vue'
defineOptions({ name: 'ArtPortalMegaMenu' })
interface PortalModuleItem extends AppRouteRecord {
isRouteActive: boolean
formattedTitle: string
}
const route = useRoute()
const menuStore = useMenuStore()
const { menuList } = storeToRefs(menuStore)
const rootRef = ref<HTMLElement | null>(null)
const openModulePath = ref<string | null>(null)
const panelStyle = ref<CSSProperties>({})
const measureRef = ref<HTMLElement | null>(null)
const moreTriggerRef = ref<HTMLElement | null>(null)
const l2FlyoutRef = ref<HTMLElement | null>(null)
const moreFlyoutRef = ref<HTMLElement | null>(null)
const l2TriggerRefs = ref<Record<string, HTMLElement | null>>({})
const openL2Key = ref<string | null>(null)
const moreOpen = ref(false)
const visibleCount = ref(0)
const l2FlyoutStyle = ref<CSSProperties>({})
const moreFlyoutStyle = ref<CSSProperties>({})
const currentRoutePath = computed(() => String(route.meta.activePath || route.path))
const moduleList = computed<PortalModuleItem[]>(() => {
return filterPortalTopModules(menuList.value).map((item) => ({
...item,
isRouteActive: isPortalModuleActive(item, currentRoutePath.value),
formattedTitle: formatMenuTitle(item.meta.title)
}))
const activeModule = computed(() => {
const matched = resolveActivePortalModule(menuList.value, currentRoutePath.value)
if (matched) {
return matched
}
const tops = filterPortalTopModules(menuList.value)
return tops.find((item) => item.path === PORTAL_DEFAULT_MODULE_PATH) || tops[0]
})
const openModule = computed(() => {
if (!openModulePath.value) return undefined
return moduleList.value.find((item) => item.path === openModulePath.value)
})
const l2Items = computed(() => listPortalFlatNavL2Items(activeModule.value))
const columns = computed<PortalMegaPanelColumns>(() =>
buildMegaPanelColumns(openModule.value)
const visibleItems = computed(() => l2Items.value.slice(0, visibleCount.value))
const overflowItems = computed(() => l2Items.value.slice(visibleCount.value))
const overflowHasActive = computed(() =>
overflowItems.value.some((item) => isPortalL2Active(item, currentRoutePath.value))
)
const isSingleColumn = computed(() => columns.value.right.length === 0)
const openL2Item = computed(() => {
if (!openL2Key.value) return undefined
return visibleItems.value.find((item) => itemKey(item) === openL2Key.value)
})
/** 与顶栏中间槽上限一致;一级较少时保证两栏二级可读 */
const PANEL_MIN_WIDTH = 720
const PANEL_EDGE_PAD = 16
const itemKey = (item: AppRouteRecord): string => String(item.path || item.meta?.title || '')
const syncPanelPosition = (): void => {
const el = rootRef.value
if (!el) return
const rect = el.getBoundingClientRect()
const viewportW = window.innerWidth
const minWidth = Math.min(PANEL_MIN_WIDTH, Math.max(0, viewportW - PANEL_EDGE_PAD * 2))
const width = Math.max(rect.width, minWidth)
const centerX = rect.left + rect.width / 2
let left = centerX - width / 2
left = Math.max(PANEL_EDGE_PAD, Math.min(left, viewportW - width - PANEL_EDGE_PAD))
panelStyle.value = {
top: `${rect.bottom}px`,
left: `${left}px`,
width: `${width}px`
const setL2TriggerRef = (key: string, el: Element | ComponentPublicInstance | null): void => {
if (!el) {
delete l2TriggerRefs.value[key]
return
}
l2TriggerRefs.value[key] = el as HTMLElement
}
const closePanel = (): void => {
openModulePath.value = null
}
const toggleModule = async (item: PortalModuleItem): Promise<void> => {
const path = String(item.path || '')
if (!path) return
openModulePath.value = openModulePath.value === path ? null : path
if (openModulePath.value) {
await nextTick()
syncPanelPosition()
}
}
/** 顶栏同时只高亮一个一级:面板展开时跟展开项,否则跟当前路由 */
const isL1Selected = (item: PortalModuleItem): boolean => {
if (openModulePath.value) {
return openModulePath.value === item.path
}
return item.isRouteActive
const isL2Active = (item: AppRouteRecord): boolean => {
return isPortalL2Active(item, currentRoutePath.value)
}
const isLinkActive = (link: AppRouteRecord): boolean => {
return isMegaPanelLinkActive(link, currentRoutePath.value)
}
const closePopovers = (): void => {
openL2Key.value = null
moreOpen.value = false
}
const clampFlyoutLeft = (left: number, width: number): number => {
const pad = 8
const maxLeft = Math.max(pad, window.innerWidth - width - pad)
return Math.min(Math.max(left, pad), maxLeft)
}
const syncL2FlyoutPosition = async (): Promise<void> => {
const key = openL2Key.value
if (!key) return
await nextTick()
const trigger = l2TriggerRefs.value[key]
const flyout = l2FlyoutRef.value
if (!trigger || !flyout) return
const rect = trigger.getBoundingClientRect()
const flyoutWidth = flyout.offsetWidth || 160
const left = clampFlyoutLeft(rect.left + rect.width / 2 - flyoutWidth / 2, flyoutWidth)
l2FlyoutStyle.value = {
top: `${rect.bottom - 2}px`,
left: `${left}px`
}
}
const syncMoreFlyoutPosition = async (): Promise<void> => {
if (!moreOpen.value) return
await nextTick()
const trigger = moreTriggerRef.value
const flyout = moreFlyoutRef.value
if (!trigger || !flyout) return
const rect = trigger.getBoundingClientRect()
const flyoutWidth = flyout.offsetWidth || 180
const left = clampFlyoutLeft(rect.right - flyoutWidth, flyoutWidth)
moreFlyoutStyle.value = {
top: `${rect.bottom - 2}px`,
left: `${left}px`
}
}
const toggleMore = async (): Promise<void> => {
moreOpen.value = !moreOpen.value
openL2Key.value = null
if (moreOpen.value) {
await syncMoreFlyoutPosition()
}
}
const recomputeVisibleCount = (): void => {
const root = rootRef.value
const measure = measureRef.value
const items = l2Items.value
if (!root || !measure || !items.length) {
visibleCount.value = 0
return
}
const availableWidth = root.clientWidth
const itemEls = measure.querySelectorAll<HTMLElement>('.portal-flat-nav__measure-item')
const moreEl = measure.querySelector<HTMLElement>('.portal-flat-nav__measure-more')
if (!itemEls.length || !moreEl) {
visibleCount.value = items.length
return
}
const itemWidths = Array.from(itemEls).map((el) => el.getBoundingClientRect().width)
const moreWidth = moreEl.getBoundingClientRect().width
visibleCount.value = calcPortalFlatNavVisibleCount(itemWidths, moreWidth, availableWidth)
}
/** 有三级:只展开下拉;无三级:直接跳转 */
const handleL2Click = async (item: AppRouteRecord): Promise<void> => {
if (l2HasExpandableChildren(item)) {
const key = itemKey(item)
openL2Key.value = openL2Key.value === key ? null : key
moreOpen.value = false
if (openL2Key.value) {
await syncL2FlyoutPosition()
}
return
}
closePopovers()
handleMenuJump(item)
}
const handleLeafL2Click = (item: AppRouteRecord): void => {
closePopovers()
handleMenuJump(item)
}
const handleLinkClick = (link: AppRouteRecord): void => {
closePanel()
closePopovers()
handleMenuJump(link)
}
watch(
() => route.fullPath,
() => {
closePanel()
closePopovers()
}
)
watch(openModulePath, async (path) => {
if (!path) return
await nextTick()
syncPanelPosition()
watch(
l2Items,
async () => {
closePopovers()
await nextTick()
recomputeVisibleCount()
},
{ deep: true }
)
useResizeObserver(rootRef, () => {
recomputeVisibleCount()
if (openL2Key.value) {
void syncL2FlyoutPosition()
}
if (moreOpen.value) {
void syncMoreFlyoutPosition()
}
})
onMounted(() => {
window.addEventListener('resize', syncPanelPosition)
window.addEventListener('scroll', syncPanelPosition, true)
const syncOpenFlyoutPositions = (): void => {
if (openL2Key.value) void syncL2FlyoutPosition()
if (moreOpen.value) void syncMoreFlyoutPosition()
}
onMounted(async () => {
await nextTick()
recomputeVisibleCount()
window.addEventListener('resize', syncOpenFlyoutPositions)
window.addEventListener('scroll', syncOpenFlyoutPositions, true)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', syncPanelPosition)
window.removeEventListener('scroll', syncPanelPosition, true)
window.removeEventListener('resize', syncOpenFlyoutPositions)
window.removeEventListener('scroll', syncOpenFlyoutPositions, true)
})
onClickOutside(rootRef, (event) => {
const target = event.target as HTMLElement | null
if (target?.closest?.('.portal-mega-panel')) {
return
onClickOutside(
rootRef,
() => {
closePopovers()
},
{
ignore: [l2FlyoutRef, moreFlyoutRef]
}
closePanel()
})
)
</script>
<style lang="scss" scoped>
.portal-mega-menu {
.portal-flat-nav {
position: relative;
display: flex;
align-items: stretch;
justify-content: center;
@@ -230,181 +377,147 @@
height: 100%;
}
.portal-mega-l1 {
.portal-flat-nav__measure {
position: absolute;
top: 0;
left: 0;
z-index: -1;
display: flex;
gap: 2px;
align-items: stretch;
height: 56px;
pointer-events: none;
visibility: hidden;
white-space: nowrap;
}
.portal-flat-nav__bar {
display: flex;
flex-wrap: nowrap;
gap: 2px;
align-items: stretch;
justify-content: center;
min-width: 0;
max-height: 100%;
/* 仅允许横向溢出;若只写 overflow-x:auto,overflow-y 会被算成 auto,易出现竖向滚动条 */
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
max-width: 100%;
height: 100%;
overflow: hidden;
}
.portal-mega-l1__item {
.portal-flat-nav__item-wrap {
position: relative;
display: flex;
flex-shrink: 0;
align-items: stretch;
height: 100%;
}
.portal-flat-nav__item {
box-sizing: border-box;
display: inline-flex;
flex-shrink: 0;
gap: 4px;
align-items: center;
height: 100%;
max-height: 56px;
padding: 0 14px;
padding: 0 12px;
margin: 0;
font-size: 14px;
font-size: 13px;
font-weight: 500;
line-height: 1.2;
color: #fff;
color: rgba(255, 255, 255, 0.92);
white-space: nowrap;
cursor: pointer;
background: transparent;
border: none;
border-bottom: 4px solid transparent;
border-bottom: 2px solid transparent;
transition:
background-color 0.15s ease,
color 0.15s ease,
border-color 0.15s ease;
&:hover {
color: #fff;
background: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.12);
}
&.is-selected {
&.is-active,
&.is-open {
font-weight: 600;
color: #fff;
background: rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.16);
border-bottom-color: #fff;
}
}
.portal-mega-l1__caret {
.portal-flat-nav__caret {
font-size: 10px;
opacity: 0.75;
opacity: 0.8;
}
.portal-flat-nav__empty {
display: flex;
align-items: center;
margin: 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.72);
}
</style>
<style lang="scss">
.portal-mega-overlay {
.portal-flat-nav-flyout {
position: fixed;
inset: 56px 0 0 0;
z-index: 190;
background: rgba(15, 23, 42, 0.25);
}
.portal-mega-panel {
position: fixed;
z-index: 200;
z-index: 3200;
box-sizing: border-box;
max-height: min(720px, calc(100vh - 72px));
min-width: 160px;
max-height: min(360px, calc(100vh - 72px));
padding: 6px;
overflow: auto;
background: #fff;
border: 1px solid var(--mint-border, #e2e8f0);
border-top: none;
border-radius: 0 0 10px 10px;
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.12);
}
border-radius: 10px;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
.portal-mega-panel__body {
padding: 12px 16px 18px;
}
.portal-mega-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
align-items: stretch;
&.is-single {
grid-template-columns: 1fr;
}
}
.portal-mega-pane {
min-height: 100%;
}
.portal-mega-pane--right {
padding-left: 16px;
border-left: 1px solid var(--mint-border, #e2e8f0);
}
.portal-mega-row {
display: grid;
grid-template-columns: 110px 1fr;
gap: 8px 14px;
align-items: start;
padding: 12px 0;
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: 0;
}
}
.portal-mega-row__l2 {
padding-top: 6px;
font-size: 12px;
font-weight: 500;
line-height: 1.35;
color: #666666;
}
.portal-mega-row__l3 {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 4px;
align-items: stretch;
}
.portal-mega-link {
display: block;
width: 100%;
box-sizing: border-box;
padding: 4px 0;
margin: 0;
font-size: 13px;
font-weight: 500;
line-height: 1.45;
color: #0f172a;
text-align: left;
cursor: pointer;
background: transparent;
border: none;
border-radius: 0;
transition: color 0.15s ease;
&:hover {
color: var(--primary-dark, #059669);
&--more {
min-width: 180px;
}
&.is-active {
font-weight: 600;
color: var(--primary-dark, #059669);
}
}
.portal-mega-empty {
margin: 12px 0;
font-size: 13px;
color: #94a3b8;
}
@media only screen and (width <= 900px) {
.portal-mega-split {
grid-template-columns: 1fr;
&__group {
padding: 6px 10px 4px;
font-size: 11px;
font-weight: 700;
color: var(--mint-text-muted, #64748b);
letter-spacing: 0.02em;
}
.portal-mega-pane--right {
padding-left: 0;
border-left: none;
}
&__opt {
display: block;
box-sizing: border-box;
width: 100%;
padding: 8px 10px;
margin: 0;
font-size: 13px;
font-weight: 500;
line-height: 1.35;
color: #1e293b;
text-align: left;
white-space: nowrap;
cursor: pointer;
background: transparent;
border: none;
border-radius: 8px;
.portal-mega-row {
grid-template-columns: 96px 1fr;
&:hover {
color: var(--primary-dark, #059669);
background: var(--primary-bg, #ecfdf5);
}
&.is-active {
font-weight: 600;
color: var(--primary-dark, #059669);
background: var(--primary-bg, #ecfdf5);
}
&--sub {
padding-left: 18px;
color: #475569;
}
}
}
</style>
@@ -1,18 +1,14 @@
<!-- 门户顶栏一级模块下拉 -->
<!-- 门户顶栏:品牌旁一级模块下拉 -->
<template>
<div class="portal-module-select">
<ElDropdown
v-if="moduleList.length"
trigger="click"
teleported
popper-class="portal-module-select-popper"
@command="handleModuleSelect"
>
<button type="button" class="portal-module-select-trigger">
<ArtSvgIcon
v-if="activeModule?.meta?.icon"
:icon="activeModule.meta.icon"
class="portal-module-select-trigger__icon"
/>
<span class="portal-module-select-trigger__label">{{ triggerLabel }}</span>
<ElIcon class="portal-module-select-trigger__arrow"><ArrowDown /></ElIcon>
</button>
@@ -24,11 +20,6 @@
:command="item.path"
:class="{ 'is-active': item.isActive }"
>
<ArtSvgIcon
v-if="item.meta.icon"
:icon="item.meta.icon"
class="portal-module-select-item__icon"
/>
<span>{{ item.formattedTitle }}</span>
</ElDropdownItem>
</ElDropdownMenu>
@@ -83,7 +74,10 @@
if (matched) {
return matched
}
return moduleList.value.find((item) => item.path === PORTAL_DEFAULT_MODULE_PATH) || moduleList.value[0]
return (
moduleList.value.find((item) => item.path === PORTAL_DEFAULT_MODULE_PATH) ||
moduleList.value[0]
)
})
const triggerLabel = computed(() => {
@@ -116,49 +110,46 @@
gap: 6px;
align-items: center;
max-width: 180px;
height: 36px;
padding: 0 12px;
height: 32px;
padding: 0 10px 0 12px;
cursor: pointer;
background: transparent;
border: none;
color: #fff;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 8px;
&__icon {
flex-shrink: 0;
font-size: 18px;
color: var(--primary, #1ca851);
}
&__label {
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
font-size: 13px;
font-weight: 600;
line-height: 1.2;
color: var(--mint-text, #1e293b);
color: #fff;
white-space: nowrap;
}
&__arrow {
flex-shrink: 0;
font-size: 14px;
color: var(--mint-text-muted, #64748b);
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
}
&:hover:not(.is-disabled) {
background: transparent;
background: rgba(255, 255, 255, 0.22);
border-color: rgba(255, 255, 255, 0.55);
}
&.is-disabled {
cursor: not-allowed;
background: transparent;
opacity: 0.72;
}
}
</style>
<style lang="scss">
/* 高于顶栏中间二级(含 Teleport 下拉),避免一级面板被挡住 */
.portal-module-select-popper.el-popper {
z-index: 3300 !important;
backdrop-filter: none;
}
@@ -167,17 +158,13 @@
display: flex;
gap: 8px;
align-items: center;
font-size: 15px;
font-size: 14px;
&.is-active {
color: var(--primary, #1ca851);
background: var(--primary-bg, #e6f8ed);
font-weight: 600;
color: var(--primary-dark, #059669);
background: var(--primary-bg, #ecfdf5);
}
}
.portal-module-select-item__icon {
flex-shrink: 0;
font-size: 16px;
}
}
</style>
@@ -2,11 +2,11 @@
<template>
<div
v-if="showWorkTab"
class="worktab-strip box-border flex w-full items-center px-5 mb-3 select-none max-sm:px-[15px]"
class="worktab-strip worktab-strip--capsule box-border flex w-full items-center select-none"
>
<div class="worktab-scroll-wrap min-w-0 flex-1" ref="scrollRef">
<ul
class="float-left whitespace-nowrap !bg-transparent flex"
class="worktab-list float-left whitespace-nowrap !bg-transparent flex"
:class="[tabStyle === 'tab-google' ? 'pl-1' : '']"
ref="tabsRef"
:style="{
@@ -15,8 +15,11 @@
}"
>
<li
class="worktab-chip inline-flex flex-cc mr-1.5 text-xs c-p group"
:class="[item.path === activeTab ? 'is-active' : '', item.fixedTab ? 'is-fixed' : '']"
class="worktab-chip inline-flex flex-cc text-xs c-p group"
:class="[
item.path === activeTab ? 'is-active' : '',
item.fixedTab ? 'is-fixed is-home' : ''
]"
v-for="(item, index) in list"
:key="item.path"
:ref="item.path"
@@ -24,24 +27,26 @@
@click="clickTab(item)"
@contextmenu.prevent="(e) => showMenu(e, item.path)"
>
{{ item.customTitle || formatMenuTitle(item.title) }}
<span class="worktab-chip__label">{{
item.customTitle || formatMenuTitle(item.title)
}}</span>
<span
v-if="list.length > 1 && !item.fixedTab"
class="worktab-close inline-flex flex-cc relative ml-0.5 p-1 rounded-full tad-200"
class="worktab-close inline-flex flex-cc relative rounded-full tad-200"
@click.stop="closeWorktab('current', item.path)"
>
<ArtSvgIcon icon="close-bold" class="text-[10px] text-inherit" />
<ArtSvgIcon icon="close-bold" class="worktab-close__icon text-[10px]" />
</span>
</li>
</ul>
</div>
<div class="flex shrink-0">
<div class="worktab-menu-wrap flex shrink-0">
<div
class="worktab-menu-btn flex-cc relative top-0 size-8 leading-8 text-center c-p tad-200"
class="worktab-menu-btn flex-cc relative top-0 leading-none text-center c-p tad-200"
@click="(e) => showMenu(e, activeTab)"
>
<ArtSvgIcon icon="arrow-down" class="text-2xl text-g-700" />
<ArtSvgIcon icon="arrow-down" class="text-xl text-inherit" />
</div>
</div>
@@ -508,93 +513,167 @@
</script>
<style scoped>
.worktab-strip {
/* 方案 B′:浅底胶囊居中 + 未激活白底描边(docs/portal-worktab-capsule-v2-demo.html) */
.worktab-strip--capsule {
position: relative;
padding: 0 24px 4px !important;
background: var(--mint-surface);
gap: 8px;
align-items: center;
height: 48px;
min-height: 48px;
padding: 0 14px !important;
margin: 0 !important;
background: #f1f5f9;
border-bottom: 1px solid var(--mint-border, #e2e8f0);
overflow: visible !important;
}
.worktab-scroll-wrap {
display: flex;
align-items: center;
min-height: 100%;
overflow-x: clip;
overflow-y: visible;
}
.worktab-list {
gap: 6px;
align-items: center;
margin: 0;
padding: 0;
}
.worktab-chip {
position: relative;
z-index: 1;
max-width: 220px;
padding: 10px 16px !important;
margin-bottom: -7px;
display: inline-flex;
gap: 6px;
align-items: center;
max-width: 160px;
height: 32px;
min-height: 32px;
padding: 0 8px 0 12px !important;
margin: 0 !important;
overflow: hidden;
font-size: 13px;
font-weight: 600;
color: var(--mint-text-muted);
font-weight: 500;
line-height: 1;
color: #475569;
white-space: nowrap;
cursor: pointer;
background: #fff;
border: 1px solid #dbe3ec;
border-radius: 8px !important;
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.02);
transition:
background 0.15s ease,
color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.worktab-chip__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: var(--mint-surface);
border: 1px solid var(--mint-border);
border-radius: 8px 8px 0 0 !important;
transform: translateY(1px);
}
.worktab-chip.is-fixed {
padding-right: 12px !important;
padding-left: 12px !important;
}
.worktab-chip:hover {
color: var(--mint-text);
background: var(--mint-row-alt);
color: var(--mint-text, #1e293b);
background: #fff;
border-color: #cbd5e1;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.worktab-chip.is-active {
z-index: 2;
color: var(--mint-primary-dark);
border-bottom-color: var(--mint-surface);
box-shadow: none;
font-weight: 600;
color: var(--mint-primary-dark, #059669);
background: #fff;
border-color: #6ee7b7;
box-shadow:
0 0 0 1px rgba(16, 185, 129, 0.12),
0 1px 2px rgba(16, 185, 129, 0.1);
}
.worktab-chip.is-active::after {
content: '';
position: absolute;
z-index: 3;
right: 0;
bottom: 1px;
left: 0;
height: 2px;
background: var(--mint-primary);
pointer-events: none;
.worktab-chip.is-home.is-active {
background: var(--mint-primary-bg, #ecfdf5);
border-color: #6ee7b7;
}
.worktab-close {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
margin: 0;
color: #64748b;
border-radius: 999px;
opacity: 1;
transition:
background 0.15s ease,
color 0.15s ease;
}
.worktab-close__icon {
color: #64748b;
}
.worktab-close:hover {
background: var(--mint-row-alt);
color: var(--mint-text, #1e293b);
background: #f1f5f9;
}
.worktab-close:hover .worktab-close__icon {
color: var(--mint-text, #1e293b);
}
.worktab-chip.is-active .worktab-close,
.worktab-chip.is-active .worktab-close__icon {
color: #64748b;
}
.worktab-menu-wrap {
display: inline-flex;
align-items: center;
}
.worktab-menu-btn {
margin-left: 6px;
color: var(--mint-text-muted);
border: 1px solid var(--mint-border);
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: var(--mint-text-muted, #64748b);
background: #fff;
border: 1px solid #dbe3ec;
border-radius: 8px;
background: var(--mint-surface);
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.02);
}
.worktab-menu-btn:hover {
color: var(--mint-text);
background: var(--mint-row-alt);
color: var(--mint-primary-dark, #059669);
background: var(--mint-primary-bg, #ecfdf5);
border-color: #a7f3d0;
}
@media only screen and (width <= 768px) {
.worktab-strip {
padding-right: 0.625rem;
padding-left: 0.625rem;
.worktab-strip--capsule {
padding-right: 12px !important;
padding-left: 12px !important;
}
}
@media only screen and (width <= 640px) {
.worktab-strip {
padding-right: 0.9375rem;
padding-left: 0.9375rem;
.worktab-strip--capsule {
padding-right: 10px !important;
padding-left: 10px !important;
}
}
</style>
@@ -1,7 +1,10 @@
<template>
<div
class="filter-date-shortcuts list-toolbar-date-shortcuts"
:class="{ 'is-select': variant === 'select' }"
:class="{
'is-select': variant === 'select',
'is-pills': variant === 'pills'
}"
role="group"
:aria-label="ariaLabel"
>
@@ -20,6 +23,22 @@
:value="item.type"
/>
</el-select>
<div v-else-if="variant === 'pills'" class="panel-pill-tabs panel-pill-tabs--start list-toolbar-date-pills">
<div class="panel-pill-tabs__track" role="tablist" :aria-label="ariaLabel">
<button
v-for="item in shortcutItems"
:key="item.type"
type="button"
role="tab"
class="panel-pill-tabs__btn"
:class="{ 'is-active': active === item.type }"
:aria-selected="active === item.type"
@click="emit('change', item.type)"
>
{{ item.label }}
</button>
</div>
</div>
<template v-else>
<el-button
v-for="item in shortcutItems"
@@ -42,8 +61,8 @@
defineProps<{
active: DateShortcutType | ''
ariaLabel?: string
/** buttons=五个描边按钮;select=下拉选择(列表工具栏默认) */
variant?: 'buttons' | 'select'
/** buttons=描边按钮;select=下拉;pills=胶囊分段 Tab(列表工具栏推荐) */
variant?: 'buttons' | 'select' | 'pills'
}>(),
{
ariaLabel: '日期快捷筛选',
@@ -87,6 +106,15 @@
margin-right: 8px;
}
.list-toolbar-date-shortcuts.is-pills {
display: inline-flex;
align-items: center;
flex-shrink: 0;
padding-right: 8px;
margin-right: 4px;
border-right: 1px solid var(--mint-border, #d9e8e2);
}
.date-shortcut-select {
width: 112px;
}
@@ -94,4 +122,22 @@
.date-shortcut-select :deep(.el-select__wrapper) {
min-height: 36px;
}
/* 工具栏内胶囊 Tab:紧凑尺寸,左对齐,无面板级外边距 */
.list-toolbar-date-pills.panel-pill-tabs {
margin: 0;
justify-content: flex-start;
}
.list-toolbar-date-pills .panel-pill-tabs__track {
gap: 2px;
padding: 3px;
}
.list-toolbar-date-pills .panel-pill-tabs__btn {
min-width: 0;
height: 30px;
padding: 0 12px;
font-size: 13px;
}
</style>