mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-12 19:40:41 +08:00
1646 lines
50 KiB
Vue
1646 lines
50 KiB
Vue
<script lang="ts" setup>
|
||
import draggable from 'vuedraggable'
|
||
import { useToast } from 'vue-toast-notification'
|
||
import api from '@/api'
|
||
import type { Plugin } from '@/api/types'
|
||
import NoDataFound from '@/components/NoDataFound.vue'
|
||
import PluginAppCard from '@/components/cards/PluginAppCard.vue'
|
||
import noImage from '@images/logos/plugin.png'
|
||
import { useDisplay } from 'vuetify'
|
||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||
import { getPluginTabs } from '@/router/i18n-menu'
|
||
import PluginMarketSettingDialog from '@/components/dialog/PluginMarketSettingDialog.vue'
|
||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||
import { useI18n } from 'vue-i18n'
|
||
import PluginMixedSortCard from '@/components/cards/PluginMixedSortCard.vue'
|
||
|
||
// 国际化
|
||
const { t } = useI18n()
|
||
|
||
const route = useRoute()
|
||
|
||
// 显示器宽度
|
||
const display = useDisplay()
|
||
|
||
// APP
|
||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||
|
||
// 当前标签
|
||
const activeTab = ref('installed')
|
||
|
||
// 获取插件标签页
|
||
const pluginTabs = computed(() => getPluginTabs())
|
||
|
||
// 插件ID参数
|
||
const pluginId = ref(route.query.id)
|
||
|
||
// 当前排序字段
|
||
const activeSort = ref(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 SearchDialog = ref(false)
|
||
|
||
// 插件市场设置窗口
|
||
const MarketSettingDialog = ref(false)
|
||
|
||
// 插件市场刷新状态
|
||
const isMarketRefreshing = ref(false)
|
||
|
||
// 搜索关键字
|
||
const keyword = ref('')
|
||
|
||
// 每一个插件的图标加载状态
|
||
const pluginIconLoaded = ref<{ [key: string]: boolean }>({})
|
||
|
||
// 每一个插件的动作标识
|
||
const pluginActions: Ref<{ [key: string]: boolean }> = ref({})
|
||
|
||
// 提示框
|
||
const $toast = useToast()
|
||
|
||
// 进度框
|
||
const progressDialog = ref(false)
|
||
|
||
// 进度框文本
|
||
const progressText = ref(t('plugin.installingPlugin'))
|
||
|
||
// 过滤表单
|
||
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
|
||
)
|
||
})
|
||
|
||
// 插件过滤条件
|
||
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 newFolderDialog = ref(false)
|
||
|
||
// 新文件夹名称
|
||
const newFolderName = ref('')
|
||
|
||
// 获取文件夹内筛选后的插件
|
||
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 = dataList.value.find(p => p.id === 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) {
|
||
// 主列表:显示未归类的插件
|
||
const folderedPluginIds = new Set()
|
||
Object.values(pluginFolders.value).forEach(folderData => {
|
||
const plugins = Array.isArray(folderData) ? folderData : folderData.plugins || []
|
||
plugins.forEach((pid: string) => folderedPluginIds.add(pid))
|
||
})
|
||
return filteredDataList.value.filter(plugin => !folderedPluginIds.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 => {
|
||
const orderItem = orderConfig.value.find((item: any) => item.type === 'folder' && item.id === folder.name)
|
||
allItems.push({
|
||
type: 'folder',
|
||
id: folder.name,
|
||
data: folder,
|
||
order: orderItem?.order ?? 999,
|
||
})
|
||
})
|
||
|
||
// 添加插件项目
|
||
displayedPlugins.value.forEach(plugin => {
|
||
const orderItem = orderConfig.value.find((item: any) => item.type === 'plugin' && item.id === plugin.id)
|
||
allItems.push({
|
||
type: 'plugin',
|
||
id: plugin.id || '',
|
||
data: plugin,
|
||
order: orderItem?.order ?? 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() {
|
||
// 顺序配置
|
||
const local_order = localStorage.getItem('MP_PLUGIN_ORDER')
|
||
if (local_order) {
|
||
const parsed = JSON.parse(local_order)
|
||
// 兼容旧格式(只有id)和新格式(包含type和order)
|
||
if (parsed.length > 0 && typeof parsed[0] === 'object' && 'type' in parsed[0]) {
|
||
orderConfig.value = parsed
|
||
} else {
|
||
// 旧格式,转换为新格式
|
||
orderConfig.value = parsed.map((item: any, index: number) => ({
|
||
id: typeof item === 'string' ? item : item.id,
|
||
type: 'plugin',
|
||
order: index,
|
||
}))
|
||
}
|
||
} else {
|
||
const response2 = await api.get('/user/config/PluginOrder')
|
||
if (response2 && response2.data && response2.data.value) {
|
||
const serverData = response2.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,
|
||
}))
|
||
}
|
||
localStorage.setItem('MP_PLUGIN_ORDER', JSON.stringify(orderConfig.value))
|
||
}
|
||
}
|
||
}
|
||
|
||
// 按order的顺序对插件进行排序
|
||
function sortPluginOrder() {
|
||
if (!orderConfig.value) {
|
||
return
|
||
}
|
||
if (dataList.value.length === 0) {
|
||
return
|
||
}
|
||
dataList.value.sort((a, b) => {
|
||
const aIndex = orderConfig.value.findIndex((item: { id: string }) => item.id === a.id)
|
||
const bIndex = orderConfig.value.findIndex((item: { id: string }) => item.id === b.id)
|
||
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : 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 = dataList.value.find(p => p.id === 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
|
||
const orderString = JSON.stringify(orderObj)
|
||
localStorage.setItem('MP_PLUGIN_ORDER', orderString)
|
||
|
||
// 保存到服务端
|
||
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,
|
||
})
|
||
}
|
||
})
|
||
|
||
// 保存全局排序配置
|
||
const orderString = JSON.stringify(orderConfig.value)
|
||
localStorage.setItem('MP_PLUGIN_ORDER', orderString)
|
||
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) => {
|
||
value && !options.includes(value) && 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.repo_url))
|
||
}
|
||
|
||
// 关闭插件市场窗口
|
||
function pluginDialogClose() {
|
||
PluginAppDialog.value = false
|
||
}
|
||
|
||
// 安装插件
|
||
async function installPlugin(item: Plugin) {
|
||
try {
|
||
// 显示等待提示框
|
||
progressDialog.value = true
|
||
progressText.value = t('plugin.installing', { name: item?.plugin_name, version: item?.plugin_version })
|
||
|
||
const result: { [key: string]: any } = await api.get(`plugin/install/${item?.id}`, {
|
||
params: {
|
||
repo_url: item?.repo_url,
|
||
force: item?.has_update,
|
||
},
|
||
})
|
||
|
||
// 隐藏等待提示框
|
||
progressDialog.value = false
|
||
|
||
if (result.success) {
|
||
$toast.success(t('plugin.installSuccess', { name: item?.plugin_name }))
|
||
// 清空过滤条件
|
||
hasUpdateFilter.value = false
|
||
enabledFilter.value = false
|
||
installedFilter.value = null
|
||
// 刷新
|
||
refreshData()
|
||
} else {
|
||
$toast.error(t('plugin.installFailed', { name: item?.plugin_name, message: result.message }))
|
||
}
|
||
} catch (error) {
|
||
console.error(error)
|
||
}
|
||
}
|
||
|
||
// 打开插件搜索结果
|
||
function openPlugin(item: Plugin) {
|
||
// 如果是已安装插件则打开插件详情
|
||
if (item.installed === true) {
|
||
// 标记插件动作
|
||
pluginActions.value[item.id || '0'] = true
|
||
} else {
|
||
// 如果是未安装插件则安装
|
||
installPlugin(item)
|
||
}
|
||
closeSearchDialog()
|
||
}
|
||
|
||
// 关闭插件搜索窗口
|
||
function closeSearchDialog() {
|
||
SearchDialog.value = false
|
||
}
|
||
|
||
// 插件图标加载错误
|
||
function pluginIconError(item: Plugin) {
|
||
pluginIconLoaded.value[item.id || '0'] = false
|
||
}
|
||
|
||
// 插件图标地址
|
||
function pluginIcon(item: Plugin) {
|
||
// 如果图片加载错误
|
||
if (pluginIconLoaded.value[item.id || '0'] === false) return noImage
|
||
// 如果是网络图片则使用代理后返回
|
||
if (item?.plugin_icon?.startsWith('http'))
|
||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(item?.plugin_icon)}`
|
||
|
||
return `./plugin_icon/${item?.plugin_icon}`
|
||
}
|
||
|
||
// 过滤插件
|
||
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() {
|
||
try {
|
||
loading.value = true
|
||
dataList.value = await api.get('plugin/', {
|
||
params: {
|
||
state: 'installed',
|
||
},
|
||
})
|
||
// 排序
|
||
sortPluginOrder()
|
||
loading.value = false
|
||
isRefreshed.value = true
|
||
} catch (error) {
|
||
console.error(error)
|
||
}
|
||
}
|
||
|
||
// 获取未安装插件列表数据
|
||
async function fetchUninstalledPlugins(force: boolean = false) {
|
||
try {
|
||
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
|
||
}
|
||
}
|
||
}
|
||
loading.value = false
|
||
isRefreshed.value = true
|
||
// 更新插件市场列表
|
||
// 排除已安装且有更新的,上面的问题在于"本地存在未安装的旧版本插件且云端有更新时"不会在插件市场展示
|
||
marketList.value = uninstalledList.value.filter(item => !(item.has_update && item.installed))
|
||
// 初始化过滤选项
|
||
marketList.value.forEach(initOptions)
|
||
// 设置APP市场加载完成
|
||
isAppMarketLoaded.value = true
|
||
} catch (error) {
|
||
console.error(error)
|
||
}
|
||
}
|
||
|
||
// 加载插件统计数据
|
||
async function getPluginStatistics() {
|
||
try {
|
||
PluginStatistics.value = await api.get('plugin/statistic')
|
||
} catch (error) {
|
||
console.error(error)
|
||
}
|
||
}
|
||
|
||
// 加载所有数据
|
||
async function refreshData() {
|
||
await fetchInstalledPlugins()
|
||
fetchUninstalledPlugins()
|
||
// 重新加载文件夹配置,确保分身插件能正确显示在文件夹中
|
||
await loadPluginFolders()
|
||
}
|
||
|
||
// 对uninstalledList进行排序到sortedUninstalledList
|
||
watch([marketList, filterForm, activeSort], () => {
|
||
// 匹配过滤函数
|
||
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.repo_url))
|
||
) {
|
||
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'] - PluginStatistics.value[a.id || '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)
|
||
})
|
||
|
||
// 标签转换
|
||
function pluginLabels(label: string | undefined) {
|
||
if (!label) return []
|
||
return label.split(',')
|
||
}
|
||
|
||
// 新安装了插件
|
||
function pluginInstalled() {
|
||
pluginDialogClose()
|
||
refreshData()
|
||
}
|
||
|
||
// 插件市场设置完成
|
||
function marketSettingDone() {
|
||
MarketSettingDialog.value = false
|
||
// 重新加载数据
|
||
refreshData()
|
||
}
|
||
|
||
// 手动刷新插件市场
|
||
async function refreshMarket() {
|
||
isMarketRefreshing.value = true
|
||
try {
|
||
await fetchUninstalledPlugins(true)
|
||
await getPluginStatistics()
|
||
} catch (error) {
|
||
console.error(error)
|
||
} finally {
|
||
isMarketRefreshing.value = false
|
||
}
|
||
}
|
||
|
||
// 处理掉github地址的前缀
|
||
function handleRepoUrl(url: string | undefined) {
|
||
if (!url) return ''
|
||
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)
|
||
displayUninstalledList.value.push(...itemsToMove)
|
||
done('ok')
|
||
}
|
||
|
||
// 组件挂载后
|
||
onMounted(async () => {
|
||
await loadPluginOrderConfig()
|
||
await loadPluginFolders() // 加载文件夹配置
|
||
await refreshData()
|
||
getPluginStatistics()
|
||
if (activeTab.value != 'market' && pluginId.value) {
|
||
// 找到这个插件
|
||
const plugin = dataList.value.find(item => item.id === pluginId.value)
|
||
if (plugin) {
|
||
plugin.page_open = true
|
||
}
|
||
}
|
||
})
|
||
|
||
// 使用动态按钮钩子
|
||
useDynamicButton({
|
||
icon: 'mdi-magnify',
|
||
onClick: () => {
|
||
SearchDialog.value = true
|
||
},
|
||
})
|
||
|
||
// 获取插件文件夹配置
|
||
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) => {
|
||
// 从全局排序配置中查找文件夹的order
|
||
const aOrderItem = orderConfig.value.find((item: any) => item.type === 'folder' && item.id === a)
|
||
const bOrderItem = orderConfig.value.find((item: any) => item.type === 'folder' && item.id === b)
|
||
|
||
const aOrder = aOrderItem?.order ?? processedFolders[a].order ?? 999
|
||
const bOrder = bOrderItem?.order ?? 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()
|
||
|
||
newFolderDialog.value = false
|
||
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 = ''
|
||
newFolderDialog.value = true
|
||
}
|
||
|
||
// 移出文件夹
|
||
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 = filteredDataList.value.find(p => p.id === 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>
|
||
<VHeaderTab :items="pluginTabs" v-model="activeTab">
|
||
<template #append>
|
||
<VMenu
|
||
v-if="activeTab === 'installed'"
|
||
v-model="filterInstalledPluginDialog"
|
||
width="20rem"
|
||
:close-on-content-click="false"
|
||
scrim
|
||
>
|
||
<template #activator="{ props }">
|
||
<VBtn
|
||
icon="mdi-filter-multiple-outline"
|
||
variant="text"
|
||
:color="installedFilter || hasUpdateFilter || enabledFilter ? 'primary' : 'gray'"
|
||
size="default"
|
||
class="settings-icon-button"
|
||
v-bind="props"
|
||
/>
|
||
</template>
|
||
<VCard>
|
||
<VCardItem>
|
||
<VCardTitle>
|
||
<VIcon icon="mdi-filter-multiple-outline" class="mr-2" />
|
||
{{ t('plugin.filterPlugins') }}
|
||
</VCardTitle>
|
||
<VDialogCloseBtn @click="filterInstalledPluginDialog = false" />
|
||
</VCardItem>
|
||
<VCardText>
|
||
<VRow>
|
||
<VCol cols="12">
|
||
<VCombobox
|
||
v-model="installedFilter"
|
||
:items="installedPluginNames"
|
||
:label="t('plugin.name')"
|
||
density="comfortable"
|
||
clearable
|
||
/>
|
||
</VCol>
|
||
<VCol cols="6">
|
||
<VSwitch v-model="enabledFilter" :label="t('plugin.running')" />
|
||
</VCol>
|
||
<VCol cols="6">
|
||
<VSwitch v-model="hasUpdateFilter" :label="t('plugin.hasNewVersion')" />
|
||
</VCol>
|
||
</VRow>
|
||
</VCardText>
|
||
</VCard>
|
||
</VMenu>
|
||
<VMenu
|
||
v-if="activeTab === 'market'"
|
||
v-model="filterMarketPluginDialog"
|
||
width="25rem"
|
||
:close-on-content-click="false"
|
||
scrim
|
||
>
|
||
<template #activator="{ props }">
|
||
<VBtn
|
||
icon="mdi-filter-multiple-outline"
|
||
variant="text"
|
||
:color="isFilterFormEmpty ? 'gray' : 'primary'"
|
||
size="default"
|
||
class="settings-icon-button"
|
||
v-bind="props"
|
||
/>
|
||
</template>
|
||
<VCard>
|
||
<VCardItem>
|
||
<VCardTitle>
|
||
<VIcon icon="mdi-filter-multiple-outline" class="mr-2" />
|
||
{{ t('plugin.filterPlugins') }}
|
||
</VCardTitle>
|
||
<VDialogCloseBtn @click="filterMarketPluginDialog = false" />
|
||
</VCardItem>
|
||
<VCardText>
|
||
<!-- 过滤表单 -->
|
||
<div v-if="isAppMarketLoaded">
|
||
<VRow>
|
||
<VCol cols="6">
|
||
<VTextField v-model="filterForm.name" density="comfortable" :label="t('plugin.name')" clearable />
|
||
</VCol>
|
||
<VCol v-if="authorFilterOptions.length > 0" cols="6">
|
||
<VSelect
|
||
v-model="filterForm.author"
|
||
:items="authorFilterOptions"
|
||
density="comfortable"
|
||
chips
|
||
:label="t('plugin.author')"
|
||
multiple
|
||
clearable
|
||
/>
|
||
</VCol>
|
||
<VCol v-if="labelFilterOptions.length > 0" cols="6">
|
||
<VSelect
|
||
v-model="filterForm.label"
|
||
:items="labelFilterOptions"
|
||
density="comfortable"
|
||
chips
|
||
:label="t('plugin.label')"
|
||
multiple
|
||
clearable
|
||
/>
|
||
</VCol>
|
||
<VCol v-if="repoFilterOptions.length > 0" cols="6">
|
||
<VSelect
|
||
v-model="filterForm.repo"
|
||
:items="repoFilterOptions"
|
||
density="comfortable"
|
||
chips
|
||
:label="t('plugin.repository')"
|
||
multiple
|
||
clearable
|
||
/>
|
||
</VCol>
|
||
<VCol v-if="sortOptions.length > 0" cols="6">
|
||
<VSelect
|
||
v-model="activeSort"
|
||
:items="sortOptions"
|
||
density="comfortable"
|
||
:label="t('plugin.sortTitle')"
|
||
/>
|
||
</VCol>
|
||
</VRow>
|
||
</div>
|
||
</VCardText>
|
||
</VCard>
|
||
</VMenu>
|
||
<VBtn
|
||
v-if="activeTab === 'market'"
|
||
icon="mdi-refresh"
|
||
variant="text"
|
||
color="gray"
|
||
size="default"
|
||
class="settings-icon-button"
|
||
:loading="isMarketRefreshing"
|
||
@click="refreshMarket"
|
||
/>
|
||
<VBtn
|
||
v-if="activeTab === 'market'"
|
||
icon="mdi-store-cog"
|
||
variant="text"
|
||
color="gray"
|
||
size="default"
|
||
class="settings-icon-button"
|
||
@click="MarketSettingDialog = true"
|
||
/>
|
||
<VBtn
|
||
v-if="activeTab === 'installed' && !currentFolder"
|
||
icon="mdi-folder-plus"
|
||
variant="text"
|
||
color="gray"
|
||
size="default"
|
||
class="settings-icon-button"
|
||
@click="showNewFolderDialog"
|
||
/>
|
||
<VBtn
|
||
v-if="activeTab === 'installed' && currentFolder"
|
||
icon="mdi-arrow-left"
|
||
variant="text"
|
||
color="gray"
|
||
size="default"
|
||
class="settings-icon-button"
|
||
@click="backToMain"
|
||
/>
|
||
</template>
|
||
</VHeaderTab>
|
||
|
||
<VWindow v-model="activeTab" class="mt-5 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" />
|
||
|
||
<!-- 文件夹和插件网格 -->
|
||
<div v-if="(mixedSortList.length > 0 || displayedPlugins.length > 0) && isRefreshed">
|
||
<!-- 混合排序列表(文件夹和插件) -->
|
||
<template v-if="!currentFolder">
|
||
<!-- 主列表:使用draggable进行混合排序 -->
|
||
<draggable
|
||
v-model="mixedSortList"
|
||
@end="saveMixedSortOrder"
|
||
@start="onDragStartPlugin"
|
||
handle=".cursor-move"
|
||
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"
|
||
@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>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<!-- 文件夹内:使用draggable排序 + 移出按钮 -->
|
||
<draggable
|
||
v-model="draggableFolderPlugins"
|
||
@end="saveFolderPluginOrder"
|
||
@start="onDragStartPlugin"
|
||
handle=".cursor-move"
|
||
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"
|
||
:show-remove-button="true"
|
||
@refresh-data="refreshData"
|
||
@action-done="
|
||
pluginId => {
|
||
pluginActions[pluginId] = false
|
||
}
|
||
"
|
||
@remove-from-folder="removeFromFolder"
|
||
/>
|
||
</template>
|
||
</draggable>
|
||
</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" class="mt-12" />
|
||
<!-- 资源列表 -->
|
||
<VInfiniteScroll
|
||
v-if="isAppMarketLoaded && !isMarketRefreshing"
|
||
mode="intersect"
|
||
side="end"
|
||
:items="displayUninstalledList"
|
||
@load="loadMarketMore"
|
||
class="overflow-visible"
|
||
>
|
||
<template #loading />
|
||
<template #empty />
|
||
<div class="grid gap-4 grid-plugin-card">
|
||
<template
|
||
v-for="(data, index) in displayUninstalledList"
|
||
:key="`${data.id}_v${data.plugin_version}_${index}`"
|
||
>
|
||
<PluginAppCard :plugin="data" :count="PluginStatistics[data.id || '0']" @install="pluginInstalled" />
|
||
</template>
|
||
</div>
|
||
</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>
|
||
|
||
<div v-if="isRefreshed">
|
||
<!-- 插件搜索图标 -->
|
||
<VFab
|
||
v-if="!appMode"
|
||
icon="mdi-magnify"
|
||
color="info"
|
||
location="bottom"
|
||
size="x-large"
|
||
fixed
|
||
app
|
||
appear
|
||
@click="SearchDialog = true"
|
||
:class="{ 'mb-12': appMode }"
|
||
/>
|
||
</div>
|
||
<!-- 插件市场设置窗口 -->
|
||
<PluginMarketSettingDialog
|
||
v-if="MarketSettingDialog"
|
||
v-model="MarketSettingDialog"
|
||
@close="MarketSettingDialog = false"
|
||
@save="marketSettingDone"
|
||
/>
|
||
|
||
<!-- 插件搜索窗口 -->
|
||
<VDialog
|
||
v-if="SearchDialog"
|
||
v-model="SearchDialog"
|
||
scrollable
|
||
max-width="40rem"
|
||
:max-height="!display.mdAndUp.value ? '' : '85vh'"
|
||
:fullscreen="!display.mdAndUp.value"
|
||
>
|
||
<VCard class="mx-auto" width="100%">
|
||
<VToolbar flat class="p-0">
|
||
<VTextField
|
||
v-model="keyword"
|
||
:label="t('plugin.searchPlugins')"
|
||
single-line
|
||
:placeholder="t('plugin.searchPlaceholder')"
|
||
variant="solo"
|
||
prepend-inner-icon="mdi-magnify"
|
||
flat
|
||
class="mx-1"
|
||
/>
|
||
</VToolbar>
|
||
<VDialogCloseBtn @click="closeSearchDialog" />
|
||
<VList v-if="filterPlugins.length > 0" lines="two">
|
||
<VVirtualScroll :items="filterPlugins">
|
||
<template #default="{ item }">
|
||
<VListItem @click="openPlugin(item)">
|
||
<template #prepend>
|
||
<VAvatar>
|
||
<VImg :src="pluginIcon(item)" @error="pluginIconError(item)">
|
||
<template #placeholder>
|
||
<div class="w-full h-full">
|
||
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
|
||
</div>
|
||
</template>
|
||
</VImg>
|
||
</VAvatar>
|
||
</template>
|
||
<VListItemTitle>
|
||
{{ item.plugin_name }}<span class="text-sm ms-2 mt-1 text-gray-500">v{{ item?.plugin_version }}</span>
|
||
<VIcon v-if="item.installed" color="success" icon="mdi-check-circle" class="ms-2" size="small" />
|
||
</VListItemTitle>
|
||
<VListItemSubtitle>
|
||
<VChip
|
||
v-for="label in pluginLabels(item.plugin_label)"
|
||
variant="tonal"
|
||
size="small"
|
||
class="me-1 my-1"
|
||
color="info"
|
||
label
|
||
>
|
||
{{ label }}
|
||
</VChip>
|
||
{{ item.plugin_desc }}
|
||
</VListItemSubtitle>
|
||
</VListItem>
|
||
</template>
|
||
</VVirtualScroll>
|
||
</VList>
|
||
</VCard>
|
||
</VDialog>
|
||
|
||
<!-- 安装插件进度框 -->
|
||
<VDialog v-if="progressDialog" v-model="progressDialog" :scrim="false" width="25rem">
|
||
<VCard color="primary">
|
||
<VCardText class="text-center">
|
||
{{ progressText }}
|
||
<VProgressLinear indeterminate color="white" class="mb-0 mt-1" />
|
||
</VCardText>
|
||
</VCard>
|
||
</VDialog>
|
||
|
||
<!-- 新建文件夹对话框 -->
|
||
<VDialog v-if="newFolderDialog" v-model="newFolderDialog" max-width="400">
|
||
<VCard>
|
||
<VDialogCloseBtn @click="newFolderDialog = false" />
|
||
<VCardItem>
|
||
<VCardTitle>{{ t('plugin.newFolder') }}</VCardTitle>
|
||
</VCardItem>
|
||
<VDivider />
|
||
<VCardText>
|
||
<VTextField
|
||
v-model="newFolderName"
|
||
:label="t('plugin.folderName')"
|
||
variant="outlined"
|
||
@keyup.enter="createNewFolder"
|
||
/>
|
||
</VCardText>
|
||
<VCardActions>
|
||
<VSpacer />
|
||
<VBtn color="primary" @click="createNewFolder" prepend-icon="mdi-folder-plus" class="px-5">{{
|
||
t('plugin.create')
|
||
}}</VBtn>
|
||
</VCardActions>
|
||
</VCard>
|
||
</VDialog>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
// 样式已移至 PluginMixedSortCard 组件
|
||
</style>
|