补充各角色微信个人扫码绑定页面及导航
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-28 21:47:39 +08:00
parent 1763b8ef4b
commit a62987c4b3
7 changed files with 484 additions and 6 deletions

View File

@ -233,7 +233,6 @@ const pluginDefinitions = {
wechat_push: { label: '微信推送', fields: [
field('appId', '应用编号(App ID)'),
field('templateId', '消息模板编号'),
field('userId', '接收用户编号'),
field('webDomain', '网页域名')
] }
}

View File

@ -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 : [])

View File

@ -37,7 +37,8 @@
ref="currentPage"
:page-key="selectedSection"
/>
<TelegramSettings v-else-if="activePage?.kind === 'account'" ref="currentPage" />
<TelegramSettings v-else-if="activePage?.value === 'telegram-user'" ref="currentPage" />
<WeChatSettings v-else-if="activePage?.value === 'wechat-user'" ref="currentPage" />
<section v-else-if="activePage?.kind === 'external'" class="plugin-section">
<header class="plugin-section-header"><h3>{{ activePage.label }}</h3></header>
<div class="iframe-wrapper">
@ -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,

View File

@ -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);
}
}

View File

@ -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
}
}

View File

@ -0,0 +1,116 @@
<template>
<section v-loading="!!working" class="wechat-settings" aria-label="本人微信通知">
<header class="wechat-header">
<div>
<h4>微信通知</h4>
<p>绑定本人微信,仅接收当前账号的邮件通知。</p>
</div>
<el-tag :type="status.needsRebind ? 'warning' : status.bound ? 'success' : 'info'">
{{ bindingLabel }}
</el-tag>
</header>
<el-alert v-if="errorMessage" :title="errorMessage" type="error" :closable="false" show-icon />
<el-alert v-if="ready && status.message" :title="status.message" type="warning" :closable="false" show-icon />
<el-alert
v-if="status.needsRebind"
title="系统公众号已变更,请重新扫码绑定。原绑定不会收到新通知。"
type="warning"
:closable="false"
show-icon
/>
<div class="wechat-identity">
<span class="wechat-label">绑定微信</span>
<strong>{{ status.bound ? status.displayName || '已绑定的微信账号' : '尚未绑定' }}</strong>
<div class="wechat-buttons">
<el-button :disabled="!ready || !status.available || !!working" @click="beginBinding">
{{ status.bound ? '重新绑定' : '扫码绑定' }}
</el-button>
<el-button v-if="status.bound" type="danger" plain :disabled="!!working" @click="submit('unbind')">
解除绑定
</el-button>
</div>
</div>
<el-form label-position="left" label-width="100px" :disabled="!ready || !!working">
<el-form-item label="邮件通知">
<el-switch
v-model="settings.enabled"
:disabled="!status.bound || (!settings.enabled && (!status.available || status.needsRebind))"
/>
</el-form-item>
<el-form-item label="包含正文">
<el-switch v-model="settings.showContent" :disabled="!status.bound" />
</el-form-item>
</el-form>
<p class="wechat-hint">开启正文后,邮件内容将发送到微信。停用通知不会解除绑定。</p>
<footer class="wechat-buttons">
<el-button :disabled="!!working" @click="refresh">重新加载</el-button>
<el-button
:disabled="!ready || !!working || !status.available || !status.bound || status.needsRebind || hasChanges || !settings.enabled"
@click="submit('test')"
>
发送测试消息
</el-button>
<el-button type="primary" :disabled="!ready || !!working || !hasChanges" @click="submit('save')">
保存设置
</el-button>
</footer>
<el-dialog
:model-value="dialogVisible"
title="绑定本人微信"
width="420px"
append-to-body
:close-on-click-modal="false"
:close-on-press-escape="!working"
:show-close="!working"
:before-close="closeBinding"
>
<div class="wechat-binding" aria-live="polite">
<el-alert v-if="bindingError" :title="bindingError" type="warning" :closable="false" show-icon />
<template v-if="binding.phase === 'claimed'">
<h4>请核对微信身份</h4>
<strong>{{ binding.displayName || '已授权的微信账号' }}</strong>
<p>确认是本人后完成绑定;不是本人请取消。</p>
</template>
<template v-else-if="binding.phase !== 'expired'">
<img v-if="binding.image" :src="binding.image" width="240" height="240" alt="本人微信绑定二维码" />
<p>{{ binding.phase === 'processing' ? '正在读取微信授权结果…' : '请用本人微信扫码授权,再回到此处确认。' }}</p>
<span class="wechat-hint">二维码有效期为 5 分钟,请勿转发。</span>
</template>
</div>
<template #footer>
<div class="wechat-buttons wechat-dialog-actions">
<el-button :disabled="!!working" @click="closeBinding">取消</el-button>
<el-button
v-if="bindingError && binding.phase !== 'expired'"
:disabled="!!working || polling"
@click="poll"
>
重试状态查询
</el-button>
<el-button
v-if="binding.phase === 'claimed'"
type="primary"
:disabled="!!working"
@click="confirmBinding"
>
确认绑定
</el-button>
</div>
</template>
</el-dialog>
</section>
</template>
<script setup>
import { useWeChatSettings } from './WeChatSettings.js'
const {
settings, status, binding, ready, working, errorMessage, bindingError,
dialogVisible, polling, hasChanges, bindingLabel, refresh, submit,
beginBinding, confirmBinding, closeBinding, poll, beforeLeave
} = useWeChatSettings()
defineExpose({ beforeLeave })
</script>
<style scoped src="./WeChatSettings.css"></style>

View File

@ -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 () => {