fix: some
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Has been cancelled

This commit is contained in:
luo
2026-09-20 10:10:50 +08:00
parent e37d4902ca
commit 085a83d4e4
19 changed files with 744 additions and 424 deletions

View File

@@ -1,6 +1,6 @@
/** /**
* H5 运营弹窗配置相关 API * H5 运营弹窗配置相关 API
* 契约来源:docs/产品迭代8月份/通知.md * 契约来源:docs/产品迭代8月份/h5.md
*/ */
import { BaseService } from '../BaseService' import { BaseService } from '../BaseService'

View File

@@ -1,5 +1,33 @@
<template> <template>
<ElDialog v-model="visible" title="凭证查看" width="560px" @closed="handleClose"> <div v-if="inline" class="voucher-preview voucher-preview--inline">
<div v-if="loading" class="voucher-preview__loading">加载中...</div>
<div v-else-if="files.length" class="voucher-preview__list">
<div v-for="file in files" :key="file.key" class="voucher-preview__item">
<ElImage
v-if="file.isImage"
class="voucher-preview__image"
:src="file.url"
:preview-src-list="imageUrls"
:initial-index="getImageIndex(file.url)"
fit="cover"
preview-teleported
/>
<div v-else class="voucher-preview__file">
<ElIcon><component :is="getFileIcon(file)" /></ElIcon>
<span class="voucher-preview__file-type">{{ file.fileType }}</span>
</div>
<div class="voucher-preview__meta">
<div class="voucher-preview__name" :title="file.name">{{
getDisplayName(file.name)
}}</div>
<ElButton link type="primary" @click="openFile(file)">查看/下载</ElButton>
</div>
</div>
</div>
<ElEmpty v-else description="暂无凭证" />
</div>
<ElDialog v-else v-model="visible" title="凭证查看" width="560px" @closed="handleClose">
<div v-if="loading" class="voucher-preview__loading">加载中...</div> <div v-if="loading" class="voucher-preview__loading">加载中...</div>
<div v-else-if="files.length" class="voucher-preview__list"> <div v-else-if="files.length" class="voucher-preview__list">
<div v-for="file in files" :key="file.key" class="voucher-preview__item"> <div v-for="file in files" :key="file.key" class="voucher-preview__item">
@@ -48,6 +76,7 @@
interface Props { interface Props {
fileKey?: string | string[] fileKey?: string | string[]
fileKeys?: string[] fileKeys?: string[]
inline?: boolean
} }
interface VoucherFile { interface VoucherFile {
@@ -59,7 +88,9 @@
fileType: string fileType: string
} }
const props = defineProps<Props>() const props = withDefaults(defineProps<Props>(), {
inline: false
})
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
@@ -165,7 +196,7 @@
return return
} }
visible.value = true visible.value = !props.inline
loading.value = true loading.value = true
files.value = [] files.value = []

View File

@@ -525,6 +525,7 @@
"detailsOfPaymentMerchant": "Payment Merchant Details", "detailsOfPaymentMerchant": "Payment Merchant Details",
"detailsOfPaymentMerchantPool": "Merchant Pool Details", "detailsOfPaymentMerchantPool": "Merchant Pool Details",
"h5PopupConfiguration": "H5 Popup Configurations", "h5PopupConfiguration": "H5 Popup Configurations",
"h5PopupConfigurationDetail": "H5 Popup Configuration Detail",
"assetWalletAutoRenewal": "Asset Wallet Auto Renewal" "assetWalletAutoRenewal": "Asset Wallet Auto Renewal"
}, },
"batch": { "batch": {

View File

@@ -476,6 +476,7 @@
"detailsOfPaymentMerchant": "支付商户详情", "detailsOfPaymentMerchant": "支付商户详情",
"detailsOfPaymentMerchantPool": "商户池详情", "detailsOfPaymentMerchantPool": "商户池详情",
"h5PopupConfiguration": "H5运营弹窗配置", "h5PopupConfiguration": "H5运营弹窗配置",
"h5PopupConfigurationDetail": "H5运营弹窗配置详情",
"assetWalletAutoRenewal": "资产钱包自动续费" "assetWalletAutoRenewal": "资产钱包自动续费"
} }
}, },

View File

