chore(docs): archive quickcommands double-click tooltip implementation
move quickcommands-double-click-tooltip records from plan to archive and mark status as completed. update changelog, archive index, and frontend module documentation to reflect the finalized interaction change and traceability metadata
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useTagsStore } from '../stores/tags.store';
|
||||
import { useConnectionsStore } from '../stores/connections.store';
|
||||
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
|
||||
import { useConfirmDialog } from '../composables/useConfirmDialog';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['update:visible', 'deleted']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const tagsStore = useTagsStore();
|
||||
const connectionsStore = useConnectionsStore();
|
||||
const uiNotificationsStore = useUiNotificationsStore();
|
||||
const { showConfirmDialog } = useConfirmDialog();
|
||||
|
||||
const { tags, isLoading: isTagsLoading } = storeToRefs(tagsStore);
|
||||
const { connections, isLoading: isConnectionsLoading } = storeToRefs(connectionsStore);
|
||||
|
||||
const internalVisible = ref(props.visible);
|
||||
const searchQuery = ref('');
|
||||
const selectedTagIds = ref<number[]>([]);
|
||||
const deleteConnectionsTogether = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const isBusy = computed(() => isTagsLoading.value || isConnectionsLoading.value || isSubmitting.value);
|
||||
const normalizedSearchQuery = computed(() => searchQuery.value.trim().toLowerCase());
|
||||
const selectedTagIdSet = computed(() => new Set(selectedTagIds.value));
|
||||
|
||||
const tagConnectionCountMap = computed(() => {
|
||||
const counts = new Map<number, number>();
|
||||
tags.value.forEach((tag) => counts.set(tag.id, 0));
|
||||
|
||||
connections.value.forEach((connection) => {
|
||||
connection.tag_ids?.forEach((tagId) => {
|
||||
counts.set(tagId, (counts.get(tagId) ?? 0) + 1);
|
||||
});
|
||||
});
|
||||
|
||||
return counts;
|
||||
});
|
||||
|
||||
const filteredTags = computed(() => {
|
||||
return tags.value
|
||||
.filter((tag) => {
|
||||
if (!normalizedSearchQuery.value) {
|
||||
return true;
|
||||
}
|
||||
return tag.name.toLowerCase().includes(normalizedSearchQuery.value);
|
||||
})
|
||||
.map((tag) => ({
|
||||
...tag,
|
||||
connectionCount: tagConnectionCountMap.value.get(tag.id) ?? 0,
|
||||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
});
|
||||
|
||||
const selectedTagCount = computed(() => selectedTagIds.value.length);
|
||||
const affectedConnectionIds = computed(() => {
|
||||
const affectedIds = new Set<number>();
|
||||
|
||||
connections.value.forEach((connection) => {
|
||||
if (connection.tag_ids?.some((tagId) => selectedTagIdSet.value.has(tagId))) {
|
||||
affectedIds.add(connection.id);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(affectedIds);
|
||||
});
|
||||
|
||||
const resetState = () => {
|
||||
searchQuery.value = '';
|
||||
selectedTagIds.value = [];
|
||||
deleteConnectionsTogether.value = false;
|
||||
};
|
||||
|
||||
const ensureDataLoaded = async () => {
|
||||
if (!tags.value.length && !tagsStore.isLoading) {
|
||||
await tagsStore.fetchTags();
|
||||
}
|
||||
if (!connections.value.length && !connectionsStore.isLoading) {
|
||||
await connectionsStore.fetchConnections();
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.visible, async (newValue) => {
|
||||
internalVisible.value = newValue;
|
||||
if (newValue) {
|
||||
resetState();
|
||||
await ensureDataLoaded();
|
||||
}
|
||||
});
|
||||
|
||||
watch(internalVisible, (newValue) => {
|
||||
if (newValue !== props.visible) {
|
||||
emit('update:visible', newValue);
|
||||
}
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
internalVisible.value = false;
|
||||
};
|
||||
|
||||
const isTagSelected = (tagId: number) => {
|
||||
return selectedTagIdSet.value.has(tagId);
|
||||
};
|
||||
|
||||
const toggleTagSelection = (tagId: number) => {
|
||||
if (selectedTagIdSet.value.has(tagId)) {
|
||||
selectedTagIds.value = selectedTagIds.value.filter((id) => id !== tagId);
|
||||
return;
|
||||
}
|
||||
|
||||
selectedTagIds.value = [...selectedTagIds.value, tagId];
|
||||
};
|
||||
|
||||
const selectAllFilteredTags = () => {
|
||||
selectedTagIds.value = Array.from(new Set([...selectedTagIds.value, ...filteredTags.value.map((tag) => tag.id)]));
|
||||
};
|
||||
|
||||
const deselectAllFilteredTags = () => {
|
||||
const filteredTagIds = new Set(filteredTags.value.map((tag) => tag.id));
|
||||
selectedTagIds.value = selectedTagIds.value.filter((tagId) => !filteredTagIds.has(tagId));
|
||||
};
|
||||
|
||||
const invertFilteredTagSelection = () => {
|
||||
const nextSelection = new Set(selectedTagIds.value);
|
||||
filteredTags.value.forEach((tag) => {
|
||||
if (nextSelection.has(tag.id)) {
|
||||
nextSelection.delete(tag.id);
|
||||
} else {
|
||||
nextSelection.add(tag.id);
|
||||
}
|
||||
});
|
||||
selectedTagIds.value = Array.from(nextSelection);
|
||||
};
|
||||
|
||||
const handleDeleteSelectedTags = async () => {
|
||||
if (selectedTagCount.value === 0) {
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'warning',
|
||||
message: t('connections.tagManagement.noSelection', '请先选择至少一个标签。'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await showConfirmDialog({
|
||||
message: deleteConnectionsTogether.value
|
||||
? t('connections.tagManagement.confirmDeleteWithConnections', {
|
||||
tagCount: selectedTagCount.value,
|
||||
connectionCount: affectedConnectionIds.value.length,
|
||||
})
|
||||
: t('connections.tagManagement.confirmDeleteKeepConnections', {
|
||||
tagCount: selectedTagCount.value,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const summary = await tagsStore.deleteTagsBatch(selectedTagIds.value, deleteConnectionsTogether.value);
|
||||
if (!summary) {
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'error',
|
||||
message: t('connections.tagManagement.errorDelete', { error: tagsStore.error || 'Unknown error' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'success',
|
||||
message: deleteConnectionsTogether.value
|
||||
? t('connections.tagManagement.successWithConnections', {
|
||||
tagCount: summary.deleted_tags_count,
|
||||
deletedConnectionsCount: summary.deleted_connections_count,
|
||||
})
|
||||
: t('connections.tagManagement.successKeepConnections', {
|
||||
tagCount: summary.deleted_tags_count,
|
||||
connectionCount: summary.affected_connections_count,
|
||||
}),
|
||||
});
|
||||
emit('deleted');
|
||||
handleClose();
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="internalVisible"
|
||||
class="fixed inset-0 z-50 bg-overlay flex items-center justify-center p-4"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<div class="w-full max-w-3xl max-h-[90vh] flex flex-col rounded-2xl border border-border bg-background text-foreground shadow-xl overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-border/60 bg-header/30">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold">{{ t('connections.tagManagement.title', '批量标签管理') }}</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
{{ t('connections.tagManagement.selectionSummary', { tagCount: selectedTagCount, connectionCount: affectedConnectionIds.length }) }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="h-10 px-4 rounded-xl border border-border bg-background text-foreground hover:bg-border transition-colors"
|
||||
@click="handleClose"
|
||||
>
|
||||
{{ t('common.cancel', '取消') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<div class="relative flex-1">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary text-sm"></i>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('connections.tagManagement.searchPlaceholder', '搜索标签名称...')"
|
||||
class="w-full h-11 pl-10 pr-4 rounded-xl border border-border/60 bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
class="h-11 px-4 rounded-xl border border-border bg-background text-foreground hover:bg-border transition-colors"
|
||||
@click="selectAllFilteredTags"
|
||||
>
|
||||
{{ t('connections.tagManagement.selectAll', '全选') }}
|
||||
</button>
|
||||
<button
|
||||
class="h-11 px-4 rounded-xl border border-border bg-background text-foreground hover:bg-border transition-colors"
|
||||
@click="deselectAllFilteredTags"
|
||||
>
|
||||
{{ t('connections.tagManagement.deselectAll', '取消全选') }}
|
||||
</button>
|
||||
<button
|
||||
class="h-11 px-4 rounded-xl border border-border bg-background text-foreground hover:bg-border transition-colors"
|
||||
@click="invertFilteredTagSelection"
|
||||
>
|
||||
{{ t('connections.tagManagement.invertSelection', '反选') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto px-5 py-4">
|
||||
<div v-if="isBusy && filteredTags.length === 0" class="flex items-center justify-center h-full text-text-secondary">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>{{ t('common.loading', '加载中') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="filteredTags.length === 0"
|
||||
class="h-full min-h-[260px] rounded-2xl border border-dashed border-border/70 bg-card/40 flex flex-col items-center justify-center text-center px-6"
|
||||
>
|
||||
<i class="fas fa-tags text-2xl text-text-secondary mb-3"></i>
|
||||
<p class="text-base font-medium text-foreground">
|
||||
{{
|
||||
tags.length === 0
|
||||
? t('connections.tagManagement.emptyTitle', '暂无可管理标签')
|
||||
: t('connections.tagManagement.emptySearch', '没有匹配的标签。')
|
||||
}}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-text-secondary">
|
||||
{{ t('connections.tagManagement.emptyDescription', '创建标签后即可在这里批量删除或清理。') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul v-else class="space-y-2">
|
||||
<li v-for="tag in filteredTags" :key="tag.id">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-2xl border px-4 py-3 text-left transition-colors flex items-center gap-3"
|
||||
:class="isTagSelected(tag.id)
|
||||
? 'border-primary/35 bg-primary/10 text-foreground'
|
||||
: 'border-border bg-card/50 text-foreground hover:bg-header/35'"
|
||||
@click="toggleTagSelection(tag.id)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-border bg-background text-primary focus:ring-primary"
|
||||
:checked="isTagSelected(tag.id)"
|
||||
@click.stop="toggleTagSelection(tag.id)"
|
||||
@change.stop
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-medium" :title="tag.name">{{ tag.name }}</div>
|
||||
<div class="mt-1 text-sm text-text-secondary">
|
||||
{{ t('connections.tagManagement.affectedConnections', { count: tag.connectionCount }) }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="px-2.5 py-1 rounded-full text-xs border border-current/15 bg-black/10">
|
||||
{{ tag.connectionCount }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4 border-t border-border/60 bg-background/90">
|
||||
<label class="flex items-start gap-3 rounded-2xl border border-error/20 bg-error/5 px-4 py-3">
|
||||
<input
|
||||
v-model="deleteConnectionsTogether"
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border bg-background text-error focus:ring-error"
|
||||
/>
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-foreground">
|
||||
{{ t('connections.tagManagement.deleteConnectionsLabel', '删除标签时一并删除标签下的所有服务器') }}
|
||||
</span>
|
||||
<span class="mt-1 block text-xs text-text-secondary">
|
||||
{{ t('connections.tagManagement.deleteConnectionsHint', '关闭后仅删除标签本身,原服务器会保留并归入“未标记”。') }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="mt-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ t('connections.tagManagement.selectionSummary', { tagCount: selectedTagCount, connectionCount: affectedConnectionIds.length }) }}
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
class="h-11 px-4 rounded-xl border border-border bg-background text-foreground hover:bg-border transition-colors"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleClose"
|
||||
>
|
||||
{{ t('common.cancel', '取消') }}
|
||||
</button>
|
||||
<button
|
||||
class="h-11 px-4 rounded-xl border border-red-600 bg-red-600 text-white hover:bg-red-700 hover:border-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2"
|
||||
:disabled="selectedTagCount === 0 || isSubmitting"
|
||||
@click="handleDeleteSelectedTags"
|
||||
>
|
||||
<i :class="['fas', isSubmitting ? 'fa-spinner fa-spin' : 'fa-trash-alt']"></i>
|
||||
<span>{{ t('connections.tagManagement.deleteButton', '删除选中标签') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -224,6 +224,28 @@
|
||||
"successMessage": "Selected connections have been successfully deleted.",
|
||||
"errorMessage": "Batch delete connections failed: {error}"
|
||||
},
|
||||
"tagManagement": {
|
||||
"openButton": "Tag Management",
|
||||
"title": "Bulk Tag Management",
|
||||
"searchPlaceholder": "Search tag names...",
|
||||
"emptyTitle": "No tags to manage yet",
|
||||
"emptyDescription": "Create tags first, then you can clean them up in bulk here.",
|
||||
"emptySearch": "No matching tags.",
|
||||
"selectionSummary": "{tagCount} tags selected, affecting {connectionCount} servers",
|
||||
"selectAll": "Select All",
|
||||
"deselectAll": "Deselect All",
|
||||
"invertSelection": "Invert Selection",
|
||||
"affectedConnections": "{count} servers",
|
||||
"deleteConnectionsLabel": "Delete all servers under the selected tags as well",
|
||||
"deleteConnectionsHint": "If disabled, only the tags are removed and the servers stay as Untagged.",
|
||||
"deleteButton": "Delete Selected Tags",
|
||||
"noSelection": "Select at least one tag first.",
|
||||
"confirmDeleteKeepConnections": "Delete the selected {tagCount} tags? The related servers will be kept and moved to Untagged.",
|
||||
"confirmDeleteWithConnections": "Delete the selected {tagCount} tags and the {connectionCount} affected servers? This cannot be undone.",
|
||||
"successKeepConnections": "{tagCount} tags deleted. {connectionCount} servers were moved to Untagged.",
|
||||
"successWithConnections": "{tagCount} tags deleted, and {deletedConnectionsCount} servers were deleted as well.",
|
||||
"errorDelete": "Failed to batch delete tags: {error}"
|
||||
},
|
||||
"actions": {
|
||||
"testAllFiltered":"Test All",
|
||||
"connect": "Connect",
|
||||
|
||||
@@ -134,6 +134,28 @@
|
||||
"successMessage": "選択した接続は正常に削除されました。",
|
||||
"errorMessage": "接続の一括削除に失敗しました: {error}"
|
||||
},
|
||||
"tagManagement": {
|
||||
"openButton": "タグ管理",
|
||||
"title": "タグ一括管理",
|
||||
"searchPlaceholder": "タグ名を検索...",
|
||||
"emptyTitle": "管理できるタグがありません",
|
||||
"emptyDescription": "タグを作成すると、ここで一括削除や整理ができます。",
|
||||
"emptySearch": "一致するタグがありません。",
|
||||
"selectionSummary": "{tagCount} 個のタグを選択中、{connectionCount} 台のサーバーに影響します",
|
||||
"selectAll": "すべて選択",
|
||||
"deselectAll": "選択解除",
|
||||
"invertSelection": "選択を反転",
|
||||
"affectedConnections": "{count} 台のサーバー",
|
||||
"deleteConnectionsLabel": "タグ削除時に、そのタグ配下のサーバーも削除する",
|
||||
"deleteConnectionsHint": "オフの場合はタグのみ削除され、サーバーは「タグなし」に残ります。",
|
||||
"deleteButton": "選択したタグを削除",
|
||||
"noSelection": "まず少なくとも1つのタグを選択してください。",
|
||||
"confirmDeleteKeepConnections": "選択した {tagCount} 個のタグを削除しますか?関連サーバーは保持され、「タグなし」に移動します。",
|
||||
"confirmDeleteWithConnections": "選択した {tagCount} 個のタグと、影響を受ける {connectionCount} 台のサーバーを削除しますか?この操作は元に戻せません。",
|
||||
"successKeepConnections": "{tagCount} 個のタグを削除し、{connectionCount} 台のサーバーを「タグなし」に移動しました。",
|
||||
"successWithConnections": "{tagCount} 個のタグを削除し、あわせて {deletedConnectionsCount} 台のサーバーも削除しました。",
|
||||
"errorDelete": "タグの一括削除に失敗しました: {error}"
|
||||
},
|
||||
"actions": {
|
||||
"testAllFiltered":"すべてテスト",
|
||||
"connect": "接続",
|
||||
|
||||
@@ -225,6 +225,28 @@
|
||||
"successMessage": "选中的连接已成功删除。",
|
||||
"errorMessage": "批量删除连接失败: {error}"
|
||||
},
|
||||
"tagManagement": {
|
||||
"openButton": "标签管理",
|
||||
"title": "批量标签管理",
|
||||
"searchPlaceholder": "搜索标签名称...",
|
||||
"emptyTitle": "暂无可管理标签",
|
||||
"emptyDescription": "创建标签后即可在这里批量删除或清理。",
|
||||
"emptySearch": "没有匹配的标签。",
|
||||
"selectionSummary": "已选择 {tagCount} 个标签,命中 {connectionCount} 台服务器",
|
||||
"selectAll": "全选",
|
||||
"deselectAll": "取消全选",
|
||||
"invertSelection": "反选",
|
||||
"affectedConnections": "{count} 台服务器",
|
||||
"deleteConnectionsLabel": "删除标签时一并删除标签下的所有服务器",
|
||||
"deleteConnectionsHint": "关闭后仅删除标签本身,原服务器会保留并归入“未标记”。",
|
||||
"deleteButton": "删除选中标签",
|
||||
"noSelection": "请先选择至少一个标签。",
|
||||
"confirmDeleteKeepConnections": "确定删除选中的 {tagCount} 个标签吗?关联服务器将保留并归入“未标记”。",
|
||||
"confirmDeleteWithConnections": "确定删除选中的 {tagCount} 个标签及其命中的 {connectionCount} 台服务器吗?此操作不可撤销。",
|
||||
"successKeepConnections": "已删除 {tagCount} 个标签,{connectionCount} 台服务器已归入“未标记”。",
|
||||
"successWithConnections": "已删除 {tagCount} 个标签,并同步删除 {deletedConnectionsCount} 台服务器。",
|
||||
"errorDelete": "批量删除标签失败: {error}"
|
||||
},
|
||||
"actions": {
|
||||
"testAllFiltered":"测试全部",
|
||||
"connect": "连接",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import apiClient from '../utils/apiClient'; // 使用统一的 apiClient
|
||||
import { useConnectionsStore } from './connections.store';
|
||||
|
||||
// 定义标签信息接口
|
||||
export interface TagInfo {
|
||||
@@ -10,10 +11,20 @@ export interface TagInfo {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface TagBatchDeleteSummary {
|
||||
deleted_tag_ids: number[];
|
||||
deleted_tags_count: number;
|
||||
affected_connection_ids: number[];
|
||||
affected_connections_count: number;
|
||||
deleted_connections_count: number;
|
||||
delete_connections: boolean;
|
||||
}
|
||||
|
||||
export const useTagsStore = defineStore('tags', () => {
|
||||
const tags = ref<TagInfo[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const connectionsStore = useConnectionsStore();
|
||||
|
||||
// 获取标签列表 (带缓存)
|
||||
async function fetchTags() {
|
||||
@@ -62,6 +73,15 @@ export const useTagsStore = defineStore('tags', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRelatedConnectionData() {
|
||||
localStorage.removeItem('tagsCache');
|
||||
localStorage.removeItem('connectionsCache');
|
||||
await Promise.all([
|
||||
fetchTags(),
|
||||
connectionsStore.fetchConnections(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 添加新标签 (添加后清除缓存)
|
||||
async function addTag(name: string): Promise<TagInfo | null> { // 修改返回类型
|
||||
isLoading.value = true;
|
||||
@@ -103,18 +123,30 @@ export const useTagsStore = defineStore('tags', () => {
|
||||
|
||||
// 删除标签
|
||||
async function deleteTag(id: number): Promise<boolean> {
|
||||
const summary = await deleteTagsBatch([id], false);
|
||||
return Boolean(summary && summary.deleted_tags_count > 0);
|
||||
}
|
||||
|
||||
async function deleteTagsBatch(tagIds: number[], deleteConnections: boolean): Promise<TagBatchDeleteSummary | null> {
|
||||
const normalizedTagIds = Array.from(new Set(tagIds.filter((tagId) => Number.isInteger(tagId) && tagId > 0)));
|
||||
if (normalizedTagIds.length === 0) {
|
||||
error.value = '至少需要选择一个标签';
|
||||
return null;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await apiClient.delete(`/tags/${id}`); // 使用 apiClient 并移除 base URL
|
||||
// 删除成功后,清除缓存并重新获取
|
||||
localStorage.removeItem('tagsCache');
|
||||
await fetchTags();
|
||||
return true;
|
||||
const response = await apiClient.post<{ message: string; summary: TagBatchDeleteSummary }>('/tags/bulk-delete', {
|
||||
tag_ids: normalizedTagIds,
|
||||
delete_connections: deleteConnections,
|
||||
});
|
||||
await refreshRelatedConnectionData();
|
||||
return response.data.summary;
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete tag:', err);
|
||||
error.value = err.response?.data?.message || err.message || '删除标签失败';
|
||||
return false;
|
||||
console.error('Failed to batch delete tags:', err);
|
||||
error.value = err.response?.data?.message || err.message || '批量删除标签失败';
|
||||
return null;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
@@ -128,18 +160,7 @@ export const useTagsStore = defineStore('tags', () => {
|
||||
// 假设后端 API 端点是 PUT /api/tags/:tagId/connections
|
||||
await apiClient.put(`/tags/${tagId}/connections`, { connection_ids: connectionIds });
|
||||
// 更新成功后,清除相关缓存并重新获取数据以确保一致性
|
||||
localStorage.removeItem('tagsCache'); // 清除标签缓存
|
||||
localStorage.removeItem('connectionsCache'); // 清除连接缓存,因为连接的 tag_ids 可能已更改
|
||||
|
||||
await fetchTags(); // 重新获取标签
|
||||
// 可能还需要通知 connectionsStore 重新获取连接,或者在这里直接调用
|
||||
// (这取决于您希望如何管理 store 间的依赖和数据同步)
|
||||
// 例如: const connectionsStore = useConnectionsStore(); await connectionsStore.fetchConnections();
|
||||
// 为简单起见,这里假设调用者会处理连接列表的刷新,或者依赖于后续的自动刷新机制。
|
||||
// 或者,更健壮的做法是在此 action 成功后,让 connectionsStore 也刷新。
|
||||
// 但为了减少此处的直接依赖,暂时只刷新 tagsStore。
|
||||
// WorkspaceConnectionList 在模态框保存成功后会重新 fetchConnections。
|
||||
|
||||
await refreshRelatedConnectionData();
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
console.error(`Failed to update connections for tag ${tagId}:`, err);
|
||||
@@ -158,6 +179,7 @@ export const useTagsStore = defineStore('tags', () => {
|
||||
addTag,
|
||||
updateTag,
|
||||
deleteTag,
|
||||
deleteTagsBatch,
|
||||
updateTagConnections, // 暴露新的 action
|
||||
};
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import AddConnectionForm from '../components/AddConnectionForm.vue';
|
||||
import BatchEditConnectionForm from '../components/BatchEditConnectionForm.vue';
|
||||
import LoginCredentialManagementModal from '../components/LoginCredentialManagementModal.vue';
|
||||
import ManageConnectionTagsModal from '../components/ManageConnectionTagsModal.vue';
|
||||
import { useConnectionsStore } from '../stores/connections.store';
|
||||
import { useProxiesStore } from '../stores/proxies.store';
|
||||
import { useSessionStore } from '../stores/session.store';
|
||||
@@ -97,6 +98,7 @@ const tagsSectionExpanded = ref(true);
|
||||
const showAddEditConnectionForm = ref(false);
|
||||
const connectionToEdit = ref<ConnectionInfo | null>(null);
|
||||
const showLoginCredentialManagement = ref(false);
|
||||
const showTagManagement = ref(false);
|
||||
|
||||
const isBatchEditMode = ref(false);
|
||||
const selectedConnectionIdsForBatch = ref<Set<number>>(new Set());
|
||||
@@ -400,6 +402,22 @@ const visibleTagTreeNodes = computed<TagTreeNode[]>(() => {
|
||||
return rows;
|
||||
});
|
||||
|
||||
const availableScopeIds = computed(() => {
|
||||
const ids = new Set<ScopeId>(['all', 'untagged']);
|
||||
|
||||
const walkNodes = (nodes: TagTreeNode[]) => {
|
||||
nodes.forEach((node) => {
|
||||
ids.add(node.id);
|
||||
if (node.children.length > 0) {
|
||||
walkNodes(node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
walkNodes(tagTreeNodes.value);
|
||||
return ids;
|
||||
});
|
||||
|
||||
const expandableTreeNodeIds = computed<ScopeId[]>(() => {
|
||||
const ids: ScopeId[] = [];
|
||||
|
||||
@@ -723,6 +741,16 @@ watch([selectedScope, activeTypeFilter, searchQuery], () => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
tagTreeNodes,
|
||||
() => {
|
||||
if (!availableScopeIds.value.has(selectedScope.value)) {
|
||||
selectedScope.value = 'all';
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const selectScope = (scopeId: ScopeId) => {
|
||||
selectedScope.value = scopeId;
|
||||
};
|
||||
@@ -822,6 +850,13 @@ const handleConnectionModified = async () => {
|
||||
await connectionsStore.fetchConnections();
|
||||
};
|
||||
|
||||
const handleTagsDeleted = () => {
|
||||
showTagManagement.value = false;
|
||||
if (!availableScopeIds.value.has(selectedScope.value)) {
|
||||
selectedScope.value = 'all';
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBatchEditMode = () => {
|
||||
isBatchEditMode.value = !isBatchEditMode.value;
|
||||
if (!isBatchEditMode.value) {
|
||||
@@ -1288,6 +1323,14 @@ onBeforeUnmount(() => {
|
||||
<i class="fas fa-plus"></i>
|
||||
<span>{{ t('connections.addConnection', '新增连接') }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="showTagManagement = true"
|
||||
class="h-11 px-4 rounded-xl border border-border bg-background text-foreground hover:bg-border transition-colors inline-flex items-center gap-2"
|
||||
:title="t('connections.tagManagement.openButton', '标签管理')"
|
||||
>
|
||||
<i class="fas fa-tags"></i>
|
||||
<span>{{ t('connections.tagManagement.openButton', '标签管理') }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="showLoginCredentialManagement = true"
|
||||
class="h-11 px-4 rounded-xl border border-border bg-background text-foreground hover:bg-border transition-colors inline-flex items-center gap-2"
|
||||
@@ -1632,6 +1675,13 @@ onBeforeUnmount(() => {
|
||||
v-if="showLoginCredentialManagement"
|
||||
@close="showLoginCredentialManagement = false"
|
||||
/>
|
||||
|
||||
<ManageConnectionTagsModal
|
||||
v-if="showTagManagement"
|
||||
:visible="showTagManagement"
|
||||
@update:visible="showTagManagement = $event"
|
||||
@deleted="handleTagsDeleted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user