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
@@ -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 () => {
testStatus.value = 'testing';
@@ -487,9 +516,19 @@ const testButtonText = computed(() => {
<div>
<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>
<!-- 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">
@@ -25,6 +25,23 @@
></textarea>
<small v-if="commandError" class="text-error text-xs mt-1 block">{{ commandError }}</small>
</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">
<!-- 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>
@@ -42,22 +59,26 @@
import { ref, reactive, computed, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
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<{
commandToEdit?: QuickCommandFE | null; // 接收要编辑的指令对象
commandToEdit?: QuickCommandFE | null; // 接收要编辑的指令对象 (should include tagIds)
}>();
const emit = defineEmits(['close']);
const { t } = useI18n();
const quickCommandsStore = useQuickCommandsStore();
const quickCommandTagsStore = useQuickCommandTagsStore(); // +++ Instantiate tag store +++
const isSubmitting = ref(false);
const isEditing = computed(() => !!props.commandToEdit);
const formData = reactive({
name: '',
command: '',
name: '',
command: '',
tagIds: [] as number[], // +++ Add tagIds +++
});
const commandError = ref<string | null>(null);
@@ -76,9 +97,54 @@ onMounted(() => {
if (isEditing.value && props.commandToEdit) {
formData.name = props.commandToEdit.name ?? '';
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 () => {
if (commandError.value) return; // 如果校验失败则不提交
@@ -89,9 +155,11 @@ const handleSubmit = async () => {
const finalName = formData.name.trim().length > 0 ? formData.name.trim() : null;
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 {
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;
+95 -94
View File
@@ -1,24 +1,40 @@
<script setup lang="ts">
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 { 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<{
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 tagsStore = useTagsStore();
const { tags, isLoading, error } = storeToRefs(tagsStore);
// const tagsStore = useTagsStore(); // REMOVE
// const { tags, isLoading, error } = storeToRefs(tagsStore); // REMOVE
const inputValue = ref(''); // 输入框的值
const inputRef = ref<HTMLInputElement | null>(null); // 输入框引用
const showSuggestions = ref(false); // 是否显示建议列表
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
watch(() => props.modelValue, (newVal) => {
// 只有在值确实不同的情况下才更新,避免无限循环
@@ -37,66 +53,59 @@ watch(selectedTagIds, (newVal) => {
// 计算属性:所有标签的 Map,方便通过 ID 查找
// Use availableTags prop for the map
const tagsMap = computed(() => {
const map = new Map<number, TagInfo>();
tags.value.forEach(tag => map.set(tag.id, tag));
return map;
const map = new Map<number, GenericTag>();
availableTags.value.forEach(tag => map.set(tag.id, tag));
return map;
});
// 计算属性:已选中的标签对象
const selectedTags = computed(() => {
// 确保先从 map 中查找,再过滤掉未找到的 (可能标签已被删除)
return selectedTagIds.value
.map(id => tagsMap.value.get(id))
.filter((tag): tag is TagInfo => tag !== undefined);
.map(id => tagsMap.value.get(id)) // Get from the map based on prop
.filter((tag): tag is GenericTag => tag !== undefined);
});
// 计算属性:过滤后的建议列表
// 计算属性:过滤后的建议列表 (based on availableTags)
const suggestions = computed(() => {
if (!showSuggestions.value) { // 仅在需要显示时计算
return [];
}
let result: TagInfo[];
// 如果输入框为空,显示所有未选中的标签
let result: GenericTag[]; // Use GenericTag type
// Use availableTags from prop
const currentAvailableTags = availableTags.value;
// 如果输入框为空,显示所有未选中的可用标签
if (!inputValue.value) {
result = tags.value.filter(tag => !selectedTagIds.value.includes(tag.id));
result = currentAvailableTags.filter(tag => !selectedTagIds.value.includes(tag.id));
} else {
const lowerCaseInput = inputValue.value.toLowerCase();
result = tags.value.filter(tag =>
tag.name.toLowerCase().includes(lowerCaseInput) &&
!selectedTagIds.value.includes(tag.id) // 排除已选中的
);
const lowerCaseInput = inputValue.value.toLowerCase();
result = currentAvailableTags.filter(tag =>
tag.name.toLowerCase().includes(lowerCaseInput) &&
!selectedTagIds.value.includes(tag.id) // 排除已选中的
);
}
return result;
});
// 处理输入框聚焦
const handleFocus = async () => {
showSuggestions.value = false; // 在异步操作前显式设置为 false
// 1. 首先获取最新的标签
await tagsStore.fetchTags();
// 处理输入框聚焦 (不再 fetch, 仅根据现有 availableTags 判断是否显示)
const handleFocus = () => {
// 计算建议 (不依赖 showSuggestions ref)
let potentialSuggestions: GenericTag[];
const currentInput = inputValue.value;
const currentAvailableTags = availableTags.value.filter(tag => !selectedTagIds.value.includes(tag.id));
// 2. 基于更新后的标签列表和当前输入值,计算出实际可以显示的建议标签
// (这部分逻辑与 computed 'suggestions' 类似,但不依赖 showSuggestions.value)
let potentialSuggestions: TagInfo[];
const currentInput = inputValue.value; // 获取当前输入框的值
// 过滤掉已选中的标签
const availableTags = tags.value.filter(tag => !selectedTagIds.value.includes(tag.id));
if (!currentInput) {
// 如果输入框为空,所有未选中的标签都是潜在建议
potentialSuggestions = availableTags;
} else {
// 如果输入框有值,则根据输入值过滤可用标签
const lowerCaseInput = currentInput.toLowerCase();
potentialSuggestions = availableTags.filter(tag =>
tag.name.toLowerCase().includes(lowerCaseInput)
);
}
// 3. 只有当确实存在潜在建议时,才显示建议列表
const shouldShow = potentialSuggestions.length > 0;
showSuggestions.value = shouldShow; // 最终状态由计算结果决定
if (!currentInput) {
potentialSuggestions = currentAvailableTags;
} else {
const lowerCaseInput = currentInput.toLowerCase();
potentialSuggestions = currentAvailableTags.filter(tag =>
tag.name.toLowerCase().includes(lowerCaseInput)
);
}
// 只有当确实存在潜在建议时,才显示建议列表
showSuggestions.value = potentialSuggestions.length > 0;
};
// 处理输入框失焦
@@ -111,23 +120,18 @@ const handleKeyDown = async (event: KeyboardEvent) => {
event.preventDefault(); // 阻止表单提交等默认行为
const trimmedInput = inputValue.value.trim();
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)) {
// 如果是现有标签且未选中,则选中它
selectTag(existingTag);
} else if (!existingTag) {
// 如果是新标签,则创建并选中
const success = await tagsStore.addTag(trimmedInput);
if (success) {
// addTag 内部会 fetchTags, store 会更新
// 需要等待 DOM 更新和 store 更新完成
await nextTick(); // 等待 store 更新
const newTag = tags.value.find(tag => tag.name === trimmedInput); // 再次查找确保获取到 ID
if (newTag) {
selectTag(newTag);
}
}
// 如果是现有标签且未选中,则选中它
selectTag(existingTag);
} else if (!existingTag && allowCreate.value) { // Only create if allowed and not existing
// 如果是新标签,则 emit 事件让父组件处理创建
console.log(`[TagInput] Emitting create-tag for: ${trimmedInput}`); // +++ 添加日志 +++
emit('create-tag', trimmedInput);
// 父组件负责创建、更新 availableTags prop,然后 TagInput 会响应式更新
// 父组件也负责将新创建的 tag ID 添加到 modelValue
}
inputValue.value = ''; // 清空输入框
showSuggestions.value = false; // 创建或选择后隐藏建议
@@ -137,12 +141,11 @@ const handleKeyDown = async (event: KeyboardEvent) => {
}
};
// 选中一个标签 (来自建议列表或 Enter 创建)
const selectTag = (tag: TagInfo) => {
if (!selectedTagIds.value.includes(tag.id)) {
// 使用 .push() 来触发 watch
const updatedIds = [...selectedTagIds.value, tag.id];
selectedTagIds.value = updatedIds;
// 选中一个标签 (来自建议列表或 Enter 匹配)
const selectTag = (tag: GenericTag) => {
if (!selectedTagIds.value.includes(tag.id)) {
// 使用 .push() 来触发 watch -> emit update:modelValue
selectedTagIds.value = [...selectedTagIds.value, tag.id];
}
inputValue.value = ''; // 清空输入框
showSuggestions.value = false; // 选择后隐藏建议
@@ -150,27 +153,22 @@ const selectTag = (tag: TagInfo) => {
};
// 仅从本地选择中移除一个标签 (点击选中标签的 'x' 或 Backspace)
const removeTagLocally = (tagToRemove: TagInfo) => {
selectedTagIds.value = selectedTagIds.value.filter(id => id !== tagToRemove.id);
const removeTagLocally = (tagToRemove: GenericTag) => {
// This will trigger the watch and emit update:modelValue
selectedTagIds.value = selectedTagIds.value.filter(id => id !== tagToRemove.id);
};
// 处理全局删除标签 (点击标签上的 'x' 图标) - 这是全局删除
const handleDeleteTagGlobally = async (tagToDelete: TagInfo) => {
// 弹出确认框,防止误删
if (confirm(t('tags.prompts.confirmDelete', { name: tagToDelete.name }))) {
const success = await tagsStore.deleteTag(tagToDelete.id);
if (success) {
// deleteTag 内部会 fetchTags, store 会更新
// selectedTagIds 会因为 watch props.modelValue 而自动更新 (如果父组件也更新了)
// 或者手动从 selectedTagIds 中移除 (更保险)
removeTagLocally(tagToDelete);
// 可选:显示成功提示
} else {
// 可选:显示错误提示
alert(t('tags.errorDelete', { error: tagsStore.error || '未知错误' }));
}
}
};
// 处理全局删除标签 (点击标签上的 'x' 图标) - Emit event
const handleDeleteTagGlobally = (tagToDelete: GenericTag) => {
console.log(`[TagInput] handleDeleteTagGlobally called for tag ID: ${tagToDelete.id}, Name: ${tagToDelete.name}`); // +++ 添加日志 +++
// Emit event for parent to handle deletion confirmation and API call
console.log(`[TagInput] Emitting delete-tag with ID: ${tagToDelete.id}`); // +++ 添加日志 +++
emit('delete-tag', tagToDelete.id);
// Parent should handle confirmation, call store action, and update modelValue/availableTags
// We might still want to remove it locally immediately for better UX,
// but relying on parent updating modelValue is cleaner.
// removeTagLocally(tagToDelete); // Optional: remove locally immediately
}; // Remove the extra closing brace here if it exists, ensure function closes correctly
</script>
@@ -186,13 +184,15 @@ const handleDeleteTagGlobally = async (tagToDelete: TagInfo) => {
@click.stop="removeTagLocally(tag)"
:title="t('tags.removeSelection')"
>&times;</button>
<!-- Only show delete button if allowDelete is true -->
<button
type="button"
class="ml-1 p-0 bg-transparent border-none cursor-pointer text-text-alt hover:text-error text-xs leading-none"
@click.stop="handleDeleteTagGlobally(tag)"
:title="t('tags.deleteTagGlobally')"
v-if="allowDelete"
type="button"
class="ml-1 p-0 bg-transparent border-none cursor-pointer text-text-alt hover:text-error text-xs leading-none"
@click.stop="handleDeleteTagGlobally(tag)"
:title="t('tags.deleteTagGlobally')"
>
<i class="fas fa-trash-alt"></i>
<i class="fas fa-trash-alt"></i>
</button>
</span>
</div>
@@ -217,9 +217,10 @@ const handleDeleteTagGlobally = async (tagToDelete: TagInfo) => {
>
{{ suggestion.name }}
</li>
</ul>
<div v-if="isLoading" class="absolute bottom-[-1.5em] left-0 text-xs text-text-secondary mt-1">{{ t('tags.loading') }}</div>
<div v-if="error" class="absolute bottom-[-1.5em] left-0 text-xs text-error mt-1">{{ t('tags.error', { error: error }) }}</div>
</ul>
<!-- Remove isLoading and error display as they are no longer managed here -->
<!-- <div v-if="isLoading" ...></div> -->
<!-- <div v-if="error" ...></div> -->
</div>
</template>
@@ -118,11 +118,27 @@ const filteredAndGroupedConnections = computed(() => {
const tagMap = new Map(tags.value.map(tag => [tag.id, tag]));
const lowerSearchTerm = searchTerm.value.toLowerCase();
// 1. 过滤连接
// 1. 过滤连接 (New logic: filter by connection name, host, OR tag name)
const filteredConnections = connections.value.filter(conn => {
const nameMatch = conn.name && conn.name.toLowerCase().includes(lowerSearchTerm);
const hostMatch = conn.host.toLowerCase().includes(lowerSearchTerm);
return nameMatch || hostMatch;
// 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
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. 分组过滤后的连接
+7 -1
View File
@@ -906,7 +906,13 @@
"command": "Command:",
"commandPlaceholder": "e.g., ls -alh /home/user",
"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": {
+6
View File
@@ -492,6 +492,8 @@
"empty": "クイックコマンドはありません。'+'ボタンをクリックして作成してください!",
"form": {
"add": "追加",
"tags": "タグ:",
"tagsPlaceholder": "タグを選択または作成...",
"command": "コマンド:",
"commandPlaceholder": "例:ls -alh /home/user",
"errorCommandRequired": "コマンドは空にできません",
@@ -500,6 +502,10 @@
"titleAdd": "クイックコマンドの追加",
"titleEdit": "クイックコマンドの編集"
},
"untagged": "タグなし",
"tags": {
"clickToEditTag": "クリックしてタグ名を編集"
},
"searchPlaceholder": "名前またはコマンドを検索...",
"sortByName": "名前",
"sortByUsage": "使用頻度",
+7 -1
View File
@@ -909,7 +909,13 @@
"command": "指令:",
"commandPlaceholder": "例如:ls -alh /home/user",
"errorCommandRequired": "指令内容不能为空",
"add": "添加"
"add": "添加",
"tags": "标签:",
"tagsPlaceholder": "选择或创建标签..."
},
"untagged": "未标记",
"tags": {
"clickToEditTag": "点击编辑标签名称"
}
},
"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 apiClient from '../utils/apiClient'; // 使用统一的 apiClient
import { ref, computed } from 'vue';
import { ref, computed, watch } from 'vue'; // Import watch
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';
// 定义前端使用的快捷指令接口 (可以与后端一致)
export type QuickCommandFE = QuickCommand;
// 定义前端使用的快捷指令接口 (包含 tagIds)
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 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', () => {
const quickCommandsList = ref<QuickCommandFE[]>([]);
const quickCommandsList = ref<QuickCommandFE[]>([]); // Should now contain QuickCommandFE with tagIds
const searchTerm = ref('');
const sortBy = ref<QuickCommandSortByType>('name'); // 默认按名称排序
const isLoading = ref(false);
const error = ref<string | null>(null);
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 ---
// 计算属性:根据搜索词过滤和排序指令
const filteredAndSortedCommands = computed(() => {
// +++ 重写 Getter: 过滤、分组、排序指令 +++
const filteredAndGroupedCommands = computed((): GroupedQuickCommands[] => {
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) {
filtered = filtered.filter(cmd =>
(cmd.name && cmd.name.toLowerCase().includes(term)) ||
cmd.command.toLowerCase().includes(term)
);
filtered = filtered.filter(cmd => {
// Check command name
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 中直接排序可能不是最佳实践,但这里为了简单起见先这样实现
// 更好的方式可能是在 fetch 时就按需排序,或者在组件层排序
// 注意:这里直接修改 ref 数组的顺序,如果需要在多处使用不同排序,需要创建副本
// return [...filtered].sort((a, b) => {
// if (sortBy.value === 'usage_count') {
// // 按使用次数降序,次数相同按名称升序
// if (b.usage_count !== a.usage_count) {
// return b.usage_count - a.usage_count;
// }
// }
// // 默认或次数相同时按名称升序 (null 名称排在前面)
// const nameA = a.name ?? '';
// const nameB = b.name ?? '';
// return nameA.localeCompare(nameB);
// });
// **修正:Getter 不应修改原始数组,返回过滤后的即可,排序由 fetch 控制**
return filtered;
// 2. 分组
const groups: Record<string, { commands: QuickCommandFE[], tagId: number | null }> = {};
const untaggedCommands: QuickCommandFE[] = [];
filtered.forEach(cmd => {
let isTagged = false;
if (cmd.tagIds && cmd.tagIds.length > 0) {
cmd.tagIds.forEach(tagId => {
const tagName = tagMap.get(tagId);
if (tagName) {
if (!groups[tagName]) {
groups[tagName] = { commands: [], tagId: tagId };
// 初始化展开状态 (如果未定义,默认为 true)
if (expandedGroups.value[tagName] === undefined) {
expandedGroups.value[tagName] = true;
}
}
// 避免重复添加(如果一个指令有多个相同标签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 ---
// 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 commands = filteredAndSortedCommands.value;
const commands = flatVisibleCommands.value; // Use the flat visible list
if (commands.length === 0) {
selectedIndex.value = -1;
return;
@@ -64,9 +226,9 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
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 commands = filteredAndSortedCommands.value;
const commands = flatVisibleCommands.value; // Use the flat visible list
if (commands.length === 0) {
selectedIndex.value = -1;
return;
@@ -74,37 +236,48 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
selectedIndex.value = (selectedIndex.value - 1 + commands.length) % commands.length;
};
// 从后端获取快捷指令 (带缓存和排序)
// 从后端获取快捷指令 (包含 tagIds,不再发送 sortBy)
const fetchQuickCommands = async () => {
const cacheKey = 'quickCommandsCache';
// 将排序方式加入缓存键,确保不同排序有不同缓存
const cacheKeyWithSort = `${cacheKey}_${sortBy.value}`;
error.value = null; // 重置错误
// 简化缓存:只缓存原始列表,不再区分排序
const cacheKey = 'quickCommandsListCache';
error.value = null;
// 1. 尝试从 localStorage 加载缓存
try {
const cachedData = localStorage.getItem(cacheKeyWithSort);
const cachedData = localStorage.getItem(cacheKey);
if (cachedData) {
console.log(`[QuickCmdStore] Loading commands from cache (sort: ${sortBy.value}).`);
quickCommandsList.value = JSON.parse(cachedData);
isLoading.value = false; // 先显示缓存
console.log(`[QuickCmdStore] Loading commands from cache.`);
// 确保解析后的数据符合 QuickCommandFE 结构 (特别是 tagIds)
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 {
isLoading.value = true; // 无缓存,初始加载
isLoading.value = true;
}
} catch (e) {
console.error('[QuickCmdStore] Failed to load or parse commands cache:', e);
localStorage.removeItem(cacheKeyWithSort); // 解析失败则移除缓存
isLoading.value = true; // 缓存无效,需要加载
localStorage.removeItem(cacheKey);
isLoading.value = true;
}
// 2. 后台获取最新数据
isLoading.value = true; // 标记正在后台获取
isLoading.value = true;
try {
console.log(`[QuickCmdStore] Fetching latest commands from server (sort: ${sortBy.value})...`);
const response = await apiClient.get<QuickCommandFE[]>('/quick-commands', {
params: { sortBy: sortBy.value }
});
const freshData = response.data;
console.log(`[QuickCmdStore] Fetching latest commands from server...`);
// 不再发送 sortBy 参数
const response = await apiClient.get<QuickCommandFE[]>('/quick-commands');
// 确保返回的数据包含 tagIds 数组
const freshData = response.data.map(cmd => ({
...cmd,
tagIds: Array.isArray(cmd.tagIds) ? cmd.tagIds : [] // 确保 tagIds 是数组
}));
const freshDataString = JSON.stringify(freshData);
// 3. 对比并更新
@@ -112,37 +285,37 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
if (currentDataString !== freshDataString) {
console.log('[QuickCmdStore] Commands data changed, updating state and cache.');
quickCommandsList.value = freshData;
localStorage.setItem(cacheKeyWithSort, freshDataString); // 更新对应排序的缓存
localStorage.setItem(cacheKey, freshDataString); // 更新缓存
} else {
console.log('[QuickCmdStore] Commands data is up-to-date.');
}
error.value = null; // 清除错误
error.value = null;
} catch (err: any) {
console.error('[QuickCmdStore] 获取快捷指令失败:', err);
error.value = err.response?.data?.message || '获取快捷指令时发生错误';
// 保留缓存数据,仅设置错误状态
uiNotificationsStore.showError(error.value ?? '未知错误');
if (error.value) {
uiNotificationsStore.showError(error.value);
}
} finally {
isLoading.value = false; // 加载完成
isLoading.value = false;
}
};
// 清除所有排序的快捷指令缓存
// 清除快捷指令列表缓存
const clearQuickCommandsCache = () => {
const cacheKeyBase = 'quickCommandsCache';
// 移除两种排序的缓存
localStorage.removeItem(`${cacheKeyBase}_name`);
localStorage.removeItem(`${cacheKeyBase}_usage_count`);
console.log('[QuickCmdStore] Cleared all quick commands caches.');
localStorage.removeItem('quickCommandsListCache');
console.log('[QuickCmdStore] Cleared quick commands list cache.');
};
// 添加快捷指令 (添加后清除缓存)
const addQuickCommand = async (name: string | null, command: string): Promise<boolean> => {
// 添加快捷指令 (发送 tagIds)
const addQuickCommand = async (name: string | null, command: string, tagIds?: number[]): Promise<boolean> => {
try {
await apiClient.post('/quick-commands', { name, command });
clearQuickCommandsCache(); // 清除所有排序缓存
await fetchQuickCommands(); // 刷新当前排序的列表和缓存
// 在请求体中包含 tagIds
const response = await apiClient.post<{ message: string, command: QuickCommandFE }>('/quick-commands', { name, command, tagIds });
// 后端现在返回完整的 command 对象,可以直接使用或触发刷新
clearQuickCommandsCache(); // 清除缓存
await fetchQuickCommands(); // 重新获取以确保数据同步
uiNotificationsStore.showSuccess('快捷指令已添加');
return true;
} catch (err: any) {
@@ -153,12 +326,14 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
}
};
// 更新快捷指令
const updateQuickCommand = async (id: number, name: string | null, command: string): Promise<boolean> => {
// 更新快捷指令 (发送 tagIds)
const updateQuickCommand = async (id: number, name: string | null, command: string, tagIds?: number[]): Promise<boolean> => {
try {
await apiClient.put(`/quick-commands/${id}`, { name, command });
clearQuickCommandsCache(); // 清除所有排序缓存
await fetchQuickCommands(); // 刷新当前排序的列表和缓存
// 在请求体中包含 tagIds (即使是 undefined 也要发送,让后端知道是否要更新)
const response = await apiClient.put<{ message: string, command: QuickCommandFE }>(`/quick-commands/${id}`, { name, command, tagIds });
// 后端现在返回完整的 command 对象
clearQuickCommandsCache(); // 清除缓存
await fetchQuickCommands(); // 重新获取以确保数据同步
uiNotificationsStore.showSuccess('快捷指令已更新');
return true;
} catch (err: any) {
@@ -214,12 +389,12 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
selectedIndex.value = -1; // Reset selection when search term changes
};
// 设置排序方式重新获取数据
const setSortBy = async (newSortBy: QuickCommandSortByType) => {
// 设置排序方式 (只更新本地状态,不再重新获取数据)
const setSortBy = (newSortBy: QuickCommandSortByType) => {
if (sortBy.value !== newSortBy) {
sortBy.value = newSortBy;
// 排序方式改变,不需要清除缓存,fetchQuickCommands 会读取对应排序的缓存或重新获取
await fetchQuickCommands();
// 排序现在由 filteredAndGroupedCommands getter 处理,无需重新 fetch
selectedIndex.value = -1; // Reset selection when sort changes
}
};
@@ -236,8 +411,10 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
sortBy,
isLoading,
error,
filteredAndSortedCommands, // 使用计算属性
selectedIndex, // NEW: Expose selected index
filteredAndGroupedCommands, // Expose the grouped data
flatVisibleCommands, // Expose the flat visible list for navigation logic if needed outside
selectedIndex, // Index within flatVisibleCommands
expandedGroups, // Expose expanded groups state
fetchQuickCommands,
addQuickCommand,
updateQuickCommand,
@@ -245,8 +422,64 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
incrementUsage,
setSearchTerm,
setSortBy,
selectNextCommand, // NEW: Expose action
selectPreviousCommand, // NEW: Expose action
resetSelection, // Ensure resetSelection is exported
selectNextCommand,
selectPreviousCommand,
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>
<!-- List Area -->
<div class="flex-grow overflow-y-auto p-2">
<!-- Loading State -->
<!-- Loading State (Only show if loading AND no commands are displayed yet) -->
<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>
<p>{{ t('common.loading', '加载中...') }}</p>
<!-- Loading State (Show if loading and no groups are ready 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">
<i class="fas fa-spinner fa-spin text-xl mb-2"></i>
<p>{{ t('common.loading', '加载中...') }}</p>
</div>
<!-- Empty State -->
<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">
<i class="fas fa-bolt text-xl mb-2"></i>
<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">
<!-- Empty State (Show if not loading and no groups exist) -->
<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>
<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">
{{ $t('quickCommands.addFirst', '创建第一个快捷指令') }}
</button>
</div>
<!-- Command List -->
<ul v-else class="list-none p-0 m-0" ref="commandListRef">
<li
v-for="(cmd, index) in filteredAndSortedCommands"
: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"
:class="{ 'bg-primary/20 font-medium': index === storeSelectedIndex }"
@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 (Show on Hover) -->
<div class="flex items-center flex-shrink-0 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity duration-150">
<!-- Usage Count -->
<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>
<!-- Edit Button -->
<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>
<!-- Delete 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>
<!-- Grouped Command List -->
<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">
<!-- Group Header -->
<!-- Group Header - Modified for inline editing -->
<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="{ '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]}]"
@click.stop="toggleGroup(groupData.groupName)"
class="cursor-pointer flex-shrink-0"
></i>
<!-- Editing State -->
<input
v-if="editingTagId === (groupData.tagId === null ? 'untagged' : groupData.tagId)"
:key="groupData.tagId === null ? 'untagged-input' : `tag-input-${groupData.tagId}`"
:ref="(el) => setTagInputRef(el, groupData.tagId === null ? 'untagged' : groupData.tagId)"
type="text"
v-model="editedTagName"
class="text-sm bg-input border border-primary rounded px-1 py-0 w-full"
@blur="finishEditingTag"
@keydown.enter.prevent="finishEditingTag"
@keydown.esc.prevent="cancelEditingTag"
@click.stop
/>
<!-- 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>
@@ -84,13 +124,15 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed, nextTick, defineExpose, watch } from 'vue'; // Import watch
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 { useI18n } from 'vue-i18n';
import AddEditQuickCommandForm from '../components/AddEditQuickCommandForm.vue'; //
import { useFocusSwitcherStore } from '../stores/focusSwitcher.store'; // +++ Store +++
const quickCommandsStore = useQuickCommandsStore();
const quickCommandTagsStore = useQuickCommandTagsStore(); // +++ Instantiate the new tag store +++
const uiNotificationsStore = useUiNotificationsStore(); //
const { t } = useI18n();
const focusSwitcherStore = useFocusSwitcherStore(); // +++ Store +++
@@ -99,16 +141,32 @@ const hoveredItemId = ref<number | null>(null);
const isFormVisible = ref(false);
const commandToEdit = ref<QuickCommandFE | null>(null);
// 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 +++
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 ---
const searchTerm = computed(() => quickCommandsStore.searchTerm);
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 { 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<{
@@ -116,10 +174,30 @@ const emit = defineEmits<{
}>();
// --- ---
onMounted(() => {
quickCommandsStore.fetchQuickCommands(); //
onMounted(async () => { // Make onMounted async
// 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(() => {
// +++ +++
@@ -140,21 +218,25 @@ const updateSearchTerm = (event: Event) => {
// selectedIndex.value = -1; // REMOVED: Store handles resetting index
};
//
const scrollToSelected = async (index: number) => { // Accept index as argument
await nextTick(); // DOM
if (index < 0 || !commandListRef.value) return;
// +++ +++
const scrollToSelected = async (index: number) => {
await nextTick(); // DOM
if (index < 0 || !commandListContainerRef.value || !flatVisibleCommands.value[index]) return;
const listElement = commandListRef.value;
const selectedItem = listElement.children[index] as HTMLLIElement;
const selectedCommandId = flatVisibleCommands.value[index].id;
const listContainer = commandListContainerRef.value;
if (selectedItem) {
// 使 scrollIntoView 使
selectedItem.scrollIntoView({
behavior: 'smooth', // 使 'auto'
block: 'nearest',
});
}
// Find the element using the data attribute
const selectedElement = listContainer.querySelector(`li[data-command-id="${selectedCommandId}"]`) as HTMLLIElement;
if (selectedElement) {
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
@@ -162,12 +244,13 @@ watch(storeSelectedIndex, (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 commands = filteredAndSortedCommands.value;
if (!commands.length) return;
// Use flatVisibleCommands for navigation logic
const commands = flatVisibleCommands.value;
if (!commands.length) return;
switch (event.key) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
quickCommandsStore.selectNextCommand(); // Use store action
@@ -193,16 +276,37 @@ const handleSearchInputBlur = () => {
setTimeout(() => {
//
//
if (document.activeElement !== searchInputRef.value && !commandListRef.value?.contains(document.activeElement)) {
quickCommandsStore.resetSelection();
if (document.activeElement !== searchInputRef.value && !commandListContainerRef.value?.contains(document.activeElement)) {
quickCommandsStore.resetSelection();
}
}, 100); //
}, 100); //
};
//
// (Action remains the same, store handles the logic change)
const toggleSortBy = () => {
const newSortBy = sortBy.value === 'name' ? 'usage_count' : 'name';
quickCommandsStore.setSortBy(newSortBy);
const newSortBy = sortBy.value === 'name' ? 'usage_count' : 'name';
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
@@ -259,5 +363,117 @@ const focusSearchInput = (): boolean => {
};
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>