Compare commits

..

5 Commits

Author SHA1 Message Date
Zhanghu
13e6046b41 修改轮播项移动问题 2026-01-14 09:14:59 +08:00
Zhanghu
3c45381f28 改进轮播项 2026-01-13 18:11:20 +08:00
Zhanghu
037a6b2079 轮播应用OK 2026-01-13 17:57:48 +08:00
Zhanghu
f5d6db464e 修复在线设备数量问题 2026-01-13 15:45:24 +08:00
Zhanghu
6c362c0e87 修复在线设备数量 2026-01-13 15:37:51 +08:00
27 changed files with 2225 additions and 42 deletions

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ src/DRS9.Dashboard.Infrastructure/bin/
src/DRS9.Dashboard.Infrastructure/obj/ src/DRS9.Dashboard.Infrastructure/obj/
src/DRS9.Dashboard.Server/bin/ src/DRS9.Dashboard.Server/bin/
src/DRS9.Dashboard.Server/obj/ src/DRS9.Dashboard.Server/obj/
src/DRS9.Dashboard.Server/dashboard.db-shm
src/DRS9.Dashboard.Server/dashboard.db-wal

View File

@@ -15,9 +15,8 @@ public record ApplicationCreateRequest
[MaxLength(1000)] [MaxLength(1000)]
public string? Description { get; set; } public string? Description { get; set; }
[Required]
[MaxLength(2000)] [MaxLength(2000)]
public string ContentUrl { get; set; } = string.Empty; public string? ContentUrl { get; set; } = string.Empty; // 轮播类型不需要 ContentUrl
[MaxLength(500)] [MaxLength(500)]
public string? ThumbnailUrl { get; set; } public string? ThumbnailUrl { get; set; }

View File

@@ -0,0 +1,66 @@
using System.ComponentModel.DataAnnotations;
namespace DRS9.Dashboard.Application.DTOs;
/// <summary>
/// 轮播项 DTO
/// </summary>
public class RotatorItemDto
{
public int Id { get; set; }
public int ApplicationId { get; set; }
public string ItemType { get; set; } = string.Empty; // Image, Video, Webpage
public string Url { get; set; } = string.Empty;
public int Duration { get; set; } = 10; // 显示时长(秒)
public int Order { get; set; } = 0;
public bool IsEnabled { get; set; } = true;
}
/// <summary>
/// 创建轮播项请求
/// </summary>
public class RotatorItemCreateRequest
{
[Required]
public string ItemType { get; set; } = string.Empty;
[Required]
[Url]
public string Url { get; set; } = string.Empty;
[Range(1, 3600)]
public int Duration { get; set; } = 10;
public int Order { get; set; } = 0;
}
/// <summary>
/// 更新轮播项请求
/// </summary>
public class RotatorItemUpdateRequest
{
[Required]
public string ItemType { get; set; } = string.Empty;
[Required]
[Url]
public string Url { get; set; } = string.Empty;
[Range(1, 3600)]
public int Duration { get; set; } = 10;
public int Order { get; set; } = 0;
public bool IsEnabled { get; set; } = true;
}
/// <summary>
/// 文件上传响应
/// </summary>
public class FileUploadResponse
{
public bool Success { get; set; }
public string? Message { get; set; }
public string? Url { get; set; }
public string? ItemType { get; set; }
}

View File

@@ -46,12 +46,22 @@ public class ApplicationService
public async Task<ApplicationResponse> CreateAsync(ApplicationCreateRequest request) public async Task<ApplicationResponse> CreateAsync(ApplicationCreateRequest request)
{ {
// 验证Dashboard 类型必须有 ContentUrl
if (request.Type == "Dashboard" && string.IsNullOrWhiteSpace(request.ContentUrl))
{
return new ApplicationResponse
{
Success = false,
Message = "Dashboard 类型应用必须提供 URL"
};
}
var application = new AppEntity var application = new AppEntity
{ {
Name = request.Name, Name = request.Name,
Type = request.Type, Type = request.Type,
Description = request.Description, Description = request.Description,
ContentUrl = request.ContentUrl, ContentUrl = request.ContentUrl ?? "", // 轮播类型可以为空
ThumbnailUrl = request.ThumbnailUrl, ThumbnailUrl = request.ThumbnailUrl,
Priority = request.Priority, Priority = request.Priority,
IsEnabled = true IsEnabled = true

View File

@@ -212,7 +212,10 @@ public class DeviceManagementService
ApplicationId = da.ApplicationId, ApplicationId = da.ApplicationId,
ApplicationName = da.Application.Name, ApplicationName = da.Application.Name,
ApplicationType = da.Application.Type, ApplicationType = da.Application.Type,
ContentUrl = da.Application.ContentUrl, // 轮播类型使用轮播查看器 URL其他类型使用应用的 ContentUrl
ContentUrl = da.Application.Type == "Rotator"
? $"/rotator/{da.ApplicationId}"
: da.Application.ContentUrl,
Order = da.Order, Order = da.Order,
Duration = da.Duration Duration = da.Duration
}).ToList() }).ToList()

View File

@@ -0,0 +1,183 @@
using DRS9.Dashboard.Application.DTOs;
using DRS9.Dashboard.Domain.Entities;
using DRS9.Dashboard.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace DRS9.Dashboard.Application.Services;
public class RotatorItemService
{
private readonly DashboardDbContext _context;
public RotatorItemService(DashboardDbContext context)
{
_context = context;
}
/// <summary>
/// 获取指定应用的所有轮播项
/// </summary>
public async Task<List<RotatorItemDto>> GetByApplicationIdAsync(int applicationId)
{
return await _context.RotatorItems
.Where(ri => ri.ApplicationId == applicationId)
.OrderBy(ri => ri.Order)
.ThenBy(ri => ri.Id)
.Select(ri => new RotatorItemDto
{
Id = ri.Id,
ApplicationId = ri.ApplicationId,
ItemType = ri.ItemType,
Url = ri.Url,
Duration = ri.Duration,
Order = ri.Order,
IsEnabled = ri.IsEnabled
})
.ToListAsync();
}
/// <summary>
/// 获取轮播项详情
/// </summary>
public async Task<RotatorItemDto?> GetByIdAsync(int id)
{
return await _context.RotatorItems
.Where(ri => ri.Id == id)
.Select(ri => new RotatorItemDto
{
Id = ri.Id,
ApplicationId = ri.ApplicationId,
ItemType = ri.ItemType,
Url = ri.Url,
Duration = ri.Duration,
Order = ri.Order,
IsEnabled = ri.IsEnabled
})
.FirstOrDefaultAsync();
}
/// <summary>
/// 创建轮播项
/// </summary>
public async Task<RotatorItemDto?> CreateAsync(int applicationId, RotatorItemCreateRequest request)
{
// 验证应用是否存在且为 Rotator 类型
var application = await _context.Applications.FindAsync(applicationId);
if (application == null)
return null;
if (application.Type != "Rotator")
throw new InvalidOperationException("只能为轮播类型的应用添加轮播项");
// 如果没有指定 Order设置为当前最大值 + 1
if (request.Order == 0)
{
var maxOrder = await _context.RotatorItems
.Where(ri => ri.ApplicationId == applicationId)
.MaxAsync(ri => (int?)ri.Order) ?? 0;
request.Order = maxOrder + 1;
}
var rotatorItem = new RotatorItem
{
ApplicationId = applicationId,
ItemType = request.ItemType,
Url = request.Url,
Duration = request.Duration,
Order = request.Order,
IsEnabled = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
try
{
_context.RotatorItems.Add(rotatorItem);
await _context.SaveChangesAsync();
}
catch (Exception ex)
{
var innerEx = ex.InnerException;
string fullMessage = ex.Message;
while (innerEx != null)
{
fullMessage += " | Inner: " + innerEx.Message;
innerEx = innerEx.InnerException;
}
throw new InvalidOperationException(fullMessage, ex);
}
return await GetByIdAsync(rotatorItem.Id);
}
/// <summary>
/// 更新轮播项
/// </summary>
public async Task<RotatorItemDto?> UpdateAsync(int id, RotatorItemUpdateRequest request)
{
var rotatorItem = await _context.RotatorItems.FindAsync(id);
if (rotatorItem == null)
return null;
rotatorItem.ItemType = request.ItemType;
rotatorItem.Url = request.Url;
rotatorItem.Duration = request.Duration;
rotatorItem.Order = request.Order;
rotatorItem.IsEnabled = request.IsEnabled;
rotatorItem.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return await GetByIdAsync(id);
}
/// <summary>
/// 删除轮播项
/// </summary>
public async Task<bool> DeleteAsync(int id)
{
var rotatorItem = await _context.RotatorItems.FindAsync(id);
if (rotatorItem == null)
return false;
_context.RotatorItems.Remove(rotatorItem);
await _context.SaveChangesAsync();
return true;
}
/// <summary>
/// 批量更新轮播项顺序
/// </summary>
public async Task<bool> ReorderAsync(int applicationId, List<int> itemIds)
{
var items = await _context.RotatorItems
.Where(ri => ri.ApplicationId == applicationId)
.ToListAsync();
Console.WriteLine($"=== ReorderAsync called ===");
Console.WriteLine($"ApplicationId: {applicationId}");
Console.WriteLine($"Received itemIds: [{string.Join(", ", itemIds)}]");
Console.WriteLine($"Total items in DB: {items.Count}");
for (int i = 0; i < itemIds.Count; i++)
{
var item = items.FirstOrDefault(x => x.Id == itemIds[i]);
if (item != null)
{
var oldOrder = item.Order;
item.Order = i + 1;
item.UpdatedAt = DateTime.UtcNow;
Console.WriteLine($" Item {item.Id}: Order {oldOrder} -> {item.Order}");
}
else
{
Console.WriteLine($" WARNING: Item with ID {itemIds[i]} not found!");
}
}
var affected = await _context.SaveChangesAsync();
Console.WriteLine($"SaveChangesAsync affected: {affected} rows");
Console.WriteLine($"=== ReorderAsync completed ===");
return true;
}
}

View File

@@ -25,4 +25,5 @@ public class Application : BaseEntity
// 导航属性 // 导航属性
public ICollection<DeviceAssignment> Assignments { get; set; } = new List<DeviceAssignment>(); public ICollection<DeviceAssignment> Assignments { get; set; } = new List<DeviceAssignment>();
public ICollection<RotatorItem> RotatorItems { get; set; } = new List<RotatorItem>();
} }

