整合插件配置入口并保留权限与未保存保护
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-08 14:17:14 +08:00
parent 82f740d128
commit 6a17af13ed
13 changed files with 346 additions and 250 deletions

View File

@ -8,7 +8,7 @@ const lang = {
"newUser": "新增用户",
"editUser": "编辑用户",
"user_management": "用户管理",
"plugin_settings": "插件设置",
"plugin_settings": "插件配置",
"lang": "zhCn",
"submit": "提交",
"compose": "发件",

View File

@ -3,7 +3,6 @@ import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
import BaseConfig from './BaseConfig/BaseConfig.vue'
import EmailConfig from './EmailConfig/EmailConfig.vue'
import SslConfig from './SslConfig/SslConfig.vue'
import PluginsConfig from './PluginsConfig/PluginsConfig.vue'
import ConfigMaintenance from './ConfigMaintenance/ConfigMaintenance.vue'
export function useNavigation() {
@ -15,12 +14,7 @@ export function useNavigation() {
const pages = [
{ value: 'base', label: '基础配置', component: BaseConfig },
{ value: 'mail', label: '邮件服务', component: EmailConfig },
{ value: 'ssl', label: 'SSL 配置', component: SslConfig },
{ value: 'plugins', label: '插件配置', children: [
{ value: 'spam_block', label: '垃圾邮件拦截', component: PluginsConfig },
{ value: 'telegram_push', label: 'Telegram 推送', component: PluginsConfig },
{ value: 'wechat_push', label: '微信推送', component: PluginsConfig }
] }
{ value: 'ssl', label: 'SSL 配置', component: SslConfig }
]
const tools = [
{ value: 'migration', label: '数据库迁移', capability: 'database.migrate' },

View File

@ -83,7 +83,7 @@ export function usePage(pageKey = 'base') {
const scope = pending.payload && loadedText.value
? (changed.length ? `上次未完成的保存涉及:${changed.join('、')}。` : '上次未完成保存的插件运行配置未变化。')
: '上次未完成保存的内容尚未完整读取,请刷新后核对。'
return `${scope}三个插件共用一组运行配置,请在对应三级菜单核对;完成保存时会统一生效,其他插件的原有配置会保留。`
return `${scope}三个插件共用一组运行配置,请在“插件配置”的对应页面核对;完成保存时会统一生效,其他插件的原有配置会保留。`
}
if (pending?.section !== 'mail' || apiSection() !== 'mail') return ''
const active = readFormConfig(loadedText.value)

View File

@ -53,7 +53,8 @@ export function usePage() {
{ key: 'rule', label: lang.rule_setting, hint: '邮件处理规则', icon: Filter, component: RuleSettings, capability: 'user.read' },
{ key: 'users', label: lang.user_management, hint: '账号与权限管理', icon: User, component: UserManagement, capability: 'user.manage' },
{ key: 'plugins', label: lang.plugin_settings, hint: '扩展功能管理', icon: Grid, component: PluginSettings, capability: 'plugin.read' },
].filter(item => globalStatus.hasCapability(item.capability)))
].filter(item => globalStatus.hasCapability(item.capability) ||
(item.key === 'plugins' && globalStatus.hasCapability('config.read'))))
const activeSettings = computed(() => settingsMenu.value.find(item => item.key === globalStatus.settingsActiveTab))
const settingsPages = new Map()

View File

@ -1,34 +1,19 @@
.settings-card {
height: 100%;
display: flex;
flex-direction: column;
}
.settings-header {
margin-bottom: 18px;
}
.settings-header h3 {
font-size: 18px;
font-weight: 600;
color: var(--pm-text-primary);
margin: 0 0 6px;
}
.settings-desc {
font-size: 14px;
color: var(--pm-text-secondary);
margin: 0;
}
.plugin-container {
flex: 1;
.plugin-settings {
min-width: 0;
}
.plugin-list {
.plugin-list-error {
display: grid;
gap: 12px;
justify-items: start;
gap: 8px;
padding: 8px;
color: var(--el-color-danger);
font-size: 12px;
overflow-wrap: anywhere;
}
.plugin-settings :deep(.telegram-settings) {
padding: 0;
}
.plugin-section {
@ -55,29 +40,7 @@
background: var(--pm-bg-primary);
}
.plugin-section-title {
display: flex;
align-items: center;
min-width: 0;
gap: 12px;
}
.plugin-section-index {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
flex: 0 0 30px;
border-radius: 9px;
color: var(--pm-primary-color);
background: color-mix(in srgb, var(--pm-primary-color) 12%, var(--pm-bg-secondary));
font-size: 12px;
font-weight: 700;
letter-spacing: .04em;
}
.plugin-section-header h4 {
.plugin-section-header h3 {
overflow: hidden;
margin: 0 0 2px;
color: var(--pm-text-primary);
@ -87,27 +50,6 @@
white-space: nowrap;
}
.plugin-section-header p {
margin: 0;
color: var(--pm-text-secondary);
font-size: 12px;
}
.plugin-section-status {
flex: 0 0 auto;
padding: 4px 9px;
border: 1px solid color-mix(in srgb, #16a34a 22%, var(--pm-border-color));
border-radius: 999px;
color: #15803d;
background: #f0fdf4;
font-size: 12px;
line-height: 1;
}
.plugin-section-status.is-active {
font-weight: 600;
}
.plugin-section :deep(button:focus-visible),
.plugin-section :deep(input:focus-visible),
.plugin-section :deep(textarea:focus-visible),
@ -116,10 +58,6 @@
outline-offset: 2px;
}
.plugin-section-content {
min-width: 0;
}
.iframe-wrapper {
height: 320px;
min-height: 240px;
@ -133,33 +71,11 @@ iframe {
border: 0;
}
.empty-state {
display: flex;
min-height: 260px;
justify-content: center;
align-items: center;
border: 1px dashed var(--pm-border-color);
border-radius: var(--pm-radius-md);
background: var(--pm-bg-secondary);
}
@media (max-width: 768px) {
.settings-header {
margin-bottom: 14px;
}
.plugin-list {
gap: 10px;
}
.plugin-section-header {
padding: 10px 12px;
}
.plugin-section-status {
display: none;
}
.iframe-wrapper {
height: 280px;
}

View File

@ -1,80 +1,80 @@
import { computed, reactive, ref } from 'vue'
import { computed, nextTick, onMounted, onBeforeUnmount, ref } from 'vue'
import { http } from '@/utils/axios'
import lang from '../../../i18n/i18n'
import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
export function usePage() {
const pluginList = reactive({})
const pluginFrames = ref([])
const store = useGlobalStatusStore()
const selectedSection = ref(store.hasCapability('config.read') ? 'spam_block' : 'telegram-user')
const currentPage = ref(null)
const pageHost = ref(null)
const switching = ref(false)
const externalPlugins = ref([])
const loading = ref(false)
const errorMessage = ref('')
let disposed = false
const pluginLogicalID = name => {
const aliases = {
SpamBlock: 'spam_block',
WechatPush: 'wechat_push',
TelegramPush: 'telegram_push'
// 全局配置不依赖正在运行的插件列表,未启用的插件也必须可以配置。
const sectionOptions = computed(() => store.hasCapability('config.read') ? [
{ value: 'spam_block', label: '垃圾邮件拦截', kind: 'config' },
{ value: 'telegram_push', label: 'Telegram 推送', kind: 'config' },
{ value: 'wechat_push', label: '微信推送', kind: 'config' }
] : [])
const accountOptions = computed(() => store.hasCapability('plugin.read') && store.hasCapability('plugin.write_own')
? [{ value: 'telegram-user', label: 'Telegram 通知', kind: 'account' }]
: [])
const canReadExternal = () => store.hasCapability('config.read') && store.hasCapability('plugin.read')
const externalOptions = computed(() => canReadExternal() ? externalPlugins.value : [])
const visiblePages = () => [...sectionOptions.value, ...accountOptions.value, ...externalOptions.value]
const activePage = computed(() => visiblePages().find(item => item.value === selectedSection.value))
const beforeLeave = () => currentPage.value?.beforeLeave?.() ?? true
const selectSection = async value => {
const visible = () => visiblePages().some(item => item.value === value)
if (disposed || switching.value || value === selectedSection.value || !visible()) return
switching.value = true
try {
if (!await beforeLeave() || disposed || !visible()) return
selectedSection.value = value
await nextTick()
pageHost.value?.closest('.el-tabs__content')?.scrollTo({ top: 0 })
pageHost.value?.focus({ preventScroll: true })
} finally {
switching.value = false
}
return aliases[name] || name
}
const pluginEntries = computed(() => {
const order = {
spam_block: 1,
telegram_push: 2,
wechat_push: 3
}
const labels = {
spam_block: {
label: '垃圾邮件拦截',
description: '模型识别与过滤策略'
},
telegram_push: {
label: 'Telegram 推送',
description: '邮件通知与消息推送'
},
wechat_push: {
label: '微信推送',
description: '插件页面与推送参数'
const load = async () => {
if (disposed || loading.value || !canReadExternal()) return
loading.value = true
errorMessage.value = ''
try {
const response = await http.get('/api/v1/plugin/list')
if (disposed || !canReadExternal()) return
if (response?.errorNo !== 0 || !Array.isArray(response.data)) {
throw new Error(response?.errorMsg || '其他插件列表读取失败')
}
}
return Object.entries(pluginList)
.map(([name, src]) => {
const logicalID = pluginLogicalID(name)
const metadata = labels[logicalID] || {
const builtins = new Set([
'spam_block', 'telegram_push', 'wechat_push',
'SpamBlock', 'TelegramPush', 'WechatPush', 'WeChatPushHook'
])
externalPlugins.value = [...new Set(response.data)]
.filter(name => typeof name === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(name) && !builtins.has(name))
.map(name => ({
value: `external:${name}`,
label: name,
description: '插件配置页面'
}
return {
name,
src,
logicalID,
label: metadata.label,
description: metadata.description
}
})
.sort((left, right) => (order[left.logicalID] || 99) - (order[right.logicalID] || 99))
})
http.get('/api/v1/plugin/list').then(response => {
if (Array.isArray(response.data) && response.data.length > 0) {
for (const name of response.data) {
if (typeof name !== 'string' || !/^[a-zA-Z0-9_-]{1,64}$/.test(name)) {
continue
}
pluginList[name] = `/api/v1/plugin/settings/${encodeURIComponent(name)}/index.html`
}
kind: 'external',
src: `/api/v1/plugin/settings/${encodeURIComponent(name)}/index.html`
}))
} catch (error) {
if (!disposed && canReadExternal()) errorMessage.value = error.message || '其他插件列表读取失败'
} finally {
loading.value = false
}
})
}
onMounted(load)
onBeforeUnmount(() => { disposed = true })
return {
ref,
reactive,
http,
lang,
pluginList,
pluginEntries,
pluginFrames
sectionOptions, accountOptions, externalOptions, selectedSection, activePage,
currentPage, pageHost, selectSection, beforeLeave, load, loading, errorMessage
}
}

View File

@ -1,46 +1,56 @@
<template>
<div class="settings-card">
<div class="settings-header">
<h3>{{ lang.extensions }}</h3>
<p class="settings-desc">{{ lang.extensions_desc }}</p>
</div>
<div class="plugin-container">
<div v-if="pluginEntries.length" class="plugin-list">
<section
v-for="(entry, index) in pluginEntries"
:key="entry.src"
class="plugin-section"
>
<header class="plugin-section-header">
<div class="plugin-section-title">
<span class="plugin-section-index">{{ String(index + 1).padStart(2, '0') }}</span>
<div>
<h4>{{ entry.label }}</h4>
<p>{{ entry.description }}</p>
</div>
</div>
<span class="plugin-section-status is-active">已启用</span>
</header>
<div class="plugin-section-content">
<SpamBlockSettings v-if="entry.logicalID === 'spam_block'" />
<TelegramSettings v-else-if="entry.logicalID === 'telegram_push'" />
<div v-else class="iframe-wrapper">
<iframe
:src="entry.src"
sandbox="allow-scripts"
referrerpolicy="no-referrer"
ref="pluginFrames"
:data-plugin="entry.name"
:title="entry.name"
/>
</div>
<div class="config-settings plugin-settings">
<div class="config-workspace">
<aside class="config-navigation">
<SettingsSubmenu
v-if="sectionOptions.length"
title="插件配置"
caption="运行配置"
:items="sectionOptions"
:model-value="selectedSection"
@select="selectSection"
/>
<SettingsSubmenu
v-if="accountOptions.length"
title="账号通知"
caption="当前账号"
:items="accountOptions"
:model-value="selectedSection"
@select="selectSection"
/>
<SettingsSubmenu
v-if="externalOptions.length"
title="其他插件"
:items="externalOptions"
:model-value="selectedSection"
@select="selectSection"
/>
<div v-if="errorMessage" class="plugin-list-error" role="alert">
<span>{{ errorMessage }}</span>
<el-button size="small" :loading="loading" @click="load">重试</el-button>
</div>
</aside>
<div ref="pageHost" class="config-main" :aria-label="activePage?.label" tabindex="-1">
<PluginsConfig
v-if="activePage?.kind === 'config'"
:key="selectedSection"
ref="currentPage"
:page-key="selectedSection"
/>
<TelegramSettings v-else-if="activePage?.kind === 'account'" 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">
<iframe
:key="activePage.value"
:src="activePage.src"
sandbox="allow-scripts"
referrerpolicy="no-referrer"
:title="activePage.label"
/>
</div>
</section>
</div>
<div v-else class="empty-state">
<el-empty :description="lang.no_plugins_installed" :image-size="120" />
<el-empty v-else description="当前账号没有可访问的插件配置" :image-size="100" />
</div>
</div>
</div>
@ -48,13 +58,17 @@
<script setup>
import { usePage } from './PluginSettings.js'
import SpamBlockSettings from './SpamBlockSettings/SpamBlockSettings.vue'
import SettingsSubmenu from '../SettingsSubmenu/SettingsSubmenu.vue'
import PluginsConfig from '../ConfigSettings/PluginsConfig/PluginsConfig.vue'
import TelegramSettings from './TelegramSettings/TelegramSettings.vue'
const {
lang,
pluginEntries
sectionOptions, accountOptions, externalOptions, selectedSection, activePage,
currentPage, pageHost, selectSection, beforeLeave, load, loading, errorMessage
} = usePage()
defineExpose({ beforeLeave })
</script>
<style scoped src="../ConfigSettings/ConfigSettings.css"></style>
<style scoped src="./PluginSettings.css"></style>

View File

@ -1,5 +1,5 @@
import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { http } from '@/utils/axios'
const endpoint = '/api/v1/plugin/settings/telegram_push'
@ -18,6 +18,8 @@ export function useTelegramSettings() {
const working = ref('')
const errorMessage = ref('')
const botUsername = ref('')
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}` : '')
@ -34,8 +36,21 @@ export function useTelegramSettings() {
settings[key] = key === 'chatId' ? String(data.settings[key] || '') : data.settings[key] === true
}
version.value = data.version
savedSettings.value = JSON.stringify(settings)
ready.value = true
}
const beforeLeave = async () => {
if (working.value) return false
if (!hasChanges.value) return true
try {
await ElMessageBox.confirm('当前通知设置尚未保存,确定离开吗?', '未保存的更改', {
type: 'warning', confirmButtonText: '离开', cancelButtonText: '继续编辑'
})
return true
} catch {
return false
}
}
const load = async () => {
if (working.value || disposed) return
working.value = 'load'
@ -83,8 +98,22 @@ export function useTelegramSettings() {
working.value = ''
}
}
const refresh = async () => {
if (await beforeLeave()) await load()
}
const preventUnload = event => {
if (!hasChanges.value && !working.value) return
event.preventDefault()
event.returnValue = ''
}
onMounted(load)
onBeforeUnmount(() => { disposed = true })
return { settings, version, ready, working, errorMessage, botLink, load, submit }
onMounted(() => {
if (typeof window !== 'undefined') window.addEventListener('beforeunload', preventUnload)
load()
})
onBeforeUnmount(() => {
disposed = true
if (typeof window !== 'undefined') window.removeEventListener('beforeunload', preventUnload)
})
return { settings, version, ready, working, errorMessage, botLink, load, refresh, submit, beforeLeave, hasChanges }
}

View File

@ -27,7 +27,7 @@
</el-form>
<p class="telegram-hint">启用正文或附件后,相应内容会发送到 Telegram,请确认聊天对象和隐私需求。</p>
<footer class="telegram-actions">
<el-button :disabled="!!working" @click="load">重新加载</el-button>
<el-button :disabled="!!working" @click="refresh">重新加载</el-button>
<el-button :disabled="!ready || !!working || !settings.chatId.trim()" @click="submit('test')">发送测试消息</el-button>
<el-button type="primary" :disabled="!ready || !!working" @click="submit('save')">保存设置</el-button>
</footer>
@ -37,7 +37,9 @@
<script setup>
import { useTelegramSettings } from './TelegramSettings.js'
const { settings, ready, working, errorMessage, botLink, load, submit } = useTelegramSettings()
const { settings, ready, working, errorMessage, botLink, refresh, submit, beforeLeave } = useTelegramSettings()
defineExpose({ beforeLeave })
</script>
<style scoped src="./TelegramSettings.css"></style>

View File

@ -5,7 +5,7 @@ import { compileFunction } from 'node:vm'
import { computed, reactive, ref } from './vue.mjs'
const source = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigNavigation.js', import.meta.url), 'utf8')
const components = ['BaseConfig', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance']
const components = ['BaseConfig', 'EmailConfig', 'SslConfig', 'ConfigMaintenance']
const createNavigation = compileFunction(
source.replace(/^import .*$/gm, '').replace('export function useNavigation', 'function useNavigation') + '\nreturn useNavigation()',
['computed', 'nextTick', 'ref', 'useGlobalStatusStore', ...components]
@ -18,20 +18,14 @@ function fixture(capabilities = ['config.read']) {
return { page, allowed }
}
test('配置菜单包含三个独立插件三级页面,无权限的维护入口不可打开', async () => {
test('配置管理只保留服务配置与维护入口,不再包含插件菜单', async () => {
const { page, allowed } = fixture()
assert.equal(new Set(page.sectionOptions.value.map(item => item.component)).size, 4)
assert.deepEqual(page.sectionOptions.value.map(item => item.value), ['base', 'mail', 'ssl', 'plugins'])
const children = page.sectionOptions.value.find(item => item.value === 'plugins').children
assert.deepEqual(children.map(item => item.value), ['spam_block', 'telegram_push', 'wechat_push'])
await page.selectSection('plugins')
assert.equal(page.selectedSection.value, 'spam_block')
assert.equal(page.activePage.value.label, '垃圾邮件拦截')
await page.selectSection('telegram_push')
assert.equal(page.activePage.value.label, 'Telegram 推送')
await page.selectSection('plugins')
assert.equal(page.selectedSection.value, 'telegram_push')
await page.selectSection('base')
assert.equal(new Set(page.sectionOptions.value.map(item => item.component)).size, 3)
assert.deepEqual(page.sectionOptions.value.map(item => item.value), ['base', 'mail', 'ssl'])
for (const value of ['plugins', 'spam_block', 'telegram_push', 'wechat_push']) {
await page.selectSection(value)
assert.equal(page.selectedSection.value, 'base')
}
assert.equal(page.maintenanceOptions.value.length, 0)
await page.selectSection('audit')
await page.selectSection('invalid')
@ -44,15 +38,15 @@ test('配置菜单包含三个独立插件三级页面,无权限的维护入
assert.equal(page.activePage.value, undefined)
})
test('插件三级菜单切换保留离开确认及权限检查', async () => {
test('配置页面切换保留离开确认及权限检查', async () => {
const { page, allowed } = fixture()
await page.selectSection('wechat_push')
await page.selectSection('ssl')
page.currentPage.value = { beforeLeave: async () => false }
await page.selectSection('spam_block')
assert.equal(page.selectedSection.value, 'wechat_push')
await page.selectSection('base')
assert.equal(page.selectedSection.value, 'ssl')
page.currentPage.value = { beforeLeave: async () => { allowed.delete('config.read'); return true } }
await page.selectSection('telegram_push')
assert.equal(page.selectedSection.value, 'wechat_push')
await page.selectSection('mail')
assert.equal(page.selectedSection.value, 'ssl')
assert.equal(page.activePage.value, undefined)
})

View File

@ -0,0 +1,114 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { compileFunction } from 'node:vm'
import { computed, reactive, ref } from './vue.mjs'
const root = new URL('../../frontend/src/views/components/PluginSettings/', import.meta.url)
const source = readFileSync(new URL('PluginSettings.js', root), 'utf8')
const createPage = compileFunction(
source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage()',
['computed', 'nextTick', 'onMounted', 'onBeforeUnmount', 'ref', 'http', 'useGlobalStatusStore']
)
function fixture(capabilities = ['config.read', 'plugin.read', 'plugin.write_own'], get = async () => ({ errorNo: 0, data: [] })) {
const allowed = reactive(new Set(capabilities))
const requests = []
let mount, dispose
const page = createPage(computed, async () => {}, fn => { mount = fn }, fn => { dispose = fn }, ref,
{ get: async url => { requests.push(url); return get(url) } },
() => ({ hasCapability: value => allowed.has(value) }))
return { page, allowed, requests, mount, dispose }
}
test('独立插件配置包含三个运行配置页,停用插件不依赖运行列表也能配置', async () => {
const { page, mount } = fixture()
assert.deepEqual(page.sectionOptions.value.map(item => item.value), ['spam_block', 'telegram_push', 'wechat_push'])
assert.equal(page.activePage.value.label, '垃圾邮件拦截')
await mount()
for (const key of ['telegram_push', 'wechat_push', 'spam_block']) {
await page.selectSection(key)
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'])
})
test('普通账号只保留本人通知设置,不读取或进入全局配置和管理员插件列表', async () => {
const { page, mount, requests, allowed } = fixture(['plugin.read', 'plugin.write_own'])
await mount()
assert.equal(requests.length, 0)
assert.deepEqual(page.sectionOptions.value, [])
assert.equal(page.activePage.value.kind, 'account')
await page.selectSection('spam_block')
assert.equal(page.selectedSection.value, 'telegram-user')
allowed.delete('plugin.write_own')
assert.equal(page.activePage.value, undefined)
})
test('只有配置读取权限的账号仍可进入插件配置,但不能读取无权限的插件列表', async () => {
const { page, mount, requests } = fixture(['config.read'])
await mount()
assert.equal(requests.length, 0)
assert.equal(page.sectionOptions.value.length, 3)
assert.equal(page.accountOptions.value.length, 0)
})
test('插件切换等待离开确认,取消、并发点击及权限收回不会丢弃当前页', async () => {
const { page, allowed } = fixture()
let resolve
let prompts = 0
page.currentPage.value = { beforeLeave: () => { prompts++; return new Promise(done => { resolve = done }) } }
const attempt = page.selectSection('telegram_push')
await page.selectSection('wechat_push')
assert.equal(prompts, 1)
resolve(false)
await attempt
assert.equal(page.selectedSection.value, 'spam_block')
const second = page.selectSection('wechat_push')
resolve(true)
await second
assert.equal(page.selectedSection.value, 'wechat_push')
page.currentPage.value = { beforeLeave: async () => { allowed.delete('config.read'); return true } }
await page.selectSection('telegram_push')
assert.equal(page.selectedSection.value, 'wechat_push')
assert.equal(page.activePage.value, undefined)
})
test('未知插件只使用安全名称生成隔离页面,内置插件别名不得重复出现', async () => {
const { page, mount, requests } = fixture(undefined, async () => ({ errorNo: 0, data: [
'spam_block', 'telegram_push', 'wechat_push', 'SpamBlock', 'TelegramPush', 'WechatPush',
'WeChatPushHook', 'ExtraPlugin', 'ExtraPlugin', '../bad', '<script>', null
] }))
await mount()
assert.deepEqual(requests, ['/api/v1/plugin/list'])
assert.deepEqual(page.externalOptions.value.map(item => item.value), ['external:ExtraPlugin'])
await page.selectSection('external:ExtraPlugin')
assert.equal(page.activePage.value.src, '/api/v1/plugin/settings/ExtraPlugin/index.html')
const template = readFileSync(new URL('PluginSettings.vue', root), 'utf8')
assert.match(template, /sandbox="allow-scripts"/)
assert.doesNotMatch(template, /allow-same-origin|v-for=".*pluginEntries/)
assert.match(template, /:key="selectedSection"[\s\S]*:page-key="selectedSection"/)
assert.match(template, /defineExpose\(\{ beforeLeave \}\)/)
})
test('插件列表故障不隐藏三个运行配置页,支持重试并丢弃卸载后的响应', async () => {
let fail = true
const { page, mount } = fixture(undefined, async () => {
if (fail) throw new Error('读取失败')
return { errorNo: 0, data: [] }
})
await mount()
assert.equal(page.errorMessage.value, '读取失败')
assert.equal(page.sectionOptions.value.length, 3)
fail = false
await page.load()
assert.equal(page.errorMessage.value, '')
let resolve
const late = fixture(undefined, () => new Promise(done => { resolve = done }))
const loading = late.mount()
late.dispose()
resolve({ errorNo: 0, data: ['ExtraPlugin'] })
await loading
assert.deepEqual(late.page.externalOptions.value, [])
})

View File

@ -49,17 +49,21 @@ test('一级菜单保留能力过滤和当前分类标题联动', () => {
hasCapability: value => capabilities.has(value)
})
const page = createPage(computed, ref, () => {}, () => {}, { innerWidth: 1440 }, () => store,
{ security: '安全' }, () => ({}), ...names.map(name => ({ name })))
{ security: '安全', plugin_settings: '插件配置' }, () => ({}), ...names.map(name => ({ name })))
assert.deepEqual(page.settingsMenu.value.map(item => item.key), ['security', 'third-party', 'group', 'rule'])
assert.equal(page.settingsMenu.value.find(item => item.key === 'third-party').label, '邮件客户端')
assert.equal(page.activeSettings.value.label, '安全')
capabilities.add('config.read')
store.settingsActiveTab = 'config'
assert.equal(page.activeSettings.value.label, '配置管理')
assert.equal(page.settingsMenu.value.length, 5)
assert.equal(page.settingsMenu.value.length, 6)
assert.equal(page.settingsMenu.value.find(item => item.key === 'plugins').label, '插件配置')
assert.ok(page.settingsMenu.value.every(item => item.icon && item.hint))
capabilities.delete('config.read')
assert.equal(page.activeSettings.value, undefined)
assert.equal(page.settingsMenu.value.some(item => item.key === 'plugins'), false)
capabilities.add('plugin.read')
assert.equal(page.settingsMenu.value.some(item => item.key === 'plugins'), true)
})
test('三级菜单只发送当前分组的可见子项,选中状态仍由父页面确认', () => {
@ -79,7 +83,7 @@ test('三级菜单只发送当前分组的可见子项,选中状态仍由父
})
test('菜单模板可编译并保留按钮、当前项语义及原有选区处理器', () => {
for (const name of ['HomeHeader', 'SettingsSubmenu', 'SecuritySettings', 'ConfigSettings']) {
for (const name of ['HomeHeader', 'SettingsSubmenu', 'SecuritySettings', 'ConfigSettings', 'PluginSettings']) {
const source = read(`${name}/${name}.vue`)
const { descriptor, errors } = parse(source)
assert.deepEqual(errors, [])
@ -88,7 +92,7 @@ test('菜单模板可编译并保留按钮、当前项语义及原有选区处
const submenu = read('SettingsSubmenu/SettingsSubmenu.vue')
assert.match(submenu, /<button[\s\S]*type="button"/)
assert.match(submenu, /:aria-current="modelValue === item.value \? 'page' : undefined"/)
for (const name of ['SecuritySettings', 'ConfigSettings']) {
for (const name of ['SecuritySettings', 'ConfigSettings', 'PluginSettings']) {
assert.match(read(`${name}/${name}.vue`), /<SettingsSubmenu[\s\S]*@select="selectSection"/)
}
assert.match(read('HomeHeader/HomeHeader.vue'), /globalStatus.settingsDrawerVisible && globalStatus.settingsActiveTab === item.key/)
@ -108,8 +112,7 @@ test('一级标签样式不会穿透到插件内部,抽屉样式可命中传
test('两级菜单分别提供选中、非选中悬停和键盘焦点样式', () => {
for (const path of [
'HomeHeader/HomeHeader.css',
'SettingsSubmenu/SettingsSubmenu.css',
'PluginSettings/PluginSettings.css'
'SettingsSubmenu/SettingsSubmenu.css'
]) {
const source = read(path)
assert.match(source, /:hover:not\(/)

View File

@ -8,7 +8,7 @@ import { computed, reactive, ref } from './vue.mjs'
const root = new URL('../../frontend/src/views/components/PluginSettings/', import.meta.url)
const source = readFileSync(new URL('TelegramSettings/TelegramSettings.js', root), 'utf8')
const create = compileFunction(source.replace(/^import .*$/gm, '').replace('export function ', 'function ') + '\nreturn useTelegramSettings()',
['computed', 'onMounted', 'onBeforeUnmount', 'reactive', 'ref', 'ElMessage', 'http'])
['computed', 'onMounted', 'onBeforeUnmount', 'reactive', 'ref', 'ElMessage', 'http', 'ElMessageBox'])
test('Telegram 原生设置保留版本、限制用户字段,不依赖插件 iframe', async () => {
const requests = []
@ -66,7 +66,7 @@ test('Telegram 模板独立编译,其他插件继续保持 sandbox 隔离', ()
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc')
const parent = readFileSync(new URL('PluginSettings.vue', root), 'utf8')
assert.match(parent, /TelegramSettings v-else-if="entry.logicalID === 'telegram_push'"/)
assert.match(parent, /TelegramSettings v-else-if="activePage\?\.kind === 'account'"/)
assert.match(parent, /sandbox="allow-scripts"/)
assert.doesNotMatch(parent, /allow-same-origin/)
const { descriptor, errors } = parse(readFileSync(new URL('TelegramSettings/TelegramSettings.vue', root), 'utf8'))
@ -75,3 +75,32 @@ test('Telegram 模板独立编译,其他插件继续保持 sandbox 隔离', ()
assert.deepEqual(compileStyle({ source: readFileSync(new URL('TelegramSettings/TelegramSettings.css', root), 'utf8'), id: 'telegram-settings', scoped: true }).errors, [])
assert.doesNotMatch(source, /window\.parent|parent\.document|postMessage|localStorage/)
})
test('Telegram 通知编辑在切页和重新加载前确认,取消保留输入,保存后解除保护', async () => {
let cancel = true
let reads = 0
let prompts = 0
const page = create(computed, () => {}, () => {}, reactive, ref, { success() {} }, {
get: async url => {
reads++
return { errorNo: 0, data: url.endsWith('/bot') ? {} : { version: 1, settings: { chatId: '123' } } }
},
put: async (url, body) => ({ errorNo: 0, data: { version: 2, settings: body.settings } })
}, { confirm: async () => { prompts++; if (cancel) throw new Error('取消') } })
await page.load()
assert.equal(await page.beforeLeave(), true)
page.settings.chatId = '456'
assert.equal(await page.beforeLeave(), false)
await page.refresh()
assert.equal(reads, 2)
assert.equal(page.settings.chatId, '456')
cancel = false
assert.equal(await page.beforeLeave(), true)
await page.submit('save')
assert.equal(page.hasChanges.value, false)
const count = prompts
assert.equal(await page.beforeLeave(), true)
assert.equal(prompts, count)
page.working.value = 'save'
assert.equal(await page.beforeLeave(), false)
})