重构(前端): 持久化快速命令排序和密码切换

添加持久化排序字段并重新排序快速命令和标签的端点,更新前端以支持手动拖放排序,并为连接和凭据表单添加密码可见性切换。此外,将 SSH 连接测试作为连接列表中的默认操作,并刷新相关模块文档和更改日志。
This commit is contained in:
yinjianm
2026-04-19 02:50:44 +08:00
parent 00d7c6c2f3
commit 8ce007a305
33 changed files with 1996 additions and 975 deletions
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import SshKeySelector from './SshKeySelector.vue'; // Assuming SshKeySelector is used here
import LoginCredentialSelector from './LoginCredentialSelector.vue';
@@ -19,6 +20,26 @@ const props = defineProps<{
}>();
const { t } = useI18n();
const visiblePasswordFields = reactive({
sshPassword: false,
rdpPassword: false,
vncPassword: false,
});
const resetPasswordVisibility = (): void => {
visiblePasswordFields.sshPassword = false;
visiblePasswordFields.rdpPassword = false;
visiblePasswordFields.vncPassword = false;
};
const togglePasswordVisibility = (field: keyof typeof visiblePasswordFields): void => {
visiblePasswordFields[field] = !visiblePasswordFields[field];
};
watch(() => props.formData.type, resetPasswordVisibility);
watch(() => props.formData.auth_method, resetPasswordVisibility);
watch(() => props.formData.credential_source, resetPasswordVisibility);
</script>
<template>
@@ -86,8 +107,20 @@ const { t } = useI18n();
<div v-if="props.formData.auth_method === 'password'">
<label for="conn-password" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.password') }}</label>
<input type="password" id="conn-password" v-model="props.formData.password" :required="props.formData.auth_method === 'password' && !isEditMode" autocomplete="new-password"
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
<div class="relative">
<input :type="visiblePasswordFields.sshPassword ? 'text' : 'password'" id="conn-password" v-model="props.formData.password" :required="props.formData.auth_method === 'password' && !isEditMode" autocomplete="new-password"
class="w-full px-3 py-2 pr-11 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
<button
type="button"
class="absolute inset-y-0 right-0 flex items-center px-3 text-text-secondary hover:text-foreground focus:outline-none focus:text-foreground"
:title="visiblePasswordFields.sshPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-label="visiblePasswordFields.sshPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-pressed="visiblePasswordFields.sshPassword"
@click="togglePasswordVisibility('sshPassword')"
>
<i :class="visiblePasswordFields.sshPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
</div>
<div v-if="props.formData.auth_method === 'key'" class="space-y-4">
@@ -102,8 +135,20 @@ const { t } = useI18n();
<template v-if="props.formData.type === 'RDP'">
<div>
<label for="conn-password-rdp" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.password') }}</label>
<input type="password" id="conn-password-rdp" v-model="props.formData.password" :required="!isEditMode" autocomplete="new-password"
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
<div class="relative">
<input :type="visiblePasswordFields.rdpPassword ? 'text' : 'password'" id="conn-password-rdp" v-model="props.formData.password" :required="!isEditMode" autocomplete="new-password"
class="w-full px-3 py-2 pr-11 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
<button
type="button"
class="absolute inset-y-0 right-0 flex items-center px-3 text-text-secondary hover:text-foreground focus:outline-none focus:text-foreground"
:title="visiblePasswordFields.rdpPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-label="visiblePasswordFields.rdpPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-pressed="visiblePasswordFields.rdpPassword"
@click="togglePasswordVisibility('rdpPassword')"
>
<i :class="visiblePasswordFields.rdpPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
</div>
</template>
@@ -111,8 +156,20 @@ const { t } = useI18n();
<template v-if="props.formData.type === 'VNC'">
<div>
<label for="conn-password-vnc" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.vncPassword', 'VNC 密码') }}</label>
<input type="password" id="conn-password-vnc" v-model="props.formData.vncPassword" :required="!isEditMode" autocomplete="new-password"
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
<div class="relative">
<input :type="visiblePasswordFields.vncPassword ? 'text' : 'password'" id="conn-password-vnc" v-model="props.formData.vncPassword" :required="!isEditMode" autocomplete="new-password"
class="w-full px-3 py-2 pr-11 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
<button
type="button"
class="absolute inset-y-0 right-0 flex items-center px-3 text-text-secondary hover:text-foreground focus:outline-none focus:text-foreground"
:title="visiblePasswordFields.vncPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-label="visiblePasswordFields.vncPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-pressed="visiblePasswordFields.vncPassword"
@click="togglePasswordVisibility('vncPassword')"
>
<i :class="visiblePasswordFields.vncPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
</div>
</template>
</template>
@@ -4,9 +4,9 @@
ref="modalContentRef"
class="bg-background text-foreground p-6 rounded-xl border border-border/50 shadow-2xl flex flex-col"
:style="{
width: resizableWidth ? `${resizableWidth}px` : undefined,
width: resizableWidth ? `${resizableWidth}px` : `min(calc(100vw - ${MODAL_VIEWPORT_GUTTER_PX}px), ${MODAL_DEFAULT_WIDTH_RATIO * 100}vw)`,
height: resizableHeight ? `${resizableHeight}px` : undefined,
maxWidth: 'calc(100vw - 2rem)',
maxWidth: `calc(100vw - ${MODAL_VIEWPORT_GUTTER_PX}px)`,
maxHeight: 'calc(100vh - 2rem)',
}"
>
@@ -183,6 +183,8 @@ const modalContentRef = ref<HTMLElement | null>(null);
const commandTextareaRef = ref<HTMLTextAreaElement | null>(null);
const R_MIN_WIDTH = 580; // 可调整大小的最小宽度 (像素)
const R_MIN_HEIGHT = 440; // 可调整大小的最小高度 (像素)
const MODAL_DEFAULT_WIDTH_RATIO = 0.6;
const MODAL_VIEWPORT_GUTTER_PX = 32;
const placeholder = t('quickCommands.form.commandPlaceholder') + 'echo "Hello,\${USERNAME}"'
const { width: resizableWidth, height: resizableHeight } = useResizable(modalContentRef, {
@@ -239,7 +241,7 @@ watch(() => formData.command, (newCommand) => {
// 初始化表单数据 (如果是编辑模式)
onMounted(() => {
if (typeof window !== 'undefined') {
let initialW = Math.min(window.innerWidth * 0.74, 860); // 目标 74vw,最大 860px
let initialW = Math.min(window.innerWidth * MODAL_DEFAULT_WIDTH_RATIO, window.innerWidth - MODAL_VIEWPORT_GUTTER_PX);
let initialH = Math.min(window.innerHeight * 0.68, 600); // 目标 68vh,最大 600px
initialW = Math.max(R_MIN_WIDTH, initialW);
@@ -39,6 +39,19 @@ const initialFormData: LoginCredentialInput = {
const formData = reactive({ ...initialFormData });
const formError = ref<string | null>(null);
const visiblePasswordFields = reactive({
sshPassword: false,
genericPassword: false,
});
const resetPasswordVisibility = (): void => {
visiblePasswordFields.sshPassword = false;
visiblePasswordFields.genericPassword = false;
};
const togglePasswordVisibility = (field: keyof typeof visiblePasswordFields): void => {
visiblePasswordFields[field] = !visiblePasswordFields[field];
};
watch(() => props.initialType, (newValue) => {
if (!credentialToEdit.value && newValue) {
@@ -47,12 +60,17 @@ watch(() => props.initialType, (newValue) => {
});
watch(() => formData.type, (newType) => {
resetPasswordVisibility();
if (newType !== 'SSH') {
formData.auth_method = 'password';
formData.ssh_key_id = null;
}
});
watch(() => formData.auth_method, () => {
resetPasswordVisibility();
});
onMounted(() => {
loginCredentialsStore.fetchLoginCredentials();
});
@@ -60,6 +78,7 @@ onMounted(() => {
const resetForm = () => {
Object.assign(formData, initialFormData, { type: props.initialType || 'SSH' });
formError.value = null;
resetPasswordVisibility();
};
const showAddForm = () => {
@@ -71,6 +90,7 @@ const showAddForm = () => {
const showEditForm = async (credential: LoginCredentialBasicInfo) => {
formError.value = null;
credentialToEdit.value = credential;
resetPasswordVisibility();
const details = await loginCredentialsStore.fetchLoginCredentialDetails(credential.id);
if (!details) {
@@ -300,7 +320,19 @@ const cancelForm = () => {
</div>
<div v-if="formData.auth_method === 'password'">
<label for="credential-password" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.password', '密码') }}</label>
<input id="credential-password" v-model="formData.password" type="password" autocomplete="new-password" class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
<div class="relative">
<input id="credential-password" v-model="formData.password" :type="visiblePasswordFields.sshPassword ? 'text' : 'password'" autocomplete="new-password" class="w-full px-3 py-2 pr-11 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
<button
type="button"
class="absolute inset-y-0 right-0 flex items-center px-3 text-text-secondary hover:text-foreground focus:outline-none focus:text-foreground"
:title="visiblePasswordFields.sshPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-label="visiblePasswordFields.sshPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-pressed="visiblePasswordFields.sshPassword"
@click="togglePasswordVisibility('sshPassword')"
>
<i :class="visiblePasswordFields.sshPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
</div>
<div v-else>
<label class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.sshKey', 'SSH 密钥') }}</label>
@@ -313,7 +345,19 @@ const cancelForm = () => {
<div v-else>
<label for="credential-password-generic" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.password', '密码') }}</label>
<input id="credential-password-generic" v-model="formData.password" type="password" autocomplete="new-password" class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
<div class="relative">
<input id="credential-password-generic" v-model="formData.password" :type="visiblePasswordFields.genericPassword ? 'text' : 'password'" autocomplete="new-password" class="w-full px-3 py-2 pr-11 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
<button
type="button"
class="absolute inset-y-0 right-0 flex items-center px-3 text-text-secondary hover:text-foreground focus:outline-none focus:text-foreground"
:title="visiblePasswordFields.genericPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-label="visiblePasswordFields.genericPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-pressed="visiblePasswordFields.genericPassword"
@click="togglePasswordVisibility('genericPassword')"
>
<i :class="visiblePasswordFields.genericPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
</div>
<div>
+2
View File
@@ -265,6 +265,8 @@
"authMethodPassword": "Password",
"authMethodKey": "SSH Key",
"password": "Password:",
"showPassword": "Show password",
"hidePassword": "Hide password",
"privateKey": "Private Key:",
"passphrase": "Passphrase:",
"vncPassword": "VNC Password",
+2
View File
@@ -196,6 +196,8 @@
"optional": "オプション",
"passphrase": "パスフレーズ:",
"password": "パスワード:",
"showPassword": "パスワードを表示",
"hidePassword": "パスワードを隠す",
"port": "ポート:",
"privateKey": "秘密鍵:",
"noSshKey":"SSHキーなし",
+2
View File
@@ -266,6 +266,8 @@
"authMethodPassword": "密码",
"authMethodKey": "SSH 密钥",
"password": "密码:",
"showPassword": "显示密码",
"hidePassword": "隐藏密码",
"privateKey": "私钥:",
"passphrase": "私钥密码:",
"vncPassword": "VNC 密码:",
@@ -3,62 +3,58 @@ import { ref } from 'vue';
import apiClient from '../utils/apiClient';
import { useUiNotificationsStore } from './uiNotifications.store';
// 定义快捷指令标签接口 (与后端 QuickCommandTag 对应)
export interface QuickCommandTag {
id: number;
name: string;
sort_order: number;
created_at: number;
updated_at: number;
}
const TAG_CACHE_KEY = 'quickCommandTagsCache';
const normalizeTag = (tag: any): QuickCommandTag => ({
id: Number(tag.id),
name: typeof tag.name === 'string' ? tag.name : '',
sort_order: Number.isFinite(tag.sort_order) ? Number(tag.sort_order) : 0,
created_at: Number(tag.created_at ?? 0),
updated_at: Number(tag.updated_at ?? 0),
});
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);
const cachedData = localStorage.getItem(TAG_CACHE_KEY);
if (cachedData) {
tags.value = JSON.parse(cachedData);
isLoading.value = false;
} else {
isLoading.value = true;
const parsedData = JSON.parse(cachedData);
if (Array.isArray(parsedData)) {
tags.value = parsedData.map(normalizeTag);
}
}
} catch (e) {
console.error('[QuickCmdTagStore] Failed to load or parse cache:', e);
localStorage.removeItem(cacheKey);
isLoading.value = true;
} catch (cacheError) {
console.error('[QuickCommandTagsStore] 读取标签缓存失败:', cacheError);
localStorage.removeItem(TAG_CACHE_KEY);
}
// 2. 后台获取最新数据
isLoading.value = true;
try {
// 使用新的 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) {
tags.value = freshData;
localStorage.setItem(cacheKey, freshDataString);
} else {
}
error.value = null;
const freshTags = Array.isArray(response.data) ? response.data.map(normalizeTag) : [];
tags.value = freshTags;
localStorage.setItem(TAG_CACHE_KEY, JSON.stringify(freshTags));
return true;
} catch (err: any) {
console.error('[QuickCmdTagStore] Failed to fetch tags:', err);
console.error('[QuickCommandTagsStore] 获取标签失败:', err);
error.value = err.response?.data?.message || err.message || '获取快捷指令标签列表失败';
if (error.value) { // Check if error.value is not null
uiNotificationsStore.showError(error.value); // 显示错误通知
if (error.value) {
uiNotificationsStore.showError(error.value);
}
return false;
} finally {
@@ -66,22 +62,19 @@ export const useQuickCommandTagsStore = defineStore('quickCommandTags', () => {
}
}
// 添加新快捷指令标签 (添加后清除缓存)
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(); // 重新获取以更新列表
const response = await apiClient.post<{ message: string; tag: QuickCommandTag }>('/quick-command-tags', { name });
localStorage.removeItem(TAG_CACHE_KEY);
await fetchTags();
uiNotificationsStore.showSuccess('快捷指令标签已添加');
return newTag;
return response.data.tag ? normalizeTag(response.data.tag) : null;
} catch (err: any) {
console.error('[QuickCmdTagStore] Failed to add tag:', err);
console.error('[QuickCommandTagsStore] 添加标签失败:', err);
error.value = err.response?.data?.message || err.message || '添加快捷指令标签失败';
if (error.value) { // Check if error.value is not null
if (error.value) {
uiNotificationsStore.showError(error.value);
}
return null;
@@ -90,21 +83,19 @@ export const useQuickCommandTagsStore = defineStore('quickCommandTags', () => {
}
}
// 更新快捷指令标签
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');
localStorage.removeItem(TAG_CACHE_KEY);
await fetchTags();
uiNotificationsStore.showSuccess('快捷指令标签已更新');
return true;
} catch (err: any) {
console.error('[QuickCmdTagStore] Failed to update tag:', err);
console.error('[QuickCommandTagsStore] 更新标签失败:', err);
error.value = err.response?.data?.message || err.message || '更新快捷指令标签失败';
if (error.value) { // Check if error.value is not null
if (error.value) {
uiNotificationsStore.showError(error.value);
}
return false;
@@ -113,21 +104,43 @@ export const useQuickCommandTagsStore = defineStore('quickCommandTags', () => {
}
}
// 删除快捷指令标签
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');
localStorage.removeItem(TAG_CACHE_KEY);
await fetchTags();
uiNotificationsStore.showSuccess('快捷指令标签已删除');
return true;
} catch (err: any) {
console.error('[QuickCmdTagStore] Failed to delete tag:', err);
console.error('[QuickCommandTagsStore] 删除标签失败:', err);
error.value = err.response?.data?.message || err.message || '删除快捷指令标签失败';
if (error.value) { // Check if error.value is not null
if (error.value) {
uiNotificationsStore.showError(error.value);
}
return false;
} finally {
isLoading.value = false;
}
}
async function reorderTags(tagIds: number[]): Promise<boolean> {
if (!Array.isArray(tagIds) || tagIds.length === 0) {
return false;
}
isLoading.value = true;
error.value = null;
try {
await apiClient.put('/quick-command-tags/reorder', { tagIds });
localStorage.removeItem(TAG_CACHE_KEY);
await fetchTags();
return true;
} catch (err: any) {
console.error('[QuickCommandTagsStore] 更新标签顺序失败:', err);
error.value = err.response?.data?.message || err.message || '更新快捷指令标签顺序失败';
if (error.value) {
uiNotificationsStore.showError(error.value);
}
return false;
@@ -144,5 +157,6 @@ export const useQuickCommandTagsStore = defineStore('quickCommandTags', () => {
addTag,
updateTag,
deleteTag,
reorderTags,
};
});
});
@@ -1,170 +1,221 @@
import { defineStore } from 'pinia';
import apiClient from '../utils/apiClient';
import { ref, computed, watch } from 'vue';
import apiClient from '../utils/apiClient';
import { ref, computed, watch } from 'vue';
import { useUiNotificationsStore } from './uiNotifications.store';
import { useQuickCommandTagsStore, type QuickCommandTag } from './quickCommandTags.store';
import { useI18n } from 'vue-i18n';
import { useQuickCommandTagsStore } from './quickCommandTags.store';
import { useSettingsStore } from './settings.store';
import { useI18n } from 'vue-i18n';
// 定义前端使用的快捷指令接口 (包含 tagIds)
export interface QuickCommandFE { // Renamed from QuickCommand if necessary
export interface QuickCommandFE {
id: number;
name: string | null;
command: string;
usage_count: number;
sort_order: number;
created_at: number;
updated_at: number;
tagIds: number[]; // +++ Add tagIds +++
variables?: Record<string, string>; // New: Add variables
tagIds: number[];
tagOrders: Record<number, number>;
variables?: Record<string, string> | null;
}
// 定义排序类型
export type QuickCommandSortByType = 'name' | 'usage_count' | 'last_used';
export type QuickCommandSortByType = 'manual' | 'name' | 'usage_count' | 'last_used';
// 定义分组后的数据结构
export interface GroupedQuickCommands {
groupName: string;
tagId: number | null; // null for "Untagged" group
tagId: number | null;
commands: QuickCommandFE[];
}
// +++ localStorage key for expanded groups +++
const EXPANDED_GROUPS_STORAGE_KEY = 'quickCommandsExpandedGroups';
const QUICK_COMMANDS_CACHE_KEY = 'quickCommandsListCache';
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const normalizeTagOrders = (tagOrders: unknown): Record<number, number> => {
if (!isRecord(tagOrders)) {
return {};
}
return Object.entries(tagOrders).reduce<Record<number, number>>((result, [tagId, sortOrder]) => {
const numericTagId = Number(tagId);
const numericSortOrder = Number(sortOrder);
if (Number.isInteger(numericTagId) && Number.isFinite(numericSortOrder)) {
result[numericTagId] = numericSortOrder;
}
return result;
}, {});
};
const normalizeQuickCommand = (command: any): QuickCommandFE => ({
id: Number(command.id),
name: typeof command.name === 'string' ? command.name : null,
command: typeof command.command === 'string' ? command.command : '',
usage_count: Number(command.usage_count ?? 0),
sort_order: Number(command.sort_order ?? 0),
created_at: Number(command.created_at ?? 0),
updated_at: Number(command.updated_at ?? 0),
tagIds: Array.isArray(command.tagIds)
? Array.from(new Set(command.tagIds.filter((tagId: unknown) => Number.isInteger(tagId) && Number(tagId) > 0)))
: [],
tagOrders: normalizeTagOrders(command.tagOrders),
variables: isRecord(command.variables) ? (command.variables as Record<string, string>) : undefined,
});
const compareByLabel = (a: QuickCommandFE, b: QuickCommandFE): number => {
const labelA = a.name ?? a.command;
const labelB = b.name ?? b.command;
return labelA.localeCompare(labelB);
};
const compareCommands = (
a: QuickCommandFE,
b: QuickCommandFE,
sortBy: QuickCommandSortByType,
tagId?: number | null,
): number => {
if (sortBy === 'manual') {
if (typeof tagId === 'number') {
const tagOrderA = a.tagOrders[tagId];
const tagOrderB = b.tagOrders[tagId];
if (typeof tagOrderA === 'number' && typeof tagOrderB === 'number' && tagOrderA !== tagOrderB) {
return tagOrderA - tagOrderB;
}
if (typeof tagOrderA === 'number' && typeof tagOrderB !== 'number') {
return -1;
}
if (typeof tagOrderA !== 'number' && typeof tagOrderB === 'number') {
return 1;
}
}
if (a.sort_order !== b.sort_order) {
return a.sort_order - b.sort_order;
}
}
if (sortBy === 'usage_count' && a.usage_count !== b.usage_count) {
return b.usage_count - a.usage_count;
}
if (sortBy === 'last_used' && a.updated_at !== b.updated_at) {
return b.updated_at - a.updated_at;
}
return compareByLabel(a, b);
};
export const useQuickCommandsStore = defineStore('quickCommands', () => {
const quickCommandsList = ref<QuickCommandFE[]>([]); // Should now contain QuickCommandFE with tagIds
const quickCommandsList = ref<QuickCommandFE[]>([]);
const searchTerm = ref('');
const sortBy = ref<QuickCommandSortByType>('name'); // 默认按名称排序
const sortBy = ref<QuickCommandSortByType>('manual');
const isLoading = ref(false);
const error = ref<string | null>(null);
const uiNotificationsStore = useUiNotificationsStore();
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 selectedIndex = ref<number>(-1);
const expandedGroups = ref<Record<string, boolean>>({});
// --- Getters ---
const uiNotificationsStore = useUiNotificationsStore();
const quickCommandTagsStore = useQuickCommandTagsStore();
const settingsStore = useSettingsStore();
const { t } = useI18n();
// +++ 重写 Getter: 过滤、分组、排序指令 +++
const filteredAndGroupedCommands = computed((): GroupedQuickCommands[] => {
const filteredCommands = computed(() => {
const term = searchTerm.value.toLowerCase().trim();
const allTags = quickCommandTagsStore.tags; // 获取快捷指令专属标签
const tagMap = new Map(allTags.map(tag => [tag.id, tag.name]));
const untaggedGroupName = t('quickCommands.untagged', '未标记'); // 获取 "未标记" 的翻译
if (!term) {
return quickCommandsList.value;
}
// 1. 过滤 (New logic: filter by command name, command content, OR tag name)
let filtered = quickCommandsList.value;
if (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;
const tagMap = new Map(quickCommandTagsStore.tags.map((tag) => [tag.id, tag.name]));
return quickCommandsList.value.filter((command) => {
if (command.name && command.name.toLowerCase().includes(term)) {
return true;
}
if (command.command.toLowerCase().includes(term)) {
return true;
}
return command.tagIds.some((tagId) => {
const tagName = tagMap.get(tagId);
return typeof tagName === 'string' && tagName.toLowerCase().includes(term);
});
});
});
const sortedFlatCommands = computed(() => {
return [...filteredCommands.value].sort((a, b) => compareCommands(a, b, sortBy.value));
});
const filteredAndGroupedCommands = computed((): GroupedQuickCommands[] => {
const untaggedGroupName = t('quickCommands.untagged', '未标记');
const sortedTags = [...quickCommandTagsStore.tags].sort((a, b) => {
if (a.sort_order !== b.sort_order) {
return a.sort_order - b.sort_order;
}
return a.name.localeCompare(b.name);
});
const groups = new Map<number, GroupedQuickCommands>();
const untaggedCommands: QuickCommandFE[] = [];
for (const tag of sortedTags) {
if (expandedGroups.value[tag.name] === undefined) {
expandedGroups.value[tag.name] = true;
}
groups.set(tag.id, {
groupName: tag.name,
tagId: tag.id,
commands: [],
});
}
// 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;
}
});
for (const command of filteredCommands.value) {
const validTagIds = command.tagIds.filter((tagId) => groups.has(tagId));
if (validTagIds.length === 0) {
untaggedCommands.push(command);
continue;
}
if (!isTagged) {
untaggedCommands.push(cmd);
for (const tagId of validTagIds) {
groups.get(tagId)!.commands.push(command);
}
});
}
// 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;
} else if (sortBy.value === 'last_used') {
if (b.updated_at !== a.updated_at) return b.updated_at - a.updated_at;
}
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
};
});
const result: GroupedQuickCommands[] = [];
for (const tag of sortedTags) {
const group = groups.get(tag.id);
if (!group || group.commands.length === 0) {
continue;
}
group.commands.sort((a, b) => compareCommands(a, b, sortBy.value, tag.id));
result.push(group);
}
// 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;
} else if (sortBy.value === 'last_used') {
if (b.updated_at !== a.updated_at) return b.updated_at - a.updated_at;
}
const nameA = a.name ?? a.command;
const nameB = b.name ?? b.command;
return nameA.localeCompare(nameB);
});
result.push({
groupName: untaggedGroupName,
tagId: null,
commands: untaggedCommands
});
if (expandedGroups.value[untaggedGroupName] === undefined) {
expandedGroups.value[untaggedGroupName] = true;
}
result.push({
groupName: untaggedGroupName,
tagId: null,
commands: [...untaggedCommands].sort((a, b) => compareCommands(a, b, sortBy.value, null)),
});
}
return result;
});
// +++ Getter: 获取当前可见的扁平指令列表 (用于键盘导航) +++
const flatVisibleCommands = computed((): QuickCommandFE[] => {
if (!settingsStore.showQuickCommandTagsBoolean) {
return sortedFlatCommands.value;
}
const flatList: QuickCommandFE[] = [];
filteredAndGroupedCommands.value.forEach(group => {
// 只添加已展开分组中的指令
filteredAndGroupedCommands.value.forEach((group) => {
if (expandedGroups.value[group.groupName]) {
flatList.push(...group.commands);
}
@@ -172,130 +223,91 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
return flatList;
});
// --- Actions ---
// +++ 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.');
if (isRecord(parsedState)) {
expandedGroups.value = Object.entries(parsedState).reduce<Record<string, boolean>>((result, [key, value]) => {
result[key] = Boolean(value);
return result;
}, {});
return;
}
}
} catch (e) {
console.error('[QuickCmdStore] Failed to load or parse expanded groups state:', e);
} catch (cacheError) {
console.error('[QuickCommandsStore] 读取分组展开状态失败:', cacheError);
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);
} catch (cacheError) {
console.error('[QuickCommandsStore] 保存分组展开状态失败:', cacheError);
}
};
// +++ 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;
expandedGroups.value[groupName] = expandedGroups.value[groupName] === undefined
? false
: !expandedGroups.value[groupName];
};
// Action to select the next command in the *visible* flat list
const selectNextCommand = () => {
const commands = flatVisibleCommands.value; // Use the flat visible list
const commands = flatVisibleCommands.value;
if (commands.length === 0) {
selectedIndex.value = -1;
return;
}
selectedIndex.value = (selectedIndex.value + 1) % commands.length;
};
// Action to select the previous command in the *visible* flat list
const selectPreviousCommand = () => {
const commands = flatVisibleCommands.value; // Use the flat visible list
const commands = flatVisibleCommands.value;
if (commands.length === 0) {
selectedIndex.value = -1;
return;
}
selectedIndex.value = (selectedIndex.value - 1 + commands.length) % commands.length;
};
// 从后端获取快捷指令 (包含 tagIds,不再发送 sortBy)
const clearQuickCommandsCache = () => {
localStorage.removeItem(QUICK_COMMANDS_CACHE_KEY);
};
const fetchQuickCommands = async () => {
// 简化缓存:只缓存原始列表,不再区分排序
const cacheKey = 'quickCommandsListCache';
error.value = null;
// 1. 尝试从 localStorage 加载缓存
try {
const cachedData = localStorage.getItem(cacheKey);
const cachedData = localStorage.getItem(QUICK_COMMANDS_CACHE_KEY);
if (cachedData) {
// 确保解析后的数据符合 QuickCommandFE 结构 (特别是 tagIds 和 variables)
const parsedData = JSON.parse(cachedData) as QuickCommandFE[];
// 基本验证,确保 tagIds 是数组,variables 是对象或undefined
if (Array.isArray(parsedData) && parsedData.every(item => Array.isArray(item.tagIds) && (item.variables === undefined || typeof item.variables === 'object'))) {
quickCommandsList.value = parsedData;
isLoading.value = false;
} else {
console.warn('[QuickCmdStore] Cached data format invalid, ignoring cache.');
localStorage.removeItem(cacheKey);
isLoading.value = true;
const parsedData = JSON.parse(cachedData);
if (Array.isArray(parsedData)) {
quickCommandsList.value = parsedData.map(normalizeQuickCommand);
}
} else {
isLoading.value = true;
}
} catch (e) {
console.error('[QuickCmdStore] Failed to load or parse commands cache:', e);
localStorage.removeItem(cacheKey);
isLoading.value = true;
} catch (cacheError) {
console.error('[QuickCommandsStore] 读取快捷指令缓存失败:', cacheError);
clearQuickCommandsCache();
}
// 2. 后台获取最新数据
isLoading.value = true;
try {
console.log(`[QuickCmdStore] Fetching latest commands from server...`);
// 不再发送 sortBy 参数
const response = await apiClient.get<QuickCommandFE[]>('/quick-commands');
// 确保返回的数据包含 tagIds 数组和 variables 对象
const freshData = response.data.map(cmd => ({
...cmd,
tagIds: Array.isArray(cmd.tagIds) ? cmd.tagIds : [], // 确保 tagIds 是数组
variables: typeof cmd.variables === 'object' ? cmd.variables : undefined // 确保 variables 是对象或 undefined
}));
const freshDataString = JSON.stringify(freshData);
// 3. 对比并更新
const currentDataString = JSON.stringify(quickCommandsList.value);
if (currentDataString !== freshDataString) {
console.log('[QuickCmdStore] Commands data changed, updating state and cache.');
quickCommandsList.value = freshData;
localStorage.setItem(cacheKey, freshDataString); // 更新缓存
} else {
}
error.value = null;
const freshData = Array.isArray(response.data) ? response.data.map(normalizeQuickCommand) : [];
quickCommandsList.value = freshData;
localStorage.setItem(QUICK_COMMANDS_CACHE_KEY, JSON.stringify(freshData));
} catch (err: any) {
console.error('[QuickCmdStore] 获取快捷指令失败:', err);
console.error('[QuickCommandsStore] 获取快捷指令失败:', err);
error.value = err.response?.data?.message || '获取快捷指令时发生错误';
if (error.value) {
uiNotificationsStore.showError(error.value);
@@ -305,109 +317,154 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
}
};
// 清除快捷指令列表缓存
const clearQuickCommandsCache = () => {
localStorage.removeItem('quickCommandsListCache');
console.log('[QuickCmdStore] Cleared quick commands list cache.');
};
// 添加快捷指令 (发送 tagIds 和 variables)
const addQuickCommand = async (name: string | null, command: string, tagIds?: number[], variables?: Record<string, string>): Promise<boolean> => {
const addQuickCommand = async (
name: string | null,
command: string,
tagIds?: number[],
variables?: Record<string, string>,
): Promise<boolean> => {
try {
// 在请求体中包含 tagIds 和 variables
const response = await apiClient.post<{ message: string, command: QuickCommandFE }>('/quick-commands', { name, command, tagIds, variables });
// 后端现在返回完整的 command 对象,可以直接使用或触发刷新
clearQuickCommandsCache(); // 清除缓存
await fetchQuickCommands(); // 重新获取以确保数据同步
await apiClient.post('/quick-commands', { name, command, tagIds, variables });
clearQuickCommandsCache();
await fetchQuickCommands();
uiNotificationsStore.showSuccess('快捷指令已添加');
return true;
} catch (err: any) {
console.error('添加快捷指令失败:', err);
console.error('[QuickCommandsStore] 添加快捷指令失败:', err);
const message = err.response?.data?.message || '添加快捷指令时发生错误';
uiNotificationsStore.showError(message);
return false;
}
};
// 更新快捷指令 (发送 tagIds 和 variables)
const updateQuickCommand = async (id: number, name: string | null, command: string, tagIds?: number[], variables?: Record<string, string>): Promise<boolean> => {
try {
// 在请求体中包含 tagIds 和 variables (即使是 undefined 也要发送,让后端知道是否要更新)
const response = await apiClient.put<{ message: string, command: QuickCommandFE }>(`/quick-commands/${id}`, { name, command, tagIds, variables });
// 后端现在返回完整的 command 对象
clearQuickCommandsCache(); // 清除缓存
await fetchQuickCommands(); // 重新获取以确保数据同步
const updateQuickCommand = async (
id: number,
name: string | null,
command: string,
tagIds?: number[],
variables?: Record<string, string>,
): Promise<boolean> => {
try {
await apiClient.put(`/quick-commands/${id}`, { name, command, tagIds, variables });
clearQuickCommandsCache();
await fetchQuickCommands();
uiNotificationsStore.showSuccess('快捷指令已更新');
return true;
} catch (err: any) {
console.error('更新快捷指令失败:', err);
console.error('[QuickCommandsStore] 更新快捷指令失败:', err);
const message = err.response?.data?.message || '更新快捷指令时发生错误';
uiNotificationsStore.showError(message);
return false;
}
};
// 删除快捷指令
const deleteQuickCommand = async (id: number) => {
try {
await apiClient.delete(`/quick-commands/${id}`);
clearQuickCommandsCache(); // 清除所有排序缓存
// 从本地列表中移除
const index = quickCommandsList.value.findIndex(cmd => cmd.id === id);
if (index !== -1) {
quickCommandsList.value.splice(index, 1);
}
quickCommandsList.value = quickCommandsList.value.filter((command) => command.id !== id);
clearQuickCommandsCache();
uiNotificationsStore.showSuccess('快捷指令已删除');
} catch (err: any) {
console.error('删除快捷指令失败:', err);
console.error('[QuickCommandsStore] 删除快捷指令失败:', err);
const message = err.response?.data?.message || '删除快捷指令时发生错误';
uiNotificationsStore.showError(message);
}
};
// 增加使用次数 (调用 API,然后更新本地数据)
const incrementUsage = async (id: number) => {
try {
await apiClient.post(`/quick-commands/${id}/increment-usage`); // 使用 apiClient
// 更新本地计数,避免重新请求整个列表
const command = quickCommandsList.value.find(cmd => cmd.id === id);
try {
await apiClient.post(`/quick-commands/${id}/increment-usage`);
const command = quickCommandsList.value.find((item) => item.id === id);
if (command) {
command.usage_count += 1;
// 如果当前是按使用次数排序,可能需要重新排序或刷新列表
if (sortBy.value === 'usage_count') {
// 清除所有排序缓存并重新获取当前排序
clearQuickCommandsCache();
await fetchQuickCommands();
}
command.updated_at = Math.floor(Date.now() / 1000);
}
} catch (err: any) {
console.error('增加使用次数失败:', err);
// 这里可以选择不提示用户错误,因为这是一个后台操作
} catch (err) {
console.error('[QuickCommandsStore] 增加快捷指令使用次数失败:', err);
}
};
// 设置搜索词
const setSearchTerm = (term: string) => {
searchTerm.value = term;
selectedIndex.value = -1; // Reset selection when search term changes
selectedIndex.value = -1;
};
// 设置排序方式 (只更新本地状态,不再重新获取数据)
const setSortBy = (newSortBy: QuickCommandSortByType) => {
if (sortBy.value !== newSortBy) {
sortBy.value = newSortBy;
// 排序现在由 filteredAndGroupedCommands getter 处理,无需重新 fetch
selectedIndex.value = -1; // Reset selection when sort changes
selectedIndex.value = -1;
}
};
// Action to reset the selection
const resetSelection = () => {
selectedIndex.value = -1;
};
// Removed duplicate resetSelection definition
const reorderQuickCommands = async (commandIds: number[]): Promise<boolean> => {
if (!Array.isArray(commandIds) || commandIds.length === 0) {
return false;
}
try {
setSortBy('manual');
await apiClient.put('/quick-commands/reorder', { commandIds });
clearQuickCommandsCache();
await fetchQuickCommands();
return true;
} catch (err: any) {
console.error('[QuickCommandsStore] 更新快捷指令顺序失败:', err);
const message = err.response?.data?.message || '更新快捷指令顺序失败';
uiNotificationsStore.showError(message);
return false;
}
};
const reorderCommandsInTag = async (tagId: number, commandIds: number[]): Promise<boolean> => {
if (!Number.isInteger(tagId) || !Array.isArray(commandIds) || commandIds.length === 0) {
return false;
}
try {
setSortBy('manual');
await apiClient.put('/quick-commands/reorder-by-tag', { tagId, commandIds });
clearQuickCommandsCache();
await fetchQuickCommands();
return true;
} catch (err: any) {
console.error('[QuickCommandsStore] 更新标签内快捷指令顺序失败:', err);
const message = err.response?.data?.message || '更新标签内快捷指令顺序失败';
uiNotificationsStore.showError(message);
return false;
}
};
const assignCommandsToTagAction = async (commandIds: number[], tagId: number): Promise<boolean> => {
if (!Array.isArray(commandIds) || commandIds.length === 0) {
return false;
}
isLoading.value = true;
error.value = null;
try {
const response = await apiClient.post('/quick-commands/bulk-assign-tag', { commandIds, tagId });
if (!response.data?.success) {
throw new Error(response.data?.message || '批量分配标签失败');
}
clearQuickCommandsCache();
await fetchQuickCommands();
return true;
} catch (err: any) {
console.error('[QuickCommandsStore] 批量分配标签失败:', err);
error.value = err.response?.data?.message || err.message || '批量分配标签失败';
if (error.value) {
uiNotificationsStore.showError(error.value);
}
return false;
} finally {
isLoading.value = false;
}
};
return {
quickCommandsList,
@@ -415,10 +472,10 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
sortBy,
isLoading,
error,
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
filteredAndGroupedCommands,
flatVisibleCommands,
selectedIndex,
expandedGroups,
fetchQuickCommands,
addQuickCommand,
updateQuickCommand,
@@ -429,60 +486,11 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
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.`);
// 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;
}
},
toggleGroup,
loadExpandedGroups,
clearQuickCommandsCache,
reorderQuickCommands,
reorderCommandsInTag,
assignCommandsToTagAction,
};
});
+11 -10
View File
@@ -1597,6 +1597,17 @@ onBeforeUnmount(() => {
<span>{{ t('connections.actions.connect', '连接') }}</span>
</button>
<button
v-if="conn.type === 'SSH'"
:disabled="getSingleTestButtonInfo(conn.id, conn.type).disabled"
:title="getSingleTestButtonInfo(conn.id, conn.type).title"
class="px-4 py-2 rounded-lg border border-border bg-background text-foreground hover:bg-border transition-colors inline-flex items-center gap-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
@click.stop="handleTestSingleConnection(conn)"
>
<i :class="getSingleTestButtonInfo(conn.id, conn.type).iconClass"></i>
<span>{{ getSingleTestButtonInfo(conn.id, conn.type).text }}</span>
</button>
<div class="relative">
<button
@click.stop="toggleMoreMenu(conn.id)"
@@ -1618,16 +1629,6 @@ onBeforeUnmount(() => {
<i class="fas fa-pen w-4 text-center"></i>
<span>{{ t('connections.actions.edit', '编辑') }}</span>
</button>
<button
v-if="conn.type === 'SSH'"
:disabled="getSingleTestButtonInfo(conn.id, conn.type).disabled"
:title="getSingleTestButtonInfo(conn.id, conn.type).title"
class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-header transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
@click.stop="handleTestSingleConnection(conn); closeMoreMenu()"
>
<i :class="[getSingleTestButtonInfo(conn.id, conn.type).iconClass, 'w-4 text-center']"></i>
<span>{{ getSingleTestButtonInfo(conn.id, conn.type).text }}</span>
</button>
<button
class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-header transition-colors flex items-center gap-2"
@click.stop="handleCloneConnection(conn); closeMoreMenu()"
@@ -68,8 +68,17 @@
<div
class="group font-semibold flex items-center text-foreground rounded-md hover:bg-header/80 transition-colors duration-150"
:style="{ padding: isCompactMode ? `calc(0.25rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` : `calc(0.5rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` }"
:class="{ 'cursor-pointer': editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId) }"
:draggable="groupData.tagId !== null && !dragDisabledBySearch"
:class="{
'cursor-pointer': editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId),
'cursor-grab': groupData.tagId !== null && !dragDisabledBySearch,
'qc-drop-target': isGroupDropTarget(groupData.tagId),
}"
@click="editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId) ? toggleGroup(groupData.groupName) : null"
@dragstart="handleGroupDragStart($event, groupData.tagId)"
@dragover.prevent="handleGroupDragOver(groupData.tagId)"
@drop.prevent="handleGroupDrop(groupData.tagId)"
@dragend="resetDragState"
>
<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]}]"
@@ -114,10 +123,20 @@
:title="cmd.command"
class="group flex justify-between items-center mb-1 cursor-pointer rounded-md hover:bg-primary/10 transition-colors duration-150"
:style="{ padding: isCompactMode ? `calc(0.1rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` : `calc(0.625rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` }"
:class="{ 'bg-primary/20 font-medium': isCommandSelected(cmd.id) }"
:draggable="!dragDisabledBySearch"
:class="{
'bg-primary/20 font-medium': isCommandSelected(cmd.id),
'cursor-grab': !dragDisabledBySearch,
'qc-drop-target': isCommandDropTarget(cmd.id, groupData.tagId),
'opacity-70': isDraggingCommand(cmd.id, groupData.tagId),
}"
@click="selectCommand(cmd.id)"
@dblclick="executeCommand(cmd)"
@contextmenu.prevent="showQuickCommandContextMenu($event, cmd)"
@dragstart="handleCommandDragStart($event, cmd.id, groupData.tagId)"
@dragover.prevent="handleCommandDragOver(cmd.id, groupData.tagId)"
@drop.prevent="handleCommandDrop(cmd.id, groupData.tagId)"
@dragend="resetDragState"
>
<!-- Command Info -->
<div class="flex flex-col overflow-hidden mr-2 flex-grow">
@@ -162,10 +181,20 @@
:title="cmd.command"
class="group flex justify-between items-center mb-1 cursor-pointer rounded-md hover:bg-primary/10 transition-colors duration-150"
:style="{ padding: isCompactMode ? `calc(0.1rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` : `calc(0.625rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` }"
:class="{ 'bg-primary/20 font-medium': isCommandSelected(cmd.id) }"
:draggable="!dragDisabledBySearch"
:class="{
'bg-primary/20 font-medium': isCommandSelected(cmd.id),
'cursor-grab': !dragDisabledBySearch,
'qc-drop-target': isCommandDropTarget(cmd.id, null),
'opacity-70': isDraggingCommand(cmd.id, null),
}"
@click="selectCommand(cmd.id)"
@dblclick="executeCommand(cmd)"
@contextmenu.prevent="showQuickCommandContextMenu($event, cmd)"
@dragstart="handleCommandDragStart($event, cmd.id, null)"
@dragover.prevent="handleCommandDragOver(cmd.id, null)"
@drop.prevent="handleCommandDrop(cmd.id, null)"
@dragend="resetDragState"
>
<!-- Command Info -->
<div class="flex flex-col overflow-hidden mr-2 flex-grow">
@@ -379,6 +408,17 @@ const flatFilteredCommands = computed(() => {
return quickCommandsStore.flatVisibleCommands;
});
const dragDisabledBySearch = computed(() => searchTerm.value.trim().length > 0);
const draggingGroupTagId = ref<number | null>(null);
const groupDropTargetTagId = ref<number | null>(null);
const draggingCommand = ref<{ commandId: number; groupTagId: number | null } | null>(null);
const commandDropTarget = ref<{ commandId: number; groupTagId: number | null } | null>(null);
const dragDisabledTitle = computed(() =>
dragDisabledBySearch.value
? t('quickCommands.dragDisabledBySearch', '搜索结果中不可拖动排序')
: t('quickCommands.dragCommand', '拖动排序快捷指令')
);
// --- Compact Mode ---
const isCompactMode = computed(() => quickCommandsCompactModeBoolean.value);
@@ -403,6 +443,163 @@ const selectCommand = (commandId: number) => {
// --- ---
const resetDragState = () => {
draggingGroupTagId.value = null;
groupDropTargetTagId.value = null;
draggingCommand.value = null;
commandDropTarget.value = null;
};
const moveById = <T extends { id: number }>(items: T[], sourceId: number, targetId: number): T[] => {
const clonedItems = [...items];
const sourceIndex = clonedItems.findIndex((item) => item.id === sourceId);
const targetIndex = clonedItems.findIndex((item) => item.id === targetId);
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) {
return clonedItems;
}
const [sourceItem] = clonedItems.splice(sourceIndex, 1);
clonedItems.splice(targetIndex, 0, sourceItem);
return clonedItems;
};
const isGroupDropTarget = (tagId: number | null): boolean =>
tagId !== null && groupDropTargetTagId.value === tagId;
const isDraggingCommand = (commandId: number, groupTagId: number | null): boolean =>
draggingCommand.value?.commandId === commandId && draggingCommand.value?.groupTagId === groupTagId;
const isCommandDropTarget = (commandId: number, groupTagId: number | null): boolean =>
commandDropTarget.value?.commandId === commandId && commandDropTarget.value?.groupTagId === groupTagId;
const handleGroupDragStart = (event: DragEvent, tagId: number | null) => {
if (dragDisabledBySearch.value || tagId === null) {
event.preventDefault();
return;
}
draggingGroupTagId.value = tagId;
groupDropTargetTagId.value = null;
event.dataTransfer?.setData('text/plain', String(tagId));
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
}
};
const handleGroupDragOver = (tagId: number | null) => {
if (draggingGroupTagId.value === null || dragDisabledBySearch.value || tagId === null || tagId === draggingGroupTagId.value) {
return;
}
groupDropTargetTagId.value = tagId;
};
const handleGroupDrop = async (tagId: number | null) => {
if (draggingGroupTagId.value === null || dragDisabledBySearch.value || tagId === null || tagId === draggingGroupTagId.value) {
resetDragState();
return;
}
const taggedGroups = filteredAndGroupedCommands.value
.filter((group) => group.tagId !== null)
.map((group) => ({ ...group, id: group.tagId as number }));
const reorderedGroups = moveById(taggedGroups, draggingGroupTagId.value, tagId);
const reorderedVisibleTagIds = reorderedGroups.map((group) => group.id);
const globalTagIds = [...quickCommandTagsStore.tags]
.sort((a, b) => (a.sort_order - b.sort_order) || (a.id - b.id))
.map((tag) => tag.id);
const visibleTagIdSet = new Set(reorderedVisibleTagIds);
let visibleIndex = 0;
const mergedTagIds = globalTagIds.map((existingTagId) => {
if (!visibleTagIdSet.has(existingTagId)) {
return existingTagId;
}
const nextVisibleTagId = reorderedVisibleTagIds[visibleIndex];
visibleIndex += 1;
return nextVisibleTagId ?? existingTagId;
});
await quickCommandTagsStore.reorderTags(mergedTagIds);
resetDragState();
};
const handleCommandDragStart = (event: DragEvent, commandId: number, groupTagId: number | null) => {
if (dragDisabledBySearch.value) {
event.preventDefault();
return;
}
draggingCommand.value = { commandId, groupTagId };
commandDropTarget.value = null;
event.dataTransfer?.setData('text/plain', String(commandId));
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
}
};
const handleCommandDragOver = (commandId: number, groupTagId: number | null) => {
if (!draggingCommand.value || dragDisabledBySearch.value) {
return;
}
if (draggingCommand.value.groupTagId !== groupTagId || draggingCommand.value.commandId === commandId) {
return;
}
commandDropTarget.value = { commandId, groupTagId };
};
const handleCommandDrop = async (commandId: number, groupTagId: number | null) => {
if (!draggingCommand.value || dragDisabledBySearch.value) {
resetDragState();
return;
}
if (draggingCommand.value.groupTagId !== groupTagId || draggingCommand.value.commandId === commandId) {
resetDragState();
return;
}
let currentCommands: QuickCommandFE[] = [];
if (showQuickCommandTagsBoolean.value) {
currentCommands = filteredAndGroupedCommands.value.find((group) => group.tagId === groupTagId)?.commands ?? [];
} else {
currentCommands = flatFilteredCommands.value;
}
const reorderedCommands = moveById(currentCommands, draggingCommand.value.commandId, commandId);
if (showQuickCommandTagsBoolean.value) {
if (groupTagId !== null) {
await quickCommandsStore.reorderCommandsInTag(groupTagId, reorderedCommands.map((item) => item.id));
} else {
const reorderedUntaggedIds = reorderedCommands.map((item) => item.id);
const globalCommandIds = [...quickCommandsStore.quickCommandsList]
.sort((a, b) => (a.sort_order - b.sort_order) || (a.id - b.id))
.map((command) => command.id);
let untaggedIndex = 0;
const mergedCommandIds = globalCommandIds.map((existingCommandId) => {
const command = quickCommandsStore.quickCommandsList.find((item) => item.id === existingCommandId);
if (!command || command.tagIds.length > 0) {
return existingCommandId;
}
const nextUntaggedId = reorderedUntaggedIds[untaggedIndex];
untaggedIndex += 1;
return nextUntaggedId ?? existingCommandId;
});
await quickCommandsStore.reorderQuickCommands(mergedCommandIds);
}
} else {
await quickCommandsStore.reorderQuickCommands(reorderedCommands.map((item) => item.id));
}
resetDragState();
};
onMounted(async () => { // Make onMounted async
// Load expanded groups state first
quickCommandsStore.loadExpandedGroups();
@@ -518,8 +715,10 @@ const handleSearchInputBlur = () => {
// (Action remains the same, store handles the logic change)
const toggleSortBy = () => {
const newSortBy = sortBy.value === 'name' ? 'last_used' : 'name';
quickCommandsStore.setSortBy(newSortBy);
const sortModes: QuickCommandSortByType[] = ['manual', 'name', 'last_used'];
const currentIndex = sortModes.indexOf(sortBy.value);
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % sortModes.length;
quickCommandsStore.setSortBy(sortModes[nextIndex]);
};
// +++ Action to toggle group expansion +++
@@ -896,3 +1095,10 @@ const handleQuickCommandMenuAction = async (action: QuickCommandContextAction, c
};
</script>
<style scoped>
.qc-drop-target {
outline: 1px dashed color-mix(in srgb, var(--color-primary, #3b82f6) 72%, transparent);
outline-offset: -2px;
}
</style>