fix: 新增: 修改资产套餐已用量, 修改资产套餐过期时间
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m22s

This commit is contained in:
sexygoat
2026-06-13 10:41:10 +08:00
parent e04a283319
commit 2ecab976f5
12 changed files with 688 additions and 68 deletions

View File

@@ -0,0 +1,28 @@
## Context
The feature exposes high-impact package usage adjustments from the asset information package list. Because it can change real package consumption and expiration, the UI gate must combine deployment environment, account type, and production button permissions.
## Goals / Non-Goals
- Goals: Add two package-list operations, call the documented PATCH APIs, validate operator input, and refresh page data after success.
- Goals: Keep production access limited to platform users (`user_type=2`) with explicit button permissions.
- Goals: Keep test/development access limited to super admins (`user_type=1`) without requiring button permissions.
- Non-Goals: Add backend endpoints, change package list data retrieval, or change existing refund/package display behavior.
## Decisions
- Decision: Treat non-production as `import.meta.env.DEV` or a project-defined test mode discovered during implementation; production behavior MUST be used for production builds.
- Decision: Use separate permission codes for the two production-only buttons so operators can be granted one action without the other.
- Decision: Prefer small modal forms from the package list component unless an existing asset-operation dialog pattern provides a clearer reuse point.
- Decision: Send `data_usage_mb` as a non-negative integer MB value and `expires_at` as the selected formatted datetime string.
## Risks / Trade-offs
- Permission code names are not specified in the request. Implementation MUST confirm or define final codes before wiring production checks.
- Environment naming can vary by deployment. Implementation MUST inspect existing env conventions and avoid making test-only access available in production mode.
- Adjusting real used data can make displayed virtual/remaining values stale if the list is not refreshed. The UI MUST refresh after success.
## Open Questions
- What are the final production permission codes for “修改已用量” and “修改过期时间”?
- Which env flag identifies the requested “测试环境” if it is not `import.meta.env.DEV`?

View File

@@ -0,0 +1,33 @@
# Change: Add asset package admin adjustment actions
## Why
资产信息页的套餐列表目前只能查看套餐使用记录,缺少运营侧直接修正套餐真实已用量和过期时间的入口。后端已提供对应 PATCH 接口,前端需要补齐受环境、账号类型和按钮权限约束的操作能力。
## What Changes
- 在资产信息页“套餐列表”的“操作”列新增两个操作:
- 修改已用量
- 修改过期时间
- 新增两个 PATCH 接口的前端契约:
- `PATCH /api/admin/assets/{identifier}/packages/{package_usage_id}/used-data`
- `PATCH /api/admin/assets/{identifier}/packages/{package_usage_id}/expires-at`
- 两个操作按钮使用环境差异化可见性规则:
- 线上环境:仅 `user_type=2` 且拥有对应按钮权限时显示
- 测试/开发环境:仅 `user_type=1` 显示,不要求按钮权限
- 为两个操作提供弹窗表单、输入校验、提交 loading、成功提示和刷新套餐列表行为。
- 不改变套餐列表既有字段展示、退款入口或现有套餐流量展示口径。
## Impact
- Affected specs:
- `asset-package-admin-adjustments`
- Affected code:
- `src/api/modules/asset.ts`
- `src/types/api/asset.ts`
- `src/views/asset-management/asset-information/components/PackageListCard.vue`
- Potential dialog component(s) under `src/views/asset-management/asset-information/components/dialogs/`
- Dependencies:
- Current asset identifier from asset information page context
- Current logged-in account `user_type`
- Button permissions for production visibility

View File

