feat: 添加显隐标签的设置

This commit is contained in:
Baobhan Sith
2025-05-03 20:34:52 +08:00
parent c3470a5419
commit 6144633a5e
10 changed files with 592 additions and 137 deletions
@@ -31,6 +31,8 @@ const AUTO_COPY_ON_SELECT_KEY = 'autoCopyOnSelect'; // 终端选中自动复制
const STATUS_MONITOR_INTERVAL_SECONDS_KEY = 'statusMonitorIntervalSeconds'; // 状态监控间隔设置键 const STATUS_MONITOR_INTERVAL_SECONDS_KEY = 'statusMonitorIntervalSeconds'; // 状态监控间隔设置键
const DEFAULT_STATUS_MONITOR_INTERVAL_SECONDS = 3; // 默认状态监控间隔 const DEFAULT_STATUS_MONITOR_INTERVAL_SECONDS = 3; // 默认状态监控间隔
const IP_BLACKLIST_ENABLED_KEY = 'ipBlacklistEnabled'; // IP 黑名单启用设置键 const IP_BLACKLIST_ENABLED_KEY = 'ipBlacklistEnabled'; // IP 黑名单启用设置键
const SHOW_CONNECTION_TAGS_KEY = 'showConnectionTags'; // 连接标签显示设置键
const SHOW_QUICK_COMMAND_TAGS_KEY = 'showQuickCommandTags'; // 快捷指令标签显示设置键
export const settingsService = { export const settingsService = {
/** /**
@@ -479,6 +481,60 @@ export const settingsService = {
// Directly call the specific repository function with the full, validated config // Directly call the specific repository function with the full, validated config
await setCaptchaConfigInRepo(configToSave); await setCaptchaConfigInRepo(configToSave);
console.log('[SettingsService] CAPTCHA config successfully set.'); console.log('[SettingsService] CAPTCHA config successfully set.');
}, // <-- Add comma here
// --- Show Connection Tags ---
async getShowConnectionTags(): Promise<boolean> {
console.log(`[Service] Attempting to get setting for key: ${SHOW_CONNECTION_TAGS_KEY}`);
try {
const valueStr = await settingsRepository.getSetting(SHOW_CONNECTION_TAGS_KEY);
console.log(`[Service] Raw value from repository for ${SHOW_CONNECTION_TAGS_KEY}:`, valueStr);
// 默认显示,所以只有当值为 'false' 时才返回 false
return valueStr !== 'false';
} catch (error) {
console.error(`[Service] Error getting show connection tags setting (key: ${SHOW_CONNECTION_TAGS_KEY}):`, error);
return true; // 默认返回 true
}
}, // *** 确保这里有逗号 ***
async setShowConnectionTags(enabled: boolean): Promise<void> {
console.log(`[Service] setShowConnectionTags called with: ${enabled}`);
try {
const valueStr = String(enabled);
console.log(`[Service] Attempting to save setting. Key: ${SHOW_CONNECTION_TAGS_KEY}, Value: ${valueStr}`);
await settingsRepository.setSetting(SHOW_CONNECTION_TAGS_KEY, valueStr);
console.log(`[Service] Successfully saved setting for key: ${SHOW_CONNECTION_TAGS_KEY}`);
} catch (error) {
console.error(`[Service] Error calling settingsRepository.setSetting for key ${SHOW_CONNECTION_TAGS_KEY}:`, error);
throw new Error('Failed to save show connection tags setting.');
}
}, // *** 确保这里有逗号 ***
// --- Show Quick Command Tags ---
async getShowQuickCommandTags(): Promise<boolean> {
console.log(`[Service] Attempting to get setting for key: ${SHOW_QUICK_COMMAND_TAGS_KEY}`);
try {
const valueStr = await settingsRepository.getSetting(SHOW_QUICK_COMMAND_TAGS_KEY);
console.log(`[Service] Raw value from repository for ${SHOW_QUICK_COMMAND_TAGS_KEY}:`, valueStr);
// 默认显示,所以只有当值为 'false' 时才返回 false
return valueStr !== 'false';
} catch (error) {
console.error(`[Service] Error getting show quick command tags setting (key: ${SHOW_QUICK_COMMAND_TAGS_KEY}):`, error);
return true; // 默认返回 true
}
}, // *** 确保这里有逗号 ***
async setShowQuickCommandTags(enabled: boolean): Promise<void> {
console.log(`[Service] setShowQuickCommandTags called with: ${enabled}`);
try {
const valueStr = String(enabled);
console.log(`[Service] Attempting to save setting. Key: ${SHOW_QUICK_COMMAND_TAGS_KEY}, Value: ${valueStr}`);
await settingsRepository.setSetting(SHOW_QUICK_COMMAND_TAGS_KEY, valueStr);
console.log(`[Service] Successfully saved setting for key: ${SHOW_QUICK_COMMAND_TAGS_KEY}`);
} catch (error) {
console.error(`[Service] Error calling settingsRepository.setSetting for key ${SHOW_QUICK_COMMAND_TAGS_KEY}:`, error);
throw new Error('Failed to save show quick command tags setting.');
}
} // <-- No comma after the last method } // <-- No comma after the last method
}; // <-- End of settingsService object definition }; // <-- End of settingsService object definition
@@ -417,6 +417,86 @@ async setCaptchaConfig(req: Request, res: Response): Promise<void> {
res.status(500).json({ message: '设置 CAPTCHA 配置失败', error: error.message }); res.status(500).json({ message: '设置 CAPTCHA 配置失败', error: error.message });
} }
} }
} }, // <-- Add comma here
// --- Show Connection Tags ---
async getShowConnectionTags(req: Request, res: Response): Promise<void> {
try {
console.log('[控制器] 收到获取“显示连接标签”设置的请求。');
const isEnabled = await settingsService.getShowConnectionTags();
console.log(`[控制器] 向客户端发送“显示连接标签”设置: ${isEnabled}`);
res.json({ enabled: isEnabled });
} catch (error: any) {
console.error('[控制器] 获取“显示连接标签”设置时出错:', error);
res.status(500).json({ message: '获取“显示连接标签”设置失败', error: error.message });
}
}, // *** 确保这里有逗号 ***
async setShowConnectionTags(req: Request, res: Response): Promise<void> {
console.log('[控制器] 收到设置“显示连接标签”设置的请求。');
try {
const { enabled } = req.body;
console.log('[控制器] 请求体 enabled:', enabled);
if (typeof enabled !== 'boolean') {
console.warn('[控制器] 收到无效的 enabled 格式:', enabled);
res.status(400).json({ message: '无效的请求体,"enabled" 必须是一个布尔值' });
return;
}
console.log('[控制器] 调用 settingsService.setShowConnectionTags...');
await settingsService.setShowConnectionTags(enabled);
console.log('[控制器] settingsService.setShowConnectionTags 成功完成。');
auditLogService.logAction('SETTINGS_UPDATED', { updatedKeys: ['showConnectionTags'] });
notificationService.sendNotification('SETTINGS_UPDATED', { updatedKeys: ['showConnectionTags'] });
console.log('[控制器] 发送成功响应。');
res.status(200).json({ message: '“显示连接标签”设置已成功更新' });
} catch (error: any) {
console.error('[控制器] 设置“显示连接标签”时出错:', error);
res.status(500).json({ message: '设置“显示连接标签”失败', error: error.message });
}
}, // *** 确保这里有逗号 ***
// --- Show Quick Command Tags ---
async getShowQuickCommandTags(req: Request, res: Response): Promise<void> {
try {
console.log('[控制器] 收到获取“显示快捷指令标签”设置的请求。');
const isEnabled = await settingsService.getShowQuickCommandTags();
console.log(`[控制器] 向客户端发送“显示快捷指令标签”设置: ${isEnabled}`);
res.json({ enabled: isEnabled });
} catch (error: any) {
console.error('[控制器] 获取“显示快捷指令标签”设置时出错:', error);
res.status(500).json({ message: '获取“显示快捷指令标签”设置失败', error: error.message });
}
}, // *** 确保这里有逗号 ***
async setShowQuickCommandTags(req: Request, res: Response): Promise<void> {
console.log('[控制器] 收到设置“显示快捷指令标签”设置的请求。');
try {
const { enabled } = req.body;
console.log('[控制器] 请求体 enabled:', enabled);
if (typeof enabled !== 'boolean') {
console.warn('[控制器] 收到无效的 enabled 格式:', enabled);
res.status(400).json({ message: '无效的请求体,"enabled" 必须是一个布尔值' });
return;
}
console.log('[控制器] 调用 settingsService.setShowQuickCommandTags...');
await settingsService.setShowQuickCommandTags(enabled);
console.log('[控制器] settingsService.setShowQuickCommandTags 成功完成。');
auditLogService.logAction('SETTINGS_UPDATED', { updatedKeys: ['showQuickCommandTags'] });
notificationService.sendNotification('SETTINGS_UPDATED', { updatedKeys: ['showQuickCommandTags'] });
console.log('[控制器] 发送成功响应。');
res.status(200).json({ message: '“显示快捷指令标签”设置已成功更新' });
} catch (error: any) {
console.error('[控制器] 设置“显示快捷指令标签”时出错:', error);
res.status(500).json({ message: '设置“显示快捷指令标签”失败', error: error.message });
}
} // <-- No comma after the last method
}; };
@@ -53,6 +53,18 @@ router.get('/sidebar', settingsController.getSidebarConfig);
// PUT /api/v1/settings/sidebar - 更新侧栏配置 // PUT /api/v1/settings/sidebar - 更新侧栏配置
router.put('/sidebar', settingsController.setSidebarConfig); router.put('/sidebar', settingsController.setSidebarConfig);
// +++ 新增:显示连接标签路由 +++
// GET /api/v1/settings/show-connection-tags - 获取设置
router.get('/show-connection-tags', settingsController.getShowConnectionTags);
// PUT /api/v1/settings/show-connection-tags - 更新设置
router.put('/show-connection-tags', settingsController.setShowConnectionTags);
// +++ 新增:显示快捷指令标签路由 +++
// GET /api/v1/settings/show-quick-command-tags - 获取设置
router.get('/show-quick-command-tags', settingsController.getShowQuickCommandTags);
// PUT /api/v1/settings/show-quick-command-tags - 更新设置
router.put('/show-quick-command-tags', settingsController.setShowQuickCommandTags);
export default router; export default router;
@@ -9,6 +9,7 @@ import { useTagsStore, TagInfo } from '../stores/tags.store'; // 确保 TagInfo
import { useSessionStore } from '../stores/session.store'; import { useSessionStore } from '../stores/session.store';
import { useFocusSwitcherStore } from '../stores/focusSwitcher.store'; import { useFocusSwitcherStore } from '../stores/focusSwitcher.store';
import { useUiNotificationsStore } from '../stores/uiNotifications.store'; // +++ 修正导入大小写 +++ import { useUiNotificationsStore } from '../stores/uiNotifications.store'; // +++ 修正导入大小写 +++
import { useSettingsStore } from '../stores/settings.store'; // 新增:导入设置 store
// 定义事件 // 定义事件
const emit = defineEmits([ const emit = defineEmits([
@@ -26,9 +27,11 @@ const tagsStore = useTagsStore();
const sessionStore = useSessionStore(); // 获取 session store 实例 const sessionStore = useSessionStore(); // 获取 session store 实例
const focusSwitcherStore = useFocusSwitcherStore(); // +++ 实例化焦点切换 Store +++ const focusSwitcherStore = useFocusSwitcherStore(); // +++ 实例化焦点切换 Store +++
const uiNotificationsStore = useUiNotificationsStore(); // +++ 修正实例化大小写 +++ const uiNotificationsStore = useUiNotificationsStore(); // +++ 修正实例化大小写 +++
const settingsStore = useSettingsStore(); // 新增:实例化设置 store
const { connections, isLoading: connectionsLoading, error: connectionsError } = storeToRefs(connectionsStore); const { connections, isLoading: connectionsLoading, error: connectionsError } = storeToRefs(connectionsStore);
const { tags, isLoading: tagsLoading, error: tagsError } = storeToRefs(tagsStore); const { tags, isLoading: tagsLoading, error: tagsError } = storeToRefs(tagsStore);
const { showConnectionTagsBoolean } = storeToRefs(settingsStore); // 新增:获取设置项
// 搜索词 // 搜索词
const searchTerm = ref(''); const searchTerm = ref('');
@@ -73,17 +76,25 @@ const highlightedIndex = ref(-1); // -1 表示没有高亮项
const listAreaRef = ref<HTMLElement | null>(null); // 列表容器的 ref const listAreaRef = ref<HTMLElement | null>(null); // 列表容器的 ref
// 计算属性:扁平化的、当前可见的连接列表(用于键盘导航) // 计算属性:扁平化的、当前可见的连接列表(用于键盘导航)
// 注意:这个 flatVisibleConnections 依赖于 filteredAndGroupedConnections 和 expandedGroups
// 当 showConnectionTagsBoolean 为 false 时,它不会被直接使用,但键盘导航逻辑依赖它
const flatVisibleConnections = computed(() => { const flatVisibleConnections = computed(() => {
const flatList: ConnectionInfo[] = []; const flatList: ConnectionInfo[] = [];
filteredAndGroupedConnections.value.forEach(group => { // 如果显示标签,则只包含展开分组的连接
// 只添加展开分组中的连接 if (showConnectionTagsBoolean.value) {
if (expandedGroups.value[group.groupName]) { filteredAndGroupedConnections.value.forEach(group => {
flatList.push(...group.connections); if (expandedGroups.value[group.groupName]) {
} flatList.push(...group.connections);
}); }
});
} else {
// 如果不显示标签,则包含所有过滤后的连接
flatList.push(...flatFilteredConnections.value); // 使用下面定义的 flatFilteredConnections
}
return flatList; return flatList;
}); });
// 计算属性:当前高亮连接的 ID // 计算属性:当前高亮连接的 ID
const highlightedConnectionId = computed(() => { const highlightedConnectionId = computed(() => {
if (highlightedIndex.value >= 0 && highlightedIndex.value < flatVisibleConnections.value.length) { if (highlightedIndex.value >= 0 && highlightedIndex.value < flatVisibleConnections.value.length) {
@@ -110,8 +121,7 @@ const setTagInputRef = (el: any, id: string | number) => {
} }
}; };
// 计算属性:过滤并按标签分组连接 // 计算属性:过滤并按标签分组连接 (仅在 showConnectionTagsBoolean 为 true 时使用)
// 需要修改 filteredAndGroupedConnections,使其包含 tagId
const filteredAndGroupedConnections = computed(() => { const filteredAndGroupedConnections = computed(() => {
const groups: Record<string, { connections: ConnectionInfo[], tagId: number | null }> = {}; // 修改:添加 tagId const groups: Record<string, { connections: ConnectionInfo[], tagId: number | null }> = {}; // 修改:添加 tagId
const untagged: ConnectionInfo[] = []; const untagged: ConnectionInfo[] = [];
@@ -128,7 +138,7 @@ const filteredAndGroupedConnections = computed(() => {
if (conn.host.toLowerCase().includes(lowerSearchTerm)) { if (conn.host.toLowerCase().includes(lowerSearchTerm)) {
return true; return true;
} }
// Check associated tag names // Check associated tag names (Always check tags for filtering, regardless of display setting)
if (conn.tag_ids && conn.tag_ids.length > 0) { if (conn.tag_ids && conn.tag_ids.length > 0) {
for (const tagId of conn.tag_ids) { for (const tagId of conn.tag_ids) {
const tag = tagMap.get(tagId); // Use the existing tagMap const tag = tagMap.get(tagId); // Use the existing tagMap
@@ -151,21 +161,27 @@ const filteredAndGroupedConnections = computed(() => {
const groupName = tag.name; const groupName = tag.name;
if (!groups[groupName]) { if (!groups[groupName]) {
groups[groupName] = { connections: [], tagId: tag.id }; // 修改:存储 tagId groups[groupName] = { connections: [], tagId: tag.id }; // 修改:存储 tagId
// Initialize expanded state only if not already set
if (expandedGroups.value[groupName] === undefined) { if (expandedGroups.value[groupName] === undefined) {
expandedGroups.value[groupName] = true; expandedGroups.value[groupName] = true; // Default to expanded
} }
} }
// Avoid duplicates if a connection has multiple tags matching the search
if (!groups[groupName].connections.some(c => c.id === conn.id)) { if (!groups[groupName].connections.some(c => c.id === conn.id)) {
groups[groupName].connections.push(conn); groups[groupName].connections.push(conn);
} }
tagged = true; tagged = true;
} }
}); });
if (!tagged) { // If none of the tags were found in the tagMap (e.g., stale data), treat as untagged
if (!tagged && !untagged.some(c => c.id === conn.id)) {
untagged.push(conn); untagged.push(conn);
} }
} else { } else {
untagged.push(conn); // Ensure untagged connections are not duplicated
if (!untagged.some(c => c.id === conn.id)) {
untagged.push(conn);
}
} }
}); });
@@ -185,8 +201,9 @@ const filteredAndGroupedConnections = computed(() => {
if (untagged.length > 0) { if (untagged.length > 0) {
const untaggedGroupName = t('workspaceConnectionList.untagged'); const untaggedGroupName = t('workspaceConnectionList.untagged');
// Initialize expanded state only if not already set
if (expandedGroups.value[untaggedGroupName] === undefined) { if (expandedGroups.value[untaggedGroupName] === undefined) {
expandedGroups.value[untaggedGroupName] = true; expandedGroups.value[untaggedGroupName] = true; // Default to expanded
} }
// 未标记的分组没有 tagId // 未标记的分组没有 tagId
result.push({ groupName: untaggedGroupName, connections: untagged, tagId: null }); result.push({ groupName: untaggedGroupName, connections: untagged, tagId: null });
@@ -195,24 +212,65 @@ const filteredAndGroupedConnections = computed(() => {
return result; return result;
}); });
// 新增:计算属性,仅过滤,不分组 (用于 showConnectionTagsBoolean 为 false 时)
const flatFilteredConnections = computed(() => {
const lowerSearchTerm = searchTerm.value.toLowerCase();
const tagMap = new Map(tags.value.map(tag => [tag.id, tag.name])); // 创建 tagMap 用于搜索
const filtered = connections.value.filter(conn => {
// Check connection name
if (conn.name && conn.name.toLowerCase().includes(lowerSearchTerm)) {
return true;
}
// Check connection host
if (conn.host.toLowerCase().includes(lowerSearchTerm)) {
return true;
}
// Check associated tag names (Always check tags for filtering)
if (conn.tag_ids && conn.tag_ids.length > 0) {
for (const tagId of conn.tag_ids) {
const tagName = tagMap.get(tagId);
if (tagName && tagName.toLowerCase().includes(lowerSearchTerm)) {
return true; // Match found in tag name
}
}
}
// No match found
return false;
});
// Sort the flat list
return filtered.sort((a, b) => (a.name || a.host).localeCompare(b.name || b.host));
});
// +++ 监听分组状态变化并保存到 localStorage +++ // +++ 监听分组状态变化并保存到 localStorage +++
watch(expandedGroups, (newState) => { watch(expandedGroups, (newState) => {
try { // Only save if tags are shown
localStorage.setItem(EXPANDED_GROUPS_STORAGE_KEY, JSON.stringify(newState)); if (showConnectionTagsBoolean.value) {
} catch (e) { try {
console.error('Failed to save expanded groups state to localStorage:', e); localStorage.setItem(EXPANDED_GROUPS_STORAGE_KEY, JSON.stringify(newState));
} catch (e) {
console.error('Failed to save expanded groups state to localStorage:', e);
}
} }
}, { deep: true }); }, { deep: true });
// 监听搜索词变化,重置高亮索引 // 监听搜索词变化,重置高亮索引
watch(searchTerm, () => { watch(searchTerm, () => {
highlightedIndex.value = -1; highlightedIndex.value = -1;
}); });
// 监听分组展开状态变化,重置高亮索引 (这个 watch 保留,用于重置高亮) // 监听分组展开状态变化,重置高亮索引 (这个 watch 保留,用于重置高亮)
watch(expandedGroups, () => { watch(expandedGroups, () => {
highlightedIndex.value = -1; highlightedIndex.value = -1;
}, { deep: true }); }, { deep: true });
// 监听显示模式变化,重置高亮索引
watch(showConnectionTagsBoolean, () => {
highlightedIndex.value = -1;
});
// +++ 监听编辑状态,自动聚焦输入框 +++ // +++ 监听编辑状态,自动聚焦输入框 +++
watch(editingTagId, async (newId) => { watch(editingTagId, async (newId) => {
if (newId !== null) { if (newId !== null) {
@@ -226,13 +284,13 @@ const filteredAndGroupedConnections = computed(() => {
} }
} }
}); });
// 切换分组展开/折叠 // 切换分组展开/折叠
const toggleGroup = (groupName: string) => { const toggleGroup = (groupName: string) => {
// 状态现在总是 boolean,直接切换 // 状态现在总是 boolean,直接切换
expandedGroups.value[groupName] = !expandedGroups.value[groupName]; expandedGroups.value[groupName] = !expandedGroups.value[groupName];
}; };
// 处理单击连接 (左键/Enter) - 使用 session store 处理连接请求 // 处理单击连接 (左键/Enter) - 使用 session store 处理连接请求
const handleConnect = (connectionId: number, event?: MouseEvent | KeyboardEvent) => { const handleConnect = (connectionId: number, event?: MouseEvent | KeyboardEvent) => {
if (event instanceof MouseEvent && event.button !== 0) { if (event instanceof MouseEvent && event.button !== 0) {
@@ -364,6 +422,8 @@ onMounted(() => {
unregisterFocusAction = focusSwitcherStore.registerFocusAction('connectionListSearch', focusSearchInput); unregisterFocusAction = focusSwitcherStore.registerFocusAction('connectionListSearch', focusSearchInput);
connectionsStore.fetchConnections(); // 移到 onMounted connectionsStore.fetchConnections(); // 移到 onMounted
tagsStore.fetchTags(); // 移到 onMounted tagsStore.fetchTags(); // 移到 onMounted
// Load initial expanded state after fetching tags/connections
expandedGroups.value = loadInitialExpandedGroups();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -390,7 +450,7 @@ defineExpose({ focusSearchInput });
// --- 键盘导航和确认 --- // --- 键盘导航和确认 ---
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
const list = flatVisibleConnections.value; const list = flatVisibleConnections.value; // Always navigate the potentially flat list
if (!list.length) return; if (!list.length) return;
switch (event.key) { switch (event.key) {
@@ -418,7 +478,8 @@ const scrollToHighlighted = async () => {
await nextTick(); // 等待 DOM 更新 await nextTick(); // 等待 DOM 更新
if (!listAreaRef.value || highlightedConnectionId.value === null) return; if (!listAreaRef.value || highlightedConnectionId.value === null) return;
const highlightedElement = listAreaRef.value.querySelector(`.connection-item[data-conn-id="${highlightedConnectionId.value}"]`); // Query selector needs to work for both grouped and flat lists
const highlightedElement = listAreaRef.value.querySelector(`li[data-conn-id="${highlightedConnectionId.value}"]`);
if (highlightedElement) { if (highlightedElement) {
highlightedElement.scrollIntoView({ block: 'nearest', inline: 'nearest' }); highlightedElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
} }
@@ -547,7 +608,7 @@ const cancelEditingTag = () => {
@blur="handleBlur" @blur="handleBlur"
/> />
<button <button
class="ml-2 w-8 h-8 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 disabled:bg-gray-400 disabled:cursor-not-allowed disabled:opacity-70 flex-shrink-0 flex items-center justify-center" class="ml-2 w-8 h-8 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 disabled:bg-gray-400 disabled:cursor-not-allowed disabled:opacity-70 flex-shrink-0 flex items-center justify-center"
@click="handleMenuAction('add')" @click="handleMenuAction('add')"
:title="t('connections.addConnection')" :title="t('connections.addConnection')"
> >
@@ -558,7 +619,8 @@ const cancelEditingTag = () => {
<!-- Connection List Area --> <!-- Connection List Area -->
<div class="flex-grow overflow-y-auto p-2" ref="listAreaRef"> <div class="flex-grow overflow-y-auto p-2" ref="listAreaRef">
<!-- No Results / No Connections State --> <!-- No Results / No Connections State -->
<div v-if="filteredAndGroupedConnections.length === 0 && connections.length > 0" class="p-6 text-center text-text-secondary"> <!-- 修改 v-if 条件考虑两种模式并且仅在有搜索词时显示 "No Results" -->
<div v-if="((showConnectionTagsBoolean && filteredAndGroupedConnections.length === 0) || (!showConnectionTagsBoolean && flatFilteredConnections.length === 0)) && connections.length > 0 && searchTerm" class="p-6 text-center text-text-secondary">
<i class="fas fa-search text-xl mb-2"></i> <i class="fas fa-search text-xl mb-2"></i>
<p>{{ t('workspaceConnectionList.noResults') }} "{{ searchTerm }}"</p> <p>{{ t('workspaceConnectionList.noResults') }} "{{ searchTerm }}"</p>
</div> </div>
@@ -573,11 +635,13 @@ const cancelEditingTag = () => {
</button> </button>
</div> </div>
<!-- Groups and Connections --> <!-- Groups and Connections (Conditional Rendering) -->
<div v-else> <div v-else>
<div v-for="groupData in filteredAndGroupedConnections" :key="groupData.groupName" class="mb-1 last:mb-0"> <!-- Grouped View -->
<!-- Group Header --> <div v-if="showConnectionTagsBoolean">
<div <div v-for="groupData in filteredAndGroupedConnections" :key="groupData.groupName" class="mb-1 last:mb-0">
<!-- Group Header -->
<div
class="group px-3 py-2 font-semibold flex items-center text-foreground rounded-md hover:bg-header/80 transition-colors duration-150" class="group px-3 py-2 font-semibold flex items-center text-foreground rounded-md hover:bg-header/80 transition-colors duration-150"
:class="{ 'cursor-pointer': editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId) }" :class="{ 'cursor-pointer': editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId) }"
@click="editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId) ? toggleGroup(groupData.groupName) : null" @click="editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId) ? toggleGroup(groupData.groupName) : null"
@@ -632,7 +696,26 @@ const cancelEditingTag = () => {
</span> </span>
</li> </li>
</ul> </ul>
</div>
</div> </div>
<!-- Flat View -->
<ul v-else class="list-none p-0 m-0">
<li
v-for="conn in flatFilteredConnections"
:key="conn.id"
class="group my-0.5 py-2 pr-3 pl-4 cursor-pointer flex items-center rounded-md whitespace-nowrap overflow-hidden text-ellipsis text-foreground hover:bg-primary/10 transition-colors duration-150"
:class="{ 'bg-primary/20 text-white font-medium': conn.id === highlightedConnectionId }"
:data-conn-id="conn.id"
@click.left="handleConnect(conn.id)"
@click.right.prevent
@contextmenu.prevent="showContextMenu($event, conn)"
>
<i :class="['fas', conn.type === 'RDP' ? 'fa-desktop' : 'fa-server', 'mr-2.5 w-4 text-center text-text-secondary group-hover:text-primary', { 'text-white': conn.id === highlightedConnectionId }]"></i>
<span class="overflow-hidden text-ellipsis whitespace-nowrap flex-grow text-sm" :title="conn.name || conn.host">
{{ conn.name || conn.host }}
</span>
</li>
</ul>
</div> </div>
</div> </div>
</div> </div>
+7 -1
View File
@@ -625,7 +625,13 @@
}, },
"error": { "error": {
"sidebarPersistentSaveFailed": "Failed to save sidebar setting." "sidebarPersistentSaveFailed": "Failed to save sidebar setting."
} },
"showConnectionTagsTitle": "Show Connection Tags",
"showConnectionTagsLabel": "Show tags in connection list",
"showConnectionTagsDescription": "Disable to hide tags in the connection list and exclude them from search.",
"showQuickCommandTagsTitle": "Show Quick Command Tags",
"showQuickCommandTagsLabel": "Show tags in quick command list",
"showQuickCommandTagsDescription": "Disable to hide tags in the quick command list and exclude them from search."
}, },
"ipBlacklist": { "ipBlacklist": {
"title": "IP Blacklist Management", "title": "IP Blacklist Management",
+7 -1
View File
@@ -857,7 +857,13 @@
"success": { "success": {
"sidebarPersistentSaved": "サイドバーの設定を保存しました。" "sidebarPersistentSaved": "サイドバーの設定を保存しました。"
}, },
"title": "ワークスペースとターミナル" "title": "ワークスペースとターミナル",
"showConnectionTagsTitle": "接続タグを表示",
"showConnectionTagsLabel": "接続リストにタグを表示",
"showConnectionTagsDescription": "無効にすると、接続リストのタグが非表示になり、検索から除外されます。",
"showQuickCommandTagsTitle": "クイックコマンドタグを表示",
"showQuickCommandTagsLabel": "クイックコマンドリストにタグを表示",
"showQuickCommandTagsDescription": "無効にすると、クイックコマンドリストのタグが非表示になり、検索から除外されます。"
}, },
"about": { "about": {
"version": "バージョン", "version": "バージョン",
+7 -1
View File
@@ -625,7 +625,13 @@
}, },
"error": { "error": {
"sidebarPersistentSaveFailed": "保存侧边栏设置失败。" "sidebarPersistentSaveFailed": "保存侧边栏设置失败。"
} },
"showConnectionTagsTitle": "显示连接标签",
"showConnectionTagsLabel": "在连接列表中显示标签",
"showConnectionTagsDescription": "关闭后将隐藏连接列表中的标签,并从搜索中排除标签。",
"showQuickCommandTagsTitle": "显示快捷指令标签",
"showQuickCommandTagsLabel": "在快捷指令列表中显示标签",
"showQuickCommandTagsDescription": "关闭后将隐藏快捷指令列表中的标签,并从搜索中排除标签。"
}, },
"ipBlacklist": { "ipBlacklist": {
"title": "IP 黑名单管理", "title": "IP 黑名单管理",
+81 -17
View File
@@ -53,6 +53,8 @@ interface SettingsState {
ipBlacklistEnabled?: string; ipBlacklistEnabled?: string;
dashboardSortBy?: SortField; dashboardSortBy?: SortField;
dashboardSortOrder?: SortOrder; dashboardSortOrder?: SortOrder;
showConnectionTags?: string; // 'true' or 'false'
showQuickCommandTags?: string; // 'true' or 'false'
[key: string]: string | undefined; [key: string]: string | undefined;
} }
@@ -82,8 +84,23 @@ export const useSettingsStore = defineStore('settings', () => {
try { try {
console.log('[SettingsStore] 加载通用设置...'); console.log('[SettingsStore] 加载通用设置...');
const response = await apiClient.get<Record<string, string>>('/settings'); // 使用 apiClient // Fetch all settings, including the new ones
settings.value = response.data; // Store fetched general settings const [
generalSettingsResponse,
showConnectionTagsResponse,
showQuickCommandTagsResponse
] = await Promise.all([
apiClient.get<Record<string, string>>('/settings'),
apiClient.get<{ enabled: boolean }>('/settings/show-connection-tags'),
apiClient.get<{ enabled: boolean }>('/settings/show-quick-command-tags')
]);
settings.value = generalSettingsResponse.data; // Store fetched general settings
// Store the specific boolean settings
settings.value.showConnectionTags = String(showConnectionTagsResponse.data.enabled);
settings.value.showQuickCommandTags = String(showQuickCommandTagsResponse.data.enabled);
// --- 更详细的日志 --- // --- 更详细的日志 ---
console.log('[SettingsStore] Fetched settings from backend:', JSON.stringify(settings.value)); console.log('[SettingsStore] Fetched settings from backend:', JSON.stringify(settings.value));
@@ -238,6 +255,15 @@ export const useSettingsStore = defineStore('settings', () => {
settings.value.dashboardSortOrder = 'desc'; settings.value.dashboardSortOrder = 'desc';
} }
// NEW: Tag visibility defaults
if (settings.value.showConnectionTags === undefined) {
settings.value.showConnectionTags = 'true'; // 默认显示
}
if (settings.value.showQuickCommandTags === undefined) {
settings.value.showQuickCommandTags = 'true'; // 默认显示
}
// --- 语言设置 --- // --- 语言设置 ---
const langFromSettings = settings.value.language; const langFromSettings = settings.value.language;
console.log(`[SettingsStore] Language from fetched settings: ${langFromSettings}`); // <-- 添加日志 console.log(`[SettingsStore] Language from fetched settings: ${langFromSettings}`); // <-- 添加日志
@@ -299,10 +325,11 @@ export const useSettingsStore = defineStore('settings', () => {
/** /**
* Updates a single general setting value both locally and on the backend. * Updates a single general setting value both locally and on the backend.
* Uses specific endpoints for boolean settings where available.
* @param key The setting key to update. * @param key The setting key to update.
* @param value The new value for the setting. * @param value The new value for the setting (string for general, boolean for specific).
*/ */
async function updateSetting(key: keyof SettingsState, value: string) { async function updateSetting(key: keyof SettingsState, value: string | boolean) {
// 移除外观相关的键检查 // 移除外观相关的键检查
const allowedKeys: Array<keyof SettingsState> = [ const allowedKeys: Array<keyof SettingsState> = [
'language', 'ipWhitelist', 'maxLoginAttempts', 'loginBanDuration', 'language', 'ipWhitelist', 'maxLoginAttempts', 'loginBanDuration',
@@ -319,26 +346,48 @@ export const useSettingsStore = defineStore('settings', () => {
'rdpModalHeight', // NEW: 添加 RDP 模态框高度键 'rdpModalHeight', // NEW: 添加 RDP 模态框高度键
'ipBlacklistEnabled', 'ipBlacklistEnabled',
'dashboardSortBy', 'dashboardSortBy',
'dashboardSortOrder' 'dashboardSortOrder',
'showConnectionTags', // NEW
'showQuickCommandTags' // NEW
]; ];
if (!allowedKeys.includes(key)) { if (!allowedKeys.includes(key)) {
console.error(`[SettingsStore] 尝试更新不允许的设置键: ${key}`); console.error(`[SettingsStore] 尝试更新不允许的设置键: ${key}`);
throw new Error(`不允许更新设置项 '${key}'`); throw new Error(`不允许更新设置项 '${key}'`);
} }
// Use specific endpoints for boolean settings
const booleanEndpoints: Partial<Record<keyof SettingsState, string>> = {
showConnectionTags: '/settings/show-connection-tags',
showQuickCommandTags: '/settings/show-quick-command-tags',
autoCopyOnSelect: '/settings/auto-copy-on-select',
// Add other boolean settings with specific endpoints here if needed
};
try { try {
console.log(`[SettingsStore] Attempting to update setting - Key: ${key}, Value: ${value}`); // +++ Add log +++ let apiPromise: Promise<any>;
// 注意:后端 controller 现在会过滤,但前端也做一层检查更好 const endpoint = booleanEndpoints[key];
const payload = { [key]: value };
console.log('[SettingsStore] Sending PUT request to /settings with payload:', payload); // +++ Add log +++ if (endpoint && typeof value === 'boolean') {
await apiClient.put('/settings', payload); // 使用 apiClient console.log(`[SettingsStore] Attempting to update boolean setting via specific endpoint - Key: ${key}, Value: ${value}, Endpoint: ${endpoint}`);
console.log(`[SettingsStore] Successfully updated setting via API - Key: ${key}`); // +++ Add log +++ apiPromise = apiClient.put(endpoint, { enabled: value });
// Update store state *after* successful API call } else if (typeof value === 'string') {
settings.value = { ...settings.value, [key]: value }; console.log(`[SettingsStore] Attempting to update general setting - Key: ${key}, Value: ${value}`);
const payload = { [key]: value };
console.log('[SettingsStore] Sending PUT request to /settings with payload:', payload);
apiPromise = apiClient.put('/settings', payload);
} else {
throw new Error(`Invalid value type for setting '${key}': expected boolean for specific endpoint or string for general.`);
}
await apiPromise;
console.log(`[SettingsStore] Successfully updated setting via API - Key: ${key}`);
// Update store state *after* successful API call
settings.value = { ...settings.value, [key]: String(value) }; // Store as string internally
// If updating language, check if it's valid and update i18n // If updating language, check if it's valid and update i18n
if (key === 'language' && availableLocales.includes(value)) { if (key === 'language' && typeof value === 'string' && availableLocales.includes(value)) {
console.log(`[SettingsStore] updateSetting: Language updated to ${value}. Calling setLocale...`); // <-- 添加日志 console.log(`[SettingsStore] updateSetting: Language updated to ${value}. Calling setLocale...`);
setLocale(value); setLocale(value);
} else if (key === 'language') { } else if (key === 'language') {
console.warn(`[SettingsStore] updateSetting: Attempted to set invalid language '${value}'. Ignoring i18n update.`); console.warn(`[SettingsStore] updateSetting: Attempted to set invalid language '${value}'. Ignoring i18n update.`);
@@ -381,7 +430,9 @@ export const useSettingsStore = defineStore('settings', () => {
'rdpModalHeight', // NEW: 添加 RDP 模态框高度键 'rdpModalHeight', // NEW: 添加 RDP 模态框高度键
'ipBlacklistEnabled', 'ipBlacklistEnabled',
'dashboardSortBy', 'dashboardSortBy',
'dashboardSortOrder' 'dashboardSortOrder',
'showConnectionTags', // NEW
'showQuickCommandTags' // NEW
]; ];
const filteredUpdates: Partial<SettingsState> = {}; const filteredUpdates: Partial<SettingsState> = {};
let languageUpdate: string | undefined = undefined; let languageUpdate: string | undefined = undefined;
@@ -435,7 +486,8 @@ export const useSettingsStore = defineStore('settings', () => {
const newWidths = { ...parsedSidebarPaneWidths.value, [paneName]: width }; const newWidths = { ...parsedSidebarPaneWidths.value, [paneName]: width };
parsedSidebarPaneWidths.value = newWidths; // Update local reactive state first parsedSidebarPaneWidths.value = newWidths; // Update local reactive state first
try { try {
await updateSetting('sidebarPaneWidths', JSON.stringify(newWidths)); // Use updateMultipleSettings for consistency, even for one setting
await updateMultipleSettings({ sidebarPaneWidths: JSON.stringify(newWidths) });
} catch (error) { } catch (error) {
console.error(`[SettingsStore] Failed to save sidebarPaneWidths after updating ${paneName}:`, error); console.error(`[SettingsStore] Failed to save sidebarPaneWidths after updating ${paneName}:`, error);
// Optionally revert local state or show error to user // Optionally revert local state or show error to user
@@ -642,6 +694,15 @@ export const useSettingsStore = defineStore('settings', () => {
const recaptchaSiteKey = computed(() => captchaSettings.value?.recaptchaSiteKey ?? ''); const recaptchaSiteKey = computed(() => captchaSettings.value?.recaptchaSiteKey ?? '');
// DO NOT expose secret keys via getters // DO NOT expose secret keys via getters
// NEW: Getters for tag visibility
const showConnectionTagsBoolean = computed(() => {
return settings.value.showConnectionTags !== 'false'; // Default to true
});
const showQuickCommandTagsBoolean = computed(() => {
return settings.value.showQuickCommandTags !== 'false'; // Default to true
});
return { return {
settings, // 只包含通用设置 settings, // 只包含通用设置
isLoading, isLoading,
@@ -677,5 +738,8 @@ export const useSettingsStore = defineStore('settings', () => {
dashboardSortBy, dashboardSortBy,
dashboardSortOrder, dashboardSortOrder,
saveDashboardSortPreference, saveDashboardSortPreference,
// NEW: Expose tag visibility getters
showConnectionTagsBoolean,
showQuickCommandTagsBoolean,
}; };
}); });
+137 -87
View File
@@ -26,88 +26,123 @@
</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 (Show if loading and no groups are ready yet) --> <!-- Loading State -->
<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 && quickCommandsStore.quickCommandsList.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 (Show if not loading and no groups exist) --> <!-- Empty State (No commands at all) -->
<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"> <div v-else-if="!isLoading && quickCommandsStore.quickCommandsList.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>
<!-- Grouped Command List --> <!-- No Results State (Commands exist, but filter yields no results) -->
<div v-else-if="!isLoading && ((showQuickCommandTagsBoolean && filteredAndGroupedCommands.length === 0) || (!showQuickCommandTagsBoolean && flatFilteredCommands.length === 0)) && searchTerm" class="p-6 text-center text-text-secondary text-sm flex flex-col items-center justify-center h-full">
<i class="fas fa-search text-xl mb-2"></i>
<p>{{ t('quickCommands.noResults', '没有找到匹配的指令') }} "{{ searchTerm }}"</p>
</div>
<!-- Command List (Grouped or Flat) -->
<div v-else class="list-none p-0 m-0" ref="commandListContainerRef"> <!-- Changed ref name --> <div v-else class="list-none p-0 m-0" ref="commandListContainerRef"> <!-- Changed ref name -->
<div v-for="groupData in filteredAndGroupedCommands" :key="groupData.groupName" class="mb-1 last:mb-0"> <!-- Grouped View -->
<!-- Group Header --> <div v-if="showQuickCommandTagsBoolean">
<!-- Group Header - Modified for inline editing --> <div v-for="groupData in filteredAndGroupedCommands" :key="groupData.groupName" class="mb-1 last:mb-0">
<div <!-- Group Header - Modified for inline editing -->
class="group px-3 py-2 font-semibold flex items-center text-foreground rounded-md hover:bg-header/80 transition-colors duration-150" <div
:class="{ 'cursor-pointer': editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId) }" class="group px-3 py-2 font-semibold flex items-center text-foreground rounded-md hover:bg-header/80 transition-colors duration-150"
@click="editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId) ? toggleGroup(groupData.groupName) : null" :class="{ 'cursor-pointer': editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId) }"
> @click="editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId) ? toggleGroup(groupData.groupName) : null"
<i >
: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]}]" <i
@click.stop="toggleGroup(groupData.groupName)" :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]}]"
class="cursor-pointer flex-shrink-0" @click.stop="toggleGroup(groupData.groupName)"
></i> class="cursor-pointer flex-shrink-0"
<!-- Editing State --> ></i>
<input <!-- Editing State -->
v-if="editingTagId === (groupData.tagId === null ? 'untagged' : groupData.tagId)" <input
:key="groupData.tagId === null ? 'untagged-input' : `tag-input-${groupData.tagId}`" v-if="editingTagId === (groupData.tagId === null ? 'untagged' : groupData.tagId)"
:ref="(el) => setTagInputRef(el, groupData.tagId === null ? 'untagged' : groupData.tagId)" :key="groupData.tagId === null ? 'untagged-input' : `tag-input-${groupData.tagId}`"
type="text" :ref="(el) => setTagInputRef(el, groupData.tagId === null ? 'untagged' : groupData.tagId)"
v-model="editedTagName" type="text"
class="text-sm bg-input border border-primary rounded px-1 py-0 w-full" v-model="editedTagName"
@blur="finishEditingTag" class="text-sm bg-input border border-primary rounded px-1 py-0 w-full"
@keydown.enter.prevent="finishEditingTag" @blur="finishEditingTag"
@keydown.esc.prevent="cancelEditingTag" @keydown.enter.prevent="finishEditingTag"
@click.stop @keydown.esc.prevent="cancelEditingTag"
/> @click.stop
<!-- Display State --> />
<span <!-- Display State -->
v-else <span
class="text-sm inline-block overflow-hidden text-ellipsis whitespace-nowrap" v-else
:class="{ 'cursor-pointer hover:underline': true }" class="text-sm inline-block overflow-hidden text-ellipsis whitespace-nowrap"
:title="t('quickCommands.tags.clickToEditTag', '点击编辑标签')" :class="{ 'cursor-pointer hover:underline': true }"
@click.stop="startEditingTag(groupData.tagId, groupData.groupName)" :title="t('quickCommands.tags.clickToEditTag', '点击编辑标签')"
> @click.stop="startEditingTag(groupData.tagId, groupData.groupName)"
{{ groupData.groupName }} >
</span> {{ groupData.groupName }}
<!-- Optional: Add count? --> </span>
<!-- <span v-if="editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId)" class="ml-auto text-xs text-text-secondary pl-2">({{ groupData.commands.length }})</span> --> <!-- Optional: Add count? -->
</div> <!-- <span v-if="editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId)" class="ml-auto text-xs text-text-secondary pl-2">({{ groupData.commands.length }})</span> -->
<!-- Command Items List (only show if expanded) --> </div>
<ul v-show="quickCommandsStore.expandedGroups[groupData.groupName]" class="list-none p-0 m-0 pl-3"> <!-- Command Items List (only show if expanded) -->
<li <ul v-show="quickCommandsStore.expandedGroups[groupData.groupName]" class="list-none p-0 m-0 pl-3">
v-for="(cmd) in groupData.commands" <li
:key="cmd.id" v-for="(cmd) in groupData.commands"
:data-command-id="cmd.id" :key="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" :data-command-id="cmd.id"
:class="{ 'bg-primary/20 font-medium': isCommandSelected(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"
@click="executeCommand(cmd)" :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"> <!-- Command Info (Structure remains the same) -->
<span v-if="cmd.name" class="font-medium text-sm truncate mb-0.5 text-foreground">{{ cmd.name }}</span> <div class="flex flex-col overflow-hidden mr-2 flex-grow">
<span class="text-xs truncate font-mono" :class="{ 'text-sm': !cmd.name, 'text-text-secondary': true }">{{ cmd.command }}</span> <span v-if="cmd.name" class="font-medium text-sm truncate mb-0.5 text-foreground">{{ cmd.name }}</span>
</div> <span class="text-xs truncate font-mono" :class="{ 'text-sm': !cmd.name, 'text-text-secondary': true }">{{ cmd.command }}</span>
<!-- Actions (Structure remains the same) --> </div>
<div class="flex items-center flex-shrink-0 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity duration-150"> <!-- Actions (Structure remains the same) -->
<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> <div class="flex items-center flex-shrink-0 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity duration-150">
<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', '编辑')"> <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>
<i class="fas fa-edit text-sm"></i> <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', '编辑')">
</button> <i class="fas fa-edit text-sm"></i>
<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', '删除')"> </button>
<i class="fas fa-times text-sm"></i> <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', '删除')">
</button> <i class="fas fa-times text-sm"></i>
</div> </button>
</li> </div>
</ul> </li>
</div> </ul>
</div>
</div>
<!-- Flat View -->
<ul v-else class="list-none p-0 m-0">
<li
v-for="(cmd) in flatFilteredCommands"
: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 -->
<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 -->
<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>
@@ -130,12 +165,14 @@ 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 +++
import { useSettingsStore } from '../stores/settings.store'; // store
const quickCommandsStore = useQuickCommandsStore(); const quickCommandsStore = useQuickCommandsStore();
const quickCommandTagsStore = useQuickCommandTagsStore(); // +++ Instantiate the new tag store +++ 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 +++
const settingsStore = useSettingsStore(); // store
const hoveredItemId = ref<number | null>(null); const hoveredItemId = ref<number | null>(null);
const isFormVisible = ref(false); const isFormVisible = ref(false);
@@ -159,9 +196,17 @@ const isLoading = computed(() => quickCommandsStore.isLoading);
// selectedIndex now refers to the index within the flatVisibleCommands list // selectedIndex now refers to the index within the flatVisibleCommands list
// Also get expandedGroups reactively for the template // Also get expandedGroups reactively for the template
const { selectedIndex: storeSelectedIndex, flatVisibleCommands, expandedGroups } = storeToRefs(quickCommandsStore); const { selectedIndex: storeSelectedIndex, flatVisibleCommands, expandedGroups } = storeToRefs(quickCommandsStore);
const { showQuickCommandTagsBoolean } = storeToRefs(settingsStore); //
//
const flatFilteredCommands = computed(() => {
// 使 store flatVisibleCommands
return quickCommandsStore.flatVisibleCommands;
});
// --- Helper function for selection check --- // --- Helper function for selection check ---
const isCommandSelected = (commandId: number): boolean => { const isCommandSelected = (commandId: number): boolean => {
// 使 store flatVisibleCommands storeSelectedIndex
if (storeSelectedIndex.value < 0 || !flatVisibleCommands.value[storeSelectedIndex.value]) { if (storeSelectedIndex.value < 0 || !flatVisibleCommands.value[storeSelectedIndex.value]) {
return false; return false;
} }
@@ -181,8 +226,18 @@ onMounted(async () => { // Make onMounted async
await quickCommandsStore.fetchQuickCommands(); await quickCommandsStore.fetchQuickCommands();
// Also fetch the quick command tags using the correct store instance // Also fetch the quick command tags using the correct store instance
await quickCommandTagsStore.fetchTags(); await quickCommandTagsStore.fetchTags();
// +++ +++
unregisterFocus = focusSwitcherStore.registerFocusAction('quickCommandsSearch', focusSearchInput);
}); });
onBeforeUnmount(() => {
// +++ +++
if (unregisterFocus) {
unregisterFocus();
}
});
// +++ Watcher to focus input when editing starts +++ // +++ Watcher to focus input when editing starts +++
watch(editingTagId, async (newId) => { watch(editingTagId, async (newId) => {
if (newId !== null) { if (newId !== null) {
@@ -197,18 +252,11 @@ watch(editingTagId, async (newId) => {
} }
}); });
//
watch(showQuickCommandTagsBoolean, () => {
quickCommandsStore.resetSelection();
});
// +++ / +++
onMounted(() => {
// +++ +++
unregisterFocus = focusSwitcherStore.registerFocusAction('quickCommandsSearch', focusSearchInput);
});
onBeforeUnmount(() => {
// +++ +++
if (unregisterFocus) {
unregisterFocus();
}
});
// --- --- // --- ---
@@ -221,12 +269,13 @@ const updateSearchTerm = (event: Event) => {
// +++ +++ // +++ +++
const scrollToSelected = async (index: number) => { const scrollToSelected = async (index: number) => {
await nextTick(); // DOM await nextTick(); // DOM
// 使 store flatVisibleCommands
if (index < 0 || !commandListContainerRef.value || !flatVisibleCommands.value[index]) return; if (index < 0 || !commandListContainerRef.value || !flatVisibleCommands.value[index]) return;
const selectedCommandId = flatVisibleCommands.value[index].id; const selectedCommandId = flatVisibleCommands.value[index].id;
const listContainer = commandListContainerRef.value; const listContainer = commandListContainerRef.value;
// Find the element using the data attribute // Find the element using the data attribute (works for both views)
const selectedElement = listContainer.querySelector(`li[data-command-id="${selectedCommandId}"]`) as HTMLLIElement; const selectedElement = listContainer.querySelector(`li[data-command-id="${selectedCommandId}"]`) as HTMLLIElement;
if (selectedElement) { if (selectedElement) {
@@ -244,9 +293,9 @@ watch(storeSelectedIndex, (newIndex) => {
scrollToSelected(newIndex); scrollToSelected(newIndex);
}); });
// Keyboard navigation now operates on the flat visible list // Keyboard navigation now operates on the flat visible list from the store
const handleSearchInputKeydown = (event: KeyboardEvent) => { const handleSearchInputKeydown = (event: KeyboardEvent) => {
// Use flatVisibleCommands for navigation logic // 使 store flatVisibleCommands
const commands = flatVisibleCommands.value; const commands = flatVisibleCommands.value;
if (!commands.length) return; if (!commands.length) return;
@@ -263,6 +312,7 @@ const handleSearchInputKeydown = (event: KeyboardEvent) => {
break; break;
case 'Enter': case 'Enter':
event.preventDefault(); event.preventDefault();
// 使 store storeSelectedIndex
if (storeSelectedIndex.value >= 0 && storeSelectedIndex.value < commands.length) { if (storeSelectedIndex.value >= 0 && storeSelectedIndex.value < commands.length) {
executeCommand(commands[storeSelectedIndex.value]); executeCommand(commands[storeSelectedIndex.value]);
} }
@@ -294,6 +344,7 @@ const toggleGroup = (groupName: string) => {
// After toggling, selection might become invalid if the selected item is now hidden // After toggling, selection might become invalid if the selected item is now hidden
// Reset selection or check if the selected item is still visible // Reset selection or check if the selected item is still visible
nextTick(() => { // Wait for DOM update potentially caused by v-show nextTick(() => { // Wait for DOM update potentially caused by v-show
// 使 store flatVisibleCommands storeSelectedIndex
const selectedCmdId = storeSelectedIndex.value >= 0 && flatVisibleCommands.value[storeSelectedIndex.value] const selectedCmdId = storeSelectedIndex.value >= 0 && flatVisibleCommands.value[storeSelectedIndex.value]
? flatVisibleCommands.value[storeSelectedIndex.value].id ? flatVisibleCommands.value[storeSelectedIndex.value].id
: null; : null;
@@ -476,4 +527,3 @@ const cancelEditingTag = () => {
}; };
</script> </script>
@@ -444,6 +444,46 @@
</div> </div>
</form> </form>
</div> </div>
<hr class="border-border/50"> <!-- NEW: Separator -->
<!-- Show Connection Tags -->
<div class="settings-section-content">
<h3 class="text-base font-semibold text-foreground mb-3">{{ $t('settings.workspace.showConnectionTagsTitle', '显示连接标签') }}</h3>
<form @submit.prevent="handleUpdateShowConnectionTags" class="space-y-4">
<div class="flex items-center">
<input type="checkbox" id="showConnectionTags" v-model="showConnectionTagsLocal"
class="h-4 w-4 rounded border-border text-primary focus:ring-primary mr-2 cursor-pointer">
<label for="showConnectionTags" class="text-sm text-foreground cursor-pointer select-none">{{ $t('settings.workspace.showConnectionTagsLabel', '在连接列表中显示标签') }}</label>
</div>
<p class="text-xs text-text-secondary mt-1">{{ $t('settings.workspace.showConnectionTagsDescription', '关闭后将隐藏连接列表中的标签,并从搜索中排除标签。') }}</p>
<div class="flex items-center justify-between pt-2">
<button type="submit" :disabled="showConnectionTagsLoading"
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out text-sm font-medium">
{{ $t('common.save') }}
</button>
<p v-if="showConnectionTagsMessage" :class="['text-sm', showConnectionTagsSuccess ? 'text-success' : 'text-error']">{{ showConnectionTagsMessage }}</p>
</div>
</form>
</div>
<hr class="border-border/50"> <!-- NEW: Separator -->
<!-- Show Quick Command Tags -->
<div class="settings-section-content">
<h3 class="text-base font-semibold text-foreground mb-3">{{ $t('settings.workspace.showQuickCommandTagsTitle', '显示快捷指令标签') }}</h3>
<form @submit.prevent="handleUpdateShowQuickCommandTags" class="space-y-4">
<div class="flex items-center">
<input type="checkbox" id="showQuickCommandTags" v-model="showQuickCommandTagsLocal"
class="h-4 w-4 rounded border-border text-primary focus:ring-primary mr-2 cursor-pointer">
<label for="showQuickCommandTags" class="text-sm text-foreground cursor-pointer select-none">{{ $t('settings.workspace.showQuickCommandTagsLabel', '在快捷指令列表中显示标签') }}</label>
</div>
<p class="text-xs text-text-secondary mt-1">{{ $t('settings.workspace.showQuickCommandTagsDescription', '关闭后将隐藏快捷指令列表中的标签,并从搜索中排除标签。') }}</p>
<div class="flex items-center justify-between pt-2">
<button type="submit" :disabled="showQuickCommandTagsLoading"
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out text-sm font-medium">
{{ $t('common.save') }}
</button>
<p v-if="showQuickCommandTagsMessage" :class="['text-sm', showQuickCommandTagsSuccess ? 'text-success' : 'text-error']">{{ showQuickCommandTagsMessage }}</p>
</div>
</form>
</div>
</div> </div>
</div> </div>
@@ -624,6 +664,8 @@ const {
captchaSettings, // <-- Import CAPTCHA settings state captchaSettings, // <-- Import CAPTCHA settings state
commandInputSyncTarget, // NEW: Import command input sync target getter commandInputSyncTarget, // NEW: Import command input sync target getter
ipBlacklistEnabledBoolean, // <-- Import IP Blacklist enabled getter ipBlacklistEnabledBoolean, // <-- Import IP Blacklist enabled getter
showConnectionTagsBoolean, // NEW: Import connection tag visibility getter
showQuickCommandTagsBoolean, // NEW: Import quick command tag visibility getter
} = storeToRefs(settingsStore); } = storeToRefs(settingsStore);
// Removed Passkey state import from authStore // Removed Passkey state import from authStore
@@ -648,6 +690,8 @@ const popupEditorEnabled = ref(true); // 本地状态,用于 v-model
const workspaceSidebarPersistentEnabled = ref(false); // const workspaceSidebarPersistentEnabled = ref(false); //
const commandInputSyncTargetLocal = ref<'none' | 'quickCommands' | 'commandHistory'>('none'); // NEW: Local state for command input sync target const commandInputSyncTargetLocal = ref<'none' | 'quickCommands' | 'commandHistory'>('none'); // NEW: Local state for command input sync target
const ipBlacklistEnabled = ref(true); // <-- Local state for IP Blacklist switch const ipBlacklistEnabled = ref(true); // <-- Local state for IP Blacklist switch
const showConnectionTagsLocal = ref(true); // NEW: Local state for connection tags switch
const showQuickCommandTagsLocal = ref(true); // NEW: Local state for quick command tags switch
// --- Local UI feedback state --- // --- Local UI feedback state ---
const ipWhitelistLoading = ref(false); const ipWhitelistLoading = ref(false);
@@ -692,6 +736,12 @@ const selectedTimezone = ref('UTC'); // 本地状态,用于时区 v-model
const timezoneLoading = ref(false); const timezoneLoading = ref(false);
const timezoneMessage = ref(''); const timezoneMessage = ref('');
const timezoneSuccess = ref(false); const timezoneSuccess = ref(false);
const showConnectionTagsLoading = ref(false); // NEW
const showConnectionTagsMessage = ref(''); // NEW
const showConnectionTagsSuccess = ref(false); // NEW
const showQuickCommandTagsLoading = ref(false); // NEW
const showQuickCommandTagsMessage = ref(''); // NEW
const showQuickCommandTagsSuccess = ref(false); // NEW
// CAPTCHA Form State // CAPTCHA Form State
const captchaForm = reactive<UpdateCaptchaSettingsDto>({ // Use reactive for the form object const captchaForm = reactive<UpdateCaptchaSettingsDto>({ // Use reactive for the form object
enabled: false, enabled: false,
@@ -740,6 +790,8 @@ watch(settings, (newSettings, oldSettings) => {
commandInputSyncTargetLocal.value = commandInputSyncTarget.value; // NEW: Sync command input sync target commandInputSyncTargetLocal.value = commandInputSyncTarget.value; // NEW: Sync command input sync target
selectedTimezone.value = newSettings.timezone || 'UTC'; // selectedTimezone.value = newSettings.timezone || 'UTC'; //
ipBlacklistEnabled.value = ipBlacklistEnabledBoolean.value; // <-- Sync IP Blacklist enabled state ipBlacklistEnabled.value = ipBlacklistEnabledBoolean.value; // <-- Sync IP Blacklist enabled state
showConnectionTagsLocal.value = showConnectionTagsBoolean.value; // NEW: Sync connection tags state
showQuickCommandTagsLocal.value = showQuickCommandTagsBoolean.value; // NEW: Sync quick command tags state
}, { deep: true, immediate: true }); // immediate: true to run on initial load }, { deep: true, immediate: true }); // immediate: true to run on initial load
@@ -928,6 +980,46 @@ const handleUpdateTimezone = async () => {
} }
}; };
// --- Show Connection Tags setting method ---
const handleUpdateShowConnectionTags = async () => {
showConnectionTagsLoading.value = true;
showConnectionTagsMessage.value = '';
showConnectionTagsSuccess.value = false;
try {
await settingsStore.updateSetting('showConnectionTags', showConnectionTagsLocal.value);
showConnectionTagsMessage.value = t('settings.workspace.success.showConnectionTagsSaved', '连接标签显示设置已保存'); //
showConnectionTagsSuccess.value = true;
} catch (error: any) {
console.error('更新显示连接标签设置失败:', error);
showConnectionTagsMessage.value = error.message || t('settings.workspace.error.showConnectionTagsSaveFailed', '保存连接标签显示设置失败'); //
showConnectionTagsSuccess.value = false;
// No need to revert local state on failure with explicit save button
} finally {
showConnectionTagsLoading.value = false;
// Keep message visible until next save attempt
}
};
// --- Show Quick Command Tags setting method ---
const handleUpdateShowQuickCommandTags = async () => {
showQuickCommandTagsLoading.value = true;
showQuickCommandTagsMessage.value = '';
showQuickCommandTagsSuccess.value = false;
try {
await settingsStore.updateSetting('showQuickCommandTags', showQuickCommandTagsLocal.value);
showQuickCommandTagsMessage.value = t('settings.workspace.success.showQuickCommandTagsSaved', '快捷指令标签显示设置已保存'); //
showQuickCommandTagsSuccess.value = true;
} catch (error: any) {
console.error('更新显示快捷指令标签设置失败:', error);
showQuickCommandTagsMessage.value = error.message || t('settings.workspace.error.showQuickCommandTagsSaveFailed', '保存快捷指令标签显示设置失败'); //
showQuickCommandTagsSuccess.value = false;
// No need to revert local state on failure with explicit save button
} finally {
showQuickCommandTagsLoading.value = false;
// Keep message visible until next save attempt
}
};
// --- --- // --- ---
const openStyleCustomizer = () => { const openStyleCustomizer = () => {
appearanceStore.toggleStyleCustomizer(true); appearanceStore.toggleStyleCustomizer(true);