fix: some

This commit is contained in:
luo
2026-09-17 12:16:20 +08:00
parent 2308d82d0f
commit 76d4e4ec02
126 changed files with 11671 additions and 294 deletions

View File

@@ -0,0 +1,74 @@
/**
* 代理扫码分销注册相关 API公开无需登录
*/
import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api'
/**
* 发送短信验证码请求参数
*/
export interface SendCodeParams {
phone: string // 手机号
scene: 'bind_phone' // 业务场景
}
/**
* 发送短信验证码响应
*/
export interface SendCodeResponse {
cooldown_seconds: number // 冷却秒数,用于倒计时
}
/**
* 代理扫码注册请求参数
*/
export interface AgentDistributionRegistrationParams {
distribution_code: string // 分销码
phone: string // 手机号
code: string // 短信验证码
password: string // 密码
shop_name: string // 店铺名称
shop_code: string // 店铺编号
username: string // 用户名
contact_name?: string // 联系人(选填)
province?: string // 省(选填)
city?: string // 市(选填)
district?: string // 区县(选填)
address?: string // 详细地址(选填)
}
/**
* 代理扫码注册响应
*/
export interface AgentDistributionRegistrationResponse {
id: number // 注册记录ID
status: number // 状态0 待审批)
status_name: string // 状态名称
}
export class AgentDistributionService extends BaseService {
/**
* 发送短信验证码
* POST /api/c/v1/auth/send-code公开不带 Token
*/
static sendCode(params: SendCodeParams): Promise<BaseResponse<SendCodeResponse>> {
return this.post<BaseResponse<SendCodeResponse>>('/api/c/v1/auth/send-code', params, {
requestOptions: { withToken: false }
})
}
/**
* 代理扫码注册
* POST /api/c/v1/agent-distribution-registrations公开不带 Token
*/
static registerAgent(
params: AgentDistributionRegistrationParams
): Promise<BaseResponse<AgentDistributionRegistrationResponse>> {
return this.post<BaseResponse<AgentDistributionRegistrationResponse>>(
'/api/c/v1/agent-distribution-registrations',
params,
{ requestOptions: { withToken: false } }
)
}
}

View File

@@ -0,0 +1,93 @@
/**
* 业务用户组相关 API - AUG26-003
*/
import { BaseService } from '../BaseService'
import type {
BusinessUserGroupItem,
BusinessUserGroupMembersResult,
BusinessUserGroupQueryParams,
CreateBusinessUserGroupParams,
DeleteBusinessUserGroupParams,
SetBusinessUserGroupMembersParams,
UpdateBusinessUserGroupParams,
BaseResponse,
PaginationResponse
} from '@/types/api'
export class BusinessUserGroupService extends BaseService {
/**
* 查询业务用户组列表
* GET /api/admin/business-user-groups
*/
static getGroups(
params?: BusinessUserGroupQueryParams
): Promise<PaginationResponse<BusinessUserGroupItem>> {
return this.getPage<BusinessUserGroupItem>('/api/admin/business-user-groups', params)
}
/**
* 查询业务用户组详情
* GET /api/admin/business-user-groups/{id}
*/
static getGroup(id: number): Promise<BaseResponse<BusinessUserGroupItem>> {
return this.getOne<BusinessUserGroupItem>(`/api/admin/business-user-groups/${id}`)
}
/**
* 创建业务用户组
* POST /api/admin/business-user-groups
*/
static createGroup(
data: CreateBusinessUserGroupParams
): Promise<BaseResponse<BusinessUserGroupItem>> {
return this.create<BusinessUserGroupItem>('/api/admin/business-user-groups', data)
}
/**
* 更新业务用户组
* PUT /api/admin/business-user-groups/{id}
*/
static updateGroup(
id: number,
data: UpdateBusinessUserGroupParams
): Promise<BaseResponse<BusinessUserGroupItem>> {
return this.update<BusinessUserGroupItem>(`/api/admin/business-user-groups/${id}`, data)
}
/**
* 删除业务用户组(必须携带 confirm body
* DELETE /api/admin/business-user-groups/{id}
*/
static deleteGroup(id: number, data: DeleteBusinessUserGroupParams): Promise<BaseResponse> {
return this.delete<BaseResponse>(`/api/admin/business-user-groups/${id}`, undefined, { data })
}
/**
* 批量设置平台用户业务用户组归属(整体替换)
* PUT /api/admin/business-user-groups/{id}/members
*/
static setGroupMembers(
id: number,
data: SetBusinessUserGroupMembersParams
): Promise<BaseResponse<BusinessUserGroupMembersResult>> {
return this.put<BaseResponse<BusinessUserGroupMembersResult>>(
`/api/admin/business-user-groups/${id}/members`,
data
)
}
/**
* 批量清空平台用户业务用户组归属DELETE 带 body
* DELETE /api/admin/business-user-groups/members
*/
static clearGroupMembers(
data: SetBusinessUserGroupMembersParams
): Promise<BaseResponse<BusinessUserGroupMembersResult>> {
return this.delete<BaseResponse<BusinessUserGroupMembersResult>>(
'/api/admin/business-user-groups/members',
undefined,
{ data }
)
}
}

View File

@@ -15,6 +15,8 @@ import type {
CommissionRecordQueryParams,
SubmitWithdrawalParams,
ShopCommissionRecordPageResult,
ShopCommissionRecordDetail,
ShopCommissionRecordSource,
ShopFundSummaryQueryParams,
ShopFundSummaryPageResult,
ShopCommissionStatsQueryParams,
@@ -23,10 +25,85 @@ import type {
DailyCommissionStatsItem,
ResolveCommissionParams,
MainWalletTransactionQueryParams,
MainWalletTransactionPageResult
MainWalletTransactionPageResult,
WithdrawalQualificationSubmitParams,
WithdrawalQualificationQueryParams,
WithdrawalQualificationPageResult,
VoidWithdrawalQualificationParams,
ResubmitWithdrawalRequestParams,
WithdrawalRequestDetail,
} from '@/types/api/commission'
export class CommissionService extends BaseService {
// ==================== 提现资料资格相关 ====================
/**
* 提交/替换提现资料资格
* POST /api/admin/shops/{shop_id}/withdrawal-qualifications
*/
static submitWithdrawalQualification(
shopId: number,
data: WithdrawalQualificationSubmitParams
): Promise<BaseResponse<{ id: number; status: number; status_name: string }>> {
return this.post<BaseResponse<{ id: number; status: number; status_name: string }>>(
`/api/admin/shops/${shopId}/withdrawal-qualifications`,
data
)
}
/**
* 查询提现资料资格
* GET /api/admin/shops/{shop_id}/withdrawal-qualifications
*/
static getWithdrawalQualifications(
shopId: number,
params?: WithdrawalQualificationQueryParams
): Promise<BaseResponse<WithdrawalQualificationPageResult>> {
return this.get<BaseResponse<WithdrawalQualificationPageResult>>(
`/api/admin/shops/${shopId}/withdrawal-qualifications`,
params
)
}
/**
* 作废提现资料资格(超管)
* POST /api/admin/withdrawal-qualifications/{id}/void
*/
static voidWithdrawalQualification(
id: number,
data: VoidWithdrawalQualificationParams
): Promise<BaseResponse> {
return this.post<BaseResponse>(`/api/admin/withdrawal-qualifications/${id}/void`, data)
}
/**
* 重提被驳回的提现
* PUT /api/admin/shops/{shop_id}/withdrawal-requests/{id}
*/
static resubmitWithdrawalRequest(
shopId: number,
id: number,
data: ResubmitWithdrawalRequestParams
): Promise<BaseResponse<WithdrawalRequestDetail>> {
return this.put<BaseResponse<WithdrawalRequestDetail>>(
`/api/admin/shops/${shopId}/withdrawal-requests/${id}`,
data
)
}
/**
* 提现申请详情
* GET /api/admin/shops/{shop_id}/withdrawal-requests/{id}
*/
static getWithdrawalRequestDetail(
shopId: number,
id: number
): Promise<BaseResponse<WithdrawalRequestDetail>> {
return this.get<BaseResponse<WithdrawalRequestDetail>>(
`/api/admin/shops/${shopId}/withdrawal-requests/${id}`
)
}
// ==================== 提现申请管理 ====================
/**
@@ -107,6 +184,22 @@ export class CommissionService extends BaseService {
)
}
/**
* 获取佣金明细详情
* GET /api/admin/shops/{shop_id}/commission-records/{id}
* source 省略时按原佣金处理;越权与不存在返回同一结果(佣金明细不存在)
*/
static getShopCommissionRecordDetail(
shopId: number,
id: number,
source?: ShopCommissionRecordSource
): Promise<BaseResponse<ShopCommissionRecordDetail>> {
return this.get<BaseResponse<ShopCommissionRecordDetail>>(
`/api/admin/shops/${shopId}/commission-records/${id}`,
source ? { source } : undefined
)
}
/**
* 获取代理商提现记录
* GET /api/admin/shops/{shop_id}/withdrawal-requests

View File

@@ -8,6 +8,7 @@ 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 type ExchangeMigrationStatus = 'not_migrated' | 'pending' | 'migrated' | 'failed'
// 换货单查询参数
export interface ExchangeQueryParams {
@@ -64,6 +65,9 @@ export interface ExchangeResponse {
current_approver_summary?: string | null // 当前审批人摘要
processing_status?: string | null // 业务处理状态
processing_status_name?: string | null // 业务处理状态名称
migration_status?: ExchangeMigrationStatus // 迁移状态not_migrated/pending/migrated/failed
migration_status_name?: string // 迁移状态中文名
migration_failure_reason?: string | null // 迁移失败原因(仅 migration_status=failed 时返回)
remark?: string
created_at: string
updated_at: string

View File

@@ -0,0 +1,87 @@
/**
* H5 运营弹窗配置相关 API
* 契约来源docs/产品迭代8月份/通知.md
*/
import { BaseService } from '../BaseService'
import type {
BaseResponse,
CreateH5PopupConfigurationRequest,
H5PopupConfiguration,
H5PopupConfigurationListResponse,
H5PopupConfigurationQueryParams,
UpdateH5PopupConfigurationRequest
} from '@/types/api'
/**
* H5 运营弹窗配置服务
* 仅超级管理员和平台账号可访问,代理/企业/个人由后端 403 兜底。
*/
export class H5PopupConfigurationService extends BaseService {
/**
* 查询运营弹窗配置列表(分页,按最近更新时间倒序)
* GET /api/admin/h5-popup-configurations
*/
static getConfigurations(
params?: H5PopupConfigurationQueryParams
): Promise<BaseResponse<H5PopupConfigurationListResponse>> {
return this.get<BaseResponse<H5PopupConfigurationListResponse>>(
'/api/admin/h5-popup-configurations',
params
)
}
/**
* 创建运营弹窗配置
* POST /api/admin/h5-popup-configurations
*/
static createConfiguration(
data: CreateH5PopupConfigurationRequest
): Promise<BaseResponse<H5PopupConfiguration>> {
return this.post<BaseResponse<H5PopupConfiguration>>('/api/admin/h5-popup-configurations', data)
}
/**
* 查询运营弹窗配置详情
* GET /api/admin/h5-popup-configurations/{id}
*/
static getConfiguration(id: number): Promise<BaseResponse<H5PopupConfiguration>> {
return this.getOne<H5PopupConfiguration>(`/api/admin/h5-popup-configurations/${id}`)
}
/**
* 更新运营弹窗配置(成功即版本 +1
* PUT /api/admin/h5-popup-configurations/{id}
*/
static updateConfiguration(
id: number,
data: UpdateH5PopupConfigurationRequest
): Promise<BaseResponse<H5PopupConfiguration>> {
return this.put<BaseResponse<H5PopupConfiguration>>(
`/api/admin/h5-popup-configurations/${id}`,
data
)
}
/**
* 启用运营弹窗配置(刷新最近更新时间,不递增版本)
* POST /api/admin/h5-popup-configurations/{id}/enable
*/
static enableConfiguration(id: number): Promise<BaseResponse<H5PopupConfiguration>> {
return this.post<BaseResponse<H5PopupConfiguration>>(
`/api/admin/h5-popup-configurations/${id}/enable`,
{ id }
)
}
/**
* 停用运营弹窗配置(刷新最近更新时间,不递增版本)
* POST /api/admin/h5-popup-configurations/{id}/disable
*/
static disableConfiguration(id: number): Promise<BaseResponse<H5PopupConfiguration>> {
return this.post<BaseResponse<H5PopupConfiguration>>(
`/api/admin/h5-popup-configurations/${id}/disable`,
{ id }
)
}
}

View File

@@ -1,4 +1,4 @@
/**
/**
* API 服务模块统一导出
*/
@@ -10,6 +10,7 @@ export { AccountService } from './account'
export { ShopService } from './shop'
export { CardService } from './card'
export { CommissionService } from './commission'
export { BusinessUserGroupService } from './businessUserGroup'
export { EnterpriseService } from './enterprise'
export { StorageService } from './storage'
export { AuthorizationService } from './authorization'
@@ -42,5 +43,13 @@ export { AuditService } from './audit'
export { WecomService } from './wecom'
export { EmployeeCollectionService } from './employeeCollection'
export { PhoneAssetAssociationService } from './phoneAsset'
export { AgentDistributionService } from './agentDistribution'
export { H5PopupConfigurationService } from './h5PopupConfiguration'
// TODO: 按需添加其他业务模块
// export { SettingService } from './setting'
export { PackageTrafficAlertService } from './packageTrafficAlert'

View File

@@ -0,0 +1,101 @@
/**
* 套餐真流量预警 API 服务
* 6 个接口仅超级管理员/平台账号可访问,其他账号由后端 403 兜底
* 对应 docs/产品迭代8月份/套餐真流量预警_API_前端简版.md
*/
import { BaseService } from '../BaseService'
import type {
BaseResponse,
PaginationResponse,
PackageTrafficAlertRuleItem,
PackageTrafficAlertRuleQueryParams,
CreatePackageTrafficAlertRuleParams,
UpdatePackageTrafficAlertRuleParams,
PackageTrafficAlertRecordItem,
PackageTrafficAlertRecordDetail,
PackageTrafficAlertRecordQueryParams,
ExportPackageTrafficAlertParams,
CreatePackageTrafficAlertExportResponse
} from '@/types/api'
export class PackageTrafficAlertService extends BaseService {
/**
* 查询套餐真流量预警规则列表
* GET /api/admin/package-traffic-alert-rules
* 返回套餐、商品当前真流量额度、阈值、启用状态、备注与更新时间
*/
static getAlertRules(
params?: PackageTrafficAlertRuleQueryParams
): Promise<PaginationResponse<PackageTrafficAlertRuleItem>> {
return this.getPage<PackageTrafficAlertRuleItem>(
'/api/admin/package-traffic-alert-rules',
params
)
}
/**
* 创建套餐真流量预警规则
* POST /api/admin/package-traffic-alert-rules
* 同一套餐商品最多一条真流量预警规则
*/
static createAlertRule(
data: CreatePackageTrafficAlertRuleParams
): Promise<BaseResponse<PackageTrafficAlertRuleItem>> {
return this.create<PackageTrafficAlertRuleItem>('/api/admin/package-traffic-alert-rules', data)
}
/**
* 修改套餐真流量预警规则
* PUT /api/admin/package-traffic-alert-rules/{id}
* 允许修改阈值、启停与备注;修改不影响既有预警快照,停用后扫描不再创建新预警
*/
static updateAlertRule(
id: number,
data: UpdatePackageTrafficAlertRuleParams
): Promise<BaseResponse<PackageTrafficAlertRuleItem>> {
return this.update<PackageTrafficAlertRuleItem>(
`/api/admin/package-traffic-alert-rules/${id}`,
data
)
}
/**
* 查询套餐真流量预警记录列表
* GET /api/admin/package-traffic-alerts
* 列表数据除 business_user_group_names 外均为触发时快照
*/
static getAlertRecords(
params?: PackageTrafficAlertRecordQueryParams
): Promise<PaginationResponse<PackageTrafficAlertRecordItem>> {
return this.getPage<PackageTrafficAlertRecordItem>('/api/admin/package-traffic-alerts', params)
}
/**
* 查询套餐真流量预警记录详情
* GET /api/admin/package-traffic-alerts/{id}
* 越权查询统一按资源不可见处理
*/
static getAlertRecordDetail(id: number): Promise<BaseResponse<PackageTrafficAlertRecordDetail>> {
// 越权查询详情统一按资源不可见处理,抑制全局报错提示,由页面给出统一文案
return this.getOne<PackageTrafficAlertRecordDetail>(
`/api/admin/package-traffic-alerts/${id}`,
undefined,
{ requestOptions: { errorMessageMode: 'none' } }
)
}
/**
* 导出套餐真流量达量预警
* POST /api/admin/package-traffic-alerts/export
* 只创建异步导出任务,后续通过既有导出任务列表下载文件
*/
static exportAlertRecords(
data: ExportPackageTrafficAlertParams
): Promise<BaseResponse<CreatePackageTrafficAlertExportResponse>> {
return this.post<BaseResponse<CreatePackageTrafficAlertExportResponse>>(
'/api/admin/package-traffic-alerts/export',
data
)
}
}

View File

@@ -0,0 +1,107 @@
/**
* 手机号资产关联相关 API
* 契约来源docs/产品迭代8月份/手机号资产关联.md
*/
import { BaseService } from '../BaseService'
import type {
BaseResponse,
PhoneAssetAssociationListResponse,
PhoneAssetAssociationQueryParams,
BatchUnbindPhoneAssetAssociationRequest,
BatchUnbindPhoneAssetAssociationResponse,
CreatePhoneAssetUnbindImportRequest,
PhoneAssetUnbindImportTask,
PhoneAssetUnbindImportTaskDetail,
PhoneAssetUnbindImportTaskListResponse,
PhoneAssetUnbindImportTaskQueryParams,
UnbindPhoneAssetAssociationResponse
} from '@/types/api'
/**
* 手机号资产关联服务
* 仅超级管理员和平台账号可访问,代理/企业/个人由后端 403 兜底。
*/
export class PhoneAssetAssociationService extends BaseService {
/**
* 查询手机号资产关联列表(分页)
* GET /api/admin/phone-asset-associations
*/
static getAssociations(
params?: PhoneAssetAssociationQueryParams
): Promise<BaseResponse<PhoneAssetAssociationListResponse>> {
return this.get<BaseResponse<PhoneAssetAssociationListResponse>>(
'/api/admin/phone-asset-associations',
params
)
}
/**
* 解除单条手机号资产关联
* DELETE /api/admin/phone-asset-associations/{id}
*
* 注意接口文档未定义请求体reason 与 confirmed 当前按查询参数传递;
* 联调时若后端以 JSON body 接收,需调整为请求体方式。
*/
static unbindAssociation(
id: number,
reason: string,
confirmed: boolean
): Promise<BaseResponse<UnbindPhoneAssetAssociationResponse>> {
return this.delete<BaseResponse<UnbindPhoneAssetAssociationResponse>>(
`/api/admin/phone-asset-associations/${id}`,
{ reason, confirmed }
)
}
/**
* 按资产批量解除手机号关联
* POST /api/admin/phone-asset-associations/batch-unbind
*/
static batchUnbind(
data: BatchUnbindPhoneAssetAssociationRequest
): Promise<BaseResponse<BatchUnbindPhoneAssetAssociationResponse>> {
return this.post<BaseResponse<BatchUnbindPhoneAssetAssociationResponse>>(
'/api/admin/phone-asset-associations/batch-unbind',
data
)
}
/**
* 创建 CSV 解绑导入任务
* POST /api/admin/phone-asset-associations/unbind-imports
*/
static createUnbindImport(
data: CreatePhoneAssetUnbindImportRequest
): Promise<BaseResponse<PhoneAssetUnbindImportTask>> {
return this.post<BaseResponse<PhoneAssetUnbindImportTask>>(
'/api/admin/phone-asset-associations/unbind-imports',
data
)
}
/**
* 查询解绑导入任务列表(分页)
* GET /api/admin/phone-asset-associations/unbind-imports
*/
static getUnbindImportTasks(
params?: PhoneAssetUnbindImportTaskQueryParams
): Promise<BaseResponse<PhoneAssetUnbindImportTaskListResponse>> {
return this.get<BaseResponse<PhoneAssetUnbindImportTaskListResponse>>(
'/api/admin/phone-asset-associations/unbind-imports',
params
)
}
/**
* 查询解绑导入任务详情
* GET /api/admin/phone-asset-associations/unbind-imports/{id}
*/
static getUnbindImportTaskDetail(
id: number
): Promise<BaseResponse<PhoneAssetUnbindImportTaskDetail>> {
return this.getOne<PhoneAssetUnbindImportTaskDetail>(
`/api/admin/phone-asset-associations/unbind-imports/${id}`
)
}
}

View File

@@ -9,6 +9,9 @@ import type {
RefundListResponse,
CreateRefundRequest,
ResubmitRefundRequest,
ApproveRefundRequest,
RejectRefundRequest,
ReturnRefundRequest,
BaseResponse
} from '@/types/api'
@@ -37,6 +40,33 @@ export class RefundService extends BaseService {
return this.post<BaseResponse<Refund>>('/api/admin/refunds', data)
}
/**
* 审批通过退款申请(仅存量无企微审批实例的申请可用)
* @param id 退款申请ID
* @param data 审批通过请求参数
*/
static approveRefund(id: number, data: ApproveRefundRequest): Promise<BaseResponse<Refund>> {
return this.post<BaseResponse<Refund>>(`/api/admin/refunds/${id}/approve`, data)
}
/**
* 审批拒绝退款申请(仅存量无企微审批实例的申请可用)
* @param id 退款申请ID
* @param data 拒绝请求参数
*/
static rejectRefund(id: number, data: RejectRefundRequest): Promise<BaseResponse<Refund>> {
return this.post<BaseResponse<Refund>>(`/api/admin/refunds/${id}/reject`, data)
}
/**
* 退回退款申请(仅存量无企微审批实例的申请可用)
* @param id 退款申请ID
* @param data 退回请求参数
*/
static returnRefund(id: number, data: ReturnRefundRequest): Promise<BaseResponse<Refund>> {
return this.post<BaseResponse<Refund>>(`/api/admin/refunds/${id}/return`, data)
}
/**
* 重新提交退款申请
* @param id 退款申请ID
@@ -53,4 +83,4 @@ export class RefundService extends BaseService {
static triggerApproval(id: number): Promise<BaseResponse<Refund>> {
return this.post<BaseResponse<Refund>>(`/api/admin/refunds/${id}/trigger-approval`)
}
}
}

View File

@@ -12,6 +12,11 @@ import type {
AssignShopRolesRequest,
UpdateShopCreditLimitRequest,
UpdateShopCreditLimitResponse,
BatchUpdateBusinessOwnerParams,
BatchUpdateBusinessOwnerResult,
CreateBusinessOwnerImportParams,
BusinessOwnerImportDetail,
BusinessOwnerImportQueryParams,
BaseResponse,
PaginationResponse,
ShopBusinessOwnerCandidate,
@@ -155,4 +160,56 @@ export class ShopService extends BaseService {
data
)
}
// ========== 批量交接平台业务员 ==========
/**
* 勾选批量交接平台业务员
* PUT /api/admin/shops/business-owner/batch
* @param data shop_ids1-500+ business_owner_account_idnull=清空)
*/
static batchUpdateBusinessOwner(
data: BatchUpdateBusinessOwnerParams
): Promise<BaseResponse<BatchUpdateBusinessOwnerResult>> {
return this.put<BaseResponse<BatchUpdateBusinessOwnerResult>>(
'/api/admin/shops/business-owner/batch',
data
)
}
// ========== 业务负责人 CSV 导入 ==========
/**
* 创建业务负责人导入任务
* POST /api/admin/shops/business-owner-imports
* @param data 上传后拿到的 file_key
*/
static createBusinessOwnerImport(
data: CreateBusinessOwnerImportParams
): Promise<BaseResponse<BusinessOwnerImportDetail>> {
return this.create<BusinessOwnerImportDetail>('/api/admin/shops/business-owner-imports', data)
}
/**
* 查询业务负责人导入任务详情
* GET /api/admin/shops/business-owner-imports/{id}
*/
static getBusinessOwnerImportDetail(
id: number
): Promise<BaseResponse<BusinessOwnerImportDetail>> {
return this.getOne<BusinessOwnerImportDetail>(`/api/admin/shops/business-owner-imports/${id}`)
}
/**
* 查询业务负责人导入任务列表
* GET /api/admin/shops/business-owner-imports
*/
static getBusinessOwnerImportList(
params?: BusinessOwnerImportQueryParams
): Promise<PaginationResponse<BusinessOwnerImportDetail>> {
return this.getPage<BusinessOwnerImportDetail>(
'/api/admin/shops/business-owner-imports',
params
)
}
}

View File

@@ -27,6 +27,8 @@ export type FilePurpose =
| 'iot_import'
| 'batch_purchase'
| 'device_batch_allocation'
| 'shop_import'
| 'phone_unbind_import'
| 'export'
| 'attachment'

View File

@@ -0,0 +1,269 @@
<template>
<ElDialog
:model-value="modelValue"
title="佣金明细详情"
width="780px"
@update:model-value="handleVisibleChange"
@closed="handleDialogClosed"
>
<div v-loading="loading" class="commission-record-detail">
<template v-if="record">
<ElDescriptions :column="2" border size="small">
<ElDescriptionsItem label="记录类型">
<ElTag :type="isClawback ? 'danger' : 'primary'" size="small">
{{ recordSourceName }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="statusTagType" size="small">
{{ record.status_name || statusName }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="佣金金额">
<span :class="{ 'amount-negative': isNegative(record.amount) }">
{{ formatMoney(record.amount) }}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="入账后佣金余额">
<span :class="{ 'amount-negative': isNegative(record.balance_after) }">
{{ formatMoney(record.balance_after) }}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="佣金来源">
<ElTag :type="sourceTagType" size="small">{{ sourceName }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="是否可提现">
<ElTag v-if="isClawback" type="danger" size="small">不可提现</ElTag>
<span v-else>可提现</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="订单号">{{ record.order_no || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="卖家店铺">
{{ record.seller_shop_name || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="ICCID">{{ record.iccid || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备虚拟号">
{{ record.virtual_no || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="记录生成时间">
{{ record.created_at ? formatDateTime(record.created_at) : '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="佣金入账时间">
{{ record.released_at ? formatDateTime(record.released_at) : '-' }}
</ElDescriptionsItem>
<template v-if="isClawback">
<ElDescriptionsItem label="被回溯原佣金ID">
{{ record.original_commission_id ?? '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="来源退款单号">
{{ record.refund_no || '-' }}
</ElDescriptionsItem>
</template>
</ElDescriptions>
<template v-if="!isClawback">
<div class="detail-section-title">
回溯明细
<span class="detail-section-summary">
累计回溯金额{{ formatMoney(clawbackTotalAmount) }}
</span>
</div>
<ElTable :data="clawbackRecords" size="small" border :max-height="260">
<ElTableColumn label="回溯金额" prop="amount" width="120">
<template #default="{ row }">
<span class="amount-negative">{{ formatMoney(row.amount) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="回溯后余额" prop="balance_after" width="130">
<template #default="{ row }">
<span :class="{ 'amount-negative': isNegative(row.balance_after) }">
{{ formatMoney(row.balance_after) }}
</span>
</template>
</ElTableColumn>
<ElTableColumn
label="来源退款单号"
prop="refund_no"
min-width="180"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.refund_no || '-' }}</template>
</ElTableColumn>
<ElTableColumn label="状态" prop="status_name" width="100">
<template #default="{ row }">
<ElTag type="danger" size="small">{{ row.status_name || '回溯' }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="生成时间" prop="created_at" width="180">
<template #default="{ row }">
{{ row.created_at ? formatDateTime(row.created_at) : '-' }}
</template>
</ElTableColumn>
</ElTable>
</template>
<template v-else-if="originalCommission">
<div class="detail-section-title">来源原佣金</div>
<ElDescriptions :column="2" border size="small">
<ElDescriptionsItem label="原佣金记录ID">
{{ originalCommission.id }}
</ElDescriptionsItem>
<ElDescriptionsItem label="原佣金金额">
{{ formatMoney(originalCommission.amount) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="原佣金余额">
{{ formatMoney(originalCommission.balance_after) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="原佣金状态">
{{ originalCommission.status_name || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="订单号">
{{ originalCommission.order_no || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="佣金入账时间">
{{
originalCommission.released_at
? formatDateTime(originalCommission.released_at)
: '-'
}}
</ElDescriptionsItem>
</ElDescriptions>
</template>
</template>
<ElEmpty v-else-if="!loading" description="佣金明细不存在" />
</div>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { CommissionService } from '@/api/modules'
import type { ShopCommissionRecordDetail, ShopCommissionRecordSource } from '@/types/api'
import {
COMMISSION_RECORD_SOURCE_MAP,
CommissionSourceMap,
CommissionStatusMap,
isCommissionClawbackRecord
} from '@/config/constants/commission'
import { formatDateTime, formatMoney } from '@/utils/business/format'
import { getErrorMessage } from '@/utils/business'
defineOptions({ name: 'CommissionRecordDetailDialog' })
const props = withDefaults(
defineProps<{
modelValue: boolean
shopId?: number | null
recordId?: number | null
source?: ShopCommissionRecordSource
}>(),
{
shopId: null,
recordId: null,
source: 'original'
}
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const loading = ref(false)
const detail = ref<ShopCommissionRecordDetail | null>(null)
const record = computed(() => detail.value?.record || null)
const isClawback = computed(() =>
isCommissionClawbackRecord({
source: detail.value?.source || props.source,
status: detail.value?.record?.status
})
)
const commissionStatusMeta = computed(
() =>
CommissionStatusMap[Number(record.value?.status) as keyof typeof CommissionStatusMap] || null
)
const commissionSourceMeta = computed(
() =>
CommissionSourceMap[
(record.value?.commission_source || '') as keyof typeof CommissionSourceMap
] || null
)
const recordSourceMeta = computed(
() => COMMISSION_RECORD_SOURCE_MAP[isClawback.value ? 'clawback' : 'original']
)
const recordSourceName = computed(() => recordSourceMeta.value.label)
const statusName = computed(() => commissionStatusMeta.value?.label || '-')
const statusTagType = computed(() => commissionStatusMeta.value?.type || 'info')
const sourceName = computed(
() => commissionSourceMeta.value?.label || record.value?.commission_source || '-'
)
const sourceTagType = computed(() => commissionSourceMeta.value?.type || 'info')
const clawbackRecords = computed(() => detail.value?.clawback_records ?? [])
const clawbackTotalAmount = computed(() => record.value?.clawback_total_amount ?? 0)
const originalCommission = computed(() => detail.value?.original_commission || null)
const isNegative = (value?: number | null) => typeof value === 'number' && value < 0
const loadDetail = async () => {
if (!props.shopId || !props.recordId) return
loading.value = true
detail.value = null
try {
const res = await CommissionService.getShopCommissionRecordDetail(
props.shopId,
props.recordId,
props.source
)
if (res.code === 0) {
detail.value = res.data
} else {
ElMessage.error(res.msg || '佣金明细不存在')
}
} catch (error) {
ElMessage.error(getErrorMessage(error, '佣金明细不存在'))
} finally {
loading.value = false
}
}
const handleVisibleChange = (value: boolean) => {
emit('update:modelValue', value)
}
const handleDialogClosed = () => {
detail.value = null
}
watch(
() => [props.modelValue, props.shopId, props.recordId, props.source] as const,
([visible]) => {
if (visible) loadDetail()
}
)
</script>
<style scoped lang="scss">
.commission-record-detail {
min-height: 120px;
}
.amount-negative {
color: var(--el-color-danger);
}
.detail-section-title {
display: flex;
align-items: center;
justify-content: space-between;
margin: 16px 0 8px;
font-size: 14px;
font-weight: 600;
}
.detail-section-summary {
font-size: 12px;
font-weight: 400;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<ElDialog v-model="dialogVisible" title="创建退款申请" width="40%" @closed="handleDialogClosed">
<ElForm ref="formRef" :model="formData" :rules="formRules" label-width="80px">
<ElDialog v-model="dialogVisible" title="创建退款申请" width="45%" @closed="handleDialogClosed">
<ElForm ref="formRef" :model="formData" :rules="formRules" label-width="120px">
<ElFormItem label="订单号" prop="order_id">
<ElSelect
v-if="!hasInitialOrder"
@@ -23,6 +23,13 @@
</ElSelect>
<ElInput v-else :model-value="props.initialOrderNo" disabled style="width: 100%" />
</ElFormItem>
<ElFormItem label="退款方式" prop="method">
<ElRadioGroup v-model="formData.method">
<ElRadio v-for="opt in RefundMethodOptions" :key="opt.value" :label="opt.value">
{{ opt.label }}
</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="退款金额" prop="requested_refund_amount">
<ElInputNumber
v-model="formData.requested_refund_amount"
@@ -46,7 +53,21 @@
placeholder="请输入退款原因"
/>
</ElFormItem>
<ElFormItem label="退款凭证" prop="refund_voucher_key">
<template v-if="formData.method === RefundMethod.CUSTOMER_ACCOUNT">
<ElFormItem label="收款账户名" prop="customer_account_name">
<ElInput v-model="formData.customer_account_name" placeholder="请输入收款账户名" maxlength="50" />
</ElFormItem>
<ElFormItem label="收款账号" prop="customer_account_number">
<ElInput v-model="formData.customer_account_number" placeholder="请输入收款账号" maxlength="64" />
</ElFormItem>
<ElFormItem label="开户银行">
<ElInput v-model="formData.customer_bank_name" placeholder="选填" maxlength="50" />
</ElFormItem>
</template>
<ElFormItem
:label="formData.method === RefundMethod.CUSTOMER_ACCOUNT ? '退款凭证(必填)' : '退款凭证'"
prop="refund_voucher_key"
>
<VoucherUpload
ref="uploadRef"
v-model="formData.refund_voucher_key"
@@ -54,6 +75,9 @@
@uploading-change="voucherUploading = $event"
@change="formRef?.validateField('refund_voucher_key')"
/>
<div v-if="formData.method === RefundMethod.CUSTOMER_ACCOUNT" class="voucher-tip">
客户收款信息方式下须同时提供客户收款信息与至少 1 个退款凭证
</div>
</ElFormItem>
</ElForm>
<template #footer>
@@ -77,7 +101,8 @@
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { RefundService, OrderService } from '@/api/modules'
import type { CreateRefundRequest, Order } from '@/types/api'
import { RefundMethod, type CreateRefundRequest, type Order } from '@/types/api'
import { RefundMethodOptions } from '@/config/constants/refund'
import { formatMoney, yuanToFen } from '@/utils/business/format'
import { getErrorMessage } from '@/utils/business'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
@@ -110,16 +135,24 @@
const formData = reactive<{
order_id: number | null
package_usage_id?: number
method?: RefundMethod
requested_refund_amount?: number
actual_received_amount: number
refund_reason: string
customer_account_name: string
customer_account_number: string
customer_bank_name: string
refund_voucher_key: string[]
}>({
order_id: null,
package_usage_id: undefined,
method: RefundMethod.ORIGINAL_ROUTE,
requested_refund_amount: undefined,
actual_received_amount: 0,
refund_reason: '',
customer_account_name: '',
customer_account_number: '',
customer_bank_name: '',
refund_voucher_key: []
})
@@ -134,14 +167,43 @@
}
}
const requireCustomerAccount = (field: string, message: string) => ({
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (formData.method === RefundMethod.CUSTOMER_ACCOUNT && !value?.trim()) {
callback(new Error(message))
} else {
callback()
}
},
trigger: 'blur'
})
const validateVoucherKey = (
_rule: unknown,
value: string[],
callback: (error?: Error) => void
) => {
if (
formData.method === RefundMethod.CUSTOMER_ACCOUNT &&
(!Array.isArray(value) || value.length === 0)
) {
callback(new Error('请上传至少 1 个退款凭证'))
} else {
callback()
}
}
const formRules = reactive<FormRules>({
order_id: [{ required: true, message: '请选择订单', trigger: 'change' }],
method: [{ required: true, message: '请选择退款方式', trigger: 'change' }],
requested_refund_amount: [
{ required: true, message: '请输入申请退款金额', trigger: 'blur' },
{ validator: validateRefundAmount, trigger: 'blur' }
],
refund_reason: [{ required: true, message: '请输入退款原因', trigger: 'blur' }],
refund_voucher_key: [{ required: true, message: '请上传退款凭证', trigger: 'change' }]
customer_account_name: requireCustomerAccount('customer_account_name', '请填写收款账户名'),
customer_account_number: requireCustomerAccount('customer_account_number', '请填写收款账号'),
refund_voucher_key: [{ validator: validateVoucherKey, trigger: 'change' }]
})
const hasInitialOrder = computed(() => !!props.initialOrderId || !!props.initialOrderNo)
@@ -202,9 +264,13 @@
voucherUploading.value = false
formData.order_id = null
formData.package_usage_id = undefined
formData.method = RefundMethod.ORIGINAL_ROUTE
formData.requested_refund_amount = undefined
formData.actual_received_amount = 0
formData.refund_reason = ''
formData.customer_account_name = ''
formData.customer_account_number = ''
formData.customer_bank_name = ''
formData.refund_voucher_key = []
orderSearchOptions.value = []
uploadRef.value?.clearFiles(false)
@@ -222,20 +288,30 @@
submitLoading.value = true
try {
const data: CreateRefundRequest = {
actual_received_amount: formData.actual_received_amount,
order_id: formData.order_id!,
method: formData.method!,
package_usage_id: formData.package_usage_id ?? 0,
refund_reason: formData.refund_reason,
refund_voucher_key: formData.refund_voucher_key,
requested_refund_amount: yuanToFen(formData.requested_refund_amount) ?? 0
}
if (formData.method === RefundMethod.CUSTOMER_ACCOUNT) {
data.customer_account_info = {
account_name: formData.customer_account_name.trim(),
account_number: formData.customer_account_number.trim(),
...(formData.customer_bank_name.trim()
? { bank_name: formData.customer_bank_name.trim() }
: {})
}
data.refund_voucher_key = formData.refund_voucher_key
} else if (formData.refund_voucher_key.length) {
data.refund_voucher_key = formData.refund_voucher_key
}
await RefundService.createRefund(data)
ElMessage.success('退款申请创建成功')
dialogVisible.value = false
emit('success')
} catch (error) {
console.error(error)
ElMessage.error(getErrorMessage(error, '退款申请创建失败'))
} finally {
submitLoading.value = false
}
@@ -257,3 +333,12 @@
}
})
</script>
<style scoped lang="scss">
.voucher-tip {
margin-top: 4px;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,201 @@
<template>
<ElDialog
:model-value="modelValue"
:title="dialogTitle"
width="480px"
@update:model-value="handleVisibleChange"
@closed="handleDialogClosed"
>
<template v-if="action === 'approve'">
<ElForm ref="approveFormRef" :model="approveForm" :rules="approveRules" label-width="120px">
<ElFormItem label="退款单号">
<span>{{ refund?.refund_no || '-' }}</span>
</ElFormItem>
<ElFormItem label="审批退款金额">
<ElInputNumber
v-model="approveForm.approved_refund_amount"
:min="0"
:precision="2"
:step="1"
style="width: 100%"
placeholder="不填则按申请金额审批"
/>
</ElFormItem>
<ElFormItem label="审批备注">
<ElInput
v-model="approveForm.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入审批备注(选填)"
/>
</ElFormItem>
</ElForm>
</template>
<template v-else-if="action === 'reject'">
<ElForm ref="rejectFormRef" :model="rejectForm" :rules="rejectRules" label-width="120px">
<ElFormItem label="退款单号">
<span>{{ refund?.refund_no || '-' }}</span>
</ElFormItem>
<ElFormItem label="拒绝原因" prop="reject_reason">
<ElInput
v-model="rejectForm.reject_reason"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
placeholder="请输入拒绝原因"
/>
</ElFormItem>
</ElForm>
</template>
<template v-else>
<ElForm ref="returnFormRef" :model="returnForm" :rules="returnRules" label-width="120px">
<ElFormItem label="退款单号">
<span>{{ refund?.refund_no || '-' }}</span>
</ElFormItem>
<ElFormItem label="退回备注">
<ElInput
v-model="returnForm.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入退回备注(选填)"
/>
</ElFormItem>
</ElForm>
</template>
<template #footer>
<div class="dialog-footer">
<ElButton @click="handleVisibleChange(false)">取消</ElButton>
<ElButton type="primary" :loading="submitLoading" @click="handleConfirm">
{{ confirmText }}
</ElButton>
</div>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { RefundService } from '@/api/modules'
import type { Refund } from '@/types/api'
import { yuanToFen } from '@/utils/business/format'
defineOptions({ name: 'RefundApprovalDialog' })
type ApprovalAction = 'approve' | 'reject' | 'return'
const props = defineProps<{
modelValue: boolean
refund: Refund | null
action: ApprovalAction
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
success: []
}>()
const submitLoading = ref(false)
const approveFormRef = ref<FormInstance>()
const rejectFormRef = ref<FormInstance>()
const returnFormRef = ref<FormInstance>()
const approveForm = reactive<{
approved_refund_amount?: number
remark: string
}>({
approved_refund_amount: undefined,
remark: ''
})
const rejectForm = reactive<{
reject_reason: string
}>({
reject_reason: ''
})
const returnForm = reactive<{
remark: string
}>({
remark: ''
})
const approveRules = reactive<FormRules>({})
const rejectRules = reactive<FormRules>({
reject_reason: [{ required: true, message: '请输入拒绝原因', trigger: 'blur' }]
})
const returnRules = reactive<FormRules>({})
const dialogTitle = computed(() => {
if (props.action === 'approve') return '审批通过退款申请'
if (props.action === 'reject') return '审批拒绝退款申请'
return '退回退款申请'
})
const confirmText = computed(() => {
if (props.action === 'approve') return '确认通过'
if (props.action === 'reject') return '确认拒绝'
return '确认退回'
})
const handleVisibleChange = (visible: boolean) => {
emit('update:modelValue', visible)
}
const handleDialogClosed = () => {
approveForm.approved_refund_amount = undefined
approveForm.remark = ''
rejectForm.reject_reason = ''
returnForm.remark = ''
submitLoading.value = false
}
const handleConfirm = async () => {
if (!props.refund || submitLoading.value) return
let valid = true
if (props.action === 'approve') {
valid = await approveFormRef.value?.validate().catch(() => false) !== false
} else if (props.action === 'reject') {
valid = await rejectFormRef.value?.validate().catch(() => false) !== false
} else {
valid = await returnFormRef.value?.validate().catch(() => false) !== false
}
if (!valid) return
submitLoading.value = true
try {
const refundId = props.refund.id
if (props.action === 'approve') {
await RefundService.approveRefund(refundId, {
approved_refund_amount: yuanToFen(approveForm.approved_refund_amount),
remark: approveForm.remark.trim() || undefined
})
ElMessage.success('审批通过成功')
} else if (props.action === 'reject') {
await RefundService.rejectRefund(refundId, {
reject_reason: rejectForm.reject_reason.trim()
})
ElMessage.success('审批拒绝成功')
} else {
await RefundService.returnRefund(refundId, {
remark: returnForm.remark.trim() || undefined
})
ElMessage.success('退回成功')
}
emit('update:modelValue', false)
emit('success')
} catch (error) {
console.error(error)
ElMessage.error('操作失败,请稍后重试')
} finally {
submitLoading.value = false
}
}
</script>

View File

@@ -71,7 +71,7 @@
<!-- 分页 -->
<div
v-if="pagination && tableData.length > 0"
v-if="pagination && (alwaysShowPagination || tableData.length > 0)"
class="table-pagination"
:class="paginationAlign"
>
@@ -113,7 +113,7 @@
/** 是否显示加载状态 */
loading?: boolean
/** 行数据的 Key用于标识每一行数据 */
rowKey?: string
rowKey?: string | ((row: any) => string)
/** 行的 className 的回调方法 */
rowClassName?: ((data: { row: any; rowIndex: number }) => string) | string
/** 是否显示边框 */
@@ -144,6 +144,8 @@
pageSizes?: number[]
/** 只有一页时是否隐藏分页器 */
hideOnSinglePage?: boolean
/** 数据为空时也始终显示分页器 */
alwaysShowPagination?: boolean
/** 分页器的对齐方式 */
paginationAlign?: 'left' | 'center' | 'right'
/** 分页器的大小 */
@@ -185,6 +187,7 @@
currentPage: 1,
pageSize: 20,
hideOnSinglePage: false,
alwaysShowPagination: false,
pageSizes: () => [10, 20, 30, 50],
paginationAlign: 'center',
paginationSize: 'default',

View File

@@ -11,10 +11,34 @@ export const AUGUST_PERMISSIONS = {
applicationCreate: 'employee_collection:application_create',
applicationDetail: 'employee_collection:application_detail',
applicationUpdate: 'employee_collection:application_update',
applicationViewVoucher: 'employee_collection:application_view_voucher',
paymentMethodPage: 'employee_collection:payment_method_view',
paymentMethodCreate: 'employee_collection:payment_method_create',
paymentMethodEdit: 'employee_collection:payment_method_edit',
paymentMethodDelete: 'employee_collection:payment_method_delete'
},
phoneAssetAssociation: {
list: 'phone_asset_association:list',
unbind: 'phone_asset_association:unbind',
unbindBatch: 'phone_asset_association:unbind_batch',
unbindImportPage: 'phone_asset_association:unbind_import_view',
unbindImportCreate: 'phone_asset_association:unbind_import_create',
unbindImportDetail: 'phone_asset_association:unbind_import_detail'
},
h5PopupConfiguration: {
list: 'h5_popup_configuration:list',
create: 'h5_popup_configuration:create',
update: 'h5_popup_configuration:update',
enable: 'h5_popup_configuration:enable',
disable: 'h5_popup_configuration:disable'
},
packageTrafficAlert: {
rulesView: 'package_traffic_alert:rules_view',
ruleCreate: 'package_traffic_alert:rule_create',
ruleUpdate: 'package_traffic_alert:rule_update',
recordsView: 'package_traffic_alert:records_view',
recordDetail: 'package_traffic_alert:record_detail',
export: 'package_traffic_alert:export'
}
} as const

View File

@@ -0,0 +1,48 @@
/**
* 业务用户组相关常量与权限编码
*/
import type { BusinessUserGroupBusinessLine } from '@/types/api'
/** 业务线选项standard 标品 / smart 智能产品 / other 其他 */
export const BUSINESS_USER_GROUP_BUSINESS_LINE_OPTIONS: Array<{
label: string
value: BusinessUserGroupBusinessLine
type?: 'primary' | 'success' | 'warning' | 'info' | 'danger'
}> = [
{ label: '标品', value: 'standard', type: 'primary' },
{ label: '智能产品', value: 'smart', type: 'success' },
{ label: '其他', value: 'other', type: 'warning' }
]
/** 业务线文案映射 */
export const BUSINESS_USER_GROUP_BUSINESS_LINE_MAP = BUSINESS_USER_GROUP_BUSINESS_LINE_OPTIONS.reduce(
(map, item) => {
map[item.value] = item
return map
},
{} as Record<BusinessUserGroupBusinessLine, (typeof BUSINESS_USER_GROUP_BUSINESS_LINE_OPTIONS)[number]>
)
/** 获取业务线标签 */
export function getBusinessUserGroupBusinessLineLabel(businessLine?: string | null): string {
if (!businessLine) return '-'
return BUSINESS_USER_GROUP_BUSINESS_LINE_MAP[businessLine as BusinessUserGroupBusinessLine]
?.label ?? businessLine
}
/** 业务用户组页面与按钮权限编码 */
export const BUSINESS_USER_GROUP_PERMISSIONS = {
page: 'business_user_group:page',
create: 'business_user_group:create',
update: 'business_user_group:update',
delete: 'business_user_group:delete',
members: 'business_user_group:members'
}
/** 店铺业务负责人运营权限编码 */
export const SHOP_BUSINESS_OWNER_PERMISSIONS = {
batch: 'shop:business_owner_batch',
importPage: 'shop:business_owner_import',
importCreate: 'shop:business_owner_import'
}

View File

@@ -10,6 +10,7 @@ export const COMMISSION_STATUS_OPTIONS = [
{ label: '解冻中', value: CommissionStatus.UNFREEZING, type: 'warning' as const },
{ label: '已发放', value: CommissionStatus.RELEASED, type: 'success' as const },
{ label: '已失效', value: CommissionStatus.INVALID, type: 'danger' as const },
{ label: '回溯', value: CommissionStatus.CLAWBACK, type: 'danger' as const },
{ label: '待人工修正', value: CommissionStatus.PENDING_CORRECTION, type: 'warning' as const }
]
@@ -46,6 +47,7 @@ export const CommissionStatusMap = {
2: { label: '解冻中', type: 'warning' as const, color: '#E6A23C' },
3: { label: '已发放', type: 'success' as const, color: '#67C23A' },
4: { label: '已失效', type: 'danger' as const, color: '#F56C6C' },
5: { label: '回溯', type: 'danger' as const, color: '#F56C6C' },
99: { label: '待人工修正', type: 'warning' as const, color: '#E6A23C' }
}
@@ -113,3 +115,33 @@ export function getCommissionTypeType(type: string) {
const config = CommissionTypeMap[type as keyof typeof CommissionTypeMap]
return config?.type || 'info'
}
// ========== 佣金记录(含回溯明细)映射 ==========
// 记录来源映射 (original:原佣金, clawback:回溯明细)
export const COMMISSION_RECORD_SOURCE_MAP = {
original: { label: '原佣金', type: 'primary' as const },
clawback: { label: '回溯明细', type: 'danger' as const }
}
// 佣金记录状态筛选选项(含回溯,不含待人工修正)
export const COMMISSION_RECORD_STATUS_OPTIONS = [
{ label: '已冻结', value: CommissionStatus.FROZEN },
{ label: '解冻中', value: CommissionStatus.UNFREEZING },
{ label: '已发放', value: CommissionStatus.RELEASED },
{ label: '已失效', value: CommissionStatus.INVALID },
{ label: '回溯', value: CommissionStatus.CLAWBACK }
]
// 是否回溯明细行source 与 status 双判断,兼容未返回 source 的响应)
export function isCommissionClawbackRecord(record: {
source?: string
status?: number
}): boolean {
return record.source === 'clawback' || record.status === CommissionStatus.CLAWBACK
}
// 列表行 key原佣金与回溯明细 ID 空间独立,同一页可能出现相同 id
export function getCommissionRecordRowKey(record: { id: number; source?: string }): string {
return `${record.source || 'original'}:${record.id}`
}

View File

@@ -84,6 +84,24 @@ export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneCo
detail: 'export_task:exchange_detail',
download: 'export_task:exchange_download'
}
},
commission_record: {
scene: 'commission_record',
sceneName: '佣金记录',
pageTitle: '导出佣金记录',
permissions: {
detail: 'export_task:commission_record_detail',
download: 'export_task:commission_record_download'
}
},
package_traffic_alert: {
scene: 'package_traffic_alert',
sceneName: '套餐真流量预警',
pageTitle: '导出套餐真流量预警',
permissions: {
detail: 'export_task:package_traffic_alert_detail',
download: 'export_task:package_traffic_alert_download'
}
}
}

View File

@@ -34,7 +34,12 @@ export * from './enableStatus'
// 导出任务相关
export * from './exportTask'
export * from './businessUserGroup'
// 套餐真流量预警相关
export * from './packageTrafficAlert'
// 批量订购相关
export * from './bulkPurchase'
export * from './julyIteration'
export * from './augustIteration'

View File

@@ -0,0 +1,60 @@
/**
* 套餐真流量预警相关常量
* 对应 docs/产品迭代8月份/套餐真流量预警_API_前端简版.md
*/
import {
PackageTrafficAlertNotificationStatus,
type PackageTrafficAlertAssetType
} from '@/types/api'
/** 通知投递结果选项1:已通知, 2:待投递, 3:投递失败, 4:未通知(接收人已失效), 5:未通知(无有效业务员)) */
export const TRAFFIC_ALERT_NOTIFICATION_STATUS_OPTIONS = [
{
label: '已通知',
value: PackageTrafficAlertNotificationStatus.NOTIFIED,
tagType: 'success' as const
},
{
label: '待投递',
value: PackageTrafficAlertNotificationStatus.PENDING_DELIVERY,
tagType: 'info' as const
},
{
label: '投递失败',
value: PackageTrafficAlertNotificationStatus.DELIVERY_FAILED,
tagType: 'danger' as const
},
{
label: '未通知(接收人已失效)',
value: PackageTrafficAlertNotificationStatus.NOT_NOTIFIED_RECEIVER_INVALID,
tagType: 'warning' as const
},
{
label: '未通知(无有效业务员)',
value: PackageTrafficAlertNotificationStatus.NOT_NOTIFIED_NO_VALID_OWNER,
tagType: 'warning' as const
}
]
export const getTrafficAlertNotificationStatusOption = (
status?: PackageTrafficAlertNotificationStatus | null
) => TRAFFIC_ALERT_NOTIFICATION_STATUS_OPTIONS.find((item) => item.value === status)
/** 资产类型选项iot_card:物联网卡, device:设备) */
export const TRAFFIC_ALERT_ASSET_TYPE_OPTIONS: Array<{
label: string
value: PackageTrafficAlertAssetType
}> = [
{ label: '物联网卡', value: 'iot_card' },
{ label: '设备', value: 'device' }
]
export const getTrafficAlertAssetTypeName = (assetType?: PackageTrafficAlertAssetType | null) =>
TRAFFIC_ALERT_ASSET_TYPE_OPTIONS.find((item) => item.value === assetType)?.label || '-'
/** 导出格式选项xlsx:Excel, csv:CSV */
export const TRAFFIC_ALERT_EXPORT_FORMAT_OPTIONS = [
{ label: 'XLSX', value: 'xlsx' as const },
{ label: 'CSV', value: 'csv' as const }
]

View File

@@ -0,0 +1,17 @@
/**
* 支付商户与商户池管理权限编码。
* 页面和按钮统一引用这里的常量,后端菜单权限可直接复用同名编码。
* 编码命名参考代理充值页 agent_recharge:<action> 风格。
*/
export const PAYMENT_MERCHANT_POOL_PERMISSIONS = {
merchantCreate: 'payment_merchant_pool:merchant_create',
merchantEdit: 'payment_merchant_pool:merchant_edit',
merchantToggle: 'payment_merchant_pool:merchant_toggle',
merchantDelete: 'payment_merchant_pool:merchant_delete',
merchantDetail: 'payment_merchant_pool:merchant_detail',
poolCreate: 'payment_merchant_pool:pool_create',
poolEdit: 'payment_merchant_pool:pool_edit',
poolToggle: 'payment_merchant_pool:pool_toggle',
poolDetail: 'payment_merchant_pool:pool_detail',
wechatAuthEdit: 'payment_merchant_pool:wechat_auth_edit'
} as const

View File

@@ -0,0 +1,89 @@
/**
* 退款管理展示与规则常量
*/
import {
RefundStatus,
RefundMethod,
ChannelRefundStatus,
RefundFailureReason
} from '@/types/api/refund'
/** 退款状态名称映射 */
export const RefundStatusNameMap: Record<RefundStatus, string> = {
[RefundStatus.PENDING]: '待审批',
[RefundStatus.APPROVED]: '已通过',
[RefundStatus.REJECTED]: '已拒绝',
[RefundStatus.RETURNED]: '已退回',
[RefundStatus.REFUNDING]: '原路退款处理中',
[RefundStatus.REFUND_FAILED]: '原路退款失败'
}
/** 退款状态标签类型映射 */
export const RefundStatusTypeMap: Record<
RefundStatus,
'warning' | 'success' | 'danger' | 'info'
> = {
[RefundStatus.PENDING]: 'warning',
[RefundStatus.APPROVED]: 'success',
[RefundStatus.REJECTED]: 'danger',
[RefundStatus.RETURNED]: 'info',
[RefundStatus.REFUNDING]: 'warning',
[RefundStatus.REFUND_FAILED]: 'danger'
}
/** 退款方式名称映射 */
export const RefundMethodNameMap: Record<RefundMethod, string> = {
[RefundMethod.ORIGINAL_ROUTE]: '原路退回',
[RefundMethod.CUSTOMER_ACCOUNT]: '客户收款信息',
[RefundMethod.ASSET_WALLET]: '资产钱包',
[RefundMethod.AGENT_WALLET]: '代理钱包'
}
/** 渠道退款状态名称映射 */
export const ChannelRefundStatusNameMap: Record<ChannelRefundStatus, string> = {
[ChannelRefundStatus.NOT_STARTED]: '未发起或不适用',
[ChannelRefundStatus.PROCESSING]: '处理中',
[ChannelRefundStatus.SUCCEEDED]: '已成功',
[ChannelRefundStatus.FAILED]: '已失败'
}
/** 失败分类名称映射 */
export const RefundFailureReasonNameMap: Record<RefundFailureReason, string> = {
[RefundFailureReason.CHANNEL_REJECTED]: '渠道明确拒绝',
[RefundFailureReason.CREDENTIAL_INVALID]: '渠道凭证失效',
[RefundFailureReason.INSUFFICIENT_BALANCE]: '渠道余额不足',
[RefundFailureReason.TIMEOUT_UNKNOWN]: '超时或结果未知',
[RefundFailureReason.APPROVAL_REJECTED]: '企微驳回或关闭',
[RefundFailureReason.REVOKED_AFTER_APPROVED]: '企微通过后撤销',
[RefundFailureReason.PAYMENT_FACT_INVALID]: '本地原支付事实不可用'
}
/** 退款方式选项(表单用) */
export const RefundMethodOptions = [
{ label: '原路退回', value: RefundMethod.ORIGINAL_ROUTE },
{ label: '客户收款信息', value: RefundMethod.CUSTOMER_ACCOUNT },
{ label: '资产钱包', value: RefundMethod.ASSET_WALLET },
{ label: '代理钱包', value: RefundMethod.AGENT_WALLET }
]
/**
* 是否可重提3 已拒绝 / 4 已退回 / 6 原路退款失败anomaly_flag=1 时禁止)
*/
export const canResubmitRefund = (item: {
status?: RefundStatus
anomaly_flag?: number
}): boolean => {
if (!item.status) return false
if (item.status === RefundStatus.REJECTED || item.status === RefundStatus.RETURNED) return true
if (item.status === RefundStatus.REFUND_FAILED) return item.anomaly_flag !== 1
return false
}
/**
* 审批操作是否可用:仅待审批且未关联企微审批实例(存量无实例)的申请
*/
export const canManualApproveRefund = (item: {
status?: RefundStatus
approval_instance_id?: number | null
}): boolean => item.status === RefundStatus.PENDING && !item.approval_instance_id

View File

@@ -413,7 +413,10 @@
"seriesAssignDetail": "Series Assignment Detail",
"packageSeries": "Package Series",
"packageSeriesDetail": "Package Series Detail",
"packageCommission": "Package Commission Cards"
"packageCommission": "Package Commission Cards",
"trafficAlertRules": "Traffic Alert Rules",
"trafficAlerts": "Traffic Alert Records",
"trafficAlertDetail": "Traffic Alert Record Detail"
},
"accountManagement": {
"title": "Account Management",
@@ -455,7 +458,9 @@
},
"shopManagement": {
"title": "Shop Management",
"shopList": "Shop List"
"shopList": "Shop List",
"businessUserGroups": "Business User Groups",
"businessOwnerImport": "Business Owner Import"
},
"assetManagement": {
"title": "Asset Management",
@@ -494,14 +499,18 @@
"exportRefund": "Export Refunds",
"exportAgentRecharge": "Export Agent Recharges",
"exportExchange": "Export Exchanges",
"exportCommissionRecord": "Export Commission Records",
"exportPackageTrafficAlert": "Export Package Traffic Alerts",
"exportTaskDetail": "Export Task Detail",
"exchangeManagement": "Exchange Management",
"exchangeDetail": "Exchange Order Detail"
"exchangeDetail": "Exchange Order Detail",
"phoneAssetAssociation": "Phone-Asset Associations",
"phoneAssetUnbindImportTasks": "Unbind Import Tasks",
"phoneAssetUnbindImportTaskDetail": "Unbind Import Task Detail"
},
"settings": {
"title": "Settings Management",
"paymentSettings": "Payment Settings",
"agentSelfRecharge": "Agent Self-Recharge Settings",
"detailsOfPaymentConfiguration": "Payment Configuration Details",
"paymentMerchant": "Payment Merchant",
"developerApi": "Developer API",
@@ -509,7 +518,10 @@
"paymentMerchantPools": "Merchant Pool Management",
"paymentMerchantPoolsTabMerchants": "Payment Merchants",
"paymentMerchantPoolsTabPools": "Merchant Pools",
"paymentMerchantPoolsTabWechatAuth": "WeChat Authorization"
"paymentMerchantPoolsTabWechatAuth": "WeChat Authorization",
"detailsOfPaymentMerchant": "Payment Merchant Details",
"detailsOfPaymentMerchantPool": "Merchant Pool Details",
"h5PopupConfiguration": "H5 Popup Configurations"
},
"batch": {
"title": "Batch Operations",

View File

@@ -380,7 +380,10 @@
"seriesGrantsDetail": "代理系列授权详情",
"seriesGrantPackages": "代理系列授权套餐列表",
"packageSeries": "套餐系列",
"packageSeriesDetail": "套餐系列详情"
"packageSeriesDetail": "套餐系列详情",
"trafficAlertRules": "真流量预警规则",
"trafficAlerts": "真流量达量预警",
"trafficAlertDetail": "真流量达量预警详情"
},
"accountManagement": {
"title": "账号管理",
@@ -391,7 +394,9 @@
},
"shopManagement": {
"title": "店铺管理",
"shopList": "店铺列表"
"shopList": "店铺列表",
"businessUserGroups": "业务用户组",
"businessOwnerImport": "业务负责人导入"
},
"assetManagement": {
"title": "资产管理",
@@ -423,9 +428,14 @@
"exportRefund": "导出退款",
"exportAgentRecharge": "导出代理充值",
"exportExchange": "导出换货",
"exportCommissionRecord": "导出佣金记录",
"exportPackageTrafficAlert": "导出套餐真流量预警",
"exportTaskDetail": "导出任务详情",
"exchangeManagement": "换货管理",
"exchangeDetail": "换货单详情"
"exchangeDetail": "换货单详情",
"phoneAssetAssociation": "手机号资产关联",
"phoneAssetUnbindImportTasks": "解绑导入任务",
"phoneAssetUnbindImportTaskDetail": "解绑导入任务详情"
},
"orderManagement": {
"title": "订单管理",
@@ -453,14 +463,16 @@
"settings": {
"title": "设置管理",
"paymentSettings": "支付设置",
"agentSelfRecharge": "代理自充设置",
"detailsOfPaymentConfiguration": "支付配置详情",
"withdrawalSettings": "提现配置",
"passwordSettings": "密码设置",
"paymentMerchantPools": "商户池管理",
"paymentMerchantPoolsTabMerchants": "支付商户",
"paymentMerchantPoolsTabPools": "商户池",
"paymentMerchantPoolsTabWechatAuth": "微信授权配置"
"paymentMerchantPoolsTabWechatAuth": "微信授权配置",
"detailsOfPaymentMerchant": "支付商户详情",
"detailsOfPaymentMerchantPool": "商户池详情",
"h5PopupConfiguration": "H5运营弹窗配置"
}
},
"table": {

View File

@@ -380,6 +380,7 @@ function convertBackendMenuToRoute(
path: menuUrl,
name: matchedRoute.name,
component: matchedRoute.component,
redirect: matchedRoute.redirect,
meta: {
...matchedRoute.meta,
title: menu.name,

View File

@@ -10,6 +10,7 @@ import { useUserStore } from '@/store/modules/user'
* 不需要登录的路由白名单
*/
export const LOGIN_WHITE_LIST = [
'/agent-registration',
'/auth/login',
'/exception/403',
'/exception/404',

View File

@@ -4,6 +4,11 @@ import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
import { JULY_PERMISSIONS } from '@/config/constants/julyIteration'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration'
import { PAYMENT_MERCHANT_POOL_PERMISSIONS } from '@/config/constants/paymentMerchantPools'
import {
BUSINESS_USER_GROUP_PERMISSIONS,
SHOP_BUSINESS_OWNER_PERMISSIONS
} from '@/config/constants'
/**
* 菜单列表、异步路由
@@ -206,6 +211,37 @@ export const asyncRoutes: AppRouteRecord[] = [
isHide: true,
keepAlive: false
}
},
// 真流量预警规则
{
path: 'traffic-alert-rules',
name: 'TrafficAlertRules',
component: RoutesAlias.TrafficAlertRules,
meta: {
title: 'menus.packageManagement.trafficAlertRules',
keepAlive: true
}
},
// 真流量达量预警记录
{
path: 'traffic-alerts',
name: 'TrafficAlerts',
component: RoutesAlias.TrafficAlerts,
meta: {
title: 'menus.packageManagement.trafficAlerts',
keepAlive: true
}
},
// 真流量达量预警记录详情
{
path: 'traffic-alerts/detail/:id',
name: 'TrafficAlertDetail',
component: RoutesAlias.TrafficAlertDetail,
meta: {
title: 'menus.packageManagement.trafficAlertDetail',
isHide: true,
keepAlive: false
}
}
]
},
@@ -240,6 +276,30 @@ export const asyncRoutes: AppRouteRecord[] = [
isHide: true,
keepAlive: false
}
},
// 业务用户组
{
path: 'business-user-groups',
name: 'BusinessUserGroups',
component: RoutesAlias.BusinessUserGroups,
meta: {
title: 'menus.shopManagement.businessUserGroups',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN'],
permissions: [BUSINESS_USER_GROUP_PERMISSIONS.page]
}
},
// 业务负责人导入
{
path: 'business-owner-import',
name: 'BusinessOwnerImport',
component: RoutesAlias.BusinessOwnerImport,
meta: {
title: 'menus.shopManagement.businessOwnerImport',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN'],
permissions: [SHOP_BUSINESS_OWNER_PERMISSIONS.importPage]
}
}
]
},
@@ -359,6 +419,40 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: false
}
},
// 手机号资产关联
{
path: 'phone-asset-association',
name: 'PhoneAssetAssociation',
component: RoutesAlias.PhoneAssetAssociation,
meta: {
title: 'menus.assetManagement.phoneAssetAssociation',
permissions: [AUGUST_PERMISSIONS.phoneAssetAssociation.list],
keepAlive: true
}
},
// 解绑导入任务列表
{
path: 'phone-asset-association/unbind-import-tasks',
name: 'PhoneAssetUnbindImportTasks',
component: RoutesAlias.PhoneAssetUnbindImportTasks,
meta: {
title: 'menus.assetManagement.phoneAssetUnbindImportTasks',
permissions: [AUGUST_PERMISSIONS.phoneAssetAssociation.unbindImportPage],
keepAlive: true
}
},
// 解绑导入任务详情
{
path: 'phone-asset-association/unbind-import-tasks/detail',
name: 'PhoneAssetUnbindImportTaskDetail',
component: RoutesAlias.PhoneAssetUnbindImportTaskDetail,
meta: {
title: 'menus.assetManagement.phoneAssetUnbindImportTaskDetail',
permissions: [AUGUST_PERMISSIONS.phoneAssetAssociation.unbindImportDetail],
isHide: true,
keepAlive: false
}
},
// 记录管理
{
path: 'record-management',
@@ -590,6 +684,26 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true
}
},
{
path: 'export-commission-record',
name: 'ExportCommissionRecordTaskList',
component: RoutesAlias.ExportCommissionRecordTaskList,
meta: {
title: 'menus.assetManagement.exportCommissionRecord',
exportTaskScene: 'commission_record',
keepAlive: true
}
},
{
path: 'export-package-traffic-alert',
name: 'ExportPackageTrafficAlertTaskList',
component: RoutesAlias.ExportPackageTrafficAlertTaskList,
meta: {
title: 'menus.assetManagement.exportPackageTrafficAlert',
exportTaskScene: 'package_traffic_alert',
keepAlive: true
}
},
{
path: 'export-task-detail',
name: 'ExportTaskDetail',
@@ -915,7 +1029,8 @@ export const asyncRoutes: AppRouteRecord[] = [
title: 'menus.settings.detailsOfPaymentMerchant',
isHide: true,
keepAlive: false,
allowedUserTypes: [1, 2]
allowedUserTypes: [1, 2],
permissions: [PAYMENT_MERCHANT_POOL_PERMISSIONS.merchantDetail]
}
},
// 商户池详情
@@ -927,7 +1042,8 @@ export const asyncRoutes: AppRouteRecord[] = [
title: 'menus.settings.detailsOfPaymentMerchantPool',
isHide: true,
keepAlive: false,
allowedUserTypes: [1, 2]
allowedUserTypes: [1, 2],
permissions: [PAYMENT_MERCHANT_POOL_PERMISSIONS.poolDetail]
}
},
// 支付设置详情
@@ -963,17 +1079,6 @@ export const asyncRoutes: AppRouteRecord[] = [
roles: ['R_SUPER', 'R_ADMIN']
}
},
// 代理自充设置(跳转系统配置并带上模块筛选)
{
path: 'agent-self-recharge',
name: 'AgentSelfRechargeSettings',
redirect: { path: RoutesAlias.SystemConfigs, query: { module: 'c2b.payment' } },
meta: {
title: 'menus.settings.agentSelfRecharge',
keepAlive: false,
roles: ['R_SUPER']
}
},
{
path: 'wecom',
name: 'WecomSettings',
@@ -1016,6 +1121,18 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
// H5运营弹窗配置
{
path: 'h5-popup-configuration',
name: 'H5PopupConfigurations',
component: RoutesAlias.H5PopupConfigurations,
meta: {
title: 'menus.settings.h5PopupConfiguration',
keepAlive: true,
allowedUserTypes: [1, 2],
permissions: [AUGUST_PERMISSIONS.h5PopupConfiguration.list]
}
}
]
},

View File

@@ -17,6 +17,12 @@ export const staticRoutes: AppRouteRecordRaw[] = [
component: () => import('@views/auth/login/index.vue'),
meta: { title: 'menus.login.title', isHideTab: true, setTheme: true }
},
{
path: '/agent-registration',
name: 'AgentRegistration',
component: () => import('@views/agent-registration/index.vue'),
meta: { title: '代理扫码注册', isHideTab: true, setTheme: true, noLogin: true }
},
{
path: '/exception',
component: Home,

View File

@@ -38,10 +38,15 @@ export enum RoutesAlias {
SeriesGrants = '/package-management/series-grants', // 代理系列授权
SeriesGrantsDetail = '/package-management/series-grants/detail', // 代理系列授权详情
SeriesGrantPackages = '/package-management/series-grants/packages', // 代理系列授权套餐列表
TrafficAlertRules = '/package-management/traffic-alert-rules', // 真流量预警规则
TrafficAlerts = '/package-management/traffic-alerts', // 真流量达量预警记录
TrafficAlertDetail = '/package-management/traffic-alerts/detail', // 真流量达量预警记录详情
// 店铺管理
Shop = '/shop-management/list', // 店铺列表
ShopDetail = '/shop-management/detail', // 店铺详情
BusinessUserGroups = '/shop-management/business-user-groups', // 业务用户组
BusinessOwnerImport = '/shop-management/business-owner-import', // 业务负责人导入
// 通用页面(店铺账号-企业账号)
EnterpriseCustomerAccounts = '/common/account-list', // 企业客户账号列表和店铺账号列表共用
@@ -79,6 +84,8 @@ export enum RoutesAlias {
ExportRefundTaskList = '/asset-management/export-task-management/export-refund', // 导出退款
ExportAgentRechargeTaskList = '/asset-management/export-task-management/export-agent-recharge', // 导出代理充值
ExportExchangeTaskList = '/asset-management/export-task-management/export-exchange', // 导出换货
ExportCommissionRecordTaskList = '/asset-management/export-task-management/export-commission-record', // 导出佣金记录
ExportPackageTrafficAlertTaskList = '/asset-management/export-task-management/export-package-traffic-alert', // 导出套餐真流量预警
ExportTaskDetail = '/asset-management/export-task-management/export-task-detail', // 导出任务详情
// 订单管理
@@ -112,11 +119,11 @@ export enum RoutesAlias {
PaymentMerchantPoolDetail = '/settings/payment-merchant-pools/pool-detail', // 商户池详情
OperationPasswordSettings = '/settings/operation-password', // 操作密码设置
SystemConfigs = '/settings/system-configs', // 系统配置
AgentSelfRechargeSettings = '/settings/agent-self-recharge', // 代理自充设置
WecomSettings = '/settings/wecom', // 企业微信配置兼容入口
WecomApplications = '/settings/wecom/applications', // 企业微信应用
WecomMembers = '/settings/wecom/members', // 企业微信成员
WecomScenes = '/settings/wecom/scenes', // 企业微信审批场景
H5PopupConfigurations = '/settings/h5-popup-configuration', // H5运营弹窗配置
// 轮询管理
DataCleanup = '/polling-management/data-cleanup', // 数据清理
@@ -137,7 +144,12 @@ export enum RoutesAlias {
Drag = '/widgets/drag', // 拖拽
Charts = '/template/charts', // 图表
Map = '/template/map', // 地图
Calendar = '/template/calendar' // 日历
Calendar = '/template/calendar', // 日历
// 手机号资产关联
PhoneAssetAssociation = '/asset-management/phone-asset-association', // 手机号资产关联列表
PhoneAssetUnbindImportTasks = '/asset-management/phone-asset-association/unbind-import-tasks', // 解绑导入任务列表
PhoneAssetUnbindImportTaskDetail = '/asset-management/phone-asset-association/unbind-import-tasks/detail' // 解绑导入任务详情
}
// 主页路由 - 修改为资产信息页面

View File

@@ -7,7 +7,7 @@ import { AppRouteRecord } from '@/types/router'
* @returns 处理后的路由配置
*/
export const menuDataToRouter = (route: AppRouteRecord, parentPath = ''): AppRouteRecord => {
const { id, name, component, meta, children } = route
const { id, name, component, redirect, meta, children } = route
const fullPath = buildRoutePath(route, parentPath)
@@ -16,6 +16,7 @@ export const menuDataToRouter = (route: AppRouteRecord, parentPath = ''): AppRou
name,
path: fullPath,
component,
redirect,
meta,
children: processChildren(children || [], fullPath)
}

View File

@@ -0,0 +1 @@
店铺编码,操作类型,业务员登录账号,备注
1 店铺编码 操作类型 业务员登录账号 备注

View File

@@ -159,6 +159,7 @@ export interface AssetExchangeTrace {
export interface AssetResolveResponse {
asset_type: AssetType // 资产类型card 或 device
asset_id: number // 数据库 ID
associated_phones?: string[] // 当前关联手机号列表(完整手机号,无关联时为空数组)
identifier?: string // 原样回传本次查询所用的标识符
virtual_no: string // 虚拟号
status: number // 资产状态

View File

@@ -0,0 +1,74 @@
/**
* 业务用户组相关类型定义 - 匹配后端 AUG26-003 接口
*/
import type { PaginationParams } from './common'
/** 业务线枚举standard 标品 / smart 智能产品 / other 其他 */
export type BusinessUserGroupBusinessLine = 'standard' | 'smart' | 'other'
/** 业务用户组响应实体 */
export interface BusinessUserGroupItem {
id: number // 业务用户组ID
code: string // 业务用户组稳定编码,创建后不可修改
name: string // 业务用户组名称
business_line: string // 所属业务线,空字符串表示未设置
business_line_name: string // 所属业务线中文名称,未设置时为空字符串
enabled: boolean // 是否启用;停用后不得新增成员,也不得作为批量目标
sort: number // 排序值
remark: string // 备注
created_at: string // 创建时间
updated_at: string // 更新时间
}
/** 业务用户组列表查询参数 */
export interface BusinessUserGroupQueryParams extends PaginationParams {
page?: number // 页码,默认 1
page_size?: number // 每页数量,默认 20最大 100
enabled?: boolean // 按启用状态过滤;不传返回全部
keyword?: string // 按稳定编码或名称模糊搜索,最多 100 字符
business_line?: BusinessUserGroupBusinessLine | '' // 按所属业务线过滤
}
/** 业务用户组分页响应 */
export interface BusinessUserGroupPageResult {
items: BusinessUserGroupItem[] | null
page?: number
size?: number
total?: number
}
/** 创建业务用户组请求 */
export interface CreateBusinessUserGroupParams {
code: string // 稳定编码1-64 字符,未删除组内唯一,创建后不可修改
name: string // 名称1-100 字符
business_line?: BusinessUserGroupBusinessLine | '' // 所属业务线,留空表示不设置
enabled?: boolean // 是否启用,默认启用
sort?: number // 排序值,非负整数,默认 0
remark?: string // 备注,最多 500 字符
}
/** 更新业务用户组请求(部分更新,全部可选) */
export interface UpdateBusinessUserGroupParams {
name?: string // 名称1-100 字符
business_line?: BusinessUserGroupBusinessLine | '' | null // 三态:缺省不改 / "" 清空 / 枚举设置
enabled?: boolean // 是否启用
sort?: number // 排序值,非负整数
remark?: string // 备注,最多 500 字符
}
/** 删除业务用户组请求 */
export interface DeleteBusinessUserGroupParams {
confirm: boolean // 确认删除,必须为 true
}
/** 批量设置/清空成员请求 */
export interface SetBusinessUserGroupMembersParams {
account_ids: number[] // 平台用户账号ID列表至少一个每个账号必须是启用平台用户
}
/** 批量设置/清空成员结果 */
export interface BusinessUserGroupMembersResult {
group_id: number // 目标业务用户组ID清空操作为 0
account_ids: number[] | null // 本次成功维护归属的平台用户账号ID列表
}

View File

@@ -384,6 +384,7 @@ export interface StandaloneIotCard {
gateway_card_imei?: string // 网关侧返回的卡 IMEI可空
id: number // 卡ID
iccid: string // ICCID
associated_phones?: string[] // 当前关联手机号列表(完整手机号,无关联时为空数组)
imsi?: string // IMSI (可选)
msisdn?: string // 卡接入号 (可选)
virtual_no?: string // 卡虚拟号(可空)

View File

@@ -4,12 +4,131 @@
import { PaginationParams } from '@/types'
// ==================== 提现资料资格相关 ====================
/**
* 提交/替换提现资料资格请求参数
*/
export interface WithdrawalQualificationSubmitParams {
subject_type: string // 主体类型enterprise 等)
subject_code: string // 主体证件号
legal_person_id_card: string // 法人身份证号
contract_file_key: string // 合同附件 Key
id_card_front_file_key: string // 身份证正面 Key
id_card_back_file_key: string // 身份证背面 Key
business_license_file_key?: string // 营业执照 Key
shop_front_file_key?: string // 门头照 Key
invoice_file_key?: string // 发票附件 Key
invoice_title?: string // 发票抬头
invoice_subject_code?: string // 发票主体证件号
}
/**
* 提现资料资格项
*/
export interface WithdrawalQualificationItem {
id: number // 资格ID
shop_id: number // 店铺ID
shop_name: string // 店铺名称
subject_type: string // 主体类型
subject_type_name: string // 主体类型名称
subject_code_masked: string // 脱敏后主体证件号
legal_person_id_card_masked: string // 脱敏后法人身份证号
status: number // 状态0 待审批等)
status_name: string // 状态名称
approval_instance_id?: number // 审批实例ID
approval_status?: number // 审批状态
approval_status_name?: string // 审批状态名称
contract_file_key?: string // 合同附件 Key
id_card_front_file_key?: string // 身份证正面 Key
id_card_back_file_key?: string // 身份证背面 Key
business_license_file_key?: string // 营业执照 Key
shop_front_file_key?: string // 门头照 Key
invoice_file_key?: string // 发票附件 Key
invoice_title?: string // 发票抬头
invalid_reason?: string // 作废原因
invalidated_at?: string | null // 作废时间
created_at: string // 创建时间
updated_at: string // 更新时间
}
/**
* 提现资料资格查询参数
*/
export interface WithdrawalQualificationQueryParams extends PaginationParams {
status?: number // 状态筛选
}
/**
* 提现资料资格分页结果
*/
export interface WithdrawalQualificationPageResult {
items: WithdrawalQualificationItem[] | null
page: number
size: number
total: number
}
/**
* 作废提现资料资格请求参数
*/
export interface VoidWithdrawalQualificationParams {
reason: string // 作废原因(必填)
}
/**
* 重提被驳回的提现请求参数
*/
export interface ResubmitWithdrawalRequestParams {
account_name: string // 收款账户名
account_number: string // 收款账号
amount: number // 提现金额(分)
withdrawal_method: WithdrawalMethod // 提现方式
invoice_keys?: string[] // 发票附件 Key 列表
}
/**
* 提现申请尝试记录项
*/
export interface WithdrawalAttemptItem {
attempt: number // 第几次尝试
status: number // 状态
status_name: string // 状态名称
reject_reason?: string // 驳回原因
created_at: string // 尝试时间
}
/**
* 提现申请详情
*/
export interface WithdrawalRequestDetail {
id: number // 提现申请ID
shop_id: number // 店铺ID
shop_name: string // 店铺名称
withdrawal_no: string // 提现单号
withdrawal_method: WithdrawalMethod // 提现方式
account_name: string // 收款账户名
account_number: string // 收款账号
amount: number // 提现金额(分)
actual_amount: number // 实际到账(分)
fee: number // 手续费(分)
fee_rate: number // 手续费率基点100=1%
status: number // 状态
status_name: string // 状态名称
reject_reason?: string // 驳回原因
anomaly_flag: number // 异常标记
anomaly_name: string // 异常名称
anomaly_reason?: string // 异常原因
attempts?: WithdrawalAttemptItem[] // 尝试记录
}
// 佣金状态
export enum CommissionStatus {
FROZEN = 1, // 已冻结
UNFREEZING = 2, // 解冻中
RELEASED = 3, // 已发放
INVALID = 4, // 已失效
CLAWBACK = 5, // 回溯
PENDING_CORRECTION = 99 // 待人工修正
}
@@ -146,7 +265,7 @@ export interface MyCommissionRecordItem {
export interface CommissionRecordQueryParams extends PaginationParams {
page_size?: number // 每页数量
commission_source?: string // 佣金来源cost_diff:成本差价one_time:一次性佣金)
status?: number // 状态1已入账2已失效
status?: number // 状态1已冻结2解冻中3已发放4已失效5回溯99待人工修正
start_time?: string // 开始时间
end_time?: string // 结束时间
iccid?: string // ICCID模糊查询
@@ -189,7 +308,7 @@ export interface ShopCommissionRecordItem {
amount: number // 佣金金额(分)
balance_after: number // 入账后佣金余额(分)
commission_source: string // 佣金来源cost_diff:成本差价one_time:一次性佣金)
status: number // 状态1已冻结, 2解冻中, 3已发放, 4已失效
status: number // 状态1已冻结, 2解冻中, 3已发放, 4已失效, 5回溯, 99待人工修正
status_name: string // 状态名称
order_id?: number // 订单ID
order_no?: string // 订单号
@@ -199,6 +318,45 @@ export interface ShopCommissionRecordItem {
created_at: string // 佣金入账时间
seller_shop_id?: number // 卖家店铺ID
seller_shop_name?: string // 卖家店铺名称
source?: ShopCommissionRecordSource // 记录来源original:原佣金, clawback:回溯明细)
released_at?: string // 佣金入账时间,回溯明细为空
withdrawable?: boolean | null // 是否可提现,仅回溯明细返回且恒为 false
original_commission_id?: number // 被回溯的原佣金记录ID仅回溯明细返回
refund_id?: number // 来源退款申请ID仅回溯明细返回
refund_no?: string // 来源退款单号,仅回溯明细返回
clawback_records?: ShopCommissionClawbackItem[] | null // 该原佣金已生成的全部回溯明细,仅原佣金返回
clawback_total_amount?: number | null // 该原佣金累计回溯金额(分,负值),仅原佣金返回
}
/**
* 佣金记录来源
*/
export type ShopCommissionRecordSource = 'original' | 'clawback'
/**
* 佣金回溯明细项(原佣金详情中返回的回溯摘要,或回溯明细行)
*/
export interface ShopCommissionClawbackItem {
id: number // 回溯明细ID
amount: number // 回溯金额(分),恒为负数
balance_after: number // 回溯后佣金余额(分),可为负数
original_commission_id: number // 被回溯的原佣金记录ID
refund_id?: number // 来源退款申请ID
refund_no?: string // 来源退款单号
status: number // 状态5:回溯)
status_name?: string // 状态名称
withdrawable?: boolean | null // 是否可提现,回溯明细恒为不可提现
created_at: string // 生成时间
}
/**
* 佣金明细详情响应
*/
export interface ShopCommissionRecordDetail {
source?: ShopCommissionRecordSource // 记录来源original:原佣金, clawback:回溯明细)
record: ShopCommissionRecordItem // 当前记录
clawback_records?: ShopCommissionClawbackItem[] | null // 该原佣金已生成的全部回溯明细,仅原佣金详情返回
original_commission?: ShopCommissionRecordItem | null // 回溯明细对应的原佣金,仅回溯详情返回
}
/**

View File

@@ -39,6 +39,7 @@ export type SwitchMode = '0' | '1' // 0=自动, 1=手动
export interface Device {
id: number // 设备ID
virtual_no: string // 虚拟号(原 device_no
associated_phones?: string[] // 当前关联手机号列表(完整手机号,无关联时为空数组)
device_name: string // 设备名称
device_model: string // 设备型号
device_type: string // 设备类型

View File

@@ -9,6 +9,8 @@ export type ExportTaskScene =
| 'agent_recharge'
| 'refund'
| 'exchange'
| 'commission_record'
| 'package_traffic_alert'
export type ExportTaskFormat = 'xlsx' | 'csv'

View File

@@ -0,0 +1,113 @@
/**
* H5 运营弹窗配置相关类型
* 契约来源docs/产品迭代8月份/通知.md
*/
// 命中页面集合 (home:首页, asset_detail:资产详情, package_purchase:套餐购买, asset_wallet_recharge:资产钱包充值)
export type H5PopupPage = 'home' | 'asset_detail' | 'package_purchase' | 'asset_wallet_recharge'
// 投放频率 (once:每客户每配置版本仅一次, daily:每客户每配置版本每个上海自然日一次)
export type H5PopupFrequency = 'once' | 'daily'
// 受控动作 (package_purchase:套餐购买, asset_wallet_recharge:资产钱包充值);空字符串表示无受控动作
export type H5PopupActionType = 'package_purchase' | 'asset_wallet_recharge' | ''
// 卡类型范围 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)
export type H5PopupCardType = 'CMCC' | 'CUCC' | 'CTCC' | 'CBN'
/**
* H5 运营弹窗配置(列表项 / 详情 / 创建与更新响应)
*/
export interface H5PopupConfiguration {
id: number // 配置ID
title: string // 弹窗标题
content: string // 弹窗正文
pages: H5PopupPage[] | null // 命中页面集合;空数组等价于非法(页面必选)
priority: number // 优先级,数值越大越优先
frequency: H5PopupFrequency // 投放频率
frequency_text: string // 投放频率名称(中文)
enabled: boolean // 是否启用;停用后停止新投放,历史通知在展示期内仍可见
enabled_text: string // 启停状态名称(中文)
action_type: H5PopupActionType // 受控动作;空值表示无受控动作
shop_ids: number[] | null // 店铺范围;空数组表示全量
device_types: string[] | null // 设备类型范围;空数组表示全量
card_types: H5PopupCardType[] | null // 卡类型范围;空数组表示全量
starts_at: string // 生效开始时间ISO 8601
ends_at: string // 生效结束时间ISO 8601
version: number // 配置版本,每次更新递增;版本参与频率去重,旧版本通知保留原快照
creator: number // 创建人账号ID
updater: number // 最近更新人账号ID
created_at: string // 创建时间ISO 8601
updated_at: string // 最近更新时间ISO 8601启停同样刷新该时间
}
/**
* 列表查询参数
* GET /api/admin/h5-popup-configurations
*/
export interface H5PopupConfigurationQueryParams {
page?: number // 页码,默认 1110000
page_size?: number // 每页数量,默认 20最大 100
enabled?: boolean | null // 是否启用筛选
}
/**
* 列表分页响应
*/
export interface H5PopupConfigurationListResponse {
items: H5PopupConfiguration[] | null
page: number
size: number
total: number
}
/**
* 创建请求
* POST /api/admin/h5-popup-configurations
* title/content/pages/frequency/starts_at/ends_at 必填;
* shop_ids/device_types/card_types 空数组 = 全量。
*/
export interface CreateH5PopupConfigurationRequest {
title: string // 弹窗标题1100 字符
content: string // 弹窗正文12000 字符,不接受 HTML、URL 或前端路由
pages: H5PopupPage[] // 命中页面集合(非空)
priority?: number // 优先级数值越大越优先01000000
frequency: H5PopupFrequency // 投放频率
starts_at: string // 生效开始时间ISO 8601
ends_at: string // 生效结束时间ISO 8601不得早于开始时间
enabled?: boolean // 是否启用,默认由后端决定
action_type?: H5PopupActionType // 受控动作
shop_ids?: number[] // 店铺范围,空数组 = 全量
device_types?: string[] // 设备类型范围,空数组 = 全量
card_types?: H5PopupCardType[] // 卡类型范围,空数组 = 全量
}
/**
* 更新请求
* PUT /api/admin/h5-popup-configurations/{id}
* 除 id 外全部可选不传保持原值action_type 空字符串 = 清除受控动作;
* shop_ids/device_types/card_types 空数组 = 改为全量pages 空数组非法。
*/
export interface UpdateH5PopupConfigurationRequest {
id: number // 运营弹窗配置ID
title?: string // 不传保持原值1100 字符
content?: string // 不传保持原值12000 字符
pages?: H5PopupPage[] // 不传保持原值;传空数组等价于非法(页面必选)
priority?: number // 不传保持原值01000000
frequency?: H5PopupFrequency // 不传保持原值
starts_at?: string // 不传保持原值ISO 8601
ends_at?: string // 不传保持原值ISO 8601不得早于开始时间
enabled?: boolean // 不传保持原值;启停会刷新最近更新时间并影响同优先级排序
action_type?: H5PopupActionType | null // 不传保持原值;传空字符串 = 清除受控动作
shop_ids?: number[] | null // 不传保持原值;传空数组 = 改为全量
device_types?: string[] | null // 不传保持原值;传空数组 = 改为全量
card_types?: H5PopupCardType[] | null // 不传保持原值;传空数组 = 改为全量
}
/**
* 启用/停用请求体
* POST /api/admin/h5-popup-configurations/{id}/enable、/{id}/disable
*/
export interface H5PopupConfigurationIDParams {
id: number // 运营弹窗配置ID
}

View File

@@ -1,4 +1,4 @@
/**
/**
* API 类型统一导出
*/
@@ -20,6 +20,7 @@ export * from './account'
// 店铺相关
export * from './shop'
export * from './businessUserGroup'
// 网卡相关
export * from './card'
@@ -137,3 +138,12 @@ export * from './wecom'
// 员工代收款相关
export * from './employeeCollection'
// 手机号资产关联相关
export * from './phoneAsset'
// H5 运营弹窗配置相关
export * from './h5PopupConfiguration'
// 套餐真流量预警相关
export * from './packageTrafficAlert'

View File

@@ -0,0 +1,137 @@
/**
* 套餐真流量预警相关类型
* 对应 docs/产品迭代8月份/套餐真流量预警_API_前端简版.md
* 6 个接口仅超级管理员/平台账号可访问,其他账号后端返回 403
*/
import type { BaseResponse, PaginationParams, PaginationResponse } from './common'
// ========== 预警规则 ==========
/** 预警规则列表查询参数 */
export interface PackageTrafficAlertRuleQueryParams extends PaginationParams {
package_id?: number // 按套餐商品ID过滤
enabled?: boolean // 按启用状态过滤;不传时查询全部
}
/** 预警规则项 */
export interface PackageTrafficAlertRuleItem {
id: number // 预警规则ID
package_id: number // 套餐商品ID
package_name: string // 套餐名称
real_data_mb: number // 套餐商品当前真流量额度(MB),仅用于配置校验展示,不作为预警分母
threshold_percent: number // 真流量预警阈值百分比1~100允许两位小数
enabled: boolean // 是否启用
enabled_name: string // 启用状态名称(中文)
remark: string // 备注
updated_at: string // 最近更新时间
}
/** 创建预警规则参数 */
export interface CreatePackageTrafficAlertRuleParams {
package_id: number // 套餐商品ID必须存在且真流量额度大于零
threshold_percent: number // 真流量预警阈值百分比,取值 1 至 100允许两位小数
enabled?: boolean // 是否启用(默认 true停用后扫描不再创建新预警
remark?: string // 备注,最多 500 字符
}
/** 修改预警规则参数(允许修改阈值、启停与备注;修改不影响既有预警快照) */
export interface UpdatePackageTrafficAlertRuleParams {
threshold_percent?: number // 新的真流量预警阈值百分比,取值 1 至 100允许两位小数
enabled?: boolean // 是否启用;停用后扫描不再创建新预警,既有预警保留
remark?: string // 备注,最多 500 字符
}
// ========== 预警记录 ==========
/** 资产类型iot_card:物联网卡, device:设备) */
export type PackageTrafficAlertAssetType = 'iot_card' | 'device'
/** 通知投递结果 */
export enum PackageTrafficAlertNotificationStatus {
/** 已通知 */
NOTIFIED = 1,
/** 待投递 */
PENDING_DELIVERY = 2,
/** 投递失败 */
DELIVERY_FAILED = 3,
/** 未通知(接收人已失效) */
NOT_NOTIFIED_RECEIVER_INVALID = 4,
/** 未通知(无有效业务员) */
NOT_NOTIFIED_NO_VALID_OWNER = 5
}
/** 预警记录列表查询参数(与导出筛选一致) */
export interface PackageTrafficAlertRecordQueryParams extends PaginationParams {
package_id?: number // 按阈值来源套餐商品ID过滤
shop_id?: number // 按触发时所属店铺ID过滤
business_owner_account_id?: number // 按触发时店铺业务员账号ID过滤
asset_type?: PackageTrafficAlertAssetType // 资产类型 (iot_card:物联网卡, device:设备)
asset_identifier?: string // 资产或卡标识关键词,匹配资产标识、卡标识与对应标识符快照
threshold_percent?: number // 按触发阈值快照精确过滤,允许两位小数
start_time?: string // 触发时间起始RFC3339含该时刻
end_time?: string // 触发时间截止RFC3339含该时刻
notification_status?: PackageTrafficAlertNotificationStatus // 通知投递结果过滤 (1-5)
}
/**
* 预警记录项
* 除 business_user_group_names当前值其余字段均为触发时快照
*/
export interface PackageTrafficAlertRecordItem {
id: number // 预警记录ID
package_id?: number // 阈值来源套餐商品ID
package_name?: string | null // 套餐名称快照
shop_id?: number | null // 触发时所属店铺ID
shop_name?: string | null // 触发时所属店铺名称
business_owner_account_id?: number | null // 触发时店铺业务员账号ID
business_owner_username?: string | null // 触发时店铺业务员账号名
business_user_group_names?: string[] // 当前业务用户组名称(非触发时快照,可为空数组)
asset_type?: PackageTrafficAlertAssetType | null // 资产类型快照
asset_identifier?: string | null // 资产或卡标识快照
threshold_percent?: number // 触发阈值快照
triggered_at?: string // 触发时间
notification_status?: PackageTrafficAlertNotificationStatus // 通知投递结果
notification_status_name?: string // 通知投递结果名称
}
/** 预警记录详情(在列表项基础上标识触发后归属是否变化) */
export interface PackageTrafficAlertRecordDetail extends PackageTrafficAlertRecordItem {
shop_changed_since_trigger?: boolean // 触发后店铺归属是否变化
owner_changed_since_trigger?: boolean // 触发后业务员归属是否变化
}
// ========== 导出 ==========
/** 创建预警记录导出任务参数(复用既有异步导出任务) */
export interface ExportPackageTrafficAlertParams {
format: 'xlsx' | 'csv' // 导出格式 (xlsx:Excel, csv:CSV)
package_id?: number
shop_id?: number
business_owner_account_id?: number
asset_type?: PackageTrafficAlertAssetType
asset_identifier?: string // 最多 100 字符
threshold_percent?: number
start_time?: string
end_time?: string
notification_status?: PackageTrafficAlertNotificationStatus
}
/** 创建预警记录导出任务响应 */
export interface CreatePackageTrafficAlertExportResponse {
task_id: number // 导出任务ID
task_no: string // 任务编号
status: number // 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)
status_name: string // 任务状态名称(中文)
message: string // 提示信息
}
// ========== 响应类型 ==========
export type PackageTrafficAlertRuleListResponse = PaginationResponse<PackageTrafficAlertRuleItem>
export type PackageTrafficAlertRuleResponse = BaseResponse<PackageTrafficAlertRuleItem>
export type PackageTrafficAlertRecordListResponse =
PaginationResponse<PackageTrafficAlertRecordItem>
export type PackageTrafficAlertRecordDetailResponse = BaseResponse<PackageTrafficAlertRecordDetail>
export type CreatePackageTrafficAlertExportApiResponse =
BaseResponse<CreatePackageTrafficAlertExportResponse>

View File

@@ -241,7 +241,7 @@ export const PAYMENT_CREDENTIAL_FIELD_SPECS: Record<
},
wechat_v2: {
required: ['wx_mch_id', 'wx_api_v2_key', 'wx_notify_url'],
optional: []
optional: ['wx_client_cert_content', 'wx_client_key_content']
},
fuiou: {
required: [

View File

@@ -36,6 +36,8 @@ interface WechatPayConfigFields {
wx_serial_no: string
wx_cert_content: string
wx_key_content: string
wx_client_cert_content: string
wx_client_key_content: string
}
interface AlipayConfigFields {

192
src/types/api/phoneAsset.ts Normal file
View File

@@ -0,0 +1,192 @@
/**
* 手机号资产关联相关类型
* 契约来源docs/产品迭代8月份/手机号资产关联.md
*/
// 资产类型
export type PhoneAssetType = 'iot_card' | 'device'
// 关联状态 (0:已失效, 1:有效)
export type PhoneAssetAssociationStatus = 0 | 1
// 失效方式
export type PhoneAssetInvalidationMethod = 'backend_single' | 'backend_batch' | 'csv_import'
// 建立来源(固定为 H5 短信验证)
export type PhoneAssetSource = 'h5_sms_verification'
/**
* 手机号资产关联列表项
* GET /api/admin/phone-asset-associations
*/
export interface PhoneAssetAssociation {
id: number // 关联关系ID
asset_id: number // 资产ID
asset_identifier: string // 资产当前标识(卡为 ICCID设备为虚拟号
asset_type: PhoneAssetType // 资产类型
phone: string // 关联手机号(完整值)
established_at: string // 建立时间(短信验证通过时间)
source: PhoneAssetSource // 建立来源,固定为 h5_sms_verification
status: PhoneAssetAssociationStatus // 关联状态 (0:已失效, 1:有效)
status_name: string // 关联状态中文名称
invalidated_at: string | null // 失效时间;有效关系为空
invalidation_method: PhoneAssetInvalidationMethod | null // 失效方式;有效关系为空
invalidation_method_name: string // 失效方式中文名称;有效关系为空
invalidation_reason: string // 失效原因;有效关系为空
}
/**
* 关联列表查询参数
*/
export interface PhoneAssetAssociationQueryParams {
page?: number // 页码,默认 1
page_size?: number // 每页数量,默认 20最大 100
asset_identifier?: string // 资产标识ICCID、虚拟号、IMEI、SN 或接入号,精确匹配)
phone?: string // 手机号(完整值精确匹配)
status?: PhoneAssetAssociationStatus | null // 关联状态 (0:已失效, 1:有效)
created_at_start?: string // 关联创建时间起(含)
created_at_end?: string // 关联创建时间止(含)
}
/**
* 关联列表分页响应
*/
export interface PhoneAssetAssociationListResponse {
items: PhoneAssetAssociation[] | null
page: number
size: number
total: number
}
/**
* 单条解绑响应
* DELETE /api/admin/phone-asset-associations/{id}
*/
export interface UnbindPhoneAssetAssociationResponse {
asset_id: number // 资产ID
asset_type: PhoneAssetType // 资产类型
id: number // 关联关系ID
invalidated_at: string // 失效时间
unbound_count: number // 本次解除的有效关系数,成功时为 1
}
/**
* 批量解绑资产项
*/
export interface BatchUnbindAssetItem {
asset_id: number // 资产ID
asset_type: PhoneAssetType // 资产类型 (iot_card:物联网卡, device:设备)
}
/**
* 批量解绑请求
* POST /api/admin/phone-asset-associations/batch-unbind
*/
export interface BatchUnbindPhoneAssetAssociationRequest {
assets: BatchUnbindAssetItem[] // 待解除的资产集合,按 (资产类型, 资产ID) 去重后执行
confirmed: boolean // 二次确认,必须为 true
reason: string // 解除原因1500 字符)
}
/**
* 批量解绑逐项结果
*/
export interface BatchUnbindAssetResult {
asset_id: number // 资产ID
asset_type: PhoneAssetType // 资产类型
success: boolean // 该项是否解除成功
reason: string // 该项失败原因,成功时为空
unbound_count: number // 该项解除的有效关系数
}
/**
* 批量解绑响应
*/
export interface BatchUnbindPhoneAssetAssociationResponse {
success_count: number // 解除成功的资产项数
fail_count: number // 解除失败的资产项数
items: BatchUnbindAssetResult[] | null // 逐项结果,顺序与请求去重后的资产集合一致
}
/**
* 解绑导入任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)
*/
export type PhoneAssetUnbindImportTaskStatus = 1 | 2 | 3 | 4
/**
* 创建解绑导入任务请求
* POST /api/admin/phone-asset-associations/unbind-imports
*/
export interface CreatePhoneAssetUnbindImportRequest {
file_key: string // CSV 对象存储 Keyphone-unbind-imports/ 开头且扩展名为 .csv
reason: string // 任务级解绑原因1500 字符)
confirmed: boolean // 二次确认,必须为 true
}
/**
* 解绑导入任务(列表项 / 创建响应)
*/
export interface PhoneAssetUnbindImportTask {
id: number // 导入任务ID
task_no: string // 导入任务编号
file_name: string // 上传的源 CSV 文件名
status: PhoneAssetUnbindImportTaskStatus // 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)
status_name: string // 任务状态中文名称
total_count: number // 数据行总数;任务级失败时为 0
success_count: number // 成功行数
fail_count: number // 失败行数
unbind_reason: string // 任务级解绑原因
error_message: string // 任务级失败原因;与行级失败原因分开记录
creator_name: string // 任务创建人名称快照
created_at: string // 创建时间
started_at: string // 开始处理时间
completed_at: string // 完成时间
}
/**
* 解绑导入任务列表分页响应
* GET /api/admin/phone-asset-associations/unbind-imports
*/
export interface PhoneAssetUnbindImportTaskListResponse {
items: PhoneAssetUnbindImportTask[] | null
page: number
size: number
total: number
}
/**
* 解绑导入任务列表查询参数
*/
export interface PhoneAssetUnbindImportTaskQueryParams {
page?: number // 页码,默认 1
page_size?: number // 每页数量,默认 20最大 100
status?: PhoneAssetUnbindImportTaskStatus | null // 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)
}
/**
* 逐行状态 (3:成功, 4:失败)
*/
export type PhoneAssetUnbindImportItemStatus = 3 | 4
/**
* 解绑导入任务逐行结果
*/
export interface PhoneAssetUnbindImportItem {
line: number // 行号,自数据首行起计(表头不计入)
asset_identifier: string // 导入文件中的资产标识原文
asset_id: number // 资产ID未定位到资产时为 0
asset_type: PhoneAssetType | '' // 资产类型;未定位到资产时为空
status: PhoneAssetUnbindImportItemStatus // 行状态 (3:成功, 4:失败)
status_name: string // 行状态中文名称
reason: string // 失败原因;成功行为空
associated_phones: string[] | null // 解绑当时的完整关联手机号快照;失败行为空数组
unbound_count: number // 该行解除的有效关系数
}
/**
* 解绑导入任务详情
* GET /api/admin/phone-asset-associations/unbind-imports/{id}
*/
export interface PhoneAssetUnbindImportTaskDetail extends PhoneAssetUnbindImportTask {
items: PhoneAssetUnbindImportItem[] | null // 逐行结果明细;任务级失败时为空数组
}

View File

@@ -7,7 +7,36 @@ export enum RefundStatus {
PENDING = 1, // 待审批
APPROVED = 2, // 已通过
REJECTED = 3, // 已拒绝
RETURNED = 4 // 已退回
RETURNED = 4, // 已退回
REFUNDING = 5, // 原路退款处理中
REFUND_FAILED = 6 // 原路退款失败
}
// 退款方式
export enum RefundMethod {
ORIGINAL_ROUTE = 'original_route', // 原路退回
CUSTOMER_ACCOUNT = 'customer_account', // 客户收款信息
ASSET_WALLET = 'asset_wallet', // 资产钱包
AGENT_WALLET = 'agent_wallet' // 代理钱包
}
// 渠道退款状态
export enum ChannelRefundStatus {
NOT_STARTED = 0, // 未发起或不适用
PROCESSING = 1, // 处理中
SUCCEEDED = 2, // 已成功
FAILED = 3 // 已失败
}
// 失败原因分类
export enum RefundFailureReason {
CHANNEL_REJECTED = 'channel_rejected', // 渠道明确拒绝
CREDENTIAL_INVALID = 'credential_invalid', // 渠道凭证失效
INSUFFICIENT_BALANCE = 'insufficient_balance', // 渠道余额不足
TIMEOUT_UNKNOWN = 'timeout_unknown', // 超时或结果未知
APPROVAL_REJECTED = 'approval_rejected', // 企微驳回或关闭
REVOKED_AFTER_APPROVED = 'revoked_after_approved', // 企微通过后撤销
PAYMENT_FACT_INVALID = 'payment_fact_invalid' // 本地原支付事实不可用
}
export interface RefundAttachment {
@@ -42,6 +71,25 @@ export interface RefundApproval {
business_process_result?: unknown
}
// 退款申请审批尝试历史项
export interface RefundAttemptItem {
id: number // 尝试记录ID
attempt_no: number // 第几次尝试
method?: RefundMethod | null // 退款方式
method_name?: string | null // 退款方式名称
refund_amount?: number | null // 退款金额(分)
frozen_actual_received_amount?: number | null // 冻结实收金额(分)
refund_reason?: string | null // 退款原因
customer_account_info?: Record<string, string> | null // 客户收款信息
customer_voucher_key?: string[] | string | null // 客户凭证键
channel_refund_request_no?: string | null // 渠道退款申请号
submitted_by_account_id?: number | null // 提交人账号ID
approval_instance_id?: number | null // 审批实例ID
approval_status?: number | null // 审批状态
approval_status_name?: string | null // 审批状态名称
created_at: string // 创建时间
}
// 退款申请
export interface Refund {
id: number
@@ -55,9 +103,29 @@ export interface Refund {
asset_type: string // 资产类型
requested_refund_amount?: number | null // 申请退款金额(分)
approved_refund_amount?: number | null // 实际退款金额(分)
actual_received_amount?: number | null // 实收金额(分)
actual_received_amount?: number | null // 实收金额(分,历史数据保留
status: RefundStatus
status_name?: string | null
method?: RefundMethod | null // 退款方式
method_name?: string | null // 退款方式名称
frozen_actual_received_amount?: number | null // 冻结实收金额(分)
customer_account_info?: Record<string, string> | null // 客户收款信息
channel_refund_status?: ChannelRefundStatus | null // 渠道退款状态
channel_refund_status_name?: string | null // 渠道退款状态名称
channel_refund_no?: string | null // 渠道退款单号
channel_refund_request_no?: string | null // 渠道退款申请号
channel_refund_amount?: number | null // 渠道退款金额(分)
channel_refunded_at?: string | null // 渠道退款时间
failure_reason?: RefundFailureReason | null // 失败分类
failure_reason_name?: string | null // 失败分类名称
failure_message?: string | null // 失败消息
anomaly_flag?: number // 异常标记0 正常、1 异常)
anomaly_reason?: string | null // 异常原因
latest_attempt_id?: number | null // 最新尝试记录ID
latest_approval_instance_id?: number | null // 最新审批实例ID
attempts?: RefundAttemptItem[] // 审批尝试历史
refund_package_used_mb?: number // 当前退款套餐已用量MB
refund_package_total_mb?: number // 当前退款套餐总量MB
refund_reason: string
refund_voucher_key?: string[] | string // 退款凭证附件列表;历史数据可能为单字符串或逗号字符串
attachments?: RefundAttachment[]
@@ -110,17 +178,36 @@ export interface RefundListResponse {
// 创建退款申请请求
export interface CreateRefundRequest {
order_id: number
method: RefundMethod // 退款方式(必填)
requested_refund_amount: number // 申请退款金额(分)
actual_received_amount: number // 实收金额(分)
package_usage_id: number // 关联套餐使用记录 ID无关联记录时传 0
refund_reason: string
refund_voucher_key: string[]
customer_account_info?: Record<string, string> | null // 客户收款信息customer_account 方式必填)
refund_voucher_key?: string[] | null // 退款凭证customer_account 方式必填至少 1 个)
}
// 重新提交退款申请请求
export interface ResubmitRefundRequest {
method?: RefundMethod | null // 退款方式(不传沿用原值)
requested_refund_amount?: number
actual_received_amount?: number
attachments?: RefundAttachment[]
refund_reason?: string
customer_account_info?: Record<string, string> | null // 客户收款信息(不传沿用原值)
refund_voucher_key?: string[] | null
attachments?: RefundAttachment[]
}
// 审批通过退款申请请求
export interface ApproveRefundRequest {
approved_refund_amount?: number | null // 审批退款金额(分)
remark?: string
}
// 审批拒绝退款申请请求
export interface RejectRefundRequest {
reject_reason: string
}
// 退回退款申请请求
export interface ReturnRefundRequest {
remark?: string
}

View File

@@ -26,7 +26,13 @@ export interface ShopResponse {
business_owner_username: string // 平台业务员账号名
business_owner_phone_summary: string // 平台业务员手机号摘要
business_owner_available: boolean // 是否仍可用于通知接收
distribution_code?: string // 分销码(用于生成注册二维码)
client_login_disabled: boolean // 是否禁止 C 端新登录
business_user_group_id: number | null // 业务用户组ID实时推导可空
business_user_group_code: string // 业务用户组编码
business_user_group_name: string // 业务用户组名称
business_user_group_enabled: boolean // 业务用户组是否启用;停用仍返回组且 enabled=false
business_user_group_business_line: string // 业务用户组业务线
}
// 店铺列表查询参数
@@ -41,6 +47,9 @@ export interface ShopQueryParams extends PaginationParams {
contact_phone?: string // 联系电话精确查询
business_owner_account_id?: number | null // 平台业务员账号ID
client_login_disabled?: boolean
business_user_group_id?: number | null // 按业务用户组ID筛选
business_line?: string | null // 按业务线筛选standard/smart/other
ungrouped?: boolean // true=无负责人或负责人无分组
page?: number // 页码
page_size?: number // 每页数量
}
@@ -139,3 +148,69 @@ export interface UpdateShopCreditLimitResponse {
version: number // 更新后的钱包版本
wallet_id: number // 主钱包 ID
}
// ========== 批量交接平台业务员 ==========
/** 批量交接平台业务员请求 */
export interface BatchUpdateBusinessOwnerParams {
shop_ids: number[] // 店铺ID列表1-500自动去重
business_owner_account_id: number | null // 显式必传null=清空负责人ID=换绑
}
/** 批量交接平台业务员响应 */
export interface BatchUpdateBusinessOwnerResult {
batch_key: string // 批次标识
shop_count: number // 处理店铺数
cleared: number // 清空负责人的店铺数
}
// ========== 业务负责人 CSV 导入 ==========
/** 业务负责人导入任务行级结果 */
export interface BusinessOwnerImportRow {
line: number // 行号,从数据首行 1 开始
shop_code: string // 店铺编码
operation_type: string // 操作类型:换绑 / 清空
status: number // 3=成功 4=失败
status_name?: string // 状态中文名(成功/失败)
reason: string // 失败原因
}
/** 业务负责人导入任务详情 */
export interface BusinessOwnerImportDetail {
id: number // 导入任务ID
task_no?: string // 任务编号
file_name?: string // 文件名
creator_name?: string // 创建人
status: number // 1=待处理 2=处理中 3=已完成 4=失败
status_name?: string // 状态中文名(待处理/处理中/已完成/失败)
started_at?: string // 开始时间
completed_at?: string // 完成时间
total_count?: number
success_count?: number
fail_count?: number
error_message?: string | null // 任务级失败原因
items?: BusinessOwnerImportRow[] | null // 行级结果
created_at?: string
updated_at?: string
}
/** 创建业务负责人导入任务请求 */
export interface CreateBusinessOwnerImportParams {
file_key: string // 由 /storage/upload-urlpurpose=shop_import获取
}
/** 业务负责人导入任务列表查询参数 */
export interface BusinessOwnerImportQueryParams {
page?: number
page_size?: number
status?: number // 按状态筛选
}
/** 业务负责人导入任务列表响应 */
export interface BusinessOwnerImportPageResult {
items: BusinessOwnerImportDetail[] | null
page?: number
size?: number
total?: number
}

View File

@@ -4,6 +4,9 @@ export type WecomBusinessType =
| 'refund_approval'
| 'offline_recharge_approval'
| 'employee_collection_approval'
| 'agent_distribution_approval'
| 'withdrawal_qualification_approval'
| 'commission_withdrawal_approval'
export interface WecomApplication {
id: number

View File

@@ -86,6 +86,7 @@ declare module 'vue' {
CommentItem: typeof import('./../components/custom/comment-widget/widget/CommentItem.vue')['default']
CommentWidget: typeof import('./../components/custom/comment-widget/index.vue')['default']
CommissionDisplay: typeof import('./../components/business/CommissionDisplay.vue')['default']
CommissionRecordDetailDialog: typeof import('./../components/business/CommissionRecordDetailDialog.vue')['default']
ContainerSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/ContainerSettings.vue')['default']
CreateRefundDialog: typeof import('./../components/business/CreateRefundDialog.vue')['default']
CustomerAccountDialog: typeof import('./../components/business/CustomerAccountDialog.vue')['default']
@@ -126,6 +127,7 @@ declare module 'vue' {
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElResult: typeof import('element-plus/es')['ElResult']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
@@ -152,6 +154,7 @@ declare module 'vue' {
PackageSelector: typeof import('./../components/business/PackageSelector.vue')['default']
PaymentVoucherDialog: typeof import('./../components/business/PaymentVoucherDialog.vue')['default']
RealnamePolicyDialog: typeof import('./../components/device/RealnamePolicyDialog.vue')['default']
RefundApprovalDialog: typeof import('./../components/business/RefundApprovalDialog.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SectionTitle: typeof import('./../components/core/layouts/art-settings-panel/widget/SectionTitle.vue')['default']

View File

@@ -616,7 +616,8 @@
{
prop: 'shop_name',
label: '店铺名称',
minWidth: 150,
minWidth: 160,
showOverflowTooltip: true,
formatter: (row: any) => {
return row.shop_name || '-'
}

View File

@@ -0,0 +1,425 @@
<template>
<div class="agent-registration-page">
<div class="register-card">
<div class="register-header">
<h1 class="register-title">代理扫码注册</h1>
<p class="register-subtitle">填写资料提交后审核结果将通过通知告知</p>
</div>
<div v-if="submitted" class="register-result">
<ElResult icon="success" title="提交成功" sub-title="待审批审核结果将通知你">
<template #extra>
<ElButton type="primary" @click="handleFillAgain">重新填写</ElButton>
</template>
</ElResult>
</div>
<ElForm
v-else
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
size="large"
class="register-form"
>
<div class="form-grid">
<ElFormItem label="分销码" prop="distribution_code">
<ElInput
v-model="form.distribution_code"
:disabled="lockedDistributionCode"
placeholder="请输入分销码"
maxlength="64"
clearable
/>
</ElFormItem>
<ElFormItem label="手机号" prop="phone">
<ElInput v-model="form.phone" placeholder="请输入手机号" maxlength="11" clearable />
</ElFormItem>
</div>
<ElFormItem label="短信验证码" prop="code">
<div class="code-row">
<ElInput v-model="form.code" placeholder="请输入短信验证码" maxlength="6" />
<ElButton :disabled="!canSendCode" :loading="sending" @click="handleSendCode">
{{ countdownText }}
</ElButton>
</div>
</ElFormItem>
<ElFormItem label="密码" prop="password">
<ElInput
v-model="form.password"
type="password"
show-password
placeholder="请输入密码8-32 位)"
maxlength="32"
/>
<div class="strength-bar" :class="strengthClass">
<i v-for="n in 3" :key="n" :class="{ active: passwordStrength >= n }" />
</div>
</ElFormItem>
<div class="form-grid">
<ElFormItem label="店铺名称" prop="shop_name">
<ElInput
v-model="form.shop_name"
:disabled="lockedShopName"
placeholder="请输入店铺名称"
maxlength="50"
clearable
/>
</ElFormItem>
<ElFormItem label="店铺编号" prop="shop_code">
<ElInput
v-model="form.shop_code"
:disabled="lockedShopCode"
placeholder="请输入店铺编号"
maxlength="50"
clearable
/>
</ElFormItem>
</div>
<ElFormItem label="用户名" prop="username">
<ElInput v-model="form.username" placeholder="请输入登录用户名" maxlength="50" clearable />
</ElFormItem>
<div class="form-grid">
<ElFormItem label="联系人" prop="contact_name">
<ElInput v-model="form.contact_name" placeholder="选填" maxlength="50" clearable />
</ElFormItem>
<ElFormItem label="省市区">
<ElCascader
v-model="form.region"
:options="regionOptions"
placeholder="请选择省市区"
clearable
class="region-cascader"
@change="handleRegionChange"
/>
</ElFormItem>
</div>
<ElFormItem label="详细地址" prop="address">
<ElInput v-model="form.address" placeholder="选填,请输入详细地址" maxlength="200" clearable />
</ElFormItem>
<ElButton
type="primary"
size="large"
class="submit-btn"
:loading="submitting"
@click="handleSubmit"
>
提交注册
</ElButton>
</ElForm>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { CascaderOption, CascaderValue, FormInstance, FormRules } from 'element-plus'
import {
AgentDistributionService,
type AgentDistributionRegistrationParams
} from '@/api/modules/agentDistribution'
import { regionData } from '@/utils/constants/regionData'
const route = useRoute()
const regionOptions = regionData as CascaderOption[]
interface RegisterForm {
distribution_code: string
phone: string
code: string
password: string
shop_name: string
shop_code: string
username: string
contact_name: string
province: string
city: string
district: string
address: string
region: string[]
}
const form = reactive<RegisterForm>({
distribution_code: (route.query.distribution_code as string) || '',
phone: '',
code: '',
password: '',
shop_name: (route.query.shop_name as string) || '',
shop_code: (route.query.shop_code as string) || '',
username: '',
contact_name: '',
province: '',
city: '',
district: '',
address: '',
region: []
})
const formRef = ref<FormInstance>()
const submitted = ref(false)
const sending = ref(false)
const submitting = ref(false)
const countdown = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
const rules: FormRules = {
distribution_code: [{ required: true, message: '请输入分销码', trigger: 'blur' }],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
],
code: [{ required: true, message: '请输入短信验证码', trigger: 'blur' }],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, max: 32, message: '密码长度为 8-32 位', trigger: 'blur' }
],
shop_name: [{ required: true, message: '请输入店铺名称', trigger: 'blur' }],
shop_code: [{ required: true, message: '请输入店铺编号', trigger: 'blur' }],
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }]
}
const lockedDistributionCode = computed(() => !!route.query.distribution_code)
const lockedShopName = computed(() => !!route.query.shop_name)
const lockedShopCode = computed(() => !!route.query.shop_code)
const canSendCode = computed(() => countdown.value <= 0 && !sending.value)
const countdownText = computed(() =>
countdown.value > 0 ? `${countdown.value}s 后重发` : '获取验证码'
)
const startCountdown = () => {
countdown.value = 60
if (timer) clearInterval(timer)
timer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0 && timer) {
clearInterval(timer)
timer = null
}
}, 1000)
}
const handleSendCode = async () => {
if (!/^1[3-9]\d{9}$/.test(form.phone)) {
ElMessage.warning('请先输入正确的手机号')
return
}
sending.value = true
try {
const res = await AgentDistributionService.sendCode({
phone: form.phone,
scene: 'bind_phone'
})
if (res.code === 0) {
ElMessage.success('验证码已发送')
startCountdown()
}
} catch {
// 错误提示由请求层统一处理
} finally {
sending.value = false
}
}
const passwordStrength = computed(() => {
const pwd = form.password
if (!pwd) return 0
let score = 0
if (pwd.length >= 8) score += 1
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score += 1
if (/\d/.test(pwd)) score += 1
if (/[^A-Za-z0-9]/.test(pwd)) score += 1
if (score >= 4) return 3
if (score >= 2) return 2
return 1
})
const strengthClass = computed(() => {
const map = ['', 'is-weak', 'is-medium', 'is-strong']
return map[passwordStrength.value] || ''
})
const handleRegionChange = (value: CascaderValue | null | undefined) => {
const parts = Array.isArray(value) ? value.map((item) => String(item)) : []
form.province = parts[0] || ''
form.city = parts[1] || ''
form.district = parts[2] || ''
}
const handleSubmit = () => {
if (!formRef.value) return
formRef.value.validate(async (valid) => {
if (!valid) return
submitting.value = true
try {
const params: AgentDistributionRegistrationParams = {
distribution_code: form.distribution_code.trim(),
phone: form.phone.trim(),
code: form.code.trim(),
password: form.password,
shop_name: form.shop_name.trim(),
shop_code: form.shop_code.trim(),
username: form.username.trim()
}
if (form.contact_name.trim()) params.contact_name = form.contact_name.trim()
if (form.province) params.province = form.province
if (form.city) params.city = form.city
if (form.district) params.district = form.district
if (form.address.trim()) params.address = form.address.trim()
const res = await AgentDistributionService.registerAgent(params)
if (res.code === 0) {
submitted.value = true
} else {
ElMessage.error('分销码不可用')
}
} catch {
ElMessage.error('分销码不可用')
} finally {
submitting.value = false
}
})
}
const handleFillAgain = () => {
submitted.value = false
formRef.value?.resetFields()
form.distribution_code = (route.query.distribution_code as string) || form.distribution_code
form.shop_name = (route.query.shop_name as string) || form.shop_name
form.shop_code = (route.query.shop_code as string) || form.shop_code
form.code = ''
}
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
</script>
<style scoped lang="scss">
.agent-registration-page {
display: flex;
align-items: flex-start;
justify-content: center;
min-height: 100vh;
padding: 48px 16px 64px;
box-sizing: border-box;
background: linear-gradient(160deg, #eef3fb 0%, #f7f9fd 40%, #eef3fb 100%);
.register-card {
width: 100%;
max-width: 520px;
padding: 36px 36px 28px;
box-sizing: border-box;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 30px rgb(93 135 255 / 12%);
}
.register-header {
margin-bottom: 24px;
text-align: center;
.register-title {
margin: 0;
font-size: 22px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.register-subtitle {
margin: 8px 0 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
}
.code-row {
display: flex;
gap: 12px;
width: 100%;
.el-input {
flex: 1;
}
.el-button {
flex: 0 0 auto;
width: 128px;
}
}
.strength-bar {
display: flex;
gap: 6px;
margin-top: 8px;
i {
flex: 1;
height: 4px;
border-radius: 2px;
background: var(--el-border-color-lighter);
transition: background-color 0.2s ease;
&.active {
background: var(--el-color-warning);
}
}
&.is-strong i.active {
background: var(--el-color-success);
}
&.is-weak i.active {
background: var(--el-color-danger);
}
}
.region-cascader {
width: 100%;
}
.submit-btn {
width: 100%;
margin-top: 8px;
}
.register-result {
padding: 8px 0;
}
@media (max-width: 768px) {
padding: 24px 12px 40px;
.register-card {
padding: 24px 16px 20px;
border-radius: 10px;
}
.form-grid {
grid-template-columns: 1fr;
}
.code-row .el-button {
width: 112px;
}
}
}
</style>

View File

@@ -47,6 +47,12 @@
border
size="large"
>
<!-- 关联手机号(通用) -->
<ElDescriptionsItem
label="关联手机号"
label-class-name="asset-label asset-label--associated-phones"
>{{ (cardInfo?.associated_phones || []).join('、') || '-' }}</ElDescriptionsItem
>
<!-- 卡专属字段 -->
<template v-if="cardInfo?.asset_type === 'card'">
<ElDescriptionsItem
@@ -698,6 +704,7 @@
}
interface AssetInfo {
associated_phones?: string[]
asset_id: number
asset_type: 'card' | 'device'
identifier: string

View File

@@ -135,6 +135,7 @@ export function useAssetInfo() {
// 卡专属字段 (asset_type === 'card')
iccid: data.iccid || '',
msisdn: data.msisdn || '',
associated_phones: data.associated_phones ?? [],
imsi: data.imsi || '',
carrier_id: data.carrier_id,
carrier_type: data.carrier_type || '',

View File

@@ -16,6 +16,7 @@ export interface AssetInfo {
iccid?: string
imei?: string
msisdn?: string
associated_phones?: string[] // 当前关联手机号(完整值)
carrier_type?: string
carrier_name?: string
real_name_status?: number

View File

@@ -1617,6 +1617,7 @@
{ label: '设备号', prop: 'virtual_no' },
{ label: 'IMEI', prop: 'imei' },
{ label: '设备名称', prop: 'device_name' },
{ label: '关联手机号', prop: 'associated_phones' },
{ label: '店铺名称', prop: 'shop_name' },
{ label: '套餐系列', prop: 'series_name' },
{ label: '预计套餐到期时间', prop: 'estimated_final_expires_at' },
@@ -1893,10 +1894,19 @@
showOverflowTooltip: true,
minWidth: 150
},
{
prop: 'associated_phones',
label: '关联手机号',
minWidth: 160,
showOverflowTooltip: true,
formatter: (row: Device) =>
row.associated_phones?.length ? row.associated_phones.join('、') : '-'
},
{
prop: 'shop_name',
label: '店铺名称',
minWidth: 120
minWidth: 160,
showOverflowTooltip: true
},
{
prop: 'series_name',

View File

@@ -34,7 +34,7 @@
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import { formatDateTime } from '@/utils/business/format'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import type { DetailField, DetailSection } from '@/components/common/DetailPage.vue'
defineOptions({ name: 'ExchangeDetail' })
@@ -62,6 +62,16 @@
return types[status] || 'info'
}
const getMigrationStatusType = (migrationStatus?: string): TagProps['type'] => {
const types: Record<string, TagProps['type']> = {
not_migrated: 'info',
pending: 'warning',
migrated: 'success',
failed: 'danger'
}
return types[migrationStatus || ''] || 'info'
}
// 根据流程类型动态生成详情页配置
const detailSections = computed<DetailSection[]>(() => {
if (!exchangeDetail.value) return []
@@ -70,9 +80,7 @@
const sections: DetailSection[] = []
// 基本信息
sections.push({
title: '基本信息',
fields: [
const basicFields: DetailField[] = [
{ label: '换货单号', prop: 'exchange_no' },
{
label: '状态',
@@ -112,9 +120,28 @@
label: '继承归属店铺',
formatter: (_, data) =>
formatExchangeShopName(data.inherited_shop_name, data.inherited_shop_id)
},
{
label: '迁移状态',
render: (data) =>
data.migration_status
? h(
ElTag,
{ type: getMigrationStatusType(data.migration_status) },
() => data.migration_status_name || data.migration_status
)
: h('span', '--')
}
]
})
]
if (exchangeDetail.value.migration_status === 'failed') {
basicFields.push({
label: '迁移失败原因',
prop: 'migration_failure_reason',
formatter: (value) => value || '--',
fullWidth: true
})
}
sections.push({ title: '基本信息', fields: basicFields })
// 收货信息(仅物流换货显示)
if (flowType === 'shipping') {

View File

@@ -987,6 +987,19 @@
formatter: (row: ExchangeResponse) =>
h(ElTag, { type: getStatusType(row.status) }, () => row.status_name || '--')
},
{
prop: 'migration_status',
label: '迁移状态',
width: 130,
formatter: (row: ExchangeResponse) =>
row.migration_status
? h(
ElTag,
{ type: getMigrationStatusType(row.migration_status) },
() => row.migration_status_name || '--'
)
: '--'
},
{
prop: 'created_at',
label: '创建时间',
@@ -1006,6 +1019,18 @@
return types[status] || 'info'
}
const getMigrationStatusType = (
migrationStatus?: string
): 'success' | 'info' | 'warning' | 'danger' | 'primary' => {
const types: Record<string, 'success' | 'info' | 'warning' | 'danger' | 'primary'> = {
not_migrated: 'info',
pending: 'warning',
migrated: 'success',
failed: 'danger'
}
return types[migrationStatus || ''] || 'info'
}
const getAssetTypeName = (assetType?: string | null) => {
if (assetType === 'iot_card') return '物联网卡'
if (assetType === 'device') return '设备'
@@ -1638,7 +1663,9 @@
// 状态3已发货待确认确认完成
if (status === 3) {
if (hasAuth('exchange:complete')) {
// migration_status=failed 的重试仅超管/平台账号可操作,代理/企业不渲染入口(后端以 403/1005 兜底)
const canRetryFailed = row.migration_status !== 'failed' || userType === 1 || userType === 2
if (hasAuth('exchange:complete') && canRetryFailed) {
actions.push({
label: '确认完成',
handler: () => handleCompleteExchange(row),

View File

@@ -0,0 +1,9 @@
<template>
<ExportTaskList />
</template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportCommissionRecordTaskList' })
</script>

View File

@@ -0,0 +1,9 @@
<template>
<ExportTaskList />
</template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportPackageTrafficAlertTaskList' })
</script>

View File

@@ -1946,6 +1946,7 @@
{ label: 'ICCID', prop: 'iccid' },
{ label: 'IMEI', prop: 'gateway_card_imei' },
{ label: 'MSISDN', prop: 'msisdn' },
{ label: '关联手机号', prop: 'associated_phones' },
{ label: '卡虚拟号', prop: 'virtual_no' },
{ label: '运营商', prop: 'carrier_name' },
{ label: '卡业务类型', prop: 'card_category' },
@@ -2092,6 +2093,14 @@
label: 'MSISDN',
width: 160
},
{
prop: 'associated_phones',
label: '关联手机号',
minWidth: 150,
showOverflowTooltip: true,
formatter: (row: StandaloneIotCard) =>
row.associated_phones?.length ? row.associated_phones.join('、') : '-'
},
{
prop: 'virtual_no',
label: '卡虚拟号',

View File

@@ -0,0 +1,750 @@
<template>
<ArtTableFullScreen>
<div class="phone-asset-association-page" id="table-full-screen">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
label-width="110"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #actions>
<ElButton
type="danger"
plain
:disabled="selectedRows.length === 0"
@click="openBatchUnbindDialog"
v-if="canBatchUnbind"
>
批量解绑
</ElButton>
<ElButton type="primary" plain @click="openImportDialog" v-if="canImportCreate">
CSV解绑导入
</ElButton>
<ElButton @click="goUnbindImportTasks" v-if="canImportPage">解绑导入任务</ElButton>
</template>
</ArtTableHeader>
<ArtTable
:loading="loading"
:data="associationList"
:currentPage="pagination.page"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="100"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@selection-change="handleSelectionChange"
>
<template #default>
<ElTableColumn type="selection" width="55" />
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
<!-- 单条解绑 -->
<ElDialog
v-model="unbindDialogVisible"
title="解除手机号资产关联"
width="480px"
:close-on-click-modal="false"
>
<ElForm ref="unbindFormRef" :model="unbindForm" :rules="unbindRules" label-width="90px">
<ElFormItem label="资产标识">
<span>{{ unbindForm.asset_identifier || '-' }}</span>
</ElFormItem>
<ElFormItem label="手机号">
<span>{{ unbindForm.phone || '-' }}</span>
</ElFormItem>
<ElFormItem label="解除原因" prop="reason">
<ElInput
v-model="unbindForm.reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请填写解除原因1~500 字符)"
/>
</ElFormItem>
<ElFormItem label="二次确认" prop="confirmed">
<ElCheckbox v-model="unbindForm.confirmed">我已确认解除该关联关系</ElCheckbox>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="unbindDialogVisible = false">取消</ElButton>
<ElButton type="danger" :loading="unbindLoading" @click="confirmUnbind">确认解绑</ElButton>
</template>
</ElDialog>
<!-- 批量解绑 -->
<ElDialog
v-model="batchUnbindDialogVisible"
title="批量解除手机号资产关联"
width="520px"
:close-on-click-modal="false"
>
<ElAlert
type="warning"
:closable="false"
:title="`已选择 ${selectedRows.length} 项资产按资产类型与ID去重后执行上限 200`"
style="margin-bottom: 12px"
/>
<ElForm ref="batchFormRef" :model="batchForm" :rules="batchRules" label-width="90px">
<ElFormItem label="解除原因" prop="reason">
<ElInput
v-model="batchForm.reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请填写解除原因1~500 字符)"
/>
</ElFormItem>
<ElFormItem label="二次确认" prop="confirmed">
<ElCheckbox v-model="batchForm.confirmed">我已确认解除上述全部关联关系</ElCheckbox>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="batchUnbindDialogVisible = false">取消</ElButton>
<ElButton type="danger" :loading="batchUnbindLoading" @click="confirmBatchUnbind">
确认批量解绑
</ElButton>
</template>
</ElDialog>
<!-- 批量解绑结果 -->
<ElDialog v-model="batchResultVisible" title="批量解绑结果" width="560px">
<div class="batch-result-summary">
<span>成功 {{ batchResult?.success_count ?? 0 }} </span>
<span class="text-danger">失败 {{ batchResult?.fail_count ?? 0 }} </span>
</div>
<ElTable :data="batchResult?.items || []" max-height="320" size="small" border>
<ElTableColumn prop="asset_type" label="资产类型" width="110" />
<ElTableColumn prop="asset_id" label="资产ID" width="90" />
<ElTableColumn prop="success" label="结果" width="80" />
<ElTableColumn prop="unbound_count" label="解除关系数" width="100" />
<ElTableColumn prop="reason" label="失败原因" show-overflow-tooltip />
</ElTable>
<template #footer>
<ElButton type="primary" @click="batchResultVisible = false">关闭</ElButton>
</template>
</ElDialog>
<!-- CSV 解绑导入 -->
<ElDialog
v-model="importDialogVisible"
title="CSV 解绑导入"
width="520px"
:close-on-click-modal="false"
@closed="handleImportDialogClosed"
>
<div class="import-tips">
<span>模板表头</span>
<ElButton link type="primary" @click="downloadTemplate">下载模板</ElButton>
<div class="import-tips-detail">
资产标识,备注备注可选支持 UTF-8可带 BOM UTF-8 GBK 解码无行数上限
</div>
</div>
<ElForm ref="importFormRef" :model="importForm" :rules="importRules" label-width="90px">
<ElFormItem label="CSV 文件" prop="file">
<ElUpload
ref="uploadRef"
drag
:auto-upload="false"
:limit="1"
accept=".csv"
:on-change="handleFileChange"
:on-exceed="handleFileExceed"
>
<ElIcon class="el-icon--upload"><UploadFilled /></ElIcon>
<div class="el-upload__text">拖拽文件到此处<em>点击选择 .csv 文件</em></div>
</ElUpload>
</ElFormItem>
<ElFormItem label="解除原因" prop="reason">
<ElInput
v-model="importForm.reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请填写任务级解除原因1~500 字符)"
/>
</ElFormItem>
<ElFormItem label="二次确认" prop="confirmed">
<ElCheckbox v-model="importForm.confirmed">我已确认按模板批量解除关联关系</ElCheckbox>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="importDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="importLoading" @click="confirmImport"
>创建导入任务</ElButton
>
</template>
</ElDialog>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
ElMessage,
ElTag,
FormInstance,
FormRules,
UploadFile,
UploadInstance
} from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import { PhoneAssetAssociationService, StorageService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import { normalizeApiError } from '@/utils/business/apiError'
import { RoutesAlias } from '@/router/routesAlias'
import { AUGUST_PERMISSIONS } from '@/config/constants'
import type { SearchFormItem } from '@/types'
import type {
BatchUnbindAssetItem,
BatchUnbindPhoneAssetAssociationResponse,
PhoneAssetAssociation,
PhoneAssetAssociationStatus
} from '@/types/api'
defineOptions({ name: 'PhoneAssetAssociation' })
const router = useRouter()
const { hasAuth } = useAuth()
const perms = AUGUST_PERMISSIONS.phoneAssetAssociation
const canBatchUnbind = computed(() => hasAuth(perms.unbindBatch))
const canImportCreate = computed(() => hasAuth(perms.unbindImportCreate))
const canImportPage = computed(() => hasAuth(perms.unbindImportPage))
const canUnbind = computed(() => hasAuth(perms.unbind))
const loading = ref(false)
const associationList = ref<PhoneAssetAssociation[]>([])
const selectedRows = ref<PhoneAssetAssociation[]>([])
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
const initialSearchState = {
asset_identifier: '',
phone: '',
status: undefined as PhoneAssetAssociationStatus | undefined,
dateRange: [] as string[],
created_at_start: '',
created_at_end: ''
}
const searchForm = reactive({ ...initialSearchState })
const statusOptions = [
{ label: '有效', value: 1 as const },
{ label: '已失效', value: 0 as const }
]
const searchFormItems: SearchFormItem[] = [
{
label: '资产标识',
prop: 'asset_identifier',
type: 'input',
config: { clearable: true, placeholder: 'ICCID / 虚拟号 / IMEI / SN / 接入号(精确匹配)' }
},
{
label: '手机号',
prop: 'phone',
type: 'input',
config: { clearable: true, placeholder: '完整手机号(精确匹配)' }
},
{
label: '关联状态',
prop: 'status',
type: 'select',
config: { clearable: true, placeholder: '全部' },
options: () => statusOptions
},
{
label: '创建时间',
prop: 'dateRange',
type: 'datetimerange',
config: {
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
}
}
]
const columnOptions = [
{ label: '资产类型', prop: 'asset_type' },
{ label: '资产标识', prop: 'asset_identifier' },
{ label: '手机号', prop: 'phone' },
{ label: '建立时间', prop: 'established_at' },
{ label: '来源', prop: 'source' },
{ label: '状态', prop: 'status' },
{ label: '失效时间', prop: 'invalidated_at' },
{ label: '失效方式', prop: 'invalidation_method_name' },
{ label: '失效原因', prop: 'invalidation_reason' }
]
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'asset_type',
label: '资产类型',
width: 100,
formatter: (row: PhoneAssetAssociation) =>
row.asset_type === 'iot_card' ? '物联网卡' : '设备'
},
{
prop: 'asset_identifier',
label: '资产标识',
minWidth: 200,
showOverflowTooltip: true,
formatter: (row: PhoneAssetAssociation) =>
h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: () => goToAsset(row)
},
row.asset_identifier
)
},
{ prop: 'phone', label: '手机号', width: 140 },
{
prop: 'established_at',
label: '建立时间',
minWidth: 170,
formatter: (row: PhoneAssetAssociation) => formatDateTime(row.established_at) || '-'
},
{
prop: 'source',
label: '来源',
width: 120,
formatter: () => 'H5 短信验证'
},
{
prop: 'status',
label: '状态',
width: 90,
formatter: (row: PhoneAssetAssociation) =>
h(
ElTag,
{ type: row.status === 1 ? 'success' : 'info' },
() => row.status_name || (row.status === 1 ? '有效' : '已失效')
)
},
{
prop: 'invalidated_at',
label: '失效时间',
minWidth: 170,
formatter: (row: PhoneAssetAssociation) => formatDateTime(row.invalidated_at) || '-'
},
{
prop: 'invalidation_method_name',
label: '失效方式',
width: 130,
formatter: (row: PhoneAssetAssociation) => row.invalidation_method_name || '-'
},
{
prop: 'invalidation_reason',
label: '失效原因',
minWidth: 160,
showOverflowTooltip: true,
formatter: (row: PhoneAssetAssociation) => row.invalidation_reason || '-'
}
])
// ========== 列表数据 ==========
const syncDateRange = () => {
if (Array.isArray(searchForm.dateRange) && searchForm.dateRange.length === 2) {
searchForm.created_at_start = searchForm.dateRange[0] || ''
searchForm.created_at_end = searchForm.dateRange[1] || ''
} else {
searchForm.created_at_start = ''
searchForm.created_at_end = ''
}
}
const toRfc3339 = (date: string, endOfDay = false) => {
if (!date) return undefined
if (date.includes('T')) return date
return `${date}T${endOfDay ? '23:59:59' : '00:00:00'}+08:00`
}
const getTableData = async () => {
loading.value = true
try {
syncDateRange()
const res = await PhoneAssetAssociationService.getAssociations({
page: pagination.page,
page_size: pagination.pageSize,
asset_identifier: searchForm.asset_identifier || undefined,
phone: searchForm.phone || undefined,
status: searchForm.status,
created_at_start: toRfc3339(searchForm.created_at_start),
created_at_end: toRfc3339(searchForm.created_at_end, true)
})
if (res.code === 0 && res.data) {
associationList.value = res.data.items || []
pagination.total = res.data.total || 0
} else {
ElMessage.error(res.msg || '获取手机号资产关联列表失败')
}
} catch (error) {
console.error('获取手机号资产关联列表失败:', error)
ElMessage.error(normalizeApiError(error).message || '获取手机号资产关联列表失败')
} finally {
loading.value = false
}
}
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
getTableData()
}
const handleSearch = () => {
pagination.page = 1
getTableData()
}
const handleRefresh = () => {
getTableData()
}
const handleSizeChange = (pageSize: number) => {
pagination.pageSize = pageSize
getTableData()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
getTableData()
}
const handleSelectionChange = (rows: PhoneAssetAssociation[]) => {
selectedRows.value = rows
}
const goToAsset = (row: PhoneAssetAssociation) => {
router.push({
path: RoutesAlias.AssetInformation,
query:
row.asset_type === 'iot_card'
? { iccid: row.asset_identifier }
: { virtual_no: row.asset_identifier }
})
}
const getActions = (row: PhoneAssetAssociation) => {
const actions: any[] = []
if (row.status === 1 && canUnbind.value) {
actions.push({ label: '解绑', handler: () => openUnbindDialog(row), type: 'danger' })
}
return actions
}
// ========== 单条解绑 ==========
const unbindDialogVisible = ref(false)
const unbindLoading = ref(false)
const unbindFormRef = ref<FormInstance>()
const unbindForm = reactive<{
id: number
asset_identifier: string
phone: string
reason: string
confirmed: boolean
}>({
id: 0,
asset_identifier: '',
phone: '',
reason: '',
confirmed: false
})
const unbindRules: FormRules = {
reason: [
{ required: true, message: '请填写解除原因', trigger: 'blur' },
{ min: 1, max: 500, message: '解除原因长度为 1~500 字符', trigger: 'blur' }
],
confirmed: [
{
validator: (_rule, value: boolean, callback) => {
if (!value) callback(new Error('请先勾选二次确认'))
else callback()
},
trigger: 'change'
}
]
}
const openUnbindDialog = (row: PhoneAssetAssociation) => {
unbindForm.id = row.id
unbindForm.asset_identifier = row.asset_identifier
unbindForm.phone = row.phone
unbindForm.reason = ''
unbindForm.confirmed = false
unbindDialogVisible.value = true
}
const confirmUnbind = async () => {
if (!(await unbindFormRef.value?.validate().catch(() => false))) return
unbindLoading.value = true
try {
const res = await PhoneAssetAssociationService.unbindAssociation(
unbindForm.id,
unbindForm.reason.trim(),
unbindForm.confirmed
)
if (res.code !== 0) {
ElMessage.error(res.msg || '解除关联失败')
return
}
ElMessage.success('解除关联成功')
unbindDialogVisible.value = false
getTableData()
} catch (error) {
console.error('解除关联失败:', error)
ElMessage.error(normalizeApiError(error).message || '解除关联失败')
} finally {
unbindLoading.value = false
}
}
// ========== 批量解绑 ==========
const batchUnbindDialogVisible = ref(false)
const batchUnbindLoading = ref(false)
const batchFormRef = ref<FormInstance>()
const batchForm = reactive({ reason: '', confirmed: false, assets: [] as BatchUnbindAssetItem[] })
const batchResult = ref<BatchUnbindPhoneAssetAssociationResponse | null>(null)
const batchResultVisible = ref(false)
const batchRules: FormRules = {
reason: [
{ required: true, message: '请填写解除原因', trigger: 'blur' },
{ min: 1, max: 500, message: '解除原因长度为 1~500 字符', trigger: 'blur' }
],
confirmed: [
{
validator: (_rule, value: boolean, callback) => {
if (!value) callback(new Error('请先勾选二次确认'))
else callback()
},
trigger: 'change'
}
]
}
const buildUniqueAssets = (rows: PhoneAssetAssociation[]): BatchUnbindAssetItem[] => {
const map = new Map<string, BatchUnbindAssetItem>()
rows.forEach((row) => {
if (row.status !== 1) return
const key = `${row.asset_type}:${row.asset_id}`
if (!map.has(key)) map.set(key, { asset_type: row.asset_type, asset_id: row.asset_id })
})
return Array.from(map.values()).slice(0, 200)
}
const openBatchUnbindDialog = () => {
batchForm.assets = buildUniqueAssets(selectedRows.value)
if (batchForm.assets.length === 0) {
ElMessage.warning('请选择有效关联的资产')
return
}
batchForm.reason = ''
batchForm.confirmed = false
batchResult.value = null
batchUnbindDialogVisible.value = true
}
const confirmBatchUnbind = async () => {
if (!(await batchFormRef.value?.validate().catch(() => false))) return
batchUnbindLoading.value = true
try {
const res = await PhoneAssetAssociationService.batchUnbind({
assets: batchForm.assets,
reason: batchForm.reason.trim(),
confirmed: batchForm.confirmed
})
if (res.code !== 0 || !res.data) {
ElMessage.error(res.msg || '批量解绑失败')
return
}
batchResult.value = res.data
batchUnbindDialogVisible.value = false
batchResultVisible.value = true
getTableData()
} catch (error) {
console.error('批量解绑失败:', error)
ElMessage.error(normalizeApiError(error).message || '批量解绑失败')
} finally {
batchUnbindLoading.value = false
}
}
// ========== CSV 解绑导入 ==========
const importDialogVisible = ref(false)
const importLoading = ref(false)
const uploadRef = ref<UploadInstance>()
const importFormRef = ref<FormInstance>()
const importForm = reactive({
file: null as File | null,
reason: '',
confirmed: false
})
const importRules: FormRules = {
file: [
{
validator: (_rule, _value, callback) => {
if (!importForm.file) callback(new Error('请选择 CSV 文件'))
else callback()
},
trigger: 'change'
}
],
reason: [
{ required: true, message: '请填写任务级解除原因', trigger: 'blur' },
{ min: 1, max: 500, message: '解除原因长度为 1~500 字符', trigger: 'blur' }
],
confirmed: [
{
validator: (_rule, value: boolean, callback) => {
if (!value) callback(new Error('请先勾选二次确认'))
else callback()
},
trigger: 'change'
}
]
}
const openImportDialog = () => {
importForm.file = null
importForm.reason = ''
importForm.confirmed = false
importDialogVisible.value = true
}
const handleImportDialogClosed = () => {
uploadRef.value?.clearFiles()
}
const handleFileChange = (file: UploadFile) => {
const rawFile = file.raw
if (!rawFile) return
if (!rawFile.name.toLowerCase().endsWith('.csv')) {
ElMessage.error('解绑导入仅支持 .csv 文件')
uploadRef.value?.clearFiles()
importForm.file = null
return
}
importForm.file = rawFile
}
const handleFileExceed = () => {
ElMessage.warning('仅支持上传一个 CSV 文件')
}
const downloadTemplate = () => {
const content = '\uFEFF资产标识,备注\n'
const blob = new Blob([content], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '手机号资产解绑导入模板.csv'
link.click()
URL.revokeObjectURL(url)
}
const goUnbindImportTasks = () => {
router.push(RoutesAlias.PhoneAssetUnbindImportTasks)
}
const confirmImport = async () => {
if (!(await importFormRef.value?.validate().catch(() => false))) return
const file = importForm.file
if (!file) return
importLoading.value = true
try {
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: 'text/csv',
purpose: 'phone_unbind_import'
})
if (uploadUrlRes.code !== 0 || !uploadUrlRes.data) {
ElMessage.error(uploadUrlRes.msg || '获取上传地址失败')
return
}
const { upload_url, file_key } = uploadUrlRes.data
await StorageService.uploadFile(upload_url, file, 'text/csv')
const res = await PhoneAssetAssociationService.createUnbindImport({
file_key,
reason: importForm.reason.trim(),
confirmed: importForm.confirmed
})
if (res.code !== 0 || !res.data) {
ElMessage.error(res.msg || '创建解绑导入任务失败')
return
}
ElMessage.success(`解绑导入任务已创建!任务编号:${res.data.task_no}`)
importDialogVisible.value = false
goUnbindImportTasks()
} catch (error) {
console.error('创建解绑导入任务失败:', error)
ElMessage.error(normalizeApiError(error).message || '创建解绑导入任务失败')
} finally {
importLoading.value = false
}
}
onMounted(() => {
getTableData()
})
</script>
<style scoped lang="scss">
.phone-asset-association-page {
height: 100%;
}
.batch-result-summary {
margin-bottom: 12px;
font-size: 14px;
.text-danger {
color: var(--el-color-danger);
margin-left: 16px;
}
}
.import-tips {
margin-bottom: 12px;
font-size: 13px;
color: var(--el-text-color-secondary);
.import-tips-detail {
margin-top: 4px;
line-height: 1.6;
}
}
</style>

View File

@@ -0,0 +1,278 @@
<template>
<ArtTableFullScreen>
<div class="unbind-import-task-detail-page" id="table-full-screen">
<ElCard shadow="never" class="art-table-card">
<div class="detail-header">
<ElButton @click="goBack">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">解绑导入任务详情</h2>
</div>
<div v-if="loading && !taskDetail" class="detail-loading">
<ElIcon class="is-loading" :size="36">
<Loading />
</ElIcon>
<div>加载中...</div>
</div>
<template v-else-if="taskDetail">
<DetailPage :sections="detailSections" :data="taskDetail" />
<div class="row-table-title">逐行结果</div>
<ElTable :data="taskDetail.items || []" border stripe size="default" max-height="460">
<ElTableColumn prop="line" label="行号" width="80" />
<ElTableColumn
prop="asset_identifier"
label="资产标识(原文)"
min-width="180"
show-overflow-tooltip
/>
<ElTableColumn prop="asset_type" label="资产类型" width="110">
<template #default="{ row }">
{{ row.asset_type ? (row.asset_type === 'iot_card' ? '物联网卡' : '设备') : '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="asset_id" label="资产ID" width="100">
<template #default="{ row }">
{{ row.asset_id ? row.asset_id : '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="status" label="行状态" width="90">
<template #default="{ row }">
<ElTag :type="row.status === 3 ? 'success' : 'danger'" size="small">
{{ row.status_name || (row.status === 3 ? '成功' : '失败') }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn
prop="associated_phones"
label="解绑时关联手机号"
min-width="200"
show-overflow-tooltip
>
<template #default="{ row }">
{{ (row.associated_phones || []).join('、') || '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="unbound_count" label="解除关系数" width="100" />
<ElTableColumn prop="reason" label="失败原因" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
{{ row.reason || '-' }}
</template>
</ElTableColumn>
</ElTable>
</template>
<ElEmpty v-else description="暂无详情" />
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElButton, ElCard, ElEmpty, ElIcon, ElMessage, ElTag } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { PhoneAssetAssociationService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { formatDateTime } from '@/utils/business/format'
import { normalizeApiError } from '@/utils/business/apiError'
import { RoutesAlias } from '@/router/routesAlias'
import { AUGUST_PERMISSIONS } from '@/config/constants'
import type {
PhoneAssetUnbindImportTaskDetail,
PhoneAssetUnbindImportTaskStatus
} from '@/types/api'
defineOptions({ name: 'PhoneAssetUnbindImportTaskDetail' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const taskId = computed(() => Number(route.query.id || route.params.id || 0))
const loading = ref(false)
const taskDetail = ref<PhoneAssetUnbindImportTaskDetail | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
const getStatusTagType = (status?: PhoneAssetUnbindImportTaskStatus) => {
const map: Record<number, 'info' | 'warning' | 'success' | 'danger'> = {
1: 'info',
2: 'warning',
3: 'success',
4: 'danger'
}
return status ? map[status] || 'info' : 'info'
}
const fetchDetail = async () => {
if (!taskId.value) {
ElMessage.error('缺少任务ID')
return
}
loading.value = true
try {
const res = await PhoneAssetAssociationService.getUnbindImportTaskDetail(taskId.value)
if (res.code === 403) {
ElMessage.error(res.msg || '无权限查看该任务详情')
return
}
if (res.code !== 0 || !res.data) {
ElMessage.error(res.msg || '获取解绑导入任务详情失败')
return
}
taskDetail.value = res.data
startPollingIfNeeded()
} catch (error) {
console.error('获取解绑导入任务详情失败:', error)
ElMessage.error(normalizeApiError(error).message || '获取解绑导入任务详情失败')
} finally {
loading.value = false
}
}
const startPollingIfNeeded = () => {
const status = taskDetail.value?.status
if (status === 1 || status === 2) {
if (!pollTimer) {
pollTimer = setInterval(fetchDetail, 5000)
}
} else {
stopPolling()
}
}
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
const detailSections = computed((): DetailSection[] => {
const data = taskDetail.value
if (!data) return []
return [
{
title: '任务基本信息',
fields: [
{ label: '任务编号', prop: 'task_no', formatter: (value: string) => value || '-' },
{ label: '文件名', prop: 'file_name', formatter: (value: string) => value || '-' },
{
label: '任务状态',
render: (task: PhoneAssetUnbindImportTaskDetail) =>
h(ElTag, { type: getStatusTagType(task.status) }, () => task.status_name || '-')
},
{
label: '任务级解绑原因',
prop: 'unbind_reason',
fullWidth: true,
formatter: (value: string) => value || '-'
},
{
label: '创建人',
prop: 'creator_name',
formatter: (value: string) => value || '-'
},
{
label: '创建时间',
prop: 'created_at',
formatter: (value: string) => formatDateTime(value) || '-'
},
{
label: '开始时间',
prop: 'started_at',
formatter: (value: string) => formatDateTime(value) || '-'
},
{
label: '完成时间',
prop: 'completed_at',
formatter: (value: string) => formatDateTime(value) || '-'
},
{
label: '任务级错误',
prop: 'error_message',
fullWidth: true,
formatter: (value: string) => value || '-'
}
]
},
{
title: '处理统计',
fields: [
{
label: '数据行总数',
prop: 'total_count',
formatter: (value: number) => String(value ?? 0)
},
{
label: '成功行数',
prop: 'success_count',
formatter: (value: number) => String(value ?? 0)
},
{
label: '失败行数',
prop: 'fail_count',
formatter: (value: number) => String(value ?? 0)
}
]
}
]
})
const goBack = () => {
router.push(RoutesAlias.PhoneAssetUnbindImportTasks)
}
onMounted(() => {
if (!hasAuth(AUGUST_PERMISSIONS.phoneAssetAssociation.unbindImportDetail)) {
ElMessage.warning('您没有查看解绑导入任务详情的权限')
goBack()
return
}
fetchDetail()
})
onBeforeUnmount(() => {
stopPolling()
})
</script>
<style scoped lang="scss">
.unbind-import-task-detail-page {
height: 100%;
}
.detail-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
.detail-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
}
.detail-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 60px 0;
color: var(--el-text-color-secondary);
}
.row-table-title {
margin: 20px 0 12px;
font-size: 15px;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,295 @@
<template>
<ArtTableFullScreen>
<div class="unbind-import-tasks-page" id="table-full-screen">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
label-width="110"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #actions>
<ElButton @click="goAssociationList">返回关联列表</ElButton>
</template>
</ArtTableHeader>
<ArtTable
:loading="loading"
:data="taskList"
:currentPage="pagination.page"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="90"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElTag } from 'element-plus'
import { PhoneAssetAssociationService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import { normalizeApiError } from '@/utils/business/apiError'
import { RoutesAlias } from '@/router/routesAlias'
import { AUGUST_PERMISSIONS } from '@/config/constants'
import type { SearchFormItem } from '@/types'
import type { PhoneAssetUnbindImportTask, PhoneAssetUnbindImportTaskStatus } from '@/types/api'
defineOptions({ name: 'PhoneAssetUnbindImportTasks' })
const router = useRouter()
const { hasAuth } = useAuth()
const perms = AUGUST_PERMISSIONS.phoneAssetAssociation
const canDetail = computed(() => hasAuth(perms.unbindImportDetail))
const loading = ref(false)
const taskList = ref<PhoneAssetUnbindImportTask[]>([])
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
const initialSearchState = {
status: undefined as PhoneAssetUnbindImportTaskStatus | undefined
}
const searchForm = reactive({ ...initialSearchState })
const statusOptions = [
{ label: '待处理', value: 1 },
{ label: '处理中', value: 2 },
{ label: '已完成', value: 3 },
{ label: '失败', value: 4 }
]
const searchFormItems: SearchFormItem[] = [
{
label: '任务状态',
prop: 'status',
type: 'select',
config: { clearable: true, placeholder: '全部' },
options: () => statusOptions
}
]
const columnOptions = [
{ label: '任务编号', prop: 'task_no' },
{ label: '文件名', prop: 'file_name' },
{ label: '状态', prop: 'status' },
{ label: '总行数', prop: 'total_count' },
{ label: '成功行数', prop: 'success_count' },
{ label: '失败行数', prop: 'fail_count' },
{ label: '任务级原因', prop: 'unbind_reason' },
{ label: '创建人', prop: 'creator_name' },
{ label: '创建时间', prop: 'created_at' },
{ label: '开始时间', prop: 'started_at' },
{ label: '完成时间', prop: 'completed_at' },
{ label: '任务级错误', prop: 'error_message' }
]
const getStatusTagType = (status?: PhoneAssetUnbindImportTaskStatus) => {
const map: Record<number, 'info' | 'warning' | 'success' | 'danger'> = {
1: 'info',
2: 'warning',
3: 'success',
4: 'danger'
}
return status ? map[status] || 'info' : 'info'
}
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'task_no',
label: '任务编号',
minWidth: 170,
showOverflowTooltip: true,
formatter: (row: PhoneAssetUnbindImportTask) =>
h(
'span',
{
class: 'task-no-link',
onClick: () => goDetail(row)
},
row.task_no || '-'
)
},
{
prop: 'file_name',
label: '文件名',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: PhoneAssetUnbindImportTask) => row.file_name || '-'
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: PhoneAssetUnbindImportTask) =>
h(ElTag, { type: getStatusTagType(row.status) }, () => row.status_name || '-')
},
{
prop: 'total_count',
label: '总行数',
width: 90,
formatter: (row: PhoneAssetUnbindImportTask) => row.total_count ?? 0
},
{
prop: 'success_count',
label: '成功行数',
width: 90,
formatter: (row: PhoneAssetUnbindImportTask) => row.success_count ?? 0
},
{
prop: 'fail_count',
label: '失败行数',
width: 90,
formatter: (row: PhoneAssetUnbindImportTask) => row.fail_count ?? 0
},
{
prop: 'unbind_reason',
label: '任务级原因',
minWidth: 160,
showOverflowTooltip: true,
formatter: (row: PhoneAssetUnbindImportTask) => row.unbind_reason || '-'
},
{
prop: 'creator_name',
label: '创建人',
width: 120,
formatter: (row: PhoneAssetUnbindImportTask) => row.creator_name || '-'
},
{
prop: 'created_at',
label: '创建时间',
minWidth: 170,
formatter: (row: PhoneAssetUnbindImportTask) => formatDateTime(row.created_at) || '-'
},
{
prop: 'started_at',
label: '开始时间',
minWidth: 170,
formatter: (row: PhoneAssetUnbindImportTask) => formatDateTime(row.started_at) || '-'
},
{
prop: 'completed_at',
label: '完成时间',
minWidth: 170,
formatter: (row: PhoneAssetUnbindImportTask) => formatDateTime(row.completed_at) || '-'
},
{
prop: 'error_message',
label: '任务级错误',
minWidth: 160,
showOverflowTooltip: true,
formatter: (row: PhoneAssetUnbindImportTask) => row.error_message || '-'
}
])
const getTableData = async () => {
loading.value = true
try {
const res = await PhoneAssetAssociationService.getUnbindImportTasks({
page: pagination.page,
page_size: pagination.pageSize,
status: searchForm.status
})
if (res.code === 0 && res.data) {
taskList.value = res.data.items || []
pagination.total = res.data.total || 0
} else {
ElMessage.error(res.msg || '获取解绑导入任务列表失败')
}
} catch (error) {
console.error('获取解绑导入任务列表失败:', error)
ElMessage.error(normalizeApiError(error).message || '获取解绑导入任务列表失败')
} finally {
loading.value = false
}
}
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
getTableData()
}
const handleSearch = () => {
pagination.page = 1
getTableData()
}
const handleRefresh = () => {
getTableData()
}
const handleSizeChange = (pageSize: number) => {
pagination.pageSize = pageSize
getTableData()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
getTableData()
}
const goDetail = (row: PhoneAssetUnbindImportTask) => {
if (!canDetail.value) {
ElMessage.warning('您没有查看解绑导入任务详情的权限')
return
}
router.push({
path: RoutesAlias.PhoneAssetUnbindImportTaskDetail,
query: { id: String(row.id) }
})
}
const getActions = (row: PhoneAssetUnbindImportTask) => {
const actions: any[] = []
if (canDetail.value) {
actions.push({ label: '详情', handler: () => goDetail(row), type: 'primary' })
}
return actions
}
const goAssociationList = () => {
router.push(RoutesAlias.PhoneAssetAssociation)
}
onMounted(() => {
getTableData()
})
</script>
<style scoped lang="scss">
.unbind-import-tasks-page {
height: 100%;
}
:deep(.task-no-link) {
color: var(--el-color-primary);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
</style>

View File

@@ -340,7 +340,8 @@
{
prop: 'to_owner_name',
label: '目标所有者',
width: 150,
width: 160,
showOverflowTooltip: true,
formatter: (row: AssetAllocationRecord) => row.to_owner_name || '-'
},
{

View File

@@ -54,9 +54,24 @@
<ElTabs v-model="activeTab" class="detail-tabs">
<!-- 佣金明细 Tab -->
<ElTabPane label="佣金明细" name="commission">
<div class="commission-export-actions">
<ElButton
v-if="hasAuth('commission_record:export')"
@click="commissionExportDialogVisible = true"
>
导出
</ElButton>
</div>
<ExportTaskCreateDialog
v-model="commissionExportDialogVisible"
scene="commission_record"
:query="commissionExportQuery"
confirm-permission="commission_record:export"
title="导出佣金记录"
/>
<ArtTable
ref="commissionTableRef"
row-key="id"
:row-key="getCommissionRowKey"
:loading="commissionLoading"
:data="commissionRecords"
:currentPage="commissionPagination.page"
@@ -70,14 +85,30 @@
<template #default>
<ElTableColumn label="佣金金额" prop="amount" width="120">
<template #default="scope">
<span style="font-weight: 500; color: var(--el-color-success)">
<span
:class="Number(scope.row.amount) < 0 ? 'amount-negative' : 'amount-positive'"
>
{{ formatMoney(scope.row.amount) }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="入账后余额" prop="balance_after" width="120">
<ElTableColumn label="记录来源" prop="source" width="150">
<template #default="scope">
{{ formatMoney(scope.row.balance_after) }}
<div class="record-source-cell">
<ElTag :type="commissionRecordSourceMeta(scope.row).type" size="small">
{{ commissionRecordSourceMeta(scope.row).label }}
</ElTag>
<ElTag v-if="isCommissionClawbackRecord(scope.row)" type="danger" size="small">
不可提现
</ElTag>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="入账后余额" prop="balance_after" width="130">
<template #default="scope">
<span :class="{ 'amount-negative': Number(scope.row.balance_after) < 0 }">
{{ formatMoney(scope.row.balance_after) }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="佣金来源" prop="commission_source" width="120">
@@ -133,21 +164,27 @@
{{ formatDateTime(scope.row.created_at) }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="150" fixed="right">
<ElTableColumn label="操作" width="210" fixed="right">
<template #default="scope">
<div v-if="scope.row.status === 99" style="display: flex; gap: 8px">
<div style="display: flex; gap: 8px; align-items: center">
<ArtButtonTable
text="入账"
iconColor="#67C23A"
@click="handleResolveCommission(scope.row, 'release')"
/>
<ArtButtonTable
text="作废"
iconColor="#F56C6C"
@click="handleResolveCommission(scope.row, 'invalidate')"
text="详情"
iconColor="#409EFF"
@click="openCommissionRecordDetail(scope.row)"
/>
<template v-if="scope.row.status === 99">
<ArtButtonTable
text="入账"
iconColor="#67C23A"
@click="handleResolveCommission(scope.row, 'release')"
/>
<ArtButtonTable
text="作废"
iconColor="#F56C6C"
@click="handleResolveCommission(scope.row, 'invalidate')"
/>
</template>
</div>
<span v-else>-</span>
</template>
</ElTableColumn>
</template>
@@ -429,6 +466,14 @@
</div>
</template>
</ElDialog>
<!-- 佣金明细详情 -->
<CommissionRecordDetailDialog
v-model="commissionDetailVisible"
:shop-id="currentShop?.shop_id"
:record-id="commissionDetailId"
:source="commissionDetailSource"
/>
</div>
</template>
@@ -445,7 +490,8 @@
WithdrawalRequestItem,
CommissionResolveAction,
MainWalletTransactionItem,
MainWalletTransactionQueryParams
MainWalletTransactionQueryParams,
ShopCommissionRecordSource
} from '@/types/api/commission'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
@@ -456,10 +502,14 @@
CommissionStatusMap,
WithdrawalStatusMap,
WithdrawalMethodMap,
CommissionSourceMap
CommissionSourceMap,
COMMISSION_RECORD_SOURCE_MAP,
getCommissionRecordRowKey,
isCommissionClawbackRecord
} from '@/config/constants/commission'
import { RoutesAlias } from '@/router/routesAlias'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import CommissionRecordDetailDialog from '@/components/business/CommissionRecordDetailDialog.vue'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { resolveFinanceAuditTarget } from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
@@ -518,6 +568,27 @@
total: 0
})
// 佣金明细详情
const commissionDetailVisible = ref(false)
const commissionDetailId = ref<number | null>(null)
const commissionDetailSource = ref<ShopCommissionRecordSource>('original')
const getCommissionRowKey = getCommissionRecordRowKey
const commissionRecordSourceMeta = (row: ShopCommissionRecordItem) =>
COMMISSION_RECORD_SOURCE_MAP[isCommissionClawbackRecord(row) ? 'clawback' : 'original']
const openCommissionRecordDetail = (row: ShopCommissionRecordItem) => {
commissionDetailId.value = row.id
commissionDetailSource.value = isCommissionClawbackRecord(row) ? 'clawback' : 'original'
commissionDetailVisible.value = true
}
// 导出佣金记录(仅支持 shop_id 筛选)
const commissionExportDialogVisible = ref(false)
const commissionExportQuery = computed(() => ({
shop_id: currentShop.value?.shop_id
}))
// 提现记录状态
const withdrawalLoading = ref(false)
const withdrawalTableRef = ref()
@@ -1204,4 +1275,26 @@
:deep(.el-table__row.table-row-with-context-menu) {
cursor: pointer;
}
.amount-positive {
font-weight: 500;
color: var(--el-color-success);
}
.amount-negative {
font-weight: 500;
color: var(--el-color-danger);
}
.record-source-cell {
display: inline-flex;
gap: 4px;
align-items: center;
}
.commission-export-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
</style>

View File

@@ -28,6 +28,11 @@
v-permission="'my_commission:add'"
>发起提现</ElButton
>
<ElButton
v-permission="'commission_record:export'"
@click="exportDialogVisible = true"
>导出</ElButton
>
<div class="commission-summary">
<div class="commission-summary-item">
@@ -73,7 +78,7 @@
<!-- 表格 -->
<ArtTable
ref="commissionTableRef"
row-key="id"
:row-key="getCommissionRowKey"
:loading="commissionLoading"
:data="commissionList"
:currentPage="commissionPagination.page"
@@ -122,6 +127,8 @@
:currentPage="withdrawalPagination.page"
:pageSize="withdrawalPagination.pageSize"
:total="withdrawalPagination.total"
:actions="getWithdrawalActions"
:actionsWidth="150"
:marginTop="10"
:height="500"
@size-change="handleWithdrawalSizeChange"
@@ -136,6 +143,49 @@
</template>
</ArtTable>
</ElTabPane>
<!-- 提现资料资格 -->
<ElTabPane label="提现资料" name="qualification">
<ArtTableHeader
:columnList="qualificationColumnOptions"
v-model:columns="qualificationColumnChecks"
@refresh="getQualificationList"
style="margin-top: 20px"
>
<template #left>
<ElButton
type="primary"
v-permission="'my_commission:add'"
@click="showQualificationDialog"
>
提交/更新资料
</ElButton>
</template>
</ArtTableHeader>
<ArtTable
ref="qualificationTableRef"
row-key="id"
:loading="qualificationLoading"
:data="qualificationList"
:currentPage="qualificationPagination.page"
:pageSize="qualificationPagination.pageSize"
:total="qualificationPagination.total"
:actions="getQualificationActions"
:actionsWidth="100"
:marginTop="10"
:height="500"
@size-change="handleQualificationSizeChange"
@current-change="handleQualificationCurrentChange"
>
<template #default>
<ElTableColumn
v-for="col in qualificationColumns"
:key="col.prop || col.type"
v-bind="col"
/>
</template>
</ArtTable>
</ElTabPane>
</ElTabs>
</ElCard>
@@ -234,13 +284,259 @@
</div>
</template>
</ElDialog>
<!-- 提交/更新提现资料资格对话框 -->
<ElDialog
v-model="qualificationDialogVisible"
title="提现资料资格"
width="720px"
destroy-on-close
>
<ElForm
ref="qualificationFormRef"
:model="qualificationForm"
:rules="qualificationRules"
label-width="110px"
>
<ElFormItem label="主体类型" prop="subject_type">
<ElRadioGroup v-model="qualificationForm.subject_type">
<ElRadio label="enterprise">企业</ElRadio>
<ElRadio label="individual">个人</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="主体证件号" prop="subject_code">
<ElInput
v-model="qualificationForm.subject_code"
placeholder="统一社会信用代码/证件号"
maxlength="50"
/>
</ElFormItem>
<ElFormItem label="法人身份证号" prop="legal_person_id_card">
<ElInput
v-model="qualificationForm.legal_person_id_card"
placeholder="请输入法人/经营者身份证号"
maxlength="18"
/>
</ElFormItem>
<ElFormItem label="合同" prop="contract_file_key">
<VoucherUpload
v-model="qualificationForm.contract_file_key"
voucher-name="合同"
:max-count="1"
accept=".pdf,.jpg,.jpeg,.png"
/>
</ElFormItem>
<ElFormItem label="身份证正面" prop="id_card_front_file_key">
<VoucherUpload
v-model="qualificationForm.id_card_front_file_key"
voucher-name="身份证正面"
:max-count="1"
accept=".jpg,.jpeg,.png"
/>
</ElFormItem>
<ElFormItem label="身份证背面" prop="id_card_back_file_key">
<VoucherUpload
v-model="qualificationForm.id_card_back_file_key"
voucher-name="身份证背面"
:max-count="1"
accept=".jpg,.jpeg,.png"
/>
</ElFormItem>
<ElFormItem label="营业执照">
<VoucherUpload
v-model="qualificationForm.business_license_file_key"
voucher-name="营业执照"
:max-count="1"
accept=".jpg,.jpeg,.png"
/>
</ElFormItem>
<ElFormItem label="门头照">
<VoucherUpload
v-model="qualificationForm.shop_front_file_key"
voucher-name="门头照"
:max-count="1"
accept=".jpg,.jpeg,.png"
/>
</ElFormItem>
<ElFormItem label="发票">
<VoucherUpload
v-model="qualificationForm.invoice_file_key"
voucher-name="发票"
:max-count="1"
accept=".pdf,.jpg,.jpeg,.png"
/>
</ElFormItem>
<div class="qualification-row">
<ElFormItem label="发票抬头">
<ElInput
v-model="qualificationForm.invoice_title"
placeholder="选填"
maxlength="100"
/>
</ElFormItem>
<ElFormItem label="发票主体证件号">
<ElInput
v-model="qualificationForm.invoice_subject_code"
placeholder="选填"
maxlength="50"
/>
</ElFormItem>
</div>
</ElForm>
<template #footer>
<ElButton @click="qualificationDialogVisible = false">取消</ElButton>
<ElButton
type="primary"
:loading="qualificationSubmitting"
@click="handleSubmitQualification"
>
提交
</ElButton>
</template>
</ElDialog>
<!-- 提现详情抽屉 -->
<ElDrawer v-model="detailDrawerVisible" title="提现申请详情" size="520px">
<div v-loading="detailLoading" class="withdrawal-detail">
<template v-if="withdrawalDetail">
<ElDescriptions :column="1" border>
<ElDescriptionsItem label="提现单号">{{ withdrawalDetail.withdrawal_no }}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">{{ withdrawalDetail.status_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="提现金额">{{ formatMoney(withdrawalDetail.amount) }}</ElDescriptionsItem>
<ElDescriptionsItem label="实际到账">{{ formatMoney(withdrawalDetail.actual_amount) }}</ElDescriptionsItem>
<ElDescriptionsItem label="手续费">{{ formatMoney(withdrawalDetail.fee) }}</ElDescriptionsItem>
<ElDescriptionsItem label="提现方式">
{{ withdrawalMethodLabel(withdrawalDetail.withdrawal_method) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="收款账户">
{{ withdrawalDetail.account_name }} / {{ withdrawalDetail.account_number }}
</ElDescriptionsItem>
<ElDescriptionsItem label="驳回原因">{{ withdrawalDetail.reject_reason || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="异常标记">
<ElTag :type="withdrawalDetail.anomaly_flag ? 'danger' : 'success'">
{{ withdrawalDetail.anomaly_name }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem v-if="withdrawalDetail.anomaly_reason" label="异常原因">
{{ withdrawalDetail.anomaly_reason }}
</ElDescriptionsItem>
<ElDescriptionsItem label="尝试记录">
<template v-if="withdrawalDetail.attempts && withdrawalDetail.attempts.length > 0">
<div
v-for="attempt in withdrawalDetail.attempts"
:key="attempt.attempt"
class="attempt-item"
>
<span> {{ attempt.attempt }} </span>
<ElTag size="small">{{ attempt.status_name }}</ElTag>
<span v-if="attempt.reject_reason" class="attempt-reason">
{{ attempt.reject_reason }}
</span>
<span class="attempt-time">{{ formatDateTime(attempt.created_at) }}</span>
</div>
</template>
<span v-else>-</span>
</ElDescriptionsItem>
</ElDescriptions>
</template>
</div>
</ElDrawer>
<!-- 重新提交被驳回的提现 -->
<ElDialog
v-model="resubmitDialogVisible"
title="重新提交提现"
width="560px"
destroy-on-close
>
<ElForm
ref="resubmitFormRef"
:model="resubmitForm"
:rules="resubmitRules"
label-width="100px"
>
<ElFormItem label="提现金额" prop="amount">
<ElInputNumber
v-model="resubmitForm.amount"
:min="1"
:max="withdrawalAmountMax"
:precision="2"
:step="1"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="提现方式" prop="withdrawal_method">
<ElRadioGroup v-model="resubmitForm.withdrawal_method">
<ElRadio :label="WithdrawalMethod.ALIPAY">支付宝</ElRadio>
<ElRadio :label="WithdrawalMethod.WECHAT">微信</ElRadio>
<ElRadio :label="WithdrawalMethod.BANK">银行卡</ElRadio>
</ElRadioGroup>
</ElFormItem>
<template v-if="resubmitForm.withdrawal_method !== WithdrawalMethod.BANK">
<ElFormItem label="账户名" prop="account_name">
<ElInput v-model="resubmitForm.account_name" placeholder="请输入账户名" />
</ElFormItem>
<ElFormItem label="账号" prop="account_number">
<ElInput v-model="resubmitForm.account_number" placeholder="请输入账号" />
</ElFormItem>
</template>
<template v-else>
<ElFormItem label="银行名称" prop="bank_name">
<ElSelect v-model="resubmitForm.bank_name" placeholder="请选择银行" style="width: 100%">
<ElOption v-for="bank in bankOptions" :key="bank" :label="bank" :value="bank" />
</ElSelect>
</ElFormItem>
<ElFormItem label="账户名" prop="account_name">
<ElInput v-model="resubmitForm.account_name" placeholder="请输入账户名" />
</ElFormItem>
<ElFormItem label="卡号" prop="account_number">
<ElInput v-model="resubmitForm.account_number" placeholder="请输入银行卡号" />
</ElFormItem>
</template>
<ElFormItem label="发票附件">
<VoucherUpload
v-model="resubmitForm.invoice_keys"
voucher-name="发票"
:max-count="5"
accept=".pdf,.jpg,.jpeg,.png"
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="resubmitDialogVisible = false">取消</ElButton>
<ElButton
type="primary"
:loading="resubmitSubmitting"
@click="handleSubmitResubmit"
>
提交
</ElButton>
</template>
</ElDialog>
<!-- 佣金明细详情 -->
<CommissionRecordDetailDialog
v-model="commissionDetailVisible"
:shop-id="currentShopId"
:record-id="commissionDetailId"
:source="commissionDetailSource"
/>
<!-- 导出佣金记录 -->
<ExportTaskCreateDialog
v-model="exportDialogVisible"
scene="commission_record"
:query="exportQuery"
confirm-permission="commission_record:export"
title="导出佣金记录"
/>
</div>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { CommissionService, OrderService } from '@/api/modules'
import { ElMessage, ElTag } from 'element-plus'
import { ElButton, ElMessage, ElMessageBox, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type {
MyCommissionSummary,
@@ -249,11 +545,24 @@
CommissionRecordQueryParams,
WithdrawalRequestQueryParams,
SubmitWithdrawalParams,
WithdrawalStatus
WithdrawalQualificationItem,
WithdrawalQualificationSubmitParams,
WithdrawalRequestDetail,
ResubmitWithdrawalRequestParams,
ShopCommissionRecordSource,
} from '@/types/api/commission'
import { WithdrawalMethod } from '@/types/api/commission'
import { WithdrawalMethod, WithdrawalStatus } from '@/types/api/commission'
import type { Order, OrderQueryParams } from '@/types/api/order'
import { WithdrawalStatusMap, WithdrawalMethodMap } from '@/config/constants/commission'
import {
WithdrawalStatusMap,
WithdrawalMethodMap,
COMMISSION_RECORD_SOURCE_MAP,
COMMISSION_RECORD_STATUS_OPTIONS,
getCommissionRecordRowKey,
isCommissionClawbackRecord
} from '@/config/constants/commission'
import CommissionRecordDetailDialog from '@/components/business/CommissionRecordDetailDialog.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useUserStore } from '@/store/modules/user'
@@ -349,12 +658,10 @@
label: '结算状态',
prop: 'status',
type: 'select',
options: [
{ label: '已冻结', value: 1 },
{ label: '解冻中', value: 2 },
{ label: '已发放', value: 3 },
{ label: '已失效', value: 4 }
],
options: COMMISSION_RECORD_STATUS_OPTIONS.map((item) => ({
label: item.label,
value: Number(item.value)
})),
config: {
clearable: true,
placeholder: '请选择状态'
@@ -431,6 +738,7 @@
const commissionColumnOptions = [
{ label: '佣金金额', prop: 'amount' },
{ label: '佣金来源', prop: 'commission_source' },
{ label: '记录来源', prop: 'source' },
{ label: '状态', prop: 'status_name' },
{ label: 'ICCID', prop: 'iccid' },
{ label: '虚拟号', prop: 'virtual_no' },
@@ -442,6 +750,28 @@
const commissionList = ref<ShopCommissionRecordItem[]>([])
// 佣金明细详情与导出
const commissionDetailVisible = ref(false)
const commissionDetailId = ref<number | null>(null)
const commissionDetailSource = ref<ShopCommissionRecordSource>('original')
const exportDialogVisible = ref(false)
const getCommissionRowKey = getCommissionRecordRowKey
const openCommissionDetail = (row: ShopCommissionRecordItem) => {
commissionDetailId.value = row.id
commissionDetailSource.value = isCommissionClawbackRecord(row) ? 'clawback' : 'original'
commissionDetailVisible.value = true
}
// 导出仅支持 shop_id / status / commission_source / order_no
const exportQuery = computed(() => ({
shop_id: currentShopId.value,
status: commissionSearchForm.status,
commission_source: commissionSearchForm.commission_source,
order_no: commissionSearchForm.order_no || undefined
}))
// 佣金来源映射
const CommissionSourceMap = {
cost_diff: { label: '成本差价', type: 'primary' as const },
@@ -454,8 +784,27 @@
{
prop: 'amount',
label: '佣金金额',
width: 120,
formatter: (row: ShopCommissionRecordItem) => formatMoney(row.amount)
width: 130,
formatter: (row: ShopCommissionRecordItem) =>
h(
'span',
{ class: Number(row.amount) < 0 ? 'amount-negative' : '' },
formatMoney(row.amount)
)
},
{
prop: 'source',
label: '记录来源',
width: 150,
formatter: (row: ShopCommissionRecordItem) => {
const clawback = isCommissionClawbackRecord(row)
const meta = COMMISSION_RECORD_SOURCE_MAP[clawback ? 'clawback' : 'original']
const children = [h(ElTag, { type: meta.type, size: 'small' }, () => meta.label)]
if (clawback) {
children.push(h(ElTag, { type: 'danger', size: 'small' }, () => '不可提现'))
}
return h('span', { class: 'record-source-cell' }, children)
}
},
{
prop: 'commission_source',
@@ -503,14 +852,30 @@
{
prop: 'balance_after',
label: '入账后余额',
width: 120,
formatter: (row: ShopCommissionRecordItem) => formatMoney(row.balance_after)
width: 130,
formatter: (row: ShopCommissionRecordItem) =>
h(
'span',
{ class: Number(row.balance_after) < 0 ? 'amount-negative' : '' },
formatMoney(row.balance_after)
)
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: ShopCommissionRecordItem) => formatDateTime(row.created_at)
},
{
prop: 'actions',
label: '操作',
width: 90,
formatter: (row: ShopCommissionRecordItem) =>
h(
ElButton,
{ link: true, type: 'primary', onClick: () => openCommissionDetail(row) },
() => '详情'
)
}
]
)
@@ -966,9 +1331,418 @@
getCommissionList()
} else if (newTab === 'withdrawal') {
getWithdrawalList()
} else if (newTab === 'qualification') {
getQualificationList()
}
})
// ==================== 提现资料资格 ====================
const qualificationLoading = ref(false)
const qualificationTableRef = ref()
const qualificationList = ref<WithdrawalQualificationItem[]>([])
const qualificationPagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
const qualificationColumnOptions = [
{ label: '主体类型', prop: 'subject_type_name' },
{ label: '主体证件号', prop: 'subject_code_masked' },
{ label: '法人身份证', prop: 'legal_person_id_card_masked' },
{ label: '状态', prop: 'status_name' },
{ label: '审批状态', prop: 'approval_status_name' },
{ label: '附件', prop: 'attachments' },
{ label: '作废原因', prop: 'invalid_reason' },
{ label: '作废时间', prop: 'invalidated_at' },
{ label: '创建时间', prop: 'created_at' }
]
const qualificationAttachmentLabels = (row: WithdrawalQualificationItem): string => {
const labels: string[] = []
if (row.contract_file_key) labels.push('合同')
if (row.id_card_front_file_key) labels.push('身份证正面')
if (row.id_card_back_file_key) labels.push('身份证背面')
if (row.business_license_file_key) labels.push('营业执照')
if (row.shop_front_file_key) labels.push('门头照')
if (row.invoice_file_key) labels.push('发票')
return labels.length > 0 ? labels.join('、') : '-'
}
const {
columnChecks: qualificationColumnChecks,
columns: qualificationColumns
} = useCheckedColumns(() => [
{ prop: 'subject_type_name', label: '主体类型', width: 90 },
{ prop: 'subject_code_masked', label: '主体证件号', minWidth: 160 },
{ prop: 'legal_person_id_card_masked', label: '法人身份证', minWidth: 170 },
{
prop: 'status_name',
label: '状态',
width: 100,
formatter: (row: WithdrawalQualificationItem) => {
const tagType = row.status === 0 ? 'warning' : row.status === 1 ? 'success' : 'danger'
return h(ElTag, { type: tagType }, () => row.status_name || String(row.status))
}
},
{
prop: 'approval_status_name',
label: '审批状态',
width: 100,
formatter: (row: WithdrawalQualificationItem) => row.approval_status_name || '-'
},
{
prop: 'attachments',
label: '附件',
minWidth: 200,
formatter: (row: WithdrawalQualificationItem) => qualificationAttachmentLabels(row)
},
{
prop: 'invalid_reason',
label: '作废原因',
minWidth: 160,
formatter: (row: WithdrawalQualificationItem) => row.invalid_reason || '-'
},
{
prop: 'invalidated_at',
label: '作废时间',
width: 170,
formatter: (row: WithdrawalQualificationItem) =>
row.invalidated_at ? formatDateTime(row.invalidated_at) : '-'
},
{
prop: 'created_at',
label: '创建时间',
width: 170,
formatter: (row: WithdrawalQualificationItem) => formatDateTime(row.created_at)
}
])
const getQualificationList = async () => {
if (!currentShopId.value) return
qualificationLoading.value = true
try {
const res = await CommissionService.getWithdrawalQualifications(currentShopId.value, {
page: qualificationPagination.page,
page_size: qualificationPagination.pageSize
})
if (res.code === 0) {
qualificationList.value = res.data.items || []
qualificationPagination.total = res.data.total || 0
}
} catch (error) {
console.error(error)
} finally {
qualificationLoading.value = false
}
}
const handleQualificationSizeChange = (newPageSize: number) => {
qualificationPagination.pageSize = newPageSize
getQualificationList()
}
const handleQualificationCurrentChange = (newCurrentPage: number) => {
qualificationPagination.page = newCurrentPage
getQualificationList()
}
const qualificationDialogVisible = ref(false)
const qualificationSubmitting = ref(false)
const qualificationFormRef = ref<FormInstance>()
interface QualificationForm {
subject_type: string
subject_code: string
legal_person_id_card: string
contract_file_key: string[]
id_card_front_file_key: string[]
id_card_back_file_key: string[]
business_license_file_key: string[]
shop_front_file_key: string[]
invoice_file_key: string[]
invoice_title: string
invoice_subject_code: string
}
const qualificationForm = reactive<QualificationForm>({
subject_type: 'enterprise',
subject_code: '',
legal_person_id_card: '',
contract_file_key: [],
id_card_front_file_key: [],
id_card_back_file_key: [],
business_license_file_key: [],
shop_front_file_key: [],
invoice_file_key: [],
invoice_title: '',
invoice_subject_code: ''
})
const qualificationRules = reactive<FormRules>({
subject_type: [{ required: true, message: '请选择主体类型', trigger: 'change' }],
subject_code: [{ required: true, message: '请输入主体证件号', trigger: 'blur' }],
legal_person_id_card: [{ required: true, message: '请输入法人身份证号', trigger: 'blur' }],
contract_file_key: [{ required: true, message: '请上传合同附件', trigger: 'change' }],
id_card_front_file_key: [{ required: true, message: '请上传身份证正面', trigger: 'change' }],
id_card_back_file_key: [{ required: true, message: '请上传身份证背面', trigger: 'change' }]
})
const showQualificationDialog = () => {
qualificationDialogVisible.value = true
}
const handleSubmitQualification = async () => {
if (!qualificationFormRef.value) return
if (!currentShopId.value) {
ElMessage.warning('未找到店铺信息')
return
}
await qualificationFormRef.value.validate(async (valid) => {
if (!valid) return
qualificationSubmitting.value = true
try {
const params: WithdrawalQualificationSubmitParams = {
subject_type: qualificationForm.subject_type,
subject_code: qualificationForm.subject_code.trim(),
legal_person_id_card: qualificationForm.legal_person_id_card.trim(),
contract_file_key: qualificationForm.contract_file_key[0] || '',
id_card_front_file_key: qualificationForm.id_card_front_file_key[0] || '',
id_card_back_file_key: qualificationForm.id_card_back_file_key[0] || ''
}
if (qualificationForm.business_license_file_key[0]) {
params.business_license_file_key = qualificationForm.business_license_file_key[0]
}
if (qualificationForm.shop_front_file_key[0]) {
params.shop_front_file_key = qualificationForm.shop_front_file_key[0]
}
if (qualificationForm.invoice_file_key[0]) {
params.invoice_file_key = qualificationForm.invoice_file_key[0]
}
if (qualificationForm.invoice_title.trim()) {
params.invoice_title = qualificationForm.invoice_title.trim()
}
if (qualificationForm.invoice_subject_code.trim()) {
params.invoice_subject_code = qualificationForm.invoice_subject_code.trim()
}
const res = await CommissionService.submitWithdrawalQualification(
currentShopId.value!,
params
)
if (res.code === 0) {
ElMessage.success('提现资料资格提交成功')
qualificationDialogVisible.value = false
getQualificationList()
}
} catch (error) {
console.error(error)
} finally {
qualificationSubmitting.value = false
}
})
}
const getQualificationActions = (row: WithdrawalQualificationItem) => {
if (!userStore.isSuperAdmin) return []
return [
{
label: '作废',
handler: () => handleVoidQualification(row),
type: 'danger' as const
}
]
}
const handleVoidQualification = async (row: WithdrawalQualificationItem) => {
try {
const { value } = await ElMessageBox.prompt('请输入作废原因', '作废提现资料资格', {
confirmButtonText: '确认作废',
cancelButtonText: '取消',
inputPlaceholder: '作废原因(必填)',
inputValidator: (reason: string) =>
reason && reason.trim() ? true : '作废原因必填',
type: 'warning'
})
await CommissionService.voidWithdrawalQualification(row.id, { reason: value.trim() })
ElMessage.success('资格已作废')
getQualificationList()
} catch {
// 取消或请求失败
}
}
// ==================== 提现详情与重提 ====================
const detailDrawerVisible = ref(false)
const detailLoading = ref(false)
const withdrawalDetail = ref<WithdrawalRequestDetail | null>(null)
const withdrawalMethodLabel = (method: string) => {
const config = WithdrawalMethodMap[method as keyof typeof WithdrawalMethodMap]
return config ? config.label : method
}
const openWithdrawalDetail = async (row: WithdrawalRequestItem) => {
if (!currentShopId.value) return
detailDrawerVisible.value = true
detailLoading.value = true
withdrawalDetail.value = null
try {
const res = await CommissionService.getWithdrawalRequestDetail(
currentShopId.value,
row.id
)
if (res.code === 0) {
withdrawalDetail.value = res.data
}
} catch (error) {
console.error(error)
} finally {
detailLoading.value = false
}
}
const getWithdrawalActions = (row: WithdrawalRequestItem) => {
const actions: Array<{ label: string; handler: (row: any) => void; type?: 'primary' | 'danger' }> = [
{
label: '详情',
handler: () => openWithdrawalDetail(row),
type: 'primary'
}
]
if (row.status === WithdrawalStatus.REJECTED) {
actions.push({
label: '重新提交',
handler: () => openResubmitDialog(row),
type: 'primary'
})
}
return actions
}
const resubmitDialogVisible = ref(false)
const resubmitSubmitting = ref(false)
const resubmitFormRef = ref<FormInstance>()
const resubmitTargetId = ref<number | null>(null)
const bankOptions = [
'中国工商银行',
'中国建设银行',
'中国农业银行',
'中国银行',
'招商银行',
'交通银行',
'中国邮政储蓄银行',
'其他银行'
]
const resubmitForm = reactive<{
amount: number | undefined
withdrawal_method: WithdrawalMethod
account_name: string
account_number: string
bank_name: string
invoice_keys: string[]
}>({
amount: undefined,
withdrawal_method: WithdrawalMethod.ALIPAY,
account_name: '',
account_number: '',
bank_name: '',
invoice_keys: []
})
const resubmitRules = reactive<FormRules>({
amount: [
{ required: true, message: '请输入提现金额', trigger: 'blur' },
{
validator: (_rule, value, callback) => {
const amountFen = yuanToFen(value)
if (amountFen === undefined || amountFen <= 0) {
callback(new Error('请输入正确的提现金额'))
} else if (amountFen > summary.value.available_commission) {
callback(new Error('提现金额不能大于可提现佣金'))
} else {
callback()
}
},
trigger: 'blur'
}
],
withdrawal_method: [{ required: true, message: '请选择提现方式', trigger: 'change' }],
account_name: [
{ required: true, message: '请输入账户名', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
],
account_number: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 5, max: 50, message: '长度在 5 到 50 个字符', trigger: 'blur' }
],
bank_name: [
{
validator: (_rule, value, callback) => {
if (resubmitForm.withdrawal_method === WithdrawalMethod.BANK && !value) {
callback(new Error('请选择银行名称'))
} else {
callback()
}
},
trigger: 'change'
}
]
})
const openResubmitDialog = (row: WithdrawalRequestItem) => {
resubmitTargetId.value = row.id
resubmitForm.amount = Number((row.amount / 100).toFixed(2))
resubmitForm.withdrawal_method = row.withdrawal_method || WithdrawalMethod.ALIPAY
resubmitForm.account_name = row.account_name || ''
resubmitForm.account_number = row.account_number || ''
resubmitForm.bank_name = row.bank_name || ''
resubmitForm.invoice_keys = []
resubmitDialogVisible.value = true
}
const handleSubmitResubmit = async () => {
if (!resubmitFormRef.value) return
if (!currentShopId.value || !resubmitTargetId.value) return
await resubmitFormRef.value.validate(async (valid) => {
if (!valid) return
resubmitSubmitting.value = true
try {
const amount = yuanToFen(resubmitForm.amount)
if (amount === undefined) {
ElMessage.warning('请输入提现金额')
return
}
const params: ResubmitWithdrawalRequestParams = {
amount,
withdrawal_method: resubmitForm.withdrawal_method,
account_name: resubmitForm.account_name,
account_number: resubmitForm.account_number
}
if (resubmitForm.invoice_keys.length > 0) {
params.invoice_keys = resubmitForm.invoice_keys
}
await CommissionService.resubmitWithdrawalRequest(
currentShopId.value!,
resubmitTargetId.value!,
params
)
ElMessage.success('重新提交成功')
resubmitDialogVisible.value = false
await getWithdrawalList()
await loadSummary()
} catch (error) {
console.error(error)
} finally {
resubmitSubmitting.value = false
}
})
}
onMounted(() => {
if (!hasShopId.value) {
ElMessage.warning('当前账号未关联店铺,无法查看佣金信息')
@@ -981,6 +1755,30 @@
</script>
<style scoped lang="scss">
.qualification-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
}
.withdrawal-detail {
.attempt-item {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 6px;
font-size: 13px;
.attempt-reason {
color: var(--el-color-danger);
}
.attempt-time {
color: var(--el-text-color-secondary);
}
}
}
.commission-summary {
display: inline-flex;
flex-wrap: wrap;
@@ -1018,4 +1816,14 @@
}
}
}
.amount-negative {
color: var(--el-color-danger);
}
.record-source-cell {
display: inline-flex;
gap: 4px;
align-items: center;
}
</style>

View File

@@ -440,6 +440,8 @@
{
prop: 'created_at',
label: '创建时间',
width: 160,
showOverflowTooltip: true,
formatter: (row: PlatformAccount) => h('span', formatDateTime((row as any).created_at))
},
{

View File

@@ -56,7 +56,15 @@
<template #header>
<div class="block-title">付款凭证</div>
</template>
<ElButton v-if="voucherKeys.length" @click="voucherVisible = true">查看付款凭证</ElButton>
<ElButton
v-if="
voucherKeys.length &&
hasAuth(AUGUST_PERMISSIONS.employeeCollection.applicationViewVoucher)
"
@click="voucherVisible = true"
>
查看付款凭证
</ElButton>
<span v-else class="empty-text">暂无付款凭证</span>
</ElCard>
@@ -116,6 +124,8 @@
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { EmployeeCollectionService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration'
import type {
EmployeeCollectionAllocation,
EmployeeCollectionApplication,
@@ -136,6 +146,7 @@
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const loading = ref(false)
const detail = ref<EmployeeCollectionApplicationDetailData | null>(null)

View File

@@ -275,9 +275,6 @@
const getActions = (row: EmployeeCollectionApplication) => {
const actions: any[] = []
if (hasAuth(AUGUST_PERMISSIONS.employeeCollection.applicationDetail)) {
actions.push({ label: '详情', handler: () => handleViewDetail(row), type: 'primary' })
}
if (
hasAuth(AUGUST_PERMISSIONS.employeeCollection.applicationUpdate) &&
canResubmitApplication(row)

View File

@@ -472,9 +472,6 @@
type: 'primary'
})
}
if (hasAuth(AUGUST_PERMISSIONS.employeeCollection.billDetail)) {
actions.push({ label: '详情', handler: () => handleViewDetail(row), type: 'primary' })
}
if (hasAuth(AUGUST_PERMISSIONS.employeeCollection.billClose) && canCloseBill(row)) {
actions.push({ label: '关闭账单', handler: () => openCloseDialog(row), type: 'danger' })
}

View File

@@ -4,6 +4,7 @@
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
/>
@@ -29,11 +30,16 @@
ref="tableRef"
row-key="id"
:loading="loading"
:data="filteredPaymentMethods"
:pagination="false"
:data="paymentMethods"
:current-page="pagination.page"
:page-size="pagination.page_size"
:total="pagination.total"
:margin-top="10"
:actions="getActions"
:actions-width="140"
:actions-width="120"
:always-show-pagination="true"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
@@ -45,7 +51,7 @@
<ElDialog
v-model="dialogVisible"
:title="editing ? '编辑收款方式' : '新增收款方式'"
width="520px"
width="35%"
destroy-on-close
@closed="handleDialogClosed"
>
@@ -85,7 +91,7 @@
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { h, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { EmployeeCollectionService } from '@/api/modules'
@@ -100,7 +106,7 @@
import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration'
import { getErrorMessage } from '@/utils/business'
import { formatDateTime } from '@/utils/business/format'
import { normalizeCollectionList } from '../employeeCollectionDisplay'
import { normalizeCollectionPage } from '../employeeCollectionDisplay'
defineOptions({ name: 'EmployeeCollectionPaymentMethods' })
@@ -115,6 +121,8 @@
const currentId = ref<number | null>(null)
const paymentMethods = ref<EmployeeCollectionPaymentMethod[]>([])
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const initialSearchState = {
keyword: '',
enabled: undefined as number | undefined
@@ -170,21 +178,6 @@
}
])
const filteredPaymentMethods = computed(() => {
const keyword = searchForm.keyword?.trim().toLowerCase()
return paymentMethods.value.filter((item) => {
const matchKeyword =
!keyword ||
(item.name || '').toLowerCase().includes(keyword) ||
(item.code || '').toLowerCase().includes(keyword)
const matchEnabled =
searchForm.enabled === undefined ||
searchForm.enabled === null ||
(searchForm.enabled === 1 ? item.enabled : !item.enabled)
return matchKeyword && matchEnabled
})
})
const form = reactive({
code: '',
name: '',
@@ -201,7 +194,10 @@
const getTableData = async () => {
loading.value = true
try {
const params: EmployeeCollectionPaymentMethodQueryParams = { page: 1, page_size: 100 }
const params: EmployeeCollectionPaymentMethodQueryParams = {
page: pagination.page,
page_size: pagination.page_size
}
if (searchForm.enabled !== undefined && searchForm.enabled !== null) {
params.enabled = searchForm.enabled === 1
}
@@ -210,7 +206,9 @@
}
const res = await EmployeeCollectionService.getPaymentMethods(params)
if (res.code === 0) {
paymentMethods.value = normalizeCollectionList<EmployeeCollectionPaymentMethod>(res.data)
const { list, total } = normalizeCollectionPage<EmployeeCollectionPaymentMethod>(res.data)
paymentMethods.value = list
pagination.total = total
}
} catch (error) {
ElMessage.error(getErrorMessage(error, '获取收款方式失败'))
@@ -219,10 +217,25 @@
}
}
const handleSearch = () => getTableData()
const handleSearch = () => {
pagination.page = 1
void getTableData()
}
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
void getTableData()
}
const handleSizeChange = (size: number) => {
pagination.page_size = size
pagination.page = 1
void getTableData()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
void getTableData()
}

View File

@@ -17,6 +17,21 @@
>
重新申请
</ElButton>
<template v-if="refund && canManualApproveRefund(refund)">
<ElButton
v-if="hasAuth('refund:approve')"
type="primary"
@click="openApprovalDialog('approve')"
>
通过
</ElButton>
<ElButton v-if="hasAuth('refund:reject')" type="danger" @click="openApprovalDialog('reject')">
拒绝
</ElButton>
<ElButton v-if="hasAuth('refund:return')" type="primary" @click="openApprovalDialog('return')">
退回
</ElButton>
</template>
</div>
<!-- 详情内容 -->
@@ -31,6 +46,14 @@
<PaymentVoucherDialog :file-keys="refundVoucherFileKeys" @close="refundVoucherFileKeys = []" />
<!-- 退款审批操作对话框 -->
<RefundApprovalDialog
v-model="approvalDialogVisible"
:refund="refund"
:action="approvalAction"
@success="refund && fetchRefundDetail(refund.id)"
/>
<!-- 重新提交对话框 -->
<ElDialog
v-model="resubmitDialogVisible"
@@ -57,16 +80,44 @@
placeholder="不填则使用原金额"
/>
</ElFormItem>
<ElFormItem label="实收金额">
<ElInputNumber
v-model="resubmitForm.actual_received_amount"
:min="1"
:precision="2"
:step="1"
<ElFormItem label="退款方式">
<ElSelect
v-model="resubmitForm.method"
clearable
placeholder="不修改则沿用原退款方式"
style="width: 100%"
placeholder="不填则使用原金额"
/>
>
<ElOption
v-for="opt in RefundMethodOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
</ElFormItem>
<template v-if="resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT">
<ElFormItem label="收款账户名" prop="customer_account_name">
<ElInput
v-model="resubmitForm.customer_account_name"
placeholder="请输入收款账户名"
maxlength="50"
/>
</ElFormItem>
<ElFormItem label="收款账号" prop="customer_account_number">
<ElInput
v-model="resubmitForm.customer_account_number"
placeholder="请输入收款账号"
maxlength="64"
/>
</ElFormItem>
<ElFormItem label="开户银行">
<ElInput
v-model="resubmitForm.customer_bank_name"
placeholder="选填"
maxlength="50"
/>
</ElFormItem>
</template>
<ElFormItem label="退款原因">
<ElInput
v-model="resubmitForm.refund_reason"
@@ -74,18 +125,28 @@
:rows="3"
maxlength="1000"
show-word-limit
placeholder="请输入退款原因"
/>
placeholder="请输入退款原因"
/>
</ElFormItem>
<ElFormItem label="退款凭证" prop="refund_voucher_key">
<ElFormItem
:label="
resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT ? '退款凭证(必填)' : '退款凭证'
"
prop="refund_voucher_key"
>
<VoucherUpload
ref="resubmitUploadRef"
v-model="resubmitForm.refund_voucher_key"
voucher-name="退款凭证"
@uploading-change="resubmitVoucherUploading = $event"
@change="resubmitFormRef?.validateField('refund_voucher_key')"
@files-change="resubmitForm.attachments = $event"
/>
<div
v-if="resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT"
class="resubmit-voucher-tip"
>
客户收款信息方式下须同时提供客户收款信息与至少 1 个退款凭证
</div>
</ElFormItem>
</ElForm>
<template #footer>
@@ -109,15 +170,28 @@
import { ref, onMounted, computed, h, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElButton, ElCard, ElIcon, ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { RefundService } from '@/api/modules'
import type { Refund, ResubmitRefundRequest, RefundAttachment } from '@/types/api'
import {
RefundMethod,
type Refund,
type RefundAttemptItem,
type ResubmitRefundRequest,
type RefundAttachment
} from '@/types/api'
import {
RefundMethodOptions,
canManualApproveRefund,
canResubmitRefund
} from '@/config/constants/refund'
import { fenToYuan, formatDateTime, yuanToFen } from '@/utils/business/format'
import { toVoucherKeyList, getErrorMessage } from '@/utils/business'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import RefundApprovalDialog from '@/components/business/RefundApprovalDialog.vue'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
@@ -132,6 +206,14 @@
const loading = ref(false)
const refund = ref<Refund | null>(null)
const refundVoucherFileKeys = ref<string[]>([])
const approvalDialogVisible = ref(false)
const approvalAction = ref<'approve' | 'reject' | 'return'>('approve')
const openApprovalDialog = (action: 'approve' | 'reject' | 'return') => {
if (!refund.value || !canManualApproveRefund(refund.value)) return
approvalAction.value = action
approvalDialogVisible.value = true
}
const pageTitle = computed(() => `退款详情`)
@@ -143,19 +225,56 @@
const resubmitFormRef = ref()
const resubmitUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const resubmitRules = ref()
const requireResubmitCustomerAccount =
(message: string) =>
(_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT && !value?.trim()) {
callback(new Error(message))
} else {
callback()
}
}
const validateResubmitVoucherKey = (
_rule: unknown,
value: string[],
callback: (error?: Error) => void
) => {
if (
resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT &&
(!Array.isArray(value) || value.length === 0)
) {
callback(new Error('请上传至少 1 个退款凭证'))
} else {
callback()
}
}
const resubmitRules = reactive<FormRules>({
customer_account_name: [
{ validator: requireResubmitCustomerAccount('请填写收款账户名'), trigger: 'blur' }
],
customer_account_number: [
{ validator: requireResubmitCustomerAccount('请填写收款账号'), trigger: 'blur' }
],
refund_voucher_key: [{ validator: validateResubmitVoucherKey, trigger: 'change' }]
})
const resubmitForm = reactive<{
requested_refund_amount?: number
actual_received_amount?: number
method?: RefundMethod | ''
customer_account_name: string
customer_account_number: string
customer_bank_name: string
refund_voucher_key: string[]
attachments: RefundAttachment[]
refund_reason?: string
}>({
requested_refund_amount: undefined,
actual_received_amount: undefined,
method: '',
customer_account_name: '',
customer_account_number: '',
customer_bank_name: '',
refund_voucher_key: [],
attachments: [],
refund_reason: ''
})
@@ -165,6 +284,11 @@
return `¥${(amount / 100).toFixed(2)}`
}
const formatMb = (value: number | null | undefined): string => {
if (value === undefined || value === null || Number.isNaN(value)) return '-'
return `${value} MB`
}
const getApprovalProviderText = (item: Refund) => {
const provider = item.approval_provider || item.approval_source || item.approval?.source
if (provider === 'wecom') return '企微'
@@ -224,6 +348,60 @@
)
}
const renderAttempts = (attempts: unknown) => {
if (!Array.isArray(attempts) || attempts.length === 0) return h('span', '-')
const sorted = [...attempts].sort((a, b) => {
const timeA = new Date(a.created_at || 0).getTime()
const timeB = new Date(b.created_at || 0).getTime()
return timeB - timeA
})
return h(
'div',
{ class: 'attempt-list' },
sorted.map((item: RefundAttemptItem) => {
const voucherKeys = toVoucherKeyList(item.customer_voucher_key ?? undefined)
return h('div', { class: 'attempt-item' }, [
h('div', { class: 'attempt-item__header' }, [
h('strong', `${item.attempt_no ?? '-'} 次尝试`),
h('span', item.approval_status_name || item.approval_status || '-')
]),
h(
'div',
{ class: 'attempt-item__meta' },
[
`退款方式:${item.method_name || '-'}`,
`金额:${formatCurrency(item.refund_amount)}`,
item.channel_refund_request_no
? `渠道申请号:${item.channel_refund_request_no}`
: '',
item.created_at ? `提交时间:${formatDateTime(item.created_at)}` : ''
]
.filter(Boolean)
.join(' ')
),
item.refund_reason
? h('div', { class: 'attempt-item__reason' }, `退款原因:${item.refund_reason}`)
: null,
voucherKeys.length
? h(
ElButton,
{
type: 'primary',
link: true,
onClick: () => {
refundVoucherFileKeys.value = voucherKeys
}
},
() => '查看该次凭证'
)
: null
])
})
)
}
const getRefundAttachmentKeys = (item: Refund) => {
const keys = item.attachments?.map((attachment) => attachment.file_key).filter(Boolean) || []
return keys.length ? keys : toVoucherKeyList(item.refund_voucher_key)
@@ -260,6 +438,64 @@
label: '实收金额',
formatter: (_, data) => formatCurrency(data.actual_received_amount)
},
{
label: '退款方式',
formatter: (_, data) => data.method_name || '-'
},
{
label: '冻结实收金额',
formatter: (_, data) => formatCurrency(data.frozen_actual_received_amount)
},
{
label: '渠道退款状态',
formatter: (_, data) => data.channel_refund_status_name || '-'
},
{
label: '渠道退款流水号',
formatter: (_, data) => data.channel_refund_no || '-'
},
{
label: '渠道退款申请号',
formatter: (_, data) => data.channel_refund_request_no || '-'
},
{
label: '渠道退款金额',
formatter: (_, data) => formatCurrency(data.channel_refund_amount)
},
{
label: '渠道退款时间',
formatter: (_, data) =>
data.channel_refunded_at ? formatDateTime(data.channel_refunded_at) : '-'
},
{
label: '失败分类',
formatter: (_, data) => data.failure_reason_name || data.failure_reason || '-'
},
{
label: '失败原因/消息',
formatter: (_, data) => data.failure_message || '-',
fullWidth: true
},
{
label: '异常标记',
formatter: (_, data) =>
Number(data.anomaly_flag) === 1
? `异常:${data.anomaly_reason || ''}`
: '正常'
},
{
label: '套餐已用量/总量',
formatter: (_, data) =>
`${formatMb(data.refund_package_used_mb)} / ${formatMb(data.refund_package_total_mb)}`
},
{
label: '客户收款信息',
fullWidth: true,
render: (data) =>
data.method === RefundMethod.CUSTOMER_ACCOUNT && data.customer_account_info
? renderStructuredValue(data.customer_account_info)
: h('span', '-')
},
...(!isRestrictedCustomerRole.value
? [
{
@@ -416,6 +652,16 @@
fullWidth: true
}
]
},
{
title: '审批尝试历史',
fields: [
{
label: '尝试记录',
fullWidth: true,
render: (data) => renderAttempts(data.attempts)
}
]
}
])
@@ -448,39 +694,29 @@
const handleResubmitDialogClosed = () => {
resubmitFormRef.value?.resetFields()
resubmitForm.requested_refund_amount = undefined
resubmitForm.actual_received_amount = undefined
resubmitForm.method = ''
resubmitForm.customer_account_name = ''
resubmitForm.customer_account_number = ''
resubmitForm.customer_bank_name = ''
resubmitForm.refund_voucher_key = []
resubmitForm.attachments = []
resubmitForm.refund_reason = ''
resubmitVoucherUploading.value = false
resubmitUploadRef.value?.clearFiles(false)
}
const canResubmit = (item: Refund) => {
const statusText = [item.approval_status, item.approval_status_name, item.status_name]
.filter(Boolean)
.join(' ')
.toLowerCase()
return (
item.status === 3 ||
statusText.includes('reject') ||
statusText.includes('revoke') ||
statusText.includes('delete') ||
statusText.includes('驳回') ||
statusText.includes('撤销') ||
statusText.includes('删除')
)
return canResubmitRefund(item)
}
const handleShowResubmit = () => {
if (!refund.value || !canResubmit(refund.value)) return
resubmitForm.requested_refund_amount =
fenToYuan(refund.value.requested_refund_amount) || undefined
resubmitForm.actual_received_amount =
fenToYuan(refund.value.actual_received_amount) || undefined
resubmitForm.method = ''
resubmitForm.customer_account_name = ''
resubmitForm.customer_account_number = ''
resubmitForm.customer_bank_name = ''
resubmitForm.refund_voucher_key = []
resubmitForm.attachments = []
resubmitForm.refund_reason = refund.value.refund_reason
resubmitDialogVisible.value = true
}
@@ -499,9 +735,22 @@
try {
const data: ResubmitRefundRequest = {
requested_refund_amount: yuanToFen(resubmitForm.requested_refund_amount),
actual_received_amount: yuanToFen(resubmitForm.actual_received_amount),
refund_reason: resubmitForm.refund_reason || undefined,
attachments: resubmitForm.attachments
refund_reason: resubmitForm.refund_reason || undefined
}
if (resubmitForm.method) {
data.method = resubmitForm.method
}
if (resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT) {
data.customer_account_info = {
account_name: resubmitForm.customer_account_name.trim(),
account_number: resubmitForm.customer_account_number.trim(),
...(resubmitForm.customer_bank_name.trim()
? { bank_name: resubmitForm.customer_bank_name.trim() }
: {})
}
data.refund_voucher_key = resubmitForm.refund_voucher_key
} else if (resubmitForm.refund_voucher_key.length) {
data.refund_voucher_key = resubmitForm.refund_voucher_key
}
await RefundService.resubmitRefund(refundId, data)
@@ -564,6 +813,45 @@
}
}
.resubmit-voucher-tip {
margin-top: 4px;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
}
.attempt-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.attempt-item {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 12px;
background: var(--el-fill-color-light);
border-radius: 6px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
}
&__meta {
font-size: 12px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
&__reason {
font-size: 12px;
color: var(--el-text-color-regular);
}
}
.loading-container {
display: flex;
flex-direction: column;

View File

@@ -51,6 +51,14 @@
<!-- 创建退款申请对话框 -->
<CreateRefundDialog v-model="createDialogVisible" @success="handleCreateSuccess" />
<!-- 退款审批操作对话框 -->
<RefundApprovalDialog
v-model="approvalDialogVisible"
:refund="currentRefund"
:action="approvalAction"
@success="getTableData"
/>
<!-- 导出任务对话框 -->
<ExportTaskCreateDialog
v-model="exportDialogVisible"
@@ -92,16 +100,44 @@
placeholder="不填则使用原金额"
/>
</ElFormItem>
<ElFormItem label="实收金额">
<ElInputNumber
v-model="resubmitForm.actual_received_amount"
:min="1"
:precision="2"
:step="1"
<ElFormItem label="退款方式">
<ElSelect
v-model="resubmitForm.method"
clearable
placeholder="不修改则沿用原退款方式"
style="width: 100%"
placeholder="不填则使用原金额"
/>
>
<ElOption
v-for="opt in RefundMethodOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
</ElFormItem>
<template v-if="resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT">
<ElFormItem label="收款账户名" prop="customer_account_name">
<ElInput
v-model="resubmitForm.customer_account_name"
placeholder="请输入收款账户名"
maxlength="50"
/>
</ElFormItem>
<ElFormItem label="收款账号" prop="customer_account_number">
<ElInput
v-model="resubmitForm.customer_account_number"
placeholder="请输入收款账号"
maxlength="64"
/>
</ElFormItem>
<ElFormItem label="开户银行">
<ElInput
v-model="resubmitForm.customer_bank_name"
placeholder="选填"
maxlength="50"
/>
</ElFormItem>
</template>
<ElFormItem label="退款原因">
<ElInput
v-model="resubmitForm.refund_reason"
@@ -112,15 +148,25 @@
placeholder="请输入退款原因"
/>
</ElFormItem>
<ElFormItem label="退款凭证" prop="refund_voucher_key">
<ElFormItem
:label="
resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT ? '退款凭证(必填)' : '退款凭证'
"
prop="refund_voucher_key"
>
<VoucherUpload
ref="resubmitUploadRef"
v-model="resubmitForm.refund_voucher_key"
voucher-name="退款凭证"
@uploading-change="resubmitVoucherUploading = $event"
@change="resubmitFormRef?.validateField('refund_voucher_key')"
@files-change="resubmitForm.attachments = $event"
/>
<div
v-if="resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT"
class="resubmit-voucher-tip"
>
客户收款信息方式下须同时提供客户收款信息与至少 1 个退款凭证
</div>
</ElFormItem>
</ElForm>
<template #footer>
@@ -150,11 +196,17 @@
import type { FormInstance, FormRules } from 'element-plus'
import {
RefundStatus,
RefundMethod,
type Refund,
type RefundQueryParams,
type ResubmitRefundRequest,
type RefundAttachment
type ResubmitRefundRequest
} from '@/types/api'
import {
RefundMethodOptions,
RefundStatusTypeMap,
canResubmitRefund,
canManualApproveRefund
} from '@/config/constants/refund'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { fenToYuan, formatDateTime, yuanToFen } from '@/utils/business/format'
@@ -178,6 +230,7 @@
import { useAuth } from '@/composables/useAuth'
import CreateRefundDialog from '@/components/business/CreateRefundDialog.vue'
import RefundApprovalDialog from '@/components/business/RefundApprovalDialog.vue'
defineOptions({ name: 'RefundList' })
const router = useRouter()
@@ -207,6 +260,14 @@
const resubmitDialogVisible = ref(false)
const currentRefund = ref<Refund | null>(null)
const refundVoucherFileKeys = ref<string[]>([])
const approvalDialogVisible = ref(false)
const approvalAction = ref<'approve' | 'reject' | 'return'>('approve')
const openApprovalDialog = (row: Refund, action: 'approve' | 'reject' | 'return') => {
currentRefund.value = row
approvalAction.value = action
approvalDialogVisible.value = true
}
// 搜索表单初始值
const initialSearchState: RefundQueryParams = {
@@ -277,7 +338,9 @@
{ label: '待审批', value: 1 },
{ label: '已通过', value: 2 },
{ label: '已拒绝', value: 3 },
{ label: '已退回', value: 4 }
{ label: '已退回', value: 4 },
{ label: '原路退款处理中', value: 5 },
{ label: '原路退款失败', value: 6 }
],
config: {
clearable: true
@@ -302,6 +365,15 @@
{ label: '申请退款金额', prop: 'requested_refund_amount' },
{ label: '实际退款金额', prop: 'approved_refund_amount' },
{ label: '实收金额', prop: 'actual_received_amount' },
{ label: '退款方式', prop: 'method' },
{ label: '冻结实收金额', prop: 'frozen_actual_received_amount' },
{ label: '渠道退款状态', prop: 'channel_refund_status' },
{ label: '渠道退款流水号', prop: 'channel_refund_no' },
{ label: '渠道退款金额', prop: 'channel_refund_amount' },
{ label: '失败分类', prop: 'failure_reason' },
{ label: '异常标记', prop: 'anomaly_flag' },
{ label: '套餐已用量(MB)', prop: 'refund_package_used_mb' },
{ label: '套餐总量(MB)', prop: 'refund_package_total_mb' },
{ label: '状态', prop: 'status' },
{ label: '审批渠道', prop: 'approval_provider' },
{ label: '提交人', prop: 'submitter_name' },
@@ -320,19 +392,56 @@
const resubmitFormRef = ref<FormInstance>()
const resubmitRules = reactive<FormRules>({})
const requireResubmitCustomerAccount =
(message: string) =>
(_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT && !value?.trim()) {
callback(new Error(message))
} else {
callback()
}
}
const validateResubmitVoucherKey = (
_rule: unknown,
value: string[],
callback: (error?: Error) => void
) => {
if (
resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT &&
(!Array.isArray(value) || value.length === 0)
) {
callback(new Error('请上传至少 1 个退款凭证'))
} else {
callback()
}
}
const resubmitRules = reactive<FormRules>({
customer_account_name: [
{ validator: requireResubmitCustomerAccount('请填写收款账户名'), trigger: 'blur' }
],
customer_account_number: [
{ validator: requireResubmitCustomerAccount('请填写收款账号'), trigger: 'blur' }
],
refund_voucher_key: [{ validator: validateResubmitVoucherKey, trigger: 'change' }]
})
const resubmitForm = reactive<{
requested_refund_amount?: number
actual_received_amount?: number
method?: RefundMethod | ''
customer_account_name: string
customer_account_number: string
customer_bank_name: string
refund_voucher_key: string[]
attachments: RefundAttachment[]
refund_reason?: string
}>({
requested_refund_amount: undefined,
actual_received_amount: undefined,
method: '',
customer_account_name: '',
customer_account_number: '',
customer_bank_name: '',
refund_voucher_key: [],
attachments: [],
refund_reason: ''
})
@@ -345,15 +454,14 @@
return `¥${(amount / 100).toFixed(2)}`
}
const formatMb = (value: number | null | undefined): string => {
if (value === undefined || value === null || Number.isNaN(value)) return '-'
return `${value} MB`
}
// 获取状态标签类型
const getStatusType = (status: RefundStatus): 'warning' | 'success' | 'danger' | 'info' => {
const statusMap: Record<RefundStatus, 'warning' | 'success' | 'danger' | 'info'> = {
1: 'warning', // 待审批
2: 'success', // 已通过
3: 'danger', // 已拒绝
4: 'info' // 已退回
}
return statusMap[status] || 'info'
return RefundStatusTypeMap[status] || 'info'
}
// 动态列配置
@@ -425,6 +533,69 @@
width: 120,
formatter: (row: Refund) => formatCurrency(row.actual_received_amount)
},
{
prop: 'method',
label: '退款方式',
width: 130,
formatter: (row: Refund) => row.method_name || '-'
},
{
prop: 'frozen_actual_received_amount',
label: '冻结实收金额',
width: 140,
formatter: (row: Refund) => formatCurrency(row.frozen_actual_received_amount)
},
{
prop: 'channel_refund_status',
label: '渠道退款状态',
width: 130,
formatter: (row: Refund) => row.channel_refund_status_name || '-'
},
{
prop: 'channel_refund_no',
label: '渠道退款流水号',
width: 180,
showOverflowTooltip: true,
formatter: (row: Refund) => row.channel_refund_no || '-'
},
{
prop: 'channel_refund_amount',
label: '渠道退款金额',
width: 140,
formatter: (row: Refund) => formatCurrency(row.channel_refund_amount)
},
{
prop: 'failure_reason',
label: '失败分类',
width: 150,
showOverflowTooltip: true,
formatter: (row: Refund) => row.failure_reason_name || '-'
},
{
prop: 'anomaly_flag',
label: '异常标记',
width: 110,
formatter: (row: Refund) => {
if (Number(row.anomaly_flag) !== 1) return h(ElTag, { type: 'success' }, () => '正常')
return h(
'span',
{ title: row.anomaly_reason || '退款流程异常' },
h(ElTag, { type: 'danger' }, () => '异常')
)
}
},
{
prop: 'refund_package_used_mb',
label: '套餐已用量(MB)',
width: 140,
formatter: (row: Refund) => formatMb(row.refund_package_used_mb)
},
{
prop: 'refund_package_total_mb',
label: '套餐总量(MB)',
width: 130,
formatter: (row: Refund) => formatMb(row.refund_package_total_mb)
},
{
prop: 'status',
label: '状态',
@@ -659,9 +830,11 @@
currentRefund.value = row
// 预填充原有数据
resubmitForm.requested_refund_amount = fenToYuan(row.requested_refund_amount) || undefined
resubmitForm.actual_received_amount = fenToYuan(row.actual_received_amount) || undefined
resubmitForm.method = ''
resubmitForm.customer_account_name = ''
resubmitForm.customer_account_number = ''
resubmitForm.customer_bank_name = ''
resubmitForm.refund_voucher_key = []
resubmitForm.attachments = []
resubmitForm.refund_reason = row.refund_reason
resubmitDialogVisible.value = true
}
@@ -670,9 +843,11 @@
const handleResubmitDialogClosed = () => {
resubmitFormRef.value?.resetFields()
resubmitForm.requested_refund_amount = undefined
resubmitForm.actual_received_amount = undefined
resubmitForm.method = ''
resubmitForm.customer_account_name = ''
resubmitForm.customer_account_number = ''
resubmitForm.customer_bank_name = ''
resubmitForm.refund_voucher_key = []
resubmitForm.attachments = []
resubmitForm.refund_reason = ''
resubmitVoucherUploading.value = false
resubmitUploadRef.value?.clearFiles(false)
@@ -695,9 +870,22 @@
if (!refundId) return
const data: ResubmitRefundRequest = {
requested_refund_amount: yuanToFen(resubmitForm.requested_refund_amount),
actual_received_amount: yuanToFen(resubmitForm.actual_received_amount),
refund_reason: resubmitForm.refund_reason || undefined,
attachments: resubmitForm.attachments
refund_reason: resubmitForm.refund_reason || undefined
}
if (resubmitForm.method) {
data.method = resubmitForm.method
}
if (resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT) {
data.customer_account_info = {
account_name: resubmitForm.customer_account_name.trim(),
account_number: resubmitForm.customer_account_number.trim(),
...(resubmitForm.customer_bank_name.trim()
? { bank_name: resubmitForm.customer_bank_name.trim() }
: {})
}
data.refund_voucher_key = resubmitForm.refund_voucher_key
} else if (resubmitForm.refund_voucher_key.length) {
data.refund_voucher_key = resubmitForm.refund_voucher_key
}
await RefundService.resubmitRefund(refundId, data)
@@ -735,20 +923,7 @@
}
const canResubmit = (row: Refund) => {
const statusText = [row.approval_status, row.approval_status_name, row.status_name]
.filter(Boolean)
.join(' ')
.toLowerCase()
return (
row.status === 3 ||
statusText.includes('reject') ||
statusText.includes('revoke') ||
statusText.includes('delete') ||
statusText.includes('驳回') ||
statusText.includes('撤销') ||
statusText.includes('删除')
)
return canResubmitRefund(row)
}
const canTriggerApproval = (row: Refund) => {
@@ -854,6 +1029,28 @@
})
}
if (canManualApproveRefund(row) && hasAuth('refund:approve')) {
actions.push({
label: '通过',
handler: () => openApprovalDialog(row, 'approve'),
type: 'primary'
})
}
if (canManualApproveRefund(row) && hasAuth('refund:reject')) {
actions.push({
label: '拒绝',
handler: () => openApprovalDialog(row, 'reject'),
type: 'danger'
})
}
if (canManualApproveRefund(row) && hasAuth('refund:return')) {
actions.push({
label: '退回',
handler: () => openApprovalDialog(row, 'return'),
type: 'primary'
})
}
if (canResubmit(row) && hasAuth('refund:resubmit')) {
actions.push({
label: '重新申请',
@@ -876,4 +1073,11 @@
.refund-page {
height: 100%;
}
.resubmit-voucher-tip {
margin-top: 4px;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -936,7 +936,8 @@
{
prop: 'shop_name',
label: '店铺名称',
minWidth: 140
showOverflowTooltip: true,
minWidth: 160
},
{
prop: 'allocator_shop_name',

View File

@@ -0,0 +1,499 @@
<template>
<ArtTableFullScreen>
<div class="traffic-alert-rules-page" id="table-full-screen">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
label-width="90"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #actions>
<ElButton
v-if="canCreate"
type="primary"
:icon="Plus"
@click="openFormDialog('create')"
>
新增规则
</ElButton>
</template>
</ArtTableHeader>
<ArtTable
:loading="loading"
:data="ruleList"
:currentPage="pagination.page"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="150"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
<ElDialog
v-model="formDialogVisible"
:title="formMode === 'create' ? '新增真流量预警规则' : '编辑真流量预警规则'"
width="560px"
:close-on-click-modal="false"
destroy-on-close
@closed="resetForm"
>
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="120px">
<ElFormItem label="套餐商品" prop="package_id">
<ElSelect
v-model="form.package_id"
filterable
remote
clearable
:remote-method="searchPackages"
:loading="packageLoading"
:disabled="formMode === 'edit'"
placeholder="输入套餐名称搜索"
style="width: 100%"
>
<ElOption
v-for="item in packageOptions"
:key="item.id"
:label="item.package_name"
:value="item.id"
>
<span>{{ item.package_name }}</span>
<span class="package-option-extra">真流量 {{ formatMbToGb(item.real_data_mb) }}</span>
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem label="预警阈值(%)" prop="threshold_percent">
<ElInputNumber
v-model="form.threshold_percent"
:min="1"
:max="100"
:precision="2"
:step="1"
:controls="false"
placeholder="1~100允许两位小数"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="是否启用" prop="enabled">
<ElSwitch v-model="form.enabled" />
</ElFormItem>
<ElFormItem label="备注" prop="remark">
<ElInput
v-model="form.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入备注(最多 500 字符)"
/>
</ElFormItem>
</ElForm>
<ElAlert
type="info"
:closable="false"
show-icon
class="rule-tip-alert"
title="同一套餐商品最多一条规则;修改不影响既有预警快照,停用后扫描不再创建新预警,既有预警保留。"
/>
<template #footer>
<ElButton @click="formDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">确定</ElButton>
</template>
</ElDialog>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, ElTag, FormInstance, FormRules } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { PackageManageService, PackageTrafficAlertService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import { normalizeApiError } from '@/utils/business/apiError'
import { AUGUST_PERMISSIONS } from '@/config/constants'
import type { SearchFormItem } from '@/types'
import type {
PackageResponse,
PackageTrafficAlertRuleItem,
UpdatePackageTrafficAlertRuleParams
} from '@/types/api'
defineOptions({ name: 'TrafficAlertRules' })
const { hasAuth } = useAuth()
const perms = AUGUST_PERMISSIONS.packageTrafficAlert
const canCreate = computed(() => hasAuth(perms.ruleCreate))
const canUpdate = computed(() => hasAuth(perms.ruleUpdate))
// ========== 列表数据 ==========
const loading = ref(false)
const ruleList = ref<PackageTrafficAlertRuleItem[]>([])
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
const initialSearchState = {
package_id: undefined as number | undefined,
enabled: undefined as number | undefined
}
const searchForm = reactive({ ...initialSearchState })
const statusOptions = [
{ label: '启用', value: 1 },
{ label: '停用', value: 0 }
]
const packageOptions = ref<PackageResponse[]>([])
const packageLoading = ref(false)
const searchPackages = async (query: string = '') => {
packageLoading.value = true
try {
const res = await PackageManageService.getPackages({
page: 1,
page_size: 20,
package_name: query || undefined
})
if (res.code === 0 && res.data) {
packageOptions.value = res.data.items || []
}
} catch (error) {
console.error('搜索套餐失败:', error)
} finally {
packageLoading.value = false
}
}
const searchFormItems = computed<SearchFormItem[]>(() => [
{
label: '套餐',
prop: 'package_id',
type: 'select',
config: {
clearable: true,
filterable: true,
remote: true,
remoteMethod: searchPackages,
loading: packageLoading.value
},
options: () =>
packageOptions.value.map((item) => ({ label: item.package_name, value: item.id }))
},
{
label: '启用状态',
prop: 'enabled',
type: 'select',
config: { clearable: true, placeholder: '全部' },
options: () => statusOptions
}
])
const formatMbToGb = (mb?: number | null): string => {
if (mb === undefined || mb === null || Number.isNaN(mb)) return '-'
return `${(mb / 1024).toFixed(2)} GB`
}
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'package_name',
label: '套餐名称',
minWidth: 180,
showOverflowTooltip: true
},
{
prop: 'real_data_mb',
label: '当前真流量额度',
width: 150,
formatter: (row: PackageTrafficAlertRuleItem) => formatMbToGb(row.real_data_mb)
},
{
prop: 'threshold_percent',
label: '预警阈值',
width: 110,
formatter: (row: PackageTrafficAlertRuleItem) =>
row.threshold_percent === undefined || row.threshold_percent === null
? '-'
: `${row.threshold_percent}%`
},
{
prop: 'enabled',
label: '启用状态',
width: 100,
formatter: (row: PackageTrafficAlertRuleItem) =>
h(
ElTag,
{ type: row.enabled ? 'success' : 'info' },
() => row.enabled_name || (row.enabled ? '启用' : '停用')
)
},
{
prop: 'remark',
label: '备注',
minWidth: 160,
showOverflowTooltip: true,
formatter: (row: PackageTrafficAlertRuleItem) => row.remark || '-'
},
{
prop: 'updated_at',
label: '更新时间',
minWidth: 170,
formatter: (row: PackageTrafficAlertRuleItem) => formatDateTime(row.updated_at)
}
])
const columnOptions = [
{ label: '套餐名称', prop: 'package_name' },
{ label: '当前真流量额度', prop: 'real_data_mb' },
{ label: '预警阈值', prop: 'threshold_percent' },
{ label: '启用状态', prop: 'enabled' },
{ label: '备注', prop: 'remark' },
{ label: '更新时间', prop: 'updated_at' }
]
const getTableData = async () => {
loading.value = true
try {
const res = await PackageTrafficAlertService.getAlertRules({
page: pagination.page,
page_size: pagination.pageSize,
package_id: searchForm.package_id ?? undefined,
enabled:
searchForm.enabled === undefined || searchForm.enabled === null
? undefined
: searchForm.enabled === 1
})
if (res.code === 0 && res.data) {
ruleList.value = res.data.items || []
pagination.total = res.data.total || 0
} else {
ElMessage.error(res.msg || '获取真流量预警规则列表失败')
}
} catch (error) {
console.error('获取真流量预警规则列表失败:', error)
ElMessage.error(normalizeApiError(error).message || '获取真流量预警规则列表失败')
} finally {
loading.value = false
}
}
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
getTableData()
}
const handleSearch = () => {
pagination.page = 1
getTableData()
}
const handleRefresh = () => {
getTableData()
}
const handleSizeChange = (pageSize: number) => {
pagination.pageSize = pageSize
getTableData()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
getTableData()
}
const refreshRow = (updated: PackageTrafficAlertRuleItem) => {
const index = ruleList.value.findIndex((item) => item.id === updated.id)
if (index >= 0) {
ruleList.value.splice(index, 1, updated)
}
}
// ========== 行操作 ==========
const getActions = (row: PackageTrafficAlertRuleItem) => {
const actions: Array<{
label: string
handler: () => void
type?: 'primary' | 'danger'
}> = []
if (!canUpdate.value) return actions
actions.push({ label: '编辑', handler: () => openFormDialog('edit', row) })
if (row.enabled) {
actions.push({
label: '停用',
handler: () => handleToggleEnabled(row, false),
type: 'danger' as const
})
} else {
actions.push({
label: '启用',
handler: () => handleToggleEnabled(row, true),
type: 'primary' as const
})
}
return actions
}
const handleToggleEnabled = async (row: PackageTrafficAlertRuleItem, enabled: boolean) => {
if (!enabled) {
try {
await ElMessageBox.confirm(
'停用后扫描不再创建新预警,既有预警保留。确认停用该规则吗?',
'停用确认',
{ type: 'warning', confirmButtonText: '确认停用', cancelButtonText: '取消' }
)
} catch {
return
}
}
try {
const res = await PackageTrafficAlertService.updateAlertRule(row.id, { enabled })
if (res.code === 0 && res.data) {
refreshRow(res.data)
ElMessage.success(enabled ? '已启用' : '已停用,不再创建新预警')
} else {
ElMessage.error(res.msg || '操作失败')
}
} catch (error) {
console.error('更新真流量预警规则失败:', error)
ElMessage.error(normalizeApiError(error).message || '操作失败')
}
}
// ========== 创建 / 编辑 ==========
const formDialogVisible = ref(false)
const submitting = ref(false)
const formMode = ref<'create' | 'edit'>('create')
const formRef = ref<FormInstance>()
const editingRuleId = ref<number | null>(null)
const emptyForm = () => ({
package_id: undefined as number | undefined,
threshold_percent: undefined as number | undefined,
enabled: true,
remark: ''
})
const form = reactive(emptyForm())
const resetForm = () => {
Object.assign(form, emptyForm())
editingRuleId.value = null
formRef.value?.clearValidate()
}
const formRules: FormRules = {
package_id: [{ required: true, message: '请选择套餐商品', trigger: 'change' }],
threshold_percent: [
{ required: true, message: '请输入预警阈值', trigger: 'blur' },
{
validator: (_rule, value: number | undefined, callback) => {
if (value === undefined || value === null || Number.isNaN(Number(value))) {
callback(new Error('请输入预警阈值'))
} else if (Number(value) < 1 || Number(value) > 100) {
callback(new Error('阈值取值 1 至 100'))
} else {
callback()
}
},
trigger: 'blur'
}
]
}
const openFormDialog = (mode: 'create' | 'edit', row?: PackageTrafficAlertRuleItem) => {
formMode.value = mode
resetForm()
if (mode === 'edit' && row) {
editingRuleId.value = row.id
form.package_id = row.package_id
form.threshold_percent = row.threshold_percent
form.enabled = row.enabled
form.remark = row.remark || ''
}
formDialogVisible.value = true
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitting.value = true
try {
let res
if (formMode.value === 'create') {
res = await PackageTrafficAlertService.createAlertRule({
package_id: form.package_id as number,
threshold_percent: form.threshold_percent as number,
enabled: form.enabled,
remark: form.remark || undefined
})
} else {
const payload: UpdatePackageTrafficAlertRuleParams = {
threshold_percent: form.threshold_percent as number,
enabled: form.enabled,
remark: form.remark
}
res = await PackageTrafficAlertService.updateAlertRule(
editingRuleId.value as number,
payload
)
}
if (res.code === 0 && res.data) {
ElMessage.success(formMode.value === 'create' ? '创建成功' : '修改成功')
formDialogVisible.value = false
if (formMode.value === 'create') {
getTableData()
} else {
refreshRow(res.data)
}
} else {
ElMessage.error(res.msg || '操作失败')
}
} catch (error) {
console.error('保存真流量预警规则失败:', error)
ElMessage.error(normalizeApiError(error).message || '操作失败')
} finally {
submitting.value = false
}
})
}
onMounted(() => {
getTableData()
})
</script>
<style lang="scss" scoped>
.traffic-alert-rules-page {
.package-option-extra {
margin-left: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.rule-tip-alert {
margin-top: 12px;
}
}
</style>

View File

@@ -0,0 +1,196 @@
<template>
<div class="traffic-alert-detail-page">
<ElCard shadow="never">
<div class="detail-header">
<ElButton @click="handleBack">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">真流量达量预警详情</h2>
</div>
<div v-if="loading" class="loading-container">
<ElIcon class="is-loading"><Loading /></ElIcon>
<span>加载中...</span>
</div>
<template v-else-if="detail">
<ElAlert
v-if="detail.shop_changed_since_trigger || detail.owner_changed_since_trigger"
type="warning"
:closable="false"
show-icon
class="ownership-change-alert"
>
<template #title>{{ changeAlertText }}</template>
</ElAlert>
<DetailPage :sections="detailSections" :data="detail" />
</template>
<ElResult
v-else
icon="warning"
title="无法查看该预警记录"
sub-title="预警记录不存在或您没有查看权限"
/>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElTag } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import { PackageTrafficAlertService } from '@/api/modules'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { formatDateTime } from '@/utils/business/format'
import { normalizeApiError } from '@/utils/business/apiError'
import {
getTrafficAlertAssetTypeName,
getTrafficAlertNotificationStatusOption
} from '@/config/constants'
import type { PackageTrafficAlertRecordDetail } from '@/types/api'
defineOptions({ name: 'TrafficAlertDetail' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const detail = ref<PackageTrafficAlertRecordDetail | null>(null)
const recordId = computed(() => Number(route.params.id))
const changeAlertText = computed(() => {
if (!detail.value) return ''
const parts: string[] = []
if (detail.value.shop_changed_since_trigger) parts.push('触发后店铺归属已变化')
if (detail.value.owner_changed_since_trigger) parts.push('触发后业务员归属已变化')
return `${parts.join('')},以下店铺/业务员信息为触发时快照。`
})
const detailSections: DetailSection[] = [
{
title: '触发信息',
fields: [
{ label: '套餐', prop: 'package_name' },
{
label: '触发阈值',
prop: 'threshold_percent',
formatter: (value: unknown) => (value === undefined || value === null ? '-' : `${value}%`)
},
{
label: '触发时间',
prop: 'triggered_at',
formatter: (value: unknown) => formatDateTime(value as string)
}
]
},
{
title: '触发时归属快照',
fields: [
{ label: '店铺', prop: 'shop_name' },
{ label: '业务员', prop: 'business_owner_username' },
{
label: '业务用户组(当前)',
prop: 'business_user_group_names',
formatter: (value: unknown) =>
Array.isArray(value) && value.length ? (value as string[]).join('、') : '-'
}
]
},
{
title: '资产信息',
fields: [
{
label: '资产类型',
prop: 'asset_type',
formatter: (value: unknown) => getTrafficAlertAssetTypeName(value as never)
},
{ label: '资产标识', prop: 'asset_identifier' }
]
},
{
title: '通知结果',
fields: [
{
label: '通知状态',
render: (data: PackageTrafficAlertRecordDetail) => {
const option = getTrafficAlertNotificationStatusOption(data.notification_status)
return h(
ElTag,
{ type: option?.tagType || 'info' },
() => data.notification_status_name || option?.label || '-'
)
}
}
]
}
]
const fetchDetail = async () => {
loading.value = true
detail.value = null
try {
const res = await PackageTrafficAlertService.getAlertRecordDetail(recordId.value)
if (res.code === 0 && res.data) {
detail.value = res.data
}
} catch (error) {
const normalized = normalizeApiError(error)
// 403/404 统一按资源不可见处理,不额外弹错
if (normalized.status !== 403 && normalized.status !== 404) {
ElMessage.error(normalized.message || '获取预警记录详情失败')
}
} finally {
loading.value = false
}
}
const handleBack = () => {
router.back()
}
onMounted(() => {
if (!Number.isFinite(recordId.value) || recordId.value <= 0) {
return
}
fetchDetail()
})
</script>
<style lang="scss" scoped>
.traffic-alert-detail-page {
.detail-header {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
.detail-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.ownership-change-alert {
margin-bottom: 16px;
}
.loading-container {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
padding: 60px 0;
color: var(--el-text-color-secondary);
}
}
</style>

View File

@@ -0,0 +1,533 @@
<template>
<ArtTableFullScreen>
<div class="traffic-alerts-page" id="table-full-screen">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
label-width="90"
show-expand
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #actions>
<ElButton v-if="canExport" :icon="Download" @click="openExportDialog"> 导出 </ElButton>
</template>
</ArtTableHeader>
<ArtTable
:loading="loading"
:data="recordList"
:currentPage="pagination.page"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="100"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
<ElDialog
v-model="exportDialogVisible"
title="导出真流量达量预警"
width="40%"
:close-on-click-modal="false"
destroy-on-close
>
<ElAlert type="info" :closable="false" show-icon class="export-rule-alert">
<template #title>导出将基于当前筛选条件全量导出不会仅导出当前分页数据</template>
</ElAlert>
<ElForm label-width="80px" class="export-task-form">
<ElFormItem label="导出格式">
<ElRadioGroup v-model="exportFormat">
<ElRadio
v-for="item in TRAFFIC_ALERT_EXPORT_FORMAT_OPTIONS"
:key="item.value"
:label="item.value"
>
{{ item.label }}
</ElRadio>
</ElRadioGroup>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="exportDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="exportSubmitting" @click="handleExportSubmit">
创建导出任务
</ElButton>
</template>
</ElDialog>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, ElRadio, ElRadioGroup, ElTag } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import { PackageManageService, PackageTrafficAlertService, ShopService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import { normalizeApiError } from '@/utils/business/apiError'
import {
AUGUST_PERMISSIONS,
TRAFFIC_ALERT_ASSET_TYPE_OPTIONS,
TRAFFIC_ALERT_EXPORT_FORMAT_OPTIONS,
TRAFFIC_ALERT_NOTIFICATION_STATUS_OPTIONS,
getTrafficAlertAssetTypeName,
getTrafficAlertNotificationStatusOption
} from '@/config/constants'
import { RoutesAlias } from '@/router/routesAlias'
import type { SearchFormItem } from '@/types'
import type {
PackageResponse,
PackageTrafficAlertAssetType,
PackageTrafficAlertNotificationStatus,
PackageTrafficAlertRecordItem,
PackageTrafficAlertRecordQueryParams,
ShopBusinessOwnerCandidate,
ShopResponse
} from '@/types/api'
defineOptions({ name: 'TrafficAlerts' })
const router = useRouter()
const { hasAuth } = useAuth()
const perms = AUGUST_PERMISSIONS.packageTrafficAlert
const canViewDetail = computed(() => hasAuth(perms.recordDetail))
const canExport = computed(() => hasAuth(perms.export))
// ========== 列表数据 ==========
const loading = ref(false)
const recordList = ref<PackageTrafficAlertRecordItem[]>([])
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
const initialSearchState = {
package_id: undefined as number | undefined,
shop_id: undefined as number | undefined,
business_owner_account_id: undefined as number | undefined,
asset_type: undefined as PackageTrafficAlertAssetType | undefined,
asset_identifier: '',
threshold_percent: '',
dateRange: [] as string[],
notification_status: undefined as PackageTrafficAlertNotificationStatus | undefined,
start_time: '',
end_time: ''
}
const searchForm = reactive({ ...initialSearchState })
// 筛选下拉选项
const packageOptions = ref<PackageResponse[]>([])
const packageLoading = ref(false)
const shopOptions = ref<ShopResponse[]>([])
const shopLoading = ref(false)
const ownerOptions = ref<ShopBusinessOwnerCandidate[]>([])
const ownerLoading = ref(false)
const searchPackages = async (query: string = '') => {
packageLoading.value = true
try {
const res = await PackageManageService.getPackages({
page: 1,
page_size: 20,
package_name: query || undefined
})
if (res.code === 0 && res.data) {
packageOptions.value = res.data.items || []
}
} catch (error) {
console.error('搜索套餐失败:', error)
} finally {
packageLoading.value = false
}
}
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 && res.data) {
shopOptions.value = res.data.items || []
}
} catch (error) {
console.error('搜索店铺失败:', error)
} finally {
shopLoading.value = false
}
}
const searchOwners = async (query: string = '') => {
ownerLoading.value = true
try {
const res = await ShopService.getBusinessOwnerCandidates({
page: 1,
page_size: 20,
keyword: query || undefined
})
if (res.code === 0 && res.data) {
ownerOptions.value = res.data.items || []
}
} catch (error) {
console.error('搜索业务员失败:', error)
} finally {
ownerLoading.value = false
}
}
const searchFormItems = computed<SearchFormItem[]>(() => [
{
label: '套餐',
prop: 'package_id',
type: 'select',
config: {
clearable: true,
filterable: true,
remote: true,
remoteMethod: searchPackages,
loading: packageLoading.value,
placeholder: '请输入套餐名称搜索'
},
options: () =>
packageOptions.value.map((item) => ({ label: item.package_name, value: item.id }))
},
{
label: '店铺',
prop: 'shop_id',
type: 'select',
config: {
clearable: true,
filterable: true,
remote: true,
remoteMethod: searchShops,
loading: shopLoading.value,
placeholder: '请输入店铺名称搜索'
},
options: () => shopOptions.value.map((item) => ({ label: item.shop_name, value: item.id }))
},
{
label: '业务员',
prop: 'business_owner_account_id',
type: 'select',
config: {
clearable: true,
filterable: true,
remote: true,
remoteMethod: searchOwners,
loading: ownerLoading.value,
placeholder: '请输入账号名或手机号搜索'
},
options: () => ownerOptions.value.map((item) => ({ label: item.username, value: item.id }))
},
{
label: '资产类型',
prop: 'asset_type',
type: 'select',
config: { clearable: true, placeholder: '全部' },
options: TRAFFIC_ALERT_ASSET_TYPE_OPTIONS
},
{
label: '资产关键词',
prop: 'asset_identifier',
type: 'input',
config: { clearable: true, placeholder: '资产标识/卡标识关键词' }
},
{
label: '触发阈值(%)',
prop: 'threshold_percent',
type: 'input',
config: { clearable: true, placeholder: '1~100允许两位小数' }
},
{
label: '触发时间',
prop: 'dateRange',
type: 'datetimerange',
config: {
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
}
},
{
label: '通知状态',
prop: 'notification_status',
type: 'select',
config: { clearable: true, placeholder: '全部' },
options: TRAFFIC_ALERT_NOTIFICATION_STATUS_OPTIONS
}
])
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'package_name',
label: '套餐名称',
minWidth: 160,
showOverflowTooltip: true,
formatter: (row: PackageTrafficAlertRecordItem) => row.package_name || '-'
},
{
prop: 'shop_name',
label: '店铺',
minWidth: 160,
showOverflowTooltip: true,
formatter: (row: PackageTrafficAlertRecordItem) => row.shop_name || '-'
},
{
prop: 'business_owner_username',
label: '业务员',
width: 130,
showOverflowTooltip: true,
formatter: (row: PackageTrafficAlertRecordItem) => row.business_owner_username || '-'
},
{
prop: 'business_user_group_names',
label: '业务用户组',
minWidth: 150,
showOverflowTooltip: true,
formatter: (row: PackageTrafficAlertRecordItem) =>
row.business_user_group_names?.length ? row.business_user_group_names.join('、') : '-'
},
{
prop: 'asset_type',
label: '资产类型',
width: 100,
formatter: (row: PackageTrafficAlertRecordItem) =>
getTrafficAlertAssetTypeName(row.asset_type)
},
{
prop: 'asset_identifier',
label: '资产标识',
minWidth: 160,
showOverflowTooltip: true,
formatter: (row: PackageTrafficAlertRecordItem) => row.asset_identifier || '-'
},
{
prop: 'threshold_percent',
label: '触发阈值',
width: 100,
formatter: (row: PackageTrafficAlertRecordItem) =>
row.threshold_percent === undefined || row.threshold_percent === null
? '-'
: `${row.threshold_percent}%`
},
{
prop: 'triggered_at',
label: '触发时间',
width: 180,
formatter: (row: PackageTrafficAlertRecordItem) => formatDateTime(row.triggered_at)
},
{
prop: 'notification_status',
label: '通知状态',
width: 180,
formatter: (row: PackageTrafficAlertRecordItem) => {
const option = getTrafficAlertNotificationStatusOption(row.notification_status)
return h(
ElTag,
{ type: option?.tagType || 'info' },
() => row.notification_status_name || option?.label || '-'
)
}
}
])
const columnOptions = [
{ label: '套餐名称', prop: 'package_name' },
{ label: '店铺', prop: 'shop_name' },
{ label: '业务员', prop: 'business_owner_username' },
{ label: '业务用户组', prop: 'business_user_group_names' },
{ label: '资产类型', prop: 'asset_type' },
{ label: '资产标识', prop: 'asset_identifier' },
{ label: '触发阈值', prop: 'threshold_percent' },
{ label: '触发时间', prop: 'triggered_at' },
{ label: '通知状态', prop: 'notification_status' }
]
const buildQueryParams = (): PackageTrafficAlertRecordQueryParams => ({
package_id: searchForm.package_id,
shop_id: searchForm.shop_id,
business_owner_account_id: searchForm.business_owner_account_id,
asset_type: searchForm.asset_type,
asset_identifier: searchForm.asset_identifier.trim() || undefined,
threshold_percent: searchForm.threshold_percent
? Number(searchForm.threshold_percent)
: undefined,
start_time: searchForm.start_time || undefined,
end_time: searchForm.end_time || undefined,
notification_status: searchForm.notification_status
})
const getTableData = async () => {
loading.value = true
try {
const res = await PackageTrafficAlertService.getAlertRecords({
...buildQueryParams(),
page: pagination.page,
page_size: pagination.pageSize
})
if (res.code === 0 && res.data) {
recordList.value = res.data.items || []
pagination.total = res.data.total || 0
} else {
ElMessage.error(res.msg || '获取真流量达量预警记录失败')
}
} catch (error) {
console.error('获取真流量达量预警记录失败:', error)
ElMessage.error(normalizeApiError(error).message || '获取真流量达量预警记录失败')
} finally {
loading.value = false
}
}
const validateThreshold = (): boolean => {
const raw = searchForm.threshold_percent.trim()
if (!raw) return true
if (!/^\d+(\.\d{1,2})?$/.test(raw) || Number(raw) < 1 || Number(raw) > 100) {
ElMessage.warning('触发阈值须为 1 至 100 之间的数字,允许两位小数')
return false
}
return true
}
const syncDateRange = () => {
if (Array.isArray(searchForm.dateRange) && searchForm.dateRange.length === 2) {
searchForm.start_time = searchForm.dateRange[0]
searchForm.end_time = searchForm.dateRange[1]
} else {
searchForm.start_time = ''
searchForm.end_time = ''
}
}
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
getTableData()
}
const handleSearch = () => {
if (!validateThreshold()) return
syncDateRange()
pagination.page = 1
getTableData()
}
const handleRefresh = () => {
getTableData()
}
const handleSizeChange = (pageSize: number) => {
pagination.pageSize = pageSize
getTableData()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
getTableData()
}
// ========== 行操作 ==========
const goDetail = (row: PackageTrafficAlertRecordItem) => {
router.push(`${RoutesAlias.TrafficAlertDetail}/${row.id}`)
}
const getActions = (row: PackageTrafficAlertRecordItem) => {
if (!canViewDetail.value) return []
return [
{
label: '详情',
handler: () => goDetail(row),
type: 'primary' as const
}
]
}
// ========== 导出 ==========
const exportDialogVisible = ref(false)
const exportSubmitting = ref(false)
const exportFormat = ref<'xlsx' | 'csv'>('xlsx')
const openExportDialog = () => {
if (!validateThreshold()) return
exportFormat.value = 'xlsx'
exportDialogVisible.value = true
}
const handleExportSubmit = async () => {
exportSubmitting.value = true
try {
syncDateRange()
const res = await PackageTrafficAlertService.exportAlertRecords({
format: exportFormat.value,
...buildQueryParams()
})
if (res.code === 0 && res.data) {
exportDialogVisible.value = false
if (res.data.task_id) {
localStorage.setItem('export-task-active:package_traffic_alert', String(res.data.task_id))
}
ElMessageBox.alert(
res.data.message || '导出任务已创建,请在导出任务列表中下载',
'导出任务已创建',
{
confirmButtonText: '前往导出任务列表',
type: 'success'
}
)
.then(() => {
router.push(RoutesAlias.ExportPackageTrafficAlertTaskList)
})
.catch(() => undefined)
} else {
ElMessage.error(res.msg || '创建导出任务失败')
}
} catch (error) {
console.error('创建真流量达量预警导出任务失败:', error)
ElMessage.error(normalizeApiError(error).message || '创建导出任务失败')
} finally {
exportSubmitting.value = false
}
}
onMounted(() => {
getTableData()
searchPackages('')
searchShops('')
searchOwners('')
})
</script>
<style lang="scss" scoped>
.traffic-alerts-page {
.export-rule-alert {
margin-bottom: 18px;
}
.export-task-form {
padding-top: 4px;
}
}
</style>

View File

@@ -0,0 +1,810 @@
<template>
<ArtTableFullScreen>
<div class="h5-popup-configuration-page" id="table-full-screen">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
label-width="110"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #actions>
<ElButton v-if="canCreate" type="primary" :icon="Plus" @click="openCreateDialog">
新增配置
</ElButton>
</template>
</ArtTableHeader>
<ArtTable
:loading="loading"
:data="configurationList"
:currentPage="pagination.page"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="200"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
<!-- 创建 / 编辑 -->
<ElDialog
v-model="formDialogVisible"
:title="formMode === 'create' ? '新增运营弹窗配置' : '编辑运营弹窗配置'"
width="680px"
:close-on-click-modal="false"
destroy-on-close
@closed="resetForm"
>
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="110px">
<ElFormItem label="弹窗标题" prop="title">
<ElInput
v-model="form.title"
maxlength="100"
show-word-limit
placeholder="请输入弹窗标题1~100 字符)"
/>
</ElFormItem>
<ElFormItem label="弹窗正文" prop="content">
<ElInput
v-model="form.content"
type="textarea"
:rows="4"
maxlength="2000"
show-word-limit
placeholder="请输入弹窗正文1~2000 字符,不接受 HTML、URL 或前端路由)"
/>
</ElFormItem>
<ElFormItem label="命中页面" prop="pages">
<ElCheckboxGroup v-model="form.pages">
<ElCheckbox v-for="item in PAGE_OPTIONS" :key="item.value" :label="item.value">
{{ item.label }}
</ElCheckbox>
</ElCheckboxGroup>
</ElFormItem>
<ElFormItem label="投放频率" prop="frequency">
<ElSelect v-model="form.frequency" placeholder="请选择投放频率" style="width: 100%">
<ElOption
v-for="item in FREQUENCY_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="生效时间" prop="timeRange">
<ElDatePicker
v-model="form.timeRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DDTHH:mm:ssZ"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="优先级" prop="priority">
<ElInputNumber
v-model="form.priority"
:min="0"
:max="1000000"
:controls="false"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="是否启用" prop="enabled">
<ElSwitch v-model="form.enabled" />
</ElFormItem>
<ElFormItem label="受控动作" prop="action_type">
<ElSelect v-model="form.action_type" placeholder="无" style="width: 100%">
<ElOption
v-for="item in ACTION_TYPE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="店铺范围" prop="shop_ids">
<ElSelect
v-model="form.shop_ids"
multiple
filterable
remote
:remote-method="searchShops"
:loading="shopLoading"
placeholder="选择店铺(不选表示全量投放)"
style="width: 100%"
>
<ElOption
v-for="shop in shopOptions"
:key="shop.id"
:label="shop.shop_name"
:value="shop.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="设备类型范围" prop="device_types">
<ElSelect
v-model="form.device_types"
multiple
filterable
allow-create
default-first-option
placeholder="选择或输入设备类型(不选表示全量投放)"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="卡类型范围" prop="card_types">
<ElSelect
v-model="form.card_types"
multiple
filterable
placeholder="选择卡类型(不选表示全量投放)"
style="width: 100%"
>
<ElOption
v-for="item in CARD_TYPE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="formDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="formLoading" @click="handleSubmit">
{{ formMode === 'create' ? '创建' : '保存' }}
</ElButton>
</template>
</ElDialog>
<!-- 详情 -->
<ElDialog v-model="detailDialogVisible" title="运营弹窗配置详情" width="680px">
<div v-if="detailLoading" class="detail-loading">加载中...</div>
<ElDescriptions v-else-if="detail" :column="2" border>
<ElDescriptionsItem label="配置ID">{{ detail.id }}</ElDescriptionsItem>
<ElDescriptionsItem label="版本">{{ detail.version }}</ElDescriptionsItem>
<ElDescriptionsItem label="标题" :span="2">{{ detail.title }}</ElDescriptionsItem>
<ElDescriptionsItem label="正文" :span="2">
<span class="detail-content">{{ detail.content }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="命中页面" :span="2">
{{ formatPages(detail.pages) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="店铺范围">
{{ formatScope(detail.shop_ids) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="设备类型范围">
{{ formatScope(detail.device_types) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="卡类型范围">
{{ formatCardTypes(detail.card_types) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="优先级">{{ detail.priority }}</ElDescriptionsItem>
<ElDescriptionsItem label="投放频率">
{{ detail.frequency_text || FREQUENCY_NAMES[detail.frequency] || detail.frequency }}
</ElDescriptionsItem>
<ElDescriptionsItem label="启停状态">
{{ detail.enabled_text || (detail.enabled ? '启用' : '停用') }}
</ElDescriptionsItem>
<ElDescriptionsItem label="受控动作">
{{ ACTION_TYPE_NAMES[detail.action_type] || '无' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="生效开始" :span="2">
{{ formatDateTime(detail.starts_at) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="生效结束" :span="2">
{{ formatDateTime(detail.ends_at) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="创建人">{{ detail.creator }}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{
formatDateTime(detail.created_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="最近更新人">{{ detail.updater }}</ElDescriptionsItem>
<ElDescriptionsItem label="最近更新时间">{{
formatDateTime(detail.updated_at)
}}</ElDescriptionsItem>
</ElDescriptions>
</ElDialog>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, ElTag, FormInstance, FormRules } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { H5PopupConfigurationService, ShopService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import { normalizeApiError } from '@/utils/business/apiError'
import { AUGUST_PERMISSIONS } from '@/config/constants'
import type { SearchFormItem } from '@/types'
import type {
H5PopupActionType,
H5PopupCardType,
H5PopupConfiguration,
H5PopupFrequency,
H5PopupPage
} from '@/types/api'
defineOptions({ name: 'H5PopupConfiguration' })
const { hasAuth } = useAuth()
const perms = AUGUST_PERMISSIONS.h5PopupConfiguration
const canCreate = computed(() => hasAuth(perms.create))
const canUpdate = computed(() => hasAuth(perms.update))
const canEnable = computed(() => hasAuth(perms.enable))
const canDisable = computed(() => hasAuth(perms.disable))
const PAGE_NAMES: Record<H5PopupPage, string> = {
home: '首页',
asset_detail: '资产详情',
package_purchase: '套餐购买',
asset_wallet_recharge: '资产钱包充值'
}
const PAGE_OPTIONS: Array<{ label: string; value: H5PopupPage }> = [
{ label: '首页', value: 'home' },
{ label: '资产详情', value: 'asset_detail' },
{ label: '套餐购买', value: 'package_purchase' },
{ label: '资产钱包充值', value: 'asset_wallet_recharge' }
]
const FREQUENCY_NAMES: Record<H5PopupFrequency, string> = {
once: '每客户每配置版本仅一次',
daily: '每客户每配置版本每个上海自然日一次'
}
const FREQUENCY_OPTIONS: Array<{ label: string; value: H5PopupFrequency }> = [
{ label: '仅一次(每配置版本)', value: 'once' },
{ label: '每个上海自然日一次(每配置版本)', value: 'daily' }
]
const ACTION_TYPE_NAMES: Record<string, string> = {
'': '无',
package_purchase: '套餐购买',
asset_wallet_recharge: '资产钱包充值'
}
const ACTION_TYPE_OPTIONS: Array<{ label: string; value: string }> = [
{ label: '无', value: '' },
{ label: '套餐购买', value: 'package_purchase' },
{ label: '资产钱包充值', value: 'asset_wallet_recharge' }
]
const CARD_TYPE_NAMES: Record<H5PopupCardType, string> = {
CMCC: '中国移动',
CUCC: '中国联通',
CTCC: '中国电信',
CBN: '中国广电'
}
const CARD_TYPE_OPTIONS: Array<{ label: string; value: H5PopupCardType }> = [
{ label: '中国移动', value: 'CMCC' },
{ label: '中国联通', value: 'CUCC' },
{ label: '中国电信', value: 'CTCC' },
{ label: '中国广电', value: 'CBN' }
]
const loading = ref(false)
const configurationList = ref<H5PopupConfiguration[]>([])
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
const initialSearchState = {
enabled: undefined as number | undefined
}
const searchForm = reactive({ ...initialSearchState })
const statusOptions = [
{ label: '启用', value: 1 },
{ label: '停用', value: 0 }
]
const searchFormItems: SearchFormItem[] = [
{
label: '启停状态',
prop: 'enabled',
type: 'select',
config: { clearable: true, placeholder: '全部' },
options: () => statusOptions
}
]
const columnOptions = [
{ label: '标题', prop: 'title' },
{ label: '命中页面', prop: 'pages' },
{ label: '范围', prop: 'scope' },
{ label: '优先级', prop: 'priority' },
{ label: '频率', prop: 'frequency_text' },
{ label: '启停状态', prop: 'enabled' },
{ label: '受控动作', prop: 'action_type' },
{ label: '生效时间', prop: 'effective_time' },
{ label: '版本', prop: 'version' },
{ label: '创建人', prop: 'creator' },
{ label: '最近更新人', prop: 'updater' },
{ label: '最近更新时间', prop: 'updated_at' }
]
const formatPages = (pages: H5PopupPage[] | null | undefined): string => {
if (!pages || pages.length === 0) return '全部'
return pages.map((page) => PAGE_NAMES[page] || page).join('、')
}
const formatScope = (values: string[] | number[] | null | undefined): string => {
if (!values || values.length === 0) return '全部'
const text = values
.slice(0, 3)
.map((item) => (typeof item === 'number' && item !== 0 ? `#${item}` : String(item)))
.join('、')
return values.length > 3 ? `${text}${values.length}` : text
}
const formatCardTypes = (values: H5PopupCardType[] | null | undefined): string => {
if (!values || values.length === 0) return '全部'
return values.map((item) => CARD_TYPE_NAMES[item] || item).join('、')
}
const formatScopeColumn = (row: H5PopupConfiguration): string => {
const parts: string[] = []
parts.push(`店铺:${row.shop_ids?.length ? `${row.shop_ids.length}` : '全部'}`)
parts.push(
`设备:${
row.device_types?.length
? row.device_types.slice(0, 3).join('、') + (row.device_types.length > 3 ? ' 等' : '')
: '全部'
}`
)
parts.push(`卡型:${formatCardTypes(row.card_types)}`)
return parts.join('')
}
const { columnChecks, columns } = useCheckedColumns(() => [
{ prop: 'title', label: '标题', minWidth: 180, showOverflowTooltip: true },
{
prop: 'pages',
label: '命中页面',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: H5PopupConfiguration) => formatPages(row.pages)
},
{
prop: 'scope',
label: '范围',
minWidth: 200,
showOverflowTooltip: true,
formatter: (row: H5PopupConfiguration) => formatScopeColumn(row)
},
{ prop: 'priority', label: '优先级', width: 90 },
{
prop: 'frequency_text',
label: '频率',
minWidth: 150,
showOverflowTooltip: true,
formatter: (row: H5PopupConfiguration) =>
row.frequency_text || FREQUENCY_NAMES[row.frequency] || row.frequency
},
{
prop: 'enabled',
label: '启停状态',
width: 90,
formatter: (row: H5PopupConfiguration) =>
h(
ElTag,
{ type: row.enabled ? 'success' : 'info' },
() => row.enabled_text || (row.enabled ? '启用' : '停用')
)
},
{
prop: 'action_type',
label: '受控动作',
width: 120,
formatter: (row: H5PopupConfiguration) => ACTION_TYPE_NAMES[row.action_type] || '无'
},
{
prop: 'effective_time',
label: '生效时间',
minWidth: 170,
formatter: (row: H5PopupConfiguration) =>
`${formatDateTime(row.starts_at)} ~ ${formatDateTime(row.ends_at)}`
},
{ prop: 'version', label: '版本', width: 70 },
{
prop: 'creator',
label: '创建人',
width: 90,
formatter: (row: H5PopupConfiguration) => String(row.creator || 0)
},
{
prop: 'updater',
label: '最近更新人',
width: 110,
formatter: (row: H5PopupConfiguration) => String(row.updater || 0)
},
{
prop: 'updated_at',
label: '最近更新时间',
minWidth: 170,
formatter: (row: H5PopupConfiguration) => formatDateTime(row.updated_at)
}
])
// ========== 列表数据 ==========
const getTableData = async () => {
loading.value = true
try {
const res = await H5PopupConfigurationService.getConfigurations({
page: pagination.page,
page_size: pagination.pageSize,
enabled:
searchForm.enabled === undefined || searchForm.enabled === null
? undefined
: searchForm.enabled === 1
})
if (res.code === 0 && res.data) {
configurationList.value = res.data.items || []
pagination.total = res.data.total || 0
} else {
ElMessage.error(res.msg || '获取运营弹窗配置列表失败')
}
} catch (error) {
console.error('获取运营弹窗配置列表失败:', error)
ElMessage.error(normalizeApiError(error).message || '获取运营弹窗配置列表失败')
} finally {
loading.value = false
}
}
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
getTableData()
}
const handleSearch = () => {
pagination.page = 1
getTableData()
}
const handleRefresh = () => {
getTableData()
}
const handleSizeChange = (pageSize: number) => {
pagination.pageSize = pageSize
getTableData()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
getTableData()
}
const refreshRow = (updated: H5PopupConfiguration) => {
const index = configurationList.value.findIndex((item) => item.id === updated.id)
if (index >= 0) {
configurationList.value.splice(index, 1, updated)
}
}
// ========== 行操作 ==========
const getActions = (row: H5PopupConfiguration) => {
const actions: any[] = [
{ label: '详情', handler: () => openDetailDialog(row), type: 'primary' }
]
if (canUpdate.value) {
actions.push({ label: '编辑', handler: () => openEditDialog(row) })
}
if (canEnable.value && !row.enabled) {
actions.push({ label: '启用', handler: () => handleEnable(row), type: 'success' })
}
if (canDisable.value && row.enabled) {
actions.push({ label: '停用', handler: () => handleDisable(row), type: 'danger' })
}
return actions
}
const handleEnable = async (row: H5PopupConfiguration) => {
try {
const res = await H5PopupConfigurationService.enableConfiguration(row.id)
if (res.code === 0 && res.data) {
refreshRow(res.data)
ElMessage.success('启用后参与候选匹配')
} else {
ElMessage.error(res.msg || '启用失败')
}
} catch (error) {
console.error('启用运营弹窗配置失败:', error)
ElMessage.error(normalizeApiError(error).message || '启用失败')
}
}
const handleDisable = async (row: H5PopupConfiguration) => {
try {
await ElMessageBox.confirm(
'停用后停止新投放,历史通知在展示期内仍可见。确认停用该配置吗?',
'停用确认',
{ type: 'warning', confirmButtonText: '确认停用', cancelButtonText: '取消' }
)
} catch {
return
}
try {
const res = await H5PopupConfigurationService.disableConfiguration(row.id)
if (res.code === 0 && res.data) {
refreshRow(res.data)
ElMessage.success('停用后停止新投放,历史通知在展示期内仍可见')
} else {
ElMessage.error(res.msg || '停用失败')
}
} catch (error) {
console.error('停用运营弹窗配置失败:', error)
ElMessage.error(normalizeApiError(error).message || '停用失败')
}
}
// ========== 详情 ==========
const detailDialogVisible = ref(false)
const detailLoading = ref(false)
const detail = ref<H5PopupConfiguration | null>(null)
const openDetailDialog = async (row: H5PopupConfiguration) => {
detail.value = null
detailDialogVisible.value = true
detailLoading.value = true
try {
const res = await H5PopupConfigurationService.getConfiguration(row.id)
if (res.code === 0 && res.data) {
detail.value = res.data
} else {
ElMessage.error(res.msg || '获取配置详情失败')
detailDialogVisible.value = false
}
} catch (error) {
console.error('获取运营弹窗配置详情失败:', error)
ElMessage.error(normalizeApiError(error).message || '获取配置详情失败')
detailDialogVisible.value = false
} finally {
detailLoading.value = false
}
}
// ========== 创建 / 编辑 ==========
const formDialogVisible = ref(false)
const formLoading = ref(false)
const formMode = ref<'create' | 'edit'>('create')
const formRef = ref<FormInstance>()
const shopOptions = ref<Array<{ id: number; shop_name: string }>>([])
const shopLoading = ref(false)
const emptyForm = (): {
id: number
title: string
content: string
pages: H5PopupPage[]
priority: number
frequency: H5PopupFrequency
enabled: boolean
action_type: H5PopupActionType
shop_ids: number[]
device_types: string[]
card_types: H5PopupCardType[]
timeRange: string[]
} => ({
id: 0,
title: '',
content: '',
pages: [],
priority: 0,
frequency: 'once',
enabled: true,
action_type: '',
shop_ids: [],
device_types: [],
card_types: [],
timeRange: []
})
const form = reactive(emptyForm())
const resetForm = () => {
Object.assign(form, emptyForm())
formRef.value?.clearValidate()
}
const toPickerTime = (iso: string): string => {
if (!iso) return ''
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
date.getHours()
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}+08:00`
}
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 || []
}
} catch (error) {
console.error('搜索店铺失败:', error)
} finally {
shopLoading.value = false
}
}
const loadShopNames = async (ids: number[]) => {
const missing = ids.filter((id) => !shopOptions.value.some((shop) => shop.id === id))
if (missing.length === 0) return
await Promise.all(
missing.slice(0, 50).map(async (id) => {
try {
const res = await ShopService.getShops({ id, page: 1, page_size: 1 })
if (res.code === 0 && res.data.items?.length) {
shopOptions.value = [...shopOptions.value, res.data.items[0]]
}
} catch (error) {
console.error('获取店铺信息失败:', error)
}
})
)
}
const formRules: FormRules = {
title: [
{ required: true, message: '请输入弹窗标题', trigger: 'blur' },
{ min: 1, max: 100, message: '弹窗标题长度为 1~100 字符', trigger: 'blur' }
],
content: [
{ required: true, message: '请输入弹窗正文', trigger: 'blur' },
{ min: 1, max: 2000, message: '弹窗正文长度为 1~2000 字符', trigger: 'blur' }
],
pages: [
{
validator: (_rule, value: H5PopupPage[], callback) => {
if (!value || value.length === 0) callback(new Error('请至少选择一个命中页面'))
else callback()
},
trigger: 'change'
}
],
frequency: [{ required: true, message: '请选择投放频率', trigger: 'change' }],
timeRange: [
{
validator: (_rule, value: string[] | null, callback) => {
if (!value || value.length !== 2) {
callback(new Error('请选择生效时间'))
return
}
if (new Date(value[1]).getTime() < new Date(value[0]).getTime()) {
callback(new Error('生效结束时间不得早于开始时间'))
return
}
callback()
},
trigger: 'change'
}
]
}
const openCreateDialog = () => {
formMode.value = 'create'
resetForm()
formDialogVisible.value = true
shopOptions.value = []
searchShops('')
}
const openEditDialog = async (row: H5PopupConfiguration) => {
formMode.value = 'edit'
Object.assign(form, {
id: row.id,
title: row.title,
content: row.content,
pages: [...(row.pages || [])],
priority: row.priority,
frequency: row.frequency,
enabled: row.enabled,
action_type: (row.action_type || '') as H5PopupActionType,
shop_ids: [...(row.shop_ids || [])],
device_types: [...(row.device_types || [])],
card_types: [...(row.card_types || [])],
timeRange: [toPickerTime(row.starts_at), toPickerTime(row.ends_at)]
})
formDialogVisible.value = true
shopOptions.value = []
await Promise.all([searchShops(''), loadShopNames(form.shop_ids)])
}
const handleSubmit = async () => {
if (!(await formRef.value?.validate().catch(() => false))) return
formLoading.value = true
const payload = {
title: form.title.trim(),
content: form.content.trim(),
pages: [...form.pages],
priority: form.priority,
frequency: form.frequency,
starts_at: form.timeRange[0],
ends_at: form.timeRange[1],
enabled: form.enabled,
action_type: form.action_type,
shop_ids: [...form.shop_ids],
device_types: [...form.device_types],
card_types: [...form.card_types]
}
try {
if (formMode.value === 'create') {
const res = await H5PopupConfigurationService.createConfiguration(payload)
if (res.code === 0 && res.data) {
ElMessage.success('创建成功')
formDialogVisible.value = false
getTableData()
} else {
ElMessage.error(res.msg || '创建失败')
}
} else {
const res = await H5PopupConfigurationService.updateConfiguration(form.id, {
id: form.id,
...payload
})
if (res.code === 0 && res.data) {
ElMessage.success(
'保存成功:每次更新版本递增,旧版本通知保留原快照,新版本可向原命中客户按频率重新投放一次'
)
formDialogVisible.value = false
getTableData()
} else {
ElMessage.error(res.msg || '保存失败')
}
}
} catch (error) {
console.error('保存运营弹窗配置失败:', error)
ElMessage.error(normalizeApiError(error).message || '保存失败')
} finally {
formLoading.value = false
}
}
onMounted(() => {
getTableData()
})
</script>
<style scoped lang="scss">
.h5-popup-configuration-page {
height: 100%;
}
.detail-loading {
padding: 24px 0;
text-align: center;
color: var(--el-text-color-secondary);
}
.detail-content {
white-space: pre-wrap;
word-break: break-all;
}
</style>

View File

@@ -15,7 +15,7 @@
@refresh="loadMerchants"
>
<template #left>
<ElButton v-if="canManage" type="primary" :icon="Plus" @click="showCreateDrawer">
<ElButton v-if="canCreateMerchant" type="primary" :icon="Plus" @click="showCreateDrawer">
新增支付商户
</ElButton>
</template>
@@ -31,7 +31,7 @@
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="160"
:actionsWidth="120"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
@@ -48,7 +48,7 @@
destroy-on-close
@closed="clearSensitiveForm"
>
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="120px">
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="100px">
<ElFormItem label="商户名称" prop="name">
<ElInput v-model="form.name" maxlength="100" placeholder="请输入商户名称" />
</ElFormItem>
@@ -146,6 +146,32 @@
</div>
</div>
<ElButton type="primary" plain :icon="Plus" @click="addCredential">添加凭证字段</ElButton>
<template v-if="form.provider_type === 'wechat_v2'">
<ElDivider content-position="left">退款专用可选凭证原路退款</ElDivider>
<div class="refund-credential-fields">
<ElFormItem label="客户端证书内容">
<ElInput
:model-value="refundCredentialValue('wx_client_cert_content')"
type="textarea"
:rows="4"
placeholder="选填PEM 格式客户端证书内容(仅原路退款需要)"
@update:model-value="setRefundCredentialValue('wx_client_cert_content', $event)"
/>
</ElFormItem>
<ElFormItem label="客户端私钥内容">
<ElInput
:model-value="refundCredentialValue('wx_client_key_content')"
type="textarea"
:rows="4"
placeholder="选填PEM 格式客户端私钥内容(仅原路退款需要)"
@update:model-value="setRefundCredentialValue('wx_client_key_content', $event)"
/>
</ElFormItem>
</div>
<div v-if="refundCredentialIncomplete" class="credential-tip">
提示仅填写证书或私钥其一该商户暂不可用于原路退款不影响支付/查单/回调
</div>
</template>
<div v-if="credentialError" class="field-error" role="alert">
{{ credentialError }}
</div>
@@ -156,7 +182,7 @@
<ElButton
type="primary"
:loading="submitLoading"
:disabled="!canManage"
:disabled="formMode === 'create' ? !canCreateMerchant : !canEditMerchant"
@click="handleSubmit"
>
保存
@@ -174,7 +200,8 @@
import type { FormInstance, FormRules } from 'element-plus'
import { PaymentMerchantPoolsService } from '@/api/modules'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { usePermission } from '@/composables/usePermission'
import { useAuth } from '@/composables/useAuth'
import { PAYMENT_MERCHANT_POOL_PERMISSIONS } from '@/config/constants/paymentMerchantPools'
import type { SearchFormItem } from '@/types/component'
import {
PAYMENT_METHOD_OPTIONS,
@@ -198,16 +225,24 @@
type FilterVo = string | number | undefined | null | unknown[]
const { isPlatformAccount } = usePermission()
const router = useRouter()
const canManage = computed(() => isPlatformAccount.value)
const { hasAuth } = useAuth()
const canCreateMerchant = computed(() =>
hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.merchantCreate)
)
const canEditMerchant = computed(() => hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.merchantEdit))
const canToggleMerchant = computed(() =>
hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.merchantToggle)
)
const canDeleteMerchant = computed(() =>
hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.merchantDelete)
)
const canViewMerchantDetail = computed(() =>
hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.merchantDetail)
)
const handleNameClick = (row: PaymentMerchant) => {
if (!canManage.value) {
ElMessage.warning('您没有查看支付商户详情的权限')
return
}
router.push({
path: `${RoutesAlias.PaymentMerchantPoolsDetail}/${row.id}`
})
@@ -262,17 +297,19 @@
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: PaymentMerchant) =>
h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: (event: MouseEvent) => {
event.stopPropagation()
handleNameClick(row)
}
},
row.name
)
canViewMerchantDetail.value
? h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: (event: MouseEvent) => {
event.stopPropagation()
handleNameClick(row)
}
},
row.name
)
: row.name
},
{
label: '支付方式',
@@ -322,7 +359,6 @@
// 列表加载
const loadMerchants = async () => {
if (!canManage.value) return
loading.value = true
try {
const params: PaymentMerchantQueryParams = {
@@ -458,7 +494,7 @@
}
const showCreateDrawer = () => {
if (!canManage.value) return
if (!canCreateMerchant.value) return
Object.assign(form, initialFormState())
credentialTemplateKeys.value = []
formDrawerVisible.value = true
@@ -468,7 +504,7 @@
}
const startCredentialReplacement = () => {
if (!canManage.value) return
if (!canEditMerchant.value) return
form.credentials = []
if (credentialTemplateKeys.value.length > 0) {
form.credentials = credentialTemplateKeys.value.map((key) => ({
@@ -490,6 +526,33 @@
form.credentials.splice(index, 1)
}
const refundCredentialValue = (key: string): string => {
return form.credentials.find((entry) => entry.key === key)?.value || ''
}
const setRefundCredentialValue = (key: string, value: string) => {
const index = form.credentials.findIndex((entry) => entry.key === key)
const trimmed = value.trim()
if (index >= 0) {
if (trimmed) {
form.credentials[index].value = trimmed
} else {
form.credentials.splice(index, 1)
}
return
}
if (trimmed) {
form.credentials.push({ localId: generateLocalId(), key, value: trimmed })
}
}
const refundCredentialIncomplete = computed(() => {
if (form.provider_type !== 'wechat_v2') return false
const cert = refundCredentialValue('wx_client_cert_content')
const key = refundCredentialValue('wx_client_key_content')
return Boolean(cert) !== Boolean(key)
})
const handlePaymentMethodChange = (value: PaymentMerchantMethod) => {
form.payment_method = value
const firstMatch = PAYMENT_PROVIDER_OPTIONS.find((option) => option.paymentMethod === value)
@@ -499,7 +562,7 @@
}
const handleSubmit = async () => {
if (!canManage.value) return
if (formMode.value === 'create' ? !canCreateMerchant.value : !canEditMerchant.value) return
if (!formRef.value) return
try {
await formRef.value.validate()
@@ -532,6 +595,9 @@
})
ElMessage.success('支付凭证更新成功')
}
if (refundCredentialIncomplete.value) {
ElMessage.warning('该商户退款凭证不完整,不可用于原路退款(不影响支付/查单/回调)')
}
formDrawerVisible.value = false
clearSensitiveForm()
await loadMerchants()
@@ -565,7 +631,7 @@
const tableRef = ref()
const openEditDrawer = async (id: number) => {
if (!canManage.value) return
if (!canEditMerchant.value) return
try {
const res = await PaymentMerchantPoolsService.getPaymentMerchantById(id)
if (res.code !== 0) return
@@ -589,7 +655,7 @@
}
const toggleEnabled = async (merchant: PaymentMerchant) => {
if (!canManage.value) return
if (!canToggleMerchant.value) return
const target = !merchant.enabled
const action = target ? '启用' : '停用'
try {
@@ -613,7 +679,7 @@
}
const confirmDeleteMerchant = async (merchant: PaymentMerchant) => {
if (!canManage.value) return
if (!canDeleteMerchant.value) return
try {
await ElMessageBox.confirm(
`确认删除支付商户 “${merchant.name}”?该操作不可恢复。`,
@@ -642,26 +708,24 @@
label: '编辑',
type: 'primary' as const,
handler: () => openEditDrawer(row.id),
permission: canManage.value ? '' : 'hidden'
permission: canEditMerchant.value ? '' : 'hidden'
},
{
label: row.enabled ? '停用' : '启用',
type: 'primary' as const,
handler: () => toggleEnabled(row),
permission: canManage.value ? '' : 'hidden'
permission: canToggleMerchant.value ? '' : 'hidden'
},
{
label: '删除',
type: 'danger' as const,
handler: () => confirmDeleteMerchant(row),
permission: canManage.value ? '' : 'hidden'
permission: canDeleteMerchant.value ? '' : 'hidden'
}
]
onMounted(() => {
if (canManage.value) {
loadMerchants()
}
loadMerchants()
})
onUnmounted(() => {
@@ -700,6 +764,13 @@
word-break: break-all;
}
.refund-credential-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 8px;
}
.field-error {
margin-top: 8px;
font-size: 12px;

View File

@@ -15,7 +15,7 @@
@refresh="loadPools"
>
<template #left>
<ElButton v-if="canManage" type="primary" :icon="Plus" @click="showCreateDrawer">
<ElButton v-if="canCreatePool" type="primary" :icon="Plus" @click="showCreateDrawer">
新增商户池
</ElButton>
</template>
@@ -31,7 +31,7 @@
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="220"
:actionsWidth="120"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
@@ -195,7 +195,7 @@
<ElButton
type="primary"
:loading="submitLoading"
:disabled="!canManage"
:disabled="formMode === 'create' ? !canCreatePool : !canEditPool"
@click="handleSubmit"
>
保存
@@ -214,7 +214,8 @@
import type { FormInstance, FormRules } from 'element-plus'
import { PaymentMerchantPoolsService } from '@/api/modules'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { usePermission } from '@/composables/usePermission'
import { useAuth } from '@/composables/useAuth'
import { PAYMENT_MERCHANT_POOL_PERMISSIONS } from '@/config/constants/paymentMerchantPools'
import type { SearchFormItem } from '@/types/component'
import {
PAYMENT_METHOD_OPTIONS,
@@ -239,16 +240,15 @@
type FilterVo = string | number | undefined | null | unknown[]
const { isPlatformAccount } = usePermission()
const router = useRouter()
const canManage = computed(() => isPlatformAccount.value)
const { hasAuth } = useAuth()
const canCreatePool = computed(() => hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.poolCreate))
const canEditPool = computed(() => hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.poolEdit))
const canTogglePool = computed(() => hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.poolToggle))
const canViewPoolDetail = computed(() => hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.poolDetail))
const handleNameClick = (row: PaymentMerchantPool) => {
if (!canManage.value) {
ElMessage.warning('您没有查看商户池详情的权限')
return
}
router.push({ path: `${RoutesAlias.PaymentMerchantPoolDetail}/${row.id}` })
}
@@ -282,17 +282,19 @@
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: PaymentMerchantPool) =>
h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: (event: MouseEvent) => {
event.stopPropagation()
handleNameClick(row)
}
},
row.name
)
canViewPoolDetail.value
? h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: (event: MouseEvent) => {
event.stopPropagation()
handleNameClick(row)
}
},
row.name
)
: row.name
},
{
label: '支付方式',
@@ -357,7 +359,6 @@
}
const loadPools = async () => {
if (!canManage.value) return
loading.value = true
try {
const params = {
@@ -674,7 +675,7 @@
}
const showCreateDrawer = async () => {
if (!canManage.value) return
if (!canCreatePool.value) return
Object.assign(form, initialFormState())
formDrawerVisible.value = true
formMode.value = 'create'
@@ -682,7 +683,7 @@
}
const openEditDrawer = async (pool: PaymentMerchantPool) => {
if (!canManage.value) return
if (!canEditPool.value) return
await loadAvailableMerchants(pool.payment_method)
Object.assign(form, initialFormState(), {
id: pool.id,
@@ -711,7 +712,7 @@
}
const handleSubmit = async () => {
if (!canManage.value) return
if (formMode.value === 'create' ? !canCreatePool.value : !canEditPool.value) return
if (!formRef.value) return
try {
await formRef.value.validate()
@@ -741,7 +742,7 @@
const tableRef = ref()
const toggleEnabled = async (pool: PaymentMerchantPool) => {
if (!canManage.value) return
if (!canTogglePool.value) return
const target = !pool.enabled
const action = target ? '启用' : '停用'
try {
@@ -769,20 +770,18 @@
label: '编辑',
type: 'primary' as const,
handler: () => openEditDrawer(row),
permission: canManage.value ? '' : 'hidden'
permission: canEditPool.value ? '' : 'hidden'
},
{
label: row.enabled ? '停用' : '启用',
type: 'primary' as const,
handler: () => toggleEnabled(row),
permission: canManage.value ? '' : 'hidden'
permission: canTogglePool.value ? '' : 'hidden'
}
]
onMounted(() => {
if (canManage.value) {
loadPools()
}
loadPools()
})
onUnmounted(() => {

View File

@@ -1,6 +1,6 @@
<template>
<div class="wechat-authorization-management">
<ElCard v-if="canManage" shadow="never" class="art-table-card">
<ElCard v-if="canEditWechatAuth" shadow="never" class="art-table-card">
<template #header>
<div class="card-header">
<span class="card-title">微信授权配置</span>
@@ -93,7 +93,8 @@
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { PaymentMerchantPoolsService } from '@/api/modules'
import { usePermission } from '@/composables/usePermission'
import { useAuth } from '@/composables/useAuth'
import { PAYMENT_MERCHANT_POOL_PERMISSIONS } from '@/config/constants/paymentMerchantPools'
import type {
UpdateWechatAuthorizationRequest,
WechatAuthorizationConfig
@@ -101,8 +102,10 @@
defineOptions({ name: 'WechatAuthorizationManagement' })
const { isPlatformAccount } = usePermission()
const canManage = computed(() => isPlatformAccount.value)
const { hasAuth } = useAuth()
const canEditWechatAuth = computed(() =>
hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.wechatAuthEdit)
)
const formRef = ref<FormInstance>()
const submitLoading = ref(false)
@@ -139,7 +142,6 @@
})
const loadConfig = async () => {
if (!canManage.value) return
try {
const res = await PaymentMerchantPoolsService.getWechatAuthorization()
if (res.code === 0) {
@@ -151,7 +153,7 @@
}
const handleSubmit = async () => {
if (!canManage.value) return
if (!canEditWechatAuth.value) return
if (!formRef.value) return
try {
await formRef.value.validate()
@@ -201,7 +203,7 @@
}
onMounted(() => {
if (canManage.value) {
if (canEditWechatAuth.value) {
loadConfig()
}
})

View File

@@ -215,6 +215,16 @@
label: '支付密钥',
prop: 'wx_key_content',
formatter: (value) => getPaymentConfigStatus(value)
},
{
label: '客户端证书内容',
prop: 'wx_client_cert_content',
formatter: (value) => getPaymentConfigStatus(value)
},
{
label: '客户端私钥内容',
prop: 'wx_client_key_content',
formatter: (value) => getPaymentConfigStatus(value)
}
]
}

View File

@@ -288,6 +288,28 @@
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="客户端证书内容" prop="wx_client_cert_content">
<ElInput
v-model="form.wx_client_cert_content"
type="textarea"
:rows="3"
placeholder="选填PEM 格式客户端证书内容,仅原路退款需要"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="客户端私钥内容" prop="wx_client_key_content">
<ElInput
v-model="form.wx_client_key_content"
type="textarea"
:rows="3"
placeholder="选填PEM 格式客户端私钥内容,仅原路退款需要"
/>
</ElFormItem>
</ElCol>
</ElRow>
</template>
<template v-if="form.provider_type === 'fuiou'">
@@ -405,6 +427,8 @@
wx_serial_no: string
wx_cert_content: string
wx_key_content: string
wx_client_cert_content: string
wx_client_key_content: string
ali_app_id: string
ali_private_key: string
ali_public_key: string
@@ -448,6 +472,8 @@
'wx_api_v3_key',
'wx_cert_content',
'wx_key_content',
'wx_client_cert_content',
'wx_client_key_content',
'ali_private_key',
'ali_public_key',
'fy_private_key',
@@ -486,6 +512,8 @@
wx_serial_no: '',
wx_cert_content: '',
wx_key_content: '',
wx_client_cert_content: '',
wx_client_key_content: '',
ali_app_id: '',
ali_private_key: '',
ali_public_key: '',

View File

@@ -65,6 +65,10 @@
<ElOption label="退款审批" value="refund_approval" />
<ElOption label="线下代充值审批" value="offline_recharge_approval" />
<ElOption label="员工代收款审批" value="employee_collection_approval" />
<ElOption label="代理扫码分销注册审批" value="agent_distribution_approval" />
<ElOption label="提现资料资格审批" value="withdrawal_qualification_approval" />
<ElOption label="佣金提现终审" value="commission_withdrawal_approval" />
</ElSelect>
</ElFormItem>
</ElCol>

View File

@@ -0,0 +1,666 @@
<template>
<ArtTableFullScreen>
<div class="business-owner-import-page" id="table-full-screen">
<!-- 导入入口卡片 -->
<ElCard shadow="never" class="import-entry-card">
<div class="import-entry">
<div class="import-tip">
<p>通过 CSV 批量交接/清空店铺平台业务员</p>
<p
>表头固定<code>店铺编码,操作类型,业务员登录账号,备注</code>操作类型仅支持换绑清空</p
>
<p>支持 UTF-8可带 BOM一行失败不影响其他行</p>
</div>
<div class="import-actions">
<ElButton
v-permission="SHOP_BUSINESS_OWNER_PERMISSIONS.importPage"
@click="handleDownloadTemplate"
>
下载模板
</ElButton>
<ElButton
type="primary"
v-permission="SHOP_BUSINESS_OWNER_PERMISSIONS.importCreate"
@click="importDialogVisible = true"
>
导入业务员
</ElButton>
</div>
</div>
</ElCard>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElSelect
v-model="searchForm.status"
placeholder="状态:全部"
clearable
style="width: 160px"
@change="handleSearch"
>
<ElOption
v-for="option in importStatusOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.page"
:pageSize="pagination.pageSize"
:total="pagination.total"
:actions="getActions"
:actionsWidth="120"
:marginTop="10"
:height="400"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
<!-- 新建导入弹窗 -->
<ElDialog v-model="importDialogVisible" title="导入业务员" width="560px" destroy-on-close>
<ElAlert type="info" :closable="false" show-icon class="import-alert">
请先下载模板按模板填写店铺编码与操作类型后再上传 CSV
</ElAlert>
<div class="upload-tip-row">
<ElButton @click="handleDownloadTemplate">下载模板</ElButton>
</div>
<ElUpload
ref="uploadRef"
drag
:auto-upload="false"
:limit="1"
accept=".csv"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
:file-list="fileList"
>
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div class="upload-text">
<div>将文件拖到此处 <em>点击选择 CSV</em></div>
<div class="upload-tip">仅支持 .csv 文件UTF-8 可带 BOM自动兼容 GBK</div>
</div>
</ElUpload>
<template #footer>
<ElButton @click="importDialogVisible = false">取消</ElButton>
<ElButton
type="primary"
:loading="uploading"
:disabled="!selectedFile"
@click="handleStartImport"
>
开始导入
</ElButton>
</template>
</ElDialog>
<!-- 导入结果抽屉 -->
<ElDrawer
v-model="detailDrawerVisible"
:title="`导入结果 - ${currentTask?.id || ''}`"
size="70%"
destroy-on-close
>
<div v-if="currentTask" class="import-detail">
<ElAlert
:type="detailAlertType"
:closable="false"
show-icon
:title="detailAlertTitle"
class="detail-alert"
>
<template v-if="currentTask.error_message" #default>
<div class="detail-error">{{ currentTask.error_message }}</div>
</template>
</ElAlert>
<div class="detail-stats">
<div class="stat-item">
<span class="stat-label">总数</span>
<strong>{{ currentTask.total_count ?? 0 }}</strong>
</div>
<div class="stat-item">
<span class="stat-label">成功</span>
<strong class="stat-success">{{ currentTask.success_count ?? 0 }}</strong>
</div>
<div class="stat-item">
<span class="stat-label">失败</span>
<strong class="stat-fail">{{ currentTask.fail_count ?? 0 }}</strong>
</div>
<div class="stat-item">
<span class="stat-label">状态</span>
<strong>{{ currentTask.status_name || importStatusLabel(currentTask.status) }}</strong>
</div>
</div>
<div class="detail-meta">
<div class="meta-item">
<span class="meta-label">任务编号</span>
<span class="meta-value">{{ currentTask.task_no || '--' }}</span>
</div>
<div class="meta-item">
<span class="meta-label">文件名</span>
<span class="meta-value">{{ currentTask.file_name || '--' }}</span>
</div>
<div class="meta-item">
<span class="meta-label">创建人</span>
<span class="meta-value">{{ currentTask.creator_name || '--' }}</span>
</div>
<div class="meta-item">
<span class="meta-label">开始时间</span>
<span class="meta-value">
{{ currentTask.started_at ? formatDateTime(currentTask.started_at) : '--' }}
</span>
</div>
<div class="meta-item">
<span class="meta-label">完成时间</span>
<span class="meta-value">
{{ currentTask.completed_at ? formatDateTime(currentTask.completed_at) : '--' }}
</span>
</div>
</div>
<ElTable v-if="rowItems.length" :data="rowItems" border stripe max-height="420">
<ElTableColumn label="行号" prop="line" width="80" align="center" />
<ElTableColumn label="店铺编码" prop="shop_code" min-width="140" />
<ElTableColumn label="操作类型" prop="operation_type" width="100" align="center" />
<ElTableColumn label="结果" width="110" align="center">
<template #default="scope">
<ElTag :type="scope.row.status === 3 ? 'success' : 'danger'" size="small">
{{ scope.row.status_name || (scope.row.status === 3 ? '成功' : '失败') }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="失败原因" prop="reason" min-width="220" show-overflow-tooltip />
</ElTable>
<ElEmpty
v-else-if="!detailLoading && !rowItems.length && currentTask.status === 4"
description="任务级失败,无行级明细"
/>
</div>
</ElDrawer>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onMounted, onBeforeUnmount, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import type { UploadFile, UploadInstance, UploadUserFile } from 'element-plus'
import { ShopService, StorageService } from '@/api/modules'
import type { BusinessOwnerImportDetail } from '@/types/api'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { formatDateTime } from '@/utils/business/format'
import { SHOP_BUSINESS_OWNER_PERMISSIONS } from '@/config/constants'
defineOptions({ name: 'BusinessOwnerImport' })
const { hasAuth } = useAuth()
const importStatusOptions = [
{ label: '待处理', value: 1 },
{ label: '处理中', value: 2 },
{ label: '已完成', value: 3 },
{ label: '失败', value: 4 }
]
const importStatusLabel = (status?: number) =>
importStatusOptions.find((item) => item.value === status)?.label || '-'
// ==================== 任务列表 ====================
const loading = ref(false)
const tableRef = ref()
const tableData = ref<BusinessOwnerImportDetail[]>([])
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
const searchForm = reactive({
status: undefined as number | undefined
})
const columnOptions = [
{ label: '任务ID', prop: 'id', width: 90 },
{ label: '任务编号', prop: 'task_no', width: 160 },
{ label: '文件名', prop: 'file_name', minWidth: 160, showOverflowTooltip: true },
{ label: '创建人', prop: 'creator_name', width: 100 },
{ label: '状态', prop: 'status', width: 100 },
{ label: '总数', prop: 'total_count', width: 80 },
{ label: '成功', prop: 'success_count', width: 80 },
{ label: '失败', prop: 'fail_count', width: 80 },
{ label: '开始时间', prop: 'started_at', width: 170 },
{ label: '完成时间', prop: 'completed_at', width: 170 },
{ label: '创建时间', prop: 'created_at', width: 170 }
]
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'id',
label: '任务ID',
width: 90
},
{
prop: 'task_no',
label: '任务编号',
width: 160,
showOverflowTooltip: true
},
{
prop: 'file_name',
label: '文件名',
minWidth: 160,
showOverflowTooltip: true
},
{
prop: 'creator_name',
label: '创建人',
width: 100
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: BusinessOwnerImportDetail) =>
h(
'span',
{
style: `color: ${row.status === 4 ? 'var(--el-color-danger)' : 'var(--el-color-primary)'}`
},
row.status_name || importStatusLabel(row.status)
)
},
{ prop: 'total_count', label: '总数', width: 80 },
{ prop: 'success_count', label: '成功', width: 80 },
{ prop: 'fail_count', label: '失败', width: 80 },
{
prop: 'started_at',
label: '开始时间',
width: 170,
formatter: (row: BusinessOwnerImportDetail) =>
row.started_at ? formatDateTime(row.started_at) : '--'
},
{
prop: 'completed_at',
label: '完成时间',
width: 170,
formatter: (row: BusinessOwnerImportDetail) =>
row.completed_at ? formatDateTime(row.completed_at) : '--'
},
{
prop: 'created_at',
label: '创建时间',
width: 170,
formatter: (row: BusinessOwnerImportDetail) => formatDateTime(row.created_at)
}
])
const getActions = (row: BusinessOwnerImportDetail) => {
if (!hasAuth(SHOP_BUSINESS_OWNER_PERMISSIONS.importPage)) return []
return [
{
label: '查看结果',
handler: () => openDetail(row),
type: 'primary' as const
}
]
}
const getList = async () => {
loading.value = true
try {
const res = await ShopService.getBusinessOwnerImportList({
page: pagination.page,
page_size: pagination.pageSize,
status: searchForm.status
})
tableData.value = res.data?.items || []
pagination.total = res.data?.total || 0
} catch {
// 错误提示由请求层统一处理
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.page = 1
getList()
}
const handleRefresh = () => {
getList()
}
const handleSizeChange = (size: number) => {
pagination.pageSize = size
pagination.page = 1
getList()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
getList()
}
// ==================== 模板下载 ====================
const handleDownloadTemplate = () => {
try {
const link = document.createElement('a')
link.href = new URL('@/template/业务负责人导入模板.csv', import.meta.url).href
link.download = '业务负责人导入模板.csv'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success('模板下载成功')
} catch (error) {
console.error('下载模板失败:', error)
ElMessage.error('下载模板失败')
}
}
// ==================== 新建导入 ====================
const importDialogVisible = ref(false)
const uploadRef = ref<UploadInstance>()
const fileList = ref<UploadUserFile[]>([])
const selectedFile = ref<File | null>(null)
const uploading = ref(false)
const handleFileChange = (file: UploadFile) => {
selectedFile.value = file.raw || null
}
const handleFileRemove = () => {
selectedFile.value = null
}
const handleStartImport = async () => {
if (!selectedFile.value) {
ElMessage.warning('请先选择 CSV 文件')
return
}
uploading.value = true
try {
// 1. 获取预签名上传地址
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: selectedFile.value.name,
content_type: 'text/csv',
purpose: 'shop_import'
})
const { upload_url, file_key } = uploadUrlRes.data
// 2. 预签名 PUT 直传
await StorageService.uploadFile(upload_url, selectedFile.value, 'text/csv')
// 3. 创建导入任务
const createRes = await ShopService.createBusinessOwnerImport({ file_key })
const taskId = createRes.data?.id
importDialogVisible.value = false
selectedFile.value = null
fileList.value = []
if (taskId) {
await openDetailById(taskId)
} else {
getList()
}
ElMessage.success('导入任务已创建')
} catch (error) {
console.error('导入失败:', error)
ElMessage.error('导入失败')
} finally {
uploading.value = false
}
}
// ==================== 结果抽屉 ====================
const detailDrawerVisible = ref(false)
const detailLoading = ref(false)
const currentTask = ref<BusinessOwnerImportDetail | null>(null)
const rowItems = ref<NonNullable<BusinessOwnerImportDetail['items']>>([])
let pollTimer: number | undefined
const isTaskRunning = (status?: number) => status === 1 || status === 2
const stopPolling = () => {
if (pollTimer !== undefined) {
window.clearTimeout(pollTimer)
pollTimer = undefined
}
}
const detailAlertType = computed(() => {
const status = currentTask.value?.status
if (status === 4) return 'error'
if (status === 3) return (currentTask.value?.fail_count ?? 0) > 0 ? 'warning' : 'success'
return 'info'
})
const detailAlertTitle = computed(() => {
const status = currentTask.value?.status
if (status === 4) return `任务失败:${currentTask.value?.error_message || '未知原因'}`
if (status === 3) {
const failed = currentTask.value?.fail_count ?? 0
return failed > 0
? `任务完成,但 ${failed} 行失败(失败行保留原负责人)`
: '任务完成,全部成功'
}
return '任务处理中,请稍候…'
})
const refreshDetail = async () => {
if (!currentTask.value) return
detailLoading.value = true
try {
const res = await ShopService.getBusinessOwnerImportDetail(currentTask.value.id)
currentTask.value = res.data
rowItems.value = res.data.items || []
if (isTaskRunning(res.data.status)) {
schedulePoll()
}
} catch {
// 轮询失败保留当前状态
} finally {
detailLoading.value = false
}
}
const schedulePoll = () => {
stopPolling()
if (!currentTask.value || !isTaskRunning(currentTask.value.status)) return
pollTimer = window.setTimeout(() => {
pollTimer = undefined
void refreshDetail()
}, 3000)
}
const openDetail = (row: BusinessOwnerImportDetail) => {
currentTask.value = row
rowItems.value = row.items || []
detailDrawerVisible.value = true
if (isTaskRunning(row.status)) {
schedulePoll()
}
}
const openDetailById = async (taskId: number) => {
currentTask.value = null
rowItems.value = []
detailDrawerVisible.value = true
detailLoading.value = true
try {
const res = await ShopService.getBusinessOwnerImportDetail(taskId)
currentTask.value = res.data
rowItems.value = res.data.items || []
if (isTaskRunning(res.data.status)) {
schedulePoll()
}
} catch {
ElMessage.error('获取导入结果失败')
} finally {
detailLoading.value = false
getList()
}
}
onMounted(() => {
getList()
})
onBeforeUnmount(() => {
stopPolling()
})
</script>
<style scoped lang="scss">
.import-entry-card {
margin-bottom: 12px;
.import-entry {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
.import-tip {
font-size: 13px;
color: var(--el-text-color-secondary);
line-height: 1.8;
p {
margin: 0;
}
code {
padding: 2px 6px;
font-size: 12px;
background: var(--el-fill-color-light);
border-radius: 4px;
}
}
.import-actions {
display: flex;
gap: 8px;
}
}
}
.import-alert {
margin-bottom: 12px;
}
.upload-tip-row {
margin-bottom: 12px;
}
.upload-icon {
font-size: 48px;
color: var(--el-color-primary);
margin-bottom: 8px;
}
.upload-text {
color: var(--el-text-color-regular);
.upload-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
}
.import-detail {
.detail-alert {
margin-bottom: 16px;
}
.detail-error {
margin-top: 6px;
color: var(--el-color-danger);
word-break: break-all;
}
.detail-stats {
display: flex;
gap: 24px;
margin-bottom: 16px;
.stat-item {
display: flex;
flex-direction: column;
gap: 4px;
.stat-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
strong {
font-size: 18px;
}
.stat-success {
color: var(--el-color-success);
}
.stat-fail {
color: var(--el-color-danger);
}
}
}
.detail-meta {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px 24px;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--el-fill-color-light);
border-radius: 6px;
.meta-item {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
.meta-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.meta-value {
font-size: 13px;
color: var(--el-text-color-primary);
word-break: break-all;
}
}
}
}
</style>

View File

@@ -0,0 +1,630 @@
<template>
<div class="business-user-groups-page">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton
type="primary"
v-permission="BUSINESS_USER_GROUP_PERMISSIONS.create"
@click="openCreateDialog"
>
新建用户组
</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.page"
:pageSize="pagination.pageSize"
:total="pagination.total"
:actions="getActions"
:actionsWidth="120"
:marginTop="10"
:height="500"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
<!-- 新建/编辑弹窗 -->
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新建用户组' : '编辑用户组'"
width="620px"
destroy-on-close
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="100px">
<ElFormItem label="稳定编码" prop="code">
<ElInput
v-model="form.code"
:disabled="dialogType === 'edit'"
placeholder="1-64 字符,创建后不可修改"
maxlength="64"
show-word-limit
/>
</ElFormItem>
<ElFormItem label="名称" prop="name">
<ElInput v-model="form.name" placeholder="请输入名称" maxlength="100" show-word-limit />
</ElFormItem>
<ElFormItem label="业务线" prop="business_line">
<ElSelect v-model="form.business_line" placeholder="不设置" clearable style="width: 100%">
<ElOption
v-for="option in businessLineOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="排序" prop="sort">
<ElInputNumber v-model="form.sort" :min="0" :precision="0" style="width: 200px" />
</ElFormItem>
<ElFormItem label="启用" prop="enabled">
<ElSwitch v-model="form.enabled" />
</ElFormItem>
<ElFormItem label="备注" prop="remark">
<ElInput
v-model="form.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="最多 500 字符"
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">保存</ElButton>
</template>
</ElDialog>
<!-- 成员管理弹窗 -->
<ElDialog
v-model="memberDialogVisible"
:title="`成员管理 - ${currentGroup?.name || ''}`"
width="680px"
destroy-on-close
>
<div v-if="currentGroup" class="member-group-info">
<ElTag :type="currentGroup.enabled ? 'success' : 'danger'" size="small">
{{ currentGroup.enabled ? '启用' : '已停用' }}
</ElTag>
<span class="member-group-code">{{ currentGroup.code }}</span>
<span v-if="!currentGroup.enabled" class="member-group-tip">
停用组保留现有成员不能作为批量目标
</span>
</div>
<ElDivider content-position="left">设置成员整体替换</ElDivider>
<ElAlert type="warning" :closable="false" show-icon class="member-alert">
保存后将以所选账号整体替换该组现有成员账号必须是启用平台用户
</ElAlert>
<ElSelect
v-model="memberAccountIds"
multiple
filterable
remote
:remote-method="searchMemberCandidates"
:loading="memberCandidatesLoading"
placeholder="搜索并选择平台账号"
style="width: 100%; margin-top: 12px"
>
<ElOption
v-for="candidate in memberCandidates"
:key="candidate.id"
:label="candidate.username"
:value="candidate.id"
>
<div class="member-option">
<span>{{ candidate.username }}</span>
<span class="member-option-phone">{{ candidate.phone_summary }}</span>
</div>
</ElOption>
</ElSelect>
<div class="member-actions">
<ElButton type="primary" :loading="settingMembers" @click="handleSetMembers">
保存成员
</ElButton>
</div>
<ElDivider content-position="left">移出成员回未分组</ElDivider>
<ElAlert type="info" :closable="false" show-icon class="member-alert">
选择要移出用户组的平台账号清空后回未分组
</ElAlert>
<ElSelect
v-model="clearAccountIds"
multiple
filterable
remote
:remote-method="searchMemberCandidates"
:loading="memberCandidatesLoading"
placeholder="搜索并选择要移出的平台账号"
style="width: 100%; margin-top: 12px"
>
<ElOption
v-for="candidate in memberCandidates"
:key="candidate.id"
:label="candidate.username"
:value="candidate.id"
>
<div class="member-option">
<span>{{ candidate.username }}</span>
<span class="member-option-phone">{{ candidate.phone_summary }}</span>
</div>
</ElOption>
</ElSelect>
<div class="member-actions">
<ElButton type="danger" :loading="clearingMembers" @click="handleClearMembers">
移出所选账号
</ElButton>
</div>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { BusinessUserGroupService, ShopService } from '@/api/modules'
import type {
BusinessUserGroupBusinessLine,
BusinessUserGroupItem,
CreateBusinessUserGroupParams,
UpdateBusinessUserGroupParams,
ShopBusinessOwnerCandidate
} from '@/types/api'
import type { FormInstance, FormRules } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { formatDateTime } from '@/utils/business/format'
import {
BUSINESS_USER_GROUP_BUSINESS_LINE_MAP,
BUSINESS_USER_GROUP_BUSINESS_LINE_OPTIONS,
BUSINESS_USER_GROUP_PERMISSIONS
} from '@/config/constants'
defineOptions({ name: 'BusinessUserGroups' })
const { hasAuth } = useAuth()
const businessLineOptions = BUSINESS_USER_GROUP_BUSINESS_LINE_OPTIONS
// ==================== 列表 ====================
const loading = ref(false)
const tableRef = ref()
const tableData = ref<BusinessUserGroupItem[]>([])
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
const searchForm = reactive({
keyword: '',
enabled: undefined as number | undefined,
business_line: undefined as BusinessUserGroupBusinessLine | undefined
})
const searchFormItems: SearchFormItem[] = [
{
label: '名称',
prop: 'keyword',
type: 'input',
config: {
clearable: true,
placeholder: '按稳定编码或名称模糊搜索'
}
},
{
label: '状态',
prop: 'enabled',
type: 'select',
options: [
{ label: '启用', value: 1 },
{ label: '停用', value: 0 }
],
config: {
clearable: true,
placeholder: '全部'
}
},
{
label: '业务线',
prop: 'business_line',
type: 'select',
options: businessLineOptions.map((item) => ({ label: item.label, value: item.value })),
config: {
clearable: true,
placeholder: '全部'
}
}
]
const columnOptions = [
{ label: '稳定编码', prop: 'code', minWidth: 140, showOverflowTooltip: true },
{ label: '名称', prop: 'name', minWidth: 160, showOverflowTooltip: true },
{ label: '业务线', prop: 'business_line', width: 110 },
{ label: '状态', prop: 'enabled', width: 90 },
{ label: '排序', prop: 'sort', width: 80 },
{ label: '备注', prop: 'remark', minWidth: 160, showOverflowTooltip: true },
{ label: '创建时间', prop: 'created_at', width: 170 }
]
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'code',
label: '稳定编码',
minWidth: 140,
showOverflowTooltip: true
},
{
prop: 'name',
label: '名称',
minWidth: 160,
showOverflowTooltip: true
},
{
prop: 'business_line',
label: '业务线',
width: 110,
formatter: (row: BusinessUserGroupItem) => {
if (!row.business_line) return '-'
const config =
BUSINESS_USER_GROUP_BUSINESS_LINE_MAP[row.business_line as BusinessUserGroupBusinessLine]
return config ? config.label : row.business_line
}
},
{
prop: 'enabled',
label: '状态',
width: 90,
formatter: (row: BusinessUserGroupItem) =>
h(
'span',
{ style: `color: ${row.enabled ? 'var(--el-color-success)' : 'var(--el-color-danger)'}` },
row.enabled ? '启用' : '已停用'
)
},
{ prop: 'sort', label: '排序', width: 80 },
{ prop: 'remark', label: '备注', minWidth: 160, showOverflowTooltip: true },
{
prop: 'created_at',
label: '创建时间',
width: 170,
formatter: (row: BusinessUserGroupItem) => formatDateTime(row.created_at)
}
])
const getActions = (row: BusinessUserGroupItem) => {
const actions: Array<{ label: string; handler: () => void; type?: 'primary' | 'danger' }> = []
if (hasAuth(BUSINESS_USER_GROUP_PERMISSIONS.update)) {
actions.push({
label: '编辑',
handler: () => openEditDialog(row),
type: 'primary'
})
}
if (hasAuth(BUSINESS_USER_GROUP_PERMISSIONS.members)) {
actions.push({
label: '成员',
handler: () => openMemberDialog(row),
type: 'primary'
})
}
if (hasAuth(BUSINESS_USER_GROUP_PERMISSIONS.delete)) {
actions.push({
label: '删除',
handler: () => handleDelete(row),
type: 'danger'
})
}
return actions
}
const getList = async () => {
loading.value = true
try {
const res = await BusinessUserGroupService.getGroups({
page: pagination.page,
page_size: pagination.pageSize,
enabled: searchForm.enabled === undefined ? undefined : searchForm.enabled === 1,
keyword: searchForm.keyword.trim() || undefined,
business_line: searchForm.business_line
})
tableData.value = res.data?.items || []
pagination.total = res.data?.total || 0
} catch {
// 错误提示由请求层统一处理
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.page = 1
getList()
}
const handleReset = () => {
pagination.page = 1
getList()
}
const handleRefresh = () => {
getList()
}
const handleSizeChange = (size: number) => {
pagination.pageSize = size
pagination.page = 1
getList()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
getList()
}
// ==================== 新建/编辑 ====================
const dialogVisible = ref(false)
const dialogType = ref<'add' | 'edit'>('add')
const submitting = ref(false)
const formRef = ref<FormInstance>()
const currentGroup = ref<BusinessUserGroupItem | null>(null)
const form = reactive<CreateBusinessUserGroupParams>({
code: '',
name: '',
business_line: undefined,
sort: 0,
enabled: true,
remark: ''
})
const rules = computed<FormRules>(() => ({
code:
dialogType.value === 'add'
? [
{ required: true, message: '请输入稳定编码', trigger: 'blur' },
{ min: 1, max: 64, message: '长度 1-64 字符', trigger: 'blur' }
]
: [],
name: [
{ required: true, message: '请输入名称', trigger: 'blur' },
{ min: 1, max: 100, message: '长度 1-100 字符', trigger: 'blur' }
]
}))
const resetForm = () => {
form.code = ''
form.name = ''
form.business_line = undefined
form.sort = 0
form.enabled = true
form.remark = ''
}
const openCreateDialog = () => {
dialogType.value = 'add'
currentGroup.value = null
resetForm()
dialogVisible.value = true
}
const openEditDialog = (row: BusinessUserGroupItem) => {
dialogType.value = 'edit'
currentGroup.value = row
form.code = row.code
form.name = row.name
form.business_line = (row.business_line || undefined) as
| BusinessUserGroupBusinessLine
| undefined
form.sort = row.sort
form.enabled = row.enabled
form.remark = row.remark
dialogVisible.value = true
}
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
const payload: CreateBusinessUserGroupParams | UpdateBusinessUserGroupParams = {
name: form.name,
// 三态语义:清空选择器后回退为 ''(清空),枚举值(设置),其余字段缺失=不改
business_line: form.business_line ?? '',
sort: form.sort,
enabled: form.enabled,
remark: form.remark
}
submitting.value = true
try {
if (dialogType.value === 'add') {
await BusinessUserGroupService.createGroup({
...(payload as CreateBusinessUserGroupParams),
code: form.code
})
ElMessage.success('创建成功')
} else {
await BusinessUserGroupService.updateGroup(currentGroup.value!.id, payload)
ElMessage.success('保存成功')
}
dialogVisible.value = false
getList()
} finally {
submitting.value = false
}
}
// ==================== 删除 ====================
const handleDelete = async (row: BusinessUserGroupItem) => {
try {
await ElMessageBox.confirm(
`确定删除用户组「${row.name}」吗?仅无成员的用户组可删除。`,
'删除确认',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning'
}
)
} catch {
return
}
try {
await BusinessUserGroupService.deleteGroup(row.id, { confirm: true })
ElMessage.success('删除成功')
getList()
} catch {
// 有成员等场景由后端提示
}
}
// ==================== 成员管理 ====================
const memberDialogVisible = ref(false)
const memberCandidates = ref<ShopBusinessOwnerCandidate[]>([])
const memberCandidatesLoading = ref(false)
const memberAccountIds = ref<number[]>([])
const clearAccountIds = ref<number[]>([])
const settingMembers = ref(false)
const clearingMembers = ref(false)
const searchMemberCandidates = async (keyword?: string) => {
memberCandidatesLoading.value = true
try {
const res = await ShopService.getBusinessOwnerCandidates({
page: 1,
page_size: 50,
keyword: keyword?.trim() || undefined
})
memberCandidates.value = res.data.items || []
} catch {
memberCandidates.value = []
} finally {
memberCandidatesLoading.value = false
}
}
const openMemberDialog = (row: BusinessUserGroupItem) => {
currentGroup.value = row
memberAccountIds.value = []
clearAccountIds.value = []
memberCandidates.value = []
memberDialogVisible.value = true
setTimeout(() => {
searchMemberCandidates()
}, 0)
}
const handleSetMembers = async () => {
if (!currentGroup.value) return
if (!memberAccountIds.value.length) {
ElMessage.warning('请至少选择一个平台账号')
return
}
settingMembers.value = true
try {
const res = await BusinessUserGroupService.setGroupMembers(currentGroup.value.id, {
account_ids: memberAccountIds.value
})
ElMessage.success(`已维护 ${res.data?.account_ids?.length ?? 0} 个账号归属`)
memberDialogVisible.value = false
getList()
} finally {
settingMembers.value = false
}
}
const handleClearMembers = async () => {
if (!clearAccountIds.value.length) {
ElMessage.warning('请至少选择一个平台账号')
return
}
clearingMembers.value = true
try {
const res = await BusinessUserGroupService.clearGroupMembers({
account_ids: clearAccountIds.value
})
ElMessage.success(`已清空 ${res.data?.account_ids?.length ?? 0} 个账号归属`)
memberDialogVisible.value = false
getList()
} finally {
clearingMembers.value = false
}
}
onMounted(() => {
getList()
})
</script>
<style scoped lang="scss">
.member-group-info {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
.member-group-code {
font-weight: 500;
color: var(--el-text-color-primary);
}
.member-group-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.member-alert {
margin-top: 12px;
}
.member-actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.member-option {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.member-option-phone {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
</style>

View File

@@ -15,6 +15,9 @@
<ElDescriptions v-if="shop" :column="2" border>
<ElDescriptionsItem label="店铺名称">{{ shop.shop_name || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="店铺编号">{{ shop.shop_code || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="分销码">{{
shop.distribution_code || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="店铺层级">{{ shop.level }}</ElDescriptionsItem>
<ElDescriptionsItem label="上级店铺">{{
shop.parent_shop_name || '-'
@@ -33,6 +36,12 @@
: '-'
}}
</ElDescriptionsItem>
<ElDescriptionsItem label="业务用户组">
{{ formatBusinessUserGroup(shop) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="业务用户组业务线">
{{ getBusinessUserGroupBusinessLineLabel(shop.business_user_group_business_line) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="省份">{{ shop.province || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="城市">{{ shop.city || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="区县">{{ shop.district || '-' }}</ElDescriptionsItem>
@@ -43,18 +52,30 @@
<ElDescriptionsItem label="创建时间">{{ shop.created_at || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{ shop.updated_at || '-' }}</ElDescriptionsItem>
</ElDescriptions>
<div v-if="shop?.distribution_code" class="register-qrcode">
<div class="qrcode-box">
<QrcodeVue :value="registerUrl" :size="168" level="H" />
</div>
<div class="qrcode-tip">
<span>扫码注册将该二维码提供给下级代理扫码注册或直接分享下方链接</span>
<code>{{ registerUrl }}</code>
</div>
</div>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import QrcodeVue from 'qrcode.vue'
import { ShopService } from '@/api/modules'
import type { ShopResponse } from '@/types/api'
import { getBusinessUserGroupBusinessLineLabel } from '@/config/constants'
defineOptions({ name: 'ShopDetail' })
@@ -63,6 +84,14 @@
const loading = ref(false)
const shop = ref<ShopResponse | null>(null)
const registerUrl = computed(() => {
const shopData = shop.value
const code = shopData?.distribution_code
if (!shopData || !code) return ''
const base = `${window.location.origin}${window.location.pathname}`
return `${base}#/agent-registration?distribution_code=${encodeURIComponent(code)}&shop_name=${encodeURIComponent(shopData.shop_name)}&shop_code=${encodeURIComponent(shopData.shop_code)}`
})
const formatBusinessOwner = (value: ShopResponse) => {
if (!value.business_owner_username) return '-'
return value.business_owner_phone_summary
@@ -70,6 +99,14 @@
: value.business_owner_username
}
const formatBusinessUserGroup = (value: ShopResponse) => {
if (!value.business_user_group_id) return '-'
const text = value.business_user_group_code
? `${value.business_user_group_name || '-'}${value.business_user_group_code}`
: value.business_user_group_name || '-'
return value.business_user_group_enabled ? text : `${text}(已停用)`
}
const handleBack = () => router.back()
const fetchDetail = async () => {
@@ -115,4 +152,41 @@
font-weight: 600;
color: var(--el-text-color-primary);
}
.register-qrcode {
display: flex;
gap: 20px;
align-items: flex-start;
padding: 24px;
margin-top: 20px;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
.qrcode-box {
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
background: #fff;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.qrcode-tip {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
color: var(--el-text-color-secondary);
code {
padding: 6px 10px;
font-size: 12px;
word-break: break-all;
background: var(--el-fill-color-light);
border-radius: 4px;
}
}
}
</style>

View File

@@ -20,6 +20,19 @@
>
<template #left>
<ElButton @click="showDialog('add')" v-permission="'shop:add'">新增店铺</ElButton>
<ElButton
type="primary"
v-permission="SHOP_BUSINESS_OWNER_PERMISSIONS.batch"
@click="openBatchTransferDialog"
>
批量交接
</ElButton>
<ElButton
v-permission="SHOP_BUSINESS_OWNER_PERMISSIONS.importPage"
@click="goToImportPage"
>
导入业务员
</ElButton>
</template>
</ArtTableHeader>
@@ -40,6 +53,7 @@
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-if="canBatchTransfer" type="selection" width="55" />
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
@@ -310,6 +324,60 @@
:shop="currentCreditShop"
@submitted="getShopList"
/>
<!-- 批量交接平台业务员弹窗 -->
<ElDialog
v-model="batchTransferDialogVisible"
title="批量交接平台业务员"
width="560px"
destroy-on-close
>
<ElAlert type="info" :closable="false" show-icon class="batch-transfer-alert">
已选择 {{ selectedRows.length }} 家店铺(单次最多 500 家)
</ElAlert>
<ElForm label-width="110px" class="batch-transfer-form">
<ElFormItem label="交接方式">
<ElRadioGroup v-model="batchTransferMode">
<ElRadio label="rebind">换绑业务员</ElRadio>
<ElRadio label="clear">清空负责人</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem v-if="batchTransferMode === 'rebind'" label="目标业务员">
<ElSelect
v-model="batchTransferAccountId"
filterable
remote
clearable
:loading="batchOwnerLoading"
:remote-method="searchBatchOwners"
placeholder="请选择或搜索平台业务员"
style="width: 100%"
>
<ElOption
v-for="owner in batchOwnerOptions"
:key="owner.id"
:label="owner.username"
:value="owner.id"
>
<div style="display: flex; justify-content: space-between">
<span>{{ owner.username }}</span>
<span style="font-size: 12px; color: var(--el-text-color-secondary)">
{{ owner.phone_summary }}
</span>
</div>
</ElOption>
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="batchTransferDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="batchSubmitting" @click="handleBatchTransfer">
确认交接
</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
@@ -334,10 +402,17 @@
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { generateShopCode } from '@/utils/codeGenerator'
import { CommissionService, ShopService, RoleService } from '@/api/modules'
import {
BusinessUserGroupService,
CommissionService,
RoleService,
ShopService
} from '@/api/modules'
import ShopCreditLimitDialog from '@/components/business/ShopCreditLimitDialog.vue'
import type { SearchFormItem } from '@/types'
import type {
BatchUpdateBusinessOwnerParams,
BusinessUserGroupItem,
CreateShopParams,
ShopBusinessOwnerCandidate,
ShopResponse,
@@ -348,7 +423,14 @@
import { RoleType, RoleStatus } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { getCompatibleNumericId } from '@/utils/business/id'
import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants'
import {
BUSINESS_USER_GROUP_BUSINESS_LINE_OPTIONS,
CommonStatus,
getBusinessUserGroupBusinessLineLabel,
getStatusText,
SHOP_BUSINESS_OWNER_PERMISSIONS,
STATUS_SELECT_OPTIONS
} from '@/config/constants'
import { regionData } from '@/utils/constants/regionData'
import { RoutesAlias } from '@/router/routesAlias'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
@@ -484,7 +566,10 @@
parent_id: undefined as number | undefined,
level: undefined as number | undefined,
status: undefined as number | undefined,
business_owner_account_id: undefined as number | undefined
business_owner_account_id: undefined as number | undefined,
business_user_group_id: undefined as number | undefined,
business_line: undefined as string | undefined,
ungrouped: undefined as number | undefined
}
// 响应式表单数据
@@ -505,6 +590,22 @@
// 选中的行数据
const selectedRows = ref<any[]>([])
// 业务用户组筛选选项(非代理账号可见)
const groupOptions = ref<BusinessUserGroupItem[]>([])
const groupLoading = ref(false)
// 批量交接平台业务员
const batchTransferDialogVisible = ref(false)
const batchTransferMode = ref<'rebind' | 'clear'>('rebind')
const batchTransferAccountId = ref<number | null>(null)
const batchOwnerOptions = ref<ShopBusinessOwnerCandidate[]>([])
const batchOwnerLoading = ref(false)
const batchSubmitting = ref(false)
const canBatchTransfer = computed(
() => !isAgentAccount.value && hasAuth(SHOP_BUSINESS_OWNER_PERMISSIONS.batch)
)
// 重置表单
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
@@ -602,6 +703,50 @@
label: `${salesperson.username} (${salesperson.phone_summary})`,
value: salesperson.id
}))
},
{
label: '业务用户组',
prop: 'business_user_group_id',
type: 'select' as const,
config: {
clearable: true,
filterable: true,
loading: groupLoading.value,
placeholder: '请选择业务用户组'
},
options: () =>
groupOptions.value.map((group) => ({
label: group.business_line_name
? `${group.name}${group.business_line_name}`
: group.name,
value: group.id
}))
},
{
label: '业务线',
prop: 'business_line',
type: 'select' as const,
config: {
clearable: true,
placeholder: '请选择业务线'
},
options: BUSINESS_USER_GROUP_BUSINESS_LINE_OPTIONS.map((item) => ({
label: item.label,
value: item.value
}))
},
{
label: '未分组',
prop: 'ungrouped',
type: 'select' as const,
config: {
clearable: true,
placeholder: '是=无负责人或负责人无分组'
},
options: [
{ label: '是', value: 1 },
{ label: '否', value: 0 }
]
}
]
: [])
@@ -616,6 +761,7 @@
{ label: '联系人', prop: 'contact_name' },
{ label: '联系电话', prop: 'contact_phone' },
{ label: '平台业务员', prop: 'business_owner_username' },
{ label: '业务用户组', prop: 'business_user_group_name' },
...(canModifyShopStatus ? [{ label: '状态', prop: 'status' }] : []),
{ label: '创建时间', prop: 'created_at' },
{ label: '操作', prop: 'operation' }
@@ -776,6 +922,23 @@
showOverflowTooltip: true,
formatter: (row: ShopResponse) => formatBusinessOwner(row)
},
{
prop: 'business_user_group_name',
label: '业务用户组',
minWidth: 160,
showOverflowTooltip: true,
formatter: (row: ShopResponse) => {
if (!row.business_user_group_id) return '-'
const name = row.business_user_group_name || row.business_user_group_code || '-'
const businessLine = getBusinessUserGroupBusinessLineLabel(
row.business_user_group_business_line
)
const text =
businessLine && businessLine !== '-' ? `${name}${businessLine}` : name
if (row.business_user_group_enabled) return text
return h(ElTag, { type: 'danger', size: 'small' }, { default: () => `${text}(已停用)` })
}
},
{
prop: 'client_login_disabled',
label: 'C 端登录',
@@ -1020,6 +1183,7 @@
searchParentShops('') // 加载上级店铺选项
if (!isAgentAccount.value) {
searchPlatformSalespeople('') // 加载启用的平台业务员选项
loadGroupOptions() // 加载业务用户组选项
}
})
@@ -1038,7 +1202,11 @@
level: searchForm.level,
status: searchForm.status,
...(!isAgentAccount.value && {
business_owner_account_id: searchForm.business_owner_account_id
business_owner_account_id: searchForm.business_owner_account_id,
business_user_group_id: searchForm.business_user_group_id,
business_line: searchForm.business_line || undefined,
ungrouped:
searchForm.ungrouped === undefined ? undefined : searchForm.ungrouped === 1
})
}
const res = await ShopService.getShops(params)
@@ -1062,6 +1230,89 @@
selectedRows.value = selection
}
// 打开批量交接弹窗
const openBatchTransferDialog = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先勾选需要交接的店铺')
return
}
if (selectedRows.value.length > 500) {
ElMessage.warning('单次最多可交接 500 家店铺')
return
}
batchTransferMode.value = 'rebind'
batchTransferAccountId.value = null
void searchBatchOwners('')
batchTransferDialogVisible.value = true
}
// 搜索批量交接目标业务员
const searchBatchOwners = async (query: string) => {
batchOwnerLoading.value = true
try {
const res = await ShopService.getBusinessOwnerCandidates({
page: 1,
page_size: 20,
keyword: query || undefined
})
if (res.code === 0) {
batchOwnerOptions.value = res.data.items || []
}
} catch (error) {
console.error('获取平台业务员列表失败:', error)
batchOwnerOptions.value = []
} finally {
batchOwnerLoading.value = false
}
}
// 提交批量交接
const handleBatchTransfer = async () => {
if (batchTransferMode.value === 'rebind' && !batchTransferAccountId.value) {
ElMessage.warning('请选择目标业务员')
return
}
batchSubmitting.value = true
try {
const data: BatchUpdateBusinessOwnerParams = {
shop_ids: Array.from(new Set(selectedRows.value.map((row: ShopResponse) => row.id))),
business_owner_account_id:
batchTransferMode.value === 'clear' ? null : batchTransferAccountId.value
}
const res = await ShopService.batchUpdateBusinessOwner(data)
if (res.code === 0) {
ElMessage.success(`批量交接成功,共处理 ${res.data?.shop_count ?? 0} 家店铺`)
batchTransferDialogVisible.value = false
selectedRows.value = []
await getShopList()
}
} catch (error) {
console.error('批量交接失败:', error)
} finally {
batchSubmitting.value = false
}
}
// 跳转到业务负责人导入页
const goToImportPage = () => {
router.push(RoutesAlias.BusinessOwnerImport)
}
// 加载业务用户组选项(非代理账号)
const loadGroupOptions = async () => {
groupLoading.value = true
try {
const res = await BusinessUserGroupService.getGroups({ page: 1, page_size: 100 })
if (res.code === 0) {
groupOptions.value = res.data.items || []
}
} catch (error) {
console.error('加载业务用户组失败:', error)
} finally {
groupLoading.value = false
}
}
// 表单验证规则
const rules = reactive<FormRules>({
shop_name: [