@@ -430,29 +430,6 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true keepAlive: true
} }
}, },
// 解绑导入任务列表
{
path: 'phone-asset-association/unbind-import-tasks',
name: 'PhoneAssetUnbindImportTasks',
component: RoutesAlias.PhoneAssetUnbindImportTasks,
meta: {
title: 'menus.assetManagement.phoneAssetUnbindImportTasks',
permissions: [AUGUST_PERMISSIONS.phoneAssetAssociation.unbindImportPage],
keepAlive: true
}
},
// 解绑导入任务详情
{
path: 'phone-asset-association/unbind-import-tasks/detail',
name: 'PhoneAssetUnbindImportTaskDetail',
component: RoutesAlias.PhoneAssetUnbindImportTaskDetail,
meta: {
title: 'menus.assetManagement.phoneAssetUnbindImportTaskDetail',
permissions: [AUGUST_PERMISSIONS.phoneAssetAssociation.unbindImportDetail],
isHide: true,
keepAlive: false
}
},
// 记录管理 // 记录管理
{ {
path: 'record-management', path: 'record-management',
@@ -557,6 +534,29 @@ export const asyncRoutes: AppRouteRecord[] = [
title: 'menus.assetManagement.orderPackageInvalidateTask', title: 'menus.assetManagement.orderPackageInvalidateTask',
keepAlive: true keepAlive: true
} }
},
// 解绑导入任务
{
path: 'phone-asset-unbind-import-tasks',
name: 'PhoneAssetUnbindImportTasks',
component: RoutesAlias.PhoneAssetUnbindImportTasks,
meta: {
title: 'menus.assetManagement.phoneAssetUnbindImportTasks',
permissions: [AUGUST_PERMISSIONS.phoneAssetAssociation.unbindImportPage],
keepAlive: true
}
},
// 解绑导入任务详情
{
path: 'phone-asset-unbind-import-tasks/detail',
name: 'PhoneAssetUnbindImportTaskDetail',
component: RoutesAlias.PhoneAssetUnbindImportTaskDetail,
meta: {
title: 'menus.assetManagement.phoneAssetUnbindImportTaskDetail',
permissions: [AUGUST_PERMISSIONS.phoneAssetAssociation.unbindImportDetail],
isHide: true,
keepAlive: false
}
} }
] ]
}, },
@@ -1164,6 +1164,19 @@ export const asyncRoutes: AppRouteRecord[] = [
permissions: [AUGUST_PERMISSIONS.h5PopupConfiguration.list] permissions: [AUGUST_PERMISSIONS.h5PopupConfiguration.list]
} }
}, },
// H5运营弹窗配置详情
{
path: 'h5-popup-configuration/detail/:id',
name: 'H5PopupConfigurationDetail',
component: RoutesAlias.H5PopupConfigurationDetail,
meta: {
title: 'menus.settings.h5PopupConfigurationDetail',
isHide: true,
keepAlive: false,
allowedUserTypes: [1, 2],
permissions: [AUGUST_PERMISSIONS.h5PopupConfiguration.list]
}
},
// 资产钱包自动续费配置 // 资产钱包自动续费配置
{ {
path: 'asset-wallet-auto-renewal', path: 'asset-wallet-auto-renewal',

View File

@@ -127,6 +127,7 @@ export enum RoutesAlias {
WecomMembers = '/settings/wecom/members', // 企业微信成员 WecomMembers = '/settings/wecom/members', // 企业微信成员
WecomScenes = '/settings/wecom/scenes', // 企业微信审批场景 WecomScenes = '/settings/wecom/scenes', // 企业微信审批场景
H5PopupConfigurations = '/settings/h5-popup-configuration', // H5运营弹窗配置 H5PopupConfigurations = '/settings/h5-popup-configuration', // H5运营弹窗配置
H5PopupConfigurationDetail = '/settings/h5-popup-configuration/detail', // H5运营弹窗配置详情
AssetWalletAutoRenewal = '/settings/asset-wallet-auto-renewal', // 资产钱包自动续费配置 AssetWalletAutoRenewal = '/settings/asset-wallet-auto-renewal', // 资产钱包自动续费配置
// 轮询管理 // 轮询管理
@@ -153,8 +154,8 @@ export enum RoutesAlias {
// 手机号资产关联 // 手机号资产关联
PhoneAssetAssociation = '/asset-management/phone-asset-association', // 手机号资产关联列表 PhoneAssetAssociation = '/asset-management/phone-asset-association', // 手机号资产关联列表
PhoneAssetUnbindImportTasks = '/asset-management/phone-asset-association/unbind-import-tasks', // 解绑导入任务列表 PhoneAssetUnbindImportTasks = '/asset-management/task-management/phone-asset-unbind-import-tasks', // 解绑导入任务列表
PhoneAssetUnbindImportTaskDetail = '/asset-management/phone-asset-association/unbind-import-tasks/detail', // 解绑导入任务详情 PhoneAssetUnbindImportTaskDetail = '/asset-management/task-management/phone-asset-unbind-import-tasks/detail', // 解绑导入任务详情
// 运营报表 // 运营报表
OperationsActivationReport = '/operations-reports/activation', // 设备激活报表 OperationsActivationReport = '/operations-reports/activation', // 设备激活报表

View File

@@ -171,8 +171,8 @@ export interface EmployeeCollectionBillQueryParams extends PaginationParams {
status?: EmployeeCollectionBillStatus status?: EmployeeCollectionBillStatus
debtor_account_id?: number debtor_account_id?: number
customer_id?: number customer_id?: number
created_from?: string start_time?: string
created_to?: string end_time?: string
} }
export interface CloseEmployeeCollectionBillRequest { export interface CloseEmployeeCollectionBillRequest {
@@ -195,7 +195,7 @@ export interface EmployeeCollectionApplicationAttempt {
approval_status_name?: string | null approval_status_name?: string | null
approval_opinion?: string | null approval_opinion?: string | null
acting_reason?: string | null acting_reason?: string | null
external_transaction_no?: string | null external_transaction_no?: string | null // 人工确认的外部交易流水号,申请人可原样核对与重用
paid_amount?: number | null paid_amount?: number | null
paid_at?: string | null paid_at?: string | null
payer_name?: string | null payer_name?: string | null
@@ -220,7 +220,7 @@ export interface EmployeeCollectionApplication {
paid_amount?: number | null // 人工确认的付款金额(分) paid_amount?: number | null // 人工确认的付款金额(分)
paid_at?: string | null paid_at?: string | null
payer_name?: string | null payer_name?: string | null
external_transaction_no?: string | null external_transaction_no?: string | null // 人工确认的外部交易流水号,申请人可原样核对与重用
payment_voucher_keys?: string[] | null payment_voucher_keys?: string[] | null
remark?: string | null remark?: string | null
status: EmployeeCollectionApplicationStatus status: EmployeeCollectionApplicationStatus

View File

@@ -6,11 +6,14 @@
// 命中页面集合 (home:首页, asset_detail:资产详情, package_purchase:套餐购买, asset_wallet_recharge:资产钱包充值) // 命中页面集合 (home:首页, asset_detail:资产详情, package_purchase:套餐购买, asset_wallet_recharge:资产钱包充值)
export type H5PopupPage = 'home' | 'asset_detail' | 'package_purchase' | 'asset_wallet_recharge' export type H5PopupPage = 'home' | 'asset_detail' | 'package_purchase' | 'asset_wallet_recharge'
// 弹窗类型(promotion: 套餐政策推广, announcement: 通用公告)
export type H5PopupType = 'promotion' | 'announcement'
// 投放频率 (once:每客户每配置版本仅一次, daily:每客户每配置版本每个上海自然日一次) // 投放频率 (once:每客户每配置版本仅一次, daily:每客户每配置版本每个上海自然日一次)
export type H5PopupFrequency = 'once' | 'daily' export type H5PopupFrequency = 'once' | 'daily'
// 受控动作 (package_purchase:套餐购买, asset_wallet_recharge:资产钱包充值);空字符串表示无受控动作 // 受控动作 (package_purchase:套餐购买, asset_wallet_recharge:资产钱包充值);空字符串表示无受控动作
export type H5PopupActionType = 'package_purchase' | 'asset_wallet_recharge' | '' export type H5PopupActionType = 'package_purchase' | 'asset_wallet_recharge'
// 卡类型范围 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电) // 卡类型范围 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)
export type H5PopupCardType = 'CMCC' | 'CUCC' | 'CTCC' | 'CBN' export type H5PopupCardType = 'CMCC' | 'CUCC' | 'CTCC' | 'CBN'
@@ -20,6 +23,8 @@ export type H5PopupCardType = 'CMCC' | 'CUCC' | 'CTCC' | 'CBN'
*/ */
export interface H5PopupConfiguration { export interface H5PopupConfiguration {
id: number // 配置ID id: number // 配置ID
popup_type: H5PopupType // 弹窗类型
popup_type_text: string // 弹窗类型名称(中文)
title: string // 弹窗标题 title: string // 弹窗标题
content: string // 弹窗正文 content: string // 弹窗正文
pages: H5PopupPage[] | null // 命中页面集合;空数组等价于非法(页面必选) pages: H5PopupPage[] | null // 命中页面集合;空数组等价于非法(页面必选)
@@ -28,7 +33,7 @@ export interface H5PopupConfiguration {
frequency_text: string // 投放频率名称(中文) frequency_text: string // 投放频率名称(中文)
enabled: boolean // 是否启用;停用后停止新投放,历史通知在展示期内仍可见 enabled: boolean // 是否启用;停用后停止新投放,历史通知在展示期内仍可见
enabled_text: string // 启停状态名称(中文) enabled_text: string // 启停状态名称(中文)
action_type: H5PopupActionType // 受控动作;空值表示无受控动作 action_type: H5PopupActionType | null // 受控动作;空值表示无受控动作
shop_ids: number[] | null // 店铺范围;空数组表示全量 shop_ids: number[] | null // 店铺范围;空数组表示全量
device_types: string[] | null // 设备类型范围;空数组表示全量 device_types: string[] | null // 设备类型范围;空数组表示全量
card_types: H5PopupCardType[] | null // 卡类型范围;空数组表示全量 card_types: H5PopupCardType[] | null // 卡类型范围;空数组表示全量
@@ -68,6 +73,7 @@ export interface H5PopupConfigurationListResponse {
* shop_ids/device_types/card_types 空数组 = 全量。 * shop_ids/device_types/card_types 空数组 = 全量。
*/ */
export interface CreateH5PopupConfigurationRequest { export interface CreateH5PopupConfigurationRequest {
popup_type: H5PopupType // 弹窗类型
title: string // 弹窗标题,1~100 字符 title: string // 弹窗标题,1~100 字符
content: string // 弹窗正文,1~2000 字符,不接受 HTML、URL 或前端路由 content: string // 弹窗正文,1~2000 字符,不接受 HTML、URL 或前端路由
pages: H5PopupPage[] // 命中页面集合(非空) pages: H5PopupPage[] // 命中页面集合(非空)
@@ -76,7 +82,7 @@ export interface CreateH5PopupConfigurationRequest {
starts_at: string // 生效开始时间(ISO 8601) starts_at: string // 生效开始时间(ISO 8601)
ends_at: string // 生效结束时间(ISO 8601),不得早于开始时间 ends_at: string // 生效结束时间(ISO 8601),不得早于开始时间
enabled?: boolean // 是否启用,默认由后端决定 enabled?: boolean // 是否启用,默认由后端决定
action_type?: H5PopupActionType // 受控动作 action_type?: H5PopupActionType | null // 受控动作
shop_ids?: number[] // 店铺范围,空数组 = 全量 shop_ids?: number[] // 店铺范围,空数组 = 全量
device_types?: string[] // 设备类型范围,空数组 = 全量 device_types?: string[] // 设备类型范围,空数组 = 全量
card_types?: H5PopupCardType[] // 卡类型范围,空数组 = 全量 card_types?: H5PopupCardType[] // 卡类型范围,空数组 = 全量
@@ -90,6 +96,7 @@ export interface CreateH5PopupConfigurationRequest {
*/ */
export interface UpdateH5PopupConfigurationRequest { export interface UpdateH5PopupConfigurationRequest {
id: number // 运营弹窗配置ID id: number // 运营弹窗配置ID
popup_type?: H5PopupType // 弹窗类型
title?: string // 不传保持原值,1~100 字符 title?: string // 不传保持原值,1~100 字符
content?: string // 不传保持原值,1~2000 字符 content?: string // 不传保持原值,1~2000 字符
pages?: H5PopupPage[] // 不传保持原值;传空数组等价于非法(页面必选) pages?: H5PopupPage[] // 不传保持原值;传空数组等价于非法(页面必选)
@@ -98,7 +105,7 @@ export interface UpdateH5PopupConfigurationRequest {
starts_at?: string // 不传保持原值(ISO 8601) starts_at?: string // 不传保持原值(ISO 8601)
ends_at?: string // 不传保持原值(ISO 8601),不得早于开始时间 ends_at?: string // 不传保持原值(ISO 8601),不得早于开始时间
enabled?: boolean // 不传保持原值;启停会刷新最近更新时间并影响同优先级排序 enabled?: boolean // 不传保持原值;启停会刷新最近更新时间并影响同优先级排序
action_type?: H5PopupActionType | null // 不传保持原值;传空字符串 = 清除受控动作 action_type?: H5PopupActionType | '' // 不传保持原值;传空字符串 = 清除受控动作
shop_ids?: number[] | null // 不传保持原值;传空数组 = 改为全量 shop_ids?: number[] | null // 不传保持原值;传空数组 = 改为全量
device_types?: string[] | null // 不传保持原值;传空数组 = 改为全量 device_types?: string[] | null // 不传保持原值;传空数组 = 改为全量
card_types?: H5PopupCardType[] | null // 不传保持原值;传空数组 = 改为全量 card_types?: H5PopupCardType[] | null // 不传保持原值;传空数组 = 改为全量

View File

@@ -6,6 +6,45 @@ import type { RequestOptions, ErrorMessageMode } from '@/types/api'
import { normalizeApiError } from '@/utils/business/apiError' import { normalizeApiError } from '@/utils/business/apiError'
import { syncCompatibleIdFields } from '@/utils/business/id' import { syncCompatibleIdFields } from '@/utils/business/id'
const normalizeRfc3339Time = (value: unknown, isEndTime: boolean): unknown => {
if (value instanceof Date) {
return value.toISOString().replace(/\.\d{3}(?=Z$)/, '')
}
if (typeof value !== 'string' || !value) return value
const dateOnlyMatch = value.match(/^(\d{4}-\d{2}-\d{2})$/)
if (dateOnlyMatch) {
return `${dateOnlyMatch[1]}T${isEndTime ? '23:59:59' : '00:00:00'}+08:00`
}
const localDateTimeMatch = value.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})(?::(\d{2}))?$/)
if (localDateTimeMatch) {
return `${localDateTimeMatch[1]}T${localDateTimeMatch[2]}:${localDateTimeMatch[3] || '00'}+08:00`
}
return value.replace(/\.\d{3}(?=Z$|[+-]\d{2}:\d{2}$)/, '')
}
const normalizeTimeQueryParams = (params: unknown) => {
if (
!params ||
typeof params !== 'object' ||
Array.isArray(params) ||
params instanceof URLSearchParams
) {
return params
}
const queryParams = params as Record<string, unknown>
if (!('start_time' in queryParams) && !('end_time' in queryParams)) return params
return {
...queryParams,
start_time: normalizeRfc3339Time(queryParams.start_time, false),
end_time: normalizeRfc3339Time(queryParams.end_time, true)
}
}
const axiosInstance = axios.create({ const axiosInstance = axios.create({
timeout: 15000, // 请求超时时间(毫秒) timeout: 15000, // 请求超时时间(毫秒)
baseURL: import.meta.env.DEV ? '' : import.meta.env.VITE_API_URL, // 开发服务器使用代理,其他模式使用完整URL baseURL: import.meta.env.DEV ? '' : import.meta.env.VITE_API_URL, // 开发服务器使用代理,其他模式使用完整URL
@@ -43,6 +82,7 @@ const axiosInstance = axios.create({
// 请求拦截器 // 请求拦截器
axiosInstance.interceptors.request.use( axiosInstance.interceptors.request.use(
(request: InternalAxiosRequestConfig) => { (request: InternalAxiosRequestConfig) => {
request.params = normalizeTimeQueryParams(request.params)
const { accessToken } = useUserStore() const { accessToken } = useUserStore()
// 如果 token 存在,则设置请求头 // 如果 token 存在,则设置请求头

View File

@@ -100,18 +100,6 @@
当前暂无可用在线支付方式,暂时无法提交充值。 当前暂无可用在线支付方式,暂时无法提交充值。
</div> </div>
</ElFormItem> </ElFormItem>
<ElFormItem v-if="createMode === 'offline'" label="店铺" prop="shop_id">
<ElCascader
v-model="createForm.shop_id"
:options="shopCascadeOptions"
:props="shopCascadeProps"
placeholder="请选择店铺"
filterable
clearable
style="width: 100%"
@change="handleShopChange"
/>
</ElFormItem>
<ElFormItem <ElFormItem
v-if="createForm.payment_method === 'offline'" v-if="createForm.payment_method === 'offline'"
label="支付凭证" label="支付凭证"
@@ -127,26 +115,6 @@
@change="handleVoucherChange" @change="handleVoucherChange"
/> />
</ElFormItem> </ElFormItem>
<ElFormItem
v-if="createMode === 'offline'"
label="收款方式"
prop="offline_payment_method_id"
>
<ElSelect
v-model="createForm.offline_payment_method_id"
placeholder="请选择收款方式"
style="width: 100%"
filterable
:loading="paymentMethodOptionsLoading"
>
<ElOption
v-for="item in offlinePaymentMethodOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem <ElFormItem
v-if="createForm.payment_method === 'offline'" v-if="createForm.payment_method === 'offline'"
label="交易流水号" label="交易流水号"
@@ -169,6 +137,47 @@
<div class="external-transaction-tip"> <div class="external-transaction-tip">
识别结果仅供参考,请对照凭证核对后再提交。 识别结果仅供参考,请对照凭证核对后再提交。
</div> </div>
<div v-if="ocrLoading" class="ocr-progress">
<ElProgress
:percentage="100"
:indeterminate="true"
:show-text="false"
:stroke-width="10"
/>
<div class="ocr-progress__text">正在识别支付凭证,请稍候...</div>
</div>
</ElFormItem>
<ElFormItem v-if="createMode === 'offline'" label="店铺" prop="shop_id">
<ElCascader
v-model="createForm.shop_id"
:options="shopCascadeOptions"
:props="shopCascadeProps"
placeholder="请选择店铺"
filterable
clearable
style="width: 100%"
@change="handleShopChange"
/>
</ElFormItem>
<ElFormItem
v-if="createMode === 'offline'"
label="收款方式"
prop="offline_payment_method_id"
>
<ElSelect
v-model="createForm.offline_payment_method_id"
placeholder="请选择收款方式"
style="width: 100%"
filterable
:loading="paymentMethodOptionsLoading"
>
<ElOption
v-for="item in offlinePaymentMethodOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</ElSelect>
</ElFormItem> </ElFormItem>
<ElFormItem v-if="createMode === 'offline'" label="其他凭证"> <ElFormItem v-if="createMode === 'offline'" label="其他凭证">
<VoucherUpload <VoucherUpload
@@ -1624,6 +1633,17 @@
color: var(--el-color-danger); color: var(--el-color-danger);
} }
.ocr-progress {
margin-top: 10px;
&__text {
margin-top: 6px;
font-size: 13px;
font-weight: 500;
color: var(--el-color-primary);
}
}
.external-transaction-row { .external-transaction-row {
display: flex; display: flex;
gap: 8px; gap: 8px;

View File

@@ -18,6 +18,15 @@
@uploading-change="voucherUploading = $event" @uploading-change="voucherUploading = $event"
@change="handleVoucherChange" @change="handleVoucherChange"
/> />
<div v-if="ocrLoading" class="ocr-progress">
<div class="ocr-progress__text">正在识别付款凭证,请稍候...</div>
<ElProgress
:percentage="100"
:indeterminate="true"
:show-text="false"
:stroke-width="8"
/>
</div>
</ElFormItem> </ElFormItem>
<ElFormItem label="外部交易流水号" prop="external_transaction_no"> <ElFormItem label="外部交易流水号" prop="external_transaction_no">
@@ -67,81 +76,106 @@
<ElFormItem label="核销账单" required> <ElFormItem label="核销账单" required>
<div class="bill-selector"> <div class="bill-selector">
<div class="bill-selector__filters"> <div class="bill-selector__filters">
<div class="bill-selector__row"> <ElRow :gutter="8" class="bill-selector__row">
<ElSelect <ElCol :span="6">
v-model="billSearch.source_type" <ElSelect
placeholder="来源" v-model="billSearch.source_type"
clearable placeholder="来源"
class="bill-selector__filter" clearable
> class="bill-selector__filter"
<ElOption >
v-for="opt in BILL_SOURCE_OPTIONS" <ElOption
:key="opt.value" v-for="opt in BILL_SOURCE_OPTIONS"
:label="opt.label" :key="opt.value"
:value="opt.value" :label="opt.label"
:value="opt.value"
/>
</ElSelect>
</ElCol>
<ElCol :span="6">
<ElInput
v-model="billSearch.source_no"
placeholder="来源单号"
clearable
class="bill-selector__filter"
/> />
</ElSelect> </ElCol>
<ElInput <ElCol :span="6">
v-model="billSearch.source_no" <ElSelect
placeholder="来源单号" v-model="billSearch.status"
clearable placeholder="核销状态"
class="bill-selector__filter" clearable
/> class="bill-selector__filter"
<ElSelect >
v-model="billSearch.status" <ElOption
placeholder="核销状态" v-for="opt in BILL_STATUS_OPTIONS"
clearable :key="opt.value"
class="bill-selector__filter" :label="opt.label"
> :value="opt.value"
<ElOption />
v-for="opt in BILL_STATUS_OPTIONS" </ElSelect>
:key="opt.value" </ElCol>
:label="opt.label" <ElCol :span="6">
:value="opt.value" <ElSelect
v-model="billSearch.customer_id"
placeholder="店铺"
clearable
filterable
remote
reserve-keyword
:remote-method="searchBillShops"
class="bill-selector__filter"
>
<ElOption
v-for="shop in shopOptions"
:key="shop.id"
:label="shop.shop_name"
:value="shop.id"
/>
</ElSelect>
</ElCol>
</ElRow>
<ElRow :gutter="8" class="bill-selector__row">
<ElCol :span="6">
<ElDatePicker
v-model="billSearch.start_time"
type="date"
placeholder="开始日期"
value-format="YYYY-MM-DD"
clearable
class="bill-selector__filter"
style="width: 100%; min-width: 0"
/> />
</ElSelect> </ElCol>
<ElSelect <ElCol :span="6">
v-model="billSearch.customer_id" <ElDatePicker
placeholder="店铺" v-model="billSearch.end_time"
clearable type="date"
filterable placeholder="结束日期"
remote value-format="YYYY-MM-DD"
reserve-keyword clearable
:remote-method="searchBillShops" class="bill-selector__filter"
class="bill-selector__filter" style="width: 100%; min-width: 0"
>
<ElOption
v-for="shop in shopOptions"
:key="shop.id"
:label="shop.shop_name"
:value="shop.id"
/> />
</ElSelect> </ElCol>
</div> <ElCol :span="6">
<div class="bill-selector__row"> <ElButton
<ElDatePicker class="bill-selector__filter bill-selector__action"
v-model="billSearch.dateRange" @click="handleBillReset"
type="daterange" >
range-separator="至" 重置
start-placeholder="开始日期" </ElButton>
end-placeholder="结束日期" </ElCol>
value-format="YYYY-MM-DD" <ElCol :span="6">
class="bill-selector__filter bill-selector__filter--span2" <ElButton
/> type="primary"
<ElButton class="bill-selector__filter bill-selector__action"
class="bill-selector__filter bill-selector__action" @click="handleBillSearch"
@click="handleBillReset" >
> 搜索
重置 </ElButton>
</ElButton> </ElCol>
<ElButton </ElRow>
type="primary"
class="bill-selector__filter bill-selector__action"
@click="handleBillSearch"
>
搜索
</ElButton>
</div>
</div> </div>
<div v-if="billsLoading" class="bill-selector__empty">加载中...</div> <div v-if="billsLoading" class="bill-selector__empty">加载中...</div>
@@ -340,9 +374,8 @@
source_no: '', source_no: '',
status: undefined as number | undefined, status: undefined as number | undefined,
customer_id: undefined as number | undefined, customer_id: undefined as number | undefined,
dateRange: [] as string[], start_time: '',
created_from: '', end_time: ''
created_to: ''
}) })
const billPagination = reactive({ const billPagination = reactive({
@@ -520,8 +553,8 @@
source_no: billSearch.source_no.trim() || undefined, source_no: billSearch.source_no.trim() || undefined,
status: billSearch.status, status: billSearch.status,
customer_id: billSearch.customer_id, customer_id: billSearch.customer_id,
created_from: billSearch.created_from || undefined, start_time: billSearch.start_time || undefined,
created_to: billSearch.created_to || undefined end_time: billSearch.end_time || undefined
}) })
const loadCandidateBills = async () => { const loadCandidateBills = async () => {
@@ -565,18 +598,7 @@
} }
} }
const syncBillCreatedRange = () => {
if (Array.isArray(billSearch.dateRange) && billSearch.dateRange.length === 2) {
billSearch.created_from = billSearch.dateRange[0]
billSearch.created_to = billSearch.dateRange[1]
} else {
billSearch.created_from = ''
billSearch.created_to = ''
}
}
const handleBillSearch = () => { const handleBillSearch = () => {
syncBillCreatedRange()
billPagination.page = 1 billPagination.page = 1
void loadCandidateBills() void loadCandidateBills()
} }
@@ -586,9 +608,8 @@
billSearch.source_no = '' billSearch.source_no = ''
billSearch.status = undefined billSearch.status = undefined
billSearch.customer_id = undefined billSearch.customer_id = undefined
billSearch.dateRange = [] billSearch.start_time = ''
billSearch.created_from = '' billSearch.end_time = ''
billSearch.created_to = ''
billPagination.page = 1 billPagination.page = 1
void loadCandidateBills() void loadCandidateBills()
} }
@@ -650,9 +671,8 @@
billSearch.source_no = '' billSearch.source_no = ''
billSearch.status = undefined billSearch.status = undefined
billSearch.customer_id = undefined billSearch.customer_id = undefined
billSearch.dateRange = [] billSearch.start_time = ''
billSearch.created_from = '' billSearch.end_time = ''
billSearch.created_to = ''
billPagination.page = 1 billPagination.page = 1
billPagination.total = 0 billPagination.total = 0
candidateBills.value = [] candidateBills.value = []
@@ -863,10 +883,7 @@
} }
&__row { &__row {
display: grid; width: 100%;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
align-items: center;
} }
&__filter { &__filter {
@@ -874,10 +891,6 @@
min-width: 0; min-width: 0;
} }
&__filter--span2 {
grid-column: span 2;
}
&__action { &__action {
width: 100%; width: 100%;
margin: 0; margin: 0;
@@ -953,4 +966,20 @@
line-height: 18px; line-height: 18px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
} }
.ocr-progress {
width: 100%;
margin-top: 12px;
padding: 10px 12px;
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-7);
border-radius: 8px;
&__text {
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: var(--el-color-primary);
}
}
</style> </style>

View File

@@ -56,15 +56,14 @@
<template #header> <template #header>
<div class="block-title">付款凭证</div> <div class="block-title">付款凭证</div>
</template> </template>
<ElButton <PaymentVoucherDialog
v-if=" v-if="
voucherKeys.length && voucherKeys.length &&
hasAuth(AUGUST_PERMISSIONS.employeeCollection.applicationViewVoucher) hasAuth(AUGUST_PERMISSIONS.employeeCollection.applicationViewVoucher)
" "
@click="voucherVisible = true" inline
> :file-keys="voucherKeys"
查看付款凭证 />
</ElButton>
<span v-else class="empty-text">暂无付款凭证</span> <span v-else class="empty-text">暂无付款凭证</span>
</ElCard> </ElCard>
@@ -102,8 +101,6 @@
</ElTimelineItem> </ElTimelineItem>
</ElTimeline> </ElTimeline>
</ElCard> </ElCard>
<PaymentVoucherDialog :file-keys="voucherKeys" @close="voucherVisible = false" />
</div> </div>
</template> </template>
@@ -150,7 +147,6 @@
const loading = ref(false) const loading = ref(false)
const detail = ref<EmployeeCollectionApplicationDetailData | null>(null) const detail = ref<EmployeeCollectionApplicationDetailData | null>(null)
const voucherVisible = ref(false)
const application = computed<EmployeeCollectionApplication | null>( const application = computed<EmployeeCollectionApplication | null>(
() => detail.value?.application || null () => detail.value?.application || null
@@ -169,7 +165,11 @@
{ {
title: '申请信息', title: '申请信息',
fields: [ fields: [
{ label: '申请编号', prop: 'id', formatter: (value) => (value ? `#${value}` : '-') }, {
label: '外部交易流水号',
prop: 'external_transaction_no',
formatter: (value) => value || '-'
},
{ label: '收款方式', prop: 'payment_method_name', formatter: (value) => value || '-' }, { label: '收款方式', prop: 'payment_method_name', formatter: (value) => value || '-' },
{ {
label: '付款金额', label: '付款金额',
@@ -181,11 +181,6 @@
prop: 'paid_at', prop: 'paid_at',
formatter: (value) => formatDateTime(value) formatter: (value) => formatDateTime(value)
}, },
{
label: '外部交易流水号',
prop: 'external_transaction_no',
formatter: (value) => value || '-'
},
{ {
label: '状态', label: '状态',
prop: 'status', prop: 'status',

View File

@@ -136,7 +136,7 @@
] ]
const columnOptions = [ const columnOptions = [
{ label: '申请编号', prop: 'id' }, { label: '外部交易流水号', prop: 'external_transaction_no' },
{ label: '收款方式', prop: 'payment_method_name' }, { label: '收款方式', prop: 'payment_method_name' },
{ label: '付款金额', prop: 'paid_amount' }, { label: '付款金额', prop: 'paid_amount' },
{ label: '状态', prop: 'status' }, { label: '状态', prop: 'status' },
@@ -149,9 +149,9 @@
const { columnChecks, columns } = useCheckedColumns(() => [ const { columnChecks, columns } = useCheckedColumns(() => [
{ {
prop: 'id', prop: 'external_transaction_no',
label: '申请编号', label: '外部交易流水号',
minWidth: 140, minWidth: 220,
formatter: (row: EmployeeCollectionApplication) => formatter: (row: EmployeeCollectionApplication) =>
h( h(
'span', 'span',
@@ -159,7 +159,7 @@
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;', style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: () => handleViewDetail(row) onClick: () => handleViewDetail(row)
}, },
`#${row.id}` row.external_transaction_no || '-'
) )
}, },
{ {

View File

@@ -167,8 +167,8 @@
status: undefined as number | undefined, status: undefined as number | undefined,
customer_id: undefined as number | undefined, customer_id: undefined as number | undefined,
dateRange: [] as string[], dateRange: [] as string[],
created_from: '', start_time: '',
created_to: '' end_time: ''
} }
const searchForm = reactive({ ...initialSearchState }) const searchForm = reactive({ ...initialSearchState })
const pagination = reactive({ page: 1, page_size: 20, total: 0 }) const pagination = reactive({ page: 1, page_size: 20, total: 0 })
@@ -258,8 +258,8 @@
source_no: searchForm.source_no.trim() || undefined, source_no: searchForm.source_no.trim() || undefined,
status: searchForm.status, status: searchForm.status,
customer_id: searchForm.customer_id, customer_id: searchForm.customer_id,
created_from: searchForm.created_from || undefined, start_time: searchForm.start_time || undefined,
created_to: searchForm.created_to || undefined end_time: searchForm.end_time || undefined
}) })
const searchShops = async (query: string) => { const searchShops = async (query: string) => {
@@ -391,11 +391,11 @@
const handleSearch = () => { const handleSearch = () => {
if (Array.isArray(searchForm.dateRange) && searchForm.dateRange.length === 2) { if (Array.isArray(searchForm.dateRange) && searchForm.dateRange.length === 2) {
searchForm.created_from = searchForm.dateRange[0] searchForm.start_time = searchForm.dateRange[0]
searchForm.created_to = searchForm.dateRange[1] searchForm.end_time = searchForm.dateRange[1]
} else { } else {
searchForm.created_from = '' searchForm.start_time = ''
searchForm.created_to = '' searchForm.end_time = ''
} }
pagination.page = 1 pagination.page = 1
reload() reload()

View File

@@ -0,0 +1,226 @@
<template>
<div class="h5-popup-configuration-detail-page">
<ElCard shadow="never">
<div class="detail-header">
<ElButton @click="handleBack">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">H5运营弹窗配置详情</h2>
</div>
<DetailPage v-if="configuration" :sections="detailSections" :data="configuration" />
<div v-if="loading" class="loading-container">
<ElIcon class="is-loading"><Loading /></ElIcon>
<span>加载中...</span>
</div>
<ElEmpty v-else-if="!configuration" description="未找到运营弹窗配置" />
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElButton, ElCard, ElIcon, ElMessage, ElTag } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { H5PopupConfigurationService } from '@/api/modules'
import type { H5PopupConfiguration, H5PopupPage, H5PopupType } from '@/types/api'
import { RoutesAlias } from '@/router/routesAlias'
import { formatDateTime } from '@/utils/business/format'
import { getErrorMessage } from '@/utils/business'
defineOptions({ name: 'H5PopupConfigurationDetail' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const configuration = ref<H5PopupConfiguration | null>(null)
const popupTypeNames: Record<H5PopupType, string> = {
promotion: '套餐政策推广',
announcement: '通用公告'
}
const pageNames: Record<H5PopupPage, string> = {
home: '首页',
asset_detail: '资产详情',
package_purchase: '套餐购买',
asset_wallet_recharge: '资产钱包充值'
}
const frequencyNames = {
once: '每客户每配置版本仅一次',
daily: '每客户每配置版本每个上海自然日一次'
}
const actionTypeNames: Record<string, string> = {
package_purchase: '套餐购买',
asset_wallet_recharge: '资产钱包充值'
}
const cardTypeNames: Record<string, string> = {
CMCC: '中国移动',
CUCC: '中国联通',
CTCC: '中国电信',
CBN: '中国广电'
}
const formatList = (
values: Array<string | number> | null | undefined,
formatter?: (value: string) => string
) => {
if (!values?.length) return '全部'
return values.map((value) => formatter?.(String(value)) || String(value)).join('、')
}
const detailSections = computed((): DetailSection[] => [
{
title: '基础信息',
fields: [
{ label: '配置ID', prop: 'id' },
{ label: '版本', prop: 'version' },
{
label: '弹窗类型',
render: (data: H5PopupConfiguration) =>
h(
ElTag,
{ type: 'primary' },
() => data.popup_type_text || popupTypeNames[data.popup_type]
)
},
{
label: '启停状态',
render: (data: H5PopupConfiguration) =>
h(
ElTag,
{ type: data.enabled ? 'success' : 'info' },
() => data.enabled_text || (data.enabled ? '启用' : '停用')
)
},
{ label: '标题', prop: 'title', fullWidth: true },
{ label: '正文', prop: 'content', fullWidth: true }
]
},
{
title: '投放配置',
fields: [
{
label: '命中页面',
formatter: (_value: unknown, data: H5PopupConfiguration) =>
formatList(data.pages, (value) => pageNames[value as H5PopupPage] || value),
fullWidth: true
},
{ label: '优先级', prop: 'priority' },
{
label: '投放频率',
formatter: (_value: unknown, data: H5PopupConfiguration) =>
data.frequency_text || frequencyNames[data.frequency] || data.frequency
},
{
label: '受控动作',
formatter: (_value: unknown, data: H5PopupConfiguration) =>
actionTypeNames[data.action_type || ''] || '无'
},
{
label: '生效开始',
formatter: (_value: unknown, data: H5PopupConfiguration) => formatDateTime(data.starts_at)
},
{
label: '生效结束',
formatter: (_value: unknown, data: H5PopupConfiguration) => formatDateTime(data.ends_at)
}
]
},
{
title: '投放范围与审计信息',
fields: [
{
label: '店铺范围',
formatter: (_value: unknown, data: H5PopupConfiguration) => formatList(data.shop_ids),
fullWidth: true
},
{
label: '设备类型范围',
formatter: (_value: unknown, data: H5PopupConfiguration) => formatList(data.device_types),
fullWidth: true
},
{
label: '卡类型范围',
formatter: (_value: unknown, data: H5PopupConfiguration) =>
formatList(data.card_types, (value) => cardTypeNames[value] || value),
fullWidth: true
},
{ label: '创建人', prop: 'creator' },
{
label: '创建时间',
formatter: (_value: unknown, data: H5PopupConfiguration) =>
formatDateTime(data.created_at)
},
{ label: '最近更新人', prop: 'updater' },
{
label: '最近更新时间',
formatter: (_value: unknown, data: H5PopupConfiguration) =>
formatDateTime(data.updated_at)
}
]
}
])
const loadDetail = async () => {
const id = Number(route.params.id)
if (!id) {
ElMessage.error('无效的配置ID')
return
}
loading.value = true
try {
const res = await H5PopupConfigurationService.getConfiguration(id)
if (res.code === 0) configuration.value = res.data
else ElMessage.error(res.msg || '获取运营弹窗配置详情失败')
} catch (error) {
ElMessage.error(getErrorMessage(error, '获取运营弹窗配置详情失败'))
} finally {
loading.value = false
}
}
const handleBack = () => {
router.push(RoutesAlias.H5PopupConfigurations)
}
onMounted(() => void loadDetail())
</script>
<style scoped lang="scss">
.h5-popup-configuration-detail-page {
height: 100%;
}
.detail-header {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.detail-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.loading-container {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
min-height: 240px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -52,6 +52,16 @@
@closed="resetForm" @closed="resetForm"
> >
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="110px"> <ElForm ref="formRef" :model="form" :rules="formRules" label-width="110px">
<ElFormItem label="弹窗类型" prop="popup_type">
<ElSelect v-model="form.popup_type" placeholder="请选择弹窗类型" style="width: 100%">
<ElOption
v-for="item in POPUP_TYPE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="弹窗标题" prop="title"> <ElFormItem label="弹窗标题" prop="title">
<ElInput <ElInput
v-model="form.title" v-model="form.title"
@@ -174,60 +184,12 @@
</ElButton> </ElButton>
</template> </template>
</ElDialog> </ElDialog>
<!-- 详情 -->
<ElDialog v-model="detailDialogVisible" title="运营弹窗配置详情" width="680px">
<div v-if="detailLoading" class="detail-loading">加载中...</div>
<ElDescriptions v-else-if="detail" :column="2" border>
<ElDescriptionsItem label="配置ID">{{ detail.id }}</ElDescriptionsItem>
<ElDescriptionsItem label="版本">{{ detail.version }}</ElDescriptionsItem>
<ElDescriptionsItem label="标题" :span="2">{{ detail.title }}</ElDescriptionsItem>
<ElDescriptionsItem label="正文" :span="2">
<span class="detail-content">{{ detail.content }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="命中页面" :span="2">
{{ formatPages(detail.pages) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="店铺范围">
{{ formatScope(detail.shop_ids) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="设备类型范围">
{{ formatScope(detail.device_types) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="卡类型范围">
{{ formatCardTypes(detail.card_types) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="优先级">{{ detail.priority }}</ElDescriptionsItem>
<ElDescriptionsItem label="投放频率">
{{ detail.frequency_text || FREQUENCY_NAMES[detail.frequency] || detail.frequency }}
</ElDescriptionsItem>
<ElDescriptionsItem label="启停状态">
{{ detail.enabled_text || (detail.enabled ? '启用' : '停用') }}
</ElDescriptionsItem>
<ElDescriptionsItem label="受控动作">
{{ ACTION_TYPE_NAMES[detail.action_type] || '无' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="生效开始" :span="2">
{{ formatDateTime(detail.starts_at) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="生效结束" :span="2">
{{ formatDateTime(detail.ends_at) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="创建人">{{ detail.creator }}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{
formatDateTime(detail.created_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="最近更新人">{{ detail.updater }}</ElDescriptionsItem>
<ElDescriptionsItem label="最近更新时间">{{
formatDateTime(detail.updated_at)
}}</ElDescriptionsItem>
</ElDescriptions>
</ElDialog>
</ArtTableFullScreen> </ArtTableFullScreen>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue' import { computed, h, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, ElTag, FormInstance, FormRules } from 'element-plus' import { ElMessage, ElMessageBox, ElTag, FormInstance, FormRules } from 'element-plus'
import { Plus } from '@element-plus/icons-vue' import { Plus } from '@element-plus/icons-vue'
import { H5PopupConfigurationService, ShopService } from '@/api/modules' import { H5PopupConfigurationService, ShopService } from '@/api/modules'
@@ -242,12 +204,14 @@
H5PopupCardType, H5PopupCardType,
H5PopupConfiguration, H5PopupConfiguration,
H5PopupFrequency, H5PopupFrequency,
H5PopupPage H5PopupPage,
H5PopupType
} from '@/types/api' } from '@/types/api'
defineOptions({ name: 'H5PopupConfiguration' }) defineOptions({ name: 'H5PopupConfiguration' })
const { hasAuth } = useAuth() const { hasAuth } = useAuth()
const router = useRouter()
const perms = AUGUST_PERMISSIONS.h5PopupConfiguration const perms = AUGUST_PERMISSIONS.h5PopupConfiguration
const canCreate = computed(() => hasAuth(perms.create)) const canCreate = computed(() => hasAuth(perms.create))
@@ -267,6 +231,14 @@
{ label: '套餐购买', value: 'package_purchase' }, { label: '套餐购买', value: 'package_purchase' },
{ label: '资产钱包充值', value: 'asset_wallet_recharge' } { label: '资产钱包充值', value: 'asset_wallet_recharge' }
] ]
const POPUP_TYPE_NAMES: Record<H5PopupType, string> = {
promotion: '套餐政策推广',
announcement: '通用公告'
}
const POPUP_TYPE_OPTIONS: Array<{ label: string; value: H5PopupType }> = [
{ label: '套餐政策推广', value: 'promotion' },
{ label: '通用公告', value: 'announcement' }
]
const FREQUENCY_NAMES: Record<H5PopupFrequency, string> = { const FREQUENCY_NAMES: Record<H5PopupFrequency, string> = {
once: '每客户每配置版本仅一次', once: '每客户每配置版本仅一次',
daily: '每客户每配置版本每个上海自然日一次' daily: '每客户每配置版本每个上海自然日一次'
@@ -322,6 +294,7 @@
] ]
const columnOptions = [ const columnOptions = [
{ label: '弹窗类型', prop: 'popup_type' },
{ label: '标题', prop: 'title' }, { label: '标题', prop: 'title' },
{ label: '命中页面', prop: 'pages' }, { label: '命中页面', prop: 'pages' },
{ label: '范围', prop: 'scope' }, { label: '范围', prop: 'scope' },
@@ -341,15 +314,6 @@
return pages.map((page) => PAGE_NAMES[page] || page).join('、') return pages.map((page) => PAGE_NAMES[page] || page).join('、')
} }
const formatScope = (values: string[] | number[] | null | undefined): string => {
if (!values || values.length === 0) return '全部'
const text = values
.slice(0, 3)
.map((item) => (typeof item === 'number' && item !== 0 ? `#${item}` : String(item)))
.join('、')
return values.length > 3 ? `${text} 等${values.length}项` : text
}
const formatCardTypes = (values: H5PopupCardType[] | null | undefined): string => { const formatCardTypes = (values: H5PopupCardType[] | null | undefined): string => {
if (!values || values.length === 0) return '全部' if (!values || values.length === 0) return '全部'
return values.map((item) => CARD_TYPE_NAMES[item] || item).join('、') return values.map((item) => CARD_TYPE_NAMES[item] || item).join('、')
@@ -370,7 +334,31 @@
} }
const { columnChecks, columns } = useCheckedColumns(() => [ const { columnChecks, columns } = useCheckedColumns(() => [
{ prop: 'title', label: '标题', minWidth: 180, showOverflowTooltip: true }, {
prop: 'popup_type',
label: '弹窗类型',
width: 120,
formatter: (row: H5PopupConfiguration) =>
row.popup_type_text || POPUP_TYPE_NAMES[row.popup_type] || row.popup_type
},
{
prop: 'title',
label: '标题',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: H5PopupConfiguration) =>
h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: (event: MouseEvent) => {
event.stopPropagation()
goDetail(row)
}
},
row.title
)
},
{ {
prop: 'pages', prop: 'pages',
label: '命中页面', label: '命中页面',
@@ -409,7 +397,7 @@
prop: 'action_type', prop: 'action_type',
label: '受控动作', label: '受控动作',
width: 120, width: 120,
formatter: (row: H5PopupConfiguration) => ACTION_TYPE_NAMES[row.action_type] || '无' formatter: (row: H5PopupConfiguration) => ACTION_TYPE_NAMES[row.action_type || ''] || '无'
}, },
{ {
prop: 'effective_time', prop: 'effective_time',
@@ -500,10 +488,15 @@
// ========== 行操作 ========== // ========== 行操作 ==========
const goDetail = (row: H5PopupConfiguration) => {
router.push({
name: 'H5PopupConfigurationDetail',
params: { id: String(row.id) }
})
}
const getActions = (row: H5PopupConfiguration) => { const getActions = (row: H5PopupConfiguration) => {
const actions: any[] = [ const actions: any[] = []
{ label: '详情', handler: () => openDetailDialog(row), type: 'primary' }
]
if (canUpdate.value) { if (canUpdate.value) {
actions.push({ label: '编辑', handler: () => openEditDialog(row) }) actions.push({ label: '编辑', handler: () => openEditDialog(row) })
} }
@@ -555,33 +548,6 @@
} }
} }
// ========== 详情 ==========
const detailDialogVisible = ref(false)
const detailLoading = ref(false)
const detail = ref<H5PopupConfiguration | null>(null)
const openDetailDialog = async (row: H5PopupConfiguration) => {
detail.value = null
detailDialogVisible.value = true
detailLoading.value = true
try {
const res = await H5PopupConfigurationService.getConfiguration(row.id)
if (res.code === 0 && res.data) {
detail.value = res.data
} else {
ElMessage.error(res.msg || '获取配置详情失败')
detailDialogVisible.value = false
}
} catch (error) {
console.error('获取运营弹窗配置详情失败:', error)
ElMessage.error(normalizeApiError(error).message || '获取配置详情失败')
detailDialogVisible.value = false
} finally {
detailLoading.value = false
}
}
// ========== 创建 / 编辑 ========== // ========== 创建 / 编辑 ==========
const formDialogVisible = ref(false) const formDialogVisible = ref(false)
@@ -593,19 +559,21 @@
const emptyForm = (): { const emptyForm = (): {
id: number id: number
popup_type: H5PopupType
title: string title: string
content: string content: string
pages: H5PopupPage[] pages: H5PopupPage[]
priority: number priority: number
frequency: H5PopupFrequency frequency: H5PopupFrequency
enabled: boolean enabled: boolean
action_type: H5PopupActionType action_type: H5PopupActionType | ''
shop_ids: number[] shop_ids: number[]
device_types: string[] device_types: string[]
card_types: H5PopupCardType[] card_types: H5PopupCardType[]
timeRange: string[] timeRange: string[]
} => ({ } => ({
id: 0, id: 0,
popup_type: 'promotion',
title: '', title: '',
content: '', content: '',
pages: [], pages: [],
@@ -672,6 +640,7 @@
} }
const formRules: FormRules = { const formRules: FormRules = {
popup_type: [{ required: true, message: '请选择弹窗类型', trigger: 'change' }],
title: [ title: [
{ required: true, message: '请输入弹窗标题', trigger: 'blur' }, { required: true, message: '请输入弹窗标题', trigger: 'blur' },
{ min: 1, max: 100, message: '弹窗标题长度为 1~100 字符', trigger: 'blur' } { min: 1, max: 100, message: '弹窗标题长度为 1~100 字符', trigger: 'blur' }
@@ -720,13 +689,14 @@
formMode.value = 'edit' formMode.value = 'edit'
Object.assign(form, { Object.assign(form, {
id: row.id, id: row.id,
popup_type: row.popup_type,
title: row.title, title: row.title,
content: row.content, content: row.content,
pages: [...(row.pages || [])], pages: [...(row.pages || [])],
priority: row.priority, priority: row.priority,
frequency: row.frequency, frequency: row.frequency,
enabled: row.enabled, enabled: row.enabled,
action_type: (row.action_type || '') as H5PopupActionType, action_type: row.action_type || '',
shop_ids: [...(row.shop_ids || [])], shop_ids: [...(row.shop_ids || [])],
device_types: [...(row.device_types || [])], device_types: [...(row.device_types || [])],
card_types: [...(row.card_types || [])], card_types: [...(row.card_types || [])],
@@ -741,6 +711,7 @@
if (!(await formRef.value?.validate().catch(() => false))) return if (!(await formRef.value?.validate().catch(() => false))) return
formLoading.value = true formLoading.value = true
const payload = { const payload = {
popup_type: form.popup_type,
title: form.title.trim(), title: form.title.trim(),
content: form.content.trim(), content: form.content.trim(),
pages: [...form.pages], pages: [...form.pages],
@@ -749,14 +720,16 @@
starts_at: form.timeRange[0], starts_at: form.timeRange[0],
ends_at: form.timeRange[1], ends_at: form.timeRange[1],
enabled: form.enabled, enabled: form.enabled,
action_type: form.action_type,
shop_ids: [...form.shop_ids], shop_ids: [...form.shop_ids],
device_types: [...form.device_types], device_types: [...form.device_types],
card_types: [...form.card_types] card_types: [...form.card_types]
} }
try { try {
if (formMode.value === 'create') { if (formMode.value === 'create') {
const res = await H5PopupConfigurationService.createConfiguration(payload) const res = await H5PopupConfigurationService.createConfiguration({
...payload,
action_type: form.action_type || null
})
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
ElMessage.success('创建成功') ElMessage.success('创建成功')
formDialogVisible.value = false formDialogVisible.value = false
@@ -767,7 +740,8 @@
} else { } else {
const res = await H5PopupConfigurationService.updateConfiguration(form.id, { const res = await H5PopupConfigurationService.updateConfiguration(form.id, {
id: form.id, id: form.id,
...payload ...payload,
action_type: form.action_type
}) })
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
ElMessage.success( ElMessage.success(
@@ -796,15 +770,4 @@
.h5-popup-configuration-page { .h5-popup-configuration-page {
height: 100%; height: 100%;
} }
.detail-loading {
padding: 24px 0;
text-align: center;
color: var(--el-text-color-secondary);
}
.detail-content {
white-space: pre-wrap;
word-break: break-all;
}
</style> </style>

View File

@@ -1,34 +1,6 @@
<template> <template>
<ArtTableFullScreen> <ArtTableFullScreen>
<div class="business-owner-import-page" id="table-full-screen"> <div class="business-owner-import-page" id="table-full-screen">
<!-- 导入入口卡片 -->
<ElCard shadow="never" class="import-entry-card">
<div class="import-entry">
<div class="import-tip">
<p>通过 CSV 批量交接/清空店铺平台业务员。</p>
<p
>表头固定:<code>店铺编码,操作类型,业务员登录账号,备注</code>;操作类型仅支持「换绑」或「清空」。</p
>
<p>支持 UTF-8(可带 BOM),一行失败不影响其他行。</p>
</div>
<div class="import-actions">
<ElButton
v-permission="SHOP_BUSINESS_OWNER_PERMISSIONS.importPage"
@click="handleDownloadTemplate"
>
下载模板
</ElButton>
<ElButton
type="primary"
v-permission="SHOP_BUSINESS_OWNER_PERMISSIONS.importCreate"
@click="importDialogVisible = true"
>
导入业务员
</ElButton>
</div>
</div>
</ElCard>
<ElCard shadow="never" class="art-table-card"> <ElCard shadow="never" class="art-table-card">
<!-- 表格头部 --> <!-- 表格头部 -->
<ArtTableHeader <ArtTableHeader
@@ -37,20 +9,29 @@
@refresh="handleRefresh" @refresh="handleRefresh"
> >
<template #left> <template #left>
<ElSelect <div class="header-actions">
v-model="searchForm.status" <ElSelect
placeholder="状态:全部" v-model="searchForm.status"
clearable placeholder="状态:全部"
style="width: 160px" clearable
@change="handleSearch" style="width: 160px"
> @change="handleSearch"
<ElOption >
v-for="option in importStatusOptions" <ElOption
:key="option.value" v-for="option in importStatusOptions"
:label="option.label" :key="option.value"
:value="option.value" :label="option.label"
/> :value="option.value"
</ElSelect> />
</ElSelect>
<ElButton
type="primary"
v-permission="SHOP_BUSINESS_OWNER_PERMISSIONS.importCreate"
@click="importDialogVisible = true"
>
导入业务员
</ElButton>
</div>
</template> </template>
</ArtTableHeader> </ArtTableHeader>
@@ -63,8 +44,6 @@
:currentPage="pagination.page" :currentPage="pagination.page"
:pageSize="pagination.pageSize" :pageSize="pagination.pageSize"
:total="pagination.total" :total="pagination.total"
:actions="getActions"
:actionsWidth="120"
:marginTop="10" :marginTop="10"
:height="400" :height="400"
@size-change="handleSizeChange" @size-change="handleSizeChange"
@@ -80,7 +59,12 @@
<!-- 新建导入弹窗 --> <!-- 新建导入弹窗 -->
<ElDialog v-model="importDialogVisible" title="导入业务员" width="560px" destroy-on-close> <ElDialog v-model="importDialogVisible" title="导入业务员" width="560px" destroy-on-close>
<ElAlert type="info" :closable="false" show-icon class="import-alert"> <ElAlert type="info" :closable="false" show-icon class="import-alert">
请先下载模板,按模板填写店铺编码与操作类型后再上传 CSV。 <template #title>导入说明</template>
<div>通过 CSV 批量交接或清空店铺平台业务员。</div>
<div>
表头固定:<code>店铺编码,操作类型,业务员登录账号,备注</code>;操作类型仅支持「换绑」或「清空」。
</div>
<div>支持 UTF-8(可带 BOM),一行失败不影响其他行。</div>
</ElAlert> </ElAlert>
<div class="upload-tip-row"> <div class="upload-tip-row">
<ElButton @click="handleDownloadTemplate">下载模板</ElButton> <ElButton @click="handleDownloadTemplate">下载模板</ElButton>
@@ -118,11 +102,11 @@
<!-- 导入结果抽屉 --> <!-- 导入结果抽屉 -->
<ElDrawer <ElDrawer
v-model="detailDrawerVisible" v-model="detailDrawerVisible"
:title="`导入结果 - ${currentTask?.id || ''}`" :title="`导入结果${currentTask?.task_no ? ` - ${currentTask.task_no}` : ''}`"
size="70%" size="820px"
destroy-on-close destroy-on-close
> >
<div v-if="currentTask" class="import-detail"> <div v-if="currentTask" v-loading="detailLoading" class="import-detail">
<ElAlert <ElAlert
:type="detailAlertType" :type="detailAlertType"
:closable="false" :closable="false"
@@ -136,19 +120,19 @@
</ElAlert> </ElAlert>
<div class="detail-stats"> <div class="detail-stats">
<div class="stat-item"> <div class="stat-item stat-item--total">
<span class="stat-label">总数</span> <span class="stat-label">总数</span>
<strong>{{ currentTask.total_count ?? 0 }}</strong> <strong>{{ currentTask.total_count ?? 0 }}</strong>
</div> </div>
<div class="stat-item"> <div class="stat-item stat-item--success">
<span class="stat-label">成功</span> <span class="stat-label">成功</span>
<strong class="stat-success">{{ currentTask.success_count ?? 0 }}</strong> <strong class="stat-success">{{ currentTask.success_count ?? 0 }}</strong>
</div> </div>
<div class="stat-item"> <div class="stat-item stat-item--fail">
<span class="stat-label">失败</span> <span class="stat-label">失败</span>
<strong class="stat-fail">{{ currentTask.fail_count ?? 0 }}</strong> <strong class="stat-fail">{{ currentTask.fail_count ?? 0 }}</strong>
</div> </div>
<div class="stat-item"> <div class="stat-item stat-item--status">
<span class="stat-label">状态</span> <span class="stat-label">状态</span>
<strong>{{ currentTask.status_name || importStatusLabel(currentTask.status) }}</strong> <strong>{{ currentTask.status_name || importStatusLabel(currentTask.status) }}</strong>
</div> </div>
@@ -181,6 +165,10 @@
</div> </div>
</div> </div>
<div class="detail-section-title">
<span>逐行处理结果</span>
<ElTag size="small" type="info">{{ rowItems.length }} 条</ElTag>
</div>
<ElTable v-if="rowItems.length" :data="rowItems" border stripe max-height="420"> <ElTable v-if="rowItems.length" :data="rowItems" border stripe max-height="420">
<ElTableColumn label="行号" prop="line" width="80" align="center" /> <ElTableColumn label="行号" prop="line" width="80" align="center" />
<ElTableColumn label="店铺编码" prop="shop_code" min-width="140" /> <ElTableColumn label="店铺编码" prop="shop_code" min-width="140" />
@@ -195,8 +183,8 @@
<ElTableColumn label="失败原因" prop="reason" min-width="220" show-overflow-tooltip /> <ElTableColumn label="失败原因" prop="reason" min-width="220" show-overflow-tooltip />
</ElTable> </ElTable>
<ElEmpty <ElEmpty
v-else-if="!detailLoading && !rowItems.length && currentTask.status === 4" v-else-if="!detailLoading"
description="任务级失败,无行级明细" :description="currentTask.status === 4 ? '任务级失败,无行级明细' : '暂无逐行处理结果'"
/> />
</div> </div>
</ElDrawer> </ElDrawer>
@@ -211,14 +199,11 @@
import { ShopService, StorageService } from '@/api/modules' import { ShopService, StorageService } from '@/api/modules'
import type { BusinessOwnerImportDetail } from '@/types/api' import type { BusinessOwnerImportDetail } from '@/types/api'
import { useCheckedColumns } from '@/composables/useCheckedColumns' import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import { SHOP_BUSINESS_OWNER_PERMISSIONS } from '@/config/constants' import { SHOP_BUSINESS_OWNER_PERMISSIONS } from '@/config/constants'
defineOptions({ name: 'BusinessOwnerImport' }) defineOptions({ name: 'BusinessOwnerImport' })
const { hasAuth } = useAuth()
const importStatusOptions = [ const importStatusOptions = [
{ label: '待处理', value: 1 }, { label: '待处理', value: 1 },
{ label: '处理中', value: 2 }, { label: '处理中', value: 2 },
@@ -244,7 +229,6 @@
}) })
const columnOptions = [ const columnOptions = [
{ label: '任务ID', prop: 'id', width: 90 },
{ label: '任务编号', prop: 'task_no', width: 160 }, { label: '任务编号', prop: 'task_no', width: 160 },
{ label: '文件名', prop: 'file_name', minWidth: 160, showOverflowTooltip: true }, { label: '文件名', prop: 'file_name', minWidth: 160, showOverflowTooltip: true },
{ label: '创建人', prop: 'creator_name', width: 100 }, { label: '创建人', prop: 'creator_name', width: 100 },
@@ -258,16 +242,23 @@
] ]
const { columnChecks, columns } = useCheckedColumns(() => [ const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'id',
label: '任务ID',
width: 90
},
{ {
prop: 'task_no', prop: 'task_no',
label: '任务编号', label: '任务编号',
width: 160, width: 160,
showOverflowTooltip: true showOverflowTooltip: true,
formatter: (row: BusinessOwnerImportDetail) =>
h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: (event: MouseEvent) => {
event.stopPropagation()
openDetail(row)
}
},
row.task_no || '--'
)
}, },
{ {
prop: 'file_name', prop: 'file_name',
@@ -318,17 +309,6 @@
} }
]) ])
const getActions = (row: BusinessOwnerImportDetail) => {
if (!hasAuth(SHOP_BUSINESS_OWNER_PERMISSIONS.importPage)) return []
return [
{
label: '查看结果',
handler: () => openDetail(row),
type: 'primary' as const
}
]
}
const getList = async () => { const getList = async () => {
loading.value = true loading.value = true
try { try {
@@ -337,8 +317,13 @@
page_size: pagination.pageSize, page_size: pagination.pageSize,
status: searchForm.status status: searchForm.status
}) })
tableData.value = res.data?.items || [] if (res.code === 0 && res.data) {
pagination.total = res.data?.total || 0 const { items, total, page, size } = res.data
tableData.value = items || []
pagination.total = total || 0
pagination.page = page ?? pagination.page
pagination.pageSize = size ?? pagination.pageSize
}
} catch { } catch {
// 错误提示由请求层统一处理 // 错误提示由请求层统一处理
} finally { } finally {
@@ -537,38 +522,10 @@
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.import-entry-card { .header-actions {
margin-bottom: 12px; display: flex;
gap: 8px;
.import-entry { align-items: center;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
.import-tip {
font-size: 13px;
color: var(--el-text-color-secondary);
line-height: 1.8;
p {
margin: 0;
}
code {
padding: 2px 6px;
font-size: 12px;
background: var(--el-fill-color-light);
border-radius: 4px;
}
}
.import-actions {
display: flex;
gap: 8px;
}
}
} }
.import-alert { .import-alert {
@@ -596,8 +553,11 @@
} }
.import-detail { .import-detail {
padding: 0 4px 20px;
.detail-alert { .detail-alert {
margin-bottom: 16px; margin-bottom: 16px;
border-radius: 10px;
} }
.detail-error { .detail-error {
@@ -607,14 +567,35 @@
} }
.detail-stats { .detail-stats {
display: flex; display: grid;
gap: 24px; grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 16px; gap: 12px;
margin-bottom: 20px;
.stat-item { .stat-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 6px;
min-width: 0;
padding: 14px 16px;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
&--success {
background: var(--el-color-success-light-9);
border-color: var(--el-color-success-light-7);
}
&--fail {
background: var(--el-color-danger-light-9);
border-color: var(--el-color-danger-light-7);
}
&--status {
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-7);
}
.stat-label { .stat-label {
font-size: 12px; font-size: 12px;
@@ -622,7 +603,8 @@
} }
strong { strong {
font-size: 18px; font-size: 22px;
line-height: 1.2;
} }
.stat-success { .stat-success {
@@ -638,11 +620,12 @@
.detail-meta { .detail-meta {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px 24px; gap: 14px 24px;
margin-bottom: 16px; margin-bottom: 20px;
padding: 12px 16px; padding: 16px;
background: var(--el-fill-color-light); background: var(--el-fill-color-lighter);
border-radius: 6px; border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
.meta-item { .meta-item {
display: flex; display: flex;
@@ -662,5 +645,15 @@
} }
} }
} }
.detail-section-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
} }
</style> </style>