This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# Change: 适配复机实名校验结果提示
|
||||
|
||||
## Why
|
||||
|
||||
复机是否允许取决于运营商的实名能力和后端实名规则,不能以行业卡类别作为前端放行依据。当前复机失败不会向运营清晰呈现后端返回的中文业务原因,无法区分同为行业卡但实名能力不同的放行或拦截结果。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 复用资产信息页现有复机入口和确认框,继续调用 `POST /api/admin/assets/{identifier}/start`。
|
||||
- 补齐复机成功响应类型,读取最新的 `status`、`status_name`、`real_name_status`、`real_name_status_name`。
|
||||
- 复机提交期间禁用入口,避免重复提交。
|
||||
- 复机被后端实名规则或其他业务规则拦截时,在当前复机操作上下文直接展示后端返回的中文业务原因,不以通用前端文案替代,也不只记录到控制台。
|
||||
- 复机成功后重新拉取资产详情,以最新资产数据更新页面。
|
||||
- 前端不得读取或根据 `card_category` 判断是否需要实名、是否允许复机,所有放行或拦截均以后端结果为准。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `asset-information`
|
||||
- Affected code:
|
||||
- `src/types/api/asset.ts`
|
||||
- `src/api/modules/asset.ts`
|
||||
- `src/views/asset-management/asset-information/composables/useAssetOperations.ts`
|
||||
- `src/views/asset-management/asset-information/components/BasicInfoCard.vue`
|
||||
- API contract:
|
||||
- `POST /api/admin/assets/{identifier}/start`
|
||||
@@ -0,0 +1,71 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Asset Start Response Contract
|
||||
|
||||
The asset start API contract SHALL use `POST /api/admin/assets/{identifier}/start` and support a successful response containing the latest `status`, `status_name`, `real_name_status`, and `real_name_status_name` values.
|
||||
|
||||
#### Scenario: Read successful asset start result
|
||||
|
||||
- **WHEN** 用户对资产提交复机且后端成功处理请求
|
||||
- **THEN** 前端类型 MUST support `status`、`status_name`、`real_name_status` 和 `real_name_status_name`
|
||||
- **AND** 前端 MUST use the existing asset start endpoint
|
||||
|
||||
### Requirement: Asset Start Submission and Refresh
|
||||
|
||||
The asset information page SHALL retain the existing asset start entry and confirmation dialog. It MUST prevent duplicate submissions while the asset start request is pending and refresh the asset detail after a successful start.
|
||||
|
||||
#### Scenario: Keep existing asset start confirmation
|
||||
|
||||
- **GIVEN** 用户正在查看处于停机状态的资产信息页
|
||||
- **WHEN** 用户发起复机操作
|
||||
- **THEN** 页面 MUST display the existing start confirmation dialog
|
||||
- **AND** 页面 MUST NOT add a separate asset start entry
|
||||
|
||||
#### Scenario: Prevent duplicate asset start submissions
|
||||
|
||||
- **GIVEN** 用户已确认复机操作
|
||||
- **WHEN** 复机请求仍在提交中
|
||||
- **THEN** 复机按钮 MUST be disabled
|
||||
- **AND** 系统 MUST NOT send another asset start request for that interaction
|
||||
|
||||
#### Scenario: Refresh asset detail after a successful start
|
||||
|
||||
- **WHEN** 复机接口成功返回
|
||||
- **THEN** 页面 MUST reload the current asset detail
|
||||
- **AND** 页面 MUST render the refreshed asset status and real-name status data
|
||||
|
||||
### Requirement: Backend-Controlled Asset Start Realname Validation
|
||||
|
||||
The asset information page SHALL rely exclusively on the backend asset start result to decide whether an asset is allowed to start. The frontend MUST NOT read or use `card_category` to infer real-name requirements or to permit an asset start.
|
||||
|
||||
#### Scenario: Allow start based on backend result
|
||||
|
||||
- **GIVEN** 用户对行业卡资产发起复机
|
||||
- **WHEN** 后端允许复机
|
||||
- **THEN** 页面 MUST treat the operation as successful regardless of the asset `card_category`
|
||||
- **AND** 页面 MUST reload the asset detail
|
||||
|
||||
#### Scenario: Block start based on backend realname validation
|
||||
|
||||
- **GIVEN** 用户对行业卡资产发起复机
|
||||
- **WHEN** 后端因实名规则拒绝复机并返回中文业务原因
|
||||
- **THEN** 页面 MUST keep the asset start operation unsuccessful
|
||||
- **AND** 页面 MUST NOT use `card_category` to override or bypass the backend decision
|
||||
|
||||
### Requirement: Asset Start Business Failure Feedback
|
||||
|
||||
When the asset start API rejects an operation for real-name validation or another business rule, the asset information page SHALL show the backend-provided Chinese business reason in the current asset start operation context.
|
||||
|
||||
#### Scenario: Show backend realname rejection reason
|
||||
|
||||
- **GIVEN** 用户已在资产信息页确认复机
|
||||
- **WHEN** 后端返回实名校验失败及中文业务原因
|
||||
- **THEN** 页面 MUST display that backend Chinese business reason directly in the asset start operation context
|
||||
- **AND** 页面 MUST NOT replace it with a generic frontend failure message
|
||||
|
||||
#### Scenario: Show other backend business rejection reason
|
||||
|
||||
- **GIVEN** 用户已在资产信息页确认复机
|
||||
- **WHEN** 后端返回非实名相关的业务拦截及中文业务原因
|
||||
- **THEN** 页面 MUST display that backend Chinese business reason directly in the asset start operation context
|
||||
- **AND** 页面 MUST NOT only log the failure to the browser console
|
||||
@@ -0,0 +1,24 @@
|
||||
## 1. API Contract
|
||||
|
||||
- [x] 1.1 定义复机成功响应类型,支持 `status`、`status_name`、`real_name_status`、`real_name_status_name`。
|
||||
- [x] 1.2 更新复机接口返回类型,继续使用 `POST /api/admin/assets/{identifier}/start`。
|
||||
|
||||
## 2. Asset Start Interaction
|
||||
|
||||
- [x] 2.1 保持资产信息页现有复机入口和确认框。
|
||||
- [x] 2.2 复机请求提交期间禁用复机按钮,防止重复提交。
|
||||
- [x] 2.3 复机成功后重新拉取资产详情。
|
||||
|
||||
## 3. Backend Realname Validation Feedback
|
||||
|
||||
- [x] 3.1 后端返回实名或其他业务拦截结果时,在复机操作上下文显示后端中文业务原因。
|
||||
- [x] 3.2 不以通用前端错误文案覆盖后端中文业务原因,也不只将错误写入控制台。
|
||||
- [x] 3.3 不读取或根据 `card_category` 推断实名要求或复机放行结果。
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [ ] 4.1 验证复机成功后页面重新加载并展示最新资产详情。
|
||||
- [ ] 4.2 验证后端实名校验拦截时显示其中文业务原因。
|
||||
- [ ] 4.3 验证同为行业卡但运营商实名能力不同的资产,页面按后端结果分别放行或拦截。
|
||||
- [ ] 4.4 验证提交期间无法重复发起复机请求。
|
||||
- [x] 4.5 运行相关前端校验,并执行 `openspec validate update-asset-start-realname-validation-feedback --strict`。
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
AssetPackageParams,
|
||||
AssetCurrentPackageResponse,
|
||||
DeviceStopResponse,
|
||||
AssetStartResponse,
|
||||
AssetWalletTransactionListResponse,
|
||||
AssetWalletTransactionParams,
|
||||
AssetWalletResponse,
|
||||
@@ -182,9 +183,16 @@ export class AssetService extends BaseService {
|
||||
* 前端按资产标识限制 5 分钟内只能调用一次
|
||||
* @param identifier 资产标识符(ICCID 或 VirtualNo)
|
||||
*/
|
||||
static startAsset(identifier: string): Promise<BaseResponse<void>> {
|
||||
static startAsset(
|
||||
identifier: string,
|
||||
config?: Record<string, any>
|
||||
): Promise<BaseResponse<AssetStartResponse>> {
|
||||
return runRateLimitedAssetAction('start', identifier, () =>
|
||||
this.post<BaseResponse<void>>(`/api/admin/assets/${identifier}/start`, {})
|
||||
this.post<BaseResponse<AssetStartResponse>>(
|
||||
`/api/admin/assets/${identifier}/start`,
|
||||
{},
|
||||
config
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,14 @@ export interface AssetPollingStatus {
|
||||
next_poll_at?: string | null // 下次轮询时间
|
||||
}
|
||||
|
||||
// 资产复机结果
|
||||
export interface AssetStartResponse {
|
||||
status: number // 最新资产状态
|
||||
status_name: string // 最新资产状态名称
|
||||
real_name_status: RealNameStatus // 最新实名状态
|
||||
real_name_status_name: string // 最新实名状态名称
|
||||
}
|
||||
|
||||
// 换货关联资产
|
||||
export interface AssetExchangeTraceAsset {
|
||||
asset_type: AssetType
|
||||
|
||||
@@ -469,6 +469,14 @@
|
||||
</template>
|
||||
|
||||
<!-- IoT卡操作按钮 -->
|
||||
<ElAlert
|
||||
v-if="startErrorMessage"
|
||||
:title="startErrorMessage"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-top: 16px"
|
||||
/>
|
||||
<div
|
||||
v-if="cardInfo?.asset_type === 'card'"
|
||||
class="card-operations"
|
||||
@@ -558,6 +566,7 @@
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElButton,
|
||||
ElAlert,
|
||||
ElEmpty,
|
||||
ElMessageBox,
|
||||
ElMessage
|
||||
@@ -670,6 +679,7 @@
|
||||
pollingEnabled: boolean
|
||||
realtimeLoading?: boolean
|
||||
startDisabled?: boolean
|
||||
startErrorMessage?: string
|
||||
stopDisabled?: boolean
|
||||
startRemainingText?: string
|
||||
stopRemainingText?: string
|
||||
@@ -682,6 +692,7 @@
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
realtimeLoading: false,
|
||||
startDisabled: false,
|
||||
startErrorMessage: '',
|
||||
stopDisabled: false,
|
||||
startRemainingText: '',
|
||||
stopRemainingText: ''
|
||||
|
||||
@@ -11,6 +11,7 @@ import { formatRemainingTime, FrontendRateLimitError } from '@/utils/business/ap
|
||||
export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Promise<void>) {
|
||||
// Loading states
|
||||
const enableCardLoading = ref(false)
|
||||
const startFailureMessage = ref('')
|
||||
const disableCardLoading = ref(false)
|
||||
const manualDeactivateCardLoading = ref(false)
|
||||
const rebootDeviceLoading = ref(false)
|
||||
@@ -29,22 +30,33 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
startFailureMessage.value = ''
|
||||
enableCardLoading.value = true
|
||||
const res = await AssetService.startAsset(cardInfo.value.iccid)
|
||||
const res = await AssetService.startAsset(cardInfo.value.iccid, {
|
||||
requestOptions: { errorMessageMode: 'none' }
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('启用成功')
|
||||
if (refreshAssetFn) {
|
||||
await refreshAssetFn()
|
||||
}
|
||||
return true
|
||||
}
|
||||
startFailureMessage.value = res.msg || '复机失败,请稍后重试'
|
||||
return false
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
if (error instanceof FrontendRateLimitError) {
|
||||
ElMessage.warning(`操作过于频繁,请${formatRemainingTime(error.remainingMs)}后再试`)
|
||||
return
|
||||
return false
|
||||
}
|
||||
console.error('启用失败:', error)
|
||||
const backendMessage = error?.response?.data?.msg
|
||||
startFailureMessage.value =
|
||||
typeof backendMessage === 'string' && backendMessage.trim()
|
||||
? backendMessage
|
||||
: '复机失败,请稍后重试'
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
enableCardLoading.value = false
|
||||
}
|
||||
@@ -303,6 +315,7 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
|
||||
return {
|
||||
// Loading states
|
||||
enableCardLoading,
|
||||
startFailureMessage,
|
||||
disableCardLoading,
|
||||
manualDeactivateCardLoading,
|
||||
rebootDeviceLoading,
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
v-model:polling-enabled="pollingEnabled"
|
||||
:realtime-loading="realtimeLoading"
|
||||
:start-disabled="startDisabled"
|
||||
:start-error-message="startFailureMessage"
|
||||
:stop-disabled="stopDisabled"
|
||||
:start-remaining-text="startRemainingText"
|
||||
:stop-remaining-text="stopRemainingText"
|
||||
@@ -258,6 +259,7 @@
|
||||
// ========== 资产操作方法(使用 composable) ==========
|
||||
const {
|
||||
enableCardLoading,
|
||||
startFailureMessage,
|
||||
disableCardLoading,
|
||||
enableCard: enableCardOp,
|
||||
disableCard: disableCardOp,
|
||||
@@ -399,6 +401,7 @@
|
||||
const handleSearch = async ({ identifier }: { identifier: string }) => {
|
||||
// 更新搜索框的值
|
||||
assetSearchCardRef.value?.updateSearchValue(identifier)
|
||||
startFailureMessage.value = ''
|
||||
|
||||
// fetchAssetDetail 内部已经会并行加载所有相关数据,不需要重复调用
|
||||
await fetchAssetDetail(identifier)
|
||||
@@ -458,7 +461,10 @@
|
||||
const handleEnableCard = async () => {
|
||||
if (startDisabled.value) return
|
||||
try {
|
||||
await enableCardOp()
|
||||
const started = await enableCardOp()
|
||||
if (started && cardInfo.value) {
|
||||
await fetchAssetDetail(cardInfo.value.identifier)
|
||||
}
|
||||
} finally {
|
||||
updateRateLimitNow()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user