feat: 新增文件库功能,支持局域网文件浏览与媒体播放

后端:
- 新增 ManagedLibraryRoot / ManagedFileRecord 数据模型及 SQLite 迁移
- 新增文件库服务、端点服务及定时扫描后台任务
- 新增 REST API: drives、directories、roots CRUD、files 分页搜索、文本预览
- 新增文件流端点支持视频/音频流式传输
- 数据库切换为 SQLite,Kestrel 绑定 0.0.0.0 支持局域网访问

前端:
- 管理端:磁盘浏览、目录选择、根目录添加/启用/删除/扫描
- 客户端:根目录选择、文件搜索/筛选/分页、音视频播放、文本预览
- 全新响应式 UI(桌面+移动端),CSS 变量设计系统
- HTTP 客户端支持 Vite 开发代理与生产同源自动切换
- 移除 HTTPS 强制重定向以提升移动端视频流兼容性
This commit is contained in:
2026-05-21 16:45:56 +08:00
parent e3fe965f10
commit a68bb6c4b3
31 changed files with 3162 additions and 85 deletions
@@ -0,0 +1,64 @@
using System.Text.Json.Serialization;
namespace Avalonia_Services.Services.FileLibrary
{
public sealed record AddLibraryRootRequest(
[property: JsonPropertyName("path")] string? Path,
[property: JsonPropertyName("displayName")] string? DisplayName = null,
[property: JsonPropertyName("scanIntervalMinutes")] int? ScanIntervalMinutes = null);
public sealed record UpdateLibraryRootRequest(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("isEnabled")] bool IsEnabled);
public sealed record ScanLibraryRootRequest(
[property: JsonPropertyName("id")] int Id);
public sealed record DeleteLibraryRootRequest(
[property: JsonPropertyName("id")] int Id);
public sealed record DriveDto(
string Name,
string DisplayName,
string RootDirectory,
string DriveType,
long? TotalSize,
long? AvailableFreeSpace,
bool IsReady);
public sealed record DirectoryDto(
string Name,
string FullPath);
public sealed record LibraryRootDto(
int Id,
string Path,
string DisplayName,
bool IsEnabled,
bool IsAvailable,
int ScanIntervalMinutes,
DateTime? LastScanStartedAt,
DateTime? LastScanCompletedAt,
string? LastScanError,
int FileCount);
public sealed record FileRecordDto(
int Id,
int LibraryRootId,
string FileName,
string RelativePath,
string Extension,
long SizeBytes,
DateTime LastWriteTimeUtc,
string MediaType,
string ContentType,
string StreamUrl,
string? TextUrl,
bool BrowserPlayable);
public sealed record TextPreviewDto(
int Id,
string FileName,
string Content,
bool Truncated);
}
@@ -0,0 +1,99 @@
using Avalonia_Common.Core;
using Avalonia_Services.Core;
using System.Text.Json;
namespace Avalonia_Services.Services.FileLibrary
{
public sealed class FileLibraryEndpointService(IFileLibraryService fileLibrary) : IFileLibraryEndpointService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public async Task<object?> GetDrivesAsync(ServiceEndpointContext ctx)
{
return ResponseHelper.Ok(await fileLibrary.GetDrivesAsync());
}
public async Task<object?> GetDirectoriesAsync(ServiceEndpointContext ctx)
{
var path = ctx.Query.GetValueOrDefault("path");
return ResponseHelper.Ok(await fileLibrary.GetDirectoriesAsync(path));
}
public async Task<object?> GetRootsAsync(ServiceEndpointContext ctx)
{
return ResponseHelper.Ok(await fileLibrary.GetRootsAsync());
}
public async Task<object?> AddRootAsync(ServiceEndpointContext ctx)
{
var request = ReadBody<AddLibraryRootRequest>(ctx);
return ResponseHelper.Ok(await fileLibrary.AddRootAsync(request), "文件库目录已添加并完成扫描。");
}
public async Task<object?> SetRootEnabledAsync(ServiceEndpointContext ctx)
{
var request = ReadBody<UpdateLibraryRootRequest>(ctx);
return ResponseHelper.Ok(await fileLibrary.SetRootEnabledAsync(request), "文件库目录状态已更新。");
}
public async Task<object?> DeleteRootAsync(ServiceEndpointContext ctx)
{
var request = ReadBody<DeleteLibraryRootRequest>(ctx);
await fileLibrary.DeleteRootAsync(request);
return ResponseHelper.Succeed("文件库目录已删除。");
}
public async Task<object?> ScanRootAsync(ServiceEndpointContext ctx)
{
var request = ReadBody<ScanLibraryRootRequest>(ctx);
return ResponseHelper.Ok(await fileLibrary.ScanRootAsync(request.Id), "文件库目录扫描完成。");
}
public async Task<object?> SearchFilesAsync(ServiceEndpointContext ctx)
{
return await fileLibrary.SearchFilesAsync(ctx);
}
public async Task<object?> GetFileAsync(ServiceEndpointContext ctx)
{
var id = ReadId(ctx);
var file = await fileLibrary.GetFileAsync(id);
return file is null
? ResponseHelper.Failure(404, "文件不存在或尚未扫描入库。")
: ResponseHelper.Ok(file);
}
public async Task<object?> GetTextPreviewAsync(ServiceEndpointContext ctx)
{
var id = ReadId(ctx);
var preview = await fileLibrary.GetTextPreviewAsync(id);
return preview is null
? ResponseHelper.Failure(404, "文本文件不存在或无法预览。")
: ResponseHelper.Ok(preview);
}
private static T ReadBody<T>(ServiceEndpointContext ctx)
{
if (string.IsNullOrWhiteSpace(ctx.Body))
{
throw new InvalidOperationException("请求体不能为空。");
}
var body = JsonSerializer.Deserialize<T>(ctx.Body, JsonOptions);
return body ?? throw new InvalidOperationException("请求体格式错误。");
}
private static int ReadId(ServiceEndpointContext ctx)
{
if (int.TryParse(ctx.Query.GetValueOrDefault("id"), out var id) && id > 0)
{
return id;
}
throw new InvalidOperationException("id 参数无效。");
}
}
}
@@ -0,0 +1,409 @@
using Avalonia_Common.Core;
using Avalonia_EFCore.Database;
using Avalonia_EFCore.Models;
using Avalonia_Services.Core;
using Microsoft.EntityFrameworkCore;
using System.Text;
namespace Avalonia_Services.Services.FileLibrary
{
public sealed class FileLibraryService(AppDataContext db) : IFileLibraryService
{
private const int DefaultScanIntervalMinutes = 5;
private const int MaxTextPreviewBytes = 1024 * 1024;
public Task<List<DriveDto>> GetDrivesAsync(CancellationToken cancellationToken = default)
{
var drives = DriveInfo.GetDrives()
.Select(drive => new DriveDto(
drive.Name,
drive.IsReady ? $"{drive.Name} ({drive.VolumeLabel})" : drive.Name,
drive.RootDirectory.FullName,
drive.DriveType.ToString(),
SafeDriveValue(drive, d => d.TotalSize),
SafeDriveValue(drive, d => d.AvailableFreeSpace),
drive.IsReady))
.OrderBy(drive => drive.Name)
.ToList();
return Task.FromResult(drives);
}
public Task<List<DirectoryDto>> GetDirectoriesAsync(string? path, CancellationToken cancellationToken = default)
{
var normalized = NormalizeExistingDirectory(path);
var directories = Directory.EnumerateDirectories(normalized)
.Select(directory => new DirectoryInfo(directory))
.OrderBy(directory => directory.Name)
.Select(directory => new DirectoryDto(directory.Name, directory.FullName))
.ToList();
return Task.FromResult(directories);
}
public async Task<List<LibraryRootDto>> GetRootsAsync(CancellationToken cancellationToken = default)
{
var counts = await db.ManagedFileRecords
.Where(file => file.Exists)
.GroupBy(file => file.LibraryRootId)
.Select(group => new { RootId = group.Key, Count = group.Count() })
.ToDictionaryAsync(item => item.RootId, item => item.Count, cancellationToken);
var roots = await db.ManagedLibraryRoots
.OrderBy(root => root.Path)
.ToListAsync(cancellationToken);
return roots.Select(root => ToRootDto(root, counts.GetValueOrDefault(root.Id))).ToList();
}
public async Task<LibraryRootDto> AddRootAsync(AddLibraryRootRequest request, CancellationToken cancellationToken = default)
{
var normalized = NormalizeExistingDirectory(request.Path);
var existing = await db.ManagedLibraryRoots.FirstOrDefaultAsync(root => root.Path == normalized, cancellationToken);
if (existing is not null)
{
existing.IsEnabled = true;
existing.IsAvailable = true;
existing.DisplayName = ResolveDisplayName(normalized, request.DisplayName);
existing.ScanIntervalMinutes = NormalizeInterval(request.ScanIntervalMinutes);
await db.SaveChangesAsync(cancellationToken);
return await ScanRootAsync(existing.Id, cancellationToken);
}
var root = new ManagedLibraryRoot
{
Path = normalized,
DisplayName = ResolveDisplayName(normalized, request.DisplayName),
ScanIntervalMinutes = NormalizeInterval(request.ScanIntervalMinutes),
IsEnabled = true,
IsAvailable = true,
};
db.ManagedLibraryRoots.Add(root);
await db.SaveChangesAsync(cancellationToken);
return await ScanRootAsync(root.Id, cancellationToken);
}
public async Task<LibraryRootDto> SetRootEnabledAsync(UpdateLibraryRootRequest request, CancellationToken cancellationToken = default)
{
var root = await db.ManagedLibraryRoots.FirstOrDefaultAsync(item => item.Id == request.Id, cancellationToken)
?? throw new InvalidOperationException("文件库目录不存在。");
root.IsEnabled = request.IsEnabled;
await db.SaveChangesAsync(cancellationToken);
var count = await db.ManagedFileRecords.CountAsync(file => file.LibraryRootId == root.Id && file.Exists, cancellationToken);
return ToRootDto(root, count);
}
public async Task DeleteRootAsync(DeleteLibraryRootRequest request, CancellationToken cancellationToken = default)
{
var root = await db.ManagedLibraryRoots.FirstOrDefaultAsync(item => item.Id == request.Id, cancellationToken)
?? throw new InvalidOperationException("文件库目录不存在。");
db.ManagedLibraryRoots.Remove(root);
await db.SaveChangesAsync(cancellationToken);
}
public async Task<LibraryRootDto> ScanRootAsync(int rootId, CancellationToken cancellationToken = default)
{
var root = await db.ManagedLibraryRoots.FirstOrDefaultAsync(item => item.Id == rootId, cancellationToken)
?? throw new InvalidOperationException("文件库目录不存在。");
root.LastScanStartedAt = DateTime.UtcNow;
root.LastScanError = null;
await db.SaveChangesAsync(cancellationToken);
try
{
if (!Directory.Exists(root.Path))
{
throw new DirectoryNotFoundException($"目录不存在:{root.Path}");
}
root.IsAvailable = true;
root.IsEnabled = true;
var existing = await db.ManagedFileRecords
.Where(file => file.LibraryRootId == root.Id)
.ToDictionaryAsync(file => file.AbsolutePath, StringComparer.OrdinalIgnoreCase, cancellationToken);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var path in EnumerateSupportedFiles(root.Path))
{
cancellationToken.ThrowIfCancellationRequested();
var info = new FileInfo(path);
if (!info.Exists || !MediaFileTypes.TryGet(info.Extension.ToLowerInvariant(), out var mediaType, out var contentType, out _))
{
continue;
}
var absolutePath = info.FullName;
seen.Add(absolutePath);
if (!existing.TryGetValue(absolutePath, out var record))
{
record = new ManagedFileRecord
{
LibraryRootId = root.Id,
AbsolutePath = absolutePath,
};
db.ManagedFileRecords.Add(record);
}
record.FileName = info.Name;
record.RelativePath = Path.GetRelativePath(root.Path, absolutePath);
record.Extension = info.Extension.ToLowerInvariant();
record.SizeBytes = info.Length;
record.LastWriteTimeUtc = info.LastWriteTimeUtc;
record.MediaType = mediaType;
record.ContentType = contentType;
record.Exists = true;
record.LastSeenAt = DateTime.UtcNow;
}
foreach (var stale in existing.Values.Where(file => !seen.Contains(file.AbsolutePath)))
{
stale.Exists = false;
}
root.LastScanCompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
}
catch (Exception ex)
{
root.IsAvailable = false;
root.IsEnabled = false;
root.LastScanError = ex.Message;
root.LastScanCompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(CancellationToken.None);
throw;
}
var count = await db.ManagedFileRecords.CountAsync(file => file.LibraryRootId == root.Id && file.Exists, cancellationToken);
return ToRootDto(root, count);
}
public async Task ScanDueRootsAsync(CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
var roots = await db.ManagedLibraryRoots
.Where(root => root.IsEnabled && root.IsAvailable)
.ToListAsync(cancellationToken);
foreach (var root in roots)
{
var interval = Math.Max(1, root.ScanIntervalMinutes);
var isDue = root.LastScanCompletedAt is null || root.LastScanCompletedAt.Value.AddMinutes(interval) <= now;
if (!isDue)
{
continue;
}
try
{
await ScanRootAsync(root.Id, cancellationToken);
}
catch
{
// ScanRootAsync records the error on the root. Continue scanning other roots.
}
}
}
public async Task<PagedResponse<FileRecordDto>> SearchFilesAsync(ServiceEndpointContext ctx, CancellationToken cancellationToken = default)
{
var page = ParseInt(ctx.Query.GetValueOrDefault("page"), 1, 1, 100000);
var pageSize = ParseInt(ctx.Query.GetValueOrDefault("pageSize"), 24, 1, 100);
var mediaType = ctx.Query.GetValueOrDefault("mediaType")?.Trim();
var keyword = ctx.Query.GetValueOrDefault("keyword")?.Trim();
var rootId = ParseInt(ctx.Query.GetValueOrDefault("rootId"), 0, 0, int.MaxValue);
var query = db.ManagedFileRecords
.AsNoTracking()
.Where(file => file.Exists && file.LibraryRoot != null && file.LibraryRoot.IsAvailable);
if (!string.IsNullOrWhiteSpace(mediaType) && !mediaType.Equals("all", StringComparison.OrdinalIgnoreCase))
{
query = query.Where(file => file.MediaType == mediaType);
}
if (rootId > 0)
{
query = query.Where(file => file.LibraryRootId == rootId);
}
if (!string.IsNullOrWhiteSpace(keyword))
{
query = query.Where(file => file.FileName.Contains(keyword) || file.RelativePath.Contains(keyword));
}
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderBy(file => file.MediaType)
.ThenBy(file => file.RelativePath)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(file => ToFileDto(file))
.ToListAsync(cancellationToken);
return PagedResponse<FileRecordDto>.From(items, total, page, pageSize);
}
public async Task<FileRecordDto?> GetFileAsync(int id, CancellationToken cancellationToken = default)
{
return await db.ManagedFileRecords
.AsNoTracking()
.Where(file => file.Id == id && file.Exists && file.LibraryRoot != null && file.LibraryRoot.IsAvailable)
.Select(file => ToFileDto(file))
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<TextPreviewDto?> GetTextPreviewAsync(int id, CancellationToken cancellationToken = default)
{
var file = await db.ManagedFileRecords
.AsNoTracking()
.Include(item => item.LibraryRoot)
.FirstOrDefaultAsync(item =>
item.Id == id
&& item.Exists
&& item.MediaType == "text"
&& item.LibraryRoot != null
&& item.LibraryRoot.IsAvailable,
cancellationToken);
if (file is null || !File.Exists(file.AbsolutePath))
{
return null;
}
await using var stream = File.OpenRead(file.AbsolutePath);
var limit = (int)Math.Min(stream.Length, MaxTextPreviewBytes);
var buffer = new byte[limit];
var read = await stream.ReadAsync(buffer.AsMemory(0, limit), cancellationToken);
var content = Encoding.UTF8.GetString(buffer, 0, read);
return new TextPreviewDto(file.Id, file.FileName, content, stream.Length > MaxTextPreviewBytes);
}
private static IEnumerable<string> EnumerateSupportedFiles(string rootPath)
{
var pending = new Stack<string>();
pending.Push(rootPath);
while (pending.Count > 0)
{
var current = pending.Pop();
IEnumerable<string> directories;
IEnumerable<string> files;
try
{
directories = Directory.EnumerateDirectories(current);
files = Directory.EnumerateFiles(current);
}
catch
{
continue;
}
foreach (var directory in directories)
{
pending.Push(directory);
}
foreach (var file in files)
{
if (MediaFileTypes.TryGet(Path.GetExtension(file), out _, out _, out _))
{
yield return file;
}
}
}
}
private static string NormalizeExistingDirectory(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new InvalidOperationException("目录路径不能为空。");
}
var fullPath = Path.GetFullPath(path.Trim());
if (!Directory.Exists(fullPath))
{
throw new DirectoryNotFoundException($"目录不存在:{fullPath}");
}
return new DirectoryInfo(fullPath).FullName;
}
private static string ResolveDisplayName(string path, string? displayName)
{
if (!string.IsNullOrWhiteSpace(displayName))
{
return displayName.Trim();
}
var directory = new DirectoryInfo(path);
return string.IsNullOrWhiteSpace(directory.Name) ? directory.FullName : directory.Name;
}
private static int NormalizeInterval(int? interval)
{
return Math.Clamp(interval ?? DefaultScanIntervalMinutes, 1, 1440);
}
private static long? SafeDriveValue(DriveInfo drive, Func<DriveInfo, long> selector)
{
try
{
return drive.IsReady ? selector(drive) : null;
}
catch
{
return null;
}
}
private static LibraryRootDto ToRootDto(ManagedLibraryRoot root, int fileCount)
{
return new LibraryRootDto(
root.Id,
root.Path,
root.DisplayName,
root.IsEnabled,
root.IsAvailable,
root.ScanIntervalMinutes,
root.LastScanStartedAt,
root.LastScanCompletedAt,
root.LastScanError,
fileCount);
}
private static FileRecordDto ToFileDto(ManagedFileRecord file)
{
return new FileRecordDto(
file.Id,
file.LibraryRootId,
file.FileName,
file.RelativePath,
file.Extension,
file.SizeBytes,
file.LastWriteTimeUtc,
file.MediaType,
file.ContentType,
$"/api/files/{file.Id}/stream",
file.MediaType == "text" ? $"/api/files/text?id={file.Id}" : null,
MediaFileTypes.IsBrowserPlayable(file.Extension));
}
private static int ParseInt(string? value, int fallback, int min, int max)
{
return int.TryParse(value, out var parsed)
? Math.Clamp(parsed, min, max)
: fallback;
}
}
}
@@ -0,0 +1,27 @@
using Avalonia_Services.Core;
namespace Avalonia_Services.Services.FileLibrary
{
public interface IFileLibraryEndpointService
{
Task<object?> GetDrivesAsync(ServiceEndpointContext ctx);
Task<object?> GetDirectoriesAsync(ServiceEndpointContext ctx);
Task<object?> GetRootsAsync(ServiceEndpointContext ctx);
Task<object?> AddRootAsync(ServiceEndpointContext ctx);
Task<object?> SetRootEnabledAsync(ServiceEndpointContext ctx);
Task<object?> DeleteRootAsync(ServiceEndpointContext ctx);
Task<object?> ScanRootAsync(ServiceEndpointContext ctx);
Task<object?> SearchFilesAsync(ServiceEndpointContext ctx);
Task<object?> GetFileAsync(ServiceEndpointContext ctx);
Task<object?> GetTextPreviewAsync(ServiceEndpointContext ctx);
}
}
@@ -0,0 +1,30 @@
using Avalonia_Common.Core;
using Avalonia_Services.Core;
namespace Avalonia_Services.Services.FileLibrary
{
public interface IFileLibraryService
{
Task<List<DriveDto>> GetDrivesAsync(CancellationToken cancellationToken = default);
Task<List<DirectoryDto>> GetDirectoriesAsync(string? path, CancellationToken cancellationToken = default);
Task<List<LibraryRootDto>> GetRootsAsync(CancellationToken cancellationToken = default);
Task<LibraryRootDto> AddRootAsync(AddLibraryRootRequest request, CancellationToken cancellationToken = default);
Task<LibraryRootDto> SetRootEnabledAsync(UpdateLibraryRootRequest request, CancellationToken cancellationToken = default);
Task DeleteRootAsync(DeleteLibraryRootRequest request, CancellationToken cancellationToken = default);
Task<LibraryRootDto> ScanRootAsync(int rootId, CancellationToken cancellationToken = default);
Task ScanDueRootsAsync(CancellationToken cancellationToken = default);
Task<PagedResponse<FileRecordDto>> SearchFilesAsync(ServiceEndpointContext ctx, CancellationToken cancellationToken = default);
Task<FileRecordDto?> GetFileAsync(int id, CancellationToken cancellationToken = default);
Task<TextPreviewDto?> GetTextPreviewAsync(int id, CancellationToken cancellationToken = default);
}
}
@@ -0,0 +1,51 @@
namespace Avalonia_Services.Services.FileLibrary
{
public static class MediaFileTypes
{
private static readonly Dictionary<string, (string MediaType, string ContentType, bool BrowserPlayable)> Types =
new(StringComparer.OrdinalIgnoreCase)
{
[".txt"] = ("text", "text/plain; charset=utf-8", true),
[".log"] = ("text", "text/plain; charset=utf-8", true),
[".json"] = ("text", "application/json; charset=utf-8", true),
[".xml"] = ("text", "application/xml; charset=utf-8", true),
[".csv"] = ("text", "text/csv; charset=utf-8", true),
[".md"] = ("text", "text/markdown; charset=utf-8", true),
[".ini"] = ("text", "text/plain; charset=utf-8", true),
[".yml"] = ("text", "text/yaml; charset=utf-8", true),
[".yaml"] = ("text", "text/yaml; charset=utf-8", true),
[".mp4"] = ("video", "video/mp4", true),
[".webm"] = ("video", "video/webm", true),
[".ogg"] = ("video", "video/ogg", true),
[".ogv"] = ("video", "video/ogg", true),
[".mov"] = ("video", "video/quicktime", false),
[".mkv"] = ("video", "video/x-matroska", false),
[".mp3"] = ("audio", "audio/mpeg", true),
[".wav"] = ("audio", "audio/wav", true),
[".m4a"] = ("audio", "audio/mp4", true),
[".aac"] = ("audio", "audio/aac", true),
[".oga"] = ("audio", "audio/ogg", true),
};
public static bool TryGet(string extension, out string mediaType, out string contentType, out bool browserPlayable)
{
if (Types.TryGetValue(extension, out var value))
{
mediaType = value.MediaType;
contentType = value.ContentType;
browserPlayable = value.BrowserPlayable;
return true;
}
mediaType = string.Empty;
contentType = "application/octet-stream";
browserPlayable = false;
return false;
}
public static bool IsBrowserPlayable(string extension)
{
return Types.TryGetValue(extension, out var value) && value.BrowserPlayable;
}
}
}