fix: some
All checks were successful
构建并部署前端到测试环境 / deploy (push) Successful in 56s

This commit is contained in:
luo
2026-10-13 17:21:46 +08:00
parent b2a76adc9a
commit c31183205d
13 changed files with 427 additions and 18 deletions

View File

@@ -76,6 +76,10 @@
创建失败保留资产/原因等字段;完成失败重新加载换货单,保持后端「已发货待确认」事实,不乐观标记完成。解除后由用户再次提交。取消只关闭提示,不当作解绑或换货成功。
用户追加明确要求快捷定位并勾选关联:创建换货冻结当前新资产标识及关联手机号数组,确认完成使用原单新资产标识;错误响应手机号优先。主动跳转通过 `asset_identifier` 和 `phone` 查询参数传递目标,允许该 URL 带号码但不额外写日志或本地存储。关联页按 1 页/20 条加载,仅默认选中精确匹配的有效行;不自动解绑,继续原原因与二次确认。
通过 ArtTable 透传清空/勾选方法,数据更新后 nextTick 完成实际选择。缓存重入和路由参数变更重置查询,请求序号防止过期响应覆盖;手工筛选/翻页、解绑后刷新清除旧选择。批量结果保持后端布尔契约,仅展示中文「解绑成功/解绑失败」。原查看与批量解绑权限不变。
### 5. 预警只展示快照,不重算阈值
删除 `PackageTrafficAlertRuleItem.real_data_mb` 与规则行/详情/列设置依赖。来自 `/api/admin/packages` 的套餐选项 `real_data_mb` 保留,套餐选择与规则参数校验不变。

View File

@@ -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 弹窗 → 带参跳转 → 筛选并勾选有效记录 → 手工取消禁用按钮 → 原确认表单 → 中文成功结果链路,没有调用真实后台解绑接口。

View File

@@ -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 端请求。

View File

@@ -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 不猜测缺失值或自动执行解绑

View File

@@ -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 保持原后端契约

View File

@@ -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 项回归测试通过,本地完整跳转、实际勾选、手工取消及模拟批量结果验证通过;未操作真实资产。

View File

@@ -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']) {

View File

@@ -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

View File

@@ -234,6 +234,9 @@
// 提供给父组件的方法
defineExpose({
clearSelection: () => tableRef.value?.clearSelection(),
toggleRowSelection: (row: any, selected?: boolean) =>
tableRef.value?.toggleRowSelection(row, selected),
// 展开所有行
expandAll: () => {
const elTable = tableRef.value

View File

@@ -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
}

View 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 ? '解绑成功' : '解绑失败'

View File

@@ -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
}

View File

@@ -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">