@@ -0,0 +1,134 @@
## ADDED Requirements
### Requirement: Package List Adjustment Actions
The asset information package list SHALL provide operator actions to update a package usage record's real used data and expiration time.
#### Scenario: Render adjustment actions in the package list
- **GIVEN** 用户正在查看资产信息页的“套餐列表”
- **AND** 当前登录账号满足对应操作的可见性规则
- **WHEN** 页面渲染套餐列表操作列
- **THEN** 系统 MUST 显示“修改已用量”操作
- **AND** 系统 MUST 显示“修改过期时间”操作
#### Scenario: Open used-data adjustment form
- **GIVEN** 用户可见“修改已用量”操作
- **WHEN** 用户点击某条套餐记录的“修改已用量”
- **THEN** 系统 MUST 打开修改已用量弹窗
- **AND** 弹窗 MUST 展示当前套餐记录上下文
- **AND** 表单 MUST 要求输入新的套餐真实已用量 MB
#### Scenario: Open expiration adjustment form
- **GIVEN** 用户可见“修改过期时间”操作
- **WHEN** 用户点击某条套餐记录的“修改过期时间”
- **THEN** 系统 MUST 打开修改过期时间弹窗
- **AND** 弹窗 MUST 展示当前套餐记录上下文
- **AND** 表单 MUST 要求输入新的套餐过期时间
### Requirement: Used Data Update API Integration
The frontend SHALL update a package usage record's real used data through `PATCH /api/admin/assets/{identifier}/packages/{package_usage_id}/used-data`.
#### Scenario: Submit valid used-data update
- **GIVEN** 用户正在修改某条套餐记录的已用量
- **AND** 当前资产标识符为 `identifier`
- **AND** 当前套餐使用记录 ID 为 `package_usage_id`
- **WHEN** 用户输入非负整数 `data_usage_mb` 并提交
- **THEN** 系统 MUST send `PATCH /api/admin/assets/{identifier}/packages/{package_usage_id}/used-data`
- **AND** 请求体 MUST be JSON with `data_usage_mb`
- **AND** `data_usage_mb` MUST be a non-negative integer MB value
#### Scenario: Reject invalid used-data input
- **GIVEN** 用户正在修改套餐已用量
- **WHEN** 用户未输入值、输入负数或输入非整数
- **THEN** 系统 MUST block submission
- **AND** 系统 MUST show a validation message explaining that the used data must be a non-negative integer MB value
- **AND** 系统 MUST NOT call the PATCH endpoint
#### Scenario: Used-data update succeeds
- **GIVEN** used-data PATCH request returns `code=0`
- **WHEN** 系统收到响应
- **THEN** 系统 MUST show a success message
- **AND** 系统 MUST close the edit dialog
- **AND** 系统 MUST refresh the asset package list so the latest package usage data is displayed
### Requirement: Expiration Time Update API Integration
The frontend SHALL update a package usage record's expiration time through `PATCH /api/admin/assets/{identifier}/packages/{package_usage_id}/expires-at`.
#### Scenario: Submit valid expiration update
- **GIVEN** 用户正在修改某条套餐记录的过期时间
- **AND** 当前资产标识符为 `identifier`
- **AND** 当前套餐使用记录 ID 为 `package_usage_id`
- **WHEN** 用户输入有效的 `expires_at` 并提交
- **THEN** 系统 MUST send `PATCH /api/admin/assets/{identifier}/packages/{package_usage_id}/expires-at`
- **AND** 请求体 MUST be JSON with `expires_at`
- **AND** `expires_at` MUST be formatted as `YYYY-MM-DD HH:MM:SS` or another backend-accepted RFC3339 string
#### Scenario: Reject missing expiration input
- **GIVEN** 用户正在修改套餐过期时间
- **WHEN** 用户未选择或未输入过期时间
- **THEN** 系统 MUST block submission
- **AND** 系统 MUST show a validation message explaining that expiration time is required
- **AND** 系统 MUST NOT call the PATCH endpoint
#### Scenario: Expiration update succeeds
- **GIVEN** expires-at PATCH request returns `code=0`
- **WHEN** 系统收到响应
- **THEN** 系统 MUST show a success message
- **AND** 系统 MUST close the edit dialog
- **AND** 系统 MUST refresh the asset package list so the latest expiration time is displayed
### Requirement: Environment And Account Visibility Gate
The frontend SHALL gate both package adjustment actions by environment, current account `user_type`, and production button permissions.
#### Scenario: Production platform user with permission sees matching action
- **GIVEN** 当前运行环境为线上环境
- **AND** 当前登录账号的 `user_type``2`
- **AND** 当前登录账号拥有“修改已用量”的按钮权限
- **WHEN** 页面渲染套餐列表操作列
- **THEN** 系统 MUST show “修改已用量”
#### Scenario: Production platform user without permission cannot see matching action
- **GIVEN** 当前运行环境为线上环境
- **AND** 当前登录账号的 `user_type``2`
- **AND** 当前登录账号缺少“修改过期时间”的按钮权限
- **WHEN** 页面渲染套餐列表操作列
- **THEN** 系统 MUST NOT show “修改过期时间”
#### Scenario: Production super admin cannot see adjustment actions
- **GIVEN** 当前运行环境为线上环境
- **AND** 当前登录账号的 `user_type``1`
- **WHEN** 页面渲染套餐列表操作列
- **THEN** 系统 MUST NOT show “修改已用量”
- **AND** 系统 MUST NOT show “修改过期时间”
#### Scenario: Test environment super admin sees actions without permissions
- **GIVEN** 当前运行环境为测试或开发环境
- **AND** 当前登录账号的 `user_type``1`
- **AND** 当前登录账号没有两个调整操作的按钮权限
- **WHEN** 页面渲染套餐列表操作列
- **THEN** 系统 MUST show “修改已用量”
- **AND** 系统 MUST show “修改过期时间”
#### Scenario: Test environment non-super-admin cannot see actions
- **GIVEN** 当前运行环境为测试或开发环境
- **AND** 当前登录账号的 `user_type` is not `1`
- **WHEN** 页面渲染套餐列表操作列
- **THEN** 系统 MUST NOT show “修改已用量”
- **AND** 系统 MUST NOT show “修改过期时间”

