Files
g82tt 68be41e7a2 初始提交:浏览器首页 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 模式)
2026-07-05 05:09:56 +08:00

192 lines
7.3 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}