提交
This commit is contained in:
1 parent
2df2ef0808
commit
683f14828f
388 files changed
+2738488
-81306
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user