From 52268859aa446ee27d855aa26f95578842dabc9f Mon Sep 17 00:00:00 2001 From: luo Date: Mon, 12 Oct 2026 11:43:12 +0800 Subject: [PATCH] fix: some --- scripts/tests/assetBindingCardRender.test.mjs | 288 ++++++++ scripts/tests/assetInfoState.test.mjs | 686 ++++++++++++++++++ src/types/components.d.ts | 4 + .../components/BasicInfoCard.vue | 38 +- .../composables/useAssetFormatters.ts | 15 + .../composables/useAssetInfo.ts | 184 ++++- .../asset-information/index.vue | 61 +- .../asset-management/device-list/index.vue | 8 + .../iot-card-management/index.vue | 8 + 9 files changed, 1216 insertions(+), 76 deletions(-) create mode 100644 scripts/tests/assetBindingCardRender.test.mjs create mode 100644 scripts/tests/assetInfoState.test.mjs diff --git a/scripts/tests/assetBindingCardRender.test.mjs b/scripts/tests/assetBindingCardRender.test.mjs new file mode 100644 index 0000000..3b2e3f4 --- /dev/null +++ b/scripts/tests/assetBindingCardRender.test.mjs @@ -0,0 +1,288 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import * as vue from 'vue' +import { compileScript, parse } from '@vue/compiler-sfc' + +function evaluate(source, imports) { + const compiled = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } + }).outputText + const module = { exports: {} } + new Function('require', 'module', 'exports', compiled)( + (name) => { + assert.ok(imports[name], `Unexpected runtime dependency: ${name}`) + return imports[name] + }, + module, + module.exports + ) + return module.exports +} + +const basePath = 'src/views/asset-management/asset-information' +const { descriptor } = parse(readFileSync(`${basePath}/components/BasicInfoCard.vue`, 'utf8')) +const componentSource = compileScript(descriptor, { + id: 'binding-card-test', + inlineTemplate: true +}).content +const formatters = evaluate(readFileSync(`${basePath}/composables/useAssetFormatters.ts`, 'utf8'), { + '@/config/constants/carrierTypes': { + getCarrierTypeLabel: (code) => ({ CTCC: '中国电信', CMCC: '中国移动' })[code] || code + } +}) + +function createHostNode(tag, text = '') { + return { + tag, + text, + props: {}, + children: [], + parentNode: null, + removeChild(child) { + const index = this.children.indexOf(child) + assert.notEqual(index, -1) + this.children.splice(index, 1) + child.parentNode = null + } + } +} + +function mountBasicInfo(t, userType, permissions = []) { + const user = vue.reactive({ info: { id: userType, user_type: userType }, permissions }) + const hasAuth = (permission) => + Number(user.info.user_type) === 1 || user.permissions.includes(permission) + user.hasPermission = hasAuth + user.hasButton = hasAuth + const elements = Object.fromEntries( + [ + 'ElCard', + 'ElDescriptions', + 'ElDescriptionsItem', + 'ElTag', + 'ElTooltip', + 'ElSwitch', + 'ElDivider', + 'ElButton', + 'ElAlert', + 'ElEmpty' + ].map((name) => [ + name, + vue.defineComponent({ + name, + setup: + (_, { slots, attrs }) => + () => + vue.h(name, attrs, [slots.header?.(), slots.default?.()]) + }) + ]) + ) + const component = evaluate(componentSource, { + vue, + '@vueuse/core': { useWindowSize: () => ({ width: vue.ref(1200) }) }, + 'element-plus': { ...elements, ElMessageBox: {}, ElMessage: {} }, + '@element-plus/icons-vue': { + CopyDocument: vue.defineComponent(() => () => vue.h('copy-icon')) + }, + '../composables/useAssetFormatters': formatters, + '@/composables/useAuth': { useAuth: () => ({ hasAuth }) }, + '@/store/modules/user': { useUserStore: () => user }, + '@/config/constants': { + JULY_PERMISSIONS: { speedTier: { view: 'speed:view', update: 'speed:update' } } + }, + '@/utils/business/format': { formatDateTime: (value) => String(value || '') }, + '@/utils/business/expiryEstimate': { + getExpiryEstimateText: () => '-', + getExpiryEstimateTooltip: () => '', + getExpiryEstimateClass: () => '' + }, + '@/config/constants/audit': { AUDIT_PERMISSIONS: {} }, + '@/utils/business/auditNavigation': { resolveAuditResourceTarget: () => null }, + '@/components/business/audit/investigationController': { openAuditInvestigation: () => {} } + }).default + const renderer = vue.createRenderer({ + createElement: (tag) => createHostNode(tag), + createText: (text) => createHostNode('#text', text), + createComment: (text) => createHostNode('#comment', text), + setText: (node, text) => { + node.text = text + }, + setElementText: (node, text) => { + node.text = text + node.children = [] + }, + parentNode: (node) => node.parentNode, + nextSibling: (node) => { + const siblings = node.parentNode?.children || [] + return siblings[siblings.indexOf(node) + 1] || null + }, + patchProp: (node, key, _, value) => { + node.props[key] = value + }, + insert: (node, parent, anchor) => { + node.parentNode?.removeChild(node) + const index = anchor ? parent.children.indexOf(anchor) : parent.children.length + assert.notEqual(index, -1, 'Vue tried to insert beside a node removed outside its renderer') + parent.children.splice(index, 0, node) + node.parentNode = parent + }, + remove: (node) => node.parentNode?.removeChild(node) + }) + const asset = vue.reactive({ + asset_type: 'device', + asset_id: 101, + identifier: 'DEVICE-A', + cards: [ + { + iccid: 'CARD-1', + msisdn: '13800000001', + carrier_name: '平台运营商账户', + carrier_type: 'CTCC', + slot_position: 1, + is_current: true, + network_status: 1 + }, + { + iccid: 'CARD-2', + msisdn: '13800000002', + carrier_name: '第二运营商账户', + carrier_type: 'CMCC', + slot_position: 2, + is_current: false, + network_status: 0 + } + ] + }) + const root = createHostNode('root') + const app = renderer.createApp({ + render: () => vue.h(component, { cardInfo: asset, deviceRealtime: null, pollingEnabled: false }) + }) + const directives = evaluate(readFileSync('src/directives/permission.ts', 'utf8'), { + '@/store/modules/user': { useUserStore: () => user } + }) + directives.setupPermissionDirective(app) + app.directive('loading', {}) + app.component( + 'el-icon', + vue.defineComponent({ + setup: + (_, { slots }) => + () => + vue.h('icon', slots.default?.()) + }) + ) + app.mount(root) + t.after(() => app.unmount()) + return { root, asset, user } +} + +const textOf = (node) => + node.tag === '#comment' ? '' : node.text + node.children.map(textOf).join('') +const nodesWithClass = (node, className) => [ + ...(String(node.props.class || '') + .split(' ') + .includes(className) + ? [node] + : []), + ...node.children.flatMap((child) => nodesWithClass(child, className)) +] + +test('all account types render both binding cards; only the established carrier label rule differs', async (t) => { + for (const userType of [1, 2, 3, 4]) { + const { root } = mountBasicInfo(t, userType) + await vue.nextTick() + const cards = nodesWithClass(root, 'binding-card') + assert.equal(cards.length, 2) + assert.match(textOf(cards[0]), /CARD-1/) + assert.match(textOf(cards[1]), /CARD-2/) + assert.match(textOf(cards[0]), /13800000001/) + assert.match(textOf(cards[0]), userType < 3 ? /平台运营商账户/ : /中国电信/) + } +}) + +test('permission changes and current-card/status updates preserve card data without external DOM removal', async (t) => { + const { root, asset, user } = mountBasicInfo(t, 3, ['iot_info:stop']) + await vue.nextTick() + assert.match(textOf(nodesWithClass(root, 'binding-card-actions')[0]), /停用此卡/) + user.permissions = ['iot_info:start', 'bound_card:update_realname_status'] + asset.cards[0].network_status = 0 + asset.cards[0].is_current = false + asset.cards[1].is_current = true + await vue.nextTick() + const cards = nodesWithClass(root, 'binding-card') + assert.equal(cards.length, 2) + assert.match(textOf(cards[0]), /CARD-2/) + assert.match(textOf(cards[1]), /CARD-1/) + assert.match(textOf(nodesWithClass(cards[1], 'binding-card-actions')[0]), /启用此卡/) + assert.match(textOf(nodesWithClass(cards[1], 'binding-card-actions')[0]), /更新实名状态/) + assert.doesNotMatch(textOf(nodesWithClass(cards[1], 'binding-card-actions')[0]), /停用此卡/) + user.permissions = [] + await vue.nextTick() + assert.equal(nodesWithClass(root, 'binding-card').length, 2) + for (const actions of nodesWithClass(root, 'binding-card-actions')) + assert.equal(textOf(actions).trim(), '') +}) + +test('missing network status renders unknown and exposes neither start nor stop', async (t) => { + const { root, asset } = mountBasicInfo(t, 3, ['iot_info:start', 'iot_info:stop']) + delete asset.cards[0].network_status + await vue.nextTick() + const card = nodesWithClass(root, 'binding-card')[0] + assert.match(textOf(card), /网络状态未知/) + assert.doesNotMatch(textOf(nodesWithClass(card, 'binding-card-actions')[0]), /启用此卡|停用此卡/) +}) + +test('read-only accounts render realtime start/stop transitions without losing binding-card nodes', async (t) => { + for (const userType of [2, 3, 4]) { + const { root, asset } = mountBasicInfo(t, userType) + asset.cards[0].network_status = 0 + await vue.nextTick() + assert.equal(nodesWithClass(root, 'binding-card').length, 2) + assert.match(textOf(nodesWithClass(root, 'binding-card')[0]), /网络状态停机/) + asset.cards[0].network_status = 1 + await vue.nextTick() + assert.match(textOf(nodesWithClass(root, 'binding-card')[0]), /网络状态正常/) + for (const actions of nodesWithClass(root, 'binding-card-actions')) { + assert.equal(textOf(actions).trim(), '') + } + } +}) + +test('current-first sorting does not mutate source cards; zero, one, and multiple bindings render stably', async (t) => { + const { root, asset } = mountBasicInfo(t, 2) + const originalOrder = asset.cards.map((card) => card.iccid) + asset.cards[0].is_current = false + asset.cards[1].is_current = true + await vue.nextTick() + assert.deepEqual( + asset.cards.map((card) => card.iccid), + originalOrder + ) + assert.match(textOf(nodesWithClass(root, 'binding-card')[0]), /CARD-2/) + asset.cards = [asset.cards[1]] + await vue.nextTick() + assert.equal(nodesWithClass(root, 'binding-card').length, 1) + asset.cards = [] + await vue.nextTick() + assert.equal(nodesWithClass(root, 'binding-card').length, 0) +}) + +test('device/card type switches and denied actions do not leave removed DOM anchors', async (t) => { + const { root, asset, user } = mountBasicInfo(t, 3) + asset.asset_type = 'card' + asset.iccid = 'CARD-1' + asset.network_status = 1 + await vue.nextTick() + asset.network_status = 0 + await vue.nextTick() + assert.equal(textOf(nodesWithClass(root, 'card-operations')[0]).trim(), '') + user.permissions = ['iot_info:start'] + await vue.nextTick() + assert.match(textOf(nodesWithClass(root, 'card-operations')[0]), /启用此卡/) + asset.asset_type = 'device' + user.permissions = ['device_info:reboot'] + await vue.nextTick() + assert.equal(nodesWithClass(root, 'binding-card').length, 2) + assert.match(textOf(nodesWithClass(root, 'device-operations')[0]), /重启设备/) +}) diff --git a/scripts/tests/assetInfoState.test.mjs b/scripts/tests/assetInfoState.test.mjs new file mode 100644 index 0000000..e1f3410 --- /dev/null +++ b/scripts/tests/assetInfoState.test.mjs @@ -0,0 +1,686 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import * as vue from 'vue' +import { parse } from '@vue/compiler-sfc' + +const sourcePath = 'src/views/asset-management/asset-information/composables/useAssetInfo.ts' +const compiledSource = ts.transpileModule(readFileSync(sourcePath, 'utf8'), { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } +}).outputText + +const response = (data) => ({ code: 0, data }) +const deferred = () => { + let resolve, reject + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +const boundCards = () => [ + { + card_id: 11, + iccid: 'CARD-1', + msisdn: '13800000001', + carrier_name: '运营商账户', + carrier_type: 'CTCC', + slot_position: 1, + is_current: true, + network_status: 1, + real_name_status: 1, + real_name_at: '2026-10-01T01:00:00Z', + realname_policy: 'before_order', + gateway_extend: 'old reason' + }, + { + card_id: 12, + iccid: 'CARD-2', + msisdn: '13800000002', + carrier_name: '另一运营商账户', + carrier_type: 'CMCC', + slot_position: 2, + is_current: false, + network_status: 0, + real_name_status: 0, + real_name_at: null + } +] + +const detail = (identifier = 'DEVICE-A', cards = boundCards()) => ({ + asset_type: 'device', + asset_id: identifier === 'DEVICE-A' ? 101 : 202, + virtual_no: identifier, + bound_card_count: cards.length, + cards, + real_total_mb: 1024, + real_used_mb: 100 +}) + +function harness(t, overrides = {}) { + const user = vue.reactive({ info: { id: 1, user_type: 1 }, isLogin: true }) + const messages = [] + const api = { + resolveAsset: async (identifier) => response(detail(identifier)), + getRealtimeStatus: async () => response(null), + getCurrentPackage: async () => response(null), + getAssetWallet: async () => response(null), + getAssetPackages: async () => response({ items: [], total: 0 }), + refreshAsset: async () => response(null), + ...overrides + } + const imports = { + vue, + 'element-plus': { + ElMessage: Object.fromEntries( + ['error', 'success', 'warning'].map((type) => [ + type, + (message) => messages.push({ type, message }) + ]) + ) + }, + '@/api/modules': { AssetService: api }, + '@/utils/business/apiRateLimit': { FrontendRateLimitError: class extends Error {} }, + '@/store/modules/user': { useUserStore: () => user } + } + const module = { exports: {} } + new Function('require', 'module', 'exports', compiledSource)( + (name) => { + assert.ok(imports[name], `Unexpected runtime dependency: ${name}`) + return imports[name] + }, + module, + module.exports + ) + const scope = vue.effectScope() + const state = scope.run(() => module.exports.useAssetInfo()) + t.after(() => scope.stop()) + return { state, api, user, messages, scope } +} + +test('partial realtime responses retain the complete resolved binding-card roster', async (t) => { + const { state } = harness(t, { + getRealtimeStatus: async () => + response({ + asset_type: 'device', + asset_id: 101, + cards: [{ iccid: 'CARD-1', network_status: 0 }] + }) + }) + await state.fetchAssetDetail('DEVICE-A') + assert.deepEqual( + state.cardInfo.value.cards.map((card) => card.iccid), + ['CARD-1', 'CARD-2'] + ) + assert.equal(state.cardInfo.value.cards[0].network_status, 0) + assert.equal(state.cardInfo.value.cards[1].msisdn, '13800000002') +}) + +test('realtime fields cannot erase resolved card identity, carrier, slot, policy, or real-name time', async (t) => { + const { state } = harness(t, { + getRealtimeStatus: async () => + response({ + asset_type: 'device', + asset_id: 101, + cards: [ + { + iccid: 'CARD-1', + card_id: 0, + msisdn: '', + carrier_name: '', + carrier_type: '', + slot_position: 0, + realname_policy: '', + real_name_at: 'untrusted realtime time', + network_status: 0, + real_name_status: 0, + is_current: false, + gateway_extend: '' + } + ] + }) + }) + await state.fetchAssetDetail('DEVICE-A') + assert.deepEqual(state.cardInfo.value.cards[0], { + ...boundCards()[0], + network_status: 0, + real_name_status: 0, + is_current: false, + gateway_extend: '' + }) +}) + +test('empty or omitted realtime cards preserve the detail roster; detail reload can remove bindings', async (t) => { + const { state, api } = harness(t) + await state.fetchAssetDetail('DEVICE-A') + for (const cards of [[], undefined, null]) { + api.getRealtimeStatus = async () => response({ asset_type: 'device', asset_id: 101, cards }) + await state.loadRealtimeStatus('DEVICE-A', 'device') + assert.equal(state.cardInfo.value.cards.length, 2) + } + api.resolveAsset = async () => response(detail('DEVICE-A', [])) + await state.fetchAssetDetail('DEVICE-A') + assert.deepEqual(state.cardInfo.value.cards, []) +}) + +test('unknown realtime cards do not introduce bindings absent from the authorized detail response', async (t) => { + const { state } = harness(t, { + getRealtimeStatus: async () => + response({ asset_type: 'device', asset_id: 101, cards: [{ iccid: 'UNRESOLVED-CARD' }] }) + }) + await state.fetchAssetDetail('DEVICE-A') + assert.deepEqual( + state.cardInfo.value.cards.map((card) => card.iccid), + ['CARD-1', 'CARD-2'] + ) +}) + +test('realtime card order does not change the resolved roster; explicit nullable timestamps can clear', async (t) => { + const cards = boundCards() + cards[0].last_data_check_at = 'old timestamp' + const { state } = harness(t, { + resolveAsset: async () => response(detail('DEVICE-A', cards)), + getRealtimeStatus: async () => + response({ + asset_type: 'device', + asset_id: 101, + cards: [ + { iccid: 'CARD-2' }, + { iccid: 'CARD-1', last_data_check_at: null, real_name_status: null } + ] + }) + }) + await state.fetchAssetDetail('DEVICE-A') + assert.deepEqual( + state.cardInfo.value.cards.map((card) => card.iccid), + ['CARD-1', 'CARD-2'] + ) + assert.equal(state.cardInfo.value.cards[0].last_data_check_at, null) + assert.equal(state.cardInfo.value.cards[0].real_name_status, 1) +}) + +test('a late resolve response cannot replace a newer search', async (t) => { + const oldDetail = deferred() + const { state } = harness(t, { + resolveAsset: (identifier) => + identifier === 'DEVICE-A' ? oldDetail.promise : Promise.resolve(response(detail(identifier))) + }) + const oldSearch = state.fetchAssetDetail('DEVICE-A') + await state.fetchAssetDetail('DEVICE-B') + oldDetail.resolve(response(detail('DEVICE-A'))) + await oldSearch + assert.equal(state.cardInfo.value.identifier, 'DEVICE-B') +}) + +test('late realtime responses cannot write old card data into a different asset', async (t) => { + const { state, api } = harness(t) + await state.fetchAssetDetail('DEVICE-A') + const oldRealtime = deferred() + api.getRealtimeStatus = () => oldRealtime.promise + const oldLoad = state.loadRealtimeStatus('DEVICE-A', 'device') + api.getRealtimeStatus = async () => response(null) + await state.fetchAssetDetail('DEVICE-B') + oldRealtime.resolve( + response({ asset_type: 'device', asset_id: 101, cards: [{ iccid: 'OLD-CARD' }] }) + ) + await oldLoad + assert.equal(state.cardInfo.value.identifier, 'DEVICE-B') + assert.deepEqual( + state.cardInfo.value.cards.map((card) => card.iccid), + ['CARD-1', 'CARD-2'] + ) +}) + +test('the newest same-asset realtime request wins and stale completion cannot end its loading state', async (t) => { + const { state, api } = harness(t) + await state.fetchAssetDetail('DEVICE-A') + const older = deferred() + const newer = deferred() + api.getRealtimeStatus = () => older.promise + const oldLoad = state.loadRealtimeStatus('DEVICE-A', 'device') + api.getRealtimeStatus = () => newer.promise + const newLoad = state.loadRealtimeStatus('DEVICE-A', 'device') + older.resolve( + response({ + asset_type: 'device', + asset_id: 101, + cards: [{ iccid: 'CARD-1', network_status: 0 }] + }) + ) + await oldLoad + assert.equal(state.realtimeLoading.value, true) + assert.equal(state.cardInfo.value.cards[0].network_status, 1) + newer.resolve( + response({ + asset_type: 'device', + asset_id: 101, + cards: [{ iccid: 'CARD-1', network_status: 1 }] + }) + ) + await newLoad + assert.equal(state.realtimeLoading.value, false) + assert.equal(state.cardInfo.value.cards[0].network_status, 1) +}) + +test('realtime responses identifying a different asset are ignored', async (t) => { + const { state, api } = harness(t) + await state.fetchAssetDetail('DEVICE-A') + for (const identity of [ + { asset_type: 'device', asset_id: 202 }, + { asset_type: 'card', asset_id: 101 } + ]) { + api.getRealtimeStatus = async () => + response({ ...identity, cards: [{ iccid: 'CARD-1', network_status: 0 }] }) + await state.loadRealtimeStatus('DEVICE-A', 'device') + assert.equal(state.cardInfo.value.cards[0].network_status, 1) + } +}) + +test('starting a new search clears previous device, package, wallet, and binding data immediately', async (t) => { + const { state, api } = harness(t) + await state.fetchAssetDetail('DEVICE-A') + state.deviceRealtime.value = { current_iccid: 'CARD-1' } + state.currentPackage.value = { package_name: 'old package' } + state.packageList.value = [{ id: 1 }] + state.walletInfo.value = { total_balance: 123 } + const newDetail = deferred() + api.resolveAsset = () => newDetail.promise + const nextSearch = state.fetchAssetDetail('DEVICE-B') + assert.equal(state.cardInfo.value, null) + assert.equal(state.deviceRealtime.value, null) + assert.equal(state.currentPackage.value, null) + assert.equal(state.walletInfo.value, null) + assert.deepEqual(state.packageList.value, []) + newDetail.resolve({ code: 403, msg: 'forbidden' }) + await nextSearch + assert.equal(state.cardInfo.value, null) +}) + +test('changing account invalidates pending requests even when searching the same identifier', async (t) => { + const oldDetail = deferred() + const { state, user, api } = harness(t, { resolveAsset: () => oldDetail.promise }) + const oldSearch = state.fetchAssetDetail('DEVICE-A') + user.info = { id: 2, user_type: 3 } + api.resolveAsset = async () => response(detail('DEVICE-A', [boundCards()[1]])) + await state.fetchAssetDetail('DEVICE-A') + oldDetail.resolve(response(detail('DEVICE-A'))) + await oldSearch + assert.deepEqual( + state.cardInfo.value.cards.map((card) => card.iccid), + ['CARD-2'] + ) +}) + +test('logout clears visible data and invalidates pending realtime responses', async (t) => { + const { state, user, api } = harness(t) + await state.fetchAssetDetail('DEVICE-A') + const pending = deferred() + api.getRealtimeStatus = () => pending.promise + const load = state.loadRealtimeStatus('DEVICE-A', 'device') + user.isLogin = false + assert.equal(state.cardInfo.value, null) + pending.resolve(response({ asset_type: 'device', asset_id: 101, cards: boundCards() })) + await load + assert.equal(state.cardInfo.value, null) +}) + +test('a late failure of an old search does not clear the current successful asset', async (t) => { + const oldDetail = deferred() + const { state } = harness(t, { + resolveAsset: (identifier) => + identifier === 'DEVICE-A' ? oldDetail.promise : Promise.resolve(response(detail(identifier))) + }) + const oldSearch = state.fetchAssetDetail('DEVICE-A') + await state.fetchAssetDetail('DEVICE-B') + oldDetail.reject(new Error('old request failed')) + await oldSearch + assert.equal(state.cardInfo.value?.identifier, 'DEVICE-B') +}) + +test('account types 1/2/3/4 retain the same resolved bindings despite sparse realtime fields', async (t) => { + for (const userType of [1, 2, 3, 4]) { + const { state, user } = harness(t, { + getRealtimeStatus: async () => + response({ + asset_type: 'device', + asset_id: 101, + cards: [{ iccid: 'CARD-1', carrier_name: '' }] + }) + }) + user.info = { id: userType, user_type: userType } + await state.fetchAssetDetail('DEVICE-A') + assert.deepEqual(state.cardInfo.value.cards, boundCards()) + } +}) + +test('a current-card change clears the previous current flag even for a partial realtime list', async (t) => { + const { state } = harness(t, { + getRealtimeStatus: async () => + response({ + asset_type: 'device', + asset_id: 101, + cards: [{ iccid: 'CARD-2', is_current: true }] + }) + }) + await state.fetchAssetDetail('DEVICE-A') + assert.deepEqual( + state.cardInfo.value.cards.map((card) => card.is_current), + [false, true] + ) +}) + +test('same-identifier re-search never takes a real-name timestamp from a previous search or realtime', async (t) => { + const { state, api } = harness(t) + await state.fetchAssetDetail('DEVICE-A') + api.resolveAsset = async () => + response(detail('DEVICE-A', [{ ...boundCards()[0], real_name_at: null }])) + api.getRealtimeStatus = async () => + response({ + asset_type: 'device', + asset_id: 101, + cards: [{ iccid: 'CARD-1', real_name_at: 'stale time' }] + }) + await state.fetchAssetDetail('DEVICE-A') + assert.equal(state.cardInfo.value.cards[0].real_name_at, null) +}) + +test('manual refresh resolves new binding relations and preserves the existing package page', async (t) => { + let resolveCalls = 0 + const { state } = harness(t, { + resolveAsset: async () => response(detail('DEVICE-A', ++resolveCalls === 1 ? boundCards() : [])) + }) + await state.fetchAssetDetail('DEVICE-A') + state.packageList.value = [{ id: 88, children: [{ id: 89 }] }] + assert.equal(await state.refreshAsset('DEVICE-A', 'device'), true) + assert.equal(resolveCalls, 2) + assert.deepEqual(state.cardInfo.value.cards, []) + assert.deepEqual(state.packageList.value, [{ id: 88, children: [{ id: 89 }] }]) +}) + +test('a pending refresh does not re-search or overwrite a newer asset', async (t) => { + const pending = deferred() + const calls = [] + const { state } = harness(t, { + refreshAsset: () => pending.promise, + resolveAsset: async (identifier) => { + calls.push(identifier) + return response(detail(identifier)) + } + }) + await state.fetchAssetDetail('DEVICE-A') + const refresh = state.refreshAsset('DEVICE-A', 'device') + await state.fetchAssetDetail('DEVICE-B') + pending.resolve(response(null)) + assert.equal(await refresh, false) + assert.equal(state.cardInfo.value.identifier, 'DEVICE-B') + assert.deepEqual(calls, ['DEVICE-A', 'DEVICE-B']) +}) + +test('late current-package, wallet, and package-list responses do not contaminate a new asset', async (t) => { + const { state, api } = harness(t) + await state.fetchAssetDetail('DEVICE-A') + const pending = { package: deferred(), wallet: deferred(), list: deferred() } + api.getCurrentPackage = () => pending.package.promise + api.getAssetWallet = () => pending.wallet.promise + api.getAssetPackages = () => pending.list.promise + const oldLoads = [ + state.loadCurrentPackage('DEVICE-A'), + state.loadAssetWallet('DEVICE-A'), + state.loadPackageList('DEVICE-A') + ] + api.getCurrentPackage = async () => response(null) + api.getAssetWallet = async () => response(null) + await state.fetchAssetDetail('DEVICE-B') + pending.package.resolve(response({ package_name: 'old package' })) + pending.wallet.resolve(response({ total_balance: 123 })) + pending.list.resolve(response({ items: [{ id: 1 }], total: 1 })) + const results = await Promise.all(oldLoads) + assert.equal(state.currentPackage.value, null) + assert.equal(state.walletInfo.value, null) + assert.deepEqual(state.packageList.value, []) + assert.equal(results[2], null) +}) + +test('a newer same-asset package page wins even if the older page returns last', async (t) => { + const { state, api } = harness(t) + await state.fetchAssetDetail('DEVICE-A') + const older = deferred() + api.getAssetPackages = () => older.promise + const firstPage = state.loadPackageList('DEVICE-A', 1) + api.getAssetPackages = async () => + response({ items: [{ id: 2, children: [{ id: 3 }] }], total: 9, page: 2 }) + await state.loadPackageList('DEVICE-A', 2) + older.resolve(response({ items: [{ id: 1 }], total: 1, page: 1 })) + assert.equal(await firstPage, null) + assert.deepEqual(state.packageList.value, [{ id: 2, children: [{ id: 3 }] }]) +}) + +test('unmount invalidates outstanding resolve requests', async (t) => { + const pending = deferred() + const { state, scope } = harness(t, { resolveAsset: () => pending.promise }) + const search = state.fetchAssetDetail('DEVICE-A') + scope.stop() + pending.resolve(response(detail('DEVICE-A'))) + assert.equal(await search, null) + assert.equal(state.cardInfo.value, null) +}) + +test('failed realtime loading preserves resolved bindings and a sparse identified request can update status', async (t) => { + t.mock.method(console, 'error', () => {}) + const { state, api } = harness(t, { + getRealtimeStatus: async () => { + throw new Error('realtime unavailable') + } + }) + await state.fetchAssetDetail('DEVICE-A') + assert.deepEqual(state.cardInfo.value.cards, boundCards()) + assert.equal(state.realtimeLoading.value, false) + api.getRealtimeStatus = async () => response({ cards: [{ iccid: 'CARD-1', network_status: 0 }] }) + await state.loadRealtimeStatus('DEVICE-A', 'device') + assert.equal(state.cardInfo.value.cards[0].network_status, 0) + assert.equal(state.cardInfo.value.cards.length, 2) +}) + +test('updating user metadata without changing the account or data scope does not clear asset data', async (t) => { + const { state, user } = harness(t) + await state.fetchAssetDetail('DEVICE-A') + const asset = state.cardInfo.value + user.info = { ...user.info, username: 'updated display name' } + await vue.nextTick() + assert.equal(state.cardInfo.value, asset) +}) + +test('a disposed asset composable cannot restart loads after an operation finishes late', async (t) => { + let calls = 0 + const { state, scope } = harness(t, { + resolveAsset: async () => { + calls += 1 + return response(detail('DEVICE-A')) + } + }) + scope.stop() + assert.equal(await state.fetchAssetDetail('DEVICE-A'), null) + assert.equal(calls, 0) +}) + +test('standalone card realtime status still updates while the resolved gateway IMEI remains authoritative', async (t) => { + const { state } = harness(t, { + resolveAsset: async () => + response({ + ...detail('CARD-1', []), + asset_type: 'card', + iccid: 'CARD-1', + gateway_card_imei: 'resolved-imei' + }), + getRealtimeStatus: async () => + response({ + asset_type: 'card', + asset_id: 202, + network_status: 0, + real_name_status: 1, + gateway_card_imei: 'realtime-imei' + }) + }) + await state.fetchAssetDetail('CARD-1') + assert.equal(state.cardInfo.value.network_status, 0) + assert.equal(state.cardInfo.value.real_name_status, 1) + assert.equal(state.cardInfo.value.gateway_card_imei, 'resolved-imei') +}) + +function pageHandlers(state) { + const { descriptor } = parse( + readFileSync('src/views/asset-management/asset-information/index.vue', 'utf8') + ) + const source = ts.createSourceFile( + 'asset-information.ts', + descriptor.scriptSetup.content, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ) + const wanted = new Set([ + 'handleSearch', + 'handleRefresh', + 'showUpdateRealnameStatusDialog', + 'handleUpdateRealnameStatusSuccess', + 'handleUpdateBindingCardRealnameStatus', + 'handleBindingCardRealnameStatusSuccess' + ]) + const declarations = [] + function visit(node) { + if (ts.isVariableDeclaration(node) && wanted.has(node.name.getText(source))) + declarations.push(node.getText(source)) + ts.forEachChild(node, visit) + } + visit(source) + assert.equal(declarations.length, wanted.size) + const bindings = { + cardInfo: state.cardInfo, + fetchAssetDetail: state.fetchAssetDetail, + loadPackageList: state.loadPackageList, + refreshAsset: state.refreshAsset, + refreshSubmitting: vue.ref(false), + refreshDisabled: vue.ref(false), + updateRateLimitNow: () => {}, + assetSearchCardRef: vue.ref(null), + startFailureMessage: vue.ref(''), + packagePagination: vue.ref({ page: 1, total: 0, pageSize: 5, status: undefined }), + walletTransactionSearchToken: vue.ref(0), + pollingEnabled: vue.ref(false), + updateRealnameStatusDialogVisible: vue.ref(false), + bindingCardRealnameStatusDialogVisible: vue.ref(false), + bindingCardRealnameStatusIccid: vue.ref(''), + bindingCardRealnameStatusValue: vue.ref(0), + nextTick: vue.nextTick + } + const compiled = ts.transpileModule( + `let searchRequestId = 0, pollingInitializing = false, realnameStatusAsset = null, bindingCardRealnameStatusAsset = null\n${declarations.map((declaration) => `const ${declaration}`).join('\n')}\nreturn { ${[...wanted].join(', ')} }`, + { compilerOptions: { target: ts.ScriptTarget.ES2022 } } + ).outputText + return { + ...new Function(...Object.keys(bindings), compiled)(...Object.values(bindings)), + ...bindings + } +} + +test('page search resolves a device only once and uses its canonical identifier for related requests', async (t) => { + const calls = [] + const { state } = harness(t, { + resolveAsset: async (identifier) => { + calls.push(['resolve', identifier]) + return response(detail('DEVICE-A')) + }, + getRealtimeStatus: async (identifier) => { + calls.push(['realtime', identifier]) + return response(null) + }, + getAssetPackages: async (identifier) => { + calls.push(['packages', identifier]) + return response({ items: [], total: 0 }) + } + }) + await pageHandlers(state).handleSearch({ identifier: 'IMEI-ALIAS' }) + assert.deepEqual(calls, [ + ['resolve', 'IMEI-ALIAS'], + ['realtime', 'DEVICE-A'], + ['packages', 'DEVICE-A'] + ]) +}) + +test('active gateway synchronization is called only by the sync button, never by searches or data reloads', async (t) => { + const syncCalls = [] + const { state } = harness(t, { + refreshAsset: async (identifier) => { + syncCalls.push(identifier) + return response(null) + } + }) + const page = pageHandlers(state) + await page.handleRefresh() + await page.handleSearch({ identifier: 'DEVICE-A' }) + await page.handleSearch({ identifier: 'DEVICE-A' }) + await state.loadRealtimeStatus('DEVICE-A', 'device') + page.handleUpdateBindingCardRealnameStatus({ card: boundCards()[0] }) + await page.handleBindingCardRealnameStatusSuccess() + page.showUpdateRealnameStatusDialog() + await page.handleUpdateRealnameStatusSuccess() + assert.deepEqual(syncCalls, []) + + await page.handleRefresh() + assert.deepEqual(syncCalls, ['DEVICE-A']) + await state.fetchAssetDetail('DEVICE-A') + page.refreshDisabled.value = true + await page.handleRefresh() + assert.deepEqual(syncCalls, ['DEVICE-A']) +}) + +test('a stale page search cannot start additional loads or reset the newer search pagination', async (t) => { + const pending = deferred() + const { state } = harness(t, { + resolveAsset: (identifier) => + identifier === 'DEVICE-A' ? pending.promise : Promise.resolve(response(detail(identifier))), + getAssetPackages: async () => response({ items: [{ id: 2 }], total: 22 }) + }) + const page = pageHandlers(state) + const older = page.handleSearch({ identifier: 'DEVICE-A' }) + await page.handleSearch({ identifier: 'DEVICE-B' }) + pending.resolve(response(detail('DEVICE-A'))) + await older + assert.equal(page.packagePagination.value.total, 22) + assert.equal(page.walletTransactionSearchToken.value, 1) + assert.equal(state.cardInfo.value.identifier, 'DEVICE-B') +}) + +test('successful bound-card real-name update reloads detail fields but an old dialog cannot reload a new asset', async (t) => { + let resolveCalls = 0 + const { state, api } = harness(t, { + resolveAsset: async (identifier) => { + resolveCalls += 1 + return response(detail(identifier)) + } + }) + await state.fetchAssetDetail('DEVICE-A') + const page = pageHandlers(state) + page.handleUpdateBindingCardRealnameStatus({ card: boundCards()[0] }) + api.resolveAsset = async () => { + resolveCalls += 1 + return response( + detail('DEVICE-A', [{ ...boundCards()[0], real_name_status: 0, real_name_at: null }]) + ) + } + await page.handleBindingCardRealnameStatusSuccess() + assert.equal(state.cardInfo.value.cards[0].real_name_status, 0) + assert.equal(state.cardInfo.value.cards[0].real_name_at, null) + assert.equal(resolveCalls, 2) + api.resolveAsset = async (identifier) => { + resolveCalls += 1 + return response(detail(identifier)) + } + page.handleUpdateBindingCardRealnameStatus({ card: boundCards()[0] }) + await state.fetchAssetDetail('DEVICE-B') + await page.handleBindingCardRealnameStatusSuccess() + assert.equal(state.cardInfo.value.identifier, 'DEVICE-B') + assert.equal(resolveCalls, 3) +}) diff --git a/src/types/components.d.ts b/src/types/components.d.ts index 9f219b5..ad2f7d2 100644 --- a/src/types/components.d.ts +++ b/src/types/components.d.ts @@ -95,6 +95,7 @@ declare module 'vue' { ElAlert: typeof import('element-plus/es')['ElAlert'] ElAvatar: typeof import('element-plus/es')['ElAvatar'] ElButton: typeof import('element-plus/es')['ElButton'] + ElCalendar: typeof import('element-plus/es')['ElCalendar'] ElCard: typeof import('element-plus/es')['ElCard'] ElCascader: typeof import('element-plus/es')['ElCascader'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] @@ -124,12 +125,15 @@ declare module 'vue' { ElOption: typeof import('element-plus/es')['ElOption'] ElPagination: typeof import('element-plus/es')['ElPagination'] ElPopover: typeof import('element-plus/es')['ElPopover'] + ElProgress: typeof import('element-plus/es')['ElProgress'] ElRadio: typeof import('element-plus/es')['ElRadio'] + ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElResult: typeof import('element-plus/es')['ElResult'] ElRow: typeof import('element-plus/es')['ElRow'] ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] ElSelect: typeof import('element-plus/es')['ElSelect'] + ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] diff --git a/src/views/asset-management/asset-information/components/BasicInfoCard.vue b/src/views/asset-management/asset-information/components/BasicInfoCard.vue index b12160b..820692a 100644 --- a/src/views/asset-management/asset-information/components/BasicInfoCard.vue +++ b/src/views/asset-management/asset-information/components/BasicInfoCard.vue @@ -453,8 +453,8 @@
网络状态 - - {{ bindingCard.network_status === 1 ? '正常' : '停机' }} + + {{ getNetworkStatusName(bindingCard.network_status) }}
@@ -486,30 +486,28 @@
{{ getBindingStartButtonText(bindingCard) }} {{ getBindingStopButtonText(bindingCard) }} 更新实名状态 @@ -580,8 +578,7 @@ style="margin-top: 16px; text-align: right" > 实名认证策略 @@ -619,28 +614,27 @@
- 设置切卡模式 - + 重启设备 - + 恢复出厂 切换SIM卡 - + 设置WiFi @@ -944,6 +938,8 @@ formatDuration, getRealNameStatusName, getRealNameStatusType, + getNetworkStatusName, + getNetworkStatusType, getRealnamePolicyName, getCarrierTypeName, getAssetStatusName, diff --git a/src/views/asset-management/asset-information/composables/useAssetFormatters.ts b/src/views/asset-management/asset-information/composables/useAssetFormatters.ts index 39b9537..2d6d41c 100644 --- a/src/views/asset-management/asset-information/composables/useAssetFormatters.ts +++ b/src/views/asset-management/asset-information/composables/useAssetFormatters.ts @@ -68,6 +68,19 @@ export function useAssetFormatters() { return map[status] || 'info' } + // 获取网络状态名称和标签类型,缺失状态不能误报为停机。 + const getNetworkStatusName = (status?: number) => { + if (status === 1) return '正常' + if (status === 0) return '停机' + return '未知' + } + + const getNetworkStatusType = (status?: number): TagType => { + if (status === 1) return 'success' + if (status === 0) return 'danger' + return 'info' + } + // 获取实名认证策略名称 const getRealnamePolicyName = (policy?: string) => { if (!policy || policy === '') return '未知' @@ -261,6 +274,8 @@ export function useAssetFormatters() { formatIccidWithDashes, getRealNameStatusName, getRealNameStatusType, + getNetworkStatusName, + getNetworkStatusType, getRealnamePolicyName, getCarrierTypeName, getAssetStatusName, diff --git a/src/views/asset-management/asset-information/composables/useAssetInfo.ts b/src/views/asset-management/asset-information/composables/useAssetInfo.ts index 8363034..8c9585f 100644 --- a/src/views/asset-management/asset-information/composables/useAssetInfo.ts +++ b/src/views/asset-management/asset-information/composables/useAssetInfo.ts @@ -3,9 +3,10 @@ * 负责资产详情数据的加载和状态管理 */ -import { ref } from 'vue' +import { getCurrentScope, onScopeDispose, ref, watch } from 'vue' import { ElMessage } from 'element-plus' import { AssetService } from '@/api/modules' +import { useUserStore } from '@/store/modules/user' import { FrontendRateLimitError } from '@/utils/business/apiRateLimit' import type { AssetBoundCard, @@ -25,13 +26,19 @@ const normalizeOptionalText = (value?: string | null) => typeof value === 'string' ? value.trim() : '' const NULLABLE_REALTIME_CARD_FIELDS = new Set([ + 'gateway_extend', 'last_data_check_at', 'last_real_name_check_at', 'last_card_status_check_at' ]) -// 详情接口是绑定卡实名时间的唯一来源;实时状态接口不应覆盖或保留旧值。 -const DETAIL_ONLY_BOUND_CARD_FIELDS = new Set(['real_name_at']) +// 绑定关系和静态信息以详情接口为准,实时接口只补充同一张卡的状态。 +const REALTIME_BOUND_CARD_FIELDS = new Set([ + 'network_status', + 'real_name_status', + 'is_current', + ...NULLABLE_REALTIME_CARD_FIELDS +]) /** * 格式化数据大小(MB -> GB/MB) @@ -60,22 +67,24 @@ const getDisplayedUsagePercentage = (traffic?: TrafficDisplaySource | null) => { const mergeAssetBoundCards = ( existingCards: AssetBoundCard[] = [], realtimeCards: AssetBoundCard[] = [] -): AssetBoundCard[] => - realtimeCards.map((realtimeCard) => { - const existingCard = existingCards.find((card) => card.iccid === realtimeCard.iccid) - if (!existingCard) { - return { ...realtimeCard, real_name_at: null } - } +): AssetBoundCard[] => { + const realtimeByIccid = new Map(realtimeCards.map((card) => [card.iccid, card])) + const currentCard = existingCards.find( + (card) => realtimeByIccid.get(card.iccid)?.is_current === true + ) + return existingCards.map((existingCard) => { const mergedCard = { ...existingCard } as unknown as Record - for (const [key, value] of Object.entries(realtimeCard)) { - if (DETAIL_ONLY_BOUND_CARD_FIELDS.has(key)) continue + for (const [key, value] of Object.entries(realtimeByIccid.get(existingCard.iccid) || {})) { + if (!REALTIME_BOUND_CARD_FIELDS.has(key)) continue if (value !== undefined && (value !== null || NULLABLE_REALTIME_CARD_FIELDS.has(key))) { mergedCard[key] = value } } + if (currentCard) mergedCard.is_current = existingCard.iccid === currentCard.iccid return mergedCard as unknown as AssetBoundCard }) +} export function useAssetInfo() { // 状态管理 @@ -90,22 +99,93 @@ export function useAssetInfo() { const walletLoading = ref(false) const packageLoading = ref(false) const realtimeLoading = ref(false) + let assetVersion = 0 + let packageRequestId = 0 + let currentPackageRequestId = 0 + let realtimeRequestId = 0 + let walletRequestId = 0 + let disposed = false + + const resetAssetInfo = () => { + assetVersion += 1 + cardInfo.value = null + deviceRealtime.value = null + currentPackage.value = null + currentPackageErrorMsg.value = '' + packageList.value = [] + walletInfo.value = null + loading.value = false + currentPackageLoading.value = false + packageLoading.value = false + realtimeLoading.value = false + walletLoading.value = false + } + + // 同一标识符也可能属于不同搜索或登录会话,不能仅比较 identifier。 + const captureAssetRequest = (identifier: string) => { + const version = assetVersion + const asset = cardInfo.value + return () => + !disposed && + version === assetVersion && + asset !== null && + cardInfo.value === asset && + asset.identifier === identifier + } + + const userStore = useUserStore() + watch( + [ + () => userStore.info.id, + () => userStore.info.user_type, + () => userStore.info.shop_id, + () => userStore.info.enterprise_id, + () => userStore.isLogin + ], + resetAssetInfo, + { flush: 'sync' } + ) + if (getCurrentScope()) { + onScopeDispose(() => { + disposed = true + resetAssetInfo() + }) + } /** * 获取资产详情 * @param identifier - 资产标识符(ICCID、IMEI、SN、虚拟号或MSISDN) */ - const fetchAssetDetail = async (identifier: string) => { + const fetchAssetDetail = async ( + identifier: string, + { preservePackageList = false }: { preservePackageList?: boolean } = {} + ) => { + if (disposed) return null + const previousAsset = cardInfo.value + const previousPackages = + preservePackageList && cardInfo.value?.identifier === identifier ? packageList.value : [] + resetAssetInfo() + packageList.value = previousPackages + const version = assetVersion try { loading.value = true const response = await AssetService.resolveAsset(identifier, { include_usage_summary: true }) + if (version !== assetVersion) return null if (response.code === 0 && response.data) { const data = response.data const resolvedGatewayCardImei = normalizeOptionalText(data.gateway_card_imei) // 获取资产标识符 - const assetIdentifier = data.asset_type === 'card' ? data.iccid : data.virtual_no + const assetIdentifier = + (data.asset_type === 'card' ? data.iccid : data.virtual_no) || identifier + if ( + previousAsset?.asset_type !== data.asset_type || + previousAsset?.asset_id !== data.asset_id || + previousAsset?.identifier !== assetIdentifier + ) { + packageList.value = [] + } // 映射API数据到页面显示格式 cardInfo.value = { @@ -168,7 +248,7 @@ export function useAssetInfo() { sn: data.sn || '', max_sim_slots: data.max_sim_slots || 0, bound_card_count: data.bound_card_count || 0, - cards: data.cards || [], + cards: (data.cards || []).map((card) => ({ ...card })), device_protect_status: data.device_protect_status, online_status: data.online_status, last_online_time: data.last_online_time, @@ -196,15 +276,17 @@ export function useAssetInfo() { loadAssetWallet(assetIdentifier) ]) } + return version === assetVersion ? cardInfo.value : null } else { ElMessage.error(response.msg || '查询失败') cardInfo.value = null } } catch { - cardInfo.value = null + if (version === assetVersion) resetAssetInfo() } finally { - loading.value = false + if (version === assetVersion) loading.value = false } + return null } /** @@ -213,14 +295,24 @@ export function useAssetInfo() { * @param page - 页码 * @param pageSize - 每页条数 */ - const loadPackageList = async (identifier: string, page: number = 1, pageSize: number = 10, status?: number) => { + const loadPackageList = async ( + identifier: string, + page: number = 1, + pageSize: number = 10, + status?: number + ) => { + const isCurrentAsset = captureAssetRequest(identifier) + if (!isCurrentAsset()) return null + const requestId = ++packageRequestId + const isCurrent = () => isCurrentAsset() && requestId === packageRequestId try { packageLoading.value = true const response = await AssetService.getAssetPackages(identifier, { page, - page_size: pageSize, - status + page_size: pageSize, + status }) + if (!isCurrent()) return null if (response.code === 0 && response.data) { // items 代表顶层主套餐关系组,必须保留其 children,不得展平。 const items = response.data.items || [] @@ -233,12 +325,15 @@ export function useAssetInfo() { pageSize: response.data.page_size || pageSize } } + packageList.value = [] return { items: [], total: 0, page: 1, pageSize } } catch (error) { + if (!isCurrent()) return null console.error('获取套餐列表失败:', error) + packageList.value = [] return { items: [], total: 0, page: 1, pageSize } } finally { - packageLoading.value = false + if (isCurrent()) packageLoading.value = false } } @@ -247,11 +342,16 @@ export function useAssetInfo() { * @param identifier - 资产标识符 */ const loadCurrentPackage = async (identifier: string) => { + const isCurrentAsset = captureAssetRequest(identifier) + if (!isCurrentAsset()) return + const requestId = ++currentPackageRequestId + const isCurrent = () => isCurrentAsset() && requestId === currentPackageRequestId try { currentPackageLoading.value = true currentPackageErrorMsg.value = '' const response = await AssetService.getCurrentPackage(identifier) + if (!isCurrent()) return if (response.code === 0) { if (response.data) { @@ -300,6 +400,7 @@ export function useAssetInfo() { currentPackageErrorMsg.value = response.msg || '获取当前套餐失败' } } catch (error: any) { + if (!isCurrent()) return // 404表示无当前生效套餐,这是正常情况 currentPackage.value = null if (error?.response?.status === 404) { @@ -309,7 +410,7 @@ export function useAssetInfo() { currentPackageErrorMsg.value = error?.message || '获取当前套餐失败' } } finally { - currentPackageLoading.value = false + if (isCurrent()) currentPackageLoading.value = false } } @@ -319,11 +420,22 @@ export function useAssetInfo() { * @param assetType - 资产类型(card/device) */ const loadRealtimeStatus = async (identifier: string, assetType: string) => { + const isCurrentAsset = captureAssetRequest(identifier) + if (!isCurrentAsset() || cardInfo.value.asset_type !== assetType) return + const requestId = ++realtimeRequestId + const isCurrent = () => isCurrentAsset() && requestId === realtimeRequestId try { realtimeLoading.value = true const response = await AssetService.getRealtimeStatus(identifier) + if (!isCurrent()) return if (response.code === 0 && response.data) { const data = response.data + if ( + (data.asset_type !== undefined && data.asset_type !== assetType) || + (data.asset_id !== undefined && String(data.asset_id) !== String(cardInfo.value.asset_id)) + ) { + return + } // 更新实时状态数据 if (assetType === 'card') { // 卡的实时状态 @@ -364,8 +476,7 @@ export function useAssetInfo() { cardInfo.value.device_protect_status = data.device_protect_status } if (data.cards && data.cards.length > 0) { - // 合并实时卡数据与原始卡数据,保留 real_name_at 等静态字段。 - // 设备详情里的实名状态、实名时间依赖选中的绑定卡,因此不能在实时刷新时丢掉这些字段。 + // 实时 cards 可能缺少部分卡或静态字段,不能替换详情里的完整绑定列表。 cardInfo.value.cards = mergeAssetBoundCards(cardInfo.value.cards || [], data.cards) } if (data.last_data_check_at !== undefined) { @@ -399,9 +510,9 @@ export function useAssetInfo() { } } } catch (error) { - console.error('获取实时状态失败:', error) + if (isCurrent()) console.error('获取实时状态失败:', error) } finally { - realtimeLoading.value = false + if (isCurrent()) realtimeLoading.value = false } } @@ -410,13 +521,22 @@ export function useAssetInfo() { * @param identifier - 资产标识符 */ const loadAssetWallet = async (identifier: string) => { + const isCurrentAsset = captureAssetRequest(identifier) + if (!isCurrentAsset()) return + const requestId = ++walletRequestId + const isCurrent = () => isCurrentAsset() && requestId === walletRequestId try { walletLoading.value = true const response = await AssetService.getAssetWallet(identifier) + if (!isCurrent()) return if (response.code === 0 && response.data) { walletInfo.value = response.data + } else { + walletInfo.value = null } } catch (error: any) { + if (!isCurrent()) return + walletInfo.value = null if (error?.response?.status === 403) { // 企业账号禁止查看钱包信息,不显示错误 walletInfo.value = null @@ -424,7 +544,7 @@ export function useAssetInfo() { console.error('获取钱包概况失败:', error) } } finally { - walletLoading.value = false + if (isCurrent()) walletLoading.value = false } } @@ -435,16 +555,18 @@ export function useAssetInfo() { * @returns true if refresh was successful, false if rate limited */ const refreshAsset = async (identifier: string, assetType: string): Promise => { + const isCurrent = captureAssetRequest(identifier) + if (!isCurrent() || cardInfo.value.asset_type !== assetType) return false try { + // 主动同步网关的接口仅允许由右上角“同步”按钮触发,搜索和普通数据重载不能调用。 await AssetService.refreshAsset(identifier) + if (!isCurrent()) return false ElMessage.success('刷新成功') - // 刷新成功后重新加载实时状态数据 - if (identifier) { - await loadRealtimeStatus(identifier, assetType) - } - return true + // 绑定/解绑和实名时间由详情接口确认,不能只刷新实时状态而保留过期绑定关系。 + return Boolean(await fetchAssetDetail(identifier, { preservePackageList: true })) } catch (error: any) { + if (!isCurrent()) return false if (error instanceof FrontendRateLimitError) { ElMessage.warning('操作过于频繁,请稍后重试') return false diff --git a/src/views/asset-management/asset-information/index.vue b/src/views/asset-management/asset-information/index.vue index 4b231ab..b9c41ac 100644 --- a/src/views/asset-management/asset-information/index.vue +++ b/src/views/asset-management/asset-information/index.vue @@ -16,6 +16,7 @@
(0) + let realnameStatusAsset: any = null + let bindingCardRealnameStatusAsset: any = null const switchModeCards = computed(() => (cardInfo.value?.cards || []) @@ -402,34 +405,33 @@ /** * 处理资产搜索 */ + let searchRequestId = 0 const handleSearch = async ({ identifier }: { identifier: string }) => { + const requestId = ++searchRequestId // 更新搜索框的值 assetSearchCardRef.value?.updateSearchValue(identifier) startFailureMessage.value = '' + packagePagination.value.page = 1 + packagePagination.value.total = 0 + packagePagination.value.status = undefined - // fetchAssetDetail 内部已经会并行加载所有相关数据,不需要重复调用 - await fetchAssetDetail(identifier) - - // 如果是设备类型,需要再次调用搜索接口获取完整数据 - if (cardInfo.value?.asset_type === 'device') { - const deviceIdentifier = cardInfo.value.virtual_no || cardInfo.value.iccid || identifier - await fetchAssetDetail(deviceIdentifier) - } - - if (cardInfo.value) { - walletTransactionSearchToken.value += 1 - } + // resolve 已返回完整详情;设备不再重复搜索,避免多轮响应相互覆盖。 + const asset = await fetchAssetDetail(identifier) + if (requestId !== searchRequestId || !asset || cardInfo.value !== asset) return + walletTransactionSearchToken.value += 1 // 从接口数据初始化轮询开关,不触发更新接口 pollingInitializing = true - pollingEnabled.value = cardInfo.value?.enable_polling ?? false + pollingEnabled.value = asset.enable_polling ?? false await nextTick() pollingInitializing = false + if (requestId !== searchRequestId || cardInfo.value !== asset) return - // 重置分页并加载套餐列表 - packagePagination.value.page = 1 - const result = await loadPackageList(identifier, 1, packagePagination.value.pageSize) - packagePagination.value.total = result.total + // 套餐也使用解析后的规范标识符,支持通过 ICCID、IMEI、SN 等入口查同一设备。 + const result = await loadPackageList(asset.identifier, 1, packagePagination.value.pageSize) + if (result && requestId === searchRequestId && cardInfo.value === asset) { + packagePagination.value.total = result.total + } } /** @@ -455,7 +457,7 @@ try { const started = await enableCardOp() if (started && cardInfo.value) { - await fetchAssetDetail(cardInfo.value.identifier) + await fetchAssetDetail(cardInfo.value.identifier, { preservePackageList: true }) } } finally { updateRateLimitNow() @@ -568,18 +570,24 @@ * 显示更新实名状态对话框 */ const showUpdateRealnameStatusDialog = () => { + realnameStatusAsset = cardInfo.value updateRealnameStatusDialogVisible.value = true } /** * 更新实名状态成功 */ - const handleUpdateRealnameStatusSuccess = async () => {} + const handleUpdateRealnameStatusSuccess = async () => { + if (realnameStatusAsset && cardInfo.value === realnameStatusAsset) { + await fetchAssetDetail(realnameStatusAsset.identifier, { preservePackageList: true }) + } + } /** * 显示绑定卡更新实名状态对话框 */ const handleUpdateBindingCardRealnameStatus = (payload: { card: any }) => { + bindingCardRealnameStatusAsset = cardInfo.value bindingCardRealnameStatusIccid.value = payload.card.iccid bindingCardRealnameStatusValue.value = payload.card.real_name_status ?? 0 bindingCardRealnameStatusDialogVisible.value = true @@ -588,7 +596,11 @@ /** * 绑定卡更新实名状态成功 */ - const handleBindingCardRealnameStatusSuccess = async () => {} + const handleBindingCardRealnameStatusSuccess = async () => { + if (bindingCardRealnameStatusAsset && cardInfo.value === bindingCardRealnameStatusAsset) { + await fetchAssetDetail(bindingCardRealnameStatusAsset.identifier, { preservePackageList: true }) + } + } /** * 确认实名认证策略 @@ -676,6 +688,7 @@ packagePagination.value.page, packagePagination.value.pageSize ) + if (!result) return packagePagination.value.total = result.total await Promise.all([ loadCurrentPackage(cardInfo.value.identifier), @@ -696,7 +709,7 @@ page, packagePagination.value.pageSize ) - packagePagination.value.total = result.total + if (result) packagePagination.value.total = result.total } /** @@ -707,7 +720,7 @@ packagePagination.value.pageSize = size packagePagination.value.page = 1 // 重置到第一页 const result = await loadPackageList(cardInfo.value.identifier, 1, size) - packagePagination.value.total = result.total + if (result) packagePagination.value.total = result.total } /** @@ -720,7 +733,7 @@ packagePagination.value.page, packagePagination.value.pageSize ) - packagePagination.value.total = result.total + if (result) packagePagination.value.total = result.total } const handlePackageStatusChange = async (status: number | undefined) => { @@ -733,7 +746,7 @@ packagePagination.value.pageSize, status ) - packagePagination.value.total = result.total + if (result) packagePagination.value.total = result.total } /** diff --git a/src/views/asset-management/device-list/index.vue b/src/views/asset-management/device-list/index.vue index f69a792..718c697 100644 --- a/src/views/asset-management/device-list/index.vue +++ b/src/views/asset-management/device-list/index.vue @@ -1618,6 +1618,7 @@ { label: 'IMEI', prop: 'imei' }, { label: '设备名称', prop: 'device_name' }, { label: '关联手机号', prop: 'associated_phones' }, + { label: '授权企业', prop: 'authorized_enterprise_name' }, { label: '店铺名称', prop: 'shop_name' }, { label: '套餐系列', prop: 'series_name' }, { label: '预计套餐到期时间', prop: 'estimated_final_expires_at' }, @@ -1903,6 +1904,13 @@ formatter: (row: Device) => row.associated_phones?.length ? row.associated_phones.join('、') : '-' }, + { + prop: 'authorized_enterprise_name', + label: '授权企业', + minWidth: 160, + showOverflowTooltip: true, + formatter: (row: Device) => row.authorized_enterprise_name || '-' + }, { prop: 'shop_name', label: '店铺名称', diff --git a/src/views/asset-management/iot-card-management/index.vue b/src/views/asset-management/iot-card-management/index.vue index bdc5f07..9fe1f38 100644 --- a/src/views/asset-management/iot-card-management/index.vue +++ b/src/views/asset-management/iot-card-management/index.vue @@ -1949,6 +1949,7 @@ { label: '关联手机号', prop: 'associated_phones' }, { label: '卡虚拟号', prop: 'virtual_no' }, { label: '运营商', prop: 'carrier_name' }, + { label: '授权企业', prop: 'authorized_enterprise_name' }, { label: '卡业务类型', prop: 'card_category' }, { label: '状态', prop: 'status' }, { label: '资产世代', prop: 'generation' }, @@ -2128,6 +2129,13 @@ width: 150, showOverflowTooltip: true }, + { + prop: 'authorized_enterprise_name', + label: '授权企业', + minWidth: 160, + showOverflowTooltip: true, + formatter: (row: StandaloneIotCard) => row.authorized_enterprise_name || '-' + }, { prop: 'shop_name', label: '店铺名称',