This commit is contained in:
parent
7d0e84a381
commit
4ea3a39e41
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
@ -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 }}",
|
||||
|
||||
38
Makefile
38
Makefile
@ -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
75
deploy/build-plugins.mjs
Normal 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
|
||||
})
|
||||
}
|
||||
@ -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
44
deploy/package.mjs
Normal 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
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
|
||||
50
docs/插件构建与配置.md
Normal file
50
docs/插件构建与配置.md
Normal 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 生产模式现有的插件隔离限制仍然保留,不会因打包而绕过。
|
||||
|
||||
构建和打包不清理旧文件。旧版平铺产物会保留,但不会被新的按平台打包流程选入。
|
||||
@ -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 })
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"/)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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, [])
|
||||
})
|
||||
}
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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`)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user