mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-20 23:24:03 +08:00
1812 lines
56 KiB
Vue
1812 lines
56 KiB
Vue
<script lang="ts" setup>
|
||
import { useToast } from 'vue-toastification'
|
||
import api from '@/api'
|
||
import type { Plugin } from '@/api/types'
|
||
import NoDataFound from '@/components/NoDataFound.vue'
|
||
import { useDisplay } from 'vuetify'
|
||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||
import { getPluginTabs } from '@/router/i18n-menu'
|
||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||
import { useI18n } from 'vue-i18n'
|
||
import PluginMixedSortCard from '@/components/cards/PluginMixedSortCard.vue'
|
||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||
import { usePWA } from '@/composables/usePWA'
|
||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
|
||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||
|
||
// 国际化
|
||
const { t } = useI18n()
|
||
|
||
const route = useRoute()
|
||
|
||
// 市场卡片、拖拽排序和市场设置只在对应标签/操作中需要,延迟到真正使用时加载。
|
||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||
const PluginAppCard = defineAsyncComponent(() => import('@/components/cards/PluginAppCard.vue'))
|
||
const PluginFolderCreateDialog = defineAsyncComponent(() => import('@/components/dialog/PluginFolderCreateDialog.vue'))
|
||
const PluginMarketSettingDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketSettingDialog.vue'))
|
||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||
const PluginSearchDialog = defineAsyncComponent(() => import('@/components/dialog/PluginSearchDialog.vue'))
|
||
|
||
// 显示器宽度
|
||
const display = useDisplay()
|
||
|
||
// APP
|
||
// PWA模式检测
|
||
const { appMode } = usePWA()
|
||
|
||
// 当前标签
|
||
const activeTab = ref('installed')
|
||
const sortMode = ref(false)
|
||
|
||
// 获取插件标签页
|
||
const pluginTabs = computed(() => getPluginTabs(t))
|
||
|
||
// 本地插件来源显示名称
|
||
const localRepoLabel = computed(() => t('plugin.local'))
|
||
|
||
// 使用动态标签页
|
||
const { registerHeaderTab } = useDynamicHeaderTab()
|
||
|
||
// 注册动态标签页(在setup顶层立即执行)
|
||
registerHeaderTab({
|
||
items: pluginTabs.value,
|
||
modelValue: activeTab,
|
||
appendButtons: [
|
||
{
|
||
icon: 'mdi-filter-multiple-outline',
|
||
variant: 'text',
|
||
color: computed(() =>
|
||
installedFilter.value || hasUpdateFilter.value || enabledFilter.value ? 'primary' : 'gray',
|
||
),
|
||
class: 'settings-icon-button',
|
||
dataAttr: 'installed-filter-btn',
|
||
action: () => {
|
||
filterInstalledPluginDialog.value = true
|
||
},
|
||
show: computed(() => activeTab.value === 'installed'),
|
||
},
|
||
{
|
||
icon: 'mdi-sort-variant',
|
||
variant: 'text',
|
||
color: computed(() => (sortMode.value ? 'warning' : 'gray')),
|
||
class: 'settings-icon-button',
|
||
action: () => {
|
||
sortMode.value = !sortMode.value
|
||
},
|
||
show: computed(() => activeTab.value === 'installed'),
|
||
},
|
||
{
|
||
icon: 'mdi-filter-multiple-outline',
|
||
variant: 'text',
|
||
color: computed(() => (isFilterFormEmpty.value ? 'gray' : 'primary')),
|
||
class: 'settings-icon-button',
|
||
dataAttr: 'market-filter-btn',
|
||
action: () => {
|
||
filterMarketPluginDialog.value = true
|
||
},
|
||
show: computed(() => activeTab.value === 'market'),
|
||
},
|
||
{
|
||
icon: 'mdi-refresh',
|
||
variant: 'text',
|
||
color: 'gray',
|
||
class: 'settings-icon-button',
|
||
action: () => {
|
||
refreshMarket()
|
||
},
|
||
show: computed(() => activeTab.value === 'market'),
|
||
},
|
||
{
|
||
icon: 'mdi-arrow-left',
|
||
variant: 'text',
|
||
color: 'gray',
|
||
class: 'settings-icon-button',
|
||
action: () => {
|
||
backToMain()
|
||
},
|
||
show: computed(() => activeTab.value === 'installed' && !!currentFolder.value),
|
||
},
|
||
],
|
||
})
|
||
|
||
// 插件ID参数
|
||
const pluginId = ref(route.query.id)
|
||
|
||
// 当前排序字段
|
||
const activeSort = ref<string | null>(null)
|
||
|
||
// 插件顺序配置
|
||
const orderConfig = ref<{ id: string; type?: string; order?: number }[]>([])
|
||
|
||
// 排序选项
|
||
const sortOptions = computed(() => [
|
||
{ title: t('plugin.sort.popular'), value: 'count' },
|
||
{ title: t('plugin.sort.name'), value: 'plugin_name' },
|
||
{ title: t('plugin.sort.author'), value: 'plugin_author' },
|
||
{ title: t('plugin.sort.repository'), value: 'repo_url' },
|
||
{ title: t('plugin.sort.latest'), value: 'add_time' },
|
||
])
|
||
|
||
// 加载中
|
||
const loading = ref(false)
|
||
|
||
// 已安装插件列表
|
||
const dataList = ref<Plugin[]>([])
|
||
|
||
// 计算已安装插件的名称列表
|
||
const installedPluginNames = computed(() => {
|
||
return dataList.value.map(item => item.plugin_name)
|
||
})
|
||
|
||
// 过滤后的已安装插件列表
|
||
const filteredDataList = ref<Plugin[]>([])
|
||
|
||
// 未安装插件列表
|
||
const uninstalledList = ref<Plugin[]>([])
|
||
|
||
// 插件市场插件列表
|
||
const marketList = ref<Plugin[]>([])
|
||
|
||
// 排序后的未安装插件列表
|
||
const sortedUninstalledList = ref<Plugin[]>([])
|
||
|
||
// 显示的未安装插件列表
|
||
const displayUninstalledList = ref<Plugin[]>([])
|
||
|
||
// 是否刷新过
|
||
const isRefreshed = ref(false)
|
||
|
||
// APP市场是否加载完成
|
||
const isAppMarketLoaded = ref(false)
|
||
|
||
// APP市场窗口
|
||
const PluginAppDialog = ref(false)
|
||
|
||
// 插件安装统计
|
||
const PluginStatistics = ref<{ [key: string]: number }>({})
|
||
|
||
// 插件市场刷新状态
|
||
const isMarketRefreshing = ref(false)
|
||
|
||
// 搜索关键字
|
||
const keyword = ref('')
|
||
|
||
// 每一个插件的动作标识
|
||
const pluginActions: Ref<{ [key: string]: boolean }> = ref({})
|
||
|
||
// 提示框
|
||
const $toast = useToast()
|
||
|
||
// 进度框文本
|
||
const progressText = ref(t('plugin.installingPlugin'))
|
||
let folderCreateDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||
let searchDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||
|
||
// 过滤表单
|
||
const filterForm = reactive({
|
||
// 名称
|
||
name: '' as string,
|
||
// 作者
|
||
author: [] as string[],
|
||
// 标签
|
||
label: [] as string[],
|
||
// 插件库
|
||
repo: [] as string[],
|
||
})
|
||
|
||
// 默认背景
|
||
const defaultGradient =
|
||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8) 100%)'
|
||
// 默认文件夹图标
|
||
const defaultIcon = 'mdi-folder'
|
||
// 默认文件夹颜色
|
||
const defaultColor = '#2196F3'
|
||
|
||
// 计算过滤表单是否全部为空
|
||
const isFilterFormEmpty = computed(() => {
|
||
return (
|
||
!filterForm.name && filterForm.author.length === 0 && filterForm.label.length === 0 && filterForm.repo.length === 0
|
||
)
|
||
})
|
||
|
||
// 切换市场过滤器多选项
|
||
function toggleMarketFilter(field: 'author' | 'label' | 'repo', value: string) {
|
||
const index = filterForm[field].indexOf(value)
|
||
if (index > -1) {
|
||
filterForm[field].splice(index, 1)
|
||
} else {
|
||
filterForm[field].push(value)
|
||
}
|
||
}
|
||
|
||
// 插件过滤条件
|
||
const installedFilter = ref(null)
|
||
|
||
// 有新版本过滤条件
|
||
const hasUpdateFilter = ref(false)
|
||
|
||
// 已启用过滤条件
|
||
const enabledFilter = ref(false)
|
||
|
||
// 已安装插件过滤窗口
|
||
const filterInstalledPluginDialog = ref(false)
|
||
|
||
// 插件市场过滤窗口
|
||
const filterMarketPluginDialog = ref(false)
|
||
|
||
// 作者过滤项
|
||
const authorFilterOptions = ref<string[]>([])
|
||
// 标签过滤项
|
||
const labelFilterOptions = ref<string[]>([])
|
||
// 插件库过滤项
|
||
const repoFilterOptions = ref<string[]>([])
|
||
|
||
// 插件文件夹配置
|
||
const pluginFolders: Ref<{ [key: string]: any }> = ref({})
|
||
|
||
// 文件夹排序
|
||
const folderOrder = ref<string[]>([])
|
||
|
||
// 当前查看的文件夹
|
||
const currentFolder = ref('')
|
||
|
||
// 新建文件夹对话框
|
||
// 新文件夹名称
|
||
const newFolderName = ref('')
|
||
|
||
const pluginByIdMap = computed(() => new Map(dataList.value.map(plugin => [plugin.id, plugin])))
|
||
const orderValueMap = computed(() => {
|
||
const map = new Map<string, number>()
|
||
|
||
orderConfig.value.forEach((item, index) => {
|
||
map.set(`${item.type || 'plugin'}:${item.id}`, item.order ?? index)
|
||
})
|
||
|
||
return map
|
||
})
|
||
|
||
const folderedPluginIds = computed(() => {
|
||
const pluginIds = new Set<string>()
|
||
|
||
Object.values(pluginFolders.value).forEach(folderData => {
|
||
const plugins = Array.isArray(folderData) ? folderData : folderData.plugins || []
|
||
plugins.forEach((pluginId: string) => pluginIds.add(pluginId))
|
||
})
|
||
|
||
return pluginIds
|
||
})
|
||
|
||
const canDragSort = computed(() => sortMode.value && activeTab.value === 'installed')
|
||
const shouldVirtualizeInstalledMainList = computed(() => !sortMode.value && !currentFolder.value)
|
||
const shouldVirtualizeInstalledFolderList = computed(() => !sortMode.value && !!currentFolder.value)
|
||
const installedScrollToIndex = computed(() => {
|
||
if (sortMode.value || currentFolder.value || !pluginId.value) {
|
||
return undefined
|
||
}
|
||
|
||
const targetIndex = mixedSortList.value.findIndex(item => item.type === 'plugin' && item.id === pluginId.value)
|
||
|
||
return targetIndex >= 0 ? targetIndex : undefined
|
||
})
|
||
|
||
// 获取文件夹内筛选后的插件
|
||
const getFilteredFolderPlugins = (folderName: string) => {
|
||
const folderData = pluginFolders.value[folderName]
|
||
const folderPluginIds = Array.isArray(folderData) ? folderData : folderData?.plugins || []
|
||
|
||
// 获取文件夹内的插件并应用筛选条件
|
||
const folderPlugins: Plugin[] = []
|
||
folderPluginIds.forEach((pluginId: string) => {
|
||
const plugin = pluginByIdMap.value.get(pluginId)
|
||
if (plugin) {
|
||
folderPlugins.push(plugin)
|
||
}
|
||
})
|
||
|
||
// 应用筛选条件
|
||
return folderPlugins.filter(plugin => {
|
||
if (!installedFilter.value && !hasUpdateFilter.value && !enabledFilter.value) return true
|
||
if (hasUpdateFilter.value && enabledFilter.value) {
|
||
return plugin.has_update && plugin.state
|
||
}
|
||
if (hasUpdateFilter.value) return plugin.has_update
|
||
if (enabledFilter.value) return plugin.state
|
||
if (installedFilter.value) {
|
||
return plugin.plugin_name?.toLowerCase().includes((installedFilter.value as string).toLowerCase())
|
||
}
|
||
if (installedFilter.value) {
|
||
return plugin.plugin_name?.toLowerCase().includes((installedFilter.value as string).toLowerCase())
|
||
}
|
||
if (installedFilter.value) {
|
||
return plugin.plugin_name?.toLowerCase().includes((installedFilter.value as string).toLowerCase())
|
||
}
|
||
return true
|
||
})
|
||
}
|
||
|
||
// 显示的插件列表(考虑文件夹筛选)
|
||
const displayedPlugins = computed(() => {
|
||
if (!currentFolder.value) {
|
||
// 主列表:显示未归类的插件
|
||
return filteredDataList.value.filter(plugin => !folderedPluginIds.value.has(plugin.id))
|
||
} else {
|
||
// 文件夹内:返回筛选后的插件
|
||
return getFilteredFolderPlugins(currentFolder.value)
|
||
}
|
||
})
|
||
|
||
// 混合排序项目类型
|
||
interface MixedSortItem {
|
||
type: 'folder' | 'plugin'
|
||
id: string
|
||
data: any
|
||
order: number
|
||
}
|
||
|
||
// 混合排序列表(包含文件夹和插件)
|
||
const mixedSortList = ref<MixedSortItem[]>([])
|
||
|
||
// 可拖拽的插件列表(文件夹内用)
|
||
const draggableFolderPlugins = ref<Plugin[]>([])
|
||
|
||
// 是否正在拖拽排序中
|
||
const isDraggingSortMode = ref(false)
|
||
|
||
// 显示的文件夹列表(按排序显示)
|
||
const displayedFolders = computed(() => {
|
||
if (currentFolder.value) return [] // 在文件夹内不显示其他文件夹
|
||
|
||
const folderNames = Object.keys(pluginFolders.value)
|
||
|
||
// 按排序显示文件夹
|
||
const sortedFolderNames = [...folderOrder.value].filter(name => folderNames.includes(name))
|
||
// 添加不在排序中的新文件夹
|
||
const unsortedFolders = folderNames.filter(name => !folderOrder.value.includes(name))
|
||
sortedFolderNames.push(...unsortedFolders)
|
||
|
||
return sortedFolderNames
|
||
.map(folderName => {
|
||
const folderData = pluginFolders.value[folderName]
|
||
const config = Array.isArray(folderData) ? {} : folderData
|
||
|
||
// 获取筛选后的插件数量
|
||
const filteredPlugins = getFilteredFolderPlugins(folderName)
|
||
|
||
return {
|
||
name: folderName,
|
||
pluginCount: filteredPlugins.length,
|
||
config: config,
|
||
}
|
||
})
|
||
.filter(folder => {
|
||
// 当有筛选条件时,只显示包含筛选后插件的文件夹
|
||
if (installedFilter.value || hasUpdateFilter.value || enabledFilter.value) {
|
||
return folder.pluginCount > 0
|
||
}
|
||
return true
|
||
})
|
||
})
|
||
|
||
// 更新混合排序列表
|
||
function updateMixedSortList() {
|
||
if (isDraggingSortMode.value) return // 拖拽排序时跳过更新
|
||
|
||
if (!currentFolder.value) {
|
||
// 主列表:创建混合列表
|
||
const items: MixedSortItem[] = []
|
||
|
||
// 始终使用全局排序配置来创建混合列表
|
||
const allItems: { type: 'folder' | 'plugin'; id: string; data: any; order: number }[] = []
|
||
|
||
// 添加文件夹项目
|
||
displayedFolders.value.forEach(folder => {
|
||
allItems.push({
|
||
type: 'folder',
|
||
id: folder.name,
|
||
data: folder,
|
||
order: orderValueMap.value.get(`folder:${folder.name}`) ?? 999,
|
||
})
|
||
})
|
||
|
||
// 添加插件项目
|
||
displayedPlugins.value.forEach(plugin => {
|
||
allItems.push({
|
||
type: 'plugin',
|
||
id: plugin.id || '',
|
||
data: plugin,
|
||
order: orderValueMap.value.get(`plugin:${plugin.id}`) ?? 999,
|
||
})
|
||
})
|
||
|
||
// 按order排序
|
||
allItems.sort((a, b) => a.order - b.order)
|
||
|
||
// 转换为MixedSortItem格式
|
||
allItems.forEach((item, index) => {
|
||
items.push({
|
||
type: item.type,
|
||
id: item.id,
|
||
data: item.data,
|
||
order: index,
|
||
})
|
||
})
|
||
|
||
// 按order排序
|
||
items.sort((a, b) => a.order - b.order)
|
||
mixedSortList.value = items
|
||
} else {
|
||
// 文件夹内:只更新插件列表
|
||
draggableFolderPlugins.value = [...displayedPlugins.value]
|
||
}
|
||
}
|
||
|
||
// 监听相关数据变化,更新混合排序列表
|
||
watch(
|
||
[displayedPlugins, displayedFolders, orderConfig, folderOrder, installedFilter, hasUpdateFilter, enabledFilter],
|
||
() => {
|
||
// 只有在非拖拽状态下才更新
|
||
if (!isDraggingSortMode.value) {
|
||
updateMixedSortList()
|
||
}
|
||
},
|
||
{
|
||
immediate: true,
|
||
deep: true,
|
||
},
|
||
)
|
||
|
||
// 监听文件夹切换,更新列表
|
||
watch(currentFolder, () => {
|
||
// 只有在非拖拽状态下才更新
|
||
if (!isDraggingSortMode.value) {
|
||
updateMixedSortList()
|
||
}
|
||
})
|
||
|
||
// 加载插件顺序
|
||
async function loadPluginOrderConfig() {
|
||
try {
|
||
const response = await api.get('/user/config/PluginOrder')
|
||
if (response && response.data && response.data.value) {
|
||
const serverData = response.data.value
|
||
// 兼容服务端的旧格式和新格式
|
||
if (serverData.length > 0 && typeof serverData[0] === 'object' && 'type' in serverData[0]) {
|
||
orderConfig.value = serverData
|
||
} else {
|
||
// 旧格式,转换为新格式
|
||
orderConfig.value = serverData.map((item: any, index: number) => ({
|
||
id: typeof item === 'string' ? item : item.id,
|
||
type: 'plugin',
|
||
order: index,
|
||
}))
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load plugin order config:', error)
|
||
orderConfig.value = []
|
||
}
|
||
}
|
||
|
||
// 按order的顺序对插件进行排序
|
||
function sortPluginOrder() {
|
||
if (!orderConfig.value) {
|
||
return
|
||
}
|
||
if (dataList.value.length === 0) {
|
||
return
|
||
}
|
||
dataList.value.sort((a, b) => {
|
||
const aIndex = orderValueMap.value.get(`plugin:${a.id}`) ?? Number.MAX_SAFE_INTEGER
|
||
const bIndex = orderValueMap.value.get(`plugin:${b.id}`) ?? Number.MAX_SAFE_INTEGER
|
||
|
||
return aIndex - bIndex
|
||
})
|
||
}
|
||
|
||
// 保存混合排序
|
||
async function saveMixedSortOrder() {
|
||
try {
|
||
// 分离文件夹和插件,并记录它们的全局排序位置
|
||
const newFolderOrder: string[] = []
|
||
const newPluginOrder: Plugin[] = []
|
||
const globalOrder: { type: 'folder' | 'plugin'; id: string; order: number }[] = []
|
||
|
||
mixedSortList.value.forEach((item, index) => {
|
||
globalOrder.push({
|
||
type: item.type,
|
||
id: item.id,
|
||
order: index,
|
||
})
|
||
|
||
if (item.type === 'folder') {
|
||
newFolderOrder.push(item.id)
|
||
} else if (item.type === 'plugin') {
|
||
newPluginOrder.push(item.data)
|
||
}
|
||
})
|
||
|
||
// 更新文件夹排序并设置order属性
|
||
folderOrder.value = newFolderOrder
|
||
newFolderOrder.forEach((folderName, index) => {
|
||
if (pluginFolders.value[folderName]) {
|
||
// 找到该文件夹在全局排序中的位置
|
||
const globalOrderItem = globalOrder.find(item => item.type === 'folder' && item.id === folderName)
|
||
pluginFolders.value[folderName].order = globalOrderItem ? globalOrderItem.order : index
|
||
}
|
||
})
|
||
|
||
// 添加文件夹中的插件到插件列表末尾
|
||
Object.values(pluginFolders.value).forEach(folderData => {
|
||
const plugins = Array.isArray(folderData) ? folderData : folderData.plugins || []
|
||
plugins.forEach((id: string) => {
|
||
const folderPlugin = pluginByIdMap.value.get(id)
|
||
if (folderPlugin && !newPluginOrder.find(p => p.id === id)) {
|
||
newPluginOrder.push(folderPlugin)
|
||
}
|
||
})
|
||
})
|
||
|
||
// 更新插件列表
|
||
filteredDataList.value = newPluginOrder
|
||
|
||
// 保存插件排序配置(包含全局排序信息)
|
||
const orderObj = globalOrder.map(item => ({
|
||
id: item.id,
|
||
type: item.type,
|
||
order: item.order,
|
||
}))
|
||
orderConfig.value = orderObj
|
||
|
||
// 保存到服务端
|
||
await api.post('/user/config/PluginOrder', orderObj)
|
||
|
||
// 保存文件夹排序
|
||
await savePluginFolders()
|
||
} catch (error) {
|
||
console.error(error)
|
||
} finally {
|
||
// 清除拖拽标志
|
||
isDraggingSortMode.value = false
|
||
|
||
// 在清除拖拽标志后更新混合排序列表显示
|
||
updateMixedSortList()
|
||
}
|
||
}
|
||
|
||
// 保存文件夹内插件顺序
|
||
async function saveFolderPluginOrder() {
|
||
if (!currentFolder.value) return
|
||
|
||
try {
|
||
// 更新文件夹内插件顺序
|
||
const folderData = pluginFolders.value[currentFolder.value]
|
||
if (folderData) {
|
||
const newPluginIds = draggableFolderPlugins.value.map(plugin => plugin.id)
|
||
|
||
if (Array.isArray(folderData)) {
|
||
// 旧格式,直接替换数组
|
||
pluginFolders.value[currentFolder.value] = newPluginIds
|
||
} else {
|
||
// 新格式,更新plugins字段
|
||
folderData.plugins = newPluginIds
|
||
}
|
||
|
||
// 更新全局排序配置中文件夹内插件的顺序
|
||
const folderOrderItem = orderConfig.value.find(
|
||
(item: any) => item.type === 'folder' && item.id === currentFolder.value,
|
||
)
|
||
const folderGlobalOrder = folderOrderItem?.order ?? 999
|
||
|
||
// 为文件夹内的插件分配连续的order值
|
||
newPluginIds.forEach((pluginId, index) => {
|
||
const existingItem = orderConfig.value.find((item: any) => item.type === 'plugin' && item.id === pluginId)
|
||
if (existingItem) {
|
||
existingItem.order = folderGlobalOrder + 0.1 + index * 0.01 // 使用小数确保在文件夹后面
|
||
} else {
|
||
orderConfig.value.push({
|
||
id: pluginId,
|
||
type: 'plugin',
|
||
order: folderGlobalOrder + 0.1 + index * 0.01,
|
||
})
|
||
}
|
||
})
|
||
|
||
// 保存全局排序配置
|
||
await api.post('/user/config/PluginOrder', orderConfig.value)
|
||
|
||
// 保存到后端
|
||
await savePluginFolders()
|
||
}
|
||
} catch (error) {
|
||
console.error(error)
|
||
} finally {
|
||
// 清除拖拽标志
|
||
isDraggingSortMode.value = false
|
||
}
|
||
}
|
||
|
||
// 初始化过滤选项
|
||
function initOptions(item: Plugin) {
|
||
const optionValue = (options: Array<string>, value: string | undefined, preferred = false) => {
|
||
if (!value || options.includes(value)) return
|
||
if (preferred) options.unshift(value)
|
||
else options.push(value)
|
||
}
|
||
const optionMutipleValue = (options: Array<string>, value: string | undefined) => {
|
||
value && value.split(',').forEach(v => !options.includes(v) && options.push(v))
|
||
}
|
||
optionValue(authorFilterOptions.value, item.plugin_author)
|
||
optionMutipleValue(labelFilterOptions.value, item.plugin_label)
|
||
optionValue(
|
||
repoFilterOptions.value,
|
||
handleRepoUrl(item),
|
||
Boolean(item.is_local || item.repo_url?.startsWith('local://')),
|
||
)
|
||
}
|
||
|
||
// 关闭插件市场窗口
|
||
function pluginDialogClose() {
|
||
PluginAppDialog.value = false
|
||
}
|
||
|
||
// 打开插件安装进度弹窗。
|
||
function openPluginProgressDialog(text: string) {
|
||
progressDialogController?.close()
|
||
progressDialogController = openSharedDialog(ProgressDialog, { text }, {}, { closeOn: false })
|
||
}
|
||
|
||
// 关闭插件安装进度弹窗。
|
||
function closePluginProgressDialog() {
|
||
progressDialogController?.close()
|
||
progressDialogController = null
|
||
}
|
||
|
||
// 安装插件
|
||
async function installPlugin(item: Plugin) {
|
||
try {
|
||
// 显示等待提示框
|
||
progressText.value = t('plugin.installing', { name: item?.plugin_name, version: item?.plugin_version })
|
||
openPluginProgressDialog(progressText.value)
|
||
|
||
const result: { [key: string]: any } = await api.get(`plugin/install/${item?.id}`, {
|
||
params: {
|
||
repo_url: item?.repo_url,
|
||
force: item?.has_update,
|
||
},
|
||
})
|
||
|
||
// 隐藏等待提示框
|
||
closePluginProgressDialog()
|
||
|
||
if (result.success) {
|
||
$toast.success(t('plugin.installSuccess', { name: item?.plugin_name }))
|
||
// 清空过滤条件
|
||
hasUpdateFilter.value = false
|
||
enabledFilter.value = false
|
||
installedFilter.value = null
|
||
// 刷新
|
||
await refreshData()
|
||
} else {
|
||
$toast.error(t('plugin.installFailed', { name: item?.plugin_name, message: result.message }))
|
||
}
|
||
} catch (error) {
|
||
closePluginProgressDialog()
|
||
console.error(error)
|
||
}
|
||
}
|
||
|
||
// 打开插件搜索结果
|
||
function openPlugin(item: Plugin) {
|
||
// 如果是已安装插件则打开插件详情
|
||
if (item.installed === true) {
|
||
// 标记插件动作
|
||
pluginActions.value[item.id || '0'] = true
|
||
} else {
|
||
// 如果是未安装插件则安装
|
||
installPlugin(item)
|
||
}
|
||
closeSearchDialog()
|
||
}
|
||
|
||
// 关闭插件搜索窗口
|
||
function closeSearchDialog() {
|
||
searchDialogController?.close()
|
||
searchDialogController = null
|
||
}
|
||
|
||
// 过滤插件
|
||
const filterPlugins = computed(() => {
|
||
const all_list = [...dataList.value, ...uninstalledList.value]
|
||
return all_list.filter((item: Plugin) => {
|
||
// 需要忽略大小写
|
||
return (
|
||
item.plugin_name?.toLowerCase().includes(keyword.value.toLowerCase()) ||
|
||
item.plugin_desc?.toLowerCase().includes(keyword.value.toLowerCase()) ||
|
||
!keyword
|
||
)
|
||
})
|
||
})
|
||
|
||
// 获取插件列表数据
|
||
async function fetchInstalledPlugins(context: KeepAliveRefreshContext = {}) {
|
||
const showLoading = !context.silent || !isRefreshed.value
|
||
|
||
try {
|
||
if (showLoading) {
|
||
loading.value = true
|
||
}
|
||
dataList.value = await api.get('plugin/', {
|
||
params: {
|
||
state: 'installed',
|
||
},
|
||
})
|
||
// 排序
|
||
sortPluginOrder()
|
||
isRefreshed.value = true
|
||
} catch (error) {
|
||
console.error(error)
|
||
} finally {
|
||
if (showLoading) {
|
||
loading.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// 获取未安装插件列表数据
|
||
async function fetchUninstalledPlugins(force: boolean = false, context: KeepAliveRefreshContext = {}) {
|
||
const showLoading = !context.silent || !isAppMarketLoaded.value
|
||
|
||
try {
|
||
if (showLoading) {
|
||
loading.value = true
|
||
}
|
||
uninstalledList.value = await api.get('plugin/', {
|
||
params: {
|
||
state: 'market',
|
||
force: force,
|
||
},
|
||
})
|
||
// 设置更新状态
|
||
for (const uninstalled of uninstalledList.value) {
|
||
for (const data of dataList.value) {
|
||
if (uninstalled.id === data.id) {
|
||
data.has_update = true
|
||
data.repo_url = uninstalled.repo_url
|
||
data.history = uninstalled.history
|
||
}
|
||
}
|
||
}
|
||
isRefreshed.value = true
|
||
// 更新插件市场列表
|
||
// 排除已安装且有更新的,上面的问题在于"本地存在未安装的旧版本插件且云端有更新时"不会在插件市场展示
|
||
marketList.value = uninstalledList.value.filter(item => !(item.has_update && item.installed))
|
||
// 初始化过滤选项
|
||
repoFilterOptions.value = []
|
||
marketList.value.forEach(initOptions)
|
||
// 设置APP市场加载完成
|
||
isAppMarketLoaded.value = true
|
||
} catch (error) {
|
||
console.error(error)
|
||
} finally {
|
||
if (showLoading) {
|
||
loading.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// 加载插件统计数据
|
||
async function getPluginStatistics() {
|
||
try {
|
||
PluginStatistics.value = await api.get('plugin/statistic')
|
||
} catch (error) {
|
||
console.error(error)
|
||
}
|
||
}
|
||
|
||
// 加载所有数据
|
||
async function refreshData(context: KeepAliveRefreshContext = {}) {
|
||
await fetchInstalledPlugins(context)
|
||
await fetchUninstalledPlugins(false, context)
|
||
await getPluginStatistics()
|
||
// 重新加载文件夹配置,确保分身插件能正确显示在文件夹中
|
||
await loadPluginFolders()
|
||
}
|
||
|
||
// 对uninstalledList进行排序到sortedUninstalledList
|
||
watch([marketList, filterForm, activeSort, PluginStatistics], () => {
|
||
// 匹配过滤函数
|
||
const match = (filter: Array<string>, value: string | undefined) =>
|
||
filter.length === 0 || (value && filter.includes(value))
|
||
const matchMultiple = (filter: Array<string>, value: string | undefined) =>
|
||
filter.length === 0 || (value && value.split(',').some(v => filter.includes(v)))
|
||
const filterText = (filter: string, value: string | undefined) =>
|
||
!filter || (value && value.toLowerCase().includes(filter.toLowerCase()))
|
||
|
||
sortedUninstalledList.value = []
|
||
|
||
// 过滤
|
||
marketList.value.forEach(value => {
|
||
if (value) {
|
||
if (
|
||
filterText(filterForm.name, `${value.plugin_name} ${value.plugin_desc}`) &&
|
||
match(filterForm.author, value.plugin_author) &&
|
||
matchMultiple(filterForm.label, value.plugin_label) &&
|
||
match(filterForm.repo, handleRepoUrl(value))
|
||
) {
|
||
sortedUninstalledList.value.push(value)
|
||
}
|
||
}
|
||
})
|
||
|
||
// 排序
|
||
if (!isNullOrEmptyObject(PluginStatistics.value)) {
|
||
if (!activeSort.value || activeSort.value === 'count') {
|
||
sortedUninstalledList.value = sortedUninstalledList.value.sort((a, b) => {
|
||
return (PluginStatistics.value[b.id || '0'] ?? 0) - (PluginStatistics.value[a.id || '0'] ?? 0)
|
||
})
|
||
} else if (activeSort.value) {
|
||
sortedUninstalledList.value = sortedUninstalledList.value.sort((a: any, b: any) => {
|
||
return a[activeSort.value ?? ''] > b[activeSort.value ?? ''] ? 1 : -1
|
||
})
|
||
}
|
||
}
|
||
|
||
// 显示前20个
|
||
displayUninstalledList.value = sortedUninstalledList.value.splice(0, 20)
|
||
})
|
||
|
||
// 新安装了插件
|
||
async function pluginInstalled() {
|
||
pluginDialogClose()
|
||
await refreshData()
|
||
}
|
||
|
||
// 插件市场设置完成
|
||
function marketSettingDone() {
|
||
// 重新加载数据
|
||
refreshData()
|
||
}
|
||
|
||
// 手动刷新插件市场
|
||
async function refreshMarket() {
|
||
const showMarketLoading = !isAppMarketLoaded.value
|
||
if (showMarketLoading) {
|
||
isMarketRefreshing.value = true
|
||
}
|
||
try {
|
||
await fetchUninstalledPlugins(true, { silent: isAppMarketLoaded.value, source: 'manual' })
|
||
await getPluginStatistics()
|
||
} catch (error) {
|
||
console.error(error)
|
||
} finally {
|
||
if (showMarketLoading) {
|
||
isMarketRefreshing.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
async function refreshActiveTabData(context: KeepAliveRefreshContext = {}) {
|
||
if (sortMode.value || isDraggingSortMode.value) return
|
||
|
||
if (activeTab.value === 'market') {
|
||
await fetchUninstalledPlugins(false, context)
|
||
await getPluginStatistics()
|
||
return
|
||
}
|
||
|
||
await fetchInstalledPlugins(context)
|
||
await getPluginStatistics()
|
||
// 文件夹配置可能在其它入口被插件操作改变,重新进入时同步一次。
|
||
await loadPluginFolders()
|
||
}
|
||
|
||
function parseLocalRepoPath(repoUrl: string | undefined) {
|
||
if (!repoUrl?.startsWith('local://')) return ''
|
||
|
||
try {
|
||
return new URL(repoUrl).searchParams.get('path') || ''
|
||
} catch (error) {
|
||
return decodeURIComponent(repoUrl.match(/[?&]path=([^&]+)/)?.[1] || '')
|
||
}
|
||
}
|
||
|
||
// 处理掉github地址的前缀
|
||
function handleRepoUrl(item: Plugin | string | undefined) {
|
||
const url = typeof item === 'string' ? item : item?.repo_url
|
||
if (!url) return ''
|
||
if (url.startsWith('local://')) return parseLocalRepoPath(url) || localRepoLabel.value
|
||
if (typeof item !== 'string' && item?.is_local) return parseLocalRepoPath(url) || localRepoLabel.value
|
||
return url.replace('https://github.com/', '').replace('https://raw.githubusercontent.com/', '')
|
||
}
|
||
|
||
// 监测dataList变化或installedFilter、hasUpdateFilter变化时更新filteredDataList
|
||
watch([dataList, installedFilter, hasUpdateFilter, enabledFilter], () => {
|
||
filteredDataList.value = dataList.value.filter(item => {
|
||
if (!installedFilter.value && !hasUpdateFilter.value && !enabledFilter.value) return true
|
||
if (hasUpdateFilter.value && enabledFilter.value) {
|
||
return item.has_update && item.state
|
||
}
|
||
if (hasUpdateFilter.value) return item.has_update
|
||
if (enabledFilter.value) return item.state
|
||
if (installedFilter.value) {
|
||
return item.plugin_name?.toLowerCase().includes((installedFilter.value as string).toLowerCase())
|
||
}
|
||
return true
|
||
})
|
||
})
|
||
|
||
// 插件市场加载更多数据
|
||
function loadMarketMore({ done }: { done: any }) {
|
||
// 从 dataList 中获取最前面的 20 个元素
|
||
const itemsToMove = sortedUninstalledList.value.splice(0, 20)
|
||
if (itemsToMove.length === 0) {
|
||
done('empty')
|
||
return
|
||
}
|
||
|
||
displayUninstalledList.value.push(...itemsToMove)
|
||
done('ok')
|
||
}
|
||
|
||
// 组件挂载后
|
||
|
||
onMounted(async () => {
|
||
await loadPluginOrderConfig()
|
||
await loadPluginFolders() // 加载文件夹配置
|
||
await refreshData()
|
||
if (activeTab.value != 'market' && pluginId.value) {
|
||
// 找到这个插件
|
||
const plugin = dataList.value.find(item => item.id === pluginId.value)
|
||
if (plugin) {
|
||
plugin.page_open = true
|
||
}
|
||
}
|
||
})
|
||
|
||
const { refresh: refreshKeepAliveData } = useKeepAliveRefresh(refreshActiveTabData)
|
||
|
||
watch(activeTab, (newTab, oldTab) => {
|
||
if (!oldTab || newTab === oldTab) return
|
||
|
||
refreshKeepAliveData({ silent: true, source: 'tab' })
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
closePluginProgressDialog()
|
||
folderCreateDialogController?.close()
|
||
searchDialogController?.close()
|
||
})
|
||
|
||
function openPluginSearchDialog() {
|
||
searchDialogController = openSharedDialog(
|
||
PluginSearchDialog,
|
||
{
|
||
keyword: keyword.value,
|
||
plugins: filterPlugins.value,
|
||
},
|
||
{
|
||
'open-plugin': openPlugin,
|
||
'update:keyword': (value: string) => {
|
||
keyword.value = value
|
||
searchDialogController?.updateProps({ keyword: value, plugins: filterPlugins.value })
|
||
},
|
||
},
|
||
{ closeOn: ['close'] },
|
||
)
|
||
}
|
||
|
||
function openMarketSettingDialog() {
|
||
openSharedDialog(
|
||
PluginMarketSettingDialog,
|
||
{},
|
||
{
|
||
save: marketSettingDone,
|
||
},
|
||
{ closeOn: ['close', 'save'] },
|
||
)
|
||
}
|
||
|
||
const showSearchAction = computed(() => activeTab.value === 'installed' || activeTab.value === 'market')
|
||
const showNewFolderAction = computed(() => activeTab.value === 'installed' && !currentFolder.value)
|
||
const showMarketSettingAction = computed(() => activeTab.value === 'market')
|
||
|
||
const pluginDynamicMenuItems = computed(() => {
|
||
if (!appMode.value) return undefined
|
||
if (!showSearchAction.value) return undefined
|
||
|
||
const items = [
|
||
{
|
||
titleKey: 'plugin.searchPlugins',
|
||
icon: 'mdi-magnify',
|
||
action: openPluginSearchDialog,
|
||
},
|
||
]
|
||
|
||
if (showNewFolderAction.value) {
|
||
items.push({
|
||
titleKey: 'plugin.newFolder',
|
||
icon: 'mdi-folder-plus',
|
||
action: showNewFolderDialog,
|
||
})
|
||
}
|
||
|
||
if (showMarketSettingAction.value) {
|
||
items.push({
|
||
titleKey: 'dialog.pluginMarketSetting.title',
|
||
icon: 'mdi-store-cog',
|
||
action: openMarketSettingDialog,
|
||
})
|
||
}
|
||
|
||
return items.length > 1 ? items : undefined
|
||
})
|
||
|
||
useDynamicButton({
|
||
icon: 'mdi-magnify',
|
||
onClick: openPluginSearchDialog,
|
||
menuItems: pluginDynamicMenuItems,
|
||
show: computed(() => appMode.value && showSearchAction.value && isRefreshed.value),
|
||
})
|
||
|
||
// 获取插件文件夹配置
|
||
async function loadPluginFolders() {
|
||
try {
|
||
const response = await api.get('plugin/folders')
|
||
const foldersData: any = response && typeof response === 'object' ? response : {}
|
||
|
||
// 处理旧格式兼容性(array)和新格式(object with config)
|
||
const processedFolders: any = {}
|
||
const order = []
|
||
|
||
Object.keys(foldersData).forEach(folderName => {
|
||
const folderData = foldersData[folderName]
|
||
|
||
if (Array.isArray(folderData)) {
|
||
// 旧格式:直接是插件数组
|
||
processedFolders[folderName] = {
|
||
plugins: folderData,
|
||
order: order.length,
|
||
icon: defaultIcon,
|
||
color: defaultColor,
|
||
gradient: defaultGradient,
|
||
background: '',
|
||
showIcon: true,
|
||
}
|
||
} else if (folderData && typeof folderData === 'object') {
|
||
// 新格式:包含配置的对象
|
||
processedFolders[folderName] = {
|
||
plugins: folderData.plugins || [],
|
||
order: folderData.order ?? order.length,
|
||
icon: folderData.icon || defaultIcon,
|
||
color: folderData.color || defaultColor,
|
||
gradient: folderData.gradient || defaultGradient,
|
||
background: folderData.background || '',
|
||
showIcon: folderData.showIcon !== undefined ? folderData.showIcon : true,
|
||
}
|
||
}
|
||
|
||
order.push(folderName)
|
||
})
|
||
|
||
pluginFolders.value = processedFolders
|
||
|
||
// 设置文件夹排序 - 使用全局排序配置
|
||
const folderNames = Object.keys(processedFolders)
|
||
folderOrder.value = folderNames.sort((a, b) => {
|
||
const aOrder = orderValueMap.value.get(`folder:${a}`) ?? processedFolders[a].order ?? 999
|
||
const bOrder = orderValueMap.value.get(`folder:${b}`) ?? processedFolders[b].order ?? 999
|
||
|
||
return aOrder - bOrder
|
||
})
|
||
} catch (error) {
|
||
pluginFolders.value = {}
|
||
folderOrder.value = []
|
||
}
|
||
}
|
||
|
||
// 保存插件文件夹配置
|
||
async function savePluginFolders() {
|
||
try {
|
||
// 更新排序信息
|
||
const foldersToSave: any = {}
|
||
Object.keys(pluginFolders.value).forEach(folderName => {
|
||
const folderData = pluginFolders.value[folderName]
|
||
const orderIndex = folderOrder.value.indexOf(folderName)
|
||
|
||
foldersToSave[folderName] = {
|
||
...folderData,
|
||
order: orderIndex >= 0 ? orderIndex : 999,
|
||
}
|
||
})
|
||
|
||
await api.post('plugin/folders', foldersToSave)
|
||
} catch (error) {
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 创建新文件夹
|
||
async function createNewFolder() {
|
||
if (!newFolderName.value.trim()) {
|
||
$toast.error(t('plugin.folderNameEmpty'))
|
||
return
|
||
}
|
||
|
||
if (pluginFolders.value[newFolderName.value]) {
|
||
$toast.error(t('plugin.folderExists'))
|
||
return
|
||
}
|
||
|
||
try {
|
||
// 直接在本地添加文件夹
|
||
pluginFolders.value[newFolderName.value] = {
|
||
plugins: [],
|
||
order: folderOrder.value.length,
|
||
icon: defaultIcon,
|
||
color: defaultColor,
|
||
gradient: defaultGradient,
|
||
background: '',
|
||
showIcon: true,
|
||
}
|
||
|
||
// 添加到排序列表
|
||
folderOrder.value.push(newFolderName.value)
|
||
|
||
// 保存到后端
|
||
await savePluginFolders()
|
||
|
||
folderCreateDialogController?.close()
|
||
folderCreateDialogController = null
|
||
newFolderName.value = ''
|
||
$toast.success(t('plugin.folderCreateSuccess'))
|
||
} catch (error) {
|
||
// 回滚本地更改
|
||
delete pluginFolders.value[newFolderName.value]
|
||
folderOrder.value = folderOrder.value.filter(name => name !== newFolderName.value)
|
||
$toast.error(t('plugin.folderCreateFailed'))
|
||
}
|
||
}
|
||
|
||
// 打开文件夹
|
||
function openFolder(folderName: string) {
|
||
currentFolder.value = folderName
|
||
}
|
||
|
||
// 返回主列表
|
||
function backToMain() {
|
||
currentFolder.value = ''
|
||
}
|
||
|
||
// 重命名文件夹
|
||
async function renameFolder(oldName: string, newName: string) {
|
||
if (pluginFolders.value[newName]) {
|
||
$toast.error(t('plugin.folderExists'))
|
||
return
|
||
}
|
||
|
||
try {
|
||
// 更新本地状态
|
||
const folderData = pluginFolders.value[oldName] || { plugins: [] }
|
||
pluginFolders.value[newName] = folderData
|
||
delete pluginFolders.value[oldName]
|
||
|
||
// 更新排序列表
|
||
const orderIndex = folderOrder.value.indexOf(oldName)
|
||
if (orderIndex >= 0) {
|
||
folderOrder.value[orderIndex] = newName
|
||
}
|
||
|
||
// 如果正在查看该文件夹,更新当前文件夹名
|
||
if (currentFolder.value === oldName) {
|
||
currentFolder.value = newName
|
||
}
|
||
|
||
// 保存到后端
|
||
await savePluginFolders()
|
||
|
||
$toast.success(t('plugin.folderRenameSuccess'))
|
||
} catch (error) {
|
||
console.error(error)
|
||
// 回滚本地更改
|
||
pluginFolders.value[oldName] = pluginFolders.value[newName] || { plugins: [] }
|
||
delete pluginFolders.value[newName]
|
||
const orderIndex = folderOrder.value.indexOf(newName)
|
||
if (orderIndex >= 0) {
|
||
folderOrder.value[orderIndex] = oldName
|
||
}
|
||
if (currentFolder.value === newName) {
|
||
currentFolder.value = oldName
|
||
}
|
||
$toast.error(t('plugin.folderRenameFailed'))
|
||
}
|
||
}
|
||
|
||
// 删除文件夹
|
||
async function deleteFolder(folderName: string) {
|
||
// 保存被删除的文件夹内容以便回滚
|
||
const deletedFolder = { ...pluginFolders.value[folderName] }
|
||
try {
|
||
delete pluginFolders.value[folderName]
|
||
|
||
// 从排序列表中移除
|
||
folderOrder.value = folderOrder.value.filter(name => name !== folderName)
|
||
|
||
// 如果正在查看该文件夹,返回主列表
|
||
if (currentFolder.value === folderName) {
|
||
currentFolder.value = ''
|
||
}
|
||
|
||
// 保存到后端
|
||
await savePluginFolders()
|
||
|
||
$toast.success(t('plugin.folderDeleteSuccess'))
|
||
} catch (error) {
|
||
// 回滚本地更改
|
||
pluginFolders.value[folderName] = deletedFolder
|
||
if (!folderOrder.value.includes(folderName)) {
|
||
folderOrder.value.push(folderName)
|
||
}
|
||
$toast.error(t('plugin.folderDeleteFailed'))
|
||
}
|
||
}
|
||
|
||
// 显示新建文件夹对话框
|
||
function showNewFolderDialog() {
|
||
newFolderName.value = ''
|
||
folderCreateDialogController = openSharedDialog(
|
||
PluginFolderCreateDialog,
|
||
{ name: newFolderName.value },
|
||
{
|
||
create: createNewFolder,
|
||
'update:name': (value: string) => {
|
||
newFolderName.value = value
|
||
folderCreateDialogController?.updateProps({ name: value })
|
||
},
|
||
},
|
||
{ closeOn: ['close'] },
|
||
)
|
||
}
|
||
|
||
// 移出文件夹
|
||
async function removeFromFolder(pluginId: string) {
|
||
if (!currentFolder.value) return
|
||
|
||
try {
|
||
// 从当前文件夹中移除插件
|
||
const folderData = pluginFolders.value[currentFolder.value]
|
||
const plugins = Array.isArray(folderData) ? folderData : folderData?.plugins || []
|
||
const index = plugins.indexOf(pluginId)
|
||
if (index > -1) {
|
||
plugins.splice(index, 1)
|
||
if (!Array.isArray(folderData)) {
|
||
folderData.plugins = plugins
|
||
}
|
||
|
||
// 保存配置
|
||
await savePluginFolders()
|
||
|
||
$toast.success(t('plugin.removeFromFolderSuccess'))
|
||
}
|
||
} catch (error) {
|
||
console.error(error)
|
||
$toast.error(t('plugin.operationFailed'))
|
||
}
|
||
}
|
||
|
||
// 更新文件夹配置
|
||
async function updateFolderConfig(folderName: string, config: any) {
|
||
try {
|
||
// 更新本地配置
|
||
if (pluginFolders.value[folderName]) {
|
||
pluginFolders.value[folderName] = {
|
||
...pluginFolders.value[folderName],
|
||
...config,
|
||
}
|
||
|
||
// 保存到后端
|
||
await savePluginFolders()
|
||
}
|
||
} catch (error) {
|
||
$toast.error(t('plugin.saveFolderConfigFailed'))
|
||
}
|
||
}
|
||
|
||
// 当前拖拽的插件ID
|
||
const currentDraggedPluginId = ref('')
|
||
|
||
// 处理拖拽到文件夹的事件
|
||
async function handleDropToFolder(event: DragEvent, folderName: string) {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
const target = event.currentTarget as HTMLElement
|
||
target.classList.remove('drag-over')
|
||
|
||
// 使用跟踪的插件ID
|
||
const pluginId = currentDraggedPluginId.value
|
||
|
||
if (!pluginId) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
// 检查是否是文件夹名(忽略文件夹拖入文件夹的情况)
|
||
if (Object.keys(pluginFolders.value).includes(pluginId)) {
|
||
return
|
||
}
|
||
|
||
// 验证插件ID
|
||
const plugin = pluginByIdMap.value.get(pluginId)
|
||
|
||
if (!plugin) {
|
||
return
|
||
}
|
||
|
||
// 获取目标文件夹数据
|
||
const targetFolderData = pluginFolders.value[folderName] || { plugins: [] }
|
||
const targetPlugins = Array.isArray(targetFolderData) ? targetFolderData : targetFolderData.plugins || []
|
||
|
||
// 检查插件是否已在此文件夹中
|
||
if (targetPlugins.includes(pluginId)) {
|
||
$toast.warning('插件已在此文件夹中')
|
||
return
|
||
}
|
||
|
||
// 从其他文件夹中移除该插件
|
||
Object.keys(pluginFolders.value).forEach(fname => {
|
||
if (fname !== folderName) {
|
||
const folderData = pluginFolders.value[fname]
|
||
const plugins = Array.isArray(folderData) ? folderData : folderData.plugins || []
|
||
const index = plugins.indexOf(pluginId)
|
||
if (index > -1) {
|
||
plugins.splice(index, 1)
|
||
if (!Array.isArray(folderData)) {
|
||
folderData.plugins = plugins
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
// 从主列表中移除(如果存在)
|
||
const mainIndex = mixedSortList.value.findIndex(item => item.type === 'plugin' && item.id === pluginId)
|
||
if (mainIndex > -1) {
|
||
mixedSortList.value.splice(mainIndex, 1)
|
||
}
|
||
|
||
// 添加到目标文件夹
|
||
if (!pluginFolders.value[folderName]) {
|
||
pluginFolders.value[folderName] = {
|
||
plugins: [],
|
||
order: folderOrder.value.length,
|
||
icon: defaultIcon,
|
||
color: defaultColor,
|
||
gradient: defaultGradient,
|
||
background: '',
|
||
showIcon: true,
|
||
}
|
||
}
|
||
|
||
const targetFolder = pluginFolders.value[folderName]
|
||
if (Array.isArray(targetFolder)) {
|
||
targetFolder.push(pluginId)
|
||
} else {
|
||
targetFolder.plugins = targetFolder.plugins || []
|
||
targetFolder.plugins.push(pluginId)
|
||
}
|
||
|
||
// 保存配置
|
||
await savePluginFolders()
|
||
|
||
// 更新混合排序列表
|
||
updateMixedSortList()
|
||
|
||
$toast.success(`插件已移动到文件夹 "${folderName}"`)
|
||
} catch (error) {
|
||
$toast.error('操作失败')
|
||
}
|
||
}
|
||
|
||
// 拖拽开始事件(修复版本)
|
||
function onDragStartPlugin(evt: any) {
|
||
// 设置拖拽模式标志
|
||
isDraggingSortMode.value = true
|
||
|
||
// 从oldIndex获取插件ID
|
||
const oldIndex = evt.oldIndex
|
||
if (oldIndex !== undefined) {
|
||
if (currentFolder.value) {
|
||
const plugin = draggableFolderPlugins.value[oldIndex]
|
||
if (plugin && plugin.id) {
|
||
currentDraggedPluginId.value = plugin.id
|
||
return
|
||
}
|
||
} else {
|
||
const item = mixedSortList.value[oldIndex]
|
||
if (item && item.id) {
|
||
currentDraggedPluginId.value = item.id
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// 从拖拽元素获取
|
||
const item = evt.item
|
||
if (item && item.dataset && item.dataset.pluginId) {
|
||
currentDraggedPluginId.value = item.dataset.pluginId
|
||
return
|
||
}
|
||
|
||
// 查找data-plugin-id属性
|
||
const pluginCard = item?.querySelector('[data-plugin-id]')
|
||
if (pluginCard) {
|
||
currentDraggedPluginId.value = pluginCard.getAttribute('data-plugin-id') || ''
|
||
return
|
||
}
|
||
|
||
// 直接从元素属性获取
|
||
if (item && item.getAttribute && item.getAttribute('data-plugin-id')) {
|
||
currentDraggedPluginId.value = item.getAttribute('data-plugin-id')
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<!-- 已安装插件过滤下拉菜单 -->
|
||
<Teleport to="body" v-if="filterInstalledPluginDialog">
|
||
<VMenu
|
||
v-model="filterInstalledPluginDialog"
|
||
:close-on-content-click="false"
|
||
:activator="'[data-menu-activator=installed-filter-btn]'"
|
||
location="bottom end"
|
||
>
|
||
<VCard min-width="220">
|
||
<!-- 名称搜索 -->
|
||
<div class="pa-3">
|
||
<VCombobox
|
||
v-model="installedFilter"
|
||
:items="installedPluginNames"
|
||
:placeholder="t('plugin.name')"
|
||
prepend-inner-icon="mdi-magnify"
|
||
density="compact"
|
||
variant="outlined"
|
||
hide-details
|
||
clearable
|
||
/>
|
||
</div>
|
||
<VDivider class="mt-2" />
|
||
<!-- 快捷筛选 -->
|
||
<VList density="compact" class="px-2 py-1">
|
||
<VListSubheader>{{ t('common.filter') }}</VListSubheader>
|
||
<VListItem :active="enabledFilter" @click="enabledFilter = !enabledFilter" density="compact">
|
||
<template #prepend>
|
||
<VIcon icon="mdi-play-circle" color="success" size="small" />
|
||
</template>
|
||
<VListItemTitle>{{ t('plugin.running') }}</VListItemTitle>
|
||
<template #append>
|
||
<VIcon v-if="enabledFilter" icon="mdi-check" color="primary" size="small" />
|
||
</template>
|
||
</VListItem>
|
||
<VListItem :active="hasUpdateFilter" @click="hasUpdateFilter = !hasUpdateFilter" density="compact">
|
||
<template #prepend>
|
||
<VIcon icon="mdi-arrow-up-circle" color="info" size="small" />
|
||
</template>
|
||
<VListItemTitle>{{ t('plugin.hasNewVersion') }}</VListItemTitle>
|
||
<template #append>
|
||
<VIcon v-if="hasUpdateFilter" icon="mdi-check" color="primary" size="small" />
|
||
</template>
|
||
</VListItem>
|
||
</VList>
|
||
</VCard>
|
||
</VMenu>
|
||
</Teleport>
|
||
|
||
<!-- 插件市场过滤下拉菜单 -->
|
||
<Teleport to="body" v-if="filterMarketPluginDialog">
|
||
<VMenu
|
||
v-model="filterMarketPluginDialog"
|
||
:close-on-content-click="false"
|
||
:activator="'[data-menu-activator=market-filter-btn]'"
|
||
location="bottom end"
|
||
>
|
||
<VCard min-width="260" max-width="320">
|
||
<!-- 名称搜索 -->
|
||
<div class="pa-3">
|
||
<VTextField
|
||
v-model="filterForm.name"
|
||
:placeholder="t('plugin.name')"
|
||
prepend-inner-icon="mdi-magnify"
|
||
density="compact"
|
||
variant="outlined"
|
||
hide-details
|
||
clearable
|
||
/>
|
||
</div>
|
||
<VDivider class="mt-2" />
|
||
<!-- 排序 -->
|
||
<VList density="compact" class="px-2 py-1">
|
||
<VListSubheader>{{ t('plugin.sortTitle') }}</VListSubheader>
|
||
<VListItem
|
||
v-for="option in sortOptions"
|
||
:key="option.value"
|
||
:active="(activeSort || 'count') === option.value"
|
||
@click="activeSort = option.value"
|
||
density="compact"
|
||
>
|
||
<VListItemTitle>{{ option.title }}</VListItemTitle>
|
||
<template #append>
|
||
<VIcon v-if="(activeSort || 'count') === option.value" icon="mdi-check" color="primary" size="small" />
|
||
</template>
|
||
</VListItem>
|
||
</VList>
|
||
<!-- 下拉多选筛选项 -->
|
||
<VDivider />
|
||
<div class="px-3 py-2 d-flex flex-column gap-2">
|
||
<VSelect
|
||
v-if="authorFilterOptions.length > 0"
|
||
v-model="filterForm.author"
|
||
:items="authorFilterOptions"
|
||
:label="t('plugin.author')"
|
||
multiple
|
||
chips
|
||
closable-chips
|
||
density="compact"
|
||
variant="outlined"
|
||
hide-details
|
||
clearable
|
||
/>
|
||
<VSelect
|
||
v-if="labelFilterOptions.length > 0"
|
||
v-model="filterForm.label"
|
||
:items="labelFilterOptions"
|
||
:label="t('plugin.label')"
|
||
multiple
|
||
chips
|
||
closable-chips
|
||
density="compact"
|
||
variant="outlined"
|
||
hide-details
|
||
clearable
|
||
/>
|
||
<VSelect
|
||
v-if="repoFilterOptions.length > 0"
|
||
v-model="filterForm.repo"
|
||
:items="repoFilterOptions"
|
||
:label="t('plugin.repository')"
|
||
multiple
|
||
chips
|
||
closable-chips
|
||
density="compact"
|
||
variant="outlined"
|
||
hide-details
|
||
clearable
|
||
/>
|
||
</div>
|
||
</VCard>
|
||
</VMenu>
|
||
</Teleport>
|
||
|
||
<VWindow v-model="activeTab" class="disable-tab-transition px-2" :touch="false">
|
||
<!-- 我的插件 -->
|
||
<VWindowItem value="installed">
|
||
<transition name="fade-slide" appear>
|
||
<div>
|
||
<VPageContentTitle v-if="installedFilter" :title="t('plugin.filter', { name: installedFilter })" />
|
||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4">
|
||
<div class="d-flex flex-wrap align-center justify-space-between gap-2">
|
||
<span>{{ t('common.sortModeHint') }}</span>
|
||
<VBtn variant="tonal" color="error" @click="sortMode = false">
|
||
{{ t('common.exit') }}
|
||
</VBtn>
|
||
</div>
|
||
</VAlert>
|
||
|
||
<!-- 文件夹和插件网格 -->
|
||
<div v-if="(mixedSortList.length > 0 || displayedPlugins.length > 0) && isRefreshed">
|
||
<!-- 混合排序列表(文件夹和插件) -->
|
||
<template v-if="!currentFolder">
|
||
<!-- 主列表:使用draggable进行混合排序 -->
|
||
<Draggable
|
||
v-if="canDragSort"
|
||
v-model="mixedSortList"
|
||
@end="saveMixedSortOrder"
|
||
@start="onDragStartPlugin"
|
||
item-key="id"
|
||
tag="div"
|
||
class="grid gap-4 grid-plugin-card"
|
||
group="mixed"
|
||
>
|
||
<template #item="{ element }">
|
||
<PluginMixedSortCard
|
||
:item="element"
|
||
:plugin-statistics="PluginStatistics"
|
||
:plugin-actions="pluginActions"
|
||
:sortable="true"
|
||
@open-folder="openFolder"
|
||
@delete-folder="deleteFolder"
|
||
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
|
||
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
|
||
@refresh-data="refreshData"
|
||
@action-done="
|
||
pluginId => {
|
||
pluginActions[pluginId] = false
|
||
}
|
||
"
|
||
@drop-to-folder="(event, folderName) => handleDropToFolder(event, folderName)"
|
||
/>
|
||
</template>
|
||
</Draggable>
|
||
<ProgressiveCardGrid
|
||
v-else-if="shouldVirtualizeInstalledMainList"
|
||
:items="mixedSortList"
|
||
:get-item-key="item => `${item.type}:${item.id}`"
|
||
:min-item-width="256"
|
||
:estimated-item-height="180"
|
||
:scroll-to-index="installedScrollToIndex"
|
||
>
|
||
<template #default="{ item }">
|
||
<PluginMixedSortCard
|
||
:item="item"
|
||
:plugin-statistics="PluginStatistics"
|
||
:plugin-actions="pluginActions"
|
||
:sortable="false"
|
||
@open-folder="openFolder"
|
||
@delete-folder="deleteFolder"
|
||
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
|
||
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
|
||
@refresh-data="refreshData"
|
||
@action-done="
|
||
pluginId => {
|
||
pluginActions[pluginId] = false
|
||
}
|
||
"
|
||
@drop-to-folder="(event, folderName) => handleDropToFolder(event, folderName)"
|
||
/>
|
||
</template>
|
||
</ProgressiveCardGrid>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<!-- 文件夹内:使用draggable排序 + 移出按钮 -->
|
||
<Draggable
|
||
v-if="canDragSort"
|
||
v-model="draggableFolderPlugins"
|
||
@end="saveFolderPluginOrder"
|
||
@start="onDragStartPlugin"
|
||
item-key="id"
|
||
tag="div"
|
||
class="grid gap-4 grid-plugin-card"
|
||
group="plugins"
|
||
>
|
||
<template #item="{ element }">
|
||
<PluginMixedSortCard
|
||
:item="{ type: 'plugin', id: element.id, data: element, order: 0 }"
|
||
:plugin-statistics="PluginStatistics"
|
||
:plugin-actions="pluginActions"
|
||
:sortable="true"
|
||
:show-remove-button="true"
|
||
@refresh-data="refreshData"
|
||
@action-done="
|
||
pluginId => {
|
||
pluginActions[pluginId] = false
|
||
}
|
||
"
|
||
@remove-from-folder="removeFromFolder"
|
||
/>
|
||
</template>
|
||
</Draggable>
|
||
<ProgressiveCardGrid
|
||
v-else-if="shouldVirtualizeInstalledFolderList"
|
||
:items="draggableFolderPlugins"
|
||
:get-item-key="item => item.id"
|
||
:min-item-width="256"
|
||
:estimated-item-height="180"
|
||
>
|
||
<template #default="{ item }">
|
||
<PluginMixedSortCard
|
||
:item="{ type: 'plugin', id: item.id, data: item, order: 0 }"
|
||
:plugin-statistics="PluginStatistics"
|
||
:plugin-actions="pluginActions"
|
||
:sortable="false"
|
||
:show-remove-button="true"
|
||
@refresh-data="refreshData"
|
||
@action-done="
|
||
pluginId => {
|
||
pluginActions[pluginId] = false
|
||
}
|
||
"
|
||
@remove-from-folder="removeFromFolder"
|
||
/>
|
||
</template>
|
||
</ProgressiveCardGrid>
|
||
</template>
|
||
</div>
|
||
|
||
<NoDataFound
|
||
v-if="displayedFolders.length === 0 && displayedPlugins.length === 0 && isRefreshed"
|
||
error-code="404"
|
||
:error-title="t('common.noData')"
|
||
:error-description="
|
||
installedFilter || hasUpdateFilter ? t('plugin.noMatchingContent') : t('plugin.pleaseInstallFromMarket')
|
||
"
|
||
/>
|
||
</div>
|
||
</transition>
|
||
</VWindowItem>
|
||
<!-- 插件市场 -->
|
||
<VWindowItem value="market">
|
||
<transition name="fade-slide" appear>
|
||
<div>
|
||
<LoadingBanner
|
||
v-if="!isAppMarketLoaded || (isMarketRefreshing && displayUninstalledList.length === 0)"
|
||
class="mt-12"
|
||
/>
|
||
<!-- 资源列表 -->
|
||
<VInfiniteScroll
|
||
v-if="isAppMarketLoaded && !(isMarketRefreshing && displayUninstalledList.length === 0)"
|
||
mode="intersect"
|
||
side="end"
|
||
:items="displayUninstalledList"
|
||
@load="loadMarketMore"
|
||
class="overflow-visible"
|
||
>
|
||
<template #loading />
|
||
<template #empty />
|
||
<ProgressiveCardGrid
|
||
v-if="displayUninstalledList.length > 0"
|
||
:items="displayUninstalledList"
|
||
:get-item-key="item => `${item.id}_v${item.plugin_version}`"
|
||
:min-item-width="256"
|
||
:estimated-item-height="260"
|
||
>
|
||
<template #default="{ item }">
|
||
<PluginAppCard :plugin="item" :count="PluginStatistics[item.id || '0']" @install="pluginInstalled" />
|
||
</template>
|
||
</ProgressiveCardGrid>
|
||
</VInfiniteScroll>
|
||
<NoDataFound
|
||
v-if="displayUninstalledList.length === 0 && isAppMarketLoaded"
|
||
error-code="404"
|
||
:error-title="t('common.noData')"
|
||
:error-description="t('plugin.allPluginsInstalled')"
|
||
/>
|
||
</div>
|
||
</transition>
|
||
</VWindowItem>
|
||
</VWindow>
|
||
</div>
|
||
|
||
<!-- 插件搜索图标 -->
|
||
<Teleport to="body" v-if="route.path === '/plugins'">
|
||
<div v-if="isRefreshed && !appMode && showSearchAction" class="compact-fab-stack">
|
||
<VFab
|
||
v-if="showMarketSettingAction"
|
||
icon="mdi-store-cog"
|
||
color="warning"
|
||
variant="tonal"
|
||
appear
|
||
class="compact-fab compact-fab--secondary"
|
||
@click="openMarketSettingDialog"
|
||
/>
|
||
<VFab
|
||
v-if="showNewFolderAction"
|
||
icon="mdi-folder-plus"
|
||
color="success"
|
||
variant="tonal"
|
||
appear
|
||
class="compact-fab compact-fab--secondary"
|
||
@click="showNewFolderDialog"
|
||
/>
|
||
<VFab
|
||
icon="mdi-magnify"
|
||
color="primary"
|
||
appear
|
||
class="compact-fab compact-fab--primary"
|
||
@click="openPluginSearchDialog"
|
||
/>
|
||
</div>
|
||
</Teleport>
|
||
</template>
|