feat: 连接卡片右键菜单 (连接/断开、编辑、复制、删除)
- ConnectionCard 移除悬浮操作按钮, 简化为纯展示+左键连接 - ConnectionList 新增右键上下文菜单 (Teleport to body) - 菜单项: 连接/断开(按状态切换)、编辑、复制连接、删除 - 删除前确认弹窗, 支持点击外部/右键关闭菜单
This commit is contained in:
@@ -1,14 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import type { ConnectionConfig } from '@renderer/stores/connection'
|
import type { ConnectionConfig } from '@renderer/stores/connection'
|
||||||
import { useConnectionStore } from '@renderer/stores/connection'
|
import { useConnectionStore } from '@renderer/stores/connection'
|
||||||
import { useI18n } from '@renderer/i18n'
|
|
||||||
|
|
||||||
const props = defineProps<{ connection: ConnectionConfig }>()
|
const props = defineProps<{ connection: ConnectionConfig }>()
|
||||||
const emit = defineEmits<{ edit: [conn: ConnectionConfig]; delete: [id: string] }>()
|
|
||||||
const connStore = useConnectionStore()
|
const connStore = useConnectionStore()
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const isActive = computed(() => connStore.activeId === props.connection.id)
|
const isActive = computed(() => connStore.activeId === props.connection.id)
|
||||||
const isConn = computed(() => connStore.isConnectedById(props.connection.id))
|
const isConn = computed(() => connStore.isConnectedById(props.connection.id))
|
||||||
@@ -17,9 +13,6 @@ const statusColor = computed(() => {
|
|||||||
return 'var(--text-muted)'
|
return 'var(--text-muted)'
|
||||||
})
|
})
|
||||||
|
|
||||||
const COLOR_PRESETS = ['#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#3b82f6', '#ec4899']
|
|
||||||
const showColorPicker = ref(false)
|
|
||||||
|
|
||||||
const assignedColor = computed(() => props.connection.color || '')
|
const assignedColor = computed(() => props.connection.color || '')
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
@@ -34,42 +27,6 @@ function handleClick() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cycleColor() {
|
|
||||||
if (!props.connection.color) {
|
|
||||||
setColor(COLOR_PRESETS[0])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const idx = COLOR_PRESETS.indexOf(props.connection.color)
|
|
||||||
if (idx === -1 || idx === COLOR_PRESETS.length - 1) {
|
|
||||||
setColor('')
|
|
||||||
} else {
|
|
||||||
setColor(COLOR_PRESETS[idx + 1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setColor(color: string) {
|
|
||||||
const updated = { ...props.connection, color: color || undefined }
|
|
||||||
connStore.saveConnection(updated)
|
|
||||||
showColorPicker.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectColor(color: string) {
|
|
||||||
setColor(color)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDuplicate() {
|
|
||||||
const copy: ConnectionConfig = {
|
|
||||||
...props.connection,
|
|
||||||
id: `${props.connection.id}_copy_${Date.now()}`,
|
|
||||||
name: `${props.connection.name} (copy)`,
|
|
||||||
order: Date.now(),
|
|
||||||
createdAt: Date.now(),
|
|
||||||
}
|
|
||||||
await connStore.saveConnection(copy)
|
|
||||||
ElMessage.success(t('connection.duplicated'))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drag events
|
|
||||||
function onDragStart(e: DragEvent) {
|
function onDragStart(e: DragEvent) {
|
||||||
if (e.dataTransfer) {
|
if (e.dataTransfer) {
|
||||||
e.dataTransfer.setData('text/plain', props.connection.id)
|
e.dataTransfer.setData('text/plain', props.connection.id)
|
||||||
@@ -96,36 +53,6 @@ function onDragStart(e: DragEvent) {
|
|||||||
<div class="card-name">{{ connection.name }}</div>
|
<div class="card-name">{{ connection.name }}</div>
|
||||||
<div class="card-host">{{ connection.host }}:{{ connection.port }}</div>
|
<div class="card-host">{{ connection.host }}:{{ connection.port }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions" @click.stop>
|
|
||||||
<!-- Color dot / picker -->
|
|
||||||
<div class="color-wrapper" @mouseenter="showColorPicker = true" @mouseleave="showColorPicker = false">
|
|
||||||
<button
|
|
||||||
class="card-btn color-dot"
|
|
||||||
:style="{ background: assignedColor || 'var(--text-muted)' }"
|
|
||||||
:title="t('connection.setColor')"
|
|
||||||
@click="cycleColor"
|
|
||||||
/>
|
|
||||||
<div v-if="showColorPicker" class="color-dropdown">
|
|
||||||
<button
|
|
||||||
v-for="c in COLOR_PRESETS"
|
|
||||||
:key="c"
|
|
||||||
class="color-option"
|
|
||||||
:style="{ background: c }"
|
|
||||||
:class="{ active: assignedColor === c }"
|
|
||||||
@click="selectColor(c)"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
v-if="assignedColor"
|
|
||||||
class="color-option color-clear"
|
|
||||||
title="Clear"
|
|
||||||
@click="setColor('')"
|
|
||||||
>✕</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="card-btn" :title="t('connection.duplicate')" @click="handleDuplicate">📋</button>
|
|
||||||
<button class="card-btn" title="Edit" @click="emit('edit', connection)">✎</button>
|
|
||||||
<button class="card-btn card-btn-danger" title="Delete" @click="emit('delete', connection.id)">✕</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -187,97 +114,4 @@ function onDragStart(e: DragEvent) {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-card:hover .card-actions {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-btn {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-btn:hover {
|
|
||||||
background: var(--bg-card-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-btn-danger:hover {
|
|
||||||
background: rgba(239, 68, 68, 0.15);
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Color picker */
|
|
||||||
.color-wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-dot {
|
|
||||||
width: 14px !important;
|
|
||||||
height: 14px !important;
|
|
||||||
border-radius: 50% !important;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-dropdown {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
right: 0;
|
|
||||||
margin-top: 6px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 6px;
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
z-index: 100;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-option {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
transition: transform 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-option:hover {
|
|
||||||
transform: scale(1.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-option.active {
|
|
||||||
border-color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-clear {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 10px;
|
|
||||||
background: var(--bg-card-hover) !important;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-color: var(--border-color);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,6 +12,71 @@ const searchQuery = ref('')
|
|||||||
const dragOverId = ref<string | null>(null)
|
const dragOverId = ref<string | null>(null)
|
||||||
const emit = defineEmits<{ edit: [conn: ConnectionConfig | null] }>()
|
const emit = defineEmits<{ edit: [conn: ConnectionConfig | null] }>()
|
||||||
|
|
||||||
|
// Context menu
|
||||||
|
const ctxMenu = ref<{ visible: boolean; x: number; y: number; conn: ConnectionConfig | null }>({
|
||||||
|
visible: false, x: 0, y: 0, conn: null,
|
||||||
|
})
|
||||||
|
const ctxIsConnected = computed(() =>
|
||||||
|
ctxMenu.value.conn ? connStore.isConnectedById(ctxMenu.value.conn.id) : false,
|
||||||
|
)
|
||||||
|
|
||||||
|
function onContextMenu(e: MouseEvent, conn: ConnectionConfig) {
|
||||||
|
ctxMenu.value = { visible: true, x: e.clientX, y: e.clientY, conn }
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCtxMenu() {
|
||||||
|
ctxMenu.value.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctxConnect() {
|
||||||
|
if (ctxMenu.value.conn) connStore.connect(ctxMenu.value.conn)
|
||||||
|
closeCtxMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctxDisconnect() {
|
||||||
|
if (ctxMenu.value.conn) connStore.disconnectById(ctxMenu.value.conn.id)
|
||||||
|
closeCtxMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctxEdit() {
|
||||||
|
if (ctxMenu.value.conn) emit('edit', ctxMenu.value.conn)
|
||||||
|
closeCtxMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ctxDuplicate() {
|
||||||
|
const conn = ctxMenu.value.conn
|
||||||
|
if (conn) {
|
||||||
|
const copy: ConnectionConfig = {
|
||||||
|
...conn,
|
||||||
|
id: `${conn.id}_copy_${Date.now()}`,
|
||||||
|
name: `${conn.name} (copy)`,
|
||||||
|
order: Date.now(),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}
|
||||||
|
await connStore.saveConnection(copy)
|
||||||
|
ElMessage.success(t('connection.duplicated'))
|
||||||
|
}
|
||||||
|
closeCtxMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ctxDelete() {
|
||||||
|
const conn = ctxMenu.value.conn
|
||||||
|
closeCtxMenu()
|
||||||
|
if (!conn) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`${t('connection.delete')}: ${conn.name}?`,
|
||||||
|
t('common.confirm'),
|
||||||
|
{
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: t('connection.delete'),
|
||||||
|
cancelButtonText: t('common.cancel'),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await connStore.deleteConnection(conn.id)
|
||||||
|
} catch { /* cancelled */ }
|
||||||
|
}
|
||||||
|
|
||||||
const filteredConnections = computed(() => {
|
const filteredConnections = computed(() => {
|
||||||
const q = searchQuery.value.toLowerCase()
|
const q = searchQuery.value.toLowerCase()
|
||||||
return connStore.connections
|
return connStore.connections
|
||||||
@@ -19,21 +84,6 @@ const filteredConnections = computed(() => {
|
|||||||
.sort((a, b) => a.order - b.order)
|
.sort((a, b) => a.order - b.order)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm('Delete this connection?', 'Confirm', {
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: 'Delete',
|
|
||||||
cancelButtonText: 'Cancel',
|
|
||||||
})
|
|
||||||
await connStore.deleteConnection(id)
|
|
||||||
} catch { /* cancelled */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEdit(conn: ConnectionConfig) {
|
|
||||||
emit('edit', conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNew() {
|
function handleNew() {
|
||||||
emit('edit', null)
|
emit('edit', null)
|
||||||
}
|
}
|
||||||
@@ -124,12 +174,9 @@ async function handleImport() {
|
|||||||
@dragover="onDragOver($event, conn.id)"
|
@dragover="onDragOver($event, conn.id)"
|
||||||
@dragleave="onDragLeave"
|
@dragleave="onDragLeave"
|
||||||
@drop="onDrop($event, conn.id)"
|
@drop="onDrop($event, conn.id)"
|
||||||
|
@contextmenu.prevent="onContextMenu($event, conn)"
|
||||||
>
|
>
|
||||||
<ConnectionCard
|
<ConnectionCard :connection="conn" />
|
||||||
:connection="conn"
|
|
||||||
@edit="handleEdit"
|
|
||||||
@delete="handleDelete"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="filteredConnections.length === 0" class="list-empty">
|
<div v-if="filteredConnections.length === 0" class="list-empty">
|
||||||
<p v-if="searchQuery">{{ t('connection.noMatch') }}</p>
|
<p v-if="searchQuery">{{ t('connection.noMatch') }}</p>
|
||||||
@@ -150,6 +197,30 @@ async function handleImport() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Context menu -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<template v-if="ctxMenu.visible && ctxMenu.conn">
|
||||||
|
<div class="ctx-overlay" @click="closeCtxMenu" @contextmenu.prevent="closeCtxMenu" />
|
||||||
|
<div
|
||||||
|
class="ctx-menu"
|
||||||
|
:style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' }"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<button class="ctx-item" @click="ctxConnect" v-if="!ctxIsConnected">
|
||||||
|
{{ t('connection.connect') }}
|
||||||
|
</button>
|
||||||
|
<button class="ctx-item" @click="ctxDisconnect" v-else>
|
||||||
|
{{ t('connection.disconnect') }}
|
||||||
|
</button>
|
||||||
|
<div class="ctx-divider" />
|
||||||
|
<button class="ctx-item" @click="ctxEdit">{{ t('connection.edit') }}</button>
|
||||||
|
<button class="ctx-item" @click="ctxDuplicate">{{ t('connection.duplicate') }}</button>
|
||||||
|
<div class="ctx-divider" />
|
||||||
|
<button class="ctx-item ctx-danger" @click="ctxDelete">{{ t('connection.delete') }}</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -237,4 +308,54 @@ async function handleImport() {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Context menu */
|
||||||
|
.ctx-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9998;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
min-width: 140px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-item:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user