From 6ef410fdfa8b6917ddac597b41fb37b7cd49ffdc Mon Sep 17 00:00:00 2001 From: luoqian <2769838458@qq.com> Date: Fri, 22 May 2026 17:44:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=87=E4=BB=B6=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E3=80=81=E8=A7=86=E9=A2=91=E7=BB=AD=E6=92=AD=E4=B8=8E=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E6=96=87=E4=BB=B6=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端 Header 新增搜索栏,接入已有 SearchFiles API,结果支持列表/网格视图 - 新增 PlaybackPosition 数据库列与 /api/files/progress 端点,播放进度存服务端 - 播放中每 5 秒自动保存进度,再次打开视频时弹出"继续播放"提示 - 目录浏览新增媒体类型过滤条(全部/视频/音频/文本),前端即时过滤 - 新增 4 种数据库迁移(AddPlaybackPosition) --- ...0522092342_AddPlaybackPosition.Designer.cs | 448 +++++++++++++++++ .../20260522092342_AddPlaybackPosition.cs | 28 ++ .../MySQL/MySqlAppDataContextModelSnapshot.cs | 4 + ...0522092330_AddPlaybackPosition.Designer.cs | 463 ++++++++++++++++++ .../20260522092330_AddPlaybackPosition.cs | 28 ++ .../PostgreSqlAppDataContextModelSnapshot.cs | 4 + ...0522092304_AddPlaybackPosition.Designer.cs | 446 +++++++++++++++++ .../20260522092304_AddPlaybackPosition.cs | 28 ++ .../SqliteAppDataContextModelSnapshot.cs | 4 + ...0522092317_AddPlaybackPosition.Designer.cs | 463 ++++++++++++++++++ .../20260522092317_AddPlaybackPosition.cs | 28 ++ .../SqlServerAppDataContextModelSnapshot.cs | 4 + FileShare-EFCore/Models/ManagedFileRecord.cs | 4 + FileShare-Services/Endpoints/AppEndpoints.cs | 4 + .../FileLibrary/FileLibraryContracts.cs | 10 +- .../FileLibrary/FileLibraryEndpointService.cs | 7 + .../FileLibrary/FileLibraryService.cs | 14 +- .../IFileLibraryEndpointService.cs | 7 + .../FileLibrary/IFileLibraryService.cs | 8 + FileShare-Web-VUE/src/api/index.ts | 3 + FileShare-Web-VUE/src/assets/main.css | 144 ++++++ .../src/components/ClientPage.vue | 251 +++++++++- 22 files changed, 2386 insertions(+), 14 deletions(-) create mode 100644 FileShare-EFCore/Migrations/MySQL/20260522092342_AddPlaybackPosition.Designer.cs create mode 100644 FileShare-EFCore/Migrations/MySQL/20260522092342_AddPlaybackPosition.cs create mode 100644 FileShare-EFCore/Migrations/PostgreSQL/20260522092330_AddPlaybackPosition.Designer.cs create mode 100644 FileShare-EFCore/Migrations/PostgreSQL/20260522092330_AddPlaybackPosition.cs create mode 100644 FileShare-EFCore/Migrations/SQLite/20260522092304_AddPlaybackPosition.Designer.cs create mode 100644 FileShare-EFCore/Migrations/SQLite/20260522092304_AddPlaybackPosition.cs create mode 100644 FileShare-EFCore/Migrations/SqlServer/20260522092317_AddPlaybackPosition.Designer.cs create mode 100644 FileShare-EFCore/Migrations/SqlServer/20260522092317_AddPlaybackPosition.cs diff --git a/FileShare-EFCore/Migrations/MySQL/20260522092342_AddPlaybackPosition.Designer.cs b/FileShare-EFCore/Migrations/MySQL/20260522092342_AddPlaybackPosition.Designer.cs new file mode 100644 index 0000000..91c2f38 --- /dev/null +++ b/FileShare-EFCore/Migrations/MySQL/20260522092342_AddPlaybackPosition.Designer.cs @@ -0,0 +1,448 @@ +// +using System; +using FileShare_EFCore.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FileShare_EFCore.Migrations.MySQL +{ + [DbContext(typeof(MySqlAppDataContext))] + [Migration("20260522092342_AddPlaybackPosition")] + partial class AddPlaybackPosition + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("FileShare_EFCore.Models.ApiRefreshTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("created-at"); + + b.Property("Device") + .HasMaxLength(200) + .HasColumnType("varchar(200)") + .HasColumnName("device"); + + b.Property("ExpiresAt") + .HasColumnType("datetime(6)") + .HasColumnName("expires-at"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("varchar(64)") + .HasColumnName("ip-address"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("varchar(128)") + .HasColumnName("replaced-by-token-hash"); + + b.Property("RevokedAt") + .HasColumnType("datetime(6)") + .HasColumnName("revoked-at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)") + .HasColumnName("token-hash"); + + b.Property("UserId") + .HasColumnType("int") + .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("FileShare_EFCore.Models.ManagedFileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("AbsolutePath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("varchar(2048)") + .HasColumnName("absolute-path"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)") + .HasColumnName("content-type"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("created-at"); + + b.Property("Exists") + .HasColumnType("tinyint(1)") + .HasColumnName("exists"); + + b.Property("Extension") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)") + .HasColumnName("extension"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("varchar(260)") + .HasColumnName("file-name"); + + b.Property("LastPlayedAt") + .HasColumnType("datetime(6)") + .HasColumnName("last-played-at"); + + b.Property("LastSeenAt") + .HasColumnType("datetime(6)") + .HasColumnName("last-seen-at"); + + b.Property("LastWriteTimeUtc") + .HasColumnType("datetime(6)") + .HasColumnName("last-write-time-utc"); + + b.Property("LibraryRootId") + .HasColumnType("int") + .HasColumnName("library-root-id"); + + b.Property("MediaType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasColumnName("media-type"); + + b.Property("PlaybackPosition") + .HasColumnType("double") + .HasColumnName("playback-position"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("varchar(1024)") + .HasColumnName("relative-path"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size-bytes"); + + b.Property("ThumbnailId") + .HasColumnType("int") + .HasColumnName("thumbnail-id"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("updated-at"); + + b.Property("VideoDuration") + .HasColumnType("double") + .HasColumnName("video-duration"); + + b.HasKey("Id") + .HasName("pk-managed-file-record"); + + b.HasIndex("AbsolutePath") + .IsUnique() + .HasDatabaseName("idx-managed-file-record-absolute-path"); + + b.HasIndex("LastPlayedAt") + .HasDatabaseName("idx-managed-file-record-last-played-at"); + + b.HasIndex("LibraryRootId") + .HasDatabaseName("idx-managed-file-record-root-id"); + + b.HasIndex("ThumbnailId") + .HasDatabaseName("idx-managed-file-record-thumbnail-id"); + + b.HasIndex("MediaType", "Exists") + .HasDatabaseName("idx-managed-file-record-media-type-exists"); + + b.ToTable("managed-file-record", t => + { + t.HasComment("文件库文件记录"); + }); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("created-at"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)") + .HasColumnName("display-name"); + + b.Property("IsAvailable") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true) + .HasColumnName("is-available"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)") + .HasColumnName("is-enabled"); + + b.Property("LastScanCompletedAt") + .HasColumnType("datetime(6)") + .HasColumnName("last-scan-completed-at"); + + b.Property("LastScanError") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)") + .HasColumnName("last-scan-error"); + + b.Property("LastScanStartedAt") + .HasColumnType("datetime(6)") + .HasColumnName("last-scan-started-at"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("varchar(1024)") + .HasColumnName("path"); + + b.Property("ScanIntervalMinutes") + .HasColumnType("int") + .HasColumnName("scan-interval-minutes"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)") + .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("FileShare_EFCore.Models.ManagedThumbnailMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)") + .HasColumnName("content-type"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("created-at"); + + b.Property("LibraryRootId") + .HasColumnType("int") + .HasColumnName("library-root-id"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("varchar(1024)") + .HasColumnName("relative-path"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("updated-at"); + + b.HasKey("Id") + .HasName("pk-managed-thumbnail-map"); + + b.HasIndex("LibraryRootId") + .HasDatabaseName("idx-managed-thumbnail-map-root-id"); + + b.HasIndex("RelativePath") + .IsUnique() + .HasDatabaseName("idx-managed-thumbnail-map-relative-path"); + + b.ToTable("managed-thumbnail-map", t => + { + t.HasComment("文件缩略图映射记录"); + }); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id") + .HasComment("用户主键"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("created-at") + .HasComment("创建时间"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("varchar(200)") + .HasColumnName("email") + .HasComment("用户邮箱"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("varchar(100)") + .HasColumnName("name") + .HasComment("用户名称"); + + b.Property("PasswordHash") + .HasMaxLength(200) + .HasColumnType("varchar(200)") + .HasColumnName("password-hash") + .HasComment("密码哈希值"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasColumnName("phone-number") + .HasComment("电话号码"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("updated-at") + .HasComment("更新时间"); + + b.HasKey("Id") + .HasName("pk-user"); + + b.ToTable("user", t => + { + t.HasComment("用户实体,演示数据库 CRUD 操作"); + }); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.WeatherForecastEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id") + .HasComment("天气预报主键"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("created-at") + .HasComment("创建时间"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date") + .HasComment("预报日期"); + + b.Property("Summary") + .HasMaxLength(200) + .HasColumnType("varchar(200)") + .HasColumnName("summary") + .HasComment("天气摘要"); + + b.Property("TemperatureC") + .HasColumnType("int") + .HasColumnName("temperature-c") + .HasComment("摄氏温度"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("updated-at") + .HasComment("更新时间"); + + b.HasKey("Id") + .HasName("pk-weather-forecast"); + + b.ToTable("weather-forecast", t => + { + t.HasComment("天气预报数据实体"); + }); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b => + { + b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot") + .WithMany("Files") + .HasForeignKey("LibraryRootId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FileShare_EFCore.Models.ManagedThumbnailMap", "Thumbnail") + .WithMany("Files") + .HasForeignKey("ThumbnailId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("LibraryRoot"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b => + { + b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot") + .WithMany("Thumbnails") + .HasForeignKey("LibraryRootId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LibraryRoot"); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b => + { + b.Navigation("Files"); + + b.Navigation("Thumbnails"); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b => + { + b.Navigation("Files"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FileShare-EFCore/Migrations/MySQL/20260522092342_AddPlaybackPosition.cs b/FileShare-EFCore/Migrations/MySQL/20260522092342_AddPlaybackPosition.cs new file mode 100644 index 0000000..6cae1f0 --- /dev/null +++ b/FileShare-EFCore/Migrations/MySQL/20260522092342_AddPlaybackPosition.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FileShare_EFCore.Migrations.MySQL +{ + /// + public partial class AddPlaybackPosition : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "playback-position", + table: "managed-file-record", + type: "double", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "playback-position", + table: "managed-file-record"); + } + } +} diff --git a/FileShare-EFCore/Migrations/MySQL/MySqlAppDataContextModelSnapshot.cs b/FileShare-EFCore/Migrations/MySQL/MySqlAppDataContextModelSnapshot.cs index d8f3226..add6e0d 100644 --- a/FileShare-EFCore/Migrations/MySQL/MySqlAppDataContextModelSnapshot.cs +++ b/FileShare-EFCore/Migrations/MySQL/MySqlAppDataContextModelSnapshot.cs @@ -140,6 +140,10 @@ namespace FileShare_EFCore.Migrations.MySQL .HasColumnType("varchar(20)") .HasColumnName("media-type"); + b.Property("PlaybackPosition") + .HasColumnType("double") + .HasColumnName("playback-position"); + b.Property("RelativePath") .IsRequired() .HasMaxLength(1024) diff --git a/FileShare-EFCore/Migrations/PostgreSQL/20260522092330_AddPlaybackPosition.Designer.cs b/FileShare-EFCore/Migrations/PostgreSQL/20260522092330_AddPlaybackPosition.Designer.cs new file mode 100644 index 0000000..88e03c3 --- /dev/null +++ b/FileShare-EFCore/Migrations/PostgreSQL/20260522092330_AddPlaybackPosition.Designer.cs @@ -0,0 +1,463 @@ +// +using System; +using FileShare_EFCore.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FileShare_EFCore.Migrations.PostgreSQL +{ + [DbContext(typeof(PostgreSqlAppDataContext))] + [Migration("20260522092330_AddPlaybackPosition")] + partial class AddPlaybackPosition + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FileShare_EFCore.Models.ApiRefreshTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created-at"); + + b.Property("Device") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("device"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires-at"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ip-address"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("replaced-by-token-hash"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked-at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token-hash"); + + b.Property("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("FileShare_EFCore.Models.ManagedFileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AbsolutePath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("absolute-path"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("content-type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created-at"); + + b.Property("Exists") + .HasColumnType("boolean") + .HasColumnName("exists"); + + b.Property("Extension") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("extension"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("character varying(260)") + .HasColumnName("file-name"); + + b.Property("LastPlayedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last-played-at"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last-seen-at"); + + b.Property("LastWriteTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last-write-time-utc"); + + b.Property("LibraryRootId") + .HasColumnType("integer") + .HasColumnName("library-root-id"); + + b.Property("MediaType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("media-type"); + + b.Property("PlaybackPosition") + .HasColumnType("double precision") + .HasColumnName("playback-position"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("relative-path"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size-bytes"); + + b.Property("ThumbnailId") + .HasColumnType("integer") + .HasColumnName("thumbnail-id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated-at"); + + b.Property("VideoDuration") + .HasColumnType("double precision") + .HasColumnName("video-duration"); + + b.HasKey("Id") + .HasName("pk-managed-file-record"); + + b.HasIndex("AbsolutePath") + .IsUnique() + .HasDatabaseName("idx-managed-file-record-absolute-path"); + + b.HasIndex("LastPlayedAt") + .HasDatabaseName("idx-managed-file-record-last-played-at"); + + b.HasIndex("LibraryRootId") + .HasDatabaseName("idx-managed-file-record-root-id"); + + b.HasIndex("ThumbnailId") + .HasDatabaseName("idx-managed-file-record-thumbnail-id"); + + b.HasIndex("MediaType", "Exists") + .HasDatabaseName("idx-managed-file-record-media-type-exists"); + + b.ToTable("managed-file-record", t => + { + t.HasComment("文件库文件记录"); + }); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created-at"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display-name"); + + b.Property("IsAvailable") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is-available"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is-enabled"); + + b.Property("LastScanCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last-scan-completed-at"); + + b.Property("LastScanError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last-scan-error"); + + b.Property("LastScanStartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last-scan-started-at"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("path"); + + b.Property("ScanIntervalMinutes") + .HasColumnType("integer") + .HasColumnName("scan-interval-minutes"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .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("FileShare_EFCore.Models.ManagedThumbnailMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("content-type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created-at"); + + b.Property("LibraryRootId") + .HasColumnType("integer") + .HasColumnName("library-root-id"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("relative-path"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated-at"); + + b.HasKey("Id") + .HasName("pk-managed-thumbnail-map"); + + b.HasIndex("LibraryRootId") + .HasDatabaseName("idx-managed-thumbnail-map-root-id"); + + b.HasIndex("RelativePath") + .IsUnique() + .HasDatabaseName("idx-managed-thumbnail-map-relative-path"); + + b.ToTable("managed-thumbnail-map", t => + { + t.HasComment("文件缩略图映射记录"); + }); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id") + .HasComment("用户主键"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created-at") + .HasComment("创建时间"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("email") + .HasComment("用户邮箱"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name") + .HasComment("用户名称"); + + b.Property("PasswordHash") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("password-hash") + .HasComment("密码哈希值"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("phone-number") + .HasComment("电话号码"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated-at") + .HasComment("更新时间"); + + b.HasKey("Id") + .HasName("pk-user"); + + b.ToTable("user", t => + { + t.HasComment("用户实体,演示数据库 CRUD 操作"); + }); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.WeatherForecastEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id") + .HasComment("天气预报主键"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created-at") + .HasComment("创建时间"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date") + .HasComment("预报日期"); + + b.Property("Summary") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("summary") + .HasComment("天气摘要"); + + b.Property("TemperatureC") + .HasColumnType("integer") + .HasColumnName("temperature-c") + .HasComment("摄氏温度"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated-at") + .HasComment("更新时间"); + + b.HasKey("Id") + .HasName("pk-weather-forecast"); + + b.ToTable("weather-forecast", t => + { + t.HasComment("天气预报数据实体"); + }); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b => + { + b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot") + .WithMany("Files") + .HasForeignKey("LibraryRootId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FileShare_EFCore.Models.ManagedThumbnailMap", "Thumbnail") + .WithMany("Files") + .HasForeignKey("ThumbnailId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("LibraryRoot"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b => + { + b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot") + .WithMany("Thumbnails") + .HasForeignKey("LibraryRootId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LibraryRoot"); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b => + { + b.Navigation("Files"); + + b.Navigation("Thumbnails"); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b => + { + b.Navigation("Files"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FileShare-EFCore/Migrations/PostgreSQL/20260522092330_AddPlaybackPosition.cs b/FileShare-EFCore/Migrations/PostgreSQL/20260522092330_AddPlaybackPosition.cs new file mode 100644 index 0000000..8a9e89a --- /dev/null +++ b/FileShare-EFCore/Migrations/PostgreSQL/20260522092330_AddPlaybackPosition.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FileShare_EFCore.Migrations.PostgreSQL +{ + /// + public partial class AddPlaybackPosition : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "playback-position", + table: "managed-file-record", + type: "double precision", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "playback-position", + table: "managed-file-record"); + } + } +} diff --git a/FileShare-EFCore/Migrations/PostgreSQL/PostgreSqlAppDataContextModelSnapshot.cs b/FileShare-EFCore/Migrations/PostgreSQL/PostgreSqlAppDataContextModelSnapshot.cs index 2134fc5..f84c2a0 100644 --- a/FileShare-EFCore/Migrations/PostgreSQL/PostgreSqlAppDataContextModelSnapshot.cs +++ b/FileShare-EFCore/Migrations/PostgreSQL/PostgreSqlAppDataContextModelSnapshot.cs @@ -147,6 +147,10 @@ namespace FileShare_EFCore.Migrations.PostgreSQL .HasColumnType("character varying(20)") .HasColumnName("media-type"); + b.Property("PlaybackPosition") + .HasColumnType("double precision") + .HasColumnName("playback-position"); + b.Property("RelativePath") .IsRequired() .HasMaxLength(1024) diff --git a/FileShare-EFCore/Migrations/SQLite/20260522092304_AddPlaybackPosition.Designer.cs b/FileShare-EFCore/Migrations/SQLite/20260522092304_AddPlaybackPosition.Designer.cs new file mode 100644 index 0000000..1c7da55 --- /dev/null +++ b/FileShare-EFCore/Migrations/SQLite/20260522092304_AddPlaybackPosition.Designer.cs @@ -0,0 +1,446 @@ +// +using System; +using FileShare_EFCore.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FileShare_EFCore.Migrations.SQLite +{ + [DbContext(typeof(SqliteAppDataContext))] + [Migration("20260522092304_AddPlaybackPosition")] + partial class AddPlaybackPosition + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("FileShare_EFCore.Models.ApiRefreshTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created-at"); + + b.Property("Device") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("device"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT") + .HasColumnName("expires-at"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("ip-address"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("replaced-by-token-hash"); + + b.Property("RevokedAt") + .HasColumnType("TEXT") + .HasColumnName("revoked-at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("token-hash"); + + b.Property("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("FileShare_EFCore.Models.ManagedFileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("AbsolutePath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT") + .HasColumnName("absolute-path"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("content-type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created-at"); + + b.Property("Exists") + .HasColumnType("INTEGER") + .HasColumnName("exists"); + + b.Property("Extension") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT") + .HasColumnName("extension"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("TEXT") + .HasColumnName("file-name"); + + b.Property("LastPlayedAt") + .HasColumnType("TEXT") + .HasColumnName("last-played-at"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT") + .HasColumnName("last-seen-at"); + + b.Property("LastWriteTimeUtc") + .HasColumnType("TEXT") + .HasColumnName("last-write-time-utc"); + + b.Property("LibraryRootId") + .HasColumnType("INTEGER") + .HasColumnName("library-root-id"); + + b.Property("MediaType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("media-type"); + + b.Property("PlaybackPosition") + .HasColumnType("REAL") + .HasColumnName("playback-position"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT") + .HasColumnName("relative-path"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER") + .HasColumnName("size-bytes"); + + b.Property("ThumbnailId") + .HasColumnType("INTEGER") + .HasColumnName("thumbnail-id"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated-at"); + + b.Property("VideoDuration") + .HasColumnType("REAL") + .HasColumnName("video-duration"); + + b.HasKey("Id") + .HasName("pk-managed-file-record"); + + b.HasIndex("AbsolutePath") + .IsUnique() + .HasDatabaseName("idx-managed-file-record-absolute-path"); + + b.HasIndex("LastPlayedAt") + .HasDatabaseName("idx-managed-file-record-last-played-at"); + + b.HasIndex("LibraryRootId") + .HasDatabaseName("idx-managed-file-record-root-id"); + + b.HasIndex("ThumbnailId") + .HasDatabaseName("idx-managed-file-record-thumbnail-id"); + + b.HasIndex("MediaType", "Exists") + .HasDatabaseName("idx-managed-file-record-media-type-exists"); + + b.ToTable("managed-file-record", t => + { + t.HasComment("文件库文件记录"); + }); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created-at"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("display-name"); + + b.Property("IsAvailable") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("is-available"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER") + .HasColumnName("is-enabled"); + + b.Property("LastScanCompletedAt") + .HasColumnType("TEXT") + .HasColumnName("last-scan-completed-at"); + + b.Property("LastScanError") + .HasMaxLength(2000) + .HasColumnType("TEXT") + .HasColumnName("last-scan-error"); + + b.Property("LastScanStartedAt") + .HasColumnType("TEXT") + .HasColumnName("last-scan-started-at"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("ScanIntervalMinutes") + .HasColumnType("INTEGER") + .HasColumnName("scan-interval-minutes"); + + b.Property("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("FileShare_EFCore.Models.ManagedThumbnailMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("content-type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created-at"); + + b.Property("LibraryRootId") + .HasColumnType("INTEGER") + .HasColumnName("library-root-id"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT") + .HasColumnName("relative-path"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated-at"); + + b.HasKey("Id") + .HasName("pk-managed-thumbnail-map"); + + b.HasIndex("LibraryRootId") + .HasDatabaseName("idx-managed-thumbnail-map-root-id"); + + b.HasIndex("RelativePath") + .IsUnique() + .HasDatabaseName("idx-managed-thumbnail-map-relative-path"); + + b.ToTable("managed-thumbnail-map", t => + { + t.HasComment("文件缩略图映射记录"); + }); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id") + .HasComment("用户主键"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created-at") + .HasComment("创建时间"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("email") + .HasComment("用户邮箱"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name") + .HasComment("用户名称"); + + b.Property("PasswordHash") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("password-hash") + .HasComment("密码哈希值"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("phone-number") + .HasComment("电话号码"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated-at") + .HasComment("更新时间"); + + b.HasKey("Id") + .HasName("pk-user"); + + b.ToTable("user", t => + { + t.HasComment("用户实体,演示数据库 CRUD 操作"); + }); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.WeatherForecastEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id") + .HasComment("天气预报主键"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created-at") + .HasComment("创建时间"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("date") + .HasComment("预报日期"); + + b.Property("Summary") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("summary") + .HasComment("天气摘要"); + + b.Property("TemperatureC") + .HasColumnType("INTEGER") + .HasColumnName("temperature-c") + .HasComment("摄氏温度"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated-at") + .HasComment("更新时间"); + + b.HasKey("Id") + .HasName("pk-weather-forecast"); + + b.ToTable("weather-forecast", t => + { + t.HasComment("天气预报数据实体"); + }); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b => + { + b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot") + .WithMany("Files") + .HasForeignKey("LibraryRootId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FileShare_EFCore.Models.ManagedThumbnailMap", "Thumbnail") + .WithMany("Files") + .HasForeignKey("ThumbnailId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("LibraryRoot"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b => + { + b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot") + .WithMany("Thumbnails") + .HasForeignKey("LibraryRootId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LibraryRoot"); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b => + { + b.Navigation("Files"); + + b.Navigation("Thumbnails"); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b => + { + b.Navigation("Files"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FileShare-EFCore/Migrations/SQLite/20260522092304_AddPlaybackPosition.cs b/FileShare-EFCore/Migrations/SQLite/20260522092304_AddPlaybackPosition.cs new file mode 100644 index 0000000..50bee7e --- /dev/null +++ b/FileShare-EFCore/Migrations/SQLite/20260522092304_AddPlaybackPosition.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FileShare_EFCore.Migrations.SQLite +{ + /// + public partial class AddPlaybackPosition : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "playback-position", + table: "managed-file-record", + type: "REAL", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "playback-position", + table: "managed-file-record"); + } + } +} diff --git a/FileShare-EFCore/Migrations/SQLite/SqliteAppDataContextModelSnapshot.cs b/FileShare-EFCore/Migrations/SQLite/SqliteAppDataContextModelSnapshot.cs index 788881c..b71579a 100644 --- a/FileShare-EFCore/Migrations/SQLite/SqliteAppDataContextModelSnapshot.cs +++ b/FileShare-EFCore/Migrations/SQLite/SqliteAppDataContextModelSnapshot.cs @@ -138,6 +138,10 @@ namespace FileShare_EFCore.Migrations.SQLite .HasColumnType("TEXT") .HasColumnName("media-type"); + b.Property("PlaybackPosition") + .HasColumnType("REAL") + .HasColumnName("playback-position"); + b.Property("RelativePath") .IsRequired() .HasMaxLength(1024) diff --git a/FileShare-EFCore/Migrations/SqlServer/20260522092317_AddPlaybackPosition.Designer.cs b/FileShare-EFCore/Migrations/SqlServer/20260522092317_AddPlaybackPosition.Designer.cs new file mode 100644 index 0000000..ef70aff --- /dev/null +++ b/FileShare-EFCore/Migrations/SqlServer/20260522092317_AddPlaybackPosition.Designer.cs @@ -0,0 +1,463 @@ +// +using System; +using FileShare_EFCore.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FileShare_EFCore.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerAppDataContext))] + [Migration("20260522092317_AddPlaybackPosition")] + partial class AddPlaybackPosition + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("FileShare_EFCore.Models.ApiRefreshTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2") + .HasColumnName("created-at"); + + b.Property("Device") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("device"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2") + .HasColumnName("expires-at"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("ip-address"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("replaced-by-token-hash"); + + b.Property("RevokedAt") + .HasColumnType("datetime2") + .HasColumnName("revoked-at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("token-hash"); + + b.Property("UserId") + .HasColumnType("int") + .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("FileShare_EFCore.Models.ManagedFileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AbsolutePath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)") + .HasColumnName("absolute-path"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("content-type"); + + b.Property("CreatedAt") + .HasColumnType("datetime2") + .HasColumnName("created-at"); + + b.Property("Exists") + .HasColumnType("bit") + .HasColumnName("exists"); + + b.Property("Extension") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)") + .HasColumnName("extension"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)") + .HasColumnName("file-name"); + + b.Property("LastPlayedAt") + .HasColumnType("datetime2") + .HasColumnName("last-played-at"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2") + .HasColumnName("last-seen-at"); + + b.Property("LastWriteTimeUtc") + .HasColumnType("datetime2") + .HasColumnName("last-write-time-utc"); + + b.Property("LibraryRootId") + .HasColumnType("int") + .HasColumnName("library-root-id"); + + b.Property("MediaType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)") + .HasColumnName("media-type"); + + b.Property("PlaybackPosition") + .HasColumnType("float") + .HasColumnName("playback-position"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)") + .HasColumnName("relative-path"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size-bytes"); + + b.Property("ThumbnailId") + .HasColumnType("int") + .HasColumnName("thumbnail-id"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2") + .HasColumnName("updated-at"); + + b.Property("VideoDuration") + .HasColumnType("float") + .HasColumnName("video-duration"); + + b.HasKey("Id") + .HasName("pk-managed-file-record"); + + b.HasIndex("AbsolutePath") + .IsUnique() + .HasDatabaseName("idx-managed-file-record-absolute-path"); + + b.HasIndex("LastPlayedAt") + .HasDatabaseName("idx-managed-file-record-last-played-at"); + + b.HasIndex("LibraryRootId") + .HasDatabaseName("idx-managed-file-record-root-id"); + + b.HasIndex("ThumbnailId") + .HasDatabaseName("idx-managed-file-record-thumbnail-id"); + + b.HasIndex("MediaType", "Exists") + .HasDatabaseName("idx-managed-file-record-media-type-exists"); + + b.ToTable("managed-file-record", t => + { + t.HasComment("文件库文件记录"); + }); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2") + .HasColumnName("created-at"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("display-name"); + + b.Property("IsAvailable") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true) + .HasColumnName("is-available"); + + b.Property("IsEnabled") + .HasColumnType("bit") + .HasColumnName("is-enabled"); + + b.Property("LastScanCompletedAt") + .HasColumnType("datetime2") + .HasColumnName("last-scan-completed-at"); + + b.Property("LastScanError") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("last-scan-error"); + + b.Property("LastScanStartedAt") + .HasColumnType("datetime2") + .HasColumnName("last-scan-started-at"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)") + .HasColumnName("path"); + + b.Property("ScanIntervalMinutes") + .HasColumnType("int") + .HasColumnName("scan-interval-minutes"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2") + .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("FileShare_EFCore.Models.ManagedThumbnailMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("content-type"); + + b.Property("CreatedAt") + .HasColumnType("datetime2") + .HasColumnName("created-at"); + + b.Property("LibraryRootId") + .HasColumnType("int") + .HasColumnName("library-root-id"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)") + .HasColumnName("relative-path"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2") + .HasColumnName("updated-at"); + + b.HasKey("Id") + .HasName("pk-managed-thumbnail-map"); + + b.HasIndex("LibraryRootId") + .HasDatabaseName("idx-managed-thumbnail-map-root-id"); + + b.HasIndex("RelativePath") + .IsUnique() + .HasDatabaseName("idx-managed-thumbnail-map-relative-path"); + + b.ToTable("managed-thumbnail-map", t => + { + t.HasComment("文件缩略图映射记录"); + }); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id") + .HasComment("用户主键"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2") + .HasColumnName("created-at") + .HasComment("创建时间"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("email") + .HasComment("用户邮箱"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("name") + .HasComment("用户名称"); + + b.Property("PasswordHash") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("password-hash") + .HasComment("密码哈希值"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("phone-number") + .HasComment("电话号码"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2") + .HasColumnName("updated-at") + .HasComment("更新时间"); + + b.HasKey("Id") + .HasName("pk-user"); + + b.ToTable("user", t => + { + t.HasComment("用户实体,演示数据库 CRUD 操作"); + }); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.WeatherForecastEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id") + .HasComment("天气预报主键"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2") + .HasColumnName("created-at") + .HasComment("创建时间"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date") + .HasComment("预报日期"); + + b.Property("Summary") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("summary") + .HasComment("天气摘要"); + + b.Property("TemperatureC") + .HasColumnType("int") + .HasColumnName("temperature-c") + .HasComment("摄氏温度"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2") + .HasColumnName("updated-at") + .HasComment("更新时间"); + + b.HasKey("Id") + .HasName("pk-weather-forecast"); + + b.ToTable("weather-forecast", t => + { + t.HasComment("天气预报数据实体"); + }); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b => + { + b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot") + .WithMany("Files") + .HasForeignKey("LibraryRootId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FileShare_EFCore.Models.ManagedThumbnailMap", "Thumbnail") + .WithMany("Files") + .HasForeignKey("ThumbnailId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("LibraryRoot"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b => + { + b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot") + .WithMany("Thumbnails") + .HasForeignKey("LibraryRootId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LibraryRoot"); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b => + { + b.Navigation("Files"); + + b.Navigation("Thumbnails"); + }); + + modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b => + { + b.Navigation("Files"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FileShare-EFCore/Migrations/SqlServer/20260522092317_AddPlaybackPosition.cs b/FileShare-EFCore/Migrations/SqlServer/20260522092317_AddPlaybackPosition.cs new file mode 100644 index 0000000..30fe291 --- /dev/null +++ b/FileShare-EFCore/Migrations/SqlServer/20260522092317_AddPlaybackPosition.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FileShare_EFCore.Migrations.SqlServer +{ + /// + public partial class AddPlaybackPosition : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "playback-position", + table: "managed-file-record", + type: "float", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "playback-position", + table: "managed-file-record"); + } + } +} diff --git a/FileShare-EFCore/Migrations/SqlServer/SqlServerAppDataContextModelSnapshot.cs b/FileShare-EFCore/Migrations/SqlServer/SqlServerAppDataContextModelSnapshot.cs index 90669f8..04eb52d 100644 --- a/FileShare-EFCore/Migrations/SqlServer/SqlServerAppDataContextModelSnapshot.cs +++ b/FileShare-EFCore/Migrations/SqlServer/SqlServerAppDataContextModelSnapshot.cs @@ -147,6 +147,10 @@ namespace FileShare_EFCore.Migrations.SqlServer .HasColumnType("nvarchar(20)") .HasColumnName("media-type"); + b.Property("PlaybackPosition") + .HasColumnType("float") + .HasColumnName("playback-position"); + b.Property("RelativePath") .IsRequired() .HasMaxLength(1024) diff --git a/FileShare-EFCore/Models/ManagedFileRecord.cs b/FileShare-EFCore/Models/ManagedFileRecord.cs index 1f37e8f..d21f66e 100644 --- a/FileShare-EFCore/Models/ManagedFileRecord.cs +++ b/FileShare-EFCore/Models/ManagedFileRecord.cs @@ -79,6 +79,10 @@ namespace FileShare_EFCore.Models [Column("last-played-at")] public DateTime? LastPlayedAt { get; set; } + /// 视频播放位置(秒)。 + [Column("playback-position")] + public double? PlaybackPosition { get; set; } + /// 创建时间。 [Column("created-at")] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; diff --git a/FileShare-Services/Endpoints/AppEndpoints.cs b/FileShare-Services/Endpoints/AppEndpoints.cs index 90caea8..b77627c 100644 --- a/FileShare-Services/Endpoints/AppEndpoints.cs +++ b/FileShare-Services/Endpoints/AppEndpoints.cs @@ -77,6 +77,10 @@ namespace FileShare_Services.Endpoints .WithOpenApi("FileLibrary", "标记文件已播放。") .WithName("MarkFilePlayed"); + endpoints.MapPost("api/files/progress", (service, request, _) => service.SaveFileProgressAsync(request)) + .WithOpenApi("FileLibrary", "保存文件播放进度。") + .WithName("SaveFileProgress"); + endpoints.MapGet("api/files/detail", (service, request, _) => service.GetFileAsync(request)) .WithOpenApi("FileLibrary", "查询文件详情。") .WithName("GetFileDetail"); diff --git a/FileShare-Services/Services/FileLibrary/FileLibraryContracts.cs b/FileShare-Services/Services/FileLibrary/FileLibraryContracts.cs index 5f2e06d..98daf1b 100644 --- a/FileShare-Services/Services/FileLibrary/FileLibraryContracts.cs +++ b/FileShare-Services/Services/FileLibrary/FileLibraryContracts.cs @@ -59,6 +59,13 @@ namespace FileShare_Services.Services.FileLibrary public sealed record MarkFilePlayedRequest( [property: JsonPropertyName("id")] int Id); + /// + /// 保存文件播放进度的请求。 + /// + public sealed record SaveFileProgressRequest( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("position")] double Position); + /// /// 分页搜索已扫描文件的请求。 /// @@ -121,7 +128,8 @@ namespace FileShare_Services.Services.FileLibrary bool BrowserPlayable, string? ThumbnailUrl = null, double? VideoDuration = null, - DateTime? LastPlayedAt = null); + DateTime? LastPlayedAt = null, + double? PlaybackPosition = null); /// /// 浏览文件库目录结构的请求。 diff --git a/FileShare-Services/Services/FileLibrary/FileLibraryEndpointService.cs b/FileShare-Services/Services/FileLibrary/FileLibraryEndpointService.cs index 878af83..f74fbfa 100644 --- a/FileShare-Services/Services/FileLibrary/FileLibraryEndpointService.cs +++ b/FileShare-Services/Services/FileLibrary/FileLibraryEndpointService.cs @@ -101,6 +101,13 @@ namespace FileShare_Services.Services.FileLibrary return ResponseHelper.Succeed(); } + /// + public async Task SaveFileProgressAsync(SaveFileProgressRequest request) + { + await fileLibrary.SaveFileProgressAsync(request.Id, request.Position); + return ResponseHelper.Succeed(); + } + /// /// 验证文件 ID 是否有效,无效时抛出 。 /// diff --git a/FileShare-Services/Services/FileLibrary/FileLibraryService.cs b/FileShare-Services/Services/FileLibrary/FileLibraryService.cs index f8af9d6..a54687c 100644 --- a/FileShare-Services/Services/FileLibrary/FileLibraryService.cs +++ b/FileShare-Services/Services/FileLibrary/FileLibraryService.cs @@ -404,6 +404,17 @@ namespace FileShare_Services.Services.FileLibrary } } + /// + public async Task SaveFileProgressAsync(int id, double position, CancellationToken cancellationToken = default) + { + var record = await db.ManagedFileRecords.FindAsync([id], cancellationToken); + if (record is not null) + { + record.PlaybackPosition = Math.Max(0, position); + await db.SaveChangesAsync(cancellationToken); + } + } + /// /// 深度优先遍历目录树,枚举所有被 支持的媒体文件路径。 /// 遇到无权限的目录时跳过该分支继续遍历。 @@ -559,7 +570,8 @@ namespace FileShare_Services.Services.FileLibrary MediaFileTypes.IsBrowserPlayable(file.Extension), file.ThumbnailId is null ? null : $"/api/thumbnails/{file.ThumbnailId}", file.VideoDuration, - file.LastPlayedAt); + file.LastPlayedAt, + file.PlaybackPosition); } } diff --git a/FileShare-Services/Services/FileLibrary/IFileLibraryEndpointService.cs b/FileShare-Services/Services/FileLibrary/IFileLibraryEndpointService.cs index ed8a515..df68761 100644 --- a/FileShare-Services/Services/FileLibrary/IFileLibraryEndpointService.cs +++ b/FileShare-Services/Services/FileLibrary/IFileLibraryEndpointService.cs @@ -98,5 +98,12 @@ namespace FileShare_Services.Services.FileLibrary /// 包含文件 ID 的请求。 /// API 响应。 Task MarkFilePlayedAsync(MarkFilePlayedRequest request); + + /// + /// 保存文件播放进度位置。 + /// + /// 包含文件 ID 和播放位置的请求。 + /// API 响应。 + Task SaveFileProgressAsync(SaveFileProgressRequest request); } } diff --git a/FileShare-Services/Services/FileLibrary/IFileLibraryService.cs b/FileShare-Services/Services/FileLibrary/IFileLibraryService.cs index 485b473..84688d6 100644 --- a/FileShare-Services/Services/FileLibrary/IFileLibraryService.cs +++ b/FileShare-Services/Services/FileLibrary/IFileLibraryService.cs @@ -113,5 +113,13 @@ namespace FileShare_Services.Services.FileLibrary /// 文件记录 ID。 /// 取消令牌。 Task MarkFilePlayedAsync(int id, CancellationToken cancellationToken = default); + + /// + /// 保存文件的播放进度位置。 + /// + /// 文件记录 ID。 + /// 播放位置(秒)。 + /// 取消令牌。 + Task SaveFileProgressAsync(int id, double position, CancellationToken cancellationToken = default); } } diff --git a/FileShare-Web-VUE/src/api/index.ts b/FileShare-Web-VUE/src/api/index.ts index 1a686e4..1ee18b6 100644 --- a/FileShare-Web-VUE/src/api/index.ts +++ b/FileShare-Web-VUE/src/api/index.ts @@ -46,6 +46,7 @@ export interface FileRecordDto { thumbnailUrl: string | null videoDuration: number | null lastPlayedAt: string | null + playbackPosition: number | null } export interface BrowseDirectoryResponse { @@ -106,5 +107,7 @@ export const api = { request(`files/recent${qs({ type, count })}`), markFilePlayed: (id: number) => request('files/played', { method: 'POST', body: { id } }), + saveFileProgress: (id: number, position: number) => + request('files/progress', { method: 'POST', body: { id, position } }), qrCode: () => request<{ url: string; qrCodeBase64: string }>('qrcode'), } diff --git a/FileShare-Web-VUE/src/assets/main.css b/FileShare-Web-VUE/src/assets/main.css index cb944da..c264eb1 100644 --- a/FileShare-Web-VUE/src/assets/main.css +++ b/FileShare-Web-VUE/src/assets/main.css @@ -392,6 +392,8 @@ a { .mobile-header-actions { display: flex; align-items: center; + justify-content: flex-end; + min-width: 0; gap: 8px; } @@ -944,6 +946,118 @@ a { background: #111827; } +/* Search */ +.search-form { + display: flex; + align-items: center; + min-width: 0; + gap: 6px; +} + +.search-input { + flex: 1 1 auto; + min-width: 0; + min-height: 34px; + border: 1px solid var(--line); + border-radius: 10px; + padding: 4px 12px; + font-size: 14px; + background: var(--panel); + color: var(--text); + outline: none; + width: 180px; +} + +.search-input:focus { + border-color: var(--accent); +} + +.search-submit { + flex: 0 0 auto; + min-height: 34px; + border-radius: 10px; + padding: 4px 11px; + color: #fff; + background: var(--accent); + font-size: 14px; + font-weight: 700; +} + +.search-results-title { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + color: var(--muted); + font-size: 15px; + font-weight: 800; +} + +/* Filter chips */ +.filter-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.filter-chips button { + min-height: 28px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 2px 10px; + font-size: 13px; + font-weight: 600; + color: var(--muted); + background: transparent; +} + +.filter-chips button.active { + color: #fff; + background: var(--accent); + border-color: var(--accent); +} + +/* Resume banner */ +.resume-banner { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; + border: 1px solid var(--accent); + border-radius: 10px; + padding: 10px 14px; + background: #eef2ff; + font-size: 14px; + font-weight: 700; + color: var(--accent-strong); +} + +.resume-banner-actions { + display: flex; + gap: 8px; +} + +.resume-yes, +.resume-no { + min-height: 30px; + border: none; + border-radius: 8px; + padding: 4px 14px; + font-size: 13px; + font-weight: 700; +} + +.resume-yes { + color: #fff; + background: var(--accent); +} + +.resume-no { + color: var(--muted); + background: #f2f4f7; +} + /* Recent files section */ .recent-files { margin-top: 4px; @@ -988,6 +1102,36 @@ a { .filter-row button { width: 100%; } + + .mobile-header { + align-items: stretch; + flex-direction: column; + } + + .mobile-header-actions { + justify-content: flex-start; + flex-wrap: wrap; + } + + .search-form { + flex: 1 1 100%; + order: -1; + } + + .search-input { + width: 100%; + } + + .view-toggle-bar, + .section-header { + align-items: stretch; + flex-direction: column; + gap: 8px; + } + + .view-toggle { + align-self: flex-start; + } } @media (min-width: 900px) { diff --git a/FileShare-Web-VUE/src/components/ClientPage.vue b/FileShare-Web-VUE/src/components/ClientPage.vue index 3ca4e2e..b46c283 100644 --- a/FileShare-Web-VUE/src/components/ClientPage.vue +++ b/FileShare-Web-VUE/src/components/ClientPage.vue @@ -23,6 +23,22 @@ const viewMode = ref<'list' | 'grid'>('list') const qrModal = ref | null>(null) +// Search +const searchQuery = ref('') +const searchResults = ref([]) +const searchLoading = ref(false) +const isSearching = ref(false) + +// File filter +const filterType = ref<'all' | 'video' | 'audio' | 'text'>('all') + +// Video resume +const videoRef = ref(null) +const resumePosition = ref(0) +const showResumePrompt = ref(false) +const resumeRequested = ref(false) +let lastPositionSave = 0 + const activeRoots = computed(() => roots.value.filter((root) => root.isEnabled && root.isAvailable)) const selectedRoot = computed(() => roots.value.find((root) => root.id === selectedFile.value?.libraryRootId)) const currentBrowsePath = computed(() => browsePath.value.join('/')) @@ -30,15 +46,21 @@ const currentBrowsePath = computed(() => browsePath.value.join('/')) const breadcrumbs = computed(() => { const root = roots.value.find((r) => r.id === rootId.value) const items = [{ label: root?.displayName ?? '文件库', path: '' }] - for (let i = 0; i < browsePath.value.length; i++) { + browsePath.value.forEach((label, index) => { items.push({ - label: browsePath.value[i], - path: browsePath.value.slice(0, i + 1).join('/'), + label, + path: browsePath.value.slice(0, index + 1).join('/'), }) - } + }) return items }) +const filteredFiles = computed(() => { + if (!browseData.value) return [] + if (filterType.value === 'all') return browseData.value.files + return browseData.value.files.filter((f) => f.mediaType === filterType.value) +}) + const selectedMediaUrl = computed(() => selectedFile.value ? api.mediaUrl(selectedFile.value.streamUrl) : '') const selectedThumbnailUrl = computed(() => selectedFile.value?.thumbnailUrl ? api.thumbnailUrl(selectedFile.value.thumbnailUrl) : '' @@ -158,11 +180,146 @@ async function enterSubdirectory(name: string) { await browseDirectory() } +function changeFilter(type: 'all' | 'video' | 'audio' | 'text') { + filterType.value = type +} + +async function doSearch() { + const keyword = searchQuery.value.trim() + if (!keyword) return + try { + errorMessage.value = '' + searchLoading.value = true + isSearching.value = true + const response = await api.searchFiles({ page: 1, pageSize: 48, keyword }) + searchResults.value = response.items + } catch (error) { + setError(error) + } finally { + searchLoading.value = false + } +} + +function exitSearch() { + isSearching.value = false + searchQuery.value = '' + searchResults.value = [] +} + +function updatePlaybackPosition(id: number, position: number) { + if (selectedFile.value?.id === id) { + selectedFile.value.playbackPosition = position + } + + for (const files of [searchResults.value, recentFiles.value, browseData.value?.files]) { + const file = files?.find((item) => item.id === id) + if (file) { + file.playbackPosition = position + } + } +} + +function saveVideoProgress(position?: number) { + if (!selectedFile.value || selectedFile.value.mediaType !== 'video') return + const video = videoRef.value + if (!video) return + + const nextPosition = position ?? Math.floor(video.currentTime) + if (nextPosition < 0) return + + const fileId = selectedFile.value.id + api.saveFileProgress(fileId, nextPosition) + .then(() => updatePlaybackPosition(fileId, nextPosition)) + .catch(() => {}) +} + +function handleVideoTimeUpdate() { + const video = videoRef.value + if (!video || video.paused || video.currentTime === 0) return + + const now = Date.now() + if (now - lastPositionSave < 5000) return + lastPositionSave = now + saveVideoProgress() +} + +function handleVideoPause() { + const video = videoRef.value + if (!video || video.currentTime === 0 || video.ended) return + saveVideoProgress() +} + +function handleVideoPlay() { + seekToResumePosition() + showResumePrompt.value = false +} + +function handleVideoEnded() { + saveVideoProgress(0) +} + +function resetResume() { + resumePosition.value = 0 + resumeRequested.value = false + showResumePrompt.value = false +} + +function tryResume(file: FileRecordDto) { + if (file.mediaType !== 'video' || !file.playbackPosition || file.playbackPosition <= 0) return + resumePosition.value = file.playbackPosition + resumeRequested.value = true + showResumePrompt.value = true +} + +function seekToResumePosition() { + const video = videoRef.value + if (!video || !resumeRequested.value || resumePosition.value <= 0 || video.readyState < 1) return + + const maxPosition = Number.isFinite(video.duration) && video.duration > 1 + ? Math.max(0, video.duration - 1) + : resumePosition.value + video.currentTime = Math.min(resumePosition.value, maxPosition) + resumeRequested.value = false +} + +function resumePlayback() { + showResumePrompt.value = false + resumeRequested.value = true + seekToResumePosition() + videoRef.value?.play().catch(() => {}) +} + +function dismissResume() { + if (videoRef.value) { + videoRef.value.currentTime = 0 + } + resetResume() +} + +async function selectSearchFile(file: FileRecordDto) { + const relativeParts = file.relativePath.split(/[\\/]/).filter(Boolean) + relativeParts.pop() + + rootId.value = file.libraryRootId + browsePath.value = relativeParts + isBrowsingRoots.value = false + activeTab.value = 'libraries' + exitSearch() + await browseDirectory() + + await selectFile(browseData.value?.files.find((item) => item.id === file.id) ?? file) +} + async function selectFile(file: FileRecordDto) { + resetResume() + lastPositionSave = 0 selectedFile.value = file textPreview.value = null - if (file.mediaType === 'video' || file.mediaType === 'audio') { + if (file.mediaType === 'video') { + api.markFilePlayed(file.id).catch(() => {}) + tryResume(file) + } else if (file.mediaType === 'audio') { api.markFilePlayed(file.id).catch(() => {}) } @@ -203,6 +360,16 @@ onMounted(async () => {
+ +
+ + +
管理
@@ -211,7 +378,7 @@ onMounted(async () => {

{{ errorMessage }}

-