Merge branch 'develop'

This commit is contained in:
luo
2026-08-21 17:23:16 +08:00
328 changed files with 27039 additions and 5117 deletions

View File

@@ -2,6 +2,7 @@
<ChunkErrorBoundary>
<ElConfigProvider size="default" :locale="locales[language]" :z-index="3000">
<RouterView></RouterView>
<AuditInvestigationHost />
</ElConfigProvider>
</ChunkErrorBoundary>
</template>
@@ -17,6 +18,7 @@
import { checkStorageCompatibility } from '@/utils'
import ChunkErrorBoundary from '@/components/core/others/ChunkErrorBoundary.vue'
import { ElMessageBox } from 'element-plus'
import AuditInvestigationHost from '@/components/business/audit/AuditInvestigationHost.vue'
const userStore = useUserStore()
const { language } = storeToRefs(userStore)
@@ -74,7 +76,7 @@
}
const getEntrySignature = (html: string) => {
const matches = html.match(/(?:src|href)="[^"]*\/assets\/[^""]+\.(?:js|css)"/g)
const matches = html.match(/(?:src|href)="[^"]*\/assets\/[^"]+\.(?:js|css)"/g)
return matches?.sort().join('|') || ''
}
@@ -104,7 +106,6 @@
[
navigator.userAgent,
navigator.language,
navigator.platform,
Intl.DateTimeFormat().resolvedOptions().timeZone,
`${screen.width}x${screen.height}x${screen.colorDepth}`
].join('|')

View File

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

View File

@@ -8,12 +8,23 @@ import type {
AgentRechargeQueryParams,
AgentRechargeListResponse,
CreateAgentRechargeRequest,
AgentRechargePaymentMethods,
AgentRechargePaymentStatusResponse,
ConfirmOfflinePaymentRequest,
RejectAgentRechargeRequest,
BaseResponse
} from '@/types/api'
export class AgentRechargeService extends BaseService {
/**
* 获取代理在线充值可用支付方式和金额限制
*/
static getPaymentMethods(): Promise<BaseResponse<AgentRechargePaymentMethods>> {
return this.get<BaseResponse<AgentRechargePaymentMethods>>(
'/api/admin/agent-recharges/payment-methods'
)
}
/**
* 获取代理充值订单列表
* @param params 查询参数
@@ -32,6 +43,15 @@ export class AgentRechargeService extends BaseService {
return this.getOne<AgentRecharge>(`/api/admin/agent-recharges/${id}`)
}
/**
* 查询在线充值支付及钱包到账状态
*/
static getPaymentStatus(id: number): Promise<BaseResponse<AgentRechargePaymentStatusResponse>> {
return this.getOne<AgentRechargePaymentStatusResponse>(
`/api/admin/agent-recharges/${id}/payment-status`
)
}
/**
* 创建代理充值订单
* @param data 创建充值订单请求参数
@@ -68,4 +88,14 @@ export class AgentRechargeService extends BaseService {
): Promise<BaseResponse<void>> {
return this.post<BaseResponse<void>>(`/api/admin/agent-recharges/${id}/reject`, data)
}
/**
* 补发历史线下代理充值审批
* @param id 充值记录ID
*/
static triggerApproval(id: number): Promise<BaseResponse<AgentRecharge>> {
return this.post<BaseResponse<AgentRecharge>>(
`/api/admin/agent-recharges/${id}/trigger-approval`
)
}
}

View File

