This commit is contained in:
@@ -76,6 +76,10 @@
|
||||
|
||||
创建失败保留资产/原因等字段;完成失败重新加载换货单,保持后端「已发货待确认」事实,不乐观标记完成。解除后由用户再次提交。取消只关闭提示,不当作解绑或换货成功。
|
||||
|
||||
用户追加明确要求快捷定位并勾选关联:创建换货冻结当前新资产标识及关联手机号数组,确认完成使用原单新资产标识;错误响应手机号优先。主动跳转通过 `asset_identifier` 和 `phone` 查询参数传递目标,允许该 URL 带号码但不额外写日志或本地存储。关联页按 1 页/20 条加载,仅默认选中精确匹配的有效行;不自动解绑,继续原原因与二次确认。
|
||||
|
||||
通过 ArtTable 透传清空/勾选方法,数据更新后 nextTick 完成实际选择。缓存重入和路由参数变更重置查询,请求序号防止过期响应覆盖;手工筛选/翻页、解绑后刷新清除旧选择。批量结果保持后端布尔契约,仅展示中文「解绑成功/解绑失败」。原查看与批量解绑权限不变。
|
||||
|
||||
### 5. 预警只展示快照,不重算阈值
|
||||
|
||||
删除 `PackageTrafficAlertRuleItem.real_data_mb` 与规则行/详情/列设置依赖。来自 `/api/admin/packages` 的套餐选项 `real_data_mb` 保留,套餐选择与规则参数校验不变。
|
||||
|
||||
@@ -51,3 +51,16 @@ node scripts/tests/verifyBuild.mjs --serve
|
||||
4. 新建与历史订单、IoT 卡、设备、预警导出文件列。
|
||||
|
||||
这些项目继续保留在任务清单,用户验收及部署后再归档。
|
||||
|
||||
## 用户追加:换货冲突快捷解绑
|
||||
|
||||
已在用户明确要求下补充以下行为:
|
||||
|
||||
- 创建换货捕获提交时新资产 ICCID/虚拟号及 `associated_phones`;确认完成使用换货单的新资产标识。跳转手机号优先取 1202 `data.phone`,缺失时取新资产手机号数组首个有效值,不使用旧资产。
|
||||
- 用户点击跳转后,路由带 `asset_identifier` 和 `phone`,目标页从 1 页/20 条精确查询。只实际勾选匹配两项条件且有效的记录,不全选历史失效记录。
|
||||
- 表格勾选方法由已有 ArtTable 透传;手工取消后不立即重新勾选,重置、筛选、翻页及解绑后刷新清空旧选择;缓存重入或路由参数变化重新取目标,过期响应不能覆盖新查询。
|
||||
- 批量解绑仍使用 `phone_asset_association:unbind_batch` 和原原因/二次确认表单;结果列展示「解绑成功/解绑失败」,资产类型同时显示中文,不修改后端布尔值契约。
|
||||
|
||||
原说明要求号码不额外记录或持久化;用户追加明确要求两项路由参数,因此用户主动跳转时允许号码进入该 URL 查询参数,不额外记录日志或写入本地存储。
|
||||
|
||||
新增三项自动测试,全套 66 项通过,类型检查、受影响文件 lint、编码和严格规范校验通过,生产构建通过(原有警告仍在)。本地模拟确认了完整的 1202 弹窗 → 带参跳转 → 筛选并勾选有效记录 → 手工取消禁用按钮 → 原确认表单 → 中文成功结果链路,没有调用真实后台解绑接口。
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- M8:核销申请列表和详情使用真实的 `approval_submission_status` 展示审批提交状态,不能只依赖当前可能为空的名称字段;账单详情中的申请状态同步扩展。
|
||||
- M1:**BREAKING**:按用户最新确认,后台七个密码接口相关的既有表单、服务和类型统一到「8~12 位、只允许英文字母和数字,必须同时包含大写字母、小写字母和数字,不允许特殊符号」,包括后台 `PUT /api/auth/password`;账号独立改密服务按文档将请求字段修正为 `new_password`。旧密码登录、本人改密的旧密码输入、操作密码、WiFi 密码不套用新密码规则。
|
||||
- M12:创建换货与确认完成捕获错误码 `1202`,从错误响应 `data.phone` 读取手机号,在弹窗中引导到已有「手机号资产关联」入口;不自动解绑、不新增扫码/退款前置拦截,失败时保留表单及原单状态。
|
||||
- 用户追加 M12 快捷处理:跳转携带本次新资产标识与关联手机号,关联页面自动预填筛选并勾选匹配有效记录;复用原批量解绑权限和二次确认,结果表布尔值改为中文成功/失败,不自动执行解绑。
|
||||
- M16:**BREAKING**:删除预警规则响应类型、规则列表及规则详情展示中对 `real_data_mb` 的依赖;预警列表和详情补齐 `used_mb`、`limit_mb` 及新增 `threshold_mb`,以 MB 展示触发时快照,说明包含加油包。套餐接口和套餐选项中的 `real_data_mb` 保留。
|
||||
- M2:**BREAKING**:店铺业务员批量换绑说明及前端内置模板(如有)改用固定表头 `店铺编码,操作类型,平台登录账号,备注`,注明换绑时只填写平台用户登录手机号、清空时该列留空;不兼容旧表头,保留逐行失败原因。
|
||||
- M23:设备导入文案将 IMEI 列从可选改为必填,不改变现有模板列名、列序、请求结构,也不新增 IMEI 长度、格式或重复校验;失败详情继续展示后端行号和原因。
|
||||
@@ -51,7 +52,7 @@
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `order-management`、`admin-authentication`、`employee-collection`、`refund-management`、`agent-distribution-registration`、`agent-recharge`、`commission-management`、`exchange-management`、`package-traffic-alert`、`shop-management`、`device-batch-import`。
|
||||
- Affected specs: `order-management`、`admin-authentication`、`employee-collection`、`refund-management`、`agent-distribution-registration`、`agent-recharge`、`commission-management`、`exchange-management`、`phone-asset-association-management`、`package-traffic-alert`、`shop-management`、`device-batch-import`。
|
||||
- Affected code:
|
||||
- `src/types/api/{order,account,employeeCollection,refund,agentDistributionRegistration,agentRecharge,commission,packageTrafficAlert}.ts` 及相关类型导出。
|
||||
- `src/api/modules/{order,account,exchange}.ts`;其余审批相关服务仅按既有接口复用,不扩展 C 端请求。
|
||||
|
||||
@@ -42,3 +42,20 @@
|
||||
- **THEN** 页面 MUST 继续按原业务文案及统一错误分类反馈,不重复弹错
|
||||
- **AND** MUST NOT 新增依据扫码历史或退款状态的前置拒绝逻辑
|
||||
- **AND** 当前换货迁移字段、角色限制和接口成功行为 MUST 保持不变
|
||||
|
||||
### Requirement: 换货冲突跳转携带新资产与手机号
|
||||
|
||||
收到 1202 后,用户点击「前往手机号资产关联」时,后台 SHALL 通过 `asset_identifier` 和 `phone` 路由查询参数传递本次提交的新资产标识及关联手机号。创建换货 MUST 使用提交时选中的新卡 ICCID 或设备虚拟号;确认完成 MUST 使用换货单的新资产标识,不得带入旧资产。手机号 MUST 优先使用错误响应 `data.phone`,缺失时可取新资产 `associated_phones` 的首个有效值。
|
||||
|
||||
#### Scenario: 创建换货选择新资产后被拒
|
||||
|
||||
- **WHEN** 创建请求因新资产已有手机号关联返回 1202,用户选择跳转
|
||||
- **THEN** 路由 MUST 携带选中新资产的 `asset_identifier` 与对应 `phone`
|
||||
- **AND** 新旧资产同时存在手机号关联时 MUST NOT 使用旧资产标识或旧手机号
|
||||
- **AND** 长 ICCID MUST 保持字符串,不转为数值
|
||||
|
||||
#### Scenario: 确认完成失败后跳转
|
||||
|
||||
- **WHEN** 确认完成返回 1202,用户选择跳转
|
||||
- **THEN** 路由 MUST 携带该换货单的 `new_asset_identifier` 和错误响应手机号
|
||||
- **AND** 参数不完整时 MUST 不猜测缺失值或自动执行解绑
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 按换货新资产与手机号预填查询并勾选
|
||||
|
||||
手机号资产关联页面 SHALL 读取路由 `asset_identifier` 和 `phone`,预填筛选并从 `page=1,page_size=20` 请求列表。用户具备原批量解绑权限时,页面 MUST 默认勾选同时匹配资产标识、手机号且 `status=1` 的当前页记录,旧资产、其他手机号和已失效记录不得勾选。
|
||||
|
||||
#### Scenario: 从换货冲突入口进入
|
||||
|
||||
- **WHEN** 路由携带新资产和手机号参数
|
||||
- **THEN** 页面 MUST 使用两项参数调用 `GET /api/admin/phone-asset-associations`
|
||||
- **AND** 数据渲染后 MUST 实际勾选匹配有效记录,使已有批量解绑按钮可用
|
||||
- **AND** MUST 保留解除原因必填与二次确认,不自动发送解绑请求
|
||||
|
||||
#### Scenario: 缓存页面再次接收另一资产
|
||||
|
||||
- **WHEN** 关联页面已存在缓存或同一路径查询参数改变
|
||||
- **THEN** 页面 MUST 重置分页、筛选和旧勾选并重新查询新的两项条件
|
||||
- **AND** 过期请求 MUST NOT 覆盖最新数据或选中新目标以外的行
|
||||
|
||||
#### Scenario: 参数缺失、手动筛选或解绑后刷新
|
||||
|
||||
- **WHEN** 两项参数不完整、用户修改筛选/翻页、手工取消选择或完成批量解绑
|
||||
- **THEN** 页面 MUST NOT 将无关记录全选或恢复旧勾选
|
||||
- **AND** 无批量解绑权限时 MUST NOT 显示可执行的批量解绑入口
|
||||
|
||||
### Requirement: 批量解绑逐项结果使用中文
|
||||
|
||||
批量解绑结果表 SHALL 将 `success=true` 展示为「解绑成功」,`success=false` 展示为「解绑失败」,保留中文汇总、解除关系数及后端失败原因,不显示原始布尔值。
|
||||
|
||||
#### Scenario: 显示成功或失败结果
|
||||
|
||||
- **WHEN** 后端返回批量解绑逐项结果
|
||||
- **THEN** 结果列 MUST 使用中文成功/失败标签,不显示 `true` 或 `false`
|
||||
- **AND** 失败原因、统计值及原结果布尔类型 MUST 保持原后端契约
|
||||
@@ -59,3 +59,10 @@
|
||||
- [ ] 7.11 完成真实测试环境联调及用户验收后归档,不以模拟页面替代后端验收。
|
||||
|
||||
7.2、7.3、7.5、7.7 的本地逻辑与部分模拟页面已覆盖;尚未验证真实支付、冻结/预占金额、解除后重试、导入结果与导出文件。复现方式及证据见 [实施记录](./implementation.md)。
|
||||
|
||||
## 8. 换货冲突关联管理快捷处理(用户追加)
|
||||
|
||||
- [x] 8.1 两个换货入口携带当前新资产标识和关联手机号跳转,提交时冻结目标,禁止旧资产回退。
|
||||
- [x] 8.2 关联页预填两项筛选,默认 1 页/20 条,仅勾选匹配有效记录;缓存、参数变化、过期响应和手工筛选清除旧选择。
|
||||
- [x] 8.3 复用原批量解绑权限、原因与二次确认,结果布尔值改中文成功/失败。
|
||||
- [x] 8.4 66 项回归测试通过,本地完整跳转、实际勾选、手工取消及模拟批量结果验证通过;未操作真实资产。
|
||||
|
||||
@@ -226,6 +226,91 @@ test('exchange phone conflicts support HTTP error and business response without
|
||||
})
|
||||
})
|
||||
|
||||
test('exchange association handoff carries only the new asset and its bound phone as strings', async () => {
|
||||
const { getExchangePhoneAssociationQuery } = await loadModule(
|
||||
'src/utils/business/exchangeError.ts'
|
||||
)
|
||||
const newAsset = { asset_identifier: '89860624590014594318', associated_phones: ['19232713115'] }
|
||||
assert.deepEqual(
|
||||
getExchangePhoneAssociationQuery({ code: 1202, data: { phone: '19232713115' } }, newAsset),
|
||||
{
|
||||
asset_identifier: '89860624590014594318',
|
||||
phone: '19232713115'
|
||||
}
|
||||
)
|
||||
assert.deepEqual(
|
||||
getExchangePhoneAssociationQuery({ response: { data: { code: 1202 } } }, newAsset),
|
||||
{
|
||||
asset_identifier: '89860624590014594318',
|
||||
phone: '19232713115'
|
||||
}
|
||||
)
|
||||
assert.equal(getExchangePhoneAssociationQuery({ code: 1206 }, newAsset), undefined)
|
||||
assert.equal(
|
||||
getExchangePhoneAssociationQuery({ code: 1202, data: { phone: '19232713115' } }, {}),
|
||||
undefined
|
||||
)
|
||||
assert.equal(
|
||||
getExchangePhoneAssociationQuery(
|
||||
{ code: 1202 },
|
||||
{ asset_identifier: '89860624590014594318', associated_phones: [] }
|
||||
),
|
||||
undefined
|
||||
)
|
||||
assert.deepEqual(
|
||||
getExchangePhoneAssociationQuery({ code: 1202, data: { phone: '13900000000' } }, newAsset),
|
||||
{
|
||||
asset_identifier: '89860624590014594318',
|
||||
phone: '13900000000'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('association preselection excludes old assets, different phones and invalid historical rows', async () => {
|
||||
const {
|
||||
getPhoneAssetAssociationTarget,
|
||||
getMatchingPhoneAssetAssociations,
|
||||
getPhoneAssetUnbindResultName
|
||||
} = await loadModule('src/utils/business/phoneAssetAssociation.ts')
|
||||
const target = getPhoneAssetAssociationTarget({
|
||||
asset_identifier: ' 89860624590014594318 ',
|
||||
phone: '19232713115'
|
||||
})
|
||||
const rows = [
|
||||
{ id: 1, asset_identifier: target.asset_identifier, phone: target.phone, status: 1 },
|
||||
{ id: 2, asset_identifier: '89860625770070732445', phone: target.phone, status: 1 },
|
||||
{ id: 3, asset_identifier: target.asset_identifier, phone: '13900000000', status: 1 },
|
||||
{ id: 4, asset_identifier: target.asset_identifier, phone: target.phone, status: 0 }
|
||||
]
|
||||
assert.deepEqual(
|
||||
getMatchingPhoneAssetAssociations(rows, target).map((row) => row.id),
|
||||
[1]
|
||||
)
|
||||
assert.deepEqual(getMatchingPhoneAssetAssociations(rows, { ...target, phone: '' }), [])
|
||||
assert.deepEqual(
|
||||
getPhoneAssetAssociationTarget({ asset_identifier: ['a', 'b'], phone: ['19232713115'] }),
|
||||
{ asset_identifier: '', phone: '' }
|
||||
)
|
||||
assert.equal(getPhoneAssetUnbindResultName(true), '解绑成功')
|
||||
assert.equal(getPhoneAssetUnbindResultName(false), '解绑失败')
|
||||
})
|
||||
|
||||
test('association page watches handoff changes, clears stale selections and retains manual unbind confirmation', async () => {
|
||||
const page = await readFile(
|
||||
'src/views/asset-management/phone-asset-association/index.vue',
|
||||
'utf8'
|
||||
)
|
||||
assert.match(page, /getPhoneAssetAssociationTarget\(route.query\)/)
|
||||
assert.match(page, /toggleRowSelection\(row, true\)/)
|
||||
assert.match(page, /requestId !== listRequestId/)
|
||||
assert.match(page, /getPhoneAssetUnbindResultName\(row.success\)/)
|
||||
assert.doesNotMatch(page, /prop="success"/)
|
||||
assert.match(page, /batchForm.confirmed = false/)
|
||||
assert.match(page, /batchFormRef.value\?\.validate/)
|
||||
const table = await readFile('src/components/core/tables/ArtTable.vue', 'utf8')
|
||||
assert.match(table, /clearSelection:.*clearSelection\(\)/)
|
||||
})
|
||||
|
||||
test('password validation errors accept backend message as well as msg', async () => {
|
||||
const { normalizeApiError } = await loadModule('src/utils/business/apiError.ts')
|
||||
for (const field of ['msg', 'message']) {
|
||||
|
||||
@@ -101,11 +101,55 @@ const exchange = {
|
||||
updated_at: time
|
||||
}
|
||||
|
||||
const associations = [
|
||||
{
|
||||
id: 101,
|
||||
asset_id: 1001,
|
||||
asset_type: 'iot_card',
|
||||
asset_identifier: exchange.new_asset_identifier,
|
||||
phone: '13800000000',
|
||||
status: 1,
|
||||
status_name: '有效',
|
||||
established_at: time
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
asset_id: 1001,
|
||||
asset_type: 'iot_card',
|
||||
asset_identifier: exchange.new_asset_identifier,
|
||||
phone: '13800000000',
|
||||
status: 0,
|
||||
status_name: '已失效',
|
||||
established_at: time
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
asset_id: 1002,
|
||||
asset_type: 'iot_card',
|
||||
asset_identifier: exchange.old_asset_identifier,
|
||||
phone: '13800000000',
|
||||
status: 1,
|
||||
status_name: '有效',
|
||||
established_at: time
|
||||
},
|
||||
{
|
||||
id: 104,
|
||||
asset_id: 1002,
|
||||
asset_type: 'iot_card',
|
||||
asset_identifier: exchange.old_asset_identifier,
|
||||
phone: '13900000000',
|
||||
status: 1,
|
||||
status_name: '有效',
|
||||
established_at: time
|
||||
}
|
||||
]
|
||||
|
||||
export const mockApiPlugin = {
|
||||
name: 'october-local-mock-api',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
const path = new URL(req.url, 'http://127.0.0.1').pathname
|
||||
const url = new URL(req.url, 'http://127.0.0.1')
|
||||
const path = url.pathname
|
||||
if (!path.startsWith('/api/')) return next()
|
||||
if (path === '/api/auth/login' || path === '/api/auth/me') {
|
||||
const user = {
|
||||
@@ -134,6 +178,37 @@ export const mockApiPlugin = {
|
||||
return
|
||||
}
|
||||
if (req.method !== 'GET') {
|
||||
if (path === '/api/admin/phone-asset-associations/batch-unbind') {
|
||||
let body = ''
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk
|
||||
})
|
||||
req.on('end', () => {
|
||||
const request = JSON.parse(body)
|
||||
const items = request.assets.map((asset) => {
|
||||
const matches = associations.filter(
|
||||
(row) =>
|
||||
row.asset_id === asset.asset_id &&
|
||||
row.asset_type === asset.asset_type &&
|
||||
row.status === 1
|
||||
)
|
||||
matches.forEach((row) => {
|
||||
row.status = 0
|
||||
row.status_name = '已失效'
|
||||
})
|
||||
return { ...asset, success: true, unbound_count: matches.length, reason: '' }
|
||||
})
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'mock',
|
||||
data: { success_count: items.length, fail_count: 0, items }
|
||||
})
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
@@ -153,6 +228,15 @@ export const mockApiPlugin = {
|
||||
page_size: 20
|
||||
})
|
||||
let data = page([])
|
||||
if (path === '/api/admin/phone-asset-associations')
|
||||
data = page(
|
||||
associations.filter(
|
||||
(row) =>
|
||||
(!url.searchParams.get('asset_identifier') ||
|
||||
row.asset_identifier === url.searchParams.get('asset_identifier')) &&
|
||||
(!url.searchParams.get('phone') || row.phone === url.searchParams.get('phone'))
|
||||
)
|
||||
)
|
||||
if (path === '/api/admin/orders/1') data = order
|
||||
if (path === '/api/admin/exchanges') data = page([exchange])
|
||||
if (path === '/api/admin/exchanges/1') data = exchange
|
||||
|
||||
@@ -234,6 +234,9 @@
|
||||
|
||||
// 提供给父组件的方法
|
||||
defineExpose({
|
||||
clearSelection: () => tableRef.value?.clearSelection(),
|
||||
toggleRowSelection: (row: any, selected?: boolean) =>
|
||||
tableRef.value?.toggleRowSelection(row, selected),
|
||||
// 展开所有行
|
||||
expandAll: () => {
|
||||
const elTable = tableRef.value
|
||||
|
||||
@@ -22,3 +22,26 @@ export const getExchangeBusinessErrorMessage = (value: unknown): string | undefi
|
||||
const failure = getBusinessFailure(value)
|
||||
return failure?.msg || failure?.message
|
||||
}
|
||||
|
||||
export interface ExchangeNewAssetContext {
|
||||
asset_identifier?: string
|
||||
associated_phones?: string[] | null
|
||||
}
|
||||
|
||||
// 新资产上下文由提交时选中的资产提供,绝不回退到旧资产。
|
||||
export const getExchangePhoneAssociationQuery = (
|
||||
failure: unknown,
|
||||
newAsset: ExchangeNewAssetContext
|
||||
): { asset_identifier: string; phone: string } | undefined => {
|
||||
const response = getBusinessFailure(failure)
|
||||
if (Number(response?.code) !== 1202) return undefined
|
||||
const asset_identifier = newAsset.asset_identifier?.trim() || ''
|
||||
const responsePhone = typeof response?.data?.phone === 'string' ? response.data.phone.trim() : ''
|
||||
const phone =
|
||||
responsePhone ||
|
||||
newAsset.associated_phones
|
||||
?.find((value) => typeof value === 'string' && value.trim())
|
||||
?.trim() ||
|
||||
''
|
||||
return asset_identifier && phone ? { asset_identifier, phone } : undefined
|
||||
}
|
||||
|
||||
30
src/utils/business/phoneAssetAssociation.ts
Normal file
30
src/utils/business/phoneAssetAssociation.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { PhoneAssetAssociation } from '@/types/api/phoneAsset'
|
||||
|
||||
export interface PhoneAssetAssociationTarget {
|
||||
asset_identifier: string
|
||||
phone: string
|
||||
}
|
||||
|
||||
// 只接受单个字符串,拒绝重复 query 参数导致的歧义,不把长 ICCID 转成数值。
|
||||
export const getPhoneAssetAssociationTarget = (
|
||||
query: Record<string, unknown>
|
||||
): PhoneAssetAssociationTarget => ({
|
||||
asset_identifier: typeof query.asset_identifier === 'string' ? query.asset_identifier.trim() : '',
|
||||
phone: typeof query.phone === 'string' ? query.phone.trim() : ''
|
||||
})
|
||||
|
||||
export const getMatchingPhoneAssetAssociations = (
|
||||
rows: PhoneAssetAssociation[],
|
||||
target: PhoneAssetAssociationTarget
|
||||
): PhoneAssetAssociation[] => {
|
||||
if (!target.asset_identifier || !target.phone) return []
|
||||
return rows.filter(
|
||||
(row) =>
|
||||
row.status === 1 &&
|
||||
row.asset_identifier === target.asset_identifier &&
|
||||
row.phone === target.phone
|
||||
)
|
||||
}
|
||||
|
||||
export const getPhoneAssetUnbindResultName = (success: boolean): string =>
|
||||
success ? '解绑成功' : '解绑失败'
|
||||
@@ -446,7 +446,9 @@
|
||||
import { normalizeApiError } from '@/utils/business/apiError'
|
||||
import {
|
||||
getExchangePhoneConflictMessage,
|
||||
getExchangeBusinessErrorMessage
|
||||
getExchangeBusinessErrorMessage,
|
||||
getExchangePhoneAssociationQuery,
|
||||
type ExchangeNewAssetContext
|
||||
} from '@/utils/business/exchangeError'
|
||||
|
||||
defineOptions({ name: 'ExchangeManagement' })
|
||||
@@ -455,7 +457,11 @@
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
const handleExchangeFailure = async (failure: unknown, fallback: string) => {
|
||||
const handleExchangeFailure = async (
|
||||
failure: unknown,
|
||||
fallback: string,
|
||||
newAsset: ExchangeNewAssetContext = {}
|
||||
) => {
|
||||
const conflictMessage = getExchangePhoneConflictMessage(failure)
|
||||
if (!conflictMessage) {
|
||||
const normalized = normalizeApiError(failure)
|
||||
@@ -467,6 +473,7 @@
|
||||
return
|
||||
}
|
||||
const canManageAssociations = hasAuth(AUGUST_PERMISSIONS.phoneAssetAssociation.list)
|
||||
const associationQuery = getExchangePhoneAssociationQuery(failure, newAsset)
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
canManageAssociations
|
||||
@@ -481,7 +488,8 @@
|
||||
dangerouslyUseHTMLString: false
|
||||
}
|
||||
)
|
||||
if (canManageAssociations) await router.push(RoutesAlias.PhoneAssetAssociation)
|
||||
if (canManageAssociations)
|
||||
await router.push({ path: RoutesAlias.PhoneAssetAssociation, query: associationQuery })
|
||||
} catch {
|
||||
// 用户关闭提示,不执行解绑或改写换货状态。
|
||||
}
|
||||
@@ -735,6 +743,7 @@
|
||||
type ShopSelectableOption = {
|
||||
shop_id?: number | null
|
||||
shop_name?: string | null
|
||||
associated_phones?: string[]
|
||||
}
|
||||
|
||||
const getShopDisplayName = (option?: ShopSelectableOption | null) => {
|
||||
@@ -1552,6 +1561,11 @@
|
||||
createFormRef.value?.validate(async (valid) => {
|
||||
if (!valid || createLoading.value) return
|
||||
|
||||
// 提交时冻结新资产上下文;请求或提示期间改动表单也不能跳到其他资产。
|
||||
const newAsset: ExchangeNewAssetContext = {
|
||||
asset_identifier: createForm.new_identifier,
|
||||
associated_phones: [...(createSelectedNewAsset.value?.associated_phones || [])]
|
||||
}
|
||||
createLoading.value = true
|
||||
try {
|
||||
const exchangeReason =
|
||||
@@ -1583,9 +1597,9 @@
|
||||
return
|
||||
}
|
||||
|
||||
await handleExchangeFailure(res, '换货单创建失败')
|
||||
await handleExchangeFailure(res, '换货单创建失败', newAsset)
|
||||
} catch (error) {
|
||||
await handleExchangeFailure(error, '换货单创建失败')
|
||||
await handleExchangeFailure(error, '换货单创建失败', newAsset)
|
||||
} finally {
|
||||
createLoading.value = false
|
||||
}
|
||||
@@ -1810,6 +1824,9 @@
|
||||
const handleCompleteExchange = async (row: ExchangeResponse) => {
|
||||
if (completeLoadingId.value === row.id) return
|
||||
|
||||
const newAsset: ExchangeNewAssetContext = {
|
||||
asset_identifier: row.new_asset_identifier || undefined
|
||||
}
|
||||
completeLoadingId.value = row.id
|
||||
|
||||
try {
|
||||
@@ -1829,11 +1846,11 @@
|
||||
// 刷新失败也不能吞掉原操作的 1202 业务响应。
|
||||
await Promise.allSettled([refreshExchangeDetail(row.id), loadExchangeList()])
|
||||
if (res.code === 0) ElMessage.success('操作成功')
|
||||
else await handleExchangeFailure(res, '确认完成失败')
|
||||
else await handleExchangeFailure(res, '确认完成失败', newAsset)
|
||||
} catch (error) {
|
||||
// 仅重新读取原单状态,不乐观标记完成,错误对象可能包含完整手机号,不输出日志。
|
||||
await Promise.allSettled([refreshExchangeDetail(row.id), loadExchangeList()])
|
||||
await handleExchangeFailure(error, '确认完成失败')
|
||||
await handleExchangeFailure(error, '确认完成失败', newAsset)
|
||||
} finally {
|
||||
completeLoadingId.value = null
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<ArtTableFullScreen>
|
||||
<div class="phone-asset-association-page" id="table-full-screen">
|
||||
<ArtSearchBar
|
||||
v-model:filter="searchForm"
|
||||
:filter="searchForm"
|
||||
@update:filter="Object.assign(searchForm, $event)"
|
||||
:items="searchFormItems"
|
||||
:show-expand="false"
|
||||
@reset="handleReset"
|
||||
@@ -33,6 +34,8 @@
|
||||
</ArtTableHeader>
|
||||
|
||||
<ArtTable
|
||||
ref="associationTableRef"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:data="associationList"
|
||||
:currentPage="pagination.page"
|
||||
@@ -130,9 +133,19 @@
|
||||
<span class="text-danger">失败 {{ batchResult?.fail_count ?? 0 }} 项</span>
|
||||
</div>
|
||||
<ElTable :data="batchResult?.items || []" max-height="320" size="small" border>
|
||||
<ElTableColumn prop="asset_type" label="资产类型" width="110" />
|
||||
<ElTableColumn label="资产类型" width="110"
|
||||
><template #default="{ row }">{{
|
||||
row.asset_type === 'iot_card' ? '物联网卡' : '设备'
|
||||
}}</template></ElTableColumn
|
||||
>
|
||||
<ElTableColumn prop="asset_id" label="资产ID" width="90" />
|
||||
<ElTableColumn prop="success" label="结果" width="80" />
|
||||
<ElTableColumn label="结果" width="100"
|
||||
><template #default="{ row }"
|
||||
><ElTag :type="row.success ? 'success' : 'danger'">{{
|
||||
getPhoneAssetUnbindResultName(row.success)
|
||||
}}</ElTag></template
|
||||
></ElTableColumn
|
||||
>
|
||||
<ElTableColumn prop="unbound_count" label="解除关系数" width="100" />
|
||||
<ElTableColumn prop="reason" label="失败原因" show-overflow-tooltip />
|
||||
</ElTable>
|
||||
@@ -207,8 +220,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
computed,
|
||||
h,
|
||||
nextTick,
|
||||
onActivated,
|
||||
onBeforeUnmount,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
reactive,
|
||||
ref,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElTag, FormInstance, FormRules } from 'element-plus'
|
||||
import { PhoneAssetAssociationService } from '@/api/modules'
|
||||
import VoucherUpload from '@/components/business/VoucherUpload.vue'
|
||||
@@ -219,6 +243,13 @@
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { AUGUST_PERMISSIONS } from '@/config/constants'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import ArtTable from '@/components/core/tables/ArtTable.vue'
|
||||
import {
|
||||
getPhoneAssetAssociationTarget,
|
||||
getMatchingPhoneAssetAssociations,
|
||||
getPhoneAssetUnbindResultName,
|
||||
type PhoneAssetAssociationTarget
|
||||
} from '@/utils/business/phoneAssetAssociation'
|
||||
import type {
|
||||
BatchUnbindAssetItem,
|
||||
BatchUnbindPhoneAssetAssociationResponse,
|
||||
@@ -229,6 +260,7 @@
|
||||
defineOptions({ name: 'PhoneAssetAssociation' })
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { hasAuth } = useAuth()
|
||||
const perms = AUGUST_PERMISSIONS.phoneAssetAssociation
|
||||
|
||||
@@ -240,6 +272,9 @@
|
||||
const loading = ref(false)
|
||||
const associationList = ref<PhoneAssetAssociation[]>([])
|
||||
const selectedRows = ref<PhoneAssetAssociation[]>([])
|
||||
const associationTableRef = ref<InstanceType<typeof ArtTable>>()
|
||||
const pendingAutoSelect = ref<PhoneAssetAssociationTarget | null>(null)
|
||||
let listRequestId = 0
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
|
||||
|
||||
const initialSearchState = {
|
||||
@@ -391,6 +426,13 @@
|
||||
}
|
||||
|
||||
const getTableData = async () => {
|
||||
const requestId = ++listRequestId
|
||||
const autoSelectTarget = pendingAutoSelect.value
|
||||
const canAutoSelect = canBatchUnbind.value
|
||||
associationTableRef.value?.clearSelection()
|
||||
selectedRows.value = []
|
||||
associationList.value = []
|
||||
pagination.total = 0
|
||||
loading.value = true
|
||||
try {
|
||||
syncDateRange()
|
||||
@@ -403,26 +445,43 @@
|
||||
created_at_start: toRfc3339(searchForm.created_at_start),
|
||||
created_at_end: toRfc3339(searchForm.created_at_end, true)
|
||||
})
|
||||
if (requestId !== listRequestId) return
|
||||
if (res.code === 0 && res.data) {
|
||||
associationList.value = res.data.items || []
|
||||
pagination.total = res.data.total || 0
|
||||
await nextTick()
|
||||
if (requestId !== listRequestId) return
|
||||
if (
|
||||
autoSelectTarget &&
|
||||
canAutoSelect &&
|
||||
canBatchUnbind.value &&
|
||||
searchForm.asset_identifier.trim() === autoSelectTarget.asset_identifier &&
|
||||
searchForm.phone.trim() === autoSelectTarget.phone
|
||||
) {
|
||||
const matches = getMatchingPhoneAssetAssociations(associationList.value, autoSelectTarget)
|
||||
matches.forEach((row) => associationTableRef.value?.toggleRowSelection(row, true))
|
||||
}
|
||||
// 只在路由进入后选一次,后续手工取消/筛选/翻页不重新选中。
|
||||
pendingAutoSelect.value = null
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取手机号资产关联列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取手机号资产关联列表失败:', error)
|
||||
if (requestId === listRequestId) ElMessage.error(normalizeApiError(error).message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (requestId === listRequestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
pendingAutoSelect.value = null
|
||||
Object.assign(searchForm, { ...initialSearchState })
|
||||
pagination.page = 1
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pendingAutoSelect.value = null
|
||||
pagination.page = 1
|
||||
getTableData()
|
||||
}
|
||||
@@ -432,11 +491,13 @@
|
||||
}
|
||||
|
||||
const handleSizeChange = (pageSize: number) => {
|
||||
pendingAutoSelect.value = null
|
||||
pagination.pageSize = pageSize
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
pendingAutoSelect.value = null
|
||||
pagination.page = page
|
||||
getTableData()
|
||||
}
|
||||
@@ -593,7 +654,8 @@
|
||||
batchResult.value = res.data
|
||||
batchUnbindDialogVisible.value = false
|
||||
batchResultVisible.value = true
|
||||
getTableData()
|
||||
pendingAutoSelect.value = null
|
||||
await getTableData()
|
||||
} catch (error) {
|
||||
console.error('批量解绑失败:', error)
|
||||
} finally {
|
||||
@@ -691,9 +753,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
let pageActive = false
|
||||
const loadRouteTarget = () => {
|
||||
const target = getPhoneAssetAssociationTarget(route.query)
|
||||
Object.assign(searchForm, { ...initialSearchState, ...target, dateRange: [] })
|
||||
pagination.page = 1
|
||||
pagination.pageSize = 20
|
||||
pendingAutoSelect.value = target.asset_identifier && target.phone ? target : null
|
||||
void getTableData()
|
||||
}
|
||||
onMounted(() => {
|
||||
getTableData()
|
||||
pageActive = true
|
||||
loadRouteTarget()
|
||||
})
|
||||
onActivated(() => {
|
||||
if (pageActive) return // 首次挂载已加载。
|
||||
pageActive = true
|
||||
loadRouteTarget()
|
||||
})
|
||||
onDeactivated(() => {
|
||||
pageActive = false
|
||||
++listRequestId
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
pageActive = false
|
||||
++listRequestId
|
||||
})
|
||||
watch(
|
||||
() => [route.path, route.query.asset_identifier, route.query.phone],
|
||||
() => {
|
||||
if (pageActive && route.path === RoutesAlias.PhoneAssetAssociation) loadRouteTarget()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
Reference in New Issue
Block a user