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. 分组过滤后的连接