View File

@@ -0,0 +1,21 @@
## 1. Implementation
- [x] 1.1 Add API request/response types for package used-data and expires-at updates.
- [x] 1.2 Add AssetService PATCH methods for `/assets/{identifier}/packages/{package_usage_id}/used-data` and `/assets/{identifier}/packages/{package_usage_id}/expires-at`.
- [x] 1.3 Pass the current asset identifier to the package list component if it is not already available there.
- [x] 1.4 Add operation buttons to the package list with environment, `user_type`, and permission visibility rules.
- [x] 1.5 Add edit-used-data dialog/form with non-negative integer MB validation and submit handling.
- [x] 1.6 Add edit-expires-at dialog/form with required datetime validation and submit handling.
- [x] 1.7 Refresh package list data and show success feedback after each successful update.
- [x] 1.8 Run targeted lint/type checks for changed files and document any unrelated existing failures.
## 2. Permission Setup
- [x] 2.1 Confirm final production permission codes for “修改已用量” and “修改过期时间”.
- [x] 2.2 Wire the confirmed permission codes into button visibility checks.
## 3. Validation
- [x] 3.1 Verify production-mode behavior: `user_type=2` requires the matching permission for each button.
- [x] 3.2 Verify test/development-mode behavior: only `user_type=1` sees the buttons and permissions are ignored.
- [x] 3.3 Verify unauthorized account types do not see either operation.

View File

