Add transfer tracking and secure admin access

This commit is contained in:
2026-07-26 11:57:57 +08:00
parent 0738953e6d
commit 7df25edd96
111 changed files with 6379 additions and 1934 deletions
@@ -0,0 +1,7 @@
namespace MiaoJiZhang.Api.Contracts;
public record AdminLoginRequest(string Username, string Password);
public record AdminChangePasswordRequest(string CurrentPassword, string NewPassword);
public record CreateAdminUserRequest(string Username, string Password, string Role);
public record UpdateAdminUserRequest(string Role, bool IsActive, bool MustChangePassword);
public record ResetAdminPasswordRequest(string Password);
@@ -21,7 +21,14 @@ public record CreateTransactionRequest(
DateTime? OccurredAt, // null = 现在
string? Source, // manual | voice | ocr
string? SourceText,
string? ClientRequestId = null);
string? ClientRequestId = null,
string? TransferDirection = null,
string? Counterparty = null,
string? Provider = null,
string? ProviderTransactionId = null,
string? RecognitionOccurrenceId = null,
string? EvidenceFingerprint = null,
string? RecognitionConfidence = null);
public record UpdateTransactionRequest(
long LedgerId,
@@ -31,14 +38,23 @@ public record UpdateTransactionRequest(
string? Note,
string? PaymentMethod,
DateTime OccurredAt,
DateTime? BaseUpdatedAt = null);
DateTime? BaseUpdatedAt = null,
string? TransferDirection = null,
string? Counterparty = null);
public record TransactionDto(
long Id, long LedgerId, long CategoryId, string CategoryName, string CategoryIcon,
string Type, decimal Amount, string? Note, string? PaymentMethod,
DateTime OccurredAt, string Source, string? SourceText,
bool IsDeleted = false, string CategoryColor = "mint",
DateTime? UpdatedAt = null);
DateTime? UpdatedAt = null,
string? TransferDirection = null,
string? Counterparty = null,
string? Provider = null,
string? ProviderTransactionId = null,
string? RecognitionOccurrenceId = null,
string? EvidenceFingerprint = null,
string? RecognitionConfidence = null);
public record DailyGroupDto(DateOnly Date, decimal Expense, decimal Income, List<TransactionDto> Items);
@@ -128,15 +144,18 @@ public record PeriodReportDto(
public record OcrParseRequest(string Text, string? Source = null);
public record OcrParseResponse(
bool Matched, long CategoryId, string CategoryName, string CategoryIcon,
decimal Amount, string? PaymentMethod, string Note, string Type);
decimal Amount, string? PaymentMethod, string Note, string Type,
string? TransferDirection = null, string? Counterparty = null);
public record ImageParseItemResponse(
bool Matched, long CategoryId, string CategoryName, string CategoryIcon,
string Type, decimal Amount, string? PaymentMethod, string Note,
DateTime? OccurredAt);
DateTime? OccurredAt,
string? TransferDirection = null, string? Counterparty = null);
public record ImageParseResponse(
bool Matched, long CategoryId, string CategoryName, string CategoryIcon,
decimal Amount, string? PaymentMethod, string Note, string Type,
List<ImageParseItemResponse> Items, DateTime? OccurredAt);
List<ImageParseItemResponse> Items, DateTime? OccurredAt,
string? TransferDirection = null, string? Counterparty = null);
public record RecognitionBatchCandidateRequest(
string CandidateId,
@@ -152,7 +171,13 @@ public record RecognitionBatchCandidateRequest(
string? CategoryHint,
string Confidence,
string? SourceText,
List<string>? EvidenceIds = null);
List<string>? EvidenceIds = null,
string? TransferDirection = null,
string? Counterparty = null,
string? Provider = null,
string? ProviderTransactionId = null,
string? RecognitionOccurrenceId = null,
string? IdentityConfidence = null);
public record RecognitionBatchEvidenceRequest(
string EvidenceId,
@@ -180,7 +205,9 @@ public record RecognitionBatchActionResponse(
string? Note,
DateTime? OccurredAt,
double Confidence,
string Reason);
string Reason,
string? TransferDirection = null,
string? Counterparty = null);
public record RecognitionBatchResponse(
string BatchId,
@@ -196,7 +223,14 @@ public record RecognitionBatchTransactionItemRequest(
string? PaymentMethod,
DateTime OccurredAt,
string? Source,
string? SourceText);
string? SourceText,
string? TransferDirection = null,
string? Counterparty = null,
string? Provider = null,
string? ProviderTransactionId = null,
string? RecognitionOccurrenceId = null,
string? EvidenceFingerprint = null,
string? RecognitionConfidence = null);
public record CreateRecognitionBatchRequest(
string BatchId,
@@ -0,0 +1,145 @@
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(AdminRoles.SuperAdmin)]
[Route("api/admin/security")]
public sealed class AdminAccountsController(AppDbContext db) : ControllerBase
{
[HttpGet("accounts")]
public async Task<IActionResult> Accounts(CancellationToken ct)
{
var users = await db.AdminUsers.AsNoTracking()
.OrderBy(item => item.Username)
.Select(item => new
{
item.Id,
item.Username,
item.Role,
item.IsActive,
item.MustChangePassword,
item.LastLoginAt,
item.CreatedAt,
activeSessions = item.Sessions.Count(session =>
!session.RevokedAt.HasValue && session.ExpiresAt > DateTime.UtcNow),
})
.ToListAsync(ct);
return Ok(users);
}
[HttpPost("accounts")]
public async Task<IActionResult> Create(CreateAdminUserRequest request, CancellationToken ct)
{
var username = request.Username.Trim();
if (username.Length is < 3 or > 64 || request.Password.Length is < 12 or > 128 ||
!AdminRoles.All.Contains(request.Role))
return BadRequest(new ApiError("ADMIN_ACCOUNT_INVALID", "管理员账号、密码或角色无效"));
if (await db.AdminUsers.AnyAsync(item => item.Username == username, ct))
return Conflict(new ApiError("ADMIN_ACCOUNT_EXISTS", "管理员用户名已存在"));
var now = DateTime.UtcNow;
var user = new AdminUser
{
Username = username,
PasswordHash = AdminSessionService.HashPassword(request.Password),
Role = request.Role,
IsActive = true,
MustChangePassword = true,
CreatedAt = now,
UpdatedAt = now,
};
db.AdminUsers.Add(user);
await db.SaveChangesAsync(ct);
return Ok(new { user.Id, user.Username, user.Role, user.IsActive, user.MustChangePassword });
}
[HttpPut("accounts/{id:long}")]
public async Task<IActionResult> Update(
long id,
UpdateAdminUserRequest request,
CancellationToken ct)
{
if (!AdminRoles.All.Contains(request.Role))
return BadRequest(new ApiError("ADMIN_ROLE_INVALID", "管理员角色无效"));
var user = await db.AdminUsers.FindAsync([id], ct);
if (user is null) return NotFound();
if (user.Role == AdminRoles.SuperAdmin &&
(!request.IsActive || request.Role != AdminRoles.SuperAdmin) &&
await ActiveSuperAdminCount(ct) <= 1)
{
return Conflict(new ApiError("LAST_SUPER_ADMIN", "不能停用或降级最后一个超级管理员"));
}
user.Role = request.Role;
user.IsActive = request.IsActive;
user.MustChangePassword = request.MustChangePassword;
user.AuthVersion++;
user.UpdatedAt = DateTime.UtcNow;
await RevokeSessions(id, ct);
await db.SaveChangesAsync(ct);
return Ok(new { user.Id, user.Username, user.Role, user.IsActive, user.MustChangePassword });
}
[HttpPut("accounts/{id:long}/password")]
public async Task<IActionResult> ResetPassword(
long id,
ResetAdminPasswordRequest request,
CancellationToken ct)
{
if (request.Password.Length is < 12 or > 128)
return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "密码长度必须为 12 到 128 位"));
var user = await db.AdminUsers.FindAsync([id], ct);
if (user is null) return NotFound();
user.PasswordHash = AdminSessionService.HashPassword(request.Password);
user.MustChangePassword = true;
user.AuthVersion++;
user.UpdatedAt = DateTime.UtcNow;
await RevokeSessions(id, ct);
await db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("accounts/{id:long}/revoke-sessions")]
public async Task<IActionResult> Revoke(long id, CancellationToken ct)
{
if (!await db.AdminUsers.AnyAsync(item => item.Id == id, ct)) return NotFound();
await RevokeSessions(id, ct);
return NoContent();
}
[HttpGet("audit")]
public async Task<IActionResult> Audit(
[FromQuery] string? username,
[FromQuery] int page = 1,
[FromQuery] int limit = 50,
CancellationToken ct = default)
{
page = Math.Max(1, page);
limit = Math.Clamp(limit, 1, 100);
var query = db.AdminAuditLogs.AsNoTracking();
if (!string.IsNullOrWhiteSpace(username))
{
var term = username.Trim();
query = query.Where(item => item.Username != null && item.Username.Contains(term));
}
var total = await query.CountAsync(ct);
var list = await query.OrderByDescending(item => item.CreatedAt)
.Skip((page - 1) * limit).Take(limit).ToListAsync(ct);
return Ok(new { total, page, list });
}
private Task<int> ActiveSuperAdminCount(CancellationToken ct) => db.AdminUsers.CountAsync(
item => item.IsActive && item.Role == AdminRoles.SuperAdmin,
ct);
private Task<int> RevokeSessions(long userId, CancellationToken ct)
{
var now = DateTime.UtcNow;
return db.AdminSessions.Where(item => item.AdminUserId == userId && !item.RevokedAt.HasValue)
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.RevokedAt, now), ct);
}
}
@@ -0,0 +1,85 @@
using MiaoJiZhang.Api.Contracts;
using MiaoJiZhang.Api.Services;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Controllers;
[ApiController]
[Route("api/admin/auth")]
public sealed class AdminAuthController(
AppDbContext db,
AdminSessionService sessions) : ControllerBase
{
[HttpPost("login")]
[EnableRateLimiting("admin-auth")]
public async Task<IActionResult> Login(AdminLoginRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrEmpty(request.Password))
return BadRequest(new ApiError("ADMIN_LOGIN_INVALID", "请输入用户名和密码"));
var result = await sessions.LoginAsync(
HttpContext,
request.Username,
request.Password,
ct);
return result is null
? Unauthorized(new ApiError("ADMIN_LOGIN_FAILED", "用户名或密码错误,账号也可能已锁定"))
: Ok(ToResponse(result.Principal, result.CsrfToken));
}
[HttpGet("me")]
[AdminAuth]
public async Task<IActionResult> Me(CancellationToken ct)
{
var principal = AdminRequestContext.Principal(HttpContext)!;
var csrfToken = await sessions.RotateCsrfAsync(principal.SessionId, ct);
return Ok(ToResponse(principal, csrfToken));
}
[HttpPost("logout")]
[AdminAuth]
public async Task<IActionResult> Logout(CancellationToken ct)
{
var principal = AdminRequestContext.Principal(HttpContext)!;
await sessions.LogoutAsync(HttpContext, principal.SessionId, ct);
return NoContent();
}
[HttpPut("password")]
[AdminAuth]
public async Task<IActionResult> ChangePassword(
AdminChangePasswordRequest request,
CancellationToken ct)
{
if (request.NewPassword.Length < 12 || request.NewPassword.Length > 128)
return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "新密码长度必须为 12 到 128 位"));
var principal = AdminRequestContext.Principal(HttpContext)!;
var user = await db.AdminUsers.FirstAsync(item => item.Id == principal.UserId, ct);
if (!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, user.PasswordHash))
return BadRequest(new ApiError("ADMIN_PASSWORD_INCORRECT", "当前密码不正确"));
if (BCrypt.Net.BCrypt.Verify(request.NewPassword, user.PasswordHash))
return BadRequest(new ApiError("ADMIN_PASSWORD_UNCHANGED", "新密码不能与当前密码相同"));
user.PasswordHash = AdminSessionService.HashPassword(request.NewPassword);
user.MustChangePassword = false;
user.AuthVersion++;
user.UpdatedAt = DateTime.UtcNow;
await sessions.RevokeOtherSessionsAsync(user.Id, principal.SessionId, user.AuthVersion, ct);
await db.SaveChangesAsync(ct);
var csrfToken = await sessions.RotateCsrfAsync(principal.SessionId, ct);
var updated = principal with { MustChangePassword = false };
HttpContext.Items[AdminRequestContext.PrincipalKey] = updated;
return Ok(ToResponse(updated, csrfToken));
}
private static object ToResponse(AdminPrincipal principal, string csrfToken) => new
{
id = principal.UserId,
principal.Username,
principal.Role,
principal.MustChangePassword,
csrfToken,
};
}
@@ -45,7 +45,9 @@ public class BudgetsController(
var (start, end) = ChinaClock.MonthRangeUtc(year, month);
var spentByCat = await db.Transactions
.Where(t => t.UserId == Uid && t.LedgerId == targetLedgerId &&
t.Type == TransactionType.Expense && t.OccurredAt >= start && t.OccurredAt < end)
(t.Type == TransactionType.Expense ||
t.Type == TransactionType.Transfer && t.TransferDirection == TransferDirection.Out) &&
t.OccurredAt >= start && t.OccurredAt < end)
.GroupBy(t => t.CategoryId)
.Select(g => new { g.Key, Amount = g.Sum(t => t.Amount) })
.ToDictionaryAsync(x => x.Key, x => x.Amount);
@@ -152,7 +154,8 @@ public class BudgetsController(
var transactions = await db.Transactions
.Where(t => t.UserId == Uid && t.LedgerId == ledgerId &&
t.Type == TransactionType.Expense &&
(t.Type == TransactionType.Expense ||
t.Type == TransactionType.Transfer && t.TransferDirection == TransferDirection.Out) &&
t.OccurredAt >= historyStart && t.OccurredAt < currentEnd)
.Select(t => new { t.CategoryId, t.Amount, t.OccurredAt })
.ToListAsync();
@@ -358,17 +358,21 @@ public class ChatController(
if (transactions.Count == 1)
{
var transaction = transactions[0];
var type = transaction.Type == TransactionType.Income
? "收入"
: "支出";
var type = transaction.Type switch
{
TransactionType.Income => "收入",
TransactionType.Transfer when transaction.TransferDirection == TransferDirection.In => "转入",
TransactionType.Transfer => "转出",
_ => "支出",
};
return $"已记录{type}{transaction.Category.Name} ¥{transaction.Amount:F2}{tic}";
}
var income = transactions
.Where(t => t.Type == TransactionType.Income)
.Where(t => t.Type.IsIncome(t.TransferDirection))
.Sum(t => t.Amount);
var expense = transactions
.Where(t => t.Type == TransactionType.Expense)
.Where(t => t.Type.IsExpense(t.TransferDirection))
.Sum(t => t.Amount);
return $"已记录 {transactions.Count} 笔,其中收入 ¥{income:F2}、支出 ¥{expense:F2}{tic}";
}
@@ -530,4 +534,4 @@ public class ChatController(
transaction,
message.CreatedAt);
}
}
}
@@ -83,19 +83,22 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
"expense"));
}
var bill = drafts[0];
var category = await db.Categories.FirstAsync(
c => c.Id == bill.CategoryId && c.Type == bill.Type,
ct);
var bill = drafts[0];
var category = await db.Categories.FirstAsync(
c => c.Id == bill.CategoryId &&
c.Type == bill.Type.CategoryType(bill.TransferDirection),
ct);
return Ok(new OcrParseResponse(
true,
category.Id,
category.Name,
category.IconKey,
bill.Amount,
bill.PaymentMethod,
bill.Note,
bill.Type == TransactionType.Income ? "income" : "expense"));
bill.PaymentMethod,
bill.Note,
bill.Type.ToWire(),
bill.TransferDirection.ToWire(),
bill.Counterparty));
}
/// <summary>上传截屏或小票,提取其中全部独立交易。</summary>
@@ -171,18 +174,24 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
foreach (var result in results.Take(20))
{
var normalizedType = result.Type.Trim().ToLowerInvariant();
if (normalizedType is not ("income" or "expense"))
var transferDirection = result.TransferDirection is "in" or "out"
? result.TransferDirection
: null;
if (normalizedType is not ("income" or "expense" or "transfer") ||
normalizedType == "transfer" && transferDirection is null)
{
items.Add(new ImageParseItemResponse(
false, 0, "待确认", "tag", "unknown",
result.Amount, result.PaymentMethod, result.Note,
result.OccurredAt));
result.OccurredAt,
transferDirection,
result.Counterparty));
continue;
}
var type = normalizedType == "income"
? TransactionType.Income
: TransactionType.Expense;
var category = FindCategory(categories, type, result.CategoryName);
var categoryType = normalizedType == "income" || transferDirection == "in"
? TransactionType.Income
: TransactionType.Expense;
var category = FindCategory(categories, categoryType, result.CategoryName);
items.Add(new ImageParseItemResponse(
true,
category.Id,
@@ -192,7 +201,9 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
result.Amount,
result.PaymentMethod,
result.Note,
result.OccurredAt));
result.OccurredAt,
transferDirection,
result.Counterparty));
}
var first = items[0];
@@ -206,7 +217,9 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
first.Note,
first.Type,
items,
first.OccurredAt));
first.OccurredAt,
first.TransferDirection,
first.Counterparty));
}
[HttpPost("recognition-batch")]
@@ -307,7 +320,12 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
candidate.RecognitionKind,
candidate.CategoryHint,
candidate.Confidence,
candidate.EvidenceIds ?? [])).ToList();
candidate.EvidenceIds ?? [],
candidate.TransferDirection,
candidate.Counterparty,
candidate.ProviderTransactionId,
candidate.RecognitionOccurrenceId,
candidate.IdentityConfidence)).ToList();
IReadOnlyList<RecognitionBatchModelAction>? modelActions;
try
@@ -366,11 +384,29 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
action = "keep";
reason = "撤销证据不足,已保留本地结果";
}
var type = model?.Type is "income" or "expense" ? model.Type : candidate.Type;
var type = model?.Type is "income" or "expense" or "transfer"
? model.Type
: candidate.Type;
var transferDirection = type == "transfer"
? model?.TransferDirection is "in" or "out"
? model.TransferDirection
: candidate.TransferDirection is "in" or "out"
? candidate.TransferDirection
: null
: null;
if (type == "transfer" && transferDirection is null)
{
action = "keep";
type = candidate.Type;
transferDirection = candidate.TransferDirection;
reason = "转账方向不明确,已保留本地结果";
}
var amount = model?.Amount is > 0 ? model.Amount.Value : candidate.Amount;
var category = FindCategory(
categories,
type == "income" ? TransactionType.Income : TransactionType.Expense,
type == "income" || transferDirection == "in"
? TransactionType.Income
: TransactionType.Expense,
model?.CategoryName ?? candidate.CategoryHint ?? "其他");
result.Add(new RecognitionBatchActionResponse(
action,
@@ -385,7 +421,11 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
string.IsNullOrWhiteSpace(model?.Note) ? candidate.Merchant : model.Note,
model?.OccurredAt ?? candidate.OccurredAt,
confidence,
reason));
reason,
transferDirection,
string.IsNullOrWhiteSpace(model?.Counterparty)
? candidate.Counterparty
: model.Counterparty));
}
var usedEvidence = new HashSet<string>();
@@ -395,13 +435,16 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
!evidenceById.TryGetValue(model.EvidenceId, out var evidence) ||
!string.IsNullOrWhiteSpace(evidence.CandidateId) ||
!usedEvidence.Add(model.EvidenceId) ||
model.Type is not ("income" or "expense") ||
model.Type is not ("income" or "expense" or "transfer") ||
model.Type == "transfer" && model.TransferDirection is not ("in" or "out") ||
model.Amount is not > 0)
{
continue;
}
var type = model.Type == "income" ? TransactionType.Income : TransactionType.Expense;
var category = FindCategory(categories, type, model.CategoryName ?? "其他");
var categoryType = model.Type == "income" || model.TransferDirection == "in"
? TransactionType.Income
: TransactionType.Expense;
var category = FindCategory(categories, categoryType, model.CategoryName ?? "其他");
result.Add(new RecognitionBatchActionResponse(
"create",
model.ActionId,
@@ -415,7 +458,9 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
model.Note,
model.OccurredAt ?? evidence.CapturedAt,
model.Confidence,
model.Reason));
model.Reason,
model.TransferDirection,
model.Counterparty));
}
return result.Take(20).ToList();
}
@@ -129,10 +129,10 @@ public class ReportsController(
.ToListAsync();
var income = list
.Where(transaction => transaction.Type == TransactionType.Income)
.Where(transaction => transaction.Type.IsIncome(transaction.TransferDirection))
.Sum(transaction => transaction.Amount);
var expenses = list
.Where(transaction => transaction.Type == TransactionType.Expense)
.Where(transaction => transaction.Type.IsExpense(transaction.TransferDirection))
.ToList();
var expense = expenses.Sum(transaction => transaction.Amount);
var aiCount = list.Count(
@@ -241,8 +241,8 @@ public class SearchController(AppDbContext db, LedgerResolver ledgers) : Control
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
if (type is not null && type is not ("income" or "expense"))
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expenseincome"));
if (type is not null && type is not ("income" or "expense" or "transfer"))
return BadRequest(new ApiError("TYPE_INVALID", "账单类型必须是 expenseincome 或 transfer"));
var query = db.Transactions.Include(t => t.Category)
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value);
@@ -251,12 +251,14 @@ public class SearchController(AppDbContext db, LedgerResolver ledgers) : Control
var keyword = q.Trim();
query = query.Where(t =>
(t.Note != null && t.Note.Contains(keyword)) ||
(t.Counterparty != null && t.Counterparty.Contains(keyword)) ||
t.Category.Name.Contains(keyword) ||
(t.SourceText != null && t.SourceText.Contains(keyword)));
}
if (categoryId.HasValue) query = query.Where(t => t.CategoryId == categoryId.Value);
if (type == "income") query = query.Where(t => t.Type == TransactionType.Income);
if (type == "expense") query = query.Where(t => t.Type == TransactionType.Expense);
if (type == "transfer") query = query.Where(t => t.Type == TransactionType.Transfer);
if (minAmount.HasValue) query = query.Where(t => t.Amount >= minAmount.Value);
if (maxAmount.HasValue) query = query.Where(t => t.Amount <= maxAmount.Value);
if (from.HasValue) query = query.Where(t => t.OccurredAt >= NormalizeTime(from.Value));
@@ -281,4 +283,3 @@ public class SearchController(AppDbContext db, LedgerResolver ledgers) : Control
private static DateTime NormalizeTime(DateTime value) =>
value.Kind == DateTimeKind.Utc ? value : ChinaClock.ToUtc(value);
}
@@ -27,19 +27,31 @@ public class TransactionsController(
if (req.Amount <= 0) return BadRequest(new ApiError("AMOUNT_INVALID", "金额必须大于 0"));
var type = ParseType(req.Type);
if (!type.HasValue)
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expenseincome"));
return BadRequest(new ApiError("TYPE_INVALID", "账单类型必须是 expenseincome 或 transfer"));
var transferDirection = ParseTransferDirection(req.TransferDirection);
if (type == TransactionType.Transfer && !transferDirection.HasValue)
return BadRequest(new ApiError("TRANSFER_DIRECTION_REQUIRED", "转账必须选择转入或转出"));
if (type != TransactionType.Transfer && !string.IsNullOrWhiteSpace(req.TransferDirection))
return BadRequest(new ApiError("TRANSFER_DIRECTION_INVALID", "非转账账单不能设置转账方向"));
var categoryType = CategoryTypeFor(type.Value, transferDirection);
var clientRequestId = string.IsNullOrWhiteSpace(req.ClientRequestId)
? null
: req.ClientRequestId.Trim();
var provider = NormalizeOptional(req.Provider, 24);
var providerTransactionId = NormalizeOptional(req.ProviderTransactionId, 128);
var occurrenceId = NormalizeOptional(req.RecognitionOccurrenceId, 64);
if (clientRequestId?.Length > 64)
return BadRequest(new ApiError("CLIENT_REQUEST_ID_INVALID", "幂等标识最长 64 个字符"));
if (clientRequestId != null)
if (providerTransactionId is not null && provider is null)
return BadRequest(new ApiError("PROVIDER_REQUIRED", "服务商交易号必须同时提供服务商"));
if (clientRequestId is not null || providerTransactionId is not null || occurrenceId is not null)
{
var existing = await db.Transactions
.Include(t => t.Category)
.FirstOrDefaultAsync(t =>
t.UserId == Uid && t.ClientRequestId == clientRequestId);
var existing = await FindExistingTransactionAsync(
clientRequestId,
provider,
providerTransactionId,
occurrenceId);
if (existing is not null) return Ok(ToDto(existing, existing.Category));
}
@@ -48,7 +60,7 @@ public class TransactionsController(
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var cat = await db.Categories.FirstOrDefaultAsync(c =>
c.Id == req.CategoryId && !c.IsDeleted && c.Type == type.Value &&
c.Id == req.CategoryId && !c.IsDeleted && c.Type == categoryType &&
(c.UserId == null || c.UserId == Uid));
if (cat is null) return BadRequest(new ApiError("CATEGORY_TYPE_MISMATCH", "分类与收支类型不一致"));
@@ -64,9 +76,16 @@ public class TransactionsController(
Amount = req.Amount,
Note = req.Note,
PaymentMethod = req.PaymentMethod,
TransferDirection = transferDirection,
Counterparty = NormalizeOptional(req.Counterparty, 100),
Source = SourceFromWire(req.Source),
SourceText = req.SourceText,
ClientRequestId = clientRequestId,
Provider = provider,
ProviderTransactionId = providerTransactionId,
RecognitionOccurrenceId = occurrenceId,
EvidenceFingerprint = NormalizeOptional(req.EvidenceFingerprint, 64),
RecognitionConfidence = NormalizeOptional(req.RecognitionConfidence, 24),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
@@ -75,7 +94,7 @@ public class TransactionsController(
try
{
await db.SaveChangesAsync();
if (tx.Type == TransactionType.Expense)
if (IsExpense(tx))
{
await budgetPush.EvaluateAsync(Uid,
[new BudgetExpenseChange(tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount)]);
@@ -84,17 +103,19 @@ public class TransactionsController(
await writeScope.CommitAsync();
return Ok(ToDto(tx, cat));
}
catch (DbUpdateException) when (clientRequestId is not null)
catch (DbUpdateException) when (
clientRequestId is not null || providerTransactionId is not null || occurrenceId 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;
var existing = await db.Transactions
.AsNoTracking()
.Include(t => t.Category)
.FirstOrDefaultAsync(t =>
t.UserId == Uid && t.ClientRequestId == clientRequestId);
var existing = await FindExistingTransactionAsync(
clientRequestId,
provider,
providerTransactionId,
occurrenceId,
asNoTracking: true);
if (existing is not null) return Ok(ToDto(existing, existing.Category));
throw;
}
@@ -108,13 +129,26 @@ public class TransactionsController(
if (!Guid.TryParse(req.BatchId, out _) || req.Items.Count is < 1 or > 20)
return BadRequest(new ApiError("BATCH_INVALID", "批次标识或账单数量无效"));
if (req.Items.Select(item => item.CandidateId).Distinct().Count() != req.Items.Count ||
req.Items.Select(item => item.ClientRequestId).Distinct().Count() != req.Items.Count)
req.Items.Select(item => item.ClientRequestId).Distinct().Count() != req.Items.Count ||
req.Items.Where(item => !string.IsNullOrWhiteSpace(item.RecognitionOccurrenceId))
.Select(item => item.RecognitionOccurrenceId!.Trim()).Distinct().Count() !=
req.Items.Count(item => !string.IsNullOrWhiteSpace(item.RecognitionOccurrenceId)) ||
req.Items.Where(item => !string.IsNullOrWhiteSpace(item.ProviderTransactionId))
.Select(item => $"{item.Provider?.Trim()}\n{item.ProviderTransactionId!.Trim()}")
.Distinct().Count() !=
req.Items.Count(item => !string.IsNullOrWhiteSpace(item.ProviderTransactionId)))
{
return BadRequest(new ApiError("BATCH_DUPLICATED", "批次中存在重复账单标识"));
}
if (req.Items.Any(item => item.Amount <= 0 ||
item.ClientRequestId.Length is < 1 or > 64 ||
ParseType(item.Type) is null))
ParseType(item.Type) is null ||
(ParseType(item.Type) == TransactionType.Transfer &&
ParseTransferDirection(item.TransferDirection) is null) ||
(ParseType(item.Type) != TransactionType.Transfer &&
!string.IsNullOrWhiteSpace(item.TransferDirection)) ||
(!string.IsNullOrWhiteSpace(item.ProviderTransactionId) &&
string.IsNullOrWhiteSpace(item.Provider))))
{
return BadRequest(new ApiError("BATCH_ITEM_INVALID", "批次中存在无效账单"));
}
@@ -130,22 +164,27 @@ public class TransactionsController(
foreach (var item in req.Items)
{
var type = ParseType(item.Type)!.Value;
if (!categories.TryGetValue(item.CategoryId, out var category) || category.Type != type)
var categoryType = CategoryTypeFor(type, ParseTransferDirection(item.TransferDirection));
if (!categories.TryGetValue(item.CategoryId, out var category) || category.Type != categoryType)
return BadRequest(new ApiError("CATEGORY_TYPE_MISMATCH", "分类与收支类型不一致"));
}
var requestIds = req.Items.Select(item => item.ClientRequestId).ToList();
var existing = await db.Transactions
.Include(transaction => transaction.Category)
.Where(transaction => transaction.UserId == Uid &&
transaction.ClientRequestId != null &&
requestIds.Contains(transaction.ClientRequestId))
.ToDictionaryAsync(transaction => transaction.ClientRequestId!, ct);
var existing = new Dictionary<string, Transaction>();
foreach (var item in req.Items)
{
var found = await FindExistingTransactionAsync(
item.ClientRequestId.Trim(),
NormalizeOptional(item.Provider, 24),
NormalizeOptional(item.ProviderTransactionId, 128),
NormalizeOptional(item.RecognitionOccurrenceId, 64),
ct: ct);
if (found is not null) existing[item.CandidateId] = found;
}
await using var transactionScope = await db.Database.BeginTransactionAsync(ct);
var mapped = new List<(string CandidateId, Transaction Transaction)>();
foreach (var item in req.Items)
{
if (existing.TryGetValue(item.ClientRequestId, out var found))
if (existing.TryGetValue(item.CandidateId, out var found))
{
mapped.Add((item.CandidateId, found));
continue;
@@ -161,10 +200,17 @@ public class TransactionsController(
Amount = item.Amount,
Note = item.Note?.Trim(),
PaymentMethod = item.PaymentMethod?.Trim(),
TransferDirection = ParseTransferDirection(item.TransferDirection),
Counterparty = NormalizeOptional(item.Counterparty, 100),
OccurredAt = NormalizeOccurredAt(item.OccurredAt),
Source = SourceFromWire(item.Source),
SourceText = item.SourceText,
ClientRequestId = item.ClientRequestId,
Provider = NormalizeOptional(item.Provider, 24),
ProviderTransactionId = NormalizeOptional(item.ProviderTransactionId, 128),
RecognitionOccurrenceId = NormalizeOptional(item.RecognitionOccurrenceId, 64),
EvidenceFingerprint = NormalizeOptional(item.EvidenceFingerprint, 64),
RecognitionConfidence = NormalizeOptional(item.RecognitionConfidence, 24),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
@@ -175,8 +221,8 @@ public class TransactionsController(
{
await db.SaveChangesAsync(ct);
var expenseChanges = mapped
.Where(item => !existing.ContainsKey(item.Transaction.ClientRequestId ?? "") &&
item.Transaction.Type == TransactionType.Expense)
.Where(item => !existing.ContainsKey(item.CandidateId) &&
IsExpense(item.Transaction))
.Select(item => new BudgetExpenseChange(
item.Transaction.LedgerId,
item.Transaction.CategoryId,
@@ -198,17 +244,22 @@ public class TransactionsController(
{
entry.State = EntityState.Detached;
}
var raced = await db.Transactions
.AsNoTracking()
.Include(transaction => transaction.Category)
.Where(transaction => transaction.UserId == Uid &&
transaction.ClientRequestId != null &&
requestIds.Contains(transaction.ClientRequestId))
.ToDictionaryAsync(transaction => transaction.ClientRequestId!, ct);
if (raced.Count != requestIds.Count) throw;
return Ok(req.Items.Select(item => new RecognitionBatchTransactionDto(
item.CandidateId,
ToDto(raced[item.ClientRequestId], raced[item.ClientRequestId].Category))).ToList());
var raced = new List<RecognitionBatchTransactionDto>();
foreach (var item in req.Items)
{
var found = await FindExistingTransactionAsync(
item.ClientRequestId.Trim(),
NormalizeOptional(item.Provider, 24),
NormalizeOptional(item.ProviderTransactionId, 128),
NormalizeOptional(item.RecognitionOccurrenceId, 64),
asNoTracking: true,
ct: ct);
if (found is null) throw;
raced.Add(new RecognitionBatchTransactionDto(
item.CandidateId,
ToDto(found, found.Category)));
}
return Ok(raced);
}
}
@@ -228,12 +279,18 @@ public class TransactionsController(
if (req.Amount <= 0) return BadRequest(new ApiError("AMOUNT_INVALID", "金额必须大于 0"));
var type = ParseType(req.Type);
if (!type.HasValue)
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expenseincome"));
return BadRequest(new ApiError("TYPE_INVALID", "账单类型必须是 expenseincome 或 transfer"));
var transferDirection = ParseTransferDirection(req.TransferDirection);
if (type == TransactionType.Transfer && !transferDirection.HasValue)
return BadRequest(new ApiError("TRANSFER_DIRECTION_REQUIRED", "转账必须选择转入或转出"));
if (type != TransactionType.Transfer && !string.IsNullOrWhiteSpace(req.TransferDirection))
return BadRequest(new ApiError("TRANSFER_DIRECTION_INVALID", "非转账账单不能设置转账方向"));
var categoryType = CategoryTypeFor(type.Value, transferDirection);
var ledgerId = await ledgers.ResolveAsync(Uid, req.LedgerId);
if (!ledgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var category = await db.Categories.FirstOrDefaultAsync(c =>
c.Id == req.CategoryId && !c.IsDeleted && c.Type == type.Value &&
c.Id == req.CategoryId && !c.IsDeleted && c.Type == categoryType &&
(c.UserId == null || c.UserId == Uid));
if (category is null) return BadRequest(new ApiError("CATEGORY_TYPE_MISMATCH", "分类与收支类型不一致"));
var tx = await db.Transactions.FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid);
@@ -247,7 +304,7 @@ public class TransactionsController(
server = ToDto(tx, await db.Categories.FindAsync(tx.CategoryId) ?? category),
});
var expenseChanges = new List<BudgetExpenseChange>();
if (tx.Type == TransactionType.Expense)
if (IsExpense(tx))
expenseChanges.Add(new BudgetExpenseChange(
tx.LedgerId, tx.CategoryId, tx.OccurredAt, -tx.Amount));
tx.LedgerId = ledgerId.Value;
@@ -257,9 +314,11 @@ public class TransactionsController(
tx.Amount = req.Amount;
tx.Note = req.Note?.Trim();
tx.PaymentMethod = req.PaymentMethod?.Trim();
tx.TransferDirection = transferDirection;
tx.Counterparty = NormalizeOptional(req.Counterparty, 100);
tx.OccurredAt = NormalizeOccurredAt(req.OccurredAt);
tx.UpdatedAt = DateTime.UtcNow;
if (tx.Type == TransactionType.Expense)
if (IsExpense(tx))
expenseChanges.Add(new BudgetExpenseChange(
tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount));
await using var writeScope = await db.Database.BeginTransactionAsync();
@@ -327,7 +386,7 @@ public class TransactionsController(
tx.UpdatedAt = DateTime.UtcNow;
await using var writeScope = await db.Database.BeginTransactionAsync();
await db.SaveChangesAsync();
if (tx.Type == TransactionType.Expense)
if (IsExpense(tx))
{
await budgetPush.EvaluateAsync(Uid,
[new BudgetExpenseChange(tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount)]);
@@ -378,16 +437,16 @@ public class TransactionsController(
var list = await q.OrderByDescending(t => t.OccurredAt).ToListAsync();
var income = list.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount);
var expense = list.Where(t => t.Type == TransactionType.Expense).Sum(t => t.Amount);
var income = list.Where(IsIncome).Sum(t => t.Amount);
var expense = list.Where(IsExpense).Sum(t => t.Amount);
var days = list
.GroupBy(t => DateOnly.FromDateTime(ChinaClock.ToLocal(t.OccurredAt)))
.OrderByDescending(g => g.Key)
.Select(g => new DailyGroupDto(
g.Key,
g.Where(t => t.Type == TransactionType.Expense).Sum(t => t.Amount),
g.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount),
g.Where(IsExpense).Sum(t => t.Amount),
g.Where(IsIncome).Sum(t => t.Amount),
g.Select(t => ToDto(t, t.Category)).ToList()))
.ToList();
@@ -413,9 +472,9 @@ public class TransactionsController(
t.OccurredAt >= start && t.OccurredAt < end)
.ToListAsync();
var expenses = list.Where(t => t.Type == TransactionType.Expense).ToList();
var expenses = list.Where(IsExpense).ToList();
var totalExpense = expenses.Sum(t => t.Amount);
var totalIncome = list.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount);
var totalIncome = list.Where(IsIncome).Sum(t => t.Amount);
var byCat = expenses
.GroupBy(t => t.Category)
@@ -438,7 +497,9 @@ public class TransactionsController(
var (prevStart, _) = ChinaClock.MonthRangeUtc(prevYear, prevMonth);
var prevExpense = await db.Transactions
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value &&
t.Type == TransactionType.Expense && t.OccurredAt >= prevStart && t.OccurredAt < start)
(t.Type == TransactionType.Expense ||
t.Type == TransactionType.Transfer && t.TransferDirection == TransferDirection.Out) &&
t.OccurredAt >= prevStart && t.OccurredAt < start)
.SumAsync(t => t.Amount);
var trend = prevExpense == 0 ? "这是你的第一个月记账哦"
: totalExpense > prevExpense * 1.05m ? $"比上月多花了 ¥{(totalExpense - prevExpense):F0}"
@@ -513,11 +574,11 @@ public class TransactionsController(
.ToListAsync();
var expenses = list
.Where(transaction => transaction.Type == TransactionType.Expense)
.Where(IsExpense)
.ToList();
var totalExpense = expenses.Sum(transaction => transaction.Amount);
var totalIncome = list
.Where(transaction => transaction.Type == TransactionType.Income)
.Where(IsIncome)
.Sum(transaction => transaction.Amount);
var byCategory = expenses
.GroupBy(transaction => transaction.Category)
@@ -550,8 +611,8 @@ public class TransactionsController(
return new PeriodTrendPointDto(
$"{pointStart.Month}月",
pointStart,
pointItems.Where(item => item.Type == TransactionType.Expense).Sum(item => item.Amount),
pointItems.Where(item => item.Type == TransactionType.Income).Sum(item => item.Amount));
pointItems.Where(IsExpense).Sum(item => item.Amount),
pointItems.Where(IsIncome).Sum(item => item.Amount));
})
.ToList();
}
@@ -570,8 +631,8 @@ public class TransactionsController(
return new PeriodTrendPointDto(
label,
date,
dayItems.Where(item => item.Type == TransactionType.Expense).Sum(item => item.Amount),
dayItems.Where(item => item.Type == TransactionType.Income).Sum(item => item.Amount));
dayItems.Where(IsExpense).Sum(item => item.Amount),
dayItems.Where(IsIncome).Sum(item => item.Amount));
})
.ToList();
}
@@ -580,7 +641,9 @@ public class TransactionsController(
.Where(transaction =>
transaction.UserId == Uid &&
transaction.LedgerId == resolvedLedgerId.Value &&
transaction.Type == TransactionType.Expense &&
(transaction.Type == TransactionType.Expense ||
transaction.Type == TransactionType.Transfer &&
transaction.TransferDirection == TransferDirection.Out) &&
transaction.OccurredAt >= previousStart &&
transaction.OccurredAt < start)
.SumAsync(transaction => transaction.Amount);
@@ -634,9 +697,79 @@ public class TransactionsController(
{
"income" => TransactionType.Income,
"expense" => TransactionType.Expense,
"transfer" => TransactionType.Transfer,
_ => null,
};
private static TransferDirection? ParseTransferDirection(string? value) => value switch
{
"in" => TransferDirection.In,
"out" => TransferDirection.Out,
_ => null,
};
private static TransactionType CategoryTypeFor(
TransactionType type,
TransferDirection? direction) => type == TransactionType.Transfer
? direction == TransferDirection.In ? TransactionType.Income : TransactionType.Expense
: type;
internal static bool IsExpense(Transaction transaction) =>
transaction.Type == TransactionType.Expense ||
transaction.Type == TransactionType.Transfer &&
transaction.TransferDirection == TransferDirection.Out;
internal static bool IsIncome(Transaction transaction) =>
transaction.Type == TransactionType.Income ||
transaction.Type == TransactionType.Transfer &&
transaction.TransferDirection == TransferDirection.In;
private static string? NormalizeOptional(string? value, int maxLength)
{
var normalized = value?.Trim();
if (string.IsNullOrEmpty(normalized)) return null;
return normalized.Length <= maxLength ? normalized : normalized[..maxLength];
}
private async Task<Transaction?> FindExistingTransactionAsync(
string? clientRequestId,
string? provider,
string? providerTransactionId,
string? occurrenceId,
bool asNoTracking = false,
CancellationToken ct = default)
{
IQueryable<Transaction> Query() => db.Transactions
.IgnoreQueryFilters()
.Include(transaction => transaction.Category);
if (provider is not null && providerTransactionId is not null)
{
var providerMatch = await Track(Query().Where(transaction => transaction.UserId == Uid &&
transaction.Provider == provider &&
transaction.ProviderTransactionId == providerTransactionId))
.FirstOrDefaultAsync(ct);
if (providerMatch is not null) return providerMatch;
}
if (occurrenceId is not null)
{
var occurrenceMatch = await Track(Query().Where(transaction => transaction.UserId == Uid &&
transaction.RecognitionOccurrenceId == occurrenceId))
.FirstOrDefaultAsync(ct);
if (occurrenceMatch is not null) return occurrenceMatch;
}
if (clientRequestId is not null)
{
return await Track(Query().Where(transaction => transaction.UserId == Uid &&
transaction.ClientRequestId == clientRequestId))
.FirstOrDefaultAsync(ct);
}
return null;
IQueryable<Transaction> Track(IQueryable<Transaction> query) =>
asNoTracking ? query.AsNoTracking() : query;
}
private static bool HasVersionConflict(Transaction transaction, DateTime? baseUpdatedAt)
{
if (!baseUpdatedAt.HasValue) return false;
@@ -662,7 +795,12 @@ public class TransactionsController(
internal static TransactionDto ToDto(Transaction t, Category c) => new(
t.Id, t.LedgerId, c.Id, c.Name, c.IconKey,
t.Type == TransactionType.Income ? "income" : "expense",
t.Type switch
{
TransactionType.Income => "income",
TransactionType.Transfer => "transfer",
_ => "expense",
},
t.Amount,
t.Source == TransactionSource.AiChat
&& !string.IsNullOrWhiteSpace(t.SourceText)
@@ -685,5 +823,17 @@ public class TransactionsController(
t.SourceText,
t.IsDeleted,
c.ColorKey,
t.UpdatedAt);
t.UpdatedAt,
t.TransferDirection switch
{
TransferDirection.In => "in",
TransferDirection.Out => "out",
_ => null,
},
t.Counterparty,
t.Provider,
t.ProviderTransactionId,
t.RecognitionOccurrenceId,
t.EvidenceFingerprint,
t.RecognitionConfidence);
}
@@ -206,9 +206,12 @@ public class UsersController(
}),
transactions = transactions.Select(t => new
{
t.Id, t.LedgerId, t.CategoryId,
type = t.Type.ToString().ToLowerInvariant(),
t.Amount, t.Note, t.PaymentMethod, t.OccurredAt,
t.Id, t.LedgerId, t.CategoryId,
type = t.Type.ToString().ToLowerInvariant(),
transferDirection = t.TransferDirection.ToWire(),
t.Counterparty, t.Amount, t.Note, t.PaymentMethod, t.OccurredAt,
t.Provider, t.ProviderTransactionId, t.RecognitionOccurrenceId,
t.EvidenceFingerprint, t.RecognitionConfidence,
source = t.Source.ToString(), t.SourceText,
t.IsDeleted, t.DeletedAt, t.CreatedAt, t.UpdatedAt,
}),
@@ -225,14 +228,26 @@ public class UsersController(
var ledgerNames = ledgers.ToDictionary(l => l.Id, l => l.Name);
var transactionCsv = new StringBuilder(
"ID,账本,类型,金额,分类,备注,支付方式,发生时间,来源,已删除\r\n");
"ID,账本,类型,转账方向,对方,金额,分类,备注,支付方式,发生时间,来源,已删除\r\n");
foreach (var tx in transactions)
{
transactionCsv.AppendJoin(',', new[]
{
Csv(tx.Id),
Csv(ledgerNames.GetValueOrDefault(tx.LedgerId, "")),
Csv(tx.Type == TransactionType.Income ? "收入" : "支出"),
Csv(tx.Type switch
{
TransactionType.Income => "收入",
TransactionType.Transfer => "转账",
_ => "支出",
}),
Csv(tx.TransferDirection switch
{
TransferDirection.In => "转入",
TransferDirection.Out => "转出",
_ => "",
}),
Csv(tx.Counterparty),
Csv(tx.Amount),
Csv(tx.Category.Name),
Csv(tx.Note),
+18 -8
View File
@@ -15,7 +15,7 @@ var authPermitLimit = Math.Max(1, builder.Configuration.GetValue("RateLimiting:A
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("auth", context =>
options.AddPolicy("auth", context =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
@@ -23,7 +23,16 @@ builder.Services.AddRateLimiter(options =>
PermitLimit = authPermitLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}));
}));
options.AddPolicy("admin-auth", context =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}));
options.AddPolicy("ai", context =>
RateLimitPartition.GetConcurrencyLimiter(
context.User.FindFirst("sub")?.Value ??
@@ -52,6 +61,8 @@ builder.Services.AddScoped<LedgerResolver>();
builder.Services.AddScoped<AiPermissionService>();
builder.Services.AddScoped<AiChatQuotaService>();
builder.Services.AddScoped<BudgetPushService>();
builder.Services.AddScoped<AdminSessionService>();
builder.Services.AddScoped<AdminBootstrapService>();
builder.Services.AddSingleton<PushTokenProtector>();
builder.Services.AddScoped<AiPermissionFilter>();
builder.Services.AddHttpClient("LlmClient");
@@ -84,9 +95,6 @@ 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"];
if (string.IsNullOrWhiteSpace(adminKey) || adminKey.Length < 24)
throw new InvalidOperationException("必须通过 Admin__Key 配置至少 24 位的管理密钥");
if (builder.Configuration.GetValue<bool>("Push:Enabled"))
{
var pushKey = builder.Configuration["Push:TokenEncryptionKey"];
@@ -156,7 +164,8 @@ var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
await db.Database.MigrateAsync();
await scope.ServiceProvider.GetRequiredService<AdminBootstrapService>().EnsureAsync();
if (app.Environment.IsDevelopment())
await DbSeeder.SeedAsync(db);
}
@@ -167,8 +176,9 @@ var buildTime = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss") + " UTC";
var apiVersion = builder.Configuration["Build:Version"] ?? "dev";
app.UseAuthentication();
app.UseRateLimiter();
app.UseAuthorization();
app.UseRateLimiter();
app.UseAuthorization();
app.UseMiddleware<AdminAuditMiddleware>();
app.MapControllers();
app.MapGet("/api/ping", () => Results.Ok(new { status = "ok", version = apiVersion, built = buildTime }));
app.MapGet("/api/version", () => Results.Ok(new { app = "记之 API", version = apiVersion, built = buildTime }));
@@ -0,0 +1,71 @@
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Infrastructure.Persistence;
namespace MiaoJiZhang.Api.Services;
public sealed class AdminAuditMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, AppDbContext db)
{
var isAdmin = context.Request.Path.StartsWithSegments("/api/admin");
var isAuth = context.Request.Path.StartsWithSegments("/api/admin/auth");
var shouldAudit = isAdmin && (isAuth ||
!AdminSessionService.IsSafeMethod(context.Request.Method));
if (!shouldAudit)
{
await next(context);
return;
}
Exception? failure = null;
try
{
await next(context);
}
catch (Exception exception)
{
failure = exception;
throw;
}
finally
{
try
{
var principal = AdminRequestContext.Principal(context);
var attemptedUsername = context.Items.TryGetValue(
AdminRequestContext.AuditUsernameKey,
out var attemptedValue)
? attemptedValue?.ToString()
: null;
var status = failure is null
? context.Response.StatusCode
: StatusCodes.Status500InternalServerError;
db.AdminAuditLogs.Add(new AdminAuditLog
{
AdminUserId = principal?.UserId,
Username = principal?.Username ?? attemptedUsername,
Action = ActionName(context),
Resource = context.Request.Path.Value ?? "/api/admin",
HttpMethod = context.Request.Method,
Path = (context.Request.Path + context.Request.QueryString).ToString(),
StatusCode = status,
Success = failure is null && status < 400,
Detail = failure?.GetType().Name,
IpAddress = context.Connection.RemoteIpAddress?.ToString(),
CreatedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync(CancellationToken.None);
}
catch
{
// Audit persistence must not replace the original API result.
}
}
}
private static string ActionName(HttpContext context)
{
var path = context.Request.Path.Value?.Trim('/').Replace('/', '.') ?? "api.admin";
return $"{context.Request.Method.ToLowerInvariant()}.{path}";
}
}
@@ -1,24 +1,49 @@
using MiaoJiZhang.Domain.Entities;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace MiaoJiZhang.Api.Services;
/// <summary>
/// 管理后台鉴权:请求头 X-Admin-Key 与 appsettings.Admin:Key 匹配即可。
/// 仅内部使用,不依赖 JWT/用户体系。
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AdminAuthAttribute : Attribute, IAuthorizationFilter
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public sealed class AdminAuthAttribute(params string[] roles) : Attribute, IAsyncAuthorizationFilter
{
public void OnAuthorization(AuthorizationFilterContext context)
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
var config = context.HttpContext.RequestServices.GetRequiredService<IConfiguration>();
var key = config["Admin:Key"];
if (string.IsNullOrWhiteSpace(key) ||
!context.HttpContext.Request.Headers.TryGetValue("X-Admin-Key", out var provided) ||
provided != key)
var request = context.HttpContext.Request;
var service = context.HttpContext.RequestServices.GetRequiredService<AdminSessionService>();
var authenticated = await service.AuthenticateAsync(
context.HttpContext,
validateCsrf: !AdminSessionService.IsSafeMethod(request.Method),
context.HttpContext.RequestAborted);
if (authenticated is null)
{
context.Result = new UnauthorizedObjectResult(new { error = "admin_key_required", message = "请在 Header 中提供 X-Admin-Key" });
context.Result = new UnauthorizedObjectResult(new
{
error = "admin_session_required",
message = "管理会话已失效,请重新登录",
});
return;
}
var principal = authenticated.Value.Principal;
if (principal.MustChangePassword &&
!request.Path.StartsWithSegments("/api/admin/auth"))
{
context.Result = new ObjectResult(new
{
error = "password_change_required",
message = "首次登录必须修改密码",
}) { StatusCode = StatusCodes.Status403Forbidden };
return;
}
if (principal.Role == AdminRoles.Viewer &&
!AdminSessionService.IsSafeMethod(request.Method) &&
!request.Path.StartsWithSegments("/api/admin/auth"))
{
context.Result = new ForbidResult();
return;
}
if (roles.Length > 0 && !roles.Contains(principal.Role))
context.Result = new ForbidResult();
}
}
}
@@ -0,0 +1,38 @@
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Services;
public sealed class AdminBootstrapService(
AppDbContext db,
IConfiguration configuration,
ILogger<AdminBootstrapService> logger)
{
public async Task EnsureAsync(CancellationToken ct = default)
{
if (await db.AdminUsers.AnyAsync(ct)) return;
var username = configuration["Admin:BootstrapUsername"]?.Trim();
var password = configuration["Admin:BootstrapPassword"];
if (string.IsNullOrWhiteSpace(username) || username.Length is < 3 or > 64 ||
string.IsNullOrWhiteSpace(password) || password.Length < 12)
{
throw new InvalidOperationException(
"首次启动必须通过 Admin__BootstrapUsername 和 Admin__BootstrapPassword 配置管理员,密码至少 12 位");
}
var now = DateTime.UtcNow;
db.AdminUsers.Add(new AdminUser
{
Username = username,
PasswordHash = AdminSessionService.HashPassword(password),
Role = AdminRoles.SuperAdmin,
IsActive = true,
MustChangePassword = true,
CreatedAt = now,
UpdatedAt = now,
});
await db.SaveChangesAsync(ct);
logger.LogWarning("Bootstrapped the first super administrator account: {Username}", username);
}
}
@@ -0,0 +1,217 @@
using System.Security.Cryptography;
using System.Text;
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Services;
public sealed record AdminPrincipal(
long UserId,
long SessionId,
string Username,
string Role,
bool MustChangePassword);
public sealed record AdminLoginResult(AdminPrincipal Principal, string CsrfToken);
public static class AdminRequestContext
{
public const string PrincipalKey = "miaoji.admin.principal";
public const string AuditUsernameKey = "miaoji.admin.audit.username";
public static AdminPrincipal? Principal(HttpContext context) =>
context.Items.TryGetValue(PrincipalKey, out var value)
? value as AdminPrincipal
: null;
}
public sealed class AdminSessionService(
AppDbContext db,
IConfiguration configuration,
IWebHostEnvironment environment)
{
public const string CookieName = "miaoji_admin_session";
public const string CsrfHeader = "X-CSRF-Token";
private static readonly TimeSpan IdleLifetime = TimeSpan.FromHours(8);
private static readonly TimeSpan AbsoluteLifetime = TimeSpan.FromDays(7);
private static readonly TimeSpan LockoutLifetime = TimeSpan.FromMinutes(15);
public async Task<AdminLoginResult?> LoginAsync(
HttpContext context,
string username,
string password,
CancellationToken ct)
{
var normalizedUsername = username.Trim();
context.Items[AdminRequestContext.AuditUsernameKey] = normalizedUsername;
var user = await db.AdminUsers.FirstOrDefaultAsync(
item => item.Username == normalizedUsername,
ct);
var now = DateTime.UtcNow;
if (user is null || !user.IsActive ||
user.LockedUntil.HasValue && user.LockedUntil.Value > now)
{
BCrypt.Net.BCrypt.Verify(password, DummyPasswordHash());
return null;
}
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
{
user.FailedLoginCount++;
if (user.FailedLoginCount >= 5)
{
user.FailedLoginCount = 0;
user.LockedUntil = now.Add(LockoutLifetime);
}
user.UpdatedAt = now;
await db.SaveChangesAsync(ct);
return null;
}
user.FailedLoginCount = 0;
user.LockedUntil = null;
user.LastLoginAt = now;
user.UpdatedAt = now;
var rawToken = NewToken();
var csrfToken = NewToken();
var session = new AdminSession
{
AdminUser = user,
TokenHash = Hash(rawToken),
CsrfTokenHash = Hash(csrfToken),
AuthVersion = user.AuthVersion,
ExpiresAt = now.Add(IdleLifetime),
AbsoluteExpiresAt = now.Add(AbsoluteLifetime),
LastSeenAt = now,
IpAddress = ClientIp(context),
UserAgent = Trim(context.Request.Headers.UserAgent.ToString(), 300),
CreatedAt = now,
};
db.AdminSessions.Add(session);
await db.SaveChangesAsync(ct);
WriteCookie(context, rawToken, session.AbsoluteExpiresAt);
var principal = ToPrincipal(user, session);
context.Items[AdminRequestContext.PrincipalKey] = principal;
return new AdminLoginResult(principal, csrfToken);
}
public async Task<(AdminPrincipal Principal, string CsrfToken)?> AuthenticateAsync(
HttpContext context,
bool validateCsrf,
CancellationToken ct)
{
if (!context.Request.Cookies.TryGetValue(CookieName, out var token) ||
string.IsNullOrWhiteSpace(token))
return null;
var tokenHash = Hash(token);
var now = DateTime.UtcNow;
var session = await db.AdminSessions
.Include(item => item.AdminUser)
.FirstOrDefaultAsync(item => item.TokenHash == tokenHash, ct);
if (session is null || session.RevokedAt.HasValue ||
session.ExpiresAt <= now || session.AbsoluteExpiresAt <= now ||
!session.AdminUser.IsActive ||
session.AuthVersion != session.AdminUser.AuthVersion)
{
DeleteCookie(context);
return null;
}
var csrfToken = context.Request.Headers[CsrfHeader].FirstOrDefault();
if (validateCsrf && (string.IsNullOrWhiteSpace(csrfToken) ||
!CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(Hash(csrfToken)),
Encoding.ASCII.GetBytes(session.CsrfTokenHash))))
{
return null;
}
if (now - session.LastSeenAt >= TimeSpan.FromMinutes(5))
{
session.LastSeenAt = now;
session.ExpiresAt = Min(now.Add(IdleLifetime), session.AbsoluteExpiresAt);
await db.SaveChangesAsync(ct);
}
var principal = ToPrincipal(session.AdminUser, session);
context.Items[AdminRequestContext.PrincipalKey] = principal;
return (principal, csrfToken ?? string.Empty);
}
public async Task<string> RotateCsrfAsync(long sessionId, CancellationToken ct)
{
var session = await db.AdminSessions.FindAsync([sessionId], ct) ??
throw new InvalidOperationException("管理会话不存在");
var token = NewToken();
session.CsrfTokenHash = Hash(token);
await db.SaveChangesAsync(ct);
return token;
}
public async Task LogoutAsync(HttpContext context, long sessionId, CancellationToken ct)
{
var session = await db.AdminSessions.FindAsync([sessionId], ct);
if (session is not null && !session.RevokedAt.HasValue)
{
session.RevokedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
}
DeleteCookie(context);
}
public async Task RevokeOtherSessionsAsync(
long userId,
long currentSessionId,
int authVersion,
CancellationToken ct)
{
var now = DateTime.UtcNow;
await db.AdminSessions
.Where(item => item.AdminUserId == userId && item.Id != currentSessionId &&
!item.RevokedAt.HasValue)
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.RevokedAt, now), ct);
var current = await db.AdminSessions.FindAsync([currentSessionId], ct);
if (current is not null) current.AuthVersion = authVersion;
}
public static string HashPassword(string password) =>
BCrypt.Net.BCrypt.HashPassword(password, workFactor: 12);
public static bool IsSafeMethod(string method) =>
HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method);
private void WriteCookie(HttpContext context, string token, DateTime expiresAt) =>
context.Response.Cookies.Append(CookieName, token, CookieOptions(context, expiresAt));
private void DeleteCookie(HttpContext context) =>
context.Response.Cookies.Delete(CookieName, CookieOptions(context, DateTime.UtcNow.AddDays(-1)));
private CookieOptions CookieOptions(HttpContext context, DateTime expiresAt) => new()
{
HttpOnly = true,
Secure = configuration.GetValue<bool?>("Admin:CookieSecure") ??
(!environment.IsDevelopment() || context.Request.IsHttps),
SameSite = SameSiteMode.Strict,
Path = "/api/admin",
IsEssential = true,
Expires = expiresAt,
};
private static AdminPrincipal ToPrincipal(AdminUser user, AdminSession session) =>
new(user.Id, session.Id, user.Username, user.Role, user.MustChangePassword);
private static DateTime Min(DateTime left, DateTime right) => left <= right ? left : right;
private static string NewToken() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static string Hash(string value) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private static string? ClientIp(HttpContext context) =>
Trim(context.Connection.RemoteIpAddress?.ToString(), 64);
private static string? Trim(string? value, int length) =>
string.IsNullOrEmpty(value) ? null : value.Length <= length ? value : value[..length];
private static string DummyPasswordHash() =>
"$2a$12$1i3L4fD4PrM9xMVzKDnwoO.nGsRoW6u9Q9tT6A4vPi04QoV9S3Mca";
}
@@ -34,8 +34,8 @@ public class AgentService(
"properties": {
"type": {
"type": "string",
"enum": ["expense", "income"],
"description": "交易方向,收入必须是 income,支出必须是 expense"
"enum": ["expense", "income", "transfer"],
"description": "账单类型;明确转账时使用 transfer"
},
"amount": {
"type": "number",
@@ -53,6 +53,15 @@ public class AgentService(
"type": "string",
"description": "可选,例如微信支付、支付宝、现金"
},
"transferDirection": {
"type": "string",
"enum": ["in", "out"],
"description": "type=transfer 时必填,转入为 in,转出为 out"
},
"counterparty": {
"type": "string",
"description": "转账对方,可选"
},
"occurredAt": {
"type": "string",
"description": "可选,ISO 8601 时间;未提时间不要填写"
@@ -105,7 +114,7 @@ public class AgentService(
},
"type": {
"type": "string",
"enum": ["expense", "income"]
"enum": ["expense", "income", "transfer"]
},
"categoryName": {
"type": "string"
@@ -306,9 +315,9 @@ public class AgentService(
foreach (var bill in bills)
{
var category = await db.Categories.FirstAsync(
c => c.Id == bill.CategoryId && c.Type == bill.Type,
c => c.Id == bill.CategoryId && c.Type == bill.Type.CategoryType(bill.TransferDirection),
ct);
if (category.Type != bill.Type)
if (category.Type != bill.Type.CategoryType(bill.TransferDirection))
throw new InvalidOperationException("交易类型与分类类型不一致");
pending.Add(new Transaction
@@ -318,6 +327,8 @@ public class AgentService(
CategoryId = category.Id,
Category = category,
Type = bill.Type,
TransferDirection = bill.TransferDirection,
Counterparty = bill.Counterparty,
Amount = bill.Amount,
Note = NormalizeNote(bill.Note, sourceText, category.Name),
PaymentMethod = bill.PaymentMethod,
@@ -336,7 +347,7 @@ public class AgentService(
{
await db.SaveChangesAsync(ct);
await budgetPush.EvaluateAsync(userId, pending
.Where(transaction => transaction.Type == TransactionType.Expense)
.Where(transaction => transaction.Type.IsExpense(transaction.TransferDirection))
.Select(transaction => new BudgetExpenseChange(
transaction.LedgerId,
transaction.CategoryId,
@@ -361,9 +372,9 @@ public class AgentService(
items = created.Select(transaction => new
{
id = transaction.Id,
type = transaction.Type == TransactionType.Income
? "income"
: "expense",
type = transaction.Type.ToWire(),
transferDirection = transaction.TransferDirection.ToWire(),
transaction.Counterparty,
amount = transaction.Amount,
categoryName = transaction.Category.Name,
note = transaction.Note,
@@ -395,9 +406,20 @@ public class AgentService(
{
"income" => TransactionType.Income,
"expense" => TransactionType.Expense,
"transfer" => TransactionType.Transfer,
_ => throw new InvalidOperationException(
"交易 type 必须是 incomeexpense"),
"交易 type 必须是 incomeexpense 或 transfer"),
};
TransferDirection? transferDirection = null;
if (type == TransactionType.Transfer)
{
transferDirection = RequiredString(item, "transferDirection") switch
{
"in" => TransferDirection.In,
"out" => TransferDirection.Out,
_ => throw new InvalidOperationException("转账方向必须是 in 或 out"),
};
}
if (!item.TryGetProperty("amount", out var amountNode) ||
!amountNode.TryGetDecimal(out var amount) ||
amount <= 0)
@@ -411,7 +433,7 @@ public class AgentService(
if (note.Length > 30) note = note[..30];
var category = await ResolveCategoryAsync(
userId,
type,
type.CategoryType(transferDirection),
categoryName,
ct);
@@ -447,7 +469,9 @@ public class AgentService(
string.IsNullOrWhiteSpace(note) ? category.Name : note,
amount,
paymentMethod,
occurredAt));
occurredAt,
transferDirection,
OptionalString(item, "counterparty", 100)));
}
return bills;
}
@@ -470,18 +494,20 @@ public class AgentService(
.ToListAsync(ct);
var income = transactions
.Where(t => t.Type == TransactionType.Income)
.Where(t => t.Type.IsIncome(t.TransferDirection))
.Sum(t => t.Amount);
var expense = transactions
.Where(t => t.Type == TransactionType.Expense)
.Where(t => t.Type.IsExpense(t.TransferDirection))
.Sum(t => t.Amount);
var categories = transactions
.GroupBy(t => new { t.Type, t.Category.Name })
.GroupBy(t => new
{
EffectiveType = t.Type.IsIncome(t.TransferDirection) ? "income" : "expense",
t.Category.Name,
})
.Select(group => new
{
type = group.Key.Type == TransactionType.Income
? "income"
: "expense",
type = group.Key.EffectiveType,
categoryName = group.Key.Name,
amount = group.Sum(t => t.Amount),
})
@@ -524,6 +550,7 @@ public class AgentService(
{
"income" => TransactionType.Income,
"expense" => TransactionType.Expense,
"transfer" => TransactionType.Transfer,
_ => throw new InvalidOperationException("筛选类型无效"),
};
query = query.Where(t => t.Type == type);
@@ -552,9 +579,9 @@ public class AgentService(
items = transactions.Select(transaction => new
{
id = transaction.Id,
type = transaction.Type == TransactionType.Income
? "income"
: "expense",
type = transaction.Type.ToWire(),
transferDirection = transaction.TransferDirection.ToWire(),
transaction.Counterparty,
transaction.Amount,
categoryName = transaction.Category.Name,
transaction.Note,
@@ -605,7 +632,9 @@ public class AgentService(
var expenses = await db.Transactions
.Where(t => t.UserId == userId &&
t.LedgerId == ledgerId &&
t.Type == TransactionType.Expense &&
(t.Type == TransactionType.Expense ||
t.Type == TransactionType.Transfer &&
t.TransferDirection == TransferDirection.Out) &&
t.OccurredAt >= start &&
t.OccurredAt < end)
.Select(t => new { t.CategoryId, t.Amount })
@@ -687,7 +716,9 @@ public class AgentService(
private static object ToToolItem(ParsedBill bill) => new
{
type = bill.Type == TransactionType.Income ? "income" : "expense",
type = bill.Type.ToWire(),
transferDirection = bill.TransferDirection.ToWire(),
bill.Counterparty,
bill.Amount,
bill.CategoryName,
bill.Note,
@@ -706,6 +737,15 @@ public class AgentService(
return node.GetString()!;
}
private static string? OptionalString(JsonElement root, string name, int maxLength)
{
if (!root.TryGetProperty(name, out var node) || node.ValueKind != JsonValueKind.String)
return null;
var value = node.GetString()?.Trim();
if (string.IsNullOrEmpty(value)) return null;
return value.Length <= maxLength ? value : value[..maxLength];
}
private static (DateTime Start, DateTime End, string Label) ResolvePeriod(
string period)
{
+12 -4
View File
@@ -12,8 +12,10 @@ public record ParsedBill(
string CategoryName,
string Note,
decimal Amount,
string? PaymentMethod,
DateTime? OccurredAt = null);
string? PaymentMethod,
DateTime? OccurredAt = null,
TransferDirection? TransferDirection = null,
string? Counterparty = null);
public record IntentResult(string Kind, ParsedBill? Bill); // bill | query | chat
@@ -67,13 +69,19 @@ public class ReplyService(AppDbContext db)
public async Task<string> BillReplyAsync(long userId, ParsedBill bill)
{
var (persona, tic) = await GetPersonaAsync(userId);
var action = bill.Type == TransactionType.Income ? "收入" : "支出";
var action = bill.Type switch
{
TransactionType.Income => "收入",
TransactionType.Transfer when bill.TransferDirection == TransferDirection.In => "转入",
TransactionType.Transfer => "转出",
_ => "支出",
};
var body = persona switch
{
"gentle" => $"{action}记好啦~{bill.CategoryName} ¥{bill.Amount:F2}",
"strict" => $"已记录{action}{bill.CategoryName} ¥{bill.Amount:F2}。",
"meme" => $"{action}记上了!{bill.CategoryName} ¥{bill.Amount:F2},家人们谁懂啊",
_ => bill.Type == TransactionType.Income
_ => bill.Type.IsIncome(bill.TransferDirection)
? $"收入到账!{bill.CategoryName} ¥{bill.Amount:F2},钱包回血啦"
: $"记好了!{bill.CategoryName} ¥{bill.Amount:F2},这笔支出我帮你盯着",
};
@@ -56,7 +56,9 @@ public sealed class BudgetPushService(AppDbContext db)
var spent = await db.Transactions
.Where(transaction => transaction.UserId == userId &&
transaction.LedgerId == group.Key.LedgerId &&
transaction.Type == TransactionType.Expense &&
(transaction.Type == TransactionType.Expense ||
transaction.Type == TransactionType.Transfer &&
transaction.TransferDirection == TransferDirection.Out) &&
transaction.OccurredAt >= start && transaction.OccurredAt < end)
.GroupBy(transaction => transaction.CategoryId)
.Select(items => new { CategoryId = items.Key, Amount = items.Sum(item => item.Amount) })
@@ -75,7 +75,9 @@ public sealed class BudgetRecommendationService(
.Where(transaction =>
transaction.UserId == userId &&
transaction.LedgerId == ledgerId &&
transaction.Type == TransactionType.Expense &&
(transaction.Type == TransactionType.Expense ||
transaction.Type == TransactionType.Transfer &&
transaction.TransferDirection == TransferDirection.Out) &&
transaction.OccurredAt >= currentStart &&
transaction.OccurredAt < currentEnd)
.SumAsync(transaction => transaction.Amount, ct);
@@ -179,7 +181,9 @@ public sealed class BudgetRecommendationService(
.Where(transaction =>
transaction.UserId == userId &&
transaction.LedgerId == ledgerId &&
transaction.Type == TransactionType.Expense &&
(transaction.Type == TransactionType.Expense ||
transaction.Type == TransactionType.Transfer &&
transaction.TransferDirection == TransferDirection.Out) &&
transaction.OccurredAt >= historyStart &&
transaction.OccurredAt < currentEnd)
.Select(transaction => new
+13 -4
View File
@@ -50,8 +50,10 @@ public record ImageParseResult(
decimal Amount,
string CategoryName,
string? PaymentMethod,
string Note,
DateTime? OccurredAt);
string Note,
DateTime? OccurredAt,
string? TransferDirection = null,
string? Counterparty = null);
public record RecognitionBatchModelCandidate(
string CandidateId,
@@ -65,7 +67,12 @@ public record RecognitionBatchModelCandidate(
string RecognitionKind,
string? CategoryHint,
string Confidence,
IReadOnlyList<string> EvidenceIds);
IReadOnlyList<string> EvidenceIds,
string? TransferDirection,
string? Counterparty,
string? ProviderTransactionId,
string? RecognitionOccurrenceId,
string? IdentityConfidence);
public record RecognitionBatchModelEvidence(
string EvidenceId,
@@ -95,7 +102,9 @@ public record RecognitionBatchModelAction(
string? Note,
DateTime? OccurredAt,
double Confidence,
string Reason);
string Reason,
string? TransferDirection,
string? Counterparty);
public class NullLlmClient : ILlmClient
{
@@ -37,10 +37,11 @@ public partial class OpenAiVisionClient : ILlmClient
CancellationToken ct = default)
{
if (!IsEnabled) return null;
const string prompt =
"你是记账意图识别器。只返回 JSON:" +
"{\"kind\":\"bill\"|\"query\"|\"chat\",\"type\":\"expense\"|\"income\"," +
"\"amount\":0,\"categoryName\":\"\",\"note\":\"\"}。" +
const string prompt =
"你是记账意图识别器。只返回 JSON:" +
"{\"kind\":\"bill\"|\"query\"|\"chat\",\"type\":\"expense\"|\"income\"|\"transfer\"," +
"\"transferDirection\":\"in\"|\"out\"|null,\"counterparty\":null," +
"\"amount\":0,\"categoryName\":\"\",\"note\":\"\"}。" +
"收入信号包括赚了、工资到账、奖金、兼职、稿费、红包、报销、退款、理财收益、收款;" +
"支出分类:餐饮/饮品/购物/交通/住房/娱乐/医疗/学习/服饰/人情/旅行/其他;" +
"收入分类:工资/奖金/理财/兼职/红包/报销/其他。" +
@@ -62,8 +63,8 @@ public partial class OpenAiVisionClient : ILlmClient
var shanghaiNow = ChinaClock.Now;
var systemPrompt = $$"""
你是账单截图识别器。逐条提取图片中所有独立、真实发生的交易,只返回 JSON:
{"bills":[{"type":"expense","amount":0,"categoryName":"","paymentMethod":null,"note":"","occurredAt":"2026-07-18T14:30:00+08:00"}]}
type expense income
{"bills":[{"type":"expense","transferDirection":null,"counterparty":null,"amount":0,"categoryName":"","paymentMethod":null,"note":"","occurredAt":"2026-07-18T14:30:00+08:00"}]}
type expenseincome transfertransfer transferDirection=in|outcounterparty
///////////
//////
note
@@ -181,11 +182,16 @@ public partial class OpenAiVisionClient : ILlmClient
categoryHint = candidate.CategoryHint,
confidence = candidate.Confidence,
evidenceIds = candidate.EvidenceIds,
transferDirection = candidate.TransferDirection,
counterparty = candidate.Counterparty,
providerTransactionId = candidate.ProviderTransactionId,
recognitionOccurrenceId = candidate.RecognitionOccurrenceId,
identityConfidence = candidate.IdentityConfidence,
}),
new JsonSerializerOptions(JsonSerializerDefaults.Web));
var systemPrompt = $$"""
你是支付结果批次对账器。只返回 JSON:
{"actions":[{"action":"keep|update|create|drop","actionId":"a1","candidateId":null,"evidenceId":null,"type":null,"amount":null,"categoryName":null,"paymentMethod":null,"note":null,"occurredAt":null,"confidence":0.0,"reason":""}]}
{"actions":[{"action":"keep|update|create|drop","actionId":"a1","candidateId":null,"evidenceId":null,"type":null,"transferDirection":null,"counterparty":null,"amount":null,"categoryName":null,"paymentMethod":null,"note":null,"occurredAt":null,"confidence":0.0,"reason":""}]}
{{candidateJson}}
{{string.Join('/', input.ExpenseCategories)}}
{{string.Join('/', input.IncomeCategories)}}
@@ -193,7 +199,7 @@ public partial class OpenAiVisionClient : ILlmClient
flowSessionId 使
flowSessionId drop
evidenceId create evidenceId
update/create type expense incomeamount 0
update/create type expenseincome transferamount 0transfer transferDirection=in|out
沿reason 40
""";
var messages = new List<object>();
@@ -331,7 +337,9 @@ public partial class OpenAiVisionClient : ILlmClient
ReadText(item, "note", "merchant")?.Trim(),
ReadOccurredAt(item),
confidence,
reason.Length > 80 ? reason[..80] : reason));
reason.Length > 80 ? reason[..80] : reason,
ReadText(item, "transferDirection", "transfer_direction")?.Trim().ToLowerInvariant(),
ReadText(item, "counterparty")?.Trim()));
}
return results;
}
@@ -485,8 +493,9 @@ public partial class OpenAiVisionClient : ILlmClient
var rawType = ReadText(item, "type", "transactionType", "direction");
var type = rawType?.Trim().ToLowerInvariant() switch
{
"income" or "收入" or "入账" => "income",
"expense" or "支出" or "出账" => "expense",
"income" or "收入" or "入账" => "income",
"expense" or "支出" or "出账" => "expense",
"transfer" or "转账" => "transfer",
_ => null,
};
if (type is null) return;
@@ -508,7 +517,18 @@ public partial class OpenAiVisionClient : ILlmClient
"description",
"title",
"counterparty");
var occurredAt = ReadOccurredAt(item);
var occurredAt = ReadOccurredAt(item);
var transferDirection = type == "transfer"
? ReadText(item, "transferDirection", "transfer_direction", "direction")
?.Trim().ToLowerInvariant() switch
{
"in" or "转入" => "in",
"out" or "转出" => "out",
_ => null,
}
: null;
if (type == "transfer" && transferDirection is null) return;
var counterparty = ReadText(item, "counterparty", "merchant")?.Trim();
results.Add(new ImageParseResult(
type,
@@ -516,7 +536,9 @@ public partial class OpenAiVisionClient : ILlmClient
string.IsNullOrWhiteSpace(category) ? "其他" : category.Trim(),
string.IsNullOrWhiteSpace(payment) ? null : payment.Trim(),
string.IsNullOrWhiteSpace(note) ? "" : note.Trim(),
occurredAt));
occurredAt,
transferDirection,
string.IsNullOrWhiteSpace(counterparty) ? null : counterparty));
}
private static DateTime? ReadOccurredAt(JsonElement item)
@@ -959,12 +981,25 @@ public partial class OpenAiVisionClient : ILlmClient
? typeNode.GetString()
: null;
typeText = typeText?.Trim().ToLowerInvariant();
if (typeText is null ||
typeText is not ("income" or "expense"))
return new IntentResult("chat", null);
var type = typeText == "income"
? TransactionType.Income
: TransactionType.Expense;
if (typeText is null ||
typeText is not ("income" or "expense" or "transfer"))
return new IntentResult("chat", null);
var type = typeText switch
{
"income" => TransactionType.Income,
"transfer" => TransactionType.Transfer,
_ => TransactionType.Expense,
};
var transferDirection = type == TransactionType.Transfer
? ReadText(document, "transferDirection", "transfer_direction") switch
{
"in" => TransferDirection.In,
"out" => TransferDirection.Out,
_ => (TransferDirection?)null,
}
: null;
if (type == TransactionType.Transfer && transferDirection is null)
return new IntentResult("chat", null);
var category = document.TryGetProperty(
"categoryName",
out var categoryNode)
@@ -983,9 +1018,12 @@ public partial class OpenAiVisionClient : ILlmClient
type,
0,
category,
note,
amount,
null));
note,
amount,
null,
null,
transferDirection,
ReadText(document, "counterparty")?.Trim()));
}
catch
{
+3 -1
View File
@@ -17,7 +17,9 @@
},
"AllowedHosts": "*",
"Admin": {
"Key": ""
"BootstrapUsername": "",
"BootstrapPassword": "",
"CookieSecure": true
},
"Push": {
"Enabled": false,
@@ -0,0 +1 @@
.toolbar[data-v-ac276d87]{justify-content:space-between;align-items:center;margin-bottom:18px;display:flex}h2[data-v-ac276d87]{margin:0 0 3px;font-size:20px}.toolbar span[data-v-ac276d87]{color:#8c8c8c;font-size:12px}
@@ -0,0 +1 @@
import{$ as e,F as t,M as n,Q as r,R as i,b as a,bt as o,g as s,ot as c,p as l,q as u,s as d,t as f,v as p,y as m}from"./api-DftvpHMa.js";import{a as h}from"./config-provider-DjHSmQsy.js";import{t as g}from"./modal-B_MK8QJe.js";import{t as _}from"./time-pIfF89ap.js";import{t as v}from"./_plugin-vue_export-helper-BDNMzG2s.js";var y={class:`toolbar`},b=v(a({__name:`AdminAccounts`,setup(a){let v=e(!1),b=e([]),x=e(!1),S=e(null),C=r({username:``,password:``,role:`operator`}),w=e(``);async function T(){v.value=!0;try{b.value=await f.adminAccounts()}finally{v.value=!1}}async function E(){if(C.password.length<12)return h.error(`初始密码至少 12 位`);await f.createAdminAccount(C),h.success(`管理员已创建`),x.value=!1,Object.assign(C,{username:``,password:``,role:`operator`}),await T()}async function D(e,t){try{await f.updateAdminAccount(e.id,{role:t.role??e.role,isActive:t.isActive??e.isActive,mustChangePassword:t.mustChangePassword??e.mustChangePassword}),await T()}catch(e){h.error(e.response?.data?.message||`更新失败`)}}async function O(){if(!S.value||w.value.length<12)return h.error(`新密码至少 12 位`);await f.resetAdminPassword(S.value.id,w.value),h.success(`密码已重置,现有会话已撤销`),S.value=null,w.value=``,await T()}function k(e){g.confirm({title:`撤销 ${e.username} 的全部会话?`,async onOk(){await f.revokeAdminSessions(e.id),h.success(`会话已撤销`),await T()}})}return n(T),(e,n)=>{let r=i(`a-button`),a=i(`a-table-column`),f=i(`a-select-option`),h=i(`a-select`),g=i(`a-switch`),T=i(`a-space`),A=i(`a-table`),j=i(`a-input`),M=i(`a-form-item`),N=i(`a-input-password`),P=i(`a-form`),F=i(`a-modal`);return t(),s(d,null,[l(`div`,y,[n[8]||=l(`div`,null,[l(`h2`,null,`管理员账号`),l(`span`,null,`角色、登录状态与会话`)],-1),m(r,{type:`primary`,onClick:n[0]||=e=>x.value=!0},{default:u(()=>[...n[7]||=[p(`新建管理员`,-1)]]),_:1})]),m(A,{"data-source":b.value,loading:v.value,"row-key":`id`,pagination:!1,size:`middle`},{default:u(()=>[m(a,{title:`账号`,"data-index":`username`}),m(a,{title:`角色`},{default:u(({record:e})=>[m(h,{value:e.role,style:{width:`138px`},onChange:t=>D(e,{role:t})},{default:u(()=>[m(f,{value:`super_admin`},{default:u(()=>[...n[9]||=[p(`super_admin`,-1)]]),_:1}),m(f,{value:`operator`},{default:u(()=>[...n[10]||=[p(`operator`,-1)]]),_:1}),m(f,{value:`viewer`},{default:u(()=>[...n[11]||=[p(`viewer`,-1)]]),_:1})]),_:1},8,[`value`,`onChange`])]),_:1}),m(a,{title:`状态`},{default:u(({record:e})=>[m(g,{checked:e.isActive,onChange:t=>D(e,{isActive:t})},null,8,[`checked`,`onChange`])]),_:1}),m(a,{title:`会话`,"data-index":`activeSessions`}),m(a,{title:`最近登录`},{default:u(({record:e})=>[p(o(c(_)(e.lastLoginAt)),1)]),_:1}),m(a,{title:`操作`,width:230},{default:u(({record:e})=>[m(T,null,{default:u(()=>[m(r,{size:`small`,onClick:t=>S.value=e},{default:u(()=>[...n[12]||=[p(`重置密码`,-1)]]),_:1},8,[`onClick`]),m(r,{size:`small`,danger:``,onClick:t=>k(e)},{default:u(()=>[...n[13]||=[p(`撤销会话`,-1)]]),_:1},8,[`onClick`])]),_:2},1024)]),_:1})]),_:1},8,[`data-source`,`loading`]),m(F,{open:x.value,"onUpdate:open":n[4]||=e=>x.value=e,title:`新建管理员`,"ok-text":`创建`,onOk:E},{default:u(()=>[m(P,{layout:`vertical`},{default:u(()=>[m(M,{label:`用户名`},{default:u(()=>[m(j,{value:C.username,"onUpdate:value":n[1]||=e=>C.username=e},null,8,[`value`])]),_:1}),m(M,{label:`初始密码`},{default:u(()=>[m(N,{value:C.password,"onUpdate:value":n[2]||=e=>C.password=e},null,8,[`value`])]),_:1}),m(M,{label:`角色`},{default:u(()=>[m(h,{value:C.role,"onUpdate:value":n[3]||=e=>C.role=e},{default:u(()=>[m(f,{value:`operator`},{default:u(()=>[...n[14]||=[p(`operator`,-1)]]),_:1}),m(f,{value:`viewer`},{default:u(()=>[...n[15]||=[p(`viewer`,-1)]]),_:1}),m(f,{value:`super_admin`},{default:u(()=>[...n[16]||=[p(`super_admin`,-1)]]),_:1})]),_:1},8,[`value`])]),_:1})]),_:1})]),_:1},8,[`open`]),m(F,{open:!!S.value,title:`重置密码`,"ok-text":`重置并撤销会话`,onCancel:n[6]||=e=>S.value=null,onOk:O},{default:u(()=>[m(N,{value:w.value,"onUpdate:value":n[5]||=e=>w.value=e,placeholder:`至少 12 位的新密码`},null,8,[`value`])]),_:1},8,[`open`])],64)}}}),[[`__scopeId`,`data-v-ac276d87`]]);export{b as default};
@@ -0,0 +1 @@
import{$ as e,F as t,M as n,R as r,b as i,bt as a,g as o,ot as s,p as c,q as l,s as u,t as d,v as f,y as p}from"./api-DftvpHMa.js";import{t as m}from"./time-pIfF89ap.js";import{t as h}from"./_plugin-vue_export-helper-BDNMzG2s.js";var g={class:`toolbar`},_=h(i({__name:`Audit`,setup(i){let h=e(!1),_=e(``),v=e([]),y=e(0),b=e(1);async function x(e=b.value){h.value=!0;try{let t=await d.auditLogs({page:e,limit:50,username:_.value||void 0});v.value=t.list,y.value=t.total,b.value=e}finally{h.value=!1}}return n(()=>x()),(e,n)=>{let i=r(`a-input-search`),d=r(`a-table-column`),S=r(`a-tag`),C=r(`a-table`),w=r(`a-pagination`);return t(),o(u,null,[c(`div`,g,[n[3]||=c(`div`,null,[c(`h2`,null,`操作审计`),c(`span`,null,`登录、认证与后台写操作`)],-1),p(i,{value:_.value,"onUpdate:value":n[0]||=e=>_.value=e,placeholder:`管理员用户名`,style:{width:`260px`},onSearch:n[1]||=e=>x(1)},null,8,[`value`])]),p(C,{"data-source":v.value,loading:h.value,"row-key":`id`,pagination:!1,size:`small`},{default:l(()=>[p(d,{title:`时间`,width:170},{default:l(({record:e})=>[f(a(s(m)(e.createdAt)),1)]),_:1}),p(d,{title:`管理员`,"data-index":`username`,width:130}),p(d,{title:`动作`,"data-index":`action`}),p(d,{title:`状态`,width:90},{default:l(({record:e})=>[p(S,{color:e.success?`green`:`red`},{default:l(()=>[f(a(e.statusCode),1)]),_:2},1032,[`color`])]),_:1}),p(d,{title:`IP`,"data-index":`ipAddress`,width:140})]),_:1},8,[`data-source`,`loading`]),p(w,{current:b.value,"onUpdate:current":n[2]||=e=>b.value=e,total:y.value,"page-size":50,"show-size-changer":!1,style:{"margin-top":`16px`,"text-align":`right`},onChange:x},null,8,[`current`,`total`])],64)}}}),[[`__scopeId`,`data-v-4248d2d4`]]);export{_ as default};
@@ -0,0 +1 @@
.toolbar[data-v-4248d2d4]{justify-content:space-between;align-items:center;margin-bottom:18px;display:flex}h2[data-v-4248d2d4]{margin:0 0 3px;font-size:20px}.toolbar span[data-v-4248d2d4]{color:#8c8c8c;font-size:12px}
@@ -0,0 +1 @@
import{$ as e,F as t,M as n,R as r,b as i,bt as a,g as o,h as s,m as c,ot as l,p as u,q as d,s as f,t as p,v as m,y as h}from"./api-DftvpHMa.js";import{a as g}from"./config-provider-DjHSmQsy.js";import{n as _,t as v}from"./EditOutlined-BANF15gL.js";import{t as y}from"./DeleteOutlined-9xCyM9wU.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=i({__name:`Avatars`,setup(i){let x=e([]),S=e(!0),C=e(!1),w=e(null),T=e({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}];n(D);async function D(){S.value=!0;try{x.value=await p.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 p.updateAvatar(w.value.id,e):await p.createAvatar(e),g.success(w.value?`已更新`:`已创建`),C.value=!1,D()}async function j(e){await p.deleteAvatar(e),g.success(`已删除`),D()}return(e,n)=>{let i=r(`a-button`),p=r(`a-tag`),g=r(`a-popconfirm`),D=r(`a-table`),M=r(`a-input`),N=r(`a-form-item`),P=r(`a-col`),F=r(`a-row`),I=r(`a-switch`),L=r(`a-form`),R=r(`a-modal`);return t(),o(f,null,[u(`div`,b,[n[7]||=u(`h2`,null,`AI 形象管理`,-1),h(i,{type:`primary`,onClick:O},{default:d(()=>[h(l(_)),n[6]||=m(` 新建形象`,-1)]),_:1})]),h(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:d(({column:e,record:n})=>[e.key===`on`?(t(),c(p,{key:0,color:n.isEnabled?`green`:`default`},{default:d(()=>[m(a(n.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):s(``,!0),e.key===`act`?(t(),o(f,{key:1},[h(i,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(n)},{default:d(()=>[h(l(v))]),_:1},8,[`onClick`]),h(g,{title:`确定删除?`,onConfirm:e=>j(n.id)},{default:d(()=>[h(i,{size:`small`,danger:``},{default:d(()=>[h(l(y))]),_:1})]),_:1},8,[`onConfirm`])],64)):s(``,!0)]),_:1},8,[`dataSource`,`loading`]),h(R,{open:C.value,"onUpdate:open":n[5]||=e=>C.value=e,title:w.value?`编辑形象`:`新建形象`,onOk:A,width:500},{default:d(()=>[h(L,{layout:`vertical`,style:{"margin-top":`8px`}},{default:d(()=>[h(F,{gutter:12},{default:d(()=>[h(P,{span:12},{default:d(()=>[h(N,{label:`Key`},{default:d(()=>[h(M,{value:T.value.key,"onUpdate:value":n[0]||=e=>T.value.key=e,placeholder:`cat`},null,8,[`value`])]),_:1})]),_:1}),h(P,{span:12},{default:d(()=>[h(N,{label:`默认名`},{default:d(()=>[h(M,{value:T.value.name,"onUpdate:value":n[1]||=e=>T.value.name=e,placeholder:`小账喵`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),h(N,{label:`口癖后缀`},{default:d(()=>[h(M,{value:T.value.speechTic,"onUpdate:value":n[2]||=e=>T.value.speechTic=e,placeholder:`喵 / 汪 / 留空=无口癖`},null,8,[`value`]),n[8]||=u(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`口癖跟随形象——决策 20`,-1)]),_:1}),h(N,{label:`头像图片 URL`},{default:d(()=>[h(M,{value:T.value.imageUrl,"onUpdate:value":n[3]||=e=>T.value.imageUrl=e,placeholder:`可选,CDN 地址`},null,8,[`value`])]),_:1}),h(N,{label:`是否启用`},{default:d(()=>[h(I,{checked:T.value.isEnabled,"onUpdate:checked":n[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,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};
@@ -0,0 +1 @@
.auth-page[data-v-45e404bc]{background:#f5f6f7;place-items:center;min-height:100vh;padding:24px;display:grid}.auth-panel[data-v-45e404bc]{background:#fff;border:1px solid #e6e8eb;border-radius:8px;width:min(440px,100%);padding:32px;box-shadow:0 12px 32px #0000000f}.brand[data-v-45e404bc]{color:#1677ff;font-size:14px;font-weight:700}h1[data-v-45e404bc]{margin:8px 0 24px;font-size:24px}
@@ -0,0 +1 @@
import{$ as e,F as t,Q as n,R as r,b as i,g as a,p as o,q as s,v as c,y as l}from"./api-DftvpHMa.js";import{a as u}from"./config-provider-DjHSmQsy.js";import{a as d,t as f}from"./auth-BCjWWmZ9.js";import{t as p}from"./_plugin-vue_export-helper-BDNMzG2s.js";var m={class:`auth-page`},h={class:`auth-panel`},g=p(i({__name:`ChangePassword`,setup(i){let p=d(),g=n({currentPassword:``,newPassword:``,confirmPassword:``}),_=e(!1);async function v(){if(g.newPassword.length<12)return u.error(`新密码至少 12 位`);if(g.newPassword!==g.confirmPassword)return u.error(`两次输入的新密码不一致`);_.value=!0;try{await f.changePassword(g.currentPassword,g.newPassword),u.success(`密码已更新`),await p.replace(`/dashboard`)}catch(e){u.error(e.response?.data?.message||`密码修改失败`)}finally{_.value=!1}}async function y(){await f.logout(),await p.replace(`/login`)}return(e,n)=>{let i=r(`a-input-password`),u=r(`a-form-item`),d=r(`a-button`),f=r(`a-space`),p=r(`a-form`);return t(),a(`main`,m,[o(`section`,h,[n[5]||=o(`div`,{class:`brand`},`记之 Admin`,-1),n[6]||=o(`h1`,null,`修改初始密码`,-1),l(p,{layout:`vertical`,onFinish:v},{default:s(()=>[l(u,{label:`当前密码`,required:``},{default:s(()=>[l(i,{value:g.currentPassword,"onUpdate:value":n[0]||=e=>g.currentPassword=e,autocomplete:`current-password`},null,8,[`value`])]),_:1}),l(u,{label:`新密码`,required:``},{default:s(()=>[l(i,{value:g.newPassword,"onUpdate:value":n[1]||=e=>g.newPassword=e,autocomplete:`new-password`},null,8,[`value`])]),_:1}),l(u,{label:`确认新密码`,required:``},{default:s(()=>[l(i,{value:g.confirmPassword,"onUpdate:value":n[2]||=e=>g.confirmPassword=e,autocomplete:`new-password`},null,8,[`value`])]),_:1}),l(f,{style:{width:`100%`,"justify-content":`flex-end`}},{default:s(()=>[l(d,{onClick:y},{default:s(()=>[...n[3]||=[c(`退出登录`,-1)]]),_:1}),l(d,{type:`primary`,"html-type":`submit`,loading:_.value},{default:s(()=>[...n[4]||=[c(`保存密码`,-1)]]),_:1},8,[`loading`])]),_:1})]),_:1})])])}}}),[[`__scopeId`,`data-v-45e404bc`]]);export{g 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,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};
@@ -0,0 +1 @@
import{$ as e,F as t,L as n,M as r,R as i,b as a,bt as o,f as s,g as c,m as l,ot as u,p as d,q as f,s as p,t as m,v as h,y as g}from"./api-DftvpHMa.js";import{a as _}from"./config-provider-DjHSmQsy.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=a({__name:`Configs`,setup(a){let k=e([]),A=e(!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=s(()=>{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});r(P);async function P(){A.value=!0;try{k.value=await m.configs()}finally{A.value=!1}}let F=e(!1),I=e(null),L=e(``),R=s(()=>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 m.updateConfig(I.value.id,L.value),_.success(`已更新 ${I.value.key}`),F.value=!1,P())}return(e,r)=>{let a=i(`a-button`),s=i(`a-tag`),m=i(`a-card`),_=i(`a-col`),k=i(`a-row`),A=i(`a-tab-pane`),V=i(`a-tabs`),H=i(`a-switch`),U=i(`a-input-number`),W=i(`a-select`),G=i(`a-input`),K=i(`a-modal`);return t(),c(`div`,null,[d(`div`,y,[r[6]||=d(`h2`,{style:{margin:`0`}},`品牌配置`,-1),g(a,{onClick:P},{default:f(()=>[...r[5]||=[h(`刷新`,-1)]]),_:1})]),g(V,null,{default:f(()=>[(t(),c(p,null,n(M,e=>g(A,{key:e.key,tab:e.label},{default:f(()=>[g(k,{gutter:[16,12]},{default:f(()=>[(t(!0),c(p,null,n(N.value[e.key],e=>(t(),l(_,{key:e.id,span:8},{default:f(()=>[g(m,{size:`small`,hoverable:``,onClick:t=>z(e)},{default:f(()=>[d(`div`,b,[d(`div`,null,[d(`div`,x,o(j[e.key]?.label||e.key),1),d(`div`,S,o(j[e.key]?.desc||``),1)]),g(s,{color:`blue`,style:{"margin-left":`8px`}},{default:f(()=>[h(`v`+o(e.version),1)]),_:2},1024)]),j[e.key]?.type===`switch`?(t(),c(`div`,C,[e.value===`true`?(t(),c(`span`,w,`✅ 已开启`)):(t(),c(`span`,T,`❌ 已关闭`))])):(t(),c(`div`,E,o(e.key.includes(`key`)?`••••••••`:e.value||`(空)`),1)),d(`div`,D,o(u(v)(e.updatedAt)),1)]),_:2},1032,[`onClick`])]),_:2},1024))),128))]),_:2},1024)]),_:2},1032,[`tab`])),64))]),_:1}),g(K,{open:F.value,"onUpdate:open":r[4]||=e=>F.value=e,title:`编辑配置: ${I.value?.key}`,onOk:B,width:440},{default:f(()=>[d(`div`,O,o(R.value?.desc),1),R.value?.type===`switch`?(t(),l(H,{key:0,checked:L.value===`true`,onChange:r[0]||=e=>L.value=String(e),"checked-children":`开启`,"un-checked-children":`关闭`},null,8,[`checked`])):R.value?.type===`number`?(t(),l(U,{key:1,value:L.value,"onUpdate:value":r[1]||=e=>L.value=e,style:{width:`100%`}},null,8,[`value`])):R.value?.type===`select`&&R.value.options?(t(),l(W,{key:2,value:L.value,"onUpdate:value":r[2]||=e=>L.value=e,style:{width:`100%`},options:R.value.options.map(e=>({value:e,label:e}))},null,8,[`value`,`options`])):(t(),l(G,{key:3,value:L.value,"onUpdate:value":r[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
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{y as e}from"./api-DftvpHMa.js";import{n as t}from"./CheckCircleOutlined-Cj-UZA8Z.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:`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};
@@ -0,0 +1 @@
import{y as e}from"./api-DftvpHMa.js";import{n as t}from"./CheckCircleOutlined-Cj-UZA8Z.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};
@@ -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:`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 @@
.auth-page[data-v-7f269c01]{background:#f5f6f7;place-items:center;min-height:100vh;padding:24px;display:grid}.auth-panel[data-v-7f269c01]{background:#fff;border:1px solid #e6e8eb;border-radius:8px;width:min(400px,100%);padding:32px;box-shadow:0 12px 32px #0000000f}.brand[data-v-7f269c01]{color:#1677ff;font-size:14px;font-weight:700}h1[data-v-7f269c01]{margin:8px 0 24px;font-size:24px}.ant-alert[data-v-7f269c01]{margin-bottom:18px}
@@ -0,0 +1 @@
import{$ as e,F as t,Q as n,R as r,b as i,g as a,h as o,m as s,p as c,q as l,v as u,y as d}from"./api-DftvpHMa.js";import{a as f,i as p,t as m}from"./auth-BCjWWmZ9.js";import{t as h}from"./_plugin-vue_export-helper-BDNMzG2s.js";var g={class:`auth-page`},_={class:`auth-panel`},v=h(i({__name:`Login`,setup(i){let h=p(),v=f(),y=n({username:``,password:``}),b=e(!1),x=e(``);async function S(){if(!(!y.username.trim()||!y.password)){b.value=!0,x.value=``;try{if((await m.login(y.username,y.password)).mustChangePassword)await v.replace(`/change-password`);else{let e=typeof h.query.redirect==`string`?h.query.redirect:`/dashboard`;await v.replace(e)}}catch(e){x.value=e.response?.data?.message||`登录失败,请检查用户名和密码`}finally{b.value=!1}}}return(e,n)=>{let i=r(`a-alert`),f=r(`a-input`),p=r(`a-form-item`),m=r(`a-input-password`),h=r(`a-button`),v=r(`a-form`);return t(),a(`main`,g,[c(`section`,_,[n[3]||=c(`div`,{class:`brand`},`记之 Admin`,-1),n[4]||=c(`h1`,null,`管理后台登录`,-1),x.value?(t(),s(i,{key:0,type:`error`,message:x.value,"show-icon":``},null,8,[`message`])):o(``,!0),d(v,{layout:`vertical`,onFinish:S},{default:l(()=>[d(p,{label:`用户名`,required:``},{default:l(()=>[d(f,{value:y.username,"onUpdate:value":n[0]||=e=>y.username=e,autocomplete:`username`,size:`large`,autofocus:``},null,8,[`value`])]),_:1}),d(p,{label:`密码`,required:``},{default:l(()=>[d(m,{value:y.password,"onUpdate:value":n[1]||=e=>y.password=e,autocomplete:`current-password`,size:`large`},null,8,[`value`])]),_:1}),d(h,{type:`primary`,"html-type":`submit`,size:`large`,block:``,loading:b.value},{default:l(()=>[...n[2]||=[u(`登录`,-1)]]),_:1},8,[`loading`])]),_:1})])])}}}),[[`__scopeId`,`data-v-7f269c01`]]);export{v 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,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};
@@ -0,0 +1 @@
import{$ as e,F as t,M as n,R as r,b as i,bt as a,g as o,h as s,m as c,ot as l,p as u,q as d,s as f,t as p,v as m,y as h}from"./api-DftvpHMa.js";import{a as g}from"./config-provider-DjHSmQsy.js";import{n as _,t as v}from"./EditOutlined-BANF15gL.js";import{t as y}from"./DeleteOutlined-9xCyM9wU.js";var b={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},x=i({__name:`Personas`,setup(i){let x=e([]),S=e(!0),C=e(!1),w=e(null),T=e({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}];n(D);async function D(){S.value=!0;try{x.value=await p.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 p.updatePersona(w.value.id,e):await p.createPersona(e),g.success(w.value?`已更新(版本号+1`:`已创建`),C.value=!1,D()}async function j(e){await p.deletePersona(e),g.success(`已删除`),D()}return(e,n)=>{let i=r(`a-button`),p=r(`a-tag`),g=r(`a-popconfirm`),D=r(`a-table`),M=r(`a-input`),N=r(`a-form-item`),P=r(`a-col`),F=r(`a-row`),I=r(`a-textarea`),L=r(`a-switch`),R=r(`a-form`),z=r(`a-modal`);return t(),o(f,null,[u(`div`,b,[n[8]||=u(`h2`,null,`AI 性格管理`,-1),h(i,{type:`primary`,onClick:O},{default:d(()=>[h(l(_)),n[7]||=m(` 新建性格`,-1)]),_:1})]),h(D,{columns:E,dataSource:x.value,loading:S.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:d(({column:e,record:n})=>[e.key===`on`?(t(),c(p,{key:0,color:n.isEnabled?`green`:`default`},{default:d(()=>[m(a(n.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):s(``,!0),e.key===`act`?(t(),o(f,{key:1},[h(i,{size:`small`,style:{"margin-right":`6px`},onClick:e=>k(n)},{default:d(()=>[h(l(v))]),_:1},8,[`onClick`]),h(g,{title:`确定删除?`,onConfirm:e=>j(n.id)},{default:d(()=>[h(i,{size:`small`,danger:``},{default:d(()=>[h(l(y))]),_:1})]),_:1},8,[`onConfirm`])],64)):s(``,!0)]),_:1},8,[`dataSource`,`loading`]),h(z,{open:C.value,"onUpdate:open":n[6]||=e=>C.value=e,title:w.value?`编辑性格`:`新建性格`,onOk:A,width:560},{default:d(()=>[h(R,{layout:`vertical`,style:{"margin-top":`8px`}},{default:d(()=>[h(F,{gutter:12},{default:d(()=>[h(P,{span:12},{default:d(()=>[h(N,{label:`Key`},{default:d(()=>[h(M,{value:T.value.key,"onUpdate:value":n[0]||=e=>T.value.key=e,placeholder:`sassy_cat`},null,8,[`value`])]),_:1})]),_:1}),h(P,{span:12},{default:d(()=>[h(N,{label:`名称`},{default:d(()=>[h(M,{value:T.value.name,"onUpdate:value":n[1]||=e=>T.value.name=e,placeholder:`毒舌猫娘`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),h(N,{label:`描述`},{default:d(()=>[h(M,{value:T.value.description,"onUpdate:value":n[2]||=e=>T.value.description=e,placeholder:`一句话描述`},null,8,[`value`])]),_:1}),h(N,{label:`示例台词`},{default:d(()=>[h(M,{value:T.value.sampleLine,"onUpdate:value":n[3]||=e=>T.value.sampleLine=e,placeholder:`这句会展示给用户选性格时看`},null,8,[`value`])]),_:1}),h(N,{label:`Prompt 模板`},{default:d(()=>[h(I,{value:T.value.promptTemplate,"onUpdate:value":n[4]||=e=>T.value.promptTemplate=e,rows:5,placeholder:`系统提示词模板,支持 {tic} 占位符,此字段可调不发版`},null,8,[`value`]),n[9]||=u(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},[m(` ⚡ 调这个不需要发版,前端实时生效。可用占位符:`),u(`code`,null,`{'{tic}'}`),m(` = 口癖(喵/汪/无) `)],-1)]),_:1}),h(N,{label:`是否启用`},{default:d(()=>[h(L,{checked:T.value.isEnabled,"onUpdate:checked":n[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
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{y as e}from"./api-DftvpHMa.js";import{n as t}from"./CheckCircleOutlined-Cj-UZA8Z.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M909.1 209.3l-56.4 44.1C775.8 155.1 656.2 92 521.9 92 290 92 102.3 279.5 102 511.5 101.7 743.7 289.8 932 521.9 932c181.3 0 335.8-115 394.6-276.1 1.5-4.2-.7-8.9-4.9-10.3l-56.7-19.5a8 8 0 00-10.1 4.8c-1.8 5-3.8 10-5.9 14.9-17.3 41-42.1 77.8-73.7 109.4A344.77 344.77 0 01655.9 829c-42.3 17.9-87.4 27-133.8 27-46.5 0-91.5-9.1-133.8-27A341.5 341.5 0 01279 755.2a342.16 342.16 0 01-73.7-109.4c-17.9-42.4-27-87.4-27-133.9s9.1-91.5 27-133.9c17.3-41 42.1-77.8 73.7-109.4 31.6-31.6 68.4-56.4 109.3-73.8 42.3-17.9 87.4-27 133.8-27 46.5 0 91.5 9.1 133.8 27a341.5 341.5 0 01109.3 73.8c9.9 9.9 19.2 20.4 27.8 31.4l-60.2 47a8 8 0 003 14.1l175.6 43c5 1.2 9.9-2.6 9.9-7.7l.8-180.9c-.1-6.6-7.8-10.3-13-6.2z`}}]},name:`reload`,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=`ReloadOutlined`,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:`M909.1 209.3l-56.4 44.1C775.8 155.1 656.2 92 521.9 92 290 92 102.3 279.5 102 511.5 101.7 743.7 289.8 932 521.9 932c181.3 0 335.8-115 394.6-276.1 1.5-4.2-.7-8.9-4.9-10.3l-56.7-19.5a8 8 0 00-10.1 4.8c-1.8 5-3.8 10-5.9 14.9-17.3 41-42.1 77.8-73.7 109.4A344.77 344.77 0 01655.9 829c-42.3 17.9-87.4 27-133.8 27-46.5 0-91.5-9.1-133.8-27A341.5 341.5 0 01279 755.2a342.16 342.16 0 01-73.7-109.4c-17.9-42.4-27-87.4-27-133.9s9.1-91.5 27-133.9c17.3-41 42.1-77.8 73.7-109.4 31.6-31.6 68.4-56.4 109.3-73.8 42.3-17.9 87.4-27 133.8-27 46.5 0 91.5 9.1 133.8 27a341.5 341.5 0 01109.3 73.8c9.9 9.9 19.2 20.4 27.8 31.4l-60.2 47a8 8 0 003 14.1l175.6 43c5 1.2 9.9-2.6 9.9-7.7l.8-180.9c-.1-6.6-7.8-10.3-13-6.2z`}}]},name:`reload`,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=`ReloadOutlined`,a.inheritAttrs=!1;export{a as t};
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 @@
import{$ as e,F as t,L as n,M as r,R as i,b as a,bt as o,g as s,h as c,m as l,ot as u,p as d,q as f,s as p,t as m,v as h,y as g}from"./api-DftvpHMa.js";import{a as _}from"./config-provider-DjHSmQsy.js";import{n as v,t as y}from"./EditOutlined-BANF15gL.js";import{t as b}from"./DeleteOutlined-9xCyM9wU.js";var x={style:{display:`flex`,"justify-content":`space-between`,"align-items":`center`,"margin-bottom":`16px`}},S={key:1,style:{color:`#ccc`}},C=a({__name:`Stickers`,setup(a){let C=e([]),w=e(!0),T=e(!1),E=e(null),D=e({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}];r(k);async function k(){w.value=!0;try{C.value=await m.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 m.updateSticker(E.value.id,e):await m.createSticker(e),_.success(E.value?`已更新`:`已创建`),T.value=!1,k()}async function N(e){await m.deleteSticker(e),_.success(`已删除`),k()}return(e,r)=>{let a=i(`a-button`),m=i(`a-tag`),_=i(`a-popconfirm`),k=i(`a-table`),P=i(`a-input`),F=i(`a-form-item`),I=i(`a-col`),L=i(`a-row`),R=i(`a-select-option`),z=i(`a-select`),B=i(`a-switch`),V=i(`a-form`),H=i(`a-modal`);return t(),s(p,null,[d(`div`,x,[r[8]||=d(`h2`,null,`表情包库`,-1),g(a,{type:`primary`,onClick:A},{default:f(()=>[g(u(v)),r[7]||=h(` 新建表情包`,-1)]),_:1})]),g(k,{columns:O,dataSource:C.value,loading:w.value,rowKey:`id`,size:`small`,pagination:{pageSize:10}},{bodyCell:f(({column:e,record:r})=>[e.key===`tags`?(t(),s(p,{key:0},[r.triggerTags?(t(!0),s(p,{key:0},n((r.triggerTags||``).split(`,`).filter(Boolean),e=>(t(),l(m,{key:e,color:`blue`,style:{margin:`1px 2px`}},{default:f(()=>[h(o(e),1)]),_:2},1024))),128)):(t(),s(`span`,S,`-`))],64)):c(``,!0),e.key===`on`?(t(),l(m,{key:1,color:r.isEnabled?`green`:`default`},{default:f(()=>[h(o(r.isEnabled?`启用`:`禁用`),1)]),_:2},1032,[`color`])):c(``,!0),e.key===`act`?(t(),s(p,{key:2},[g(a,{size:`small`,style:{"margin-right":`6px`},onClick:e=>j(r)},{default:f(()=>[g(u(y))]),_:1},8,[`onClick`]),g(_,{title:`确定删除?`,onConfirm:e=>N(r.id)},{default:f(()=>[g(a,{size:`small`,danger:``},{default:f(()=>[g(u(b))]),_:1})]),_:1},8,[`onConfirm`])],64)):c(``,!0)]),_:1},8,[`dataSource`,`loading`]),g(H,{open:T.value,"onUpdate:open":r[6]||=e=>T.value=e,title:E.value?`编辑表情包`:`新建表情包`,onOk:M,width:520},{default:f(()=>[g(V,{layout:`vertical`,style:{"margin-top":`8px`}},{default:f(()=>[g(L,{gutter:12},{default:f(()=>[g(I,{span:12},{default:f(()=>[g(F,{label:`Key`},{default:f(()=>[g(P,{value:D.value.key,"onUpdate:value":r[0]||=e=>D.value.key=e,placeholder:`salary`},null,8,[`value`])]),_:1})]),_:1}),g(I,{span:12},{default:f(()=>[g(F,{label:`名称`},{default:f(()=>[g(P,{value:D.value.label,"onUpdate:value":r[1]||=e=>D.value.label=e,placeholder:`发工资啦`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),g(L,{gutter:12},{default:f(()=>[g(I,{span:12},{default:f(()=>[g(F,{label:`分组`},{default:f(()=>[g(z,{value:D.value.groupKey,"onUpdate:value":r[2]||=e=>D.value.groupKey=e},{default:f(()=>[g(R,{value:`ai_exclusive`},{default:f(()=>[...r[9]||=[h(`🤖 AI 专属`,-1)]]),_:1}),g(R,{value:`classic`},{default:f(()=>[...r[10]||=[h(`📦 经典`,-1)]]),_:1})]),_:1},8,[`value`])]),_:1})]),_:1}),g(I,{span:12},{default:f(()=>[g(F,{label:`图片 URL`},{default:f(()=>[g(P,{value:D.value.imageUrl,"onUpdate:value":r[3]||=e=>D.value.imageUrl=e,placeholder:`可选 CDN 地址`},null,8,[`value`])]),_:1})]),_:1})]),_:1}),g(F,{label:`触发标签`},{default:f(()=>[g(P,{value:D.value.triggerTags,"onUpdate:value":r[4]||=e=>D.value.triggerTags=e,placeholder:`over_budget,salary,forgive,逗号分隔`},null,8,[`value`]),r[11]||=d(`div`,{style:{color:`#999`,"font-size":`11px`,"margin-top":`4px`}},`标签匹配用户场景,AI 自动选择对应表情包`,-1)]),_:1}),g(F,{label:`是否启用`},{default:f(()=>[g(B,{checked:D.value.isEnabled,"onUpdate:checked":r[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 _,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
@@ -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:`M824.2 699.9a301.55 301.55 0 00-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5a300.95 300.95 0 00-86.4 60.4C345 754.6 314 826.8 312 903.8a8 8 0 008 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5A226.62 226.62 0 01612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c.1 4.3 3.7 7.7 8 7.7h56a8 8 0 008-8.2c-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5a126.86 126.86 0 01-37.5-91.8c.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5A127.3 127.3 0 01612 612zM361.5 510.4c-.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1a127.54 127.54 0 01-38.7-95.4c.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204a8 8 0 008 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z`}}]},name:`team`,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=`TeamOutlined`,a.inheritAttrs=!1;export{a as t};
@@ -0,0 +1 @@
import{y as e}from"./api-DftvpHMa.js";import{n as t}from"./CheckCircleOutlined-Cj-UZA8Z.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M824.2 699.9a301.55 301.55 0 00-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5a300.95 300.95 0 00-86.4 60.4C345 754.6 314 826.8 312 903.8a8 8 0 008 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5A226.62 226.62 0 01612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c.1 4.3 3.7 7.7 8 7.7h56a8 8 0 008-8.2c-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5a126.86 126.86 0 01-37.5-91.8c.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5A127.3 127.3 0 01612 612zM361.5 510.4c-.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1a127.54 127.54 0 01-38.7-95.4c.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204a8 8 0 008 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z`}}]},name:`team`,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=`TeamOutlined`,a.inheritAttrs=!1;export{a as t};
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=(e,t)=>{let n=e.__vccOpts||e;for(let[e,r]of t)n[e]=r;return n};export{e as t};
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
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
+11 -7
View File
@@ -5,13 +5,17 @@
<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-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-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">
<script type="module" crossorigin src="/assets/index-DxeieaIN.js"></script>
<link rel="modulepreload" crossorigin href="/assets/api-DftvpHMa.js">
<link rel="modulepreload" crossorigin href="/assets/CheckCircleOutlined-Cj-UZA8Z.js">
<link rel="modulepreload" crossorigin href="/assets/config-provider-DjHSmQsy.js">
<link rel="modulepreload" crossorigin href="/assets/modal-B_MK8QJe.js">
<link rel="modulepreload" crossorigin href="/assets/dayjs.min-BCbhqiun.js">
<link rel="modulepreload" crossorigin href="/assets/EditOutlined-BANF15gL.js">
<link rel="modulepreload" crossorigin href="/assets/DeleteOutlined-9xCyM9wU.js">
<link rel="modulepreload" crossorigin href="/assets/ReloadOutlined-B8fhuZd5.js">
<link rel="modulepreload" crossorigin href="/assets/TeamOutlined-TB46PgwW.js">
<link rel="modulepreload" crossorigin href="/assets/auth-BCjWWmZ9.js">
<link rel="stylesheet" crossorigin href="/assets/index-B6VCboLO.css">
</head>
<body>