admin page phaze2

This commit is contained in:
2026-09-25 16:34:36 +03:30
parent 9d4ad26dcc
commit 6abb5aae15
19 changed files with 2847 additions and 6 deletions

View File

@@ -123,4 +123,96 @@ public sealed class AdminController : ControllerBase
var result = await _adminService.GetUsageAsync(from, to, userId, cancellationToken); var result = await _adminService.GetUsageAsync(from, to, userId, cancellationToken);
return Ok(result); 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);
}
} }

View File

@@ -127,3 +127,77 @@ public sealed class UsageDayDto
public int AiQueryCount { get; set; } public int AiQueryCount { get; set; }
public int AiTokenCount { 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; }
}

View File

@@ -12,4 +12,10 @@ public interface IAdminService
Task<PagedResult<AdminDeviceListItemDto>> GetDevicesAsync(AdminDeviceFilter filter, CancellationToken cancellationToken); Task<PagedResult<AdminDeviceListItemDto>> GetDevicesAsync(AdminDeviceFilter filter, CancellationToken cancellationToken);
Task<AdminDeviceDetailDto?> GetDeviceAsync(int id, CancellationToken cancellationToken); Task<AdminDeviceDetailDto?> GetDeviceAsync(int id, CancellationToken cancellationToken);
Task<IReadOnlyList<UsageDayDto>> GetUsageAsync(DateTime? from, DateTime? to, int? userId, 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);
} }

View 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; }
}

View File

@@ -31,6 +31,7 @@ public sealed class GreenHomeDbContext : DbContext
public DbSet<Domain.DevicePostImage> DevicePostImages => Set<Domain.DevicePostImage>(); public DbSet<Domain.DevicePostImage> DevicePostImages => Set<Domain.DevicePostImage>();
public DbSet<Domain.PowerOutageLog> PowerOutageLogs => Set<Domain.PowerOutageLog>(); public DbSet<Domain.PowerOutageLog> PowerOutageLogs => Set<Domain.PowerOutageLog>();
public DbSet<Domain.UsageCounter> UsageCounters => Set<Domain.UsageCounter>(); public DbSet<Domain.UsageCounter> UsageCounters => Set<Domain.UsageCounter>();
public DbSet<Domain.AdminAuditLog> AdminAuditLogs => Set<Domain.AdminAuditLog>();
protected override void OnModelCreating(ModelBuilder modelBuilder) 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 => new { x.UserId, x.Date }).IsUnique();
b.HasIndex(x => x.Date); 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);
});
} }
} }

View File

@@ -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");
}
}
}

View File

