From 68be41e7a28570e616be8ec760cc0ab469e03d50 Mon Sep 17 00:00:00 2001 From: g82tt Date: Sun, 5 Jul 2026 05:09:56 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4=EF=BC=9A?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8=E9=A6=96=E9=A1=B5=20MyHomePage=20?= =?UTF-8?q?=E5=85=A8=E6=A0=88=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 项目概述 个人浏览器首页导航应用,支持书签分类管理、搜索引擎快捷搜索、 必应每日壁纸轮播、前后端分离部署,适配 1Panel 服务器(Docker 模式)。 # 技术栈 - 前端:Vue 3 + TypeScript + Vite + Pinia + Capacitor(Android 打包) - 后端:.NET 8 + SqlSugar(多数据库) + SQLite/MySQL + Swashbuckle - 部署:1Panel 应用商店自定义应用(Docker Compose 模式) # 项目结构 - backend/ .NET 8 API 后端(8 个 Controller + 15 个 Service) - frontend/ Vue 3 前端(19 个组件 + 9 个 API 模块 + 5 个 Store) - docker/ Docker 部署文件(后端镜像 + Nginx 反代) - docs/ 部署手册(1Panel 实战版) - scripts/ E2E 测试脚本 # 已实现功能 - 书签管理:增删改查 + 树形分类 + 拖拽排序 + 主色自适应 - 搜索引擎:8 个内置引擎 + 自定义引擎 + favicon 自动抓取 - 必应壁纸:每日轮播 + 多分辨率自动选择 + 1.6MP 质量优先 - 全局设置:主题/行为/数据/工具 4 分类 + 跨设备同步 - 文件上传:图标/书签/通用(容器持久化 + 跨域 URL 拼接) - 同步:基于变更日志的设备间数据同步 - 跨域部署:前后端分离 + runtime config.json 无需重新编译 # 进度记录 - 已完成 P0~P52 共 53 个开发节点(详细见 说明文档.md) - 当前版本:v1.0 部署就绪 # 部署文档 - README.md:项目说明 + 快速开始 - 说明文档.md:完整开发进度(中文) - docs/DEPLOY.md:1Panel 部署手册(Docker 模式) --- .gitignore | 43 + README.md | 168 + backend/.gitignore | 18 + backend/Common/ApiResponse.cs | 54 + backend/Common/BusinessException.cs | 16 + backend/Common/ExceptionHandlingMiddleware.cs | 56 + backend/Controllers/BookmarksController.cs | 48 + backend/Controllers/CategoriesController.cs | 48 + .../Controllers/SearchEnginesController.cs | 48 + backend/Controllers/SettingsController.cs | 24 + backend/Controllers/SyncController.cs | 45 + backend/Controllers/UploadController.cs | 30 + backend/Controllers/UtilityController.cs | 43 + backend/Controllers/WallpaperController.cs | 80 + .../Configuration/CorsOptions.cs | 10 + .../Configuration/DatabaseOptions.cs | 13 + .../Configuration/UploadOptions.cs | 16 + .../Database/DatabaseInitializer.cs | 245 ++ .../Database/SqlSugarContext.cs | 59 + backend/Models/Dtos/BookmarkDtos.cs | 55 + backend/Models/Dtos/CategoryDtos.cs | 82 + backend/Models/Dtos/SearchEngineDtos.cs | 49 + backend/Models/Dtos/SettingDtos.cs | 59 + backend/Models/Dtos/SyncDtos.cs | 32 + backend/Models/Dtos/UploadDtos.cs | 17 + backend/Models/Dtos/WallpaperDtos.cs | 29 + backend/Models/Entities/BaseEntity.cs | 19 + backend/Models/Entities/Bookmark.cs | 48 + backend/Models/Entities/Category.cs | 24 + backend/Models/Entities/SearchEngine.cs | 40 + backend/Models/Entities/Setting.cs | 44 + backend/Models/Entities/SyncLog.cs | 27 + backend/MyHomePage.Api.csproj | 32 + backend/Program.cs | 140 + .../PublishProfiles/FolderProfile.pubxml | 20 + backend/Properties/launchSettings.json | 14 + backend/Repositories/BaseRepository.cs | 44 + backend/Services/BookmarkService.cs | 191 ++ backend/Services/CategoryService.cs | 111 + backend/Services/FaviconService.cs | 454 +++ backend/Services/IBookmarkService.cs | 13 + backend/Services/ICategoryService.cs | 13 + backend/Services/ISearchEngineService.cs | 14 + backend/Services/ISettingService.cs | 10 + backend/Services/ISyncService.cs | 9 + backend/Services/IUploadService.cs | 20 + backend/Services/SearchEngineService.cs | 121 + backend/Services/SettingService.cs | 85 + backend/Services/SyncLogHelper.cs | 25 + backend/Services/SyncService.cs | 60 + backend/Services/UploadService.cs | 144 + backend/Services/WallpaperService.cs | 383 +++ backend/Uploads/.gitkeep | 1 + backend/appsettings.Development.json | 13 + backend/appsettings.json | 67 + backend/categories.json | 1 + backend/dotnet-tools.json | 13 + browser-homepage/browser-homepage.design | 57 + browser-homepage/colors_and_type.css | 101 + browser-homepage/generation-tree.json | 68 + browser-homepage/orchestration-summary.json | 208 ++ browser-homepage/pages/desktop-settings.html | 493 +++ browser-homepage/pages/desktop.html | 576 ++++ browser-homepage/pages/mobile.html | 597 ++++ docker-compose.yml | 78 + docker/README.md | 44 + docker/backend.Dockerfile | 29 + docker/nginx.conf | 26 + docs/DEPLOY.md | 378 +++ frontend/.env.development | 3 + frontend/.env.production | 3 + frontend/.gitignore | 12 + frontend/ANDROID.md | 69 + frontend/capacitor.config.ts | 27 + frontend/index.html | 14 + frontend/package-lock.json | 3007 +++++++++++++++++ frontend/package.json | 32 + frontend/public/config.json | 3 + frontend/public/favicon.svg | 10 + frontend/src/App.vue | 64 + frontend/src/api/bookmarks.ts | 18 + frontend/src/api/categories.ts | 18 + frontend/src/api/http.ts | 78 + frontend/src/api/searchEngines.ts | 18 + frontend/src/api/settings.ts | 8 + frontend/src/api/sync.ts | 14 + frontend/src/api/upload.ts | 12 + frontend/src/api/utility.ts | 26 + frontend/src/api/wallpaper.ts | 53 + frontend/src/components/AppButton.vue | 107 + frontend/src/components/AppCard.vue | 44 + frontend/src/components/AppCategoryTabs.vue | 77 + frontend/src/components/AppDrawer.vue | 92 + frontend/src/components/AppFab.vue | 30 + frontend/src/components/AppIcon.vue | 48 + frontend/src/components/AppIconPicker.vue | 167 + frontend/src/components/AppLinkCard.vue | 192 ++ frontend/src/components/AppLinkListItem.vue | 159 + frontend/src/components/AppMobileTopBar.vue | 44 + frontend/src/components/AppModal.vue | 112 + frontend/src/components/AppSearchBar.vue | 261 ++ frontend/src/components/AppSidebar.vue | 489 +++ frontend/src/components/AppToastHost.vue | 64 + frontend/src/components/AppWallpaper.vue | 131 + frontend/src/components/BookmarkForm.vue | 522 +++ frontend/src/components/CategoryForm.vue | 133 + frontend/src/config.ts | 71 + frontend/src/env.d.ts | 15 + frontend/src/main.ts | 30 + frontend/src/router/index.ts | 15 + frontend/src/stores/bookmarks.ts | 59 + frontend/src/stores/categories.ts | 59 + frontend/src/stores/searchEngines.ts | 66 + frontend/src/stores/settings.ts | 261 ++ frontend/src/stores/sync.ts | 53 + frontend/src/styles/global.css | 88 + frontend/src/styles/tokens.css | 142 + frontend/src/types/api.ts | 158 + frontend/src/utils/color.ts | 171 + frontend/src/utils/icon.ts | 158 + frontend/src/utils/storage.ts | 39 + frontend/src/utils/toast.ts | 30 + frontend/src/views/HomeView.vue | 531 +++ frontend/src/views/SettingsView.vue | 1083 ++++++ frontend/tsconfig.json | 28 + frontend/tsconfig.node.json | 12 + frontend/vite.config.ts | 45 + scripts/e2e-test.ps1 | 121 + 说明文档.md | 262 ++ 129 files changed, 15900 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/.gitignore create mode 100644 backend/Common/ApiResponse.cs create mode 100644 backend/Common/BusinessException.cs create mode 100644 backend/Common/ExceptionHandlingMiddleware.cs create mode 100644 backend/Controllers/BookmarksController.cs create mode 100644 backend/Controllers/CategoriesController.cs create mode 100644 backend/Controllers/SearchEnginesController.cs create mode 100644 backend/Controllers/SettingsController.cs create mode 100644 backend/Controllers/SyncController.cs create mode 100644 backend/Controllers/UploadController.cs create mode 100644 backend/Controllers/UtilityController.cs create mode 100644 backend/Controllers/WallpaperController.cs create mode 100644 backend/Infrastructure/Configuration/CorsOptions.cs create mode 100644 backend/Infrastructure/Configuration/DatabaseOptions.cs create mode 100644 backend/Infrastructure/Configuration/UploadOptions.cs create mode 100644 backend/Infrastructure/Database/DatabaseInitializer.cs create mode 100644 backend/Infrastructure/Database/SqlSugarContext.cs create mode 100644 backend/Models/Dtos/BookmarkDtos.cs create mode 100644 backend/Models/Dtos/CategoryDtos.cs create mode 100644 backend/Models/Dtos/SearchEngineDtos.cs create mode 100644 backend/Models/Dtos/SettingDtos.cs create mode 100644 backend/Models/Dtos/SyncDtos.cs create mode 100644 backend/Models/Dtos/UploadDtos.cs create mode 100644 backend/Models/Dtos/WallpaperDtos.cs create mode 100644 backend/Models/Entities/BaseEntity.cs create mode 100644 backend/Models/Entities/Bookmark.cs create mode 100644 backend/Models/Entities/Category.cs create mode 100644 backend/Models/Entities/SearchEngine.cs create mode 100644 backend/Models/Entities/Setting.cs create mode 100644 backend/Models/Entities/SyncLog.cs create mode 100644 backend/MyHomePage.Api.csproj create mode 100644 backend/Program.cs create mode 100644 backend/Properties/PublishProfiles/FolderProfile.pubxml create mode 100644 backend/Properties/launchSettings.json create mode 100644 backend/Repositories/BaseRepository.cs create mode 100644 backend/Services/BookmarkService.cs create mode 100644 backend/Services/CategoryService.cs create mode 100644 backend/Services/FaviconService.cs create mode 100644 backend/Services/IBookmarkService.cs create mode 100644 backend/Services/ICategoryService.cs create mode 100644 backend/Services/ISearchEngineService.cs create mode 100644 backend/Services/ISettingService.cs create mode 100644 backend/Services/ISyncService.cs create mode 100644 backend/Services/IUploadService.cs create mode 100644 backend/Services/SearchEngineService.cs create mode 100644 backend/Services/SettingService.cs create mode 100644 backend/Services/SyncLogHelper.cs create mode 100644 backend/Services/SyncService.cs create mode 100644 backend/Services/UploadService.cs create mode 100644 backend/Services/WallpaperService.cs create mode 100644 backend/Uploads/.gitkeep create mode 100644 backend/appsettings.Development.json create mode 100644 backend/appsettings.json create mode 100644 backend/categories.json create mode 100644 backend/dotnet-tools.json create mode 100644 browser-homepage/browser-homepage.design create mode 100644 browser-homepage/colors_and_type.css create mode 100644 browser-homepage/generation-tree.json create mode 100644 browser-homepage/orchestration-summary.json create mode 100644 browser-homepage/pages/desktop-settings.html create mode 100644 browser-homepage/pages/desktop.html create mode 100644 browser-homepage/pages/mobile.html create mode 100644 docker-compose.yml create mode 100644 docker/README.md create mode 100644 docker/backend.Dockerfile create mode 100644 docker/nginx.conf create mode 100644 docs/DEPLOY.md create mode 100644 frontend/.env.development create mode 100644 frontend/.env.production create mode 100644 frontend/.gitignore create mode 100644 frontend/ANDROID.md create mode 100644 frontend/capacitor.config.ts create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/config.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/bookmarks.ts create mode 100644 frontend/src/api/categories.ts create mode 100644 frontend/src/api/http.ts create mode 100644 frontend/src/api/searchEngines.ts create mode 100644 frontend/src/api/settings.ts create mode 100644 frontend/src/api/sync.ts create mode 100644 frontend/src/api/upload.ts create mode 100644 frontend/src/api/utility.ts create mode 100644 frontend/src/api/wallpaper.ts create mode 100644 frontend/src/components/AppButton.vue create mode 100644 frontend/src/components/AppCard.vue create mode 100644 frontend/src/components/AppCategoryTabs.vue create mode 100644 frontend/src/components/AppDrawer.vue create mode 100644 frontend/src/components/AppFab.vue create mode 100644 frontend/src/components/AppIcon.vue create mode 100644 frontend/src/components/AppIconPicker.vue create mode 100644 frontend/src/components/AppLinkCard.vue create mode 100644 frontend/src/components/AppLinkListItem.vue create mode 100644 frontend/src/components/AppMobileTopBar.vue create mode 100644 frontend/src/components/AppModal.vue create mode 100644 frontend/src/components/AppSearchBar.vue create mode 100644 frontend/src/components/AppSidebar.vue create mode 100644 frontend/src/components/AppToastHost.vue create mode 100644 frontend/src/components/AppWallpaper.vue create mode 100644 frontend/src/components/BookmarkForm.vue create mode 100644 frontend/src/components/CategoryForm.vue create mode 100644 frontend/src/config.ts create mode 100644 frontend/src/env.d.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/stores/bookmarks.ts create mode 100644 frontend/src/stores/categories.ts create mode 100644 frontend/src/stores/searchEngines.ts create mode 100644 frontend/src/stores/settings.ts create mode 100644 frontend/src/stores/sync.ts create mode 100644 frontend/src/styles/global.css create mode 100644 frontend/src/styles/tokens.css create mode 100644 frontend/src/types/api.ts create mode 100644 frontend/src/utils/color.ts create mode 100644 frontend/src/utils/icon.ts create mode 100644 frontend/src/utils/storage.ts create mode 100644 frontend/src/utils/toast.ts create mode 100644 frontend/src/views/HomeView.vue create mode 100644 frontend/src/views/SettingsView.vue create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 scripts/e2e-test.ps1 create mode 100644 说明文档.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a7e1eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# ============================================================= +# 根目录 .gitignore(兜底用) +# - backend/.gitignore 已管 backend/bin/obj/.vs/Uploads/*/.db 等 +# - frontend/.gitignore 已管 frontend/node_modules/dist +# - 本文件只补根目录零散的 .log/.err/.out/.user 等 +# ============================================================= + +# ---- .NET 用户专属文件(CS 项目会生成 .user/.suo) ---- +*.user +*.suo + +# ---- 根目录零散的运行时文件(VS 调试输出 / 控制台重定向) ---- +backend/*.log +backend/*.log.err +backend/*.err +backend/*.out +backend/console.err +backend/api.log +backend/api.log.err +backend/err.log +backend/favicon_test.log +backend/out.log +backend/run.err +backend/run.out +backend/MyHomePage.Api.csproj.user +backend/.vs/ + +# ---- 本地 SQLite 数据库(运行时数据,禁止入仓) ---- +backend/*.db +backend/*.db-shm +backend/*.db-wal + +# ---- 前端运行输出(Vite dev 进程重定向) ---- +frontend/dev.err +frontend/dev.out + +# ---- IDE / 编辑器配置 ---- +.idea/ +.vscode/ + +# ---- OS 临时文件 ---- +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce88468 --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ +# MyHomePage · 浏览器首页 + +> 跨端可用的浏览器首页 / 起始页:PC、平板、手机浏览器 + Android APP 共享同一份数据,实时同步。 + +![MyHomePage](https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=modern%20glassmorphism%20browser%20homepage%20with%20gradient%20background%2C%20dark%20theme%2C%20search%20bar%2C%20category%20sidebar%2C%20link%20cards%20grid%2C%20floating%20UI&image_size=landscape_16_9) + +## 功能特性 + +- 二级分类导航(常用工具 > 搜索引擎 / AI 工具 / 开发工具) +- 链接收藏管理:增删改、图标、简介 +- 搜索引擎管理:增删改、默认引擎切换 +- 设置面板:主题模式(暗/亮/跟随系统)、主色调、背景图(6 套预设 + 自定义上传) +- PC、平板、手机浏览器自适应 +- PC、平板、手机、Android APP 四端数据实时同步 +- 文件上传(链接图标 / 自定义背景图),落地路径可配置 +- 后端支持 MySQL / SQLite,通过 `appsettings.json` 一键切换 + +## 技术栈 + +| 层 | 选型 | +|----|------| +| 前端 | Vue 3 + TypeScript + Vite + Pinia + Vue Router | +| UI | 自研组件(玻璃拟态设计 token),图标 lucide-vue-next | +| APP 壳 | Capacitor 6(共享前端代码) | +| 后端 | .NET 8 + ASP.NET Core Web API + C# | +| ORM | SqlSugar(多数据库自动切换) | +| 数据库 | MySQL 8 / SQLite | +| 部署 | Docker Compose | + +## 目录结构 + +``` +MyHomePage/ +├── frontend/ # Vue 3 前端 +│ ├── src/ +│ │ ├── api/ # axios 封装 +│ │ ├── components/ # 通用组件 + 业务组件 +│ │ ├── views/ # HomeView / SettingsView +│ │ ├── stores/ # Pinia stores +│ │ ├── router/ # 路由 +│ │ ├── styles/ # tokens.css + global.css +│ │ ├── types/ # TypeScript 类型 +│ │ └── utils/ # icon / toast 工具 +│ ├── capacitor.config.ts # Capacitor 配置 +│ ├── ANDROID.md # 打包 Android 指引 +│ ├── package.json +│ └── vite.config.ts +├── backend/ # .NET 8 Web API +│ ├── Controllers/ # 6 个 REST 控制器 +│ ├── Services/ # 业务服务 +│ ├── Models/ # Entities + Dtos +│ ├── Repositories/ # BaseRepository +│ ├── Common/ # ApiResponse + 异常中间件 +│ ├── Infrastructure/ # SqlSugarContext + 配置 +│ ├── Uploads/ # 上传文件目录 +│ ├── MyHomePage.Api.csproj +│ ├── Program.cs +│ └── appsettings.json +├── docker/ # Docker 部署资源 +│ ├── backend.Dockerfile +│ ├── nginx.conf +│ └── README.md +├── docker-compose.yml +└── browser-homepage/ # 设计稿(已存在) +``` + +## 快速开始 + +### 1. 启动后端 + +```bash +cd backend +dotnet restore +dotnet run +# 默认监听 http://localhost:5080 +# Swagger UI: http://localhost:5080/swagger +``` + +第一次启动会自动建表(CodeFirst)并写入种子数据(常用工具 + AI 工具 + 开发工具 3 个分类 + 6 个链接 + 3 个搜索引擎)。 + +### 2. 切换数据库 + +默认 SQLite(`myhomepage.db`)。如需 MySQL: + +编辑 `backend/appsettings.json`: + +```json +{ + "Database": { + "Provider": "MySql", + "ConnectionString": "Server=localhost;Port=3306;Database=myhomepage;Uid=root;Pwd=yourpw;CharSet=utf8mb4;" + } +} +``` + +### 3. 启动前端 + +```bash +cd frontend +npm install +npm run dev +# 默认监听 http://localhost:5173 +``` + +Vite 已配 proxy:`/api` → `http://localhost:5080`。 + +### 4. 浏览器访问 + +打开 http://localhost:5173 + +## 打包 Android APP + +见 [frontend/ANDROID.md](file:///d:/Code/MyHomePage/frontend/ANDROID.md) + +## Docker 一键部署 + +见 [docker/README.md](file:///d:/Code/MyHomePage/docker/README.md) + +```bash +docker compose up -d --build +# 访问 http://localhost:8080 +``` + +## API 总览 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET / POST / PUT / DELETE | `/api/categories` | 分类 CRUD(支持二级树) | +| GET / POST / PUT / DELETE | `/api/bookmarks` | 链接 CRUD,可按 `?categoryId=` 过滤 | +| GET / POST / PUT / DELETE | `/api/search-engines` | 搜索引擎 CRUD | +| PUT | `/api/search-engines/{id}/default` | 设为默认(自动取消其他) | +| GET / PUT | `/api/settings` | 用户设置读写 | +| POST | `/api/upload` | 单文件上传,返回 `{ url, path, ... }` | +| GET | `/api/sync/changes?since={ISO8601}` | 增量同步,返回 `{ changes, snapshot, serverTime }` | +| GET | `/health` | 健康检查 | +| GET | `/swagger` | OpenAPI 文档 | + +所有接口统一响应格式: +```json +{ "code": 0, "message": "ok", "data": { ... }, "timestamp": 1783158000000 } +``` + +`code !== 0` 表示业务错误,`code === 0` 表示成功。 + +## 上传路径配置 + +```json +{ + "Upload": { + "Path": "Uploads", // 相对 ContentRoot;填绝对路径也支持 + "BaseUrl": "/uploads", // 前端拼接前缀 + "MaxSizeBytes": 10485760 // 10MB + } +} +``` + +通过环境变量覆盖:`Upload__Path=/data/uploads`、`Upload__BaseUrl=https://cdn.example.com/uploads`。 + +## 开发约定 + +- 严格按 [说明文档.md](file:///d:/Code/MyHomePage/说明文档.md) 中的 TODO 推进 +- 每次完成一个任务立即更新「说明文档」中的进度记录 +- 函数级注释(功能 / 参数 / 返回值),便于 AI 理解 +- API 错误码用 4xx 表示业务校验失败,5xx 表示系统错误 + +## License + +MIT diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..8949090 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,18 @@ +## 忽略构建产物 +bin/ +obj/ + +## 用户专属 +*.user +*.suo +.vs/ +.idea/ + +## 上传目录的运行时文件(保留 .gitkeep) +Uploads/* +!Uploads/.gitkeep + +## 本地数据库 +*.db +*.db-shm +*.db-wal diff --git a/backend/Common/ApiResponse.cs b/backend/Common/ApiResponse.cs new file mode 100644 index 0000000..a039580 --- /dev/null +++ b/backend/Common/ApiResponse.cs @@ -0,0 +1,54 @@ +namespace MyHomePage.Api.Common; + +/// +/// 统一 API 响应包装。 +/// 全部接口返回该类型,前端可依据 判定业务结果。 +/// +/// 业务数据类型 +public class ApiResponse +{ + /// 业务状态码:0 表示成功,非 0 表示错误 + public int Code { get; set; } + + /// 提示信息 + public string Message { get; set; } = string.Empty; + + /// 业务数据 + public T? Data { get; set; } + + /// 服务器时间戳(毫秒) + public long Timestamp { get; set; } = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + /// 构造成功响应 + public static ApiResponse Ok(T data, string message = "ok") => + new() { Code = 0, Message = message, Data = data }; + + /// 构造成功响应(无数据) + public static ApiResponse Ok(string message = "ok") => + new() { Code = 0, Message = message }; + + /// 异步等待数据后构造成功响应(用于服务层返回 Task<T>) + public static async Task> OkAsync(Task dataTask, string message = "ok") + { + var data = await dataTask; + return Ok(data, message); + } + + /// 异步等待数据后构造成功响应(列表场景,重命名避免类型参数遮蔽) + public static async Task>> OkListAsync(Task> dataTask, string message = "ok") + { + var data = await dataTask; + return new ApiResponse> { Code = 0, Message = message, Data = data }; + } + + /// 构造失败响应 + public static ApiResponse Fail(int code, string message) => + new() { Code = code, Message = message }; +} + +/// 无泛型版本的快捷响应(用于无数据的接口) +public class ApiResponse : ApiResponse +{ + /// 构造成功响应(无数据) + public static ApiResponse Ok() => new() { Code = 0, Message = "ok" }; +} diff --git a/backend/Common/BusinessException.cs b/backend/Common/BusinessException.cs new file mode 100644 index 0000000..2394bb4 --- /dev/null +++ b/backend/Common/BusinessException.cs @@ -0,0 +1,16 @@ +namespace MyHomePage.Api.Common; + +/// +/// 业务异常:被中间件捕获后转为 ApiResponse.Fail, +/// 不会进入 ASP.NET Core 默认 500 处理流程。 +/// +public class BusinessException : Exception +{ + /// 业务错误码(默认 400) + public int Code { get; } + + public BusinessException(string message, int code = 400) : base(message) + { + Code = code; + } +} diff --git a/backend/Common/ExceptionHandlingMiddleware.cs b/backend/Common/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..03a4069 --- /dev/null +++ b/backend/Common/ExceptionHandlingMiddleware.cs @@ -0,0 +1,56 @@ +using System.Text.Json; + +namespace MyHomePage.Api.Common; + +/// +/// 全局异常处理中间件。 +/// 捕获下游抛出的 与未处理异常,统一包装为 。 +/// +public class ExceptionHandlingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly IHostEnvironment _env; + + public ExceptionHandlingMiddleware( + RequestDelegate next, + ILogger logger, + IHostEnvironment env) + { + _next = next; + _logger = logger; + _env = env; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (BusinessException ex) + { + _logger.LogWarning("Business exception ({Code}): {Message}", ex.Code, ex.Message); + // 使用 ex.Code 透传业务状态码(默认 400/404/500),不再硬编码 200 + var status = ex.Code is >= 400 and < 600 ? ex.Code : StatusCodes.Status400BadRequest; + await WriteAsync(context, ApiResponse.Fail(ex.Code, ex.Message), status); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled exception: {Message}", ex.Message); + var message = _env.IsDevelopment() ? ex.Message : "服务器内部错误"; + await WriteAsync(context, ApiResponse.Fail(500, message), StatusCodes.Status500InternalServerError); + } + } + + private static async Task WriteAsync(HttpContext ctx, object payload, int statusCode) + { + ctx.Response.StatusCode = statusCode; + ctx.Response.ContentType = "application/json; charset=utf-8"; + var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + await ctx.Response.WriteAsync(json); + } +} diff --git a/backend/Controllers/BookmarksController.cs b/backend/Controllers/BookmarksController.cs new file mode 100644 index 0000000..c94af2d --- /dev/null +++ b/backend/Controllers/BookmarksController.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using MyHomePage.Api.Common; +using MyHomePage.Api.Models.Dtos; +using MyHomePage.Api.Services; + +namespace MyHomePage.Api.Controllers; + +/// 链接收藏。 +[ApiController] +[Route("api/bookmarks")] +public class BookmarksController : ControllerBase +{ + private readonly IBookmarkService _service; + + public BookmarksController(IBookmarkService service) => _service = service; + + /// 获取链接列表,可按分类过滤 + [HttpGet] + public async Task>> List([FromQuery] int? categoryId = null) => + await ApiResponse>.OkListAsync(_service.ListAsync(categoryId)); + + /// 根据 ID 获取链接 + [HttpGet("{id:int}")] + public async Task> GetById(int id) + { + var dto = await _service.GetByIdAsync(id) + ?? throw new BusinessException("链接不存在", 404); + return ApiResponse.Ok(dto); + } + + /// 创建链接 + [HttpPost] + public async Task> Create([FromBody] BookmarkUpsertRequest request) => + ApiResponse.Ok(await _service.CreateAsync(request)); + + /// 更新链接 + [HttpPut("{id:int}")] + public async Task> Update(int id, [FromBody] BookmarkUpsertRequest request) => + ApiResponse.Ok(await _service.UpdateAsync(id, request)); + + /// 删除链接(软删) + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + await _service.DeleteAsync(id); + return ApiResponse.Ok(); + } +} diff --git a/backend/Controllers/CategoriesController.cs b/backend/Controllers/CategoriesController.cs new file mode 100644 index 0000000..590454a --- /dev/null +++ b/backend/Controllers/CategoriesController.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using MyHomePage.Api.Common; +using MyHomePage.Api.Models.Dtos; +using MyHomePage.Api.Services; + +namespace MyHomePage.Api.Controllers; + +/// 分类管理:支持二级树形结构。 +[ApiController] +[Route("api/categories")] +public class CategoriesController : ControllerBase +{ + private readonly ICategoryService _service; + + public CategoriesController(ICategoryService service) => _service = service; + + /// 获取全量分类(树形) + [HttpGet] + public async Task>> GetTree() => + await ApiResponse>.OkListAsync(_service.GetTreeAsync()); + + /// 根据 ID 获取分类 + [HttpGet("{id:int}")] + public async Task> GetById(int id) + { + var dto = await _service.GetByIdAsync(id) + ?? throw new BusinessException("分类不存在", 404); + return ApiResponse.Ok(dto); + } + + /// 创建分类 + [HttpPost] + public async Task> Create([FromBody] CategoryUpsertRequest request) => + ApiResponse.Ok(await _service.CreateAsync(request)); + + /// 更新分类 + [HttpPut("{id:int}")] + public async Task> Update(int id, [FromBody] CategoryUpsertRequest request) => + ApiResponse.Ok(await _service.UpdateAsync(id, request)); + + /// 删除分类 + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + await _service.DeleteAsync(id); + return ApiResponse.Ok(); + } +} diff --git a/backend/Controllers/SearchEnginesController.cs b/backend/Controllers/SearchEnginesController.cs new file mode 100644 index 0000000..b144a3c --- /dev/null +++ b/backend/Controllers/SearchEnginesController.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using MyHomePage.Api.Common; +using MyHomePage.Api.Models.Dtos; +using MyHomePage.Api.Services; + +namespace MyHomePage.Api.Controllers; + +/// 搜索引擎管理。 +[ApiController] +[Route("api/search-engines")] +public class SearchEnginesController : ControllerBase +{ + private readonly ISearchEngineService _service; + + public SearchEnginesController(ISearchEngineService service) => _service = service; + + [HttpGet] + public async Task>> List() => + await ApiResponse>.OkListAsync(_service.ListAsync()); + + [HttpGet("{id:int}")] + public async Task> GetById(int id) + { + var dto = await _service.GetByIdAsync(id) + ?? throw new BusinessException("搜索引擎不存在", 404); + return ApiResponse.Ok(dto); + } + + [HttpPost] + public async Task> Create([FromBody] SearchEngineUpsertRequest request) => + ApiResponse.Ok(await _service.CreateAsync(request)); + + [HttpPut("{id:int}")] + public async Task> Update(int id, [FromBody] SearchEngineUpsertRequest request) => + ApiResponse.Ok(await _service.UpdateAsync(id, request)); + + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + await _service.DeleteAsync(id); + return ApiResponse.Ok(); + } + + /// 将指定 ID 的引擎设为默认(唯一) + [HttpPut("{id:int}/default")] + public async Task> SetDefault(int id) => + ApiResponse.Ok(await _service.SetDefaultAsync(id)); +} diff --git a/backend/Controllers/SettingsController.cs b/backend/Controllers/SettingsController.cs new file mode 100644 index 0000000..b03bb0f --- /dev/null +++ b/backend/Controllers/SettingsController.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Mvc; +using MyHomePage.Api.Common; +using MyHomePage.Api.Models.Dtos; +using MyHomePage.Api.Services; + +namespace MyHomePage.Api.Controllers; + +/// 用户设置:单行配置。 +[ApiController] +[Route("api/settings")] +public class SettingsController : ControllerBase +{ + private readonly ISettingService _service; + + public SettingsController(ISettingService service) => _service = service; + + [HttpGet] + public async Task> Get() => + ApiResponse.Ok(await _service.GetAsync()); + + [HttpPut] + public async Task> Update([FromBody] SettingUpdateRequest request) => + ApiResponse.Ok(await _service.UpdateAsync(request)); +} diff --git a/backend/Controllers/SyncController.cs b/backend/Controllers/SyncController.cs new file mode 100644 index 0000000..ad8963b --- /dev/null +++ b/backend/Controllers/SyncController.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Mvc; +using MyHomePage.Api.Common; +using MyHomePage.Api.Models.Dtos; +using MyHomePage.Api.Services; + +namespace MyHomePage.Api.Controllers; + +/// 多端同步:拉取增量变更 + 全量快照。 +[ApiController] +[Route("api/sync")] +public class SyncController : ControllerBase +{ + private readonly ISyncService _service; + private readonly ILogger _logger; + + public SyncController(ISyncService service, ILogger logger) + { + _service = service; + _logger = logger; + } + + /// + /// 拉取自 之后的变更。 + /// 为空或解析失败时返回全量(P34.2 防御:避免前端传 ?since=undefined 触发 400)。 + /// + [HttpGet("changes")] + public async Task> Changes([FromQuery] string? since) + { + DateTime? sinceDt = null; + if (!string.IsNullOrWhiteSpace(since)) + { + // 严格解析(RoundtripKind 接受 ISO8601 字符串);失败则降级为全量 + if (DateTime.TryParse(since, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.RoundtripKind, out var parsed)) + { + sinceDt = parsed; + } + else + { + _logger.LogWarning("Sync changes received unparseable since={Since}, fallback to full snapshot", since); + } + } + return ApiResponse.Ok(await _service.GetChangesAsync(sinceDt)); + } +} diff --git a/backend/Controllers/UploadController.cs b/backend/Controllers/UploadController.cs new file mode 100644 index 0000000..7cdc5cd --- /dev/null +++ b/backend/Controllers/UploadController.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Mvc; +using MyHomePage.Api.Common; +using MyHomePage.Api.Models.Dtos; +using MyHomePage.Api.Services; + +namespace MyHomePage.Api.Controllers; + +/// 文件上传(图片为主)。 +[ApiController] +[Route("api/upload")] +public class UploadController : ControllerBase +{ + private readonly IUploadService _service; + + public UploadController(IUploadService service) => _service = service; + + /// 单文件上传 + /// + /// Swashbuckle 6.x 不支持 [FromForm] IFormFile 自动生成 schema(会抛 SwaggerGeneratorException), + /// 这里用 [ApiExplorerSettings(IgnoreApi = true)] 让 swagger UI 跳过此端点的文档生成, + /// 实际 API 功能完全不受影响(前端 BookmarkForm 仍可正常调用)。 + /// + [HttpPost] + [ApiExplorerSettings(IgnoreApi = true)] + public async Task> Upload([FromForm] IFormFile file) + { + var result = await _service.SaveAsync(file); + return ApiResponse.Ok(result); + } +} diff --git a/backend/Controllers/UtilityController.cs b/backend/Controllers/UtilityController.cs new file mode 100644 index 0000000..627cfe3 --- /dev/null +++ b/backend/Controllers/UtilityController.cs @@ -0,0 +1,43 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc; +using MyHomePage.Api.Common; +using MyHomePage.Api.Services; + +namespace MyHomePage.Api.Controllers; + +/// 工具类 API:favicon 抓取等小工具(手动测试用 / 调试入口)。 +[ApiController] +[Route("api/utility")] +public class UtilityController : ControllerBase +{ + private readonly FaviconService _favicon; + + public UtilityController(FaviconService favicon) => _favicon = favicon; + + /// + /// P31:手动触发 favicon 抓取(不影响正常创建流程)。 + /// 任何失败(网络/404/SSRF)均返回 iconUrl=null,由调用方静默用默认。 + /// + [HttpPost("favicon")] + public async Task> FetchFavicon([FromBody] FaviconRequest request) + { + if (string.IsNullOrWhiteSpace(request.Url)) + throw new BusinessException("URL 不能为空", 400); + + var iconUrl = await _favicon.FetchAndSaveAsync(request.Url, HttpContext.RequestAborted); + return ApiResponse.Ok(new FaviconResultDto { Url = request.Url, IconUrl = iconUrl }); + } +} + +/// favicon 抓取请求 DTO +public class FaviconRequest +{ + [Required] public string Url { get; set; } = string.Empty; +} + +/// favicon 抓取结果 DTO +public class FaviconResultDto +{ + public string Url { get; set; } = string.Empty; + public string? IconUrl { get; set; } +} diff --git a/backend/Controllers/WallpaperController.cs b/backend/Controllers/WallpaperController.cs new file mode 100644 index 0000000..ae4f2d8 --- /dev/null +++ b/backend/Controllers/WallpaperController.cs @@ -0,0 +1,80 @@ +using Microsoft.AspNetCore.Mvc; +using MyHomePage.Api.Common; +using MyHomePage.Api.Models.Dtos; +using MyHomePage.Api.Services; + +namespace MyHomePage.Api.Controllers; + +/// +/// 360 在线壁纸代理(P34): +/// - GET /api/wallpaper/categories 拉全部分类(24h 缓存) +/// - GET /api/wallpaper/random 按视口分辨率返回 1 张随机图 +/// - POST /api/wallpaper/refresh 立即刷新分类池子并返回 1 张新随机图 +/// +[ApiController] +[Route("api/wallpaper")] +public class WallpaperController : ControllerBase +{ + private readonly WallpaperService _wallpaper; + private readonly ILogger _logger; + + public WallpaperController(WallpaperService wallpaper, ILogger logger) + { + _wallpaper = wallpaper; + _logger = logger; + } + + /// 全部分类列表(24h 缓存)。失败返回空集合(前端展示「暂无可用分类」)。 + [HttpGet("categories")] + public async Task>> GetCategories(CancellationToken ct) + { + var cats = await _wallpaper.GetCategoriesAsync(ct); + return ApiResponse>.Ok(cats); + } + + /// + /// 按分类 + 视口分辨率返回 1 张随机壁纸 URL。 + /// 查询参数:cid(可空,例 "36")、w(视口宽 px,默认 1920)、h(视口高 px,默认 1080)。 + /// + [HttpGet("random")] + public async Task> GetRandom( + [FromQuery] string? cid, + [FromQuery] int? w, + [FromQuery] int? h, + CancellationToken ct) + { + var (width, height) = SanitizeViewport(w, h); + var result = await _wallpaper.GetRandomAsync(cid ?? "", width, height, ct); + if (result is null) + throw new BusinessException("暂无可用壁纸(分类可能无效或 360 接口暂不可达)", 404); + return ApiResponse.Ok(result); + } + + /// + /// 立即刷新指定分类的池子(清缓存重新拉 200 张),并立即返回 1 张新随机图。 + /// 即主人要求的「立即切换」按钮后端入口。 + /// + [HttpPost("refresh")] + public async Task> Refresh( + [FromQuery] string? cid, + [FromQuery] int? w, + [FromQuery] int? h, + CancellationToken ct) + { + var (width, height) = SanitizeViewport(w, h); + var result = await _wallpaper.RefreshAsync(cid ?? "", width, height, ct); + if (result is null) + throw new BusinessException("暂无可用壁纸(分类可能无效或 360 接口暂不可达)", 404); + _logger.LogInformation("Wallpaper manual refresh: cid={Cid} {W}x{H}", cid ?? "", width, height); + return ApiResponse.Ok(result); + } + + /// 把客户端上报的 w/h 限制在合理范围(避免异常大数把 360 路径撑爆) + private static (int w, int h) SanitizeViewport(int? w, int? h) + { + // 默认 1920x1080 = PC 桌面 + var width = w is > 0 and < 8000 ? w.Value : 1920; + var height = h is > 0 and < 8000 ? h.Value : 1080; + return (width, height); + } +} diff --git a/backend/Infrastructure/Configuration/CorsOptions.cs b/backend/Infrastructure/Configuration/CorsOptions.cs new file mode 100644 index 0000000..0e5e5ed --- /dev/null +++ b/backend/Infrastructure/Configuration/CorsOptions.cs @@ -0,0 +1,10 @@ +namespace MyHomePage.Api.Infrastructure.Configuration; + +/// 跨域配置节点(对应 appsettings.json 中的 Cors) +public class CorsOptions +{ + public const string SectionName = "Cors"; + + /// 允许的来源列表 + public string[] Origins { get; set; } = Array.Empty(); +} diff --git a/backend/Infrastructure/Configuration/DatabaseOptions.cs b/backend/Infrastructure/Configuration/DatabaseOptions.cs new file mode 100644 index 0000000..9c50b80 --- /dev/null +++ b/backend/Infrastructure/Configuration/DatabaseOptions.cs @@ -0,0 +1,13 @@ +namespace MyHomePage.Api.Infrastructure.Configuration; + +/// 数据库配置节点(对应 appsettings.json 中的 Database) +public class DatabaseOptions +{ + public const string SectionName = "Database"; + + /// 数据库提供者:MySql | Sqlite + public string Provider { get; set; } = "Sqlite"; + + /// 连接字符串 + public string ConnectionString { get; set; } = "Data Source=myhomepage.db"; +} diff --git a/backend/Infrastructure/Configuration/UploadOptions.cs b/backend/Infrastructure/Configuration/UploadOptions.cs new file mode 100644 index 0000000..92c4c0d --- /dev/null +++ b/backend/Infrastructure/Configuration/UploadOptions.cs @@ -0,0 +1,16 @@ +namespace MyHomePage.Api.Infrastructure.Configuration; + +/// 文件上传配置节点(对应 appsettings.json 中的 Upload) +public class UploadOptions +{ + public const string SectionName = "Upload"; + + /// 上传文件保存目录(相对 ContentRoot 解析) + public string Path { get; set; } = "Uploads"; + + /// 前端访问上传文件时使用的基础 URL 前缀 + public string BaseUrl { get; set; } = "/uploads"; + + /// 单文件最大字节数(默认 10MB) + public long MaxSizeBytes { get; set; } = 10 * 1024 * 1024; +} diff --git a/backend/Infrastructure/Database/DatabaseInitializer.cs b/backend/Infrastructure/Database/DatabaseInitializer.cs new file mode 100644 index 0000000..dc24a2e --- /dev/null +++ b/backend/Infrastructure/Database/DatabaseInitializer.cs @@ -0,0 +1,245 @@ +using MyHomePage.Api.Models.Entities; +using SqlSugar; + +namespace MyHomePage.Api.Infrastructure.Database; + +/// +/// 数据库初始化器:CodeFirst 建表 + 种子数据。 +/// 应用启动时调用一次。 +/// +public class DatabaseInitializer +{ + private readonly SqlSugarContext _ctx; + private readonly ILogger _logger; + + public DatabaseInitializer(SqlSugarContext ctx, ILogger logger) + { + _ctx = ctx; + _logger = logger; + } + + /// 建表 + 种子数据 + public async Task InitializeAsync() + { + try + { + _logger.LogInformation("开始 CodeFirst 建表..."); + + // ===== 兼容老库:表已存在则跳过 CodeFirst InitTables(Sqlite 不支持 alter column primary key,触发表结构不一致会抛)===== + // 后续靠 MigrateSettingColumns / MigrateBookmarkColumns 给老库补列。 + const string settingsTable = "settings"; + if (_ctx.Db.DbMaintenance.IsAnyTable(settingsTable)) + { + _logger.LogInformation("检测到 settings 表已存在,跳过 CodeFirst(已通过轻量迁移补齐列)"); + } + else + { + _ctx.Db.CodeFirst.InitTables( + typeof(Category), + typeof(Bookmark), + typeof(SearchEngine), + typeof(Setting), + typeof(SyncLog) + ); + } + + // 对已存在数据库做轻量迁移:给 settings 表补上新增列(CodeFirst InitTables 不会自动 ALTER 老库) + MigrateSettingColumns(); + // 给 bookmarks 表补充 ColorBg 列(P28 链接 logo 背景色) + MigrateBookmarkColumns(); + // 给 search_engines 表补充 IconType / IconUrl / ColorBg 列(P37 引擎图标逻辑对齐链接) + MigrateSearchEngineColumns(); + // 给 settings 表补充 OpenSearchInNewTab 列(P46 搜索框行为开关) + MigrateSettingColumnsV2(); + + await SeedAsync(); + _logger.LogInformation("数据库初始化完成"); + } + catch (Exception ex) + { + _logger.LogError(ex, "数据库初始化失败"); + throw; + } + } + + /// 为 settings 表补充新列(已存在则跳过)。 + private void MigrateSettingColumns() + { + const string tableName = "settings"; + // P26.2 + if (!_ctx.Db.DbMaintenance.IsAnyColumn(tableName, "OpenLinksInNewTab")) + { + _ctx.Db.DbMaintenance.AddColumn(tableName, new DbColumnInfo + { + DbColumnName = "OpenLinksInNewTab", + DataType = "INTEGER", + IsNullable = false, + DefaultValue = "1" + }); + _logger.LogInformation("已为 settings 表补充列 {Column}", "OpenLinksInNewTab"); + } + // P34 360 壁纸模式 + if (!_ctx.Db.DbMaintenance.IsAnyColumn(tableName, "WallpaperEnabled")) + { + _ctx.Db.DbMaintenance.AddColumn(tableName, new DbColumnInfo + { + DbColumnName = "WallpaperEnabled", + DataType = "INTEGER", + IsNullable = false, + DefaultValue = "0" + }); + _logger.LogInformation("已为 settings 表补充列 {Column}", "WallpaperEnabled"); + } + if (!_ctx.Db.DbMaintenance.IsAnyColumn(tableName, "WallpaperCategoryId")) + { + _ctx.Db.DbMaintenance.AddColumn(tableName, new DbColumnInfo + { + DbColumnName = "WallpaperCategoryId", + DataType = "varchar(32)", + IsNullable = true, + DefaultValue = "" + }); + _logger.LogInformation("已为 settings 表补充列 {Column}", "WallpaperCategoryId"); + } + if (!_ctx.Db.DbMaintenance.IsAnyColumn(tableName, "WallpaperInterval")) + { + _ctx.Db.DbMaintenance.AddColumn(tableName, new DbColumnInfo + { + DbColumnName = "WallpaperInterval", + DataType = "INTEGER", + IsNullable = false, + DefaultValue = "30" + }); + _logger.LogInformation("已为 settings 表补充列 {Column}", "WallpaperInterval"); + } + } + + /// 为 bookmarks 表补充 ColorBg 列(已存在则跳过)。 + private void MigrateBookmarkColumns() + { + const string tableName = "bookmarks"; + const string newColumn = "ColorBg"; + if (!_ctx.Db.DbMaintenance.IsAnyColumn(tableName, newColumn)) + { + _ctx.Db.DbMaintenance.AddColumn(tableName, new DbColumnInfo + { + DbColumnName = newColumn, + DataType = "varchar(32)", + IsNullable = true + }); + _logger.LogInformation("已为 bookmarks 表补充列 {Column}", newColumn); + } + } + + /// 为 search_engines 表补充 IconType / IconUrl / ColorBg 列(已存在则跳过,P37 引擎图标逻辑对齐链接)。 + private void MigrateSearchEngineColumns() + { + const string tableName = "search_engines"; + AddColumnIfMissing(tableName, "IconType", "varchar(16)", isNullable: false, defaultValue: "lucide"); + AddColumnIfMissing(tableName, "IconUrl", "varchar(512)", isNullable: true); + AddColumnIfMissing(tableName, "ColorBg", "varchar(32)", isNullable: true); + } + + /// P46:给 settings 表补 OpenSearchInNewTab 列(int default 1)—— 复刻 P37/P42 的「轻量迁移」模式 + private void MigrateSettingColumnsV2() + { + const string tableName = "settings"; + // 注意:int 类型的 column 在 SqlSugar + SQLite 下要显式声明 DataType + if (!_ctx.Db.DbMaintenance.IsAnyColumn(tableName, "OpenSearchInNewTab")) + { + _ctx.Db.DbMaintenance.AddColumn(tableName, new DbColumnInfo + { + DbColumnName = "OpenSearchInNewTab", + DataType = "int", + IsNullable = false, + DefaultValue = "1" + }); + } + } + + private void AddColumnIfMissing(string tableName, string columnName, string dataType, bool isNullable, string? defaultValue = null) + { + if (_ctx.Db.DbMaintenance.IsAnyColumn(tableName, columnName)) return; + _ctx.Db.DbMaintenance.AddColumn(tableName, new DbColumnInfo + { + DbColumnName = columnName, + DataType = dataType, + IsNullable = isNullable, + DefaultValue = defaultValue + }); + _logger.LogInformation("已为 {Table} 表补充列 {Column}", tableName, columnName); + } + + /// 写入种子数据(仅当表为空时执行) + private async Task SeedAsync() + { + var db = _ctx.Db; + + // 搜索引擎种子 + if (!db.Queryable().Any()) + { + var engines = new List + { + new() { Name = "百度", UrlTemplate = "https://www.baidu.com/s?wd={q}", Icon = "search", Sort = 0, IsDefault = true, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }, + new() { Name = "Google", UrlTemplate = "https://www.google.com/search?q={q}", Icon = "search", Sort = 1, IsDefault = false, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }, + new() { Name = "Bing", UrlTemplate = "https://www.bing.com/search?q={q}", Icon = "search", Sort = 2, IsDefault = false, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow } + }; + await db.Insertable(engines).ExecuteCommandAsync(); + _logger.LogInformation("已写入搜索引擎种子数据 ({Count} 条)", engines.Count); + } + + // 设置种子(单行) + if (!db.Queryable().Any()) + { + var setting = new Setting + { + Id = 1, + ThemeMode = "dark", + AccentColor = "#6c5ce7", + BackgroundImage = "wp1", + BackgroundType = "preset", + OpenLinksInNewTab = 1, + UpdatedAt = DateTime.UtcNow + }; + await db.Insertable(setting).ExecuteCommandAsync(); + _logger.LogInformation("已写入默认设置"); + } + + // 分类 + 链接种子 + if (!db.Queryable().Any()) + { + var now = DateTime.UtcNow; + // 一级:常用工具 + var catTools = new Category + { + ParentId = 0, + Name = "常用工具", + Icon = "wrench", + Sort = 0, + CreatedAt = now, + UpdatedAt = now + }; + catTools.Id = await db.Insertable(catTools).ExecuteReturnIdentityAsync(); + var catToolsId = catTools.Id; + + // 二级分类(单独插入回填 Id,方便后续链接绑定) + var subAi = new Category { ParentId = catToolsId, Name = "AI 工具", Icon = "bot", Sort = 1, CreatedAt = now, UpdatedAt = now }; + var subDev = new Category { ParentId = catToolsId, Name = "开发工具", Icon = "code-2", Sort = 2, CreatedAt = now, UpdatedAt = now }; + subAi.Id = await db.Insertable(subAi).ExecuteReturnIdentityAsync(); + subDev.Id = await db.Insertable(subDev).ExecuteReturnIdentityAsync(); + + // 链接示例 + var bookmarks = new List + { + new() { CategoryId = subAi.Id, Title = "ChatGPT", Url = "https://chat.openai.com", Description = "AI 对话助手,智能问答", Icon = "bot", IconType = "lucide", Sort = 0, CreatedAt = now, UpdatedAt = now }, + new() { CategoryId = subAi.Id, Title = "Claude", Url = "https://claude.ai", Description = "Anthropic 推出的 AI 助手", Icon = "bot", IconType = "lucide", Sort = 1, CreatedAt = now, UpdatedAt = now }, + new() { CategoryId = subDev.Id, Title = "GitHub", Url = "https://github.com", Description = "代码托管与协作平台", Icon = "github", IconType = "lucide", Sort = 0, CreatedAt = now, UpdatedAt = now }, + new() { CategoryId = subDev.Id, Title = "MDN", Url = "https://developer.mozilla.org", Description = "Web 技术文档参考", Icon = "book", IconType = "lucide", Sort = 1, CreatedAt = now, UpdatedAt = now }, + new() { CategoryId = subDev.Id, Title = "Stack Overflow", Url = "https://stackoverflow.com", Description = "开发者问答社区", Icon = "message-circle", IconType = "lucide", Sort = 2, CreatedAt = now, UpdatedAt = now }, + new() { CategoryId = subDev.Id, Title = "VS Code", Url = "https://code.visualstudio.com", Description = "轻量级代码编辑器", Icon = "code-2", IconType = "lucide", Sort = 3, CreatedAt = now, UpdatedAt = now } + }; + await db.Insertable(bookmarks).ExecuteCommandAsync(); + _logger.LogInformation("已写入分类 / 链接种子数据"); + } + } +} diff --git a/backend/Infrastructure/Database/SqlSugarContext.cs b/backend/Infrastructure/Database/SqlSugarContext.cs new file mode 100644 index 0000000..97698f7 --- /dev/null +++ b/backend/Infrastructure/Database/SqlSugarContext.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Options; +using MyHomePage.Api.Infrastructure.Configuration; +using SqlSugar; + +namespace MyHomePage.Api.Infrastructure.Database; + +/// +/// SqlSugar 上下文(单例生命周期)。 +/// 根据 自动切换 MySQL / SQLite。 +/// +public class SqlSugarContext : IDisposable +{ + private readonly DatabaseOptions _options; + public ISqlSugarClient Db { get; } + + public SqlSugarContext(IOptions options) + { + _options = options.Value; + Db = new SqlSugarScope(BuildConnectionConfig(_options), BuildAopConfig()); + } + + /// 根据配置构建 SqlSugar 连接配置 + private static ConnectionConfig BuildConnectionConfig(DatabaseOptions options) + { + var dbType = options.Provider.Equals("MySql", StringComparison.OrdinalIgnoreCase) + ? DbType.MySql + : DbType.Sqlite; + + return new ConnectionConfig + { + ConfigId = "default", + ConnectionString = options.ConnectionString, + DbType = dbType, + IsAutoCloseConnection = true, + InitKeyType = InitKeyType.Attribute, + // SqlSugar AOP 启用默认值 + MoreSettings = new ConnMoreSettings + { + IsAutoRemoveDataCache = true + } + }; + } + + /// 配置 AOP:日志 + 性能监控 + private static Action BuildAopConfig() => db => + { + db.Aop.OnLogExecuting = (sql, parameters) => + { + // 由 Serilog / 默认 logger 接管,避免在控制台双打 + // 这里只做轻量占位,实际日志由 SqlSugarScopeClientConfiguration 注入的 logger 输出 + }; + }; + + public void Dispose() + { + Db?.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/backend/Models/Dtos/BookmarkDtos.cs b/backend/Models/Dtos/BookmarkDtos.cs new file mode 100644 index 0000000..eb1f360 --- /dev/null +++ b/backend/Models/Dtos/BookmarkDtos.cs @@ -0,0 +1,55 @@ +using MyHomePage.Api.Models.Entities; + +namespace MyHomePage.Api.Models.Dtos; + +/// 链接输出 DTO +public class BookmarkDto +{ + public int Id { get; set; } + public int CategoryId { get; set; } + public string Title { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; + public string? Description { get; set; } + public string? Icon { get; set; } + public string IconType { get; set; } = "lucide"; + public string? IconUrl { get; set; } + public string? ColorBg { get; set; } + public int Sort { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + + /// + /// 集中映射 Bookmark → BookmarkDto。BookmarkService / SyncService 共用, + /// 避免手写 new BookmarkDto { ... } 漏字段(P28 Bug 教训)。 + /// + public static BookmarkDto FromEntity(Bookmark b) => new() + { + Id = b.Id, + CategoryId = b.CategoryId, + Title = b.Title, + Url = b.Url, + Description = b.Description, + Icon = b.Icon, + IconType = b.IconType, + IconUrl = b.IconUrl, + ColorBg = b.ColorBg, + Sort = b.Sort, + CreatedAt = b.CreatedAt, + UpdatedAt = b.UpdatedAt + }; +} + +/// 链接创建/更新入参 +public class BookmarkUpsertRequest +{ + public int? Id { get; set; } + public int CategoryId { get; set; } + public string Title { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; + public string? Description { get; set; } + public string? Icon { get; set; } + public string? IconType { get; set; } + public string? IconUrl { get; set; } + public string? ColorBg { get; set; } + public int Sort { get; set; } +} diff --git a/backend/Models/Dtos/CategoryDtos.cs b/backend/Models/Dtos/CategoryDtos.cs new file mode 100644 index 0000000..743a028 --- /dev/null +++ b/backend/Models/Dtos/CategoryDtos.cs @@ -0,0 +1,82 @@ +using MyHomePage.Api.Models.Entities; + +namespace MyHomePage.Api.Models.Dtos; + +/// 分类输出 DTO(包含二级子项) +public class CategoryDto +{ + public int Id { get; set; } + public int ParentId { get; set; } + public string Name { get; set; } = string.Empty; + public string? Icon { get; set; } + public int Sort { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public List Children { get; set; } = new(); + + /// + /// 把扁平的 Category 实体集合构建为树形 DTO 列表。 + /// 规则:parentId == 0 → 顶级;其余 → 挂到对应父分类的 Children 下。 + /// 若父分类在当前集合中不存在(孤儿),降级为顶级。 + /// 子项按 Sort, Id 升序排列。 + /// + public static List BuildTree(IEnumerable entities) + { + var list = entities + .Select(c => new CategoryDto + { + Id = c.Id, + ParentId = c.ParentId, + Name = c.Name, + Icon = c.Icon, + Sort = c.Sort, + CreatedAt = c.CreatedAt, + UpdatedAt = c.UpdatedAt + }) + .ToList(); + return BuildTreeFromFlat(list); + } + + /// + /// 把扁平的 DTO 集合构建为树形 DTO 列表(按 Id 重新组织父子关系)。 + /// + public static List BuildTreeFromFlat(IEnumerable flat) + { + var all = flat.ToList(); + var byId = all.ToDictionary(d => d.Id); + var roots = new List(); + + // 重置所有 Children(防止调用方传入了预填的 Children 造成重复) + foreach (var d in all) d.Children = new List(); + + foreach (var d in all.OrderBy(x => x.Sort).ThenBy(x => x.Id)) + { + if (d.ParentId == 0 || !byId.TryGetValue(d.ParentId, out var parent)) + { + // 顶级 OR 孤儿(父分类不存在)→ 降级为顶级 + roots.Add(d); + } + else + { + parent.Children.Add(d); + } + } + + // 给每个父分类的 children 排序 + foreach (var r in roots) + { + r.Children = r.Children.OrderBy(x => x.Sort).ThenBy(x => x.Id).ToList(); + } + return roots; + } +} + +/// 分类创建/更新入参 +public class CategoryUpsertRequest +{ + public int? Id { get; set; } + public int ParentId { get; set; } + public string Name { get; set; } = string.Empty; + public string? Icon { get; set; } + public int Sort { get; set; } +} diff --git a/backend/Models/Dtos/SearchEngineDtos.cs b/backend/Models/Dtos/SearchEngineDtos.cs new file mode 100644 index 0000000..0df601f --- /dev/null +++ b/backend/Models/Dtos/SearchEngineDtos.cs @@ -0,0 +1,49 @@ +using MyHomePage.Api.Models.Entities; + +namespace MyHomePage.Api.Models.Dtos; + +/// 搜索引擎输出 DTO +public class SearchEngineDto +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string UrlTemplate { get; set; } = string.Empty; + public string IconType { get; set; } = "lucide"; + public string? Icon { get; set; } + public string? IconUrl { get; set; } + public string? ColorBg { get; set; } + public int Sort { get; set; } + public bool IsDefault { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + + /// 从实体映射(中心化转换,防止漏字段,与 BookmarkDto.FromEntity 对齐) + public static SearchEngineDto FromEntity(SearchEngine e) => new() + { + Id = e.Id, + Name = e.Name, + UrlTemplate = e.UrlTemplate, + IconType = e.IconType, + Icon = e.Icon, + IconUrl = e.IconUrl, + ColorBg = e.ColorBg, + Sort = e.Sort, + IsDefault = e.IsDefault, + CreatedAt = e.CreatedAt, + UpdatedAt = e.UpdatedAt + }; +} + +/// 搜索引擎创建/更新入参 +public class SearchEngineUpsertRequest +{ + public int? Id { get; set; } + public string Name { get; set; } = string.Empty; + public string UrlTemplate { get; set; } = string.Empty; + public string IconType { get; set; } = "lucide"; + public string? Icon { get; set; } + public string? IconUrl { get; set; } + public string? ColorBg { get; set; } + public int Sort { get; set; } + public bool IsDefault { get; set; } +} diff --git a/backend/Models/Dtos/SettingDtos.cs b/backend/Models/Dtos/SettingDtos.cs new file mode 100644 index 0000000..fadf5a7 --- /dev/null +++ b/backend/Models/Dtos/SettingDtos.cs @@ -0,0 +1,59 @@ +using MyHomePage.Api.Models.Entities; + +namespace MyHomePage.Api.Models.Dtos; + +/// 设置输出 DTO +public class SettingDto +{ + public string ThemeMode { get; set; } = "dark"; + public string AccentColor { get; set; } = "#6c5ce7"; + public string? BackgroundImage { get; set; } + public string BackgroundType { get; set; } = "preset"; + public bool OpenLinksInNewTab { get; set; } = true; + + /// 搜索框行为(P46):true = 搜索结果在新选项卡打开(默认);false = 当前选项卡打开 + public bool OpenSearchInNewTab { get; set; } = true; + + // ===== P34 360 在线壁纸模式 ===== + /// 是否启用 360 在线壁纸(按分类随机 + 定时切换) + public bool WallpaperEnabled { get; set; } = false; + /// 360 壁纸分类 ID,空字符串 = 全部/推荐 + public string WallpaperCategoryId { get; set; } = ""; + /// 自动切换间隔(分钟),0 = 不自动切换 + public int WallpaperInterval { get; set; } = 30; + + public DateTime UpdatedAt { get; set; } + + /// 从实体构造 DTO,统一所有 Controller / Service 的转换逻辑,避免漏字段。 + public static SettingDto FromEntity(Setting s) => new() + { + ThemeMode = s.ThemeMode, + AccentColor = s.AccentColor, + BackgroundImage = s.BackgroundImage, + BackgroundType = s.BackgroundType, + OpenLinksInNewTab = s.OpenLinksInNewTab != 0, + OpenSearchInNewTab = s.OpenSearchInNewTab != 0, + WallpaperEnabled = s.WallpaperEnabled != 0, + WallpaperCategoryId = s.WallpaperCategoryId ?? "", + WallpaperInterval = s.WallpaperInterval, + UpdatedAt = s.UpdatedAt + }; +} + +/// 设置更新入参(全部可选) +public class SettingUpdateRequest +{ + public string? ThemeMode { get; set; } + public string? AccentColor { get; set; } + public string? BackgroundImage { get; set; } + public string? BackgroundType { get; set; } + public bool? OpenLinksInNewTab { get; set; } + + /// 搜索框行为(P46):true = 搜索结果在新选项卡打开(默认);false = 当前选项卡打开 + public bool? OpenSearchInNewTab { get; set; } + + // ===== P34 360 在线壁纸 ===== + public bool? WallpaperEnabled { get; set; } + public string? WallpaperCategoryId { get; set; } + public int? WallpaperInterval { get; set; } +} diff --git a/backend/Models/Dtos/SyncDtos.cs b/backend/Models/Dtos/SyncDtos.cs new file mode 100644 index 0000000..676f177 --- /dev/null +++ b/backend/Models/Dtos/SyncDtos.cs @@ -0,0 +1,32 @@ +namespace MyHomePage.Api.Models.Dtos; + +/// 同步单条记录 +public class SyncChangeDto +{ + public string EntityType { get; set; } = string.Empty; + public int EntityId { get; set; } + public string Operation { get; set; } = "update"; + public DateTime Timestamp { get; set; } +} + +/// 同步响应 +public class SyncChangesResponse +{ + /// 本次拉取的变更记录 + public List Changes { get; set; } = new(); + + /// 全量最新数据快照(按实体类型分组) + public SyncSnapshot Snapshot { get; set; } = new(); + + /// 服务器当前时间(用作下次 since) + public DateTime ServerTime { get; set; } = DateTime.UtcNow; +} + +/// 全量快照 +public class SyncSnapshot +{ + public List Categories { get; set; } = new(); + public List Bookmarks { get; set; } = new(); + public List SearchEngines { get; set; } = new(); + public SettingDto? Settings { get; set; } +} diff --git a/backend/Models/Dtos/UploadDtos.cs b/backend/Models/Dtos/UploadDtos.cs new file mode 100644 index 0000000..5889ff0 --- /dev/null +++ b/backend/Models/Dtos/UploadDtos.cs @@ -0,0 +1,17 @@ +namespace MyHomePage.Api.Models.Dtos; + +/// 文件上传结果 +public class UploadResultDto +{ + /// 相对 BaseUrl 的子路径(如 2026/07/04/abc.png) + public string Path { get; set; } = string.Empty; + + /// 前端可直接访问的完整 URL + public string Url { get; set; } = string.Empty; + + /// 原始文件名 + public string FileName { get; set; } = string.Empty; + + /// 文件大小(字节) + public long Size { get; set; } +} diff --git a/backend/Models/Dtos/WallpaperDtos.cs b/backend/Models/Dtos/WallpaperDtos.cs new file mode 100644 index 0000000..d94a3cf --- /dev/null +++ b/backend/Models/Dtos/WallpaperDtos.cs @@ -0,0 +1,29 @@ +namespace MyHomePage.Api.Models.Dtos; + +/// 360 壁纸分类(P34) +public class WallpaperCategoryDto +{ + /// 分类 ID(字符串,例:"36") + public string Id { get; set; } = string.Empty; + /// 分类名(中文,例:"4K专区") + public string Name { get; set; } = string.Empty; + /// 排序权重(P34.1 主人反馈:360 接口真实数据有此字段,降序展示) + public int OrderNum { get; set; } +} + +/// 随机壁纸返回结果(P34) +public class WallpaperRandomDto +{ + /// 最终 URL(命中 360 预设分辨率的 img_* 字段,或兜底 RewriteUrl 自构) + public string Url { get; set; } = string.Empty; + /// 360 接口原始 url(bdr/__85 画质低的版本,调试用) + public string OriginalUrl { get; set; } = string.Empty; + /// 请求的视口宽度(px) + public int Width { get; set; } + /// 请求的视口高度(px) + public int Height { get; set; } + /// 命中的 360 预设分辨率(形如 "1600x900");未命中为 null(走 RewriteUrl 兜底) + public string? Preset { get; set; } + /// 是否走 RewriteUrl 兜底(true = preset 没命中) + public bool UsedFallback { get; set; } +} diff --git a/backend/Models/Entities/BaseEntity.cs b/backend/Models/Entities/BaseEntity.cs new file mode 100644 index 0000000..0a81502 --- /dev/null +++ b/backend/Models/Entities/BaseEntity.cs @@ -0,0 +1,19 @@ +using SqlSugar; + +namespace MyHomePage.Api.Models.Entities; + +/// 实体基类:所有业务表都包含主键 + 时间戳 +public abstract class BaseEntity +{ + /// 主键(自增,使用 int 以兼容 SQLite + MySQL) + [SugarColumn(IsPrimaryKey = true, IsIdentity = true)] + public int Id { get; set; } + + /// 创建时间(UTC) + [SugarColumn(IsNullable = false)] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// 更新时间(UTC) + [SugarColumn(IsNullable = false)] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/backend/Models/Entities/Bookmark.cs b/backend/Models/Entities/Bookmark.cs new file mode 100644 index 0000000..405a205 --- /dev/null +++ b/backend/Models/Entities/Bookmark.cs @@ -0,0 +1,48 @@ +using SqlSugar; + +namespace MyHomePage.Api.Models.Entities; + +/// 链接收藏 +[SugarTable("bookmarks")] +public class Bookmark : BaseEntity +{ + /// 所属分类 ID + [SugarColumn(IsNullable = false, IndexGroupNameList = new[] { "idx_category" })] + public int CategoryId { get; set; } + + /// 链接标题 + [SugarColumn(Length = 128, IsNullable = false)] + public string Title { get; set; } = string.Empty; + + /// 链接 URL + [SugarColumn(Length = 512, IsNullable = false)] + public string Url { get; set; } = string.Empty; + + /// 简介 + [SugarColumn(Length = 512, IsNullable = true)] + public string? Description { get; set; } + + /// 图标标识:lucide 名 / emoji / 自定义 key + [SugarColumn(Length = 64, IsNullable = true)] + public string? Icon { get; set; } + + /// 图标类型:lucide | emoji | image + [SugarColumn(Length = 16, IsNullable = true, DefaultValue = "lucide")] + public string IconType { get; set; } = "lucide"; + + /// 图标 URL(IconType = image 时使用) + [SugarColumn(Length = 512, IsNullable = true)] + public string? IconUrl { get; set; } + + /// logo 背景色(#hex / rgb / hsl);null = 自适应(由前端从 url / iconUrl 推断) + [SugarColumn(Length = 32, IsNullable = true)] + public string? ColorBg { get; set; } + + /// 排序值 + [SugarColumn(DefaultValue = "0")] + public int Sort { get; set; } + + /// 软删标记 + [SugarColumn(DefaultValue = "0")] + public bool IsDeleted { get; set; } +} diff --git a/backend/Models/Entities/Category.cs b/backend/Models/Entities/Category.cs new file mode 100644 index 0000000..b726fd0 --- /dev/null +++ b/backend/Models/Entities/Category.cs @@ -0,0 +1,24 @@ +using SqlSugar; + +namespace MyHomePage.Api.Models.Entities; + +/// 分类(二级树形,ParentId 为 0 表示一级) +[SugarTable("categories")] +public class Category : BaseEntity +{ + /// 父分类 ID;一级分类为 0 + [SugarColumn(DefaultValue = "0")] + public int ParentId { get; set; } + + /// 分类名称 + [SugarColumn(Length = 64, IsNullable = false)] + public string Name { get; set; } = string.Empty; + + /// lucide 图标名 + [SugarColumn(Length = 64, IsNullable = true)] + public string? Icon { get; set; } + + /// 排序值,越小越靠前 + [SugarColumn(DefaultValue = "0")] + public int Sort { get; set; } +} diff --git a/backend/Models/Entities/SearchEngine.cs b/backend/Models/Entities/SearchEngine.cs new file mode 100644 index 0000000..ce04362 --- /dev/null +++ b/backend/Models/Entities/SearchEngine.cs @@ -0,0 +1,40 @@ +using SqlSugar; + +namespace MyHomePage.Api.Models.Entities; + +/// 搜索引擎 +[SugarTable("search_engines")] +public class SearchEngine : BaseEntity +{ + /// 展示名 + [SugarColumn(Length = 64, IsNullable = false)] + public string Name { get; set; } = string.Empty; + + /// URL 模板,必须包含 {q} 占位符 + [SugarColumn(Length = 512, IsNullable = false)] + public string UrlTemplate { get; set; } = string.Empty; + + /// 图标类型:lucide / image / emoji(与 Bookmark.IconType 对齐) + [SugarColumn(Length = 16, IsNullable = false, DefaultValue = "lucide")] + public string IconType { get; set; } = "lucide"; + + /// 图标内容:lucide 名 / emoji 字符(IconType=lucide/emoji 时使用) + [SugarColumn(Length = 64, IsNullable = true)] + public string? Icon { get; set; } + + /// 图标图片 URL(IconType=image 时使用) + [SugarColumn(Length = 512, IsNullable = true)] + public string? IconUrl { get; set; } + + /// logo 背景色(#hex / rgb / hsl);null = 自适应(与 Bookmark.ColorBg 对齐) + [SugarColumn(Length = 32, IsNullable = true)] + public string? ColorBg { get; set; } + + /// 排序值 + [SugarColumn(DefaultValue = "0")] + public int Sort { get; set; } + + /// 是否默认引擎(应用层保证唯一) + [SugarColumn(DefaultValue = "0")] + public bool IsDefault { get; set; } +} diff --git a/backend/Models/Entities/Setting.cs b/backend/Models/Entities/Setting.cs new file mode 100644 index 0000000..0c32f37 --- /dev/null +++ b/backend/Models/Entities/Setting.cs @@ -0,0 +1,44 @@ +using SqlSugar; + +namespace MyHomePage.Api.Models.Entities; + +/// 用户设置(单行记录,Id 固定为 1) +[SugarTable("settings")] +public class Setting : BaseEntity +{ + /// 主题模式:dark | light | auto + [SugarColumn(Length = 16, IsNullable = false, DefaultValue = "dark")] + public string ThemeMode { get; set; } = "dark"; + + /// 主色调(HEX 字符串) + [SugarColumn(Length = 16, IsNullable = false, DefaultValue = "#6c5ce7")] + public string AccentColor { get; set; } = "#6c5ce7"; + + /// 背景图:预设 key(wp1..wp6)或自定义 URL + [SugarColumn(Length = 512, IsNullable = true, DefaultValue = "wp1")] + public string? BackgroundImage { get; set; } + + /// 背景类型:preset | custom | solid + [SugarColumn(Length = 16, IsNullable = false, DefaultValue = "preset")] + public string BackgroundType { get; set; } = "preset"; + + /// 链接打开方式:1 = 新选项卡(默认);0 = 当前选项卡。底层用 int 存储以兼容 SqlSugar + SQLite。 + [SugarColumn(IsNullable = false, DefaultValue = "1")] + public int OpenLinksInNewTab { get; set; } = 1; + + /// 搜索框行为:1 = 搜索结果在新选项卡打开(默认);0 = 当前选项卡打开(P46)。 + [SugarColumn(IsNullable = false, DefaultValue = "1")] + public int OpenSearchInNewTab { get; set; } = 1; + + /// 是否启用 360 在线壁纸模式(P34):0 = 关闭(默认,使用预设/自定义背景),1 = 开启(按分类随机 + 定时切换) + [SugarColumn(IsNullable = false, DefaultValue = "0")] + public int WallpaperEnabled { get; set; } = 0; + + /// 360 壁纸分类 ID(P34),例如 "36"。空字符串表示「全部/推荐」。 + [SugarColumn(Length = 32, IsNullable = true, DefaultValue = "")] + public string? WallpaperCategoryId { get; set; } = ""; + + /// 壁纸自动切换间隔(分钟,P34)。默认 30。0 表示不自动切换,仅手动触发立即切换按钮。 + [SugarColumn(IsNullable = false, DefaultValue = "30")] + public int WallpaperInterval { get; set; } = 30; +} diff --git a/backend/Models/Entities/SyncLog.cs b/backend/Models/Entities/SyncLog.cs new file mode 100644 index 0000000..8939295 --- /dev/null +++ b/backend/Models/Entities/SyncLog.cs @@ -0,0 +1,27 @@ +using SqlSugar; + +namespace MyHomePage.Api.Models.Entities; + +/// 同步日志:每次增删改都写一条,前端通过 since=timestamp 拉取增量 +[SugarTable("sync_log")] +public class SyncLog +{ + [SugarColumn(IsPrimaryKey = true, IsIdentity = true)] + public int Id { get; set; } + + /// 实体类型:category | bookmark | search_engine | setting + [SugarColumn(Length = 32, IsNullable = false, IndexGroupNameList = new[] { "idx_synclog_type" })] + public string EntityType { get; set; } = string.Empty; + + /// 实体 ID + [SugarColumn(IsNullable = false, IndexGroupNameList = new[] { "idx_synclog_type" })] + public int EntityId { get; set; } + + /// 操作:create | update | delete + [SugarColumn(Length = 16, IsNullable = false)] + public string Operation { get; set; } = "update"; + + /// 变更时间(UTC) + [SugarColumn(IsNullable = false)] + public DateTime Timestamp { get; set; } = DateTime.UtcNow; +} diff --git a/backend/MyHomePage.Api.csproj b/backend/MyHomePage.Api.csproj new file mode 100644 index 0000000..3451eb7 --- /dev/null +++ b/backend/MyHomePage.Api.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + enable + MyHomePage.Api + MyHomePage.Api + false + true + $(NoWarn);1591 + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/backend/Program.cs b/backend/Program.cs new file mode 100644 index 0000000..8c1e2e0 --- /dev/null +++ b/backend/Program.cs @@ -0,0 +1,140 @@ +using Microsoft.Extensions.Options; +using MyHomePage.Api.Common; +using MyHomePage.Api.Infrastructure.Configuration; +using MyHomePage.Api.Infrastructure.Database; +using MyHomePage.Api.Services; +using SqlSugar; + +var builder = WebApplication.CreateBuilder(args); + +// ===== 配置节点绑定 ===== +builder.Services.Configure(builder.Configuration.GetSection(DatabaseOptions.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(UploadOptions.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(CorsOptions.SectionName)); + +// ===== 控制器 ===== +builder.Services.AddControllers() + .AddJsonOptions(o => + { + // 前端约定使用 camelCase + o.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; + o.JsonSerializerOptions.DictionaryKeyPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; + }); + +// ===== Swagger / OpenAPI ===== +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new() { Title = "MyHomePage API", Version = "v1" }); + // 注意:API 全部返回 ApiResponse,类型签名会进 swagger + c.CustomOperationIds(apiDesc => + { + // Controller Action 有 action 键,minimal API(MapGet / MapPost 等)没有 + // 用 TryGetValue 防御,避免 KeyNotFoundException 启动崩 + var routeValues = apiDesc.ActionDescriptor.RouteValues; + if (routeValues.TryGetValue("action", out var action) + && !string.IsNullOrEmpty(action) + && routeValues.TryGetValue("controller", out var controller) + && !string.IsNullOrEmpty(controller)) + { + return $"{controller}_{action}"; + } + // minimal API 兜底:用路由模板做 operationId(去掉 / 和特殊字符) + // 例:/health → health;/api/wallpaper/random → api_wallpaper_random + return apiDesc.RelativePath? + .Replace("/", "_", StringComparison.Ordinal) + .Trim('_') + ?? "UnknownEndpoint"; + }); +}); + +// ===== CORS ===== +var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get() ?? Array.Empty(); +builder.Services.AddCors(o => o.AddDefaultPolicy(p => +{ + if (corsOrigins.Length > 0) + p.WithOrigins(corsOrigins).AllowAnyHeader().AllowAnyMethod().AllowCredentials(); + else + p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod(); +})); + +// ===== SqlSugar 单例 ===== +builder.Services.AddSingleton(); + +// ===== 服务依赖注入 ===== +builder.Services.AddScoped(); +builder.Services.AddScoped(sp => sp.GetRequiredService().Db); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// ===== P31:favicon 自动抓取 ===== +builder.Services.AddMemoryCache(); // IMemoryCache(24h 缓存已抓 favicon,Singleton) +builder.Services.AddHttpClient(nameof(FaviconService), c => // 命名 HttpClient(IHttpClientFactory 管理生命周期) +{ + c.Timeout = TimeSpan.FromSeconds(5); // 全局 5s 超时(个别 GET 还会二次限制) + c.DefaultRequestHeaders.Add("User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"); +}); +builder.Services.AddScoped(); // 注入 IHttpClientFactory + IMemoryCache + +// ===== P34:360 在线壁纸代理 ===== +builder.Services.AddHttpClient(nameof(WallpaperService), c => +{ + c.Timeout = TimeSpan.FromSeconds(10); // 拉分类 / 200 张池子给 10s 充裕 + c.DefaultRequestHeaders.Add("User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"); +}); +builder.Services.AddScoped(); + +// ===== 上传文件大小限制 ===== +var maxUpload = builder.Configuration.GetValue("Upload:MaxSizeBytes") ?? 10L * 1024 * 1024; +builder.Services.Configure(o => +{ + o.MultipartBodyLengthLimit = maxUpload; +}); +builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = maxUpload); + +var app = builder.Build(); + +// ===== 启动时初始化数据库(CodeFirst + 种子) ===== +using (var scope = app.Services.CreateScope()) +{ + var initializer = scope.ServiceProvider.GetRequiredService(); + await initializer.InitializeAsync(); +} + +// ===== HTTP Pipeline ===== +app.UseMiddleware(); + +// 静态文件:暴露 Upload 目录 +var uploadPath = Path.IsPathRooted(app.Configuration["Upload:Path"] ?? "Uploads") + ? app.Configuration["Upload:Path"]! + : Path.Combine(app.Environment.ContentRootPath, app.Configuration["Upload:Path"] ?? "Uploads"); +Directory.CreateDirectory(uploadPath); +app.UseStaticFiles(new Microsoft.AspNetCore.Builder.StaticFileOptions +{ + FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(uploadPath), + RequestPath = app.Configuration["Upload:BaseUrl"] ?? "/uploads", + ServeUnknownFileTypes = true +}); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseCors(); +app.UseAuthorization(); +app.MapControllers(); + +// 根路径健康检查 +app.MapGet("/", () => Results.Ok(new { name = "MyHomePage API", version = "1.0.0", status = "ok" })); +app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTimeOffset.UtcNow })); + +app.Run(); diff --git a/backend/Properties/PublishProfiles/FolderProfile.pubxml b/backend/Properties/PublishProfiles/FolderProfile.pubxml new file mode 100644 index 0000000..a0b0416 --- /dev/null +++ b/backend/Properties/PublishProfiles/FolderProfile.pubxml @@ -0,0 +1,20 @@ + + + + + false + false + true + Release + Any CPU + FileSystem + bin\Release\net8.0\publish\ + FileSystem + <_TargetId>Folder + + net8.0 + linux-x64 + 55a8f953-c4cd-4c72-3c4b-a1fc5ba1847b + true + + \ No newline at end of file diff --git a/backend/Properties/launchSettings.json b/backend/Properties/launchSettings.json new file mode 100644 index 0000000..543069b --- /dev/null +++ b/backend/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/backend/Repositories/BaseRepository.cs b/backend/Repositories/BaseRepository.cs new file mode 100644 index 0000000..e65ef13 --- /dev/null +++ b/backend/Repositories/BaseRepository.cs @@ -0,0 +1,44 @@ +using System.Linq.Expressions; +using MyHomePage.Api.Infrastructure.Database; +using SqlSugar; + +namespace MyHomePage.Api.Repositories; + +/// 通用仓储接口:覆盖最常用的 CRUD 能力。 +/// 实体类型 +public interface IBaseRepository where T : class, new() +{ + Task GetByIdAsync(int id); + Task> ListAsync(Expression>? where = null); + Task InsertAsync(T entity); + Task UpdateAsync(T entity); + Task DeleteAsync(int id); +} + +/// 通用仓储实现:直接包装 SqlSugar 客户端。 +public class BaseRepository : IBaseRepository where T : class, new() +{ + protected readonly ISqlSugarClient Db; + + public BaseRepository(SqlSugarContext ctx) + { + Db = ctx.Db; + } + + public Task GetByIdAsync(int id) => + Db.Queryable().InSingleAsync(id); + + public Task> ListAsync(Expression>? where = null) => + where is null + ? Db.Queryable().ToListAsync() + : Db.Queryable().Where(where).ToListAsync(); + + public Task InsertAsync(T entity) => + Db.Insertable(entity).ExecuteReturnIdentityAsync(); + + public Task UpdateAsync(T entity) => + Db.Updateable(entity).ExecuteCommandAsync(); + + public Task DeleteAsync(int id) => + Db.Deleteable().In(id).ExecuteCommandAsync(); +} diff --git a/backend/Services/BookmarkService.cs b/backend/Services/BookmarkService.cs new file mode 100644 index 0000000..722e015 --- /dev/null +++ b/backend/Services/BookmarkService.cs @@ -0,0 +1,191 @@ +using MyHomePage.Api.Common; +using MyHomePage.Api.Models.Dtos; +using MyHomePage.Api.Models.Entities; +using SqlSugar; + +namespace MyHomePage.Api.Services; + +/// +public class BookmarkService : IBookmarkService +{ + /// 判定「用户未指定图标」的默认值集合。匹配其中之一则视为未指定,触发 favicon 自动抓取。 + private static readonly HashSet DefaultIconNames = new(StringComparer.OrdinalIgnoreCase) + { + "link", "globe", "bookmark", "", null! // null/empty 也算未指定 + }; + + private readonly ISqlSugarClient _db; + private readonly SyncLogHelper _sync; + private readonly FaviconService _favicon; + + public BookmarkService(ISqlSugarClient db, SyncLogHelper sync, FaviconService favicon) + { + _db = db; + _sync = sync; + _favicon = favicon; + } + + /// + public async Task> ListAsync(int? categoryId = null) + { + var query = _db.Queryable().Where(b => !b.IsDeleted); + if (categoryId.HasValue) + query = query.Where(b => b.CategoryId == categoryId.Value); + + var list = await query + .OrderBy(b => b.Sort) + .OrderBy(b => b.Id) + .ToListAsync(); + + return list.Select(ToDto).ToList(); + } + + /// + public async Task GetByIdAsync(int id) + { + var b = await _db.Queryable().InSingleAsync(id); + return b is null || b.IsDeleted ? null : ToDto(b); + } + + /// + public async Task CreateAsync(BookmarkUpsertRequest request) + { + Validate(request); + // 校验分类存在 + var catExists = await _db.Queryable().AnyAsync(c => c.Id == request.CategoryId); + if (!catExists) throw new BusinessException("分类不存在", 400); + + var now = DateTime.UtcNow; + var entity = new Bookmark + { + CategoryId = request.CategoryId, + Title = request.Title.Trim(), + Url = request.Url.Trim(), + Description = request.Description?.Trim(), + Icon = request.Icon, + IconType = request.IconType ?? "lucide", + IconUrl = request.IconUrl, + ColorBg = NormalizeColor(request.ColorBg), + Sort = request.Sort, + IsDeleted = false, + CreatedAt = now, + UpdatedAt = now + }; + entity.Id = await _db.Insertable(entity).ExecuteReturnIdentityAsync(); + + // P31:未指定图标时自动抓取网站 favicon(失败静默用默认,不影响主记录创建) + await MaybeFetchFaviconAsync(entity); + + await _sync.WriteAsync("bookmark", entity.Id, "create"); + return ToDto(entity); + } + + /// + public async Task UpdateAsync(int id, BookmarkUpsertRequest request) + { + Validate(request); + var entity = await _db.Queryable().InSingleAsync(id) + ?? throw new BusinessException("链接不存在", 404); + + entity.CategoryId = request.CategoryId; + entity.Title = request.Title.Trim(); + entity.Url = request.Url.Trim(); + entity.Description = request.Description?.Trim(); + entity.Icon = request.Icon; + entity.IconType = request.IconType ?? "lucide"; + entity.IconUrl = request.IconUrl; + entity.ColorBg = NormalizeColor(request.ColorBg); // P28 修复:原代码漏了 ColorBg,导致 PUT 后仍是旧值 + entity.Sort = request.Sort; + entity.UpdatedAt = DateTime.UtcNow; + + await _db.Updateable(entity).ExecuteCommandAsync(); + + // P31:URL 变了 或 图标从「自定义」回到「未指定」时,重新触发 favicon 抓取 + if (IsIconUnspecified(entity)) + { + await MaybeFetchFaviconAsync(entity); + } + + await _sync.WriteAsync("bookmark", id, "update"); + return ToDto(entity); + } + + /// + /// P31:判定链接是否「未指定图标」(即需要自动抓 favicon 的状态): + /// - iconUrl 为空(用户没上传图片) + /// - iconType 为 lucide 或 null(即非 image / 非 emoji) + /// - icon 字段是默认值("link" / "globe" / "bookmark" / 空) + /// + private static bool IsIconUnspecified(Bookmark b) + { + if (!string.IsNullOrEmpty(b.IconUrl)) return false; + if (b.IconType == "image" || b.IconType == "emoji") return false; + var name = (b.Icon ?? "").Trim(); + return DefaultIconNames.Contains(name); + } + + /// + /// P31:抓取并写入 favicon。失败静默(不影响主流程)。 + /// 成功后:entity.IconType = "favicon",entity.IconUrl = /uploads/yyyy/MM/dd/favicons/xxx.ext + /// + private async Task MaybeFetchFaviconAsync(Bookmark entity) + { + if (!IsIconUnspecified(entity)) return; + try + { + var iconUrl = await _favicon.FetchAndSaveAsync(entity.Url); + if (!string.IsNullOrEmpty(iconUrl)) + { + entity.IconType = "favicon"; + entity.IconUrl = iconUrl; + entity.Icon = null; + entity.UpdatedAt = DateTime.UtcNow; + await _db.Updateable(entity) + .UpdateColumns(it => new { it.IconType, it.IconUrl, it.Icon, it.UpdatedAt }) + .ExecuteCommandAsync(); + } + } + catch + { + // 静默吞掉异常(favicon 抓取失败不影响链接创建/更新) + } + } + + /// + public async Task DeleteAsync(int id) + { + var entity = await _db.Queryable().InSingleAsync(id) + ?? throw new BusinessException("链接不存在", 404); + + entity.IsDeleted = true; + entity.UpdatedAt = DateTime.UtcNow; + await _db.Updateable(entity).ExecuteCommandAsync(); + await _sync.WriteAsync("bookmark", id, "delete"); + } + + private static void Validate(BookmarkUpsertRequest req) + { + if (string.IsNullOrWhiteSpace(req.Title)) throw new BusinessException("标题不能为空", 400); + if (string.IsNullOrWhiteSpace(req.Url)) throw new BusinessException("URL 不能为空", 400); + if (req.Title.Length > 128) throw new BusinessException("标题不能超过 128 字符", 400); + if (req.Url.Length > 512) throw new BusinessException("URL 过长", 400); + if (!Uri.TryCreate(req.Url, UriKind.Absolute, out _)) throw new BusinessException("URL 格式不正确", 400); + } + + private static BookmarkDto ToDto(Bookmark b) => BookmarkDto.FromEntity(b); + + /// + /// 规范化颜色:空串视为 null;仅保留 #hex / rgb(...) / hsl(...) 格式。无效则置 null。 + /// 长度上限 32(够 rgb / hsl / 短 hex / 长 hex)。 + /// + private static string? NormalizeColor(string? color) + { + if (string.IsNullOrWhiteSpace(color)) return null; + var c = color.Trim(); + if (c.Length > 32) return null; + if (c.StartsWith('#') && (c.Length == 4 || c.Length == 7 || c.Length == 9)) return c; + if (c.StartsWith("rgb", StringComparison.OrdinalIgnoreCase) && c.EndsWith(')')) return c; + if (c.StartsWith("hsl", StringComparison.OrdinalIgnoreCase) && c.EndsWith(')')) return c; + return null; + } +} diff --git a/backend/Services/CategoryService.cs b/backend/Services/CategoryService.cs new file mode 100644 index 0000000..204d318 --- /dev/null +++ b/backend/Services/CategoryService.cs @@ -0,0 +1,111 @@ +using MyHomePage.Api.Common; +using MyHomePage.Api.Models.Dtos; +using MyHomePage.Api.Models.Entities; +using SqlSugar; + +namespace MyHomePage.Api.Services; + +/// +public class CategoryService : ICategoryService +{ + private readonly ISqlSugarClient _db; + private readonly SyncLogHelper _sync; + + public CategoryService(ISqlSugarClient db, SyncLogHelper sync) + { + _db = db; + _sync = sync; + } + + /// + public async Task> GetTreeAsync() + { + var all = await _db.Queryable() + .OrderBy(c => c.Sort) + .OrderBy(c => c.Id) + .ToListAsync(); + + return CategoryDto.BuildTree(all); + } + + /// + public async Task GetByIdAsync(int id) + { + var entity = await _db.Queryable().InSingleAsync(id); + return entity is null ? null : ToDto(entity); + } + + /// + public async Task CreateAsync(CategoryUpsertRequest request) + { + Validate(request); + var now = DateTime.UtcNow; + var entity = new Category + { + ParentId = request.ParentId, + Name = request.Name.Trim(), + Icon = request.Icon, + Sort = request.Sort, + CreatedAt = now, + UpdatedAt = now + }; + entity.Id = await _db.Insertable(entity).ExecuteReturnIdentityAsync(); + await _sync.WriteAsync("category", entity.Id, "create"); + return ToDto(entity); + } + + /// + public async Task UpdateAsync(int id, CategoryUpsertRequest request) + { + Validate(request); + var entity = await _db.Queryable().InSingleAsync(id) + ?? throw new BusinessException("分类不存在", 404); + + entity.ParentId = request.ParentId; + entity.Name = request.Name.Trim(); + entity.Icon = request.Icon; + entity.Sort = request.Sort; + entity.UpdatedAt = DateTime.UtcNow; + + await _db.Updateable(entity).ExecuteCommandAsync(); + await _sync.WriteAsync("category", id, "update"); + return ToDto(entity); + } + + /// + public async Task DeleteAsync(int id) + { + var entity = await _db.Queryable().InSingleAsync(id) + ?? throw new BusinessException("分类不存在", 404); + + // 如果是父分类,先检查是否有子分类 / 链接 + if (entity.ParentId == 0) + { + var hasChildren = await _db.Queryable().AnyAsync(c => c.ParentId == id); + if (hasChildren) throw new BusinessException("请先删除子分类", 400); + } + var hasBookmarks = await _db.Queryable().AnyAsync(b => b.CategoryId == id && !b.IsDeleted); + if (hasBookmarks) throw new BusinessException("该分类下仍有链接,请先删除链接", 400); + + await _db.Deleteable(id).ExecuteCommandAsync(); + await _sync.WriteAsync("category", id, "delete"); + } + + /// 校验入参 + private static void Validate(CategoryUpsertRequest req) + { + if (string.IsNullOrWhiteSpace(req.Name)) throw new BusinessException("分类名称不能为空", 400); + if (req.Name.Length > 64) throw new BusinessException("分类名称不能超过 64 字符", 400); + } + + private static CategoryDto ToDto(Category c) => new() + { + Id = c.Id, + ParentId = c.ParentId, + Name = c.Name, + Icon = c.Icon, + Sort = c.Sort, + CreatedAt = c.CreatedAt, + UpdatedAt = c.UpdatedAt + }; +} diff --git a/backend/Services/FaviconService.cs b/backend/Services/FaviconService.cs new file mode 100644 index 0000000..2385c29 --- /dev/null +++ b/backend/Services/FaviconService.cs @@ -0,0 +1,454 @@ +using System.Net; +using System.Net.Sockets; +using System.Text.RegularExpressions; +using System.Web; +using Microsoft.Extensions.Caching.Memory; +using MyHomePage.Api.Common; +using MyHomePage.Api.Infrastructure.Configuration; +using Microsoft.Extensions.Options; + +namespace MyHomePage.Api.Services; + +/// +/// 自动抓取网站 favicon。 +/// P31 主链路:BookmarkService.Create/Update 检测「未指定图标」时调用本服务: +/// 1. HTTP GET 目标页面(限制 5s / 1MB,User-Agent 模拟浏览器) +/// 2. 解析 HTML <link rel="icon"> / apple-touch-icon / shortcut icon +/// 3. 按优先级选最佳 icon(apple-touch > sizes 最大 > /favicon.ico 兜底) +/// 4. 下载 icon 图片到 Upload/favicons/ 目录 +/// 5. 返回前端可访问的 URL(保存到 bookmark.IconUrl + iconType='favicon') +/// SSRF 防护:拒绝内网 / 本地 / 链路本地地址。 +/// 失败时返回 null(不抛异常),由调用方走默认图标。 +/// +public class FaviconService +{ + private readonly IUploadService _upload; + private readonly IMemoryCache _cache; + private readonly UploadOptions _uploadOptions; + private readonly ILogger _logger; + + /// 缓存键前缀 + 缓存时长(同一 URL 24h 内不再重抓) + private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(24); + private const string CacheKeyPrefix = "favicon:"; + + /// UA 字符串:模拟常见浏览器,避免被部分站点拒绝 + private const string UserAgent = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; + + /// 下载的 icon 大小上限(5MB) + private const long MaxIconBytes = 5L * 1024 * 1024; + + /// HttpClient 名字(与 Program.cs AddHttpClient(name) 对应) + private const string HttpClientName = nameof(FaviconService); + + private readonly IHttpClientFactory _httpFactory; + + public FaviconService( + IHttpClientFactory httpFactory, + IUploadService upload, + IMemoryCache cache, + IOptions uploadOptions, + ILogger logger) + { + _httpFactory = httpFactory; + _upload = upload; + _cache = cache; + _uploadOptions = uploadOptions.Value; + _logger = logger; + } + + /// 每次调用前从 factory 取一个新 HttpClient(短生命周期,由 factory 池化) + private HttpClient NewClient() => _httpFactory.CreateClient(HttpClientName); + + /// + /// 抓取 pageUrl 的 favicon 并保存到 upload 目录,返回前端可访问的 URL。 + /// 任何环节失败均返回 null(不抛异常,由调用方静默用默认图标)。 + /// + public async Task FetchAndSaveAsync(string pageUrl, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(pageUrl)) return null; + if (!Uri.TryCreate(pageUrl, UriKind.Absolute, out var pageUri)) return null; + if (pageUri.Scheme != Uri.UriSchemeHttp && pageUri.Scheme != Uri.UriSchemeHttps) return null; + + var cacheKey = CacheKeyPrefix + pageUri.Host + pageUri.AbsolutePath; + if (_cache.TryGetValue(cacheKey, out var cached)) + { + _logger.LogDebug("Favicon cache hit: {Url} → {Icon}", pageUrl, cached ?? "(null)"); + return cached; + } + + try + { + var iconUrl = await FetchIconUrlAsync(pageUri, ct); + if (string.IsNullOrEmpty(iconUrl)) { /* P51 临时:禁用负缓存以便重复请求能拿到新结果 CacheNull(cacheKey); */ return null; } + + var saved = await DownloadAndSaveAsync(iconUrl, pageUri, ct); + if (saved == null) { /* P51 临时:禁用负缓存以便重复请求能拿到新结果 CacheNull(cacheKey); */ return null; } + + _cache.Set(cacheKey, saved, CacheTtl); + _logger.LogInformation("Favicon fetched: {Page} → {Icon}", pageUrl, saved); + return saved; + } + catch (Exception ex) + { + // P51 修复:LogWarning → LogError(docker logs 默认级别是 Information 看不到 warning 堆栈), + // 并附上 UploadOptions.Path 实际值,方便排查容器内 /uploads 权限 / 路径覆盖问题 + _logger.LogError(ex, + "Favicon fetch failed: {Url} | UploadOptions.Path='{OptPath}' (env={Env})", + pageUrl, _uploadOptions.Path, Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "(default)"); + return null; + } + } + + private void CacheNull(string key) => _cache.Set(key, (string?)null, TimeSpan.FromMinutes(10)); + + /// + /// 主流程:抓 HTML → 解析 link → 选最佳 icon URL。 + /// + private async Task FetchIconUrlAsync(Uri pageUri, CancellationToken ct) + { + // 1. GET 页面(限 1MB) + var html = await FetchHtmlAsync(pageUri, ct); + if (string.IsNullOrEmpty(html)) return null; + + // 2. 解析 link tags + var links = ParseIconLinks(html, pageUri); + + // 3. 按优先级选最佳 + if (links.Count == 0) + { + // 兜底:直接尝试 /favicon.ico + return new Uri(pageUri, "/favicon.ico").ToString(); + } + + // 优先级:apple-touch-icon > icon(type=image/* sizes 最大) > shortcut icon > 其他 + var best = links + .OrderByDescending(l => l.Priority) + .ThenByDescending(l => l.Score) + .FirstOrDefault(); + return best?.Url; + } + + /// 抓取页面 HTML(限 1MB,5s 超时) + private async Task FetchHtmlAsync(Uri pageUri, CancellationToken ct) + { + if (await IsPrivateOrLocalhostAsync(pageUri, ct)) return null; + + using var _http = NewClient(); + using var req = new HttpRequestMessage(HttpMethod.Get, pageUri); + req.Headers.Add("User-Agent", UserAgent); + req.Headers.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"); + req.Headers.Add("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8"); + + using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); + // P33:详细日志 — 让主人能看清楚拿到的 HTML 是什么(含 location 跳转到哪) + _logger.LogInformation("Favicon fetch HTML: {Url} → {Status} {ContentType} ({Len} bytes)", + pageUri, (int)resp.StatusCode, resp.Content.Headers.ContentType?.MediaType ?? "?", + resp.Content.Headers.ContentLength ?? -1); + if (!resp.IsSuccessStatusCode) + { + _logger.LogDebug("Favicon fetch: {Url} returned {Status}, skip", pageUri, resp.StatusCode); + return null; + } + + // 限制 content-length + var contentLength = resp.Content.Headers.ContentLength; + if (contentLength.HasValue && contentLength.Value > 1024 * 1024) return null; + + await using var stream = await resp.Content.ReadAsStreamAsync(ct); + var buffer = new byte[1024 * 1024]; + var total = 0; + int read; + while (total < buffer.Length && (read = await stream.ReadAsync(buffer.AsMemory(total, buffer.Length - total), ct)) > 0) + { + total += read; + } + // 尝试解析为 HTML(先看 charset) + var charset = resp.Content.Headers.ContentType?.CharSet ?? "utf-8"; + string html; + try + { + html = System.Text.Encoding.GetEncoding(charset).GetString(buffer, 0, total); + } + catch + { + html = System.Text.Encoding.UTF8.GetString(buffer, 0, total); + } + + // P33:HTML 长度 + 是否含 favicon 关键字(方便定位"是否真的没找到") + var hasIconTag = html.Contains("rel=\"icon\"", StringComparison.OrdinalIgnoreCase) + || html.Contains("rel='icon'", StringComparison.OrdinalIgnoreCase) + || html.Contains("rel=\"alternate icon\"", StringComparison.OrdinalIgnoreCase); + _logger.LogDebug("Favicon HTML scan: {Url} len={Len} contains-icon-link={Has}", + pageUri, total, hasIconTag); + if (!hasIconTag) + { + // 截取 HTML 前 200 字符方便主人看是被什么页面拦了(如 FN Connect 反向代理页) + _logger.LogWarning("Favicon HTML has no : {Url} → first 200 chars: {Snippet}", + pageUri, html.Length > 0 ? html.Substring(0, Math.Min(200, html.Length)) : "(empty)"); + } + + return html; + } + + /// + /// 解析 HTML 中的 favicon 链接。 + /// P33 改进: + /// - 正则支持 rel / href 任意顺序(之前要求 rel 在前,对 href 在前的写法失败) + /// - priority 映射支持 `alternate icon` / `fluid-icon` 等包含 icon 关键字的 rel + /// - 同时解析 <meta property="og:image"> 作为兜底 + /// - 加详细日志,方便定位"为什么没抓到" + /// + private List ParseIconLinks(string html, Uri baseUri) + { + var results = new List(); + + // ===== 第一步:解析 ===== + // 用 .*? 懒匹配 rel/href 任意顺序;属性值允许 "..."/'...' 两种引号 + var linkPattern = new Regex( + @"]*?)/?>", // 整个 块(包括自闭合 />) + RegexOptions.IgnoreCase | RegexOptions.Compiled); + // P33 关键修复:属性名匹配前用 (? maxSize) maxSize = sz; + } + } + } + } + + // 解析绝对 URL + if (!Uri.TryCreate(baseUri, href, out var absoluteUri)) continue; + if (absoluteUri.Scheme != Uri.UriSchemeHttp && absoluteUri.Scheme != Uri.UriSchemeHttps) continue; + + // P33 改进:根据 rel 包含的关键字判定 priority + int priority; + int score; + if (relLower.Contains("apple-touch")) + { + priority = 300; + score = maxSize > 0 ? maxSize : 180; + } + else if (relLower == "shortcut icon") + { + priority = 100; + score = maxSize; + } + else if (relLower == "icon") + { + priority = 200; + score = maxSize; + } + else if (relLower.Contains("icon")) + { + // 兜底:alternate icon / fluid-icon / icon-zzz 等 + priority = 150; + score = maxSize; + } + else + { + priority = 50; + score = maxSize; + } + + _logger.LogDebug("Favicon link candidate: rel={Rel} href={Href} sizes={Sizes} → priority={P} score={S}", + relLower, absoluteUri, sizes ?? "-", priority, score); + + results.Add(new IconLink + { + Url = absoluteUri.ToString(), + Priority = priority, + Score = score + }); + } + + // ===== 第二步:兜底 ===== + // 很多现代站点(特别是博客/文档站)有 og:image,作为 icon 兜底 + var ogPattern = new Regex( + @"]*?\bproperty\s*=\s*[""']og:image[""'][^>]*?\bcontent\s*=\s*[""']([^""']+)[""']", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + // 也匹配 content 在前的写法 + var ogPatternAlt = new Regex( + @"]*?\bcontent\s*=\s*[""']([^""']+)[""'][^>]*?\bproperty\s*=\s*[""']og:image[""']", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + string? ogImage = null; + var ogMatch = ogPattern.Match(html); + if (ogMatch.Success) ogImage = ogMatch.Groups[1].Value; + else + { + var ogMatchAlt = ogPatternAlt.Match(html); + if (ogMatchAlt.Success) ogImage = ogMatchAlt.Groups[1].Value; + } + if (!string.IsNullOrEmpty(ogImage) && Uri.TryCreate(baseUri, ogImage, out var ogUri) + && (ogUri.Scheme == Uri.UriSchemeHttp || ogUri.Scheme == Uri.UriSchemeHttps)) + { + _logger.LogDebug("Favicon og:image fallback: {Url}", ogUri); + results.Add(new IconLink + { + Url = ogUri.ToString(), + Priority = 30, // 比 link 兜底还低,避免抢了真正的 favicon + Score = 0 + }); + } + + return results; + } + + /// 下载 icon 图片并保存到 upload 目录 + private async Task DownloadAndSaveAsync(string iconUrl, Uri pageUri, CancellationToken ct) + { + if (!Uri.TryCreate(iconUrl, UriKind.Absolute, out var iconUri)) return null; + if (iconUri.Scheme != Uri.UriSchemeHttp && iconUri.Scheme != Uri.UriSchemeHttps) return null; + if (await IsPrivateOrLocalhostAsync(iconUri, ct)) return null; + + using var _http = NewClient(); + using var req = new HttpRequestMessage(HttpMethod.Get, iconUri); + req.Headers.Add("User-Agent", UserAgent); + req.Headers.Add("Referer", pageUri.Scheme + "://" + pageUri.Host); + + using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); + if (!resp.IsSuccessStatusCode) return null; + + // content-type 校验 + var contentType = resp.Content.Headers.ContentType?.MediaType ?? ""; + if (!contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) && + !contentType.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + // 限制 content-length + var contentLength = resp.Content.Headers.ContentLength; + if (contentLength.HasValue && contentLength.Value > MaxIconBytes) return null; + + await using var stream = await resp.Content.ReadAsStreamAsync(ct); + // 用 MemoryStream 缓冲以同时拿到 content-type + using var ms = new MemoryStream(); + var buffer = new byte[81920]; + long total = 0; + int read; + while (total < MaxIconBytes && (read = await stream.ReadAsync(buffer, 0, (int)Math.Min(buffer.Length, MaxIconBytes - total))) > 0) + { + ms.Write(buffer, 0, read); + total += read; + } + if (total == 0 || total >= MaxIconBytes) return null; + ms.Position = 0; + + // 文件名:从 iconUrl 推断,最后一段 + var fileName = Path.GetFileName(iconUri.AbsolutePath); + if (string.IsNullOrEmpty(fileName) || fileName == "/") fileName = "favicon"; + + var result = await _upload.SaveStreamAsync(ms, fileName, contentType, subDir: "favicons"); + return result.Url; + } + + /// SSRF 防护:解析域名 IP,拒绝内网/本地/链路本地 + private async Task IsPrivateOrLocalhostAsync(Uri uri, CancellationToken ct) + { + try + { + // localhost 字面 + if (uri.HostNameType == UriHostNameType.Basic) + { + if (uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) return true; + } + + // 解析为 IP + IPAddress[] addresses; + try + { + addresses = await Dns.GetHostAddressesAsync(uri.Host, ct); + } + catch + { + return true; // 解析失败视为不安全 + } + + foreach (var ip in addresses) + { + if (IsPrivateOrLocalIp(ip)) return true; + } + return false; + } + catch + { + return true; + } + } + + private static bool IsPrivateOrLocalIp(IPAddress ip) + { + if (IPAddress.IsLoopback(ip)) return true; + if (ip.AddressFamily == AddressFamily.InterNetwork) + { + var bytes = ip.GetAddressBytes(); + // 10.0.0.0/8 + if (bytes[0] == 10) return true; + // 172.16.0.0/12 + if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true; + // 192.168.0.0/16 + if (bytes[0] == 192 && bytes[1] == 168) return true; + // 169.254.0.0/16 (link-local) + if (bytes[0] == 169 && bytes[1] == 254) return true; + // 0.0.0.0 + if (bytes[0] == 0 && bytes[1] == 0 && bytes[2] == 0 && bytes[3] == 0) return true; + } + return false; + } + + private class IconLink + { + public string Url { get; set; } = string.Empty; + public int Priority { get; set; } + public int Score { get; set; } + } +} diff --git a/backend/Services/IBookmarkService.cs b/backend/Services/IBookmarkService.cs new file mode 100644 index 0000000..72f79ee --- /dev/null +++ b/backend/Services/IBookmarkService.cs @@ -0,0 +1,13 @@ +using MyHomePage.Api.Models.Dtos; + +namespace MyHomePage.Api.Services; + +/// 链接服务:按分类查询 + 软删。 +public interface IBookmarkService +{ + Task> ListAsync(int? categoryId = null); + Task GetByIdAsync(int id); + Task CreateAsync(BookmarkUpsertRequest request); + Task UpdateAsync(int id, BookmarkUpsertRequest request); + Task DeleteAsync(int id); +} diff --git a/backend/Services/ICategoryService.cs b/backend/Services/ICategoryService.cs new file mode 100644 index 0000000..46f736b --- /dev/null +++ b/backend/Services/ICategoryService.cs @@ -0,0 +1,13 @@ +using MyHomePage.Api.Models.Dtos; + +namespace MyHomePage.Api.Services; + +/// 分类服务:支持二级树形结构。 +public interface ICategoryService +{ + Task> GetTreeAsync(); + Task GetByIdAsync(int id); + Task CreateAsync(CategoryUpsertRequest request); + Task UpdateAsync(int id, CategoryUpsertRequest request); + Task DeleteAsync(int id); +} diff --git a/backend/Services/ISearchEngineService.cs b/backend/Services/ISearchEngineService.cs new file mode 100644 index 0000000..0ca0d4c --- /dev/null +++ b/backend/Services/ISearchEngineService.cs @@ -0,0 +1,14 @@ +using MyHomePage.Api.Models.Dtos; + +namespace MyHomePage.Api.Services; + +/// 搜索引擎服务:增删改 + 默认引擎切换(保证唯一)。 +public interface ISearchEngineService +{ + Task> ListAsync(); + Task GetByIdAsync(int id); + Task CreateAsync(SearchEngineUpsertRequest request); + Task UpdateAsync(int id, SearchEngineUpsertRequest request); + Task DeleteAsync(int id); + Task SetDefaultAsync(int id); +} diff --git a/backend/Services/ISettingService.cs b/backend/Services/ISettingService.cs new file mode 100644 index 0000000..7f226c6 --- /dev/null +++ b/backend/Services/ISettingService.cs @@ -0,0 +1,10 @@ +using MyHomePage.Api.Models.Dtos; + +namespace MyHomePage.Api.Services; + +/// 设置服务:单行配置(Id=1),不存在则创建。 +public interface ISettingService +{ + Task GetAsync(); + Task UpdateAsync(SettingUpdateRequest request); +} diff --git a/backend/Services/ISyncService.cs b/backend/Services/ISyncService.cs new file mode 100644 index 0000000..b82ec01 --- /dev/null +++ b/backend/Services/ISyncService.cs @@ -0,0 +1,9 @@ +using MyHomePage.Api.Models.Dtos; + +namespace MyHomePage.Api.Services; + +/// 多端同步服务:基于 SyncLog 的增量同步 + 全量快照。 +public interface ISyncService +{ + Task GetChangesAsync(DateTime? since); +} diff --git a/backend/Services/IUploadService.cs b/backend/Services/IUploadService.cs new file mode 100644 index 0000000..972b441 --- /dev/null +++ b/backend/Services/IUploadService.cs @@ -0,0 +1,20 @@ +using MyHomePage.Api.Models.Dtos; + +namespace MyHomePage.Api.Services; + +/// 文件上传服务。 +public interface IUploadService +{ + /// 保存浏览器上传的文件(IFormFile)。 + Task SaveAsync(IFormFile file); + + /// 保存任意来源的字节流(如抓取的 favicon)。 + /// 数据流(由调用方负责释放) + /// 用于推断扩展名的原始文件名 + /// HTTP Content-Type(如 image/png) + /// 可选子目录(如 "favicons"),用于逻辑分组 + Task SaveStreamAsync(Stream stream, string fileName, string contentType, string? subDir = null); + + /// 确保上传根目录存在,返回根目录绝对路径。 + string EnsureRoot(); +} diff --git a/backend/Services/SearchEngineService.cs b/backend/Services/SearchEngineService.cs new file mode 100644 index 0000000..866ec84 --- /dev/null +++ b/backend/Services/SearchEngineService.cs @@ -0,0 +1,121 @@ +using MyHomePage.Api.Common; +using MyHomePage.Api.Models.Dtos; +using MyHomePage.Api.Models.Entities; +using SqlSugar; + +namespace MyHomePage.Api.Services; + +/// +public class SearchEngineService : ISearchEngineService +{ + private readonly ISqlSugarClient _db; + private readonly SyncLogHelper _sync; + + public SearchEngineService(ISqlSugarClient db, SyncLogHelper sync) + { + _db = db; + _sync = sync; + } + + /// + public async Task> ListAsync() + { + var list = await _db.Queryable() + .OrderBy(e => e.Sort) + .OrderBy(e => e.Id) + .ToListAsync(); + return list.Select(SearchEngineDto.FromEntity).ToList(); + } + + /// + public async Task GetByIdAsync(int id) + { + var e = await _db.Queryable().InSingleAsync(id); + return e is null ? null : SearchEngineDto.FromEntity(e); + } + + /// + public async Task CreateAsync(SearchEngineUpsertRequest request) + { + Validate(request); + var now = DateTime.UtcNow; + var entity = new SearchEngine + { + Name = request.Name.Trim(), + UrlTemplate = request.UrlTemplate.Trim(), + IconType = request.IconType, + Icon = request.Icon, + IconUrl = request.IconUrl, + ColorBg = request.ColorBg, + Sort = request.Sort, + IsDefault = request.IsDefault, + CreatedAt = now, + UpdatedAt = now + }; + entity.Id = await _db.Insertable(entity).ExecuteReturnIdentityAsync(); + if (entity.IsDefault) await ResetDefaultAsync(entity.Id); + await _sync.WriteAsync("search_engine", entity.Id, "create"); + return SearchEngineDto.FromEntity(entity); + } + + /// + public async Task UpdateAsync(int id, SearchEngineUpsertRequest request) + { + Validate(request); + var entity = await _db.Queryable().InSingleAsync(id) + ?? throw new BusinessException("搜索引擎不存在", 404); + + entity.Name = request.Name.Trim(); + entity.UrlTemplate = request.UrlTemplate.Trim(); + entity.IconType = request.IconType; + entity.Icon = request.Icon; + entity.IconUrl = request.IconUrl; + entity.ColorBg = request.ColorBg; + entity.Sort = request.Sort; + entity.IsDefault = request.IsDefault; + entity.UpdatedAt = DateTime.UtcNow; + + await _db.Updateable(entity).ExecuteCommandAsync(); + if (entity.IsDefault) await ResetDefaultAsync(entity.Id); + await _sync.WriteAsync("search_engine", id, "update"); + return SearchEngineDto.FromEntity(entity); + } + + /// + public async Task DeleteAsync(int id) + { + var entity = await _db.Queryable().InSingleAsync(id) + ?? throw new BusinessException("搜索引擎不存在", 404); + await _db.Deleteable(id).ExecuteCommandAsync(); + await _sync.WriteAsync("search_engine", id, "delete"); + } + + /// + public async Task SetDefaultAsync(int id) + { + var entity = await _db.Queryable().InSingleAsync(id) + ?? throw new BusinessException("搜索引擎不存在", 404); + await ResetDefaultAsync(id); + entity.IsDefault = true; + entity.UpdatedAt = DateTime.UtcNow; + await _db.Updateable(entity).ExecuteCommandAsync(); + await _sync.WriteAsync("search_engine", id, "update"); + return SearchEngineDto.FromEntity(entity); + } + + /// 把其他引擎的 IsDefault 全部置为 false + private async Task ResetDefaultAsync(int keepId) + { + await _db.Updateable() + .SetColumns(e => e.IsDefault == false) + .Where(e => e.Id != keepId && e.IsDefault) + .ExecuteCommandAsync(); + } + + private static void Validate(SearchEngineUpsertRequest req) + { + if (string.IsNullOrWhiteSpace(req.Name)) throw new BusinessException("名称不能为空", 400); + if (string.IsNullOrWhiteSpace(req.UrlTemplate)) throw new BusinessException("URL 模板不能为空", 400); + if (!req.UrlTemplate.Contains("{q}")) throw new BusinessException("URL 模板必须包含 {q} 占位符", 400); + } +} diff --git a/backend/Services/SettingService.cs b/backend/Services/SettingService.cs new file mode 100644 index 0000000..2b8cd63 --- /dev/null +++ b/backend/Services/SettingService.cs @@ -0,0 +1,85 @@ +using MyHomePage.Api.Common; +using MyHomePage.Api.Models.Dtos; +using MyHomePage.Api.Models.Entities; +using SqlSugar; + +namespace MyHomePage.Api.Services; + +/// +public class SettingService : ISettingService +{ + private const int DefaultId = 1; + private static readonly HashSet AllowedThemeModes = new(StringComparer.OrdinalIgnoreCase) { "dark", "light", "auto" }; + private static readonly HashSet AllowedBackgroundTypes = new(StringComparer.OrdinalIgnoreCase) { "preset", "custom", "solid" }; + // ===== P34 360 壁纸切换间隔合法值(分钟)===== + private static readonly HashSet AllowedWallpaperIntervals = new() { 0, 1, 5, 15, 30, 60 }; + + private readonly ISqlSugarClient _db; + private readonly SyncLogHelper _sync; + + public SettingService(ISqlSugarClient db, SyncLogHelper sync) + { + _db = db; + _sync = sync; + } + + /// + public async Task GetAsync() + { + var entity = await _db.Queryable().InSingleAsync(DefaultId); + if (entity is null) + { + // 兜底:写入默认值 + entity = new Setting { Id = DefaultId }; + await _db.Insertable(entity).ExecuteCommandAsync(); + } + return ToDto(entity); + } + + /// + public async Task UpdateAsync(SettingUpdateRequest request) + { + var entity = await _db.Queryable().InSingleAsync(DefaultId); + if (entity is null) + { + entity = new Setting { Id = DefaultId }; + await _db.Insertable(entity).ExecuteCommandAsync(); + } + + if (!string.IsNullOrEmpty(request.ThemeMode)) + { + if (!AllowedThemeModes.Contains(request.ThemeMode)) throw new BusinessException("不支持的主题模式", 400); + entity.ThemeMode = request.ThemeMode; + } + if (!string.IsNullOrEmpty(request.AccentColor)) + { + if (request.AccentColor.Length > 16) throw new BusinessException("主色调格式错误", 400); + entity.AccentColor = request.AccentColor; + } + if (request.BackgroundImage is not null) entity.BackgroundImage = request.BackgroundImage; + if (!string.IsNullOrEmpty(request.BackgroundType)) + { + if (!AllowedBackgroundTypes.Contains(request.BackgroundType)) throw new BusinessException("不支持的背景类型", 400); + entity.BackgroundType = request.BackgroundType; + } + if (request.OpenLinksInNewTab.HasValue) entity.OpenLinksInNewTab = request.OpenLinksInNewTab.Value ? 1 : 0; + if (request.OpenSearchInNewTab.HasValue) entity.OpenSearchInNewTab = request.OpenSearchInNewTab.Value ? 1 : 0; + + // ===== P34 360 壁纸模式 ===== + if (request.WallpaperEnabled.HasValue) entity.WallpaperEnabled = request.WallpaperEnabled.Value ? 1 : 0; + if (request.WallpaperCategoryId is not null) entity.WallpaperCategoryId = request.WallpaperCategoryId; + if (request.WallpaperInterval.HasValue) + { + if (!AllowedWallpaperIntervals.Contains(request.WallpaperInterval.Value)) + throw new BusinessException("不支持的壁纸切换间隔(允许:0/1/5/15/30/60 分钟)", 400); + entity.WallpaperInterval = request.WallpaperInterval.Value; + } + + entity.UpdatedAt = DateTime.UtcNow; + + await _db.Updateable(entity).ExecuteCommandAsync(); + await _sync.WriteAsync("setting", entity.Id, "update"); + return ToDto(entity); + } + + private static SettingDto ToDto(Setting s) => SettingDto.FromEntity(s);} diff --git a/backend/Services/SyncLogHelper.cs b/backend/Services/SyncLogHelper.cs new file mode 100644 index 0000000..ca643b1 --- /dev/null +++ b/backend/Services/SyncLogHelper.cs @@ -0,0 +1,25 @@ +using MyHomePage.Api.Models.Entities; +using MyHomePage.Api.Infrastructure.Database; +using SqlSugar; + +namespace MyHomePage.Api.Services; + +/// 同步日志写入助手:增删改实体时统一调用。 +public class SyncLogHelper +{ + private readonly ISqlSugarClient _db; + + public SyncLogHelper(ISqlSugarClient db) => _db = db; + + /// 写入一条同步日志 + public Task WriteAsync(string entityType, int entityId, string operation) + { + return _db.Insertable(new SyncLog + { + EntityType = entityType, + EntityId = entityId, + Operation = operation, + Timestamp = DateTime.UtcNow + }).ExecuteCommandAsync(); + } +} diff --git a/backend/Services/SyncService.cs b/backend/Services/SyncService.cs new file mode 100644 index 0000000..ade4d45 --- /dev/null +++ b/backend/Services/SyncService.cs @@ -0,0 +1,60 @@ +using MyHomePage.Api.Models.Dtos; +using MyHomePage.Api.Models.Entities; +using SqlSugar; + +namespace MyHomePage.Api.Services; + +/// +public class SyncService : ISyncService +{ + private readonly ISqlSugarClient _db; + + public SyncService(ISqlSugarClient db) => _db = db; + + /// + public async Task GetChangesAsync(DateTime? since) + { + // 1. 增量变更记录 + var changesQuery = _db.Queryable().OrderBy(s => s.Id); + if (since.HasValue) changesQuery = changesQuery.Where(s => s.Timestamp > since.Value); + + var logs = await changesQuery.ToListAsync(); + var changes = logs.Select(l => new SyncChangeDto + { + EntityType = l.EntityType, + EntityId = l.EntityId, + Operation = l.Operation, + Timestamp = l.Timestamp + }).ToList(); + + // 2. 全量快照(无论 since 是否为空都返回,前端可以本地落库) + var snapshot = new SyncSnapshot(); + var categories = await _db.Queryable().OrderBy(c => c.Sort).OrderBy(c => c.Id).ToListAsync(); + snapshot.Categories = CategoryDto.BuildTree(categories); + + var bookmarks = await _db.Queryable().Where(b => !b.IsDeleted) + .OrderBy(b => b.Sort) + .OrderBy(b => b.Id) + .ToListAsync(); + // P28 修复:用 BookmarkDto.FromEntity 共享映射,避免漏字段 + snapshot.Bookmarks = bookmarks.Select(BookmarkDto.FromEntity).ToList(); + + var engines = await _db.Queryable().OrderBy(e => e.Sort).ToListAsync(); + // P42 修复:用 SearchEngineDto.FromEntity 共享映射(之前手动 new 漏了 IconType/IconUrl/ColorBg, + // 同步后前端 store 拿到老 DTO,engineLogoStyle 命中兜底色块 → 引擎 logo "消失") + snapshot.SearchEngines = engines.Select(SearchEngineDto.FromEntity).ToList(); + + var setting = await _db.Queryable().InSingleAsync(1); + if (setting is not null) + { + snapshot.Settings = SettingDto.FromEntity(setting); + } + + return new SyncChangesResponse + { + Changes = changes, + Snapshot = snapshot, + ServerTime = DateTime.UtcNow + }; + } +} diff --git a/backend/Services/UploadService.cs b/backend/Services/UploadService.cs new file mode 100644 index 0000000..f4e8b8b --- /dev/null +++ b/backend/Services/UploadService.cs @@ -0,0 +1,144 @@ +using Microsoft.Extensions.Options; +using MyHomePage.Api.Common; +using MyHomePage.Api.Infrastructure.Configuration; +using MyHomePage.Api.Models.Dtos; + +namespace MyHomePage.Api.Services; + +/// +public class UploadService : IUploadService +{ + /// 允许的图片扩展名白名单(与 SaveAsync 共用) + private static readonly string[] AllowedExtensions = { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico" }; + + /// Content-Type 到扩展名的兜底映射 + private static readonly Dictionary ContentTypeToExt = new(StringComparer.OrdinalIgnoreCase) + { + ["image/png"] = ".png", + ["image/jpeg"] = ".jpg", + ["image/jpg"] = ".jpg", + ["image/gif"] = ".gif", + ["image/webp"] = ".webp", + ["image/svg+xml"] = ".svg", + ["image/x-icon"] = ".ico", + ["image/vnd.microsoft.icon"] = ".ico", + ["image/ico"] = ".ico", + }; + + private readonly UploadOptions _options; + private readonly IWebHostEnvironment _env; + private readonly ILogger _logger; + + /// P51 诊断:是否已记录过 upload root(避免每次保存重复 log) + private bool _rootLogged; + + public UploadService( + IOptions options, + IWebHostEnvironment env, + ILogger logger) + { + _options = options.Value; + _env = env; + _logger = logger; + } + + /// + public string EnsureRoot() + { + var root = Path.IsPathRooted(_options.Path) + ? _options.Path + : Path.Combine(_env.ContentRootPath, _options.Path); + Directory.CreateDirectory(root); + + // P51 诊断:第一次调用时 log 一次实际 root(暴露容器内 /uploads 路径覆盖问题) + if (!_rootLogged) + { + _rootLogged = true; + _logger.LogInformation( + "Upload root resolved: Path='{Path}' (IsRooted={IsRooted}) → Actual='{Actual}' | env={Env}", + _options.Path, Path.IsPathRooted(_options.Path), root, _env.EnvironmentName); + } + return root; + } + + /// P51 诊断兼容:保留旧方法名(Program.cs 启动期若想强制 log 可调) + public string EnsureRootWithLog() => EnsureRoot(); + + /// + public async Task SaveAsync(IFormFile file) + { + if (file is null || file.Length == 0) throw new BusinessException("文件为空", 400); + if (file.Length > _options.MaxSizeBytes) throw new BusinessException($"文件大小超过限制({_options.MaxSizeBytes / 1024 / 1024}MB)", 400); + + var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (string.IsNullOrEmpty(ext)) throw new BusinessException("文件必须包含扩展名", 400); + if (Array.IndexOf(AllowedExtensions, ext) < 0) throw new BusinessException("仅支持图片格式", 400); + + await using var stream = file.OpenReadStream(); + return await SaveStreamInternalAsync(stream, file.FileName, file.ContentType, ext, subDir: null); + } + + /// + public async Task SaveStreamAsync(Stream stream, string fileName, string contentType, string? subDir = null) + { + if (stream is null) throw new BusinessException("数据流为空", 400); + + // 推断扩展名:优先文件名 → 兜底 content-type + var ext = Path.GetExtension(fileName).ToLowerInvariant(); + if (string.IsNullOrEmpty(ext)) + { + if (string.IsNullOrEmpty(contentType)) throw new BusinessException("无法推断文件扩展名", 400); + if (!ContentTypeToExt.TryGetValue(contentType, out ext)) + throw new BusinessException($"不支持的内容类型:{contentType}", 400); + } + if (Array.IndexOf(AllowedExtensions, ext) < 0) throw new BusinessException("仅支持图片格式", 400); + + return await SaveStreamInternalAsync(stream, fileName, contentType, ext, subDir); + } + + /// 实际写文件的内部流程(SaveAsync / SaveStreamAsync 共用) + private async Task SaveStreamInternalAsync(Stream stream, string fileName, string contentType, string ext, string? subDir) + { + var root = EnsureRoot(); + // 按日期分目录:2026/07/04[/favicons] + var datePath = DateTime.UtcNow.ToString("yyyy/MM/dd"); + var relativeDir = string.IsNullOrEmpty(subDir) ? datePath : Path.Combine(datePath, subDir); + var dir = Path.Combine(root, relativeDir); + + var name = $"{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}_{Guid.NewGuid():N}{ext}"; + var fullPath = Path.Combine(dir, name); + + // P51 诊断:把 IO 异常(容器权限/路径/磁盘满)原样抛出 + 完整上下文日志 + try + { + Directory.CreateDirectory(dir); + await using (var fs = new FileStream(fullPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + await stream.CopyToAsync(fs); + } + } + catch (Exception ex) + { + _logger.LogError(ex, + "Upload save failed: file={File}, contentType={ContentType}, ext={Ext}, subDir={SubDir}, " + + "UploadOptions.Path={OptPath} (IsRooted={IsRooted}), ContentRoot={ContentRoot}, env={Env}, " + + "computed root={Root}, dir={Dir}, fullPath={FullPath}", + fileName, contentType, ext, subDir, + _options.Path, Path.IsPathRooted(_options.Path), _env.ContentRootPath, _env.EnvironmentName, + root, dir, fullPath); + throw new BusinessException($"文件保存失败: {ex.GetType().Name}: {ex.Message}", 500); + } + + var relative = Path.Combine(relativeDir, name).Replace('\\', '/'); + var url = (_options.BaseUrl ?? "/uploads").TrimEnd('/') + "/" + relative; + _logger.LogInformation("Upload saved: {Path} ({ContentType})", fullPath, contentType); + + return new UploadResultDto + { + Path = relative, + Url = url, + FileName = fileName, + Size = new FileInfo(fullPath).Length + }; + } +} diff --git a/backend/Services/WallpaperService.cs b/backend/Services/WallpaperService.cs new file mode 100644 index 0000000..5b8aee5 --- /dev/null +++ b/backend/Services/WallpaperService.cs @@ -0,0 +1,383 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Caching.Memory; +using MyHomePage.Api.Common; +using MyHomePage.Api.Models.Dtos; + +namespace MyHomePage.Api.Services; + +/// +/// 360 在线壁纸代理服务(P34 + P34.1 修正)。 +/// +/// 数据来源(参考 360 chrome 壁纸公开接口): +/// 1. 全部分类列表(含 order_num 排序字段) +/// http://cdn.apc.360.cn/index.php?c=WallPaper&a=getAllCategoriesV2&from=360chrome +/// 2. 按分类 ID 获取图片列表(每条 data 含 url + 6 个预设分辨率 + 原始分辨率) +/// http://wallpaper.apc.360.cn/index.php?c=WallPaper&a=getAppsByCategory +/// &cid={cid}&start=0&count=200&from=360chrome +/// +/// P34.1 修正(主人反馈 360 接口实际返回内容): +/// - 分类数据有 order_num 字段(110/100/99/.../9),应按 order_num 降序展示(不再是字母排序) +/// - 列表里 img_1600_900 / img_1440_900 / img_1366_768 / img_1280_800 / img_1280_1024 / img_1024_768 +/// 是 360 官方为每张图准备的预设分辨率 URL,**优先用这些**(CDN 必定存在,画质 85) +/// - 视口分辨率若不匹配任何 preset,兜底走 RewriteUrl(quality=85) 自构 bdm/{W}_{H}_85 +/// - 原图 url 字段用 bdr/__85/...,**不直接使用**(那是 bdr 压缩档,画质低) +/// +/// 后端缓存策略(避免每次前端访问都打 360 接口): +/// - 分类列表:启动拉一次,缓存 24h +/// - 图片池(每个 cid):缓存 200 张,TTL 12h +/// - 立即刷新:手动调 RefreshAsync 清缓存重新拉,并立即返回一张随机图 +/// +public class WallpaperService +{ + private readonly IHttpClientFactory _httpFactory; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + + private const string HttpClientName = nameof(WallpaperService); + private const string UserAgent = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; + + /// 分类列表缓存 TTL(24h,启动再热一次) + private static readonly TimeSpan CategoryTtl = TimeSpan.FromHours(24); + /// 图片池缓存 TTL(12h) + private static readonly TimeSpan PoolTtl = TimeSpan.FromHours(12); + + /// 每次拉取池子的最大张数(主人决策:200 张池子) + private const int PoolCount = 200; + + /// 分类列表 URL + private const string CategoryUrl = + "http://cdn.apc.360.cn/index.php?c=WallPaper&a=getAllCategoriesV2&from=360chrome"; + /// 分类图片 URL 模板({0}=cid, {1}=start, {2}=count) + private const string AppsByCategoryUrlTemplate = + "http://wallpaper.apc.360.cn/index.php?c=WallPaper&a=getAppsByCategory&cid={0}&start={1}&count={2}&from=360chrome"; + + /// 画质固定 85(与 360 官方预设 img_*_85 一致,主人截图原始数据证实) + private const int DefaultQuality = 85; + + /// + /// 360 官方为每张壁纸预设的固定分辨率 + 画质 85(P34.1 主人反馈真实接口结构)。 + /// 视口尺寸进来后 → 在这 6 个 preset 里挑"宽高比例最接近且单边≥视口"的命中即用。 + /// + private static readonly (int W, int H)[] PresetResolutions = + { + (1600, 900), + (1440, 900), + (1366, 768), + (1280, 800), + (1280, 1024), + (1024, 768) + }; + + public WallpaperService( + IHttpClientFactory httpFactory, + IMemoryCache cache, + ILogger logger) + { + _httpFactory = httpFactory; + _cache = cache; + _logger = logger; + } + + /// 每次调用前从 factory 取一个新 HttpClient(短生命周期,由 factory 池化) + private HttpClient NewClient() => _httpFactory.CreateClient(HttpClientName); + + // ===================================================================== + // 公开 API(Controller 调用) + // ===================================================================== + + /// 获取全部分类列表(24h 缓存)。失败时返回空集合(不抛)。 + public async Task> GetCategoriesAsync(CancellationToken ct = default) + { + const string cacheKey = "wallpaper:categories"; + if (_cache.TryGetValue>(cacheKey, out var cached) && cached is not null) + { + _logger.LogDebug("Wallpaper categories cache hit: {Count}", cached.Count); + return cached; + } + + try + { + using var client = NewClient(); + using var req = new HttpRequestMessage(HttpMethod.Get, CategoryUrl); + req.Headers.Add("User-Agent", UserAgent); + req.Headers.Add("Referer", "http://chrome.360.cn/"); + + using var resp = await client.SendAsync(req, ct); + if (!resp.IsSuccessStatusCode) + { + _logger.LogWarning("Wallpaper categories fetch failed: HTTP {Status}", (int)resp.StatusCode); + return new List(); + } + + var json = await resp.Content.ReadAsStringAsync(ct); + // P34.1:按 360 官方 order_num 字段降序(之前是字母排序,错的) + var list = ParseCategoryJson(json); + _cache.Set(cacheKey, list, CategoryTtl); + _logger.LogInformation("Wallpaper categories fetched: {Count}", list.Count); + return list; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Wallpaper categories fetch error"); + return new List(); + } + } + + /// + /// 取一张随机壁纸 URL(按视口分辨率选最接近的 preset,无命中走 RewriteUrl 兜底)。 + /// + /// 分类 ID(空字符串 = 全部/推荐) + /// 视口宽度(px) + /// 视口高度(px) + /// 取消令牌 + public async Task GetRandomAsync(string cid, int w, int h, CancellationToken ct = default) + { + var pool = await GetOrFetchPoolAsync(cid ?? "", ct); + if (pool.Count == 0) + { + _logger.LogWarning("Wallpaper pool empty for cid={Cid}", cid); + return null; + } + return BuildRandom(pool, w, h); + } + + /// 强制刷新图片池(立即切换按钮使用),并立即返回一张随机图。 + public async Task RefreshAsync(string cid, int w, int h, CancellationToken ct = default) + { + cid ??= ""; + _cache.Remove(PoolKey(cid)); + _logger.LogInformation("Wallpaper pool refreshed: cid={Cid}", string.IsNullOrEmpty(cid) ? "(all)" : cid); + var pool = await GetOrFetchPoolAsync(cid, ct); + if (pool.Count == 0) return null; + return BuildRandom(pool, w, h); + } + + // ===================================================================== + // 池子管理 + // ===================================================================== + + private string PoolKey(string cid) => $"wallpaper:pool:{cid}"; + + /// 获取分类的图片池(缓存 12h)。无池时主动拉。 + private async Task> GetOrFetchPoolAsync(string cid, CancellationToken ct) + { + var key = PoolKey(cid); + if (_cache.TryGetValue>(key, out var cached) && cached is not null && cached.Count > 0) + return cached; + + var fetched = await FetchPoolFrom360Async(cid, ct); + if (fetched.Count > 0) + _cache.Set(key, fetched, PoolTtl); + return fetched; + } + + /// 实际请求 360 接口拿 200 张 PoolItem 列表(含 6 个预设分辨率 URL) + private async Task> FetchPoolFrom360Async(string cid, CancellationToken ct) + { + try + { + using var client = NewClient(); + // 360 接口 cid 空字符串会返回空,这里用一个常见的「推荐」分类(36 = 4K专区)兜底 + var effectiveCid = string.IsNullOrEmpty(cid) ? "36" : cid; + var url = string.Format(AppsByCategoryUrlTemplate, effectiveCid, 0, PoolCount); + + using var req = new HttpRequestMessage(HttpMethod.Get, url); + req.Headers.Add("User-Agent", UserAgent); + req.Headers.Add("Referer", "http://chrome.360.cn/"); + + using var resp = await client.SendAsync(req, ct); + if (!resp.IsSuccessStatusCode) + { + _logger.LogWarning("Wallpaper pool fetch failed: cid={Cid} HTTP {Status}", effectiveCid, (int)resp.StatusCode); + return new List(); + } + + var json = await resp.Content.ReadAsStringAsync(ct); + var items = ParseAppsJson(json); + _logger.LogInformation("Wallpaper pool fetched: cid={Cid} count={Count}", effectiveCid, items.Count); + return items; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Wallpaper pool fetch error: cid={Cid}", cid); + return new List(); + } + } + + /// 从池中随机选 1 张 → 用 PickBestUrl 选最佳 URL → 构造 DTO + private static WallpaperRandomDto BuildRandom(List pool, int w, int h) + { + if (pool.Count == 0) return null!; + var pick = pool[Random.Shared.Next(pool.Count)]; + var (finalUrl, usedPreset, fallback) = PickBestUrl(pick, w, h); + return new WallpaperRandomDto + { + Url = finalUrl, + OriginalUrl = pick.Url, + Width = w, + Height = h, + // 扩展信息:P34.1 调试用,前端可忽略 + Preset = usedPreset is null ? null : $"{usedPreset.Value.W}x{usedPreset.Value.H}", + UsedFallback = fallback + }; + } + + /// + /// 为给定 PoolItem 选最合适的 URL。 + /// 选法:先按"宽高比 (aspect) 差最小"在 6 个 preset 里挑(要求 aspect 差 < 0.15), + /// 比例匹配的候选里再按"单边最接近视口"选最佳。 + /// 没有任何 preset 比例匹配 → 走 RewriteUrl(quality=85) 自构 bdm/{W}_{H}_85 兜底(避免 preset 5:4 拉伸到 9:16 视口的变形)。 + /// + private static (string url, (int W, int H)? preset, bool fallback) PickBestUrl(PoolItem item, int w, int h) + { + if (item.Presets.Count > 0) + { + double targetAspect = (double)w / Math.Max(1, h); + const double AspectTolerance = 0.15; // 比例差阈值(绝对值):超过即视为比例不匹配,走兜底 + (int W, int H)? best = null; + double bestAspectDelta = double.MaxValue; + foreach (var preset in PresetResolutions) + { + if (!item.Presets.ContainsKey(preset)) continue; + double presetAspect = (double)preset.W / Math.Max(1, preset.H); + double aspectDelta = Math.Abs(presetAspect - targetAspect); + if (aspectDelta >= AspectTolerance) continue; // 比例不匹配,跳过 + // 在比例匹配的 preset 里选 aspect 最接近的 + if (aspectDelta < bestAspectDelta) { bestAspectDelta = aspectDelta; best = preset; } + } + if (best is not null && item.Presets.TryGetValue(best.Value, out var presetUrl)) + { + return (presetUrl, best, false); + } + } + // 兜底:RewriteUrl(quality=85) 自构 + var fallbackUrl = RewriteUrl(item.Url, w, h, DefaultQuality); + return (fallbackUrl, null, true); + } + + /// + /// 把 360 原始 URL 改造成指定分辨率/画质。 + /// 例如:http://p8.qhimg.com/bdr/__85/t01e5f605262fb61fb4.jpg + /// → http://p8.qhimg.com/bdm/1920_1080_85/t01e5f605262fb61fb4.jpg + /// 只改路径段 bdr/__85 → bdm/{W}_{H}_{Q},主机保持原样(360 CDN p3-p19 等节点都支持 bdm 路径)。 + /// + private static string RewriteUrl(string original, int w, int h, int quality) + { + if (string.IsNullOrEmpty(original)) return original; + return Regex.Replace(original, @"/bdr/__85/", $"/bdm/{w}_{h}_{quality}/"); + } + + // ===================================================================== + // JSON 解析 + // ===================================================================== + + /// + /// 解析分类 JSON:{ errno, errmsg, total, data: [{ id, name, order_num, tag, create_time }] }。 + /// P34.1 修正:直接按 order_num int 降序排(不再字母排序),保留 18 个原始顺序。 + /// + private static List ParseCategoryJson(string json) + { + var result = new List(); + if (string.IsNullOrEmpty(json)) return result; + try + { + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("data", out var dataEl) || dataEl.ValueKind != JsonValueKind.Array) + return result; + foreach (var item in dataEl.EnumerateArray()) + { + string id = item.TryGetProperty("id", out var idEl) ? idEl.ToString() : ""; + string name = item.TryGetProperty("name", out var nameEl) ? nameEl.ToString() : ""; + // order_num 是字符串(接口实际为 "110"),用 TryGetInt32 + TryGetString 兼容 + int orderNum = 0; + if (item.TryGetProperty("order_num", out var orderEl)) + { + if (orderEl.ValueKind == JsonValueKind.Number && orderEl.TryGetInt32(out var n)) orderNum = n; + else if (int.TryParse(orderEl.ToString(), out var s)) orderNum = s; + } + if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(name)) + { + result.Add(new WallpaperCategoryDto + { + Id = id, + Name = name, + OrderNum = orderNum + }); + } + } + // P34.1:按 360 官方 order_num 降序(4K 专区 110 → 文字控 9) + result = result.OrderByDescending(c => c.OrderNum).ToList(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[Wallpaper] category parse error: {ex.Message}"); + } + return result; + } + + /// + /// 解析分类图片 JSON:每条 data 含 url + 6 个 img_xxx_xxx 预设分辨率。 + /// P34.1 修正:不再只读 url,而是同时读 img_1600_900 / img_1440_900 / img_1366_768 / img_1280_800 / img_1280_1024 / img_1024_768。 + /// + private static List ParseAppsJson(string json) + { + var result = new List(); + if (string.IsNullOrEmpty(json)) return result; + try + { + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("data", out var dataEl) || dataEl.ValueKind != JsonValueKind.Array) + return result; + + // 预设字段名列表 + string[] presetKeys = + { + "img_1600_900", "img_1440_900", "img_1366_768", + "img_1280_800", "img_1280_1024", "img_1024_768" + }; + + foreach (var item in dataEl.EnumerateArray()) + { + // 原始 url(兜底用) + string url = item.TryGetProperty("url", out var urlEl) ? urlEl.ToString() : ""; + if (string.IsNullOrEmpty(url)) continue; + + var presets = new Dictionary<(int, int), string>(); + foreach (var key in presetKeys) + { + if (!item.TryGetProperty(key, out var valEl)) continue; + var val = valEl.ToString(); + if (string.IsNullOrEmpty(val)) continue; + // key 形如 "img_1600_900" + var parts = key.Substring(4).Split('_'); // ["1600","900"] + if (parts.Length != 2) continue; + if (!int.TryParse(parts[0], out var w)) continue; + if (!int.TryParse(parts[1], out var h)) continue; + presets[(w, h)] = val; + } + if (presets.Count == 0) continue; // 没有任何 preset → 跳过(不要单 url 兜底条目,避免池子里混入"无 preset"项) + result.Add(new PoolItem { Url = url, Presets = presets }); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[Wallpaper] apps parse error: {ex.Message}"); + } + return result; + } + + // ===================================================================== + // 内部类型 + // ===================================================================== + + /// 池子里的单条记录:原始 url + 6 个预设分辨率 URL 字典 + private sealed class PoolItem + { + /// 原始 bdr/__85 url(兜底用) + public string Url { get; set; } = string.Empty; + /// 预设分辨率 → URL(例:{(1600,900): "http://.../bdm/1600_900_85/..."}) + public Dictionary<(int W, int H), string> Presets { get; set; } = new(); + } +} diff --git a/backend/Uploads/.gitkeep b/backend/Uploads/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/Uploads/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/appsettings.Development.json b/backend/appsettings.Development.json new file mode 100644 index 0000000..bb8af4e --- /dev/null +++ b/backend/appsettings.Development.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information", + "SqlSugar": "Debug" + } + }, + "Database": { + "Provider": "Sqlite", + "ConnectionString": "Data Source=myhomepage.dev.db" + } +} diff --git a/backend/appsettings.json b/backend/appsettings.json new file mode 100644 index 0000000..7d437cd --- /dev/null +++ b/backend/appsettings.json @@ -0,0 +1,67 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "SqlSugar": "Information" + } + }, + "AllowedHosts": "*", + "Urls": "http://0.0.0.0:5080", + "Database": { + // ===== 当前默认:SQLite(开发 / 单机部署) ===== + "Provider": "Sqlite", + "ConnectionString": "Data Source=myhomepage.db", + + // ====================================================================== + // 部署到 MySQL 时切换方式(任选其一,1Panel 推荐方式 C) + // ====================================================================== + // + // 方式 A:直接修改本文件(不推荐,会被提交到 git,不便多环境管理) + // "Provider": "MySql", + // "ConnectionString": "server=127.0.0.1;port=3306;database=myhomepage;user=myhomepage_user;password=YOUR_PASSWORD;charset=utf8mb4;", + // + // 方式 B:新建 backend/appsettings.Production.json 覆盖 Database 节 + // 内容: + // { + // "Database": { + // "Provider": "MySql", + // "ConnectionString": "server=127.0.0.1;port=3306;database=myhomepage;user=myhomepage_user;password=YOUR_PASSWORD;charset=utf8mb4;" + // } + // } + // 启动时设环境变量 ASPNETCORE_ENVIRONMENT=Production 即生效 + // + // 方式 C(1Panel 推荐,零文件改动): + // 在 1Panel 网站详情页 → 「环境变量」里设: + // Database__Provider=MySql + // Database__ConnectionString=server=127.0.0.1;port=3306;database=myhomepage;user=myhomepage_user;password=xxx;charset=utf8mb4; + // ASP.NET Core 配置系统会自动用环境变量覆盖 appsettings.json 里的 Database 节 + // + // 连接串参数说明: + // server MySQL 主机(1Panel 部署本机用 127.0.0.1;远程用实际 IP) + // port 端口(默认 3306) + // database 数据库名(需要先在 1Panel 数据库面板建好,utf8mb4 字符集) + // user 用户名(建议建专用用户,权限限定在 myhomepage 库,主机锁 localhost) + // password 用户密码(如果含特殊字符需 URL encode:# → %23,@ → %40,; → %3B 等) + // charset 字符集(务必 utf8mb4,否则 emoji 存不进) + // 其他可选 SslMode=None/Required;Pooling=true;TreatTinyAsBoolean=true 等 + // ====================================================================== + }, + "Upload": { + // P52 修复:1Panel Docker 部署时改成 "/uploads"(绝对路径)让 volume 挂载生效 + // - 容器内路径 = 宿主机 /data/myhomepage/upload + // - 容器销毁后 favicon 仍然保留 + // - 不污染 /app 代码目录 + // 本地 dev / 单机 SQLite 时用 "Uploads"(相对路径 = /app/Uploads)即可 + "Path": "/uploads", + "BaseUrl": "/uploads", + "MaxSizeBytes": 10485760 + }, + "Cors": { + "Origins": [ + "http://localhost:5173", + "http://localhost:4173", + "http://localhost:3000" + ] + } +} diff --git a/backend/categories.json b/backend/categories.json new file mode 100644 index 0000000..a1f2f40 --- /dev/null +++ b/backend/categories.json @@ -0,0 +1 @@ +{"code":0,"message":"ok","data":[{"id":1,"parentId":0,"name":"常用工具","icon":"wrench","sort":0,"createdAt":"2026-07-04T09:58:39.5249273","updatedAt":"2026-07-04T09:58:39.5249273","children":[{"id":21,"parentId":1,"name":"测试","icon":"alarm-clock","sort":0,"createdAt":"2026-07-04T11:49:12.616178","updatedAt":"2026-07-04T11:49:12.616178","children":[]},{"id":22,"parentId":1,"name":"测试1","icon":"alert-circle","sort":0,"createdAt":"2026-07-04T11:50:36.1058327","updatedAt":"2026-07-04T11:50:36.1058327","children":[]},{"id":23,"parentId":1,"name":"测试2","icon":"alert-triangle","sort":0,"createdAt":"2026-07-04T11:51:47.9096876","updatedAt":"2026-07-04T11:51:47.9096876","children":[]},{"id":24,"parentId":1,"name":"测试3","icon":"aperture","sort":0,"createdAt":"2026-07-04T11:53:28.595894","updatedAt":"2026-07-04T11:53:28.595894","children":[]},{"id":25,"parentId":1,"name":"test_debug","icon":"layers","sort":0,"createdAt":"2026-07-04T11:57:49.3220922","updatedAt":"2026-07-04T11:57:49.3220922","children":[]},{"id":2,"parentId":1,"name":"AI 工具","icon":"bot","sort":1,"createdAt":"2026-07-04T09:58:39.5249273","updatedAt":"2026-07-04T09:58:39.5249273","children":[]},{"id":3,"parentId":1,"name":"开发工具","icon":"code-2","sort":2,"createdAt":"2026-07-04T09:58:39.5249273","updatedAt":"2026-07-04T09:58:39.5249273","children":[]}]}],"timestamp":1783166718609} diff --git a/backend/dotnet-tools.json b/backend/dotnet-tools.json new file mode 100644 index 0000000..807729e --- /dev/null +++ b/backend/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.9", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/browser-homepage/browser-homepage.design b/browser-homepage/browser-homepage.design new file mode 100644 index 0000000..a1a8269 --- /dev/null +++ b/browser-homepage/browser-homepage.design @@ -0,0 +1,57 @@ +{ + "data": [ + { + "id": "page-desktop", + "title": "浏览器首页 - 桌面端", + "type": "page", + "version": 1, + "createdAt": 1751620800000, + "canvasData": { + "x": 0, + "y": 0, + "group": 0 + }, + "devMetadata": { + "htmlSrc": "pages/desktop.html", + "interactions": [] + } + }, + { + "id": "page-desktop-settings", + "title": "浏览器首页 - 桌面端 - 设置面板", + "type": "page", + "version": 1, + "createdAt": 1751620800000, + "canvasData": { + "x": 620, + "y": 0, + "group": 0 + }, + "devMetadata": { + "htmlSrc": "pages/desktop-settings.html", + "interactions": [] + } + }, + { + "id": "page-mobile", + "title": "浏览器首页 - 移动端", + "type": "page", + "version": 1, + "createdAt": 1751620800000, + "canvasData": { + "x": 1240, + "y": 0, + "group": 0 + }, + "devMetadata": { + "htmlSrc": "pages/mobile.html", + "interactions": [] + } + } + ], + "config": { + "autoLayout": true, + "deviceType": "desktop", + "projectName": "浏览器首页" + } +} \ No newline at end of file diff --git a/browser-homepage/colors_and_type.css b/browser-homepage/colors_and_type.css new file mode 100644 index 0000000..df5fa52 --- /dev/null +++ b/browser-homepage/colors_and_type.css @@ -0,0 +1,101 @@ +/* ===== Browser Homepage - Brand CSS ===== */ +/* Dark theme with glassmorphism, inspired by gaming browser start pages */ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap'); + +:root { + /* ---- Brand Colors ---- */ + --color-bg-primary: #0d0d12; + --color-bg-secondary: #16161e; + --color-bg-tertiary: #1e1e2a; + --color-bg-card: rgba(30, 30, 42, 0.65); + --color-bg-card-hover: rgba(40, 40, 56, 0.75); + --color-bg-sidebar: rgba(13, 13, 18, 0.85); + --color-bg-search: rgba(22, 22, 30, 0.8); + --color-bg-input: rgba(30, 30, 42, 0.5); + --color-bg-overlay: rgba(0, 0, 0, 0.5); + + /* Text */ + --color-text-primary: #e8e8f0; + --color-text-secondary: #9494a8; + --color-text-muted: #5e5e72; + --color-text-inverse: #0d0d12; + + /* Brand accent */ + --color-brand: #6c5ce7; + --color-brand-light: #a29bfe; + --color-brand-hover: #7d6ff0; + + /* Border */ + --color-border: rgba(255, 255, 255, 0.06); + --color-border-hover: rgba(255, 255, 255, 0.12); + --color-border-active: rgba(108, 92, 231, 0.5); + + /* State colors */ + --state-success: #00b894; + --state-warning: #fdcb6e; + --state-error: #e17055; + --state-info: #74b9ff; + + /* ---- Typography ---- */ + --font-sans: 'Inter', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, sans-serif; + --font-heading: 'Inter', 'Noto Sans SC', sans-serif; + + --text-xs: 11px; + --text-sm: 13px; + --text-base: 14px; + --text-lg: 16px; + --text-xl: 20px; + --text-2xl: 24px; + --text-3xl: 32px; + + --leading-tight: 1.25; + --leading-normal: 1.5; + --leading-relaxed: 1.625; + + --tracking-tight: -0.01em; + --tracking-normal: 0em; + + /* ---- Spacing ---- */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + + /* ---- Radius ---- */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-full: 9999px; + + /* ---- Shadows ---- */ + --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.05); + --shadow-float: 0 8px 32px rgba(0, 0, 0, 0.25); + --shadow-dropdown: 0 12px 48px rgba(0, 0, 0, 0.35); + + /* ---- Glassmorphism ---- */ + --glass-bg: rgba(30, 30, 42, 0.65); + --glass-blur: 12px; + --glass-border: 1px solid rgba(255, 255, 255, 0.08); + + /* ---- Transitions ---- */ + --transition-fast: 150ms ease; + --transition-normal: 250ms ease; + --transition-slow: 400ms ease; + + /* ---- Sidebar ---- */ + --sidebar-width: 240px; + --sidebar-collapsed-width: 64px; + + /* ---- Z-index ---- */ + --z-sidebar: 100; + --z-dropdown: 200; + --z-modal: 300; + --z-tooltip: 400; +} diff --git a/browser-homepage/generation-tree.json b/browser-homepage/generation-tree.json new file mode 100644 index 0000000..10f5f4f --- /dev/null +++ b/browser-homepage/generation-tree.json @@ -0,0 +1,68 @@ +{ + "root": { + "nodeId": "gen-project-shell", + "kind": "project-shell", + "pageIds": [ + "page-desktop", + "page-desktop-settings", + "page-mobile" + ], + "sharedRegions": [ + "brand css variables", + "color palette", + "glassmorphism mixin" + ], + "privateRegions": [], + "mutableSlots": ["pageTitle", "viewportLayout"], + "status": "generated", + "children": [ + { + "nodeId": "gen-page-desktop", + "kind": "page-leaf", + "pageIds": ["page-desktop"], + "output": "pages/desktop.html", + "sharedRegions": [], + "privateRegions": [ + "sidebar with two-level nav", + "search bar with engine selector", + "link card grid" + ], + "mutableSlots": ["pageTitle"], + "status": "generated", + "children": [] + }, + { + "nodeId": "gen-page-desktop-settings", + "kind": "page-leaf", + "pageIds": ["page-desktop-settings"], + "output": "pages/desktop-settings.html", + "sharedRegions": [], + "privateRegions": [ + "settings popover panel", + "theme switch (light/dark/auto)", + "accent color picker", + "background image picker" + ], + "mutableSlots": ["pageTitle"], + "status": "generated", + "children": [] + }, + { + "nodeId": "gen-page-mobile", + "kind": "page-leaf", + "pageIds": ["page-mobile"], + "output": "pages/mobile.html", + "sharedRegions": [], + "privateRegions": [ + "top row: hamburger + search + settings gear", + "navigation drawer (avatar/profile relocated here)", + "vertical link list", + "FAB" + ], + "mutableSlots": ["pageTitle"], + "status": "generated", + "children": [] + } + ] + } +} \ No newline at end of file diff --git a/browser-homepage/orchestration-summary.json b/browser-homepage/orchestration-summary.json new file mode 100644 index 0000000..f2388f0 --- /dev/null +++ b/browser-homepage/orchestration-summary.json @@ -0,0 +1,208 @@ +{ + "project": { + "name": "浏览器首页", + "path": "D:\\Code\\MyHomePage\\browser-homepage", + "operation": "create", + "deviceType": "desktop", + "language": "zh-CN", + "dashboardMode": false, + "replicationMode": null, + "sourceUrl": null, + "visualSpecExcerpt": null, + "styleDefinitionBrief": "Dark glassmorphism browser start page, cool neutral dark palette with purple accent, Inter + Noto Sans SC, dense information layout with sidebar navigation and card grid, gaming-inspired aesthetic", + "designRead": "Browser homepage / power users / dark glassmorphism utility / medium-high density / avoid pastel or light themes", + "designDials": { + "layoutVariance": 2, + "motionIntensity": 2, + "visualDensity": 4 + }, + "styleContinuityAnchors": { + "colorSystem": { + "primaryColorRole": "Purple accent (#6c5ce7) for active states and interactive elements", + "brandHuePolicy": "Dark base palette with single purple brand hue; categories and identities use text/icons/neutral tints", + "stateColors": "success/warning/error/info semantic tokens for status indicators" + }, + "shapeSystem": "Rounded corners, radius scale 4-16px, glassmorphism cards with subtle borders", + "typographySystem": "Inter + Noto Sans SC, sans-serif only, clean weight hierarchy 300-700", + "spacingSystem": "Compact 4-8-12-16-20-24 rhythm, dense sidebar + spacious card grid", + "componentLanguage": "Glass cards with translucent backgrounds, subtle borders, no heavy shadows on static elements", + "surfaceAndDepth": "Static surfaces use border + translucent bg; floating layers (dropdown, modal) use deeper shadows alpha 0.25-0.35", + "imageryAndIconography": "SVG icons for navigation, brand favicons for link cards, dark background aesthetic", + "interactionTone": "Subtle hover feedback, smooth transitions, clean focus states" + }, + "specsConstraints": null, + "sharedProjectShellContract": { + "navigationShell": "Left sidebar with two-level category navigation, collapsible on mobile", + "primaryColorSystem": "Single purple accent hue #6c5ce7 for active states and primary interactions", + "typographySystem": "Inter + Noto Sans SC, text-xs(11px) to text-3xl(32px), weights 300-700", + "radiusScale": "sm:4px, md:8px, lg:12px, xl:16px, full:9999px", + "surfaceDepthModel": "Static surfaces: border + translucent bg, shadow alpha <=0.05. Floating layers: shadow alpha 0.25-0.35", + "ctaStyle": "Rounded buttons with glass effect, subtle hover transitions", + "alignmentRules": "Left-aligned content areas, centered search bar, grid layout for cards" + }, + "generationTree": { + "root": { + "nodeId": "gen-project-shell", + "kind": "project-shell", + "pageIds": ["page-desktop", "page-desktop-settings", "page-mobile"], + "output": "partials/project-shell.html", + "sharedRegions": ["brand css variables", "color palette", "glassmorphism mixin"], + "privateRegions": [], + "mutableSlots": ["pageTitle", "viewportLayout"], + "status": "generated", + "children": [ + { + "nodeId": "gen-page-desktop", + "kind": "page-leaf", + "pageIds": ["page-desktop"], + "output": "pages/desktop.html", + "sharedRegions": [], + "privateRegions": ["sidebar with two-level nav", "search bar with engine selector", "link card grid"], + "mutableSlots": ["pageTitle"], + "status": "generated", + "children": [] + }, + { + "nodeId": "gen-page-desktop-settings", + "kind": "page-leaf", + "pageIds": ["page-desktop-settings"], + "output": "pages/desktop-settings.html", + "sharedRegions": [], + "privateRegions": ["settings popover panel", "theme switch (light/dark/auto)", "accent color picker", "background image picker"], + "mutableSlots": ["pageTitle"], + "status": "generated", + "children": [] + }, + { + "nodeId": "gen-page-mobile", + "kind": "page-leaf", + "pageIds": ["page-mobile"], + "output": "pages/mobile.html", + "sharedRegions": [], + "privateRegions": ["top row hamburger + search + settings gear", "navigation drawer with avatar/profile", "vertical link list", "FAB"], + "mutableSlots": ["pageTitle"], + "status": "generated", + "children": [] + } + ] + } + } + }, + "designSource": { + "operatingMode": "free-explore", + "libraryIdentity": { + "name": null, + "id": null, + "version": null, + "scope": null, + "path": null, + "versionSource": null + }, + "cssFilePath": "D:\\Code\\MyHomePage\\browser-homepage\\colors_and_type.css", + "brandPrefix": "bh", + "themeMode": "dark", + "designDecisionSummary": "Dark glassmorphism theme with purple accent, translucent cards, two-level sidebar navigation, Inter+Noto Sans SC typography", + "styleConstraints": { + "radiusMax": 16, + "spacingBase": 4, + "fontSizeBody": 14, + "fontSizeMin": 11, + "controlHeightDefault": 36, + "controlHeightLarge": 42 + }, + "productContext": { + "kitType": null, + "productType": "Browser homepage / navigation tool" + }, + "actualTokenNameReference": [] + }, + "pages": [ + { + "nodeId": "page-desktop", + "slug": "desktop", + "title": "浏览器首页 - 桌面端", + "htmlSrc": "pages/desktop.html", + "pageIndex": 1, + "stateGroupId": null, + "stateRole": null, + "baseStatePageId": null, + "sharedShellContract": [], + "mutableRegions": [], + "derivedFromHtmlSrc": null, + "derivationType": "original", + "sourcePageId": null, + "sourceHtmlSrc": null, + "pageType": "information-dense", + "businessScenario": "Desktop browser homepage with two-level category sidebar, search bar with engine selector, and link card grid", + "visualNorthStar": "Dark glassmorphism sidebar + translucent card grid, dense information architecture, purple accent highlights", + "compositionPattern": "Two-column asymmetric layout: fixed sidebar (20% width) + scrollable main content (80% width)", + "continuityAnchors": ["dark glassmorphism card style", "purple accent for active states", "Inter + Noto Sans SC typography"], + "libraryRestraintMode": false, + "uiKitPath": null, + "componentPlan": [], + "imagePlan": [], + "chartsRequired": false, + "miniProgramStyle": false, + "qualityRisks": ["dense sidebar navigation may need careful spacing", "two-level nav expand/collapse interaction"] + }, + { + "nodeId": "page-desktop-settings", + "slug": "desktop-settings", + "title": "浏览器首页 - 桌面端 - 设置面板", + "htmlSrc": "pages/desktop-settings.html", + "pageIndex": 2, + "stateGroupId": null, + "stateRole": null, + "baseStatePageId": null, + "sharedShellContract": [], + "mutableRegions": [], + "derivedFromHtmlSrc": "pages/desktop.html", + "derivationType": "comparison-from-source", + "sourcePageId": "page-desktop", + "sourceHtmlSrc": "pages/desktop.html", + "pageType": "information-dense", + "businessScenario": "Desktop browser homepage with the settings popover panel opened, showing theme/accent/background controls", + "visualNorthStar": "Same dark glassmorphism base as desktop page, with floating settings popover anchored to the settings button, dimmed background", + "compositionPattern": "Source page layout skeleton + floating settings popover (right-aligned) anchored from the settings button", + "continuityAnchors": ["dark glassmorphism card style", "purple accent for active states", "Inter + Noto Sans SC typography"], + "libraryRestraintMode": false, + "uiKitPath": null, + "componentPlan": [], + "imagePlan": [], + "chartsRequired": false, + "miniProgramStyle": false, + "qualityRisks": ["popover must not overlap sidebar critical content", "background dim layer should not fully hide source context"] + }, + { + "nodeId": "page-mobile", + "slug": "mobile", + "title": "浏览器首页 - 移动端", + "htmlSrc": "pages/mobile.html", + "pageIndex": 3, + "stateGroupId": null, + "stateRole": null, + "baseStatePageId": null, + "sharedShellContract": [], + "mutableRegions": [], + "derivedFromHtmlSrc": null, + "derivationType": "original", + "sourcePageId": null, + "sourceHtmlSrc": null, + "pageType": "information-dense", + "businessScenario": "Mobile browser homepage with single-row top bar (hamburger + search + settings gear), avatar/profile relocated into hamburger drawer, vertical link list and FAB", + "visualNorthStar": "Compact mobile-first dark layout, single-row top bar with hamburger + search + gear, drawer for categories + profile", + "compositionPattern": "Single-column mobile layout: single-row top bar + horizontal category tabs + vertical link list + bottom-right FAB", + "continuityAnchors": ["dark glassmorphism card style", "purple accent for active states", "Inter + Noto Sans SC typography"], + "libraryRestraintMode": false, + "uiKitPath": null, + "componentPlan": [], + "imagePlan": [], + "chartsRequired": false, + "miniProgramStyle": false, + "qualityRisks": ["top row three elements must not crowd search input", "touch targets must be >= 44px"] + } + ], + "assets": [], + "wiringPlan": [], + "hiddenInteractionPlan": [] +} diff --git a/browser-homepage/pages/desktop-settings.html b/browser-homepage/pages/desktop-settings.html new file mode 100644 index 0000000..8db28dc --- /dev/null +++ b/browser-homepage/pages/desktop-settings.html @@ -0,0 +1,493 @@ + + + + + + 浏览器首页 - 设置 + + + + + + + +
+ + + + +
+ + + +
+ + + + + diff --git a/browser-homepage/pages/desktop.html b/browser-homepage/pages/desktop.html new file mode 100644 index 0000000..8681d33 --- /dev/null +++ b/browser-homepage/pages/desktop.html @@ -0,0 +1,576 @@ + + + + + + 浏览器首页 - 桌面端 + + + + + + + +
+ + + + + +
+ + + + + diff --git a/browser-homepage/pages/mobile.html b/browser-homepage/pages/mobile.html new file mode 100644 index 0000000..1892097 --- /dev/null +++ b/browser-homepage/pages/mobile.html @@ -0,0 +1,597 @@ + + + + + + 浏览器首页 + + + + + + + +
+ + +
+ + + + + +
+ + + + + + +
+ + + +
+ + + + + +
+ + +
+ + + + +
+
+

