Add domestic vendor push infrastructure
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
namespace MiaoJiZhang.Api.Contracts;
|
||||
|
||||
public record PushPreferencesResponse(bool System, bool Budget, bool Operations);
|
||||
public record UpdatePushPreferencesRequest(bool System, bool Budget, bool Operations);
|
||||
|
||||
public record RegisterPushDeviceRequest(
|
||||
string Provider,
|
||||
string Token,
|
||||
string PackageName,
|
||||
string Flavor,
|
||||
string AppVersion,
|
||||
int VersionCode,
|
||||
bool NotificationsAllowed);
|
||||
|
||||
public record PushDeviceRegistrationResponse(
|
||||
long DeviceId,
|
||||
string InstallationId,
|
||||
string Provider,
|
||||
bool Active,
|
||||
string UnbindToken);
|
||||
|
||||
public record CreatePushCampaignRequest(
|
||||
string Title,
|
||||
string Body,
|
||||
string Category,
|
||||
string Action = "none",
|
||||
string? EntityId = null,
|
||||
string Flavor = "production",
|
||||
string? Provider = null,
|
||||
int? MinVersionCode = null,
|
||||
int? MaxVersionCode = null,
|
||||
long? TargetUserId = null,
|
||||
int? TtlSeconds = null);
|
||||
|
||||
public record SchedulePushCampaignRequest(DateTime? ScheduledAt = null);
|
||||
|
||||
public record TestPushRequest(
|
||||
long DeviceId,
|
||||
string Title,
|
||||
string Body,
|
||||
string Category = "system",
|
||||
string Action = "none",
|
||||
string? EntityId = null);
|
||||
@@ -0,0 +1,326 @@
|
||||
using MiaoJiZhang.Api.Contracts;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AdminAuth]
|
||||
[Route("api/admin/push")]
|
||||
public class AdminPushController(
|
||||
AppDbContext db,
|
||||
PushProviderRegistry providers,
|
||||
IConfiguration configuration) : ControllerBase
|
||||
{
|
||||
[HttpGet("campaigns")]
|
||||
public async Task<IActionResult> Campaigns(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int limit = 20,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
limit = Math.Clamp(limit, 1, 100);
|
||||
var query = db.PushMessages.AsNoTracking().Where(message => message.Source == "admin");
|
||||
var total = await query.CountAsync(ct);
|
||||
var messages = await query.OrderByDescending(message => message.CreatedAt)
|
||||
.Skip((page - 1) * limit).Take(limit).ToListAsync(ct);
|
||||
var ids = messages.Select(message => message.Id).ToList();
|
||||
var counts = await db.PushDeliveries.Where(delivery => ids.Contains(delivery.PushMessageId))
|
||||
.GroupBy(delivery => new { delivery.PushMessageId, delivery.State })
|
||||
.Select(group => new { group.Key.PushMessageId, group.Key.State, Count = group.Count() })
|
||||
.ToListAsync(ct);
|
||||
return Ok(new
|
||||
{
|
||||
total,
|
||||
page,
|
||||
list = messages.Select(message => ToDto(message, counts
|
||||
.Where(item => item.PushMessageId == message.Id)
|
||||
.ToDictionary(item => item.State, item => item.Count))),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("campaigns/estimate")]
|
||||
public async Task<IActionResult> Estimate(CreatePushCampaignRequest request, CancellationToken ct)
|
||||
{
|
||||
var error = Validate(request);
|
||||
if (error is not null) return BadRequest(error);
|
||||
var count = await EligibleDevices(request).CountAsync(ct);
|
||||
return Ok(new { devices = count });
|
||||
}
|
||||
|
||||
[HttpPost("campaigns")]
|
||||
public async Task<IActionResult> Create(CreatePushCampaignRequest request, CancellationToken ct)
|
||||
{
|
||||
var error = Validate(request);
|
||||
if (error is not null) return BadRequest(error);
|
||||
if (request.TargetUserId.HasValue &&
|
||||
!await db.Users.AnyAsync(user => user.Id == request.TargetUserId.Value, ct))
|
||||
return BadRequest(new ApiError("PUSH_TARGET_INVALID", "目标用户不存在"));
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var message = Map(request, new PushMessage
|
||||
{
|
||||
PublicId = Guid.NewGuid().ToString(),
|
||||
Source = "admin",
|
||||
State = PushMessageStates.Draft,
|
||||
CreatedAt = now,
|
||||
}, now);
|
||||
db.PushMessages.Add(message);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Ok(ToDto(message, new Dictionary<string, int>()));
|
||||
}
|
||||
|
||||
[HttpPut("campaigns/{id:long}")]
|
||||
public async Task<IActionResult> Update(long id, CreatePushCampaignRequest request, CancellationToken ct)
|
||||
{
|
||||
var error = Validate(request);
|
||||
if (error is not null) return BadRequest(error);
|
||||
if (request.TargetUserId.HasValue &&
|
||||
!await db.Users.AnyAsync(user => user.Id == request.TargetUserId.Value, ct))
|
||||
return BadRequest(new ApiError("PUSH_TARGET_INVALID", "目标用户不存在"));
|
||||
var message = await db.PushMessages.FirstOrDefaultAsync(item => item.Id == id && item.Source == "admin", ct);
|
||||
if (message is null) return NotFound();
|
||||
if (message.State is not (PushMessageStates.Draft or PushMessageStates.Scheduled))
|
||||
return Conflict(new ApiError("PUSH_CAMPAIGN_LOCKED", "推送已开始发送,不能再编辑"));
|
||||
Map(request, message, DateTime.UtcNow);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Ok(ToDto(message, new Dictionary<string, int>()));
|
||||
}
|
||||
|
||||
[HttpPost("campaigns/{id:long}/send")]
|
||||
public async Task<IActionResult> Send(
|
||||
long id,
|
||||
SchedulePushCampaignRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var message = await db.PushMessages.FirstOrDefaultAsync(item => item.Id == id && item.Source == "admin", ct);
|
||||
if (message is null) return NotFound();
|
||||
if (message.State is not (PushMessageStates.Draft or PushMessageStates.Scheduled))
|
||||
return Conflict(new ApiError("PUSH_CAMPAIGN_LOCKED", "推送已开始发送或已经结束"));
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var scheduledAt = request.ScheduledAt?.ToUniversalTime();
|
||||
message.ScheduledAt = scheduledAt;
|
||||
message.State = scheduledAt.HasValue && scheduledAt.Value > now.AddSeconds(5)
|
||||
? PushMessageStates.Scheduled
|
||||
: PushMessageStates.Queued;
|
||||
message.UpdatedAt = now;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Ok(ToDto(message, new Dictionary<string, int>()));
|
||||
}
|
||||
|
||||
[HttpPost("campaigns/{id:long}/cancel")]
|
||||
public async Task<IActionResult> Cancel(long id, CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var cancelled = await db.PushMessages
|
||||
.Where(message => message.Id == id && message.Source == "admin" &&
|
||||
(message.State == PushMessageStates.Draft ||
|
||||
message.State == PushMessageStates.Scheduled ||
|
||||
message.State == PushMessageStates.Queued) &&
|
||||
message.StartedAt == null)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(message => message.State, PushMessageStates.Cancelled)
|
||||
.SetProperty(message => message.CancelledAt, now)
|
||||
.SetProperty(message => message.UpdatedAt, now), ct);
|
||||
if (cancelled != 1)
|
||||
{
|
||||
var exists = await db.PushMessages.AnyAsync(
|
||||
message => message.Id == id && message.Source == "admin", ct);
|
||||
return exists
|
||||
? Conflict(new ApiError("PUSH_CAMPAIGN_LOCKED", "推送已经开始,不能取消"))
|
||||
: NotFound();
|
||||
}
|
||||
var message = await db.PushMessages.AsNoTracking().FirstAsync(item => item.Id == id, ct);
|
||||
return Ok(ToDto(message, new Dictionary<string, int>()));
|
||||
}
|
||||
|
||||
[HttpGet("devices")]
|
||||
public async Task<IActionResult> Devices(
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] int limit = 50,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, 100);
|
||||
var query = db.PushDevices.AsNoTracking().Include(device => device.User).AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.Trim();
|
||||
query = query.Where(device => device.User.Username.Contains(term) ||
|
||||
device.InstallationId.Contains(term));
|
||||
}
|
||||
var devices = await query.OrderByDescending(device => device.LastSeenAt).Take(limit).ToListAsync(ct);
|
||||
return Ok(devices.Select(device => new
|
||||
{
|
||||
device.Id,
|
||||
device.UserId,
|
||||
device.User.Username,
|
||||
device.Provider,
|
||||
device.PackageName,
|
||||
device.Flavor,
|
||||
device.AppVersion,
|
||||
device.VersionCode,
|
||||
device.NotificationsAllowed,
|
||||
device.IsActive,
|
||||
device.DisabledReason,
|
||||
tokenSuffix = device.TokenHash[^Math.Min(8, device.TokenHash.Length)..],
|
||||
device.LastSeenAt,
|
||||
}));
|
||||
}
|
||||
|
||||
[HttpPost("test")]
|
||||
public async Task<IActionResult> Test(TestPushRequest request, CancellationToken ct)
|
||||
{
|
||||
if (request.Title.Trim().Length is < 1 or > 80 || request.Body.Trim().Length is < 1 or > 240 ||
|
||||
!PushCategories.All.Contains(request.Category) || !PushActions.All.Contains(request.Action))
|
||||
return BadRequest(new ApiError("PUSH_MESSAGE_INVALID", "测试推送内容或分类无效"));
|
||||
var device = await db.PushDevices.FirstOrDefaultAsync(item => item.Id == request.DeviceId, ct);
|
||||
if (device is null || !device.IsActive || !device.NotificationsAllowed)
|
||||
return BadRequest(new ApiError("PUSH_DEVICE_INACTIVE", "测试设备不存在或当前不可投递"));
|
||||
var now = DateTime.UtcNow;
|
||||
var message = new PushMessage
|
||||
{
|
||||
PublicId = Guid.NewGuid().ToString(),
|
||||
Source = "admin",
|
||||
State = PushMessageStates.Queued,
|
||||
Category = request.Category.ToLowerInvariant(),
|
||||
Title = request.Title.Trim(),
|
||||
Body = request.Body.Trim(),
|
||||
Action = request.Action.ToLowerInvariant(),
|
||||
EntityId = request.EntityId?.Trim(),
|
||||
TargetUserId = device.UserId,
|
||||
Flavor = device.Flavor,
|
||||
ProviderFilter = device.Provider,
|
||||
TtlSeconds = DefaultTtl(request.Category),
|
||||
IsTest = true,
|
||||
TestDeviceId = device.Id,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
db.PushMessages.Add(message);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Ok(new { message.Id, message.PublicId, message.State });
|
||||
}
|
||||
|
||||
[HttpGet("health")]
|
||||
public IActionResult Health()
|
||||
{
|
||||
var flavors = new[] { "production", "internal" };
|
||||
return Ok(new
|
||||
{
|
||||
enabled = configuration.GetValue<bool>("Push:Enabled"),
|
||||
tokenEncryptionConfigured = !string.IsNullOrWhiteSpace(configuration["Push:TokenEncryptionKey"]),
|
||||
providers = providers.All.Select(provider => new
|
||||
{
|
||||
provider = provider.Provider,
|
||||
environments = flavors.Select(flavor => new
|
||||
{
|
||||
flavor,
|
||||
enabled = provider.IsEnabled(flavor),
|
||||
errors = provider.ConfigurationErrors(flavor),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
private IQueryable<PushDevice> EligibleDevices(CreatePushCampaignRequest request)
|
||||
{
|
||||
var category = request.Category.Trim().ToLowerInvariant();
|
||||
var query = db.PushDevices.Where(device =>
|
||||
device.IsActive && device.NotificationsAllowed &&
|
||||
!device.User.IsBanned && device.User.AccountClosureScheduledAt == null &&
|
||||
db.UserPushPreferences.Any(preference => preference.UserId == device.UserId &&
|
||||
preference.Category == category && preference.IsEnabled) &&
|
||||
device.Flavor == request.Flavor.ToLowerInvariant());
|
||||
if (request.TargetUserId.HasValue)
|
||||
query = query.Where(device => device.UserId == request.TargetUserId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(request.Provider))
|
||||
query = query.Where(device => device.Provider == request.Provider.ToLowerInvariant());
|
||||
if (request.MinVersionCode.HasValue)
|
||||
query = query.Where(device => device.VersionCode >= request.MinVersionCode.Value);
|
||||
if (request.MaxVersionCode.HasValue)
|
||||
query = query.Where(device => device.VersionCode <= request.MaxVersionCode.Value);
|
||||
return query;
|
||||
}
|
||||
|
||||
private static PushMessage Map(CreatePushCampaignRequest request, PushMessage message, DateTime now)
|
||||
{
|
||||
message.Title = request.Title.Trim();
|
||||
message.Body = request.Body.Trim();
|
||||
message.Category = request.Category.Trim().ToLowerInvariant();
|
||||
message.Action = request.Action.Trim().ToLowerInvariant();
|
||||
message.EntityId = string.IsNullOrWhiteSpace(request.EntityId) ? null : request.EntityId.Trim();
|
||||
message.Flavor = request.Flavor.Trim().ToLowerInvariant();
|
||||
message.ProviderFilter = string.IsNullOrWhiteSpace(request.Provider)
|
||||
? null
|
||||
: request.Provider.Trim().ToLowerInvariant();
|
||||
message.MinVersionCode = request.MinVersionCode;
|
||||
message.MaxVersionCode = request.MaxVersionCode;
|
||||
message.TargetUserId = request.TargetUserId;
|
||||
message.TtlSeconds = request.TtlSeconds ?? DefaultTtl(message.Category);
|
||||
message.UpdatedAt = now;
|
||||
return message;
|
||||
}
|
||||
|
||||
private static ApiError? Validate(CreatePushCampaignRequest request)
|
||||
{
|
||||
if (request.Title.Trim().Length is < 1 or > 80)
|
||||
return new ApiError("PUSH_TITLE_INVALID", "标题长度必须在 1 到 80 个字符之间");
|
||||
if (request.Body.Trim().Length is < 1 or > 240)
|
||||
return new ApiError("PUSH_BODY_INVALID", "正文长度必须在 1 到 240 个字符之间");
|
||||
if (!PushCategories.All.Contains(request.Category))
|
||||
return new ApiError("PUSH_CATEGORY_INVALID", "推送分类无效");
|
||||
if (!PushActions.All.Contains(request.Action))
|
||||
return new ApiError("PUSH_ACTION_INVALID", "点击动作无效");
|
||||
if (request.Flavor is not ("production" or "internal"))
|
||||
return new ApiError("PUSH_FLAVOR_INVALID", "推送环境无效");
|
||||
if (!string.IsNullOrWhiteSpace(request.Provider) && !PushProviders.All.Contains(request.Provider))
|
||||
return new ApiError("PUSH_PROVIDER_INVALID", "推送厂商无效");
|
||||
if (request.MinVersionCode is < 1 || request.MaxVersionCode is < 1 ||
|
||||
request.MinVersionCode > request.MaxVersionCode)
|
||||
return new ApiError("PUSH_VERSION_RANGE_INVALID", "版本号范围无效");
|
||||
if (request.TtlSeconds.HasValue && request.TtlSeconds is < 60 or > 604800)
|
||||
return new ApiError("PUSH_TTL_INVALID", "消息有效期必须在 60 秒到 7 天之间");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int DefaultTtl(string category) => category.ToLowerInvariant() switch
|
||||
{
|
||||
PushCategories.System => 72 * 3600,
|
||||
_ => 24 * 3600,
|
||||
};
|
||||
|
||||
private static object ToDto(PushMessage message, IReadOnlyDictionary<string, int> counts) => new
|
||||
{
|
||||
message.Id,
|
||||
message.PublicId,
|
||||
message.State,
|
||||
message.Title,
|
||||
message.Body,
|
||||
message.Category,
|
||||
message.Action,
|
||||
message.EntityId,
|
||||
message.Flavor,
|
||||
provider = message.ProviderFilter,
|
||||
message.MinVersionCode,
|
||||
message.MaxVersionCode,
|
||||
message.TargetUserId,
|
||||
message.TtlSeconds,
|
||||
message.ScheduledAt,
|
||||
message.CreatedAt,
|
||||
message.StartedAt,
|
||||
message.CompletedAt,
|
||||
message.CancelledAt,
|
||||
deliveries = new
|
||||
{
|
||||
queued = counts.GetValueOrDefault(PushDeliveryStates.Queued),
|
||||
sending = counts.GetValueOrDefault(PushDeliveryStates.Sending),
|
||||
accepted = counts.GetValueOrDefault(PushDeliveryStates.Accepted),
|
||||
failed = counts.GetValueOrDefault(PushDeliveryStates.Failed),
|
||||
skipped = counts.GetValueOrDefault(PushDeliveryStates.Skipped),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Security.Claims;
|
||||
using MiaoJiZhang.Api.Contracts;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/push")]
|
||||
public class PushController(AppDbContext db, PushTokenProtector tokenProtector) : ControllerBase
|
||||
{
|
||||
private long Uid => long.Parse(
|
||||
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!);
|
||||
|
||||
[HttpGet("preferences")]
|
||||
public async Task<ActionResult<PushPreferencesResponse>> Preferences(CancellationToken ct)
|
||||
{
|
||||
var enabled = await db.UserPushPreferences
|
||||
.Where(item => item.UserId == Uid && item.IsEnabled)
|
||||
.Select(item => item.Category)
|
||||
.ToListAsync(ct);
|
||||
return Ok(new PushPreferencesResponse(
|
||||
enabled.Contains(PushCategories.System),
|
||||
enabled.Contains(PushCategories.Budget),
|
||||
enabled.Contains(PushCategories.Operations)));
|
||||
}
|
||||
|
||||
[HttpPut("preferences")]
|
||||
public async Task<ActionResult<PushPreferencesResponse>> UpdatePreferences(
|
||||
UpdatePushPreferencesRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var desired = new Dictionary<string, bool>
|
||||
{
|
||||
[PushCategories.System] = request.System,
|
||||
[PushCategories.Budget] = request.Budget,
|
||||
[PushCategories.Operations] = request.Operations,
|
||||
};
|
||||
var existing = await db.UserPushPreferences
|
||||
.Where(item => item.UserId == Uid)
|
||||
.ToDictionaryAsync(item => item.Category, ct);
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var (category, enabled) in desired)
|
||||
{
|
||||
if (!existing.TryGetValue(category, out var preference))
|
||||
{
|
||||
preference = new UserPushPreference
|
||||
{
|
||||
UserId = Uid,
|
||||
Category = category,
|
||||
};
|
||||
db.UserPushPreferences.Add(preference);
|
||||
}
|
||||
preference.IsEnabled = enabled;
|
||||
preference.UpdatedAt = now;
|
||||
}
|
||||
if (!desired.Values.Any(value => value))
|
||||
{
|
||||
var devices = await db.PushDevices
|
||||
.Where(device => device.UserId == Uid && device.IsActive)
|
||||
.ToListAsync(ct);
|
||||
foreach (var device in devices)
|
||||
{
|
||||
device.IsActive = false;
|
||||
device.DisabledReason = "all_categories_disabled";
|
||||
device.UpdatedAt = now;
|
||||
}
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Ok(new PushPreferencesResponse(request.System, request.Budget, request.Operations));
|
||||
}
|
||||
|
||||
[HttpPut("devices/{installationId}")]
|
||||
public async Task<ActionResult<PushDeviceRegistrationResponse>> RegisterDevice(
|
||||
string installationId,
|
||||
RegisterPushDeviceRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var validation = ValidateDevice(installationId, request);
|
||||
if (validation is not null) return validation;
|
||||
if (!tokenProtector.IsConfigured)
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable,
|
||||
new ApiError("PUSH_NOT_CONFIGURED", "推送服务尚未完成安全配置"));
|
||||
|
||||
var provider = request.Provider.Trim().ToLowerInvariant();
|
||||
var token = request.Token.Trim();
|
||||
var tokenHash = PushTokenProtector.Hash(token);
|
||||
var duplicate = await db.PushDevices.FirstOrDefaultAsync(device =>
|
||||
device.Provider == provider &&
|
||||
device.PackageName == request.PackageName &&
|
||||
device.TokenHash == tokenHash &&
|
||||
device.InstallationId != installationId, ct);
|
||||
if (duplicate is not null) db.PushDevices.Remove(duplicate);
|
||||
|
||||
var device = await db.PushDevices.FirstOrDefaultAsync(item =>
|
||||
item.PackageName == request.PackageName &&
|
||||
item.InstallationId == installationId, ct);
|
||||
var now = DateTime.UtcNow;
|
||||
if (device is null)
|
||||
{
|
||||
device = new PushDevice
|
||||
{
|
||||
UserId = Uid,
|
||||
InstallationId = installationId,
|
||||
PackageName = request.PackageName,
|
||||
CreatedAt = now,
|
||||
};
|
||||
db.PushDevices.Add(device);
|
||||
}
|
||||
|
||||
var unbindToken = PushTokenProtector.CreateUnbindToken();
|
||||
device.UserId = Uid;
|
||||
device.Provider = provider;
|
||||
device.TokenCiphertext = tokenProtector.Protect(token);
|
||||
device.TokenHash = tokenHash;
|
||||
device.UnbindTokenHash = PushTokenProtector.Hash(unbindToken);
|
||||
device.Flavor = request.Flavor.Trim().ToLowerInvariant();
|
||||
device.AppVersion = request.AppVersion.Trim();
|
||||
device.VersionCode = request.VersionCode;
|
||||
device.NotificationsAllowed = request.NotificationsAllowed;
|
||||
device.IsActive = request.NotificationsAllowed;
|
||||
device.DisabledReason = request.NotificationsAllowed ? null : "notification_permission_denied";
|
||||
device.UpdatedAt = now;
|
||||
device.LastSeenAt = now;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return Ok(new PushDeviceRegistrationResponse(
|
||||
device.Id,
|
||||
device.InstallationId,
|
||||
device.Provider,
|
||||
device.IsActive,
|
||||
unbindToken));
|
||||
}
|
||||
|
||||
[HttpDelete("devices/{installationId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> UnregisterDevice(
|
||||
string installationId,
|
||||
[FromHeader(Name = "X-Push-Unbind-Token")] string? unbindToken,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var userIdValue = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||
var hasUser = long.TryParse(userIdValue, out var userId);
|
||||
var unbindHash = string.IsNullOrWhiteSpace(unbindToken)
|
||||
? null
|
||||
: PushTokenProtector.Hash(unbindToken);
|
||||
var device = await db.PushDevices.FirstOrDefaultAsync(item =>
|
||||
item.InstallationId == installationId &&
|
||||
((hasUser && item.UserId == userId) ||
|
||||
(unbindHash != null && item.UnbindTokenHash == unbindHash)), ct);
|
||||
if (device is null)
|
||||
return hasUser || unbindHash is not null ? NoContent() : Unauthorized();
|
||||
|
||||
db.PushDevices.Remove(device);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private ActionResult? ValidateDevice(string installationId, RegisterPushDeviceRequest request)
|
||||
{
|
||||
if (!Guid.TryParse(installationId, out _))
|
||||
return BadRequest(new ApiError("INSTALLATION_ID_INVALID", "设备安装标识无效"));
|
||||
if (!PushProviders.All.Contains(request.Provider))
|
||||
return BadRequest(new ApiError("PUSH_PROVIDER_INVALID", "不支持该设备推送厂商"));
|
||||
if (string.IsNullOrWhiteSpace(request.Token) || request.Token.Length > 4096)
|
||||
return BadRequest(new ApiError("PUSH_TOKEN_INVALID", "推送令牌无效"));
|
||||
var expectedFlavor = request.PackageName switch
|
||||
{
|
||||
"com.nx.miaoji" => "production",
|
||||
"com.nx.miaoji.internal" => "internal",
|
||||
_ => null,
|
||||
};
|
||||
if (expectedFlavor is null || !string.Equals(expectedFlavor, request.Flavor, StringComparison.OrdinalIgnoreCase))
|
||||
return BadRequest(new ApiError("PUSH_PACKAGE_INVALID", "推送包名或环境无效"));
|
||||
if (request.AppVersion.Length is < 1 or > 32 || request.VersionCode < 1)
|
||||
return BadRequest(new ApiError("APP_VERSION_INVALID", "应用版本无效"));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,10 @@ namespace MiaoJiZhang.Api.Controllers;
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/transactions")]
|
||||
public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : ControllerBase
|
||||
public class TransactionsController(
|
||||
AppDbContext db,
|
||||
LedgerResolver ledgers,
|
||||
BudgetPushService budgetPush) : ControllerBase
|
||||
{
|
||||
private long Uid => long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!);
|
||||
|
||||
@@ -68,13 +71,22 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.Transactions.Add(tx);
|
||||
await using var writeScope = await db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
if (tx.Type == TransactionType.Expense)
|
||||
{
|
||||
await budgetPush.EvaluateAsync(Uid,
|
||||
[new BudgetExpenseChange(tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount)]);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
await writeScope.CommitAsync();
|
||||
return Ok(ToDto(tx, cat));
|
||||
}
|
||||
catch (DbUpdateException) when (clientRequestId is not null)
|
||||
{
|
||||
await writeScope.RollbackAsync();
|
||||
// Another channel may have committed the same recognition candidate
|
||||
// after the initial lookup. Resolve the unique-key race as idempotent success.
|
||||
db.Entry(tx).State = EntityState.Detached;
|
||||
@@ -161,6 +173,17 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
|
||||
}
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
var expenseChanges = mapped
|
||||
.Where(item => !existing.ContainsKey(item.Transaction.ClientRequestId ?? "") &&
|
||||
item.Transaction.Type == TransactionType.Expense)
|
||||
.Select(item => new BudgetExpenseChange(
|
||||
item.Transaction.LedgerId,
|
||||
item.Transaction.CategoryId,
|
||||
item.Transaction.OccurredAt,
|
||||
item.Transaction.Amount))
|
||||
.ToList();
|
||||
await budgetPush.EvaluateAsync(Uid, expenseChanges, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await transactionScope.CommitAsync(ct);
|
||||
return Ok(mapped.Select(item => new RecognitionBatchTransactionDto(
|
||||
@@ -223,6 +246,10 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
|
||||
message = "账单已在其他设备修改,请选择保留本地或云端版本",
|
||||
server = ToDto(tx, await db.Categories.FindAsync(tx.CategoryId) ?? category),
|
||||
});
|
||||
var expenseChanges = new List<BudgetExpenseChange>();
|
||||
if (tx.Type == TransactionType.Expense)
|
||||
expenseChanges.Add(new BudgetExpenseChange(
|
||||
tx.LedgerId, tx.CategoryId, tx.OccurredAt, -tx.Amount));
|
||||
tx.LedgerId = ledgerId.Value;
|
||||
tx.CategoryId = category.Id;
|
||||
tx.Category = category;
|
||||
@@ -232,7 +259,14 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
|
||||
tx.PaymentMethod = req.PaymentMethod?.Trim();
|
||||
tx.OccurredAt = NormalizeOccurredAt(req.OccurredAt);
|
||||
tx.UpdatedAt = DateTime.UtcNow;
|
||||
if (tx.Type == TransactionType.Expense)
|
||||
expenseChanges.Add(new BudgetExpenseChange(
|
||||
tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount));
|
||||
await using var writeScope = await db.Database.BeginTransactionAsync();
|
||||
await db.SaveChangesAsync();
|
||||
await budgetPush.EvaluateAsync(Uid, expenseChanges);
|
||||
await db.SaveChangesAsync();
|
||||
await writeScope.CommitAsync();
|
||||
return Ok(ToDto(tx, category));
|
||||
}
|
||||
|
||||
@@ -291,7 +325,15 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
|
||||
}); tx.IsDeleted = false;
|
||||
tx.DeletedAt = null;
|
||||
tx.UpdatedAt = DateTime.UtcNow;
|
||||
await using var writeScope = await db.Database.BeginTransactionAsync();
|
||||
await db.SaveChangesAsync();
|
||||
if (tx.Type == TransactionType.Expense)
|
||||
{
|
||||
await budgetPush.EvaluateAsync(Uid,
|
||||
[new BudgetExpenseChange(tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount)]);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
await writeScope.CommitAsync();
|
||||
return Ok(ToDto(tx, tx.Category));
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@ using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddOpenApi();
|
||||
var authPermitLimit = Math.Max(1, builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 10));
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.AddPolicy("auth", context =>
|
||||
@@ -19,7 +20,7 @@ builder.Services.AddRateLimiter(options =>
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 10,
|
||||
PermitLimit = authPermitLimit,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
}));
|
||||
@@ -50,13 +51,32 @@ builder.Services.AddScoped<ReplyService>();
|
||||
builder.Services.AddScoped<LedgerResolver>();
|
||||
builder.Services.AddScoped<AiPermissionService>();
|
||||
builder.Services.AddScoped<AiChatQuotaService>();
|
||||
builder.Services.AddScoped<AiPermissionFilter>();
|
||||
builder.Services.AddHttpClient("LlmClient");
|
||||
builder.Services.AddScoped<BudgetPushService>();
|
||||
builder.Services.AddSingleton<PushTokenProtector>();
|
||||
builder.Services.AddScoped<AiPermissionFilter>();
|
||||
builder.Services.AddHttpClient("LlmClient");
|
||||
builder.Services.AddHttpClient("PushProviders", client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(20);
|
||||
});
|
||||
foreach (var provider in new[]
|
||||
{
|
||||
"huawei", "honor", "xiaomi", "oppo", "vivo", "meizu",
|
||||
})
|
||||
{
|
||||
builder.Services.AddSingleton<IPushProvider>(services => new OfficialPushProvider(
|
||||
provider,
|
||||
services.GetRequiredService<IConfiguration>(),
|
||||
services.GetRequiredService<IHttpClientFactory>(),
|
||||
services.GetRequiredService<ILogger<OfficialPushProvider>>()));
|
||||
}
|
||||
builder.Services.AddSingleton<PushProviderRegistry>();
|
||||
builder.Services.AddSingleton<OpenAiVisionClient>();
|
||||
builder.Services.AddSingleton<ILlmClient>(sp => sp.GetRequiredService<OpenAiVisionClient>());
|
||||
builder.Services.AddHostedService<RecycleBinCleanupService>();
|
||||
builder.Services.AddScoped<AccountDataEraser>();
|
||||
builder.Services.AddHostedService<AccountClosureCleanupService>();
|
||||
builder.Services.AddHostedService<AccountClosureCleanupService>();
|
||||
builder.Services.AddHostedService<PushDispatchService>();
|
||||
|
||||
var conn = builder.Configuration.GetConnectionString("Default");
|
||||
if (string.IsNullOrWhiteSpace(conn))
|
||||
@@ -64,9 +84,19 @@ if (string.IsNullOrWhiteSpace(conn))
|
||||
var jwtSecret = builder.Configuration["Jwt:Secret"];
|
||||
if (string.IsNullOrWhiteSpace(jwtSecret) || jwtSecret.Length < 32)
|
||||
throw new InvalidOperationException("必须通过 Jwt__Secret 配置至少 32 位的 JWT 密钥");
|
||||
var adminKey = builder.Configuration["Admin:Key"];
|
||||
var adminKey = builder.Configuration["Admin:Key"];
|
||||
if (string.IsNullOrWhiteSpace(adminKey) || adminKey.Length < 24)
|
||||
throw new InvalidOperationException("必须通过 Admin__Key 配置至少 24 位的管理密钥");
|
||||
throw new InvalidOperationException("必须通过 Admin__Key 配置至少 24 位的管理密钥");
|
||||
if (builder.Configuration.GetValue<bool>("Push:Enabled"))
|
||||
{
|
||||
var pushKey = builder.Configuration["Push:TokenEncryptionKey"];
|
||||
byte[]? key = null;
|
||||
try { key = string.IsNullOrWhiteSpace(pushKey) ? null : Convert.FromBase64String(pushKey); }
|
||||
catch (FormatException) { }
|
||||
if (key?.Length != 32)
|
||||
throw new InvalidOperationException(
|
||||
"启用推送时必须通过 Push__TokenEncryptionKey 配置 base64 编码的 32 字节密钥");
|
||||
}
|
||||
builder.Services.AddDbContext<AppDbContext>(o =>
|
||||
o.UseMySql(conn, ServerVersion.AutoDetect(conn)));
|
||||
|
||||
|
||||
@@ -9,6 +9,18 @@ public class AccountDataEraser(AppDbContext db)
|
||||
{
|
||||
await using var transaction =
|
||||
await db.Database.BeginTransactionAsync(ct);
|
||||
await db.PushMessages
|
||||
.Where(message => message.TargetUserId == userId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await db.PushDevices
|
||||
.Where(device => device.UserId == userId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await db.UserPushPreferences
|
||||
.Where(preference => preference.UserId == userId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await db.BudgetNotificationReceipts
|
||||
.Where(receipt => receipt.UserId == userId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await db.ChatMessages
|
||||
.Where(message => message.UserId == userId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
@@ -14,6 +14,7 @@ public record AgentTurnResult(
|
||||
public class AgentService(
|
||||
AppDbContext db,
|
||||
ILlmClient llm,
|
||||
BudgetPushService budgetPush,
|
||||
ILogger<AgentService> logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions =
|
||||
@@ -330,12 +331,23 @@ public class AgentService(
|
||||
}
|
||||
|
||||
db.Transactions.AddRange(pending);
|
||||
await using var writeScope = await db.Database.BeginTransactionAsync(ct);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
await budgetPush.EvaluateAsync(userId, pending
|
||||
.Where(transaction => transaction.Type == TransactionType.Expense)
|
||||
.Select(transaction => new BudgetExpenseChange(
|
||||
transaction.LedgerId,
|
||||
transaction.CategoryId,
|
||||
transaction.OccurredAt,
|
||||
transaction.Amount)), ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await writeScope.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await writeScope.RollbackAsync(ct);
|
||||
foreach (var transaction in pending)
|
||||
db.Entry(transaction).State = EntityState.Detached;
|
||||
throw;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Domain.Enums;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public sealed record BudgetExpenseChange(
|
||||
long LedgerId,
|
||||
long CategoryId,
|
||||
DateTime OccurredAt,
|
||||
decimal Delta);
|
||||
|
||||
public sealed class BudgetPushService(AppDbContext db)
|
||||
{
|
||||
private static readonly int[] Thresholds = [80, 100];
|
||||
|
||||
public async Task EvaluateAsync(
|
||||
long userId,
|
||||
IEnumerable<BudgetExpenseChange> rawChanges,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var changes = rawChanges
|
||||
.Where(change => change.Delta != 0)
|
||||
.Select(change => new
|
||||
{
|
||||
Change = change,
|
||||
Local = ChinaClock.ToLocal(change.OccurredAt),
|
||||
})
|
||||
.Select(item => new ChangeWithPeriod(
|
||||
item.Change.LedgerId,
|
||||
item.Change.CategoryId,
|
||||
item.Local.Year * 100 + item.Local.Month,
|
||||
item.Change.Delta))
|
||||
.ToList();
|
||||
if (changes.Count == 0) return;
|
||||
|
||||
var crossed = new List<CrossedBudget>();
|
||||
foreach (var group in changes.GroupBy(change => new { change.LedgerId, change.Period }))
|
||||
{
|
||||
var period = group.Key.Period;
|
||||
var year = period / 100;
|
||||
var month = period % 100;
|
||||
if (month is < 1 or > 12) continue;
|
||||
var rows = await db.Budgets
|
||||
.Where(budget => budget.UserId == userId && budget.LedgerId == group.Key.LedgerId &&
|
||||
(budget.Period == period || budget.Period == 0) && budget.Amount > 0)
|
||||
.ToListAsync(ct);
|
||||
var budgets = rows.GroupBy(budget => budget.CategoryId)
|
||||
.Select(items => items.FirstOrDefault(item => item.Period == period) ??
|
||||
items.First(item => item.Period == 0))
|
||||
.ToList();
|
||||
if (budgets.Count == 0) continue;
|
||||
|
||||
var (start, end) = ChinaClock.MonthRangeUtc(year, month);
|
||||
var spent = await db.Transactions
|
||||
.Where(transaction => transaction.UserId == userId &&
|
||||
transaction.LedgerId == group.Key.LedgerId &&
|
||||
transaction.Type == TransactionType.Expense &&
|
||||
transaction.OccurredAt >= start && transaction.OccurredAt < end)
|
||||
.GroupBy(transaction => transaction.CategoryId)
|
||||
.Select(items => new { CategoryId = items.Key, Amount = items.Sum(item => item.Amount) })
|
||||
.ToDictionaryAsync(item => item.CategoryId, item => item.Amount, ct);
|
||||
var existing = await db.BudgetNotificationReceipts
|
||||
.Where(receipt => receipt.UserId == userId && receipt.Period == period &&
|
||||
budgets.Select(budget => budget.Id).Contains(receipt.BudgetId))
|
||||
.Select(receipt => new { receipt.BudgetId, receipt.Threshold })
|
||||
.ToListAsync(ct);
|
||||
var existingKeys = existing.Select(item => (item.BudgetId, item.Threshold)).ToHashSet();
|
||||
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
var currentSpent = budget.CategoryId.HasValue
|
||||
? spent.GetValueOrDefault(budget.CategoryId.Value)
|
||||
: spent.Values.Sum();
|
||||
var delta = budget.CategoryId.HasValue
|
||||
? group.Where(change => change.CategoryId == budget.CategoryId.Value).Sum(change => change.Delta)
|
||||
: group.Sum(change => change.Delta);
|
||||
var previousSpent = currentSpent - delta;
|
||||
var highestCrossed = 0;
|
||||
foreach (var threshold in Thresholds)
|
||||
{
|
||||
if (currentSpent * 100 < budget.Amount * threshold ||
|
||||
existingKeys.Contains((budget.Id, threshold))) continue;
|
||||
db.BudgetNotificationReceipts.Add(new BudgetNotificationReceipt
|
||||
{
|
||||
UserId = userId,
|
||||
BudgetId = budget.Id,
|
||||
Period = period,
|
||||
Threshold = threshold,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
existingKeys.Add((budget.Id, threshold));
|
||||
if (delta > 0 && previousSpent * 100 < budget.Amount * threshold)
|
||||
highestCrossed = threshold;
|
||||
}
|
||||
if (highestCrossed > 0)
|
||||
crossed.Add(new CrossedBudget(budget.CategoryId, highestCrossed));
|
||||
}
|
||||
}
|
||||
|
||||
if (crossed.Count == 0) return;
|
||||
var notificationsEnabled = await db.UserPushPreferences.AnyAsync(preference =>
|
||||
preference.UserId == userId && preference.Category == PushCategories.Budget &&
|
||||
preference.IsEnabled, ct);
|
||||
if (!notificationsEnabled) return;
|
||||
|
||||
var categoryIds = crossed.Where(item => item.CategoryId.HasValue)
|
||||
.Select(item => item.CategoryId!.Value).Distinct().ToList();
|
||||
var names = await db.Categories.Where(category => categoryIds.Contains(category.Id))
|
||||
.ToDictionaryAsync(category => category.Id, category => category.Name, ct);
|
||||
var details = crossed
|
||||
.OrderByDescending(item => item.Threshold)
|
||||
.ThenBy(item => item.CategoryId)
|
||||
.Select(item =>
|
||||
$"{(item.CategoryId.HasValue ? names.GetValueOrDefault(item.CategoryId.Value, "分类预算") : "总预算")}" +
|
||||
(item.Threshold >= 100 ? "已用完" : "已使用 80%"))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var body = string.Join(";", details);
|
||||
if (body.Length > 240) body = body[..237] + "...";
|
||||
var now = DateTime.UtcNow;
|
||||
db.PushMessages.Add(new PushMessage
|
||||
{
|
||||
PublicId = Guid.NewGuid().ToString(),
|
||||
Source = "budget",
|
||||
State = PushMessageStates.Queued,
|
||||
Category = PushCategories.Budget,
|
||||
Title = crossed.Any(item => item.Threshold >= 100) ? "预算已达到上限" : "预算接近上限",
|
||||
Body = body,
|
||||
Action = PushActions.Budget,
|
||||
TargetUserId = userId,
|
||||
Flavor = "",
|
||||
TtlSeconds = 24 * 3600,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
}
|
||||
|
||||
private sealed record ChangeWithPeriod(long LedgerId, long CategoryId, int Period, decimal Delta);
|
||||
private sealed record CrossedBudget(long? CategoryId, int Threshold);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public sealed class PushDispatchService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IConfiguration configuration,
|
||||
ILogger<PushDispatchService> logger) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan[] RetrySchedule =
|
||||
[
|
||||
TimeSpan.FromMinutes(1),
|
||||
TimeSpan.FromMinutes(5),
|
||||
TimeSpan.FromMinutes(30),
|
||||
TimeSpan.FromHours(2),
|
||||
TimeSpan.FromHours(6),
|
||||
];
|
||||
|
||||
private DateTime nextCleanupAt = DateTime.MinValue;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (configuration.GetValue<bool>("Push:Enabled"))
|
||||
{
|
||||
try
|
||||
{
|
||||
await RecoverAndFinalizeMessages(stoppingToken);
|
||||
await ExpandMessages(stoppingToken);
|
||||
await DispatchDeliveries(stoppingToken);
|
||||
if (nextCleanupAt <= DateTime.UtcNow)
|
||||
{
|
||||
await Cleanup(stoppingToken);
|
||||
nextCleanupAt = DateTime.UtcNow.AddHours(6);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Push dispatch loop failed");
|
||||
}
|
||||
}
|
||||
await timer.WaitForNextTickAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RecoverAndFinalizeMessages(CancellationToken ct)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var now = DateTime.UtcNow;
|
||||
var staleBefore = now.AddMinutes(-3);
|
||||
await db.PushMessages
|
||||
.Where(message => message.State == PushMessageStates.Sending &&
|
||||
message.StartedAt <= staleBefore &&
|
||||
!db.PushDeliveries.Any(delivery => delivery.PushMessageId == message.Id))
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(message => message.State, PushMessageStates.Queued)
|
||||
.SetProperty(message => message.StartedAt, (DateTime?)null)
|
||||
.SetProperty(message => message.UpdatedAt, now), ct);
|
||||
|
||||
var ready = await db.PushMessages
|
||||
.Where(message => message.State == PushMessageStates.Sending &&
|
||||
db.PushDeliveries.Any(delivery => delivery.PushMessageId == message.Id) &&
|
||||
!db.PushDeliveries.Any(delivery => delivery.PushMessageId == message.Id &&
|
||||
(delivery.State == PushDeliveryStates.Queued ||
|
||||
delivery.State == PushDeliveryStates.Sending)))
|
||||
.Select(message => message.Id)
|
||||
.Take(100)
|
||||
.ToListAsync(ct);
|
||||
foreach (var messageId in ready)
|
||||
await FinalizeMessage(db, messageId, ct);
|
||||
}
|
||||
|
||||
private async Task ExpandMessages(CancellationToken ct)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var now = DateTime.UtcNow;
|
||||
var candidates = await db.PushMessages
|
||||
.Where(message =>
|
||||
message.State == PushMessageStates.Queued ||
|
||||
(message.State == PushMessageStates.Scheduled && message.ScheduledAt <= now))
|
||||
.OrderBy(message => message.ScheduledAt ?? message.CreatedAt)
|
||||
.Select(message => message.Id)
|
||||
.Take(20)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var id in candidates)
|
||||
{
|
||||
var claimed = await db.PushMessages
|
||||
.Where(message => message.Id == id &&
|
||||
(message.State == PushMessageStates.Queued ||
|
||||
(message.State == PushMessageStates.Scheduled && message.ScheduledAt <= now)))
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(message => message.State, PushMessageStates.Sending)
|
||||
.SetProperty(message => message.StartedAt, now)
|
||||
.SetProperty(message => message.UpdatedAt, now), ct);
|
||||
if (claimed != 1) continue;
|
||||
|
||||
var message = await db.PushMessages.FirstAsync(item => item.Id == id, ct);
|
||||
var deviceQuery = db.PushDevices
|
||||
.Where(device => device.IsActive && device.NotificationsAllowed &&
|
||||
!device.User.IsBanned && device.User.AccountClosureScheduledAt == null);
|
||||
if (message.IsTest)
|
||||
{
|
||||
deviceQuery = deviceQuery.Where(device => device.Id == message.TestDeviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
deviceQuery = deviceQuery.Where(device =>
|
||||
db.UserPushPreferences.Any(preference =>
|
||||
preference.UserId == device.UserId &&
|
||||
preference.Category == message.Category &&
|
||||
preference.IsEnabled));
|
||||
if (message.TargetUserId.HasValue)
|
||||
deviceQuery = deviceQuery.Where(device => device.UserId == message.TargetUserId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(message.Flavor))
|
||||
deviceQuery = deviceQuery.Where(device => device.Flavor == message.Flavor);
|
||||
if (!string.IsNullOrWhiteSpace(message.ProviderFilter))
|
||||
deviceQuery = deviceQuery.Where(device => device.Provider == message.ProviderFilter);
|
||||
if (message.MinVersionCode.HasValue)
|
||||
deviceQuery = deviceQuery.Where(device => device.VersionCode >= message.MinVersionCode.Value);
|
||||
if (message.MaxVersionCode.HasValue)
|
||||
deviceQuery = deviceQuery.Where(device => device.VersionCode <= message.MaxVersionCode.Value);
|
||||
}
|
||||
|
||||
var devices = await deviceQuery.Select(device => new
|
||||
{
|
||||
device.Id,
|
||||
device.UserId,
|
||||
device.Provider,
|
||||
}).ToListAsync(ct);
|
||||
var existing = await db.PushDeliveries
|
||||
.Where(delivery => delivery.PushMessageId == id)
|
||||
.Select(delivery => delivery.PushDeviceId)
|
||||
.ToListAsync(ct);
|
||||
var existingIds = existing.ToHashSet();
|
||||
foreach (var device in devices.Where(device => !existingIds.Contains(device.Id)))
|
||||
{
|
||||
db.PushDeliveries.Add(new PushDelivery
|
||||
{
|
||||
PushMessageId = id,
|
||||
PushDeviceId = device.Id,
|
||||
UserId = device.UserId,
|
||||
Provider = device.Provider,
|
||||
State = PushDeliveryStates.Queued,
|
||||
NextAttemptAt = now,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
}
|
||||
if (devices.Count == 0)
|
||||
{
|
||||
message.State = PushMessageStates.Completed;
|
||||
message.CompletedAt = now;
|
||||
message.UpdatedAt = now;
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DispatchDeliveries(CancellationToken ct)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var registry = scope.ServiceProvider.GetRequiredService<PushProviderRegistry>();
|
||||
var tokenProtector = scope.ServiceProvider.GetRequiredService<PushTokenProtector>();
|
||||
var now = DateTime.UtcNow;
|
||||
var candidates = await db.PushDeliveries
|
||||
.Where(delivery =>
|
||||
(delivery.State == PushDeliveryStates.Queued && delivery.NextAttemptAt <= now) ||
|
||||
(delivery.State == PushDeliveryStates.Sending && delivery.LeaseExpiresAt <= now))
|
||||
.OrderBy(delivery => delivery.NextAttemptAt)
|
||||
.Select(delivery => delivery.Id)
|
||||
.Take(50)
|
||||
.ToListAsync(ct);
|
||||
var affectedMessages = new HashSet<long>();
|
||||
|
||||
foreach (var id in candidates)
|
||||
{
|
||||
var claimNow = DateTime.UtcNow;
|
||||
var leaseId = Guid.NewGuid().ToString();
|
||||
var leaseUntil = claimNow.AddMinutes(2);
|
||||
var claimed = await db.PushDeliveries
|
||||
.Where(delivery => delivery.Id == id &&
|
||||
((delivery.State == PushDeliveryStates.Queued && delivery.NextAttemptAt <= claimNow) ||
|
||||
(delivery.State == PushDeliveryStates.Sending && delivery.LeaseExpiresAt <= claimNow)))
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(delivery => delivery.State, PushDeliveryStates.Sending)
|
||||
.SetProperty(delivery => delivery.LeaseId, leaseId)
|
||||
.SetProperty(delivery => delivery.LeaseExpiresAt, leaseUntil)
|
||||
.SetProperty(delivery => delivery.AttemptCount, delivery => delivery.AttemptCount + 1)
|
||||
.SetProperty(delivery => delivery.UpdatedAt, claimNow), ct);
|
||||
if (claimed != 1) continue;
|
||||
|
||||
var delivery = await db.PushDeliveries
|
||||
.Include(item => item.PushMessage)
|
||||
.Include(item => item.PushDevice).ThenInclude(device => device.User)
|
||||
.FirstAsync(item => item.Id == id, ct);
|
||||
affectedMessages.Add(delivery.PushMessageId);
|
||||
await SendOne(db, registry, tokenProtector, delivery, ct);
|
||||
}
|
||||
|
||||
foreach (var messageId in affectedMessages)
|
||||
await FinalizeMessage(db, messageId, ct);
|
||||
}
|
||||
|
||||
private static async Task SendOne(
|
||||
AppDbContext db,
|
||||
PushProviderRegistry registry,
|
||||
PushTokenProtector tokenProtector,
|
||||
PushDelivery delivery,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var message = delivery.PushMessage;
|
||||
var device = delivery.PushDevice;
|
||||
var expiresAt = (message.ScheduledAt ?? message.CreatedAt).AddSeconds(message.TtlSeconds);
|
||||
if (expiresAt <= now)
|
||||
{
|
||||
Skip(delivery, "message_expired", now);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
if (!device.IsActive || !device.NotificationsAllowed ||
|
||||
device.User.IsBanned || device.User.AccountClosureScheduledAt.HasValue)
|
||||
{
|
||||
Skip(delivery, "device_or_account_inactive", now);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
if (!message.IsTest && !await db.UserPushPreferences.AnyAsync(preference =>
|
||||
preference.UserId == device.UserId && preference.Category == message.Category &&
|
||||
preference.IsEnabled, ct))
|
||||
{
|
||||
Skip(delivery, "category_disabled", now);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var provider = registry.Find(device.Provider);
|
||||
if (provider is null)
|
||||
{
|
||||
Fail(delivery, "provider_unknown", "Unknown push provider", now);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
var envelope = new PushEnvelope(
|
||||
message.PublicId,
|
||||
message.Title,
|
||||
message.Body,
|
||||
message.Category,
|
||||
message.Action,
|
||||
message.EntityId,
|
||||
Math.Max(60, (int)(expiresAt - now).TotalSeconds));
|
||||
var result = await provider.SendAsync(
|
||||
device.Flavor,
|
||||
device.PackageName,
|
||||
tokenProtector.Unprotect(device.TokenCiphertext),
|
||||
envelope,
|
||||
ct);
|
||||
if (result.Accepted)
|
||||
{
|
||||
delivery.State = PushDeliveryStates.Accepted;
|
||||
delivery.AcceptedAt = now;
|
||||
delivery.ProviderMessageId = result.ProviderMessageId;
|
||||
delivery.ErrorCode = null;
|
||||
delivery.ErrorMessage = null;
|
||||
ClearLease(delivery, now);
|
||||
}
|
||||
else if (result.Retryable && delivery.AttemptCount <= RetrySchedule.Length && expiresAt > now)
|
||||
{
|
||||
var retry = result.RetryAfter ?? RetrySchedule[Math.Clamp(delivery.AttemptCount - 1, 0, RetrySchedule.Length - 1)];
|
||||
delivery.State = PushDeliveryStates.Queued;
|
||||
delivery.NextAttemptAt = now.Add(retry) < expiresAt ? now.Add(retry) : expiresAt;
|
||||
delivery.ErrorCode = result.ErrorCode;
|
||||
delivery.ErrorMessage = result.ErrorMessage;
|
||||
ClearLease(delivery, now);
|
||||
}
|
||||
else
|
||||
{
|
||||
Fail(delivery, result.ErrorCode ?? "provider_rejected", result.ErrorMessage, now);
|
||||
}
|
||||
if (result.InvalidToken)
|
||||
{
|
||||
device.IsActive = false;
|
||||
device.DisabledReason = "provider_invalid_token";
|
||||
device.UpdatedAt = now;
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static async Task FinalizeMessage(AppDbContext db, long messageId, CancellationToken ct)
|
||||
{
|
||||
var pending = await db.PushDeliveries.AnyAsync(delivery =>
|
||||
delivery.PushMessageId == messageId &&
|
||||
(delivery.State == PushDeliveryStates.Queued || delivery.State == PushDeliveryStates.Sending), ct);
|
||||
if (pending) return;
|
||||
var failed = await db.PushDeliveries.AnyAsync(delivery =>
|
||||
delivery.PushMessageId == messageId && delivery.State == PushDeliveryStates.Failed, ct);
|
||||
var now = DateTime.UtcNow;
|
||||
await db.PushMessages.Where(message => message.Id == messageId)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(message => message.State,
|
||||
failed ? PushMessageStates.PartiallyFailed : PushMessageStates.Completed)
|
||||
.SetProperty(message => message.CompletedAt, now)
|
||||
.SetProperty(message => message.UpdatedAt, now), ct);
|
||||
}
|
||||
|
||||
private async Task Cleanup(CancellationToken ct)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var staleBefore = DateTime.UtcNow.AddDays(-90);
|
||||
await db.PushDevices
|
||||
.Where(device => device.IsActive && device.LastSeenAt < staleBefore)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(device => device.IsActive, false)
|
||||
.SetProperty(device => device.DisabledReason, "stale_device")
|
||||
.SetProperty(device => device.UpdatedAt, DateTime.UtcNow), ct);
|
||||
await db.PushDeliveries
|
||||
.Where(delivery => delivery.UpdatedAt < staleBefore &&
|
||||
delivery.State != PushDeliveryStates.Queued &&
|
||||
delivery.State != PushDeliveryStates.Sending)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
private static void Skip(PushDelivery delivery, string code, DateTime now)
|
||||
{
|
||||
delivery.State = PushDeliveryStates.Skipped;
|
||||
delivery.ErrorCode = code;
|
||||
delivery.ErrorMessage = null;
|
||||
ClearLease(delivery, now);
|
||||
}
|
||||
|
||||
private static void Fail(PushDelivery delivery, string code, string? message, DateTime now)
|
||||
{
|
||||
delivery.State = PushDeliveryStates.Failed;
|
||||
delivery.ErrorCode = code;
|
||||
delivery.ErrorMessage = message is { Length: > 400 } ? message[..400] : message;
|
||||
ClearLease(delivery, now);
|
||||
}
|
||||
|
||||
private static void ClearLease(PushDelivery delivery, DateTime now)
|
||||
{
|
||||
delivery.LeaseId = null;
|
||||
delivery.LeaseExpiresAt = null;
|
||||
delivery.UpdatedAt = now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Collections.Concurrent;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public sealed record PushEnvelope(
|
||||
string MessageId,
|
||||
string Title,
|
||||
string Body,
|
||||
string Category,
|
||||
string Action,
|
||||
string? EntityId,
|
||||
int TtlSeconds);
|
||||
|
||||
public sealed record PushSendResult(
|
||||
bool Accepted,
|
||||
bool Retryable,
|
||||
bool InvalidToken,
|
||||
string? ProviderMessageId = null,
|
||||
string? ErrorCode = null,
|
||||
string? ErrorMessage = null,
|
||||
TimeSpan? RetryAfter = null);
|
||||
|
||||
public interface IPushProvider
|
||||
{
|
||||
string Provider { get; }
|
||||
bool IsEnabled(string flavor);
|
||||
IReadOnlyList<string> ConfigurationErrors(string flavor);
|
||||
Task<PushSendResult> SendAsync(
|
||||
string flavor,
|
||||
string packageName,
|
||||
string token,
|
||||
PushEnvelope message,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class PushProviderRegistry(IEnumerable<IPushProvider> providers)
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, IPushProvider> items = providers
|
||||
.ToDictionary(provider => provider.Provider, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public IReadOnlyCollection<IPushProvider> All => items.Values.ToArray();
|
||||
public IPushProvider? Find(string provider) => items.GetValueOrDefault(provider);
|
||||
}
|
||||
|
||||
public sealed class OfficialPushProvider(
|
||||
string provider,
|
||||
IConfiguration configuration,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<OfficialPushProvider> logger) : IPushProvider
|
||||
{
|
||||
private readonly SemaphoreSlim tokenLock = new(1, 1);
|
||||
private readonly ConcurrentDictionary<string, CachedAccessToken> accessTokens =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public string Provider { get; } = provider;
|
||||
|
||||
public bool IsEnabled(string flavor) =>
|
||||
configuration.GetValue<bool>($"Push:Providers:{Provider}:{flavor}:Enabled");
|
||||
|
||||
public IReadOnlyList<string> ConfigurationErrors(string flavor)
|
||||
{
|
||||
if (!IsEnabled(flavor)) return ["disabled"];
|
||||
var required = Provider switch
|
||||
{
|
||||
PushProviders.Huawei or PushProviders.Honor => new[] { "AppId", "AppSecret" },
|
||||
PushProviders.Xiaomi => new[] { "AppSecret" },
|
||||
PushProviders.Oppo => new[] { "AppKey", "MasterSecret" },
|
||||
PushProviders.Vivo => new[] { "AppId", "AppKey", "AppSecret" },
|
||||
PushProviders.Meizu => new[] { "AppId", "AppSecret" },
|
||||
_ => [],
|
||||
};
|
||||
return required
|
||||
.Where(key => string.IsNullOrWhiteSpace(Value(flavor, key)))
|
||||
.Select(key => $"missing_{key.ToLowerInvariant()}")
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<PushSendResult> SendAsync(
|
||||
string flavor,
|
||||
string packageName,
|
||||
string token,
|
||||
PushEnvelope message,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var errors = ConfigurationErrors(flavor);
|
||||
if (errors.Count > 0)
|
||||
return new(false, false, false, ErrorCode: "provider_not_configured",
|
||||
ErrorMessage: string.Join(',', errors));
|
||||
try
|
||||
{
|
||||
return Provider switch
|
||||
{
|
||||
PushProviders.Huawei => await SendHuaweiLike(flavor, packageName, token, message, false, ct),
|
||||
PushProviders.Honor => await SendHuaweiLike(flavor, packageName, token, message, true, ct),
|
||||
PushProviders.Xiaomi => await SendXiaomi(flavor, packageName, token, message, ct),
|
||||
PushProviders.Oppo => await SendOppo(flavor, packageName, token, message, ct),
|
||||
PushProviders.Vivo => await SendVivo(flavor, packageName, token, message, ct),
|
||||
PushProviders.Meizu => await SendMeizu(flavor, packageName, token, message, ct),
|
||||
_ => new(false, false, false, ErrorCode: "provider_unknown"),
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return new(false, true, false, ErrorCode: "provider_timeout", ErrorMessage: "Provider request timed out");
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Push provider {Provider} request failed", Provider);
|
||||
return new(false, true, false, ErrorCode: "provider_network_error", ErrorMessage: exception.Message);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Push provider {Provider} failed unexpectedly", Provider);
|
||||
return new(false, false, false, ErrorCode: "provider_internal_error", ErrorMessage: exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PushSendResult> SendHuaweiLike(
|
||||
string flavor,
|
||||
string packageName,
|
||||
string token,
|
||||
PushEnvelope message,
|
||||
bool honor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var accessToken = await GetOAuthToken(flavor, honor, ct);
|
||||
if (accessToken.Result is not null) return accessToken.Result;
|
||||
var appId = Value(flavor, "AppId")!;
|
||||
var defaultUrl = honor
|
||||
? $"https://push-api.cloud.hihonor.com/api/v1/{appId}/sendMessage"
|
||||
: $"https://push-api.cloud.huawei.com/v1/{appId}/messages:send";
|
||||
var url = Value(flavor, "SendUrl") ?? defaultUrl;
|
||||
var intent = IntentUri(packageName, message);
|
||||
object payload = honor
|
||||
? new
|
||||
{
|
||||
message = new
|
||||
{
|
||||
notification = new { title = message.Title, body = message.Body },
|
||||
android = new
|
||||
{
|
||||
ttl = $"{message.TtlSeconds}s",
|
||||
data = PayloadJson(message),
|
||||
notification = new
|
||||
{
|
||||
channel_id = Channel(flavor, message.Category),
|
||||
click_action = new { type = 1, intent },
|
||||
},
|
||||
},
|
||||
token = new[] { token },
|
||||
},
|
||||
}
|
||||
: new
|
||||
{
|
||||
validate_only = false,
|
||||
message = new
|
||||
{
|
||||
notification = new { title = message.Title, body = message.Body },
|
||||
android = new
|
||||
{
|
||||
ttl = $"{message.TtlSeconds}s",
|
||||
data = PayloadJson(message),
|
||||
notification = new
|
||||
{
|
||||
channel_id = Channel(flavor, message.Category),
|
||||
notify_id = StableNotificationId(message.MessageId),
|
||||
click_action = new { type = 1, intent },
|
||||
},
|
||||
},
|
||||
token = new[] { token },
|
||||
},
|
||||
};
|
||||
using var request = JsonRequest(HttpMethod.Post, url, payload);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Token);
|
||||
return await Send(request, ct);
|
||||
}
|
||||
|
||||
private async Task<(string? Token, PushSendResult? Result)> GetOAuthToken(
|
||||
string flavor,
|
||||
bool honor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (FreshAccessToken(flavor) is { } cached) return (cached, null);
|
||||
await tokenLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (FreshAccessToken(flavor) is { } lockedCached) return (lockedCached, null);
|
||||
var defaultUrl = honor
|
||||
? "https://iam.developer.hihonor.com/auth/token"
|
||||
: "https://oauth-login.cloud.huawei.com/oauth2/v3/token";
|
||||
var url = Value(flavor, "AuthUrl") ?? defaultUrl;
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "client_credentials",
|
||||
["client_id"] = Value(flavor, "AppId")!,
|
||||
["client_secret"] = Value(flavor, "AppSecret")!,
|
||||
}),
|
||||
};
|
||||
using var response = await Client().SendAsync(request, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return (null, FromFailure(response, body));
|
||||
using var json = JsonDocument.Parse(body);
|
||||
if (!TryString(json.RootElement, out var value, "access_token", "accessToken", "token"))
|
||||
return (null, new(false, false, false, ErrorCode: "provider_auth_invalid", ErrorMessage: Trim(body)));
|
||||
var expires = TryInt(json.RootElement, "expires_in", "expiresIn") ?? 3600;
|
||||
accessTokens[flavor] = new CachedAccessToken(
|
||||
value!,
|
||||
DateTime.UtcNow.AddSeconds(Math.Max(expires, 300)));
|
||||
return (value, null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tokenLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PushSendResult> SendXiaomi(
|
||||
string flavor,
|
||||
string packageName,
|
||||
string token,
|
||||
PushEnvelope message,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var url = Value(flavor, "SendUrl") ?? "https://api.xmpush.xiaomi.com/v3/message/regid";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["registration_id"] = token,
|
||||
["restricted_package_name"] = packageName,
|
||||
["title"] = message.Title,
|
||||
["description"] = message.Body,
|
||||
["notify_id"] = StableNotificationId(message.MessageId).ToString(),
|
||||
["time_to_live"] = (message.TtlSeconds * 1000L).ToString(),
|
||||
["extra.notify_effect"] = "2",
|
||||
["extra.intent_uri"] = IntentUri(packageName, message),
|
||||
["extra.jz_payload"] = PayloadJson(message),
|
||||
["extra.channel_id"] = Channel(flavor, message.Category),
|
||||
}),
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation("Authorization", $"key={Value(flavor, "AppSecret")}");
|
||||
return await Send(request, ct);
|
||||
}
|
||||
|
||||
private async Task<PushSendResult> SendOppo(
|
||||
string flavor,
|
||||
string packageName,
|
||||
string token,
|
||||
PushEnvelope message,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
|
||||
var appKey = Value(flavor, "AppKey")!;
|
||||
var sign = Sha256Hex(appKey + timestamp + Value(flavor, "MasterSecret"));
|
||||
var authUrl = Value(flavor, "AuthUrl") ?? "https://api.push.oppomobile.com/server/v1/auth";
|
||||
using var authRequest = new HttpRequestMessage(HttpMethod.Post, authUrl)
|
||||
{
|
||||
Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["app_key"] = appKey,
|
||||
["timestamp"] = timestamp,
|
||||
["sign"] = sign,
|
||||
}),
|
||||
};
|
||||
using var authResponse = await Client().SendAsync(authRequest, ct);
|
||||
var authBody = await authResponse.Content.ReadAsStringAsync(ct);
|
||||
if (!authResponse.IsSuccessStatusCode) return FromFailure(authResponse, authBody);
|
||||
using var authJson = JsonDocument.Parse(authBody);
|
||||
if (!TryNestedString(authJson.RootElement, out var authToken, "data", "auth_token") &&
|
||||
!TryString(authJson.RootElement, out authToken, "auth_token", "authToken"))
|
||||
return new(false, false, false, ErrorCode: "provider_auth_invalid", ErrorMessage: Trim(authBody));
|
||||
|
||||
var notification = JsonSerializer.Serialize(new
|
||||
{
|
||||
app_message_id = message.MessageId,
|
||||
title = message.Title,
|
||||
content = message.Body,
|
||||
click_action_type = 1,
|
||||
click_action_activity = $"{packageName}/com.nx.miaoji.MainActivity",
|
||||
action_parameters = PayloadJson(message),
|
||||
off_line = true,
|
||||
off_line_ttl = message.TtlSeconds,
|
||||
channel_id = Channel(flavor, message.Category),
|
||||
});
|
||||
var sendUrl = Value(flavor, "SendUrl") ??
|
||||
"https://api.push.oppomobile.com/server/v1/message/notification/unicast";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, sendUrl)
|
||||
{
|
||||
Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["auth_token"] = authToken!,
|
||||
["registration_id"] = token,
|
||||
["message"] = notification,
|
||||
}),
|
||||
};
|
||||
return await Send(request, ct);
|
||||
}
|
||||
|
||||
private async Task<PushSendResult> SendVivo(
|
||||
string flavor,
|
||||
string packageName,
|
||||
string token,
|
||||
PushEnvelope message,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var appId = Value(flavor, "AppId")!;
|
||||
var appKey = Value(flavor, "AppKey")!;
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
|
||||
var sign = Md5Hex(appId + appKey + timestamp + Value(flavor, "AppSecret"));
|
||||
var authUrl = Value(flavor, "AuthUrl") ?? "https://api-push.vivo.com.cn/message/auth";
|
||||
using var authRequest = JsonRequest(HttpMethod.Post, authUrl, new
|
||||
{
|
||||
appId = int.TryParse(appId, out var id) ? id : 0,
|
||||
appKey,
|
||||
timestamp = long.Parse(timestamp),
|
||||
sign,
|
||||
});
|
||||
using var authResponse = await Client().SendAsync(authRequest, ct);
|
||||
var authBody = await authResponse.Content.ReadAsStringAsync(ct);
|
||||
if (!authResponse.IsSuccessStatusCode) return FromFailure(authResponse, authBody);
|
||||
using var authJson = JsonDocument.Parse(authBody);
|
||||
if (!TryString(authJson.RootElement, out var authToken, "authToken", "auth_token"))
|
||||
return new(false, false, false, ErrorCode: "provider_auth_invalid", ErrorMessage: Trim(authBody));
|
||||
|
||||
var sendUrl = Value(flavor, "SendUrl") ?? "https://api-push.vivo.com.cn/message/send";
|
||||
using var request = JsonRequest(HttpMethod.Post, sendUrl, new
|
||||
{
|
||||
regId = token,
|
||||
notifyType = 4,
|
||||
title = message.Title,
|
||||
content = message.Body,
|
||||
timeToLive = message.TtlSeconds,
|
||||
skipType = 3,
|
||||
skipContent = IntentUri(packageName, message),
|
||||
requestId = message.MessageId,
|
||||
classification = message.Category == PushCategories.Operations ? 1 : 0,
|
||||
clientCustomMap = new Dictionary<string, string> { ["jz_payload"] = PayloadJson(message) },
|
||||
});
|
||||
request.Headers.TryAddWithoutValidation("authToken", authToken);
|
||||
return await Send(request, ct);
|
||||
}
|
||||
|
||||
private async Task<PushSendResult> SendMeizu(
|
||||
string flavor,
|
||||
string packageName,
|
||||
string token,
|
||||
PushEnvelope message,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var appId = Value(flavor, "AppId")!;
|
||||
var messageJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
noticeBarInfo = new
|
||||
{
|
||||
title = message.Title,
|
||||
content = message.Body,
|
||||
noticeBarType = 0,
|
||||
},
|
||||
clickTypeInfo = new
|
||||
{
|
||||
clickType = 3,
|
||||
parameters = new Dictionary<string, string> { ["jz_payload"] = PayloadJson(message) },
|
||||
uri = IntentUri(packageName, message),
|
||||
},
|
||||
pushTimeInfo = new { offLine = true, validTime = message.TtlSeconds / 3600 },
|
||||
advanceInfo = new { notifyId = StableNotificationId(message.MessageId) },
|
||||
});
|
||||
var values = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["appId"] = appId,
|
||||
["pushIds"] = JsonSerializer.Serialize(new[] { token }),
|
||||
["messageJson"] = messageJson,
|
||||
};
|
||||
var signSource = string.Concat(values.Select(item => item.Key + item.Value)) + Value(flavor, "AppSecret");
|
||||
values["sign"] = Md5Hex(signSource);
|
||||
var url = Value(flavor, "SendUrl") ??
|
||||
"https://server-api-push.meizu.com/garcia/api/server/push/varnished/pushByPushId";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = new FormUrlEncodedContent(values),
|
||||
};
|
||||
return await Send(request, ct);
|
||||
}
|
||||
|
||||
private async Task<PushSendResult> Send(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
using var response = await Client().SendAsync(request, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode) return FromFailure(response, body);
|
||||
string? providerId = null;
|
||||
try
|
||||
{
|
||||
using var json = JsonDocument.Parse(body);
|
||||
var businessFailure = FromBusinessFailure(json.RootElement, body);
|
||||
if (businessFailure is not null) return businessFailure;
|
||||
TryString(json.RootElement, out providerId,
|
||||
"requestId", "request_id", "taskId", "msgId", "messageId", "code");
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Some providers return an empty or non-JSON success body.
|
||||
}
|
||||
return new(true, false, false, providerId);
|
||||
}
|
||||
|
||||
private PushSendResult? FromBusinessFailure(JsonElement root, string body)
|
||||
{
|
||||
string? code = null;
|
||||
var failed = Provider switch
|
||||
{
|
||||
PushProviders.Huawei or PushProviders.Honor =>
|
||||
HasUnexpectedValue(root, ["0", "200", "80000000"], out code, "code"),
|
||||
PushProviders.Xiaomi =>
|
||||
HasUnexpectedValue(root, ["ok", "success"], out code, "result") ||
|
||||
HasUnexpectedValue(root, ["0"], out code, "code"),
|
||||
PushProviders.Oppo =>
|
||||
HasUnexpectedValue(root, ["0"], out code, "code"),
|
||||
PushProviders.Vivo =>
|
||||
HasUnexpectedValue(root, ["0"], out code, "result", "code"),
|
||||
PushProviders.Meizu =>
|
||||
HasUnexpectedValue(root, ["200"], out code, "code"),
|
||||
_ => false,
|
||||
};
|
||||
if (!failed && TryString(root, out var error, "error", "error_description") &&
|
||||
!string.IsNullOrWhiteSpace(error))
|
||||
{
|
||||
code = error;
|
||||
failed = true;
|
||||
}
|
||||
if (!failed) return null;
|
||||
|
||||
var normalized = body.ToLowerInvariant();
|
||||
var retryable = normalized.Contains("rate limit") || normalized.Contains("too many") ||
|
||||
normalized.Contains("frequency") || normalized.Contains("system busy") ||
|
||||
normalized.Contains("try again");
|
||||
return new PushSendResult(
|
||||
false,
|
||||
retryable,
|
||||
LooksLikeInvalidToken(normalized),
|
||||
ErrorCode: $"provider_{NormalizeCode(code)}",
|
||||
ErrorMessage: Trim(body));
|
||||
}
|
||||
|
||||
private static PushSendResult FromFailure(HttpResponseMessage response, string body)
|
||||
{
|
||||
var normalized = body.ToLowerInvariant();
|
||||
var retryable = response.StatusCode == HttpStatusCode.TooManyRequests ||
|
||||
(int)response.StatusCode >= 500;
|
||||
TimeSpan? retryAfter = response.Headers.RetryAfter?.Delta;
|
||||
return new(false, retryable, LooksLikeInvalidToken(normalized),
|
||||
ErrorCode: $"http_{(int)response.StatusCode}",
|
||||
ErrorMessage: Trim(body), RetryAfter: retryAfter);
|
||||
}
|
||||
|
||||
private string? FreshAccessToken(string flavor) =>
|
||||
accessTokens.TryGetValue(flavor, out var cached) &&
|
||||
cached.ExpiresAt > DateTime.UtcNow.AddMinutes(2)
|
||||
? cached.Token
|
||||
: null;
|
||||
|
||||
private HttpClient Client() => httpClientFactory.CreateClient("PushProviders");
|
||||
private string? Value(string flavor, string key) =>
|
||||
configuration[$"Push:Providers:{Provider}:{flavor}:{key}"];
|
||||
private string Channel(string flavor, string category) =>
|
||||
Value(flavor, $"Channels:{category}") ?? $"jizhi_{category}";
|
||||
|
||||
private static HttpRequestMessage JsonRequest(HttpMethod method, string url, object body) => new(method, url)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
private static string PayloadJson(PushEnvelope message) => JsonSerializer.Serialize(new
|
||||
{
|
||||
v = 1,
|
||||
messageId = message.MessageId,
|
||||
category = message.Category,
|
||||
action = message.Action,
|
||||
entityId = message.EntityId,
|
||||
});
|
||||
|
||||
private static string IntentUri(string packageName, PushEnvelope message)
|
||||
{
|
||||
var entity = message.EntityId is null ? "" : $"&entityId={Uri.EscapeDataString(message.EntityId)}";
|
||||
return $"miaoji://push/open?messageId={Uri.EscapeDataString(message.MessageId)}" +
|
||||
$"&category={Uri.EscapeDataString(message.Category)}&action={Uri.EscapeDataString(message.Action)}{entity}";
|
||||
}
|
||||
|
||||
private static int StableNotificationId(string messageId)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(messageId));
|
||||
return BitConverter.ToInt32(bytes, 0) & int.MaxValue;
|
||||
}
|
||||
|
||||
private static string Sha256Hex(string value) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
private static string Md5Hex(string value) =>
|
||||
Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
private static string Trim(string value) => value.Length <= 400 ? value : value[..400];
|
||||
|
||||
private static bool HasUnexpectedValue(
|
||||
JsonElement root,
|
||||
string[] accepted,
|
||||
out string? value,
|
||||
params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (!TryString(root, out value, name)) continue;
|
||||
return !accepted.Contains(value!);
|
||||
}
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool LooksLikeInvalidToken(string normalized) =>
|
||||
normalized.Contains("invalid token") || normalized.Contains("invalid reg") ||
|
||||
normalized.Contains("registration_id_invalid") || normalized.Contains("target invalid") ||
|
||||
normalized.Contains("pushid") && normalized.Contains("invalid");
|
||||
|
||||
private static string NormalizeCode(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return "rejected";
|
||||
var normalized = new string(value.Where(character => char.IsLetterOrDigit(character) || character == '_')
|
||||
.Take(48).ToArray());
|
||||
return string.IsNullOrEmpty(normalized) ? "rejected" : normalized.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool TryString(JsonElement root, out string? value, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty(name, out var element))
|
||||
{
|
||||
value = element.ValueKind == JsonValueKind.String ? element.GetString() : element.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(value)) return true;
|
||||
}
|
||||
}
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryNestedString(JsonElement root, out string? value, string parent, string child)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty(parent, out var nested))
|
||||
return TryString(nested, out value, child);
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int? TryInt(JsonElement root, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (!root.TryGetProperty(name, out var value)) continue;
|
||||
if (value.TryGetInt32(out var parsed)) return parsed;
|
||||
if (int.TryParse(value.ToString(), out parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed record CachedAccessToken(string Token, DateTime ExpiresAt);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public sealed class PushTokenProtector(IConfiguration configuration)
|
||||
{
|
||||
private readonly byte[]? key = ReadKey(configuration["Push:TokenEncryptionKey"]);
|
||||
|
||||
public bool IsConfigured => key is { Length: 32 };
|
||||
|
||||
public string Protect(string value)
|
||||
{
|
||||
if (key is null)
|
||||
throw new InvalidOperationException("Push__TokenEncryptionKey must be a base64-encoded 32-byte key");
|
||||
|
||||
var plaintext = Encoding.UTF8.GetBytes(value);
|
||||
var nonce = RandomNumberGenerator.GetBytes(12);
|
||||
var tag = new byte[16];
|
||||
var ciphertext = new byte[plaintext.Length];
|
||||
using var aes = new AesGcm(key, tag.Length);
|
||||
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
||||
|
||||
var envelope = new byte[nonce.Length + tag.Length + ciphertext.Length];
|
||||
Buffer.BlockCopy(nonce, 0, envelope, 0, nonce.Length);
|
||||
Buffer.BlockCopy(tag, 0, envelope, nonce.Length, tag.Length);
|
||||
Buffer.BlockCopy(ciphertext, 0, envelope, nonce.Length + tag.Length, ciphertext.Length);
|
||||
return Convert.ToBase64String(envelope);
|
||||
}
|
||||
|
||||
public string Unprotect(string value)
|
||||
{
|
||||
if (key is null)
|
||||
throw new InvalidOperationException("Push__TokenEncryptionKey must be a base64-encoded 32-byte key");
|
||||
|
||||
var envelope = Convert.FromBase64String(value);
|
||||
if (envelope.Length < 29) throw new CryptographicException("Invalid push token envelope");
|
||||
var nonce = envelope.AsSpan(0, 12);
|
||||
var tag = envelope.AsSpan(12, 16);
|
||||
var ciphertext = envelope.AsSpan(28);
|
||||
var plaintext = new byte[ciphertext.Length];
|
||||
using var aes = new AesGcm(key, tag.Length);
|
||||
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||
return Encoding.UTF8.GetString(plaintext);
|
||||
}
|
||||
|
||||
public static string Hash(string value) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
|
||||
|
||||
public static string CreateUnbindToken() =>
|
||||
Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
||||
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
private static byte[]? ReadKey(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
try
|
||||
{
|
||||
var parsed = Convert.FromBase64String(value);
|
||||
return parsed.Length == 32 ? parsed : null;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,5 +18,17 @@
|
||||
"AllowedHosts": "*",
|
||||
"Admin": {
|
||||
"Key": ""
|
||||
}
|
||||
},
|
||||
"Push": {
|
||||
"Enabled": false,
|
||||
"TokenEncryptionKey": "",
|
||||
"Providers": {
|
||||
"huawei": { "production": { "Enabled": false }, "internal": { "Enabled": false } },
|
||||
"honor": { "production": { "Enabled": false }, "internal": { "Enabled": false } },
|
||||
"xiaomi": { "production": { "Enabled": false }, "internal": { "Enabled": false } },
|
||||
"oppo": { "production": { "Enabled": false }, "internal": { "Enabled": false } },
|
||||
"vivo": { "production": { "Enabled": false }, "internal": { "Enabled": false } },
|
||||
"meizu": { "production": { "Enabled": false }, "internal": { "Enabled": false } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{n as g,r as _,t as v}from"./EditOutlined-CeylGsUo.js";import{t as y}from"./api-BV_Zb8mM.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=t({__name:`Avatars`,setup(t){let x=a([]),S=a(!0),C=a(!1),w=a(null),T=a({key:``,name:``,speechTic:``,imageUrl:``,isEnabled:!0}),E=[{title:`Key`,dataIndex:`key`,key:`key`,width:90},{title:`默认名`,dataIndex:`defaultName`,key:`name`,width:100},{title:`口癖`,dataIndex:`speechTic`,key:`tic`,width:80},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(D);async function D(){S.value=!0;try{x.value=await y.avatars()}finally{S.value=!1}}function O(){w.value=null,T.value={key:``,name:``,speechTic:``,imageUrl:``,isEnabled:!0},C.value=!0}function k(e){w.value=e,T.value={key:e.key,name:e.defaultName,speechTic:e.speechTic,imageUrl:e.imageUrl||``,isEnabled:e.isEnabled},C.value=!0}async function A(){let e={...T.value,imageUrl:T.value.imageUrl||null};w.value?await y.updateAvatar(w.value.id,e):await y.createAvatar(e),c.success(w.value?`已更新`:`已创建`),C.value=!1,D()}async function j(e){await y.deleteAvatar(e),c.success(`已删除`),D()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),y=e(`a-popconfirm`),D=e(`a-table`),M=e(`a-input`),N=e(`a-form-item`),P=e(`a-col`),F=e(`a-row`),I=e(`a-switch`),L=e(`a-form`),R=e(`a-modal`);return r(),u(d,null,[s(`div`,b,[i[7]||=s(`h2`,null,`AI 形象管理`,-1),o(a,{type:`primary`,onClick:O},{default:n(()=>[o(l(_)),i[6]||=m(` 新建形象`,-1)]),_:1})]),o(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`on`?(r(),p(c,{key:0,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:1},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(t)},{default:n(()=>[o(l(v))]),_:1},8,[`onClick`]),o(y,{title:`确定删除?`,onConfirm:e=>j(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(g))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(R,{open:C.value,"onUpdate:open":i[5]||=e=>C.value=e,title:w.value?`编辑形象`:`新建形象`,onOk:A,width:500},{default:n(()=>[o(L,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(F,{gutter:12},{default:n(()=>[o(P,{span:12},{default:n(()=>[o(N,{label:`Key`},{default:n(()=>[o(M,{value:T.value.key,"onUpdate:value":i[0]||=e=>T.value.key=e,placeholder:`cat`},null,8,[`value`])]),_:1})]),_:1}),o(P,{span:12},{default:n(()=>[o(N,{label:`默认名`},{default:n(()=>[o(M,{value:T.value.name,"onUpdate:value":i[1]||=e=>T.value.name=e,placeholder:`小账喵`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(N,{label:`口癖后缀`},{default:n(()=>[o(M,{value:T.value.speechTic,"onUpdate:value":i[2]||=e=>T.value.speechTic=e,placeholder:`喵 / 汪 / 留空=无口癖`},null,8,[`value`]),i[8]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`口癖跟随形象——决策 20`,-1)]),_:1}),o(N,{label:`头像图片 URL`},{default:n(()=>[o(M,{value:T.value.imageUrl,"onUpdate:value":i[3]||=e=>T.value.imageUrl=e,placeholder:`可选,CDN 地址`},null,8,[`value`])]),_:1}),o(N,{label:`是否启用`},{default:n(()=>[o(I,{checked:T.value.isEnabled,"onUpdate:checked":i[4]||=e=>T.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{x as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{n as g,r as _,t as v}from"./EditOutlined-CeylGsUo.js";import{t as y}from"./api-C4vz6nB3.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=t({__name:`Avatars`,setup(t){let x=a([]),S=a(!0),C=a(!1),w=a(null),T=a({key:``,name:``,speechTic:``,imageUrl:``,isEnabled:!0}),E=[{title:`Key`,dataIndex:`key`,key:`key`,width:90},{title:`默认名`,dataIndex:`defaultName`,key:`name`,width:100},{title:`口癖`,dataIndex:`speechTic`,key:`tic`,width:80},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(D);async function D(){S.value=!0;try{x.value=await y.avatars()}finally{S.value=!1}}function O(){w.value=null,T.value={key:``,name:``,speechTic:``,imageUrl:``,isEnabled:!0},C.value=!0}function k(e){w.value=e,T.value={key:e.key,name:e.defaultName,speechTic:e.speechTic,imageUrl:e.imageUrl||``,isEnabled:e.isEnabled},C.value=!0}async function A(){let e={...T.value,imageUrl:T.value.imageUrl||null};w.value?await y.updateAvatar(w.value.id,e):await y.createAvatar(e),c.success(w.value?`已更新`:`已创建`),C.value=!1,D()}async function j(e){await y.deleteAvatar(e),c.success(`已删除`),D()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),y=e(`a-popconfirm`),D=e(`a-table`),M=e(`a-input`),N=e(`a-form-item`),P=e(`a-col`),F=e(`a-row`),I=e(`a-switch`),L=e(`a-form`),R=e(`a-modal`);return r(),u(d,null,[s(`div`,b,[i[7]||=s(`h2`,null,`AI 形象管理`,-1),o(a,{type:`primary`,onClick:O},{default:n(()=>[o(l(_)),i[6]||=m(` 新建形象`,-1)]),_:1})]),o(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`on`?(r(),p(c,{key:0,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:1},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(t)},{default:n(()=>[o(l(v))]),_:1},8,[`onClick`]),o(y,{title:`确定删除?`,onConfirm:e=>j(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(g))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(R,{open:C.value,"onUpdate:open":i[5]||=e=>C.value=e,title:w.value?`编辑形象`:`新建形象`,onOk:A,width:500},{default:n(()=>[o(L,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(F,{gutter:12},{default:n(()=>[o(P,{span:12},{default:n(()=>[o(N,{label:`Key`},{default:n(()=>[o(M,{value:T.value.key,"onUpdate:value":i[0]||=e=>T.value.key=e,placeholder:`cat`},null,8,[`value`])]),_:1})]),_:1}),o(P,{span:12},{default:n(()=>[o(N,{label:`默认名`},{default:n(()=>[o(M,{value:T.value.name,"onUpdate:value":i[1]||=e=>T.value.name=e,placeholder:`小账喵`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(N,{label:`口癖后缀`},{default:n(()=>[o(M,{value:T.value.speechTic,"onUpdate:value":i[2]||=e=>T.value.speechTic=e,placeholder:`喵 / 汪 / 留空=无口癖`},null,8,[`value`]),i[8]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`口癖跟随形象——决策 20`,-1)]),_:1}),o(N,{label:`头像图片 URL`},{default:n(()=>[o(M,{value:T.value.imageUrl,"onUpdate:value":i[3]||=e=>T.value.imageUrl=e,placeholder:`可选,CDN 地址`},null,8,[`value`])]),_:1}),o(N,{label:`是否启用`},{default:n(()=>[o(I,{checked:T.value.isEnabled,"onUpdate:checked":i[4]||=e=>T.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{x as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{n as g,t as _}from"./EditOutlined-h6ScL3Qz.js";import{t as v}from"./DeleteOutlined-yVoeJ3Fd.js";import{t as y}from"./api-wmB-hCXT.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=t({__name:`Avatars`,setup(t){let x=a([]),S=a(!0),C=a(!1),w=a(null),T=a({key:``,name:``,speechTic:``,imageUrl:``,isEnabled:!0}),E=[{title:`Key`,dataIndex:`key`,key:`key`,width:90},{title:`默认名`,dataIndex:`defaultName`,key:`name`,width:100},{title:`口癖`,dataIndex:`speechTic`,key:`tic`,width:80},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(D);async function D(){S.value=!0;try{x.value=await y.avatars()}finally{S.value=!1}}function O(){w.value=null,T.value={key:``,name:``,speechTic:``,imageUrl:``,isEnabled:!0},C.value=!0}function k(e){w.value=e,T.value={key:e.key,name:e.defaultName,speechTic:e.speechTic,imageUrl:e.imageUrl||``,isEnabled:e.isEnabled},C.value=!0}async function A(){let e={...T.value,imageUrl:T.value.imageUrl||null};w.value?await y.updateAvatar(w.value.id,e):await y.createAvatar(e),c.success(w.value?`已更新`:`已创建`),C.value=!1,D()}async function j(e){await y.deleteAvatar(e),c.success(`已删除`),D()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),y=e(`a-popconfirm`),D=e(`a-table`),M=e(`a-input`),N=e(`a-form-item`),P=e(`a-col`),F=e(`a-row`),I=e(`a-switch`),L=e(`a-form`),R=e(`a-modal`);return r(),u(d,null,[s(`div`,b,[i[7]||=s(`h2`,null,`AI 形象管理`,-1),o(a,{type:`primary`,onClick:O},{default:n(()=>[o(l(g)),i[6]||=m(` 新建形象`,-1)]),_:1})]),o(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`on`?(r(),p(c,{key:0,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:1},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(t)},{default:n(()=>[o(l(_))]),_:1},8,[`onClick`]),o(y,{title:`确定删除?`,onConfirm:e=>j(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(v))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(R,{open:C.value,"onUpdate:open":i[5]||=e=>C.value=e,title:w.value?`编辑形象`:`新建形象`,onOk:A,width:500},{default:n(()=>[o(L,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(F,{gutter:12},{default:n(()=>[o(P,{span:12},{default:n(()=>[o(N,{label:`Key`},{default:n(()=>[o(M,{value:T.value.key,"onUpdate:value":i[0]||=e=>T.value.key=e,placeholder:`cat`},null,8,[`value`])]),_:1})]),_:1}),o(P,{span:12},{default:n(()=>[o(N,{label:`默认名`},{default:n(()=>[o(M,{value:T.value.name,"onUpdate:value":i[1]||=e=>T.value.name=e,placeholder:`小账喵`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(N,{label:`口癖后缀`},{default:n(()=>[o(M,{value:T.value.speechTic,"onUpdate:value":i[2]||=e=>T.value.speechTic=e,placeholder:`喵 / 汪 / 留空=无口癖`},null,8,[`value`]),i[8]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`口癖跟随形象——决策 20`,-1)]),_:1}),o(N,{label:`头像图片 URL`},{default:n(()=>[o(M,{value:T.value.imageUrl,"onUpdate:value":i[3]||=e=>T.value.imageUrl=e,placeholder:`可选,CDN 地址`},null,8,[`value`])]),_:1}),o(N,{label:`是否启用`},{default:n(()=>[o(I,{checked:T.value.isEnabled,"onUpdate:checked":i[4]||=e=>T.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{x as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,bn as l,fn as u,gn as d,or as f,vn as p,xn as m,zn as h}from"./config-provider-q7ATIdCu.js";import{t as g}from"./api-BV_Zb8mM.js";var _={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`20px`}},v={style:{display:`flex`,"justify-content":`space-between`,"align-items":`flex-start`}},y={style:{"font-size":`13px`,"font-weight":`600`,"margin-bottom":`2px`}},b={style:{color:`#999`,"font-size":`11px`,"margin-bottom":`6px`}},x={key:0,style:{"margin-top":`6px`,"font-size":`18px`}},S={key:0,style:{color:`#00B386`}},C={key:1,style:{color:`#ccc`}},w={key:1,style:{"margin-top":`6px`,"font-size":`16px`,"font-weight":`700`,"word-break":`break-all`}},T={style:{color:`#999`,"font-size":`10px`,"margin-top":`4px`}},E={style:{"margin-bottom":`10px`,color:`#999`,"font-size":`12px`}},D=t({__name:`Configs`,setup(t){let D=a([]),O=a(!0),k={"brand.app_name":{label:`App 名称`,desc:`App 内展示名称`,type:`text`},"brand.slogan":{label:`App 标语`,desc:`启动页/关于页口号`,type:`text`},"brand.logo_url":{label:`Logo URL`,desc:`品牌 Logo 远程地址`,type:`url`},"limit.daily_ai_messages":{label:`全局日限额`,desc:`全站每日 AI 消息上限`,type:`number`},"limit.daily_ai_messages_per_user":{label:`每人日限额`,desc:`单用户每日 AI 消息上限`,type:`number`},"limit.max_monthly_budget":{label:`最大月预算`,desc:`用户可设置的最高月预算金额`,type:`number`},"feature.ocr_enabled":{label:`OCR 拍照识别`,desc:`是否开放 OCR 小票识别功能`,type:`switch`},"feature.voice_enabled":{label:`语音输入`,desc:`是否开放语音记账功能`,type:`switch`},"feature.ai_auto_book":{label:`AI 自动入账`,desc:`AI 识别记账意图后是否直接写库`,type:`switch`},"feature.sticker_enabled":{label:`表情包功能`,desc:`是否开放表情包面板和 AI 表情回复`,type:`switch`},"system.default_ledger_name":{label:`默认账本名`,desc:`新用户注册时自动创建`,type:`text`},"system.max_ledgers_per_user":{label:`每人最多账本`,desc:`单用户可创建账本上限`,type:`number`}},A=[{key:`brand`,label:`品牌`,prefix:`brand.`},{key:`limit`,label:`限额`,prefix:`limit.`},{key:`feature`,label:`功能开关`,prefix:`feature.`},{key:`system`,label:`系统`,prefix:`system.`}],j=d(()=>{let e={};for(let t of D.value){let n=A.find(e=>t.key.startsWith(e.prefix))?.key||`other`;e[n]||(e[n]=[]),e[n].push(t)}return e});i(M);async function M(){O.value=!0;try{D.value=await g.configs()}finally{O.value=!1}}let N=a(!1),P=a(null),F=a(``),I=d(()=>P.value?k[P.value.key]:null);function L(e){P.value=e,F.value=e.value,N.value=!0}async function R(){P.value&&(await g.updateConfig(P.value.id,F.value),c.success(`已更新 ${P.value.key}`),N.value=!1,M())}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),d=e(`a-card`),g=e(`a-col`),D=e(`a-row`),O=e(`a-tab-pane`),z=e(`a-tabs`),B=e(`a-switch`),V=e(`a-input-number`),H=e(`a-select`),U=e(`a-input`),W=e(`a-modal`);return r(),l(`div`,null,[s(`div`,_,[i[6]||=s(`h2`,{style:{margin:`0`}},`品牌配置`,-1),o(a,{onClick:M},{default:n(()=>[...i[5]||=[m(`刷新`,-1)]]),_:1})]),o(z,null,{default:n(()=>[(r(),l(u,null,h(A,e=>o(O,{key:e.key,tab:e.label},{default:n(()=>[o(D,{gutter:[16,12]},{default:n(()=>[(r(!0),l(u,null,h(j.value[e.key],e=>(r(),p(g,{key:e.id,span:8},{default:n(()=>[o(d,{size:`small`,hoverable:``,onClick:t=>L(e)},{default:n(()=>[s(`div`,v,[s(`div`,null,[s(`div`,y,f(k[e.key]?.label||e.key),1),s(`div`,b,f(k[e.key]?.desc||``),1)]),o(c,{color:`blue`,style:{"margin-left":`8px`}},{default:n(()=>[m(`v`+f(e.version),1)]),_:2},1024)]),k[e.key]?.type===`switch`?(r(),l(`div`,x,[e.value===`true`?(r(),l(`span`,S,`✅ 已开启`)):(r(),l(`span`,C,`❌ 已关闭`))])):(r(),l(`div`,w,f(e.key.includes(`key`)?`••••••••`:e.value||`(空)`),1)),s(`div`,T,f(e.updatedAt?.split(`T`)[0]),1)]),_:2},1032,[`onClick`])]),_:2},1024))),128))]),_:2},1024)]),_:2},1032,[`tab`])),64))]),_:1}),o(W,{open:N.value,"onUpdate:open":i[4]||=e=>N.value=e,title:`编辑配置: ${P.value?.key}`,onOk:R,width:440},{default:n(()=>[s(`div`,E,f(I.value?.desc),1),I.value?.type===`switch`?(r(),p(B,{key:0,checked:F.value===`true`,onChange:i[0]||=e=>F.value=String(e),"checked-children":`开启`,"un-checked-children":`关闭`},null,8,[`checked`])):I.value?.type===`number`?(r(),p(V,{key:1,value:F.value,"onUpdate:value":i[1]||=e=>F.value=e,style:{width:`100%`}},null,8,[`value`])):I.value?.type===`select`&&I.value.options?(r(),p(H,{key:2,value:F.value,"onUpdate:value":i[2]||=e=>F.value=e,style:{width:`100%`},options:I.value.options.map(e=>({value:e,label:e}))},null,8,[`value`,`options`])):(r(),p(U,{key:3,value:F.value,"onUpdate:value":i[3]||=e=>F.value=e},null,8,[`value`]))]),_:1},8,[`open`,`title`])])}}});export{D as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,bn as l,fn as u,gn as d,or as f,vn as p,xn as m,zn as h}from"./config-provider-q7ATIdCu.js";import{t as g}from"./api-C4vz6nB3.js";var _={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`20px`}},v={style:{display:`flex`,"justify-content":`space-between`,"align-items":`flex-start`}},y={style:{"font-size":`13px`,"font-weight":`600`,"margin-bottom":`2px`}},b={style:{color:`#999`,"font-size":`11px`,"margin-bottom":`6px`}},x={key:0,style:{"margin-top":`6px`,"font-size":`18px`}},S={key:0,style:{color:`#00B386`}},C={key:1,style:{color:`#ccc`}},w={key:1,style:{"margin-top":`6px`,"font-size":`16px`,"font-weight":`700`,"word-break":`break-all`}},T={style:{color:`#999`,"font-size":`10px`,"margin-top":`4px`}},E={style:{"margin-bottom":`10px`,color:`#999`,"font-size":`12px`}},D=t({__name:`Configs`,setup(t){let D=a([]),O=a(!0),k={"brand.app_name":{label:`App 名称`,desc:`App 内展示名称`,type:`text`},"brand.slogan":{label:`App 标语`,desc:`启动页/关于页口号`,type:`text`},"brand.logo_url":{label:`Logo URL`,desc:`品牌 Logo 远程地址`,type:`url`},"limit.daily_ai_messages":{label:`全局日限额`,desc:`全站每日 AI 消息上限`,type:`number`},"limit.daily_ai_messages_per_user":{label:`每人日限额`,desc:`单用户每日 AI 消息上限`,type:`number`},"limit.max_monthly_budget":{label:`最大月预算`,desc:`用户可设置的最高月预算金额`,type:`number`},"feature.ocr_enabled":{label:`OCR 拍照识别`,desc:`是否开放 OCR 小票识别功能`,type:`switch`},"feature.voice_enabled":{label:`语音输入`,desc:`是否开放语音记账功能`,type:`switch`},"feature.ai_auto_book":{label:`AI 自动入账`,desc:`AI 识别记账意图后是否直接写库`,type:`switch`},"feature.sticker_enabled":{label:`表情包功能`,desc:`是否开放表情包面板和 AI 表情回复`,type:`switch`},"system.default_ledger_name":{label:`默认账本名`,desc:`新用户注册时自动创建`,type:`text`},"system.max_ledgers_per_user":{label:`每人最多账本`,desc:`单用户可创建账本上限`,type:`number`}},A=[{key:`brand`,label:`品牌`,prefix:`brand.`},{key:`limit`,label:`限额`,prefix:`limit.`},{key:`feature`,label:`功能开关`,prefix:`feature.`},{key:`system`,label:`系统`,prefix:`system.`}],j=d(()=>{let e={};for(let t of D.value){let n=A.find(e=>t.key.startsWith(e.prefix))?.key||`other`;e[n]||(e[n]=[]),e[n].push(t)}return e});i(M);async function M(){O.value=!0;try{D.value=await g.configs()}finally{O.value=!1}}let N=a(!1),P=a(null),F=a(``),I=d(()=>P.value?k[P.value.key]:null);function L(e){P.value=e,F.value=e.value,N.value=!0}async function R(){P.value&&(await g.updateConfig(P.value.id,F.value),c.success(`已更新 ${P.value.key}`),N.value=!1,M())}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),d=e(`a-card`),g=e(`a-col`),D=e(`a-row`),O=e(`a-tab-pane`),z=e(`a-tabs`),B=e(`a-switch`),V=e(`a-input-number`),H=e(`a-select`),U=e(`a-input`),W=e(`a-modal`);return r(),l(`div`,null,[s(`div`,_,[i[6]||=s(`h2`,{style:{margin:`0`}},`品牌配置`,-1),o(a,{onClick:M},{default:n(()=>[...i[5]||=[m(`刷新`,-1)]]),_:1})]),o(z,null,{default:n(()=>[(r(),l(u,null,h(A,e=>o(O,{key:e.key,tab:e.label},{default:n(()=>[o(D,{gutter:[16,12]},{default:n(()=>[(r(!0),l(u,null,h(j.value[e.key],e=>(r(),p(g,{key:e.id,span:8},{default:n(()=>[o(d,{size:`small`,hoverable:``,onClick:t=>L(e)},{default:n(()=>[s(`div`,v,[s(`div`,null,[s(`div`,y,f(k[e.key]?.label||e.key),1),s(`div`,b,f(k[e.key]?.desc||``),1)]),o(c,{color:`blue`,style:{"margin-left":`8px`}},{default:n(()=>[m(`v`+f(e.version),1)]),_:2},1024)]),k[e.key]?.type===`switch`?(r(),l(`div`,x,[e.value===`true`?(r(),l(`span`,S,`✅ 已开启`)):(r(),l(`span`,C,`❌ 已关闭`))])):(r(),l(`div`,w,f(e.key.includes(`key`)?`••••••••`:e.value||`(空)`),1)),s(`div`,T,f(e.updatedAt?.split(`T`)[0]),1)]),_:2},1032,[`onClick`])]),_:2},1024))),128))]),_:2},1024)]),_:2},1032,[`tab`])),64))]),_:1}),o(W,{open:N.value,"onUpdate:open":i[4]||=e=>N.value=e,title:`编辑配置: ${P.value?.key}`,onOk:R,width:440},{default:n(()=>[s(`div`,E,f(I.value?.desc),1),I.value?.type===`switch`?(r(),p(B,{key:0,checked:F.value===`true`,onChange:i[0]||=e=>F.value=String(e),"checked-children":`开启`,"un-checked-children":`关闭`},null,8,[`checked`])):I.value?.type===`number`?(r(),p(V,{key:1,value:F.value,"onUpdate:value":i[1]||=e=>F.value=e,style:{width:`100%`}},null,8,[`value`])):I.value?.type===`select`&&I.value.options?(r(),p(H,{key:2,value:F.value,"onUpdate:value":i[2]||=e=>F.value=e,style:{width:`100%`},options:I.value.options.map(e=>({value:e,label:e}))},null,8,[`value`,`options`])):(r(),p(U,{key:3,value:F.value,"onUpdate:value":i[3]||=e=>F.value=e},null,8,[`value`]))]),_:1},8,[`open`,`title`])])}}});export{D as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,gn as f,or as p,vn as m,xn as h,zn as g}from"./config-provider-q7ATIdCu.js";import{t as _}from"./api-wmB-hCXT.js";import{t as v}from"./time-pIfF89ap.js";var y={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`20px`}},b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`flex-start`}},x={style:{"font-size":`13px`,"font-weight":`600`,"margin-bottom":`2px`}},S={style:{color:`#999`,"font-size":`11px`,"margin-bottom":`6px`}},C={key:0,style:{"margin-top":`6px`,"font-size":`18px`}},w={key:0,style:{color:`#00B386`}},T={key:1,style:{color:`#ccc`}},E={key:1,style:{"margin-top":`6px`,"font-size":`16px`,"font-weight":`700`,"word-break":`break-all`}},D={style:{color:`#999`,"font-size":`10px`,"margin-top":`4px`}},O={style:{"margin-bottom":`10px`,color:`#999`,"font-size":`12px`}},k=t({__name:`Configs`,setup(t){let k=a([]),A=a(!0),j={"brand.app_name":{label:`App 名称`,desc:`App 内展示名称`,type:`text`},"brand.slogan":{label:`App 标语`,desc:`启动页/关于页口号`,type:`text`},"brand.logo_url":{label:`Logo URL`,desc:`品牌 Logo 远程地址`,type:`url`},"limit.daily_ai_messages":{label:`全局日限额`,desc:`全站每日 AI 消息上限`,type:`number`},"limit.daily_ai_messages_per_user":{label:`每人日限额`,desc:`单用户每日 AI 消息上限`,type:`number`},"limit.max_monthly_budget":{label:`最大月预算`,desc:`用户可设置的最高月预算金额`,type:`number`},"feature.ocr_enabled":{label:`OCR 拍照识别`,desc:`是否开放 OCR 小票识别功能`,type:`switch`},"feature.voice_enabled":{label:`语音输入`,desc:`是否开放语音记账功能`,type:`switch`},"feature.ai_auto_book":{label:`AI 自动入账`,desc:`AI 识别记账意图后是否直接写库`,type:`switch`},"feature.sticker_enabled":{label:`表情包功能`,desc:`是否开放表情包面板和 AI 表情回复`,type:`switch`},"system.default_ledger_name":{label:`默认账本名`,desc:`新用户注册时自动创建`,type:`text`},"system.max_ledgers_per_user":{label:`每人最多账本`,desc:`单用户可创建账本上限`,type:`number`}},M=[{key:`brand`,label:`品牌`,prefix:`brand.`},{key:`limit`,label:`限额`,prefix:`limit.`},{key:`feature`,label:`功能开关`,prefix:`feature.`},{key:`system`,label:`系统`,prefix:`system.`}],N=f(()=>{let e={};for(let t of k.value){let n=M.find(e=>t.key.startsWith(e.prefix))?.key||`other`;e[n]||(e[n]=[]),e[n].push(t)}return e});i(P);async function P(){A.value=!0;try{k.value=await _.configs()}finally{A.value=!1}}let F=a(!1),I=a(null),L=a(``),R=f(()=>I.value?j[I.value.key]:null);function z(e){I.value=e,L.value=e.value,F.value=!0}async function B(){I.value&&(await _.updateConfig(I.value.id,L.value),c.success(`已更新 ${I.value.key}`),F.value=!1,P())}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),f=e(`a-card`),_=e(`a-col`),k=e(`a-row`),A=e(`a-tab-pane`),V=e(`a-tabs`),H=e(`a-switch`),U=e(`a-input-number`),W=e(`a-select`),G=e(`a-input`),K=e(`a-modal`);return r(),u(`div`,null,[s(`div`,y,[i[6]||=s(`h2`,{style:{margin:`0`}},`品牌配置`,-1),o(a,{onClick:P},{default:n(()=>[...i[5]||=[h(`刷新`,-1)]]),_:1})]),o(V,null,{default:n(()=>[(r(),u(d,null,g(M,e=>o(A,{key:e.key,tab:e.label},{default:n(()=>[o(k,{gutter:[16,12]},{default:n(()=>[(r(!0),u(d,null,g(N.value[e.key],e=>(r(),m(_,{key:e.id,span:8},{default:n(()=>[o(f,{size:`small`,hoverable:``,onClick:t=>z(e)},{default:n(()=>[s(`div`,b,[s(`div`,null,[s(`div`,x,p(j[e.key]?.label||e.key),1),s(`div`,S,p(j[e.key]?.desc||``),1)]),o(c,{color:`blue`,style:{"margin-left":`8px`}},{default:n(()=>[h(`v`+p(e.version),1)]),_:2},1024)]),j[e.key]?.type===`switch`?(r(),u(`div`,C,[e.value===`true`?(r(),u(`span`,w,`✅ 已开启`)):(r(),u(`span`,T,`❌ 已关闭`))])):(r(),u(`div`,E,p(e.key.includes(`key`)?`••••••••`:e.value||`(空)`),1)),s(`div`,D,p(l(v)(e.updatedAt)),1)]),_:2},1032,[`onClick`])]),_:2},1024))),128))]),_:2},1024)]),_:2},1032,[`tab`])),64))]),_:1}),o(K,{open:F.value,"onUpdate:open":i[4]||=e=>F.value=e,title:`编辑配置: ${I.value?.key}`,onOk:B,width:440},{default:n(()=>[s(`div`,O,p(R.value?.desc),1),R.value?.type===`switch`?(r(),m(H,{key:0,checked:L.value===`true`,onChange:i[0]||=e=>L.value=String(e),"checked-children":`开启`,"un-checked-children":`关闭`},null,8,[`checked`])):R.value?.type===`number`?(r(),m(U,{key:1,value:L.value,"onUpdate:value":i[1]||=e=>L.value=e,style:{width:`100%`}},null,8,[`value`])):R.value?.type===`select`&&R.value.options?(r(),m(W,{key:2,value:L.value,"onUpdate:value":i[2]||=e=>L.value=e,style:{width:`100%`},options:R.value.options.map(e=>({value:e,label:e}))},null,8,[`value`,`options`])):(r(),m(G,{key:3,value:L.value,"onUpdate:value":i[3]||=e=>L.value=e},null,8,[`value`]))]),_:1},8,[`open`,`title`])])}}});export{k as default};
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{Sn as e,y as t}from"./config-provider-q7ATIdCu.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60.4l24.7 523c1.6 34.1 29.8 61 63.9 61h454c34.2 0 62.3-26.8 63.9-61l24.7-523H888c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM731.3 840H292.7l-24.2-512h487l-24.2 512z`}}]},name:`delete`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){return e(t,r({},r({},i,a.attrs),{icon:n}),null)};a.displayName=`DeleteOutlined`,a.inheritAttrs=!1;export{a as t};
|
||||
@@ -1 +0,0 @@
|
||||
import{Sn as e,y as t}from"./config-provider-q7ATIdCu.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M482 152h60q8 0 8 8v704q0 8-8 8h-60q-8 0-8-8V160q0-8 8-8z`}},{tag:`path`,attrs:{d:`M192 474h672q8 0 8 8v60q0 8-8 8H160q-8 0-8-8v-60q0-8 8-8z`}}]},name:`plus`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){return e(t,r({},r({},i,a.attrs),{icon:n}),null)};a.displayName=`PlusOutlined`,a.inheritAttrs=!1;var o={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60.4l24.7 523c1.6 34.1 29.8 61 63.9 61h454c34.2 0 62.3-26.8 63.9-61l24.7-523H888c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM731.3 840H292.7l-24.2-512h487l-24.2 512z`}}]},name:`delete`,theme:`outlined`};function s(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){c(e,t,n[t])})}return e}function c(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var l=function(n,r){return e(t,s({},s({},n,r.attrs),{icon:o}),null)};l.displayName=`DeleteOutlined`,l.inheritAttrs=!1;var u={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M257.7 752c2 0 4-.2 6-.5L431.9 722c2-.4 3.9-1.3 5.3-2.8l423.9-423.9a9.96 9.96 0 000-14.1L694.9 114.9c-1.9-1.9-4.4-2.9-7.1-2.9s-5.2 1-7.1 2.9L256.8 538.8c-1.5 1.5-2.4 3.3-2.8 5.3l-29.5 168.2a33.5 33.5 0 009.4 29.8c6.6 6.4 14.9 9.9 23.8 9.9zm67.4-174.4L687.8 215l73.3 73.3-362.7 362.6-88.9 15.7 15.6-89zM880 836H144c-17.7 0-32 14.3-32 32v36c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-36c0-17.7-14.3-32-32-32z`}}]},name:`edit`,theme:`outlined`};function d(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){f(e,t,n[t])})}return e}function f(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var p=function(n,r){return e(t,d({},d({},n,r.attrs),{icon:u}),null)};p.displayName=`EditOutlined`,p.inheritAttrs=!1;export{l as n,a as r,p as t};
|
||||
@@ -0,0 +1 @@
|
||||
import{Sn as e,y as t}from"./config-provider-q7ATIdCu.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M482 152h60q8 0 8 8v704q0 8-8 8h-60q-8 0-8-8V160q0-8 8-8z`}},{tag:`path`,attrs:{d:`M192 474h672q8 0 8 8v60q0 8-8 8H160q-8 0-8-8v-60q0-8 8-8z`}}]},name:`plus`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){return e(t,r({},r({},i,a.attrs),{icon:n}),null)};a.displayName=`PlusOutlined`,a.inheritAttrs=!1;var o={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M257.7 752c2 0 4-.2 6-.5L431.9 722c2-.4 3.9-1.3 5.3-2.8l423.9-423.9a9.96 9.96 0 000-14.1L694.9 114.9c-1.9-1.9-4.4-2.9-7.1-2.9s-5.2 1-7.1 2.9L256.8 538.8c-1.5 1.5-2.4 3.3-2.8 5.3l-29.5 168.2a33.5 33.5 0 009.4 29.8c6.6 6.4 14.9 9.9 23.8 9.9zm67.4-174.4L687.8 215l73.3 73.3-362.7 362.6-88.9 15.7 15.6-89zM880 836H144c-17.7 0-32 14.3-32 32v36c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-36c0-17.7-14.3-32-32-32z`}}]},name:`edit`,theme:`outlined`};function s(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){c(e,t,n[t])})}return e}function c(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var l=function(n,r){return e(t,s({},s({},n,r.attrs),{icon:o}),null)};l.displayName=`EditOutlined`,l.inheritAttrs=!1;export{a as n,l as t};
|
||||
@@ -0,0 +1 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{n as g,t as _}from"./EditOutlined-h6ScL3Qz.js";import{t as v}from"./DeleteOutlined-yVoeJ3Fd.js";import{t as y}from"./api-wmB-hCXT.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=t({__name:`Personas`,setup(t){let x=a([]),S=a(!0),C=a(!1),w=a(null),T=a({key:``,name:``,description:``,sampleLine:``,promptTemplate:``,isEnabled:!0}),E=[{title:`Key`,dataIndex:`key`,key:`key`,width:110},{title:`名称`,dataIndex:`name`,key:`name`,width:100},{title:`描述`,dataIndex:`description`,key:`desc`,ellipsis:!0},{title:`版本`,dataIndex:`version`,key:`ver`,width:60},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(D);async function D(){S.value=!0;try{x.value=await y.personas()}finally{S.value=!1}}function O(){w.value=null,T.value={key:``,name:``,description:``,sampleLine:``,promptTemplate:``,isEnabled:!0},C.value=!0}function k(e){w.value=e,T.value={key:e.key,name:e.name,description:e.description,sampleLine:e.sampleLine,promptTemplate:e.promptTemplate,isEnabled:e.isEnabled},C.value=!0}async function A(){let e={...T.value,isEnabled:T.value.isEnabled};w.value?await y.updatePersona(w.value.id,e):await y.createPersona(e),c.success(w.value?`已更新(版本号+1)`:`已创建`),C.value=!1,D()}async function j(e){await y.deletePersona(e),c.success(`已删除`),D()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),y=e(`a-popconfirm`),D=e(`a-table`),M=e(`a-input`),N=e(`a-form-item`),P=e(`a-col`),F=e(`a-row`),I=e(`a-textarea`),L=e(`a-switch`),R=e(`a-form`),z=e(`a-modal`);return r(),u(d,null,[s(`div`,b,[i[8]||=s(`h2`,null,`AI 性格管理`,-1),o(a,{type:`primary`,onClick:O},{default:n(()=>[o(l(g)),i[7]||=m(` 新建性格`,-1)]),_:1})]),o(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`on`?(r(),p(c,{key:0,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:1},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(t)},{default:n(()=>[o(l(_))]),_:1},8,[`onClick`]),o(y,{title:`确定删除?`,onConfirm:e=>j(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(v))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(z,{open:C.value,"onUpdate:open":i[6]||=e=>C.value=e,title:w.value?`编辑性格`:`新建性格`,onOk:A,width:560},{default:n(()=>[o(R,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(F,{gutter:12},{default:n(()=>[o(P,{span:12},{default:n(()=>[o(N,{label:`Key`},{default:n(()=>[o(M,{value:T.value.key,"onUpdate:value":i[0]||=e=>T.value.key=e,placeholder:`sassy_cat`},null,8,[`value`])]),_:1})]),_:1}),o(P,{span:12},{default:n(()=>[o(N,{label:`名称`},{default:n(()=>[o(M,{value:T.value.name,"onUpdate:value":i[1]||=e=>T.value.name=e,placeholder:`毒舌猫娘`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(N,{label:`描述`},{default:n(()=>[o(M,{value:T.value.description,"onUpdate:value":i[2]||=e=>T.value.description=e,placeholder:`一句话描述`},null,8,[`value`])]),_:1}),o(N,{label:`示例台词`},{default:n(()=>[o(M,{value:T.value.sampleLine,"onUpdate:value":i[3]||=e=>T.value.sampleLine=e,placeholder:`这句会展示给用户选性格时看`},null,8,[`value`])]),_:1}),o(N,{label:`Prompt 模板`},{default:n(()=>[o(I,{value:T.value.promptTemplate,"onUpdate:value":i[4]||=e=>T.value.promptTemplate=e,rows:5,placeholder:`系统提示词模板,支持 {tic} 占位符,此字段可调不发版`},null,8,[`value`]),i[9]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},[m(` ⚡ 调这个不需要发版,前端实时生效。可用占位符:`),s(`code`,null,`{'{tic}'}`),m(` = 口癖(喵/汪/无) `)],-1)]),_:1}),o(N,{label:`是否启用`},{default:n(()=>[o(L,{checked:T.value.isEnabled,"onUpdate:checked":i[5]||=e=>T.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{x as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{n as g,r as _,t as v}from"./EditOutlined-CeylGsUo.js";import{t as y}from"./api-C4vz6nB3.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=t({__name:`Personas`,setup(t){let x=a([]),S=a(!0),C=a(!1),w=a(null),T=a({key:``,name:``,description:``,sampleLine:``,promptTemplate:``,isEnabled:!0}),E=[{title:`Key`,dataIndex:`key`,key:`key`,width:110},{title:`名称`,dataIndex:`name`,key:`name`,width:100},{title:`描述`,dataIndex:`description`,key:`desc`,ellipsis:!0},{title:`版本`,dataIndex:`version`,key:`ver`,width:60},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(D);async function D(){S.value=!0;try{x.value=await y.personas()}finally{S.value=!1}}function O(){w.value=null,T.value={key:``,name:``,description:``,sampleLine:``,promptTemplate:``,isEnabled:!0},C.value=!0}function k(e){w.value=e,T.value={key:e.key,name:e.name,description:e.description,sampleLine:e.sampleLine,promptTemplate:e.promptTemplate,isEnabled:e.isEnabled},C.value=!0}async function A(){let e={...T.value,isEnabled:T.value.isEnabled};w.value?await y.updatePersona(w.value.id,e):await y.createPersona(e),c.success(w.value?`已更新(版本号+1)`:`已创建`),C.value=!1,D()}async function j(e){await y.deletePersona(e),c.success(`已删除`),D()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),y=e(`a-popconfirm`),D=e(`a-table`),M=e(`a-input`),N=e(`a-form-item`),P=e(`a-col`),F=e(`a-row`),I=e(`a-textarea`),L=e(`a-switch`),R=e(`a-form`),z=e(`a-modal`);return r(),u(d,null,[s(`div`,b,[i[8]||=s(`h2`,null,`AI 性格管理`,-1),o(a,{type:`primary`,onClick:O},{default:n(()=>[o(l(_)),i[7]||=m(` 新建性格`,-1)]),_:1})]),o(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`on`?(r(),p(c,{key:0,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:1},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(t)},{default:n(()=>[o(l(v))]),_:1},8,[`onClick`]),o(y,{title:`确定删除?`,onConfirm:e=>j(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(g))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(z,{open:C.value,"onUpdate:open":i[6]||=e=>C.value=e,title:w.value?`编辑性格`:`新建性格`,onOk:A,width:560},{default:n(()=>[o(R,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(F,{gutter:12},{default:n(()=>[o(P,{span:12},{default:n(()=>[o(N,{label:`Key`},{default:n(()=>[o(M,{value:T.value.key,"onUpdate:value":i[0]||=e=>T.value.key=e,placeholder:`sassy_cat`},null,8,[`value`])]),_:1})]),_:1}),o(P,{span:12},{default:n(()=>[o(N,{label:`名称`},{default:n(()=>[o(M,{value:T.value.name,"onUpdate:value":i[1]||=e=>T.value.name=e,placeholder:`毒舌猫娘`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(N,{label:`描述`},{default:n(()=>[o(M,{value:T.value.description,"onUpdate:value":i[2]||=e=>T.value.description=e,placeholder:`一句话描述`},null,8,[`value`])]),_:1}),o(N,{label:`示例台词`},{default:n(()=>[o(M,{value:T.value.sampleLine,"onUpdate:value":i[3]||=e=>T.value.sampleLine=e,placeholder:`这句会展示给用户选性格时看`},null,8,[`value`])]),_:1}),o(N,{label:`Prompt 模板`},{default:n(()=>[o(I,{value:T.value.promptTemplate,"onUpdate:value":i[4]||=e=>T.value.promptTemplate=e,rows:5,placeholder:`系统提示词模板,支持 {tic} 占位符,此字段可调不发版`},null,8,[`value`]),i[9]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},[m(` ⚡ 调这个不需要发版,前端实时生效。可用占位符:`),s(`code`,null,`{'{tic}'}`),m(` = 口癖(喵/汪/无) `)],-1)]),_:1}),o(N,{label:`是否启用`},{default:n(()=>[o(L,{checked:T.value.isEnabled,"onUpdate:checked":i[5]||=e=>T.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{x as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{n as g,r as _,t as v}from"./EditOutlined-CeylGsUo.js";import{t as y}from"./api-BV_Zb8mM.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=t({__name:`Personas`,setup(t){let x=a([]),S=a(!0),C=a(!1),w=a(null),T=a({key:``,name:``,description:``,sampleLine:``,promptTemplate:``,isEnabled:!0}),E=[{title:`Key`,dataIndex:`key`,key:`key`,width:110},{title:`名称`,dataIndex:`name`,key:`name`,width:100},{title:`描述`,dataIndex:`description`,key:`desc`,ellipsis:!0},{title:`版本`,dataIndex:`version`,key:`ver`,width:60},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(D);async function D(){S.value=!0;try{x.value=await y.personas()}finally{S.value=!1}}function O(){w.value=null,T.value={key:``,name:``,description:``,sampleLine:``,promptTemplate:``,isEnabled:!0},C.value=!0}function k(e){w.value=e,T.value={key:e.key,name:e.name,description:e.description,sampleLine:e.sampleLine,promptTemplate:e.promptTemplate,isEnabled:e.isEnabled},C.value=!0}async function A(){let e={...T.value,isEnabled:T.value.isEnabled};w.value?await y.updatePersona(w.value.id,e):await y.createPersona(e),c.success(w.value?`已更新(版本号+1)`:`已创建`),C.value=!1,D()}async function j(e){await y.deletePersona(e),c.success(`已删除`),D()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),y=e(`a-popconfirm`),D=e(`a-table`),M=e(`a-input`),N=e(`a-form-item`),P=e(`a-col`),F=e(`a-row`),I=e(`a-textarea`),L=e(`a-switch`),R=e(`a-form`),z=e(`a-modal`);return r(),u(d,null,[s(`div`,b,[i[8]||=s(`h2`,null,`AI 性格管理`,-1),o(a,{type:`primary`,onClick:O},{default:n(()=>[o(l(_)),i[7]||=m(` 新建性格`,-1)]),_:1})]),o(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`on`?(r(),p(c,{key:0,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:1},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(t)},{default:n(()=>[o(l(v))]),_:1},8,[`onClick`]),o(y,{title:`确定删除?`,onConfirm:e=>j(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(g))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(z,{open:C.value,"onUpdate:open":i[6]||=e=>C.value=e,title:w.value?`编辑性格`:`新建性格`,onOk:A,width:560},{default:n(()=>[o(R,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(F,{gutter:12},{default:n(()=>[o(P,{span:12},{default:n(()=>[o(N,{label:`Key`},{default:n(()=>[o(M,{value:T.value.key,"onUpdate:value":i[0]||=e=>T.value.key=e,placeholder:`sassy_cat`},null,8,[`value`])]),_:1})]),_:1}),o(P,{span:12},{default:n(()=>[o(N,{label:`名称`},{default:n(()=>[o(M,{value:T.value.name,"onUpdate:value":i[1]||=e=>T.value.name=e,placeholder:`毒舌猫娘`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(N,{label:`描述`},{default:n(()=>[o(M,{value:T.value.description,"onUpdate:value":i[2]||=e=>T.value.description=e,placeholder:`一句话描述`},null,8,[`value`])]),_:1}),o(N,{label:`示例台词`},{default:n(()=>[o(M,{value:T.value.sampleLine,"onUpdate:value":i[3]||=e=>T.value.sampleLine=e,placeholder:`这句会展示给用户选性格时看`},null,8,[`value`])]),_:1}),o(N,{label:`Prompt 模板`},{default:n(()=>[o(I,{value:T.value.promptTemplate,"onUpdate:value":i[4]||=e=>T.value.promptTemplate=e,rows:5,placeholder:`系统提示词模板,支持 {tic} 占位符,此字段可调不发版`},null,8,[`value`]),i[9]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},[m(` ⚡ 调这个不需要发版,前端实时生效。可用占位符:`),s(`code`,null,`{'{tic}'}`),m(` = 口癖(喵/汪/无) `)],-1)]),_:1}),o(N,{label:`是否启用`},{default:n(()=>[o(L,{checked:T.value.isEnabled,"onUpdate:checked":i[5]||=e=>T.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{x as default};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.page-header[data-v-3ceae462],.toolbar[data-v-3ceae462],.modal-actions[data-v-3ceae462],.estimate-row[data-v-3ceae462]{justify-content:space-between;align-items:center;gap:12px;display:flex}.page-header[data-v-3ceae462]{margin-bottom:16px}.page-header h2[data-v-3ceae462]{margin:0}.toolbar[data-v-3ceae462]{margin-bottom:12px}.subtle[data-v-3ceae462]{color:#8c8c8c;font-size:12px}.campaign-title[data-v-3ceae462]{margin-bottom:3px;font-weight:600}.campaign-body[data-v-3ceae462]{color:#595959;white-space:pre-wrap;margin-bottom:6px;font-size:12px}.action-label[data-v-3ceae462]{color:#8c8c8c;font-size:12px}.mono[data-v-3ceae462]{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.estimate-row[data-v-3ceae462]{justify-content:flex-start;min-height:32px;margin-bottom:16px}.modal-actions[data-v-3ceae462]{justify-content:flex-end;padding-top:4px}@media (width<=760px){.page-header[data-v-3ceae462]{flex-direction:column;align-items:flex-start}}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h,zn as g}from"./config-provider-q7ATIdCu.js";import{n as _,r as v,t as y}from"./EditOutlined-CeylGsUo.js";import{t as b}from"./api-C4vz6nB3.js";var x={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},S={key:1,style:{color:`#ccc`}},C=t({__name:`Stickers`,setup(t){let C=a([]),w=a(!0),T=a(!1),E=a(null),D=a({key:``,label:``,groupKey:`classic`,triggerTags:``,imageUrl:``,isEnabled:!0}),O=[{title:`Key`,dataIndex:`key`,key:`key`,width:110},{title:`名称`,dataIndex:`label`,key:`label`,width:100},{title:`分组`,dataIndex:`groupKey`,key:`group`,width:90},{title:`触发标签`,dataIndex:`triggerTags`,key:`tags`,width:200},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(k);async function k(){w.value=!0;try{C.value=await b.stickers()}finally{w.value=!1}}function A(){E.value=null,D.value={key:``,label:``,groupKey:`classic`,triggerTags:``,imageUrl:``,isEnabled:!0},T.value=!0}function j(e){E.value=e,D.value={key:e.key,label:e.label,groupKey:e.groupKey,triggerTags:e.triggerTags||``,imageUrl:e.imageUrl||``,isEnabled:e.isEnabled},T.value=!0}async function M(){let e={...D.value,triggerTags:D.value.triggerTags||null,imageUrl:D.value.imageUrl||null};E.value?await b.updateSticker(E.value.id,e):await b.createSticker(e),c.success(E.value?`已更新`:`已创建`),T.value=!1,k()}async function N(e){await b.deleteSticker(e),c.success(`已删除`),k()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),b=e(`a-popconfirm`),k=e(`a-table`),P=e(`a-input`),F=e(`a-form-item`),I=e(`a-col`),L=e(`a-row`),R=e(`a-select-option`),z=e(`a-select`),B=e(`a-switch`),V=e(`a-form`),H=e(`a-modal`);return r(),u(d,null,[s(`div`,x,[i[8]||=s(`h2`,null,`表情包库`,-1),o(a,{type:`primary`,onClick:A},{default:n(()=>[o(l(v)),i[7]||=m(` 新建表情包`,-1)]),_:1})]),o(k,{columns:O,dataSource:C.value,loading:w.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`tags`?(r(),u(d,{key:0},[t.triggerTags?(r(!0),u(d,{key:0},g((t.triggerTags||``).split(`,`).filter(Boolean),e=>(r(),p(c,{key:e,color:`blue`,style:{margin:`1px 2px`}},{default:n(()=>[m(f(e),1)]),_:2},1024))),128)):(r(),u(`span`,S,`-`))],64)):h(``,!0),e.key===`on`?(r(),p(c,{key:1,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:2},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>j(t)},{default:n(()=>[o(l(y))]),_:1},8,[`onClick`]),o(b,{title:`确定删除?`,onConfirm:e=>N(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(_))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(H,{open:T.value,"onUpdate:open":i[6]||=e=>T.value=e,title:E.value?`编辑表情包`:`新建表情包`,onOk:M,width:520},{default:n(()=>[o(V,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(L,{gutter:12},{default:n(()=>[o(I,{span:12},{default:n(()=>[o(F,{label:`Key`},{default:n(()=>[o(P,{value:D.value.key,"onUpdate:value":i[0]||=e=>D.value.key=e,placeholder:`salary`},null,8,[`value`])]),_:1})]),_:1}),o(I,{span:12},{default:n(()=>[o(F,{label:`名称`},{default:n(()=>[o(P,{value:D.value.label,"onUpdate:value":i[1]||=e=>D.value.label=e,placeholder:`发工资啦`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(L,{gutter:12},{default:n(()=>[o(I,{span:12},{default:n(()=>[o(F,{label:`分组`},{default:n(()=>[o(z,{value:D.value.groupKey,"onUpdate:value":i[2]||=e=>D.value.groupKey=e},{default:n(()=>[o(R,{value:`ai_exclusive`},{default:n(()=>[...i[9]||=[m(`🤖 AI 专属`,-1)]]),_:1}),o(R,{value:`classic`},{default:n(()=>[...i[10]||=[m(`📦 经典`,-1)]]),_:1})]),_:1},8,[`value`])]),_:1})]),_:1}),o(I,{span:12},{default:n(()=>[o(F,{label:`图片 URL`},{default:n(()=>[o(P,{value:D.value.imageUrl,"onUpdate:value":i[3]||=e=>D.value.imageUrl=e,placeholder:`可选 CDN 地址`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(F,{label:`触发标签`},{default:n(()=>[o(P,{value:D.value.triggerTags,"onUpdate:value":i[4]||=e=>D.value.triggerTags=e,placeholder:`over_budget,salary,forgive,逗号分隔`},null,8,[`value`]),i[11]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`标签匹配用户场景,AI 自动选择对应表情包`,-1)]),_:1}),o(F,{label:`是否启用`},{default:n(()=>[o(B,{checked:D.value.isEnabled,"onUpdate:checked":i[5]||=e=>D.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{C as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h,zn as g}from"./config-provider-q7ATIdCu.js";import{n as _,r as v,t as y}from"./EditOutlined-CeylGsUo.js";import{t as b}from"./api-BV_Zb8mM.js";var x={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},S={key:1,style:{color:`#ccc`}},C=t({__name:`Stickers`,setup(t){let C=a([]),w=a(!0),T=a(!1),E=a(null),D=a({key:``,label:``,groupKey:`classic`,triggerTags:``,imageUrl:``,isEnabled:!0}),O=[{title:`Key`,dataIndex:`key`,key:`key`,width:110},{title:`名称`,dataIndex:`label`,key:`label`,width:100},{title:`分组`,dataIndex:`groupKey`,key:`group`,width:90},{title:`触发标签`,dataIndex:`triggerTags`,key:`tags`,width:200},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(k);async function k(){w.value=!0;try{C.value=await b.stickers()}finally{w.value=!1}}function A(){E.value=null,D.value={key:``,label:``,groupKey:`classic`,triggerTags:``,imageUrl:``,isEnabled:!0},T.value=!0}function j(e){E.value=e,D.value={key:e.key,label:e.label,groupKey:e.groupKey,triggerTags:e.triggerTags||``,imageUrl:e.imageUrl||``,isEnabled:e.isEnabled},T.value=!0}async function M(){let e={...D.value,triggerTags:D.value.triggerTags||null,imageUrl:D.value.imageUrl||null};E.value?await b.updateSticker(E.value.id,e):await b.createSticker(e),c.success(E.value?`已更新`:`已创建`),T.value=!1,k()}async function N(e){await b.deleteSticker(e),c.success(`已删除`),k()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),b=e(`a-popconfirm`),k=e(`a-table`),P=e(`a-input`),F=e(`a-form-item`),I=e(`a-col`),L=e(`a-row`),R=e(`a-select-option`),z=e(`a-select`),B=e(`a-switch`),V=e(`a-form`),H=e(`a-modal`);return r(),u(d,null,[s(`div`,x,[i[8]||=s(`h2`,null,`表情包库`,-1),o(a,{type:`primary`,onClick:A},{default:n(()=>[o(l(v)),i[7]||=m(` 新建表情包`,-1)]),_:1})]),o(k,{columns:O,dataSource:C.value,loading:w.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`tags`?(r(),u(d,{key:0},[t.triggerTags?(r(!0),u(d,{key:0},g((t.triggerTags||``).split(`,`).filter(Boolean),e=>(r(),p(c,{key:e,color:`blue`,style:{margin:`1px 2px`}},{default:n(()=>[m(f(e),1)]),_:2},1024))),128)):(r(),u(`span`,S,`-`))],64)):h(``,!0),e.key===`on`?(r(),p(c,{key:1,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:2},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>j(t)},{default:n(()=>[o(l(y))]),_:1},8,[`onClick`]),o(b,{title:`确定删除?`,onConfirm:e=>N(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(_))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(H,{open:T.value,"onUpdate:open":i[6]||=e=>T.value=e,title:E.value?`编辑表情包`:`新建表情包`,onOk:M,width:520},{default:n(()=>[o(V,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(L,{gutter:12},{default:n(()=>[o(I,{span:12},{default:n(()=>[o(F,{label:`Key`},{default:n(()=>[o(P,{value:D.value.key,"onUpdate:value":i[0]||=e=>D.value.key=e,placeholder:`salary`},null,8,[`value`])]),_:1})]),_:1}),o(I,{span:12},{default:n(()=>[o(F,{label:`名称`},{default:n(()=>[o(P,{value:D.value.label,"onUpdate:value":i[1]||=e=>D.value.label=e,placeholder:`发工资啦`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(L,{gutter:12},{default:n(()=>[o(I,{span:12},{default:n(()=>[o(F,{label:`分组`},{default:n(()=>[o(z,{value:D.value.groupKey,"onUpdate:value":i[2]||=e=>D.value.groupKey=e},{default:n(()=>[o(R,{value:`ai_exclusive`},{default:n(()=>[...i[9]||=[m(`🤖 AI 专属`,-1)]]),_:1}),o(R,{value:`classic`},{default:n(()=>[...i[10]||=[m(`📦 经典`,-1)]]),_:1})]),_:1},8,[`value`])]),_:1})]),_:1}),o(I,{span:12},{default:n(()=>[o(F,{label:`图片 URL`},{default:n(()=>[o(P,{value:D.value.imageUrl,"onUpdate:value":i[3]||=e=>D.value.imageUrl=e,placeholder:`可选 CDN 地址`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(F,{label:`触发标签`},{default:n(()=>[o(P,{value:D.value.triggerTags,"onUpdate:value":i[4]||=e=>D.value.triggerTags=e,placeholder:`over_budget,salary,forgive,逗号分隔`},null,8,[`value`]),i[11]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`标签匹配用户场景,AI 自动选择对应表情包`,-1)]),_:1}),o(F,{label:`是否启用`},{default:n(()=>[o(B,{checked:D.value.isEnabled,"onUpdate:checked":i[5]||=e=>D.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{C as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h,zn as g}from"./config-provider-q7ATIdCu.js";import{n as _,t as v}from"./EditOutlined-h6ScL3Qz.js";import{t as y}from"./DeleteOutlined-yVoeJ3Fd.js";import{t as b}from"./api-wmB-hCXT.js";var x={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},S={key:1,style:{color:`#ccc`}},C=t({__name:`Stickers`,setup(t){let C=a([]),w=a(!0),T=a(!1),E=a(null),D=a({key:``,label:``,groupKey:`classic`,triggerTags:``,imageUrl:``,isEnabled:!0}),O=[{title:`Key`,dataIndex:`key`,key:`key`,width:110},{title:`名称`,dataIndex:`label`,key:`label`,width:100},{title:`分组`,dataIndex:`groupKey`,key:`group`,width:90},{title:`触发标签`,dataIndex:`triggerTags`,key:`tags`,width:200},{title:`状态`,dataIndex:`isEnabled`,key:`on`,width:70},{title:``,key:`act`,width:150}];i(k);async function k(){w.value=!0;try{C.value=await b.stickers()}finally{w.value=!1}}function A(){E.value=null,D.value={key:``,label:``,groupKey:`classic`,triggerTags:``,imageUrl:``,isEnabled:!0},T.value=!0}function j(e){E.value=e,D.value={key:e.key,label:e.label,groupKey:e.groupKey,triggerTags:e.triggerTags||``,imageUrl:e.imageUrl||``,isEnabled:e.isEnabled},T.value=!0}async function M(){let e={...D.value,triggerTags:D.value.triggerTags||null,imageUrl:D.value.imageUrl||null};E.value?await b.updateSticker(E.value.id,e):await b.createSticker(e),c.success(E.value?`已更新`:`已创建`),T.value=!1,k()}async function N(e){await b.deleteSticker(e),c.success(`已删除`),k()}return(t,i)=>{let a=e(`a-button`),c=e(`a-tag`),b=e(`a-popconfirm`),k=e(`a-table`),P=e(`a-input`),F=e(`a-form-item`),I=e(`a-col`),L=e(`a-row`),R=e(`a-select-option`),z=e(`a-select`),B=e(`a-switch`),V=e(`a-form`),H=e(`a-modal`);return r(),u(d,null,[s(`div`,x,[i[8]||=s(`h2`,null,`表情包库`,-1),o(a,{type:`primary`,onClick:A},{default:n(()=>[o(l(_)),i[7]||=m(` 新建表情包`,-1)]),_:1})]),o(k,{columns:O,dataSource:C.value,loading:w.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:n(({column:e,record:t})=>[e.key===`tags`?(r(),u(d,{key:0},[t.triggerTags?(r(!0),u(d,{key:0},g((t.triggerTags||``).split(`,`).filter(Boolean),e=>(r(),p(c,{key:e,color:`blue`,style:{margin:`1px 2px`}},{default:n(()=>[m(f(e),1)]),_:2},1024))),128)):(r(),u(`span`,S,`-`))],64)):h(``,!0),e.key===`on`?(r(),p(c,{key:1,color:t.isEnabled?`green`:`default`},{default:n(()=>[m(f(t.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`act`?(r(),u(d,{key:2},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>j(t)},{default:n(()=>[o(l(v))]),_:1},8,[`onClick`]),o(b,{title:`确定删除?`,onConfirm:e=>N(t.id)},{default:n(()=>[o(a,{size:`small`,danger:``},{default:n(()=>[o(l(y))]),_:1})]),_:1},8,[`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`]),o(H,{open:T.value,"onUpdate:open":i[6]||=e=>T.value=e,title:E.value?`编辑表情包`:`新建表情包`,onOk:M,width:520},{default:n(()=>[o(V,{layout:`vertical`,style:{"margin-top":`8px`}},{default:n(()=>[o(L,{gutter:12},{default:n(()=>[o(I,{span:12},{default:n(()=>[o(F,{label:`Key`},{default:n(()=>[o(P,{value:D.value.key,"onUpdate:value":i[0]||=e=>D.value.key=e,placeholder:`salary`},null,8,[`value`])]),_:1})]),_:1}),o(I,{span:12},{default:n(()=>[o(F,{label:`名称`},{default:n(()=>[o(P,{value:D.value.label,"onUpdate:value":i[1]||=e=>D.value.label=e,placeholder:`发工资啦`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(L,{gutter:12},{default:n(()=>[o(I,{span:12},{default:n(()=>[o(F,{label:`分组`},{default:n(()=>[o(z,{value:D.value.groupKey,"onUpdate:value":i[2]||=e=>D.value.groupKey=e},{default:n(()=>[o(R,{value:`ai_exclusive`},{default:n(()=>[...i[9]||=[m(`🤖 AI 专属`,-1)]]),_:1}),o(R,{value:`classic`},{default:n(()=>[...i[10]||=[m(`📦 经典`,-1)]]),_:1})]),_:1},8,[`value`])]),_:1})]),_:1}),o(I,{span:12},{default:n(()=>[o(F,{label:`图片 URL`},{default:n(()=>[o(P,{value:D.value.imageUrl,"onUpdate:value":i[3]||=e=>D.value.imageUrl=e,placeholder:`可选 CDN 地址`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),o(F,{label:`触发标签`},{default:n(()=>[o(P,{value:D.value.triggerTags,"onUpdate:value":i[4]||=e=>D.value.triggerTags=e,placeholder:`over_budget,salary,forgive,逗号分隔`},null,8,[`value`]),i[11]||=s(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`标签匹配用户场景,AI 自动选择对应表情包`,-1)]),_:1}),o(F,{label:`是否启用`},{default:n(()=>[o(B,{checked:D.value.isEnabled,"onUpdate:checked":i[5]||=e=>D.value.isEnabled=e},null,8,[`checked`])]),_:1})]),_:1})]),_:1},8,[`open`,`title`])],64)}}});export{C as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{t as g}from"./ReloadOutlined-CVrW_3-b.js";import{t as _}from"./api-C4vz6nB3.js";var v={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},y={style:{"margin-bottom":`14px`,display:`flex`,gap:`8px`}},b={key:0},x={key:1,style:{color:`#ccc`}},S={style:{color:`#999`}},C=15,w=t({__name:`Users`,setup(t){let w=a([]),T=a(0),E=a(!0),D=a(``),O=a(1),k=[{title:`ID`,dataIndex:`id`,key:`id`,width:60},{title:`用户名`,dataIndex:`username`,key:`un`,width:120},{title:`模式`,dataIndex:`appMode`,key:`mode`,width:80},{title:`AI 伙伴`,key:`comp`,width:150},{title:`账单(总/AI)`,key:`tx`},{title:`封禁`,dataIndex:`isBanned`,key:`ban`,width:70},{title:`注册时间`,dataIndex:`createdAt`,key:`reg`,width:110},{title:`最后登录`,dataIndex:`lastLoginAt`,key:`login`,width:110},{title:``,key:`act`,width:200}],A=a(!1),j=a(``),M=a(null);i(N);async function N(){E.value=!0;try{let e=await _.users({search:D.value||void 0,page:O.value,limit:C});w.value=e.list,T.value=e.total}finally{E.value=!1}}async function P(){O.value=1,N()}async function F(e){let t=await _.toggleBan(e.id);c.success(t.isBanned?`已封禁 ${e.username}`:`已解封 ${e.username}`),N()}async function I(e){j.value=e.nickname||e.username,A.value=!0,M.value=await _.userStats(e.id)}return(t,i)=>{let a=e(`a-button`),c=e(`a-input-search`),_=e(`a-tag`),L=e(`a-popconfirm`),R=e(`a-table`),z=e(`a-statistic`),B=e(`a-card`),V=e(`a-col`),H=e(`a-row`),U=e(`a-modal`);return r(),u(d,null,[s(`div`,v,[i[3]||=s(`h2`,null,`用户管理`,-1),o(a,{onClick:N},{default:n(()=>[o(l(g)),i[2]||=m(` 刷新`,-1)]),_:1})]),s(`div`,y,[o(c,{value:D.value,"onUpdate:value":i[0]||=e=>D.value=e,placeholder:`搜索用户名...`,style:{"max-width":`280px`},onSearch:P},null,8,[`value`])]),o(R,{columns:k,dataSource:w.value,loading:E.value,rowKey:`id`,size:`small`,pagination:{current:O.value,total:T.value,pageSize:C,showTotal:e=>`共 ${e} 人`,onChange:e=>{O.value=e,N()}}},{bodyCell:n(({column:e,record:t})=>[e.key===`mode`?(r(),p(_,{key:0,color:t.appMode===`ai`?`blue`:`green`},{default:n(()=>[m(f(t.appMode===`ai`?`全AI`:`普通`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`comp`?(r(),u(d,{key:1},[t.companion?(r(),u(`span`,b,`形象:`+f(t.companion.avatarKey)+` · 性格:`+f(t.companion.personaKey),1)):(r(),u(`span`,x,`未设置`))],64)):h(``,!0),e.key===`tx`?(r(),u(d,{key:2},[m(f(t.txCount)+` `,1),s(`span`,S,`(AI:`+f(t.aiTxCount)+`)`,1)],64)):h(``,!0),e.key===`ban`?(r(),p(_,{key:3,color:t.isBanned?`red`:`default`},{default:n(()=>[m(f(t.isBanned?`已封`:`正常`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`reg`?(r(),u(d,{key:4},[m(f(t.createdAt?.split(`T`)[0]),1)],64)):h(``,!0),e.key===`login`?(r(),u(d,{key:5},[m(f(t.lastLoginAt?t.lastLoginAt.split(`T`)[0]:`从未`),1)],64)):h(``,!0),e.key===`act`?(r(),u(d,{key:6},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>I(t)},{default:n(()=>[...i[4]||=[m(`📊 统计`,-1)]]),_:1},8,[`onClick`]),o(L,{title:t.isBanned?`确定解封?`:`确定封禁?`,onConfirm:e=>F(t)},{default:n(()=>[o(a,{size:`small`,danger:!t.isBanned},{default:n(()=>[m(f(t.isBanned?`解封`:`封禁`),1)]),_:2},1032,[`danger`])]),_:2},1032,[`title`,`onConfirm`])],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`,`pagination`]),o(U,{open:A.value,"onUpdate:open":i[1]||=e=>A.value=e,title:`${j.value} 使用统计`,footer:null,width:420},{default:n(()=>[M.value?(r(),p(H,{key:0,gutter:12},{default:n(()=>[o(V,{span:8},{default:n(()=>[o(B,{size:`small`},{default:n(()=>[o(z,{title:`总账单`,value:M.value.totalTransactions},null,8,[`value`])]),_:1})]),_:1}),o(V,{span:8},{default:n(()=>[o(B,{size:`small`},{default:n(()=>[o(z,{title:`AI 记账`,value:M.value.aiBooked},null,8,[`value`])]),_:1})]),_:1}),o(V,{span:8},{default:n(()=>[o(B,{size:`small`},{default:n(()=>[o(z,{title:`AI 准确率`,value:M.value.aiAccuracy,suffix:`%`,"value-style":{color:M.value.aiAccuracy>=70?`#00B386`:`#F0642D`}},null,8,[`value`,`value-style`])]),_:1})]),_:1})]),_:1})):h(``,!0)]),_:1},8,[`open`,`title`])],64)}}});export{w as default};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{Bn as e,Cn as t,Kn as n,Ln as r,Pn as i,Qn as a,Sn as o,_n as s,a as c,ar as l,bn as u,fn as d,or as f,vn as p,xn as m,yn as h}from"./config-provider-q7ATIdCu.js";import{t as g}from"./ReloadOutlined-CVrW_3-b.js";import{t as _}from"./api-BV_Zb8mM.js";var v={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},y={style:{"margin-bottom":`14px`,display:`flex`,gap:`8px`}},b={key:0},x={key:1,style:{color:`#ccc`}},S={style:{color:`#999`}},C={key:2,style:{"font-size":`10px`,color:`#999`,"margin-top":`3px`}},w=15,T=t({__name:`Users`,setup(t){let T=a([]),E=a(0),D=a(!0),O=a(``),k=a(1),A=[{title:`ID`,dataIndex:`id`,key:`id`,width:60},{title:`用户名`,dataIndex:`username`,key:`un`,width:120},{title:`模式`,dataIndex:`appMode`,key:`mode`,width:80},{title:`AI 伙伴`,key:`comp`,width:150},{title:`账单(总/AI)`,key:`tx`},{title:`状态`,key:`status`,width:100},{title:`注册时间`,dataIndex:`createdAt`,key:`reg`,width:110},{title:`最后登录`,dataIndex:`lastLoginAt`,key:`login`,width:110},{title:``,key:`act`,width:200}],j=a(!1),M=a(``),N=a(null);i(P);async function P(){D.value=!0;try{let e=await _.users({search:O.value||void 0,page:k.value,limit:w});T.value=e.list,E.value=e.total}finally{D.value=!1}}async function F(){k.value=1,P()}async function I(e){let t=await _.toggleBan(e.id);c.success(t.isBanned?`已封禁 ${e.username}`:`已解封 ${e.username}`),P()}async function L(e){await _.cancelAccountClosure(e.id),c.success(`已取消 `+e.username+` 的注销流程`),P()}async function R(e){M.value=e.nickname||e.username,j.value=!0,N.value=await _.userStats(e.id)}return(t,i)=>{let a=e(`a-button`),c=e(`a-input-search`),_=e(`a-tag`),z=e(`a-popconfirm`),B=e(`a-table`),V=e(`a-statistic`),H=e(`a-card`),U=e(`a-col`),W=e(`a-row`),G=e(`a-modal`);return r(),u(d,null,[s(`div`,v,[i[3]||=s(`h2`,null,`用户管理`,-1),o(a,{onClick:P},{default:n(()=>[o(l(g)),i[2]||=m(` 刷新`,-1)]),_:1})]),s(`div`,y,[o(c,{value:O.value,"onUpdate:value":i[0]||=e=>O.value=e,placeholder:`搜索用户名...`,style:{"max-width":`280px`},onSearch:F},null,8,[`value`])]),o(B,{columns:A,dataSource:T.value,loading:D.value,rowKey:`id`,size:`small`,pagination:{current:k.value,total:E.value,pageSize:w,showTotal:e=>`共 ${e} 人`,onChange:e=>{k.value=e,P()}}},{bodyCell:n(({column:e,record:t})=>[e.key===`mode`?(r(),p(_,{key:0,color:t.appMode===`ai`?`blue`:`green`},{default:n(()=>[m(f(t.appMode===`ai`?`全AI`:`普通`),1)]),_:2},1032,[`color`])):h(``,!0),e.key===`comp`?(r(),u(d,{key:1},[t.companion?(r(),u(`span`,b,`形象:`+f(t.companion.avatarKey)+` · 性格:`+f(t.companion.personaKey),1)):(r(),u(`span`,x,`未设置`))],64)):h(``,!0),e.key===`tx`?(r(),u(d,{key:2},[m(f(t.txCount)+` `,1),s(`span`,S,`(AI:`+f(t.aiTxCount)+`)`,1)],64)):h(``,!0),e.key===`status`?(r(),u(d,{key:3},[t.accountClosureScheduledAt?(r(),p(_,{key:0,color:`orange`},{default:n(()=>[...i[4]||=[m(`注销中`,-1)]]),_:1})):(r(),p(_,{key:1,color:t.isBanned?`red`:`default`},{default:n(()=>[m(f(t.isBanned?`已封`:`正常`),1)]),_:2},1032,[`color`])),t.accountClosureScheduledAt?(r(),u(`div`,C,f(t.accountClosureScheduledAt.split(`T`)[0])+` 删除 `,1)):h(``,!0)],64)):h(``,!0),e.key===`reg`?(r(),u(d,{key:4},[m(f(t.createdAt?.split(`T`)[0]),1)],64)):h(``,!0),e.key===`login`?(r(),u(d,{key:5},[m(f(t.lastLoginAt?t.lastLoginAt.split(`T`)[0]:`从未`),1)],64)):h(``,!0),e.key===`act`?(r(),u(d,{key:6},[o(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>R(t)},{default:n(()=>[...i[5]||=[m(`📊 统计`,-1)]]),_:1},8,[`onClick`]),t.accountClosureScheduledAt?(r(),p(z,{key:0,title:`确定取消该用户的注销流程?`,onConfirm:e=>L(t)},{default:n(()=>[o(a,{size:`small`,type:`primary`},{default:n(()=>[...i[6]||=[m(`取消注销`,-1)]]),_:1})]),_:1},8,[`onConfirm`])):(r(),p(z,{key:1,title:t.isBanned?`确定解封?`:`确定封禁?`,onConfirm:e=>I(t)},{default:n(()=>[o(a,{size:`small`,danger:!t.isBanned},{default:n(()=>[m(f(t.isBanned?`解封`:`封禁`),1)]),_:2},1032,[`danger`])]),_:2},1032,[`title`,`onConfirm`]))],64)):h(``,!0)]),_:1},8,[`dataSource`,`loading`,`pagination`]),o(G,{open:j.value,"onUpdate:open":i[1]||=e=>j.value=e,title:`${M.value} 使用统计`,footer:null,width:420},{default:n(()=>[N.value?(r(),p(W,{key:0,gutter:12},{default:n(()=>[o(U,{span:8},{default:n(()=>[o(H,{size:`small`},{default:n(()=>[o(V,{title:`总账单`,value:N.value.totalTransactions},null,8,[`value`])]),_:1})]),_:1}),o(U,{span:8},{default:n(()=>[o(H,{size:`small`},{default:n(()=>[o(V,{title:`AI 记账`,value:N.value.aiBooked},null,8,[`value`])]),_:1})]),_:1}),o(U,{span:8},{default:n(()=>[o(H,{size:`small`},{default:n(()=>[o(V,{title:`AI 准确率`,value:N.value.aiAccuracy,suffix:`%`,"value-style":{color:N.value.aiAccuracy>=70?`#00B386`:`#F0642D`}},null,8,[`value`,`value-style`])]),_:1})]),_:1})]),_:1})):h(``,!0)]),_:1},8,[`open`,`title`])],64)}}});export{T as default};
|
||||
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
var e=new Intl.DateTimeFormat(`zh-CN`,{timeZone:`Asia/Shanghai`,year:`numeric`,month:`2-digit`,day:`2-digit`});function t(t){if(!t)return``;let n=/(?:Z|[+-]\d{2}:?\d{2})$/i.test(t)?t:t+`Z`,r=new Date(n);return Number.isNaN(r.getTime())?``:e.format(r).replaceAll(`/`,`-`)}export{t};
|
||||
@@ -5,9 +5,11 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>admin-web</title>
|
||||
<script type="module" crossorigin src="/assets/index-BSN4-dIH.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BK5aVReu.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/dayjs.min-CeCVojfG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/config-provider-q7ATIdCu.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/EditOutlined-CeylGsUo.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/EditOutlined-h6ScL3Qz.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/DeleteOutlined-yVoeJ3Fd.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ReloadOutlined-CVrW_3-b.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/TeamOutlined-0klbs6LP.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B6VCboLO.css">
|
||||
|
||||
Reference in New Issue
Block a user