@@ -77,6 +77,44 @@ namespace GreenHome.Infrastructure.Data.Migrations
b.ToTable("AIQueries", (string)null); 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 => modelBuilder.Entity("GreenHome.Domain.AlertCondition", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -1173,6 +1211,17 @@ namespace GreenHome.Infrastructure.Data.Migrations
b.Navigation("User"); 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 => modelBuilder.Entity("GreenHome.Domain.AlertCondition", b =>
{ {
b.HasOne("GreenHome.Domain.Device", "Device") b.HasOne("GreenHome.Domain.Device", "Device")

View File

@@ -7,6 +7,10 @@
<NuGetAudit>false</NuGetAudit> <NuGetAudit>false</NuGetAudit>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" /> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />

View File

@@ -1,8 +1,13 @@
using System.Text.Json;
using AutoMapper; using AutoMapper;
using GreenHome.Application; using GreenHome.Application;
using GreenHome.Application.Dtos.Admin; using GreenHome.Application.Dtos.Admin;
using GreenHome.Domain; using GreenHome.Domain;
using GreenHome.Infrastructure.Data; 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.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
@@ -12,12 +17,27 @@ public sealed class AdminService : IAdminService
{ {
private readonly GreenHomeDbContext _dbContext; private readonly GreenHomeDbContext _dbContext;
private readonly IMapper _mapper; private readonly IMapper _mapper;
private readonly ISmsService _smsService;
private readonly IVoiceCallService _voiceCallService;
private readonly UsageTracker _usageTracker;
private readonly IWebHostEnvironment _environment;
private readonly int _onlineWindowMinutes; 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; _dbContext = dbContext;
_mapper = mapper; _mapper = mapper;
_smsService = smsService;
_voiceCallService = voiceCallService;
_usageTracker = usageTracker;
_environment = environment;
_onlineWindowMinutes = configuration.GetValue("Admin:OnlineWindowMinutes", 15); _onlineWindowMinutes = configuration.GetValue("Admin:OnlineWindowMinutes", 15);
} }
@@ -173,6 +193,8 @@ public sealed class AdminService : IAdminService
} }
user.Role = role; user.Role = role;
AddAuditLog(currentAdminId, "User.SetRole", id.ToString(),
JsonSerializer.Serialize(new { role = role.ToString(), roleId = (int)role }));
await _dbContext.SaveChangesAsync(cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken);
return UserMutationOutcome.Ok; return UserMutationOutcome.Ok;
} }
@@ -193,6 +215,7 @@ public sealed class AdminService : IAdminService
} }
user.IsDisabled = !isEnabled; user.IsDisabled = !isEnabled;
AddAuditLog(currentAdminId, isEnabled ? "User.Enable" : "User.Disable", id.ToString());
await _dbContext.SaveChangesAsync(cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken);
return UserMutationOutcome.Ok; return UserMutationOutcome.Ok;
} }
@@ -319,8 +342,280 @@ public sealed class AdminService : IAdminService
}).ToList(); }).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 ==================== // ==================== 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> /// <summary>
/// آخرین تلیمتری هر دستگاه + نام مالک (برای لیست‌های admin). /// آخرین تلیمتری هر دستگاه + نام مالک (برای لیست‌های admin).
/// </summary> /// </summary>

View File

@@ -16,7 +16,10 @@ import AdminLayout from '@/app/admin/AdminLayout'
import AdminDashboardPage from '@/app/admin/page' import AdminDashboardPage from '@/app/admin/page'
import AdminUsersPage from '@/app/admin/users/page' import AdminUsersPage from '@/app/admin/users/page'
import AdminDevicesPage from '@/app/admin/devices/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 AdminUsagePage from '@/app/admin/usage/page'
import AdminAuditPage from '@/app/admin/audit/page'
function DeviceRedirect() { function DeviceRedirect() {
const location = useLocation() const location = useLocation()
@@ -46,7 +49,10 @@ export default function App() {
<Route index element={<AdminDashboardPage />} /> <Route index element={<AdminDashboardPage />} />
<Route path="users" element={<AdminUsersPage />} /> <Route path="users" element={<AdminUsersPage />} />
<Route path="devices" element={<AdminDevicesPage />} /> <Route path="devices" element={<AdminDevicesPage />} />
<Route path="alerts" element={<AdminAlertsPage />} />
<Route path="posts" element={<AdminPostsPage />} />
<Route path="usage" element={<AdminUsagePage />} /> <Route path="usage" element={<AdminUsagePage />} />
<Route path="audit" element={<AdminAuditPage />} />
</Route> </Route>
<Route path="/alert-settings" element={<DeviceRedirect />} /> <Route path="/alert-settings" element={<DeviceRedirect />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View File

@@ -2,14 +2,17 @@
import { Outlet, useLocation } from 'react-router-dom' import { Outlet, useLocation } from 'react-router-dom'
import { Link } from '@/lib/router' 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' import { cn } from '@/lib/utils'
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: '/admin', label: 'داشبورد', icon: LayoutDashboard, exact: true }, { href: '/admin', label: 'داشبورد', icon: LayoutDashboard, exact: true },
{ href: '/admin/users', label: 'کاربران', icon: Users, exact: false }, { href: '/admin/users', label: 'کاربران', icon: Users, exact: false },
{ href: '/admin/devices', label: 'دستگاه‌ها', icon: Cpu, 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/usage', label: 'مصرف', icon: BarChart3, exact: false },
{ href: '/admin/audit', label: 'گزارش اعمال', icon: ScrollText, exact: false },
] ]
export default function AdminLayout() { export default function AdminLayout() {

View 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>
)
}

View 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>
)
}

