拆分插件三级配置菜单并统一三插件构建打包
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-06 16:22:38 +08:00
parent 7d0e84a381
commit 4ea3a39e41
20 changed files with 486 additions and 66 deletions

View File

@ -63,13 +63,17 @@ jobs:
BUILD_FLAGS="-s -w -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.Version=${{ env.VERSION }}' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.GoVersion=$(go version)' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.GitHash=$(git show -s --format=%H)' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)'"
go build -ldflags "$BUILD_FLAGS" -o ../out/release/${{ env.FILENAME }} ./cmd/smail
go build -ldflags "$BUILD_FLAGS" -o ../out/release/${{ env.ADMINFILENAME }} ./cmd/smail-admin
go build -ldflags "-s -w" -o ../out/release/plugins/${{ env.WCFILENAME }} hooks/wechat_push/wechat_push.go
go build -ldflags "-s -w" -o ../out/release/plugins/${{ env.SPAMBLOCKFILENAME }} hooks/spam_block/spam_block.go
cd ../plugins/pmail_telegram_push
npm ci
npm run build-only
go build -ldflags "-s -w" -o ../../out/release/plugins/${{ env.TELEGRAMFILENAME }} .
cd ../../out/release
npm run type-check
cd ../..
node deploy/build-plugins.mjs --target=${{ matrix.goos }}_${{ matrix.goarch }}
for name in spam_block pmail_telegram_push wechat_push; do
extension=""
if [ "${{ matrix.goos }}" = "windows" ]; then extension=".exe"; fi
cp "out/plugins/${{ matrix.goos }}_${{ matrix.goarch }}/$name$extension" "out/release/plugins/$name$extension"
done
cd out/release
cat > release.json <<EOF
{
"version": "${{ env.VERSION }}",

View File

@ -6,7 +6,7 @@ BUILD_LDFLAGS = -s -w -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.Version=$(S
.PHONY: build clean clean_codex_test_artifacts build_fe build_server wechat_push spam_block telegram_push plugin package test test_mysql test_postgres
build: build_fe build_server spam_block wechat_push telegram_push package
build: package
clean:
@echo "请先审查 test-log 中的清理清单,不自动递归删除文件。"
@ -17,7 +17,7 @@ clean_codex_test_artifacts:
build_fe:
cd frontend && yarn && yarn build
build_server:
build_server: build_fe
mkdir -p out/backend
cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail_linux_amd64 ./cmd/smail
cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail_linux_arm64 ./cmd/smail
@ -33,41 +33,19 @@ build_server:
cd backend && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail-admin_mac_arm64 ./cmd/smail-admin
wechat_push:
mkdir -p out/plugins
cd backend/hooks/wechat_push && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o ../../../out/plugins/wechat_push_linux_amd64 wechat_push.go
cd backend/hooks/wechat_push && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w" -o ../../../out/plugins/wechat_push_linux_arm64 wechat_push.go
cd backend/hooks/wechat_push && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o ../../../out/plugins/wechat_push_windows_amd64.exe wechat_push.go
cd backend/hooks/wechat_push && CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "-s -w" -o ../../../out/plugins/wechat_push_windows_arm64.exe wechat_push.go
cd backend/hooks/wechat_push && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o ../../../out/plugins/wechat_push_mac_amd64 wechat_push.go
cd backend/hooks/wechat_push && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o ../../../out/plugins/wechat_push_mac_arm64 wechat_push.go
node deploy/build-plugins.mjs --all --plugin=wechat_push
spam_block:
mkdir -p out/plugins
cd backend/hooks/spam_block && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o ../../../out/plugins/spam_block_linux_amd64 spam_block.go
cd backend/hooks/spam_block && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w" -o ../../../out/plugins/spam_block_linux_arm64 spam_block.go
cd backend/hooks/spam_block && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o ../../../out/plugins/spam_block_windows_amd64.exe spam_block.go
cd backend/hooks/spam_block && CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "-s -w" -o ../../../out/plugins/spam_block_windows_arm64.exe spam_block.go
cd backend/hooks/spam_block && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o ../../../out/plugins/spam_block_mac_amd64 spam_block.go
cd backend/hooks/spam_block && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o ../../../out/plugins/spam_block_mac_arm64 spam_block.go
node deploy/build-plugins.mjs --all --plugin=spam_block
telegram_push:
mkdir -p out/plugins
cd plugins/pmail_telegram_push && npm ci && npm run build
cd plugins/pmail_telegram_push && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o ../../out/plugins/pmail_telegram_push_linux_amd64 .
cd plugins/pmail_telegram_push && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w" -o ../../out/plugins/pmail_telegram_push_linux_arm64 .
cd plugins/pmail_telegram_push && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o ../../out/plugins/pmail_telegram_push_windows_amd64.exe .
cd plugins/pmail_telegram_push && CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "-s -w" -o ../../out/plugins/pmail_telegram_push_windows_arm64.exe .
cd plugins/pmail_telegram_push && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o ../../out/plugins/pmail_telegram_push_mac_amd64 .
cd plugins/pmail_telegram_push && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o ../../out/plugins/pmail_telegram_push_mac_arm64 .
cd plugins/pmail_telegram_push && npm ci && npm run type-check
node deploy/build-plugins.mjs --all --plugin=telegram_push
plugin: spam_block wechat_push telegram_push
package:
mkdir -p out/package/config out/package/plugins
cp out/backend/smail* out/package/
cp out/plugins/*_* out/package/plugins/
cp README.md out/package/
printf '{\n "version": "%s",\n "gitHash": "%s",\n "protocolVersion": 1,\n "createdAt": "%s"\n}\n' "$(SMAIL_VERSION)" "$(GIT_HASH)" "$(BUILD_TIME)" > out/package/release.json
package: build_server plugin
SMAIL_VERSION="$(SMAIL_VERSION)" GIT_HASH="$(GIT_HASH)" node deploy/package.mjs
test:
node test/run.mjs all

75
deploy/build-plugins.mjs Normal file
View File

@ -0,0 +1,75 @@
import { spawn } from 'node:child_process'
import { access, mkdir } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
export const root = fileURLToPath(new URL('../', import.meta.url))
export const plugins = [
{ id: 'spam_block', executable: 'spam_block', directory: 'backend', source: './hooks/spam_block' },
{ id: 'telegram_push', executable: 'pmail_telegram_push', directory: 'plugins/pmail_telegram_push', source: '.' },
{ id: 'wechat_push', executable: 'wechat_push', directory: 'backend', source: './hooks/wechat_push' }
]
export const targets = ['linux_amd64', 'linux_arm64', 'windows_amd64', 'windows_arm64', 'darwin_amd64', 'darwin_arm64']
export function hostTarget() {
const goos = { win32: 'windows', linux: 'linux', darwin: 'darwin' }[process.platform]
const goarch = { x64: 'amd64', arm64: 'arm64' }[process.arch]
return `${goos}_${goarch}`
}
export function buildPlan({ target = hostTarget(), plugin, development = false } = {}) {
if (!targets.includes(target)) throw new Error(`不支持的编译平台:${target}`)
if (plugin && !plugins.some(item => item.id === plugin)) throw new Error(`未知插件:${plugin}`)
if (development && target !== hostTarget()) throw new Error('开发运行目录只能使用本机平台插件')
const [goos, goarch] = target.split('_')
return plugins.filter(item => !plugin || item.id === plugin).map(item => ({
...item,
goos,
goarch,
output: path.join(root, 'out', 'plugins', ...(development ? [] : [target]), `${item.executable}${goos === 'windows' ? '.exe' : ''}`)
}))
}
function run(command, args, cwd, env = process.env) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { cwd, env, stdio: 'inherit', windowsHide: true })
child.once('error', reject)
child.once('exit', code => code === 0 ? resolve() : reject(new Error(`构建失败:${command},退出码 ${code}`)))
})
}
export async function buildPlugins(args) {
const options = {}
let all = false
for (const arg of args) {
if (arg === '--all') all = true
else if (arg === '--dev') options.development = true
else if (arg.startsWith('--target=')) options.target = arg.slice(9)
else if (arg.startsWith('--plugin=')) options.plugin = arg.slice(9)
else throw new Error(`未知构建参数:${arg}`)
}
if (all && (options.target || options.development)) throw new Error('全平台编译不能同时指定单个平台或开发目录')
const plan = (all ? targets : [options.target || hostTarget()]).flatMap(target => buildPlan({ ...options, target }))
if (plan.some(item => item.id === 'telegram_push')) {
const directory = path.join(root, 'plugins', 'pmail_telegram_push')
const vite = path.join(directory, 'node_modules', 'vite', 'bin', 'vite.js')
try { await access(vite) } catch { throw new Error('请先在 plugins/pmail_telegram_push 执行 npm ci 安装依赖') }
// 构建并同步 Telegram 单文件界面,保证 Go 嵌入的是本次资源。
await run(process.execPath, [vite, 'build'], directory)
await run(process.execPath, [path.join(root, 'deploy', 'prepare-frontend.mjs'), 'telegram'], root)
}
for (const item of plan) {
await mkdir(path.dirname(item.output), { recursive: true })
await run('go', ['build', '-ldflags', '-s -w', '-o', item.output, item.source], path.join(root, item.directory), {
...process.env, CGO_ENABLED: '0', GOOS: item.goos, GOARCH: item.goarch
})
console.log(`插件已编译:${path.relative(root, item.output)}`)
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
buildPlugins(process.argv.slice(2)).catch(error => {
console.error(error.message)
process.exitCode = 1
})
}

View File

@ -20,4 +20,6 @@ try {
& go build -o (Join-Path $output 'smail-config.exe') ./cmd/smail-config
if ($LASTEXITCODE -ne 0) { throw '配置工具构建失败' }
} finally { Pop-Location }
Write-Output '构建完成:out/frontend 和 out/backend。正式发行请使用 Makefile 的跨平台构建目标。'
& node (Join-Path $PSScriptRoot 'build-plugins.mjs') --dev
if ($LASTEXITCODE -ne 0) { throw '插件构建失败' }
Write-Output '构建完成:out/frontend、out/backend 和 out/plugins,包含三个内置插件。正式发行请使用 Makefile 的跨平台构建目标。'

44
deploy/package.mjs Normal file
View File

@ -0,0 +1,44 @@
import { access, copyFile, mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { buildPlan, root, targets } from './build-plugins.mjs'
export function packagePlan(target) {
const pluginPlan = buildPlan({ target })
const [goos, goarch] = target.split('_')
const extension = goos === 'windows' ? '.exe' : ''
const suffix = `${goos === 'darwin' ? 'mac' : goos}_${goarch}${extension}`
const directory = path.join(root, 'out', 'package', target)
return [
...['smail', 'smail-admin'].map(name => ({
source: path.join(root, 'out', 'backend', `${name}_${suffix}`),
target: path.join(directory, `${name}${extension}`)
})),
...pluginPlan.map(item => ({ source: item.output, target: path.join(directory, 'plugins', path.basename(item.output)) })),
{ source: path.join(root, 'README.md'), target: path.join(directory, 'README.md') }
]
}
export async function packageRelease(selectedTargets = targets) {
const plan = selectedTargets.flatMap(packagePlan)
// 所有输入齐备后才写发布目录,不把旧的或其他平台插件混入发布包。
for (const item of plan) await access(item.source)
for (const item of plan) {
await mkdir(path.dirname(item.target), { recursive: true })
await copyFile(item.source, item.target)
}
for (const target of selectedTargets) {
await writeFile(path.join(root, 'out', 'package', target, 'release.json'), JSON.stringify({
version: process.env.SMAIL_VERSION || 'dev', gitHash: process.env.GIT_HASH || '',
protocolVersion: 1, target, plugins: buildPlan({ target }).map(item => item.id), createdAt: new Date().toISOString()
}, null, 2) + '\n')
}
console.log(`发布包已生成:${selectedTargets.map(target => `out/package/${target}`).join('、')}`)
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
packageRelease(process.argv.length > 2 ? process.argv.slice(2) : targets).catch(error => {
console.error(`发布打包失败:${error.message}`)
process.exitCode = 1
})
}

View File

@ -21,8 +21,13 @@ try {
# SMAIL_ROOT 是 config 的父目录,不能指向 config 本身或编译输出目录。
$env:SMAIL_ROOT = $root
$arguments = @('--dev-disable-admin-reset')
# 插件工作目录保持不变;配置、数据库和密钥统一通过 SMAIL_ROOT 定位。
$workingDirectory = Join-Path $root 'backend'
# 插件从 out/plugins 加载;账号、配置和密钥仍通过 SMAIL_ROOT 定位到根目录 config。
foreach ($name in @('spam_block.exe', 'pmail_telegram_push.exe', 'wechat_push.exe')) {
if (-not (Test-Path -LiteralPath (Join-Path $root "out\plugins\$name") -PathType Leaf)) {
throw '内置插件产物不完整,请先运行 deploy/build.ps1'
}
}
$workingDirectory = Join-Path $root 'out'
} else {
if (Get-NetTCPConnection -State Listen -LocalPort 15173 -ErrorAction SilentlyContinue) { throw '15173 端口已占用,请先确认并停止旧前端' }
$binary = (Get-Command node -ErrorAction Stop).Source

View File

@ -0,0 +1,50 @@
# 插件构建与配置
## 配置入口
在“设置 → 配置管理 → 插件配置”中选择三级菜单:
- 垃圾邮件拦截
- Telegram 推送
- 微信推送
每次只显示所选插件的字段。切换页面前会提示未保存的编辑内容;三个插件仍共用后端插件配置分区,保存时保留其余插件、内部字段和权限。服务器存在候选配置时,页面会提示候选涉及哪些插件,应用时整个候选一起生效。
## Windows 开发构建
先安装 `frontend` 和 `plugins/pmail_telegram_push` 的前端依赖,然后在项目根目录运行:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File deploy/build.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File deploy/start-dev.ps1 -Service backend
```
修改后端或重新编译正在运行的插件前,先停止对应开发服务。`-SkipFrontend` 只跳过主前端,三个插件始终随开发构建编译,Telegram 的嵌入页面也会重新生成。
- 主前端:`out/frontend`
- 后端及管理工具:`out/backend`
- 三个插件:`out/plugins/spam_block.exe`、`out/plugins/pmail_telegram_push.exe`、`out/plugins/wechat_push.exe`
- 开发服务工作目录:`out`,保证相对路径 `plugins` 指向实际插件产物
- `SMAIL_ROOT` 仍固定到项目根目录,现有账号、邮件数据库和运行配置保持在根目录 `config`,不能复制一份到 `out/config`
## 跨平台构建与发布
```sh
make build
```
构建依赖顺序为:主前端 → 后端、三个插件 → 发布目录。使用并行构建时也不会在产物尚未生成前打包。
插件统一构建入口:
```sh
node deploy/build-plugins.mjs --target=linux_amd64
node deploy/build-plugins.mjs --target=windows_amd64
node deploy/build-plugins.mjs --all
```
支持 Linux、Windows、macOS 的 amd64 和 arm64。插件产物位于 `out/plugins/<平台>_<架构>`,发布目录位于 `out/package/<平台>_<架构>`。每个平台的发布目录均包含主程序、管理工具、三个插件及发布元数据;插件使用后端目录中约定的固定文件名,不需要手动去掉平台后缀。
GitHub 发布工作流复用相同的插件构建脚本。三个插件保持独立进程,随主程序一起交付,并非合并进主程序执行文件;原有启停配置和进程隔离检查不变。编译成功不代表第三方服务已配置、推送已送达或生产平台隔离验收完成。Windows 生产模式现有的插件隔离限制仍然保留,不会因打包而绕过。
构建和打包不清理旧文件。旧版平铺产物会保留,但不会被新的按平台打包流程选入。

View File

@ -198,7 +198,8 @@ const pluginDefinitions = {
}
export function getFieldGroups(section, config) {
if (section !== 'plugins') {
const pluginId = Object.hasOwn(pluginDefinitions, section) ? section : ''
if (section !== 'plugins' && !pluginId) {
return ({ base: baseGroups, smtp: smtpGroups, imap: imapGroups, pop3: pop3Groups, email: emailGroups, ssl: sslGroups }[section] || [])
.map(item => ({
...item,
@ -208,7 +209,7 @@ export function getFieldGroups(section, config) {
}))
.filter(item => item.fields.length > 0)
}
return Object.entries(config.plugins || {}).map(([id, plugin]) => {
return Object.entries(config.plugins || {}).filter(([id]) => !pluginId || id === pluginId).map(([id, plugin]) => {
const definition = pluginDefinitions[id]
const prefix = ['plugins', id]
const input = (name, label, type, extra = {}) => ({ path: [...prefix, name], label, type, ...extra })

View File

@ -1,8 +1,8 @@
<template>
<div class="config-form" :data-form-section="section">
<p v-if="!groups.length" class="form-empty">当前没有可配置的插件。安装插件后可在这里管理启用状态与运行设置。</p>
<p v-if="!groups.length" class="form-empty">当前插件配置不存在,请刷新配置或检查初始化状态。</p>
<section v-for="group in groups" :key="group.id" class="form-group" :aria-label="group.title">
<p v-if="section === 'plugins'" class="credential-status">{{ group.secretConfigured ? '凭据已配置' : '未配置凭据' }}</p>
<p v-if="group.secretConfigured !== undefined" class="credential-status">{{ group.secretConfigured ? '凭据已配置' : '未配置凭据' }}</p>
<p v-if="group.note" class="form-group-note" role="note">{{ group.note }}</p>
<div class="form-grid">
<ConfigField

View File

@ -22,7 +22,11 @@ export function useNavigation() {
{ value: 'pop3', label: 'POP3 配置', component: Pop3Config },
{ value: 'email', label: '邮箱策略', component: EmailConfig },
{ value: 'ssl', label: 'SSL 配置', component: SslConfig },
{ value: 'plugins', label: '插件配置', component: PluginsConfig }
{ value: 'plugins', label: '插件配置', children: [
{ value: 'spam_block', label: '垃圾邮件拦截', component: PluginsConfig },
{ value: 'telegram_push', label: 'Telegram 推送', component: PluginsConfig },
{ value: 'wechat_push', label: '微信推送', component: PluginsConfig }
] }
]
const tools = [
{ value: 'migration', label: '数据库迁移', capability: 'database.migrate' },
@ -35,12 +39,15 @@ export function useNavigation() {
? tools.filter(item => store.hasCapability(item.capability))
.map(item => ({ ...item, component: ConfigMaintenance }))
: [])
const activePage = computed(() => [...sectionOptions.value, ...maintenanceOptions.value]
const visiblePages = () => [...sectionOptions.value.flatMap(item => item.children || [item]), ...maintenanceOptions.value]
const activePage = computed(() => visiblePages()
.find(item => item.value === selectedSection.value))
const beforeLeave = () => currentPage.value?.beforeLeave?.() ?? true
const selectSection = async value => {
const visible = () => [...sectionOptions.value, ...maintenanceOptions.value].some(item => item.value === value)
const parent = sectionOptions.value.find(item => item.value === value && item.children)
if (parent) value = parent.children.find(item => item.value === selectedSection.value)?.value || parent.children[0].value
const visible = () => visiblePages().some(item => item.value === value)
if (switching.value || value === selectedSection.value || !visible()) return
switching.value = true
try {

View File

@ -11,7 +11,10 @@ const sections = [
{ value: 'pop3', label: 'POP3 配置', description: '管理 POP3 的 STLS 与隐式 TLS 监听;端口变更需应用配置并重启后端。' },
{ value: 'email', label: '邮箱策略', description: '自动汇总三个协议的配置端口,管理邮件与账号恢复策略。' },
{ value: 'ssl', label: 'SSL 配置', description: '管理证书与加密连接,应用后请留意待重启提示。' },
{ value: 'plugins', label: '插件配置', description: '管理插件运行配置,敏感设置仅显示配置状态。' }
{ value: 'plugins', label: '插件配置', description: '管理插件运行配置,敏感设置仅显示配置状态。' },
{ value: 'spam_block', label: '垃圾邮件拦截', description: '配置垃圾邮件检测服务、超时和拦截阈值,仅编辑当前插件。' },
{ value: 'telegram_push', label: 'Telegram 推送', description: '配置 Telegram 推送的运行参数与网络权限,个人绑定在第三方授权中管理。' },
{ value: 'wechat_push', label: '微信推送', description: '配置微信推送的应用、模板与接收用户,敏感凭据不回填。' }
]
const serviceLabels = {
@ -51,8 +54,10 @@ export function usePage(pageKey = 'base') {
degradedReasons: []
})
const selectedSection = ref(isConfigPage ? pageKey : 'base')
// 页面与存储分区分离,IMAP、POP3 复用邮箱分区的原子保存与候选保护。
const apiSection = () => ['imap', 'pop3'].includes(selectedSection.value) ? 'email' : selectedSection.value
// 独立页面复用原配置分区的原子保存与候选保护,不丢弃其他页面的字段。
const pluginPages = ['spam_block', 'telegram_push', 'wechat_push']
const apiSection = () => pluginPages.includes(selectedSection.value) ? 'plugins'
: ['imap', 'pop3'].includes(selectedSection.value) ? 'email' : selectedSection.value
const currentSection = computed(() => sections.find(item => item.value === selectedSection.value) || sections[0])
const sectionLoading = ref(false)
let sectionRequestId = 0
@ -85,6 +90,18 @@ export function usePage(pageKey = 'base') {
const pendingCandidate = ref(null)
const pendingScopeHint = computed(() => {
const pending = pendingCandidate.value
if (pending?.section === 'plugins' && apiSection() === 'plugins') {
const active = readFormConfig(loadedText.value).plugins || {}
const candidate = pending.payload?.plugins || {}
const changed = Object.keys({ ...active, ...candidate }).filter(id =>
['enabled', 'executable', 'permissions', 'settings'].some(key =>
JSON.stringify(active[id]?.[key]) !== JSON.stringify(candidate[id]?.[key]))
).map(id => sections.find(item => item.value === id)?.label || id)
const scope = pending.payload && loadedText.value
? (changed.length ? `本次候选涉及:${changed.join('、')}。` : '本次候选的插件运行配置未变化。')
: '候选内容尚未完整读取,请刷新后核对。'
return `${scope}三个插件共用一个配置分区,请在对应三级菜单核对;应用时整个候选一起生效,其他插件的原有配置会保留。`
}
if (pending?.section !== 'email' || apiSection() !== 'email') return ''
const active = readFormConfig(loadedText.value)
const candidate = pending.payload || {}
@ -508,7 +525,7 @@ export function usePage(pageKey = 'base') {
if (selectedSection.value === 'smtp') {
delete payload.relayPasswordStatus
}
if (selectedSection.value === 'plugins' && payload.plugins && typeof payload.plugins === 'object') {
if (apiSection() === 'plugins' && payload.plugins && typeof payload.plugins === 'object') {
Object.values(payload.plugins).forEach(plugin => {
if (plugin && typeof plugin === 'object') {
delete plugin.settingsConfigured

View File

@ -7,7 +7,8 @@ import { provide } from 'vue'
import { usePage } from '../ConfigSettings.js'
import ConfigEditor from '../ConfigEditor/ConfigEditor.vue'
const page = usePage('plugins')
const props = defineProps({ pageKey: { type: String, required: true } })
const page = usePage(props.pageKey)
provide('smail-config-page', page)
defineExpose({ beforeLeave: page.beforeLeave })
</script>

View File

@ -75,6 +75,54 @@
outline-offset: -2px;
}
.settings-submenu-count {
float: right;
min-width: 18px;
border-radius: 5px;
background: var(--pm-bg-primary);
text-align: center;
font-size: 11px;
}
.settings-tertiary-list {
display: grid;
gap: 4px;
margin: 6px 0 8px 12px;
padding-left: 8px;
border-left: 1px solid var(--pm-border-color);
}
.settings-tertiary-item {
min-width: 0;
min-height: 36px;
padding: 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--pm-text-secondary);
font: inherit;
font-size: 12px;
line-height: 1.5;
text-align: left;
cursor: pointer;
}
.settings-tertiary-item.active {
color: var(--submenu-accent);
background: color-mix(in srgb, var(--submenu-accent) 8%, var(--pm-bg-secondary));
font-weight: 600;
}
.settings-tertiary-item:hover:not(.active) {
color: var(--pm-text-primary);
background: var(--pm-bg-hover);
}
.settings-tertiary-item:focus-visible {
outline: 2px solid var(--submenu-accent);
outline-offset: -2px;
}
@media (prefers-color-scheme: dark) {
.settings-submenu {
--submenu-accent: var(--pm-primary-light-3);
@ -109,6 +157,14 @@
white-space: nowrap;
}
.settings-submenu-entry {
flex: 0 0 auto;
}
.settings-tertiary-list {
margin-left: 4px;
}
.settings-submenu-item.active::before {
top: auto;
bottom: 2px;

View File

@ -7,7 +7,8 @@ export const submenuProps = {
export function useSubmenu(props, emit) {
function select(value) {
if (value !== props.modelValue && props.items.some(item => item.value === value)) {
const visible = props.items.some(item => item.value === value || item.children?.some(child => child.value === value))
if (value !== props.modelValue && visible) {
emit('select', value)
}
}

View File

@ -5,17 +5,37 @@
<strong>{{ title }}</strong>
</div>
<div class="settings-submenu-list">
<button
v-for="item in items"
:key="item.value"
type="button"
class="settings-submenu-item"
:class="{ active: modelValue === item.value }"
:aria-current="modelValue === item.value ? 'page' : undefined"
@click="select(item.value)"
>
<span>{{ item.label }}</span>
</button>
<div v-for="item in items" :key="item.value" class="settings-submenu-entry">
<button
type="button"
class="settings-submenu-item"
:class="{ active: modelValue === item.value || item.children?.some(child => child.value === modelValue) }"
:aria-current="modelValue === item.value ? 'page' : undefined"
:aria-expanded="item.children ? item.children.some(child => child.value === modelValue) : undefined"
@click="select(item.value)"
>
<span>{{ item.label }}</span>
<span v-if="item.children" class="settings-submenu-count">{{ item.children.length }}</span>
</button>
<div
v-if="item.children?.some(child => child.value === modelValue)"
class="settings-tertiary-list"
role="group"
:aria-label="`${item.label}三级菜单`"
>
<button
v-for="child in item.children"
:key="child.value"
type="button"
class="settings-tertiary-item"
:class="{ active: modelValue === child.value }"
:aria-current="modelValue === child.value ? 'page' : undefined"
@click="select(child.value)"
>
{{ child.label }}
</button>
</div>
</div>
</div>
</nav>
</template>

View File

@ -45,6 +45,34 @@ test('各协议仅编辑自己的监听端口,邮箱策略没有重复端口
assert.ok(validateFormConfig('smtp', { ...config, listener: { ...config.listener, tlsPort: 587 } })['listener.tlsPort'])
})
test('三个插件页面只展示和编辑自身字段,完整保留其他插件和未知字段', () => {
const config = { meta: { generation: 9 }, plugins: {
spam_block: { enabled: false, settings: { threshold: 20 }, secretStatus: { configured: true } },
telegram_push: { enabled: false, settings: { timeout: 30, futureField: '保留值' } },
wechat_push: { enabled: true, settings: { appId: 'test-only' } },
future_plugin: { enabled: false, settings: ['原样保留'] }
} }
const text = JSON.stringify(config)
for (const id of ['spam_block', 'telegram_push', 'wechat_push']) {
const groups = getFieldGroups(id, config)
assert.equal(groups.length, 1)
assert.equal(groups[0].id, id)
assert.ok(groups[0].fields.every(field => field.path[1] === id))
const edited = readFormConfig(updateFormConfig(text, id, ['plugins', id, 'enabled'], true))
for (const other of Object.keys(config.plugins).filter(key => key !== id)) {
assert.deepEqual(edited.plugins[other], config.plugins[other])
assert.equal(updateFormConfig(text, id, ['plugins', other, 'enabled'], true), text)
}
assert.deepEqual(edited.meta, config.meta)
assert.deepEqual(edited.plugins[id].settings, config.plugins[id].settings)
}
assert.deepEqual(getFieldGroups('wechat_push', { plugins: {} }), [])
const invalidOther = structuredClone(config)
invalidOther.plugins.telegram_push.settings.timeout = 999
assert.deepEqual(validateFormConfig('spam_block', invalidOther), {})
assert.ok(validateFormConfig('telegram_push', invalidOther)['plugins.telegram_push.settings.timeout'])
})
test('文本输入的左右留白属于原生输入框,不影响数字、选择和开关控件', () => {
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
const { parse, compileStyle } = require('@vue/compiler-sfc')
@ -85,7 +113,7 @@ test('分组不显示重复标题和介绍,保留无障碍名称、凭据状
assert.doesNotMatch(template, /<h[1-6]\b|form-group-heading|form-group-description|group\.description/)
assert.doesNotMatch(template, /aria-labelledby/)
assert.match(template, /<section\b[^>]*:aria-label="group.title"/)
assert.match(template, /v-if="section === 'plugins'" class="credential-status"/)
assert.match(template, /v-if="group.secretConfigured !== undefined" class="credential-status"/)
assert.match(template, /group.secretConfigured \? '凭据已配置' : '未配置凭据'/)
assert.match(template, /v-if="group.note" class="form-group-note" role="note"/)
assert.match(template, /v-if="!groups.length" class="form-empty"/)

View File

@ -18,10 +18,20 @@ 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, 7)
assert.deepEqual(page.sectionOptions.value.map(item => item.value), ['base', 'smtp', 'imap', 'pop3', 'email', '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(page.maintenanceOptions.value.length, 0)
await page.selectSection('audit')
await page.selectSection('invalid')
@ -34,6 +44,18 @@ test('七个菜单对应七个不同页面组件,无权限的维护入口不
assert.equal(page.activePage.value, undefined)
})
test('插件三级菜单切换保留离开确认及权限检查', async () => {
const { page, allowed } = fixture()
await page.selectSection('wechat_push')
page.currentPage.value = { beforeLeave: async () => false }
await page.selectSection('spam_block')
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 } = fixture()
let resolve

View File

@ -684,3 +684,69 @@ test('共用邮箱分区的候选明确提示其他页面的改动,忽略代
const template = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue', import.meta.url), 'utf8')
assert.match(template, /:title="pendingScopeHint"/)
})
for (const key of ['spam_block', 'telegram_push', 'wechat_push']) {
test(`${key} 独立页面保留整个插件分区,验证、候选恢复和应用使用统一接口`, async () => {
let active = { meta: { generation: 1 }, plugins: {
spam_block: { enabled: false, executable: 'spam_block.exe', permissions: { network: [] }, settings: { threshold: 20 } },
telegram_push: { enabled: false, executable: 'pmail_telegram_push.exe', settings: { timeout: 30 }, secretStatus: { configured: true }, settingsConfigured: true },
wechat_push: { enabled: false, executable: 'wechat_push.exe', settings: { appId: 'test-only', future: 'preserved' } }
} }
const original = structuredClone(active)
let pending = null
const requests = []
const http = {
get: async path => {
requests.push(path)
return { errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: active.meta.generation }
: path.endsWith('/pending') ? pending : active }
},
put: async (path, body) => {
assert.equal(path, '/api/v1/config/plugins')
assert.equal(body.expectedGeneration, 1)
assert.deepEqual(body.secretChanges, {})
assert.equal(body.payload.plugins.telegram_push.secretStatus, undefined)
assert.equal(body.payload.plugins.telegram_push.settingsConfigured, undefined)
for (const id of Object.keys(original.plugins)) {
assert.deepEqual(body.payload.plugins[id].settings, original.plugins[id].settings)
assert.equal(body.payload.plugins[id].enabled, id === key)
}
pending = { generation: 2, section: 'plugins', sameSession: true, candidateId: 'plugin-test', payload: body.payload }
return { errorNo: 0, data: pending }
},
post: async (path, body) => {
requests.push(path)
if (path.endsWith('/apply')) {
assert.equal(body.generation, 2)
assert.equal(body.candidateId, 'plugin-test')
active = pending.payload
active.meta.generation = 2
pending = null
}
return { errorNo: 0, data: { valid: true } }
}
}
const { page, messages, dispose } = fixture(() => {}, key, { http })
await page.loadAll()
assert.deepEqual(requests, ['/api/v1/config/status', '/api/v1/config/plugins', '/api/v1/config/pending'])
assert.equal(page.selectedSection.value, key)
page.updateField(['plugins', key, 'enabled'], true)
await page.validateConfig()
await page.saveConfig()
assert.ok(requests.includes('/api/v1/config/plugins/validate'))
assert.match(page.pendingScopeHint.value, new RegExp(`本次候选涉及:${page.currentSection.value.label}。`))
const other = Object.keys(original.plugins).find(id => id !== key)
page.updateField(['plugins', other, 'enabled'], true)
assert.equal(page.formConfig.value.plugins[other].enabled, false)
dispose()
const restored = fixture(() => {}, other, { http }).page
await restored.loadAll()
assert.equal(restored.savedGeneration.value, 2)
assert.match(restored.pendingScopeHint.value, /三个插件共用一个配置分区/)
assert.ok(restored.pendingScopeHint.value.includes(page.currentSection.value.label))
await restored.applyConfig()
assert.ok(requests.includes('/api/v1/config/plugins/apply'))
assert.equal(restored.savedGeneration.value, 0)
assert.deepEqual(messages, [])
})
}

View File

@ -3,6 +3,8 @@ import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
import { buildPlan, hostTarget, plugins, targets } from '../../deploy/build-plugins.mjs'
import { packagePlan } from '../../deploy/package.mjs'
const root = fileURLToPath(new URL('../../', import.meta.url))
const read = relative => readFile(path.join(root, relative), 'utf8')
@ -10,7 +12,7 @@ const read = relative => readFile(path.join(root, relative), 'utf8')
test('跨平台构建输出不得越过项目 out 目录', async () => {
const content = await read('Makefile')
const commands = [...content.matchAll(/cd ([^\s]+) && [^\n]*go build [^\n]* -o ([^\s]+) /g)]
assert.equal(commands.length, 30)
assert.equal(commands.length, 12)
for (const [, directory, output] of commands) {
const relative = path.relative(path.join(root, 'out'), path.resolve(root, directory, output))
assert.ok(relative && !relative.startsWith('..') && !path.isAbsolute(relative), output)
@ -31,13 +33,38 @@ test('开发启动必须固定运行配置根目录并集中保存日志', async
assert.match(content, /\$env:SMAIL_ROOT = \$root\r?\n/)
assert.match(content, /\$legacyConfig = Join-Path \$root 'backend\\config'/)
assert.match(content, /Test-Path -LiteralPath \(Join-Path \$legacyConfig \$name\)/)
assert.match(content, /\$workingDirectory = Join-Path \$root 'backend'/)
assert.match(content, /\$workingDirectory = Join-Path \$root 'out'/)
for (const name of ['spam_block.exe', 'pmail_telegram_push.exe', 'wechat_push.exe']) assert.ok(content.includes(name))
assert.match(content, /out\\backend\\smail-dev\.exe/)
assert.match(content, /test-log\\runtime/)
assert.match(content, /-WindowStyle Hidden/)
assert.ok(content.charCodeAt(0) === 0xfeff, 'Windows PowerShell 5 脚本应有 UTF-8 BOM')
})
test('六个平台默认包含三个插件,构建和发布名称与后端目录完全匹配', async () => {
const catalog = await read('backend/plugins/catalog/catalog.go')
for (const target of targets) {
const plan = buildPlan({ target })
assert.equal(plan.length, 3)
assert.deepEqual(plan.map(item => item.id), plugins.map(item => item.id))
for (const item of plan) {
assert.equal(path.dirname(item.output), path.join(root, 'out', 'plugins', target))
assert.ok(catalog.includes(`"${path.basename(item.output)}"`))
}
const files = packagePlan(target)
assert.equal(files.length, 6)
assert.equal(files.filter(file => path.basename(path.dirname(file.target)) === 'plugins').length, 3)
assert.ok(files.every(file => file.target.startsWith(path.join(root, 'out', 'package', target))))
}
assert.ok(buildPlan({ development: true }).every(item => path.dirname(item.output) === path.join(root, 'out', 'plugins')))
assert.throws(() => buildPlan({ target: '../invalid' }))
assert.throws(() => buildPlan({ plugin: 'unknown' }))
assert.throws(() => buildPlan({ development: true, target: targets.find(target => target !== hostTarget()) }))
assert.match(await read('deploy/build.ps1'), /build-plugins\.mjs'\) --dev/)
assert.match(await read('Makefile'), /package: build_server plugin/)
assert.match(await read('.github/workflows/release.yml'), /node deploy\/build-plugins\.mjs --target=/)
})
test('安全工作流必须先构建嵌入资源再执行 Go 测试', async () => {
for (const file of ['.github/workflows/release.yml', '.github/workflows/security.yml']) {
const content = (await read(file)).split(' security:')[1]

View File

@ -62,6 +62,22 @@ test('一级菜单保留能力过滤和当前分类标题联动', () => {
assert.equal(page.activeSettings.value, undefined)
})
test('三级菜单只发送当前分组的可见子项,选中状态仍由父页面确认', () => {
const props = reactive({ modelValue: 'spam_block', items: [{ value: 'plugins', children: [{ value: 'spam_block' }, { value: 'telegram_push' }] }] })
const events = []
const { select } = useSubmenu(props, (...event) => events.push(event))
select('unknown')
select('spam_block')
select('telegram_push')
assert.deepEqual(events, [['select', 'telegram_push']])
assert.equal(props.modelValue, 'spam_block')
props.items = []
select('telegram_push')
assert.equal(events.length, 1)
assert.match(read('SettingsSubmenu/SettingsSubmenu.vue'), /settings-tertiary-list/)
assert.match(read('SettingsSubmenu/SettingsSubmenu.css'), /\.settings-tertiary-item:focus-visible/)
})
test('菜单模板可编译并保留按钮、当前项语义及原有选区处理器', () => {
for (const name of ['HomeHeader', 'SettingsSubmenu', 'SecuritySettings', 'ConfigSettings']) {
const source = read(`${name}/${name}.vue`)