feat: implement custom WebUI support with dynamic frontend replacement

- Add WebUI service and controller for managing custom frontend packages

- Allow overriding default static assets when custom WebUI is activated

- Add Make commands for packing custom WebUI distributions

- Update UI settings to support uploading, activating and deleting WebUIs

- Document WebUI feature in README and changelog
This commit is contained in:
duorameng
2026-05-29 11:49:01 +08:00
parent 41af3cf9e0
commit 323a58e041
19 changed files with 1004 additions and 35 deletions
+49 -2
View File
@@ -2,13 +2,17 @@
import { ref } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { UploadCloud, ExternalLink } from 'lucide-vue-next'
import PasswordSettings from './PasswordSettings.vue'
import SiteSettings from './SiteSettings.vue'
import SchedulerSettings from './SchedulerSettings.vue'
import BackupSettings from './BackupSettings.vue'
import AboutSettings from './AboutSettings.vue'
import WebUISettings from './WebUISettings.vue'
const activeTab = ref('password')
const webuiRef = ref<any>(null)
</script>
<template>
@@ -19,9 +23,10 @@ const activeTab = ref('password')
</div>
<Tabs v-model="activeTab" class="max-w-2xl">
<TabsList class="w-full grid grid-cols-5 h-auto p-1 bg-muted/50 rounded-lg">
<TabsList class="w-full grid grid-cols-3 sm:grid-cols-6 gap-y-1 sm:gap-y-0 h-auto p-1 bg-muted/50 rounded-lg">
<TabsTrigger value="password" class="text-xs sm:text-sm px-1 sm:px-3 py-1.5 whitespace-nowrap">密码修改</TabsTrigger>
<TabsTrigger value="site" class="text-xs sm:text-sm px-1 sm:px-3 py-1.5 whitespace-nowrap">站点设置</TabsTrigger>
<TabsTrigger value="webui" class="text-xs sm:text-sm px-1 sm:px-3 py-1.5 whitespace-nowrap">前端定制</TabsTrigger>
<TabsTrigger value="scheduler" class="text-xs sm:text-sm px-1 sm:px-3 py-1.5 whitespace-nowrap">调度设置</TabsTrigger>
<TabsTrigger value="backup" class="text-xs sm:text-sm px-1 sm:px-3 py-1.5 whitespace-nowrap">备份恢复</TabsTrigger>
<TabsTrigger value="about" class="text-xs sm:text-sm px-1 sm:px-3 py-1.5 whitespace-nowrap">关于</TabsTrigger>
@@ -43,7 +48,7 @@ const activeTab = ref('password')
<Card>
<CardHeader>
<CardTitle>站点设置</CardTitle>
<CardDescription>配置站点标题图标和系统参数</CardDescription>
<CardDescription>配置站点标题图标和系统常规参数</CardDescription>
</CardHeader>
<CardContent>
<SiteSettings />
@@ -51,6 +56,48 @@ const activeTab = ref('password')
</Card>
</TabsContent>
<TabsContent value="webui" class="mt-6">
<Card>
<CardHeader class="flex flex-row items-start justify-between space-y-0 gap-4">
<div class="space-y-1.5 flex-1 min-w-0">
<CardTitle class="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<span>自定义前端包 (WebUI)</span>
<a href="https://engigu.github.io/baihu-panel/guide/webui" target="_blank" class="flex items-center gap-1 text-xs text-blue-600 hover:underline font-normal shrink-0">
开发文档
<ExternalLink class="w-3 h-3 shrink-0" />
</a>
</CardTitle>
<CardDescription class="line-clamp-2 sm:line-clamp-none">完全接管和替换默认的系统面板界面实现深度定制</CardDescription>
</div>
<div class="shrink-0 mt-0 sm:mt-1">
<Button
@click="webuiRef?.triggerUpload"
:disabled="webuiRef?.uploading"
variant="outline"
size="sm"
class="h-8 px-2 sm:px-3"
title="上传前端资源包"
>
<span v-if="webuiRef?.uploading" class="flex items-center justify-center">
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="hidden sm:inline ml-2">上传中...</span>
</span>
<span v-else class="flex items-center">
<UploadCloud class="w-4 h-4" />
<span class="hidden sm:inline ml-2">上传资源包</span>
</span>
</Button>
</div>
</CardHeader>
<CardContent>
<WebUISettings ref="webuiRef" />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="scheduler" class="mt-6">
<Card>
<CardHeader>
+288
View File
@@ -0,0 +1,288 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { api, type WebUI } from '@/api'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { CheckCircle2, Trash2, MonitorPlay } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
const webuis = ref<WebUI[]>([])
const activeWebUI = ref<string>('default')
const loading = ref(true)
const uploading = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
// Pagination
const currentPage = ref(1)
const pageSize = 6
const totalPages = computed(() => Math.ceil(webuis.value.length / pageSize))
const paginatedWebuis = computed(() => {
const start = (currentPage.value - 1) * pageSize
const end = start + pageSize
return webuis.value.slice(start, end)
})
const loadData = async () => {
loading.value = true
try {
const [listRes, siteRes] = await Promise.all([
api.webui.list(),
api.settings.getSite()
])
webuis.value = listRes
activeWebUI.value = siteRes.active_webui || 'default'
} catch (err: any) {
toast.error('加载前端包列表失败', { description: err.message })
} finally {
loading.value = false
}
}
const handleFileUpload = async (event: Event) => {
const target = event.target as HTMLInputElement
if (!target.files || target.files.length === 0) return
const file = target.files[0]
if (!file) return
const nameLower = file.name.toLowerCase()
if (!nameLower.endsWith('.zip') && !nameLower.endsWith('.tar.gz') && !nameLower.endsWith('.tgz')) {
toast.error('仅支持上传 .zip 或 .tar.gz 格式的前端包')
return
}
uploading.value = true
try {
await api.webui.upload(file)
toast.success('上传成功', { description: '新的前端包已安装' })
await loadData()
} catch (err: any) {
toast.error('上传失败', { description: err.message })
} finally {
uploading.value = false
if (fileInput.value) {
fileInput.value.value = ''
}
}
}
const triggerUpload = () => {
fileInput.value?.click()
}
const activateWebUI = async (name: string) => {
if (name === activeWebUI.value) return
try {
await api.webui.setActive(name)
toast.success('切换成功', { description: '正在重载前端界面...' })
activeWebUI.value = name
// Reload page after a short delay to apply the new UI
setTimeout(() => {
window.location.reload()
}, 1000)
} catch (err: any) {
toast.error('切换失败', { description: err.message })
}
}
const deleteWebUI = async (name: string) => {
if (!confirm(`确定要删除前端包 "${name}" 吗?此操作不可恢复。`)) return
try {
await api.webui.delete(name)
toast.success('删除成功')
// 检查删除后当前页是否为空
if (paginatedWebuis.value.length === 1 && currentPage.value > 1) {
currentPage.value--
}
await loadData()
} catch (err: any) {
toast.error('删除失败', { description: err.message })
}
}
defineExpose({
triggerUpload,
uploading
})
onMounted(() => {
loadData()
})
</script>
<template>
<div class="space-y-4">
<!-- Warning Tip -->
<div class="rounded-md bg-yellow-500/10 border border-yellow-500/20 p-2.5 text-[10px] text-yellow-600 dark:text-yellow-400 leading-relaxed mt-2">
<strong>风险提示</strong>自定义前端可能导致界面无法访问如果不慎应用了错误或不兼容的包导致白屏请进入终端执行 <code class="bg-yellow-500/20 px-1 py-0.5 rounded mx-0.5 font-mono">baihu webui reset</code> 一键恢复默认内置界面
</div>
<!-- Hidden file input for uploading -->
<input
type="file"
ref="fileInput"
class="hidden"
accept=".zip,.tar.gz,.tgz"
@change="handleFileUpload"
/>
<!-- WebUI Table / Custom List Layout -->
<div class="rounded-lg border bg-card overflow-hidden">
<!-- 表头 (仅在大屏显示) -->
<div class="hidden sm:flex items-center gap-4 px-4 py-1.5 border-b bg-muted/20 text-xs text-muted-foreground font-medium">
<span class="w-32 shrink-0 pl-1">名称</span>
<span class="flex-1 min-w-0">描述</span>
<span class="w-20 shrink-0 text-center">版本</span>
<span class="w-20 shrink-0">作者</span>
<span class="w-20 shrink-0 text-center">状态</span>
<span class="w-24 shrink-0 text-right pr-1">操作</span>
</div>
<!-- 列表内容 -->
<div class="divide-y text-sm">
<template v-if="loading">
<!-- Skeleton rows -->
<div v-for="i in 3" :key="i" class="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 px-4 py-3 sm:py-2 hover:bg-muted/30 transition-colors">
<div class="w-full sm:w-32 shrink-0 sm:pl-1 flex justify-between"><div class="h-4 bg-muted rounded w-20 animate-pulse"></div></div>
<div class="w-full sm:flex-1 min-w-0"><div class="h-4 bg-muted rounded w-full animate-pulse"></div></div>
<div class="hidden sm:block w-20 shrink-0"><div class="h-4 bg-muted rounded w-10 mx-auto animate-pulse"></div></div>
<div class="hidden sm:block w-20 shrink-0"><div class="h-4 bg-muted rounded w-12 animate-pulse"></div></div>
<div class="hidden sm:block w-20 shrink-0"><div class="h-4 bg-muted rounded w-12 mx-auto animate-pulse"></div></div>
<div class="w-full sm:w-24 shrink-0 sm:pr-1 flex justify-end"><div class="h-7 bg-muted rounded w-16 animate-pulse"></div></div>
</div>
</template>
<template v-else>
<div v-if="webuis.length === 0" class="text-center py-12 text-muted-foreground text-xs">
暂无前端资源包
</div>
<div v-for="item in paginatedWebuis" :key="item.name"
class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 px-4 py-3 sm:py-1.5 hover:bg-muted/30 transition-colors">
<!-- 名称与移动端状态 -->
<div class="w-full sm:w-32 shrink-0 sm:pl-1 font-medium flex items-center justify-between sm:justify-start gap-2 overflow-hidden">
<div class="flex items-center gap-2 overflow-hidden">
<MonitorPlay class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span class="truncate" :title="item.name">{{ item.name }}</span>
</div>
<!-- 移动端状态展示 -->
<div class="sm:hidden shrink-0 flex items-center">
<Badge v-if="activeWebUI === item.name" variant="default" class="bg-primary/10 text-primary hover:bg-primary/20 border-primary/20 font-normal py-0 h-5 text-[10px]">
<CheckCircle2 class="w-3 h-3 mr-1" /> 使用中
</Badge>
</div>
</div>
<!-- 描述 -->
<div class="w-full sm:flex-1 min-w-0 text-muted-foreground text-xs truncate" :title="item.description || '无描述'">
{{ item.description || '无描述' }}
</div>
<!-- 移动端版本与作者PC端分成两列 -->
<div class="flex items-center justify-between sm:contents mt-1 sm:mt-0 text-xs text-muted-foreground">
<div class="flex items-center gap-4 sm:contents">
<div class="flex items-center gap-1 sm:w-20 shrink-0 sm:justify-center">
<span class="sm:hidden text-muted-foreground/70">版本:</span>
<Badge variant="outline" class="font-mono text-[9px] px-1 py-0 h-4">v{{ item.version || '1.0' }}</Badge>
</div>
<div class="flex items-center gap-1 sm:w-20 shrink-0 truncate" :title="item.author || 'Unknown'">
<span class="sm:hidden text-muted-foreground/70">作者:</span>
<span class="truncate">{{ item.author || 'Unknown' }}</span>
</div>
</div>
<!-- 移动端的操作按钮放在这里与版本同行 -->
<div class="sm:hidden flex items-center gap-2">
<Button
v-if="activeWebUI !== item.name"
variant="outline"
size="sm"
class="h-6 text-[10px] px-2 py-0"
@click="activateWebUI(item.name)"
>
启用
</Button>
<Button
v-if="item.name !== 'default' && activeWebUI !== item.name"
variant="ghost"
size="icon"
class="h-6 w-6 text-destructive hover:text-destructive hover:bg-destructive/10 shrink-0"
@click="deleteWebUI(item.name)"
>
<Trash2 class="w-3 h-3" />
</Button>
</div>
</div>
<!-- PC端状态 -->
<div class="hidden sm:flex w-20 shrink-0 justify-center">
<Badge v-if="activeWebUI === item.name" variant="default" class="bg-primary/10 text-primary hover:bg-primary/20 border-primary/20 font-normal py-0 h-5 text-[10px]">
<CheckCircle2 class="w-3 h-3 mr-1" /> 使用中
</Badge>
<span v-else class="text-muted-foreground text-xs">-</span>
</div>
<!-- PC端操作 -->
<div class="hidden sm:flex w-24 shrink-0 pr-1 justify-end items-center gap-2">
<Button
v-if="activeWebUI !== item.name"
variant="outline"
size="sm"
class="h-7 text-xs px-2 py-0"
@click="activateWebUI(item.name)"
>
启用
</Button>
<Button
v-else
variant="outline"
disabled
size="sm"
class="h-7 text-xs px-2 py-0 opacity-50"
>
已激活
</Button>
<Button
v-if="item.name !== 'default' && activeWebUI !== item.name"
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10 shrink-0"
@click="deleteWebUI(item.name)"
title="删除此包"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
<div v-else class="w-7 shrink-0"></div>
</div>
</div>
</template>
</div>
</div>
<!-- Pagination Controls -->
<div v-if="totalPages > 1" class="flex items-center justify-between pt-2">
<p class="text-xs text-muted-foreground">
{{ webuis.length }} 个前端包
</p>
<div class="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</Button>
<div class="text-xs font-medium">
{{ currentPage }} / {{ totalPages }}
</div>
<Button
variant="outline"
size="sm"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</Button>
</div>
</div>
</div>
</template>