View File

@@ -0,0 +1,38 @@
namespace DRS9.Dashboard.Domain.Entities;
/// <summary>
/// 轮播项 - 用于轮播应用的图片、视频、网页条目
/// </summary>
public class RotatorItem
{
public int Id { get; set; }
public int ApplicationId { get; set; }
/// <summary>
/// 条目类型Image, Video, Webpage
/// </summary>
public string ItemType { get; set; } = string.Empty;
/// <summary>
/// URL图片、视频或网页的链接
/// </summary>
public string Url { get; set; } = string.Empty;
/// <summary>
/// 显示时长(秒)
/// </summary>
public int Duration { get; set; } = 10;
/// <summary>
/// 排序顺序
/// </summary>
public int Order { get; set; } = 0;
/// <summary>
/// 是否启用
/// </summary>
public bool IsEnabled { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -18,6 +18,7 @@ public class DashboardDbContext : DbContext
public DbSet<Playlist> Playlists { get; set; } public DbSet<Playlist> Playlists { get; set; }
public DbSet<PlaylistItem> PlaylistItems { get; set; } public DbSet<PlaylistItem> PlaylistItems { get; set; }
public DbSet<AppVersion> AppVersions { get; set; } public DbSet<AppVersion> AppVersions { get; set; }
public DbSet<RotatorItem> RotatorItems { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -101,5 +102,17 @@ public class DashboardDbContext : DbContext
{ {
entity.HasIndex(e => new { e.Platform, e.Version }); entity.HasIndex(e => new { e.Platform, e.Version });
}); });
// RotatorItem 配置
modelBuilder.Entity<RotatorItem>(entity =>
{
entity.HasKey(e => e.Id);
// 不配置关系,只配置索引,避免 EF Core 创建影子属性
// 外键约束在迁移中已定义,这里不再重复配置
// 同一应用中的轮播项按 Order 排序
entity.HasIndex(e => new { e.ApplicationId, e.Order });
});
} }
} }

View File

@@ -0,0 +1,543 @@
// <auto-generated />
using System;
using DRS9.Dashboard.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DRS9.Dashboard.Infrastructure.Migrations
{
[DbContext(typeof(DashboardDbContext))]
[Migration("20260113093910_AddRotatorItemsTableV2")]
partial class AddRotatorItemsTableV2
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.11");
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.AppVersion", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ChangeLog")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DownloadUrl")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<long?>("FileSize")
.HasColumnType("INTEGER");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<bool>("IsForceUpdate")
.HasColumnType("INTEGER");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("VersionName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Platform", "Version");
b.ToTable("AppVersions");
});
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.Application", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ContentUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<int>("Priority")
.HasColumnType("INTEGER");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name");
b.ToTable("Applications");
});
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.AuditLog", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Details")
.HasColumnType("TEXT");
b.Property<int?>("EntityId")
.HasColumnType("INTEGER");
b.Property<string>("EntityType")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("IpAddress")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuditLogs");
});
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.Device", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("ActivatedAt")
.HasColumnType("TEXT");
b.Property<string>("AppVersion")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DeviceCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int?>("DeviceGroupId")
.HasColumnType("INTEGER");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("DeviceType")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastSeenAt")
.HasColumnType("TEXT");
b.Property<string>("OsVersion")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("DeviceCode")
.IsUnique();
b.HasIndex("DeviceGroupId");
b.ToTable("Devices");
});
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.DeviceAssignment", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ApplicationId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("DeviceId")
.HasColumnType("INTEGER");
b.Property<int>("Duration")
.HasColumnType("INTEGER");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ApplicationId");
b.HasIndex("DeviceId", "ApplicationId")
.IsUnique();
b.ToTable("DeviceAssignments");
});
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.DeviceGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("DeviceGroups");
});
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.Playlist", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<int?>("DeviceGroupId")
.HasColumnType("INTEGER");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("LoopMode")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("DeviceGroupId");
b.HasIndex("Name");
b.ToTable("Playlists");
});
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.PlaylistItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ApplicationId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Duration")
.HasColumnType("INTEGER");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<int>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<string>("ScheduleRule")
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ApplicationId");
b.HasIndex("PlaylistId", "Order");
b.ToTable("PlaylistItems");
});
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.RotatorItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ApplicationId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Duration")
.HasColumnType("INTEGER");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("ItemType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ApplicationId", "Order");
b.ToTable("RotatorItems");
});
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.Device", b =>
{
b.HasOne("DRS9.Dashboard.Domain.Entities.DeviceGroup", "DeviceGroup")
.WithMany("Devices")
.HasForeignKey("DeviceGroupId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("DeviceGroup");
});
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.DeviceAssignment", b =>
{
b.HasOne("DRS9.Dashboard.Domain.Entities.Application", "Application")
.WithMany("Assignments")
.HasForeignKey("ApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DRS9.Dashboard.Domain.Entities.Device", "Device")
.WithMany("Assignments")
.HasForeignKey("DeviceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Application");
b.Navigation("Device");
});
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.Playlist", b =>
{
b.HasOne("DRS9.Dashboard.Domain.Entities.DeviceGroup", "DeviceGroup")
.WithMany()
.HasForeignKey("DeviceGroupId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("DeviceGroup");
});
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.PlaylistItem", b =>
{
b.HasOne("DRS9.Dashboard.Domain.Entities.Application", "Application")
.WithMany()
.HasForeignKey("ApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DRS9.Dashboard.Domain.Entities.Playlist", "Playlist")
.WithMany("Items")
.HasForeignKey("PlaylistId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Application");
b.Navigation("Playlist");
});
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.RotatorItem", b =>
{
b.HasOne("DRS9.Dashboard.Domain.Entities.Application", null)
.WithMany("RotatorItems")
.HasForeignKey("ApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.Application", b =>
{
b.Navigation("Assignments");
b.Navigation("RotatorItems");
});
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.Device", b =>
{
b.Navigation("Assignments");
});
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.DeviceGroup", b =>
{
b.Navigation("Devices");
});
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.Playlist", b =>
{
b.Navigation("Items");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,53 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DRS9.Dashboard.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddRotatorItemsTableV2 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "RotatorItems",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ApplicationId = table.Column<int>(type: "INTEGER", nullable: false),
ItemType = table.Column<string>(type: "TEXT", nullable: false),
Url = table.Column<string>(type: "TEXT", nullable: false),
Duration = table.Column<int>(type: "INTEGER", nullable: false),
Order = table.Column<int>(type: "INTEGER", nullable: false),
IsEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RotatorItems", x => x.Id);
table.ForeignKey(
name: "FK_RotatorItems_Applications_ApplicationId",
column: x => x.ApplicationId,
principalTable: "Applications",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_RotatorItems_ApplicationId_Order",
table: "RotatorItems",
columns: new[] { "ApplicationId", "Order" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RotatorItems");
}
}
}

