Add domestic vendor push infrastructure

This commit is contained in:
2026-07-26 01:45:59 +08:00
parent 7cca34b331
commit 0738953e6d
77 changed files with 6470 additions and 855 deletions
+4 -2
View File
@@ -2,7 +2,8 @@
import { ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { DashboardOutlined, SettingOutlined, ControlOutlined, SmileOutlined,
GithubOutlined, PictureOutlined, TeamOutlined, AppstoreOutlined } from '@ant-design/icons-vue'
GithubOutlined, PictureOutlined, TeamOutlined, AppstoreOutlined,
NotificationOutlined } from '@ant-design/icons-vue'
const router = useRouter()
const route = useRoute()
@@ -20,6 +21,7 @@ const nav = [
{ key: 'Avatars', icon: GithubOutlined, label: 'AI 形象' },
{ key: 'Stickers', icon: PictureOutlined, label: '表情包库' },
{ key: 'Users', icon: TeamOutlined, label: '用户管理' },
{ key: 'PushCampaigns', icon: NotificationOutlined, label: '推送管理' },
]
</script>
@@ -37,7 +39,7 @@ const nav = [
<span>{{ n.label }}</span>
</a-menu-item>
</a-menu>
<div style="position:absolute;bottom:16px;left:20px;font-size:11px;color:#bbb">v20260718-1630</div>
<div style="position:absolute;bottom:16px;left:20px;font-size:11px;color:#bbb">v20260726-0130</div>
</a-layout-sider>
<a-layout>
<a-layout-content style="margin: 18px 20px; padding: 20px; background: #fff; border-radius: 10px; min-height: 360px;">
+12 -3
View File
@@ -44,9 +44,18 @@ export const api = {
sysCategories: () => http.get('/api/admin/categories').then(r => r.data),
createSysCategory: (d: { name: string; iconKey: string; type: string }) => http.post('/api/admin/categories', d).then(r => r.data),
updateSysCategory: (id: number, d: { name: string; iconKey: string; type: string }) => http.put(`/api/admin/categories/${id}`, d).then(r => r.data),
deleteSysCategory: (id: number) => http.delete(`/api/admin/categories/${id}`),
testLlm: () => http.post('/api/admin/llm/test').then(r => r.data),
}
deleteSysCategory: (id: number) => http.delete(`/api/admin/categories/${id}`),
testLlm: () => http.post('/api/admin/llm/test').then(r => r.data),
pushCampaigns: (params: { page: number; limit: number }) => http.get('/api/admin/push/campaigns', { params }).then(r => r.data),
estimatePushCampaign: (data: any) => http.post('/api/admin/push/campaigns/estimate', data).then(r => r.data),
createPushCampaign: (data: any) => http.post('/api/admin/push/campaigns', data).then(r => r.data),
updatePushCampaign: (id: number, data: any) => http.put(`/api/admin/push/campaigns/${id}`, data).then(r => r.data),
sendPushCampaign: (id: number, scheduledAt?: string) => http.post(`/api/admin/push/campaigns/${id}/send`, { scheduledAt }).then(r => r.data),
cancelPushCampaign: (id: number) => http.post(`/api/admin/push/campaigns/${id}/cancel`).then(r => r.data),
pushDevices: (params: { search?: string; limit?: number }) => http.get('/api/admin/push/devices', { params }).then(r => r.data),
testPush: (data: any) => http.post('/api/admin/push/test', data).then(r => r.data),
pushHealth: () => http.get('/api/admin/push/health').then(r => r.data),
}
export default http
+2 -1
View File
@@ -12,7 +12,8 @@ const router = createRouter({
{ path: '/avatars', name: 'Avatars', component: () => import('../views/Avatars.vue') },
{ path: '/stickers', name: 'Stickers', component: () => import('../views/Stickers.vue') },
{ path: '/users', name: 'Users', component: () => import('../views/Users.vue') },
{ path: '/push', name: 'PushCampaigns', component: () => import('../views/PushCampaigns.vue') },
],
})
export default router
export default router
+618
View File
@@ -0,0 +1,618 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import dayjs, { type Dayjs } from 'dayjs'
import { message } from 'ant-design-vue'
import {
EditOutlined,
PlusOutlined,
ReloadOutlined,
SendOutlined,
} from '@ant-design/icons-vue'
import { api } from '../api'
interface DeliveryCounts {
queued: number
sending: number
accepted: number
failed: number
skipped: number
}
interface Campaign {
id: number
publicId: string
state: string
title: string
body: string
category: string
action: string
entityId: string | null
flavor: string
provider: string | null
minVersionCode: number | null
maxVersionCode: number | null
targetUserId: number | null
ttlSeconds: number
scheduledAt: string | null
createdAt: string
startedAt: string | null
completedAt: string | null
cancelledAt: string | null
deliveries: DeliveryCounts
}
interface PushDevice {
id: number
userId: number
username: string
provider: string
packageName: string
flavor: string
appVersion: string
versionCode: number
notificationsAllowed: boolean
isActive: boolean
disabledReason: string | null
tokenSuffix: string
lastSeenAt: string
}
interface ProviderHealth {
provider: string
environments: Array<{ flavor: string; enabled: boolean; errors: string[] }>
}
interface PushHealth {
enabled: boolean
tokenEncryptionConfigured: boolean
providers: ProviderHealth[]
}
type SubmitMode = 'draft' | 'now' | 'scheduled'
const activeTab = ref('campaigns')
const campaigns = ref<Campaign[]>([])
const campaignTotal = ref(0)
const campaignPage = ref(1)
const pageSize = 20
const campaignLoading = ref(false)
const health = ref<PushHealth | null>(null)
const healthLoading = ref(false)
const editorOpen = ref(false)
const editingId = ref<number | null>(null)
const editorSaving = ref(false)
const estimating = ref(false)
const estimate = ref<number | null>(null)
const scheduleAt = ref<Dayjs | null>(null)
const form = reactive(emptyCampaign())
const devices = ref<PushDevice[]>([])
const deviceLoading = ref(false)
const deviceSearch = ref('')
const testOpen = ref(false)
const testSaving = ref(false)
const testForm = reactive({
deviceId: undefined as number | undefined,
title: '',
body: '',
category: 'system',
action: 'none',
entityId: '',
})
const categoryLabels: Record<string, string> = {
system: '系统通知',
budget: '预算提醒',
operations: '运营通知',
}
const actionLabels: Record<string, string> = {
none: '仅打开应用',
home: '首页',
budget: '预算页面',
update: '版本更新',
}
const providerLabels: Record<string, string> = {
huawei: '华为',
honor: '荣耀',
xiaomi: '小米',
oppo: 'OPPO',
vivo: 'vivo',
meizu: '魅族',
}
const stateMeta: Record<string, { label: string; color: string }> = {
draft: { label: '草稿', color: 'default' },
scheduled: { label: '已定时', color: 'blue' },
queued: { label: '待发送', color: 'cyan' },
sending: { label: '发送中', color: 'processing' },
completed: { label: '已完成', color: 'success' },
partially_failed: { label: '部分失败', color: 'warning' },
cancelled: { label: '已取消', color: 'default' },
}
const campaignColumns = [
{ title: '内容', key: 'content', width: 310 },
{ title: '目标', key: 'target', width: 180 },
{ title: '状态', key: 'state', width: 100 },
{ title: '投递结果', key: 'deliveries', width: 220 },
{ title: '时间', key: 'time', width: 180 },
{ title: '操作', key: 'actions', width: 210 },
]
const deviceColumns = [
{ title: '设备', key: 'device', width: 220 },
{ title: '用户', key: 'user', width: 150 },
{ title: '厂商', key: 'provider', width: 90 },
{ title: '应用', key: 'app', width: 170 },
{ title: '状态', key: 'state', width: 120 },
{ title: '最后活跃', key: 'time', width: 165 },
{ title: '操作', key: 'actions', width: 100 },
]
const canEstimate = computed(() => form.title.trim() && form.body.trim())
onMounted(async () => {
await Promise.all([loadCampaigns(), loadHealth()])
})
function emptyCampaign() {
return {
title: '',
body: '',
category: 'system',
action: 'none',
entityId: '',
flavor: 'production',
provider: undefined as string | undefined,
minVersionCode: undefined as number | undefined,
maxVersionCode: undefined as number | undefined,
targetUserId: undefined as number | undefined,
ttlSeconds: 259200,
}
}
function campaignPayload() {
return {
title: form.title.trim(),
body: form.body.trim(),
category: form.category,
action: form.action,
entityId: form.entityId.trim() || null,
flavor: form.flavor,
provider: form.provider || null,
minVersionCode: form.minVersionCode ?? null,
maxVersionCode: form.maxVersionCode ?? null,
targetUserId: form.targetUserId ?? null,
ttlSeconds: form.ttlSeconds,
}
}
function assignForm(value: ReturnType<typeof emptyCampaign>) {
Object.assign(form, value)
}
async function loadCampaigns() {
campaignLoading.value = true
try {
const result = await api.pushCampaigns({ page: campaignPage.value, limit: pageSize })
campaigns.value = result.list
campaignTotal.value = result.total
} finally {
campaignLoading.value = false
}
}
async function loadHealth() {
healthLoading.value = true
try {
health.value = await api.pushHealth()
} catch (error) {
message.error(errorText(error, '推送服务状态读取失败'))
} finally {
healthLoading.value = false
}
}
async function loadDevices() {
deviceLoading.value = true
try {
devices.value = await api.pushDevices({ search: deviceSearch.value.trim() || undefined, limit: 100 })
} finally {
deviceLoading.value = false
}
}
function openCreate() {
editingId.value = null
estimate.value = null
scheduleAt.value = null
assignForm(emptyCampaign())
editorOpen.value = true
}
function openEdit(item: Campaign) {
editingId.value = item.id
estimate.value = null
scheduleAt.value = item.scheduledAt ? dayjs(normalizeUtc(item.scheduledAt)) : null
assignForm({
title: item.title,
body: item.body,
category: item.category,
action: item.action,
entityId: item.entityId || '',
flavor: item.flavor,
provider: item.provider || undefined,
minVersionCode: item.minVersionCode ?? undefined,
maxVersionCode: item.maxVersionCode ?? undefined,
targetUserId: item.targetUserId ?? undefined,
ttlSeconds: item.ttlSeconds,
})
editorOpen.value = true
}
async function estimateTargets() {
estimating.value = true
try {
const result = await api.estimatePushCampaign(campaignPayload())
estimate.value = result.devices
} catch (error) {
message.error(errorText(error, '目标设备估算失败'))
} finally {
estimating.value = false
}
}
async function submitCampaign(mode: SubmitMode) {
if (!form.title.trim() || !form.body.trim()) {
message.warning('请填写推送标题和正文')
return
}
if (mode === 'scheduled' && (!scheduleAt.value || !scheduleAt.value.isAfter(dayjs().add(5, 'second')))) {
message.warning('定时发送时间必须晚于当前时间')
return
}
editorSaving.value = true
try {
const saved = editingId.value
? await api.updatePushCampaign(editingId.value, campaignPayload())
: await api.createPushCampaign(campaignPayload())
if (mode !== 'draft') {
await api.sendPushCampaign(
saved.id,
mode === 'scheduled' ? scheduleAt.value?.toISOString() : undefined,
)
}
editorOpen.value = false
message.success(mode === 'draft' ? '草稿已保存' : mode === 'scheduled' ? '定时任务已保存' : '推送已进入发送队列')
await loadCampaigns()
} catch (error) {
message.error(errorText(error, '推送活动保存失败'))
} finally {
editorSaving.value = false
}
}
async function sendNow(item: Campaign) {
try {
await api.sendPushCampaign(item.id)
message.success('推送已进入发送队列')
await loadCampaigns()
} catch (error) {
message.error(errorText(error, '推送启动失败'))
}
}
async function cancelCampaign(item: Campaign) {
try {
await api.cancelPushCampaign(item.id)
message.success('推送活动已取消')
await loadCampaigns()
} catch (error) {
message.error(errorText(error, '取消失败'))
}
}
async function openTest(device?: PushDevice) {
if (!devices.value.length) await loadDevices()
Object.assign(testForm, {
deviceId: device?.id,
title: '',
body: '',
category: 'system',
action: 'none',
entityId: '',
})
testOpen.value = true
}
async function submitTest() {
if (!testForm.deviceId || !testForm.title.trim() || !testForm.body.trim()) {
message.warning('请选择设备并填写推送内容')
return
}
testSaving.value = true
try {
await api.testPush({
...testForm,
title: testForm.title.trim(),
body: testForm.body.trim(),
entityId: testForm.entityId.trim() || null,
})
testOpen.value = false
message.success('测试推送已进入发送队列')
await loadCampaigns()
} catch (error) {
message.error(errorText(error, '测试推送失败'))
} finally {
testSaving.value = false
}
}
function onTabChange(value: string) {
activeTab.value = value
if (value === 'devices' && !devices.value.length) loadDevices()
}
function isEditable(item: Campaign) {
return item.state === 'draft' || item.state === 'scheduled'
}
function isCancellable(item: Campaign) {
return ['draft', 'scheduled', 'queued'].includes(item.state) && !item.startedAt
}
function state(item: Campaign) {
return stateMeta[item.state] || { label: item.state, color: 'default' }
}
function normalizeUtc(value: string) {
return /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value) ? value : `${value}Z`
}
function formatTime(value?: string | null) {
if (!value) return '-'
const date = new Date(normalizeUtc(value))
return Number.isNaN(date.getTime())
? '-'
: new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date).replaceAll('/', '-')
}
function errorText(error: unknown, fallback: string) {
const candidate = error as { response?: { data?: { message?: string; detail?: string; error?: string } } }
const data = candidate.response?.data
return data?.message || data?.detail || data?.error || fallback
}
</script>
<template>
<div class="page-header">
<div>
<h2>推送管理</h2>
<div class="subtle">厂商通道配置活动投递和设备联调</div>
</div>
<a-space>
<a-button @click="openTest()"><SendOutlined />测试设备</a-button>
<a-button type="primary" @click="openCreate"><PlusOutlined />新建推送</a-button>
</a-space>
</div>
<a-alert
v-if="health && (!health.enabled || !health.tokenEncryptionConfigured)"
type="warning"
show-icon
style="margin-bottom: 14px"
:message="!health.enabled ? '推送总开关未启用' : '设备令牌加密密钥未配置'"
description="完成服务端环境变量配置后再执行正式投递。" />
<a-spin :spinning="healthLoading">
<a-descriptions v-if="health" bordered size="small" :column="4" style="margin-bottom: 16px">
<a-descriptions-item label="服务">
<a-badge :status="health.enabled ? 'success' : 'default'" :text="health.enabled ? '已启用' : '未启用'" />
</a-descriptions-item>
<a-descriptions-item label="令牌加密">
<a-badge :status="health.tokenEncryptionConfigured ? 'success' : 'error'" :text="health.tokenEncryptionConfigured ? '已配置' : '缺少密钥'" />
</a-descriptions-item>
<a-descriptions-item v-for="item in health.providers" :key="item.provider" :label="providerLabels[item.provider] || item.provider">
<a-space size="small">
<a-tooltip v-for="env in item.environments" :key="env.flavor" :title="env.errors.join('、') || '配置完整'">
<a-tag :color="env.enabled && !env.errors.length ? 'success' : 'default'">
{{ env.flavor === 'production' ? '正式' : '内测' }}
</a-tag>
</a-tooltip>
</a-space>
</a-descriptions-item>
</a-descriptions>
</a-spin>
<a-tabs :active-key="activeTab" @change="onTabChange">
<a-tab-pane key="campaigns" tab="活动与历史">
<div class="toolbar">
<span class="subtle"> {{ campaignTotal }} 个活动</span>
<a-button size="small" @click="loadCampaigns"><ReloadOutlined />刷新</a-button>
</div>
<a-table
:columns="campaignColumns"
:data-source="campaigns"
:loading="campaignLoading"
row-key="id"
size="small"
:scroll="{ x: 1200 }"
:pagination="{
current: campaignPage,
pageSize,
total: campaignTotal,
showTotal: (total: number) => `共 ${total} 条`,
onChange: (page: number) => { campaignPage = page; loadCampaigns() },
}">
<template #bodyCell="{ column, record }: { column: { key: string }; record: Campaign }">
<template v-if="column.key === 'content'">
<div class="campaign-title">{{ record.title }}</div>
<div class="campaign-body">{{ record.body }}</div>
<a-tag>{{ categoryLabels[record.category] || record.category }}</a-tag>
<span class="action-label">{{ actionLabels[record.action] || record.action }}</span>
</template>
<template v-else-if="column.key === 'target'">
<div>{{ record.flavor === 'production' ? '正式环境' : '内测环境' }}</div>
<div class="subtle">
{{ record.provider ? providerLabels[record.provider] : '全部厂商' }}
<template v-if="record.targetUserId"> · 用户 {{ record.targetUserId }}</template>
</div>
<div v-if="record.minVersionCode || record.maxVersionCode" class="subtle">
版本 {{ record.minVersionCode || 1 }} - {{ record.maxVersionCode || '不限' }}
</div>
</template>
<template v-else-if="column.key === 'state'">
<a-tag :color="state(record).color">{{ state(record).label }}</a-tag>
</template>
<template v-else-if="column.key === 'deliveries'">
<a-space wrap size="small">
<a-tag color="success">成功 {{ record.deliveries.accepted }}</a-tag>
<a-tag v-if="record.deliveries.queued + record.deliveries.sending">处理中 {{ record.deliveries.queued + record.deliveries.sending }}</a-tag>
<a-tag v-if="record.deliveries.failed" color="error">失败 {{ record.deliveries.failed }}</a-tag>
<a-tag v-if="record.deliveries.skipped">跳过 {{ record.deliveries.skipped }}</a-tag>
</a-space>
</template>
<template v-else-if="column.key === 'time'">
<div>{{ record.scheduledAt ? '计划 ' + formatTime(record.scheduledAt) : formatTime(record.createdAt) }}</div>
<div v-if="record.completedAt" class="subtle">完成 {{ formatTime(record.completedAt) }}</div>
</template>
<template v-else-if="column.key === 'actions'">
<a-space size="small">
<a-button v-if="isEditable(record)" size="small" @click="openEdit(record)"><EditOutlined />编辑</a-button>
<a-popconfirm v-if="record.state === 'draft' || record.state === 'scheduled'" title="立即开始投递这条推送?" @confirm="sendNow(record)">
<a-button size="small" type="primary"><SendOutlined />发送</a-button>
</a-popconfirm>
<a-popconfirm v-if="isCancellable(record)" title="确定取消该推送活动?" @confirm="cancelCampaign(record)">
<a-button size="small" danger>取消</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="devices" tab="注册设备">
<div class="toolbar">
<a-input-search v-model:value="deviceSearch" placeholder="用户名或安装 ID" style="width: 280px" @search="loadDevices" />
<a-button size="small" @click="loadDevices"><ReloadOutlined />刷新</a-button>
</div>
<a-table :columns="deviceColumns" :data-source="devices" :loading="deviceLoading" row-key="id" size="small" :scroll="{ x: 1050 }" :pagination="false">
<template #bodyCell="{ column, record }: { column: { key: string }; record: PushDevice }">
<template v-if="column.key === 'device'">
<div class="mono">#{{ record.id }} · ...{{ record.tokenSuffix }}</div>
<div class="subtle mono">{{ record.packageName }}</div>
</template>
<template v-else-if="column.key === 'user'">
<div>{{ record.username }}</div>
<div class="subtle">用户 {{ record.userId }}</div>
</template>
<template v-else-if="column.key === 'provider'">
{{ providerLabels[record.provider] || record.provider }}
</template>
<template v-else-if="column.key === 'app'">
<div>{{ record.appVersion }} ({{ record.versionCode }})</div>
<a-tag>{{ record.flavor === 'production' ? '正式' : '内测' }}</a-tag>
</template>
<template v-else-if="column.key === 'state'">
<a-badge :status="record.isActive && record.notificationsAllowed ? 'success' : 'default'" :text="record.isActive && record.notificationsAllowed ? '可投递' : '不可投递'" />
<div v-if="record.disabledReason" class="subtle">{{ record.disabledReason }}</div>
</template>
<template v-else-if="column.key === 'time'">{{ formatTime(record.lastSeenAt) }}</template>
<template v-else-if="column.key === 'actions'">
<a-button size="small" :disabled="!record.isActive || !record.notificationsAllowed" @click="openTest(record)"><SendOutlined />测试</a-button>
</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
<a-modal v-model:open="editorOpen" :title="editingId ? '编辑推送' : '新建推送'" :width="760" :footer="null" :mask-closable="false">
<a-form layout="vertical">
<a-row :gutter="12">
<a-col :span="12"><a-form-item label="标题" required><a-input v-model:value="form.title" :maxlength="80" show-count /></a-form-item></a-col>
<a-col :span="6"><a-form-item label="分类" required><a-select v-model:value="form.category"><a-select-option v-for="(label, value) in categoryLabels" :key="value" :value="value">{{ label }}</a-select-option></a-select></a-form-item></a-col>
<a-col :span="6"><a-form-item label="有效期"><a-input-number v-model:value="form.ttlSeconds" :min="60" :max="604800" style="width: 100%" addon-after="" /></a-form-item></a-col>
</a-row>
<a-form-item label="正文" required><a-textarea v-model:value="form.body" :maxlength="240" show-count :rows="3" /></a-form-item>
<a-row :gutter="12">
<a-col :span="8"><a-form-item label="点击动作"><a-select v-model:value="form.action"><a-select-option v-for="(label, value) in actionLabels" :key="value" :value="value">{{ label }}</a-select-option></a-select></a-form-item></a-col>
<a-col :span="8"><a-form-item label="关联对象 ID"><a-input v-model:value="form.entityId" :disabled="form.action === 'none' || form.action === 'home'" /></a-form-item></a-col>
<a-col :span="8"><a-form-item label="环境"><a-segmented v-model:value="form.flavor" block :options="[{ label: '正式', value: 'production' }, { label: '内测', value: 'internal' }]" /></a-form-item></a-col>
</a-row>
<a-divider orientation="left">目标范围</a-divider>
<a-row :gutter="12">
<a-col :span="8"><a-form-item label="厂商"><a-select v-model:value="form.provider" allow-clear placeholder="全部厂商"><a-select-option v-for="(label, value) in providerLabels" :key="value" :value="value">{{ label }}</a-select-option></a-select></a-form-item></a-col>
<a-col :span="8"><a-form-item label="指定用户 ID"><a-input-number v-model:value="form.targetUserId" :min="1" style="width: 100%" /></a-form-item></a-col>
<a-col :span="4"><a-form-item label="最低版本"><a-input-number v-model:value="form.minVersionCode" :min="1" style="width: 100%" /></a-form-item></a-col>
<a-col :span="4"><a-form-item label="最高版本"><a-input-number v-model:value="form.maxVersionCode" :min="1" style="width: 100%" /></a-form-item></a-col>
</a-row>
<div class="estimate-row">
<a-button :loading="estimating" :disabled="!canEstimate" @click="estimateTargets">估算目标设备</a-button>
<span v-if="estimate !== null">当前条件下预计投递 <strong>{{ estimate }}</strong> 台设备</span>
</div>
<a-form-item label="定时发送时间">
<a-date-picker v-model:value="scheduleAt" show-time format="YYYY-MM-DD HH:mm" :disabled-date="(date: Dayjs) => date.isBefore(dayjs().startOf('day'))" style="width: 100%" placeholder="仅在选择定时发送时使用" />
</a-form-item>
<div class="modal-actions">
<a-button @click="editorOpen = false">关闭</a-button>
<a-button :loading="editorSaving" @click="submitCampaign('draft')">保存草稿</a-button>
<a-button :loading="editorSaving" :disabled="!scheduleAt" @click="submitCampaign('scheduled')">定时发送</a-button>
<a-popconfirm title="确认立即投递" @confirm="submitCampaign('now')">
<a-button type="primary" :loading="editorSaving"><SendOutlined />立即发送</a-button>
</a-popconfirm>
</div>
</a-form>
</a-modal>
<a-modal v-model:open="testOpen" title="单设备测试" :confirm-loading="testSaving" ok-text="发送测试" cancel-text="取消" @ok="submitTest">
<a-form layout="vertical">
<a-form-item label="设备" required>
<a-select v-model:value="testForm.deviceId" show-search option-filter-prop="label" placeholder="选择可投递设备">
<a-select-option v-for="device in devices.filter(item => item.isActive && item.notificationsAllowed)" :key="device.id" :value="device.id" :label="`${device.username} ${device.provider} ${device.id}`">
{{ device.username }} · {{ providerLabels[device.provider] }} · #{{ device.id }} · {{ device.appVersion }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="标题" required><a-input v-model:value="testForm.title" :maxlength="80" show-count /></a-form-item>
<a-form-item label="正文" required><a-textarea v-model:value="testForm.body" :maxlength="240" show-count :rows="3" /></a-form-item>
<a-row :gutter="12">
<a-col :span="12"><a-form-item label="分类"><a-select v-model:value="testForm.category"><a-select-option v-for="(label, value) in categoryLabels" :key="value" :value="value">{{ label }}</a-select-option></a-select></a-form-item></a-col>
<a-col :span="12"><a-form-item label="点击动作"><a-select v-model:value="testForm.action"><a-select-option v-for="(label, value) in actionLabels" :key="value" :value="value">{{ label }}</a-select-option></a-select></a-form-item></a-col>
</a-row>
<a-form-item label="关联对象 ID"><a-input v-model:value="testForm.entityId" :disabled="testForm.action === 'none' || testForm.action === 'home'" /></a-form-item>
</a-form>
</a-modal>
</template>
<style scoped>
.page-header,
.toolbar,
.modal-actions,
.estimate-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.page-header { margin-bottom: 16px; }
.page-header h2 { margin: 0; }
.toolbar { margin-bottom: 12px; }
.subtle { color: #8c8c8c; font-size: 12px; }
.campaign-title { font-weight: 600; margin-bottom: 3px; }
.campaign-body { color: #595959; font-size: 12px; margin-bottom: 6px; white-space: pre-wrap; }
.action-label { color: #8c8c8c; font-size: 12px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.estimate-row { justify-content: flex-start; min-height: 32px; margin-bottom: 16px; }
.modal-actions { justify-content: flex-end; padding-top: 4px; }
@media (max-width: 760px) {
.page-header { align-items: flex-start; flex-direction: column; }
}
</style>