This commit is contained in:
zlwu committed 2026-08-09 14:51:41 +08:00
1 parent ca6aca2b14
commit f70dc26206
63 files changed
+589912 -9127

No files matched your search

+1
View File
@@ -23,6 +23,7 @@ function mapListQuery(query: ContractQuery = {}): Record<string, any> {
pageSize: query.pageSize ?? 20
}
const fields: (keyof ContractQuery)[] = [
'keyword',
'contractNo',
'contractName',
'customerName',
+2
View File
@@ -9,6 +9,8 @@
export interface ContractQuery {
pageNum?: number
pageSize?: number
/** 即时检索:合同编号 / 合同产品名称 / 产品拼音首字母 */
keyword?: string
contractNo?: string
contractName?: string
customerId?: number | string
+17 -1
View File
@@ -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[],
+42 -17
View File
@@ -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: '人员' },
+60 -35
View File
@@ -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 }
+105 -68
View File
@@ -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>
+418 -155
View File
@@ -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>