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 {
|
||||
// 稳定通知渠道身份;显示名称变化时保持不变,用于配置保存和运行态引用
|
||||
id: string
|
||||
// 名称
|
||||
name: string
|
||||
// 类型 telegram/wechat/vocechat/synologychat
|
||||
|
||||
@@ -47,6 +47,7 @@ const notificationInfoDialog = computed({
|
||||
|
||||
// 通知详情
|
||||
const notificationInfo = ref<NotificationConf>({
|
||||
id: '',
|
||||
name: '',
|
||||
type: '',
|
||||
enabled: false,
|
||||
@@ -183,9 +184,12 @@ async function updateWechatClawBotQrImage(status?: WechatClawBotStatus | null) {
|
||||
/** 组装微信客服状态接口所需的请求参数。 */
|
||||
function getWechatClawBotRequestParams(extraParams: Record<string, any> = {}) {
|
||||
const config = notificationInfo.value.config || {}
|
||||
// 配置身份优先于显示名称;名称仅作为旧配置尚未归一化时的明确读取回退。
|
||||
const source = notificationInfo.value.id || notificationInfo.value.name
|
||||
const fallbackSource = props.notification.id || props.notification.name
|
||||
return {
|
||||
source: notificationInfo.value.name,
|
||||
fallback_source: props.notification.name,
|
||||
source,
|
||||
fallback_source: fallbackSource,
|
||||
WECHATCLAWBOT_BASE_URL: config.WECHATCLAWBOT_BASE_URL,
|
||||
WECHATCLAWBOT_DEFAULT_TARGET: config.WECHATCLAWBOT_DEFAULT_TARGET,
|
||||
WECHATCLAWBOT_ADMINS: config.WECHATCLAWBOT_ADMINS,
|
||||
@@ -225,19 +229,24 @@ function openNotificationInfoDialog() {
|
||||
/** 保存通知渠道编辑结果并通知父级刷新。 */
|
||||
function saveNotificationInfo() {
|
||||
// 为空不保存,跳出警告框
|
||||
if (!notificationInfo.value.name) {
|
||||
const normalizedName = notificationInfo.value.name.trim()
|
||||
if (!normalizedName) {
|
||||
$toast.error(t('notification.name') + t('common.required'))
|
||||
return
|
||||
}
|
||||
// 重名判断
|
||||
if (props.notifications.some(item => item.name === notificationInfo.value.name && item !== props.notification)) {
|
||||
$toast.error(t('notification.channel') + `【${notificationInfo.value.name}】` + t('common.exists'))
|
||||
const duplicate = props.notifications.some(
|
||||
item => item.id !== props.notification.id && item.name.trim().toLowerCase() === normalizedName.toLowerCase(),
|
||||
)
|
||||
if (duplicate) {
|
||||
$toast.error(t('notification.channel') + `【${normalizedName}】` + t('common.exists'))
|
||||
return
|
||||
}
|
||||
notificationInfo.value.name = normalizedName
|
||||
ensureWechatConfigDefaults(notificationInfo.value)
|
||||
ensureWechatClawBotConfigDefaults(notificationInfo.value)
|
||||
notificationInfoDialog.value = false
|
||||
emit('change', notificationInfo.value, props.notification.name)
|
||||
emit('change', notificationInfo.value, props.notification.id)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
|
||||
@@ -37,14 +37,14 @@ describe('SendMessageAction', () => {
|
||||
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({
|
||||
success: true,
|
||||
message: '',
|
||||
data: {
|
||||
value: [
|
||||
{ name: 'Telegram', type: 'telegram', enabled: true },
|
||||
{ name: '企业微信', type: 'wechat', enabled: false },
|
||||
{ id: 'telegram-channel', name: 'Telegram', type: 'telegram', enabled: true },
|
||||
{ id: 'wechat-channel', name: '企业微信', type: 'wechat', enabled: false },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ export default {
|
||||
loading: 'Loading',
|
||||
success: 'Success',
|
||||
error: 'Error',
|
||||
exists: 'Exists',
|
||||
openInNewWindow: 'Open in new window',
|
||||
download: 'Download',
|
||||
uploadSpeed: 'Upload speed',
|
||||
@@ -2522,6 +2523,7 @@ export default {
|
||||
notification: {
|
||||
channels: 'Notification Channels',
|
||||
channelsDesc: 'Set message sending channel parameters',
|
||||
channelsLoadFailed: 'Failed to load notification channels. Refresh and try again.',
|
||||
organizeSuccess: 'Media Import',
|
||||
downloadAdded: 'Download Added',
|
||||
subscribeAdded: 'Subscribe Added',
|
||||
|
||||
@@ -13,6 +13,7 @@ export default {
|
||||
loading: '加载中',
|
||||
success: '成功',
|
||||
error: '错误',
|
||||
exists: '已存在',
|
||||
openInNewWindow: '在新窗口中打开',
|
||||
download: '下载',
|
||||
inputMessage: '输入消息或命令',
|
||||
@@ -2475,6 +2476,7 @@ export default {
|
||||
notification: {
|
||||
channels: '通知渠道',
|
||||
channelsDesc: '设置消息发送渠道参数',
|
||||
channelsLoadFailed: '加载通知渠道失败,请刷新后重试',
|
||||
organizeSuccess: '资源入库',
|
||||
downloadAdded: '资源下载',
|
||||
subscribeAdded: '添加订阅',
|
||||
|
||||
@@ -13,6 +13,7 @@ export default {
|
||||
loading: '加載中',
|
||||
success: '成功',
|
||||
error: '錯誤',
|
||||
exists: '已存在',
|
||||
openInNewWindow: '在新窗口中打開',
|
||||
download: '下載',
|
||||
inputMessage: '輸入消息或命令',
|
||||
@@ -2474,6 +2475,7 @@ export default {
|
||||
notification: {
|
||||
channels: '通知渠道',
|
||||
channelsDesc: '設置消息發送渠道參數',
|
||||
channelsLoadFailed: '載入通知渠道失敗,請刷新後重試',
|
||||
organizeSuccess: '資源入庫',
|
||||
downloadAdded: '資源下載',
|
||||
subscribeAdded: '添加訂閱',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import { manageNotificationChannel } from '@/api/manage'
|
||||
import type { NotificationConf, NotificationSwitchConf } from '@/api/types'
|
||||
import NotificationChannelCard from '@/components/cards/NotificationChannelCard.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -79,6 +78,19 @@ const editorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dar
|
||||
// 所有消息渠道
|
||||
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()
|
||||
|
||||
@@ -132,10 +144,75 @@ const notificationTime = ref({
|
||||
end: '23:59',
|
||||
})
|
||||
|
||||
const wechatClawBotRenameMap = ref<Record<string, string>>({})
|
||||
|
||||
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() {
|
||||
editorDialogOpen.value = false
|
||||
@@ -184,11 +261,15 @@ watch(editorTheme, theme => {
|
||||
|
||||
// 添加通知渠道
|
||||
function addNotification(notification: string) {
|
||||
let name = `${t('setting.notification.channel')}${notifications.value.length + 1}`
|
||||
while (notifications.value.some(item => item.name === name)) {
|
||||
name = `${t('setting.notification.channel')}${parseInt(name.split(t('setting.notification.channel'))[1]) + 1}`
|
||||
const prefix = t('setting.notification.channel')
|
||||
let index = notifications.value.length + 1
|
||||
let name = `${prefix}${index}`
|
||||
while (notifications.value.some(item => notificationNameKey(item.name) === notificationNameKey(name))) {
|
||||
index += 1
|
||||
name = `${prefix}${index}`
|
||||
}
|
||||
notifications.value.push({
|
||||
id: createNotificationId(),
|
||||
name: name,
|
||||
type: notification,
|
||||
enabled: false,
|
||||
@@ -198,59 +279,21 @@ function addNotification(notification: string) {
|
||||
|
||||
// 移除通知渠道
|
||||
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)
|
||||
}
|
||||
|
||||
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查询通知渠道设置
|
||||
async function loadNotificationSetting() {
|
||||
notificationLoadState.value = 'loading'
|
||||
try {
|
||||
const result = await api.get<{ value?: NotificationConf[] }>('system/setting/Notifications')
|
||||
notifications.value = result.value ?? []
|
||||
wechatClawBotRenameMap.value = {}
|
||||
const result = await api.get<NotificationConfigResponse>('system/setting/Notifications')
|
||||
notifications.value = normalizeNotificationList(result.value)
|
||||
notificationLoadState.value = 'ready'
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
console.error(error)
|
||||
// 读取失败时保留当前可见配置,并锁住保存,避免用不完整列表覆盖服务端数据。
|
||||
notificationLoadState.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,14 +354,22 @@ async function loadNotificationTime() {
|
||||
|
||||
// 调用API保存通知设置
|
||||
async function saveNotificationSetting() {
|
||||
if (!canSaveNotifications.value || !validateNotificationNames(notifications.value)) return
|
||||
|
||||
notificationSaveLoading.value = true
|
||||
try {
|
||||
await migrateWechatClawBotRenames()
|
||||
await api.post('system/setting/Notifications', notifications.value, { feedback: 'silent' })
|
||||
wechatClawBotRenameMap.value = {}
|
||||
const payload = notifications.value.map((notification, index) => normalizeNotification(notification, index))
|
||||
notifications.value = payload
|
||||
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'))
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
console.error(error)
|
||||
$toast.error(t('setting.notification.saveFailed'))
|
||||
} finally {
|
||||
notificationSaveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,14 +385,22 @@ async function saveNotificationTime() {
|
||||
}
|
||||
|
||||
// 通知渠道设置变化时赋值
|
||||
function changNotificationSetting(notification: NotificationConf, name: string) {
|
||||
const index = notifications.value.findIndex(item => item.name === name)
|
||||
function changNotificationSetting(notification: NotificationConf, id: string) {
|
||||
const normalizedNotification = normalizeNotification(notification, notifications.value.length)
|
||||
const index = notifications.value.findIndex(item => item.id === id)
|
||||
if (index !== -1) {
|
||||
const previous = notifications.value[index]
|
||||
notifications.value[index] = notification
|
||||
if (previous?.type === 'wechatclawbot' && previous.name !== notification.name) {
|
||||
trackWechatClawBotRename(previous.name, notification.name)
|
||||
if (!normalizedNotification.name) {
|
||||
$toast.error(t('notification.nameRequired'))
|
||||
return
|
||||
}
|
||||
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>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VAlert v-if="notificationLoadState === 'error'" type="error" variant="tonal" class="mb-4">
|
||||
{{ t('setting.notification.channelsLoadFailed') }}
|
||||
</VAlert>
|
||||
<Draggable
|
||||
v-model="notifications"
|
||||
handle=".cursor-move"
|
||||
item-key="name"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
:component-data="{ 'class': 'grid gap-3 grid-app-card' }"
|
||||
>
|
||||
@@ -430,7 +492,13 @@ useSilentSettingRefresh(loadPageData, {
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<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') }}
|
||||
</VBtn>
|
||||
<VBtn color="success" variant="tonal">
|
||||
|
||||
@@ -43,7 +43,7 @@ vi.mock('@/components/cards/NotificationChannelCard.vue', async () => {
|
||||
<input
|
||||
:aria-label="'name-' + 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>
|
||||
</section>
|
||||
@@ -73,9 +73,15 @@ vi.mock('vuedraggable', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
const notificationsFixture = [
|
||||
{ name: 'Alpha', type: 'wechatclawbot', enabled: true, config: { token: 'fixture-token' } },
|
||||
{ name: '通知3', type: 'telegram', enabled: false, config: {} },
|
||||
const notificationsFixture: Array<{
|
||||
id?: string
|
||||
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 = {
|
||||
@@ -85,10 +91,10 @@ const templateFixture = {
|
||||
subscribeComplete: '{}',
|
||||
}
|
||||
|
||||
function mockLoadedSettings() {
|
||||
function mockLoadedSettings(channels = notificationsFixture) {
|
||||
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||
if (endpoint === 'system/setting/Notifications') {
|
||||
return { success: true, data: { value: structuredClone(notificationsFixture) } }
|
||||
return { success: true, data: { value: structuredClone(channels) } }
|
||||
}
|
||||
if (endpoint === 'system/setting/NotificationSwitchs') {
|
||||
return { success: true, data: { value: [{ type: '资源下载', action: 'user' }] } }
|
||||
@@ -156,7 +162,23 @@ describe('AccountSettingNotification', () => {
|
||||
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()
|
||||
await renderNotificationSettings()
|
||||
await screen.findByText('Alpha / wechatclawbot')
|
||||
@@ -172,12 +194,80 @@ describe('AccountSettingNotification', () => {
|
||||
await user.click(channelCard.getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/Notifications', [
|
||||
expect.objectContaining({ name: '通知4', type: 'wechat' }),
|
||||
expect.objectContaining({ name: 'Alpha', type: 'wechatclawbot' }),
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith('notification/config', [
|
||||
expect.objectContaining({ id: expect.any(String), name: '通知4', type: 'wechat' }),
|
||||
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 () => {
|
||||
@@ -192,7 +282,7 @@ describe('AccountSettingNotification', () => {
|
||||
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()
|
||||
await renderNotificationSettings()
|
||||
await screen.findByText('Alpha / wechatclawbot')
|
||||
@@ -201,27 +291,21 @@ describe('AccountSettingNotification', () => {
|
||||
await fireEvent.update(await screen.findByLabelText('name-Beta'), 'Gamma')
|
||||
await user.click(getCard('通知渠道').getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledTimes(2))
|
||||
expect(mocks.apiPost).toHaveBeenNthCalledWith(1, 'notification/manage', {
|
||||
target: 'WechatClawBot',
|
||||
action: 'migrate_cache',
|
||||
params: { old_name: 'Alpha', new_name: 'Gamma' },
|
||||
})
|
||||
expect(mocks.apiPost).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'system/setting/Notifications',
|
||||
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith(
|
||||
'notification/config',
|
||||
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()
|
||||
await renderNotificationSettings()
|
||||
await screen.findByText('Alpha / wechatclawbot')
|
||||
await fireEvent.update(screen.getByLabelText('name-Alpha'), 'Beta')
|
||||
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 waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('通知设置保存失败!'))
|
||||
expect(mocks.apiPost).toHaveBeenCalledTimes(1)
|
||||
@@ -229,15 +313,9 @@ describe('AccountSettingNotification', () => {
|
||||
mocks.apiPost.mockClear()
|
||||
mocks.apiPost.mockResolvedValue({ success: true })
|
||||
await user.click(save)
|
||||
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledTimes(2))
|
||||
expect(mocks.apiPost).toHaveBeenNthCalledWith(1, 'notification/manage', {
|
||||
target: 'WechatClawBot',
|
||||
action: 'migrate_cache',
|
||||
params: { old_name: 'Alpha', new_name: 'Beta' },
|
||||
})
|
||||
expect(mocks.apiPost).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'system/setting/Notifications',
|
||||
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith(
|
||||
'notification/config',
|
||||
expect.arrayContaining([expect.objectContaining({ name: 'Beta' })]),
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user