feat: 产品迭代7月份
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m51s

This commit is contained in:
luo
2026-07-27 16:30:29 +08:00
parent cc6fc9243e
commit f0a2b84e53
75 changed files with 2926 additions and 534 deletions

View File

@@ -8,10 +8,14 @@ import type {
AccountQueryParams,
CreatePlatformAccountParams,
BaseResponse,
PaginationResponse
PaginationResponse,
WecomAccountBindingRequest
} from '@/types/api'
export class AccountService extends BaseService {
static bindWecom(id: number, data: WecomAccountBindingRequest): Promise<BaseResponse> {
return this.put<BaseResponse>(`/api/admin/accounts/${id}/wecom-binding`, data)
}
// ========== 账号管理 (Account Management) ==========
/**

View File

@@ -1,31 +1,33 @@
import request from '@/utils/http'
import type {
BulkPurchaseCreateRequest,
BulkPurchaseCreateApiResponse,
BulkPurchaseItemsApiResponse,
BulkPurchaseTaskListApiResponse,
BulkPurchaseTaskApiResponse
} from '@/types/api'
export class BulkPurchaseService {
static createTask(data: FormData): Promise<BulkPurchaseCreateApiResponse> {
static createTask(data: BulkPurchaseCreateRequest): Promise<BulkPurchaseCreateApiResponse> {
return request.post<BulkPurchaseCreateApiResponse>({
url: '/api/admin/bulk-purchases',
url: '/api/admin/asset-package-batch-orders',
data
})
}
static getTasks(params?: {
page?: number
page_size?: number
status?: number
}): Promise<BulkPurchaseTaskListApiResponse> {
return request.get<BulkPurchaseTaskListApiResponse>({
url: '/api/admin/asset-package-batch-orders',
params
})
}
static getTask(taskId: number): Promise<BulkPurchaseTaskApiResponse> {
return request.get<BulkPurchaseTaskApiResponse>({
url: `/api/admin/bulk-purchases/${taskId}`
})
}
static getItems(
taskId: number,
params?: { page?: number; size?: number; status?: string | number }
): Promise<BulkPurchaseItemsApiResponse> {
return request.get<BulkPurchaseItemsApiResponse>({
url: `/api/admin/bulk-purchases/${taskId}/items`,
params
url: `/api/admin/asset-package-batch-orders/${taskId}`
})
}
}

View File

@@ -36,7 +36,10 @@ import type {
AssetAllocationRecordDetail,
BatchSetCardSeriesBindingRequest,
BatchSetCardSeriesBindingResponse,
BatchUpdateAssetRealnamePolicyRequest
BatchUpdateAssetRealnamePolicyRequest,
BatchUpdateAssetRealnamePolicyResponse,
SpeedTierCode,
SetSpeedTierResponse
} from '@/types/api'
type ApiQueryParams = PaginationParams & Record<string, unknown>
@@ -57,6 +60,15 @@ interface CardChangeNotice {
}
export class CardService extends BaseService {
static setSpeedTier(
iccid: string,
code: SpeedTierCode
): Promise<BaseResponse<SetSpeedTierResponse>> {
return this.put<BaseResponse<SetSpeedTierResponse>>(
`/api/admin/iot-cards/${encodeURIComponent(iccid)}/speed-tier`,
{ code }
)
}
// ========== 号卡商品管理 ==========
/**
@@ -418,8 +430,8 @@ export class CardService extends BaseService {
static batchUpdateRealnamePolicy(
data: BatchUpdateAssetRealnamePolicyRequest,
config?: Record<string, any>
): Promise<BaseResponse<void>> {
return this.post<BaseResponse<void>>(
): Promise<BaseResponse<BatchUpdateAssetRealnamePolicyResponse>> {
return this.post<BaseResponse<BatchUpdateAssetRealnamePolicyResponse>>(
'/api/admin/iot-cards/batch-update-realname-policy',
data,
config

View File

@@ -18,13 +18,15 @@ import type {
BatchSetDeviceSeriesBindingRequest,
BatchSetDeviceSeriesBindingResponse,
BatchUpdateAssetRealnamePolicyRequest,
BatchUpdateAssetRealnamePolicyResponse,
ImportDeviceRequest,
ImportDeviceResponse,
DeviceImportTaskQueryParams,
DeviceImportTaskListResponse,
DeviceImportTaskDetail,
DeviceBatchAllocationRequest,
DeviceBatchAllocationResponse,
BaseResponse,
SetSpeedLimitRequest,
SwitchCardRequest,
SetWiFiRequest,
DeviceOperationResponse
@@ -147,6 +149,15 @@ export class DeviceService extends BaseService {
)
}
static createAllocationTask(
data: DeviceBatchAllocationRequest
): Promise<BaseResponse<DeviceBatchAllocationResponse>> {
return this.post<BaseResponse<DeviceBatchAllocationResponse>>(
'/api/admin/devices/import/allocations',
data
)
}
/**
* 获取导入任务详情
* @param id 任务ID
@@ -176,8 +187,8 @@ export class DeviceService extends BaseService {
static batchUpdateRealnamePolicy(
data: BatchUpdateAssetRealnamePolicyRequest,
config?: Record<string, any>
): Promise<BaseResponse<void>> {
return this.post<BaseResponse<void>>(
): Promise<BaseResponse<BatchUpdateAssetRealnamePolicyResponse>> {
return this.post<BaseResponse<BatchUpdateAssetRealnamePolicyResponse>>(
'/api/admin/devices/batch-update-realname-policy',
data,
config
@@ -208,21 +219,6 @@ export class DeviceService extends BaseService {
)
}
/**
* 设置限速
* @param imei 设备号(IMEI)
* @param data 限速参数
*/
static setSpeedLimit(
imei: string,
data: SetSpeedLimitRequest
): Promise<BaseResponse<DeviceOperationResponse>> {
return this.put<BaseResponse<DeviceOperationResponse>>(
`/api/admin/devices/by-identifier/${imei}/speed-limit`,
data
)
}
/**
* 切换SIM卡
* @param imei 设备号(IMEI)

View File

@@ -5,12 +5,16 @@
import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api'
export type ExchangeAssetType = 'iot_card' | 'device'
export type ExchangeFlowType = 'shipping' | 'direct'
export type ExchangeStatus = 1 | 2 | 3 | 4 | 5
// 换货单查询参数
export interface ExchangeQueryParams {
page?: number
page_size?: number
status?: number // 换货状态
flow_type?: string // 流程类型shipping/direct
status?: ExchangeStatus // 换货状态
flow_type?: ExchangeFlowType // 流程类型shipping/direct
old_asset_keyword?: string // 旧资产关键词ICCID、接入号、虚拟号、IMEI、SN
new_asset_keyword?: string // 新资产关键词ICCID、接入号、虚拟号、IMEI、SN
created_at_start?: string // 创建时间起始
@@ -20,9 +24,9 @@ export interface ExchangeQueryParams {
// 创建换货单请求
export interface CreateExchangeRequest {
exchange_reason: string // 换货原因
old_asset_type: string // 旧资产类型 (iot_card 或 device)
old_asset_type: ExchangeAssetType // 旧资产类型 (iot_card 或 device)
old_identifier: string // 旧资产标识符(ICCID/虚拟号/IMEI/SN)
flow_type?: string // 流程类型shipping/direct默认 shipping
flow_type?: ExchangeFlowType // 流程类型shipping/direct默认 shipping
new_identifier?: string // 新资产标识符direct 时必填)
migrate_data?: boolean // 是否迁移数据(默认 false
remark?: string // 备注(可选)
@@ -33,16 +37,16 @@ export interface ExchangeResponse {
id: number
exchange_no: string
exchange_reason: string
old_asset_type: string
old_asset_type: ExchangeAssetType
old_asset_id: number
old_asset_identifier: string
new_asset_type?: string | null
new_asset_type?: ExchangeAssetType | null
new_asset_id?: number | null
new_asset_identifier?: string | null
status: number // 换货状态1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)
status: ExchangeStatus // 换货状态1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)
status_name?: string
status_text: string
flow_type: string // 流程类型shipping/direct
flow_type: ExchangeFlowType // 流程类型shipping/direct
flow_type_name: string // 流程类型名称
shipped_at?: string | null // 发货时间(仅 shipping 发货后有值)
completed_at?: string | null // 换货完成时间

View File

@@ -19,9 +19,10 @@ export class ExportTaskService extends BaseService {
}
static getExportTaskDetail(id: number): Promise<ExportTaskDetailApiResponse> {
return this.getOne<ExportTaskDetail>(`/api/admin/export-tasks/${id}`)
return this.get<ExportTaskDetailApiResponse>(`/api/admin/export-tasks/${id}`)
}
/** 保留旧页面的取消入口,后端支持时由页面权限控制显示。 */
static cancelExportTask(id: number): Promise<CancelExportTaskApiResponse> {
return this.post<CancelExportTaskApiResponse>(`/api/admin/export-tasks/${id}/cancel`, {})
}

View File

@@ -37,6 +37,7 @@ export { ExportTaskService } from './exportTask'
export { OrderPackageInvalidateTaskService } from './orderPackageInvalidateTask'
export { BulkPurchaseService } from './bulkPurchase'
export { NotificationService } from './notification'
export { WecomService } from './wecom'
// TODO: 按需添加其他业务模块
// export { SettingService } from './setting'

View File

@@ -11,7 +11,7 @@ import type {
UpdatePackageStatusRequest,
UpdatePackageShelfStatusRequest,
BaseResponse,
PaginationResponse,
PaginationResponse
} from '@/types/api'
export class PackageManageService extends BaseService {
@@ -68,7 +68,7 @@ export class PackageManageService extends BaseService {
* 更新套餐状态
* PUT /api/admin/packages/{id}/status
* @param id 套餐ID
* @param status 状态 (1:启用, 2:禁用)
* @param status 状态 (0:禁用, 1:启用)
*/
static updatePackageStatus(id: number, status: number): Promise<BaseResponse> {
const data: UpdatePackageStatusRequest = { status }

View File

@@ -23,7 +23,12 @@ const triggerBrowserDownload = (downloadUrl: string, fileName: string) => {
/**
* 文件用途枚举
*/
export type FilePurpose = 'iot_import' | 'device_import' | 'export' | 'attachment'
export type FilePurpose =
| 'iot_import'
| 'batch_purchase'
| 'device_batch_allocation'
| 'export'
| 'attachment'
/**
* 获取上传 URL 请求参数

78
src/api/modules/wecom.ts Normal file
View File

@@ -0,0 +1,78 @@
import { BaseService } from '../BaseService'
import type {
BaseResponse,
WecomAccountBindingRequest,
WecomApplication,
WecomApplicationListResponse,
WecomApplicationQueryParams,
WecomApplicationRequest,
WecomApplicationResponse,
WecomBusinessType,
WecomMemberListResponse,
WecomMemberQueryParams,
WecomSceneListResponse,
WecomSceneQueryParams,
WecomSceneRequest,
WecomSceneResponse,
WecomSyncMembersApiResponse
} from '@/types/api'
export class WecomService extends BaseService {
static getApplications(
params?: WecomApplicationQueryParams
): Promise<WecomApplicationListResponse> {
return this.get<WecomApplicationListResponse>('/api/admin/wecom/applications', params)
}
static saveApplication(data: WecomApplicationRequest): Promise<WecomApplicationResponse> {
return this.post<WecomApplicationResponse>('/api/admin/wecom/applications', data)
}
static testApplication(id: number): Promise<BaseResponse<{ success: boolean }>> {
return this.post<BaseResponse<{ success: boolean }>>(`/api/admin/wecom/applications/${id}/test`)
}
static syncMembers(id: number): Promise<WecomSyncMembersApiResponse> {
return this.post<WecomSyncMembersApiResponse>(
`/api/admin/wecom/applications/${id}/members/sync`
)
}
static getMembers(
id: number,
params?: WecomMemberQueryParams
): Promise<WecomMemberListResponse> {
return this.get<WecomMemberListResponse>(
`/api/admin/wecom/applications/${id}/members`,
params
)
}
static setDefaultCreator(id: number, userid: string): Promise<WecomApplicationResponse> {
return this.put<WecomApplicationResponse>(
`/api/admin/wecom/applications/${id}/default-creator`,
{ userid }
)
}
static getScenes(params?: WecomSceneQueryParams): Promise<WecomSceneListResponse> {
return this.get<WecomSceneListResponse>('/api/admin/wecom/scenes', params)
}
static saveScene(
businessType: WecomBusinessType,
data: WecomSceneRequest
): Promise<WecomSceneResponse> {
return this.put<WecomSceneResponse>(`/api/admin/wecom/scenes/${businessType}`, data)
}
static bindAccount(
accountId: number,
data: WecomAccountBindingRequest
): Promise<BaseResponse<WecomApplication>> {
return this.put<BaseResponse<WecomApplication>>(
`/api/admin/accounts/${accountId}/wecom-binding`,
data
)
}
}

View File

@@ -57,7 +57,8 @@
import { Close, Document, UploadFilled } from '@element-plus/icons-vue'
import type { UploadFile, UploadInstance, UploadRawFile } from 'element-plus'
import { StorageService } from '@/api/modules'
import type { RefundAttachment } from '@/types/api/refund'
import type { RefundAttachment } from '@/types/api/refund'
import type { FilePurpose } from '@/api/modules/storage'
interface Props {
modelValue?: string[] | string
@@ -65,13 +66,21 @@
maxCount?: number
accept?: string
tip?: string
purpose?: FilePurpose
maxSizeMb?: number
singleColumnCsv?: boolean
maxCsvRows?: number
}
const props = withDefaults(defineProps<Props>(), {
voucherName: '凭证',
maxCount: 5,
accept: '',
tip: ''
tip: '',
purpose: 'attachment',
maxSizeMb: 0,
singleColumnCsv: false,
maxCsvRows: 0
})
const emit = defineEmits<{
@@ -185,6 +194,27 @@
const file = uploadFile.raw
if (!file) return
if (props.maxSizeMb > 0 && file.size > props.maxSizeMb * 1024 * 1024) {
ElMessage.warning(`文件不能超过 ${props.maxSizeMb}MB`)
removeUploadFile(uploadFile)
return
}
if (props.singleColumnCsv) {
const content = await file.text()
const rows = content.replace(/^\uFEFF/, '').split(/\r?\n/).filter(Boolean)
if (props.maxCsvRows > 0 && Math.max(rows.length - 1, 0) > props.maxCsvRows) {
ElMessage.warning(`CSV 数据行不能超过 ${props.maxCsvRows}`)
removeUploadFile(uploadFile)
return
}
if (rows.some((row) => row.includes(','))) {
ElMessage.warning('CSV 只能包含一列资产标识')
removeUploadFile(uploadFile)
return
}
}
if (props.accept) {
const accepted = props.accept.split(',').some((type) => {
const value = type.trim().toLowerCase()
@@ -220,7 +250,7 @@
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: contentType,
purpose: 'attachment'
purpose: props.purpose
})
if (uploadUrlRes.code !== 0) {

View File

@@ -8,6 +8,7 @@ import {
type ShallowRef
} from 'vue'
import { isAsyncTaskActive, isAsyncTaskTerminal, type AsyncTaskProgress } from '@/types/api'
import { normalizeApiError } from '@/utils/business/apiError'
const POLL_DELAYS = [2000, 3000, 5000]
const MAX_POLL_DELAY = 10000
@@ -50,8 +51,7 @@ const writeStoredTaskId = (storageKey: string, taskId: number | null) => {
}
}
const getErrorMessage = (error: any) =>
error?.response?.data?.msg || error?.message || '获取导出任务详情失败'
const getErrorMessage = (error: unknown) => normalizeApiError(error).message
export function useAsyncTaskPolling<T extends AsyncTaskProgress>(
options: AsyncTaskPollingOptions<T>

View File

@@ -43,6 +43,56 @@ export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneCo
download: 'export_task:order_download',
cancel: 'export_task:order_cancel'
}
},
package: {
scene: 'package',
sceneName: '套餐管理',
pageTitle: '导出套餐',
permissions: {
detail: 'export_task:package_detail',
download: 'export_task:package_download',
cancel: 'export_task:package_cancel'
}
},
agent_wallet_transaction: {
scene: 'agent_wallet_transaction',
sceneName: '代理主钱包流水',
pageTitle: '导出代理主钱包流水',
permissions: {
detail: 'export_task:agent_wallet_transaction_detail',
download: 'export_task:agent_wallet_transaction_download',
cancel: 'export_task:agent_wallet_transaction_cancel'
}
},
agent_recharge: {
scene: 'agent_recharge',
sceneName: '代理充值',
pageTitle: '导出代理充值',
permissions: {
detail: 'export_task:agent_recharge_detail',
download: 'export_task:agent_recharge_download',
cancel: 'export_task:agent_recharge_cancel'
}
},
refund: {
scene: 'refund',
sceneName: '退款',
pageTitle: '导出退款',
permissions: {
detail: 'export_task:refund_detail',
download: 'export_task:refund_download',
cancel: 'export_task:refund_cancel'
}
},
exchange: {
scene: 'exchange',
sceneName: '换货',
pageTitle: '导出换货',
permissions: {
detail: 'export_task:exchange_detail',
download: 'export_task:exchange_download',
cancel: 'export_task:exchange_cancel'
}
}
}

View File

@@ -37,3 +37,4 @@ export * from './exportTask'
// 批量订购相关
export * from './bulkPurchase'
export * from './julyIteration'

View File

@@ -0,0 +1,32 @@
/**
* 七月迭代新增后台权限编码。
* 页面和按钮统一引用这里的常量,后端菜单权限可直接复用同名编码。
*/
export const JULY_PERMISSIONS = {
wecom: {
page: 'wecom:config',
application: 'wecom:application',
member: 'wecom:member',
scene: 'wecom:scene',
binding: 'wecom:account_binding'
},
systemConfig: {
payment: 'system_config:payment_methods'
},
speedTier: {
view: 'iot_card:speed_tier',
update: 'iot_card:speed_tier:update'
},
deviceAllocation: {
page: 'device_task:allocation',
create: 'device_task:allocation:create',
detail: 'device_task:allocation:detail'
},
batchPurchase: {
page: 'bulk_purchase:view',
create: 'bulk_purchase:create',
detail: 'bulk_purchase:detail'
}
} as const
export type JulyPermission = string

View File

@@ -39,27 +39,6 @@ export const asyncRoutes: AppRouteRecord[] = [
]
},
// 临期资产
{
path: '/operations',
name: 'Operations',
component: RoutesAlias.Home,
meta: {
title: '运营管理'
},
children: [
{
path: 'expiring-assets',
name: 'ExpiringAssets',
component: RoutesAlias.ExpiringAssets,
meta: {
title: '临期资产',
keepAlive: true
}
}
]
},
// 仪表台
{
name: 'Dashboard',
@@ -352,6 +331,16 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true
}
},
// 临期资产
{
path: 'expiring-assets',
name: 'ExpiringAssets',
component: RoutesAlias.ExpiringAssets,
meta: {
title: '临期资产',
keepAlive: true
}
},
// IoT卡管理
{
path: 'iot-card-management',
@@ -775,6 +764,72 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
{
path: 'wecom',
name: 'WecomSettings',
redirect: RoutesAlias.WecomApplications,
meta: {
title: '企业微信配置',
isHide: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
// 企业微信应用
{
path: 'wecom/applications',
name: 'WecomApplications',
component: RoutesAlias.WecomApplications,
meta: {
title: '企微应用管理',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
// 企业微信应用配置详情
{
path: 'wecom/applications/create',
name: 'WecomApplicationCreate',
component: RoutesAlias.WecomApplicationDetail,
meta: {
title: '新增企微应用',
isHide: true,
keepAlive: false,
roles: ['R_SUPER', 'R_ADMIN']
}
},
{
path: 'wecom/applications/detail/:id',
name: 'WecomApplicationDetail',
component: RoutesAlias.WecomApplicationDetail,
meta: {
title: '企微应用配置',
isHide: true,
keepAlive: false,
roles: ['R_SUPER', 'R_ADMIN']
}
},
// 企业微信成员及默认发起人
{
path: 'wecom/members',
name: 'WecomMembers',
component: RoutesAlias.WecomMembers,
meta: {
title: '企微成员管理',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
// 企业微信审批场景
{
path: 'wecom/scenes',
name: 'WecomScenes',
component: RoutesAlias.WecomScenes,
meta: {
title: '企微审批场景',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
}
]
},

View File

@@ -80,7 +80,7 @@ export enum RoutesAlias {
// 站内通知
Notifications = '/notifications', // 通知中心
ExpiringAssets = '/operations/expiring-assets', // 临期资产
ExpiringAssets = '/asset-management/expiring-assets', // 临期资产
// 佣金管理
WithdrawalApproval = '/commission-management/withdrawal-approval', // 提现审批
@@ -93,6 +93,11 @@ export enum RoutesAlias {
PaymentSettingsDetail = '/settings/payment-settings/detail', // 支付设置详情
OperationPasswordSettings = '/settings/operation-password', // 操作密码设置
SystemConfigs = '/settings/system-configs', // 系统配置
WecomSettings = '/settings/wecom', // 企业微信配置兼容入口
WecomApplications = '/settings/wecom/applications', // 企业微信应用
WecomApplicationDetail = '/settings/wecom/applications/detail', // 企业微信应用配置
WecomMembers = '/settings/wecom/members', // 企业微信成员
WecomScenes = '/settings/wecom/scenes', // 企业微信审批场景
// 轮询管理
DataCleanup = '/polling-management/data-cleanup', // 数据清理

View File

@@ -8,7 +8,8 @@ export enum AgentRechargeStatus {
PAID = 2, // 已支付
COMPLETED = 3, // 已完成
CLOSED = 4, // 已关闭
REFUNDED = 5 // 已退款
REFUNDED = 5, // 已退款
REJECTED = 6 // 已驳回
}
// 支付方式
@@ -35,8 +36,10 @@ export interface AgentRecharge {
rejection_reason?: string | null // 拒绝原因
remark?: string // 运营备注
submitter_name?: string | null // 提交人名称
approval_source?: 'none' | 'legacy' | 'wecom' | null // 审批来源
approval_status?: string | null // 审批状态
submitter_id?: number | null
approval_provider?: 'wecom' | null
approval_instance_id?: number | null
approval_status?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | null // 企微审批状态
approval_status_name?: string | null // 审批状态名称
current_approver_summary?: string | null // 当前审批人摘要
processing_status?: string | null // 业务处理状态
@@ -55,7 +58,7 @@ export interface AgentRechargeQueryParams {
status?: AgentRechargeStatus
start_date?: string
end_date?: string
dateRange?: string[] | any // For date range picker in UI
dateRange?: string[] // For date range picker in UI
}
// 代理充值订单列表响应

View File

@@ -10,6 +10,8 @@ type ExpiryBase = 'from_activation' | 'from_purchase'
export type AssetType = 'card' | 'device'
import type { AllowedPaymentMethod } from './systemConfig'
// 网络状态
export enum NetworkStatus {
OFFLINE = 0, // 停机
@@ -104,6 +106,11 @@ export interface BatchUpdateAssetRealnamePolicyRequest {
realname_policy: AssetRealnamePolicy
}
export interface BatchUpdateAssetRealnamePolicyResponse {
realname_policy: AssetRealnamePolicy
success_count: number
}
// 换货关联资产
export interface AssetExchangeTraceAsset {
asset_type: string
@@ -135,7 +142,10 @@ export interface AssetResolveResponse {
series_id: number // 套餐系列 ID
series_name: string // 套餐系列名称
real_name_status: RealNameStatus // 实名状态0 未实名 / 1 已实名
realname_policy?: string // 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
realname_policy?: AssetRealnamePolicy // 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
effective_realname_policy?: AssetRealnamePolicy
realname_required?: boolean
allowed_payment_methods?: AllowedPaymentMethod[]
real_name_at?: string | null // 实名时间
network_status?: NetworkStatus // 网络状态0 停机 / 1 开机(仅 card
current_package: string // 当前套餐名称(无则空)
@@ -206,7 +216,7 @@ export interface AssetBoundCard {
network_status: NetworkStatus // 网络状态
real_name_status: RealNameStatus // 实名状态
real_name_at?: string | null // 实名时间
realname_policy?: string // 实名认证策略
realname_policy?: AssetRealnamePolicy // 实名认证策略
slot_position: number // 插槽位置
is_current?: boolean // 是否为设备当前使用的卡
gateway_extend?: string // Gateway 卡状态扩展字段,原样返回上游 extend
@@ -267,7 +277,7 @@ export interface AssetRealtimeStatusResponse {
// ===== 卡专属字段 =====
network_status?: NetworkStatus // 网络状态(仅 card
real_name_status?: RealNameStatus // 实名状态(仅 card
realname_policy?: string // 实名认证策略(仅 card
realname_policy?: AssetRealnamePolicy // 实名认证策略(仅 card
gateway_card_imei?: string // Gateway 返回的卡 IMEI
current_month_usage_mb?: number // 本月已用流量 MB仅 card
last_gateway_reading_mb?: number // 运营商周期月内已用流量 MB仅 card

View File

@@ -2,6 +2,13 @@ import type { BaseResponse } from './common'
export type BulkPurchasePaymentMethod = 'wallet' | 'offline'
export interface BulkPurchaseCreateRequest {
file_key: string
package_id: number
payment_method: BulkPurchasePaymentMethod
voucher_keys?: string[]
}
export enum BulkPurchaseTaskStatus {
PENDING = 1,
PROCESSING = 2,
@@ -19,37 +26,46 @@ export interface BulkPurchaseCreateResponse {
}
export interface BulkPurchaseTask {
id?: number
task_id: number
task_no?: string
id: number
task_id?: number
task_no: string
file_name: string
package_id: number
package_code: string
package_name: string
payment_method: BulkPurchasePaymentMethod
status: BulkPurchaseTaskStatus
status_name?: string
total_count?: number
success_count?: number
status_name: string
total_count: number
success_count: number
fail_count: number
failed_count?: number
total_amount?: number
success_amount?: number
failed_amount?: number
amount_summary?: Record<string, number | string>
error_code?: string
error_summary?: string
created_at?: string
updated_at?: string
started_at?: string
completed_at?: string
error_message: string
creator_name: string
voucher_keys: string[] | null
started_at: string | null
completed_at: string | null
created_at: string
updated_at: string
items?: BulkPurchaseItem[]
}
export interface BulkPurchaseItem {
id?: number
row_number?: number
line?: number
asset_identifier?: string
line: number
asset_identifier: string
package_code?: string
status: BulkPurchaseTaskStatus
status_name: string
order_id: number
order_no: string
amount: number
reason: string
iccid?: string
virtual_no?: string
package_code?: string
status?: string | number
status_name?: string
error_code?: string
error_reason?: string
error_summary?: string
}
@@ -64,4 +80,11 @@ export interface BulkPurchaseItemsResponse {
export type BulkPurchaseCreateApiResponse = BaseResponse<BulkPurchaseCreateResponse>
export type BulkPurchaseTaskApiResponse = BaseResponse<BulkPurchaseTask>
export type BulkPurchaseItemsApiResponse = BaseResponse<BulkPurchaseItemsResponse>
export interface BulkPurchaseTaskListResponse {
items: BulkPurchaseTask[]
page: number
size: number
total: number
}
export type BulkPurchaseTaskListApiResponse = BaseResponse<BulkPurchaseTaskListResponse>

View File

@@ -5,6 +5,16 @@
import { PaginationParams, ImportTask } from './common'
import type { ExpiryEstimateStatus } from './asset'
export type SpeedTierCode = -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
export interface SetSpeedTierResponse {
code: SpeedTierCode
iccid: string
integration_id: string
iot_card_id: number
speed_tier_name: string
}
// 运营商类型
export enum Operator {
CHINA_MOBILE = 'mobile', // 中国移动

View File

@@ -3,7 +3,7 @@
*/
import { PaginationParams } from './common'
import type { ExpiryEstimateStatus } from './asset'
import type { AssetRealnamePolicy, ExpiryEstimateStatus } from './asset'
// ========== 设备状态枚举 ==========
@@ -65,7 +65,7 @@ export interface Device {
last_gateway_sync_at?: string | null // 最后 sync-info 同步时间
imei?: string // IMEI设备国际移动设备识别码
sn?: string // 设备序列号
realname_policy?: string // 实名认证策略
realname_policy?: AssetRealnamePolicy // 实名认证策略
real_name_status: 0 | 1 // 实名状态 (0:未实名, 1:已实名)
real_name_status_name: string // 实名状态名称
estimated_final_expires_at?: string | null // 当前及排队主套餐接续后的预计最终到期时间
@@ -217,6 +217,7 @@ export enum DeviceImportTaskStatus {
// 导入任务查询参数
export interface DeviceImportTaskQueryParams extends PaginationParams {
status?: DeviceImportTaskStatus // 任务状态
operation_type?: 'import' | 'assign_shop' | 'assign_series'
batch_no?: string // 批次号(模糊查询)
start_time?: string // 创建时间起始
end_time?: string // 创建时间结束
@@ -237,6 +238,11 @@ export interface DeviceImportTask {
fail_count: number // 失败数
skip_count: number // 跳过数
error_message: string // 错误信息
operation_type: 'import' | 'assign_shop' | 'assign_series'
operation_name: string
target_id: number | null
realname_policy: 'none' | 'before_order' | 'after_order'
warning_count: number
created_at: string // 创建时间
started_at: string | null // 开始处理时间
completed_at: string | null // 完成时间
@@ -244,17 +250,17 @@ export interface DeviceImportTask {
// 导入任务列表响应
export interface DeviceImportTaskListResponse {
list: DeviceImportTask[] | null // 任务列表
items: DeviceImportTask[] // 任务列表
page: number // 当前页码
page_size: number // 每页数量
size: number // 每页数量
total: number // 总数
total_pages: number // 总页数
}
// 导入结果详细项
export interface DeviceImportResultItem {
line: number // 行号
virtual_no: string // 虚拟号(原 device_no
device_identifier: string // CSV 原始设备标识
virtual_no: string // 设备虚拟号
reason: string // 原因
}
@@ -262,6 +268,19 @@ export interface DeviceImportResultItem {
export interface DeviceImportTaskDetail extends DeviceImportTask {
failed_items: DeviceImportResultItem[] | null // 失败记录详情
skipped_items: DeviceImportResultItem[] | null // 跳过记录详情
warning_items: DeviceImportResultItem[] | null // 警告记录详情
}
export interface DeviceBatchAllocationRequest {
file_key: string
operation_type: 'assign_shop' | 'assign_series'
target_id: number
}
export interface DeviceBatchAllocationResponse {
message: string
task_id: number
task_no: string
}
// ========== 批量设置设备的套餐系列绑定相关 ==========

View File

@@ -1,6 +1,14 @@
import type { BaseResponse, PaginationParams } from './common'
export type ExportTaskScene = 'device' | 'iot_card' | 'order'
export type ExportTaskScene =
| 'device'
| 'iot_card'
| 'order'
| 'package'
| 'agent_wallet_transaction'
| 'agent_recharge'
| 'refund'
| 'exchange'
export type ExportTaskFormat = 'xlsx' | 'csv'
@@ -45,6 +53,10 @@ export interface ExportTaskItem {
created_at: string
started_at?: string
completed_at?: string
creator_enterprise_id?: number | null
creator_shop_id?: number | null
creator_user_id?: number
creator_user_type?: number
}
export interface ExportTaskListResponse {

View File

@@ -125,3 +125,6 @@ export * from './bulkPurchase'
// 站内通知相关
export * from './notification'
// 企业微信审批配置相关
export * from './wecom'

View File

@@ -22,6 +22,14 @@ export type OrderPaymentMethod = 'wallet' | 'wechat' | 'alipay' | 'offline'
// 订单操作者类型
export type OrderOperatorType = 'platform' | 'agent' | 'enterprise' | 'personal_customer'
export type PurchaseRole =
| 'self_purchase'
| 'purchased_by_parent'
| 'purchased_by_platform'
| 'purchase_for_subordinate'
export type OrderAssetType = 'card' | 'device'
// 订单佣金流程状态
export enum OrderCommissionStatus {
PENDING = 1, // 待计算
@@ -71,9 +79,10 @@ export interface Order {
device_id: number | null
iot_card_id: number | null
asset_identifier?: string // 资产标识符快照ICCID 或 VirtualNo
asset_type?: string // 资产类型:single_card 或 device
purchase_role?: string // 订单渠道
asset_type?: OrderAssetType // 资产类型card 或 device
purchase_role?: PurchaseRole // 订单渠道
purchase_remark?: string // 购买备注
is_purchase_on_behalf?: boolean // 是否为代购订单
is_purchased_by_parent?: boolean // 是否由上级代购
is_expired?: boolean // 是否已过期
expires_at?: string | null // 订单超时时间
@@ -97,13 +106,13 @@ export interface OrderQueryParams {
order_type?: OrderType
order_no?: string
identifier?: string // 按资产标识符过滤ICCID 或 VirtualNo
purchase_role?: string // 订单渠道
purchase_role?: PurchaseRole // 订单渠道
buyer_phone?: string // 按买家手机号精确过滤
start_time?: string
end_time?: string
is_expired?: boolean // 是否已过期
seller_shop_id?: number // 所属代理商ID销售来源店铺ID
dateRange?: string[] | any // For date range picker in UI
dateRange?: string[] // For date range picker in UI
}
// 订单列表响应

View File

@@ -171,7 +171,7 @@ export interface PackageResponse extends PackageAllocationExpiryBaseFields {
suggested_retail_price?: number | null // 建议零售价(分),可能为 null 表示"未配置"
retail_price?: number | null // 原始代理零售价(分),可能为 null
is_gift?: boolean // 是否为赠送套餐
price_config_status?: string // 价格配置状态
price_config_status?: number // 价格配置状态0未配置1赠送0价2已配置非0
price_config_status_name?: string // 价格配置状态名称
effective_retail_price?: number | null // 有效零售价(分)
retail_price_config_status?: string // 零售价配置状态(代理视角)
@@ -180,7 +180,7 @@ export interface PackageResponse extends PackageAllocationExpiryBaseFields {
profit_margin?: number | null // 利润空间(分,仅代理用户可见)
tier_info?: CommissionTierInfo // 返佣档位信息
shelf_status?: number // 上架状态 (1:上架, 2:下架)
status?: number // 状态 (1:启用, 2:禁用)
status?: number // 状态 (0:禁用, 1:启用)
description?: string
created_at?: string
updated_at?: string
@@ -193,7 +193,6 @@ export interface PackageQueryParams extends PaginationParams {
package_name?: string // 套餐名称(模糊搜索)
series_id?: number // 系列ID
package_type?: string // 套餐类型
data_type?: string // 流量类型
shelf_status?: number // 上架状态
status?: number // 状态
}
@@ -244,7 +243,7 @@ export interface UpdatePackageRequest {
* 更新套餐状态请求
*/
export interface UpdatePackageStatusRequest {
status: number // 1:启用, 2:禁
status: number // 0:禁用, 1:启用
}
/**

View File

@@ -66,8 +66,11 @@ export interface Refund {
asset_reset: boolean
commission_deducted: boolean
submitter_name?: string | null // 提交人名称
submitter_id?: number | null
approval_provider?: 'wecom' | null
approval_instance_id?: number | null
approval_source?: 'none' | 'legacy' | 'wecom' | null // 审批来源
approval_status?: string | null // 审批状态
approval_status?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | null // 审批状态
approval_status_name?: string | null // 审批状态名称
current_approver_summary?: string | null // 当前审批人摘要
processing_status?: string | null // 业务处理状态

View File

@@ -26,6 +26,7 @@ export interface ShopResponse {
business_owner_username: string // 平台业务员账号名
business_owner_phone_summary: string // 平台业务员手机号摘要
business_owner_available: boolean // 是否仍可用于通知接收
client_login_disabled: boolean // 是否禁止 C 端新登录
}
// 店铺列表查询参数
@@ -39,6 +40,7 @@ export interface ShopQueryParams extends PaginationParams {
status?: number | null // 状态 (0:禁用, 1:启用)
contact_phone?: string // 联系电话精确查询
business_owner_account_id?: number | null // 平台业务员账号ID
client_login_disabled?: boolean
page?: number // 页码
page_size?: number // 每页数量
}
@@ -59,6 +61,7 @@ export interface CreateShopParams {
contact_name?: string // 联系人姓名
contact_phone?: string // 联系人电话
business_owner_account_id?: number | null // 平台业务员账号ID
client_login_disabled?: boolean
}
// 更新店铺参数
@@ -72,6 +75,7 @@ export interface UpdateShopParams {
contact_name?: string // 联系人姓名
contact_phone?: string // 联系人电话
business_owner_account_id?: number | null // 平台业务员账号ID
client_login_disabled?: boolean
}
// 店铺业务员候选

View File

@@ -2,6 +2,12 @@ import type { PaginationParams } from './common'
export type SystemConfigModule = 'carrier_callback' | 'c2b.payment'
export type AllowedPaymentMethod = 'wallet' | 'wechat' | 'alipay'
export const PAYMENT_CONFIG_KEYS = {
card: 'c2b.payment.card_allowed_methods',
device: 'c2b.payment.device_allowed_methods'
} as const
export type SystemConfigValueType = 'string' | 'int' | 'bool' | 'json'
export interface SystemConfigItem {
@@ -35,3 +41,16 @@ export interface UpdateSystemConfigRequest {
key: string
value: string
}
export function parsePaymentMethods(value: string): AllowedPaymentMethod[] {
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed)
? parsed.filter((method): method is AllowedPaymentMethod =>
['wallet', 'wechat', 'alipay'].includes(method)
)
: []
} catch {
return []
}
}

95
src/types/api/wecom.ts Normal file
View File

@@ -0,0 +1,95 @@
import type { BaseResponse, PaginationData, PaginationParams } from './common'
export type WecomBusinessType = 'refund_approval' | 'offline_recharge_approval'
export interface WecomApplication {
id: number
corp_id: string
agent_id: number
name: string
secret?: string
callback_token?: string
encoding_aes_key?: string
credentials_set: boolean
default_creator_userid?: string | null
default_creator_name?: string | null
status: 0 | 1
status_name: string
last_connected_at: string | null
created_at: string
updated_at: string
}
export interface WecomApplicationRequest {
corp_id?: string
agent_id?: number
name?: string
secret?: string
callback_token?: string
encoding_aes_key?: string
status?: 0 | 1
}
export interface WecomMember {
application_id: number
corp_id: string
userid: string
name: string
department_ids: number[] | null
synced_at: string
}
export interface WecomSceneControlMapping {
business_field: string
control_id: string
control_type: string
option_mapping: Record<string, string>
}
export interface WecomScene {
id: number
application_id: number
business_type: WecomBusinessType
business_type_name: string
template_id: string
template_name: string
control_mapping: WecomSceneControlMapping[]
template_fingerprint: string
last_verified_at: string
status: 0 | 1
status_name: string
updated_at: string
}
export interface WecomSceneRequest {
application_id?: number
template_id?: string
control_mapping?: WecomSceneControlMapping[]
status?: 0 | 1
}
export interface WecomAccountBindingRequest {
application_id: number
userid: string
}
export interface WecomSyncMembersResponse {
application_id: number
synced_at: string
synced_count: number
}
export type WecomApplicationListResponse = BaseResponse<PaginationData<WecomApplication>>
export type WecomMemberListResponse = BaseResponse<PaginationData<WecomMember>>
export type WecomSceneListResponse = BaseResponse<PaginationData<WecomScene>>
export type WecomApplicationResponse = BaseResponse<WecomApplication>
export type WecomSceneResponse = BaseResponse<WecomScene>
export type WecomSyncMembersApiResponse = BaseResponse<WecomSyncMembersResponse>
export interface WecomMemberQueryParams extends PaginationParams {
keyword?: string
}
export interface WecomApplicationQueryParams extends PaginationParams {}
export interface WecomSceneQueryParams extends PaginationParams {}

View File

@@ -91,6 +91,7 @@ declare module 'vue' {
ElCard: typeof import('element-plus/es')['ElCard']
ElCascader: typeof import('element-plus/es')['ElCascader']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']

View File

@@ -0,0 +1,31 @@
import axios from 'axios'
export type ApiErrorKind = 'auth' | 'forbidden' | 'validation' | 'conflict' | 'timeout' | 'server' | 'unknown'
export interface NormalizedApiError {
kind: ApiErrorKind
status?: number
code?: number | string
message: string
unknownResult: boolean
}
const getResponse = (error: unknown) => (axios.isAxiosError(error) ? error.response : undefined)
export const normalizeApiError = (error: unknown): NormalizedApiError => {
const response = getResponse(error)
const status = response?.status
const code = response?.data?.code
const backendMessage = response?.data?.msg
const timeout = axios.isAxiosError(error) && error.code === 'ECONNABORTED'
if (timeout) {
return { kind: 'timeout', status, code, message: '请求超时,结果可能未知,请刷新确认', unknownResult: true }
}
if (status === 401 || code === 401) return { kind: 'auth', status, code, message: '登录状态已失效,请重新登录', unknownResult: false }
if (status === 403) return { kind: 'forbidden', status, code, message: '没有执行此操作的权限', unknownResult: false }
if (status === 400) return { kind: 'validation', status, code, message: backendMessage || '请求参数或业务条件不符合要求', unknownResult: false }
if (status === 409) return { kind: 'conflict', status, code, message: backendMessage || '数据已发生变化,请刷新后重试', unknownResult: false }
if (status && status >= 500) return { kind: 'server', status, code, message: '服务器处理失败,请稍后重试', unknownResult: false }
return { kind: 'unknown', status, code, message: backendMessage || '操作失败,请稍后重试', unknownResult: false }
}

View File

@@ -2,6 +2,9 @@ export type ApprovalSource = 'none' | 'legacy' | 'wecom'
export interface ApprovalSummary {
approval_source?: ApprovalSource | null
approval_provider?: 'wecom' | null
approval_instance_id?: number | null
approval_status?: number | string | null
approval_status_name?: string | null
current_approver_summary?: string | null
processing_status_name?: string | null
@@ -9,14 +12,36 @@ export interface ApprovalSummary {
export const getApprovalStatusText = (summary: ApprovalSummary): string => {
if (summary.approval_source === 'legacy') return '历史审批'
if (summary.approval_source === 'wecom') return summary.approval_status_name || '-'
if (
summary.approval_source === 'wecom' ||
summary.approval_provider === 'wecom' ||
summary.approval_instance_id
) {
return summary.approval_status_name || approvalStatusNames[Number(summary.approval_status)] || '-'
}
return '-'
}
export const getCurrentApproverSummaryText = (summary: ApprovalSummary): string => {
if (summary.approval_source !== 'wecom') return '-'
if (
summary.approval_source !== 'wecom' &&
summary.approval_provider !== 'wecom' &&
!summary.approval_instance_id
) return '-'
return summary.current_approver_summary || '-'
}
export const getProcessingStatusText = (summary: ApprovalSummary): string =>
summary.processing_status_name || '-'
export const approvalStatusNames: Record<number, string> = {
0: '提交中',
1: '审批中',
2: '已通过',
3: '已拒绝',
4: '已撤销',
5: '通过后撤销',
6: '已删除',
7: '提交失败',
8: '提交结果未知'
}

View File

@@ -3,6 +3,7 @@ import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/modules/user'
import { ApiStatus } from './status'
import type { RequestOptions, ErrorMessageMode } from '@/types/api'
import { normalizeApiError } from '@/utils/business/apiError'
const axiosInstance = axios.create({
timeout: 15000, // 请求超时时间(毫秒)
@@ -220,7 +221,8 @@ function handleErrorMessage(
) {
if (mode === 'none') return
const backendMessage = error.response?.data?.msg
const normalized = normalizeApiError(error)
const backendMessage = normalized.message
const httpStatus = error.response?.status
// 401 过期类错误由 clearLocalStateAndRedirect 统一提示,避免重复弹出后端报错。
@@ -245,7 +247,7 @@ function handleErrorMessage(
}
// 其他错误显示通用提示
const message = '请求超时或服务器异常!'
const message = normalized.message
if (mode === 'modal') {
// TODO: 可以使用 ElMessageBox 显示模态框
ElMessage.error(message)

View File

@@ -9,6 +9,14 @@
</ElTag>
</div>
<div class="card-header-right">
<ElButton
v-if="cardInfo?.asset_type === 'card' && hasAuth(JULY_PERMISSIONS.speedTier.view)"
type="primary"
link
@click="emit('showSpeedLimit')"
>
设置限速
</ElButton>
<ElTooltip content="开启后系统将自动轮询更新资产状态" placement="top">
<div v-if="canShowPolling" class="polling-switch-wrapper">
<span class="polling-label">自动轮询</span>
@@ -194,33 +202,6 @@
</template>
</ElDescriptions>
<ElDivider content-position="left">同步状态</ElDivider>
<ElDescriptions :column="descriptionsColumn" border>
<ElDescriptionsItem label="轮询状态">
<ElTag
v-if="cardInfo?.polling"
:type="cardInfo.polling.enabled ? 'success' : 'info'"
size="small"
>
{{ cardInfo.polling.enabled ? '已启用' : '未启用' }}
</ElTag>
<span v-else>-</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="最后活跃时间">
{{ formatDateTime(cardInfo?.polling?.last_activity_at) || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="同步轨迹">
<ElButton
v-permission="'asset_info:view_sync_trail'"
type="primary"
link
@click="emit('viewSyncTrail')"
>
查看同步轨迹
</ElButton>
</ElDescriptionsItem>
</ElDescriptions>
<template v-if="previousExchangeAsset || nextExchangeAsset">
<ElDivider content-position="left">换货链路</ElDivider>
<ElDescriptions :column="descriptionsColumn" border>
@@ -596,6 +577,7 @@
} from '@/types/api'
import { useAssetFormatters } from '../composables/useAssetFormatters'
import { useAuth } from '@/composables/useAuth'
import { JULY_PERMISSIONS } from '@/config/constants'
import { useUserStore } from '@/store/modules/user'
import { formatDateTime } from '@/utils/business/format'
import {
@@ -798,7 +780,6 @@
(e: 'navigateToCard', iccid: string): void
(e: 'navigateToDevice', deviceNo: string): void
(e: 'navigateToAsset', identifier: string): void
(e: 'viewSyncTrail'): void
}
const emit = defineEmits<Emits>()

View File

@@ -126,6 +126,7 @@
import { useUserStore } from '@/store/modules/user'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
import { normalizeApiError } from '@/utils/business/apiError'
interface Props {
modelValue: boolean
@@ -336,7 +337,11 @@
data.payment_voucher_key = toVoucherKeyList(form.payment_voucher_key)
}
await OrderService.createOrder(data)
const response = await OrderService.createOrder(data)
if (response.code !== 0) {
ElMessage.error(response.msg || '订单创建失败')
return
}
if (form.payment_method === 'wallet') {
ElMessage.success('订单创建成功,已自动完成支付')
@@ -352,6 +357,7 @@
return
}
console.error('创建订单失败:', error)
ElMessage.error(normalizeApiError(error).message)
} finally {
loading.value = false
}

View File

@@ -1,25 +1,10 @@
<template>
<ElDialog v-model="visible" title="设置限速" width="500px">
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElFormItem label="下行速率" prop="download_speed">
<ElInputNumber
v-model="form.download_speed"
:min="1"
:step="128"
controls-position="right"
style="width: 100%"
/>
<div style="margin-top: 4px; font-size: 12px; color: #909399">单位: KB/s</div>
</ElFormItem>
<ElFormItem label="上行速率" prop="upload_speed">
<ElInputNumber
v-model="form.upload_speed"
:min="1"
:step="128"
controls-position="right"
style="width: 100%"
/>
<div style="margin-top: 4px; font-size: 12px; color: #909399">单位: KB/s</div>
<ElFormItem label="固定档位" prop="code">
<ElSelect v-model="form.code" style="width: 100%">
<ElOption v-for="tier in speedTiers" :key="tier.code" :label="tier.label" :value="tier.code" />
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
@@ -31,7 +16,7 @@
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ElDialog, ElForm, ElFormItem, ElInputNumber, ElButton } from 'element-plus'
import { ElDialog, ElForm, ElFormItem, ElSelect, ElOption, ElButton } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
@@ -40,7 +25,7 @@
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', data: { download_speed: number; upload_speed: number }): void
(e: 'confirm', data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }): void
}
const props = defineProps<Props>()
@@ -49,14 +34,23 @@
const formRef = ref<FormInstance>()
const loading = ref(false)
const form = reactive({
download_speed: 1024,
upload_speed: 512
})
const speedTiers = [
{ code: -1, label: '不限速' },
{ code: 0, label: '0kbps' },
{ code: 1, label: '128Kbps' },
{ code: 2, label: '512Kbps' },
{ code: 3, label: '1Mbps' },
{ code: 4, label: '2Mbps' },
{ code: 5, label: '10Mbps' },
{ code: 6, label: '20Mbps' },
{ code: 7, label: '50Mbps' },
{ code: 8, label: '100Mbps' }
] as const
const form = reactive({ code: 3 as (typeof speedTiers)[number]['code'] })
const rules: FormRules = {
download_speed: [{ required: true, message: '请输入下行速率', trigger: 'blur' }],
upload_speed: [{ required: true, message: '请输入上行速率', trigger: 'blur' }]
code: [{ required: true, message: '请选择限速档位', trigger: 'change' }]
}
const visible = computed({
@@ -67,8 +61,7 @@
// 监听对话框打开,重置表单
watch(visible, (newVal) => {
if (newVal) {
form.download_speed = 1024
form.upload_speed = 512
form.code = 3
}
})
@@ -82,8 +75,7 @@
try {
await formRef.value.validate()
emit('confirm', {
download_speed: form.download_speed,
upload_speed: form.upload_speed
code: form.code
})
} catch (error) {
console.error('表单验证失败:', error)

View File

@@ -16,7 +16,6 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
const manualDeactivateCardLoading = ref(false)
const rebootDeviceLoading = ref(false)
const resetDeviceLoading = ref(false)
const speedLimitLoading = ref(false)
const switchCardLoading = ref(false)
const setWiFiLoading = ref(false)
const pollingLoading = ref(false)
@@ -181,35 +180,6 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
}
}
/**
* Set device speed limit
*/
const setSpeedLimit = async (form: { download_speed: number; upload_speed: number }) => {
try {
speedLimitLoading.value = true
const res = await DeviceService.setSpeedLimit(cardInfo.value.imei, {
download_speed: form.download_speed,
upload_speed: form.upload_speed
})
if (res.code === 0) {
ElMessage.success('限速设置成功')
if (refreshAssetFn) {
await refreshAssetFn()
}
return true
} else {
ElMessage.error(res.msg || '设置失败')
return false
}
} catch (error: any) {
console.error('设置限速失败:', error)
console.log(error?.message || '设置失败')
return false
} finally {
speedLimitLoading.value = false
}
}
/**
* Switch to different card
*/
@@ -320,7 +290,6 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
manualDeactivateCardLoading,
rebootDeviceLoading,
resetDeviceLoading,
speedLimitLoading,
switchCardLoading,
setWiFiLoading,
pollingLoading,
@@ -333,7 +302,6 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
// Device operations
rebootDevice,
resetDevice,
setSpeedLimit,
switchCard,
setWiFi,

View File

@@ -36,7 +36,8 @@
@show-switch-card="showSwitchCardDialog"
@show-switch-mode="showSwitchModeDialog"
@show-set-wifi="showSetWiFiDialog"
@show-realname-policy="showRealnamePolicyDialog"
@show-realname-policy="showRealnamePolicyDialog"
@show-speed-limit="speedLimitDialogVisible = true"
@show-update-realname-status="showUpdateRealnameStatusDialog"
@enable-binding-card="handleEnableBindingCard"
@disable-binding-card="handleDisableBindingCard"
@@ -44,7 +45,6 @@
@navigate-to-asset="handleNavigateToAsset"
@navigate-to-card="handleNavigateToCard"
@navigate-to-device="handleNavigateToDevice"
@view-sync-trail="handleViewSyncTrail"
/>
</div>
@@ -174,6 +174,7 @@
:current-realname-status="bindingCardRealnameStatusValue"
@success="handleBindingCardRealnameStatusSuccess"
/>
<SpeedLimitDialog v-model="speedLimitDialogVisible" @confirm="handleSpeedTierConfirm" />
</div>
</template>
@@ -182,7 +183,7 @@
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElCard, ElEmpty } from 'element-plus'
import { RoutesAlias } from '@/router/routesAlias'
import { DeviceService } from '@/api/modules'
import { CardService, DeviceService } from '@/api/modules'
import {
formatRemainingTime,
FrontendRateLimitError,
@@ -203,8 +204,9 @@
SwitchModeDialog,
WiFiConfigDialog,
PackageRechargeDialog,
DailyRecordsDialog,
OrderHistoryDialog
DailyRecordsDialog,
OrderHistoryDialog,
SpeedLimitDialog
} from './components/dialogs'
import SwitchCardDialog from '@/components/device/SwitchCardDialog.vue'
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
@@ -291,6 +293,7 @@
// 绑定卡实名状态更新
const bindingCardRealnameStatusDialogVisible = ref(false)
const speedLimitDialogVisible = ref(false)
const bindingCardRealnameStatusIccid = ref('')
const bindingCardRealnameStatusValue = ref<number>(0)
@@ -444,18 +447,6 @@
}
}
const handleViewSyncTrail = () => {
if (!cardInfo.value) return
router.push({
path: '/audit/integrations',
query: {
resource_type: cardInfo.value.asset_type,
resource_key: cardInfo.value.identifier
}
})
}
/**
* IoT卡操作 - 启用卡
*/
@@ -814,6 +805,15 @@
handleSearch({ identifier })
}
}
const handleSpeedTierConfirm = async (data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }) => {
if (!cardInfo.value?.iccid) return
const response = await CardService.setSpeedTier(cardInfo.value.iccid, data.code)
if (response.code === 0) {
ElMessage.success(`限速设置成功:${response.data.speed_tier_name}`)
speedLimitDialogVisible.value = false
await handleRefresh()
}
}
</script>
<style lang="scss" scoped>

View File

@@ -2299,7 +2299,8 @@
return
}
ElMessage.success('批量修改实名顺序成功')
const successCount = res.data?.success_count ?? assetIds.length
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
batchRealnamePolicyDialogVisible.value = false
selectedDevices.value = []
await getTableData()

View File

@@ -83,6 +83,11 @@
formatter: (_, data) =>
data.flow_type_name || (flowType === 'direct' ? '直接换货' : '物流换货')
},
{
label: '提交人',
formatter: (value) => value || '--',
prop: 'submitter_name'
},
{
label: '换货原因',
prop: 'exchange_reason',

View File

@@ -380,7 +380,13 @@
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { ExchangeService, CardService, DeviceService } from '@/api/modules'
import type { ExchangeResponse } from '@/api/modules/exchange'
import type {
CreateExchangeRequest,
ExchangeAssetType,
ExchangeFlowType,
ExchangeQueryParams,
ExchangeResponse
} from '@/api/modules/exchange'
import type { StandaloneIotCard, Device } from '@/types/api'
import { ElMessage, ElTag, ElButton, ElMessageBox, ElSwitch } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
@@ -422,7 +428,7 @@
]
// 搜索表单
const searchForm = reactive({
const searchForm = reactive<ExchangeQueryParams & { created_at_range: string[] }>({
status: undefined,
flow_type: undefined, // 流程类型筛选
old_asset_keyword: '',
@@ -433,7 +439,16 @@
})
// 创建换货单表单
const createForm = reactive({
const createForm = reactive<{
exchange_reason: string
exchange_reason_other: string
old_asset_type: ExchangeAssetType | ''
old_identifier: string
flow_type: ExchangeFlowType
new_identifier: string
migrate_data: boolean
remark: string
}>({
exchange_reason: '',
exchange_reason_other: '',
old_asset_type: '',
@@ -819,7 +834,7 @@
loading.value = true
try {
// 过滤掉空值参数
const params: any = {
const params: ExchangeQueryParams = {
page: pagination.page,
page_size: pagination.page_size
}
@@ -1328,9 +1343,9 @@
? createForm.exchange_reason_other.trim()
: createForm.exchange_reason
const requestData: any = {
const requestData: CreateExchangeRequest = {
exchange_reason: exchangeReason,
old_asset_type: createForm.old_asset_type,
old_asset_type: createForm.old_asset_type as ExchangeAssetType,
old_identifier: createForm.old_identifier,
flow_type: createForm.flow_type,
remark: createForm.remark || undefined
@@ -1351,7 +1366,7 @@
}
ElMessage.error(res.msg || '换货单创建失败')
} catch (error: any) {
} catch (error) {
console.error('创建换货单失败:', error)
} finally {
createLoading.value = false

View File

@@ -169,16 +169,16 @@
{ label: '临期等级', prop: 'expiry_level_name' }
]
const getExpiryClass = (days: number) => {
if (days <= 3) return 'expiry-critical'
if (days <= 7) return 'expiry-warning'
if (days <= 15) return 'expiry-notice'
const getExpiryClass = (level?: string | null) => {
if (level === 'critical' || level === '0_3') return 'expiry-critical'
if (level === 'warning' || level === '4_7') return 'expiry-warning'
if (level === 'notice' || level === '8_15') return 'expiry-notice'
return ''
}
const getExpiryTagType = (days: number) => {
if (days <= 3) return 'danger'
if (days <= 7) return 'warning'
const getExpiryTagType = (level?: string | null) => {
if (level === 'critical' || level === '0_3') return 'danger'
if (level === 'warning' || level === '4_7') return 'warning'
return 'info'
}
@@ -245,7 +245,7 @@
formatter: (row: ExpiringAssetItem) =>
h(
'span',
{ class: getExpiryClass(row.days_until_final_expiry) },
{ class: getExpiryClass(row.expiry_level) },
formatDateTime(row.estimated_final_expires_at)
)
},
@@ -256,7 +256,7 @@
formatter: (row: ExpiringAssetItem) =>
h(
ElTag,
{ type: getExpiryTagType(row.days_until_final_expiry), size: 'small' },
{ type: getExpiryTagType(row.expiry_level), size: 'small' },
() => `${row.days_until_final_expiry}`
)
},

View File

@@ -2448,7 +2448,8 @@
return
}
ElMessage.success('批量修改实名顺序成功')
const successCount = res.data?.success_count ?? assetIds.length
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
batchRealnamePolicyDialogVisible.value = false
selectedCards.value = []
await getTableData()

View File

@@ -1,6 +1,6 @@
<template>
<ArtTableFullScreen>
<div class="device-task-page" id="table-full-screen">
<div v-if="isPlatformAccount" class="device-task-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
@@ -10,6 +10,16 @@
@search="handleSearch"
></ArtSearchBar>
<div v-if="pollingError || pollingForbidden" class="task-polling-error">
<ElAlert
type="warning"
:closable="false"
show-icon
:title="pollingForbidden ? '当前账号无权查看该任务' : pollingError || '任务状态获取失败'"
/>
<ElButton size="small" @click="polling.retry">重试</ElButton>
</div>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
@@ -19,10 +29,11 @@
>
<template #left>
<ElButton
v-if="isPlatformAccount && hasAuth(JULY_PERMISSIONS.deviceAllocation.page)"
type="primary"
:icon="Upload"
@click="importDialogVisible = true"
v-permission="'device_task:bulk_import'"
v-permission="JULY_PERMISSIONS.deviceAllocation.page"
>
批量导入设备
</ElButton>
@@ -57,8 +68,8 @@
<template #title>
<div style="line-height: 1.8">
<p><strong>导入说明</strong></p>
<p>1. 请先下载 Excel 模板文件按照模板格式填写设备信息</p>
<p>2. 仅支持 Excel 格式.xlsx单次最多导入 1000 </p>
<p>1. 设备分配使用单列 UTF-8 CSV导入设备仍使用 Excel 模板</p>
<p>2. CSV 单次最多 1000 文件不超过 10MB</p>
<p>3. 列格式请设置为文本格式避免长数字被转为科学计数法</p>
<p>4. <strong>重要列顺序固定不可调整</strong>系统按位置读取不识别列名</p>
<p style="color: var(--el-color-primary)">5. 必填列虚拟号第1列</p>
@@ -77,7 +88,23 @@
</ElButton>
</div>
<ElRow :gutter="20">
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="任务类型">
<ElSelect v-model="importForm.operation_type" style="width: 100%">
<ElOption label="导入设备" value="import" />
<ElOption label="分配目标代理" value="assign_shop" />
<ElOption label="设置套餐系列" value="assign_series" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol v-if="importForm.operation_type !== 'import'" :span="12">
<ElFormItem label="目标 ID">
<ElInput v-model="importForm.target_id" placeholder="代理店铺或套餐系列 ID" />
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="批次号" prop="batch_no">
<ElInput
@@ -108,18 +135,18 @@
</ElCol>
</ElRow>
<ElUpload
<ElUpload
ref="uploadRef"
drag
:auto-upload="false"
:on-change="handleFileChange"
:limit="1"
accept=".xlsx"
>
accept=".xlsx,.csv"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text"> Excel 文件拖到此处<em>点击选择</em></div>
<div class="el-upload__text">将文件拖到此处<em>点击选择</em></div>
<template #tip>
<div class="el-upload__tip">只能上传 .xlsx 格式的 Excel 文件且不超过 300MB</div>
<div class="el-upload__tip">导入设备支持 .xlsx批量分配支持单列 .csv</div>
</template>
</ElUpload>
@@ -140,8 +167,8 @@
<script setup lang="ts">
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { DeviceService } from '@/api/modules'
import { ElMessage, ElTag } from 'element-plus'
import { Download, UploadFilled, Upload } from '@element-plus/icons-vue'
@@ -149,16 +176,29 @@
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { useAsyncTaskPolling } from '@/composables/useAsyncTaskPolling'
import { useUserStore } from '@/store/modules/user'
import { formatDateTime } from '@/utils/business/format'
import { StorageService } from '@/api/modules/storage'
import type { DeviceImportTask, DeviceImportTaskStatus, RealnamePolicy } from '@/types/api/device'
import type {
DeviceImportTask,
DeviceImportTaskDetail,
DeviceImportTaskQueryParams,
DeviceImportTaskStatus,
RealnamePolicy
} from '@/types/api/device'
import { RoutesAlias } from '@/router/routesAlias'
import { generatePackageCode } from '@/utils/codeGenerator'
import { JULY_PERMISSIONS } from '@/config/constants'
import { normalizeApiError } from '@/utils/business/apiError'
defineOptions({ name: 'DeviceTask' })
const router = useRouter()
const route = useRoute()
const { hasAuth } = useAuth()
const userStore = useUserStore()
const isPlatformAccount = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
const loading = ref(false)
const tableRef = ref()
@@ -168,14 +208,16 @@
const importDialogVisible = ref(false)
const importForm = reactive({
batch_no: '',
realname_policy: '' as '' | 'none' | 'before_order' | 'after_order'
realname_policy: '' as '' | 'none' | 'before_order' | 'after_order',
operation_type: 'import' as 'import' | 'assign_shop' | 'assign_series',
target_id: ''
})
// 搜索表单初始值
const initialSearchState = {
status: undefined,
batch_no: '',
dateRange: undefined as any,
dateRange: undefined as string[] | undefined,
start_time: '',
end_time: ''
}
@@ -247,6 +289,23 @@
const taskList = ref<DeviceImportTask[]>([])
const polling = useAsyncTaskPolling<DeviceImportTaskDetail>({
storageKey: 'device-import-active-task',
autoRestore: false,
fetchTask: async (taskId) => {
const res = await DeviceService.getImportTaskDetail(taskId)
if (res.code !== 0 || !res.data) throw new Error(res.msg || '获取设备任务失败')
return res.data
},
isForbidden: (error) => {
const response = (error as { response?: { status?: number; data?: { code?: number } } })
?.response
return response?.status === 403 || response?.data?.code === 403
}
})
const pollingError = polling.error
const pollingForbidden = polling.forbidden
// 获取状态标签类型
const getStatusType = (status: DeviceImportTaskStatus) => {
switch (status) {
@@ -265,6 +324,7 @@
// 查看详情
const viewDetail = (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return
router.push({
path: RoutesAlias.TaskDetail,
query: {
@@ -276,7 +336,7 @@
// 处理名称点击
const handleNameClick = (row: DeviceImportTask) => {
if (hasAuth('device_task:view_detail')) {
if (isPlatformAccount.value && hasAuth('device_task:view_detail')) {
viewDetail(row)
} else {
ElMessage.warning('您没有查看详情的权限')
@@ -385,15 +445,31 @@
}
])
watch(polling.task, () => {
if (isPlatformAccount.value) void getTableData()
})
onMounted(() => {
getTableData()
if (!isPlatformAccount.value) return
void getTableData()
const routeTaskId = Number(route.query.task_id)
if (routeTaskId) {
void polling.start(routeTaskId)
} else if (polling.taskId.value) {
void polling.retry()
}
})
// 获取设备任务列表
const getTableData = async () => {
if (!isPlatformAccount.value) {
taskList.value = []
pagination.total = 0
return
}
loading.value = true
try {
const params: any = {
const params: DeviceImportTaskQueryParams = {
page: pagination.page,
page_size: pagination.pageSize,
status: searchForm.status,
@@ -406,13 +482,6 @@
params.end_time = searchForm.dateRange[1]
}
// 清理空值
Object.keys(params).forEach((key) => {
if (params[key] === '' || params[key] === undefined) {
delete params[key]
}
})
const res = await DeviceService.getImportTasks(params)
if (res.code === 0) {
const taskItems = (res.data as typeof res.data & { items?: DeviceImportTask[] | null })
@@ -422,6 +491,7 @@
}
} catch (error) {
console.error(error)
ElMessage.error(normalizeApiError(error).message)
} finally {
loading.value = false
}
@@ -473,8 +543,9 @@
}
// 文件选择变化
const handleFileChange = (uploadFile: any) => {
const maxSize = 300 * 1024 * 1024
const handleFileChange = async (uploadFile: any) => {
const isCsvAllocation = importForm.operation_type !== 'import'
const maxSize = (isCsvAllocation ? 10 : 300) * 1024 * 1024
if (uploadFile.raw && uploadFile.raw.size > maxSize) {
ElMessage.error('文件大小不能超过 300MB')
uploadRef.value?.clearFiles()
@@ -482,13 +553,23 @@
return
}
if (uploadFile.raw && !uploadFile.raw.name.endsWith('.xlsx')) {
ElMessage.error('只能上传 .xlsx 格式的 Excel 文件')
if (uploadFile.raw && (isCsvAllocation ? !uploadFile.raw.name.endsWith('.csv') : !uploadFile.raw.name.endsWith('.xlsx'))) {
ElMessage.error(isCsvAllocation ? '批量分配只能上传 .csv 文件' : '设备导入只能上传 .xlsx 文件')
uploadRef.value?.clearFiles()
fileList.value = []
return
}
if (isCsvAllocation && uploadFile.raw) {
const rows = (await uploadFile.raw.text()).replace(/^\uFEFF/, '').split(/\r?\n/).filter(Boolean)
if (rows.length > 1001 || rows.some((row: string) => row.includes(','))) {
ElMessage.error('设备分配 CSV 必须是单列且最多 1000 行数据')
uploadRef.value?.clearFiles()
fileList.value = []
return
}
}
fileList.value = uploadFile.raw ? [uploadFile.raw] : []
}
@@ -510,24 +591,33 @@
clearFiles()
importForm.batch_no = ''
importForm.realname_policy = ''
importForm.operation_type = 'import'
importForm.target_id = ''
importDialogVisible.value = false
}
// 提交上传
const submitUpload = async () => {
if (!fileList.value.length) {
ElMessage.warning('请先选择 Excel 文件')
return
}
if (!isPlatformAccount.value || !hasAuth(JULY_PERMISSIONS.deviceAllocation.page)) return
if (!fileList.value.length) {
ElMessage.warning('请先选择文件')
return
}
if (importForm.operation_type !== 'import' && !Number(importForm.target_id)) {
ElMessage.warning('请输入有效的目标 ID')
return
}
const file = fileList.value[0]
uploading.value = true
try {
ElMessage.info('正在准备上传...')
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
purpose: 'iot_import'
const isAllocation = importForm.operation_type !== 'import'
const contentType = isAllocation ? 'text/csv' : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: contentType,
purpose: isAllocation ? 'device_batch_allocation' : 'iot_import'
})
if (uploadUrlRes.code !== 0) {
@@ -538,18 +628,20 @@
const { upload_url, file_key } = uploadUrlRes.data
ElMessage.info('正在上传文件...')
await StorageService.uploadFile(
upload_url,
file,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
await StorageService.uploadFile(upload_url, file, contentType)
ElMessage.info('正在创建导入任务...')
const importRes = await DeviceService.importDevices({
file_key,
batch_no: importForm.batch_no || undefined,
realname_policy: (importForm.realname_policy || undefined) as RealnamePolicy | undefined
})
ElMessage.info(isAllocation ? '正在创建分配任务...' : '正在创建导入任务...')
const importRes = isAllocation
? await DeviceService.createAllocationTask({
file_key,
operation_type: importForm.operation_type as 'assign_shop' | 'assign_series',
target_id: Number(importForm.target_id)
})
: await DeviceService.importDevices({
file_key,
batch_no: importForm.batch_no || undefined,
realname_policy: (importForm.realname_policy || undefined) as RealnamePolicy | undefined
})
if (importRes.code !== 0) {
ElMessage.error(importRes.msg || '创建导入任务失败')
@@ -557,8 +649,11 @@
}
const taskNo = importRes.data.task_no
const taskId = importRes.data.task_id
handleCancelImport()
await router.replace({ path: route.path, query: { task_id: String(taskId) } })
await polling.start(taskId)
await getTableData()
ElMessage.success({
@@ -568,7 +663,7 @@
})
} catch (error: any) {
console.error('设备导入失败:', error)
console.log(error.message || '设备导入失败')
ElMessage.error(normalizeApiError(error).message || error.message || '设备导入失败')
} finally {
uploading.value = false
}
@@ -576,6 +671,7 @@
// 从行数据下载失败数据
const downloadFailDataByRow = async (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return
try {
const res = await DeviceService.getImportTaskDetail(row.id)
if (res.code === 0 && res.data) {
@@ -622,6 +718,7 @@
}
const downloadTaskFile = async (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return
const fileKey = row.file_name?.trim()
if (!fileKey) {
ElMessage.warning('当前任务没有可下载的原始文件')
@@ -639,6 +736,7 @@
// 获取操作按钮
const getActions = (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return []
const actions: any[] = []
const showDownloadFileAction = false
@@ -664,6 +762,13 @@
<style lang="scss" scoped>
.device-task-page {
.task-polling-error {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
:deep(.el-icon--upload) {
margin-bottom: 16px;
font-size: 67px;

View File

@@ -59,11 +59,14 @@
import type { DeviceImportTaskDetail } from '@/types/api/device'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import DetailPage from '@/components/common/DetailPage.vue'
import { useUserStore } from '@/store/modules/user'
defineOptions({ name: 'TaskDetail' })
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const isPlatformAccount = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
type TaskType = 'card' | 'device'
type TaskDetail = IotCardImportTaskDetail | DeviceImportTaskDetail
@@ -244,6 +247,12 @@
taskType.value = queryTaskType
}
if (taskType.value === 'device' && !isPlatformAccount.value) {
ElMessage.error('当前账号无权查看设备任务详情')
goBack()
return
}
loading.value = true
try {
if (taskType.value === 'device') {

View File

@@ -507,6 +507,7 @@
CommissionSourceMap
} from '@/config/constants/commission'
import { RoutesAlias } from '@/router/routesAlias'
import { normalizeApiError } from '@/utils/business/apiError'
defineOptions({ name: 'AgentCommission' })
@@ -1021,9 +1022,18 @@
}
const handleCreditConflict = async () => {
ElMessage.warning('资金概况已被更新,已刷新最新数据,请重新调整')
creditDialogVisible.value = false
const shopId = currentCreditShop.value?.shop_id
await getTableData()
if (shopId) {
const latest = summaryList.value.find((item) => item.shop_id === shopId)
if (latest) {
// 仅刷新后端版本和当前余额,保留用户刚填写的表单值,方便确认后重试。
currentCreditShop.value = latest
}
}
ElMessage.warning('资金概况已被其他操作更新,已刷新最新版本;请确认后重新提交')
}
const handleCreditSubmit = async () => {
@@ -1058,6 +1068,7 @@
await handleCreditConflict()
} else {
console.error('实际信用额度调整失败:', error)
ElMessage.error(normalizeApiError(error).message)
}
} finally {
creditSubmitting.value = false

View File

@@ -68,7 +68,8 @@
2: 'success', // 已支付
3: 'success', // 已完成
4: 'info', // 已关闭
5: 'danger' // 已退款
5: 'danger', // 已退款
6: 'danger' // 已驳回
}
return statusMap[status] || 'info'
}
@@ -81,7 +82,8 @@
2: '已支付',
3: '已完成',
4: '已关闭',
5: '已退款'
5: '已退款',
6: '已驳回'
}
return statusMap[status] || '-'
}

View File

@@ -464,7 +464,8 @@
2: 'success', // 已支付
3: 'success', // 已完成
4: 'info', // 已关闭
5: 'danger' // 已退款
5: 'danger', // 已退款
6: 'danger' // 已驳回
}
return statusMap[status] || 'info'
}
@@ -477,7 +478,8 @@
2: '已支付',
3: '已完成',
4: '已关闭',
5: '已退款'
5: '已退款',
6: '已驳回'
}
return statusMap[status] || '-'
}

View File

@@ -11,8 +11,8 @@
<ElButton
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.template)"
tag="a"
href="/templates/bulk-purchase-template.xlsx"
download="批量订购模板.xlsx"
href="/templates/bulk-purchase-template.csv"
download="批量订购套餐模板.csv"
>
下载模板
</ElButton>
@@ -20,25 +20,8 @@
</template>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="110px" class="create-form">
<ElFormItem label="代理商" prop="shop_id">
<ElSelect
v-model="form.shop_id"
filterable
remote
reserve-keyword
clearable
placeholder="请选择代理商"
:remote-method="searchShops"
:loading="shopLoading"
style="width: 100%"
>
<ElOption
v-for="shop in shopOptions"
:key="shop.id"
:label="`${shop.shop_name} (${shop.shop_code})`"
:value="shop.id"
/>
</ElSelect>
<ElFormItem label="套餐 ID" prop="package_id">
<ElInputNumber v-model="form.package_id" :min="1" controls-position="right" />
</ElFormItem>
<ElFormItem label="支付方式" prop="payment_method">
@@ -53,9 +36,13 @@
ref="orderUploadRef"
v-model="orderFileKeys"
voucher-name="订单文件"
:max-count="1"
accept=".xlsx,.xls,.csv"
tip="支持 .xlsx、.xls 或 .csv 文件"
:max-count="1"
purpose="batch_purchase"
:max-size-mb="10"
single-column-csv
:max-csv-rows="1000"
accept=".csv"
tip="仅支持 UTF-8 单列 CSV最多 1000 行,最大 10MB"
@uploading-change="orderFileUploading = $event"
@change="formRef?.validateField('orderFile')"
/>
@@ -70,7 +57,7 @@
ref="voucherUploadRef"
v-model="voucherFileKeys"
voucher-name="整批支付凭证"
@uploading-change="voucherUploading = $event"
@uploading-change="voucherUploading = $event"
@change="formRef?.validateField('voucherFile')"
/>
</ElFormItem>
@@ -209,7 +196,8 @@
ElButton,
ElCard,
ElDescriptions,
ElDescriptionsItem,
ElDescriptionsItem,
ElInputNumber,
ElForm,
ElMessage,
ElOption,
@@ -223,12 +211,11 @@
} from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import { BulkPurchaseService, ShopService } from '@/api/modules'
import { BulkPurchaseService } from '@/api/modules'
import type {
BulkPurchaseItem,
BulkPurchasePaymentMethod,
BulkPurchaseTask,
ShopResponse
BulkPurchaseTask
} from '@/types/api'
import { BulkPurchaseTaskStatus } from '@/types/api'
import { useAuth } from '@/composables/useAuth'
@@ -244,8 +231,6 @@
const formRef = ref<FormInstance>()
const orderUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const voucherUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const shopOptions = ref<ShopResponse[]>([])
const shopLoading = ref(false)
const submitting = ref(false)
const orderFileKeys = ref<string[]>([])
const orderFileUploading = ref(false)
@@ -260,15 +245,15 @@
const requestId = ref('')
const form = reactive<{
shop_id?: number
package_id?: number
payment_method: BulkPurchasePaymentMethod
}>({
shop_id: undefined,
package_id: undefined,
payment_method: 'wallet'
})
const rules = computed<FormRules>(() => ({
shop_id: [{ required: true, message: '请选择代理商', trigger: 'change' }],
package_id: [{ required: true, message: '请输入套餐 ID', trigger: 'change' }],
payment_method: [{ required: true, message: '请选择支付方式', trigger: 'change' }],
orderFile: [
{
@@ -310,20 +295,6 @@
return requestId.value
}
const searchShops = async (query: string) => {
shopLoading.value = true
try {
const res = await ShopService.getShops({
page: 1,
page_size: 20,
shop_name: query || undefined
})
if (res.code === 0) shopOptions.value = res.data.items || []
} finally {
shopLoading.value = false
}
}
const handlePaymentMethodChange = (value: string | number | boolean | undefined) => {
if (value === 'wallet') {
voucherFileKeys.value = []
@@ -341,18 +312,16 @@
)
return
const valid = await formRef.value?.validate().catch(() => false)
if (!valid || !form.shop_id || orderFileKeys.value.length === 0) return
if (!valid || !form.package_id || orderFileKeys.value.length === 0) return
submitting.value = true
try {
const data = new FormData()
data.append('shop_id', String(form.shop_id))
data.append('payment_method', form.payment_method)
data.append('file', orderFileKeys.value[0])
data.append('request_id', getRequestId())
if (form.payment_method === 'offline' && voucherFileKeys.value.length > 0) {
data.append('voucher_file', voucherFileKeys.value[0])
}
const data = {
file_key: orderFileKeys.value[0],
package_id: form.package_id,
payment_method: form.payment_method,
...(form.payment_method === 'offline' ? { voucher_keys: voucherFileKeys.value } : {})
}
const res = await BulkPurchaseService.createTask(data)
if (res.code !== 0 || !res.data?.task_id) {
@@ -367,7 +336,7 @@
query: { task_id: String(res.data.task_id) }
})
await polling.start(res.data.task_id)
await loadItems()
await loadItems()
} catch (error: any) {
ElMessage.error(error?.message || '创建批量订购任务失败')
} finally {
@@ -376,26 +345,9 @@
}
const loadItems = async () => {
const taskId = taskDetail.value?.task_id
const taskId = taskDetail.value?.id || taskDetail.value?.task_id
if (!taskId || !hasAuth(BULK_PURCHASE_PERMISSIONS.items)) return
itemsLoading.value = true
try {
const res = await BulkPurchaseService.getItems(taskId, {
page: itemsPage.value,
size: itemsSize.value,
status: itemStatus.value
})
if (res.code === 0 && res.data) {
items.value = res.data.items || []
itemsTotal.value = res.data.total || 0
} else {
ElMessage.error(res.msg || '获取批量订购明细失败')
}
} catch (error: any) {
ElMessage.error(error?.message || '获取批量订购明细失败')
} finally {
itemsLoading.value = false
}
items.value = taskDetail.value?.items || []
}
const reloadItems = () => {
@@ -441,7 +393,6 @@
})
onMounted(() => {
if (hasAuth(BULK_PURCHASE_PERMISSIONS.create)) void searchShops('')
const routeTaskId = Number(route.query.task_id)
if (routeTaskId && routeTaskId !== polling.taskId.value) void polling.start(routeTaskId)
})

View File

@@ -104,6 +104,22 @@
return type === 'single_card' ? '单卡购买' : '设备购买'
}
const getAssetTypeText = (type?: string): string => {
if (type === 'card') return 'IoT卡'
if (type === 'device') return '设备'
return '-'
}
const getPaymentMethodText = (method?: string): string => {
const methodMap: Record<string, string> = {
wallet: '钱包',
wechat: '微信',
alipay: '支付宝',
offline: '线下'
}
return method ? methodMap[method] || method : '-'
}
// 获取买家类型文本
const getBuyerTypeText = (type: string): string => {
return type === 'personal' ? '个人客户' : '代理商'
@@ -174,10 +190,19 @@
label: '订单类型',
formatter: (_: any, data: any) => getOrderTypeText(data.order_type)
},
{
label: '资产类型',
formatter: (_, data) => getAssetTypeText(data.asset_type)
},
{
label: '支付状态',
formatter: (_: any, data: any) => data.payment_status_text || '-'
},
{
label: '支付方式',
prop: 'payment_method',
formatter: (value) => getPaymentMethodText(value)
},
{
label: '订单金额',
prop: 'total_amount',
@@ -253,7 +278,12 @@
formatter: (_, data) => (data.is_purchased_by_parent ? '是' : '否')
},
{
label: 'ICCID/VirtualNo',
label: '是否代购',
formatter: (_, data) =>
data.is_purchase_on_behalf === undefined ? '-' : data.is_purchase_on_behalf ? '是' : '否'
},
{
label: '资产标识符',
prop: 'asset_identifier',
formatter: (value) => value || '-'
},

View File

@@ -413,7 +413,7 @@
options: [
{ label: '钱包支付', value: 'wallet' },
{ label: '微信支付', value: 'wechat' },
// { label: '支付宝支付', value: 'alipay' },
{ label: '支付宝支付', value: 'alipay' },
{ label: '线下支付', value: 'offline' }
],
config: {
@@ -468,9 +468,9 @@
placeholder: '请选择订单渠道',
options: [
{ label: '自己购买', value: 'self_purchase' },
// { label: '上级代理购买', value: 'purchased_by_parent' },
{ label: '平台代购', value: 'purchased_by_platform' }
// { label: '给下级购买', value: 'purchase_for_subordinate' }
{ label: '上级代理购买', value: 'purchased_by_parent' },
{ label: '平台代购', value: 'purchased_by_platform' },
{ label: '给下级购买', value: 'purchase_for_subordinate' }
],
config: {
clearable: true
@@ -504,17 +504,22 @@
{ 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' },
{ label: '是否上级代购', prop: 'is_purchased_by_parent' },
{ label: '下单时间', prop: 'created_at' },
{ label: '操作者', prop: 'operator_name' },
{ label: '销售店铺', prop: 'seller_shop_name' },
{ label: '支付状态', prop: 'payment_status' },
{ label: '佣金流程状态', prop: 'commission_status_name' },
{ label: '佣金业务结果', prop: 'commission_result_name' },
{ label: '订单金额', prop: 'total_amount' }
{ label: '订单金额', prop: 'total_amount' },
{ label: '是否过期', prop: 'is_expired' },
{ label: '超时时间', prop: 'expires_at' }
]
// 只有非代理账号和非企业账号才能看到实付金额选项
@@ -854,6 +859,13 @@
)
}
},
{
prop: 'asset_type',
label: '资产类型',
width: 110,
formatter: (row: Order) =>
row.asset_type === 'card' ? 'IoT卡' : row.asset_type === 'device' ? '设备' : '-'
},
{
prop: 'buyer_type',
label: '买家类型',
@@ -876,6 +888,18 @@
)
}
},
{
prop: 'is_expired',
label: '是否过期',
width: 100,
formatter: (row: Order) => (row.is_expired === undefined ? '-' : row.is_expired ? '是' : '否')
},
{
prop: 'expires_at',
label: '超时时间',
width: 180,
formatter: (row: Order) => (row.expires_at ? formatDateTime(row.expires_at) : '-')
},
{
prop: 'asset_identifier',
label: '资产标识符',
@@ -909,6 +933,20 @@
showOverflowTooltip: true,
formatter: (row: Order) => row.purchase_remark || '-'
},
{
prop: 'is_purchase_on_behalf',
label: '是否代购',
width: 100,
formatter: (row: Order) =>
row.is_purchase_on_behalf === undefined ? '-' : row.is_purchase_on_behalf ? '是' : '否'
},
{
prop: 'is_purchased_by_parent',
label: '是否上级代购',
width: 110,
formatter: (row: Order) =>
row.is_purchased_by_parent === undefined ? '-' : row.is_purchased_by_parent ? '是' : '否'
},
{
prop: 'created_at',
label: '下单时间',

View File

@@ -34,7 +34,7 @@
import { PackageManageService } from '@/api/modules'
import type { PackageResponse } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { getStatusLabel, getShelfStatusText } from '@/config/constants'
import { getShelfStatusText } from '@/config/constants'
import { useUserStore } from '@/store/modules/user'
defineOptions({ name: 'PackageDetail' })
@@ -103,7 +103,16 @@
{
label: '价格配置状态',
formatter: (_: unknown, data: PackageResponse) => {
return data.price_config_status_name || '-'
const statusMap: Record<number, string> = {
0: '未配置',
1: '赠送 0 价',
2: '已配置非 0'
}
return (
data.price_config_status_name ||
statusMap[data.price_config_status ?? -1] ||
'-'
)
}
},
{
@@ -179,7 +188,9 @@
{
label: '状态',
formatter: (_: unknown, data: PackageResponse) => {
return getStatusLabel(data.status ?? 0)
if (data.status === 1) return '启用'
if (data.status === 0 || data.status === 2) return '禁用'
return '-'
}
},
{
@@ -224,6 +235,13 @@
}
return `${virtualDataMb} MB`
}
},
{
label: '虚流量比例',
formatter: (_: unknown, data: PackageResponse) =>
data.virtual_ratio === null || data.virtual_ratio === undefined
? '-'
: Number(data.virtual_ratio).toFixed(2)
}
])
]

View File

@@ -21,6 +21,9 @@
<ElButton type="primary" @click="showDialog('add')" v-permission="'package:add'"
>新增套餐</ElButton
>
<ElButton v-if="hasAuth('package:export')" @click="exportDialogVisible = true">
导出
</ElButton>
</template>
</ArtTableHeader>
@@ -45,6 +48,14 @@
</template>
</ArtTable>
<ExportTaskCreateDialog
v-model="exportDialogVisible"
scene="package"
:query="exportQuery"
confirm-permission="package:export"
title="导出套餐"
/>
<!-- 新增/编辑对话框 -->
<ElDialog
v-model="dialogVisible"
@@ -240,11 +251,12 @@
>
<div>
缩减比例{{ realDataGb }}×(1-{{ virtualRatioPercent }}%) =
{{ calculatedVirtualDataGb }}GB
{{ formatTwoDecimals(calculatedVirtualDataGb) }}GB
</div>
<div v-if="calculatedVirtualDataGb > 0">
增长比例{{ realDataGb }}GB/{{ calculatedVirtualDataGb }}GB =
{{ calculatedGrowthRatio * 100 }}%
增长比例{{ realDataGb }}GB/{{
formatTwoDecimals(calculatedVirtualDataGb)
}}GB = {{ formatTwoDecimals(calculatedGrowthRatio * 100) }}%
</div>
</div>
</ElFormItem>
@@ -407,10 +419,10 @@
import { useUserStore } from '@/store/modules/user'
import { formatDateTime } from '@/utils/business/format'
import { RoutesAlias } from '@/router/routesAlias'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import {
CommonStatus,
getStatusText,
frontendStatusToApi,
apiStatusToFrontend,
PACKAGE_TYPE_OPTIONS,
getPackageTypeLabel,
@@ -434,6 +446,7 @@
const shouldHideVirtualTrafficColumns = computed(() => [3, 4].includes(currentUserType.value))
const dialogVisible = ref(false)
const exportDialogVisible = ref(false)
const loading = ref(false)
const submitLoading = ref(false)
const seriesLoading = ref(false)
@@ -589,6 +602,7 @@
]),
{ label: '套餐周期类型', prop: 'calendar_type' },
{ label: '有效期', prop: 'duration_months' },
{ label: '套餐天数', prop: 'duration_days' },
{ label: '总流量', prop: 'real_data_mb' },
...(!shouldHideVirtualTrafficColumns.value
? [
@@ -607,8 +621,8 @@
formatter: (row: any) => {
const map: Record<string, string> = {
daily: '每日',
weekly: '每周',
monthly: '每月',
yearly: '每年',
none: '不重置'
}
return map[row.data_reset_cycle] || row.data_reset_cycle || '-'
@@ -632,8 +646,8 @@
formatter: (row: PackageResponse) => row.expiry_base_override_name || '跟随套餐默认'
},
{ label: '最终生效条件', prop: 'effective_expiry_base_name' },
...(canUpdatePackageShelfStatus ? [{ label: '上架状态', prop: 'shelf_status' }] : []),
...(canUpdatePackageStatus ? [{ label: '状态', prop: 'status' }] : []),
{ label: '上架状态', prop: 'shelf_status' },
{ label: '状态', prop: 'status' },
{ label: '创建时间', prop: 'created_at' },
{ label: '更新时间', prop: 'updated_at' }
]
@@ -749,6 +763,8 @@
return Number((realDataGb.value / calculatedVirtualDataGb.value).toFixed(2))
})
const formatTwoDecimals = (value: number) => value.toFixed(2)
// GB 转 MB 处理
const handleRealDataChange = (value: number | null | undefined) => {
if (value === null || value === undefined) {
@@ -902,7 +918,17 @@
}
}
]
: []),
: [
{
prop: 'shelf_status',
label: '上架状态',
width: 100,
formatter: (row: PackageResponse) =>
h(ElTag, { type: row.shelf_status === 1 ? 'success' : 'info', size: 'small' }, () =>
getShelfStatusText(row.shelf_status ?? 0)
)
}
]),
...(canUpdatePackageStatus
? [
{
@@ -924,7 +950,24 @@
}
}
]
: []),
: [
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: PackageResponse) => {
const frontendStatus = apiStatusToFrontend(row.status ?? 0)
return h(
ElTag,
{
type: frontendStatus === CommonStatus.ENABLED ? 'success' : 'danger',
size: 'small'
},
() => getStatusText(frontendStatus)
)
}
}
]),
{
prop: 'real_data_mb',
label: '总流量',
@@ -959,28 +1002,36 @@
prop: 'virtual_ratio',
label: '虚流量比例',
width: 120,
formatter: (row: PackageResponse) => {
// 如果启用虚流量且真流量大于0计算虚量百分比
const virtualData = row.virtual_data_mb ?? 0
const realData = row.real_data_mb ?? 0
if (row.enable_virtual_data && realData > 0 && virtualData > 0) {
// 虚量百分比 = (1 - 虚流量/真流量) * 100%
// 例如真流量100G虚流量70G则虚量百分比 = (1 - 70/100) * 100% = 30%
const ratio = (1 - virtualData / realData) * 100
return `${ratio.toFixed(2)}%`
}
// 否则返回 0%
return '0%'
}
formatter: (row: PackageResponse) =>
row.virtual_ratio === null || row.virtual_ratio === undefined
? '-'
: formatTwoDecimals(Number(row.virtual_ratio))
}
]
: []),
{
prop: 'enable_virtual_data',
label: '启用虚流量',
width: 110,
formatter: (row: PackageResponse) => (row.enable_virtual_data ? '是' : '否')
},
{
prop: 'duration_months',
label: '有效期',
width: 100,
formatter: (row: PackageResponse) => `${row.duration_months}`
},
{
prop: 'duration_days',
label: '套餐天数',
width: 100,
formatter: (row: PackageResponse) =>
row.calendar_type === 'by_day' &&
row.duration_days !== null &&
row.duration_days !== undefined
? `${row.duration_days}`
: '-'
},
{
prop: 'calendar_type',
label: '套餐周期类型',
@@ -1002,8 +1053,8 @@
formatter: (row: PackageResponse) => {
const map: Record<string, string> = {
daily: '每日',
weekly: '每周',
monthly: '每月',
yearly: '每年',
none: '不重置'
}
const dataResetCycle = row.data_reset_cycle
@@ -1025,11 +1076,33 @@
return map[expiryBase] || expiryBase
}
},
{
prop: 'default_expiry_base_name',
label: '套餐默认生效条件',
width: 150
},
{
prop: 'expiry_base_override_name',
label: '分配覆盖生效条件',
width: 150,
formatter: (row: PackageResponse) => row.expiry_base_override_name || '跟随套餐默认'
},
{
prop: 'effective_expiry_base_name',
label: '最终生效条件',
width: 150
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: PackageResponse) => formatDateTime(row.created_at)
},
{
prop: 'updated_at',
label: '更新时间',
width: 180,
formatter: (row: PackageResponse) => formatDateTime(row.updated_at)
}
])
@@ -1080,7 +1153,7 @@
// 监听流量重置周期变化
watch(
() => form.data_reset_cycle,
(cycle) => {
() => {
// 流量重置周期变化时不需要清空套餐天数套餐天数只受calendar_type控制
}
)
@@ -1097,7 +1170,7 @@
// 监听 is_gift 变化,自动设置 suggested_retail_price
const handleIsGiftChange = (isGift: string | number | boolean) => {
if (Boolean(isGift)) {
if (isGift) {
form.suggested_retail_price = 0
} else {
form.suggested_retail_price = undefined
@@ -1195,7 +1268,7 @@
series_id: searchForm.series_id || undefined,
package_type: searchForm.package_type || undefined,
shelf_status: searchForm.shelf_status || undefined,
status: searchForm.status || undefined
status: searchForm.status ?? undefined
}
const res = await PackageManageService.getPackages(params)
if (res.code === 0) {
@@ -1210,6 +1283,25 @@
}
}
// 导出查询参数:保留 status=0禁用等有效零值
const exportQuery = computed(() => {
const query: Record<string, unknown> = {
package_name: searchForm.package_name || undefined,
series_id: searchForm.series_id ?? undefined,
package_type: searchForm.package_type || undefined,
shelf_status: searchForm.shelf_status ?? undefined,
status: searchForm.status ?? undefined
}
Object.keys(query).forEach((key) => {
if (query[key] === undefined || query[key] === null || query[key] === '') {
delete query[key]
}
})
return query
})
// 重置搜索
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
@@ -1387,7 +1479,6 @@
const costPriceInCents = Math.round(form.cost_price * 100)
const data: any = {
package_code: form.package_code,
package_name: form.package_name,
package_type: form.package_type,
duration_months: form.duration_months,
@@ -1395,8 +1486,12 @@
is_gift: form.is_gift
}
if (dialogType.value === 'add') {
data.package_code = form.package_code
}
// 可选字段
if (form.series_id !== undefined && form.series_id !== null) {
if (form.series_id !== undefined) {
data.series_id = form.series_id
}
if (form.calendar_type) {
@@ -1458,7 +1553,7 @@
// 状态切换
const handleStatusChange = async (row: PackageResponse, newFrontendStatus: number) => {
const oldStatus = row.status
const newApiStatus = frontendStatusToApi(newFrontendStatus)
const newApiStatus = newFrontendStatus
row.status = newApiStatus
try {
await PackageManageService.updatePackageStatus(row.id, newApiStatus)

View File

@@ -398,7 +398,6 @@
getStatusText,
frontendStatusToApi,
apiStatusToFrontend,
STATUS_SELECT_OPTIONS,
ENABLE_STATUS_OPTIONS,
getEnableStatusText
} from '@/config/constants'
@@ -406,6 +405,11 @@
defineOptions({ name: 'PackageSeries' })
const PACKAGE_SERIES_STATUS_SELECT_OPTIONS = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 2 }
]
const { hasAuth } = useAuth()
const canUpdatePackageSeriesStatus = hasAuth('package_series:update_status')
const router = useRouter()
@@ -455,7 +459,7 @@
clearable: true,
placeholder: '请选择状态'
},
options: STATUS_SELECT_OPTIONS
options: PACKAGE_SERIES_STATUS_SELECT_OPTIONS
}
]
@@ -788,7 +792,7 @@
page: pagination.page,
page_size: pagination.page_size,
series_name: searchForm.series_name || undefined,
status: searchForm.status || undefined,
status: searchForm.status ?? undefined,
enable_one_time_commission: searchForm.enable_one_time_commission ?? undefined
}
const res = await PackageSeriesService.getPackageSeries(params)

View File

@@ -348,7 +348,8 @@
GrantPackageItem,
GrantPackageInfo,
PackageAllocationExpiryBaseOverride,
PackageSeriesResponse
PackageSeriesResponse,
PackageResponse
} from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { getEnableStatusText } from '@/config/constants'
@@ -550,23 +551,31 @@
packageLoading.value = true
try {
const params: any = {
page: 1,
page_size: 50,
series_id: detailData.value.series_id
}
const pageSize = 100
const allPackages: PackageResponse[] = []
let page = 1
let total = 0
if (packageName) {
params.package_name = packageName
}
do {
const params: any = {
page,
page_size: pageSize,
series_id: detailData.value.series_id
}
if (packageName) params.package_name = packageName
const res = await PackageManageService.getPackages(params)
if (res.code === 0) {
availablePackages.value = mergeGrantPackageCandidates(
res.data.items,
detailData.value.packages || []
)
}
const res = await PackageManageService.getPackages(params)
if (res.code !== 0) break
allPackages.push(...(res.data.items || []))
total = res.data.total || allPackages.length
page += 1
} while (allPackages.length < total)
availablePackages.value = mergeGrantPackageCandidates(
allPackages,
detailData.value.packages || []
)
} catch (error) {
console.error('加载套餐选项失败:', error)
} finally {

View File

@@ -594,12 +594,16 @@
getStatusText,
frontendStatusToApi,
apiStatusToFrontend,
STATUS_SELECT_OPTIONS,
getEnableStatusText
} from '@/config/constants'
defineOptions({ name: 'SeriesGrants' })
const SERIES_GRANT_STATUS_SELECT_OPTIONS = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 2 }
]
const { hasAuth } = useAuth()
const canUpdateSeriesGrantStatus = hasAuth('series_grants:update_status')
const router = useRouter()
@@ -736,7 +740,7 @@
clearable: true,
placeholder: '请选择状态'
},
options: STATUS_SELECT_OPTIONS
options: SERIES_GRANT_STATUS_SELECT_OPTIONS
}
])
@@ -1074,25 +1078,26 @@
const loadPackageOptions = async (packageName?: string) => {
packageLoading.value = true
try {
const params: any = {
page: 1,
page_size: 20
}
const pageSize = 100
const allPackages: PackageResponse[] = []
let page = 1
let total = 0
// 如果已选择套餐系列则根据系列ID过滤套餐
if (form.series_id) {
params.series_id = form.series_id
}
do {
const params: any = { page, page_size: pageSize }
if (form.series_id) params.series_id = form.series_id
if (packageName) params.package_name = packageName
if (packageName) {
params.package_name = packageName
}
const res = await PackageManageService.getPackages(params)
if (res.code !== 0) break
const res = await PackageManageService.getPackages(params)
if (res.code === 0) {
// 过滤掉赠送套餐
packageOptions.value = res.data.items.filter((pkg) => !pkg.is_gift)
}
allPackages.push(...(res.data.items || []))
total = res.data.total || allPackages.length
page += 1
} while (allPackages.length < total)
// 过滤掉赠送套餐
packageOptions.value = allPackages.filter((pkg) => !pkg.is_gift)
} catch (error) {
console.error('加载套餐选项失败:', error)
} finally {
@@ -1125,7 +1130,7 @@
// 获取套餐名称
const getPackageName = (packageId: number) => {
const pkg = packageOptions.value.find((p) => p.id === packageId)
return pkg ? pkg.package_name : `套餐ID: ${packageId}`
return pkg ? pkg.package_name : '套餐名称不可用'
}
const getPackageCostPriceMax = (pkg?: {
@@ -1452,7 +1457,7 @@
series_id: searchForm.series_id || undefined,
allocator_shop_id:
searchForm.allocator_shop_id !== undefined ? searchForm.allocator_shop_id : undefined,
status: searchForm.status || undefined
status: searchForm.status ?? undefined
}
const res = await ShopSeriesGrantService.getShopSeriesGrants(params)
if (res.code === 0) {

View File

@@ -371,18 +371,20 @@
packageLoading.value = true
try {
const params: any = {
page: 1,
page_size: 50,
series_id: seriesId.value
}
if (packageName) {
params.package_name = packageName
}
const res = await PackageManageService.getPackages(params)
if (res.code === 0) {
availablePackages.value = mergeGrantPackageCandidates(res.data.items, packageList.value)
}
const pageSize = 100
const allPackages: any[] = []
let page = 1
let total = 0
do {
const params: any = { page, page_size: pageSize, series_id: seriesId.value }
if (packageName) params.package_name = packageName
const res = await PackageManageService.getPackages(params)
if (res.code !== 0) break
allPackages.push(...(res.data.items || []))
total = res.data.total || allPackages.length
page += 1
} while (allPackages.length < total)
availablePackages.value = mergeGrantPackageCandidates(allPackages, packageList.value)
} catch (error) {
console.error('加载套餐选项失败:', error)
} finally {

View File

@@ -51,7 +51,12 @@
<span>{{ currentConfig?.description || '-' }}</span>
</ElFormItem>
<ElFormItem label="配置值">
<ElSwitch v-if="isBooleanConfig" v-model="editBoolean" />
<ElCheckboxGroup v-if="isPaymentConfig" v-model="editPaymentMethods">
<ElCheckbox v-for="method in paymentMethods" :key="method" :label="method">
{{ method }}
</ElCheckbox>
</ElCheckboxGroup>
<ElSwitch v-else-if="isBooleanConfig" v-model="editBoolean" />
<ElInputNumber
v-else-if="isIntegerConfig"
v-model="editNumber"
@@ -101,11 +106,20 @@
import { ElMessage, ElTag } from 'element-plus'
import { SystemConfigService } from '@/api/modules'
import type { SearchFormItem } from '@/types'
import type { SystemConfigItem, SystemConfigModule } from '@/types/api/systemConfig'
import {
PAYMENT_CONFIG_KEYS,
parsePaymentMethods,
type AllowedPaymentMethod,
type SystemConfigItem,
type SystemConfigModule
} from '@/types/api/systemConfig'
import { formatDateTime } from '@/utils/business/format'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { JULY_PERMISSIONS } from '@/config/constants'
import { useAuth } from '@/composables/useAuth'
defineOptions({ name: 'SystemConfigs' })
const { hasAuth } = useAuth()
const moduleOptions = [
{ label: '运营商回调配置', value: 'carrier_callback' },
@@ -133,6 +147,8 @@
const originalValue = ref('')
const editBoolean = ref(false)
const editNumber = ref<number | null>(null)
const editPaymentMethods = ref<AllowedPaymentMethod[]>([])
const paymentMethods: AllowedPaymentMethod[] = ['wallet', 'wechat', 'alipay']
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const columnOptions = [
@@ -198,6 +214,11 @@
const isBooleanConfig = computed(
() => currentConfig.value?.value_type === 'bool' || currentConfig.value?.control === 'switch'
)
const isPaymentConfig = computed(() =>
[PAYMENT_CONFIG_KEYS.card, PAYMENT_CONFIG_KEYS.device].includes(
currentConfig.value?.config_key as (typeof PAYMENT_CONFIG_KEYS)[keyof typeof PAYMENT_CONFIG_KEYS]
)
)
const isIntegerConfig = computed(
() => !isBooleanConfig.value && currentConfig.value?.value_type === 'int'
)
@@ -222,6 +243,7 @@
})
const getSubmittedValue = () => {
if (isPaymentConfig.value) return JSON.stringify(editPaymentMethods.value)
if (isBooleanConfig.value) return String(editBoolean.value)
if (isIntegerConfig.value) return editNumber.value === null ? '' : String(editNumber.value)
return editValue.value
@@ -246,12 +268,17 @@
return '请输入有效的 JSON'
}
}
if (isPaymentConfig.value) {
if (editPaymentMethods.value.length === 0) return '至少保留一种支付方式'
}
if (value === '') return '配置值不能为空'
return ''
}
const getActions = (row: SystemConfigItem) =>
row.readonly
row.readonly ||
([PAYMENT_CONFIG_KEYS.card, PAYMENT_CONFIG_KEYS.device].includes(row.config_key as never) &&
!hasAuth(JULY_PERMISSIONS.systemConfig.payment))
? []
: [{ label: '编辑', handler: () => showEditDialog(row), type: 'primary' as const }]
@@ -280,6 +307,7 @@
editValue.value = row.value
editBoolean.value = row.value === 'true'
editNumber.value = /^-?\d+$/.test(row.value) ? Number(row.value) : null
editPaymentMethods.value = parsePaymentMethods(row.value)
dialogVisible.value = true
}

View File

@@ -0,0 +1,253 @@
<template>
<div class="wecom-application-detail-page">
<ElCard shadow="never">
<template #header>
<div class="page-header">
<div>
<div class="page-title">{{ isEdit ? '编辑企微应用' : '新增企微应用' }}</div>
<div class="page-description"
>应用凭据由平台管理员维护保存后可在应用列表测试连接</div
>
</div>
<ElButton @click="goBack">返回应用列表</ElButton>
</div>
</template>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-width="140px"
class="application-form"
>
<ElRow :gutter="24">
<ElCol :xs="24" :sm="12">
<ElFormItem label="企业 ID" prop="corp_id">
<ElInput v-model="form.corp_id" placeholder="请输入企业微信 CorpID" />
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="AgentID" prop="agent_id">
<ElInputNumber
v-model="form.agent_id"
:min="1"
controls-position="right"
class="full-width"
/>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="应用名称" prop="name">
<ElInput v-model="form.name" placeholder="请输入应用名称" />
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="Secret" prop="secret">
<ElInput
v-model="form.secret"
type="password"
show-password
placeholder="请输入应用 Secret"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElDivider content-position="left">回调配置</ElDivider>
<ElRow :gutter="24">
<ElCol :xs="24" :sm="12">
<ElFormItem label="回调 Token" prop="callback_token">
<ElInput v-model="form.callback_token" placeholder="请输入回调 Token" />
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="EncodingAESKey" prop="encoding_aes_key">
<ElInput v-model="form.encoding_aes_key" placeholder="请输入 43 位 EncodingAESKey" />
</ElFormItem>
</ElCol>
</ElRow>
<ElFormItem label="启用状态">
<ElSwitch v-model="form.enabled" active-text="启用" inactive-text="禁用" />
</ElFormItem>
<div class="form-actions">
<ElButton @click="goBack">取消</ElButton>
<ElButton
v-if="isEdit"
v-permission="JULY_PERMISSIONS.wecom.application"
:loading="testing"
@click="testApplication"
>
测试连接
</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.application"
type="primary"
:loading="saving"
@click="saveApplication"
>
保存应用
</ElButton>
</div>
</ElForm>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import { RoutesAlias } from '@/router/routesAlias'
import type { WecomApplication } from '@/types/api'
defineOptions({ name: 'WecomApplicationDetail' })
const route = useRoute()
const router = useRouter()
const formRef = ref<FormInstance>()
const saving = ref(false)
const testing = ref(false)
const applicationId = computed(() => Number(route.params.id) || 0)
const isEdit = computed(() => applicationId.value > 0)
const form = reactive({
corp_id: '',
agent_id: 0,
name: '',
secret: '',
callback_token: '',
encoding_aes_key: '',
enabled: true
})
const rules = reactive<FormRules>({
corp_id: [{ required: true, message: '请输入企业 ID', trigger: 'blur' }],
agent_id: [{ required: true, message: '请输入 AgentID', trigger: 'change' }],
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
secret: [{ required: true, message: '请输入 Secret', trigger: 'blur' }]
})
const fillForm = (application?: WecomApplication) => {
Object.assign(
form,
application
? {
corp_id: application.corp_id,
agent_id: application.agent_id,
name: application.name,
secret: application.secret || '',
callback_token: application.callback_token || '',
encoding_aes_key: application.encoding_aes_key || '',
enabled: application.status === 1
}
: {
corp_id: '',
agent_id: 0,
name: '',
secret: '',
callback_token: '',
encoding_aes_key: '',
enabled: true
}
)
}
const loadApplication = async () => {
if (!isEdit.value) {
fillForm()
return
}
const response = await WecomService.getApplications({ page: 1, page_size: 100 })
if (response.code === 0) {
const application = response.data.items?.find((item) => item.id === applicationId.value)
if (application) fillForm(application)
else {
ElMessage.error('未找到对应的企微应用')
goBack()
}
}
}
const saveApplication = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
const response = await WecomService.saveApplication({
corp_id: form.corp_id,
agent_id: form.agent_id,
name: form.name,
secret: form.secret,
callback_token: form.callback_token,
encoding_aes_key: form.encoding_aes_key,
status: form.enabled ? 1 : 0
})
if (response.code === 0) {
ElMessage.success('企微应用保存成功')
await router.push(RoutesAlias.WecomApplications)
}
} finally {
saving.value = false
}
}
const testApplication = async () => {
if (!applicationId.value) return
testing.value = true
try {
const response = await WecomService.testApplication(applicationId.value)
if (response.code === 0) {
if (response.data.success) ElMessage.success('连接成功,已取得 access_token')
else ElMessage.warning('连接失败,请检查应用凭据')
}
} finally {
testing.value = false
}
}
const goBack = () => void router.push(RoutesAlias.WecomApplications)
onMounted(() => void loadApplication())
</script>
<style scoped lang="scss">
.wecom-application-detail-page {
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.page-title {
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
}
.page-description {
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.application-form {
max-width: 1000px;
}
.full-width {
width: 100%;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 28px;
}
}
</style>

View File

@@ -0,0 +1,171 @@
<template>
<div class="wecom-applications-page">
<ElCard shadow="never">
<template #header>
<div class="page-header">
<div>
<div class="page-title">企微应用管理</div>
<div class="page-description">管理企业微信应用凭据并查看连接和默认发起人状态</div>
</div>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.application"
type="primary"
@click="goToCreate"
>
新增应用
</ElButton>
</div>
</template>
<ElTable v-loading="loading" :data="applications" border>
<ElTableColumn prop="name" label="应用名称" min-width="160" show-overflow-tooltip />
<ElTableColumn label="凭据状态" width="110">
<template #default="scope">
<ElTag :type="scope.row.credentials_set ? 'success' : 'warning'" size="small">
{{ scope.row.credentials_set ? '已完整' : '未完整' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="启用状态" width="100">
<template #default="scope">
<ElTag :type="scope.row.status === 1 ? 'success' : 'info'" size="small">
{{ scope.row.status_name }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="默认发起人" min-width="150">
<template #default="scope">{{ scope.row.default_creator_name || '未设置' }}</template>
</ElTableColumn>
<ElTableColumn label="最近连接" min-width="170">
<template #default="scope">{{ formatDate(scope.row.last_connected_at) }}</template>
</ElTableColumn>
<ElTableColumn label="操作" width="300" fixed="right">
<template #default="scope">
<ElButton
v-permission="JULY_PERMISSIONS.wecom.application"
link
type="primary"
@click="testApplication(scope.row.id)"
>
测试连接
</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.member"
link
type="primary"
@click="goToMembers(scope.row.id)"
>
成员管理
</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.application"
link
@click="goToDetail(scope.row.id)"
>
编辑配置
</ElButton>
</template>
</ElTableColumn>
</ElTable>
<div class="pagination-wrapper">
<ElPagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="pagination.total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="loadApplications"
/>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import { RoutesAlias } from '@/router/routesAlias'
import type { WecomApplication } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'WecomApplications' })
const router = useRouter()
const loading = ref(false)
const applications = ref<WecomApplication[]>([])
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const formatDate = (value?: string | null) => (value ? formatDateTime(value) : '-')
const loadApplications = async () => {
loading.value = true
try {
const response = await WecomService.getApplications({
page: pagination.page,
page_size: pagination.page_size
})
if (response.code === 0) {
applications.value = response.data.items || []
pagination.total = response.data.total || 0
}
} finally {
loading.value = false
}
}
const handleSizeChange = (size: number) => {
pagination.page_size = size
pagination.page = 1
void loadApplications()
}
const goToCreate = () => void router.push(`${RoutesAlias.WecomApplications}/create`)
const goToDetail = (id: number) => void router.push(`${RoutesAlias.WecomApplicationDetail}/${id}`)
const goToMembers = (id: number) =>
void router.push({ path: RoutesAlias.WecomMembers, query: { application_id: String(id) } })
const testApplication = async (id: number) => {
const response = await WecomService.testApplication(id)
if (response.code === 0) {
if (response.data.success) ElMessage.success('连接成功,已取得 access_token')
else ElMessage.warning('连接失败,请检查应用凭据')
await loadApplications()
}
}
onMounted(() => void loadApplications())
</script>
<style scoped lang="scss">
.wecom-applications-page {
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.page-title {
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
}
.page-description {
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}
</style>

View File

@@ -0,0 +1,424 @@
<template>
<div class="wecom-members-page">
<ElCard shadow="never">
<template #header>
<div class="page-header">
<div>
<div class="page-title">企微成员管理</div>
<div class="page-description"
>同步应用可见成员设置默认审批发起人并完成平台账号绑定</div
>
</div>
<ElButton @click="goToApplications">返回应用列表</ElButton>
</div>
</template>
<ElForm inline class="search-form" @submit.prevent>
<ElFormItem label="企微应用">
<ElSelect
v-model="selectedApplicationId"
filterable
class="application-select"
placeholder="请选择企微应用"
@change="handleApplicationChange"
>
<ElOption
v-for="application in applications"
:key="application.id"
:label="application.name"
:value="application.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="成员搜索">
<ElInput
v-model="keyword"
clearable
placeholder="姓名"
class="keyword-input"
@keyup.enter="handleSearch"
/>
</ElFormItem>
<ElFormItem>
<ElButton type="primary" @click="handleSearch">查询</ElButton>
<ElButton @click="handleReset">重置</ElButton>
</ElFormItem>
</ElForm>
<div class="toolbar">
<div class="selected-application">
当前应用<span>{{ selectedApplication?.name || '未选择' }}</span>
<ElTag v-if="selectedApplication?.default_creator_name" type="success" size="small">
默认发起人{{ selectedApplication.default_creator_name }}
</ElTag>
</div>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.member"
:disabled="!selectedApplicationId"
:loading="syncing"
@click="syncMembers"
>
同步成员
</ElButton>
</div>
<ElTable
v-loading="loading"
:data="members"
row-key="userid"
highlight-current-row
border
@row-click="selectMember"
>
<ElTableColumn label="选择" width="70" align="center">
<template #default="scope">
<ElRadio v-model="selectedUserid" :label="scope.row.userid">
<span class="sr-only">选择 {{ scope.row.name }}</span>
</ElRadio>
</template>
</ElTableColumn>
<ElTableColumn prop="name" label="成员姓名" min-width="150" />
<ElTableColumn prop="synced_at" label="同步时间" min-width="180" />
<ElTableColumn label="操作" width="230" fixed="right">
<template #default="scope">
<ElButton
v-permission="JULY_PERMISSIONS.wecom.member"
link
type="primary"
@click.stop="setDefaultCreator(scope.row)"
>
设为默认发起人
</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.binding"
link
@click.stop="selectMember(scope.row)"
>
绑定账号
</ElButton>
</template>
</ElTableColumn>
</ElTable>
<div class="pagination-wrapper">
<ElPagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="pagination.total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="loadMembers"
/>
</div>
</ElCard>
<ElCard shadow="never" class="operation-card">
<template #header>当前成员操作</template>
<ElAlert
v-if="selectedMember"
:title="`已选择:${selectedMember.name}`"
type="info"
:closable="false"
/>
<ElEmpty v-else description="请先在上方列表选择成员" :image-size="70" />
<div class="operation-row">
<span class="operation-label">绑定平台账号</span>
<ElInputNumber
v-model="bindingAccountId"
:min="1"
:disabled="!selectedMember"
controls-position="right"
placeholder="账号 ID"
/>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.binding"
type="primary"
:disabled="!selectedMember || !bindingAccountId"
:loading="binding"
@click="bindAccount"
>
绑定账号
</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.member"
:disabled="!selectedMember"
:loading="savingDefault"
@click="saveSelectedDefaultCreator"
>
保存为默认发起人
</ElButton>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { AccountService, WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import { RoutesAlias } from '@/router/routesAlias'
import type { WecomApplication, WecomMember } from '@/types/api'
defineOptions({ name: 'WecomMembers' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const syncing = ref(false)
const savingDefault = ref(false)
const binding = ref(false)
const applications = ref<WecomApplication[]>([])
const members = ref<WecomMember[]>([])
const selectedApplicationId = ref<number>()
const selectedUserid = ref('')
const bindingAccountId = ref<number>()
const keyword = ref('')
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const selectedApplication = computed(() =>
applications.value.find((application) => application.id === selectedApplicationId.value)
)
const selectedMember = computed(() =>
members.value.find((member) => member.userid === selectedUserid.value)
)
const loadApplications = async () => {
const response = await WecomService.getApplications({ page: 1, page_size: 100 })
if (response.code !== 0) return
applications.value = response.data.items || []
const queryApplicationId = Number(route.query.application_id)
selectedApplicationId.value =
applications.value.find((application) => application.id === queryApplicationId)?.id ||
applications.value[0]?.id
}
const loadMembers = async () => {
if (!selectedApplicationId.value) {
members.value = []
pagination.total = 0
return
}
loading.value = true
try {
const response = await WecomService.getMembers(selectedApplicationId.value, {
page: pagination.page,
page_size: pagination.page_size,
keyword: keyword.value.trim() || undefined
})
if (response.code === 0) {
members.value = response.data.items || []
pagination.total = response.data.total || 0
if (!members.value.some((member) => member.userid === selectedUserid.value)) {
selectedUserid.value = ''
}
}
} finally {
loading.value = false
}
}
const handleApplicationChange = () => {
pagination.page = 1
selectedUserid.value = ''
void loadMembers()
}
const handleSearch = () => {
pagination.page = 1
void loadMembers()
}
const handleReset = () => {
keyword.value = ''
pagination.page = 1
void loadMembers()
}
const handleSizeChange = (size: number) => {
pagination.page_size = size
pagination.page = 1
void loadMembers()
}
const selectMember = (member: WecomMember) => {
selectedUserid.value = member.userid
}
const syncMembers = async () => {
if (!selectedApplicationId.value) return
syncing.value = true
try {
const response = await WecomService.syncMembers(selectedApplicationId.value)
if (response.code === 0) {
ElMessage.success(`已同步 ${response.data.synced_count} 名成员`)
await loadMembers()
}
} finally {
syncing.value = false
}
}
const setDefaultCreator = async (member: WecomMember) => {
if (!selectedApplicationId.value) return
selectedUserid.value = member.userid
await saveSelectedDefaultCreator()
}
const saveSelectedDefaultCreator = async () => {
if (!selectedApplicationId.value || !selectedMember.value) {
ElMessage.warning('请选择要设置的成员')
return
}
savingDefault.value = true
try {
const response = await WecomService.setDefaultCreator(
selectedApplicationId.value,
selectedMember.value.userid
)
if (response.code === 0) {
ElMessage.success('默认发起人已保存')
const application = selectedApplication.value
if (application) {
application.default_creator_userid = selectedMember.value.userid
application.default_creator_name = selectedMember.value.name
}
}
} finally {
savingDefault.value = false
}
}
const bindAccount = async () => {
if (!selectedApplicationId.value || !selectedMember.value || !bindingAccountId.value) {
ElMessage.warning('请选择成员并输入账号 ID')
return
}
binding.value = true
try {
const response = await AccountService.bindWecom(bindingAccountId.value, {
application_id: selectedApplicationId.value,
userid: selectedMember.value.userid
})
if (response.code === 0) ElMessage.success('账号企微绑定成功')
} finally {
binding.value = false
}
}
const goToApplications = () => void router.push(RoutesAlias.WecomApplications)
onMounted(async () => {
await loadApplications()
await loadMembers()
})
</script>
<style scoped lang="scss">
.wecom-members-page {
.page-header,
.toolbar,
.operation-row {
display: flex;
align-items: center;
}
.page-header,
.toolbar {
justify-content: space-between;
gap: 16px;
}
.page-title {
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
}
.page-description {
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.search-form {
margin-top: 4px;
padding: 14px 16px 0;
background: var(--el-fill-color-light);
border-radius: 4px;
}
.application-select {
width: 280px;
}
.keyword-input {
width: 220px;
}
.toolbar {
margin: 20px 0 12px;
.selected-application {
color: var(--el-text-color-secondary);
span {
color: var(--el-text-color-primary);
font-weight: 600;
}
.el-tag {
margin-left: 10px;
}
}
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.operation-card {
margin-top: 16px;
}
.operation-row {
flex-wrap: wrap;
gap: 12px;
margin-top: 18px;
}
.operation-label {
color: var(--el-text-color-primary);
font-weight: 500;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 768px) {
.page-header,
.toolbar {
align-items: flex-start;
flex-direction: column;
}
.application-select,
.keyword-input {
width: 100%;
}
}
}
</style>

View File

@@ -0,0 +1,375 @@
<template>
<div class="wecom-scenes-page">
<ElCard shadow="never">
<template #header>
<div class="page-header">
<div>
<div class="page-title">企微审批场景</div>
<div class="page-description"
>分别维护退款和线下代充值审批模板并提交后由后端校验控件映射</div
>
</div>
<ElButton v-permission="JULY_PERMISSIONS.wecom.scene" type="primary" @click="createScene">
新增场景配置
</ElButton>
</div>
</template>
<div class="scene-layout">
<div class="scene-list">
<div class="section-heading">场景配置列表</div>
<ElTable
v-loading="loading"
:data="scenes"
row-key="business_type"
highlight-current-row
border
@row-click="selectScene"
>
<ElTableColumn prop="business_type_name" label="业务类型" min-width="150" />
<ElTableColumn
prop="template_name"
label="模板名称"
min-width="180"
show-overflow-tooltip
/>
<ElTableColumn label="状态" width="90">
<template #default="scope">
<ElTag :type="scope.row.status === 1 ? 'success' : 'info'" size="small">
{{ scope.row.status_name }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="last_verified_at" label="最近校验" min-width="170" />
<ElTableColumn label="操作" width="90" fixed="right">
<template #default="scope">
<ElButton
v-permission="JULY_PERMISSIONS.wecom.scene"
link
type="primary"
@click.stop="selectScene(scope.row)"
>
编辑
</ElButton>
</template>
</ElTableColumn>
</ElTable>
</div>
<ElCard shadow="never" class="editor-card">
<template #header>
<div class="editor-header">
<span>{{ editing ? '编辑审批场景' : '新增审批场景' }}</span>
<ElTag v-if="editing" size="small" type="info">{{ sceneForm.business_type }}</ElTag>
</div>
</template>
<ElForm :model="sceneForm" label-width="110px">
<ElFormItem label="业务类型" required>
<ElSelect v-model="sceneForm.business_type" :disabled="editing" class="full-width">
<ElOption label="退款审批" value="refund_approval" />
<ElOption label="线下代充值审批" value="offline_recharge_approval" />
</ElSelect>
</ElFormItem>
<ElFormItem label="企微应用">
<ElSelect v-model="sceneForm.application_id" clearable filterable class="full-width">
<ElOption
v-for="application in applications"
:key="application.id"
:label="application.name"
:value="application.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="模板 ID" required>
<ElInput v-model="sceneForm.template_id" placeholder="请输入企微后台模板 ID" />
</ElFormItem>
<ElFormItem label="启用状态">
<ElSwitch v-model="sceneForm.enabled" active-text="启用" inactive-text="禁用" />
</ElFormItem>
<ElDivider content-position="left">控件映射</ElDivider>
<div class="mapping-heading">
<span>业务字段与模板控件的对应关系</span>
<ElButton text type="primary" @click="addMapping()">新增映射</ElButton>
</div>
<div v-if="mappingRows.length" class="mapping-list">
<div v-for="(mapping, index) in mappingRows" :key="mapping.key" class="mapping-row">
<ElInput v-model="mapping.business_field" placeholder="业务字段,如 refund_no" />
<ElInput v-model="mapping.control_id" placeholder="控件 ID" />
<ElInput v-model="mapping.control_type" placeholder="控件类型,如 Text" />
<ElInput
v-model="mapping.option_mapping"
placeholder="选择项映射 JSON可为空对象"
/>
<ElButton text type="danger" @click="removeMapping(index)">删除</ElButton>
</div>
</div>
<ElEmpty v-else description="暂无控件映射,请新增一条" :image-size="60" />
<div class="editor-actions">
<ElButton @click="createScene">清空表单</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.scene"
type="primary"
:loading="saving"
@click="saveScene"
>
保存并校验
</ElButton>
</div>
</ElForm>
</ElCard>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import type {
WecomApplication,
WecomBusinessType,
WecomScene,
WecomSceneControlMapping
} from '@/types/api'
defineOptions({ name: 'WecomScenes' })
interface MappingEditor {
key: number
business_field: string
control_id: string
control_type: string
option_mapping: string
}
const loading = ref(false)
const saving = ref(false)
const applications = ref<WecomApplication[]>([])
const scenes = ref<WecomScene[]>([])
const selectedBusinessType = ref<WecomBusinessType>()
const nextMappingKey = ref(1)
const sceneForm = reactive({
business_type: 'refund_approval' as WecomBusinessType,
application_id: undefined as number | undefined,
template_id: '',
enabled: true
})
const mappingRows = ref<MappingEditor[]>([])
const editing = computed(() => Boolean(selectedBusinessType.value))
const addMapping = (mapping?: Partial<MappingEditor>) => {
mappingRows.value.push({
key: nextMappingKey.value++,
business_field: mapping?.business_field || '',
control_id: mapping?.control_id || '',
control_type: mapping?.control_type || '',
option_mapping: mapping?.option_mapping || '{}'
})
}
const removeMapping = (index: number) => mappingRows.value.splice(index, 1)
const fillEditor = (scene?: WecomScene) => {
if (!scene) {
selectedBusinessType.value = undefined
Object.assign(sceneForm, {
business_type: 'refund_approval' as WecomBusinessType,
application_id: applications.value[0]?.id,
template_id: '',
enabled: true
})
mappingRows.value = []
addMapping()
return
}
selectedBusinessType.value = scene.business_type
Object.assign(sceneForm, {
business_type: scene.business_type,
application_id: scene.application_id,
template_id: scene.template_id,
enabled: scene.status === 1
})
mappingRows.value = (scene.control_mapping || []).map((mapping) => ({
key: nextMappingKey.value++,
business_field: mapping.business_field,
control_id: mapping.control_id,
control_type: mapping.control_type,
option_mapping: JSON.stringify(mapping.option_mapping || {})
}))
}
const selectScene = (scene: WecomScene) => fillEditor(scene)
const createScene = () => fillEditor()
const loadData = async () => {
loading.value = true
try {
const [applicationResponse, sceneResponse] = await Promise.all([
WecomService.getApplications({ page: 1, page_size: 100 }),
WecomService.getScenes({ page: 1, page_size: 100 })
])
if (applicationResponse.code === 0) applications.value = applicationResponse.data.items || []
if (sceneResponse.code === 0) {
scenes.value = sceneResponse.data.items || []
const current = scenes.value.find(
(scene) => scene.business_type === selectedBusinessType.value
)
fillEditor(current || scenes.value[0])
}
} finally {
loading.value = false
}
}
const parseMapping = (): WecomSceneControlMapping[] | undefined => {
const result: WecomSceneControlMapping[] = []
for (const mapping of mappingRows.value) {
if (!mapping.business_field && !mapping.control_id && !mapping.control_type) continue
if (!mapping.business_field || !mapping.control_id || !mapping.control_type) {
ElMessage.warning('请完整填写控件映射字段')
return
}
let optionMapping: Record<string, string>
try {
const parsed = JSON.parse(mapping.option_mapping || '{}')
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object')
throw new Error('invalid')
optionMapping = parsed as Record<string, string>
} catch {
ElMessage.warning(`${result.length + 1} 条选择项映射不是有效 JSON`)
return
}
result.push({
business_field: mapping.business_field,
control_id: mapping.control_id,
control_type: mapping.control_type,
option_mapping: optionMapping
})
}
return result
}
const saveScene = async () => {
if (!sceneForm.template_id.trim()) {
ElMessage.warning('请输入模板 ID')
return
}
const controlMapping = parseMapping()
if (!controlMapping) return
saving.value = true
try {
const response = await WecomService.saveScene(sceneForm.business_type, {
application_id: sceneForm.application_id,
template_id: sceneForm.template_id.trim(),
control_mapping: controlMapping,
status: sceneForm.enabled ? 1 : 0
})
if (response.code === 0) {
ElMessage.success('场景保存并校验成功')
await loadData()
}
} finally {
saving.value = false
}
}
onMounted(() => void loadData())
</script>
<style scoped lang="scss">
.wecom-scenes-page {
.page-header,
.editor-header,
.mapping-heading,
.editor-actions {
display: flex;
align-items: center;
}
.page-header,
.editor-header,
.mapping-heading {
justify-content: space-between;
gap: 16px;
}
.page-title {
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
}
.page-description {
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.scene-layout {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(480px, 1fr);
gap: 16px;
}
.section-heading {
margin-bottom: 12px;
color: var(--el-text-color-primary);
font-weight: 600;
}
.editor-card {
min-width: 0;
}
.full-width {
width: 100%;
}
.mapping-heading {
margin-bottom: 12px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.mapping-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.mapping-row {
display: grid;
grid-template-columns: 1.05fr 1fr 0.8fr 1.3fr auto;
gap: 8px;
align-items: center;
}
.editor-actions {
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
@media (max-width: 1200px) {
.scene-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.page-header {
align-items: flex-start;
flex-direction: column;
}
.mapping-row {
grid-template-columns: 1fr;
}
}
}
</style>

View File

@@ -228,6 +228,13 @@
:inactive-value="CommonStatus.DISABLED"
/>
</ElFormItem>
<ElFormItem v-if="dialogType === 'edit'" label="C 端登录">
<ElSwitch
v-model="formData.client_login_disabled"
active-text="禁止新登录"
inactive-text="允许登录"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
@@ -322,9 +329,11 @@
import { ShopService, RoleService } from '@/api/modules'
import type { SearchFormItem } from '@/types'
import type {
CreateShopParams,
ShopBusinessOwnerCandidate,
ShopResponse,
ShopRoleResponse
ShopRoleResponse,
UpdateShopParams
} from '@/types/api'
import { RoleType, RoleStatus } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
@@ -609,7 +618,8 @@
formData.address = row.address || ''
formData.contact_name = row.contact_name || ''
formData.contact_phone = row.contact_phone || ''
formData.business_owner_account_id = row.business_owner_account_id ?? null
formData.business_owner_account_id = row.business_owner_account_id ?? null
formData.client_login_disabled = row.client_login_disabled
formData.status = row.status
formData.init_username = ''
formData.init_password = ''
@@ -627,7 +637,8 @@
formData.address = ''
formData.contact_name = ''
formData.contact_phone = ''
formData.business_owner_account_id = null
formData.business_owner_account_id = null
formData.client_login_disabled = false
formData.status = CommonStatus.ENABLED
formData.init_username = ''
formData.init_password = ''
@@ -729,13 +740,19 @@
label: '联系电话',
width: 130
},
{
prop: 'business_owner_username',
label: '平台业务员',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: ShopResponse) => formatBusinessOwner(row)
},
{
prop: 'business_owner_username',
label: '平台业务员',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: ShopResponse) => formatBusinessOwner(row)
},
{
prop: 'client_login_disabled',
label: 'C 端登录',
width: 110,
formatter: (row: ShopResponse) => (row.client_login_disabled ? '已限制' : '正常')
},
...(canModifyShopStatus
? [
{
@@ -825,7 +842,8 @@
init_password: '',
init_phone: '',
default_role_id: undefined as number | undefined,
business_owner_account_id: null as number | null
business_owner_account_id: null as number | null,
client_login_disabled: false
})
// 处理编码生成
@@ -987,14 +1005,15 @@
submitLoading.value = true
try {
if (dialogType.value === 'add') {
const data: any = {
const data: CreateShopParams = {
shop_name: formData.shop_name,
shop_code: formData.shop_code,
init_username: formData.init_username,
init_password: formData.init_password,
init_phone: formData.init_phone,
default_role_id: formData.default_role_id,
business_owner_account_id: formData.business_owner_account_id
default_role_id: formData.default_role_id!,
business_owner_account_id: formData.business_owner_account_id,
client_login_disabled: formData.client_login_disabled
}
// 可选字段 - parent_id 可能是数组(级联选择器)或数字
@@ -1013,10 +1032,11 @@
await ShopService.createShop(data)
ElMessage.success('新增成功')
} else {
const data: any = {
const data: UpdateShopParams = {
shop_name: formData.shop_name,
status: formData.status,
business_owner_account_id: formData.business_owner_account_id
business_owner_account_id: formData.business_owner_account_id,
client_login_disabled: formData.client_login_disabled
}
// 可选字段

View File

@@ -247,8 +247,8 @@
ElOption
} from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { RoleType } from '@/types/api'
import type { PlatformRole, PermissionTreeNode } from '@/types/api'
import { RoleStatus, RoleType } from '@/types/api'
import type { PlatformRole, PermissionTreeNode, PlatformRoleFormData } from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useRouter } from 'vue-router'
import { RoutesAlias } from '@/router/routesAlias'
@@ -381,12 +381,18 @@
]
})
const form = reactive<any>({
type RoleFormState = PlatformRoleFormData & {
id: number
credit_enabled: boolean
credit_limit_yuan: number
}
const form = reactive<RoleFormState>({
id: 0,
role_name: '',
role_desc: '',
role_type: 1,
status: CommonStatus.ENABLED,
role_type: RoleType.PLATFORM,
status: RoleStatus.ENABLED,
credit_enabled: false,
credit_limit_yuan: 0
})
@@ -1138,7 +1144,7 @@
form.role_name = ''
form.role_desc = ''
form.role_type = 1
form.status = CommonStatus.ENABLED
form.status = RoleStatus.ENABLED
form.credit_enabled = false
form.credit_limit_yuan = 0
}