mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
test(plugin): cover market list state workflows (#631)
This commit is contained in:
@@ -897,20 +897,6 @@
|
|||||||
"count": 1
|
"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": {
|
"src/views/reorganize/TransferHistoryView.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 11
|
"count": 11
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { useDisplay } from 'vuetify'
|
|||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
|
||||||
const PluginFolderRenameDialog = defineAsyncComponent(() => import('@/components/dialog/PluginFolderRenameDialog.vue'))
|
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 {
|
interface FolderConfig {
|
||||||
@@ -60,7 +62,7 @@ const defaultColor = '#2196F3'
|
|||||||
const defaultIcon = 'mdi-folder'
|
const defaultIcon = 'mdi-folder'
|
||||||
// 默认渐变
|
// 默认渐变
|
||||||
const defaultGradient =
|
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(() => {
|
const backgroundImage = computed(() => {
|
||||||
@@ -166,7 +168,6 @@ function showSettingDialog() {
|
|||||||
// 保存设置
|
// 保存设置
|
||||||
function saveSettings(config: FolderConfig) {
|
function saveSettings(config: FolderConfig) {
|
||||||
emit('update-config', props.folderName, config)
|
emit('update-config', props.folderName, config)
|
||||||
$toast.success(t('folder.folderSettingsSaved'))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -226,69 +227,72 @@ const dropdownItems = ref([
|
|||||||
'plugin-folder-card--sortable': props.sortable,
|
'plugin-folder-card--sortable': props.sortable,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template v-if="backgroundImage" #image>
|
<template v-if="backgroundImage" #image>
|
||||||
<VImg :src="backgroundImage" cover position="top"> </VImg>
|
<VImg :src="backgroundImage" cover position="top"> </VImg>
|
||||||
</template>
|
</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__content">
|
||||||
<!-- 主体内容 -->
|
<!-- 主体内容 -->
|
||||||
<div class="plugin-folder-card__body" :class="{ 'plugin-folder-card__body--no-icon': !shouldShowIcon }">
|
<div class="plugin-folder-card__body" :class="{ 'plugin-folder-card__body--no-icon': !shouldShowIcon }">
|
||||||
<!-- 文件夹图标 -->
|
<!-- 文件夹图标 -->
|
||||||
<div v-if="shouldShowIcon" class="plugin-folder-card__icon-container">
|
<div v-if="shouldShowIcon" class="plugin-folder-card__icon-container">
|
||||||
<VIcon
|
<VIcon
|
||||||
:icon="folderIcon"
|
:icon="folderIcon"
|
||||||
:size="display.mobile ? 56 : 72"
|
:size="display.mobile ? 56 : 72"
|
||||||
:color="iconColor"
|
:color="iconColor"
|
||||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
: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>
|
||||||
|
|
||||||
<!-- 文件夹信息 -->
|
<!-- 更多菜单按钮 - 右下角 -->
|
||||||
<div
|
<div v-if="!props.sortable" class="absolute top-0 right-0">
|
||||||
class="plugin-folder-card__info"
|
<VMenu v-model="menuVisible" location="top end" :close-on-content-click="true">
|
||||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
|
<template #activator="{ props: menuProps }">
|
||||||
>
|
<IconBtn v-bind="menuProps" @click.stop>
|
||||||
<!-- 文件夹名称 -->
|
<VIcon size="small" icon="mdi-dots-vertical" class="text-white" />
|
||||||
<h3 class="plugin-folder-card__name">
|
</IconBtn>
|
||||||
{{ props.folderName }}
|
</template>
|
||||||
</h3>
|
<VList>
|
||||||
<!-- 插件数量 -->
|
<VListItem
|
||||||
<p class="plugin-folder-card__count">{{ t('folder.pluginCount', { count: props.pluginCount }) }}</p>
|
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>
|
</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>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
<script lang="ts" setup>
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import api from '@/api'
|
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 NoDataFound from '@/components/states/NoDataFound.vue'
|
||||||
import { useDisplay } from 'vuetify'
|
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
import { getPluginTabs } from '@/router/i18n-menu'
|
import { getPluginTabs } from '@/router/i18n-menu'
|
||||||
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||||
@@ -14,7 +13,7 @@ import { usePWA } from '@/composables/usePWA'
|
|||||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||||
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
|
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
import { useUserStore } from '@/stores'
|
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
@@ -22,6 +21,29 @@ const { t } = useI18n()
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const userStore = useUserStore()
|
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))
|
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 ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||||
const PluginSearchDialog = defineAsyncComponent(() => import('@/components/dialog/PluginSearchDialog.vue'))
|
const PluginSearchDialog = defineAsyncComponent(() => import('@/components/dialog/PluginSearchDialog.vue'))
|
||||||
|
|
||||||
// 显示器宽度
|
|
||||||
const display = useDisplay()
|
|
||||||
|
|
||||||
// APP
|
// APP
|
||||||
// PWA模式检测
|
// PWA模式检测
|
||||||
const { appMode } = usePWA()
|
const { appMode } = usePWA()
|
||||||
@@ -125,13 +144,13 @@ registerHeaderTab({
|
|||||||
const pluginId = ref(route.query.id)
|
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.popular'), value: 'count' },
|
||||||
{ title: t('plugin.sort.name'), value: 'plugin_name' },
|
{ title: t('plugin.sort.name'), value: 'plugin_name' },
|
||||||
{ title: t('plugin.sort.author'), value: 'plugin_author' },
|
{ title: t('plugin.sort.author'), value: 'plugin_author' },
|
||||||
@@ -139,9 +158,6 @@ const sortOptions = computed(() => [
|
|||||||
{ title: t('plugin.sort.latest'), value: 'add_time' },
|
{ title: t('plugin.sort.latest'), value: 'add_time' },
|
||||||
])
|
])
|
||||||
|
|
||||||
// 加载中
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
// 已安装插件列表
|
// 已安装插件列表
|
||||||
const dataList = ref<Plugin[]>([])
|
const dataList = ref<Plugin[]>([])
|
||||||
|
|
||||||
@@ -168,9 +184,14 @@ const displayUninstalledList = ref<Plugin[]>([])
|
|||||||
// 是否刷新过
|
// 是否刷新过
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
|
|
||||||
|
// 首次列表失败与真实空数组必须保持为不同状态,便于用户原地重试。
|
||||||
|
const installedLoadError = ref(false)
|
||||||
|
|
||||||
// APP市场是否加载完成
|
// APP市场是否加载完成
|
||||||
const isAppMarketLoaded = ref(false)
|
const isAppMarketLoaded = ref(false)
|
||||||
|
|
||||||
|
const marketLoadError = ref(false)
|
||||||
|
|
||||||
// APP市场窗口
|
// APP市场窗口
|
||||||
const PluginAppDialog = ref(false)
|
const PluginAppDialog = ref(false)
|
||||||
|
|
||||||
@@ -183,6 +204,12 @@ const PluginRatings = ref<{ [key: string]: PluginRating }>({})
|
|||||||
// 插件市场刷新状态
|
// 插件市场刷新状态
|
||||||
const isMarketRefreshing = ref(false)
|
const isMarketRefreshing = ref(false)
|
||||||
|
|
||||||
|
// 每类远程快照独立管理 writer 代际,旧请求只能完成自身 Promise,不能覆盖新状态。
|
||||||
|
let installedWriterGeneration = 0
|
||||||
|
let marketWriterGeneration = 0
|
||||||
|
let ratingWriterGeneration = 0
|
||||||
|
let statisticWriterGeneration = 0
|
||||||
|
|
||||||
// 搜索关键字
|
// 搜索关键字
|
||||||
const keyword = ref('')
|
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() {
|
function closeMarketFilterMenu() {
|
||||||
filterMarketPluginDialog.value = false
|
filterMarketPluginDialog.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选择插件市场排序项并关闭过滤菜单。
|
// 选择插件市场排序项并关闭过滤菜单。
|
||||||
function selectMarketSort(value: string) {
|
function selectMarketSort(value: PluginSortKey) {
|
||||||
activeSort.value = value
|
activeSort.value = value
|
||||||
closeMarketFilterMenu()
|
closeMarketFilterMenu()
|
||||||
}
|
}
|
||||||
@@ -298,7 +315,7 @@ const labelFilterOptions = ref<string[]>([])
|
|||||||
const repoFilterOptions = ref<string[]>([])
|
const repoFilterOptions = ref<string[]>([])
|
||||||
|
|
||||||
// 插件文件夹配置
|
// 插件文件夹配置
|
||||||
const pluginFolders: Ref<{ [key: string]: any }> = ref({})
|
const pluginFolders = ref<PluginFolderMap>({})
|
||||||
|
|
||||||
// 文件夹排序
|
// 文件夹排序
|
||||||
const folderOrder = ref<string[]>([])
|
const folderOrder = ref<string[]>([])
|
||||||
@@ -395,7 +412,7 @@ const displayedPlugins = computed(() => {
|
|||||||
interface MixedSortItem {
|
interface MixedSortItem {
|
||||||
type: 'folder' | 'plugin'
|
type: 'folder' | 'plugin'
|
||||||
id: string
|
id: string
|
||||||
data: any
|
data: Plugin | { name: string; pluginCount: number; config: Partial<PluginFolderConfig> }
|
||||||
order: number
|
order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,7 +469,7 @@ function updateMixedSortList() {
|
|||||||
const items: MixedSortItem[] = []
|
const items: MixedSortItem[] = []
|
||||||
|
|
||||||
// 始终使用全局排序配置来创建混合列表
|
// 始终使用全局排序配置来创建混合列表
|
||||||
const allItems: { type: 'folder' | 'plugin'; id: string; data: any; order: number }[] = []
|
const allItems: MixedSortItem[] = []
|
||||||
|
|
||||||
// 添加文件夹项目
|
// 添加文件夹项目
|
||||||
displayedFolders.value.forEach(folder => {
|
displayedFolders.value.forEach(folder => {
|
||||||
@@ -524,14 +541,21 @@ async function loadPluginOrderConfig() {
|
|||||||
try {
|
try {
|
||||||
const response = await api.get('/user/config/PluginOrder')
|
const response = await api.get('/user/config/PluginOrder')
|
||||||
if (response && response.data && response.data.value) {
|
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]) {
|
if (serverData.length > 0 && typeof serverData[0] === 'object' && serverData[0] && 'type' in serverData[0]) {
|
||||||
orderConfig.value = serverData
|
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 {
|
} else {
|
||||||
// 旧格式,转换为新格式
|
// 旧格式,转换为新格式
|
||||||
orderConfig.value = serverData.map((item: any, index: number) => ({
|
orderConfig.value = serverData.map((item, index) => ({
|
||||||
id: typeof item === 'string' ? item : item.id,
|
id: typeof item === 'string' ? item : item.id || '',
|
||||||
type: 'plugin',
|
type: 'plugin',
|
||||||
order: index,
|
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的顺序对插件进行排序
|
// 按order的顺序对插件进行排序
|
||||||
function sortPluginOrder() {
|
function sortPluginOrder() {
|
||||||
if (!orderConfig.value) {
|
if (!orderConfig.value) {
|
||||||
@@ -561,6 +630,11 @@ function sortPluginOrder() {
|
|||||||
|
|
||||||
// 保存混合排序
|
// 保存混合排序
|
||||||
async function saveMixedSortOrder() {
|
async function saveMixedSortOrder() {
|
||||||
|
const folderSnapshot = captureFolderState()
|
||||||
|
const previousOrder = orderConfig.value.map(item => ({ ...item }))
|
||||||
|
const previousFilteredData = [...filteredDataList.value]
|
||||||
|
let orderPersisted = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 分离文件夹和插件,并记录它们的全局排序位置
|
// 分离文件夹和插件,并记录它们的全局排序位置
|
||||||
const newFolderOrder: string[] = []
|
const newFolderOrder: string[] = []
|
||||||
@@ -577,7 +651,7 @@ async function saveMixedSortOrder() {
|
|||||||
if (item.type === 'folder') {
|
if (item.type === 'folder') {
|
||||||
newFolderOrder.push(item.id)
|
newFolderOrder.push(item.id)
|
||||||
} else if (item.type === 'plugin') {
|
} 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]) {
|
if (pluginFolders.value[folderName]) {
|
||||||
// 找到该文件夹在全局排序中的位置
|
// 找到该文件夹在全局排序中的位置
|
||||||
const globalOrderItem = globalOrder.find(item => item.type === 'folder' && item.id === 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
|
orderConfig.value = orderObj
|
||||||
|
|
||||||
// 保存到服务端
|
// 保存到服务端
|
||||||
await api.post('/user/config/PluginOrder', orderObj)
|
await savePluginOrderConfig(orderObj)
|
||||||
|
orderPersisted = true
|
||||||
|
|
||||||
// 保存文件夹排序
|
// 保存文件夹排序
|
||||||
await savePluginFolders()
|
await savePluginFolders()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
if (orderPersisted) {
|
||||||
|
await reloadPersistedOrderingState()
|
||||||
|
} else {
|
||||||
|
restoreFolderState(folderSnapshot)
|
||||||
|
orderConfig.value = previousOrder
|
||||||
|
filteredDataList.value = previousFilteredData
|
||||||
|
}
|
||||||
|
$toast.error(t('plugin.operationFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
// 清除拖拽标志
|
// 清除拖拽标志
|
||||||
isDraggingSortMode.value = false
|
isDraggingSortMode.value = false
|
||||||
@@ -633,6 +719,10 @@ async function saveMixedSortOrder() {
|
|||||||
async function saveFolderPluginOrder() {
|
async function saveFolderPluginOrder() {
|
||||||
if (!currentFolder.value) return
|
if (!currentFolder.value) return
|
||||||
|
|
||||||
|
const folderSnapshot = captureFolderState()
|
||||||
|
const previousOrder = orderConfig.value.map(item => ({ ...item }))
|
||||||
|
let orderPersisted = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 更新文件夹内插件顺序
|
// 更新文件夹内插件顺序
|
||||||
const folderData = pluginFolders.value[currentFolder.value]
|
const folderData = pluginFolders.value[currentFolder.value]
|
||||||
@@ -648,14 +738,12 @@ async function saveFolderPluginOrder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 更新全局排序配置中文件夹内插件的顺序
|
// 更新全局排序配置中文件夹内插件的顺序
|
||||||
const folderOrderItem = orderConfig.value.find(
|
const folderOrderItem = orderConfig.value.find(item => item.type === 'folder' && item.id === currentFolder.value)
|
||||||
(item: any) => item.type === 'folder' && item.id === currentFolder.value,
|
|
||||||
)
|
|
||||||
const folderGlobalOrder = folderOrderItem?.order ?? 999
|
const folderGlobalOrder = folderOrderItem?.order ?? 999
|
||||||
|
|
||||||
// 为文件夹内的插件分配连续的order值
|
// 为文件夹内的插件分配连续的order值
|
||||||
newPluginIds.forEach((pluginId, index) => {
|
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) {
|
if (existingItem) {
|
||||||
existingItem.order = folderGlobalOrder + 0.1 + index * 0.01 // 使用小数确保在文件夹后面
|
existingItem.order = folderGlobalOrder + 0.1 + index * 0.01 // 使用小数确保在文件夹后面
|
||||||
} else {
|
} else {
|
||||||
@@ -668,16 +756,25 @@ async function saveFolderPluginOrder() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 保存全局排序配置
|
// 保存全局排序配置
|
||||||
await api.post('/user/config/PluginOrder', orderConfig.value)
|
await savePluginOrderConfig(orderConfig.value)
|
||||||
|
orderPersisted = true
|
||||||
|
|
||||||
// 保存到后端
|
// 保存到后端
|
||||||
await savePluginFolders()
|
await savePluginFolders()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
if (orderPersisted) {
|
||||||
|
await reloadPersistedOrderingState()
|
||||||
|
} else {
|
||||||
|
restoreFolderState(folderSnapshot)
|
||||||
|
orderConfig.value = previousOrder
|
||||||
|
}
|
||||||
|
$toast.error(t('plugin.operationFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
// 清除拖拽标志
|
// 清除拖拽标志
|
||||||
isDraggingSortMode.value = false
|
isDraggingSortMode.value = false
|
||||||
|
updateMixedSortList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,7 +813,7 @@ function isLocalRepoSource(item: Plugin | string | undefined) {
|
|||||||
function decodeLocalRepoPath(value: string) {
|
function decodeLocalRepoPath(value: string) {
|
||||||
try {
|
try {
|
||||||
return decodeURIComponent(value)
|
return decodeURIComponent(value)
|
||||||
} catch (error) {
|
} catch {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -766,16 +863,13 @@ async function installPlugin(item: Plugin) {
|
|||||||
progressText.value = t('plugin.installing', { name: item?.plugin_name, version: item?.plugin_version })
|
progressText.value = t('plugin.installing', { name: item?.plugin_name, version: item?.plugin_version })
|
||||||
openPluginProgressDialog(progressText.value)
|
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: {
|
params: {
|
||||||
repo_url: item?.repo_url,
|
repo_url: item?.repo_url,
|
||||||
force: item?.has_update,
|
force: item?.has_update,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 隐藏等待提示框
|
|
||||||
closePluginProgressDialog()
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
$toast.success(t('plugin.installSuccess', { name: item?.plugin_name }))
|
$toast.success(t('plugin.installSuccess', { name: item?.plugin_name }))
|
||||||
// 清空过滤条件
|
// 清空过滤条件
|
||||||
@@ -784,12 +878,15 @@ async function installPlugin(item: Plugin) {
|
|||||||
installedFilter.value = null
|
installedFilter.value = null
|
||||||
// 刷新
|
// 刷新
|
||||||
await refreshData()
|
await refreshData()
|
||||||
|
await pluginSidebarNavStore.ensureSidebarNav(true)
|
||||||
} else {
|
} else {
|
||||||
$toast.error(t('plugin.installFailed', { name: item?.plugin_name, message: result.message }))
|
$toast.error(t('plugin.installFailed', { name: item?.plugin_name, message: result.message }))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
closePluginProgressDialog()
|
|
||||||
console.error(error)
|
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 = {}) {
|
async function fetchInstalledPlugins(context: KeepAliveRefreshContext = {}) {
|
||||||
const showLoading = !context.silent || !isRefreshed.value
|
const generation = ++installedWriterGeneration
|
||||||
|
if (!context.silent || !isRefreshed.value) {
|
||||||
|
installedLoadError.value = false
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (showLoading) {
|
const installedPlugins: Plugin[] = await api.get('plugin/', {
|
||||||
loading.value = true
|
|
||||||
}
|
|
||||||
dataList.value = await api.get('plugin/', {
|
|
||||||
params: {
|
params: {
|
||||||
state: 'installed',
|
state: 'installed',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
if (generation !== installedWriterGeneration) return
|
||||||
|
|
||||||
|
dataList.value = installedPlugins
|
||||||
|
mergeMarketMetadataIntoInstalled()
|
||||||
// 排序
|
// 排序
|
||||||
sortPluginOrder()
|
sortPluginOrder()
|
||||||
isRefreshed.value = true
|
isRefreshed.value = true
|
||||||
|
installedLoadError.value = false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
} finally {
|
if (generation === installedWriterGeneration && !isRefreshed.value) {
|
||||||
if (showLoading) {
|
installedLoadError.value = true
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将市场更新元数据投影到当前已安装快照。 */
|
||||||
|
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 = {}) {
|
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 {
|
try {
|
||||||
if (showLoading) {
|
const marketResponse: Plugin[] = await api.get('plugin/', {
|
||||||
loading.value = true
|
|
||||||
}
|
|
||||||
const marketResponse = await api.get('plugin/', {
|
|
||||||
params: {
|
params: {
|
||||||
state: 'market',
|
state: 'market',
|
||||||
force: force,
|
force: force,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
uninstalledList.value = Array.isArray(marketResponse) ? marketResponse : []
|
if (generation !== marketWriterGeneration) return
|
||||||
// 设置更新状态
|
|
||||||
for (const uninstalled of uninstalledList.value) {
|
uninstalledList.value = marketResponse
|
||||||
for (const data of dataList.value) {
|
mergeMarketMetadataIntoInstalled()
|
||||||
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
|
|
||||||
// 更新插件市场列表
|
// 更新插件市场列表
|
||||||
// 排除已安装且有更新的,上面的问题在于"本地存在未安装的旧版本插件且云端有更新时"不会在插件市场展示
|
// 排除已安装且有更新的,上面的问题在于"本地存在未安装的旧版本插件且云端有更新时"不会在插件市场展示
|
||||||
marketList.value = uninstalledList.value.filter(item => !(item.has_update && item.installed))
|
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)
|
marketList.value.forEach(initOptions)
|
||||||
// 设置APP市场加载完成
|
// 设置APP市场加载完成
|
||||||
isAppMarketLoaded.value = true
|
isAppMarketLoaded.value = true
|
||||||
|
marketLoadError.value = false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
} finally {
|
if (generation === marketWriterGeneration && !isAppMarketLoaded.value) {
|
||||||
if (showLoading) {
|
marketLoadError.value = true
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载插件统计数据
|
// 加载插件统计数据
|
||||||
async function getPluginStatistics() {
|
async function getPluginStatistics() {
|
||||||
|
const generation = ++statisticWriterGeneration
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
@@ -910,11 +1020,14 @@ async function getPluginStatistics() {
|
|||||||
|
|
||||||
/** 批量加载插件评分并合并到已安装和市场插件对象。 */
|
/** 批量加载插件评分并合并到已安装和市场插件对象。 */
|
||||||
async function getPluginRatings() {
|
async function getPluginRatings() {
|
||||||
|
const generation = ++ratingWriterGeneration
|
||||||
const pluginIds = Array.from(
|
const pluginIds = Array.from(
|
||||||
new Set([...dataList.value, ...marketList.value].map(plugin => plugin.id).filter(Boolean)),
|
new Set([...dataList.value, ...marketList.value].map(plugin => plugin.id).filter(Boolean)),
|
||||||
)
|
)
|
||||||
if (pluginIds.length === 0) {
|
if (pluginIds.length === 0) {
|
||||||
PluginRatings.value = {}
|
if (generation === ratingWriterGeneration) {
|
||||||
|
PluginRatings.value = {}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -929,6 +1042,12 @@ async function getPluginRatings() {
|
|||||||
})
|
})
|
||||||
Object.assign(ratings, response)
|
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
|
PluginRatings.value = ratings
|
||||||
|
|
||||||
for (const plugin of [...dataList.value, ...uninstalledList.value, ...marketList.value]) {
|
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)
|
return (PluginStatistics.value[b.id || '0'] ?? 0) - (PluginStatistics.value[a.id || '0'] ?? 0)
|
||||||
})
|
})
|
||||||
} else if (activeSort.value) {
|
} else if (activeSort.value) {
|
||||||
sortedUninstalledList.value = sortedUninstalledList.value.sort((a: any, b: any) => {
|
const sortKey = activeSort.value
|
||||||
return a[activeSort.value ?? ''] > b[activeSort.value ?? ''] ? 1 : -1
|
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() {
|
async function pluginInstalled() {
|
||||||
pluginDialogClose()
|
pluginDialogClose()
|
||||||
await refreshData()
|
await refreshData()
|
||||||
|
await pluginSidebarNavStore.ensureSidebarNav(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 插件市场设置完成
|
// 插件市场设置完成
|
||||||
@@ -1053,7 +1180,7 @@ function parseLocalRepoPath(repoUrl: string | undefined) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return new URL(text).searchParams.get('path') || ''
|
return new URL(text).searchParams.get('path') || ''
|
||||||
} catch (error) {
|
} catch {
|
||||||
return decodeLocalRepoPath(text.match(/[?&]path=([^&]+)/)?.[1] || '')
|
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 个元素
|
// 从 dataList 中获取最前面的 20 个元素
|
||||||
const itemsToMove = sortedUninstalledList.value.splice(0, 20)
|
const itemsToMove = sortedUninstalledList.value.splice(0, 20)
|
||||||
if (itemsToMove.length === 0) {
|
if (itemsToMove.length === 0) {
|
||||||
@@ -1205,12 +1332,14 @@ useDynamicButton({
|
|||||||
// 获取插件文件夹配置
|
// 获取插件文件夹配置
|
||||||
async function loadPluginFolders() {
|
async function loadPluginFolders() {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('plugin/folders')
|
const foldersData = await api.get<
|
||||||
const foldersData: any = response && typeof response === 'object' ? response : {}
|
Record<string, string[] | Partial<PluginFolderConfig>>,
|
||||||
|
Record<string, string[] | Partial<PluginFolderConfig>>
|
||||||
|
>('plugin/folders')
|
||||||
|
|
||||||
// 处理旧格式兼容性(array)和新格式(object with config)
|
// 处理旧格式兼容性(array)和新格式(object with config)
|
||||||
const processedFolders: any = {}
|
const processedFolders: Record<string, PluginFolderConfig> = {}
|
||||||
const order = []
|
const order: string[] = []
|
||||||
|
|
||||||
Object.keys(foldersData).forEach(folderName => {
|
Object.keys(foldersData).forEach(folderName => {
|
||||||
const folderData = foldersData[folderName]
|
const folderData = foldersData[folderName]
|
||||||
@@ -1253,6 +1382,7 @@ async function loadPluginFolders() {
|
|||||||
return aOrder - bOrder
|
return aOrder - bOrder
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
pluginFolders.value = {}
|
pluginFolders.value = {}
|
||||||
folderOrder.value = []
|
folderOrder.value = []
|
||||||
}
|
}
|
||||||
@@ -1260,40 +1390,52 @@ async function loadPluginFolders() {
|
|||||||
|
|
||||||
// 保存插件文件夹配置
|
// 保存插件文件夹配置
|
||||||
async function savePluginFolders() {
|
async function savePluginFolders() {
|
||||||
try {
|
const foldersToSave: Record<string, PluginFolderConfig> = {}
|
||||||
// 更新排序信息
|
Object.keys(pluginFolders.value).forEach(folderName => {
|
||||||
const foldersToSave: any = {}
|
const folderData = pluginFolders.value[folderName]
|
||||||
Object.keys(pluginFolders.value).forEach(folderName => {
|
const orderIndex = folderOrder.value.indexOf(folderName)
|
||||||
const folderData = pluginFolders.value[folderName]
|
const normalizedFolder = Array.isArray(folderData)
|
||||||
const orderIndex = folderOrder.value.indexOf(folderName)
|
? {
|
||||||
|
plugins: [...folderData],
|
||||||
|
order: orderIndex,
|
||||||
|
icon: defaultIcon,
|
||||||
|
color: defaultColor,
|
||||||
|
gradient: defaultGradient,
|
||||||
|
background: '',
|
||||||
|
showIcon: true,
|
||||||
|
}
|
||||||
|
: folderData
|
||||||
|
|
||||||
foldersToSave[folderName] = {
|
foldersToSave[folderName] = {
|
||||||
...folderData,
|
...normalizedFolder,
|
||||||
order: orderIndex >= 0 ? orderIndex : 999,
|
order: orderIndex >= 0 ? orderIndex : 999,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
await api.post('plugin/folders', foldersToSave)
|
const result = await api.post<ApiResponse<unknown>, ApiResponse<unknown>>('plugin/folders', foldersToSave)
|
||||||
} catch (error) {
|
if (!result?.success) {
|
||||||
throw error
|
throw new Error(result?.message || t('plugin.operationFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建新文件夹
|
// 创建新文件夹
|
||||||
async function createNewFolder() {
|
async function createNewFolder() {
|
||||||
if (!newFolderName.value.trim()) {
|
const folderName = newFolderName.value.trim()
|
||||||
|
if (!folderName) {
|
||||||
$toast.error(t('plugin.folderNameEmpty'))
|
$toast.error(t('plugin.folderNameEmpty'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pluginFolders.value[newFolderName.value]) {
|
if (pluginFolders.value[folderName]) {
|
||||||
$toast.error(t('plugin.folderExists'))
|
$toast.error(t('plugin.folderExists'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const snapshot = captureFolderState()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 直接在本地添加文件夹
|
// 直接在本地添加文件夹
|
||||||
pluginFolders.value[newFolderName.value] = {
|
pluginFolders.value[folderName] = {
|
||||||
plugins: [],
|
plugins: [],
|
||||||
order: folderOrder.value.length,
|
order: folderOrder.value.length,
|
||||||
icon: defaultIcon,
|
icon: defaultIcon,
|
||||||
@@ -1304,7 +1446,7 @@ async function createNewFolder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 添加到排序列表
|
// 添加到排序列表
|
||||||
folderOrder.value.push(newFolderName.value)
|
folderOrder.value.push(folderName)
|
||||||
|
|
||||||
// 保存到后端
|
// 保存到后端
|
||||||
await savePluginFolders()
|
await savePluginFolders()
|
||||||
@@ -1314,10 +1456,9 @@ async function createNewFolder() {
|
|||||||
newFolderName.value = ''
|
newFolderName.value = ''
|
||||||
$toast.success(t('plugin.folderCreateSuccess'))
|
$toast.success(t('plugin.folderCreateSuccess'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 回滚本地更改
|
console.error(error)
|
||||||
delete pluginFolders.value[newFolderName.value]
|
restoreFolderState(snapshot)
|
||||||
folderOrder.value = folderOrder.value.filter(name => name !== newFolderName.value)
|
$toast.error(t('plugin.operationFailed'))
|
||||||
$toast.error(t('plugin.folderCreateFailed'))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1338,6 +1479,7 @@ async function renameFolder(oldName: string, newName: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const snapshot = captureFolderState()
|
||||||
try {
|
try {
|
||||||
// 更新本地状态
|
// 更新本地状态
|
||||||
const folderData = pluginFolders.value[oldName] || { plugins: [] }
|
const folderData = pluginFolders.value[oldName] || { plugins: [] }
|
||||||
@@ -1361,24 +1503,14 @@ async function renameFolder(oldName: string, newName: string) {
|
|||||||
$toast.success(t('plugin.folderRenameSuccess'))
|
$toast.success(t('plugin.folderRenameSuccess'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
// 回滚本地更改
|
restoreFolderState(snapshot)
|
||||||
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'))
|
$toast.error(t('plugin.folderRenameFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除文件夹
|
// 删除文件夹
|
||||||
async function deleteFolder(folderName: string) {
|
async function deleteFolder(folderName: string) {
|
||||||
// 保存被删除的文件夹内容以便回滚
|
const snapshot = captureFolderState()
|
||||||
const deletedFolder = { ...pluginFolders.value[folderName] }
|
|
||||||
try {
|
try {
|
||||||
delete pluginFolders.value[folderName]
|
delete pluginFolders.value[folderName]
|
||||||
|
|
||||||
@@ -1395,11 +1527,8 @@ async function deleteFolder(folderName: string) {
|
|||||||
|
|
||||||
$toast.success(t('plugin.folderDeleteSuccess'))
|
$toast.success(t('plugin.folderDeleteSuccess'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 回滚本地更改
|
console.error(error)
|
||||||
pluginFolders.value[folderName] = deletedFolder
|
restoreFolderState(snapshot)
|
||||||
if (!folderOrder.value.includes(folderName)) {
|
|
||||||
folderOrder.value.push(folderName)
|
|
||||||
}
|
|
||||||
$toast.error(t('plugin.folderDeleteFailed'))
|
$toast.error(t('plugin.folderDeleteFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1425,6 +1554,7 @@ function showNewFolderDialog() {
|
|||||||
async function removeFromFolder(pluginId: string) {
|
async function removeFromFolder(pluginId: string) {
|
||||||
if (!currentFolder.value) return
|
if (!currentFolder.value) return
|
||||||
|
|
||||||
|
const snapshot = captureFolderState()
|
||||||
try {
|
try {
|
||||||
// 从当前文件夹中移除插件
|
// 从当前文件夹中移除插件
|
||||||
const folderData = pluginFolders.value[currentFolder.value]
|
const folderData = pluginFolders.value[currentFolder.value]
|
||||||
@@ -1443,12 +1573,14 @@ async function removeFromFolder(pluginId: string) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
restoreFolderState(snapshot)
|
||||||
$toast.error(t('plugin.operationFailed'))
|
$toast.error(t('plugin.operationFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新文件夹配置
|
// 更新文件夹配置
|
||||||
async function updateFolderConfig(folderName: string, config: any) {
|
async function updateFolderConfig(folderName: string, config: Partial<PluginFolderConfig>) {
|
||||||
|
const snapshot = captureFolderState()
|
||||||
try {
|
try {
|
||||||
// 更新本地配置
|
// 更新本地配置
|
||||||
if (pluginFolders.value[folderName]) {
|
if (pluginFolders.value[folderName]) {
|
||||||
@@ -1459,8 +1591,11 @@ async function updateFolderConfig(folderName: string, config: any) {
|
|||||||
|
|
||||||
// 保存到后端
|
// 保存到后端
|
||||||
await savePluginFolders()
|
await savePluginFolders()
|
||||||
|
$toast.success(t('folder.folderSettingsSaved'))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
restoreFolderState(snapshot)
|
||||||
$toast.error(t('plugin.saveFolderConfigFailed'))
|
$toast.error(t('plugin.saveFolderConfigFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1482,6 +1617,7 @@ async function handleDropToFolder(event: DragEvent, folderName: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const snapshot = captureFolderState()
|
||||||
try {
|
try {
|
||||||
// 检查是否是文件夹名(忽略文件夹拖入文件夹的情况)
|
// 检查是否是文件夹名(忽略文件夹拖入文件夹的情况)
|
||||||
if (Object.keys(pluginFolders.value).includes(pluginId)) {
|
if (Object.keys(pluginFolders.value).includes(pluginId)) {
|
||||||
@@ -1555,12 +1691,17 @@ async function handleDropToFolder(event: DragEvent, folderName: string) {
|
|||||||
|
|
||||||
$toast.success(`插件已移动到文件夹 "${folderName}"`)
|
$toast.success(`插件已移动到文件夹 "${folderName}"`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
restoreFolderState(snapshot)
|
||||||
|
isDraggingSortMode.value = false
|
||||||
|
currentDraggedPluginId.value = ''
|
||||||
|
updateMixedSortList()
|
||||||
$toast.error('操作失败')
|
$toast.error('操作失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拖拽开始事件(修复版本)
|
// 记录拖拽起点对应的插件 ID
|
||||||
function onDragStartPlugin(evt: any) {
|
function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
||||||
// 设置拖拽模式标志
|
// 设置拖拽模式标志
|
||||||
isDraggingSortMode.value = true
|
isDraggingSortMode.value = true
|
||||||
|
|
||||||
@@ -1598,7 +1739,7 @@ function onDragStartPlugin(evt: any) {
|
|||||||
|
|
||||||
// 直接从元素属性获取
|
// 直接从元素属性获取
|
||||||
if (item && item.getAttribute && item.getAttribute('data-plugin-id')) {
|
if (item && item.getAttribute && item.getAttribute('data-plugin-id')) {
|
||||||
currentDraggedPluginId.value = item.getAttribute('data-plugin-id')
|
currentDraggedPluginId.value = item.getAttribute('data-plugin-id') || ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1756,7 +1897,19 @@ function onDragStartPlugin(evt: any) {
|
|||||||
<VWindowItem value="installed">
|
<VWindowItem value="installed">
|
||||||
<div>
|
<div>
|
||||||
<VPageContentTitle v-if="installedFilter" :title="t('plugin.filter', { name: installedFilter })" />
|
<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">
|
<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">
|
<div class="d-flex flex-wrap align-center justify-space-between gap-2 py-5">
|
||||||
<span>{{ t('common.sortModeHint') }}</span>
|
<span>{{ t('common.sortModeHint') }}</span>
|
||||||
@@ -1901,9 +2054,23 @@ function onDragStartPlugin(evt: any) {
|
|||||||
<VWindowItem value="market">
|
<VWindowItem value="market">
|
||||||
<div>
|
<div>
|
||||||
<LoadingBanner
|
<LoadingBanner
|
||||||
v-if="!isAppMarketLoaded || (isMarketRefreshing && displayUninstalledList.length === 0)"
|
v-if="
|
||||||
|
(!isAppMarketLoaded && !marketLoadError) || (isMarketRefreshing && displayUninstalledList.length === 0)
|
||||||
|
"
|
||||||
class="mt-12"
|
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
|
<VInfiniteScroll
|
||||||
v-if="isAppMarketLoaded && !(isMarketRefreshing && displayUninstalledList.length === 0)"
|
v-if="isAppMarketLoaded && !(isMarketRefreshing && displayUninstalledList.length === 0)"
|
||||||
|
|||||||
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/MediaCard.vue',
|
||||||
'src/components/cards/SiteCard.vue',
|
'src/components/cards/SiteCard.vue',
|
||||||
'src/components/cards/DownloadingCard.vue',
|
'src/components/cards/DownloadingCard.vue',
|
||||||
|
'src/components/cards/PluginFolderCard.vue',
|
||||||
'src/components/slide/VirtualSlideView.vue',
|
'src/components/slide/VirtualSlideView.vue',
|
||||||
'src/views/discover/PersonCardSlideView.vue',
|
'src/views/discover/PersonCardSlideView.vue',
|
||||||
'src/views/reorganize/TransferHistoryView.vue',
|
'src/views/reorganize/TransferHistoryView.vue',
|
||||||
@@ -376,6 +377,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'src/utils/searchStream.ts',
|
'src/utils/searchStream.ts',
|
||||||
'src/views/site/SiteCardListView.vue',
|
'src/views/site/SiteCardListView.vue',
|
||||||
'src/views/reorganize/DownloadingListView.vue',
|
'src/views/reorganize/DownloadingListView.vue',
|
||||||
|
'src/views/plugin/PluginCardListView.vue',
|
||||||
'src/utils/siteIconCache.ts',
|
'src/utils/siteIconCache.ts',
|
||||||
],
|
],
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
@@ -404,6 +406,18 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
lines: 80,
|
lines: 80,
|
||||||
statements: 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': {
|
'src/views/reorganize/FileBrowserView.vue': {
|
||||||
branches: 80,
|
branches: 80,
|
||||||
functions: 85,
|
functions: 85,
|
||||||
|
|||||||
Reference in New Issue
Block a user