Compare commits
2 Commits
61916dc6da
...
6abc5f8b6d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6abc5f8b6d | ||
|
|
460dcd17ef |
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>
|
/// </summary>
|
||||||
public TransactionType Type { get; set; }
|
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
|
public interface IOpenAiService
|
||||||
{
|
{
|
||||||
Task<string?> ChatAsync(string systemPrompt, string userPrompt);
|
Task<string?> ChatAsync(string systemPrompt, string userPrompt, int timeoutSeconds = 15);
|
||||||
Task<string?> ChatAsync(string prompt);
|
Task<string?> ChatAsync(string prompt, int timeoutSeconds = 15);
|
||||||
IAsyncEnumerable<string> ChatStreamAsync(string systemPrompt, string userPrompt);
|
IAsyncEnumerable<string> ChatStreamAsync(string systemPrompt, string userPrompt);
|
||||||
IAsyncEnumerable<string> ChatStreamAsync(string prompt);
|
IAsyncEnumerable<string> ChatStreamAsync(string prompt);
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ public class OpenAiService(
|
|||||||
ILogger<OpenAiService> logger
|
ILogger<OpenAiService> logger
|
||||||
) : IOpenAiService
|
) : 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;
|
var cfg = aiSettings.Value;
|
||||||
if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
|
if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
|
||||||
@@ -27,7 +27,7 @@ public class OpenAiService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var http = new HttpClient();
|
using var http = new HttpClient();
|
||||||
http.Timeout = TimeSpan.FromSeconds(15);
|
http.Timeout = TimeSpan.FromSeconds(timeoutSeconds);
|
||||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
|
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
|
||||||
|
|
||||||
var payload = new
|
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;
|
var cfg = aiSettings.Value;
|
||||||
if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
|
if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
|
||||||
@@ -84,7 +84,7 @@ public class OpenAiService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var http = new HttpClient();
|
using var http = new HttpClient();
|
||||||
http.Timeout = TimeSpan.FromSeconds(60 * 5);
|
http.Timeout = TimeSpan.FromSeconds(timeoutSeconds);
|
||||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
|
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
|
||||||
|
|
||||||
var payload = new
|
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
|
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"
|
v-for="category in categories"
|
||||||
:key="category.id"
|
:key="category.id"
|
||||||
>
|
>
|
||||||
<van-cell
|
<van-cell :title="category.name">
|
||||||
:title="category.name"
|
<template #icon>
|
||||||
is-link
|
<div
|
||||||
@click="handleEdit(category)"
|
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>
|
<template #right>
|
||||||
<van-button
|
<van-button
|
||||||
square
|
square
|
||||||
@@ -131,6 +153,52 @@
|
|||||||
message="删除后无法恢复,确定要删除吗?"
|
message="删除后无法恢复,确定要删除吗?"
|
||||||
@confirm="handleConfirmDelete"
|
@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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -143,7 +211,9 @@ import {
|
|||||||
getCategoryList,
|
getCategoryList,
|
||||||
createCategory,
|
createCategory,
|
||||||
deleteCategory,
|
deleteCategory,
|
||||||
updateCategory
|
updateCategory,
|
||||||
|
generateIcon,
|
||||||
|
updateSelectedIcon
|
||||||
} from '@/api/transactionCategory'
|
} from '@/api/transactionCategory'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -185,6 +255,12 @@ const editForm = ref({
|
|||||||
name: ''
|
name: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 图标选择对话框
|
||||||
|
const showIconDialog = ref(false)
|
||||||
|
const currentCategory = ref(null) // 当前正在编辑图标的分类
|
||||||
|
const selectedIconIndex = ref(0)
|
||||||
|
const isGeneratingIcon = ref(false)
|
||||||
|
|
||||||
// 计算导航栏标题
|
// 计算导航栏标题
|
||||||
const navTitle = computed(() => {
|
const navTitle = computed(() => {
|
||||||
if (currentLevel.value === 0) {
|
if (currentLevel.value === 0) {
|
||||||
@@ -309,6 +385,98 @@ const handleEdit = (category) => {
|
|||||||
showEditDialog.value = true
|
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 +560,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(() => {
|
onMounted(() => {
|
||||||
// 初始化时显示类型选择
|
// 初始化时显示类型选择
|
||||||
currentLevel.value = 0
|
currentLevel.value = 0
|
||||||
@@ -412,6 +606,85 @@ onMounted(() => {
|
|||||||
cursor: pointer;
|
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) {
|
/* @media (prefers-color-scheme: dark) {
|
||||||
.level-container {
|
.level-container {
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ public class TransactionCategoryController(
|
|||||||
ITransactionCategoryRepository categoryRepository,
|
ITransactionCategoryRepository categoryRepository,
|
||||||
ITransactionRecordRepository transactionRecordRepository,
|
ITransactionRecordRepository transactionRecordRepository,
|
||||||
ILogger<TransactionCategoryController> logger,
|
ILogger<TransactionCategoryController> logger,
|
||||||
IBudgetRepository budgetRepository
|
IBudgetRepository budgetRepository,
|
||||||
|
IOpenAiService openAiService
|
||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -199,6 +200,125 @@ public class TransactionCategoryController(
|
|||||||
return $"批量创建分类失败: {ex.Message}".Fail<int>();
|
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>
|
/// <summary>
|
||||||
@@ -216,3 +336,18 @@ public record UpdateCategoryDto(
|
|||||||
long Id,
|
long Id,
|
||||||
string Name
|
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")
|
.WithIdentity("LogCleanupTrigger")
|
||||||
.WithCronSchedule("0 0 2 * * ?") // 每天凌晨2点执行
|
.WithCronSchedule("0 0 2 * * ?") // 每天凌晨2点执行
|
||||||
.WithDescription("每天凌晨2点执行日志清理(保留30天)"));
|
.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
|
// 添加 Quartz Hosted Service
|
||||||
|
|||||||
Reference in New Issue
Block a user