feat(pl): 增加合同与证件用量视图以追踪共享额度及段产品使用。

This commit is contained in:
wuzilong committed 2026-11-01 21:53:36 +08:00
1 parent ce6f102dae
commit 0d1d264bc7
18 files changed
+1017 -16

No files matched your search

+1 -1
View File
@@ -110,7 +110,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'
+107
View File
@@ -44,11 +44,13 @@ export interface CertBody {
routeText?: string | null
consigneeName?: string | null
applicantName?: string | null
contractBindings?: CertificateContractBinding[]
purchaseRefs?: PurchaseReference[]
}
export interface QuotaCert extends Omit<CertBody, 'status'> {
id: string
status: 'draft' | 'active' | 'suspended' | 'void'
bindingsEditable?: boolean
revision: number
reservedQty: string | number
usedQty: string | number
@@ -268,3 +270,108 @@ export const quotaAdjustmentApi = {
total: (kind: CertType, id: string, body: CertTotalAdjustment) =>
request.post(`${base}/${kind}/${id}/total-adjust`, body)
}
export type QuotaQuantity = string | number
export interface CertificateContractBinding {
id?: string
contractId: string
linkStatus?: 'active' | 'inactive'
products: ScopeProduct[]
scopeRevision?: number
remark?: string | null
orderNum?: number | null
}
export interface StatisticsPage<T> {
list: T[]
total: number
page: number
pageSize: number
}
export interface ContractStatisticsSummary extends ContractOption {
certCount: number
}
export interface CertificateStatisticsSummary {
id: string
certId: string
certType: CertType
certNo: string
productNameOnCert: string
quantityUnit: string
totalQty: QuotaQuantity
reservedQty: QuotaQuantity
usedQty: QuotaQuantity
availableQty: QuotaQuantity
status: string
validFrom: string | null
validTo: string | null
}
export interface StatisticsProduct {
id: string
contractProductId: string
productId: string
productName: string
model: string | null
unit: string | null
bindingId: string | null
scopeId: string | null
scopeStatus: string | null
linkStatus: string | null
reservedQty: QuotaQuantity
usedQty: QuotaQuantity
}
export interface StatisticsBinding {
bindingId: string | null
linkStatus: string | null
scopeRevision: number | null
contractReservedQty: QuotaQuantity
contractUsedQty: QuotaQuantity
products: StatisticsProduct[]
}
export interface ContractStatistics extends ContractOption {
certificates: (CertificateStatisticsSummary & StatisticsBinding & { productNames: string[] })[]
}
export interface StatisticsUsageItem {
id: string
usageId: string
certType: CertType
certId: string
planId: string
planNo: string
stopType: string
stopId: string
stopProductId: string
planProductId: string
contractId: string
contractProductId: string
productName: string
quantityUnit: string
reservedQty: QuotaQuantity
usedQty: QuotaQuantity
itemStatus: string
}
export interface CertificateStatistics extends CertificateStatisticsSummary {
contracts: (ContractOption & StatisticsBinding)[]
usageItems: StatisticsUsageItem[]
openingUsedQty: QuotaQuantity
unattributedUsedQty: QuotaQuantity
}
export interface StatisticsQuery {
keyword?: string
page?: number
pageSize?: number
certType?: CertType
}
export const quotaStatisticsApi = {
contracts: (params: StatisticsQuery) =>
request
.get(`${base}/statistics/contracts`, { params })
.then(pickBxData<StatisticsPage<ContractStatisticsSummary>>),
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>),
certificate: (kind: CertType, id: string) =>
request.get(`${base}/${kind}/${id}/statistics`).then(pickBxData<CertificateStatistics>)
}
+27 -9
View File
@@ -61,26 +61,44 @@ export const plRoutes: AppRouteRecord[] = [
path: 'cert-upload',
meta: { title: '计划用证', keepAlive: false },
children: [
{
id: 'PL00000000000000000000000000015',
name: 'PlPlanCert',
path: 'plan-cert',
component: '/pl/plan-cert/index',
meta: { title: '计划用证', keepAlive: false, menuCode: 'pl:plan-cert:list' }
},
{
id: 'PL_QUOTA_CERT',
name: 'PlQuotaCert',
path: 'quota-cert',
component: '/pl/quota-cert/index',
meta: { title: '购买证与运输证', keepAlive: false, menuCode: 'pl:quota-cert:list' }
meta: { title: '证件建档', keepAlive: false, menuCode: 'pl:quota-cert:list' }
},
{
id: 'PL_CONTRACT_QUOTA',
name: 'PlContractQuota',
path: 'contract-quota',
component: '/pl/contract-quota/index',
meta: { title: '合同用证', keepAlive: false, menuCode: 'pl:quota-cert:list' }
},
{
id: 'PL_CERTIFICATE_USAGE',
name: 'PlCertificateUsage',
path: 'certificate-usage',
component: '/pl/certificate-usage/index',
meta: { title: '证件用量', keepAlive: false, menuCode: 'pl:quota-cert:list' }
},
{
id: 'PL00000000000000000000000000015',
name: 'PlPlanCert',
path: 'plan-cert',
component: '/pl/plan-cert/index',
meta: { title: '计划段次用证', keepAlive: false, menuCode: 'pl:plan-cert:list' }
},
{
id: 'PL_LEGAL_USE_DOCUMENT',
name: 'PlLegalUseDocument',
path: 'legal-use-document',
component: '/pl/legal-use-document/index',
meta: { title: '合法使用证明', keepAlive: false, menuCode: 'pl:legal-use-document:list' }
meta: {
title: '合法使用证明',
keepAlive: false,
menuCode: 'pl:legal-use-document:list'
}
}
]
},
@@ -511,6 +511,16 @@
/>
</el-tab-pane>
<el-tab-pane v-if="canViewQuotaCert" label="合同证件与额度" name="quota" lazy>
<ContractQuotaPanel
v-if="detailActiveTab === 'quota' && detailContract?.id"
:key="detailContract.id"
:contract-id="String(detailContract.id)"
:active="detailActiveTab === 'quota'"
embedded
/>
</el-tab-pane>
<!-- 回款记录 -->
<el-tab-pane label="回款记录" name="payment">
<div class="panel dialog-list-panel">
@@ -1045,6 +1055,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 PlContractLinkedPlansPanel from '@/components/business/pl-contract-linked-plans-panel/index.vue';
import {
parseSettlementClauseDrafts,
@@ -0,0 +1,5 @@
<template><StatisticsPanel mode="certificate" :active="active" /></template>
<script setup lang="ts">
import StatisticsPanel from '../quota-statistics/StatisticsPanel.vue'
defineProps<{ active?: boolean }>()
</script>
@@ -0,0 +1,6 @@
<template>
<div class="page-container"><CertificateUsagePanel /></div>
</template>
<script setup lang="ts">
import CertificateUsagePanel from './CertificateUsagePanel.vue'
</script>
@@ -0,0 +1,5 @@
<template><StatisticsPanel mode="contract" v-bind="props" /></template>
<script setup lang="ts">
import StatisticsPanel from '../quota-statistics/StatisticsPanel.vue'
const props = defineProps<{ contractId?: string; embedded?: boolean; active?: boolean }>()
</script>
@@ -0,0 +1,6 @@
<template>
<div class="page-container"><ContractQuotaPanel /></div>
</template>
<script setup lang="ts">
import ContractQuotaPanel from './ContractQuotaPanel.vue'
</script>
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<div :class="mobile ? 'mp-h5-list-content' : 'page-container'">
<section class="panel">
<div v-if="!mobile" class="panel-top"><h2 class="title-bar-heading">计划用证</h2></div>
<div v-if="!mobile" class="panel-top"><h2 class="title-bar-heading">计划段次用证</h2></div>
<div v-if="mobile" class="filter-block"
><el-form label-position="top" @submit.prevent="load"
><el-form-item label="计划编号"
@@ -21,7 +21,7 @@
remote
:remote-method="searchContracts"
:loading="searching"
:disabled="saving"
:disabled="saving || !!fixedContractId"
placeholder="输入合同编号或名称"
@change="loadContract"
>
@@ -174,6 +174,7 @@
import { Check, Close } from '@element-plus/icons-vue'
import {
quotaCertApi,
quotaStatisticsApi,
type CertType,
type CertBinding,
type QuotaCert,
@@ -181,7 +182,7 @@
type ContractProduct
} from '@/api/pl/quota-cert'
import { bindingPayload, bindingProductEnabled } from './model'
const props = defineProps<{ canEdit: boolean }>()
const props = defineProps<{ canEdit: boolean; fixedContractId?: string }>()
const emit = defineEmits<{ saved: [] }>()
const visible = ref(false)
const loading = ref(false)
@@ -213,17 +214,34 @@
const c = certificates.transport.find((c) => c.id === id)
return `车号:${c?.vehicleNumbersJson?.join('、') || '未填写'};引用:${c?.purchaseRefs?.map((r) => r.referenceNo).join('、') || '未填写'}`
}
let sessionVersion = 0
onBeforeUnmount(() => {
sessionVersion++
loadVersion++
})
async function searchContracts(keyword = '') {
const version = sessionVersion
searching.value = true
try {
contracts.value = await quotaCertApi.contracts(keyword)
const result = await quotaCertApi.contracts(keyword)
if (version === sessionVersion) contracts.value = result
} finally {
searching.value = false
}
}
async function loadFixedContract(id: string) {
const version = sessionVersion
const value = await quotaStatisticsApi.contract(id)
if (version !== sessionVersion) return
contracts.value = [
{ id: value.id, contractNo: value.contractNo, contractName: value.contractName }
]
}
async function open(id = '') {
if (saving.value) return
const session = ++sessionVersion
visible.value = true
id = props.fixedContractId || id
contractId.value = id
loadedContractId.value = ''
bindings.value = []
@@ -234,8 +252,9 @@
const [purchase, transport] = await Promise.all([
quotaCertApi.list('purchase'),
quotaCertApi.list('transport'),
searchContracts()
props.fixedContractId ? loadFixedContract(id) : searchContracts()
])
if (session !== sessionVersion) return
certificates.purchase = purchase
certificates.transport = transport
if (id) await loadContract()
+1 -1
View File
@@ -2,7 +2,7 @@
<QuotaHistoryDialog ref="historyDialog" :can-edit="canEdit" @saved="load" />
<div class="page-container">
<section class="panel">
<div class="panel-top"><h2 class="title-bar-heading">购买证与运输证</h2></div>
<div class="panel-top"><h2 class="title-bar-heading">{{ mobile ? '购买证与运输证' : '证件建档' }}</h2></div>
<div class="list-toolbar"
><div class="list-toolbar-row"
><el-select v-model="kind" aria-label="证件种类" style="width: 130px"
@@ -0,0 +1,102 @@
<template>
<div class="view-section">
<template v-if="mode === 'contract'">
<el-empty v-if="!contract?.certificates.length" description="暂无关联证件" :image-size="64" />
<section
v-for="cert in contract?.certificates"
:key="`${cert.certType}:${cert.certId}`"
class="view-section"
>
<h3>{{ cert.certNo }} · {{ cert.productNameOnCert }}</h3>
<el-tag size="small">{{ cert.certType === 'purchase' ? '购买证' : '运输证' }}</el-tag>
<el-tag size="small" :type="cert.linkStatus === 'active' ? 'success' : 'info'">{{
cert.linkStatus === 'active' ? '有效关联' : '历史 / 停用关联'
}}</el-tag>
<el-tag size="small" :type="cert.status === 'active' ? 'success' : 'info'">{{
statusLabel(cert.status)
}}</el-tag>
<p class="form-tip">有效期:{{ cert.validFrom || '—' }} 至 {{ cert.validTo || '—' }}</p>
<el-descriptions :column="3">
<el-descriptions-item label="全证核定"
>{{ cert.totalQty }} {{ cert.quantityUnit }}</el-descriptions-item
>
<el-descriptions-item label="全证预占"
>{{ cert.reservedQty }} {{ cert.quantityUnit }}</el-descriptions-item
>
<el-descriptions-item label="全证已用"
>{{ cert.usedQty }} {{ cert.quantityUnit }}</el-descriptions-item
>
<el-descriptions-item label="全证共享余额"
>{{ cert.availableQty }} {{ cert.quantityUnit }}</el-descriptions-item
>
<el-descriptions-item label="本合同预占"
>{{ cert.contractReservedQty }} {{ cert.quantityUnit }}</el-descriptions-item
>
<el-descriptions-item label="本合同已用"
>{{ cert.contractUsedQty }} {{ cert.quantityUnit }}</el-descriptions-item
>
</el-descriptions>
<StatisticsProducts :products="cert.products" :quantity-unit="cert.quantityUnit" />
</section>
</template>
<template v-else-if="certificate">
<el-descriptions :column="2">
<el-descriptions-item label="期初已用(独立记录)"
>{{ certificate.openingUsedQty }} {{ certificate.quantityUnit }}</el-descriptions-item
>
<el-descriptions-item label="未归属合同已用"
>{{ certificate.unattributedUsedQty }}
{{ certificate.quantityUnit }}</el-descriptions-item
>
</el-descriptions>
<el-empty v-if="!certificate.contracts.length" description="暂无关联合同" :image-size="64" />
<section v-for="item in certificate.contracts" :key="item.id" class="view-section">
<h3>{{ item.contractNo }} · {{ item.contractName }}</h3>
<el-tag size="small" :type="item.linkStatus === 'active' ? 'success' : 'info'">{{
item.linkStatus === 'active' ? '有效关联' : '历史 / 停用关联'
}}</el-tag>
<p
>本合同预占 {{ item.contractReservedQty }} / 已用 {{ item.contractUsedQty }}
{{ certificate.quantityUnit }}</p
>
<StatisticsProducts :products="item.products" :quantity-unit="certificate.quantityUnit" />
</section>
<h3>计划段产品用量追踪</h3>
<el-table :data="certificate.usageItems" stripe class="list-table" row-key="id">
<el-table-column prop="planNo" label="计划编号" min-width="180" />
<el-table-column label="段次" min-width="150"
><template #default="{ row }"
><el-tag size="small">{{ row.stopType === 'load' ? '装货' : '卸货' }}</el-tag>
{{ row.stopId }}</template
></el-table-column
>
<el-table-column prop="productName" label="产品" min-width="160" />
<el-table-column prop="stopProductId" label="段产品标识" min-width="180" />
<el-table-column prop="contractProductId" label="合同产品标识" min-width="180" />
<el-table-column prop="quantityUnit" label="单位" min-width="90" />
<el-table-column prop="reservedQty" label="预占" min-width="120" />
<el-table-column prop="usedQty" label="已用" min-width="120" />
<el-table-column label="状态" min-width="120"
><template #default="{ row }"
><el-tag size="small">{{ statusLabel(row.itemStatus) }}</el-tag></template
></el-table-column
>
</el-table>
</template>
</div>
</template>
<script setup lang="ts">
import type { ContractStatistics, CertificateStatistics } from '@/api/pl/quota-cert'
import StatisticsProducts from './StatisticsProducts.vue'
import { statusLabel } from './labels'
const props = defineProps<{
mode: 'contract' | 'certificate'
detail: ContractStatistics | CertificateStatistics
}>()
const contract = computed(() =>
props.mode === 'contract' ? (props.detail as ContractStatistics) : undefined
)
const certificate = computed(() =>
props.mode === 'certificate' ? (props.detail as CertificateStatistics) : undefined
)
</script>
@@ -0,0 +1,353 @@
<template>
<section :class="embedded ? 'dialog-list-panel' : 'panel'">
<div v-if="!embedded" class="panel-top"
><h2 class="title-bar-heading">{{ mode === 'contract' ? '合同用证' : '证件用量' }}</h2></div
>
<div v-if="!contractId" class="list-toolbar"
><div class="list-toolbar-row">
<ListQuickSearchInput
v-model="keyword"
:placeholder="mode === 'contract' ? '合同编号 / 名称' : '证件编号 / 品名'"
@search="search"
@clear="search"
/>
<el-select
v-if="mode === 'certificate'"
v-model="certType"
aria-label="证件类型"
@change="search"
style="width: 150px"
><el-option label="购买证" value="purchase" /><el-option label="运输证" value="transport"
/></el-select>
<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>
<p class="form-tip"
>额度按单张证件核算;共享余额为全证余额,多个合同共用,不代表本合同独享额度。</p
>
<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="contractId" v-loading="loading">
<template v-if="fixedDetail">
<el-button v-if="canEdit" @click="edit(contractId)"
><el-icon><Edit /></el-icon>维护合同适用证件</el-button
>
<StatisticsDetail mode="contract" :detail="fixedDetail" />
</template>
<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-${mode}-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">
<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
>
<template v-else-if="details[row.id]?.data">
<el-button v-if="mode === 'contract' && canEdit" @click="edit(row.id)"
><el-icon><Edit /></el-icon>维护合同适用证件</el-button
>
<StatisticsDetail :mode="mode" :detail="details[row.id].data!" />
</template>
</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
v-if="column.prop === searchColumn"
:prop="column.prop"
:label="column.label"
type="text"
:enable-sort="false"
:model-value="filters[column.prop] || null"
:is-active="isFilterActive(column.prop)"
@update:model-value="(v) => setFilterState(column.prop, v)"
/> </div
></template>
<template #default="{ row }"
><el-tag
v-if="column.prop === 'status'"
size="small"
:type="row.status === 'active' ? 'success' : 'info'"
>{{ statusLabel(row.status) }}</el-tag
><el-tag v-else-if="column.prop === 'contractType'" size="small">{{
row.contractType === 'purchase'
? '采购合同'
: row.contractType === 'sales'
? '销售合同'
: row.contractType || '—'
}}</el-tag
><span v-else>{{ 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>
<ContractScopeDialog
v-if="allowed"
:key="sessionKey"
ref="scopeDialog"
:can-edit="canEdit"
:fixed-contract-id="editingContractId"
@saved="load"
/>
</section>
</template>
<script setup lang="ts">
import { Edit, Refresh, RefreshLeft } from '@element-plus/icons-vue'
import { fetchGetMenuCodes } from '@/api/auth'
import { useUserStore } from '@/store/modules/user'
import {
quotaStatisticsApi,
type ContractStatistics,
type CertificateStatistics,
type CertType
} 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 ContractScopeDialog from '../quota-cert/ContractScopeDialog.vue'
import StatisticsDetail from './StatisticsDetail.vue'
import { createStatisticsLoader } from './loader'
import { statusLabel } from './labels'
const props = withDefaults(
defineProps<{
mode: 'contract' | 'certificate'
contractId?: string
embedded?: boolean
active?: boolean
}>(),
{ active: true }
)
const user = useUserStore()
const allowed = ref(false),
canEdit = ref(false),
loading = ref(false),
error = ref(''),
keyword = ref(''),
page = ref(1),
total = ref(0)
const certType = ref<CertType>('purchase')
const rows = ref<Record<string, any>[]>([])
const fixedDetail = ref<ContractStatistics>()
const details = reactive<
Record<
string,
{ loading: boolean; error?: boolean; data?: ContractStatistics | CertificateStatistics }
>
>({})
const editingContractId = ref(''),
sessionKey = ref(0)
const scopeDialog = ref<InstanceType<typeof ContractScopeDialog>>()
const loader = createStatisticsLoader(quotaStatisticsApi)
let epoch = 0
const searchColumn = props.mode === 'contract' ? 'contractNo' : 'certNo'
const columns: ColumnFilterDef[] = (
props.mode === 'contract'
? [
['contractNo', '合同编号'],
['contractName', '合同名称'],
['contractType', '合同类型'],
['certCount', '关联证件数']
]
: [
['certNo', '证件编号'],
['productNameOnCert', '证载品名'],
['quantityUnit', '单位'],
['totalQty', '全证核定'],
['reservedQty', '全证预占'],
['usedQty', '全证已用'],
['availableQty', '全证共享余额'],
['status', '状态'],
['validFrom', '有效期起'],
['validTo', '有效期止']
]
).map(([prop, label]) => ({ prop, label, type: 'text', queryKey: 'keyword' }))
const { visibleColumns, checkAll, isIndeterminate, resetToDefault } = usePersistedListColumns(
`pl-${props.mode}-statistics-v1`,
columns
)
const visibleDefs = computed(() => columns.filter((c) => visibleColumns.value.includes(c.prop)))
const {
filters,
hasActiveFilters,
setFilterState,
clearAllFilters,
isFilterActive,
toQueryParams,
watchVisibleColumns
} = useServerColumnFilters(
columns.filter((c) => c.prop === searchColumn),
{ onChange: search }
)
watchVisibleColumns(visibleColumns)
function clear() {
loader.invalidate()
epoch++
rows.value = []
fixedDetail.value = undefined
total.value = 0
loading.value = false
error.value = ''
Object.keys(details).forEach((k) => delete details[k])
}
async function load() {
clear()
if (!allowed.value || !props.active) return
const current = epoch
loading.value = true
try {
const data = await loader.load({
mode: props.mode,
contractId: props.contractId,
allowed: allowed.value,
query: {
page: page.value,
pageSize: 20,
keyword: toQueryParams().keyword || keyword.value,
...(props.mode === 'certificate' ? { certType: certType.value } : {})
}
})
if (current !== epoch || !data) return
if (props.contractId) fixedDetail.value = data
else {
rows.value = data.list
total.value = data.total
}
} catch {
if (current === epoch) error.value = '统计加载失败,请重试'
} finally {
if (current === epoch) loading.value = false
}
}
async function loadDetail(row: Record<string, any>) {
if (!allowed.value || !props.active) return
const current = epoch
const state = reactive({
loading: true,
error: false,
data: undefined as ContractStatistics | CertificateStatistics | undefined
})
details[row.id] = state
try {
const data = await (props.mode === 'contract'
? quotaStatisticsApi.contract(row.id)
: quotaStatisticsApi.certificate(row.certType, row.certId))
if (current === epoch && details[row.id] === state) state.data = data
} catch {
if (current === epoch && details[row.id] === state) state.error = true
} finally {
if (current === epoch && details[row.id] === state) state.loading = false
}
}
function expand(row: Record<string, any>, expanded: Record<string, any>[]) {
if (expanded.some((r) => r.id === row.id) && !details[row.id]) void loadDetail(row)
}
function search() {
page.value = 1
void load()
}
function reset() {
keyword.value = ''
clearAllFilters()
search()
}
async function edit(id: string) {
editingContractId.value = id
await nextTick()
void scopeDialog.value?.open(id)
}
async function init() {
clear()
allowed.value = false
canEdit.value = false
sessionKey.value++
if (!user.accessToken || !props.active) return
const current = epoch
try {
const p = await fetchGetMenuCodes()
if (current !== epoch) return
allowed.value =
!!p.all || !!p.isSuperAdmin || (p.menuCodes || []).includes('pl:quota-cert:list')
canEdit.value =
allowed.value &&
(!!p.all || !!p.isSuperAdmin || (p.menuCodes || []).includes('pl:quota-cert:edit'))
await load()
} catch {
if (current === epoch) error.value = '权限加载失败,请重新打开页面'
}
}
watch(
() => [props.contractId, props.active, JSON.stringify([user.accessToken, user.getUserInfo])],
() => {
page.value = 1
void init()
},
{ immediate: true, flush: 'sync' }
)
onBeforeUnmount(() => {
clear()
allowed.value = false
})
const tableWrapRef = ref<HTMLElement | null>(null),
pagerRef = ref<HTMLElement | null>(null),
tableRef = ref()
const { tableHeight } = useListTableViewportHeight({
tableEl: tableWrapRef,
pagerEl: pagerRef,
tableRef,
enabled: computed(() => !props.embedded)
})
</script>
@@ -0,0 +1,27 @@
<template>
<el-table :data="products" stripe class="list-table" row-key="id" empty-text="暂无合同产品范围">
<el-table-column prop="productName" label="绑定合同产品" min-width="180" />
<el-table-column prop="model" label="规格" min-width="140" />
<el-table-column prop="unit" label="产品单位" min-width="90" />
<el-table-column label="额度单位" min-width="90"
><template #default>{{ quantityUnit }}</template></el-table-column
>
<el-table-column prop="reservedQty" label="预占" min-width="120" />
<el-table-column prop="usedQty" label="已用" min-width="120" />
<el-table-column label="范围状态" min-width="130"
><template #default="{ row }"
><el-tag size="small" :type="row.scopeStatus === 'active' ? 'success' : 'info'">{{
row.scopeStatus === 'active'
? '有效'
: row.scopeStatus === 'inactive'
? '停用'
: '历史解绑'
}}</el-tag></template
></el-table-column
>
</el-table>
</template>
<script setup lang="ts">
import type { StatisticsProduct } from '@/api/pl/quota-cert'
defineProps<{ products: StatisticsProduct[]; quantityUnit: string }>()
</script>
@@ -0,0 +1,14 @@
export const statusLabel = (value: string) =>
({
draft: '草稿',
active: '有效',
suspended: '停用',
void: '作废',
reserved: '预占',
settled: '已核销',
in_transit: '运输中',
exception: '异常',
released: '已释放',
consumed: '已核销',
cancelled: '已取消'
})[value] || value
@@ -0,0 +1,29 @@
import type { quotaStatisticsApi, StatisticsQuery } from '@/api/pl/quota-cert'
// A context owns its requests; invalidation also protects failures arriving after a switch.
export function createStatisticsLoader(api: typeof quotaStatisticsApi) {
let epoch = 0
return {
invalidate() {
epoch++
},
async load(options: {
mode: 'contract' | 'certificate'
contractId?: string
allowed: boolean
query?: StatisticsQuery
}): Promise<any> {
const current = ++epoch
if (!options.allowed) return
try {
const result = await (options.mode === 'contract'
? options.contractId
? api.contract(options.contractId)
: api.contracts(options.query || {})
: api.certificates(options.query || {}))
return current === epoch ? result : undefined
} catch (error) {
if (current === epoch) throw error
}
}
}
}