From c7bcde6f0bfde389e01a879e22030bee443a99f2 Mon Sep 17 00:00:00 2001 From: smail Date: Tue, 29 Sep 2026 16:06:14 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84Telegram=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E7=BB=91=E5=AE=9A=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充各角色本人 Telegram 授权绑定界面、超级管理员回调配置入口及交互保护。 --- .../PluginsConfig/PluginsConfig.vue | 16 +- .../TelegramCallbackSettings.css | 31 +++ .../TelegramCallbackSettings.js | 81 ++++++ .../TelegramCallbackSettings.vue | 28 ++ .../TelegramSettings/TelegramSettings.css | 51 +++- .../TelegramSettings/TelegramSettings.js | 242 +++++++++++++++--- .../TelegramSettings/TelegramSettings.vue | 104 ++++++-- 7 files changed, 490 insertions(+), 63 deletions(-) create mode 100644 frontend/src/views/components/ConfigSettings/PluginsConfig/TelegramCallbackSettings/TelegramCallbackSettings.css create mode 100644 frontend/src/views/components/ConfigSettings/PluginsConfig/TelegramCallbackSettings/TelegramCallbackSettings.js create mode 100644 frontend/src/views/components/ConfigSettings/PluginsConfig/TelegramCallbackSettings/TelegramCallbackSettings.vue diff --git a/frontend/src/views/components/ConfigSettings/PluginsConfig/PluginsConfig.vue b/frontend/src/views/components/ConfigSettings/PluginsConfig/PluginsConfig.vue index 45e3389..620f8a2 100644 --- a/frontend/src/views/components/ConfigSettings/PluginsConfig/PluginsConfig.vue +++ b/frontend/src/views/components/ConfigSettings/PluginsConfig/PluginsConfig.vue @@ -1,14 +1,24 @@ diff --git a/frontend/src/views/components/ConfigSettings/PluginsConfig/TelegramCallbackSettings/TelegramCallbackSettings.css b/frontend/src/views/components/ConfigSettings/PluginsConfig/TelegramCallbackSettings/TelegramCallbackSettings.css new file mode 100644 index 0000000..fedfdbe --- /dev/null +++ b/frontend/src/views/components/ConfigSettings/PluginsConfig/TelegramCallbackSettings/TelegramCallbackSettings.css @@ -0,0 +1,31 @@ +.telegram-callback { + margin: 0 20px 20px; + padding: 16px; + border: 1px solid var(--el-border-color-light); + border-radius: 8px; +} + +.telegram-callback header, +.telegram-callback-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} + +.telegram-callback h4 { + margin: 0; +} + +.telegram-callback p { + color: var(--el-text-color-secondary); + line-height: 1.6; +} + +.telegram-callback .el-alert { + margin-bottom: 12px; +} + +.telegram-callback-actions .el-button { + margin-left: 0; +} diff --git a/frontend/src/views/components/ConfigSettings/PluginsConfig/TelegramCallbackSettings/TelegramCallbackSettings.js b/frontend/src/views/components/ConfigSettings/PluginsConfig/TelegramCallbackSettings/TelegramCallbackSettings.js new file mode 100644 index 0000000..ca5755c --- /dev/null +++ b/frontend/src/views/components/ConfigSettings/PluginsConfig/TelegramCallbackSettings/TelegramCallbackSettings.js @@ -0,0 +1,81 @@ +import { computed, inject, onBeforeUnmount, ref, watch } from 'vue' +import { ElMessageBox } from 'element-plus' +import { http } from '@/utils/axios' +import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore' +import { isRecentAuthenticationCancelled, requestWithRecentAuthentication } from '@/utils/recentAuthentication' + +const endpoint = '/api/v1/plugin/settings/telegram_push/callback' + +export function useTelegramCallbackSettings(emit) { + const page = inject('smail-config-page') + const store = useGlobalStatusStore() + const working = ref('') + const ready = ref(false) + const checked = ref(false) + const message = ref('') + const errorMessage = ref('') + let disposed = false + let generation = 0 + const unavailable = computed(() => store.role !== 'super_admin' || page.loading.value || + page.sectionLoading.value || page.busy.value || page.hasChanges.value || + !!page.pendingCandidate.value || !!page.sectionError.value || !!page.availabilityError.value || + !page.canWrite.value || !page.pluginSecretStatus.value?.configured || + page.formConfig.value.plugins?.telegram_push?.enabled !== true) + + // 配置或令牌发生变化后,旧的回调检查结果不能继续显示为已就绪。 + watch(() => [page.status.activeGeneration, page.pluginSecretStatus.value?.version, unavailable.value], () => { + generation++ + checked.value = false + ready.value = false + message.value = '' + }) + const operate = async action => { + if (disposed || unavailable.value || working.value) return + working.value = action + emit('working', true) + errorMessage.value = '' + const current = generation + try { + if (action === 'configure') { + try { + await ElMessageBox.confirm('将使用已保存的系统机器人配置接收绑定请求,不会自动绑定任何账号,也不会覆盖其他应用的回调。', '启用授权回调', { + type: 'warning', confirmButtonText: '启用或更新', cancelButtonText: '取消', closeOnClickModal: false + }) + } catch { return } + } + const request = () => { + if (disposed || unavailable.value || current !== generation) throw new Error('系统配置已变更,请刷新后重试') + return action === 'configure' ? http.post(endpoint) : http.get(endpoint) + } + const response = action === 'configure' ? await requestWithRecentAuthentication(request) : await request() + if (disposed || current !== generation) return + if (response?.errorNo !== 0 || typeof response.data?.ready !== 'boolean') { + throw new Error(response?.errorMsg || '读取授权回调状态失败') + } + checked.value = true + ready.value = response.data.ready + message.value = ready.value ? '回调已配置,各账号可前往“账号通知”绑定本人 Telegram。' : response.data.message || '尚未启用授权回调' + } catch (error) { + if (!disposed && !isRecentAuthenticationCancelled(error)) { + checked.value = false + ready.value = false + errorMessage.value = error.message || '授权回调操作失败' + } + } finally { + working.value = '' + emit('working', false) + } + } + const preventUnload = event => { + if (!working.value) return + event.preventDefault() + event.returnValue = '' + } + window.addEventListener('beforeunload', preventUnload) + onBeforeUnmount(() => { + disposed = true + generation++ + window.removeEventListener('beforeunload', preventUnload) + }) + return { working, ready, checked, message, errorMessage, unavailable, operate } +} diff --git a/frontend/src/views/components/ConfigSettings/PluginsConfig/TelegramCallbackSettings/TelegramCallbackSettings.vue b/frontend/src/views/components/ConfigSettings/PluginsConfig/TelegramCallbackSettings/TelegramCallbackSettings.vue new file mode 100644 index 0000000..7557292 --- /dev/null +++ b/frontend/src/views/components/ConfigSettings/PluginsConfig/TelegramCallbackSettings/TelegramCallbackSettings.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/frontend/src/views/components/PluginSettings/TelegramSettings/TelegramSettings.css b/frontend/src/views/components/PluginSettings/TelegramSettings/TelegramSettings.css index 93f81a7..762c227 100644 --- a/frontend/src/views/components/PluginSettings/TelegramSettings/TelegramSettings.css +++ b/frontend/src/views/components/PluginSettings/TelegramSettings/TelegramSettings.css @@ -31,14 +31,21 @@ margin-bottom: 20px; } -.telegram-actions { +.telegram-actions, +.telegram-buttons, +.telegram-identity { display: flex; flex-wrap: wrap; gap: 12px; + align-items: center; +} + +.telegram-actions { margin-top: 20px; } -.telegram-actions .el-button { +.telegram-actions .el-button, +.telegram-buttons .el-button { margin-left: 0; } .telegram-settings :deep(.el-form) { @@ -52,8 +59,44 @@ margin-bottom: 0; } -.telegram-settings :deep(.el-form-item:first-child) { - grid-column: 1 / -1; +.telegram-identity { + padding: 16px; + margin-bottom: 20px; + border: 1px solid var(--el-border-color-light); + border-radius: 8px; +} + +.telegram-identity strong { + flex: 1; + min-width: 100px; + overflow-wrap: anywhere; +} + +.telegram-binding { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + text-align: center; + overflow-wrap: anywhere; +} + +.telegram-binding img { + max-width: 100%; + height: auto; +} + +.telegram-binding h4, +.telegram-binding p { + margin: 0; +} + +.telegram-binding a { + color: var(--el-color-primary); +} + +.telegram-dialog-actions { + justify-content: flex-end; } @media (max-width: 768px) { diff --git a/frontend/src/views/components/PluginSettings/TelegramSettings/TelegramSettings.js b/frontend/src/views/components/PluginSettings/TelegramSettings/TelegramSettings.js index 9580ead..176b9c4 100644 --- a/frontend/src/views/components/PluginSettings/TelegramSettings/TelegramSettings.js +++ b/frontend/src/views/components/PluginSettings/TelegramSettings/TelegramSettings.js @@ -1,10 +1,12 @@ import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' +import QRCode from 'qrcode' import { http } from '@/utils/axios' +import { isRecentAuthenticationCancelled, requestWithRecentAuthentication } from '@/utils/recentAuthentication' const endpoint = '/api/v1/plugin/settings/telegram_push' const defaults = { - chatId: '', + enabled: false, showContent: false, spoilerContent: false, sendAttachments: false, @@ -13,46 +15,58 @@ const defaults = { export function useTelegramSettings() { const settings = reactive({ ...defaults }) + const status = reactive({ bound: false, displayName: '', needsRebind: false, available: false, message: '' }) + const binding = reactive({ ticket: '', phase: '', displayName: '', expiresAt: 0, url: '', image: '' }) const version = ref(0) const ready = ref(false) const working = ref('') const errorMessage = ref('') - const botUsername = ref('') + const bindingError = ref('') + const dialogVisible = ref(false) + const polling = ref(false) const savedSettings = ref('') const hasChanges = computed(() => ready.value && JSON.stringify(settings) !== savedSettings.value) let disposed = false - const botLink = computed(() => /^[a-zA-Z0-9_]{5,32}$/.test(botUsername.value) - ? `https://t.me/${botUsername.value}` : '') + let generation = 0 + let pollTimer + let expiryTimer + const bindingLabel = computed(() => status.needsRebind ? '需要重新绑定' : status.bound ? '已绑定' : '未绑定') const checkResponse = (response, fallback) => { if (!response || response.errorNo !== 0) throw new Error(response?.errorMsg || fallback) return response.data } const acceptSettings = data => { - if (!data?.settings || !Number.isSafeInteger(data.version) || data.version < 0) { + if (!data || typeof data.bound !== 'boolean' || !Number.isSafeInteger(data.version) || data.version < 0) { throw new Error('插件设置响应不完整,请重新加载') } for (const key of Object.keys(defaults)) { - settings[key] = key === 'chatId' ? String(data.settings[key] || '') : data.settings[key] === true + settings[key] = data[key] === true + } + status.bound = data.bound + status.displayName = typeof data.displayName === 'string' ? data.displayName : '' + status.needsRebind = data.needsRebind === true + if (typeof data.available === 'boolean') { + status.available = data.available + status.message = typeof data.message === 'string' ? data.message : '' } version.value = data.version savedSettings.value = JSON.stringify(settings) ready.value = true } - const beforeLeave = async () => { - if (working.value) return false + const confirmDiscard = async () => { if (!hasChanges.value) return true try { - await ElMessageBox.confirm('当前通知设置尚未保存,确定离开吗?', '未保存的更改', { - type: 'warning', confirmButtonText: '离开', cancelButtonText: '继续编辑' + await ElMessageBox.confirm('当前通知设置尚未保存,确定放弃这些更改吗?', '未保存的更改', { + type: 'warning', confirmButtonText: '放弃更改', cancelButtonText: '继续编辑' }) - return true + return !disposed } catch { return false } } const load = async () => { - if (working.value || disposed) return + if (working.value || disposed || dialogVisible.value) return working.value = 'load' ready.value = false errorMessage.value = '' @@ -60,49 +74,190 @@ export function useTelegramSettings() { const response = await http.get(`${endpoint}/user`) if (disposed) return acceptSettings(checkResponse(response, '读取 Telegram 设置失败')) - try { - const info = checkResponse(await http.get(`${endpoint}/bot`), '读取机器人信息失败') - if (!disposed) botUsername.value = info?.username || '' - } catch { - // 机器人不可用不应阻止用户停用通知或调整本人设置。 - botUsername.value = '' - } } catch (error) { if (!disposed) errorMessage.value = error.message || '读取 Telegram 设置失败' } finally { working.value = '' } } - const submit = async action => { - if (!ready.value || working.value || disposed) return - const chatId = settings.chatId.trim() - if ((chatId && !/^-?[1-9][0-9]{0,18}$/.test(chatId)) || (action === 'test' && !chatId)) { - errorMessage.value = '请输入有效的 Telegram 聊天 ID;留空可保存为停用状态' - return + const reportSync = data => { + if (data.synced === false) { + errorMessage.value = '设置已保存,但运行中的推送服务尚未同步,请联系超级管理员。' + } else { + ElMessage.success('Telegram 通知设置已保存') } - working.value = action - errorMessage.value = '' - // 仅发送本人可维护的白名单字段,不接收插件 HTML 提供的路由或身份。 - const payload = { settings: { ...settings, chatId }, version: version.value } + } + const recentRequest = request => requestWithRecentAuthentication(() => { + if (disposed) throw new Error('当前页面已关闭') + return request() + }) + const bindingRequest = (action, ticket = binding.ticket) => http.post(`${endpoint}/binding`, { action, ticket }) + const stopTimers = () => { + clearTimeout(pollTimer) + clearTimeout(expiryTimer) + polling.value = false + } + const clearBinding = () => { + generation++ + stopTimers() + dialogVisible.value = false + Object.assign(binding, { ticket: '', phase: '', displayName: '', expiresAt: 0, url: '', image: '' }) + bindingError.value = '' + } + const expire = () => { + generation++ + stopTimers() + binding.phase = 'expired' + binding.url = '' + binding.image = '' + bindingError.value = '授权链接已过期,请关闭后重新申请。' + } + const poll = async () => { + if (disposed || polling.value || !dialogVisible.value || !binding.ticket || binding.phase === 'expired') return + if (Date.now() >= binding.expiresAt) return expire() + clearTimeout(pollTimer) + const current = generation + polling.value = true + bindingError.value = '' try { - const response = action === 'test' - ? await http.post(`${endpoint}/test`, payload) - : await http.put(`${endpoint}/user`, payload) - if (disposed) return - const data = checkResponse(response, action === 'test' ? '发送测试消息失败' : '保存 Telegram 设置失败') - if (action !== 'test') acceptSettings(data) - ElMessage.success(action === 'test' ? '测试消息已发送' : 'Telegram 设置已保存') + const data = checkResponse(await bindingRequest('status'), '读取授权状态失败') + if (disposed || current !== generation) return + if (!['waiting', 'claimed'].includes(data?.phase)) throw new Error('授权状态异常,请重新绑定') + binding.phase = data.phase + binding.displayName = typeof data.displayName === 'string' ? data.displayName : '' + if (data.phase === 'claimed') { + binding.url = '' + binding.image = '' + } else { + pollTimer = setTimeout(poll, 2000) + } } catch (error) { - if (!disposed) errorMessage.value = error.message || 'Telegram 操作失败' + if (!disposed && current === generation) bindingError.value = error.message || '读取授权状态失败,请重试' + } finally { + if (current === generation) polling.value = false + } + } + const closeBinding = async () => { + if (working.value) return false + working.value = 'cancel' + generation++ + stopTimers() + try { + if (binding.ticket) checkResponse(await bindingRequest('cancel'), '取消绑定失败') + clearBinding() + return true + } catch (error) { + if (!disposed) bindingError.value = error.message || '取消绑定失败,请重试' + return false } finally { working.value = '' } } + const beginBinding = async () => { + if (!ready.value || !status.available || working.value || disposed || dialogVisible.value) return + working.value = 'begin' + errorMessage.value = '' + let ticket = '' + try { + if (!await confirmDiscard()) return + const data = checkResponse(await recentRequest(() => bindingRequest('begin', '')), '创建授权请求失败') + ticket = typeof data?.ticket === 'string' ? data.ticket : '' + if (!/^[A-Za-z0-9_-]{43}$/.test(ticket)) throw new Error('绑定凭据无效,请重新申请') + if (disposed) return + const url = new URL(data.url) + const expiresAt = Date.parse(data.expiresAt) + if (url.origin !== 'https://t.me' || !/^\/[A-Za-z0-9_]{5,32}$/.test(url.pathname) || + url.username || url.password || url.hash || [...url.searchParams].length !== 1 || + url.searchParams.get('start') !== `smail_${ticket}` || !Number.isFinite(expiresAt) || + expiresAt <= Date.now() || expiresAt > Date.now() + 310000) { + throw new Error('Telegram 授权地址或有效期异常,请重新申请') + } + const image = await QRCode.toDataURL(url.href, { width: 240, margin: 2 }) + if (disposed) return + clearBinding() + Object.assign(binding, { ticket, phase: 'waiting', displayName: '', expiresAt, url: url.href, image }) + dialogVisible.value = true + ticket = '' + expiryTimer = setTimeout(expire, Math.max(0, expiresAt - Date.now())) + await poll() + } catch (error) { + if (!disposed && !isRecentAuthenticationCancelled(error)) errorMessage.value = error.message || '创建授权请求失败' + } finally { + if (/^[A-Za-z0-9_-]{43}$/.test(ticket)) { + // 页面关闭或二维码生成失败时撤销请求,失败时仍由服务端有效期兜底。 + try { await bindingRequest('cancel', ticket) } catch { /* 不再展示已失效的授权地址。 */ } + } + working.value = '' + } + } + const confirmBinding = async () => { + if (disposed || working.value || binding.phase !== 'claimed') return + if (Date.now() >= binding.expiresAt) return expire() + working.value = 'confirm' + bindingError.value = '' + const ticket = binding.ticket + try { + const data = checkResponse(await recentRequest(() => { + if (Date.now() >= binding.expiresAt) throw new Error('授权已过期,请重新绑定') + return bindingRequest('confirm', ticket) + }), '确认绑定失败') + if (disposed) return + acceptSettings(data) + clearBinding() + reportSync(data) + } catch (error) { + if (!disposed && !isRecentAuthenticationCancelled(error)) bindingError.value = error.message || '确认绑定失败' + } finally { + working.value = '' + } + } + const submit = async action => { + if (!ready.value || working.value || disposed || dialogVisible.value) return + working.value = action + errorMessage.value = '' + try { + if (action === 'unbind') { + try { + await ElMessageBox.confirm('解除后将停止向此 Telegram 账号发送通知,确定继续吗?', '解除本人绑定', { + type: 'warning', confirmButtonText: '解除绑定', cancelButtonText: '取消' + }) + } catch { return } + } + // 页面不提交接收身份,只能修改本人偏好或使用已确认的绑定。 + const payload = { ...settings, version: version.value } + const response = await recentRequest(() => action === 'test' + ? http.post(`${endpoint}/test`) + : action === 'unbind' + ? http.delete(`${endpoint}/user`, { data: payload }) + : http.put(`${endpoint}/user`, payload)) + if (disposed) return + const data = checkResponse(response, action === 'test' ? '发送测试消息失败' : '保存 Telegram 设置失败') + if (action === 'test') ElMessage.success('测试请求已提交,请到 Telegram 核对') + else { + acceptSettings(data) + reportSync(data) + } + } catch (error) { + if (!disposed && !isRecentAuthenticationCancelled(error)) errorMessage.value = error.message || 'Telegram 操作失败' + } finally { + working.value = '' + } + } + const beforeLeave = async () => { + if (working.value || !await confirmDiscard()) return false + if (!dialogVisible.value) return true + try { + await ElMessageBox.confirm('当前授权尚未完成,确定取消并离开吗?', '取消绑定', { + type: 'warning', confirmButtonText: '取消并离开', cancelButtonText: '继续绑定' + }) + } catch { return false } + return closeBinding() + } const refresh = async () => { - if (await beforeLeave()) await load() + if (!working.value && await confirmDiscard()) await load() } const preventUnload = event => { - if (!hasChanges.value && !working.value) return + if (!hasChanges.value && !working.value && !dialogVisible.value) return event.preventDefault() event.returnValue = '' } @@ -113,7 +268,14 @@ export function useTelegramSettings() { }) onBeforeUnmount(() => { disposed = true + const ticket = binding.ticket + clearBinding() if (typeof window !== 'undefined') window.removeEventListener('beforeunload', preventUnload) + if (ticket) bindingRequest('cancel', ticket).catch(() => {}) }) - return { settings, version, ready, working, errorMessage, botLink, load, refresh, submit, beforeLeave, hasChanges } + return { + settings, status, binding, ready, working, errorMessage, bindingError, + dialogVisible, polling, hasChanges, bindingLabel, refresh, submit, + beginBinding, confirmBinding, closeBinding, poll, beforeLeave + } } diff --git a/frontend/src/views/components/PluginSettings/TelegramSettings/TelegramSettings.vue b/frontend/src/views/components/PluginSettings/TelegramSettings/TelegramSettings.vue index a67c7c1..b8a3d54 100644 --- a/frontend/src/views/components/PluginSettings/TelegramSettings/TelegramSettings.vue +++ b/frontend/src/views/components/PluginSettings/TelegramSettings/TelegramSettings.vue @@ -1,43 +1,115 @@