Compare commits

12 Commits

Author SHA1 Message Date
luo
8229ad9248 fix: 退款管理申请退款显示逻辑
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 52m24s
2026-09-12 11:45:42 +08:00
luo
fc01fa2ac9 fix: 超时时间改为90s, 分配店铺查询
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 6m11s
2026-09-09 16:40:40 +08:00
luo
65cc63674e fix: 格式化时间
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m44s
2026-09-08 14:33:59 +08:00
luo
01cddd7eda fix:bug
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m46s
2026-09-08 10:59:38 +08:00
luo
a7b006076a fix: 字段位置交换
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 9m55s
2026-09-04 17:08:35 +08:00
luo
c36c59e268 fix: 推送到main
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 23m29s
2026-08-31 17:27:08 +08:00
luo
e56951d4b7 fix: 资产信息 绑定卡运营商显示逻辑 2026-08-31 17:10:27 +08:00
luo
b5c58d695f fix: ui 2026-08-24 16:44:08 +08:00
luo
6f6ac83b71 fix: ui 2026-08-24 16:37:21 +08:00
luo
48e4adf3ff fix: ui 2026-08-24 16:33:20 +08:00
luo
d335984c20 fix: 店铺用户名 2026-08-24 16:10:24 +08:00
luo
89e6d19d1f fix: other 2026-08-24 10:18:09 +08:00
29 changed files with 383 additions and 274 deletions

View File

