@@ -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 00000000..5b108ebd
--- /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 00000000..bdef95f6
--- /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 00000000..4307800b
--- /dev/null
+++ b/frontend/src/views/components/PluginSettings/WeChatSettings/WeChatSettings.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
绑定微信
+
{{ status.bound ? status.displayName || '已绑定的微信账号' : '尚未绑定' }}
+
+
+ {{ status.bound ? '重新绑定' : '扫码绑定' }}
+
+
+ 解除绑定
+
+
+
+
+
+
+
+
+
+
+
+ 开启正文后,邮件内容将发送到微信。停用通知不会解除绑定。
+
+
+
+
+
+ 请核对微信身份
+ {{ binding.displayName || '已授权的微信账号' }}
+ 确认是本人后完成绑定;不是本人请取消。
+
+
+
+ {{ binding.phase === 'processing' ? '正在读取微信授权结果…' : '请用本人微信扫码授权,再回到此处确认。' }}
+ 二维码有效期为 5 分钟,请勿转发。
+
+
+
+
+ 取消
+
+ 重试状态查询
+
+
+ 确认绑定
+
+
+
+
+
+
+
+
+
+
diff --git a/test/frontend/plugin-navigation.test.mjs b/test/frontend/plugin-navigation.test.mjs
index d093cca7..5b8e5364 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 () => {