tmp
This commit is contained in:
9080
.pans/v2.pen
Normal file
9080
.pans/v2.pen
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,4 +14,11 @@ public class TransactionCategory : BaseEntity
|
||||
/// 交易类型(支出/收入)
|
||||
/// </summary>
|
||||
public TransactionType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图标(SVG格式,JSON数组存储5个图标供选择)
|
||||
/// 示例:["<svg>...</svg>", "<svg>...</svg>", ...]
|
||||
/// </summary>
|
||||
[Column(StringLength = -1)]
|
||||
public string? Icon { get; set; }
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ namespace Service.AI;
|
||||
|
||||
public interface IOpenAiService
|
||||
{
|
||||
Task<string?> ChatAsync(string systemPrompt, string userPrompt);
|
||||
Task<string?> ChatAsync(string prompt);
|
||||
Task<string?> ChatAsync(string systemPrompt, string userPrompt, int timeoutSeconds = 15);
|
||||
Task<string?> ChatAsync(string prompt, int timeoutSeconds = 15);
|
||||
IAsyncEnumerable<string> ChatStreamAsync(string systemPrompt, string userPrompt);
|
||||
IAsyncEnumerable<string> ChatStreamAsync(string prompt);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ public class OpenAiService(
|
||||
ILogger<OpenAiService> logger
|
||||
) : IOpenAiService
|
||||
{
|
||||
public async Task<string?> ChatAsync(string systemPrompt, string userPrompt)
|
||||
public async Task<string?> ChatAsync(string systemPrompt, string userPrompt, int timeoutSeconds = 15)
|
||||
{
|
||||
var cfg = aiSettings.Value;
|
||||
if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
|
||||
@@ -27,7 +27,7 @@ public class OpenAiService(
|
||||
}
|
||||
|
||||
using var http = new HttpClient();
|
||||
http.Timeout = TimeSpan.FromSeconds(15);
|
||||
http.Timeout = TimeSpan.FromSeconds(timeoutSeconds);
|
||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
|
||||
|
||||
var payload = new
|
||||
@@ -72,7 +72,7 @@ public class OpenAiService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> ChatAsync(string prompt)
|
||||
public async Task<string?> ChatAsync(string prompt, int timeoutSeconds = 15)
|
||||
{
|
||||
var cfg = aiSettings.Value;
|
||||
if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
|
||||
@@ -84,7 +84,7 @@ public class OpenAiService(
|
||||
}
|
||||
|
||||
using var http = new HttpClient();
|
||||
http.Timeout = TimeSpan.FromSeconds(60 * 5);
|
||||
http.Timeout = TimeSpan.FromSeconds(timeoutSeconds);
|
||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
|
||||
|
||||
var payload = new
|
||||
|
||||
150
Service/Jobs/CategoryIconGenerationJob.cs
Normal file
150
Service/Jobs/CategoryIconGenerationJob.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
using Quartz;
|
||||
using Service.AI;
|
||||
|
||||
namespace Service.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 分类图标生成定时任务
|
||||
/// 每10分钟扫描一次,为没有图标的分类生成 5 个 SVG 图标
|
||||
/// </summary>
|
||||
public class CategoryIconGenerationJob(
|
||||
ITransactionCategoryRepository categoryRepository,
|
||||
IOpenAiService openAiService,
|
||||
ILogger<CategoryIconGenerationJob> logger) : IJob
|
||||
{
|
||||
private static readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
// 尝试获取锁,如果失败则跳过本次执行
|
||||
if (!await _semaphore.WaitAsync(0))
|
||||
{
|
||||
logger.LogInformation("上一个分类图标生成任务尚未完成,跳过本次执行");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
logger.LogInformation("开始执行分类图标生成任务");
|
||||
|
||||
// 查询所有分类,然后过滤出没有图标的
|
||||
var allCategories = await categoryRepository.GetAllAsync();
|
||||
var categoriesWithoutIcon = allCategories
|
||||
.Where(c => string.IsNullOrEmpty(c.Icon))
|
||||
.ToList();
|
||||
|
||||
if (categoriesWithoutIcon.Count == 0)
|
||||
{
|
||||
logger.LogInformation("所有分类都已有图标,跳过本次任务");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("发现 {Count} 个分类没有图标,开始生成", categoriesWithoutIcon.Count);
|
||||
|
||||
// 为每个分类生成图标
|
||||
foreach (var category in categoriesWithoutIcon)
|
||||
{
|
||||
try
|
||||
{
|
||||
await GenerateIconsForCategoryAsync(category);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "为分类 {CategoryName}(ID:{CategoryId}) 生成图标失败",
|
||||
category.Name, category.Id);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("分类图标生成任务执行完成");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "分类图标生成任务执行出错");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为单个分类生成 5 个 SVG 图标
|
||||
/// </summary>
|
||||
private async Task GenerateIconsForCategoryAsync(TransactionCategory category)
|
||||
{
|
||||
logger.LogInformation("正在为分类 {CategoryName}(ID:{CategoryId}) 生成图标",
|
||||
category.Name, category.Id);
|
||||
|
||||
var typeText = category.Type == TransactionType.Expense ? "支出" : "收入";
|
||||
|
||||
var systemPrompt = """
|
||||
你是一个专业的 SVG 图标设计师,擅长创作精美、富有表现力的图标。
|
||||
请根据分类名称和类型,生成 5 个风格迥异、视觉效果突出的 SVG 图标。
|
||||
|
||||
设计要求:
|
||||
1. 尺寸:24x24,viewBox="0 0 24 24"
|
||||
2. 色彩:使用丰富的渐变色和多色搭配,让图标更有吸引力和辨识度
|
||||
- 可以使用 <linearGradient> 或 <radialGradient> 创建渐变效果
|
||||
- 不同元素使用不同颜色,增加层次感
|
||||
- 根据分类含义选择合适的配色方案(如餐饮用暖色系、交通用蓝色系等)
|
||||
3. 设计风格:5 个图标必须风格明显不同,避免雷同
|
||||
- 第1个:扁平化风格,色彩鲜明,使用渐变
|
||||
- 第2个:线性风格,多色描边,细节丰富
|
||||
- 第3个:3D立体风格,使用阴影和高光效果
|
||||
- 第4个:卡通可爱风格,圆润造型,活泼配色
|
||||
- 第5个:现代简约风格,几何与曲线结合,优雅配色
|
||||
4. 细节丰富:不要只用简单的几何图形,添加特征性的细节元素
|
||||
- 例如:餐饮可以加刀叉、蒸汽、食材纹理等
|
||||
- 交通可以加轮胎、车窗、尾气等
|
||||
- 每个图标要有独特的视觉记忆点
|
||||
5. 图标要直观表达分类含义,让人一眼就能识别
|
||||
6. 只返回 JSON 数组格式,包含 5 个完整的 SVG 字符串,不要有任何其他文字说明
|
||||
|
||||
重要:每个 SVG 必须是自包含的完整代码,包含所有必要的 gradient 定义。
|
||||
""";
|
||||
|
||||
var userPrompt = $"""
|
||||
分类名称:{category.Name}
|
||||
分类类型:{typeText}
|
||||
|
||||
请为这个分类生成 5 个精美的、风格各异的彩色 SVG 图标。
|
||||
确保每个图标都有独特的视觉特征,不会与其他图标混淆。
|
||||
|
||||
返回格式(纯 JSON 数组,无其他内容):
|
||||
["<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>"]
|
||||
""";
|
||||
|
||||
var response = await openAiService.ChatAsync(systemPrompt, userPrompt, timeoutSeconds: 60 * 10);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(response))
|
||||
{
|
||||
logger.LogWarning("AI 未返回有效的图标数据,分类: {CategoryName}", category.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证返回的是有效的 JSON 数组
|
||||
try
|
||||
{
|
||||
var icons = JsonSerializer.Deserialize<List<string>>(response);
|
||||
if (icons == null || icons.Count != 5)
|
||||
{
|
||||
logger.LogWarning("AI 返回的图标数量不正确(期望5个),分类: {CategoryName}", category.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存图标到数据库
|
||||
category.Icon = response;
|
||||
await categoryRepository.UpdateAsync(category);
|
||||
|
||||
logger.LogInformation("成功为分类 {CategoryName}(ID:{CategoryId}) 生成并保存了 5 个图标",
|
||||
category.Name, category.Id);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogError(ex, "解析 AI 返回的图标数据失败,分类: {CategoryName},响应内容: {Response}",
|
||||
category.Name, response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,3 +76,30 @@ export const batchCreateCategories = (dataList) => {
|
||||
data: dataList
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定分类生成新的SVG图标
|
||||
* @param {number} categoryId - 分类ID
|
||||
* @returns {Promise<{success: boolean, data: string}>} 返回生成的SVG内容
|
||||
*/
|
||||
export const generateIcon = (categoryId) => {
|
||||
return request({
|
||||
url: '/TransactionCategory/GenerateIcon',
|
||||
method: 'post',
|
||||
data: { categoryId }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新分类的选中图标索引
|
||||
* @param {number} categoryId - 分类ID
|
||||
* @param {number} selectedIndex - 选中的图标索引
|
||||
* @returns {Promise<{success: boolean}>}
|
||||
*/
|
||||
export const updateSelectedIcon = (categoryId, selectedIndex) => {
|
||||
return request({
|
||||
url: '/TransactionCategory/UpdateSelectedIcon',
|
||||
method: 'post',
|
||||
data: { categoryId, selectedIndex }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,11 +56,33 @@
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
>
|
||||
<van-cell
|
||||
:title="category.name"
|
||||
is-link
|
||||
@click="handleEdit(category)"
|
||||
/>
|
||||
<van-cell :title="category.name">
|
||||
<template #icon>
|
||||
<div
|
||||
v-if="category.icon"
|
||||
class="category-icon"
|
||||
v-html="parseIcon(category.icon)"
|
||||
/>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="category-actions">
|
||||
<van-button
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
@click="handleIconSelect(category)"
|
||||
>
|
||||
选择图标
|
||||
</van-button>
|
||||
<van-button
|
||||
size="small"
|
||||
@click="handleEditOld(category)"
|
||||
>
|
||||
编辑
|
||||
</van-button>
|
||||
</div>
|
||||
</template>
|
||||
</van-cell>
|
||||
<template #right>
|
||||
<van-button
|
||||
square
|
||||
@@ -131,6 +153,52 @@
|
||||
message="删除后无法恢复,确定要删除吗?"
|
||||
@confirm="handleConfirmDelete"
|
||||
/>
|
||||
|
||||
<!-- 图标选择对话框 -->
|
||||
<van-dialog
|
||||
v-model:show="showIconDialog"
|
||||
title="选择图标"
|
||||
show-cancel-button
|
||||
@confirm="handleConfirmIconSelect"
|
||||
>
|
||||
<div class="icon-selector">
|
||||
<div
|
||||
v-if="currentCategory && currentCategory.icon"
|
||||
class="icon-list"
|
||||
>
|
||||
<div
|
||||
v-for="(icon, index) in parseIconArray(currentCategory.icon)"
|
||||
:key="index"
|
||||
class="icon-item"
|
||||
:class="{ active: selectedIconIndex === index }"
|
||||
@click="selectedIconIndex = index"
|
||||
>
|
||||
<div
|
||||
class="icon-preview"
|
||||
v-html="icon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="empty-icons"
|
||||
>
|
||||
<van-empty description="暂无图标" />
|
||||
</div>
|
||||
|
||||
<div class="icon-actions">
|
||||
<van-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="isGeneratingIcon"
|
||||
:disabled="isGeneratingIcon"
|
||||
@click="handleGenerateIcon"
|
||||
>
|
||||
{{ isGeneratingIcon ? 'AI生成中...' : '生成新图标' }}
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -143,7 +211,9 @@ import {
|
||||
getCategoryList,
|
||||
createCategory,
|
||||
deleteCategory,
|
||||
updateCategory
|
||||
updateCategory,
|
||||
generateIcon,
|
||||
updateSelectedIcon
|
||||
} from '@/api/transactionCategory'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -185,6 +255,12 @@ const editForm = ref({
|
||||
name: ''
|
||||
})
|
||||
|
||||
// 图标选择对话框
|
||||
const showIconDialog = ref(false)
|
||||
const currentCategory = ref(null) // 当前正在编辑图标的分类
|
||||
const selectedIconIndex = ref(0)
|
||||
const isGeneratingIcon = ref(false)
|
||||
|
||||
// 计算导航栏标题
|
||||
const navTitle = computed(() => {
|
||||
if (currentLevel.value === 0) {
|
||||
@@ -309,6 +385,97 @@ const handleEdit = (category) => {
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开图标选择器
|
||||
*/
|
||||
const handleIconSelect = (category) => {
|
||||
currentCategory.value = category
|
||||
selectedIconIndex.value = 0
|
||||
showIconDialog.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成新图标
|
||||
*/
|
||||
const handleGenerateIcon = async () => {
|
||||
if (!currentCategory.value) return
|
||||
|
||||
try {
|
||||
isGeneratingIcon.value = true
|
||||
showLoadingToast({
|
||||
message: 'AI正在生成图标...',
|
||||
forbidClick: true,
|
||||
duration: 0
|
||||
})
|
||||
|
||||
const { success, data, message } = await generateIcon(currentCategory.value.id)
|
||||
|
||||
if (success) {
|
||||
showSuccessToast('图标生成成功')
|
||||
// 重新加载分类列表以获取最新的图标
|
||||
await loadCategories()
|
||||
// 更新当前分类引用
|
||||
const updated = categories.value.find((c) => c.id === currentCategory.value.id)
|
||||
if (updated) {
|
||||
currentCategory.value = updated
|
||||
}
|
||||
} else {
|
||||
showToast(message || '生成图标失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成图标失败:', error)
|
||||
showToast('生成图标失败: ' + (error.message || '未知错误'))
|
||||
} finally {
|
||||
isGeneratingIcon.value = false
|
||||
closeToast()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认选择图标
|
||||
*/
|
||||
const handleConfirmIconSelect = async () => {
|
||||
if (!currentCategory.value) return
|
||||
|
||||
try {
|
||||
showLoadingToast({
|
||||
message: '保存中...',
|
||||
forbidClick: true,
|
||||
duration: 0
|
||||
})
|
||||
|
||||
const { success, message } = await updateSelectedIcon(
|
||||
currentCategory.value.id,
|
||||
selectedIconIndex.value
|
||||
)
|
||||
|
||||
if (success) {
|
||||
showSuccessToast('图标保存成功')
|
||||
showIconDialog.value = false
|
||||
await loadCategories()
|
||||
} else {
|
||||
showToast(message || '保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存图标失败:', error)
|
||||
showToast('保存图标失败: ' + (error.message || '未知错误'))
|
||||
} finally {
|
||||
closeToast()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑分类
|
||||
*/
|
||||
const handleEditOld = (category) => {
|
||||
editForm.value = {
|
||||
id: category.id,
|
||||
name: category.name
|
||||
}
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 确认编辑
|
||||
*/
|
||||
@@ -392,6 +559,32 @@ const resetAddForm = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析图标数组(第一个图标为当前选中的)
|
||||
*/
|
||||
const parseIcon = (iconJson) => {
|
||||
if (!iconJson) return ''
|
||||
try {
|
||||
const icons = JSON.parse(iconJson)
|
||||
return Array.isArray(icons) && icons.length > 0 ? icons[0] : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析图标数组为完整数组
|
||||
*/
|
||||
const parseIconArray = (iconJson) => {
|
||||
if (!iconJson) return []
|
||||
try {
|
||||
const icons = JSON.parse(iconJson)
|
||||
return Array.isArray(icons) ? icons : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化时显示类型选择
|
||||
currentLevel.value = 0
|
||||
@@ -412,6 +605,85 @@ onMounted(() => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.category-icon :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.category-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-selector {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.icon-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.icon-item {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 2px solid var(--van-border-color);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.icon-item:hover {
|
||||
border-color: var(--van-primary-color);
|
||||
background-color: var(--van-primary-color-light);
|
||||
}
|
||||
|
||||
.icon-item.active {
|
||||
border-color: var(--van-primary-color);
|
||||
background-color: var(--van-primary-color-light);
|
||||
box-shadow: 0 2px 8px rgba(25, 137, 250, 0.3);
|
||||
}
|
||||
|
||||
.icon-preview {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-preview :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.empty-icons {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.icon-actions {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--van-border-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 深色模式 */
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
.level-container {
|
||||
|
||||
@@ -6,7 +6,8 @@ public class TransactionCategoryController(
|
||||
ITransactionCategoryRepository categoryRepository,
|
||||
ITransactionRecordRepository transactionRecordRepository,
|
||||
ILogger<TransactionCategoryController> logger,
|
||||
IBudgetRepository budgetRepository
|
||||
IBudgetRepository budgetRepository,
|
||||
IOpenAiService openAiService
|
||||
) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
@@ -199,6 +200,125 @@ public class TransactionCategoryController(
|
||||
return $"批量创建分类失败: {ex.Message}".Fail<int>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为指定分类生成新的SVG图标
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<BaseResponse<string>> GenerateIconAsync([FromBody] GenerateIconDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var category = await categoryRepository.GetByIdAsync(dto.CategoryId);
|
||||
if (category == null)
|
||||
{
|
||||
return "分类不存在".Fail<string>();
|
||||
}
|
||||
|
||||
// 使用AI生成简洁的SVG图标
|
||||
var systemPrompt = @"你是一个SVG图标生成专家。请生成简洁、现代的单色SVG图标。
|
||||
要求:
|
||||
1. 只返回<svg>标签及其内容,不要其他说明文字
|
||||
2. viewBox=""0 0 24 24""
|
||||
3. 尺寸为24x24
|
||||
4. 使用单色,fill=""currentColor""
|
||||
5. 简洁的设计,适合作为应用图标";
|
||||
|
||||
var userPrompt = $"为分类"{category.Name}"({(category.Type == TransactionType.Expense ? "支出" : category.Type == TransactionType.Income ? "收入" : "不计收支")})生成一个SVG图标";
|
||||
|
||||
var svgContent = await openAiService.ChatAsync(systemPrompt, userPrompt, timeoutSeconds: 30);
|
||||
if (string.IsNullOrWhiteSpace(svgContent))
|
||||
{
|
||||
return "AI生成图标失败".Fail<string>();
|
||||
}
|
||||
|
||||
// 提取SVG标签内容
|
||||
var svgMatch = System.Text.RegularExpressions.Regex.Match(svgContent, @"<svg[^>]*>.*?</svg>",
|
||||
System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||
|
||||
if (!svgMatch.Success)
|
||||
{
|
||||
return "生成的内容不包含有效的SVG标签".Fail<string>();
|
||||
}
|
||||
|
||||
var svg = svgMatch.Value;
|
||||
|
||||
// 解析现有图标数组
|
||||
var icons = string.IsNullOrWhiteSpace(category.Icon)
|
||||
? new List<string>()
|
||||
: JsonSerializer.Deserialize<List<string>>(category.Icon) ?? new List<string>();
|
||||
|
||||
// 添加新图标
|
||||
icons.Add(svg);
|
||||
|
||||
// 更新数据库
|
||||
category.Icon = JsonSerializer.Serialize(icons);
|
||||
category.UpdateTime = DateTime.Now;
|
||||
|
||||
var success = await categoryRepository.UpdateAsync(category);
|
||||
if (!success)
|
||||
{
|
||||
return "更新分类图标失败".Fail<string>();
|
||||
}
|
||||
|
||||
return svg.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "生成图标失败, CategoryId: {CategoryId}", dto.CategoryId);
|
||||
return $"生成图标失败: {ex.Message}".Fail<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新分类的选中图标索引
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<BaseResponse> UpdateSelectedIconAsync([FromBody] UpdateSelectedIconDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var category = await categoryRepository.GetByIdAsync(dto.CategoryId);
|
||||
if (category == null)
|
||||
{
|
||||
return "分类不存在".Fail();
|
||||
}
|
||||
|
||||
// 验证索引有效性
|
||||
if (string.IsNullOrWhiteSpace(category.Icon))
|
||||
{
|
||||
return "该分类没有可用图标".Fail();
|
||||
}
|
||||
|
||||
var icons = JsonSerializer.Deserialize<List<string>>(category.Icon);
|
||||
if (icons == null || dto.SelectedIndex < 0 || dto.SelectedIndex >= icons.Count)
|
||||
{
|
||||
return "无效的图标索引".Fail();
|
||||
}
|
||||
|
||||
// 这里可以添加一个SelectedIconIndex字段到实体中,或者将选中的图标移到数组第一位
|
||||
// 暂时采用移动到第一位的方式
|
||||
var selectedIcon = icons[dto.SelectedIndex];
|
||||
icons.RemoveAt(dto.SelectedIndex);
|
||||
icons.Insert(0, selectedIcon);
|
||||
|
||||
category.Icon = JsonSerializer.Serialize(icons);
|
||||
category.UpdateTime = DateTime.Now;
|
||||
|
||||
var success = await categoryRepository.UpdateAsync(category);
|
||||
if (success)
|
||||
{
|
||||
return "更新图标成功".Ok();
|
||||
}
|
||||
|
||||
return "更新图标失败".Fail();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "更新选中图标失败, Dto: {@Dto}", dto);
|
||||
return $"更新选中图标失败: {ex.Message}".Fail();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -216,3 +336,18 @@ public record UpdateCategoryDto(
|
||||
long Id,
|
||||
string Name
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// 生成图标DTO
|
||||
/// </summary>
|
||||
public record GenerateIconDto(
|
||||
long CategoryId
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// 更新选中图标DTO
|
||||
/// </summary>
|
||||
public record UpdateSelectedIconDto(
|
||||
long CategoryId,
|
||||
int SelectedIndex
|
||||
);
|
||||
|
||||
@@ -66,6 +66,17 @@ public static class Expand
|
||||
.WithIdentity("LogCleanupTrigger")
|
||||
.WithCronSchedule("0 0 2 * * ?") // 每天凌晨2点执行
|
||||
.WithDescription("每天凌晨2点执行日志清理(保留30天)"));
|
||||
|
||||
// 配置分类图标生成任务 - 每24小时执行一次
|
||||
var categoryIconJobKey = new JobKey("CategoryIconGenerationJob");
|
||||
q.AddJob<CategoryIconGenerationJob>(opts => opts
|
||||
.WithIdentity(categoryIconJobKey)
|
||||
.WithDescription("分类图标生成任务"));
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(categoryIconJobKey)
|
||||
.WithIdentity("CategoryIconGenerationTrigger")
|
||||
.WithCronSchedule("0 0 0 * * ?") // 每24小时执行一次
|
||||
.WithDescription("每24小时扫描并为无图标分类生成SVG图标"));
|
||||
});
|
||||
|
||||
// 添加 Quartz Hosted Service
|
||||
|
||||
Reference in New Issue
Block a user