feat(admin-frontend): 完成订阅与系统管理真实工作台

补齐订单、优惠券、主题、插件、公告与支付管理页面,
接入对应后台接口、路由入口与工具层类型定义。
同时修复套餐页开关初始化误写问题,避免浏览即触发写操作。

在订阅协议侧为 Stash 导出增加 AnyTLS 版本守卫,
未知版本或低于 3.3.0 时不再导出该协议,并补充回归测试与知识记录。
This commit is contained in:
yinjianm
2026-04-24 16:52:41 +08:00
parent 16203b14f6
commit f7cef30b9c
89 changed files with 11122 additions and 92 deletions
@@ -0,0 +1,367 @@
<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>