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

# 项目概述
个人浏览器首页导航应用,支持书签分类管理、搜索引擎快捷搜索、
必应每日壁纸轮播、前后端分离部署,适配 1Panel 服务器(Docker 模式)。

# 技术栈
- 前端:Vue 3 + TypeScript + Vite + Pinia + Capacitor(Android 打包)
- 后端:.NET 8 + SqlSugar(多数据库) + SQLite/MySQL + Swashbuckle
- 部署:1Panel 应用商店自定义应用(Docker Compose 模式)

# 项目结构
- backend/    .NET 8 API 后端(8 个 Controller + 15 个 Service)
- frontend/   Vue 3 前端(19 个组件 + 9 个 API 模块 + 5 个 Store)
- docker/     Docker 部署文件(后端镜像 + Nginx 反代)
- docs/       部署手册(1Panel 实战版)
- scripts/    E2E 测试脚本

# 已实现功能
- 书签管理:增删改查 + 树形分类 + 拖拽排序 + 主色自适应
- 搜索引擎:8 个内置引擎 + 自定义引擎 + favicon 自动抓取
- 必应壁纸:每日轮播 + 多分辨率自动选择 + 1.6MP 质量优先
- 全局设置:主题/行为/数据/工具 4 分类 + 跨设备同步
- 文件上传:图标/书签/通用(容器持久化 + 跨域 URL 拼接)
- 同步:基于变更日志的设备间数据同步
- 跨域部署:前后端分离 + runtime config.json 无需重新编译

# 进度记录
- 已完成 P0~P52 共 53 个开发节点(详细见 说明文档.md)
- 当前版本:v1.0 部署就绪

