初始提交:浏览器首页 MyHomePage 全栈项目

# 项目概述
个人浏览器首页导航应用,支持书签分类管理、搜索引擎快捷搜索、
必应每日壁纸轮播、前后端分离部署,适配 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 模式)
This commit is contained in:
2026-07-05 05:09:56 +08:00
commit 68be41e7a2
129 changed files with 15900 additions and 0 deletions
+43
View File
@@ -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
+168
View File
@@ -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
+18
View File
@@ -0,0 +1,18 @@
## 忽略构建产物
bin/
obj/
## 用户专属
*.user
*.suo
.vs/
.idea/
## 上传目录的运行时文件(保留 .gitkeep)
Uploads/*
!Uploads/.gitkeep
## 本地数据库
*.db
*.db-shm
*.db-wal
+54
View File
@@ -0,0 +1,54 @@
namespace MyHomePage.Api.Common;
/// <summary>
/// 统一 API 响应包装。
/// 全部接口返回该类型,前端可依据 <see cref="Code"/> 判定业务结果。
/// </summary>
/// <typeparam name="T">业务数据类型</typeparam>
public class ApiResponse<T>
{
/// <summary>业务状态码:0 表示成功,非 0 表示错误</summary>
public int Code { get; set; }
/// <summary>提示信息</summary>
public string Message { get; set; } = string.Empty;
/// <summary>业务数据</summary>
public T? Data { get; set; }
/// <summary>服务器时间戳(毫秒)</summary>
public long Timestamp { get; set; } = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
/// <summary>构造成功响应</summary>
public static ApiResponse<T> Ok(T data, string message = "ok") =>
new() { Code = 0, Message = message, Data = data };
/// <summary>构造成功响应(无数据)</summary>
public static ApiResponse<T> Ok(string message = "ok") =>
new() { Code = 0, Message = message };
/// <summary>异步等待数据后构造成功响应(用于服务层返回 Task&lt;T&gt;</summary>
public static async Task<ApiResponse<T>> OkAsync(Task<T> dataTask, string message = "ok")
{
var data = await dataTask;
return Ok(data, message);
}
/// <summary>异步等待数据后构造成功响应(列表场景,重命名避免类型参数遮蔽)</summary>
public static async Task<ApiResponse<List<TItem>>> OkListAsync<TItem>(Task<List<TItem>> dataTask, string message = "ok")
{
var data = await dataTask;
return new ApiResponse<List<TItem>> { Code = 0, Message = message, Data = data };
}
/// <summary>构造失败响应</summary>
public static ApiResponse<T> Fail(int code, string message) =>
new() { Code = code, Message = message };
}
/// <summary>无泛型版本的快捷响应(用于无数据的接口)</summary>
public class ApiResponse : ApiResponse<object>
{
/// <summary>构造成功响应(无数据)</summary>
public static ApiResponse Ok() => new() { Code = 0, Message = "ok" };
}
+16
View File
@@ -0,0 +1,16 @@
namespace MyHomePage.Api.Common;
/// <summary>
/// 业务异常:被中间件捕获后转为 <c>ApiResponse.Fail</c>
/// 不会进入 ASP.NET Core 默认 500 处理流程。
/// </summary>
public class BusinessException : Exception
{
/// <summary>业务错误码(默认 400</summary>
public int Code { get; }
public BusinessException(string message, int code = 400) : base(message)
{
Code = code;
}
}
@@ -0,0 +1,56 @@
using System.Text.Json;
namespace MyHomePage.Api.Common;
/// <summary>
/// 全局异常处理中间件。
/// 捕获下游抛出的 <see cref="BusinessException"/> 与未处理异常,统一包装为 <see cref="ApiResponse{T}"/>。
/// </summary>
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
private readonly IHostEnvironment _env;
public ExceptionHandlingMiddleware(
RequestDelegate next,
ILogger<ExceptionHandlingMiddleware> 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<object>.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<object>.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);
}
}
@@ -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;
/// <summary>链接收藏。</summary>
[ApiController]
[Route("api/bookmarks")]
public class BookmarksController : ControllerBase
{
private readonly IBookmarkService _service;
public BookmarksController(IBookmarkService service) => _service = service;
/// <summary>获取链接列表,可按分类过滤</summary>
[HttpGet]
public async Task<ApiResponse<List<BookmarkDto>>> List([FromQuery] int? categoryId = null) =>
await ApiResponse<List<BookmarkDto>>.OkListAsync(_service.ListAsync(categoryId));
/// <summary>根据 ID 获取链接</summary>
[HttpGet("{id:int}")]
public async Task<ApiResponse<BookmarkDto>> GetById(int id)
{
var dto = await _service.GetByIdAsync(id)
?? throw new BusinessException("链接不存在", 404);
return ApiResponse<BookmarkDto>.Ok(dto);
}
/// <summary>创建链接</summary>
[HttpPost]
public async Task<ApiResponse<BookmarkDto>> Create([FromBody] BookmarkUpsertRequest request) =>
ApiResponse<BookmarkDto>.Ok(await _service.CreateAsync(request));
/// <summary>更新链接</summary>
[HttpPut("{id:int}")]
public async Task<ApiResponse<BookmarkDto>> Update(int id, [FromBody] BookmarkUpsertRequest request) =>
ApiResponse<BookmarkDto>.Ok(await _service.UpdateAsync(id, request));
/// <summary>删除链接(软删)</summary>
[HttpDelete("{id:int}")]
public async Task<ApiResponse> Delete(int id)
{
await _service.DeleteAsync(id);
return ApiResponse.Ok();
}
}
@@ -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;
/// <summary>分类管理:支持二级树形结构。</summary>
[ApiController]
[Route("api/categories")]
public class CategoriesController : ControllerBase
{
private readonly ICategoryService _service;
public CategoriesController(ICategoryService service) => _service = service;
/// <summary>获取全量分类(树形)</summary>
[HttpGet]
public async Task<ApiResponse<List<CategoryDto>>> GetTree() =>
await ApiResponse<List<CategoryDto>>.OkListAsync(_service.GetTreeAsync());
/// <summary>根据 ID 获取分类</summary>
[HttpGet("{id:int}")]
public async Task<ApiResponse<CategoryDto>> GetById(int id)
{
var dto = await _service.GetByIdAsync(id)
?? throw new BusinessException("分类不存在", 404);
return ApiResponse<CategoryDto>.Ok(dto);
}
/// <summary>创建分类</summary>
[HttpPost]
public async Task<ApiResponse<CategoryDto>> Create([FromBody] CategoryUpsertRequest request) =>
ApiResponse<CategoryDto>.Ok(await _service.CreateAsync(request));
/// <summary>更新分类</summary>
[HttpPut("{id:int}")]
public async Task<ApiResponse<CategoryDto>> Update(int id, [FromBody] CategoryUpsertRequest request) =>
ApiResponse<CategoryDto>.Ok(await _service.UpdateAsync(id, request));
/// <summary>删除分类</summary>
[HttpDelete("{id:int}")]
public async Task<ApiResponse> Delete(int id)
{
await _service.DeleteAsync(id);
return ApiResponse.Ok();
}
}
@@ -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;
/// <summary>搜索引擎管理。</summary>
[ApiController]
[Route("api/search-engines")]
public class SearchEnginesController : ControllerBase
{
private readonly ISearchEngineService _service;
public SearchEnginesController(ISearchEngineService service) => _service = service;
[HttpGet]
public async Task<ApiResponse<List<SearchEngineDto>>> List() =>
await ApiResponse<List<SearchEngineDto>>.OkListAsync(_service.ListAsync());
[HttpGet("{id:int}")]
public async Task<ApiResponse<SearchEngineDto>> GetById(int id)
{
var dto = await _service.GetByIdAsync(id)
?? throw new BusinessException("搜索引擎不存在", 404);
return ApiResponse<SearchEngineDto>.Ok(dto);
}
[HttpPost]
public async Task<ApiResponse<SearchEngineDto>> Create([FromBody] SearchEngineUpsertRequest request) =>
ApiResponse<SearchEngineDto>.Ok(await _service.CreateAsync(request));
[HttpPut("{id:int}")]
public async Task<ApiResponse<SearchEngineDto>> Update(int id, [FromBody] SearchEngineUpsertRequest request) =>
ApiResponse<SearchEngineDto>.Ok(await _service.UpdateAsync(id, request));
[HttpDelete("{id:int}")]
public async Task<ApiResponse> Delete(int id)
{
await _service.DeleteAsync(id);
return ApiResponse.Ok();
}
/// <summary>将指定 ID 的引擎设为默认(唯一)</summary>
[HttpPut("{id:int}/default")]
public async Task<ApiResponse<SearchEngineDto>> SetDefault(int id) =>
ApiResponse<SearchEngineDto>.Ok(await _service.SetDefaultAsync(id));
}
+24
View File
@@ -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;
/// <summary>用户设置:单行配置。</summary>
[ApiController]
[Route("api/settings")]
public class SettingsController : ControllerBase
{
private readonly ISettingService _service;
public SettingsController(ISettingService service) => _service = service;
[HttpGet]
public async Task<ApiResponse<SettingDto>> Get() =>
ApiResponse<SettingDto>.Ok(await _service.GetAsync());
[HttpPut]
public async Task<ApiResponse<SettingDto>> Update([FromBody] SettingUpdateRequest request) =>
ApiResponse<SettingDto>.Ok(await _service.UpdateAsync(request));
}
+45
View File
@@ -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;
/// <summary>多端同步:拉取增量变更 + 全量快照。</summary>
[ApiController]
[Route("api/sync")]
public class SyncController : ControllerBase
{
private readonly ISyncService _service;
private readonly ILogger<SyncController> _logger;
public SyncController(ISyncService service, ILogger<SyncController> logger)
{
_service = service;
_logger = logger;
}
/// <summary>
/// 拉取自 <paramref name="since"/> 之后的变更。
/// <paramref name="since"/> 为空或解析失败时返回全量(P34.2 防御:避免前端传 ?since=undefined 触发 400)。
/// </summary>
[HttpGet("changes")]
public async Task<ApiResponse<SyncChangesResponse>> 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<SyncChangesResponse>.Ok(await _service.GetChangesAsync(sinceDt));
}
}
+30
View File
@@ -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;
/// <summary>文件上传(图片为主)。</summary>
[ApiController]
[Route("api/upload")]
public class UploadController : ControllerBase
{
private readonly IUploadService _service;
public UploadController(IUploadService service) => _service = service;
/// <summary>单文件上传</summary>
/// <remarks>
/// Swashbuckle 6.x 不支持 [FromForm] IFormFile 自动生成 schema(会抛 SwaggerGeneratorException),
/// 这里用 [ApiExplorerSettings(IgnoreApi = true)] 让 swagger UI 跳过此端点的文档生成,
/// 实际 API 功能完全不受影响(前端 BookmarkForm 仍可正常调用)。
/// </remarks>
[HttpPost]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<ApiResponse<UploadResultDto>> Upload([FromForm] IFormFile file)
{
var result = await _service.SaveAsync(file);
return ApiResponse<UploadResultDto>.Ok(result);
}
}
+43
View File
@@ -0,0 +1,43 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using MyHomePage.Api.Common;
using MyHomePage.Api.Services;
namespace MyHomePage.Api.Controllers;
/// <summary>工具类 APIfavicon 抓取等小工具(手动测试用 / 调试入口)。</summary>
[ApiController]
[Route("api/utility")]
public class UtilityController : ControllerBase
{
private readonly FaviconService _favicon;
public UtilityController(FaviconService favicon) => _favicon = favicon;
/// <summary>
/// P31:手动触发 favicon 抓取(不影响正常创建流程)。
/// 任何失败(网络/404/SSRF)均返回 iconUrl=null,由调用方静默用默认。
/// </summary>
[HttpPost("favicon")]
public async Task<ApiResponse<FaviconResultDto>> 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<FaviconResultDto>.Ok(new FaviconResultDto { Url = request.Url, IconUrl = iconUrl });
}
}
/// <summary>favicon 抓取请求 DTO</summary>
public class FaviconRequest
{
[Required] public string Url { get; set; } = string.Empty;
}
/// <summary>favicon 抓取结果 DTO</summary>
public class FaviconResultDto
{
public string Url { get; set; } = string.Empty;
public string? IconUrl { get; set; }
}
@@ -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;
/// <summary>
/// 360 在线壁纸代理(P34):
/// - GET /api/wallpaper/categories 拉全部分类(24h 缓存)
/// - GET /api/wallpaper/random 按视口分辨率返回 1 张随机图
/// - POST /api/wallpaper/refresh 立即刷新分类池子并返回 1 张新随机图
/// </summary>
[ApiController]
[Route("api/wallpaper")]
public class WallpaperController : ControllerBase
{
private readonly WallpaperService _wallpaper;
private readonly ILogger<WallpaperController> _logger;
public WallpaperController(WallpaperService wallpaper, ILogger<WallpaperController> logger)
{
_wallpaper = wallpaper;
_logger = logger;
}
/// <summary>全部分类列表(24h 缓存)。失败返回空集合(前端展示「暂无可用分类」)。</summary>
[HttpGet("categories")]
public async Task<ApiResponse<List<WallpaperCategoryDto>>> GetCategories(CancellationToken ct)
{
var cats = await _wallpaper.GetCategoriesAsync(ct);
return ApiResponse<List<WallpaperCategoryDto>>.Ok(cats);
}
/// <summary>
/// 按分类 + 视口分辨率返回 1 张随机壁纸 URL。
/// 查询参数:cid(可空,例 "36")、w(视口宽 px,默认 1920)、h(视口高 px,默认 1080)。
/// </summary>
[HttpGet("random")]
public async Task<ApiResponse<WallpaperRandomDto>> 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<WallpaperRandomDto>.Ok(result);
}
/// <summary>
/// 立即刷新指定分类的池子(清缓存重新拉 200 张),并立即返回 1 张新随机图。
/// 即主人要求的「立即切换」按钮后端入口。
/// </summary>
[HttpPost("refresh")]
public async Task<ApiResponse<WallpaperRandomDto>> 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<WallpaperRandomDto>.Ok(result);
}
/// <summary>把客户端上报的 w/h 限制在合理范围(避免异常大数把 360 路径撑爆)</summary>
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);
}
}
@@ -0,0 +1,10 @@
namespace MyHomePage.Api.Infrastructure.Configuration;
/// <summary>跨域配置节点(对应 appsettings.json 中的 Cors</summary>
public class CorsOptions
{
public const string SectionName = "Cors";
/// <summary>允许的来源列表</summary>
public string[] Origins { get; set; } = Array.Empty<string>();
}
@@ -0,0 +1,13 @@
namespace MyHomePage.Api.Infrastructure.Configuration;
/// <summary>数据库配置节点(对应 appsettings.json 中的 Database</summary>
public class DatabaseOptions
{
public const string SectionName = "Database";
/// <summary>数据库提供者:MySql | Sqlite</summary>
public string Provider { get; set; } = "Sqlite";
/// <summary>连接字符串</summary>
public string ConnectionString { get; set; } = "Data Source=myhomepage.db";
}
@@ -0,0 +1,16 @@
namespace MyHomePage.Api.Infrastructure.Configuration;
/// <summary>文件上传配置节点(对应 appsettings.json 中的 Upload</summary>
public class UploadOptions
{
public const string SectionName = "Upload";
/// <summary>上传文件保存目录(相对 ContentRoot 解析)</summary>
public string Path { get; set; } = "Uploads";
/// <summary>前端访问上传文件时使用的基础 URL 前缀</summary>
public string BaseUrl { get; set; } = "/uploads";
/// <summary>单文件最大字节数(默认 10MB</summary>
public long MaxSizeBytes { get; set; } = 10 * 1024 * 1024;
}
@@ -0,0 +1,245 @@
using MyHomePage.Api.Models.Entities;
using SqlSugar;
namespace MyHomePage.Api.Infrastructure.Database;
/// <summary>
/// 数据库初始化器:CodeFirst 建表 + 种子数据。
/// 应用启动时调用一次。
/// </summary>
public class DatabaseInitializer
{
private readonly SqlSugarContext _ctx;
private readonly ILogger<DatabaseInitializer> _logger;
public DatabaseInitializer(SqlSugarContext ctx, ILogger<DatabaseInitializer> logger)
{
_ctx = ctx;
_logger = logger;
}
/// <summary>建表 + 种子数据</summary>
public async Task InitializeAsync()
{
try
{
_logger.LogInformation("开始 CodeFirst 建表...");
// ===== 兼容老库:表已存在则跳过 CodeFirst InitTablesSqlite 不支持 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;
}
}
/// <summary>为 settings 表补充新列(已存在则跳过)。</summary>
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");
}
}
/// <summary>为 bookmarks 表补充 ColorBg 列(已存在则跳过)。</summary>
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);
}
}
/// <summary>为 search_engines 表补充 IconType / IconUrl / ColorBg 列(已存在则跳过,P37 引擎图标逻辑对齐链接)。</summary>
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);
}
/// <summary>P46:给 settings 表补 OpenSearchInNewTab 列(int default 1)—— 复刻 P37/P42 的「轻量迁移」模式</summary>
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);
}
/// <summary>写入种子数据(仅当表为空时执行)</summary>
private async Task SeedAsync()
{
var db = _ctx.Db;
// 搜索引擎种子
if (!db.Queryable<SearchEngine>().Any())
{
var engines = new List<SearchEngine>
{
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<Setting>().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<Category>().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<Bookmark>
{
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("已写入分类 / 链接种子数据");
}
}
}
@@ -0,0 +1,59 @@
using Microsoft.Extensions.Options;
using MyHomePage.Api.Infrastructure.Configuration;
using SqlSugar;
namespace MyHomePage.Api.Infrastructure.Database;
/// <summary>
/// SqlSugar 上下文(单例生命周期)。
/// 根据 <see cref="DatabaseOptions.Provider"/> 自动切换 MySQL / SQLite。
/// </summary>
public class SqlSugarContext : IDisposable
{
private readonly DatabaseOptions _options;
public ISqlSugarClient Db { get; }
public SqlSugarContext(IOptions<DatabaseOptions> options)
{
_options = options.Value;
Db = new SqlSugarScope(BuildConnectionConfig(_options), BuildAopConfig());
}
/// <summary>根据配置构建 SqlSugar 连接配置</summary>
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
}
};
}
/// <summary>配置 AOP:日志 + 性能监控</summary>
private static Action<SqlSugarClient> BuildAopConfig() => db =>
{
db.Aop.OnLogExecuting = (sql, parameters) =>
{
// 由 Serilog / 默认 logger 接管,避免在控制台双打
// 这里只做轻量占位,实际日志由 SqlSugarScopeClientConfiguration 注入的 logger 输出
};
};
public void Dispose()
{
Db?.Dispose();
GC.SuppressFinalize(this);
}
}
+55
View File
@@ -0,0 +1,55 @@
using MyHomePage.Api.Models.Entities;
namespace MyHomePage.Api.Models.Dtos;
/// <summary>链接输出 DTO</summary>
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; }
/// <summary>
/// 集中映射 Bookmark → BookmarkDto。BookmarkService / SyncService 共用,
/// 避免手写 new BookmarkDto { ... } 漏字段(P28 Bug 教训)。
/// </summary>
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
};
}
/// <summary>链接创建/更新入参</summary>
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; }
}
+82
View File
@@ -0,0 +1,82 @@
using MyHomePage.Api.Models.Entities;
namespace MyHomePage.Api.Models.Dtos;
/// <summary>分类输出 DTO(包含二级子项)</summary>
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<CategoryDto> Children { get; set; } = new();
/// <summary>
/// 把扁平的 Category 实体集合构建为树形 DTO 列表。
/// 规则:parentId == 0 → 顶级;其余 → 挂到对应父分类的 Children 下。
/// 若父分类在当前集合中不存在(孤儿),降级为顶级。
/// 子项按 Sort, Id 升序排列。
/// </summary>
public static List<CategoryDto> BuildTree(IEnumerable<Category> 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);
}
/// <summary>
/// 把扁平的 DTO 集合构建为树形 DTO 列表(按 Id 重新组织父子关系)。
/// </summary>
public static List<CategoryDto> BuildTreeFromFlat(IEnumerable<CategoryDto> flat)
{
var all = flat.ToList();
var byId = all.ToDictionary(d => d.Id);
var roots = new List<CategoryDto>();
// 重置所有 Children(防止调用方传入了预填的 Children 造成重复)
foreach (var d in all) d.Children = new List<CategoryDto>();
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;
}
}
/// <summary>分类创建/更新入参</summary>
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; }
}
+49
View File
@@ -0,0 +1,49 @@
using MyHomePage.Api.Models.Entities;
namespace MyHomePage.Api.Models.Dtos;
/// <summary>搜索引擎输出 DTO</summary>
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; }
/// <summary>从实体映射(中心化转换,防止漏字段,与 BookmarkDto.FromEntity 对齐)</summary>
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
};
}
/// <summary>搜索引擎创建/更新入参</summary>
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; }
}
+59
View File
@@ -0,0 +1,59 @@
using MyHomePage.Api.Models.Entities;
namespace MyHomePage.Api.Models.Dtos;
/// <summary>设置输出 DTO</summary>
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;
/// <summary>搜索框行为(P46):true = 搜索结果在新选项卡打开(默认);false = 当前选项卡打开</summary>
public bool OpenSearchInNewTab { get; set; } = true;
// ===== P34 360 在线壁纸模式 =====
/// <summary>是否启用 360 在线壁纸(按分类随机 + 定时切换)</summary>
public bool WallpaperEnabled { get; set; } = false;
/// <summary>360 壁纸分类 ID,空字符串 = 全部/推荐</summary>
public string WallpaperCategoryId { get; set; } = "";
/// <summary>自动切换间隔(分钟),0 = 不自动切换</summary>
public int WallpaperInterval { get; set; } = 30;
public DateTime UpdatedAt { get; set; }
/// <summary>从实体构造 DTO,统一所有 Controller / Service 的转换逻辑,避免漏字段。</summary>
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
};
}
/// <summary>设置更新入参(全部可选)</summary>
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; }
/// <summary>搜索框行为(P46):true = 搜索结果在新选项卡打开(默认);false = 当前选项卡打开</summary>
public bool? OpenSearchInNewTab { get; set; }
// ===== P34 360 在线壁纸 =====
public bool? WallpaperEnabled { get; set; }
public string? WallpaperCategoryId { get; set; }
public int? WallpaperInterval { get; set; }
}
+32
View File
@@ -0,0 +1,32 @@
namespace MyHomePage.Api.Models.Dtos;
/// <summary>同步单条记录</summary>
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; }
}
/// <summary>同步响应</summary>
public class SyncChangesResponse
{
/// <summary>本次拉取的变更记录</summary>
public List<SyncChangeDto> Changes { get; set; } = new();
/// <summary>全量最新数据快照(按实体类型分组)</summary>
public SyncSnapshot Snapshot { get; set; } = new();
/// <summary>服务器当前时间(用作下次 since</summary>
public DateTime ServerTime { get; set; } = DateTime.UtcNow;
}
/// <summary>全量快照</summary>
public class SyncSnapshot
{
public List<CategoryDto> Categories { get; set; } = new();
public List<BookmarkDto> Bookmarks { get; set; } = new();
public List<SearchEngineDto> SearchEngines { get; set; } = new();
public SettingDto? Settings { get; set; }
}
+17
View File
@@ -0,0 +1,17 @@
namespace MyHomePage.Api.Models.Dtos;
/// <summary>文件上传结果</summary>
public class UploadResultDto
{
/// <summary>相对 BaseUrl 的子路径(如 2026/07/04/abc.png</summary>
public string Path { get; set; } = string.Empty;
/// <summary>前端可直接访问的完整 URL</summary>
public string Url { get; set; } = string.Empty;
/// <summary>原始文件名</summary>
public string FileName { get; set; } = string.Empty;
/// <summary>文件大小(字节)</summary>
public long Size { get; set; }
}
+29
View File
@@ -0,0 +1,29 @@
namespace MyHomePage.Api.Models.Dtos;
/// <summary>360 壁纸分类(P34</summary>
public class WallpaperCategoryDto
{
/// <summary>分类 ID(字符串,例:"36"</summary>
public string Id { get; set; } = string.Empty;
/// <summary>分类名(中文,例:"4K专区"</summary>
public string Name { get; set; } = string.Empty;
/// <summary>排序权重(P34.1 主人反馈:360 接口真实数据有此字段,降序展示)</summary>
public int OrderNum { get; set; }
}
/// <summary>随机壁纸返回结果(P34</summary>
public class WallpaperRandomDto
{
/// <summary>最终 URL(命中 360 预设分辨率的 img_* 字段,或兜底 RewriteUrl 自构)</summary>
public string Url { get; set; } = string.Empty;
/// <summary>360 接口原始 urlbdr/__85 画质低的版本,调试用)</summary>
public string OriginalUrl { get; set; } = string.Empty;
/// <summary>请求的视口宽度(px</summary>
public int Width { get; set; }
/// <summary>请求的视口高度(px</summary>
public int Height { get; set; }
/// <summary>命中的 360 预设分辨率(形如 "1600x900");未命中为 null(走 RewriteUrl 兜底)</summary>
public string? Preset { get; set; }
/// <summary>是否走 RewriteUrl 兜底(true = preset 没命中)</summary>
public bool UsedFallback { get; set; }
}
+19
View File
@@ -0,0 +1,19 @@
using SqlSugar;
namespace MyHomePage.Api.Models.Entities;
/// <summary>实体基类:所有业务表都包含主键 + 时间戳</summary>
public abstract class BaseEntity
{
/// <summary>主键(自增,使用 int 以兼容 SQLite + MySQL</summary>
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
public int Id { get; set; }
/// <summary>创建时间(UTC</summary>
[SugarColumn(IsNullable = false)]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>更新时间(UTC</summary>
[SugarColumn(IsNullable = false)]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
+48
View File
@@ -0,0 +1,48 @@
using SqlSugar;
namespace MyHomePage.Api.Models.Entities;
/// <summary>链接收藏</summary>
[SugarTable("bookmarks")]
public class Bookmark : BaseEntity
{
/// <summary>所属分类 ID</summary>
[SugarColumn(IsNullable = false, IndexGroupNameList = new[] { "idx_category" })]
public int CategoryId { get; set; }
/// <summary>链接标题</summary>
[SugarColumn(Length = 128, IsNullable = false)]
public string Title { get; set; } = string.Empty;
/// <summary>链接 URL</summary>
[SugarColumn(Length = 512, IsNullable = false)]
public string Url { get; set; } = string.Empty;
/// <summary>简介</summary>
[SugarColumn(Length = 512, IsNullable = true)]
public string? Description { get; set; }
/// <summary>图标标识:lucide 名 / emoji / 自定义 key</summary>
[SugarColumn(Length = 64, IsNullable = true)]
public string? Icon { get; set; }
/// <summary>图标类型:lucide | emoji | image</summary>
[SugarColumn(Length = 16, IsNullable = true, DefaultValue = "lucide")]
public string IconType { get; set; } = "lucide";
/// <summary>图标 URLIconType = image 时使用)</summary>
[SugarColumn(Length = 512, IsNullable = true)]
public string? IconUrl { get; set; }
/// <summary>logo 背景色(#hex / rgb / hsl);null = 自适应(由前端从 url / iconUrl 推断)</summary>
[SugarColumn(Length = 32, IsNullable = true)]
public string? ColorBg { get; set; }
/// <summary>排序值</summary>
[SugarColumn(DefaultValue = "0")]
public int Sort { get; set; }
/// <summary>软删标记</summary>
[SugarColumn(DefaultValue = "0")]
public bool IsDeleted { get; set; }
}
+24
View File
@@ -0,0 +1,24 @@
using SqlSugar;
namespace MyHomePage.Api.Models.Entities;
/// <summary>分类(二级树形,ParentId 为 0 表示一级)</summary>
[SugarTable("categories")]
public class Category : BaseEntity
{
/// <summary>父分类 ID;一级分类为 0</summary>
[SugarColumn(DefaultValue = "0")]
public int ParentId { get; set; }
/// <summary>分类名称</summary>
[SugarColumn(Length = 64, IsNullable = false)]
public string Name { get; set; } = string.Empty;
/// <summary>lucide 图标名</summary>
[SugarColumn(Length = 64, IsNullable = true)]
public string? Icon { get; set; }
/// <summary>排序值,越小越靠前</summary>
[SugarColumn(DefaultValue = "0")]
public int Sort { get; set; }
}
+40
View File
@@ -0,0 +1,40 @@
using SqlSugar;
namespace MyHomePage.Api.Models.Entities;
/// <summary>搜索引擎</summary>
[SugarTable("search_engines")]
public class SearchEngine : BaseEntity
{
/// <summary>展示名</summary>
[SugarColumn(Length = 64, IsNullable = false)]
public string Name { get; set; } = string.Empty;
/// <summary>URL 模板,必须包含 {q} 占位符</summary>
[SugarColumn(Length = 512, IsNullable = false)]
public string UrlTemplate { get; set; } = string.Empty;
/// <summary>图标类型:lucide / image / emoji(与 Bookmark.IconType 对齐)</summary>
[SugarColumn(Length = 16, IsNullable = false, DefaultValue = "lucide")]
public string IconType { get; set; } = "lucide";
/// <summary>图标内容:lucide 名 / emoji 字符(IconType=lucide/emoji 时使用)</summary>
[SugarColumn(Length = 64, IsNullable = true)]
public string? Icon { get; set; }
/// <summary>图标图片 URLIconType=image 时使用)</summary>
[SugarColumn(Length = 512, IsNullable = true)]
public string? IconUrl { get; set; }
/// <summary>logo 背景色(#hex / rgb / hsl);null = 自适应(与 Bookmark.ColorBg 对齐)</summary>
[SugarColumn(Length = 32, IsNullable = true)]
public string? ColorBg { get; set; }
/// <summary>排序值</summary>
[SugarColumn(DefaultValue = "0")]
public int Sort { get; set; }
/// <summary>是否默认引擎(应用层保证唯一)</summary>
[SugarColumn(DefaultValue = "0")]
public bool IsDefault { get; set; }
}
+44
View File
@@ -0,0 +1,44 @@
using SqlSugar;
namespace MyHomePage.Api.Models.Entities;
/// <summary>用户设置(单行记录,Id 固定为 1</summary>
[SugarTable("settings")]
public class Setting : BaseEntity
{
/// <summary>主题模式:dark | light | auto</summary>
[SugarColumn(Length = 16, IsNullable = false, DefaultValue = "dark")]
public string ThemeMode { get; set; } = "dark";
/// <summary>主色调(HEX 字符串)</summary>
[SugarColumn(Length = 16, IsNullable = false, DefaultValue = "#6c5ce7")]
public string AccentColor { get; set; } = "#6c5ce7";
/// <summary>背景图:预设 keywp1..wp6)或自定义 URL</summary>
[SugarColumn(Length = 512, IsNullable = true, DefaultValue = "wp1")]
public string? BackgroundImage { get; set; }
/// <summary>背景类型:preset | custom | solid</summary>
[SugarColumn(Length = 16, IsNullable = false, DefaultValue = "preset")]
public string BackgroundType { get; set; } = "preset";
/// <summary>链接打开方式:1 = 新选项卡(默认);0 = 当前选项卡。底层用 int 存储以兼容 SqlSugar + SQLite。</summary>
[SugarColumn(IsNullable = false, DefaultValue = "1")]
public int OpenLinksInNewTab { get; set; } = 1;
/// <summary>搜索框行为:1 = 搜索结果在新选项卡打开(默认);0 = 当前选项卡打开(P46)。</summary>
[SugarColumn(IsNullable = false, DefaultValue = "1")]
public int OpenSearchInNewTab { get; set; } = 1;
/// <summary>是否启用 360 在线壁纸模式(P34):0 = 关闭(默认,使用预设/自定义背景),1 = 开启(按分类随机 + 定时切换)</summary>
[SugarColumn(IsNullable = false, DefaultValue = "0")]
public int WallpaperEnabled { get; set; } = 0;
/// <summary>360 壁纸分类 IDP34),例如 "36"。空字符串表示「全部/推荐」。</summary>
[SugarColumn(Length = 32, IsNullable = true, DefaultValue = "")]
public string? WallpaperCategoryId { get; set; } = "";
/// <summary>壁纸自动切换间隔(分钟,P34)。默认 30。0 表示不自动切换,仅手动触发立即切换按钮。</summary>
[SugarColumn(IsNullable = false, DefaultValue = "30")]
public int WallpaperInterval { get; set; } = 30;
}
+27
View File
@@ -0,0 +1,27 @@
using SqlSugar;
namespace MyHomePage.Api.Models.Entities;
/// <summary>同步日志:每次增删改都写一条,前端通过 since=timestamp 拉取增量</summary>
[SugarTable("sync_log")]
public class SyncLog
{
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
public int Id { get; set; }
/// <summary>实体类型:category | bookmark | search_engine | setting</summary>
[SugarColumn(Length = 32, IsNullable = false, IndexGroupNameList = new[] { "idx_synclog_type" })]
public string EntityType { get; set; } = string.Empty;
/// <summary>实体 ID</summary>
[SugarColumn(IsNullable = false, IndexGroupNameList = new[] { "idx_synclog_type" })]
public int EntityId { get; set; }
/// <summary>操作:create | update | delete</summary>
[SugarColumn(Length = 16, IsNullable = false)]
public string Operation { get; set; } = "update";
/// <summary>变更时间(UTC</summary>
[SugarColumn(IsNullable = false)]
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}
+32
View File
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>MyHomePage.Api</RootNamespace>
<AssemblyName>MyHomePage.Api</AssemblyName>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<!-- SqlSugar 多数据库 ORM -->
<PackageReference Include="SqlSugarCore" Version="5.1.4.171" />
<!-- MySQL 驱动 -->
<PackageReference Include="MySqlConnector" Version="2.3.7" />
<!-- SQLite 驱动 -->
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" />
<!-- Swagger / OpenAPI -->
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
<!-- 启动时排除 Uploads 目录中的所有文件 -->
<None Update="Uploads\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
+140
View File
@@ -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<DatabaseOptions>(builder.Configuration.GetSection(DatabaseOptions.SectionName));
builder.Services.Configure<UploadOptions>(builder.Configuration.GetSection(UploadOptions.SectionName));
builder.Services.Configure<CorsOptions>(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<T>,类型签名会进 swagger
c.CustomOperationIds(apiDesc =>
{
// Controller Action 有 action 键,minimal APIMapGet / 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<string[]>() ?? Array.Empty<string>();
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<SqlSugarContext>();
// ===== 服务依赖注入 =====
builder.Services.AddScoped<SyncLogHelper>();
builder.Services.AddScoped<ISqlSugarClient>(sp => sp.GetRequiredService<SqlSugarContext>().Db);
builder.Services.AddScoped<ICategoryService, CategoryService>();
builder.Services.AddScoped<IBookmarkService, BookmarkService>();
builder.Services.AddScoped<ISearchEngineService, SearchEngineService>();
builder.Services.AddScoped<ISettingService, SettingService>();
builder.Services.AddScoped<IUploadService, UploadService>();
builder.Services.AddScoped<ISyncService, SyncService>();
builder.Services.AddScoped<DatabaseInitializer>();
// ===== P31favicon 自动抓取 =====
builder.Services.AddMemoryCache(); // IMemoryCache24h 缓存已抓 faviconSingleton
builder.Services.AddHttpClient(nameof(FaviconService), c => // 命名 HttpClientIHttpClientFactory 管理生命周期)
{
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<FaviconService>(); // 注入 IHttpClientFactory + IMemoryCache
// ===== P34360 在线壁纸代理 =====
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<WallpaperService>();
// ===== 上传文件大小限制 =====
var maxUpload = builder.Configuration.GetValue<long?>("Upload:MaxSizeBytes") ?? 10L * 1024 * 1024;
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(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<DatabaseInitializer>();
await initializer.InitializeAsync();
}
// ===== HTTP Pipeline =====
app.UseMiddleware<ExceptionHandlingMiddleware>();
// 静态文件:暴露 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();
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<DeleteExistingFiles>false</DeleteExistingFiles>
<ExcludeApp_Data>false</ExcludeApp_Data>
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<PublishProvider>FileSystem</PublishProvider>
<PublishUrl>bin\Release\net8.0\publish\</PublishUrl>
<WebPublishMethod>FileSystem</WebPublishMethod>
<_TargetId>Folder</_TargetId>
<SiteUrlToLaunchAfterPublish />
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<ProjectGuid>55a8f953-c4cd-4c72-3c4b-a1fc5ba1847b</ProjectGuid>
<SelfContained>true</SelfContained>
</PropertyGroup>
</Project>
+14
View File
@@ -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"
}
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using System.Linq.Expressions;
using MyHomePage.Api.Infrastructure.Database;
using SqlSugar;
namespace MyHomePage.Api.Repositories;
/// <summary>通用仓储接口:覆盖最常用的 CRUD 能力。</summary>
/// <typeparam name="T">实体类型</typeparam>
public interface IBaseRepository<T> where T : class, new()
{
Task<T?> GetByIdAsync(int id);
Task<List<T>> ListAsync(Expression<Func<T, bool>>? where = null);
Task<int> InsertAsync(T entity);
Task<int> UpdateAsync(T entity);
Task<int> DeleteAsync(int id);
}
/// <summary>通用仓储实现:直接包装 SqlSugar 客户端。</summary>
public class BaseRepository<T> : IBaseRepository<T> where T : class, new()
{
protected readonly ISqlSugarClient Db;
public BaseRepository(SqlSugarContext ctx)
{
Db = ctx.Db;
}
public Task<T?> GetByIdAsync(int id) =>
Db.Queryable<T>().InSingleAsync(id);
public Task<List<T>> ListAsync(Expression<Func<T, bool>>? where = null) =>
where is null
? Db.Queryable<T>().ToListAsync()
: Db.Queryable<T>().Where(where).ToListAsync();
public Task<int> InsertAsync(T entity) =>
Db.Insertable(entity).ExecuteReturnIdentityAsync();
public Task<int> UpdateAsync(T entity) =>
Db.Updateable(entity).ExecuteCommandAsync();
public Task<int> DeleteAsync(int id) =>
Db.Deleteable<T>().In(id).ExecuteCommandAsync();
}
+191
View File
@@ -0,0 +1,191 @@
using MyHomePage.Api.Common;
using MyHomePage.Api.Models.Dtos;
using MyHomePage.Api.Models.Entities;
using SqlSugar;
namespace MyHomePage.Api.Services;
/// <inheritdoc />
public class BookmarkService : IBookmarkService
{
/// <summary>判定「用户未指定图标」的默认值集合。匹配其中之一则视为未指定,触发 favicon 自动抓取。</summary>
private static readonly HashSet<string> 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;
}
/// <inheritdoc />
public async Task<List<BookmarkDto>> ListAsync(int? categoryId = null)
{
var query = _db.Queryable<Bookmark>().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();
}
/// <inheritdoc />
public async Task<BookmarkDto?> GetByIdAsync(int id)
{
var b = await _db.Queryable<Bookmark>().InSingleAsync(id);
return b is null || b.IsDeleted ? null : ToDto(b);
}
/// <inheritdoc />
public async Task<BookmarkDto> CreateAsync(BookmarkUpsertRequest request)
{
Validate(request);
// 校验分类存在
var catExists = await _db.Queryable<Category>().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);
}
/// <inheritdoc />
public async Task<BookmarkDto> UpdateAsync(int id, BookmarkUpsertRequest request)
{
Validate(request);
var entity = await _db.Queryable<Bookmark>().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);
}
/// <summary>
/// P31:判定链接是否「未指定图标」(即需要自动抓 favicon 的状态):
/// - iconUrl 为空(用户没上传图片)
/// - iconType 为 lucide 或 null(即非 image / 非 emoji
/// - icon 字段是默认值("link" / "globe" / "bookmark" / 空)
/// </summary>
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);
}
/// <summary>
/// P31:抓取并写入 favicon。失败静默(不影响主流程)。
/// 成功后:entity.IconType = "favicon"entity.IconUrl = /uploads/yyyy/MM/dd/favicons/xxx.ext
/// </summary>
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 抓取失败不影响链接创建/更新)
}
}
/// <inheritdoc />
public async Task DeleteAsync(int id)
{
var entity = await _db.Queryable<Bookmark>().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);
/// <summary>
/// 规范化颜色:空串视为 null;仅保留 #hex / rgb(...) / hsl(...) 格式。无效则置 null。
/// 长度上限 32(够 rgb / hsl / 短 hex / 长 hex)。
/// </summary>
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;
}
}
+111
View File
@@ -0,0 +1,111 @@
using MyHomePage.Api.Common;
using MyHomePage.Api.Models.Dtos;
using MyHomePage.Api.Models.Entities;
using SqlSugar;
namespace MyHomePage.Api.Services;
/// <inheritdoc />
public class CategoryService : ICategoryService
{
private readonly ISqlSugarClient _db;
private readonly SyncLogHelper _sync;
public CategoryService(ISqlSugarClient db, SyncLogHelper sync)
{
_db = db;
_sync = sync;
}
/// <inheritdoc />
public async Task<List<CategoryDto>> GetTreeAsync()
{
var all = await _db.Queryable<Category>()
.OrderBy(c => c.Sort)
.OrderBy(c => c.Id)
.ToListAsync();
return CategoryDto.BuildTree(all);
}
/// <inheritdoc />
public async Task<CategoryDto?> GetByIdAsync(int id)
{
var entity = await _db.Queryable<Category>().InSingleAsync(id);
return entity is null ? null : ToDto(entity);
}
/// <inheritdoc />
public async Task<CategoryDto> 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);
}
/// <inheritdoc />
public async Task<CategoryDto> UpdateAsync(int id, CategoryUpsertRequest request)
{
Validate(request);
var entity = await _db.Queryable<Category>().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);
}
/// <inheritdoc />
public async Task DeleteAsync(int id)
{
var entity = await _db.Queryable<Category>().InSingleAsync(id)
?? throw new BusinessException("分类不存在", 404);
// 如果是父分类,先检查是否有子分类 / 链接
if (entity.ParentId == 0)
{
var hasChildren = await _db.Queryable<Category>().AnyAsync(c => c.ParentId == id);
if (hasChildren) throw new BusinessException("请先删除子分类", 400);
}
var hasBookmarks = await _db.Queryable<Bookmark>().AnyAsync(b => b.CategoryId == id && !b.IsDeleted);
if (hasBookmarks) throw new BusinessException("该分类下仍有链接,请先删除链接", 400);
await _db.Deleteable<Category>(id).ExecuteCommandAsync();
await _sync.WriteAsync("category", id, "delete");
}
/// <summary>校验入参</summary>
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
};
}
+454
View File
@@ -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;
/// <summary>
/// 自动抓取网站 favicon。
/// P31 主链路:BookmarkService.Create/Update 检测「未指定图标」时调用本服务:
/// 1. HTTP GET 目标页面(限制 5s / 1MBUser-Agent 模拟浏览器)
/// 2. 解析 HTML &lt;link rel="icon"&gt; / apple-touch-icon / shortcut icon
/// 3. 按优先级选最佳 iconapple-touch > sizes 最大 > /favicon.ico 兜底)
/// 4. 下载 icon 图片到 Upload/favicons/ 目录
/// 5. 返回前端可访问的 URL(保存到 bookmark.IconUrl + iconType='favicon'
/// SSRF 防护:拒绝内网 / 本地 / 链路本地地址。
/// 失败时返回 null(不抛异常),由调用方走默认图标。
/// </summary>
public class FaviconService
{
private readonly IUploadService _upload;
private readonly IMemoryCache _cache;
private readonly UploadOptions _uploadOptions;
private readonly ILogger<FaviconService> _logger;
/// <summary>缓存键前缀 + 缓存时长(同一 URL 24h 内不再重抓)</summary>
private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(24);
private const string CacheKeyPrefix = "favicon:";
/// <summary>UA 字符串:模拟常见浏览器,避免被部分站点拒绝</summary>
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";
/// <summary>下载的 icon 大小上限(5MB</summary>
private const long MaxIconBytes = 5L * 1024 * 1024;
/// <summary>HttpClient 名字(与 Program.cs AddHttpClient(name) 对应)</summary>
private const string HttpClientName = nameof(FaviconService);
private readonly IHttpClientFactory _httpFactory;
public FaviconService(
IHttpClientFactory httpFactory,
IUploadService upload,
IMemoryCache cache,
IOptions<UploadOptions> uploadOptions,
ILogger<FaviconService> logger)
{
_httpFactory = httpFactory;
_upload = upload;
_cache = cache;
_uploadOptions = uploadOptions.Value;
_logger = logger;
}
/// <summary>每次调用前从 factory 取一个新 HttpClient(短生命周期,由 factory 池化)</summary>
private HttpClient NewClient() => _httpFactory.CreateClient(HttpClientName);
/// <summary>
/// 抓取 pageUrl 的 favicon 并保存到 upload 目录,返回前端可访问的 URL。
/// 任何环节失败均返回 null(不抛异常,由调用方静默用默认图标)。
/// </summary>
public async Task<string?> 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<string?>(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 → LogErrordocker 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));
/// <summary>
/// 主流程:抓 HTML → 解析 link → 选最佳 icon URL。
/// </summary>
private async Task<string?> 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;
}
/// <summary>抓取页面 HTML(限 1MB5s 超时)</summary>
private async Task<string?> 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);
}
// P33HTML 长度 + 是否含 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 <link rel=icon>: {Url} → first 200 chars: {Snippet}",
pageUri, html.Length > 0 ? html.Substring(0, Math.Min(200, html.Length)) : "(empty)");
}
return html;
}
/// <summary>
/// 解析 HTML 中的 favicon 链接。
/// P33 改进:
/// - 正则支持 rel / href 任意顺序(之前要求 rel 在前,对 href 在前的写法失败)
/// - priority 映射支持 `alternate icon` / `fluid-icon` 等包含 icon 关键字的 rel
/// - 同时解析 &lt;meta property="og:image"&gt; 作为兜底
/// - 加详细日志,方便定位"为什么没抓到"
/// </summary>
private List<IconLink> ParseIconLinks(string html, Uri baseUri)
{
var results = new List<IconLink>();
// ===== 第一步:解析 <link rel="..." href="..." [sizes] [type]> =====
// 用 .*? 懒匹配 rel/href 任意顺序;属性值允许 "..."/'...' 两种引号
var linkPattern = new Regex(
@"<link\b([^>]*?)/?>", // 整个 <link ... > 块(包括自闭合 />
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// P33 关键修复:属性名匹配前用 (?<![-\w]) 负向后行断言,
// 避免 `data-base-href` / `data-href` 等自定义 data-* 属性被误识别为 `href`。
// (之前 GitHub 真实 link 有 data-base-href,截断到下一引号,导致 favicon.svg 变成 favicon 然后 404
var attrPattern = new Regex(
@"(?<![-\w])(rel|href|size|sizes|type|as)\s*=\s*[""']([^""']*)[""']",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
foreach (Match linkMatch in linkPattern.Matches(html))
{
var block = linkMatch.Groups[1].Value;
string? rel = null, href = null, sizes = null, type = null;
foreach (Match a in attrPattern.Matches(block))
{
var name = a.Groups[1].Value.ToLowerInvariant();
var val = a.Groups[2].Value.Trim();
switch (name)
{
case "rel": rel = val; break;
case "href": href = val; break;
case "sizes": sizes = val; break;
case "type": type = val; break;
}
}
if (string.IsNullOrEmpty(rel) || string.IsNullOrEmpty(href)) continue;
if (href.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) continue;
var relLower = rel.ToLowerInvariant();
if (!relLower.Contains("icon")) continue;
if (relLower == "mask-icon") continue; // safari pinned tab mask, 不是图片
// mask-icon 之外只要含 icon 都算(含 "apple-touch-icon" / "shortcut icon" / "alternate icon" / "fluid-icon"
// 过滤掉非图片类型(极少出现但保险)
if (!string.IsNullOrEmpty(type) && !type.StartsWith("image/", StringComparison.OrdinalIgnoreCase) && !type.Contains("icon"))
continue;
// 解析 sizes
int maxSize = 0;
if (!string.IsNullOrEmpty(sizes))
{
if (sizes.Trim().Equals("any", StringComparison.OrdinalIgnoreCase))
{
maxSize = 512; // any 通常是 svg/高分辨率
}
else
{
foreach (var s in sizes.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
var parts = s.Split('x', 2);
if (parts.Length == 2 && int.TryParse(parts[0], out var w) && int.TryParse(parts[1], out var h))
{
var sz = Math.Max(w, h);
if (sz > 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
});
}
// ===== 第二步:兜底 <meta property="og:image" content="..."> =====
// 很多现代站点(特别是博客/文档站)有 og:image,作为 icon 兜底
var ogPattern = new Regex(
@"<meta\b[^>]*?\bproperty\s*=\s*[""']og:image[""'][^>]*?\bcontent\s*=\s*[""']([^""']+)[""']",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// 也匹配 content 在前的写法
var ogPatternAlt = new Regex(
@"<meta\b[^>]*?\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;
}
/// <summary>下载 icon 图片并保存到 upload 目录</summary>
private async Task<string?> 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;
}
/// <summary>SSRF 防护:解析域名 IP,拒绝内网/本地/链路本地</summary>
private async Task<bool> 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; }
}
}
+13
View File
@@ -0,0 +1,13 @@
using MyHomePage.Api.Models.Dtos;
namespace MyHomePage.Api.Services;
/// <summary>链接服务:按分类查询 + 软删。</summary>
public interface IBookmarkService
{
Task<List<BookmarkDto>> ListAsync(int? categoryId = null);
Task<BookmarkDto?> GetByIdAsync(int id);
Task<BookmarkDto> CreateAsync(BookmarkUpsertRequest request);
Task<BookmarkDto> UpdateAsync(int id, BookmarkUpsertRequest request);
Task DeleteAsync(int id);
}
+13
View File
@@ -0,0 +1,13 @@
using MyHomePage.Api.Models.Dtos;
namespace MyHomePage.Api.Services;
/// <summary>分类服务:支持二级树形结构。</summary>
public interface ICategoryService
{
Task<List<CategoryDto>> GetTreeAsync();
Task<CategoryDto?> GetByIdAsync(int id);
Task<CategoryDto> CreateAsync(CategoryUpsertRequest request);
Task<CategoryDto> UpdateAsync(int id, CategoryUpsertRequest request);
Task DeleteAsync(int id);
}
+14
View File
@@ -0,0 +1,14 @@
using MyHomePage.Api.Models.Dtos;
namespace MyHomePage.Api.Services;
/// <summary>搜索引擎服务:增删改 + 默认引擎切换(保证唯一)。</summary>
public interface ISearchEngineService
{
Task<List<SearchEngineDto>> ListAsync();
Task<SearchEngineDto?> GetByIdAsync(int id);
Task<SearchEngineDto> CreateAsync(SearchEngineUpsertRequest request);
Task<SearchEngineDto> UpdateAsync(int id, SearchEngineUpsertRequest request);
Task DeleteAsync(int id);
Task<SearchEngineDto> SetDefaultAsync(int id);
}
+10
View File
@@ -0,0 +1,10 @@
using MyHomePage.Api.Models.Dtos;
namespace MyHomePage.Api.Services;
/// <summary>设置服务:单行配置(Id=1),不存在则创建。</summary>
public interface ISettingService
{
Task<SettingDto> GetAsync();
Task<SettingDto> UpdateAsync(SettingUpdateRequest request);
}
+9
View File
@@ -0,0 +1,9 @@
using MyHomePage.Api.Models.Dtos;
namespace MyHomePage.Api.Services;
/// <summary>多端同步服务:基于 SyncLog 的增量同步 + 全量快照。</summary>
public interface ISyncService
{
Task<SyncChangesResponse> GetChangesAsync(DateTime? since);
}
+20
View File
@@ -0,0 +1,20 @@
using MyHomePage.Api.Models.Dtos;
namespace MyHomePage.Api.Services;
/// <summary>文件上传服务。</summary>
public interface IUploadService
{
/// <summary>保存浏览器上传的文件(IFormFile)。</summary>
Task<UploadResultDto> SaveAsync(IFormFile file);
/// <summary>保存任意来源的字节流(如抓取的 favicon)。</summary>
/// <param name="stream">数据流(由调用方负责释放)</param>
/// <param name="fileName">用于推断扩展名的原始文件名</param>
/// <param name="contentType">HTTP Content-Type(如 image/png</param>
/// <param name="subDir">可选子目录(如 "favicons"),用于逻辑分组</param>
Task<UploadResultDto> SaveStreamAsync(Stream stream, string fileName, string contentType, string? subDir = null);
/// <summary>确保上传根目录存在,返回根目录绝对路径。</summary>
string EnsureRoot();
}
+121
View File
@@ -0,0 +1,121 @@
using MyHomePage.Api.Common;
using MyHomePage.Api.Models.Dtos;
using MyHomePage.Api.Models.Entities;
using SqlSugar;
namespace MyHomePage.Api.Services;
/// <inheritdoc />
public class SearchEngineService : ISearchEngineService
{
private readonly ISqlSugarClient _db;
private readonly SyncLogHelper _sync;
public SearchEngineService(ISqlSugarClient db, SyncLogHelper sync)
{
_db = db;
_sync = sync;
}
/// <inheritdoc />
public async Task<List<SearchEngineDto>> ListAsync()
{
var list = await _db.Queryable<SearchEngine>()
.OrderBy(e => e.Sort)
.OrderBy(e => e.Id)
.ToListAsync();
return list.Select(SearchEngineDto.FromEntity).ToList();
}
/// <inheritdoc />
public async Task<SearchEngineDto?> GetByIdAsync(int id)
{
var e = await _db.Queryable<SearchEngine>().InSingleAsync(id);
return e is null ? null : SearchEngineDto.FromEntity(e);
}
/// <inheritdoc />
public async Task<SearchEngineDto> 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);
}
/// <inheritdoc />
public async Task<SearchEngineDto> UpdateAsync(int id, SearchEngineUpsertRequest request)
{
Validate(request);
var entity = await _db.Queryable<SearchEngine>().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);
}
/// <inheritdoc />
public async Task DeleteAsync(int id)
{
var entity = await _db.Queryable<SearchEngine>().InSingleAsync(id)
?? throw new BusinessException("搜索引擎不存在", 404);
await _db.Deleteable<SearchEngine>(id).ExecuteCommandAsync();
await _sync.WriteAsync("search_engine", id, "delete");
}
/// <inheritdoc />
public async Task<SearchEngineDto> SetDefaultAsync(int id)
{
var entity = await _db.Queryable<SearchEngine>().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);
}
/// <summary>把其他引擎的 IsDefault 全部置为 false</summary>
private async Task ResetDefaultAsync(int keepId)
{
await _db.Updateable<SearchEngine>()
.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);
}
}
+85
View File
@@ -0,0 +1,85 @@
using MyHomePage.Api.Common;
using MyHomePage.Api.Models.Dtos;
using MyHomePage.Api.Models.Entities;
using SqlSugar;
namespace MyHomePage.Api.Services;
/// <inheritdoc />
public class SettingService : ISettingService
{
private const int DefaultId = 1;
private static readonly HashSet<string> AllowedThemeModes = new(StringComparer.OrdinalIgnoreCase) { "dark", "light", "auto" };
private static readonly HashSet<string> AllowedBackgroundTypes = new(StringComparer.OrdinalIgnoreCase) { "preset", "custom", "solid" };
// ===== P34 360 壁纸切换间隔合法值(分钟)=====
private static readonly HashSet<int> 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;
}
/// <inheritdoc />
public async Task<SettingDto> GetAsync()
{
var entity = await _db.Queryable<Setting>().InSingleAsync(DefaultId);
if (entity is null)
{
// 兜底:写入默认值
entity = new Setting { Id = DefaultId };
await _db.Insertable(entity).ExecuteCommandAsync();
}
return ToDto(entity);
}
/// <inheritdoc />
public async Task<SettingDto> UpdateAsync(SettingUpdateRequest request)
{
var entity = await _db.Queryable<Setting>().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);}
+25
View File
@@ -0,0 +1,25 @@
using MyHomePage.Api.Models.Entities;
using MyHomePage.Api.Infrastructure.Database;
using SqlSugar;
namespace MyHomePage.Api.Services;
/// <summary>同步日志写入助手:增删改实体时统一调用。</summary>
public class SyncLogHelper
{
private readonly ISqlSugarClient _db;
public SyncLogHelper(ISqlSugarClient db) => _db = db;
/// <summary>写入一条同步日志</summary>
public Task WriteAsync(string entityType, int entityId, string operation)
{
return _db.Insertable(new SyncLog
{
EntityType = entityType,
EntityId = entityId,
Operation = operation,
Timestamp = DateTime.UtcNow
}).ExecuteCommandAsync();
}
}
+60
View File
@@ -0,0 +1,60 @@
using MyHomePage.Api.Models.Dtos;
using MyHomePage.Api.Models.Entities;
using SqlSugar;
namespace MyHomePage.Api.Services;
/// <inheritdoc />
public class SyncService : ISyncService
{
private readonly ISqlSugarClient _db;
public SyncService(ISqlSugarClient db) => _db = db;
/// <inheritdoc />
public async Task<SyncChangesResponse> GetChangesAsync(DateTime? since)
{
// 1. 增量变更记录
var changesQuery = _db.Queryable<SyncLog>().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<Category>().OrderBy(c => c.Sort).OrderBy(c => c.Id).ToListAsync();
snapshot.Categories = CategoryDto.BuildTree(categories);
var bookmarks = await _db.Queryable<Bookmark>().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<SearchEngine>().OrderBy(e => e.Sort).ToListAsync();
// P42 修复:用 SearchEngineDto.FromEntity 共享映射(之前手动 new 漏了 IconType/IconUrl/ColorBg
// 同步后前端 store 拿到老 DTOengineLogoStyle 命中兜底色块 → 引擎 logo "消失"
snapshot.SearchEngines = engines.Select(SearchEngineDto.FromEntity).ToList();
var setting = await _db.Queryable<Setting>().InSingleAsync(1);
if (setting is not null)
{
snapshot.Settings = SettingDto.FromEntity(setting);
}
return new SyncChangesResponse
{
Changes = changes,
Snapshot = snapshot,
ServerTime = DateTime.UtcNow
};
}
}
+144
View File
@@ -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;
/// <inheritdoc />
public class UploadService : IUploadService
{
/// <summary>允许的图片扩展名白名单(与 SaveAsync 共用)</summary>
private static readonly string[] AllowedExtensions = { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico" };
/// <summary>Content-Type 到扩展名的兜底映射</summary>
private static readonly Dictionary<string, string> 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<UploadService> _logger;
/// <summary>P51 诊断:是否已记录过 upload root(避免每次保存重复 log</summary>
private bool _rootLogged;
public UploadService(
IOptions<UploadOptions> options,
IWebHostEnvironment env,
ILogger<UploadService> logger)
{
_options = options.Value;
_env = env;
_logger = logger;
}
/// <inheritdoc />
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;
}
/// <summary>P51 诊断兼容:保留旧方法名(Program.cs 启动期若想强制 log 可调)</summary>
public string EnsureRootWithLog() => EnsureRoot();
/// <inheritdoc />
public async Task<UploadResultDto> 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);
}
/// <inheritdoc />
public async Task<UploadResultDto> 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);
}
/// <summary>实际写文件的内部流程(SaveAsync / SaveStreamAsync 共用)</summary>
private async Task<UploadResultDto> 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
};
}
}
+383
View File
@@ -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;
/// <summary>
/// 360 在线壁纸代理服务(P34 + P34.1 修正)。
///
/// 数据来源(参考 360 chrome 壁纸公开接口):
/// 1. 全部分类列表(含 order_num 排序字段)
/// http://cdn.apc.360.cn/index.php?c=WallPaper&amp;a=getAllCategoriesV2&amp;from=360chrome
/// 2. 按分类 ID 获取图片列表(每条 data 含 url + 6 个预设分辨率 + 原始分辨率)
/// http://wallpaper.apc.360.cn/index.php?c=WallPaper&amp;a=getAppsByCategory
/// &amp;cid={cid}&amp;start=0&amp;count=200&amp;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 清缓存重新拉,并立即返回一张随机图
/// </summary>
public class WallpaperService
{
private readonly IHttpClientFactory _httpFactory;
private readonly IMemoryCache _cache;
private readonly ILogger<WallpaperService> _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";
/// <summary>分类列表缓存 TTL24h,启动再热一次)</summary>
private static readonly TimeSpan CategoryTtl = TimeSpan.FromHours(24);
/// <summary>图片池缓存 TTL12h</summary>
private static readonly TimeSpan PoolTtl = TimeSpan.FromHours(12);
/// <summary>每次拉取池子的最大张数(主人决策:200 张池子)</summary>
private const int PoolCount = 200;
/// <summary>分类列表 URL</summary>
private const string CategoryUrl =
"http://cdn.apc.360.cn/index.php?c=WallPaper&a=getAllCategoriesV2&from=360chrome";
/// <summary>分类图片 URL 模板({0}=cid, {1}=start, {2}=count</summary>
private const string AppsByCategoryUrlTemplate =
"http://wallpaper.apc.360.cn/index.php?c=WallPaper&a=getAppsByCategory&cid={0}&start={1}&count={2}&from=360chrome";
/// <summary>画质固定 85(与 360 官方预设 img_*_85 一致,主人截图原始数据证实)</summary>
private const int DefaultQuality = 85;
/// <summary>
/// 360 官方为每张壁纸预设的固定分辨率 + 画质 85(P34.1 主人反馈真实接口结构)。
/// 视口尺寸进来后 → 在这 6 个 preset 里挑"宽高比例最接近且单边≥视口"的命中即用。
/// </summary>
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<WallpaperService> logger)
{
_httpFactory = httpFactory;
_cache = cache;
_logger = logger;
}
/// <summary>每次调用前从 factory 取一个新 HttpClient(短生命周期,由 factory 池化)</summary>
private HttpClient NewClient() => _httpFactory.CreateClient(HttpClientName);
// =====================================================================
// 公开 APIController 调用)
// =====================================================================
/// <summary>获取全部分类列表(24h 缓存)。失败时返回空集合(不抛)。</summary>
public async Task<List<WallpaperCategoryDto>> GetCategoriesAsync(CancellationToken ct = default)
{
const string cacheKey = "wallpaper:categories";
if (_cache.TryGetValue<List<WallpaperCategoryDto>>(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<WallpaperCategoryDto>();
}
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<WallpaperCategoryDto>();
}
}
/// <summary>
/// 取一张随机壁纸 URL(按视口分辨率选最接近的 preset,无命中走 RewriteUrl 兜底)。
/// </summary>
/// <param name="cid">分类 ID(空字符串 = 全部/推荐)</param>
/// <param name="w">视口宽度(px</param>
/// <param name="h">视口高度(px</param>
/// <param name="ct">取消令牌</param>
public async Task<WallpaperRandomDto?> 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);
}
/// <summary>强制刷新图片池(立即切换按钮使用),并立即返回一张随机图。</summary>
public async Task<WallpaperRandomDto?> 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}";
/// <summary>获取分类的图片池(缓存 12h)。无池时主动拉。</summary>
private async Task<List<PoolItem>> GetOrFetchPoolAsync(string cid, CancellationToken ct)
{
var key = PoolKey(cid);
if (_cache.TryGetValue<List<PoolItem>>(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;
}
/// <summary>实际请求 360 接口拿 200 张 PoolItem 列表(含 6 个预设分辨率 URL</summary>
private async Task<List<PoolItem>> 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<PoolItem>();
}
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<PoolItem>();
}
}
/// <summary>从池中随机选 1 张 → 用 PickBestUrl 选最佳 URL → 构造 DTO</summary>
private static WallpaperRandomDto BuildRandom(List<PoolItem> 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
};
}
/// <summary>
/// 为给定 PoolItem 选最合适的 URL。
/// 选法:先按"宽高比 (aspect) 差最小"在 6 个 preset 里挑(要求 aspect 差 &lt; 0.15),
/// 比例匹配的候选里再按"单边最接近视口"选最佳。
/// 没有任何 preset 比例匹配 → 走 RewriteUrl(quality=85) 自构 bdm/{W}_{H}_85 兜底(避免 preset 5:4 拉伸到 9:16 视口的变形)。
/// </summary>
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);
}
/// <summary>
/// 把 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 路径)。
/// </summary>
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 解析
// =====================================================================
/// <summary>
/// 解析分类 JSON{ errno, errmsg, total, data: [{ id, name, order_num, tag, create_time }] }。
/// P34.1 修正:直接按 order_num int 降序排(不再字母排序),保留 18 个原始顺序。
/// </summary>
private static List<WallpaperCategoryDto> ParseCategoryJson(string json)
{
var result = new List<WallpaperCategoryDto>();
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;
}
/// <summary>
/// 解析分类图片 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。
/// </summary>
private static List<PoolItem> ParseAppsJson(string json)
{
var result = new List<PoolItem>();
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;
}
// =====================================================================
// 内部类型
// =====================================================================
/// <summary>池子里的单条记录:原始 url + 6 个预设分辨率 URL 字典</summary>
private sealed class PoolItem
{
/// <summary>原始 bdr/__85 url(兜底用)</summary>
public string Url { get; set; } = string.Empty;
/// <summary>预设分辨率 → URL(例:{(1600,900): "http://.../bdm/1600_900_85/..."}</summary>
public Dictionary<(int W, int H), string> Presets { get; set; } = new();
}
}
+1
View File
@@ -0,0 +1 @@
+13
View File
@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information",
"SqlSugar": "Debug"
}
},
"Database": {
"Provider": "Sqlite",
"ConnectionString": "Data Source=myhomepage.dev.db"
}
}
+67
View File
@@ -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/RequiredPooling=trueTreatTinyAsBoolean=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"
]
}
}
+1
View File
@@ -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}
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.9",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
+57
View File
@@ -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": "浏览器首页"
}
}
+101
View File
@@ -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;
}
+68
View File
@@ -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": []
}
]
}
}
+208
View File
@@ -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": []
}
@@ -0,0 +1,493 @@
<!DOCTYPE html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>浏览器首页 - 设置</title>
<style id="theme-vars">
/* ===== 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;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.1/dist/index.global.js"></script>
<script src="https://unpkg.com/lucide@1.8.0/dist/umd/lucide.min.js"></script>
<style type="text/tailwindcss">
@theme inline {
--color-border: var(--color-border);
}
@layer base {
body { background: var(--color-bg-primary); color: var(--color-text-primary); }
td, th { @apply break-words; word-break: break-all; word-break: auto-phrase; }
th { @apply whitespace-nowrap; }
}
</style>
<style>
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
[data-icon] {
display: inline-flex;
align-items: center;
justify-content: center;
-webkit-mask-size: contain;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
background-color: currentColor;
}
</style>
</head>
<body class="min-h-screen font-sans antialiased" style="font-family: var(--font-sans);">
<main class="flex min-h-screen relative">
<!-- ===== SOURCE PAGE (DIMMED) ===== -->
<div class="source-page relative w-full" style="filter: brightness(0.55); pointer-events: none;">
<!-- LEFT SIDEBAR -->
<aside class="fixed left-0 top-0 bottom-0 z-[var(--z-sidebar)] flex flex-col" style="width: var(--sidebar-width); background: var(--color-bg-sidebar); border-right: var(--glass-border); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur));">
<!-- User avatar area -->
<div class="flex items-center gap-3 px-5 py-5" style="border-bottom: var(--glass-border);">
<div class="w-9 h-9 rounded-full flex items-center justify-center shrink-0" style="background: var(--color-brand); color: var(--color-text-inverse); font-size: var(--text-sm); font-weight: 600;">U</div>
<div class="min-w-0 flex-1">
<p class="truncate" style="font-size: var(--text-sm); font-weight: 600; color: var(--color-text-primary);">User</p>
<p class="truncate" style="font-size: 11px; color: var(--color-text-muted);">myhomepage</p>
</div>
</div>
<!-- Navigation tree -->
<nav class="flex-1 overflow-y-auto py-3 px-3 no-scrollbar" aria-label="Category navigation">
<a href="#" class="nav-item active flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors duration-150" data-dom-id="nav-home" style="color: var(--color-brand); background: rgba(108, 92, 231, 0.12);" data-nav-key="home" data-active="true">
<i data-lucide="home" class="w-5 h-5 shrink-0"></i>
<span style="font-size: var(--text-base); font-weight: 500;">主页</span>
</a>
<div class="mb-1">
<button class="nav-item flex items-center gap-3 px-3 py-2 rounded-lg w-full transition-colors duration-150 cursor-pointer" style="color: var(--color-text-secondary); background: transparent;" aria-expanded="true">
<i data-lucide="wrench" class="w-5 h-5 shrink-0"></i>
<span class="flex-1 text-left" style="font-size: var(--text-base); font-weight: 500;">常用工具</span>
<i data-lucide="chevron-down" class="w-4 h-4 shrink-0"></i>
</button>
<div class="sub-nav ml-5 mt-1 space-y-0.5">
<a href="#" class="sub-nav-item flex items-center gap-3 px-3 py-1.5 rounded-md transition-colors duration-150" style="color: var(--color-text-muted); background: transparent;">
<i data-lucide="search" class="w-4 h-4 shrink-0"></i>
<span style="font-size: var(--text-sm);">搜索引擎</span>
</a>
<a href="#" class="sub-nav-item flex items-center gap-3 px-3 py-1.5 rounded-md transition-colors duration-150" style="color: var(--color-text-muted); background: transparent;">
<i data-lucide="bot" class="w-4 h-4 shrink-0"></i>
<span style="font-size: var(--text-sm);">AI工具</span>
</a>
<a href="#" class="sub-nav-item flex items-center gap-3 px-3 py-1.5 rounded-md transition-colors duration-150" style="color: var(--color-text-muted); background: transparent;">
<i data-lucide="code-2" class="w-4 h-4 shrink-0"></i>
<span style="font-size: var(--text-sm);">开发工具</span>
</a>
</div>
</div>
<a href="#" class="nav-item flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors duration-150" style="color: var(--color-text-secondary); background: transparent;">
<i data-lucide="shopping-bag" class="w-5 h-5 shrink-0"></i>
<span style="font-size: var(--text-base); font-weight: 500;">购物</span>
</a>
<a href="#" class="nav-item flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors duration-150" style="color: var(--color-text-secondary); background: transparent;">
<i data-lucide="play-circle" class="w-5 h-5 shrink-0"></i>
<span style="font-size: var(--text-base); font-weight: 500;">视频</span>
</a>
<a href="#" class="nav-item flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors duration-150" style="color: var(--color-text-secondary); background: transparent;">
<i data-lucide="newspaper" class="w-5 h-5 shrink-0"></i>
<span style="font-size: var(--text-base); font-weight: 500;">资讯</span>
</a>
<a href="#" class="nav-item flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors duration-150" style="color: var(--color-text-secondary); background: transparent;">
<i data-lucide="message-circle" class="w-5 h-5 shrink-0"></i>
<span style="font-size: var(--text-base); font-weight: 500;">社交</span>
</a>
</nav>
<!-- Settings at bottom (ACTIVE STATE) -->
<div class="px-3 pb-4" style="border-top: var(--glass-border);">
<a href="#" class="nav-item flex items-center gap-3 px-3 py-2 rounded-lg mt-3 transition-colors duration-150" data-dom-id="nav-settings-active" style="color: var(--color-brand); background: rgba(108, 92, 231, 0.18);" data-nav-key="settings" data-active="true">
<i data-lucide="settings" class="w-5 h-5 shrink-0"></i>
<span style="font-size: var(--text-base); font-weight: 500;">设置</span>
</a>
</div>
</aside>
<!-- RIGHT MAIN AREA -->
<div class="flex-1 flex flex-col min-h-screen" style="margin-left: var(--sidebar-width);">
<section class="flex items-center justify-center px-8 pt-10 pb-6" aria-label="Search">
<div class="relative w-full" style="max-width: 640px;">
<div class="flex items-center rounded-lg overflow-hidden" style="background: var(--color-bg-search); border: var(--glass-border); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); height: 46px;">
<button class="flex items-center gap-2 px-3 h-full shrink-0" style="border-right: var(--glass-border); color: var(--color-text-secondary); background: transparent;">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" style="color: var(--color-brand);">
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2"/>
<path d="M12 8V4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<span style="font-size: var(--text-sm); font-weight: 500;">百度</span>
<i data-lucide="chevron-down" class="w-3.5 h-3.5"></i>
</button>
<input type="text" placeholder="搜索或输入网址" class="flex-1 h-full bg-transparent outline-none px-4" style="color: var(--color-text-primary); font-size: var(--text-base);">
<button class="flex items-center justify-center w-10 h-full shrink-0" style="color: var(--color-text-muted); background: transparent;" aria-label="Search">
<i data-lucide="search" class="w-5 h-5"></i>
</button>
</div>
</div>
</section>
<section class="flex-1 overflow-y-auto px-8 pb-8" aria-label="Link cards">
<div class="flex items-center justify-between mb-5" style="max-width: 1120px; margin-left: auto; margin-right: auto;">
<h1 style="font-size: var(--text-xl); font-weight: 600; color: var(--color-text-primary); text-wrap: balance; word-break: keep-all;">常用工具</h1>
<span style="font-size: var(--text-sm); color: var(--color-text-muted);">8 个链接</span>
</div>
<div class="grid gap-4" style="grid-template-columns: repeat(4, 1fr); max-width: 1120px; margin-left: auto; margin-right: auto;">
<a href="#" class="link-card group flex flex-col gap-3 rounded-lg p-4 transition-all duration-150" style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style="background: rgba(16, 163, 127, 0.15);">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" style="color: #10a37f;"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="currentColor"/></svg>
</div>
<h3 style="font-size: var(--text-base); font-weight: 600; color: var(--color-text-primary);">ChatGPT</h3>
</div>
<p style="font-size: var(--text-sm); color: var(--color-text-secondary); line-height: var(--leading-normal);">AI对话助手,智能问答</p>
</a>
<a href="#" class="link-card group flex flex-col gap-3 rounded-lg p-4 transition-all duration-150" style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style="background: rgba(230, 230, 230, 0.1);">
<i data-lucide="github" class="w-5 h-5" style="color: var(--color-text-primary);"></i>
</div>
<h3 style="font-size: var(--text-base); font-weight: 600; color: var(--color-text-primary);">GitHub</h3>
</div>
<p style="font-size: var(--text-sm); color: var(--color-text-secondary); line-height: var(--leading-normal);">代码托管与协作平台</p>
</a>
<a href="#" class="link-card group flex flex-col gap-3 rounded-lg p-4 transition-all duration-150" style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style="background: rgba(244, 128, 36, 0.12);">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" style="color: #f48024;"><path d="M15.73 21.02l-.6-2.35h-6.26l-.6 2.35H4.67L8.2 7h7.6l3.53 14.02h-3.6zm-1.33-5.33l-2.2-8.52h-.2l-2.2 8.52h4.6zM2 22h20v2H2v-2z" fill="currentColor"/></svg>
</div>
<h3 style="font-size: var(--text-base); font-weight: 600; color: var(--color-text-primary);">Stack Overflow</h3>
</div>
<p style="font-size: var(--text-sm); color: var(--color-text-secondary); line-height: var(--leading-normal);">开发者问答社区</p>
</a>
<a href="#" class="link-card group flex flex-col gap-3 rounded-lg p-4 transition-all duration-150" style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style="background: rgba(121, 79, 169, 0.15);">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" style="color: #794fa9;"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<h3 style="font-size: var(--text-base); font-weight: 600; color: var(--color-text-primary);">MDN Web Docs</h3>
</div>
<p style="font-size: var(--text-sm); color: var(--color-text-secondary); line-height: var(--leading-normal);">Web 技术文档参考</p>
</a>
<a href="#" class="link-card group flex flex-col gap-3 rounded-lg p-4 transition-all duration-150" style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style="background: rgba(0, 122, 204, 0.15);">
<i data-lucide="code-2" class="w-5 h-5" style="color: #007ac1;"></i>
</div>
<h3 style="font-size: var(--text-base); font-weight: 600; color: var(--color-text-primary);">VS Code</h3>
</div>
<p style="font-size: var(--text-sm); color: var(--color-text-secondary); line-height: var(--leading-normal);">轻量级代码编辑器</p>
</a>
<a href="#" class="link-card group flex flex-col gap-3 rounded-lg p-4 transition-all duration-150" style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style="background: rgba(230, 230, 230, 0.08);">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" style="color: var(--color-text-primary);"><path d="M4.5 4.5h15v15h-15z" stroke="currentColor" stroke-width="1.5" rx="2"/><path d="M8 9h8M8 12h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</div>
<h3 style="font-size: var(--text-base); font-weight: 600; color: var(--color-text-primary);">Notion</h3>
</div>
<p style="font-size: var(--text-sm); color: var(--color-text-secondary); line-height: var(--leading-normal);">笔记与协作空间</p>
</a>
<a href="#" class="link-card group flex flex-col gap-3 rounded-lg p-4 transition-all duration-150" style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style="background: rgba(242, 78, 30, 0.12);">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" style="color: #f24e1e;"><path d="M8 2a3 3 0 100 6 3 3 0 000-6zm0 8a3 3 0 100 6 3 3 0 000-6zm0 8a3 3 0 100 6 3 3 0 000-6zm8-8a3 3 0 100 6 3 3 0 000-6z" stroke="currentColor" stroke-width="1.5"/><path d="M16 2a3 3 0 110 6V2z" fill="currentColor"/><path d="M16 8a3 3 0 110 6V8z" stroke="currentColor" stroke-width="1.5"/></svg>
</div>
<h3 style="font-size: var(--text-base); font-weight: 600; color: var(--color-text-primary);">Figma</h3>
</div>
<p style="font-size: var(--text-sm); color: var(--color-text-secondary); line-height: var(--leading-normal);">在线设计协作工具</p>
</a>
<a href="#" class="link-card group flex flex-col gap-3 rounded-lg p-4 transition-all duration-150" style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style="background: rgba(140, 103, 255, 0.12);">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" style="color: #8c67ff;"><circle cx="12" cy="12" r="6" stroke="currentColor" stroke-width="1.5"/><path d="M12 9v3l2 2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<h3 style="font-size: var(--text-base); font-weight: 600; color: var(--color-text-primary);">Canva</h3>
</div>
<p style="font-size: var(--text-sm); color: var(--color-text-secondary); line-height: var(--leading-normal);">在线平面设计平台</p>
</a>
</div>
</section>
</div>
</div>
<!-- ===== DIM BACKDROP (covers source page, clickable to close) ===== -->
<div class="fixed inset-0 z-[var(--z-modal)]" data-dom-id="settings-backdrop" aria-label="Close settings" role="button" tabindex="0" style="background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); cursor: pointer;"></div>
<!-- ===== SETTINGS POPOVER (OPEN STATE) ===== -->
<div class="settings-popover fixed z-[calc(var(--z-modal)+1)] flex flex-col" data-dom-id="settings-popover" role="dialog" aria-modal="true" aria-labelledby="settings-title"
style="left: calc(var(--sidebar-width) + 8px); bottom: 80px; width: 360px; max-height: calc(100vh - 100px);
background: rgba(22, 22, 30, 0.92); border: 1px solid rgba(255, 255, 255, 0.10); border-radius: var(--radius-xl);
box-shadow: var(--shadow-dropdown); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
overflow: hidden;">
<!-- Popover Header -->
<header class="flex items-center justify-between px-5 py-4 shrink-0" style="border-bottom: 1px solid var(--color-border);">
<div class="flex items-center gap-2">
<i data-lucide="settings" class="w-4 h-4" style="color: var(--color-brand);"></i>
<h2 id="settings-title" style="font-size: var(--text-base); font-weight: 600; color: var(--color-text-primary);">设置</h2>
</div>
<button class="settings-close flex items-center justify-center w-8 h-8 rounded-md transition-colors duration-150" data-dom-id="settings-close-btn" aria-label="Close settings"
style="color: var(--color-text-secondary); background: transparent;">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</header>
<!-- Popover Body (scrollable) -->
<div class="flex-1 overflow-y-auto px-5 py-5 no-scrollbar" style="scrollbar-width: thin;">
<!-- SECTION: Theme Mode -->
<section class="mb-6" aria-label="Theme mode">
<h3 class="uppercase mb-3" style="font-size: 11px; font-weight: 600; color: var(--color-text-muted); letter-spacing: 0.08em;">主题模式</h3>
<div class="grid grid-cols-3 gap-2">
<!-- Dark (selected) -->
<button class="theme-card selected flex flex-col items-center justify-center gap-2 py-3 rounded-lg transition-all duration-150" data-theme="dark" aria-pressed="true" data-dom-id="theme-dark"
style="background: rgba(108, 92, 231, 0.10); border: 1.5px solid var(--color-brand);">
<i data-lucide="moon" class="w-5 h-5" style="color: var(--color-brand);"></i>
<span style="font-size: var(--text-sm); font-weight: 500; color: var(--color-brand);">暗色</span>
</button>
<!-- Light -->
<button class="theme-card flex flex-col items-center justify-center gap-2 py-3 rounded-lg transition-all duration-150" data-theme="light" aria-pressed="false" data-dom-id="theme-light"
style="background: rgba(255, 255, 255, 0.03); border: 1px solid var(--color-border);">
<i data-lucide="sun" class="w-5 h-5" style="color: var(--color-text-secondary);"></i>
<span style="font-size: var(--text-sm); font-weight: 500; color: var(--color-text-secondary);">亮色</span>
</button>
<!-- Auto -->
<button class="theme-card flex flex-col items-center justify-center gap-2 py-3 rounded-lg transition-all duration-150" data-theme="auto" aria-pressed="false" data-dom-id="theme-auto"
style="background: rgba(255, 255, 255, 0.03); border: 1px solid var(--color-border);">
<i data-lucide="monitor" class="w-5 h-5" style="color: var(--color-text-secondary);"></i>
<span style="font-size: var(--text-sm); font-weight: 500; color: var(--color-text-secondary);">跟随系统</span>
</button>
</div>
</section>
<!-- SECTION: Accent Color -->
<section class="mb-6" aria-label="Accent color">
<h3 class="uppercase mb-3" style="font-size: 11px; font-weight: 600; color: var(--color-text-muted); letter-spacing: 0.08em;">主色调</h3>
<div class="flex items-center gap-4 px-1 py-1">
<!-- Purple (selected) -->
<button class="color-swatch selected relative w-8 h-8 rounded-full transition-all duration-150" data-color="#6c5ce7" aria-label="Purple accent" aria-pressed="true" data-dom-id="color-purple"
style="background: #6c5ce7; box-shadow: 0 0 0 2px var(--color-bg-tertiary), 0 0 0 4px #6c5ce7; outline: 2px solid #6c5ce7; outline-offset: -1px;">
<i data-lucide="check" class="w-4 h-4 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" style="color: #ffffff;"></i>
</button>
<!-- Blue -->
<button class="color-swatch relative w-8 h-8 rounded-full transition-all duration-150" data-color="#0984e3" aria-label="Blue accent" aria-pressed="false" data-dom-id="color-blue"
style="background: #0984e3;"></button>
<!-- Green -->
<button class="color-swatch relative w-8 h-8 rounded-full transition-all duration-150" data-color="#00b894" aria-label="Green accent" aria-pressed="false" data-dom-id="color-green"
style="background: #00b894;"></button>
<!-- Orange -->
<button class="color-swatch relative w-8 h-8 rounded-full transition-all duration-150" data-color="#e17055" aria-label="Orange accent" aria-pressed="false" data-dom-id="color-orange"
style="background: #e17055;"></button>
<!-- Pink -->
<button class="color-swatch relative w-8 h-8 rounded-full transition-all duration-150" data-color="#e84393" aria-label="Pink accent" aria-pressed="false" data-dom-id="color-pink"
style="background: #e84393;"></button>
</div>
</section>
<!-- SECTION: Background Image -->
<section aria-label="Background image">
<h3 class="uppercase mb-3" style="font-size: 11px; font-weight: 600; color: var(--color-text-muted); letter-spacing: 0.08em;">背景图</h3>
<!-- Wallpaper Grid (3 cols x 2 rows) -->
<div class="grid grid-cols-3 gap-2 mb-3">
<!-- WP1: Radial purple -->
<button class="wallpaper-thumb relative w-full rounded-md overflow-hidden transition-all duration-150 cursor-pointer" data-wp="wp1" aria-label="Wallpaper 1"
style="aspect-ratio: 16/10; background: radial-gradient(circle at 30% 30%, #6c5ce7 0%, #2d1b69 60%, #0d0d12 100%); border: 1.5px solid var(--color-brand);">
<span class="absolute top-1 right-1 flex items-center justify-center w-4 h-4 rounded-full" style="background: var(--color-brand);">
<i data-lucide="check" class="w-2.5 h-2.5" style="color: #ffffff;"></i>
</span>
</button>
<!-- WP2: Linear teal/blue -->
<button class="wallpaper-thumb relative w-full rounded-md overflow-hidden transition-all duration-150 cursor-pointer" data-wp="wp2" aria-label="Wallpaper 2"
style="aspect-ratio: 16/10; background: linear-gradient(135deg, #0984e3 0%, #74b9ff 50%, #0d0d12 100%); border: 1px solid var(--color-border);"></button>
<!-- WP3: Conic green/purple -->
<button class="wallpaper-thumb relative w-full rounded-md overflow-hidden transition-all duration-150 cursor-pointer" data-wp="wp3" aria-label="Wallpaper 3"
style="aspect-ratio: 16/10; background: conic-gradient(from 45deg at 50% 50%, #00b894 0deg, #6c5ce7 180deg, #0d0d12 360deg); border: 1px solid var(--color-border);"></button>
<!-- WP4: Sunset orange -->
<button class="wallpaper-thumb relative w-full rounded-md overflow-hidden transition-all duration-150 cursor-pointer" data-wp="wp4" aria-label="Wallpaper 4"
style="aspect-ratio: 16/10; background: linear-gradient(180deg, #e17055 0%, #fdcb6e 50%, #2d1b69 100%); border: 1px solid var(--color-border);"></button>
<!-- WP5: Pink mesh -->
<button class="wallpaper-thumb relative w-full rounded-md overflow-hidden transition-all duration-150 cursor-pointer" data-wp="wp5" aria-label="Wallpaper 5"
style="aspect-ratio: 16/10; background: radial-gradient(circle at 70% 80%, #e84393 0%, #6c5ce7 50%, #0d0d12 100%); border: 1px solid var(--color-border);"></button>
<!-- WP6: Deep blue radial -->
<button class="wallpaper-thumb relative w-full rounded-md overflow-hidden transition-all duration-150 cursor-pointer" data-wp="wp6" aria-label="Wallpaper 6"
style="aspect-ratio: 16/10; background: radial-gradient(ellipse at 50% 0%, #4834d4 0%, #19196e 50%, #0d0d12 100%); border: 1px solid var(--color-border);"></button>
<!-- Upload Custom (spans 2 cols) -->
<button class="wallpaper-upload col-span-2 flex items-center justify-center gap-2 rounded-md transition-colors duration-150 cursor-pointer" data-wp="upload" aria-label="Upload custom wallpaper" data-dom-id="upload-wallpaper"
style="aspect-ratio: 16/10; background: rgba(255, 255, 255, 0.03); border: 1.5px dashed var(--color-border-hover);">
<i data-lucide="upload-cloud" class="w-4 h-4" style="color: var(--color-text-muted);"></i>
<span style="font-size: var(--text-sm); color: var(--color-text-muted);">上传自定义</span>
</button>
</div>
<!-- Clear wallpaper button -->
<button class="clear-wallpaper flex items-center justify-center gap-2 w-full py-2 rounded-md transition-colors duration-150 cursor-pointer" data-dom-id="clear-wallpaper-btn" aria-label="Clear wallpaper (solid color)"
style="background: transparent; border: 1px solid var(--color-border);">
<i data-lucide="image-off" class="w-4 h-4" style="color: var(--color-text-secondary);"></i>
<span style="font-size: var(--text-sm); color: var(--color-text-secondary);">纯色背景</span>
</button>
</section>
</div>
</div>
</main>
<script>lucide.createIcons();</script>
<script>
// Theme mode switching
document.querySelectorAll('.theme-card').forEach(function(card) {
card.addEventListener('click', function() {
document.querySelectorAll('.theme-card').forEach(function(c) {
c.classList.remove('selected');
c.setAttribute('aria-pressed', 'false');
c.style.background = 'rgba(255, 255, 255, 0.03)';
c.style.border = '1px solid var(--color-border)';
var icon = c.querySelector('i[data-lucide]');
var span = c.querySelector('span');
if (icon) icon.style.color = 'var(--color-text-secondary)';
if (span) span.style.color = 'var(--color-text-secondary)';
});
this.classList.add('selected');
this.setAttribute('aria-pressed', 'true');
this.style.background = 'rgba(108, 92, 231, 0.10)';
this.style.border = '1.5px solid var(--color-brand)';
var icon = this.querySelector('i[data-lucide]');
var span = this.querySelector('span');
if (icon) icon.style.color = 'var(--color-brand)';
if (span) span.style.color = 'var(--color-brand)';
});
});
// Color swatch selection
document.querySelectorAll('.color-swatch').forEach(function(sw) {
sw.addEventListener('click', function() {
document.querySelectorAll('.color-swatch').forEach(function(s) {
s.classList.remove('selected');
s.setAttribute('aria-pressed', 'false');
s.style.boxShadow = 'none';
s.style.outline = 'none';
});
this.classList.add('selected');
this.setAttribute('aria-pressed', 'true');
var color = this.getAttribute('data-color');
this.style.boxShadow = '0 0 0 2px var(--color-bg-tertiary), 0 0 0 4px ' + color;
this.style.outline = '2px solid ' + color;
this.style.outlineOffset = '-1px';
});
});
// Wallpaper thumbnail selection
document.querySelectorAll('.wallpaper-thumb').forEach(function(wp) {
wp.addEventListener('click', function() {
document.querySelectorAll('.wallpaper-thumb').forEach(function(w) {
w.style.border = '1px solid var(--color-border)';
var check = w.querySelector('span');
if (check) check.style.display = 'none';
});
this.style.border = '1.5px solid var(--color-brand)';
var check = this.querySelector('span');
if (check) check.style.display = 'flex';
});
});
</script>
</body>
</html>
+576
View File
@@ -0,0 +1,576 @@
<!DOCTYPE html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>浏览器首页 - 桌面端</title>
<style id="theme-vars">
/* ===== 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;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.1/dist/index.global.js"></script>
<script src="https://unpkg.com/lucide@1.8.0/dist/umd/lucide.min.js"></script>
<style type="text/tailwindcss">
@theme inline {
--color-border: var(--color-border);
}
@layer base {
body { background: var(--color-bg-primary); color: var(--color-text-primary); }
td, th { @apply break-words; word-break: break-all; word-break: auto-phrase; }
th { @apply whitespace-nowrap; }
}
</style>
<style>
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
[data-icon] {
display: inline-flex;
align-items: center;
justify-content: center;
-webkit-mask-size: contain;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
background-color: currentColor;
}
</style>
</head>
<body class="min-h-screen font-sans antialiased" style="font-family: var(--font-sans);">
<main class="flex min-h-screen">
<!-- ===== LEFT SIDEBAR ===== -->
<aside class="fixed left-0 top-0 bottom-0 z-[var(--z-sidebar)] flex flex-col" style="width: var(--sidebar-width); background: var(--color-bg-sidebar); border-right: var(--glass-border); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur));">
<!-- User avatar area -->
<div class="flex items-center gap-3 px-5 py-5" style="border-bottom: var(--glass-border);">
<div class="w-9 h-9 rounded-full flex items-center justify-center shrink-0" style="background: var(--color-brand); color: var(--color-text-inverse); font-size: var(--text-sm); font-weight: 600;">U</div>
<div class="min-w-0 flex-1">
<p class="truncate" style="font-size: var(--text-sm); font-weight: 600; color: var(--color-text-primary);">User</p>
<p class="truncate" style="font-size: 11px; color: var(--color-text-muted);">myhomepage</p>
</div>
</div>
<!-- Navigation tree -->
<nav class="flex-1 overflow-y-auto py-3 px-3 no-scrollbar" aria-label="Category navigation">
<!-- 主页 -->
<a href="#" class="nav-item active flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors duration-150" data-dom-id="nav-home" style="color: var(--color-brand); background: rgba(108, 92, 231, 0.12);" data-nav-key="home" data-active="true">
<i data-lucide="home" class="w-5 h-5 shrink-0"></i>
<span style="font-size: var(--text-base); font-weight: 500;">主页</span>
</a>
<!-- 常用工具 (expandable) -->
<div class="mb-1">
<button class="nav-item flex items-center gap-3 px-3 py-2 rounded-lg w-full transition-colors duration-150 cursor-pointer" data-dom-id="nav-tools-toggle" style="color: var(--color-text-secondary); background: transparent;" data-nav-key="tools" data-active="false" aria-expanded="true">
<i data-lucide="wrench" class="w-5 h-5 shrink-0"></i>
<span class="flex-1 text-left" style="font-size: var(--text-base); font-weight: 500;">常用工具</span>
<i data-lucide="chevron-down" class="w-4 h-4 shrink-0 transition-transform duration-200" style="transform: rotate(0deg);"></i>
</button>
<div class="sub-nav ml-5 mt-1 space-y-0.5" data-dom-id="nav-tools-sub">
<a href="#" class="sub-nav-item flex items-center gap-3 px-3 py-1.5 rounded-md transition-colors duration-150" data-dom-id="nav-search-engines" style="color: var(--color-text-muted); background: transparent;">
<i data-lucide="search" class="w-4 h-4 shrink-0"></i>
<span style="font-size: var(--text-sm);">搜索引擎</span>
</a>
<a href="#" class="sub-nav-item flex items-center gap-3 px-3 py-1.5 rounded-md transition-colors duration-150" data-dom-id="nav-ai-tools" style="color: var(--color-text-muted); background: transparent;">
<i data-lucide="bot" class="w-4 h-4 shrink-0"></i>
<span style="font-size: var(--text-sm);">AI工具</span>
</a>
<a href="#" class="sub-nav-item flex items-center gap-3 px-3 py-1.5 rounded-md transition-colors duration-150" data-dom-id="nav-dev-tools" style="color: var(--color-text-muted); background: transparent;">
<i data-lucide="code-2" class="w-4 h-4 shrink-0"></i>
<span style="font-size: var(--text-sm);">开发工具</span>
</a>
</div>
</div>
<!-- 购物 (expandable) -->
<div class="mb-1">
<button class="nav-item flex items-center gap-3 px-3 py-2 rounded-lg w-full transition-colors duration-150 cursor-pointer" data-dom-id="nav-shopping-toggle" style="color: var(--color-text-secondary); background: transparent;" data-nav-key="shopping" data-active="false" aria-expanded="false">
<i data-lucide="shopping-bag" class="w-5 h-5 shrink-0"></i>
<span class="flex-1 text-left" style="font-size: var(--text-base); font-weight: 500;">购物</span>
<i data-lucide="chevron-down" class="w-4 h-4 shrink-0 transition-transform duration-200" style="transform: rotate(-90deg);"></i>
</button>
<div class="sub-nav ml-5 mt-1 space-y-0.5 hidden" data-dom-id="nav-shopping-sub">
<a href="#" class="sub-nav-item flex items-center gap-3 px-3 py-1.5 rounded-md transition-colors duration-150" style="color: var(--color-text-muted); background: transparent;">
<i data-lucide="store" class="w-4 h-4 shrink-0"></i>
<span style="font-size: var(--text-sm);">电商</span>
</a>
<a href="#" class="sub-nav-item flex items-center gap-3 px-3 py-1.5 rounded-md transition-colors duration-150" style="color: var(--color-text-muted); background: transparent;">
<i data-lucide="smartphone" class="w-4 h-4 shrink-0"></i>
<span style="font-size: var(--text-sm);">数码</span>
</a>
</div>
</div>
<!-- 视频 (expandable) -->
<div class="mb-1">
<button class="nav-item flex items-center gap-3 px-3 py-2 rounded-lg w-full transition-colors duration-150 cursor-pointer" data-dom-id="nav-video-toggle" style="color: var(--color-text-secondary); background: transparent;" data-nav-key="video" data-active="false" aria-expanded="false">
<i data-lucide="play-circle" class="w-5 h-5 shrink-0"></i>
<span class="flex-1 text-left" style="font-size: var(--text-base); font-weight: 500;">视频</span>
<i data-lucide="chevron-down" class="w-4 h-4 shrink-0 transition-transform duration-200" style="transform: rotate(-90deg);"></i>
</button>
<div class="sub-nav ml-5 mt-1 space-y-0.5 hidden" data-dom-id="nav-video-sub">
<a href="#" class="sub-nav-item flex items-center gap-3 px-3 py-1.5 rounded-md transition-colors duration-150" style="color: var(--color-text-muted); background: transparent;">
<i data-lucide="tv" class="w-4 h-4 shrink-0"></i>
<span style="font-size: var(--text-sm);">影视</span>
</a>
<a href="#" class="sub-nav-item flex items-center gap-3 px-3 py-1.5 rounded-md transition-colors duration-150" style="color: var(--color-text-muted); background: transparent;">
<i data-lucide="radio" class="w-4 h-4 shrink-0"></i>
<span style="font-size: var(--text-sm);">直播</span>
</a>
</div>
</div>
<!-- 资讯 (expandable) -->
<div class="mb-1">
<button class="nav-item flex items-center gap-3 px-3 py-2 rounded-lg w-full transition-colors duration-150 cursor-pointer" data-dom-id="nav-news-toggle" style="color: var(--color-text-secondary); background: transparent;" data-nav-key="news" data-active="false" aria-expanded="false">
<i data-lucide="newspaper" class="w-5 h-5 shrink-0"></i>
<span class="flex-1 text-left" style="font-size: var(--text-base); font-weight: 500;">资讯</span>
<i data-lucide="chevron-down" class="w-4 h-4 shrink-0 transition-transform duration-200" style="transform: rotate(-90deg);"></i>
</button>
<div class="sub-nav ml-5 mt-1 space-y-0.5 hidden" data-dom-id="nav-news-sub">
<a href="#" class="sub-nav-item flex items-center gap-3 px-3 py-1.5 rounded-md transition-colors duration-150" style="color: var(--color-text-muted); background: transparent;">
<i data-lucide="cpu" class="w-4 h-4 shrink-0"></i>
<span style="font-size: var(--text-sm);">科技</span>
</a>
<a href="#" class="sub-nav-item flex items-center gap-3 px-3 py-1.5 rounded-md transition-colors duration-150" style="color: var(--color-text-muted); background: transparent;">
<i data-lucide="globe" class="w-4 h-4 shrink-0"></i>
<span style="font-size: var(--text-sm);">新闻</span>
</a>
</div>
</div>
<!-- 社交 -->
<a href="#" class="nav-item flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors duration-150" data-dom-id="nav-social" style="color: var(--color-text-secondary); background: transparent;" data-nav-key="social" data-active="false">
<i data-lucide="message-circle" class="w-5 h-5 shrink-0"></i>
<span style="font-size: var(--text-base); font-weight: 500;">社交</span>
</a>
</nav>
<!-- Settings at bottom -->
<div class="px-3 pb-4" style="border-top: var(--glass-border);">
<a href="#" class="nav-item flex items-center gap-3 px-3 py-2 rounded-lg mt-3 transition-colors duration-150" data-dom-id="nav-settings" style="color: var(--color-text-muted); background: transparent;" data-nav-key="settings" data-active="false">
<i data-lucide="settings" class="w-5 h-5 shrink-0"></i>
<span style="font-size: var(--text-base); font-weight: 500;">设置</span>
</a>
</div>
</aside>
<!-- ===== RIGHT MAIN AREA ===== -->
<div class="flex-1 flex flex-col min-h-screen" style="margin-left: var(--sidebar-width);">
<!-- Search Bar Section -->
<section class="flex items-center justify-center px-8 pt-10 pb-6" aria-label="Search">
<div class="relative w-full" style="max-width: 640px;">
<div class="flex items-center rounded-lg overflow-hidden" style="background: var(--color-bg-search); border: var(--glass-border); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); height: 46px;" data-dom-id="search-bar">
<!-- Engine Selector -->
<button class="flex items-center gap-2 px-3 h-full shrink-0 transition-colors duration-150" style="border-right: var(--glass-border); color: var(--color-text-secondary); background: transparent;" data-dom-id="search-engine-btn" aria-label="Select search engine" aria-haspopup="listbox">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" style="color: var(--color-brand);">
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2"/>
<path d="M12 8V4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M15.5 14.5L18 17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M8.5 14.5L6 17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<span style="font-size: var(--text-sm); font-weight: 500;">百度</span>
<i data-lucide="chevron-down" class="w-3.5 h-3.5"></i>
</button>
<!-- Search Input -->
<input type="text" placeholder="搜索或输入网址" class="flex-1 h-full bg-transparent outline-none px-4" style="color: var(--color-text-primary); font-size: var(--text-base);" data-dom-id="search-input" aria-label="Search input">
<!-- Search Button -->
<button class="flex items-center justify-center w-10 h-full shrink-0 transition-colors duration-150" style="color: var(--color-text-muted); background: transparent;" data-dom-id="search-btn" aria-label="Search">
<i data-lucide="search" class="w-5 h-5"></i>
</button>
</div>
<!-- Engine Dropdown (hidden by default, shown on engine button click) -->
<div class="absolute left-0 top-full mt-1 w-48 rounded-lg py-1 hidden" style="background: var(--color-bg-tertiary); border: var(--glass-border); box-shadow: var(--shadow-dropdown); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); z-index: var(--z-dropdown);" data-dom-id="search-engine-dropdown" role="listbox">
<button class="engine-option flex items-center gap-3 w-full px-4 py-2.5 transition-colors duration-150" style="color: var(--color-text-primary); background: transparent;" role="option" aria-selected="true" data-engine="baidu">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" style="color: var(--color-brand);"><circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2"/><path d="M12 8V4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span style="font-size: var(--text-sm);">百度</span>
</button>
<button class="engine-option flex items-center gap-3 w-full px-4 py-2.5 transition-colors duration-150" style="color: var(--color-text-secondary); background: transparent;" role="option" aria-selected="false" data-engine="google">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" style="color: var(--color-text-secondary);"><circle cx="12" cy="12" r="6" stroke="currentColor" stroke-width="2"/></svg>
<span style="font-size: var(--text-sm);">Google</span>
</button>
<button class="engine-option flex items-center gap-3 w-full px-4 py-2.5 transition-colors duration-150" style="color: var(--color-text-secondary); background: transparent;" role="option" aria-selected="false" data-engine="bing">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" style="color: var(--color-text-secondary);"><circle cx="12" cy="12" r="6" stroke="currentColor" stroke-width="1.5"/><path d="M8 16l4-4 4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span style="font-size: var(--text-sm);">Bing</span>
</button>
<button class="engine-option flex items-center gap-3 w-full px-4 py-2.5 transition-colors duration-150" style="color: var(--color-text-secondary); background: transparent;" role="option" aria-selected="false" data-engine="duckduckgo">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" style="color: var(--color-text-secondary);"><circle cx="12" cy="10" r="5" stroke="currentColor" stroke-width="1.5"/><path d="M9 16c0 2 1.5 4 3 4s3-2 3-4" stroke="currentColor" stroke-width="1.5"/></svg>
<span style="font-size: var(--text-sm);">DuckDuckGo</span>
</button>
</div>
</div>
</section>
<!-- Link Card Grid -->
<section class="flex-1 overflow-y-auto px-8 pb-8" aria-label="Link cards">
<!-- Section header -->
<div class="flex items-center justify-between mb-5" style="max-width: 1120px; margin-left: auto; margin-right: auto;">
<h1 class="truncate" style="font-size: var(--text-xl); font-weight: 600; color: var(--color-text-primary); text-wrap: balance; word-break: keep-all;">常用工具</h1>
<span class="shrink-0" style="font-size: var(--text-sm); color: var(--color-text-muted);">8 个链接</span>
</div>
<!-- Card Grid -->
<div class="grid gap-4" style="grid-template-columns: repeat(4, 1fr); max-width: 1120px; margin-left: auto; margin-right: auto;">
<!-- Card: ChatGPT -->
<a href="#" class="link-card group flex flex-col gap-3 rounded-lg p-4 transition-all duration-150" style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);" data-dom-id="card-chatgpt">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style="background: rgba(16, 163, 127, 0.15);">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" style="color: #10a37f;"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="currentColor"/></svg>
</div>
<h3 class="truncate" style="font-size: var(--text-base); font-weight: 600; color: var(--color-text-primary);">ChatGPT</h3>
</div>
<p class="truncate" style="font-size: var(--text-sm); color: var(--color-text-secondary); line-height: var(--leading-normal);">AI对话助手,智能问答</p>
</a>
<!-- Card: GitHub -->
<a href="#" class="link-card group flex flex-col gap-3 rounded-lg p-4 transition-all duration-150" style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);" data-dom-id="card-github">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style="background: rgba(230, 230, 230, 0.1);">
<i data-lucide="github" class="w-5 h-5" style="color: var(--color-text-primary);"></i>
</div>
<h3 class="truncate" style="font-size: var(--text-base); font-weight: 600; color: var(--color-text-primary);">GitHub</h3>
</div>
<p class="truncate" style="font-size: var(--text-sm); color: var(--color-text-secondary); line-height: var(--leading-normal);">代码托管与协作平台</p>
</a>
<!-- Card: Stack Overflow -->
<a href="#" class="link-card group flex flex-col gap-3 rounded-lg p-4 transition-all duration-150" style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);" data-dom-id="card-stackoverflow">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style="background: rgba(244, 128, 36, 0.12);">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" style="color: #f48024;"><path d="M15.73 21.02l-.6-2.35h-6.26l-.6 2.35H4.67L8.2 7h7.6l3.53 14.02h-3.6zm-1.33-5.33l-2.2-8.52h-.2l-2.2 8.52h4.6zM2 22h20v2H2v-2z" fill="currentColor"/></svg>
</div>
<h3 class="truncate" style="font-size: var(--text-base); font-weight: 600; color: var(--color-text-primary);">Stack Overflow</h3>
</div>
<p class="truncate" style="font-size: var(--text-sm); color: var(--color-text-secondary); line-height: var(--leading-normal);">开发者问答社区</p>
</a>
<!-- Card: MDN Web Docs -->
<a href="#" class="link-card group flex flex-col gap-3 rounded-lg p-4 transition-all duration-150" style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);" data-dom-id="card-mdn">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style="background: rgba(121, 79, 169, 0.15);">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" style="color: #794fa9;"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<h3 class="truncate" style="font-size: var(--text-base); font-weight: 600; color: var(--color-text-primary);">MDN Web Docs</h3>
</div>
<p class="truncate" style="font-size: var(--text-sm); color: var(--color-text-secondary); line-height: var(--leading-normal);">Web 技术文档参考</p>
</a>
<!-- Card: VS Code -->
<a href="#" class="link-card group flex flex-col gap-3 rounded-lg p-4 transition-all duration-150" style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);" data-dom-id="card-vscode">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style="background: rgba(0, 122, 204, 0.15);">
<i data-lucide="code-2" class="w-5 h-5" style="color: #007ac1;"></i>
</div>
<h3 class="truncate" style="font-size: var(--text-base); font-weight: 600; color: var(--color-text-primary);">VS Code</h3>
</div>
<p class="truncate" style="font-size: var(--text-sm); color: var(--color-text-secondary); line-height: var(--leading-normal);">轻量级代码编辑器</p>
</a>
<!-- Card: Notion -->
<a href="#" class="link-card group flex flex-col gap-3 rounded-lg p-4 transition-all duration-150" style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);" data-dom-id="card-notion">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style="background: rgba(230, 230, 230, 0.08);">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" style="color: var(--color-text-primary);"><path d="M4.5 4.5h15v15h-15z" stroke="currentColor" stroke-width="1.5" rx="2"/><path d="M8 9h8M8 12h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</div>
<h3 class="truncate" style="font-size: var(--text-base); font-weight: 600; color: var(--color-text-primary);">Notion</h3>
</div>
<p class="truncate" style="font-size: var(--text-sm); color: var(--color-text-secondary); line-height: var(--leading-normal);">笔记与协作空间</p>
</a>
<!-- Card: Figma -->
<a href="#" class="link-card group flex flex-col gap-3 rounded-lg p-4 transition-all duration-150" style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);" data-dom-id="card-figma">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style="background: rgba(242, 78, 30, 0.12);">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" style="color: #f24e1e;"><path d="M8 2a3 3 0 100 6 3 3 0 000-6zm0 8a3 3 0 100 6 3 3 0 000-6zm0 8a3 3 0 100 6 3 3 0 000-6zm8-8a3 3 0 100 6 3 3 0 000-6z" stroke="currentColor" stroke-width="1.5"/><path d="M16 2a3 3 0 110 6V2z" fill="currentColor"/><path d="M16 8a3 3 0 110 6V8z" stroke="currentColor" stroke-width="1.5"/></svg>
</div>
<h3 class="truncate" style="font-size: var(--text-base); font-weight: 600; color: var(--color-text-primary);">Figma</h3>
</div>
<p class="truncate" style="font-size: var(--text-sm); color: var(--color-text-secondary); line-height: var(--leading-normal);">在线设计协作工具</p>
</a>
<!-- Card: Canva -->
<a href="#" class="link-card group flex flex-col gap-3 rounded-lg p-4 transition-all duration-150" style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);" data-dom-id="card-canva">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style="background: rgba(140, 103, 255, 0.12);">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" style="color: #8c67ff;"><circle cx="12" cy="12" r="6" stroke="currentColor" stroke-width="1.5"/><path d="M12 9v3l2 2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<h3 class="truncate" style="font-size: var(--text-base); font-weight: 600; color: var(--color-text-primary);">Canva</h3>
</div>
<p class="truncate" style="font-size: var(--text-sm); color: var(--color-text-secondary); line-height: var(--leading-normal);">在线平面设计平台</p>
</a>
<!-- Add-link button card -->
<a href="#" class="link-card flex flex-col items-center justify-center gap-2 rounded-lg p-4 transition-all duration-150" style="background: var(--color-bg-card); border: var(--glass-border); border-style: dashed; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); min-height: 96px;" data-dom-id="card-add-link">
<i data-lucide="plus" class="w-6 h-6" style="color: var(--color-text-muted);"></i>
<span style="font-size: var(--text-sm); color: var(--color-text-muted);">添加链接</span>
</a>
</div>
</section>
</div>
</main>
<style>
/* ---- Nav item interaction states ---- */
.nav-item:hover {
color: var(--color-text-primary) !important;
background: rgba(255, 255, 255, 0.04) !important;
}
.nav-item:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: -2px;
}
.nav-item.active {
color: var(--color-brand) !important;
background: rgba(108, 92, 231, 0.12) !important;
}
.sub-nav-item:hover {
color: var(--color-text-primary) !important;
background: rgba(255, 255, 255, 0.03) !important;
}
.sub-nav-item:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: -2px;
}
/* ---- Link card hover ---- */
.link-card:hover {
background: var(--color-bg-card-hover) !important;
border-color: var(--color-border-hover) !important;
transform: translateY(-2px);
}
.link-card:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}
.link-card:active {
transform: translateY(0);
}
/* ---- Search input focus ---- */
[data-dom-id="search-input"]:focus {
box-shadow: inset 0 0 0 0;
}
[data-dom-id="search-bar"]:focus-within {
border-color: var(--color-border-active) !important;
}
[data-dom-id="search-input"]::placeholder {
color: var(--color-text-muted);
}
/* ---- Engine button hover ---- */
[data-dom-id="search-engine-btn"]:hover {
color: var(--color-text-primary) !important;
background: rgba(255, 255, 255, 0.04) !important;
}
[data-dom-id="search-btn"]:hover {
color: var(--color-brand) !important;
}
/* ---- Engine option hover ---- */
.engine-option:hover {
color: var(--color-text-primary) !important;
background: rgba(255, 255, 255, 0.06) !important;
}
/* ---- Add-link card hover ---- */
[data-dom-id="card-add-link"]:hover {
border-color: var(--color-brand) !important;
background: rgba(108, 92, 231, 0.08) !important;
}
[data-dom-id="card-add-link"]:hover i,
[data-dom-id="card-add-link"]:hover span {
color: var(--color-brand) !important;
}
/* ---- Sidebar chevron rotation ---- */
[aria-expanded="true"] > i[data-lucide="chevron-down"],
.nav-item[aria-expanded="true"] i[data-lucide="chevron-down"] {
transform: rotate(0deg) !important;
}
[aria-expanded="false"] i[data-lucide="chevron-down"] {
transform: rotate(-90deg) !important;
}
/* ---- Responsive grid ---- */
@media (max-width: 1280px) {
.grid[style*="repeat(4"] {
grid-template-columns: repeat(3, 1fr) !important;
}
}
@media (max-width: 1024px) {
.grid[style*="repeat(4"] {
grid-template-columns: repeat(2, 1fr) !important;
}
}
@media (max-width: 768px) {
aside {
width: var(--sidebar-collapsed-width) !important;
}
aside .nav-item span,
aside .sub-nav-item span,
aside .nav-item .flex-1,
aside .sub-nav,
aside .user-info-text {
display: none !important;
}
aside .nav-item {
justify-content: center !important;
}
div[style*="margin-left: var(--sidebar-width)"] {
margin-left: var(--sidebar-collapsed-width) !important;
}
}
/* ---- Reduced motion ---- */
@media (prefers-reduced-motion: reduce) {
.link-card,
.nav-item,
.sub-nav-item,
.engine-option,
i[data-lucide="chevron-down"] {
transition-duration: 0.01ms !important;
}
}
</style>
<script>
// Sidebar category expand/collapse toggle
document.querySelectorAll('.nav-item[data-nav-key][aria-expanded]').forEach(function(btn) {
btn.addEventListener('click', function() {
var expanded = this.getAttribute('aria-expanded') === 'true';
this.setAttribute('aria-expanded', String(!expanded));
var subNav = this.nextElementSibling;
if (subNav && subNav.classList.contains('sub-nav')) {
subNav.classList.toggle('hidden', expanded);
}
});
});
// Search engine dropdown toggle
var engineBtn = document.querySelector('[data-dom-id="search-engine-btn"]');
var engineDropdown = document.querySelector('[data-dom-id="search-engine-dropdown"]');
if (engineBtn && engineDropdown) {
engineBtn.addEventListener('click', function(e) {
e.stopPropagation();
engineDropdown.classList.toggle('hidden');
});
document.addEventListener('click', function(e) {
if (!engineDropdown.contains(e.target)) {
engineDropdown.classList.add('hidden');
}
});
engineDropdown.querySelectorAll('.engine-option').forEach(function(opt) {
opt.addEventListener('click', function() {
var name = this.querySelector('span').textContent;
engineBtn.querySelector('span').textContent = name;
engineDropdown.querySelectorAll('.engine-option').forEach(function(o) {
o.setAttribute('aria-selected', 'false');
o.style.color = 'var(--color-text-secondary)';
});
this.setAttribute('aria-selected', 'true');
this.style.color = 'var(--color-text-primary)';
engineDropdown.classList.add('hidden');
});
});
}
</script>
<script>lucide.createIcons();</script>
</body>
</html>
+597
View File
@@ -0,0 +1,597 @@
<!DOCTYPE html>
<html lang="zh-CN" class="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>浏览器首页</title>
<style id="theme-vars">
/* ===== 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;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.1/dist/index.global.js"></script>
<script src="https://unpkg.com/lucide@1.8.0/dist/umd/lucide.min.js"></script>
<style type="text/tailwindcss">
@theme inline {
--color-border: var(--color-border);
}
@layer base {
body { background: var(--color-bg-primary); color: var(--color-text-primary); }
td, th { @apply break-words; word-break: break-all; word-break: auto-phrase; }
th { @apply whitespace-nowrap; }
}
</style>
<style>
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
[data-icon] {
display: inline-flex;
align-items: center;
justify-content: center;
-webkit-mask-size: contain;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
background-color: currentColor;
}
</style>
</head>
<body class="min-h-screen font-sans antialiased">
<main class="relative max-w-[375px] mx-auto min-h-screen" style="font-family: var(--font-sans);">
<!-- ===== TOP BAR (single row: hamburger | search | gear) ===== -->
<header class="sticky top-0 z-50 flex items-center gap-2 px-4 h-14"
style="background: var(--color-bg-secondary); border-bottom: var(--color-border);">
<!-- Hamburger menu button (44x44) -->
<button id="drawer-toggle" type="button"
class="inline-flex items-center justify-center w-11 h-11 shrink-0 rounded-lg transition-colors duration-150"
style="color: var(--color-text-primary);"
aria-label="打开导航菜单"
data-dom-id="hamburger-menu">
<i data-lucide="menu" class="w-5 h-5"></i>
</button>
<!-- Search bar (engine selector + input) -->
<div class="flex items-center gap-2 flex-1 min-w-0 rounded-xl px-2.5 h-11"
style="background: var(--color-bg-search); border: var(--glass-border); backdrop-filter: blur(var(--glass-blur));">
<!-- Search engine selector -->
<button id="engine-selector" type="button"
class="inline-flex items-center justify-center shrink-0 h-7 px-2 rounded-md text-xs font-medium whitespace-nowrap"
style="background: var(--color-bg-tertiary); color: var(--color-brand); border: 1px solid var(--color-border-active);"
aria-label="选择搜索引擎"
data-dom-id="engine-selector">
<i data-lucide="search" class="w-3 h-3 mr-1"></i>
<span>Google</span>
<i data-lucide="chevron-down" class="w-3 h-3 ml-1"></i>
</button>
<!-- Search input -->
<input type="text" placeholder="搜索或输入网址"
class="flex-1 min-w-0 bg-transparent outline-none text-sm"
style="color: var(--color-text-primary);"
aria-label="搜索输入框"
data-dom-id="search-input" />
</div>
<!-- Settings gear button (44x44) — replaces top-bar avatar -->
<button id="settings-gear-toggle" type="button"
class="inline-flex items-center justify-center w-11 h-11 shrink-0 rounded-lg transition-colors duration-150"
style="color: var(--color-text-primary);"
aria-label="设置"
data-dom-id="settings-gear">
<i data-lucide="settings" class="w-5 h-5"></i>
</button>
</header>
<!-- ===== CATEGORY TABS ===== -->
<nav class="px-4 pt-3 pb-3" aria-label="分类导航">
<div class="flex gap-2 overflow-x-auto no-scrollbar pb-1">
<button class="shrink-0 inline-flex items-center justify-center h-8 px-4 rounded-lg text-xs font-semibold whitespace-nowrap"
style="background: var(--color-brand); color: var(--color-text-inverse);"
data-tab-key="all" data-active="true"
data-dom-id="tab-all">
全部
</button>
<button class="shrink-0 inline-flex items-center justify-center h-8 px-4 rounded-lg text-xs font-medium whitespace-nowrap"
style="background: var(--color-bg-tertiary); color: var(--color-text-secondary); border: 1px solid var(--color-border);"
data-tab-key="tools"
data-dom-id="tab-tools">
常用工具
</button>
<button class="shrink-0 inline-flex items-center justify-center h-8 px-4 rounded-lg text-xs font-medium whitespace-nowrap"
style="background: var(--color-bg-tertiary); color: var(--color-text-secondary); border: 1px solid var(--color-border);"
data-tab-key="shopping"
data-dom-id="tab-shopping">
购物
</button>
<button class="shrink-0 inline-flex items-center justify-center h-8 px-4 rounded-lg text-xs font-medium whitespace-nowrap"
style="background: var(--color-bg-tertiary); color: var(--color-text-secondary); border: 1px solid var(--color-border);"
data-tab-key="video"
data-dom-id="tab-video">
视频
</button>
<button class="shrink-0 inline-flex items-center justify-center h-8 px-4 rounded-lg text-xs font-medium whitespace-nowrap"
style="background: var(--color-bg-tertiary); color: var(--color-text-secondary); border: 1px solid var(--color-border);"
data-tab-key="news"
data-dom-id="tab-news">
资讯
</button>
<button class="shrink-0 inline-flex items-center justify-center h-8 px-4 rounded-lg text-xs font-medium whitespace-nowrap"
style="background: var(--color-bg-tertiary); color: var(--color-text-secondary); border: 1px solid var(--color-border);"
data-tab-key="social"
data-dom-id="tab-social">
社交
</button>
</div>
</nav>
<!-- ===== LINK CARD LIST ===== -->
<section class="px-4 pb-24 space-y-2" aria-label="链接列表">
<!-- ChatGPT -->
<a href="#" class="flex items-center gap-3 px-3 py-3 rounded-xl transition-colors duration-150"
style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(var(--glass-blur));"
data-dom-id="link-chatgpt">
<div class="w-10 h-10 rounded-lg shrink-0 flex items-center justify-center"
style="background: rgba(16, 163, 127, 0.15);">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: #10a37f;">
<path d="M12 2a10 10 0 0 1 0 20 10 10 0 0 1 0-20z"/>
<path d="M7 12h10M12 7v10"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold truncate" style="color: var(--color-text-primary);">ChatGPT</p>
<p class="text-xs truncate mt-0.5" style="color: var(--color-text-secondary);">AI 对话助手</p>
</div>
<i data-lucide="external-link" class="w-4 h-4 shrink-0" style="color: var(--color-text-muted);"></i>
</a>
<!-- GitHub -->
<a href="#" class="flex items-center gap-3 px-3 py-3 rounded-xl transition-colors duration-150"
style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(var(--glass-blur));"
data-dom-id="link-github">
<div class="w-10 h-10 rounded-lg shrink-0 flex items-center justify-center"
style="background: rgba(255, 255, 255, 0.08);">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style="color: var(--color-text-primary);">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold truncate" style="color: var(--color-text-primary);">GitHub</p>
<p class="text-xs truncate mt-0.5" style="color: var(--color-text-secondary);">代码托管平台</p>
</div>
<i data-lucide="external-link" class="w-4 h-4 shrink-0" style="color: var(--color-text-muted);"></i>
</a>
<!-- Stack Overflow -->
<a href="#" class="flex items-center gap-3 px-3 py-3 rounded-xl transition-colors duration-150"
style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(var(--glass-blur));"
data-dom-id="link-stackoverflow">
<div class="w-10 h-10 rounded-lg shrink-0 flex items-center justify-center"
style="background: rgba(244, 128, 36, 0.15);">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: #f48024;">
<path d="M4 17l6-6 4 4 6-8"/>
<path d="M14 7h6v6"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold truncate" style="color: var(--color-text-primary);">Stack Overflow</p>
<p class="text-xs truncate mt-0.5" style="color: var(--color-text-secondary);">开发者问答社区</p>
</div>
<i data-lucide="external-link" class="w-4 h-4 shrink-0" style="color: var(--color-text-muted);"></i>
</a>
<!-- MDN -->
<a href="#" class="flex items-center gap-3 px-3 py-3 rounded-xl transition-colors duration-150"
style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(var(--glass-blur));"
data-dom-id="link-mdn">
<div class="w-10 h-10 rounded-lg shrink-0 flex items-center justify-center"
style="background: rgba(83, 40, 209, 0.15);">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: #5328d1;">
<path d="M4 4h6v6H4z"/>
<path d="M14 4h6v6h-6z"/>
<path d="M4 14h6v6H4z"/>
<path d="M14 14h6v6h-6z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold truncate" style="color: var(--color-text-primary);">MDN Web Docs</p>
<p class="text-xs truncate mt-0.5" style="color: var(--color-text-secondary);">Web 技术文档</p>
</div>
<i data-lucide="external-link" class="w-4 h-4 shrink-0" style="color: var(--color-text-muted);"></i>
</a>
<!-- VS Code -->
<a href="#" class="flex items-center gap-3 px-3 py-3 rounded-xl transition-colors duration-150"
style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(var(--glass-blur));"
data-dom-id="link-vscode">
<div class="w-10 h-10 rounded-lg shrink-0 flex items-center justify-center"
style="background: rgba(0, 120, 215, 0.15);">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: #0078d7;">
<path d="M17.5 2.5l-10 8 4 2 6-10z"/>
<path d="M17.5 21.5l-10-8 4-2 6 10z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold truncate" style="color: var(--color-text-primary);">VS Code</p>
<p class="text-xs truncate mt-0.5" style="color: var(--color-text-secondary);">代码编辑器</p>
</div>
<i data-lucide="external-link" class="w-4 h-4 shrink-0" style="color: var(--color-text-muted);"></i>
</a>
<!-- Notion -->
<a href="#" class="flex items-center gap-3 px-3 py-3 rounded-xl transition-colors duration-150"
style="background: var(--color-bg-card); border: var(--glass-border); backdrop-filter: blur(var(--glass-blur));"
data-dom-id="link-notion">
<div class="w-10 h-10 rounded-lg shrink-0 flex items-center justify-center"
style="background: rgba(255, 255, 255, 0.08);">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style="color: var(--color-text-primary);">
<path d="M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L17.86 2.685c-.42-.326-.98-.7-2.055-.607L3.39 3.553c-.466.046-.56.28-.374.466zm.793 3.26v13.917c0 .747.373 1.027 1.214.98l14.523-.84c.842-.046.935-.56.935-1.167V6.354c0-.606-.233-.933-.747-.886l-15.178.886c-.56.047-.747.327-.747.933zm14.337.42c.093.42 0 .84-.42.886l-.7.14v10.264c-.608.327-1.168.514-1.635.514-.747 0-.934-.234-1.495-.934l-4.573-7.186v6.953l1.448.327s0 .84-1.214.84l-3.356.187c-.093-.187 0-.653.327-.746l.84-.233V9.854L7.822 9.9c-.093-.42.14-1.026.793-1.073l3.593-.233 4.76 7.28v-6.44l-1.215-.14c-.093-.514.28-.886.747-.933zM2.61.947l10.873-.654c1.355-.093 1.682-.046 2.52.56L18.79 2.87c.56.374.747.47.747.887v16.203c0 .98-.374 1.587-1.682 1.68l-14.946.84c-.98.047-1.448-.093-1.962-.747L.46 19.08C-.094 18.38 0 17.82 0 17.213V2.407c0-.84.374-1.4 1.215-1.493z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold truncate" style="color: var(--color-text-primary);">Notion</p>
<p class="text-xs truncate mt-0.5" style="color: var(--color-text-secondary);">协作笔记工具</p>
</div>
<i data-lucide="external-link" class="w-4 h-4 shrink-0" style="color: var(--color-text-muted);"></i>
</a>
</section>
<!-- ===== FAB BUTTON ===== -->
<button class="fixed bottom-6 right-6 w-12 h-12 rounded-full flex items-center justify-center z-40 shadow-lg transition-transform duration-150 active:scale-95"
style="background: var(--color-brand); color: var(--color-text-inverse); box-shadow: var(--shadow-float);"
aria-label="添加链接"
data-dom-id="fab-add-link">
<i data-lucide="plus" class="w-5 h-5"></i>
</button>
<!-- ===== NAVIGATION DRAWER OVERLAY ===== -->
<div id="drawer-backdrop" class="fixed inset-0 z-50 hidden"
style="background: var(--color-bg-overlay); backdrop-filter: blur(2px);"
data-dom-id="drawer-backdrop"></div>
<!-- ===== NAVIGATION DRAWER (closed by default — avatar/profile moved here) ===== -->
<aside id="nav-drawer" class="fixed top-0 left-0 bottom-0 z-50 w-[280px] flex flex-col -translate-x-full transition-transform duration-250"
style="background: var(--color-bg-secondary); border-right: var(--glass-border); transform: translateX(-100%);"
aria-label="导航菜单"
data-dom-id="nav-drawer">
<!-- User profile (avatar + name + email) -->
<div class="flex items-center gap-3 px-4 h-16 shrink-0"
style="border-bottom: var(--color-border);">
<div class="w-10 h-10 rounded-full flex items-center justify-center shrink-0"
style="background: var(--color-brand); color: var(--color-text-inverse);">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</div>
<div class="min-w-0">
<p class="text-sm font-semibold truncate" style="color: var(--color-text-primary);">用户</p>
<p class="text-xs truncate" style="color: var(--color-text-muted);">浏览主页</p>
</div>
</div>
<!-- Two-level category navigation -->
<nav class="flex-1 overflow-y-auto px-2 py-3" aria-label="分类导航">
<!-- 常用工具 -->
<div class="mb-1">
<button class="drawer-cat-toggle flex items-center gap-2 w-full px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150"
style="color: var(--color-text-primary);"
data-dom-id="drawer-cat-tools">
<i data-lucide="wrench" class="w-4 h-4 shrink-0" style="color: var(--color-text-muted);"></i>
<span class="flex-1 text-left">常用工具</span>
<i data-lucide="chevron-down" class="w-4 h-4 shrink-0 transition-transform duration-150" style="color: var(--color-text-muted);"></i>
</button>
<div class="drawer-subitems ml-9 hidden">
<a href="#" class="block px-3 py-2 rounded-md text-xs transition-colors duration-150"
style="color: var(--color-text-secondary);">搜索引擎</a>
<a href="#" class="block px-3 py-2 rounded-md text-xs transition-colors duration-150"
style="color: var(--color-text-secondary);">AI 工具</a>
<a href="#" class="block px-3 py-2 rounded-md text-xs transition-colors duration-150"
style="color: var(--color-text-secondary);">开发工具</a>
</div>
</div>
<!-- 购物 -->
<div class="mb-1">
<button class="drawer-cat-toggle flex items-center gap-2 w-full px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150"
style="color: var(--color-text-primary);"
data-dom-id="drawer-cat-shopping">
<i data-lucide="shopping-bag" class="w-4 h-4 shrink-0" style="color: var(--color-text-muted);"></i>
<span class="flex-1 text-left">购物</span>
<i data-lucide="chevron-down" class="w-4 h-4 shrink-0 transition-transform duration-150" style="color: var(--color-text-muted);"></i>
</button>
<div class="drawer-subitems ml-9 hidden">
<a href="#" class="block px-3 py-2 rounded-md text-xs transition-colors duration-150"
style="color: var(--color-text-secondary);">电商</a>
<a href="#" class="block px-3 py-2 rounded-md text-xs transition-colors duration-150"
style="color: var(--color-text-secondary);">数码</a>
</div>
</div>
<!-- 视频 -->
<div class="mb-1">
<button class="drawer-cat-toggle flex items-center gap-2 w-full px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150"
style="color: var(--color-text-primary);"
data-dom-id="drawer-cat-video">
<i data-lucide="play-circle" class="w-4 h-4 shrink-0" style="color: var(--color-text-muted);"></i>
<span class="flex-1 text-left">视频</span>
<i data-lucide="chevron-down" class="w-4 h-4 shrink-0 transition-transform duration-150" style="color: var(--color-text-muted);"></i>
</button>
<div class="drawer-subitems ml-9 hidden">
<a href="#" class="block px-3 py-2 rounded-md text-xs transition-colors duration-150"
style="color: var(--color-text-secondary);">影视</a>
<a href="#" class="block px-3 py-2 rounded-md text-xs transition-colors duration-150"
style="color: var(--color-text-secondary);">直播</a>
</div>
</div>
<!-- 资讯 -->
<div class="mb-1">
<button class="drawer-cat-toggle flex items-center gap-2 w-full px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150"
style="color: var(--color-text-primary);"
data-dom-id="drawer-cat-news">
<i data-lucide="newspaper" class="w-4 h-4 shrink-0" style="color: var(--color-text-muted);"></i>
<span class="flex-1 text-left">资讯</span>
<i data-lucide="chevron-down" class="w-4 h-4 shrink-0 transition-transform duration-150" style="color: var(--color-text-muted);"></i>
</button>
<div class="drawer-subitems ml-9 hidden">
<a href="#" class="block px-3 py-2 rounded-md text-xs transition-colors duration-150"
style="color: var(--color-text-secondary);">科技</a>
<a href="#" class="block px-3 py-2 rounded-md text-xs transition-colors duration-150"
style="color: var(--color-text-secondary);">新闻</a>
</div>
</div>
<!-- 社交 -->
<div class="mb-1">
<button class="drawer-cat-toggle flex items-center gap-2 w-full px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150"
style="color: var(--color-text-primary);"
data-dom-id="drawer-cat-social">
<i data-lucide="message-circle" class="w-4 h-4 shrink-0" style="color: var(--color-text-muted);"></i>
<span class="flex-1 text-left">社交</span>
<i data-lucide="chevron-down" class="w-4 h-4 shrink-0 transition-transform duration-150" style="color: var(--color-text-muted);"></i>
</button>
<div class="drawer-subitems ml-9 hidden">
<a href="#" class="block px-3 py-2 rounded-md text-xs transition-colors duration-150"
style="color: var(--color-text-secondary);">即时通讯</a>
<a href="#" class="block px-3 py-2 rounded-md text-xs transition-colors duration-150"
style="color: var(--color-text-secondary);">社区论坛</a>
</div>
</div>
</nav>
<!-- Settings link -->
<div class="shrink-0 px-2 py-3" style="border-top: var(--color-border);">
<a href="#" class="flex items-center gap-2 px-3 py-2.5 rounded-lg text-sm transition-colors duration-150"
style="color: var(--color-text-secondary);"
data-dom-id="drawer-settings">
<i data-lucide="settings" class="w-4 h-4 shrink-0" style="color: var(--color-text-muted);"></i>
<span>设置</span>
</a>
</div>
</aside>
</main>
<style>
/* Drawer slide-in animation */
#nav-drawer.open {
transform: translateX(0) !important;
}
/* Active tab style for category tabs */
[data-tab-key][data-active="true"] {
background: var(--color-brand) !important;
color: var(--color-text-inverse) !important;
border-color: var(--color-brand) !important;
}
[data-tab-key]:not([data-active="true"]):hover {
background: var(--color-bg-card-hover) !important;
}
/* Link card hover */
a[style*="background: var(--color-bg-card)"]:hover {
background: var(--color-bg-card-hover) !important;
}
/* Drawer category toggle hover */
.drawer-cat-toggle:hover {
background: var(--color-bg-tertiary);
}
.drawer-cat-toggle[aria-expanded="true"] > svg:last-child {
transform: rotate(180deg);
}
/* Drawer sub-items visible */
.drawer-subitems:not(.hidden) {
display: block;
}
/* FAB hover */
button[style*="background: var(--color-brand)"][data-dom-id="fab-add-link"]:hover {
background: var(--color-brand-hover) !important;
}
/* Top-bar icon hover */
#drawer-toggle:hover,
#settings-gear-toggle:hover {
background: var(--color-bg-tertiary) !important;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
#nav-drawer,
.drawer-cat-toggle,
.drawer-cat-toggle > svg:last-child,
a[style*="background: var(--color-bg-card)"],
button[style*="background: var(--color-brand)"][data-dom-id="fab-add-link"],
#drawer-toggle,
#settings-gear-toggle {
transition-duration: 0.01ms !important;
}
}
/* Drawer backdrop animation */
#drawer-backdrop.show {
opacity: 1 !important;
pointer-events: auto !important;
}
#drawer-backdrop {
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-normal);
}
#nav-drawer {
transition: transform var(--transition-normal);
}
</style>
<script>
/* Drawer open/close (hamburger toggles the drawer that holds avatar/profile) */
const drawer = document.getElementById('nav-drawer');
const backdrop = document.getElementById('drawer-backdrop');
const toggle = document.getElementById('drawer-toggle');
const gearToggle = document.getElementById('settings-gear-toggle');
function openDrawer() {
drawer.classList.add('open');
backdrop.classList.remove('hidden');
backdrop.classList.add('show');
document.body.style.overflow = 'hidden';
}
function closeDrawer() {
drawer.classList.remove('open');
backdrop.classList.remove('show');
document.body.style.overflow = '';
setTimeout(function() { backdrop.classList.add('hidden'); }, 250);
}
toggle.addEventListener('click', openDrawer);
backdrop.addEventListener('click', closeDrawer);
/* Gear button also opens the drawer (settings lives at the bottom of the drawer) */
if (gearToggle) {
gearToggle.addEventListener('click', openDrawer);
}
/* Drawer category toggle */
document.querySelectorAll('.drawer-cat-toggle').forEach(function(btn) {
btn.addEventListener('click', function() {
var sub = this.nextElementSibling;
var expanded = this.getAttribute('aria-expanded') === 'true';
this.setAttribute('aria-expanded', String(!expanded));
if (expanded) {
sub.classList.add('hidden');
} else {
sub.classList.remove('hidden');
}
});
});
/* Category tab switching */
document.querySelectorAll('[data-tab-key]').forEach(function(tab) {
tab.addEventListener('click', function() {
document.querySelectorAll('[data-tab-key]').forEach(function(t) {
t.removeAttribute('data-active');
t.style.background = 'var(--color-bg-tertiary)';
t.style.color = 'var(--color-text-secondary)';
t.style.border = '1px solid var(--color-border)';
});
this.setAttribute('data-active', 'true');
this.style.background = 'var(--color-brand)';
this.style.color = 'var(--color-text-inverse)';
this.style.border = '1px solid var(--color-brand)';
});
});
</script>
<script>lucide.createIcons();</script>
</body>
</html>
+78
View File
@@ -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:
+44
View File
@@ -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` 修改(必须以 `/` 开头表示容器内绝对路径)。
+29
View File
@@ -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"]
+26
View File
@@ -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;
}
}
+378
View File
@@ -0,0 +1,378 @@
# MyHomePage 1Panel 部署手册
> 适用环境:服务器已装 1Panelv2.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 上传产物到服务器
**方式 11Panel 文件管理(推荐,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/` 目录没传全
### Q2API 全部 404
- 检查第五步 5.4 的反向代理是否生效
- 服务器本地 `curl http://127.0.0.1:8080/health` 验证后端活着
### Q3CORS 跨域报错
- 检查第四步 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`(走反代最稳)
### Q7360 壁纸不显示
- 后端环境变量里 `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
└── ...
```
+3
View File
@@ -0,0 +1,3 @@
# 开发环境:使用 vite proxy/api -> http://localhost:5080
# 如果需要直连后端,把空值改为 http://localhost:5080
VITE_API_BASE=
+3
View File
@@ -0,0 +1,3 @@
# Capacitor Android APP 生产环境后端地址
# APP 内不能使用 vite proxy,必须填真实后端 URL(带 https:// 或 http://
VITE_API_BASE=http://10.0.2.2:5080
+12
View File
@@ -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
+69
View File
@@ -0,0 +1,69 @@
# Capacitor Android 打包
## 前置环境
1. **Node.js** ≥ 18(项目用 22
2. **JDK 17**`java -version` 可验证)
3. **Android Studio** + Android SDKAPI 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`
`<application>` 标签里加:
```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` 保持一致,建议反代
+27
View File
@@ -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;
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0f0f1a" />
<title>MyHomePage · 浏览器首页</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+3007
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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"
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"apiBase": ""
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#6c5ce7"/>
<stop offset="100%" stop-color="#00cec9"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#g)"/>
<path d="M18 42V22h8a8 8 0 0 1 0 16h-4v4Zm4-10h4a2 2 0 0 0 0-4h-4Zm16 10V22h4l8 12V22h4v20h-4l-8-12v12Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 439 B

