优化开发启动权限并减小前端首屏体积
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-06 23:26:55 +08:00
parent 6cb68866a4
commit 882171915c
10 changed files with 108 additions and 32 deletions

View File

@ -1,4 +1,7 @@
param([ValidateSet('backend', 'frontend')][string]$Service = 'backend')
param(
[ValidateSet('backend', 'frontend')][string]$Service = 'backend',
[switch]$SkipBuild
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
@ -9,8 +12,11 @@ $previousRoot = $env:SMAIL_ROOT
try {
if ($Service -eq 'backend') {
$binary = Join-Path $root 'out\backend\smail-dev.exe'
if (-not (Test-Path -LiteralPath $binary)) { throw '请先运行 deploy/build.ps1 编译后端' }
if (Get-NetTCPConnection -State Listen -LocalPort 443 -ErrorAction SilentlyContinue) { throw '443 端口已占用,请先确认并停止旧后端' }
$running = Get-CimInstance Win32_Process | Where-Object {
$_.ExecutablePath -and $_.ExecutablePath -eq $binary
}
if ($running) { throw '开发后端仍在运行,请先核对并停止旧进程,避免编译或启动旧版本' }
# 拒绝沿用旧运行目录,避免迁移遗漏时静默创建另一份账号库。
$legacyConfig = Join-Path $root 'backend\config'
foreach ($name in @('config.json', 'config.dev.json', 'credential.key', 'smail.db', 'smail_temp.db')) {
@ -18,9 +24,17 @@ try {
throw 'backend/config 中仍有运行文件,请先停止后端并完成根目录 config 迁移'
}
}
# 默认先编译当前工作区,不再把历史 smail-dev.exe 当成最新后端。
# 不编译前端,保留 Vite 热更新;缺少首次嵌入资源时要求显式完整构建。
if (-not $SkipBuild) {
& (Join-Path $PSScriptRoot 'build.ps1') -SkipFrontend
}
if (-not (Test-Path -LiteralPath $binary -PathType Leaf)) { throw '请先运行 deploy/build.ps1 编译后端' }
# SMAIL_ROOT 是 config 的父目录,不能指向 config 本身或编译输出目录。
$env:SMAIL_ROOT = $root
$arguments = @('--dev-disable-admin-reset')
Write-Output '开发启动:沿用 config/config.json 和现有数据库;明确停用管理员恢复通道,不请求权限提升。'
if ($SkipBuild) { Write-Warning '已跳过编译,请自行确认二进制与当前源码一致。' }
# 插件从 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)) {
@ -36,13 +50,15 @@ try {
$arguments = @(('"{0}"' -f $vite), '--host', '127.0.0.1', '--port', '15173', '--strictPort')
$workingDirectory = Join-Path $root 'frontend'
}
$stdout = Join-Path $logs "$Service-$stamp.stdout.log"
$stderr = Join-Path $logs "$Service-$stamp.stderr.log"
$process = Start-Process -FilePath $binary -ArgumentList $arguments -WorkingDirectory $workingDirectory -WindowStyle Hidden -PassThru `
-RedirectStandardOutput (Join-Path $logs "$Service-$stamp.stdout.log") `
-RedirectStandardError (Join-Path $logs "$Service-$stamp.stderr.log")
-RedirectStandardOutput $stdout -RedirectStandardError $stderr
Start-Sleep -Seconds 3
$process.Refresh()
if ($process.HasExited) { throw "服务启动失败,请检查 test-log/runtime 中的 $Service 日志" }
if ($process.HasExited) { throw "服务启动失败;标准输出:$stdout;错误日志:$stderr" }
Write-Output "$Service 已启动,进程编号:$($process.Id);日志目录:test-log/runtime"
Write-Output "运行文件:$binary;标准输出:$stdout;错误日志:$stderr"
} finally {
$env:SMAIL_ROOT = $previousRoot
}

View File

@ -20,6 +20,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File deploy/start-dev.ps1 -Servic
powershell -NoProfile -ExecutionPolicy Bypass -File deploy/start-dev.ps1 -Service frontend
```
后端启动入口默认先编译当前源码及内置插件,再带明确的开发停用参数启动,不请求 UAC 提升。前端使用 Vite 热更新,不会因此切换后端配置模式。首次需完整构建以准备 Go 嵌入资源;后续只改后端时直接运行 `start-dev.ps1 -Service backend` 即可。已自行完成最新构建时可加 `-SkipBuild`,脚本会提示由调用者确认版本。不要无参数双击历史二进制:无参数属于正式模式,恢复通道权限检查仍会生效。
此参数只停用本机超级管理员密码重置通道,不改变配置文件选择,仍读取 `config/config.json`。账号、数据库、HTTPS、Cookie 和 CSRF 策略均沿用原配置。开发日志会明确提示重置通道已停用。
2026-09-05 配置管理修复后,当前 `config/config.json` 已迁移为分区根配置。必须先执行上面的编译命令,再运行新二进制;不要直接运行迁移前保留的旧程序。备份和本次验收边界见《配置管理接入修复验收》。后续修复期间已成功普通权限停止旧后端并启动新后端,下方早期停止受阻记录仅反映当时状态。
@ -42,6 +44,7 @@ go run . dev
- 移除 `--dev-disable-admin-reset`,不要以末尾 `dev` 启动。
- Windows 保持受保护命名管道及管理员客户端校验;Linux 保持受保护本机套接字及对端身份校验。
- 安装需要的管理员权限不等于业务必须以管理员运行。Linux 服务使用 `smail` 账户和最小端口绑定能力;Windows 使用专用服务身份。正式服务身份对管道的可用性需要在真实安装环境单独验收,不能把普通开发参数作为生产修复。
- 通道创建或运行失败仍会让正式后端启动失败或退出,不静默降级。
- 没有新增 HTTP、局域网或公网重置接口。
- 启动参数属于部署主机权限边界,不能由网页请求切换。不要把开发启动命令复制进正式服务配置。

View File

@ -4,8 +4,7 @@ import App from './App.vue'
import {router} from './router'
import {useGlobalStatusStore} from './stores/useGlobalStatusStore'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './styles/element-services.css'
import './assets/main.css'
const isReservedResetPath = (pathname) => {
@ -41,5 +40,4 @@ router.onError((error) => {
})
app.use(router)
app.use(ElementPlus)
app.mount('#app')

View File

@ -0,0 +1,8 @@
/* 模板组件和指令由解析器按需加载;显式调用的服务需要独立补齐样式。 */
@import 'element-plus/theme-chalk/base.css';
@import 'element-plus/theme-chalk/el-message.css';
@import 'element-plus/theme-chalk/el-message-box.css';
@import 'element-plus/theme-chalk/el-notification.css';
@import 'element-plus/theme-chalk/el-overlay.css';
@import 'element-plus/theme-chalk/el-button.css';
@import 'element-plus/theme-chalk/el-input.css';

View File

@ -1,28 +1,31 @@
import {EpMenu} from "vue-icons-plus/ep";
import { Lock, Connection, Setting, FolderOpened, Filter, User, Grid } from '@element-plus/icons-vue';
import {computed, ref, onMounted, onUnmounted} from 'vue'
import SecuritySettings from '@/views/components/SecuritySettings/SecuritySettings.vue'
import ThirdPartyAuthorization from '@/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue'
import ConfigSettings from '@/views/components/ConfigSettings/ConfigSettings.vue'
import {computed, ref, onMounted, onUnmounted, defineAsyncComponent} from 'vue'
import SettingsPageStatus from '../SettingsPageStatus/SettingsPageStatus.vue'
import lang from '../../../i18n/i18n';
import GroupSettings from '../GroupSettings/GroupSettings.vue';
import RuleSettings from '../RuleSettings/RuleSettings.vue';
import UserManagement from '../UserManagement/UserManagement.vue';
import PluginSettings from '../PluginSettings/PluginSettings.vue';
import {useGlobalStatusStore} from "@/stores/useGlobalStatusStore";
import BrandLogo from '../BrandLogo/BrandLogo.vue';
// 分类首次打开时才下载页面,失败时保留可读提示,不能显示空白面板。
const lazySettings = loader => defineAsyncComponent({
loader,
loadingComponent: SettingsPageStatus,
errorComponent: SettingsPageStatus,
delay: 150,
timeout: 30000
})
const SecuritySettings = lazySettings(() => import('../SecuritySettings/SecuritySettings.vue'))
const ThirdPartyAuthorization = lazySettings(() => import('../ThirdPartyAuthorization/ThirdPartyAuthorization.vue'))
const ConfigSettings = lazySettings(() => import('../ConfigSettings/ConfigSettings.vue'))
const GroupSettings = lazySettings(() => import('../GroupSettings/GroupSettings.vue'))
const RuleSettings = lazySettings(() => import('../RuleSettings/RuleSettings.vue'))
const UserManagement = lazySettings(() => import('../UserManagement/UserManagement.vue'))
const PluginSettings = lazySettings(() => import('../PluginSettings/PluginSettings.vue'))
export function usePage() {
const globalStatus = useGlobalStatusStore();

View File

@ -0,0 +1,9 @@
.settings-page-status {
padding: 24px;
color: var(--pm-text-secondary);
text-align: center;
}
.settings-page-status[role='alert'] {
color: var(--el-color-danger);
}

View File

@ -0,0 +1,11 @@
<template>
<div class="settings-page-status" :role="error ? 'alert' : 'status'">
{{ error ? '设置页面加载失败,请刷新后重试。' : '正在加载设置页面…' }}
</div>
</template>
<script setup>
defineProps({ error: { type: Error, default: null } })
</script>
<style scoped src="./SettingsPageStatus.css"></style>

View File

@ -74,13 +74,27 @@ export default defineConfig(({ mode }) => {
build: {
outDir: fileURLToPath(new URL('../out/frontend', import.meta.url)),
emptyOutDir: false,
manifest: true,
rollupOptions: {
output: {
manualChunks: {
'vue-core': ['vue', 'vue-router', 'pinia'],
'element-plus': ['element-plus'],
'editor': ['@wangeditor/editor', '@wangeditor/editor-for-vue'],
'icons': ['@element-plus/icons-vue', 'vue-icons-plus']
manualChunks(id) {
const modulePath = id.replaceAll('\\', '/')
if (!modulePath.includes('/node_modules/')) return
if (/\/node_modules\/(?:@vue\/|vue\/|vue-router\/|pinia\/)/.test(modulePath)) {
return 'vue-core'
}
// 编辑器仅在写信页面加载;按包拆分其依赖,避免单个异步块超过浏览器缓存阈值。
const packagePath = modulePath.split('/node_modules/')[1]
const packageParts = packagePath.split('/')
const packageName = packageParts[0].startsWith('@')
? `${packageParts[0]}-${packageParts[1]}`
: packageParts[0]
if (packageName.startsWith('@wangeditor-')) {
return `editor-${packageName.slice('@wangeditor-'.length)}`
}
if (['@uppy-core', '@uppy-xhr-upload', 'slate', 'snabbdom'].includes(packageName)) {
return `editor-${packageName.replace(/^@uppy-/, '')}`
}
}
}
}

View File

@ -28,6 +28,18 @@ test('前端构建必须自动同步 Go 嵌入资源', async () => {
assert.match(await read('plugins/pmail_telegram_push/vite.config.ts'), /\.\.\/\.\.\/out\/plugins\/telegram-ui/)
})
test('前端入口按需加载 Element Plus,并延迟设置页和写信编辑器', async () => {
const main = await read('frontend/src/main.js')
const vite = await read('frontend/vite.config.js')
const header = await read('frontend/src/views/components/HomeHeader/HomeHeader.js')
assert.doesNotMatch(main, /app\.use\(ElementPlus\)/)
assert.doesNotMatch(main, /element-plus\/dist\/index\.css/)
assert.match(main, /element-services\.css/)
assert.match(vite, /manualChunks\(id\)/)
assert.match(header, /defineAsyncComponent/)
assert.match(header, /import\('\.\.\/ConfigSettings\/ConfigSettings\.vue'\)/)
})
test('开发启动必须固定运行配置根目录并集中保存日志', async () => {
const content = await read('deploy/start-dev.ps1')
assert.match(content, /\$env:SMAIL_ROOT = \$root\r?\n/)
@ -38,6 +50,9 @@ test('开发启动必须固定运行配置根目录并集中保存日志', async
assert.match(content, /out\\backend\\smail-dev\.exe/)
assert.match(content, /test-log\\runtime/)
assert.match(content, /-WindowStyle Hidden/)
assert.match(content, /\[switch\]\$SkipBuild/)
assert.match(content, /--dev-disable-admin-reset/)
assert.match(content, /默认先编译当前工作区/)
assert.ok(content.charCodeAt(0) === 0xfeff, 'Windows PowerShell 5 脚本应有 UTF-8 BOM')
})

View File

@ -36,13 +36,12 @@ test('一级菜单保留能力过滤和当前分类标题联动', () => {
const source = read('HomeHeader/HomeHeader.js')
const names = [
'EpMenu', 'Lock', 'Connection', 'Setting', 'FolderOpened', 'Filter', 'User', 'Grid',
'SecuritySettings', 'ThirdPartyAuthorization', 'ConfigSettings', 'GroupSettings',
'RuleSettings', 'UserManagement', 'PluginSettings', 'BrandLogo'
'SettingsPageStatus', 'BrandLogo'
]
const createPage = compileFunction(
source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage')
.replace(/export \{[\s\S]*?\}\s*$/, '') + '\nreturn usePage()',
['computed', 'ref', 'onMounted', 'onUnmounted', 'window', 'useGlobalStatusStore', 'lang', ...names]
['computed', 'ref', 'onMounted', 'onUnmounted', 'window', 'useGlobalStatusStore', 'lang', 'defineAsyncComponent', ...names]
)
const capabilities = reactive(new Set(['password.change_own', 'user.read']))
const store = reactive({
@ -50,7 +49,7 @@ test('一级菜单保留能力过滤和当前分类标题联动', () => {
hasCapability: value => capabilities.has(value)
})
const page = createPage(computed, ref, () => {}, () => {}, { innerWidth: 1440 }, () => store,
{ security: '安全' }, ...names.map(name => ({ name })))
{ security: '安全' }, () => ({}), ...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, '安全')