From 882171915c258087d1df676067be9c1465c5289b Mon Sep 17 00:00:00 2001 From: smail Date: Sun, 6 Sep 2026 23:26:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=BC=80=E5=8F=91=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E6=9D=83=E9=99=90=E5=B9=B6=E5=87=8F=E5=B0=8F=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E9=A6=96=E5=B1=8F=E4=BD=93=E7=A7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/start-dev.ps1 | 26 ++++++++++++--- docs/开发启动权限说明.md | 3 ++ frontend/src/main.js | 4 +-- frontend/src/styles/element-services.css | 8 +++++ .../views/components/HomeHeader/HomeHeader.js | 33 ++++++++++--------- .../SettingsPageStatus/SettingsPageStatus.css | 9 +++++ .../SettingsPageStatus/SettingsPageStatus.vue | 11 +++++++ frontend/vite.config.js | 24 +++++++++++--- test/frontend/output-paths.test.mjs | 15 +++++++++ test/frontend/settings-menu.test.mjs | 7 ++-- 10 files changed, 108 insertions(+), 32 deletions(-) create mode 100644 frontend/src/styles/element-services.css create mode 100644 frontend/src/views/components/SettingsPageStatus/SettingsPageStatus.css create mode 100644 frontend/src/views/components/SettingsPageStatus/SettingsPageStatus.vue diff --git a/deploy/start-dev.ps1 b/deploy/start-dev.ps1 index 1698768..03fc552 100644 --- a/deploy/start-dev.ps1 +++ b/deploy/start-dev.ps1 @@ -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 } diff --git a/docs/开发启动权限说明.md b/docs/开发启动权限说明.md index d3e87b1..962e429 100644 --- a/docs/开发启动权限说明.md +++ b/docs/开发启动权限说明.md @@ -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、局域网或公网重置接口。 - 启动参数属于部署主机权限边界,不能由网页请求切换。不要把开发启动命令复制进正式服务配置。 diff --git a/frontend/src/main.js b/frontend/src/main.js index 3d509df..70dba9a 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -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') diff --git a/frontend/src/styles/element-services.css b/frontend/src/styles/element-services.css new file mode 100644 index 0000000..12d2cc9 --- /dev/null +++ b/frontend/src/styles/element-services.css @@ -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'; diff --git a/frontend/src/views/components/HomeHeader/HomeHeader.js b/frontend/src/views/components/HomeHeader/HomeHeader.js index 1449184..b460959 100644 --- a/frontend/src/views/components/HomeHeader/HomeHeader.js +++ b/frontend/src/views/components/HomeHeader/HomeHeader.js @@ -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(); diff --git a/frontend/src/views/components/SettingsPageStatus/SettingsPageStatus.css b/frontend/src/views/components/SettingsPageStatus/SettingsPageStatus.css new file mode 100644 index 0000000..222eadf --- /dev/null +++ b/frontend/src/views/components/SettingsPageStatus/SettingsPageStatus.css @@ -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); +} diff --git a/frontend/src/views/components/SettingsPageStatus/SettingsPageStatus.vue b/frontend/src/views/components/SettingsPageStatus/SettingsPageStatus.vue new file mode 100644 index 0000000..174c980 --- /dev/null +++ b/frontend/src/views/components/SettingsPageStatus/SettingsPageStatus.vue @@ -0,0 +1,11 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js index b6eb9cf..fbdd0f8 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -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-/, '')}` + } } } } diff --git a/test/frontend/output-paths.test.mjs b/test/frontend/output-paths.test.mjs index fd54219..c2edef8 100644 --- a/test/frontend/output-paths.test.mjs +++ b/test/frontend/output-paths.test.mjs @@ -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') }) diff --git a/test/frontend/settings-menu.test.mjs b/test/frontend/settings-menu.test.mjs index 2e53d17..2c3cc84 100644 --- a/test/frontend/settings-menu.test.mjs +++ b/test/frontend/settings-menu.test.mjs @@ -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, '安全')