feat(pl): 合同用证改为客户用证并修复列表筛选。
证件额度按客户展示;Web 客户列表改用 toQueryParams,修复 buildQuery is not a function。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
d5f3a79ea5
commit
ea46761f40
29 files changed
+1777
-295
No files matched your search
@@ -111,7 +111,7 @@
|
||||
|
||||
const panelTitleClassList = [...panelTitleIconByText.map(([, cls]) => cls), 'auto-title-list']
|
||||
const panelTitleBarClass = 'title-bar-heading'
|
||||
const panelTitleBarTextRules = [/^(购买证与运输证|合法使用证明|计划用证|证件建档|证用品类|合同用证|证件用量|计划段次用证|证书齐备|合同证件与额度|运输证件规则)$/]
|
||||
const panelTitleBarTextRules = [/^(购买证与运输证|合法使用证明|计划用证|证件建档|证用品类|合同用证|客户用证|证件用量|计划段次用证|证书齐备|合同证件与额度|客户证件与额度|运输证件规则)$/]
|
||||
const panelTitleHeadingSelector =
|
||||
'.page-container .panel-top h2, .page-container .panel-top h3, .page-container .storage-content-header h3, .page-container .storage-aside__head h2, .page-container .user-dept-aside__head h2, .console-container .panel-top h2'
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface QuotaCert extends Omit<CertBody, 'status'> {
|
||||
verifiedTime?: string | null
|
||||
openingConfirmed: boolean
|
||||
usagePolicy: 'once' | 'multiple' | 'needs_confirmation'
|
||||
singleUseConsumed?: boolean
|
||||
createTime?: string
|
||||
certType?: CertType
|
||||
derivedCategoryIds?: string[]
|
||||
@@ -132,8 +133,10 @@ export interface LegalDocument {
|
||||
}
|
||||
const base = '/transport/quota-certs'
|
||||
export const quotaCertApi = {
|
||||
list: (kind: CertType, params: { keyword?: string; status?: string } = {}) =>
|
||||
request.get(`${base}/${kind}`, { params }).then(pickBxData<QuotaCert[]>),
|
||||
list: (
|
||||
kind: CertType,
|
||||
params: { keyword?: string; status?: string; customerId?: string } = {}
|
||||
) => request.get(`${base}/${kind}`, { params }).then(pickBxData<QuotaCert[]>),
|
||||
get: (kind: CertType, id: string) =>
|
||||
request.get(`${base}/${kind}/${id}`).then(pickBxData<QuotaCert>),
|
||||
save: (kind: CertType, body: CertBody, id?: string) =>
|
||||
@@ -504,6 +507,13 @@ export interface StatisticsPage<T> {
|
||||
export interface ContractStatisticsSummary extends ContractOption {
|
||||
certCount: number
|
||||
}
|
||||
export interface CustomerStatisticsSummary {
|
||||
id: string
|
||||
customerId: string
|
||||
customerNo: string
|
||||
customerName: string
|
||||
certCount: number
|
||||
}
|
||||
export interface CertificateStatisticsSummary {
|
||||
id: string
|
||||
certId: string
|
||||
@@ -522,6 +532,14 @@ export interface CertificateStatisticsSummary {
|
||||
buyPartyName?: string | null
|
||||
validUseText?: string | null
|
||||
}
|
||||
export interface CustomerStatistics extends CustomerStatisticsSummary {
|
||||
certificates: (CertificateStatisticsSummary & {
|
||||
usagePolicy?: QuotaCert['usagePolicy']
|
||||
singleUseConsumed?: boolean
|
||||
openingConfirmed?: boolean
|
||||
createTime?: string
|
||||
})[]
|
||||
}
|
||||
export interface StatisticsProduct {
|
||||
id: string
|
||||
contractProductId: string
|
||||
@@ -579,6 +597,8 @@ export interface StatisticsQuery {
|
||||
contractNo?: string
|
||||
contractName?: string
|
||||
contractTypeIn?: string
|
||||
customerNo?: string
|
||||
customerName?: string
|
||||
certCountOp?: string
|
||||
certCount?: string
|
||||
certCountMin?: string
|
||||
@@ -622,12 +642,18 @@ export const quotaStatisticsApi = {
|
||||
request
|
||||
.get(`${base}/statistics/contracts`, { params })
|
||||
.then(pickBxData<StatisticsPage<ContractStatisticsSummary>>),
|
||||
customers: (params: StatisticsQuery) =>
|
||||
request
|
||||
.get(`${base}/statistics/customers`, { params })
|
||||
.then(pickBxData<StatisticsPage<CustomerStatisticsSummary>>),
|
||||
certificates: (params: StatisticsQuery) =>
|
||||
request
|
||||
.get(`${base}/statistics/certificates`, { params })
|
||||
.then(pickBxData<StatisticsPage<CertificateStatisticsSummary>>),
|
||||
contract: (id: string) =>
|
||||
request.get(`${base}/contracts/${id}/statistics`).then(pickBxData<ContractStatistics>),
|
||||
customer: (id: string) =>
|
||||
request.get(`${base}/customers/${id}/statistics`).then(pickBxData<CustomerStatistics>),
|
||||
certificate: (kind: CertType, id: string) =>
|
||||
request.get(`${base}/${kind}/${id}/statistics`).then(pickBxData<CertificateStatistics>)
|
||||
}
|
||||
@@ -133,7 +133,7 @@
|
||||
mode: 'list',
|
||||
title: '运输证件管理',
|
||||
search: '证件编号 / 品名',
|
||||
tabs: ['证件建档', '合同用证', '证件用量', '计划段次用证', '证书齐备'],
|
||||
tabs: ['证件建档', '客户用证', '证件用量', '计划段次用证', '证书齐备'],
|
||||
filters: [],
|
||||
caption: '正在加载运输证件管理'
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export const plRoutes: AppRouteRecord[] = [
|
||||
name: 'PlContractQuota',
|
||||
path: 'contract-quota',
|
||||
component: '/pl/contract-quota/index',
|
||||
meta: { title: '合同用证', keepAlive: false, menuCode: 'pl:quota-cert:list' }
|
||||
meta: { title: '客户用证', keepAlive: false, menuCode: 'pl:quota-cert:list' }
|
||||
},
|
||||
{
|
||||
id: 'PL_CERTIFICATE_USAGE',
|
||||
|
||||
@@ -171,11 +171,17 @@ export const staticRoutes: AppRouteRecordRaw[] = [
|
||||
component: () => import('@views/m/pl/plan-cert/archive-detail.vue'),
|
||||
meta: { title: '证件建档', isHideTab: true, isMpWebview: true }
|
||||
},
|
||||
{
|
||||
path: '/m/pl/plan-cert/customer/:id',
|
||||
name: 'MpPlPlanCertCustomer',
|
||||
component: () => import('@views/m/pl/plan-cert/customer-detail.vue'),
|
||||
meta: { title: '客户用证', isHideTab: true, isMpWebview: true }
|
||||
},
|
||||
{
|
||||
path: '/m/pl/plan-cert/contract/:id',
|
||||
name: 'MpPlPlanCertContract',
|
||||
component: () => import('@views/m/pl/plan-cert/contract-detail.vue'),
|
||||
meta: { title: '合同用证', isHideTab: true, isMpWebview: true }
|
||||
redirect: { name: 'MpPlPlanCert', query: { tab: 'contract' } },
|
||||
meta: { title: '客户用证', isHideTab: true, isMpWebview: true }
|
||||
},
|
||||
{
|
||||
path: '/m/pl/plan-cert/usage/:kind/:id',
|
||||
|
||||
@@ -504,13 +504,12 @@
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane v-if="canViewQuotaCert" label="合同证件与额度" name="quota" lazy>
|
||||
<ContractQuotaPanel
|
||||
<el-tab-pane v-if="canViewQuotaCert" label="客户证件与额度" name="quota" lazy>
|
||||
<CustomerCertQuotaPanel
|
||||
v-if="visible && detailActiveTab === 'quota' && detailContract?.id && String(detailContract.id) === String(currentDetailId)"
|
||||
:key="detailContract.id"
|
||||
:contract-id="String(detailContract.id)"
|
||||
:key="String(detailContract.customerId || detailContract.id)"
|
||||
:customer-id="detailContract.customerId"
|
||||
:active="detailActiveTab === 'quota'"
|
||||
embedded
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -1060,7 +1059,7 @@ import UploadDialog from './UploadDialog.vue';
|
||||
import ContractFormDialog from './ContractFormDialog.vue';
|
||||
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue';
|
||||
import type { ViewField } from '@/components/business/system-view-dialog/types';
|
||||
import ContractQuotaPanel from '@/views/pl/contract-quota/ContractQuotaPanel.vue';
|
||||
import CustomerCertQuotaPanel from '@/views/pl/contract-quota/CustomerCertQuotaPanel.vue';
|
||||
import StopDocuments from '@/views/pl/legal-use-document/StopDocuments.vue';
|
||||
import PlContractLinkedPlansPanel from '@/components/business/pl-contract-linked-plans-panel/index.vue';
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="mp-customer-cert-quota">
|
||||
<p v-if="!customerId" class="mp-wv-empty">合同未关联客户,无法展示证件</p>
|
||||
<p v-else-if="error" class="mp-wv-empty" role="alert">
|
||||
{{ error }}
|
||||
<button class="mp-wv-btn" type="button" @click="load">重试</button>
|
||||
</p>
|
||||
<div v-else-if="loading" class="mp-cert-page-loading"><van-loading /></div>
|
||||
<template v-else>
|
||||
<p class="mp-customer-cert-quota__tip">
|
||||
展示该客户全部购买证、运输证;证件关联客户,不再按合同绑定。
|
||||
</p>
|
||||
<van-empty v-if="!rows.length" description="该客户暂无购买证或运输证" />
|
||||
<article
|
||||
v-for="row in rows"
|
||||
:key="row.certType + row.id"
|
||||
class="mp-wv-section"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="openDetail(row)"
|
||||
@keydown.enter.prevent="openDetail(row)"
|
||||
>
|
||||
<div class="mp-wv-card__head">
|
||||
<div>
|
||||
<h3>{{ row.certNo || '—' }}</h3>
|
||||
<p>{{ row.productNameOnCert || '未填写证面品名' }}</p>
|
||||
<p>有效期:{{ row.validFrom || '—' }} 至 {{ row.validTo || '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mp-cert-tags">
|
||||
<span
|
||||
class="status-tag mp-cert-kind-tag"
|
||||
:class="`status-tag--${certKindMobileTone(row.certType)}`"
|
||||
>{{ certKindLabel(row.certType) }}</span
|
||||
>
|
||||
<span class="status-tag" :class="`status-tag--${statusTone(row.status)}`">{{
|
||||
certStatusLabel(row.status)
|
||||
}}</span>
|
||||
<span v-if="row.singleUseConsumed" class="status-tag status-tag--danger"
|
||||
>已使用不可再用</span
|
||||
>
|
||||
</div>
|
||||
<div class="mp-wv-card__kv">
|
||||
<div class="mp-wv-card__kv-row"
|
||||
><span>有效次数</span><span>{{ row.validUseText || '—' }}</span></div
|
||||
>
|
||||
<div class="mp-wv-card__kv-row"
|
||||
><span>核定</span><span>{{ qtyText(row.totalQty, row.quantityUnit) }}</span></div
|
||||
>
|
||||
<div class="mp-wv-card__kv-row"
|
||||
><span>预占</span><span>{{ qtyText(row.reservedQty, row.quantityUnit) }}</span></div
|
||||
>
|
||||
<div class="mp-wv-card__kv-row"
|
||||
><span>已用</span><span>{{ qtyText(row.usedQty, row.quantityUnit) }}</span></div
|
||||
>
|
||||
<div class="mp-wv-card__kv-row"
|
||||
><span>余量</span><span>{{ qtyText(row.availableQty, row.quantityUnit) }}</span></div
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
||||
import { quotaCertApi, type CertType, type QuotaCert } from '@/api/pl/quota-cert'
|
||||
import { certKindLabel, certKindMobileTone } from '@/views/pl/quota-cert/cert-list-face'
|
||||
import { formatCertQty } from '@/views/pl/quota-cert/model'
|
||||
|
||||
type CustomerCertRow = QuotaCert & { certType: CertType }
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ customerId?: string | number | null; active?: boolean }>(),
|
||||
{ active: true }
|
||||
)
|
||||
|
||||
const router = useRouter()
|
||||
const dictStore = useDictStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const rows = ref<CustomerCertRow[]>([])
|
||||
let loadEpoch = 0
|
||||
|
||||
function unitLabel(unit?: string | null) {
|
||||
const label = getProductUnitLabel(dictStore.getLabel, unit)
|
||||
return !label || label === '—' ? '' : label
|
||||
}
|
||||
|
||||
function qtyText(value: unknown, unit?: string | null) {
|
||||
const text = formatCertQty(value)
|
||||
const name = unitLabel(unit)
|
||||
return name ? `${text} ${name}` : text
|
||||
}
|
||||
|
||||
function certStatusLabel(status?: string | null) {
|
||||
return (
|
||||
{
|
||||
draft: '待核对',
|
||||
active: '证件已启用',
|
||||
suspended: '证件已停用',
|
||||
closed: '证件已核销',
|
||||
void: '证件已作废'
|
||||
}[status || ''] ||
|
||||
status ||
|
||||
'—'
|
||||
)
|
||||
}
|
||||
|
||||
function statusTone(status?: string | null) {
|
||||
if (status === 'active') return 'success'
|
||||
if (status === 'suspended') return 'warning'
|
||||
if (status === 'closed' || status === 'void') return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const customerId = props.customerId == null ? '' : String(props.customerId).trim()
|
||||
const epoch = ++loadEpoch
|
||||
if (!customerId) {
|
||||
rows.value = []
|
||||
error.value = ''
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [purchase, transport] = await Promise.all([
|
||||
quotaCertApi.list('purchase', { customerId }),
|
||||
quotaCertApi.list('transport', { customerId })
|
||||
])
|
||||
if (epoch !== loadEpoch) return
|
||||
rows.value = [
|
||||
...(purchase || []).map((item) => ({ ...item, certType: 'purchase' as const })),
|
||||
...(transport || []).map((item) => ({ ...item, certType: 'transport' as const }))
|
||||
].sort((a, b) => String(b.createTime || '').localeCompare(String(a.createTime || '')))
|
||||
} catch (e: any) {
|
||||
if (epoch !== loadEpoch) return
|
||||
rows.value = []
|
||||
error.value = e?.message || '加载客户证件失败'
|
||||
} finally {
|
||||
if (epoch === loadEpoch) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(row: CustomerCertRow) {
|
||||
void router.push(`/m/pl/plan-cert/archive/${row.certType}/${row.id}`)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.customerId, props.active],
|
||||
([, active]) => {
|
||||
if (active) void load()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mp-customer-cert-quota__tip {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--mp-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.mp-wv-section {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mp-cert-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 8px 0 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -342,6 +342,19 @@
|
||||
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'quota'" class="mp-contract-detail-pane">
|
||||
<section class="mp-wv-section mp-contract-detail-card">
|
||||
<h3>客户证件与额度</h3>
|
||||
<CustomerCertQuotaTab
|
||||
v-if="canViewQuotaCert && activeTab === 'quota'"
|
||||
:key="String(contract.customerId || contract.id)"
|
||||
:customer-id="contract.customerId"
|
||||
:active="activeTab === 'quota'"
|
||||
/>
|
||||
<p v-else class="mp-wv-card__meta">无证件查看权限</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'plans'" class="mp-contract-detail-pane">
|
||||
<section class="mp-wv-section mp-contract-detail-card">
|
||||
<h3>运输计划</h3>
|
||||
@@ -933,6 +946,8 @@
|
||||
} from '@/utils/mp-webview'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
import { fetchGetMenuCodes } from '@/api/auth'
|
||||
import CustomerCertQuotaTab from './CustomerCertQuotaTab.vue'
|
||||
import '@/styles/mp-webview.scss'
|
||||
import '../customer/customer.scss'
|
||||
|
||||
@@ -970,6 +985,7 @@
|
||||
type DetailTabKey =
|
||||
| 'contract'
|
||||
| 'plans'
|
||||
| 'quota'
|
||||
| 'payment'
|
||||
| 'invoice'
|
||||
| 'approval'
|
||||
@@ -979,6 +995,7 @@
|
||||
const acting = ref(false)
|
||||
const errorText = ref('')
|
||||
const activeTab = ref<DetailTabKey>('contract')
|
||||
const canViewQuotaCert = ref(false)
|
||||
const detailBody = ref<HTMLElement>()
|
||||
const partiesExpanded = ref(false)
|
||||
const approverPickerOpen = ref(false)
|
||||
@@ -1056,14 +1073,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
const detailTabs: Array<{ key: DetailTabKey; label: string }> = [
|
||||
{ key: 'contract', label: '合同信息' },
|
||||
{ key: 'plans', label: '运输计划' },
|
||||
{ key: 'payment', label: '回款记录' },
|
||||
{ key: 'invoice', label: '开票记录' },
|
||||
{ key: 'approval', label: '审批流程' },
|
||||
{ key: 'operation', label: '操作记录' }
|
||||
]
|
||||
const detailTabs = computed(() => {
|
||||
const tabs: Array<{ key: DetailTabKey; label: string }> = [
|
||||
{ key: 'contract', label: '合同信息' },
|
||||
{ key: 'plans', label: '运输计划' }
|
||||
]
|
||||
if (canViewQuotaCert.value) tabs.push({ key: 'quota', label: '客户证件与额度' })
|
||||
tabs.push(
|
||||
{ key: 'payment', label: '回款记录' },
|
||||
{ key: 'invoice', label: '开票记录' },
|
||||
{ key: 'approval', label: '审批流程' },
|
||||
{ key: 'operation', label: '操作记录' }
|
||||
)
|
||||
return tabs
|
||||
})
|
||||
|
||||
async function refreshQuotaCertPermission() {
|
||||
try {
|
||||
const permissions = await fetchGetMenuCodes()
|
||||
const codes = permissions?.menuCodes || []
|
||||
canViewQuotaCert.value =
|
||||
!!permissions?.all || !!permissions?.isSuperAdmin || codes.includes('pl:quota-cert:list')
|
||||
} catch {
|
||||
canViewQuotaCert.value = false
|
||||
}
|
||||
if (!canViewQuotaCert.value && activeTab.value === 'quota') {
|
||||
activeTab.value = 'contract'
|
||||
}
|
||||
}
|
||||
|
||||
const unreceivedAmount = computed(() => {
|
||||
const amount = Number(contract.value?.amount) || 0
|
||||
@@ -1573,7 +1610,7 @@
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getContract(id)
|
||||
const [res] = await Promise.all([getContract(id), refreshQuotaCertPermission()])
|
||||
if (!isCurrentLoad(requestSeq, id)) return
|
||||
const data = ((res as any)?.data ?? res) as ContractVO
|
||||
if (String(data?.id || '') !== id) throw new Error('合同不存在或无权查看')
|
||||
|
||||
@@ -1,144 +1,7 @@
|
||||
<template>
|
||||
<main class="mp-h5-list-content mp-plan-cert-list">
|
||||
<p class="mp-contract-quota-tip">
|
||||
新规则已生效:安排选证改为客户+证用品类。本页合同用证仅供历史查看与旧规则计划,请勿再作为新计划的用证操作入口。
|
||||
</p>
|
||||
<p v-if="error" class="mp-wv-empty" role="alert">
|
||||
{{ error }}
|
||||
<button class="mp-wv-btn" type="button" @click="reload">重试</button>
|
||||
</p>
|
||||
<div v-else-if="authReady && !allowed" class="mp-wv-empty">
|
||||
<div class="mp-wv-empty__title">无证件查看权限</div>
|
||||
</div>
|
||||
<van-list v-else v-model:loading="loading" :finished="finished" finished-text="" @load="load">
|
||||
<article
|
||||
v-for="row in rows"
|
||||
:key="row.id"
|
||||
class="mp-wv-card mp-wv-flat-card"
|
||||
@click="open(row.id)"
|
||||
>
|
||||
<div class="mp-wv-card__head">
|
||||
<div>
|
||||
<div class="mp-wv-card__no">{{ row.contractNo || '未编号合同' }}</div>
|
||||
<div class="mp-wv-card__sub">{{ row.contractName || '—' }}</div>
|
||||
</div>
|
||||
<span class="status-tag status-tag--info">{{ contractTypeLabel(row.contractType) }}</span>
|
||||
</div>
|
||||
<div class="mp-wv-card__kv">
|
||||
<div class="mp-wv-card__kv-row">
|
||||
<span>关联证件数</span><span>{{ row.certCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mp-wv-card-actions mp-wv-card-actions--single" @click.stop>
|
||||
<button class="mp-wv-btn mp-wv-btn--primary" type="button" @click="open(row.id)">查看</button>
|
||||
</div>
|
||||
</article>
|
||||
</van-list>
|
||||
<van-empty
|
||||
v-if="authReady && allowed && !loading && finished && !error && !rows.length"
|
||||
description="暂无合同用证数据"
|
||||
/>
|
||||
</main>
|
||||
<CustomerTab :keyword="keyword" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { fetchGetMenuCodes } from '@/api/auth'
|
||||
import { quotaStatisticsApi, type ContractStatisticsSummary } from '@/api/pl/quota-cert'
|
||||
import { contractTypeLabel } from '@/views/pl/quota-statistics/labels'
|
||||
|
||||
const props = defineProps<{ keyword: string }>()
|
||||
const router = useRouter()
|
||||
const rows = ref<ContractStatisticsSummary[]>([])
|
||||
const loading = ref(true)
|
||||
const finished = ref(false)
|
||||
const error = ref('')
|
||||
const allowed = ref(false)
|
||||
const authReady = ref(false)
|
||||
const page = ref(1)
|
||||
let epoch = 0
|
||||
let checking = true
|
||||
|
||||
function open(id: string) {
|
||||
void router.push(`/m/pl/plan-cert/contract/${id}`)
|
||||
}
|
||||
async function init() {
|
||||
const current = epoch
|
||||
try {
|
||||
const permissions = await fetchGetMenuCodes()
|
||||
if (current !== epoch) return
|
||||
const codes = permissions?.menuCodes || []
|
||||
allowed.value =
|
||||
!!permissions?.all || !!permissions?.isSuperAdmin || codes.includes('pl:quota-cert:list')
|
||||
authReady.value = true
|
||||
checking = false
|
||||
if (!allowed.value) {
|
||||
finished.value = true
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
await load()
|
||||
} catch {
|
||||
if (current !== epoch) return
|
||||
checking = false
|
||||
authReady.value = true
|
||||
finished.value = true
|
||||
loading.value = false
|
||||
error.value = '权限加载失败,请重新打开页面'
|
||||
}
|
||||
}
|
||||
function reload() {
|
||||
if (!allowed.value) {
|
||||
void init()
|
||||
return
|
||||
}
|
||||
epoch++
|
||||
page.value = 1
|
||||
rows.value = []
|
||||
loading.value = true
|
||||
finished.value = false
|
||||
error.value = ''
|
||||
void load()
|
||||
}
|
||||
async function load() {
|
||||
if (checking || !allowed.value) return
|
||||
const current = epoch
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await quotaStatisticsApi.contracts({
|
||||
page: page.value,
|
||||
pageSize: 20,
|
||||
keyword: props.keyword || undefined
|
||||
})
|
||||
if (current !== epoch) return
|
||||
const list = data?.list || []
|
||||
rows.value = page.value === 1 ? list : rows.value.concat(list)
|
||||
finished.value = rows.value.length >= (data?.total || 0) || list.length < 20
|
||||
page.value += 1
|
||||
} catch {
|
||||
if (current === epoch) {
|
||||
error.value = '统计加载失败,请重试'
|
||||
finished.value = true
|
||||
}
|
||||
} finally {
|
||||
if (current === epoch) loading.value = false
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => props.keyword,
|
||||
() => {
|
||||
if (!checking && allowed.value) reload()
|
||||
}
|
||||
)
|
||||
void init()
|
||||
import CustomerTab from './CustomerTab.vue'
|
||||
defineProps<{ keyword: string }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mp-contract-quota-tip {
|
||||
margin: 8px 12px 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #ecfdf5;
|
||||
color: #065f46;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<main class="mp-h5-list-content mp-plan-cert-list">
|
||||
<p class="mp-customer-quota-tip">按客户展示购买证、运输证;证件关联客户,不再按合同绑定。</p>
|
||||
<p v-if="error" class="mp-wv-empty" role="alert">
|
||||
{{ error }}
|
||||
<button class="mp-wv-btn" type="button" @click="reload">重试</button>
|
||||
</p>
|
||||
<div v-else-if="authReady && !allowed" class="mp-wv-empty">
|
||||
<div class="mp-wv-empty__title">无证件查看权限</div>
|
||||
</div>
|
||||
<van-list v-else v-model:loading="loading" :finished="finished" finished-text="" @load="load">
|
||||
<article
|
||||
v-for="row in rows"
|
||||
:key="row.id"
|
||||
class="mp-wv-card mp-wv-flat-card"
|
||||
@click="open(row.id)"
|
||||
>
|
||||
<div class="mp-wv-card__head">
|
||||
<div>
|
||||
<div class="mp-wv-card__no">{{ row.customerName || '未命名客户' }}</div>
|
||||
<div class="mp-wv-card__sub">{{ row.customerNo || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mp-wv-card__kv">
|
||||
<div class="mp-wv-card__kv-row">
|
||||
<span>关联证件数</span><span>{{ row.certCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mp-wv-card-actions mp-wv-card-actions--single" @click.stop>
|
||||
<button class="mp-wv-btn mp-wv-btn--primary" type="button" @click="open(row.id)"
|
||||
>查看</button
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
</van-list>
|
||||
<van-empty
|
||||
v-if="authReady && allowed && !loading && finished && !error && !rows.length"
|
||||
description="暂无客户用证数据"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { fetchGetMenuCodes } from '@/api/auth'
|
||||
import { quotaStatisticsApi, type CustomerStatisticsSummary } from '@/api/pl/quota-cert'
|
||||
|
||||
const props = defineProps<{ keyword: string }>()
|
||||
const router = useRouter()
|
||||
const rows = ref<CustomerStatisticsSummary[]>([])
|
||||
const loading = ref(true)
|
||||
const finished = ref(false)
|
||||
const error = ref('')
|
||||
const allowed = ref(false)
|
||||
const authReady = ref(false)
|
||||
const page = ref(1)
|
||||
let epoch = 0
|
||||
let checking = true
|
||||
|
||||
function open(id: string) {
|
||||
void router.push(`/m/pl/plan-cert/customer/${id}`)
|
||||
}
|
||||
async function init() {
|
||||
const current = epoch
|
||||
try {
|
||||
const permissions = await fetchGetMenuCodes()
|
||||
if (current !== epoch) return
|
||||
const codes = permissions?.menuCodes || []
|
||||
allowed.value =
|
||||
!!permissions?.all || !!permissions?.isSuperAdmin || codes.includes('pl:quota-cert:list')
|
||||
authReady.value = true
|
||||
checking = false
|
||||
if (!allowed.value) {
|
||||
finished.value = true
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
await load()
|
||||
} catch {
|
||||
if (current !== epoch) return
|
||||
checking = false
|
||||
authReady.value = true
|
||||
finished.value = true
|
||||
loading.value = false
|
||||
error.value = '权限加载失败,请重新打开页面'
|
||||
}
|
||||
}
|
||||
function reload() {
|
||||
if (!allowed.value) {
|
||||
void init()
|
||||
return
|
||||
}
|
||||
epoch++
|
||||
page.value = 1
|
||||
rows.value = []
|
||||
loading.value = true
|
||||
finished.value = false
|
||||
error.value = ''
|
||||
void load()
|
||||
}
|
||||
async function load() {
|
||||
if (checking || !allowed.value) return
|
||||
const current = epoch
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await quotaStatisticsApi.customers({
|
||||
page: page.value,
|
||||
pageSize: 20,
|
||||
keyword: props.keyword || undefined
|
||||
})
|
||||
if (current !== epoch) return
|
||||
const list = data?.list || []
|
||||
rows.value = page.value === 1 ? list : rows.value.concat(list)
|
||||
finished.value = rows.value.length >= (data?.total || 0) || list.length < 20
|
||||
page.value += 1
|
||||
} catch {
|
||||
if (current === epoch) {
|
||||
error.value = '统计加载失败,请重试'
|
||||
finished.value = true
|
||||
}
|
||||
} finally {
|
||||
if (current === epoch) loading.value = false
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => props.keyword,
|
||||
() => {
|
||||
if (!checking && allowed.value) reload()
|
||||
}
|
||||
)
|
||||
void init()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mp-customer-quota-tip {
|
||||
margin: 8px 12px 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #ecfdf5;
|
||||
color: #065f46;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1201,19 +1201,20 @@
|
||||
return
|
||||
}
|
||||
const certRecord = (cert || {}) as Record<string, any>
|
||||
let name =
|
||||
String(certRecord.customerName || '').trim() ||
|
||||
systemCustomer.value.customerName ||
|
||||
customerOptions.value.find((item) => item.id === id)?.customerName ||
|
||||
''
|
||||
let name = ''
|
||||
try {
|
||||
const detail = (await fetchCustomerDetail({ id })) as Record<string, any>
|
||||
const data = detail?.data || detail
|
||||
name = String(data?.customerName || '').trim()
|
||||
} catch {
|
||||
name = ''
|
||||
}
|
||||
// 系统客户以 CRM 为准;禁止用证面收货单位等缓存名顶替。
|
||||
if (!name) {
|
||||
try {
|
||||
const detail = (await fetchCustomerDetail({ id })) as Record<string, any>
|
||||
const data = detail?.data || detail
|
||||
name = String(data?.customerName || '').trim() || id
|
||||
} catch {
|
||||
name = id
|
||||
}
|
||||
name =
|
||||
String(certRecord.customerName || '').trim() ||
|
||||
customerOptions.value.find((item) => item.id === id)?.customerName ||
|
||||
id
|
||||
}
|
||||
systemCustomer.value = { id, customerName: name }
|
||||
form.value.customerId = id
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<MpWebviewShell @ready="ready = true">
|
||||
<MpBusinessHeader title="客户用证" back-label="返回运输证件管理" @back="router.back()" />
|
||||
<main v-if="ready" class="mp-h5-list-content mp-h5-detail-page mp-plan-cert-list">
|
||||
<p v-if="error" class="mp-wv-empty" role="alert">
|
||||
{{ error }}
|
||||
<button class="mp-wv-btn" type="button" @click="retry">重试</button>
|
||||
</p>
|
||||
<div v-else-if="!authReady || loading" class="mp-cert-page-loading"><van-loading /></div>
|
||||
<div v-else-if="!allowed" class="mp-wv-empty">
|
||||
<div class="mp-wv-empty__title">无证件查看权限</div>
|
||||
</div>
|
||||
<template v-else-if="detail">
|
||||
<section class="mp-wv-section">
|
||||
<div class="mp-cert-contract-head">
|
||||
<div class="mp-cert-contract-head__main">
|
||||
<h3>{{ detail.customerName || '未命名客户' }}</h3>
|
||||
<p>{{ detail.customerNo || '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mp-wv-card__kv">
|
||||
<div class="mp-wv-card__kv-row">
|
||||
<span>关联证件数</span><span>{{ detail.certCount ?? certificates.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<CustomerCertQuotaTab :customer-id="customerId" :active="true" />
|
||||
</template>
|
||||
</main>
|
||||
</MpWebviewShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchGetMenuCodes } from '@/api/auth'
|
||||
import { quotaStatisticsApi, type CustomerStatistics } from '@/api/pl/quota-cert'
|
||||
import CustomerCertQuotaTab from '@/views/m/crm/contract/CustomerCertQuotaTab.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const ready = ref(false)
|
||||
const authReady = ref(false)
|
||||
const allowed = ref(false)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const detail = ref<CustomerStatistics | null>(null)
|
||||
const customerId = computed(() => String(route.params.id || '').trim())
|
||||
const certificates = computed(() => detail.value?.certificates || [])
|
||||
|
||||
async function load() {
|
||||
const id = customerId.value
|
||||
if (!id) {
|
||||
error.value = '缺少客户 ID'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const permissions = await fetchGetMenuCodes()
|
||||
const codes = permissions?.menuCodes || []
|
||||
allowed.value =
|
||||
!!permissions?.all || !!permissions?.isSuperAdmin || codes.includes('pl:quota-cert:list')
|
||||
authReady.value = true
|
||||
if (!allowed.value) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
detail.value = await quotaStatisticsApi.customer(id)
|
||||
} catch (e: any) {
|
||||
detail.value = null
|
||||
error.value = e?.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
void load()
|
||||
}
|
||||
|
||||
watch(customerId, () => void load(), { immediate: true })
|
||||
</script>
|
||||
@@ -44,7 +44,16 @@ export function certFaceGroups(
|
||||
: display(cert, field[0]),
|
||||
wide: longFaceKeys.has(field[0]) || field[0] === 'vehicleNumbersJson'
|
||||
}))
|
||||
if (index === 0) fields.unshift({ label: '状态', value: display(cert, 'status') })
|
||||
if (index === 0) {
|
||||
fields.unshift(
|
||||
{ label: '状态', value: display(cert, 'status') },
|
||||
{
|
||||
label: '系统客户',
|
||||
value: String(cert.customerName || '').trim() || '—',
|
||||
wide: true
|
||||
}
|
||||
)
|
||||
}
|
||||
return { title: group.title, fields }
|
||||
})
|
||||
groups[groups.length - 1].fields.push({
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
const committed = ref('')
|
||||
const CERT_TABS = [
|
||||
{ name: 'archive', title: '证件建档', perm: 'mini:pl:quota-cert:list' },
|
||||
{ name: 'contract', title: '合同用证', perm: 'mini:pl:contract-quota:list' },
|
||||
{ name: 'contract', title: '客户用证', perm: 'mini:pl:contract-quota:list' },
|
||||
{ name: 'usage', title: '证件用量', perm: 'mini:pl:certificate-usage:list' },
|
||||
{ name: 'plan', title: '计划段次用证', perm: 'mini:pl:plan-cert:segment' },
|
||||
{ name: 'ready', title: '证书齐备', perm: 'mini:pl:cert-ready:list' }
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template><StatisticsPanel mode="contract" v-bind="props" /></template>
|
||||
<template><CustomerQuotaPanel v-bind="props" /></template>
|
||||
<script setup lang="ts">
|
||||
import StatisticsPanel from '../quota-statistics/StatisticsPanel.vue'
|
||||
import CustomerQuotaPanel from './CustomerQuotaPanel.vue'
|
||||
const props = withDefaults(
|
||||
defineProps<{ contractId?: string; embedded?: boolean; active?: boolean }>(),
|
||||
defineProps<{ customerId?: string; embedded?: boolean; active?: boolean }>(),
|
||||
{ active: true }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<div class="dialog-list-panel customer-cert-quota-panel">
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row contract-quota-toolbar">
|
||||
<span class="contract-quota-toolbar__tip"
|
||||
>展示该客户全部购买证、运输证(证件关联客户,不再按合同绑定)。额度按单张证件核算,多个合同共用共享余额。</span
|
||||
>
|
||||
<el-button :loading="loading" @click="load"
|
||||
><el-icon><Refresh /></el-icon>刷新</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" class="mb-3"
|
||||
><el-button @click="load"><el-icon><Refresh /></el-icon>重试</el-button></el-alert
|
||||
>
|
||||
<div v-loading="loading">
|
||||
<el-empty
|
||||
v-if="!customerId"
|
||||
description="合同未关联客户,无法展示证件"
|
||||
/>
|
||||
<el-empty
|
||||
v-else-if="!loading && !error && !rows.length"
|
||||
description="该客户暂无购买证或运输证"
|
||||
/>
|
||||
<ListTable
|
||||
v-else-if="rows.length"
|
||||
table-key="crm-customer-cert-quota"
|
||||
:data="rows"
|
||||
class="list-table"
|
||||
stripe
|
||||
empty-text="该客户暂无购买证或运输证"
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
|
||||
<el-table-column label="证种" min-width="100" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="certKindTagType(row.certType)">{{
|
||||
certKindLabel(row.certType)
|
||||
}}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="证号" min-width="160" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" @click="openDetail(row)">{{ row.certNo || '—' }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="productNameOnCert"
|
||||
label="证面品名"
|
||||
min-width="140"
|
||||
:show-overflow-tooltip="true"
|
||||
/>
|
||||
<el-table-column label="有效次数" min-width="120" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.validUseText || '—' }}</span>
|
||||
<el-tag
|
||||
v-if="row.singleUseConsumed"
|
||||
class="ml-1"
|
||||
type="danger"
|
||||
size="small"
|
||||
>已使用不可再用</el-tag
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="核定" min-width="110" align="right" header-align="center">
|
||||
<template #default="{ row }">{{ qtyText(row.totalQty, row.quantityUnit) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预占" min-width="110" align="right" header-align="center">
|
||||
<template #default="{ row }">{{ qtyText(row.reservedQty, row.quantityUnit) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已用" min-width="110" align="right" header-align="center">
|
||||
<template #default="{ row }">{{ qtyText(row.usedQty, row.quantityUnit) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="余量" min-width="110" align="right" header-align="center">
|
||||
<template #default="{ row }">{{ qtyText(row.availableQty, row.quantityUnit) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="有效期" min-width="200" align="center" header-align="center">
|
||||
<template #default="{ row }"
|
||||
>{{ row.validFrom || '—' }} 至 {{ row.validTo || '—' }}</template
|
||||
>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="110" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="statusTagType(row.status)">{{
|
||||
certStatusLabel(row.status)
|
||||
}}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</ListTable>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="detailVisible"
|
||||
class="dialog-form-layout"
|
||||
title="证件详情"
|
||||
width="720px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-loading="detailLoading" class="customer-cert-detail">
|
||||
<template v-if="detail">
|
||||
<div class="customer-cert-detail__tags">
|
||||
<el-tag size="small" :type="certKindTagType(detail.certType)">{{
|
||||
certKindLabel(detail.certType)
|
||||
}}</el-tag>
|
||||
<el-tag size="small" :type="statusTagType(detail.status)">{{
|
||||
certStatusLabel(detail.status)
|
||||
}}</el-tag>
|
||||
<el-tag v-if="detail.singleUseConsumed" size="small" type="danger"
|
||||
>已使用不可再用</el-tag
|
||||
>
|
||||
</div>
|
||||
<dl class="customer-cert-detail__kv">
|
||||
<div v-for="item in detailFields" :key="item.label">
|
||||
<dt>{{ item.label }}</dt>
|
||||
<dd>{{ item.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
||||
import { quotaCertApi, type CertType, type QuotaCert } from '@/api/pl/quota-cert'
|
||||
import { certKindLabel, certKindTagType } from '@/views/pl/quota-cert/cert-list-face'
|
||||
import { formatCertQty } from '@/views/pl/quota-cert/model'
|
||||
|
||||
type CustomerCertRow = QuotaCert & { certType: CertType }
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ customerId?: string | number | null; active?: boolean }>(),
|
||||
{ active: true }
|
||||
)
|
||||
|
||||
const dictStore = useDictStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const rows = ref<CustomerCertRow[]>([])
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<CustomerCertRow | null>(null)
|
||||
let loadEpoch = 0
|
||||
|
||||
const detailFields = computed(() => {
|
||||
const row = detail.value
|
||||
if (!row) return []
|
||||
return [
|
||||
{ label: '证号', value: row.certNo || '—' },
|
||||
{ label: '证面品名', value: row.productNameOnCert || '—' },
|
||||
{ label: '有效次数', value: row.validUseText || '—' },
|
||||
{ label: '核定', value: qtyText(row.totalQty, row.quantityUnit) },
|
||||
{ label: '预占', value: qtyText(row.reservedQty, row.quantityUnit) },
|
||||
{ label: '已用', value: qtyText(row.usedQty, row.quantityUnit) },
|
||||
{ label: '余量', value: qtyText(row.availableQty, row.quantityUnit) },
|
||||
{ label: '有效期起', value: row.validFrom || '—' },
|
||||
{ label: '有效期止', value: row.validTo || '—' },
|
||||
{ label: '状态', value: certStatusLabel(row.status) }
|
||||
]
|
||||
})
|
||||
|
||||
function unitLabel(unit?: string | null) {
|
||||
const label = getProductUnitLabel(dictStore.getLabel, unit)
|
||||
return !label || label === '—' ? '' : label
|
||||
}
|
||||
|
||||
function qtyText(value: unknown, unit?: string | null) {
|
||||
const text = formatCertQty(value)
|
||||
const name = unitLabel(unit)
|
||||
return name ? `${text} ${name}` : text
|
||||
}
|
||||
|
||||
function certStatusLabel(status?: string | null) {
|
||||
return (
|
||||
{
|
||||
draft: '待核对',
|
||||
active: '证件已启用',
|
||||
suspended: '证件已停用',
|
||||
closed: '证件已核销',
|
||||
void: '证件已作废'
|
||||
}[status || ''] ||
|
||||
status ||
|
||||
'—'
|
||||
)
|
||||
}
|
||||
|
||||
function statusTagType(status?: string | null) {
|
||||
if (status === 'active') return 'success'
|
||||
if (status === 'suspended') return 'warning'
|
||||
if (status === 'closed' || status === 'void') return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const customerId = props.customerId == null ? '' : String(props.customerId).trim()
|
||||
const epoch = ++loadEpoch
|
||||
if (!customerId) {
|
||||
rows.value = []
|
||||
error.value = ''
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [purchase, transport] = await Promise.all([
|
||||
quotaCertApi.list('purchase', { customerId }),
|
||||
quotaCertApi.list('transport', { customerId })
|
||||
])
|
||||
if (epoch !== loadEpoch) return
|
||||
rows.value = [
|
||||
...(purchase || []).map((item) => ({ ...item, certType: 'purchase' as const })),
|
||||
...(transport || []).map((item) => ({ ...item, certType: 'transport' as const }))
|
||||
].sort((a, b) => String(b.createTime || '').localeCompare(String(a.createTime || '')))
|
||||
} catch (e: any) {
|
||||
if (epoch !== loadEpoch) return
|
||||
rows.value = []
|
||||
error.value = e?.message || '加载客户证件失败'
|
||||
} finally {
|
||||
if (epoch === loadEpoch) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(row: CustomerCertRow) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detail.value = null
|
||||
try {
|
||||
const data = await quotaCertApi.get(row.certType, row.id)
|
||||
detail.value = { ...data, certType: row.certType }
|
||||
} catch (e: any) {
|
||||
detailVisible.value = false
|
||||
ElMessage.error(e?.message || '加载证件详情失败')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.customerId, props.active],
|
||||
([, active]) => {
|
||||
if (active) void load()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.contract-quota-toolbar {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.contract-quota-toolbar__tip {
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.customer-cert-detail__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.customer-cert-detail__kv {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 16px;
|
||||
margin: 0;
|
||||
|
||||
div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--mint-text, #0f172a);
|
||||
}
|
||||
}
|
||||
|
||||
.ml-1 {
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<section :class="embedded ? 'dialog-list-panel' : 'panel'">
|
||||
<div v-if="!embedded" class="panel-top"><h2 class="title-bar-heading">客户用证</h2></div>
|
||||
<div v-if="!customerId" class="list-toolbar">
|
||||
<div class="list-toolbar-row cert-list-toolbar-row">
|
||||
<ListQuickSearchInput
|
||||
v-model="keyword"
|
||||
placeholder="客户编号 / 名称"
|
||||
@search="search"
|
||||
@clear="search"
|
||||
/>
|
||||
<el-button v-if="keyword || hasActiveFilters" @click="reset"
|
||||
><el-icon><RefreshLeft /></el-icon>清除筛选</el-button
|
||||
>
|
||||
<ListToolbarEndActions
|
||||
:loading="loading"
|
||||
:column-defs="columns"
|
||||
v-model:visible-columns="visibleColumns"
|
||||
v-model:check-all="checkAll"
|
||||
:is-indeterminate="isIndeterminate"
|
||||
@refresh="load"
|
||||
@reset-columns="resetToDefault"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="customerId" class="list-toolbar">
|
||||
<div class="list-toolbar-row contract-quota-toolbar">
|
||||
<span class="contract-quota-toolbar__tip"
|
||||
>展示该客户全部购买证、运输证;额度按单张证件核算,多个合同共用共享余额。</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false"
|
||||
><el-button @click="allowed ? load() : init()"
|
||||
><el-icon><Refresh /></el-icon>重试</el-button
|
||||
></el-alert
|
||||
>
|
||||
<div v-if="customerId" v-loading="loading">
|
||||
<CustomerCertQuotaPanel
|
||||
v-if="fixedCustomerId"
|
||||
:customer-id="fixedCustomerId"
|
||||
:active="active && !loading"
|
||||
/>
|
||||
<el-empty
|
||||
v-else-if="!loading && !error"
|
||||
:description="allowed ? '客户不存在或无权查看' : '无证件查看权限'"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="list-body list-wrap">
|
||||
<div ref="tableWrapRef" class="table-scroll">
|
||||
<ListTable
|
||||
ref="tableRef"
|
||||
table-key="pl-customer-quota-statistics"
|
||||
:height="tableHeight"
|
||||
:data="rows"
|
||||
row-key="id"
|
||||
stripe
|
||||
class="list-table"
|
||||
v-loading="loading"
|
||||
@expand-change="expand"
|
||||
>
|
||||
<el-table-column type="expand">
|
||||
<template #default="{ row }">
|
||||
<div
|
||||
v-loading="details[row.id]?.loading"
|
||||
class="view-section statistics-expansion"
|
||||
:style="expansionStyle"
|
||||
>
|
||||
<el-alert
|
||||
v-if="details[row.id]?.error"
|
||||
title="详情加载失败"
|
||||
type="error"
|
||||
:closable="false"
|
||||
><el-button @click="loadDetail(row)"
|
||||
><el-icon><Refresh /></el-icon>重试</el-button
|
||||
></el-alert
|
||||
>
|
||||
<CustomerCertQuotaPanel
|
||||
v-else-if="details[row.id]?.ready"
|
||||
:customer-id="row.id"
|
||||
:active="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-for="column in visibleDefs"
|
||||
:key="column.prop"
|
||||
:prop="column.prop"
|
||||
:label="column.label"
|
||||
:min-width="column.type === 'text' ? 170 : 130"
|
||||
>
|
||||
<template #header>
|
||||
<div class="col-header-with-filter">
|
||||
<span class="col-header-with-filter__label">{{ column.label }}</span>
|
||||
<ColumnHeaderFilter
|
||||
:prop="column.prop"
|
||||
:label="column.label"
|
||||
:type="column.type"
|
||||
:enable-sort="false"
|
||||
:options="column.enumOptions || []"
|
||||
:model-value="filters[column.prop] || null"
|
||||
:is-active="isFilterActive(column.prop)"
|
||||
@update:model-value="(v) => setFilterState(column.prop, v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<span>{{ row[column.prop] ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</ListTable>
|
||||
</div>
|
||||
<div ref="pagerRef" class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="20"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="load"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Refresh, RefreshLeft } from '@element-plus/icons-vue'
|
||||
import { fetchGetMenuCodes } from '@/api/auth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { quotaStatisticsApi } from '@/api/pl/quota-cert'
|
||||
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
|
||||
import { useServerColumnFilters } from '@/composables/useServerColumnFilters'
|
||||
import { useListTableViewportHeight } from '@/composables/useListTableViewportHeight'
|
||||
import type { ColumnFilterDef } from '@/composables/useClientColumnFilters'
|
||||
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
|
||||
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
|
||||
import ColumnHeaderFilter from '@/components/business/column-header-filter/index.vue'
|
||||
import CustomerCertQuotaPanel from './CustomerCertQuotaPanel.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ customerId?: string; embedded?: boolean; active?: boolean }>(),
|
||||
{ active: true }
|
||||
)
|
||||
|
||||
const user = useUserStore()
|
||||
const allowed = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const keyword = ref('')
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const rows = ref<Record<string, any>[]>([])
|
||||
const fixedCustomerId = ref('')
|
||||
const details = reactive<Record<string, { loading: boolean; error?: boolean; ready?: boolean }>>(
|
||||
{}
|
||||
)
|
||||
let epoch = 0
|
||||
|
||||
const text = (prop: string, label: string): ColumnFilterDef => ({
|
||||
prop,
|
||||
label,
|
||||
type: 'text',
|
||||
queryKey: prop
|
||||
})
|
||||
const number = (prop: string, label: string): ColumnFilterDef => ({
|
||||
prop,
|
||||
label,
|
||||
type: 'number',
|
||||
queryKey: prop
|
||||
})
|
||||
const columns: ColumnFilterDef[] = [
|
||||
text('customerNo', '客户编号'),
|
||||
text('customerName', '客户名称'),
|
||||
number('certCount', '关联证件数')
|
||||
]
|
||||
const {
|
||||
filters,
|
||||
hasActiveFilters,
|
||||
isFilterActive,
|
||||
setFilterState,
|
||||
clearAllFilters,
|
||||
toQueryParams,
|
||||
watchVisibleColumns
|
||||
} = useServerColumnFilters(columns, { onChange: search })
|
||||
const { visibleColumns, checkAll, isIndeterminate, visibleDefs, resetToDefault } =
|
||||
usePersistedListColumns('pl-customer-quota-columns-v1', columns)
|
||||
watchVisibleColumns(visibleColumns)
|
||||
const tableWrapRef = ref<HTMLElement>()
|
||||
const pagerRef = ref<HTMLElement>()
|
||||
const tableRef = ref()
|
||||
const { tableHeight, expansionStyle } = useListTableViewportHeight({
|
||||
wrapRef: tableWrapRef,
|
||||
pagerRef,
|
||||
tableRef
|
||||
})
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const p = await fetchGetMenuCodes()
|
||||
allowed.value =
|
||||
!!p?.all || !!p?.isSuperAdmin || (p?.menuCodes || []).includes('pl:quota-cert:list')
|
||||
if (allowed.value) await load()
|
||||
else error.value = '无证件查看权限'
|
||||
} catch {
|
||||
error.value = '权限加载失败'
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
keyword.value = ''
|
||||
clearAllFilters()
|
||||
search()
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!props.active) return
|
||||
const current = ++epoch
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
if (props.customerId) {
|
||||
const data = await quotaStatisticsApi.customer(String(props.customerId))
|
||||
if (current !== epoch) return
|
||||
fixedCustomerId.value = data?.id || String(props.customerId)
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
} else {
|
||||
fixedCustomerId.value = ''
|
||||
const data = await quotaStatisticsApi.customers({
|
||||
page: page.value,
|
||||
pageSize: 20,
|
||||
keyword: keyword.value || undefined,
|
||||
...toQueryParams()
|
||||
})
|
||||
if (current !== epoch) return
|
||||
rows.value = data?.list || []
|
||||
total.value = data?.total || 0
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (current !== epoch) return
|
||||
error.value = e?.message || '加载失败'
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
fixedCustomerId.value = ''
|
||||
} finally {
|
||||
if (current === epoch) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function expand(row: { id: string }, expanded: unknown[]) {
|
||||
const open = Array.isArray(expanded)
|
||||
? expanded.some((item: any) => item?.id === row.id)
|
||||
: Boolean(expanded)
|
||||
if (open) void loadDetail(row)
|
||||
}
|
||||
|
||||
async function loadDetail(row: { id: string }) {
|
||||
details[row.id] = { loading: true, error: false, ready: false }
|
||||
try {
|
||||
await quotaStatisticsApi.customer(row.id)
|
||||
details[row.id] = { loading: false, ready: true }
|
||||
} catch {
|
||||
details[row.id] = { loading: false, error: true }
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.customerId, props.active, JSON.stringify([user.accessToken, user.getUserInfo])],
|
||||
() => {
|
||||
if (props.active) void init()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.contract-quota-toolbar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.contract-quota-toolbar__tip {
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.statistics-expansion {
|
||||
padding: 8px 12px 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -666,7 +666,7 @@
|
||||
type ContractOption,
|
||||
type ScopeProduct
|
||||
} from '@/api/pl/quota-cert'
|
||||
import { fetchCustomerOptions } from '@/api/crm/customer'
|
||||
import { fetchCustomerDetail, fetchCustomerOptions } from '@/api/crm/customer'
|
||||
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
|
||||
import CertContractBinder from './CertContractBinder.vue'
|
||||
import {
|
||||
@@ -761,28 +761,32 @@
|
||||
|
||||
async function ensureCustomerOption(id?: string | null, nameHint = '') {
|
||||
if (!id) return
|
||||
if (customerOptions.value.some((c) => c.id === id)) return
|
||||
if (nameHint) {
|
||||
customerOptions.value = [{ id, customerName: nameHint }, ...customerOptions.value]
|
||||
return
|
||||
}
|
||||
let name = ''
|
||||
try {
|
||||
const result = await fetchCustomerOptions({ keyword: '', limit: 200 })
|
||||
const list = Array.isArray(result)
|
||||
? result
|
||||
: (result as any)?.list || (result as any)?.data || []
|
||||
const hit = (list || []).find((row: any) => String(row.id) === id)
|
||||
if (hit) {
|
||||
customerOptions.value = [
|
||||
{ id, customerName: String(hit.customerName || hit.name || id) },
|
||||
...customerOptions.value
|
||||
]
|
||||
} else {
|
||||
customerOptions.value = [{ id, customerName: id }, ...customerOptions.value]
|
||||
}
|
||||
const detail = (await fetchCustomerDetail({ id })) as Record<string, any>
|
||||
const data = detail?.data || detail
|
||||
name = String(data?.customerName || data?.name || '').trim()
|
||||
} catch {
|
||||
customerOptions.value = [{ id, customerName: id }, ...customerOptions.value]
|
||||
name = ''
|
||||
}
|
||||
if (!name) {
|
||||
try {
|
||||
const result = await fetchCustomerOptions({ keyword: nameHint || '', limit: 200 })
|
||||
const list = Array.isArray(result)
|
||||
? result
|
||||
: (result as any)?.list || (result as any)?.data || []
|
||||
const hit = (list || []).find((row: any) => String(row.id) === id)
|
||||
name = String(hit?.customerName || hit?.name || '').trim()
|
||||
} catch {
|
||||
name = ''
|
||||
}
|
||||
}
|
||||
// 系统客户名称以 CRM 为准;nameHint 仅作 CRM 不可用时的兜底(应为接口返回的 customerName)。
|
||||
const label = name || String(nameHint || '').trim() || id
|
||||
customerOptions.value = [
|
||||
{ id, customerName: label },
|
||||
...customerOptions.value.filter((c) => c.id !== id)
|
||||
]
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
@@ -1048,8 +1052,17 @@
|
||||
value: statusLabels[detail.value.status],
|
||||
tagType: tagType(detail.value.status)
|
||||
}
|
||||
const systemCustomerField: ViewField = {
|
||||
label: '系统客户',
|
||||
value: String(detail.value.customerName || '').trim() || '—',
|
||||
span: 'full'
|
||||
}
|
||||
const groups = certFormGroups(kind.value).map((group, index) =>
|
||||
detailGroup(group.title, group.fields, index === 0 ? [statusField] : [])
|
||||
detailGroup(
|
||||
group.title,
|
||||
group.fields,
|
||||
index === 0 ? [statusField, systemCustomerField] : []
|
||||
)
|
||||
)
|
||||
groups[groups.length - 1].fields.push({
|
||||
label: '系统备注',
|
||||
@@ -1424,7 +1437,7 @@
|
||||
applyCertForm(targetKind, cert)
|
||||
await ensureCustomerOption(
|
||||
form.value.customerId,
|
||||
formKind.value === 'purchase' ? form.value.buyerName : form.value.consigneeName
|
||||
String((cert as QuotaCert).customerName || '').trim()
|
||||
)
|
||||
if (targetKind === 'transport') purchases.value = purchaseOptions()
|
||||
} catch (error) {
|
||||
|
||||
Reference in new issue
Block a user