View File

@@ -1,8 +1,8 @@
'use client' 'use client'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Cpu, Thermometer, Eye } from 'lucide-react' import { Cpu, Thermometer, Eye, MessageSquare, PhoneCall } from 'lucide-react'
import { api, PagedResult, AdminDeviceListItem, AdminDeviceDetail } from '@/lib/api' import { api, PagedResult, AdminDeviceListItem, AdminDeviceDetail, AdminDeviceTestResult } from '@/lib/api'
import { toPersianDigits } from '@/lib/format/persian-digits' import { toPersianDigits } from '@/lib/format/persian-digits'
import { formatPersianDateFromIso } from '@/lib/date/persian-date' import { formatPersianDateFromIso } from '@/lib/date/persian-date'
import { SearchInput } from '@/components/forms/SearchInput' import { SearchInput } from '@/components/forms/SearchInput'
@@ -22,6 +22,8 @@ export default function AdminDevicesPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [detail, setDetail] = useState<AdminDeviceDetail | null>(null) 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) => { const fetchDevices = useCallback(async (p: number, s: string) => {
setLoading(true) setLoading(true)
@@ -49,12 +51,26 @@ export default function AdminDevicesPage() {
const openDetail = async (id: number) => { const openDetail = async (id: number) => {
try { 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) { } catch (e) {
setError((e as Error).message || 'خطا در دریافت جزئیات دستگاه') 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 totalPages = result ? Math.max(1, Math.ceil(result.totalCount / PAGE_SIZE)) : 1
const showPagination = totalPages > 1 const showPagination = totalPages > 1
@@ -191,6 +207,36 @@ export default function AdminDevicesPage() {
)} )}
</div> </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> <div>
<h4 className="font-semibold text-gray-900 mb-2 flex items-center gap-2"> <h4 className="font-semibold text-gray-900 mb-2 flex items-center gap-2">
<Thermometer className="w-4 h-4 text-gray-400" /> <Thermometer className="w-4 h-4 text-gray-400" />

View 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>
)
}

View File

@@ -14,6 +14,7 @@ type ConfirmDialogOptions = {
type ConfirmDialogHook = { type ConfirmDialogHook = {
confirm: (options: ConfirmDialogOptions) => Promise<boolean> confirm: (options: ConfirmDialogOptions) => Promise<boolean>
ConfirmDialog: React.ComponentType
} }
export function useConfirmDialog(): ConfirmDialogHook { export function useConfirmDialog(): ConfirmDialogHook {
@@ -71,7 +72,7 @@ export function useConfirmDialog(): ConfirmDialogHook {
return { return {
confirm, confirm,
ConfirmDialog: ConfirmDialogComponent ConfirmDialog: ConfirmDialogComponent
} as ConfirmDialogHook & { ConfirmDialog: React.ComponentType } }
} }
// Simple confirm function for direct use (fallback to window.confirm) // Simple confirm function for direct use (fallback to window.confirm)

View File

@@ -24,6 +24,10 @@ import type {
AdminDeviceListItem, AdminDeviceListItem,
AdminDeviceDetail, AdminDeviceDetail,
AdminUsageDay, AdminUsageDay,
AdminAlertItem,
AdminPostItem,
AdminDeviceTestResult,
AdminAuditLogItem,
} from './types' } from './types'
const API_BASE = import.meta.env.VITE_API_URL ?? '' 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)) if (q.userId) params.set('userId', String(q.userId))
return http<AdminUsageDay[]>(`${API_BASE}/api/admin/usage?${params.toString()}`) 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()}`)
},
} }

View File

@@ -342,3 +342,41 @@ export type AdminUsageDay = {
aiTokenCount: number 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
}