diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..7f0be91 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,525 @@ +# YMhut Box 中文项目说明 + +YMhut Box 是一个以 C#、.NET 和 WinUI 3 为核心的 Windows 原生工具箱项目。当前仓库同时保留了新版 WinUI 桌面端、核心工具逻辑、测试项目、辅助进程、统一管理服务、旧版 Electron 项目以及更新/反馈相关服务代码。 + +当前版本信息来自 `version.json`: + +- 版本号:`2.0.7` +- 构建号:`05` +- 发布通道:`stable` + +## 项目定位 + +本项目用于构建 YMhut Box 桌面工具箱,主要目标是把原有 Web/Electron 工具箱迁移到原生 Windows 桌面体验,并配套提供更新、反馈、资源管理和服务端管理能力。 + +核心能力包括: + +- WinUI 3 桌面壳、页面、窗口和托盘体验。 +- C#/.NET 核心工具目录、工具执行、设置、日志、反馈、下载和更新逻辑。 +- WebView2 内嵌页面,用于工具页、结果页、启动动画和 3D/可视化内容。 +- 独立 Worker、插件宿主和下载宿主进程。 +- MSTest 单元测试。 +- MSIX 和传统 EXE 安装包构建流程。 +- Go 版本统一管理服务,兼容旧版更新 JSON、下载目录和反馈接口。 +- 旧版 Electron 项目保留,便于历史对照和迁移。 + +## 技术栈 + +桌面端: + +- C# / .NET 10 +- WinUI 3 +- Windows App SDK +- WebView2 +- Microsoft.Extensions.DependencyInjection +- SQLite +- MSTest + +服务端: + +- Go +- SQLite,部分路径支持 MySQL 配置 +- Vue 前端管理台和门户页 +- Rust WASM 辅助模块 + +旧版客户端: + +- Electron +- Node.js +- HTML/CSS/JavaScript + +## 仓库结构 + +```text +. +|-- src/ +| |-- box-winUI/ # WinUI 3 桌面应用 +| |-- YMhut.Box.Core/ # 核心业务逻辑、工具系统、设置、日志、反馈、更新 +| |-- YMhut.Box.Tests/ # MSTest 测试项目 +| |-- YMhut.Box.Worker/ # 工具执行 Worker 进程 +| |-- YMhut.Box.PluginHost/ # 插件宿主进程 +| `-- YMhut.Box.DownloadHost/ # 下载宿主进程 +|-- assets/ # 图标、字体、图片、本地数据、太阳系纹理源素材 +|-- server/ +| |-- unified-management/ # 统一管理服务,整合更新和反馈能力 +| |-- update/ # 旧更新服务 Go 实现 +| `-- feedback-mailer/ # 旧反馈工单服务 +|-- box-old/ # 旧版 Electron 工具箱 +|-- installer/ # Inno Setup 安装脚本 +|-- scripts/ # 构建脚本 +|-- docs/ # 架构、贡献、迁移、插件文档 +|-- update-notice/ # 更新公告 JSON +|-- 新增工具文档/ # 新增工具接口/需求说明 +|-- build.bat # Windows 构建入口 +|-- YMhut.Box.Native.sln # Visual Studio 解决方案 +|-- Directory.Build.props # 统一版本注入 +|-- NuGet.Config # NuGet 配置 +|-- version.json # 项目版本源 +`-- README.md # 英文简要说明 +``` + +## 解决方案项目 + +`YMhut.Box.Native.sln` 包含以下项目: + +- `YMhut.Box.Core`:核心库,目标框架 `net10.0`。 +- `YMhut.Box.WinUI`:桌面主程序,目标框架 `net10.0-windows10.0.19041.0`。 +- `YMhut.Box.Tests`:测试项目,目标框架 `net10.0`。 +- `YMhut.Box.Worker`:独立工具执行进程。 +- `YMhut.Box.PluginHost`:插件宿主进程。 +- `YMhut.Box.DownloadHost`:下载宿主进程。 + +## 桌面端说明 + +主程序目录是 `src/box-winUI/`。 + +主要内容: + +- `App.xaml`、`App.xaml.cs`:应用入口和生命周期。 +- `MainWindow.xaml`、`MainWindow.xaml.cs`:主窗口。 +- `Views/`:主页面、设置页、工具箱页、媒体页、浏览器页、日志页、插件页等。 +- `Views/Tools/`:具体工具页面、工具注册表、工具页面基类和生成工具页。 +- `Services/`:下载、更新、托盘、启动、插件、WebView2、窗口状态等服务。 +- `Controls/`:天气、指标图表、信息条等自定义控件。 +- `Assets/`:桌面端图标、启动页、工具页 Web 资源、太阳系/地球 3D 资源。 +- `Strings/zh-CN`、`Strings/en-US`:中英文资源文件。 + +Release 构建时: + +- 运行时标识为 `win-x64`。 +- 使用 MSIX 包类型。 +- 关闭 Appx 签名,由构建脚本处理本地证书和输出。 +- 会复制 Worker、PluginHost、DownloadHost 输出到主程序输出目录。 +- 会复制内置 WebView2 静态资源和工具数据。 + +## 核心库说明 + +核心库目录是 `src/YMhut.Box.Core/`。 + +主要模块: + +- `Api/`:API 端点和远程接口管理。 +- `App/`:应用路径、安装布局、运行时布局策略、开源参考资料。 +- `Data/`:本地参考数据加载。 +- `DevEnvironments/`:开发环境配置模型。 +- `Downloads/`:下载队列、下载模型、直接下载校验。 +- `Feedback/`:反馈码、反馈提交、反馈包加密、反馈记录。 +- `Logging/`:JSON、SQLite、弹性日志服务和日志展示本地化。 +- `Media/`:远程媒体目录、媒体解析、音量模型。 +- `Net/`:HTTP 服务和重定向解析。 +- `Plugins/`:内置插件、插件注册、插件状态和插件宿主协议。 +- `Settings/`:应用设置、语言、置顶工具、协议文档。 +- `SolarSystem/`:太阳系星历模型和服务。 +- `Startup/`:启动检查、安装完整性检查和启动初始化管线。 +- `System/`:硬件信息和系统指标。 +- `Tools/`:工具目录、工具执行、结果渲染、隐私清理、Worker 协议和布局计算。 +- `Updates/`:更新模型、版本比较、更新接口策略。 + +## 测试 + +测试项目在 `src/YMhut.Box.Tests/`。 + +测试覆盖方向包括: + +- 设置读写和协议接受。 +- 下载、开发环境、反馈服务。 +- 安装布局、日志、媒体解析。 +- 插件、工具目录、工具执行和 Worker。 +- 远程工具排序、结果体验、Web payload 序列化。 +- 敏感文本、启动检查、更新版本比较。 + +常用测试命令: + +```powershell +dotnet restore YMhut.Box.Native.sln --configfile NuGet.Config --ignore-failed-sources +dotnet test src\YMhut.Box.Tests\YMhut.Box.Tests.csproj -c Debug --no-restore +``` + +## 构建 + +推荐使用根目录的 `build.bat`: + +```powershell +build.bat --target=publish +build.bat --target=msix +build.bat --target=exe +build.bat --target=both +``` + +可选参数: + +```powershell +build.bat --target=both --configuration=Release --platform=x64 +build.bat --target=both --skip-tests +build.bat --target=both --skip-exe +build.bat --target=exe --skip-msix +``` + +构建脚本实际入口是: + +```powershell +scripts\build-winui.ps1 +``` + +主要输出目录: + +- `build/winui/publish/`:发布输出。 +- `build/winui/msix-stage/`:MSIX 临时目录。 +- `build/winui/logs/`:构建日志。 +- `latest/`:最新构建结果。 +- `installer_output/`:安装包、证书、更新信息。 +- `server/update/public/downloads/`:可被更新服务使用的下载包目录。 + +这些输出目录通常不提交到 Git。 + +## 发布和更新 + +构建脚本会使用以下默认地址生成下载和更新信息: + +- 下载基础地址:`https://update.ymhut.cn/downloads/` +- 更新信息基础地址:`https://update.ymhut.cn/update-info/` + +可通过环境变量覆盖: + +```powershell +$env:YMHUT_DOWNLOAD_BASE_URI="https://example.com/downloads/" +$env:YMHUT_UPDATE_BASE_URI="https://example.com/update-info/" +``` + +更新公告文件位于 `update-notice/`,用于维护不同版本的更新说明。 + +## 服务端 + +### 统一管理服务 + +目录: + +```text +server/unified-management/ +``` + +它用于整合旧 `server/update` 和 `server/feedback-mailer` 的能力。 + +主要能力: + +- Go 后端,默认 SQLite。 +- 可选 MySQL 配置。 +- 兼容旧路由:`update-info.json`、`tool-status.json`、`media-types.json`、`modules.json`、下载文件和反馈接口。 +- 提供 `/api/client/bootstrap` 客户端发现接口。 +- 管理员登录、验证码、HttpOnly session cookie、CSRF 检查。 +- 媒体源和数据源健康检查。 +- Vue 管理台、门户页和 Rust WASM 辅助模块。 + +运行: + +```powershell +cd server\unified-management +go mod tidy +go run main.go +``` + +或: + +```powershell +go run .\cmd\unified-management +``` + +测试: + +```powershell +go test ./... +``` + +默认监听地址: + +```text +:33550 +``` + +常用环境变量: + +- `YMHUT_LISTEN`:监听地址。 +- `PORT`:端口,作为监听地址的简写来源。 +- `YMHUT_BASE_URL`:公开服务地址。 +- `YMHUT_DB_PROVIDER`:`sqlite` 或 `mysql`。 +- `YMHUT_SQLITE_PATH`:SQLite 数据库路径。 +- `YMHUT_MYSQL_DSN`:MySQL DSN。 +- `YMHUT_UPDATE_PUBLIC_DIR`:旧更新服务 public 目录。 +- `YMHUT_DOWNLOADS_DIR`:下载包目录。 +- `YMHUT_SOURCE_CHECK_SECONDS`:源健康检查间隔。 + +前端目录: + +- `web/admin`:Vue 管理台。 +- `web/portal`:Vue 公共门户。 +- `web/setup`:初始化/设置页面。 +- `web/wasm`:Rust WASM 辅助模块。 + +前端构建示例: + +```powershell +cd server\unified-management\web\admin +npm install +npm run build + +cd ..\portal +npm install +npm run build +``` + +发布二进制: + +```powershell +cd server\unified-management +.\scripts\build-release.ps1 -Version 0.1.0 +``` + +输出目录为 `server/unified-management/dist-release/`,该目录不提交。 + +### 更新服务 + +目录: + +```text +server/update/ +``` + +该目录保留旧更新服务的 Go 实现,包括: + +- 下载中心页面。 +- JSON API。 +- 下载文件目录。 +- 管理后台。 +- 路由配置。 +- 用户和认证模型。 +- 静态资源和视图模板。 + +注意:`server/update/public/downloads/` 存放安装包和 MSIX 等大文件,已在 `.gitignore` 中排除。 + +### 反馈服务 + +目录: + +```text +server/feedback-mailer/ +``` + +该目录保留反馈工单服务,包括: + +- Go 后端。 +- SQLite 数据。 +- 管理前端。 +- 反馈包处理。 +- 旧客户端兼容接口。 + +注意:`config.json`、生成的可执行文件和 `storage/` 运行态目录已在 `.gitignore` 中排除。 + +## 旧版项目 + +旧版 Electron 项目保留在: + +```text +box-old/ +``` + +它包含: + +- Electron 主进程和 preload。 +- 旧版 Web UI。 +- 旧版工具实现。 +- 旧版工具状态和更新信息。 +- 旧构建脚本和安装配置。 + +该目录主要用于历史保留、迁移参考和功能对照。 + +## 资源 + +资源目录: + +```text +assets/ +``` + +主要内容: + +- `icons/`:应用图标。 +- `images/`:图片资源。 +- `fonts/`:字体资源。 +- `data/`:本地数据,如行政区划、三国杀皮肤配置、参考数据。 +- `source-textures/solar/`:太阳系纹理源文件。 + +桌面端运行时还会从 `src/box-winUI/Assets/` 加载应用内静态资源,包括: + +- 启动画面。 +- 工具页 Web 资源。 +- 工具结果页 Web 资源。 +- home globe 和太阳系可视化资源。 + +## Git 和 Gitea 推送 + +当前远端仓库地址: + +```text +http://admin_gitea@git.ymhut.cn/admin_gitea/YMhut-box-C-.git +``` + +常用提交流程: + +```powershell +git status +git add . +git commit -m "提交说明" +git push +``` + +查看远端: + +```powershell +git remote -v +``` + +如果提示需要登录,请使用 Gitea 账号登录。不要把密码写进远端 URL,也不要把密码写进文档或提交记录。 + +如果 Git 使用了错误的缓存凭据,可以清理该站点凭据后重新推送: + +```powershell +@" +protocol=http +host=git.ymhut.cn + +"@ | git credential reject + +git push +``` + +## 不应提交的内容 + +`.gitignore` 已排除常见缓存、构建产物、运行态数据和大安装包。 + +主要包括: + +- `.cache/` +- `.codex/` +- `.codex_state/` +- `.agents/` +- `.tmp/` +- `.idea/` +- `.vs/` +- `build/` +- `latest/` +- `installer_output/` +- `Release/` +- `node_modules/` +- `dist/` +- `bin/` +- `obj/` +- `src/box-winUI/AppPackages/` +- `server/update/build/` +- `server/update/public/downloads/` +- `server/unified-management/dist-release/` +- `server/unified-management/storage/` +- `server/feedback-mailer/storage/` +- `server/feedback-mailer/config.json` + +如果执行 `git status --ignored` 看到这些路径前面是 `!!`,表示它们已被忽略,这是正常现象。 + +## 常见状态说明 + +`git status` 显示: + +```text +Your branch is up to date with 'origin/main'. +``` + +表示本地分支和远端分支同步。 + +显示: + +```text +Changes not staged for commit +``` + +表示有文件已修改,但还没有执行 `git add`。 + +显示: + +```text +LF will be replaced by CRLF +``` + +这是 Windows 换行提示,不是错误。它表示 Git 可能在工作区把 LF 换行转换成 CRLF。 + +显示: + +```text +Everything up-to-date +``` + +表示没有新的本地提交需要推送。 + +## 本地开发建议 + +推荐流程: + +```powershell +git pull +dotnet restore YMhut.Box.Native.sln --configfile NuGet.Config --ignore-failed-sources +dotnet build src\box-winUI\YMhut.Box.WinUI.csproj -c Debug -p:Platform=x64 --no-restore +dotnet test src\YMhut.Box.Tests\YMhut.Box.Tests.csproj -c Debug --no-restore +``` + +服务端开发: + +```powershell +cd server\unified-management +go test ./... +go run main.go +``` + +前端管理台开发: + +```powershell +cd server\unified-management\web\admin +npm install +npm run dev +``` + +门户页开发: + +```powershell +cd server\unified-management\web\portal +npm install +npm run dev +``` + +## 维护注意事项 + +- 修改版本号时优先更新 `version.json`,构建时会通过 `Directory.Build.props` 注入程序集版本。 +- 提交前先看 `git status`,确认没有缓存、构建产物和本地数据库混入。 +- 发布安装包、MSIX、下载包建议走构建脚本输出目录,不直接手动放入源码提交。 +- 服务端默认账号、数据库路径、生产密钥和密码不要写入仓库。 +- 大文件如果确实需要进入仓库,建议分批提交和推送,避免 HTTP 413。 + +## 许可证 + +仓库包含 `LICENSE`,当前为 GNU General Public License v3.0 文本。 diff --git a/server/unified-management/internal/db/store.go b/server/unified-management/internal/db/store.go index a6cbd08..d87f58b 100644 --- a/server/unified-management/internal/db/store.go +++ b/server/unified-management/internal/db/store.go @@ -855,24 +855,53 @@ func (s *Store) IsDefaultAdminPassword(ctx context.Context) (bool, error) { } func (s *Store) ChangeAdminPassword(ctx context.Context, username, current, next string) error { + _, err := s.ChangeAdminPasswordWithWarning(ctx, username, current, next) + return err +} + +func (s *Store) ChangeAdminPasswordWithWarning(ctx context.Context, username, current, next string) (string, error) { if strings.TrimSpace(next) == "" { - return errors.New("new password is required") + return "", errors.New("new password is required") } _, ok, err := s.VerifyAdminPassword(ctx, username, current) if err != nil { - return err + return "", err } if !ok { - return errors.New("current password is invalid") + return "", errors.New("current password is invalid") } - result, err := s.exec(`UPDATE admin_users SET password_hash = ?, password_changed = 1, updated_at = ? WHERE username = ?`, passwordHash(next), Now(), username) + username = firstNonEmpty(strings.TrimSpace(username), "admin") + hash := passwordHash(next) + now := Now() + if err := s.changeAdminPasswordOn(s.localDB, s.localDialect, username, hash, now, true); err != nil { + return "", err + } + conn, d := s.active() + if conn != nil && conn != s.localDB { + if err := s.changeAdminPasswordOn(conn, d, username, hash, now, false); err != nil { + s.markFailover(err) + return "远端 MySQL 同步失败,密码已持久化到本地 SQLite", nil + } + } + return "", nil +} + +func (s *Store) changeAdminPasswordOn(conn *sql.DB, d dialect, username, hash, updatedAt string, insertIfMissing bool) error { + if conn == nil { + return errors.New("database is not available") + } + result, err := conn.Exec(d.rebind(`UPDATE admin_users SET password_hash = ?, password_changed = 1, updated_at = ? WHERE username = ?`), hash, updatedAt, username) if err != nil { return err } - if rows, _ := result.RowsAffected(); rows == 0 { + if rows, _ := result.RowsAffected(); rows > 0 { + return nil + } + if !insertIfMissing { return errors.New("admin user not found") } - return nil + _, err = conn.Exec(d.rebind(`INSERT INTO admin_users (username, password_hash, password_changed, created_at, updated_at) VALUES (?, ?, 1, ?, ?)`), username, hash, updatedAt, updatedAt) + return err } func (s *Store) InsertFeedback(item Feedback) error { diff --git a/server/unified-management/internal/releases/releases.go b/server/unified-management/internal/releases/releases.go index 436e0e3..5361635 100644 --- a/server/unified-management/internal/releases/releases.go +++ b/server/unified-management/internal/releases/releases.go @@ -368,10 +368,11 @@ func sha256File(path string) string { } func safePackageName(name string) (string, error) { - name = strings.TrimSpace(filepath.Base(name)) - if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) { + original := strings.TrimSpace(name) + if original == "" || original == "." || original == ".." || strings.ContainsAny(original, `/\`) { return "", errors.New("invalid filename") } + name = filepath.Base(original) lower := strings.ToLower(name) for _, suffix := range []string{".exe", ".msix", ".appinstaller", ".msi", ".zip", ".7z"} { if strings.HasSuffix(lower, suffix) { diff --git a/server/unified-management/internal/releases/releases_test.go b/server/unified-management/internal/releases/releases_test.go index dc805a0..fd2291b 100644 --- a/server/unified-management/internal/releases/releases_test.go +++ b/server/unified-management/internal/releases/releases_test.go @@ -49,8 +49,9 @@ func TestSaveUploadedPackageWritesFileAndUpdatesManifest(t *testing.T) { DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"), BaseURL: "https://update.ymhut.cn", Database: config.DatabaseConfig{ - Provider: "sqlite", - SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"), + Provider: "sqlite", + SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"), + HealthIntervalSec: 30, }, } store, err := db.Open(cfg) @@ -88,8 +89,9 @@ func TestSaveUploadedPackageRejectsUnsafeName(t *testing.T) { UpdatePublicDir: filepath.Join(dir, "data", "update", "public"), DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"), Database: config.DatabaseConfig{ - Provider: "sqlite", - SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"), + Provider: "sqlite", + SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"), + HealthIntervalSec: 30, }, } store, err := db.Open(cfg) diff --git a/server/unified-management/internal/sources/sources.go b/server/unified-management/internal/sources/sources.go index f32430c..e104025 100644 --- a/server/unified-management/internal/sources/sources.go +++ b/server/unified-management/internal/sources/sources.go @@ -29,9 +29,9 @@ type legacyMedia struct { } type legacyCategory struct { - ID string `json:"id"` - Name string `json:"name"` - Enabled *bool `json:"enabled"` + ID string `json:"id"` + Name string `json:"name"` + Enabled *bool `json:"enabled"` Subcategories []legacySubcategory `json:"subcategories"` } @@ -43,7 +43,7 @@ type legacySubcategory struct { ThumbnailURL string `json:"thumbnail_url"` RefreshInterval int `json:"refresh_interval"` SupportedFormats []string `json:"supported_formats"` - Downloadable bool `json:"downloadable"` + Downloadable bool `json:"downloadable"` } func NewService(cfg *config.Config, store *db.Store) *Service { @@ -150,19 +150,19 @@ func (s *Service) Catalog(includeHidden bool) (map[string]any, error) { var formats []string _ = json.Unmarshal([]byte(item.SupportedFormats), &formats) sub := map[string]any{ - "id": item.SourceID, - "name": item.Name, - "description": item.Description, - "api_url": item.APIURL, - "urlTemplate": firstNonEmpty(item.URLTemplate, item.APIURL), - "thumbnail_url": item.ThumbnailURL, - "method": item.Method, - "proxy_mode": item.ProxyMode, - "proxyMode": item.ProxyMode, - "refresh_interval": item.CheckIntervalSec, - "cacheSeconds": item.CacheSeconds, - "supported_formats": formats, - "downloadable": true, + "id": item.SourceID, + "name": item.Name, + "description": item.Description, + "api_url": item.APIURL, + "urlTemplate": firstNonEmpty(item.URLTemplate, item.APIURL), + "thumbnail_url": item.ThumbnailURL, + "method": item.Method, + "proxy_mode": item.ProxyMode, + "proxyMode": item.ProxyMode, + "refresh_interval": item.CheckIntervalSec, + "cacheSeconds": item.CacheSeconds, + "supported_formats": formats, + "downloadable": true, "health": map[string]any{ "status": item.LastStatus, "latency_ms": item.LastLatencyMS, diff --git a/server/unified-management/internal/sources/sources_test.go b/server/unified-management/internal/sources/sources_test.go index b761ab1..119c001 100644 --- a/server/unified-management/internal/sources/sources_test.go +++ b/server/unified-management/internal/sources/sources_test.go @@ -68,8 +68,9 @@ func testStore(t *testing.T) (*config.Config, *db.Store) { DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"), BaseURL: "https://update.ymhut.cn", Database: config.DatabaseConfig{ - Provider: "sqlite", - SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"), + Provider: "sqlite", + SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"), + HealthIntervalSec: 30, }, } store, err := db.Open(cfg) diff --git a/server/unified-management/internal/synclegacy/synclegacy.go b/server/unified-management/internal/synclegacy/synclegacy.go index f6964bf..7f9a60e 100644 --- a/server/unified-management/internal/synclegacy/synclegacy.go +++ b/server/unified-management/internal/synclegacy/synclegacy.go @@ -294,7 +294,7 @@ func (s *Service) importOldWebhooks(oldDB *sql.DB, result *Result) { if err := rows.Scan(&id, &name, &event, &status, &attempts, &response, &message, &payload, &createdAt, &finishedAt); err != nil { continue } - _ = s.store.InsertAudit(db.AuditLog{Actor: "legacy", Type: "webhook." + status, Target: name, Message: "旧反馈 Webhook 记录:" + strings.TrimSpace(event+" "+message), CreatedAt: firstNonEmpty(createdAt, finishedAt, db.Now())}) + _ = s.store.InsertAudit(db.AuditLog{Actor: "legacy", Type: "webhook." + status, Target: name, Message: "旧反馈 Webhook 记录:" + strings.TrimSpace(event+" "+message), CreatedAt: firstNonEmpty(createdAt, finishedAt, db.Now())}) result.Stats["importedRows"]++ } } diff --git a/server/unified-management/internal/web/router.go b/server/unified-management/internal/web/router.go index 2baf041..2f766ed 100644 --- a/server/unified-management/internal/web/router.go +++ b/server/unified-management/internal/web/router.go @@ -195,12 +195,17 @@ func (r *router) handleChangePassword(w http.ResponseWriter, req *http.Request) writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err) return } - if err := r.store.ChangeAdminPassword(req.Context(), "admin", body.CurrentPassword, body.NewPassword); err != nil { + warning, err := r.store.ChangeAdminPasswordWithWarning(req.Context(), "admin", body.CurrentPassword, body.NewPassword) + if err != nil { writeError(w, http.StatusBadRequest, "PASSWORD_CHANGE_FAILED", err) return } _ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "auth.password_changed", Target: "admin", Message: "后台密码已修改", IP: req.RemoteAddr, UserAgent: req.UserAgent()}) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + payload := map[string]any{"ok": true, "isDefaultPassword": false} + if warning != "" { + payload["warning"] = warning + } + writeJSON(w, http.StatusOK, payload) } func (r *router) handleClientBootstrap(w http.ResponseWriter, req *http.Request) { diff --git a/server/unified-management/web/admin/src/App.vue b/server/unified-management/web/admin/src/App.vue index b592a53..af05169 100644 --- a/server/unified-management/web/admin/src/App.vue +++ b/server/unified-management/web/admin/src/App.vue @@ -30,6 +30,7 @@ import SettingsView from "./views/SettingsView.vue"; import SourcesView from "./views/SourcesView.vue"; type LegacyName = "update-info" | "media-types"; +type ToastState = { message: string; type: "success" | "warn" | "error" }; type Captcha = { captchaId: string; @@ -54,9 +55,10 @@ const route = useRoute(); const router = useRouter(); const currentPath = computed(() => normalizeAdminPath(route.path)); const loading = ref(false); -const toast = ref(""); +const toast = ref(null); const autoRefreshPaused = ref(false); let refreshTimer: number | undefined; +let toastTimer: number | undefined; const captcha = ref(null); const authBootstrap = ref(null); @@ -99,11 +101,20 @@ const sourceDraft = reactive({ clientVisible: true, supportedFormats: "[\"json\"]", }); -const legacyDrafts = reactive>({ - "update-info": { raw: "", note: "", preview: null }, - "media-types": { raw: "", note: "", preview: null }, +const legacyDrafts = reactive>({ + "update-info": { raw: "", note: "", preview: null, tab: "form", form: {} }, + "media-types": { raw: "", note: "", preview: null, tab: "form", form: { categories: [] } }, }); const noticeDraft = reactive({ version: "", raw: "", note: "", preview: null as any }); +const uploadDraft = reactive({ + file: null as File | null, + version: "", + platform: "windows", + arch: "x64", + channel: "stable", + notes: "", + updateManifest: true, +}); const routes: RouteItem[] = [ { path: "/admin/dashboard", label: "仪表盘", description: "服务状态、接口心跳与运营指标", icon: LayoutDashboard }, @@ -151,7 +162,7 @@ const clientCalls = computed(() => dashboard.value?.clientCalls || []); const releasePackages = computed(() => releases.value?.packages || []); const sourceCategories = computed(() => sources.value?.categories || []); const visibleEndpointCount = computed(() => endpoints.value.filter((item) => item.enabled && item.clientVisible).length); -const healthyEndpointCount = computed(() => endpoints.value.filter((item) => endpointStatus(item) === "ok").length); +const healthyEndpointCount = computed(() => endpoints.value.filter((item) => ["ok", "redirected"].includes(endpointStatus(item))).length); const latestNotice = computed(() => releaseNotices.value[0] || null); const activeLegacyLabel = computed(() => activeLegacyName.value === "media-types" ? "media-types.json" : "update-info.json"); @@ -176,19 +187,25 @@ const heartbeatOption = computed(() => ({ ], })); -const healthOption = computed(() => ({ - tooltip: { trigger: "item" }, - legend: { bottom: 0 }, - series: [ - { - name: "接口健康", - type: "pie", - radius: ["48%", "72%"], - data: objectEntries(sourceHealth.value), - color: ["#16a34a", "#f59e0b", "#dc2626", "#64748b"], - }, - ], -})); +const healthOption = computed(() => { + const data = healthStatusOrder.map((item) => ({ + name: item.label, + value: Number(sourceHealth.value?.[item.key] || 0), + itemStyle: { color: item.color }, + })).filter((item) => item.value > 0); + return { + tooltip: { trigger: "item" }, + legend: { bottom: 0 }, + series: [ + { + name: "接口健康", + type: "pie", + radius: ["48%", "72%"], + data: data.length ? data : [{ name: "暂无数据", value: 1, itemStyle: { color: "#cbd5e1" } }], + }, + ], + }; +}); const feedbackOption = computed(() => ({ tooltip: { trigger: "axis" }, @@ -200,7 +217,7 @@ const feedbackOption = computed(() => ({ const availabilityOption = computed(() => { const total = Number(kpis.value.sourceTotal || 0); - const ok = Number(sourceHealth.value.ok || 0); + const ok = Number(sourceHealth.value.ok || 0) + Number(sourceHealth.value.redirected || 0); const value = total ? Math.round((ok / total) * 100) : 0; return { series: [ @@ -217,10 +234,21 @@ const availabilityOption = computed(() => { }; }); +const healthStatusOrder = [ + { key: "ok", label: "正常", color: "#16a34a" }, + { key: "redirected", label: "重定向健康", color: "#f59e0b" }, + { key: "degraded", label: "降级", color: "#d97706" }, + { key: "error", label: "错误", color: "#dc2626" }, + { key: "unknown", label: "未知", color: "#94a3b8" }, +]; + const viewContext = computed(() => ({ activeLegacyLabel: activeLegacyLabel.value, activeLegacyName: activeLegacyName.value, addFeedbackComment, + addMediaCategory, + addMediaSubcategory, + addUpdateMirror, auditLogs: auditLogs.value, autoRefreshPaused: autoRefreshPaused.value, availabilityOption: availabilityOption.value, @@ -254,11 +282,13 @@ const viewContext = computed(() => ({ loadFeedbacks, navigate, noticeDraft, + onPackageSelected, openFeedback, openNotice, passwordForm, pretty, previewLegacySync, + removeItem, quickActions, releaseNotices: releaseNotices.value, releasePackages: releasePackages.value, @@ -278,6 +308,11 @@ const viewContext = computed(() => ({ syncDatabase, testDatabase, toggleAutoRefresh, + updateLegacyRawFromForm, + uploadDraft, + uploadPackage, + auditMessage, + auditTypeLabel, validateLegacy, validateNotice, visibleEndpointCount: visibleEndpointCount.value, @@ -285,7 +320,7 @@ const viewContext = computed(() => ({ async function api(target: string, init: RequestInit = {}): Promise { const headers = new Headers(init.headers); - if (!headers.has("Content-Type") && init.body) headers.set("Content-Type", "application/json"); + if (!headers.has("Content-Type") && init.body && !(init.body instanceof FormData)) headers.set("Content-Type", "application/json"); if (csrf.value) headers.set("X-CSRF-Token", csrf.value); const res = await fetch(target, { ...init, headers, credentials: "include" }); const data = await res.json().catch(() => ({})); @@ -311,11 +346,12 @@ function toggleAutoRefresh() { autoRefreshPaused.value = !autoRefreshPaused.value; } -function setToast(message: string) { - toast.value = message; - window.setTimeout(() => { - if (toast.value === message) toast.value = ""; - }, 4200); +function setToast(message: string, type: ToastState["type"] = "success") { + toast.value = { message, type }; + if (toastTimer) window.clearTimeout(toastTimer); + toastTimer = window.setTimeout(() => { + if (toast.value?.message === message) toast.value = null; + }, 2500); } async function guarded(task: () => Promise) { @@ -324,7 +360,7 @@ async function guarded(task: () => Promise) { await task(); } catch (error) { const message = error instanceof Error ? error.message : String(error); - toast.value = message; + setToast(message, "error"); if (message.includes("Login required") || message.includes("UNAUTHORIZED")) { navigate("/admin/login"); } @@ -492,6 +528,38 @@ async function loadLegacy(name: LegacyName) { legacyDocuments[name] = data.document; legacyDrafts[name].raw = data.document.raw || ""; legacyDrafts[name].preview = data.document.parsed || null; + legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {}); +} + +function onPackageSelected(event: Event) { + const input = event.target as HTMLInputElement; + uploadDraft.file = input.files?.[0] || null; + if (uploadDraft.file && !uploadDraft.version) { + const version = uploadDraft.file.name.match(/\d+\.\d+\.\d+(?:\.\d+)?/)?.[0]; + if (version) uploadDraft.version = version; + } +} + +async function uploadPackage() { + if (!uploadDraft.file) { + setToast("请选择要上传的发布包", "warn"); + return; + } + await guarded(async () => { + const form = new FormData(); + form.append("file", uploadDraft.file as File); + form.append("version", uploadDraft.version); + form.append("platform", uploadDraft.platform); + form.append("arch", uploadDraft.arch); + form.append("channel", uploadDraft.channel); + form.append("notes", uploadDraft.notes); + form.append("updateManifest", String(uploadDraft.updateManifest)); + await api("/api/admin/releases/packages", { method: "POST", body: form, headers: {} }); + uploadDraft.file = null; + uploadDraft.notes = ""; + setToast("发布包已上传并放入下载目录"); + await loadReleases(); + }); } async function validateLegacy(name: LegacyName) { @@ -506,6 +574,7 @@ async function validateLegacy(name: LegacyName) { async function saveLegacy(name: LegacyName) { await guarded(async () => { + if (legacyDrafts[name].tab === "form") updateLegacyRawFromForm(name); const data = await api<{ document: any }>(`/api/admin/legacy/${name}`, { method: "PUT", body: JSON.stringify({ raw: legacyDrafts[name].raw, note: legacyDrafts[name].note }), @@ -513,6 +582,7 @@ async function saveLegacy(name: LegacyName) { legacyDocuments[name] = data.document; legacyDrafts[name].raw = data.document.raw; legacyDrafts[name].preview = data.document.parsed; + legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {}); legacyDrafts[name].note = ""; setToast("兼容 JSON 已保存并发布到旧路径"); }); @@ -527,10 +597,110 @@ async function restoreLegacy(name: LegacyName, revisionId: number) { legacyDocuments[name] = data.document; legacyDrafts[name].raw = data.document.raw; legacyDrafts[name].preview = data.document.parsed; + legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {}); setToast("兼容 JSON 已恢复"); }); } +function makeLegacyForm(name: LegacyName, parsed: any) { + if (name === "media-types") { + return { + layout_version: parsed.layout_version || "1.0.0", + last_updated: parsed.last_updated || "", + ui_config: JSON.stringify(parsed.ui_config || {}, null, 2), + categories: clone(parsed.categories || []).map((cat: any) => ({ + id: cat.id || "", + name: cat.name || "", + enabled: cat.enabled !== false, + subcategories: clone(cat.subcategories || []).map((sub: any) => ({ + id: sub.id || "", + name: sub.name || "", + description: sub.description || "", + api_url: sub.api_url || "", + thumbnail_url: sub.thumbnail_url || "", + refresh_interval: Number(sub.refresh_interval || 300), + supported_formats: Array.isArray(sub.supported_formats) ? sub.supported_formats.join(", ") : "", + downloadable: sub.downloadable !== false, + })), + })), + }; + } + return { + app_version: parsed.app_version || parsed.version || "", + title: parsed.title || "", + message: parsed.message || "", + message_md: parsed.message_md || "", + download_url: parsed.download_url || "", + release_notes: parsed.release_notes || "", + release_notes_md: parsed.release_notes_md || "", + update_notes: JSON.stringify(parsed.update_notes || {}, null, 2), + last_update_notes: JSON.stringify(parsed.last_update_notes || {}, null, 2), + package_sha256: parsed.package_sha256 || "", + package_size: parsed.package_size || "", + updated_at: parsed.updated_at || parsed.last_updated || "", + }; +} + +function updateLegacyRawFromForm(name: LegacyName) { + const current = parseJSONSafe(legacyDrafts[name].raw, legacyDrafts[name].preview || {}); + const form = legacyDrafts[name].form || {}; + if (name === "media-types") { + current.layout_version = form.layout_version || "1.0.0"; + current.last_updated = form.last_updated || new Date().toISOString(); + current.ui_config = parseJSONSafe(form.ui_config, current.ui_config || {}); + current.categories = (form.categories || []).map((cat: any) => ({ + ...(findByID(current.categories, cat.id) || {}), + id: cat.id, + name: cat.name, + enabled: cat.enabled !== false, + subcategories: (cat.subcategories || []).map((sub: any) => ({ + ...(findByID((findByID(current.categories, cat.id) || {}).subcategories, sub.id) || {}), + id: sub.id, + name: sub.name, + description: sub.description, + api_url: sub.api_url, + thumbnail_url: sub.thumbnail_url, + refresh_interval: Number(sub.refresh_interval || 300), + supported_formats: splitList(sub.supported_formats), + downloadable: sub.downloadable !== false, + })), + })); + } else { + for (const key of ["app_version", "title", "message", "message_md", "download_url", "release_notes", "release_notes_md", "package_sha256", "updated_at"]) { + if (form[key] !== undefined) current[key] = form[key]; + } + if (form.package_size !== "") current.package_size = Number(form.package_size || 0); + current.update_notes = parseJSONSafe(form.update_notes, current.update_notes || {}); + current.last_update_notes = parseJSONSafe(form.last_update_notes, current.last_update_notes || {}); + } + legacyDrafts[name].raw = JSON.stringify(current, null, 2) + "\n"; + legacyDrafts[name].preview = current; +} + +function addUpdateMirror() { + const doc = parseJSONSafe(legacyDrafts["update-info"].raw, legacyDrafts["update-info"].preview || {}); + const mirrors = Array.isArray(doc.download_mirrors) ? doc.download_mirrors : []; + mirrors.push({ id: `mirror-${mirrors.length + 1}`, name: "备用镜像", url: "", type: "direct", enabled: true }); + doc.download_mirrors = mirrors; + legacyDrafts["update-info"].raw = JSON.stringify(doc, null, 2) + "\n"; + legacyDrafts["update-info"].preview = doc; +} + +function addMediaCategory(name: LegacyName) { + const form = legacyDrafts[name].form; + if (!Array.isArray(form.categories)) form.categories = []; + form.categories.push({ id: `category-${form.categories.length + 1}`, name: "新分类", enabled: true, subcategories: [] }); +} + +function addMediaSubcategory(category: any) { + if (!Array.isArray(category.subcategories)) category.subcategories = []; + category.subcategories.push({ id: `source-${category.subcategories.length + 1}`, name: "新接口", api_url: "", refresh_interval: 300, supported_formats: "json", downloadable: true }); +} + +function removeItem(list: any[], index: number) { + list.splice(index, 1); +} + async function loadSources() { const data = await api<{ catalog: any }>("/api/admin/sources"); sources.value = data.catalog || { categories: [] }; @@ -639,7 +809,7 @@ function endpointStatus(item: any) { function statusTone(status: string) { const value = String(status || "").toLowerCase(); if (["ok", "online", "new", "sqlite", "mysql", "sent", "ready"].includes(value)) return "good"; - if (["degraded", "pending", "processing", "queued", "missing"].includes(value)) return "warn"; + if (["redirected", "degraded", "pending", "processing", "queued", "missing"].includes(value)) return "warn"; if (["error", "failed", "closed", "offline"].includes(value)) return "bad"; return "neutral"; } @@ -651,6 +821,7 @@ function objectEntries(value: Record) { function labelStatus(value: string) { const labels: Record = { ok: "正常", + redirected: "重定向健康", error: "错误", degraded: "降级", unknown: "未知", @@ -662,6 +833,35 @@ function labelStatus(value: string) { return labels[value] || value || "未知"; } +function auditTypeLabel(value: string) { + const labels: Record = { + "auth.login": "管理员登录", + "auth.password_changed": "修改后台密码", + "feedback.created": "客户端提交反馈", + "feedback.updated": "更新反馈工单", + "legacy_json.saved": "保存兼容 JSON", + "legacy_json.restored": "恢复兼容 JSON", + "legacy_json.seeded": "导入 JSON 基板", + "release_notice.saved": "保存版本日志", + "release.package_uploaded": "上传发布包", + "legacy.sync": "旧项目同步", + }; + return labels[value] || value || "未知操作"; +} + +function auditMessage(item: any) { + const message = String(item?.message || ""); + const legacy: Record = { + "Admin login": "管理员登录", + "Admin password changed": "后台密码已修改", + "Legacy JSON saved": "兼容 JSON 已保存", + "Legacy JSON restored": "兼容 JSON 已恢复", + "Release notice saved": "版本日志已保存", + "Feedback updated": "反馈工单已更新", + }; + return legacy[message] || message || auditTypeLabel(item?.type); +} + function formatBytes(value: number) { if (!Number.isFinite(value) || value <= 0) return "0 B"; const units = ["B", "KB", "MB", "GB"]; @@ -683,6 +883,30 @@ function pretty(value: any) { return JSON.stringify(value || {}, null, 2); } +function clone(value: T): T { + return JSON.parse(JSON.stringify(value ?? null)); +} + +function parseJSONSafe(value: string, fallback: any) { + try { + return JSON.parse(value || "{}"); + } catch { + return clone(fallback || {}); + } +} + +function findByID(list: any, id: string) { + if (!Array.isArray(list)) return null; + return list.find((item) => item?.id === id) || null; +} + +function splitList(value: string) { + return String(value || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + onMounted(() => { void load(); refreshTimer = window.setInterval(() => { @@ -696,6 +920,10 @@ onUnmounted(() => { diff --git a/server/unified-management/web/portal/src/pages/OverviewPage.vue b/server/unified-management/web/portal/src/pages/OverviewPage.vue index e0f212e..fd636a9 100644 --- a/server/unified-management/web/portal/src/pages/OverviewPage.vue +++ b/server/unified-management/web/portal/src/pages/OverviewPage.vue @@ -1,5 +1,5 @@