ChatGPT

+

AI 对话助手

+
+ +
+ + + +
+ + + +
+
+

GitHub

+

代码托管平台

+
+ +
+ + + +
+ + + + +
+
+

Stack Overflow

+

开发者问答社区

+
+ +
+ + + +
+ + + + + + +
+
+

MDN Web Docs

+

Web 技术文档

+
+ +
+ + + +
+ + + + +
+
+

VS Code

+

代码编辑器

+
+ +
+ + + +
+ + + +
+
+

Notion

+

协作笔记工具

+
+ +
+
+ + + + + + + + + + +
+ + + + + \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..75751e4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,78 @@ +# 完整 Docker Compose 部署:MySQL + 后端(内嵌前端 + Nginx 反代可选) +# 使用方法:docker compose up -d +# 完整部署手册:docs/DEPLOY.md + +services: + mysql: + image: mysql:8.0 + container_name: myhomepage-mysql + restart: always # 开机自启(生产用 always,不用 unless-stopped) + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpw} + MYSQL_DATABASE: myhomepage + MYSQL_USER: myhomepage + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-myhomepagepw} + TZ: Asia/Shanghai # 时区 + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --default-time-zone=+08:00 + volumes: + - mysql-data:/var/lib/mysql + # 不暴露 3306 到宿主机(默认安全);如需远程连接改为 "127.0.0.1:3306:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "myhomepage", "-p${MYSQL_PASSWORD:-myhomepagepw}"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s # 首次启动给 30s 宽限 + logging: # P47:日志轮转(单文件 10M / 保留 3 份) + driver: json-file + options: + max-size: "10m" + max-file: "3" + + backend: + build: + context: . + dockerfile: docker/backend.Dockerfile + image: myhomepage-backend:latest # 显式打 tag,方便回滚 + container_name: myhomepage-backend + restart: always + depends_on: + mysql: + condition: service_healthy + environment: + # === 数据库 === + Database__Provider: MySql + Database__ConnectionString: "Server=mysql;Port=3306;Database=myhomepage;Uid=myhomepage;Pwd=${MYSQL_PASSWORD:-myhomepagepw};CharSet=utf8mb4;SslMode=None;AllowPublicKeyRetrieval=true;" + # === 上传 === + Upload__Path: /app/Uploads + Upload__BaseUrl: /uploads + Upload__MaxSizeBytes: 10485760 # 10MB + # === CORS(生产域名按需加) === + Cors__Origins__0: "http://localhost" + Cors__Origins__1: "http://localhost:8080" + # Cors__Origins__2: "https://yourdomain.com" # ← 改成你的域名 + # === 日志级别 === + Logging__LogLevel__Default: Information + Logging__LogLevel__Microsoft.AspNetCore: Warning + Logging__LogLevel__SqlSugar: Information + # === 时区 === + TZ: Asia/Shanghai + volumes: + - uploads-data:/app/Uploads # 上传文件持久化 + ports: + - "8080:8080" # 直连模式(用 Nginx 反代时改 "127.0.0.1:8080:8080") + healthcheck: # P47:后端健康检查(K8s / 监控依赖) + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s # 首次启动给 60s(编译 + 建表 + 种子) + logging: # P47:日志轮转 + driver: json-file + options: + max-size: "10m" + max-file: "5" + +volumes: + mysql-data: + uploads-data: diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..8f30ef4 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,44 @@ +# Docker 部署说明 + +## 快速启动 + +```bash +# 1. 启动(首次会构建镜像,5-10 分钟) +docker compose up -d --build + +# 2. 查看日志 +docker compose logs -f backend + +# 3. 停止 +docker compose down + +# 4. 清理(含数据卷) +docker compose down -v +``` + +启动后访问: +- 前端 + 后端(同一镜像): http://localhost:8080 +- Swagger: http://localhost:8080/swagger +- MySQL: 127.0.0.1:3306(用户 `myhomepage`,密码见 `docker-compose.yml` 或环境变量 `MYSQL_PASSWORD`) + +## 数据库切换 + +默认使用 MySQL。如需切换到 SQLite,修改 `docker-compose.yml` 中 `backend` 的环境变量: + +```yaml +Database__Provider: Sqlite +Database__ConnectionString: "Data Source=/app/data/myhomepage.db" +volumes: + - sqlite-data:/app/data +``` + +并添加卷: +```yaml +volumes: + sqlite-data: +``` + +## 上传目录 + +上传文件落到容器内 `/app/Uploads`,通过 `uploads-data` 卷持久化。 +可通过环境变量 `Upload__Path` 修改(必须以 `/` 开头表示容器内绝对路径)。 diff --git a/docker/backend.Dockerfile b/docker/backend.Dockerfile new file mode 100644 index 0000000..3614688 --- /dev/null +++ b/docker/backend.Dockerfile @@ -0,0 +1,29 @@ +# 多阶段构建:先编译前端,再打包后端 + 前端 + Nginx +# 第一阶段:编译前端 +FROM node:20-alpine AS frontend +WORKDIR /web +# 仅复制 package.json 以利用缓存 +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm install --no-audit --no-fund +COPY frontend/ ./ +RUN npm run build + +# 第二阶段:发布 .NET 后端 +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS backend +WORKDIR /src +COPY backend/*.csproj ./ +RUN dotnet restore +COPY backend/ ./ +RUN dotnet publish -c Release -o /app /p:UseAppHost=false + +# 第三阶段:运行时镜像(Nginx + .NET) +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +WORKDIR /app +COPY --from=backend /app ./ +# 把前端构建产物复制到后端的 wwwroot +COPY --from=frontend /web/dist ./wwwroot + +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Production +EXPOSE 8080 +ENTRYPOINT ["dotnet", "MyHomePage.Api.dll"] diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..ff7160c --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,26 @@ +# 反向给本机后端的示例(仅供参考;当前部署不依赖 nginx,前后端同镜像) +server { + listen 80; + server_name _; + + client_max_body_size 20M; + + # 前端静态资源 + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + } + + # 后端 API + location /api/ { + proxy_pass http://backend:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # 上传文件 + location /uploads/ { + proxy_pass http://backend:8080; + } +} diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..a8b3ad9 --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,378 @@ +# MyHomePage 1Panel 部署手册 + +> 适用环境:服务器已装 1Panel(v2.x)+ 1Panel 内置 MySQL(已部署并启动) +> 全程不碰 Docker / nginx.conf / systemd —— 全部交给 1Panel 面板管理 + +--- + +## 一、部署架构 + +``` + [公网/局域网] + │ + ┌───────────────┴───────────────┐ + │ │ + 域名 A(前端) 域名 B(后端,可选) + │ │ + ▼ ▼ + 1Panel 静态网站 1Panel .NET 运行时 + (Nginx 反代) (Kestrel) + │ │ + │ /api/* 反代到 127.0.0.1:8080 │ + └──────────────┐ ┌──────────────┘ + ▼ ▼ + 1Panel MySQL + (本机 127.0.0.1:3306) +``` + +**一句话总结**:MySQL 已部署 + 前端走静态网站 + 后端走 .NET 运行环境 = 三步上线。 + +--- + +## 二、前置准备 + +### 2.1 服务器侧 + +| 条件 | 说明 | +|------|------| +| 1Panel v2.x | 已装好(主人的情况) | +| MySQL 实例 | 1Panel 应用商店装的 MySQL,已 running | +| 域名 | 可选:解析 A 记录到服务器 IP;没域名也能用 IP+端口 | +| 1Panel 防火墙 | 默认放行 80/443;后端用 8080 需手动放行 | + +### 2.2 本地编译 + +#### 2.2.1 编译后端 + +```powershell +cd d:\Code\MyHomePage\backend +dotnet publish -c Release -o ..\publish\backend +``` + +产物:`publish\backend\`(约 80MB,含 .dll / appsettings.json / wwwroot/ 等)。 + +#### 2.2.2 编译前端 + +```powershell +cd d:\Code\MyHomePage\frontend +npm install # 首次或依赖变化时 +npm run build +``` + +产物:`frontend\dist\`(约 400KB,含 index.html / assets/)。 + +### 2.3 上传产物到服务器 + +**方式 1:1Panel 文件管理(推荐,Web 拖拽)** +- 1Panel → 文件 → 进入 `/opt/1panel/apps/` +- 新建两个文件夹:`myhomepage-backend` / `myhomepage-frontend` +- 分别拖入编译产物 + +**方式 2:scp 上传(打 zip 后传一次)** + +```powershell +cd d:\Code\MyHomePage +Compress-Archive -Path publish\backend,frontend\dist -DestinationPath myhomepage.zip +scp myhomepage.zip root@your-server:/opt/1panel/apps/ +``` + +服务器侧: + +```bash +cd /opt/1panel/apps/ +unzip myhomepage.zip +mv publish/backend myhomepage-backend +mv frontend/dist myhomepage-frontend +``` + +--- + +## 三、第一步:1Panel 准备 MySQL + +### 3.1 创建数据库 + +- 1Panel → 数据库 → MySQL → 你的实例 → **创建数据库** +- 数据库名:`myhomepage` +- 字符集:`utf8mb4` +- 排序规则:`utf8mb4_unicode_ci` + +### 3.2 创建用户并授权 + +- 1Panel → 数据库 → MySQL → 你的实例 → **创建用户** +- 用户名:`myhomepage_user` +- 密码:自定义或自动生成(**复制保存**!) +- 主机:**`localhost`**(只允许本机连接,禁止公网) +- 授权:库 `myhomepage`,权限 `ALL` + +### 3.3 记录连接信息 + +| 字段 | 值 | +|------|-----| +| host | `127.0.0.1` | +| port | `3306` | +| database | `myhomepage` | +| user | `myhomepage_user` | +| password | (你刚设的) | + +--- + +## 四、第二步:部署后端 + +### 4.1 上传后端文件 + +把 `publish\backend\` 内**所有文件**传到 `/opt/1panel/apps/myhomepage-backend/`。 + +最终目录结构(参考): + +``` +/opt/1panel/apps/myhomepage-backend/ +├── MyHomePage.Api.dll +├── appsettings.json +├── appsettings.Production.json +├── web.config # 1Panel 运行环境会自动生成 +├── Uploads/ # 后面建 +├── wwwroot/ # 后端静态资源(如果有) +└── ... +``` + +### 4.2 创建 .NET 网站 + +- 1Panel → **网站** → **创建网站** +- 类型:选择「**运行环境**」标签 → 选 **.NET**(如果应用商店没装 .NET Runtime,先去应用商店搜 `.NET` 安装) +- 主域名:填后端域名(如 `api.example.com`),或留空用 `IP:8080` +- 端口:`8080` +- 代号:`myhomepage-api` +- 网站目录:`/opt/1panel/apps/myhomepage-backend` +- 启动命令:`dotnet MyHomePage.Api.dll --urls http://0.0.0.0:8080` + +### 4.3 配置环境变量 + +在创建好的网站详情页 → 「环境变量」或「配置文件」tab,添加: + +| 变量名 | 值 | 说明 | +|--------|-----|------| +| `ASPNETCORE_ENVIRONMENT` | `Production` | 启用生产配置 | +| `Database__Provider` | `MySql` | 切换到 MySQL | +| `Database__ConnectionString` | `server=127.0.0.1;port=3306;database=myhomepage;user=myhomepage_user;password=你的密码;charset=utf8mb4;` | 连接串 | +| `Upload__Path` | `/opt/1panel/apps/myhomepage-backend/Uploads` | 上传文件落盘路径 | +| `Upload__BaseUrl` | `https://你的前端域名/uploads` | 前端拼接 URL 前缀 | +| `Cors__Origins__0` | `https://你的前端域名` | 允许跨域的白名单(必须) | + +> 双下划线 `__` 是 ASP.NET Core 配置嵌套的语法,等价于 JSON 里的 `{ "Cors": { "Origins": [ ... ] } }`。 +> 如果主人在前端用 `VITE_API_BASE=https://api.example.com` 直接走独立域名,跨域问题更彻底。 + +### 4.4 创建上传目录 + +- 1Panel → 文件 → 在 `/opt/1panel/apps/myhomepage-backend/` 下新建 `Uploads/` +- 权限 755(默认即可) + +### 4.5 启动后端 + +- 网站详情页 → **启动** 按钮 +- 等 5-10 秒 → **日志** tab 应该看到: + ``` + Now listening on: http://0.0.0.0:8080 + Application started. + ``` + +### 4.6 本地验证 + +```bash +# 服务器侧 +curl http://127.0.0.1:8080/health +# 期望:OK +``` + +看到 200 = 后端已就绪 ✅ + +--- + +## 五、第三步:部署前端 + +### 5.1 上传前端文件 + +把 `frontend\dist\` 内**所有文件**传到 `/opt/1panel/apps/myhomepage-frontend/`。 + +### 5.2 创建静态网站 + +- 1Panel → **网站** → **创建网站** +- 类型:选择「**静态网站**」标签 +- 主域名:填前端域名(如 `home.example.com`) +- 端口:`80`(HTTP)或 `443`(HTTPS) +- 代号:`myhomepage-web` +- 网站目录:`/opt/1panel/apps/myhomepage-frontend` + +### 5.3 申请 SSL(强烈推荐) + +- 网站详情页 → **SSL** → **申请** → Let's Encrypt +- 1Panel 自动续期 + +### 5.4 配置反向代理(关键!) + +网站详情页 → **反向代理** 或直接编辑 **配置文件**(Nginx),加这段: + +```nginx +# /api/* 转发到后端 +location /api/ { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 60s; +} + +# /uploads/* 转发到后端(用户上传图片) +location /uploads/ { + proxy_pass http://127.0.0.1:8080; +} +``` + +> 如果前端 `.env.production` 配了 `VITE_API_BASE=https://api.example.com`,**跳过本节**(前后端走不同域名更干净)。 + +### 5.5 验证前端 + +浏览器打开 `https://你的前端域名/` → 看到首页 = 成功 ✅ + +--- + +## 六、第四步:联调验证 + +### 6.1 必查清单 + +- [ ] 浏览器打开前端 → 首页正常渲染 +- [ ] DevTools → Network → 5 个 API 请求(settings / categories / bookmarks / search-engines / wallpaper)**全部 200** +- [ ] 新建一个分类 + 一个链接 → 刷新页面看是否持久化 +- [ ] 切换主题 / 主色调 → 刷新看是否持久化 +- [ ] 上传一张图片 → 看是否显示 +- [ ] 360 壁纸功能(在设置里启用)→ 切换间隔生效 + +### 6.2 Android APP 后端地址 + +主人后续会打包 Android。`.env.production` 或 Capacitor 配置里填: + +``` +# 同域名反代 +https://你的前端域名/api + +# 或独立后端域名 +https://api.example.com +``` + +--- + +## 七、日常运维 + +### 7.1 看日志 + +- 1Panel → 网站 → 你的网站 → **日志** tab +- 实时滚动 / 可下载 .log 文件 +- 排错先看这里 ✅ + +### 7.2 重启服务 + +- 1Panel → 网站 → 你的网站 → **重启** 按钮 +- 5-10 秒生效 + +### 7.3 备份数据库 + +- 1Panel → 数据库 → MySQL → 你的实例 → **备份** +- 1Panel 默认每天自动备份到本地目录 +- 也可「立即备份」→ 下载到本地双保险 + +### 7.4 更新后端 + +```powershell +# 本地 +cd d:\Code\MyHomePage\backend +dotnet publish -c Release -o ..\publish\backend +``` + +- 1Panel → 文件 → `/opt/1panel/apps/myhomepage-backend/` → 覆盖上传所有文件 +- 1Panel → 网站 → myhomepage-api → **重启** + +### 7.5 更新前端 + +```powershell +# 本地 +cd d:\Code\MyHomePage\frontend +npm run build +``` + +- 1Panel → 文件 → `/opt/1panel/apps/myhomepage-frontend/` → 覆盖上传所有文件 +- 静态网站无需重启,浏览器 Ctrl+F5 强刷即可 + +### 7.6 紧急回滚 + +| 想回滚 | 操作 | +|--------|------| +| 后端代码 | 1Panel → 文件 → 把后端目录回退到上一版 → 重启 | +| 前端代码 | 1Panel → 文件 → 把前端目录回退到上一版 → 强刷 | +| 数据库 | 1Panel → 数据库 → 选昨天的备份 → 恢复(**会覆盖当前数据**) | + +--- + +## 八、常见问题 + +### Q1:前端打开是白屏 +- 1Panel → 网站 → 静态网站 → 日志查 404 +- 大概率是 `index.html` 没传,或 `assets/` 目录没传全 + +### Q2:API 全部 404 +- 检查第五步 5.4 的反向代理是否生效 +- 服务器本地 `curl http://127.0.0.1:8080/health` 验证后端活着 + +### Q3:CORS 跨域报错 +- 检查第四步 4.3 的 `Cors__Origins__0` 是否设了前端完整域名(含 `https://`) +- 重启后端 + +### Q4:数据库连接失败 +- 1Panel → 数据库 → MySQL → 看实例 running +- 服务器本地 `mysql -u myhomepage_user -p` 验证能登 +- 检查连接串的 `password=` 是不是带特殊字符(需要 URL encode) + +### Q5:上传图片 500 +- 检查 `/opt/1panel/apps/myhomepage-backend/Uploads/` 权限 755 +- 服务器 `df -h` 看磁盘 + +### Q6:移动端 / Android APP 访问不到 +- 1Panel 防火墙放行 8080(如果不走反代) +- 或在 Android 里直接填 `https://你的前端域名/api`(走反代最稳) + +### Q7:360 壁纸不显示 +- 后端环境变量里 `Upload__BaseUrl` 和 `Cors__Origins__0` 都填前端完整域名 +- 服务器能访问外网(curl `https://wallpaper.apc.360.cn` 验证) + +--- + +## 附录 A:环境变量速查表 + +| 变量 | 必填 | 示例值 | 说明 | +|------|------|--------|------| +| `ASPNETCORE_ENVIRONMENT` | 是 | `Production` | 启用生产配置 | +| `Database__Provider` | 是 | `MySql` | 固定值 | +| `Database__ConnectionString` | 是 | `server=127.0.0.1;port=3306;database=myhomepage;user=myhomepage_user;password=xxx;charset=utf8mb4;` | MySQL 连接串 | +| `Upload__Path` | 是 | `/opt/1panel/apps/myhomepage-backend/Uploads` | 上传落盘绝对路径 | +| `Upload__BaseUrl` | 是 | `https://home.example.com/uploads` | 前端拼接 URL 前缀 | +| `Cors__Origins__0` | 是 | `https://home.example.com` | 跨域白名单(多域名时加 `Cors__Origins__1`) | + +## 附录 B:目录结构速查 + +``` +/opt/1panel/apps/ +├── myhomepage-backend/ +│ ├── MyHomePage.Api.dll +│ ├── appsettings.json +│ ├── appsettings.Production.json +│ ├── Uploads/ # 用户上传 +│ └── ... +└── myhomepage-frontend/ + ├── index.html + ├── assets/ + │ ├── index-xxxxxx.js + │ └── index-xxxxxx.css + └── ... +``` diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..a7be8bd --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1,3 @@ +# 开发环境:使用 vite proxy(/api -> http://localhost:5080) +# 如果需要直连后端,把空值改为 http://localhost:5080 +VITE_API_BASE= diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000..51c8e89 --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1,3 @@ +# Capacitor Android APP 生产环境后端地址 +# APP 内不能使用 vite proxy,必须填真实后端 URL(带 https:// 或 http://) +VITE_API_BASE=http://10.0.2.2:5080 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..488c93f --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,12 @@ +node_modules +dist +.DS_Store +*.local +.env.local + +# Capacitor +android/app/build +android/build +android/.gradle +android/local.properties +android/app/release diff --git a/frontend/ANDROID.md b/frontend/ANDROID.md new file mode 100644 index 0000000..5d1ba53 --- /dev/null +++ b/frontend/ANDROID.md @@ -0,0 +1,69 @@ +# Capacitor Android 打包 + +## 前置环境 + +1. **Node.js** ≥ 18(项目用 22) +2. **JDK 17**(`java -version` 可验证) +3. **Android Studio** + Android SDK(API 34) +4. 配好 `ANDROID_HOME` 或 `ANDROID_SDK_ROOT` 环境变量 + +## 一次性配置 + +```bash +cd frontend +npm install + +# 构建前端静态资源到 dist/ +npm run build + +# 初始化 Capacitor + 创建 android 壳工程 +npx cap init cn.myhomepage.app MyHomePage --web-dir=dist +npx cap add android +``` + +## 每次更新 + +```bash +# 1. 改完前端代码 +npm run build + +# 2. 同步到 android 工程 +npx cap sync android + +# 3. 在 Android Studio 中打开并打包 +npx cap open android +# 或命令行: +cd android +./gradlew assembleDebug # 调试 APK → android/app/build/outputs/apk/debug/ +./gradlew assembleRelease # 发布 APK(需先在 build.gradle 配签名) +``` + +## APP 内后端地址 + +APP 内不能使用 vite proxy,必须指向真实后端: + +修改 `frontend/.env.production`: +``` +VITE_API_BASE=http://10.0.2.2:5080 # Android 模拟器访问宿主机 +# 或: +VITE_API_BASE=https://your-domain.com +``` + +然后 `npm run build` 重新构建。 + +## 网络权限 + +首次 `npx cap add android` 后,在 `android/app/src/main/AndroidManifest.xml` +的 `` 标签里加: + +```xml +android:usesCleartextTraffic="true" +``` + +(如已在 `capacitor.config.ts` 中设 `cleartext: true`,则通常已自动加好) + +## 常见问题 + +- **空白页 / 加载失败**:检查 `VITE_API_BASE` 是否填写,后端是否允许 CORS +- **CORS 报错**:在 `backend/appsettings.json` 的 `Cors.Origins` 中加上 `capacitor://localhost` 和 `http://localhost` +- **图片显示 404**:`Upload.BaseUrl` 与 `appsettings.{Env}.json` 保持一致,建议反代 diff --git a/frontend/capacitor.config.ts b/frontend/capacitor.config.ts new file mode 100644 index 0000000..15c040b --- /dev/null +++ b/frontend/capacitor.config.ts @@ -0,0 +1,27 @@ +/** + * Capacitor 配置:用于打包 Android APP。 + * 完整文档:https://capacitorjs.com/docs/config + */ +import type { CapacitorConfig } from '@capacitor/cli'; + +const config: CapacitorConfig = { + appId: 'cn.myhomepage.app', + appName: 'MyHomePage', + webDir: 'dist', + /** 后端地址:APP 内 webview 调用 */ + server: { + /** 留空则使用 http.ts 的相对路径 /api(仅当 Capacitor 拦截代理可用时) */ + url: undefined, + /** 启用 Capacitor HTTP 拦截,让 /api 走 native 网络栈(避免 CORS) */ + iosScheme: 'https', + androidScheme: 'https', + cleartext: true + }, + android: { + allowMixedContent: true, + captureInput: true, + webContentsDebuggingEnabled: true + } +}; + +export default config; diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a589516 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + MyHomePage · 浏览器首页 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..1333fc7 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3007 @@ +{ + "name": "myhomepage-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "myhomepage-frontend", + "version": "1.0.0", + "dependencies": { + "@capacitor/android": "^6.1.2", + "@capacitor/core": "^6.1.2", + "axios": "^1.7.2", + "lucide-vue-next": "^0.395.0", + "pinia": "^2.1.7", + "vue": "^3.4.27", + "vue-router": "^4.3.3" + }, + "devDependencies": { + "@capacitor/cli": "^6.1.2", + "@types/node": "^20.12.12", + "@vitejs/plugin-vue": "^5.0.4", + "@vue/tsconfig": "^0.5.1", + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vue-tsc": "^2.0.19" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@capacitor/android": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-6.2.1.tgz", + "integrity": "sha512-8gd4CIiQO5LAIlPIfd5mCuodBRxMMdZZEdj8qG8m+dQ1sQ2xyemVpzHmRK8qSCHorsBUCg3D62j2cp6bEBAkdw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^6.2.0" + } + }, + "node_modules/@capacitor/cli": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-6.2.1.tgz", + "integrity": "sha512-JKl0FpFge8PgQNInw12kcKieQ4BmOyazQ4JGJOfEpVXlgrX1yPhSZTPjngupzTCiK3I7q7iGG5kjun0fDqgSCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/cli-framework-output": "^2.2.5", + "@ionic/utils-fs": "^3.1.6", + "@ionic/utils-subprocess": "2.1.11", + "@ionic/utils-terminal": "^2.3.3", + "commander": "^9.3.0", + "debug": "^4.3.4", + "env-paths": "^2.2.0", + "kleur": "^4.1.4", + "native-run": "^2.0.0", + "open": "^8.4.0", + "plist": "^3.0.5", + "prompts": "^2.4.2", + "rimraf": "^4.4.1", + "semver": "^7.3.7", + "tar": "^6.1.11", + "tslib": "^2.4.0", + "xml2js": "^0.5.0" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@capacitor/core": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-6.2.1.tgz", + "integrity": "sha512-urZwxa7hVE/BnA18oCFAdizXPse6fCKanQyEqpmz6cBJ2vObwMpyJDG5jBeoSsgocS9+Ax+9vb4ducWJn0y2qQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.5.tgz", + "integrity": "sha512-HD72a71IQVBmQckDwmA8RxNVMTbxnaLbgFOl+dO5tbvW9CkkSFCv41h6fUuNsSEVgngfkn0i98HDuZC8mk+lTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.5.tgz", + "integrity": "sha512-XnYNSwfewUqxq+yjER1hxTKggftpNjFLJH0s37jcrNDwbzmbpFTQTVAp4ikNK4rd9DOebX/jbeZb8jfD86IYxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.10.tgz", + "integrity": "sha512-mZ7JEowcuGQK+SKsJXi0liYTcXd2bNMR3nE0CyTROpMECUpJeAvvaBaPGZf5ERQUPeWBVuwqAqjUmIdxhz5bxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.5", + "@ionic/utils-terminal": "2.3.3", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-process/node_modules/@ionic/utils-terminal": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.3.tgz", + "integrity": "sha512-RnuSfNZ5fLEyX3R5mtcMY97cGD1A0NVBbarsSQ6yMMfRJ5YHU7hHVyUfvZeClbqkBC/pAqI/rYJuXKCT9YeMCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.5.tgz", + "integrity": "sha512-hkm46uHvEC05X/8PHgdJi4l4zv9VQDELZTM+Kz69odtO9zZYfnt8DkfXHJqJ+PxmtiE5mk/ehJWLnn/XAczTUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-2.1.11.tgz", + "integrity": "sha512-6zCDixNmZCbMCy5np8klSxOZF85kuDyzZSTTQKQP90ZtYNCcPYmuFSzaqDwApJT4r5L3MY3JrqK1gLkc6xiUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.5", + "@ionic/utils-fs": "3.1.6", + "@ionic/utils-process": "2.1.10", + "@ionic/utils-stream": "3.1.5", + "@ionic/utils-terminal": "2.3.3", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-subprocess/node_modules/@ionic/utils-fs": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.6.tgz", + "integrity": "sha512-eikrNkK89CfGPmexjTfSWl4EYqsPSBh0Ka7by4F0PLc1hJZYtJxUZV3X4r5ecA8ikjicUmcbU7zJmAjmqutG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-subprocess/node_modules/@ionic/utils-terminal": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.3.tgz", + "integrity": "sha512-RnuSfNZ5fLEyX3R5mtcMY97cGD1A0NVBbarsSQ6yMMfRJ5YHU7hHVyUfvZeClbqkBC/pAqI/rYJuXKCT9YeMCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", + "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.5.1.tgz", + "integrity": "sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", + "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.7.tgz", + "integrity": "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/lucide-vue-next": { + "version": "0.395.0", + "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.395.0.tgz", + "integrity": "sha512-5EwqFxv6Uwi7JgQ48G8yrsFOPe6erPkjgUX7uKDbO1+B5j11duYxILpvD1ZmjmaleiK+5mOk45bv11KLYM0Ewg==", + "deprecated": "Package deprecated. Please use @lucide/vue instead.", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/native-run": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", + "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-fs": "^3.1.7", + "@ionic/utils-terminal": "^2.3.4", + "bplist-parser": "^0.3.2", + "debug": "^4.3.4", + "elementtree": "^0.1.7", + "ini": "^4.1.1", + "plist": "^3.1.0", + "split2": "^4.2.0", + "through2": "^4.0.2", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "bin": { + "native-run": "bin/native-run" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rimraf": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-4.4.1.tgz", + "integrity": "sha512-Gk8NlF062+T9CqNGn6h4tls3k6T1+/nXdOcSZVikNVtlRdYpA7wRJJMoXmuvOnLW844rPjdQ7JgXCYM6PPC/og==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^9.2.0" + }, + "bin": { + "rimraf": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "dev": true, + "license": "ISC" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..82276ba --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "myhomepage-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "MyHomePage 浏览器首页 - Vue 3 前端", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "android:sync": "cap sync android", + "android:open": "cap open android" + }, + "dependencies": { + "vue": "^3.4.27", + "vue-router": "^4.3.3", + "pinia": "^2.1.7", + "axios": "^1.7.2", + "lucide-vue-next": "^0.395.0", + "@capacitor/core": "^6.1.2", + "@capacitor/android": "^6.1.2" + }, + "devDependencies": { + "@types/node": "^20.12.12", + "@vitejs/plugin-vue": "^5.0.4", + "@vue/tsconfig": "^0.5.1", + "@capacitor/cli": "^6.1.2", + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vue-tsc": "^2.0.19" + } +} diff --git a/frontend/public/config.json b/frontend/public/config.json new file mode 100644 index 0000000..3077b06 --- /dev/null +++ b/frontend/public/config.json @@ -0,0 +1,3 @@ +{ + "apiBase": "" +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..bc24ad7 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..cf1e37c --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/frontend/src/api/bookmarks.ts b/frontend/src/api/bookmarks.ts new file mode 100644 index 0000000..310b247 --- /dev/null +++ b/frontend/src/api/bookmarks.ts @@ -0,0 +1,18 @@ +import http from './http'; +import type { Bookmark, BookmarkUpsert } from '@/types/api'; + +/** 链接列表(可按分类过滤) */ +export const fetchBookmarks = (categoryId?: number) => + http.get('/bookmarks', { params: { categoryId } }).then(r => r.data); + +/** 创建链接 */ +export const createBookmark = (body: BookmarkUpsert) => + http.post('/bookmarks', body).then(r => r.data); + +/** 更新链接 */ +export const updateBookmark = (id: number, body: BookmarkUpsert) => + http.put(`/bookmarks/${id}`, body).then(r => r.data); + +/** 删除链接(软删) */ +export const deleteBookmark = (id: number) => + http.delete(`/bookmarks/${id}`).then(r => r.data); diff --git a/frontend/src/api/categories.ts b/frontend/src/api/categories.ts new file mode 100644 index 0000000..33f88ac --- /dev/null +++ b/frontend/src/api/categories.ts @@ -0,0 +1,18 @@ +import http from './http'; +import type { Category, CategoryUpsert } from '@/types/api'; + +/** 获取全量分类(树形) */ +export const fetchCategoryTree = () => + http.get('/categories').then(r => r.data); + +/** 创建分类 */ +export const createCategory = (body: CategoryUpsert) => + http.post('/categories', body).then(r => r.data); + +/** 更新分类 */ +export const updateCategory = (id: number, body: CategoryUpsert) => + http.put(`/categories/${id}`, body).then(r => r.data); + +/** 删除分类 */ +export const deleteCategory = (id: number) => + http.delete(`/categories/${id}`).then(r => r.data); diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts new file mode 100644 index 0000000..943a925 --- /dev/null +++ b/frontend/src/api/http.ts @@ -0,0 +1,78 @@ +import axios, { type AxiosInstance, type AxiosResponse, AxiosError } from 'axios'; +import type { ApiEnvelope } from '@/types/api'; +import { loadRuntimeConfig } from '@/config'; + +/** + * 全局 axios 实例 + * -------------------------------------------------------------------- + * baseURL 优先级(initHttp 调用后): + * 1. /config.json 的 apiBase 字段(运行时配置,部署后可改 — 主人 P49 需求) + * 2. .env.production / .env.development 的 VITE_API_BASE(编译时注入,保留兼容) + * 3. /api(相对路径,走 1Panel 反代 — 默认) + * + * 初始化流程:main.ts bootstrap() → initHttp() → app.mount() + * 这样保证所有业务代码拿到的 baseURL 都是最终值,不会出现"组件已发请求但 baseURL 还没改"的竞态 + */ +function resolveBaseURL(): string { + // 编译时 VITE_API_BASE 兜底(保留旧构建兼容) + const envBase = import.meta.env.VITE_API_BASE?.trim(); + if (envBase) { + return `${envBase.replace(/\/$/, '')}/api`; + } + return '/api'; +} + +const http: AxiosInstance = axios.create({ + baseURL: resolveBaseURL(), + timeout: 15000, + headers: { 'Content-Type': 'application/json' } +}); + +/** + * 启动时调用:加载 /config.json,更新 axios 的 baseURL + * - 必须 await 完成后再 app.mount(),否则首屏请求会带错 baseURL + * - 失败时 loadRuntimeConfig 内部已兜底,baseURL 保持默认 /api + */ +export async function initHttp(): Promise { + const config = await loadRuntimeConfig(); + if (config.apiBase) { + const newBaseURL = `${config.apiBase.replace(/\/$/, '')}/api`; + http.defaults.baseURL = newBaseURL; + console.info(`[http] baseURL 已更新为 ${newBaseURL}(来源:/config.json)`); + } else { + console.info('[http] baseURL 保持 /api(来源:默认相对路径)'); + } +} + +// 响应拦截器:解包 ApiResponse +http.interceptors.response.use( + (response: AxiosResponse>) => { + const payload = response.data; + // 二进制(文件下载)透传 + if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') { + return response; + } + if (payload && typeof payload === 'object' && 'code' in payload) { + if (payload.code !== 0) { + const err = new Error(payload.message || '请求失败') as Error & { code?: number }; + err.code = payload.code; + return Promise.reject(err); + } + // 把 data 直接挂回 response.data,便于调用方直接拿到业务数据 + response.data = payload.data as never; + } + return response; + }, + (error: AxiosError) => { + const status = error.response?.status; + const message = + (error.response?.data as { message?: string } | undefined)?.message || + error.message || + '网络错误'; + const err = new Error(message) as Error & { status?: number }; + err.status = status; + return Promise.reject(err); + } +); + +export default http; diff --git a/frontend/src/api/searchEngines.ts b/frontend/src/api/searchEngines.ts new file mode 100644 index 0000000..6f9ac7a --- /dev/null +++ b/frontend/src/api/searchEngines.ts @@ -0,0 +1,18 @@ +import http from './http'; +import type { SearchEngine, SearchEngineUpsert } from '@/types/api'; + +export const fetchSearchEngines = () => + http.get('/search-engines').then(r => r.data); + +export const createSearchEngine = (body: SearchEngineUpsert) => + http.post('/search-engines', body).then(r => r.data); + +export const updateSearchEngine = (id: number, body: SearchEngineUpsert) => + http.put(`/search-engines/${id}`, body).then(r => r.data); + +export const deleteSearchEngine = (id: number) => + http.delete(`/search-engines/${id}`).then(r => r.data); + +/** 设为默认引擎(其他自动取消) */ +export const setDefaultEngine = (id: number) => + http.put(`/search-engines/${id}/default`).then(r => r.data); diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts new file mode 100644 index 0000000..6f95fdd --- /dev/null +++ b/frontend/src/api/settings.ts @@ -0,0 +1,8 @@ +import http from './http'; +import type { AppSettings, SettingUpdate } from '@/types/api'; + +export const fetchSettings = () => + http.get('/settings').then(r => r.data); + +export const updateSettings = (body: SettingUpdate) => + http.put('/settings', body).then(r => r.data); diff --git a/frontend/src/api/sync.ts b/frontend/src/api/sync.ts new file mode 100644 index 0000000..3b7e56b --- /dev/null +++ b/frontend/src/api/sync.ts @@ -0,0 +1,14 @@ +import http from './http'; +import type { SyncChangesResponse } from '@/types/api'; + +/** + * 拉取增量同步(since 可选,ISO8601 字符串)。 + * + * P34.2 修复:axios 在传 `params: { since: undefined }` 时**不会过滤 undefined**, + * 会序列化成 `?since=undefined` 字符串 → 后端 `DateTime?` 解析失败 → 400。 + * 这里显式判断:undefined / null / 空字符串都不带 since 参数(让后端走全量分支)。 + */ +export const fetchChanges = (since?: string | null) => { + const params = since ? { since } : {}; + return http.get('/sync/changes', { params }).then(r => r.data); +}; diff --git a/frontend/src/api/upload.ts b/frontend/src/api/upload.ts new file mode 100644 index 0000000..2c2fe85 --- /dev/null +++ b/frontend/src/api/upload.ts @@ -0,0 +1,12 @@ +import http from './http'; +import type { UploadResult } from '@/types/api'; + +/** 上传单个文件(FormData 方式) */ +export const uploadFile = async (file: File): Promise => { + const form = new FormData(); + form.append('file', file); + const { data } = await http.post('/upload', form, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + return data; +}; diff --git a/frontend/src/api/utility.ts b/frontend/src/api/utility.ts new file mode 100644 index 0000000..0d986fb --- /dev/null +++ b/frontend/src/api/utility.ts @@ -0,0 +1,26 @@ +import http from './http'; + +export interface FaviconResult { + /** 原始请求的 URL */ + url: string; + /** + * 抓取并保存到 upload 路径后的相对 URL(如 "/uploads/2026/07/04/favicons/xxx.png")。 + * 若抓取失败(网络/404/SSRF)则为 null,由调用方回退到默认图标。 + */ + iconUrl: string | null; +} + +/** + * P32:手动触发后端 favicon 抓取(BookmarkForm 「自动获取」按钮调用)。 + * 后端实现见 [backend/Controllers/UtilityController.cs](file:///d:/Code/MyHomePage/backend/Controllers/UtilityController.cs) 与 + * [backend/Services/FaviconService.cs](file:///d:/Code/MyHomePage/backend/Services/FaviconService.cs)。 + * + * @param url 目标网站 URL(必须 http/https) + * @returns 抓取结果;iconUrl=null 时调用方应保持原状(不修改 icon 字段) + */ +export async function fetchFavicon(url: string): Promise { + const { data } = await http.post('/utility/favicon', { url }, { + timeout: 15000 // favicon 抓取 + 下载可能稍久,给 15s + }); + return data; +} diff --git a/frontend/src/api/wallpaper.ts b/frontend/src/api/wallpaper.ts new file mode 100644 index 0000000..c8e81e1 --- /dev/null +++ b/frontend/src/api/wallpaper.ts @@ -0,0 +1,53 @@ +import http from './http'; + +/** 360 壁纸分类 */ +export interface WallpaperCategory { + id: string; + name: string; +} + +/** 随机壁纸返回结果 */ +export interface WallpaperRandom { + /** 改造后的最终 URL(带指定分辨率/画质) */ + url: string; + /** 360 接口原始 URL(调试用) */ + originalUrl: string; + width: number; + height: number; +} + +/** + * P34:拉取 360 全部分类列表(24h 缓存,由后端代理)。 + * 后端实现见 [backend/Controllers/WallpaperController.cs](file:///d:/Code/MyHomePage/backend/Controllers/WallpaperController.cs)。 + */ +export async function fetchWallpaperCategories(): Promise { + const { data } = await http.get('/wallpaper/categories', { timeout: 12000 }); + return data ?? []; +} + +/** + * P34:按分类 + 视口分辨率取 1 张随机壁纸。 + * + * @param cid 360 分类 ID(空 = 全部/推荐,后端兜底用 36=4K专区) + * @param w 视口宽度 px + * @param h 视口高度 px + */ +export async function fetchWallpaperRandom(cid: string, w: number, h: number): Promise { + const { data } = await http.get('/wallpaper/random', { + params: { cid, w, h }, + timeout: 12000 + }); + return data; +} + +/** + * P34:立即刷新指定分类的池子(清缓存重新拉 200 张),并返回 1 张新随机图。 + * 「立即切换」按钮调用。 + */ +export async function refreshWallpaperRandom(cid: string, w: number, h: number): Promise { + const { data } = await http.post('/wallpaper/refresh', null, { + params: { cid, w, h }, + timeout: 15000 // 刷新池子要给久一点 + }); + return data; +} diff --git a/frontend/src/components/AppButton.vue b/frontend/src/components/AppButton.vue new file mode 100644 index 0000000..a1498e6 --- /dev/null +++ b/frontend/src/components/AppButton.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/frontend/src/components/AppCard.vue b/frontend/src/components/AppCard.vue new file mode 100644 index 0000000..adbeff9 --- /dev/null +++ b/frontend/src/components/AppCard.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/frontend/src/components/AppCategoryTabs.vue b/frontend/src/components/AppCategoryTabs.vue new file mode 100644 index 0000000..f1021de --- /dev/null +++ b/frontend/src/components/AppCategoryTabs.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/frontend/src/components/AppDrawer.vue b/frontend/src/components/AppDrawer.vue new file mode 100644 index 0000000..140a31c --- /dev/null +++ b/frontend/src/components/AppDrawer.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/frontend/src/components/AppFab.vue b/frontend/src/components/AppFab.vue new file mode 100644 index 0000000..63b7d33 --- /dev/null +++ b/frontend/src/components/AppFab.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/frontend/src/components/AppIcon.vue b/frontend/src/components/AppIcon.vue new file mode 100644 index 0000000..ca76aca --- /dev/null +++ b/frontend/src/components/AppIcon.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/frontend/src/components/AppIconPicker.vue b/frontend/src/components/AppIconPicker.vue new file mode 100644 index 0000000..2eb2618 --- /dev/null +++ b/frontend/src/components/AppIconPicker.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/frontend/src/components/AppLinkCard.vue b/frontend/src/components/AppLinkCard.vue new file mode 100644 index 0000000..f4eb261 --- /dev/null +++ b/frontend/src/components/AppLinkCard.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/frontend/src/components/AppLinkListItem.vue b/frontend/src/components/AppLinkListItem.vue new file mode 100644 index 0000000..fc38ac1 --- /dev/null +++ b/frontend/src/components/AppLinkListItem.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/frontend/src/components/AppMobileTopBar.vue b/frontend/src/components/AppMobileTopBar.vue new file mode 100644 index 0000000..d4ccb7c --- /dev/null +++ b/frontend/src/components/AppMobileTopBar.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/frontend/src/components/AppModal.vue b/frontend/src/components/AppModal.vue new file mode 100644 index 0000000..9b5e4d2 --- /dev/null +++ b/frontend/src/components/AppModal.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/frontend/src/components/AppSearchBar.vue b/frontend/src/components/AppSearchBar.vue new file mode 100644 index 0000000..c1c7512 --- /dev/null +++ b/frontend/src/components/AppSearchBar.vue @@ -0,0 +1,261 @@ + + + + + diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue new file mode 100644 index 0000000..6c537e5 --- /dev/null +++ b/frontend/src/components/AppSidebar.vue @@ -0,0 +1,489 @@ + + + + + diff --git a/frontend/src/components/AppToastHost.vue b/frontend/src/components/AppToastHost.vue new file mode 100644 index 0000000..28cb133 --- /dev/null +++ b/frontend/src/components/AppToastHost.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/frontend/src/components/AppWallpaper.vue b/frontend/src/components/AppWallpaper.vue new file mode 100644 index 0000000..a93ad27 --- /dev/null +++ b/frontend/src/components/AppWallpaper.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/frontend/src/components/BookmarkForm.vue b/frontend/src/components/BookmarkForm.vue new file mode 100644 index 0000000..1645da5 --- /dev/null +++ b/frontend/src/components/BookmarkForm.vue @@ -0,0 +1,522 @@ + + +