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:
parent
e3fe965f10
commit
a68bb6c4b3
4
.gitignore
vendored
4
.gitignore
vendored
@ -26,5 +26,9 @@
|
|||||||
/Avalonia-API/avalonia-api.db
|
/Avalonia-API/avalonia-api.db
|
||||||
/Avalonia-API/avalonia-api.db-shm
|
/Avalonia-API/avalonia-api.db-shm
|
||||||
/Avalonia-API/avalonia-api.db-wal
|
/Avalonia-API/avalonia-api.db-wal
|
||||||
|
/Avalonia-API/app.db
|
||||||
|
/Avalonia-API/app.db-shm
|
||||||
|
/Avalonia-API/app.db-wal
|
||||||
|
/Avalonia-API/wwwroot
|
||||||
/package-output
|
/package-output
|
||||||
/package-scripts/tools
|
/package-scripts/tools
|
||||||
|
|||||||
@ -28,4 +28,23 @@
|
|||||||
<Folder Include="Controllers\" />
|
<Folder Include="Controllers\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target Name="RestoreFrontendPackages" BeforeTargets="Build" Condition="'$(SkipFrontendBuild)' != 'true' And Exists('..\Avalonia-Web-VUE\package.json') And !Exists('..\Avalonia-Web-VUE\node_modules')">
|
||||||
|
<Message Importance="high" Text="Restoring Avalonia-Web-VUE npm packages..." />
|
||||||
|
<Exec WorkingDirectory="..\Avalonia-Web-VUE" Command="npm.cmd install" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<Target Name="BuildFrontend" BeforeTargets="Build" DependsOnTargets="RestoreFrontendPackages" Condition="'$(SkipFrontendBuild)' != 'true' And Exists('..\Avalonia-Web-VUE\package.json')">
|
||||||
|
<Message Importance="high" Text="Building Avalonia-Web-VUE into Avalonia-API/wwwroot..." />
|
||||||
|
<Exec WorkingDirectory="..\Avalonia-Web-VUE" Command="npm.cmd run build-only" />
|
||||||
|
<RemoveDir Directories="wwwroot" />
|
||||||
|
<MakeDir Directories="wwwroot" />
|
||||||
|
<ItemGroup>
|
||||||
|
<FrontendDist Include="..\Avalonia-Web-VUE\dist\**\*.*" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Copy
|
||||||
|
SourceFiles="@(FrontendDist)"
|
||||||
|
DestinationFiles="@(FrontendDist->'wwwroot\%(RecursiveDir)%(Filename)%(Extension)')"
|
||||||
|
SkipUnchangedFiles="false" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
using Avalonia_API.Authentication;
|
using Avalonia_API.Authentication;
|
||||||
|
using Avalonia_API.Services;
|
||||||
using Avalonia_EFCore.Database;
|
using Avalonia_EFCore.Database;
|
||||||
using Avalonia_Services.Core;
|
using Avalonia_Services.Core;
|
||||||
using Avalonia_Services.Endpoints;
|
using Avalonia_Services.Endpoints;
|
||||||
using Avalonia_Services.Services;
|
using Avalonia_Services.Services;
|
||||||
using Avalonia_Services.Services.AuthService;
|
using Avalonia_Services.Services.AuthService;
|
||||||
|
using Avalonia_Services.Services.FileLibrary;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@ -36,6 +39,11 @@ namespace Avalonia_API.Configuration
|
|||||||
|
|
||||||
// ---- 业务服务 ----
|
// ---- 业务服务 ----
|
||||||
services.AddScoped<WeatherForecastService>();
|
services.AddScoped<WeatherForecastService>();
|
||||||
|
services.AddScoped<IFileLibraryService, FileLibraryService>();
|
||||||
|
services.AddScoped<IFileLibraryEndpointService, FileLibraryEndpointService>();
|
||||||
|
services.AddHostedService<FileLibraryScanHostedService>();
|
||||||
|
services.AddDataProtection()
|
||||||
|
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, "data-protection-keys")));
|
||||||
|
|
||||||
// ---- API 鉴权 ----
|
// ---- API 鉴权 ----
|
||||||
var jwtSection = configuration.GetSection("Jwt");
|
var jwtSection = configuration.GetSection("Jwt");
|
||||||
|
|||||||
47
Avalonia-API/Extensions/FileStreamEndpointExtensions.cs
Normal file
47
Avalonia-API/Extensions/FileStreamEndpointExtensions.cs
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
using Avalonia_EFCore.Database;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Avalonia_API.Extensions
|
||||||
|
{
|
||||||
|
public static class FileStreamEndpointExtensions
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapFileStreamEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapMethods("/api/files/{id:int}/stream", ["GET", "HEAD"], async (int id, AppDataContext db, HttpContext httpContext) =>
|
||||||
|
{
|
||||||
|
// Browsers cancel in-flight range requests aggressively while seeking.
|
||||||
|
// Keep this small metadata lookup independent from RequestAborted so
|
||||||
|
// EF does not throw TaskCanceledException before the file is opened.
|
||||||
|
var file = await db.ManagedFileRecords
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(item => item.LibraryRoot)
|
||||||
|
.FirstOrDefaultAsync(item =>
|
||||||
|
item.Id == id
|
||||||
|
&& item.Exists
|
||||||
|
&& item.LibraryRoot != null
|
||||||
|
&& item.LibraryRoot.IsAvailable);
|
||||||
|
|
||||||
|
if (file is null || !System.IO.File.Exists(file.AbsolutePath))
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var stream = System.IO.File.Open(file.AbsolutePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||||
|
httpContext.Response.Headers.ContentDisposition = $"inline; filename=\"{Uri.EscapeDataString(file.FileName)}\"";
|
||||||
|
httpContext.Response.Headers.AcceptRanges = "bytes";
|
||||||
|
httpContext.Response.Headers.CacheControl = "public, max-age=3600";
|
||||||
|
|
||||||
|
return Results.File(
|
||||||
|
stream,
|
||||||
|
contentType: file.ContentType,
|
||||||
|
fileDownloadName: null,
|
||||||
|
lastModified: file.LastWriteTimeUtc,
|
||||||
|
enableRangeProcessing: true);
|
||||||
|
})
|
||||||
|
.WithName("StreamManagedFile")
|
||||||
|
.WithTags("FileLibrary");
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -131,9 +131,11 @@ namespace Avalonia_API.Extensions
|
|||||||
{
|
{
|
||||||
return async (HttpContext httpContext) =>
|
return async (HttpContext httpContext) =>
|
||||||
{
|
{
|
||||||
var ctx = await BuildContextFromHttpContext(httpContext);
|
var ctx = httpContext.Items["UnifiedContext"] as ServiceEndpointContext
|
||||||
|
?? await BuildContextFromHttpContext(httpContext);
|
||||||
ctx.Items["ServiceProvider"] = serviceProvider;
|
ctx.Items["ServiceProvider"] = serviceProvider;
|
||||||
ctx.Items["User"] = httpContext.User;
|
ctx.Items["User"] = httpContext.User;
|
||||||
|
httpContext.Items["UnifiedContext"] = ctx;
|
||||||
|
|
||||||
var result = await unifiedHandler(ctx);
|
var result = await unifiedHandler(ctx);
|
||||||
|
|
||||||
@ -204,6 +206,7 @@ namespace Avalonia_API.Extensions
|
|||||||
|
|
||||||
httpContext.Items["UnifiedContext"] = ctx;
|
httpContext.Items["UnifiedContext"] = ctx;
|
||||||
|
|
||||||
|
object? nextResult = null;
|
||||||
await unifiedFilter.InvokeAsync(ctx, async (c) =>
|
await unifiedFilter.InvokeAsync(ctx, async (c) =>
|
||||||
{
|
{
|
||||||
httpContext.Response.StatusCode = c.StatusCode;
|
httpContext.Response.StatusCode = c.StatusCode;
|
||||||
@ -211,7 +214,7 @@ namespace Avalonia_API.Extensions
|
|||||||
{
|
{
|
||||||
httpContext.Response.Headers[kvp.Key] = kvp.Value;
|
httpContext.Response.Headers[kvp.Key] = kvp.Value;
|
||||||
}
|
}
|
||||||
await aspNext(aspContext);
|
nextResult = await aspNext(aspContext);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ctx.ResponseBody is not null)
|
if (ctx.ResponseBody is not null)
|
||||||
@ -219,7 +222,7 @@ namespace Avalonia_API.Extensions
|
|||||||
return Results.Json(ctx.ResponseBody, statusCode: ctx.StatusCode);
|
return Results.Json(ctx.ResponseBody, statusCode: ctx.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null!;
|
return nextResult;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,12 +13,22 @@ try
|
|||||||
{
|
{
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// 配置 Kestrel 监听所有本机 IP
|
||||||
|
builder.WebHost.UseUrls("http://0.0.0.0:5206", "https://0.0.0.0:7165");
|
||||||
|
|
||||||
// 使用 Serilog 作为日志提供程序
|
// 使用 Serilog 作为日志提供程序
|
||||||
builder.Host.UseSerilog();
|
builder.Host.UseSerilog();
|
||||||
|
|
||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy("LanFileViewer", policy =>
|
||||||
|
policy.AllowAnyOrigin()
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod());
|
||||||
|
});
|
||||||
|
|
||||||
// 注册统一端点及业务服务(入口在 Avalonia-Services/Endpoints/AppEndpoints.cs)
|
// 注册统一端点及业务服务(入口在 Avalonia-Services/Endpoints/AppEndpoints.cs)
|
||||||
builder.Services.AddUnifiedApiServices(builder.Configuration);
|
builder.Services.AddUnifiedApiServices(builder.Configuration);
|
||||||
@ -41,12 +51,17 @@ try
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseDefaultFiles();
|
||||||
|
app.UseStaticFiles();
|
||||||
|
// 局域网文件播放优先使用 HTTP,避免手机浏览器对自签 HTTPS/HTTP2 视频流的兼容问题。
|
||||||
|
app.UseCors("LanFileViewer");
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
// 将统一端点映射到 ASP.NET Core 路由
|
// 将统一端点映射到 ASP.NET Core 路由
|
||||||
app.MapUnifiedEndpoints(endpoints, app.Services);
|
app.MapUnifiedEndpoints(endpoints, app.Services);
|
||||||
|
app.MapFileStreamEndpoints();
|
||||||
|
app.MapFallbackToFile("index.html");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": false,
|
"launchBrowser": false,
|
||||||
"applicationUrl": "http://localhost:5206",
|
"applicationUrl": "http://0.0.0.0:5206",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
@ -14,7 +14,7 @@
|
|||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": false,
|
"launchBrowser": false,
|
||||||
"applicationUrl": "https://localhost:7165;http://localhost:5206",
|
"applicationUrl": "https://0.0.0.0:7165;http://0.0.0.0:5206",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
|
|||||||
38
Avalonia-API/Services/FileLibraryScanHostedService.cs
Normal file
38
Avalonia-API/Services/FileLibraryScanHostedService.cs
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
using Avalonia_Services.Services.FileLibrary;
|
||||||
|
|
||||||
|
namespace Avalonia_API.Services
|
||||||
|
{
|
||||||
|
public sealed class FileLibraryScanHostedService(IServiceScopeFactory scopeFactory, ILogger<FileLibraryScanHostedService> logger)
|
||||||
|
: BackgroundService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await ScanAsync(stoppingToken);
|
||||||
|
|
||||||
|
using var timer = new PeriodicTimer(Interval);
|
||||||
|
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||||
|
{
|
||||||
|
await ScanAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ScanAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
var scanner = scope.ServiceProvider.GetRequiredService<IFileLibraryService>();
|
||||||
|
await scanner.ScanDueRootsAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "文件库定时扫描失败。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,8 +14,8 @@
|
|||||||
"RefreshTokenDays": 30
|
"RefreshTokenDays": 30
|
||||||
},
|
},
|
||||||
"DatabaseConfiguration": {
|
"DatabaseConfiguration": {
|
||||||
"Provider": "MySQL",
|
"Provider": "SQLite",
|
||||||
"ConnectionString": "Server=127.0.0.1;Port=3306;Database=avalonia-api;Uid=root;Pwd=123456;Max Pool Size=100;Min Pool Size=5;AllowZeroDateTime=True;AllowLoadLocalInfile=true;SslMode=Required",
|
"ConnectionString": "Data Source=app.db",
|
||||||
"AutoMigrate": true,
|
"AutoMigrate": true,
|
||||||
"RecreateDatabase": false,
|
"RecreateDatabase": false,
|
||||||
"EnableDetailedLog": false,
|
"EnableDetailedLog": false,
|
||||||
|
|||||||
@ -19,6 +19,12 @@ namespace Avalonia_EFCore.Database
|
|||||||
/// <summary>API refresh token 数据</summary>
|
/// <summary>API refresh token 数据</summary>
|
||||||
public DbSet<ApiRefreshTokenEntity> ApiRefreshTokens => Set<ApiRefreshTokenEntity>();
|
public DbSet<ApiRefreshTokenEntity> ApiRefreshTokens => Set<ApiRefreshTokenEntity>();
|
||||||
|
|
||||||
|
/// <summary>文件库根目录数据</summary>
|
||||||
|
public DbSet<ManagedLibraryRoot> ManagedLibraryRoots => Set<ManagedLibraryRoot>();
|
||||||
|
|
||||||
|
/// <summary>文件库文件记录数据</summary>
|
||||||
|
public DbSet<ManagedFileRecord> ManagedFileRecords => Set<ManagedFileRecord>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 配置实体映射,包括主键、索引和属性约束。
|
/// 配置实体映射,包括主键、索引和属性约束。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -45,6 +51,34 @@ namespace Avalonia_EFCore.Database
|
|||||||
entity.HasIndex(e => e.TokenHash).IsUnique().HasDatabaseName("idx-api-refresh-token-hash");
|
entity.HasIndex(e => e.TokenHash).IsUnique().HasDatabaseName("idx-api-refresh-token-hash");
|
||||||
entity.HasIndex(e => e.UserId).HasDatabaseName("idx-api-refresh-token-user-id");
|
entity.HasIndex(e => e.UserId).HasDatabaseName("idx-api-refresh-token-user-id");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ManagedLibraryRoot>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id).HasName("pk-managed-library-root");
|
||||||
|
entity.HasIndex(e => e.Path).IsUnique().HasDatabaseName("idx-managed-library-root-path");
|
||||||
|
entity.Property(e => e.Path).HasMaxLength(1024);
|
||||||
|
entity.Property(e => e.DisplayName).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.LastScanError).HasMaxLength(2000);
|
||||||
|
entity.Property(e => e.IsAvailable).HasDefaultValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ManagedFileRecord>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id).HasName("pk-managed-file-record");
|
||||||
|
entity.HasIndex(e => e.LibraryRootId).HasDatabaseName("idx-managed-file-record-root-id");
|
||||||
|
entity.HasIndex(e => e.AbsolutePath).IsUnique().HasDatabaseName("idx-managed-file-record-absolute-path");
|
||||||
|
entity.HasIndex(e => new { e.MediaType, e.Exists }).HasDatabaseName("idx-managed-file-record-media-type-exists");
|
||||||
|
entity.Property(e => e.FileName).HasMaxLength(260);
|
||||||
|
entity.Property(e => e.RelativePath).HasMaxLength(1024);
|
||||||
|
entity.Property(e => e.AbsolutePath).HasMaxLength(2048);
|
||||||
|
entity.Property(e => e.Extension).HasMaxLength(32);
|
||||||
|
entity.Property(e => e.MediaType).HasMaxLength(20);
|
||||||
|
entity.Property(e => e.ContentType).HasMaxLength(100);
|
||||||
|
entity.HasOne(e => e.LibraryRoot)
|
||||||
|
.WithMany(e => e.Files)
|
||||||
|
.HasForeignKey(e => e.LibraryRootId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
352
Avalonia-EFCore/Migrations/SQLite/20260521080213_AddFileLibrary.Designer.cs
generated
Normal file
352
Avalonia-EFCore/Migrations/SQLite/20260521080213_AddFileLibrary.Designer.cs
generated
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Avalonia_EFCore.Database;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Avalonia_EFCore.Migrations.SQLite
|
||||||
|
{
|
||||||
|
[DbContext(typeof(SqliteAppDataContext))]
|
||||||
|
[Migration("20260521080213_AddFileLibrary")]
|
||||||
|
partial class AddFileLibrary
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
|
||||||
|
|
||||||
|
modelBuilder.Entity("Avalonia_EFCore.Models.ApiRefreshTokenEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created-at");
|
||||||
|
|
||||||
|
b.Property<string>("Device")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("device");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("expires-at");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("ip-address");
|
||||||
|
|
||||||
|
b.Property<string>("ReplacedByTokenHash")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("replaced-by-token-hash");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RevokedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("revoked-at");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("token-hash");
|
||||||
|
|
||||||
|
b.Property<int>("UserId")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("user-id");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk-api-refresh-token");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("idx-api-refresh-token-hash");
|
||||||
|
|
||||||
|
b.HasIndex("UserId")
|
||||||
|
.HasDatabaseName("idx-api-refresh-token-user-id");
|
||||||
|
|
||||||
|
b.ToTable("api-refresh-token", t =>
|
||||||
|
{
|
||||||
|
t.HasComment("API refresh token");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Avalonia_EFCore.Models.ManagedFileRecord", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("AbsolutePath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("absolute-path");
|
||||||
|
|
||||||
|
b.Property<string>("ContentType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("content-type");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created-at");
|
||||||
|
|
||||||
|
b.Property<bool>("Exists")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("exists");
|
||||||
|
|
||||||
|
b.Property<string>("Extension")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("extension");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(260)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("file-name");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastSeenAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("last-seen-at");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastWriteTimeUtc")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("last-write-time-utc");
|
||||||
|
|
||||||
|
b.Property<int>("LibraryRootId")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("library-root-id");
|
||||||
|
|
||||||
|
b.Property<string>("MediaType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("media-type");
|
||||||
|
|
||||||
|
b.Property<string>("RelativePath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("relative-path");
|
||||||
|
|
||||||
|
b.Property<long>("SizeBytes")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("size-bytes");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("updated-at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk-managed-file-record");
|
||||||
|
|
||||||
|
b.HasIndex("AbsolutePath")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("idx-managed-file-record-absolute-path");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryRootId")
|
||||||
|
.HasDatabaseName("idx-managed-file-record-root-id");
|
||||||
|
|
||||||
|
b.HasIndex("MediaType", "Exists")
|
||||||
|
.HasDatabaseName("idx-managed-file-record-media-type-exists");
|
||||||
|
|
||||||
|
b.ToTable("managed-file-record", t =>
|
||||||
|
{
|
||||||
|
t.HasComment("文件库文件记录");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Avalonia_EFCore.Models.ManagedLibraryRoot", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created-at");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("display-name");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAvailable")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(true)
|
||||||
|
.HasColumnName("is-available");
|
||||||
|
|
||||||
|
b.Property<bool>("IsEnabled")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("is-enabled");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastScanCompletedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("last-scan-completed-at");
|
||||||
|
|
||||||
|
b.Property<string>("LastScanError")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("last-scan-error");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastScanStartedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("last-scan-started-at");
|
||||||
|
|
||||||
|
b.Property<string>("Path")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("path");
|
||||||
|
|
||||||
|
b.Property<int>("ScanIntervalMinutes")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("scan-interval-minutes");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("updated-at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk-managed-library-root");
|
||||||
|
|
||||||
|
b.HasIndex("Path")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("idx-managed-library-root-path");
|
||||||
|
|
||||||
|
b.ToTable("managed-library-root", t =>
|
||||||
|
{
|
||||||
|
t.HasComment("文件库根目录");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id")
|
||||||
|
.HasComment("用户主键");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created-at")
|
||||||
|
.HasComment("创建时间");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("email")
|
||||||
|
.HasComment("用户邮箱");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("name")
|
||||||
|
.HasComment("用户名称");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("password-hash")
|
||||||
|
.HasComment("密码哈希值");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("phone-number")
|
||||||
|
.HasComment("电话号码");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("updated-at")
|
||||||
|
.HasComment("更新时间");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk-user");
|
||||||
|
|
||||||
|
b.ToTable("user", t =>
|
||||||
|
{
|
||||||
|
t.HasComment("用户实体,演示数据库 CRUD 操作");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Avalonia_EFCore.Models.WeatherForecastEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id")
|
||||||
|
.HasComment("天气预报主键");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created-at")
|
||||||
|
.HasComment("创建时间");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("Date")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("date")
|
||||||
|
.HasComment("预报日期");
|
||||||
|
|
||||||
|
b.Property<string>("Summary")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("summary")
|
||||||
|
.HasComment("天气摘要");
|
||||||
|
|
||||||
|
b.Property<int>("TemperatureC")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("temperature-c")
|
||||||
|
.HasComment("摄氏温度");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("updated-at")
|
||||||
|
.HasComment("更新时间");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk-weather-forecast");
|
||||||
|
|
||||||
|
b.ToTable("weather-forecast", t =>
|
||||||
|
{
|
||||||
|
t.HasComment("天气预报数据实体");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Avalonia_EFCore.Models.ManagedFileRecord", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Avalonia_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
|
||||||
|
.WithMany("Files")
|
||||||
|
.HasForeignKey("LibraryRootId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("LibraryRoot");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Avalonia_EFCore.Models.ManagedLibraryRoot", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Files");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Avalonia_EFCore.Migrations.SQLite
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddFileLibrary : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "managed-library-root",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
path = table.Column<string>(type: "TEXT", maxLength: 1024, nullable: false),
|
||||||
|
displayname = table.Column<string>(name: "display-name", type: "TEXT", maxLength: 200, nullable: false),
|
||||||
|
isenabled = table.Column<bool>(name: "is-enabled", type: "INTEGER", nullable: false),
|
||||||
|
isavailable = table.Column<bool>(name: "is-available", type: "INTEGER", nullable: false, defaultValue: true),
|
||||||
|
scanintervalminutes = table.Column<int>(name: "scan-interval-minutes", type: "INTEGER", nullable: false),
|
||||||
|
lastscanstartedat = table.Column<DateTime>(name: "last-scan-started-at", type: "TEXT", nullable: true),
|
||||||
|
lastscancompletedat = table.Column<DateTime>(name: "last-scan-completed-at", type: "TEXT", nullable: true),
|
||||||
|
lastscanerror = table.Column<string>(name: "last-scan-error", type: "TEXT", maxLength: 2000, nullable: true),
|
||||||
|
createdat = table.Column<DateTime>(name: "created-at", type: "TEXT", nullable: false),
|
||||||
|
updatedat = table.Column<DateTime>(name: "updated-at", type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk-managed-library-root", x => x.id);
|
||||||
|
},
|
||||||
|
comment: "文件库根目录");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "managed-file-record",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
libraryrootid = table.Column<int>(name: "library-root-id", type: "INTEGER", nullable: false),
|
||||||
|
filename = table.Column<string>(name: "file-name", type: "TEXT", maxLength: 260, nullable: false),
|
||||||
|
relativepath = table.Column<string>(name: "relative-path", type: "TEXT", maxLength: 1024, nullable: false),
|
||||||
|
absolutepath = table.Column<string>(name: "absolute-path", type: "TEXT", maxLength: 2048, nullable: false),
|
||||||
|
extension = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||||
|
sizebytes = table.Column<long>(name: "size-bytes", type: "INTEGER", nullable: false),
|
||||||
|
lastwritetimeutc = table.Column<DateTime>(name: "last-write-time-utc", type: "TEXT", nullable: false),
|
||||||
|
mediatype = table.Column<string>(name: "media-type", type: "TEXT", maxLength: 20, nullable: false),
|
||||||
|
contenttype = table.Column<string>(name: "content-type", type: "TEXT", maxLength: 100, nullable: false),
|
||||||
|
exists = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
lastseenat = table.Column<DateTime>(name: "last-seen-at", type: "TEXT", nullable: false),
|
||||||
|
createdat = table.Column<DateTime>(name: "created-at", type: "TEXT", nullable: false),
|
||||||
|
updatedat = table.Column<DateTime>(name: "updated-at", type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk-managed-file-record", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_managed-file-record_managed-library-root_library-root-id",
|
||||||
|
column: x => x.libraryrootid,
|
||||||
|
principalTable: "managed-library-root",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
},
|
||||||
|
comment: "文件库文件记录");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "idx-managed-file-record-absolute-path",
|
||||||
|
table: "managed-file-record",
|
||||||
|
column: "absolute-path",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "idx-managed-file-record-media-type-exists",
|
||||||
|
table: "managed-file-record",
|
||||||
|
columns: new[] { "media-type", "exists" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "idx-managed-file-record-root-id",
|
||||||
|
table: "managed-file-record",
|
||||||
|
column: "library-root-id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "idx-managed-library-root-path",
|
||||||
|
table: "managed-library-root",
|
||||||
|
column: "path",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "managed-file-record");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "managed-library-root");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -77,6 +77,163 @@ namespace Avalonia_EFCore.Migrations.SQLite
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Avalonia_EFCore.Models.ManagedFileRecord", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("AbsolutePath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("absolute-path");
|
||||||
|
|
||||||
|
b.Property<string>("ContentType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("content-type");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created-at");
|
||||||
|
|
||||||
|
b.Property<bool>("Exists")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("exists");
|
||||||
|
|
||||||
|
b.Property<string>("Extension")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("extension");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(260)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("file-name");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastSeenAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("last-seen-at");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastWriteTimeUtc")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("last-write-time-utc");
|
||||||
|
|
||||||
|
b.Property<int>("LibraryRootId")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("library-root-id");
|
||||||
|
|
||||||
|
b.Property<string>("MediaType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("media-type");
|
||||||
|
|
||||||
|
b.Property<string>("RelativePath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("relative-path");
|
||||||
|
|
||||||
|
b.Property<long>("SizeBytes")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("size-bytes");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("updated-at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk-managed-file-record");
|
||||||
|
|
||||||
|
b.HasIndex("AbsolutePath")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("idx-managed-file-record-absolute-path");
|
||||||
|
|
||||||
|
b.HasIndex("LibraryRootId")
|
||||||
|
.HasDatabaseName("idx-managed-file-record-root-id");
|
||||||
|
|
||||||
|
b.HasIndex("MediaType", "Exists")
|
||||||
|
.HasDatabaseName("idx-managed-file-record-media-type-exists");
|
||||||
|
|
||||||
|
b.ToTable("managed-file-record", t =>
|
||||||
|
{
|
||||||
|
t.HasComment("文件库文件记录");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Avalonia_EFCore.Models.ManagedLibraryRoot", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created-at");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("display-name");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAvailable")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(true)
|
||||||
|
.HasColumnName("is-available");
|
||||||
|
|
||||||
|
b.Property<bool>("IsEnabled")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("is-enabled");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastScanCompletedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("last-scan-completed-at");
|
||||||
|
|
||||||
|
b.Property<string>("LastScanError")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("last-scan-error");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastScanStartedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("last-scan-started-at");
|
||||||
|
|
||||||
|
b.Property<string>("Path")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("path");
|
||||||
|
|
||||||
|
b.Property<int>("ScanIntervalMinutes")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("scan-interval-minutes");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("updated-at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk-managed-library-root");
|
||||||
|
|
||||||
|
b.HasIndex("Path")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("idx-managed-library-root-path");
|
||||||
|
|
||||||
|
b.ToTable("managed-library-root", t =>
|
||||||
|
{
|
||||||
|
t.HasComment("文件库根目录");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b =>
|
modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@ -170,6 +327,22 @@ namespace Avalonia_EFCore.Migrations.SQLite
|
|||||||
t.HasComment("天气预报数据实体");
|
t.HasComment("天气预报数据实体");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Avalonia_EFCore.Models.ManagedFileRecord", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Avalonia_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
|
||||||
|
.WithMany("Files")
|
||||||
|
.HasForeignKey("LibraryRootId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("LibraryRoot");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Avalonia_EFCore.Models.ManagedLibraryRoot", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Files");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
81
Avalonia-EFCore/Models/ManagedFileRecord.cs
Normal file
81
Avalonia-EFCore/Models/ManagedFileRecord.cs
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Avalonia_EFCore.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 扫描入库的可在线查看文件。
|
||||||
|
/// </summary>
|
||||||
|
[Comment("文件库文件记录")]
|
||||||
|
[Table("managed-file-record")]
|
||||||
|
public class ManagedFileRecord
|
||||||
|
{
|
||||||
|
/// <summary>主键 ID。</summary>
|
||||||
|
[Key]
|
||||||
|
[Column("id")]
|
||||||
|
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>所属根目录 ID。</summary>
|
||||||
|
[Column("library-root-id")]
|
||||||
|
public int LibraryRootId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>文件名。</summary>
|
||||||
|
[Column("file-name")]
|
||||||
|
[MaxLength(260)]
|
||||||
|
public string FileName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>相对根目录路径。</summary>
|
||||||
|
[Column("relative-path")]
|
||||||
|
[MaxLength(1024)]
|
||||||
|
public string RelativePath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>服务器本机绝对路径。</summary>
|
||||||
|
[Column("absolute-path")]
|
||||||
|
[MaxLength(2048)]
|
||||||
|
public string AbsolutePath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>扩展名,小写并包含点。</summary>
|
||||||
|
[Column("extension")]
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string Extension { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>文件大小,字节。</summary>
|
||||||
|
[Column("size-bytes")]
|
||||||
|
public long SizeBytes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>文件最后修改时间 UTC。</summary>
|
||||||
|
[Column("last-write-time-utc")]
|
||||||
|
public DateTime LastWriteTimeUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>媒体类型:text、video、audio。</summary>
|
||||||
|
[Column("media-type")]
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string MediaType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>MIME 类型。</summary>
|
||||||
|
[Column("content-type")]
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string ContentType { get; set; } = "application/octet-stream";
|
||||||
|
|
||||||
|
/// <summary>文件是否仍存在。</summary>
|
||||||
|
[Column("exists")]
|
||||||
|
public bool Exists { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>最近扫描时间。</summary>
|
||||||
|
[Column("last-seen-at")]
|
||||||
|
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>创建时间。</summary>
|
||||||
|
[Column("created-at")]
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>更新时间。</summary>
|
||||||
|
[Column("updated-at")]
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>所属根目录。</summary>
|
||||||
|
public ManagedLibraryRoot? LibraryRoot { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
66
Avalonia-EFCore/Models/ManagedLibraryRoot.cs
Normal file
66
Avalonia-EFCore/Models/ManagedLibraryRoot.cs
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Avalonia_EFCore.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 管理端添加的文件库根目录或磁盘。
|
||||||
|
/// </summary>
|
||||||
|
[Comment("文件库根目录")]
|
||||||
|
[Table("managed-library-root")]
|
||||||
|
public class ManagedLibraryRoot
|
||||||
|
{
|
||||||
|
/// <summary>主键 ID。</summary>
|
||||||
|
[Key]
|
||||||
|
[Column("id")]
|
||||||
|
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>服务器本机绝对路径。</summary>
|
||||||
|
[Column("path")]
|
||||||
|
[MaxLength(1024)]
|
||||||
|
public string Path { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>显示名称。</summary>
|
||||||
|
[Column("display-name")]
|
||||||
|
[MaxLength(200)]
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>是否启用扫描。</summary>
|
||||||
|
[Column("is-enabled")]
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>目录最近一次扫描是否可用。</summary>
|
||||||
|
[Column("is-available")]
|
||||||
|
public bool IsAvailable { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>扫描间隔分钟数。</summary>
|
||||||
|
[Column("scan-interval-minutes")]
|
||||||
|
public int ScanIntervalMinutes { get; set; } = 5;
|
||||||
|
|
||||||
|
/// <summary>最近扫描开始时间。</summary>
|
||||||
|
[Column("last-scan-started-at")]
|
||||||
|
public DateTime? LastScanStartedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>最近扫描完成时间。</summary>
|
||||||
|
[Column("last-scan-completed-at")]
|
||||||
|
public DateTime? LastScanCompletedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>最近扫描错误。</summary>
|
||||||
|
[Column("last-scan-error")]
|
||||||
|
[MaxLength(2000)]
|
||||||
|
public string? LastScanError { get; set; }
|
||||||
|
|
||||||
|
/// <summary>创建时间。</summary>
|
||||||
|
[Column("created-at")]
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>更新时间。</summary>
|
||||||
|
[Column("updated-at")]
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>文件记录。</summary>
|
||||||
|
public List<ManagedFileRecord> Files { get; set; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ using Avalonia_EFCore.Database;
|
|||||||
using Avalonia_EFCore.Models;
|
using Avalonia_EFCore.Models;
|
||||||
using Avalonia_Services.Core;
|
using Avalonia_Services.Core;
|
||||||
using Avalonia_Services.Services;
|
using Avalonia_Services.Services;
|
||||||
|
using Avalonia_Services.Services.FileLibrary;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Avalonia_Services.Endpoints
|
namespace Avalonia_Services.Endpoints
|
||||||
@ -47,6 +48,46 @@ namespace Avalonia_Services.Endpoints
|
|||||||
endpoints.MapPost("api/processData", ProcessDataAsync)
|
endpoints.MapPost("api/processData", ProcessDataAsync)
|
||||||
.WithName("ProcessData");
|
.WithName("ProcessData");
|
||||||
|
|
||||||
|
endpoints.MapGet<IFileLibraryEndpointService>("api/library/drives", (service, ctx) => service.GetDrivesAsync(ctx))
|
||||||
|
.WithOpenApi("FileLibrary", "查询服务器磁盘。")
|
||||||
|
.WithName("GetLibraryDrives");
|
||||||
|
|
||||||
|
endpoints.MapGet<IFileLibraryEndpointService>("api/library/directories", (service, ctx) => service.GetDirectoriesAsync(ctx))
|
||||||
|
.WithOpenApi("FileLibrary", "查询服务器目录。")
|
||||||
|
.WithName("GetLibraryDirectories");
|
||||||
|
|
||||||
|
endpoints.MapGet<IFileLibraryEndpointService>("api/library/roots", (service, ctx) => service.GetRootsAsync(ctx))
|
||||||
|
.WithOpenApi("FileLibrary", "查询文件库目录。")
|
||||||
|
.WithName("GetLibraryRoots");
|
||||||
|
|
||||||
|
endpoints.MapPost<IFileLibraryEndpointService>("api/library/roots", (service, ctx) => service.AddRootAsync(ctx))
|
||||||
|
.WithOpenApi("FileLibrary", "添加文件库目录。")
|
||||||
|
.WithName("AddLibraryRoot");
|
||||||
|
|
||||||
|
endpoints.MapPost<IFileLibraryEndpointService>("api/library/roots/enabled", (service, ctx) => service.SetRootEnabledAsync(ctx))
|
||||||
|
.WithOpenApi("FileLibrary", "启用或禁用文件库目录。")
|
||||||
|
.WithName("SetLibraryRootEnabled");
|
||||||
|
|
||||||
|
endpoints.MapPost<IFileLibraryEndpointService>("api/library/roots/delete", (service, ctx) => service.DeleteRootAsync(ctx))
|
||||||
|
.WithOpenApi("FileLibrary", "删除文件库目录。")
|
||||||
|
.WithName("DeleteLibraryRoot");
|
||||||
|
|
||||||
|
endpoints.MapPost<IFileLibraryEndpointService>("api/library/roots/scan", (service, ctx) => service.ScanRootAsync(ctx))
|
||||||
|
.WithOpenApi("FileLibrary", "立即扫描文件库目录。")
|
||||||
|
.WithName("ScanLibraryRoot");
|
||||||
|
|
||||||
|
endpoints.MapGet<IFileLibraryEndpointService>("api/files", (service, ctx) => service.SearchFilesAsync(ctx))
|
||||||
|
.WithOpenApi("FileLibrary", "分页查询已扫描文件。")
|
||||||
|
.WithName("SearchFiles");
|
||||||
|
|
||||||
|
endpoints.MapGet<IFileLibraryEndpointService>("api/files/detail", (service, ctx) => service.GetFileAsync(ctx))
|
||||||
|
.WithOpenApi("FileLibrary", "查询文件详情。")
|
||||||
|
.WithName("GetFileDetail");
|
||||||
|
|
||||||
|
endpoints.MapGet<IFileLibraryEndpointService>("api/files/text", (service, ctx) => service.GetTextPreviewAsync(ctx))
|
||||||
|
.WithOpenApi("FileLibrary", "预览文本文件。")
|
||||||
|
.WithName("GetTextPreview");
|
||||||
|
|
||||||
// ---- 需要鉴权的端点示例 ----
|
// ---- 需要鉴权的端点示例 ----
|
||||||
// endpoints.MapGet("api/admin/dashboard", AdminDashboardAsync)
|
// endpoints.MapGet("api/admin/dashboard", AdminDashboardAsync)
|
||||||
// .WithName("AdminDashboard")
|
// .WithName("AdminDashboard")
|
||||||
|
|||||||
@ -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 参数无效。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
409
Avalonia-Services/Services/FileLibrary/FileLibraryService.cs
Normal file
409
Avalonia-Services/Services/FileLibrary/FileLibraryService.cs
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
Avalonia-Services/Services/FileLibrary/MediaFileTypes.cs
Normal file
51
Avalonia-Services/Services/FileLibrary/MediaFileTypes.cs
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,47 +1,435 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import HelloWorld from './components/HelloWorld.vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import TheWelcome from './components/TheWelcome.vue'
|
import { api, type DirectoryDto, type DriveDto, type FileRecordDto, type LibraryRootDto, type MediaType, type TextPreviewDto } from './api'
|
||||||
|
|
||||||
|
const isAdminPage = computed(() => window.location.pathname.toLowerCase().startsWith('/admin'))
|
||||||
|
const roots = ref<LibraryRootDto[]>([])
|
||||||
|
const drives = ref<DriveDto[]>([])
|
||||||
|
const directories = ref<DirectoryDto[]>([])
|
||||||
|
const files = ref<FileRecordDto[]>([])
|
||||||
|
const selectedFile = ref<FileRecordDto | null>(null)
|
||||||
|
const textPreview = ref<TextPreviewDto | null>(null)
|
||||||
|
const currentPath = ref('')
|
||||||
|
const manualPath = ref('')
|
||||||
|
const keyword = ref('')
|
||||||
|
const mediaType = ref<MediaType>('all')
|
||||||
|
const rootId = ref<number | undefined>()
|
||||||
|
const isBrowsingRoots = ref(true)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 24
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const scanningId = ref<number | null>(null)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||||
|
const availableRoots = computed(() => roots.value.filter((root) => root.isAvailable))
|
||||||
|
const activeRoots = computed(() => roots.value.filter((root) => root.isEnabled && root.isAvailable))
|
||||||
|
const selectedRoot = computed(() => roots.value.find((root) => root.id === selectedFile.value?.libraryRootId))
|
||||||
|
const totalRootFiles = computed(() => roots.value.reduce((sum, root) => sum + root.fileCount, 0))
|
||||||
|
const selectedMediaUrl = computed(() => selectedFile.value ? api.mediaUrl(selectedFile.value.streamUrl) : '')
|
||||||
|
const clientTitle = computed(() => {
|
||||||
|
if (isBrowsingRoots.value) return '文件库'
|
||||||
|
return rootId.value ? roots.value.find((root) => root.id === rootId.value)?.displayName ?? '文件' : '文件'
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatSize(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
const units = ['KB', 'MB', 'GB', 'TB']
|
||||||
|
let value = bytes / 1024
|
||||||
|
let index = 0
|
||||||
|
while (value >= 1024 && index < units.length - 1) {
|
||||||
|
value /= 1024
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[index]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null) {
|
||||||
|
if (!value) return '未扫描'
|
||||||
|
return new Date(value).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setError(error: unknown) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : '操作失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRoots() {
|
||||||
|
roots.value = await api.getRoots()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDrives() {
|
||||||
|
drives.value = await api.getDrives()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDirectory(path: string) {
|
||||||
|
try {
|
||||||
|
errorMessage.value = ''
|
||||||
|
currentPath.value = path
|
||||||
|
manualPath.value = path
|
||||||
|
directories.value = await api.getDirectories(path)
|
||||||
|
} catch (error) {
|
||||||
|
setError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRoot(path = manualPath.value) {
|
||||||
|
try {
|
||||||
|
errorMessage.value = ''
|
||||||
|
loading.value = true
|
||||||
|
await api.addRoot({ path, scanIntervalMinutes: 5 })
|
||||||
|
await Promise.all([loadRoots(), loadFiles()])
|
||||||
|
} catch (error) {
|
||||||
|
setError(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRoot(root: LibraryRootDto) {
|
||||||
|
try {
|
||||||
|
errorMessage.value = ''
|
||||||
|
await api.setRootEnabled(root.id, !root.isEnabled)
|
||||||
|
await loadRoots()
|
||||||
|
} catch (error) {
|
||||||
|
setError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRoot(root: LibraryRootDto) {
|
||||||
|
try {
|
||||||
|
errorMessage.value = ''
|
||||||
|
await api.deleteRoot(root.id)
|
||||||
|
if (rootId.value === root.id) rootId.value = undefined
|
||||||
|
await Promise.all([loadRoots(), loadFiles()])
|
||||||
|
} catch (error) {
|
||||||
|
setError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanRoot(root: LibraryRootDto) {
|
||||||
|
try {
|
||||||
|
errorMessage.value = ''
|
||||||
|
scanningId.value = root.id
|
||||||
|
await api.scanRoot(root.id)
|
||||||
|
await Promise.all([loadRoots(), loadFiles()])
|
||||||
|
} catch (error) {
|
||||||
|
setError(error)
|
||||||
|
} finally {
|
||||||
|
scanningId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles(resetPage = false) {
|
||||||
|
try {
|
||||||
|
errorMessage.value = ''
|
||||||
|
if (resetPage) page.value = 1
|
||||||
|
const result = await api.searchFiles({
|
||||||
|
page: page.value,
|
||||||
|
pageSize,
|
||||||
|
mediaType: mediaType.value,
|
||||||
|
keyword: keyword.value,
|
||||||
|
rootId: rootId.value,
|
||||||
|
})
|
||||||
|
files.value = result.items
|
||||||
|
total.value = result.total
|
||||||
|
if (!selectedFile.value || !files.value.some((file) => file.id === selectedFile.value?.id)) {
|
||||||
|
selectedFile.value = null
|
||||||
|
textPreview.value = null
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openClientRoot(id: number) {
|
||||||
|
rootId.value = id
|
||||||
|
isBrowsingRoots.value = false
|
||||||
|
await loadFiles(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToRoots() {
|
||||||
|
isBrowsingRoots.value = true
|
||||||
|
rootId.value = undefined
|
||||||
|
selectedFile.value = null
|
||||||
|
textPreview.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectFile(file: FileRecordDto | null) {
|
||||||
|
selectedFile.value = file
|
||||||
|
textPreview.value = null
|
||||||
|
if (!file || file.mediaType !== 'text') return
|
||||||
|
|
||||||
|
try {
|
||||||
|
textPreview.value = await api.getTextPreview(file.id)
|
||||||
|
} catch (error) {
|
||||||
|
setError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePage(next: number) {
|
||||||
|
page.value = Math.min(Math.max(1, next), totalPages.value)
|
||||||
|
await loadFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
await Promise.all([loadRoots(), loadFiles()])
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await loadRoots()
|
||||||
|
if (isAdminPage.value) {
|
||||||
|
await loadDrives()
|
||||||
|
} else {
|
||||||
|
total.value = activeRoots.value.reduce((sum, root) => sum + root.fileCount, 0)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header>
|
<main v-if="isAdminPage" class="admin-shell">
|
||||||
<img alt="Vue logo" class="logo" src="./assets/logo.svg" width="125" height="125" />
|
<header class="admin-hero">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">FileShare Admin</p>
|
||||||
|
<h1>文件库管理</h1>
|
||||||
|
<p>添加服务器本机磁盘或目录,系统按状态定时扫描,异常目录会自动下线并停止自动扫描。</p>
|
||||||
|
</div>
|
||||||
|
<div class="admin-metrics">
|
||||||
|
<div>
|
||||||
|
<strong>{{ roots.length }}</strong>
|
||||||
|
<span>目录</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{{ activeRoots.length }}</strong>
|
||||||
|
<span>正常启用</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{{ totalRootFiles }}</strong>
|
||||||
|
<span>入库文件</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="wrapper">
|
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
|
||||||
<HelloWorld msg="You did it!" />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
<section class="admin-layout">
|
||||||
<TheWelcome />
|
<section class="admin-card path-card">
|
||||||
|
<div class="card-heading">
|
||||||
|
<div>
|
||||||
|
<h2>添加扫描目录</h2>
|
||||||
|
<p>选择服务器路径,或直接输入绝对路径。</p>
|
||||||
|
</div>
|
||||||
|
<a href="/" class="client-link">客户端</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>服务器路径</span>
|
||||||
|
<div class="inline-form">
|
||||||
|
<input v-model="manualPath" type="text" placeholder="例如 D:\Media 或 E:\" />
|
||||||
|
<button class="primary-button" type="button" :disabled="loading || !manualPath" @click="addRoot()">添加并扫描</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="admin-browser">
|
||||||
|
<div class="drive-list">
|
||||||
|
<h3>磁盘</h3>
|
||||||
|
<button
|
||||||
|
v-for="drive in drives"
|
||||||
|
:key="drive.name"
|
||||||
|
class="drive-row"
|
||||||
|
type="button"
|
||||||
|
:disabled="!drive.isReady"
|
||||||
|
@click="openDirectory(drive.rootDirectory)"
|
||||||
|
>
|
||||||
|
<span>{{ drive.displayName }}</span>
|
||||||
|
<small>{{ drive.driveType }} · {{ drive.availableFreeSpace !== null ? formatSize(drive.availableFreeSpace) : '不可用' }}</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="directory-list">
|
||||||
|
<div class="browser-header">
|
||||||
|
<h3>目录</h3>
|
||||||
|
<button type="button" class="text-button" :disabled="!currentPath" @click="addRoot(currentPath)">添加当前目录</button>
|
||||||
|
</div>
|
||||||
|
<p class="current-path">{{ currentPath || '请选择一个磁盘' }}</p>
|
||||||
|
<button
|
||||||
|
v-for="directory in directories"
|
||||||
|
:key="directory.fullPath"
|
||||||
|
class="directory-row"
|
||||||
|
type="button"
|
||||||
|
@click="openDirectory(directory.fullPath)"
|
||||||
|
>
|
||||||
|
{{ directory.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-card roots-card">
|
||||||
|
<div class="card-heading">
|
||||||
|
<div>
|
||||||
|
<h2>目录状态</h2>
|
||||||
|
<p>异常目录不会自动扫描,客户端也无法访问其中的文件;手动扫描成功后恢复正常。</p>
|
||||||
|
</div>
|
||||||
|
<button class="secondary-button" type="button" :disabled="loading" @click="refreshAll">刷新</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="root-table">
|
||||||
|
<article v-for="root in roots" :key="root.id" class="root-item">
|
||||||
|
<div class="root-main">
|
||||||
|
<span :class="['status-pill', root.isAvailable ? 'ok' : 'bad']">{{ root.isAvailable ? '正常' : '异常' }}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{{ root.displayName }}</strong>
|
||||||
|
<p>{{ root.path }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="root-meta">
|
||||||
|
<span>{{ root.fileCount }} 个文件</span>
|
||||||
|
<span>{{ formatDate(root.lastScanCompletedAt) }}</span>
|
||||||
|
<span>{{ root.isEnabled ? '启用' : '停用' }}</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="root.lastScanError" class="root-error">{{ root.lastScanError }}</p>
|
||||||
|
<div class="root-actions">
|
||||||
|
<button type="button" class="secondary-button" @click="scanRoot(root)">
|
||||||
|
{{ scanningId === root.id ? '扫描中' : root.isAvailable ? '立即扫描' : '手动扫描恢复' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="secondary-button" :disabled="!root.isAvailable" @click="toggleRoot(root)">
|
||||||
|
{{ root.isEnabled ? '停用' : '启用' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="danger-button" @click="deleteRoot(root)">删除</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<p v-if="roots.length === 0" class="empty-state">还没有添加扫描目录</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main v-else class="client-shell">
|
||||||
|
<header class="mobile-header">
|
||||||
|
<div>
|
||||||
|
<h1>{{ clientTitle }}</h1>
|
||||||
|
<p>{{ isBrowsingRoots ? `${activeRoots.length} 个目录` : `${total} 个文件` }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-header-actions">
|
||||||
|
<button v-if="!isBrowsingRoots" type="button" class="back-button" @click="backToRoots">返回</button>
|
||||||
|
<a href="/admin" class="admin-link">管理</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
|
||||||
|
|
||||||
|
<section v-if="isBrowsingRoots" class="root-picker">
|
||||||
|
<button
|
||||||
|
v-for="root in activeRoots"
|
||||||
|
:key="root.id"
|
||||||
|
class="root-tile"
|
||||||
|
type="button"
|
||||||
|
@click="openClientRoot(root.id)"
|
||||||
|
>
|
||||||
|
<span>{{ root.displayName }}</span>
|
||||||
|
<strong>{{ root.fileCount }}</strong>
|
||||||
|
<small>{{ root.path }}</small>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p v-if="activeRoots.length === 0" class="empty-state">暂无可访问目录</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else class="mobile-filters">
|
||||||
|
<input v-model="keyword" type="search" placeholder="搜索文件" @keyup.enter="loadFiles(true)" />
|
||||||
|
<div class="filter-row">
|
||||||
|
<select v-model="mediaType" @change="loadFiles(true)">
|
||||||
|
<option value="all">全部</option>
|
||||||
|
<option value="video">视频</option>
|
||||||
|
<option value="audio">音频</option>
|
||||||
|
<option value="text">文本</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="rootId" @change="loadFiles(true)">
|
||||||
|
<option v-for="root in availableRoots" :key="root.id" :value="root.id">{{ root.displayName }}</option>
|
||||||
|
</select>
|
||||||
|
<button class="primary-button" type="button" @click="loadFiles(true)">查询</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="!isBrowsingRoots" class="player-panel">
|
||||||
|
<p v-if="!selectedFile" class="empty-state">请选择一个文件</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="player-title">
|
||||||
|
<div>
|
||||||
|
<h2>{{ selectedFile.fileName }}</h2>
|
||||||
|
<p>{{ selectedRoot?.displayName ?? '文件库' }} · {{ selectedFile.relativePath }}</p>
|
||||||
|
</div>
|
||||||
|
<span>{{ selectedFile.extension }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<video
|
||||||
|
v-if="selectedFile.mediaType === 'video' && selectedFile.browserPlayable"
|
||||||
|
:key="selectedFile.id"
|
||||||
|
controls
|
||||||
|
playsinline
|
||||||
|
webkit-playsinline
|
||||||
|
preload="metadata"
|
||||||
|
>
|
||||||
|
<source :src="selectedMediaUrl" :type="selectedFile.contentType" />
|
||||||
|
</video>
|
||||||
|
<audio
|
||||||
|
v-else-if="selectedFile.mediaType === 'audio' && selectedFile.browserPlayable"
|
||||||
|
:key="selectedFile.id"
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
>
|
||||||
|
<source :src="selectedMediaUrl" :type="selectedFile.contentType" />
|
||||||
|
</audio>
|
||||||
|
<pre v-else-if="selectedFile.mediaType === 'text'">{{ textPreview?.content ?? '加载中...' }}</pre>
|
||||||
|
<p v-else class="unsupported">浏览器不支持在线播放此格式。</p>
|
||||||
|
<a
|
||||||
|
v-if="selectedFile.mediaType !== 'text'"
|
||||||
|
class="open-media-link"
|
||||||
|
:href="selectedMediaUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
新窗口打开原视频/音频
|
||||||
|
</a>
|
||||||
|
<p v-if="textPreview?.truncated" class="hint">文本超过 1 MB,已截断显示。</p>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="!isBrowsingRoots" class="mobile-list">
|
||||||
|
<button
|
||||||
|
v-for="file in files"
|
||||||
|
:key="file.id"
|
||||||
|
class="mobile-file"
|
||||||
|
:class="{ active: selectedFile?.id === file.id }"
|
||||||
|
type="button"
|
||||||
|
@click="selectFile(file)"
|
||||||
|
>
|
||||||
|
<span class="type-badge">{{ file.mediaType }}</span>
|
||||||
|
<span>
|
||||||
|
<strong>{{ file.fileName }}</strong>
|
||||||
|
<small>{{ file.relativePath }}</small>
|
||||||
|
<small>
|
||||||
|
{{ formatSize(file.sizeBytes) }} · {{ formatDate(file.lastWriteTimeUtc) }}
|
||||||
|
<template v-if="file.mediaType !== 'text' && !file.browserPlayable"> · 手机可能不支持</template>
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p v-if="files.length === 0" class="empty-state">暂无可查看文件</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav v-if="!isBrowsingRoots" class="mobile-pager">
|
||||||
|
<button type="button" :disabled="page <= 1" @click="changePage(page - 1)">上一页</button>
|
||||||
|
<span>{{ page }} / {{ totalPages }}</span>
|
||||||
|
<button type="button" :disabled="page >= totalPages" @click="changePage(page + 1)">下一页</button>
|
||||||
|
</nav>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
header {
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
padding-right: calc(var(--section-gap) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
margin: 0 2rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
header .wrapper {
|
|
||||||
display: flex;
|
|
||||||
place-items: flex-start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@ -4,8 +4,20 @@ import { isWebView2 } from './env'
|
|||||||
// WebView2 自定义协议前缀
|
// WebView2 自定义协议前缀
|
||||||
const WEBVIEW2_BASE = 'app://api/'
|
const WEBVIEW2_BASE = 'app://api/'
|
||||||
|
|
||||||
// 普通浏览器 HTTP API 地址,按需修改
|
// Vite 开发页走 5206 API;API 托管前端时使用同源地址。
|
||||||
const HTTP_BASE = 'http://localhost:5000/api/'
|
const isViteDevServer = window.location.port === '51552'
|
||||||
|
const HTTP_ORIGIN = isViteDevServer
|
||||||
|
? `${window.location.protocol}//${window.location.hostname || 'localhost'}:5206`
|
||||||
|
: window.location.origin
|
||||||
|
const HTTP_BASE = `${HTTP_ORIGIN}/api/`
|
||||||
|
|
||||||
|
export const apiOrigin = (): string => HTTP_ORIGIN
|
||||||
|
|
||||||
|
export const apiUrl = (path: string): string => {
|
||||||
|
if (/^https?:\/\//i.test(path)) return path
|
||||||
|
const normalized = path.startsWith('/') ? path : `/${path}`
|
||||||
|
return `${isWebView2() ? '' : HTTP_ORIGIN}${normalized}`
|
||||||
|
}
|
||||||
|
|
||||||
// ─── axios 实例 ────────────────────────────────────────────────────────────────
|
// ─── axios 实例 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -31,9 +43,9 @@ http.interceptors.request.use((config) => {
|
|||||||
// WebView2 桥接和 HTTP 两个环境返回结构相同,拦截器可统一处理
|
// WebView2 桥接和 HTTP 两个环境返回结构相同,拦截器可统一处理
|
||||||
http.interceptors.response.use(
|
http.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
const payload = response.data as { success: boolean; data?: unknown; error?: string }
|
const payload = response.data as { success: boolean; data?: unknown; error?: string; message?: string }
|
||||||
if (payload?.success === false) {
|
if (payload?.success === false) {
|
||||||
return Promise.reject(new Error(payload.error ?? '请求失败'))
|
return Promise.reject(new Error(payload.error ?? payload.message ?? '请求失败'))
|
||||||
}
|
}
|
||||||
return (payload?.data ?? payload) as never
|
return (payload?.data ?? payload) as never
|
||||||
},
|
},
|
||||||
@ -65,11 +77,9 @@ export async function request<T = unknown>(endpoint: string, options: RequestOpt
|
|||||||
headers: { 'Content-Type': 'application/json', ...(options.headers ?? {}) },
|
headers: { 'Content-Type': 'application/json', ...(options.headers ?? {}) },
|
||||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||||
})
|
})
|
||||||
const payload = await res.json() as { success: boolean; data?: T; error?: string }
|
const payload = await res.json() as { success: boolean; data?: T; error?: string; message?: string }
|
||||||
// eslint-disable-next-line no-debugger
|
|
||||||
debugger
|
|
||||||
if (payload?.success === false) {
|
if (payload?.success === false) {
|
||||||
throw new Error(payload.error ?? '请求失败')
|
throw new Error(payload.error ?? payload.message ?? '请求失败')
|
||||||
}
|
}
|
||||||
return (payload?.data ?? payload) as T
|
return (payload?.data ?? payload) as T
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,93 @@
|
|||||||
import { request } from './http'
|
import { apiUrl, request } from './http'
|
||||||
|
|
||||||
|
export type MediaType = 'all' | 'text' | 'video' | 'audio'
|
||||||
|
|
||||||
|
export interface DriveDto {
|
||||||
|
name: string
|
||||||
|
displayName: string
|
||||||
|
rootDirectory: string
|
||||||
|
driveType: string
|
||||||
|
totalSize: number | null
|
||||||
|
availableFreeSpace: number | null
|
||||||
|
isReady: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectoryDto {
|
||||||
|
name: string
|
||||||
|
fullPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LibraryRootDto {
|
||||||
|
id: number
|
||||||
|
path: string
|
||||||
|
displayName: string
|
||||||
|
isEnabled: boolean
|
||||||
|
isAvailable: boolean
|
||||||
|
scanIntervalMinutes: number
|
||||||
|
lastScanStartedAt: string | null
|
||||||
|
lastScanCompletedAt: string | null
|
||||||
|
lastScanError: string | null
|
||||||
|
fileCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileRecordDto {
|
||||||
|
id: number
|
||||||
|
libraryRootId: number
|
||||||
|
fileName: string
|
||||||
|
relativePath: string
|
||||||
|
extension: string
|
||||||
|
sizeBytes: number
|
||||||
|
lastWriteTimeUtc: string
|
||||||
|
mediaType: 'text' | 'video' | 'audio'
|
||||||
|
contentType: string
|
||||||
|
streamUrl: string
|
||||||
|
textUrl: string | null
|
||||||
|
browserPlayable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextPreviewDto {
|
||||||
|
id: number
|
||||||
|
fileName: string
|
||||||
|
content: string
|
||||||
|
truncated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedResponse<T> {
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const qs = (params: Record<string, string | number | undefined | null>) => {
|
||||||
|
const search = new URLSearchParams()
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value !== undefined && value !== null && value !== '') search.set(key, String(value))
|
||||||
|
}
|
||||||
|
const value = search.toString()
|
||||||
|
return value ? `?${value}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
// 业务接口定义,新增接口在此处添加一行即可
|
// 业务接口定义,新增接口在此处添加一行即可
|
||||||
export const api = {
|
export const api = {
|
||||||
getUser: () => request('getUser'),
|
getUser: () => request('getUser'),
|
||||||
processData: (input: string) => request('processData', { method: 'POST', body: { input } }),
|
processData: (input: string) => request('processData', { method: 'POST', body: { input } }),
|
||||||
wData: (input: string) => request('wData', { method: 'POST', body: { input } }),
|
wData: (input: string) => request('wData', { method: 'POST', body: { input } }),
|
||||||
|
getDrives: () => request<DriveDto[]>('library/drives'),
|
||||||
|
getDirectories: (path: string) => request<DirectoryDto[]>(`library/directories${qs({ path })}`),
|
||||||
|
getRoots: () => request<LibraryRootDto[]>('library/roots'),
|
||||||
|
addRoot: (body: { path: string; displayName?: string; scanIntervalMinutes?: number }) =>
|
||||||
|
request<LibraryRootDto>('library/roots', { method: 'POST', body }),
|
||||||
|
setRootEnabled: (id: number, isEnabled: boolean) =>
|
||||||
|
request<LibraryRootDto>('library/roots/enabled', { method: 'POST', body: { id, isEnabled } }),
|
||||||
|
deleteRoot: (id: number) =>
|
||||||
|
request('library/roots/delete', { method: 'POST', body: { id } }),
|
||||||
|
scanRoot: (id: number) =>
|
||||||
|
request<LibraryRootDto>('library/roots/scan', { method: 'POST', body: { id } }),
|
||||||
|
searchFiles: (params: { page: number; pageSize: number; mediaType?: MediaType; keyword?: string; rootId?: number }) =>
|
||||||
|
request<PagedResponse<FileRecordDto>>(`files${qs(params)}`),
|
||||||
|
getTextPreview: (id: number) =>
|
||||||
|
request<TextPreviewDto>(`files/text${qs({ id })}`),
|
||||||
|
mediaUrl: (path: string) => apiUrl(path),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,35 +1,652 @@
|
|||||||
@import './base.css';
|
@import './base.css';
|
||||||
|
|
||||||
#app {
|
:root {
|
||||||
max-width: 1280px;
|
--page: #f5f7fb;
|
||||||
margin: 0 auto;
|
--panel: #ffffff;
|
||||||
padding: 2rem;
|
--panel-strong: #101828;
|
||||||
font-weight: normal;
|
--line: #d9e1ea;
|
||||||
|
--text: #17202c;
|
||||||
|
--muted: #667085;
|
||||||
|
--accent: #0f766e;
|
||||||
|
--accent-strong: #115e59;
|
||||||
|
--danger: #b42318;
|
||||||
|
--danger-bg: #fff2f0;
|
||||||
|
--shadow: 0 18px 45px rgba(16, 24, 40, 0.10);
|
||||||
}
|
}
|
||||||
|
|
||||||
a,
|
* {
|
||||||
.green {
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-width: 320px;
|
||||||
|
background: var(--page);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
a {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
min-height: 38px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 13px;
|
||||||
|
color: var(--text);
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
color: var(--text);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: hsla(160, 100%, 37%, 1);
|
|
||||||
transition: 0.4s;
|
|
||||||
padding: 3px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
#app {
|
||||||
a:hover {
|
min-height: 100vh;
|
||||||
background-color: hsla(160, 100%, 37%, 0.2);
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
color: var(--text);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-button {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-button {
|
||||||
|
min-height: 30px;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
border: 1px solid #f1b8b2;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: var(--danger);
|
||||||
|
background: var(--danger-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state,
|
||||||
|
.hint,
|
||||||
|
.unsupported {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
width: min(1480px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 28px;
|
||||||
|
align-items: end;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 34px;
|
||||||
|
color: #fff;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(15, 118, 110, 0.96), rgba(16, 24, 40, 0.98)),
|
||||||
|
#101828;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: #a7f3d0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-hero h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-hero p {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: #d9f5ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 120px);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-metrics div {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-metrics strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-metrics span {
|
||||||
|
color: #d9f5ef;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(420px, 0.9fr) minmax(0, 1.1fr);
|
||||||
|
gap: 18px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 22px;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 10px 24px rgba(16, 24, 40, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-heading,
|
||||||
|
.browser-header,
|
||||||
|
.inline-form,
|
||||||
|
.root-actions,
|
||||||
|
.root-main,
|
||||||
|
.root-meta,
|
||||||
|
.mobile-header,
|
||||||
|
.filter-row,
|
||||||
|
.player-title,
|
||||||
|
.mobile-pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-heading,
|
||||||
|
.browser-header,
|
||||||
|
.mobile-header,
|
||||||
|
.player-title {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-heading h2,
|
||||||
|
.drive-list h3,
|
||||||
|
.directory-list h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-heading p {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-link,
|
||||||
|
.admin-link {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-browser {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.8fr 1.2fr;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-list,
|
||||||
|
.directory-list {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-row,
|
||||||
|
.directory-row {
|
||||||
|
display: grid;
|
||||||
|
justify-items: start;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 54px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-row span,
|
||||||
|
.directory-row {
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 100%;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-row small,
|
||||||
|
.current-path {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-path {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-table {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-main {
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-main strong {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-main p,
|
||||||
|
.root-error {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-meta {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-meta span {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
background: #f2f4f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.ok {
|
||||||
|
color: #067647;
|
||||||
|
background: #dcfae6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.bad {
|
||||||
|
color: var(--danger);
|
||||||
|
background: var(--danger-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-shell {
|
||||||
|
width: min(860px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
|
margin: -14px -14px 12px;
|
||||||
|
padding: 16px 14px 12px;
|
||||||
|
background: rgba(245, 247, 251, 0.94);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header p,
|
||||||
|
.player-title p,
|
||||||
|
.mobile-file small {
|
||||||
|
margin: 3px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-filters,
|
||||||
|
.player-panel,
|
||||||
|
.mobile-list,
|
||||||
|
.root-picker {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-picker {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-tile {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 4px 12px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 76px;
|
||||||
|
padding: 13px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-tile span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-tile strong {
|
||||||
|
grid-row: span 2;
|
||||||
|
align-self: center;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-tile small {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--muted);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row select {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-panel {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-title {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-title h2 {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-title span {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
background: #ccfbef;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-panel video {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 54vh;
|
||||||
|
margin-top: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-panel audio {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-media-link {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
background: #f0fdfa;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-panel pre {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 54vh;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #111827;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 68px;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-file.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: #ecfdf5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-file > span:last-child {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-file strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
flex: 0 0 52px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent-strong);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-pager {
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state,
|
||||||
|
.unsupported {
|
||||||
|
margin: 22px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.admin-layout,
|
||||||
|
.admin-browser {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-metrics {
|
||||||
|
grid-template-columns: repeat(3, minmax(90px, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (max-width: 780px) {
|
||||||
body {
|
.admin-shell {
|
||||||
display: flex;
|
padding: 14px;
|
||||||
place-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
.admin-hero {
|
||||||
display: grid;
|
grid-template-columns: 1fr;
|
||||||
grid-template-columns: 1fr 1fr;
|
padding: 22px;
|
||||||
padding: 0 2rem;
|
}
|
||||||
|
|
||||||
|
.admin-hero h1 {
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-metrics {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form,
|
||||||
|
.filter-row {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form button,
|
||||||
|
.filter-row button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.client-shell {
|
||||||
|
padding-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header {
|
||||||
|
position: static;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite'
|
||||||
import plugin from '@vitejs/plugin-vue';
|
import plugin from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [plugin()],
|
plugins: [plugin()],
|
||||||
server: {
|
build: {
|
||||||
port: 51552,
|
outDir: 'dist',
|
||||||
}
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 51552,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
BIN
app.db-shm
Normal file
BIN
app.db-shm
Normal file
Binary file not shown.
BIN
app.db-wal
Normal file
BIN
app.db-wal
Normal file
Binary file not shown.
310
logs/log-20260521.txt
Normal file
310
logs/log-20260521.txt
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
2026-05-21 16:16:06.522 [INF] Avalonia-API 正在启动...
|
||||||
|
2026-05-21 16:16:06.716 [INF] 正在初始化数据库 Provider="SQLite", AppVersion=1.0.0+e3fe965f108e748a0bfa13f6f122d650d73575d9
|
||||||
|
2026-05-21 16:16:07.218 [INF] 未检测到已应用迁移,将按当前 Provider="SQLite" 从 0 构建完整表结构
|
||||||
|
2026-05-21 16:16:07.219 [INF] 当前已应用 0 个迁移,检测到 5 个待执行迁移: 20260514000100_InitialCreate, 20260515072045_AutoMigration_20260515152037, 20260515085847_AutoMigration_20260515165835, 20260520083230_AutoMigration_20260520163216, 20260521080213_AddFileLibrary
|
||||||
|
2026-05-21 16:16:07.518 [INF] 数据库迁移完成(5 个迁移已应用)
|
||||||
|
2026-05-21 16:16:07.537 [WRN] The WebRootPath was not found: D:\Project\FileShare\wwwroot. Static files may be unavailable.
|
||||||
|
2026-05-21 16:16:07.553 [INF] User profile is available. Using 'C:\Users\Administrator\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||||
|
2026-05-21 16:16:07.574 [ERR] An exception occurred while trying to decrypt the element.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
2026-05-21 16:16:07.584 [ERR] An exception occurred while processing the key element '<key id="8ddb637d-22e6-47ce-8bac-51dcba504349" version="1" />'.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
2026-05-21 16:16:07.791 [ERR] An exception occurred while trying to decrypt the element.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
2026-05-21 16:16:07.792 [ERR] An exception occurred while processing the key element '<key id="8ddb637d-22e6-47ce-8bac-51dcba504349" version="1" />'.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
2026-05-21 16:16:08.004 [ERR] An exception occurred while trying to decrypt the element.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
2026-05-21 16:16:08.004 [ERR] An exception occurred while processing the key element '<key id="8ddb637d-22e6-47ce-8bac-51dcba504349" version="1" />'.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
2026-05-21 16:16:08.213 [ERR] An exception occurred while trying to decrypt the element.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
2026-05-21 16:16:08.213 [ERR] An exception occurred while processing the key element '<key id="8ddb637d-22e6-47ce-8bac-51dcba504349" version="1" />'.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
2026-05-21 16:16:08.417 [ERR] An exception occurred while trying to decrypt the element.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
2026-05-21 16:16:08.417 [ERR] An exception occurred while processing the key element '<key id="8ddb637d-22e6-47ce-8bac-51dcba504349" version="1" />'.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
2026-05-21 16:16:08.628 [ERR] An exception occurred while trying to decrypt the element.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
2026-05-21 16:16:08.629 [ERR] An exception occurred while processing the key element '<key id="8ddb637d-22e6-47ce-8bac-51dcba504349" version="1" />'.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
2026-05-21 16:16:08.837 [ERR] An exception occurred while trying to decrypt the element.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
2026-05-21 16:16:08.838 [ERR] An exception occurred while processing the key element '<key id="8ddb637d-22e6-47ce-8bac-51dcba504349" version="1" />'.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
2026-05-21 16:16:09.048 [ERR] An exception occurred while trying to decrypt the element.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
2026-05-21 16:16:09.049 [ERR] An exception occurred while processing the key element '<key id="8ddb637d-22e6-47ce-8bac-51dcba504349" version="1" />'.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
2026-05-21 16:16:09.252 [ERR] An exception occurred while trying to decrypt the element.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
2026-05-21 16:16:09.252 [ERR] An exception occurred while processing the key element '<key id="8ddb637d-22e6-47ce-8bac-51dcba504349" version="1" />'.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
2026-05-21 16:16:09.467 [ERR] An exception occurred while trying to decrypt the element.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
2026-05-21 16:16:09.467 [ERR] An exception occurred while processing the key element '<key id="8ddb637d-22e6-47ce-8bac-51dcba504349" version="1" />'.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
2026-05-21 16:16:09.681 [ERR] An exception occurred while trying to decrypt the element.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
2026-05-21 16:16:09.682 [ERR] An exception occurred while processing the key element '<key id="8ddb637d-22e6-47ce-8bac-51dcba504349" version="1" />'.
|
||||||
|
System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
2026-05-21 16:16:09.685 [WRN] Key {8ddb637d-22e6-47ce-8bac-51dcba504349} is ineligible to be the default key because its CreateEncryptor method failed after the maximum number of retries.
|
||||||
|
System.AggregateException: One or more errors occurred. (Error occurred during a cryptographic operation.) (Error occurred during a cryptographic operation.) (Error occurred during a cryptographic operation.) (Error occurred during a cryptographic operation.) (Error occurred during a cryptographic operation.) (Error occurred during a cryptographic operation.) (Error occurred during a cryptographic operation.) (Error occurred during a cryptographic operation.) (Error occurred during a cryptographic operation.) (Error occurred during a cryptographic operation.) (Error occurred during a cryptographic operation.)
|
||||||
|
---> System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)
|
||||||
|
--- End of inner exception stack trace ---
|
||||||
|
---> (Inner Exception #1) System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)<---
|
||||||
|
|
||||||
|
---> (Inner Exception #2) System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)<---
|
||||||
|
|
||||||
|
---> (Inner Exception #3) System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)<---
|
||||||
|
|
||||||
|
---> (Inner Exception #4) System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)<---
|
||||||
|
|
||||||
|
---> (Inner Exception #5) System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)<---
|
||||||
|
|
||||||
|
---> (Inner Exception #6) System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)<---
|
||||||
|
|
||||||
|
---> (Inner Exception #7) System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)<---
|
||||||
|
|
||||||
|
---> (Inner Exception #8) System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)<---
|
||||||
|
|
||||||
|
---> (Inner Exception #9) System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)<---
|
||||||
|
|
||||||
|
---> (Inner Exception #10) System.Security.Cryptography.CryptographicException: Error occurred during a cryptographic operation.
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapiCore(Byte* pbProtectedData, UInt32 cbProtectedData, Byte* pbOptionalEntropy, UInt32 cbOptionalEntropy)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Cng.DpapiSecretSerializerHelper.UnprotectWithDpapi(Byte[] protectedSecret)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.DpapiXmlDecryptor.Decrypt(XElement encryptedElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlEncryptionExtensions.DecryptElement(XElement element, IActivator activator)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.get_Descriptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.CngGcmAuthenticatedEncryptorFactory.CreateEncryptorInstance(IKey key)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.Key.CreateEncryptor()
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.DefaultKeyResolver.CanCreateAuthenticatedEncryptor(IKey key, Int32& retriesRemaining)<---
|
||||||
|
|
||||||
|
2026-05-21 16:16:09.689 [INF] Creating key {ae945183-411d-4f02-bb46-18985da84c2c} with creation date 2026-05-21 08:16:07Z, activation date 2026-05-21 08:16:07Z, and expiration date 2026-08-19 08:16:07Z.
|
||||||
|
2026-05-21 16:16:09.698 [ERR] An error occurred while reading the key ring.
|
||||||
|
System.UnauthorizedAccessException: Access to the path 'C:\Users\Administrator\AppData\Local\ASP.NET\DataProtection-Keys\32a7edcd-b47a-4345-a5b4-266014323e5e.tmp' is denied.
|
||||||
|
at Microsoft.Win32.SafeHandles.SafeFileHandle.CreateFile(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options)
|
||||||
|
at Microsoft.Win32.SafeHandles.SafeFileHandle.Open(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)
|
||||||
|
at System.IO.Strategies.OSFileStreamStrategy..ctor(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)
|
||||||
|
at System.IO.Strategies.FileStreamHelpers.ChooseStrategyCore(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)
|
||||||
|
at System.IO.File.OpenWrite(String path)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Repositories.FileSystemXmlRepository.StoreElementCore(XElement element, String filename)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Repositories.FileSystemXmlRepository.StoreElement(XElement element, String friendlyName)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.CreateNewKey(Guid keyId, DateTimeOffset creationDate, DateTimeOffset activationDate, DateTimeOffset expirationDate)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.CreateNewKey(DateTimeOffset activationDate, DateTimeOffset expirationDate)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.KeyRingProvider.CreateCacheableKeyRingCore(DateTimeOffset now, IKey keyJustAdded)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.KeyRingProvider.<GetCurrentKeyRingCoreNew>b__31_1(Object utcNowState)
|
||||||
|
at System.Threading.Tasks.Task`1.InnerInvoke()
|
||||||
|
at System.Threading.ExecutionContext.RunFromThreadPoolDispatchLoop(Thread threadPoolThread, ExecutionContext executionContext, ContextCallback callback, Object state)
|
||||||
|
--- End of stack trace from previous location ---
|
||||||
|
at System.Threading.ExecutionContext.RunFromThreadPoolDispatchLoop(Thread threadPoolThread, ExecutionContext executionContext, ContextCallback callback, Object state)
|
||||||
|
at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread)
|
||||||
|
--- End of stack trace from previous location ---
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.KeyRingProvider.GetKeyRingFromCompletedTaskUnsynchronized(Task`1 task, DateTime utcNow)
|
||||||
|
2026-05-21 16:16:09.700 [INF] Key ring failed to load during application startup.
|
||||||
|
System.UnauthorizedAccessException: Access to the path 'C:\Users\Administrator\AppData\Local\ASP.NET\DataProtection-Keys\32a7edcd-b47a-4345-a5b4-266014323e5e.tmp' is denied.
|
||||||
|
at Microsoft.Win32.SafeHandles.SafeFileHandle.CreateFile(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options)
|
||||||
|
at Microsoft.Win32.SafeHandles.SafeFileHandle.Open(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)
|
||||||
|
at System.IO.Strategies.OSFileStreamStrategy..ctor(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)
|
||||||
|
at System.IO.Strategies.FileStreamHelpers.ChooseStrategyCore(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)
|
||||||
|
at System.IO.File.OpenWrite(String path)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Repositories.FileSystemXmlRepository.StoreElementCore(XElement element, String filename)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Repositories.FileSystemXmlRepository.StoreElement(XElement element, String friendlyName)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.IInternalXmlKeyManager.CreateNewKey(Guid keyId, DateTimeOffset creationDate, DateTimeOffset activationDate, DateTimeOffset expirationDate)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.CreateNewKey(DateTimeOffset activationDate, DateTimeOffset expirationDate)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.KeyRingProvider.CreateCacheableKeyRingCore(DateTimeOffset now, IKey keyJustAdded)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.KeyRingProvider.<GetCurrentKeyRingCoreNew>b__31_1(Object utcNowState)
|
||||||
|
at System.Threading.Tasks.Task`1.InnerInvoke()
|
||||||
|
at System.Threading.ExecutionContext.RunFromThreadPoolDispatchLoop(Thread threadPoolThread, ExecutionContext executionContext, ContextCallback callback, Object state)
|
||||||
|
--- End of stack trace from previous location ---
|
||||||
|
at System.Threading.ExecutionContext.RunFromThreadPoolDispatchLoop(Thread threadPoolThread, ExecutionContext executionContext, ContextCallback callback, Object state)
|
||||||
|
at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread)
|
||||||
|
--- End of stack trace from previous location ---
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.KeyRingProvider.GetKeyRingFromCompletedTaskUnsynchronized(Task`1 task, DateTime utcNow)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.KeyManagement.KeyRingProvider.GetCurrentKeyRingCoreNew(DateTime utcNow, Boolean forceRefresh)
|
||||||
|
at Microsoft.AspNetCore.DataProtection.Internal.DataProtectionHostedService.StartAsync(CancellationToken token)
|
||||||
|
2026-05-21 16:16:09.781 [WRN] The WebRootPath was not found: D:\Project\FileShare\wwwroot. Static files may be unavailable.
|
||||||
|
2026-05-21 16:16:09.795 [INF] Now listening on: http://localhost:5206
|
||||||
|
2026-05-21 16:16:09.796 [INF] Application started. Press Ctrl+C to shut down.
|
||||||
|
2026-05-21 16:16:09.796 [INF] Hosting environment: Production
|
||||||
|
2026-05-21 16:16:09.797 [INF] Content root path: D:\Project\FileShare
|
||||||
Loading…
x
Reference in New Issue
Block a user