mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 01:36:45 +08:00
完善通知渠道稳定身份与保存交互 (#736)
This commit is contained in:
@@ -1839,6 +1839,8 @@ export interface DownloaderConf {
|
|||||||
|
|
||||||
// 通知配置
|
// 通知配置
|
||||||
export interface NotificationConf {
|
export interface NotificationConf {
|
||||||
|
// 稳定通知渠道身份;显示名称变化时保持不变,用于配置保存和运行态引用
|
||||||
|
id: string
|
||||||
// 名称
|
// 名称
|
||||||
name: string
|
name: string
|
||||||
// 类型 telegram/wechat/vocechat/synologychat
|
// 类型 telegram/wechat/vocechat/synologychat
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const notificationInfoDialog = computed({
|
|||||||
|
|
||||||
// 通知详情
|
// 通知详情
|
||||||
const notificationInfo = ref<NotificationConf>({
|
const notificationInfo = ref<NotificationConf>({
|
||||||
|
id: '',
|
||||||
name: '',
|
name: '',
|
||||||
type: '',
|
type: '',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -183,9 +184,12 @@ async function updateWechatClawBotQrImage(status?: WechatClawBotStatus | null) {
|
|||||||
/** 组装微信客服状态接口所需的请求参数。 */
|
/** 组装微信客服状态接口所需的请求参数。 */
|
||||||
function getWechatClawBotRequestParams(extraParams: Record<string, any> = {}) {
|
function getWechatClawBotRequestParams(extraParams: Record<string, any> = {}) {
|
||||||
const config = notificationInfo.value.config || {}
|
const config = notificationInfo.value.config || {}
|
||||||
|
// 配置身份优先于显示名称;名称仅作为旧配置尚未归一化时的明确读取回退。
|
||||||
|
const source = notificationInfo.value.id || notificationInfo.value.name
|
||||||
|
const fallbackSource = props.notification.id || props.notification.name
|
||||||
return {
|
return {
|
||||||
source: notificationInfo.value.name,
|
source,
|
||||||
fallback_source: props.notification.name,
|
fallback_source: fallbackSource,
|
||||||
WECHATCLAWBOT_BASE_URL: config.WECHATCLAWBOT_BASE_URL,
|
WECHATCLAWBOT_BASE_URL: config.WECHATCLAWBOT_BASE_URL,
|
||||||
WECHATCLAWBOT_DEFAULT_TARGET: config.WECHATCLAWBOT_DEFAULT_TARGET,
|
WECHATCLAWBOT_DEFAULT_TARGET: config.WECHATCLAWBOT_DEFAULT_TARGET,
|
||||||
WECHATCLAWBOT_ADMINS: config.WECHATCLAWBOT_ADMINS,
|
WECHATCLAWBOT_ADMINS: config.WECHATCLAWBOT_ADMINS,
|
||||||
@@ -225,19 +229,24 @@ function openNotificationInfoDialog() {
|
|||||||
/** 保存通知渠道编辑结果并通知父级刷新。 */
|
/** 保存通知渠道编辑结果并通知父级刷新。 */
|
||||||
function saveNotificationInfo() {
|
function saveNotificationInfo() {
|
||||||
// 为空不保存,跳出警告框
|
// 为空不保存,跳出警告框
|
||||||
if (!notificationInfo.value.name) {
|
const normalizedName = notificationInfo.value.name.trim()
|
||||||
|
if (!normalizedName) {
|
||||||
$toast.error(t('notification.name') + t('common.required'))
|
$toast.error(t('notification.name') + t('common.required'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 重名判断
|
// 重名判断
|
||||||
if (props.notifications.some(item => item.name === notificationInfo.value.name && item !== props.notification)) {
|
const duplicate = props.notifications.some(
|
||||||
$toast.error(t('notification.channel') + `【${notificationInfo.value.name}】` + t('common.exists'))
|
item => item.id !== props.notification.id && item.name.trim().toLowerCase() === normalizedName.toLowerCase(),
|
||||||
|
)
|
||||||
|
if (duplicate) {
|
||||||
|
$toast.error(t('notification.channel') + `【${normalizedName}】` + t('common.exists'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
notificationInfo.value.name = normalizedName
|
||||||
ensureWechatConfigDefaults(notificationInfo.value)
|
ensureWechatConfigDefaults(notificationInfo.value)
|
||||||
ensureWechatClawBotConfigDefaults(notificationInfo.value)
|
ensureWechatClawBotConfigDefaults(notificationInfo.value)
|
||||||
notificationInfoDialog.value = false
|
notificationInfoDialog.value = false
|
||||||
emit('change', notificationInfo.value, props.notification.name)
|
emit('change', notificationInfo.value, props.notification.id)
|
||||||
emit('done')
|
emit('done')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,14 +37,14 @@ describe('SendMessageAction', () => {
|
|||||||
expect(getSelectItems(container, '渠道')).toEqual([])
|
expect(getSelectItems(container, '渠道')).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps unwrapped admin notification channels to name options', async () => {
|
it('maps admin notification channels to name options while preserving workflow values', async () => {
|
||||||
mocks.apiGet.mockResolvedValue({
|
mocks.apiGet.mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
message: '',
|
message: '',
|
||||||
data: {
|
data: {
|
||||||
value: [
|
value: [
|
||||||
{ name: 'Telegram', type: 'telegram', enabled: true },
|
{ id: 'telegram-channel', name: 'Telegram', type: 'telegram', enabled: true },
|
||||||
{ name: '企业微信', type: 'wechat', enabled: false },
|
{ id: 'wechat-channel', name: '企业微信', type: 'wechat', enabled: false },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export default {
|
|||||||
loading: 'Loading',
|
loading: 'Loading',
|
||||||
success: 'Success',
|
success: 'Success',
|
||||||
error: 'Error',
|
error: 'Error',
|
||||||
|
exists: 'Exists',
|
||||||
openInNewWindow: 'Open in new window',
|
openInNewWindow: 'Open in new window',
|
||||||
download: 'Download',
|
download: 'Download',
|
||||||
uploadSpeed: 'Upload speed',
|
uploadSpeed: 'Upload speed',
|
||||||
@@ -2522,6 +2523,7 @@ export default {
|
|||||||
notification: {
|
notification: {
|
||||||
channels: 'Notification Channels',
|
channels: 'Notification Channels',
|
||||||
channelsDesc: 'Set message sending channel parameters',
|
channelsDesc: 'Set message sending channel parameters',
|
||||||
|
channelsLoadFailed: 'Failed to load notification channels. Refresh and try again.',
|
||||||
organizeSuccess: 'Media Import',
|
organizeSuccess: 'Media Import',
|
||||||
downloadAdded: 'Download Added',
|
downloadAdded: 'Download Added',
|
||||||
subscribeAdded: 'Subscribe Added',
|
subscribeAdded: 'Subscribe Added',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export default {
|
|||||||
loading: '加载中',
|
loading: '加载中',
|
||||||
success: '成功',
|
success: '成功',
|
||||||
error: '错误',
|
error: '错误',
|
||||||
|
exists: '已存在',
|
||||||
openInNewWindow: '在新窗口中打开',
|
openInNewWindow: '在新窗口中打开',
|
||||||
download: '下载',
|
download: '下载',
|
||||||
inputMessage: '输入消息或命令',
|
inputMessage: '输入消息或命令',
|
||||||
@@ -2475,6 +2476,7 @@ export default {
|
|||||||
notification: {
|
notification: {
|
||||||
channels: '通知渠道',
|
channels: '通知渠道',
|
||||||
channelsDesc: '设置消息发送渠道参数',
|
channelsDesc: '设置消息发送渠道参数',
|
||||||
|
channelsLoadFailed: '加载通知渠道失败,请刷新后重试',
|
||||||
organizeSuccess: '资源入库',
|
organizeSuccess: '资源入库',
|
||||||
downloadAdded: '资源下载',
|
downloadAdded: '资源下载',
|
||||||
subscribeAdded: '添加订阅',
|
subscribeAdded: '添加订阅',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export default {
|
|||||||
loading: '加載中',
|
loading: '加載中',
|
||||||
success: '成功',
|
success: '成功',
|
||||||
error: '錯誤',
|
error: '錯誤',
|
||||||
|
exists: '已存在',
|
||||||
openInNewWindow: '在新窗口中打開',
|
openInNewWindow: '在新窗口中打開',
|
||||||
download: '下載',
|
download: '下載',
|
||||||
inputMessage: '輸入消息或命令',
|
inputMessage: '輸入消息或命令',
|
||||||
@@ -2474,6 +2475,7 @@ export default {
|
|||||||
notification: {
|
notification: {
|
||||||
channels: '通知渠道',
|
channels: '通知渠道',
|
||||||
channelsDesc: '設置消息發送渠道參數',
|
channelsDesc: '設置消息發送渠道參數',
|
||||||
|
channelsLoadFailed: '載入通知渠道失敗,請刷新後重試',
|
||||||
organizeSuccess: '資源入庫',
|
organizeSuccess: '資源入庫',
|
||||||
downloadAdded: '資源下載',
|
downloadAdded: '資源下載',
|
||||||
subscribeAdded: '添加訂閱',
|
subscribeAdded: '添加訂閱',
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { manageNotificationChannel } from '@/api/manage'
|
|
||||||
import type { NotificationConf, NotificationSwitchConf } from '@/api/types'
|
import type { NotificationConf, NotificationSwitchConf } from '@/api/types'
|
||||||
import NotificationChannelCard from '@/components/cards/NotificationChannelCard.vue'
|
import NotificationChannelCard from '@/components/cards/NotificationChannelCard.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -79,6 +78,19 @@ const editorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dar
|
|||||||
// 所有消息渠道
|
// 所有消息渠道
|
||||||
const notifications = ref<NotificationConf[]>([])
|
const notifications = ref<NotificationConf[]>([])
|
||||||
|
|
||||||
|
type NotificationConfigInput = Partial<NotificationConf> & {
|
||||||
|
id?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationConfigResponse {
|
||||||
|
value?: NotificationConfigInput[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationLoadState = 'idle' | 'loading' | 'ready' | 'error'
|
||||||
|
|
||||||
|
const notificationLoadState = ref<NotificationLoadState>('idle')
|
||||||
|
const notificationSaveLoading = ref(false)
|
||||||
|
|
||||||
// 提示框
|
// 提示框
|
||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
|
|
||||||
@@ -132,10 +144,75 @@ const notificationTime = ref({
|
|||||||
end: '23:59',
|
end: '23:59',
|
||||||
})
|
})
|
||||||
|
|
||||||
const wechatClawBotRenameMap = ref<Record<string, string>>({})
|
|
||||||
|
|
||||||
let editorDialogController: ReturnType<typeof openSharedDialog> | null = null
|
let editorDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
|
|
||||||
|
/** 创建仅用于新建渠道的稳定身份;保存后以后端归一化身份为准。 */
|
||||||
|
function createNotificationId() {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
return `notification-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 为旧配置建立与后端一致的可重复身份,避免在保存前退回到显示名称作为列表 key。 */
|
||||||
|
function createLegacyNotificationId(notification: NotificationConfigInput, index: number) {
|
||||||
|
const type = typeof notification.type === 'string' ? notification.type : 'notification'
|
||||||
|
const name = typeof notification.name === 'string' ? notification.name.trim() : ''
|
||||||
|
return `legacy-${type}-${name || String(index)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一通知配置的身份、名称和可选字段,供 GET、编辑和保存共用。 */
|
||||||
|
function normalizeNotification(notification: NotificationConfigInput, index: number): NotificationConf {
|
||||||
|
const rawId = notification.id
|
||||||
|
const id =
|
||||||
|
typeof rawId === 'string' && rawId.trim()
|
||||||
|
? rawId.trim()
|
||||||
|
: typeof rawId === 'number'
|
||||||
|
? String(rawId)
|
||||||
|
: createLegacyNotificationId(notification, index)
|
||||||
|
const name = typeof notification.name === 'string' ? notification.name.trim() : ''
|
||||||
|
const type = typeof notification.type === 'string' ? notification.type : ''
|
||||||
|
const config = notification.config && typeof notification.config === 'object' ? notification.config : {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...notification,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
config,
|
||||||
|
enabled: Boolean(notification.enabled),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNotificationList(value: NotificationConfigInput[] = []) {
|
||||||
|
return value.map((notification, index) => normalizeNotification(notification, index))
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationNameKey(name: string) {
|
||||||
|
return name.trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检查名称是否为空或与另一个渠道重复,名称比较不区分大小写。 */
|
||||||
|
function validateNotificationNames(value: NotificationConf[]) {
|
||||||
|
const names = new Set<string>()
|
||||||
|
for (const notification of value) {
|
||||||
|
const name = notification.name.trim()
|
||||||
|
if (!name) {
|
||||||
|
$toast.error(t('notification.nameRequired'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const key = notificationNameKey(name)
|
||||||
|
if (names.has(key)) {
|
||||||
|
$toast.error(`${t('notification.channel')}【${name}】${t('common.exists')}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
names.add(key)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSaveNotifications = computed(() => notificationLoadState.value === 'ready' && !notificationSaveLoading.value)
|
||||||
|
|
||||||
// 关闭通知模板共享弹窗,并同步本页的弹窗占用状态。
|
// 关闭通知模板共享弹窗,并同步本页的弹窗占用状态。
|
||||||
function closeTemplateEditorDialog() {
|
function closeTemplateEditorDialog() {
|
||||||
editorDialogOpen.value = false
|
editorDialogOpen.value = false
|
||||||
@@ -184,11 +261,15 @@ watch(editorTheme, theme => {
|
|||||||
|
|
||||||
// 添加通知渠道
|
// 添加通知渠道
|
||||||
function addNotification(notification: string) {
|
function addNotification(notification: string) {
|
||||||
let name = `${t('setting.notification.channel')}${notifications.value.length + 1}`
|
const prefix = t('setting.notification.channel')
|
||||||
while (notifications.value.some(item => item.name === name)) {
|
let index = notifications.value.length + 1
|
||||||
name = `${t('setting.notification.channel')}${parseInt(name.split(t('setting.notification.channel'))[1]) + 1}`
|
let name = `${prefix}${index}`
|
||||||
|
while (notifications.value.some(item => notificationNameKey(item.name) === notificationNameKey(name))) {
|
||||||
|
index += 1
|
||||||
|
name = `${prefix}${index}`
|
||||||
}
|
}
|
||||||
notifications.value.push({
|
notifications.value.push({
|
||||||
|
id: createNotificationId(),
|
||||||
name: name,
|
name: name,
|
||||||
type: notification,
|
type: notification,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -198,59 +279,21 @@ function addNotification(notification: string) {
|
|||||||
|
|
||||||
// 移除通知渠道
|
// 移除通知渠道
|
||||||
function removeNotification(notification: NotificationConf) {
|
function removeNotification(notification: NotificationConf) {
|
||||||
const index = notifications.value.indexOf(notification)
|
const index = notifications.value.findIndex(item => item.id === notification.id)
|
||||||
if (index > -1) notifications.value.splice(index, 1)
|
if (index > -1) notifications.value.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackWechatClawBotRename(oldName: string, newName: string) {
|
|
||||||
if (!oldName || !newName || oldName === newName) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const renameMap = { ...wechatClawBotRenameMap.value }
|
|
||||||
let chainedRename = false
|
|
||||||
// 连续改名只保留原始缓存名到当前渠道名,避免为不存在的中间名发起迁移。
|
|
||||||
for (const [source, target] of Object.entries(renameMap)) {
|
|
||||||
if (target === oldName) {
|
|
||||||
renameMap[source] = newName
|
|
||||||
chainedRename = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!chainedRename) {
|
|
||||||
renameMap[oldName] = newName
|
|
||||||
}
|
|
||||||
wechatClawBotRenameMap.value = Object.fromEntries(
|
|
||||||
Object.entries(renameMap).filter(([source, target]) => source && target && source !== target),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateWechatClawBotRenames() {
|
|
||||||
const activeWechatClawBotNames = new Set(
|
|
||||||
notifications.value.filter(item => item.type === 'wechatclawbot').map(item => item.name),
|
|
||||||
)
|
|
||||||
const renameEntries = Object.entries(wechatClawBotRenameMap.value).filter(
|
|
||||||
([oldName, newName]) => oldName && newName && oldName !== newName && activeWechatClawBotNames.has(newName),
|
|
||||||
)
|
|
||||||
for (const [oldName, newName] of renameEntries) {
|
|
||||||
await manageNotificationChannel(
|
|
||||||
'WechatClawBot',
|
|
||||||
'migrate_cache',
|
|
||||||
{
|
|
||||||
old_name: oldName,
|
|
||||||
new_name: newName,
|
|
||||||
},
|
|
||||||
{ feedback: 'silent' },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用API查询通知渠道设置
|
// 调用API查询通知渠道设置
|
||||||
async function loadNotificationSetting() {
|
async function loadNotificationSetting() {
|
||||||
|
notificationLoadState.value = 'loading'
|
||||||
try {
|
try {
|
||||||
const result = await api.get<{ value?: NotificationConf[] }>('system/setting/Notifications')
|
const result = await api.get<NotificationConfigResponse>('system/setting/Notifications')
|
||||||
notifications.value = result.value ?? []
|
notifications.value = normalizeNotificationList(result.value)
|
||||||
wechatClawBotRenameMap.value = {}
|
notificationLoadState.value = 'ready'
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.error(error)
|
||||||
|
// 读取失败时保留当前可见配置,并锁住保存,避免用不完整列表覆盖服务端数据。
|
||||||
|
notificationLoadState.value = 'error'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,14 +354,22 @@ async function loadNotificationTime() {
|
|||||||
|
|
||||||
// 调用API保存通知设置
|
// 调用API保存通知设置
|
||||||
async function saveNotificationSetting() {
|
async function saveNotificationSetting() {
|
||||||
|
if (!canSaveNotifications.value || !validateNotificationNames(notifications.value)) return
|
||||||
|
|
||||||
|
notificationSaveLoading.value = true
|
||||||
try {
|
try {
|
||||||
await migrateWechatClawBotRenames()
|
const payload = notifications.value.map((notification, index) => normalizeNotification(notification, index))
|
||||||
await api.post('system/setting/Notifications', notifications.value, { feedback: 'silent' })
|
notifications.value = payload
|
||||||
wechatClawBotRenameMap.value = {}
|
const result = await api.post<NotificationConfigResponse>('notification/config', payload, { feedback: 'silent' })
|
||||||
|
if (result?.value) {
|
||||||
|
notifications.value = normalizeNotificationList(result.value)
|
||||||
|
}
|
||||||
$toast.success(t('setting.notification.saveSuccess'))
|
$toast.success(t('setting.notification.saveSuccess'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.error(error)
|
||||||
$toast.error(t('setting.notification.saveFailed'))
|
$toast.error(t('setting.notification.saveFailed'))
|
||||||
|
} finally {
|
||||||
|
notificationSaveLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,14 +385,22 @@ async function saveNotificationTime() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 通知渠道设置变化时赋值
|
// 通知渠道设置变化时赋值
|
||||||
function changNotificationSetting(notification: NotificationConf, name: string) {
|
function changNotificationSetting(notification: NotificationConf, id: string) {
|
||||||
const index = notifications.value.findIndex(item => item.name === name)
|
const normalizedNotification = normalizeNotification(notification, notifications.value.length)
|
||||||
|
const index = notifications.value.findIndex(item => item.id === id)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
const previous = notifications.value[index]
|
if (!normalizedNotification.name) {
|
||||||
notifications.value[index] = notification
|
$toast.error(t('notification.nameRequired'))
|
||||||
if (previous?.type === 'wechatclawbot' && previous.name !== notification.name) {
|
return
|
||||||
trackWechatClawBotRename(previous.name, notification.name)
|
|
||||||
}
|
}
|
||||||
|
const duplicate = notifications.value.some(
|
||||||
|
item => item.id !== id && notificationNameKey(item.name) === notificationNameKey(normalizedNotification.name),
|
||||||
|
)
|
||||||
|
if (duplicate) {
|
||||||
|
$toast.error(`${t('notification.channel')}【${normalizedNotification.name}】${t('common.exists')}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notifications.value[index] = normalizedNotification
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,10 +469,13 @@ useSilentSettingRefresh(loadPageData, {
|
|||||||
<VCardSubtitle>{{ t('setting.notification.channelsDesc') }}</VCardSubtitle>
|
<VCardSubtitle>{{ t('setting.notification.channelsDesc') }}</VCardSubtitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VCardText>
|
<VCardText>
|
||||||
|
<VAlert v-if="notificationLoadState === 'error'" type="error" variant="tonal" class="mb-4">
|
||||||
|
{{ t('setting.notification.channelsLoadFailed') }}
|
||||||
|
</VAlert>
|
||||||
<Draggable
|
<Draggable
|
||||||
v-model="notifications"
|
v-model="notifications"
|
||||||
handle=".cursor-move"
|
handle=".cursor-move"
|
||||||
item-key="name"
|
item-key="id"
|
||||||
tag="div"
|
tag="div"
|
||||||
:component-data="{ 'class': 'grid gap-3 grid-app-card' }"
|
:component-data="{ 'class': 'grid gap-3 grid-app-card' }"
|
||||||
>
|
>
|
||||||
@@ -430,7 +492,13 @@ useSilentSettingRefresh(loadPageData, {
|
|||||||
<VCardText>
|
<VCardText>
|
||||||
<VForm @submit.prevent="() => {}">
|
<VForm @submit.prevent="() => {}">
|
||||||
<div class="d-flex flex-wrap gap-4 mt-4">
|
<div class="d-flex flex-wrap gap-4 mt-4">
|
||||||
<VBtn mtype="submit" @click="saveNotificationSetting" prepend-icon="mdi-content-save">
|
<VBtn
|
||||||
|
mtype="submit"
|
||||||
|
:loading="notificationSaveLoading"
|
||||||
|
:disabled="!canSaveNotifications"
|
||||||
|
@click="saveNotificationSetting"
|
||||||
|
prepend-icon="mdi-content-save"
|
||||||
|
>
|
||||||
{{ t('common.save') }}
|
{{ t('common.save') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VBtn color="success" variant="tonal">
|
<VBtn color="success" variant="tonal">
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ vi.mock('@/components/cards/NotificationChannelCard.vue', async () => {
|
|||||||
<input
|
<input
|
||||||
:aria-label="'name-' + notification.name"
|
:aria-label="'name-' + notification.name"
|
||||||
:value="notification.name"
|
:value="notification.name"
|
||||||
@input="$emit('change', { ...notification, name: $event.target.value }, notification.name)"
|
@input="$emit('change', { ...notification, name: $event.target.value }, notification.id)"
|
||||||
/>
|
/>
|
||||||
<button :aria-label="'remove-' + notification.name" @click="$emit('close')">remove</button>
|
<button :aria-label="'remove-' + notification.name" @click="$emit('close')">remove</button>
|
||||||
</section>
|
</section>
|
||||||
@@ -73,9 +73,15 @@ vi.mock('vuedraggable', async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const notificationsFixture = [
|
const notificationsFixture: Array<{
|
||||||
{ name: 'Alpha', type: 'wechatclawbot', enabled: true, config: { token: 'fixture-token' } },
|
id?: string
|
||||||
{ name: '通知3', type: 'telegram', enabled: false, config: {} },
|
name: string
|
||||||
|
type: string
|
||||||
|
enabled: boolean
|
||||||
|
config: Record<string, unknown>
|
||||||
|
}> = [
|
||||||
|
{ id: 'channel-alpha', name: 'Alpha', type: 'wechatclawbot', enabled: true, config: { token: 'fixture-token' } },
|
||||||
|
{ id: 'channel-three', name: '通知3', type: 'telegram', enabled: false, config: {} },
|
||||||
]
|
]
|
||||||
|
|
||||||
const templateFixture = {
|
const templateFixture = {
|
||||||
@@ -85,10 +91,10 @@ const templateFixture = {
|
|||||||
subscribeComplete: '{}',
|
subscribeComplete: '{}',
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockLoadedSettings() {
|
function mockLoadedSettings(channels = notificationsFixture) {
|
||||||
mocks.apiGet.mockImplementation((endpoint: string) => {
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
if (endpoint === 'system/setting/Notifications') {
|
if (endpoint === 'system/setting/Notifications') {
|
||||||
return { success: true, data: { value: structuredClone(notificationsFixture) } }
|
return { success: true, data: { value: structuredClone(channels) } }
|
||||||
}
|
}
|
||||||
if (endpoint === 'system/setting/NotificationSwitchs') {
|
if (endpoint === 'system/setting/NotificationSwitchs') {
|
||||||
return { success: true, data: { value: [{ type: '资源下载', action: 'user' }] } }
|
return { success: true, data: { value: [{ type: '资源下载', action: 'user' }] } }
|
||||||
@@ -156,7 +162,23 @@ describe('AccountSettingNotification', () => {
|
|||||||
expect(refreshOptions.active.value).toBe(false)
|
expect(refreshOptions.active.value).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates a unique automatic channel name, removes channels, and saves the current order', async () => {
|
it('keeps the backend legacy identity when renaming a channel without an id', async () => {
|
||||||
|
mockLoadedSettings([{ name: 'Alpha', type: 'wechatclawbot', enabled: true, config: {} }])
|
||||||
|
const user = userEvent.setup()
|
||||||
|
await renderNotificationSettings()
|
||||||
|
await screen.findByText('Alpha / wechatclawbot')
|
||||||
|
|
||||||
|
await fireEvent.update(screen.getByLabelText('name-Alpha'), 'Beta')
|
||||||
|
await user.click(getCard('通知渠道').getByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mocks.apiPost).toHaveBeenCalledWith('notification/config', [
|
||||||
|
expect.objectContaining({ id: 'legacy-wechatclawbot-Alpha', name: 'Beta' }),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a unique automatic channel name, removes channels, and saves the current order once', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
await renderNotificationSettings()
|
await renderNotificationSettings()
|
||||||
await screen.findByText('Alpha / wechatclawbot')
|
await screen.findByText('Alpha / wechatclawbot')
|
||||||
@@ -172,12 +194,80 @@ describe('AccountSettingNotification', () => {
|
|||||||
await user.click(channelCard.getByRole('button', { name: '保存' }))
|
await user.click(channelCard.getByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/Notifications', [
|
expect(mocks.apiPost).toHaveBeenCalledWith('notification/config', [
|
||||||
expect.objectContaining({ name: '通知4', type: 'wechat' }),
|
expect.objectContaining({ id: expect.any(String), name: '通知4', type: 'wechat' }),
|
||||||
expect.objectContaining({ name: 'Alpha', type: 'wechatclawbot' }),
|
expect.objectContaining({ id: 'channel-alpha', name: 'Alpha', type: 'wechatclawbot' }),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('通知设置保存成功')
|
expect(mocks.apiPost).toHaveBeenCalledTimes(1)
|
||||||
|
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('通知设置保存成功'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims channel names and rejects case-insensitive duplicates before saving', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
await renderNotificationSettings()
|
||||||
|
await screen.findByText('Alpha / wechatclawbot')
|
||||||
|
|
||||||
|
await fireEvent.update(screen.getByLabelText('name-Alpha'), ' 通知3 ')
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('通知渠道【通知3】已存在')
|
||||||
|
expect(screen.getByText('Alpha / wechatclawbot')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await fireEvent.update(screen.getByLabelText('name-Alpha'), ' Beta ')
|
||||||
|
expect(screen.getByText('Beta / wechatclawbot')).toBeInTheDocument()
|
||||||
|
await user.click(getCard('通知渠道').getByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mocks.apiPost).toHaveBeenCalledWith(
|
||||||
|
'notification/config',
|
||||||
|
expect.arrayContaining([expect.objectContaining({ id: 'channel-alpha', name: 'Beta' })]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the current channels and disables saving when loading fails', async () => {
|
||||||
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
|
if (endpoint === 'system/setting/Notifications') throw new Error('notifications unavailable')
|
||||||
|
if (endpoint === 'system/setting/NotificationSwitchs') {
|
||||||
|
return { success: true, data: { value: [{ type: '资源下载', action: 'user' }] } }
|
||||||
|
}
|
||||||
|
if (endpoint === 'system/setting/NotificationSendTime') {
|
||||||
|
return { success: true, data: { value: { start: '08:30', end: '22:00' } } }
|
||||||
|
}
|
||||||
|
if (endpoint === 'system/setting/NotificationTemplates') {
|
||||||
|
return { success: true, data: { value: structuredClone(templateFixture) } }
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected GET ${endpoint}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const user = userEvent.setup()
|
||||||
|
await renderNotificationSettings()
|
||||||
|
expect(await screen.findByText('加载通知渠道失败,请刷新后重试')).toBeInTheDocument()
|
||||||
|
const save = getCard('通知渠道').getByRole('button', { name: '保存' })
|
||||||
|
expect(save).toBeDisabled()
|
||||||
|
|
||||||
|
await user.click(save)
|
||||||
|
expect(mocks.apiPost).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prevents duplicate channel saves while the request is pending', async () => {
|
||||||
|
let resolveSave!: (value: unknown) => void
|
||||||
|
mocks.apiPost.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise(resolve => {
|
||||||
|
resolveSave = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
await renderNotificationSettings()
|
||||||
|
await screen.findByText('Alpha / wechatclawbot')
|
||||||
|
const save = getCard('通知渠道').getByRole('button', { name: '保存' })
|
||||||
|
|
||||||
|
await user.click(save)
|
||||||
|
await user.click(save)
|
||||||
|
expect(mocks.apiPost).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
resolveSave({ success: true, data: { value: structuredClone(notificationsFixture) } })
|
||||||
|
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('通知设置保存成功'))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('offers DingTalk as a native notification channel', async () => {
|
it('offers DingTalk as a native notification channel', async () => {
|
||||||
@@ -192,7 +282,7 @@ describe('AccountSettingNotification', () => {
|
|||||||
expect(screen.getByText('通知4 / dingtalk')).toBeInTheDocument()
|
expect(screen.getByText('通知4 / dingtalk')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('compresses chained ClawBot renames and migrates the original source before saving channels', async () => {
|
it('submits chained ClawBot renames and migration cleanup in one configuration request', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
await renderNotificationSettings()
|
await renderNotificationSettings()
|
||||||
await screen.findByText('Alpha / wechatclawbot')
|
await screen.findByText('Alpha / wechatclawbot')
|
||||||
@@ -201,27 +291,21 @@ describe('AccountSettingNotification', () => {
|
|||||||
await fireEvent.update(await screen.findByLabelText('name-Beta'), 'Gamma')
|
await fireEvent.update(await screen.findByLabelText('name-Beta'), 'Gamma')
|
||||||
await user.click(getCard('通知渠道').getByRole('button', { name: '保存' }))
|
await user.click(getCard('通知渠道').getByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledTimes(2))
|
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledTimes(1))
|
||||||
expect(mocks.apiPost).toHaveBeenNthCalledWith(1, 'notification/manage', {
|
expect(mocks.apiPost).toHaveBeenCalledWith(
|
||||||
target: 'WechatClawBot',
|
'notification/config',
|
||||||
action: 'migrate_cache',
|
|
||||||
params: { old_name: 'Alpha', new_name: 'Gamma' },
|
|
||||||
})
|
|
||||||
expect(mocks.apiPost).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
'system/setting/Notifications',
|
|
||||||
expect.arrayContaining([expect.objectContaining({ name: 'Gamma', type: 'wechatclawbot' })]),
|
expect.arrayContaining([expect.objectContaining({ name: 'Gamma', type: 'wechatclawbot' })]),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps a failed ClawBot migration pending and retries it before saving channels', async () => {
|
it('keeps a failed configuration request editable and retries it as one request', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
await renderNotificationSettings()
|
await renderNotificationSettings()
|
||||||
await screen.findByText('Alpha / wechatclawbot')
|
await screen.findByText('Alpha / wechatclawbot')
|
||||||
await fireEvent.update(screen.getByLabelText('name-Alpha'), 'Beta')
|
await fireEvent.update(screen.getByLabelText('name-Alpha'), 'Beta')
|
||||||
const save = getCard('通知渠道').getByRole('button', { name: '保存' })
|
const save = getCard('通知渠道').getByRole('button', { name: '保存' })
|
||||||
|
|
||||||
mocks.apiPost.mockResolvedValueOnce({ success: false, message: 'migration failed' })
|
mocks.apiPost.mockRejectedValueOnce(new Error('configuration failed'))
|
||||||
await user.click(save)
|
await user.click(save)
|
||||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('通知设置保存失败!'))
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('通知设置保存失败!'))
|
||||||
expect(mocks.apiPost).toHaveBeenCalledTimes(1)
|
expect(mocks.apiPost).toHaveBeenCalledTimes(1)
|
||||||
@@ -229,15 +313,9 @@ describe('AccountSettingNotification', () => {
|
|||||||
mocks.apiPost.mockClear()
|
mocks.apiPost.mockClear()
|
||||||
mocks.apiPost.mockResolvedValue({ success: true })
|
mocks.apiPost.mockResolvedValue({ success: true })
|
||||||
await user.click(save)
|
await user.click(save)
|
||||||
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledTimes(2))
|
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledTimes(1))
|
||||||
expect(mocks.apiPost).toHaveBeenNthCalledWith(1, 'notification/manage', {
|
expect(mocks.apiPost).toHaveBeenCalledWith(
|
||||||
target: 'WechatClawBot',
|
'notification/config',
|
||||||
action: 'migrate_cache',
|
|
||||||
params: { old_name: 'Alpha', new_name: 'Beta' },
|
|
||||||
})
|
|
||||||
expect(mocks.apiPost).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
'system/setting/Notifications',
|
|
||||||
expect.arrayContaining([expect.objectContaining({ name: 'Beta' })]),
|
expect.arrayContaining([expect.objectContaining({ name: 'Beta' })]),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user