From 144a3cb23648a81dced78b8968db6251e69304c9 Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Sun, 1 Nov 2026 23:32:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(crm):=20=E6=96=B0=E5=A2=9E=E5=90=88?= =?UTF-8?q?=E5=90=8C=E8=BF=90=E8=BE=93=E8=AE=A1=E5=88=92=E5=90=88=E6=B3=95?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E8=AF=81=E6=98=8E=E9=A1=B5=E7=AD=BE=E5=B9=B6?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=E6=9D=83=E9=99=90=E4=B8=8E=E5=BC=82=E6=AD=A5?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-admin/scripts/contract-proof-tab.test.ts | 352 ++++++++++++++++++ .../components/ContractDetailDialog.vue | 64 +++- 2 files changed, 412 insertions(+), 4 deletions(-) create mode 100644 web-admin/scripts/contract-proof-tab.test.ts diff --git a/web-admin/scripts/contract-proof-tab.test.ts b/web-admin/scripts/contract-proof-tab.test.ts new file mode 100644 index 000000000..41b7cb621 --- /dev/null +++ b/web-admin/scripts/contract-proof-tab.test.ts @@ -0,0 +1,352 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { parse, compileTemplate } from '@vue/compiler-sfc' +import ts from 'typescript' +import * as vue from 'vue' + +const descriptor = parse( + readFileSync( + new URL('../src/views/crm/contract/components/ContractDetailDialog.vue', import.meta.url), + 'utf8' + ) +).descriptor +const flush = async () => { + for (let i = 0; i < 30; i++) await Promise.resolve() +} +function deferred() { + let resolve!: (value?: any) => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} +function harness() { + const permissions: ReturnType[] = [], + contracts: any[] = [], + mounted: any[] = [], + disposed: any[] = [] + const user = vue.reactive({ + accessToken: 'a', + getUserInfo: { userId: 'u', tenantNo: 't', roles: ['operator'] } + }) + const dict = deferred() + const source = ts.createSourceFile( + 'detail.ts', + descriptor.scriptSetup!.content, + ts.ScriptTarget.Latest, + true + ) + const env: any = Object.fromEntries( + Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key)) + ) + delete env.default + for (const statement of source.statements) { + if (!ts.isImportDeclaration(statement)) continue + const clause = statement.importClause + if (clause?.name) env[clause.name.text] = () => undefined + if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) { + for (const item of clause.namedBindings.elements) + if (!(item.name.text in env)) env[item.name.text] = () => undefined + } + } + Object.assign(env, { + defineProps: () => ({}), + withDefaults: (v: any) => v, + defineEmits: () => () => {}, + defineExpose: () => {}, + useUserStore: () => user, + useDictStore: () => ({ loadAllDict: () => dict.promise }), + fetchGetMenuCodes: () => { + const p = deferred() + permissions.push(p) + return p.promise + }, + getContractDetail: (id: string) => { + const p = deferred() + contracts.push({ id, ...p }) + return p.promise + }, + listContractApproval: async () => ({ data: [] }), + onMounted: (fn: any) => mounted.push(fn), + onBeforeUnmount: (fn: any) => disposed.push(fn) + }) + const code = ts.transpile( + source.statements + .filter((s) => !ts.isImportDeclaration(s)) + .map((s) => s.getText(source)) + .join('\n'), + { target: ts.ScriptTarget.ESNext } + ) + const scope = vue.effectScope() + const state = scope.run(() => + new Function( + ...Object.keys(env), + `${code}; return { visible, detailActiveTab, detailContract, currentDetailId, canViewQuotaCert, canViewProof: typeof canViewProof === 'undefined' ? ref(false) : canViewProof, open, loadDetailData, detailLoading, handleDetailCopy, formDialogCopySource }` + )(...Object.values(env)) + ) + mounted.forEach((fn) => fn()) + return { + state, + user, + permissions, + contracts, + dict, + stop() { + disposed.forEach((fn) => fn()) + scope.stop() + } + } +} + +test('independent permissions initialize without waiting for dictionaries', async () => { + for (const [code, quota, proof] of [ + ['pl:legal-use-document:list', false, true], + ['pl:quota-cert:list', true, false], + ['none', false, false] + ] as const) { + const h = harness() + h.state.visible.value = true + await flush() + assert.equal(h.permissions.length, 1) + h.permissions[0].resolve({ menuCodes: [code] }) + await flush() + assert.equal(h.state.canViewQuotaCert.value, quota) + assert.equal(h.state.canViewProof.value, proof) + h.dict.resolve() + h.stop() + } +}) + +for (const change of ['tenant', 'token', 'user', 'roles', 'close']) + test(`permissions discard old responses after ${change} and reopen`, async () => { + const h = harness(), + s = h.state + s.visible.value = true + await flush() + assert.equal(h.permissions.length, 1) + if (change === 'tenant') h.user.getUserInfo.tenantNo = 'new' + if (change === 'token') h.user.accessToken = 'new' + if (change === 'roles') h.user.getUserInfo.roles = [] + if (change === 'user') h.user.getUserInfo.userId = 'new' + if (change === 'close') { + s.visible.value = false + s.visible.value = true + } + if (change !== 'close') { + assert.equal(s.visible.value, false) + s.visible.value = true + } + await flush() + assert.equal(h.permissions.length >= 2, true) + h.permissions[h.permissions.length - 1].resolve({ menuCodes: ['pl:legal-use-document:list'] }) + await flush() + h.permissions[0].resolve({ all: true }) + await flush() + assert.equal(s.canViewQuotaCert.value, false) + assert.equal(s.canViewProof.value, true) + s.detailActiveTab.value = 'proof' + s.visible.value = false + assert.equal(s.canViewProof.value, false) + assert.equal(s.detailActiveTab.value, 'contract') + h.stop() + }) + +test('switching contracts clears old scope immediately and discards reversed detail responses', async () => { + const h = harness(), + s = h.state + const first = s.open('one') + h.contracts[0].resolve({ data: { id: 'one' } }) + await first + s.detailActiveTab.value = 'proof' + const second = s.open('two') + assert.equal(s.detailContract.value, undefined) + const third = s.open('three') + h.contracts[2].resolve({ data: { id: 'three' } }) + await third + h.contracts[1].resolve({ data: { id: 'two' } }) + await second + assert.equal(s.detailContract.value.id, 'three') + h.stop() +}) + +// Compile the actual three panes to exercise v-if/props/key, without the unrelated 4,000-line dialog. +const template = descriptor.template!.content +const start = template.indexOf('', start) +const compiled = compileTemplate({ + source: `
${template.slice(start, end)}
`, + filename: 'tabs.vue', + id: 'tabs' +}) +const renderSource = compiled.code + .replace( + /import \{([^}]+)\} from "vue"/g, + (_, bindings) => `const {${bindings.replace(/ as /g, ':')}} = Vue` + ) + .replace('export function render', 'function render') +const render = new Function('Vue', `${renderSource}; return render`)({ + ...vue, + resolveComponent: (name: string) => ({ name }) +}) +function panes(ctx: any) { + const tree = render(ctx, []) + return tree.children.filter((v: any) => typeof v.type !== 'symbol') +} +function panel(pane: any) { + return pane.children.default().find((v: any) => typeof v.type !== 'symbol') +} +test('real template keeps plans quota proof order and separate lazy contract scopes', () => { + const ctx = { + visible: true, + currentDetailId: 42, + canViewQuotaCert: true, + canViewProof: true, + detailActiveTab: 'proof', + detailContract: { id: 42 } + } + const rows = panes(ctx) + assert.deepEqual( + rows.map((v: any) => v.props.name), + ['plans', 'quota', 'proof'] + ) + assert.equal(rows[2].props.label, '运输计划合法使用证明') + assert.equal(rows[2].props.lazy, '') + assert.equal(panel(rows[1]), undefined) + const proof = panel(rows[2]) + assert.equal(proof.props['contract-id'], '42') + assert.equal(proof.key, 42) + assert.equal(proof.props.active, true) + assert.equal(proof.props.embedded, '') + ctx.detailContract.id = 43 + ctx.currentDetailId = 43 + assert.equal(panel(panes(ctx)[2]).key, 43) + ctx.canViewQuotaCert = false + assert.deepEqual( + panes(ctx).map((v: any) => v.props.name), + ['plans', 'proof'] + ) + ctx.detailActiveTab = 'quota' + assert.equal(panel(panes(ctx)[1]), undefined) +}) + +test('closing A and opening B does not accept A detail or invalidate B permissions', async () => { + const h = harness(), + s = h.state + const a = s.open('A') + s.visible.value = false + const b = s.open('B') + h.contracts[1].resolve({ data: { id: 'B' } }) + await b + h.permissions[h.permissions.length - 1].resolve({ menuCodes: ['pl:legal-use-document:list'] }) + await flush() + h.contracts[0].resolve({ data: { id: 'A' } }) + await a + assert.equal(s.detailContract.value.id, 'B') + assert.equal(s.canViewProof.value, true) + h.stop() +}) + +test('mounted template unmounts old contract and never mounts quota for proof-only users', async () => { + const lifecycle: string[] = [] + const Child = (name: string) => + vue.defineComponent({ + props: ['contractId', 'active', 'embedded'], + setup(props) { + lifecycle.push(`mount:${name}:${props.contractId}`) + vue.onBeforeUnmount(() => lifecycle.push(`unmount:${name}:${props.contractId}`)) + return () => vue.h('span') + } + }) + const components: Record = { + 'el-tab-pane': vue.defineComponent({ + setup(_, { slots }) { + return () => vue.h('section', slots.default?.()) + } + }), + StopDocuments: Child('proof'), + ContractQuotaPanel: Child('quota'), + PlContractLinkedPlansPanel: vue.defineComponent({ + setup() { + return () => vue.h('span') + } + }) + } + const mountedRender = new Function('Vue', `${renderSource}; return render`)({ + ...vue, + resolveComponent: (name: string) => components[name] + }) + const ctx = vue.reactive({ + visible: true, + currentDetailId: 'A', + canViewQuotaCert: false, + canViewProof: true, + detailActiveTab: 'proof', + detailContract: { id: 'A' } + }) + const renderer = vue.createRenderer({ + createElement: () => ({ children: [] }), + createText: () => ({}), + createComment: () => ({}), + insert(node, parent, anchor) { + node.parent = parent + const old = parent.children.indexOf(node) + if (old >= 0) parent.children.splice(old, 1) + const i = parent.children.indexOf(anchor) + parent.children.splice(i < 0 ? parent.children.length : i, 0, node) + }, + remove(node) { + const list = node.parent?.children + if (list) list.splice(list.indexOf(node), 1) + }, + setText() {}, + setElementText() {}, + patchProp() {}, + parentNode: (node) => node.parent, + nextSibling(node) { + return node.parent?.children[node.parent.children.indexOf(node) + 1] + } + }) + const app = renderer.createApp({ setup: () => () => mountedRender(ctx, []) }) + app.mount({ children: [] }) + assert.deepEqual(lifecycle, ['mount:proof:A']) + ctx.currentDetailId = 'B' + await vue.nextTick() + assert.deepEqual(lifecycle, ['mount:proof:A', 'unmount:proof:A']) + ctx.detailContract = { id: 'B' } + await vue.nextTick() + assert.equal(lifecycle.at(-1), 'mount:proof:B') + ctx.detailActiveTab = 'contract' + await vue.nextTick() + assert.equal(lifecycle.at(-1), 'unmount:proof:B') + assert.equal( + lifecycle.some((entry) => entry.includes('quota')), + false + ) + app.unmount() +}) + +test('copy action retains the loaded contract after hiding the detail dialog', async () => { + const h = harness(), + s = h.state + const opened = s.open('copy') + h.contracts[0].resolve({ data: { id: 'copy' } }) + await opened + s.handleDetailCopy() + assert.equal(s.formDialogCopySource.value?.id, 'copy') + h.stop() +}) + +test('session change closes loading detail and old finally cannot revive it', async () => { + const h = harness(), + s = h.state + const pending = s.open('old') + assert.equal(s.detailLoading.value, true) + h.user.getUserInfo.tenantNo = 'another' + assert.equal(s.visible.value, false) + assert.equal(s.detailLoading.value, false) + h.contracts[0].resolve({ data: { id: 'old' } }) + await pending + assert.equal(s.detailContract.value, undefined) + h.stop() +}) diff --git a/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue b/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue index 8f8825cdf..9f96001f8 100644 --- a/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue +++ b/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue @@ -513,7 +513,7 @@ + + + +
@@ -992,6 +1002,7 @@ import { ref, reactive, computed, onMounted, defineAsyncComponent, nextTick, watch } from 'vue'; import { useRouter } from 'vue-router'; import { fetchGetMenuCodes } from '@/api/auth' +import { useUserStore } from '@/store/modules/user' import { isFrameworkContractType, isContractEditableStatus, getContractReeditConfirmMessage } from '../contractType' import { displayFrameworkMoney, @@ -1056,6 +1067,7 @@ 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 StopDocuments from '@/views/pl/legal-use-document/StopDocuments.vue'; import PlContractLinkedPlansPanel from '@/components/business/pl-contract-linked-plans-panel/index.vue'; import { parseSettlementClauseDrafts, @@ -1097,7 +1109,9 @@ import { const dictStore = useDictStore(); const router = useRouter(); +const user = useUserStore() const canViewQuotaCert = ref(false) +const canViewProof = ref(false) const props = withDefaults( defineProps<{ @@ -1116,6 +1130,43 @@ const detailLoading = ref(false); const detailActiveTab = ref('contract'); const currentDetailId = ref(); const detailContract = ref(); +// 权限会话与详情请求各自计数,互不取消初始化或刷新。 +let permissionEpoch = 0 +let detailEpoch = 0 +const sessionScope = computed(() => JSON.stringify([ + user.accessToken, + user.getUserInfo +])) +watch([visible, sessionScope], async ([isVisible]) => { + const epoch = ++permissionEpoch + canViewQuotaCert.value = false + canViewProof.value = false + if (['quota', 'proof'].includes(detailActiveTab.value)) detailActiveTab.value = 'contract' + if (!isVisible || !user.accessToken) return + try { + const perms = await fetchGetMenuCodes() + if (epoch !== permissionEpoch) return + const all = !!perms.all || !!perms.isSuperAdmin + canViewQuotaCert.value = all || (perms.menuCodes || []).includes('pl:quota-cert:list') + canViewProof.value = all || (perms.menuCodes || []).includes('pl:legal-use-document:list') + } catch { + // 失败时保持无权限;下次打开重新加载。 + } +}, { flush: 'sync' }) +watch([visible, currentDetailId, sessionScope], ([isVisible]) => { + ++detailEpoch + detailLoading.value = false + // 关闭后的复制/编辑动作仍需读取本次合同;重新打开或切换范围时清空。 + if (isVisible) detailContract.value = undefined +}, { flush: 'sync' }) +watch(sessionScope, () => { + visible.value = false + handleDetailDrawerClose() +}, { flush: 'sync' }) +onBeforeUnmount(() => { + ++permissionEpoch + ++detailEpoch +}) /** 供方 / 需方信息展开状态(默认缩略,两侧共用) */ const partiesExpanded = ref(false); const detailPaymentList = ref([]); @@ -1275,17 +1326,22 @@ function getContractPdfCanvasScale() { const loadDetailData = async () => { if (!currentDetailId.value) return; + const epoch = ++detailEpoch + const isCurrent = () => epoch === detailEpoch && visible.value detailLoading.value = true; try { const res = await getContractDetail(String(currentDetailId.value)); + if (!isCurrent()) return detailContract.value = res.data; await enrichContractProductAliasDisplay(detailContract.value?.products || []) + if (!isCurrent()) return // 加载关联商机名称 if (detailContract.value?.opportunityId) { try { const oppRes = await getOpportunity(detailContract.value.opportunityId); + if (!isCurrent()) return if (oppRes?.data) { detailContract.value.opportunityName = oppRes.data.opportunityName; } @@ -1298,6 +1354,7 @@ const loadDetailData = async () => { if (detailContract.value?.quoteId) { try { const quoteRes = await getQuote(detailContract.value.quoteId); + if (!isCurrent()) return if (quoteRes?.data) { detailContract.value.quoteNo = quoteRes.data.quoteNo; } @@ -1306,13 +1363,14 @@ const loadDetailData = async () => { } } + if (!isCurrent()) return // 加载审批记录 loadDetailApprovalRecords(); // 异步加载附件信息 loadDetailContractAttachments(); } finally { - detailLoading.value = false; + if (isCurrent()) detailLoading.value = false; } }; @@ -3288,8 +3346,6 @@ const handleGeneratePdfFromDialog = async () => { onMounted(async () => { await dictStore.loadAllDict(); - const perms = await fetchGetMenuCodes() - canViewQuotaCert.value = !!perms.all || !!perms.isSuperAdmin || (perms.menuCodes || []).includes('pl:quota-cert:list') }); function formatPlanQty(value: unknown) {