feat: 添加快捷指令的标签管理系统

Related to #5
This commit is contained in:
Baobhan Sith
2025-05-03 15:18:51 +08:00
parent 430aac8512
commit 026ed949fb
22 changed files with 1828 additions and 296 deletions
+34 -1
View File
@@ -73,8 +73,41 @@ const definedMigrations: Migration[] = [
-- UPDATE connections SET encrypted_passphrase = NULL WHERE encrypted_passphrase = ''; -- 示例 -- UPDATE connections SET encrypted_passphrase = NULL WHERE encrypted_passphrase = ''; -- 示例
` `
}, },
// --- Quick Command Tags Migrations ---
{
id: 2,
name: 'Create quick_command_tags table',
check: async (db: Database): Promise<boolean> => {
const tableAlreadyExists = await tableExists(db, 'quick_command_tags');
return !tableAlreadyExists; // Only run if the table does NOT exist
},
sql: `
CREATE TABLE IF NOT EXISTS quick_command_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
`
},
{
id: 3,
name: 'Create quick_command_tag_associations table',
check: async (db: Database): Promise<boolean> => {
const tableAlreadyExists = await tableExists(db, 'quick_command_tag_associations');
return !tableAlreadyExists; // Only run if the table does NOT exist
},
sql: `
CREATE TABLE IF NOT EXISTS quick_command_tag_associations (
quick_command_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (quick_command_id, tag_id),
FOREIGN KEY (quick_command_id) REFERENCES quick_commands(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES quick_command_tags(id) ON DELETE CASCADE
);
`
}
// --- 未来可以添加更多迁移 --- // --- 未来可以添加更多迁移 ---
// { id: 2, name: '...', sql: '...' },
]; ];
/** /**
+24
View File
@@ -152,6 +152,30 @@ CREATE TABLE IF NOT EXISTS quick_commands (
); );
`; `;
// --- Quick Command Tags ---
export const createQuickCommandTagsTableSQL = `
CREATE TABLE IF NOT EXISTS quick_command_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
`;
export const createQuickCommandTagAssociationsTableSQL = `
CREATE TABLE IF NOT EXISTS quick_command_tag_associations (
quick_command_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (quick_command_id, tag_id),
FOREIGN KEY (quick_command_id) REFERENCES quick_commands(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES quick_command_tags(id) ON DELETE CASCADE
);
`;
// --- End Quick Command Tags ---
// 从 database.ts 移动过来的,保持一致性 // 从 database.ts 移动过来的,保持一致性
export const createTerminalThemesTableSQL = ` export const createTerminalThemesTableSQL = `
CREATE TABLE IF NOT EXISTS terminal_themes ( CREATE TABLE IF NOT EXISTS terminal_themes (
+3 -1
View File
@@ -21,6 +21,7 @@ import quickCommandsRoutes from './quick-commands/quick-commands.routes';
import terminalThemeRoutes from './terminal-themes/terminal-theme.routes'; import terminalThemeRoutes from './terminal-themes/terminal-theme.routes';
import appearanceRoutes from './appearance/appearance.routes'; import appearanceRoutes from './appearance/appearance.routes';
import sshKeysRouter from './ssh_keys/ssh_keys.routes'; // +++ Import SSH Key routes +++ import sshKeysRouter from './ssh_keys/ssh_keys.routes'; // +++ Import SSH Key routes +++
import quickCommandTagRoutes from './quick-command-tags/quick-command-tag.routes'; // +++ Import Quick Command Tag routes +++
import { initializeWebSocket } from './websocket'; import { initializeWebSocket } from './websocket';
import { ipWhitelistMiddleware } from './auth/ipWhitelist.middleware'; import { ipWhitelistMiddleware } from './auth/ipWhitelist.middleware';
@@ -261,7 +262,8 @@ const startServer = () => {
app.use('/api/v1/terminal-themes', terminalThemeRoutes); app.use('/api/v1/terminal-themes', terminalThemeRoutes);
app.use('/api/v1/appearance', appearanceRoutes); app.use('/api/v1/appearance', appearanceRoutes);
app.use('/api/v1/ssh-keys', sshKeysRouter); // +++ Register SSH Key routes +++ app.use('/api/v1/ssh-keys', sshKeysRouter); // +++ Register SSH Key routes +++
app.use('/api/v1/quick-command-tags', quickCommandTagRoutes); // +++ Register Quick Command Tag routes +++
// 状态检查接口 // 状态检查接口
app.get('/api/v1/status', (req: Request, res: Response) => { app.get('/api/v1/status', (req: Request, res: Response) => {
res.json({ status: '后端服务运行中!' }); res.json({ status: '后端服务运行中!' });
@@ -0,0 +1,130 @@
import { Request, Response } from 'express';
import * as QuickCommandTagService from '../services/quick-command-tag.service';
/**
* 处理获取所有快捷指令标签的请求
*/
export const getAllQuickCommandTags = async (req: Request, res: Response): Promise<void> => {
try {
const tags = await QuickCommandTagService.getAllQuickCommandTags();
res.status(200).json(tags);
} catch (error: any) {
console.error('[Controller] 获取快捷指令标签列表失败:', error.message);
res.status(500).json({ message: error.message || '无法获取快捷指令标签列表' });
}
};
/**
* 处理添加新快捷指令标签的请求
*/
export const addQuickCommandTag = async (req: Request, res: Response): Promise<void> => {
const { name } = req.body;
if (!name || typeof name !== 'string' || name.trim().length === 0) {
res.status(400).json({ message: '标签名称不能为空且必须是字符串' });
return;
}
try {
const newId = await QuickCommandTagService.addQuickCommandTag(name);
// 成功添加后,获取新创建的标签信息返回给前端
const newTag = await QuickCommandTagService.getQuickCommandTagById(newId);
if (newTag) {
res.status(201).json({ message: '快捷指令标签已添加', tag: newTag });
} else {
// 理论上不应该发生,但作为健壮性检查
console.error(`[Controller] 添加快捷指令标签后未能找到 ID: ${newId}`);
res.status(201).json({ message: '快捷指令标签已添加,但无法检索新记录', id: newId });
}
} catch (error: any) {
console.error('[Controller] 添加快捷指令标签失败:', error.message);
// 检查是否是名称重复错误
if (error.message && error.message.includes('已存在')) {
res.status(409).json({ message: error.message }); // 409 Conflict
} else {
res.status(500).json({ message: error.message || '无法添加快捷指令标签' });
}
}
};
/**
* 处理更新快捷指令标签的请求
*/
export const updateQuickCommandTag = async (req: Request, res: Response): Promise<void> => {
const id = parseInt(req.params.id, 10);
const { name } = req.body;
if (isNaN(id)) {
res.status(400).json({ message: '无效的标签 ID' });
return;
}
if (!name || typeof name !== 'string' || name.trim().length === 0) {
res.status(400).json({ message: '标签名称不能为空且必须是字符串' });
return;
}
try {
const success = await QuickCommandTagService.updateQuickCommandTag(id, name);
if (success) {
// 成功更新后,获取更新后的标签信息返回给前端
const updatedTag = await QuickCommandTagService.getQuickCommandTagById(id);
if (updatedTag) {
res.status(200).json({ message: '快捷指令标签已更新', tag: updatedTag });
} else {
console.error(`[Controller] 更新快捷指令标签后未能找到 ID: ${id}`);
res.status(200).json({ message: '快捷指令标签已更新,但无法检索更新后的记录' });
}
} else {
// 检查标签是否真的不存在
const tagExists = await QuickCommandTagService.getQuickCommandTagById(id);
if (!tagExists) {
res.status(404).json({ message: '未找到要更新的快捷指令标签' });
} else {
// 如果标签存在但更新失败(理论上不太可能,除非并发问题),返回服务器错误
console.error(`[Controller] 更新快捷指令标签 ${id} 失败,但标签存在。`);
res.status(500).json({ message: '更新快捷指令标签时发生未知错误' });
}
}
} catch (error: any) {
console.error('[Controller] 更新快捷指令标签失败:', error.message);
// 检查是否是名称重复错误
if (error.message && error.message.includes('已存在')) {
res.status(409).json({ message: error.message }); // 409 Conflict
} else {
res.status(500).json({ message: error.message || '无法更新快捷指令标签' });
}
}
};
/**
* 处理删除快捷指令标签的请求
*/
export const deleteQuickCommandTag = async (req: Request, res: Response): Promise<void> => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
res.status(400).json({ message: '无效的标签 ID' });
return;
}
try {
// 先检查标签是否存在,以便返回 404
const tagExists = await QuickCommandTagService.getQuickCommandTagById(id);
if (!tagExists) {
res.status(404).json({ message: '未找到要删除的快捷指令标签' });
return;
}
const success = await QuickCommandTagService.deleteQuickCommandTag(id);
if (success) {
res.status(200).json({ message: '快捷指令标签已删除' });
} else {
// 如果上面检查存在但删除失败,说明有内部错误
console.error(`[Controller] 删除快捷指令标签 ${id} 失败,但标签存在。`);
res.status(500).json({ message: '删除快捷指令标签时发生未知错误' });
}
} catch (error: any) {
console.error('[Controller] 删除快捷指令标签失败:', error.message);
res.status(500).json({ message: error.message || '无法删除快捷指令标签' });
}
};
@@ -0,0 +1,19 @@
import express from 'express';
import * as QuickCommandTagController from './quick-command-tag.controller';
import { isAuthenticated } from '../auth/auth.middleware'; // 假设需要认证
const router = express.Router();
// 获取所有快捷指令标签
router.get('/', isAuthenticated, QuickCommandTagController.getAllQuickCommandTags);
// 添加新的快捷指令标签
router.post('/', isAuthenticated, QuickCommandTagController.addQuickCommandTag);
// 更新快捷指令标签
router.put('/:id', isAuthenticated, QuickCommandTagController.updateQuickCommandTag);
// 删除快捷指令标签
router.delete('/:id', isAuthenticated, QuickCommandTagController.deleteQuickCommandTag);
export default router;
@@ -6,8 +6,10 @@ import { QuickCommandSortBy } from '../services/quick-commands.service';
* 处理添加新快捷指令的请求 * 处理添加新快捷指令的请求
*/ */
export const addQuickCommand = async (req: Request, res: Response): Promise<void> => { export const addQuickCommand = async (req: Request, res: Response): Promise<void> => {
const { name, command } = req.body; // 从请求体中解构出 name, command, 以及可选的 tagIds
const { name, command, tagIds } = req.body;
// --- 基本验证 ---
if (!command || typeof command !== 'string' || command.trim().length === 0) { if (!command || typeof command !== 'string' || command.trim().length === 0) {
res.status(400).json({ message: '指令内容不能为空' }); res.status(400).json({ message: '指令内容不能为空' });
return; return;
@@ -17,12 +19,26 @@ export const addQuickCommand = async (req: Request, res: Response): Promise<void
res.status(400).json({ message: '名称必须是字符串或 null' }); res.status(400).json({ message: '名称必须是字符串或 null' });
return; return;
} }
// 验证 tagIds (如果提供的话)
if (tagIds !== undefined && (!Array.isArray(tagIds) || !tagIds.every(id => typeof id === 'number'))) {
res.status(400).json({ message: 'tagIds 必须是一个数字数组' });
return;
}
// --- 结束验证 ---
try { try {
const newId = await QuickCommandsService.addQuickCommand(name, command); // 将 tagIds 传递给 Service 层
res.status(201).json({ id: newId, message: '快捷指令已添加' }); const newId = await QuickCommandsService.addQuickCommand(name, command, tagIds);
// 尝试获取新创建的带标签的指令信息返回
const newCommand = await QuickCommandsService.getQuickCommandById(newId);
if (newCommand) {
res.status(201).json({ message: '快捷指令已添加', command: newCommand });
} else {
console.error(`[Controller] 添加快捷指令后未能找到 ID: ${newId}`);
res.status(201).json({ message: '快捷指令已添加,但无法检索新记录', id: newId });
}
} catch (error: any) { } catch (error: any) {
console.error('添加快捷指令控制器出错:', error); console.error('[Controller] 添加快捷指令失败:', error.message);
res.status(500).json({ message: error.message || '无法添加快捷指令' }); res.status(500).json({ message: error.message || '无法添加快捷指令' });
} }
}; };
@@ -49,8 +65,10 @@ export const getAllQuickCommands = async (req: Request, res: Response): Promise<
*/ */
export const updateQuickCommand = async (req: Request, res: Response): Promise<void> => { export const updateQuickCommand = async (req: Request, res: Response): Promise<void> => {
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
const { name, command } = req.body; // 从请求体中解构出 name, command, 以及可选的 tagIds
const { name, command, tagIds } = req.body;
// --- 基本验证 ---
if (isNaN(id)) { if (isNaN(id)) {
res.status(400).json({ message: '无效的 ID' }); res.status(400).json({ message: '无效的 ID' });
return; return;
@@ -63,13 +81,35 @@ export const updateQuickCommand = async (req: Request, res: Response): Promise<v
res.status(400).json({ message: '名称必须是字符串或 null' }); res.status(400).json({ message: '名称必须是字符串或 null' });
return; return;
} }
// 验证 tagIds (如果提供的话)
// 注意: tagIds 为 undefined 表示不更新标签,空数组 [] 表示清除所有标签
if (tagIds !== undefined && (!Array.isArray(tagIds) || !tagIds.every(id => typeof id === 'number'))) {
res.status(400).json({ message: 'tagIds 必须是一个数字数组' });
return;
}
// --- 结束验证 ---
try { try {
const success = await QuickCommandsService.updateQuickCommand(id, name, command); // 将 tagIds 传递给 Service 层
const success = await QuickCommandsService.updateQuickCommand(id, name, command, tagIds);
if (success) { if (success) {
res.status(200).json({ message: '快捷指令已更新' }); // 尝试获取更新后的带标签的指令信息返回
const updatedCommand = await QuickCommandsService.getQuickCommandById(id);
if (updatedCommand) {
res.status(200).json({ message: '快捷指令已更新', command: updatedCommand });
} else {
console.error(`[Controller] 更新快捷指令后未能找到 ID: ${id}`);
res.status(200).json({ message: '快捷指令已更新,但无法检索更新后的记录' });
}
} else { } else {
res.status(404).json({ message: '未找到要更新的快捷指令' }); // 检查指令是否真的不存在
const commandExists = await QuickCommandsService.getQuickCommandById(id);
if (!commandExists) {
res.status(404).json({ message: '未找到要更新的快捷指令' });
} else {
console.error(`[Controller] 更新快捷指令 ${id} 失败,但指令存在。`);
res.status(500).json({ message: '更新快捷指令时发生未知错误' });
}
} }
} catch (error: any) { } catch (error: any) {
console.error('更新快捷指令控制器出错:', error); console.error('更新快捷指令控制器出错:', error);
@@ -126,3 +166,28 @@ export const incrementUsage = async (req: Request, res: Response): Promise<void>
res.status(500).json({ message: error.message || '无法增加使用次数' }); res.status(500).json({ message: error.message || '无法增加使用次数' });
} }
}; };
/**
* 批量将标签分配给多个快捷指令
*/
export const assignTagToCommands = async (req: Request, res: Response): Promise<void> => { // Add : Promise<void>
const { commandIds, tagId } = req.body;
// 基本验证
if (!Array.isArray(commandIds) || commandIds.length === 0 || typeof tagId !== 'number') {
res.status(400).json({ success: false, message: '请求体必须包含 commandIds (非空数组) 和 tagId (数字)。' });
return; // Use return without value to exit early
}
try {
// 调用 Service 函数处理批量分配
console.log(`[Controller] assignTagToCommands: Received commandIds: ${JSON.stringify(commandIds)}, tagId: ${tagId}`); // +++ 添加日志 +++
await QuickCommandsService.assignTagToCommands(commandIds, tagId);
res.status(200).json({ success: true, message: `标签 ${tagId} 已成功尝试关联到 ${commandIds.length} 个指令。` });
} catch (error: any) {
console.error('[Controller] 批量分配标签时出错:', error.message);
// 根据错误类型返回不同的状态码可能更好,但这里简化处理
res.status(500).json({ success: false, message: error.message || '批量分配标签时发生内部服务器错误。' });
// No return needed here, error handling completes the response
}
};
@@ -13,5 +13,6 @@ router.get('/', QuickCommandsController.getAllQuickCommands); // GET /api/v1/qui
router.put('/:id', QuickCommandsController.updateQuickCommand); // PUT /api/v1/quick-commands/:id router.put('/:id', QuickCommandsController.updateQuickCommand); // PUT /api/v1/quick-commands/:id
router.delete('/:id', QuickCommandsController.deleteQuickCommand); // DELETE /api/v1/quick-commands/:id router.delete('/:id', QuickCommandsController.deleteQuickCommand); // DELETE /api/v1/quick-commands/:id
router.post('/:id/increment-usage', QuickCommandsController.incrementUsage); // POST /api/v1/quick-commands/:id/increment-usage router.post('/:id/increment-usage', QuickCommandsController.incrementUsage); // POST /api/v1/quick-commands/:id/increment-usage
router.post('/bulk-assign-tag', QuickCommandsController.assignTagToCommands); // POST /api/v1/quick-commands/bulk-assign-tag
export default router; export default router;
@@ -0,0 +1,197 @@
import { Database } from 'sqlite3';
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
// 定义 Quick Command Tag 类型
export interface QuickCommandTag {
id: number;
name: string;
created_at: number;
updated_at: number;
}
/**
* 获取所有快捷指令标签
*/
export const findAllQuickCommandTags = async (): Promise<QuickCommandTag[]> => {
try {
const db = await getDbInstance();
const rows = await allDb<QuickCommandTag>(db, `SELECT * FROM quick_command_tags ORDER BY name ASC`);
return rows;
} catch (err: any) {
console.error('[仓库] 查询快捷指令标签列表时出错:', err.message);
throw new Error('获取快捷指令标签列表失败');
}
};
/**
* 根据 ID 获取单个快捷指令标签
*/
export const findQuickCommandTagById = async (id: number): Promise<QuickCommandTag | null> => {
try {
const db = await getDbInstance();
const row = await getDbRow<QuickCommandTag>(db, `SELECT * FROM quick_command_tags WHERE id = ?`, [id]);
return row || null;
} catch (err: any) {
console.error(`[仓库] 查询快捷指令标签 ${id} 时出错:`, err.message);
throw new Error('获取快捷指令标签信息失败');
}
};
/**
* 创建新快捷指令标签
*/
export const createQuickCommandTag = async (name: string): Promise<number> => {
const now = Math.floor(Date.now() / 1000);
const sql = `INSERT INTO quick_command_tags (name, created_at, updated_at) VALUES (?, ?, ?)`;
try {
const db = await getDbInstance();
const result = await runDb(db, sql, [name, now, now]);
if (typeof result.lastID !== 'number' || result.lastID <= 0) {
throw new Error('创建快捷指令标签后未能获取有效的 lastID');
}
return result.lastID;
} catch (err: any) {
console.error('[仓库] 创建快捷指令标签时出错:', err.message);
if (err.message.includes('UNIQUE constraint failed')) {
throw new Error(`快捷指令标签名称 "${name}" 已存在。`);
}
throw new Error(`创建快捷指令标签失败: ${err.message}`);
}
};
/**
* 更新快捷指令标签名称
*/
export const updateQuickCommandTag = async (id: number, name: string): Promise<boolean> => {
const now = Math.floor(Date.now() / 1000);
const sql = `UPDATE quick_command_tags SET name = ?, updated_at = ? WHERE id = ?`;
try {
const db = await getDbInstance();
const result = await runDb(db, sql, [name, now, id]);
return result.changes > 0;
} catch (err: any) {
console.error(`[仓库] 更新快捷指令标签 ${id} 时出错:`, err.message);
if (err.message.includes('UNIQUE constraint failed')) {
throw new Error(`快捷指令标签名称 "${name}" 已存在。`);
}
throw new Error(`更新快捷指令标签失败: ${err.message}`);
}
};
/**
* 删除快捷指令标签 (同时会通过外键 CASCADE 删除关联)
*/
export const deleteQuickCommandTag = async (id: number): Promise<boolean> => {
const sql = `DELETE FROM quick_command_tags WHERE id = ?`;
try {
const db = await getDbInstance();
// 由于 quick_command_tag_associations 设置了 ON DELETE CASCADE,
// 删除 quick_command_tags 中的记录会自动删除关联表中的相关记录。
const result = await runDb(db, sql, [id]);
return result.changes > 0;
} catch (err: any) {
console.error(`[仓库] 删除快捷指令标签 ${id} 时出错:`, err.message);
throw new Error('删除快捷指令标签失败');
}
};
/**
* 设置单个快捷指令的标签关联 (先删除旧关联,再插入新关联)
* @param commandId - 快捷指令 ID
* @param tagIds - 新的标签 ID 数组 (空数组表示清除所有关联)
* @returns Promise<void>
*/
export const setCommandTagAssociations = async (commandId: number, tagIds: number[]): Promise<void> => {
const db = await getDbInstance();
const deleteSql = `DELETE FROM quick_command_tag_associations WHERE quick_command_id = ?`;
const insertSql = `INSERT INTO quick_command_tag_associations (quick_command_id, tag_id) VALUES (?, ?)`;
try {
await runDb(db, 'BEGIN TRANSACTION');
// 1. 删除该指令的所有旧关联
await runDb(db, deleteSql, [commandId]);
// 2. 插入新关联 (如果 tagIds 不为空)
if (tagIds && tagIds.length > 0) {
const stmt = await db.prepare(insertSql);
for (const tagId of tagIds) {
// 验证 tagId 是否为有效数字
if (typeof tagId !== 'number' || isNaN(tagId)) {
console.warn(`[Repo] setCommandTagAssociations: 无效的 tagId (${tagId}),跳过关联到指令 ${commandId}`);
continue;
}
await stmt.run(commandId, tagId);
}
await stmt.finalize();
}
await runDb(db, 'COMMIT');
} catch (err: any) {
console.error('设置快捷指令标签关联时出错:', err.message);
await runDb(db, 'ROLLBACK'); // 出错时回滚
throw new Error('无法设置快捷指令标签关联');
}
};
/**
* 将单个标签批量添加到多个快捷指令
* @param commandIds - 需要添加标签的快捷指令 ID 数组
* @param tagId - 要添加的标签 ID
* @returns Promise<void>
*/
export const addTagToCommands = async (commandIds: number[], tagId: number): Promise<void> => {
if (!commandIds || commandIds.length === 0) {
return; // 没有指令需要关联
}
const db = await getDbInstance();
const insertSql = `INSERT OR IGNORE INTO quick_command_tag_associations (quick_command_id, tag_id) VALUES (?, ?)`;
try {
await runDb(db, 'BEGIN TRANSACTION');
// 准备批量插入语句
const stmt = await db.prepare(insertSql);
for (const commandId of commandIds) {
// 验证 commandId 和 tagId 是否为有效数字(可选,但推荐)
if (typeof commandId !== 'number' || isNaN(commandId) || typeof tagId !== 'number' || isNaN(tagId)) {
console.warn(`[Repo] addTagToCommands: 无效的 commandId (${commandId}) 或 tagId (${tagId}),跳过关联。`);
continue;
}
await stmt.run(commandId, tagId);
}
await stmt.finalize(); // 完成批量插入
await runDb(db, 'COMMIT');
console.log(`[Repo] addTagToCommands: 成功将标签 ${tagId} 关联到 ${commandIds.length} 个指令。`);
} catch (err: any) {
console.error(`[Repo] addTagToCommands: 批量关联标签 ${tagId} 到指令时出错:`, err.message);
await runDb(db, 'ROLLBACK');
throw new Error('无法批量关联标签到快捷指令');
}
};
/**
* 更新指定快捷指令的标签关联 (使用事务)
* @param commandId 快捷指令 ID
* @param tagIds 新的快捷指令标签 ID 数组 (空数组表示清除所有标签)
*/
// Removed the duplicate function declaration that returned Promise<boolean>
/**
* 查找指定快捷指令的所有标签
* @param commandId 快捷指令 ID
* @returns 标签对象数组 { id: number, name: string }[]
*/
export const findTagsByCommandId = async (commandId: number): Promise<QuickCommandTag[]> => {
const sql = `
SELECT t.id, t.name, t.created_at, t.updated_at
FROM quick_command_tags t
JOIN quick_command_tag_associations ta ON t.id = ta.tag_id
WHERE ta.quick_command_id = ?
ORDER BY t.name ASC`;
try {
const db = await getDbInstance();
const rows = await allDb<QuickCommandTag>(db, sql, [commandId]);
return rows;
} catch (err: any) {
console.error(`Repository: 查询快捷指令 ${commandId} 的标签时出错:`, err.message);
throw new Error('获取快捷指令标签失败');
}
};
@@ -1,6 +1,6 @@
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection'; import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
// 定义快捷指令接口 // 定义基础快捷指令接口
export interface QuickCommand { export interface QuickCommand {
id: number; id: number;
name: string | null; // 名称可选 name: string | null; // 名称可选
@@ -10,7 +10,16 @@ export interface QuickCommand {
updated_at: number; // Unix 时间戳 (秒) updated_at: number; // Unix 时间戳 (秒)
} }
type DbQuickCommandRow = QuickCommand; // 定义包含标签 ID 的接口
export interface QuickCommandWithTags extends QuickCommand {
tagIds: number[];
}
// 用于从数据库获取带 tag_ids_str 的行
interface DbQuickCommandWithTagsRow extends QuickCommand {
tag_ids_str: string | null;
}
/** /**
* 添加一条新的快捷指令 * 添加一条新的快捷指令
@@ -70,22 +79,34 @@ export const deleteQuickCommand = async (id: number): Promise<boolean> => {
}; };
/** /**
* 获取所有快捷指令 * 获取所有快捷指令及其关联的标签 ID
* @param sortBy - 排序字段 ('name' 或 'usage_count') * @param sortBy - 排序字段 ('name' 或 'usage_count')
* @returns 返回包含所有快捷指令条目的数组 * @returns 返回包含所有快捷指令条目及标签 ID 的数组
*/ */
export const getAllQuickCommands = async (sortBy: 'name' | 'usage_count' = 'name'): Promise<QuickCommand[]> => { export const getAllQuickCommands = async (sortBy: 'name' | 'usage_count' = 'name'): Promise<QuickCommandWithTags[]> => {
let orderByClause = 'ORDER BY name ASC'; // 默认按名称升序 let orderByClause = 'ORDER BY qc.name ASC'; // 默认按名称升序
if (sortBy === 'usage_count') { if (sortBy === 'usage_count') {
orderByClause = 'ORDER BY usage_count DESC, name ASC'; // 按使用频率降序,同频率按名称升序 orderByClause = 'ORDER BY qc.usage_count DESC, qc.name ASC'; // 按使用频率降序,同频率按名称升序
} }
const sql = `SELECT id, name, command, usage_count, created_at, updated_at FROM quick_commands ${orderByClause}`; // 使用 LEFT JOIN 连接关联表,并使用 GROUP_CONCAT 获取标签 ID 字符串
const sql = `
SELECT
qc.id, qc.name, qc.command, qc.usage_count, qc.created_at, qc.updated_at,
GROUP_CONCAT(qta.tag_id) as tag_ids_str
FROM quick_commands qc
LEFT JOIN quick_command_tag_associations qta ON qc.id = qta.quick_command_id
GROUP BY qc.id
${orderByClause}`;
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const rows = await allDb<DbQuickCommandRow>(db, sql); const rows = await allDb<DbQuickCommandWithTagsRow>(db, sql);
return rows; // 将 tag_ids_str 解析为数字数组
return rows.map(row => ({
...row,
tagIds: row.tag_ids_str ? row.tag_ids_str.split(',').map(Number).filter(id => !isNaN(id)) : []
}));
} catch (err: any) { } catch (err: any) {
console.error('获取快捷指令时出错:', err.message); console.error('获取快捷指令(带标签)时出错:', err.message);
throw new Error('无法获取快捷指令'); throw new Error('无法获取快捷指令');
} }
}; };
@@ -108,18 +129,34 @@ export const incrementUsageCount = async (id: number): Promise<boolean> => {
}; };
/** /**
* 根据 ID 查找快捷指令 (用于编辑前获取数据) * 根据 ID 查找快捷指令及其关联的标签 ID
* @param id - 要查找的记录 ID * @param id - 要查找的记录 ID
* @returns 返回找到的快捷指令条目,如果未找到则返回 undefined * @returns 返回找到的快捷指令条目及标签 ID,如果未找到则返回 undefined
*/ */
export const findQuickCommandById = async (id: number): Promise<QuickCommand | undefined> => { export const findQuickCommandById = async (id: number): Promise<QuickCommandWithTags | undefined> => {
const sql = `SELECT id, name, command, usage_count, created_at, updated_at FROM quick_commands WHERE id = ?`; // 使用 LEFT JOIN 连接关联表,并使用 GROUP_CONCAT 获取标签 ID 字符串
const sql = `
SELECT
qc.id, qc.name, qc.command, qc.usage_count, qc.created_at, qc.updated_at,
GROUP_CONCAT(qta.tag_id) as tag_ids_str
FROM quick_commands qc
LEFT JOIN quick_command_tag_associations qta ON qc.id = qta.quick_command_id
WHERE qc.id = ?
GROUP BY qc.id`;
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const row = await getDbRow<DbQuickCommandRow>(db, sql, [id]); const row = await getDbRow<DbQuickCommandWithTagsRow>(db, sql, [id]);
return row; if (row && typeof row.id !== 'undefined') {
// 将 tag_ids_str 解析为数字数组
return {
...row,
tagIds: row.tag_ids_str ? row.tag_ids_str.split(',').map(Number).filter(id => !isNaN(id)) : []
};
} else {
return undefined;
}
} catch (err: any) { } catch (err: any) {
console.error('查找快捷指令时出错:', err.message); console.error('查找快捷指令(带标签)时出错:', err.message);
throw new Error('无法查找快捷指令'); throw new Error('无法查找快捷指令');
} }
}; };
@@ -0,0 +1,118 @@
import * as QuickCommandTagRepository from '../repositories/quick-command-tag.repository';
import { QuickCommandTag } from '../repositories/quick-command-tag.repository';
/**
* 获取所有快捷指令标签
*/
export const getAllQuickCommandTags = async (): Promise<QuickCommandTag[]> => {
return QuickCommandTagRepository.findAllQuickCommandTags();
};
/**
* 根据 ID 获取单个快捷指令标签
*/
export const getQuickCommandTagById = async (id: number): Promise<QuickCommandTag | null> => {
return QuickCommandTagRepository.findQuickCommandTagById(id);
};
/**
* 添加新的快捷指令标签
* @param name 标签名称
* @returns 返回新标签的 ID
*/
export const addQuickCommandTag = async (name: string): Promise<number> => {
if (!name || name.trim().length === 0) {
throw new Error('标签名称不能为空');
}
const trimmedName = name.trim();
// 可以在这里添加更多验证逻辑,例如检查名称格式等
try {
const newId = await QuickCommandTagRepository.createQuickCommandTag(trimmedName);
return newId;
} catch (error: any) {
// Service 层可以重新抛出或处理 Repository 抛出的错误
console.error(`[Service] 添加快捷指令标签 "${trimmedName}" 失败:`, error.message);
throw error; // 重新抛出,让 Controller 处理 HTTP 响应
}
};
/**
* 更新快捷指令标签
* @param id 标签 ID
* @param name 新的标签名称
* @returns 返回是否成功更新
*/
export const updateQuickCommandTag = async (id: number, name: string): Promise<boolean> => {
if (!name || name.trim().length === 0) {
throw new Error('标签名称不能为空');
}
const trimmedName = name.trim();
// 可以在这里添加更多验证逻辑
try {
const success = await QuickCommandTagRepository.updateQuickCommandTag(id, trimmedName);
if (!success) {
// 可能需要检查标签是否存在,或者让 Repository 处理
console.warn(`[Service] 尝试更新不存在的快捷指令标签 ID: ${id}`);
}
return success;
} catch (error: any) {
console.error(`[Service] 更新快捷指令标签 ${id} 失败:`, error.message);
throw error;
}
};
/**
* 删除快捷指令标签
* @param id 标签 ID
* @returns 返回是否成功删除
*/
export const deleteQuickCommandTag = async (id: number): Promise<boolean> => {
try {
const success = await QuickCommandTagRepository.deleteQuickCommandTag(id);
if (!success) {
console.warn(`[Service] 尝试删除不存在的快捷指令标签 ID: ${id}`);
}
return success;
} catch (error: any) {
console.error(`[Service] 删除快捷指令标签 ${id} 失败:`, error.message);
throw error;
}
};
/**
* 设置指定快捷指令的标签关联
* @param commandId 快捷指令 ID
* @param tagIds 新的快捷指令标签 ID 数组
* @returns Promise<void>
*/
export const setCommandTags = async (commandId: number, tagIds: number[]): Promise<void> => {
// 验证 tagIds 是否为数字数组 (基本验证)
if (!Array.isArray(tagIds) || !tagIds.every(id => typeof id === 'number')) {
throw new Error('标签 ID 列表必须是一个数字数组');
}
// 可以在这里添加更复杂的验证,例如检查 tagIds 是否都存在于 quick_command_tags 表中
// 但 Repository 中的 setCommandTagAssociations 已包含基本的检查和错误处理
try {
// 直接调用 Repository 处理关联更新 (Repository 函数现在返回 void)
await QuickCommandTagRepository.setCommandTagAssociations(commandId, tagIds);
// Service 函数也返回 void,所以不需要 return
} catch (error: any) {
console.error(`[Service] 设置快捷指令 ${commandId} 的标签失败:`, error.message);
throw error;
}
};
/**
* 获取指定快捷指令的所有标签
* @param commandId 快捷指令 ID
* @returns 标签对象数组
*/
export const getTagsForCommand = async (commandId: number): Promise<QuickCommandTag[]> => {
try {
return await QuickCommandTagRepository.findTagsByCommandId(commandId);
} catch (error: any) {
console.error(`[Service] 获取快捷指令 ${commandId} 的标签失败:`, error.message);
throw error;
}
};
@@ -1,5 +1,6 @@
import * as QuickCommandsRepository from '../repositories/quick-commands.repository'; import * as QuickCommandsRepository from '../repositories/quick-commands.repository';
import { QuickCommand } from '../repositories/quick-commands.repository'; import { QuickCommandWithTags } from '../repositories/quick-commands.repository'; // Import the type with tags
import * as QuickCommandTagRepository from '../repositories/quick-command-tag.repository'; // Import the new tag repository
// 定义排序类型 // 定义排序类型
export type QuickCommandSortBy = 'name' | 'usage_count'; export type QuickCommandSortBy = 'name' | 'usage_count';
@@ -8,15 +9,28 @@ export type QuickCommandSortBy = 'name' | 'usage_count';
* 添加快捷指令 * 添加快捷指令
* @param name - 指令名称 (可选) * @param name - 指令名称 (可选)
* @param command - 指令内容 * @param command - 指令内容
* @param tagIds - 关联的快捷指令标签 ID 数组 (可选)
* @returns 返回添加记录的 ID * @returns 返回添加记录的 ID
*/ */
export const addQuickCommand = async (name: string | null, command: string): Promise<number> => { export const addQuickCommand = async (name: string | null, command: string, tagIds?: number[]): Promise<number> => {
if (!command || command.trim().length === 0) { if (!command || command.trim().length === 0) {
throw new Error('指令内容不能为空'); throw new Error('指令内容不能为空');
} }
// 如果 name 是空字符串,则视为 null // 如果 name 是空字符串,则视为 null
const finalName = name && name.trim().length > 0 ? name.trim() : null; const finalName = name && name.trim().length > 0 ? name.trim() : null;
return QuickCommandsRepository.addQuickCommand(finalName, command.trim()); const commandId = await QuickCommandsRepository.addQuickCommand(finalName, command.trim());
// 添加成功后,设置标签关联
if (commandId > 0 && tagIds && Array.isArray(tagIds)) {
try {
await QuickCommandTagRepository.setCommandTagAssociations(commandId, tagIds);
} catch (tagError: any) {
// 如果标签关联失败,可以选择记录警告或回滚(但通常不回滚主记录)
console.warn(`[Service] 添加快捷指令 ${commandId} 成功,但设置标签关联失败:`, tagError.message);
// 可以考虑是否需要通知用户部分操作失败
}
}
return commandId;
}; };
/** /**
@@ -24,15 +38,27 @@ export const addQuickCommand = async (name: string | null, command: string): Pro
* @param id - 要更新的记录 ID * @param id - 要更新的记录 ID
* @param name - 新的指令名称 (可选) * @param name - 新的指令名称 (可选)
* @param command - 新的指令内容 * @param command - 新的指令内容
* @param tagIds - 新的关联标签 ID 数组 (可选, undefined 表示不更新标签)
* @returns 返回是否成功更新 (更新行数 > 0) * @returns 返回是否成功更新 (更新行数 > 0)
*/ */
export const updateQuickCommand = async (id: number, name: string | null, command: string): Promise<boolean> => { export const updateQuickCommand = async (id: number, name: string | null, command: string, tagIds?: number[]): Promise<boolean> => {
if (!command || command.trim().length === 0) { if (!command || command.trim().length === 0) {
throw new Error('指令内容不能为空'); throw new Error('指令内容不能为空');
} }
const finalName = name && name.trim().length > 0 ? name.trim() : null; const finalName = name && name.trim().length > 0 ? name.trim() : null;
const changes = await QuickCommandsRepository.updateQuickCommand(id, finalName, command.trim()); const commandUpdated = await QuickCommandsRepository.updateQuickCommand(id, finalName, command.trim());
return changes;
// 如果指令更新成功,并且提供了 tagIds (即使是空数组也表示要更新),则更新标签关联
if (commandUpdated && typeof tagIds !== 'undefined') {
try {
await QuickCommandTagRepository.setCommandTagAssociations(id, tagIds);
} catch (tagError: any) {
console.warn(`[Service] 更新快捷指令 ${id} 成功,但更新标签关联失败:`, tagError.message);
// 即使标签更新失败,主记录已更新,通常返回 true
}
}
// 返回主记录是否更新成功
return commandUpdated;
}; };
/** /**
@@ -48,9 +74,10 @@ export const deleteQuickCommand = async (id: number): Promise<boolean> => {
/** /**
* 获取所有快捷指令,并按指定方式排序 * 获取所有快捷指令,并按指定方式排序
* @param sortBy - 排序字段 ('name' 或 'usage_count') * @param sortBy - 排序字段 ('name' 或 'usage_count')
* @returns 返回排序后的快捷指令数组 * @returns 返回排序后的快捷指令数组 (包含 tagIds)
*/ */
export const getAllQuickCommands = async (sortBy: QuickCommandSortBy = 'name'): Promise<QuickCommand[]> => { export const getAllQuickCommands = async (sortBy: QuickCommandSortBy = 'name'): Promise<QuickCommandWithTags[]> => {
// Repository 已返回带 tagIds 的数据
return QuickCommandsRepository.getAllQuickCommands(sortBy); return QuickCommandsRepository.getAllQuickCommands(sortBy);
}; };
@@ -67,8 +94,38 @@ export const incrementUsageCount = async (id: number): Promise<boolean> => {
/** /**
* 根据 ID 获取单个快捷指令 (可能用于编辑) * 根据 ID 获取单个快捷指令 (可能用于编辑)
* @param id - 记录 ID * @param id - 记录 ID
* @returns 返回找到的快捷指令,或 undefined * @returns 返回找到的快捷指令 (包含 tagIds),或 undefined
*/ */
export const getQuickCommandById = async (id: number): Promise<QuickCommand | undefined> => { export const getQuickCommandById = async (id: number): Promise<QuickCommandWithTags | undefined> => {
// Repository 已返回带 tagIds 的数据
return QuickCommandsRepository.findQuickCommandById(id); return QuickCommandsRepository.findQuickCommandById(id);
}; };
/**
* 将单个标签批量关联到多个快捷指令
* @param commandIds - 需要添加标签的快捷指令 ID 数组
* @param tagId - 要添加的标签 ID
* @returns Promise<void>
*/
export const assignTagToCommands = async (commandIds: number[], tagId: number): Promise<void> => {
try {
// 基本验证
if (!Array.isArray(commandIds) || commandIds.some(id => typeof id !== 'number' || isNaN(id))) {
throw new Error('无效的指令 ID 列表');
}
if (typeof tagId !== 'number' || isNaN(tagId)) {
throw new Error('无效的标签 ID');
}
// 调用 Repository 函数执行批量关联
// 注意:这里需要导入 QuickCommandTagRepository
console.log(`[Service] assignTagToCommands: Calling repo with commandIds: ${JSON.stringify(commandIds)}, tagId: ${tagId}`); // +++ 添加日志 +++
await QuickCommandTagRepository.addTagToCommands(commandIds, tagId);
console.log(`[Service] assignTagToCommands: Repo call finished for tag ${tagId}.`); // +++ 修改日志 +++
// 可以在这里添加额外的业务逻辑,例如发送事件通知等
} catch (error: any) {
console.error(`[Service] assignTagToCommands: 批量关联标签 ${tagId} 到指令时出错:`, error.message);
// 向上抛出错误,让 Controller 处理 HTTP 响应
throw error;
}
};
@@ -275,6 +275,35 @@ const handleSubmit = async () => {
} }
}; };
// --- Tag Creation/Deletion Handling ---
const handleCreateTag = async (tagName: string) => {
console.log(`[ConnForm] Received create-tag event for: ${tagName}`); // +++ 添加日志 +++
if (!tagName || tagName.trim().length === 0) return;
console.log(`[ConnForm] Calling tagsStore.addTag...`); // +++ 添加日志 +++
const newTag = await tagsStore.addTag(tagName.trim()); // Use the correct store
if (newTag && !formData.tag_ids.includes(newTag.id)) {
console.log(`[ConnForm] New tag created (ID: ${newTag.id}), adding to selection.`); // +++ 添加日志 +++
// Add the new tag's ID to the selected list
formData.tag_ids.push(newTag.id);
}
};
const handleDeleteTag = async (tagId: number) => {
const tagToDelete = tags.value.find(t => t.id === tagId);
if (!tagToDelete) return;
if (confirm(t('tags.prompts.confirmDelete', { name: tagToDelete.name }))) {
const success = await tagsStore.deleteTag(tagId); // Use the correct store
if (success) {
// TagInput's modelValue will update automatically via watch
// No need to manually remove from formData.tag_ids here
} else {
// Optional: Show error notification if deletion fails
alert(t('tags.errorDelete', { error: tagsStore.error || '未知错误' }));
}
}
};
// 处理测试连接 // 处理测试连接
const handleTestConnection = async () => { const handleTestConnection = async () => {
testStatus.value = 'testing'; testStatus.value = 'testing';
@@ -487,9 +516,19 @@ const testButtonText = computed(() => {
<div> <div>
<label class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.tags') }} ({{ t('connections.form.optional') }})</label> <label class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.tags') }} ({{ t('connections.form.optional') }})</label>
<TagInput v-model="formData.tag_ids" /> <TagInput
v-model="formData.tag_ids"
:available-tags="tags"
:allow-create="true"
:allow-delete="true"
@create-tag="handleCreateTag"
@delete-tag="handleDeleteTag"
:placeholder="t('tags.inputPlaceholder', '添加或选择标签...')"
/>
<div v-if="isTagLoading" class="mt-1 text-xs text-text-secondary">{{ t('tags.loading') }}</div>
<div v-if="tagStoreError" class="mt-1 text-xs text-error">{{ t('tags.error', { error: tagStoreError }) }}</div>
</div> </div>
</div> </div>
<!-- Error message --> <!-- Error message -->
<div v-if="formError || storeError" class="text-error bg-error/10 border border-error/30 rounded-md p-3 text-sm text-center font-medium"> <div v-if="formError || storeError" class="text-error bg-error/10 border border-error/30 rounded-md p-3 text-sm text-center font-medium">
@@ -25,6 +25,23 @@
></textarea> ></textarea>
<small v-if="commandError" class="text-error text-xs mt-1 block">{{ commandError }}</small> <small v-if="commandError" class="text-error text-xs mt-1 block">{{ commandError }}</small>
</div> </div>
<!-- +++ Tag Input Section +++ -->
<div>
<label for="qc-tags" class="block mb-1.5 text-sm font-medium text-text-secondary">{{ t('quickCommands.form.tags', '标签:') }}</label>
<TagInput
id="qc-tags"
v-model="formData.tagIds"
:available-tags="quickCommandTagsStore.tags"
:placeholder="t('quickCommands.form.tagsPlaceholder', '添加或选择标签...')"
@create-tag="handleCreateTag"
:allow-create="true"
:allow-delete="true"
@delete-tag="handleDeleteTag"
class="w-full"
/>
<!-- Add styling/classes as needed for TagInput -->
</div>
<!-- +++ End Tag Input Section +++ -->
<div class="flex justify-end mt-8 pt-4 border-t border-border/50"> <div class="flex justify-end mt-8 pt-4 border-t border-border/50">
<!-- Secondary/Cancel Button --> <!-- Secondary/Cancel Button -->
<button type="button" @click="closeForm" class="py-2 px-5 rounded-lg text-sm font-medium transition-colors duration-150 bg-background border border-border/50 text-text-secondary hover:bg-border hover:text-foreground mr-3">{{ t('common.cancel', '取消') }}</button> <button type="button" @click="closeForm" class="py-2 px-5 rounded-lg text-sm font-medium transition-colors duration-150 bg-background border border-border/50 text-text-secondary hover:bg-border hover:text-foreground mr-3">{{ t('common.cancel', '取消') }}</button>
@@ -42,22 +59,26 @@
import { ref, reactive, computed, watch, onMounted } from 'vue'; import { ref, reactive, computed, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useQuickCommandsStore, type QuickCommandFE } from '../stores/quickCommands.store'; import { useQuickCommandsStore, type QuickCommandFE } from '../stores/quickCommands.store';
import { useQuickCommandTagsStore } from '../stores/quickCommandTags.store'; // +++ Import new tag store +++
import TagInput from './TagInput.vue'; // +++ Import TagInput component (assuming it exists) +++
const props = defineProps<{ const props = defineProps<{
commandToEdit?: QuickCommandFE | null; // commandToEdit?: QuickCommandFE | null; // (should include tagIds)
}>(); }>();
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
const { t } = useI18n(); const { t } = useI18n();
const quickCommandsStore = useQuickCommandsStore(); const quickCommandsStore = useQuickCommandsStore();
const quickCommandTagsStore = useQuickCommandTagsStore(); // +++ Instantiate tag store +++
const isSubmitting = ref(false); const isSubmitting = ref(false);
const isEditing = computed(() => !!props.commandToEdit); const isEditing = computed(() => !!props.commandToEdit);
const formData = reactive({ const formData = reactive({
name: '', name: '',
command: '', command: '',
tagIds: [] as number[], // +++ Add tagIds +++
}); });
const commandError = ref<string | null>(null); const commandError = ref<string | null>(null);
@@ -76,9 +97,54 @@ onMounted(() => {
if (isEditing.value && props.commandToEdit) { if (isEditing.value && props.commandToEdit) {
formData.name = props.commandToEdit.name ?? ''; formData.name = props.commandToEdit.name ?? '';
formData.command = props.commandToEdit.command; formData.command = props.commandToEdit.command;
// Initialize tagIds if editing
formData.tagIds = props.commandToEdit.tagIds ? [...props.commandToEdit.tagIds] : [];
} }
// Fetch tags if not already loaded (optional, might be better in parent)
// if (quickCommandTagsStore.tags.length === 0) {
// quickCommandTagsStore.fetchTags();
// }
}); });
// --- Tag Creation Handling ---
// Assuming TagInput emits 'create-tag' with the tag name
const handleCreateTag = async (tagName: string) => {
console.log(`[QuickCmdForm] Received create-tag event for: ${tagName}`); // +++ +++
if (!tagName || tagName.trim().length === 0) return;
console.log(`[QuickCmdForm] Calling quickCommandTagsStore.addTag...`); // +++ +++
const newTag = await quickCommandTagsStore.addTag(tagName.trim());
if (newTag && !formData.tagIds.includes(newTag.id)) {
console.log(`[QuickCmdForm] New tag created (ID: ${newTag.id}), adding to selection.`); // +++ +++
// Add the new tag's ID to the selected list
formData.tagIds.push(newTag.id);
}
};
// --- Tag Deletion Handling ---
const handleDeleteTag = async (tagId: number) => {
console.log(`[QuickCmdForm] Received delete-tag event for ID: ${tagId}`); // +++ +++
const tagToDelete = quickCommandTagsStore.tags.find(t => t.id === tagId);
if (!tagToDelete) return;
if (confirm(t('tags.prompts.confirmDelete', { name: tagToDelete.name }))) {
console.log(`[QuickCmdForm] Calling quickCommandTagsStore.deleteTag...`); // +++ +++
const success = await quickCommandTagsStore.deleteTag(tagId);
if (success) {
// If deletion is successful, TagInput's availableTags will update,
// and the tag should disappear from the input.
// We also need to remove it from the local formData.tagIds if it was selected.
const index = formData.tagIds.indexOf(tagId);
if (index > -1) {
console.log(`[QuickCmdForm] Removing deleted tag ID ${tagId} from selection.`); // +++ +++
formData.tagIds.splice(index, 1);
}
} else {
// Optional: Show error notification if deletion fails
alert(t('tags.errorDelete', { error: quickCommandTagsStore.error || '未知错误' }));
}
}
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (commandError.value) return; // if (commandError.value) return; //
@@ -89,9 +155,11 @@ const handleSubmit = async () => {
const finalName = formData.name.trim().length > 0 ? formData.name.trim() : null; const finalName = formData.name.trim().length > 0 ? formData.name.trim() : null;
if (isEditing.value && props.commandToEdit) { if (isEditing.value && props.commandToEdit) {
success = await quickCommandsStore.updateQuickCommand(props.commandToEdit.id, finalName, formData.command.trim()); // Pass tagIds to update action
success = await quickCommandsStore.updateQuickCommand(props.commandToEdit.id, finalName, formData.command.trim(), formData.tagIds);
} else { } else {
success = await quickCommandsStore.addQuickCommand(finalName, formData.command.trim()); // Pass tagIds to add action
success = await quickCommandsStore.addQuickCommand(finalName, formData.command.trim(), formData.tagIds);
} }
isSubmitting.value = false; isSubmitting.value = false;
+95 -94
View File
@@ -1,24 +1,40 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'; import { ref, computed, watch, nextTick } from 'vue';
import { storeToRefs } from 'pinia'; // import { storeToRefs } from 'pinia'; // No longer needed directly
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useTagsStore, TagInfo } from '../stores/tags.store'; // import { useTagsStore, TagInfo } from '../stores/tags.store'; // REMOVE dependency on specific store
// Define a generic tag structure for the prop
interface GenericTag {
id: number;
name: string;
}
const props = defineProps<{ const props = defineProps<{
modelValue: number[]; // tag_ids modelValue: number[]; // tag_ids
availableTags?: GenericTag[]; // Optional: The list of tags to choose from/display
placeholder?: string; // Optional: Placeholder for the input
allowCreate?: boolean; // Optional: Allow creating new tags via Enter (default true)
allowDelete?: boolean; // Optional: Allow showing the global delete button (default true)
}>(); }>();
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue', 'create-tag', 'delete-tag']);
const { t } = useI18n(); const { t } = useI18n();
const tagsStore = useTagsStore(); // const tagsStore = useTagsStore(); // REMOVE
const { tags, isLoading, error } = storeToRefs(tagsStore); // const { tags, isLoading, error } = storeToRefs(tagsStore); // REMOVE
const inputValue = ref(''); // const inputValue = ref(''); //
const inputRef = ref<HTMLInputElement | null>(null); // const inputRef = ref<HTMLInputElement | null>(null); //
const showSuggestions = ref(false); // const showSuggestions = ref(false); //
const selectedTagIds = ref<number[]>([]); // tag_ids const selectedTagIds = ref<number[]>([]); // tag_ids
// Default values for props
const availableTags = computed(() => props.availableTags ?? []);
const placeholder = computed(() => props.placeholder ?? t('tags.inputPlaceholder', '添加或选择标签...'));
const allowCreate = computed(() => props.allowCreate !== false); // Default true
const allowDelete = computed(() => props.allowDelete !== false); // Default true
// props.modelValue selectedTagIds // props.modelValue selectedTagIds
watch(() => props.modelValue, (newVal) => { watch(() => props.modelValue, (newVal) => {
// //
@@ -37,66 +53,59 @@ watch(selectedTagIds, (newVal) => {
// Map便 ID // Map便 ID
// Use availableTags prop for the map
const tagsMap = computed(() => { const tagsMap = computed(() => {
const map = new Map<number, TagInfo>(); const map = new Map<number, GenericTag>();
tags.value.forEach(tag => map.set(tag.id, tag)); availableTags.value.forEach(tag => map.set(tag.id, tag));
return map; return map;
}); });
// //
const selectedTags = computed(() => { const selectedTags = computed(() => {
// map () // map ()
return selectedTagIds.value return selectedTagIds.value
.map(id => tagsMap.value.get(id)) .map(id => tagsMap.value.get(id)) // Get from the map based on prop
.filter((tag): tag is TagInfo => tag !== undefined); .filter((tag): tag is GenericTag => tag !== undefined);
}); });
// // (based on availableTags)
const suggestions = computed(() => { const suggestions = computed(() => {
if (!showSuggestions.value) { // if (!showSuggestions.value) { //
return []; return [];
} }
let result: TagInfo[]; let result: GenericTag[]; // Use GenericTag type
// // Use availableTags from prop
const currentAvailableTags = availableTags.value;
//
if (!inputValue.value) { if (!inputValue.value) {
result = tags.value.filter(tag => !selectedTagIds.value.includes(tag.id)); result = currentAvailableTags.filter(tag => !selectedTagIds.value.includes(tag.id));
} else { } else {
const lowerCaseInput = inputValue.value.toLowerCase(); const lowerCaseInput = inputValue.value.toLowerCase();
result = tags.value.filter(tag => result = currentAvailableTags.filter(tag =>
tag.name.toLowerCase().includes(lowerCaseInput) && tag.name.toLowerCase().includes(lowerCaseInput) &&
!selectedTagIds.value.includes(tag.id) // !selectedTagIds.value.includes(tag.id) //
); );
} }
return result; return result;
}); });
// // ( fetch, availableTags )
const handleFocus = async () => { const handleFocus = () => {
showSuggestions.value = false; // false // ( showSuggestions ref)
// 1. let potentialSuggestions: GenericTag[];
await tagsStore.fetchTags(); const currentInput = inputValue.value;
const currentAvailableTags = availableTags.value.filter(tag => !selectedTagIds.value.includes(tag.id));
// 2. if (!currentInput) {
// ( computed 'suggestions' showSuggestions.value) potentialSuggestions = currentAvailableTags;
let potentialSuggestions: TagInfo[]; } else {
const currentInput = inputValue.value; // const lowerCaseInput = currentInput.toLowerCase();
// potentialSuggestions = currentAvailableTags.filter(tag =>
const availableTags = tags.value.filter(tag => !selectedTagIds.value.includes(tag.id)); tag.name.toLowerCase().includes(lowerCaseInput)
);
if (!currentInput) { }
// //
potentialSuggestions = availableTags; showSuggestions.value = potentialSuggestions.length > 0;
} else {
//
const lowerCaseInput = currentInput.toLowerCase();
potentialSuggestions = availableTags.filter(tag =>
tag.name.toLowerCase().includes(lowerCaseInput)
);
}
// 3.
const shouldShow = potentialSuggestions.length > 0;
showSuggestions.value = shouldShow; //
}; };
// //
@@ -111,23 +120,18 @@ const handleKeyDown = async (event: KeyboardEvent) => {
event.preventDefault(); // event.preventDefault(); //
const trimmedInput = inputValue.value.trim(); const trimmedInput = inputValue.value.trim();
const lowerCaseInput = trimmedInput.toLowerCase(); const lowerCaseInput = trimmedInput.toLowerCase();
const existingTag = tags.value.find(tag => tag.name.toLowerCase() === lowerCaseInput); // Check against availableTags prop
const existingTag = availableTags.value.find(tag => tag.name.toLowerCase() === lowerCaseInput);
if (existingTag && !selectedTagIds.value.includes(existingTag.id)) { if (existingTag && !selectedTagIds.value.includes(existingTag.id)) {
// //
selectTag(existingTag); selectTag(existingTag);
} else if (!existingTag) { } else if (!existingTag && allowCreate.value) { // Only create if allowed and not existing
// // emit
const success = await tagsStore.addTag(trimmedInput); console.log(`[TagInput] Emitting create-tag for: ${trimmedInput}`); // +++ +++
if (success) { emit('create-tag', trimmedInput);
// addTag fetchTags, store // availableTags prop TagInput
// DOM store // tag ID modelValue
await nextTick(); // store
const newTag = tags.value.find(tag => tag.name === trimmedInput); // ID
if (newTag) {
selectTag(newTag);
}
}
} }
inputValue.value = ''; // inputValue.value = ''; //
showSuggestions.value = false; // showSuggestions.value = false; //
@@ -137,12 +141,11 @@ const handleKeyDown = async (event: KeyboardEvent) => {
} }
}; };
// ( Enter ) // ( Enter )
const selectTag = (tag: TagInfo) => { const selectTag = (tag: GenericTag) => {
if (!selectedTagIds.value.includes(tag.id)) { if (!selectedTagIds.value.includes(tag.id)) {
// 使 .push() watch // 使 .push() watch -> emit update:modelValue
const updatedIds = [...selectedTagIds.value, tag.id]; selectedTagIds.value = [...selectedTagIds.value, tag.id];
selectedTagIds.value = updatedIds;
} }
inputValue.value = ''; // inputValue.value = ''; //
showSuggestions.value = false; // showSuggestions.value = false; //
@@ -150,27 +153,22 @@ const selectTag = (tag: TagInfo) => {
}; };
// ( 'x' Backspace) // ( 'x' Backspace)
const removeTagLocally = (tagToRemove: TagInfo) => { const removeTagLocally = (tagToRemove: GenericTag) => {
selectedTagIds.value = selectedTagIds.value.filter(id => id !== tagToRemove.id); // This will trigger the watch and emit update:modelValue
selectedTagIds.value = selectedTagIds.value.filter(id => id !== tagToRemove.id);
}; };
// ( 'x' ) - // ( 'x' ) - Emit event
const handleDeleteTagGlobally = async (tagToDelete: TagInfo) => { const handleDeleteTagGlobally = (tagToDelete: GenericTag) => {
// console.log(`[TagInput] handleDeleteTagGlobally called for tag ID: ${tagToDelete.id}, Name: ${tagToDelete.name}`); // +++ +++
if (confirm(t('tags.prompts.confirmDelete', { name: tagToDelete.name }))) { // Emit event for parent to handle deletion confirmation and API call
const success = await tagsStore.deleteTag(tagToDelete.id); console.log(`[TagInput] Emitting delete-tag with ID: ${tagToDelete.id}`); // +++ +++
if (success) { emit('delete-tag', tagToDelete.id);
// deleteTag fetchTags, store // Parent should handle confirmation, call store action, and update modelValue/availableTags
// selectedTagIds watch props.modelValue () // We might still want to remove it locally immediately for better UX,
// selectedTagIds () // but relying on parent updating modelValue is cleaner.
removeTagLocally(tagToDelete); // removeTagLocally(tagToDelete); // Optional: remove locally immediately
// }; // Remove the extra closing brace here if it exists, ensure function closes correctly
} else {
//
alert(t('tags.errorDelete', { error: tagsStore.error || '未知错误' }));
}
}
};
</script> </script>
@@ -186,13 +184,15 @@ const handleDeleteTagGlobally = async (tagToDelete: TagInfo) => {
@click.stop="removeTagLocally(tag)" @click.stop="removeTagLocally(tag)"
:title="t('tags.removeSelection')" :title="t('tags.removeSelection')"
>&times;</button> >&times;</button>
<!-- Only show delete button if allowDelete is true -->
<button <button
type="button" v-if="allowDelete"
class="ml-1 p-0 bg-transparent border-none cursor-pointer text-text-alt hover:text-error text-xs leading-none" type="button"
@click.stop="handleDeleteTagGlobally(tag)" class="ml-1 p-0 bg-transparent border-none cursor-pointer text-text-alt hover:text-error text-xs leading-none"
:title="t('tags.deleteTagGlobally')" @click.stop="handleDeleteTagGlobally(tag)"
:title="t('tags.deleteTagGlobally')"
> >
<i class="fas fa-trash-alt"></i> <i class="fas fa-trash-alt"></i>
</button> </button>
</span> </span>
</div> </div>
@@ -217,9 +217,10 @@ const handleDeleteTagGlobally = async (tagToDelete: TagInfo) => {
> >
{{ suggestion.name }} {{ suggestion.name }}
</li> </li>
</ul> </ul>
<div v-if="isLoading" class="absolute bottom-[-1.5em] left-0 text-xs text-text-secondary mt-1">{{ t('tags.loading') }}</div> <!-- Remove isLoading and error display as they are no longer managed here -->
<div v-if="error" class="absolute bottom-[-1.5em] left-0 text-xs text-error mt-1">{{ t('tags.error', { error: error }) }}</div> <!-- <div v-if="isLoading" ...></div> -->
<!-- <div v-if="error" ...></div> -->
</div> </div>
</template> </template>
@@ -118,11 +118,27 @@ const filteredAndGroupedConnections = computed(() => {
const tagMap = new Map(tags.value.map(tag => [tag.id, tag])); const tagMap = new Map(tags.value.map(tag => [tag.id, tag]));
const lowerSearchTerm = searchTerm.value.toLowerCase(); const lowerSearchTerm = searchTerm.value.toLowerCase();
// 1. // 1. (New logic: filter by connection name, host, OR tag name)
const filteredConnections = connections.value.filter(conn => { const filteredConnections = connections.value.filter(conn => {
const nameMatch = conn.name && conn.name.toLowerCase().includes(lowerSearchTerm); // Check connection name
const hostMatch = conn.host.toLowerCase().includes(lowerSearchTerm); if (conn.name && conn.name.toLowerCase().includes(lowerSearchTerm)) {
return nameMatch || hostMatch; return true;
}
// Check connection host
if (conn.host.toLowerCase().includes(lowerSearchTerm)) {
return true;
}
// Check associated tag names
if (conn.tag_ids && conn.tag_ids.length > 0) {
for (const tagId of conn.tag_ids) {
const tag = tagMap.get(tagId); // Use the existing tagMap
if (tag && tag.name.toLowerCase().includes(lowerSearchTerm)) {
return true; // Match found in tag name
}
}
}
// No match found
return false;
}); });
// 2. // 2.
+7 -1
View File
@@ -906,7 +906,13 @@
"command": "Command:", "command": "Command:",
"commandPlaceholder": "e.g., ls -alh /home/user", "commandPlaceholder": "e.g., ls -alh /home/user",
"errorCommandRequired": "Command cannot be empty", "errorCommandRequired": "Command cannot be empty",
"add": "Add" "add": "Add",
"tags": "Tags:",
"tagsPlaceholder": "Select or create tags..."
},
"untagged": "Untagged",
"tags": {
"clickToEditTag": "Click to edit tag name"
} }
}, },
"setup": { "setup": {
+6
View File
@@ -492,6 +492,8 @@
"empty": "クイックコマンドはありません。'+'ボタンをクリックして作成してください!", "empty": "クイックコマンドはありません。'+'ボタンをクリックして作成してください!",
"form": { "form": {
"add": "追加", "add": "追加",
"tags": "タグ:",
"tagsPlaceholder": "タグを選択または作成...",
"command": "コマンド:", "command": "コマンド:",
"commandPlaceholder": "例:ls -alh /home/user", "commandPlaceholder": "例:ls -alh /home/user",
"errorCommandRequired": "コマンドは空にできません", "errorCommandRequired": "コマンドは空にできません",
@@ -500,6 +502,10 @@
"titleAdd": "クイックコマンドの追加", "titleAdd": "クイックコマンドの追加",
"titleEdit": "クイックコマンドの編集" "titleEdit": "クイックコマンドの編集"
}, },
"untagged": "タグなし",
"tags": {
"clickToEditTag": "クリックしてタグ名を編集"
},
"searchPlaceholder": "名前またはコマンドを検索...", "searchPlaceholder": "名前またはコマンドを検索...",
"sortByName": "名前", "sortByName": "名前",
"sortByUsage": "使用頻度", "sortByUsage": "使用頻度",
+7 -1
View File
@@ -909,7 +909,13 @@
"command": "指令:", "command": "指令:",
"commandPlaceholder": "例如:ls -alh /home/user", "commandPlaceholder": "例如:ls -alh /home/user",
"errorCommandRequired": "指令内容不能为空", "errorCommandRequired": "指令内容不能为空",
"add": "添加" "add": "添加",
"tags": "标签:",
"tagsPlaceholder": "选择或创建标签..."
},
"untagged": "未标记",
"tags": {
"clickToEditTag": "点击编辑标签名称"
} }
}, },
"setup": { "setup": {
@@ -0,0 +1,152 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import apiClient from '../utils/apiClient';
import { useUiNotificationsStore } from './uiNotifications.store';
// 定义快捷指令标签接口 (与后端 QuickCommandTag 对应)
export interface QuickCommandTag {
id: number;
name: string;
created_at: number;
updated_at: number;
}
export const useQuickCommandTagsStore = defineStore('quickCommandTags', () => {
const tags = ref<QuickCommandTag[]>([]);
const isLoading = ref(false);
const error = ref<string | null>(null);
const uiNotificationsStore = useUiNotificationsStore();
// 获取快捷指令标签列表 (带缓存)
async function fetchTags() {
const cacheKey = 'quickCommandTagsCache';
error.value = null;
// 1. 尝试从 localStorage 加载缓存
try {
const cachedData = localStorage.getItem(cacheKey);
if (cachedData) {
console.log('[QuickCmdTagStore] Loading quick command tags from cache.');
tags.value = JSON.parse(cachedData);
isLoading.value = false;
} else {
isLoading.value = true;
}
} catch (e) {
console.error('[QuickCmdTagStore] Failed to load or parse cache:', e);
localStorage.removeItem(cacheKey);
isLoading.value = true;
}
// 2. 后台获取最新数据
isLoading.value = true;
try {
console.log('[QuickCmdTagStore] Fetching latest quick command tags from server...');
// 使用新的 API 端点
const response = await apiClient.get<QuickCommandTag[]>('/quick-command-tags');
const freshData = response.data;
const freshDataString = JSON.stringify(freshData);
// 3. 对比并更新
const currentDataString = JSON.stringify(tags.value);
if (currentDataString !== freshDataString) {
console.log('[QuickCmdTagStore] Tags data changed, updating state and cache.');
tags.value = freshData;
localStorage.setItem(cacheKey, freshDataString);
} else {
console.log('[QuickCmdTagStore] Tags data is up-to-date.');
}
error.value = null;
return true;
} catch (err: any) {
console.error('[QuickCmdTagStore] Failed to fetch tags:', err);
error.value = err.response?.data?.message || err.message || '获取快捷指令标签列表失败';
if (error.value) { // Check if error.value is not null
uiNotificationsStore.showError(error.value); // 显示错误通知
}
return false;
} finally {
isLoading.value = false;
}
}
// 添加新快捷指令标签 (添加后清除缓存)
async function addTag(name: string): Promise<QuickCommandTag | null> {
isLoading.value = true;
error.value = null;
try {
// 使用新的 API 端点
const response = await apiClient.post<{ message: string, tag: QuickCommandTag }>('/quick-command-tags', { name });
const newTag = response.data.tag;
localStorage.removeItem('quickCommandTagsCache'); // 清除缓存
await fetchTags(); // 重新获取以更新列表
uiNotificationsStore.showSuccess('快捷指令标签已添加');
return newTag;
} catch (err: any) {
console.error('[QuickCmdTagStore] Failed to add tag:', err);
error.value = err.response?.data?.message || err.message || '添加快捷指令标签失败';
if (error.value) { // Check if error.value is not null
uiNotificationsStore.showError(error.value);
}
return null;
} finally {
isLoading.value = false;
}
}
// 更新快捷指令标签
async function updateTag(id: number, name: string): Promise<boolean> {
isLoading.value = true;
error.value = null;
try {
// 使用新的 API 端点
await apiClient.put(`/quick-command-tags/${id}`, { name });
localStorage.removeItem('quickCommandTagsCache');
await fetchTags();
uiNotificationsStore.showSuccess('快捷指令标签已更新');
return true;
} catch (err: any) {
console.error('[QuickCmdTagStore] Failed to update tag:', err);
error.value = err.response?.data?.message || err.message || '更新快捷指令标签失败';
if (error.value) { // Check if error.value is not null
uiNotificationsStore.showError(error.value);
}
return false;
} finally {
isLoading.value = false;
}
}
// 删除快捷指令标签
async function deleteTag(id: number): Promise<boolean> {
isLoading.value = true;
error.value = null;
try {
// 使用新的 API 端点
await apiClient.delete(`/quick-command-tags/${id}`);
localStorage.removeItem('quickCommandTagsCache');
await fetchTags();
uiNotificationsStore.showSuccess('快捷指令标签已删除');
return true;
} catch (err: any) {
console.error('[QuickCmdTagStore] Failed to delete tag:', err);
error.value = err.response?.data?.message || err.message || '删除快捷指令标签失败';
if (error.value) { // Check if error.value is not null
uiNotificationsStore.showError(error.value);
}
return false;
} finally {
isLoading.value = false;
}
}
return {
tags,
isLoading,
error,
fetchTags,
addTag,
updateTag,
deleteTag,
};
});
@@ -1,62 +1,224 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import apiClient from '../utils/apiClient'; // 使用统一的 apiClient import apiClient from '../utils/apiClient'; // 使用统一的 apiClient
import { ref, computed } from 'vue'; import { ref, computed, watch } from 'vue'; // Import watch
import { useUiNotificationsStore } from './uiNotifications.store'; import { useUiNotificationsStore } from './uiNotifications.store';
import type { QuickCommand } from '../types/quick-commands.types'; // 引入本地 QuickCommand 类型 import { useQuickCommandTagsStore, type QuickCommandTag } from './quickCommandTags.store'; // +++ Import new tag store +++
import { useI18n } from 'vue-i18n'; // +++ Import i18n for "Untagged" +++
// Assuming QuickCommand type in types includes tagIds now, or define it here
// import type { QuickCommand } from '../types/quick-commands.types';
// 定义前端使用的快捷指令接口 (可以与后端一致) // 定义前端使用的快捷指令接口 (包含 tagIds)
export type QuickCommandFE = QuickCommand; export interface QuickCommandFE { // Renamed from QuickCommand if necessary
id: number;
name: string | null;
command: string;
usage_count: number;
created_at: number;
updated_at: number;
tagIds: number[]; // +++ Add tagIds +++
}
// 定义排序类型 // 定义排序类型
export type QuickCommandSortByType = 'name' | 'usage_count'; export type QuickCommandSortByType = 'name' | 'usage_count';
// 定义分组后的数据结构
export interface GroupedQuickCommands {
groupName: string;
tagId: number | null; // null for "Untagged" group
commands: QuickCommandFE[];
}
// +++ localStorage key for expanded groups +++
const EXPANDED_GROUPS_STORAGE_KEY = 'quickCommandsExpandedGroups';
export const useQuickCommandsStore = defineStore('quickCommands', () => { export const useQuickCommandsStore = defineStore('quickCommands', () => {
const quickCommandsList = ref<QuickCommandFE[]>([]); const quickCommandsList = ref<QuickCommandFE[]>([]); // Should now contain QuickCommandFE with tagIds
const searchTerm = ref(''); const searchTerm = ref('');
const sortBy = ref<QuickCommandSortByType>('name'); // 默认按名称排序 const sortBy = ref<QuickCommandSortByType>('name'); // 默认按名称排序
const isLoading = ref(false); const isLoading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const uiNotificationsStore = useUiNotificationsStore(); const uiNotificationsStore = useUiNotificationsStore();
const selectedIndex = ref<number>(-1); // NEW: Index of the selected command in the filtered list const quickCommandTagsStore = useQuickCommandTagsStore(); // +++ Inject new tag store +++
const { t } = useI18n(); // +++ For "Untagged" translation +++
const selectedIndex = ref<number>(-1); // Index in the flatVisibleCommands list
// +++ State for expanded groups +++
const expandedGroups = ref<Record<string, boolean>>({});
// --- Getters --- // --- Getters ---
// 计算属性:根据搜索词过滤和排序指令 // +++ 重写 Getter: 过滤、分组、排序指令 +++
const filteredAndSortedCommands = computed(() => { const filteredAndGroupedCommands = computed((): GroupedQuickCommands[] => {
const term = searchTerm.value.toLowerCase().trim(); const term = searchTerm.value.toLowerCase().trim();
let filtered = quickCommandsList.value; const allTags = quickCommandTagsStore.tags; // 获取快捷指令专属标签
const tagMap = new Map(allTags.map(tag => [tag.id, tag.name]));
const untaggedGroupName = t('quickCommands.untagged', '未标记'); // 获取 "未标记" 的翻译
// 1. 过滤 (New logic: filter by command name, command content, OR tag name)
let filtered = quickCommandsList.value;
if (term) { if (term) {
filtered = filtered.filter(cmd => filtered = filtered.filter(cmd => {
(cmd.name && cmd.name.toLowerCase().includes(term)) || // Check command name
cmd.command.toLowerCase().includes(term) if (cmd.name && cmd.name.toLowerCase().includes(term)) {
); return true;
}
// Check command content
if (cmd.command.toLowerCase().includes(term)) {
return true;
}
// Check associated tag names
if (cmd.tagIds && cmd.tagIds.length > 0) {
for (const tagId of cmd.tagIds) {
const tagName = tagMap.get(tagId);
if (tagName && tagName.toLowerCase().includes(term)) {
return true; // Match found in tag name
}
}
}
// No match found
return false;
});
} }
// Pinia store getter 中直接排序可能不是最佳实践,但这里为了简单起见先这样实现 // 2. 分组
// 更好的方式可能是在 fetch 时就按需排序,或者在组件层排序 const groups: Record<string, { commands: QuickCommandFE[], tagId: number | null }> = {};
// 注意:这里直接修改 ref 数组的顺序,如果需要在多处使用不同排序,需要创建副本 const untaggedCommands: QuickCommandFE[] = [];
// return [...filtered].sort((a, b) => {
// if (sortBy.value === 'usage_count') { filtered.forEach(cmd => {
// // 按使用次数降序,次数相同按名称升序 let isTagged = false;
// if (b.usage_count !== a.usage_count) { if (cmd.tagIds && cmd.tagIds.length > 0) {
// return b.usage_count - a.usage_count; cmd.tagIds.forEach(tagId => {
// } const tagName = tagMap.get(tagId);
// } if (tagName) {
// // 默认或次数相同时按名称升序 (null 名称排在前面) if (!groups[tagName]) {
// const nameA = a.name ?? ''; groups[tagName] = { commands: [], tagId: tagId };
// const nameB = b.name ?? ''; // 初始化展开状态 (如果未定义,默认为 true)
// return nameA.localeCompare(nameB); if (expandedGroups.value[tagName] === undefined) {
// }); expandedGroups.value[tagName] = true;
// **修正:Getter 不应修改原始数组,返回过滤后的即可,排序由 fetch 控制** }
return filtered; }
// 避免重复添加(如果一个指令有多个相同标签ID? 不太可能但做个防御)
if (!groups[tagName].commands.some(c => c.id === cmd.id)) {
groups[tagName].commands.push(cmd);
}
isTagged = true;
}
});
}
if (!isTagged) {
untaggedCommands.push(cmd);
}
});
// 3. 排序分组内指令 & 格式化输出
const sortedGroupNames = Object.keys(groups).sort((a, b) => a.localeCompare(b));
const result: GroupedQuickCommands[] = sortedGroupNames.map(groupName => {
const groupData = groups[groupName];
// 组内排序
groupData.commands.sort((a, b) => {
if (sortBy.value === 'usage_count') {
if (b.usage_count !== a.usage_count) return b.usage_count - a.usage_count;
}
const nameA = a.name ?? a.command; // Fallback to command if name is null
const nameB = b.name ?? b.command;
return nameA.localeCompare(nameB);
});
return {
groupName: groupName,
tagId: groupData.tagId,
commands: groupData.commands
};
});
// 4. 处理未标记的分组
if (untaggedCommands.length > 0) {
// 初始化展开状态 (如果未定义,默认为 true)
if (expandedGroups.value[untaggedGroupName] === undefined) {
expandedGroups.value[untaggedGroupName] = true;
}
// 组内排序
untaggedCommands.sort((a, b) => {
if (sortBy.value === 'usage_count') {
if (b.usage_count !== a.usage_count) return b.usage_count - a.usage_count;
}
const nameA = a.name ?? a.command;
const nameB = b.name ?? b.command;
return nameA.localeCompare(nameB);
});
result.push({
groupName: untaggedGroupName,
tagId: null,
commands: untaggedCommands
});
}
return result;
}); });
// +++ 新增 Getter: 获取当前可见的扁平指令列表 (用于键盘导航) +++
const flatVisibleCommands = computed((): QuickCommandFE[] => {
const flatList: QuickCommandFE[] = [];
filteredAndGroupedCommands.value.forEach(group => {
// 只添加已展开分组中的指令
if (expandedGroups.value[group.groupName]) {
flatList.push(...group.commands);
}
});
return flatList;
});
// --- Actions --- // --- Actions ---
// NEW: Action to select the next command in the filtered list // +++ Load initial expanded groups state from localStorage +++
const loadExpandedGroups = () => {
try {
const storedState = localStorage.getItem(EXPANDED_GROUPS_STORAGE_KEY);
if (storedState) {
const parsedState = JSON.parse(storedState);
if (typeof parsedState === 'object' && parsedState !== null) {
expandedGroups.value = parsedState;
console.log('[QuickCmdStore] Loaded expanded groups state from localStorage.');
return;
}
}
} catch (e) {
console.error('[QuickCmdStore] Failed to load or parse expanded groups state:', e);
localStorage.removeItem(EXPANDED_GROUPS_STORAGE_KEY);
}
// Default to empty object if no valid state found
expandedGroups.value = {};
};
// +++ Save expanded groups state to localStorage +++
const saveExpandedGroups = () => {
try {
localStorage.setItem(EXPANDED_GROUPS_STORAGE_KEY, JSON.stringify(expandedGroups.value));
} catch (e) {
console.error('[QuickCmdStore] Failed to save expanded groups state:', e);
}
};
// +++ Watch for changes and save +++
watch(expandedGroups, saveExpandedGroups, { deep: true });
// +++ Action to toggle group expansion +++
const toggleGroup = (groupName: string) => {
// Ensure the group exists in the state before toggling
if (expandedGroups.value[groupName] === undefined) {
// Default to true if toggling a group that wasn't explicitly set (e.g., newly appeared group)
expandedGroups.value[groupName] = false; // Start collapsed if toggled first time? Or true? Let's start true.
} else {
expandedGroups.value[groupName] = !expandedGroups.value[groupName];
}
// The watcher will automatically save the state
// Reset selection when a group is toggled? Maybe not necessary.
// selectedIndex.value = -1;
};
// Action to select the next command in the *visible* flat list
const selectNextCommand = () => { const selectNextCommand = () => {
const commands = filteredAndSortedCommands.value; const commands = flatVisibleCommands.value; // Use the flat visible list
if (commands.length === 0) { if (commands.length === 0) {
selectedIndex.value = -1; selectedIndex.value = -1;
return; return;
@@ -64,9 +226,9 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
selectedIndex.value = (selectedIndex.value + 1) % commands.length; selectedIndex.value = (selectedIndex.value + 1) % commands.length;
}; };
// NEW: Action to select the previous command in the filtered list // Action to select the previous command in the *visible* flat list
const selectPreviousCommand = () => { const selectPreviousCommand = () => {
const commands = filteredAndSortedCommands.value; const commands = flatVisibleCommands.value; // Use the flat visible list
if (commands.length === 0) { if (commands.length === 0) {
selectedIndex.value = -1; selectedIndex.value = -1;
return; return;
@@ -74,37 +236,48 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
selectedIndex.value = (selectedIndex.value - 1 + commands.length) % commands.length; selectedIndex.value = (selectedIndex.value - 1 + commands.length) % commands.length;
}; };
// 从后端获取快捷指令 (带缓存和排序) // 从后端获取快捷指令 (包含 tagIds,不再发送 sortBy)
const fetchQuickCommands = async () => { const fetchQuickCommands = async () => {
const cacheKey = 'quickCommandsCache'; // 简化缓存:只缓存原始列表,不再区分排序
// 将排序方式加入缓存键,确保不同排序有不同缓存 const cacheKey = 'quickCommandsListCache';
const cacheKeyWithSort = `${cacheKey}_${sortBy.value}`; error.value = null;
error.value = null; // 重置错误
// 1. 尝试从 localStorage 加载缓存 // 1. 尝试从 localStorage 加载缓存
try { try {
const cachedData = localStorage.getItem(cacheKeyWithSort); const cachedData = localStorage.getItem(cacheKey);
if (cachedData) { if (cachedData) {
console.log(`[QuickCmdStore] Loading commands from cache (sort: ${sortBy.value}).`); console.log(`[QuickCmdStore] Loading commands from cache.`);
quickCommandsList.value = JSON.parse(cachedData); // 确保解析后的数据符合 QuickCommandFE 结构 (特别是 tagIds)
isLoading.value = false; // 先显示缓存 const parsedData = JSON.parse(cachedData) as QuickCommandFE[];
// 基本验证,确保 tagIds 是数组
if (Array.isArray(parsedData) && parsedData.every(item => Array.isArray(item.tagIds))) {
quickCommandsList.value = parsedData;
isLoading.value = false;
} else {
console.warn('[QuickCmdStore] Cached data format invalid, ignoring cache.');
localStorage.removeItem(cacheKey);
isLoading.value = true;
}
} else { } else {
isLoading.value = true; // 无缓存,初始加载 isLoading.value = true;
} }
} catch (e) { } catch (e) {
console.error('[QuickCmdStore] Failed to load or parse commands cache:', e); console.error('[QuickCmdStore] Failed to load or parse commands cache:', e);
localStorage.removeItem(cacheKeyWithSort); // 解析失败则移除缓存 localStorage.removeItem(cacheKey);
isLoading.value = true; // 缓存无效,需要加载 isLoading.value = true;
} }
// 2. 后台获取最新数据 // 2. 后台获取最新数据
isLoading.value = true; // 标记正在后台获取 isLoading.value = true;
try { try {
console.log(`[QuickCmdStore] Fetching latest commands from server (sort: ${sortBy.value})...`); console.log(`[QuickCmdStore] Fetching latest commands from server...`);
const response = await apiClient.get<QuickCommandFE[]>('/quick-commands', { // 不再发送 sortBy 参数
params: { sortBy: sortBy.value } const response = await apiClient.get<QuickCommandFE[]>('/quick-commands');
}); // 确保返回的数据包含 tagIds 数组
const freshData = response.data; const freshData = response.data.map(cmd => ({
...cmd,
tagIds: Array.isArray(cmd.tagIds) ? cmd.tagIds : [] // 确保 tagIds 是数组
}));
const freshDataString = JSON.stringify(freshData); const freshDataString = JSON.stringify(freshData);
// 3. 对比并更新 // 3. 对比并更新
@@ -112,37 +285,37 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
if (currentDataString !== freshDataString) { if (currentDataString !== freshDataString) {
console.log('[QuickCmdStore] Commands data changed, updating state and cache.'); console.log('[QuickCmdStore] Commands data changed, updating state and cache.');
quickCommandsList.value = freshData; quickCommandsList.value = freshData;
localStorage.setItem(cacheKeyWithSort, freshDataString); // 更新对应排序的缓存 localStorage.setItem(cacheKey, freshDataString); // 更新缓存
} else { } else {
console.log('[QuickCmdStore] Commands data is up-to-date.'); console.log('[QuickCmdStore] Commands data is up-to-date.');
} }
error.value = null; // 清除错误 error.value = null;
} catch (err: any) { } catch (err: any) {
console.error('[QuickCmdStore] 获取快捷指令失败:', err); console.error('[QuickCmdStore] 获取快捷指令失败:', err);
error.value = err.response?.data?.message || '获取快捷指令时发生错误'; error.value = err.response?.data?.message || '获取快捷指令时发生错误';
// 保留缓存数据,仅设置错误状态 if (error.value) {
uiNotificationsStore.showError(error.value ?? '未知错误'); uiNotificationsStore.showError(error.value);
}
} finally { } finally {
isLoading.value = false; // 加载完成 isLoading.value = false;
} }
}; };
// 清除所有排序的快捷指令缓存 // 清除快捷指令列表缓存
const clearQuickCommandsCache = () => { const clearQuickCommandsCache = () => {
const cacheKeyBase = 'quickCommandsCache'; localStorage.removeItem('quickCommandsListCache');
// 移除两种排序的缓存 console.log('[QuickCmdStore] Cleared quick commands list cache.');
localStorage.removeItem(`${cacheKeyBase}_name`);
localStorage.removeItem(`${cacheKeyBase}_usage_count`);
console.log('[QuickCmdStore] Cleared all quick commands caches.');
}; };
// 添加快捷指令 (添加后清除缓存) // 添加快捷指令 (发送 tagIds)
const addQuickCommand = async (name: string | null, command: string): Promise<boolean> => { const addQuickCommand = async (name: string | null, command: string, tagIds?: number[]): Promise<boolean> => {
try { try {
await apiClient.post('/quick-commands', { name, command }); // 在请求体中包含 tagIds
clearQuickCommandsCache(); // 清除所有排序缓存 const response = await apiClient.post<{ message: string, command: QuickCommandFE }>('/quick-commands', { name, command, tagIds });
await fetchQuickCommands(); // 刷新当前排序的列表和缓存 // 后端现在返回完整的 command 对象,可以直接使用或触发刷新
clearQuickCommandsCache(); // 清除缓存
await fetchQuickCommands(); // 重新获取以确保数据同步
uiNotificationsStore.showSuccess('快捷指令已添加'); uiNotificationsStore.showSuccess('快捷指令已添加');
return true; return true;
} catch (err: any) { } catch (err: any) {
@@ -153,12 +326,14 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
} }
}; };
// 更新快捷指令 // 更新快捷指令 (发送 tagIds)
const updateQuickCommand = async (id: number, name: string | null, command: string): Promise<boolean> => { const updateQuickCommand = async (id: number, name: string | null, command: string, tagIds?: number[]): Promise<boolean> => {
try { try {
await apiClient.put(`/quick-commands/${id}`, { name, command }); // 在请求体中包含 tagIds (即使是 undefined 也要发送,让后端知道是否要更新)
clearQuickCommandsCache(); // 清除所有排序缓存 const response = await apiClient.put<{ message: string, command: QuickCommandFE }>(`/quick-commands/${id}`, { name, command, tagIds });
await fetchQuickCommands(); // 刷新当前排序的列表和缓存 // 后端现在返回完整的 command 对象
clearQuickCommandsCache(); // 清除缓存
await fetchQuickCommands(); // 重新获取以确保数据同步
uiNotificationsStore.showSuccess('快捷指令已更新'); uiNotificationsStore.showSuccess('快捷指令已更新');
return true; return true;
} catch (err: any) { } catch (err: any) {
@@ -214,12 +389,12 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
selectedIndex.value = -1; // Reset selection when search term changes selectedIndex.value = -1; // Reset selection when search term changes
}; };
// 设置排序方式重新获取数据 // 设置排序方式 (只更新本地状态,不再重新获取数据)
const setSortBy = async (newSortBy: QuickCommandSortByType) => { const setSortBy = (newSortBy: QuickCommandSortByType) => {
if (sortBy.value !== newSortBy) { if (sortBy.value !== newSortBy) {
sortBy.value = newSortBy; sortBy.value = newSortBy;
// 排序方式改变,不需要清除缓存,fetchQuickCommands 会读取对应排序的缓存或重新获取 // 排序现在由 filteredAndGroupedCommands getter 处理,无需重新 fetch
await fetchQuickCommands(); selectedIndex.value = -1; // Reset selection when sort changes
} }
}; };
@@ -236,8 +411,10 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
sortBy, sortBy,
isLoading, isLoading,
error, error,
filteredAndSortedCommands, // 使用计算属性 filteredAndGroupedCommands, // Expose the grouped data
selectedIndex, // NEW: Expose selected index flatVisibleCommands, // Expose the flat visible list for navigation logic if needed outside
selectedIndex, // Index within flatVisibleCommands
expandedGroups, // Expose expanded groups state
fetchQuickCommands, fetchQuickCommands,
addQuickCommand, addQuickCommand,
updateQuickCommand, updateQuickCommand,
@@ -245,8 +422,64 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
incrementUsage, incrementUsage,
setSearchTerm, setSearchTerm,
setSortBy, setSortBy,
selectNextCommand, // NEW: Expose action selectNextCommand,
selectPreviousCommand, // NEW: Expose action selectPreviousCommand,
resetSelection, // Ensure resetSelection is exported resetSelection,
toggleGroup, // +++ Expose toggleGroup action +++
loadExpandedGroups, // +++ Expose load action +++
// +++ Action to assign a tag to multiple commands +++
async assignCommandsToTagAction(commandIds: number[], tagId: number): Promise<boolean> {
if (!commandIds || commandIds.length === 0) {
console.warn('[Store] assignCommandsToTagAction: No command IDs provided.');
return false;
}
isLoading.value = true; // Use the store's isLoading state
error.value = null; // Use the store's error state
try {
const response = await apiClient.post('/quick-commands/bulk-assign-tag', { commandIds, tagId });
if (response.data.success) {
console.log(`[Store] Successfully assigned tag ${tagId} to ${commandIds.length} commands via API.`);
// --- Manual state update for immediate UI feedback ---
let updatedCount = 0;
commandIds.forEach(cmdId => {
const commandIndex = quickCommandsList.value.findIndex(cmd => cmd.id === cmdId);
if (commandIndex !== -1) {
const command = quickCommandsList.value[commandIndex];
// Ensure tagIds exists and add the new tagId if not already present
if (!Array.isArray(command.tagIds)) {
command.tagIds = [];
}
if (!command.tagIds.includes(tagId)) {
command.tagIds.push(tagId);
updatedCount++;
}
} else {
console.warn(`[Store] assignCommandsToTagAction: Command ID ${cmdId} not found in local list for manual update.`);
}
});
console.log(`[Store] Manually updated tagIds for ${updatedCount} commands in local state.`);
// --- End manual state update ---
// Optionally, still fetch for full consistency, but UI should update based on manual change first.
// clearQuickCommandsCache();
// await fetchQuickCommands();
return true;
} else {
// This case might not happen if backend throws errors instead
error.value = response.data.message || '批量分配标签失败 (未知)';
if (error.value) uiNotificationsStore.showError(error.value); // Check if error.value is not null
return false;
}
} catch (err: any) {
console.error('[Store] Error assigning tag to commands:', err);
error.value = err.response?.data?.message || err.message || '批量分配标签时发生网络或服务器错误';
if (error.value) uiNotificationsStore.showError(error.value); // Check if error.value is not null
return false;
} finally {
isLoading.value = false;
}
},
}; };
}); });
+285 -69
View File
@@ -26,49 +26,89 @@
</div> </div>
<!-- List Area --> <!-- List Area -->
<div class="flex-grow overflow-y-auto p-2"> <div class="flex-grow overflow-y-auto p-2">
<!-- Loading State --> <!-- Loading State (Show if loading and no groups are ready yet) -->
<!-- Loading State (Only show if loading AND no commands are displayed yet) --> <div v-if="isLoading && filteredAndGroupedCommands.length === 0" class="p-6 text-center text-text-secondary text-sm flex flex-col items-center justify-center h-full">
<div v-if="isLoading && filteredAndSortedCommands.length === 0" class="p-6 text-center text-text-secondary text-sm flex flex-col items-center justify-center h-full"> <i class="fas fa-spinner fa-spin text-xl mb-2"></i>
<i class="fas fa-spinner fa-spin text-xl mb-2"></i> <p>{{ t('common.loading', '加载中...') }}</p>
<p>{{ t('common.loading', '加载中...') }}</p>
</div> </div>
<!-- Empty State --> <!-- Empty State (Show if not loading and no groups exist) -->
<div v-else-if="filteredAndSortedCommands.length === 0" class="p-6 text-center text-text-secondary text-sm flex flex-col items-center justify-center h-full"> <div v-else-if="!isLoading && filteredAndGroupedCommands.length === 0" class="p-6 text-center text-text-secondary text-sm flex flex-col items-center justify-center h-full">
<i class="fas fa-bolt text-xl mb-2"></i> <i class="fas fa-bolt text-xl mb-2"></i>
<p class="mb-3">{{ $t('quickCommands.empty', '没有快捷指令。') }}</p> <p class="mb-3">{{ $t('quickCommands.empty', '没有快捷指令。') }}</p>
<button @click="openAddForm" class="px-4 py-2 bg-primary text-white border-none rounded-lg text-sm font-semibold cursor-pointer shadow-md transition-colors duration-200 ease-in-out hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"> <button @click="openAddForm" class="px-4 py-2 bg-primary text-white border-none rounded-lg text-sm font-semibold cursor-pointer shadow-md transition-colors duration-200 ease-in-out hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
{{ $t('quickCommands.addFirst', '创建第一个快捷指令') }} {{ $t('quickCommands.addFirst', '创建第一个快捷指令') }}
</button> </button>
</div> </div>
<!-- Command List --> <!-- Grouped Command List -->
<ul v-else class="list-none p-0 m-0" ref="commandListRef"> <div v-else class="list-none p-0 m-0" ref="commandListContainerRef"> <!-- Changed ref name -->
<li <div v-for="groupData in filteredAndGroupedCommands" :key="groupData.groupName" class="mb-1 last:mb-0">
v-for="(cmd, index) in filteredAndSortedCommands" <!-- Group Header -->
:key="cmd.id" <!-- Group Header - Modified for inline editing -->
class="group flex justify-between items-center px-3 py-2.5 mb-1 cursor-pointer rounded-md hover:bg-primary/10 transition-colors duration-150" <div
:class="{ 'bg-primary/20 font-medium': index === storeSelectedIndex }" class="group px-3 py-2 font-semibold flex items-center text-foreground rounded-md hover:bg-header/80 transition-colors duration-150"
@click="executeCommand(cmd)" :class="{ 'cursor-pointer': editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId) }"
> @click="editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId) ? toggleGroup(groupData.groupName) : null"
<!-- Command Info --> >
<div class="flex flex-col overflow-hidden mr-2 flex-grow"> <i
<span v-if="cmd.name" class="font-medium text-sm truncate mb-0.5 text-foreground">{{ cmd.name }}</span> :class="['fas', expandedGroups[groupData.groupName] ? 'fa-chevron-down' : 'fa-chevron-right', 'mr-2 w-4 text-center text-text-secondary group-hover:text-foreground transition-transform duration-200 ease-in-out', {'transform rotate-0': !expandedGroups[groupData.groupName]}]"
<span class="text-xs truncate font-mono" :class="{ 'text-sm': !cmd.name, 'text-text-secondary': true }">{{ cmd.command }}</span> @click.stop="toggleGroup(groupData.groupName)"
</div> class="cursor-pointer flex-shrink-0"
<!-- Actions (Show on Hover) --> ></i>
<div class="flex items-center flex-shrink-0 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity duration-150"> <!-- Editing State -->
<!-- Usage Count --> <input
<span class="text-xs bg-border px-1.5 py-0.5 rounded mr-2 text-text-secondary" :title="t('quickCommands.usageCount', '使用次数')">{{ cmd.usage_count }}</span> v-if="editingTagId === (groupData.tagId === null ? 'untagged' : groupData.tagId)"
<!-- Edit Button --> :key="groupData.tagId === null ? 'untagged-input' : `tag-input-${groupData.tagId}`"
<button @click.stop="openEditForm(cmd)" class="p-1.5 rounded hover:bg-black/10 transition-colors duration-150 text-text-secondary hover:text-primary" :title="$t('common.edit', '编辑')"> :ref="(el) => setTagInputRef(el, groupData.tagId === null ? 'untagged' : groupData.tagId)"
<i class="fas fa-edit text-sm"></i> type="text"
</button> v-model="editedTagName"
<!-- Delete Button --> class="text-sm bg-input border border-primary rounded px-1 py-0 w-full"
<button @click.stop="confirmDelete(cmd)" class="p-1.5 rounded hover:bg-black/10 transition-colors duration-150 text-text-secondary hover:text-error" :title="$t('common.delete', '删除')"> @blur="finishEditingTag"
<i class="fas fa-times text-sm"></i> @keydown.enter.prevent="finishEditingTag"
</button> @keydown.esc.prevent="cancelEditingTag"
</div> @click.stop
</li> />
</ul> <!-- Display State -->
<span
v-else
class="text-sm inline-block overflow-hidden text-ellipsis whitespace-nowrap flex-grow"
:class="{ 'cursor-pointer hover:underline': true }"
:title="t('quickCommands.tags.clickToEditTag', '点击编辑标签')"
@click.stop="startEditingTag(groupData.tagId, groupData.groupName)"
>
{{ groupData.groupName }}
</span>
<!-- Optional: Add count? -->
<!-- <span v-if="editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId)" class="ml-auto text-xs text-text-secondary pl-2">({{ groupData.commands.length }})</span> -->
</div>
<!-- Command Items List (only show if expanded) -->
<ul v-show="quickCommandsStore.expandedGroups[groupData.groupName]" class="list-none p-0 m-0 pl-3">
<li
v-for="(cmd) in groupData.commands"
:key="cmd.id"
:data-command-id="cmd.id"
class="group flex justify-between items-center px-3 py-2.5 mb-1 cursor-pointer rounded-md hover:bg-primary/10 transition-colors duration-150"
:class="{ 'bg-primary/20 font-medium': isCommandSelected(cmd.id) }"
@click="executeCommand(cmd)"
>
<!-- Command Info (Structure remains the same) -->
<div class="flex flex-col overflow-hidden mr-2 flex-grow">
<span v-if="cmd.name" class="font-medium text-sm truncate mb-0.5 text-foreground">{{ cmd.name }}</span>
<span class="text-xs truncate font-mono" :class="{ 'text-sm': !cmd.name, 'text-text-secondary': true }">{{ cmd.command }}</span>
</div>
<!-- Actions (Structure remains the same) -->
<div class="flex items-center flex-shrink-0 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity duration-150">
<span class="text-xs bg-border px-1.5 py-0.5 rounded mr-2 text-text-secondary" :title="t('quickCommands.usageCount', '使用次数')">{{ cmd.usage_count }}</span>
<button @click.stop="openEditForm(cmd)" class="p-1.5 rounded hover:bg-black/10 transition-colors duration-150 text-text-secondary hover:text-primary" :title="$t('common.edit', '编辑')">
<i class="fas fa-edit text-sm"></i>
</button>
<button @click.stop="confirmDelete(cmd)" class="p-1.5 rounded hover:bg-black/10 transition-colors duration-150 text-text-secondary hover:text-error" :title="$t('common.delete', '删除')">
<i class="fas fa-times text-sm"></i>
</button>
</div>
</li>
</ul>
</div>
</div>
</div> </div>
</div> </div>
@@ -84,13 +124,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed, nextTick, defineExpose, watch } from 'vue'; // Import watch import { ref, onMounted, onBeforeUnmount, computed, nextTick, defineExpose, watch } from 'vue'; // Import watch
import { storeToRefs } from 'pinia'; // Import storeToRefs import { storeToRefs } from 'pinia'; // Import storeToRefs
import { useQuickCommandsStore, type QuickCommandFE, type QuickCommandSortByType } from '../stores/quickCommands.store'; import { useQuickCommandsStore, type QuickCommandFE, type QuickCommandSortByType, type GroupedQuickCommands } from '../stores/quickCommands.store'; // Import GroupedQuickCommands
import { useQuickCommandTagsStore } from '../stores/quickCommandTags.store'; // +++ Import the new tag store +++
import { useUiNotificationsStore } from '../stores/uiNotifications.store'; import { useUiNotificationsStore } from '../stores/uiNotifications.store';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import AddEditQuickCommandForm from '../components/AddEditQuickCommandForm.vue'; // import AddEditQuickCommandForm from '../components/AddEditQuickCommandForm.vue'; //
import { useFocusSwitcherStore } from '../stores/focusSwitcher.store'; // +++ Store +++ import { useFocusSwitcherStore } from '../stores/focusSwitcher.store'; // +++ Store +++
const quickCommandsStore = useQuickCommandsStore(); const quickCommandsStore = useQuickCommandsStore();
const quickCommandTagsStore = useQuickCommandTagsStore(); // +++ Instantiate the new tag store +++
const uiNotificationsStore = useUiNotificationsStore(); // const uiNotificationsStore = useUiNotificationsStore(); //
const { t } = useI18n(); const { t } = useI18n();
const focusSwitcherStore = useFocusSwitcherStore(); // +++ Store +++ const focusSwitcherStore = useFocusSwitcherStore(); // +++ Store +++
@@ -99,16 +141,32 @@ const hoveredItemId = ref<number | null>(null);
const isFormVisible = ref(false); const isFormVisible = ref(false);
const commandToEdit = ref<QuickCommandFE | null>(null); const commandToEdit = ref<QuickCommandFE | null>(null);
// const selectedIndex = ref<number>(-1); // REMOVED: Use store's selectedIndex // const selectedIndex = ref<number>(-1); // REMOVED: Use store's selectedIndex
const commandListRef = ref<HTMLUListElement | null>(null); // Ref for the command list UL const commandListContainerRef = ref<HTMLDivElement | null>(null); // Changed ref name to match template
const searchInputRef = ref<HTMLInputElement | null>(null); // +++ Ref for the search input +++ const searchInputRef = ref<HTMLInputElement | null>(null); // +++ Ref for the search input +++
let unregisterFocus: (() => void) | null = null; // +++ +++ let unregisterFocus: (() => void) | null = null; // +++ +++
// +++ State for inline tag editing +++
const editingTagId = ref<number | null | 'untagged'>(null);
const editedTagName = ref('');
const tagInputRefs = ref(new Map<string | number, HTMLInputElement | null>());
// --- Store Getter --- // --- Store Getter ---
const searchTerm = computed(() => quickCommandsStore.searchTerm); const searchTerm = computed(() => quickCommandsStore.searchTerm);
const sortBy = computed(() => quickCommandsStore.sortBy); const sortBy = computed(() => quickCommandsStore.sortBy);
const filteredAndSortedCommands = computed(() => quickCommandsStore.filteredAndSortedCommands); // Use the new grouped getter
const filteredAndGroupedCommands = computed(() => quickCommandsStore.filteredAndGroupedCommands);
const isLoading = computed(() => quickCommandsStore.isLoading); const isLoading = computed(() => quickCommandsStore.isLoading);
const { selectedIndex: storeSelectedIndex } = storeToRefs(quickCommandsStore); // Get selectedIndex reactively // selectedIndex now refers to the index within the flatVisibleCommands list
// Also get expandedGroups reactively for the template
const { selectedIndex: storeSelectedIndex, flatVisibleCommands, expandedGroups } = storeToRefs(quickCommandsStore);
// --- Helper function for selection check ---
const isCommandSelected = (commandId: number): boolean => {
if (storeSelectedIndex.value < 0 || !flatVisibleCommands.value[storeSelectedIndex.value]) {
return false;
}
return flatVisibleCommands.value[storeSelectedIndex.value].id === commandId;
};
// --- --- // --- ---
const emit = defineEmits<{ const emit = defineEmits<{
@@ -116,10 +174,30 @@ const emit = defineEmits<{
}>(); }>();
// --- --- // --- ---
onMounted(() => { onMounted(async () => { // Make onMounted async
quickCommandsStore.fetchQuickCommands(); // // Load expanded groups state first
quickCommandsStore.loadExpandedGroups();
// Then fetch commands (which might initialize expandedGroups for new groups)
await quickCommandsStore.fetchQuickCommands();
// Also fetch the quick command tags using the correct store instance
await quickCommandTagsStore.fetchTags();
}); });
// +++ Watcher to focus input when editing starts +++
watch(editingTagId, async (newId) => {
if (newId !== null) {
await nextTick();
const inputRef = tagInputRefs.value.get(newId);
if (inputRef) {
inputRef.focus();
inputRef.select();
} else {
console.error(`[QuickCmdView] Watcher: Input ref for ID ${newId} not found.`);
}
}
});
// +++ / +++ // +++ / +++
onMounted(() => { onMounted(() => {
// +++ +++ // +++ +++
@@ -140,21 +218,25 @@ const updateSearchTerm = (event: Event) => {
// selectedIndex.value = -1; // REMOVED: Store handles resetting index // selectedIndex.value = -1; // REMOVED: Store handles resetting index
}; };
// // +++ +++
const scrollToSelected = async (index: number) => { // Accept index as argument const scrollToSelected = async (index: number) => {
await nextTick(); // DOM await nextTick(); // DOM
if (index < 0 || !commandListRef.value) return; if (index < 0 || !commandListContainerRef.value || !flatVisibleCommands.value[index]) return;
const listElement = commandListRef.value; const selectedCommandId = flatVisibleCommands.value[index].id;
const selectedItem = listElement.children[index] as HTMLLIElement; const listContainer = commandListContainerRef.value;
if (selectedItem) { // Find the element using the data attribute
// 使 scrollIntoView 使 const selectedElement = listContainer.querySelector(`li[data-command-id="${selectedCommandId}"]`) as HTMLLIElement;
selectedItem.scrollIntoView({
behavior: 'smooth', // 使 'auto' if (selectedElement) {
block: 'nearest', selectedElement.scrollIntoView({
}); behavior: 'smooth',
} block: 'nearest',
});
} else {
console.warn(`[QuickCmdView] scrollToSelected: Could not find element for command ID ${selectedCommandId}`);
}
}; };
// Watch for changes in the store's selectedIndex and scroll // Watch for changes in the store's selectedIndex and scroll
@@ -162,12 +244,13 @@ watch(storeSelectedIndex, (newIndex) => {
scrollToSelected(newIndex); scrollToSelected(newIndex);
}); });
// Renamed function to avoid conflict if needed, and added logic // Keyboard navigation now operates on the flat visible list
const handleSearchInputKeydown = (event: KeyboardEvent) => { const handleSearchInputKeydown = (event: KeyboardEvent) => {
const commands = filteredAndSortedCommands.value; // Use flatVisibleCommands for navigation logic
if (!commands.length) return; const commands = flatVisibleCommands.value;
if (!commands.length) return;
switch (event.key) { switch (event.key) {
case 'ArrowDown': case 'ArrowDown':
event.preventDefault(); event.preventDefault();
quickCommandsStore.selectNextCommand(); // Use store action quickCommandsStore.selectNextCommand(); // Use store action
@@ -193,16 +276,37 @@ const handleSearchInputBlur = () => {
setTimeout(() => { setTimeout(() => {
// //
// //
if (document.activeElement !== searchInputRef.value && !commandListRef.value?.contains(document.activeElement)) { if (document.activeElement !== searchInputRef.value && !commandListContainerRef.value?.contains(document.activeElement)) {
quickCommandsStore.resetSelection(); quickCommandsStore.resetSelection();
} }
}, 100); // }, 100); //
}; };
// // (Action remains the same, store handles the logic change)
const toggleSortBy = () => { const toggleSortBy = () => {
const newSortBy = sortBy.value === 'name' ? 'usage_count' : 'name'; const newSortBy = sortBy.value === 'name' ? 'usage_count' : 'name';
quickCommandsStore.setSortBy(newSortBy); quickCommandsStore.setSortBy(newSortBy);
};
// +++ Action to toggle group expansion +++
const toggleGroup = (groupName: string) => {
quickCommandsStore.toggleGroup(groupName);
// After toggling, selection might become invalid if the selected item is now hidden
// Reset selection or check if the selected item is still visible
nextTick(() => { // Wait for DOM update potentially caused by v-show
const selectedCmdId = storeSelectedIndex.value >= 0 && flatVisibleCommands.value[storeSelectedIndex.value]
? flatVisibleCommands.value[storeSelectedIndex.value].id
: null;
if (selectedCmdId !== null) {
const newIndex = flatVisibleCommands.value.findIndex(cmd => cmd.id === selectedCmdId);
if (newIndex === -1) { // Selected item is no longer visible
quickCommandsStore.resetSelection();
} else {
// Update index if it shifted, though usually reset is safer/simpler
// storeSelectedIndex.value = newIndex;
}
}
});
}; };
// title icon // title icon
@@ -259,5 +363,117 @@ const focusSearchInput = (): boolean => {
}; };
defineExpose({ focusSearchInput }); defineExpose({ focusSearchInput });
// +++ Methods for inline tag editing +++
const setTagInputRef = (el: any, id: string | number) => {
if (el) {
tagInputRefs.value.set(id, el as HTMLInputElement);
} else {
tagInputRefs.value.delete(id);
}
};
const startEditingTag = (tagId: number | null, currentName: string) => {
editingTagId.value = tagId === null ? 'untagged' : tagId;
editedTagName.value = tagId === null ? '' : currentName; // Clear input for "Untagged"
// Focus logic is handled by the watcher
};
const finishEditingTag = async () => {
const currentEditingId = editingTagId.value;
const newName = editedTagName.value.trim();
const originalGroup = filteredAndGroupedCommands.value.find(g => g.tagId === currentEditingId); // Find original group data
// Basic validation
if (newName === '' && currentEditingId !== 'untagged') {
cancelEditingTag();
return;
}
if (newName === '' && currentEditingId === 'untagged') {
cancelEditingTag();
return;
}
let operationSuccess = false;
try {
if (currentEditingId === 'untagged') {
// --- Create new tag and assign commands ---
console.log(`[QuickCmdView] Creating new tag: ${newName}`);
const newTag = await quickCommandTagsStore.addTag(newName);
if (newTag) {
operationSuccess = true;
uiNotificationsStore.showSuccess(t('quickCommands.tags.createSuccess')); // Use specific translation key
const untaggedGroup = filteredAndGroupedCommands.value.find(g => g.tagId === null);
const commandIdsToAssign = untaggedGroup ? untaggedGroup.commands.map(c => c.id) : [];
if (commandIdsToAssign.length > 0) {
console.log(`[QuickCmdView] Assigning ${commandIdsToAssign.length} commands to new tag ID: ${newTag.id}`);
console.log(`[QuickCmdView] Command IDs to assign: ${JSON.stringify(commandIdsToAssign)}`); // +++ +++
// Call the store action to assign commands to the new tag
const assignSuccess = await quickCommandsStore.assignCommandsToTagAction(commandIdsToAssign, newTag.id);
if (assignSuccess) {
// Success/Error Notifications and list refresh are handled within the store action
console.log(`[QuickCmdView] assignCommandsToTagAction reported success.`);
} else {
console.error(`[QuickCmdView] assignCommandsToTagAction reported failure.`);
// Optionally show a specific error here if the store action doesn't cover all cases
}
// Remove TODO and temporary warning/refresh
// console.warn("TODO: Implement assignCommandsToTagAction in quickCommands.store and backend");
// uiNotificationsStore.showWarning("");
// await quickCommandsStore.fetchQuickCommands(); // Store action handles refresh
} else {
uiNotificationsStore.showInfo(t('quickCommands.tags.noCommandsToAssign'));
}
// Update expanded group state
const untaggedGroupName = t('quickCommands.untagged', '未标记');
if (expandedGroups.value[untaggedGroupName] !== undefined) {
const currentState = expandedGroups.value[untaggedGroupName];
delete expandedGroups.value[untaggedGroupName]; // Remove old key
expandedGroups.value[newName] = currentState; // Add new key
}
}
// addTag failure handled in store
} else if (typeof currentEditingId === 'number') {
// --- Update existing tag ---
const originalTagName = originalGroup?.groupName;
if (!originalTagName) {
console.error(`[QuickCmdView] Cannot find original group name for tag ID ${currentEditingId}`);
cancelEditingTag();
return;
}
if (originalTagName === newName) {
operationSuccess = true; // No change needed
} else {
console.log(`[QuickCmdView] Updating tag ID ${currentEditingId} from "${originalTagName}" to "${newName}"`);
const updateResult = await quickCommandTagsStore.updateTag(currentEditingId, newName);
if (updateResult) {
operationSuccess = true;
// uiNotificationsStore.showSuccess(t('quickCommands.tags.updateSuccess'));
// Update expanded group state
if (expandedGroups.value[originalTagName] !== undefined) {
const currentState = expandedGroups.value[originalTagName];
delete expandedGroups.value[originalTagName];
expandedGroups.value[newName] = currentState;
}
// Refresh commands to reflect potential grouping changes if names clashed etc.
await quickCommandsStore.fetchQuickCommands();
}
// updateTag failure handled in store
}
}
} catch (error: any) {
console.error("[QuickCmdView] Error during finishEditingTag:", error);
uiNotificationsStore.showError(t('common.unexpectedError'));
} finally {
editingTagId.value = null; // Exit edit mode regardless of success
}
};
const cancelEditingTag = () => {
editingTagId.value = null;
};
</script> </script>
+106
View File
@@ -0,0 +1,106 @@
# Plan: Implement Independent Tags for Quick Commands
This plan outlines the steps to add a tagging and grouping feature for Quick Commands, ensuring the tag system is completely separate from the existing tag system used for SSH Connections.
## 1. Database Migration (`packages/backend/src/database/migrations.ts`)
* **Goal:** Add two new tables to the database schema to manage quick command specific tags and their associations.
* **Steps:**
* Analyze `migrations.ts` to understand the current migration process (Done - uses versioned migrations).
* Add a new migration (e.g., `id: 2`, `name: 'Create quick_command_tags table'`):
* SQL: `CREATE TABLE IF NOT EXISTS quick_command_tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')));`
* Include a `check` function using `tableExists(db, 'quick_command_tags')`.
* Add another new migration (e.g., `id: 3`, `name: 'Create quick_command_tag_associations table'`):
* SQL: `CREATE TABLE IF NOT EXISTS quick_command_tag_associations (quick_command_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, PRIMARY KEY (quick_command_id, tag_id), FOREIGN KEY (quick_command_id) REFERENCES quick_commands(id) ON DELETE CASCADE, FOREIGN KEY (tag_id) REFERENCES quick_command_tags(id) ON DELETE CASCADE);`
* Include a `check` function using `tableExists(db, 'quick_command_tag_associations')`.
## 2. Database Schema (`packages/backend/src/database/schema.ts`)
* **Goal:** Keep schema definitions consistent.
* **Steps:**
* Add `createQuickCommandTagsTableSQL` constant with the SQL from Migration 2.
* Add `createQuickCommandTagAssociationsTableSQL` constant with the SQL from Migration 3.
## 3. Backend (`packages/backend`)
* **Goal:** Create dedicated modules for managing quick command tags and integrate them with the existing quick command logic.
* **New Module: Quick Command Tags**
* **`repositories/quick-command-tag.repository.ts`:**
* Define `QuickCommandTag` interface (`id`, `name`, `created_at`, `updated_at`).
* Implement CRUD functions for `quick_command_tags` table (`findAll`, `findById`, `create`, `update`, `delete`).
* Implement functions to manage `quick_command_tag_associations`:
* `setCommandTagAssociations(commandId: number, tagIds: number[]): Promise<boolean>` (Transactional: delete old, insert new).
* `findTagsByCommandId(commandId: number): Promise<QuickCommandTag[]>` (JOIN `quick_command_tag_associations` and `quick_command_tags`).
* (Optional) `addTagAssociation`, `removeTagAssociation`.
* **`services/quick-command-tag.service.ts`:**
* Inject `QuickCommandTagRepository`.
* Implement business logic for managing quick command tags (validation, orchestration).
* **`quick-command-tags/quick-command-tag.controller.ts` & `quick-command-tags/quick-command-tag.routes.ts`:**
* Create new API endpoints (e.g., `POST /quick-command-tags`, `GET /quick-command-tags`, `PUT /quick-command-tags/:id`, `DELETE /quick-command-tags/:id`).
* Inject `QuickCommandTagService`.
* Handle HTTP requests/responses.
* **Modify Module: Quick Commands**
* **`repositories/quick-commands.repository.ts`:**
* Update `QuickCommand` interface (or create `QuickCommandWithTags`) to include `tagIds: number[]` (referencing `quick_command_tags.id`).
* Modify `getAllQuickCommands` and `findQuickCommandById`: Use `LEFT JOIN quick_command_tag_associations` and `GROUP_CONCAT` to fetch `tag_ids_str`, parse into `tagIds` array.
* **`services/quick-commands.service.ts`:**
* Inject **new** `QuickCommandTagRepository` or `QuickCommandTagService`.
* Modify `addQuickCommand` signature: accept `tagIds?: number[]`. Implementation: call repo `addQuickCommand`, then call **new** repo/service `setCommandTagAssociations`.
* Modify `updateQuickCommand` signature: accept `tagIds?: number[]`. Implementation: call repo `updateQuickCommand`, then call **new** repo/service `setCommandTagAssociations`.
* Modify `getAllQuickCommands` return type to include `tagIds`.
* **`quick-commands/quick-commands.controller.ts`:**
* Modify `addQuickCommand` and `updateQuickCommand` request validation to accept optional `tagIds: number[]`.
* Pass `tagIds` to the service layer.
* Ensure `getAllQuickCommands` response includes `tagIds`.
## 4. Frontend (`packages/frontend`)
* **Goal:** Create a dedicated state management for quick command tags and update the UI to display grouped commands and allow tag selection.
* **Types (`types/quick-commands.types.ts` or similar):**
* Update `QuickCommand` interface to include `tagIds: number[]`.
* **New Store (`stores/quickCommandTags.store.ts`):**
* Define `QuickCommandTag` interface (matching backend).
* State: `tags: ref<QuickCommandTag[]>`, `isLoading`, `error`.
* Actions: `fetchTags` (from `/quick-command-tags`), `addTag`, `updateTag`, `deleteTag`. Include caching logic if desired.
* **Modify Store (`stores/quickCommands.store.ts`):**
* Inject **new** `useQuickCommandTagsStore`.
* State: Add `expandedGroups: ref<Record<string, boolean>>({})`.
* Actions:
* Add `toggleGroup(groupName: string)` action (manage `expandedGroups`, potentially save to localStorage).
* Modify `fetchQuickCommands`: Fetch data including `tagIds` from `/quick-commands`. Remove `sortBy` API parameter.
* Modify `addQuickCommand`, `updateQuickCommand`: Send `tagIds` in the request body.
* Modify `incrementUsage`: Adjust logic if sorting is now purely frontend within groups.
* Getters:
* **Rewrite** `filteredAndSortedCommands` (or create `filteredAndGroupedCommands`):
* Filter `quickCommandsList` by `searchTerm`.
* Use `quickCommandTagsStore.tags` to group filtered commands by `tagIds` (create "Untagged" group).
* Sort commands *within* each group based on `sortBy` state.
* Sort groups (e.g., alphabetically).
* Return nested structure: `Array<{ groupName: string; tagId: number | null; commands: QuickCommandFE[] }>`.
* **Add** `flatVisibleCommands`: Compute a flat list of commands only from currently expanded groups.
* Keyboard Navigation Actions (`selectNextCommand`, `selectPreviousCommand`): Modify to operate on `flatVisibleCommands` and `selectedIndex`.
* Caching: Re-evaluate or simplify caching strategy.
* **Modify View (`views/QuickCommandsView.vue`):**
* `<template>`:
* Use nested `v-for` to iterate through grouped data from the store.
* Render clickable group headers with expand/collapse icons, bound to `expandedGroups` state and `toggleGroup` action.
* Render command items (`<li>`) within each group.
* `<script>`:
* Inject **new** `useQuickCommandTagsStore`.
* Bind template to new/modified store getters (`filteredAndGroupedCommands`, `expandedGroups`).
* Adjust keyboard navigation highlighting based on `flatVisibleCommands` and `selectedIndex`.
* Modify `scrollToSelected` to find the correct DOM element based on the selected command in `flatVisibleCommands`.
* **Modify Component (`components/AddEditQuickCommandForm.vue`):**
* Inject **new** `useQuickCommandTagsStore`.
* Add a tag selection UI component (e.g., multi-select dropdown, chip input using `TagInput.vue` if adaptable, or a new component).
* Populate options using `quickCommandTagsStore.tags`.
* Allow creating new tags (calling `quickCommandTagsStore.addTag`).
* Bind selected `tagIds` to form state.
* Modify form submission logic to include `tagIds` when calling `quickCommandsStore.add/updateQuickCommand`.