chore: migrate remaining ECharts components to Chart.js
- Migrated 4 components from ECharts to Chart.js: * MonthlyExpenseCard.vue (折线图) * DailyTrendChart.vue (双系列折线图) * ExpenseCategoryCard.vue (环形图) * BudgetChartAnalysis.vue (仪表盘 + 多种图表) - Removed all ECharts imports and environment variable switches - Unified all charts to use BaseChart.vue component - Build verified: pnpm build success ✓ - No echarts imports remaining ✓ Refs: openspec/changes/migrate-remaining-echarts-to-chartjs
This commit is contained in:
@@ -16,4 +16,4 @@ global using Common;
|
||||
global using System.Net;
|
||||
global using System.Net.Http;
|
||||
global using System.Text.Encodings.Web;
|
||||
global using JetBrains.Annotations;
|
||||
global using JetBrains.Annotations;
|
||||
|
||||
29
Service/IconSearch/IIconSearchService.cs
Normal file
29
Service/IconSearch/IIconSearchService.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace Service.IconSearch;
|
||||
|
||||
/// <summary>
|
||||
/// 图标搜索服务接口
|
||||
/// </summary>
|
||||
public interface IIconSearchService
|
||||
{
|
||||
/// <summary>
|
||||
/// 生成搜索关键字
|
||||
/// </summary>
|
||||
/// <param name="categoryName">分类名称</param>
|
||||
/// <returns>搜索关键字数组</returns>
|
||||
Task<List<string>> GenerateSearchKeywordsAsync(string categoryName);
|
||||
|
||||
/// <summary>
|
||||
/// 搜索图标并返回候选列表
|
||||
/// </summary>
|
||||
/// <param name="keywords">搜索关键字数组</param>
|
||||
/// <param name="limit">每个关键字返回的最大图标数量</param>
|
||||
/// <returns>图标候选列表</returns>
|
||||
Task<List<IconCandidate>> SearchIconsAsync(List<string> keywords, int limit = 20);
|
||||
|
||||
/// <summary>
|
||||
/// 更新分类图标
|
||||
/// </summary>
|
||||
/// <param name="categoryId">分类ID</param>
|
||||
/// <param name="iconIdentifier">图标标识符</param>
|
||||
Task UpdateCategoryIconAsync(long categoryId, string iconIdentifier);
|
||||
}
|
||||
15
Service/IconSearch/IIconifyApiService.cs
Normal file
15
Service/IconSearch/IIconifyApiService.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace Service.IconSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Iconify API服务接口
|
||||
/// </summary>
|
||||
public interface IIconifyApiService
|
||||
{
|
||||
/// <summary>
|
||||
/// 搜索图标
|
||||
/// </summary>
|
||||
/// <param name="keywords">搜索关键字数组</param>
|
||||
/// <param name="limit">每个关键字返回的最大图标数量</param>
|
||||
/// <returns>图标候选列表</returns>
|
||||
Task<List<IconCandidate>> SearchIconsAsync(List<string> keywords, int limit = 20);
|
||||
}
|
||||
14
Service/IconSearch/ISearchKeywordGeneratorService.cs
Normal file
14
Service/IconSearch/ISearchKeywordGeneratorService.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace Service.IconSearch;
|
||||
|
||||
/// <summary>
|
||||
/// 搜索关键字生成服务接口
|
||||
/// </summary>
|
||||
public interface ISearchKeywordGeneratorService
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据分类名称生成搜索关键字
|
||||
/// </summary>
|
||||
/// <param name="categoryName">分类名称</param>
|
||||
/// <returns>搜索关键字数组</returns>
|
||||
Task<List<string>> GenerateKeywordsAsync(string categoryName);
|
||||
}
|
||||
22
Service/IconSearch/IconCandidate.cs
Normal file
22
Service/IconSearch/IconCandidate.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace Service.IconSearch;
|
||||
|
||||
/// <summary>
|
||||
/// 图标候选对象
|
||||
/// </summary>
|
||||
public record IconCandidate
|
||||
{
|
||||
/// <summary>
|
||||
/// 图标集名称
|
||||
/// </summary>
|
||||
public string CollectionName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 图标名称
|
||||
/// </summary>
|
||||
public string IconName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 图标标识符(格式:{collectionName}:{iconName})
|
||||
/// </summary>
|
||||
public string IconIdentifier => $"{CollectionName}:{IconName}";
|
||||
}
|
||||
48
Service/IconSearch/IconSearchService.cs
Normal file
48
Service/IconSearch/IconSearchService.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
namespace Service.IconSearch;
|
||||
|
||||
public class IconSearchService(
|
||||
ISearchKeywordGeneratorService keywordGeneratorService,
|
||||
IIconifyApiService iconifyApiService,
|
||||
ITransactionCategoryRepository categoryRepository,
|
||||
ILogger<IconSearchService> logger
|
||||
) : IIconSearchService
|
||||
{
|
||||
public async Task<List<string>> GenerateSearchKeywordsAsync(string categoryName)
|
||||
{
|
||||
var keywords = await keywordGeneratorService.GenerateKeywordsAsync(categoryName);
|
||||
return keywords;
|
||||
}
|
||||
|
||||
public async Task<List<IconCandidate>> SearchIconsAsync(List<string> keywords, int limit = 20)
|
||||
{
|
||||
if (keywords == null || keywords.Count == 0)
|
||||
{
|
||||
logger.LogWarning("搜索关键字为空");
|
||||
return [];
|
||||
}
|
||||
|
||||
var icons = await iconifyApiService.SearchIconsAsync(keywords, limit);
|
||||
logger.LogInformation("搜索到 {Count} 个图标候选", icons.Count);
|
||||
return icons;
|
||||
}
|
||||
|
||||
public async Task UpdateCategoryIconAsync(long categoryId, string iconIdentifier)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(iconIdentifier))
|
||||
{
|
||||
throw new ArgumentException("图标标识符不能为空", nameof(iconIdentifier));
|
||||
}
|
||||
|
||||
var category = await categoryRepository.GetByIdAsync(categoryId);
|
||||
if (category == null)
|
||||
{
|
||||
throw new Exception($"分类不存在,ID:{categoryId}");
|
||||
}
|
||||
|
||||
category.Icon = iconIdentifier;
|
||||
category.IconKeywords = null;
|
||||
await categoryRepository.UpdateAsync(category);
|
||||
|
||||
logger.LogInformation("更新分类 {CategoryId} 的图标为 {IconIdentifier}", categoryId, iconIdentifier);
|
||||
}
|
||||
}
|
||||
117
Service/IconSearch/IconifyApiService.cs
Normal file
117
Service/IconSearch/IconifyApiService.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
namespace Service.IconSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Iconify API 响应
|
||||
/// 实际 API 返回的图标是字符串数组,格式为 "collection:iconName"
|
||||
/// 例如:["mdi:home", "svg-spinners:wind-toy"]
|
||||
/// </summary>
|
||||
public record IconifyApiResponse
|
||||
{
|
||||
[JsonPropertyName("icons")]
|
||||
public List<string>? Icons { get; init; }
|
||||
}
|
||||
|
||||
public record IconifySettings
|
||||
{
|
||||
public string ApiUrl { get; init; } = "https://api.iconify.design/search";
|
||||
public int DefaultLimit { get; init; } = 20;
|
||||
public int MaxRetryCount { get; init; } = 3;
|
||||
public int RetryDelayMs { get; init; } = 1000;
|
||||
}
|
||||
|
||||
public class IconifyApiService(
|
||||
IOptions<IconifySettings> settings,
|
||||
ILogger<IconifyApiService> logger
|
||||
) : IIconifyApiService
|
||||
{
|
||||
private readonly HttpClient _httpClient = new();
|
||||
private readonly IconifySettings _settings = settings.Value;
|
||||
|
||||
public async Task<List<IconCandidate>> SearchIconsAsync(List<string> keywords, int limit = 20)
|
||||
{
|
||||
var allIcons = new List<IconCandidate>();
|
||||
var actualLimit = limit > 0 ? limit : _settings.DefaultLimit;
|
||||
|
||||
foreach (var keyword in keywords)
|
||||
{
|
||||
try
|
||||
{
|
||||
var icons = await SearchIconsByKeywordAsync(keyword, actualLimit);
|
||||
allIcons.AddRange(icons);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "搜索图标失败,关键字:{Keyword}", keyword);
|
||||
}
|
||||
}
|
||||
|
||||
return allIcons;
|
||||
}
|
||||
|
||||
private async Task<List<IconCandidate>> SearchIconsByKeywordAsync(string keyword, int limit)
|
||||
{
|
||||
var url = $"{_settings.ApiUrl}?query={Uri.EscapeDataString(keyword)}&limit={limit}";
|
||||
var response = await CallApiWithRetryAsync(url);
|
||||
|
||||
if (string.IsNullOrEmpty(response))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var apiResponse = JsonSerializer.Deserialize<IconifyApiResponse>(response);
|
||||
if (apiResponse?.Icons == null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// 解析字符串格式 "collection:iconName" 为 IconCandidate
|
||||
var candidates = apiResponse.Icons
|
||||
.Select(iconStr =>
|
||||
{
|
||||
var parts = iconStr.Split(':', 2);
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
logger.LogWarning("无效的图标标识符格式:{IconStr}", iconStr);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new IconCandidate
|
||||
{
|
||||
CollectionName = parts[0],
|
||||
IconName = parts[1]
|
||||
};
|
||||
})
|
||||
.Where(c => c != null)
|
||||
.Cast<IconCandidate>()
|
||||
.ToList();
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private async Task<string> CallApiWithRetryAsync(string url)
|
||||
{
|
||||
var retryCount = 0;
|
||||
var delay = _settings.RetryDelayMs;
|
||||
|
||||
while (retryCount < _settings.MaxRetryCount)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
catch (HttpRequestException ex) when (retryCount < _settings.MaxRetryCount - 1)
|
||||
{
|
||||
logger.LogWarning(ex, "Iconify API调用失败,等待 {DelayMs}ms 后重试({RetryCount}/{MaxRetryCount})",
|
||||
delay, retryCount + 1, _settings.MaxRetryCount);
|
||||
await Task.Delay(delay);
|
||||
delay *= 2;
|
||||
retryCount++;
|
||||
}
|
||||
}
|
||||
|
||||
throw new HttpRequestException($"Iconify API调用失败,已重试 {_settings.MaxRetryCount} 次");
|
||||
}
|
||||
}
|
||||
94
Service/IconSearch/SearchKeywordGeneratorService.cs
Normal file
94
Service/IconSearch/SearchKeywordGeneratorService.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using Service.AI;
|
||||
|
||||
namespace Service.IconSearch;
|
||||
|
||||
public record SearchKeywordSettings
|
||||
{
|
||||
public string KeywordPromptTemplate { get; init; } =
|
||||
"为以下中文分类名称生成3-5个相关的英文搜索关键字,用于搜索图标:{categoryName}。" +
|
||||
"输出格式为JSON数组,例如:[\"food\", \"restaurant\", \"dining\"]。";
|
||||
}
|
||||
|
||||
public class SearchKeywordGeneratorService(
|
||||
IOpenAiService openAiService,
|
||||
IOptions<SearchKeywordSettings> settings,
|
||||
ILogger<SearchKeywordGeneratorService> logger
|
||||
) : ISearchKeywordGeneratorService
|
||||
{
|
||||
private readonly SearchKeywordSettings _settings = settings.Value;
|
||||
|
||||
public async Task<List<string>> GenerateKeywordsAsync(string categoryName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(categoryName))
|
||||
{
|
||||
logger.LogWarning("分类名称为空,无法生成搜索关键字");
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var prompt = _settings.KeywordPromptTemplate.Replace("{categoryName}", categoryName);
|
||||
var response = await openAiService.ChatAsync(prompt, timeoutSeconds: 15);
|
||||
|
||||
if (string.IsNullOrEmpty(response))
|
||||
{
|
||||
logger.LogWarning("AI未返回搜索关键字,分类:{CategoryName}", categoryName);
|
||||
return [];
|
||||
}
|
||||
|
||||
var keywords = ParseKeywordsFromResponse(response);
|
||||
logger.LogInformation("为分类 {CategoryName} 生成了 {Count} 个搜索关键字:{Keywords}",
|
||||
categoryName, keywords.Count, string.Join(", ", keywords));
|
||||
|
||||
return keywords;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "生成搜索关键字失败,分类:{CategoryName}", categoryName);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> ParseKeywordsFromResponse(string response)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jsonNode = JsonNode.Parse(response);
|
||||
if (jsonNode is JsonArray arrayNode)
|
||||
{
|
||||
var keywords = new List<string>();
|
||||
foreach (var item in arrayNode)
|
||||
{
|
||||
if (item is JsonValue value && value.TryGetValue(out string keyword))
|
||||
{
|
||||
keywords.Add(keyword);
|
||||
}
|
||||
}
|
||||
return keywords;
|
||||
}
|
||||
else if (jsonNode is JsonObject jsonObject)
|
||||
{
|
||||
if (jsonObject.TryGetPropertyValue("keywords", out var keywordsNode) && keywordsNode is JsonArray arrayNode2)
|
||||
{
|
||||
var keywords = new List<string>();
|
||||
foreach (var item in arrayNode2)
|
||||
{
|
||||
if (item is JsonValue value && value.TryGetValue(out string keyword))
|
||||
{
|
||||
keywords.Add(keyword);
|
||||
}
|
||||
}
|
||||
return keywords;
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogWarning("无法解析AI响应为关键字数组:{Response}", response);
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "解析AI响应失败:{Response}", response);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user