From 0d1d264bc72e6115a1ced80b16bcfd5d904cc3e2 Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Sun, 1 Nov 2026 21:53:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(pl):=20=E5=A2=9E=E5=8A=A0=E5=90=88?= =?UTF-8?q?=E5=90=8C=E4=B8=8E=E8=AF=81=E4=BB=B6=E7=94=A8=E9=87=8F=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E4=BB=A5=E8=BF=BD=E8=B8=AA=E5=85=B1=E4=BA=AB=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6=E5=8F=8A=E6=AE=B5=E4=BA=A7=E5=93=81=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-11-01-pl-stop-cert-views.sql | 36 ++ web-admin/scripts/quota-statistics.test.ts | 263 +++++++++++++ web-admin/src/App.vue | 2 +- web-admin/src/api/pl/quota-cert.ts | 107 ++++++ web-admin/src/router/modules/pl.ts | 36 +- .../components/ContractDetailDialog.vue | 11 + .../CertificateUsagePanel.vue | 5 + .../src/views/pl/certificate-usage/index.vue | 6 + .../pl/contract-quota/ContractQuotaPanel.vue | 5 + .../src/views/pl/contract-quota/index.vue | 6 + web-admin/src/views/pl/plan-quota/index.vue | 2 +- .../pl/quota-cert/ContractScopeDialog.vue | 27 +- web-admin/src/views/pl/quota-cert/index.vue | 2 +- .../pl/quota-statistics/StatisticsDetail.vue | 102 +++++ .../pl/quota-statistics/StatisticsPanel.vue | 353 ++++++++++++++++++ .../quota-statistics/StatisticsProducts.vue | 27 ++ .../src/views/pl/quota-statistics/labels.ts | 14 + .../src/views/pl/quota-statistics/loader.ts | 29 ++ 18 files changed, 1017 insertions(+), 16 deletions(-) create mode 100644 docs/sql/migrations/2026-11-01-pl-stop-cert-views.sql create mode 100644 web-admin/scripts/quota-statistics.test.ts create mode 100644 web-admin/src/views/pl/certificate-usage/CertificateUsagePanel.vue create mode 100644 web-admin/src/views/pl/certificate-usage/index.vue create mode 100644 web-admin/src/views/pl/contract-quota/ContractQuotaPanel.vue create mode 100644 web-admin/src/views/pl/contract-quota/index.vue create mode 100644 web-admin/src/views/pl/quota-statistics/StatisticsDetail.vue create mode 100644 web-admin/src/views/pl/quota-statistics/StatisticsPanel.vue create mode 100644 web-admin/src/views/pl/quota-statistics/StatisticsProducts.vue create mode 100644 web-admin/src/views/pl/quota-statistics/labels.ts create mode 100644 web-admin/src/views/pl/quota-statistics/loader.ts diff --git a/docs/sql/migrations/2026-11-01-pl-stop-cert-views.sql b/docs/sql/migrations/2026-11-01-pl-stop-cert-views.sql new file mode 100644 index 000000000..f39bda305 --- /dev/null +++ b/docs/sql/migrations/2026-11-01-pl-stop-cert-views.sql @@ -0,0 +1,36 @@ +-- 工单 #4:Web 用证管理五个视图;MySQL 5.7。保留菜单身份与授权,不恢复删除项。 +SET NAMES utf8mb4; +START TRANSACTION; +INSERT INTO sys_menu ( + id,menu_name,parent_id,menu_type,path,component,perms,is_frame,visible,status, + tenant_no,order_num,remark,create_by,create_dept_id,create_time,update_time,del_flag +) +SELECT target.id,target.name,COALESCE(folder.id,parent.id),'C',target.path,target.component,'pl:quota-cert:list',1,'1','1', + parent.tenant_no,target.sort_no,'工单#4:按合同与证件统计;不自动授权','system','',NOW(),NOW(),0 +FROM sys_menu parent +JOIN sys_menu root ON root.id=parent.parent_id AND root.tenant_no=parent.tenant_no AND root.del_flag=0 +LEFT JOIN sys_menu folder ON folder.id='PL_GROUP_CERT' AND folder.parent_id=parent.id + AND folder.tenant_no=parent.tenant_no AND folder.del_flag=0 +CROSS JOIN ( + SELECT 'PL_CONTRACT_QUOTA' id,'合同用证' name,'contract-quota' path,'pl/contract-quota/index' component,2 sort_no + UNION ALL SELECT 'PL_CERTIFICATE_USAGE','证件用量','certificate-usage','pl/certificate-usage/index',3 +) target +WHERE parent.id='PL_MENU_ROOT' AND parent.del_flag=0 AND parent.tenant_no='shanxia2.0' + AND root.path='/web' AND root.parent_id='0' + AND NOT EXISTS (SELECT 1 FROM sys_menu existing WHERE existing.id=target.id + OR (existing.tenant_no=parent.tenant_no AND existing.path=target.path)); + +UPDATE sys_menu leaf +JOIN sys_menu parent ON parent.id='PL_MENU_ROOT' AND parent.tenant_no=leaf.tenant_no AND parent.del_flag=0 +JOIN sys_menu root ON root.id=parent.parent_id AND root.tenant_no=parent.tenant_no AND root.del_flag=0 +LEFT JOIN sys_menu folder ON folder.id=leaf.parent_id AND folder.id='PL_GROUP_CERT' + AND folder.parent_id=parent.id AND folder.tenant_no=leaf.tenant_no AND folder.del_flag=0 +SET leaf.menu_name=CASE leaf.id WHEN 'PL_QUOTA_CERT' THEN '证件建档' + WHEN 'PL00000000000000000000000000015' THEN '计划段次用证' ELSE '合法使用证明' END, + leaf.order_num=CASE leaf.id WHEN 'PL_QUOTA_CERT' THEN 1 WHEN 'PL00000000000000000000000000015' THEN 4 ELSE 5 END, + leaf.update_time=NOW() +WHERE leaf.tenant_no='shanxia2.0' AND leaf.del_flag=0 + AND root.path='/web' AND root.parent_id='0' + AND (leaf.parent_id=parent.id OR folder.id IS NOT NULL) + AND leaf.id IN ('PL_QUOTA_CERT','PL00000000000000000000000000015','PL_LEGAL_USE_DOCUMENT'); +COMMIT; diff --git a/web-admin/scripts/quota-statistics.test.ts b/web-admin/scripts/quota-statistics.test.ts new file mode 100644 index 000000000..eacb6020e --- /dev/null +++ b/web-admin/scripts/quota-statistics.test.ts @@ -0,0 +1,263 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createStatisticsLoader } from '../src/views/pl/quota-statistics/loader' +test('fixed contract skips global list and retains each decimal balance', async () => { + const calls: string[] = [] + const loader = createStatisticsLoader({ + contracts: async () => { + calls.push('global') + }, + contract: async (id: string) => { + calls.push(id) + return { certificates: [{ availableQty: '0.10000001' }, { availableQty: '7.2' }] } + } + } as any) + const result = await loader.load({ mode: 'contract', contractId: 'c1', allowed: true }) + assert.deepEqual(calls, ['c1']) + assert.deepEqual( + result.certificates.map((c: any) => c.availableQty), + ['0.10000001', '7.2'] + ) +}) +test('permission denied never requests statistics', async () => { + const loader = createStatisticsLoader( + new Proxy( + {}, + { + get: () => () => { + throw Error('unexpected request') + } + } + ) as any + ) + assert.equal(await loader.load({ mode: 'contract', allowed: false }), undefined) +}) +test('context invalidation discards pending response', async () => { + let resolve!: (v: any) => void + const loader = createStatisticsLoader({ + contract: () => + new Promise((r) => { + resolve = r + }) + } as any) + const pending = loader.load({ mode: 'contract', contractId: 'old', allowed: true }) + loader.invalidate() + resolve({ id: 'old' }) + assert.equal(await pending, undefined) +}) +import { readFileSync } from 'node:fs' +import { parse } from '@vue/compiler-sfc' +import ts from 'typescript' +import * as vue from 'vue' +import { plRoutes } from '../src/router/modules/pl' +import { usePersistedListColumns } from '../src/composables/usePersistedListColumns' +import { useServerColumnFilters } from '../src/composables/useServerColumnFilters' +const flush = async () => { + for (let i = 0; i < 30; i++) await Promise.resolve() +} +function deferred() { + let resolve!: (value: any) => void, reject!: (error: any) => void + const promise = new Promise((a, b) => { + resolve = a + reject = b + }) + return { promise, resolve, reject } +} +function harness(fixed?: string) { + const calls: any[] = [], + permissions: any[] = [], + unmount: Function[] = [] + const props = vue.reactive({ + mode: 'contract', + contractId: fixed, + active: true, + embedded: !!fixed + }) + const user = vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } }) + const queue = (method: string, args: any[]) => { + const pending = deferred() + calls.push({ method, args, ...pending }) + return pending.promise + } + const api = Object.fromEntries( + ['contracts', 'contract', 'certificates', 'certificate'].map((method) => [ + method, + (...args: any[]) => queue(method, args) + ]) + ) + const env = { + ...Object.fromEntries( + Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default') + ), + defineProps: () => props, + withDefaults: (v: any) => v, + useUserStore: () => user, + fetchGetMenuCodes: () => { + const p = deferred() + permissions.push(p) + return p.promise + }, + useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }), + usePersistedListColumns, + useServerColumnFilters, + createStatisticsLoader, + quotaStatisticsApi: api, + onBeforeUnmount: (fn: Function) => unmount.push(fn) + } + const src = ts.createSourceFile( + 'panel.ts', + parse( + readFileSync( + new URL('../src/views/pl/quota-statistics/StatisticsPanel.vue', import.meta.url), + 'utf8' + ) + ).descriptor.scriptSetup!.content, + ts.ScriptTarget.Latest, + true + ) + const code = ts.transpile( + src.statements + .filter((s) => !ts.isImportDeclaration(s)) + .map((s) => s.getText(src)) + .join('\n'), + { target: ts.ScriptTarget.ESNext } + ) + const scope = vue.effectScope() + const state = scope.run(() => + new Function( + ...Object.keys(env), + `${code}; return { rows, fixedDetail, details, load, loadDetail, allowed, canEdit }` + )(...Object.values(env)) + ) + return { + state, + calls, + permissions, + props, + user, + stop: () => { + unmount.forEach((fn) => fn()) + scope.stop() + } + } +} +test('five leaves retain IDs and individual permissions', () => { + const group = plRoutes[0].children!.find((r) => r.id === 'PL_GROUP_CERT')! + assert.deepEqual( + group.children!.map((r) => r.path), + ['quota-cert', 'contract-quota', 'certificate-usage', 'plan-cert', 'legal-use-document'] + ) + assert.equal(group.children![0].id, 'PL_QUOTA_CERT') + assert.equal(group.children![3].id, 'PL00000000000000000000000000015') + assert.deepEqual( + group.children!.map((r) => r.meta!.menuCode), + [ + 'pl:quota-cert:list', + 'pl:quota-cert:list', + 'pl:quota-cert:list', + 'pl:plan-cert:list', + 'pl:legal-use-document:list' + ] + ) +}) +test('actual fixed panel loads only current contract and drops old response', async () => { + const h = harness('one') + h.permissions[0].resolve({ menuCodes: ['pl:quota-cert:list'] }) + await flush() + assert.equal(h.calls[0].method, 'contract') + assert.deepEqual(h.calls[0].args, ['one']) + h.props.contractId = 'two' + h.permissions[1].resolve({ all: true }) + await flush() + h.calls[1].resolve({ id: 'two', certificates: [] }) + await flush() + h.calls[0].resolve({ id: 'one', certificates: [] }) + await flush() + assert.equal(h.state.fixedDetail.value.id, 'two') + h.stop() +}) +for (const change of ['tenant', 'logout', 'unmount']) + test(`actual panel invalidates data on ${change}`, async () => { + const h = harness() + h.permissions[0].resolve({ all: true }) + await flush() + if (change === 'tenant') h.user.getUserInfo.tenantNo = 'b' + if (change === 'logout') h.user.accessToken = '' + if (change === 'unmount') h.stop() + h.calls[0].resolve({ list: [{ id: 'stale' }], total: 1 }) + await flush() + assert.deepEqual(h.state.rows.value, []) + assert.equal(h.state.allowed.value, false) + h.stop() + }) +test('proof-only permission cannot request quota', async () => { + const h = harness() + h.permissions[0].resolve({ menuCodes: ['pl:legal-use-document:list'] }) + await flush() + assert.equal(h.calls.length, 0) + h.stop() +}) +test('expand failure clears stale data and retry works', async () => { + const h = harness() + h.permissions[0].resolve({ all: true }) + await flush() + h.calls[0].resolve({ list: [{ id: 'c' }], total: 1 }) + await flush() + const first = h.state.loadDetail({ id: 'c' }) + h.calls[1].resolve({ id: 'c', certificates: [] }) + await first + assert.equal(h.state.details.c.data.id, 'c') + const failure = h.state.loadDetail({ id: 'c' }) + h.calls[2].reject(Error('failed')) + await failure + assert.equal(h.state.details.c.data, undefined) + assert.equal(h.state.details.c.error, true) + const retry = h.state.loadDetail({ id: 'c' }) + h.calls[3].resolve({ id: 'c', certificates: [] }) + await retry + assert.equal(h.state.details.c.data.id, 'c') + h.stop() +}) +test('statistics API maps each view to the server endpoint and preserves response decimals', async () => { + const calls: any[] = [] + const raw = { list: [{ availableQty: '1.00000001' }], total: 1 } + const source = ts.createSourceFile( + 'api.ts', + readFileSync(new URL('../src/api/pl/quota-cert.ts', import.meta.url), 'utf8'), + ts.ScriptTarget.Latest, + true + ) + const code = ts.transpile( + source.statements + .filter((s) => !ts.isImportDeclaration(s)) + .map((s) => s.getText(source).replace(/^export /, '')) + .join('\n'), + { target: ts.ScriptTarget.ESNext } + ) + const api = new Function('request', 'pickBxData', `${code};return quotaStatisticsApi`)( + { + get: (url: string, options: any) => { + calls.push({ url, options }) + return Promise.resolve(raw) + } + }, + (v: any) => v + ) + assert.equal( + (await api.contracts({ page: 2, keyword: '合同' })).list[0].availableQty, + '1.00000001' + ) + await api.certificates({ certType: 'transport', pageSize: 20 }) + await api.contract('c') + await api.certificate('purchase', 'p') + assert.deepEqual( + calls.map((c) => c.url), + [ + '/transport/quota-certs/statistics/contracts', + '/transport/quota-certs/statistics/certificates', + '/transport/quota-certs/contracts/c/statistics', + '/transport/quota-certs/purchase/p/statistics' + ] + ) + assert.deepEqual(calls[1].options.params, { certType: 'transport', pageSize: 20 }) +}) diff --git a/web-admin/src/App.vue b/web-admin/src/App.vue index fa62ea07d..df057fc09 100644 --- a/web-admin/src/App.vue +++ b/web-admin/src/App.vue @@ -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' diff --git a/web-admin/src/api/pl/quota-cert.ts b/web-admin/src/api/pl/quota-cert.ts index f3d3ba356..8ceec1c31 100644 --- a/web-admin/src/api/pl/quota-cert.ts +++ b/web-admin/src/api/pl/quota-cert.ts @@ -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 { 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 { + 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>), + certificates: (params: StatisticsQuery) => + request + .get(`${base}/statistics/certificates`, { params }) + .then(pickBxData>), + contract: (id: string) => + request.get(`${base}/contracts/${id}/statistics`).then(pickBxData), + certificate: (kind: CertType, id: string) => + request.get(`${base}/${kind}/${id}/statistics`).then(pickBxData) +} diff --git a/web-admin/src/router/modules/pl.ts b/web-admin/src/router/modules/pl.ts index dc4aea0b9..31e6b12c5 100644 --- a/web-admin/src/router/modules/pl.ts +++ b/web-admin/src/router/modules/pl.ts @@ -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' + } } ] }, diff --git a/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue b/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue index 6306c0691..8f8825cdf 100644 --- a/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue +++ b/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue @@ -511,6 +511,16 @@ /> + + + +
@@ -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, diff --git a/web-admin/src/views/pl/certificate-usage/CertificateUsagePanel.vue b/web-admin/src/views/pl/certificate-usage/CertificateUsagePanel.vue new file mode 100644 index 000000000..d7a7ad40b --- /dev/null +++ b/web-admin/src/views/pl/certificate-usage/CertificateUsagePanel.vue @@ -0,0 +1,5 @@ + + diff --git a/web-admin/src/views/pl/certificate-usage/index.vue b/web-admin/src/views/pl/certificate-usage/index.vue new file mode 100644 index 000000000..ab78c4728 --- /dev/null +++ b/web-admin/src/views/pl/certificate-usage/index.vue @@ -0,0 +1,6 @@ + + diff --git a/web-admin/src/views/pl/contract-quota/ContractQuotaPanel.vue b/web-admin/src/views/pl/contract-quota/ContractQuotaPanel.vue new file mode 100644 index 000000000..6f8d3c848 --- /dev/null +++ b/web-admin/src/views/pl/contract-quota/ContractQuotaPanel.vue @@ -0,0 +1,5 @@ + + diff --git a/web-admin/src/views/pl/contract-quota/index.vue b/web-admin/src/views/pl/contract-quota/index.vue new file mode 100644 index 000000000..6115abbe0 --- /dev/null +++ b/web-admin/src/views/pl/contract-quota/index.vue @@ -0,0 +1,6 @@ + + diff --git a/web-admin/src/views/pl/plan-quota/index.vue b/web-admin/src/views/pl/plan-quota/index.vue index 1cdc5bb51..e0a0e3017 100644 --- a/web-admin/src/views/pl/plan-quota/index.vue +++ b/web-admin/src/views/pl/plan-quota/index.vue @@ -1,7 +1,7 @@ + diff --git a/web-admin/src/views/pl/quota-statistics/StatisticsPanel.vue b/web-admin/src/views/pl/quota-statistics/StatisticsPanel.vue new file mode 100644 index 000000000..dcfb0902d --- /dev/null +++ b/web-admin/src/views/pl/quota-statistics/StatisticsPanel.vue @@ -0,0 +1,353 @@ + + diff --git a/web-admin/src/views/pl/quota-statistics/StatisticsProducts.vue b/web-admin/src/views/pl/quota-statistics/StatisticsProducts.vue new file mode 100644 index 000000000..791fc393e --- /dev/null +++ b/web-admin/src/views/pl/quota-statistics/StatisticsProducts.vue @@ -0,0 +1,27 @@ + + diff --git a/web-admin/src/views/pl/quota-statistics/labels.ts b/web-admin/src/views/pl/quota-statistics/labels.ts new file mode 100644 index 000000000..192baf5ba --- /dev/null +++ b/web-admin/src/views/pl/quota-statistics/labels.ts @@ -0,0 +1,14 @@ +export const statusLabel = (value: string) => + ({ + draft: '草稿', + active: '有效', + suspended: '停用', + void: '作废', + reserved: '预占', + settled: '已核销', + in_transit: '运输中', + exception: '异常', + released: '已释放', + consumed: '已核销', + cancelled: '已取消' + })[value] || value diff --git a/web-admin/src/views/pl/quota-statistics/loader.ts b/web-admin/src/views/pl/quota-statistics/loader.ts new file mode 100644 index 000000000..821b77f07 --- /dev/null +++ b/web-admin/src/views/pl/quota-statistics/loader.ts @@ -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 { + 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 + } + } + } +}