@@ -21,7 +21,10 @@ import type {
UpdateAssetRealnameStatusRequest,
DtoUpdateAssetRealnameStatusResponse,
AssetOperationLogsResponse,
AssetOperationLogsParams
AssetOperationLogsParams,
AssetPackageUsageRecord,
UpdateAssetPackageUsedDataRequest,
UpdateAssetPackageExpiresAtRequest
} from '@/types/api'
export class AssetService extends BaseService {
@@ -104,6 +107,36 @@ export class AssetService extends BaseService {
)
}
/**
* 修改套餐真实已用量
* PATCH /api/admin/assets/:identifier/packages/:package_usage_id/used-data
*/
static updateAssetPackageUsedData(
identifier: string,
packageUsageId: number,
data: UpdateAssetPackageUsedDataRequest
): Promise<BaseResponse<AssetPackageUsageRecord>> {
return this.patch<BaseResponse<AssetPackageUsageRecord>>(
`/api/admin/assets/${identifier}/packages/${packageUsageId}/used-data`,
data as Record<string, any>
)
}
/**
* 修改套餐过期时间
* PATCH /api/admin/assets/:identifier/packages/:package_usage_id/expires-at
*/
static updateAssetPackageExpiresAt(
identifier: string,
packageUsageId: number,
data: UpdateAssetPackageExpiresAtRequest
): Promise<BaseResponse<AssetPackageUsageRecord>> {
return this.patch<BaseResponse<AssetPackageUsageRecord>>(
`/api/admin/assets/${identifier}/packages/${packageUsageId}/expires-at`,
data as Record<string, any>
)
}
// ========== 资产停复机操作(统一接口)==========
/**

View File

@@ -36,7 +36,7 @@
<ElFormItem label="实收金额">
<ElInput :model-value="selectedOrderActualPaid" disabled style="width: 100%" />
</ElFormItem>
<ElFormItem label="退款原因">
<ElFormItem label="退款原因" prop="refund_reason">
<ElInput
v-model="formData.refund_reason"
type="textarea"
@@ -148,6 +148,7 @@
{ required: true, message: '请输入申请退款金额', trigger: 'blur' },
{ validator: validateRefundAmount, trigger: 'blur' }
],
refund_reason: [{ required: true, message: '请输入退款原因', trigger: 'blur' }],
refund_voucher_key: [{ required: true, message: '请上传退款凭证', trigger: 'change' }]
})
@@ -173,6 +174,9 @@
const res = await OrderService.getOrders(params)
if (res.code === 0) {
orderSearchOptions.value = res.data.items || []
if (hasInitialOrder.value && props.initialOrderId) {
formData.order_id = props.initialOrderId
}
if (hasInitialOrder.value && props.initialOrderNo) {
const matchedOrder = orderSearchOptions.value.find(
(o) => o.order_no === props.initialOrderNo
@@ -328,6 +332,9 @@
watch(dialogVisible, (val) => {
if (val) {
if (hasInitialOrder.value && props.initialOrderId) {
formData.order_id = props.initialOrderId
}
if (hasInitialOrder.value && props.initialOrderNo) {
searchOrders(props.initialOrderNo)
} else {

View File

@@ -270,6 +270,14 @@ export interface AssetPackageParams {
page_size?: number // 每页数量默认50
}
export interface UpdateAssetPackageUsedDataRequest {
data_usage_mb: number | null // 新的套餐真实已用量(MB)
}
export interface UpdateAssetPackageExpiresAtRequest {
expires_at: string // 新的套餐过期时间格式YYYY-MM-DD HH:MM:SS 或 RFC3339
}
/**
* 当前套餐响应(结构同套餐使用记录单项)
* 对应接口GET /api/admin/assets/:asset_type/:id/current-package

View File

@@ -8,7 +8,7 @@ export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const ElButton: (typeof import('element-plus/es'))['ElButton']
const ElMessageBox: (typeof import('element-plus/es'))['ElMessageBox']
const ElMessageBox: typeof import('element-plus/es')['ElMessageBox']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']

View File

@@ -83,7 +83,55 @@ declare module 'vue' {
CreateRefundDialog: typeof import('./../components/business/CreateRefundDialog.vue')['default']
CustomerAccountDialog: typeof import('./../components/business/CustomerAccountDialog.vue')['default']
DetailPage: typeof import('./../components/common/DetailPage.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
ElCalendar: typeof import('element-plus/es')['ElCalendar']
ElCard: typeof import('element-plus/es')['ElCard']
ElCascader: typeof import('element-plus/es')['ElCascader']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCol: typeof import('element-plus/es')['ElCol']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']
ElWatermark: typeof import('element-plus/es')['ElWatermark']
HorizontalSubmenu: typeof import('./../components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue')['default']
ImportDialog: typeof import('./../components/business/ImportDialog.vue')['default']
LoginLeftView: typeof import('./../components/core/views/login/LoginLeftView.vue')['default']
@@ -107,4 +155,7 @@ declare module 'vue' {
ThemeSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/ThemeSettings.vue')['default']
UpdateRealnameStatusDialog: typeof import('./../components/business/UpdateRealnameStatusDialog.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

View File

@@ -200,36 +200,54 @@
{{ formatDateTime(scope.row.created_at) }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="180" fixed="right">
<ElTableColumn label="操作" width="240" fixed="right">
<template #default="scope">
<ElButton
v-if="hasAuth('package_usage:daily_records')"
type="primary"
link
@click="handlePackageNameClick(scope.row)"
>
流量详单
</ElButton>
<ElButton
v-if="scope.row.status !== 4 && hasAuth('package:create_refund')"
type="primary"
link
@click="handleCreateRefund(scope.row)"
>
退款申请
</ElButton>
<ElButton
v-if="
scope.row.status === 4 &&
scope.row.refund_id &&
hasAuth('refund:package_list_detail')
"
type="primary"
link
@click="handleViewRefundDetail(scope.row)"
>
退款详情
</ElButton>
<div class="package-actions">
<ElButton
v-if="hasAuth('package_usage:daily_records')"
type="primary"
link
@click="handlePackageNameClick(scope.row)"
>
流量详单
</ElButton>
<ElButton
v-if="scope.row.status !== 4 && hasAuth('package:create_refund')"
type="primary"
link
@click="handleCreateRefund(scope.row)"
>
退款申请
</ElButton>
<ElButton
v-if="canUpdateUsedData"
type="primary"
link
@click="handleShowUsedDataDialog(scope.row)"
>
修改已用量
</ElButton>
<ElButton
v-if="canUpdateExpiresAt"
type="primary"
link
@click="handleShowExpiresAtDialog(scope.row)"
>
修改过期时间
</ElButton>
<ElButton
v-if="
scope.row.status === 4 &&
scope.row.refund_id &&
hasAuth('refund:package_list_detail')
"
type="primary"
link
@click="handleViewRefundDetail(scope.row)"
>
退款详情
</ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
@@ -250,14 +268,96 @@
<!-- 退款申请对话框 -->
<CreateRefundDialog
v-model="createRefundDialogVisible"
:initial-order-id="currentRefundOrderId"
:initial-order-no="currentRefundOrderNo"
@success="handleRefundSuccess"
/>
<ElDialog
v-model="usedDataDialogVisible"
title="修改已用量"
width="30%"
@closed="resetUsedDataForm"
>
<ElForm
ref="usedDataFormRef"
:model="usedDataForm"
:rules="usedDataRules"
label-width="90px"
>
<ElFormItem label="套餐名称">
<span>{{ selectedPackage?.package_name || '-' }}</span>
</ElFormItem>
<ElFormItem label="当前已用量">
<span>{{ formatDataSize(selectedPackage?.real_used_mb || 0) }}</span>
</ElFormItem>
<ElFormItem label="新的已用量" prop="data_usage">
<div class="used-data-input">
<ElInputNumber
v-model="usedDataForm.data_usage"
:min="0"
:max="usedDataMaxValue"
:precision="usedDataForm.unit === 'MB' ? 0 : 3"
:step="usedDataForm.unit === 'MB' ? 1 : 0.1"
class="used-data-input__number"
placeholder="请输入新的套餐真实已用量"
/>
<ElSelect v-model="usedDataForm.unit" class="used-data-input__unit">
<ElOption label="MB" value="MB" />
<ElOption label="GB" value="GB" />
</ElSelect>
</div>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="usedDataDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="usedDataSubmitting" @click="handleSubmitUsedData">
确认修改
</ElButton>
</template>
</ElDialog>
<ElDialog
v-model="expiresAtDialogVisible"
title="修改过期时间"
width="420px"
@closed="resetExpiresAtForm"
>
<ElForm
ref="expiresAtFormRef"
:model="expiresAtForm"
:rules="expiresAtRules"
label-width="120px"
>
<ElFormItem label="套餐名称">
<span>{{ selectedPackage?.package_name || '-' }}</span>
</ElFormItem>
<ElFormItem label="当前过期时间">
<span>{{ formatDateTime(selectedPackage?.expires_at) }}</span>
</ElFormItem>
<ElFormItem label="新的过期时间" prop="expires_at">
<ElDatePicker
v-model="expiresAtForm.expires_at"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择新的套餐过期时间"
style="width: 100%"
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="expiresAtDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="expiresAtSubmitting" @click="handleSubmitExpiresAt">
确认修改
</ElButton>
</template>
</ElDialog>
</ElCard>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import {
ElCard,
ElTable,
@@ -266,13 +366,22 @@
ElButton,
ElProgress,
ElEmpty,
ElPagination
ElPagination,
ElDialog,
ElForm,
ElFormItem,
ElInputNumber,
ElDatePicker,
ElSelect,
ElOption
} from 'element-plus'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useAssetFormatters } from '../composables/useAssetFormatters'
import { formatDateTime } from '@/utils/business/format'
import type { AssetPackageUsageRecord } from '@/types/api'
import { AssetService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { storeToRefs } from 'pinia'
@@ -290,15 +399,80 @@
const isAdminOrPlatform = computed(
() => userInfo.value.user_type === 1 || userInfo.value.user_type === 2
)
const isProductionMode = import.meta.env.MODE === 'production'
const canUseAdjustmentInCurrentEnv = (permission: string) => {
if (isProductionMode) {
return userInfo.value.user_type === 2 && hasAuth(permission)
}
return userInfo.value.user_type === 1
}
const canUpdateUsedData = computed(() => canUseAdjustmentInCurrentEnv('package:update_used_data'))
const canUpdateExpiresAt = computed(() =>
canUseAdjustmentInCurrentEnv('package:update_expires_at')
)
// 退款申请对话框
const createRefundDialogVisible = ref(false)
const currentRefundOrderId = ref<number | undefined>(undefined)
const currentRefundOrderNo = ref<string | undefined>(undefined)
const selectedPackage = ref<PackageInfo>()
const usedDataDialogVisible = ref(false)
const expiresAtDialogVisible = ref(false)
const usedDataSubmitting = ref(false)
const expiresAtSubmitting = ref(false)
const usedDataFormRef = ref<FormInstance>()
const expiresAtFormRef = ref<FormInstance>()
const usedDataForm = reactive<{ data_usage: number | undefined; unit: 'MB' | 'GB' }>({
data_usage: undefined,
unit: 'MB'
})
const expiresAtForm = reactive<{ expires_at: string }>({
expires_at: ''
})
const usedDataMaxValue = computed(() => {
const realTotalMb = selectedPackage.value?.real_total_mb ?? 0
return usedDataForm.unit === 'GB' ? Number((realTotalMb / 1024).toFixed(3)) : realTotalMb
})
const validateUsedData = (
_rule: unknown,
_value: number | undefined,
callback: (error?: Error) => void
) => {
const dataUsageMb = getUsedDataMb()
if (usedDataForm.data_usage === undefined || usedDataForm.data_usage === null) {
callback(new Error('请输入新的套餐真实已用量'))
return
}
if (dataUsageMb === undefined || dataUsageMb < 0 || !Number.isInteger(dataUsageMb)) {
callback(new Error('请输入可换算为非负整数 MB 的已用量'))
return
}
if (dataUsageMb > (selectedPackage.value?.real_total_mb ?? 0)) {
callback(new Error('新的已用量不能大于真流量总量'))
return
}
callback()
}
const usedDataRules = reactive<FormRules>({
data_usage: [{ validator: validateUsedData, trigger: 'blur' }]
})
const expiresAtRules = reactive<FormRules>({
expires_at: [{ required: true, message: '请选择新的套餐过期时间', trigger: 'change' }]
})
const handleCreateRefund = (row: PackageInfo) => {
if (!hasAuth('package:create_refund')) {
ElMessage.warning('您没有创建退款申请的权限')
return
}
currentRefundOrderId.value = row.order_id
currentRefundOrderNo.value = row.order_no
createRefundDialogVisible.value = true
}
@@ -307,6 +481,96 @@
emit('refresh')
}
const getPackageUsageId = (row: PackageInfo) => row.package_usage_id ?? row.id
const getUsedDataMb = () => {
if (usedDataForm.data_usage === undefined || usedDataForm.data_usage === null) return undefined
return usedDataForm.unit === 'GB' ? usedDataForm.data_usage * 1024 : usedDataForm.data_usage
}
const handleShowUsedDataDialog = (row: PackageInfo) => {
selectedPackage.value = row
usedDataForm.data_usage = row.real_used_mb ?? 0
usedDataForm.unit = 'MB'
usedDataDialogVisible.value = true
}
const handleShowExpiresAtDialog = (row: PackageInfo) => {
selectedPackage.value = row
expiresAtForm.expires_at = row.expires_at ? formatDateTime(row.expires_at) : ''
expiresAtDialogVisible.value = true
}
const resetUsedDataForm = () => {
usedDataFormRef.value?.resetFields()
usedDataForm.data_usage = undefined
usedDataForm.unit = 'MB'
selectedPackage.value = undefined
}
const resetExpiresAtForm = () => {
expiresAtFormRef.value?.resetFields()
expiresAtForm.expires_at = ''
selectedPackage.value = undefined
}
watch(
() => usedDataForm.unit,
() => {
if (usedDataForm.data_usage === undefined) return
usedDataForm.data_usage = Math.min(usedDataForm.data_usage, usedDataMaxValue.value)
}
)
const handleSubmitUsedData = async () => {
if (!usedDataFormRef.value || !selectedPackage.value) return
const packageUsageId = getPackageUsageId(selectedPackage.value)
if (!props.assetIdentifier || packageUsageId === undefined) {
ElMessage.error('缺少资产标识或套餐使用记录ID')
return
}
await usedDataFormRef.value.validate(async (valid) => {
if (!valid) return
usedDataSubmitting.value = true
try {
const dataUsageMb = getUsedDataMb()
await AssetService.updateAssetPackageUsedData(props.assetIdentifier, packageUsageId, {
data_usage_mb: dataUsageMb ?? null
})
ElMessage.success('已用量修改成功')
usedDataDialogVisible.value = false
emit('refresh')
} finally {
usedDataSubmitting.value = false
}
})
}
const handleSubmitExpiresAt = async () => {
if (!expiresAtFormRef.value || !selectedPackage.value) return
const packageUsageId = getPackageUsageId(selectedPackage.value)
if (!props.assetIdentifier || packageUsageId === undefined) {
ElMessage.error('缺少资产标识或套餐使用记录ID')
return
}
await expiresAtFormRef.value.validate(async (valid) => {
if (!valid) return
expiresAtSubmitting.value = true
try {
await AssetService.updateAssetPackageExpiresAt(props.assetIdentifier, packageUsageId, {
expires_at: expiresAtForm.expires_at
})
ElMessage.success('过期时间修改成功')
expiresAtDialogVisible.value = false
emit('refresh')
} finally {
expiresAtSubmitting.value = false
}
})
}
// Props 使用 API 类型
type PackageInfo = AssetPackageUsageRecord
@@ -326,6 +590,7 @@
boundDeviceId?: number
boundDeviceNo?: string
loading?: boolean
assetIdentifier?: string
}
const props = withDefaults(defineProps<Props>(), {
@@ -334,7 +599,8 @@
pageSize: 10,
boundDeviceId: undefined,
boundDeviceNo: '',
loading: false
loading: false,
assetIdentifier: ''
})
// Emits
@@ -464,6 +730,19 @@
white-space: nowrap;
}
.package-actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px 8px;
align-items: center;
.el-button {
justify-content: flex-start;
width: 100%;
margin-left: 0;
}
}
.traffic-progress {
.progress-text {
display: flex;
@@ -571,4 +850,28 @@
}
}
}
.used-data-input {
display: flex;
flex-wrap: nowrap;
gap: 8px;
width: 100%;
&__number {
flex: 1;
min-width: 0;
}
&__unit {
width: 112px;
:deep(.el-select__placeholder) {
justify-content: flex-start;
}
:deep(.el-select__selected-item) {
justify-content: flex-start;
}
}
}
</style>

View File

@@ -58,6 +58,7 @@
:total="packagePagination.total"
:page="packagePagination.page"
:page-size="packagePagination.pageSize"
:asset-identifier="cardInfo.identifier"
:bound-device-id="cardInfo.bound_device_id"
:bound-device-no="cardInfo.bound_device_no"
:loading="packageLoading"

View File

@@ -16,39 +16,40 @@
<DetailPage v-if="detailData" :sections="detailSections" :data="detailData" />
<!-- 订单列表 -->
<ElCard style="margin-top: 20px" shadow="never">
<div
v-if="detailData && detailData.items && detailData.items.length > 0"
class="order-items-section"
>
<h3 class="section-title">订单列表</h3>
<ElTable :data="detailData.items" border>
<ElTableColumn prop="package_name" label="套餐名称" />
<ElTableColumn prop="package_type" label="套餐类型">
<template #default="{ row }">
{{
row.package_type === 'formal'
? '正式套餐'
: row.package_type === 'addon'
? '加油包'
: '-'
}}
</template>
</ElTableColumn>
<ElTableColumn prop="quantity" label="数量" />
<ElTableColumn prop="unit_price" label="单价">
<template #default="{ row }">
{{ formatCurrency(row.unit_price) }}
</template>
</ElTableColumn>
<ElTableColumn prop="amount" label="金额">
<template #default="{ row }">
{{ formatCurrency(row.amount) }}
</template>
</ElTableColumn>
</ElTable>
</div>
</ElCard>
<div v-if="detailData && detailData.items && detailData.items.length > 0">
<ElCard style="margin-top: 20px" shadow="never">
<div
class="order-items-section"
>
<h3 class="section-title">订单列表</h3>
<ElTable :data="detailData.items" border>
<ElTableColumn prop="package_name" label="套餐名称" />
<ElTableColumn prop="package_type" label="套餐类型">
<template #default="{ row }">
{{
row.package_type === 'formal'
? '正式套餐'
: row.package_type === 'addon'
? '加油包'
: '-'
}}
</template>
</ElTableColumn>
<ElTableColumn prop="quantity" label="数量" />
<ElTableColumn prop="unit_price" label="单价">
<template #default="{ row }">
{{ formatCurrency(row.unit_price) }}
</template>
</ElTableColumn>
<ElTableColumn prop="amount" label="金额">
<template #default="{ row }">
{{ formatCurrency(row.amount) }}
</template>
</ElTableColumn>
</ElTable>
</div>
</ElCard>
</div>
<!-- 加载中 -->
<div v-if="loading" class="loading-container">