From 6a17af13ed9c1578ffe3b47693d0db3b0c47cb33 Mon Sep 17 00:00:00 2001 From: smail Date: Tue, 8 Sep 2026 14:17:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B4=E5=90=88=E6=8F=92=E4=BB=B6=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=85=A5=E5=8F=A3=E5=B9=B6=E4=BF=9D=E7=95=99=E6=9D=83?= =?UTF-8?q?=E9=99=90=E4=B8=8E=E6=9C=AA=E4=BF=9D=E5=AD=98=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/i18n/i18n.js | 2 +- .../ConfigSettings/ConfigNavigation.js | 8 +- .../ConfigSettings/ConfigSettings.js | 2 +- .../views/components/HomeHeader/HomeHeader.js | 3 +- .../PluginSettings/PluginSettings.css | 110 ++------------- .../PluginSettings/PluginSettings.js | 132 +++++++++--------- .../PluginSettings/PluginSettings.vue | 100 +++++++------ .../TelegramSettings/TelegramSettings.js | 37 ++++- .../TelegramSettings/TelegramSettings.vue | 6 +- test/frontend/config-navigation.test.mjs | 34 ++--- test/frontend/plugin-navigation.test.mjs | 114 +++++++++++++++ test/frontend/settings-menu.test.mjs | 15 +- test/frontend/telegram-settings.test.mjs | 33 ++++- 13 files changed, 346 insertions(+), 250 deletions(-) create mode 100644 test/frontend/plugin-navigation.test.mjs diff --git a/frontend/src/i18n/i18n.js b/frontend/src/i18n/i18n.js index 64f47c6..550b606 100644 --- a/frontend/src/i18n/i18n.js +++ b/frontend/src/i18n/i18n.js @@ -8,7 +8,7 @@ const lang = { "newUser": "新增用户", "editUser": "编辑用户", "user_management": "用户管理", - "plugin_settings": "插件设置", + "plugin_settings": "插件配置", "lang": "zhCn", "submit": "提交", "compose": "发件", diff --git a/frontend/src/views/components/ConfigSettings/ConfigNavigation.js b/frontend/src/views/components/ConfigSettings/ConfigNavigation.js index 1f3e6dc..360ca47 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigNavigation.js +++ b/frontend/src/views/components/ConfigSettings/ConfigNavigation.js @@ -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' }, diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.js b/frontend/src/views/components/ConfigSettings/ConfigSettings.js index 0751fa5..bcb7200 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.js +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.js @@ -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) diff --git a/frontend/src/views/components/HomeHeader/HomeHeader.js b/frontend/src/views/components/HomeHeader/HomeHeader.js index b460959..2552cca 100644 --- a/frontend/src/views/components/HomeHeader/HomeHeader.js +++ b/frontend/src/views/components/HomeHeader/HomeHeader.js @@ -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() diff --git a/frontend/src/views/components/PluginSettings/PluginSettings.css b/frontend/src/views/components/PluginSettings/PluginSettings.css index 9f1d2b7..e8fb991 100644 --- a/frontend/src/views/components/PluginSettings/PluginSettings.css +++ b/frontend/src/views/components/PluginSettings/PluginSettings.css @@ -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; } diff --git a/frontend/src/views/components/PluginSettings/PluginSettings.js b/frontend/src/views/components/PluginSettings/PluginSettings.js index dc59beb..d87b545 100644 --- a/frontend/src/views/components/PluginSettings/PluginSettings.js +++ b/frontend/src/views/components/PluginSettings/PluginSettings.js @@ -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 } } diff --git a/frontend/src/views/components/PluginSettings/PluginSettings.vue b/frontend/src/views/components/PluginSettings/PluginSettings.vue index a6abdf6..574ff3e 100644 --- a/frontend/src/views/components/PluginSettings/PluginSettings.vue +++ b/frontend/src/views/components/PluginSettings/PluginSettings.vue @@ -1,46 +1,56 @@