mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-11 08:33:46 +08:00
test(plugin): cover market list state workflows (#631)
This commit is contained in:
@@ -897,20 +897,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/plugin/PluginCardListView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 15
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 9
|
||||
},
|
||||
"no-useless-catch": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-ignored-exceptions": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/views/reorganize/TransferHistoryView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 11
|
||||
|
||||
@@ -6,7 +6,9 @@ import { useDisplay } from 'vuetify'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const PluginFolderRenameDialog = defineAsyncComponent(() => import('@/components/dialog/PluginFolderRenameDialog.vue'))
|
||||
const PluginFolderSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/PluginFolderSettingsDialog.vue'))
|
||||
const PluginFolderSettingsDialog = defineAsyncComponent(
|
||||
() => import('@/components/dialog/PluginFolderSettingsDialog.vue'),
|
||||
)
|
||||
|
||||
// 文件夹配置接口
|
||||
interface FolderConfig {
|
||||
@@ -60,7 +62,7 @@ const defaultColor = '#2196F3'
|
||||
const defaultIcon = 'mdi-folder'
|
||||
// 默认渐变
|
||||
const defaultGradient =
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8s) 100%)'
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8) 100%)'
|
||||
|
||||
// 计算背景图片
|
||||
const backgroundImage = computed(() => {
|
||||
@@ -166,7 +168,6 @@ function showSettingDialog() {
|
||||
// 保存设置
|
||||
function saveSettings(config: FolderConfig) {
|
||||
emit('update-config', props.folderName, config)
|
||||
$toast.success(t('folder.folderSettingsSaved'))
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -226,69 +227,72 @@ const dropdownItems = ref([
|
||||
'plugin-folder-card--sortable': props.sortable,
|
||||
}"
|
||||
>
|
||||
<template v-if="backgroundImage" #image>
|
||||
<VImg :src="backgroundImage" cover position="top"> </VImg>
|
||||
</template>
|
||||
<template v-if="backgroundImage" #image>
|
||||
<VImg :src="backgroundImage" cover position="top"> </VImg>
|
||||
</template>
|
||||
|
||||
<!-- 背景遮罩(当有背景图片时) -->
|
||||
<div v-if="backgroundImage" class="plugin-folder-card__overlay" />
|
||||
<!-- 背景遮罩(当有背景图片时) -->
|
||||
<div v-if="backgroundImage" class="plugin-folder-card__overlay" />
|
||||
|
||||
<!-- 背景渐变层 -->
|
||||
<div v-else class="plugin-folder-card__bg" :style="{ background: backgroundGradient }" />
|
||||
<!-- 背景渐变层 -->
|
||||
<div v-else class="plugin-folder-card__bg" :style="{ background: backgroundGradient }" />
|
||||
|
||||
<!-- 卡片内容 -->
|
||||
<div class="plugin-folder-card__content">
|
||||
<!-- 主体内容 -->
|
||||
<div class="plugin-folder-card__body" :class="{ 'plugin-folder-card__body--no-icon': !shouldShowIcon }">
|
||||
<!-- 文件夹图标 -->
|
||||
<div v-if="shouldShowIcon" class="plugin-folder-card__icon-container">
|
||||
<VIcon
|
||||
:icon="folderIcon"
|
||||
:size="display.mobile ? 56 : 72"
|
||||
:color="iconColor"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
/>
|
||||
<!-- 卡片内容 -->
|
||||
<div class="plugin-folder-card__content">
|
||||
<!-- 主体内容 -->
|
||||
<div class="plugin-folder-card__body" :class="{ 'plugin-folder-card__body--no-icon': !shouldShowIcon }">
|
||||
<!-- 文件夹图标 -->
|
||||
<div v-if="shouldShowIcon" class="plugin-folder-card__icon-container">
|
||||
<VIcon
|
||||
:icon="folderIcon"
|
||||
:size="display.mobile ? 56 : 72"
|
||||
:color="iconColor"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 文件夹信息 -->
|
||||
<div
|
||||
class="plugin-folder-card__info"
|
||||
:class="{
|
||||
'cursor-move': props.sortable && display.mdAndUp.value,
|
||||
'plugin-folder-card__info--no-icon': !shouldShowIcon,
|
||||
}"
|
||||
>
|
||||
<!-- 文件夹名称 -->
|
||||
<h3 class="plugin-folder-card__name">
|
||||
{{ props.folderName }}
|
||||
</h3>
|
||||
<!-- 插件数量 -->
|
||||
<p class="plugin-folder-card__count">{{ t('folder.pluginCount', { count: props.pluginCount }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件夹信息 -->
|
||||
<div
|
||||
class="plugin-folder-card__info"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
|
||||
>
|
||||
<!-- 文件夹名称 -->
|
||||
<h3 class="plugin-folder-card__name">
|
||||
{{ props.folderName }}
|
||||
</h3>
|
||||
<!-- 插件数量 -->
|
||||
<p class="plugin-folder-card__count">{{ t('folder.pluginCount', { count: props.pluginCount }) }}</p>
|
||||
<!-- 更多菜单按钮 - 右下角 -->
|
||||
<div v-if="!props.sortable" class="absolute top-0 right-0">
|
||||
<VMenu v-model="menuVisible" location="top end" :close-on-content-click="true">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<IconBtn v-bind="menuProps" @click.stop>
|
||||
<VIcon size="small" icon="mdi-dots-vertical" class="text-white" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
v-show="item.show"
|
||||
:key="i"
|
||||
:base-color="item.props.color"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" size="16" />
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2">{{ item.title }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 更多菜单按钮 - 右下角 -->
|
||||
<div v-if="!props.sortable" class="absolute top-0 right-0">
|
||||
<VMenu v-model="menuVisible" location="top end" :close-on-content-click="true">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<IconBtn v-bind="menuProps" @click.stop>
|
||||
<VIcon size="small" icon="mdi-dots-vertical" class="text-white" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
v-show="item.show"
|
||||
:key="i"
|
||||
:base-color="item.props.color"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" size="16" />
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2">{{ item.title }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
207
src/components/cards/__tests__/PluginFolderCard.spec.ts
Normal file
207
src/components/cards/__tests__/PluginFolderCard.spec.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import PluginFolderCard from '@/components/cards/PluginFolderCard.vue'
|
||||
import { fireEvent, screen } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
confirm: vi.fn(),
|
||||
isHovering: false,
|
||||
openSharedDialog: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useConfirm', () => ({
|
||||
useConfirm: () => mocks.confirm,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
const HoverStub = defineComponent({
|
||||
name: 'VHover',
|
||||
setup(_props, { slots }) {
|
||||
return () => h('div', slots.default?.({ isHovering: mocks.isHovering, props: {} }))
|
||||
},
|
||||
})
|
||||
|
||||
const MenuStub = defineComponent({
|
||||
name: 'VMenu',
|
||||
props: { modelValue: Boolean },
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit, slots }) {
|
||||
return () =>
|
||||
h('div', [
|
||||
slots.activator?.({ props: { onClick: () => emit('update:modelValue', !props.modelValue) } }),
|
||||
slots.default?.(),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const ButtonStub = defineComponent({
|
||||
name: 'IconBtn',
|
||||
inheritAttrs: false,
|
||||
setup(_props, { attrs, slots }) {
|
||||
return () => h('button', { ...attrs, type: 'button' }, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
const ListItemStub = defineComponent({
|
||||
name: 'VListItem',
|
||||
inheritAttrs: false,
|
||||
setup(_props, { attrs, slots }) {
|
||||
return () => h('button', { ...attrs, type: 'button' }, [slots.prepend?.(), slots.default?.()])
|
||||
},
|
||||
})
|
||||
|
||||
const passthroughStubs = {
|
||||
IconBtn: ButtonStub,
|
||||
VCard: defineComponent({
|
||||
name: 'VCard',
|
||||
inheritAttrs: false,
|
||||
setup(_props, { attrs, slots }) {
|
||||
return () => h('article', attrs, [slots.image?.(), slots.default?.()])
|
||||
},
|
||||
}),
|
||||
VHover: HoverStub,
|
||||
VIcon: true,
|
||||
VImg: true,
|
||||
VList: defineComponent({
|
||||
name: 'VList',
|
||||
setup(_props, { slots }) {
|
||||
return () => h('div', slots.default?.())
|
||||
},
|
||||
}),
|
||||
VListItem: ListItemStub,
|
||||
VListItemTitle: defineComponent({
|
||||
name: 'VListItemTitle',
|
||||
setup(_props, { slots }) {
|
||||
return () => h('span', slots.default?.())
|
||||
},
|
||||
}),
|
||||
VMenu: MenuStub,
|
||||
}
|
||||
|
||||
const defaultFolderConfig = {
|
||||
color: '#2196F3',
|
||||
gradient: 'linear-gradient(#111, #222)',
|
||||
icon: 'mdi-folder-star',
|
||||
plugins: ['PluginA', 'PluginB'],
|
||||
showIcon: true,
|
||||
}
|
||||
|
||||
async function renderFolder(sortable = false, folderConfig: Record<string, unknown> = defaultFolderConfig) {
|
||||
return renderWithProviders(PluginFolderCard, {
|
||||
global: { stubs: passthroughStubs },
|
||||
props: {
|
||||
folderConfig,
|
||||
folderName: '媒体工具',
|
||||
pluginCount: 2,
|
||||
sortable,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getDialogEvents(index = -1) {
|
||||
const call = mocks.openSharedDialog.mock.calls.at(index)
|
||||
if (!call) throw new Error('未打开共享弹窗')
|
||||
return call[2] as Record<string, (...args: unknown[]) => unknown>
|
||||
}
|
||||
|
||||
describe('PluginFolderCard', () => {
|
||||
beforeEach(() => {
|
||||
mocks.isHovering = false
|
||||
mocks.confirm.mockResolvedValue(true)
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
})
|
||||
|
||||
it('renders folder identity and opens it only outside sort mode', async () => {
|
||||
const normal = await renderFolder()
|
||||
|
||||
expect(screen.getByText('媒体工具')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 个插件')).toBeInTheDocument()
|
||||
await fireEvent.click(normal.container.querySelector('article') as Element)
|
||||
expect(normal.emitted('open')).toEqual([['媒体工具']])
|
||||
|
||||
normal.unmount()
|
||||
const sortable = await renderFolder(true)
|
||||
await fireEvent.click(sortable.container.querySelector('article') as Element)
|
||||
expect(sortable.emitted('open') ?? []).toHaveLength(0)
|
||||
expect(screen.queryByText('设置外观')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses default appearance and supports a background without an icon', async () => {
|
||||
const defaults = await renderFolder(false, {})
|
||||
expect(defaults.container.querySelector('v-icon-stub[icon="mdi-folder"]')).toBeInTheDocument()
|
||||
expect(defaults.container.querySelector('.plugin-folder-card__bg')).toBeInTheDocument()
|
||||
|
||||
defaults.unmount()
|
||||
const image = await renderFolder(false, { background: 'https://example.com/folder.jpg', showIcon: false })
|
||||
expect(image.container.querySelector('v-img-stub[src="https://example.com/folder.jpg"]')).toBeInTheDocument()
|
||||
expect(image.container.querySelector('.plugin-folder-card__icon-container')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies hover state and accepts the menu model update', async () => {
|
||||
mocks.isHovering = true
|
||||
const { container } = await renderFolder()
|
||||
|
||||
expect(container.querySelector('.plugin-folder-card--hover')).toBeInTheDocument()
|
||||
await fireEvent.click(container.querySelector('button') as Element)
|
||||
expect(screen.getByText('设置外观')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('delegates appearance persistence without announcing success before the owner responds', async () => {
|
||||
const { emitted } = await renderFolder()
|
||||
|
||||
await fireEvent.click(screen.getByText('设置外观'))
|
||||
const config = { color: '#ff0000', icon: 'mdi-folder-heart', showIcon: false }
|
||||
getDialogEvents().save(config)
|
||||
|
||||
expect(emitted('update-config')).toEqual([['媒体工具', config]])
|
||||
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('validates rename input and emits a valid rename through the shared dialog', async () => {
|
||||
const { emitted } = await renderFolder()
|
||||
await fireEvent.click(screen.getByText('重命名'))
|
||||
await fireEvent.click(screen.getByText('重命名'))
|
||||
const events = getDialogEvents()
|
||||
|
||||
await events.rename(' ')
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('文件夹名称不能为空')
|
||||
|
||||
await events.rename('影音工具')
|
||||
expect(emitted('rename')).toEqual([['媒体工具', '影音工具']])
|
||||
})
|
||||
|
||||
it('closes rename without emitting when the name is unchanged', async () => {
|
||||
const close = vi.fn()
|
||||
mocks.openSharedDialog.mockReturnValueOnce({ close, id: 1, updateProps: vi.fn() })
|
||||
const { emitted } = await renderFolder()
|
||||
|
||||
await fireEvent.click(screen.getByText('重命名'))
|
||||
await getDialogEvents().rename('媒体工具')
|
||||
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
expect(emitted('rename') ?? []).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('emits deletion only after confirmation', async () => {
|
||||
const cancelled = await renderFolder()
|
||||
mocks.confirm.mockResolvedValueOnce(false)
|
||||
await fireEvent.click(screen.getByText('删除文件夹'))
|
||||
expect(cancelled.emitted('delete') ?? []).toHaveLength(0)
|
||||
|
||||
cancelled.unmount()
|
||||
const confirmed = await renderFolder()
|
||||
mocks.confirm.mockResolvedValueOnce(true)
|
||||
await fireEvent.click(screen.getByText('删除文件夹'))
|
||||
expect(confirmed.emitted('delete')).toEqual([['媒体工具']])
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import type { Plugin, PluginRating } from '@/api/types'
|
||||
import type { ApiResponse, Plugin, PluginRating } from '@/api/types'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { getPluginTabs } from '@/router/i18n-menu'
|
||||
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||
@@ -14,7 +13,7 @@ import { usePWA } from '@/composables/usePWA'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
|
||||
// 国际化
|
||||
@@ -22,6 +21,29 @@ const { t } = useI18n()
|
||||
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||
|
||||
/** 用户保存的插件与文件夹混合顺序。 */
|
||||
interface PluginOrderItem {
|
||||
id: string
|
||||
type: 'folder' | 'plugin'
|
||||
order: number
|
||||
}
|
||||
|
||||
/** 插件文件夹的成员和展示配置。 */
|
||||
interface PluginFolderConfig {
|
||||
plugins: string[]
|
||||
order: number
|
||||
background: string
|
||||
icon: string
|
||||
color: string
|
||||
gradient: string
|
||||
showIcon: boolean
|
||||
}
|
||||
|
||||
type PluginFolderEntry = PluginFolderConfig | string[]
|
||||
type PluginFolderMap = Record<string, PluginFolderEntry>
|
||||
type PluginSortKey = 'count' | 'plugin_name' | 'plugin_author' | 'repo_url' | 'add_time'
|
||||
|
||||
// 市场卡片、拖拽排序和市场设置只在对应标签/操作中需要,延迟到真正使用时加载。
|
||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||
@@ -33,9 +55,6 @@ const PluginMarketSettingDialog = defineAsyncComponent(
|
||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||
const PluginSearchDialog = defineAsyncComponent(() => import('@/components/dialog/PluginSearchDialog.vue'))
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// APP
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
@@ -125,13 +144,13 @@ registerHeaderTab({
|
||||
const pluginId = ref(route.query.id)
|
||||
|
||||
// 当前排序字段
|
||||
const activeSort = ref<string | null>(null)
|
||||
const activeSort = ref<PluginSortKey | null>(null)
|
||||
|
||||
// 插件顺序配置
|
||||
const orderConfig = ref<{ id: string; type?: string; order?: number }[]>([])
|
||||
const orderConfig = ref<PluginOrderItem[]>([])
|
||||
|
||||
// 排序选项
|
||||
const sortOptions = computed(() => [
|
||||
const sortOptions = computed<{ title: string; value: PluginSortKey }[]>(() => [
|
||||
{ title: t('plugin.sort.popular'), value: 'count' },
|
||||
{ title: t('plugin.sort.name'), value: 'plugin_name' },
|
||||
{ title: t('plugin.sort.author'), value: 'plugin_author' },
|
||||
@@ -139,9 +158,6 @@ const sortOptions = computed(() => [
|
||||
{ title: t('plugin.sort.latest'), value: 'add_time' },
|
||||
])
|
||||
|
||||
// 加载中
|
||||
const loading = ref(false)
|
||||
|
||||
// 已安装插件列表
|
||||
const dataList = ref<Plugin[]>([])
|
||||
|
||||
@@ -168,9 +184,14 @@ const displayUninstalledList = ref<Plugin[]>([])
|
||||
// 是否刷新过
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 首次列表失败与真实空数组必须保持为不同状态,便于用户原地重试。
|
||||
const installedLoadError = ref(false)
|
||||
|
||||
// APP市场是否加载完成
|
||||
const isAppMarketLoaded = ref(false)
|
||||
|
||||
const marketLoadError = ref(false)
|
||||
|
||||
// APP市场窗口
|
||||
const PluginAppDialog = ref(false)
|
||||
|
||||
@@ -183,6 +204,12 @@ const PluginRatings = ref<{ [key: string]: PluginRating }>({})
|
||||
// 插件市场刷新状态
|
||||
const isMarketRefreshing = ref(false)
|
||||
|
||||
// 每类远程快照独立管理 writer 代际,旧请求只能完成自身 Promise,不能覆盖新状态。
|
||||
let installedWriterGeneration = 0
|
||||
let marketWriterGeneration = 0
|
||||
let ratingWriterGeneration = 0
|
||||
let statisticWriterGeneration = 0
|
||||
|
||||
// 搜索关键字
|
||||
const keyword = ref('')
|
||||
|
||||
@@ -225,23 +252,13 @@ const isFilterFormEmpty = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// 切换市场过滤器多选项
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭插件市场过滤菜单。
|
||||
function closeMarketFilterMenu() {
|
||||
filterMarketPluginDialog.value = false
|
||||
}
|
||||
|
||||
// 选择插件市场排序项并关闭过滤菜单。
|
||||
function selectMarketSort(value: string) {
|
||||
function selectMarketSort(value: PluginSortKey) {
|
||||
activeSort.value = value
|
||||
closeMarketFilterMenu()
|
||||
}
|
||||
@@ -298,7 +315,7 @@ const labelFilterOptions = ref<string[]>([])
|
||||
const repoFilterOptions = ref<string[]>([])
|
||||
|
||||
// 插件文件夹配置
|
||||
const pluginFolders: Ref<{ [key: string]: any }> = ref({})
|
||||
const pluginFolders = ref<PluginFolderMap>({})
|
||||
|
||||
// 文件夹排序
|
||||
const folderOrder = ref<string[]>([])
|
||||
@@ -395,7 +412,7 @@ const displayedPlugins = computed(() => {
|
||||
interface MixedSortItem {
|
||||
type: 'folder' | 'plugin'
|
||||
id: string
|
||||
data: any
|
||||
data: Plugin | { name: string; pluginCount: number; config: Partial<PluginFolderConfig> }
|
||||
order: number
|
||||
}
|
||||
|
||||
@@ -452,7 +469,7 @@ function updateMixedSortList() {
|
||||
const items: MixedSortItem[] = []
|
||||
|
||||
// 始终使用全局排序配置来创建混合列表
|
||||
const allItems: { type: 'folder' | 'plugin'; id: string; data: any; order: number }[] = []
|
||||
const allItems: MixedSortItem[] = []
|
||||
|
||||
// 添加文件夹项目
|
||||
displayedFolders.value.forEach(folder => {
|
||||
@@ -524,14 +541,21 @@ async function loadPluginOrderConfig() {
|
||||
try {
|
||||
const response = await api.get('/user/config/PluginOrder')
|
||||
if (response && response.data && response.data.value) {
|
||||
const serverData = response.data.value
|
||||
const serverData = response.data.value as Array<string | Partial<PluginOrderItem>>
|
||||
// 兼容服务端的旧格式和新格式
|
||||
if (serverData.length > 0 && typeof serverData[0] === 'object' && 'type' in serverData[0]) {
|
||||
orderConfig.value = serverData
|
||||
if (serverData.length > 0 && typeof serverData[0] === 'object' && serverData[0] && 'type' in serverData[0]) {
|
||||
orderConfig.value = serverData.map((item, index) => {
|
||||
const orderItem = item as Partial<PluginOrderItem>
|
||||
return {
|
||||
id: orderItem.id || '',
|
||||
type: orderItem.type === 'folder' ? 'folder' : 'plugin',
|
||||
order: orderItem.order ?? index,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 旧格式,转换为新格式
|
||||
orderConfig.value = serverData.map((item: any, index: number) => ({
|
||||
id: typeof item === 'string' ? item : item.id,
|
||||
orderConfig.value = serverData.map((item, index) => ({
|
||||
id: typeof item === 'string' ? item : item.id || '',
|
||||
type: 'plugin',
|
||||
order: index,
|
||||
}))
|
||||
@@ -543,6 +567,51 @@ async function loadPluginOrderConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存插件混合顺序,业务失败与 HTTP 失败使用同一回滚路径。 */
|
||||
async function savePluginOrderConfig(items: PluginOrderItem[]) {
|
||||
const result = await api.post<ApiResponse<unknown>, ApiResponse<unknown>>('/user/config/PluginOrder', items)
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.message || t('plugin.operationFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function clonePluginFolders(source: PluginFolderMap = pluginFolders.value): PluginFolderMap {
|
||||
return Object.fromEntries(
|
||||
Object.entries(source).map(([name, folder]) => [
|
||||
name,
|
||||
Array.isArray(folder) ? [...folder] : { ...folder, plugins: [...folder.plugins] },
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
interface FolderStateSnapshot {
|
||||
currentFolder: string
|
||||
folderOrder: string[]
|
||||
folders: PluginFolderMap
|
||||
}
|
||||
|
||||
/** 捕获一次文件夹写操作开始前的完整本地快照。 */
|
||||
function captureFolderState(): FolderStateSnapshot {
|
||||
return {
|
||||
currentFolder: currentFolder.value,
|
||||
folderOrder: [...folderOrder.value],
|
||||
folders: clonePluginFolders(),
|
||||
}
|
||||
}
|
||||
|
||||
function restoreFolderState(snapshot: FolderStateSnapshot) {
|
||||
pluginFolders.value = clonePluginFolders(snapshot.folders)
|
||||
folderOrder.value = [...snapshot.folderOrder]
|
||||
currentFolder.value = snapshot.currentFolder
|
||||
}
|
||||
|
||||
/** 双写发生部分提交时,按服务端已持久化的两个事实源重建排序状态。 */
|
||||
async function reloadPersistedOrderingState() {
|
||||
await loadPluginOrderConfig()
|
||||
await loadPluginFolders()
|
||||
sortPluginOrder()
|
||||
}
|
||||
|
||||
// 按order的顺序对插件进行排序
|
||||
function sortPluginOrder() {
|
||||
if (!orderConfig.value) {
|
||||
@@ -561,6 +630,11 @@ function sortPluginOrder() {
|
||||
|
||||
// 保存混合排序
|
||||
async function saveMixedSortOrder() {
|
||||
const folderSnapshot = captureFolderState()
|
||||
const previousOrder = orderConfig.value.map(item => ({ ...item }))
|
||||
const previousFilteredData = [...filteredDataList.value]
|
||||
let orderPersisted = false
|
||||
|
||||
try {
|
||||
// 分离文件夹和插件,并记录它们的全局排序位置
|
||||
const newFolderOrder: string[] = []
|
||||
@@ -577,7 +651,7 @@ async function saveMixedSortOrder() {
|
||||
if (item.type === 'folder') {
|
||||
newFolderOrder.push(item.id)
|
||||
} else if (item.type === 'plugin') {
|
||||
newPluginOrder.push(item.data)
|
||||
newPluginOrder.push(item.data as Plugin)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -587,7 +661,10 @@ async function saveMixedSortOrder() {
|
||||
if (pluginFolders.value[folderName]) {
|
||||
// 找到该文件夹在全局排序中的位置
|
||||
const globalOrderItem = globalOrder.find(item => item.type === 'folder' && item.id === folderName)
|
||||
pluginFolders.value[folderName].order = globalOrderItem ? globalOrderItem.order : index
|
||||
const folderData = pluginFolders.value[folderName]
|
||||
if (!Array.isArray(folderData)) {
|
||||
folderData.order = globalOrderItem ? globalOrderItem.order : index
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -614,12 +691,21 @@ async function saveMixedSortOrder() {
|
||||
orderConfig.value = orderObj
|
||||
|
||||
// 保存到服务端
|
||||
await api.post('/user/config/PluginOrder', orderObj)
|
||||
await savePluginOrderConfig(orderObj)
|
||||
orderPersisted = true
|
||||
|
||||
// 保存文件夹排序
|
||||
await savePluginFolders()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
if (orderPersisted) {
|
||||
await reloadPersistedOrderingState()
|
||||
} else {
|
||||
restoreFolderState(folderSnapshot)
|
||||
orderConfig.value = previousOrder
|
||||
filteredDataList.value = previousFilteredData
|
||||
}
|
||||
$toast.error(t('plugin.operationFailed'))
|
||||
} finally {
|
||||
// 清除拖拽标志
|
||||
isDraggingSortMode.value = false
|
||||
@@ -633,6 +719,10 @@ async function saveMixedSortOrder() {
|
||||
async function saveFolderPluginOrder() {
|
||||
if (!currentFolder.value) return
|
||||
|
||||
const folderSnapshot = captureFolderState()
|
||||
const previousOrder = orderConfig.value.map(item => ({ ...item }))
|
||||
let orderPersisted = false
|
||||
|
||||
try {
|
||||
// 更新文件夹内插件顺序
|
||||
const folderData = pluginFolders.value[currentFolder.value]
|
||||
@@ -648,14 +738,12 @@ async function saveFolderPluginOrder() {
|
||||
}
|
||||
|
||||
// 更新全局排序配置中文件夹内插件的顺序
|
||||
const folderOrderItem = orderConfig.value.find(
|
||||
(item: any) => item.type === 'folder' && item.id === currentFolder.value,
|
||||
)
|
||||
const folderOrderItem = orderConfig.value.find(item => 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)
|
||||
const existingItem = orderConfig.value.find(item => item.type === 'plugin' && item.id === pluginId)
|
||||
if (existingItem) {
|
||||
existingItem.order = folderGlobalOrder + 0.1 + index * 0.01 // 使用小数确保在文件夹后面
|
||||
} else {
|
||||
@@ -668,16 +756,25 @@ async function saveFolderPluginOrder() {
|
||||
})
|
||||
|
||||
// 保存全局排序配置
|
||||
await api.post('/user/config/PluginOrder', orderConfig.value)
|
||||
await savePluginOrderConfig(orderConfig.value)
|
||||
orderPersisted = true
|
||||
|
||||
// 保存到后端
|
||||
await savePluginFolders()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
if (orderPersisted) {
|
||||
await reloadPersistedOrderingState()
|
||||
} else {
|
||||
restoreFolderState(folderSnapshot)
|
||||
orderConfig.value = previousOrder
|
||||
}
|
||||
$toast.error(t('plugin.operationFailed'))
|
||||
} finally {
|
||||
// 清除拖拽标志
|
||||
isDraggingSortMode.value = false
|
||||
updateMixedSortList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -716,7 +813,7 @@ function isLocalRepoSource(item: Plugin | string | undefined) {
|
||||
function decodeLocalRepoPath(value: string) {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -766,16 +863,13 @@ async function installPlugin(item: Plugin) {
|
||||
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}`, {
|
||||
const result = await api.get<ApiResponse<unknown>, ApiResponse<unknown>>(`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 }))
|
||||
// 清空过滤条件
|
||||
@@ -784,12 +878,15 @@ async function installPlugin(item: Plugin) {
|
||||
installedFilter.value = null
|
||||
// 刷新
|
||||
await refreshData()
|
||||
await pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
} else {
|
||||
$toast.error(t('plugin.installFailed', { name: item?.plugin_name, message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
closePluginProgressDialog()
|
||||
console.error(error)
|
||||
$toast.error(t('plugin.installFailed', { name: item?.plugin_name, message: '' }))
|
||||
} finally {
|
||||
closePluginProgressDialog()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -828,58 +925,67 @@ const filterPlugins = computed(() => {
|
||||
|
||||
// 获取插件列表数据
|
||||
async function fetchInstalledPlugins(context: KeepAliveRefreshContext = {}) {
|
||||
const showLoading = !context.silent || !isRefreshed.value
|
||||
const generation = ++installedWriterGeneration
|
||||
if (!context.silent || !isRefreshed.value) {
|
||||
installedLoadError.value = false
|
||||
}
|
||||
|
||||
try {
|
||||
if (showLoading) {
|
||||
loading.value = true
|
||||
}
|
||||
dataList.value = await api.get('plugin/', {
|
||||
const installedPlugins: Plugin[] = await api.get('plugin/', {
|
||||
params: {
|
||||
state: 'installed',
|
||||
},
|
||||
})
|
||||
if (generation !== installedWriterGeneration) return
|
||||
|
||||
dataList.value = installedPlugins
|
||||
mergeMarketMetadataIntoInstalled()
|
||||
// 排序
|
||||
sortPluginOrder()
|
||||
isRefreshed.value = true
|
||||
installedLoadError.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
loading.value = false
|
||||
if (generation === installedWriterGeneration && !isRefreshed.value) {
|
||||
installedLoadError.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 将市场更新元数据投影到当前已安装快照。 */
|
||||
function mergeMarketMetadataIntoInstalled() {
|
||||
const marketById = new Map(uninstalledList.value.map(plugin => [plugin.id, plugin]))
|
||||
dataList.value.forEach(plugin => {
|
||||
const marketPlugin = marketById.get(plugin.id)
|
||||
if (!marketPlugin) return
|
||||
|
||||
plugin.has_update = true
|
||||
plugin.repo_url = marketPlugin.repo_url
|
||||
plugin.history = marketPlugin.history
|
||||
plugin.system_version = marketPlugin.system_version
|
||||
plugin.system_version_compatible = marketPlugin.system_version_compatible
|
||||
plugin.system_version_message = marketPlugin.system_version_message
|
||||
})
|
||||
}
|
||||
|
||||
// 获取未安装插件列表数据
|
||||
async function fetchUninstalledPlugins(force: boolean = false, context: KeepAliveRefreshContext = {}) {
|
||||
const showLoading = !context.silent || !isAppMarketLoaded.value
|
||||
const generation = ++marketWriterGeneration
|
||||
if (!context.silent || !isAppMarketLoaded.value) {
|
||||
marketLoadError.value = false
|
||||
}
|
||||
|
||||
try {
|
||||
if (showLoading) {
|
||||
loading.value = true
|
||||
}
|
||||
const marketResponse = await api.get('plugin/', {
|
||||
const marketResponse: Plugin[] = await api.get('plugin/', {
|
||||
params: {
|
||||
state: 'market',
|
||||
force: force,
|
||||
},
|
||||
})
|
||||
uninstalledList.value = Array.isArray(marketResponse) ? marketResponse : []
|
||||
// 设置更新状态
|
||||
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
|
||||
data.system_version = uninstalled.system_version
|
||||
data.system_version_compatible = uninstalled.system_version_compatible
|
||||
data.system_version_message = uninstalled.system_version_message
|
||||
}
|
||||
}
|
||||
}
|
||||
isRefreshed.value = true
|
||||
if (generation !== marketWriterGeneration) return
|
||||
|
||||
uninstalledList.value = marketResponse
|
||||
mergeMarketMetadataIntoInstalled()
|
||||
// 更新插件市场列表
|
||||
// 排除已安装且有更新的,上面的问题在于"本地存在未安装的旧版本插件且云端有更新时"不会在插件市场展示
|
||||
marketList.value = uninstalledList.value.filter(item => !(item.has_update && item.installed))
|
||||
@@ -890,19 +996,23 @@ async function fetchUninstalledPlugins(force: boolean = false, context: KeepAliv
|
||||
marketList.value.forEach(initOptions)
|
||||
// 设置APP市场加载完成
|
||||
isAppMarketLoaded.value = true
|
||||
marketLoadError.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
loading.value = false
|
||||
if (generation === marketWriterGeneration && !isAppMarketLoaded.value) {
|
||||
marketLoadError.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载插件统计数据
|
||||
async function getPluginStatistics() {
|
||||
const generation = ++statisticWriterGeneration
|
||||
try {
|
||||
PluginStatistics.value = await api.get('plugin/statistic')
|
||||
const statistics = await api.get<Record<string, number>, Record<string, number>>('plugin/statistic')
|
||||
if (generation === statisticWriterGeneration) {
|
||||
PluginStatistics.value = statistics
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -910,11 +1020,14 @@ async function getPluginStatistics() {
|
||||
|
||||
/** 批量加载插件评分并合并到已安装和市场插件对象。 */
|
||||
async function getPluginRatings() {
|
||||
const generation = ++ratingWriterGeneration
|
||||
const pluginIds = Array.from(
|
||||
new Set([...dataList.value, ...marketList.value].map(plugin => plugin.id).filter(Boolean)),
|
||||
)
|
||||
if (pluginIds.length === 0) {
|
||||
PluginRatings.value = {}
|
||||
if (generation === ratingWriterGeneration) {
|
||||
PluginRatings.value = {}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -929,6 +1042,12 @@ async function getPluginRatings() {
|
||||
})
|
||||
Object.assign(ratings, response)
|
||||
}
|
||||
|
||||
const currentPluginIds = Array.from(
|
||||
new Set([...dataList.value, ...marketList.value].map(plugin => plugin.id).filter(Boolean)),
|
||||
)
|
||||
if (generation !== ratingWriterGeneration || currentPluginIds.join('\0') !== pluginIds.join('\0')) return
|
||||
|
||||
PluginRatings.value = ratings
|
||||
|
||||
for (const plugin of [...dataList.value, ...uninstalledList.value, ...marketList.value]) {
|
||||
@@ -994,8 +1113,15 @@ watch([marketList, filterForm, activeSort, PluginStatistics], () => {
|
||||
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
|
||||
const sortKey = activeSort.value
|
||||
sortedUninstalledList.value = sortedUninstalledList.value.sort((a, b) => {
|
||||
if (sortKey === 'add_time') {
|
||||
return a.add_time !== undefined && b.add_time !== undefined && a.add_time > b.add_time ? 1 : -1
|
||||
}
|
||||
|
||||
const aValue = a[sortKey]
|
||||
const bValue = b[sortKey]
|
||||
return aValue !== undefined && bValue !== undefined && aValue > bValue ? 1 : -1
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1008,6 +1134,7 @@ watch([marketList, filterForm, activeSort, PluginStatistics], () => {
|
||||
async function pluginInstalled() {
|
||||
pluginDialogClose()
|
||||
await refreshData()
|
||||
await pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
}
|
||||
|
||||
// 插件市场设置完成
|
||||
@@ -1053,7 +1180,7 @@ function parseLocalRepoPath(repoUrl: string | undefined) {
|
||||
|
||||
try {
|
||||
return new URL(text).searchParams.get('path') || ''
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return decodeLocalRepoPath(text.match(/[?&]path=([^&]+)/)?.[1] || '')
|
||||
}
|
||||
}
|
||||
@@ -1083,7 +1210,7 @@ watch([dataList, installedFilter, hasUpdateFilter, enabledFilter], () => {
|
||||
})
|
||||
|
||||
// 插件市场加载更多数据
|
||||
function loadMarketMore({ done }: { done: any }) {
|
||||
function loadMarketMore({ done }: { done: (status: 'ok' | 'empty' | 'loading' | 'error') => void }) {
|
||||
// 从 dataList 中获取最前面的 20 个元素
|
||||
const itemsToMove = sortedUninstalledList.value.splice(0, 20)
|
||||
if (itemsToMove.length === 0) {
|
||||
@@ -1205,12 +1332,14 @@ useDynamicButton({
|
||||
// 获取插件文件夹配置
|
||||
async function loadPluginFolders() {
|
||||
try {
|
||||
const response = await api.get('plugin/folders')
|
||||
const foldersData: any = response && typeof response === 'object' ? response : {}
|
||||
const foldersData = await api.get<
|
||||
Record<string, string[] | Partial<PluginFolderConfig>>,
|
||||
Record<string, string[] | Partial<PluginFolderConfig>>
|
||||
>('plugin/folders')
|
||||
|
||||
// 处理旧格式兼容性(array)和新格式(object with config)
|
||||
const processedFolders: any = {}
|
||||
const order = []
|
||||
const processedFolders: Record<string, PluginFolderConfig> = {}
|
||||
const order: string[] = []
|
||||
|
||||
Object.keys(foldersData).forEach(folderName => {
|
||||
const folderData = foldersData[folderName]
|
||||
@@ -1253,6 +1382,7 @@ async function loadPluginFolders() {
|
||||
return aOrder - bOrder
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
pluginFolders.value = {}
|
||||
folderOrder.value = []
|
||||
}
|
||||
@@ -1260,40 +1390,52 @@ async function loadPluginFolders() {
|
||||
|
||||
// 保存插件文件夹配置
|
||||
async function savePluginFolders() {
|
||||
try {
|
||||
// 更新排序信息
|
||||
const foldersToSave: any = {}
|
||||
Object.keys(pluginFolders.value).forEach(folderName => {
|
||||
const folderData = pluginFolders.value[folderName]
|
||||
const orderIndex = folderOrder.value.indexOf(folderName)
|
||||
const foldersToSave: Record<string, PluginFolderConfig> = {}
|
||||
Object.keys(pluginFolders.value).forEach(folderName => {
|
||||
const folderData = pluginFolders.value[folderName]
|
||||
const orderIndex = folderOrder.value.indexOf(folderName)
|
||||
const normalizedFolder = Array.isArray(folderData)
|
||||
? {
|
||||
plugins: [...folderData],
|
||||
order: orderIndex,
|
||||
icon: defaultIcon,
|
||||
color: defaultColor,
|
||||
gradient: defaultGradient,
|
||||
background: '',
|
||||
showIcon: true,
|
||||
}
|
||||
: folderData
|
||||
|
||||
foldersToSave[folderName] = {
|
||||
...folderData,
|
||||
order: orderIndex >= 0 ? orderIndex : 999,
|
||||
}
|
||||
})
|
||||
foldersToSave[folderName] = {
|
||||
...normalizedFolder,
|
||||
order: orderIndex >= 0 ? orderIndex : 999,
|
||||
}
|
||||
})
|
||||
|
||||
await api.post('plugin/folders', foldersToSave)
|
||||
} catch (error) {
|
||||
throw error
|
||||
const result = await api.post<ApiResponse<unknown>, ApiResponse<unknown>>('plugin/folders', foldersToSave)
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.message || t('plugin.operationFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新文件夹
|
||||
async function createNewFolder() {
|
||||
if (!newFolderName.value.trim()) {
|
||||
const folderName = newFolderName.value.trim()
|
||||
if (!folderName) {
|
||||
$toast.error(t('plugin.folderNameEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
if (pluginFolders.value[newFolderName.value]) {
|
||||
if (pluginFolders.value[folderName]) {
|
||||
$toast.error(t('plugin.folderExists'))
|
||||
return
|
||||
}
|
||||
|
||||
const snapshot = captureFolderState()
|
||||
|
||||
try {
|
||||
// 直接在本地添加文件夹
|
||||
pluginFolders.value[newFolderName.value] = {
|
||||
pluginFolders.value[folderName] = {
|
||||
plugins: [],
|
||||
order: folderOrder.value.length,
|
||||
icon: defaultIcon,
|
||||
@@ -1304,7 +1446,7 @@ async function createNewFolder() {
|
||||
}
|
||||
|
||||
// 添加到排序列表
|
||||
folderOrder.value.push(newFolderName.value)
|
||||
folderOrder.value.push(folderName)
|
||||
|
||||
// 保存到后端
|
||||
await savePluginFolders()
|
||||
@@ -1314,10 +1456,9 @@ async function createNewFolder() {
|
||||
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'))
|
||||
console.error(error)
|
||||
restoreFolderState(snapshot)
|
||||
$toast.error(t('plugin.operationFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1338,6 +1479,7 @@ async function renameFolder(oldName: string, newName: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const snapshot = captureFolderState()
|
||||
try {
|
||||
// 更新本地状态
|
||||
const folderData = pluginFolders.value[oldName] || { plugins: [] }
|
||||
@@ -1361,24 +1503,14 @@ async function renameFolder(oldName: string, newName: string) {
|
||||
$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
|
||||
}
|
||||
restoreFolderState(snapshot)
|
||||
$toast.error(t('plugin.folderRenameFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// 删除文件夹
|
||||
async function deleteFolder(folderName: string) {
|
||||
// 保存被删除的文件夹内容以便回滚
|
||||
const deletedFolder = { ...pluginFolders.value[folderName] }
|
||||
const snapshot = captureFolderState()
|
||||
try {
|
||||
delete pluginFolders.value[folderName]
|
||||
|
||||
@@ -1395,11 +1527,8 @@ async function deleteFolder(folderName: string) {
|
||||
|
||||
$toast.success(t('plugin.folderDeleteSuccess'))
|
||||
} catch (error) {
|
||||
// 回滚本地更改
|
||||
pluginFolders.value[folderName] = deletedFolder
|
||||
if (!folderOrder.value.includes(folderName)) {
|
||||
folderOrder.value.push(folderName)
|
||||
}
|
||||
console.error(error)
|
||||
restoreFolderState(snapshot)
|
||||
$toast.error(t('plugin.folderDeleteFailed'))
|
||||
}
|
||||
}
|
||||
@@ -1425,6 +1554,7 @@ function showNewFolderDialog() {
|
||||
async function removeFromFolder(pluginId: string) {
|
||||
if (!currentFolder.value) return
|
||||
|
||||
const snapshot = captureFolderState()
|
||||
try {
|
||||
// 从当前文件夹中移除插件
|
||||
const folderData = pluginFolders.value[currentFolder.value]
|
||||
@@ -1443,12 +1573,14 @@ async function removeFromFolder(pluginId: string) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
restoreFolderState(snapshot)
|
||||
$toast.error(t('plugin.operationFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// 更新文件夹配置
|
||||
async function updateFolderConfig(folderName: string, config: any) {
|
||||
async function updateFolderConfig(folderName: string, config: Partial<PluginFolderConfig>) {
|
||||
const snapshot = captureFolderState()
|
||||
try {
|
||||
// 更新本地配置
|
||||
if (pluginFolders.value[folderName]) {
|
||||
@@ -1459,8 +1591,11 @@ async function updateFolderConfig(folderName: string, config: any) {
|
||||
|
||||
// 保存到后端
|
||||
await savePluginFolders()
|
||||
$toast.success(t('folder.folderSettingsSaved'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
restoreFolderState(snapshot)
|
||||
$toast.error(t('plugin.saveFolderConfigFailed'))
|
||||
}
|
||||
}
|
||||
@@ -1482,6 +1617,7 @@ async function handleDropToFolder(event: DragEvent, folderName: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const snapshot = captureFolderState()
|
||||
try {
|
||||
// 检查是否是文件夹名(忽略文件夹拖入文件夹的情况)
|
||||
if (Object.keys(pluginFolders.value).includes(pluginId)) {
|
||||
@@ -1555,12 +1691,17 @@ async function handleDropToFolder(event: DragEvent, folderName: string) {
|
||||
|
||||
$toast.success(`插件已移动到文件夹 "${folderName}"`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
restoreFolderState(snapshot)
|
||||
isDraggingSortMode.value = false
|
||||
currentDraggedPluginId.value = ''
|
||||
updateMixedSortList()
|
||||
$toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽开始事件(修复版本)
|
||||
function onDragStartPlugin(evt: any) {
|
||||
// 记录拖拽起点对应的插件 ID
|
||||
function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
||||
// 设置拖拽模式标志
|
||||
isDraggingSortMode.value = true
|
||||
|
||||
@@ -1598,7 +1739,7 @@ function onDragStartPlugin(evt: any) {
|
||||
|
||||
// 直接从元素属性获取
|
||||
if (item && item.getAttribute && item.getAttribute('data-plugin-id')) {
|
||||
currentDraggedPluginId.value = item.getAttribute('data-plugin-id')
|
||||
currentDraggedPluginId.value = item.getAttribute('data-plugin-id') || ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1756,7 +1897,19 @@ function onDragStartPlugin(evt: any) {
|
||||
<VWindowItem value="installed">
|
||||
<div>
|
||||
<VPageContentTitle v-if="installedFilter" :title="t('plugin.filter', { name: installedFilter })" />
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<LoadingBanner v-if="!isRefreshed && !installedLoadError" class="mt-12" />
|
||||
<NoDataFound
|
||||
v-if="installedLoadError && !isRefreshed"
|
||||
error-code="500"
|
||||
:error-title="t('common.serverConnectionFailed')"
|
||||
:error-description="t('common.troubleshooting')"
|
||||
>
|
||||
<template #button>
|
||||
<VBtn color="primary" variant="tonal" @click="refreshData()">
|
||||
{{ t('common.retry') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
</NoDataFound>
|
||||
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4 py-0 app-surface-static">
|
||||
<div class="d-flex flex-wrap align-center justify-space-between gap-2 py-5">
|
||||
<span>{{ t('common.sortModeHint') }}</span>
|
||||
@@ -1901,9 +2054,23 @@ function onDragStartPlugin(evt: any) {
|
||||
<VWindowItem value="market">
|
||||
<div>
|
||||
<LoadingBanner
|
||||
v-if="!isAppMarketLoaded || (isMarketRefreshing && displayUninstalledList.length === 0)"
|
||||
v-if="
|
||||
(!isAppMarketLoaded && !marketLoadError) || (isMarketRefreshing && displayUninstalledList.length === 0)
|
||||
"
|
||||
class="mt-12"
|
||||
/>
|
||||
<NoDataFound
|
||||
v-if="marketLoadError && !isAppMarketLoaded"
|
||||
error-code="500"
|
||||
:error-title="t('common.serverConnectionFailed')"
|
||||
:error-description="t('common.troubleshooting')"
|
||||
>
|
||||
<template #button>
|
||||
<VBtn color="primary" variant="tonal" @click="refreshMarket()">
|
||||
{{ t('common.retry') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
</NoDataFound>
|
||||
<!-- 资源列表 -->
|
||||
<VInfiniteScroll
|
||||
v-if="isAppMarketLoaded && !(isMarketRefreshing && displayUninstalledList.length === 0)"
|
||||
|
||||
1355
src/views/plugin/__tests__/PluginCardListView.spec.ts
Normal file
1355
src/views/plugin/__tests__/PluginCardListView.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -369,6 +369,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'src/components/cards/MediaCard.vue',
|
||||
'src/components/cards/SiteCard.vue',
|
||||
'src/components/cards/DownloadingCard.vue',
|
||||
'src/components/cards/PluginFolderCard.vue',
|
||||
'src/components/slide/VirtualSlideView.vue',
|
||||
'src/views/discover/PersonCardSlideView.vue',
|
||||
'src/views/reorganize/TransferHistoryView.vue',
|
||||
@@ -376,6 +377,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'src/utils/searchStream.ts',
|
||||
'src/views/site/SiteCardListView.vue',
|
||||
'src/views/reorganize/DownloadingListView.vue',
|
||||
'src/views/plugin/PluginCardListView.vue',
|
||||
'src/utils/siteIconCache.ts',
|
||||
],
|
||||
provider: 'v8',
|
||||
@@ -404,6 +406,18 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/components/cards/PluginFolderCard.vue': {
|
||||
branches: 80,
|
||||
functions: 85,
|
||||
lines: 85,
|
||||
statements: 85,
|
||||
},
|
||||
'src/views/plugin/PluginCardListView.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/views/reorganize/FileBrowserView.vue': {
|
||||
branches: 80,
|
||||
functions: 85,
|
||||
|
||||
Reference in New Issue
Block a user