提交
This commit is contained in:
1 parent
ca6aca2b14
commit
f70dc26206
63 files changed
+589912
-9127
No files matched your search
@@ -23,6 +23,7 @@ function mapListQuery(query: ContractQuery = {}): Record<string, any> {
|
||||
pageSize: query.pageSize ?? 20
|
||||
}
|
||||
const fields: (keyof ContractQuery)[] = [
|
||||
'keyword',
|
||||
'contractNo',
|
||||
'contractName',
|
||||
'customerName',
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
export interface ContractQuery {
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
/** 即时检索:合同编号 / 合同产品名称 / 产品拼音首字母 */
|
||||
keyword?: string
|
||||
contractNo?: string
|
||||
contractName?: string
|
||||
customerId?: number | string
|
||||
|
||||
@@ -380,6 +380,11 @@ export const driverApi = {
|
||||
// 计划管理 API
|
||||
// =============================================================================
|
||||
|
||||
export interface PlanContractLink {
|
||||
contractId: string
|
||||
contractNo?: string
|
||||
}
|
||||
|
||||
export interface PlanProduct {
|
||||
id: string
|
||||
planId: string
|
||||
@@ -418,6 +423,11 @@ export interface PlanProduct {
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface PlanContractLink {
|
||||
contractId: string
|
||||
contractNo?: string
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
id: string
|
||||
planNo: string
|
||||
@@ -431,6 +441,8 @@ export interface Plan {
|
||||
completeStatus: string
|
||||
contractId?: string
|
||||
contractNo?: string
|
||||
supplierContracts?: PlanContractLink[]
|
||||
demanderContracts?: PlanContractLink[]
|
||||
supplierId?: string
|
||||
supplierName?: string
|
||||
destinationId?: string
|
||||
@@ -479,6 +491,8 @@ export interface Plan {
|
||||
createBy?: string
|
||||
createTime: string
|
||||
products?: PlanProduct[]
|
||||
supplierContracts?: PlanContractLink[]
|
||||
demanderContracts?: PlanContractLink[]
|
||||
}
|
||||
|
||||
export interface PlanQuery {
|
||||
@@ -520,7 +534,7 @@ export interface PlanTransportCreateBlock {
|
||||
}
|
||||
|
||||
export interface PlanCreateFullPayload {
|
||||
contractId: string
|
||||
contractId?: string
|
||||
createDate: string
|
||||
planDate?: string | null
|
||||
belongsCompany: string
|
||||
@@ -556,6 +570,8 @@ export interface PlanCreateFullPayload {
|
||||
followUserId?: string
|
||||
followUserName?: string
|
||||
remark?: string
|
||||
supplierContracts?: PlanContractLink[]
|
||||
demanderContracts?: PlanContractLink[]
|
||||
products: Array<{
|
||||
productId: string
|
||||
productNo: string
|
||||
|
||||
@@ -15,51 +15,22 @@
|
||||
>
|
||||
<p v-if="selectableStatusHint" class="contract-selector-hint">{{ selectableStatusHint }}</p>
|
||||
|
||||
<div class="filter-block contract-selector-filter">
|
||||
<el-form ref="queryFormRef" :model="queryParams" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="合同编号" prop="contractNo">
|
||||
<el-input
|
||||
v-model="queryParams.contractNo"
|
||||
placeholder="搜索合同编号"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="合同名称" prop="contractName">
|
||||
<el-input
|
||||
v-model="queryParams.contractName"
|
||||
placeholder="搜索合同名称"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="客户名称" prop="customerName">
|
||||
<el-input
|
||||
v-model="queryParams.customerName"
|
||||
placeholder="搜索客户名称"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<div class="filter-actions-start" />
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" :loading="loading" @click="resetQuery">
|
||||
<el-icon><RefreshLeft /></el-icon>重置
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="loading" @click="handleQuery">
|
||||
<el-icon><Search /></el-icon>搜索
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="panel dialog-list-panel contract-selector-panel">
|
||||
<div class="contract-selector-quick-search">
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
clearable
|
||||
placeholder="合同编号 / 产品名称 / 产品拼音首字母"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div class="panel dialog-list-panel">
|
||||
<div class="list-body list-wrap dialog-list-body-only">
|
||||
<ListTable table-key="business-contract-selector"
|
||||
<ListTable
|
||||
table-key="business-contract-selector"
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
:data="contractList"
|
||||
@@ -74,7 +45,15 @@
|
||||
>
|
||||
<el-table-column label="" min-width="48" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-checkbox
|
||||
v-if="multiple"
|
||||
:model-value="selectedIdSet.has(String(row.id))"
|
||||
:disabled="!isContractSelectable(row)"
|
||||
@click.stop
|
||||
@change="checked => handleCheckboxChange(row, !!checked)"
|
||||
/>
|
||||
<el-radio
|
||||
v-else
|
||||
v-model="selectedId"
|
||||
:label="String(row.id)"
|
||||
:disabled="!isContractSelectable(row)"
|
||||
@@ -89,7 +68,7 @@
|
||||
<el-table-column
|
||||
prop="contractNo"
|
||||
label="合同编号"
|
||||
min-width="150"
|
||||
min-width="140"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
@@ -106,15 +85,7 @@
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="customerName"
|
||||
label="客户名称"
|
||||
min-width="150"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="产品信息" min-width="280" align="left" header-align="center">
|
||||
<el-table-column label="产品信息" min-width="260" align="left" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.products?.length" class="contract-selector-products">
|
||||
<div
|
||||
@@ -128,34 +99,36 @@
|
||||
<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">
|
||||
<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" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已关联的运输计划数" min-width="140" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
{{ planCountMap[String(row.id)] ?? 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" min-width="100" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<DictTag dict-type="crm_contract_status" :value="row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="!hideAmount"
|
||||
prop="amount"
|
||||
label="合同金额"
|
||||
min-width="120"
|
||||
align="right"
|
||||
prop="customerName"
|
||||
label="客户名称"
|
||||
min-width="150"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<span class="money-display">{{ formatMoneyDisplay(row.amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="signDate" label="签订日期" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column label="已关联的运输计划数" min-width="140" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
{{ planCountMap[String(row.id)] ?? 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</ListTable>
|
||||
|
||||
<ListPagination
|
||||
@@ -176,7 +149,7 @@
|
||||
<el-button
|
||||
class="btn-main"
|
||||
type="primary"
|
||||
:disabled="!currentRow"
|
||||
:disabled="multiple ? selectedIdSet.size < 1 : !currentRow"
|
||||
:loading="detailLoading"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
@@ -190,15 +163,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, Close, RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, TableInstance } from 'element-plus'
|
||||
import { Check, Close, Search } from '@element-plus/icons-vue'
|
||||
import type { TableInstance } from 'element-plus'
|
||||
import { getContract, listContract } from '@/api/crm/contract'
|
||||
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(
|
||||
@@ -211,10 +183,19 @@ const props = withDefaults(
|
||||
hideAmount?: boolean
|
||||
/** 仅这些状态的合同可被选中;未配置时列表内均可选 */
|
||||
selectableStatuses?: string[]
|
||||
/** 限定客户:只展示该客户合同 */
|
||||
customerId?: string
|
||||
/** 多选模式(运输计划供方/需方) */
|
||||
multiple?: boolean
|
||||
/** 多选模式下已选合同 ID(打开时预勾选) */
|
||||
selectedIds?: string[]
|
||||
}>(),
|
||||
{
|
||||
hideAmount: false,
|
||||
selectableStatuses: undefined
|
||||
selectableStatuses: undefined,
|
||||
customerId: undefined,
|
||||
multiple: false,
|
||||
selectedIds: () => []
|
||||
}
|
||||
)
|
||||
|
||||
@@ -223,6 +204,7 @@ const dictStore = useDictStore()
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
confirm: [contract: ContractVO]
|
||||
confirmMultiple: [contracts: ContractVO[]]
|
||||
}>()
|
||||
|
||||
const innerVisible = computed({
|
||||
@@ -230,7 +212,6 @@ const innerVisible = computed({
|
||||
set: value => emit('update:visible', value)
|
||||
})
|
||||
|
||||
const queryFormRef = ref<FormInstance>()
|
||||
const tableRef = ref<TableInstance>()
|
||||
const loading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
@@ -245,20 +226,30 @@ const contractDetailRef = ref<InstanceType<typeof ContractDetailDialog>>()
|
||||
const queryParams = reactive<ContractQuery>({
|
||||
pageNum: 1,
|
||||
pageSize: 20,
|
||||
contractNo: '',
|
||||
contractName: '',
|
||||
customerName: '',
|
||||
keyword: '',
|
||||
customerId: undefined,
|
||||
excludeStatus: 'invalid',
|
||||
includeProducts: true
|
||||
})
|
||||
|
||||
const selectedIdSet = ref<Set<string>>(new Set())
|
||||
|
||||
const selectableStatusHint = computed(() => {
|
||||
if (!props.selectableStatuses?.length) return ''
|
||||
const labels = props.selectableStatuses
|
||||
.map(status => dictStore.getLabel('crm_contract_status', status))
|
||||
.filter(Boolean)
|
||||
if (!labels.length) return '仅部分状态的合同可被选择'
|
||||
return `仅「${labels.join('」「')}」状态的合同可被选择,其他状态仅展示不可选`
|
||||
const parts: string[] = []
|
||||
if (props.customerId) {
|
||||
parts.push('仅展示当前客户下的合同')
|
||||
}
|
||||
if (props.selectableStatuses?.length) {
|
||||
const labels = props.selectableStatuses
|
||||
.map(status => dictStore.getLabel('crm_contract_status', status))
|
||||
.filter(Boolean)
|
||||
if (labels.length) {
|
||||
parts.push(`仅「${labels.join('」「')}」状态可被选择,其他状态仅展示不可选`)
|
||||
} else {
|
||||
parts.push('仅部分状态的合同可被选择')
|
||||
}
|
||||
}
|
||||
return parts.join(';')
|
||||
})
|
||||
|
||||
function formatContractProductLine(product: ContractProductForm) {
|
||||
@@ -357,6 +348,15 @@ function selectContractRow(row: ContractVO | null | undefined, options?: { silen
|
||||
}
|
||||
|
||||
function handleRowClick(row: ContractVO) {
|
||||
if (props.multiple) {
|
||||
if (!isContractSelectable(row)) {
|
||||
warnContractNotSelectable(row)
|
||||
return
|
||||
}
|
||||
const id = String(row.id)
|
||||
handleCheckboxChange(row, !selectedIdSet.value.has(id))
|
||||
return
|
||||
}
|
||||
selectContractRow(row)
|
||||
}
|
||||
|
||||
@@ -364,6 +364,19 @@ function handleRadioChange(row: ContractVO) {
|
||||
selectContractRow(row)
|
||||
}
|
||||
|
||||
function handleCheckboxChange(row: ContractVO, checked: boolean) {
|
||||
if (!row?.id) return
|
||||
if (!isContractSelectable(row)) {
|
||||
warnContractNotSelectable(row)
|
||||
return
|
||||
}
|
||||
const id = String(row.id)
|
||||
const next = new Set(selectedIdSet.value)
|
||||
if (checked) next.add(id)
|
||||
else next.delete(id)
|
||||
selectedIdSet.value = next
|
||||
}
|
||||
|
||||
async function loadPlanCounts() {
|
||||
const ids = contractList.value.map(item => String(item.id)).filter(Boolean)
|
||||
if (!ids.length) {
|
||||
@@ -379,28 +392,13 @@ async function loadPlanCounts() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleQuery() {
|
||||
queryParams.pageNum = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
function resetQuery() {
|
||||
queryFormRef.value?.resetFields()
|
||||
queryParams.pageNum = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
() => ({
|
||||
contractNo: queryParams.contractNo,
|
||||
contractName: queryParams.contractName,
|
||||
customerName: queryParams.customerName
|
||||
}),
|
||||
() => queryParams.keyword,
|
||||
() => {
|
||||
queryParams.pageNum = 1
|
||||
getList()
|
||||
},
|
||||
{ debounce: 300, deep: true }
|
||||
{ debounce: 300 }
|
||||
)
|
||||
|
||||
function handleCurrentChange(row: ContractVO | undefined) {
|
||||
@@ -422,8 +420,11 @@ function handleCurrentChange(row: ContractVO | undefined) {
|
||||
function handleOpen() {
|
||||
selectedId.value = ''
|
||||
currentRow.value = null
|
||||
selectedIdSet.value = new Set((props.selectedIds || []).map(String).filter(Boolean))
|
||||
planCountMap.value = {}
|
||||
queryParams.pageNum = 1
|
||||
queryParams.keyword = ''
|
||||
queryParams.customerId = props.customerId || undefined
|
||||
nextTick(() => {
|
||||
tableRef.value?.setCurrentRow(undefined)
|
||||
})
|
||||
@@ -431,6 +432,26 @@ function handleOpen() {
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (props.multiple) {
|
||||
const ids = Array.from(selectedIdSet.value)
|
||||
if (!ids.length) {
|
||||
ElMessage.warning('请至少选择一份合同')
|
||||
return
|
||||
}
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const details: ContractVO[] = []
|
||||
for (const id of ids) {
|
||||
const res = await getContract(id)
|
||||
details.push((res.data ?? res) as ContractVO)
|
||||
}
|
||||
emit('confirmMultiple', details)
|
||||
innerVisible.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!currentRow.value?.id) return
|
||||
if (!isContractSelectable(currentRow.value)) {
|
||||
warnContractNotSelectable(currentRow.value)
|
||||
@@ -460,48 +481,18 @@ async function handleConfirm() {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.contract-selector-filter {
|
||||
padding: 12px 16px 10px;
|
||||
background: var(--mint-surface);
|
||||
border: 1px solid var(--mint-border);
|
||||
border-radius: var(--mint-radius);
|
||||
margin-bottom: 12px;
|
||||
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px 14px;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-actions-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.filter-actions-end .el-button + .el-button {
|
||||
margin-left: 0 !important;
|
||||
.contract-selector-panel {
|
||||
> .list-body.list-wrap {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.contract-selector-filter .filter-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.contract-selector-quick-search {
|
||||
margin: 0 0 12px;
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.contract-selector-filter .filter-grid {
|
||||
grid-template-columns: 1fr;
|
||||
:deep(.el-input) {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
<el-tab-pane label="归档填写" name="archive" />
|
||||
<el-tab-pane label="计划概况" name="overview" />
|
||||
<el-tab-pane label="运输信息" name="transport" />
|
||||
<el-tab-pane label="合同信息" name="contract" />
|
||||
</el-tabs>
|
||||
|
||||
<div v-show="activeTab === 'archive'" class="pl-archive-tab-panel pl-arrange-form-scroll">
|
||||
@@ -189,7 +188,7 @@
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const activeTab = ref<'archive' | 'overview' | 'transport' | 'contract'>('archive')
|
||||
const activeTab = ref<'archive' | 'overview' | 'transport'>('archive')
|
||||
const tabsRef = ref<InstanceType<typeof PlPlanViewTabs> | null>(null)
|
||||
const plan = ref<Partial<Plan>>({})
|
||||
const feedbackMirrorEl = ref<HTMLElement | null>(null)
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="pl-plan-contract-panel" v-loading="loadingDetails">
|
||||
<slot
|
||||
name="toolbar"
|
||||
:open="openSelector"
|
||||
:disabled="triggerDisabled"
|
||||
:hint="hintText"
|
||||
:hint-warn="hintWarn"
|
||||
:count="selectedIds.length"
|
||||
>
|
||||
<div v-if="!readonly" class="pl-plan-contract-panel__toolbar">
|
||||
<el-button class="btn-ghost" :disabled="triggerDisabled" @click="openSelector">
|
||||
<el-icon><Search /></el-icon>选择合同
|
||||
</el-button>
|
||||
<span v-if="hintText" class="pl-plan-contract-panel__hint" :class="{ 'is-warn': hintWarn }">
|
||||
{{ hintText }}
|
||||
</span>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<div v-if="detailRows.length" class="pl-plan-contract-cards">
|
||||
<article v-for="row in detailRows" :key="row.id" class="pl-plan-contract-card">
|
||||
<div class="pl-plan-contract-card__head">
|
||||
<div class="pl-plan-contract-card__meta">
|
||||
<span class="pl-plan-contract-card__no">{{ row.contractNo || row.id || '—' }}</span>
|
||||
<DictTag dict-type="crm_contract_type" :value="row.contractType" />
|
||||
<span class="pl-plan-contract-card__count">
|
||||
{{ row.products?.length || 0 }} 个产品
|
||||
</span>
|
||||
</div>
|
||||
<div class="pl-plan-contract-card__actions">
|
||||
<el-button link type="primary" @click="handleViewContract(String(row.id))">
|
||||
<el-icon><View /></el-icon>查看
|
||||
</el-button>
|
||||
<el-button v-if="!readonly" link type="danger" @click="removeContract(String(row.id))">
|
||||
<el-icon><Delete /></el-icon>移除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="row.products?.length" class="pl-plan-contract-card__body">
|
||||
<div class="pl-plan-contract-card__table-scroll">
|
||||
<el-table
|
||||
:data="row.products"
|
||||
class="list-table pl-plan-contract-card__table"
|
||||
size="small"
|
||||
stripe
|
||||
>
|
||||
<el-table-column label="产品名称" min-width="110" align="left" show-overflow-tooltip>
|
||||
<template #default="{ row: product }">
|
||||
{{ product.standardProductName || product.productName || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="别名" min-width="80" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row: product }">
|
||||
{{ product.aliasName || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="品牌" min-width="72" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row: product }">
|
||||
{{ formatBrandConstraintLabel(product.brandMode, product.brandName) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="型号/规格" min-width="90" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row: product }">
|
||||
{{ product.model || product.productModel || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" min-width="64" align="right" show-overflow-tooltip>
|
||||
<template #default="{ row: product }">
|
||||
{{ product.quantity ?? '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单位" min-width="56" align="center">
|
||||
<template #default="{ row: product }">
|
||||
<DictTag dict-type="crm_product_unit" :value="product.unit" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="包装规格" min-width="90" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row: product }">
|
||||
{{ product.packageSpec || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="送货方式" min-width="80" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row: product }">
|
||||
{{ product.deliveryMethod || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="90" align="left" show-overflow-tooltip>
|
||||
<template #default="{ row: product }">
|
||||
{{ product.remark || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="pl-plan-contract-card__empty">暂无产品明细</p>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else-if="readonly || customerId" class="pl-plan-contract-panel__empty-hint">
|
||||
{{ readonly ? '暂无关联合同' : '暂未选择合同' }}
|
||||
</p>
|
||||
|
||||
<ContractSelector
|
||||
v-if="!readonly"
|
||||
v-model:visible="selectorVisible"
|
||||
:customer-id="customerId"
|
||||
:selectable-statuses="selectableStatuses"
|
||||
multiple
|
||||
:selected-ids="selectedIds"
|
||||
hide-amount
|
||||
@confirm-multiple="handleConfirmMultiple"
|
||||
/>
|
||||
|
||||
<ContractDetailDialog
|
||||
v-if="contractDetailDialogActive"
|
||||
ref="contractDetailRef"
|
||||
hide-product-price
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Delete, Search, View } from '@element-plus/icons-vue'
|
||||
import { getContract } from '@/api/crm/contract'
|
||||
import type { ContractProductForm, ContractVO } from '@/api/crm/contract/types'
|
||||
import { fetchProductDetail } from '@/api/crm/product'
|
||||
import ContractSelector from '@/components/business/contract-selector/index.vue'
|
||||
import DictTag from '@/components/DictTag/index.vue'
|
||||
import { applyAliasDisplayMeta, formatBrandConstraintLabel } from '@/utils/crm-product-brand'
|
||||
|
||||
const ContractDetailDialog = defineAsyncComponent(
|
||||
() => import('@/views/crm/contract/components/ContractDetailDialog.vue')
|
||||
)
|
||||
|
||||
export interface PlanContractLinkForm {
|
||||
contractId: string
|
||||
contractNo?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: PlanContractLinkForm[]
|
||||
customerId?: string
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
/** 只读:展示合同卡片,标题栏提供「查看」 */
|
||||
readonly?: boolean
|
||||
selectableStatuses?: string[]
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
required: false,
|
||||
readonly: false,
|
||||
selectableStatuses: () => ['approved', 'executing']
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: PlanContractLinkForm[]]
|
||||
view: [contractId: string]
|
||||
}>()
|
||||
|
||||
const selectorVisible = ref(false)
|
||||
const detailRows = ref<ContractVO[]>([])
|
||||
const loadingDetails = ref(false)
|
||||
const contractDetailDialogActive = ref(false)
|
||||
const contractDetailRef = ref<InstanceType<typeof ContractDetailDialog>>()
|
||||
|
||||
const selectedIds = computed(() =>
|
||||
(props.modelValue || []).map(item => String(item.contractId)).filter(Boolean)
|
||||
)
|
||||
|
||||
const triggerDisabled = computed(() => props.disabled || !props.customerId)
|
||||
|
||||
const hintWarn = computed(
|
||||
() => !!props.customerId && props.required && !selectedIds.value.length
|
||||
)
|
||||
|
||||
const hintText = computed(() => {
|
||||
if (!props.customerId) return '请先选择客户'
|
||||
if (hintWarn.value) return '请至少选择一份合同'
|
||||
if (selectedIds.value.length) return `已选 ${selectedIds.value.length} 份`
|
||||
return ''
|
||||
})
|
||||
|
||||
function openSelector() {
|
||||
if (props.readonly || triggerDisabled.value) return
|
||||
selectorVisible.value = true
|
||||
}
|
||||
|
||||
async function enrichContractProducts(contract: ContractVO): Promise<ContractVO> {
|
||||
const products = (contract.products || []) as ContractProductForm[]
|
||||
if (!products.length) return contract
|
||||
await Promise.all(
|
||||
products.map(async line => {
|
||||
if (!line.productId) {
|
||||
line.standardProductName = line.standardProductName || line.productName
|
||||
return
|
||||
}
|
||||
if (line.standardProductName || line.aliasName) return
|
||||
try {
|
||||
const res: any = await fetchProductDetail({ id: String(line.productId) })
|
||||
const data = res?.data ?? res
|
||||
if (data) applyAliasDisplayMeta(line, data)
|
||||
} catch {
|
||||
line.standardProductName = line.productName
|
||||
line.aliasName = undefined
|
||||
}
|
||||
})
|
||||
)
|
||||
return { ...contract, products }
|
||||
}
|
||||
|
||||
function emitLinksFromDetails(rows: ContractVO[]) {
|
||||
emit(
|
||||
'update:modelValue',
|
||||
rows.map(row => ({
|
||||
contractId: String(row.id),
|
||||
contractNo: row.contractNo || undefined
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
async function loadDetails(ids: string[]) {
|
||||
if (!ids.length) {
|
||||
detailRows.value = []
|
||||
return
|
||||
}
|
||||
loadingDetails.value = true
|
||||
try {
|
||||
const rows: ContractVO[] = []
|
||||
for (const id of ids) {
|
||||
const existing = detailRows.value.find(item => String(item.id) === id)
|
||||
if (existing?.products?.length) {
|
||||
rows.push(await enrichContractProducts(existing))
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const res = await getContract(id)
|
||||
const detail = (res.data ?? res) as ContractVO
|
||||
rows.push(await enrichContractProducts(detail))
|
||||
} catch {
|
||||
const link = (props.modelValue || []).find(item => String(item.contractId) === id)
|
||||
rows.push({
|
||||
id,
|
||||
contractNo: link?.contractNo || id,
|
||||
products: []
|
||||
} as ContractVO)
|
||||
}
|
||||
}
|
||||
detailRows.value = rows
|
||||
} finally {
|
||||
loadingDetails.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmMultiple(contracts: ContractVO[]) {
|
||||
const enriched = await Promise.all((contracts || []).map(item => enrichContractProducts(item)))
|
||||
detailRows.value = enriched
|
||||
emitLinksFromDetails(detailRows.value)
|
||||
}
|
||||
|
||||
function removeContract(id: string) {
|
||||
const next = detailRows.value.filter(item => String(item.id) !== id)
|
||||
detailRows.value = next
|
||||
emitLinksFromDetails(next)
|
||||
}
|
||||
|
||||
async function handleViewContract(contractId: string) {
|
||||
const id = String(contractId || '').trim()
|
||||
if (!id) {
|
||||
ElMessage.warning('暂无关联合同')
|
||||
return
|
||||
}
|
||||
emit('view', id)
|
||||
contractDetailDialogActive.value = true
|
||||
await nextTick()
|
||||
contractDetailRef.value?.open(id)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openSelector
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.customerId,
|
||||
(id, prev) => {
|
||||
if (props.readonly) return
|
||||
if (prev && id !== prev) {
|
||||
detailRows.value = []
|
||||
emit('update:modelValue', [])
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
selectedIds,
|
||||
ids => {
|
||||
const current = detailRows.value.map(item => String(item.id)).join(',')
|
||||
const next = ids.join(',')
|
||||
if (current !== next) {
|
||||
loadDetails(ids)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pl-plan-contract-panel {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pl-plan-contract-panel__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pl-plan-contract-panel__hint {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
|
||||
&.is-warn {
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
.pl-plan-contract-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pl-plan-contract-card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--mint-border, #d9e8e2);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.pl-plan-contract-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: var(--primary-bg, #ecfdf5);
|
||||
border-bottom: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
.pl-plan-contract-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pl-plan-contract-card__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pl-plan-contract-card__no {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-dark, #059669);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.pl-plan-contract-card__count {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pl-plan-contract-card__body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pl-plan-contract-card__table-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.pl-plan-contract-card__table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
|
||||
:deep(.el-table__header th) {
|
||||
padding: 6px 0;
|
||||
font-size: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
:deep(.el-table__body td) {
|
||||
padding: 6px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.pl-plan-contract-card__empty,
|
||||
.pl-plan-contract-panel__empty-hint {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
@@ -124,10 +124,56 @@
|
||||
{{ formatBrandQtySummary(row.brandQtyRows) || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="packageSpec" label="包装规格" min-width="110" show-overflow-tooltip />
|
||||
<el-table-column label="送货方式" min-width="100" align="center">
|
||||
<el-table-column prop="packageSpec" label="包装规格" min-width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="info">{{ getDeliveryMethodLabel(row.deliveryMethod) }}</el-tag>
|
||||
<el-select
|
||||
v-if="editablePackageDelivery"
|
||||
v-model="row.packageSpec"
|
||||
class="pl-plan-product-cell-control"
|
||||
placeholder="请选择包装规格"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="val => handlePackageSpecChange(row, val)"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in dictStore.getOptions('crm_product_gg')"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
<template v-else>
|
||||
<DictTag v-if="row.packageSpec" dict-type="crm_product_gg" :value="row.packageSpec" />
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="送货方式" min-width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-if="editablePackageDelivery"
|
||||
v-model="row.deliveryMethod"
|
||||
class="pl-plan-product-cell-control"
|
||||
placeholder="请选择送货方式"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in dictStore.getOptions('crm_shfs')"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
<template v-else>
|
||||
<el-tag v-if="row.deliveryMethod" size="small" type="info">
|
||||
{{ getDeliveryMethodDisplay(row.deliveryMethod) }}
|
||||
</el-tag>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="editableRemark" label="备注" min-width="140">
|
||||
@@ -190,6 +236,7 @@ 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'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
|
||||
const props = defineProps<{
|
||||
products: PlanFormProductRow[]
|
||||
@@ -201,6 +248,8 @@ const props = defineProps<{
|
||||
editableQty?: boolean
|
||||
/** 产品信息区:别名 / 品牌可改 */
|
||||
editableAliasBrand?: boolean
|
||||
/** 包装规格 / 送货方式可下拉编辑(对齐报价单) */
|
||||
editablePackageDelivery?: boolean
|
||||
/** 新增/编辑弹窗默认不展示实际数量 */
|
||||
showActualQty?: boolean
|
||||
/** 展示归档品牌数量摘要(如:君正 12 / 红四方 13) */
|
||||
@@ -214,10 +263,22 @@ const emit = defineEmits<{
|
||||
select: [row: PlanFormProductRow, index: number]
|
||||
}>()
|
||||
|
||||
const dictStore = useDictStore()
|
||||
|
||||
const selectedProductIdSet = computed(
|
||||
() => new Set((props.selectedProductIds || []).map(id => String(id)))
|
||||
)
|
||||
|
||||
function handlePackageSpecChange(row: PlanFormProductRow, value: string | number | boolean | undefined) {
|
||||
row.packageSpec = value == null || value === '' ? '' : String(value)
|
||||
}
|
||||
|
||||
function getDeliveryMethodDisplay(method?: string) {
|
||||
const label = dictStore.getLabel('crm_shfs', method || '')
|
||||
if (label && label !== method) return label
|
||||
return getDeliveryMethodLabel(method)
|
||||
}
|
||||
|
||||
function isProductSelected(row: PlanFormProductRow) {
|
||||
return selectedProductIdSet.value.has(String(row.productId))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,68 +1,15 @@
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="pl-plan-transport-feedback-blocks">
|
||||
<section class="pl-feedback-shared-products">
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
<h3 class="pl-plan-form-section-title">产品信息</h3>
|
||||
<p v-if="!sharedProductTableRows.length && !loading" class="pl-plan-form-empty-hint"
|
||||
>暂无产品</p
|
||||
>
|
||||
<div v-else class="view-subtable-wrap">
|
||||
<ListTable
|
||||
table-key="business-pl-plan-transport-feedback-shared-products"
|
||||
:data="sharedProductTableRows"
|
||||
class="view-subtable list-table"
|
||||
stripe
|
||||
>
|
||||
<el-table-column type="index" label="序号" min-width="68" align="center" />
|
||||
<el-table-column
|
||||
prop="productNo"
|
||||
label="产品编码"
|
||||
min-width="110"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">{{ row.productNo || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="productName"
|
||||
label="产品名称"
|
||||
min-width="120"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">{{ row.productName || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="aliasName" label="别名" min-width="110" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.aliasName || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="brandName" label="品牌" min-width="100" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.brandName || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="specification"
|
||||
label="型号/规格"
|
||||
min-width="100"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">{{ row.specification || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unitLabel" label="单位" min-width="72" align="center">
|
||||
<template #default="{ row }">{{ row.unitLabel || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="planQty" label="计划数量" min-width="100" align="right">
|
||||
<template #default="{ row }">{{ row.planQty ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="packageSpec"
|
||||
label="包装规格"
|
||||
min-width="110"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">{{ row.packageSpec || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.remark || '—' }}</template>
|
||||
</el-table-column>
|
||||
</ListTable>
|
||||
</div>
|
||||
<p v-if="!planProductRows.length && !loading" class="pl-plan-form-empty-hint">暂无产品明细</p>
|
||||
<PlPlanFormProductTable
|
||||
v-else
|
||||
:products="planProductRows"
|
||||
show-actual-qty
|
||||
show-brand-qty
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div class="pl-feedback-cards-row">
|
||||
@@ -377,6 +324,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { CopyDocument, Edit } from '@element-plus/icons-vue'
|
||||
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
|
||||
import PlPlanFormProductTable from '@/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue'
|
||||
import {
|
||||
businessTypeTagType,
|
||||
formatKgToTon
|
||||
@@ -422,19 +370,6 @@
|
||||
siteFeedbackImages: string
|
||||
}
|
||||
|
||||
type SharedProductTableRow = {
|
||||
key: string
|
||||
productNo?: string
|
||||
productName: string
|
||||
aliasName?: string
|
||||
brandName?: string
|
||||
specification?: string
|
||||
unitLabel?: string
|
||||
planQty?: number | null
|
||||
packageSpec?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
type TicketQtyRow = {
|
||||
key: string
|
||||
refLabel: string
|
||||
@@ -599,63 +534,19 @@
|
||||
return props.ioOrder.kind === 'inbound' ? '入库单回传' : '出库单回传'
|
||||
})
|
||||
|
||||
const sharedProductTableRows = computed<SharedProductTableRow[]>(() => {
|
||||
if (props.planProductRows?.length) {
|
||||
return props.planProductRows.map((row, index) => {
|
||||
const unitLabel =
|
||||
dictStore.getLabel('crm_product_unit', row.unit) || row.unit || ''
|
||||
const brandLabel = resolveDisplayBrandLabel({
|
||||
brandMode: row.brandMode,
|
||||
brandName: row.brandName
|
||||
})
|
||||
return {
|
||||
key: `plan-${row.id || row.productId || index}`,
|
||||
productNo: row.productNo,
|
||||
productName: row.standardProductName || row.productName || '',
|
||||
aliasName: row.aliasName,
|
||||
brandName: brandLabel,
|
||||
specification: row.model,
|
||||
unitLabel,
|
||||
planQty: row.quantity,
|
||||
packageSpec: row.packageSpec,
|
||||
remark: row.remark
|
||||
}
|
||||
})
|
||||
const feedbackQtyUnit = computed(() => {
|
||||
const planRow = props.planProductRows?.[0]
|
||||
if (planRow?.unit) {
|
||||
return dictStore.getLabel('crm_product_unit', planRow.unit) || planRow.unit || '吨'
|
||||
}
|
||||
|
||||
if (props.driverFeedbackTableRows?.length) {
|
||||
return props.driverFeedbackTableRows.map((row, index) => ({
|
||||
key: `driver-${index}-${row.productNo || row.productName}`,
|
||||
productNo: row.productNo,
|
||||
productName: row.productName || '',
|
||||
aliasName: row.aliasName,
|
||||
brandName: row.brandName,
|
||||
specification: row.specification || row.model,
|
||||
unitLabel: row.unitLabel || row.unit,
|
||||
planQty: row.planQty,
|
||||
packageSpec: row.packageSpec,
|
||||
remark: row.remark
|
||||
}))
|
||||
const driverRow = props.driverFeedbackTableRows?.[0]
|
||||
if (driverRow?.unitLabel || driverRow?.unit) {
|
||||
return driverRow.unitLabel || driverRow.unit || '吨'
|
||||
}
|
||||
|
||||
return resolvedIoOrderItems.value.map((row, index) => ({
|
||||
key: `io-${index}-${row.productId || row.productName}`,
|
||||
productNo: row.productNo,
|
||||
productName: row.productName || '',
|
||||
aliasName: row.aliasName,
|
||||
brandName: row.brandName,
|
||||
specification: row.specification,
|
||||
unitLabel: row.unitLabel || row.unit,
|
||||
planQty: row.planQty,
|
||||
packageSpec: row.packageSpec,
|
||||
remark: row.remark
|
||||
}))
|
||||
const ioRow = resolvedIoOrderItems.value[0]
|
||||
return ioRow?.unitLabel || ioRow?.unit || '吨'
|
||||
})
|
||||
|
||||
const feedbackQtyUnit = computed(
|
||||
() => sharedProductTableRows.value[0]?.unitLabel || '吨'
|
||||
)
|
||||
|
||||
function formatTicketQty(value: unknown) {
|
||||
if (value == null || value === '') return '—'
|
||||
return String(value)
|
||||
@@ -780,42 +671,6 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pl-feedback-shared-products {
|
||||
padding: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.view-subtable-wrap {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.view-subtable-wrap :deep(.el-table.view-subtable) {
|
||||
--el-table-header-bg-color: #f8fafc;
|
||||
--el-table-header-text-color: #1e293b;
|
||||
--el-table-border-color: #e2e8f0;
|
||||
width: 100%;
|
||||
|
||||
.el-table__header th.el-table__cell,
|
||||
.el-table__body td.el-table__cell {
|
||||
font-size: 14px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.el-table__header th.el-table__cell .cell {
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.el-table__body td.el-table__cell .cell {
|
||||
color: #1e293b;
|
||||
}
|
||||
}
|
||||
|
||||
.pl-feedback-cards-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<el-tab-pane label="计划概况" name="overview">
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
<h3 class="pl-plan-form-section-title">基础信息</h3>
|
||||
<ViewKvTable :fields="overviewBasicViewFields" mode="quint" />
|
||||
<ViewKvTable :fields="overviewBasicViewFields" mode="sept" />
|
||||
</section>
|
||||
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
@@ -18,6 +18,82 @@
|
||||
<PlPlanFormProductTable v-else :products="productRows" show-actual-qty show-brand-qty />
|
||||
</section>
|
||||
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
<h3 class="pl-plan-form-section-title">供需双方</h3>
|
||||
<div class="pl-plan-party-row">
|
||||
<div class="pl-plan-party-block">
|
||||
<h4 class="pl-plan-subtitle">供方信息</h4>
|
||||
<PlPlanContractMultiSelect
|
||||
:model-value="plan?.supplierContracts || []"
|
||||
:customer-id="plan?.supplierId"
|
||||
readonly
|
||||
>
|
||||
<template #toolbar>
|
||||
<div class="pl-plan-party-select-row">
|
||||
<div class="pl-plan-view-party-field pl-plan-party-select-row__customer">
|
||||
<span class="pl-plan-view-party-field__value">{{
|
||||
plan?.supplierName || '—'
|
||||
}}</span>
|
||||
<span
|
||||
v-if="supplierContactText"
|
||||
class="pl-plan-view-party-field__contact"
|
||||
>
|
||||
{{ supplierContactText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PlPlanContractMultiSelect>
|
||||
</div>
|
||||
|
||||
<div class="pl-plan-flow-bridge" aria-hidden="true">
|
||||
<span class="pl-plan-flow-bridge__label">供货</span>
|
||||
<span class="pl-plan-flow-bridge__arrow">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path
|
||||
d="M5 12h11.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.75"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.5 8.5 18.5 12l-5 3.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pl-plan-party-block">
|
||||
<h4 class="pl-plan-subtitle">需方信息</h4>
|
||||
<PlPlanContractMultiSelect
|
||||
:model-value="plan?.demanderContracts || []"
|
||||
:customer-id="plan?.customerId"
|
||||
readonly
|
||||
>
|
||||
<template #toolbar>
|
||||
<div class="pl-plan-party-select-row">
|
||||
<div class="pl-plan-view-party-field pl-plan-party-select-row__customer">
|
||||
<span class="pl-plan-view-party-field__value">{{
|
||||
plan?.customerName || '—'
|
||||
}}</span>
|
||||
<span
|
||||
v-if="demanderContactText"
|
||||
class="pl-plan-view-party-field__contact"
|
||||
>
|
||||
{{ demanderContactText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PlPlanContractMultiSelect>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
<h3 class="pl-plan-form-section-title">危化品证书信息</h3>
|
||||
<PlPlanHazardCertViewTable :products="hazardProducts" />
|
||||
@@ -127,59 +203,6 @@
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="合同信息" name="contract">
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
<p v-if="!contractSnapshot" class="pl-plan-form-empty-hint">暂无关联合同信息</p>
|
||||
<template v-else>
|
||||
<div class="pl-plan-form-section-head">
|
||||
<h3 class="pl-plan-form-section-title">基本信息</h3>
|
||||
<div class="pl-plan-form-section-actions">
|
||||
<el-button class="btn-ghost" @click="handleViewContract">
|
||||
<el-icon><View /></el-icon>查看合同
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<ViewKvTable :fields="contractBasicViewFields" mode="triple" />
|
||||
|
||||
<h4 class="pl-plan-subtitle">供需双方</h4>
|
||||
<div class="parties-row">
|
||||
<div class="party-panel">
|
||||
<div class="party-panel__title">供方信息</div>
|
||||
<ViewKvTable :fields="contractSupplierViewFields" mode="dual" />
|
||||
</div>
|
||||
|
||||
<div class="party-flow-bridge" aria-hidden="true">
|
||||
<span class="party-flow-bridge__label">供货</span>
|
||||
<span class="party-flow-bridge__arrow">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path
|
||||
d="M5 12h11.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.75"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.5 8.5 18.5 12l-5 3.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="party-panel">
|
||||
<div class="party-panel__title">需方信息</div>
|
||||
<ViewKvTable :fields="contractBuyerViewFields" mode="dual" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="pl-plan-subtitle">合同产品预览</h4>
|
||||
<PlPlanFormProductTable :products="contractProductRows" />
|
||||
</template>
|
||||
</section>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- defer:目标在同次渲染的兄弟节点(归档左侧)时,等 flush 后再解析,避免 emitsOptions 空引用 -->
|
||||
@@ -187,7 +210,6 @@
|
||||
<PlPlanTransportFeedbackBlocks v-bind="feedbackBlocksBind" v-on="feedbackBlocksOn" />
|
||||
</Teleport>
|
||||
|
||||
<ContractDetailDialog v-if="contractDetailDialogActive" ref="contractDetailRef" hide-product-price />
|
||||
<PlPlanDriverFeedbackDialog
|
||||
v-model:visible="driverFeedbackDialogVisible"
|
||||
:plan-id="planId"
|
||||
@@ -335,8 +357,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, Close, CopyDocument, View } from '@element-plus/icons-vue'
|
||||
import { Check, Close, CopyDocument } from '@element-plus/icons-vue'
|
||||
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
|
||||
import PlPlanContractMultiSelect from '@/components/business/pl-plan-contract-multi-select/index.vue'
|
||||
import BrandQtyChipsField, {
|
||||
type BrandQtyRow
|
||||
} from '@/components/business/brand-qty-chips-field/index.vue'
|
||||
@@ -352,6 +375,8 @@
|
||||
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'
|
||||
import type { ViewField } from '@/components/business/system-view-dialog/types'
|
||||
import type { ContractVO } from '@/api/crm/contract/types'
|
||||
import { getContactByCustomerId } from '@/api/crm/customer'
|
||||
import type { ContactVO } from '@/api/crm/customerTypes'
|
||||
import { inventoryApi } from '@/api/db/inventory'
|
||||
import {
|
||||
inboundOrderApi,
|
||||
@@ -362,7 +387,6 @@
|
||||
import {
|
||||
enrichPlanProductLinesAlias,
|
||||
getDeliveryMethodLabel,
|
||||
mapContractProducts,
|
||||
type PlanFormProductRow
|
||||
} from '@/composables/usePlContractToPlan'
|
||||
import { mapPlanProductToFormRow, usePlPlanViewDetail, type PlPlanTransportSiteRow } from '@/composables/usePlPlanViewDetail'
|
||||
@@ -387,10 +411,6 @@
|
||||
import { formatMoneyDisplay } from '@/utils/money-format'
|
||||
import type { Transport, TransportDriverProductFeedback } from '@/api/pl'
|
||||
|
||||
const ContractDetailDialog = defineAsyncComponent(
|
||||
() => import('@/views/crm/contract/components/ContractDetailDialog.vue')
|
||||
)
|
||||
|
||||
const dictStore = useDictStore()
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -405,7 +425,7 @@
|
||||
feedbackMirrorTo?: string | HTMLElement | null
|
||||
/** 隐藏内部 Tab 导航(由外层统一控制 Tab) */
|
||||
hideNav?: boolean
|
||||
/** 受控当前 Tab:overview / transport / contract */
|
||||
/** 受控当前 Tab:overview / transport */
|
||||
tab?: string
|
||||
}>(),
|
||||
{
|
||||
@@ -441,8 +461,6 @@
|
||||
}
|
||||
}
|
||||
)
|
||||
const contractDetailDialogActive = ref(false)
|
||||
const contractDetailRef = ref<InstanceType<typeof ContractDetailDialog>>()
|
||||
const vehicleViewVisible = ref(false)
|
||||
const driverFeedbackDialogVisible = ref(false)
|
||||
const siteFeedbackDialogVisible = ref(false)
|
||||
@@ -469,6 +487,80 @@
|
||||
resetDetail
|
||||
} = usePlPlanViewDetail()
|
||||
|
||||
type PartyContactDisplay = { contactName?: string; phone?: string }
|
||||
const supplierContact = ref<PartyContactDisplay>({})
|
||||
const demanderContact = ref<PartyContactDisplay>({})
|
||||
let partyContactLoadSeq = 0
|
||||
|
||||
function pickPrimaryContact(list: ContactVO[]): PartyContactDisplay {
|
||||
if (!list.length) return {}
|
||||
const primary =
|
||||
list.find(item => Number(item.isPrimary) === 1) ||
|
||||
list.find(item => Number(item.isKey) === 1) ||
|
||||
list[0]
|
||||
const contactName = String(primary?.contactName || '').trim()
|
||||
const phone = String(primary?.mobile || primary?.telephone || '').trim()
|
||||
return {
|
||||
contactName: contactName || undefined,
|
||||
phone: phone || undefined
|
||||
}
|
||||
}
|
||||
|
||||
function formatPartyContactText(contact: PartyContactDisplay): string {
|
||||
const parts: string[] = []
|
||||
if (contact.contactName) parts.push(contact.contactName)
|
||||
if (contact.phone) parts.push(contact.phone)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const supplierContactText = computed(() => formatPartyContactText(supplierContact.value))
|
||||
const demanderContactText = computed(() => formatPartyContactText(demanderContact.value))
|
||||
|
||||
async function loadPartyContact(customerId?: string | null): Promise<PartyContactDisplay> {
|
||||
const id = String(customerId || '').trim()
|
||||
if (!id) return {}
|
||||
try {
|
||||
const res = await getContactByCustomerId(id)
|
||||
const list = (res?.data || []) as ContactVO[]
|
||||
return pickPrimaryContact(Array.isArray(list) ? list : [])
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPartyContacts() {
|
||||
const seq = ++partyContactLoadSeq
|
||||
const supplierId = plan.value?.supplierId
|
||||
const customerId = plan.value?.customerId
|
||||
const [supplier, demanderFromCrm] = await Promise.all([
|
||||
loadPartyContact(supplierId),
|
||||
loadPartyContact(customerId)
|
||||
])
|
||||
if (seq !== partyContactLoadSeq) return
|
||||
|
||||
supplierContact.value = supplier
|
||||
|
||||
const planContactName = String(plan.value?.contactName || '').trim()
|
||||
const planContactPhone = String(plan.value?.contactPhone || '').trim()
|
||||
demanderContact.value = {
|
||||
contactName: demanderFromCrm.contactName || planContactName || undefined,
|
||||
phone: demanderFromCrm.phone || planContactPhone || undefined
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [plan.value?.id, plan.value?.supplierId, plan.value?.customerId] as const,
|
||||
() => {
|
||||
if (!plan.value?.id) {
|
||||
supplierContact.value = {}
|
||||
demanderContact.value = {}
|
||||
return
|
||||
}
|
||||
void refreshPartyContacts()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const ioOrderEditVisible = ref(false)
|
||||
const ioOrderSaving = ref(false)
|
||||
const ioOrderEditForm = ref<PlIoOrder | null>(null)
|
||||
@@ -1123,7 +1215,9 @@
|
||||
{ label: '计划日期', value: plan.value?.planDate },
|
||||
{ label: '归属公司', value: getPlPlanCompanyLabel(plan.value?.belongsCompany) },
|
||||
{ label: '单据类型', value: getPlPlanTypeLabel(plan.value?.planType || '') },
|
||||
{ label: '跟进人', value: plan.value?.followUserName }
|
||||
{ label: '跟进人', value: plan.value?.followUserName },
|
||||
{ label: '供方', value: plan.value?.supplierName },
|
||||
{ label: '需方', value: plan.value?.customerName }
|
||||
])
|
||||
|
||||
const summaryCarrierName = computed(() => displayCarrierName.value)
|
||||
@@ -1181,57 +1275,6 @@
|
||||
]
|
||||
})
|
||||
|
||||
const contractBasicViewFields = computed<ViewField[]>(() => {
|
||||
const c = contractSnapshot.value
|
||||
if (!c) return []
|
||||
const settlementLabel = c.settlementMethod
|
||||
? dictStore.getLabel('crm_settlement_method', c.settlementMethod)
|
||||
: '—'
|
||||
return [
|
||||
{ label: '合同编号', value: c.contractNo, long: false },
|
||||
{ label: '合同名称', value: c.contractName, long: false },
|
||||
{ label: '签订日期', value: c.signDate },
|
||||
{
|
||||
label: '合同有效期',
|
||||
value: c.startDate && c.endDate ? `${c.startDate} ~ ${c.endDate}` : '—',
|
||||
long: false
|
||||
},
|
||||
{ label: '结算方式', value: settlementLabel }
|
||||
]
|
||||
})
|
||||
|
||||
const contractSupplierViewFields = computed<ViewField[]>(() => {
|
||||
const c = contractSnapshot.value
|
||||
if (!c) return []
|
||||
return [
|
||||
{ label: '单位名称(章)', value: c.partyA },
|
||||
{ label: '单位地址', value: c.partyAAddress },
|
||||
{ label: '法定代表人', value: c.partyALegalPerson },
|
||||
{ label: '代理人', value: c.partyAAgent },
|
||||
{ label: '电话/传真', value: c.partyAPhoneFax },
|
||||
{ label: '开户银行', value: c.partyABank },
|
||||
{ label: '账号', value: c.partyAAccount }
|
||||
]
|
||||
})
|
||||
|
||||
const contractBuyerViewFields = computed<ViewField[]>(() => {
|
||||
const c = contractSnapshot.value
|
||||
if (!c) return []
|
||||
return [
|
||||
{ label: '单位名称(章)', value: c.partyB },
|
||||
{ label: '单位地址', value: c.partyBAddress },
|
||||
{ label: '法定代表人', value: c.partyBLegalPerson },
|
||||
{ label: '代理人', value: c.partyBAgent },
|
||||
{ label: '电话/传真', value: c.partyBPhoneFax },
|
||||
{ label: '开户银行', value: c.partyBBank },
|
||||
{ label: '账号', value: c.partyBAccount }
|
||||
]
|
||||
})
|
||||
|
||||
const contractProductRows = computed<PlanFormProductRow[]>(() =>
|
||||
mapContractProducts(contractSnapshot.value?.products)
|
||||
)
|
||||
|
||||
/** 危化证区域展示 CRM 标准名(productRows 已回填 standardProductName;库内 productName 可能是别名) */
|
||||
const hazardProducts = computed(() =>
|
||||
products.value
|
||||
@@ -1244,18 +1287,6 @@
|
||||
})
|
||||
)
|
||||
|
||||
async function handleViewContract() {
|
||||
const contractId = contractSnapshot.value?.id || plan.value?.contractId
|
||||
if (!contractId) {
|
||||
ElMessage.warning('暂无关联合同')
|
||||
return
|
||||
}
|
||||
|
||||
contractDetailDialogActive.value = true
|
||||
await nextTick()
|
||||
contractDetailRef.value?.open(contractId)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.planId,
|
||||
async (planId) => {
|
||||
@@ -1443,7 +1474,8 @@
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.parties-row {
|
||||
.parties-row,
|
||||
.pl-plan-party-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
@@ -1451,6 +1483,100 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pl-plan-party-block {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pl-plan-subtitle {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.pl-plan-party-select-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pl-plan-party-select-row__customer {
|
||||
flex: 1.4 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pl-plan-view-party-field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pl-plan-view-party-field__value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pl-plan-view-party-field__contact {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
white-space: nowrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pl-plan-flow-bridge {
|
||||
flex: 0 0 88px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.pl-plan-flow-bridge__label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-dark, #168f45);
|
||||
letter-spacing: 0.06em;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: #e6f8ed;
|
||||
border: 1px solid #a8e0b8;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.pl-plan-flow-bridge__arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
color: var(--primary-dark, #168f45);
|
||||
background: linear-gradient(145deg, #e6f8ed 0%, #d4f0de 100%);
|
||||
border: 1px solid #a8e0b8;
|
||||
box-shadow: 0 2px 8px rgba(28, 168, 81, 0.14);
|
||||
|
||||
svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.party-panel {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
@@ -1536,19 +1662,22 @@
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.parties-row {
|
||||
.parties-row,
|
||||
.pl-plan-party-row {
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.party-flow-bridge {
|
||||
.party-flow-bridge,
|
||||
.pl-plan-flow-bridge {
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.party-flow-bridge__arrow {
|
||||
.party-flow-bridge__arrow,
|
||||
.pl-plan-flow-bridge__arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** 运输计划:内置山峡/云栋客户(该侧合同可免) */
|
||||
export const PL_INTERNAL_CUSTOMER_NAMES = [
|
||||
'安庆市山峡化工有限公司',
|
||||
'安徽云栋化工有限公司'
|
||||
] as const
|
||||
|
||||
export function isPlInternalCustomerName(name?: string | null): boolean {
|
||||
const n = (name || '').trim()
|
||||
return (PL_INTERNAL_CUSTOMER_NAMES as readonly string[]).includes(n)
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
import { pinyin } from 'pinyin-pro'
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
import {
|
||||
filterPortalTopModules,
|
||||
resolveMegaPanelL3Links
|
||||
} from '@/utils/router/portal-menu'
|
||||
import {
|
||||
filterSidebarRoutes,
|
||||
isSidebarNavigableRoute
|
||||
} from '@/utils/router/sidebar-menu-filter'
|
||||
import { filterPortalTopModules, resolveMegaPanelL3Links } from '@/utils/router/portal-menu'
|
||||
import { filterSidebarRoutes, isSidebarNavigableRoute } from '@/utils/router/sidebar-menu-filter'
|
||||
|
||||
export interface PortalHomeCardItem {
|
||||
path: string
|
||||
@@ -21,10 +16,51 @@ export interface PortalHomeSection {
|
||||
cards: PortalHomeCardItem[]
|
||||
}
|
||||
|
||||
/** 首页菜单目录条目(含拼音检索元数据) */
|
||||
export interface PortalHomeMenuItem extends PortalHomeCardItem {
|
||||
modulePath: string
|
||||
moduleTitle: string
|
||||
/** 菜单全拼(小写、去空格与符号) */
|
||||
py: string
|
||||
/** 菜单首字母缩写(小写) */
|
||||
abbr: string
|
||||
/** 拼音首字母 A–Z,其它为 # */
|
||||
letter: string
|
||||
/** 模块名全拼 */
|
||||
modulePy: string
|
||||
/** 模块名首字母缩写 */
|
||||
moduleAbbr: string
|
||||
}
|
||||
|
||||
function routeTitle(item: AppRouteRecord): string {
|
||||
return String(item.meta?.title ?? item.name ?? item.path ?? '')
|
||||
}
|
||||
|
||||
function normalizeSearchKey(s: string): string {
|
||||
return String(s || '')
|
||||
.toLowerCase()
|
||||
.replace(/[\s'·・\-_/]/g, '')
|
||||
}
|
||||
|
||||
function buildPinyinMeta(title: string): { py: string; abbr: string; letter: string } {
|
||||
const text = String(title || '').trim()
|
||||
if (!text) {
|
||||
return { py: '', abbr: '', letter: '#' }
|
||||
}
|
||||
const py = normalizeSearchKey(pinyin(text, { toneType: 'none', type: 'string', separator: '' }))
|
||||
const abbr = normalizeSearchKey(
|
||||
pinyin(text, {
|
||||
pattern: 'first',
|
||||
toneType: 'none',
|
||||
type: 'string',
|
||||
separator: ''
|
||||
})
|
||||
)
|
||||
const first = (abbr[0] || py[0] || '#').toUpperCase()
|
||||
const letter = /[A-Z]/.test(first) ? first : '#'
|
||||
return { py, abbr, letter }
|
||||
}
|
||||
|
||||
function toCard(item: AppRouteRecord): PortalHomeCardItem | null {
|
||||
const path = String(item.meta?.activePath || item.path || '')
|
||||
if (!path) return null
|
||||
@@ -72,6 +108,55 @@ export function collectPortalHomeSections(menuList: AppRouteRecord[]): PortalHom
|
||||
.filter((s) => s.cards.length > 0)
|
||||
}
|
||||
|
||||
/** 扁平化首页菜单并附带拼音检索字段 */
|
||||
export function flattenPortalHomeMenus(menuList: AppRouteRecord[]): PortalHomeMenuItem[] {
|
||||
const sections = collectPortalHomeSections(menuList)
|
||||
const out: PortalHomeMenuItem[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const sec of sections) {
|
||||
const moduleMeta = buildPinyinMeta(sec.moduleTitle)
|
||||
for (const card of sec.cards) {
|
||||
if (seen.has(card.path)) continue
|
||||
seen.add(card.path)
|
||||
const meta = buildPinyinMeta(card.title)
|
||||
out.push({
|
||||
...card,
|
||||
modulePath: sec.modulePath,
|
||||
moduleTitle: sec.moduleTitle,
|
||||
py: meta.py,
|
||||
abbr: meta.abbr,
|
||||
letter: meta.letter,
|
||||
modulePy: moduleMeta.py,
|
||||
moduleAbbr: moduleMeta.abbr
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 中文名 / 模块名 / 全拼 / 首字母 匹配 */
|
||||
export function matchPortalHomeMenuItem(item: PortalHomeMenuItem, query: string): boolean {
|
||||
const q = String(query || '').trim()
|
||||
if (!q) return true
|
||||
if (item.title.includes(q) || item.moduleTitle.includes(q)) return true
|
||||
const nq = normalizeSearchKey(q)
|
||||
if (!nq) return true
|
||||
return (
|
||||
item.py.includes(nq) ||
|
||||
item.abbr.includes(nq) ||
|
||||
item.modulePy.includes(nq) ||
|
||||
item.moduleAbbr.includes(nq)
|
||||
)
|
||||
}
|
||||
|
||||
/** 标题内高亮查询子串(仅字面匹配) */
|
||||
export function highlightPortalHomeTitle(title: string, query: string): string {
|
||||
const q = String(query || '').trim()
|
||||
if (!q || !title.includes(q)) return title
|
||||
return title.split(q).join(`<mark>${q}</mark>`)
|
||||
}
|
||||
|
||||
/** 在全部分区中按 path 查找卡片元数据 */
|
||||
export function findHomeCardByPath(
|
||||
sections: PortalHomeSection[],
|
||||
|
||||
@@ -179,14 +179,26 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('customerName')"
|
||||
prop="customerName"
|
||||
label="客户名称"
|
||||
min-width="240"
|
||||
v-if="isColumnVisible('supplierName')"
|
||||
prop="supplierName"
|
||||
label="供方"
|
||||
min-width="200"
|
||||
align="left"
|
||||
header-align="left"
|
||||
class-name="col-name-wrap"
|
||||
label-class-name="col-name-wrap"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('customerName')"
|
||||
prop="customerName"
|
||||
label="需方"
|
||||
min-width="200"
|
||||
align="left"
|
||||
header-align="left"
|
||||
class-name="col-name-wrap"
|
||||
label-class-name="col-name-wrap"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('productName')"
|
||||
@@ -213,17 +225,6 @@
|
||||
<PlPlanListProductCell :products="row.products" field="spec" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('productQty')"
|
||||
label="数量"
|
||||
min-width="90"
|
||||
align="right"
|
||||
header-align="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<PlPlanListProductCell :products="row.products" field="qty" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('productUnit')"
|
||||
label="单位"
|
||||
@@ -235,6 +236,28 @@
|
||||
<PlPlanListProductCell :products="row.products" field="unit" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('productPlanQty')"
|
||||
label="计划数量"
|
||||
min-width="100"
|
||||
align="right"
|
||||
header-align="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<PlPlanListProductCell :products="row.products" field="planQty" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('productActualQty')"
|
||||
label="实际数量"
|
||||
min-width="100"
|
||||
align="right"
|
||||
header-align="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<PlPlanListProductCell :products="row.products" field="actualQty" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isColumnVisible('planAttachments')" label="计划附件" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<PlPlanListCertCell :products="row.products" compact hide-product />
|
||||
@@ -368,11 +391,13 @@ const listColumnDefs = [
|
||||
{ prop: 'archiveStatus', label: '归档状态' },
|
||||
{ prop: 'followStatus', label: '跟进状态' },
|
||||
{ prop: 'planType', label: '单据类型' },
|
||||
{ prop: 'customerName', label: '客户名称' },
|
||||
{ prop: 'supplierName', label: '供方' },
|
||||
{ prop: 'customerName', label: '需方' },
|
||||
{ prop: 'productName', label: '产品名称' },
|
||||
{ prop: 'productSpec', label: '型号/规格' },
|
||||
{ prop: 'productQty', label: '数量' },
|
||||
{ prop: 'productUnit', label: '单位' },
|
||||
{ prop: 'productPlanQty', label: '计划数量' },
|
||||
{ prop: 'productActualQty', label: '实际数量' },
|
||||
{ prop: 'planAttachments', label: '计划附件' },
|
||||
{ prop: 'licensePlate', label: '车牌号' },
|
||||
{ prop: 'personnelSummary', label: '人员' },
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column prop="planNo" label="计划编号" min-width="180" show-overflow-tooltip>
|
||||
<el-table-column prop="planNo" label="计划编号" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" @click="handleView(row)">{{ row.planNo }}</el-link>
|
||||
</template>
|
||||
@@ -180,14 +180,26 @@
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('customerName')"
|
||||
prop="customerName"
|
||||
label="客户名称"
|
||||
min-width="240"
|
||||
v-if="isColumnVisible('supplierName')"
|
||||
prop="supplierName"
|
||||
label="供方"
|
||||
min-width="200"
|
||||
align="left"
|
||||
header-align="left"
|
||||
class-name="col-name-wrap"
|
||||
label-class-name="col-name-wrap"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('customerName')"
|
||||
prop="customerName"
|
||||
label="需方"
|
||||
min-width="200"
|
||||
align="left"
|
||||
header-align="left"
|
||||
class-name="col-name-wrap"
|
||||
label-class-name="col-name-wrap"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('planType')"
|
||||
@@ -202,6 +214,19 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('followStatus')"
|
||||
prop="followStatus"
|
||||
label="跟进状态"
|
||||
min-width="100"
|
||||
align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getPlPlanFollowStatusTagType(row.followStatus)" size="small">
|
||||
{{ getPlPlanFollowStatusLabel(row.followStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('hazardCertReady')"
|
||||
label="证书齐备"
|
||||
@@ -217,7 +242,7 @@
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('productName')"
|
||||
label="产品名称"
|
||||
min-width="240"
|
||||
min-width="160"
|
||||
align="left"
|
||||
header-align="left"
|
||||
class-name="col-name-wrap"
|
||||
@@ -230,7 +255,7 @@
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('productSpec')"
|
||||
label="型号/规格"
|
||||
min-width="120"
|
||||
min-width="96"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
@@ -239,21 +264,10 @@
|
||||
<PlPlanListProductCell :products="row.products" field="spec" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('productQty')"
|
||||
label="数量"
|
||||
min-width="90"
|
||||
align="right"
|
||||
header-align="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<PlPlanListProductCell :products="row.products" field="qty" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('productUnit')"
|
||||
label="单位"
|
||||
min-width="80"
|
||||
min-width="64"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
@@ -261,6 +275,28 @@
|
||||
<PlPlanListProductCell :products="row.products" field="unit" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('productPlanQty')"
|
||||
label="计划数量"
|
||||
min-width="88"
|
||||
align="right"
|
||||
header-align="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<PlPlanListProductCell :products="row.products" field="planQty" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('productActualQty')"
|
||||
label="实际数量"
|
||||
min-width="88"
|
||||
align="right"
|
||||
header-align="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<PlPlanListProductCell :products="row.products" field="actualQty" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isColumnVisible('planAttachments')" label="计划附件" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<PlPlanListCertCell
|
||||
@@ -273,19 +309,6 @@
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('followStatus')"
|
||||
prop="followStatus"
|
||||
label="跟进状态"
|
||||
min-width="100"
|
||||
align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getPlPlanFollowStatusTagType(row.followStatus)" size="small">
|
||||
{{ getPlPlanFollowStatusLabel(row.followStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('licensePlate')"
|
||||
prop="licensePlate"
|
||||
@@ -431,15 +454,17 @@ const planList = ref<Plan[]>([])
|
||||
|
||||
const listColumnDefs = [
|
||||
{ prop: 'planDate', label: '计划日期' },
|
||||
{ prop: 'customerName', label: '客户名称' },
|
||||
{ prop: 'supplierName', label: '供方' },
|
||||
{ prop: 'customerName', label: '需方' },
|
||||
{ prop: 'planType', label: '单据类型' },
|
||||
{ prop: 'followStatus', label: '跟进状态' },
|
||||
{ prop: 'hazardCertReady', label: '证书齐备' },
|
||||
{ prop: 'productName', label: '产品名称' },
|
||||
{ prop: 'productSpec', label: '型号/规格' },
|
||||
{ prop: 'productQty', label: '数量' },
|
||||
{ prop: 'productUnit', label: '单位' },
|
||||
{ prop: 'productPlanQty', label: '计划数量' },
|
||||
{ prop: 'productActualQty', label: '实际数量' },
|
||||
{ prop: 'planAttachments', label: '计划附件' },
|
||||
{ prop: 'followStatus', label: '跟进状态' },
|
||||
{ prop: 'licensePlate', label: '车牌号' },
|
||||
{ prop: 'personnelSummary', label: '人员' },
|
||||
{ prop: 'followUserName', label: '跟进人', defaultVisible: false }
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="客户名称">
|
||||
<el-form-item label="需方">
|
||||
<el-input
|
||||
v-model="searchForm.customerName"
|
||||
placeholder="搜索客户名称"
|
||||
placeholder="搜索需方客户名称"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
@@ -230,15 +230,53 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('customerName')"
|
||||
prop="customerName"
|
||||
label="客户名称"
|
||||
min-width="240"
|
||||
v-if="isColumnVisible('supplierName')"
|
||||
prop="supplierName"
|
||||
label="供方"
|
||||
min-width="200"
|
||||
align="left"
|
||||
header-align="left"
|
||||
class-name="col-name-wrap"
|
||||
label-class-name="col-name-wrap"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('customerName')"
|
||||
prop="customerName"
|
||||
label="需方"
|
||||
min-width="200"
|
||||
align="left"
|
||||
header-align="left"
|
||||
class-name="col-name-wrap"
|
||||
label-class-name="col-name-wrap"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('contractSummary')"
|
||||
label="合同"
|
||||
min-width="220"
|
||||
align="left"
|
||||
header-align="left"
|
||||
class-name="col-name-wrap"
|
||||
label-class-name="col-name-wrap"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div
|
||||
v-if="planContractLines(row).length"
|
||||
class="pl-plan-contract-cell"
|
||||
>
|
||||
<div
|
||||
v-for="(line, index) in planContractLines(row)"
|
||||
:key="`${line.side}-${line.no}-${index}`"
|
||||
class="pl-plan-contract-cell__row"
|
||||
>
|
||||
<span class="pl-plan-contract-cell__side">{{ line.side }}</span>
|
||||
<span class="pl-plan-contract-cell__no">{{ line.no }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="pl-plan-contract-cell__empty">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('productName')"
|
||||
label="产品名称"
|
||||
@@ -454,17 +492,6 @@
|
||||
>
|
||||
<el-icon><CircleClose /></el-icon>作废
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canDeletePlan(viewingPlan)"
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="!canDeleteByCreator(viewingPlan)"
|
||||
:title="getDeletePlanDisabledTitle(viewingPlan)"
|
||||
:loading="deleteLoading"
|
||||
@click="handleDelete(viewingPlan)"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>删除
|
||||
</el-button>
|
||||
</template>
|
||||
</PlPlanViewDialog>
|
||||
|
||||
@@ -486,7 +513,6 @@ import {
|
||||
Bell,
|
||||
CircleClose,
|
||||
CopyDocument,
|
||||
Delete,
|
||||
Edit,
|
||||
Plus,
|
||||
Printer,
|
||||
@@ -510,7 +536,6 @@ import PlPlanTypeTabs from '@/components/business/pl-plan-type-tabs/index.vue'
|
||||
import ListToolbarDateShortcuts from '@/components/core/list-toolbar-date-shortcuts/index.vue'
|
||||
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
|
||||
import { useListDateShortcut } from '@/composables/useListDateShortcut'
|
||||
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
|
||||
import { canBatchPrintSlip } from '@/utils/pl-batch-print'
|
||||
import {
|
||||
canPrintSlipCommand,
|
||||
@@ -529,7 +554,6 @@ const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const voidLoading = ref(false)
|
||||
const deleteLoading = ref(false)
|
||||
const waitNoticeLoading = ref(false)
|
||||
const formDialogVisible = ref(false)
|
||||
const editingPlanId = ref('')
|
||||
@@ -548,7 +572,9 @@ const listColumnDefs = [
|
||||
{ prop: 'followStatus', label: '跟进状态' },
|
||||
{ prop: 'planDate', label: '计划日期' },
|
||||
{ prop: 'planType', label: '单据类型' },
|
||||
{ prop: 'customerName', label: '客户名称' },
|
||||
{ prop: 'supplierName', label: '供方' },
|
||||
{ prop: 'customerName', label: '需方' },
|
||||
{ prop: 'contractSummary', label: '合同' },
|
||||
{ prop: 'productName', label: '产品名称' },
|
||||
{ prop: 'productSpec', label: '型号/规格' },
|
||||
{ prop: 'productPackageSpec', label: '包装规格' },
|
||||
@@ -582,10 +608,7 @@ async function refreshViewIfOpen() {
|
||||
await viewDialogRef.value?.reload()
|
||||
}
|
||||
|
||||
function getPlanArrangePrerequisiteGaps(plan: Pick<
|
||||
Plan,
|
||||
'createDate' | 'planDate' | 'belongsCompany' | 'planType' | 'followUserId' | 'followUserName'
|
||||
>): string[] {
|
||||
function getPlanArrangePrerequisiteGaps(plan: Plan): string[] {
|
||||
const missing: string[] = []
|
||||
if (!String(plan.createDate || '').trim()) missing.push('创建日期')
|
||||
if (!String(plan.planDate || '').trim()) missing.push('计划日期')
|
||||
@@ -594,6 +617,12 @@ function getPlanArrangePrerequisiteGaps(plan: Pick<
|
||||
if (!String(plan.followUserId || '').trim() && !String(plan.followUserName || '').trim()) {
|
||||
missing.push('跟进人')
|
||||
}
|
||||
if (!String(plan.supplierId || '').trim() || !String(plan.supplierName || '').trim()) {
|
||||
missing.push('供方客户')
|
||||
}
|
||||
if (!String(plan.customerId || '').trim() || !String(plan.customerName || '').trim()) {
|
||||
missing.push('需方客户')
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
@@ -670,6 +699,20 @@ function getPlanTypeLabel(type?: string | null) {
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
/** 合同列:供/需合同各占一行展示 */
|
||||
function planContractLines(plan: Plan): Array<{ side: string; no: string }> {
|
||||
const lines: Array<{ side: string; no: string }> = []
|
||||
for (const item of plan.supplierContracts || []) {
|
||||
const no = item.contractNo || item.contractId
|
||||
if (no) lines.push({ side: '供', no: String(no) })
|
||||
}
|
||||
for (const item of plan.demanderContracts || []) {
|
||||
const no = item.contractNo || item.contractId
|
||||
if (no) lines.push({ side: '需', no: String(no) })
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -801,14 +844,6 @@ function canVoidPlan(row: Plan) {
|
||||
return row.archiveStatus !== 'archived' && row.followStatus !== 'cancelled'
|
||||
}
|
||||
|
||||
function canDeletePlan(row: Plan) {
|
||||
return (
|
||||
(row.followStatus === 'pending_arrange' || row.followStatus === 'wait_notice') &&
|
||||
row.transportStatus === 'pending' &&
|
||||
row.archiveStatus !== 'archived'
|
||||
)
|
||||
}
|
||||
|
||||
function canWaitNotice(row: Plan) {
|
||||
return (
|
||||
row.followStatus === 'pending_arrange' &&
|
||||
@@ -820,12 +855,6 @@ function canCancelWaitNotice(row: Plan) {
|
||||
return row.followStatus === 'wait_notice' && row.archiveStatus !== 'archived'
|
||||
}
|
||||
|
||||
function getDeletePlanDisabledTitle(row: Plan) {
|
||||
if (!canDeletePlan(row)) return '只能删除待安排或等通知且未安排运输的计划'
|
||||
if (!canDeleteByCreator(row)) return CREATOR_DELETE_TIP
|
||||
return ''
|
||||
}
|
||||
|
||||
function syncViewingPlan(updated: Plan) {
|
||||
if (viewingPlan.value?.id === updated.id) {
|
||||
viewingPlan.value = { ...viewingPlan.value, ...updated }
|
||||
@@ -869,35 +898,6 @@ function handleToolbarWaitNotice() {
|
||||
handleWaitNotice(selectedPlan.value)
|
||||
}
|
||||
|
||||
async function handleDelete(row: Plan) {
|
||||
if (!canDeletePlan(row)) {
|
||||
ElMessage.warning('只能删除待安排或等通知且未安排运输的计划')
|
||||
return
|
||||
}
|
||||
if (!canDeleteByCreator(row)) {
|
||||
ElMessage.warning(CREATOR_DELETE_TIP)
|
||||
return
|
||||
}
|
||||
await ElMessageBox.confirm('确定删除该运输计划吗?删除后不可恢复。', '删除运输计划', {
|
||||
type: 'warning'
|
||||
})
|
||||
deleteLoading.value = true
|
||||
try {
|
||||
await planApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
if (viewingPlan.value?.id === row.id) {
|
||||
viewVisible.value = false
|
||||
viewingPlan.value = null
|
||||
}
|
||||
fetchData()
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : '删除失败'
|
||||
ElMessage.error(message)
|
||||
} finally {
|
||||
deleteLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVoid(row: Plan) {
|
||||
const hint =
|
||||
row.transportStatus === 'arranged'
|
||||
@@ -1044,4 +1044,41 @@ async function openArrangeFromQuery() {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.pl-plan-contract-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pl-plan-contract-cell__row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text, #1e293b);
|
||||
}
|
||||
|
||||
.pl-plan-contract-cell__side {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.pl-plan-contract-cell__no {
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.pl-plan-contract-cell__empty {
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
</style>
|
||||
@@ -1,76 +1,116 @@
|
||||
<template>
|
||||
<div class="portal-home">
|
||||
<div class="portal-home-hero">
|
||||
<div>
|
||||
<h3>{{ greetText }},{{ displayName }}</h3>
|
||||
<p>{{ dateText }} · 点击卡片进入业务,星标加入收藏</p>
|
||||
<div v-if="modules.length" class="portal-home-shell">
|
||||
<aside class="portal-home-aside" aria-label="一级菜单">
|
||||
<button
|
||||
type="button"
|
||||
class="portal-home-aside__item"
|
||||
:class="{ 'is-on': isFavView }"
|
||||
@click="activeModule = FAV_KEY"
|
||||
>
|
||||
<span>收藏</span>
|
||||
<span class="n">{{ favoriteList.length }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="mod in modules"
|
||||
:key="mod.modulePath"
|
||||
type="button"
|
||||
class="portal-home-aside__item"
|
||||
:class="{ 'is-on': activeModule === mod.modulePath }"
|
||||
@click="activeModule = mod.modulePath"
|
||||
>
|
||||
<span>{{ mod.moduleTitle }}</span>
|
||||
<span class="n">{{ mod.cards.length }}</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<div class="portal-home-panel">
|
||||
<header class="portal-home-panel__head">
|
||||
<div>
|
||||
<h2 class="portal-home-panel__title">{{ panelTitle }}</h2>
|
||||
<p class="portal-home-panel__desc">{{ panelDesc }}</p>
|
||||
</div>
|
||||
<span class="portal-home-panel__count">{{ panelCount }} 项</span>
|
||||
</header>
|
||||
|
||||
<section v-if="isFavView" class="portal-home-block portal-home-block--fav">
|
||||
<VueDraggable
|
||||
v-if="favoriteList.length"
|
||||
v-model="favoriteList"
|
||||
class="portal-home-grid"
|
||||
ghost-class="portal-home-card--ghost"
|
||||
chosen-class="portal-home-card--chosen"
|
||||
:animation="180"
|
||||
@start="onFavDragStart"
|
||||
@end="onFavDragEnd"
|
||||
>
|
||||
<button
|
||||
v-for="card in favoriteList"
|
||||
:key="'fav-' + card.path"
|
||||
type="button"
|
||||
class="portal-home-card portal-home-card--fav"
|
||||
@click="onFavCardClick(card.path)"
|
||||
>
|
||||
<span class="portal-home-card__badge portal-home-card__badge--fav" aria-hidden="true">
|
||||
{{ titleAbbr(card.title) }}
|
||||
</span>
|
||||
<span class="portal-home-card__body">
|
||||
<span class="portal-home-card__title">{{ card.title }}</span>
|
||||
<span class="portal-home-card__sub">{{ favSubTitle(card.path) }}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="portal-home-card__star is-on"
|
||||
aria-label="取消收藏"
|
||||
@click.stop="onToggleStar(card.path)"
|
||||
>
|
||||
<ElIcon><StarFilled /></ElIcon>
|
||||
</button>
|
||||
</button>
|
||||
</VueDraggable>
|
||||
<p v-else class="portal-home-empty">暂无收藏,可在各模块菜单卡片上点星标添加</p>
|
||||
</section>
|
||||
|
||||
<section v-else class="portal-home-block">
|
||||
<div class="portal-home-grid">
|
||||
<button
|
||||
v-for="card in activeCards"
|
||||
:key="card.path"
|
||||
type="button"
|
||||
class="portal-home-card"
|
||||
@click="openCard(card.path)"
|
||||
>
|
||||
<span
|
||||
class="portal-home-card__badge"
|
||||
:data-tone="toneIndex(activeModule)"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ titleAbbr(card.title) }}
|
||||
</span>
|
||||
<span class="portal-home-card__body">
|
||||
<span class="portal-home-card__title">{{ card.title }}</span>
|
||||
<span class="portal-home-card__sub">{{ panelTitle }}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="portal-home-card__star"
|
||||
:class="{ 'is-on': favoritePathSet.has(card.path) }"
|
||||
:aria-label="favoritePathSet.has(card.path) ? '取消收藏' : '加入收藏'"
|
||||
@click.stop="onToggleStar(card.path)"
|
||||
>
|
||||
<ElIcon>
|
||||
<StarFilled v-if="favoritePathSet.has(card.path)" />
|
||||
<Star v-else />
|
||||
</ElIcon>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!activeCards.length" class="portal-home-empty">该模块暂无可用菜单</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="portal-home-cards-area">
|
||||
<section v-if="favoriteList.length" class="portal-home-section">
|
||||
<div class="portal-home-section__head">
|
||||
<h2 class="title-bar-heading">收藏</h2>
|
||||
<span class="portal-home-section__tip">可拖拽调整顺序</span>
|
||||
</div>
|
||||
<VueDraggable
|
||||
v-model="favoriteList"
|
||||
class="portal-home-grid"
|
||||
ghost-class="portal-home-card--ghost"
|
||||
chosen-class="portal-home-card--chosen"
|
||||
:animation="180"
|
||||
@start="onFavDragStart"
|
||||
@end="onFavDragEnd"
|
||||
>
|
||||
<button
|
||||
v-for="card in favoriteList"
|
||||
:key="'fav-' + card.path"
|
||||
type="button"
|
||||
class="portal-home-card portal-home-card--fav"
|
||||
@click="onFavCardClick(card.path)"
|
||||
>
|
||||
<span class="portal-home-card__title">{{ card.title }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="portal-home-card__star is-on"
|
||||
aria-label="取消收藏"
|
||||
@click.stop="onToggleStar(card.path)"
|
||||
>
|
||||
<ElIcon><StarFilled /></ElIcon>
|
||||
</button>
|
||||
</button>
|
||||
</VueDraggable>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-for="sec in visibleSections"
|
||||
:key="sec.modulePath"
|
||||
class="portal-home-section"
|
||||
>
|
||||
<h2 class="title-bar-heading">{{ sec.moduleTitle }}</h2>
|
||||
<div class="portal-home-grid">
|
||||
<button
|
||||
v-for="card in sec.cards"
|
||||
:key="card.path"
|
||||
type="button"
|
||||
class="portal-home-card"
|
||||
@click="openCard(card.path)"
|
||||
>
|
||||
<span class="portal-home-card__title">{{ card.title }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="portal-home-card__star"
|
||||
aria-label="收藏"
|
||||
@click.stop="onToggleStar(card.path)"
|
||||
>
|
||||
<ElIcon><Star /></ElIcon>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="!sections.length" class="portal-home-empty">暂无可用菜单</p>
|
||||
</div>
|
||||
<p v-else class="portal-home-empty portal-home-empty--page">暂无可用菜单</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -82,7 +122,6 @@
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
import { useMenuStore } from '@/store/modules/menu'
|
||||
import { useMenuFavoriteStore } from '@/store/modules/menuFavorite'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import {
|
||||
collectPortalHomeSections,
|
||||
findHomeCardByPath,
|
||||
@@ -91,58 +130,85 @@
|
||||
|
||||
defineOptions({ name: 'PortalHome' })
|
||||
|
||||
const FAV_KEY = '__favorites__'
|
||||
|
||||
const router = useRouter()
|
||||
const menuStore = useMenuStore()
|
||||
const favStore = useMenuFavoriteStore()
|
||||
const userStore = useUserStore()
|
||||
const { menuList } = storeToRefs(menuStore)
|
||||
const { items: favItems } = storeToRefs(favStore)
|
||||
|
||||
const sections = computed(() => collectPortalHomeSections(menuList.value || []))
|
||||
|
||||
const favoritePathSet = computed(() => new Set(favItems.value.map((i) => i.path)))
|
||||
|
||||
/** 模块区隐藏已在收藏中展示的菜单,避免重复 */
|
||||
const visibleSections = computed(() =>
|
||||
sections.value
|
||||
.map((sec) => ({
|
||||
...sec,
|
||||
cards: sec.cards.filter((c) => !favoritePathSet.value.has(c.path))
|
||||
}))
|
||||
.filter((sec) => sec.cards.length > 0)
|
||||
)
|
||||
|
||||
const activeModule = ref(FAV_KEY)
|
||||
const favoriteList = ref<PortalHomeCardItem[]>([])
|
||||
const favDragging = ref(false)
|
||||
const suppressFavClick = ref(false)
|
||||
|
||||
const modules = computed(() => collectPortalHomeSections(menuList.value || []))
|
||||
|
||||
const favoritePathSet = computed(() => new Set(favItems.value.map((i) => i.path)))
|
||||
|
||||
const isFavView = computed(() => activeModule.value === FAV_KEY)
|
||||
|
||||
const activeCards = computed(() => {
|
||||
if (isFavView.value) return []
|
||||
const sec = modules.value.find((m) => m.modulePath === activeModule.value)
|
||||
return sec?.cards || []
|
||||
})
|
||||
|
||||
const panelTitle = computed(() => {
|
||||
if (isFavView.value) return '收藏'
|
||||
return modules.value.find((m) => m.modulePath === activeModule.value)?.moduleTitle || ''
|
||||
})
|
||||
|
||||
const panelDesc = computed(() =>
|
||||
isFavView.value ? '可拖拽调整顺序,星标取消收藏' : '点击卡片进入功能,星标加入收藏'
|
||||
)
|
||||
|
||||
const panelCount = computed(() =>
|
||||
isFavView.value ? favoriteList.value.length : activeCards.value.length
|
||||
)
|
||||
|
||||
const titleAbbr = (title: string) => {
|
||||
const t = String(title || '').trim()
|
||||
if (!t) return '?'
|
||||
return t.slice(0, 1)
|
||||
}
|
||||
|
||||
const favSubTitle = (path: string) => {
|
||||
for (const sec of modules.value) {
|
||||
if (sec.cards.some((c) => c.path === path)) return sec.moduleTitle
|
||||
}
|
||||
return '收藏入口'
|
||||
}
|
||||
|
||||
/** 同模块稳定色相,便于扫读区分(低饱和薄荷绿系) */
|
||||
const toneIndex = (modulePath: string) => {
|
||||
const s = String(modulePath || '')
|
||||
let h = 0
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0
|
||||
return String(h % 5)
|
||||
}
|
||||
|
||||
const ensureActiveModule = () => {
|
||||
if (activeModule.value === FAV_KEY) return
|
||||
const list = modules.value
|
||||
if (!list.length) {
|
||||
activeModule.value = FAV_KEY
|
||||
return
|
||||
}
|
||||
if (!list.some((m) => m.modulePath === activeModule.value)) {
|
||||
activeModule.value = FAV_KEY
|
||||
}
|
||||
}
|
||||
|
||||
const syncFavoriteList = () => {
|
||||
favoriteList.value = favItems.value.map(
|
||||
(i) => findHomeCardByPath(sections.value, i.path) || { path: i.path, title: i.path }
|
||||
(i) => findHomeCardByPath(modules.value, i.path) || { path: i.path, title: i.path }
|
||||
)
|
||||
}
|
||||
|
||||
watch([favItems, sections], syncFavoriteList, { immediate: true, deep: true })
|
||||
|
||||
const displayName = computed(() => {
|
||||
const info = userStore.getUserInfo || ({} as Api.Auth.UserInfo)
|
||||
return String(info.realName || info.userName || info.username || '').trim() || '管理员'
|
||||
})
|
||||
|
||||
const greetText = computed(() => {
|
||||
const h = new Date().getHours()
|
||||
if (h < 12) return '上午好'
|
||||
if (h < 18) return '下午好'
|
||||
return '晚上好'
|
||||
})
|
||||
|
||||
const dateText = computed(() =>
|
||||
new Date().toLocaleDateString('zh-CN', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'short'
|
||||
})
|
||||
)
|
||||
watch(modules, ensureActiveModule, { immediate: true, deep: true })
|
||||
watch([favItems, modules], syncFavoriteList, { immediate: true, deep: true })
|
||||
|
||||
onMounted(async () => {
|
||||
if (!favStore.loaded) {
|
||||
@@ -187,72 +253,171 @@
|
||||
|
||||
<style scoped lang="scss">
|
||||
.portal-home {
|
||||
padding: 16px 0 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(var(--art-full-height, 70vh) - 24px);
|
||||
min-height: 420px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.portal-home-hero {
|
||||
.portal-home-shell {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
grid-template-columns: 180px 1fr;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid var(--mint-border, #d9e8e2);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.portal-home-aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-height: 0;
|
||||
padding: 10px 8px;
|
||||
overflow: auto;
|
||||
background: #f8fafc;
|
||||
border-right: 1px solid var(--mint-border, #d9e8e2);
|
||||
}
|
||||
|
||||
.portal-home-aside__item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 10px;
|
||||
background:
|
||||
radial-gradient(100% 120% at 100% 0%, rgba(16, 185, 129, 0.14), transparent 55%),
|
||||
linear-gradient(135deg, #ecfdf5 0%, #ffffff 60%, #f8fafc 100%);
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--mint-text, #1e293b);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 2px;
|
||||
font-size: 16px;
|
||||
span:first-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.n {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
&.is-on {
|
||||
font-weight: 700;
|
||||
color: var(--mint-text, #1e293b);
|
||||
}
|
||||
color: var(--mint-primary-dark, #059669);
|
||||
background: var(--mint-primary-bg, #ecfdf5);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
.n {
|
||||
color: var(--mint-primary-dark, #059669);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 卡片区:与问候条拉开,左右留白 */
|
||||
.portal-home-cards-area {
|
||||
margin-top: 4px;
|
||||
padding: 12px 16px 8px;
|
||||
.portal-home-panel {
|
||||
min-height: 0;
|
||||
padding: 0 0 18px;
|
||||
overflow: auto;
|
||||
background:
|
||||
radial-gradient(80% 40% at 100% 0%, rgba(16, 185, 129, 0.06), transparent 55%), #fefefe;
|
||||
}
|
||||
|
||||
.portal-home-section {
|
||||
margin-bottom: 22px;
|
||||
.portal-home-panel__head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px 12px;
|
||||
background: rgba(254, 254, 254, 0.94);
|
||||
border-bottom: 1px solid #eef2f0;
|
||||
}
|
||||
|
||||
.portal-home-panel__title {
|
||||
margin: 0 0 2px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--mint-text, #1e293b);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.portal-home-panel__desc {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.portal-home-panel__count {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--mint-primary-dark, #059669);
|
||||
background: var(--mint-primary-bg, #ecfdf5);
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.portal-home-block {
|
||||
margin: 0 18px 18px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&--fav {
|
||||
margin-top: 14px;
|
||||
padding: 12px 12px 14px;
|
||||
background: linear-gradient(180deg, #ecfdf5 0%, #fff 72%);
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-home-section__head {
|
||||
.portal-home-block__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.portal-home-section__tip {
|
||||
.portal-home-block__tip {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
/* 不在 .panel-top 内,自行补绿色竖条 */
|
||||
.title-bar-heading {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--mint-text, #1e293b);
|
||||
|
||||
@@ -268,23 +433,24 @@
|
||||
|
||||
.portal-home-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.portal-home-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 56px;
|
||||
padding: 12px 22px 12px 10px;
|
||||
text-align: center;
|
||||
min-height: 64px;
|
||||
padding: 12px 34px 12px 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border: 1px solid var(--mint-border, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
@@ -292,10 +458,18 @@
|
||||
background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--mint-primary-bg, #ecfdf5);
|
||||
background: #fff;
|
||||
border-color: #6ee7b7;
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.1);
|
||||
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.12);
|
||||
transform: translateY(-1px);
|
||||
|
||||
.portal-home-card__badge {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.portal-home-card__title {
|
||||
color: var(--mint-primary-dark, #059669);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
@@ -304,6 +478,8 @@
|
||||
|
||||
&--fav {
|
||||
cursor: grab;
|
||||
background: #fff;
|
||||
border-color: #a7f3d0;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
@@ -311,7 +487,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Sortable 幽灵节点可能无 scoped 属性 */
|
||||
:deep(.portal-home-card--ghost) {
|
||||
background: var(--mint-primary-bg, #ecfdf5);
|
||||
border-style: dashed;
|
||||
@@ -324,29 +499,89 @@
|
||||
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.16);
|
||||
}
|
||||
|
||||
.portal-home-card__badge {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
border-radius: 9px;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&[data-tone='0'] {
|
||||
color: #047857;
|
||||
background: #d1fae5;
|
||||
}
|
||||
|
||||
&[data-tone='1'] {
|
||||
color: #0f766e;
|
||||
background: #ccfbf1;
|
||||
}
|
||||
|
||||
&[data-tone='2'] {
|
||||
color: #065f46;
|
||||
background: #ecfdf5;
|
||||
box-shadow: inset 0 0 0 1px #a7f3d0;
|
||||
}
|
||||
|
||||
&[data-tone='3'] {
|
||||
color: #334155;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
&[data-tone='4'] {
|
||||
color: #115e59;
|
||||
background: #f0fdfa;
|
||||
box-shadow: inset 0 0 0 1px #99f6e4;
|
||||
}
|
||||
|
||||
&--fav {
|
||||
color: #b45309;
|
||||
background: #fef3c7;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-home-card__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-home-card__title {
|
||||
display: -webkit-box;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: var(--mint-text, #1e293b);
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
transition: color 0.15s ease;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.portal-home-card__sub {
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.portal-home-card__star {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
@@ -355,7 +590,7 @@
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
opacity: 0.55;
|
||||
opacity: 0.45;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease,
|
||||
@@ -363,7 +598,7 @@
|
||||
|
||||
&:hover {
|
||||
color: #d97706;
|
||||
background: #f1f5f9;
|
||||
background: #fff7ed;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -378,8 +613,36 @@
|
||||
}
|
||||
|
||||
.portal-home-empty {
|
||||
margin-top: 48px;
|
||||
margin-top: 32px;
|
||||
font-size: 13px;
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
text-align: center;
|
||||
|
||||
&--page {
|
||||
margin: 48px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.portal-home-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.portal-home-aside {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
max-height: 140px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--mint-border, #d9e8e2);
|
||||
}
|
||||
|
||||
.portal-home-aside__item {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.portal-home-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user