+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { onMounted, ref, computed, onBeforeUnmount } from 'vue';
import { useSettingsStore } from '@/stores/settings';
import { useSyncStore } from '@/stores/sync';
import { useSearchEnginesStore } from '@/stores/searchEngines';
import { useCategoriesStore } from '@/stores/categories';
import { useBookmarksStore } from '@/stores/bookmarks';
import AppWallpaper from '@/components/AppWallpaper.vue';
import ToastHost from '@/components/AppToastHost.vue';
const settings = useSettingsStore();
const sync = useSyncStore();
const engines = useSearchEnginesStore();
const categories = useCategoriesStore();
const bookmarks = useBookmarksStore();
// 自适应:检测宽度
const width = ref(typeof window !== 'undefined' ? window.innerWidth : 1280);
const isMobile = computed(() => width.value < 768);
const onResize = () => { width.value = window.innerWidth; };
onMounted(async () => {
window.addEventListener('resize', onResize);
// 1. 先加载设置(主题立即生效)
await settings.load();
// 2. 拉一次全量同步
try {
await sync.sync();
} catch (e) {
console.warn('[init] sync failed', e);
// 兜底:单独拉
await Promise.allSettled([engines.load(), categories.load(), bookmarks.load()]);
}
// 3. 启动定时同步:每 30 秒拉一次增量(visibility 隐藏时暂停)
const tick = async () => {
if (document.visibilityState !== 'visible') return;
try { await sync.sync(); } catch (e) { console.warn('[sync] tick failed', e); }
};
setInterval(tick, 30_000);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') tick();
});
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize);
});
</script>
<template>
<AppWallpaper />
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" :is-mobile="isMobile" />
</transition>
</router-view>
<ToastHost />
</template>
<style>
.fade-enter-active, .fade-leave-active { transition: opacity var(--duration-base) var(--ease); }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
+18
View File
@@ -0,0 +1,18 @@
import http from './http';
import type { Bookmark, BookmarkUpsert } from '@/types/api';
/** 链接列表(可按分类过滤) */
export const fetchBookmarks = (categoryId?: number) =>
http.get<Bookmark[]>('/bookmarks', { params: { categoryId } }).then(r => r.data);
/** 创建链接 */
export const createBookmark = (body: BookmarkUpsert) =>
http.post<Bookmark>('/bookmarks', body).then(r => r.data);
/** 更新链接 */
export const updateBookmark = (id: number, body: BookmarkUpsert) =>
http.put<Bookmark>(`/bookmarks/${id}`, body).then(r => r.data);
/** 删除链接(软删) */
export const deleteBookmark = (id: number) =>
http.delete<void>(`/bookmarks/${id}`).then(r => r.data);
+18
View File
@@ -0,0 +1,18 @@
import http from './http';
import type { Category, CategoryUpsert } from '@/types/api';
/** 获取全量分类(树形) */
export const fetchCategoryTree = () =>
http.get<Category[]>('/categories').then(r => r.data);
/** 创建分类 */
export const createCategory = (body: CategoryUpsert) =>
http.post<Category>('/categories', body).then(r => r.data);
/** 更新分类 */
export const updateCategory = (id: number, body: CategoryUpsert) =>
http.put<Category>(`/categories/${id}`, body).then(r => r.data);
/** 删除分类 */
export const deleteCategory = (id: number) =>
http.delete<void>(`/categories/${id}`).then(r => r.data);
+78
View File
@@ -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<void> {
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<ApiEnvelope<unknown>>) => {
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;
+18
View File
@@ -0,0 +1,18 @@
import http from './http';
import type { SearchEngine, SearchEngineUpsert } from '@/types/api';
export const fetchSearchEngines = () =>
http.get<SearchEngine[]>('/search-engines').then(r => r.data);
export const createSearchEngine = (body: SearchEngineUpsert) =>
http.post<SearchEngine>('/search-engines', body).then(r => r.data);
export const updateSearchEngine = (id: number, body: SearchEngineUpsert) =>
http.put<SearchEngine>(`/search-engines/${id}`, body).then(r => r.data);
export const deleteSearchEngine = (id: number) =>
http.delete<void>(`/search-engines/${id}`).then(r => r.data);
/** 设为默认引擎(其他自动取消) */
export const setDefaultEngine = (id: number) =>
http.put<SearchEngine>(`/search-engines/${id}/default`).then(r => r.data);
+8
View File
@@ -0,0 +1,8 @@
import http from './http';
import type { AppSettings, SettingUpdate } from '@/types/api';
export const fetchSettings = () =>
http.get<AppSettings>('/settings').then(r => r.data);
export const updateSettings = (body: SettingUpdate) =>
http.put<AppSettings>('/settings', body).then(r => r.data);
+14
View File
@@ -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<SyncChangesResponse>('/sync/changes', { params }).then(r => r.data);
};
+12
View File
@@ -0,0 +1,12 @@
import http from './http';
import type { UploadResult } from '@/types/api';
/** 上传单个文件(FormData 方式) */
export const uploadFile = async (file: File): Promise<UploadResult> => {
const form = new FormData();
form.append('file', file);
const { data } = await http.post<UploadResult>('/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' }
});
return data;
};
+26
View File
@@ -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<FaviconResult> {
const { data } = await http.post<FaviconResult>('/utility/favicon', { url }, {
timeout: 15000 // favicon 抓取 + 下载可能稍久,给 15s
});
return data;
}
+53
View File
@@ -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<WallpaperCategory[]> {
const { data } = await http.get<WallpaperCategory[]>('/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<WallpaperRandom> {
const { data } = await http.get<WallpaperRandom>('/wallpaper/random', {
params: { cid, w, h },
timeout: 12000
});
return data;
}
/**
* P34:立即刷新指定分类的池子(清缓存重新拉 200 张),并返回 1 张新随机图。
* 「立即切换」按钮调用。
*/
export async function refreshWallpaperRandom(cid: string, w: number, h: number): Promise<WallpaperRandom> {
const { data } = await http.post<WallpaperRandom>('/wallpaper/refresh', null, {
params: { cid, w, h },
timeout: 15000 // 刷新池子要给久一点
});
return data;
}
+107
View File
@@ -0,0 +1,107 @@
<script setup lang="ts">
/**
* AppButton:玻璃拟态风格的按钮,支持 variant。
*/
import { computed } from 'vue';
interface Props {
variant?: 'primary' | 'ghost' | 'danger' | 'text';
size?: 'sm' | 'md' | 'lg';
iconOnly?: boolean;
disabled?: boolean;
loading?: boolean;
type?: 'button' | 'submit';
}
const props = withDefaults(defineProps<Props>(), {
variant: 'ghost',
size: 'md',
type: 'button',
disabled: false,
loading: false,
iconOnly: false
});
defineEmits<{ (e: 'click', ev: MouseEvent): void }>();
const classes = computed(() => [
'app-btn',
`app-btn--${props.variant}`,
`app-btn--${props.size}`,
props.iconOnly && 'app-btn--icon',
props.loading && 'is-loading'
]);
</script>
<template>
<button
:class="classes"
:type="type"
:disabled="disabled || loading"
@click="$emit('click', $event)"
>
<span v-if="loading" class="app-btn__spinner" />
<slot v-else />
</button>
</template>
<style scoped>
.app-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
border-radius: var(--radius-md);
font-weight: var(--weight-medium);
transition: background var(--duration-fast) var(--ease),
color var(--duration-fast) var(--ease),
transform var(--duration-fast) var(--ease),
box-shadow var(--duration-fast) var(--ease);
user-select: none;
white-space: nowrap;
}
.app-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.app-btn:not(:disabled):active { transform: scale(0.97); }
.app-btn--sm { padding: 4px 10px; font-size: var(--font-sm); min-height: 28px; }
.app-btn--md { padding: 6px 14px; font-size: var(--font-base); min-height: 36px; }
.app-btn--lg { padding: 10px 20px; font-size: var(--font-md); min-height: 44px; }
.app-btn--icon.app-btn--sm { padding: 4px; min-width: 28px; }
.app-btn--icon.app-btn--md { padding: 6px; min-width: 36px; }
.app-btn--icon.app-btn--lg { padding: 10px; min-width: 44px; }
.app-btn--primary {
background: var(--color-accent);
color: white;
box-shadow: var(--shadow-glow);
}
.app-btn--primary:hover:not(:disabled) { background: var(--color-accent-hover); }
.app-btn--ghost {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.app-btn--ghost:hover:not(:disabled) { background: var(--color-surface-strong); border-color: var(--color-border-strong); }
.app-btn--text {
color: var(--color-text-muted);
}
.app-btn--text:hover:not(:disabled) { color: var(--color-text); background: var(--color-accent-soft); }
.app-btn--danger {
background: transparent;
color: var(--color-danger);
border: 1px solid transparent;
}
.app-btn--danger:hover:not(:disabled) { background: rgba(255,118,117,.12); }
.app-btn__spinner {
width: 14px; height: 14px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
/**
* AppCard:玻璃拟态卡片容器。
*/
interface Props {
hoverable?: boolean;
active?: boolean;
padding?: 'none' | 'sm' | 'md' | 'lg';
}
withDefaults(defineProps<Props>(), { padding: 'md', hoverable: false, active: false });
</script>
<template>
<div :class="['app-card', `app-card--p-${padding}`, { 'is-hoverable': hoverable, 'is-active': active }]">
<slot />
</div>
</template>
<style scoped>
.app-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: transform var(--duration-fast) var(--ease),
border-color var(--duration-fast) var(--ease),
box-shadow var(--duration-fast) var(--ease);
}
.app-card--p-none { padding: 0; }
.app-card--p-sm { padding: var(--space-3); }
.app-card--p-md { padding: var(--space-4); }
.app-card--p-lg { padding: var(--space-6); }
.app-card.is-hoverable { cursor: pointer; }
.app-card.is-hoverable:hover {
transform: translateY(-2px);
border-color: var(--color-accent-soft);
box-shadow: var(--shadow-md);
}
.app-card.is-active {
border-color: var(--color-accent);
background: var(--color-accent-soft);
}
</style>
@@ -0,0 +1,77 @@
<script setup lang="ts">
/**
* AppCategoryTabs:移动端横向分类标签。
*/
import AppIcon from './AppIcon.vue';
import type { Category } from '@/types/api';
interface Props {
categories: Category[]; // 一级分类(含子分类)
selected: number | null; // 当前选中的二级分类 ID
}
const props = defineProps<Props>();
const emit = defineEmits<{ (e: 'select', id: number | null): void }>();
// 把一级分类和它的子项展平到一行
function pickByIndex(idx: number): number | null {
if (idx === 0) return null;
let cursor = 1;
for (const root of props.categories) {
for (const child of root.children) {
if (cursor === idx) return child.id;
cursor++;
}
}
return null;
}
</script>
<template>
<div class="tabs">
<button
:class="['tabs__item', { active: selected === null }]"
@click="emit('select', null)"
>
<AppIcon name="layers" :size="14" />
全部
</button>
<template v-for="root in categories" :key="root.id">
<button
v-for="child in root.children"
:key="child.id"
:class="['tabs__item', { active: selected === child.id }]"
@click="emit('select', child.id)"
>
<AppIcon :name="child.icon || 'link'" :size="14" />
{{ child.name }}
</button>
</template>
</div>
</template>
<style scoped>
.tabs {
display: flex; gap: 8px;
padding: 4px 4px;
overflow-x: auto;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar { display: none; }
.tabs__item {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 14px;
font-size: var(--font-sm);
color: var(--color-text-muted);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
white-space: nowrap;
flex-shrink: 0;
transition: all var(--duration-fast) var(--ease);
}
.tabs__item.active {
background: var(--color-accent);
color: white;
border-color: var(--color-accent);
}
</style>
+92
View File
@@ -0,0 +1,92 @@
<script setup lang="ts">
/**
* AppDrawer:侧滑抽屉(左侧或右侧)。
*/
import { watch, onMounted, onBeforeUnmount } from 'vue';
interface Props {
modelValue: boolean;
side?: 'left' | 'right';
title?: string;
width?: number;
}
const props = withDefaults(defineProps<Props>(), { side: 'left', width: 320 });
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void }>();
function close() { emit('update:modelValue', false); }
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && props.modelValue) close(); }
onMounted(() => window.addEventListener('keydown', onKey));
onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
watch(() => props.modelValue, (v) => { document.body.style.overflow = v ? 'hidden' : ''; });
</script>
<template>
<Teleport to="body">
<Transition :name="`drawer-${side}`">
<div v-if="modelValue" class="app-drawer" @click.self="close">
<aside :class="['app-drawer__panel', `app-drawer__panel--${side}`]" :style="{ width: width + 'px' }" @click.stop>
<header class="app-drawer__header">
<h3 v-if="title">{{ title }}</h3>
<slot name="header" />
<button class="app-drawer__close" @click="close" aria-label="关闭">
<AppIcon name="x" :size="18" />
</button>
</header>
<div class="app-drawer__body">
<slot />
</div>
</aside>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.app-drawer {
position: fixed; inset: 0;
z-index: var(--z-drawer);
background: rgba(0,0,0,.4);
backdrop-filter: blur(4px);
}
.app-drawer__panel {
position: absolute; top: 0; bottom: 0;
background: var(--color-bg-elevated);
border-right: 1px solid var(--color-border);
display: flex; flex-direction: column;
box-shadow: var(--shadow-xl);
}
.app-drawer__panel--left { left: 0; }
.app-drawer__panel--right { right: 0; border-right: 0; border-left: 1px solid var(--color-border); }
.app-drawer__header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-border);
}
.app-drawer__header h3 { font-size: var(--font-lg); font-weight: var(--weight-semibold); }
.app-drawer__close {
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-md);
color: var(--color-text-muted);
}
.app-drawer__close:hover { background: var(--color-surface); color: var(--color-text); }
.app-drawer__body { flex: 1; overflow-y: auto; padding: var(--space-4); }
.drawer-left-enter-active, .drawer-left-leave-active { transition: opacity var(--duration-base) var(--ease); }
.drawer-left-enter-active .app-drawer__panel, .drawer-left-leave-active .app-drawer__panel {
transition: transform var(--duration-base) var(--ease);
}
.drawer-left-enter-from, .drawer-left-leave-to { opacity: 0; }
.drawer-left-enter-from .app-drawer__panel { transform: translateX(-100%); }
.drawer-left-leave-to .app-drawer__panel { transform: translateX(-100%); }
.drawer-right-enter-active, .drawer-right-leave-active { transition: opacity var(--duration-base) var(--ease); }
.drawer-right-enter-active .app-drawer__panel, .drawer-right-leave-active .app-drawer__panel {
transition: transform var(--duration-base) var(--ease);
}
.drawer-right-enter-from, .drawer-right-leave-to { opacity: 0; }
.drawer-right-enter-from .app-drawer__panel { transform: translateX(100%); }
.drawer-right-leave-to .app-drawer__panel { transform: translateX(100%); }
</style>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
/**
* AppFab:浮动操作按钮(右下角)。
*/
import AppIcon from './AppIcon.vue';
defineEmits<{ (e: 'click'): void }>();
</script>
<template>
<button class="fab" @click="$emit('click')" aria-label="添加">
<AppIcon name="plus" :size="24" />
</button>
</template>
<style scoped>
.fab {
position: fixed;
right: 20px; bottom: 24px;
width: var(--fab-size); height: var(--fab-size);
border-radius: 50%;
background: var(--color-accent);
color: white;
display: flex; align-items: center; justify-content: center;
box-shadow: var(--shadow-lg), var(--shadow-glow);
z-index: var(--z-fab);
transition: transform var(--duration-fast) var(--ease);
}
.fab:hover { transform: scale(1.08); }
.fab:active { transform: scale(0.95); }
</style>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
/**
* AppIcon:根据字符串 key 渲染 lucide 图标;找不到时退化为 emoji / 文本。
*/
import { computed } from 'vue';
import { resolveIcon } from '@/utils/icon';
interface Props {
name?: string | null;
emoji?: string | null;
url?: string | null;
size?: number;
}
const props = withDefaults(defineProps<Props>(), { size: 18 });
const Comp = computed(() => resolveIcon(props.name));
const isImage = computed(() => !!props.url);
</script>
<template>
<span class="app-icon" :style="{ width: size + 'px', height: size + 'px' }">
<img v-if="isImage" :src="props.url!" :alt="name ?? ''" :width="size" :height="size" />
<span v-else-if="emoji" class="app-icon-emoji" :style="{ fontSize: size + 'px' }">{{ emoji }}</span>
<component v-else-if="Comp" :is="Comp" :size="size" />
<span v-else class="app-icon-fallback" :style="{ fontSize: size + 'px' }">{{ (name ?? '?').charAt(0).toUpperCase() }}</span>
</span>
</template>
<style scoped>
.app-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: inherit;
border-radius: var(--radius-sm);
overflow: hidden;
}
.app-icon img { width: 100%; height: 100%; object-fit: cover; }
.app-icon-emoji { line-height: 1; }
.app-icon-fallback {
font-weight: var(--weight-semibold);
color: var(--color-text-muted);
background: var(--color-surface);
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
}
</style>
+167
View File
@@ -0,0 +1,167 @@
<script setup lang="ts">
/**
* AppIconPicker:图标选择器弹窗(P23)。
* - 从 utils/icon 拉取 SUPPORTED_ICONS,渲染为响应式网格
* - 顶部搜索框实时过滤
* - 选中后 emit('select', name) 并关闭弹窗
*/
import { ref, computed, watch, nextTick, useTemplateRef } from 'vue';
import { SUPPORTED_ICONS } from '@/utils/icon';
import AppModal from './AppModal.vue';
import AppIcon from './AppIcon.vue';
interface Props {
modelValue: boolean;
selected?: string | null;
}
const props = withDefaults(defineProps<Props>(), { selected: '' });
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
(e: 'select', name: string): void;
}>();
const search = ref('');
const searchInput = useTemplateRef<HTMLInputElement>('searchInput');
const filteredIcons = computed(() => {
const q = search.value.trim().toLowerCase();
if (!q) return SUPPORTED_ICONS;
return SUPPORTED_ICONS.filter(n => n.toLowerCase().includes(q));
});
// 打开时自动 focus 搜索框 + 清空上次搜索
watch(() => props.modelValue, async (open) => {
if (open) {
search.value = '';
await nextTick();
searchInput.value?.focus();
}
});
function pick(name: string) {
emit('select', name);
emit('update:modelValue', false);
}
</script>
<template>
<AppModal
:model-value="modelValue"
title="选择图标"
size="lg"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="picker">
<div class="picker__head">
<AppIcon name="search" :size="16" class="picker__search-icon" />
<input
ref="searchInput"
v-model="search"
class="picker__search"
placeholder="搜索图标名(layers / bot / github ..."
autocomplete="off"
spellcheck="false"
/>
<span class="picker__count">{{ filteredIcons.length }} </span>
</div>
<div v-if="filteredIcons.length === 0" class="picker__empty">
<AppIcon name="help-circle" :size="24" />
<p>没找到匹配的图标{{ search }}</p>
</div>
<div v-else class="picker__grid">
<button
v-for="name in filteredIcons"
:key="name"
:class="['picker__item', { active: name === selected }]"
:title="name"
type="button"
@click="pick(name)"
>
<AppIcon :name="name" :size="22" />
<span class="picker__name">{{ name }}</span>
</button>
</div>
</div>
</AppModal>
</template>
<style scoped>
.picker { display: flex; flex-direction: column; gap: 12px; }
.picker__head {
display: flex; align-items: center; gap: 8px;
position: relative;
}
.picker__search-icon {
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
color: var(--color-text-subtle);
pointer-events: none;
}
.picker__search {
flex: 1;
width: 100%;
padding: 8px 12px 8px 36px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
outline: none;
font-size: var(--font-sm);
transition: border-color var(--duration-fast) var(--ease);
}
.picker__search:focus { border-color: var(--color-accent); }
.picker__search::placeholder { color: var(--color-text-subtle); }
.picker__count {
font-size: var(--font-xs);
color: var(--color-text-subtle);
flex-shrink: 0;
}
.picker__empty {
display: flex; flex-direction: column; align-items: center; gap: 8px;
padding: 48px 16px;
color: var(--color-text-subtle);
text-align: center;
}
.picker__empty p { font-size: var(--font-sm); }
.picker__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
gap: 8px;
max-height: 56vh;
overflow-y: auto;
padding: 4px;
}
.picker__item {
display: flex; flex-direction: column; align-items: center; gap: 6px;
padding: 10px 6px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-muted);
cursor: pointer;
transition: all var(--duration-fast) var(--ease);
min-width: 0;
}
.picker__item:hover {
background: var(--color-bg-elevated);
border-color: var(--color-border-strong);
color: var(--color-text);
transform: translateY(-1px);
}
.picker__item.active {
background: var(--color-accent-soft);
border-color: var(--color-accent);
color: var(--color-accent);
font-weight: var(--weight-medium);
}
.picker__name {
font-size: 10px;
font-family: var(--font-mono, ui-monospace, monospace);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
line-height: 1.2;
}
</style>
+192
View File
@@ -0,0 +1,192 @@
<script setup lang="ts">
/**
* AppLinkCard:桌面端链接卡片。
* - 左:方形彩色 logo 块(用 user 选定的 colorBg;未设时品牌色兜底;上传图片则用图片主色调)
* - 中:图标(lucide / emoji / image / 名称前 2 字符)
* - 右:标题 + 描述
*/
import { ref, computed, onMounted, watch } from 'vue';
import type { Bookmark } from '@/types/api';
import AppIcon from './AppIcon.vue';
import { colorFromUrl, firstChar, extractDominantColor } from '@/utils/color';
import { resolveAssetUrl } from '@/config';
interface Props {
bookmark: Bookmark;
}
const props = defineProps<Props>();
defineEmits<{ (e: 'click', b: Bookmark): void; (e: 'edit', b: Bookmark): void; (e: 'delete', b: Bookmark): void }>();
// P31iconType='favicon' 与 'image' 都是「显示 iconUrl 图片」(仅来源不同:用户上传 vs 后端自动抓取)
const isImage = computed(() =>
(props.bookmark.iconType === 'image' || props.bookmark.iconType === 'favicon') &&
!!props.bookmark.iconUrl
);
// P52:把后端返回的相对路径("/uploads/...")拼成 apiBase 的绝对 URL
const iconSrc = computed(() => resolveAssetUrl(props.bookmark.iconUrl));
/** 适配图片时,从图片主色调异步提取 */
const adaptiveColor = ref<string | null>(null);
async function refreshAdaptive() {
adaptiveColor.value = null;
if (props.bookmark.colorBg) return;
if (isImage.value && iconSrc.value) {
const c = await extractDominantColor(iconSrc.value);
if (c) adaptiveColor.value = c;
}
}
onMounted(refreshAdaptive);
watch(() => [props.bookmark.iconUrl, props.bookmark.colorBg], refreshAdaptive);
/** 实际使用的背景色:用户指定 > 图片主色 > 品牌色 */
const bg = computed<string>(() => {
if (props.bookmark.colorBg) return props.bookmark.colorBg;
if (adaptiveColor.value) return adaptiveColor.value;
return colorFromUrl(props.bookmark.url).bg;
});
const fg = computed<string>(() => {
if (props.bookmark.colorBg) return '#ffffff'; // 简化:自定义色统一白字(用户可选亮色块时再用 fg)
if (adaptiveColor.value) return '#ffffff';
return colorFromUrl(props.bookmark.url).fg;
});
/** 兜底:没有图标时使用名称前 2 字符 */
const hasIcon = computed(() => isImage.value || !!props.bookmark.icon);
const logoText = computed(() => firstChar(props.bookmark.title));
</script>
<template>
<div class="link-card glass" @click="$emit('click', bookmark)">
<!-- 左侧正方形彩色 logo 色块 -->
<div class="link-card__logo" :style="{ background: bg, color: fg }">
<AppIcon
v-if="isImage"
:url="iconSrc"
:size="36"
class="link-card__logo-img"
/>
<span v-else-if="bookmark.iconType === 'emoji' && bookmark.icon" class="link-card__logo-emoji">{{ bookmark.icon }}</span>
<AppIcon
v-else-if="bookmark.iconType === 'lucide' && bookmark.icon"
:name="bookmark.icon"
:size="32"
/>
<span v-else class="link-card__logo-text">{{ logoText }}</span>
</div>
<!-- 右侧标题 + 描述 -->
<div class="link-card__body">
<h4 class="link-card__title">{{ bookmark.title }}</h4>
<p class="link-card__desc">{{ bookmark.description || bookmark.url }}</p>
</div>
<!-- hover 浮现的编辑/删除 -->
<div class="link-card__actions" @click.stop>
<button class="link-card__action" @click="$emit('edit', bookmark)" :aria-label="`编辑 ${bookmark.title}`">
<AppIcon name="edit" :size="14" />
</button>
<button class="link-card__action link-card__action--danger" @click="$emit('delete', bookmark)" :aria-label="`删除 ${bookmark.title}`">
<AppIcon name="trash" :size="14" />
</button>
</div>
</div>
</template>
<style scoped>
/* P28:横排 + 方形 logo + AppIcon + colorBg */
.link-card {
position: relative;
display: flex; align-items: stretch;
min-height: var(--link-card-min-height);
padding: 0;
background: var(--glass-bg-faint);
border: 1px solid var(--glass-border);
border-radius: var(--link-card-radius);
overflow: hidden;
cursor: pointer;
transition: transform var(--duration-fast) var(--ease),
box-shadow var(--duration-fast) var(--ease),
border-color var(--duration-fast) var(--ease);
backdrop-filter: blur(var(--glass-blur-sm)) saturate(180%);
-webkit-backdrop-filter: blur(var(--glass-blur-sm)) saturate(180%);
}
.link-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
border-color: var(--color-border-strong);
}
.link-card__logo {
width: var(--link-logo-size);
height: var(--link-logo-size);
flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 32px;
font-weight: var(--weight-bold);
letter-spacing: -0.5px;
user-select: none;
position: relative;
border-right: 1px solid var(--glass-border);
overflow: hidden;
}
.link-card__logo-text {
text-shadow: 0 1px 2px rgba(0,0,0,0.15);
}
.link-card__logo-img { border-radius: var(--radius-sm); }
.link-card__logo-emoji { line-height: 1; }
.link-card__body {
flex: 1; min-width: 0;
display: flex; flex-direction: column; justify-content: center;
padding: 12px 14px;
background: var(--glass-bg-faint);
backdrop-filter: blur(var(--glass-blur-sm));
-webkit-backdrop-filter: blur(var(--glass-blur-sm));
}
.link-card__title {
font-size: var(--font-md);
font-weight: var(--weight-semibold);
color: var(--color-text);
margin-bottom: 4px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.link-card__desc {
font-size: var(--font-xs);
color: var(--color-text-muted);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.link-card__actions {
position: absolute;
top: 8px; right: 8px;
display: flex; gap: 4px;
opacity: 0;
transition: opacity var(--duration-fast) var(--ease);
}
.link-card:hover .link-card__actions { opacity: 1; }
.link-card__action {
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-md);
color: var(--color-text-muted);
background: var(--glass-bg-strong);
border: 1px solid var(--glass-border);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
transition: color var(--duration-fast) var(--ease), background var(--duration-fast) var(--ease);
}
.link-card__action:hover { color: var(--color-text); background: var(--color-bg-elevated); }
.link-card__action--danger:hover { color: var(--color-danger); background: rgba(255,118,117,0.15); }
/* 移动端 / 极窄屏:logo 缩小为方形 */
@media (max-width: 640px) {
.link-card__logo { width: 64px; height: 64px; font-size: 24px; }
.link-card__body { padding: 10px 12px; }
.link-card__title { font-size: var(--font-sm); }
}
</style>
+159
View File
@@ -0,0 +1,159 @@
<script setup lang="ts">
/**
* AppLinkListItem:移动端 / 窄屏的链接列表项。
* P28 升级:
* - logo 块变**正方形**(边长 = 高度),与 AppLinkCard 视觉对齐
* - 显示 AppIconlucide / emoji / image / 名称前 2 字符兜底)
* - 背景色三级 fallbackuser.colorBg > 图片主色 > 品牌色
*/
import { ref, computed, onMounted, watch } from 'vue';
import type { Bookmark } from '@/types/api';
import AppIcon from './AppIcon.vue';
import { colorFromUrl, firstChar, extractDominantColor } from '@/utils/color';
import { resolveAssetUrl } from '@/config';
interface Props { bookmark: Bookmark; }
const props = defineProps<Props>();
defineEmits<{ (e: 'click', b: Bookmark): void; (e: 'edit', b: Bookmark): void; (e: 'delete', b: Bookmark): void }>();
// P31iconType='favicon' 与 'image' 都是「显示 iconUrl 图片」(仅来源不同:用户上传 vs 后端自动抓取)
const isImage = computed(() =>
(props.bookmark.iconType === 'image' || props.bookmark.iconType === 'favicon') &&
!!props.bookmark.iconUrl
);
// P52:跨域部署时把后端相对路径("/uploads/...")拼成 apiBase 绝对 URL
const iconSrc = computed(() => resolveAssetUrl(props.bookmark.iconUrl));
/** 自适应:从 iconUrl 提取主色 */
const adaptiveColor = ref<string | null>(null);
async function refreshAdaptive() {
adaptiveColor.value = null;
if (props.bookmark.colorBg) return;
if (isImage.value && iconSrc.value) {
const c = await extractDominantColor(iconSrc.value);
if (c) adaptiveColor.value = c;
}
}
onMounted(refreshAdaptive);
watch(() => [props.bookmark.iconUrl, props.bookmark.colorBg], refreshAdaptive);
const bg = computed<string>(() => {
if (props.bookmark.colorBg) return props.bookmark.colorBg;
if (adaptiveColor.value) return adaptiveColor.value;
return colorFromUrl(props.bookmark.url).bg;
});
const fg = computed<string>(() => {
if (props.bookmark.colorBg) return '#ffffff';
if (adaptiveColor.value) return '#ffffff';
return colorFromUrl(props.bookmark.url).fg;
});
const hasIcon = computed(() => isImage.value || !!props.bookmark.icon);
const logoText = computed(() => firstChar(props.bookmark.title));
</script>
<template>
<div class="link-row" @click="$emit('click', bookmark)">
<!-- 左侧正方形彩色 logo -->
<div class="link-row__logo" :style="{ background: bg, color: fg }">
<AppIcon
v-if="isImage"
:url="iconSrc"
:size="28"
class="link-row__logo-img"
/>
<span v-else-if="bookmark.iconType === 'emoji' && bookmark.icon" class="link-row__logo-emoji">{{ bookmark.icon }}</span>
<AppIcon
v-else-if="bookmark.iconType === 'lucide' && bookmark.icon"
:name="bookmark.icon"
:size="24"
/>
<span v-else class="link-row__logo-text">{{ logoText }}</span>
</div>
<!-- 标题 + 描述 -->
<div class="link-row__body">
<div class="link-row__title">{{ bookmark.title }}</div>
<div class="link-row__desc">{{ bookmark.description || bookmark.url }}</div>
</div>
<!-- 编辑 / 删除 -->
<div class="link-row__actions" @click.stop>
<button class="link-row__action" @click="$emit('edit', bookmark)" :aria-label="`编辑 ${bookmark.title}`">
<AppIcon name="edit" :size="14" />
</button>
<button class="link-row__action link-row__action--danger" @click="$emit('delete', bookmark)" :aria-label="`删除 ${bookmark.title}`">
<AppIcon name="trash" :size="14" />
</button>
</div>
</div>
</template>
<style scoped>
/* P28:横排 + 方形 logo + AppIcon + colorBg(与 AppLinkCard 视觉一致,体积更紧凑) */
/* P36align-items 改 center —— 原 stretch + min-height 64px + logo 56×56 固定高度时,flex 把 logo "贴顶" 排版(stretch 在子项有 height 时退化为 flex-start),导致 logo 框上下错位 */
.link-row {
display: flex; align-items: center;
min-height: var(--link-row-logo-size);
background: var(--glass-bg-faint);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
backdrop-filter: blur(var(--glass-blur-sm)) saturate(180%);
-webkit-backdrop-filter: blur(var(--glass-blur-sm)) saturate(180%);
transition: transform var(--duration-fast) var(--ease),
box-shadow var(--duration-fast) var(--ease);
}
.link-row:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.link-row:active { transform: scale(0.99); }
.link-row__logo {
width: var(--link-row-logo-size);
height: var(--link-row-logo-size);
flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 20px;
font-weight: var(--weight-bold);
letter-spacing: -0.5px;
text-shadow: 0 1px 2px rgba(0,0,0,0.15);
border-right: 1px solid var(--glass-border);
user-select: none;
overflow: hidden;
}
.link-row__logo-img { border-radius: var(--link-logo-radius); }
.link-row__logo-emoji { line-height: 1; }
.link-row__logo-text { letter-spacing: 0; }
.link-row__body {
flex: 1; min-width: 0;
display: flex; flex-direction: column; justify-content: center;
padding: 8px 10px;
}
.link-row__title {
font-size: var(--font-sm);
font-weight: var(--weight-medium);
color: var(--color-text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.link-row__desc {
font-size: var(--font-xs);
color: var(--color-text-muted);
margin-top: 2px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.link-row__actions { display: flex; gap: 2px; align-items: center; padding-right: 4px; }
.link-row__action {
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-sm);
color: var(--color-text-muted);
}
.link-row__action:hover { color: var(--color-text); background: var(--color-bg-elevated); }
.link-row__action--danger:hover { color: var(--color-danger); }
</style>
@@ -0,0 +1,44 @@
<script setup lang="ts">
/**
* AppMobileTopBar:移动端顶栏。
*/
import AppIcon from './AppIcon.vue';
import AppSearchBar from './AppSearchBar.vue';
defineEmits<{ (e: 'menu'): void; (e: 'settings'): void }>();
</script>
<template>
<header class="topbar">
<button class="topbar__icon-btn" @click="$emit('menu')" aria-label="菜单">
<AppIcon name="menu" :size="20" />
</button>
<div class="topbar__search">
<AppSearchBar />
</div>
<button class="topbar__icon-btn" @click="$emit('settings')" aria-label="设置">
<AppIcon name="settings" :size="20" />
</button>
</header>
</template>
<style scoped>
.topbar {
position: sticky; top: 0;
z-index: var(--z-elevated);
display: flex; align-items: center; gap: var(--space-2);
padding: 10px 12px;
background: var(--glass-bg-strong);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--color-border);
}
.topbar__search { flex: 1; min-width: 0; }
.topbar__icon-btn {
width: 40px; height: 40px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-md);
color: var(--color-text-muted);
flex-shrink: 0;
}
.topbar__icon-btn:hover { color: var(--color-text); background: var(--color-surface); }
</style>
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
/**
* AppModal:通用弹窗(带遮罩 + ESC 关闭 + 点击遮罩关闭)。
*/
import { watch, onMounted, onBeforeUnmount } from 'vue';
interface Props {
modelValue: boolean;
title?: string;
size?: 'sm' | 'md' | 'lg';
closeOnBackdrop?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
size: 'md',
closeOnBackdrop: true
});
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void }>();
function close() { emit('update:modelValue', false); }
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && props.modelValue) close(); }
onMounted(() => window.addEventListener('keydown', onKey));
onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
watch(() => props.modelValue, (v) => {
document.body.style.overflow = v ? 'hidden' : '';
});
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="modelValue" class="app-modal" @click.self="closeOnBackdrop && close()">
<div :class="['app-modal__panel', `app-modal__panel--${size}`]" @click.stop>
<header v-if="title || $slots.header" class="app-modal__header">
<slot name="header">
<h3>{{ title }}</h3>
</slot>
<button class="app-modal__close" @click="close" aria-label="关闭">
<AppIcon name="x" :size="18" />
</button>
</header>
<div class="app-modal__body">
<slot />
</div>
<footer v-if="$slots.footer" class="app-modal__footer">
<slot name="footer" />
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.app-modal {
position: fixed; inset: 0;
z-index: var(--z-modal);
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,.5);
backdrop-filter: blur(6px);
padding: var(--space-4);
}
.app-modal__panel {
width: 100%;
max-height: 90vh;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
display: flex; flex-direction: column;
overflow: hidden;
}
.app-modal__panel--sm { max-width: 380px; }
.app-modal__panel--md { max-width: 560px; }
.app-modal__panel--lg { max-width: 820px; }
.app-modal__header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-border);
}
.app-modal__header h3 { font-size: var(--font-xl); font-weight: var(--weight-semibold); }
.app-modal__close {
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-md);
color: var(--color-text-muted);
transition: background var(--duration-fast) var(--ease);
}
.app-modal__close:hover { background: var(--color-surface); color: var(--color-text); }
.app-modal__body {
flex: 1; overflow-y: auto;
padding: var(--space-5);
}
.app-modal__footer {
display: flex; gap: var(--space-3); justify-content: flex-end;
padding: var(--space-4) var(--space-5);
border-top: 1px solid var(--color-border);
}
.modal-enter-active, .modal-leave-active { transition: opacity var(--duration-base) var(--ease); }
.modal-enter-from, .modal-leave-to { opacity: 0; }
.modal-enter-active .app-modal__panel, .modal-leave-active .app-modal__panel {
transition: transform var(--duration-base) var(--ease);
}
.modal-enter-from .app-modal__panel, .modal-leave-to .app-modal__panel {
transform: scale(.96) translateY(8px);
}
</style>

Some files were not shown because too many files have changed in this diff Show More