@@ -19,6 +19,7 @@ import type {
AssetPackageParams,
AssetCurrentPackageResponse,
DeviceStopResponse,
AssetStartResponse,
AssetWalletTransactionListResponse,
AssetWalletTransactionParams,
AssetWalletResponse,
@@ -26,25 +27,33 @@ import type {
AssetOrdersResponse,
UpdateAssetRealnameStatusRequest,
DtoUpdateAssetRealnameStatusResponse,
AssetOperationLogsResponse,
AssetOperationLogsParams,
AssetPackageUsageRecord,
UpdateAssetPackageUsedDataRequest,
UpdateAssetPackageExpiresAtRequest
UpdateAssetPackageExpiresAtRequest,
ExpiringAssetListResponse,
ExpiringAssetQueryParams
} from '@/types/api'
const runRateLimitedAssetAction = async <T>(
action: AssetRateLimitedAction,
action: Exclude<AssetRateLimitedAction, 'refresh'>,
identifier: string,
request: () => Promise<T>
): Promise<T> => {
assertAssetActionAllowed(action, identifier)
markAssetActionCalled(action, identifier)
const response = await request()
return response
return request()
}
export class AssetService extends BaseService {
/**
* 获取管理端临期资产列表
* GET /api/admin/expiring-assets
*/
static getExpiringAssets(
params?: ExpiringAssetQueryParams
): Promise<BaseResponse<ExpiringAssetListResponse>> {
return this.get<BaseResponse<ExpiringAssetListResponse>>('/api/admin/expiring-assets', params)
}
/**
* 通过任意标识符查询设备或卡的完整详情
* 支持虚拟号、ICCID、IMEI、SN、MSISDN
@@ -54,9 +63,14 @@ export class AssetService extends BaseService {
*/
static resolveAsset(
identifier: string,
params?: AssetResolveParams
params?: AssetResolveParams,
config?: Record<string, any>
): Promise<BaseResponse<AssetResolveResponse>> {
return this.getOne<AssetResolveResponse>(`/api/admin/assets/resolve/${identifier}`, params)
return this.getOne<AssetResolveResponse>(
`/api/admin/assets/resolve/${identifier}`,
params,
config
)
}
/**
@@ -79,16 +93,13 @@ export class AssetService extends BaseService {
/**
* 主动调网关拉取最新数据后返回
* POST /api/admin/assets/:identifier/refresh
* 前端按资产标识限制 5 分钟内只能调用一次
* @param identifier 资产标识符ICCID 或 VirtualNo
*/
static refreshAsset(identifier: string): Promise<BaseResponse<AssetRefreshResponse>> {
return runRateLimitedAssetAction('refresh', identifier, () =>
this.post<BaseResponse<AssetRefreshResponse>>(
`/api/admin/assets/${identifier}/refresh`,
{},
{ timeout: 60000 }
)
return this.post<BaseResponse<AssetRefreshResponse>>(
`/api/admin/assets/${identifier}/refresh`,
{},
{ timeout: 60000 }
)
}
@@ -181,9 +192,16 @@ export class AssetService extends BaseService {
* 前端按资产标识限制 5 分钟内只能调用一次
* @param identifier 资产标识符ICCID 或 VirtualNo
*/
static startAsset(identifier: string): Promise<BaseResponse<void>> {
static startAsset(
identifier: string,
config?: Record<string, any>
): Promise<BaseResponse<AssetStartResponse>> {
return runRateLimitedAssetAction('start', identifier, () =>
this.post<BaseResponse<void>>(`/api/admin/assets/${identifier}/start`, {})
this.post<BaseResponse<AssetStartResponse>>(
`/api/admin/assets/${identifier}/start`,
{},
config
)
)
}
@@ -291,22 +309,4 @@ export class AssetService extends BaseService {
data
)
}
// ========== 资产操作审计日志 ==========
/**
* 查询资产操作审计日志
* GET /api/admin/assets/:identifier/operation-logs
* @param identifier 资产标识符
* @param params 查询参数
*/
static getOperationLogs(
identifier: string,
params?: AssetOperationLogsParams
): Promise<BaseResponse<AssetOperationLogsResponse>> {
return this.get<BaseResponse<AssetOperationLogsResponse>>(
`/api/admin/assets/${identifier}/operation-logs`,
params
)
}
}

155
src/api/modules/audit.ts Normal file
View File

@@ -0,0 +1,155 @@
import { BaseService } from '../BaseService'
import { useUserStore } from '@/store/modules/user'
import { isPlatformAuditAccount } from '@/utils/business/auditAccess'
import type {
AuditActorEventQuery,
AuditActorKind,
AuditEventDetail,
AuditEventPage,
AuditEventQuery,
AuditFinanceQuery,
AuditFinanceTimelinePage,
AuditLinkTimeline,
AuditResourceSearchPage,
AuditResourceSearchQuery,
AuditResourceTimelineQuery,
AuditRiskEventPage,
AuditRiskEventQuery,
AuditRiskOverview,
AuditRiskQuery,
AuditSubjectActivityPage,
AuditSubjectActivityQuery,
AuditSubjectResourceType,
BaseResponse,
IntegrationDetailResponse,
IntegrationListPage,
IntegrationOverview,
IntegrationQuery
} from '@/types/api'
export class AuditService extends BaseService {
private static ensurePlatformAuditAccess() {
const userStore = useUserStore()
if (!isPlatformAuditAccount(userStore.info.user_type, userStore.isSuperAdmin)) {
throw new Error('当前账号无权访问平台审计接口')
}
}
private static ensureSubjectActivityAccess(subject: 'agent' | 'enterprise') {
const userType = Number(useUserStore().info.user_type)
if ((subject === 'agent' && userType !== 3) || (subject === 'enterprise' && userType !== 4)) {
throw new Error('当前账号无权访问主体活动接口')
}
}
static getEvents(params?: AuditEventQuery): Promise<BaseResponse<AuditEventPage>> {
this.ensurePlatformAuditAccess()
return this.get('/api/admin/audit/events', params)
}
static getEventDetail(eventId: string): Promise<BaseResponse<AuditEventDetail>> {
this.ensurePlatformAuditAccess()
return this.get(`/api/admin/audit/events/${eventId}`)
}
static getActorEvents(
kind: AuditActorKind,
id: string,
params?: AuditActorEventQuery
): Promise<BaseResponse<AuditEventPage>> {
this.ensurePlatformAuditAccess()
return this.get(
`/api/admin/audit/actors/${encodeURIComponent(kind)}/${encodeURIComponent(id)}/events`,
params
)
}
static searchResources(
params: AuditResourceSearchQuery
): Promise<BaseResponse<AuditResourceSearchPage>> {
this.ensurePlatformAuditAccess()
return this.get('/api/admin/audit/resources/search', params)
}
static getResourceTimeline(
resourceType: string,
resourceId: string,
params?: AuditResourceTimelineQuery
): Promise<BaseResponse<AuditEventPage>> {
this.ensurePlatformAuditAccess()
return this.get(
`/api/admin/audit/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/timeline`,
params
)
}
static getRequestTimeline(requestId: string): Promise<BaseResponse<AuditLinkTimeline>> {
this.ensurePlatformAuditAccess()
return this.get(`/api/admin/audit/requests/${encodeURIComponent(requestId)}/timeline`)
}
static getCorrelationTimeline(correlationId: string): Promise<BaseResponse<AuditLinkTimeline>> {
this.ensurePlatformAuditAccess()
return this.get(`/api/admin/audit/correlations/${encodeURIComponent(correlationId)}/timeline`)
}
static getFinanceTimeline(
params: AuditFinanceQuery
): Promise<BaseResponse<AuditFinanceTimelinePage>> {
this.ensurePlatformAuditAccess()
return this.get('/api/admin/audit/finance/timeline', params)
}
static getRiskOverview(params?: AuditRiskQuery): Promise<BaseResponse<AuditRiskOverview>> {
this.ensurePlatformAuditAccess()
return this.get('/api/admin/audit/risks/overview', params)
}
static getRiskEvents(params?: AuditRiskEventQuery): Promise<BaseResponse<AuditRiskEventPage>> {
this.ensurePlatformAuditAccess()
return this.get('/api/admin/audit/risks/events', params)
}
static getIntegrationOverview(
params?: IntegrationQuery & { bucket?: 'hour' | 'day' }
): Promise<BaseResponse<IntegrationOverview>> {
this.ensurePlatformAuditAccess()
return this.get('/api/admin/audit/integrations/overview', params)
}
static getIntegrations(params?: IntegrationQuery): Promise<BaseResponse<IntegrationListPage>> {
this.ensurePlatformAuditAccess()
return this.get('/api/admin/audit/integrations', params)
}
static getIntegrationDetail(
integrationId: string
): Promise<BaseResponse<IntegrationDetailResponse>> {
this.ensurePlatformAuditAccess()
return this.get(`/api/admin/audit/integrations/${encodeURIComponent(integrationId)}`)
}
static getAgentResourceActivities(
resourceType: AuditSubjectResourceType,
identifier: string,
params?: AuditSubjectActivityQuery
): Promise<BaseResponse<AuditSubjectActivityPage>> {
this.ensureSubjectActivityAccess('agent')
return this.get(
`/api/admin/agent/resource-activities/${encodeURIComponent(resourceType)}/${encodeURIComponent(identifier)}`,
params
)
}
static getEnterpriseResourceActivities(
resourceType: Extract<AuditSubjectResourceType, 'iot_card' | 'device'>,
identifier: string,
params?: AuditSubjectActivityQuery
): Promise<BaseResponse<AuditSubjectActivityPage>> {
this.ensureSubjectActivityAccess('enterprise')
return this.get(
`/api/admin/enterprise/resource-activities/${encodeURIComponent(resourceType)}/${encodeURIComponent(identifier)}`,
params
)
}
}

View File

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

View File

@@ -35,7 +35,11 @@ import type {
AssetAllocationRecord,
AssetAllocationRecordDetail,
BatchSetCardSeriesBindingRequest,
BatchSetCardSeriesBindingResponse
BatchSetCardSeriesBindingResponse,
BatchUpdateAssetRealnamePolicyRequest,
BatchUpdateAssetRealnamePolicyResponse,
SpeedTierCode,
SetSpeedTierResponse
} from '@/types/api'
type ApiQueryParams = PaginationParams & Record<string, unknown>
@@ -56,6 +60,16 @@ interface CardChangeNotice {
}
export class CardService extends BaseService {
static setSpeedTier(
iccid: string,
code: SpeedTierCode
): Promise<BaseResponse<SetSpeedTierResponse>> {
return this.put<BaseResponse<SetSpeedTierResponse>>(
`/api/admin/iot-cards/${encodeURIComponent(iccid)}/speed-tier`,
{ code },
{ requestOptions: { errorMessageMode: 'none' } }
)
}
// ========== 号卡商品管理 ==========
/**
@@ -411,6 +425,20 @@ export class CardService extends BaseService {
)
}
/**
* 批量更新卡实名认证策略
*/
static batchUpdateRealnamePolicy(
data: BatchUpdateAssetRealnamePolicyRequest,
config?: Record<string, any>
): Promise<BaseResponse<BatchUpdateAssetRealnamePolicyResponse>> {
return this.post<BaseResponse<BatchUpdateAssetRealnamePolicyResponse>>(
'/api/admin/iot-cards/batch-update-realname-policy',
data,
config
)
}
// ========== IoT卡网关操作相关 ==========
/**

View File

@@ -17,13 +17,16 @@ import type {
RecallDevicesResponse,
BatchSetDeviceSeriesBindingRequest,
BatchSetDeviceSeriesBindingResponse,
BatchUpdateAssetRealnamePolicyRequest,
BatchUpdateAssetRealnamePolicyResponse,
ImportDeviceRequest,
ImportDeviceResponse,
DeviceImportTaskQueryParams,
DeviceImportTaskListResponse,
DeviceImportTaskDetail,
DeviceBatchAllocationRequest,
DeviceBatchAllocationResponse,
BaseResponse,
SetSpeedLimitRequest,
SwitchCardRequest,
SetWiFiRequest,
DeviceOperationResponse
@@ -146,6 +149,15 @@ export class DeviceService extends BaseService {
)
}
static createAllocationTask(
data: DeviceBatchAllocationRequest
): Promise<BaseResponse<DeviceBatchAllocationResponse>> {
return this.post<BaseResponse<DeviceBatchAllocationResponse>>(
'/api/admin/devices/import/allocations',
data
)
}
/**
* 获取导入任务详情
* @param id 任务ID
@@ -169,6 +181,20 @@ export class DeviceService extends BaseService {
)
}
/**
* 批量更新设备实名认证策略
*/
static batchUpdateRealnamePolicy(
data: BatchUpdateAssetRealnamePolicyRequest,
config?: Record<string, any>
): Promise<BaseResponse<BatchUpdateAssetRealnamePolicyResponse>> {
return this.post<BaseResponse<BatchUpdateAssetRealnamePolicyResponse>>(
'/api/admin/devices/batch-update-realname-policy',
data,
config
)
}
// ========== 设备操作相关 ==========
/**
@@ -193,21 +219,6 @@ export class DeviceService extends BaseService {
)
}
/**
* 设置限速
* @param imei 设备号(IMEI)
* @param data 限速参数
*/
static setSpeedLimit(
imei: string,
data: SetSpeedLimitRequest
): Promise<BaseResponse<DeviceOperationResponse>> {
return this.put<BaseResponse<DeviceOperationResponse>>(
`/api/admin/devices/by-identifier/${imei}/speed-limit`,
data
)
}
/**
* 切换SIM卡
* @param imei 设备号(IMEI)

View File

@@ -5,13 +5,18 @@
import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api'
export type ExchangeAssetType = 'iot_card' | 'device'
export type ExchangeFlowType = 'shipping' | 'direct'
export type ExchangeStatus = 1 | 2 | 3 | 4 | 5
// 换货单查询参数
export interface ExchangeQueryParams {
page?: number
page_size?: number
status?: number // 换货状态
flow_type?: string // 流程类型shipping/direct
identifier?: string // 资产标识符(模糊匹配,同时匹配旧资产、新资产
status?: ExchangeStatus // 换货状态
flow_type?: ExchangeFlowType // 流程类型shipping/direct
old_asset_keyword?: string // 资产关键词ICCID、接入号、虚拟号、IMEI、SN
new_asset_keyword?: string // 新资产关键词ICCID、接入号、虚拟号、IMEI、SN
created_at_start?: string // 创建时间起始
created_at_end?: string // 创建时间结束
}
@@ -19,9 +24,9 @@ export interface ExchangeQueryParams {
// 创建换货单请求
export interface CreateExchangeRequest {
exchange_reason: string // 换货原因
old_asset_type: string // 旧资产类型 (iot_card 或 device)
old_asset_type: ExchangeAssetType // 旧资产类型 (iot_card 或 device)
old_identifier: string // 旧资产标识符(ICCID/虚拟号/IMEI/SN)
flow_type?: string // 流程类型shipping/direct默认 shipping
flow_type?: ExchangeFlowType // 流程类型shipping/direct默认 shipping
new_identifier?: string // 新资产标识符direct 时必填)
migrate_data?: boolean // 是否迁移数据(默认 false
remark?: string // 备注(可选)
@@ -32,13 +37,16 @@ export interface ExchangeResponse {
id: number
exchange_no: string
exchange_reason: string
old_asset_type: string
old_asset_type: ExchangeAssetType
old_asset_id: number
old_asset_identifier: string
new_asset_type: string
new_asset_identifier: string
status: number // 换货状态1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)
new_asset_type?: ExchangeAssetType | null
new_asset_id?: number | null
new_asset_identifier?: string | null
status: ExchangeStatus // 换货状态1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)
status_name?: string
status_text: string
flow_type: string // 流程类型shipping/direct
flow_type: ExchangeFlowType // 流程类型shipping/direct
flow_type_name: string // 流程类型名称
shipped_at?: string | null // 发货时间(仅 shipping 发货后有值)
completed_at?: string | null // 换货完成时间
@@ -47,6 +55,15 @@ export interface ExchangeResponse {
recipient_address?: string
express_company?: string
express_no?: string
inherited_shop_id?: number | null
inherited_shop_name?: string | null
submitter_name?: string | null // 提交人名称
approval_source?: 'none' | 'legacy' | 'wecom' | null // 审批来源
approval_status?: string | null // 审批状态
approval_status_name?: string | null // 审批状态名称
current_approver_summary?: string | null // 当前审批人摘要
processing_status?: string | null // 业务处理状态
processing_status_name?: string | null // 业务处理状态名称
remark?: string
created_at: string
updated_at: string
@@ -86,8 +103,11 @@ export class ExchangeService extends BaseService {
* POST /api/admin/exchanges
* @param data 创建参数
*/
static createExchange(data: CreateExchangeRequest): Promise<BaseResponse<ExchangeResponse>> {
return this.create<ExchangeResponse>('/api/admin/exchanges', data)
static createExchange(
data: CreateExchangeRequest,
config?: Record<string, any>
): Promise<BaseResponse<ExchangeResponse>> {
return this.post<BaseResponse<ExchangeResponse>>('/api/admin/exchanges', data, config)
}
/**
@@ -114,8 +134,8 @@ export class ExchangeService extends BaseService {
* POST /api/admin/exchanges/{id}/complete
* @param id 换货单ID
*/
static completeExchange(id: number): Promise<BaseResponse> {
return this.post<BaseResponse>(`/api/admin/exchanges/${id}/complete`, {})
static completeExchange(id: number): Promise<BaseResponse<ExchangeResponse>> {
return this.post<BaseResponse<ExchangeResponse>>(`/api/admin/exchanges/${id}/complete`, {})
}
/**

View File

@@ -1,9 +1,7 @@
import { BaseService } from '../BaseService'
import type {
CancelExportTaskApiResponse,
CreateExportTaskApiResponse,
CreateExportTaskRequest,
ExportTaskDetail,
ExportTaskDetailApiResponse,
ExportTaskListApiResponse,
ExportTaskQueryParams
@@ -19,10 +17,6 @@ export class ExportTaskService extends BaseService {
}
static getExportTaskDetail(id: number): Promise<ExportTaskDetailApiResponse> {
return this.getOne<ExportTaskDetail>(`/api/admin/export-tasks/${id}`)
}
static cancelExportTask(id: number): Promise<CancelExportTaskApiResponse> {
return this.post<CancelExportTaskApiResponse>(`/api/admin/export-tasks/${id}/cancel`, {})
return this.get<ExportTaskDetailApiResponse>(`/api/admin/export-tasks/${id}`)
}
}

View File

@@ -18,10 +18,12 @@ export { CarrierService } from './carrier'
export { PackageSeriesService } from './packageSeries'
export { PackageManageService } from './packageManage'
export { ShopSeriesGrantService } from './shopSeriesGrant'
export { ShopPackageAllocationService } from './shopPackageAllocation'
export { OrderService } from './order'
export { AssetService } from './asset'
export { AgentRechargeService } from './agentRecharge'
export { PaymentSettingsService } from './paymentSettings'
export { SystemConfigService } from './systemConfig'
export { ExchangeService } from './exchange'
export { RefundService } from './refund'
export { DataCleanupService } from './dataCleanup'
@@ -33,6 +35,10 @@ export { PollingMonitorService } from './pollingMonitor'
export { SuperAdminService } from './superAdmin'
export { ExportTaskService } from './exportTask'
export { OrderPackageInvalidateTaskService } from './orderPackageInvalidateTask'
export { BulkPurchaseService } from './bulkPurchase'
export { NotificationService } from './notification'
export { AuditService } from './audit'
export { WecomService } from './wecom'
// TODO: 按需添加其他业务模块
// export { SettingService } from './setting'

View File

@@ -0,0 +1,50 @@
import { BaseService } from '../BaseService'
import type {
BaseResponse,
NotificationListResponse,
NotificationQueryParams,
NotificationReadRequest,
NotificationReadAllRequest,
NotificationReadAllResponse,
NotificationReadResponse,
NotificationUnreadCount,
NotificationUnreadSummary,
NotificationTarget
} from '@/types/api'
export class NotificationService extends BaseService {
static getUnreadCount(): Promise<BaseResponse<NotificationUnreadCount>> {
return this.get<BaseResponse<NotificationUnreadCount>>('/api/admin/notifications/unread-count')
}
static getUnreadSummary(): Promise<BaseResponse<NotificationUnreadSummary>> {
return this.get<BaseResponse<NotificationUnreadSummary>>(
'/api/admin/notifications/unread-summary'
)
}
static getNotifications(
params?: NotificationQueryParams
): Promise<BaseResponse<NotificationListResponse>> {
return this.get<BaseResponse<NotificationListResponse>>('/api/admin/notifications', params)
}
static markRead(id: number): Promise<BaseResponse<NotificationReadResponse>> {
return this.put<BaseResponse<NotificationReadResponse>>(`/api/admin/notifications/${id}/read`, {
id
} satisfies NotificationReadRequest)
}
static markAllRead(
data: NotificationReadAllRequest = {}
): Promise<BaseResponse<NotificationReadAllResponse>> {
return this.put<BaseResponse<NotificationReadAllResponse>>(
'/api/admin/notifications/read-all',
data
)
}
static getTarget(id: number): Promise<BaseResponse<NotificationTarget>> {
return this.get<BaseResponse<NotificationTarget>>(`/api/admin/notifications/${id}/target`)
}
}

View File

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

View File

@@ -8,10 +8,7 @@ import type {
RefundQueryParams,
RefundListResponse,
CreateRefundRequest,
ApproveRefundRequest,
RejectRefundRequest,
ResubmitRefundRequest,
ReturnRefundRequest,
BaseResponse
} from '@/types/api'
@@ -40,24 +37,6 @@ 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<void>> {
return this.post<BaseResponse<void>>(`/api/admin/refunds/${id}/approve`, data)
}
/**
* 审批拒绝退款申请
* @param id 退款申请ID
* @param data 审批拒绝请求参数
*/
static rejectRefund(id: number, data: RejectRefundRequest): Promise<BaseResponse<void>> {
return this.post<BaseResponse<void>>(`/api/admin/refunds/${id}/reject`, data)
}
/**
* 重新提交退款申请
* @param id 退款申请ID
@@ -68,11 +47,10 @@ export class RefundService extends BaseService {
}
/**
* 驳回申请
* 补发历史退款审批
* @param id 退款申请ID
* @param data 退回请求参数
*/
static returnRefund(id: number, data: ReturnRefundRequest): Promise<BaseResponse<void>> {
return this.post<BaseResponse<void>>(`/api/admin/refunds/${id}/return`, data)
static triggerApproval(id: number): Promise<BaseResponse<Refund>> {
return this.post<BaseResponse<Refund>>(`/api/admin/refunds/${id}/trigger-approval`)
}
}

View File

@@ -8,7 +8,8 @@ import type {
PlatformRole,
RoleQueryParams,
PlatformRoleFormData,
PermissionTreeNode,
UpdateRoleDefaultCreditRequest,
UpdateRoleDefaultCreditResponse,
BaseResponse,
PaginationResponse
} from '@/types/api'
@@ -47,10 +48,26 @@ export class RoleService extends BaseService {
* @param id 角色ID
* @param data 角色数据
*/
static updateRole(id: number, data: PlatformRoleFormData): Promise<BaseResponse> {
static updateRole(id: number, data: Partial<PlatformRoleFormData>): Promise<BaseResponse> {
return this.update(`/api/admin/roles/${id}`, data)
}
/**
* 更新客户角色默认信用
* PUT /api/admin/roles/{id}/default-credit
* @param id 角色ID
* @param data 默认信用配置
*/
static updateRoleDefaultCredit(
id: number,
data: UpdateRoleDefaultCreditRequest
): Promise<BaseResponse<UpdateRoleDefaultCreditResponse>> {
return this.put<BaseResponse<UpdateRoleDefaultCreditResponse>>(
`/api/admin/roles/${id}/default-credit`,
data
)
}
/**
* 删除角色
* DELETE /api/admin/roles/{id}

View File

@@ -10,8 +10,12 @@ import type {
UpdateShopParams,
ShopRolesResponse,
AssignShopRolesRequest,
UpdateShopCreditLimitRequest,
UpdateShopCreditLimitResponse,
BaseResponse,
PaginationResponse
PaginationResponse,
ShopBusinessOwnerCandidate,
ShopBusinessOwnerCandidateQueryParams
} from '@/types/api'
export class ShopService extends BaseService {
@@ -24,6 +28,27 @@ export class ShopService extends BaseService {
return this.getPage<ShopResponse>('/api/admin/shops', params)
}
/**
* 获取店铺业务员候选
* GET /api/admin/shops/business-owner-candidates
*/
static getBusinessOwnerCandidates(
params?: ShopBusinessOwnerCandidateQueryParams
): Promise<PaginationResponse<ShopBusinessOwnerCandidate>> {
return this.getPage<ShopBusinessOwnerCandidate>(
'/api/admin/shops/business-owner-candidates',
params
)
}
/**
* 获取店铺详情
* GET /api/admin/shops/{id}
*/
static getShopDetail(id: number): Promise<BaseResponse<ShopResponse>> {
return this.getOne<ShopResponse>(`/api/admin/shops/${id}`)
}
/**
* 店铺级联查询(树结构)
* GET /api/admin/shops/cascade
@@ -114,4 +139,20 @@ export class ShopService extends BaseService {
static deleteShopRole(shopId: number, roleId: number): Promise<BaseResponse> {
return this.delete<BaseResponse>(`/api/admin/shops/${shopId}/roles/${roleId}`)
}
/**
* 更新店铺实际信用额度
* PUT /api/admin/shops/{id}/credit-limit
* @param shopId 店铺ID
* @param data 实际信用额度配置
*/
static updateShopCreditLimit(
shopId: number,
data: UpdateShopCreditLimitRequest
): Promise<BaseResponse<UpdateShopCreditLimitResponse>> {
return this.put<BaseResponse<UpdateShopCreditLimitResponse>>(
`/api/admin/shops/${shopId}/credit-limit`,
data
)
}
}

View File

@@ -0,0 +1,55 @@
/**
* 店铺套餐分配 API 服务
*/
import { BaseService } from '../BaseService'
import type {
BaseResponse,
CreateShopPackageBatchAllocationsRequest,
CreateShopPackageAllocationRequest,
ShopPackageAllocationResponse,
ShopPackageBatchAllocationsResponse,
UpdateShopPackageAllocationExpiryBaseRequest
} from '@/types/api'
export class ShopPackageAllocationService extends BaseService {
/**
* 创建店铺套餐分配
* POST /api/admin/shop-package-allocations
*/
static createShopPackageAllocation(
data: CreateShopPackageAllocationRequest
): Promise<BaseResponse<ShopPackageAllocationResponse>> {
return this.post<BaseResponse<ShopPackageAllocationResponse>>(
'/api/admin/shop-package-allocations',
data
)
}
/**
* 批量创建店铺套餐分配
* POST /api/admin/shop-package-batch-allocations
*/
static createShopPackageBatchAllocations(
data: CreateShopPackageBatchAllocationsRequest
): Promise<BaseResponse<ShopPackageBatchAllocationsResponse>> {
return this.post<BaseResponse<ShopPackageBatchAllocationsResponse>>(
'/api/admin/shop-package-batch-allocations',
data
)
}
/**
* 更新店铺套餐分配生效条件
* PATCH /api/admin/shop-package-allocations/{id}/expiry-base
*/
static updateShopPackageAllocationExpiryBase(
id: number,
data: UpdateShopPackageAllocationExpiryBaseRequest
): Promise<BaseResponse<ShopPackageAllocationResponse>> {
return this.patch<BaseResponse<ShopPackageAllocationResponse>>(
`/api/admin/shop-package-allocations/${id}/expiry-base`,
data
)
}
}

View File

@@ -9,6 +9,7 @@ import type {
CreateShopSeriesGrantRequest,
UpdateShopSeriesGrantRequest,
ManageGrantPackagesRequest,
ShopSeriesGrantPackageOptionsResponse,
BaseResponse,
PaginationResponse
} from '@/types/api'
@@ -45,6 +46,20 @@ export class ShopSeriesGrantService extends BaseService {
return this.getOne<ShopSeriesGrantResponse>(`/api/admin/shop-series-grants/${id}`)
}
/**
* 获取代理系列授权可选套餐
* GET /api/admin/shop-series-grants/package-options
*/
static getPackageOptions(
shopId: number,
seriesId: number
): Promise<BaseResponse<ShopSeriesGrantPackageOptionsResponse>> {
return this.getOne<ShopSeriesGrantPackageOptionsResponse>(
'/api/admin/shop-series-grants/package-options',
{ shop_id: shopId, series_id: seriesId }
)
}
/**
* 更新代理系列授权
* PUT /api/admin/shop-series-grants/{id}

View File

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

View File

@@ -0,0 +1,28 @@
import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api'
import type {
SystemConfigItem,
SystemConfigPageResult,
SystemConfigQueryParams,
UpdateSystemConfigRequest
} from '@/types/api/systemConfig'
const SYSTEM_CONFIG_BASE_URL = '/api/admin/system-configs'
export class SystemConfigService extends BaseService {
static getSystemConfigs(
params?: SystemConfigQueryParams
): Promise<BaseResponse<SystemConfigPageResult>> {
return this.get<BaseResponse<SystemConfigPageResult>>(SYSTEM_CONFIG_BASE_URL, params)
}
static updateSystemConfig(
key: string,
data: UpdateSystemConfigRequest
): Promise<BaseResponse<SystemConfigItem>> {
return this.put<BaseResponse<SystemConfigItem>>(
`${SYSTEM_CONFIG_BASE_URL}/${encodeURIComponent(key)}`,
data
)
}
}

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

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

View File

@@ -72,19 +72,24 @@
}
.el-dialog {
border-radius: 100px !important;
border-radius: calc(var(--custom-radius) / 1.2 + 2px) !important;
border-radius: 12px !important;
overflow: hidden;
}
.el-dialog__header {
padding: 20px 24px 16px !important;
margin-right: 0 !important;
border-bottom: 1px solid var(--el-border-color-lighter);
.el-dialog__title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.el-dialog__body {
padding: 25px 0 !important;
padding: 20px 24px !important;
position: relative; // 为了兼容 el-pagination 样式,需要设置 relative不然会影响 el-pagination 的样式,比如 el-pagination__jump--small 会被影响,导致 el-pagination__jump--small 按钮无法点击,详见 URL_ADDRESS.com/element-plus/element-plus/issues/5684#issuecomment-1176299275;
}

View File

@@ -0,0 +1,84 @@
<template>
<ElDialog v-model="visible" title="批量修改实名顺序" width="40%" @closed="resetPolicy">
<ElForm label-width="80px">
<ElFormItem label="已选数量">
<span class="selected-count">{{ selectedCount }} {{ assetUnit }}</span>
</ElFormItem>
<ElFormItem label="认证策略">
<ElRadioGroup v-model="policy" :disabled="loading">
<ElRadio value="none">无需实名</ElRadio>
<ElRadio value="before_order">先实名后购买</ElRadio>
<ElRadio value="after_order">先购买后实名</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElAlert
v-if="errorMessage"
:title="errorMessage"
type="error"
:closable="false"
show-icon
class="error-alert"
/>
</ElForm>
<template #footer>
<ElButton :disabled="loading" @click="visible = false">取消</ElButton>
<ElButton type="primary" :loading="loading" @click="emit('confirm', policy)">
确认修改
</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
ElAlert,
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElRadio,
ElRadioGroup
} from 'element-plus'
import type { AssetRealnamePolicy } from '@/types/api'
interface Props {
modelValue: boolean
selectedCount: number
assetUnit: string
loading?: boolean
errorMessage?: string
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
errorMessage: ''
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
confirm: [policy: AssetRealnamePolicy]
}>()
const policy = ref<AssetRealnamePolicy>('none')
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const resetPolicy = () => {
policy.value = 'none'
}
</script>
<style scoped lang="scss">
.selected-count {
font-weight: 600;
color: var(--el-color-primary);
}
.error-alert {
margin-top: 12px;
}
</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="120px">
<ElForm ref="formRef" :model="formData" :rules="formRules" label-width="80px">
<ElFormItem label="订单号" prop="order_id">
<ElSelect
v-if="!hasInitialOrder"
@@ -23,7 +23,7 @@
</ElSelect>
<ElInput v-else :model-value="props.initialOrderNo" disabled style="width: 100%" />
</ElFormItem>
<ElFormItem label="申请退款金额" prop="requested_refund_amount">
<ElFormItem label="退款金额" prop="requested_refund_amount">
<ElInputNumber
v-model="formData.requested_refund_amount"
:min="0"
@@ -86,6 +86,7 @@
modelValue: boolean
initialOrderId?: number
initialOrderNo?: string
initialPackageUsageId?: number
}
const props = defineProps<Props>()
@@ -108,12 +109,14 @@
const formData = reactive<{
order_id: number | null
package_usage_id?: number
requested_refund_amount?: number
actual_received_amount: number
refund_reason: string
refund_voucher_key: string[]
}>({
order_id: null,
package_usage_id: undefined,
requested_refund_amount: undefined,
actual_received_amount: 0,
refund_reason: '',
@@ -198,6 +201,7 @@
formRef.value?.resetFields()
voucherUploading.value = false
formData.order_id = null
formData.package_usage_id = undefined
formData.requested_refund_amount = undefined
formData.actual_received_amount = 0
formData.refund_reason = ''
@@ -218,11 +222,12 @@
submitLoading.value = true
try {
const data: CreateRefundRequest = {
order_id: formData.order_id!,
requested_refund_amount: yuanToFen(formData.requested_refund_amount) ?? 0,
actual_received_amount: formData.actual_received_amount,
order_id: formData.order_id!,
package_usage_id: formData.package_usage_id ?? 0,
refund_reason: formData.refund_reason,
refund_voucher_key: formData.refund_voucher_key,
refund_reason: formData.refund_reason || undefined
requested_refund_amount: yuanToFen(formData.requested_refund_amount) ?? 0
}
await RefundService.createRefund(data)
ElMessage.success('退款申请创建成功')
@@ -243,6 +248,7 @@
if (hasInitialOrder.value && props.initialOrderId) {
formData.order_id = props.initialOrderId
}
formData.package_usage_id = props.initialPackageUsageId
if (hasInitialOrder.value && props.initialOrderNo) {
searchOrders(props.initialOrderNo)
} else {

View File

@@ -12,7 +12,7 @@
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="ID"
row-key="id"
:loading="loading"
:data="accountList"
height="60vh"

View File

@@ -1,10 +1,10 @@
<template>
<ElDialog v-model="visible" :title="title" width="520px" destroy-on-close>
<ElDialog v-model="visible" :title="title" width="40%" destroy-on-close>
<ElAlert type="info" :closable="false" show-icon class="export-rule-alert">
<template #title>{{ description }}</template>
</ElAlert>
<ElForm label-width="100px" class="export-task-form">
<ElForm label-width="80px" class="export-task-form">
<ElFormItem label="导出场景">
<ElTag>{{ sceneName }}</ElTag>
</ElFormItem>
@@ -85,13 +85,22 @@
}
)
const getNormalizedQuery = () => {
const query: Record<string, unknown> = {}
const getNormalizedQuery = (): Record<string, string> => {
const query: Record<string, string> = {}
Object.entries(props.query || {}).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return
if (Array.isArray(value) && value.length === 0) return
query[key] = value
if (typeof value === 'string') {
query[key] = value
} else if (typeof value === 'number' || typeof value === 'boolean') {
query[key] = String(value)
} else if (Array.isArray(value)) {
query[key] = value.join(',')
} else {
query[key] = JSON.stringify(value)
}
})
return query
@@ -113,6 +122,9 @@
if (res.code === 0) {
ElMessage.success(res.data?.message || '导出任务已创建')
if (res.data?.task_id) {
localStorage.setItem(`export-task-active:${props.scene}`, String(res.data.task_id))
}
emit('success', res.data)
visible.value = false
} else {

View File

@@ -1,45 +0,0 @@
<template>
<ElDrawer
v-model="dialogVisible"
title="操作审计日志"
direction="rtl"
size="70%"
:before-close="handleClose"
>
<OperationLogsCard :asset-identifier="identifier" :download-permission="downloadPermission" />
</ElDrawer>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElDrawer } from 'element-plus'
import OperationLogsCard from '@/views/asset-management/asset-information/components/OperationLogsCard.vue'
interface Props {
modelValue: boolean
identifier?: string
downloadPermission?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const dialogVisible = ref(props.modelValue)
watch(
() => props.modelValue,
(val) => {
dialogVisible.value = val
}
)
watch(dialogVisible, (val) => {
emit('update:modelValue', val)
})
const handleClose = () => {
dialogVisible.value = false
}
</script>

View File

@@ -0,0 +1,290 @@
<template>
<ElDialog
v-model="dialogVisible"
:title="props.dialogType === 'add' ? '添加套餐' : '编辑套餐'"
width="40%"
:close-on-click-modal="false"
@closed="emit('closed')"
>
<ElForm ref="formRef" :model="props.form" :rules="props.rules" label-width="130px">
<ElFormItem v-if="props.dialogType === 'add'" label="选择套餐" prop="package_ids">
<ElSelect
:model-value="props.form.package_ids"
@update:model-value="updateForm({ package_ids: $event })"
placeholder="请选择套餐(可多选)"
style="width: 100%"
filterable
remote
:remote-method="(query: string) => emit('search', query)"
:loading="props.packageLoading"
clearable
multiple
:multiple-limit="100"
popper-class="package-select-dropdown"
>
<template
v-if="props.availablePackages.length === 0 && !props.packageLoading && props.hasSeries"
>
<ElOption disabled value="" label="该系列没有可选套餐" />
</template>
<ElOption
v-for="pkg in props.availablePackages"
:key="pkg.id"
:label="getPackageOptionLabel(pkg)"
:value="pkg.id"
:disabled="pkg.is_authorized"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="props.dialogType === 'add' && props.form.packages.length"
label="套餐配置"
prop="packages"
>
<div class="package-config-list">
<div v-for="pkg in props.form.packages" :key="pkg.package_id" class="package-config-item">
<div class="package-config-name" :title="pkg.package_name || ''">
{{ pkg.package_name || '套餐名称不可用' }}
</div>
<div class="package-config-cost">
<span class="package-config-field-label">套餐成本价(元)</span>
<ElInputNumber
:model-value="pkg.cost_price_yuan"
:min="pkg.original_cost_price || 0"
:max="getPackageCostPriceMax(pkg)"
:precision="2"
:step="0.01"
:controls="false"
placeholder="请输入成本价"
style="width: 150px"
@update:model-value="updatePackageCost(pkg.package_id, $event)"
/>
</div>
</div>
</div>
</ElFormItem>
<ElFormItem v-if="props.dialogType === 'edit'" label="套餐名称">
<span>{{ props.form.package_name }}</span>
</ElFormItem>
<ElFormItem v-if="props.dialogType === 'edit'" label="套餐编码">
<span>{{ props.form.package_code }}</span>
</ElFormItem>
<ElFormItem v-if="props.dialogType === 'edit'" label="成本价()" prop="cost_price_yuan">
<ElInputNumber
:model-value="props.form.cost_price_yuan"
:precision="2"
:step="0.01"
:controls="false"
style="width: 100%"
placeholder="请输入成本价"
@update:model-value="updateForm({ cost_price_yuan: $event ?? 0 })"
/>
<div v-if="props.form.original_cost_price" class="form-tip">
请参考{{ props.form.package_name || '套餐' }} - 套餐成本价: ¥{{
props.form.original_cost_price.toFixed(2)
}}
</div>
</ElFormItem>
<ElFormItem v-if="props.dialogType === 'add' || props.canUpdateExpiryBase" label="生效条件">
<ElSelect
:model-value="props.form.expiry_base_override"
placeholder="请选择生效条件"
style="width: 100%"
@update:model-value="updateForm({ expiry_base_override: $event })"
>
<ElOption label="跟随套餐默认" value="default" />
<ElOption label="购买即生效" value="from_purchase" />
<ElOption label="实名激活时生效" value="from_activation" />
</ElSelect>
<div class="form-tip">仅影响后续新订单,不影响已购买套餐</div>
</ElFormItem>
<template v-if="props.dialogType === 'edit'">
<ElFormItem label="套餐默认生效条件">
{{ props.form.default_expiry_base_name || '-' }}
</ElFormItem>
<ElFormItem label="覆盖生效条件">
{{ props.form.expiry_base_override_name || '跟随套餐默认' }}
</ElFormItem>
<ElFormItem label="最终生效条件">
{{ props.form.effective_expiry_base_name || '-' }}
</ElFormItem>
</template>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="props.submitLoading" @click="handleSubmit">
保存
</ElButton>
</div>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInputNumber,
ElOption,
ElSelect,
type FormInstance,
type FormRules
} from 'element-plus'
import type {
GrantPackageCandidate,
SeriesGrantPackageForm
} from '@/utils/business/seriesGrantPackage'
const props = withDefaults(
defineProps<{
modelValue: boolean
dialogType: 'add' | 'edit'
form: SeriesGrantPackageForm
rules: FormRules
availablePackages: GrantPackageCandidate[]
packageLoading?: boolean
submitLoading?: boolean
canUpdateExpiryBase?: boolean
hasSeries?: boolean
}>(),
{
packageLoading: false,
submitLoading: false,
canUpdateExpiryBase: false,
hasSeries: false
}
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'update:form': [form: SeriesGrantPackageForm]
search: [query: string]
submit: []
closed: []
}>()
const formRef = ref<FormInstance>()
const dialogVisible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const formatPackageOptionPrice = (price?: number | null) => {
if (price === undefined || price === null) return '未配置'
return `¥${(price / 100).toFixed(2)}`
}
const getPackageOptionLabel = (pkg: GrantPackageCandidate) => {
const packageName = pkg.package_name || '套餐名称不可用'
return `${packageName} - 成本价 ${formatPackageOptionPrice(pkg.cost_price)} - 建议售价 ${formatPackageOptionPrice(pkg.suggested_retail_price)}`
}
const getPackageCostPriceMax = (pkg: {
original_cost_price?: number
suggested_retail_price?: number | null
}) => {
if (pkg.suggested_retail_price !== undefined && pkg.suggested_retail_price !== null) {
return Number((pkg.suggested_retail_price * 1.5).toFixed(2))
}
if (pkg.original_cost_price !== undefined && pkg.original_cost_price !== null) {
return Number((pkg.original_cost_price * 1.5).toFixed(2))
}
return undefined
}
const updateForm = (values: Partial<SeriesGrantPackageForm>) => {
emit('update:form', { ...props.form, ...values })
}
const updatePackageCost = (packageId: number, costPrice: number | undefined) => {
updateForm({
packages: props.form.packages.map((pkg) =>
pkg.package_id === packageId ? { ...pkg, cost_price_yuan: costPrice ?? 0 } : pkg
)
})
}
const handleSubmit = async () => {
if (!formRef.value) return
try {
const valid = await formRef.value.validate()
if (valid) emit('submit')
} catch {
// 表单校验失败时由表单项展示提示
}
}
watch(
() => props.modelValue,
(visible) => {
if (visible) nextTick(() => formRef.value?.clearValidate())
}
)
</script>
<style scoped lang="scss">
.form-tip {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
:global(.package-select-dropdown .el-select-dropdown__item) {
height: auto;
min-height: 34px;
padding-top: 6px;
padding-bottom: 6px;
line-height: 1.5;
white-space: normal;
}
.package-config-list {
width: 100%;
}
.package-config-item {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
.package-config-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.package-config-cost {
display: flex;
flex-shrink: 0;
gap: 8px;
align-items: center;
}
.package-config-field-label {
font-size: 13px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.dialog-footer {
text-align: right;
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<ElTable
v-loading="props.loading"
:data="props.data"
row-key="package_id"
border
stripe
:empty-text="props.emptyText"
>
<ElTableColumn prop="package_name" label="套餐名称" min-width="220" show-overflow-tooltip />
<ElTableColumn prop="package_code" label="套餐编码" min-width="180" show-overflow-tooltip />
<ElTableColumn label="成本价" width="120">
<template #default="{ row }">
<span class="amount-value">¥{{ (row.cost_price / 100).toFixed(2) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="套餐默认生效条件" min-width="150">
<template #default="{ row }">
{{ row.default_expiry_base_name || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="覆盖生效条件" min-width="150">
<template #default="{ row }">
{{ row.expiry_base_override_name || '跟随套餐默认' }}
</template>
</ElTableColumn>
<ElTableColumn label="最终生效条件" min-width="150">
<template #default="{ row }">
{{ row.effective_expiry_base_name || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="上架状态" width="100" align="center">
<template #default="{ row }">
<ElTag v-if="row.shelf_status === 1" type="success" size="small">上架</ElTag>
<ElTag v-else-if="row.shelf_status === 2" type="info" size="small">下架</ElTag>
<span v-else>-</span>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="100" align="center">
<template #default="{ row }">
<ElTag v-if="row.status === 1" type="success" size="small">启用</ElTag>
<ElTag v-else-if="row.status === 2" type="danger" size="small">禁用</ElTag>
<span v-else>-</span>
</template>
</ElTableColumn>
<ElTableColumn v-if="props.showActions" label="操作" :width="props.actionsWidth" fixed="right">
<template #default="{ row }">
<slot name="actions" :row="row" />
</template>
</ElTableColumn>
</ElTable>
</template>
<script setup lang="ts">
import { ElTable, ElTableColumn, ElTag } from 'element-plus'
import type { GrantPackageInfo } from '@/types/api'
const props = withDefaults(
defineProps<{
data: GrantPackageInfo[]
loading?: boolean
emptyText?: string
showActions?: boolean
actionsWidth?: number | string
}>(),
{
loading: false,
emptyText: '暂无套餐',
showActions: false,
actionsWidth: 240
}
)
</script>
<style scoped lang="scss">
.amount-value {
font-weight: 600;
color: var(--el-color-warning);
}
</style>

View File

@@ -0,0 +1,242 @@
<template>
<ElDialog
v-model="dialogVisible"
:title="`调整实际信用额度 - ${currentShop?.shop_name || ''}`"
width="520px"
:close-on-click-modal="false"
@closed="resetDialog"
>
<ElDescriptions :column="1" border class="credit-preview">
<ElDescriptionsItem label="店铺名称">
{{ currentShop?.shop_name || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="当前版本">
{{ currentShop?.version ?? '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="修改前">
{{ formatCreditPreview(currentShop?.credit_enabled, currentShop?.credit_limit) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="修改后">
{{ formatCreditPreview(creditForm.credit_enabled, creditLimitFen) }}
</ElDescriptionsItem>
</ElDescriptions>
<ElAlert
title="实际可用金额、欠款金额和欠款状态以后端刷新后的资金概况为准。"
type="info"
:closable="false"
show-icon
class="credit-dialog-alert"
/>
<ElForm ref="creditFormRef" :model="creditForm" :rules="creditRules" label-width="110px">
<ElFormItem label="启用信用">
<ElSwitch v-model="creditForm.credit_enabled" @change="handleCreditEnabledChange" />
</ElFormItem>
<ElFormItem label="实际信用额度" prop="credit_limit_yuan">
<ElInputNumber
v-model="creditForm.credit_limit_yuan"
:disabled="!creditForm.credit_enabled"
:min="0"
:precision="2"
:step="100"
controls-position="right"
style="width: 100%"
placeholder="请输入实际信用额度"
/>
<div class="credit-dialog-tip">单位关闭信用时额度将自动归零</div>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">确认调整</ElButton>
</div>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { CommissionService, ShopService } from '@/api/modules'
import type { ShopFundSummaryItem } from '@/types/api/commission'
import { fenToYuan, formatMoney, yuanToFen } from '@/utils/business/format'
import { normalizeApiError } from '@/utils/business/apiError'
const props = defineProps<{
modelValue: boolean
shop: ShopFundSummaryItem | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
submitted: []
}>()
const dialogVisible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const creditFormRef = ref<FormInstance>()
const submitting = ref(false)
const currentShop = ref<ShopFundSummaryItem | null>(null)
const creditForm = reactive({
credit_enabled: false,
credit_limit_yuan: 0
})
const creditLimitFen = computed(() =>
creditForm.credit_enabled ? yuanToFen(creditForm.credit_limit_yuan) || 0 : 0
)
const creditRules = computed<FormRules>(() => ({
credit_limit_yuan: [
{
validator: (
_rule: unknown,
value: number | undefined,
callback: (error?: Error) => void
) => {
if (!creditForm.credit_enabled) {
callback()
return
}
if (value === undefined || value === null || Number.isNaN(value)) {
callback(new Error('请输入实际信用额度'))
return
}
if (value <= 0) {
callback(new Error('实际信用额度必须大于0'))
return
}
callback()
},
trigger: 'blur'
}
]
}))
const formatCreditPreview = (enabled?: boolean, creditLimit?: number) =>
enabled ? `启用 / ${formatMoney(creditLimit || 0)}` : '关闭 / ¥0.00'
const syncForm = (summary: ShopFundSummaryItem) => {
creditForm.credit_enabled = Boolean(summary.credit_enabled)
creditForm.credit_limit_yuan = summary.credit_enabled ? fenToYuan(summary.credit_limit) : 0
nextTick(() => creditFormRef.value?.clearValidate())
}
watch(
() => [props.modelValue, props.shop] as const,
([visible, shop]) => {
if (visible && shop) {
currentShop.value = shop
syncForm(shop)
}
},
{ immediate: true }
)
const handleCreditEnabledChange = (enabled: boolean | string | number) => {
if (!enabled) {
creditForm.credit_limit_yuan = 0
creditFormRef.value?.clearValidate('credit_limit_yuan')
}
}
const resetDialog = () => {
creditFormRef.value?.resetFields()
currentShop.value = null
creditForm.credit_enabled = false
creditForm.credit_limit_yuan = 0
}
const loadLatestSummary = async () => {
if (!currentShop.value) return null
const res = await CommissionService.getShopFundSummary({
page: 1,
page_size: 100,
shop_name: currentShop.value.shop_name
})
if (res.code !== 0) return null
return (
(res.data.items || []).find((item) => item.shop_id === currentShop.value?.shop_id) || null
)
}
const isConflict = (error: unknown) => {
const normalized = normalizeApiError(error)
return normalized.kind === 'conflict' || normalized.status === 409
}
const handleSubmit = async () => {
if (!currentShop.value || !creditFormRef.value) return
try {
await creditFormRef.value.validate()
} catch {
return
}
submitting.value = true
try {
const res = await ShopService.updateShopCreditLimit(currentShop.value.shop_id, {
credit_enabled: creditForm.credit_enabled,
credit_limit: creditLimitFen.value,
version: currentShop.value.version
})
if (res.code === 0) {
ElMessage.success('实际信用额度调整成功')
dialogVisible.value = false
emit('submitted')
return
}
if (res.code === 409) {
const latest = await loadLatestSummary()
if (latest) {
currentShop.value = latest
syncForm(latest)
}
ElMessage.warning('资金概况已被其他操作更新,已刷新最新版本;请确认后重新提交')
return
}
ElMessage.error(res.msg || '实际信用额度调整失败')
} catch (error) {
if (isConflict(error)) {
const latest = await loadLatestSummary()
if (latest) {
currentShop.value = latest
syncForm(latest)
}
ElMessage.warning('资金概况已被其他操作更新,已刷新最新版本;请确认后重新提交')
} else {
console.error('实际信用额度调整失败:', error)
}
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
.credit-preview {
margin-bottom: 16px;
}
.credit-dialog-alert {
margin-bottom: 16px;
}
.credit-dialog-tip {
margin-top: 6px;
font-size: 12px;
line-height: 1.4;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -2,10 +2,11 @@
<ElDialog
v-model="dialogVisible"
title="手动更新卡实名状态"
width="400px"
width="30%"
modal-class="asset-information-dialog"
@closed="handleDialogClosed"
>
<ElForm ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<ElForm ref="formRef" :model="formData" :rules="formRules" label-width="80px">
<ElFormItem label="资产标识">
<span style="font-weight: bold; color: #409eff">{{ assetIdentifier }}</span>
</ElFormItem>

View File

@@ -12,6 +12,7 @@
drag
multiple
:auto-upload="false"
:accept="accept"
:on-change="handleFileChange"
:on-remove="handleRemoveFile"
list-type="picture"
@@ -20,7 +21,7 @@
<div class="voucher-upload__text">{{ voucherName }}拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">
支持多张图片或附件最多上传 {{ maxCount }} 个文件可粘贴文件
{{ tip || `支持多张图片或附件,最多上传 ${maxCount} 个文件;可粘贴文件。` }}
</div>
</template>
<template #file="{ file }">
@@ -56,28 +57,44 @@
import { Close, Document, UploadFilled } from '@element-plus/icons-vue'
import type { UploadFile, UploadInstance, UploadRawFile } from 'element-plus'
import { StorageService } from '@/api/modules'
import type { RefundAttachment } from '@/types/api/refund'
import type { FilePurpose } from '@/api/modules/storage'
interface Props {
modelValue?: string[] | string
voucherName?: string
maxCount?: number
accept?: string
tip?: string
purpose?: FilePurpose
maxSizeMb?: number
singleColumnCsv?: boolean
maxCsvRows?: number
}
const props = withDefaults(defineProps<Props>(), {
voucherName: '凭证',
maxCount: 5
maxCount: 5,
accept: '',
tip: '',
purpose: 'attachment',
maxSizeMb: 0,
singleColumnCsv: false,
maxCsvRows: 0
})
const emit = defineEmits<{
'update:modelValue': [value: string[]]
'uploading-change': [value: boolean]
change: [value: string[]]
'files-change': [value: RefundAttachment[]]
}>()
const rootRef = ref<HTMLElement>()
const uploadRef = ref<UploadInstance>()
const uploadingCount = ref(0)
const voucherFileKeyMap = new Map<number, string>()
const voucherFileMetadataMap = new Map<number, RefundAttachment>()
const removedUploadUids = new Set<number>()
const fileObjectUrlMap = new Map<number, string>()
const selectedUploadUids = new Set<number>()
@@ -95,6 +112,10 @@
emit('change', keys)
}
const emitFileMetadata = () => {
emit('files-change', Array.from(voucherFileMetadataMap.values()))
}
const setUploadingCount = (count: number) => {
uploadingCount.value = Math.max(0, count)
emit('uploading-change', uploadingCount.value > 0)
@@ -104,12 +125,14 @@
activeUploadBatch += 1
setUploadingCount(0)
voucherFileKeyMap.clear()
voucherFileMetadataMap.clear()
removedUploadUids.clear()
selectedUploadUids.clear()
revokeAllFileObjectUrls()
uploadRef.value?.clearFiles()
if (emitValue) {
emitVoucherKeys()
emitFileMetadata()
}
}
@@ -171,6 +194,44 @@
const file = uploadFile.raw
if (!file) return
if (props.maxSizeMb > 0 && file.size > props.maxSizeMb * 1024 * 1024) {
ElMessage.warning(`文件不能超过 ${props.maxSizeMb}MB`)
removeUploadFile(uploadFile)
return
}
if (props.singleColumnCsv) {
const content = await file.text()
const rows = content
.replace(/^\uFEFF/, '')
.split(/\r?\n/)
.filter(Boolean)
if (props.maxCsvRows > 0 && Math.max(rows.length - 1, 0) > props.maxCsvRows) {
ElMessage.warning(`CSV 数据行不能超过 ${props.maxCsvRows}`)
removeUploadFile(uploadFile)
return
}
if (rows.some((row) => row.includes(','))) {
ElMessage.warning('CSV 只能包含一列资产标识')
removeUploadFile(uploadFile)
return
}
}
if (props.accept) {
const accepted = props.accept.split(',').some((type) => {
const value = type.trim().toLowerCase()
return value.startsWith('.')
? file.name.toLowerCase().endsWith(value)
: file.type.toLowerCase() === value
})
if (!accepted) {
ElMessage.warning(`只能上传 ${props.accept} 格式的文件`)
removeUploadFile(uploadFile)
return
}
}
if (!selectedUploadUids.has(uploadFile.uid)) {
if (selectedUploadUids.size >= getMaxCount()) {
showMaxCountWarning()
@@ -192,7 +253,7 @@
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: contentType,
purpose: 'attachment'
purpose: props.purpose
})
if (uploadUrlRes.code !== 0) {
@@ -209,7 +270,13 @@
}
voucherFileKeyMap.set(uploadFile.uid, file_key)
voucherFileMetadataMap.set(uploadFile.uid, {
file_key,
file_name: file.name,
file_size: file.size
})
emitVoucherKeys()
emitFileMetadata()
ElMessage.success('上传成功')
} catch (error: any) {
if (uploadBatch !== activeUploadBatch) return
@@ -217,7 +284,9 @@
console.error(`上传${props.voucherName}失败:`, error)
ElMessage.error(error?.message || '上传失败,请重试')
voucherFileKeyMap.delete(uploadFile.uid)
voucherFileMetadataMap.delete(uploadFile.uid)
emitVoucherKeys()
emitFileMetadata()
removeUploadFile(uploadFile)
} finally {
if (uploadBatch === activeUploadBatch) {
@@ -231,7 +300,9 @@
selectedUploadUids.delete(uploadFile.uid)
revokeFileObjectUrl(uploadFile.uid)
voucherFileKeyMap.delete(uploadFile.uid)
voucherFileMetadataMap.delete(uploadFile.uid)
emitVoucherKeys()
emitFileMetadata()
}
const focusUploadArea = () => {

View File

@@ -0,0 +1,162 @@
<template>
<div ref="chartRef" class="distribution-chart" role="img" :aria-label="ariaLabel"></div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
import type { EChartsOption } from 'echarts'
import { useSettingStore } from '@/store/modules/setting'
import { getCssVar } from '@/utils/ui'
export interface AuditDistributionItem {
code: string
name: string
count: number
}
const props = withDefaults(
defineProps<{
data: AuditDistributionItem[]
type?: 'donut' | 'bar'
title: string
}>(),
{ type: 'donut' }
)
const emit = defineEmits<{ select: [code: string] }>()
const settingStore = useSettingStore()
const chartRef = ref<HTMLElement>()
const ariaLabel = computed(() =>
props.data.length
? `${props.title}${props.data.map((item) => `${item.name} ${item.count}`).join('')}`
: `${props.title}:暂无数据`
)
let chart: echarts.ECharts | undefined
let resizeObserver: ResizeObserver | undefined
const colors = () => [
getCssVar('--el-color-primary'),
getCssVar('--el-color-success'),
getCssVar('--el-color-warning'),
getCssVar('--el-color-danger'),
getCssVar('--el-color-info'),
getCssVar('--el-color-primary-light-3')
]
const textColor = () => getCssVar('--el-text-color-regular') || '#606266'
const splitColor = () => getCssVar('--el-border-color-lighter') || '#ebeef5'
const options = (): EChartsOption => {
if (props.type === 'bar') {
return {
color: colors(),
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { top: 8, right: 72, bottom: 8, left: 8, containLabel: true },
xAxis: {
type: 'value',
minInterval: 1,
splitNumber: 4,
axisLabel: {
color: textColor(),
hideOverlap: true,
formatter: (value: number) => {
const absolute = Math.abs(value)
if (absolute >= 10000) {
const formatted = value / 10000
return `${Number.isInteger(formatted) ? formatted : formatted.toFixed(1)}`
}
if (absolute >= 1000) {
const formatted = value / 1000
return `${Number.isInteger(formatted) ? formatted : formatted.toFixed(1)}k`
}
return String(value)
}
},
splitLine: { lineStyle: { color: splitColor(), type: 'dashed' } }
},
yAxis: {
type: 'category',
data: props.data.map((item) => item.name),
inverse: true,
axisLabel: { color: textColor(), overflow: 'truncate', width: 90 },
axisTick: { show: false },
axisLine: { show: false }
},
series: [
{
type: 'bar',
data: props.data.map((item) => ({
value: item.count,
code: item.code,
itemStyle: {
color: getCssVar('--el-color-primary'),
borderRadius: [0, 4, 4, 0]
}
})),
barMaxWidth: 24,
label: { show: true, position: 'right', color: textColor() }
}
]
}
}
return {
color: colors(),
tooltip: { trigger: 'item', formatter: '{b}<br/>{c}{d}%' },
legend: {
type: 'scroll',
bottom: 0,
left: 'center',
textStyle: { color: textColor() }
},
series: [
{
type: 'pie',
radius: ['42%', '68%'],
center: ['50%', '43%'],
avoidLabelOverlap: true,
itemStyle: { borderColor: getCssVar('--el-bg-color'), borderWidth: 2 },
label: { show: false },
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } },
data: props.data.map((item) => ({
value: item.count,
name: item.name,
code: item.code
}))
}
]
}
}
const render = async () => {
await nextTick()
if (!chartRef.value) return
if (!chart) {
chart = echarts.init(chartRef.value)
chart.on('click', (params) => {
const code = (params.data as { code?: string } | undefined)?.code
if (code) emit('select', code)
})
}
chart.setOption(options(), true)
}
watch(() => props.data, render, { deep: true })
watch(() => settingStore.isDark, render)
onMounted(() => {
render()
if (chartRef.value) {
resizeObserver = new ResizeObserver(() => chart?.resize())
resizeObserver.observe(chartRef.value)
}
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
chart?.dispose()
chart = undefined
})
</script>
<style scoped>
.distribution-chart {
width: 100%;
height: 260px;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,58 @@
<template>
<ArtTable
:data="items"
:loading="Boolean(loading)"
:pagination="false"
row-key="event_id"
stripe
:margin-top="0"
>
<template #default>
<ElTableColumn label="发生时间" prop="occurred_at" width="180">
<template #default="{ row }">{{ formatDateTime(row.occurred_at) }}</template>
</ElTableColumn>
<ElTableColumn label="动作" min-width="180">
<template #default="{ row }">
<ElButton v-if="detailEnabled" link type="primary" @click="$emit('detail', row)">
{{ row.action_name || '-' }}
</ElButton>
<span v-else>{{ row.action_name || '-' }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="摘要" prop="summary" min-width="240" show-overflow-tooltip />
<ElTableColumn label="操作者" min-width="150">
<template #default="{ row }">{{ row.actor_name || row.actor_id || '-' }}</template>
</ElTableColumn>
<ElTableColumn label="结果" width="100" align="center">
<template #default="{ row }"
><ElTag :type="resultMeta(row).type">{{ resultMeta(row).label }}</ElTag></template
>
</ElTableColumn>
<ElTableColumn label="风险" width="90" align="center">
<template #default="{ row }"
><ElTag effect="plain" :type="riskMeta(row).type">{{
riskMeta(row).label
}}</ElTag></template
>
</ElTableColumn>
</template>
</ArtTable>
</template>
<script setup lang="ts">
import type { AuditEventView } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { auditResultMeta, auditRiskMeta } from '@/utils/business/audit'
withDefaults(
defineProps<{ items: AuditEventView[]; loading?: boolean; detailEnabled?: boolean }>(),
{
detailEnabled: true
}
)
defineEmits<{ detail: [row: AuditEventView] }>()
const resultMeta = (row: AuditEventView) =>
auditResultMeta[row.result] || { label: row.result, type: 'info' as const }
const riskMeta = (row: AuditEventView) =>
auditRiskMeta[row.risk_level] || { label: row.risk_level, type: 'info' as const }
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
<template>
<AuditInvestigationDrawer
v-model="auditInvestigationVisible"
:target="auditInvestigationTarget"
/>
<AuditResourceSearchDialog
v-model="auditResourceSearchVisible"
:resource-type="auditResourceSearchType"
:initial-keyword="auditResourceSearchKeyword"
/>
</template>
<script setup lang="ts">
import AuditInvestigationDrawer from './AuditInvestigationDrawer.vue'
import AuditResourceSearchDialog from './AuditResourceSearchDialog.vue'
import {
auditInvestigationTarget,
auditInvestigationVisible,
auditResourceSearchKeyword,
auditResourceSearchType,
auditResourceSearchVisible
} from './investigationController'
</script>

View File

@@ -0,0 +1,160 @@
<template>
<div v-if="hasLinks" class="investigation-links" aria-label="调查入口">
<ElButton v-if="actorRef" link type="primary" @click="openActor"> 操作者行为时间线 </ElButton>
<ElButton
v-show="showResources"
v-for="resource in stableResourceRefs"
:key="`${resource.resource_type}:${resource.resource_id}`"
link
type="primary"
:title="resourceTimelineLabel(resource)"
:aria-label="resourceTimelineLabel(resource)"
@click="openResource(resource)"
>
{{ resourceTimelineLabel(resource) }}
</ElButton>
<ElButton
v-show="showResources"
v-for="resource in searchableResourceRefs"
:key="`${resource.resource_type}:${resource.resource_key}`"
link
type="primary"
@click="searchResource(resource)"
>
精确注册资源
</ElButton>
<ElButton v-if="requestId" link type="primary" @click="openTimeline('request', requestId)">
请求链路
</ElButton>
<ElButton
v-if="correlationId"
link
type="primary"
@click="openTimeline('correlation', correlationId)"
>
业务关联链路
</ElButton>
<ElButton
v-for="item in visibleIntegrationRefs"
:key="item.integration_id"
link
type="primary"
@click="openIntegration(item.integration_id)"
>
外部交互
</ElButton>
</div>
<span v-else-if="showEmpty" class="muted">无可靠调查引用</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type {
AuditInvestigationRefs,
AuditInvestigationResourceRef,
AuditSearchResourceType
} from '@/types/api'
import { openAuditInvestigation, openAuditResourceSearch } from './investigationController'
import { auditResourceTypeLabels } from '@/utils/business/audit'
import { resolveInvestigationReference } from '@/utils/business/auditNavigation'
import { useAuth } from '@/composables/useAuth'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
const props = withDefaults(
defineProps<{
refs?: AuditInvestigationRefs | null
showEmpty?: boolean
showResources?: boolean
}>(),
{ showEmpty: true, showResources: true }
)
const searchableTypes = new Set<AuditSearchResourceType>([
'iot_card',
'device',
'shop',
'order',
'refund'
])
const { hasAuth } = useAuth()
const requestId = computed(() => resolveInvestigationReference(props.refs?.request_id) || '')
const correlationId = computed(
() => resolveInvestigationReference(props.refs?.correlation_id) || ''
)
const actorRef = computed(() => {
const actor = props.refs?.actor_ref
return actor?.kind && actor.id ? actor : null
})
const stableResourceRefs = computed(() =>
(props.refs?.resource_refs || []).filter(
(resource): resource is AuditInvestigationResourceRef & { resource_id: string } =>
Boolean(resource.resource_type && resource.resource_id)
)
)
const searchableResourceRefs = computed(() =>
(props.refs?.resource_refs || []).filter(
(
resource
): resource is AuditInvestigationResourceRef & {
resource_type: AuditSearchResourceType
resource_key: string
} =>
!resource.resource_id &&
Boolean(resource.resource_key) &&
searchableTypes.has(resource.resource_type as AuditSearchResourceType)
)
)
const stableIntegrationRefs = computed(() =>
(props.refs?.integration_refs || []).filter((item) => Boolean(item.integration_id))
)
const visibleIntegrationRefs = computed(() =>
hasAuth(AUDIT_PERMISSIONS.auditEventIntegrationDetail) ? stableIntegrationRefs.value : []
)
const hasLinks = computed(() =>
Boolean(
actorRef.value ||
requestId.value ||
correlationId.value ||
(props.showResources &&
(stableResourceRefs.value.length || searchableResourceRefs.value.length)) ||
visibleIntegrationRefs.value.length
)
)
const openIntegration = (id: string) => openAuditInvestigation({ mode: 'integration', id })
const openTimeline = (mode: 'request' | 'correlation', id: string) =>
openAuditInvestigation({ mode, id })
const openActor = () => {
const actor = actorRef.value
if (actor) {
openAuditInvestigation({ mode: 'actor', actorKind: actor.kind, id: actor.id })
}
}
const openResource = (resource: AuditInvestigationResourceRef & { resource_id: string }) =>
openAuditInvestigation({
mode: 'resource',
resourceType: resource.resource_type,
id: resource.resource_id
})
const resourceTimelineLabel = (
resource: AuditInvestigationResourceRef & { resource_id: string }
) =>
`资源审计时间线 · ${auditResourceTypeLabels[resource.resource_type] || resource.resource_type} · ${resource.resource_id}`
const searchResource = (
resource: AuditInvestigationResourceRef & {
resource_type: AuditSearchResourceType
resource_key: string
}
) => openAuditResourceSearch(resource.resource_type, resource.resource_key)
</script>
<style scoped>
.investigation-links {
display: flex;
flex-wrap: wrap;
gap: 2px 8px;
}
.muted {
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,269 @@
<template>
<ElDialog
v-model="visible"
title="精确注册资源"
width="min(880px, 92vw)"
append-to-body
destroy-on-close
lock-scroll
>
<div v-loading="loading" class="result-list">
<ElCard
v-for="row in items"
:key="`${row.resource_type}:${row.resource_id || row.resource_key}`"
shadow="never"
class="resource-card"
>
<template #header>
<div class="card-header">
<strong>业务信息</strong>
<ElButton link type="primary" :disabled="!row.resource_id" @click="openTimeline(row)">
<template #icon>
<ElIcon><Clock /></ElIcon>
</template>
审计时间线
</ElButton>
</div>
</template>
<div v-if="snapshotDetails(row).length" class="snapshot-grid">
<div v-for="item in snapshotDetails(row)" :key="item.label" class="snapshot-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<div v-else class="snapshot-item">
<span>业务标识</span>
<strong>{{ row.resource_key || '-' }}</strong>
</div>
</ElCard>
<ElEmpty v-if="!loading && !items.length" description="未找到匹配的注册资源" />
</div>
<div v-if="total > pageSize" class="pagination">
<ElPagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="search(false)"
/>
</div>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Clock } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { AuditService } from '@/api/modules'
import type { AuditResourceCandidate, AuditSearchResourceType } from '@/types/api'
import { openAuditInvestigation } from './investigationController'
const props = defineProps<{
modelValue: boolean
resourceType: AuditSearchResourceType
initialKeyword?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const keyword = ref('')
const loading = ref(false)
const page = ref(1)
const pageSize = 10
const total = ref(0)
const items = ref<AuditResourceCandidate[]>([])
let searchSequence = 0
const resourceTypeLabels: Record<AuditSearchResourceType, string> = {
iot_card: 'IoT卡',
device: '设备',
shop: '店铺',
order: '订单',
refund: '退款单'
}
const resourceTypeLabel = computed(() => resourceTypeLabels[props.resourceType])
interface SnapshotField {
label: string
keys: string[]
format?: (value: unknown) => string
}
const textValue = (value: unknown) => String(value ?? '-')
const refundStatusValue = (value: unknown) =>
({ 1: '待审批', 2: '已通过', 3: '已拒绝', 4: '已退回' })[Number(value)] || textValue(value)
const centAmountValue = (value: unknown) => {
const amount = Number(value)
return Number.isFinite(amount) ? `¥${(amount / 100).toFixed(2)}` : textValue(value)
}
const snapshotFields: Record<AuditSearchResourceType, SnapshotField[]> = {
iot_card: [
{ label: 'ICCID', keys: ['iccid', 'iccid_19', 'iccid_20'] },
{ label: '虚拟号', keys: ['virtual_no'] },
{ label: 'MSISDN', keys: ['msisdn'] },
{ label: '运营商', keys: ['carrier_name', 'carrier_type'] }
],
device: [
{ label: '虚拟号', keys: ['virtual_no'] },
{ label: 'IMEI', keys: ['imei'] },
{ label: 'SN', keys: ['sn', 'serial_no'] },
{ label: '设备编号', keys: ['device_no'] }
],
shop: [
{ label: '店铺编号', keys: ['shop_code'] },
{ label: '店铺名称', keys: ['shop_name', 'name'] },
{ label: '联系人', keys: ['contact_name'] },
{ label: '联系电话', keys: ['contact_phone', 'phone'] }
],
order: [
{ label: '订单号', keys: ['order_no'] },
{ label: '资产编号', keys: ['asset_identifier'] },
{ label: '订单状态', keys: ['status_name'] },
{ label: '订单金额', keys: ['total_amount', 'order_amount'], format: centAmountValue }
],
refund: [
{ label: '退款单号', keys: ['refund_no'] },
{ label: '订单号', keys: ['order_no'] },
{ label: '资产编号', keys: ['asset_identifier'] },
{
label: '申请退款金额',
keys: ['requested_refund_amount'],
format: centAmountValue
},
{ label: '状态', keys: ['status_name', 'status'], format: refundStatusValue }
]
}
const snapshotDetails = (row: AuditResourceCandidate) => {
const snapshot = row.identity_snapshot || {}
return snapshotFields[row.resource_type].flatMap((field) => {
const key = field.keys.find((candidate) => {
const value = snapshot[candidate]
return value !== undefined && value !== null && value !== ''
})
if (!key) return []
const value = snapshot[key]
return [{ label: field.label, value: field.format?.(value) || textValue(value) }]
})
}
const search = async (resetPage: boolean) => {
const exactKeyword = keyword.value.trim()
if (!exactKeyword) {
ElMessage.warning(`当前记录缺少可用于搜索${resourceTypeLabel.value}的业务标识`)
return
}
if (resetPage) page.value = 1
const sequence = ++searchSequence
loading.value = true
try {
const data = (
await AuditService.searchResources({
resource_type: props.resourceType,
keyword: exactKeyword,
page: page.value,
page_size: pageSize
})
).data
if (sequence === searchSequence) {
items.value = data.items || []
total.value = data.total
}
} catch {
if (sequence === searchSequence) ElMessage.error('注册资源搜索失败')
} finally {
if (sequence === searchSequence) loading.value = false
}
}
const openTimeline = (row: AuditResourceCandidate) => {
if (!row.resource_id) return
visible.value = false
openAuditInvestigation({
mode: 'resource',
resourceType: row.resource_type,
id: row.resource_id
})
}
watch(
() => props.modelValue,
(open) => {
if (!open) {
searchSequence++
loading.value = false
items.value = []
total.value = 0
return
}
keyword.value = props.initialKeyword?.trim() || ''
page.value = 1
items.value = []
total.value = 0
if (keyword.value) void search(true)
}
)
</script>
<style scoped lang="scss">
.result-list {
min-height: 180px;
}
.resource-card + .resource-card {
margin-top: 12px;
}
.card-header {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.snapshot-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.snapshot-item {
min-width: 0;
padding: 12px 14px;
background: var(--el-fill-color-light);
border-radius: 6px;
span,
strong {
display: block;
overflow-wrap: anywhere;
}
span {
margin-bottom: 6px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
strong {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
}
@media (width <= 600px) {
.snapshot-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,25 @@
import { ref } from 'vue'
import type { AuditInvestigationTarget } from './types'
import type { AuditSearchResourceType } from '@/types/api'
export const auditInvestigationVisible = ref(false)
export const auditInvestigationTarget = ref<AuditInvestigationTarget>()
export const auditResourceSearchVisible = ref(false)
export const auditResourceSearchType = ref<AuditSearchResourceType>('iot_card')
export const auditResourceSearchKeyword = ref('')
export const openAuditInvestigation = (target: AuditInvestigationTarget) => {
auditResourceSearchVisible.value = false
auditInvestigationTarget.value = target
auditInvestigationVisible.value = true
}
export const openAuditResourceSearch = (
resourceType: AuditSearchResourceType,
keyword?: string | null
) => {
auditInvestigationVisible.value = false
auditResourceSearchType.value = resourceType
auditResourceSearchKeyword.value = keyword?.trim() || ''
auditResourceSearchVisible.value = true
}

View File

@@ -0,0 +1,24 @@
import type { AuditActorKind, AuditSubjectResourceType } from '@/types/api'
export type AuditFinanceField =
| 'shop_id'
| 'wallet_id'
| 'order_id'
| 'order_no'
| 'payment_id'
| 'payment_no'
| 'refund_id'
| 'refund_no'
| 'recharge_id'
| 'recharge_no'
| 'approval_instance_id'
| 'third_party_trade_no'
| 'correlation_id'
export type AuditInvestigationTarget =
| { mode: 'event'; id: string }
| { mode: 'actor'; id: string; actorKind: AuditActorKind }
| { mode: 'resource'; id: string; resourceType: string }
| { mode: 'agent' | 'enterprise'; id: string; resourceType: AuditSubjectResourceType }
| { mode: 'finance'; field: AuditFinanceField; value: string | number }
| { mode: 'request' | 'correlation' | 'integration'; id: string }

View File

@@ -39,10 +39,10 @@
})
// 合并默认配置和自定义配置
const config = reactive({
const config = computed(() => ({
placeholder: `${t('table.searchBar.searchSelectPlaceholder')}${prop.item.label}`,
...(prop.item.config || {})
})
}))
// 选择框值变化处理函数
const changeValue = (val: unknown): void => {

View File

@@ -65,12 +65,12 @@
</div>
</div>
<!-- 通知 -->
<!--<div class="btn-box notice-btn" @click="visibleNotice">-->
<!-- <div class="btn notice-button">-->
<!-- <i class="iconfont-sys notice-btn">&#xe6c2;</i>-->
<!-- <span class="count notice-btn"></span>-->
<!-- </div>-->
<!--</div>-->
<div class="btn-box notice-btn" @click.stop="showNotice = !showNotice">
<div class="btn notice-button">
<i class="iconfont-sys">&#xe6c2;</i>
<span v-if="unreadCount > 0" class="notice-count">{{ formattedUnreadCount }}</span>
</div>
</div>
<!-- 聊天 -->
<!--<div class="btn-box chat-btn" @click="openChat">-->
<!-- <div class="btn chat-button">-->
@@ -175,11 +175,12 @@
</div>
<ArtWorkTab />
<art-notification v-model:value="showNotice" ref="notice" />
<art-notification v-model="showNotice" />
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { MenuTypeEnum, MenuWidth } from '@/enums/appEnum'
import { useSettingStore } from '@/store/modules/setting'
import { useUserStore } from '@/store/modules/user'
@@ -189,12 +190,14 @@
import { useI18n } from 'vue-i18n'
import { mittBus } from '@/utils/sys'
import { useMenuStore } from '@/store/modules/menu'
import { useNotificationStore } from '@/store/modules/notification'
import AppConfig from '@/config'
const isWindows = navigator.userAgent.includes('Windows')
const { locale } = useI18n()
const settingStore = useSettingStore()
const userStore = useUserStore()
const notificationStore = useNotificationStore()
const router = useRouter()
const {
@@ -214,7 +217,6 @@
const { menuList } = storeToRefs(useMenuStore())
const showNotice = ref(false)
const notice = ref(null)
const userMenuPopover = ref()
const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT)
@@ -229,6 +231,9 @@
const { width } = useWindowSize()
const unreadCount = computed(() => notificationStore.unreadCount)
const formattedUnreadCount = computed(() => notificationStore.displayCount)
const menuTopWidth = computed(() => {
return width.value * 0.5
})
@@ -251,11 +256,16 @@
onMounted(() => {
initLanguage()
document.addEventListener('click', bodyCloseNotice)
void notificationStore.refreshUnreadCount().catch(() => undefined)
unreadTimer = window.setInterval(() => {
void notificationStore.refreshUnreadCount().catch(() => undefined)
}, 30_000)
})
onUnmounted(() => {
document.removeEventListener('click', bodyCloseNotice)
let unreadTimer: number | undefined
onBeforeUnmount(() => {
if (unreadTimer !== undefined) window.clearInterval(unreadTimer)
})
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
@@ -334,24 +344,6 @@
mittBus.emit('openSearchDialog')
}
const bodyCloseNotice = (e: any) => {
let { className } = e.target
if (showNotice.value) {
if (typeof className === 'object') {
showNotice.value = false
return
}
if (className.indexOf('notice-btn') === -1) {
showNotice.value = false
}
}
}
const openChat = () => {
mittBus.emit('openChat')
}
const lockScreen = () => {
mittBus.emit('openLockScreen')
}

View File

@@ -200,6 +200,10 @@
}
}
&.notice-button {
position: relative;
}
&.chat-button:hover {
i {
animation: shake 0.5s ease-in-out;
@@ -411,6 +415,21 @@
}
}
}
.notice-count {
position: absolute;
top: 2px;
right: 0;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 10px;
line-height: 16px;
color: #fff;
text-align: center;
background: var(--el-color-danger);
border-radius: 10px;
}
}
@keyframes rotate180 {

View File

@@ -1,276 +1,181 @@
<template>
<div
class="notice"
v-show="visible"
:style="{
transform: show ? 'scaleY(1)' : 'scaleY(0.9)',
opacity: show ? 1 : 0
}"
@click.stop=""
>
<div class="header">
<span class="text">{{ $t('notice.title') }}</span>
<span class="btn">{{ $t('notice.btnRead') }}</span>
</div>
<ul class="bar">
<li
v-for="(item, index) in barList"
:key="index"
:class="{ active: barActiveIndex === index }"
@click="changeBar(index)"
>
{{ item.name }} ({{ item.num }})
</li>
</ul>
<div class="content">
<div class="scroll">
<!-- 通知 -->
<ul class="notice-list" v-show="barActiveIndex === 0">
<li v-for="(item, index) in noticeList" :key="index">
<div
class="icon"
:style="{ background: getNoticeStyle(item.type).backgroundColor + '!important' }"
>
<i
class="iconfont-sys"
:style="{ color: getNoticeStyle(item.type).iconColor + '!important' }"
v-html="getNoticeStyle(item.type).icon"
>
</i>
</div>
<div class="text">
<h4>{{ item.title }}</h4>
<p>{{ item.time }}</p>
</div>
</li>
</ul>
<!-- 消息 -->
<ul class="user-list" v-show="barActiveIndex === 1">
<li v-for="(item, index) in msgList" :key="index">
<div class="avatar">
<span>{{ item.title.slice(0, 1) }}</span>
</div>
<div class="text">
<h4>{{ item.title }}</h4>
<p>{{ item.time }}</p>
</div>
</li>
</ul>
<!-- 待办 -->
<ul class="base" v-show="barActiveIndex === 3">
<li v-for="(item, index) in pendingList" :key="index">
<h4>{{ item.title }}</h4>
<p>{{ item.time }}</p>
</li>
</ul>
<div v-if="visible" class="notice-overlay" @click="close">
<div class="notice" @click.stop>
<div class="header">
<span class="text">通知</span>
<button class="read-all" type="button" @click="handleMarkAllRead">全部已读</button>
</div>
<div class="empty-tips" v-show="barActiveIndex === 0 && noticeList.length === 0">
<i class="iconfont-sys">&#xe8d7;</i>
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
<div class="bar">
<button
v-for="tab in tabs"
:key="tab.value"
type="button"
:class="{ active: activeCategory === tab.value }"
@click="handleCategoryChange(tab.value)"
>
{{ tab.label }}<span v-if="tab.count"> ({{ tab.count }})</span>
</button>
</div>
<div class="content">
<div ref="notificationListRef" v-loading="notificationStore.loading" class="scroll">
<button
v-for="item in filteredItems"
:key="item.id"
type="button"
:class="['notification-item', { unread: !isRead(item) }]"
@click="handleNotificationClick(item)"
>
<span :class="['icon', `severity-${item.severity || 'info'}`]">
<i class="iconfont-sys">&#xe6c2;</i>
</span>
<span class="text">
<strong>{{ item.title }}</strong>
<small>{{ item.body }}</small>
<time>{{ formatDateTime(item.created_at) }}</time>
</span>
<span v-if="!isRead(item)" class="unread-dot" />
</button>
<div v-if="!filteredItems.length" class="empty-tips">
<i class="iconfont-sys">&#xe8d7;</i>
<p>暂无通知</p>
</div>
</div>
<div class="empty-tips" v-show="barActiveIndex === 1 && msgList.length === 0">
<i class="iconfont-sys">&#xe8d7;</i>
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
</div>
<div class="empty-tips" v-show="barActiveIndex === 2 && pendingList.length === 0">
<i class="iconfont-sys">&#xe8d7;</i>
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
<div
v-if="notificationStore.notificationTotal > notificationStore.notificationPageSize"
class="pagination"
>
<ElPagination
:current-page="notificationStore.notificationPage"
:page-size="notificationStore.notificationPageSize"
:total="notificationStore.notificationTotal"
:pager-count="5"
background
layout="prev, pager, next"
small
@current-change="handlePageChange"
/>
</div>
</div>
<div class="btn-wrapper">
<el-button class="view-all" @click="handleViewAll" v-ripple>
{{ $t('notice.viewAll') }}
</el-button>
</div>
</div>
<div style="height: 100px"></div>
</div>
</template>
<script setup lang="ts">
import AppConfig from '@/config'
import { useI18n } from 'vue-i18n'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { NotificationService } from '@/api/modules'
import type { NotificationCategory, NotificationItem } from '@/types/api'
import { useNotificationStore } from '@/store/modules/notification'
import { navigateNotificationTarget } from '@/utils/business/notificationNavigation'
import { formatDateTime } from '@/utils/business/format'
const { t } = useI18n()
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const props = defineProps({
value: {
type: Boolean,
default: false
}
const router = useRouter()
const notificationStore = useNotificationStore()
const activeCategory = ref<'all' | NotificationCategory>('all')
const notificationListRef = ref<HTMLElement | null>(null)
const visible = computed(() => props.modelValue)
const categoryMatches = (item: NotificationItem, category: string) => {
if (category === 'all') return true
if (category === 'system') return item.category === 'sync' || item.category === 'system'
if (category === 'expiry') return item.category === 'expiry' || item.category === 'expiring'
return item.category === category
}
const isRead = (item: NotificationItem) => item.is_read
const filteredItems = computed(() =>
notificationStore.recentNotifications.filter((item) =>
categoryMatches(item, activeCategory.value)
)
)
const tabs = computed(() => {
return [
{ value: 'all', label: '全部', count: notificationStore.summary.total },
{ value: 'approval', label: '审批', count: notificationStore.summary.approval },
{ value: 'expiry', label: '临期', count: notificationStore.summary.expiry },
{
value: 'system',
label: '同步/系统',
count: notificationStore.summary.sync + notificationStore.summary.system
}
]
})
const close = () => emit('update:modelValue', false)
const loadNotificationPage = async (page: number) => {
try {
await notificationStore.loadNotifications(page)
notificationListRef.value?.scrollTo({ top: 0 })
} catch (error: any) {
ElMessage.error(error?.message || '获取通知失败')
}
}
const handleCategoryChange = (category: 'all' | NotificationCategory) => {
activeCategory.value = category
void loadNotificationPage(1)
}
const handlePageChange = (page: number) => {
void loadNotificationPage(page)
}
const handleMarkAllRead = async () => {
try {
const response = await notificationStore.markAllRead()
if (response.code !== 0) ElMessage.error(response.msg || '全部已读失败')
} catch (error: any) {
ElMessage.error(error?.message || '全部已读失败')
}
}
const handleNotificationClick = async (item: NotificationItem) => {
try {
const targetResponse = await NotificationService.getTarget(item.id)
const readResponse = await notificationStore.markRead(item.id)
if (readResponse.code !== 0) {
ElMessage.error(readResponse.msg || '标记已读失败')
return
}
if (targetResponse.code !== 0 || !targetResponse.data) {
ElMessage.info(item.body || '该通知暂无可跳转目标')
return
}
if (!navigateNotificationTarget(router, item.ref_type, targetResponse.data)) {
ElMessage.info(item.body || '该通知暂无可跳转目标')
} else {
close()
}
} catch (error: any) {
ElMessage.error(error?.message || '处理通知失败')
}
}
watch(
() => props.value,
() => {
showNotice(props.value)
() => props.modelValue,
(open) => {
if (open) {
activeCategory.value = 'all'
void notificationStore.refreshSummary().catch((error) => {
ElMessage.error(error?.message || '获取通知失败')
})
}
}
)
const show = ref(false)
const visible = ref(false)
const barActiveIndex = ref(0)
const pendingList: any = []
const barList = ref([
{
name: computed(() => t('notice.bar[0]')),
num: 1
},
{
name: computed(() => t('notice.bar[1]')),
num: 1
},
{
name: computed(() => t('notice.bar[2]')),
num: 0
}
])
const noticeList = [
{
title: '新增国际化',
time: '2024-6-13 0:10',
type: 'notice'
},
{
title: '冷月呆呆给你发了一条消息',
time: '2024-4-21 8:05',
type: 'message'
},
{
title: '小肥猪关注了你',
time: '2020-3-17 21:12',
type: 'collection'
},
{
title: '新增使用文档',
time: '2024-02-14 0:20',
type: 'notice'
},
{
title: '小肥猪给你发了一封邮件',
time: '2024-1-20 0:15',
type: 'email'
},
{
title: '菜单mock本地真实数据',
time: '2024-1-17 22:06',
type: 'notice'
}
]
const msgList: any = [
{
title: '池不胖 关注了你',
time: '2021-2-26 23:50'
},
{
title: '唐不苦 关注了你',
time: '2021-2-21 8:05'
},
{
title: '中小鱼 关注了你',
time: '2020-1-17 21:12'
},
{
title: '何小荷 关注了你',
time: '2021-01-14 0:20'
},
{
title: '誶誶淰 关注了你',
time: '2020-12-20 0:15'
},
{
title: '冷月呆呆 关注了你',
time: '2020-12-17 22:06'
}
]
const changeBar = (index: number) => {
barActiveIndex.value = index
}
const getRandomColor = () => {
const index = Math.floor(Math.random() * AppConfig.systemMainColor.length)
return AppConfig.systemMainColor[index]
}
const noticeStyleMap = {
email: {
icon: '&#xe72e;',
iconColor: 'rgb(var(--art-warning))',
backgroundColor: 'rgb(var(--art-bg-warning))'
},
message: {
icon: '&#xe747;',
iconColor: 'rgb(var(--art-success))',
backgroundColor: 'rgb(var(--art-bg-success))'
},
collection: {
icon: '&#xe714;',
iconColor: 'rgb(var(--art-danger))',
backgroundColor: 'rgb(var(--art-bg-danger))'
},
user: {
icon: '&#xe608;',
iconColor: 'rgb(var(--art-info))',
backgroundColor: 'rgb(var(--art-bg-info))'
},
notice: {
icon: '&#xe6c2;',
iconColor: 'rgb(var(--art-primary))',
backgroundColor: 'rgb(var(--art-bg-primary))'
}
}
const getNoticeStyle = (type: string) => {
const defaultStyle = {
icon: '&#xe747;',
iconColor: '#FFFFFF',
backgroundColor: getRandomColor()
}
const style = noticeStyleMap[type as keyof typeof noticeStyleMap] || defaultStyle
return {
...style,
backgroundColor: style.backgroundColor
}
}
const showNotice = (open: boolean) => {
if (open) {
visible.value = open
setTimeout(() => {
show.value = open
}, 5)
} else {
show.value = open
setTimeout(() => {
visible.value = open
}, 350)
}
}
// 查看全部
const handleViewAll = () => {
switch (barActiveIndex.value) {
case 0:
handleNoticeAll()
break
case 1:
handleMsgAll()
break
case 2:
handlePendingAll()
break
}
}
const handleNoticeAll = () => {}
const handleMsgAll = () => {}
const handlePendingAll = () => {}
onMounted(() => {
void notificationStore.refreshUnreadCount().catch(() => undefined)
})
</script>
<style lang="scss" scoped>

View File

@@ -1,262 +1,182 @@
@use '@styles/variables.scss' as *;
@use '@styles/mixin.scss' as *;
.notice-overlay {
position: fixed;
inset: 0;
z-index: 2000;
}
.notice {
position: absolute;
top: 60px;
right: 20px;
width: 360px;
height: 500px;
overflow: hidden;
right: 18px;
width: 390px;
max-width: calc(100vw - 24px);
background: var(--art-main-bg-color);
border: 1px solid var(--art-border-color);
border-radius: calc(var(--custom-radius) / 2 + 6px) !important;
box-shadow:
0 8px 26px -4px hsl(0deg 0% 8% / 15%),
0 8px 9px -5px hsl(0deg 0% 8% / 6%);
transition: all 0.2s;
transform-origin: center top 0;
will-change: top, left;
border-radius: calc(var(--custom-radius) / 2 + 6px);
box-shadow: 0 8px 26px -4px hsl(0deg 0% 8% / 15%);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 15px;
margin-top: 15px;
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--art-border-color);
span {
font-size: 12px;
}
.text {
font-size: 16px;
font-weight: 500;
color: var(--art-gray-800);
}
.btn {
padding: 4px 6px;
cursor: pointer;
user-select: none;
border-radius: 6px;
&:hover {
background-color: var(--art-gray-200);
}
}
.text {
font-size: 16px;
font-weight: 600;
}
.bar {
box-sizing: border-box;
display: flex;
width: 100%;
height: 50px;
padding: 0 15px;
line-height: 50px;
border-bottom: 1px solid var(--art-border-color);
li {
height: 48px;
margin-right: 20px;
overflow: hidden;
font-size: 13px;
color: var(--art-gray-700);
cursor: pointer;
transition: color 0.3s;
@include userSelect;
&:last-of-type {
margin-right: 0;
}
&:hover {
color: var(--art-gray-900);
}
&.active {
color: var(--main-color) !important;
border-bottom: 2px solid var(--main-color);
}
}
.read-all {
padding: 4px 8px;
color: var(--el-color-primary);
cursor: pointer;
background: transparent;
border: 0;
}
}
.content {
width: 100%;
height: calc(100% - 95px);
.bar {
display: flex;
padding: 0 12px;
overflow-x: auto;
border-bottom: 1px solid var(--art-border-color);
.scroll {
height: calc(100% - 60px);
overflow-y: scroll;
button {
flex: 0 0 auto;
padding: 13px 8px 11px;
margin-right: 12px;
color: var(--art-gray-700);
cursor: pointer;
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
&::-webkit-scrollbar {
width: 5px !important;
}
.notice-list {
li {
box-sizing: border-box;
display: flex;
align-items: center;
padding: 15px;
cursor: pointer;
&:hover {
background-color: var(--art-gray-100);
}
&:last-of-type {
border-bottom: 0;
}
.icon {
width: 36px;
height: 36px;
line-height: 36px;
text-align: center;
border-radius: 8px;
i {
font-size: 18px;
background: transparent !important;
}
}
.text {
width: calc(100% - 45px);
margin-left: 15px;
h4 {
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--art-gray-900);
}
p {
margin-top: 5px;
font-size: 12px;
color: var(--art-gray-500);
}
}
}
}
.user-list {
li {
box-sizing: border-box;
display: flex;
align-items: center;
padding: 15px;
cursor: pointer;
&:hover {
background-color: var(--art-gray-100);
}
&:last-of-type {
border-bottom: 0;
}
.avatar {
width: 36px;
height: 36px;
img {
width: 100%;
height: 100%;
border-radius: 8px;
}
}
.text {
width: calc(100% - 45px);
margin-left: 15px;
h4 {
font-size: 13px;
font-weight: 400;
line-height: 22px;
color: var(--art-gray-900);
}
p {
margin-top: 5px;
font-size: 12px;
color: var(--art-gray-500);
}
}
}
}
.base {
li {
box-sizing: border-box;
padding: 15px 20px;
&:last-of-type {
border-bottom: 0;
}
p {
font-size: 12px;
color: var(--art-gray-500);
}
}
}
.empty-tips {
position: relative;
top: 100px;
height: 100%;
color: var(--art-gray-500);
text-align: center;
background: transparent !important;
i {
font-size: 60px;
}
p {
margin-top: 15px;
font-size: 12px;
background: transparent !important;
}
}
}
.btn-wrapper {
position: relative;
box-sizing: border-box;
width: 100%;
padding: 0 15px;
.view-all {
width: 100%;
margin-top: 12px;
}
&.active {
color: var(--el-color-primary);
border-bottom-color: var(--el-color-primary);
}
}
}
.dark {
.notice {
::-webkit-scrollbar-track {
background-color: var(--art-main-bg-color);
.content {
.scroll {
height: 390px;
overflow-y: auto;
}
.pagination {
display: flex;
justify-content: center;
padding: 12px 8px;
overflow-x: auto;
border-top: 1px solid var(--art-border-color);
:deep(.el-pagination) {
justify-content: center;
}
}
.notification-item {
display: flex;
gap: 10px;
align-items: flex-start;
width: 100%;
padding: 14px 16px;
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
&:hover {
background: var(--el-fill-color-light);
}
::-webkit-scrollbar-thumb {
background-color: #222 !important;
&.unread {
background: color-mix(in srgb, var(--el-color-primary) 5%, transparent);
}
.icon {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: var(--el-color-info);
background: var(--el-color-info-light-9);
border-radius: 8px;
&.severity-warning {
color: var(--el-color-warning);
background: var(--el-color-warning-light-9);
}
&.severity-error {
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
}
&.severity-critical {
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
}
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-width: 0;
strong,
small,
time {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
strong {
color: var(--el-text-color-primary);
}
small,
time {
color: var(--el-text-color-secondary);
}
}
.unread-dot {
flex: 0 0 auto;
width: 7px;
height: 7px;
margin-top: 5px;
background: var(--el-color-danger);
border-radius: 50%;
}
}
.empty-tips {
padding: 70px 20px;
color: var(--el-text-color-secondary);
text-align: center;
i {
font-size: 42px;
}
}
}
@media only screen and (max-width: $device-phone) {
.notice {
top: 65px;
right: 0;
width: 100%;
height: 80vh;
top: 62px;
right: 12px;
left: 12px;
width: auto;
}
}

View File

@@ -1,6 +1,11 @@
<template>
<ElDialog v-model="visible" title="设置实名认证策略" width="35%">
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElDialog
v-model="visible"
title="设置实名认证策略"
width="40%"
modal-class="asset-information-dialog"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px">
<ElFormItem label="资产标识">
<span style="font-weight: bold; color: #409eff">{{ assetIdentifier }}</span>
</ElFormItem>
@@ -9,7 +14,7 @@
{{ getPolicyName(currentPolicy) }}
</ElTag>
</ElFormItem>
<ElFormItem label="实名认证策略" prop="realname_policy">
<ElFormItem label="认证策略" prop="realname_policy">
<ElRadioGroup v-model="form.realname_policy">
<ElRadio value="none">无需实名</ElRadio>
<ElRadio value="before_order">先实名后充值/购买</ElRadio>

View File

@@ -1,5 +1,11 @@
<template>
<ElDialog v-model="visible" title="切换SIM卡" width="600px" @close="handleClose">
<ElDialog
v-model="visible"
title="切换SIM卡"
width="40%"
modal-class="asset-information-dialog"
@close="handleClose"
>
<div v-if="loading" style="padding: 40px; text-align: center">
<ElIcon class="is-loading" :size="40"><Loading /></ElIcon>
<div style="margin-top: 16px">加载设备绑定的卡列表中...</div>
@@ -24,7 +30,7 @@
ref="formRef"
:model="formData"
:rules="rules"
label-width="120px"
label-width="90px"
>
<ElFormItem label="设备信息">
<span style="font-weight: bold; color: #409eff">{{ deviceInfo }}</span>

View File

@@ -0,0 +1,178 @@
import {
computed,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
type Ref,
type ShallowRef
} from 'vue'
import { isAsyncTaskActive, isAsyncTaskTerminal, type AsyncTaskProgress } from '@/types/api'
import { normalizeApiError } from '@/utils/business/apiError'
const POLL_DELAYS = [2000, 3000, 5000]
const MAX_POLL_DELAY = 10000
export interface AsyncTaskPollingOptions<T extends AsyncTaskProgress> {
storageKey: string
fetchTask: (taskId: number) => Promise<T>
isForbidden?: (error: unknown) => boolean
autoRestore?: boolean
}
export interface AsyncTaskPollingState<T extends AsyncTaskProgress> {
taskId: Ref<number | null>
task: ShallowRef<T | null>
loading: Ref<boolean>
forbidden: Ref<boolean>
error: Ref<string | null>
isActive: Readonly<Ref<boolean>>
start: (taskId: number) => Promise<void>
retry: () => Promise<void>
stop: () => void
clear: () => void
}
const readStoredTaskId = (storageKey: string): number | null => {
try {
const value = Number(localStorage.getItem(storageKey))
return Number.isInteger(value) && value > 0 ? value : null
} catch {
return null
}
}
const writeStoredTaskId = (storageKey: string, taskId: number | null) => {
try {
if (taskId) localStorage.setItem(storageKey, String(taskId))
else localStorage.removeItem(storageKey)
} catch {
// Storage may be unavailable in private browsing or restricted webviews.
}
}
const getErrorMessage = (error: unknown) => normalizeApiError(error).message
export function useAsyncTaskPolling<T extends AsyncTaskProgress>(
options: AsyncTaskPollingOptions<T>
): AsyncTaskPollingState<T> {
const taskId = ref<number | null>(readStoredTaskId(options.storageKey))
const task = shallowRef<T | null>(null)
const loading = ref(false)
const forbidden = ref(false)
const error = ref<string | null>(null)
const isActive = computed(() => isAsyncTaskActive(task.value?.status))
let timer: number | undefined
let pollIndex = 0
let requestInFlight = false
const clearTimer = () => {
if (timer !== undefined) {
window.clearTimeout(timer)
timer = undefined
}
}
const stop = () => {
clearTimer()
}
const clear = () => {
stop()
taskId.value = null
task.value = null
error.value = null
forbidden.value = false
writeStoredTaskId(options.storageKey, null)
}
const schedule = () => {
clearTimer()
if (document.hidden || !isActive.value || !taskId.value) return
const delay = POLL_DELAYS[pollIndex] ?? MAX_POLL_DELAY
pollIndex += 1
timer = window.setTimeout(() => {
timer = undefined
void refresh()
}, delay)
}
const refresh = async () => {
if (!taskId.value || requestInFlight || document.hidden) return
requestInFlight = true
loading.value = true
forbidden.value = false
error.value = null
try {
const nextTask = await options.fetchTask(taskId.value)
task.value = nextTask
if (isAsyncTaskTerminal(nextTask.status)) {
stop()
writeStoredTaskId(options.storageKey, null)
} else {
schedule()
}
} catch (requestError) {
if (options.isForbidden?.(requestError) ?? false) {
forbidden.value = true
stop()
} else {
error.value = getErrorMessage(requestError)
stop()
}
} finally {
loading.value = false
requestInFlight = false
}
}
const start = async (nextTaskId: number) => {
if (!Number.isInteger(nextTaskId) || nextTaskId <= 0) return
taskId.value = nextTaskId
task.value = null
pollIndex = 0
writeStoredTaskId(options.storageKey, nextTaskId)
await refresh()
}
const retry = async () => {
if (!taskId.value) return
await refresh()
}
const handleVisibilityChange = () => {
if (document.hidden) {
stop()
return
}
if (taskId.value && isActive.value) {
pollIndex = 0
void refresh()
}
}
onMounted(() => {
document.addEventListener('visibilitychange', handleVisibilityChange)
if (options.autoRestore !== false && taskId.value) void refresh()
})
onBeforeUnmount(() => {
stop()
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
return {
taskId,
task: task as ShallowRef<T | null>,
loading,
forbidden,
error,
isActive,
start,
retry,
stop,
clear
}
}

View File

@@ -0,0 +1,60 @@
/** 八月审计链路页面与按钮权限。 */
export const AUDIT_PERMISSIONS = {
centerPage: 'audit:center_view',
eventList: 'audit:event_list',
eventDetail: 'audit:event_detail',
auditEventResourceTimeline: 'audit:audit_event_resource_timeline',
auditEventIntegrationDetail: 'audit:audit_event_integration_detail',
integrationDetailRequestTimeline: 'audit:integration_detail_request_timeline',
integrationDetailCorrelationTimeline: 'audit:integration_detail_correlation_timeline',
integrationDetailResourceTimeline: 'audit:integration_detail_resource_timeline',
actorTimeline: 'audit:actor_timeline',
resourceSearch: 'audit:resource_search',
resourceTimeline: 'audit:resource_timeline',
requestTimeline: 'audit:request_timeline',
correlationTimeline: 'audit:correlation_timeline',
financeTimeline: 'audit:finance_timeline',
riskPage: 'audit:risk_view',
riskEvents: 'audit:risk_events',
riskEventDetail: 'audit:risk_event_detail',
integrationPage: 'audit:integration_view',
integrationDetail: 'audit:integration_detail',
agentActivity: 'audit:agent_resource_activity',
enterpriseActivity: 'audit:enterprise_resource_activity',
agentCardActivity: 'audit:agent_card_activity',
agentDeviceActivity: 'audit:agent_device_activity',
enterpriseCardActivity: 'audit:enterprise_card_activity',
enterpriseDeviceActivity: 'audit:enterprise_device_activity',
agentExchangeActivity: 'audit:agent_exchange_activity',
cardEntry: 'audit:card_entry',
deviceEntry: 'audit:device_entry',
exchangeEntry: 'audit:exchange_entry',
exchangeOldAssetEntry: 'audit:exchange_old_asset_entry',
exchangeNewAssetEntry: 'audit:exchange_new_asset_entry',
agentAssetAllocationActivity: 'audit:agent_asset_allocation_activity',
assetAllocationEntry: 'audit:asset_allocation_entry',
assetAllocationAssetEntry: 'audit:asset_allocation_asset_entry',
assetInfoEntry: 'audit:asset_info_entry',
agentAssetInfoCardActivity: 'audit:agent_asset_info_card_activity',
agentAssetInfoDeviceActivity: 'audit:agent_asset_info_device_activity',
enterpriseAssetInfoCardActivity: 'audit:enterprise_asset_info_card_activity',
enterpriseAssetInfoDeviceActivity: 'audit:enterprise_asset_info_device_activity',
assetInfoBindingCardEntry: 'audit:asset_info_binding_card_entry',
agentAssetInfoBindingCardActivity: 'audit:agent_asset_info_binding_card_activity',
enterpriseAssetInfoBindingCardActivity: 'audit:enterprise_asset_info_binding_card_activity',
assetInfoWalletFinance: 'audit:asset_info_wallet_finance',
assetInfoWalletAsset: 'audit:asset_info_wallet_asset',
shopEntry: 'audit:shop_entry',
enterpriseEntry: 'audit:enterprise_entry',
orderEntry: 'audit:order_entry',
orderFinanceEntry: 'audit:order_finance_entry',
agentRechargeEntry: 'audit:agent_recharge_entry',
agentRechargeFinanceEntry: 'audit:agent_recharge_finance_entry',
agentRechargeApprovalEntry: 'audit:agent_recharge_approval_entry',
refundEntry: 'audit:refund_entry',
refundFinanceEntry: 'audit:refund_finance_entry',
refundApprovalEntry: 'audit:refund_approval_entry',
walletEntry: 'audit:wallet_entry'
} as const
export type AuditPermission = (typeof AUDIT_PERMISSIONS)[keyof typeof AUDIT_PERMISSIONS]

View File

@@ -0,0 +1,7 @@
export const BULK_PURCHASE_PERMISSIONS = {
page: 'bulk_purchase:view',
create: 'bulk_purchase:create',
detail: 'bulk_purchase:detail',
items: 'bulk_purchase:items',
template: 'bulk_purchase:template'
} as const

View File

@@ -3,7 +3,6 @@ import type { ExportTaskScene } from '@/types/api'
export interface ExportTaskScenePermissions {
detail: string
download: string
cancel: string
}
export interface ExportTaskSceneConfig {
@@ -20,8 +19,7 @@ export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneCo
pageTitle: '导出设备',
permissions: {
detail: 'export_task:device_detail',
download: 'export_task:device_download',
cancel: 'export_task:device_cancel'
download: 'export_task:device_download'
}
},
iot_card: {
@@ -30,8 +28,7 @@ export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneCo
pageTitle: '导出IOT卡',
permissions: {
detail: 'export_task:iot_card_detail',
download: 'export_task:iot_card_download',
cancel: 'export_task:iot_card_cancel'
download: 'export_task:iot_card_download'
}
},
order: {
@@ -40,8 +37,52 @@ export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneCo
pageTitle: '导出订单',
permissions: {
detail: 'export_task:order_detail',
download: 'export_task:order_download',
cancel: 'export_task:order_cancel'
download: 'export_task:order_download'
}
},
package: {
scene: 'package',
sceneName: '套餐管理',
pageTitle: '导出套餐',
permissions: {
detail: 'export_task:package_detail',
download: 'export_task:package_download'
}
},
agent_wallet_transaction: {
scene: 'agent_wallet_transaction',
sceneName: '代理主钱包流水',
pageTitle: '导出代理主钱包流水',
permissions: {
detail: 'export_task:agent_wallet_transaction_detail',
download: 'export_task:agent_wallet_transaction_download'
}
},
agent_recharge: {
scene: 'agent_recharge',
sceneName: '代理充值',
pageTitle: '导出代理充值',
permissions: {
detail: 'export_task:agent_recharge_detail',
download: 'export_task:agent_recharge_download'
}
},
refund: {
scene: 'refund',
sceneName: '退款',
pageTitle: '导出退款',
permissions: {
detail: 'export_task:refund_detail',
download: 'export_task:refund_download'
}
},
exchange: {
scene: 'exchange',
sceneName: '换货',
pageTitle: '导出换货',
permissions: {
detail: 'export_task:exchange_detail',
download: 'export_task:exchange_download'
}
}
}

View File

@@ -34,3 +34,7 @@ export * from './enableStatus'
// 导出任务相关
export * from './exportTask'
// 批量订购相关
export * from './bulkPurchase'
export * from './julyIteration'

View File

@@ -0,0 +1,40 @@
/**
* 七月迭代新增后台权限编码。
* 页面和按钮统一引用这里的常量,后端菜单权限可直接复用同名编码。
*/
export const JULY_PERMISSIONS = {
wecom: {
page: 'wecom:config',
applicationCreate: 'wecom:application_create',
applicationTest: 'wecom:application_test',
applicationEdit: 'wecom:application_edit',
memberManage: 'wecom:member_manage',
memberSync: 'wecom:member_sync',
memberDefaultCreator: 'wecom:member_default_creator',
sceneCreate: 'wecom:scene_create',
sceneEdit: 'wecom:scene_edit',
binding: 'wecom:account_binding'
},
systemConfig: {
payment: 'system_config:payment_methods'
},
speedTier: {
view: 'iot_card:speed_tier',
update: 'iot_card:speed_tier_update'
},
deviceAllocation: {
page: 'device_task:allocation',
create: 'device_task:allocation_create',
detail: 'device_task:allocation_detail'
},
seriesGrants: {
updateExpiryBase: 'series_grants:update_expiry_base'
},
batchPurchase: {
page: 'bulk_purchase:view',
create: 'bulk_purchase:create',
detail: 'bulk_purchase:detail'
}
} as const
export type JulyPermission = string

View File

@@ -406,6 +406,14 @@
"orderList": "Order List",
"orderDetail": "Order Details"
},
"financialManagement": {
"title": "Financial Management",
"agentRecharge": "Agent Recharge",
"agentRechargeDetail": "Agent Recharge Details",
"refundManagement": "Refund Management",
"refundDetail": "Refund Details",
"agentFundOverview": "Agent Fund Overview"
},
"deviceManagement": {
"title": "Device Management",
"devices": "Device Management"
@@ -432,6 +440,9 @@
"standaloneCardList": "IoT Card Management",
"iotCardTask": "IoT Card Tasks",
"deviceTask": "Device Tasks",
"deviceBatchAllocation": "Device Batch Tasks",
"bulkPurchase": "Bulk Package Purchase",
"bulkPurchaseDetail": "Bulk Purchase Task Detail",
"orderPackageInvalidateTask": "Order Package Void Tasks",
"orderPackageInvalidateTaskDetail": "Order Package Void Task Detail",
"taskDetail": "Task Details",
@@ -452,6 +463,11 @@
"exportDevice": "Export Devices",
"exportIotCard": "Export IOT Cards",
"exportOrder": "Export Orders",
"exportPackage": "Export Packages",
"exportAgentWalletTransaction": "Export Agent Wallet Transactions",
"exportRefund": "Export Refunds",
"exportAgentRecharge": "Export Agent Recharges",
"exportExchange": "Export Exchanges",
"exportTaskDetail": "Export Task Detail",
"exchangeManagement": "Exchange Management",
"exchangeDetail": "Exchange Order Detail"

View File

@@ -370,6 +370,9 @@
"standaloneCardList": "IoT卡管理",
"iotCardTask": "IoT卡任务",
"deviceTask": "设备任务",
"deviceBatchAllocation": "设备批量任务",
"bulkPurchase": "批量订购套餐",
"bulkPurchaseDetail": "批量订购任务详情",
"orderPackageInvalidateTask": "订单套餐批量作废",
"orderPackageInvalidateTaskDetail": "订单套餐作废任务详情",
"taskDetail": "任务详情",
@@ -386,6 +389,11 @@
"exportDevice": "导出设备",
"exportIotCard": "导出IOT卡",
"exportOrder": "导出订单",
"exportPackage": "导出套餐",
"exportAgentWalletTransaction": "导出代理主钱包流水",
"exportRefund": "导出退款",
"exportAgentRecharge": "导出代理充值",
"exportExchange": "导出换货",
"exportTaskDetail": "导出任务详情",
"exchangeManagement": "换货管理",
"exchangeDetail": "换货单详情"
@@ -400,13 +408,13 @@
"agentRecharge": "代理充值",
"agentRechargeDetail": "代理充值详情",
"refundManagement": "退款管理",
"refundDetail": "退款详情"
"refundDetail": "退款详情",
"agentFundOverview": "代理商资金概况"
},
"commission": {
"title": "佣金管理",
"withdrawal": "提现审批",
"myCommission": "我的佣金",
"agentCommission": "代理商资金概况"
"myCommission": "我的佣金"
},
"settings": {
"title": "设置管理",

View File

@@ -14,6 +14,7 @@ import { loadingService } from '@/utils/ui'
import { useCommon } from '@/composables/useCommon'
import { useWorktabStore } from '@/store/modules/worktab'
import { isInWhiteList, hasRoutePermission, isTokenValid, buildLoginRedirect } from './permission'
import { isPlatformAuditAccount } from '@/utils/business/auditAccess'
// 是否已注册动态路由
const isRouteRegistered = ref(false)
@@ -95,6 +96,14 @@ async function handleRouteGuard(
return
}
if (
to.path.startsWith('/audit') &&
!isPlatformAuditAccount(userStore.info.user_type, userStore.isSuperAdmin)
) {
next(RoutesAlias.Exception403 || '/exception/403')
return
}
// 处理动态路由注册
if (!isRouteRegistered.value && userStore.isLogin) {
await handleDynamicRoutes(to, router, next)

View File

@@ -1,5 +1,8 @@
import { RoutesAlias } from '../routesAlias'
import { AppRouteRecord } from '@/types/router'
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
import { JULY_PERMISSIONS } from '@/config/constants/julyIteration'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
/**
* 菜单列表、异步路由
@@ -74,6 +77,18 @@ export const asyncRoutes: AppRouteRecord[] = [
roles: ['R_SUPER']
}
},
{
path: 'role/detail/:id',
name: 'RoleDetail',
component: RoutesAlias.RoleDetail,
meta: {
title: '角色详情',
permissions: ['role:detail'],
isHide: true,
keepAlive: false,
roles: ['R_SUPER']
}
},
{
path: 'permission',
name: 'PermissionManagement',
@@ -213,6 +228,17 @@ export const asyncRoutes: AppRouteRecord[] = [
title: 'menus.shopManagement.shopList',
keepAlive: true
}
},
// 店铺详情
{
path: 'detail/:id',
name: 'ShopDetail',
component: RoutesAlias.ShopDetail,
meta: {
title: '店铺详情',
isHide: true,
keepAlive: false
}
}
]
},
@@ -281,6 +307,16 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true
}
},
// 临期资产
{
path: 'expiring-assets',
name: 'ExpiringAssets',
component: RoutesAlias.ExpiringAssets,
meta: {
title: '临期资产',
keepAlive: true
}
},
// IoT卡管理
{
path: 'iot-card-management',
@@ -365,17 +401,6 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: false
}
},
// 授权记录详情(放在 asset-management 层级)
{
path: 'record-management/authorization-records/detail/:id',
name: 'AuthorizationRecordDetail',
component: RoutesAlias.AuthorizationRecordDetail,
meta: {
title: 'menus.assetManagement.authorizationRecordDetail',
isHide: true,
keepAlive: false
}
},
// 任务管理
{
path: 'task-management',
@@ -406,6 +431,28 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true
}
},
// 设备批量任务
{
path: 'device-batch-allocation',
name: 'DeviceBatchAllocation',
component: RoutesAlias.DeviceBatchAllocation,
meta: {
title: 'menus.assetManagement.deviceBatchAllocation',
permissions: [JULY_PERMISSIONS.deviceAllocation.page],
keepAlive: true
}
},
// 批量订购套餐任务
{
path: 'bulk-purchase',
name: 'BulkPurchase',
component: RoutesAlias.BulkPurchase,
meta: {
title: 'menus.assetManagement.bulkPurchase',
permissions: [BULK_PURCHASE_PERMISSIONS.page],
keepAlive: true
}
},
// 订单套餐批量作废任务
{
path: 'order-package-invalidate-task',
@@ -440,6 +487,18 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: false
}
},
// 批量订购套餐任务详情
{
path: 'task-management/bulk-purchase/detail/:id',
name: 'BulkPurchaseDetail',
component: RoutesAlias.BulkPurchaseDetail,
meta: {
title: 'menus.assetManagement.bulkPurchaseDetail',
isHide: true,
permissions: [BULK_PURCHASE_PERMISSIONS.detail],
keepAlive: false
}
},
// 导出管理
{
path: 'export-task-management',
@@ -480,6 +539,56 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true
}
},
{
path: 'export-package',
name: 'ExportPackageTaskList',
component: RoutesAlias.ExportPackageTaskList,
meta: {
title: 'menus.assetManagement.exportPackage',
exportTaskScene: 'package',
keepAlive: true
}
},
{
path: 'export-agent-wallet-transaction',
name: 'ExportAgentWalletTransactionTaskList',
component: RoutesAlias.ExportAgentWalletTransactionTaskList,
meta: {
title: 'menus.assetManagement.exportAgentWalletTransaction',
exportTaskScene: 'agent_wallet_transaction',
keepAlive: true
}
},
{
path: 'export-refund',
name: 'ExportRefundTaskList',
component: RoutesAlias.ExportRefundTaskList,
meta: {
title: 'menus.assetManagement.exportRefund',
exportTaskScene: 'refund',
keepAlive: true
}
},
{
path: 'export-agent-recharge',
name: 'ExportAgentRechargeTaskList',
component: RoutesAlias.ExportAgentRechargeTaskList,
meta: {
title: 'menus.assetManagement.exportAgentRecharge',
exportTaskScene: 'agent_recharge',
keepAlive: true
}
},
{
path: 'export-exchange',
name: 'ExportExchangeTaskList',
component: RoutesAlias.ExportExchangeTaskList,
meta: {
title: 'menus.assetManagement.exportExchange',
exportTaskScene: 'exchange',
keepAlive: true
}
},
{
path: 'export-task-detail',
name: 'ExportTaskDetail',
@@ -580,6 +689,83 @@ export const asyncRoutes: AppRouteRecord[] = [
isHide: true,
keepAlive: false
}
},
// 代理商资金概况
{
path: 'agent-fund-overview',
name: 'AgentFundOverview',
component: RoutesAlias.AgentFundOverviewComponent,
meta: {
title: 'menus.financialManagement.agentFundOverview',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
}
]
},
// 审计中心
{
path: '/audit',
name: 'AuditCenter',
component: RoutesAlias.Home,
meta: {
title: '审计中心',
icon: '&#xe7c9;',
permissions: [AUDIT_PERMISSIONS.centerPage]
},
children: [
{
path: 'events',
name: 'AuditEvents',
component: RoutesAlias.AuditEvents,
meta: {
title: '审计事件',
permissions: [AUDIT_PERMISSIONS.eventList],
keepAlive: true
}
},
{
path: 'events/:eventId',
name: 'AuditEventDetail',
component: RoutesAlias.AuditEventDetail,
meta: {
title: '审计事件详情',
permissions: [AUDIT_PERMISSIONS.eventDetail],
isHide: true,
keepAlive: false
}
},
{
path: 'risks',
name: 'AuditRisks',
component: RoutesAlias.AuditRisks,
meta: {
title: '风险中心',
permissions: [AUDIT_PERMISSIONS.riskPage],
keepAlive: true
}
},
{
path: 'integrations',
name: 'AuditIntegrations',
component: RoutesAlias.AuditIntegrations,
meta: {
title: '外部交互',
permissions: [AUDIT_PERMISSIONS.integrationPage],
keepAlive: true
}
},
{
path: 'integrations/:integrationId',
name: 'AuditIntegrationDetail',
component: RoutesAlias.AuditIntegrationDetail,
meta: {
title: '外部交互详情',
permissions: [AUDIT_PERMISSIONS.integrationDetail],
isHide: true,
keepAlive: false
}
}
]
},
@@ -615,17 +801,6 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true,
roles: ['R_AGENT']
}
},
// 代理商资金概况
{
path: 'agent-fund-overview',
name: 'AgentFundOverview',
component: RoutesAlias.AgentFundOverview,
meta: {
title: 'menus.commission.agentCommission',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
}
]
},
@@ -682,6 +857,60 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true,
roles: ['R_SUPER']
}
},
// 系统配置
{
path: 'system-configs',
name: 'SystemConfigs',
component: RoutesAlias.SystemConfigs,
meta: {
title: '系统配置',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
{
path: 'wecom',
name: 'WecomSettings',
redirect: RoutesAlias.WecomApplications,
meta: {
title: '企业微信配置',
isHide: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
// 企业微信应用
{
path: 'wecom/applications',
name: 'WecomApplications',
component: RoutesAlias.WecomApplications,
meta: {
title: '企微应用管理',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
// 企业微信成员及默认发起人
{
path: 'wecom/members',
name: 'WecomMembers',
component: RoutesAlias.WecomMembers,
meta: {
title: '企微成员管理',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
// 企业微信审批场景
{
path: 'wecom/scenes',
name: 'WecomScenes',
component: RoutesAlias.WecomScenes,
meta: {
title: '企微审批场景',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
}
]
},

View File

@@ -18,10 +18,18 @@ export enum RoutesAlias {
// 系统管理
Role = '/system/role', // 角色
RoleDetail = '/system/role/detail', // 角色详情
Permission = '/system/permission', // 权限管理
CarrierManagement = '/system/carrier-management', // 运营商管理
UserCenter = '/system/user-center', // 用户中心
// 审计中心
AuditEvents = '/audit/events',
AuditEventDetail = '/audit/events/detail',
AuditRisks = '/audit/risks',
AuditIntegrations = '/audit/integrations',
AuditIntegrationDetail = '/audit/integrations/detail',
// 套餐管理
PackageList = '/package-management/package-list', // 套餐列表
PackageDetail = '/package-management/package-list/detail', // 套餐列表详情
@@ -33,6 +41,7 @@ export enum RoutesAlias {
// 店铺管理
Shop = '/shop-management/list', // 店铺列表
ShopDetail = '/shop-management/detail', // 店铺详情
// 通用页面(店铺账号-企业账号)
EnterpriseCustomerAccounts = '/common/account-list', // 企业客户账号列表和店铺账号列表共用
@@ -50,41 +59,55 @@ export enum RoutesAlias {
// 记录管理
AuthorizationRecords = '/asset-management/record-management/authorization-records', // 授权记录
AuthorizationRecordDetail = '/asset-management/record-management/authorization-records/detail', // 授权记录详情
AssetAssign = '/asset-management/record-management/asset-assign', // 分配记录
AssetAssignDetail = '/asset-management/record-management/asset-assign/detail', // 分配记录详情
// 任务管理
IotCardTask = '/asset-management/task-management/iot-card-task', // IoT卡任务
DeviceTask = '/asset-management/task-management/device-task', // 设备任务
DeviceBatchAllocation = '/asset-management/task-management/device-batch-allocation', // 设备批量任务
BulkPurchase = '/asset-management/task-management/bulk-purchase', // 批量订购套餐
BulkPurchaseDetail = '/asset-management/task-management/bulk-purchase/detail', // 批量订购套餐详情
OrderPackageInvalidateTask = '/asset-management/task-management/order-package-invalidate-task', // 订单套餐批量作废任务
OrderPackageInvalidateTaskDetail = '/asset-management/task-management/order-package-invalidate-task/detail', // 订单套餐作废任务详情
TaskDetail = '/asset-management/task-management/task-detail', // 任务详情IoT卡/设备任务详情)
ExportDeviceTaskList = '/asset-management/export-task-management/export-device', // 导出设备
ExportIotCardTaskList = '/asset-management/export-task-management/export-iot-card', // 导出IOT卡
ExportOrderTaskList = '/asset-management/export-task-management/export-order', // 导出订单
ExportPackageTaskList = '/asset-management/export-task-management/export-package', // 导出套餐
ExportAgentWalletTransactionTaskList = '/asset-management/export-task-management/export-agent-wallet-transaction', // 导出代理主钱包流水
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', // 导出换货
ExportTaskDetail = '/asset-management/export-task-management/export-task-detail', // 导出任务详情
// 订单管理
OrderList = '/order-management/order-list', // 订单列表
OrderDetail = '/order-management/order-list/detail', // 订单详情
// 财务管理
AgentRecharge = '/finance/agent-recharge', // 代理充值
AgentRechargeDetail = '/finance/agent-recharge/detail', // 代理充值详情
RefundManagement = '/finance/refund', // 退款管理
RefundDetail = '/finance/refund/detail', // 退款详情
AgentFundOverview = '/finance/agent-fund-overview', // 代理商资金概况
ExpiringAssets = '/asset-management/expiring-assets', // 临期资产
// 佣金管理
WithdrawalApproval = '/commission-management/withdrawal-approval', // 提现审批
MyCommission = '/commission-management/my-commission', // 我的佣金
AgentFundOverview = '/commission-management/agent-fund-overview', // 代理商资金概况
AgentFundOverviewComponent = '/commission-management/agent-fund-overview', // 代理商资金概况组件路径
// 设置管理
WithdrawalSettings = '/settings/withdrawal-settings', // 提现配置
PaymentSettings = '/settings/payment-settings', // 支付设置
PaymentSettingsDetail = '/settings/payment-settings/detail', // 支付设置详情
OperationPasswordSettings = '/settings/operation-password', // 操作密码设置
SystemConfigs = '/settings/system-configs', // 系统配置
WecomSettings = '/settings/wecom', // 企业微信配置兼容入口
WecomApplications = '/settings/wecom/applications', // 企业微信应用
WecomMembers = '/settings/wecom/members', // 企业微信成员
WecomScenes = '/settings/wecom/scenes', // 企业微信审批场景
// 轮询管理
DataCleanup = '/polling-management/data-cleanup', // 数据清理

View File

@@ -0,0 +1,114 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { NotificationService } from '@/api/modules/notification'
import type {
BaseResponse,
NotificationItem,
NotificationListResponse,
NotificationUnreadSummary
} from '@/types/api'
const emptySummary = (): NotificationUnreadSummary => ({
approval: 0,
expiry: 0,
sync: 0,
system: 0,
total: 0
})
export const useNotificationStore = defineStore('notificationStore', () => {
const unreadCount = ref(0)
const displayCount = ref('0')
const summary = ref<NotificationUnreadSummary>(emptySummary())
const recentNotifications = ref<NotificationItem[]>([])
const notificationPage = ref(1)
const notificationPageSize = 10
const notificationTotal = ref(0)
const loading = ref(false)
const applyNotificationList = (
response: BaseResponse<NotificationListResponse>,
fallbackPage: number
) => {
if (response.code !== 0 || !response.data) return
recentNotifications.value = response.data.items
notificationPage.value = response.data.page || fallbackPage
notificationTotal.value = Math.max(0, response.data.total || 0)
}
const refreshUnreadCount = async () => {
const response = await NotificationService.getUnreadCount()
if (response.code === 0 && response.data) {
unreadCount.value = Math.max(0, response.data.count)
displayCount.value = response.data.display_count || '0'
}
return unreadCount.value
}
const loadNotifications = async (page = notificationPage.value) => {
const targetPage = Math.max(1, page)
loading.value = true
try {
const response = await NotificationService.getNotifications({
page: targetPage,
page_size: notificationPageSize
})
applyNotificationList(response, targetPage)
return response
} finally {
loading.value = false
}
}
const refreshSummary = async (page = 1) => {
const targetPage = Math.max(1, page)
loading.value = true
try {
const [summaryResponse, listResponse] = await Promise.all([
NotificationService.getUnreadSummary(),
NotificationService.getNotifications({ page: targetPage, page_size: notificationPageSize })
])
if (summaryResponse.code === 0 && summaryResponse.data) {
summary.value = summaryResponse.data
}
applyNotificationList(listResponse, targetPage)
await refreshUnreadCount()
return summary.value
} finally {
loading.value = false
}
}
const markRead = async (id: number) => {
const response = await NotificationService.markRead(id)
if (response.code === 0) {
await refreshSummary(notificationPage.value)
}
return response
}
const markAllRead = async (category?: string) => {
const response = await NotificationService.markAllRead(category ? { category } : {})
if (response.code === 0) {
await refreshSummary(notificationPage.value)
}
return response
}
return {
unreadCount,
displayCount,
summary,
recentNotifications,
notificationPage,
notificationPageSize,
notificationTotal,
loading,
refreshUnreadCount,
loadNotifications,
refreshSummary,
markRead,
markAllRead
}
})

View File

@@ -51,7 +51,7 @@ export const useUserStore = defineStore(
}
// 检查是否是超级管理员
const isSuperAdmin = computed(() => info.value.user_type === 1)
const isSuperAdmin = computed(() => Number(info.value.user_type) === 1)
// 检查是否有某个权限
const hasPermission = (permission: string): boolean => {

View File

@@ -0,0 +1 @@
89861590172420377385
1 89861590172420377385

View File

@@ -0,0 +1 @@
89861590172420377385
1 89861590172420377385

View File

@@ -12,7 +12,8 @@ export enum AccountStatus {
// 账号实体(统一账号模型,匹配后端 ModelAccountResponse
export interface PlatformAccount {
ID: number
id?: number
ID?: number
CreatedAt?: string
UpdatedAt?: string
DeletedAt?: string | null
@@ -25,6 +26,10 @@ export interface PlatformAccount {
enterprise_name?: string // ⭐ 新增:企业名称
shop_id?: number | null // 关联店铺ID
shop_name?: string // ⭐ 新增:店铺名称
wecom_bound?: boolean // 是否已绑定企业微信账号
wecom_corp_id?: string // 企业微信 CorpID
wecom_name?: string // 企业微信成员名称
wecom_userid?: string // 企业微信成员 UserID
status: AccountStatus // 状态 (0:禁用, 1:启用)
}

View File

@@ -8,14 +8,22 @@ export enum AgentRechargeStatus {
PAID = 2, // 已支付
COMPLETED = 3, // 已完成
CLOSED = 4, // 已关闭
REFUNDED = 5 // 已退款
REFUNDED = 5, // 已退款
REJECTED = 6 // 已驳回
}
// 支付方式
export type AgentRechargePaymentMethod = 'wechat' | 'offline'
export type AgentRechargeOnlinePaymentMethod = 'wechat' | 'alipay'
export type AgentRechargePaymentMethod = AgentRechargeOnlinePaymentMethod | 'offline'
// 充值来源
export type AgentRechargeSource = 'agent_online' | 'platform_offline'
// 第三方支付状态
export type AgentRechargePaymentStatus = 0 | 1 | 2 | 3
// 支付通道
export type AgentRechargePaymentChannel = 'wechat_direct' | 'fuyou' | 'offline'
export type AgentRechargePaymentChannel = string
// 代理充值订单
export interface AgentRecharge {
@@ -30,10 +38,27 @@ export interface AgentRecharge {
payment_method: AgentRechargePaymentMethod
payment_channel: AgentRechargePaymentChannel
payment_config_id: number | null
payment_transaction_id: string
payment_transaction_id: string | null
payment_no?: string | null
request_id?: string | null
qr_content?: string | null
payment_status?: AgentRechargePaymentStatus | null
payment_status_name?: string | null
recharge_source?: AgentRechargeSource | null
recharge_source_name?: string | null
payment_voucher_key?: string[] | string // 凭证附件列表;历史数据可能为单字符串或逗号字符串
rejection_reason?: string | null // 拒绝原因
remark?: string // 运营备注
submitter_name?: string | null // 提交人名称
submitter_id?: number | null
approval_source?: 'none' | 'legacy' | 'wecom' | null // 审批来源
approval_provider?: 'wecom' | null
approval_instance_id?: number | null
approval_status?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | null // 企微审批状态
approval_status_name?: string | null // 审批状态名称
current_approver_summary?: string | null // 当前审批人摘要
processing_status?: string | null // 业务处理状态
processing_status_name?: string | null // 业务处理状态名称
created_at: string
paid_at: string | null
completed_at: string | null
@@ -46,9 +71,10 @@ export interface AgentRechargeQueryParams {
page_size?: number
shop_id?: number
status?: AgentRechargeStatus
recharge_source?: AgentRechargeSource
start_date?: string
end_date?: string
dateRange?: string[] | any // For date range picker in UI
dateRange?: string[] // For date range picker in UI
}
// 代理充值订单列表响应
@@ -61,14 +87,45 @@ export interface AgentRechargeListResponse {
}
// 创建代理充值订单请求
export interface CreateAgentRechargeRequest {
export interface CreateAgentRechargeOnlineRequest {
amount: number // 充值金额(单位:分),最小 1即 ¥0.01
payment_method: AgentRechargePaymentMethod
payment_method: AgentRechargeOnlinePaymentMethod
request_id: string
}
export interface CreateAgentRechargeOfflineRequest {
amount: number // 充值金额(单位:分),最小 1即 ¥0.01
payment_method: 'offline'
shop_id: number
payment_voucher_key?: string[] // 线下支付凭证附件列表payment_method=offline 时必填)
payment_voucher_key: string[] // 线下支付凭证附件列表payment_method=offline 时必填)
remark?: string // 运营备注,最多 1000 字
}
export type CreateAgentRechargeRequest =
| CreateAgentRechargeOnlineRequest
| CreateAgentRechargeOfflineRequest
// 在线充值可用支付方式和金额范围
export interface AgentRechargePaymentMethods {
methods: AgentRechargeOnlinePaymentMethod[]
min_amount: number
max_amount: number
}
// 在线充值支付及钱包到账状态
export interface AgentRechargePaymentStatusResponse {
recharge_id: number
recharge_no: string
recharge_source: AgentRechargeSource
recharge_source_name?: string | null
status: AgentRechargeStatus
status_name?: string | null
payment_status: AgentRechargePaymentStatus
payment_status_name?: string | null
paid_at: string | null
completed_at: string | null
}
// 确认线下充值请求
export interface ConfirmOfflinePaymentRequest {
operation_password: string

View File

@@ -10,6 +10,8 @@ type ExpiryBase = 'from_activation' | 'from_purchase'
export type AssetType = 'card' | 'device'
import type { AllowedPaymentMethod } from './systemConfig'
// 网络状态
export enum NetworkStatus {
OFFLINE = 0, // 停机
@@ -46,6 +48,107 @@ export interface AssetResolveParams {
include_usage_summary?: boolean // 是否返回当前世代流量汇总字段
}
// 资产轮询状态
export interface AssetPollingStatus {
enabled: boolean // 是否启用轮询
activity_level: string // 活跃级别
last_activity_at?: string | null // 最后活跃时间
last_activity_scene?: string | null // 最后活跃场景
next_poll_at?: string | null // 下次轮询时间
}
// 资产复机结果
export interface AssetStartResponse {
status: number // 最新资产状态
status_name: string // 最新资产状态名称
real_name_status: RealNameStatus // 最新实名状态
real_name_status_name: string // 最新实名状态名称
}
// 资产实名认证策略
export type AssetRealnamePolicy = 'none' | 'before_order' | 'after_order'
// 预计最终到期时间状态
export type ExpiryEstimateStatus = 'exact' | 'waiting_activation' | 'none' | 'invalid_data'
// 临期等级由后端返回,前端仅用于展示和筛选结果标识
export type ExpiryLevel = 'red' | 'purple' | 'pink' | string
export type ExpiringAssetType = 'iot_card' | 'device'
export interface ExpiringAssetItem {
asset_id: number
asset_type: ExpiringAssetType
days_until_final_expiry: number | null
estimated_final_expires_at: string | null
expiry_estimate_status: ExpiryEstimateStatus
expiry_estimate_status_name: string
expiry_level: ExpiryLevel
expiry_level_name: string
identifier: string
is_expiring: boolean
is_priority: boolean
package_id?: number
package_name: string
package_usage_id?: number
shop_id?: number | null
shop_name: string
}
export interface ExpiringAssetSummary {
card_count: number
device_count: number
total_count: number
window_days: number
}
export interface ExpiringAssetListResponse {
items: ExpiringAssetItem[]
page: number
size: number
summary: ExpiringAssetSummary
total: number
}
export interface ExpiringAssetQueryParams {
asset_type?: ExpiringAssetType
keyword?: string
shop_id?: number
package_id?: number
days_min?: number
days_max?: number
expires_from?: string
expires_to?: string
page?: number
page_size?: number
}
// 批量更新资产实名认证策略请求
export interface BatchUpdateAssetRealnamePolicyRequest {
asset_ids: number[]
realname_policy: AssetRealnamePolicy
}
export interface BatchUpdateAssetRealnamePolicyResponse {
realname_policy: AssetRealnamePolicy
success_count: number
}
// 换货关联资产
export interface AssetExchangeTraceAsset {
asset_type: string
asset_id: number | null
identifier: string
exchange_no: string
can_view: boolean
}
// 换货前后代链路
export interface AssetExchangeTrace {
previous_asset?: AssetExchangeTraceAsset | null
next_asset?: AssetExchangeTraceAsset | null
}
/**
* 资产解析/详情响应
* 对应接口GET /api/admin/assets/resolve/:identifier
@@ -62,7 +165,10 @@ export interface AssetResolveResponse {
series_id: number // 套餐系列 ID
series_name: string // 套餐系列名称
real_name_status: RealNameStatus // 实名状态0 未实名 / 1 已实名
realname_policy?: string // 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
realname_policy?: AssetRealnamePolicy // 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
effective_realname_policy?: AssetRealnamePolicy
realname_required?: boolean
allowed_payment_methods?: AllowedPaymentMethod[]
real_name_at?: string | null // 实名时间
network_status?: NetworkStatus // 网络状态0 停机 / 1 开机(仅 card
current_package: string // 当前套餐名称(无则空)
@@ -73,6 +179,11 @@ export interface AssetResolveResponse {
virtual_used_mb: number // 展示已用量
total_virtual_used_mb?: number | null // 所有套餐已用量(MB),未请求时为 null
total_virtual_remaining_mb?: number | null // 所有套餐剩余量(MB),未请求时为 null
estimated_final_expires_at?: string | null // 当前及排队主套餐接续后的预计最终到期时间
days_until_final_expiry?: number | null // 距预计最终到期的剩余天数
expiry_estimate_status?: ExpiryEstimateStatus // 预计最终到期时间状态
expiry_estimate_status_name?: string | null // 预计最终到期时间状态名称
is_expiring?: boolean // 是否临期,由后端判断
reduction_pct: number // 展示增幅比例(小数,如 0.428571
device_protect_status?: DeviceProtectStatus // 保护期状态none / stop / start仅 device
activated_at: string // 激活时间
@@ -80,6 +191,8 @@ export interface AssetResolveResponse {
updated_at: string // 更新时间
accumulated_recharge?: number // 累计充值金额(分)
first_commission_paid?: boolean // 一次性佣金是否已发放
polling?: AssetPollingStatus | null // 资产轮询状态
exchange_trace?: AssetExchangeTrace | null // 换货前后代链路
// ===== 卡专属字段 (asset_type === 'card' 时) =====
iccid?: string // ICCID
@@ -126,7 +239,7 @@ export interface AssetBoundCard {
network_status: NetworkStatus // 网络状态
real_name_status: RealNameStatus // 实名状态
real_name_at?: string | null // 实名时间
realname_policy?: string // 实名认证策略
realname_policy?: AssetRealnamePolicy // 实名认证策略
slot_position: number // 插槽位置
is_current?: boolean // 是否为设备当前使用的卡
gateway_extend?: string // Gateway 卡状态扩展字段,原样返回上游 extend
@@ -187,7 +300,7 @@ export interface AssetRealtimeStatusResponse {
// ===== 卡专属字段 =====
network_status?: NetworkStatus // 网络状态(仅 card
real_name_status?: RealNameStatus // 实名状态(仅 card
realname_policy?: string // 实名认证策略(仅 card
realname_policy?: AssetRealnamePolicy // 实名认证策略(仅 card
gateway_card_imei?: string // Gateway 返回的卡 IMEI
current_month_usage_mb?: number // 本月已用流量 MB仅 card
last_gateway_reading_mb?: number // 运营商周期月内已用流量 MB仅 card
@@ -393,7 +506,7 @@ export interface AssetWalletResponse {
resource_type?: string // 资源类型iot_card 或 device
balance?: number // 总余额(分)
frozen_balance?: number // 冻结余额(分)
available_balance?: number // 可用余额 = balance - frozen_balance
available_balance?: number // 后端返回的可用余额(分),前端不得自行计算
currency?: string // 币种,目前固定 CNY
status?: number // 钱包状态1-正常 2-冻结 3-关闭
status_text?: string // 状态文本
@@ -481,52 +594,3 @@ export interface DtoUpdateAssetRealnameStatusResponse {
real_name_status: number // 更新后的实名状态 (0:未实名, 1:已实名)
real_name_status_name: string // 实名状态名称(中文)
}
// ========== 资产操作审计日志 ==========
/**
* 操作审计日志项
*/
export interface AssetOperationLogItem {
id: number // 日志ID
asset_id: number // 资产ID
asset_identifier: string // 资产标识符
asset_type: string // 资产类型
operation_type: string // 操作类型
operation_desc: string // 操作描述
result_status: 'success' | 'failed' | 'denied' // 执行结果
created_at: string // 创建时间
operator_id?: number // 操作人ID
operator_name: string // 操作人名称
operator_type: string // 操作人类型
before_data: Record<string, any> // 操作前数据(原始)
after_data: Record<string, any> // 操作后数据(原始)
operation_content_before?: Record<string, any> // 操作内容(变更前)
operation_content_after?: Record<string, any> // 操作内容(变更后)
operation_fields_desc?: Record<string, string> // 字段中文说明
batch_total: number // 批量总数
success_count: number // 成功数量
fail_count: number // 失败数量
failed_items?: Array<{ iccid?: string; reason?: string }> // 失败明细
reason?: string // 原因说明
}
/**
* 资产操作审计日志响应
*/
export interface AssetOperationLogsResponse {
items: AssetOperationLogItem[] // 日志列表
page: number // 当前页码
page_size: number // 每页数量
total: number // 总记录数
}
/**
* 资产操作审计日志查询参数
*/
export interface AssetOperationLogsParams {
page?: number // 页码默认1
page_size?: number // 每页条数默认20最大100
operation_type?: string // 操作类型
result_status?: 'success' | 'failed' | 'denied' // 执行结果
}

View File

@@ -0,0 +1,28 @@
export enum AsyncTaskStatus {
PENDING = 1,
PROCESSING = 2,
COMPLETED = 3,
FAILED = 4,
CANCELED = 5
}
export interface AsyncTaskProgress {
task_id?: number
status: number
status_name?: string
total_count?: number
success_count?: number
failed_count?: number
failure_details?: unknown[] | null
error_code?: string
error_summary?: string
updated_at?: string
}
export const isAsyncTaskActive = (status?: AsyncTaskStatus) =>
status === AsyncTaskStatus.PENDING || status === AsyncTaskStatus.PROCESSING
export const isAsyncTaskTerminal = (status?: AsyncTaskStatus) =>
status === AsyncTaskStatus.COMPLETED ||
status === AsyncTaskStatus.FAILED ||
status === AsyncTaskStatus.CANCELED

449
src/types/api/audit.ts Normal file
View File

@@ -0,0 +1,449 @@
import type { PaginationParams } from './common'
export type AuditActorKind =
| 'account'
| 'personal_customer'
| 'openapi'
| 'system_task'
| 'scheduled_job'
| 'external_system'
export type AuditResult = 'success' | 'failed' | 'denied' | 'partial' | 'unknown'
export type AuditRiskLevel = 'low' | 'normal' | 'high' | 'critical'
export type AuditCategory =
| 'configuration'
| 'reliability'
| 'asset'
| 'security'
| 'identity'
| 'business'
export type AuditSource =
| 'admin_api'
| 'personal_api'
| 'openapi'
| 'worker'
| 'scheduler'
| 'callback'
export type AuditScopeType = 'platform' | 'shop' | 'personal_customer'
export type IntegrationProvider =
| 'ctcc'
| 'cmcc'
| 'cucc'
| 'wechat_pay'
| 'alipay'
| 'fuiou'
| 'wecom'
| 'gateway'
export type IntegrationDirection = 'inbound' | 'outbound'
export type IntegrationResult =
| 'pending'
| 'success'
| 'failed'
| 'unknown'
| 'not_found'
| 'invalid_payload'
| 'conflict'
| 'ignored'
| 'merged'
| 'rate_limited'
| 'completed'
| 'cancelled'
export type IntegrationResultCategory =
| 'processing'
| 'succeeded'
| 'indeterminate'
| 'failed'
| 'not_sent'
export type AuditSubjectResourceType =
| 'iot_card'
| 'device'
| 'asset_allocation_record'
| 'exchange_order'
| 'shop'
| 'enterprise'
export interface AuditRetentionInfo {
online_from: string
archived_before: string
timezone: string
}
export interface AuditActorRef {
kind: AuditActorKind
id: string
}
export interface AuditInvestigationResourceRef {
resource_type: string
resource_id?: string | null
resource_key?: string | null
display_name?: string | null
}
export interface AuditInvestigationRefs {
event_id?: string | null
actor_ref?: AuditActorRef | null
resource_refs?: AuditInvestigationResourceRef[]
request_id?: string | null
correlation_id?: string | null
integration_refs?: Array<{ integration_id: string }>
}
export interface AuditResourceView extends AuditInvestigationResourceRef {
relation: 'primary' | 'affected' | 'reference'
role: string
display_name: string
subject_summary?: string | null
subject_visibility?: 'internal_only' | 'subject_result' | 'subject_detail'
identity_snapshot?: Record<string, unknown> | null
before_data?: Record<string, unknown> | null
after_data?: Record<string, unknown> | null
subject_data?: Record<string, unknown> | null
sort_order?: number | null
created_at?: string | null
}
export interface AuditEventView {
event_id: string
parent_event_id?: string | null
action_code: string
action_name: string
category: AuditCategory
summary: string
result: AuditResult
risk_level: AuditRiskLevel
source: AuditSource
actor_kind: AuditActorKind
actor_id: string
actor_name: string
actor_shop_id?: number | null
actor_shop_name?: string | null
actor_enterprise_id?: number | null
actor_enterprise_name?: string | null
scope_type: AuditScopeType
scope_id?: string | null
scope_name?: string | null
request_id?: string | null
correlation_id?: string | null
request_method?: string | null
request_path?: string | null
ip_address?: string | null
user_agent?: string | null
success_count?: number | null
fail_count?: number | null
batch_total?: number | null
error_code?: string | null
error_summary?: string | null
content_hash?: string | null
occurred_at: string
created_at: string
resources: AuditResourceView[]
investigation_refs?: AuditInvestigationRefs | null
metadata?: Record<string, unknown> | null
}
export interface AuditEventDetail extends AuditEventView {
retention: AuditRetentionInfo
}
export interface AuditPage<T> {
items: T[]
page: number
page_size: number
total: number
retention: AuditRetentionInfo
}
export type AuditEventPage = AuditPage<AuditEventView>
export interface AuditEventQuery extends PaginationParams {
created_from?: string
created_to?: string
action?: string
category?: AuditCategory
actor_kind?: AuditActorKind
actor_id?: string
source?: AuditSource
result?: AuditResult
risk?: AuditRiskLevel
scope_type?: AuditScopeType
scope_id?: string
resource_type?: string
resource_id?: string
resource_key?: string
request_id?: string
correlation_id?: string
}
export interface AuditActorEventQuery extends PaginationParams {
action?: string
result?: AuditResult
risk?: AuditRiskLevel
resource_type?: string
resource_id?: string
created_from?: string
created_to?: string
}
export interface AuditResourceTimelineQuery extends PaginationParams {
created_from?: string
created_to?: string
action?: string
result?: AuditResult
}
export type AuditSearchResourceType = 'iot_card' | 'device' | 'shop' | 'order' | 'refund'
export interface AuditResourceSearchQuery extends PaginationParams {
resource_type: AuditSearchResourceType
keyword: string
}
export interface AuditResourceCandidate extends AuditInvestigationResourceRef {
resource_type: AuditSearchResourceType
display_name: string
historical: boolean
identity_snapshot?: Record<string, unknown> | null
}
export type AuditResourceSearchPage = AuditPage<AuditResourceCandidate>
export interface AuditLinkTimelineNode {
node_id: string
record_source: 'audit_event' | 'integration_log' | 'outbox_event'
code: string
title: string
summary: string
result: string
result_name: string
occurred_at: string
request_id?: string | null
correlation_id?: string | null
parent_event_id?: string | null
reference_only?: boolean
resources?: AuditInvestigationResourceRef[]
investigation_refs?: AuditInvestigationRefs | null
fidelity?: Record<string, boolean | null> | null
}
export interface AuditLinkTimeline {
request_id?: string | null
correlation_id?: string | null
access_log_lookup_request_id?: string | null
nodes: AuditLinkTimelineNode[]
retention: AuditRetentionInfo
}
export interface AuditFinanceQuery extends PaginationParams {
shop_id?: number
wallet_id?: number
order_id?: number
order_no?: string
payment_id?: number
payment_no?: string
refund_id?: number
refund_no?: string
recharge_id?: number
recharge_no?: string
approval_instance_id?: number
third_party_trade_no?: string
actor_kind?: AuditActorKind
actor_id?: string
correlation_id?: string
created_from?: string
created_to?: string
}
export interface AuditFinanceTimelineNode {
node_id: string
record_source: string
code: string
title: string
result: string
result_name: string
occurred_at: string
shop_id?: number | null
amount?: number | null
balance_before?: number | null
balance_after?: number | null
currency?: string | null
wallet?: { resource_type: 'agent_wallet' | 'asset_wallet'; wallet_id: number } | null
amount_authority?: {
authoritative: boolean
table?: string
field?: string
conflict_rule?: string
} | null
facts?: Record<string, unknown> | null
investigation_refs?: AuditInvestigationRefs | null
}
export type AuditFinanceTimelinePage = AuditPage<AuditFinanceTimelineNode>
export interface AuditRiskQuery {
created_from?: string
created_to?: string
risk?: AuditRiskLevel
result?: AuditResult
action?: string
source?: AuditSource
}
export interface AuditRiskEventQuery extends AuditRiskQuery, PaginationParams {}
export interface AuditNamedCount {
code: string
name: string
count: number
}
export interface AuditRiskOverview {
total: number
bucket: 'hour' | 'day'
risks: AuditNamedCount[]
results: AuditNamedCount[]
actions: AuditNamedCount[]
sources: AuditNamedCount[]
signals: AuditNamedCount[]
trend: Array<Record<string, string | number>>
retention: AuditRetentionInfo
}
export type AuditRiskEventPage = AuditPage<AuditEventView>
export interface IntegrationQuery extends PaginationParams {
created_from?: string
created_to?: string
integration_id?: string
provider?: IntegrationProvider
direction?: IntegrationDirection
operation?: string
result?: IntegrationResult
result_category?: IntegrationResultCategory
external_id?: string
resource_type?: string
resource_id?: string
resource_key?: string
trigger_source?: string
trigger_scene?: string
trigger_series?: string
state_changed?: boolean
http_status?: number
provider_code?: string
request_id?: string
correlation_id?: string
}
export interface IntegrationResourceView {
type?: string | null
id?: string | null
key?: string | null
}
export interface IntegrationIdentityView {
integration_id: string
provider: IntegrationProvider
provider_name: string
direction: IntegrationDirection
direction_name: string
operation: string
operation_name: string
external_id?: string | null
}
export interface IntegrationResultView {
category: IntegrationResultCategory
code: IntegrationResult
name: string
duration_ms: number
http_status?: number | null
provider_code?: string | null
provider_message?: string | null
recovery_strategy?: string | null
state_changed: boolean
}
export interface IntegrationLinkageView {
audit_event_id?: number | null
request_id?: string | null
correlation_id?: string | null
}
export interface IntegrationTriggerView {
attempt: number
scene?: string | null
series?: string | null
source?: string | null
}
export interface IntegrationTimestampView {
created_at: string
scheduled_at?: string | null
started_at?: string | null
updated_at: string
}
export interface IntegrationContentView {
content_hash: string
metadata?: Record<string, unknown> | null
request_summary?: Record<string, unknown> | null
response_summary?: Record<string, unknown> | null
}
export interface IntegrationFidelityView {
attempt_sequence_reliable: boolean
correlation_available: boolean
provider_message_fidelity: string
resource_id_available: boolean
trigger_series_available: boolean
}
export interface IntegrationAttemptView {
attempt: number
created_at: string
duration_ms: number
integration_id: string
operation: string
operation_name: string
result: IntegrationResult
result_category: IntegrationResultCategory
result_name: string
sent: boolean
state_changed: boolean
}
export interface IntegrationListItem {
integration_id: string
provider: IntegrationProvider
provider_name: string
direction: IntegrationDirection
direction_name: string
operation: string
operation_name: string
result: IntegrationResult
result_name: string
result_category: IntegrationResultCategory
state_changed: boolean
duration_ms?: number | null
request_id?: string | null
correlation_id?: string | null
created_at: string
resource?: IntegrationResourceView | null
}
export type IntegrationListPage = AuditPage<IntegrationListItem>
export interface IntegrationOverview {
total: number
anomaly_count: number
stale_pending_count: number
state_changed_count: number
unknown_count: number
average_duration_ms: number
p95_duration_ms: number
providers: AuditNamedCount[]
directions: AuditNamedCount[]
results: Array<AuditNamedCount & { category: IntegrationResultCategory }>
trend: Array<Record<string, string | number>>
retention: AuditRetentionInfo
}
export interface IntegrationDetailResponse {
identity: IntegrationIdentityView
result: IntegrationResultView
resource?: IntegrationResourceView | null
linkage: IntegrationLinkageView
trigger: IntegrationTriggerView
timestamps: IntegrationTimestampView
content: IntegrationContentView
fidelity: IntegrationFidelityView
attempts: IntegrationAttemptView[]
retention: AuditRetentionInfo
}
export interface AuditSubjectActivityQuery extends PaginationParams {
created_from?: string
created_to?: string
}
export interface AuditSubjectResourceSummary {
resource_type?: string
identifier?: string
display_name?: string
[key: string]: unknown
}
export interface AuditSubjectActivity {
action_code: string
action_name: string
occurred_at: string
result: AuditResult
subject_summary: string
subject_data?: Record<string, unknown> | null
related_resources?: AuditSubjectResourceSummary[]
}
export interface AuditSubjectActivityPage extends AuditPage<AuditSubjectActivity> {
resource: AuditSubjectResourceSummary
}

View File

@@ -0,0 +1,91 @@
import type { BaseResponse } from './common'
export type BulkPurchasePaymentMethod = 'wallet' | 'offline'
export interface BulkPurchaseCreateRequest {
file_key: string
package_id: number
payment_method: BulkPurchasePaymentMethod
voucher_keys?: string[]
}
export enum BulkPurchaseTaskStatus {
PENDING = 1,
PROCESSING = 2,
COMPLETED = 3,
FAILED = 4,
CANCELED = 5
}
export interface BulkPurchaseCreateResponse {
id?: number
task_id?: number
task_no?: string
status?: BulkPurchaseTaskStatus
status_name?: string
message?: string
}
export interface BulkPurchaseTask {
id: number
task_id?: number
task_no: string
file_name: string
package_id: number
package_code: string
package_name: string
payment_method: BulkPurchasePaymentMethod
status: BulkPurchaseTaskStatus
status_name: string
total_count: number
success_count: number
fail_count: number
failed_count?: number
total_amount?: number
success_amount?: number
failed_amount?: number
error_summary?: string
error_message: string
creator_name: string
voucher_keys: string[] | null
started_at: string | null
completed_at: string | null
created_at: string
updated_at: string
items?: BulkPurchaseItem[]
}
export interface BulkPurchaseItem {
line: number
asset_identifier: string
package_code?: string
status: BulkPurchaseTaskStatus
status_name: string
order_id: number
order_no: string
amount: number
reason: string
iccid?: string
virtual_no?: string
error_reason?: string
error_summary?: string
}
export interface BulkPurchaseItemsResponse {
items: BulkPurchaseItem[]
page?: number
size?: number
page_size?: number
total?: number
}
export type BulkPurchaseCreateApiResponse = BaseResponse<BulkPurchaseCreateResponse>
export type BulkPurchaseTaskApiResponse = BaseResponse<BulkPurchaseTask>
export interface BulkPurchaseTaskListResponse {
items: BulkPurchaseTask[]
page: number
size: number
total: number
}
export type BulkPurchaseTaskListApiResponse = BaseResponse<BulkPurchaseTaskListResponse>

View File

@@ -3,6 +3,17 @@
*/
import { PaginationParams, ImportTask } from './common'
import type { ExpiryEstimateStatus } from './asset'
export type SpeedTierCode = -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
export interface SetSpeedTierResponse {
code: SpeedTierCode
iccid: string
integration_id: string
iot_card_id: number
speed_tier_name: string
}
// 运营商类型
export enum Operator {
@@ -346,6 +357,7 @@ export enum StandaloneCardStatus {
export interface StandaloneCardQueryParams extends PaginationParams {
keyword?: string // 通用关键字搜索
has_active_package?: boolean // has active package filter
real_name_status?: 0 | 1 // 实名状态0 未实名 / 1 已实名
carrier_name?: string // carrier name filter
is_standalone?: boolean // standalone filter
status?: StandaloneCardStatus // 状态
@@ -384,7 +396,8 @@ export interface StandaloneIotCard {
status: StandaloneCardStatus // 状态
activation_status: number // 激活状态 (0:未激活, 1:已激活)
network_status: number // 网络状态 (0:停机, 1:开机)
real_name_status: number // 实名状态 (0:未实名, 1:已实名)
real_name_status: 0 | 1 // 实名状态 (0:未实名, 1:已实名)
real_name_status_name: string // 实名状态名称
generation?: number // 资产世代编号初始值1每次换货转新后+1
asset_status?: AssetStatus // 业务状态 (1:在库, 2:已销售, 3:已换货, 4:已停用)
asset_status_name?: string // 业务状态名称(中文)
@@ -397,6 +410,14 @@ export interface StandaloneIotCard {
data_usage_mb: number // 累计流量使用(MB)
current_month_usage_mb?: number // 自然月累计流量(MB)
current_month_start_date?: string | null // 本月开始日期
estimated_final_expires_at?: string | null // 当前及排队主套餐接续后的预计最终到期时间
days_until_final_expiry?: number | null // 距预计最终到期的剩余天数
expiry_estimate_status?: ExpiryEstimateStatus // 预计最终到期时间状态
expiry_estimate_status_name?: string | null // 预计最终到期时间状态名称
is_expiring?: boolean // 是否临期,由后端判断
expiry_level?: string | null // 临期等级
expiry_level_name?: string | null // 临期等级名称
can_renew?: boolean // 是否允许续费
last_month_total_mb?: number // 上月流量总量(MB)
last_data_check_at?: string | null // 最后流量检查时间
last_real_name_check_at?: string | null // 最后实名检查时间

View File

@@ -164,6 +164,7 @@ export interface MyCommissionSummary {
frozen_commission: number // 冻结中佣金(分)
withdrawing_commission: number // 提现中佣金(分)
withdrawn_commission: number // 已提现佣金(分)
unwithdraw_commission?: number // 未提现佣金(分)
}
/**
@@ -211,6 +212,14 @@ export interface ShopFundSummaryItem {
phone?: string // 主账号手机号
main_balance: number // 预充值余额(分)
main_frozen_balance: number // 预充值冻结余额(分)
cash_available_balance: number // 现金可用余额(分)
low_balance_warning?: boolean // 现金可用余额不足100元预警
credit_enabled: boolean // 实际信用额度是否启用
credit_limit: number // 实际信用额度(分)
available_balance: number // 可用金额(分),以后端返回为准
is_in_debt: boolean // 是否欠款,以后端返回为准
debt_amount: number // 欠款金额(分),以后端返回为准
version: number // 资金概况版本,用于并发控制
total_commission: number // 总佣金(分)
available_commission: number // 可提现佣金(分)
frozen_commission: number // 冻结中佣金(分)
@@ -229,6 +238,7 @@ export type ShopCommissionSummaryItem = ShopFundSummaryItem
* 代理商资金汇总查询参数(原佣金汇总查询参数)
*/
export interface ShopFundSummaryQueryParams extends PaginationParams {
shop_id?: number // 店铺ID
shop_name?: string // 店铺名称
username?: string // 用户名
}

View File

@@ -3,6 +3,7 @@
*/
import { PaginationParams } from './common'
import type { AssetRealnamePolicy, ExpiryEstimateStatus } from './asset'
// ========== 设备状态枚举 ==========
@@ -64,7 +65,17 @@ export interface Device {
last_gateway_sync_at?: string | null // 最后 sync-info 同步时间
imei?: string // IMEI设备国际移动设备识别码
sn?: string // 设备序列号
realname_policy?: string // 实名认证策略
realname_policy?: AssetRealnamePolicy // 实名认证策略
real_name_status: 0 | 1 // 实名状态 (0:未实名, 1:已实名)
real_name_status_name: string // 实名状态名称
estimated_final_expires_at?: string | null // 当前及排队主套餐接续后的预计最终到期时间
days_until_final_expiry?: number | null // 距预计最终到期的剩余天数
expiry_estimate_status?: ExpiryEstimateStatus // 预计最终到期时间状态
expiry_estimate_status_name?: string | null // 预计最终到期时间状态名称
is_expiring?: boolean // 是否临期,由后端判断
expiry_level?: string | null // 临期等级
expiry_level_name?: string | null // 临期等级名称
can_renew?: boolean // 是否允许续费
authorized_enterprise_id?: number | null // 当前有效授权企业ID
authorized_enterprise_name?: string | null // 当前有效授权企业名称
}
@@ -72,6 +83,7 @@ export interface Device {
// 设备查询参数
export interface DeviceQueryParams extends PaginationParams {
has_active_package?: boolean // has active package filter
real_name_status?: 0 | 1 // 实名状态0 未实名 / 1 已实名
virtual_no?: string // 虚拟号(模糊查询,原 device_no)
device_name?: string // 设备名称(模糊查询)
status?: DeviceStatus // 状态
@@ -203,8 +215,11 @@ export enum DeviceImportTaskStatus {
}
// 导入任务查询参数
export type DeviceImportTaskOperationType = 'import' | 'assign_shop' | 'assign_series' | 'recall'
export interface DeviceImportTaskQueryParams extends PaginationParams {
status?: DeviceImportTaskStatus // 任务状态
operation_type?: DeviceImportTaskOperationType
batch_no?: string // 批次号(模糊查询)
start_time?: string // 创建时间起始
end_time?: string // 创建时间结束
@@ -219,12 +234,19 @@ export interface DeviceImportTask {
file_key?: string // 原始导入文件存储键
creator_name?: string // 创建人姓名
status: DeviceImportTaskStatus // 任务状态
status_name?: string // 任务状态名称
status_text: string // 任务状态文本
total_count: number // 总数
success_count: number // 成功数
fail_count: number // 失败数
skip_count: number // 跳过数
error_message: string // 错误信息
operation_type: DeviceImportTaskOperationType
operation_name: string
target_id: number | null
target_name?: string | null
realname_policy: 'none' | 'before_order' | 'after_order'
warning_count: number
created_at: string // 创建时间
started_at: string | null // 开始处理时间
completed_at: string | null // 完成时间
@@ -232,17 +254,17 @@ export interface DeviceImportTask {
// 导入任务列表响应
export interface DeviceImportTaskListResponse {
list: DeviceImportTask[] | null // 任务列表
items: DeviceImportTask[] // 任务列表
page: number // 当前页码
page_size: number // 每页数量
size: number // 每页数量
total: number // 总数
total_pages: number // 总页数
}
// 导入结果详细项
export interface DeviceImportResultItem {
line: number // 行号
virtual_no: string // 虚拟号(原 device_no
device_identifier: string // CSV 原始设备标识
virtual_no: string // 设备虚拟号
reason: string // 原因
}
@@ -250,6 +272,19 @@ export interface DeviceImportResultItem {
export interface DeviceImportTaskDetail extends DeviceImportTask {
failed_items: DeviceImportResultItem[] | null // 失败记录详情
skipped_items: DeviceImportResultItem[] | null // 跳过记录详情
warning_items: DeviceImportResultItem[] | null // 警告记录详情
}
export interface DeviceBatchAllocationRequest {
file_key: string
operation_type: Exclude<DeviceImportTaskOperationType, 'import'>
target_id?: number
}
export interface DeviceBatchAllocationResponse {
message: string
task_id: number
task_no: string
}
// ========== 批量设置设备的套餐系列绑定相关 ==========

View File

@@ -1,6 +1,14 @@
import type { BaseResponse, PaginationParams } from './common'
export type ExportTaskScene = 'device' | 'iot_card' | 'order'
export type ExportTaskScene =
| 'device'
| 'iot_card'
| 'order'
| 'package'
| 'agent_wallet_transaction'
| 'agent_recharge'
| 'refund'
| 'exchange'
export type ExportTaskFormat = 'xlsx' | 'csv'
@@ -21,23 +29,34 @@ export interface ExportTaskQueryParams extends PaginationParams {
export interface ExportTaskItem {
id: number
task_id?: number
task_no: string
scene: ExportTaskScene
status: ExportTaskStatus
status_name: string
progress: number
status_name?: string
progress?: number
format: ExportTaskFormat
total_rows: number
processed_rows: number
total_shards: number
success_shards: number
failed_shards: number
file_key: string
error_message: string
cancel_requested: boolean
total_rows?: number
processed_rows?: number
total_shards?: number
success_shards?: number
failed_shards?: number
file_key?: string
error_message?: string
cancel_requested?: boolean
total_count?: number
success_count?: number
failed_count?: number
error_code?: string
error_summary?: string
updated_at?: string
created_at: string
started_at: string
completed_at: string
started_at?: string
completed_at?: string
creator_enterprise_id?: number | null
creator_shop_id?: number | null
creator_user_id?: number
creator_user_type?: number
}
export interface ExportTaskListResponse {
@@ -50,7 +69,7 @@ export interface ExportTaskListResponse {
export interface CreateExportTaskRequest {
scene: ExportTaskScene
format: ExportTaskFormat
query?: Record<string, unknown>
query?: Record<string, string>
}
export interface CreateExportTaskResponse {
@@ -66,15 +85,6 @@ export interface ExportTaskDetail extends ExportTaskItem {
download_expires_at?: string
}
export interface CancelExportTaskResponse {
task_id: number
status: ExportTaskStatus
status_name: string
cancel_requested: boolean
message: string
}
export type ExportTaskListApiResponse = BaseResponse<ExportTaskListResponse>
export type CreateExportTaskApiResponse = BaseResponse<CreateExportTaskResponse>
export type ExportTaskDetailApiResponse = BaseResponse<ExportTaskDetail>
export type CancelExportTaskApiResponse = BaseResponse<CancelExportTaskResponse>

View File

@@ -78,6 +78,9 @@ export * from './agentRecharge'
// 支付设置相关
export * from './paymentSettings'
// 系统配置相关
export * from './systemConfig'
// 退款管理相关
export * from './refund'
@@ -111,5 +114,20 @@ export * from './pollingMonitor'
// 导出任务相关
export * from './exportTask'
// 通用异步任务相关
export * from './asyncTask'
// 订单套餐批量作废任务相关
export * from './orderPackageInvalidateTask'
// 批量订购任务相关
export * from './bulkPurchase'
// 站内通知相关
export * from './notification'
// 审计链路与调查中心
export * from './audit'
// 企业微信审批配置相关
export * from './wecom'

View File

@@ -0,0 +1,85 @@
export type NotificationCategory = 'approval' | 'expiry' | 'sync' | 'system' | string
export type NotificationSeverity = 'info' | 'warning' | 'error' | 'critical' | string
/** 后台通知引用的受控资源类型。 */
export type NotificationRefType =
| 'system_config'
| 'integration_log'
| 'package'
| 'asset'
| 'refund'
| 'agent_recharge'
| 'wecom_approval'
| 'iot_card'
| 'device'
| 'expiring_asset'
| 'shop_fund'
| 'card_sync'
| (string & {})
export interface NotificationItem {
id: number
title: string
body: string
category: NotificationCategory
type: string
severity: NotificationSeverity
is_read: boolean
created_at: string
read_at: string | null
ref_type: NotificationRefType | null
ref_id: string | null
ref_key: string | null
}
export interface NotificationQueryParams {
category?: string
type?: string
severity?: string
is_read?: boolean
page?: number
page_size?: number
}
export interface NotificationListResponse {
items: NotificationItem[]
page: number
size: number
total: number
}
export interface NotificationUnreadCount {
count: number
display_count: string
}
export interface NotificationUnreadSummary {
approval: number
expiry: number
sync: number
system: number
total: number
}
export interface NotificationReadResponse {
success: boolean
}
export interface NotificationReadRequest {
id: number
}
export interface NotificationReadAllRequest {
category?: string
}
export interface NotificationReadAllResponse {
updated_count: number
}
export interface NotificationTarget {
available: boolean
target_id: number | string | null
target_key: string
target_type: string
}

View File

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

View File

@@ -101,10 +101,57 @@ export interface CommissionTierInfo {
*/
export type ExpiryBase = 'from_activation' | 'from_purchase'
// 套餐分配生效条件覆盖值
export type PackageAllocationExpiryBaseOverride = 'from_purchase' | 'from_activation'
// 店铺套餐分配生效条件字段
export interface PackageAllocationExpiryBaseFields {
default_expiry_base?: string | null
default_expiry_base_name?: string | null
expiry_base_override?: PackageAllocationExpiryBaseOverride | null
expiry_base_override_name?: string | null
effective_expiry_base?: string | null
effective_expiry_base_name?: string | null
}
// 创建店铺套餐分配请求
export interface CreateShopPackageAllocationRequest {
shop_id: number
package_id: number
cost_price: number
expiry_base_override: PackageAllocationExpiryBaseOverride | null
}
// 更新店铺套餐分配生效条件请求
export interface UpdateShopPackageAllocationExpiryBaseRequest {
expiry_base_override: PackageAllocationExpiryBaseOverride | null
}
// 店铺套餐分配响应
export interface ShopPackageAllocationResponse extends PackageAllocationExpiryBaseFields {
id: number
shop_id: number
package_id: number
cost_price: number
}
// 批量创建店铺套餐分配请求
export interface CreateShopPackageBatchAllocationsRequest {
shop_id: number
package_ids: number[]
cost_price?: number
expiry_base_override: PackageAllocationExpiryBaseOverride | null
}
// 批量创建店铺套餐分配响应
export interface ShopPackageBatchAllocationsResponse {
allocations: ShopPackageAllocationResponse[]
}
/**
* 套餐响应
*/
export interface PackageResponse {
export interface PackageResponse extends PackageAllocationExpiryBaseFields {
id: number
package_code: string
package_name: string
@@ -124,7 +171,7 @@ export interface PackageResponse {
suggested_retail_price?: number | null // 建议零售价(分),可能为 null 表示"未配置"
retail_price?: number | null // 原始代理零售价(分),可能为 null
is_gift?: boolean // 是否为赠送套餐
price_config_status?: string // 价格配置状态
price_config_status?: number // 价格配置状态0未配置1赠送0价2已配置非0
price_config_status_name?: string // 价格配置状态名称
effective_retail_price?: number | null // 有效零售价(分)
retail_price_config_status?: string // 零售价配置状态(代理视角)
@@ -133,7 +180,7 @@ export interface PackageResponse {
profit_margin?: number | null // 利润空间(分,仅代理用户可见)
tier_info?: CommissionTierInfo // 返佣档位信息
shelf_status?: number // 上架状态 (1:上架, 2:下架)
status?: number // 状态 (1:启用, 2:禁用)
status?: number // 状态 (0:禁用, 1:启用)
description?: string
created_at?: string
updated_at?: string
@@ -146,7 +193,6 @@ export interface PackageQueryParams extends PaginationParams {
package_name?: string // 套餐名称(模糊搜索)
series_id?: number // 系列ID
package_type?: string // 套餐类型
data_type?: string // 流量类型
shelf_status?: number // 上架状态
status?: number // 状态
}
@@ -197,7 +243,7 @@ export interface UpdatePackageRequest {
* 更新套餐状态请求
*/
export interface UpdatePackageStatusRequest {
status: number // 1:启用, 2:禁
status: number // 0:禁用, 1:启用
}
/**
@@ -234,8 +280,9 @@ export interface CommissionTier {
/**
* 套餐信息(用于系列授权)
*/
export interface GrantPackageInfo {
export interface GrantPackageInfo extends PackageAllocationExpiryBaseFields {
package_id: number
allocation_id: number // 套餐分配记录 ID用于修改生效条件
package_name?: string
package_code?: string
cost_price: number // 成本价(分)
@@ -278,6 +325,60 @@ export interface ShopSeriesGrantQueryParams extends PaginationParams {
status?: number // 状态筛选
}
/**
* 代理系列授权套餐候选项
*/
export interface ShopSeriesGrantPackageOption {
authorized: boolean
id: number
package_code: string
package_name: string
package_type: 'formal' | 'addon'
series_id: number | null
series_name: string | null
is_gift: boolean
status: number
status_name: string
shelf_status: number
shelf_status_name: string
calendar_type: 'natural_month' | 'by_day'
duration_days: number | null
duration_months: number
real_data_mb: number
virtual_data_mb: number
enable_virtual_data: boolean
virtual_ratio: number
data_reset_cycle: string
cost_price: number
retail_price: number | null
suggested_retail_price: number | null
effective_retail_price: number | null
expiry_base: string
expiry_base_override: PackageAllocationExpiryBaseOverride | null
expiry_base_override_name: string
default_expiry_base: string
default_expiry_base_name: string
effective_expiry_base: string
effective_expiry_base_name: string
price_config_status: number
price_config_status_name: string
retail_price_config_status: number | null
current_commission_rate: string
profit_margin: number | null
one_time_commission_amount: number | null
tier_info?: {
current_rate?: string
next_rate?: string
next_threshold?: number | null
}
created_at: string
updated_at: string
}
export interface ShopSeriesGrantPackageOptionsResponse {
items: ShopSeriesGrantPackageOption[] | null
}
/**
* 创建代理系列授权请求
*/
@@ -288,7 +389,8 @@ export interface CreateShopSeriesGrantRequest {
commission_tiers?: CommissionTier[] // 梯度配置列表,梯度模式时必填
enable_force_recharge?: boolean // 是否启用强充
force_recharge_amount?: number // 强充金额(分)
packages?: GrantPackageInfo[] // 套餐列表
packages: GrantPackageItem[] // 初始套餐列表1100项
expiry_base_override?: PackageAllocationExpiryBaseOverride | null
}
/**
@@ -305,8 +407,9 @@ export interface UpdateShopSeriesGrantRequest {
* 管理套餐请求中的套餐项
*/
export interface GrantPackageItem {
package_id?: number // 套餐ID
package_id: number // 套餐ID
cost_price?: number // 成本价(分)
expiry_base_override?: PackageAllocationExpiryBaseOverride | null
remove?: boolean | null // 是否删除该套餐授权true=删除)
}
@@ -314,5 +417,6 @@ export interface GrantPackageItem {
* 管理套餐请求
*/
export interface ManageGrantPackagesRequest {
packages?: GrantPackageItem[] | null // 套餐操作列表
packages: GrantPackageItem[] // 套餐操作列表1100项
expiry_base_override?: PackageAllocationExpiryBaseOverride | null // 新增套餐统一生效条件
}

View File

@@ -18,7 +18,8 @@ export enum PermissionStatus {
// 权限实体(匹配后端 ModelPermission
export interface Permission {
ID: number
id?: number
ID?: number
CreatedAt?: string
UpdatedAt?: string
DeletedAt?: string | null

View File

@@ -10,6 +10,38 @@ export enum RefundStatus {
RETURNED = 4 // 已退回
}
export interface RefundAttachment {
file_key: string
file_name?: string
file_size?: number
}
export interface RefundApprovalTimelineItem {
time?: string
timestamp?: string
status?: string
status_name?: string
operator?: string
operator_name?: string
comment?: string
content?: string
[key: string]: unknown
}
export interface RefundApproval {
source?: string | null
sp_no?: string | null
status?: string | null
status_name?: string | null
template_version?: string | null
applicant?: unknown
approvers?: unknown[]
comments?: unknown[]
attachments?: RefundAttachment[]
timeline?: RefundApprovalTimelineItem[]
business_process_result?: unknown
}
// 退款申请
export interface Refund {
id: number
@@ -25,12 +57,28 @@ export interface Refund {
approved_refund_amount?: number | null // 实际退款金额(分)
actual_received_amount?: number | null // 实收金额(分)
status: RefundStatus
status_name?: string | null
refund_reason: string
refund_voucher_key?: string[] | string // 退款凭证附件列表;历史数据可能为单字符串或逗号字符串
attachments?: RefundAttachment[]
reject_reason: string
remark: string
asset_reset: boolean
commission_deducted: boolean
submitter_name?: string | null // 提交人名称
submitter_id?: number | null
approval_provider?: 'wecom' | null
approval_instance_id?: number | null
approval_source?: 'none' | 'legacy' | 'wecom' | null // 审批来源
approval_status?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | null // 审批状态
approval_status_name?: string | null // 审批状态名称
current_approver_summary?: string | null // 当前审批人摘要
processing_status?: string | null // 业务处理状态
processing_status_name?: string | null // 业务处理状态名称
processing_failure_summary?: string | null
processing_message?: string | null
business_process_result?: unknown
approval?: RefundApproval | null
creator: number
processor_id: number | null
processed_at: string | null
@@ -64,30 +112,15 @@ export interface CreateRefundRequest {
order_id: number
requested_refund_amount: number // 申请退款金额(分)
actual_received_amount: number // 实收金额(分)
refund_voucher_key: string[] // 退款凭证附件列表
refund_reason?: string
}
// 审批通过退款申请请求
export interface ApproveRefundRequest {
approved_refund_amount?: number // 实际退款金额(分)
remark?: string
}
// 审批拒绝退款申请请求
export interface RejectRefundRequest {
reject_reason: string
package_usage_id: number // 关联套餐使用记录 ID无关联记录时传 0
refund_reason: string
refund_voucher_key: string[]
}
// 重新提交退款申请请求
export interface ResubmitRefundRequest {
requested_refund_amount?: number
actual_received_amount?: number
refund_voucher_key?: string[]
attachments?: RefundAttachment[]
refund_reason?: string
}
// 退回退款申请请求
export interface ReturnRefundRequest {
remark?: string
}

View File

@@ -18,7 +18,8 @@ export enum RoleStatus {
// 角色实体(匹配后端 ModelRole
export interface PlatformRole {
ID: number
id?: number
ID?: number
CreatedAt?: string
UpdatedAt?: string
DeletedAt?: string | null
@@ -28,6 +29,21 @@ export interface PlatformRole {
role_desc: string // 角色描述
role_type: RoleType // 角色类型
status: RoleStatus // 状态
credit_enabled?: boolean // 新建代理默认信用是否启用(仅客户角色)
credit_limit?: number // 新建代理默认信用额度(分,仅客户角色)
default_credit_enabled?: boolean // 接口返回的新建代理默认信用状态
default_credit_limit?: number // 接口返回的新建代理默认信用额度(分)
default_credit_scope?: string // 接口返回的信用模板生效范围
created_at?: string // 接口返回的创建时间
updated_at?: string // 接口返回的更新时间
}
export interface UpdateRoleDefaultCreditResponse {
affects_existing_wallets: boolean
credit_enabled: boolean
credit_limit: number
role_id: number
scope: string
}
// 角色查询参数
@@ -44,3 +60,9 @@ export interface PlatformRoleFormData {
role_type: RoleType
status: RoleStatus
}
// 更新客户角色默认信用参数
export interface UpdateRoleDefaultCreditRequest {
credit_enabled: boolean
credit_limit: number
}

View File

@@ -21,6 +21,12 @@ export interface ShopResponse {
contact_name: string // 联系人姓名
contact_phone: string // 联系人电话
status: number // 状态 (0:禁用, 1:启用)
status_name?: string // 状态名称
business_owner_account_id: number | null // 平台业务员账号ID
business_owner_username: string // 平台业务员账号名
business_owner_phone_summary: string // 平台业务员手机号摘要
business_owner_available: boolean // 是否仍可用于通知接收
client_login_disabled: boolean // 是否禁止 C 端新登录
}
// 店铺列表查询参数
@@ -32,6 +38,9 @@ export interface ShopQueryParams extends PaginationParams {
parent_id?: number | null // 上级店铺ID
level?: number | null // 店铺层级 (1-7级)
status?: number | null // 状态 (0:禁用, 1:启用)
contact_phone?: string // 联系电话精确查询
business_owner_account_id?: number | null // 平台业务员账号ID
client_login_disabled?: boolean
page?: number // 页码
page_size?: number // 每页数量
}
@@ -43,6 +52,7 @@ export interface CreateShopParams {
init_username: string // 初始账号用户名(必填)
init_password: string // 初始账号密码(必填)
init_phone: string // 初始账号手机号(必填)
default_role_id: number // 初始账号默认客户角色(必填)
parent_id?: number | null // 上级店铺ID一级店铺可不填
province?: string // 省份
city?: string // 城市
@@ -50,6 +60,8 @@ export interface CreateShopParams {
address?: string // 详细地址
contact_name?: string // 联系人姓名
contact_phone?: string // 联系人电话
business_owner_account_id?: number | null // 平台业务员账号ID
client_login_disabled?: boolean
}
// 更新店铺参数
@@ -62,6 +74,20 @@ export interface UpdateShopParams {
address?: string // 详细地址
contact_name?: string // 联系人姓名
contact_phone?: string // 联系人电话
business_owner_account_id?: number | null // 平台业务员账号ID
client_login_disabled?: boolean
}
// 店铺业务员候选
export interface ShopBusinessOwnerCandidate {
id: number // 平台业务员账号ID
username: string // 平台业务员账号名
phone_summary: string // 手机号摘要
}
// 店铺业务员候选查询参数
export interface ShopBusinessOwnerCandidateQueryParams extends PaginationParams {
keyword?: string // 用户名或手机号关键词
}
// 店铺列表分页响应
@@ -94,3 +120,22 @@ export interface ShopRolesResponse {
export interface AssignShopRolesRequest {
role_ids: number[] | null // 角色ID列表
}
// 更新店铺实际信用额度请求
export interface UpdateShopCreditLimitRequest {
credit_enabled: boolean // 是否启用实际信用额度
credit_limit: number // 实际信用额度(分)
version: number // 资金概况版本,用于并发控制
}
// 更新店铺实际信用额度响应
export interface UpdateShopCreditLimitResponse {
available_balance: number // 总可用金额(分)
balance: number // 账面余额(分)
credit_enabled: boolean // 是否启用实际信用额度
credit_limit: number // 实际信用额度(分)
frozen_balance: number // 冻结金额(分)
shop_id: number // 店铺 ID
version: number // 更新后的钱包版本
wallet_id: number // 主钱包 ID
}

View File

@@ -0,0 +1,56 @@
import type { PaginationParams } from './common'
export type SystemConfigModule = 'carrier_callback' | 'c2b.payment'
export type AllowedPaymentMethod = 'wallet' | 'wechat' | 'alipay'
export const PAYMENT_CONFIG_KEYS = {
card: 'c2b.payment.card_allowed_methods',
device: 'c2b.payment.device_allowed_methods'
} as const
export type SystemConfigValueType = 'string' | 'int' | 'bool' | 'json'
export interface SystemConfigItem {
config_key: string
module: SystemConfigModule
value: string
value_type: SystemConfigValueType
description: string
control: string
enum_values?: string[]
min: number | null
max: number | null
readonly: boolean
registered: boolean
sensitive: boolean
updated_at: string | null
}
export interface SystemConfigQueryParams extends PaginationParams {
module?: SystemConfigModule
}
export interface SystemConfigPageResult {
list: SystemConfigItem[]
page: number
page_size: number
total: number
}
export interface UpdateSystemConfigRequest {
key: string
value: string
}
export function parsePaymentMethods(value: string): AllowedPaymentMethod[] {
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed)
? parsed.filter((method): method is AllowedPaymentMethod =>
['wallet', 'wechat', 'alipay'].includes(method)
)
: []
} catch {
return []
}
}

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

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

View File

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

View File

@@ -68,8 +68,15 @@ declare module 'vue' {
ArtWangEditor: typeof import('./../components/core/forms/ArtWangEditor.vue')['default']
ArtWatermark: typeof import('./../components/core/others/ArtWatermark.vue')['default']
ArtWorkTab: typeof import('./../components/core/layouts/art-work-tab/index.vue')['default']
AuditDistributionChart: typeof import('./../components/business/audit/AuditDistributionChart.vue')['default']
AuditEventTable: typeof import('./../components/business/audit/AuditEventTable.vue')['default']
AuditInvestigationDrawer: typeof import('./../components/business/audit/AuditInvestigationDrawer.vue')['default']
AuditInvestigationHost: typeof import('./../components/business/audit/AuditInvestigationHost.vue')['default']
AuditInvestigationLinks: typeof import('./../components/business/audit/AuditInvestigationLinks.vue')['default']
AuditResourceSearchDialog: typeof import('./../components/business/audit/AuditResourceSearchDialog.vue')['default']
BasicSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/BasicSettings.vue')['default']
BatchOperationDialog: typeof import('./../components/business/BatchOperationDialog.vue')['default']
BatchRealnamePolicyDialog: typeof import('./../components/business/BatchRealnamePolicyDialog.vue')['default']
BoxStyleSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue')['default']
CardOperationDialog: typeof import('./../components/business/CardOperationDialog.vue')['default']
CardStatusTag: typeof import('./../components/business/CardStatusTag.vue')['default']
@@ -90,7 +97,10 @@ declare module 'vue' {
ElCard: typeof import('element-plus/es')['ElCard']
ElCascader: typeof import('element-plus/es')['ElCascader']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
@@ -138,7 +148,6 @@ declare module 'vue' {
LoginLeftView: typeof import('./../components/core/views/login/LoginLeftView.vue')['default']
MenuLayoutSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/MenuLayoutSettings.vue')['default']
MenuStyleSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/MenuStyleSettings.vue')['default']
OperationLogsDialog: typeof import('./../components/business/OperationLogsDialog.vue')['default']
OperatorSelect: typeof import('./../components/business/OperatorSelect.vue')['default']
PackageSelector: typeof import('./../components/business/PackageSelector.vue')['default']
PaymentVoucherDialog: typeof import('./../components/business/PaymentVoucherDialog.vue')['default']
@@ -146,9 +155,12 @@ declare module 'vue' {
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']
SeriesGrantPackageDialog: typeof import('./../components/business/SeriesGrantPackageDialog.vue')['default']
SeriesGrantPackageTable: typeof import('./../components/business/SeriesGrantPackageTable.vue')['default']
SettingDrawer: typeof import('./../components/core/layouts/art-settings-panel/widget/SettingDrawer.vue')['default']
SettingHeader: typeof import('./../components/core/layouts/art-settings-panel/widget/SettingHeader.vue')['default']
SettingItem: typeof import('./../components/core/layouts/art-settings-panel/widget/SettingItem.vue')['default']
ShopCreditLimitDialog: typeof import('./../components/business/ShopCreditLimitDialog.vue')['default']
SidebarSubmenu: typeof import('./../components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue')['default']
SpeedLimitDialog: typeof import('./../components/device/SpeedLimitDialog.vue')['default']
SwitchCardDialog: typeof import('./../components/device/SwitchCardDialog.vue')['default']

View File

@@ -34,6 +34,8 @@ export interface RouteMeta extends Record<string | number | symbol, unknown> {
isFirstLevel?: boolean
/** 角色权限 */
roles?: string[]
/** 页面访问权限,满足任意一个即可 */
permissions?: string[]
/** 导出任务固定场景 */
exportTaskScene?: ExportTaskScene
/** 是否固定标签页 */

View File

@@ -59,7 +59,7 @@ export const strongPasswordRules = (t: (key: string) => string): FormItemRule[]
trigger: 'blur'
},
{
validator: (rule: any, value: any, callback: any) => {
validator: (_rule: any, value: any, callback: any) => {
if (!value) {
callback()
return
@@ -93,7 +93,7 @@ export const confirmPasswordRules = (
trigger: 'blur'
},
{
validator: (rule: any, value: any, callback: any) => {
validator: (_rule: any, value: any, callback: any) => {
if (!value) {
callback()
return

View File

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

View File

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

137
src/utils/business/audit.ts Normal file
View File

@@ -0,0 +1,137 @@
import type {
AuditActorKind,
AuditCategory,
AuditResult,
AuditRiskLevel,
AuditScopeType,
AuditSource,
IntegrationResultCategory
} from '@/types/api'
export const auditCategoryLabels: Record<AuditCategory, string> = {
configuration: '配置',
reliability: '可靠性',
asset: '资产',
security: '安全',
identity: '身份',
business: '业务'
}
export const auditActorKindLabels: Record<AuditActorKind, string> = {
account: '人工账号',
personal_customer: '个人客户',
openapi: '开放接口账号',
system_task: '系统任务',
scheduled_job: '计划任务',
external_system: '外部系统'
}
export const auditScopeTypeLabels: Record<AuditScopeType, string> = {
platform: '平台',
shop: '店铺',
personal_customer: '个人客户'
}
export const auditSourceLabels: Record<AuditSource, string> = {
admin_api: '后台管理 API',
personal_api: '个人客户 API',
openapi: '代理 OpenAPI',
worker: '异步 Worker',
scheduler: '计划任务',
callback: '外部系统回调'
}
export const auditResourceRelationLabels: Record<string, string> = {
primary: '主要资源',
affected: '受影响资源',
reference: '引用资源'
}
export const auditSubjectVisibilityLabels: Record<string, string> = {
internal_only: '仅平台可见',
subject_result: '主体可见结论',
subject_detail: '主体可见安全详情'
}
export const auditResourceTypeLabels: Record<string, string> = {
iot_card: 'IoT 卡',
device: '设备',
asset_allocation_record: '资产分配记录',
exchange_order: '换卡订单',
shop: '店铺',
enterprise: '企业',
order: '订单',
refund: '退款',
integration_log: '外部集成记录'
}
export const auditResultMeta: Record<
AuditResult,
{ label: string; type: 'success' | 'danger' | 'warning' | 'info' }
> = {
success: { label: '成功', type: 'success' },
failed: { label: '失败', type: 'danger' },
denied: { label: '拒绝', type: 'danger' },
partial: { label: '部分成功', type: 'warning' },
unknown: { label: '未知', type: 'info' }
}
const auditResultLabels: Record<string, string> = {
success: '成功',
failed: '失败',
denied: '拒绝',
partial: '部分成功',
unknown: '未知',
pending: '处理中',
not_found: '未找到',
invalid_payload: '无效载荷',
conflict: '冲突',
ignored: '已忽略',
merged: '已合并',
rate_limited: '已限流',
completed: '已完成',
cancelled: '已取消'
}
export function auditResultDisplay(result?: string | null, resultName?: string | null): string {
const name = resultName?.trim()
if (name) return name
return result ? auditResultLabels[result] || result : '-'
}
export const auditRiskMeta: Record<
AuditRiskLevel,
{ label: string; type: 'success' | 'danger' | 'warning' | 'info' }
> = {
low: { label: '低', type: 'success' },
normal: { label: '普通', type: 'info' },
high: { label: '高', type: 'warning' },
critical: { label: '严重', type: 'danger' }
}
export const integrationCategoryMeta: Record<
IntegrationResultCategory,
{ label: string; type: 'success' | 'danger' | 'warning' | 'info' }
> = {
processing: { label: '处理中', type: 'warning' },
succeeded: { label: '成功', type: 'success' },
indeterminate: { label: '结果不确定', type: 'warning' },
failed: { label: '失败', type: 'danger' },
not_sent: { label: '未发送', type: 'info' }
}
export function toRfc3339(value?: string | Date | null): string | undefined {
if (!value) return undefined
const date = value instanceof Date ? value : new Date(value)
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
}
export function fenDisplay(value?: number | null): string {
return typeof value === 'number' ? `¥${(value / 100).toFixed(2)}` : '-'
}
export function auditJson(value: unknown): string {
if (value === null || value === undefined) return '-'
if (typeof value === 'string') return value
return JSON.stringify(value, null, 2)
}

View File

@@ -0,0 +1,14 @@
import type { AuditInvestigationTarget } from '@/components/business/audit/types'
export const isPlatformAuditAccount = (userType?: number | null, isSuperAdmin = false) =>
isSuperAdmin || [1, 2].includes(Number(userType))
export const canLoadAuditInvestigation = (
target: AuditInvestigationTarget,
userType?: number | null,
isSuperAdmin = false
) => {
if (target.mode === 'agent') return Number(userType) === 3
if (target.mode === 'enterprise') return Number(userType) === 4
return isPlatformAuditAccount(userType, isSuperAdmin)
}

View File

@@ -0,0 +1,85 @@
import type { AuditSubjectResourceType } from '@/types/api'
import type { AuditFinanceField, AuditInvestigationTarget } from '@/components/business/audit/types'
interface AuditResourceRouteOptions {
userType?: number
resourceType: AuditSubjectResourceType | string
internalId?: string | number | null
businessIdentifier?: string | null
}
interface AuditReferenceOptions {
currentId?: string | null
fidelity?: boolean | null
}
/** 仅保留非空、可靠且不会回到当前目标的显式调查引用。 */
export function resolveInvestigationReference(
value?: string | null,
options: AuditReferenceOptions = {}
): string | null {
if (options.fidelity === false) return null
const normalizedValue = value?.trim()
if (!normalizedValue || normalizedValue === options.currentId?.trim()) return null
return normalizedValue
}
/** 根据当前主体选择平台内部 ID 或主体安全业务标识。 */
export function resolveAuditResourceTarget(
options: AuditResourceRouteOptions
): AuditInvestigationTarget | null {
const { userType, resourceType, internalId, businessIdentifier } = options
const normalizedBusinessIdentifier = businessIdentifier?.trim()
if (userType === 3) {
if (
!normalizedBusinessIdentifier ||
![
'iot_card',
'device',
'asset_allocation_record',
'exchange_order',
'shop',
'enterprise'
].includes(resourceType)
)
return null
return {
mode: 'agent',
resourceType: resourceType as AuditSubjectResourceType,
id: normalizedBusinessIdentifier
}
}
if (userType === 4) {
if (!normalizedBusinessIdentifier || !['iot_card', 'device'].includes(resourceType)) return null
return {
mode: 'enterprise',
resourceType: resourceType as AuditSubjectResourceType,
id: normalizedBusinessIdentifier
}
}
if (
internalId === undefined ||
internalId === null ||
internalId === '' ||
(typeof internalId === 'string' && !internalId.trim()) ||
(typeof internalId === 'number' && (!Number.isFinite(internalId) || internalId <= 0))
)
return null
return { mode: 'resource', resourceType, id: String(internalId).trim() }
}
export function resolveFinanceAuditTarget(
field: AuditFinanceField,
value?: string | number | null
): AuditInvestigationTarget | null {
const normalizedValue = typeof value === 'string' ? value.trim() : value
if (
normalizedValue === undefined ||
normalizedValue === null ||
normalizedValue === '' ||
(typeof normalizedValue === 'number' &&
(!Number.isFinite(normalizedValue) || normalizedValue <= 0))
)
return null
return { mode: 'finance', field, value: normalizedValue }
}

View File

@@ -0,0 +1,39 @@
import { formatDateTime } from './format'
import type { ExpiryEstimateStatus } from '@/types/api'
export interface ExpiryEstimate {
estimated_final_expires_at?: string | null
days_until_final_expiry?: number | null
expiry_estimate_status?: ExpiryEstimateStatus | null
expiry_estimate_status_name?: string | null
is_expiring?: boolean
}
export const getExpiryEstimateText = (estimate: ExpiryEstimate): string => {
switch (estimate.expiry_estimate_status) {
case 'exact':
return estimate.estimated_final_expires_at
? formatDateTime(estimate.estimated_final_expires_at)
: '-'
case 'waiting_activation':
return '待激活后起算'
case 'invalid_data':
return '数据异常'
case 'none':
default:
return '-'
}
}
export const getExpiryEstimateTooltip = (estimate: ExpiryEstimate): string => {
if (!estimate.is_expiring || estimate.days_until_final_expiry == null) return ''
return `剩余${estimate.days_until_final_expiry}`
}
export const getExpiryEstimateClass = (estimate: ExpiryEstimate): string => {
if (!estimate.is_expiring || estimate.days_until_final_expiry == null) return ''
if (estimate.days_until_final_expiry <= 3) return 'expiry-estimate--critical'
if (estimate.days_until_final_expiry <= 7) return 'expiry-estimate--warning'
if (estimate.days_until_final_expiry <= 15) return 'expiry-estimate--notice'
return ''
}

58
src/utils/business/id.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* 兼容后端实体 ID 字段的大小写差异。
* 后端历史接口可能返回 `ID`,新接口返回 `id`,前端统一优先使用小写 id。
*/
export type CompatibleId = number | string
export interface CompatibleIdFields {
id?: CompatibleId | null
ID?: CompatibleId | null
}
/** 从实体中读取兼容的 ID 字段。0 也是有效 ID不能使用 ||。 */
export const getCompatibleId = (value: CompatibleIdFields | null | undefined) => {
const id = value?.id ?? value?.ID
return id === null || id === undefined || id === '' ? undefined : id
}
/** 读取并转换为数字 ID供只接受 number 的接口参数使用。 */
export const getCompatibleNumericId = (
value: CompatibleIdFields | null | undefined
): number | undefined => {
const id = getCompatibleId(value)
if (id === undefined) return undefined
const numericId = Number(id)
return Number.isFinite(numericId) ? numericId : undefined
}
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (value === null || typeof value !== 'object') return false
const prototype = Object.getPrototypeOf(value)
return prototype === Object.prototype || prototype === null
}
/** 递归补齐响应中的 id/ID 别名,不影响 Blob、FormData、Date 等特殊数据。 */
export const syncCompatibleIdFields = <T>(value: T): T => {
if (Array.isArray(value)) {
value.forEach((item) => syncCompatibleIdFields(item))
return value
}
if (!isPlainObject(value)) return value
const record = value as Record<string, unknown>
const hasId = Object.prototype.hasOwnProperty.call(record, 'id')
const hasUpperId = Object.prototype.hasOwnProperty.call(record, 'ID')
if (hasId || hasUpperId) {
const id = record.id ?? record.ID
if (id !== null && id !== undefined && id !== '') {
if (!hasId) record.id = id
if (!hasUpperId) record.ID = id
}
}
Object.values(record).forEach((item) => syncCompatibleIdFields(item))
return value
}

View File

@@ -7,3 +7,6 @@ export * from './validate'
export * from './calculate'
export * from './voucher'
export * from './apiRateLimit'
export * from './approvalSummary'
export * from './expiryEstimate'
export * from './id'

View File

@@ -0,0 +1,62 @@
import type { RouteLocationRaw, Router } from 'vue-router'
import type { NotificationRefType, NotificationTarget } from '@/types/api'
import { RoutesAlias } from '@/router/routesAlias'
type TargetRoute = RouteLocationRaw
const resolveTargetRoute = (
refType: NotificationRefType | null,
target: NotificationTarget
): TargetRoute | null => {
const targetId = target.target_id === null ? '' : String(target.target_id)
const targetType = target.target_type || refType
if (targetType === 'integration_log') {
const integrationId = target.target_key?.trim()
return integrationId
? { path: `/audit/integrations/${encodeURIComponent(integrationId)}` }
: null
}
switch (targetType) {
case 'system_config':
return { path: RoutesAlias.SystemConfigs }
case 'refund':
return { path: RoutesAlias.RefundManagement }
case 'agent_recharge':
return { path: RoutesAlias.AgentRecharge }
case 'iot_card':
return targetId ? { path: RoutesAlias.AssetInformation, query: { iccid: targetId } } : null
case 'device':
return targetId
? { path: RoutesAlias.AssetInformation, query: { virtual_no: targetId } }
: null
case 'expiring_asset':
return { path: RoutesAlias.ExpiringAssets }
case 'shop_fund':
return { path: RoutesAlias.AgentFundOverview }
case 'package':
case 'asset':
case 'wecom_approval':
case 'card_sync':
default:
return null
}
}
export const navigateNotificationTarget = (
router: Router,
refType: NotificationRefType | null,
target?: NotificationTarget | null
) => {
if (!target) return false
// 目标本身不可用时由调用方提示通知正文,不应将用户带到 403 页面。
// 目标可用但进入页面后没有页面权限时,交由路由守卫处理 403。
if (!target.available) return false
const route = resolveTargetRoute(refType, target)
if (!route) return false
void router.push(route)
return true
}

View File

@@ -25,7 +25,10 @@ export const getPaymentMerchantId = (
}
export const getPaymentNotifyUrl = (
config: Pick<PaymentSettings, 'provider_type' | 'wx_notify_url' | 'fy_notify_url' | 'ali_notify_url'>
config: Pick<
PaymentSettings,
'provider_type' | 'wx_notify_url' | 'fy_notify_url' | 'ali_notify_url'
>
): string => {
if (config.provider_type === 'wechat' || config.provider_type === 'wechat_v2') {
return config.wx_notify_url || '-'

View File

@@ -0,0 +1,76 @@
import type {
GrantPackageInfo,
PackageAllocationExpiryBaseOverride,
PackageResponse
} from '@/types/api'
export type ExpiryBaseSelection = PackageAllocationExpiryBaseOverride | 'default'
export type PackageCostFormItem = {
package_id: number
package_name?: string
package_code?: string
cost_price_yuan: number
original_cost_price?: number
suggested_retail_price?: number
}
export type SeriesGrantPackageForm = {
package_id?: number
allocation_id?: number
package_ids: number[]
package_name?: string
package_code?: string
cost_price_yuan: number
original_cost_price?: number
suggested_retail_price?: number
expiry_base_override: ExpiryBaseSelection
initial_expiry_base_override: ExpiryBaseSelection
default_expiry_base_name?: string | null
expiry_base_override_name?: string | null
effective_expiry_base_name?: string | null
packages: PackageCostFormItem[]
}
export interface GrantPackageCandidate {
id: number
package_name?: string
package_code?: string
cost_price?: number
suggested_retail_price?: number | null
is_authorized: boolean
authorized_package?: GrantPackageInfo
}
export const mergeGrantPackageCandidates = (
candidates: PackageResponse[],
authorizedPackages: GrantPackageInfo[]
): GrantPackageCandidate[] => {
const merged = new Map<number, GrantPackageCandidate>()
candidates.forEach((candidate) => {
merged.set(candidate.id, {
id: candidate.id,
package_name: candidate.package_name,
package_code: candidate.package_code,
cost_price: candidate.cost_price,
suggested_retail_price: candidate.suggested_retail_price,
is_authorized: false
})
})
authorizedPackages.forEach((authorizedPackage) => {
const candidate = merged.get(authorizedPackage.package_id)
merged.set(authorizedPackage.package_id, {
id: authorizedPackage.package_id,
package_name: candidate?.package_name ?? authorizedPackage.package_name,
package_code: candidate?.package_code ?? authorizedPackage.package_code,
cost_price: authorizedPackage.cost_price ?? candidate?.cost_price,
suggested_retail_price: candidate?.suggested_retail_price,
is_authorized: true,
authorized_package: authorizedPackage
})
})
return [...merged.values()]
}

View File

@@ -3,6 +3,8 @@ import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/modules/user'
import { ApiStatus } from './status'
import type { RequestOptions, ErrorMessageMode } from '@/types/api'
import { normalizeApiError } from '@/utils/business/apiError'
import { syncCompatibleIdFields } from '@/utils/business/id'
const axiosInstance = axios.create({
timeout: 15000, // 请求超时时间(毫秒)
@@ -89,6 +91,9 @@ const processQueue = (error: any, token: string | null = null) => {
// 响应拦截器
axiosInstance.interceptors.response.use(
(response: AxiosResponse) => {
// 兼容历史接口返回的 ID 与新接口返回的 id。
syncCompatibleIdFields(response.data)
// 401 未授权 - 尝试刷新 token
if (response.data.code === ApiStatus.unauthorized) {
const userStore = useUserStore()
@@ -220,7 +225,8 @@ function handleErrorMessage(
) {
if (mode === 'none') return
const backendMessage = error.response?.data?.msg
const normalized = normalizeApiError(error)
const backendMessage = normalized.message
const httpStatus = error.response?.status
// 401 过期类错误由 clearLocalStateAndRedirect 统一提示,避免重复弹出后端报错。
@@ -245,7 +251,7 @@ function handleErrorMessage(
}
// 其他错误显示通用提示
const message = '请求超时或服务器异常!'
const message = normalized.message
if (mode === 'modal') {
// TODO: 可以使用 ElMessageBox 显示模态框
ElMessage.error(message)

View File

@@ -1,3 +1,4 @@
<!--suppress ALL -->
<template>
<ArtTableFullScreen>
<div class="account-page" id="table-full-screen">
@@ -82,9 +83,9 @@
>
<ElOption
v-for="role in availableRolesForCreate"
:key="role.ID"
:key="getRoleId(role)"
:label="role.role_name"
:value="role.ID"
:value="getRoleId(role)"
>
<div style="display: flex; align-items: center; justify-content: space-between">
<span>{{ role.role_name }}</span>
@@ -133,8 +134,15 @@
<ElEmpty v-if="filteredAvailableRoles.length === 0" description="暂无角色" />
<template v-else-if="isPlatformUser">
<ElCheckboxGroup v-model="rolesToAdd" class="role-list">
<div v-for="role in filteredAvailableRoles" :key="role.ID" class="role-item">
<ElCheckbox :label="role.ID" :disabled="selectedRoles.includes(role.ID)">
<div
v-for="role in filteredAvailableRoles"
:key="getRoleId(role)"
class="role-item"
>
<ElCheckbox
:label="getRoleId(role)"
:disabled="selectedRoles.includes(getRoleId(role))"
>
<span class="role-info">
<span>{{ role.role_name }}</span>
<ElTag :type="role.role_type === 1 ? 'primary' : 'success'" size="small">
@@ -147,8 +155,15 @@
</template>
<template v-else>
<ElRadioGroup v-model="roleToAdd" class="role-list">
<div v-for="role in filteredAvailableRoles" :key="role.ID" class="role-item">
<ElRadio :label="role.ID" :disabled="selectedRoles.includes(role.ID)">
<div
v-for="role in filteredAvailableRoles"
:key="getRoleId(role)"
class="role-item"
>
<ElRadio
:label="getRoleId(role)"
:disabled="selectedRoles.includes(getRoleId(role))"
>
<span class="role-info">
<span>{{ role.role_name }}</span>
<ElTag :type="role.role_type === 1 ? 'primary' : 'success'" size="small">
@@ -190,7 +205,7 @@
<div class="role-list">
<div
v-for="role in filteredAssignedRoles"
:key="role.ID"
:key="getRoleId(role)"
class="role-item assigned-role-item"
>
<span class="role-info">
@@ -199,7 +214,12 @@
{{ role.role_type === 1 ? '平台角色' : '客户角色' }}
</ElTag>
</span>
<ElButton type="danger" size="small" link @click="removeSingleRole(role.ID)">
<ElButton
type="danger"
size="small"
link
@click="removeSingleRole(getRoleId(role))"
>
移除
</ElButton>
</div>
@@ -213,6 +233,74 @@
</div>
</template>
</ElDialog>
<!-- 绑定企微账号对话框 -->
<ElDialog
v-model="wecomBindingDialogVisible"
title="绑定企微账号"
width="520px"
destroy-on-close
@closed="resetWecomBindingForm"
>
<ElForm
ref="wecomBindingFormRef"
:model="wecomBindingForm"
:rules="wecomBindingRules"
label-width="100px"
>
<ElFormItem label="账号名称">
<span>{{ currentWecomAccount?.username || '-' }}</span>
</ElFormItem>
<ElFormItem label="企微应用" prop="application_id">
<ElSelect
v-model="wecomBindingForm.application_id"
placeholder="请选择企微应用"
filterable
clearable
style="width: 100%"
:loading="wecomApplicationsLoading"
@change="handleWecomApplicationChange"
>
<ElOption
v-for="application in wecomApplications"
:key="application.id"
:label="application.name"
:value="application.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="企微成员" prop="userid">
<ElSelect
v-model="wecomBindingForm.userid"
placeholder="请先选择企微应用"
filterable
clearable
style="width: 100%"
:loading="wecomMembersLoading"
:disabled="!wecomBindingForm.application_id"
>
<ElOption
v-for="member in wecomMembers"
:key="member.userid"
:label="member.name"
:value="member.userid"
/>
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="wecomBindingDialogVisible = false">取消</ElButton>
<ElButton
type="primary"
:loading="wecomBindingSubmitting"
@click="submitWecomBinding"
>
确认绑定
</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
@@ -237,21 +325,34 @@
import { useAuth } from '@/composables/useAuth'
import { AccountService } from '@/api/modules/account'
import { RoleService } from '@/api/modules/role'
import { ShopService, EnterpriseService } from '@/api/modules'
import { ShopService, EnterpriseService, WecomService } from '@/api/modules'
import type { SearchFormItem } from '@/types'
import type { PlatformRole } from '@/types/api'
import type { PlatformRole, WecomApplication, WecomMember } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants'
import { getCompatibleNumericId } from '@/utils/business/id'
import {
CommonStatus,
getStatusText,
JULY_PERMISSIONS,
STATUS_SELECT_OPTIONS
} from '@/config/constants'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
import { isPlatformAuditAccount } from '@/utils/business/auditAccess'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
import { useUserStore } from '@/store/modules/user'
defineOptions({ name: 'Account' }) // 定义组件名称,用于 KeepAlive 缓存控制
const { hasAuth } = useAuth()
const userStore = useUserStore()
const canModifyAccountStatus = hasAuth('account:modify_status')
const route = useRoute()
const dialogType = ref('add')
const dialogVisible = ref(false)
const roleDialogVisible = ref(false)
const wecomBindingDialogVisible = ref(false)
const loading = ref(false)
const currentAccountId = ref<number>(0)
const currentAccountName = ref<string>('')
@@ -262,10 +363,28 @@
const roleToAdd = ref<number | undefined>(undefined) // 单选时使用
const leftRoleFilter = ref('')
const rightRoleFilter = ref('')
const currentWecomAccount = ref<any | null>(null)
const wecomApplications = ref<WecomApplication[]>([])
const wecomMembers = ref<WecomMember[]>([])
const wecomApplicationsLoading = ref(false)
const wecomMembersLoading = ref(false)
const wecomBindingSubmitting = ref(false)
const wecomBindingFormRef = ref<FormInstance>()
const wecomBindingForm = reactive({
application_id: undefined as number | undefined,
userid: ''
})
const wecomBindingRules = reactive<FormRules>({
application_id: [{ required: true, message: '请选择企微应用', trigger: 'change' }],
userid: [{ required: true, message: '请选择企微成员', trigger: 'change' }]
})
// 是否为平台用户(平台用户可多选,其他单选)
const isPlatformUser = computed(() => currentAccountType.value === 2)
const getAccountId = (account: any) => getCompatibleNumericId(account) ?? 0
const getRoleId = (role: PlatformRole | any) => getCompatibleNumericId(role) ?? 0
// 定义表单搜索初始值
const initialSearchState = {
name: '',
@@ -400,6 +519,8 @@
{ label: '账号类型', prop: 'user_type' },
{ label: '店铺名称', prop: 'shop_name' },
{ label: '企业名称', prop: 'enterprise_name' },
{ label: '企微绑定', prop: 'wecom_bound' },
{ label: '企微用户名称', prop: 'wecom_name' },
...(canModifyAccountStatus ? [{ label: '状态', prop: 'status' }] : []),
{ label: '创建时间', prop: 'created_at' }
]
@@ -503,11 +624,27 @@
{
prop: 'enterprise_name',
label: '企业名称',
minWidth: 150,
minWidth: 200,
showOverflowTooltip: true,
formatter: (row: any) => {
return row.enterprise_name || '-'
}
},
{
prop: 'wecom_bound',
label: '企微绑定',
width: 100,
formatter: (row: any) =>
h(ElTag, { type: row.wecom_bound ? 'success' : 'info', size: 'small' }, () =>
row.wecom_bound ? '已绑定' : '未绑定'
)
},
{
prop: 'wecom_name',
label: '企微用户名称',
minWidth: 130,
formatter: (row: any) => row.wecom_name || '-'
},
...(canModifyAccountStatus
? [
{
@@ -541,6 +678,23 @@
const getActions = (row: any) => {
const actions: any[] = []
if (
isPlatformAuditAccount(userStore.info.user_type, userStore.isSuperAdmin) &&
hasAuth(AUDIT_PERMISSIONS.resourceTimeline)
) {
const auditTarget = resolveAuditResourceTarget({
userType: userStore.info.user_type,
resourceType: 'account',
internalId: row.id
})
if (auditTarget)
actions.push({
label: '审计记录',
handler: () => openAuditInvestigation(auditTarget),
type: 'primary'
})
}
if (hasAuth('account:patch_role')) {
actions.push({
label: '分配角色',
@@ -549,6 +703,14 @@
})
}
if (row.user_type === 2 && hasAuth(JULY_PERMISSIONS.wecom.binding)) {
actions.push({
label: '绑定账号',
handler: () => showWecomBindingDialog(row),
type: 'primary'
})
}
if (hasAuth('account:edit')) {
actions.push({
label: '编辑',
@@ -568,6 +730,104 @@
return actions
}
const showWecomBindingDialog = async (row: any) => {
if (row.user_type !== 2) {
ElMessage.warning('只有平台用户可以绑定企微账号')
return
}
currentWecomAccount.value = row
wecomBindingForm.application_id = undefined
wecomBindingForm.userid = ''
wecomMembers.value = []
wecomBindingDialogVisible.value = true
await loadWecomApplications()
if (row.wecom_bound && row.wecom_corp_id) {
const boundApplication = wecomApplications.value.find(
(application) => application.corp_id === row.wecom_corp_id
)
if (boundApplication) {
wecomBindingForm.application_id = boundApplication.id
await handleWecomApplicationChange(boundApplication.id, row.wecom_userid || '')
}
}
}
const loadWecomApplications = async () => {
wecomApplicationsLoading.value = true
try {
const response = await WecomService.getApplications({ page: 1, page_size: 100 })
if (response.code === 0) wecomApplications.value = response.data.items || []
} finally {
wecomApplicationsLoading.value = false
}
}
const handleWecomApplicationChange = async (applicationId?: number, selectedUserid = '') => {
wecomBindingForm.userid = selectedUserid
wecomMembers.value = []
if (!applicationId) return
wecomMembersLoading.value = true
try {
const response = await WecomService.getMembers(applicationId, {
page: 1,
page_size: 100
})
if (response.code === 0) {
wecomMembers.value = response.data.items || []
if (
selectedUserid &&
!wecomMembers.value.some((member) => member.userid === selectedUserid)
) {
wecomBindingForm.userid = selectedUserid
}
}
} finally {
wecomMembersLoading.value = false
}
}
const submitWecomBinding = async () => {
const valid = await wecomBindingFormRef.value?.validate().catch(() => false)
if (!valid || !currentWecomAccount.value || !wecomBindingForm.application_id) return
if (currentWecomAccount.value.user_type !== 2) {
ElMessage.error('只有平台用户可以绑定企微账号')
return
}
const accountId = getAccountId(currentWecomAccount.value)
if (!accountId) {
ElMessage.error('当前账号缺少账号标识,无法绑定企微')
return
}
wecomBindingSubmitting.value = true
try {
const response = await AccountService.bindWecom(accountId, {
application_id: wecomBindingForm.application_id,
userid: wecomBindingForm.userid
})
if (response.code === 0) {
ElMessage.success('账号企微绑定成功')
wecomBindingDialogVisible.value = false
await getAccountList()
}
} finally {
wecomBindingSubmitting.value = false
}
}
const resetWecomBindingForm = () => {
currentWecomAccount.value = null
wecomBindingForm.application_id = undefined
wecomBindingForm.userid = ''
wecomMembers.value = []
wecomBindingFormRef.value?.clearValidate()
}
// 表单实例
const formRef = ref<FormInstance>()
@@ -646,7 +906,9 @@
// 计算属性:过滤后的已分配角色
const filteredAssignedRoles = computed(() => {
const assignedRolesList = allRoles.value.filter((role) => selectedRoles.value.includes(role.ID))
const assignedRolesList = allRoles.value.filter((role) =>
selectedRoles.value.includes(getRoleId(role))
)
if (!rightRoleFilter.value) return assignedRolesList
const keyword = rightRoleFilter.value.toLowerCase()
return assignedRolesList.filter((role) => role.role_name.toLowerCase().includes(keyword))
@@ -658,7 +920,12 @@
ElMessage.warning('超级管理员无法分配角色')
return
}
currentAccountId.value = row.id
const accountId = getAccountId(row)
if (!accountId) {
ElMessage.error('当前账号缺少账号标识,无法分配角色')
return
}
currentAccountId.value = accountId
currentAccountName.value = row.username
currentAccountType.value = row.user_type
selectedRoles.value = []
@@ -675,12 +942,12 @@
await loadAllRoles('', roleType)
// 先加载当前账号的角色,再打开对话框
const res = await AccountService.getAccountRoles(row.id)
const res = await AccountService.getAccountRoles(accountId)
if (res.code === 0) {
// 提取角色ID数组
const roles = res.data || []
// 兼容 ID 和 id 两种字段名
selectedRoles.value = roles.map((role: any) => role.ID || role.id)
selectedRoles.value = roles.map((role: any) => getRoleId(role)).filter(Boolean)
// 数据加载完成后再打开对话框
roleDialogVisible.value = true
}
@@ -836,7 +1103,7 @@
// 创建成功后分配角色(如果选择了)
if (createRes.code === 0) {
const accountId = createRes.data?.ID || createRes.data?.id
const accountId = getCompatibleNumericId(createRes.data)
if (formData.role_id && accountId) {
try {
await AccountService.assignRolesToAccount(accountId, [formData.role_id])

View File

@@ -18,6 +18,7 @@
@refresh="handleRefresh"
>
<template #left>
<!--suppress VueUnrecognizedDirective -->
<ElButton @click="showDialog('add')" v-permission="'enterprise_customer:add'"
>新增企业客户</ElButton
>
@@ -204,6 +205,9 @@
import { h, onActivated } from 'vue'
import { useRouter } from 'vue-router'
import { RoutesAlias } from '@/router/routesAlias'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
import { EnterpriseService, ShopService } from '@/api/modules'
import { ElMessage, ElSwitch, ElCascader } from 'element-plus'
import type { CascaderOption, CascaderValue, FormInstance, FormRules } from 'element-plus'
@@ -436,7 +440,7 @@
{
prop: 'contact_name',
label: '联系人姓名',
width: 120
width: 140
},
{
prop: 'contact_phone',
@@ -502,6 +506,24 @@
const getActions = (row: EnterpriseItem) => {
const actions: any[] = []
const userType = Number(userStore.getUserInfo.user_type)
const auditPermission =
userType === 3 ? AUDIT_PERMISSIONS.agentActivity : AUDIT_PERMISSIONS.enterpriseEntry
if (hasAuth(auditPermission)) {
const auditTarget = resolveAuditResourceTarget({
userType,
resourceType: 'enterprise',
internalId: row.id,
businessIdentifier: row.enterprise_code
})
if (auditTarget)
actions.push({
label: userType === 3 ? '活动记录' : '审计记录',
handler: () => openAuditInvestigation(auditTarget),
type: 'primary'
})
}
if (hasAuth('enterprise_customer:look_customer')) {
actions.push({
label: '账号列表',
@@ -786,7 +808,7 @@
dialogVisible.value = false
formEl.resetFields()
getTableData()
await getTableData()
} catch (error) {
console.error(error)
} finally {

Some files were not shown because too many files have changed in this diff Show More