Files
Xboard/admin-frontend/src/views/system/PluginManagementView.vue
T
yinjianm f7cef30b9c feat(admin-frontend): 完成订阅与系统管理真实工作台
补齐订单、优惠券、主题、插件、公告与支付管理页面,
接入对应后台接口、路由入口与工具层类型定义。
同时修复套餐页开关初始化误写问题,避免浏览即触发写操作。

在订阅协议侧为 Stash 导出增加 AnyTLS 版本守卫,
未知版本或低于 3.3.0 时不再导出该协议,并补充回归测试与知识记录。
2026-04-24 16:52:41 +08:00

368 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { UploadRequestOptions } from 'element-plus'
import { RefreshRight, Search, UploadFilled } from '@element-plus/icons-vue'
import {
disablePlugin,
enablePlugin,
getPluginConfig,
getPlugins,
getPluginTypes,
installPlugin,
savePluginConfig,
uninstallPlugin,
upgradePlugin,
uploadPluginPackage,
} from '@/api/admin'
import type {
AdminPluginConfigField,
AdminPluginItem,
AdminPluginTypeItem,
} from '@/types/api'
import PluginCard from './PluginCard.vue'
import PluginDetailDrawer from './PluginDetailDrawer.vue'
import {
buildPluginTabs,
countEnabledPlugins,
countUpgradeablePlugins,
countUserPlugins,
filterPlugins,
hasPluginConfig,
type PluginStatusFilter,
type PluginTabValue,
PLUGIN_STATUS_FILTER_OPTIONS,
} from '@/utils/plugins'
type PluginAction = 'install' | 'enable' | 'disable' | 'upgrade' | 'uninstall'
type UploadError = Parameters<UploadRequestOptions['onError']>[0]
const loading = ref(true)
const reloading = ref(false)
const uploadLoading = ref(false)
const errorMessage = ref('')
const keyword = ref('')
const typeFilter = ref<PluginTabValue>('all')
const statusFilter = ref<PluginStatusFilter>('all')
const pluginTypes = ref<AdminPluginTypeItem[]>([])
const plugins = ref<AdminPluginItem[]>([])
const actionLoadingMap = ref<Record<string, boolean>>({})
const drawerVisible = ref(false)
const drawerLoading = ref(false)
const drawerSaving = ref(false)
const activePlugin = ref<AdminPluginItem | null>(null)
const tabs = computed(() => buildPluginTabs(pluginTypes.value))
const filteredPlugins = computed(() => filterPlugins(plugins.value, keyword.value, statusFilter.value))
const heroStats = computed(() => [
{ label: '插件总数', value: String(plugins.value.length) },
{ label: '已启用', value: String(countEnabledPlugins(plugins.value)) },
{ label: '可升级', value: String(countUpgradeablePlugins(plugins.value)) },
{ label: '用户上传', value: String(countUserPlugins(plugins.value)) },
])
function getActionKey(code: string, action: PluginAction): string {
return `${code}:${action}`
}
async function loadPluginTypes() {
const response = await getPluginTypes()
pluginTypes.value = response.data ?? []
}
async function syncActivePlugin(code?: string, refreshConfig = false) {
const targetCode = code ?? activePlugin.value?.code
if (!targetCode) return
const latest = plugins.value.find((item) => item.code === targetCode)
if (!latest) {
activePlugin.value = null
drawerVisible.value = false
return
}
if (refreshConfig && latest.is_installed && hasPluginConfig(latest)) {
const configResponse = await getPluginConfig(latest.code)
activePlugin.value = {
...latest,
config: configResponse.data as Record<string, AdminPluginConfigField>,
}
return
}
activePlugin.value = latest
}
async function loadPlugins(mode: 'initial' | 'reload' = 'initial') {
if (mode === 'initial') {
loading.value = true
} else {
reloading.value = true
}
errorMessage.value = ''
try {
const response = await getPlugins(typeFilter.value === 'all' ? {} : { type: typeFilter.value })
plugins.value = response.data ?? []
await syncActivePlugin(undefined, drawerVisible.value && Boolean(activePlugin.value?.is_installed))
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '插件列表加载失败'
} finally {
loading.value = false
reloading.value = false
}
}
async function bootstrapPage() {
try {
await loadPluginTypes()
} catch (error) {
ElMessage.warning(error instanceof Error ? error.message : '插件类型加载失败,将回退到默认文案')
}
await loadPlugins()
}
async function openDetail(plugin: AdminPluginItem) {
drawerVisible.value = true
drawerLoading.value = true
activePlugin.value = plugin
try {
if (plugin.is_installed && hasPluginConfig(plugin)) {
const response = await getPluginConfig(plugin.code)
activePlugin.value = {
...plugin,
config: response.data as Record<string, AdminPluginConfigField>,
}
return
}
activePlugin.value = plugin
} catch (error) {
activePlugin.value = plugin
ElMessage.warning(error instanceof Error ? error.message : '插件配置读取失败,已展示列表快照')
} finally {
drawerLoading.value = false
}
}
async function runPluginAction(plugin: AdminPluginItem, action: PluginAction) {
const key = getActionKey(plugin.code, action)
actionLoadingMap.value[key] = true
try {
if (action === 'install') {
await installPlugin(plugin.code)
ElMessage.success(`已安装 ${plugin.name}`)
}
if (action === 'enable') {
await enablePlugin(plugin.code)
ElMessage.success(`已启用 ${plugin.name}`)
}
if (action === 'disable') {
await disablePlugin(plugin.code)
ElMessage.success(`已禁用 ${plugin.name}`)
}
if (action === 'upgrade') {
await upgradePlugin(plugin.code)
ElMessage.success(`已升级 ${plugin.name}`)
}
if (action === 'uninstall') {
await ElMessageBox.confirm(`卸载插件「${plugin.name}」后,将移除其当前安装状态。确认继续吗?`, '卸载插件', {
type: 'warning',
})
await uninstallPlugin(plugin.code)
ElMessage.success(`已卸载 ${plugin.name}`)
}
await loadPlugins('reload')
await syncActivePlugin(plugin.code, drawerVisible.value)
} catch (error) {
if (error === 'cancel' || error === 'close') {
return
}
ElMessage.error(error instanceof Error ? error.message : '插件操作失败')
} finally {
actionLoadingMap.value[key] = false
}
}
async function handleSaveConfig(payload: Record<string, unknown>) {
if (!activePlugin.value) return
drawerSaving.value = true
try {
await savePluginConfig(activePlugin.value.code, payload)
ElMessage.success('插件配置已保存')
await loadPlugins('reload')
await syncActivePlugin(activePlugin.value.code, true)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '插件配置保存失败')
} finally {
drawerSaving.value = false
}
}
async function handleUploadRequest(options: UploadRequestOptions) {
uploadLoading.value = true
try {
await uploadPluginPackage(options.file as File)
options.onSuccess?.({ success: true })
ElMessage.success('插件上传成功')
typeFilter.value = 'all'
await loadPlugins('reload')
} catch (error) {
const message = error instanceof Error ? error.message : '插件上传失败'
options.onError?.(Object.assign(new Error(message), {
status: 500,
method: 'POST',
url: '/plugin/upload',
}) as UploadError)
ElMessage.error(message)
} finally {
uploadLoading.value = false
}
}
watch(typeFilter, () => {
void loadPlugins('reload')
})
onMounted(() => {
void bootstrapPage()
})
</script>
<template>
<div class="plugins-page">
<section class="plugins-hero">
<div class="hero-copy">
<p class="hero-kicker">System Management</p>
<h1>插件管理</h1>
<span>
在同一个工作台里查看插件状态执行安装 / 启停 / 升级动作并补齐 README 与动态配置编辑
</span>
</div>
<div class="hero-stats">
<article v-for="item in heroStats" :key="item.label">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</article>
</div>
</section>
<section class="toolbar-shell">
<div class="toolbar-main">
<ElInput
v-model="keyword"
class="toolbar-search"
clearable
placeholder="搜索插件名称、代号或描述..."
>
<template #prefix>
<ElIcon><Search /></ElIcon>
</template>
</ElInput>
<div class="plugin-tabs">
<button
v-for="item in tabs"
:key="item.value"
type="button"
class="tab-button"
:class="{ active: item.value === typeFilter }"
@click="typeFilter = item.value"
>
{{ item.label }}
</button>
</div>
</div>
<div class="toolbar-actions">
<ElButton :loading="reloading" @click="loadPlugins('reload')">
<ElIcon><RefreshRight /></ElIcon>
刷新列表
</ElButton>
<ElSelect v-model="statusFilter" class="status-select">
<ElOption
v-for="item in PLUGIN_STATUS_FILTER_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
<ElUpload
:show-file-list="false"
accept=".zip,application/zip"
:http-request="handleUploadRequest"
>
<ElButton type="primary" :loading="uploadLoading">
<ElIcon><UploadFilled /></ElIcon>
上传插件
</ElButton>
</ElUpload>
</div>
</section>
<ElAlert
v-if="errorMessage"
type="error"
show-icon
:closable="false"
:title="errorMessage"
class="page-alert"
>
<template #default>
<ElButton size="small" @click="loadPlugins('reload')">重新加载</ElButton>
</template>
</ElAlert>
<section v-if="loading" class="plugin-grid plugin-grid--loading">
<article v-for="index in 3" :key="index" class="plugin-card plugin-card--skeleton">
<ElSkeleton animated :rows="5" />
</article>
</section>
<section v-else-if="filteredPlugins.length" class="plugin-grid">
<PluginCard
v-for="plugin in filteredPlugins"
:key="plugin.code"
:plugin="plugin"
:type-labels="pluginTypes"
:action-loading-map="actionLoadingMap"
@detail="openDetail"
@action="runPluginAction($event.plugin, $event.action)"
/>
</section>
<section v-else class="empty-shell">
<ElEmpty description="当前筛选条件下暂无插件" />
<ElButton @click="statusFilter = 'all'">重置状态筛选</ElButton>
</section>
<PluginDetailDrawer
v-model:visible="drawerVisible"
:plugin="activePlugin"
:loading="drawerLoading"
:saving="drawerSaving"
:type-labels="pluginTypes"
@save-config="handleSaveConfig"
/>
</div>
</template>
<style scoped lang="scss" src="./PluginManagementView.scss"></style>