From a62987c4b3a52a76793a5d9b414f9e06fb5b3c9d Mon Sep 17 00:00:00 2001 From: smail Date: Mon, 28 Sep 2026 21:47:39 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=90=84=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E4=B8=AA=E4=BA=BA=E6=89=AB=E7=A0=81=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E9=A1=B5=E9=9D=A2=E5=8F=8A=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConfigSettings/ConfigForm/ConfigForm.js | 1 - .../PluginSettings/PluginSettings.js | 5 +- .../PluginSettings/PluginSettings.vue | 4 +- .../WeChatSettings/WeChatSettings.css | 94 +++++++ .../WeChatSettings/WeChatSettings.js | 261 ++++++++++++++++++ .../WeChatSettings/WeChatSettings.vue | 116 ++++++++ test/frontend/plugin-navigation.test.mjs | 9 +- 7 files changed, 484 insertions(+), 6 deletions(-) create mode 100644 frontend/src/views/components/PluginSettings/WeChatSettings/WeChatSettings.css create mode 100644 frontend/src/views/components/PluginSettings/WeChatSettings/WeChatSettings.js create mode 100644 frontend/src/views/components/PluginSettings/WeChatSettings/WeChatSettings.vue diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js index 4c8ebbd..b4842ed 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js @@ -233,7 +233,6 @@ const pluginDefinitions = { wechat_push: { label: '微信推送', fields: [ field('appId', '应用编号(App ID)'), field('templateId', '消息模板编号'), - field('userId', '接收用户编号'), field('webDomain', '网页域名') ] } } diff --git a/frontend/src/views/components/PluginSettings/PluginSettings.js b/frontend/src/views/components/PluginSettings/PluginSettings.js index bace7b2..c9558d1 100644 --- a/frontend/src/views/components/PluginSettings/PluginSettings.js +++ b/frontend/src/views/components/PluginSettings/PluginSettings.js @@ -22,7 +22,10 @@ export function usePage() { ] : []) ] : []) const accountOptions = computed(() => store.hasCapability('plugin.read') && store.hasCapability('plugin.write_own') - ? [{ value: 'telegram-user', label: 'Telegram 通知', kind: 'account' }] + ? [ + { value: 'telegram-user', label: 'Telegram 通知', kind: 'account' }, + { value: 'wechat-user', label: '微信通知', kind: 'account' } + ] : []) const canReadExternal = () => store.hasCapability('config.read') && store.hasCapability('plugin.read') const externalOptions = computed(() => canReadExternal() ? externalPlugins.value : []) diff --git a/frontend/src/views/components/PluginSettings/PluginSettings.vue b/frontend/src/views/components/PluginSettings/PluginSettings.vue index 574ff3e..1fd677e 100644 --- a/frontend/src/views/components/PluginSettings/PluginSettings.vue +++ b/frontend/src/views/components/PluginSettings/PluginSettings.vue @@ -37,7 +37,8 @@ ref="currentPage" :page-key="selectedSection" /> - + +

{{ activePage.label }}

