feat(favorite, ui): 完善收藏功能,引入独立标签管理、国际化支持及全面集成测试

本次更新全面增强收藏功能,引入独立的标签管理系统,完善了多语言支持,并对UI组件进行了优化。同时,新增了大量集成测试以确保功能的稳定性和数据完整性。

## 新增功能与优化

### 1. 独立标签管理系统
- **核心功能**:
    - **独立标签库 (STORAGE_KEYS.TAGS)**: 实现标签的独立存储,不依赖收藏项,支持预先创建使用次数为0的标签。
    - **标签CRUD操作**: 提供 `addTag()` (支持重复检测)、`renameTag()` (更新所有相关收藏和独立库)、`mergeTags()` (合并多个标签)、`deleteTag()` (从独立库和收藏中移除) 功能。
    - **标签统计算法**: `getAllTags()` 方法返回独立标签库与收藏中使用标签的合集,并支持按使用次数降序排序。
    - **导入导出优化**: 导出内容包含独立标签库,导入时自动重建,确保数据完整性。
- **类型转换工具 (TagTypeConverter)**: 解决API与UI格式不一致问题,提供 `toTagStatistics()`、`toAutoCompleteOptions()` 等转换方法,并支持多种排序。
- **错误处理增强**: 新增 `FavoriteTagError` 基类及 `FavoriteTagAlreadyExistsError`、`FavoriteTagNotFoundError` 等专用错误类型。

### 2. 标签管理器UI组件 (TagManager.vue)
- 提供完整的标签CRUD界面,包括数据表格展示、实时搜索、新增、重命名、合并、删除、分页功能。
- 技术栈:Naive UI (NDataTable, NModal, NPopconfirm),使用 `TagTypeConverter` 统一数据格式。
- 集成点:FavoriteManager.vue 中新增“标签管理”按钮以打开该对话框。

### 3. 智能标签建议 (useTagSuggestions)
- **智能自动完成**: 基于现有标签使用统计提供实时建议,热门标签优先,支持前缀匹配,并自动去重过滤。
- **API**: 提供 `loadTags()`、`filterTags()`、`getPopularTags()`、`getRecentTags()` 方法。
- 集成点:SaveFavoriteDialog.vue 中集成标签自动完成输入框。

### 4. 收藏功能国际化与UI优化
- **国际化覆盖 (中英繁)**:
    - 新增 `favorites.dialog.*` (保存对话框)、`favorites.manager.*` (收藏管理器)、`favorites.manager.tagManager.*` (标签管理器)、`favorites.card.*` (收藏卡片) 等完整翻译键。
    - 遵循翻译规范:使用全角标点、统一术语、简洁明了的提示信息。
- **UI组件优化**:
    - **SaveFavoriteDialog.vue**:
        - **标签自动完成集成**: 使用 `useTagSuggestions` 提供智能建议,支持回车添加,已选标签显示为可关闭 `Tag`,保存前自动注册标签到独立库。
        - **智能预填充**: 根据模式(创建/保存/编辑)智能预填充标题和内容。
    - **FavoriteManager.vue**:
        - 新增“标签管理”按钮。
        - 网格布局优化 (使用 `NGrid` 替代 `flex`)。
        - 工具栏固定,响应式设计。
        - 移除冗余视图切换,简化布局。
    - **FavoriteCard.vue**:
        - 卡片布局优化:使用 `NTag` 展示标签,显示元信息 (创建时间、使用次数、功能模式),操作按钮 (编辑、删除、应用),悬停效果。
- **用户体验提升**: 优化标签输入和管理流程,增强错误处理(如 `TAG_ALREADY_EXISTS` 静默忽略,其他错误显示友好提示)。

### 5. 分类管理架构优化与国际化
- **架构优化**:
    - 分离关注点:默认分类创建从 Core 层移至 UI 层,Core 层不再硬编码分类名称,改为接受配置参数。
    - 添加初始化标记防止重复创建默认分类。
    - `deleteCategory` 现返回受影响的收藏数量。
- **国际化增强**:
    - 新增 `useFavoriteInitializer` composable 管理国际化分类。
    - 完善中英繁三语言的分类翻译。
    - 优化默认分类配置 (新增“学习研究”和“日常助手”)。
    - 统一错误信息为英文。
- **代码组织**:
    - 测试文件迁移至标准目录 `packages/core/tests/`。
    - 改进 `FavoriteManager` 组件生命周期管理,优化初始化流程。

### 6. 全面集成测试
- 新增 `integration.test.ts` (742行,20个集成测试),覆盖以下场景:
    - CRUD 完整流程测试 (3个)。
    - 历史记录转收藏测试 (5个,覆盖12种 PromptRecordType)。
    - 标签和分类管理测试 (6个:重命名/合并/删除/排序/统计/级联)。
    - 导入导出完整性测试 (2个)。
    - 搜索和过滤功能测试 (4个)。
- `type-converter.test.ts` 包含16个测试,`tag-manager.test.ts` 包含23个测试。
This commit is contained in:
linshen committed 2025-10-19 12:31:42 +08:00
1 parent 5bf02fa4df
commit d784c99d88
21 files changed
+3908 -164

No files matched your search

