Files
one-pipe-system/src/views/settings/h5-popup-configuration/index.vue
luo 085a83d4e4
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Has been cancelled
fix: some
2026-09-20 10:10:50 +08:00

774 lines
24 KiB
Vue
Raw Blame History

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