# 部署文档
- README.md:项目说明 + 快速开始
- 说明文档.md:完整开发进度(中文)
- docs/DEPLOY.md:1Panel 部署手册(Docker 模式)
This commit is contained in:
2026-07-05 05:09:56 +08:00
commit 68be41e7a2
129 changed files with 15900 additions and 0 deletions
+383
View File
@@ -0,0 +1,383 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Caching.Memory;
using MyHomePage.Api.Common;
using MyHomePage.Api.Models.Dtos;
namespace MyHomePage.Api.Services;
/// <summary>
/// 360 在线壁纸代理服务(P34 + P34.1 修正)。
///
/// 数据来源(参考 360 chrome 壁纸公开接口):
/// 1. 全部分类列表(含 order_num 排序字段)
/// http://cdn.apc.360.cn/index.php?c=WallPaper&amp;a=getAllCategoriesV2&amp;from=360chrome
/// 2. 按分类 ID 获取图片列表(每条 data 含 url + 6 个预设分辨率 + 原始分辨率)
/// http://wallpaper.apc.360.cn/index.php?c=WallPaper&amp;a=getAppsByCategory
/// &amp;cid={cid}&amp;start=0&amp;count=200&amp;from=360chrome
///
/// P34.1 修正(主人反馈 360 接口实际返回内容):
/// - 分类数据有 order_num 字段(110/100/99/.../9),应按 order_num 降序展示(不再是字母排序)
/// - 列表里 img_1600_900 / img_1440_900 / img_1366_768 / img_1280_800 / img_1280_1024 / img_1024_768
/// 是 360 官方为每张图准备的预设分辨率 URL,**优先用这些**(CDN 必定存在,画质 85)
/// - 视口分辨率若不匹配任何 preset,兜底走 RewriteUrl(quality=85) 自构 bdm/{W}_{H}_85
/// - 原图 url 字段用 bdr/__85/...**不直接使用**(那是 bdr 压缩档,画质低)
///
/// 后端缓存策略(避免每次前端访问都打 360 接口):
/// - 分类列表:启动拉一次,缓存 24h
/// - 图片池(每个 cid):缓存 200 张,TTL 12h
/// - 立即刷新:手动调 RefreshAsync 清缓存重新拉,并立即返回一张随机图
/// </summary>
public class WallpaperService
{
private readonly IHttpClientFactory _httpFactory;
private readonly IMemoryCache _cache;
private readonly ILogger<WallpaperService> _logger;
private const string HttpClientName = nameof(WallpaperService);
private const string UserAgent =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
/// <summary>分类列表缓存 TTL24h,启动再热一次)</summary>
private static readonly TimeSpan CategoryTtl = TimeSpan.FromHours(24);
/// <summary>图片池缓存 TTL12h</summary>
private static readonly TimeSpan PoolTtl = TimeSpan.FromHours(12);
/// <summary>每次拉取池子的最大张数(主人决策:200 张池子)</summary>
private const int PoolCount = 200;
/// <summary>分类列表 URL</summary>
private const string CategoryUrl =
"http://cdn.apc.360.cn/index.php?c=WallPaper&a=getAllCategoriesV2&from=360chrome";
/// <summary>分类图片 URL 模板({0}=cid, {1}=start, {2}=count</summary>
private const string AppsByCategoryUrlTemplate =
"http://wallpaper.apc.360.cn/index.php?c=WallPaper&a=getAppsByCategory&cid={0}&start={1}&count={2}&from=360chrome";
/// <summary>画质固定 85(与 360 官方预设 img_*_85 一致,主人截图原始数据证实)</summary>
private const int DefaultQuality = 85;
/// <summary>
/// 360 官方为每张壁纸预设的固定分辨率 + 画质 85(P34.1 主人反馈真实接口结构)。
/// 视口尺寸进来后 → 在这 6 个 preset 里挑"宽高比例最接近且单边≥视口"的命中即用。
/// </summary>
private static readonly (int W, int H)[] PresetResolutions =
{
(1600, 900),
(1440, 900),
(1366, 768),
(1280, 800),
(1280, 1024),
(1024, 768)
};
public WallpaperService(
IHttpClientFactory httpFactory,
IMemoryCache cache,
ILogger<WallpaperService> logger)
{
_httpFactory = httpFactory;
_cache = cache;
_logger = logger;
}
/// <summary>每次调用前从 factory 取一个新 HttpClient(短生命周期,由 factory 池化)</summary>
private HttpClient NewClient() => _httpFactory.CreateClient(HttpClientName);
// =====================================================================
// 公开 APIController 调用)
// =====================================================================
/// <summary>获取全部分类列表(24h 缓存)。失败时返回空集合(不抛)。</summary>
public async Task<List<WallpaperCategoryDto>> GetCategoriesAsync(CancellationToken ct = default)
{
const string cacheKey = "wallpaper:categories";
if (_cache.TryGetValue<List<WallpaperCategoryDto>>(cacheKey, out var cached) && cached is not null)
{
_logger.LogDebug("Wallpaper categories cache hit: {Count}", cached.Count);
return cached;
}
try
{
using var client = NewClient();
using var req = new HttpRequestMessage(HttpMethod.Get, CategoryUrl);
req.Headers.Add("User-Agent", UserAgent);
req.Headers.Add("Referer", "http://chrome.360.cn/");
using var resp = await client.SendAsync(req, ct);
if (!resp.IsSuccessStatusCode)
{
_logger.LogWarning("Wallpaper categories fetch failed: HTTP {Status}", (int)resp.StatusCode);
return new List<WallpaperCategoryDto>();
}
var json = await resp.Content.ReadAsStringAsync(ct);
// P34.1:按 360 官方 order_num 字段降序(之前是字母排序,错的)
var list = ParseCategoryJson(json);
_cache.Set(cacheKey, list, CategoryTtl);
_logger.LogInformation("Wallpaper categories fetched: {Count}", list.Count);
return list;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Wallpaper categories fetch error");
return new List<WallpaperCategoryDto>();
}
}
/// <summary>
/// 取一张随机壁纸 URL(按视口分辨率选最接近的 preset,无命中走 RewriteUrl 兜底)。
/// </summary>
/// <param name="cid">分类 ID(空字符串 = 全部/推荐)</param>
/// <param name="w">视口宽度(px</param>
/// <param name="h">视口高度(px</param>
/// <param name="ct">取消令牌</param>
public async Task<WallpaperRandomDto?> GetRandomAsync(string cid, int w, int h, CancellationToken ct = default)
{
var pool = await GetOrFetchPoolAsync(cid ?? "", ct);
if (pool.Count == 0)
{
_logger.LogWarning("Wallpaper pool empty for cid={Cid}", cid);
return null;
}
return BuildRandom(pool, w, h);
}
/// <summary>强制刷新图片池(立即切换按钮使用),并立即返回一张随机图。</summary>
public async Task<WallpaperRandomDto?> RefreshAsync(string cid, int w, int h, CancellationToken ct = default)
{
cid ??= "";
_cache.Remove(PoolKey(cid));
_logger.LogInformation("Wallpaper pool refreshed: cid={Cid}", string.IsNullOrEmpty(cid) ? "(all)" : cid);
var pool = await GetOrFetchPoolAsync(cid, ct);
if (pool.Count == 0) return null;
return BuildRandom(pool, w, h);
}
// =====================================================================
// 池子管理
// =====================================================================
private string PoolKey(string cid) => $"wallpaper:pool:{cid}";
/// <summary>获取分类的图片池(缓存 12h)。无池时主动拉。</summary>
private async Task<List<PoolItem>> GetOrFetchPoolAsync(string cid, CancellationToken ct)
{
var key = PoolKey(cid);
if (_cache.TryGetValue<List<PoolItem>>(key, out var cached) && cached is not null && cached.Count > 0)
return cached;
var fetched = await FetchPoolFrom360Async(cid, ct);
if (fetched.Count > 0)
_cache.Set(key, fetched, PoolTtl);
return fetched;
}
/// <summary>实际请求 360 接口拿 200 张 PoolItem 列表(含 6 个预设分辨率 URL</summary>
private async Task<List<PoolItem>> FetchPoolFrom360Async(string cid, CancellationToken ct)
{
try
{
using var client = NewClient();
// 360 接口 cid 空字符串会返回空,这里用一个常见的「推荐」分类(36 = 4K专区)兜底
var effectiveCid = string.IsNullOrEmpty(cid) ? "36" : cid;
var url = string.Format(AppsByCategoryUrlTemplate, effectiveCid, 0, PoolCount);
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Add("User-Agent", UserAgent);
req.Headers.Add("Referer", "http://chrome.360.cn/");
using var resp = await client.SendAsync(req, ct);
if (!resp.IsSuccessStatusCode)
{
_logger.LogWarning("Wallpaper pool fetch failed: cid={Cid} HTTP {Status}", effectiveCid, (int)resp.StatusCode);
return new List<PoolItem>();
}
var json = await resp.Content.ReadAsStringAsync(ct);
var items = ParseAppsJson(json);
_logger.LogInformation("Wallpaper pool fetched: cid={Cid} count={Count}", effectiveCid, items.Count);
return items;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Wallpaper pool fetch error: cid={Cid}", cid);
return new List<PoolItem>();
}
}
/// <summary>从池中随机选 1 张 → 用 PickBestUrl 选最佳 URL → 构造 DTO</summary>
private static WallpaperRandomDto BuildRandom(List<PoolItem> pool, int w, int h)
{
if (pool.Count == 0) return null!;
var pick = pool[Random.Shared.Next(pool.Count)];
var (finalUrl, usedPreset, fallback) = PickBestUrl(pick, w, h);
return new WallpaperRandomDto
{
Url = finalUrl,
OriginalUrl = pick.Url,
Width = w,
Height = h,
// 扩展信息:P34.1 调试用,前端可忽略
Preset = usedPreset is null ? null : $"{usedPreset.Value.W}x{usedPreset.Value.H}",
UsedFallback = fallback
};
}
/// <summary>
/// 为给定 PoolItem 选最合适的 URL。
/// 选法:先按"宽高比 (aspect) 差最小"在 6 个 preset 里挑(要求 aspect 差 &lt; 0.15),
/// 比例匹配的候选里再按"单边最接近视口"选最佳。
/// 没有任何 preset 比例匹配 → 走 RewriteUrl(quality=85) 自构 bdm/{W}_{H}_85 兜底(避免 preset 5:4 拉伸到 9:16 视口的变形)。
/// </summary>
private static (string url, (int W, int H)? preset, bool fallback) PickBestUrl(PoolItem item, int w, int h)
{
if (item.Presets.Count > 0)
{
double targetAspect = (double)w / Math.Max(1, h);
const double AspectTolerance = 0.15; // 比例差阈值(绝对值):超过即视为比例不匹配,走兜底
(int W, int H)? best = null;
double bestAspectDelta = double.MaxValue;
foreach (var preset in PresetResolutions)
{
if (!item.Presets.ContainsKey(preset)) continue;
double presetAspect = (double)preset.W / Math.Max(1, preset.H);
double aspectDelta = Math.Abs(presetAspect - targetAspect);
if (aspectDelta >= AspectTolerance) continue; // 比例不匹配,跳过
// 在比例匹配的 preset 里选 aspect 最接近的
if (aspectDelta < bestAspectDelta) { bestAspectDelta = aspectDelta; best = preset; }
}
if (best is not null && item.Presets.TryGetValue(best.Value, out var presetUrl))
{
return (presetUrl, best, false);
}
}
// 兜底:RewriteUrl(quality=85) 自构
var fallbackUrl = RewriteUrl(item.Url, w, h, DefaultQuality);
return (fallbackUrl, null, true);
}
/// <summary>
/// 把 360 原始 URL 改造成指定分辨率/画质。
/// 例如:http://p8.qhimg.com/bdr/__85/t01e5f605262fb61fb4.jpg
/// → http://p8.qhimg.com/bdm/1920_1080_85/t01e5f605262fb61fb4.jpg
/// 只改路径段 bdr/__85 → bdm/{W}_{H}_{Q},主机保持原样(360 CDN p3-p19 等节点都支持 bdm 路径)。
/// </summary>
private static string RewriteUrl(string original, int w, int h, int quality)
{
if (string.IsNullOrEmpty(original)) return original;
return Regex.Replace(original, @"/bdr/__85/", $"/bdm/{w}_{h}_{quality}/");
}
// =====================================================================
// JSON 解析
// =====================================================================
/// <summary>
/// 解析分类 JSON{ errno, errmsg, total, data: [{ id, name, order_num, tag, create_time }] }。
/// P34.1 修正:直接按 order_num int 降序排(不再字母排序),保留 18 个原始顺序。
/// </summary>
private static List<WallpaperCategoryDto> ParseCategoryJson(string json)
{
var result = new List<WallpaperCategoryDto>();
if (string.IsNullOrEmpty(json)) return result;
try
{
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("data", out var dataEl) || dataEl.ValueKind != JsonValueKind.Array)
return result;
foreach (var item in dataEl.EnumerateArray())
{
string id = item.TryGetProperty("id", out var idEl) ? idEl.ToString() : "";
string name = item.TryGetProperty("name", out var nameEl) ? nameEl.ToString() : "";
// order_num 是字符串(接口实际为 "110"),用 TryGetInt32 + TryGetString 兼容
int orderNum = 0;
if (item.TryGetProperty("order_num", out var orderEl))
{
if (orderEl.ValueKind == JsonValueKind.Number && orderEl.TryGetInt32(out var n)) orderNum = n;
else if (int.TryParse(orderEl.ToString(), out var s)) orderNum = s;
}
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(name))
{
result.Add(new WallpaperCategoryDto
{
Id = id,
Name = name,
OrderNum = orderNum
});
}
}
// P34.1:按 360 官方 order_num 降序(4K 专区 110 → 文字控 9)
result = result.OrderByDescending(c => c.OrderNum).ToList();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[Wallpaper] category parse error: {ex.Message}");
}
return result;
}
/// <summary>
/// 解析分类图片 JSON:每条 data 含 url + 6 个 img_xxx_xxx 预设分辨率。
/// P34.1 修正:不再只读 url,而是同时读 img_1600_900 / img_1440_900 / img_1366_768 / img_1280_800 / img_1280_1024 / img_1024_768。
/// </summary>
private static List<PoolItem> ParseAppsJson(string json)
{
var result = new List<PoolItem>();
if (string.IsNullOrEmpty(json)) return result;
try
{
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("data", out var dataEl) || dataEl.ValueKind != JsonValueKind.Array)
return result;
// 预设字段名列表
string[] presetKeys =
{
"img_1600_900", "img_1440_900", "img_1366_768",
"img_1280_800", "img_1280_1024", "img_1024_768"
};
foreach (var item in dataEl.EnumerateArray())
{
// 原始 url(兜底用)
string url = item.TryGetProperty("url", out var urlEl) ? urlEl.ToString() : "";
if (string.IsNullOrEmpty(url)) continue;
var presets = new Dictionary<(int, int), string>();
foreach (var key in presetKeys)
{
if (!item.TryGetProperty(key, out var valEl)) continue;
var val = valEl.ToString();
if (string.IsNullOrEmpty(val)) continue;
// key 形如 "img_1600_900"
var parts = key.Substring(4).Split('_'); // ["1600","900"]
if (parts.Length != 2) continue;
if (!int.TryParse(parts[0], out var w)) continue;
if (!int.TryParse(parts[1], out var h)) continue;
presets[(w, h)] = val;
}
if (presets.Count == 0) continue; // 没有任何 preset → 跳过(不要单 url 兜底条目,避免池子里混入"无 preset"项)
result.Add(new PoolItem { Url = url, Presets = presets });
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[Wallpaper] apps parse error: {ex.Message}");
}
return result;
}
// =====================================================================
// 内部类型
// =====================================================================
/// <summary>池子里的单条记录:原始 url + 6 个预设分辨率 URL 字典</summary>
private sealed class PoolItem
{
/// <summary>原始 bdr/__85 url(兜底用)</summary>
public string Url { get; set; } = string.Empty;
/// <summary>预设分辨率 → URL(例:{(1600,900): "http://.../bdm/1600_900_85/..."}</summary>
public Dictionary<(int W, int H), string> Presets { get; set; } = new();
}
}