初始提交:浏览器首页 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
+144
View File
@@ -0,0 +1,144 @@
using Microsoft.Extensions.Options;
using MyHomePage.Api.Common;
using MyHomePage.Api.Infrastructure.Configuration;
using MyHomePage.Api.Models.Dtos;
namespace MyHomePage.Api.Services;
/// <inheritdoc />
public class UploadService : IUploadService
{
/// <summary>允许的图片扩展名白名单(与 SaveAsync 共用)</summary>
private static readonly string[] AllowedExtensions = { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico" };
/// <summary>Content-Type 到扩展名的兜底映射</summary>
private static readonly Dictionary<string, string> ContentTypeToExt = new(StringComparer.OrdinalIgnoreCase)
{
["image/png"] = ".png",
["image/jpeg"] = ".jpg",
["image/jpg"] = ".jpg",
["image/gif"] = ".gif",
["image/webp"] = ".webp",
["image/svg+xml"] = ".svg",
["image/x-icon"] = ".ico",
["image/vnd.microsoft.icon"] = ".ico",
["image/ico"] = ".ico",
};
private readonly UploadOptions _options;
private readonly IWebHostEnvironment _env;
private readonly ILogger<UploadService> _logger;
/// <summary>P51 诊断:是否已记录过 upload root(避免每次保存重复 log</summary>
private bool _rootLogged;
public UploadService(
IOptions<UploadOptions> options,
IWebHostEnvironment env,
ILogger<UploadService> logger)
{
_options = options.Value;
_env = env;
_logger = logger;
}
/// <inheritdoc />
public string EnsureRoot()
{
var root = Path.IsPathRooted(_options.Path)
? _options.Path
: Path.Combine(_env.ContentRootPath, _options.Path);
Directory.CreateDirectory(root);
// P51 诊断:第一次调用时 log 一次实际 root(暴露容器内 /uploads 路径覆盖问题)
if (!_rootLogged)
{
_rootLogged = true;
_logger.LogInformation(
"Upload root resolved: Path='{Path}' (IsRooted={IsRooted}) → Actual='{Actual}' | env={Env}",
_options.Path, Path.IsPathRooted(_options.Path), root, _env.EnvironmentName);
}
return root;
}
/// <summary>P51 诊断兼容:保留旧方法名(Program.cs 启动期若想强制 log 可调)</summary>
public string EnsureRootWithLog() => EnsureRoot();
/// <inheritdoc />
public async Task<UploadResultDto> SaveAsync(IFormFile file)
{
if (file is null || file.Length == 0) throw new BusinessException("文件为空", 400);
if (file.Length > _options.MaxSizeBytes) throw new BusinessException($"文件大小超过限制({_options.MaxSizeBytes / 1024 / 1024}MB", 400);
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(ext)) throw new BusinessException("文件必须包含扩展名", 400);
if (Array.IndexOf(AllowedExtensions, ext) < 0) throw new BusinessException("仅支持图片格式", 400);
await using var stream = file.OpenReadStream();
return await SaveStreamInternalAsync(stream, file.FileName, file.ContentType, ext, subDir: null);
}
/// <inheritdoc />
public async Task<UploadResultDto> SaveStreamAsync(Stream stream, string fileName, string contentType, string? subDir = null)
{
if (stream is null) throw new BusinessException("数据流为空", 400);
// 推断扩展名:优先文件名 → 兜底 content-type
var ext = Path.GetExtension(fileName).ToLowerInvariant();
if (string.IsNullOrEmpty(ext))
{
if (string.IsNullOrEmpty(contentType)) throw new BusinessException("无法推断文件扩展名", 400);
if (!ContentTypeToExt.TryGetValue(contentType, out ext))
throw new BusinessException($"不支持的内容类型:{contentType}", 400);
}
if (Array.IndexOf(AllowedExtensions, ext) < 0) throw new BusinessException("仅支持图片格式", 400);
return await SaveStreamInternalAsync(stream, fileName, contentType, ext, subDir);
}
/// <summary>实际写文件的内部流程(SaveAsync / SaveStreamAsync 共用)</summary>
private async Task<UploadResultDto> SaveStreamInternalAsync(Stream stream, string fileName, string contentType, string ext, string? subDir)
{
var root = EnsureRoot();
// 按日期分目录:2026/07/04[/favicons]
var datePath = DateTime.UtcNow.ToString("yyyy/MM/dd");
var relativeDir = string.IsNullOrEmpty(subDir) ? datePath : Path.Combine(datePath, subDir);
var dir = Path.Combine(root, relativeDir);
var name = $"{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}_{Guid.NewGuid():N}{ext}";
var fullPath = Path.Combine(dir, name);
// P51 诊断:把 IO 异常(容器权限/路径/磁盘满)原样抛出 + 完整上下文日志
try
{
Directory.CreateDirectory(dir);
await using (var fs = new FileStream(fullPath, FileMode.CreateNew, FileAccess.Write, FileShare.None))
{
await stream.CopyToAsync(fs);
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"Upload save failed: file={File}, contentType={ContentType}, ext={Ext}, subDir={SubDir}, " +
"UploadOptions.Path={OptPath} (IsRooted={IsRooted}), ContentRoot={ContentRoot}, env={Env}, " +
"computed root={Root}, dir={Dir}, fullPath={FullPath}",
fileName, contentType, ext, subDir,
_options.Path, Path.IsPathRooted(_options.Path), _env.ContentRootPath, _env.EnvironmentName,
root, dir, fullPath);
throw new BusinessException($"文件保存失败: {ex.GetType().Name}: {ex.Message}", 500);
}
var relative = Path.Combine(relativeDir, name).Replace('\\', '/');
var url = (_options.BaseUrl ?? "/uploads").TrimEnd('/') + "/" + relative;
_logger.LogInformation("Upload saved: {Path} ({ContentType})", fullPath, contentType);
return new UploadResultDto
{
Path = relative,
Url = url,
FileName = fileName,
Size = new FileInfo(fullPath).Length
};
}
}