View File

@@ -361,6 +361,45 @@ namespace DRS9.Dashboard.Infrastructure.Migrations
b.ToTable("PlaylistItems"); b.ToTable("PlaylistItems");
}); });
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.RotatorItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ApplicationId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Duration")
.HasColumnType("INTEGER");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("ItemType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ApplicationId", "Order");
b.ToTable("RotatorItems");
});
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.User", b => modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.User", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -465,9 +504,20 @@ namespace DRS9.Dashboard.Infrastructure.Migrations
b.Navigation("Playlist"); b.Navigation("Playlist");
}); });
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.RotatorItem", b =>
{
b.HasOne("DRS9.Dashboard.Domain.Entities.Application", null)
.WithMany("RotatorItems")
.HasForeignKey("ApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.Application", b => modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.Application", b =>
{ {
b.Navigation("Assignments"); b.Navigation("Assignments");
b.Navigation("RotatorItems");
}); });
modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.Device", b => modelBuilder.Entity("DRS9.Dashboard.Domain.Entities.Device", b =>

View File

@@ -31,7 +31,7 @@
</li> </li>
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="applications"> <NavLink class="nav-link" href="applications">
<i class="bi bi-grid"></i> 内容管理 <i class="bi bi-grid"></i> 应用管理
</NavLink> </NavLink>
</li> </li>
<li class="nav-item"> <li class="nav-item">

View File

