feat: 后端 & 前端: 实现连接与标签的关联管理

This commit is contained in:
Baobhan Sith
2025-04-15 07:46:57 +08:00
parent 7bd7df091b
commit 6cd4977347
8 changed files with 268 additions and 39 deletions
@@ -4,6 +4,7 @@ import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useConnectionsStore, ConnectionInfo } from '../stores/connections.store';
import { useProxiesStore } from '../stores/proxies.store'; // 引入代理 Store
import { useTagsStore } from '../stores/tags.store'; // 引入标签 Store
// 定义组件发出的事件
const emit = defineEmits(['close', 'connection-added', 'connection-updated']);
@@ -16,8 +17,10 @@ const props = defineProps<{
const { t } = useI18n();
const connectionsStore = useConnectionsStore();
const proxiesStore = useProxiesStore(); // 获取代理 store 实例
const tagsStore = useTagsStore(); // 获取标签 store 实例
const { isLoading: isConnLoading, error: connStoreError } = storeToRefs(connectionsStore);
const { proxies, isLoading: isProxyLoading, error: proxyStoreError } = storeToRefs(proxiesStore); // 获取代理列表和状态
const { tags, isLoading: isTagLoading, error: tagStoreError } = storeToRefs(tagsStore); // 获取标签列表和状态
// 表单数据模型
const initialFormData = {
@@ -29,13 +32,15 @@ const initialFormData = {
password: '',
private_key: '',
passphrase: '',
proxy_id: null as number | null, // 新增 proxy_id 字段
proxy_id: null as number | null,
tag_ids: [] as number[], // 新增 tag_ids 字段
};
const formData = reactive({ ...initialFormData });
const formError = ref<string | null>(null); // 表单级别的错误信息
const isLoading = computed(() => isConnLoading.value || isProxyLoading.value); // 合并加载状态
const storeError = computed(() => connStoreError.value || proxyStoreError.value); // 合并错误状态
// 合并所有 store 的加载和错误状态
const isLoading = computed(() => isConnLoading.value || isProxyLoading.value || isTagLoading.value);
const storeError = computed(() => connStoreError.value || proxyStoreError.value || tagStoreError.value);
// 计算属性判断是否为编辑模式
const isEditMode = computed(() => !!props.connectionToEdit);
@@ -64,7 +69,8 @@ watch(() => props.connectionToEdit, (newVal) => {
formData.port = newVal.port;
formData.username = newVal.username;
formData.auth_method = newVal.auth_method;
formData.proxy_id = newVal.proxy_id ?? null; // 填充 proxy_id
formData.proxy_id = newVal.proxy_id ?? null;
formData.tag_ids = newVal.tag_ids ? [...newVal.tag_ids] : []; // 填充 tag_ids (深拷贝)
// 清空敏感字段
formData.password = '';
formData.private_key = '';
@@ -75,9 +81,10 @@ watch(() => props.connectionToEdit, (newVal) => {
}
}, { immediate: true });
// 组件挂载时获取代理列表
// 组件挂载时获取代理和标签列表
onMounted(() => {
proxiesStore.fetchProxies();
tagsStore.fetchTags(); // 获取标签列表
});
// 处理表单提交
@@ -137,6 +144,7 @@ const handleSubmit = async () => {
username: formData.username,
auth_method: formData.auth_method,
proxy_id: formData.proxy_id || null,
tag_ids: formData.tag_ids || [], // 发送 tag_ids
};
// 处理敏感字段
@@ -248,6 +256,20 @@ const handleSubmit = async () => {
<div v-if="proxyStoreError" class="error-small">{{ t('proxies.error', { error: proxyStoreError }) }}</div>
</div>
<!-- 新增标签选择 (多选框) -->
<div class="form-group">
<label>{{ t('connections.form.tags') }} ({{ t('connections.form.optional') }})</label>
<div class="tag-checkbox-group">
<div v-if="isTagLoading" class="loading-small">{{ t('tags.loading') }}</div>
<div v-else-if="tagStoreError" class="error-small">{{ t('tags.error', { error: tagStoreError }) }}</div>
<div v-else-if="tags.length === 0" class="info-small">{{ t('tags.noTags') }}</div>
<label v-for="tag in tags" :key="tag.id" class="tag-checkbox-label">
<input type="checkbox" :value="tag.id" v-model="formData.tag_ids">
{{ tag.name }}
</label>
</div>
</div>
<!-- 显示 storeError formError -->
<div v-if="formError || storeError" class="error-message">
{{ formError || storeError }} <!-- 使用合并后的 storeError -->
@@ -315,6 +337,38 @@ textarea {
box-sizing: border-box; /* Include padding and border in element's total width and height */
}
/* 标签选择样式 */
.tag-checkbox-group {
max-height: 150px; /* 限制高度,出现滚动条 */
overflow-y: auto;
border: 1px solid #ccc;
padding: 0.5rem;
border-radius: 4px;
margin-top: 0.5rem;
}
.tag-checkbox-label {
display: block; /* 每个标签占一行 */
margin-bottom: 0.3rem;
font-weight: normal; /* 普通字体 */
cursor: pointer;
}
.tag-checkbox-label input[type="checkbox"] {
margin-right: 0.5rem;
width: auto; /* 恢复复选框默认宽度 */
}
.loading-small, .error-small, .info-small {
font-size: 0.9em;
color: #666;
margin-top: 0.2rem;
}
.error-small {
color: red;
}
.error-message {
color: red;
margin-bottom: 1rem;
@@ -1,24 +1,47 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { onMounted, computed } from 'vue'; // 引入 computed
import { storeToRefs } from 'pinia';
import { useRouter } from 'vue-router'; // 引入 useRouter
import { useI18n } from 'vue-i18n'; // 引入 useI18n
import { useConnectionsStore, ConnectionInfo } from '../stores/connections.store'; // 引入 ConnectionInfo 类型
import { useTagsStore } from '../stores/tags.store'; // 引入 Tags Store
const { t } = useI18n(); // 获取 t 函数
const router = useRouter(); // 获取 router 实例
const connectionsStore = useConnectionsStore();
const tagsStore = useTagsStore(); // 获取 Tags Store 实例
// 使用 storeToRefs 来保持 state 属性的响应性
const { connections, isLoading, error } = storeToRefs(connectionsStore);
const { tags: allTags } = storeToRefs(tagsStore); // 获取所有标签
// 定义组件发出的事件 (添加 edit-connection)
const emit = defineEmits(['edit-connection']);
// 组件挂载时获取连接列表
// 组件挂载时获取连接和标签列表
onMounted(() => {
connectionsStore.fetchConnections();
tagsStore.fetchTags(); // 获取标签列表
});
// 创建标签 ID 到名称的映射
const tagMap = computed(() => {
const map = new Map<number, string>();
allTags.value.forEach(tag => {
map.set(tag.id, tag.name);
});
return map;
});
// 获取连接的标签名称数组
const getConnectionTagNames = (conn: ConnectionInfo): string[] => {
if (!conn.tag_ids || conn.tag_ids.length === 0) {
return [];
}
return conn.tag_ids
.map(tagId => tagMap.value.get(tagId)) // 使用映射获取名称
.filter((name): name is string => !!name); // 过滤掉未找到的标签并确保类型为 string
};
// 辅助函数:格式化时间戳
const formatTimestamp = (timestamp: number | null): string => {
if (!timestamp) return t('connections.status.never'); // 使用 i18n
@@ -59,6 +82,7 @@ const handleDelete = async (conn: ConnectionInfo) => {
<th>{{ t('connections.table.port') }}</th>
<th>{{ t('connections.table.user') }}</th>
<th>{{ t('connections.table.authMethod') }}</th>
<th>{{ t('connections.table.tags') }}</th> <!-- 新增标签列 -->
<th>{{ t('connections.table.lastConnected') }}</th>
<th>{{ t('connections.table.actions') }}</th>
</tr>
@@ -70,6 +94,14 @@ const handleDelete = async (conn: ConnectionInfo) => {
<td>{{ conn.port }}</td>
<td>{{ conn.username }}</td>
<td>{{ conn.auth_method }}</td>
<td> <!-- 显示标签 -->
<span v-if="getConnectionTagNames(conn).length > 0" class="tag-list">
<span v-for="tagName in getConnectionTagNames(conn)" :key="tagName" class="tag-item">
{{ tagName }}
</span>
</span>
<span v-else class="no-tags">-</span>
</td>
<td>{{ formatTimestamp(conn.last_connected_at) }}</td>
<td>
<button @click="connectToServer(conn.id)">{{ t('connections.actions.connect') }}</button>
@@ -137,4 +169,23 @@ button {
padding: 0.2rem 0.5rem;
cursor: pointer;
}
/* 标签样式 */
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.tag-item {
background-color: #e0e0e0;
color: #333;
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.85em;
white-space: nowrap;
}
.no-tags {
color: #999;
font-style: italic;
}
</style>
+3 -1
View File
@@ -28,6 +28,7 @@
"port": "Port",
"user": "User",
"authMethod": "Auth Method",
"tags": "Tags",
"lastConnected": "Last Connected",
"actions": "Actions"
},
@@ -65,7 +66,8 @@
"errorUpdate": "Failed to update connection: {error}",
"keyUpdateNote": "Leave private key and passphrase blank to keep the existing key.",
"proxy": "Proxy:",
"noProxy": "No Proxy"
"noProxy": "No Proxy",
"tags": "Tags:"
},
"prompts": {
"confirmDelete": "Are you sure you want to delete the connection \"{name}\"? This cannot be undone."
+3 -1
View File
@@ -28,6 +28,7 @@
"port": "端口",
"user": "用户名",
"authMethod": "认证方式",
"tags": "标签",
"lastConnected": "上次连接",
"actions": "操作"
},
@@ -65,7 +66,8 @@
"errorUpdate": "更新连接失败: {error}",
"keyUpdateNote": "将私钥和密码短语留空以保留现有密钥。",
"proxy": "代理:",
"noProxy": "无代理"
"noProxy": "无代理",
"tags": "标签:"
},
"prompts": {
"confirmDelete": "确定要删除连接 \"{name}\" 吗?此操作不可撤销。"
@@ -10,6 +10,7 @@ export interface ConnectionInfo {
username: string;
auth_method: 'password' | 'key';
proxy_id?: number | null; // 新增:关联的代理 ID (可选)
tag_ids?: number[]; // 新增:关联的标签 ID 数组 (可选)
created_at: number;
updated_at: number;
last_connected_at: number | null;
@@ -62,7 +63,8 @@ export const useConnectionsStore = defineStore('connections', {
password?: string;
private_key?: string;
passphrase?: string;
proxy_id?: number | null; // 新增:允许传入 proxy_id
proxy_id?: number | null;
tag_ids?: number[]; // 新增:允许传入 tag_ids
}) {
this.isLoading = true;
this.error = null;
@@ -84,8 +86,8 @@ export const useConnectionsStore = defineStore('connections', {
},
// 更新连接 Action
// 更新参数类型以包含 proxy_id
async updateConnection(connectionId: number, updatedData: Partial<Omit<ConnectionInfo, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { password?: string; private_key?: string; passphrase?: string; proxy_id?: number | null }>) {
// 更新参数类型以包含 proxy_id 和 tag_ids
async updateConnection(connectionId: number, updatedData: Partial<Omit<ConnectionInfo, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { password?: string; private_key?: string; passphrase?: string; proxy_id?: number | null; tag_ids?: number[] }>) {
this.isLoading = true;
this.error = null;
try {