This commit is contained in:
parent
82f740d128
commit
6a17af13ed
@ -8,7 +8,7 @@ const lang = {
|
||||
"newUser": "新增用户",
|
||||
"editUser": "编辑用户",
|
||||
"user_management": "用户管理",
|
||||
"plugin_settings": "插件设置",
|
||||
"plugin_settings": "插件配置",
|
||||
"lang": "zhCn",
|
||||
"submit": "提交",
|
||||
"compose": "发件",
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
|
||||
114
test/frontend/plugin-navigation.test.mjs
Normal file
114
test/frontend/plugin-navigation.test.mjs
Normal 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, [])
|
||||
})
|
||||
@ -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\(/)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user