@@ -1,17 +1,18 @@
@page "/applications" @page "/applications"
@rendermode @(new InteractiveServerRenderMode()) @rendermode @(new InteractiveServerRenderMode())
@using DRS9.Dashboard.Application.DTOs
@inject Services.ApiClientService ApiClient @inject Services.ApiClientService ApiClient
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
<PageTitle>内容管理 - DRS9 信息发布系统</PageTitle> <PageTitle>应用管理 - DRS9 信息发布系统</PageTitle>
<h3 class="mb-4">内容管理</h3> <h3 class="mb-4">应用管理</h3>
<div class="card"> <div class="card">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-grid"></i> 内容列表</span> <span><i class="bi bi-grid"></i> 应用列表</span>
<button class="btn btn-primary btn-sm" @onclick="ShowAddModal"> <button class="btn btn-primary btn-sm" @onclick="ShowAddModal">
<i class="bi bi-plus-lg"></i> 添加内容 <i class="bi bi-plus-lg"></i> 添加应用
</button> </button>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -25,7 +26,7 @@
{ {
<div class="text-center text-muted py-4"> <div class="text-center text-muted py-4">
<i class="bi bi-inbox fs-1 d-block mb-3"></i> <i class="bi bi-inbox fs-1 d-block mb-3"></i>
<p>暂无内容,点击上方按钮添加</p> <p>暂无应用,点击上方按钮添加</p>
</div> </div>
} }
else else
@@ -42,12 +43,24 @@
</div> </div>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h6 class="mb-1">@app.Name</h6> <h6 class="mb-1">@app.Name</h6>
<p class="text-muted mb-2 small">@app.Type</p> <p class="text-muted mb-2 small">@GetAppTypeDisplay(app.Type)</p>
<p class="mb-2 small">@app.Description</p> <p class="mb-2 small">@app.Description</p>
@if (app.Type == "Rotator")
{
<span class="badge bg-info">
<i class="bi bi-list-ul"></i> @(_rotatorItemsCount.TryGetValue(app.Id, out var count) ? count : 0) 个轮播项
</span>
}
</div> </div>
</div> </div>
</div> </div>
<div class="card-footer d-flex justify-content-end gap-2"> <div class="card-footer d-flex justify-content-end gap-2">
@if (app.Type == "Rotator")
{
<button class="btn btn-sm btn-outline-info" @onclick="@(() => EditRotatorItems(app))">
<i class="bi bi-list-ul"></i> 轮播项
</button>
}
<button class="btn btn-sm btn-outline-primary" @onclick="@(() => EditApp(app))"> <button class="btn btn-sm btn-outline-primary" @onclick="@(() => EditApp(app))">
<i class="bi bi-pencil"></i> 编辑 <i class="bi bi-pencil"></i> 编辑
</button> </button>
@@ -63,14 +76,14 @@
</div> </div>
</div> </div>
<!-- 添加/编辑 Modal --> <!-- 添加/编辑应用 Modal -->
@if (_showModal) @if (_showModal)
{ {
<div class="modal fade show d-block" style="background-color: rgba(0,0,0,0.5)" tabindex="-1" @onclick="CloseModal"> <div class="modal fade show d-block" style="background-color: rgba(0,0,0,0.5)" tabindex="-1" @onclick="CloseModal">
<div class="modal-dialog" @onclick:stopPropagation="true"> <div class="modal-dialog" @onclick:stopPropagation="true">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">@(_editingApp?.Id == null ? "添加内容" : "编辑内容")</h5> <h5 class="modal-title">@(_editingApp?.Id == null ? "添加应用" : "编辑应用")</h5>
<button type="button" class="btn-close" @onclick="CloseModal"></button> <button type="button" class="btn-close" @onclick="CloseModal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -81,27 +94,28 @@
</div> </div>
} }
<div class="mb-3"> <div class="mb-3">
<label class="form-label">内容名称</label> <label class="form-label">应用名称</label>
<input type="text" class="form-control" @bind="_appName" /> <input type="text" class="form-control" @bind="_appName" />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">内容类型</label> <label class="form-label">应用类型</label>
<select class="form-select" @bind="_appType" disabled="@(_editingApp != null)"> <select class="form-select" @bind="_appType" disabled="@(_editingApp != null)">
<option value="">请选择...</option> <option value="">请选择...</option>
<option value="Dashboard">Dashboard</option> <option value="Dashboard">网页链接</option>
<option value="WebRotator">WebRotator</option> <option value="Rotator">轮播</option>
<option value="Image">Image</option>
<option value="Video">Video</option>
</select> </select>
@if (_editingApp != null) @if (_editingApp != null)
{ {
<small class="text-muted">内容类型创建后不可修改</small> <small class="text-muted">应用类型创建后不可修改</small>
} }
</div> </div>
<div class="mb-3"> @if (_appType == "Dashboard" || (_editingApp != null && _editingApp.Type == "Dashboard"))
<label class="form-label">内容 URL</label> {
<input type="url" class="form-control" @bind="_appContentUrl" placeholder="https://..." /> <div class="mb-3">
</div> <label class="form-label">URL</label>
<input type="url" class="form-control" @bind="_appContentUrl" placeholder="https://..." />
</div>
}
<div class="mb-3"> <div class="mb-3">
<label class="form-label">描述</label> <label class="form-label">描述</label>
<textarea class="form-control" @bind="_appDescription" rows="3"></textarea> <textarea class="form-control" @bind="_appDescription" rows="3"></textarea>
@@ -116,6 +130,121 @@
</div> </div>
} }
<!-- 编辑轮播项 Modal -->
@if (_showRotatorModal)
{
<div class="modal fade show d-block" style="background-color: rgba(0,0,0,0.5)" tabindex="-1" @onclick="async () => await CloseRotatorModal()">
<div class="modal-dialog modal-lg" @onclick:stopPropagation="true">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">编辑轮播项 - @_editingRotatorApp?.Name</h5>
<button type="button" class="btn-close" @onclick="async () => await CloseRotatorModal()"></button>
</div>
<div class="modal-body">
<!-- 添加新轮播项 -->
<div class="card mb-3">
<div class="card-header">
<i class="bi bi-plus-circle"></i> 添加轮播项
</div>
<div class="card-body">
@if (!string.IsNullOrEmpty(_rotatorError))
{
<div class="alert alert-danger" role="alert">
@_rotatorError
</div>
}
<div class="row">
<div class="col-md-3">
<label class="form-label">类型</label>
<select class="form-select" @bind="_newRotatorItemType">
<option value="Webpage">网页链接</option>
<option value="Image">图片</option>
<option value="Video">视频</option>
</select>
</div>
<div class="col-md-5">
<label class="form-label">URL</label>
<div class="input-group">
<input type="text" class="form-control" @bind="_newRotatorUrl" placeholder="输入链接或上传文件">
@if (_newRotatorItemType == "Image" || _newRotatorItemType == "Video")
{
<button class="btn btn-outline-secondary" @onclick="TriggerFileUpload">
<i class="bi bi-upload"></i> 上传
</button>
<input type="file" class="d-none" @ref="_fileInput" onchange="@OnFileSelected">
}
</div>
</div>
<div class="col-md-2">
<label class="form-label">时长(秒)</label>
<input type="number" class="form-control" @bind="_newRotatorDuration" min="1" max="3600">
</div>
<div class="col-md-2">
<label class="form-label">&nbsp;</label>
<button class="btn btn-primary w-100" @onclick="AddRotatorItem">
<i class="bi bi-plus"></i> 添加
</button>
</div>
</div>
</div>
</div>
<!-- 轮播项列表 -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-list-ul"></i> 轮播项列表 (@_rotatorItems.Count)</span>
</div>
<div class="card-body">
@if (_rotatorItems == null || _rotatorItems.Count == 0)
{
<p class="text-muted text-center mb-0">暂无轮播项</p>
}
else
{
<div class="list-group">
@for (int i = 0; i < _rotatorItems.Count; i++)
{
var item = _rotatorItems[i];
var index = i;
var isFirst = (index == 0);
var isLast = (index == _rotatorItems.Count - 1);
<div class="list-group-item d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<i class="bi @GetRotatorItemIcon(item.ItemType) me-2"></i>
<div>
<div class="fw-bold">@item.ItemType</div>
<small class="text-muted">@item.Url</small>
<div>
<span class="badge bg-secondary">@item.Duration 秒</span>
</div>
</div>
</div>
<div class="btn-group">
<button class="btn btn-sm btn-outline-secondary" @onclick="@(() => MoveRotatorItemUp(item, index))" disabled="@isFirst">
<i class="bi bi-arrow-up"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="@(() => MoveRotatorItemDown(item, index))" disabled="@isLast">
<i class="bi bi-arrow-down"></i>
</button>
<button class="btn btn-sm btn-outline-danger" @onclick="@(() => DeleteRotatorItem(item))">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
}
</div>
}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="async () => await CloseRotatorModal()">关闭</button>
</div>
</div>
</div>
</div>
}
@code { @code {
private List<ApplicationDto> _applications = new(); private List<ApplicationDto> _applications = new();
private ApplicationDto? _editingApp; private ApplicationDto? _editingApp;
@@ -126,9 +255,39 @@
private bool _showModal = false; private bool _showModal = false;
private string _errorMessage = ""; private string _errorMessage = "";
// 轮播项相关
private ApplicationDto? _editingRotatorApp;
private bool _showRotatorModal = false;
private List<RotatorItemDto> _rotatorItems = new();
private Dictionary<int, int> _rotatorItemsCount = new();
private string _newRotatorItemType = "Webpage";
private string _newRotatorUrl = "";
private int _newRotatorDuration = 10;
private string _rotatorError = "";
private ElementReference _fileInput;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{
await LoadApplications();
}
private async Task LoadApplications()
{ {
_applications = await ApiClient.GetApplicationsAsync(); _applications = await ApiClient.GetApplicationsAsync();
// 加载每个轮播应用的项数量
_rotatorItemsCount.Clear();
foreach (var app in _applications.Where(a => a.Type == "Rotator"))
{
try
{
var items = await ApiClient.GetRotatorItemsAsync(app.Id);
_rotatorItemsCount[app.Id] = items.Count;
}
catch
{
_rotatorItemsCount[app.Id] = 0;
}
}
} }
private void ShowAddModal() private void ShowAddModal()
@@ -162,20 +321,19 @@
private async Task SaveApp() private async Task SaveApp()
{ {
// Validate inputs
if (string.IsNullOrWhiteSpace(_appName)) if (string.IsNullOrWhiteSpace(_appName))
{ {
_errorMessage = "请输入内容名称"; _errorMessage = "请输入应用名称";
return; return;
} }
if (string.IsNullOrWhiteSpace(_appType)) if (string.IsNullOrWhiteSpace(_appType))
{ {
_errorMessage = "请选择内容类型"; _errorMessage = "请选择应用类型";
return; return;
} }
if (string.IsNullOrWhiteSpace(_appContentUrl)) if (_appType == "Dashboard" && string.IsNullOrWhiteSpace(_appContentUrl))
{ {
_errorMessage = "请输入内容 URL"; _errorMessage = "请输入网页链接 URL";
return; return;
} }
@@ -203,7 +361,7 @@
if (success) if (success)
{ {
CloseModal(); CloseModal();
_applications = await ApiClient.GetApplicationsAsync(); await LoadApplications();
await JSRuntime.InvokeVoidAsync("alert", "保存成功"); await JSRuntime.InvokeVoidAsync("alert", "保存成功");
} }
else else
@@ -214,12 +372,12 @@
private async Task DeleteApp(ApplicationDto app) private async Task DeleteApp(ApplicationDto app)
{ {
if (await JSRuntime.InvokeAsync<bool>("confirm", $"确定要删除内容 '{app.Name}' 吗?")) if (await JSRuntime.InvokeAsync<bool>("confirm", $"确定要删除应用 '{app.Name}' 吗?"))
{ {
var success = await ApiClient.DeleteApplicationAsync(app.Id); var success = await ApiClient.DeleteApplicationAsync(app.Id);
if (success) if (success)
{ {
_applications = await ApiClient.GetApplicationsAsync(); await LoadApplications();
await JSRuntime.InvokeVoidAsync("alert", "删除成功"); await JSRuntime.InvokeVoidAsync("alert", "删除成功");
} }
else else
@@ -229,21 +387,249 @@
} }
} }
private string GetAppTypeDisplay(string type) => type switch
{
"Dashboard" => "网页链接",
"Rotator" => "轮播",
_ => type
};
private string GetAppTypeClass(string type) => type switch private string GetAppTypeClass(string type) => type switch
{ {
"Dashboard" => "dashboard", "Dashboard" => "dashboard",
"WebRotator" => "web", "Rotator" => "rotator",
"Image" => "image",
"Video" => "video",
_ => "" _ => ""
}; };
private string GetAppIcon(string type) => type switch private string GetAppIcon(string type) => type switch
{ {
"Dashboard" => "bi-bar-chart", "Dashboard" => "bi-bar-chart",
"WebRotator" => "bi-arrow-repeat", "Rotator" => "bi-arrow-repeat",
"Image" => "bi-image",
"Video" => "bi-play-circle",
_ => "bi-file-earmark" _ => "bi-file-earmark"
}; };
private string GetRotatorItemIcon(string type) => type switch
{
"Image" => "bi-image",
"Video" => "bi-play-circle",
"Webpage" => "bi-globe",
_ => "bi-file-earmark"
};
// 轮播项管理
private async Task EditRotatorItems(ApplicationDto app)
{
_editingRotatorApp = app;
_rotatorError = "";
await LoadRotatorItems(app.Id);
_showRotatorModal = true;
}
private async Task LoadRotatorItems(int applicationId)
{
try
{
_rotatorItems = await ApiClient.GetRotatorItemsAsync(applicationId);
}
catch (Exception ex)
{
_rotatorItems = new();
_rotatorError = $"加载失败: {ex.Message}";
}
}
private async Task CloseRotatorModal()
{
_showRotatorModal = false;
_editingRotatorApp = null;
_rotatorItems = new();
_newRotatorUrl = "";
_rotatorError = "";
// 刷新应用列表以更新轮播项数量
await LoadApplications();
}
private async Task AddRotatorItem()
{
if (_editingRotatorApp == null) return;
if (string.IsNullOrWhiteSpace(_newRotatorUrl))
{
_rotatorError = "请输入 URL";
return;
}
try
{
var request = new RotatorItemCreateRequest
{
ItemType = _newRotatorItemType,
Url = _newRotatorUrl,
Duration = _newRotatorDuration,
Order = _rotatorItems.Count + 1
};
var success = await ApiClient.CreateRotatorItemAsync(_editingRotatorApp.Id, request);
if (success)
{
_newRotatorUrl = "";
_rotatorError = "";
await LoadRotatorItems(_editingRotatorApp.Id);
}
else
{
_rotatorError = "添加失败";
}
}
catch (Exception ex)
{
_rotatorError = $"添加失败: {ex.Message}";
}
}
private async Task DeleteRotatorItem(RotatorItemDto item)
{
if (_editingRotatorApp == null) return;
try
{
var success = await ApiClient.DeleteRotatorItemAsync(_editingRotatorApp.Id, item.Id);
if (success)
{
await LoadRotatorItems(_editingRotatorApp.Id);
}
else
{
_rotatorError = "删除失败";
}
}
catch
{
_rotatorError = "删除失败";
}
}
private async Task MoveRotatorItemUp(RotatorItemDto item, int index)
{
await JSRuntime.InvokeVoidAsync("console.log", $"=== MoveRotatorItemUp ===");
await JSRuntime.InvokeVoidAsync("console.log", $"Index: {index}, Item ID: {item.Id}");
await JSRuntime.InvokeVoidAsync("console.log", $"EditingApp: {_editingRotatorApp?.Id}");
await JSRuntime.InvokeVoidAsync("console.log", $"ItemCount: {_rotatorItems.Count}");
await JSRuntime.InvokeVoidAsync("console.log", $"Current order: [{string.Join(", ", _rotatorItems.Select(x => x.Id))}]");
if (_editingRotatorApp == null)
{
await JSRuntime.InvokeVoidAsync("console.log", $"BLOCKED: EditingApp is null");
return;
}
if (index <= 0)
{
await JSRuntime.InvokeVoidAsync("console.log", $"BLOCKED: Index {index} <= 0");
return;
}
if (index >= _rotatorItems.Count)
{
await JSRuntime.InvokeVoidAsync("console.log", $"BLOCKED: Index {index} >= Count {_rotatorItems.Count}");
return;
}
// 创建新的顺序列表:交换两个相邻项的位置
var newOrder = _rotatorItems.ToList();
var temp = newOrder[index];
newOrder[index] = newOrder[index - 1];
newOrder[index - 1] = temp;
// 获取按照新顺序排列的 ID 列表
var itemIds = newOrder.Select(x => x.Id).ToList();
await JSRuntime.InvokeVoidAsync("console.log", $"New order: [{string.Join(", ", itemIds)}]");
// 更新到服务器
var result = await ApiClient.ReorderRotatorItemsAsync(_editingRotatorApp.Id, itemIds);
await JSRuntime.InvokeVoidAsync("console.log", $"API result: {result}");
await LoadRotatorItems(_editingRotatorApp.Id);
await JSRuntime.InvokeVoidAsync("console.log", $"Loaded order: [{string.Join(", ", _rotatorItems.Select(x => x.Id))}]");
}
private async Task MoveRotatorItemDown(RotatorItemDto item, int index)
{
await JSRuntime.InvokeVoidAsync("console.log", $"=== MoveRotatorItemDown ===");
await JSRuntime.InvokeVoidAsync("console.log", $"Index: {index}, Item ID: {item.Id}");
await JSRuntime.InvokeVoidAsync("console.log", $"Current order: [{string.Join(", ", _rotatorItems.Select(x => x.Id))}]");
if (_editingRotatorApp == null)
{
await JSRuntime.InvokeVoidAsync("console.log", $"BLOCKED: EditingApp is null");
return;
}
if (index < 0)
{
await JSRuntime.InvokeVoidAsync("console.log", $"BLOCKED: Index {index} < 0");
return;
}
if (index >= _rotatorItems.Count - 1)
{
await JSRuntime.InvokeVoidAsync("console.log", $"BLOCKED: Index {index} >= Count-1 {_rotatorItems.Count - 1}");
return;
}
// 创建新的顺序列表:交换两个相邻项的位置
var newOrder = _rotatorItems.ToList();
var temp = newOrder[index];
newOrder[index] = newOrder[index + 1];
newOrder[index + 1] = temp;
// 获取按照新顺序排列的 ID 列表
var itemIds = newOrder.Select(x => x.Id).ToList();
await JSRuntime.InvokeVoidAsync("console.log", $"New order: [{string.Join(", ", itemIds)}]");
// 更新到服务器
var result = await ApiClient.ReorderRotatorItemsAsync(_editingRotatorApp.Id, itemIds);
await JSRuntime.InvokeVoidAsync("console.log", $"API result: {result}");
await LoadRotatorItems(_editingRotatorApp.Id);
await JSRuntime.InvokeVoidAsync("console.log", $"Loaded order: [{string.Join(", ", _rotatorItems.Select(x => x.Id))}]");
}
private async Task TriggerFileUpload()
{
try
{
await JSRuntime.InvokeVoidAsync("click", _fileInput);
}
catch
{
_rotatorError = "无法打开文件选择器";
}
}
private async Task OnFileSelected(ChangeEventArgs e)
{
try
{
var files = (e.Value as IBrowserFile[]);
if (files != null && files.Length > 0)
{
var file = files[0];
var result = await ApiClient.UploadFileAsync(file);
if (result?.Success == true)
{
_newRotatorUrl = result.Url ?? "";
if (!string.IsNullOrEmpty(result.ItemType))
{
_newRotatorItemType = result.ItemType;
}
_rotatorError = "";
}
else
{
_rotatorError = result?.Message ?? "上传失败";
}
}
}
catch (Exception ex)
{
_rotatorError = $"上传失败: {ex.Message}";
}
}
} }

View File

@@ -44,7 +44,7 @@ else
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div> <div>
<h3>@_stats.TotalApplications</h3> <h3>@_stats.TotalApplications</h3>
<p>内容数量</p> <p>应用数量</p>
</div> </div>
<i class="bi bi-grid fs-1 opacity-50"></i> <i class="bi bi-grid fs-1 opacity-50"></i>
</div> </div>

View File

@@ -360,10 +360,12 @@ public class DashboardController : ControllerBase
var devices = await _deviceManagementService.GetAllDevicesAsync(); var devices = await _deviceManagementService.GetAllDevicesAsync();
var applications = await _applicationService.GetAllAsync(); var applications = await _applicationService.GetAllAsync();
// 在线设备1分钟内有活动
var onlineThreshold = DateTime.UtcNow.AddMinutes(-1);
var stats = new DashboardStatsDto var stats = new DashboardStatsDto
{ {
TotalDevices = devices.Data.Count, TotalDevices = devices.Data.Count,
OnlineDevices = devices.Data.Count(d => d.IsEnabled), OnlineDevices = devices.Data.Count(d => d.LastSeenAt.HasValue && d.LastSeenAt >= onlineThreshold),
TotalApplications = applications.Total TotalApplications = applications.Total
}; };

View File

@@ -0,0 +1,244 @@
using Microsoft.AspNetCore.Mvc;
namespace DRS9.Dashboard.Server.Controllers;
[ApiController]
[Route("api/admin/[controller]")]
// [Authorize] - 暂时禁用认证以便测试
public class UploadController : ControllerBase
{
private readonly IWebHostEnvironment _environment;
private readonly ILogger<UploadController> _logger;
// 允许的图片扩展名
private static readonly string[] ImageExtensions = { ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp" };
// 允许的视频扩展名
private static readonly string[] VideoExtensions = { ".mp4", ".webm", ".ogg", ".mov", ".avi", ".mkv" };
// 最大文件大小 (50MB)
private const long MaxFileSize = 50 * 1024 * 1024;
public UploadController(IWebHostEnvironment environment, ILogger<UploadController> logger)
{
_environment = environment;
_logger = logger;
}
/// <summary>
/// 上传文件(图片或视频)
/// </summary>
[HttpPost]
public async Task<ActionResult<object>> UploadFile(IFormFile file)
{
if (file == null || file.Length == 0)
{
return BadRequest(new { success = false, message = "请选择文件" });
}
// 检查文件大小
if (file.Length > MaxFileSize)
{
return BadRequest(new { success = false, message = $"文件大小不能超过 {MaxFileSize / 1024 / 1024}MB" });
}
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
// 确定文件类型
string itemType;
string subFolder;
if (ImageExtensions.Contains(extension))
{
itemType = "Image";
subFolder = "images";
}
else if (VideoExtensions.Contains(extension))
{
itemType = "Video";
subFolder = "videos";
}
else
{
return BadRequest(new { success = false, message = "不支持的文件类型,请上传图片或视频" });
}
try
{
// 创建上传目录
var uploadsFolder = Path.Combine(_environment.WebRootPath, "uploads", subFolder);
if (!Directory.Exists(uploadsFolder))
{
Directory.CreateDirectory(uploadsFolder);
}
// 生成唯一文件名
var uniqueFileName = $"{Guid.NewGuid()}{extension}";
var filePath = Path.Combine(uploadsFolder, uniqueFileName);
// 保存文件
using (var stream = new FileStream(filePath, FileMode.Create))
{
await file.CopyToAsync(stream);
}
// 返回文件 URL
var fileUrl = $"/uploads/{subFolder}/{uniqueFileName}";
_logger.LogInformation("文件上传成功: {FileName} -> {Url}", file.FileName, fileUrl);
return Ok(new
{
success = true,
message = "上传成功",
url = fileUrl,
itemType = itemType,
fileName = file.FileName
});
}
catch (Exception ex)
{
_logger.LogError(ex, "文件上传失败");
return StatusCode(500, new { success = false, message = $"上传失败: {ex.Message}" });
}
}
/// <summary>
/// 批量上传文件
/// </summary>
[HttpPost("batch")]
public async Task<ActionResult<object>> UploadFiles(List<IFormFile> files)
{
if (files == null || files.Count == 0)
{
return BadRequest(new { success = false, message = "请选择文件" });
}
var results = new List<object>();
var successCount = 0;
var failCount = 0;
foreach (var file in files)
{
var result = await UploadSingleFile(file);
if (result.success)
{
successCount++;
}
else
{
failCount++;
}
results.Add(new
{
fileName = file.FileName,
success = result.success,
message = result.message,
url = result.url,
itemType = result.itemType
});
}
return Ok(new
{
success = true,
message = $"上传完成: {successCount} 个成功, {failCount} 个失败",
results = results
});
}
private async Task<(bool success, string message, string? url, string? itemType)> UploadSingleFile(IFormFile file)
{
if (file == null || file.Length == 0)
{
return (false, "请选择文件", null, null);
}
if (file.Length > MaxFileSize)
{
return (false, $"文件大小不能超过 {MaxFileSize / 1024 / 1024}MB", null, null);
}
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
string itemType;
string subFolder;
if (ImageExtensions.Contains(extension))
{
itemType = "Image";
subFolder = "images";
}
else if (VideoExtensions.Contains(extension))
{
itemType = "Video";
subFolder = "videos";
}
else
{
return (false, "不支持的文件类型", null, null);
}
try
{
var uploadsFolder = Path.Combine(_environment.WebRootPath, "uploads", subFolder);
if (!Directory.Exists(uploadsFolder))
{
Directory.CreateDirectory(uploadsFolder);
}
var uniqueFileName = $"{Guid.NewGuid()}{extension}";
var filePath = Path.Combine(uploadsFolder, uniqueFileName);
using (var stream = new FileStream(filePath, FileMode.Create))
{
await file.CopyToAsync(stream);
}
var fileUrl = $"/uploads/{subFolder}/{uniqueFileName}";
return (true, "上传成功", fileUrl, itemType);
}
catch (Exception ex)
{
_logger.LogError(ex, "文件上传失败: {FileName}", file.FileName);
return (false, $"上传失败: {ex.Message}", null, null);
}
}
/// <summary>
/// 删除文件
/// </summary>
[HttpDelete]
public ActionResult DeleteFile([FromBody] FileDeleteRequest request)
{
if (string.IsNullOrWhiteSpace(request.Url))
{
return BadRequest(new { success = false, message = "请提供文件 URL" });
}
try
{
// 从 URL 中提取文件路径
var filePath = Path.Combine(_environment.WebRootPath, request.Url.TrimStart('/'));
if (System.IO.File.Exists(filePath))
{
System.IO.File.Delete(filePath);
_logger.LogInformation("文件删除成功: {Url}", request.Url);
return Ok(new { success = true, message = "删除成功" });
}
else
{
return NotFound(new { success = false, message = "文件不存在" });
}
}
catch (Exception ex)
{
_logger.LogError(ex, "文件删除失败: {Url}", request.Url);
return StatusCode(500, new { success = false, message = $"删除失败: {ex.Message}" });
}
}
}
public class FileDeleteRequest
{
public string Url { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,85 @@
using DRS9.Dashboard.Application.DTOs;
using DRS9.Dashboard.Application.Services;
using Microsoft.AspNetCore.Mvc;
namespace DRS9.Dashboard.Server.Controllers;
[ApiController]
[Route("api/admin/applications/{applicationId}/rotator-items")]
// [Authorize] - 暂时禁用认证以便测试
public class RotatorItemsController : ControllerBase
{
private readonly RotatorItemService _rotatorItemService;
public RotatorItemsController(RotatorItemService rotatorItemService)
{
_rotatorItemService = rotatorItemService;
}
/// <summary>
/// 获取指定应用的所有轮播项
/// </summary>
[HttpGet]
public async Task<ActionResult<List<RotatorItemDto>>> GetRotatorItems(int applicationId)
{
var items = await _rotatorItemService.GetByApplicationIdAsync(applicationId);
return Ok(items);
}
/// <summary>
/// 添加轮播项
/// </summary>
[HttpPost]
public async Task<ActionResult<RotatorItemDto>> CreateRotatorItem(int applicationId, [FromBody] RotatorItemCreateRequest request)
{
try
{
var item = await _rotatorItemService.CreateAsync(applicationId, request);
if (item == null)
return BadRequest(new { success = false, message = "应用不存在或类型不是轮播" });
return Ok(item);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { success = false, message = ex.Message });
}
catch (Exception ex)
{
return BadRequest(new { success = false, message = ex.Message + " | " + ex.StackTrace });
}
}
/// <summary>
/// 更新轮播项
/// </summary>
[HttpPut("{id}")]
public async Task<ActionResult<RotatorItemDto>> UpdateRotatorItem(int id, [FromBody] RotatorItemUpdateRequest request)
{
var item = await _rotatorItemService.UpdateAsync(id, request);
if (item == null)
return NotFound(new { success = false, message = "轮播项不存在" });
return Ok(item);
}
/// <summary>
/// 删除轮播项
/// </summary>
[HttpDelete("{id}")]
public async Task<ActionResult> DeleteRotatorItem(int id)
{
var success = await _rotatorItemService.DeleteAsync(id);
if (!success)
return NotFound(new { success = false, message = "轮播项不存在" });
return Ok(new { success = true, message = "删除成功" });
}
/// <summary>
/// 批量更新轮播项顺序
/// </summary>
[HttpPost("reorder")]
public async Task<ActionResult> ReorderRotatorItems(int applicationId, [FromBody] List<int> itemIds)
{
await _rotatorItemService.ReorderAsync(applicationId, itemIds);
return Ok(new { success = true, message = "顺序更新成功" });
}
}

View File

@@ -0,0 +1,86 @@
using DRS9.Dashboard.Application.DTOs;
using DRS9.Dashboard.Application.Services;
using Microsoft.AspNetCore.Mvc;
namespace DRS9.Dashboard.Server.Controllers;
/// <summary>
/// 轮播显示控制器 - 供设备客户端使用
/// </summary>
[Route("rotator")]
public class RotatorViewerController : Controller
{
private readonly RotatorItemService _rotatorItemService;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<RotatorViewerController> _logger;
public RotatorViewerController(
RotatorItemService rotatorItemService,
IHttpContextAccessor httpContextAccessor,
ILogger<RotatorViewerController> logger)
{
_rotatorItemService = rotatorItemService;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
/// <summary>
/// 获取指定应用的轮播配置 (JSON)
/// </summary>
[HttpGet("{applicationId}/config.json")]
public async Task<ActionResult> GetConfig(int applicationId)
{
try
{
var items = await _rotatorItemService.GetByApplicationIdAsync(applicationId);
// 只返回启用的项目
var enabledItems = items.Where(i => i.IsEnabled).OrderBy(i => i.Order).ToList();
var config = new
{
urls = enabledItems.Select(i => GetAbsoluteUrl(i.Url)).ToList(),
switchIntervals = enabledItems.Select(i => i.Duration).ToList()
};
return Json(config);
}
catch (Exception ex)
{
_logger.LogError(ex, "获取轮播配置失败: ApplicationId={ApplicationId}", applicationId);
return Json(new { urls = new List<string>(), switchIntervals = new List<int>() });
}
}
/// <summary>
/// 显示轮播页面
/// </summary>
[HttpGet("{applicationId}")]
public IActionResult ViewRotator(int applicationId)
{
return View(applicationId);
}
private string GetAbsoluteUrl(string relativeOrAbsoluteUrl)
{
if (string.IsNullOrWhiteSpace(relativeOrAbsoluteUrl))
return string.Empty;
// 如果已经是绝对 URL直接返回
if (relativeOrAbsoluteUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
relativeOrAbsoluteUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return relativeOrAbsoluteUrl;
}
// 转换为绝对 URL
var request = _httpContextAccessor.HttpContext?.Request;
if (request != null)
{
var baseUrl = $"{request.Scheme}://{request.Host}";
return relativeOrAbsoluteUrl.StartsWith("/") ? baseUrl + relativeOrAbsoluteUrl : baseUrl + "/" + relativeOrAbsoluteUrl;
}
return relativeOrAbsoluteUrl;
}
}

View File

@@ -10,7 +10,8 @@ using Microsoft.OpenApi.Models;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Add services to the container. // Add services to the container.
builder.Services.AddControllers(); builder.Services.AddControllersWithViews(); // 支持 MVC 视图
builder.Services.AddRazorPages();
// Configure Database // Configure Database
builder.Services.AddDbContext<DashboardDbContext>(options => builder.Services.AddDbContext<DashboardDbContext>(options =>
@@ -42,6 +43,9 @@ builder.Services.AddAuthorization();
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
// Add HttpContextAccessor for MVC controllers
builder.Services.AddHttpContextAccessor();
// Configure Blazor Server Circuit // Configure Blazor Server Circuit
builder.Services.AddServerSideBlazor() builder.Services.AddServerSideBlazor()
.AddCircuitOptions(options => .AddCircuitOptions(options =>
@@ -78,6 +82,7 @@ builder.Services.AddSingleton(new JwtTokenService(
builder.Services.AddScoped<DeviceService>(); builder.Services.AddScoped<DeviceService>();
builder.Services.AddScoped<ApplicationService>(); builder.Services.AddScoped<ApplicationService>();
builder.Services.AddScoped<RotatorItemService>();
builder.Services.AddScoped<DeviceManagementService>(); builder.Services.AddScoped<DeviceManagementService>();
builder.Services.AddScoped<PlaylistService>(); builder.Services.AddScoped<PlaylistService>();
builder.Services.AddScoped<BatchManagementService>(); builder.Services.AddScoped<BatchManagementService>();
@@ -153,6 +158,9 @@ app.UseAntiforgery();
app.UseWebSockets(); app.UseWebSockets();
app.MapControllers(); app.MapControllers();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
// Blazor Server // Blazor Server
app.MapRazorComponents<DRS9.Dashboard.Server.Components.App>() app.MapRazorComponents<DRS9.Dashboard.Server.Components.App>()

View File

@@ -1,4 +1,5 @@
using DRS9.Dashboard.Application.DTOs; using DRS9.Dashboard.Application.DTOs;
using Microsoft.AspNetCore.Components.Forms;
namespace DRS9.Dashboard.Server.Services; namespace DRS9.Dashboard.Server.Services;
@@ -251,4 +252,48 @@ public class ApiClientService
return new DashboardStatsDto(); return new DashboardStatsDto();
return await response.Content.ReadFromJsonAsync<DashboardStatsDto>() ?? new DashboardStatsDto(); return await response.Content.ReadFromJsonAsync<DashboardStatsDto>() ?? new DashboardStatsDto();
} }
// 轮播项 API
public async Task<List<RotatorItemDto>> GetRotatorItemsAsync(int applicationId)
{
AddAuthHeader();
var response = await _httpClient.GetAsync($"/api/admin/applications/{applicationId}/rotator-items");
if (!response.IsSuccessStatusCode) return new List<RotatorItemDto>();
return await response.Content.ReadFromJsonAsync<List<RotatorItemDto>>() ?? new List<RotatorItemDto>();
}
public async Task<bool> CreateRotatorItemAsync(int applicationId, RotatorItemCreateRequest request)
{
AddAuthHeader();
var response = await _httpClient.PostAsJsonAsync($"/api/admin/applications/{applicationId}/rotator-items", request);
return response.IsSuccessStatusCode;
}
public async Task<bool> DeleteRotatorItemAsync(int applicationId, int itemId)
{
AddAuthHeader();
var response = await _httpClient.DeleteAsync($"/api/admin/applications/{applicationId}/rotator-items/{itemId}");
return response.IsSuccessStatusCode;
}
public async Task<bool> ReorderRotatorItemsAsync(int applicationId, List<int> itemIds)
{
AddAuthHeader();
var response = await _httpClient.PostAsJsonAsync($"/api/admin/applications/{applicationId}/rotator-items/reorder", itemIds);
return response.IsSuccessStatusCode;
}
public async Task<FileUploadResponse?> UploadFileAsync(IBrowserFile file, string? itemType = null)
{
AddAuthHeader();
using var content = new StreamContent(file.OpenReadStream(maxAllowedSize: 50 * 1024 * 1024));
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(file.ContentType);
var formData = new MultipartFormDataContent();
formData.Add(content, "file", file.Name);
var response = await _httpClient.PostAsync("/api/admin/upload", formData);
if (!response.IsSuccessStatusCode) return null;
return await response.Content.ReadFromJsonAsync<FileUploadResponse>();
}
} }

View File

@@ -0,0 +1,356 @@
@model int
@{
ViewData["Title"] = "轮播显示";
var applicationId = Model;
}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DRS9 轮播 - 应用 #@applicationId</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 100vw;
height: 100vh;
overflow: hidden;
}
.iframe-container {
width: 100%;
height: 100%;
position: relative;
background: white;
}
.iframe-wrapper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 0.5s ease-in-out;
}
.iframe-wrapper.active {
opacity: 1;
z-index: 10;
}
.iframe-wrapper.loading {
z-index: 5;
}
iframe {
width: 100%;
height: 100%;
border: none;
display: block;
}
.media-image {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.media-video {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.error-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
background: #f8f9fa;
color: #666;
text-align: center;
padding: 20px;
}
.error-icon {
font-size: 64px;
margin-bottom: 20px;
}
.error-text {
font-size: 24px;
font-weight: 600;
margin-bottom: 10px;
}
.error-url {
font-size: 16px;
color: #999;
word-break: break-all;
max-width: 80%;
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #fff;
z-index: 10000;
}
.spinner {
width: 50px;
height: 50px;
border: 5px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
margin-top: 20px;
font-size: 18px;
}
</style>
</head>
<body>
<div class="loading-overlay" id="loadingOverlay">
<div class="spinner"></div>
<div class="loading-text">正在加载轮播内容...</div>
</div>
<div class="iframe-container" id="iframeContainer"></div>
<script>
const APPLICATION_ID = @applicationId;
const CONFIG_URL = `/rotator/${APPLICATION_ID}/config.json`;
let urls = [];
let switchIntervals = [];
let currentIndex = 0;
let rotationInterval = null;
let iframeWrappers = [null, null];
let currentWrapperIndex = 0;
// 加载配置文件
async function loadConfig() {
try {
const response = await fetch(CONFIG_URL);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const config = await response.json();
urls = config.urls || [];
switchIntervals = config.switchIntervals || [];
console.log(`配置加载成功: ${urls.length} 个URL`);
if (urls.length === 0) {
showError('此轮播应用没有配置任何内容');
}
} catch (err) {
console.error('配置加载失败:', err);
showError('配置加载失败: ' + err.message);
}
}
// 检测 URL 类型
function getUrlType(url) {
const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.svg', '.webp', '.ico'];
const videoExts = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv', '.flv'];
const lowerUrl = url.toLowerCase();
for (const ext of imageExts) {
if (lowerUrl.endsWith(ext)) return 'image';
}
for (const ext of videoExts) {
if (lowerUrl.endsWith(ext)) return 'video';
}
return 'page';
}
function showError(message) {
document.getElementById('iframeContainer').innerHTML = `
<div class="error-message">
<div class="error-icon">⚠️</div>
<div class="error-text">加载失败</div>
<div class="error-url">${message}</div>
</div>
`;
hideLoading();
}
function hideLoading() {
const overlay = document.getElementById('loadingOverlay');
if (overlay) {
overlay.style.opacity = '0';
overlay.style.transition = 'opacity 0.5s';
setTimeout(() => overlay.remove(), 500);
}
}
function createIframeWrapper() {
const wrapper = document.createElement('div');
wrapper.className = 'iframe-wrapper';
return wrapper;
}
function loadUrl(url, wrapperIndex) {
const wrapper = iframeWrappers[wrapperIndex];
const urlType = getUrlType(url);
wrapper.innerHTML = '';
wrapper.classList.remove('active');
wrapper.classList.add('loading');
if (urlType === 'image') {
const img = document.createElement('img');
img.className = 'media-image';
img.onload = () => {
hideLoading();
activateWrapper(wrapperIndex);
};
img.onerror = () => {
hideLoading();
activateWrapper(wrapperIndex);
};
img.src = url;
wrapper.appendChild(img);
} else if (urlType === 'video') {
const video = document.createElement('video');
video.className = 'media-video';
video.autoplay = true;
video.muted = false;
video.playsInline = true;
video.loop = false;
video.onloadedmetadata = () => {
hideLoading();
activateWrapper(wrapperIndex);
video.play().catch(err => {
console.log('有声播放失败,切换为静音:', err);
video.muted = true;
video.play().catch(err2 => console.log('静音播放也失败:', err2));
});
};
video.onerror = () => {
hideLoading();
activateWrapper(wrapperIndex);
};
video.src = url;
wrapper.appendChild(video);
} else {
const iframe = document.createElement('iframe');
iframe.scrolling = 'no';
let loaded = false;
let errorShown = false;
const timeout = setTimeout(() => {
if (!loaded && !errorShown) {
errorShown = true;
hideLoading();
activateWrapper(wrapperIndex);
}
}, 10000);
iframe.onload = () => {
if (!errorShown) {
loaded = true;
clearTimeout(timeout);
hideLoading();
activateWrapper(wrapperIndex);
}
};
iframe.onerror = () => {
if (!errorShown) {
clearTimeout(timeout);
errorShown = true;
hideLoading();
activateWrapper(wrapperIndex);
}
};
iframe.src = url;
wrapper.appendChild(iframe);
}
}
function activateWrapper(wrapperIndex) {
iframeWrappers.forEach((w, i) => {
if (i === wrapperIndex) {
w.classList.add('active');
w.classList.remove('loading');
} else {
w.classList.remove('active');
}
});
startRotationTimer();
}
function startRotationTimer() {
if (rotationInterval) {
clearInterval(rotationInterval);
}
if (urls.length === 0) return;
const effectiveIndex = (currentIndex - 1 + urls.length) % urls.length;
const interval = switchIntervals[effectiveIndex];
rotationInterval = setInterval(loadNextUrl, interval * 1000);
}
function loadNextUrl() {
if (urls.length === 0) return;
const nextWrapperIndex = (currentWrapperIndex + 1) % 2;
const url = urls[currentIndex];
loadUrl(url, nextWrapperIndex);
currentWrapperIndex = nextWrapperIndex;
currentIndex = (currentIndex + 1) % urls.length;
}
async function init() {
const container = document.getElementById('iframeContainer');
iframeWrappers[0] = createIframeWrapper();
iframeWrappers[1] = createIframeWrapper();
container.appendChild(iframeWrappers[0]);
container.appendChild(iframeWrappers[1]);
currentIndex = 0;
currentWrapperIndex = 0;
await loadConfig();
if (urls.length > 0) {
loadNextUrl();
}
}
window.onload = init;
</script>
</body>
</html>

View File

@@ -0,0 +1,2 @@
@using DRS9.Dashboard.Server
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@@ -0,0 +1,3 @@
@{
Layout = null;
}

View File

@@ -8,7 +8,7 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Data Source=dashboard.db" "DefaultConnection": "Data Source=data.db"
}, },
"Jwt": { "Jwt": {
"Key": "DRS9_DASHBOARD_SECRET_KEY_2026_CHANGE_THIS_IN_PRODUCTION", "Key": "DRS9_DASHBOARD_SECRET_KEY_2026_CHANGE_THIS_IN_PRODUCTION",

View File

@@ -215,6 +215,11 @@ main {
color: #f57c00; color: #f57c00;
} }
.content-icon.rotator {
background-color: #e1f5fe;
color: #0288d1;
}
/* Drag and Drop */ /* Drag and Drop */
.draggable-item { .draggable-item {
cursor: move; cursor: move;