admin page phaze2
This commit is contained in:
@@ -123,4 +123,96 @@ public sealed class AdminController : ControllerBase
|
||||
var result = await _adminService.GetUsageAsync(from, to, userId, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ==================== Alerts ====================
|
||||
|
||||
[HttpGet("alerts")]
|
||||
public async Task<ActionResult<PagedResult<AdminAlertItemDto>>> Alerts(
|
||||
[FromQuery] int? deviceId,
|
||||
[FromQuery] Domain.AlertStatus? status,
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = new AdminAlertFilter
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
Status = status,
|
||||
From = from,
|
||||
To = to,
|
||||
Page = Math.Max(1, page),
|
||||
PageSize = Math.Clamp(pageSize, 1, 100)
|
||||
};
|
||||
|
||||
var result = await _adminService.GetAlertsAsync(filter, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ==================== Posts ====================
|
||||
|
||||
[HttpGet("posts")]
|
||||
public async Task<ActionResult<PagedResult<AdminPostItemDto>>> Posts(
|
||||
[FromQuery] int? deviceId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = new AdminPostFilter
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
Page = Math.Max(1, page),
|
||||
PageSize = Math.Clamp(pageSize, 1, 100)
|
||||
};
|
||||
|
||||
var result = await _adminService.GetPostsAsync(filter, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpDelete("posts/{id:int}")]
|
||||
public async Task<IActionResult> DeletePost(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||
var deleted = await _adminService.DeletePostAsync(id, currentAdminId, cancellationToken);
|
||||
return deleted ? Ok(new { success = true }) : NotFound();
|
||||
}
|
||||
|
||||
// ==================== Device Debug ====================
|
||||
|
||||
[HttpPost("devices/{id:int}/test-sms")]
|
||||
public async Task<ActionResult<AdminDeviceTestResultDto>> TestDeviceSms(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||
var result = await _adminService.TestDeviceSmsAsync(id, currentAdminId, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("devices/{id:int}/test-voice")]
|
||||
public async Task<ActionResult<AdminDeviceTestResultDto>> TestDeviceVoice(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||
var result = await _adminService.TestDeviceVoiceAsync(id, currentAdminId, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ==================== Audit Log ====================
|
||||
|
||||
[HttpGet("audit-logs")]
|
||||
public async Task<ActionResult<PagedResult<AdminAuditLogDto>>> AuditLogs(
|
||||
[FromQuery] int? adminId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = new AdminAuditLogFilter
|
||||
{
|
||||
AdminId = adminId,
|
||||
Page = Math.Max(1, page),
|
||||
PageSize = Math.Clamp(pageSize, 1, 100)
|
||||
};
|
||||
|
||||
var result = await _adminService.GetAuditLogsAsync(filter, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,3 +127,77 @@ public sealed class UsageDayDto
|
||||
public int AiQueryCount { get; set; }
|
||||
public int AiTokenCount { get; set; }
|
||||
}
|
||||
|
||||
// ==================== Alerts ====================
|
||||
|
||||
public sealed class AdminAlertFilter
|
||||
{
|
||||
public int? DeviceId { get; set; }
|
||||
public Domain.AlertStatus? Status { get; set; }
|
||||
public DateTime? From { get; set; }
|
||||
public DateTime? To { get; set; }
|
||||
public int Page { get; set; } = 1;
|
||||
public int PageSize { get; set; } = 20;
|
||||
}
|
||||
|
||||
public sealed class AdminAlertItemDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int DeviceId { get; set; }
|
||||
public string DeviceName { get; set; } = string.Empty;
|
||||
public string OwnerName { get; set; } = string.Empty;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public Domain.AlertNotificationType NotificationType { get; set; }
|
||||
public Domain.AlertStatus Status { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTime SentAt { get; set; }
|
||||
}
|
||||
|
||||
// ==================== Posts ====================
|
||||
|
||||
public sealed class AdminPostFilter
|
||||
{
|
||||
public int? DeviceId { get; set; }
|
||||
public int Page { get; set; } = 1;
|
||||
public int PageSize { get; set; } = 20;
|
||||
}
|
||||
|
||||
public sealed class AdminPostItemDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int DeviceId { get; set; }
|
||||
public string DeviceName { get; set; } = string.Empty;
|
||||
public string AuthorName { get; set; } = string.Empty;
|
||||
public string Content { get; set; } = string.Empty;
|
||||
public Domain.DevicePostType PostType { get; set; }
|
||||
public int ImageCount { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
// ==================== Device Debug ====================
|
||||
|
||||
public sealed class AdminDeviceTestResultDto
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ==================== Audit Log ====================
|
||||
|
||||
public sealed class AdminAuditLogFilter
|
||||
{
|
||||
public int? AdminId { get; set; }
|
||||
public int Page { get; set; } = 1;
|
||||
public int PageSize { get; set; } = 20;
|
||||
}
|
||||
|
||||
public sealed class AdminAuditLogDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int AdminUserId { get; set; }
|
||||
public string AdminName { get; set; } = string.Empty;
|
||||
public string Action { get; set; } = string.Empty;
|
||||
public string TargetId { get; set; } = string.Empty;
|
||||
public string Details { get; set; } = string.Empty;
|
||||
public DateTime At { get; set; }
|
||||
}
|
||||
|
||||
@@ -12,4 +12,10 @@ public interface IAdminService
|
||||
Task<PagedResult<AdminDeviceListItemDto>> GetDevicesAsync(AdminDeviceFilter filter, CancellationToken cancellationToken);
|
||||
Task<AdminDeviceDetailDto?> GetDeviceAsync(int id, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<UsageDayDto>> GetUsageAsync(DateTime? from, DateTime? to, int? userId, CancellationToken cancellationToken);
|
||||
Task<PagedResult<AdminAlertItemDto>> GetAlertsAsync(AdminAlertFilter filter, CancellationToken cancellationToken);
|
||||
Task<PagedResult<AdminPostItemDto>> GetPostsAsync(AdminPostFilter filter, CancellationToken cancellationToken);
|
||||
Task<bool> DeletePostAsync(int id, int currentAdminId, CancellationToken cancellationToken);
|
||||
Task<AdminDeviceTestResultDto> TestDeviceSmsAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken);
|
||||
Task<AdminDeviceTestResultDto> TestDeviceVoiceAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken);
|
||||
Task<PagedResult<AdminAuditLogDto>> GetAuditLogsAsync(AdminAuditLogFilter filter, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
35
src/GreenHome.Domain/AdminAuditLog.cs
Normal file
35
src/GreenHome.Domain/AdminAuditLog.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
namespace GreenHome.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// لاگ عملیات مدیریتی پنل admin (تغییر نقش/وضعیت کاربر، حذف پست، تست SMS/صوتی و ...)
|
||||
/// </summary>
|
||||
public sealed class AdminAuditLog
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// شناسه مدیری که عملیات را انجام داده
|
||||
/// </summary>
|
||||
public int AdminUserId { get; set; }
|
||||
public User AdminUser { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// نام عملیات به فرمت «Entity.Action» (مثلاً User.SetRole, Post.Delete, Device.TestSms)
|
||||
/// </summary>
|
||||
public string Action { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// شناسه هدف (id کاربر/دستگاه/پست)
|
||||
/// </summary>
|
||||
public string TargetId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// جزئیات اضافی (JSON اختیاری)
|
||||
/// </summary>
|
||||
public string Details { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// زمان عملیات (UTC)
|
||||
/// </summary>
|
||||
public DateTime At { get; set; }
|
||||
}
|
||||
@@ -31,6 +31,7 @@ public sealed class GreenHomeDbContext : DbContext
|
||||
public DbSet<Domain.DevicePostImage> DevicePostImages => Set<Domain.DevicePostImage>();
|
||||
public DbSet<Domain.PowerOutageLog> PowerOutageLogs => Set<Domain.PowerOutageLog>();
|
||||
public DbSet<Domain.UsageCounter> UsageCounters => Set<Domain.UsageCounter>();
|
||||
public DbSet<Domain.AdminAuditLog> AdminAuditLogs => Set<Domain.AdminAuditLog>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -458,5 +459,20 @@ public sealed class GreenHomeDbContext : DbContext
|
||||
b.HasIndex(x => new { x.UserId, x.Date }).IsUnique();
|
||||
b.HasIndex(x => x.Date);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Domain.AdminAuditLog>(b =>
|
||||
{
|
||||
b.ToTable("AdminAuditLogs");
|
||||
b.HasKey(x => x.Id);
|
||||
b.Property(x => x.Action).IsRequired().HasMaxLength(100);
|
||||
b.Property(x => x.TargetId).IsRequired().HasMaxLength(100);
|
||||
b.Property(x => x.Details).HasMaxLength(2000);
|
||||
b.HasOne(x => x.AdminUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.AdminUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
b.HasIndex(x => x.At);
|
||||
b.HasIndex(x => x.AdminUserId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
1597
src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925115448_AdminPanelPhase2.Designer.cs
generated
Normal file
1597
src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925115448_AdminPanelPhase2.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GreenHome.Infrastructure.Data.Migrations.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AdminPanelPhase2 : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AdminAuditLogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
AdminUserId = table.Column<int>(type: "int", nullable: false),
|
||||
Action = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
TargetId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
Details = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: false),
|
||||
At = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AdminAuditLogs", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AdminAuditLogs_Users_AdminUserId",
|
||||
column: x => x.AdminUserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AdminAuditLogs_AdminUserId",
|
||||
table: "AdminAuditLogs",
|
||||
column: "AdminUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AdminAuditLogs_At",
|
||||
table: "AdminAuditLogs",
|
||||
column: "At");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AdminAuditLogs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,44 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
||||
b.ToTable("AIQueries", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GreenHome.Domain.AdminAuditLog", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<int>("AdminUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("At")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Details")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<string>("TargetId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AdminUserId");
|
||||
|
||||
b.HasIndex("At");
|
||||
|
||||
b.ToTable("AdminAuditLogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GreenHome.Domain.AlertCondition", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1173,6 +1211,17 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GreenHome.Domain.AdminAuditLog", b =>
|
||||
{
|
||||
b.HasOne("GreenHome.Domain.User", "AdminUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("AdminUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("AdminUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GreenHome.Domain.AlertCondition", b =>
|
||||
{
|
||||
b.HasOne("GreenHome.Domain.Device", "Device")
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
<NuGetAudit>false</NuGetAudit>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
using System.Text.Json;
|
||||
using AutoMapper;
|
||||
using GreenHome.Application;
|
||||
using GreenHome.Application.Dtos.Admin;
|
||||
using GreenHome.Domain;
|
||||
using GreenHome.Infrastructure.Data;
|
||||
using GreenHome.Infrastructure.Services.Usage;
|
||||
using GreenHome.Sms.Ippanel;
|
||||
using GreenHome.VoiceCall.Avanak;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
@@ -12,12 +17,27 @@ public sealed class AdminService : IAdminService
|
||||
{
|
||||
private readonly GreenHomeDbContext _dbContext;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly ISmsService _smsService;
|
||||
private readonly IVoiceCallService _voiceCallService;
|
||||
private readonly UsageTracker _usageTracker;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly int _onlineWindowMinutes;
|
||||
|
||||
public AdminService(GreenHomeDbContext dbContext, IMapper mapper, IConfiguration configuration)
|
||||
public AdminService(
|
||||
GreenHomeDbContext dbContext,
|
||||
IMapper mapper,
|
||||
IConfiguration configuration,
|
||||
ISmsService smsService,
|
||||
IVoiceCallService voiceCallService,
|
||||
UsageTracker usageTracker,
|
||||
IWebHostEnvironment environment)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_mapper = mapper;
|
||||
_smsService = smsService;
|
||||
_voiceCallService = voiceCallService;
|
||||
_usageTracker = usageTracker;
|
||||
_environment = environment;
|
||||
_onlineWindowMinutes = configuration.GetValue("Admin:OnlineWindowMinutes", 15);
|
||||
}
|
||||
|
||||
@@ -173,6 +193,8 @@ public sealed class AdminService : IAdminService
|
||||
}
|
||||
|
||||
user.Role = role;
|
||||
AddAuditLog(currentAdminId, "User.SetRole", id.ToString(),
|
||||
JsonSerializer.Serialize(new { role = role.ToString(), roleId = (int)role }));
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return UserMutationOutcome.Ok;
|
||||
}
|
||||
@@ -193,6 +215,7 @@ public sealed class AdminService : IAdminService
|
||||
}
|
||||
|
||||
user.IsDisabled = !isEnabled;
|
||||
AddAuditLog(currentAdminId, isEnabled ? "User.Enable" : "User.Disable", id.ToString());
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return UserMutationOutcome.Ok;
|
||||
}
|
||||
@@ -319,8 +342,280 @@ public sealed class AdminService : IAdminService
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
// ==================== Alerts ====================
|
||||
|
||||
public async Task<PagedResult<AdminAlertItemDto>> GetAlertsAsync(AdminAlertFilter filter, CancellationToken cancellationToken)
|
||||
{
|
||||
if (filter.Page <= 0) filter.Page = 1;
|
||||
if (filter.PageSize <= 0) filter.PageSize = 20;
|
||||
|
||||
IQueryable<Domain.AlertLog> query = _dbContext.AlertLogs.AsNoTracking()
|
||||
.Include(a => a.Device).ThenInclude(d => d.User);
|
||||
|
||||
if (filter.DeviceId.HasValue)
|
||||
query = query.Where(a => a.DeviceId == filter.DeviceId.Value);
|
||||
if (filter.Status.HasValue)
|
||||
query = query.Where(a => a.Status == filter.Status.Value);
|
||||
if (filter.From.HasValue)
|
||||
query = query.Where(a => a.SentAt >= filter.From.Value);
|
||||
if (filter.To.HasValue)
|
||||
query = query.Where(a => a.SentAt <= filter.To.Value);
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(a => a.SentAt)
|
||||
.ThenByDescending(a => a.Id)
|
||||
.Skip((filter.Page - 1) * filter.PageSize)
|
||||
.Take(filter.PageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new PagedResult<AdminAlertItemDto>
|
||||
{
|
||||
Items = items.Select(a => new AdminAlertItemDto
|
||||
{
|
||||
Id = a.Id,
|
||||
DeviceId = a.DeviceId,
|
||||
DeviceName = a.Device.DeviceName,
|
||||
OwnerName = a.Device.User.Name + " " + a.Device.User.Family,
|
||||
Message = a.Message,
|
||||
NotificationType = a.NotificationType,
|
||||
Status = a.Status,
|
||||
ErrorMessage = a.ErrorMessage,
|
||||
SentAt = a.SentAt
|
||||
}).ToList(),
|
||||
TotalCount = totalCount,
|
||||
Page = filter.Page,
|
||||
PageSize = filter.PageSize
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Posts ====================
|
||||
|
||||
public async Task<PagedResult<AdminPostItemDto>> GetPostsAsync(AdminPostFilter filter, CancellationToken cancellationToken)
|
||||
{
|
||||
if (filter.Page <= 0) filter.Page = 1;
|
||||
if (filter.PageSize <= 0) filter.PageSize = 20;
|
||||
|
||||
IQueryable<Domain.DevicePost> query = _dbContext.DevicePosts.AsNoTracking()
|
||||
.Include(p => p.Device)
|
||||
.Include(p => p.AuthorUser)
|
||||
.Include(p => p.Images);
|
||||
|
||||
if (filter.DeviceId.HasValue)
|
||||
query = query.Where(p => p.DeviceId == filter.DeviceId.Value);
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.ThenByDescending(p => p.Id)
|
||||
.Skip((filter.Page - 1) * filter.PageSize)
|
||||
.Take(filter.PageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new PagedResult<AdminPostItemDto>
|
||||
{
|
||||
// ponytail: like/reply count intentionally omitted — DevicePost has no such fields yet.
|
||||
Items = items.Select(p => new AdminPostItemDto
|
||||
{
|
||||
Id = p.Id,
|
||||
DeviceId = p.DeviceId,
|
||||
DeviceName = p.Device.DeviceName,
|
||||
AuthorName = p.AuthorUser.Name + " " + p.AuthorUser.Family,
|
||||
Content = p.Content,
|
||||
PostType = p.PostType,
|
||||
ImageCount = p.Images.Count,
|
||||
CreatedAt = p.CreatedAt
|
||||
}).ToList(),
|
||||
TotalCount = totalCount,
|
||||
Page = filter.Page,
|
||||
PageSize = filter.PageSize
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<bool> DeletePostAsync(int id, int currentAdminId, CancellationToken cancellationToken)
|
||||
{
|
||||
var post = await _dbContext.DevicePosts
|
||||
.Include(p => p.Images)
|
||||
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
|
||||
if (post == null) return false;
|
||||
|
||||
// remove image files from disc (best effort — a missing file must not block the delete)
|
||||
var webRoot = _environment.WebRootPath ?? "wwwroot";
|
||||
foreach (var image in post.Images)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fullPath = Path.Combine(webRoot, image.FilePath.TrimStart('/'));
|
||||
if (File.Exists(fullPath)) File.Delete(fullPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// file cleanup is best-effort
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(post.VoiceFilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var voicePath = Path.Combine(webRoot, post.VoiceFilePath.TrimStart('/'));
|
||||
if (File.Exists(voicePath)) File.Delete(voicePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// file cleanup is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
AddAuditLog(currentAdminId, "Post.Delete", id.ToString());
|
||||
_dbContext.DevicePosts.Remove(post);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==================== Device Debug ====================
|
||||
|
||||
public async Task<AdminDeviceTestResultDto> TestDeviceSmsAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken)
|
||||
{
|
||||
var device = await _dbContext.Devices.AsNoTracking()
|
||||
.Include(d => d.User)
|
||||
.FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken);
|
||||
if (device == null)
|
||||
return new AdminDeviceTestResultDto { Success = false, Message = "دستگاه یافت نشد" };
|
||||
|
||||
var mobile = MobileNormalizer.Normalize(device.User.Mobile);
|
||||
if (mobile.Length == 0)
|
||||
return new AdminDeviceTestResultDto { Success = false, Message = "شماره موبایل مالک معتبر نیست" };
|
||||
|
||||
// persist the audit row up-front so it is recorded no matter how the send attempt ends
|
||||
AddAuditLog(currentAdminId, "Device.TestSms", deviceId.ToString(),
|
||||
JsonSerializer.Serialize(new { mobile }));
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _smsService.SendWebserviceSmsAsync(new WebserviceSmsRequest
|
||||
{
|
||||
Recipient = mobile,
|
||||
Message = $"پیامک تست GreenHome برای دستگاه {device.DeviceName} — {DateTime.UtcNow:yyyy/MM/dd HH:mm} UTC"
|
||||
}, cancellationToken);
|
||||
|
||||
if (result?.Meta.Status == true)
|
||||
{
|
||||
await _usageTracker.RecordAsync(device.User.Id, UsageKind.Sms, cancellationToken: cancellationToken);
|
||||
return new AdminDeviceTestResultDto { Success = true, Message = $"پیامک تست برای {mobile} ارسال شد" };
|
||||
}
|
||||
|
||||
var reason = result?.Meta.Message ?? "خطای نامشخص سرویس پیامک";
|
||||
return new AdminDeviceTestResultDto { Success = false, Message = reason };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AdminDeviceTestResultDto { Success = false, Message = $"خطا در ارسال: {ex.Message}" };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AdminDeviceTestResultDto> TestDeviceVoiceAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken)
|
||||
{
|
||||
var device = await _dbContext.Devices.AsNoTracking()
|
||||
.Include(d => d.User)
|
||||
.FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken);
|
||||
if (device == null)
|
||||
return new AdminDeviceTestResultDto { Success = false, Message = "دستگاه یافت نشد" };
|
||||
|
||||
var mobile = MobileNormalizer.Normalize(device.User.Mobile);
|
||||
if (mobile.Length == 0)
|
||||
return new AdminDeviceTestResultDto { Success = false, Message = "شماره موبایل مالک معتبر نیست" };
|
||||
|
||||
// persist the audit row up-front so it is recorded no matter how the send attempt ends
|
||||
AddAuditLog(currentAdminId, "Device.TestVoice", deviceId.ToString(),
|
||||
JsonSerializer.Serialize(new { mobile }));
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _voiceCallService.QuickSendWithTTSAsync(new QuickSendWithTTSRequest
|
||||
{
|
||||
Text = $"پیام صوتی تست از پنل مدیریت گریهوم برای دستگاه {device.DeviceName}",
|
||||
Number = mobile,
|
||||
ServerID = 0
|
||||
}, cancellationToken);
|
||||
|
||||
if (result?.IsSuccess == true)
|
||||
{
|
||||
// ponytail: ثبت مصرف فقط وقتی call واقعی ارسال میشود (هماهنگ با AlertNotificationSender)
|
||||
await _usageTracker.RecordAsync(device.User.Id, UsageKind.VoiceCall, cancellationToken: cancellationToken);
|
||||
return new AdminDeviceTestResultDto { Success = true, Message = $"تماس صوتی تست برای {mobile} انجام شد" };
|
||||
}
|
||||
|
||||
var reason = result == null ? "پاسخی از سرویس صوتی دریافت نشد"
|
||||
: $"کد خطا: {result.ReturnValue}";
|
||||
return new AdminDeviceTestResultDto { Success = false, Message = reason };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AdminDeviceTestResultDto { Success = false, Message = $"خطا در تماس صوتی: {ex.Message}" };
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Audit Log ====================
|
||||
|
||||
public async Task<PagedResult<AdminAuditLogDto>> GetAuditLogsAsync(AdminAuditLogFilter filter, CancellationToken cancellationToken)
|
||||
{
|
||||
if (filter.Page <= 0) filter.Page = 1;
|
||||
if (filter.PageSize <= 0) filter.PageSize = 20;
|
||||
|
||||
IQueryable<Domain.AdminAuditLog> query = _dbContext.AdminAuditLogs.AsNoTracking()
|
||||
.Include(a => a.AdminUser);
|
||||
|
||||
if (filter.AdminId.HasValue)
|
||||
query = query.Where(a => a.AdminUserId == filter.AdminId.Value);
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(a => a.At)
|
||||
.ThenByDescending(a => a.Id)
|
||||
.Skip((filter.Page - 1) * filter.PageSize)
|
||||
.Take(filter.PageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new PagedResult<AdminAuditLogDto>
|
||||
{
|
||||
Items = items.Select(a => new AdminAuditLogDto
|
||||
{
|
||||
Id = a.Id,
|
||||
AdminUserId = a.AdminUserId,
|
||||
AdminName = a.AdminUser.Name + " " + a.AdminUser.Family,
|
||||
Action = a.Action,
|
||||
TargetId = a.TargetId,
|
||||
Details = a.Details,
|
||||
At = a.At
|
||||
}).ToList(),
|
||||
TotalCount = totalCount,
|
||||
Page = filter.Page,
|
||||
PageSize = filter.PageSize
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
/// <summary>
|
||||
/// یک ردیف audit log به change tracker اضافه میکند؛ با همان SaveChanges عملیات اصلی ذخیره میشود.
|
||||
/// </summary>
|
||||
private void AddAuditLog(int adminUserId, string action, string targetId, string details = "")
|
||||
{
|
||||
_dbContext.AdminAuditLogs.Add(new Domain.AdminAuditLog
|
||||
{
|
||||
AdminUserId = adminUserId,
|
||||
Action = action,
|
||||
TargetId = targetId,
|
||||
Details = details,
|
||||
At = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// آخرین تلیمتری هر دستگاه + نام مالک (برای لیستهای admin).
|
||||
/// </summary>
|
||||
|
||||
@@ -16,7 +16,10 @@ import AdminLayout from '@/app/admin/AdminLayout'
|
||||
import AdminDashboardPage from '@/app/admin/page'
|
||||
import AdminUsersPage from '@/app/admin/users/page'
|
||||
import AdminDevicesPage from '@/app/admin/devices/page'
|
||||
import AdminAlertsPage from '@/app/admin/alerts/page'
|
||||
import AdminPostsPage from '@/app/admin/posts/page'
|
||||
import AdminUsagePage from '@/app/admin/usage/page'
|
||||
import AdminAuditPage from '@/app/admin/audit/page'
|
||||
|
||||
function DeviceRedirect() {
|
||||
const location = useLocation()
|
||||
@@ -46,7 +49,10 @@ export default function App() {
|
||||
<Route index element={<AdminDashboardPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="devices" element={<AdminDevicesPage />} />
|
||||
<Route path="alerts" element={<AdminAlertsPage />} />
|
||||
<Route path="posts" element={<AdminPostsPage />} />
|
||||
<Route path="usage" element={<AdminUsagePage />} />
|
||||
<Route path="audit" element={<AdminAuditPage />} />
|
||||
</Route>
|
||||
<Route path="/alert-settings" element={<DeviceRedirect />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
import { Outlet, useLocation } from 'react-router-dom'
|
||||
import { Link } from '@/lib/router'
|
||||
import { LayoutDashboard, Users, Cpu, BarChart3, ArrowRight } from 'lucide-react'
|
||||
import { LayoutDashboard, Users, Cpu, BarChart3, ArrowRight, Bell, MessageSquare, ScrollText } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/admin', label: 'داشبورد', icon: LayoutDashboard, exact: true },
|
||||
{ href: '/admin/users', label: 'کاربران', icon: Users, exact: false },
|
||||
{ href: '/admin/devices', label: 'دستگاهها', icon: Cpu, exact: false },
|
||||
{ href: '/admin/alerts', label: 'اعلانها', icon: Bell, exact: false },
|
||||
{ href: '/admin/posts', label: 'پستها', icon: MessageSquare, exact: false },
|
||||
{ href: '/admin/usage', label: 'مصرف', icon: BarChart3, exact: false },
|
||||
{ href: '/admin/audit', label: 'گزارش اعمال', icon: ScrollText, exact: false },
|
||||
]
|
||||
|
||||
export default function AdminLayout() {
|
||||
|
||||
215
src/GreenHome.Web/src/app/admin/alerts/page.tsx
Normal file
215
src/GreenHome.Web/src/app/admin/alerts/page.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState, type ChangeEvent } from 'react'
|
||||
import { Bell, MessageSquare, Phone, AlertTriangle } from 'lucide-react'
|
||||
import { api, PagedResult, AdminAlertItem } from '@/lib/api'
|
||||
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||
import { formatPersianDateFromIso } from '@/lib/date/persian-date'
|
||||
import { Pagination } from '@/components/navigation/Pagination'
|
||||
import { Badge } from '@/components/common/Badge'
|
||||
import { Button } from '@/components/common/Button'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
// AlertStatus: Success=1, Failed=2, Pending=3
|
||||
const STATUS_META: Record<number, { label: string; variant: 'success' | 'error' | 'warning' }> = {
|
||||
1: { label: 'موفق', variant: 'success' },
|
||||
2: { label: 'ناموفق', variant: 'error' },
|
||||
3: { label: 'در صف', variant: 'warning' },
|
||||
}
|
||||
|
||||
// AlertNotificationType: Call=-1, SMS=0, Relay1..4=1..4
|
||||
function notificationLabel(type: number): { label: string; icon: typeof MessageSquare } {
|
||||
if (type === -1) return { label: 'تماس صوتی', icon: Phone }
|
||||
if (type === 0) return { label: 'پیامک', icon: MessageSquare }
|
||||
if (type >= 1 && type <= 4) return { label: `رله ${toPersianDigits(type)}`, icon: Bell }
|
||||
return { label: 'نامشخص', icon: Bell }
|
||||
}
|
||||
|
||||
function daysAgoIso(n: number): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - n)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export default function AdminAlertsPage() {
|
||||
const [result, setResult] = useState<PagedResult<AdminAlertItem> | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [onlyErrors, setOnlyErrors] = useState(false)
|
||||
const [from, setFrom] = useState('')
|
||||
const [to, setTo] = useState('')
|
||||
const [deviceId, setDeviceId] = useState('')
|
||||
|
||||
const fetchAlerts = useCallback(async (p: number, errorsOnly: boolean, f: string, t: string, devId: string) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await api.adminAlerts({
|
||||
status: errorsOnly ? 2 : undefined,
|
||||
from: f || undefined,
|
||||
to: t ? `${t}T23:59:59` : undefined,
|
||||
deviceId: devId ? Number(devId) : undefined,
|
||||
page: p,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
setResult(data)
|
||||
} catch (e) {
|
||||
setError((e as Error).message || 'خطا در دریافت هشدارها')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlerts(page, onlyErrors, from, to, deviceId)
|
||||
}, [page, onlyErrors, from, to, deviceId, fetchAlerts])
|
||||
|
||||
const onDateChange = (setter: (v: string) => void) => (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setter(e.target.value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const applyLast7Days = () => {
|
||||
setFrom(daysAgoIso(6))
|
||||
setTo(new Date().toISOString().slice(0, 10))
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setFrom('')
|
||||
setTo('')
|
||||
setDeviceId('')
|
||||
setOnlyErrors(false)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const totalPages = result ? Math.max(1, Math.ceil(result.totalCount / PAGE_SIZE)) : 1
|
||||
const showPagination = totalPages > 1
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<h2 className="text-base font-bold text-gray-900">فید هشدارها</h2>
|
||||
{result && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{toPersianDigits(result.totalCount)} هشدار
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant={onlyErrors ? 'danger' : 'default'}
|
||||
size="sm"
|
||||
icon={AlertTriangle}
|
||||
onClick={() => { setOnlyErrors(!onlyErrors); setPage(1) }}
|
||||
>
|
||||
فقط خطاها
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onClick={applyLast7Days}>
|
||||
{toPersianDigits(7)} روز اخیر
|
||||
</Button>
|
||||
{(from || to || deviceId || onlyErrors) && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
حذف فیلترها
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mr-auto">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={deviceId}
|
||||
onChange={(e) => { setDeviceId(e.target.value); setPage(1) }}
|
||||
placeholder="شناسه دستگاه"
|
||||
className="text-xs border border-gray-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={from}
|
||||
onChange={onDateChange(setFrom)}
|
||||
className="text-xs border border-gray-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">تا</span>
|
||||
<input
|
||||
type="date"
|
||||
value={to}
|
||||
onChange={onDateChange(setTo)}
|
||||
className="text-xs border border-gray-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl p-3">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="p-4 animate-pulse">
|
||||
<div className="h-4 w-2/3 bg-gray-100 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : result && result.items.length > 0 ? (
|
||||
<>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 border-b border-gray-200">
|
||||
<th className="text-right font-medium py-3 px-4">دستگاه</th>
|
||||
<th className="text-right font-medium py-3 px-4">مالک</th>
|
||||
<th className="text-right font-medium py-3 px-4">نوع اعلان</th>
|
||||
<th className="text-right font-medium py-3 px-4">پیام</th>
|
||||
<th className="text-right font-medium py-3 px-4">وضعیت</th>
|
||||
<th className="text-right font-medium py-3 px-4">زمان ارسال</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.items.map((alert) => {
|
||||
const status = STATUS_META[alert.status] ?? STATUS_META[3]
|
||||
const notif = notificationLabel(alert.notificationType)
|
||||
const NotifIcon = notif.icon
|
||||
return (
|
||||
<tr key={alert.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50 transition-colors">
|
||||
<td className="py-3 px-4 font-medium text-gray-900 whitespace-nowrap">
|
||||
{alert.deviceName}
|
||||
<span className="text-xs text-gray-400 font-normal"> ({toPersianDigits(alert.deviceId)})</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-700 whitespace-nowrap">{alert.ownerName}</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="default" size="sm" icon={NotifIcon}>{notif.label}</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-700 max-w-xs">
|
||||
<p className="truncate" title={alert.message}>{alert.message}</p>
|
||||
{alert.status === 2 && alert.errorMessage && (
|
||||
<p className="text-xs text-red-500 truncate" title={alert.errorMessage}>{alert.errorMessage}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant={status.variant} size="sm">{status.label}</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-500 whitespace-nowrap">
|
||||
{formatPersianDateFromIso(alert.sentAt)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{showPagination && (
|
||||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Bell}
|
||||
title="هشدار یافت نشد"
|
||||
message="برای بازه و فیلتر انتخابی، هشداری ثبت نشده است."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
src/GreenHome.Web/src/app/admin/audit/page.tsx
Normal file
110
src/GreenHome.Web/src/app/admin/audit/page.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ScrollText } from 'lucide-react'
|
||||
import { api, PagedResult, AdminAuditLogItem } from '@/lib/api'
|
||||
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||
import { formatPersianDateFromIso } from '@/lib/date/persian-date'
|
||||
import { Pagination } from '@/components/navigation/Pagination'
|
||||
import { Badge } from '@/components/common/Badge'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export default function AdminAuditPage() {
|
||||
const [result, setResult] = useState<PagedResult<AdminAuditLogItem> | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const fetchLogs = useCallback(async (p: number) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await api.adminAuditLogs({ page: p, pageSize: PAGE_SIZE })
|
||||
setResult(data)
|
||||
} catch (e) {
|
||||
setError((e as Error).message || 'خطا در دریافت لاگ مدیریت')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs(page)
|
||||
}, [page, fetchLogs])
|
||||
|
||||
const totalPages = result ? Math.max(1, Math.ceil(result.totalCount / PAGE_SIZE)) : 1
|
||||
const showPagination = totalPages > 1
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-bold text-gray-900">لاگ مدیریت</h2>
|
||||
{result && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{toPersianDigits(result.totalCount)} عملیات
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl p-3">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="p-4 animate-pulse">
|
||||
<div className="h-4 w-2/3 bg-gray-100 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : result && result.items.length > 0 ? (
|
||||
<>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 border-b border-gray-200">
|
||||
<th className="text-right font-medium py-3 px-4">زمان</th>
|
||||
<th className="text-right font-medium py-3 px-4">مدیر</th>
|
||||
<th className="text-right font-medium py-3 px-4">عملیات</th>
|
||||
<th className="text-right font-medium py-3 px-4">هدف</th>
|
||||
<th className="text-right font-medium py-3 px-4">جزئیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.items.map((log) => (
|
||||
<tr key={log.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50 transition-colors">
|
||||
<td className="py-3 px-4 text-gray-500 whitespace-nowrap">
|
||||
{formatPersianDateFromIso(log.at)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-700 whitespace-nowrap">{log.adminName}</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="info" size="sm">
|
||||
<span dir="ltr">{log.action}</span>
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-700 tabular-nums whitespace-nowrap">
|
||||
{toPersianDigits(log.targetId)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-400 text-xs max-w-xs">
|
||||
<span className="break-all" dir="ltr" title={log.details}>{log.details || '—'}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{showPagination && (
|
||||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={ScrollText}
|
||||
title="عملیاتی ثبت نشده"
|
||||
message="هنوز هیچ عملیات مدیریتی در لاگ ثبت نشده است."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Cpu, Thermometer, Eye } from 'lucide-react'
|
||||
import { api, PagedResult, AdminDeviceListItem, AdminDeviceDetail } from '@/lib/api'
|
||||
import { Cpu, Thermometer, Eye, MessageSquare, PhoneCall } from 'lucide-react'
|
||||
import { api, PagedResult, AdminDeviceListItem, AdminDeviceDetail, AdminDeviceTestResult } from '@/lib/api'
|
||||
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||
import { formatPersianDateFromIso } from '@/lib/date/persian-date'
|
||||
import { SearchInput } from '@/components/forms/SearchInput'
|
||||
@@ -22,6 +22,8 @@ export default function AdminDevicesPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [detail, setDetail] = useState<AdminDeviceDetail | null>(null)
|
||||
const [testSms, setTestSms] = useState<{ busy: boolean; result: AdminDeviceTestResult | null }>({ busy: false, result: null })
|
||||
const [testVoice, setTestVoice] = useState<{ busy: boolean; result: AdminDeviceTestResult | null }>({ busy: false, result: null })
|
||||
|
||||
const fetchDevices = useCallback(async (p: number, s: string) => {
|
||||
setLoading(true)
|
||||
@@ -49,12 +51,26 @@ export default function AdminDevicesPage() {
|
||||
|
||||
const openDetail = async (id: number) => {
|
||||
try {
|
||||
setDetail(await api.adminDevice(id))
|
||||
const d = await api.adminDevice(id)
|
||||
setDetail(d)
|
||||
setTestSms({ busy: false, result: null })
|
||||
setTestVoice({ busy: false, result: null })
|
||||
} catch (e) {
|
||||
setError((e as Error).message || 'خطا در دریافت جزئیات دستگاه')
|
||||
}
|
||||
}
|
||||
|
||||
const runTest = async (kind: 'sms' | 'voice', deviceId: number, setter: typeof setTestSms) => {
|
||||
setter({ busy: true, result: null })
|
||||
try {
|
||||
const fn = kind === 'sms' ? api.adminTestSms : api.adminTestVoice
|
||||
const res = await fn(deviceId)
|
||||
setter({ busy: false, result: res })
|
||||
} catch (e) {
|
||||
setter({ busy: false, result: { success: false, message: (e as Error).message || 'خطای ارتباطی' } })
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = result ? Math.max(1, Math.ceil(result.totalCount / PAGE_SIZE)) : 1
|
||||
const showPagination = totalPages > 1
|
||||
|
||||
@@ -191,6 +207,36 @@ export default function AdminDevicesPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-500 mb-2">تست اعلان</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" icon={MessageSquare}
|
||||
disabled={testSms.busy || testVoice.busy}
|
||||
onClick={() => runTest('sms', detail.id, setTestSms)}>
|
||||
{testSms.busy ? 'در حال ارسال...' : 'تست پیامک'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" icon={PhoneCall}
|
||||
disabled={testSms.busy || testVoice.busy}
|
||||
onClick={() => runTest('voice', detail.id, setTestVoice)}>
|
||||
{testVoice.busy ? 'در حال تماس...' : 'تست تماس صوتی'}
|
||||
</Button>
|
||||
</div>
|
||||
{(testSms.result || testVoice.result) && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{testSms.result && (
|
||||
<p className={testSms.result.success ? 'text-xs text-green-700 bg-green-50 rounded px-2 py-1' : 'text-xs text-red-700 bg-red-50 rounded px-2 py-1'}>
|
||||
{testSms.result.success ? '✓ ' : '✗ '}پیامک: {testSms.result.message}
|
||||
</p>
|
||||
)}
|
||||
{testVoice.result && (
|
||||
<p className={testVoice.result.success ? 'text-xs text-green-700 bg-green-50 rounded px-2 py-1' : 'text-xs text-red-700 bg-red-50 rounded px-2 py-1'}>
|
||||
{testVoice.result.success ? '✓ ' : '✗ '}تماس صوتی: {testVoice.result.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
||||
<Thermometer className="w-4 h-4 text-gray-400" />
|
||||
|
||||
165
src/GreenHome.Web/src/app/admin/posts/page.tsx
Normal file
165
src/GreenHome.Web/src/app/admin/posts/page.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { MessageSquare, Image as ImageIcon, Mic, Camera, Trash2 } from 'lucide-react'
|
||||
import { api, PagedResult, AdminPostItem } from '@/lib/api'
|
||||
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||
import { formatPersianDateFromIso } from '@/lib/date/persian-date'
|
||||
import { Pagination } from '@/components/navigation/Pagination'
|
||||
import { Badge } from '@/components/common/Badge'
|
||||
import { Button } from '@/components/common/Button'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { useConfirmDialog } from '@/components/utils/ConfirmDialog'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
// DevicePostType: Text=0, Image=1, Voice=2, SensorSnapshot=3
|
||||
const POST_TYPE_META: Record<number, { label: string; icon: typeof MessageSquare }> = {
|
||||
0: { label: 'متن', icon: MessageSquare },
|
||||
1: { label: 'تصویر', icon: ImageIcon },
|
||||
2: { label: 'ویس', icon: Mic },
|
||||
3: { label: 'اسنپشات', icon: Camera },
|
||||
}
|
||||
|
||||
export default function AdminPostsPage() {
|
||||
const [result, setResult] = useState<PagedResult<AdminPostItem> | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [busyId, setBusyId] = useState<number | null>(null)
|
||||
const { confirm, ConfirmDialog } = useConfirmDialog()
|
||||
|
||||
const fetchPosts = useCallback(async (p: number) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await api.adminPosts({ page: p, pageSize: PAGE_SIZE })
|
||||
setResult(data)
|
||||
} catch (e) {
|
||||
setError((e as Error).message || 'خطا در دریافت پستها')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchPosts(page)
|
||||
}, [page, fetchPosts])
|
||||
|
||||
const handleDelete = async (post: AdminPostItem) => {
|
||||
const ok = await confirm({
|
||||
title: 'حذف پست',
|
||||
message: `این پست بهطور کامل حذف میشود (با تصاویر آن). ادامه میدهید؟`,
|
||||
confirmText: 'حذف',
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
setBusyId(post.id)
|
||||
setError('')
|
||||
try {
|
||||
await api.adminDeletePost(post.id)
|
||||
// if the current page becomes empty, step back a page
|
||||
if (result && result.items.length === 1 && page > 1) {
|
||||
setPage(page - 1)
|
||||
} else {
|
||||
fetchPosts(page)
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message || 'خطا در حذف پست')
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = result ? Math.max(1, Math.ceil(result.totalCount / PAGE_SIZE)) : 1
|
||||
const showPagination = totalPages > 1
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-bold text-gray-900">پستها</h2>
|
||||
{result && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{toPersianDigits(result.totalCount)} پست
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl p-3">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="p-4 animate-pulse">
|
||||
<div className="h-4 w-2/3 bg-gray-100 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : result && result.items.length > 0 ? (
|
||||
<>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 border-b border-gray-200">
|
||||
<th className="text-right font-medium py-3 px-4">دستگاه</th>
|
||||
<th className="text-right font-medium py-3 px-4">نویسنده</th>
|
||||
<th className="text-right font-medium py-3 px-4">نوع</th>
|
||||
<th className="text-right font-medium py-3 px-4">محتوا</th>
|
||||
<th className="text-right font-medium py-3 px-4">تاریخ</th>
|
||||
<th className="text-right font-medium py-3 px-4">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.items.map((post) => {
|
||||
const meta = POST_TYPE_META[post.postType] ?? POST_TYPE_META[0]
|
||||
const TypeIcon = meta.icon
|
||||
return (
|
||||
<tr key={post.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50 transition-colors">
|
||||
<td className="py-3 px-4 font-medium text-gray-900 whitespace-nowrap">
|
||||
{post.deviceName}
|
||||
<span className="text-xs text-gray-400 font-normal"> ({toPersianDigits(post.deviceId)})</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-700 whitespace-nowrap">{post.authorName}</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="default" size="sm" icon={TypeIcon}>{meta.label}</Badge>
|
||||
{post.imageCount > 0 && (
|
||||
<span className="text-xs text-gray-400 mr-1">
|
||||
{toPersianDigits(post.imageCount)} تصویر
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-700 max-w-sm">
|
||||
<p className="truncate" title={post.content}>{post.content || '—'}</p>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-500 whitespace-nowrap">
|
||||
{formatPersianDateFromIso(post.createdAt)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Button variant="ghost" size="sm" icon={Trash2} disabled={busyId === post.id}
|
||||
onClick={() => handleDelete(post)} tooltip="حذف پست">
|
||||
حذف
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{showPagination && (
|
||||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={MessageSquare}
|
||||
title="پستی یافت نشد"
|
||||
message="هنوز پستی ثبت نشده است."
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ type ConfirmDialogOptions = {
|
||||
|
||||
type ConfirmDialogHook = {
|
||||
confirm: (options: ConfirmDialogOptions) => Promise<boolean>
|
||||
ConfirmDialog: React.ComponentType
|
||||
}
|
||||
|
||||
export function useConfirmDialog(): ConfirmDialogHook {
|
||||
@@ -71,7 +72,7 @@ export function useConfirmDialog(): ConfirmDialogHook {
|
||||
return {
|
||||
confirm,
|
||||
ConfirmDialog: ConfirmDialogComponent
|
||||
} as ConfirmDialogHook & { ConfirmDialog: React.ComponentType }
|
||||
}
|
||||
}
|
||||
|
||||
// Simple confirm function for direct use (fallback to window.confirm)
|
||||
|
||||
@@ -24,6 +24,10 @@ import type {
|
||||
AdminDeviceListItem,
|
||||
AdminDeviceDetail,
|
||||
AdminUsageDay,
|
||||
AdminAlertItem,
|
||||
AdminPostItem,
|
||||
AdminDeviceTestResult,
|
||||
AdminAuditLogItem,
|
||||
} from './types'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? ''
|
||||
@@ -239,4 +243,34 @@ export const api = {
|
||||
if (q.userId) params.set('userId', String(q.userId))
|
||||
return http<AdminUsageDay[]>(`${API_BASE}/api/admin/usage?${params.toString()}`)
|
||||
},
|
||||
adminAlerts: (q: { deviceId?: number; status?: number; from?: string; to?: string; page?: number; pageSize?: number }) => {
|
||||
const params = new URLSearchParams()
|
||||
if (q.deviceId) params.set('deviceId', String(q.deviceId))
|
||||
if (q.status) params.set('status', String(q.status))
|
||||
if (q.from) params.set('from', q.from)
|
||||
if (q.to) params.set('to', q.to)
|
||||
if (q.page) params.set('page', String(q.page))
|
||||
if (q.pageSize) params.set('pageSize', String(q.pageSize))
|
||||
return http<PagedResult<AdminAlertItem>>(`${API_BASE}/api/admin/alerts?${params.toString()}`)
|
||||
},
|
||||
adminPosts: (q: { deviceId?: number; page?: number; pageSize?: number }) => {
|
||||
const params = new URLSearchParams()
|
||||
if (q.deviceId) params.set('deviceId', String(q.deviceId))
|
||||
if (q.page) params.set('page', String(q.page))
|
||||
if (q.pageSize) params.set('pageSize', String(q.pageSize))
|
||||
return http<PagedResult<AdminPostItem>>(`${API_BASE}/api/admin/posts?${params.toString()}`)
|
||||
},
|
||||
adminDeletePost: (id: number) =>
|
||||
http<void>(`${API_BASE}/api/admin/posts/${id}`, { method: 'DELETE' }),
|
||||
adminTestSms: (deviceId: number) =>
|
||||
http<AdminDeviceTestResult>(`${API_BASE}/api/admin/devices/${deviceId}/test-sms`, { method: 'POST' }),
|
||||
adminTestVoice: (deviceId: number) =>
|
||||
http<AdminDeviceTestResult>(`${API_BASE}/api/admin/devices/${deviceId}/test-voice`, { method: 'POST' }),
|
||||
adminAuditLogs: (q: { adminId?: number; page?: number; pageSize?: number }) => {
|
||||
const params = new URLSearchParams()
|
||||
if (q.adminId) params.set('adminId', String(q.adminId))
|
||||
if (q.page) params.set('page', String(q.page))
|
||||
if (q.pageSize) params.set('pageSize', String(q.pageSize))
|
||||
return http<PagedResult<AdminAuditLogItem>>(`${API_BASE}/api/admin/audit-logs?${params.toString()}`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -342,3 +342,41 @@ export type AdminUsageDay = {
|
||||
aiTokenCount: number
|
||||
}
|
||||
|
||||
export type AdminAlertItem = {
|
||||
id: number
|
||||
deviceId: number
|
||||
deviceName: string
|
||||
ownerName: string
|
||||
message: string
|
||||
notificationType: number
|
||||
status: number
|
||||
errorMessage?: string
|
||||
sentAt: string
|
||||
}
|
||||
|
||||
export type AdminPostItem = {
|
||||
id: number
|
||||
deviceId: number
|
||||
deviceName: string
|
||||
authorName: string
|
||||
content: string
|
||||
postType: number
|
||||
imageCount: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type AdminDeviceTestResult = {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export type AdminAuditLogItem = {
|
||||
id: number
|
||||
adminUserId: number
|
||||
adminName: string
|
||||
action: string
|
||||
targetId: string
|
||||
details: string
|
||||
at: string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user