通知系统-1

This commit is contained in:
yml2213
2026-05-14 17:29:12 +08:00
parent 91d74b3e59
commit b6c493d5e7
22 changed files with 1314 additions and 11 deletions
+1
View File
@@ -16,6 +16,7 @@ declare module 'vue' {
AdminPlatformCloudtentaclesSection: typeof import('./components/admin/AdminPlatformCloudtentaclesSection.vue')['default']
AdminPlatformKuaishouEticketSection: typeof import('./components/admin/AdminPlatformKuaishouEticketSection.vue')['default']
AdminPlatformNinetyoneSection: typeof import('./components/admin/AdminPlatformNinetyoneSection.vue')['default']
AdminPlatformNotificationSection: typeof import('./components/admin/AdminPlatformNotificationSection.vue')['default']
AdminResultCard: typeof import('./components/admin/AdminResultCard.vue')['default']
AdminStatusTag: typeof import('./components/admin/AdminStatusTag.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton']
@@ -0,0 +1,171 @@
<script setup lang="ts">
import type { AdminNotificationTestResult } from '@/types/admin'
import type { EditableNotificationRecipient } from '@/composables/admin/platform-shops/types'
type Props = {
notificationFilePath: string
notificationForm: {
enabled: boolean
barkEnabled: boolean
barkServerUrl: string
testTitle: string
testBody: string
testUrl: string
}
notificationRecipients: EditableNotificationRecipient[]
notificationSaving: boolean
notificationTesting: boolean
notificationResultError: string
notificationTestResult: AdminNotificationTestResult | null
notificationStats: {
configuredRecipientCount: number
enabledRecipientCount: number
barkReady: boolean
lastSuccessCount: number
lastFailedCount: number
}
addNotificationRecipient: () => void
removeNotificationRecipient: (id: string) => void
handleNotificationSaveConfig: () => void | Promise<void>
handleNotificationTest: () => void | Promise<void>
}
defineProps<Props>()
</script>
<template>
<section class="meta-card">
<div class="meta-line">
<span class="meta-label">职责</span>
<span>内部运营通知仅发送给后台管理员客服或值班人员</span>
</div>
<div class="meta-line">
<span class="meta-label">配置文件</span>
<span>{{ notificationFilePath || '-' }}</span>
</div>
</section>
<section class="table-card">
<div class="section-title-row">
<div>
<h3>Bark 通知</h3>
<p>已配置 {{ notificationStats.configuredRecipientCount }} 启用 {{ notificationStats.enabledRecipientCount }} </p>
</div>
<div class="section-actions">
<el-button round @click="addNotificationRecipient">新增接收人</el-button>
<el-button :loading="notificationSaving" round type="primary" @click="handleNotificationSaveConfig">保存配置</el-button>
</div>
</div>
<p v-if="notificationResultError" class="error-copy">{{ notificationResultError }}</p>
<div class="form-grid">
<label class="checkbox-line">
<input v-model="notificationForm.enabled" class="checkbox-box" type="checkbox" />
<span>启用内部通知</span>
</label>
<label class="checkbox-line">
<input v-model="notificationForm.barkEnabled" class="checkbox-box" type="checkbox" />
<span>启用 Bark 通道</span>
</label>
<label class="field-block field-wide">
<span>Bark 服务地址</span>
<input v-model="notificationForm.barkServerUrl" class="text-input" placeholder="https://api.day.app" />
</label>
</div>
<table class="data-table">
<thead>
<tr>
<th>接收人</th>
<th>Device Key</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in notificationRecipients" :key="item.id">
<td>
<input v-model="item.name" class="text-input" placeholder="例如:客服A" />
</td>
<td>
<input v-model="item.deviceKey" class="text-input" placeholder="Bark device key" />
</td>
<td>
<label class="checkbox-line">
<input v-model="item.enabled" class="checkbox-box" type="checkbox" />
<span>{{ item.enabled ? '启用' : '停用' }}</span>
</label>
</td>
<td>
<el-button link type="danger" @click="removeNotificationRecipient(item.id)">删除</el-button>
</td>
</tr>
<tr v-if="notificationRecipients.length === 0">
<td colspan="4" class="empty-inline">还没有 Bark 接收人</td>
</tr>
</tbody>
</table>
</section>
<section class="table-card">
<div class="section-title-row">
<div>
<h3>测试发送</h3>
<p>测试会发送给所有已启用且填写了 Device Key 的接收人</p>
</div>
<div class="section-actions">
<el-button :disabled="!notificationStats.barkReady" :loading="notificationTesting" round type="primary" @click="handleNotificationTest">发送测试</el-button>
</div>
</div>
<div class="form-grid">
<label class="field-block">
<span>标题</span>
<input v-model="notificationForm.testTitle" class="text-input" />
</label>
<label class="field-block field-wide">
<span>内容</span>
<input v-model="notificationForm.testBody" class="text-input" />
</label>
<label class="field-block field-wide">
<span>跳转链接</span>
<input v-model="notificationForm.testUrl" class="text-input" placeholder="可选" />
</label>
</div>
<div v-if="notificationTestResult" class="result-grid">
<div class="result-card">
<span class="result-label">成功</span>
<strong>{{ notificationTestResult.successCount }}</strong>
</div>
<div class="result-card">
<span class="result-label">失败</span>
<strong>{{ notificationTestResult.failedCount }}</strong>
</div>
<div class="result-card">
<span class="result-label">通道</span>
<strong>{{ notificationTestResult.channel || '-' }}</strong>
</div>
</div>
<table v-if="notificationTestResult" class="data-table">
<thead>
<tr>
<th>接收人</th>
<th>结果</th>
<th>状态码</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr v-for="item in notificationTestResult.results" :key="item.recipientId || item.recipientKeyMasked">
<td>{{ item.recipientName || item.recipientKeyMasked || '-' }}</td>
<td>{{ item.ok ? '成功' : '失败' }}</td>
<td>{{ item.status || '-' }}</td>
<td>{{ item.errorMessage || '-' }}</td>
</tr>
</tbody>
</table>
</section>
</template>
@@ -1,4 +1,4 @@
export type PlatformTab = 'agiso' | 'ninetyone' | 'kuaishouEticket' | 'cloudtentacles'
export type PlatformTab = 'agiso' | 'notifications' | 'ninetyone' | 'kuaishouEticket' | 'cloudtentacles'
export type EditableDefaults = {
messageTemplate: string
@@ -23,3 +23,10 @@ export type EditableKuaishouEticketShop = {
userAvatar: string
enabled: boolean
}
export type EditableNotificationRecipient = {
id: string
name: string
deviceKey: string
enabled: boolean
}
@@ -0,0 +1,152 @@
import { computed, ref } from 'vue'
import { showError, showSuccess } from '@/lib/feedback'
import {
saveAdminNotificationConfig,
testAdminNotification,
} from '@/services/admin'
import type {
AdminNotificationConfig,
AdminNotificationTestResult,
} from '@/types/admin'
import type { EditableNotificationRecipient } from './types'
export function useAdminNotificationPlatform() {
const notificationFilePath = ref('')
const notificationForm = ref({
enabled: true,
barkEnabled: true,
barkServerUrl: 'https://api.day.app',
testTitle: '订单系统测试通知',
testBody: '这是一条 Bark 内部通知测试。',
testUrl: '',
})
const notificationRecipients = ref<EditableNotificationRecipient[]>([])
const notificationSaving = ref(false)
const notificationTesting = ref(false)
const notificationResultError = ref('')
const notificationTestResult = ref<AdminNotificationTestResult | null>(null)
const notificationStats = computed(() => {
const enabledRecipients = notificationRecipients.value.filter((item) => item.enabled && item.deviceKey.trim())
return {
configuredRecipientCount: notificationRecipients.value.length,
enabledRecipientCount: enabledRecipients.length,
barkReady: notificationForm.value.enabled && notificationForm.value.barkEnabled && enabledRecipients.length > 0,
lastSuccessCount: notificationTestResult.value?.successCount || 0,
lastFailedCount: notificationTestResult.value?.failedCount || 0,
}
})
function hydrateNotificationConfig(data: {
filePath: string
source: AdminNotificationConfig
}) {
notificationFilePath.value = data.filePath
notificationForm.value.enabled = data.source.enabled !== false
notificationForm.value.barkEnabled = data.source.channels.bark.enabled !== false
notificationForm.value.barkServerUrl = data.source.channels.bark.serverUrl || 'https://api.day.app'
notificationRecipients.value = data.source.channels.bark.recipients.map((item) => ({
id: item.id || crypto.randomUUID(),
name: item.name,
deviceKey: item.deviceKey,
enabled: item.enabled !== false,
}))
}
function addNotificationRecipient() {
notificationRecipients.value.unshift({
id: crypto.randomUUID(),
name: '',
deviceKey: '',
enabled: true,
})
}
function removeNotificationRecipient(id: string) {
notificationRecipients.value = notificationRecipients.value.filter((item) => item.id !== id)
}
function buildNotificationConfigPayload(): AdminNotificationConfig {
return {
enabled: notificationForm.value.enabled,
channels: {
bark: {
enabled: notificationForm.value.barkEnabled,
serverUrl: notificationForm.value.barkServerUrl.trim() || 'https://api.day.app',
recipients: notificationRecipients.value.map((item) => ({
id: item.id,
name: item.name.trim(),
deviceKey: item.deviceKey.trim(),
deviceKeyMasked: '',
enabled: item.enabled,
})),
},
},
}
}
async function handleNotificationSaveConfig() {
notificationSaving.value = true
notificationResultError.value = ''
try {
const response = await saveAdminNotificationConfig(buildNotificationConfigPayload())
notificationFilePath.value = response.data.filePath
hydrateNotificationConfig(response.data)
showSuccess('内部通知配置已保存')
} catch (error) {
notificationResultError.value = error instanceof Error ? error.message : '内部通知配置保存失败'
showError(notificationResultError.value)
} finally {
notificationSaving.value = false
}
}
async function handleNotificationTest() {
notificationTesting.value = true
notificationResultError.value = ''
notificationTestResult.value = null
try {
const saved = await saveAdminNotificationConfig(buildNotificationConfigPayload())
notificationFilePath.value = saved.data.filePath
hydrateNotificationConfig(saved.data)
const response = await testAdminNotification({
title: notificationForm.value.testTitle.trim(),
body: notificationForm.value.testBody.trim(),
url: notificationForm.value.testUrl.trim(),
})
notificationTestResult.value = response.data
if (response.data.successCount > 0) {
showSuccess(`内部通知测试完成,成功 ${response.data.successCount}`)
} else {
showError('内部通知测试未成功发送,请检查 Bark 配置')
}
} catch (error) {
notificationResultError.value = error instanceof Error ? error.message : '内部通知测试失败'
showError(notificationResultError.value)
} finally {
notificationTesting.value = false
}
}
return {
notificationFilePath,
notificationForm,
notificationRecipients,
notificationSaving,
notificationTesting,
notificationResultError,
notificationTestResult,
notificationStats,
hydrateNotificationConfig,
addNotificationRecipient,
removeNotificationRecipient,
handleNotificationSaveConfig,
handleNotificationTest,
}
}
@@ -13,6 +13,8 @@ import type {
AdminFulfillmentLookupResult,
AdminNinetyoneOrderActionResult,
AdminNinetyoneOrderListResult,
AdminNotificationConfig,
AdminNotificationTestResult,
AdminKuaishouCloudFulfillmentConfig,
AdminKuaishouEticketConsumeResult,
AdminKuaishouEticketDetailResult,
@@ -41,6 +43,28 @@ export function saveAdminAgisoShopConfigs(payload: {
}>('/api/v1/admin/platform-config/agiso-shops', payload)
}
export function fetchAdminNotificationConfig() {
return apiGet<{
filePath: string
source: AdminNotificationConfig
}>('/api/v1/admin/platform-config/notifications')
}
export function saveAdminNotificationConfig(payload: AdminNotificationConfig) {
return apiPost<{
filePath: string
source: AdminNotificationConfig
}>('/api/v1/admin/platform-config/notifications', payload as unknown as Record<string, unknown>)
}
export function testAdminNotification(payload: {
title?: string
body?: string
url?: string
}) {
return apiPost<AdminNotificationTestResult>('/api/v1/admin/platform-config/notifications/test', payload)
}
export function fetchAdminNinetyoneOrders(params: {
page?: number
pageSize?: number
+38
View File
@@ -164,6 +164,44 @@ export interface AdminKuaishouEticketConsumeResult {
raw: Record<string, unknown>
}
export interface AdminNotificationBarkRecipient {
id: string
name: string
deviceKey: string
deviceKeyMasked: string
enabled: boolean
}
export interface AdminNotificationConfig {
enabled: boolean
channels: {
bark: {
enabled: boolean
serverUrl: string
recipients: AdminNotificationBarkRecipient[]
}
}
}
export interface AdminNotificationSendResult {
recipientId: string
recipientName: string
recipientKeyMasked: string
ok: boolean
status: number
errorMessage: string
response: unknown
}
export interface AdminNotificationTestResult {
enabled: boolean
channel: string
successCount: number
failedCount: number
skippedCount: number
results: AdminNotificationSendResult[]
}
export interface AdminNinetyoneOrderItem {
orderId: number
orderNo: string
@@ -4,16 +4,19 @@ import { onMounted, ref } from 'vue'
import AdminPlatformAgisoSection from '@/components/admin/AdminPlatformAgisoSection.vue'
import AdminPlatformCloudtentaclesSection from '@/components/admin/AdminPlatformCloudtentaclesSection.vue'
import AdminPlatformNinetyoneSection from '@/components/admin/AdminPlatformNinetyoneSection.vue'
import AdminPlatformNotificationSection from '@/components/admin/AdminPlatformNotificationSection.vue'
import AdminPlatformKuaishouEticketSection from '@/components/admin/AdminPlatformKuaishouEticketSection.vue'
import { useAdminAgisoPlatform } from '@/composables/admin/platform-shops/useAdminAgisoPlatform'
import { useAdminCloudtentaclesPlatform } from '@/composables/admin/platform-shops/useAdminCloudtentaclesPlatform'
import { useAdminNinetyonePlatform } from '@/composables/admin/platform-shops/useAdminNinetyonePlatform'
import { useAdminNotificationPlatform } from '@/composables/admin/platform-shops/useAdminNotificationPlatform'
import { useAdminKuaishouEticketPlatform } from '@/composables/admin/platform-shops/useAdminKuaishouEticketPlatform'
import type { PlatformTab } from '@/composables/admin/platform-shops/types'
import {
fetchAdminAgisoShopConfigs,
fetchAdminCloudtentaclesSourceConfig,
fetchAdminKuaishouEticketSourceConfig,
fetchAdminNotificationConfig,
} from '@/services/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import '@/styles/admin-platform-shops.css'
@@ -55,6 +58,22 @@ const {
handleNinetyoneFailOrder,
} = useAdminNinetyonePlatform()
const {
notificationFilePath,
notificationForm,
notificationRecipients,
notificationSaving,
notificationTesting,
notificationResultError,
notificationTestResult,
notificationStats,
hydrateNotificationConfig,
addNotificationRecipient,
removeNotificationRecipient,
handleNotificationSaveConfig,
handleNotificationTest,
} = useAdminNotificationPlatform()
const {
kuaishouEticketFilePath,
kuaishouEticketShops,
@@ -132,13 +151,15 @@ async function loadConfigs() {
errorMessage.value = ''
try {
const [agisoResponse, kuaishouEticketResponse, cloudtentaclesResponse] = await Promise.all([
const [agisoResponse, notificationResponse, kuaishouEticketResponse, cloudtentaclesResponse] = await Promise.all([
fetchAdminAgisoShopConfigs(),
fetchAdminNotificationConfig(),
fetchAdminKuaishouEticketSourceConfig(),
fetchAdminCloudtentaclesSourceConfig(),
])
hydrateAgisoConfig(agisoResponse.data)
hydrateNotificationConfig(notificationResponse.data)
hydrateKuaishouEticketConfig(kuaishouEticketResponse.data)
hydrateCloudtentaclesConfig(cloudtentaclesResponse.data)
await loadNinetyoneOrders(1)
@@ -198,6 +219,23 @@ onMounted(loadConfigs)
</div>
</button>
<button
type="button"
:class="['platform-overview-card', { 'is-active': activePlatform === 'notifications' }]"
@click="switchPlatform('notifications')"
>
<div class="platform-head">
<span class="platform-badge">内部通知</span>
<span class="platform-tag">Bark / 值班</span>
</div>
<strong>{{ notificationStats.enabledRecipientCount }}</strong>
<span>启用接收人</span>
<div class="platform-metrics">
<span>配置 {{ notificationStats.configuredRecipientCount }} </span>
<span>最近成功 {{ notificationStats.lastSuccessCount }} </span>
</div>
</button>
<button
type="button"
:class="['platform-overview-card', { 'is-active': activePlatform === 'ninetyone' }]"
@@ -266,6 +304,13 @@ onMounted(loadConfigs)
>
91卡券接入
</button>
<button
type="button"
:class="['switch-chip', { 'is-active': activePlatform === 'notifications' }]"
@click="switchPlatform('notifications')"
>
内部通知
</button>
<button
type="button"
:class="['switch-chip', { 'is-active': activePlatform === 'kuaishouEticket' }]"
@@ -314,6 +359,22 @@ onMounted(loadConfigs)
:handle-ninetyone-fail-order="handleNinetyoneFailOrder"
/>
<AdminPlatformNotificationSection
v-else-if="activePlatform === 'notifications'"
:notification-file-path="notificationFilePath"
:notification-form="notificationForm"
:notification-recipients="notificationRecipients"
:notification-saving="notificationSaving"
:notification-testing="notificationTesting"
:notification-result-error="notificationResultError"
:notification-test-result="notificationTestResult"
:notification-stats="notificationStats"
:add-notification-recipient="addNotificationRecipient"
:remove-notification-recipient="removeNotificationRecipient"
:handle-notification-save-config="handleNotificationSaveConfig"
:handle-notification-test="handleNotificationTest"
/>
<AdminPlatformKuaishouEticketSection
v-else-if="activePlatform === 'kuaishouEticket'"
:kuaishou-eticket-file-path="kuaishouEticketFilePath"