Feat(webui): support pinning and dragging for installed plugins (#6649) (#6776)

* refactor(persona): replace local folder components with shared folder components

* feat(webui): implement draggable reordering with animation for pinned plugins

* refactor(webui): extract PinnedPluginItem into a standalone component
This commit is contained in:
M1LKT
2026-03-22 14:55:02 +08:00
committed by GitHub
parent 554c9cecfa
commit a2dae0fc5e
11 changed files with 603 additions and 314 deletions

View File

@@ -0,0 +1,183 @@
<script setup>
import { computed } from 'vue';
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
const props = defineProps({
plugin: {
type: Object,
required: true
},
isPinned: {
type: Boolean,
default: false
},
tm: {
type: Function,
required: true
},
dragged: {
type: Boolean,
default: false
}
});
const emit = defineEmits([
'toggle-pin',
'view-readme',
'open-config',
'reload',
'update',
'show-info',
'uninstall',
'dragstart',
'dragover',
'dragenter',
'dragend',
'drop'
]);
const handlePinnedImgError = (e) => {
e.target.src = defaultPluginIcon;
};
</script>
<template>
<div
class="pinned-item pinned-card-wrapper"
:class="{ 'is-dragging': dragged }"
style="position:relative;"
draggable="true"
@dragstart="$emit('dragstart')"
@dragover.prevent="$emit('dragover', $event)"
@dragenter.prevent="$emit('dragenter', $event)"
@dragend="$emit('dragend', $event)"
@drop="$emit('drop', $event)"
>
<v-menu offset-y>
<template #activator="{ props: menuProps }">
<v-avatar
v-bind="menuProps"
size="72"
class="pinned-avatar activator-avatar"
:title="plugin.display_name || plugin.name"
>
<img
:src="(typeof plugin.logo === 'string' && plugin.logo.trim()) ? plugin.logo : defaultPluginIcon"
:alt="plugin.name"
@error="handlePinnedImgError"
/>
</v-avatar>
</template>
<v-card>
<v-card-text class="d-flex" style="gap:8px; padding:12px;">
<v-tooltip location="top" :text="tm('buttons.viewDocs')">
<template #activator="{ props: a }">
<v-btn v-bind="a" icon size="small" variant="tonal" color="info" @click.stop="$emit('view-readme', plugin)">
<v-icon>mdi-book-open-page-variant</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip location="top" :text="tm('card.actions.pluginConfig')">
<template #activator="{ props: a }">
<v-btn v-bind="a" icon size="small" variant="tonal" color="primary" @click.stop="$emit('open-config', plugin.name)">
<v-icon>mdi-cog</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip location="top" :text="tm('card.actions.reloadPlugin')">
<template #activator="{ props: a }">
<v-btn v-bind="a" icon size="small" variant="tonal" color="primary" @click.stop="$emit('reload', plugin.name)">
<v-icon>mdi-refresh</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip location="top" :text="tm('buttons.update')">
<template #activator="{ props: a }">
<v-btn v-bind="a" icon size="small" variant="tonal" color="warning" @click.stop="$emit('update', plugin.name)">
<v-icon>mdi-update</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip location="top" :text="tm('buttons.viewInfo')">
<template #activator="{ props: a }">
<v-btn v-bind="a" icon size="small" variant="tonal" color="secondary" @click.stop="$emit('show-info', plugin)">
<v-icon>mdi-information</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip location="top" :text="tm('buttons.uninstall')">
<template #activator="{ props: a }">
<v-btn v-bind="a" icon size="small" variant="tonal" color="error" @click.stop="$emit('uninstall', plugin.name)" v-if="!plugin.reserved">
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
</v-tooltip>
</v-card-text>
</v-card>
</v-menu>
<v-btn
icon
size="small"
class="pinned-pin-btn"
:color="isPinned ? 'primary' : 'secondary'"
@click.stop="$emit('toggle-pin', plugin)"
:title="isPinned ? tm('buttons.unpin') : tm('buttons.pin')"
style="position:absolute; top:6px; right:6px; min-width:22px; width:22px; height:22px;"
>
<v-icon size="14">{{ isPinned ? 'mdi-pin' : 'mdi-pin-outline' }}</v-icon>
</v-btn>
</div>
</template>
<style scoped>
.pinned-avatar {
display: inline-flex;
width: 100%;
height: 100%;
overflow: hidden;
cursor: pointer;
border-radius: 12px;
}
.pinned-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.pinned-card-wrapper {
position: relative;
display: inline-block;
width: 72px;
height: 72px;
}
.pinned-item {
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.is-dragging {
opacity: 0.5;
transform: scale(0.95);
cursor: grabbing;
}
[draggable="true"] {
cursor: grab;
}
[draggable="true"]:active {
cursor: grabbing;
}
</style>

View File

@@ -18,26 +18,26 @@
<template v-slot:prepend>
<v-icon size="small">mdi-folder-open</v-icon>
</template>
<v-list-item-title>{{ labels.open }}</v-list-item-title>
<v-list-item-title>{{ mergedLabels.open }}</v-list-item-title>
</v-list-item>
<v-list-item @click.stop="$emit('rename')">
<template v-slot:prepend>
<v-icon size="small">mdi-pencil</v-icon>
</template>
<v-list-item-title>{{ labels.rename }}</v-list-item-title>
<v-list-item-title>{{ mergedLabels.rename }}</v-list-item-title>
</v-list-item>
<v-list-item @click.stop="$emit('move')">
<template v-slot:prepend>
<v-icon size="small">mdi-folder-move</v-icon>
</template>
<v-list-item-title>{{ labels.moveTo }}</v-list-item-title>
<v-list-item-title>{{ mergedLabels.moveTo }}</v-list-item-title>
</v-list-item>
<v-divider class="my-1" />
<v-list-item @click.stop="$emit('delete')" class="text-error">
<template v-slot:prepend>
<v-icon size="small" color="error">mdi-delete</v-icon>
</template>
<v-list-item-title>{{ labels.delete }}</v-list-item-title>
<v-list-item-title>{{ mergedLabels.delete }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, inject, watch } from "vue";
import { ref, computed, inject, watch, useAttrs } from "vue";
import { useCustomizerStore } from "@/stores/customizer";
import { useModuleI18n } from "@/i18n/composables";
import { getPlatformDisplayName, getPlatformIcon } from "@/utils/platformUtils";
@@ -13,6 +13,10 @@ const props = defineProps({
type: Object,
required: true,
},
pinned: {
type: Boolean,
default: false,
},
marketMode: {
type: Boolean,
default: false,
@@ -31,6 +35,7 @@ const emit = defineEmits([
"install",
"uninstall",
"toggle-activation",
"toggle-pin",
"view-handlers",
"view-readme",
"view-changelog",
@@ -39,6 +44,8 @@ const emit = defineEmits([
const reveal = ref(false);
const showUninstallDialog = ref(false);
const attrs = useAttrs();
// 国际化
const { tm } = useModuleI18n("features/extension");
@@ -114,6 +121,11 @@ const toggleActivation = () => {
emit("toggle-activation", props.extension);
};
const togglePin = (e?: Event) => {
if (e) e.stopPropagation();
emit("toggle-pin", props.extension);
};
const viewHandlers = () => {
emit("view-handlers", props.extension);
};
@@ -130,6 +142,7 @@ const viewChangelog = () => {
<template>
<v-card
v-bind="attrs"
class="mx-auto d-flex flex-column h-100"
elevation="0"
height="100%"
@@ -203,16 +216,34 @@ const viewChangelog = () => {
<template v-if="!marketMode">
<v-tooltip location="left">
<template v-slot:activator="{ props: tooltipProps }">
<div v-bind="tooltipProps" class="extension-switch-wrap" @click.stop>
<v-switch
:model-value="extension.activated"
color="success"
density="compact"
hide-details
inset
@update:model-value="toggleActivation"
></v-switch>
</div>
<div class="extension-switch-wrap" @click.stop>
<div v-bind="tooltipProps" style="display:inline-flex; align-items:center;">
<v-switch
:model-value="extension.activated"
color="success"
density="compact"
hide-details
inset
@update:model-value="toggleActivation"
></v-switch>
</div>
<v-tooltip location="top" :text="pinned ? tm('buttons.unpin') : tm('buttons.pin')">
<template #activator="{ props: pinProps }">
<v-btn
v-bind="pinProps"
icon
size="small"
variant="tonal"
:color="pinned ? 'primary' : 'secondary'"
class="ml-2"
@click.stop="togglePin"
>
<v-icon size="18">{{ pinned ? 'mdi-pin' : 'mdi-pin-outline' }}</v-icon>
</v-btn>
</template>
</v-tooltip>
</div>
</template>
<span>{{
extension.activated ? tm("buttons.disable") : tm("buttons.enable")