更新了update门户站点界面和部分功能

This commit is contained in:
QWQLwToo
2026-06-26 14:30:09 +08:00
parent 57f4d94d0a
commit cd2fd435a2
20 changed files with 1128 additions and 168 deletions
+525
View File
@@ -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 文本。
+35 -6
View File
@@ -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 {
@@ -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) {
@@ -51,6 +51,7 @@ func TestSaveUploadedPackageWritesFileAndUpdatesManifest(t *testing.T) {
Database: config.DatabaseConfig{
Provider: "sqlite",
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
HealthIntervalSec: 30,
},
}
store, err := db.Open(cfg)
@@ -90,6 +91,7 @@ func TestSaveUploadedPackageRejectsUnsafeName(t *testing.T) {
Database: config.DatabaseConfig{
Provider: "sqlite",
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
HealthIntervalSec: 30,
},
}
store, err := db.Open(cfg)
@@ -70,6 +70,7 @@ func testStore(t *testing.T) (*config.Config, *db.Store) {
Database: config.DatabaseConfig{
Provider: "sqlite",
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
HealthIntervalSec: 30,
},
}
store, err := db.Open(cfg)
@@ -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) {
+246 -21
View File
@@ -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<ToastState | null>(null);
const autoRefreshPaused = ref(false);
let refreshTimer: number | undefined;
let toastTimer: number | undefined;
const captcha = ref<Captcha | null>(null);
const authBootstrap = ref<AuthBootstrap | null>(null);
@@ -99,11 +101,20 @@ const sourceDraft = reactive({
clientVisible: true,
supportedFormats: "[\"json\"]",
});
const legacyDrafts = reactive<Record<LegacyName, { raw: string; note: string; preview: any | null }>>({
"update-info": { raw: "", note: "", preview: null },
"media-types": { raw: "", note: "", preview: null },
const legacyDrafts = reactive<Record<LegacyName, { raw: string; note: string; preview: any | null; tab: "form" | "raw" | "preview" | "history"; form: any }>>({
"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,7 +187,13 @@ const heartbeatOption = computed(() => ({
],
}));
const healthOption = computed(() => ({
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: [
@@ -184,11 +201,11 @@ const healthOption = computed(() => ({
name: "接口健康",
type: "pie",
radius: ["48%", "72%"],
data: objectEntries(sourceHealth.value),
color: ["#16a34a", "#f59e0b", "#dc2626", "#64748b"],
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<T>(target: string, init: RequestInit = {}): Promise<T> {
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<void>) {
@@ -324,7 +360,7 @@ async function guarded(task: () => Promise<void>) {
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<string, number>) {
function labelStatus(value: string) {
const labels: Record<string, string> = {
ok: "正常",
redirected: "重定向健康",
error: "错误",
degraded: "降级",
unknown: "未知",
@@ -662,6 +833,35 @@ function labelStatus(value: string) {
return labels[value] || value || "未知";
}
function auditTypeLabel(value: string) {
const labels: Record<string, string> = {
"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<string, string> = {
"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<T>(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(() => {
</script>
<template>
<Teleport to="body">
<div v-if="toast" :class="['toast', toast.type]">{{ toast.message }}</div>
</Teleport>
<main v-if="currentPath === '/admin/login'" class="login-shell">
<section class="login-panel">
<div>
@@ -721,7 +949,6 @@ onUnmounted(() => {
</label>
<button class="btn primary full" type="submit">登录</button>
</form>
<p v-if="toast" class="notice">{{ toast }}</p>
</section>
</main>
@@ -760,8 +987,6 @@ onUnmounted(() => {
<button class="btn ghost" @click="load"><RefreshCw :size="16" />刷新</button>
</div>
</header>
<p v-if="toast" class="notice">{{ toast }}</p>
<DashboardView v-if="currentPath === '/admin/dashboard'" :ctx="viewContext" />
<FeedbacksView v-else-if="currentPath === '/admin/feedbacks'" :ctx="viewContext" />
<ReleasesView v-else-if="currentPath === '/admin/releases'" :ctx="viewContext" />
@@ -20,6 +20,7 @@
--bad: #b42318;
--bad-bg: #fff0ed;
--shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
--ease: cubic-bezier(.2,.8,.2,1);
}
* { box-sizing: border-box; }
@@ -57,7 +58,7 @@ h3 { margin-bottom: 8px; font-size: 15px; }
padding: 28px;
background: rgba(255, 255, 255, 0.96);
border: 1px solid var(--line);
border-radius: 8px;
border-radius: 16px;
box-shadow: var(--shadow);
}
@@ -67,7 +68,7 @@ input, textarea, select {
width: 100%;
min-height: 40px;
border: 1px solid var(--line);
border-radius: 6px;
border-radius: 10px;
background: #fff;
color: var(--ink);
padding: 8px 10px;
@@ -83,7 +84,7 @@ input:focus, textarea:focus, select:focus {
.captcha-button {
min-height: 40px;
border: 1px solid var(--line);
border-radius: 6px;
border-radius: 10px;
background: #fff;
padding: 0;
overflow: hidden;
@@ -96,7 +97,7 @@ input:focus, textarea:focus, select:focus {
.btn {
min-height: 38px;
border: 1px solid var(--line);
border-radius: 6px;
border-radius: 10px;
background: #fff;
color: var(--ink);
padding: 8px 12px;
@@ -106,9 +107,9 @@ input:focus, textarea:focus, select:focus {
justify-content: center;
gap: 8px;
font-weight: 800;
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
transition: transform 0.18s var(--ease), background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.btn:hover { border-color: var(--line-strong); background: #f9fafb; }
.btn:hover { transform: translateY(-1px); border-color: var(--line-strong); background: #f9fafb; box-shadow: 0 8px 20px rgba(15, 23, 42, 0.07); }
.btn.primary { background: var(--primary); color: #fff; border-color: var(--primary); }
.btn.primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
.btn.ghost { background: transparent; }
@@ -125,6 +126,34 @@ input:focus, textarea:focus, select:focus {
line-height: 1.55;
}
.toast {
position: fixed;
z-index: 1000;
top: 18px;
left: 50%;
min-width: min(420px, calc(100vw - 32px));
max-width: calc(100vw - 32px);
transform: translateX(-50%);
border: 1px solid var(--line);
border-radius: 999px;
padding: 12px 18px;
color: #102033;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.16);
backdrop-filter: blur(14px);
text-align: center;
font-weight: 900;
animation: toastIn 0.18s var(--ease);
}
.toast.success { border-color: #b7e4ca; background: rgba(232, 247, 239, 0.96); color: var(--good); }
.toast.warn { border-color: #f4d38c; background: rgba(255, 247, 230, 0.96); color: var(--warn); }
.toast.error { border-color: #f0b8b1; background: rgba(255, 240, 237, 0.96); color: var(--bad); }
@keyframes toastIn {
from { opacity: 0; transform: translate(-50%, -8px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
.app-shell { min-height: 100dvh; display: grid; grid-template-columns: 260px minmax(0, 1fr); }
.sidebar {
border-right: 1px solid var(--line);
@@ -139,7 +168,7 @@ input:focus, textarea:focus, select:focus {
height: 100dvh;
}
.brand { display: flex; gap: 12px; align-items: center; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
.brand-mark { width: 38px; height: 38px; border-radius: 8px; display: grid; place-items: center; background: #111827; color: #fff; }
.brand-mark { width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; background: #111827; color: #fff; }
.brand strong { display: block; }
.brand small { display: block; color: var(--muted); margin-top: 2px; }
.nav-groups { display: flex; flex-direction: column; gap: 14px; flex: 1; overflow-y: auto; }
@@ -154,7 +183,7 @@ input:focus, textarea:focus, select:focus {
}
.nav-group button, .logout {
border: 0;
border-radius: 6px;
border-radius: 10px;
background: transparent;
text-align: left;
padding: 10px;
@@ -165,7 +194,7 @@ input:focus, textarea:focus, select:focus {
font-weight: 800;
transition: background-color 0.18s ease, color 0.18s ease;
}
.nav-group button:hover, .logout:hover { background: #eef4ff; color: var(--primary-dark); }
.nav-group button:hover, .logout:hover { transform: translateX(2px); background: #eef4ff; color: var(--primary-dark); }
.nav-group button.active { background: var(--primary-soft); color: var(--primary-dark); }
.logout { color: #7f1d1d; }
@@ -179,10 +208,17 @@ input:focus, textarea:focus, select:focus {
.metric, .panel {
background: rgba(255, 255, 255, 0.98);
border: 1px solid var(--line);
border-radius: 8px;
border-radius: 14px;
padding: 16px;
box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
}
.metric, .panel, .quick-grid button, .revision-list button, .nested-card {
transition: transform 0.2s var(--ease), border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
.metric:hover, .panel:hover, .quick-grid button:hover, .revision-list button:hover, .nested-card:hover {
transform: translateY(-1px);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08);
}
.metric { min-height: 116px; display: flex; flex-direction: column; justify-content: space-between; }
.metric span, .metric small { color: var(--muted); }
.metric strong { font-size: 26px; overflow-wrap: anywhere; }
@@ -195,7 +231,7 @@ input:focus, textarea:focus, select:focus {
.quick-grid button {
min-height: 112px;
border: 1px solid var(--line);
border-radius: 8px;
border-radius: 12px;
background: #fff;
color: var(--ink);
padding: 12px;
@@ -229,7 +265,8 @@ table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { border-bottom: 1px solid var(--line); padding: 10px 8px; text-align: left; vertical-align: top; }
th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.03em; }
tr.clickable { cursor: pointer; }
tr.clickable:hover td { background: #f8fbff; }
tbody tr { transition: background-color 0.18s ease; }
tr.clickable:hover td, tbody tr:hover td { background: #f8fbff; }
tr.selected td { background: #eef4ff; }
.badge {
@@ -267,7 +304,7 @@ hr { border: 0; border-top: 1px solid var(--line); width: 100%; margin: 12px 0;
.compact-editor { min-height: 260px; }
details {
border: 1px solid var(--line);
border-radius: 6px;
border-radius: 12px;
padding: 10px;
background: #fff;
}
@@ -277,7 +314,7 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
max-height: 360px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 6px;
border-radius: 12px;
background: #0f172a;
color: #dbeafe;
padding: 12px;
@@ -289,7 +326,7 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
.revision-list { display: flex; flex-direction: column; gap: 8px; }
.revision-list button {
border: 1px solid var(--line);
border-radius: 6px;
border-radius: 12px;
background: #fff;
color: var(--ink);
text-align: left;
@@ -301,6 +338,50 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
.kv-grid span { color: var(--muted); }
.kv-grid strong { overflow-wrap: anywhere; }
.tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
border-bottom: 1px solid var(--line);
padding-bottom: 10px;
}
.tabs button {
min-height: 34px;
border: 1px solid var(--line);
border-radius: 999px;
background: #fff;
color: var(--muted);
padding: 7px 12px;
font-weight: 900;
transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease;
}
.tabs button:hover, .tabs button.active {
border-color: rgba(37, 99, 235, 0.28);
background: var(--primary-soft);
color: var(--primary-dark);
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.form-grid .wide, label.wide { grid-column: 1 / -1; }
.mini-editor { min-height: 160px; }
.nested-card {
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(248, 250, 252, 0.78);
padding: 14px;
}
.nested-card.inner {
margin-top: 12px;
background: #fff;
}
.upload-card {
background: linear-gradient(135deg, #ffffff, #f8fbff);
}
@media (max-width: 1180px) {
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.chart-grid, .split, .split.wide-split { grid-template-columns: 1fr; }
@@ -314,6 +395,7 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
.workspace { padding: 16px; }
.topbar, .section-head { align-items: stretch; flex-direction: column; }
.metric-grid, .two-col { grid-template-columns: 1fr; }
.form-grid { grid-template-columns: 1fr; }
.quick-grid { grid-template-columns: 1fr; }
.captcha-row { grid-template-columns: 1fr; }
table { min-width: 720px; }
@@ -321,5 +403,5 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition: none !important; scroll-behavior: auto !important; }
*, *::before, *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
}
@@ -9,9 +9,9 @@ defineProps<{ ctx: any }>();
<thead><tr><th>类型</th><th>目标</th><th>信息</th><th>IP</th><th>时间</th></tr></thead>
<tbody>
<tr v-for="item in ctx.auditLogs" :key="item.id">
<td>{{ item.type }}</td>
<td><span class="badge neutral">{{ ctx.auditTypeLabel(item.type) }}</span></td>
<td>{{ item.target }}</td>
<td>{{ item.message }}</td>
<td>{{ ctx.auditMessage(item) }}</td>
<td>{{ item.ip || "-" }}</td>
<td>{{ item.createdAt }}</td>
</tr>
@@ -12,7 +12,10 @@ defineProps<{ ctx: any }>();
<td class="mono">{{ item.id || item.sourceId }}</td>
<td>{{ item.category || item.categoryId }}</td>
<td>{{ item.proxyMode }}</td>
<td><span :class="['badge', ctx.statusTone(ctx.endpointStatus(item))]">{{ ctx.endpointStatus(item) }}</span></td>
<td>
<span :class="['badge', ctx.statusTone(ctx.endpointStatus(item))]">{{ ctx.labelStatus(ctx.endpointStatus(item)) }}</span>
<span v-if="ctx.endpointStatus(item) === 'redirected' || item.health?.meta?.redirected" class="badge warn">重定向接口</span>
</td>
<td>{{ item.cacheSeconds || 0 }}s</td>
<td class="hash">{{ item.urlTemplate || item.apiUrl }}</td>
<td><button class="btn ghost compact" @click="ctx.copyEndpointToSource(item)">编辑</button></td>
@@ -1,30 +1,102 @@
<script setup lang="ts">
import { CheckCircle2, Save } from "lucide-vue-next";
import { CheckCircle2, Plus, Save, Trash2 } from "lucide-vue-next";
defineProps<{ ctx: any }>();
</script>
<template>
<section class="split wide-split">
<section class="panel editor-panel">
<section class="panel page-stack">
<div class="section-head">
<div>
<h2>{{ ctx.activeLegacyLabel }}</h2>
<p class="muted">以当前兼容 JSON 为基板表单保存会合并进原 JSON未知字段保留</p>
</div>
<div class="button-row">
<button class="btn ghost" @click="ctx.validateLegacy(ctx.activeLegacyName)"><CheckCircle2 :size="16" />校验</button>
<button class="btn primary" @click="ctx.saveLegacy(ctx.activeLegacyName)"><Save :size="16" />保存发布</button>
</div>
</div>
<div class="tabs">
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'form'">可视化表单</button>
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'raw' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'raw'">Raw JSON</button>
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'preview' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'preview'">预览</button>
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'history' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'history'">历史版本</button>
</div>
<section v-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' && ctx.activeLegacyName === 'update-info'" class="form-grid">
<label>版本号<input v-model="ctx.legacyDrafts['update-info'].form.app_version" /></label>
<label>标题<input v-model="ctx.legacyDrafts['update-info'].form.title" /></label>
<label class="wide">公告<textarea v-model="ctx.legacyDrafts['update-info'].form.message" rows="3"></textarea></label>
<label class="wide">下载地址<input v-model="ctx.legacyDrafts['update-info'].form.download_url" /></label>
<label> SHA256<input v-model="ctx.legacyDrafts['update-info'].form.package_sha256" /></label>
<label>包大小<input v-model="ctx.legacyDrafts['update-info'].form.package_size" type="number" /></label>
<label class="wide">发布说明<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes" rows="4"></textarea></label>
<label class="wide">发布说明 Markdown<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes_md" rows="5"></textarea></label>
<label class="wide">更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.update_notes" class="code-editor mini-editor"></textarea></label>
<label class="wide">上次更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.last_update_notes" class="code-editor mini-editor"></textarea></label>
<div class="wide button-row">
<button class="btn ghost" @click="ctx.addUpdateMirror"><Plus :size="16" />新增镜像字段到底稿</button>
<button class="btn" @click="ctx.updateLegacyRawFromForm('update-info')">生成预览 JSON</button>
</div>
</section>
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form'" class="page-stack">
<div class="form-grid">
<label>布局版本<input v-model="ctx.legacyDrafts['media-types'].form.layout_version" /></label>
<label>更新时间<input v-model="ctx.legacyDrafts['media-types'].form.last_updated" /></label>
<label class="wide">UI 配置 JSON<textarea v-model="ctx.legacyDrafts['media-types'].form.ui_config" class="code-editor mini-editor"></textarea></label>
</div>
<div class="button-row">
<button class="btn ghost" @click="ctx.addMediaCategory('media-types')"><Plus :size="16" />新增分类</button>
<button class="btn" @click="ctx.updateLegacyRawFromForm('media-types')">生成预览 JSON</button>
</div>
<section v-for="(cat, cIndex) in ctx.legacyDrafts['media-types'].form.categories" :key="cIndex" class="nested-card">
<div class="section-head">
<h3>分类 {{ cIndex + 1 }}</h3>
<button class="btn ghost compact" @click="ctx.removeItem(ctx.legacyDrafts['media-types'].form.categories, cIndex)"><Trash2 :size="14" />删除</button>
</div>
<div class="form-grid">
<label>ID<input v-model="cat.id" /></label>
<label>名称<input v-model="cat.name" /></label>
<label class="checkbox"><input v-model="cat.enabled" type="checkbox" />启用分类</label>
</div>
<div class="button-row">
<button class="btn ghost compact" @click="ctx.addMediaSubcategory(cat)"><Plus :size="14" />新增子接口</button>
</div>
<section v-for="(sub, sIndex) in cat.subcategories" :key="sIndex" class="nested-card inner">
<div class="section-head">
<h3>{{ sub.name || "子接口" }}</h3>
<button class="btn ghost compact" @click="ctx.removeItem(cat.subcategories, sIndex)"><Trash2 :size="14" />删除</button>
</div>
<div class="form-grid">
<label>ID<input v-model="sub.id" /></label>
<label>名称<input v-model="sub.name" /></label>
<label class="wide">接口 URL<input v-model="sub.api_url" /></label>
<label>缩略图<input v-model="sub.thumbnail_url" /></label>
<label>刷新间隔<input v-model.number="sub.refresh_interval" type="number" /></label>
<label>格式<input v-model="sub.supported_formats" placeholder="json, xml" /></label>
<label class="checkbox"><input v-model="sub.downloadable" type="checkbox" />可下载</label>
<label class="wide">描述<textarea v-model="sub.description" rows="2"></textarea></label>
</div>
</section>
</section>
</section>
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'raw'" class="page-stack">
<textarea v-model="ctx.legacyDrafts[ctx.activeLegacyName].raw" class="code-editor"></textarea>
<label>保存备注<input v-model="ctx.legacyDrafts[ctx.activeLegacyName].note" /></label>
</section>
<aside class="panel page-stack">
<h2>预览与历史</h2>
<pre class="json-preview">{{ ctx.pretty(ctx.legacyDrafts[ctx.activeLegacyName].preview) }}</pre>
<div class="revision-list">
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'preview'">
<pre class="json-preview tall">{{ ctx.pretty(ctx.legacyDrafts[ctx.activeLegacyName].preview) }}</pre>
</section>
<section v-else class="revision-list">
<button v-for="revision in ctx.legacyDocuments[ctx.activeLegacyName]?.revisions || []" :key="revision.id" @click="ctx.restoreLegacy(ctx.activeLegacyName, revision.id)">
#{{ revision.id }} {{ revision.createdAt }}<small>{{ revision.note || "无备注" }}</small>
</button>
</div>
</aside>
<div v-if="(ctx.legacyDocuments[ctx.activeLegacyName]?.revisions || []).length === 0" class="empty-state compact">暂无历史版本</div>
</section>
</section>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { CheckCircle2, Save } from "lucide-vue-next";
import { CheckCircle2, Save, UploadCloud } from "lucide-vue-next";
defineProps<{ ctx: any }>();
</script>
@@ -9,8 +9,24 @@ defineProps<{ ctx: any }>();
<section class="panel page-stack">
<div class="section-head">
<h2>发布包</h2>
<a href="/update-info.json" target="_blank">查看旧版 update-info.json</a>
<span class="badge">{{ ctx.releasePackages.length }} 个文件</span>
</div>
<section class="nested-card upload-card">
<div class="section-head">
<h3>上传最新版本包</h3>
<span class="badge neutral">保存到下载目录</span>
</div>
<div class="form-grid">
<label class="wide">安装包<input type="file" accept=".exe,.msix,.appinstaller,.msi,.zip,.7z" @change="ctx.onPackageSelected" /></label>
<label>版本号<input v-model="ctx.uploadDraft.version" placeholder="2.0.6.31" /></label>
<label>平台<select v-model="ctx.uploadDraft.platform"><option value="windows">Windows</option><option value="linux">Linux</option></select></label>
<label>架构<select v-model="ctx.uploadDraft.arch"><option value="x64">x64</option><option value="x86">x86</option><option value="arm64">arm64</option></select></label>
<label>通道<select v-model="ctx.uploadDraft.channel"><option value="stable">stable</option><option value="beta">beta</option></select></label>
<label class="wide">发布说明<textarea v-model="ctx.uploadDraft.notes" rows="3"></textarea></label>
<label class="checkbox wide"><input v-model="ctx.uploadDraft.updateManifest" type="checkbox" />上传后同步更新兼容 update-info.json</label>
</div>
<button class="btn primary" @click="ctx.uploadPackage"><UploadCloud :size="16" />上传发布包</button>
</section>
<table>
<thead><tr><th>文件</th><th>版本</th><th>平台</th><th>大小</th><th>SHA256</th></tr></thead>
<tbody>
@@ -1,12 +1,9 @@
<script setup lang="ts">
const routes = [
{ path: "/api/client/bootstrap", label: "新版客户端 Bootstrap" },
{ path: "/api/client/releases", label: "新版发布信息" },
{ path: "/api/client/sources", label: "新版接口源目录" },
{ path: "/update-info.json", label: "旧版更新 JSON" },
{ path: "/media-types.json", label: "旧版媒体源 JSON" },
{ path: "/tool-status.json", label: "旧版工具状态" },
{ path: "/modules.json", label: "旧版模块清单" },
const items = [
{ title: "旧版更新能力", body: "客户端继续按原有方式读取更新信息、工具状态、模块清单和下载包。" },
{ title: "旧版媒体源能力", body: "媒体源结构保持旧字段兼容,后台保存后会同步到旧客户端可读结构。" },
{ title: "新版动态配置", body: "新版客户端优先从服务端读取发布、接口源、健康状态和缓存策略,失败时回退旧路径。" },
{ title: "反馈兼容", body: "旧反馈提交和状态查询入口继续保留,后台统一沉淀为反馈工单。" },
];
</script>
@@ -14,16 +11,13 @@ const routes = [
<section class="page-heading">
<p class="eyebrow">Compatibility</p>
<h1>兼容说明</h1>
<p>新旧客户端共用 update.ymhut.cn旧路径和旧 JSON 字段继续保留</p>
<p>新旧客户端共用 update.ymhut.cn门户只展示能力说明具体接口由客户端自动选择</p>
</section>
<section class="panel wide">
<h2>公开路径</h2>
<div class="route-list">
<a v-for="item in routes" :key="item.path" :href="item.path">
<strong>{{ item.path }}</strong>
<span>{{ item.label }}</span>
</a>
</div>
<section class="content-grid">
<article v-for="item in items" :key="item.title" class="panel compat-card">
<h2>{{ item.title }}</h2>
<p>{{ item.body }}</p>
</article>
</section>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Activity, ArrowDownToLine, Database, ExternalLink, HeartPulse, Network, ShieldCheck } from "lucide-vue-next";
import { Activity, ArrowDownToLine, Database, HeartPulse, Network, ShieldCheck } from "lucide-vue-next";
import { usePortalState } from "../state";
const state = usePortalState();
@@ -10,14 +10,12 @@ const state = usePortalState();
<div class="hero-copy">
<p class="eyebrow">update.ymhut.cn</p>
<h1>统一发布反馈与接口源状态门户</h1>
<p>
新版客户端通过 bootstrap 动态获取发布信息版本日志媒体/数据源目录和接口健康状态旧客户端仍可继续访问
update-info.jsonmedia-types.json下载路径和反馈根路径
</p>
<p>统一展示 YMhut Box 的发布状态反馈入口接口源可用性与版本日志新版客户端动态读取服务配置旧客户端兼容能力继续保留</p>
<div class="actions">
<a class="button primary" :href="state.downloadUrl.value"><ArrowDownToLine :size="18" />下载最新版本</a>
<a class="button" href="/api/client/bootstrap"><ShieldCheck :size="18" />客户端配置</a>
<RouterLink class="button" to="/compatibility"><ExternalLink :size="18" />兼容路径</RouterLink>
<a v-if="state.downloadUrl.value" class="button primary" :href="state.downloadUrl.value"><ArrowDownToLine :size="18" />下载最新版本</a>
<RouterLink v-else class="button primary" to="/releases"><ArrowDownToLine :size="18" />查看发布状态</RouterLink>
<RouterLink class="button" to="/sources"><ShieldCheck :size="18" />查看接口状态</RouterLink>
<RouterLink class="button" to="/compatibility">兼容说明</RouterLink>
</div>
<div class="hero-tags">
<span>Legacy JSON 兼容</span>
@@ -47,7 +45,7 @@ const state = usePortalState();
<section class="content-grid">
<article class="panel">
<div class="section-head"><h2>服务入口</h2><a href="/api/client/bootstrap">Bootstrap <ExternalLink :size="14" /></a></div>
<div class="section-head"><h2>服务入口</h2><span class="badge good">运行中</span></div>
<div class="route-list">
<RouterLink to="/releases"><strong>发布版本</strong><span>下载包版本公告和 update-notice 日志</span></RouterLink>
<RouterLink to="/sources"><strong>接口源健康</strong><span>媒体源数据源和动态客户端接口状态</span></RouterLink>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BookOpenText, ExternalLink } from "lucide-vue-next";
import { BookOpenText } from "lucide-vue-next";
import { usePortalState } from "../state";
const state = usePortalState();
@@ -14,7 +14,7 @@ const state = usePortalState();
<section class="content-grid">
<article class="panel wide">
<div class="section-head"><h2>发布包</h2><a href="/update-info.json">旧版 update-info.json <ExternalLink :size="14" /></a></div>
<div class="section-head"><h2>发布包</h2><span class="badge">{{ state.packages.value.length }} 个可用包</span></div>
<table>
<thead><tr><th>文件</th><th>版本</th><th>平台</th><th>大小</th><th></th></tr></thead>
<tbody>
@@ -31,7 +31,7 @@ const state = usePortalState();
</article>
<article class="panel wide">
<div class="section-head"><h2>版本日志</h2><a href="/api/client/notices">Notices API <ExternalLink :size="14" /></a></div>
<div class="section-head"><h2>版本日志</h2><span class="badge good">自动同步</span></div>
<div class="notice-list">
<section v-for="notice in state.notices.value" :key="notice.version" class="notice-card">
<BookOpenText :size="22" />
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { CheckCircle2, ExternalLink } from "lucide-vue-next";
import { CheckCircle2 } from "lucide-vue-next";
import { usePortalState } from "../state";
const state = usePortalState();
@@ -13,7 +13,7 @@ const state = usePortalState();
</section>
<section class="panel wide">
<div class="section-head"><h2>接口源可用性</h2><a href="/api/client/sources">Sources API <ExternalLink :size="14" /></a></div>
<div class="section-head"><h2>接口源可用性</h2><span class="badge">{{ state.sourceCount.value }} 个接口源</span></div>
<div v-if="state.categories.value.length" class="source-board">
<section v-for="cat in state.categories.value" :key="cat.id || cat.name" class="source-group">
<div>
@@ -22,11 +22,11 @@ const state = usePortalState();
</div>
<div class="source-list">
<span v-for="src in cat.subcategories || []" :key="src.id || src.sourceId" :class="['badge', state.statusTone(state.sourceStatus(src))]">
<CheckCircle2 :size="13" />{{ src.name }}
<CheckCircle2 :size="13" />{{ src.name }}<small v-if="state.sourceStatus(src) === 'redirected'">重定向</small>
</span>
</div>
</section>
</div>
<p v-else class="empty">暂无接口源数据后台同步旧 media-types.json 或手动添加后会显示在这里</p>
<p v-else class="empty">暂无接口源数据后台同步旧媒体源配置或手动添加后会显示在这里</p>
</section>
</template>
@@ -23,7 +23,7 @@ export function usePortalState() {
return total + (cat.subcategories || []).filter((item: any) => sourceStatus(item) === "ok").length;
}, 0));
const availability = computed(() => sourceCount.value ? Math.round((healthyCount.value / sourceCount.value) * 100) : 0);
const downloadUrl = computed(() => releases.value?.download_url || bootstrap.value?.release?.download_url || "/update-info.json");
const downloadUrl = computed(() => releases.value?.download_url || bootstrap.value?.release?.download_url || packages.value[0]?.url || "");
const appVersion = computed(() => releases.value?.app_version || bootstrap.value?.release?.app_version || latestNotice.value?.version || "未发布");
const databaseStatus = computed(() => bootstrap.value?.health?.database?.activeProvider || bootstrap.value?.health?.database?.configProvider || "-");
const serviceVersion = computed(() => bootstrap.value?.serviceVersion || "-");
@@ -83,7 +83,7 @@ export function sourceStatus(item: any) {
export function statusTone(status: string) {
const value = String(status || "").toLowerCase();
if (["ok", "sqlite", "mysql", "online", "ready"].includes(value)) return "good";
if (["ok", "redirected", "sqlite", "mysql", "online", "ready"].includes(value)) return "good";
if (["degraded", "pending", "missing"].includes(value)) return "warn";
if (["error", "offline", "failed"].includes(value)) return "bad";
return "neutral";
@@ -2,23 +2,22 @@
color-scheme: light;
font-family: "Microsoft YaHei UI", "Segoe UI", Arial, sans-serif;
color: #172033;
background: #f7f9ff;
background: #f5f7f4;
--ink: #172033;
--muted: #63718a;
--soft: #f7f9ff;
--soft: #f5f7f4;
--panel: rgba(255, 255, 255, 0.82);
--panel-strong: #ffffff;
--line: rgba(112, 132, 170, 0.18);
--line-strong: rgba(94, 114, 158, 0.28);
--primary: #3b82f6;
--primary-dark: #2563eb;
--cyan: #06b6d4;
--violet: #8b5cf6;
--pink: #f472b6;
--primary: #1f6f5b;
--primary-dark: #155241;
--accent: #d99227;
--good: #059669;
--warn: #b7791f;
--bad: #dc2626;
--shadow: 0 22px 65px rgba(65, 88, 140, 0.16);
--shadow: 0 22px 65px rgba(31, 48, 40, 0.12);
--ease: cubic-bezier(.2,.8,.2,1);
}
* { box-sizing: border-box; }
@@ -27,9 +26,9 @@ body {
margin: 0;
min-width: 320px;
background:
radial-gradient(circle at 8% 6%, rgba(96, 165, 250, 0.30), transparent 28%),
radial-gradient(circle at 88% 8%, rgba(244, 114, 182, 0.20), transparent 30%),
linear-gradient(180deg, #eef6ff 0%, #f8fbff 42%, #ffffff 100%);
radial-gradient(circle at 8% 6%, rgba(31, 111, 91, 0.10), transparent 30%),
radial-gradient(circle at 90% 8%, rgba(217, 146, 39, 0.10), transparent 30%),
linear-gradient(180deg, #f2f5ef 0%, #f8faf6 42%, #ffffff 100%);
}
body::before {
content: "";
@@ -37,8 +36,8 @@ body::before {
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px);
linear-gradient(rgba(31, 111, 91, 0.045) 1px, transparent 1px),
linear-gradient(90deg, rgba(31, 111, 91, 0.045) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.55), transparent 70%);
}
@@ -87,8 +86,8 @@ button { cursor: pointer; }
place-items: center;
border-radius: 50%;
color: #fff;
background: linear-gradient(135deg, var(--primary), var(--cyan));
box-shadow: 0 12px 26px rgba(37, 99, 235, 0.26);
background: linear-gradient(135deg, #10231d, var(--primary));
box-shadow: 0 12px 26px rgba(31, 111, 91, 0.22);
}
.brand strong { letter-spacing: 0; }
.nav-links {
@@ -108,18 +107,19 @@ button { cursor: pointer; }
text-decoration: none;
font-size: 14px;
font-weight: 800;
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
transition: transform 0.18s var(--ease), background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.nav-links a:hover, .nav-links a.active {
color: var(--primary-dark);
background: rgba(59, 130, 246, 0.12);
background: rgba(31, 111, 91, 0.10);
transform: translateY(-1px);
}
.admin-link {
color: #fff;
background: linear-gradient(135deg, #2563eb, #7c3aed);
box-shadow: 0 12px 28px rgba(59, 130, 246, 0.24);
background: linear-gradient(135deg, #10231d, #1f6f5b);
box-shadow: 0 12px 28px rgba(31, 111, 91, 0.22);
}
.admin-link:hover { box-shadow: 0 16px 36px rgba(59, 130, 246, 0.32); }
.admin-link:hover { transform: translateY(-1px); box-shadow: 0 16px 36px rgba(31, 111, 91, 0.28); }
.hero {
position: relative;
@@ -134,8 +134,8 @@ button { cursor: pointer; }
border-radius: 32px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.62)),
radial-gradient(circle at 88% 18%, rgba(14, 165, 233, 0.26), transparent 34%),
radial-gradient(circle at 18% 82%, rgba(139, 92, 246, 0.18), transparent 30%);
radial-gradient(circle at 88% 18%, rgba(31, 111, 91, 0.14), transparent 34%),
radial-gradient(circle at 18% 82%, rgba(217, 146, 39, 0.13), transparent 30%);
box-shadow: var(--shadow);
padding: clamp(28px, 5vw, 58px);
overflow: hidden;
@@ -148,7 +148,7 @@ button { cursor: pointer; }
width: 360px;
height: 360px;
border-radius: 50%;
background: radial-gradient(circle, rgba(59, 130, 246, 0.20), transparent 68%);
background: radial-gradient(circle, rgba(31, 111, 91, 0.13), transparent 68%);
}
.hero-copy {
position: relative;
@@ -193,7 +193,7 @@ p {
margin-top: 22px;
}
.hero-tags span {
border: 1px solid rgba(59, 130, 246, 0.18);
border: 1px solid rgba(31, 111, 91, 0.16);
border-radius: 999px;
padding: 7px 11px;
color: #355075;
@@ -215,7 +215,7 @@ p {
color: #263856;
font-weight: 900;
box-shadow: 0 10px 26px rgba(65, 88, 140, 0.10);
transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
transition: transform 0.18s var(--ease), box-shadow 0.18s ease, background-color 0.18s ease, border-color 0.18s ease;
}
.button:hover {
transform: translateY(-1px);
@@ -225,8 +225,8 @@ p {
.button.primary {
color: #fff;
border-color: transparent;
background: linear-gradient(135deg, #2563eb, #06b6d4);
box-shadow: 0 16px 34px rgba(37, 99, 235, 0.26);
background: linear-gradient(135deg, #10231d, #1f6f5b);
box-shadow: 0 16px 34px rgba(31, 111, 91, 0.24);
}
.release-card, .panel, .metric {
@@ -236,6 +236,13 @@ p {
box-shadow: 0 14px 42px rgba(65, 88, 140, 0.11);
backdrop-filter: blur(16px);
}
.release-card, .panel, .metric, .source-group, .notice-card, .route-list a {
transition: transform 0.22s var(--ease), border-color 0.22s ease, box-shadow 0.22s ease, background-color 0.22s ease;
}
.release-card:hover, .panel:hover, .metric:hover, .source-group:hover, .notice-card:hover, .route-list a:hover {
transform: translateY(-2px);
box-shadow: 0 18px 46px rgba(31, 48, 40, 0.13);
}
.release-card {
position: relative;
z-index: 1;
@@ -396,8 +403,8 @@ input {
outline: none;
}
input:focus {
border-color: rgba(59, 130, 246, 0.65);
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.14);
border-color: rgba(31, 111, 91, 0.58);
box-shadow: 0 0 0 4px rgba(31, 111, 91, 0.12);
}
.source-board {
display: grid;
@@ -434,7 +441,7 @@ input:focus {
}
.route-list a:hover {
transform: translateY(-1px);
border-color: rgba(59, 130, 246, 0.36);
border-color: rgba(31, 111, 91, 0.30);
background: rgba(255, 255, 255, 0.92);
}
.route-list span { color: var(--muted); font-size: 13px; font-weight: 700; }
@@ -476,5 +483,5 @@ input:focus {
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition: none !important; scroll-behavior: auto !important; }
*, *::before, *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
}