@@ -61,6 +62,7 @@ import { usePage } from './PluginSettings.js' import SettingsSubmenu from '../SettingsSubmenu/SettingsSubmenu.vue' import PluginsConfig from '../ConfigSettings/PluginsConfig/PluginsConfig.vue' import TelegramSettings from './TelegramSettings/TelegramSettings.vue' +import WeChatSettings from './WeChatSettings/WeChatSettings.vue' const { sectionOptions, accountOptions, externalOptions, selectedSection, activePage, diff --git a/frontend/src/views/components/PluginSettings/WeChatSettings/WeChatSettings.css b/frontend/src/views/components/PluginSettings/WeChatSettings/WeChatSettings.css new file mode 100644 index 0000000..5b108eb --- /dev/null +++ b/frontend/src/views/components/PluginSettings/WeChatSettings/WeChatSettings.css @@ -0,0 +1,94 @@ +.wechat-settings { + max-width: 760px; + padding: 16px; +} + +.wechat-header, +.wechat-identity, +.wechat-buttons { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; +} + +.wechat-header { + justify-content: space-between; + margin-bottom: 20px; +} + +.wechat-header h4 { + margin: 0 0 8px; + font-size: 18px; +} + +.wechat-header p, +.wechat-hint, +.wechat-label { + color: var(--el-text-color-secondary); + line-height: 1.6; +} + +.wechat-header p { + margin: 0; +} + +.wechat-settings .el-alert { + margin-bottom: 16px; +} + +.wechat-identity { + padding: 16px; + margin-bottom: 20px; + border: 1px solid var(--el-border-color-light); + border-radius: 8px; +} + +.wechat-identity strong { + flex: 1; + min-width: 100px; + overflow-wrap: anywhere; +} + +.wechat-buttons .el-button { + margin-left: 0; +} + +.wechat-settings :deep(.el-form) { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 20px; +} + +.wechat-settings :deep(.el-form-item) { + margin-bottom: 0; +} + +.wechat-binding { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + text-align: center; + overflow-wrap: anywhere; +} + +.wechat-binding img { + max-width: 100%; + height: auto; +} + +.wechat-binding h4, +.wechat-binding p { + margin: 0; +} + +.wechat-dialog-actions { + justify-content: flex-end; +} + +@media (max-width: 768px) { + .wechat-settings :deep(.el-form) { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/frontend/src/views/components/PluginSettings/WeChatSettings/WeChatSettings.js b/frontend/src/views/components/PluginSettings/WeChatSettings/WeChatSettings.js new file mode 100644 index 0000000..bdef95f --- /dev/null +++ b/frontend/src/views/components/PluginSettings/WeChatSettings/WeChatSettings.js @@ -0,0 +1,261 @@ +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/wechat_push' + +export function useWeChatSettings() { + const settings = reactive({ enabled: false, showContent: false }) + const status = reactive({ bound: false, displayName: '', needsRebind: false, available: false, message: '' }) + const binding = reactive({ ticket: '', phase: '', displayName: '', expiresAt: 0, image: '' }) + const ready = ref(false) + const working = ref('') + const errorMessage = ref('') + const bindingError = ref('') + const dialogVisible = ref(false) + const polling = ref(false) + const version = ref(0) + const savedSettings = ref('') + const hasChanges = computed(() => ready.value && savedSettings.value !== JSON.stringify(settings)) + const bindingLabel = computed(() => status.needsRebind ? '需要重新绑定' : status.bound ? '已绑定' : '未绑定') + let disposed = false + let generation = 0 + let pollTimer + let expiryTimer + + const checkResponse = (response, fallback) => { + if (!response || response.errorNo !== 0) throw new Error(response?.errorMsg || fallback) + return response.data + } + const acceptSettings = data => { + if (!data || !Number.isSafeInteger(data.version) || data.version < 0 || typeof data.bound !== 'boolean') { + throw new Error('微信通知设置响应不完整,请重新加载') + } + version.value = data.version + settings.enabled = data.enabled === true + settings.showContent = data.showContent === true + status.bound = data.bound + status.needsRebind = data.needsRebind === true + status.displayName = typeof data.displayName === 'string' ? data.displayName : '' + if (typeof data.available === 'boolean') { + status.available = data.available + status.message = typeof data.message === 'string' ? data.message : '' + } + savedSettings.value = JSON.stringify(settings) + ready.value = true + } + const reportSync = data => { + if (data.synced === false) { + errorMessage.value = '设置已保存,但运行中的推送服务尚未同步,请联系超级管理员。' + } else { + ElMessage.success('微信通知设置已保存') + } + } + const stopTimers = () => { + clearTimeout(pollTimer) + clearTimeout(expiryTimer) + polling.value = false + } + const clearBinding = () => { + generation++ + stopTimers() + dialogVisible.value = false + Object.assign(binding, { ticket: '', phase: '', displayName: '', expiresAt: 0, image: '' }) + bindingError.value = '' + } + const bindingRequest = (action, ticket = binding.ticket) => http.post(`${endpoint}/binding`, { action, ticket }) + const recentRequest = request => requestWithRecentAuthentication(() => { + if (disposed) throw new Error('当前页面已关闭') + return request() + }) + const confirmDiscard = async () => { + if (!hasChanges.value) return true + try { + await ElMessageBox.confirm('当前通知设置尚未保存,确定放弃这些更改吗?', '未保存的更改', { + confirmButtonText: '放弃更改', cancelButtonText: '继续编辑', type: 'warning' + }) + return !disposed + } catch { + return false + } + } + const load = async () => { + if (disposed || working.value || dialogVisible.value) return + working.value = 'load' + errorMessage.value = '' + ready.value = false + try { + const data = checkResponse(await http.get(`${endpoint}/user`), '读取微信通知设置失败') + if (!disposed) acceptSettings(data) + } catch (error) { + if (!disposed) errorMessage.value = error.message || '读取微信通知设置失败' + } finally { + working.value = '' + } + } + const expire = () => { + generation++ + stopTimers() + binding.phase = 'expired' + 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 data = checkResponse(await bindingRequest('status'), '读取扫码状态失败') + if (disposed || current !== generation) return + if (!['waiting', 'processing', 'claimed'].includes(data?.phase)) throw new Error('扫码状态异常,请重新绑定') + binding.phase = data.phase + binding.displayName = typeof data.displayName === 'string' ? data.displayName : '' + if (data.phase !== 'claimed') pollTimer = setTimeout(poll, 2000) + } catch (error) { + 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://open.weixin.qq.com' || url.pathname !== '/connect/oauth2/authorize' || + url.username || url.password || url.searchParams.get('state') !== ticket || + !Number.isFinite(expiresAt) || expiresAt <= Date.now() || expiresAt > Date.now() + 310000) { + throw new Error('微信授权地址或有效期异常,请重新申请') + } + const image = await QRCode.toDataURL(url.href, { width: 240, margin: 2 }) + if (disposed) return + clearBinding() + Object.assign(binding, { ticket, phase: 'waiting', displayName: '', expiresAt, 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' + const ticket = binding.ticket + bindingError.value = '' + try { + const data = checkResponse(await recentRequest(() => 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 (disposed || !ready.value || working.value || dialogVisible.value) return + working.value = action + errorMessage.value = '' + try { + if (action === 'unbind') { + try { + await ElMessageBox.confirm('解除后将停止向此微信发送通知,确定继续吗?', '解除本人绑定', { + confirmButtonText: '解除绑定', cancelButtonText: '取消', type: 'warning' + }) + } 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, '微信通知操作失败') + if (action === 'test') ElMessage.success('测试请求已提交,请到微信核对') + else { + acceptSettings(data) + reportSync(data) + } + } catch (error) { + if (!disposed && !isRecentAuthenticationCancelled(error)) errorMessage.value = error.message || '微信通知操作失败' + } finally { + working.value = '' + } + } + const beforeLeave = async () => { + if (working.value) return false + if (!await confirmDiscard()) return false + if (!dialogVisible.value) return true + try { + await ElMessageBox.confirm('当前扫码绑定尚未完成,确定取消并离开吗?', '取消绑定', { + confirmButtonText: '取消并离开', cancelButtonText: '继续绑定', type: 'warning' + }) + } catch { return false } + return closeBinding() + } + const refresh = async () => { + if (!working.value && await confirmDiscard()) await load() + } + const preventUnload = event => { + if (!hasChanges.value && !dialogVisible.value && !working.value) return + event.preventDefault() + event.returnValue = '' + } + onMounted(() => { + window.addEventListener('beforeunload', preventUnload) + load() + }) + onBeforeUnmount(() => { + disposed = true + const ticket = binding.ticket + clearBinding() + window.removeEventListener('beforeunload', preventUnload) + if (ticket) bindingRequest('cancel', ticket).catch(() => {}) + }) + 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/WeChatSettings/WeChatSettings.vue b/frontend/src/views/components/PluginSettings/WeChatSettings/WeChatSettings.vue new file mode 100644 index 0000000..4307800 --- /dev/null +++ b/frontend/src/views/components/PluginSettings/WeChatSettings/WeChatSettings.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/test/frontend/plugin-navigation.test.mjs b/test/frontend/plugin-navigation.test.mjs index d093cca..5b8e536 100644 --- a/test/frontend/plugin-navigation.test.mjs +++ b/test/frontend/plugin-navigation.test.mjs @@ -31,11 +31,14 @@ for (const role of ['super_admin', 'admin', 'user']) { assert.deepEqual(page.sectionOptions.value.map(item => item.value), role === 'super_admin' ? ['spam_block', 'telegram_push', 'wechat_push'] : role === 'admin' ? ['spam_block'] : []) - assert.deepEqual(page.accountOptions.value.map(item => item.value), ['telegram-user']) + assert.deepEqual(page.accountOptions.value.map(item => item.value), ['telegram-user', 'wechat-user']) await page.selectSection('telegram-user') assert.equal(page.activePage.value.kind, 'account') + await page.selectSection('wechat-user') + assert.equal(page.activePage.value.kind, 'account') + assert.equal(page.activePage.value.value, 'wechat-user') await page.selectSection('wechat_push') - assert.equal(page.selectedSection.value, role === 'super_admin' ? 'wechat_push' : 'telegram-user') + assert.equal(page.selectedSection.value, role === 'super_admin' ? 'wechat_push' : 'wechat-user') }) } @@ -59,7 +62,7 @@ test('独立插件配置包含三个运行配置页,停用插件不依赖运 assert.equal(page.activePage.value.kind, 'config') assert.equal(page.activePage.value.value, key) } - assert.deepEqual(page.accountOptions.value.map(item => item.value), ['telegram-user']) + assert.deepEqual(page.accountOptions.value.map(item => item.value), ['telegram-user', 'wechat-user']) }) test('普通账号只保留本人通知设置,不读取或进入全局配置和管理员插件列表', async () => {