@@ -3,7 +3,7 @@ name: 构建并部署前端到测试环境
on:
push:
branches:
- develop
- main
- dev
- test
@@ -27,7 +27,7 @@ jobs:
- name: 设置镜像标签
id: tag
run: |
if [ "${{ github.ref }}" = "refs/heads/develop" ]; then
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tag=latest" >> $GITHUB_OUTPUT
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
echo "tag=dev" >> $GITHUB_OUTPUT
@@ -51,8 +51,8 @@ jobs:
docker push ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
docker push ${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: 部署到本地(仅 develop 分支)
if: github.ref == 'refs/heads/develop'
- name: 部署到本地(仅 main 分支)
if: github.ref == 'refs/heads/main'
run: |
# 确保部署目录存在
mkdir -p ${{ env.DEPLOY_DIR }}

View File

@@ -0,0 +1,17 @@
# Change: Add purchased package names to the order list
## Why
Order list records already include their purchased line items, but operators cannot identify the purchased packages without opening the order detail. Showing these names next to the order number makes multi-package orders immediately understandable.
## What Changes
- Place the existing `资产标识符` column immediately after `订单编号`, followed by a `购买套餐名称` column in the order list.
- Render `items[].package_name` in the API-returned order, joining multiple item names with the Chinese enumeration separator `、`.
- Show a placeholder when an order has no returned items or no usable package name.
## Impact
- Affected specs: `order-list-purchased-package-names`
- Affected code: `src/views/order-management/order-list/index.vue`
- Dependencies: `GET /api/admin/orders` returns `items` with each item's `package_name`.

View File

@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: Order list purchased package name display
The order list SHALL display `资产标识符` immediately after `订单编号`, followed by a `购买套餐名称` column. The package-name column MUST render package names from the current row's `items` in their API-returned order and MUST join multiple names with `、`.
#### Scenario: Order contains multiple purchased packages
- **GIVEN** an order response whose `items` contains package names `正式套餐` and `加油包33`
- **WHEN** the order is rendered in the order list
- **THEN** `资产标识符` MUST be positioned between `订单编号` and `购买套餐名称`
- **AND** the `购买套餐名称` column MUST display `正式套餐、加油包33`
- **AND** the order-number column MUST retain its existing navigation behavior
#### Scenario: Order has no available package names
- **GIVEN** an order response with `items=null`, an empty item list, or item records without usable names
- **WHEN** the order is rendered in the order list
- **THEN** the `购买套餐名称` column MUST display the standard placeholder

View File

@@ -0,0 +1,10 @@
## 1. Implementation
- [x] 1.1 Place `资产标识符` after order number and add the purchased-package-name column immediately after it in the order-list column chooser.
- [x] 1.2 Render package names from `Order.items` in API-returned order, separating multiple values with `、`.
- [x] 1.3 Render a placeholder for null, empty, or unnamed order items without altering the order-number link behavior.
## 2. Verification
- [x] 2.1 Verify a single item renders its package name and multiple items render `套餐A、套餐B`.
- [x] 2.2 Run targeted format, type, and lint checks.

View File

@@ -3,7 +3,6 @@
v-model="dialogVisible"
title="手动更新卡实名状态"
width="30%"
modal-class="asset-information-dialog"
@closed="handleDialogClosed"
>
<ElForm ref="formRef" :model="formData" :rules="formRules" label-width="80px">

View File

@@ -3,7 +3,6 @@
v-model="visible"
title="设置实名认证策略"
width="40%"
modal-class="asset-information-dialog"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px">
<ElFormItem label="资产标识">

View File

@@ -3,7 +3,6 @@
v-model="visible"
title="切换SIM卡"
width="40%"
modal-class="asset-information-dialog"
@close="handleClose"
>
<div v-if="loading" style="padding: 40px; text-align: center">

View File

@@ -236,6 +236,8 @@ export interface AssetBoundCard {
card_id: number // 卡 ID
iccid: string // ICCID
msisdn: string // 手机号
carrier_type?: string // 运营商类型
carrier_name?: string // 运营商名称
network_status: NetworkStatus // 网络状态
real_name_status: RealNameStatus // 实名状态
real_name_at?: string | null // 实名时间

View File

@@ -10,6 +10,19 @@ export enum RefundStatus {
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 {
file_key: string
file_name?: string
@@ -70,7 +83,7 @@ export interface Refund {
approval_provider?: 'wecom' | null
approval_instance_id?: number | 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 // 审批状态名称
current_approver_summary?: string | null // 当前审批人摘要
processing_status?: string | null // 业务处理状态

View File

@@ -7,7 +7,7 @@ import { normalizeApiError } from '@/utils/business/apiError'
import { syncCompatibleIdFields } from '@/utils/business/id'
const axiosInstance = axios.create({
timeout: 15000, // 请求超时时间(毫秒)
timeout: 90000, // 请求超时时间(毫秒)
baseURL: import.meta.env.DEV ? '' : import.meta.env.VITE_API_URL, // 开发服务器使用代理其他模式使用完整URL
withCredentials: false, // 异步请求携带cookie
transformRequest: [

View File

@@ -118,13 +118,13 @@
label="实名时间"
label-class-name="asset-label asset-label--realname-time"
>{{
cardInfo?.real_name_at ? formatDateTime(cardInfo.real_name_at) : '-'
cardInfo?.real_name_at ? formatAssetInfoTime(cardInfo.real_name_at) : '-'
}}</ElDescriptionsItem
>
<ElDescriptionsItem
label="实名同步时间"
label-class-name="asset-label asset-label--realname-sync"
>{{ formatDateTime(cardInfo?.last_real_name_check_at) || '-' }}</ElDescriptionsItem
>{{ formatAssetInfoTime(cardInfo?.last_real_name_check_at) }}</ElDescriptionsItem
>
<ElDescriptionsItem
@@ -143,12 +143,12 @@
<ElDescriptionsItem
label="卡状态同步时间"
label-class-name="asset-label asset-label--card-sync"
>{{ formatDateTime(cardInfo?.last_card_status_check_at) || '-' }}</ElDescriptionsItem
>{{ formatAssetInfoTime(cardInfo?.last_card_status_check_at) }}</ElDescriptionsItem
>
<ElDescriptionsItem
label="流量同步时间"
label-class-name="asset-label asset-label--traffic-sync"
>{{ formatDateTime(cardInfo?.last_data_check_at) || '-' }}</ElDescriptionsItem
>{{ formatAssetInfoTime(cardInfo?.last_data_check_at) }}</ElDescriptionsItem
>
<ElDescriptionsItem
@@ -241,11 +241,10 @@
<span v-else>-</span>
</ElDescriptionsItem>
<ElDescriptionsItem
label="激活状态"
label-class-name="asset-label asset-label--activation-status"
label="虚拟号"
label-class-name="asset-label asset-label--virtual-number"
>{{ cardInfo?.virtual_no || '-' }}</ElDescriptionsItem
>
{{ cardInfo?.activation_status_name || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem
label="设备型号"
@@ -257,7 +256,7 @@
label-class-name="asset-label asset-label--realname-time"
>{{
selectedDeviceStatusCard?.real_name_at
? formatDateTime(selectedDeviceStatusCard.real_name_at)
? formatAssetInfoTime(selectedDeviceStatusCard.real_name_at)
: '-'
}}</ElDescriptionsItem
>
@@ -275,10 +274,11 @@
<span v-else>-</span>
</ElDescriptionsItem>
<ElDescriptionsItem
label="虚拟号"
label-class-name="asset-label asset-label--virtual-number"
>{{ cardInfo?.virtual_no || '-' }}</ElDescriptionsItem
label="激活状态"
label-class-name="asset-label asset-label--activation-status"
>
{{ cardInfo?.activation_status_name || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem
label="切卡模式"
label-class-name="asset-label asset-label--switch-mode"
@@ -434,7 +434,7 @@
</div>
<div class="binding-card-field">
<span class="binding-card-field-label">运营商</span>
<span class="binding-card-field-value">{{ bindingCard.carrier_name || '-' }}</span>
<span class="binding-card-field-value">{{ getCarrierAccountDisplay(bindingCard) }}</span>
</div>
<div class="binding-card-field">
<span class="binding-card-field-label">卡槽位置</span>
@@ -467,7 +467,11 @@
<div class="binding-card-field">
<span class="binding-card-field-label">实名时间</span>
<span class="binding-card-field-value">
{{ bindingCard.real_name_at ? formatDateTime(bindingCard.real_name_at) : '-' }}
{{
bindingCard.real_name_at
? formatAssetInfoTime(bindingCard.real_name_at)
: '-'
}}
</span>
</div>
<div class="binding-card-field">
@@ -679,6 +683,7 @@
card_id?: number
iccid: string
msisdn?: string
carrier_type?: string
carrier_name?: string
slot_position?: number
is_current: boolean
@@ -883,12 +888,17 @@
return !isAgentOrEnterpriseAccount.value
})
const carrierAccountDisplay = computed(() => {
const getCarrierAccountDisplay = (card?: {
carrier_type?: string
carrier_name?: string
}) => {
if (isAgentOrEnterpriseAccount.value) {
return getCarrierTypeName(props.cardInfo?.carrier_type)
return getCarrierTypeName(card?.carrier_type)
}
return props.cardInfo?.carrier_name || '-'
})
return card?.carrier_name || '-'
}
const carrierAccountDisplay = computed(() => getCarrierAccountDisplay(props.cardInfo))
const descriptionsColumn = computed(() => {
if (width.value <= 640) return 1
@@ -989,19 +999,19 @@
{
key: 'realname',
label: '实名同步时间',
value: formatDateTime(props.cardInfo?.last_real_name_check_at) || '-',
value: formatAssetInfoTime(props.cardInfo?.last_real_name_check_at),
icon: 'realname-sync'
},
{
key: 'cardSync',
label: '卡状态同步时间',
value: formatDateTime(props.cardInfo?.last_card_status_check_at) || '-',
value: formatAssetInfoTime(props.cardInfo?.last_card_status_check_at),
icon: 'card-sync'
},
{
key: 'trafficSync',
label: '流量同步时间',
value: formatDateTime(props.cardInfo?.last_data_check_at) || '-',
value: formatAssetInfoTime(props.cardInfo?.last_data_check_at),
icon: 'traffic-sync'
},
{
@@ -1134,6 +1144,34 @@
return formatDataSize(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) => {
emit('update:pollingEnabled', Boolean(value))
@@ -1452,7 +1490,7 @@
}
:deep(.asset-label--bound-device-name::before) {
content: '\e6ef';
content: '\e7d8';
}
:deep(.asset-label--device-name::before) {

View File

@@ -319,8 +319,9 @@
v-model="usedDataForm.data_usage"
:min="0"
:max="usedDataMaxValue"
:precision="3"
:step="0.1"
:precision="0"
:step="1"
step-strictly
class="used-data-input__number"
placeholder="请输入新的套餐真实已用量"
/>
@@ -457,7 +458,7 @@
const usedDataMaxValue = computed(() => {
const realTotalMb = selectedPackage.value?.real_total_mb ?? 0
return usedDataForm.unit === 'GB' ? Number((realTotalMb / 1024).toFixed(3)) : realTotalMb
return usedDataForm.unit === 'GB' ? Math.floor(realTotalMb / 1024) : Math.floor(realTotalMb)
})
const validateUsedData = (
@@ -471,6 +472,11 @@
return
}
if (!Number.isInteger(usedDataForm.data_usage)) {
callback(new Error('新的已用量必须为整数'))
return
}
if (dataUsageMb === undefined || dataUsageMb < 0) {
callback(new Error('请输入可换算为非负数字 MB 的已用量'))
return

View File

@@ -3,7 +3,6 @@
v-model="visible"
title="套餐流量详单"
width="900px"
modal-class="asset-information-dialog"
destroy-on-close
>
<div v-if="data">

View File

@@ -3,7 +3,6 @@
v-model="visible"
title="往期订单"
width="60%"
modal-class="asset-information-dialog"
destroy-on-close
>
<div v-if="loading" style="padding: 40px 0; text-align: center">

View File

@@ -3,7 +3,6 @@
v-model="visible"
title="套餐充值"
width="40%"
modal-class="asset-information-dialog"
@closed="handleDialogClosed"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px">

View File

@@ -5,7 +5,7 @@
width="500px"
:lock-scroll="true"
:z-index="4000"
modal-class="asset-information-dialog speed-limit-modal"
modal-class="speed-limit-modal"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElFormItem label="固定档位" prop="code">

View File

@@ -3,7 +3,6 @@
v-model="visible"
title="设置切卡模式"
width="45%"
modal-class="asset-information-dialog"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px">
<ElFormItem label="设备标识">

View File

@@ -3,7 +3,6 @@
v-model="visible"
title="设置WiFi"
width="40%"
modal-class="asset-information-dialog"
:append-to-body="true"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px" autocomplete="off">

View File

@@ -62,7 +62,7 @@ export function useAssetFormatters() {
const getRealNameStatusType = (status?: number): 'info' | 'success' | 'warning' | 'danger' => {
if (status === undefined || status === null) return 'info'
const map: Record<number, 'info' | 'success' | 'warning' | 'danger'> = {
0: 'info',
0: 'danger',
1: 'success'
}
return map[status] || 'info'

View File

@@ -30,6 +30,9 @@ const NULLABLE_REALTIME_CARD_FIELDS = new Set([
'last_card_status_check_at'
])
// 详情接口是绑定卡实名时间的唯一来源;实时状态接口不应覆盖或保留旧值。
const DETAIL_ONLY_BOUND_CARD_FIELDS = new Set(['real_name_at'])
/**
* 格式化数据大小(MB -> GB/MB)
*/
@@ -60,10 +63,13 @@ const mergeAssetBoundCards = (
): AssetBoundCard[] =>
realtimeCards.map((realtimeCard) => {
const existingCard = existingCards.find((card) => card.iccid === realtimeCard.iccid)
if (!existingCard) return realtimeCard
if (!existingCard) {
return { ...realtimeCard, real_name_at: null }
}
const mergedCard = { ...existingCard } as unknown as Record<string, unknown>
for (const [key, value] of Object.entries(realtimeCard)) {
if (DETAIL_ONLY_BOUND_CARD_FIELDS.has(key)) continue
if (value !== undefined && (value !== null || NULLABLE_REALTIME_CARD_FIELDS.has(key))) {
mergedCard[key] = value
}

View File

@@ -1653,112 +1653,3 @@
background: #ffffff;
}
</style>
<style lang="scss">
/* Dialogs are teleported to body, so their surface needs a page-level hook. */
.asset-information-dialog.el-dialog {
max-width: calc(100vw - 32px);
overflow: hidden;
border: 1px solid #e5eaf1;
border-radius: 14px;
box-shadow: 0 18px 50px rgb(15 23 42 / 18%);
.el-dialog__header {
padding: 20px 24px 16px;
margin: 0;
border-bottom: 1px solid #e5eaf1;
}
.el-dialog__title {
font-size: 16px;
font-weight: 650;
color: #172033;
letter-spacing: -0.01em;
}
.el-dialog__body {
padding: 22px 24px 20px;
}
.el-dialog__footer {
padding: 14px 24px 20px;
border-top: 1px solid #eef2f7;
}
.el-form-item {
margin-bottom: 18px;
}
.el-form-item__label {
color: #526078;
}
.el-button {
min-height: 34px;
border-radius: 8px;
}
.el-table {
--el-table-header-bg-color: #f8fafc;
--el-table-row-hover-bg-color: #f5f8ff;
--el-table-border-color: #e5eaf1;
border-radius: 9px;
}
.el-descriptions__label.el-descriptions__cell.is-bordered-label {
color: #526078;
background: #f8fafc;
}
.dialog-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
}
}
@media (width <= 768px) {
.asset-information-dialog.el-dialog {
width: calc(100vw - 20px) !important;
.el-dialog__header {
padding: 16px 18px 13px;
}
.el-dialog__body {
max-height: calc(100vh - 150px);
padding: 18px;
overflow-y: auto;
}
.el-dialog__footer {
padding: 12px 18px 16px;
}
}
}
html.dark .asset-information-dialog.el-dialog {
border-color: #2b3a52;
.el-dialog__header,
.el-dialog__footer {
border-color: #2b3a52;
}
.el-dialog__title,
.el-form-item__label {
color: #e5edf8;
}
.el-table {
--el-table-header-bg-color: #202d42;
--el-table-row-hover-bg-color: #223451;
--el-table-border-color: #2b3a52;
}
.el-descriptions__label.el-descriptions__cell.is-bordered-label {
color: #afbdd0;
background: #202d42;
}
}
</style>

View File

@@ -56,6 +56,7 @@ export interface BindingCard {
msisdn?: string
is_current: boolean
status?: number
carrier_type?: string
carrier_name?: string
slot_position?: number
network_status?: number

View File

@@ -108,6 +108,7 @@
v-model="allocateForm.target_shop_id"
:options="shopCascadeOptions"
:props="shopCascadeProps"
:before-filter="handleAllocateShopBeforeFilter"
placeholder="请选择目标店铺"
clearable
filterable
@@ -238,7 +239,10 @@
<span style="font-weight: bold; color: #409eff">{{ selectedDevices.length }}</span>
</ElFormItem>
<ElFormItem label="选设备方式" prop="selection_type">
<ElRadioGroup v-model="seriesBindingForm.selection_type">
<ElRadioGroup
v-model="seriesBindingForm.selection_type"
@change="handleDeviceBatchSelectionTypeChange"
>
<ElRadio label="list">设备ID列表</ElRadio>
<ElRadio label="range">设备号范围</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
@@ -736,7 +740,10 @@
</ElSelect>
</ElFormItem>
<ElFormItem label="选设备方式" prop="selection_type">
<ElRadioGroup v-model="enterpriseDeviceAuthorizeForm.selection_type">
<ElRadioGroup
v-model="enterpriseDeviceAuthorizeForm.selection_type"
@change="handleDeviceBatchSelectionTypeChange"
>
<ElRadio label="list">设备号列表</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
</ElRadioGroup>
@@ -842,7 +849,10 @@
</ElSelect>
</ElFormItem>
<ElFormItem label="选设备方式" prop="selection_type">
<ElRadioGroup v-model="enterpriseDeviceRecallForm.selection_type">
<ElRadioGroup
v-model="enterpriseDeviceRecallForm.selection_type"
@change="handleDeviceBatchSelectionTypeChange"
>
<ElRadio label="list">设备号列表</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
</ElRadioGroup>
@@ -1040,6 +1050,62 @@
const batchRealnamePolicyError = ref('')
const exportDialogVisible = ref(false)
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 = {
lazy: true,
checkStrictly: true,
@@ -1050,12 +1116,7 @@
try {
const res = await ShopService.getShopsCascade({ parent_id: parentId })
if (res.code === 0) {
const nodes = (res.data || []).map((item: any) => ({
value: item.id,
label: item.shop_name,
leaf: !item.has_children
}))
resolve(nodes)
resolve(mapShopCascadeNodes(res.data || []))
} else {
resolve([])
}
@@ -1072,11 +1133,7 @@
try {
const res = await ShopService.getShopsCascade({ parent_id: undefined })
if (res.code === 0) {
shopCascadeOptions.value = (res.data || []).map((item: any) => ({
value: item.id,
label: item.shop_name,
leaf: !item.has_children
}))
shopCascadeOptions.value = mapShopCascadeNodes(res.data || [])
}
} catch (error) {
console.error('加载顶级店铺失败:', error)
@@ -1355,7 +1412,7 @@
// 搜索表单
const searchForm = reactive<Record<string, any>>({ ...initialSearchState })
// 搜索批次号(用于搜索表单)
// 批次号仅在用户切换至筛选条件或输入批次号搜索时加载。
const loadSearchBatchNoOptions = async (batchNo?: string) => {
try {
const params: any = {
@@ -1374,11 +1431,14 @@
}
}
// 搜索批次号
const handleSearchBatchNo = (query: string) => {
if (query) {
loadSearchBatchNoOptions(query)
} else {
loadSearchBatchNoOptions(query || undefined)
}
const handleDeviceBatchSelectionTypeChange = (
selectionType: string | number | boolean | undefined
) => {
if (selectionType === DeviceSelectionType.FILTER) {
loadSearchBatchNoOptions()
}
}
@@ -2515,18 +2575,14 @@
const showEnterpriseDeviceAuthorizeDialog = async () => {
resetEnterpriseDeviceAuthorizeForm()
await Promise.all([
handleSearchEnterprise(),
loadSearchBatchNoOptions(),
loadTopLevelShopCascadeOptions()
])
await Promise.all([handleSearchEnterprise(), loadTopLevelShopCascadeOptions()])
enterpriseDeviceAuthorizeDialogVisible.value = true
enterpriseDeviceAuthorizeFormRef.value?.clearValidate()
}
const showEnterpriseDeviceRecallDialog = async () => {
resetEnterpriseDeviceRecallForm()
await Promise.all([handleSearchEnterprise(), loadSearchBatchNoOptions()])
await handleSearchEnterprise()
enterpriseDeviceRecallDialogVisible.value = true
enterpriseDeviceRecallFormRef.value?.clearValidate()
}
@@ -2667,7 +2723,7 @@
selection_type:
selectedDevices.value.length > 0 ? DeviceSelectionType.LIST : DeviceSelectionType.FILTER
})
await Promise.all([loadPackageSeriesList(), loadSearchBatchNoOptions()])
await loadPackageSeriesList()
seriesBindingDialogVisible.value = true
seriesBindingFormRef.value?.clearValidate()
}

View File

@@ -268,8 +268,8 @@
{{ createNewDeviceEmptyMessage }}
</div>
</ElFormItem>
<ElFormItem v-if="createDirectInheritedShopHint" label="归属提示">
<div class="exchange-shop-hint">{{ createDirectInheritedShopHint }}</div>
<ElFormItem label="归属提示">
<div class="exchange-shop-hint">新资产会继承旧资产店铺</div>
</ElFormItem>
<ElFormItem v-if="createForm.flow_type === 'direct'" label="是否迁移数据">
<ElSwitch v-model="createForm.migrate_data" />
@@ -771,11 +771,6 @@
return undefined
}
const createDirectInheritedShopHint = computed(() => {
const shopName = getShopDisplayName(createSelectedNewAsset.value)
return shopName ? `换货完成后将归属:${shopName}` : ''
})
const shipInheritedShopDisplayText = computed(() => {
const shopName = getShopDisplayName(shipSelectedNewAsset.value)

View File

@@ -112,6 +112,7 @@
v-model="allocateForm.to_shop_id"
:options="shopCascadeOptions"
:props="shopCascadeProps"
:before-filter="handleAllocateShopBeforeFilter"
placeholder="请选择目标店铺"
clearable
filterable
@@ -119,7 +120,10 @@
/>
</ElFormItem>
<ElFormItem label="选卡方式" prop="selection_type">
<ElRadioGroup v-model="allocateForm.selection_type">
<ElRadioGroup
v-model="allocateForm.selection_type"
@change="handleCardBatchSelectionTypeChange"
>
<ElRadio label="list">ICCID列表</ElRadio>
<ElRadio label="range">号段范围</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
@@ -246,7 +250,10 @@
>
<ElForm ref="recallFormRef" :model="recallForm" :rules="recallRules" label-width="100px">
<ElFormItem label="选卡方式" prop="selection_type">
<ElRadioGroup v-model="recallForm.selection_type">
<ElRadioGroup
v-model="recallForm.selection_type"
@change="handleCardBatchSelectionTypeChange"
>
<ElRadio label="list">ICCID列表</ElRadio>
<ElRadio label="range">号段范围</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
@@ -415,7 +422,10 @@
</ElSelect>
</ElFormItem>
<ElFormItem label="选卡方式" prop="selection_type">
<ElRadioGroup v-model="enterpriseCardAuthorizeForm.selection_type">
<ElRadioGroup
v-model="enterpriseCardAuthorizeForm.selection_type"
@change="handleCardBatchSelectionTypeChange"
>
<ElRadio label="list">ICCID列表</ElRadio>
<ElRadio label="range">号段范围</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
@@ -568,7 +578,10 @@
</ElSelect>
</ElFormItem>
<ElFormItem label="选卡方式" prop="selection_type">
<ElRadioGroup v-model="enterpriseCardRecallForm.selection_type">
<ElRadioGroup
v-model="enterpriseCardRecallForm.selection_type"
@change="handleCardBatchSelectionTypeChange"
>
<ElRadio label="list">ICCID列表</ElRadio>
<ElRadio label="range">号段范围</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
@@ -739,7 +752,10 @@
label-width="100"
>
<ElFormItem label="选卡方式" prop="selection_type">
<ElRadioGroup v-model="seriesBindingForm.selection_type">
<ElRadioGroup
v-model="seriesBindingForm.selection_type"
@change="handleCardBatchSelectionTypeChange"
>
<ElRadio label="list">ICCID列表</ElRadio>
<ElRadio label="range">号段范围</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
@@ -1338,10 +1354,9 @@
}
}
// 批量分配对话框批次号选项
// 批次号仅在用户切换至筛选条件时加载;避免弹窗打开即请求导入任务列表。
const allocateBatchNoOptions = ref<any[]>([])
// 加载批量分配对话框批次号选项
const loadAllocateBatchNoOptions = async (batchNo?: string) => {
try {
const params: any = {
@@ -1360,11 +1375,14 @@
}
}
// 搜索批量分配对话框批次号
const handleSearchAllocateBatchNo = (query: string) => {
if (query) {
loadAllocateBatchNoOptions(query)
} else {
loadAllocateBatchNoOptions(query || undefined)
}
const handleCardBatchSelectionTypeChange = (
selectionType: string | number | boolean | undefined
) => {
if (selectionType === CardSelectionType.FILTER) {
loadAllocateBatchNoOptions()
}
}
@@ -1506,6 +1524,62 @@
// 店铺相关
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 = {
lazy: true,
checkStrictly: true,
@@ -1516,12 +1590,7 @@
try {
const res = await ShopService.getShopsCascade({ parent_id: parentId })
if (res.code === 0) {
const nodes = (res.data || []).map((item: any) => ({
value: item.id,
label: item.shop_name,
leaf: !item.has_children
}))
resolve(nodes)
resolve(mapShopCascadeNodes(res.data || []))
} else {
resolve([])
}
@@ -1538,11 +1607,7 @@
try {
const res = await ShopService.getShopsCascade({ parent_id: undefined })
if (res.code === 0) {
shopCascadeOptions.value = (res.data || []).map((item: any) => ({
value: item.id,
label: item.shop_name,
leaf: !item.has_children
}))
shopCascadeOptions.value = mapShopCascadeNodes(res.data || [])
}
} catch (error) {
console.error('加载顶级店铺失败:', error)
@@ -1843,6 +1908,19 @@
{ label: '无换卡记录', value: false }
]
},
{
label: '是否绑设备',
prop: 'is_standalone',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '未绑定设备', value: true },
{ label: '已绑定设备', value: false }
]
},
{
label: '起始号',
prop: 'iccid_start',
@@ -1860,19 +1938,6 @@
clearable: true,
placeholder: '请输入ICCID结束号'
}
},
{
label: '是否绑设备',
prop: 'is_standalone',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '未绑定设备', value: true },
{ label: '已绑定设备', value: false }
]
}
]
@@ -2469,6 +2534,7 @@
return
}
allocateDialogVisible.value = true
shopCascadeOptions.value = []
Object.assign(allocateForm, {
selection_type: CardSelectionType.LIST,
to_shop_id: undefined,
@@ -2481,7 +2547,6 @@
remark: ''
})
loadAllocateCarrierOptions()
loadAllocateBatchNoOptions()
if (allocateFormRef.value) {
allocateFormRef.value.resetFields()
}
@@ -2507,7 +2572,6 @@
recallFormRef.value.resetFields()
}
loadAllocateCarrierOptions()
loadAllocateBatchNoOptions()
}
// 关闭批量分配对话框
@@ -2677,7 +2741,6 @@
await Promise.all([
handleSearchEnterprise(),
loadAllocateCarrierOptions(),
loadAllocateBatchNoOptions(),
loadTopLevelShopCascadeOptions()
])
enterpriseCardAuthorizeDialogVisible.value = true
@@ -2686,11 +2749,7 @@
const showEnterpriseCardRecallDialog = async () => {
resetEnterpriseCardRecallForm()
await Promise.all([
handleSearchEnterprise(),
loadAllocateCarrierOptions(),
loadAllocateBatchNoOptions()
])
await Promise.all([handleSearchEnterprise(), loadAllocateCarrierOptions()])
enterpriseCardRecallDialogVisible.value = true
enterpriseCardRecallFormRef.value?.clearValidate()
}
@@ -2810,11 +2869,7 @@
selection_type:
selectedCards.value.length > 0 ? CardSelectionType.LIST : CardSelectionType.FILTER
})
await Promise.all([
loadPackageSeriesList(),
loadAllocateCarrierOptions(),
loadAllocateBatchNoOptions()
])
await Promise.all([loadPackageSeriesList(), loadAllocateCarrierOptions()])
seriesBindingDialogVisible.value = true
seriesBindingFormRef.value?.clearValidate()
}

View File

@@ -113,7 +113,13 @@
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
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 { toVoucherKeyList, getErrorMessage } from '@/utils/business'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
@@ -456,22 +462,14 @@
resubmitUploadRef.value?.clearFiles(false)
}
const canResubmit = (item: Refund) => {
const statusText = [item.approval_status, item.approval_status_name, item.status_name]
.filter(Boolean)
.join(' ')
.toLowerCase()
return (
item.status === 3 ||
statusText.includes('reject') ||
statusText.includes('revoke') ||
statusText.includes('delete') ||
statusText.includes('驳回') ||
statusText.includes('撤销') ||
statusText.includes('删除')
)
}
const canResubmit = (item: Refund) =>
item.status === RefundStatus.REJECTED ||
item.status === RefundStatus.RETURNED ||
item.approval_status === RefundApprovalStatus.REJECTED ||
item.approval_status === RefundApprovalStatus.REVOKED ||
item.approval_status === RefundApprovalStatus.REVOKED_AFTER_APPROVED ||
item.approval_status === RefundApprovalStatus.SUBMIT_FAILED ||
item.approval_status === RefundApprovalStatus.RESULT_UNKNOWN
const handleShowResubmit = () => {
if (!refund.value || !canResubmit(refund.value)) return

View File

@@ -150,6 +150,7 @@
import type { FormInstance, FormRules } from 'element-plus'
import {
RefundStatus,
RefundApprovalStatus,
type Refund,
type RefundQueryParams,
type ResubmitRefundRequest,
@@ -734,22 +735,14 @@
return keys.length ? keys : toVoucherKeyList(row.refund_voucher_key)
}
const canResubmit = (row: Refund) => {
const statusText = [row.approval_status, row.approval_status_name, row.status_name]
.filter(Boolean)
.join(' ')
.toLowerCase()
return (
row.status === 3 ||
statusText.includes('reject') ||
statusText.includes('revoke') ||
statusText.includes('delete') ||
statusText.includes('驳回') ||
statusText.includes('撤销') ||
statusText.includes('删除')
)
}
const canResubmit = (row: Refund) =>
row.status === RefundStatus.REJECTED ||
row.status === RefundStatus.RETURNED ||
row.approval_status === RefundApprovalStatus.REJECTED ||
row.approval_status === RefundApprovalStatus.REVOKED ||
row.approval_status === RefundApprovalStatus.REVOKED_AFTER_APPROVED ||
row.approval_status === RefundApprovalStatus.SUBMIT_FAILED ||
row.approval_status === RefundApprovalStatus.RESULT_UNKNOWN
const canTriggerApproval = (row: Refund) => {
const approvalStatusEmpty = row.approval_status === undefined || row.approval_status === null

View File

@@ -507,12 +507,13 @@
const columnOptions = computed(() => {
const options = [
{ label: '订单编号', prop: 'order_no' },
{ label: '资产标识符', prop: 'asset_identifier' },
{ label: '购买套餐名称', prop: 'purchased_package_names' },
{ label: '买家手机号', prop: 'buyer_phone' },
{ label: '买家昵称', prop: 'buyer_nickname' },
{ label: '订单类型', prop: 'order_type' },
{ label: '资产类型', prop: 'asset_type' },
{ label: '买家类型', prop: 'buyer_type' },
{ label: '资产标识符', prop: 'asset_identifier' },
{ label: '订单渠道', prop: 'purchase_role' },
{ label: '购买备注', prop: 'purchase_remark' },
{ label: '是否代购', prop: 'is_purchase_on_behalf' },
@@ -727,6 +728,14 @@
return `¥${(amount / 100).toFixed(2)}`
}
const formatPurchasedPackageNames = (order: Order): string => {
const packageNames = (order.items || [])
.map((item) => (item.package_name || '').trim())
.filter(Boolean)
return packageNames.join('、') || '-'
}
// 获取支付状态标签类型
const getPaymentStatusType = (
status: PaymentStatus
@@ -847,6 +856,20 @@
)
}
},
{
prop: 'asset_identifier',
label: '资产标识符',
width: 180,
showOverflowTooltip: true,
formatter: (row: Order) => row.asset_identifier || '-'
},
{
prop: 'purchased_package_names',
label: '购买套餐名称',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: Order) => formatPurchasedPackageNames(row)
},
{
prop: 'buyer_phone',
label: '买家手机号',
@@ -911,13 +934,6 @@
width: 180,
formatter: (row: Order) => (row.expires_at ? formatDateTime(row.expires_at) : '-')
},
{
prop: 'asset_identifier',
label: '资产标识符',
width: 180,
showOverflowTooltip: true,
formatter: (row: Order) => row.asset_identifier || '-'
},
{
prop: 'purchase_role',
label: '订单渠道',

View File

@@ -1080,7 +1080,8 @@
],
init_username: [
{ required: true, message: '请输入初始账号用户名', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
{ min: 3, max: 50, message: '长度在 3 到 50 个字符', trigger: 'blur' },
{ pattern: /^[A-Za-z\d]+$/, message: '用户名只能包含英文或数字', trigger: 'blur' }
],
init_password: [
{ required: true, message: '请输入初始账号密码', trigger: 'blur' },