初始提交:浏览器首页 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:
+43
@@ -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
|
||||
@@ -0,0 +1,168 @@
|
||||
# MyHomePage · 浏览器首页
|
||||
|
||||
> 跨端可用的浏览器首页 / 起始页:PC、平板、手机浏览器 + Android APP 共享同一份数据,实时同步。
|
||||
|
||||

|
||||
|
||||
## 功能特性
|
||||
|
||||
- 二级分类导航(常用工具 > 搜索引擎 / 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
|
||||
@@ -0,0 +1,18 @@
|
||||
## 忽略构建产物
|
||||
bin/
|
||||
obj/
|
||||
|
||||
## 用户专属
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
.idea/
|
||||
|
||||
## 上传目录的运行时文件(保留 .gitkeep)
|
||||
Uploads/*
|
||||
!Uploads/.gitkeep
|
||||
|
||||
## 本地数据库
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
@@ -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<T>)</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" };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>工具类 API:favicon 抓取等小工具(手动测试用 / 调试入口)。</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 InitTables(Sqlite 不支持 alter column primary key,触发表结构不一致会抛)=====
|
||||
// 后续靠 MigrateSettingColumns / MigrateBookmarkColumns 给老库补列。
|
||||
const string settingsTable = "settings";
|
||||
if (_ctx.Db.DbMaintenance.IsAnyTable(settingsTable))
|
||||
{
|
||||
_logger.LogInformation("检测到 settings 表已存在,跳过 CodeFirst(已通过轻量迁移补齐列)");
|
||||
}
|
||||
else
|
||||
{
|
||||
_ctx.Db.CodeFirst.InitTables(
|
||||
typeof(Category),
|
||||
typeof(Bookmark),
|
||||
typeof(SearchEngine),
|
||||
typeof(Setting),
|
||||
typeof(SyncLog)
|
||||
);
|
||||
}
|
||||
|
||||
// 对已存在数据库做轻量迁移:给 settings 表补上新增列(CodeFirst InitTables 不会自动 ALTER 老库)
|
||||
MigrateSettingColumns();
|
||||
// 给 bookmarks 表补充 ColorBg 列(P28 链接 logo 背景色)
|
||||
MigrateBookmarkColumns();
|
||||
// 给 search_engines 表补充 IconType / IconUrl / ColorBg 列(P37 引擎图标逻辑对齐链接)
|
||||
MigrateSearchEngineColumns();
|
||||
// 给 settings 表补充 OpenSearchInNewTab 列(P46 搜索框行为开关)
|
||||
MigrateSettingColumnsV2();
|
||||
|
||||
await SeedAsync();
|
||||
_logger.LogInformation("数据库初始化完成");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "数据库初始化失败");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 接口原始 url(bdr/__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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>图标 URL(IconType = 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>图标图片 URL(IconType=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; }
|
||||
}
|
||||
@@ -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>背景图:预设 key(wp1..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 壁纸分类 ID(P34),例如 "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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 API(MapGet / MapPost 等)没有
|
||||
// 用 TryGetValue 防御,避免 KeyNotFoundException 启动崩
|
||||
var routeValues = apiDesc.ActionDescriptor.RouteValues;
|
||||
if (routeValues.TryGetValue("action", out var action)
|
||||
&& !string.IsNullOrEmpty(action)
|
||||
&& routeValues.TryGetValue("controller", out var controller)
|
||||
&& !string.IsNullOrEmpty(controller))
|
||||
{
|
||||
return $"{controller}_{action}";
|
||||
}
|
||||
// minimal API 兜底:用路由模板做 operationId(去掉 / 和特殊字符)
|
||||
// 例:/health → health;/api/wallpaper/random → api_wallpaper_random
|
||||
return apiDesc.RelativePath?
|
||||
.Replace("/", "_", StringComparison.Ordinal)
|
||||
.Trim('_')
|
||||
?? "UnknownEndpoint";
|
||||
});
|
||||
});
|
||||
|
||||
// ===== CORS =====
|
||||
var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get<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>();
|
||||
|
||||
// ===== P31:favicon 自动抓取 =====
|
||||
builder.Services.AddMemoryCache(); // IMemoryCache(24h 缓存已抓 favicon,Singleton)
|
||||
builder.Services.AddHttpClient(nameof(FaviconService), c => // 命名 HttpClient(IHttpClientFactory 管理生命周期)
|
||||
{
|
||||
c.Timeout = TimeSpan.FromSeconds(5); // 全局 5s 超时(个别 GET 还会二次限制)
|
||||
c.DefaultRequestHeaders.Add("User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36");
|
||||
});
|
||||
builder.Services.AddScoped<FaviconService>(); // 注入 IHttpClientFactory + IMemoryCache
|
||||
|
||||
// ===== P34:360 在线壁纸代理 =====
|
||||
builder.Services.AddHttpClient(nameof(WallpaperService), c =>
|
||||
{
|
||||
c.Timeout = TimeSpan.FromSeconds(10); // 拉分类 / 200 张池子给 10s 充裕
|
||||
c.DefaultRequestHeaders.Add("User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36");
|
||||
});
|
||||
builder.Services.AddScoped<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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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 / 1MB,User-Agent 模拟浏览器)
|
||||
/// 2. 解析 HTML <link rel="icon"> / apple-touch-icon / shortcut icon
|
||||
/// 3. 按优先级选最佳 icon(apple-touch > sizes 最大 > /favicon.ico 兜底)
|
||||
/// 4. 下载 icon 图片到 Upload/favicons/ 目录
|
||||
/// 5. 返回前端可访问的 URL(保存到 bookmark.IconUrl + iconType='favicon')
|
||||
/// SSRF 防护:拒绝内网 / 本地 / 链路本地地址。
|
||||
/// 失败时返回 null(不抛异常),由调用方走默认图标。
|
||||
/// </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 → LogError(docker logs 默认级别是 Information 看不到 warning 堆栈),
|
||||
// 并附上 UploadOptions.Path 实际值,方便排查容器内 /uploads 权限 / 路径覆盖问题
|
||||
_logger.LogError(ex,
|
||||
"Favicon fetch failed: {Url} | UploadOptions.Path='{OptPath}' (env={Env})",
|
||||
pageUrl, _uploadOptions.Path, Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "(default)");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void CacheNull(string key) => _cache.Set(key, (string?)null, TimeSpan.FromMinutes(10));
|
||||
|
||||
/// <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(限 1MB,5s 超时)</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);
|
||||
}
|
||||
|
||||
// P33:HTML 长度 + 是否含 favicon 关键字(方便定位"是否真的没找到")
|
||||
var hasIconTag = html.Contains("rel=\"icon\"", StringComparison.OrdinalIgnoreCase)
|
||||
|| html.Contains("rel='icon'", StringComparison.OrdinalIgnoreCase)
|
||||
|| html.Contains("rel=\"alternate icon\"", StringComparison.OrdinalIgnoreCase);
|
||||
_logger.LogDebug("Favicon HTML scan: {Url} len={Len} contains-icon-link={Has}",
|
||||
pageUri, total, hasIconTag);
|
||||
if (!hasIconTag)
|
||||
{
|
||||
// 截取 HTML 前 200 字符方便主人看是被什么页面拦了(如 FN Connect 反向代理页)
|
||||
_logger.LogWarning("Favicon HTML has no <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
|
||||
/// - 同时解析 <meta property="og:image"> 作为兜底
|
||||
/// - 加详细日志,方便定位"为什么没抓到"
|
||||
/// </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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using MyHomePage.Api.Models.Dtos;
|
||||
|
||||
namespace MyHomePage.Api.Services;
|
||||
|
||||
/// <summary>多端同步服务:基于 SyncLog 的增量同步 + 全量快照。</summary>
|
||||
public interface ISyncService
|
||||
{
|
||||
Task<SyncChangesResponse> GetChangesAsync(DateTime? since);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 拿到老 DTO,engineLogoStyle 命中兜底色块 → 引擎 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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&a=getAllCategoriesV2&from=360chrome
|
||||
/// 2. 按分类 ID 获取图片列表(每条 data 含 url + 6 个预设分辨率 + 原始分辨率)
|
||||
/// http://wallpaper.apc.360.cn/index.php?c=WallPaper&a=getAppsByCategory
|
||||
/// &cid={cid}&start=0&count=200&from=360chrome
|
||||
///
|
||||
/// P34.1 修正(主人反馈 360 接口实际返回内容):
|
||||
/// - 分类数据有 order_num 字段(110/100/99/.../9),应按 order_num 降序展示(不再是字母排序)
|
||||
/// - 列表里 img_1600_900 / img_1440_900 / img_1366_768 / img_1280_800 / img_1280_1024 / img_1024_768
|
||||
/// 是 360 官方为每张图准备的预设分辨率 URL,**优先用这些**(CDN 必定存在,画质 85)
|
||||
/// - 视口分辨率若不匹配任何 preset,兜底走 RewriteUrl(quality=85) 自构 bdm/{W}_{H}_85
|
||||
/// - 原图 url 字段用 bdr/__85/...,**不直接使用**(那是 bdr 压缩档,画质低)
|
||||
///
|
||||
/// 后端缓存策略(避免每次前端访问都打 360 接口):
|
||||
/// - 分类列表:启动拉一次,缓存 24h
|
||||
/// - 图片池(每个 cid):缓存 200 张,TTL 12h
|
||||
/// - 立即刷新:手动调 RefreshAsync 清缓存重新拉,并立即返回一张随机图
|
||||
/// </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>分类列表缓存 TTL(24h,启动再热一次)</summary>
|
||||
private static readonly TimeSpan CategoryTtl = TimeSpan.FromHours(24);
|
||||
/// <summary>图片池缓存 TTL(12h)</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);
|
||||
|
||||
// =====================================================================
|
||||
// 公开 API(Controller 调用)
|
||||
// =====================================================================
|
||||
|
||||
/// <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 差 < 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Information",
|
||||
"SqlSugar": "Debug"
|
||||
}
|
||||
},
|
||||
"Database": {
|
||||
"Provider": "Sqlite",
|
||||
"ConnectionString": "Data Source=myhomepage.dev.db"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"SqlSugar": "Information"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Urls": "http://0.0.0.0:5080",
|
||||
"Database": {
|
||||
// ===== 当前默认:SQLite(开发 / 单机部署) =====
|
||||
"Provider": "Sqlite",
|
||||
"ConnectionString": "Data Source=myhomepage.db",
|
||||
|
||||
// ======================================================================
|
||||
// 部署到 MySQL 时切换方式(任选其一,1Panel 推荐方式 C)
|
||||
// ======================================================================
|
||||
//
|
||||
// 方式 A:直接修改本文件(不推荐,会被提交到 git,不便多环境管理)
|
||||
// "Provider": "MySql",
|
||||
// "ConnectionString": "server=127.0.0.1;port=3306;database=myhomepage;user=myhomepage_user;password=YOUR_PASSWORD;charset=utf8mb4;",
|
||||
//
|
||||
// 方式 B:新建 backend/appsettings.Production.json 覆盖 Database 节
|
||||
// 内容:
|
||||
// {
|
||||
// "Database": {
|
||||
// "Provider": "MySql",
|
||||
// "ConnectionString": "server=127.0.0.1;port=3306;database=myhomepage;user=myhomepage_user;password=YOUR_PASSWORD;charset=utf8mb4;"
|
||||
// }
|
||||
// }
|
||||
// 启动时设环境变量 ASPNETCORE_ENVIRONMENT=Production 即生效
|
||||
//
|
||||
// 方式 C(1Panel 推荐,零文件改动):
|
||||
// 在 1Panel 网站详情页 → 「环境变量」里设:
|
||||
// Database__Provider=MySql
|
||||
// Database__ConnectionString=server=127.0.0.1;port=3306;database=myhomepage;user=myhomepage_user;password=xxx;charset=utf8mb4;
|
||||
// ASP.NET Core 配置系统会自动用环境变量覆盖 appsettings.json 里的 Database 节
|
||||
//
|
||||
// 连接串参数说明:
|
||||
// server MySQL 主机(1Panel 部署本机用 127.0.0.1;远程用实际 IP)
|
||||
// port 端口(默认 3306)
|
||||
// database 数据库名(需要先在 1Panel 数据库面板建好,utf8mb4 字符集)
|
||||
// user 用户名(建议建专用用户,权限限定在 myhomepage 库,主机锁 localhost)
|
||||
// password 用户密码(如果含特殊字符需 URL encode:# → %23,@ → %40,; → %3B 等)
|
||||
// charset 字符集(务必 utf8mb4,否则 emoji 存不进)
|
||||
// 其他可选 SslMode=None/Required;Pooling=true;TreatTinyAsBoolean=true 等
|
||||
// ======================================================================
|
||||
},
|
||||
"Upload": {
|
||||
// P52 修复:1Panel Docker 部署时改成 "/uploads"(绝对路径)让 volume 挂载生效
|
||||
// - 容器内路径 = 宿主机 /data/myhomepage/upload
|
||||
// - 容器销毁后 favicon 仍然保留
|
||||
// - 不污染 /app 代码目录
|
||||
// 本地 dev / 单机 SQLite 时用 "Uploads"(相对路径 = /app/Uploads)即可
|
||||
"Path": "/uploads",
|
||||
"BaseUrl": "/uploads",
|
||||
"MaxSizeBytes": 10485760
|
||||
},
|
||||
"Cors": {
|
||||
"Origins": [
|
||||
"http://localhost:5173",
|
||||
"http://localhost:4173",
|
||||
"http://localhost:3000"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.9",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "浏览器首页"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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:
|
||||
@@ -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` 修改(必须以 `/` 开头表示容器内绝对路径)。
|
||||
@@ -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"]
|
||||
@@ -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
@@ -0,0 +1,378 @@
|
||||
# MyHomePage 1Panel 部署手册
|
||||
|
||||
> 适用环境:服务器已装 1Panel(v2.x)+ 1Panel 内置 MySQL(已部署并启动)
|
||||
> 全程不碰 Docker / nginx.conf / systemd —— 全部交给 1Panel 面板管理
|
||||
|
||||
---
|
||||
|
||||
## 一、部署架构
|
||||
|
||||
```
|
||||
[公网/局域网]
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
│ │
|
||||
域名 A(前端) 域名 B(后端,可选)
|
||||
│ │
|
||||
▼ ▼
|
||||
1Panel 静态网站 1Panel .NET 运行时
|
||||
(Nginx 反代) (Kestrel)
|
||||
│ │
|
||||
│ /api/* 反代到 127.0.0.1:8080 │
|
||||
└──────────────┐ ┌──────────────┘
|
||||
▼ ▼
|
||||
1Panel MySQL
|
||||
(本机 127.0.0.1:3306)
|
||||
```
|
||||
|
||||
**一句话总结**:MySQL 已部署 + 前端走静态网站 + 后端走 .NET 运行环境 = 三步上线。
|
||||
|
||||
---
|
||||
|
||||
## 二、前置准备
|
||||
|
||||
### 2.1 服务器侧
|
||||
|
||||
| 条件 | 说明 |
|
||||
|------|------|
|
||||
| 1Panel v2.x | 已装好(主人的情况) |
|
||||
| MySQL 实例 | 1Panel 应用商店装的 MySQL,已 running |
|
||||
| 域名 | 可选:解析 A 记录到服务器 IP;没域名也能用 IP+端口 |
|
||||
| 1Panel 防火墙 | 默认放行 80/443;后端用 8080 需手动放行 |
|
||||
|
||||
### 2.2 本地编译
|
||||
|
||||
#### 2.2.1 编译后端
|
||||
|
||||
```powershell
|
||||
cd d:\Code\MyHomePage\backend
|
||||
dotnet publish -c Release -o ..\publish\backend
|
||||
```
|
||||
|
||||
产物:`publish\backend\`(约 80MB,含 .dll / appsettings.json / wwwroot/ 等)。
|
||||
|
||||
#### 2.2.2 编译前端
|
||||
|
||||
```powershell
|
||||
cd d:\Code\MyHomePage\frontend
|
||||
npm install # 首次或依赖变化时
|
||||
npm run build
|
||||
```
|
||||
|
||||
产物:`frontend\dist\`(约 400KB,含 index.html / assets/)。
|
||||
|
||||
### 2.3 上传产物到服务器
|
||||
|
||||
**方式 1:1Panel 文件管理(推荐,Web 拖拽)**
|
||||
- 1Panel → 文件 → 进入 `/opt/1panel/apps/`
|
||||
- 新建两个文件夹:`myhomepage-backend` / `myhomepage-frontend`
|
||||
- 分别拖入编译产物
|
||||
|
||||
**方式 2:scp 上传(打 zip 后传一次)**
|
||||
|
||||
```powershell
|
||||
cd d:\Code\MyHomePage
|
||||
Compress-Archive -Path publish\backend,frontend\dist -DestinationPath myhomepage.zip
|
||||
scp myhomepage.zip root@your-server:/opt/1panel/apps/
|
||||
```
|
||||
|
||||
服务器侧:
|
||||
|
||||
```bash
|
||||
cd /opt/1panel/apps/
|
||||
unzip myhomepage.zip
|
||||
mv publish/backend myhomepage-backend
|
||||
mv frontend/dist myhomepage-frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、第一步:1Panel 准备 MySQL
|
||||
|
||||
### 3.1 创建数据库
|
||||
|
||||
- 1Panel → 数据库 → MySQL → 你的实例 → **创建数据库**
|
||||
- 数据库名:`myhomepage`
|
||||
- 字符集:`utf8mb4`
|
||||
- 排序规则:`utf8mb4_unicode_ci`
|
||||
|
||||
### 3.2 创建用户并授权
|
||||
|
||||
- 1Panel → 数据库 → MySQL → 你的实例 → **创建用户**
|
||||
- 用户名:`myhomepage_user`
|
||||
- 密码:自定义或自动生成(**复制保存**!)
|
||||
- 主机:**`localhost`**(只允许本机连接,禁止公网)
|
||||
- 授权:库 `myhomepage`,权限 `ALL`
|
||||
|
||||
### 3.3 记录连接信息
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| host | `127.0.0.1` |
|
||||
| port | `3306` |
|
||||
| database | `myhomepage` |
|
||||
| user | `myhomepage_user` |
|
||||
| password | (你刚设的) |
|
||||
|
||||
---
|
||||
|
||||
## 四、第二步:部署后端
|
||||
|
||||
### 4.1 上传后端文件
|
||||
|
||||
把 `publish\backend\` 内**所有文件**传到 `/opt/1panel/apps/myhomepage-backend/`。
|
||||
|
||||
最终目录结构(参考):
|
||||
|
||||
```
|
||||
/opt/1panel/apps/myhomepage-backend/
|
||||
├── MyHomePage.Api.dll
|
||||
├── appsettings.json
|
||||
├── appsettings.Production.json
|
||||
├── web.config # 1Panel 运行环境会自动生成
|
||||
├── Uploads/ # 后面建
|
||||
├── wwwroot/ # 后端静态资源(如果有)
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 4.2 创建 .NET 网站
|
||||
|
||||
- 1Panel → **网站** → **创建网站**
|
||||
- 类型:选择「**运行环境**」标签 → 选 **.NET**(如果应用商店没装 .NET Runtime,先去应用商店搜 `.NET` 安装)
|
||||
- 主域名:填后端域名(如 `api.example.com`),或留空用 `IP:8080`
|
||||
- 端口:`8080`
|
||||
- 代号:`myhomepage-api`
|
||||
- 网站目录:`/opt/1panel/apps/myhomepage-backend`
|
||||
- 启动命令:`dotnet MyHomePage.Api.dll --urls http://0.0.0.0:8080`
|
||||
|
||||
### 4.3 配置环境变量
|
||||
|
||||
在创建好的网站详情页 → 「环境变量」或「配置文件」tab,添加:
|
||||
|
||||
| 变量名 | 值 | 说明 |
|
||||
|--------|-----|------|
|
||||
| `ASPNETCORE_ENVIRONMENT` | `Production` | 启用生产配置 |
|
||||
| `Database__Provider` | `MySql` | 切换到 MySQL |
|
||||
| `Database__ConnectionString` | `server=127.0.0.1;port=3306;database=myhomepage;user=myhomepage_user;password=你的密码;charset=utf8mb4;` | 连接串 |
|
||||
| `Upload__Path` | `/opt/1panel/apps/myhomepage-backend/Uploads` | 上传文件落盘路径 |
|
||||
| `Upload__BaseUrl` | `https://你的前端域名/uploads` | 前端拼接 URL 前缀 |
|
||||
| `Cors__Origins__0` | `https://你的前端域名` | 允许跨域的白名单(必须) |
|
||||
|
||||
> 双下划线 `__` 是 ASP.NET Core 配置嵌套的语法,等价于 JSON 里的 `{ "Cors": { "Origins": [ ... ] } }`。
|
||||
> 如果主人在前端用 `VITE_API_BASE=https://api.example.com` 直接走独立域名,跨域问题更彻底。
|
||||
|
||||
### 4.4 创建上传目录
|
||||
|
||||
- 1Panel → 文件 → 在 `/opt/1panel/apps/myhomepage-backend/` 下新建 `Uploads/`
|
||||
- 权限 755(默认即可)
|
||||
|
||||
### 4.5 启动后端
|
||||
|
||||
- 网站详情页 → **启动** 按钮
|
||||
- 等 5-10 秒 → **日志** tab 应该看到:
|
||||
```
|
||||
Now listening on: http://0.0.0.0:8080
|
||||
Application started.
|
||||
```
|
||||
|
||||
### 4.6 本地验证
|
||||
|
||||
```bash
|
||||
# 服务器侧
|
||||
curl http://127.0.0.1:8080/health
|
||||
# 期望:OK
|
||||
```
|
||||
|
||||
看到 200 = 后端已就绪 ✅
|
||||
|
||||
---
|
||||
|
||||
## 五、第三步:部署前端
|
||||
|
||||
### 5.1 上传前端文件
|
||||
|
||||
把 `frontend\dist\` 内**所有文件**传到 `/opt/1panel/apps/myhomepage-frontend/`。
|
||||
|
||||
### 5.2 创建静态网站
|
||||
|
||||
- 1Panel → **网站** → **创建网站**
|
||||
- 类型:选择「**静态网站**」标签
|
||||
- 主域名:填前端域名(如 `home.example.com`)
|
||||
- 端口:`80`(HTTP)或 `443`(HTTPS)
|
||||
- 代号:`myhomepage-web`
|
||||
- 网站目录:`/opt/1panel/apps/myhomepage-frontend`
|
||||
|
||||
### 5.3 申请 SSL(强烈推荐)
|
||||
|
||||
- 网站详情页 → **SSL** → **申请** → Let's Encrypt
|
||||
- 1Panel 自动续期
|
||||
|
||||
### 5.4 配置反向代理(关键!)
|
||||
|
||||
网站详情页 → **反向代理** 或直接编辑 **配置文件**(Nginx),加这段:
|
||||
|
||||
```nginx
|
||||
# /api/* 转发到后端
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# /uploads/* 转发到后端(用户上传图片)
|
||||
location /uploads/ {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
}
|
||||
```
|
||||
|
||||
> 如果前端 `.env.production` 配了 `VITE_API_BASE=https://api.example.com`,**跳过本节**(前后端走不同域名更干净)。
|
||||
|
||||
### 5.5 验证前端
|
||||
|
||||
浏览器打开 `https://你的前端域名/` → 看到首页 = 成功 ✅
|
||||
|
||||
---
|
||||
|
||||
## 六、第四步:联调验证
|
||||
|
||||
### 6.1 必查清单
|
||||
|
||||
- [ ] 浏览器打开前端 → 首页正常渲染
|
||||
- [ ] DevTools → Network → 5 个 API 请求(settings / categories / bookmarks / search-engines / wallpaper)**全部 200**
|
||||
- [ ] 新建一个分类 + 一个链接 → 刷新页面看是否持久化
|
||||
- [ ] 切换主题 / 主色调 → 刷新看是否持久化
|
||||
- [ ] 上传一张图片 → 看是否显示
|
||||
- [ ] 360 壁纸功能(在设置里启用)→ 切换间隔生效
|
||||
|
||||
### 6.2 Android APP 后端地址
|
||||
|
||||
主人后续会打包 Android。`.env.production` 或 Capacitor 配置里填:
|
||||
|
||||
```
|
||||
# 同域名反代
|
||||
https://你的前端域名/api
|
||||
|
||||
# 或独立后端域名
|
||||
https://api.example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、日常运维
|
||||
|
||||
### 7.1 看日志
|
||||
|
||||
- 1Panel → 网站 → 你的网站 → **日志** tab
|
||||
- 实时滚动 / 可下载 .log 文件
|
||||
- 排错先看这里 ✅
|
||||
|
||||
### 7.2 重启服务
|
||||
|
||||
- 1Panel → 网站 → 你的网站 → **重启** 按钮
|
||||
- 5-10 秒生效
|
||||
|
||||
### 7.3 备份数据库
|
||||
|
||||
- 1Panel → 数据库 → MySQL → 你的实例 → **备份**
|
||||
- 1Panel 默认每天自动备份到本地目录
|
||||
- 也可「立即备份」→ 下载到本地双保险
|
||||
|
||||
### 7.4 更新后端
|
||||
|
||||
```powershell
|
||||
# 本地
|
||||
cd d:\Code\MyHomePage\backend
|
||||
dotnet publish -c Release -o ..\publish\backend
|
||||
```
|
||||
|
||||
- 1Panel → 文件 → `/opt/1panel/apps/myhomepage-backend/` → 覆盖上传所有文件
|
||||
- 1Panel → 网站 → myhomepage-api → **重启**
|
||||
|
||||
### 7.5 更新前端
|
||||
|
||||
```powershell
|
||||
# 本地
|
||||
cd d:\Code\MyHomePage\frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
- 1Panel → 文件 → `/opt/1panel/apps/myhomepage-frontend/` → 覆盖上传所有文件
|
||||
- 静态网站无需重启,浏览器 Ctrl+F5 强刷即可
|
||||
|
||||
### 7.6 紧急回滚
|
||||
|
||||
| 想回滚 | 操作 |
|
||||
|--------|------|
|
||||
| 后端代码 | 1Panel → 文件 → 把后端目录回退到上一版 → 重启 |
|
||||
| 前端代码 | 1Panel → 文件 → 把前端目录回退到上一版 → 强刷 |
|
||||
| 数据库 | 1Panel → 数据库 → 选昨天的备份 → 恢复(**会覆盖当前数据**) |
|
||||
|
||||
---
|
||||
|
||||
## 八、常见问题
|
||||
|
||||
### Q1:前端打开是白屏
|
||||
- 1Panel → 网站 → 静态网站 → 日志查 404
|
||||
- 大概率是 `index.html` 没传,或 `assets/` 目录没传全
|
||||
|
||||
### Q2:API 全部 404
|
||||
- 检查第五步 5.4 的反向代理是否生效
|
||||
- 服务器本地 `curl http://127.0.0.1:8080/health` 验证后端活着
|
||||
|
||||
### Q3:CORS 跨域报错
|
||||
- 检查第四步 4.3 的 `Cors__Origins__0` 是否设了前端完整域名(含 `https://`)
|
||||
- 重启后端
|
||||
|
||||
### Q4:数据库连接失败
|
||||
- 1Panel → 数据库 → MySQL → 看实例 running
|
||||
- 服务器本地 `mysql -u myhomepage_user -p` 验证能登
|
||||
- 检查连接串的 `password=` 是不是带特殊字符(需要 URL encode)
|
||||
|
||||
### Q5:上传图片 500
|
||||
- 检查 `/opt/1panel/apps/myhomepage-backend/Uploads/` 权限 755
|
||||
- 服务器 `df -h` 看磁盘
|
||||
|
||||
### Q6:移动端 / Android APP 访问不到
|
||||
- 1Panel 防火墙放行 8080(如果不走反代)
|
||||
- 或在 Android 里直接填 `https://你的前端域名/api`(走反代最稳)
|
||||
|
||||
### Q7:360 壁纸不显示
|
||||
- 后端环境变量里 `Upload__BaseUrl` 和 `Cors__Origins__0` 都填前端完整域名
|
||||
- 服务器能访问外网(curl `https://wallpaper.apc.360.cn` 验证)
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:环境变量速查表
|
||||
|
||||
| 变量 | 必填 | 示例值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `ASPNETCORE_ENVIRONMENT` | 是 | `Production` | 启用生产配置 |
|
||||
| `Database__Provider` | 是 | `MySql` | 固定值 |
|
||||
| `Database__ConnectionString` | 是 | `server=127.0.0.1;port=3306;database=myhomepage;user=myhomepage_user;password=xxx;charset=utf8mb4;` | MySQL 连接串 |
|
||||
| `Upload__Path` | 是 | `/opt/1panel/apps/myhomepage-backend/Uploads` | 上传落盘绝对路径 |
|
||||
| `Upload__BaseUrl` | 是 | `https://home.example.com/uploads` | 前端拼接 URL 前缀 |
|
||||
| `Cors__Origins__0` | 是 | `https://home.example.com` | 跨域白名单(多域名时加 `Cors__Origins__1`) |
|
||||
|
||||
## 附录 B:目录结构速查
|
||||
|
||||
```
|
||||
/opt/1panel/apps/
|
||||
├── myhomepage-backend/
|
||||
│ ├── MyHomePage.Api.dll
|
||||
│ ├── appsettings.json
|
||||
│ ├── appsettings.Production.json
|
||||
│ ├── Uploads/ # 用户上传
|
||||
│ └── ...
|
||||
└── myhomepage-frontend/
|
||||
├── index.html
|
||||
├── assets/
|
||||
│ ├── index-xxxxxx.js
|
||||
│ └── index-xxxxxx.css
|
||||
└── ...
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
# 开发环境:使用 vite proxy(/api -> http://localhost:5080)
|
||||
# 如果需要直连后端,把空值改为 http://localhost:5080
|
||||
VITE_API_BASE=
|
||||
@@ -0,0 +1,3 @@
|
||||
# Capacitor Android APP 生产环境后端地址
|
||||
# APP 内不能使用 vite proxy,必须填真实后端 URL(带 https:// 或 http://)
|
||||
VITE_API_BASE=http://10.0.2.2:5080
|
||||
@@ -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
|
||||
@@ -0,0 +1,69 @@
|
||||
# Capacitor Android 打包
|
||||
|
||||
## 前置环境
|
||||
|
||||
1. **Node.js** ≥ 18(项目用 22)
|
||||
2. **JDK 17**(`java -version` 可验证)
|
||||
3. **Android Studio** + Android SDK(API 34)
|
||||
4. 配好 `ANDROID_HOME` 或 `ANDROID_SDK_ROOT` 环境变量
|
||||
|
||||
## 一次性配置
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
|
||||
# 构建前端静态资源到 dist/
|
||||
npm run build
|
||||
|
||||
# 初始化 Capacitor + 创建 android 壳工程
|
||||
npx cap init cn.myhomepage.app MyHomePage --web-dir=dist
|
||||
npx cap add android
|
||||
```
|
||||
|
||||
## 每次更新
|
||||
|
||||
```bash
|
||||
# 1. 改完前端代码
|
||||
npm run build
|
||||
|
||||
# 2. 同步到 android 工程
|
||||
npx cap sync android
|
||||
|
||||
# 3. 在 Android Studio 中打开并打包
|
||||
npx cap open android
|
||||
# 或命令行:
|
||||
cd android
|
||||
./gradlew assembleDebug # 调试 APK → android/app/build/outputs/apk/debug/
|
||||
./gradlew assembleRelease # 发布 APK(需先在 build.gradle 配签名)
|
||||
```
|
||||
|
||||
## APP 内后端地址
|
||||
|
||||
APP 内不能使用 vite proxy,必须指向真实后端:
|
||||
|
||||
修改 `frontend/.env.production`:
|
||||
```
|
||||
VITE_API_BASE=http://10.0.2.2:5080 # Android 模拟器访问宿主机
|
||||
# 或:
|
||||
VITE_API_BASE=https://your-domain.com
|
||||
```
|
||||
|
||||
然后 `npm run build` 重新构建。
|
||||
|
||||
## 网络权限
|
||||
|
||||
首次 `npx cap add android` 后,在 `android/app/src/main/AndroidManifest.xml`
|
||||
的 `<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` 保持一致,建议反代
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
Generated
+3007
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"apiBase": ""
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }>();
|
||||
|
||||
// P31:iconType='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>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AppLinkListItem:移动端 / 窄屏的链接列表项。
|
||||
* P28 升级:
|
||||
* - logo 块变**正方形**(边长 = 高度),与 AppLinkCard 视觉对齐
|
||||
* - 显示 AppIcon(lucide / emoji / image / 名称前 2 字符兜底)
|
||||
* - 背景色三级 fallback:user.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 }>();
|
||||
|
||||
// P31:iconType='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 视觉一致,体积更紧凑) */
|
||||
/* P36:align-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>
|
||||
@@ -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
Reference in New Issue
Block a user