feat: 前端支持管理插件

This commit is contained in:
lanyeeee
2026-03-12 18:02:18 +08:00
parent 933f8000dd
commit e240e719ce
2 changed files with 310 additions and 2 deletions
+12 -2
View File
@@ -8,6 +8,7 @@ import DownloadSettings from './components/DownloadSettings.vue'
import FmtSettings from './components/FmtSettings.vue'
import NetworkSettings from './components/NetworkSettings.vue'
import AssDanmakuSettings from './components/AssDanmakuSettings.vue'
import PluginSettings from './components/PluginSettings.vue'
import { NButton, NDialog, NModal, NTabPane, NTabs, useMessage } from 'naive-ui'
const store = useStore()
@@ -53,7 +54,7 @@ async function showConfigInFileManager() {
<n-modal v-if="store.config !== undefined" v-model:show="showing">
<n-dialog :showIcon="false" content-style="" @close="showing = false">
<div class="flex flex-col gap-row-2">
<n-tabs class="h-full" v-model:value="currentTabName" type="line" size="small" animated>
<n-tabs class="h-full settings-tabs" v-model:value="currentTabName" type="line" size="small" animated>
<n-tab-pane name="download_settings" tab="下载内容">
<DownloadSettings />
</n-tab-pane>
@@ -66,9 +67,18 @@ async function showConfigInFileManager() {
<n-tab-pane name="network_settings" tab="网络">
<NetworkSettings />
</n-tab-pane>
<n-tab-pane name="plugin_settings" tab="插件">
<PluginSettings />
</n-tab-pane>
</n-tabs>
<n-button class="ml-auto mt-2" size="small" @click="showConfigInFileManager">打开配置目录</n-button>
<n-button
v-if="currentTabName !== 'plugin_settings'"
class="ml-auto mt-2"
size="small"
@click="showConfigInFileManager">
打开配置目录
</n-button>
</div>
</n-dialog>
</n-modal>
@@ -0,0 +1,298 @@
<script setup lang="tsx">
import { computed, defineComponent, onMounted, onUnmounted, ref, type PropType } from 'vue'
import { open } from '@tauri-apps/plugin-dialog'
import { NButton, NCollapseTransition, NInputNumber, NSwitch, NTag, useMessage, NEmpty, NIcon } from 'naive-ui'
import { commands, events, PluginInfo } from '../../../bindings.ts'
import { PhPlusCircle } from '@phosphor-icons/vue'
import { path } from '@tauri-apps/api'
import { appDataDir } from '@tauri-apps/api/path'
const message = useMessage()
const pluginInfos = ref<Map<string, PluginInfo>>(new Map())
const sortedPluginInfos = computed<PluginInfo[]>(() =>
Array.from(pluginInfos.value.values()).sort((a, b) => b.priority - a.priority),
)
const expandedPluginIds = ref<Set<string>>(new Set())
onMounted(async () => {
const infos = await commands.getPluginInfos()
const pluginMap = new Map<string, PluginInfo>()
for (const info of infos) {
pluginMap.set(info.path, info)
}
pluginInfos.value = pluginMap
})
let unListenPluginEvent: (() => void) | undefined
onMounted(() => {
events.pluginEvent
.listen(({ payload: { event, data } }) => {
if (event === 'Loaded') {
const info = data.plugin_info
pluginInfos.value.set(info.path, info)
} else if (event === 'Update') {
const info = data.plugin_info
const pluginInfo = pluginInfos.value.get(info.path)
if (pluginInfo !== undefined) {
Object.assign(pluginInfo, info)
}
} else if (event === 'Uninstall') {
const pluginPath = data.plugin_path
pluginInfos.value.delete(pluginPath)
expandedPluginIds.value.delete(pluginPath)
}
})
.then((unListenFn) => {
unListenPluginEvent = unListenFn
})
})
onUnmounted(() => {
unListenPluginEvent?.()
})
async function addPlugin() {
const selectedPath = await open({
directory: false,
multiple: false,
filters: [{ name: '', extensions: ['dll', 'so', 'dylib'] }],
})
if (selectedPath === null) {
return
}
const result = await commands.addPlugin(selectedPath)
if (result.status === 'error') {
console.error(result.error)
return
}
message.success('添加插件成功')
}
async function showPluginConfigInFileManager() {
const configPath = await path.join(await appDataDir(), 'plugin.json')
const result = await commands.showPathInFileManager(configPath)
if (result.status === 'error') {
console.error(result.error)
}
}
const PluginCard = defineComponent({
name: 'PluginCard',
props: {
pluginInfo: {
type: Object as PropType<PluginInfo>,
required: true,
},
},
setup(props) {
function toggleAdvanced(pluginPath: string) {
if (expandedPluginIds.value.has(pluginPath)) {
expandedPluginIds.value.delete(pluginPath)
} else {
expandedPluginIds.value.add(pluginPath)
}
}
function isAdvancedShown(pluginPath: string): boolean {
return expandedPluginIds.value.has(pluginPath)
}
return () => {
async function uninstallPlugin(pluginPath: string) {
const result = await commands.uninstallPlugin(pluginPath)
if (result.status === 'error') {
console.error(result.error)
return
}
expandedPluginIds.value.delete(pluginPath)
message.success('卸载插件成功')
}
async function updateEnabled(pluginPath: string, enabled: boolean) {
const pluginInfo = pluginInfos.value.get(pluginPath)
if (pluginInfo === undefined) {
return
}
const prevEnabled = pluginInfo.enabled
pluginInfo.enabled = enabled
const result = await commands.setPluginEnabled(pluginPath, enabled)
if (result.status === 'error') {
console.error(result.error)
pluginInfo.enabled = prevEnabled
}
}
async function updatePriority(pluginPath: string, priority: number | null) {
if (priority === null) {
return
}
const pluginInfo = pluginInfos.value.get(pluginPath)
if (pluginInfo === undefined) {
return
}
const prevPriority = pluginInfo.priority
const result = await commands.setPluginPriority(pluginPath, priority)
if (result.status === 'error') {
console.error(result.error)
pluginInfo.priority = prevPriority
}
}
type TagType = 'error' | 'default' | 'primary' | 'info' | 'success' | 'warning'
function getRuntimeStatusMeta(status: PluginInfo['runtime_status']): { text: string; type: TagType } {
switch (status) {
case 'Loaded':
return { text: '已加载', type: 'success' }
case 'Disabled':
return { text: '未启用', type: 'warning' }
case 'LoadFailed':
return { text: '加载失败', type: 'error' }
case 'Unknown':
return { text: '待加载', type: 'info' }
}
}
function getFailurePolicyMeta(policy: PluginInfo['descriptor']['failure_policy']): {
text: string
type: TagType
} {
if (policy === 'FailClosed') {
return { text: 'FailClosed', type: 'warning' }
}
return { text: 'FailOpen', type: 'info' }
}
const runtimeStatus = getRuntimeStatusMeta(props.pluginInfo.runtime_status)
const failurePolicy = getFailurePolicyMeta(props.pluginInfo.descriptor.failure_policy)
const advancedShowing = isAdvancedShown(props.pluginInfo.path)
return (
<div class="rounded border-1 border-solid border-gray-3 px-3 py-2 flex flex-col gap-1">
<div class="flex items-center flex-wrap gap-2">
<span class="font-bold text-sm">{props.pluginInfo.descriptor.name}</span>
<NTag size="small">v{props.pluginInfo.descriptor.version}</NTag>
<NTag size="small" type={runtimeStatus.type}>
{runtimeStatus.text}
</NTag>
</div>
<div class="text-xs text-gray-6 break-words">{props.pluginInfo.descriptor.description}</div>
<div class="flex flex-col">
<NCollapseTransition show={advancedShowing}>
<div class="flex flex-col gap-1">
<div class="text-xs break-all">
<span class="font-bold">SDK API:</span>
<span class="ml-1">{props.pluginInfo.descriptor.sdk_api_version}</span>
</div>
<div class="text-xs break-all">
<span class="font-bold">ID:</span>
<span class="ml-1">{props.pluginInfo.descriptor.id}</span>
</div>
<div class="text-xs break-all">
<span class="font-bold">路径:</span>
<span class="ml-1">{props.pluginInfo.path}</span>
</div>
<div class="flex items-center gap-1 flex-wrap">
<span class="text-xs font-bold">Hooks:</span>
{props.pluginInfo.descriptor.hooks.map((hookPoint) => (
<NTag key={hookPoint} size="small">
{hookPoint}
</NTag>
))}
</div>
<div class="flex items-center gap-1">
<span class="text-xs font-bold">失败策略:</span>
<NTag size="small" type={failurePolicy.type}>
{failurePolicy.text}
</NTag>
</div>
</div>
</NCollapseTransition>
<div class="flex gap-4">
<div class="flex items-center gap-2">
<span class="text-sm">启用</span>
<NSwitch
size="small"
value={props.pluginInfo.enabled}
onUpdate:value={(value: boolean) => updateEnabled(props.pluginInfo.path, value)}
/>
</div>
<div class="flex items-center gap-2">
<span class="text-sm">优先级</span>
<NInputNumber
class="w-26"
size="small"
value={props.pluginInfo.priority}
onUpdate:value={(value: number | null) => updatePriority(props.pluginInfo.path, value)}
parse={(x: string) => Number(x)}
/>
</div>
</div>
<div class="flex w-full items-center gap-2">
<NButton size="small" onClick={() => toggleAdvanced(props.pluginInfo.path)}>
{advancedShowing ? '隐藏高级信息' : '显示高级信息'}
</NButton>
<NButton
class="ml-auto"
size="small"
type="error"
ghost
onClick={() => uninstallPlugin(props.pluginInfo.path)}>
卸载
</NButton>
</div>
</div>
</div>
)
}
},
})
</script>
<template>
<div class="flex flex-col gap-row-2">
<n-empty v-if="sortedPluginInfos.length === 0" description="暂无插件,点击 添加插件 按钮导入插件" />
<div v-else class="flex flex-col gap-2 max-h-60vh overflow-auto overflow-x-hidden pr-1">
<PluginCard v-for="pluginInfo in sortedPluginInfos" :key="pluginInfo.path" :plugin-info="pluginInfo" />
</div>
<div class="flex mt-2">
<n-button class="mr-auto" size="small" type="primary" @click="addPlugin">
<template #icon>
<n-icon size="20">
<PhPlusCircle />
</n-icon>
</template>
添加插件
</n-button>
<n-button size="small" @click="showPluginConfigInFileManager">打开配置目录</n-button>
</div>
</div>
</template>