Compare commits
9 Commits
iteration/
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a636a953b | ||
|
|
e009c1cb0b | ||
|
|
abd1467482 | ||
|
|
6e8f9cf015 | ||
|
|
b41772455d | ||
|
|
b11a059896 | ||
|
|
330affe0c9 | ||
|
|
8229ad9248 | ||
|
|
fc01fa2ac9 |
@@ -24,7 +24,7 @@ The system SHALL provide a paginated list of all commission withdrawal requests
|
|||||||
|
|
||||||
### Requirement: Withdrawal Request Approval
|
### Requirement: Withdrawal Request Approval
|
||||||
|
|
||||||
The system SHALL allow administrators to approve withdrawal requests.
|
The system SHALL allow administrators to approve withdrawal requests using the approval API contract. The administrator SHALL enter the remark manually, and the system SHALL submit the request's account name, account number, amount, and withdrawal method from the selected list item together with the manual payment type.
|
||||||
|
|
||||||
#### Scenario: Approve withdrawal request
|
#### Scenario: Approve withdrawal request
|
||||||
|
|
||||||
@@ -32,6 +32,13 @@ The system SHALL allow administrators to approve withdrawal requests.
|
|||||||
- **THEN** the system marks the request as approved
|
- **THEN** the system marks the request as approved
|
||||||
- **AND** updates the processor information and timestamp
|
- **AND** updates the processor information and timestamp
|
||||||
|
|
||||||
|
#### Scenario: Submit approval details
|
||||||
|
|
||||||
|
- **WHEN** administrator confirms approval after optionally entering a remark
|
||||||
|
- **THEN** the system submits the selected request's account name, account number, amount, and withdrawal method with `payment_type` set to `manual`
|
||||||
|
- **AND** submits the manually entered remark
|
||||||
|
- **AND** refreshes the list after a successful response
|
||||||
|
|
||||||
### Requirement: Withdrawal Request Rejection
|
### Requirement: Withdrawal Request Rejection
|
||||||
|
|
||||||
The system SHALL allow administrators to reject withdrawal requests with a reason.
|
The system SHALL allow administrators to reject withdrawal requests with a reason.
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
- [x] 5.6 Implement approve action with confirmation dialog
|
- [x] 5.6 Implement approve action with confirmation dialog
|
||||||
- [x] 5.7 Implement reject action with reason input dialog
|
- [x] 5.7 Implement reject action with reason input dialog
|
||||||
- [x] 5.8 Add status badges using unified status components
|
- [x] 5.8 Add status badges using unified status components
|
||||||
|
- [x] 5.9 Submit approval request details from the selected list item and collect a manual remark
|
||||||
|
|
||||||
## 6. Withdrawal Settings Module
|
## 6. Withdrawal Settings Module
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Change: 新增当前用户修改密码弹窗
|
||||||
|
|
||||||
|
状态:用户已确认执行(2026-10-09),按本提案实施。
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
当前顶部用户菜单只有“个人中心”和“锁定屏幕”,缺少当前登录用户修改密码的入口。统一认证接口已有 `PUT /api/auth/password`,但前端参数类型仍使用不符合接口约定的驼峰字段。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- 在顶部用户菜单“个人中心”和“锁定屏幕”之间新增“修改密码”,点击后打开弹窗,并关闭用户菜单浮层。
|
||||||
|
- 弹窗包含旧密码、新密码、确认新密码;输入隐藏并允许切换可见,新密码及确认新密码长度为 8–32 位,确认密码必须一致。旧密码必填,不套用新密码长度限制,以兼容已有密码。
|
||||||
|
- 复用 Element Plus 表单、弹窗及主题,提供字段校验、取消、确认、提交中状态和重复提交保护,兼容窄屏及键盘操作。
|
||||||
|
- 复用 `AuthService.changePassword`,修正参数类型,仅发送 `old_password` 和 `new_password`;确认密码不发送给后端,Bearer 认证沿用现有请求层。
|
||||||
|
- 成功后提示并关闭弹窗、清空密码和旧的记住密码凭证;不主动退出登录。失败时保持弹窗,由现有请求层展示后端错误,401 沿用现有认证过期处理。
|
||||||
|
- 补齐中文及英文菜单与表单文案,以及参数映射、密码长度边界、密码一致性、成功/失败与重复提交验证。
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affected specs: `user-auth`(当前无已归档的能力规范,本提案新增增量规范)。
|
||||||
|
- Affected code: `src/components/core/layouts/art-header-bar/index.vue`、新增修改密码弹窗组件、`src/types/api/auth.ts`、`src/api/modules/auth.ts`、`src/locales/langs/zh.json`、`src/locales/langs/en.json`。
|
||||||
|
- 不涉及管理员给其他账号重置密码,不修改锁屏密码,不引入新依赖,不调用测试环境真实密码修改接口。
|
||||||
|
- 本次另行完成的资产实名时间修正属于现有展示问题修复:仅指定三个 `real_name_at` 展示位置在现有逻辑基础上加 8 小时,不属于本提案的审批范围。
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
审批后验证 7、8、32、33 位新密码边界、确认密码不一致、旧密码为空、成功及失败响应、重复点击,以及菜单位置与重新打开后的密码清空。运行相关测试、类型检查和受影响文件的代码检查。
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Self-service password menu entry
|
||||||
|
|
||||||
|
系统 SHALL 在顶部用户菜单“个人中心”和“锁定屏幕”之间提供“修改密码”入口,点击后显示当前登录用户的修改密码弹窗,不跳转页面。
|
||||||
|
|
||||||
|
#### Scenario: 打开修改密码弹窗
|
||||||
|
|
||||||
|
- **WHEN** 当前登录用户点击顶部菜单“修改密码”
|
||||||
|
- **THEN** 系统 MUST 关闭用户菜单浮层并显示修改密码弹窗
|
||||||
|
- **AND** 弹窗 MUST 包含旧密码、新密码、确认新密码、取消及确认操作
|
||||||
|
- **AND** 输入 MUST 默认隐藏密码并支持切换可见
|
||||||
|
|
||||||
|
### Requirement: Password form validation
|
||||||
|
|
||||||
|
系统 SHALL 校验旧密码必填、新密码及确认新密码长度为 8–32 位且两次新密码一致,在校验通过前不得发送修改密码请求。系统 MUST NOT 自动裁剪或修改用户输入的密码,也不得对旧密码新增长度限制。
|
||||||
|
|
||||||
|
#### Scenario: 密码长度边界
|
||||||
|
|
||||||
|
- **WHEN** 新密码或确认新密码长度为 7 或 33 位
|
||||||
|
- **THEN** 表单 MUST 提示长度要求并阻止提交
|
||||||
|
- **AND** 长度为 8 或 32 位且其他校验通过时 MUST 允许提交
|
||||||
|
|
||||||
|
#### Scenario: 必填及一致性校验
|
||||||
|
|
||||||
|
- **WHEN** 任一密码字段为空,或者两次新密码不一致
|
||||||
|
- **THEN** 表单 MUST 展示对应字段错误并阻止提交
|
||||||
|
|
||||||
|
### Requirement: Unified authentication password contract
|
||||||
|
|
||||||
|
系统 SHALL 通过现有统一认证请求层调用 `PUT /api/auth/password`,请求体 MUST 仅包含 `old_password` 和 `new_password`,Bearer 认证 MUST 沿用当前登录会话,确认密码 MUST NOT 发送到服务端。
|
||||||
|
|
||||||
|
#### Scenario: 提交有效表单
|
||||||
|
|
||||||
|
- **WHEN** 用户提交通过校验的密码表单
|
||||||
|
- **THEN** 系统 MUST 发送旧密码至 `old_password`、新密码至 `new_password`
|
||||||
|
- **AND** 请求方法 MUST 为 PUT,请求路径 MUST 为 `/api/auth/password`
|
||||||
|
- **AND** 请求 MUST 沿用现有请求层的 Bearer 认证
|
||||||
|
|
||||||
|
### Requirement: Password submission lifecycle
|
||||||
|
|
||||||
|
系统 SHALL 在提交期间提供加载状态并阻止重复提交;成功后提示、关闭弹窗并清空敏感字段及记住密码凭证;普通失败时保留弹窗并展示后端错误;认证过期 MUST 沿用现有请求层处理。成功后系统 MUST NOT 主动退出登录或改变锁屏密码。
|
||||||
|
|
||||||
|
#### Scenario: 重复点击提交
|
||||||
|
|
||||||
|
- **WHEN** 修改密码请求尚未完成时用户再次确认
|
||||||
|
- **THEN** 系统 MUST 保持加载状态且不得发送重复请求
|
||||||
|
|
||||||
|
#### Scenario: 修改成功
|
||||||
|
|
||||||
|
- **WHEN** 修改密码请求成功
|
||||||
|
- **THEN** 系统 MUST 提示成功并关闭弹窗
|
||||||
|
- **AND** 系统 MUST 清空全部密码字段及保存的旧登录凭证
|
||||||
|
- **AND** 系统 MUST 保留当前登录状态,不修改锁屏密码
|
||||||
|
|
||||||
|
#### Scenario: 修改失败
|
||||||
|
|
||||||
|
- **WHEN** 接口返回普通错误,如旧密码错误或服务器错误
|
||||||
|
- **THEN** 系统 MUST 通过现有请求层展示错误并保留弹窗
|
||||||
|
- **AND** 系统 MUST 解除提交中状态以允许用户更正后重试
|
||||||
|
|
||||||
|
#### Scenario: 取消后重新打开
|
||||||
|
|
||||||
|
- **WHEN** 用户取消或关闭弹窗后重新打开
|
||||||
|
- **THEN** 所有密码字段及校验状态 MUST 为空
|
||||||
|
|
||||||
|
### Requirement: Password dialog interface consistency
|
||||||
|
|
||||||
|
修改密码弹窗 SHALL 复用现有 Element Plus 组件和主题,支持中英文、键盘操作及窄屏展示,密码输入 MUST 具备可识别标签和可见校验反馈。
|
||||||
|
|
||||||
|
#### Scenario: 窄屏及键盘操作
|
||||||
|
|
||||||
|
- **WHEN** 用户在窄屏设备或通过键盘打开弹窗
|
||||||
|
- **THEN** 弹窗 MUST 不产生水平溢出,且可访问全部输入、取消及确认操作
|
||||||
17
openspec/changes/add-self-service-password-dialog/tasks.md
Normal file
17
openspec/changes/add-self-service-password-dialog/tasks.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
## 1. 审批
|
||||||
|
|
||||||
|
- [x] 1.1 用户确认修改密码弹窗提案后开始实现。
|
||||||
|
|
||||||
|
## 2. 实现
|
||||||
|
|
||||||
|
- [x] 2.1 修正 `ChangePasswordParams`,仅包含 `old_password` 和 `new_password`,复用 `PUT /api/auth/password`。
|
||||||
|
- [x] 2.2 新增修改密码弹窗:旧密码必填、新密码及确认密码 8–32 位、两次新密码一致,支持密码显隐。
|
||||||
|
- [x] 2.3 加入加载状态、重复提交保护、成功提示及关闭、关闭后清空密码;成功时清除旧的记住密码凭证。
|
||||||
|
- [x] 2.4 在“个人中心”和“锁定屏幕”之间增加入口,关闭菜单浮层后显示弹窗,补充中英文文案。
|
||||||
|
|
||||||
|
## 3. 验证
|
||||||
|
|
||||||
|
- [x] 3.1 验证密码长度 7、8、32、33 位边界、必填项和确认密码不一致。
|
||||||
|
- [x] 3.2 验证请求方法、路径、Bearer 认证及下划线参数,确认请求不含确认密码。
|
||||||
|
- [x] 3.3 验证成功、失败、认证过期、重复提交和重新打开弹窗的状态清理。
|
||||||
|
- [x] 3.4 运行相关测试、类型检查及受影响文件代码检查,检查菜单顺序、键盘操作和窄屏布局。
|
||||||
289
scripts/tests/assetBindingCardRender.test.mjs
Normal file
289
scripts/tests/assetBindingCardRender.test.mjs
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
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/realNameTime': { formatRealNameTime: (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]), /重启设备/)
|
||||||
|
})
|
||||||
744
scripts/tests/assetInfoState.test.mjs
Normal file
744
scripts/tests/assetInfoState.test.mjs
Normal file
@@ -0,0 +1,744 @@
|
|||||||
|
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 {} },
|
||||||
|
'@/utils/business/apiError': {
|
||||||
|
normalizeApiError: (error) => ({ message: error?.message || 'unknown 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',
|
||||||
|
'handleSpeedTierConfirm',
|
||||||
|
'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: () => {},
|
||||||
|
loadRealtimeStatus: state.loadRealtimeStatus,
|
||||||
|
hasAuth: () => true,
|
||||||
|
JULY_PERMISSIONS: { speedTier: { update: 'speed:update' } },
|
||||||
|
CardService: {
|
||||||
|
setSpeedTier: async () => response({ speed_tier_name: 'test tier' })
|
||||||
|
},
|
||||||
|
ElMessage: { success: () => {}, error: () => {} },
|
||||||
|
normalizeApiError: (error) => ({ message: error?.message || 'unknown error' }),
|
||||||
|
speedLimitDialogVisible: vue.ref(false),
|
||||||
|
speedTierSubmitting: vue.ref(false),
|
||||||
|
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('successful speed-tier updates reload saved status without calling active gateway synchronization', async (t) => {
|
||||||
|
const syncCalls = []
|
||||||
|
const realtimeCalls = []
|
||||||
|
const { state } = harness(t, {
|
||||||
|
resolveAsset: async () =>
|
||||||
|
response({ ...detail('CARD-1', []), asset_type: 'card', iccid: 'CARD-1' }),
|
||||||
|
refreshAsset: async (identifier) => {
|
||||||
|
syncCalls.push(identifier)
|
||||||
|
return response(null)
|
||||||
|
},
|
||||||
|
getRealtimeStatus: async (identifier) => {
|
||||||
|
realtimeCalls.push(identifier)
|
||||||
|
return response(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await state.fetchAssetDetail('CARD-1')
|
||||||
|
realtimeCalls.length = 0
|
||||||
|
await pageHandlers(state).handleSpeedTierConfirm({ code: 1 })
|
||||||
|
assert.deepEqual(syncCalls, [])
|
||||||
|
assert.deepEqual(realtimeCalls, ['CARD-1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('main retains the legacy 19-digit ICCID fallback without triggering synchronization', async (t) => {
|
||||||
|
const identifiers = []
|
||||||
|
let syncCalls = 0
|
||||||
|
const { state } = harness(t, {
|
||||||
|
resolveAsset: async (identifier) => {
|
||||||
|
identifiers.push(identifier)
|
||||||
|
if (identifier.length === 19) return { code: 1006, msg: 'not found' }
|
||||||
|
return response({ ...detail('CARD-1', []), asset_type: 'card', iccid: identifier })
|
||||||
|
},
|
||||||
|
refreshAsset: async () => {
|
||||||
|
syncCalls += 1
|
||||||
|
return response(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await state.fetchAssetDetail('8986000000000000000')
|
||||||
|
assert.equal(identifiers.length, 2)
|
||||||
|
assert.equal(identifiers[0].length, 19)
|
||||||
|
assert.equal(identifiers[1].length, 20)
|
||||||
|
assert.equal(state.cardInfo.value.identifier, identifiers[1])
|
||||||
|
assert.equal(syncCalls, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
301
scripts/tests/changePassword.test.mjs
Normal file
301
scripts/tests/changePassword.test.mjs
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { effectScope, nextTick, reactive } from 'vue'
|
||||||
|
import { compileScript, parse } from '@vue/compiler-sfc'
|
||||||
|
import ts from 'typescript'
|
||||||
|
|
||||||
|
const componentPath = 'src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue'
|
||||||
|
const componentSource = await readFile(componentPath, 'utf8')
|
||||||
|
const messages = []
|
||||||
|
const requests = []
|
||||||
|
let rememberedClears = 0
|
||||||
|
let localStateClears = 0
|
||||||
|
let response = { status: 200, data: { code: 0, msg: 'ok', data: null } }
|
||||||
|
let pendingRequest
|
||||||
|
|
||||||
|
function moduleUrl(source, imports = {}) {
|
||||||
|
const { outputText } = ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ESNext }
|
||||||
|
})
|
||||||
|
const resolved = outputText.replace(/from ['"]([^'"]+)['"]/g, (_match, name) => {
|
||||||
|
return `from '${imports[name] || import.meta.resolve(name)}'`
|
||||||
|
})
|
||||||
|
return `data:text/javascript;base64,${Buffer.from(resolved).toString('base64')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 真实请求层使用模拟 Axios adapter:不会访问网络或修改真实账号密码。
|
||||||
|
const axios = (await import('axios')).default
|
||||||
|
const previousAdapter = axios.defaults.adapter
|
||||||
|
|
||||||
|
globalThis.__passwordTest = {
|
||||||
|
message: (type, value) => messages.push({ type, value }),
|
||||||
|
clearRemembered: () => rememberedClears++,
|
||||||
|
store: {
|
||||||
|
accessToken: 'mock-access-token',
|
||||||
|
refreshToken: '',
|
||||||
|
clearLocalState: () => localStateClears++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const uiUrl = moduleUrl(`
|
||||||
|
export const ElButton = {}, ElDialog = {}, ElForm = {}, ElFormItem = {}, ElInput = {}
|
||||||
|
export const ElMessage = {
|
||||||
|
success: (value) => globalThis.__passwordTest.message('success', value),
|
||||||
|
error: (value) => globalThis.__passwordTest.message('error', value)
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
const storeUrl = moduleUrl('export const useUserStore = () => globalThis.__passwordTest.store')
|
||||||
|
const statusUrl = moduleUrl(await readFile('src/utils/http/status.ts', 'utf8'))
|
||||||
|
const errorUrl = moduleUrl(await readFile('src/utils/business/apiError.ts', 'utf8'))
|
||||||
|
const idUrl = moduleUrl(await readFile('src/utils/business/id.ts', 'utf8'))
|
||||||
|
const httpSource = (await readFile('src/utils/http/index.ts', 'utf8'))
|
||||||
|
.replace('import.meta.env.DEV', 'true')
|
||||||
|
.replace('import.meta.env.VITE_API_URL', "''")
|
||||||
|
const httpUrl = moduleUrl(httpSource, {
|
||||||
|
'element-plus': uiUrl,
|
||||||
|
'@/store/modules/user': storeUrl,
|
||||||
|
'./status': statusUrl,
|
||||||
|
'@/utils/business/apiError': errorUrl,
|
||||||
|
'@/utils/business/id': idUrl
|
||||||
|
})
|
||||||
|
// 导入时才创建 Axios 实例,因此临时保留模拟 adapter 到模块初始化结束。
|
||||||
|
const mockAdapter = async (config) => {
|
||||||
|
requests.push(config)
|
||||||
|
if (pendingRequest) await pendingRequest
|
||||||
|
const result = { ...response, headers: { 'content-type': 'application/json' }, config }
|
||||||
|
if (response.status >= 400) {
|
||||||
|
throw new axios.AxiosError('Mock HTTP error', 'ERR_BAD_RESPONSE', config, undefined, result)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
axios.defaults.adapter = mockAdapter
|
||||||
|
await import(httpUrl)
|
||||||
|
axios.defaults.adapter = previousAdapter
|
||||||
|
const baseUrl = moduleUrl(await readFile('src/api/BaseService.ts', 'utf8'), {
|
||||||
|
'@/utils/http': httpUrl
|
||||||
|
})
|
||||||
|
const authUrl = moduleUrl(await readFile('src/api/modules/auth.ts', 'utf8'), {
|
||||||
|
'../BaseService': baseUrl
|
||||||
|
})
|
||||||
|
const { AuthService } = await import(authUrl)
|
||||||
|
const { descriptor } = parse(componentSource, { filename: componentPath })
|
||||||
|
const script = compileScript(descriptor, { id: 'password-test' })
|
||||||
|
const { default: Dialog } = await import(
|
||||||
|
moduleUrl(script.content, {
|
||||||
|
'element-plus': uiUrl,
|
||||||
|
'vue-i18n': moduleUrl('export const useI18n = () => ({ t: (key) => key })'),
|
||||||
|
'@/api/modules/auth': authUrl,
|
||||||
|
'@/utils/http/status': statusUrl,
|
||||||
|
'@/utils/auth/rememberPassword': moduleUrl(
|
||||||
|
'export const clearRememberedCredentials = () => globalThis.__passwordTest.clearRemembered()'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
function createDialog() {
|
||||||
|
response = { status: 200, data: { code: 0, msg: 'ok', data: null } }
|
||||||
|
pendingRequest = undefined
|
||||||
|
requests.length = 0
|
||||||
|
messages.length = 0
|
||||||
|
rememberedClears = 0
|
||||||
|
const props = reactive({ modelValue: true })
|
||||||
|
const scope = effectScope()
|
||||||
|
const state = scope.run(() =>
|
||||||
|
Dialog.setup(props, {
|
||||||
|
expose: () => {},
|
||||||
|
emit: (_event, value) => {
|
||||||
|
props.modelValue = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
let clearedValidation = 0
|
||||||
|
async function validateField(field) {
|
||||||
|
const value = state.formData[field]
|
||||||
|
for (const rule of state.rules.value[field]) {
|
||||||
|
if (rule.required && !value) throw new Error(rule.message)
|
||||||
|
if (rule.min !== undefined && value.length < rule.min) throw new Error(rule.message)
|
||||||
|
if (rule.max !== undefined && value.length > rule.max) throw new Error(rule.message)
|
||||||
|
if (rule.validator) {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
rule.validator(rule, value, (error) => (error ? reject(error) : resolve()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.formRef.value = {
|
||||||
|
validate: async () => {
|
||||||
|
for (const field of Object.keys(state.rules.value)) await validateField(field)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
validateField,
|
||||||
|
clearValidate: () => clearedValidation++
|
||||||
|
}
|
||||||
|
Object.assign(state.formData, {
|
||||||
|
oldPassword: 'old',
|
||||||
|
newPassword: 'new-password',
|
||||||
|
confirmPassword: 'new-password'
|
||||||
|
})
|
||||||
|
return { state, props, scope, validationClears: () => clearedValidation }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('密码长度 7/33 位阻止提交,8/32 位允许提交,旧密码不受新长度限制', async () => {
|
||||||
|
for (const length of [7, 8, 32, 33]) {
|
||||||
|
const { state, props, scope } = createDialog()
|
||||||
|
state.formData.newPassword = 'a'.repeat(length)
|
||||||
|
state.formData.confirmPassword = 'a'.repeat(length)
|
||||||
|
await state.handleSubmit()
|
||||||
|
assert.equal(requests.length, length === 8 || length === 32 ? 1 : 0)
|
||||||
|
assert.equal(props.modelValue, length !== 8 && length !== 32)
|
||||||
|
assert.equal(state.submitting.value, false)
|
||||||
|
scope.stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('密码必填、一致性及确认密码长度校验失败不发送请求', async () => {
|
||||||
|
for (const invalid of [
|
||||||
|
{ oldPassword: '' },
|
||||||
|
{ newPassword: '' },
|
||||||
|
{ confirmPassword: '' },
|
||||||
|
{ confirmPassword: 'different-password' },
|
||||||
|
{ confirmPassword: 'a'.repeat(7) },
|
||||||
|
{ confirmPassword: 'a'.repeat(33) }
|
||||||
|
]) {
|
||||||
|
const { state, scope } = createDialog()
|
||||||
|
Object.assign(state.formData, invalid)
|
||||||
|
await state.handleSubmit()
|
||||||
|
assert.equal(requests.length, 0)
|
||||||
|
assert.equal(state.submitting.value, false)
|
||||||
|
scope.stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('修改成功发送下划线参数及 Bearer 认证、保留密码空格、清理表单但不退出登录', async () => {
|
||||||
|
const { state, props, scope } = createDialog()
|
||||||
|
state.formData.oldPassword = ' old '
|
||||||
|
state.formData.newPassword = state.formData.confirmPassword = ' new-password '
|
||||||
|
await state.handleSubmit()
|
||||||
|
assert.equal(requests[0].method, 'put')
|
||||||
|
assert.equal(requests[0].url, '/api/auth/password')
|
||||||
|
assert.equal(requests[0].headers.get('Authorization'), 'Bearer mock-access-token')
|
||||||
|
assert.deepEqual(JSON.parse(requests[0].data), {
|
||||||
|
old_password: ' old ',
|
||||||
|
new_password: ' new-password '
|
||||||
|
})
|
||||||
|
assert.equal(rememberedClears, 1)
|
||||||
|
assert.equal(localStateClears, 0)
|
||||||
|
assert.equal(props.modelValue, false)
|
||||||
|
assert.deepEqual({ ...state.formData }, { oldPassword: '', newPassword: '', confirmPassword: '' })
|
||||||
|
assert.deepEqual(messages, [{ type: 'success', value: 'changePassword.success' }])
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('服务层丢弃确认密码和其他额外字段', async () => {
|
||||||
|
createDialog().scope.stop()
|
||||||
|
await AuthService.changePassword({
|
||||||
|
old_password: 'old',
|
||||||
|
new_password: 'new-password',
|
||||||
|
confirmPassword: 'unused'
|
||||||
|
})
|
||||||
|
assert.deepEqual(JSON.parse(requests[0].data), {
|
||||||
|
old_password: 'old',
|
||||||
|
new_password: 'new-password'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('校验或请求未完成时重复提交只产生一次请求,并阻止关闭', async () => {
|
||||||
|
const { state, props, scope } = createDialog()
|
||||||
|
let finishRequest
|
||||||
|
pendingRequest = new Promise((resolve) => {
|
||||||
|
finishRequest = resolve
|
||||||
|
})
|
||||||
|
const first = state.handleSubmit()
|
||||||
|
await state.handleSubmit()
|
||||||
|
await nextTick()
|
||||||
|
assert.equal(state.submitting.value, true)
|
||||||
|
let closed = false
|
||||||
|
state.handleBeforeClose(() => {
|
||||||
|
closed = true
|
||||||
|
})
|
||||||
|
assert.equal(closed, false)
|
||||||
|
assert.equal(props.modelValue, true)
|
||||||
|
finishRequest()
|
||||||
|
await first
|
||||||
|
assert.equal(requests.length, 1)
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('HTTP 400/500 错误保留弹窗和输入,统一请求层提示一次且允许重试', async () => {
|
||||||
|
for (const status of [400, 500]) {
|
||||||
|
const { state, props, scope } = createDialog()
|
||||||
|
response = { status, data: { code: status, msg: '旧密码错误', data: {} } }
|
||||||
|
await state.handleSubmit()
|
||||||
|
assert.equal(props.modelValue, true)
|
||||||
|
assert.equal(state.formData.oldPassword, 'old')
|
||||||
|
assert.equal(state.submitting.value, false)
|
||||||
|
assert.equal(rememberedClears, 0)
|
||||||
|
assert.equal(messages.length, 1)
|
||||||
|
assert.equal(messages[0].type, 'error')
|
||||||
|
response = { status: 200, data: { code: 0, msg: 'ok', data: null } }
|
||||||
|
await state.handleSubmit()
|
||||||
|
assert.equal(props.modelValue, false)
|
||||||
|
assert.equal(requests.length, 2)
|
||||||
|
scope.stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('HTTP 200 业务错误不会误报成功或清除保存的凭证', async () => {
|
||||||
|
const { state, props, scope } = createDialog()
|
||||||
|
response = { status: 200, data: { code: 1001, msg: '旧密码错误', data: {} } }
|
||||||
|
await state.handleSubmit()
|
||||||
|
assert.equal(props.modelValue, true)
|
||||||
|
assert.equal(rememberedClears, 0)
|
||||||
|
assert.deepEqual(messages, [{ type: 'error', value: '旧密码错误' }])
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('关闭后重新打开清空密码及校验状态', async () => {
|
||||||
|
const { state, props, scope, validationClears } = createDialog()
|
||||||
|
state.visible.value = false
|
||||||
|
state.resetForm()
|
||||||
|
props.modelValue = true
|
||||||
|
await nextTick()
|
||||||
|
assert.deepEqual({ ...state.formData }, { oldPassword: '', newPassword: '', confirmPassword: '' })
|
||||||
|
assert.ok(validationClears() > 0)
|
||||||
|
assert.equal(state.submitting.value, false)
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('菜单顺序、显隐、标签、键盘提交、窄屏宽度及中英文文案完整', async () => {
|
||||||
|
const header = await readFile('src/components/core/layouts/art-header-bar/index.vue', 'utf8')
|
||||||
|
assert.ok(header.indexOf('topBar.user.userCenter') < header.indexOf('topBar.user.changePassword'))
|
||||||
|
assert.ok(header.indexOf('topBar.user.changePassword') < header.indexOf('topBar.user.lockScreen'))
|
||||||
|
assert.match(header, /userMenuPopover\.value\?\.hide\(\)[\s\S]*showChangePassword\.value = true/)
|
||||||
|
assert.equal(componentSource.match(/show-password/g)?.length, 3)
|
||||||
|
assert.match(componentSource, /width="min\(460px, calc\(100vw - 32px\)\)"/)
|
||||||
|
assert.match(componentSource, /label-position="top"/)
|
||||||
|
assert.match(componentSource, /@submit\.prevent="handleSubmit"/)
|
||||||
|
assert.match(header, /:focus-visible/)
|
||||||
|
for (const language of ['zh', 'en']) {
|
||||||
|
const dictionary = JSON.parse(await readFile(`src/locales/langs/${language}.json`, 'utf8'))
|
||||||
|
for (const [, key] of componentSource.matchAll(/t\('changePassword\.([^']+)'\)/g)) {
|
||||||
|
assert.ok(dictionary.changePassword[key], `${language}: ${key}`)
|
||||||
|
}
|
||||||
|
assert.ok(dictionary.topBar.user.changePassword)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('HTTP 401 沿用统一认证过期处理,仅提示一次并清理本地登录状态', async () => {
|
||||||
|
const { state, scope } = createDialog()
|
||||||
|
response = { status: 401, data: { code: 401, msg: 'unauthorized', data: {} } }
|
||||||
|
await state.handleSubmit()
|
||||||
|
assert.equal(state.submitting.value, false)
|
||||||
|
assert.equal(rememberedClears, 0)
|
||||||
|
assert.deepEqual(messages, [{ type: 'error', value: '登录已过期,请重新登录' }])
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1050))
|
||||||
|
assert.equal(localStateClears, 1)
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
delete globalThis.__passwordTest
|
||||||
|
})
|
||||||
107
scripts/tests/passwordDialogPreview.mjs
Normal file
107
scripts/tests/passwordDialogPreview.mjs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// 仅供本地视觉 QA:使用模拟认证服务,绝不调用真实修改密码接口。
|
||||||
|
// 运行:node scripts/tests/passwordDialogPreview.mjs
|
||||||
|
import { createServer } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const root = fileURLToPath(new URL('../../', import.meta.url))
|
||||||
|
const previewEntry = `
|
||||||
|
import { createApp, h, ref } from 'vue'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import { ElButton, ElSelect, ElOption } from 'element-plus'
|
||||||
|
import '/src/assets/styles/el-light.scss'
|
||||||
|
import '/src/assets/styles/el-dark.scss'
|
||||||
|
import '/src/assets/styles/el-ui.scss'
|
||||||
|
import Dialog from '/src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue'
|
||||||
|
import zh from '/src/locales/langs/zh.json'
|
||||||
|
import en from '/src/locales/langs/en.json'
|
||||||
|
const i18n = createI18n({ legacy: false, locale: 'zh', messages: { zh, en } })
|
||||||
|
window.passwordPreviewRequests = []
|
||||||
|
window.passwordPreviewResult = 'success'
|
||||||
|
createApp({
|
||||||
|
setup() {
|
||||||
|
const visible = ref(false)
|
||||||
|
const dark = ref(false)
|
||||||
|
return () => h('main', { style: 'padding:24px;font-family:sans-serif' }, [
|
||||||
|
h('h2', '修改密码弹窗 · 离线验证'),
|
||||||
|
h('p', '此页面不连接真实接口。密码输入仅用于本地模拟验证。'),
|
||||||
|
h(ElButton, { onClick: () => visible.value = true }, () => '修改密码'),
|
||||||
|
h(ElButton, { onClick: () => {
|
||||||
|
dark.value = !dark.value
|
||||||
|
document.documentElement.classList.toggle('dark', dark.value)
|
||||||
|
} }, () => '切换主题'),
|
||||||
|
h(ElButton, { onClick: () => {
|
||||||
|
i18n.global.locale.value = i18n.global.locale.value === 'zh' ? 'en' : 'zh'
|
||||||
|
} }, () => '切换语言'),
|
||||||
|
h(ElSelect, {
|
||||||
|
modelValue: window.passwordPreviewResult,
|
||||||
|
'onUpdate:modelValue': (value) => window.passwordPreviewResult = value,
|
||||||
|
style: 'width:140px;margin-left:12px'
|
||||||
|
}, () => ['success', 'error'].map(value => h(ElOption, { value, label: value }))),
|
||||||
|
h(Dialog, { modelValue: visible.value, 'onUpdate:modelValue': value => visible.value = value })
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}).use(i18n).mount('#app')
|
||||||
|
`
|
||||||
|
|
||||||
|
const server = await createServer({
|
||||||
|
root,
|
||||||
|
configFile: false,
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../src', import.meta.url)),
|
||||||
|
'@styles': fileURLToPath(new URL('../../src/assets/styles', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
optimizeDeps: { noDiscovery: true, include: ['vue', 'element-plus', 'vue-i18n'] },
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
additionalData: '@use "@styles/variables.scss" as *; @use "@styles/mixin.scss" as *;'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: { host: '127.0.0.1', port: 5198, strictPort: true },
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: 'offline-password-preview',
|
||||||
|
enforce: 'pre',
|
||||||
|
resolveId(id) {
|
||||||
|
if (id === '/__password-preview.js') return '\0password-preview-entry'
|
||||||
|
if (id.endsWith('/api/modules/auth')) return '\0password-preview-auth'
|
||||||
|
},
|
||||||
|
load(id) {
|
||||||
|
if (id === '\0password-preview-entry') return previewEntry
|
||||||
|
if (id === '\0password-preview-auth') {
|
||||||
|
return `export const AuthService = {
|
||||||
|
async changePassword(params) {
|
||||||
|
window.passwordPreviewRequests.push(params)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
return window.passwordPreviewResult === 'error'
|
||||||
|
? { code: 1001, msg: '旧密码错误(模拟)' }
|
||||||
|
: { code: 0, msg: 'ok' }
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
configureServer(viteServer) {
|
||||||
|
viteServer.middlewares.use((req, res, next) => {
|
||||||
|
if (req.url !== '/') return next()
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||||
|
res.end(
|
||||||
|
'<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>离线修改密码验证</title></head><body><div id="app"></div><script type="module" src="/__password-preview.js"></script></body></html>'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
vue()
|
||||||
|
]
|
||||||
|
})
|
||||||
|
await server.listen()
|
||||||
|
server.printUrls()
|
||||||
|
const stop = async () => {
|
||||||
|
await server.close()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
process.on('SIGINT', stop)
|
||||||
|
process.on('SIGTERM', stop)
|
||||||
99
scripts/tests/realNameTime.test.mjs
Normal file
99
scripts/tests/realNameTime.test.mjs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import ts from 'typescript'
|
||||||
|
|
||||||
|
function toModuleUrl(source) {
|
||||||
|
const { outputText } = ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ESNext }
|
||||||
|
})
|
||||||
|
return `data:text/javascript;base64,${Buffer.from(outputText).toString('base64')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatModuleUrl = toModuleUrl(await readFile('src/utils/business/format.ts', 'utf8'))
|
||||||
|
const { formatDateTime } = await import(formatModuleUrl)
|
||||||
|
const realNameTimeSource = await readFile('src/utils/business/realNameTime.ts', 'utf8')
|
||||||
|
const { formatRealNameTime } = await import(
|
||||||
|
toModuleUrl(realNameTimeSource.replace("from './format'", `from '${formatModuleUrl}'`))
|
||||||
|
)
|
||||||
|
|
||||||
|
test('实名时间在现有时区换算基础上额外增加 8 小时', () => {
|
||||||
|
const cases = [
|
||||||
|
['2026-10-09T01:02:03Z', '2026-10-09 17:02:03'],
|
||||||
|
['2026-10-09T01:02:03+00:00', '2026-10-09 17:02:03'],
|
||||||
|
['2026-10-09T01:02:03+08:00', '2026-10-09 09:02:03'],
|
||||||
|
['2026-10-09T01:02:03-05:00', '2026-10-09 22:02:03'],
|
||||||
|
['2026-10-09 01:02:03', '2026-10-09 09:02:03'],
|
||||||
|
['2026-10-09T01:02:03', '2026-10-09 09:02:03'],
|
||||||
|
['2026-10-09', '2026-10-09 08:00:00']
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [input, expected] of cases) {
|
||||||
|
assert.equal(formatRealNameTime(input), expected, input)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('实名时间支持秒、毫秒时间戳及 Date 对象', () => {
|
||||||
|
const timestamp = Date.parse('2026-10-09T01:02:03Z')
|
||||||
|
for (const input of [
|
||||||
|
timestamp,
|
||||||
|
timestamp / 1000,
|
||||||
|
String(timestamp),
|
||||||
|
String(timestamp / 1000),
|
||||||
|
new Date(timestamp)
|
||||||
|
]) {
|
||||||
|
assert.equal(formatRealNameTime(input), '2026-10-09 17:02:03')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('实名时间加 8 小时正确处理跨日、跨年和闰日', () => {
|
||||||
|
assert.equal(formatRealNameTime('2026-10-09 20:30:00'), '2026-10-10 04:30:00')
|
||||||
|
assert.equal(formatRealNameTime('2026-12-31 20:30:00'), '2027-01-01 04:30:00')
|
||||||
|
assert.equal(formatRealNameTime('2024-02-28 20:30:00'), '2024-02-29 04:30:00')
|
||||||
|
assert.equal(formatRealNameTime('2024-02-29 20:30:00'), '2024-03-01 04:30:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('实名时间为空或无效时保留占位符', () => {
|
||||||
|
for (const input of [undefined, null, '', ' ', 'invalid', NaN, new Date(NaN)]) {
|
||||||
|
assert.equal(formatRealNameTime(input), '-')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('通用时间格式化逻辑保持不变', () => {
|
||||||
|
assert.equal(formatDateTime('2026-10-09T01:02:03Z'), '2026-10-09 09:02:03')
|
||||||
|
assert.equal(formatDateTime('2026-10-09 01:02:03'), '2026-10-09 01:02:03')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('实名时间展示不依赖运行环境时区', () => {
|
||||||
|
const originalTimezone = process.env.TZ
|
||||||
|
try {
|
||||||
|
for (const timezone of ['UTC', 'Asia/Shanghai', 'America/New_York']) {
|
||||||
|
process.env.TZ = timezone
|
||||||
|
assert.equal(formatRealNameTime('2026-10-09 01:02:03'), '2026-10-09 09:02:03')
|
||||||
|
assert.equal(formatRealNameTime('2026-10-09T01:02:03Z'), '2026-10-09 17:02:03')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (originalTimezone === undefined) delete process.env.TZ
|
||||||
|
else process.env.TZ = originalTimezone
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('资产信息仅在卡信息、设备信息及绑定卡列表的实名时间使用额外偏移', async () => {
|
||||||
|
const component = await readFile(
|
||||||
|
'src/views/asset-management/asset-information/components/BasicInfoCard.vue',
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.ok(component.includes('formatRealNameTime(cardInfo?.real_name_at)'))
|
||||||
|
assert.ok(component.includes('formatRealNameTime(selectedDeviceStatusCard?.real_name_at)'))
|
||||||
|
assert.ok(component.includes('formatRealNameTime(bindingCard.real_name_at)'))
|
||||||
|
assert.equal(component.match(/formatRealNameTime\(/g)?.length, 3)
|
||||||
|
assert.ok(component.includes('formatAssetInfoTime(cardInfo?.last_real_name_check_at)'))
|
||||||
|
assert.ok(component.includes('formatAssetInfoTime(cardInfo?.last_card_status_check_at)'))
|
||||||
|
assert.ok(component.includes('formatAssetInfoTime(cardInfo?.last_data_check_at)'))
|
||||||
|
assert.ok(
|
||||||
|
component.includes(
|
||||||
|
'const formatAssetInfoTime = (value?: string | null) => formatDateTime(value)'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -6,7 +6,6 @@ import { BaseService } from '../BaseService'
|
|||||||
import type {
|
import type {
|
||||||
LoginParams,
|
LoginParams,
|
||||||
LoginData,
|
LoginData,
|
||||||
UserInfo,
|
|
||||||
UserInfoResponse,
|
UserInfoResponse,
|
||||||
RefreshTokenParams,
|
RefreshTokenParams,
|
||||||
RefreshTokenData,
|
RefreshTokenData,
|
||||||
@@ -51,7 +50,8 @@ export class AuthService extends BaseService {
|
|||||||
* @param params 修改密码参数
|
* @param params 修改密码参数
|
||||||
*/
|
*/
|
||||||
static changePassword(params: ChangePasswordParams): Promise<BaseResponse> {
|
static changePassword(params: ChangePasswordParams): Promise<BaseResponse> {
|
||||||
return this.put<BaseResponse>('/api/auth/password', params)
|
const { old_password, new_password } = params
|
||||||
|
return this.put<BaseResponse>('/api/auth/password', { old_password, new_password })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
WithdrawalRequestQueryParams,
|
WithdrawalRequestQueryParams,
|
||||||
WithdrawalRequestPageResult,
|
WithdrawalRequestPageResult,
|
||||||
ApproveWithdrawalParams,
|
ApproveWithdrawalParams,
|
||||||
|
WithdrawalApprovalResponse,
|
||||||
RejectWithdrawalParams,
|
RejectWithdrawalParams,
|
||||||
WithdrawalSettingListResult,
|
WithdrawalSettingListResult,
|
||||||
WithdrawalSettingItem,
|
WithdrawalSettingItem,
|
||||||
@@ -46,8 +47,11 @@ export class CommissionService extends BaseService {
|
|||||||
* 审批通过提现申请
|
* 审批通过提现申请
|
||||||
* POST /api/admin/commission/withdrawal-requests/{id}/approve
|
* POST /api/admin/commission/withdrawal-requests/{id}/approve
|
||||||
*/
|
*/
|
||||||
static approveWithdrawal(id: number, params?: ApproveWithdrawalParams): Promise<BaseResponse> {
|
static approveWithdrawal(
|
||||||
return this.post<BaseResponse>(
|
id: number,
|
||||||
|
params: ApproveWithdrawalParams
|
||||||
|
): Promise<BaseResponse<WithdrawalApprovalResponse>> {
|
||||||
|
return this.post<BaseResponse<WithdrawalApprovalResponse>>(
|
||||||
`/api/admin/commission/withdrawal-requests/${id}/approve`,
|
`/api/admin/commission/withdrawal-requests/${id}/approve`,
|
||||||
params
|
params
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { Picture, Paperclip } from '@element-plus/icons-vue'
|
import { Picture, Paperclip } from '@element-plus/icons-vue'
|
||||||
import { mittBus } from '@/utils/sys'
|
import { mittBus } from '@/utils/sys'
|
||||||
|
import { formatDateTime } from '@/utils/business/format'
|
||||||
|
|
||||||
const { width } = useWindowSize()
|
const { width } = useWindowSize()
|
||||||
const isMobile = computed(() => width.value < 500)
|
const isMobile = computed(() => width.value < 500)
|
||||||
@@ -157,7 +158,7 @@
|
|||||||
id: messageId.value++,
|
id: messageId.value++,
|
||||||
sender: 'Ricky',
|
sender: 'Ricky',
|
||||||
content: text,
|
content: text,
|
||||||
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
time: formatDateTime(new Date(), 'HH:mm'),
|
||||||
isMe: true
|
isMe: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<template>
|
||||||
|
<ElDialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="t('changePassword.title')"
|
||||||
|
width="min(460px, calc(100vw - 32px))"
|
||||||
|
append-to-body
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="!submitting"
|
||||||
|
:show-close="!submitting"
|
||||||
|
:before-close="handleBeforeClose"
|
||||||
|
@closed="resetForm"
|
||||||
|
>
|
||||||
|
<ElForm
|
||||||
|
ref="formRef"
|
||||||
|
:model="formData"
|
||||||
|
:rules="rules"
|
||||||
|
:disabled="submitting"
|
||||||
|
label-position="top"
|
||||||
|
@submit.prevent="handleSubmit"
|
||||||
|
>
|
||||||
|
<ElFormItem :label="t('changePassword.oldPassword')" prop="oldPassword">
|
||||||
|
<ElInput
|
||||||
|
v-model="formData.oldPassword"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
autocomplete="current-password"
|
||||||
|
:placeholder="t('changePassword.oldPasswordPlaceholder')"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="t('changePassword.newPassword')" prop="newPassword">
|
||||||
|
<ElInput
|
||||||
|
v-model="formData.newPassword"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
autocomplete="new-password"
|
||||||
|
:placeholder="t('changePassword.newPasswordPlaceholder')"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="t('changePassword.confirmPassword')" prop="confirmPassword">
|
||||||
|
<ElInput
|
||||||
|
v-model="formData.confirmPassword"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
autocomplete="new-password"
|
||||||
|
:placeholder="t('changePassword.confirmPasswordPlaceholder')"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<button type="submit" hidden />
|
||||||
|
</ElForm>
|
||||||
|
<template #footer>
|
||||||
|
<ElButton :disabled="submitting" @click="visible = false">
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
|
||||||
|
{{ t('common.confirm') }}
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ElButton, ElDialog, ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus'
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { AuthService } from '@/api/modules/auth'
|
||||||
|
import { clearRememberedCredentials } from '@/utils/auth/rememberPassword'
|
||||||
|
import { ApiStatus } from '@/utils/http/status'
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: boolean }>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const submitting = ref(false)
|
||||||
|
const formData = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' })
|
||||||
|
|
||||||
|
const rules = computed<FormRules>(() => ({
|
||||||
|
oldPassword: [
|
||||||
|
{ required: true, message: t('changePassword.oldPasswordPlaceholder'), trigger: 'blur' }
|
||||||
|
],
|
||||||
|
newPassword: [
|
||||||
|
{ required: true, message: t('changePassword.newPasswordPlaceholder'), trigger: 'blur' },
|
||||||
|
{ min: 8, max: 32, message: t('changePassword.passwordLength'), trigger: 'blur' }
|
||||||
|
],
|
||||||
|
confirmPassword: [
|
||||||
|
{ required: true, message: t('changePassword.confirmPasswordPlaceholder'), trigger: 'blur' },
|
||||||
|
{ min: 8, max: 32, message: t('changePassword.passwordLength'), trigger: 'blur' },
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
if (value !== formData.newPassword) {
|
||||||
|
callback(new Error(t('changePassword.passwordMismatch')))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
Object.assign(formData, { oldPassword: '', newPassword: '', confirmPassword: '' })
|
||||||
|
formRef.value?.clearValidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(visible, (value) => {
|
||||||
|
if (value) resetForm()
|
||||||
|
})
|
||||||
|
watch(
|
||||||
|
() => formData.newPassword,
|
||||||
|
() => {
|
||||||
|
if (formData.confirmPassword) {
|
||||||
|
void formRef.value?.validateField('confirmPassword').catch(() => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleBeforeClose = (done: () => void) => {
|
||||||
|
if (!submitting.value) done()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!formRef.value || submitting.value) return
|
||||||
|
// 在异步校验前加锁,避免快速点击产生多个请求。
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const valid = await formRef.value.validate().catch(() => false)
|
||||||
|
if (!valid || !visible.value) return
|
||||||
|
|
||||||
|
const response = await AuthService.changePassword({
|
||||||
|
old_password: formData.oldPassword,
|
||||||
|
new_password: formData.newPassword
|
||||||
|
})
|
||||||
|
if (response.code !== ApiStatus.success) {
|
||||||
|
ElMessage.error(response.msg || t('changePassword.failed'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearRememberedCredentials()
|
||||||
|
ElMessage.success(t('changePassword.success'))
|
||||||
|
visible.value = false
|
||||||
|
resetForm()
|
||||||
|
} catch {
|
||||||
|
// HTTP 错误及认证过期由统一请求层处理,不重复提示或输出密码。
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -158,6 +158,12 @@
|
|||||||
<i class="menu-icon iconfont-sys"></i>
|
<i class="menu-icon iconfont-sys"></i>
|
||||||
<span class="menu-txt">{{ $t('topBar.user.userCenter') }}</span>
|
<span class="menu-txt">{{ $t('topBar.user.userCenter') }}</span>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="password-menu-item">
|
||||||
|
<button type="button" class="password-menu-button" @click="openChangePassword">
|
||||||
|
<ElIcon class="password-menu-icon"><Key /></ElIcon>
|
||||||
|
<span class="menu-txt">{{ $t('topBar.user.changePassword') }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
<li @click="lockScreen()">
|
<li @click="lockScreen()">
|
||||||
<i class="menu-icon iconfont-sys"></i>
|
<i class="menu-icon iconfont-sys"></i>
|
||||||
<span class="menu-txt">{{ $t('topBar.user.lockScreen') }}</span>
|
<span class="menu-txt">{{ $t('topBar.user.lockScreen') }}</span>
|
||||||
@@ -176,6 +182,7 @@
|
|||||||
<ArtWorkTab />
|
<ArtWorkTab />
|
||||||
|
|
||||||
<art-notification v-model="showNotice" />
|
<art-notification v-model="showNotice" />
|
||||||
|
<ChangePasswordDialog v-model="showChangePassword" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -185,7 +192,9 @@
|
|||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
import { useFullscreen } from '@vueuse/core'
|
import { useFullscreen } from '@vueuse/core'
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElIcon, ElMessageBox } from 'element-plus'
|
||||||
|
import { Key } from '@element-plus/icons-vue'
|
||||||
|
import ChangePasswordDialog from './ChangePasswordDialog.vue'
|
||||||
import { HOME_PAGE } from '@/router/routesAlias'
|
import { HOME_PAGE } from '@/router/routesAlias'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { mittBus } from '@/utils/sys'
|
import { mittBus } from '@/utils/sys'
|
||||||
@@ -217,6 +226,7 @@
|
|||||||
const { menuList } = storeToRefs(useMenuStore())
|
const { menuList } = storeToRefs(useMenuStore())
|
||||||
|
|
||||||
const showNotice = ref(false)
|
const showNotice = ref(false)
|
||||||
|
const showChangePassword = ref(false)
|
||||||
const userMenuPopover = ref()
|
const userMenuPopover = ref()
|
||||||
|
|
||||||
const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT)
|
const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT)
|
||||||
@@ -348,6 +358,11 @@
|
|||||||
mittBus.emit('openLockScreen')
|
mittBus.emit('openLockScreen')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openChangePassword = () => {
|
||||||
|
userMenuPopover.value?.hide()
|
||||||
|
showChangePassword.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const closeUserMenu = () => {
|
const closeUserMenu = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
userMenuPopover.value.hide()
|
userMenuPopover.value.hide()
|
||||||
@@ -358,4 +373,32 @@
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@use './style';
|
@use './style';
|
||||||
@use './mobile';
|
@use './mobile';
|
||||||
|
|
||||||
|
.password-menu-item {
|
||||||
|
padding: 0 !important;
|
||||||
|
|
||||||
|
.password-menu-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--el-color-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-menu-icon {
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 25px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--art-text-gray-800);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppConfig from '@/config'
|
import AppConfig from '@/config'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { formatDateTime } from '@/utils/business/format'
|
||||||
|
|
||||||
interface Comment {
|
interface Comment {
|
||||||
id: number
|
id: number
|
||||||
@@ -72,10 +73,7 @@
|
|||||||
emit('add-reply', props.comment.id, replyAuthor.value, replyContent.value)
|
emit('add-reply', props.comment.id, replyAuthor.value, replyContent.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (timestamp: string) => {
|
const formatDate = (timestamp: string) => formatDateTime(timestamp)
|
||||||
const date = new Date(timestamp)
|
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastColor: string | null = null
|
let lastColor: string | null = null
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,26 @@
|
|||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"userCenter": "User center",
|
"userCenter": "User center",
|
||||||
|
"changePassword": "Change password",
|
||||||
"docs": "Document",
|
"docs": "Document",
|
||||||
"github": "Github",
|
"github": "Github",
|
||||||
"lockScreen": "Lock screen",
|
"lockScreen": "Lock screen",
|
||||||
"logout": "Log out"
|
"logout": "Log out"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"changePassword": {
|
||||||
|
"title": "Change password",
|
||||||
|
"oldPassword": "Current password",
|
||||||
|
"newPassword": "New password",
|
||||||
|
"confirmPassword": "Confirm new password",
|
||||||
|
"oldPasswordPlaceholder": "Enter your current password",
|
||||||
|
"newPasswordPlaceholder": "Enter a new password (8–32 characters)",
|
||||||
|
"confirmPasswordPlaceholder": "Enter your new password again",
|
||||||
|
"passwordLength": "Password must be 8–32 characters long",
|
||||||
|
"passwordMismatch": "The new passwords do not match",
|
||||||
|
"success": "Password changed successfully",
|
||||||
|
"failed": "Unable to change password. Please try again"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"tips": "Prompt",
|
"tips": "Prompt",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|||||||
@@ -5,12 +5,26 @@
|
|||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"userCenter": "个人中心",
|
"userCenter": "个人中心",
|
||||||
|
"changePassword": "修改密码",
|
||||||
"docs": "使用文档",
|
"docs": "使用文档",
|
||||||
"github": "Github",
|
"github": "Github",
|
||||||
"lockScreen": "锁定屏幕",
|
"lockScreen": "锁定屏幕",
|
||||||
"logout": "退出登录"
|
"logout": "退出登录"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"changePassword": {
|
||||||
|
"title": "修改密码",
|
||||||
|
"oldPassword": "旧密码",
|
||||||
|
"newPassword": "新密码",
|
||||||
|
"confirmPassword": "确认新密码",
|
||||||
|
"oldPasswordPlaceholder": "请输入旧密码",
|
||||||
|
"newPasswordPlaceholder": "请输入 8–32 位新密码",
|
||||||
|
"confirmPasswordPlaceholder": "请再次输入新密码",
|
||||||
|
"passwordLength": "密码长度须为 8–32 位",
|
||||||
|
"passwordMismatch": "两次输入的新密码不一致",
|
||||||
|
"success": "密码修改成功",
|
||||||
|
"failed": "密码修改失败,请重试"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"tips": "提示",
|
"tips": "提示",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
|
|||||||
Binary file not shown.
@@ -63,7 +63,6 @@ export interface RefreshTokenData {
|
|||||||
|
|
||||||
// 修改密码参数
|
// 修改密码参数
|
||||||
export interface ChangePasswordParams {
|
export interface ChangePasswordParams {
|
||||||
oldPassword: string
|
old_password: string
|
||||||
newPassword: string
|
new_password: string
|
||||||
confirmPassword: string
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,23 @@ export interface WithdrawalRequestQueryParams extends PaginationParams {
|
|||||||
* 审批提现申请参数
|
* 审批提现申请参数
|
||||||
*/
|
*/
|
||||||
export interface ApproveWithdrawalParams {
|
export interface ApproveWithdrawalParams {
|
||||||
|
account_name?: string | null // 修正后的收款人姓名
|
||||||
|
account_number?: string | null // 修正后的收款账号
|
||||||
|
amount?: number | null // 修正后的提现金额(分)
|
||||||
|
payment_type: PaymentType // 放款类型
|
||||||
remark?: string // 审批备注
|
remark?: string // 审批备注
|
||||||
|
withdrawal_method?: WithdrawalMethod | null // 修正后的收款类型
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批提现申请响应
|
||||||
|
*/
|
||||||
|
export interface WithdrawalApprovalResponse {
|
||||||
|
id?: number // 提现申请ID
|
||||||
|
processed_at?: string // 处理时间
|
||||||
|
status?: WithdrawalStatus // 状态
|
||||||
|
status_name?: string // 状态名称
|
||||||
|
withdrawal_no?: string // 提现单号
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,19 @@ export enum RefundStatus {
|
|||||||
RETURNED = 4 // 已退回
|
RETURNED = 4 // 已退回
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 审批状态
|
||||||
|
export enum RefundApprovalStatus {
|
||||||
|
SUBMITTING = 0, // 提交中
|
||||||
|
APPROVING = 1, // 审批中
|
||||||
|
APPROVED = 2, // 已通过
|
||||||
|
REJECTED = 3, // 已拒绝
|
||||||
|
REVOKED = 4, // 已撤销
|
||||||
|
REVOKED_AFTER_APPROVED = 5, // 通过后撤销
|
||||||
|
DELETED = 6, // 已删除
|
||||||
|
SUBMIT_FAILED = 7, // 提交失败
|
||||||
|
RESULT_UNKNOWN = 8 // 提交结果未知
|
||||||
|
}
|
||||||
|
|
||||||
export interface RefundAttachment {
|
export interface RefundAttachment {
|
||||||
file_key: string
|
file_key: string
|
||||||
file_name?: string
|
file_name?: string
|
||||||
@@ -70,7 +83,7 @@ export interface Refund {
|
|||||||
approval_provider?: 'wecom' | null
|
approval_provider?: 'wecom' | null
|
||||||
approval_instance_id?: number | null
|
approval_instance_id?: number | null
|
||||||
approval_source?: 'none' | 'legacy' | 'wecom' | null // 审批来源
|
approval_source?: 'none' | 'legacy' | 'wecom' | null // 审批来源
|
||||||
approval_status?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | null // 审批状态
|
approval_status?: RefundApprovalStatus | null // 审批状态
|
||||||
approval_status_name?: string | null // 审批状态名称
|
approval_status_name?: string | null // 审批状态名称
|
||||||
current_approver_summary?: string | null // 当前审批人摘要
|
current_approver_summary?: string | null // 当前审批人摘要
|
||||||
processing_status?: string | null // 业务处理状态
|
processing_status?: string | null // 业务处理状态
|
||||||
|
|||||||
1
src/types/components.d.ts
vendored
1
src/types/components.d.ts
vendored
@@ -80,6 +80,7 @@ declare module 'vue' {
|
|||||||
BoxStyleSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue')['default']
|
BoxStyleSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue')['default']
|
||||||
CardOperationDialog: typeof import('./../components/business/CardOperationDialog.vue')['default']
|
CardOperationDialog: typeof import('./../components/business/CardOperationDialog.vue')['default']
|
||||||
CardStatusTag: typeof import('./../components/business/CardStatusTag.vue')['default']
|
CardStatusTag: typeof import('./../components/business/CardStatusTag.vue')['default']
|
||||||
|
ChangePasswordDialog: typeof import('./../components/core/layouts/art-header-bar/ChangePasswordDialog.vue')['default']
|
||||||
ChunkErrorBoundary: typeof import('./../components/core/others/ChunkErrorBoundary.vue')['default']
|
ChunkErrorBoundary: typeof import('./../components/core/others/ChunkErrorBoundary.vue')['default']
|
||||||
CodeGeneratorButton: typeof import('./../components/business/CodeGeneratorButton.vue')['default']
|
CodeGeneratorButton: typeof import('./../components/business/CodeGeneratorButton.vue')['default']
|
||||||
ColorSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/ColorSettings.vue')['default']
|
ColorSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/ColorSettings.vue')['default']
|
||||||
|
|||||||
@@ -168,9 +168,9 @@ export function formatDateRange(startDate: string, endDate: string): string {
|
|||||||
/**
|
/**
|
||||||
* 格式化日期时间
|
* 格式化日期时间
|
||||||
* 规则:
|
* 规则:
|
||||||
* 1. 带明确日期文本的字符串按原始时间文本展示,不跟随客户端本地时区转换
|
* 1. 带 Z 或 UTC 偏移量的字符串按绝对时刻换算为固定东八区(Asia/Shanghai)
|
||||||
* 2. 纯数字时间戳支持秒/毫秒自动识别
|
* 2. 不带时区的日期时间字符串按东八区时间文本展示
|
||||||
* 3. Date/时间戳这类“只有绝对时刻、没有原始展示时区”的值,按 UTC 稳定输出
|
* 3. 纯数字时间戳支持秒/毫秒自动识别,并按固定东八区展示
|
||||||
* @param date 日期字符串或时间戳(支持 Unix 时间戳秒/毫秒,自动检测)
|
* @param date 日期字符串或时间戳(支持 Unix 时间戳秒/毫秒,自动检测)
|
||||||
* @param format 格式化模板,默认 'YYYY-MM-DD HH:mm:ss'
|
* @param format 格式化模板,默认 'YYYY-MM-DD HH:mm:ss'
|
||||||
* @returns 格式化后的日期字符串
|
* @returns 格式化后的日期字符串
|
||||||
@@ -222,17 +222,37 @@ function normalizeTimestamp(timestamp: number): number {
|
|||||||
return timestamp
|
return timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUtcDateTimeParts(date: Date): DateTimeParts {
|
const DISPLAY_TIMEZONE = 'Asia/Shanghai'
|
||||||
|
const displayDateTimeFormatter = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: DISPLAY_TIMEZONE,
|
||||||
|
calendar: 'gregory',
|
||||||
|
numberingSystem: 'latn',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hourCycle: 'h23'
|
||||||
|
})
|
||||||
|
|
||||||
|
function getDisplayDateTimeParts(date: Date): DateTimeParts {
|
||||||
|
const parts = displayDateTimeFormatter.formatToParts(date)
|
||||||
|
const getPart = (type: Intl.DateTimeFormatPartTypes) =>
|
||||||
|
parts.find((part) => part.type === type)?.value ?? '00'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
year: String(date.getUTCFullYear()),
|
year: getPart('year'),
|
||||||
month: String(date.getUTCMonth() + 1).padStart(2, '0'),
|
month: getPart('month'),
|
||||||
day: String(date.getUTCDate()).padStart(2, '0'),
|
day: getPart('day'),
|
||||||
hours: String(date.getUTCHours()).padStart(2, '0'),
|
hours: getPart('hour'),
|
||||||
minutes: String(date.getUTCMinutes()).padStart(2, '0'),
|
minutes: getPart('minute'),
|
||||||
seconds: String(date.getUTCSeconds()).padStart(2, '0')
|
seconds: getPart('second')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EXPLICIT_TIMEZONE_SUFFIX_REGEX = /(?:Z|[+-]\d{2}:?\d{2})$/i
|
||||||
|
|
||||||
export function formatDateTime(
|
export function formatDateTime(
|
||||||
date: string | number | Date | undefined | null,
|
date: string | number | Date | undefined | null,
|
||||||
format: string = 'YYYY-MM-DD HH:mm:ss'
|
format: string = 'YYYY-MM-DD HH:mm:ss'
|
||||||
@@ -245,6 +265,11 @@ export function formatDateTime(
|
|||||||
|
|
||||||
const parsedText = parseDateTimeText(trimmedDate)
|
const parsedText = parseDateTimeText(trimmedDate)
|
||||||
if (parsedText) {
|
if (parsedText) {
|
||||||
|
if (EXPLICIT_TIMEZONE_SUFFIX_REGEX.test(trimmedDate)) {
|
||||||
|
const parsedDate = new Date(trimmedDate)
|
||||||
|
if (Number.isNaN(parsedDate.getTime())) return '-'
|
||||||
|
return formatDateTimeParts(getDisplayDateTimeParts(parsedDate), format)
|
||||||
|
}
|
||||||
return formatDateTimeParts(parsedText, format)
|
return formatDateTimeParts(parsedText, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,14 +277,14 @@ export function formatDateTime(
|
|||||||
if (!Number.isNaN(numericTimestamp)) {
|
if (!Number.isNaN(numericTimestamp)) {
|
||||||
const parsedDate = new Date(normalizeTimestamp(numericTimestamp))
|
const parsedDate = new Date(normalizeTimestamp(numericTimestamp))
|
||||||
if (Number.isNaN(parsedDate.getTime())) return '-'
|
if (Number.isNaN(parsedDate.getTime())) return '-'
|
||||||
return formatDateTimeParts(getUtcDateTimeParts(parsedDate), format)
|
return formatDateTimeParts(getDisplayDateTimeParts(parsedDate), format)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedDate = new Date(typeof date === 'number' ? normalizeTimestamp(date) : date)
|
const parsedDate = new Date(typeof date === 'number' ? normalizeTimestamp(date) : date)
|
||||||
if (Number.isNaN(parsedDate.getTime())) return '-'
|
if (Number.isNaN(parsedDate.getTime())) return '-'
|
||||||
|
|
||||||
return formatDateTimeParts(getUtcDateTimeParts(parsedDate), format)
|
return formatDateTimeParts(getDisplayDateTimeParts(parsedDate), format)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './format'
|
export * from './format'
|
||||||
|
export * from './realNameTime'
|
||||||
export * from './validate'
|
export * from './validate'
|
||||||
export * from './calculate'
|
export * from './calculate'
|
||||||
export * from './voucher'
|
export * from './voucher'
|
||||||
|
|||||||
18
src/utils/business/realNameTime.ts
Normal file
18
src/utils/business/realNameTime.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { formatDateTime } from './format'
|
||||||
|
|
||||||
|
const REAL_NAME_TIME_OFFSET_MS = 8 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实名时间在现有东八区展示逻辑的基础上额外增加 8 小时。
|
||||||
|
* 仅用于实名时间,其他时间字段继续使用 formatDateTime。
|
||||||
|
*/
|
||||||
|
export function formatRealNameTime(date: string | number | Date | undefined | null): string {
|
||||||
|
const formatted = formatDateTime(date)
|
||||||
|
if (formatted === '-') return '-'
|
||||||
|
|
||||||
|
// 先保留原格式化结果,再按明确的东八区偏移解析,避免依赖浏览器本地时区。
|
||||||
|
const displayDate = new Date(`${formatted.replace(' ', 'T')}+08:00`)
|
||||||
|
if (Number.isNaN(displayDate.getTime())) return '-'
|
||||||
|
|
||||||
|
return formatDateTime(new Date(displayDate.getTime() + REAL_NAME_TIME_OFFSET_MS))
|
||||||
|
}
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
* 数据格式化相关工具函数
|
* 数据格式化相关工具函数
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { formatDateTime } from '@/utils/business/format'
|
||||||
|
|
||||||
// 时间戳转时间
|
// 时间戳转时间
|
||||||
export function timestampToTime(timestamp: number = Date.now(), isMs: boolean = true): string {
|
export function timestampToTime(timestamp: number = Date.now(), isMs: boolean = true): string {
|
||||||
const date = new Date(isMs ? timestamp : timestamp * 1000)
|
return formatDateTime(isMs ? timestamp : timestamp * 1000)
|
||||||
return date.toISOString().replace('T', ' ').slice(0, 19)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 数字格式化(千位分隔符)
|
// 数字格式化(千位分隔符)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { normalizeApiError } from '@/utils/business/apiError'
|
|||||||
import { syncCompatibleIdFields } from '@/utils/business/id'
|
import { syncCompatibleIdFields } from '@/utils/business/id'
|
||||||
|
|
||||||
const axiosInstance = axios.create({
|
const axiosInstance = axios.create({
|
||||||
timeout: 15000, // 请求超时时间(毫秒)
|
timeout: 90000, // 请求超时时间(毫秒)
|
||||||
baseURL: import.meta.env.DEV ? '' : import.meta.env.VITE_API_URL, // 开发服务器使用代理,其他模式使用完整URL
|
baseURL: import.meta.env.DEV ? '' : import.meta.env.VITE_API_URL, // 开发服务器使用代理,其他模式使用完整URL
|
||||||
withCredentials: false, // 异步请求携带cookie
|
withCredentials: false, // 异步请求携带cookie
|
||||||
transformRequest: [
|
transformRequest: [
|
||||||
|
|||||||
@@ -592,7 +592,8 @@
|
|||||||
{
|
{
|
||||||
prop: 'username',
|
prop: 'username',
|
||||||
label: '账号名称',
|
label: '账号名称',
|
||||||
minWidth: 120
|
minWidth: 160,
|
||||||
|
showOverflowTooltip: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
prop: 'phone',
|
prop: 'phone',
|
||||||
@@ -617,6 +618,7 @@
|
|||||||
prop: 'shop_name',
|
prop: 'shop_name',
|
||||||
label: '店铺名称',
|
label: '店铺名称',
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
|
showOverflowTooltip: true,
|
||||||
formatter: (row: any) => {
|
formatter: (row: any) => {
|
||||||
return row.shop_name || '-'
|
return row.shop_name || '-'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,9 +117,7 @@
|
|||||||
<ElDescriptionsItem
|
<ElDescriptionsItem
|
||||||
label="实名时间"
|
label="实名时间"
|
||||||
label-class-name="asset-label asset-label--realname-time"
|
label-class-name="asset-label asset-label--realname-time"
|
||||||
>{{
|
>{{ formatRealNameTime(cardInfo?.real_name_at) }}</ElDescriptionsItem
|
||||||
cardInfo?.real_name_at ? formatAssetInfoTime(cardInfo.real_name_at) : '-'
|
|
||||||
}}</ElDescriptionsItem
|
|
||||||
>
|
>
|
||||||
<ElDescriptionsItem
|
<ElDescriptionsItem
|
||||||
label="实名同步时间"
|
label="实名同步时间"
|
||||||
@@ -254,11 +252,7 @@
|
|||||||
<ElDescriptionsItem
|
<ElDescriptionsItem
|
||||||
label="实名时间"
|
label="实名时间"
|
||||||
label-class-name="asset-label asset-label--realname-time"
|
label-class-name="asset-label asset-label--realname-time"
|
||||||
>{{
|
>{{ formatRealNameTime(selectedDeviceStatusCard?.real_name_at) }}</ElDescriptionsItem
|
||||||
selectedDeviceStatusCard?.real_name_at
|
|
||||||
? formatAssetInfoTime(selectedDeviceStatusCard.real_name_at)
|
|
||||||
: '-'
|
|
||||||
}}</ElDescriptionsItem
|
|
||||||
>
|
>
|
||||||
<ElDescriptionsItem
|
<ElDescriptionsItem
|
||||||
label="在线状态"
|
label="在线状态"
|
||||||
@@ -434,7 +428,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="binding-card-field">
|
<div class="binding-card-field">
|
||||||
<span class="binding-card-field-label">运营商</span>
|
<span class="binding-card-field-label">运营商</span>
|
||||||
<span class="binding-card-field-value">{{ getCarrierAccountDisplay(bindingCard) }}</span>
|
<span class="binding-card-field-value">{{
|
||||||
|
getCarrierAccountDisplay(bindingCard)
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="binding-card-field">
|
<div class="binding-card-field">
|
||||||
<span class="binding-card-field-label">卡槽位置</span>
|
<span class="binding-card-field-label">卡槽位置</span>
|
||||||
@@ -445,8 +441,8 @@
|
|||||||
<div class="binding-card-field">
|
<div class="binding-card-field">
|
||||||
<span class="binding-card-field-label">网络状态</span>
|
<span class="binding-card-field-label">网络状态</span>
|
||||||
<span class="binding-card-field-value">
|
<span class="binding-card-field-value">
|
||||||
<ElTag :type="bindingCard.network_status === 1 ? 'success' : 'danger'" size="small">
|
<ElTag :type="getNetworkStatusType(bindingCard.network_status)" size="small">
|
||||||
{{ bindingCard.network_status === 1 ? '正常' : '停机' }}
|
{{ getNetworkStatusName(bindingCard.network_status) }}
|
||||||
</ElTag>
|
</ElTag>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -467,11 +463,7 @@
|
|||||||
<div class="binding-card-field">
|
<div class="binding-card-field">
|
||||||
<span class="binding-card-field-label">实名时间</span>
|
<span class="binding-card-field-label">实名时间</span>
|
||||||
<span class="binding-card-field-value">
|
<span class="binding-card-field-value">
|
||||||
{{
|
{{ formatRealNameTime(bindingCard.real_name_at) }}
|
||||||
bindingCard.real_name_at
|
|
||||||
? formatAssetInfoTime(bindingCard.real_name_at)
|
|
||||||
: '-'
|
|
||||||
}}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="binding-card-field">
|
<div class="binding-card-field">
|
||||||
@@ -482,30 +474,28 @@
|
|||||||
|
|
||||||
<div class="binding-card-actions">
|
<div class="binding-card-actions">
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="bindingCard.network_status === 0"
|
v-if="bindingCard.network_status === 0 && hasAuth('iot_info:start')"
|
||||||
type="success"
|
type="success"
|
||||||
size="default"
|
size="default"
|
||||||
:disabled="isBindingStartDisabled(bindingCard)"
|
:disabled="isBindingStartDisabled(bindingCard)"
|
||||||
@click="handleEnableBindingCard(bindingCard)"
|
@click="handleEnableBindingCard(bindingCard)"
|
||||||
v-permission="'iot_info:start'"
|
|
||||||
>
|
>
|
||||||
{{ getBindingStartButtonText(bindingCard) }}
|
{{ getBindingStartButtonText(bindingCard) }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="bindingCard.network_status === 1"
|
v-if="bindingCard.network_status === 1 && hasAuth('iot_info:stop')"
|
||||||
type="warning"
|
type="warning"
|
||||||
size="default"
|
size="default"
|
||||||
:disabled="isBindingStopDisabled(bindingCard)"
|
:disabled="isBindingStopDisabled(bindingCard)"
|
||||||
@click="handleDisableBindingCard(bindingCard)"
|
@click="handleDisableBindingCard(bindingCard)"
|
||||||
v-permission="'iot_info:stop'"
|
|
||||||
>
|
>
|
||||||
{{ getBindingStopButtonText(bindingCard) }}
|
{{ getBindingStopButtonText(bindingCard) }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
|
v-if="hasAuth('bound_card:update_realname_status')"
|
||||||
type="primary"
|
type="primary"
|
||||||
size="default"
|
size="default"
|
||||||
@click="handleUpdateBindingCardRealnameStatus(bindingCard)"
|
@click="handleUpdateBindingCardRealnameStatus(bindingCard)"
|
||||||
v-permission="'bound_card:update_realname_status'"
|
|
||||||
>
|
>
|
||||||
更新实名状态
|
更新实名状态
|
||||||
</ElButton>
|
</ElButton>
|
||||||
@@ -570,8 +560,7 @@
|
|||||||
style="margin-top: 16px; text-align: right"
|
style="margin-top: 16px; text-align: right"
|
||||||
>
|
>
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="cardInfo?.network_status === 0"
|
v-if="cardInfo?.network_status === 0 && hasAuth('iot_info:start')"
|
||||||
v-permission="'iot_info:start'"
|
|
||||||
type="success"
|
type="success"
|
||||||
:disabled="startDisabled"
|
:disabled="startDisabled"
|
||||||
@click="handleEnableCard"
|
@click="handleEnableCard"
|
||||||
@@ -579,8 +568,7 @@
|
|||||||
{{ startRemainingText ? `启用此卡(${startRemainingText})` : '启用此卡' }}
|
{{ startRemainingText ? `启用此卡(${startRemainingText})` : '启用此卡' }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="cardInfo?.network_status === 1"
|
v-if="cardInfo?.network_status === 1 && hasAuth('iot_info:stop')"
|
||||||
v-permission="'iot_info:stop'"
|
|
||||||
type="warning"
|
type="warning"
|
||||||
:disabled="stopDisabled"
|
:disabled="stopDisabled"
|
||||||
@click="handleDisableCard"
|
@click="handleDisableCard"
|
||||||
@@ -591,15 +579,14 @@
|
|||||||
<!-- 手动停用暂时不开放 -->
|
<!-- 手动停用暂时不开放 -->
|
||||||
<!--suppress VueUnrecognizedDirective -->
|
<!--suppress VueUnrecognizedDirective -->
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="canShowRealnamePolicy"
|
v-if="canShowRealnamePolicy && hasAuth('asset:card_realname_policy')"
|
||||||
v-permission="'asset:card_realname_policy'"
|
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="handleShowRealnamePolicy"
|
@click="handleShowRealnamePolicy"
|
||||||
>
|
>
|
||||||
实名认证策略
|
实名认证策略
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
v-permission="'iot_info:update_realname_status'"
|
v-if="hasAuth('iot_info:update_realname_status')"
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="handleShowUpdateRealnameStatus"
|
@click="handleShowUpdateRealnameStatus"
|
||||||
>
|
>
|
||||||
@@ -609,28 +596,27 @@
|
|||||||
|
|
||||||
<!-- 设备操作按钮 -->
|
<!-- 设备操作按钮 -->
|
||||||
<div v-else class="device-operations" style="margin-top: 16px; text-align: right">
|
<div v-else class="device-operations" style="margin-top: 16px; text-align: right">
|
||||||
<ElButton v-permission="'device_info:switch_mode'" type="info" @click="handleShowSwitchMode"
|
<ElButton v-if="hasAuth('device_info:switch_mode')" type="info" @click="handleShowSwitchMode"
|
||||||
>设置切卡模式</ElButton
|
>设置切卡模式</ElButton
|
||||||
>
|
>
|
||||||
<ElButton v-permission="'device_info:reboot'" type="primary" @click="handleRebootDevice">
|
<ElButton v-if="hasAuth('device_info:reboot')" type="primary" @click="handleRebootDevice">
|
||||||
重启设备
|
重启设备
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton v-permission="'device_info:reset'" type="warning" @click="handleResetDevice">
|
<ElButton v-if="hasAuth('device_info:reset')" type="warning" @click="handleResetDevice">
|
||||||
恢复出厂
|
恢复出厂
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
v-permission="'device_info:switch_card'"
|
v-if="hasAuth('device_info:switch_card')"
|
||||||
type="success"
|
type="success"
|
||||||
@click="handleShowSwitchCard"
|
@click="handleShowSwitchCard"
|
||||||
>
|
>
|
||||||
切换SIM卡
|
切换SIM卡
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton v-permission="'device_info:set_wifi'" type="primary" @click="handleShowSetWiFi">
|
<ElButton v-if="hasAuth('device_info:set_wifi')" type="primary" @click="handleShowSetWiFi">
|
||||||
设置WiFi
|
设置WiFi
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="canShowRealnamePolicy"
|
v-if="canShowRealnamePolicy && hasAuth('asset_info:device_realname_policy')"
|
||||||
v-permission="'asset_info:device_realname_policy'"
|
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="handleShowRealnamePolicy"
|
@click="handleShowRealnamePolicy"
|
||||||
>
|
>
|
||||||
@@ -669,6 +655,7 @@
|
|||||||
import { JULY_PERMISSIONS } from '@/config/constants'
|
import { JULY_PERMISSIONS } from '@/config/constants'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
import { formatDateTime } from '@/utils/business/format'
|
import { formatDateTime } from '@/utils/business/format'
|
||||||
|
import { formatRealNameTime } from '@/utils/business/realNameTime'
|
||||||
import {
|
import {
|
||||||
getExpiryEstimateText,
|
getExpiryEstimateText,
|
||||||
getExpiryEstimateTooltip,
|
getExpiryEstimateTooltip,
|
||||||
@@ -888,10 +875,7 @@
|
|||||||
return !isAgentOrEnterpriseAccount.value
|
return !isAgentOrEnterpriseAccount.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const getCarrierAccountDisplay = (card?: {
|
const getCarrierAccountDisplay = (card?: { carrier_type?: string; carrier_name?: string }) => {
|
||||||
carrier_type?: string
|
|
||||||
carrier_name?: string
|
|
||||||
}) => {
|
|
||||||
if (isAgentOrEnterpriseAccount.value) {
|
if (isAgentOrEnterpriseAccount.value) {
|
||||||
return getCarrierTypeName(card?.carrier_type)
|
return getCarrierTypeName(card?.carrier_type)
|
||||||
}
|
}
|
||||||
@@ -936,6 +920,8 @@
|
|||||||
formatDuration,
|
formatDuration,
|
||||||
getRealNameStatusName,
|
getRealNameStatusName,
|
||||||
getRealNameStatusType,
|
getRealNameStatusType,
|
||||||
|
getNetworkStatusName,
|
||||||
|
getNetworkStatusType,
|
||||||
getRealnamePolicyName,
|
getRealnamePolicyName,
|
||||||
getCarrierTypeName,
|
getCarrierTypeName,
|
||||||
getAssetStatusName,
|
getAssetStatusName,
|
||||||
@@ -1144,33 +1130,7 @@
|
|||||||
return formatDataSize(value)
|
return formatDataSize(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const formatAssetInfoTime = (value?: string | null) => formatDateTime(value)
|
||||||
* 接口中的这几项时间以其原始文本为基准直接加 8 小时;时区后缀不参与换算。
|
|
||||||
* 例如 2026-08-06T11:54:26.693382+08:00 显示为 2026-08-06 19:54:26。
|
|
||||||
*/
|
|
||||||
const formatAssetInfoTime = (value?: string | null) => {
|
|
||||||
if (!value?.trim()) return '-'
|
|
||||||
|
|
||||||
const timeText = value.trim()
|
|
||||||
const match = timeText.match(
|
|
||||||
/^(\d{4})-(\d{2})-(\d{2})(?:T|\s)(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/i
|
|
||||||
)
|
|
||||||
if (!match) return '-'
|
|
||||||
|
|
||||||
const [, year, month, day, hours, minutes, seconds = '00'] = match
|
|
||||||
const date = new Date(
|
|
||||||
Date.UTC(
|
|
||||||
Number(year),
|
|
||||||
Number(month) - 1,
|
|
||||||
Number(day),
|
|
||||||
Number(hours) + 8,
|
|
||||||
Number(minutes),
|
|
||||||
Number(seconds)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return formatDateTime(date)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理轮询开关变化
|
// 处理轮询开关变化
|
||||||
const handlePollingChange = (value: string | number | boolean) => {
|
const handlePollingChange = (value: string | number | boolean) => {
|
||||||
|
|||||||
@@ -68,6 +68,19 @@ export function useAssetFormatters() {
|
|||||||
return map[status] || 'info'
|
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) => {
|
const getRealnamePolicyName = (policy?: string) => {
|
||||||
if (!policy || policy === '') return '未知'
|
if (!policy || policy === '') return '未知'
|
||||||
@@ -261,6 +274,8 @@ export function useAssetFormatters() {
|
|||||||
formatIccidWithDashes,
|
formatIccidWithDashes,
|
||||||
getRealNameStatusName,
|
getRealNameStatusName,
|
||||||
getRealNameStatusType,
|
getRealNameStatusType,
|
||||||
|
getNetworkStatusName,
|
||||||
|
getNetworkStatusType,
|
||||||
getRealnamePolicyName,
|
getRealnamePolicyName,
|
||||||
getCarrierTypeName,
|
getCarrierTypeName,
|
||||||
getAssetStatusName,
|
getAssetStatusName,
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
* 负责资产详情数据的加载和状态管理
|
* 负责资产详情数据的加载和状态管理
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref } from 'vue'
|
import { getCurrentScope, onScopeDispose, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { AssetService } from '@/api/modules'
|
import { AssetService } from '@/api/modules'
|
||||||
|
import { useUserStore } from '@/store/modules/user'
|
||||||
import { FrontendRateLimitError } from '@/utils/business/apiRateLimit'
|
import { FrontendRateLimitError } from '@/utils/business/apiRateLimit'
|
||||||
|
import { normalizeApiError } from '@/utils/business/apiError'
|
||||||
import type {
|
import type {
|
||||||
AssetBoundCard,
|
AssetBoundCard,
|
||||||
AssetWalletResponse,
|
AssetWalletResponse,
|
||||||
@@ -25,13 +27,66 @@ const normalizeOptionalText = (value?: string | null) =>
|
|||||||
typeof value === 'string' ? value.trim() : ''
|
typeof value === 'string' ? value.trim() : ''
|
||||||
|
|
||||||
const NULLABLE_REALTIME_CARD_FIELDS = new Set([
|
const NULLABLE_REALTIME_CARD_FIELDS = new Set([
|
||||||
|
'gateway_extend',
|
||||||
'last_data_check_at',
|
'last_data_check_at',
|
||||||
'last_real_name_check_at',
|
'last_real_name_check_at',
|
||||||
'last_card_status_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
|
||||||
|
])
|
||||||
|
const ASSET_NOT_FOUND_CODE = 1006
|
||||||
|
|
||||||
|
const isLegacyNineteenDigitIdentifier = (identifier: string) => /^89\d{17}$/.test(identifier)
|
||||||
|
|
||||||
|
const isAssetNotFound = (value: any) =>
|
||||||
|
value?.code === ASSET_NOT_FOUND_CODE || value?.response?.data?.code === ASSET_NOT_FOUND_CODE
|
||||||
|
|
||||||
|
const isUnauthorized = (value: any) =>
|
||||||
|
value?.code === 401 || value?.response?.status === 401 || value?.response?.data?.code === 401
|
||||||
|
|
||||||
|
const calculateLuhnCheckDigit = (identifier: string) => {
|
||||||
|
let sum = 0
|
||||||
|
let doubleDigit = true
|
||||||
|
|
||||||
|
for (let index = identifier.length - 1; index >= 0; index -= 1) {
|
||||||
|
let digit = Number(identifier[index])
|
||||||
|
if (doubleDigit) {
|
||||||
|
digit *= 2
|
||||||
|
if (digit > 9) digit -= 9
|
||||||
|
}
|
||||||
|
sum += digit
|
||||||
|
doubleDigit = !doubleDigit
|
||||||
|
}
|
||||||
|
|
||||||
|
return String((10 - (sum % 10)) % 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveAssetForAssetInfo = async (identifier: string) => {
|
||||||
|
const params = { include_usage_summary: true }
|
||||||
|
if (!isLegacyNineteenDigitIdentifier(identifier)) {
|
||||||
|
return AssetService.resolveAsset(identifier, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
const silentConfig = { requestOptions: { errorMessageMode: 'none' as const } }
|
||||||
|
let firstResponse = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
firstResponse = await AssetService.resolveAsset(identifier, params, silentConfig)
|
||||||
|
} catch (error) {
|
||||||
|
if (!isAssetNotFound(error)) throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstResponse && !isAssetNotFound(firstResponse)) return firstResponse
|
||||||
|
|
||||||
|
const correctedIdentifier = `${identifier}${calculateLuhnCheckDigit(identifier)}`
|
||||||
|
return AssetService.resolveAsset(correctedIdentifier, params, silentConfig)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化数据大小(MB -> GB/MB)
|
* 格式化数据大小(MB -> GB/MB)
|
||||||
@@ -60,22 +115,24 @@ const getDisplayedUsagePercentage = (traffic?: TrafficDisplaySource | null) => {
|
|||||||
const mergeAssetBoundCards = (
|
const mergeAssetBoundCards = (
|
||||||
existingCards: AssetBoundCard[] = [],
|
existingCards: AssetBoundCard[] = [],
|
||||||
realtimeCards: AssetBoundCard[] = []
|
realtimeCards: AssetBoundCard[] = []
|
||||||
): AssetBoundCard[] =>
|
): AssetBoundCard[] => {
|
||||||
realtimeCards.map((realtimeCard) => {
|
const realtimeByIccid = new Map(realtimeCards.map((card) => [card.iccid, card]))
|
||||||
const existingCard = existingCards.find((card) => card.iccid === realtimeCard.iccid)
|
const currentCard = existingCards.find(
|
||||||
if (!existingCard) {
|
(card) => realtimeByIccid.get(card.iccid)?.is_current === true
|
||||||
return { ...realtimeCard, real_name_at: null }
|
)
|
||||||
}
|
|
||||||
|
|
||||||
|
return existingCards.map((existingCard) => {
|
||||||
const mergedCard = { ...existingCard } as unknown as Record<string, unknown>
|
const mergedCard = { ...existingCard } as unknown as Record<string, unknown>
|
||||||
for (const [key, value] of Object.entries(realtimeCard)) {
|
for (const [key, value] of Object.entries(realtimeByIccid.get(existingCard.iccid) || {})) {
|
||||||
if (DETAIL_ONLY_BOUND_CARD_FIELDS.has(key)) continue
|
if (!REALTIME_BOUND_CARD_FIELDS.has(key)) continue
|
||||||
if (value !== undefined && (value !== null || NULLABLE_REALTIME_CARD_FIELDS.has(key))) {
|
if (value !== undefined && (value !== null || NULLABLE_REALTIME_CARD_FIELDS.has(key))) {
|
||||||
mergedCard[key] = value
|
mergedCard[key] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (currentCard) mergedCard.is_current = existingCard.iccid === currentCard.iccid
|
||||||
return mergedCard as unknown as AssetBoundCard
|
return mergedCard as unknown as AssetBoundCard
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useAssetInfo() {
|
export function useAssetInfo() {
|
||||||
// 状态管理
|
// 状态管理
|
||||||
@@ -90,22 +147,94 @@ export function useAssetInfo() {
|
|||||||
const walletLoading = ref(false)
|
const walletLoading = ref(false)
|
||||||
const packageLoading = ref(false)
|
const packageLoading = ref(false)
|
||||||
const realtimeLoading = 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)
|
* @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
|
||||||
|
const shouldShowSilentRequestError = isLegacyNineteenDigitIdentifier(identifier)
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const response = await AssetService.resolveAsset(identifier, { include_usage_summary: true })
|
const response = await resolveAssetForAssetInfo(identifier)
|
||||||
|
if (version !== assetVersion) return null
|
||||||
|
|
||||||
if (response.code === 0 && response.data) {
|
if (response.code === 0 && response.data) {
|
||||||
const data = response.data
|
const data = response.data
|
||||||
const resolvedGatewayCardImei = normalizeOptionalText(data.gateway_card_imei)
|
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数据到页面显示格式
|
// 映射API数据到页面显示格式
|
||||||
cardInfo.value = {
|
cardInfo.value = {
|
||||||
@@ -167,7 +296,7 @@ export function useAssetInfo() {
|
|||||||
sn: data.sn || '',
|
sn: data.sn || '',
|
||||||
max_sim_slots: data.max_sim_slots || 0,
|
max_sim_slots: data.max_sim_slots || 0,
|
||||||
bound_card_count: data.bound_card_count || 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,
|
device_protect_status: data.device_protect_status,
|
||||||
online_status: data.online_status,
|
online_status: data.online_status,
|
||||||
last_online_time: data.last_online_time,
|
last_online_time: data.last_online_time,
|
||||||
@@ -195,15 +324,22 @@ export function useAssetInfo() {
|
|||||||
loadAssetWallet(assetIdentifier)
|
loadAssetWallet(assetIdentifier)
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
return version === assetVersion ? cardInfo.value : null
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(response.msg || '查询失败')
|
ElMessage.error(response.msg || '查询失败')
|
||||||
cardInfo.value = null
|
cardInfo.value = null
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error: any) {
|
||||||
cardInfo.value = null
|
if (version !== assetVersion) return null
|
||||||
|
resetAssetInfo()
|
||||||
|
if (shouldShowSilentRequestError && !isUnauthorized(error)) {
|
||||||
|
const backendMessage = error?.response?.data?.msg
|
||||||
|
ElMessage.error(backendMessage || normalizeApiError(error).message)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (version === assetVersion) loading.value = false
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -213,12 +349,17 @@ export function useAssetInfo() {
|
|||||||
* @param pageSize - 每页条数
|
* @param pageSize - 每页条数
|
||||||
*/
|
*/
|
||||||
const loadPackageList = async (identifier: string, page: number = 1, pageSize: number = 10) => {
|
const loadPackageList = async (identifier: string, page: number = 1, pageSize: number = 10) => {
|
||||||
|
const isCurrentAsset = captureAssetRequest(identifier)
|
||||||
|
if (!isCurrentAsset()) return null
|
||||||
|
const requestId = ++packageRequestId
|
||||||
|
const isCurrent = () => isCurrentAsset() && requestId === packageRequestId
|
||||||
try {
|
try {
|
||||||
packageLoading.value = true
|
packageLoading.value = true
|
||||||
const response = await AssetService.getAssetPackages(identifier, {
|
const response = await AssetService.getAssetPackages(identifier, {
|
||||||
page,
|
page,
|
||||||
page_size: pageSize
|
page_size: pageSize
|
||||||
})
|
})
|
||||||
|
if (!isCurrent()) return null
|
||||||
if (response.code === 0 && response.data) {
|
if (response.code === 0 && response.data) {
|
||||||
// 使用分页响应中的 items 数组
|
// 使用分页响应中的 items 数组
|
||||||
packageList.value = response.data.items || []
|
packageList.value = response.data.items || []
|
||||||
@@ -230,12 +371,15 @@ export function useAssetInfo() {
|
|||||||
pageSize: response.data.page_size || pageSize
|
pageSize: response.data.page_size || pageSize
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
packageList.value = []
|
||||||
return { items: [], total: 0, page: 1, pageSize }
|
return { items: [], total: 0, page: 1, pageSize }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!isCurrent()) return null
|
||||||
console.error('获取套餐列表失败:', error)
|
console.error('获取套餐列表失败:', error)
|
||||||
|
packageList.value = []
|
||||||
return { items: [], total: 0, page: 1, pageSize }
|
return { items: [], total: 0, page: 1, pageSize }
|
||||||
} finally {
|
} finally {
|
||||||
packageLoading.value = false
|
if (isCurrent()) packageLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,11 +388,16 @@ export function useAssetInfo() {
|
|||||||
* @param identifier - 资产标识符
|
* @param identifier - 资产标识符
|
||||||
*/
|
*/
|
||||||
const loadCurrentPackage = async (identifier: string) => {
|
const loadCurrentPackage = async (identifier: string) => {
|
||||||
|
const isCurrentAsset = captureAssetRequest(identifier)
|
||||||
|
if (!isCurrentAsset()) return
|
||||||
|
const requestId = ++currentPackageRequestId
|
||||||
|
const isCurrent = () => isCurrentAsset() && requestId === currentPackageRequestId
|
||||||
try {
|
try {
|
||||||
currentPackageLoading.value = true
|
currentPackageLoading.value = true
|
||||||
currentPackageErrorMsg.value = ''
|
currentPackageErrorMsg.value = ''
|
||||||
|
|
||||||
const response = await AssetService.getCurrentPackage(identifier)
|
const response = await AssetService.getCurrentPackage(identifier)
|
||||||
|
if (!isCurrent()) return
|
||||||
|
|
||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
@@ -297,6 +446,7 @@ export function useAssetInfo() {
|
|||||||
currentPackageErrorMsg.value = response.msg || '获取当前套餐失败'
|
currentPackageErrorMsg.value = response.msg || '获取当前套餐失败'
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (!isCurrent()) return
|
||||||
// 404表示无当前生效套餐,这是正常情况
|
// 404表示无当前生效套餐,这是正常情况
|
||||||
currentPackage.value = null
|
currentPackage.value = null
|
||||||
if (error?.response?.status === 404) {
|
if (error?.response?.status === 404) {
|
||||||
@@ -306,7 +456,7 @@ export function useAssetInfo() {
|
|||||||
currentPackageErrorMsg.value = error?.message || '获取当前套餐失败'
|
currentPackageErrorMsg.value = error?.message || '获取当前套餐失败'
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
currentPackageLoading.value = false
|
if (isCurrent()) currentPackageLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,11 +466,22 @@ export function useAssetInfo() {
|
|||||||
* @param assetType - 资产类型(card/device)
|
* @param assetType - 资产类型(card/device)
|
||||||
*/
|
*/
|
||||||
const loadRealtimeStatus = async (identifier: string, assetType: string) => {
|
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 {
|
try {
|
||||||
realtimeLoading.value = true
|
realtimeLoading.value = true
|
||||||
const response = await AssetService.getRealtimeStatus(identifier)
|
const response = await AssetService.getRealtimeStatus(identifier)
|
||||||
|
if (!isCurrent()) return
|
||||||
if (response.code === 0 && response.data) {
|
if (response.code === 0 && response.data) {
|
||||||
const data = 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') {
|
if (assetType === 'card') {
|
||||||
// 卡的实时状态
|
// 卡的实时状态
|
||||||
@@ -361,8 +522,7 @@ export function useAssetInfo() {
|
|||||||
cardInfo.value.device_protect_status = data.device_protect_status
|
cardInfo.value.device_protect_status = data.device_protect_status
|
||||||
}
|
}
|
||||||
if (data.cards && data.cards.length > 0) {
|
if (data.cards && data.cards.length > 0) {
|
||||||
// 合并实时卡数据与原始卡数据,保留 real_name_at 等静态字段。
|
// 实时 cards 可能缺少部分卡或静态字段,不能替换详情里的完整绑定列表。
|
||||||
// 设备详情里的实名状态、实名时间依赖选中的绑定卡,因此不能在实时刷新时丢掉这些字段。
|
|
||||||
cardInfo.value.cards = mergeAssetBoundCards(cardInfo.value.cards || [], data.cards)
|
cardInfo.value.cards = mergeAssetBoundCards(cardInfo.value.cards || [], data.cards)
|
||||||
}
|
}
|
||||||
if (data.last_data_check_at !== undefined) {
|
if (data.last_data_check_at !== undefined) {
|
||||||
@@ -396,9 +556,9 @@ export function useAssetInfo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取实时状态失败:', error)
|
if (isCurrent()) console.error('获取实时状态失败:', error)
|
||||||
} finally {
|
} finally {
|
||||||
realtimeLoading.value = false
|
if (isCurrent()) realtimeLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,13 +567,22 @@ export function useAssetInfo() {
|
|||||||
* @param identifier - 资产标识符
|
* @param identifier - 资产标识符
|
||||||
*/
|
*/
|
||||||
const loadAssetWallet = async (identifier: string) => {
|
const loadAssetWallet = async (identifier: string) => {
|
||||||
|
const isCurrentAsset = captureAssetRequest(identifier)
|
||||||
|
if (!isCurrentAsset()) return
|
||||||
|
const requestId = ++walletRequestId
|
||||||
|
const isCurrent = () => isCurrentAsset() && requestId === walletRequestId
|
||||||
try {
|
try {
|
||||||
walletLoading.value = true
|
walletLoading.value = true
|
||||||
const response = await AssetService.getAssetWallet(identifier)
|
const response = await AssetService.getAssetWallet(identifier)
|
||||||
|
if (!isCurrent()) return
|
||||||
if (response.code === 0 && response.data) {
|
if (response.code === 0 && response.data) {
|
||||||
walletInfo.value = response.data
|
walletInfo.value = response.data
|
||||||
|
} else {
|
||||||
|
walletInfo.value = null
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (!isCurrent()) return
|
||||||
|
walletInfo.value = null
|
||||||
if (error?.response?.status === 403) {
|
if (error?.response?.status === 403) {
|
||||||
// 企业账号禁止查看钱包信息,不显示错误
|
// 企业账号禁止查看钱包信息,不显示错误
|
||||||
walletInfo.value = null
|
walletInfo.value = null
|
||||||
@@ -421,7 +590,7 @@ export function useAssetInfo() {
|
|||||||
console.error('获取钱包概况失败:', error)
|
console.error('获取钱包概况失败:', error)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
walletLoading.value = false
|
if (isCurrent()) walletLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,16 +601,18 @@ export function useAssetInfo() {
|
|||||||
* @returns true if refresh was successful, false if rate limited
|
* @returns true if refresh was successful, false if rate limited
|
||||||
*/
|
*/
|
||||||
const refreshAsset = async (identifier: string, assetType: string): Promise<boolean> => {
|
const refreshAsset = async (identifier: string, assetType: string): Promise<boolean> => {
|
||||||
|
const isCurrent = captureAssetRequest(identifier)
|
||||||
|
if (!isCurrent() || cardInfo.value.asset_type !== assetType) return false
|
||||||
try {
|
try {
|
||||||
|
// 主动同步网关的接口仅允许由右上角“同步”按钮触发,搜索和普通数据重载不能调用。
|
||||||
await AssetService.refreshAsset(identifier)
|
await AssetService.refreshAsset(identifier)
|
||||||
|
if (!isCurrent()) return false
|
||||||
ElMessage.success('刷新成功')
|
ElMessage.success('刷新成功')
|
||||||
|
|
||||||
// 刷新成功后重新加载实时状态数据
|
// 绑定/解绑和实名时间由详情接口确认,不能只刷新实时状态而保留过期绑定关系。
|
||||||
if (identifier) {
|
return Boolean(await fetchAssetDetail(identifier, { preservePackageList: true }))
|
||||||
await loadRealtimeStatus(identifier, assetType)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (!isCurrent()) return false
|
||||||
if (error instanceof FrontendRateLimitError) {
|
if (error instanceof FrontendRateLimitError) {
|
||||||
ElMessage.warning('操作过于频繁,请稍后重试')
|
ElMessage.warning('操作过于频繁,请稍后重试')
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<!-- 第一层:资产概览与当前生效套餐,按信息高度纵向展开 -->
|
<!-- 第一层:资产概览与当前生效套餐,按信息高度纵向展开 -->
|
||||||
<div class="row overview-primary">
|
<div class="row overview-primary">
|
||||||
<BasicInfoCard
|
<BasicInfoCard
|
||||||
|
:key="`${cardInfo.asset_type}:${cardInfo.asset_id}`"
|
||||||
:card-info="cardInfo"
|
:card-info="cardInfo"
|
||||||
:device-realtime="deviceRealtime"
|
:device-realtime="deviceRealtime"
|
||||||
v-model:polling-enabled="pollingEnabled"
|
v-model:polling-enabled="pollingEnabled"
|
||||||
@@ -296,6 +297,8 @@
|
|||||||
const speedTierSubmitting = ref(false)
|
const speedTierSubmitting = ref(false)
|
||||||
const bindingCardRealnameStatusIccid = ref('')
|
const bindingCardRealnameStatusIccid = ref('')
|
||||||
const bindingCardRealnameStatusValue = ref<number>(0)
|
const bindingCardRealnameStatusValue = ref<number>(0)
|
||||||
|
let realnameStatusAsset: any = null
|
||||||
|
let bindingCardRealnameStatusAsset: any = null
|
||||||
|
|
||||||
const switchModeCards = computed<SwitchModeCardOption[]>(() =>
|
const switchModeCards = computed<SwitchModeCardOption[]>(() =>
|
||||||
(cardInfo.value?.cards || [])
|
(cardInfo.value?.cards || [])
|
||||||
@@ -402,34 +405,32 @@
|
|||||||
/**
|
/**
|
||||||
* 处理资产搜索
|
* 处理资产搜索
|
||||||
*/
|
*/
|
||||||
|
let searchRequestId = 0
|
||||||
const handleSearch = async ({ identifier }: { identifier: string }) => {
|
const handleSearch = async ({ identifier }: { identifier: string }) => {
|
||||||
|
const requestId = ++searchRequestId
|
||||||
// 更新搜索框的值
|
// 更新搜索框的值
|
||||||
assetSearchCardRef.value?.updateSearchValue(identifier)
|
assetSearchCardRef.value?.updateSearchValue(identifier)
|
||||||
startFailureMessage.value = ''
|
startFailureMessage.value = ''
|
||||||
|
packagePagination.value.page = 1
|
||||||
|
packagePagination.value.total = 0
|
||||||
|
|
||||||
// fetchAssetDetail 内部已经会并行加载所有相关数据,不需要重复调用
|
// resolve 已返回完整详情;设备不再重复搜索,避免多轮响应相互覆盖。
|
||||||
await fetchAssetDetail(identifier)
|
const asset = await fetchAssetDetail(identifier)
|
||||||
|
if (requestId !== searchRequestId || !asset || cardInfo.value !== asset) return
|
||||||
// 如果是设备类型,需要再次调用搜索接口获取完整数据
|
walletTransactionSearchToken.value += 1
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从接口数据初始化轮询开关,不触发更新接口
|
// 从接口数据初始化轮询开关,不触发更新接口
|
||||||
pollingInitializing = true
|
pollingInitializing = true
|
||||||
pollingEnabled.value = cardInfo.value?.enable_polling ?? false
|
pollingEnabled.value = asset.enable_polling ?? false
|
||||||
await nextTick()
|
await nextTick()
|
||||||
pollingInitializing = false
|
pollingInitializing = false
|
||||||
|
if (requestId !== searchRequestId || cardInfo.value !== asset) return
|
||||||
|
|
||||||
// 重置分页并加载套餐列表
|
// 套餐也使用解析后的规范标识符,支持通过 ICCID、IMEI、SN 等入口查同一设备。
|
||||||
packagePagination.value.page = 1
|
const result = await loadPackageList(asset.identifier, 1, packagePagination.value.pageSize)
|
||||||
const result = await loadPackageList(identifier, 1, packagePagination.value.pageSize)
|
if (result && requestId === searchRequestId && cardInfo.value === asset) {
|
||||||
packagePagination.value.total = result.total
|
packagePagination.value.total = result.total
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -455,7 +456,7 @@
|
|||||||
try {
|
try {
|
||||||
const started = await enableCardOp()
|
const started = await enableCardOp()
|
||||||
if (started && cardInfo.value) {
|
if (started && cardInfo.value) {
|
||||||
await fetchAssetDetail(cardInfo.value.identifier)
|
await fetchAssetDetail(cardInfo.value.identifier, { preservePackageList: true })
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
updateRateLimitNow()
|
updateRateLimitNow()
|
||||||
@@ -568,18 +569,24 @@
|
|||||||
* 显示更新实名状态对话框
|
* 显示更新实名状态对话框
|
||||||
*/
|
*/
|
||||||
const showUpdateRealnameStatusDialog = () => {
|
const showUpdateRealnameStatusDialog = () => {
|
||||||
|
realnameStatusAsset = cardInfo.value
|
||||||
updateRealnameStatusDialogVisible.value = true
|
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 }) => {
|
const handleUpdateBindingCardRealnameStatus = (payload: { card: any }) => {
|
||||||
|
bindingCardRealnameStatusAsset = cardInfo.value
|
||||||
bindingCardRealnameStatusIccid.value = payload.card.iccid
|
bindingCardRealnameStatusIccid.value = payload.card.iccid
|
||||||
bindingCardRealnameStatusValue.value = payload.card.real_name_status ?? 0
|
bindingCardRealnameStatusValue.value = payload.card.real_name_status ?? 0
|
||||||
bindingCardRealnameStatusDialogVisible.value = true
|
bindingCardRealnameStatusDialogVisible.value = true
|
||||||
@@ -588,7 +595,11 @@
|
|||||||
/**
|
/**
|
||||||
* 绑定卡更新实名状态成功
|
* 绑定卡更新实名状态成功
|
||||||
*/
|
*/
|
||||||
const handleBindingCardRealnameStatusSuccess = async () => {}
|
const handleBindingCardRealnameStatusSuccess = async () => {
|
||||||
|
if (bindingCardRealnameStatusAsset && cardInfo.value === bindingCardRealnameStatusAsset) {
|
||||||
|
await fetchAssetDetail(bindingCardRealnameStatusAsset.identifier, { preservePackageList: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 确认实名认证策略
|
* 确认实名认证策略
|
||||||
@@ -676,6 +687,7 @@
|
|||||||
packagePagination.value.page,
|
packagePagination.value.page,
|
||||||
packagePagination.value.pageSize
|
packagePagination.value.pageSize
|
||||||
)
|
)
|
||||||
|
if (!result) return
|
||||||
packagePagination.value.total = result.total
|
packagePagination.value.total = result.total
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadCurrentPackage(cardInfo.value.identifier),
|
loadCurrentPackage(cardInfo.value.identifier),
|
||||||
@@ -696,7 +708,7 @@
|
|||||||
page,
|
page,
|
||||||
packagePagination.value.pageSize
|
packagePagination.value.pageSize
|
||||||
)
|
)
|
||||||
packagePagination.value.total = result.total
|
if (result) packagePagination.value.total = result.total
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -707,7 +719,7 @@
|
|||||||
packagePagination.value.pageSize = size
|
packagePagination.value.pageSize = size
|
||||||
packagePagination.value.page = 1 // 重置到第一页
|
packagePagination.value.page = 1 // 重置到第一页
|
||||||
const result = await loadPackageList(cardInfo.value.identifier, 1, size)
|
const result = await loadPackageList(cardInfo.value.identifier, 1, size)
|
||||||
packagePagination.value.total = result.total
|
if (result) packagePagination.value.total = result.total
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -720,7 +732,7 @@
|
|||||||
packagePagination.value.page,
|
packagePagination.value.page,
|
||||||
packagePagination.value.pageSize
|
packagePagination.value.pageSize
|
||||||
)
|
)
|
||||||
packagePagination.value.total = result.total
|
if (result) packagePagination.value.total = result.total
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -831,7 +843,10 @@
|
|||||||
speedLimitDialogVisible.value = false
|
speedLimitDialogVisible.value = false
|
||||||
await nextTick()
|
await nextTick()
|
||||||
ElMessage.success(`限速设置成功:${response.data.speed_tier_name}`)
|
ElMessage.success(`限速设置成功:${response.data.speed_tier_name}`)
|
||||||
await handleRefresh()
|
// 限速设置后只读取已保存状态,不触发右上角按钮的主动同步接口。
|
||||||
|
if (cardInfo.value?.iccid === iccid) {
|
||||||
|
await loadRealtimeStatus(cardInfo.value.identifier, cardInfo.value.asset_type)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('设置限速失败:', error)
|
console.error('设置限速失败:', error)
|
||||||
speedLimitDialogVisible.value = false
|
speedLimitDialogVisible.value = false
|
||||||
|
|||||||
@@ -108,6 +108,7 @@
|
|||||||
v-model="allocateForm.target_shop_id"
|
v-model="allocateForm.target_shop_id"
|
||||||
:options="shopCascadeOptions"
|
:options="shopCascadeOptions"
|
||||||
:props="shopCascadeProps"
|
:props="shopCascadeProps"
|
||||||
|
:before-filter="handleAllocateShopBeforeFilter"
|
||||||
placeholder="请选择目标店铺"
|
placeholder="请选择目标店铺"
|
||||||
clearable
|
clearable
|
||||||
filterable
|
filterable
|
||||||
@@ -1049,6 +1050,62 @@
|
|||||||
const batchRealnamePolicyError = ref('')
|
const batchRealnamePolicyError = ref('')
|
||||||
const exportDialogVisible = ref(false)
|
const exportDialogVisible = ref(false)
|
||||||
const shopCascadeOptions = ref<any[]>([])
|
const shopCascadeOptions = ref<any[]>([])
|
||||||
|
let allocateShopSearchRequestId = 0
|
||||||
|
const mapShopCascadeNodes = (items: any[]) => {
|
||||||
|
const seen = new Set<number>()
|
||||||
|
|
||||||
|
return items.reduce<any[]>((nodes, item) => {
|
||||||
|
if (seen.has(item.id)) return nodes
|
||||||
|
|
||||||
|
seen.add(item.id)
|
||||||
|
nodes.push({
|
||||||
|
value: item.id,
|
||||||
|
label: item.shop_name,
|
||||||
|
leaf: !item.has_children
|
||||||
|
})
|
||||||
|
return nodes
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
const handleAllocateShopBeforeFilter = async (query: string) => {
|
||||||
|
const keyword = query.trim()
|
||||||
|
if (!keyword) return true
|
||||||
|
|
||||||
|
const requestId = ++allocateShopSearchRequestId
|
||||||
|
try {
|
||||||
|
const shopResponse = await ShopService.getShops({
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
shop_name: keyword
|
||||||
|
})
|
||||||
|
if (requestId !== allocateShopSearchRequestId) return false
|
||||||
|
if (shopResponse.code !== 0) {
|
||||||
|
shopCascadeOptions.value = []
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentIds = Array.from(
|
||||||
|
new Set((shopResponse.data.items || []).map((shop) => shop.parent_id ?? null))
|
||||||
|
)
|
||||||
|
const cascadeResponses = await Promise.all(
|
||||||
|
parentIds.map((parentId) =>
|
||||||
|
ShopService.getShopsCascade({
|
||||||
|
shop_name: keyword,
|
||||||
|
parent_id: parentId ?? undefined
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (requestId !== allocateShopSearchRequestId) return false
|
||||||
|
|
||||||
|
shopCascadeOptions.value = mapShopCascadeNodes(
|
||||||
|
cascadeResponses.flatMap((response) => (response.code === 0 ? response.data || [] : []))
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId === allocateShopSearchRequestId) shopCascadeOptions.value = []
|
||||||
|
console.error('搜索批量分配目标店铺失败:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
const shopCascadeProps = {
|
const shopCascadeProps = {
|
||||||
lazy: true,
|
lazy: true,
|
||||||
checkStrictly: true,
|
checkStrictly: true,
|
||||||
@@ -1059,12 +1116,7 @@
|
|||||||
try {
|
try {
|
||||||
const res = await ShopService.getShopsCascade({ parent_id: parentId })
|
const res = await ShopService.getShopsCascade({ parent_id: parentId })
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
const nodes = (res.data || []).map((item: any) => ({
|
resolve(mapShopCascadeNodes(res.data || []))
|
||||||
value: item.id,
|
|
||||||
label: item.shop_name,
|
|
||||||
leaf: !item.has_children
|
|
||||||
}))
|
|
||||||
resolve(nodes)
|
|
||||||
} else {
|
} else {
|
||||||
resolve([])
|
resolve([])
|
||||||
}
|
}
|
||||||
@@ -1081,11 +1133,7 @@
|
|||||||
try {
|
try {
|
||||||
const res = await ShopService.getShopsCascade({ parent_id: undefined })
|
const res = await ShopService.getShopsCascade({ parent_id: undefined })
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
shopCascadeOptions.value = (res.data || []).map((item: any) => ({
|
shopCascadeOptions.value = mapShopCascadeNodes(res.data || [])
|
||||||
value: item.id,
|
|
||||||
label: item.shop_name,
|
|
||||||
leaf: !item.has_children
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载顶级店铺失败:', error)
|
console.error('加载顶级店铺失败:', error)
|
||||||
@@ -1617,6 +1665,7 @@
|
|||||||
{ label: '设备号', prop: 'virtual_no' },
|
{ label: '设备号', prop: 'virtual_no' },
|
||||||
{ label: 'IMEI', prop: 'imei' },
|
{ label: 'IMEI', prop: 'imei' },
|
||||||
{ label: '设备名称', prop: 'device_name' },
|
{ label: '设备名称', prop: 'device_name' },
|
||||||
|
{ label: '授权企业', prop: 'authorized_enterprise_name' },
|
||||||
{ label: '店铺名称', prop: 'shop_name' },
|
{ label: '店铺名称', prop: 'shop_name' },
|
||||||
{ label: '套餐系列', prop: 'series_name' },
|
{ label: '套餐系列', prop: 'series_name' },
|
||||||
{ label: '预计套餐到期时间', prop: 'estimated_final_expires_at' },
|
{ label: '预计套餐到期时间', prop: 'estimated_final_expires_at' },
|
||||||
@@ -1893,6 +1942,13 @@
|
|||||||
showOverflowTooltip: true,
|
showOverflowTooltip: true,
|
||||||
minWidth: 150
|
minWidth: 150
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
prop: 'authorized_enterprise_name',
|
||||||
|
label: '授权企业',
|
||||||
|
minWidth: 160,
|
||||||
|
showOverflowTooltip: true,
|
||||||
|
formatter: (row: Device) => row.authorized_enterprise_name || '-'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
prop: 'shop_name',
|
prop: 'shop_name',
|
||||||
label: '店铺名称',
|
label: '店铺名称',
|
||||||
@@ -2527,10 +2583,7 @@
|
|||||||
|
|
||||||
const showEnterpriseDeviceAuthorizeDialog = async () => {
|
const showEnterpriseDeviceAuthorizeDialog = async () => {
|
||||||
resetEnterpriseDeviceAuthorizeForm()
|
resetEnterpriseDeviceAuthorizeForm()
|
||||||
await Promise.all([
|
await Promise.all([handleSearchEnterprise(), loadTopLevelShopCascadeOptions()])
|
||||||
handleSearchEnterprise(),
|
|
||||||
loadTopLevelShopCascadeOptions()
|
|
||||||
])
|
|
||||||
enterpriseDeviceAuthorizeDialogVisible.value = true
|
enterpriseDeviceAuthorizeDialogVisible.value = true
|
||||||
enterpriseDeviceAuthorizeFormRef.value?.clearValidate()
|
enterpriseDeviceAuthorizeFormRef.value?.clearValidate()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,8 +268,8 @@
|
|||||||
{{ createNewDeviceEmptyMessage }}
|
{{ createNewDeviceEmptyMessage }}
|
||||||
</div>
|
</div>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem v-if="createDirectInheritedShopHint" label="归属提示">
|
<ElFormItem label="归属提示">
|
||||||
<div class="exchange-shop-hint">{{ createDirectInheritedShopHint }}</div>
|
<div class="exchange-shop-hint">新资产会继承旧资产店铺</div>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem v-if="createForm.flow_type === 'direct'" label="是否迁移数据">
|
<ElFormItem v-if="createForm.flow_type === 'direct'" label="是否迁移数据">
|
||||||
<ElSwitch v-model="createForm.migrate_data" />
|
<ElSwitch v-model="createForm.migrate_data" />
|
||||||
@@ -771,11 +771,6 @@
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const createDirectInheritedShopHint = computed(() => {
|
|
||||||
const shopName = getShopDisplayName(createSelectedNewAsset.value)
|
|
||||||
return shopName ? `换货完成后将归属:${shopName}` : ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const shipInheritedShopDisplayText = computed(() => {
|
const shipInheritedShopDisplayText = computed(() => {
|
||||||
const shopName = getShopDisplayName(shipSelectedNewAsset.value)
|
const shopName = getShopDisplayName(shipSelectedNewAsset.value)
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,7 @@
|
|||||||
v-model="allocateForm.to_shop_id"
|
v-model="allocateForm.to_shop_id"
|
||||||
:options="shopCascadeOptions"
|
:options="shopCascadeOptions"
|
||||||
:props="shopCascadeProps"
|
:props="shopCascadeProps"
|
||||||
|
:before-filter="handleAllocateShopBeforeFilter"
|
||||||
placeholder="请选择目标店铺"
|
placeholder="请选择目标店铺"
|
||||||
clearable
|
clearable
|
||||||
filterable
|
filterable
|
||||||
@@ -1523,6 +1524,62 @@
|
|||||||
|
|
||||||
// 店铺相关
|
// 店铺相关
|
||||||
const shopCascadeOptions = ref<any[]>([])
|
const shopCascadeOptions = ref<any[]>([])
|
||||||
|
let allocateShopSearchRequestId = 0
|
||||||
|
const mapShopCascadeNodes = (items: any[]) => {
|
||||||
|
const seen = new Set<number>()
|
||||||
|
|
||||||
|
return items.reduce<any[]>((nodes, item) => {
|
||||||
|
if (seen.has(item.id)) return nodes
|
||||||
|
|
||||||
|
seen.add(item.id)
|
||||||
|
nodes.push({
|
||||||
|
value: item.id,
|
||||||
|
label: item.shop_name,
|
||||||
|
leaf: !item.has_children
|
||||||
|
})
|
||||||
|
return nodes
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
const handleAllocateShopBeforeFilter = async (query: string) => {
|
||||||
|
const keyword = query.trim()
|
||||||
|
if (!keyword) return true
|
||||||
|
|
||||||
|
const requestId = ++allocateShopSearchRequestId
|
||||||
|
try {
|
||||||
|
const shopResponse = await ShopService.getShops({
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
shop_name: keyword
|
||||||
|
})
|
||||||
|
if (requestId !== allocateShopSearchRequestId) return false
|
||||||
|
if (shopResponse.code !== 0) {
|
||||||
|
shopCascadeOptions.value = []
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentIds = Array.from(
|
||||||
|
new Set((shopResponse.data.items || []).map((shop) => shop.parent_id ?? null))
|
||||||
|
)
|
||||||
|
const cascadeResponses = await Promise.all(
|
||||||
|
parentIds.map((parentId) =>
|
||||||
|
ShopService.getShopsCascade({
|
||||||
|
shop_name: keyword,
|
||||||
|
parent_id: parentId ?? undefined
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (requestId !== allocateShopSearchRequestId) return false
|
||||||
|
|
||||||
|
shopCascadeOptions.value = mapShopCascadeNodes(
|
||||||
|
cascadeResponses.flatMap((response) => (response.code === 0 ? response.data || [] : []))
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId === allocateShopSearchRequestId) shopCascadeOptions.value = []
|
||||||
|
console.error('搜索批量分配目标店铺失败:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
const shopCascadeProps = {
|
const shopCascadeProps = {
|
||||||
lazy: true,
|
lazy: true,
|
||||||
checkStrictly: true,
|
checkStrictly: true,
|
||||||
@@ -1533,12 +1590,7 @@
|
|||||||
try {
|
try {
|
||||||
const res = await ShopService.getShopsCascade({ parent_id: parentId })
|
const res = await ShopService.getShopsCascade({ parent_id: parentId })
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
const nodes = (res.data || []).map((item: any) => ({
|
resolve(mapShopCascadeNodes(res.data || []))
|
||||||
value: item.id,
|
|
||||||
label: item.shop_name,
|
|
||||||
leaf: !item.has_children
|
|
||||||
}))
|
|
||||||
resolve(nodes)
|
|
||||||
} else {
|
} else {
|
||||||
resolve([])
|
resolve([])
|
||||||
}
|
}
|
||||||
@@ -1555,11 +1607,7 @@
|
|||||||
try {
|
try {
|
||||||
const res = await ShopService.getShopsCascade({ parent_id: undefined })
|
const res = await ShopService.getShopsCascade({ parent_id: undefined })
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
shopCascadeOptions.value = (res.data || []).map((item: any) => ({
|
shopCascadeOptions.value = mapShopCascadeNodes(res.data || [])
|
||||||
value: item.id,
|
|
||||||
label: item.shop_name,
|
|
||||||
leaf: !item.has_children
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载顶级店铺失败:', error)
|
console.error('加载顶级店铺失败:', error)
|
||||||
@@ -1948,6 +1996,7 @@
|
|||||||
{ label: 'MSISDN', prop: 'msisdn' },
|
{ label: 'MSISDN', prop: 'msisdn' },
|
||||||
{ label: '卡虚拟号', prop: 'virtual_no' },
|
{ label: '卡虚拟号', prop: 'virtual_no' },
|
||||||
{ label: '运营商', prop: 'carrier_name' },
|
{ label: '运营商', prop: 'carrier_name' },
|
||||||
|
{ label: '授权企业', prop: 'authorized_enterprise_name' },
|
||||||
{ label: '卡业务类型', prop: 'card_category' },
|
{ label: '卡业务类型', prop: 'card_category' },
|
||||||
{ label: '状态', prop: 'status' },
|
{ label: '状态', prop: 'status' },
|
||||||
{ label: '资产世代', prop: 'generation' },
|
{ label: '资产世代', prop: 'generation' },
|
||||||
@@ -2118,6 +2167,13 @@
|
|||||||
width: 150,
|
width: 150,
|
||||||
showOverflowTooltip: true
|
showOverflowTooltip: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
prop: 'authorized_enterprise_name',
|
||||||
|
label: '授权企业',
|
||||||
|
minWidth: 160,
|
||||||
|
showOverflowTooltip: true,
|
||||||
|
formatter: (row: StandaloneIotCard) => row.authorized_enterprise_name || '-'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
prop: 'shop_name',
|
prop: 'shop_name',
|
||||||
label: '店铺名称',
|
label: '店铺名称',
|
||||||
@@ -2486,6 +2542,7 @@
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
allocateDialogVisible.value = true
|
allocateDialogVisible.value = true
|
||||||
|
shopCascadeOptions.value = []
|
||||||
Object.assign(allocateForm, {
|
Object.assign(allocateForm, {
|
||||||
selection_type: CardSelectionType.LIST,
|
selection_type: CardSelectionType.LIST,
|
||||||
to_shop_id: undefined,
|
to_shop_id: undefined,
|
||||||
@@ -2700,10 +2757,7 @@
|
|||||||
|
|
||||||
const showEnterpriseCardRecallDialog = async () => {
|
const showEnterpriseCardRecallDialog = async () => {
|
||||||
resetEnterpriseCardRecallForm()
|
resetEnterpriseCardRecallForm()
|
||||||
await Promise.all([
|
await Promise.all([handleSearchEnterprise(), loadAllocateCarrierOptions()])
|
||||||
handleSearchEnterprise(),
|
|
||||||
loadAllocateCarrierOptions()
|
|
||||||
])
|
|
||||||
enterpriseCardRecallDialogVisible.value = true
|
enterpriseCardRecallDialogVisible.value = true
|
||||||
enterpriseCardRecallFormRef.value?.clearValidate()
|
enterpriseCardRecallFormRef.value?.clearValidate()
|
||||||
}
|
}
|
||||||
@@ -2823,10 +2877,7 @@
|
|||||||
selection_type:
|
selection_type:
|
||||||
selectedCards.value.length > 0 ? CardSelectionType.LIST : CardSelectionType.FILTER
|
selectedCards.value.length > 0 ? CardSelectionType.LIST : CardSelectionType.FILTER
|
||||||
})
|
})
|
||||||
await Promise.all([
|
await Promise.all([loadPackageSeriesList(), loadAllocateCarrierOptions()])
|
||||||
loadPackageSeriesList(),
|
|
||||||
loadAllocateCarrierOptions()
|
|
||||||
])
|
|
||||||
seriesBindingDialogVisible.value = true
|
seriesBindingDialogVisible.value = true
|
||||||
seriesBindingFormRef.value?.clearValidate()
|
seriesBindingFormRef.value?.clearValidate()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,10 +62,10 @@
|
|||||||
<p>2. 列格式请设置为文本格式,避免长数字被转为科学计数法。</p>
|
<p>2. 列格式请设置为文本格式,避免长数字被转为科学计数法。</p>
|
||||||
<p>3. <strong>列顺序固定,不可调整。</strong>系统按位置读取,不识别列名。</p>
|
<p>3. <strong>列顺序固定,不可调整。</strong>系统按位置读取,不识别列名。</p>
|
||||||
<p style="color: var(--el-color-primary)">
|
<p style="color: var(--el-color-primary)">
|
||||||
4. 必填列:设备标识(第 1 列,支持 VirtualNo、IMEI 或 SN)。
|
4. 必填列:IMEI、设备标识(第 1 列,支持 VirtualNo、IMEI 或 SN)。
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
5. 可选列:SN、设备名称、设备型号、设备类型、IMEI、制造商、最大 SIM 槽数、卡 1~卡 4
|
5. 可选列:SN、设备名称、设备型号、设备类型、制造商、最大 SIM 槽数、卡 1~卡 4
|
||||||
ICCID。
|
ICCID。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,6 +69,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ElDialog>
|
</ElDialog>
|
||||||
|
|
||||||
|
<!-- 审批通过对话框 -->
|
||||||
|
<ElDialog v-model="approveDialogVisible" title="审批通过提现申请" width="40%">
|
||||||
|
<ElForm :model="approveForm">
|
||||||
|
<ElFormItem label="备注信息" prop="remark">
|
||||||
|
<ElInput
|
||||||
|
v-model="approveForm.remark"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="请输入备注信息(选填)"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<ElButton @click="approveDialogVisible = false">{{ $t('common.cancel') }}</ElButton>
|
||||||
|
<ElButton type="primary" @click="handleApproveSubmit" :loading="approveSubmitLoading">
|
||||||
|
{{ $t('common.confirm') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
</ElCard>
|
</ElCard>
|
||||||
</div>
|
</div>
|
||||||
</ArtTableFullScreen>
|
</ArtTableFullScreen>
|
||||||
@@ -77,28 +101,30 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { h } from 'vue'
|
import { h } from 'vue'
|
||||||
import { CommissionService, ShopService } from '@/api/modules'
|
import { CommissionService, ShopService } from '@/api/modules'
|
||||||
import { ElMessage, ElMessageBox, ElTag } from 'element-plus'
|
import { ElMessage, ElTag } from 'element-plus'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
import type {
|
import type {
|
||||||
WithdrawalRequestItem,
|
WithdrawalRequestItem,
|
||||||
WithdrawalStatus,
|
WithdrawalStatus,
|
||||||
WithdrawalMethod
|
WithdrawalMethod
|
||||||
} from '@/types/api/commission'
|
} from '@/types/api/commission'
|
||||||
|
import { PaymentType } from '@/types/api/commission'
|
||||||
import type { SearchFormItem } from '@/types'
|
import type { SearchFormItem } from '@/types'
|
||||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||||
import { formatDateTime, formatMoney } from '@/utils/business/format'
|
import { formatDateTime, formatMoney } from '@/utils/business/format'
|
||||||
import { WithdrawalStatusMap, WithdrawalMethodMap } from '@/config/constants'
|
import { WithdrawalStatusMap, WithdrawalMethodMap } from '@/config/constants'
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
defineOptions({ name: 'WithdrawalApproval' })
|
defineOptions({ name: 'WithdrawalApproval' })
|
||||||
|
|
||||||
const { t } = useI18n() // 仅保留用于 common.tips, common.confirm, common.cancel
|
|
||||||
|
|
||||||
const rejectDialogVisible = ref(false)
|
const rejectDialogVisible = ref(false)
|
||||||
|
const approveDialogVisible = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const approveSubmitLoading = ref(false)
|
||||||
const rejectSubmitLoading = ref(false)
|
const rejectSubmitLoading = ref(false)
|
||||||
const tableRef = ref()
|
const tableRef = ref()
|
||||||
const currentWithdrawalId = ref<number>(0)
|
const currentWithdrawalId = ref<number>(0)
|
||||||
|
const currentWithdrawal = ref<WithdrawalRequestItem | null>(null)
|
||||||
|
const approveForm = reactive({ remark: '' })
|
||||||
|
|
||||||
// 店铺选项
|
// 店铺选项
|
||||||
const shopOptions = ref<any[]>([])
|
const shopOptions = ref<any[]>([])
|
||||||
@@ -216,7 +242,8 @@
|
|||||||
{
|
{
|
||||||
prop: 'withdrawal_no',
|
prop: 'withdrawal_no',
|
||||||
label: '提现单号',
|
label: '提现单号',
|
||||||
minWidth: 180
|
minWidth: 180,
|
||||||
|
showOverflowTooltip: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
prop: 'shop_name',
|
prop: 'shop_name',
|
||||||
@@ -232,21 +259,18 @@
|
|||||||
prop: 'amount',
|
prop: 'amount',
|
||||||
label: '提现金额',
|
label: '提现金额',
|
||||||
width: 120,
|
width: 120,
|
||||||
align: 'right',
|
|
||||||
formatter: (row: WithdrawalRequestItem) => formatMoney(row.amount)
|
formatter: (row: WithdrawalRequestItem) => formatMoney(row.amount)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
prop: 'fee',
|
prop: 'fee',
|
||||||
label: '手续费',
|
label: '手续费',
|
||||||
width: 100,
|
width: 100,
|
||||||
align: 'right',
|
|
||||||
formatter: (row: WithdrawalRequestItem) => formatMoney(row.fee)
|
formatter: (row: WithdrawalRequestItem) => formatMoney(row.fee)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
prop: 'actual_amount',
|
prop: 'actual_amount',
|
||||||
label: '实际到账',
|
label: '实际到账',
|
||||||
width: 120,
|
width: 120,
|
||||||
align: 'right',
|
|
||||||
formatter: (row: WithdrawalRequestItem) => formatMoney(row.actual_amount)
|
formatter: (row: WithdrawalRequestItem) => formatMoney(row.actual_amount)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -367,25 +391,36 @@
|
|||||||
getTableData()
|
getTableData()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 审批通过
|
// 显示审批通过对话框
|
||||||
const handleApprove = (row: WithdrawalRequestItem) => {
|
const showApproveDialog = (row: WithdrawalRequestItem) => {
|
||||||
ElMessageBox.confirm('确定要通过该提现申请吗?', t('common.tips'), {
|
currentWithdrawal.value = row
|
||||||
confirmButtonText: t('common.confirm'),
|
approveForm.remark = ''
|
||||||
cancelButtonText: t('common.cancel'),
|
approveDialogVisible.value = true
|
||||||
type: 'warning'
|
}
|
||||||
})
|
|
||||||
.then(async () => {
|
// 提交审批通过
|
||||||
try {
|
const handleApproveSubmit = async () => {
|
||||||
await CommissionService.approveWithdrawal(row.id)
|
const row = currentWithdrawal.value
|
||||||
ElMessage.success('审批通过成功')
|
if (!row) return
|
||||||
getTableData()
|
|
||||||
} catch (error) {
|
approveSubmitLoading.value = true
|
||||||
console.error(error)
|
try {
|
||||||
}
|
await CommissionService.approveWithdrawal(row.id, {
|
||||||
})
|
account_name: row.account_name,
|
||||||
.catch(() => {
|
account_number: row.account_number,
|
||||||
// 用户取消
|
amount: row.amount,
|
||||||
|
payment_type: PaymentType.MANUAL,
|
||||||
|
remark: approveForm.remark,
|
||||||
|
withdrawal_method: row.withdrawal_method
|
||||||
})
|
})
|
||||||
|
ElMessage.success('审批通过成功')
|
||||||
|
approveDialogVisible.value = false
|
||||||
|
getTableData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
approveSubmitLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示拒绝对话框
|
// 显示拒绝对话框
|
||||||
@@ -428,7 +463,7 @@
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: '审批通过',
|
label: '审批通过',
|
||||||
handler: () => handleApprove(row),
|
handler: () => showApproveDialog(row),
|
||||||
type: 'primary' as const
|
type: 'primary' as const
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -113,7 +113,13 @@
|
|||||||
import DetailPage from '@/components/common/DetailPage.vue'
|
import DetailPage from '@/components/common/DetailPage.vue'
|
||||||
import type { DetailSection } from '@/components/common/DetailPage.vue'
|
import type { DetailSection } from '@/components/common/DetailPage.vue'
|
||||||
import { RefundService } from '@/api/modules'
|
import { RefundService } from '@/api/modules'
|
||||||
import type { Refund, ResubmitRefundRequest, RefundAttachment } from '@/types/api'
|
import {
|
||||||
|
RefundApprovalStatus,
|
||||||
|
RefundStatus,
|
||||||
|
type Refund,
|
||||||
|
type ResubmitRefundRequest,
|
||||||
|
type RefundAttachment
|
||||||
|
} from '@/types/api'
|
||||||
import { fenToYuan, formatDateTime, yuanToFen } from '@/utils/business/format'
|
import { fenToYuan, formatDateTime, yuanToFen } from '@/utils/business/format'
|
||||||
import { toVoucherKeyList, getErrorMessage } from '@/utils/business'
|
import { toVoucherKeyList, getErrorMessage } from '@/utils/business'
|
||||||
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
|
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
|
||||||
@@ -456,22 +462,14 @@
|
|||||||
resubmitUploadRef.value?.clearFiles(false)
|
resubmitUploadRef.value?.clearFiles(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const canResubmit = (item: Refund) => {
|
const canResubmit = (item: Refund) =>
|
||||||
const statusText = [item.approval_status, item.approval_status_name, item.status_name]
|
item.status === RefundStatus.REJECTED ||
|
||||||
.filter(Boolean)
|
item.status === RefundStatus.RETURNED ||
|
||||||
.join(' ')
|
item.approval_status === RefundApprovalStatus.REJECTED ||
|
||||||
.toLowerCase()
|
item.approval_status === RefundApprovalStatus.REVOKED ||
|
||||||
|
item.approval_status === RefundApprovalStatus.REVOKED_AFTER_APPROVED ||
|
||||||
return (
|
item.approval_status === RefundApprovalStatus.SUBMIT_FAILED ||
|
||||||
item.status === 3 ||
|
item.approval_status === RefundApprovalStatus.RESULT_UNKNOWN
|
||||||
statusText.includes('reject') ||
|
|
||||||
statusText.includes('revoke') ||
|
|
||||||
statusText.includes('delete') ||
|
|
||||||
statusText.includes('驳回') ||
|
|
||||||
statusText.includes('撤销') ||
|
|
||||||
statusText.includes('删除')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleShowResubmit = () => {
|
const handleShowResubmit = () => {
|
||||||
if (!refund.value || !canResubmit(refund.value)) return
|
if (!refund.value || !canResubmit(refund.value)) return
|
||||||
|
|||||||
@@ -150,6 +150,7 @@
|
|||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
RefundStatus,
|
RefundStatus,
|
||||||
|
RefundApprovalStatus,
|
||||||
type Refund,
|
type Refund,
|
||||||
type RefundQueryParams,
|
type RefundQueryParams,
|
||||||
type ResubmitRefundRequest,
|
type ResubmitRefundRequest,
|
||||||
@@ -734,22 +735,14 @@
|
|||||||
return keys.length ? keys : toVoucherKeyList(row.refund_voucher_key)
|
return keys.length ? keys : toVoucherKeyList(row.refund_voucher_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
const canResubmit = (row: Refund) => {
|
const canResubmit = (row: Refund) =>
|
||||||
const statusText = [row.approval_status, row.approval_status_name, row.status_name]
|
row.status === RefundStatus.REJECTED ||
|
||||||
.filter(Boolean)
|
row.status === RefundStatus.RETURNED ||
|
||||||
.join(' ')
|
row.approval_status === RefundApprovalStatus.REJECTED ||
|
||||||
.toLowerCase()
|
row.approval_status === RefundApprovalStatus.REVOKED ||
|
||||||
|
row.approval_status === RefundApprovalStatus.REVOKED_AFTER_APPROVED ||
|
||||||
return (
|
row.approval_status === RefundApprovalStatus.SUBMIT_FAILED ||
|
||||||
row.status === 3 ||
|
row.approval_status === RefundApprovalStatus.RESULT_UNKNOWN
|
||||||
statusText.includes('reject') ||
|
|
||||||
statusText.includes('revoke') ||
|
|
||||||
statusText.includes('delete') ||
|
|
||||||
statusText.includes('驳回') ||
|
|
||||||
statusText.includes('撤销') ||
|
|
||||||
statusText.includes('删除')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const canTriggerApproval = (row: Refund) => {
|
const canTriggerApproval = (row: Refund) => {
|
||||||
const approvalStatusEmpty = row.approval_status === undefined || row.approval_status === null
|
const approvalStatusEmpty = row.approval_status === undefined || row.approval_status === null
|
||||||
|
|||||||
Reference in New Issue
Block a user