+1
View File
@@ -147,6 +147,7 @@ export * from './services/context/constants'
// 导出收藏管理相关
export { FavoriteManager } from './services/favorite/manager'
export { FavoriteManagerElectronProxy } from './services/favorite/electron-proxy'
export { TagTypeConverter } from './services/favorite/type-converter'
export * from './services/favorite/types'
export * from './services/favorite/errors'
@@ -5,7 +5,12 @@ import {
FavoriteAlreadyExistsError,
FavoriteCategoryNotFoundError,
FavoriteValidationError,
FavoriteStorageError
FavoriteStorageError,
FavoriteTagAlreadyExistsError,
FavoriteTagNotFoundError,
FavoriteTagError,
FavoriteMigrationError,
FavoriteImportExportError
} from './errors';
declare const window: {
@@ -48,6 +53,23 @@ export class FavoriteManagerElectronProxy implements IFavoriteManager {
if (error.code === 'STORAGE_ERROR') {
throw new FavoriteStorageError(error.message || '');
}
// 标签相关错误
if (error.code === 'TAG_ALREADY_EXISTS') {
throw new FavoriteTagAlreadyExistsError(error.tag || '');
}
if (error.code === 'TAG_NOT_FOUND') {
throw new FavoriteTagNotFoundError(error.tag || '');
}
if (error.code === 'TAG_ERROR') {
throw new FavoriteTagError(error.message || '', error.code);
}
// 数据迁移和导入导出错误
if (error.code === 'MIGRATION_ERROR') {
throw new FavoriteMigrationError(error.message || '', error.cause);
}
if (error.code === 'IMPORT_EXPORT_ERROR') {
throw new FavoriteImportExportError(error.message || '', error.cause, error.details);
}
throw new FavoriteError(error.message || '未知错误');
}
}
@@ -100,7 +122,7 @@ export class FavoriteManagerElectronProxy implements IFavoriteManager {
return this.invokeMethod('updateCategory', id, updates);
}
async deleteCategory(id: string): Promise<void> {
async deleteCategory(id: string): Promise<number> {
return this.invokeMethod('deleteCategory', id);
}
@@ -134,6 +156,10 @@ export class FavoriteManagerElectronProxy implements IFavoriteManager {
return this.invokeMethod('getAllTags');
}
async addTag(tag: string): Promise<void> {
return this.invokeMethod('addTag', tag);
}
async renameTag(oldTag: string, newTag: string): Promise<number> {
return this.invokeMethod('renameTag', oldTag, newTag);
}
+56 -6
View File
@@ -11,35 +11,85 @@ export class FavoriteError extends Error {
export class FavoriteNotFoundError extends FavoriteError {
constructor(id: string) {
super(`收藏项未找到: ${id}`, 'FAVORITE_NOT_FOUND');
super(`Favorite not found: ${id}`, 'FAVORITE_NOT_FOUND');
this.name = 'FavoriteNotFoundError';
}
}
export class FavoriteAlreadyExistsError extends FavoriteError {
constructor(content: string) {
super(`收藏项已存在: ${content.slice(0, 50)}...`, 'FAVORITE_ALREADY_EXISTS');
super(`Favorite already exists: ${content.slice(0, 50)}...`, 'FAVORITE_ALREADY_EXISTS');
this.name = 'FavoriteAlreadyExistsError';
}
}
export class FavoriteCategoryNotFoundError extends FavoriteError {
constructor(id: string) {
super(`分类未找到: ${id}`, 'CATEGORY_NOT_FOUND');
super(`Category not found: ${id}`, 'CATEGORY_NOT_FOUND');
this.name = 'FavoriteCategoryNotFoundError';
}
}
export class FavoriteValidationError extends FavoriteError {
constructor(message: string) {
super(`验证错误: ${message}`, 'VALIDATION_ERROR');
super(`Validation error: ${message}`, 'VALIDATION_ERROR');
this.name = 'FavoriteValidationError';
}
}
export class FavoriteStorageError extends FavoriteError {
constructor(message: string) {
super(`存储错误: ${message}`, 'STORAGE_ERROR');
constructor(message: string, public cause?: Error) {
super(`Storage error: ${message}`, 'STORAGE_ERROR');
this.name = 'FavoriteStorageError';
}
}
/**
* 标签相关错误
*/
export class FavoriteTagError extends FavoriteError {
constructor(message: string, code: string = 'TAG_ERROR') {
super(message, code);
this.name = 'FavoriteTagError';
}
}
/**
* 标签已存在错误
*/
export class FavoriteTagAlreadyExistsError extends FavoriteTagError {
constructor(tag: string) {
super(`Tag already exists: ${tag}`, 'TAG_ALREADY_EXISTS');
this.name = 'FavoriteTagAlreadyExistsError';
}
}
/**
* Tag not found error
*/
export class FavoriteTagNotFoundError extends FavoriteTagError {
constructor(tag: string) {
super(`Tag not found: ${tag}`, 'TAG_NOT_FOUND');
this.name = 'FavoriteTagNotFoundError';
}
}
/**
* Data migration error
*/
export class FavoriteMigrationError extends FavoriteError {
constructor(message: string, public cause?: Error) {
super(`Migration error: ${message}`, 'MIGRATION_ERROR');
this.name = 'FavoriteMigrationError';
}
}
/**
* 导入导出错误
*/
export class FavoriteImportExportError extends FavoriteError {
constructor(message: string, public cause?: Error, public details?: string[]) {
super(message, 'IMPORT_EXPORT_ERROR');
this.name = 'FavoriteImportExportError';
}
}
+9 -1
View File
@@ -1,4 +1,12 @@
export * from './types';
export * from './errors';
export * from './manager';
export type { FavoritePrompt, FavoriteCategory, FavoriteStats, IFavoriteManager } from './types';
export * from './type-converter';
export type {
FavoritePrompt,
FavoriteCategory,
FavoriteStats,
FavoriteTag,
TagStatistics,
IFavoriteManager
} from './types';
+301 -118
View File
@@ -3,6 +3,7 @@ import type {
FavoritePrompt,
FavoriteCategory,
FavoriteStats,
FavoriteTag,
IFavoriteManager
} from './types';
import {
@@ -10,7 +11,10 @@ import {
FavoriteNotFoundError,
FavoriteCategoryNotFoundError,
FavoriteValidationError,
FavoriteStorageError
FavoriteStorageError,
FavoriteTagAlreadyExistsError,
FavoriteMigrationError,
FavoriteImportExportError
} from './errors';
import { TypeMapper } from './type-mapper';
@@ -21,7 +25,8 @@ export class FavoriteManager implements IFavoriteManager {
private readonly STORAGE_KEYS = {
FAVORITES: 'favorites',
CATEGORIES: 'favorite_categories',
STATS: 'favorite_stats'
STATS: 'favorite_stats',
TAGS: 'favorite_tags'
} as const;
private initPromise: Promise<void>;
@@ -48,7 +53,8 @@ export class FavoriteManager implements IFavoriteManager {
try {
this.initState = 'initializing';
await this.initializeDefaultCategories();
// ❌ 移除自动创建默认分类 - 改由 UI 层调用 ensureDefaultCategories
// await this.initializeDefaultCategories();
await this.migrateLegacyData();
this.initialized = true;
this.initState = 'initialized';
@@ -119,91 +125,61 @@ export class FavoriteManager implements IFavoriteManager {
if (migrated) {
// 迁移后更新统计信息
await this.updateStats();
console.info('[FavoriteManager] 数据迁移完成,已更新收藏项格式');
}
} catch (error) {
console.warn('[FavoriteManager] 旧数据迁移失败:', error);
// 迁移失败不应该阻止服务初始化
const errorMessage = error instanceof Error ? error.message : String(error);
const migrationError = new FavoriteMigrationError(
`Legacy data migration failed: ${errorMessage}`,
error instanceof Error ? error : undefined
);
console.warn('[FavoriteManager]', migrationError);
// 迁移失败不应该阻止服务初始化,仅记录警告
}
}
/**
* 初始化默认分类
* 确保默认分类存在(仅首次)
* 由 UI 层调用,传入国际化后的分类配置
*
* 💡 **架构说明**:
* 此方法在初始化期间被调用,现在可以安全地调用公共方法。
* `ensureInitialized()` 会检测初始化状态,在初始化期间直接返回,避免死锁。
* @param defaultCategories 默认分类配置数组
*/
private async initializeDefaultCategories(): Promise<void> {
async ensureDefaultCategories(
defaultCategories: Array<{
name: string;
description?: string;
color: string;
}>
): Promise<void> {
await this.ensureInitialized();
try {
// ✅ 现在可以安全调用公共方法,ensureInitialized() 会智能跳过等待
// ✅ 检查是否已初始化过默认分类
const hasInitialized = await this.storageProvider.getItem('favorite_categories_initialized');
if (hasInitialized === 'true') {
return; // 已经初始化过,即使用户删光了也不再自动创建
}
// ✅ 检查是否已有分类
const existingCategories = await this.getCategories();
if (existingCategories.length === 0) {
// ✅ 创建默认分类 - 可以安全调用 addCategory()
const now = Date.now();
const defaultCategories: FavoriteCategory[] = [
{
id: `cat_${now}_${Math.random().toString(36).substr(2, 9)}`,
name: '未分类',
description: '默认分类',
color: '#6B7280',
sortOrder: 0,
createdAt: now
},
{
id: `cat_${now + 1}_${Math.random().toString(36).substr(2, 9)}`,
name: '系统提示词',
description: '优化后的系统提示词',
color: '#3B82F6',
sortOrder: 1,
createdAt: now + 1
},
{
id: `cat_${now + 2}_${Math.random().toString(36).substr(2, 9)}`,
name: '用户提示词',
description: '优化后的用户提示词',
color: '#10B981',
sortOrder: 2,
createdAt: now + 2
},
{
id: `cat_${now + 3}_${Math.random().toString(36).substr(2, 9)}`,
name: '创意写作',
description: '创意写作相关的提示词',
color: '#8B5CF6',
sortOrder: 3,
createdAt: now + 3
},
{
id: `cat_${now + 4}_${Math.random().toString(36).substr(2, 9)}`,
name: '编程开发',
description: '编程开发相关的提示词',
color: '#F59E0B',
sortOrder: 4,
createdAt: now + 4
},
{
id: `cat_${now + 5}_${Math.random().toString(36).substr(2, 9)}`,
name: '商业分析',
description: '商业分析相关的提示词',
color: '#EF4444',
sortOrder: 5,
createdAt: now + 5
}
];
// ✅ 批量添加默认分类 - 使用公共方法
for (const category of defaultCategories) {
// ✅ 首次使用,创建默认分类
for (let i = 0; i < defaultCategories.length; i++) {
const category = defaultCategories[i];
await this.addCategory({
name: category.name,
description: category.description,
color: category.color,
sortOrder: category.sortOrder
sortOrder: i
});
}
// ✅ 标记已初始化
await this.storageProvider.setItem('favorite_categories_initialized', 'true');
}
} catch (error) {
console.warn('初始化默认分类失败:', error);
console.warn('[FavoriteManager] 确保默认分类失败:', error);
}
}
@@ -212,24 +188,24 @@ export class FavoriteManager implements IFavoriteManager {
// 验证输入
if (!favorite.content?.trim()) {
throw new FavoriteValidationError('提示词内容不能为空');
throw new FavoriteValidationError('Prompt content cannot be empty');
}
// 验证 functionMode 必填
if (!favorite.functionMode) {
throw new FavoriteValidationError('功能模式 (functionMode) 不能为空');
throw new FavoriteValidationError('Function mode (functionMode) cannot be empty');
}
// 验证功能模式分类的完整性
if (favorite.functionMode === 'basic' || favorite.functionMode === 'context') {
if (!favorite.optimizationMode) {
throw new FavoriteValidationError(`${favorite.functionMode} 模式必须指定 optimizationMode`);
throw new FavoriteValidationError(`${favorite.functionMode} mode must specify optimizationMode`);
}
}
if (favorite.functionMode === 'image') {
if (!favorite.imageSubMode) {
throw new FavoriteValidationError('image 模式必须指定 imageSubMode');
throw new FavoriteValidationError('Image mode must specify imageSubMode');
}
}
@@ -271,7 +247,7 @@ export class FavoriteManager implements IFavoriteManager {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FavoriteStorageError(`添加收藏失败: ${errorMessage}`);
throw new FavoriteStorageError(`Failed to add favorite: ${errorMessage}`);
}
}
@@ -342,7 +318,7 @@ export class FavoriteManager implements IFavoriteManager {
return favoritesList;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FavoriteStorageError(`获取收藏列表失败: ${errorMessage}`);
throw new FavoriteStorageError(`Failed to get favorites: ${errorMessage}`);
}
}
@@ -361,7 +337,7 @@ export class FavoriteManager implements IFavoriteManager {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FavoriteStorageError(`获取收藏详情失败: ${errorMessage}`);
throw new FavoriteStorageError(`Failed to get favorite details: ${errorMessage}`);
}
}
@@ -391,7 +367,7 @@ export class FavoriteManager implements IFavoriteManager {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FavoriteStorageError(`更新收藏失败: ${errorMessage}`);
throw new FavoriteStorageError(`Failed to update favorite: ${errorMessage}`);
}
}
@@ -415,7 +391,7 @@ export class FavoriteManager implements IFavoriteManager {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FavoriteStorageError(`删除收藏失败: ${errorMessage}`);
throw new FavoriteStorageError(`Failed to delete favorite: ${errorMessage}`);
}
}
@@ -425,7 +401,7 @@ export class FavoriteManager implements IFavoriteManager {
const favoritesList = favorites || [];
const deletedCount = favoritesList.filter(f => ids.includes(f.id)).length;
if (deletedCount === 0) {
throw new FavoriteNotFoundError('未找到要删除的收藏项');
throw new FavoriteNotFoundError('Favorite to delete not found');
}
return favoritesList.filter(f => !ids.includes(f.id));
@@ -437,7 +413,7 @@ export class FavoriteManager implements IFavoriteManager {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FavoriteStorageError(`批量删除收藏失败: ${errorMessage}`);
throw new FavoriteStorageError(`Failed to batch delete favorites: ${errorMessage}`);
}
}
@@ -458,7 +434,7 @@ export class FavoriteManager implements IFavoriteManager {
return categories ? JSON.parse(categories) : [];
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FavoriteStorageError(`获取分类列表失败: ${errorMessage}`);
throw new FavoriteStorageError(`Failed to get categories: ${errorMessage}`);
}
}
@@ -466,7 +442,7 @@ export class FavoriteManager implements IFavoriteManager {
await this.ensureInitialized();
if (!category.name?.trim()) {
throw new FavoriteValidationError('分类名称不能为空');
throw new FavoriteValidationError('Category name cannot be empty');
}
const now = Date.now();
@@ -485,7 +461,7 @@ export class FavoriteManager implements IFavoriteManager {
// 检查是否已存在同名分类
const existing = categoriesList.find(c => c.name === category.name);
if (existing) {
throw new FavoriteError(`分类已存在: ${category.name}`, 'CATEGORY_ALREADY_EXISTS');
throw new FavoriteError(`Category already exists: ${category.name}`, 'CATEGORY_ALREADY_EXISTS');
}
return [...categoriesList, newCategory];
});
@@ -496,7 +472,7 @@ export class FavoriteManager implements IFavoriteManager {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FavoriteStorageError(`添加分类失败: ${errorMessage}`);
throw new FavoriteStorageError(`Failed to add category: ${errorMessage}`);
}
}
@@ -521,18 +497,34 @@ export class FavoriteManager implements IFavoriteManager {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FavoriteStorageError(`更新分类失败: ${errorMessage}`);
throw new FavoriteStorageError(`Failed to update category: ${errorMessage}`);
}
}
async deleteCategory(id: string): Promise<void> {
/**
* 删除分类
* 会自动清空该分类下所有收藏的分类字段
*
* @param id 分类ID
* @returns 受影响的收藏数量
*/
async deleteCategory(id: string): Promise<number> {
await this.ensureInitialized();
try {
// 检查是否有收藏项使用此分类
const favoritesInCategory = await this.getFavorites({ categoryId: id });
if (favoritesInCategory.length > 0) {
throw new FavoriteValidationError(`无法删除分类,还有 ${favoritesInCategory.length} 个收藏项使用此分类`);
// ✅ 获取该分类下的所有收藏
const allFavorites = await this.getFavorites();
const favoritesInCategory = allFavorites.filter(f => f.category === id);
// ✅ 清空这些收藏的分类字段(不依赖"未分类"是否存在)
for (const favorite of favoritesInCategory) {
await this.updateFavorite(favorite.id, {
...favorite,
category: undefined // 清空分类
});
}
// ✅ 删除分类
await this.storageProvider.updateData(this.STORAGE_KEYS.CATEGORIES, (categories: FavoriteCategory[] | null) => {
const categoriesList = categories || [];
const index = categoriesList.findIndex(c => c.id === id);
@@ -542,12 +534,14 @@ export class FavoriteManager implements IFavoriteManager {
return categoriesList.filter(c => c.id !== id);
});
return favoritesInCategory.length;
} catch (error) {
if (error instanceof FavoriteError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FavoriteStorageError(`删除分类失败: ${errorMessage}`);
throw new FavoriteStorageError(`Failed to delete category: ${errorMessage}`);
}
}
@@ -562,7 +556,7 @@ export class FavoriteManager implements IFavoriteManager {
return await this.updateStats();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FavoriteStorageError(`获取统计信息失败: ${errorMessage}`);
throw new FavoriteStorageError(`Failed to get statistics: ${errorMessage}`);
}
}
@@ -617,6 +611,21 @@ export class FavoriteManager implements IFavoriteManager {
});
}
/**
* 获取独立标签库中的所有标签名称
* @private
*/
private async getAllIndependentTags(): Promise<string[]> {
try {
const storedTags = await this.storageProvider.getItem(this.STORAGE_KEYS.TAGS);
const independentTags: FavoriteTag[] = storedTags ? JSON.parse(storedTags) : [];
return independentTags.map(t => t.tag);
} catch (error) {
console.warn('获取独立标签失败:', error);
return [];
}
}
async exportFavorites(ids?: string[]): Promise<string> {
try {
let favorites: FavoritePrompt[];
@@ -628,44 +637,120 @@ export class FavoriteManager implements IFavoriteManager {
}
const categories = await this.getCategories();
const tags = await this.getAllIndependentTags();
const exportData = {
version: '1.0',
exportDate: new Date().toISOString(),
favorites,
categories
categories,
tags // 导出独立标签库(包含所有标签:使用中的 + 预创建的)
};
return JSON.stringify(exportData, null, 2);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FavoriteStorageError(`导出收藏失败: ${errorMessage}`);
throw new FavoriteImportExportError(
`Failed to export favorites: ${errorMessage}`,
error instanceof Error ? error : undefined
);
}
}
/**
* 计算标签使用统计
* @private
* @returns 包含标签名和使用次数的 Map
*/
private async computeTagCounts(): Promise<Map<string, number>> {
// 1. 获取独立标签
const storedTags = await this.storageProvider.getItem(this.STORAGE_KEYS.TAGS);
const independentTags: FavoriteTag[] = storedTags ? JSON.parse(storedTags) : [];
// 2. 统计收藏项中使用的标签
const favorites = await this.getFavorites();
const tagCounts = new Map<string, number>();
favorites.forEach(favorite => {
favorite.tags.forEach(tag => {
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
});
});
// 3. 合并独立标签和使用中的标签
// 独立标签如果未被使用,count 为 0
independentTags.forEach(({ tag }) => {
if (!tagCounts.has(tag)) {
tagCounts.set(tag, 0);
}
});
return tagCounts;
}
async getAllTags(): Promise<Array<{ tag: string; count: number }>> {
try {
const favorites = await this.getFavorites();
const tagCounts = new Map<string, number>();
favorites.forEach(favorite => {
favorite.tags.forEach(tag => {
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
});
});
const tagCounts = await this.computeTagCounts();
// 返回排序后的结果(使用次数降序,相同次数按标签名升序)
return Array.from(tagCounts.entries())
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count); // 按使用次数降序
.sort((a, b) => {
if (b.count !== a.count) {
return b.count - a.count; // 按使用次数降序
}
return a.tag.localeCompare(b.tag); // 相同次数按标签名升序
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FavoriteStorageError(`获取标签列表失败: ${errorMessage}`);
throw new FavoriteStorageError(`Failed to get tags: ${errorMessage}`);
}
}
async addTag(tag: string): Promise<void> {
await this.ensureInitialized();
const trimmedTag = tag.trim();
if (!trimmedTag) {
throw new FavoriteValidationError('Tag name cannot be empty');
}
try {
await this.storageProvider.updateData(this.STORAGE_KEYS.TAGS, (tags: FavoriteTag[] | null) => {
const tagsList = tags || [];
// 检查是否已存在
const existing = tagsList.find(t => t.tag === trimmedTag);
if (existing) {
throw new FavoriteTagAlreadyExistsError(trimmedTag);
}
const now = Date.now();
const newTag: FavoriteTag = {
tag: trimmedTag,
createdAt: now
};
return [...tagsList, newTag];
});
// 更新统计信息
await this.updateStats();
} catch (error) {
if (error instanceof FavoriteError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FavoriteStorageError(
`Failed to add tag: ${errorMessage}`,
error instanceof Error ? error : undefined
);
}
}
async renameTag(oldTag: string, newTag: string): Promise<number> {
if (!oldTag || !newTag) {
throw new FavoriteValidationError('标签名不能为空');
throw new FavoriteValidationError('Tag name cannot be empty');
}
if (oldTag === newTag) {
@@ -673,8 +758,21 @@ export class FavoriteManager implements IFavoriteManager {
}
let affectedCount = 0;
let oldTagExistedInIndependentLib = false;
try {
// 1. 更新独立标签库:删除旧标签,记录是否存在
await this.storageProvider.updateData(this.STORAGE_KEYS.TAGS, (tags: FavoriteTag[] | null) => {
const tagsList = tags || [];
// 检查旧标签是否存在
oldTagExistedInIndependentLib = tagsList.some(t => t.tag === oldTag);
// 删除旧标签
return tagsList.filter(t => t.tag !== oldTag);
});
// 2. 更新收藏列表中的标签
await this.storageProvider.updateData(this.STORAGE_KEYS.FAVORITES, (favorites: FavoritePrompt[] | null) => {
const favoritesList = favorites || [];
@@ -695,26 +793,64 @@ export class FavoriteManager implements IFavoriteManager {
return favoritesList;
});
// 3. 只有当旧标签存在于独立库或被收藏使用时,才添加新标签到独立库
if (oldTagExistedInIndependentLib || affectedCount > 0) {
await this.storageProvider.updateData(this.STORAGE_KEYS.TAGS, (tags: FavoriteTag[] | null) => {
const tagsList = tags || [];
// 添加新标签(如果不存在)
const hasNewTag = tagsList.some(t => t.tag === newTag);
if (!hasNewTag) {
tagsList.push({
tag: newTag,
createdAt: Date.now()
});
}
return tagsList;
});
}
await this.updateStats();
return affectedCount;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FavoriteStorageError(`重命名标签失败: ${errorMessage}`);
throw new FavoriteStorageError(`Failed to rename tag: ${errorMessage}`);
}
}
async mergeTags(sourceTags: string[], targetTag: string): Promise<number> {
if (!sourceTags || sourceTags.length === 0) {
throw new FavoriteValidationError('源标签列表不能为空');
throw new FavoriteValidationError('Source tag list cannot be empty');
}
if (!targetTag) {
throw new FavoriteValidationError('目标标签不能为空');
throw new FavoriteValidationError('Target tag cannot be empty');
}
let affectedCount = 0;
try {
// 1. 更新独立标签库:删除所有源标签,确保目标标签存在
await this.storageProvider.updateData(this.STORAGE_KEYS.TAGS, (tags: FavoriteTag[] | null) => {
const tagsList = tags || [];
// 删除所有源标签
const filteredTags = tagsList.filter(t => !sourceTags.includes(t.tag));
// 确保目标标签存在
const hasTargetTag = filteredTags.some(t => t.tag === targetTag);
if (!hasTargetTag) {
filteredTags.push({
tag: targetTag,
createdAt: Date.now()
});
}
return filteredTags;
});
// 2. 更新收藏列表中的标签
await this.storageProvider.updateData(this.STORAGE_KEYS.FAVORITES, (favorites: FavoritePrompt[] | null) => {
const favoritesList = favorites || [];
@@ -747,18 +883,25 @@ export class FavoriteManager implements IFavoriteManager {
return affectedCount;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FavoriteStorageError(`合并标签失败: ${errorMessage}`);
throw new FavoriteStorageError(`Failed to merge tags: ${errorMessage}`);
}
}
async deleteTag(tag: string): Promise<number> {
if (!tag) {
throw new FavoriteValidationError('标签名不能为空');
throw new FavoriteValidationError('Tag name cannot be empty');
}
let affectedCount = 0;
try {
// 1. 从独立标签中删除
await this.storageProvider.updateData(this.STORAGE_KEYS.TAGS, (tags: FavoriteTag[] | null) => {
const tagsList = tags || [];
return tagsList.filter(t => t.tag !== tag);
});
// 2. 从所有收藏项中删除
await this.storageProvider.updateData(this.STORAGE_KEYS.FAVORITES, (favorites: FavoritePrompt[] | null) => {
const favoritesList = favorites || [];
@@ -778,13 +921,13 @@ export class FavoriteManager implements IFavoriteManager {
return affectedCount;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FavoriteStorageError(`删除标签失败: ${errorMessage}`);
throw new FavoriteStorageError(`Failed to delete tag: ${errorMessage}`);
}
}
async reorderCategories(categoryIds: string[]): Promise<void> {
if (!categoryIds || categoryIds.length === 0) {
throw new FavoriteValidationError('分类ID列表不能为空');
throw new FavoriteValidationError('Category ID list cannot be empty');
}
try {
@@ -820,7 +963,7 @@ export class FavoriteManager implements IFavoriteManager {
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FavoriteStorageError(`重新排序分类失败: ${errorMessage}`);
throw new FavoriteStorageError(`Failed to reorder categories: ${errorMessage}`);
}
}
@@ -830,7 +973,7 @@ export class FavoriteManager implements IFavoriteManager {
return favorites.length;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FavoriteStorageError(`获取分类使用统计失败: ${errorMessage}`);
throw new FavoriteStorageError(`Failed to get category usage: ${errorMessage}`);
}
}
@@ -850,7 +993,43 @@ export class FavoriteManager implements IFavoriteManager {
const importData = JSON.parse(data);
if (!importData.favorites || !Array.isArray(importData.favorites)) {
throw new FavoriteValidationError('导入数据格式无效');
throw new FavoriteValidationError('Invalid import data format');
}
// 【新增】先导入分类(如果有)
if (importData.categories && Array.isArray(importData.categories)) {
for (const category of importData.categories) {
try {
// 检查分类是否已存在(根据ID或名称)
const existingCategories = await this.getCategories();
const exists = existingCategories.some(
c => c.id === category.id || c.name === category.name
);
if (!exists) {
await this.addCategory({
name: category.name,
description: category.description,
color: category.color,
sortOrder: category.sortOrder
});
}
} catch (error) {
// 分类导入失败,记录错误但继续
console.warn('Failed to import category:', category.name, error);
}
}
}
// 【新增】先导入独立标签(如果有)
if (importData.tags && Array.isArray(importData.tags)) {
for (const tag of importData.tags) {
try {
await this.addTag(tag);
} catch (error) {
// 标签已存在,跳过错误继续
}
}
}
const existingFavorites = await this.getFavorites();
@@ -860,7 +1039,7 @@ export class FavoriteManager implements IFavoriteManager {
try {
// 验证必填字段
if (!favorite.content?.trim()) {
throw new FavoriteValidationError('导入数据中存在空内容的收藏项');
throw new FavoriteValidationError('Import data contains favorite with empty content');
}
// 构建功能模式数据,兼容旧数据
@@ -872,7 +1051,7 @@ export class FavoriteManager implements IFavoriteManager {
const mapping = { functionMode, optimizationMode, imageSubMode };
if (!TypeMapper.validateMapping(mapping)) {
throw new FavoriteValidationError(
`导入数据中存在无效的功能模式分类: functionMode=${functionMode}, optimizationMode=${optimizationMode}, imageSubMode=${imageSubMode}`
`Invalid function mode in import data: functionMode=${functionMode}, optimizationMode=${optimizationMode}, imageSubMode=${imageSubMode}`
);
}
@@ -912,7 +1091,7 @@ export class FavoriteManager implements IFavoriteManager {
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
result.errors.push(`导入收藏失败: ${errorMessage}`);
result.errors.push(`Failed to import favorite: ${errorMessage}`);
}
}
@@ -920,7 +1099,11 @@ export class FavoriteManager implements IFavoriteManager {
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new FavoriteStorageError(`导入收藏失败: ${errorMessage}`);
throw new FavoriteImportExportError(
`Failed to import favorites: ${errorMessage}`,
error instanceof Error ? error : undefined,
result.errors.length > 0 ? result.errors : undefined
);
}
}
}
@@ -0,0 +1,90 @@
import type { TagStatistics } from './types';
/**
* 标签类型转换工具
* 统一处理不同格式之间的标签数据转换
*/
export class TagTypeConverter {
/**
* 将 API 返回的标签数据转换为 TagStatistics 格式
* @param apiData API 返回的标签数据 { tag: string; count: number }[]
* @returns TagStatistics[] 格式的标签统计数据
*/
static toTagStatistics(apiData: Array<{ tag: string; count: number }>): TagStatistics[] {
return apiData.map(item => ({
name: item.tag,
count: item.count,
lastUsed: undefined
}));
}
/**
* 将 TagStatistics 转换回 API 格式
* @param stats TagStatistics[] 格式的标签统计数据
* @returns API 格式的标签数据 { tag: string; count: number }[]
*/
static fromTagStatistics(stats: TagStatistics[]): Array<{ tag: string; count: number }> {
return stats.map(item => ({
tag: item.name,
count: item.count
}));
}
/**
* 将标签数据转换为自动完成选项格式
* @param apiData API 返回的标签数据
* @returns 自动完成选项格式 { label: string; value: string; count: number }[]
*/
static toAutoCompleteOptions(apiData: Array<{ tag: string; count: number }>): Array<{
label: string;
value: string;
count: number;
}> {
return apiData.map(item => ({
label: `${item.tag} (${item.count})`,
value: item.tag,
count: item.count
}));
}
/**
* 将标签数据转换为简单的字符串数组
* @param apiData API 返回的标签数据
* @returns 标签名称数组
*/
static toStringArray(apiData: Array<{ tag: string; count: number }>): string[] {
return apiData.map(item => item.tag);
}
/**
* 按使用次数降序排序标签
* @param tags 标签数据
* @returns 排序后的标签数据
*/
static sortByCount<T extends { count: number }>(tags: T[]): T[] {
return [...tags].sort((a, b) => b.count - a.count);
}
/**
* 按标签名称升序排序标签
* @param tags 标签数据
* @returns 排序后的标签数据
*/
static sortByName(tags: TagStatistics[]): TagStatistics[] {
return [...tags].sort((a, b) => a.name.localeCompare(b.name));
}
/**
* 混合排序:先按使用次数降序,相同次数按名称升序
* @param tags 标签数据
* @returns 排序后的标签数据
*/
static sortByCountThenName(tags: TagStatistics[]): TagStatistics[] {
return [...tags].sort((a, b) => {
if (b.count !== a.count) {
return b.count - a.count;
}
return a.name.localeCompare(b.name);
});
}
}
+29 -3
View File
@@ -84,6 +84,29 @@ export interface FavoriteStats {
lastUsedAt?: number;
}
/**
* 独立标签接口
*/
export interface FavoriteTag {
/** 标签名称 */
tag: string;
/** 创建时间 */
createdAt: number;
}
/**
* 标签统计信息接口
* 用于标签管理器展示标签使用情况
*/
export interface TagStatistics {
/** 标签名称 */
name: string;
/** 使用次数 */
count: number;
/** 最后使用时间(可选,暂未实现) */
lastUsed?: number;
}
/**
* 收藏管理器接口
*/
@@ -127,7 +150,7 @@ export interface IFavoriteManager {
updateCategory(id: string, updates: Partial<FavoriteCategory>): Promise<void>;
/** 删除分类 */
deleteCategory(id: string): Promise<void>;
deleteCategory(id: string): Promise<number>;
/** 获取统计信息 */
getStats(): Promise<FavoriteStats>;
@@ -151,16 +174,19 @@ export interface IFavoriteManager {
errors: string[];
}>;
/** 获取所有标签及其使用统计 */
/** 获取所有标签及其使用统计(包含独立标签和使用中的标签) */
getAllTags(): Promise<Array<{ tag: string; count: number }>>;
/** 添加独立标签 */
addTag(tag: string): Promise<void>;
/** 重命名标签 */
renameTag(oldTag: string, newTag: string): Promise<number>;
/** 合并多个标签为一个 */
mergeTags(sourceTags: string[], targetTag: string): Promise<number>;
/** 删除标签 */
/** 删除标签(同时从独立标签和所有收藏项中删除) */
deleteTag(tag: string): Promise<number>;
/** 对分类进行重新排序 */
@@ -0,0 +1,741 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { FavoriteManager } from '../../../src/services/favorite/manager';
import type { IStorageProvider } from '../../../src/services/storage/types';
import { TypeMapper } from '../../../src/services/favorite/type-mapper';
import type { PromptRecordType } from '../../../src/services/history/types';
/**
* FavoriteManager 集成测试
* 测试完整的业务流程和跨功能交互
*/
describe('FavoriteManager - 集成测试', () => {
let manager: FavoriteManager;
let mockStorage: Map<string, string>;
let storageProvider: IStorageProvider;
beforeEach(() => {
// 创建模拟存储
mockStorage = new Map<string, string>();
storageProvider = {
getItem: vi.fn(async (key: string) => mockStorage.get(key) || null),
setItem: vi.fn(async (key: string, value: string) => {
mockStorage.set(key, value);
}),
removeItem: vi.fn(async (key: string) => {
mockStorage.delete(key);
}),
clearAll: vi.fn(async () => {
mockStorage.clear();
}),
batchUpdate: vi.fn(async (operations: Array<{ key: string; operation: 'set' | 'remove'; value?: string }>) => {
operations.forEach(({ key, operation, value }) => {
if (operation === 'set' && value) {
mockStorage.set(key, value);
} else if (operation === 'remove') {
mockStorage.delete(key);
}
});
}),
updateData: vi.fn(async (key: string, updater: (data: any) => any) => {
const currentData = mockStorage.get(key);
const parsedData = currentData ? JSON.parse(currentData) : null;
const updatedData = updater(parsedData);
mockStorage.set(key, JSON.stringify(updatedData));
})
};
manager = new FavoriteManager(storageProvider);
});
describe('收藏 CRUD 完整流程', () => {
it('应该完成完整的增删改查流程', async () => {
// 1. 创建分类
const categoryId = await manager.addCategory({
name: '测试分类',
description: '用于集成测试',
color: '#FF5722'
});
// 2. 添加收藏
const favoriteId = await manager.addFavorite({
title: '测试收藏',
content: '测试内容',
tags: ['测试', '集成'],
category: categoryId,
functionMode: 'basic',
optimizationMode: 'system',
metadata: {
originalContent: '原始内容',
sourceHistoryId: 'history-001'
}
});
expect(favoriteId).toBeTruthy();
// 3. 查询单个收藏
const favorite = await manager.getFavorite(favoriteId);
expect(favorite.id).toBe(favoriteId);
expect(favorite.title).toBe('测试收藏');
expect(favorite.tags).toEqual(['测试', '集成']);
expect(favorite.category).toBe(categoryId);
expect(favorite.functionMode).toBe('basic');
expect(favorite.optimizationMode).toBe('system');
expect(favorite.metadata?.originalContent).toBe('原始内容');
expect(favorite.metadata?.sourceHistoryId).toBe('history-001');
// 4. 查询列表
const favorites = await manager.getFavorites();
expect(favorites.length).toBe(1);
expect(favorites[0].id).toBe(favoriteId);
// 5. 更新收藏
await manager.updateFavorite(favoriteId, {
title: '更新后的标题',
tags: ['测试', '集成', '更新'],
functionMode: 'context',
optimizationMode: 'user'
});
const updated = await manager.getFavorite(favoriteId);
expect(updated.title).toBe('更新后的标题');
expect(updated.tags).toEqual(['测试', '集成', '更新']);
expect(updated.functionMode).toBe('context');
expect(updated.optimizationMode).toBe('user');
expect(updated.updatedAt).not.toBe(favorite.updatedAt);
// 6. 删除收藏
await manager.deleteFavorite(favoriteId);
const allFavorites = await manager.getFavorites();
expect(allFavorites.length).toBe(0);
// 7. 验证分类仍然存在
const categories = await manager.getCategories();
expect(categories.find(c => c.id === categoryId)).toBeDefined();
});
it('应该正确处理多个收藏的关联关系', async () => {
// 创建2个分类
const cat1Id = await manager.addCategory({ name: '分类1', color: '#FF0000' });
const cat2Id = await manager.addCategory({ name: '分类2', color: '#00FF00' });
// 创建3个收藏
const fav1Id = await manager.addFavorite({
title: '收藏1',
content: '内容1',
tags: ['共享标签', '标签1'],
category: cat1Id,
functionMode: 'basic',
optimizationMode: 'system'
});
const fav2Id = await manager.addFavorite({
title: '收藏2',
content: '内容2',
tags: ['共享标签', '标签2'],
category: cat1Id,
functionMode: 'basic',
optimizationMode: 'user'
});
const fav3Id = await manager.addFavorite({
title: '收藏3',
content: '内容3',
tags: ['标签3'],
category: cat2Id,
functionMode: 'image',
imageSubMode: 'text2image'
});
// 验证标签统计
const tags = await manager.getAllTags();
const sharedTag = tags.find(t => t.tag === '共享标签');
expect(sharedTag?.count).toBe(2);
// 验证分类使用统计
const cat1Usage = await manager.getCategoryUsage(cat1Id);
const cat2Usage = await manager.getCategoryUsage(cat2Id);
expect(cat1Usage).toBe(2);
expect(cat2Usage).toBe(1);
// 按分类查询
const cat1Favorites = await manager.getFavorites({ categoryId: cat1Id });
expect(cat1Favorites.length).toBe(2);
// 按标签查询
const sharedTagFavorites = await manager.getFavorites({ tags: ['共享标签'] });
expect(sharedTagFavorites.length).toBe(2);
});
it('应该支持功能模式验证的完整流程', async () => {
// 添加各种模式的收藏
const basicId = await manager.addFavorite({
title: 'Basic Mode',
content: 'Content',
tags: [],
functionMode: 'basic',
optimizationMode: 'system'
});
const contextId = await manager.addFavorite({
title: 'Context Mode',
content: 'Content',
tags: [],
functionMode: 'context',
optimizationMode: 'user'
});
const imageId = await manager.addFavorite({
title: 'Image Mode',
content: 'Content',
tags: [],
functionMode: 'image',
imageSubMode: 'text2image'
});
// 验证每个收藏的模式
const basic = await manager.getFavorite(basicId);
expect(basic.functionMode).toBe('basic');
expect(basic.optimizationMode).toBe('system');
expect(basic.imageSubMode).toBeUndefined();
const context = await manager.getFavorite(contextId);
expect(context.functionMode).toBe('context');
expect(context.optimizationMode).toBe('user');
expect(context.imageSubMode).toBeUndefined();
const image = await manager.getFavorite(imageId);
expect(image.functionMode).toBe('image');
expect(image.imageSubMode).toBe('text2image');
expect(image.optimizationMode).toBeUndefined();
// 更新模式应该生效
await manager.updateFavorite(basicId, {
functionMode: 'context',
optimizationMode: 'system'
});
const updated = await manager.getFavorite(basicId);
expect(updated.functionMode).toBe('context');
expect(updated.optimizationMode).toBe('system');
});
});
describe('从优化历史保存收藏集成测试', () => {
it('应该从 optimize 类型正确创建收藏', async () => {
// 模拟历史记录
const recordType: PromptRecordType = 'optimize';
const mapping = TypeMapper.mapFromRecordType(recordType);
// 创建收藏
const favoriteId = await manager.addFavorite({
title: '优化后的提示词',
content: '这是优化后的内容',
tags: ['AI', '优化'],
...mapping,
metadata: {
originalContent: '这是原始内容',
sourceHistoryId: 'hist-123'
}
});
// 验证
const favorite = await manager.getFavorite(favoriteId);
expect(favorite.functionMode).toBe('basic');
expect(favorite.optimizationMode).toBe('system');
expect(favorite.metadata?.originalContent).toBe('这是原始内容');
expect(favorite.metadata?.sourceHistoryId).toBe('hist-123');
});
it('应该从 contextUserOptimize 类型正确创建收藏', async () => {
const recordType: PromptRecordType = 'contextUserOptimize';
const mapping = TypeMapper.mapFromRecordType(recordType);
const favoriteId = await manager.addFavorite({
title: '用户上下文优化',
content: '优化内容',
tags: [],
...mapping,
metadata: {
originalContent: '原始内容',
sourceHistoryId: 'hist-456'
}
});
const favorite = await manager.getFavorite(favoriteId);
expect(favorite.functionMode).toBe('context');
expect(favorite.optimizationMode).toBe('user');
});
it('应该从 imageOptimize 类型正确创建收藏', async () => {
const recordType: PromptRecordType = 'imageOptimize';
const mapping = TypeMapper.mapFromRecordType(recordType);
const favoriteId = await manager.addFavorite({
title: '图像提示词优化',
content: '优化后的图像提示词',
tags: ['图像生成'],
...mapping
});
const favorite = await manager.getFavorite(favoriteId);
expect(favorite.functionMode).toBe('image');
expect(favorite.imageSubMode).toBe('text2image');
});
it('应该从 image2imageOptimize 类型正确创建收藏', async () => {
const recordType: PromptRecordType = 'image2imageOptimize';
const mapping = TypeMapper.mapFromRecordType(recordType);
const favoriteId = await manager.addFavorite({
title: '图生图提示词',
content: '图生图优化内容',
tags: [],
...mapping
});
const favorite = await manager.getFavorite(favoriteId);
expect(favorite.functionMode).toBe('image');
expect(favorite.imageSubMode).toBe('image2image');
});
it('应该处理所有历史记录类型', async () => {
const allTypes: PromptRecordType[] = [
'optimize',
'userOptimize',
'iterate',
'test',
'contextSystemOptimize',
'contextUserOptimize',
'contextIterate',
'imageOptimize',
'contextImageOptimize',
'imageIterate',
'text2imageOptimize',
'image2imageOptimize'
];
const ids: string[] = [];
for (const type of allTypes) {
const mapping = TypeMapper.mapFromRecordType(type);
const id = await manager.addFavorite({
title: `收藏-${type}`,
content: `内容-${type}`,
tags: [type],
...mapping
});
ids.push(id);
}
// 验证所有收藏都创建成功
expect(ids.length).toBe(allTypes.length);
const favorites = await manager.getFavorites();
expect(favorites.length).toBe(allTypes.length);
// 验证每个收藏的功能模式都有效
for (const favorite of favorites) {
const mapping = {
functionMode: favorite.functionMode,
optimizationMode: favorite.optimizationMode,
imageSubMode: favorite.imageSubMode
};
expect(TypeMapper.validateMapping(mapping)).toBe(true);
}
});
});
describe('标签和分类管理集成测试', () => {
it('标签重命名应该更新所有关联收藏', async () => {
// 创建多个使用相同标签的收藏
await manager.addFavorite({
title: '收藏1',
content: '内容1',
tags: ['旧标签', '其他标签'],
functionMode: 'basic',
optimizationMode: 'system'
});
await manager.addFavorite({
title: '收藏2',
content: '内容2',
tags: ['旧标签'],
functionMode: 'basic',
optimizationMode: 'user'
});
await manager.addFavorite({
title: '收藏3',
content: '内容3',
tags: ['不相关标签'],
functionMode: 'basic',
optimizationMode: 'system'
});
// 重命名标签
await manager.renameTag('旧标签', '新标签');
// 验证所有收藏更新
const favorites = await manager.getFavorites();
const fav1 = favorites.find(f => f.title === '收藏1');
const fav2 = favorites.find(f => f.title === '收藏2');
const fav3 = favorites.find(f => f.title === '收藏3');
expect(fav1?.tags).toContain('新标签');
expect(fav1?.tags).not.toContain('旧标签');
expect(fav1?.tags).toContain('其他标签');
expect(fav2?.tags).toContain('新标签');
expect(fav2?.tags).not.toContain('旧标签');
expect(fav3?.tags).toContain('不相关标签');
expect(fav3?.tags).not.toContain('新标签');
// 验证标签统计
const tags = await manager.getAllTags();
const newTag = tags.find(t => t.tag === '新标签');
const oldTag = tags.find(t => t.tag === '旧标签');
expect(newTag?.count).toBe(2);
expect(oldTag).toBeUndefined();
});
it('标签合并应该正确去重', async () => {
// 创建测试数据
await manager.addFavorite({
title: '收藏1',
content: '内容',
tags: ['标签A', '标签B'],
functionMode: 'basic',
optimizationMode: 'system'
});
await manager.addFavorite({
title: '收藏2',
content: '内容',
tags: ['标签A', '标签C'],
functionMode: 'basic',
optimizationMode: 'system'
});
await manager.addFavorite({
title: '收藏3',
content: '内容',
tags: ['标签B', '标签C'],
functionMode: 'basic',
optimizationMode: 'system'
});
// 合并标签A和标签B -> 标签B
await manager.mergeTags(['标签A'], '标签B');
const favorites = await manager.getFavorites();
const fav1 = favorites.find(f => f.title === '收藏1');
const fav2 = favorites.find(f => f.title === '收藏2');
const fav3 = favorites.find(f => f.title === '收藏3');
// 收藏1: 原本 [A, B] -> [B] (去重)
expect(fav1?.tags).toEqual(['标签B']);
// 收藏2: 原本 [A, C] -> [B, C]
expect(fav2?.tags).toContain('标签B');
expect(fav2?.tags).toContain('标签C');
expect(fav2?.tags).not.toContain('标签A');
// 收藏3: 原本 [B, C] -> [B, C] (不变)
expect(fav3?.tags).toContain('标签B');
expect(fav3?.tags).toContain('标签C');
// 验证标签统计
const tags = await manager.getAllTags();
const tagA = tags.find(t => t.tag === '标签A');
const tagB = tags.find(t => t.tag === '标签B');
expect(tagA).toBeUndefined();
expect(tagB?.count).toBe(3); // 所有3个收藏都有标签B
});
it('标签删除应该从所有收藏中移除', async () => {
await manager.addFavorite({
title: '收藏1',
content: '内容',
tags: ['要删除', '保留'],
functionMode: 'basic',
optimizationMode: 'system'
});
await manager.addFavorite({
title: '收藏2',
content: '内容',
tags: ['要删除'],
functionMode: 'basic',
optimizationMode: 'system'
});
await manager.deleteTag('要删除');
const favorites = await manager.getFavorites();
favorites.forEach(fav => {
expect(fav.tags).not.toContain('要删除');
});
const tags = await manager.getAllTags();
expect(tags.find(t => t.tag === '要删除')).toBeUndefined();
expect(tags.find(t => t.tag === '保留')).toBeDefined();
});
it('分类排序应该更新所有分类的 sortOrder', async () => {
const cat1 = await manager.addCategory({ name: '分类1', color: '#FF0000' });
const cat2 = await manager.addCategory({ name: '分类2', color: '#00FF00' });
const cat3 = await manager.addCategory({ name: '分类3', color: '#0000FF' });
// 新顺序: 3, 1, 2
await manager.reorderCategories([cat3, cat1, cat2]);
const categories = await manager.getCategories();
const sorted = categories.sort((a, b) => a.sortOrder - b.sortOrder);
expect(sorted[0].id).toBe(cat3);
expect(sorted[0].sortOrder).toBe(0);
expect(sorted[1].id).toBe(cat1);
expect(sorted[1].sortOrder).toBe(1);
expect(sorted[2].id).toBe(cat2);
expect(sorted[2].sortOrder).toBe(2);
});
it('分类使用统计应该实时更新', async () => {
const catId = await manager.addCategory({ name: '测试分类', color: '#FF0000' });
// 初始使用为0
expect(await manager.getCategoryUsage(catId)).toBe(0);
// 添加收藏
const fav1 = await manager.addFavorite({
title: '收藏1',
content: '内容',
tags: [],
category: catId,
functionMode: 'basic',
optimizationMode: 'system'
});
expect(await manager.getCategoryUsage(catId)).toBe(1);
const fav2 = await manager.addFavorite({
title: '收藏2',
content: '内容',
tags: [],
category: catId,
functionMode: 'basic',
optimizationMode: 'system'
});
expect(await manager.getCategoryUsage(catId)).toBe(2);
// 删除一个收藏
await manager.deleteFavorite(fav1);
expect(await manager.getCategoryUsage(catId)).toBe(1);
// 更新收藏移除分类
await manager.updateFavorite(fav2, { category: undefined });
expect(await manager.getCategoryUsage(catId)).toBe(0);
});
it('删除分类应该清空关联收藏的分类字段', async () => {
const catId = await manager.addCategory({ name: '要删除的分类', color: '#FF0000' });
const fav1 = await manager.addFavorite({
title: '收藏1',
content: '内容',
tags: [],
category: catId,
functionMode: 'basic',
optimizationMode: 'system'
});
const fav2 = await manager.addFavorite({
title: '收藏2',
content: '内容',
tags: [],
category: catId,
functionMode: 'basic',
optimizationMode: 'system'
});
const affectedCount = await manager.deleteCategory(catId);
expect(affectedCount).toBe(2);
const updated1 = await manager.getFavorite(fav1);
const updated2 = await manager.getFavorite(fav2);
expect(updated1.category).toBeUndefined();
expect(updated2.category).toBeUndefined();
});
});
describe('导入导出集成测试', () => {
it('应该正确导出和导入包含所有关联数据', async () => {
// 创建完整的测试数据
const catId = await manager.addCategory({
name: '测试分类',
description: '描述',
color: '#FF5722'
});
await manager.addFavorite({
title: '完整收藏',
content: '内容',
tags: ['标签1', '标签2'],
category: catId,
functionMode: 'basic',
optimizationMode: 'system',
metadata: {
originalContent: '原始内容',
sourceHistoryId: 'hist-001',
customField: '自定义值'
}
});
// 导出
const exportData = await manager.exportFavorites();
// 清空数据
await storageProvider.clearAll();
// 导入
await manager.importFavorites(exportData);
// 验证
const favorites = await manager.getFavorites();
expect(favorites.length).toBe(1);
const favorite = favorites[0];
expect(favorite.title).toBe('完整收藏');
expect(favorite.tags).toEqual(['标签1', '标签2']);
expect(favorite.category).toBe(catId);
expect(favorite.functionMode).toBe('basic');
expect(favorite.optimizationMode).toBe('system');
expect(favorite.metadata?.originalContent).toBe('原始内容');
expect(favorite.metadata?.sourceHistoryId).toBe('hist-001');
expect(favorite.metadata?.customField).toBe('自定义值');
const categories = await manager.getCategories();
expect(categories.length).toBe(1);
expect(categories[0].name).toBe('测试分类');
});
it('应该正确处理导入时的分类和标签关联', async () => {
const cat1 = await manager.addCategory({ name: '分类1', color: '#FF0000' });
const cat2 = await manager.addCategory({ name: '分类2', color: '#00FF00' });
await manager.addFavorite({
title: '收藏1',
content: '内容1',
tags: ['共享', 'A'],
category: cat1,
functionMode: 'basic',
optimizationMode: 'system'
});
await manager.addFavorite({
title: '收藏2',
content: '内容2',
tags: ['共享', 'B'],
category: cat2,
functionMode: 'context',
optimizationMode: 'user'
});
const exportData = await manager.exportFavorites();
await storageProvider.clearAll();
await manager.importFavorites(exportData);
// 验证分类
const categories = await manager.getCategories();
expect(categories.length).toBe(2);
// 验证标签
const tags = await manager.getAllTags();
const sharedTag = tags.find(t => t.tag === '共享');
expect(sharedTag?.count).toBe(2);
// 验证收藏
const favorites = await manager.getFavorites();
expect(favorites.length).toBe(2);
});
});
describe('搜索和过滤集成测试', () => {
beforeEach(async () => {
// 创建测试数据集
const cat1 = await manager.addCategory({ name: 'AI工具', color: '#FF0000' });
const cat2 = await manager.addCategory({ name: '写作助手', color: '#00FF00' });
await manager.addFavorite({
title: 'ChatGPT提示词',
content: '帮助我写一个关于AI的文章',
tags: ['AI', 'ChatGPT'],
category: cat1,
functionMode: 'basic',
optimizationMode: 'system'
});
await manager.addFavorite({
title: '创意写作助手',
content: '帮我生成创意故事大纲',
tags: ['写作', '创意'],
category: cat2,
functionMode: 'context',
optimizationMode: 'user'
});
await manager.addFavorite({
title: 'AI绘图提示词',
content: 'a beautiful sunset over mountains',
tags: ['AI', '绘图'],
category: cat1,
functionMode: 'image',
imageSubMode: 'text2image'
});
});
it('应该能按关键词搜索', async () => {
const results = await manager.searchFavorites('AI');
expect(results.length).toBe(2);
expect(results.every(f => f.title.includes('AI') || f.content.includes('AI'))).toBe(true);
});
it('应该能按分类过滤', async () => {
const categories = await manager.getCategories();
const aiCategory = categories.find(c => c.name === 'AI工具');
const results = await manager.getFavorites({ categoryId: aiCategory!.id });
expect(results.length).toBe(2);
expect(results.every(f => f.category === aiCategory!.id)).toBe(true);
});
it('应该能按标签过滤', async () => {
const results = await manager.getFavorites({ tags: ['AI'] });
expect(results.length).toBe(2);
expect(results.every(f => f.tags.includes('AI'))).toBe(true);
});
it('应该支持组合过滤', async () => {
const categories = await manager.getCategories();
const aiCategory = categories.find(c => c.name === 'AI工具');
const results = await manager.searchFavorites('提示词', {
tags: ['AI'],
categoryId: aiCategory!.id
});
expect(results.length).toBeGreaterThan(0);
results.forEach(f => {
expect(f.title.includes('提示词') || f.content.includes('提示词')).toBe(true);
expect(f.tags.includes('AI')).toBe(true);
expect(f.category).toBe(aiCategory!.id);
});
});
});
});
@@ -0,0 +1,515 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { FavoriteManager } from '../../../src/services/favorite/manager';
import type { IStorageProvider } from '../../../src/services/storage/types';
import { FavoriteValidationError } from '../../../src/services/favorite/errors';
import { TypeMapper } from '../../../src/services/favorite/type-mapper';
/**
* FavoriteManager 扩展功能单元测试
* 测试 functionMode 验证、分类管理等功能
*/
describe('FavoriteManager - 扩展功能', () => {
let manager: FavoriteManager;
let mockStorage: Map<string, string>;
let storageProvider: IStorageProvider;
beforeEach(() => {
// 创建模拟存储
mockStorage = new Map<string, string>();
storageProvider = {
getItem: vi.fn(async (key: string) => mockStorage.get(key) || null),
setItem: vi.fn(async (key: string, value: string) => {
mockStorage.set(key, value);
}),
removeItem: vi.fn(async (key: string) => {
mockStorage.delete(key);
}),
clearAll: vi.fn(async () => {
mockStorage.clear();
}),
batchUpdate: vi.fn(async (operations: Array<{ key: string; operation: 'set' | 'remove'; value?: string }>) => {
operations.forEach(({ key, operation, value }) => {
if (operation === 'set' && value) {
mockStorage.set(key, value);
} else if (operation === 'remove') {
mockStorage.delete(key);
}
});
}),
updateData: vi.fn(async (key: string, updater: (data: any) => any) => {
const currentData = mockStorage.get(key);
const parsedData = currentData ? JSON.parse(currentData) : null;
const updatedData = updater(parsedData);
mockStorage.set(key, JSON.stringify(updatedData));
})
};
manager = new FavoriteManager(storageProvider);
});
describe('addFavorite - functionMode 验证', () => {
it('应该拒绝缺少 functionMode 的收藏', async () => {
await expect(
manager.addFavorite({
title: '测试',
content: '内容',
tags: [],
// @ts-expect-error 故意省略 functionMode 测试验证
optimizationMode: 'system'
})
).rejects.toThrow(FavoriteValidationError);
});
it('应该接受合法的 basic/system 模式', async () => {
const id = await manager.addFavorite({
title: '测试基础模式',
content: '测试内容',
tags: [],
functionMode: 'basic',
optimizationMode: 'system'
});
expect(id).toBeTruthy();
const favorite = await manager.getFavorite(id);
expect(favorite.functionMode).toBe('basic');
expect(favorite.optimizationMode).toBe('system');
});
it('应该接受合法的 context/user 模式', async () => {
const id = await manager.addFavorite({
title: '测试上下文模式',
content: '测试内容',
tags: [],
functionMode: 'context',
optimizationMode: 'user'
});
expect(id).toBeTruthy();
const favorite = await manager.getFavorite(id);
expect(favorite.functionMode).toBe('context');
expect(favorite.optimizationMode).toBe('user');
});
it('应该接受合法的 image/text2image 模式', async () => {
const id = await manager.addFavorite({
title: '测试图像模式',
content: '测试内容',
tags: [],
functionMode: 'image',
imageSubMode: 'text2image'
});
expect(id).toBeTruthy();
const favorite = await manager.getFavorite(id);
expect(favorite.functionMode).toBe('image');
expect(favorite.imageSubMode).toBe('text2image');
});
it('应该拒绝 basic 模式缺少 optimizationMode', async () => {
await expect(
manager.addFavorite({
title: '测试',
content: '内容',
tags: [],
functionMode: 'basic'
// 缺少 optimizationMode
})
).rejects.toThrow(FavoriteValidationError);
});
it('应该拒绝 image 模式缺少 imageSubMode', async () => {
await expect(
manager.addFavorite({
title: '测试',
content: '内容',
tags: [],
functionMode: 'image'
// 缺少 imageSubMode
})
).rejects.toThrow(FavoriteValidationError);
});
it('basic 模式包含 imageSubMode 不会报错(字段会被保存但不使用)', async () => {
// 注意: 当前实现不验证冲突字段,这是一个已知的设计选择
const id = await manager.addFavorite({
title: '测试',
content: '内容',
tags: [],
functionMode: 'basic',
optimizationMode: 'system',
imageSubMode: 'text2image' // 冗余字段,不会报错
});
expect(id).toBeTruthy();
const favorite = await manager.getFavorite(id);
expect(favorite.functionMode).toBe('basic');
expect(favorite.optimizationMode).toBe('system');
// imageSubMode 会被保存但在 basic 模式下不使用
});
it('image 模式包含 optimizationMode 不会报错(字段会被保存但不使用)', async () => {
// 注意: 当前实现不验证冲突字段,这是一个已知的设计选择
const id = await manager.addFavorite({
title: '测试',
content: '内容',
tags: [],
functionMode: 'image',
imageSubMode: 'text2image',
optimizationMode: 'system' // 冗余字段,不会报错
});
expect(id).toBeTruthy();
const favorite = await manager.getFavorite(id);
expect(favorite.functionMode).toBe('image');
expect(favorite.imageSubMode).toBe('text2image');
// optimizationMode 会被保存但在 image 模式下不使用
});
});
describe('addFavorite - metadata 处理', () => {
it('应该正确存储 metadata.originalContent', async () => {
const id = await manager.addFavorite({
title: '测试',
content: '优化后内容',
tags: [],
functionMode: 'basic',
optimizationMode: 'system',
metadata: {
originalContent: '原始内容'
}
});
const favorite = await manager.getFavorite(id);
expect(favorite.metadata?.originalContent).toBe('原始内容');
});
it('应该正确存储 metadata.sourceHistoryId', async () => {
const id = await manager.addFavorite({
title: '测试',
content: '优化后内容',
tags: [],
functionMode: 'basic',
optimizationMode: 'system',
metadata: {
sourceHistoryId: 'history-123'
}
});
const favorite = await manager.getFavorite(id);
expect(favorite.metadata?.sourceHistoryId).toBe('history-123');
});
it('应该保留其他 metadata 字段', async () => {
const id = await manager.addFavorite({
title: '测试',
content: '内容',
tags: [],
functionMode: 'basic',
optimizationMode: 'system',
metadata: {
customField: '自定义值',
anotherField: 123
}
});
const favorite = await manager.getFavorite(id);
expect(favorite.metadata?.customField).toBe('自定义值');
expect(favorite.metadata?.anotherField).toBe(123);
});
});
describe('分类管理 - reorderCategories', () => {
it('应该能重新排序分类', async () => {
// 创建测试分类
const cat1Id = await manager.addCategory({ name: '分类1', color: '#ff0000' });
const cat2Id = await manager.addCategory({ name: '分类2', color: '#00ff00' });
const cat3Id = await manager.addCategory({ name: '分类3', color: '#0000ff' });
// 重新排序:3, 1, 2
await manager.reorderCategories([cat3Id, cat1Id, cat2Id]);
const categories = await manager.getCategories();
// 按 sortOrder 排序
const sorted = categories.sort((a, b) => a.sortOrder - b.sortOrder);
expect(sorted[0].id).toBe(cat3Id);
expect(sorted[0].sortOrder).toBe(0);
expect(sorted[1].id).toBe(cat1Id);
expect(sorted[1].sortOrder).toBe(1);
expect(sorted[2].id).toBe(cat2Id);
expect(sorted[2].sortOrder).toBe(2);
});
it('应该过滤不存在的分类ID', async () => {
const cat1Id = await manager.addCategory({ name: '分类1', color: '#ff0000' });
// 包含不存在的ID
await manager.reorderCategories(['non-existent', cat1Id]);
const categories = await manager.getCategories();
const existingCategory = categories.find(c => c.id === cat1Id);
expect(existingCategory).toBeDefined();
expect(existingCategory!.sortOrder).toBeDefined();
});
it('空数组应该抛出验证错误', async () => {
await expect(manager.reorderCategories([])).rejects.toThrow(FavoriteValidationError);
});
});
describe('分类管理 - getCategoryUsage', () => {
it('应该返回分类的使用次数', async () => {
const catId = await manager.addCategory({ name: '测试分类', color: '#ff0000' });
// 添加3个收藏使用该分类
await manager.addFavorite({
title: '收藏1',
content: '内容1',
category: catId,
tags: [],
functionMode: 'basic',
optimizationMode: 'system'
});
await manager.addFavorite({
title: '收藏2',
content: '内容2',
category: catId,
tags: [],
functionMode: 'basic',
optimizationMode: 'system'
});
await manager.addFavorite({
title: '收藏3',
content: '内容3',
category: catId,
tags: [],
functionMode: 'basic',
optimizationMode: 'system'
});
const usage = await manager.getCategoryUsage(catId);
expect(usage).toBe(3);
});
it('应该返回0对于未使用的分类', async () => {
const catId = await manager.addCategory({ name: '未使用分类', color: '#ff0000' });
const usage = await manager.getCategoryUsage(catId);
expect(usage).toBe(0);
});
it('应该返回0对于不存在的分类', async () => {
const usage = await manager.getCategoryUsage('non-existent-id');
expect(usage).toBe(0);
});
it('不同分类应该独立计数', async () => {
const cat1Id = await manager.addCategory({ name: '分类1', color: '#ff0000' });
const cat2Id = await manager.addCategory({ name: '分类2', color: '#00ff00' });
// 分类1: 2个收藏
await manager.addFavorite({
title: '收藏1-1',
content: '内容',
category: cat1Id,
tags: [],
functionMode: 'basic',
optimizationMode: 'system'
});
await manager.addFavorite({
title: '收藏1-2',
content: '内容',
category: cat1Id,
tags: [],
functionMode: 'basic',
optimizationMode: 'system'
});
// 分类2: 1个收藏
await manager.addFavorite({
title: '收藏2-1',
content: '内容',
category: cat2Id,
tags: [],
functionMode: 'basic',
optimizationMode: 'system'
});
const usage1 = await manager.getCategoryUsage(cat1Id);
const usage2 = await manager.getCategoryUsage(cat2Id);
expect(usage1).toBe(2);
expect(usage2).toBe(1);
});
});
describe('分类管理 - deleteCategory', () => {
it('删除分类时应清空收藏的分类字段并返回受影响数量', async () => {
const catId = await manager.addCategory({ name: '测试分类', color: '#ff0000' });
// 添加2个使用该分类的收藏
const fav1Id = await manager.addFavorite({
title: '收藏1',
content: '内容1',
category: catId,
tags: [],
functionMode: 'basic',
optimizationMode: 'system'
});
const fav2Id = await manager.addFavorite({
title: '收藏2',
content: '内容2',
category: catId,
tags: [],
functionMode: 'basic',
optimizationMode: 'system'
});
// 删除分类
const affectedCount = await manager.deleteCategory(catId);
// 应该返回受影响的收藏数量
expect(affectedCount).toBe(2);
// 收藏的分类字段应该被清空
const fav1 = await manager.getFavorite(fav1Id);
const fav2 = await manager.getFavorite(fav2Id);
expect(fav1.category).toBeUndefined();
expect(fav2.category).toBeUndefined();
// 分类应该被删除
const categories = await manager.getCategories();
expect(categories.find(c => c.id === catId)).toBeUndefined();
});
it('删除空分类应该返回0', async () => {
const catId = await manager.addCategory({ name: '空分类', color: '#ff0000' });
const affectedCount = await manager.deleteCategory(catId);
expect(affectedCount).toBe(0);
});
it('删除不存在的分类应该抛出错误', async () => {
await expect(manager.deleteCategory('non-existent-id')).rejects.toThrow();
});
});
describe('ensureDefaultCategories - 默认分类管理', () => {
it('首次调用应该创建默认分类', async () => {
const customCategories = [
{ name: 'Category 1', description: 'Desc 1', color: '#FF0000' },
{ name: 'Category 2', description: 'Desc 2', color: '#00FF00' }
];
await manager.ensureDefaultCategories(customCategories);
const categories = await manager.getCategories();
expect(categories.length).toBe(2);
expect(categories.find(c => c.name === 'Category 1')).toBeDefined();
expect(categories.find(c => c.name === 'Category 2')).toBeDefined();
});
it('用户删除所有分类后不应自动重建', async () => {
// 首次创建
await manager.ensureDefaultCategories([
{ name: 'Cat1', color: '#FF0000' }
]);
let categories = await manager.getCategories();
expect(categories.length).toBe(1);
// 用户删除所有分类
for (const cat of categories) {
await manager.deleteCategory(cat.id);
}
categories = await manager.getCategories();
expect(categories.length).toBe(0);
// 再次调用不应创建(已标记为已初始化)
await manager.ensureDefaultCategories([
{ name: 'Cat1', color: '#FF0000' }
]);
categories = await manager.getCategories();
expect(categories.length).toBe(0); // 仍然为0
});
it('已有分类时不应重复创建', async () => {
// 先手动创建一个分类
await manager.addCategory({ name: 'Existing', color: '#000000' });
// 调用 ensureDefaultCategories 不应创建新分类
await manager.ensureDefaultCategories([
{ name: 'New Category', color: '#FFFFFF' }
]);
const categories = await manager.getCategories();
expect(categories.length).toBe(1); // 仍然只有1个
expect(categories[0].name).toBe('Existing');
});
it('默认分类应该有正确的 sortOrder', async () => {
await manager.ensureDefaultCategories([
{ name: 'Cat1', color: '#FF0000' },
{ name: 'Cat2', color: '#00FF00' },
{ name: 'Cat3', color: '#0000FF' }
]);
const categories = await manager.getCategories();
const sorted = categories.sort((a, b) => a.sortOrder - b.sortOrder);
// sortOrder 应该是连续递增的
sorted.forEach((cat, index) => {
expect(cat.sortOrder).toBe(index);
});
});
});
describe('TypeMapper 集成测试', () => {
it('TypeMapper 映射的结果应该能通过 addFavorite 验证', () => {
const testTypes = [
'optimize',
'userOptimize',
'contextSystemOptimize',
'contextUserOptimize',
'imageOptimize',
'text2imageOptimize',
'image2imageOptimize'
] as const;
testTypes.forEach(type => {
const mapping = TypeMapper.mapFromRecordType(type);
// 验证映射结果的有效性
expect(TypeMapper.validateMapping(mapping)).toBe(true);
});
});
it('应该能使用 TypeMapper 映射结果创建收藏', async () => {
const mapping = TypeMapper.mapFromRecordType('optimize');
const id = await manager.addFavorite({
title: '从TypeMapper映射创建',
content: '测试内容',
tags: [],
...mapping
});
expect(id).toBeTruthy();
const favorite = await manager.getFavorite(id);
expect(favorite.functionMode).toBe(mapping.functionMode);
expect(favorite.optimizationMode).toBe(mapping.optimizationMode);
});
});
});
@@ -0,0 +1,451 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { FavoriteManager } from '../../../src/services/favorite/manager';
import type { IStorageProvider } from '../../../src/services/storage/types';
import { FavoriteValidationError, FavoriteError } from '../../../src/services/favorite/errors';
/**
* 标签管理功能单元测试
*/
describe('FavoriteManager - 标签管理', () => {
let manager: FavoriteManager;
let mockStorage: Map<string, string>;
let storageProvider: IStorageProvider;
beforeEach(() => {
// 创建模拟存储
mockStorage = new Map<string, string>();
storageProvider = {
getItem: vi.fn(async (key: string) => mockStorage.get(key) || null),
setItem: vi.fn(async (key: string, value: string) => {
mockStorage.set(key, value);
}),
removeItem: vi.fn(async (key: string) => {
mockStorage.delete(key);
}),
clearAll: vi.fn(async () => {
mockStorage.clear();
}),
batchUpdate: vi.fn(async (operations: Array<{ key: string; operation: 'set' | 'remove'; value?: string }>) => {
operations.forEach(({ key, operation, value }) => {
if (operation === 'set' && value) {
mockStorage.set(key, value);
} else if (operation === 'remove') {
mockStorage.delete(key);
}
});
}),
updateData: vi.fn(async (key: string, updater: (data: any) => any) => {
const currentData = mockStorage.get(key);
const parsedData = currentData ? JSON.parse(currentData) : null;
const updatedData = updater(parsedData);
mockStorage.set(key, JSON.stringify(updatedData));
})
};
manager = new FavoriteManager(storageProvider);
});
describe('添加标签', () => {
it('应该能成功添加新标签', async () => {
await manager.addTag('测试标签');
const tags = await manager.getAllTags();
expect(tags).toHaveLength(1);
expect(tags[0].tag).toBe('测试标签');
expect(tags[0].count).toBe(0); // 新标签未使用,count 为 0
});
it('应该拒绝空标签名', async () => {
await expect(manager.addTag('')).rejects.toThrow(FavoriteValidationError);
await expect(manager.addTag(' ')).rejects.toThrow(FavoriteValidationError);
});
it('应该拒绝重复的标签', async () => {
await manager.addTag('标签1');
await expect(manager.addTag('标签1')).rejects.toThrow(FavoriteError);
});
it('应该自动去除首尾空格', async () => {
await manager.addTag(' 标签2 ');
const tags = await manager.getAllTags();
expect(tags[0].tag).toBe('标签2');
});
it('应该能添加多个标签', async () => {
await manager.addTag('标签1');
await manager.addTag('标签2');
await manager.addTag('标签3');
const tags = await manager.getAllTags();
expect(tags).toHaveLength(3);
});
});
describe('获取所有标签', () => {
it('应该返回独立标签和使用中的标签', async () => {
// 添加独立标签
await manager.addTag('独立标签1');
await manager.addTag('独立标签2');
// 添加收藏(使用某些标签)
await manager.addFavorite({
title: '测试收藏',
content: '测试内容',
tags: ['使用中标签', '独立标签1'],
functionMode: 'basic',
optimizationMode: 'system'
});
const tags = await manager.getAllTags();
// 应该包含 3 个标签:独立标签1(count=1)、独立标签2(count=0)、使用中标签(count=1)
expect(tags).toHaveLength(3);
const tag1 = tags.find(t => t.tag === '独立标签1');
expect(tag1?.count).toBe(1);
const tag2 = tags.find(t => t.tag === '独立标签2');
expect(tag2?.count).toBe(0);
const tag3 = tags.find(t => t.tag === '使用中标签');
expect(tag3?.count).toBe(1);
});
it('应该按使用次数降序排序', async () => {
await manager.addFavorite({
title: '收藏1',
content: '内容1',
tags: ['标签A', '标签B'],
functionMode: 'basic',
optimizationMode: 'system'
});
await manager.addFavorite({
title: '收藏2',
content: '内容2',
tags: ['标签B', '标签C'],
functionMode: 'basic',
optimizationMode: 'system'
});
await manager.addFavorite({
title: '收藏3',
content: '内容3',
tags: ['标签B'],
functionMode: 'basic',
optimizationMode: 'system'
});
const tags = await manager.getAllTags();
// 标签B 使用3次,应该排第一
expect(tags[0].tag).toBe('标签B');
expect(tags[0].count).toBe(3);
// 标签A 和 标签C 各使用1次
expect(tags[1].count).toBe(1);
expect(tags[2].count).toBe(1);
});
it('相同使用次数时应该按标签名升序排序', async () => {
await manager.addFavorite({
title: '收藏',
content: '内容',
tags: ['Zebra', 'Apple', 'Banana'],
functionMode: 'basic',
optimizationMode: 'system'
});
const tags = await manager.getAllTags();
// 都是使用1次,应该按字母顺序排序
expect(tags[0].tag).toBe('Apple');
expect(tags[1].tag).toBe('Banana');
expect(tags[2].tag).toBe('Zebra');
});
});
describe('重命名标签', () => {
it('应该能成功重命名标签并更新所有收藏', async () => {
// 添加使用该标签的收藏
await manager.addFavorite({
title: '收藏1',
content: '内容1',
tags: ['旧标签'],
functionMode: 'basic',
optimizationMode: 'system'
});
await manager.addFavorite({
title: '收藏2',
content: '内容2',
tags: ['旧标签', '其他标签'],
functionMode: 'basic',
optimizationMode: 'system'
});
const affectedCount = await manager.renameTag('旧标签', '新标签');
expect(affectedCount).toBe(2); // 影响了2个收藏
// 验证收藏已更新
const favorites = await manager.getFavorites();
expect(favorites[0].tags).toContain('新标签');
expect(favorites[0].tags).not.toContain('旧标签');
expect(favorites[1].tags).toContain('新标签');
expect(favorites[1].tags).not.toContain('旧标签');
// 验证标签统计
const tags = await manager.getAllTags();
const newTag = tags.find(t => t.tag === '新标签');
expect(newTag?.count).toBe(2);
const oldTag = tags.find(t => t.tag === '旧标签');
expect(oldTag).toBeUndefined();
});
it('重命名为相同名称时应该返回0', async () => {
const affectedCount = await manager.renameTag('标签A', '标签A');
expect(affectedCount).toBe(0);
});
it('应该拒绝空标签名', async () => {
await expect(manager.renameTag('', '新标签')).rejects.toThrow(FavoriteValidationError);
await expect(manager.renameTag('旧标签', '')).rejects.toThrow(FavoriteValidationError);
});
it('重命名不存在的标签时应该返回0', async () => {
const affectedCount = await manager.renameTag('不存在的标签', '新标签');
expect(affectedCount).toBe(0);
});
it('重命名时如果新标签已存在应该合并', async () => {
await manager.addFavorite({
title: '收藏',
content: '内容',
tags: ['标签A', '标签B'],
functionMode: 'basic',
optimizationMode: 'system'
});
await manager.renameTag('标签A', '标签B');
const favorites = await manager.getFavorites();
// 应该只包含一个标签B,不重复
expect(favorites[0].tags).toEqual(['标签B']);
});
});
describe('合并标签', () => {
it('应该能成功合并多个标签', async () => {
await manager.addFavorite({
title: '收藏1',
content: '内容1',
tags: ['标签A'],
functionMode: 'basic',
optimizationMode: 'system'
});
await manager.addFavorite({
title: '收藏2',
content: '内容2',
tags: ['标签B'],
functionMode: 'basic',
optimizationMode: 'system'
});
await manager.addFavorite({
title: '收藏3',
content: '内容3',
tags: ['标签C'],
functionMode: 'basic',
optimizationMode: 'system'
});
const affectedCount = await manager.mergeTags(['标签A', '标签B'], '标签C');
expect(affectedCount).toBe(2); // 影响了2个收藏(收藏1和收藏2)
const favorites = await manager.getFavorites();
expect(favorites[0].tags).toContain('标签C');
expect(favorites[0].tags).not.toContain('标签A');
expect(favorites[1].tags).toContain('标签C');
expect(favorites[1].tags).not.toContain('标签B');
// 收藏3的标签C不应重复
expect(favorites[2].tags).toEqual(['标签C']);
});
it('应该拒绝空的源标签列表', async () => {
await expect(manager.mergeTags([], '目标标签')).rejects.toThrow(FavoriteValidationError);
});
it('应该拒绝空的目标标签', async () => {
await expect(manager.mergeTags(['标签A'], '')).rejects.toThrow(FavoriteValidationError);
});
it('合并不存在的标签时应该返回0', async () => {
const affectedCount = await manager.mergeTags(['不存在1', '不存在2'], '目标');
expect(affectedCount).toBe(0);
});
});
describe('删除标签', () => {
it('应该能删除标签并从所有收藏中移除', async () => {
// 添加独立标签(未被使用)
await manager.addTag('独立标签');
// 添加使用该标签的收藏
await manager.addFavorite({
title: '收藏1',
content: '内容1',
tags: ['要删除的标签', '保留的标签'],
functionMode: 'basic',
optimizationMode: 'system'
});
await manager.addFavorite({
title: '收藏2',
content: '内容2',
tags: ['要删除的标签'],
functionMode: 'basic',
optimizationMode: 'system'
});
const affectedCount = await manager.deleteTag('要删除的标签');
expect(affectedCount).toBe(2); // 影响了2个收藏
// 验证收藏已更新
const favorites = await manager.getFavorites();
// 验证两个收藏都不包含"要删除的标签"
favorites.forEach(fav => {
expect(fav.tags).not.toContain('要删除的标签');
});
// 第一个收藏应该保留"保留的标签"
const firstFavorite = favorites.find(f => f.title === '收藏1');
expect(firstFavorite).toBeDefined();
expect(firstFavorite!.tags).toEqual(['保留的标签']);
// 第二个收藏应该没有标签
const secondFavorite = favorites.find(f => f.title === '收藏2');
expect(secondFavorite).toBeDefined();
expect(secondFavorite!.tags).toEqual([]);
// 验证标签统计:要删除的标签不应该存在
const tags = await manager.getAllTags();
const deletedTag = tags.find(t => t.tag === '要删除的标签');
expect(deletedTag).toBeUndefined();
// 独立标签应该仍然存在(count=0,因为未被使用)
const independentTag = tags.find(t => t.tag === '独立标签');
expect(independentTag).toBeDefined();
expect(independentTag!.count).toBe(0);
// 测试删除独立标签
const independentTagDelCount = await manager.deleteTag('独立标签');
expect(independentTagDelCount).toBe(0); // 未被任何收藏使用
const tagsAfterDel = await manager.getAllTags();
const deletedIndependentTag = tagsAfterDel.find(t => t.tag === '独立标签');
expect(deletedIndependentTag).toBeUndefined();
});
it('应该拒绝空标签名', async () => {
await expect(manager.deleteTag('')).rejects.toThrow(FavoriteValidationError);
});
it('删除不存在的标签时应该返回0', async () => {
const affectedCount = await manager.deleteTag('不存在的标签');
expect(affectedCount).toBe(0);
});
});
describe('标签导入导出', () => {
it('导出时应该包含独立标签', async () => {
await manager.addTag('独立标签1');
await manager.addTag('独立标签2');
// 添加使用中标签到独立标签库
await manager.addTag('使用中标签');
await manager.addFavorite({
title: '测试',
content: '内容',
tags: ['使用中标签'],
functionMode: 'basic',
optimizationMode: 'system'
});
const exportData = await manager.exportFavorites();
const parsed = JSON.parse(exportData);
expect(parsed.tags).toBeDefined();
expect(parsed.tags).toContain('独立标签1');
expect(parsed.tags).toContain('独立标签2');
expect(parsed.tags).toContain('使用中标签');
});
it('导入时应该自动创建独立标签', async () => {
const importData = JSON.stringify({
version: '1.0',
exportDate: new Date().toISOString(),
favorites: [
{
title: '测试',
content: '内容',
tags: ['标签1', '标签2'],
functionMode: 'basic',
optimizationMode: 'system'
}
],
categories: [],
tags: ['标签1', '标签2', '预创建标签']
});
await manager.importFavorites(importData);
const tags = await manager.getAllTags();
// 应该包含所有标签
expect(tags.find(t => t.tag === '标签1')).toBeDefined();
expect(tags.find(t => t.tag === '标签2')).toBeDefined();
expect(tags.find(t => t.tag === '预创建标签')).toBeDefined();
// 预创建标签使用次数为0
const preCreatedTag = tags.find(t => t.tag === '预创建标签');
expect(preCreatedTag?.count).toBe(0);
});
});
describe('保存收藏时自动注册标签', () => {
it('保存收藏时应该自动将标签添加到独立标签库', async () => {
// 这个测试需要在 UI 层面实现,这里只是验证概念
// 在 SaveFavoriteDialog 中,保存前应该调用 addTag
await manager.addFavorite({
title: '测试',
content: '内容',
tags: ['新标签1', '新标签2'],
functionMode: 'basic',
optimizationMode: 'system'
});
// 手动模拟 SaveFavoriteDialog 的行为
for (const tag of ['新标签1', '新标签2']) {
try {
await manager.addTag(tag);
} catch (error) {
// 标签已存在,忽略错误
}
}
const tags = await manager.getAllTags();
expect(tags.find(t => t.tag === '新标签1')).toBeDefined();
expect(tags.find(t => t.tag === '新标签2')).toBeDefined();
});
});
});
@@ -0,0 +1,218 @@
import { describe, it, expect } from 'vitest';
import { TagTypeConverter } from '../../../src/services/favorite/type-converter';
import type { TagStatistics } from '../../../src/services/favorite/types';
/**
* 标签类型转换器单元测试
*/
describe('TagTypeConverter', () => {
describe('toTagStatistics', () => {
it('应该将API格式转换为TagStatistics格式', () => {
const apiData = [
{ tag: '标签1', count: 5 },
{ tag: '标签2', count: 3 }
];
const result = TagTypeConverter.toTagStatistics(apiData);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
name: '标签1',
count: 5,
lastUsed: undefined
});
expect(result[1]).toEqual({
name: '标签2',
count: 3,
lastUsed: undefined
});
});
it('应该处理空数组', () => {
const result = TagTypeConverter.toTagStatistics([]);
expect(result).toEqual([]);
});
});
describe('fromTagStatistics', () => {
it('应该将TagStatistics格式转换为API格式', () => {
const stats: TagStatistics[] = [
{ name: '标签1', count: 5, lastUsed: undefined },
{ name: '标签2', count: 3, lastUsed: Date.now() }
];
const result = TagTypeConverter.fromTagStatistics(stats);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ tag: '标签1', count: 5 });
expect(result[1]).toEqual({ tag: '标签2', count: 3 });
});
it('应该处理空数组', () => {
const result = TagTypeConverter.fromTagStatistics([]);
expect(result).toEqual([]);
});
});
describe('toAutoCompleteOptions', () => {
it('应该转换为自动完成选项格式', () => {
const apiData = [
{ tag: '标签1', count: 5 },
{ tag: '标签2', count: 3 }
];
const result = TagTypeConverter.toAutoCompleteOptions(apiData);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
label: '标签1 (5)',
value: '标签1',
count: 5
});
expect(result[1]).toEqual({
label: '标签2 (3)',
value: '标签2',
count: 3
});
});
it('应该处理使用次数为0的标签', () => {
const apiData = [{ tag: '未使用标签', count: 0 }];
const result = TagTypeConverter.toAutoCompleteOptions(apiData);
expect(result[0]).toEqual({
label: '未使用标签 (0)',
value: '未使用标签',
count: 0
});
});
});
describe('toStringArray', () => {
it('应该转换为字符串数组', () => {
const apiData = [
{ tag: '标签1', count: 5 },
{ tag: '标签2', count: 3 },
{ tag: '标签3', count: 0 }
];
const result = TagTypeConverter.toStringArray(apiData);
expect(result).toEqual(['标签1', '标签2', '标签3']);
});
it('应该处理空数组', () => {
const result = TagTypeConverter.toStringArray([]);
expect(result).toEqual([]);
});
});
describe('sortByCount', () => {
it('应该按使用次数降序排序', () => {
const tags = [
{ count: 3 },
{ count: 10 },
{ count: 1 },
{ count: 5 }
];
const result = TagTypeConverter.sortByCount(tags);
expect(result.map(t => t.count)).toEqual([10, 5, 3, 1]);
});
it('应该不修改原数组', () => {
const tags = [
{ count: 3 },
{ count: 1 }
];
const result = TagTypeConverter.sortByCount(tags);
expect(tags.map(t => t.count)).toEqual([3, 1]);
expect(result.map(t => t.count)).toEqual([3, 1]);
});
});
describe('sortByName', () => {
it('应该按标签名升序排序', () => {
const tags: TagStatistics[] = [
{ name: 'Zebra', count: 1 },
{ name: 'Apple', count: 2 },
{ name: 'Banana', count: 3 }
];
const result = TagTypeConverter.sortByName(tags);
expect(result.map(t => t.name)).toEqual(['Apple', 'Banana', 'Zebra']);
});
it('应该正确处理中文排序', () => {
const tags: TagStatistics[] = [
{ name: '编程', count: 1 },
{ name: '设计', count: 2 },
{ name: '测试', count: 3 }
];
const result = TagTypeConverter.sortByName(tags);
// 中文按拼音排序
expect(result.map(t => t.name)).toEqual(['编程', '测试', '设计']);
});
it('应该不修改原数组', () => {
const tags: TagStatistics[] = [
{ name: 'B', count: 1 },
{ name: 'A', count: 2 }
];
const result = TagTypeConverter.sortByName(tags);
expect(tags.map(t => t.name)).toEqual(['B', 'A']);
expect(result.map(t => t.name)).toEqual(['A', 'B']);
});
});
describe('sortByCountThenName', () => {
it('应该先按使用次数降序,再按名称升序排序', () => {
const tags: TagStatistics[] = [
{ name: 'Zebra', count: 5 },
{ name: 'Apple', count: 5 },
{ name: 'Banana', count: 3 },
{ name: 'Cat', count: 5 }
];
const result = TagTypeConverter.sortByCountThenName(tags);
// count=5的按名称升序: Apple, Cat, Zebra
// count=3的: Banana
expect(result.map(t => t.name)).toEqual(['Apple', 'Cat', 'Zebra', 'Banana']);
});
it('应该处理使用次数全部相同的情况', () => {
const tags: TagStatistics[] = [
{ name: 'C', count: 1 },
{ name: 'A', count: 1 },
{ name: 'B', count: 1 }
];
const result = TagTypeConverter.sortByCountThenName(tags);
expect(result.map(t => t.name)).toEqual(['A', 'B', 'C']);
});
it('应该不修改原数组', () => {
const tags: TagStatistics[] = [
{ name: 'B', count: 2 },
{ name: 'A', count: 1 }
];
const result = TagTypeConverter.sortByCountThenName(tags);
expect(tags[0].name).toBe('B');
expect(result[0].name).toBe('B');
expect(result[1].name).toBe('A');
});
});
});
@@ -0,0 +1,397 @@
import { describe, it, expect } from 'vitest';
import { TypeMapper, type FunctionModeMapping } from '../../../src/services/favorite/type-mapper';
import type { PromptRecordType } from '../../../src/services/history/types';
/**
* TypeMapper 单元测试
* 测试历史记录类型到功能模式的映射逻辑
*/
describe('TypeMapper', () => {
describe('mapFromRecordType - 基础模式映射', () => {
it('应该将 optimize 映射为 basic/system', () => {
const result = TypeMapper.mapFromRecordType('optimize');
expect(result).toEqual({
functionMode: 'basic',
optimizationMode: 'system'
});
});
it('应该将 iterate 映射为 basic/system', () => {
const result = TypeMapper.mapFromRecordType('iterate');
expect(result).toEqual({
functionMode: 'basic',
optimizationMode: 'system'
});
});
it('应该将 userOptimize 映射为 basic/user', () => {
const result = TypeMapper.mapFromRecordType('userOptimize');
expect(result).toEqual({
functionMode: 'basic',
optimizationMode: 'user'
});
});
it('应该将 test 映射为 basic/system', () => {
const result = TypeMapper.mapFromRecordType('test');
expect(result).toEqual({
functionMode: 'basic',
optimizationMode: 'system'
});
});
});
describe('mapFromRecordType - 上下文模式映射', () => {
it('应该将 contextSystemOptimize 映射为 context/system', () => {
const result = TypeMapper.mapFromRecordType('contextSystemOptimize');
expect(result).toEqual({
functionMode: 'context',
optimizationMode: 'system'
});
});
it('应该将 contextIterate 映射为 context/system', () => {
const result = TypeMapper.mapFromRecordType('contextIterate');
expect(result).toEqual({
functionMode: 'context',
optimizationMode: 'system'
});
});
it('应该将 contextUserOptimize 映射为 context/user', () => {
const result = TypeMapper.mapFromRecordType('contextUserOptimize');
expect(result).toEqual({
functionMode: 'context',
optimizationMode: 'user'
});
});
});
describe('mapFromRecordType - 图像模式映射', () => {
it('应该将 imageOptimize 映射为 image/text2image', () => {
const result = TypeMapper.mapFromRecordType('imageOptimize');
expect(result).toEqual({
functionMode: 'image',
imageSubMode: 'text2image'
});
});
it('应该将 contextImageOptimize 映射为 image/text2image', () => {
const result = TypeMapper.mapFromRecordType('contextImageOptimize');
expect(result).toEqual({
functionMode: 'image',
imageSubMode: 'text2image'
});
});
it('应该将 imageIterate 映射为 image/text2image', () => {
const result = TypeMapper.mapFromRecordType('imageIterate');
expect(result).toEqual({
functionMode: 'image',
imageSubMode: 'text2image'
});
});
it('应该将 text2imageOptimize 映射为 image/text2image', () => {
const result = TypeMapper.mapFromRecordType('text2imageOptimize');
expect(result).toEqual({
functionMode: 'image',
imageSubMode: 'text2image'
});
});
it('应该将 image2imageOptimize 映射为 image/image2image', () => {
const result = TypeMapper.mapFromRecordType('image2imageOptimize');
expect(result).toEqual({
functionMode: 'image',
imageSubMode: 'image2image'
});
});
});
describe('mapFromRecordType - 未知类型处理', () => {
it('应该将未知类型映射为 basic/system 并输出警告', () => {
// 使用 console.warn 的 spy
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = TypeMapper.mapFromRecordType('unknown' as PromptRecordType);
expect(result).toEqual({
functionMode: 'basic',
optimizationMode: 'system'
});
expect(warnSpy).toHaveBeenCalledWith(
'[TypeMapper] Unknown record type: unknown, falling back to basic/system'
);
warnSpy.mockRestore();
});
});
describe('validateMapping - 合法映射验证', () => {
it('应该接受合法的 basic/system 映射', () => {
const mapping: FunctionModeMapping = {
functionMode: 'basic',
optimizationMode: 'system'
};
expect(TypeMapper.validateMapping(mapping)).toBe(true);
});
it('应该接受合法的 basic/user 映射', () => {
const mapping: FunctionModeMapping = {
functionMode: 'basic',
optimizationMode: 'user'
};
expect(TypeMapper.validateMapping(mapping)).toBe(true);
});
it('应该接受合法的 context/system 映射', () => {
const mapping: FunctionModeMapping = {
functionMode: 'context',
optimizationMode: 'system'
};
expect(TypeMapper.validateMapping(mapping)).toBe(true);
});
it('应该接受合法的 context/user 映射', () => {
const mapping: FunctionModeMapping = {
functionMode: 'context',
optimizationMode: 'user'
};
expect(TypeMapper.validateMapping(mapping)).toBe(true);
});
it('应该接受合法的 image/text2image 映射', () => {
const mapping: FunctionModeMapping = {
functionMode: 'image',
imageSubMode: 'text2image'
};
expect(TypeMapper.validateMapping(mapping)).toBe(true);
});
it('应该接受合法的 image/image2image 映射', () => {
const mapping: FunctionModeMapping = {
functionMode: 'image',
imageSubMode: 'image2image'
};
expect(TypeMapper.validateMapping(mapping)).toBe(true);
});
});
describe('validateMapping - 非法映射验证', () => {
it('应该拒绝缺少 functionMode 的映射', () => {
const mapping: Partial<FunctionModeMapping> = {
optimizationMode: 'system'
};
expect(TypeMapper.validateMapping(mapping)).toBe(false);
});
it('应该拒绝非法的 functionMode 值', () => {
const mapping = {
functionMode: 'invalid' as any,
optimizationMode: 'system'
};
expect(TypeMapper.validateMapping(mapping)).toBe(false);
});
it('应该拒绝 basic 模式缺少 optimizationMode', () => {
const mapping: Partial<FunctionModeMapping> = {
functionMode: 'basic'
};
expect(TypeMapper.validateMapping(mapping)).toBe(false);
});
it('应该拒绝 context 模式缺少 optimizationMode', () => {
const mapping: Partial<FunctionModeMapping> = {
functionMode: 'context'
};
expect(TypeMapper.validateMapping(mapping)).toBe(false);
});
it('应该拒绝 basic 模式的非法 optimizationMode 值', () => {
const mapping = {
functionMode: 'basic' as const,
optimizationMode: 'invalid' as any
};
expect(TypeMapper.validateMapping(mapping)).toBe(false);
});
it('应该拒绝 basic 模式包含 imageSubMode', () => {
const mapping = {
functionMode: 'basic' as const,
optimizationMode: 'system' as const,
imageSubMode: 'text2image' as const
};
expect(TypeMapper.validateMapping(mapping)).toBe(false);
});
it('应该拒绝 context 模式包含 imageSubMode', () => {
const mapping = {
functionMode: 'context' as const,
optimizationMode: 'system' as const,
imageSubMode: 'text2image' as const
};
expect(TypeMapper.validateMapping(mapping)).toBe(false);
});
it('应该拒绝 image 模式缺少 imageSubMode', () => {
const mapping: Partial<FunctionModeMapping> = {
functionMode: 'image'
};
expect(TypeMapper.validateMapping(mapping)).toBe(false);
});
it('应该拒绝 image 模式的非法 imageSubMode 值', () => {
const mapping = {
functionMode: 'image' as const,
imageSubMode: 'invalid' as any
};
expect(TypeMapper.validateMapping(mapping)).toBe(false);
});
it('应该拒绝 image 模式包含 optimizationMode', () => {
const mapping = {
functionMode: 'image' as const,
imageSubMode: 'text2image' as const,
optimizationMode: 'system' as const
};
expect(TypeMapper.validateMapping(mapping)).toBe(false);
});
});
describe('inferRecordTypes - 反向推断', () => {
it('应该从 basic/system 推断出 optimize 和 iterate', () => {
const mapping: FunctionModeMapping = {
functionMode: 'basic',
optimizationMode: 'system'
};
const result = TypeMapper.inferRecordTypes(mapping);
expect(result).toEqual(['optimize', 'iterate']);
});
it('应该从 basic/user 推断出 userOptimize', () => {
const mapping: FunctionModeMapping = {
functionMode: 'basic',
optimizationMode: 'user'
};
const result = TypeMapper.inferRecordTypes(mapping);
expect(result).toEqual(['userOptimize']);
});
it('应该从 context/system 推断出 contextSystemOptimize 和 contextIterate', () => {
const mapping: FunctionModeMapping = {
functionMode: 'context',
optimizationMode: 'system'
};
const result = TypeMapper.inferRecordTypes(mapping);
expect(result).toEqual(['contextSystemOptimize', 'contextIterate']);
});
it('应该从 context/user 推断出 contextUserOptimize', () => {
const mapping: FunctionModeMapping = {
functionMode: 'context',
optimizationMode: 'user'
};
const result = TypeMapper.inferRecordTypes(mapping);
expect(result).toEqual(['contextUserOptimize']);
});
it('应该从 image/text2image 推断出所有文生图类型', () => {
const mapping: FunctionModeMapping = {
functionMode: 'image',
imageSubMode: 'text2image'
};
const result = TypeMapper.inferRecordTypes(mapping);
expect(result).toEqual([
'imageOptimize',
'contextImageOptimize',
'imageIterate',
'text2imageOptimize'
]);
});
it('应该从 image/image2image 推断出 image2imageOptimize', () => {
const mapping: FunctionModeMapping = {
functionMode: 'image',
imageSubMode: 'image2image'
};
const result = TypeMapper.inferRecordTypes(mapping);
expect(result).toEqual(['image2imageOptimize']);
});
it('应该对非法映射返回空数组', () => {
const mapping = {
functionMode: 'basic' as const,
optimizationMode: undefined as any
};
const result = TypeMapper.inferRecordTypes(mapping);
expect(result).toEqual([]);
});
});
describe('映射和验证的完整性测试', () => {
it('所有 PromptRecordType 映射后的结果都应该是合法的', () => {
const allTypes: PromptRecordType[] = [
'optimize',
'userOptimize',
'iterate',
'test',
'contextSystemOptimize',
'contextUserOptimize',
'contextIterate',
'imageOptimize',
'contextImageOptimize',
'imageIterate',
'text2imageOptimize',
'image2imageOptimize'
];
allTypes.forEach(type => {
const mapping = TypeMapper.mapFromRecordType(type);
expect(TypeMapper.validateMapping(mapping)).toBe(true);
});
});
it('映射和反向推断应该是一致的', () => {
const testCases: Array<{
recordType: PromptRecordType;
mapping: FunctionModeMapping;
}> = [
{
recordType: 'optimize',
mapping: { functionMode: 'basic', optimizationMode: 'system' }
},
{
recordType: 'userOptimize',
mapping: { functionMode: 'basic', optimizationMode: 'user' }
},
{
recordType: 'contextSystemOptimize',
mapping: { functionMode: 'context', optimizationMode: 'system' }
},
{
recordType: 'contextUserOptimize',
mapping: { functionMode: 'context', optimizationMode: 'user' }
},
{
recordType: 'text2imageOptimize',
mapping: { functionMode: 'image', imageSubMode: 'text2image' }
},
{
recordType: 'image2imageOptimize',
mapping: { functionMode: 'image', imageSubMode: 'image2image' }
}
];
testCases.forEach(({ recordType, mapping }) => {
// 正向映射
const mappedResult = TypeMapper.mapFromRecordType(recordType);
expect(mappedResult).toEqual(mapping);
// 反向推断
const inferredTypes = TypeMapper.inferRecordTypes(mapping);
expect(inferredTypes).toContain(recordType);
});
});
});
});
+75 -8
View File
@@ -14,19 +14,54 @@
<NEllipsis style="flex: 1; min-width: 0; font-weight: 600; font-size: 14px;">
{{ favorite.title }}
</NEllipsis>
<div style="flex-shrink: 0; max-width: 120px; overflow: hidden;">
<NSpace :size="4" style="flex-shrink: 1; min-width: 0;" :wrap="false">
<!-- 功能模式标签 -->
<NTag
v-if="category"
:color="{ color: category.color, textColor: 'white' }"
:type="getFunctionModeTagType(favorite.functionMode)"
size="small"
:bordered="false"
style="max-width: 100%;"
style="flex-shrink: 0;"
>
<NEllipsis style="max-width: 100px;">
{{ category.name }}
</NEllipsis>
{{ t(`favorites.manager.card.functionMode.${favorite.functionMode}`) }}
</NTag>
</div>
<!-- 子模式标签 (优化模式或图像模式) -->
<NTag
v-if="favorite.optimizationMode || favorite.imageSubMode"
:type="getSubModeTagType(favorite)"
size="small"
:bordered="false"
style="flex-shrink: 0;"
>
{{ getSubModeLabel(favorite) }}
</NTag>
<!-- 分类标签:可收缩 -->
<NTooltip
v-if="category"
trigger="hover"
:overlay-style="tooltipOverlayStyle"
:content-style="tooltipContentStyle"
:theme-overrides="tooltipThemeOverrides"
placement="bottom"
to="body"
:flip="true"
>
<template #trigger>
<NTag
:color="{ color: category.color, textColor: 'white' }"
size="small"
:bordered="false"
style="max-width: 80px; flex-shrink: 1; min-width: 0;"
>
<NEllipsis style="max-width: 68px;">
{{ category.name }}
</NEllipsis>
</NTag>
</template>
{{ category.name }}
</NTooltip>
</NSpace>
</NSpace>
</template>
@@ -492,6 +527,38 @@ onBeforeUnmount(() => {
}
});
// 功能模式标签类型映射
const getFunctionModeTagType = (mode: string): 'default' | 'info' | 'success' => {
const typeMap: Record<string, 'default' | 'info' | 'success'> = {
basic: 'default',
context: 'info',
image: 'success'
};
return typeMap[mode] || 'default';
};
// 子模式标签类型映射
const getSubModeTagType = (favorite: FavoritePrompt): 'warning' | 'error' | 'success' | 'info' | 'default' => {
if (favorite.optimizationMode) {
return favorite.optimizationMode === 'system' ? 'warning' : 'error';
}
if (favorite.imageSubMode) {
return favorite.imageSubMode === 'text2image' ? 'success' : 'info';
}
return 'default';
};
// 获取子模式标签文本
const getSubModeLabel = (favorite: FavoritePrompt): string => {
if (favorite.optimizationMode) {
return t(`favorites.manager.card.optimizationMode.${favorite.optimizationMode}`);
}
if (favorite.imageSubMode) {
return t(`favorites.manager.card.imageSubMode.${favorite.imageSubMode}`);
}
return '';
};
const formatDate = (timestamp: number) => {
const date = new Date(timestamp);
const now = new Date();
+54 -15
View File
@@ -222,6 +222,13 @@
<CategoryManager @category-updated="handleCategoryUpdated" />
</n-modal>
<!-- 标签管理 -->
<TagManager
:show="tagManagerVisible"
@update:show="tagManagerVisible = $event"
@updated="loadFavorites"
/>
<!-- 新建/编辑收藏对话框 -->
<SaveFavoriteDialog
:show="createState.visible"
@@ -261,6 +268,7 @@ import {
} from 'naive-ui';
import { useI18n } from 'vue-i18n';
import { useToast } from '../composables/useToast';
import { useFavoriteInitializer } from '../composables/useFavoriteInitializer';
import ToastUI from './Toast.vue';
const { t } = useI18n();
@@ -269,13 +277,16 @@ import OutputDisplayFullscreen from './OutputDisplayFullscreen.vue';
import CategoryManager from './CategoryManager.vue';
import CategoryTreeSelect from './CategoryTreeSelect.vue';
import SaveFavoriteDialog from './SaveFavoriteDialog.vue';
import TagManager from './TagManager.vue';
import {
Search,
DotsVertical,
Upload,
Download,
Trash,
Plus
Plus,
Tags,
Folder
} from '@vicons/tabler';
import type { FavoritePrompt, FavoriteCategory } from '@prompt-optimizer/core';
import type { AppServices } from '../types/services';
@@ -303,6 +314,11 @@ const services = inject<Ref<AppServices | null> | null>('services', null);
const message = useToast();
// 初始化默认分类(仅在首次使用时创建)
const { ensureDefaultCategories } = services?.value?.favoriteManager
? useFavoriteInitializer(services.value.favoriteManager)
: { ensureDefaultCategories: async () => {} };
// 响应式数据
const loading = ref(false);
const favorites = ref<FavoritePrompt[]>([]);
@@ -327,6 +343,7 @@ const createState = reactive({
});
const previewFavorite = ref<FavoritePrompt | null>(null);
const categoryManagerVisible = ref(false);
const tagManagerVisible = ref(false);
// 响应式的视口宽度
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1280);
@@ -481,6 +498,19 @@ const pageSize = computed(() => {
});
const actionMenuOptions = computed(() => [
{
label: t('favorites.manager.actions.manageTags'),
key: 'manageTags',
icon: () => h(NIcon, null, { default: () => h(Tags) })
},
{
label: t('favorites.manager.actions.manageCategories'),
key: 'manageCategories',
icon: () => h(NIcon, null, { default: () => h(Folder) })
},
{
type: 'divider'
},
{
label: t('favorites.manager.actions.export'),
key: 'export',
@@ -759,6 +789,12 @@ const handleUseFavorite = (favorite: FavoritePrompt) => {
const handleActionMenuSelect = (key: string) => {
switch (key) {
case 'manageTags':
tagManagerVisible.value = true;
break;
case 'manageCategories':
categoryManagerVisible.value = true;
break;
case 'export':
handleExportFavorites();
break;
@@ -884,29 +920,32 @@ watch(() => editState.visible, (visible) => {
}
});
onMounted(() => {
// 窗口大小变化处理
const handleResize = () => {
if (typeof window !== 'undefined') {
viewportWidth.value = window.innerWidth;
}
};
const debouncedResize = useDebounceFn(handleResize, 150);
// 生命周期
onMounted(async () => {
if (services?.value?.favoriteManager) {
// 确保默认分类存在(仅在首次使用时创建)
await ensureDefaultCategories();
loadFavorites();
loadCategories();
}
// 监听窗口大小变化
const handleResize = () => {
if (typeof window !== 'undefined') {
viewportWidth.value = window.innerWidth;
}
};
const debouncedResize = useDebounceFn(handleResize, 150);
if (typeof window !== 'undefined') {
window.addEventListener('resize', debouncedResize);
}
});
onBeforeUnmount(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', debouncedResize);
}
});
onBeforeUnmount(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', debouncedResize);
}
});
defineExpose({
@@ -84,11 +84,31 @@
<!-- 标签(跨越两列) -->
<n-form-item :label="t('favorites.dialog.tagsLabel')">
<n-dynamic-tags
v-model:value="formData.tags"
:max="10"
:placeholder="t('favorites.dialog.tagsPlaceholder')"
/>
<div style="width: 100%;">
<!-- 已选标签显示 -->
<n-space v-if="formData.tags.length > 0" :size="[8, 8]" style="margin-bottom: 8px;">
<n-tag
v-for="(tag, index) in formData.tags"
:key="tag"
closable
@close="handleRemoveTag(index)"
type="info"
>
{{ tag }}
</n-tag>
</n-space>
<!-- 标签输入自动完成 -->
<n-auto-complete
v-model:value="tagInputValue"
:options="tagSuggestions"
:placeholder="t('favorites.dialog.tagsPlaceholder')"
:get-show="() => tagInputValue.length > 0 || tagSuggestions.length > 0"
clearable
@select="handleSelectTag"
@keydown.enter="handleAddTag"
/>
</div>
</n-form-item>
</n-form>
</n-card>
@@ -131,7 +151,8 @@ import {
NFormItem,
NInput,
NSelect,
NDynamicTags,
NAutoComplete,
NTag,
NButton,
NSpace,
NScrollbar,
@@ -141,11 +162,13 @@ import {
} from 'naive-ui';
import { useI18n } from 'vue-i18n';
import { useToast } from '../composables/useToast';
import { useTagSuggestions } from '../composables/useTagSuggestions';
import OutputDisplayCore from './OutputDisplayCore.vue';
import CategoryTreeSelect from './CategoryTreeSelect.vue';
import type { AppServices } from '../types/services';
const { t } = useI18n();
const { filterTags, loadTags } = useTagSuggestions();
interface Props {
/** 是否显示对话框 */
@@ -183,6 +206,16 @@ const message = useToast();
const saving = ref(false);
// 标签输入和建议
const tagInputValue = ref('');
const tagSuggestions = computed(() => {
const suggestions = filterTags(tagInputValue.value, formData.tags);
return suggestions.map(s => ({
label: s.label,
value: s.value
}));
});
// 对话框标题
const dialogTitle = computed(() => {
if (props.mode === 'create') return t('favorites.dialog.createTitle');
@@ -234,6 +267,27 @@ const handleFunctionModeChange = (mode: 'basic' | 'context' | 'image') => {
}
};
// 标签管理函数
const handleRemoveTag = (index: number) => {
formData.tags.splice(index, 1);
};
const handleSelectTag = (value: string) => {
if (value && !formData.tags.includes(value) && formData.tags.length < 10) {
formData.tags.push(value);
tagInputValue.value = '';
}
};
const handleAddTag = (e: KeyboardEvent) => {
e.preventDefault();
const trimmedValue = tagInputValue.value.trim();
if (trimmedValue && !formData.tags.includes(trimmedValue) && formData.tags.length < 10) {
formData.tags.push(trimmedValue);
tagInputValue.value = '';
}
};
// 关闭对话框
const handleClose = () => {
emit('update:show', false);
@@ -260,6 +314,21 @@ const handleSave = async () => {
saving.value = true;
try {
// 【优化】保存收藏前,确保所有标签都存在于独立标签库中
// 这样可以保证:1. 标签统一管理 2. 导出时完整 3. 标签管理器能看到所有标签
for (const tag of formData.tags) {
try {
await servicesValue.favoriteManager.addTag(tag);
} catch (error: any) {
// 只忽略"标签已存在"错误,其他错误需要抛出
if (error?.code !== 'TAG_ALREADY_EXISTS') {
console.error('添加标签到独立库失败:', error);
throw error;
}
// 标签已存在,这是正常情况,继续处理
}
}
if (props.mode === 'edit' && props.favorite) {
// 编辑模式:更新现有收藏
await servicesValue.favoriteManager.updateFavorite(props.favorite.id, {
@@ -308,8 +377,11 @@ const handleSave = async () => {
};
// 监听对话框显示,初始化表单
watch(() => props.show, (newShow) => {
watch(() => props.show, async (newShow) => {
if (newShow) {
// 加载标签建议
await loadTags();
if (props.mode === 'create') {
// 新建模式: 重置为空表单
formData.title = '';
+425
View File
@@ -0,0 +1,425 @@
<template>
<n-modal
:show="show"
preset="card"
:title="t('favorites.manager.tagManager.title')"
:style="{ width: 'min(90vw, 900px)', height: 'min(80vh, 700px)' }"
:mask-closable="true"
@update:show="$emit('update:show', $event)"
>
<n-space vertical :size="16" style="height: 100%;">
<!-- 搜索栏和统计 -->
<n-space justify="space-between" align="center">
<n-input
v-model:value="searchQuery"
:placeholder="t('favorites.manager.tagManager.searchPlaceholder')"
clearable
style="flex: 1; max-width: 300px;"
>
<template #prefix>
<n-icon><Search /></n-icon>
</template>
</n-input>
<n-space align="center" :size="12">
<n-text depth="3">
{{ t('favorites.manager.tagManager.totalTags', { count: filteredTags.length }) }}
</n-text>
<n-button type="primary" size="small" @click="handleAdd">
{{ t('favorites.manager.tagManager.add') }}
</n-button>
</n-space>
</n-space>
<!-- 标签表格 -->
<n-data-table
:columns="columns"
:data="filteredTags"
:loading="loading"
:pagination="pagination"
:bordered="false"
:max-height="450"
striped
/>
</n-space>
<!-- 新增标签对话框 -->
<n-modal
v-model:show="showAddDialog"
preset="dialog"
:title="t('favorites.manager.tagManager.addDialog.title')"
:positive-text="t('favorites.manager.tagManager.addDialog.confirm')"
:negative-text="t('favorites.manager.tagManager.addDialog.cancel')"
@positive-click="handleAddConfirm"
>
<n-space vertical :size="12">
<n-input
v-model:value="newTagName"
:placeholder="t('favorites.manager.tagManager.addDialog.tagNamePlaceholder')"
@keydown.enter="handleAddConfirm"
/>
</n-space>
</n-modal>
<!-- 重命名对话框 -->
<n-modal
v-model:show="showRenameDialog"
preset="dialog"
:title="t('favorites.manager.tagManager.renameDialog.title')"
:positive-text="t('favorites.manager.tagManager.renameDialog.confirm')"
:negative-text="t('favorites.manager.tagManager.renameDialog.cancel')"
@positive-click="handleRenameConfirm"
>
<n-space vertical :size="12">
<n-text>{{ t('favorites.manager.tagManager.renameDialog.currentName', { name: currentTag?.name }) }}</n-text>
<n-input
v-model:value="newTagName"
:placeholder="t('favorites.manager.tagManager.renameDialog.newNamePlaceholder')"
@keydown.enter="handleRenameConfirm"
/>
</n-space>
</n-modal>
<!-- 合并对话框 -->
<n-modal
v-model:show="showMergeDialog"
preset="dialog"
:title="t('favorites.manager.tagManager.mergeDialog.title')"
:positive-text="t('favorites.manager.tagManager.mergeDialog.confirm')"
:negative-text="t('favorites.manager.tagManager.mergeDialog.cancel')"
@positive-click="handleMergeConfirm"
>
<n-space vertical :size="12">
<n-text>{{ t('favorites.manager.tagManager.mergeDialog.sourceTag', { name: currentTag?.name }) }}</n-text>
<n-select
v-model:value="mergeTargetTag"
:options="mergeTargetOptions"
:placeholder="t('favorites.manager.tagManager.mergeDialog.targetPlaceholder')"
filterable
/>
</n-space>
</n-modal>
<template #action>
<n-button @click="$emit('update:show', false)">
{{ t('favorites.manager.tagManager.close') }}
</n-button>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { ref, computed, inject, watch, h, type Ref } from 'vue';
import {
NModal,
NSpace,
NInput,
NButton,
NDataTable,
NTag,
NIcon,
NText,
NPopconfirm,
NSelect,
NTooltip,
type DataTableColumns
} from 'naive-ui';
import { Search, Edit, Trash, GitMerge } from '@vicons/tabler';
import { useI18n } from 'vue-i18n';
import { useToast } from '../composables/useToast';
import type { AppServices } from '../types/services';
import type { TagStatistics } from '@prompt-optimizer/core';
import { TagTypeConverter } from '@prompt-optimizer/core';
const { t } = useI18n();
interface Props {
show: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:show': [value: boolean];
'updated': [];
}>();
const services = inject<Ref<AppServices | null>>('services');
const message = useToast();
const loading = ref(false);
const allTags = ref<TagStatistics[]>([]);
const searchQuery = ref('');
// 新增相关
const showAddDialog = ref(false);
// 重命名相关
const showRenameDialog = ref(false);
const currentTag = ref<TagStatistics | null>(null);
const newTagName = ref('');
// 合并相关
const showMergeDialog = ref(false);
const mergeTargetTag = ref<string>('');
// 分页配置
const pagination = {
pageSize: 10,
showSizePicker: true,
pageSizes: [10, 20, 50, 100]
};
// 过滤后的标签
const filteredTags = computed(() => {
if (!searchQuery.value) {
return allTags.value;
}
const query = searchQuery.value.toLowerCase();
return allTags.value.filter(tag => tag.name.toLowerCase().includes(query));
});
// 合并目标选项(排除当前标签)
const mergeTargetOptions = computed(() => {
return allTags.value
.filter(tag => tag.name !== currentTag.value?.name)
.map(tag => ({
label: `${tag.name} (${tag.count})`,
value: tag.name
}));
});
// 表格列定义
const columns = computed<DataTableColumns<TagStatistics>>(() => [
{
title: t('favorites.manager.tagManager.tagName'),
key: 'name',
width: 250,
ellipsis: {
tooltip: true
},
render: (row) => {
return h(NTag, { type: 'info' }, { default: () => row.name });
}
},
{
title: t('favorites.manager.tagManager.useCount'),
key: 'count',
width: 150,
align: 'center' as const,
sorter: (a, b) => a.count - b.count
},
{
title: t('favorites.manager.tagManager.actions'),
key: 'actions',
width: 250,
align: 'center' as const,
render: (row) => {
return h(NSpace, { size: 4, justify: 'center' }, {
default: () => [
h(
NButton,
{
size: 'small',
quaternary: true,
onClick: () => handleRename(row)
},
{
icon: () => h(NIcon, null, { default: () => h(Edit) }),
default: () => t('favorites.manager.tagManager.rename')
}
),
h(
NTooltip,
{},
{
trigger: () => h(
NButton,
{
size: 'small',
quaternary: true,
onClick: () => handleMerge(row)
},
{
icon: () => h(NIcon, null, { default: () => h(GitMerge) }),
default: () => t('favorites.manager.tagManager.merge')
}
),
default: () => t('favorites.manager.tagManager.mergeTooltip')
}
),
h(
NPopconfirm,
{
onPositiveClick: () => handleDelete(row),
positiveText: t('favorites.manager.tagManager.renameDialog.confirm'),
negativeText: t('favorites.manager.tagManager.renameDialog.cancel')
},
{
trigger: () => h(
NButton,
{
size: 'small',
quaternary: true,
type: 'error'
},
{
icon: () => h(NIcon, null, { default: () => h(Trash) }),
default: () => t('favorites.manager.tagManager.delete')
}
),
default: () => t('favorites.manager.tagManager.deleteConfirm', { name: row.name, count: row.count })
}
)
]
});
}
}
]);
// 加载标签数据
const loadTags = async () => {
const servicesValue = services?.value;
if (!servicesValue?.favoriteManager) {
return;
}
loading.value = true;
try {
const tags = await servicesValue.favoriteManager.getAllTags();
// 使用统一的类型转换器转换数据格式
allTags.value = TagTypeConverter.toTagStatistics(tags);
} catch (error: any) {
message.error(t('favorites.manager.tagManager.messages.loadFailed') + `: ${error?.message || '未知错误'}`);
} finally {
loading.value = false;
}
};
// 新增标签
const handleAdd = () => {
newTagName.value = '';
showAddDialog.value = true;
};
const handleAddConfirm = async () => {
const servicesValue = services?.value;
if (!servicesValue?.favoriteManager) {
return;
}
const trimmedName = newTagName.value.trim();
if (!trimmedName) {
message.warning(t('favorites.manager.tagManager.addDialog.emptyWarning'));
return false;
}
// 检查标签是否已存在
if (allTags.value.some(tag => tag.name === trimmedName)) {
message.warning(t('favorites.manager.tagManager.addDialog.existWarning'));
return false;
}
try {
// 调用 addTag API 持久化标签
await servicesValue.favoriteManager.addTag(trimmedName);
// 重新加载标签列表
await loadTags();
message.success(t('favorites.manager.tagManager.messages.addSuccess'));
showAddDialog.value = false;
return true;
} catch (error: any) {
message.error(t('favorites.manager.tagManager.messages.addFailed') + `: ${error?.message || '未知错误'}`);
return false;
}
};
// 重命名标签
const handleRename = (tag: TagStatistics) => {
currentTag.value = tag;
newTagName.value = tag.name;
showRenameDialog.value = true;
};
const handleRenameConfirm = async () => {
const servicesValue = services?.value;
if (!servicesValue?.favoriteManager || !currentTag.value) {
return;
}
const trimmedName = newTagName.value.trim();
if (!trimmedName) {
message.warning(t('favorites.manager.tagManager.renameDialog.emptyWarning'));
return false;
}
if (trimmedName === currentTag.value.name) {
showRenameDialog.value = false;
return true;
}
try {
await servicesValue.favoriteManager.renameTag(currentTag.value.name, trimmedName);
message.success(t('favorites.manager.tagManager.messages.renameSuccess'));
await loadTags();
emit('updated');
showRenameDialog.value = false;
return true;
} catch (error: any) {
message.error(t('favorites.manager.tagManager.messages.renameFailed') + `: ${error?.message || '未知错误'}`);
return false;
}
};
// 合并标签
const handleMerge = (tag: TagStatistics) => {
currentTag.value = tag;
mergeTargetTag.value = '';
showMergeDialog.value = true;
};
const handleMergeConfirm = async () => {
const servicesValue = services?.value;
if (!servicesValue?.favoriteManager || !currentTag.value || !mergeTargetTag.value) {
message.warning(t('favorites.manager.tagManager.mergeDialog.selectTargetWarning'));
return false;
}
try {
await servicesValue.favoriteManager.mergeTags([currentTag.value.name], mergeTargetTag.value);
message.success(t('favorites.manager.tagManager.messages.mergeSuccess'));
await loadTags();
emit('updated');
showMergeDialog.value = false;
return true;
} catch (error: any) {
message.error(t('favorites.manager.tagManager.messages.mergeFailed') + `: ${error?.message || '未知错误'}`);
return false;
}
};
// 删除标签
const handleDelete = async (tag: TagStatistics) => {
const servicesValue = services?.value;
if (!servicesValue?.favoriteManager) {
return;
}
try {
await servicesValue.favoriteManager.deleteTag(tag.name);
message.success(t('favorites.manager.tagManager.messages.deleteSuccess'));
await loadTags();
emit('updated');
} catch (error: any) {
message.error(t('favorites.manager.tagManager.messages.deleteFailed') + `: ${error?.message || '未知错误'}`);
}
};
// 监听对话框显示状态,打开时加载数据
watch(() => props.show, (newShow) => {
if (newShow) {
loadTags();
}
}, { immediate: true });
</script>
@@ -0,0 +1,54 @@
import { useI18n } from 'vue-i18n';
import type { FavoriteManager } from '@prompt-optimizer/core';
/**
* 收藏功能初始化器
* 负责创建国际化的默认分类
*/
export function useFavoriteInitializer(manager: FavoriteManager) {
const { t } = useI18n();
/**
* 确保默认分类存在(仅首次使用时创建)
*/
const ensureDefaultCategories = async () => {
const defaultCategories = [
{
name: t('favorites.categories.default.uncategorized'),
description: t('favorites.categories.default.uncategorizedDesc'),
color: '#6B7280'
},
{
name: t('favorites.categories.default.creativeWriting'),
description: t('favorites.categories.default.creativeWritingDesc'),
color: '#8B5CF6'
},
{
name: t('favorites.categories.default.programming'),
description: t('favorites.categories.default.programmingDesc'),
color: '#F59E0B'
},
{
name: t('favorites.categories.default.businessAnalysis'),
description: t('favorites.categories.default.businessAnalysisDesc'),
color: '#EF4444'
},
{
name: t('favorites.categories.default.learning'),
description: t('favorites.categories.default.learningDesc'),
color: '#10B981'
},
{
name: t('favorites.categories.default.dailyAssistant'),
description: t('favorites.categories.default.dailyAssistantDesc'),
color: '#3B82F6'
}
];
await manager.ensureDefaultCategories(defaultCategories);
};
return {
ensureDefaultCategories
};
}
@@ -0,0 +1,111 @@
import { ref, computed, inject, type Ref } from 'vue';
import type { AppServices } from '../types/services';
import { TagTypeConverter } from '@prompt-optimizer/core';
export interface TagSuggestion {
label: string;
value: string;
count: number;
}
/**
* 标签建议 Composable
* 提供标签自动完成功能,基于现有收藏中的标签使用情况
*/
export function useTagSuggestions() {
const services = inject<Ref<AppServices | null>>('services');
const allTags = ref<TagSuggestion[]>([]);
const loading = ref(false);
/**
* 加载所有标签统计数据
*/
const loadTags = async () => {
if (!services?.value?.favoriteManager) {
return;
}
loading.value = true;
try {
const tagStats = await services.value.favoriteManager.getAllTags();
// 使用统一的类型转换器转换为自动完成选项格式
allTags.value = TagTypeConverter.toAutoCompleteOptions(tagStats);
} catch (error) {
console.error('Failed to load tags:', error);
allTags.value = [];
} finally {
loading.value = false;
}
};
/**
* 根据输入查询过滤标签建议
* @param query 查询字符串
* @param excludeTags 需要排除的标签(已选中的标签)
* @returns 过滤后的标签建议列表
*/
const filterTags = (query: string, excludeTags: string[] = []): TagSuggestion[] => {
if (!query) {
// 如果没有输入,返回所有未选中的标签,按使用次数排序
return allTags.value
.filter(tag => !excludeTags.includes(tag.value))
.sort((a, b) => b.count - a.count);
}
// 模糊搜索匹配
const lowerQuery = query.toLowerCase();
return allTags.value
.filter(tag => {
// 排除已选中的标签
if (excludeTags.includes(tag.value)) {
return false;
}
// 包含查询字符串
return tag.value.toLowerCase().includes(lowerQuery);
})
.sort((a, b) => {
// 优先匹配前缀
const aStartsWith = a.value.toLowerCase().startsWith(lowerQuery);
const bStartsWith = b.value.toLowerCase().startsWith(lowerQuery);
if (aStartsWith && !bStartsWith) return -1;
if (!aStartsWith && bStartsWith) return 1;
// 其次按使用次数排序
return b.count - a.count;
});
};
/**
* 获取热门标签(使用次数最多的前N个)
* @param limit 返回数量限制
* @param excludeTags 需要排除的标签
*/
const getPopularTags = computed(() => {
return (limit = 10, excludeTags: string[] = []): TagSuggestion[] => {
return allTags.value
.filter(tag => !excludeTags.includes(tag.value))
.sort((a, b) => b.count - a.count)
.slice(0, limit);
};
});
/**
* 获取最近使用的标签(暂时与热门标签相同,未来可以基于时间戳优化)
* @param limit 返回数量限制
* @param excludeTags 需要排除的标签
*/
const getRecentTags = computed(() => {
return (limit = 10, excludeTags: string[] = []): TagSuggestion[] => {
// TODO: 未来可以基于收藏的更新时间来优化这个逻辑
return getPopularTags.value(limit, excludeTags);
};
});
return {
allTags,
loading,
loadTags,
filterTags,
getPopularTags,
getRecentTags
};
}
+91 -1
View File
@@ -1,3 +1,20 @@
const favoriteCategoriesMessages = {
default: {
uncategorized: 'Uncategorized',
uncategorizedDesc: 'Default category',
creativeWriting: 'Creative Writing',
creativeWritingDesc: 'Creative writing prompts',
programming: 'Programming',
programmingDesc: 'Programming and development prompts',
businessAnalysis: 'Business Analysis',
businessAnalysisDesc: 'Business analysis prompts',
learning: 'Learning & Research',
learningDesc: 'Learning and research prompts',
dailyAssistant: 'Daily Assistant',
dailyAssistantDesc: 'Daily life assistant prompts'
}
};
export default {
common: {
loading: 'Loading...',
@@ -103,6 +120,7 @@ export default {
},
favorites: {
title: 'My Favorites',
categories: favoriteCategoriesMessages,
manager: {
title: 'Favorites Manager',
searchPlaceholder: 'Search favorites...',
@@ -138,6 +156,8 @@ export default {
title: 'Category Manager'
},
actions: {
manageTags: 'Manage Tags',
manageCategories: 'Manage Categories',
export: 'Export Favorites',
clear: 'Clear Favorites',
exportSuccess: 'Export successful',
@@ -180,7 +200,77 @@ export default {
edit: 'Edit',
delete: 'Delete',
cancel: 'Cancel',
deleteConfirm: 'Delete "{title}"?'
deleteConfirm: 'Delete "{title}"?',
functionMode: {
basic: 'Basic',
context: 'Context',
image: 'Image'
},
optimizationMode: {
system: 'System',
user: 'User'
},
imageSubMode: {
text2image: 'Text-to-Image',
image2image: 'Image-to-Image'
}
},
categories: favoriteCategoriesMessages,
tagManager: {
title: 'Tag Manager',
searchPlaceholder: 'Search tags...',
totalTags: 'Total {count} tags',
add: 'Add Tag',
tagName: 'Tag Name',
useCount: 'Use Count',
actions: 'Actions',
rename: 'Rename',
merge: 'Merge',
mergeTooltip: 'Merge current tag into target tag',
delete: 'Delete',
close: 'Close',
addDialog: {
title: 'Add Tag',
tagName: 'Tag Name',
tagNamePlaceholder: 'Enter tag name',
emptyWarning: 'Tag name cannot be empty',
existWarning: 'Tag already exists',
confirm: 'Confirm',
cancel: 'Cancel'
},
renameDialog: {
title: 'Rename Tag',
oldName: 'Original Name',
newName: 'New Name',
newNamePlaceholder: 'Enter new tag name',
currentName: 'Current tag name: {name}',
emptyWarning: 'Tag name cannot be empty',
confirm: 'Confirm',
cancel: 'Cancel'
},
mergeDialog: {
title: 'Merge Tags',
sourceName: 'Source Tag',
targetName: 'Merge Into',
targetPlaceholder: 'Select target tag',
sourceTag: 'Source tag: {name}',
selectTargetWarning: 'Please select target tag',
confirm: 'Confirm Merge',
cancel: 'Cancel',
warning: 'After merging, "{source}" tag will be deleted, and all favorites using this tag will use "{target}" tag instead'
},
deleteConfirm: 'Are you sure you want to delete tag "{name}"? This will remove the tag from {count} favorites.',
messages: {
loadFailed: 'Failed to load tag list',
addSuccess: 'Tag added successfully',
addFailed: 'Failed to add tag',
renameSuccess: 'Tag renamed successfully',
renameFailed: 'Failed to rename tag',
mergeSuccess: 'Tags merged successfully',
mergeFailed: 'Failed to merge tags',
deleteSuccess: 'Tag deleted successfully',
deleteFailed: 'Failed to delete tag'
}
}
},
dialog: {
+92 -2
View File
@@ -1,4 +1,21 @@
export default {
const favoriteCategoriesMessages = {
default: {
uncategorized: '未分类',
uncategorizedDesc: '默认分类',
creativeWriting: '创意写作',
creativeWritingDesc: '创意写作相关的提示词',
programming: '编程开发',
programmingDesc: '编程开发相关的提示词',
businessAnalysis: '商业分析',
businessAnalysisDesc: '商业分析相关的提示词',
learning: '学习研究',
learningDesc: '学习研究相关的提示词',
dailyAssistant: '日常助手',
dailyAssistantDesc: '日常生活助手类提示词'
}
};
export default {
common: {
loading: '加载中...',
save: '保存',
@@ -103,6 +120,7 @@
},
favorites: {
title: '我的收藏',
categories: favoriteCategoriesMessages,
manager: {
title: '收藏管理',
searchPlaceholder: '搜索收藏...',
@@ -138,6 +156,8 @@
title: '分类管理'
},
actions: {
manageTags: '管理标签',
manageCategories: '管理分类',
export: '导出收藏',
clear: '清空收藏',
exportSuccess: '导出成功',
@@ -180,7 +200,77 @@
edit: '编辑',
delete: '删除',
cancel: '取消',
deleteConfirm: '确定删除"{title}"吗?'
deleteConfirm: '确定删除"{title}"吗?',
functionMode: {
basic: '基础',
context: '上下文',
image: '图像'
},
optimizationMode: {
system: '系统',
user: '用户'
},
imageSubMode: {
text2image: '文生图',
image2image: '图生图'
}
},
categories: favoriteCategoriesMessages,
tagManager: {
title: '标签管理',
searchPlaceholder: '搜索标签...',
totalTags: '共 {count} 个标签',
add: '新增标签',
tagName: '标签名',
useCount: '使用次数',
actions: '操作',
rename: '重命名',
merge: '合并',
mergeTooltip: '将当前标签合并到目标标签中',
delete: '删除',
close: '关闭',
addDialog: {
title: '新增标签',
tagName: '标签名称',
tagNamePlaceholder: '输入标签名称',
emptyWarning: '标签名不能为空',
existWarning: '标签已存在',
confirm: '确认',
cancel: '取消'
},
renameDialog: {
title: '重命名标签',
oldName: '原标签名',
newName: '新标签名',
newNamePlaceholder: '输入新的标签名',
currentName: '当前标签名:{name}',
emptyWarning: '标签名不能为空',
confirm: '确认',
cancel: '取消'
},
mergeDialog: {
title: '合并标签',
sourceName: '源标签',
targetName: '合并到',
targetPlaceholder: '选择目标标签',
sourceTag: '源标签:{name}',
selectTargetWarning: '请选择目标标签',
confirm: '确认合并',
cancel: '取消',
warning: '合并后,"{source}" 标签将被删除,所有使用该标签的收藏将改用 "{target}" 标签'
},
deleteConfirm: '确定删除标签 "{name}"?这将从 {count} 个收藏中移除该标签。',
messages: {
loadFailed: '加载标签列表失败',
addSuccess: '标签添加成功',
addFailed: '标签添加失败',
renameSuccess: '标签重命名成功',
renameFailed: '标签重命名失败',
mergeSuccess: '标签合并成功',
mergeFailed: '标签合并失败',
deleteSuccess: '标签删除成功',
deleteFailed: '标签删除失败'
}
}
},
dialog: {
+91 -1
View File
@@ -1,3 +1,20 @@
const favoriteCategoriesMessages = {
default: {
uncategorized: '未分類',
uncategorizedDesc: '預設分類',
creativeWriting: '創意寫作',
creativeWritingDesc: '創意寫作相關的提示詞',
programming: '程式開發',
programmingDesc: '程式開發相關的提示詞',
businessAnalysis: '商業分析',
businessAnalysisDesc: '商業分析相關的提示詞',
learning: '學習研究',
learningDesc: '學習研究相關的提示詞',
dailyAssistant: '日常助手',
dailyAssistantDesc: '日常生活助手類提示詞'
}
};
export default {
common: {
loading: '載入中...',
@@ -103,6 +120,7 @@ export default {
},
favorites: {
title: '我的收藏',
categories: favoriteCategoriesMessages,
manager: {
title: '收藏管理',
searchPlaceholder: '搜尋收藏...',
@@ -138,6 +156,8 @@ export default {
title: '分類管理'
},
actions: {
manageTags: '管理標籤',
manageCategories: '管理分類',
export: '匯出收藏',
clear: '清空收藏',
exportSuccess: '匯出成功',
@@ -180,7 +200,77 @@ export default {
edit: '編輯',
delete: '刪除',
cancel: '取消',
deleteConfirm: '確定刪除"{title}"嗎?'
deleteConfirm: '確定刪除"{title}"嗎?',
functionMode: {
basic: '基礎',
context: '情境',
image: '圖像'
},
optimizationMode: {
system: '系統',
user: '使用者'
},
imageSubMode: {
text2image: '文生圖',
image2image: '圖生圖'
}
},
categories: favoriteCategoriesMessages,
tagManager: {
title: '標籤管理',
searchPlaceholder: '搜尋標籤...',
totalTags: '共 {count} 個標籤',
add: '新增標籤',
tagName: '標籤名稱',
useCount: '使用次數',
actions: '操作',
rename: '重新命名',
merge: '合併',
mergeTooltip: '將目前標籤合併到目標標籤中',
delete: '刪除',
close: '關閉',
addDialog: {
title: '新增標籤',
tagName: '標籤名稱',
tagNamePlaceholder: '輸入標籤名稱',
emptyWarning: '標籤名稱不能為空',
existWarning: '標籤已存在',
confirm: '確認',
cancel: '取消'
},
renameDialog: {
title: '重新命名標籤',
oldName: '原標籤名稱',
newName: '新標籤名稱',
newNamePlaceholder: '輸入新的標籤名稱',
currentName: '目前標籤名稱:{name}',
emptyWarning: '標籤名稱不能為空',
confirm: '確認',
cancel: '取消'
},
mergeDialog: {
title: '合併標籤',
sourceName: '源標籤',
targetName: '合併到',
targetPlaceholder: '選擇目標標籤',
sourceTag: '源標籤:{name}',
selectTargetWarning: '請選擇目標標籤',
confirm: '確認合併',
cancel: '取消',
warning: '合併後,"{source}" 標籤將被刪除,所有使用該標籤的收藏將改用 "{target}" 標籤'
},
deleteConfirm: '確定刪除標籤 "{name}"?這將從 {count} 個收藏中移除該標籤。',
messages: {
loadFailed: '載入標籤清單失敗',
addSuccess: '標籤新增成功',
addFailed: '標籤新增失敗',
renameSuccess: '標籤重新命名成功',
renameFailed: '標籤重新命名失敗',
mergeSuccess: '標籤合併成功',
mergeFailed: '標籤合併失敗',
deleteSuccess: '標籤刪除成功',
deleteFailed: '標籤刪除失敗'
}
}
},
dialog: {