refactor: Redesign and migrate WebUI to global settings

This commit is contained in:
Memory 2025-10-03 22:49:41 +08:00 committed by GitHub
parent f34cc976b4
commit a7de9b2588
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 389 additions and 88 deletions

View File

@ -0,0 +1,356 @@
import React, { useState, useEffect, useRef } from 'react'
import SettingCard from '../base/base-setting-card'
import SettingItem from '../base/base-setting-item'
import { Button, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input } from '@heroui/react'
import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config'
import { useTranslation } from 'react-i18next'
import { MdEdit, MdDelete, MdOpenInNew } from 'react-icons/md'
interface WebUIPanel {
id: string
name: string
url: string
isDefault?: boolean
}
// 用于高亮显示URL中的变量
const HighlightedUrl: React.FC<{ url: string }> = ({ url }) => {
const parts = url.split(/(%host|%port|%secret)/g)
return (
<p className="text-sm text-default-500 break-all">
{parts.map((part, index) => {
if (part === '%host' || part === '%port' || part === '%secret') {
return (
<span key={index} className="bg-warning-200 text-warning-800 px-1 rounded">
{part}
</span>
)
}
return part
})}
</p>
)
}
// 可点击的变量标签组件
const ClickableVariableTag: React.FC<{
variable: string;
onClick: (variable: string) => void
}> = ({ variable, onClick }) => {
return (
<span
className="bg-warning-200 text-warning-800 px-1 rounded ml-1 cursor-pointer hover:bg-warning-300"
onClick={() => onClick(variable)}
>
{variable}
</span>
)
}
const WebUIConfig: React.FC = () => {
const { t } = useTranslation()
const { controledMihomoConfig } = useControledMihomoConfig()
const externalController = controledMihomoConfig?.['external-controller'] || ''
const secret = controledMihomoConfig?.secret || ''
// 解析主机和端口
const parseController = () => {
if (externalController) {
const [host, port] = externalController.split(':')
return { host: host.replace('0.0.0.0', '127.0.0.1'), port }
}
return { host: '127.0.0.1', port: '9090' }
}
const { host, port } = parseController()
// 默认WebUI面板选项
const defaultWebUIPanels: WebUIPanel[] = [
{
id: 'metacubexd',
name: 'MetaCubeXD',
url: 'https://metacubex.github.io/metacubexd/#/setup?http=true&hostname=%host&port=%port&secret=%secret',
isDefault: true
},
{
id: 'yacd',
name: 'YACD',
url: 'https://yacd.metacubex.one/?hostname=%host&port=%port&secret=%secret',
isDefault: true
},
{
id: 'zashboard',
name: 'Zashboard',
url: 'https://board.zash.run.place/#/setup?http=true&hostname=%host&port=%port&secret=%secret',
isDefault: true
}
]
const [isModalOpen, setIsModalOpen] = useState(false)
const [allPanels, setAllPanels] = useState<WebUIPanel[]>([])
const [editingPanel, setEditingPanel] = useState<WebUIPanel | null>(null)
const [newPanelName, setNewPanelName] = useState('')
const [newPanelUrl, setNewPanelUrl] = useState('')
const urlInputRef = useRef<HTMLInputElement>(null)
// 初始化面板列表
useEffect(() => {
const savedPanels = localStorage.getItem('webui-panels')
if (savedPanels) {
setAllPanels(JSON.parse(savedPanels))
} else {
setAllPanels(defaultWebUIPanels)
}
}, [])
// 保存面板列表到localStorage
useEffect(() => {
if (allPanels.length > 0) {
localStorage.setItem('webui-panels', JSON.stringify(allPanels))
}
}, [allPanels])
// 在URL输入框光标处插入或替换变量
const insertVariableAtCursor = (variable: string) => {
if (!urlInputRef.current) return
const input = urlInputRef.current
const start = input.selectionStart || 0
const end = input.selectionEnd || 0
const currentValue = newPanelUrl || ''
// 如果有选中文本,则替换选中的文本
const newValue = currentValue.substring(0, start) + variable + currentValue.substring(end)
setNewPanelUrl(newValue)
// 设置光标位置到插入变量之后
setTimeout(() => {
if (urlInputRef.current) {
const newCursorPos = start + variable.length
urlInputRef.current.setSelectionRange(newCursorPos, newCursorPos)
urlInputRef.current.focus()
}
}, 0)
}
// 打开WebUI面板
const openWebUI = (panel: WebUIPanel) => {
const url = panel.url
.replace('%host', host)
.replace('%port', port)
.replace('%secret', secret)
window.open(url, '_blank')
}
// 添加新面板
const addNewPanel = () => {
if (newPanelName && newPanelUrl) {
const newPanel: WebUIPanel = {
id: Date.now().toString(),
name: newPanelName,
url: newPanelUrl
}
setAllPanels([...allPanels, newPanel])
setNewPanelName('')
setNewPanelUrl('')
setEditingPanel(null)
}
}
// 更新面板
const updatePanel = () => {
if (editingPanel && newPanelName && newPanelUrl) {
const updatedPanels = allPanels.map(panel =>
panel.id === editingPanel.id
? { ...panel, name: newPanelName, url: newPanelUrl }
: panel
)
setAllPanels(updatedPanels)
setEditingPanel(null)
setNewPanelName('')
setNewPanelUrl('')
}
}
// 删除面板
const deletePanel = (id: string) => {
setAllPanels(allPanels.filter(panel => panel.id !== id))
}
// 开始编辑面板
const startEditing = (panel: WebUIPanel) => {
setEditingPanel(panel)
setNewPanelName(panel.name)
setNewPanelUrl(panel.url)
}
// 取消编辑
const cancelEditing = () => {
setEditingPanel(null)
setNewPanelName('')
setNewPanelUrl('')
}
// 恢复默认面板
const restoreDefaultPanels = () => {
setAllPanels(defaultWebUIPanels)
}
return (
<SettingCard>
<SettingItem title={t('settings.webui.title')} divider>
<div className="flex gap-2">
<Button
size="sm"
color="primary"
onPress={() => setIsModalOpen(true)}
>
{t('settings.webui.manage')}
</Button>
</div>
</SettingItem>
<SettingItem title={t('settings.webui.currentConfig')}>
<div className="text-sm text-default-500">
<p>{t('settings.webui.host')}: {host}</p>
<p>{t('settings.webui.port')}: {port}</p>
</div>
</SettingItem>
{/* 面板管理模态框 */}
<Modal
isOpen={isModalOpen}
onOpenChange={setIsModalOpen}
size="5xl"
scrollBehavior="inside"
backdrop="blur"
classNames={{ backdrop: 'top-[48px]' }}
hideCloseButton
>
<ModalContent className="h-full w-[calc(100%-100px)]">
<ModalHeader className="flex pb-0 app-drag">
{t('settings.webui.manage')}
</ModalHeader>
<ModalBody className="flex flex-col h-full">
<div className="flex flex-col h-full">
{/* 添加/编辑面板表单 */}
<div className="flex flex-col gap-2 p-3 bg-default-100 rounded-lg flex-shrink-0">
<Input
label={t('settings.webui.panelName')}
placeholder={t('settings.webui.panelNamePlaceholder')}
value={newPanelName}
onValueChange={setNewPanelName}
/>
<Input
ref={urlInputRef}
label={t('settings.webui.panelUrl')}
placeholder={t('settings.webui.panelUrlPlaceholder')}
value={newPanelUrl}
onValueChange={setNewPanelUrl}
/>
<div className="text-xs text-default-500">
{t('settings.webui.variableHint')}:
<ClickableVariableTag variable="%host" onClick={insertVariableAtCursor} />
<ClickableVariableTag variable="%port" onClick={insertVariableAtCursor} />
<ClickableVariableTag variable="%secret" onClick={insertVariableAtCursor} />
</div>
<div className="flex gap-2">
{editingPanel ? (
<>
<Button
size="sm"
color="primary"
onPress={updatePanel}
isDisabled={!newPanelName || !newPanelUrl}
>
{t('common.save')}
</Button>
<Button
size="sm"
color="default"
variant="bordered"
onPress={cancelEditing}
>
{t('common.cancel')}
</Button>
</>
) : (
<Button
size="sm"
color="primary"
onPress={addNewPanel}
isDisabled={!newPanelName || !newPanelUrl}
>
{t('settings.webui.addPanel')}
</Button>
)}
<Button
size="sm"
color="warning"
variant="bordered"
onPress={restoreDefaultPanels}
>
{t('settings.webui.restoreDefaults')}
</Button>
</div>
</div>
{/* 面板列表 */}
<div className="flex flex-col gap-2 mt-2 overflow-y-auto flex-grow">
<h3 className="text-lg font-semibold">{t('settings.webui.panels')}</h3>
{allPanels.map(panel => (
<div key={panel.id} className="flex items-start justify-between p-3 bg-default-50 rounded-lg flex-shrink-0">
<div className="flex-1 mr-2">
<p className="font-medium">{panel.name}</p>
<HighlightedUrl url={panel.url} />
</div>
<div className="flex gap-2">
<Button
isIconOnly
size="sm"
color="primary"
onPress={() => openWebUI(panel)}
>
<MdOpenInNew />
</Button>
<Button
isIconOnly
size="sm"
color="warning"
onPress={() => startEditing(panel)}
>
<MdEdit />
</Button>
<Button
isIconOnly
size="sm"
color="danger"
onPress={() => deletePanel(panel.id)}
>
<MdDelete />
</Button>
</div>
</div>
))}
</div>
</div>
</ModalBody>
<ModalFooter className="pt-0">
<Button
color="primary"
onPress={() => setIsModalOpen(false)}
>
{t('common.close')}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</SettingCard>
)
}
export default WebUIConfig

View File

@ -91,6 +91,21 @@
"settings.links.github": "GitHub Repository",
"settings.links.telegram": "Telegram Group",
"settings.title": "Application Settings",
"settings.webui.title": "WebUI Management Panel",
"settings.webui.manage": "Manage Panels",
"settings.webui.currentConfig": "Current Configuration",
"settings.webui.host": "Host",
"settings.webui.port": "Port",
"settings.webui.secret": "Secret",
"settings.webui.noSecret": "No Secret",
"settings.webui.panelName": "Panel Name",
"settings.webui.panelUrl": "Panel URL",
"settings.webui.panelNamePlaceholder": "Enter panel name",
"settings.webui.panelUrlPlaceholder": "Enter panel URL",
"settings.webui.variableHint": "Available Variables",
"settings.webui.addPanel": "Add Panel",
"settings.webui.panels": "Panel List",
"settings.webui.restoreDefaults": "Restore Defaults",
"mihomo.userAgent": "Subscription User Agent",
"mihomo.userAgentPlaceholder": "Default: mihomo.party/v{{version}} (clash.meta)",
"mihomo.delayTest.url": "Delay Test URL",
@ -405,12 +420,6 @@
"profiles.remote": "Remote",
"profiles.local": "Local",
"profiles.trafficUsage": "Traffic Usage Progress",
"profiles.openWebUI.title": "WebUI Management Panel",
"profiles.openWebUI.description": "Select a WebUI panel to open",
"profiles.openWebUI.local": "Local WebUI",
"profiles.updateWebUI.button": "Update Panel",
"profiles.updateWebUI.success": "WebUI panel updated successfully",
"profiles.updateWebUI.failed": "WebUI panel update failed: {{error}}",
"profiles.editInfo.title": "Edit Information",
"profiles.editInfo.name": "Name",
"profiles.editInfo.url": "Subscription URL",

View File

@ -91,6 +91,21 @@
"settings.links.github": "GitHub 仓库",
"settings.links.telegram": "Telegram 群组",
"settings.title": "应用设置",
"settings.webui.title": "WebUI 管理面板",
"settings.webui.manage": "管理面板",
"settings.webui.currentConfig": "当前配置信息",
"settings.webui.host": "主机",
"settings.webui.port": "端口",
"settings.webui.secret": "密钥",
"settings.webui.noSecret": "无密钥",
"settings.webui.panelName": "面板名称",
"settings.webui.panelUrl": "面板URL",
"settings.webui.panelNamePlaceholder": "输入面板名称",
"settings.webui.panelUrlPlaceholder": "输入面板URL",
"settings.webui.variableHint": "可用变量",
"settings.webui.addPanel": "添加面板",
"settings.webui.panels": "面板列表",
"settings.webui.restoreDefaults": "恢复默认",
"mihomo.title": "内核设置",
"mihomo.restart": "重启内核",
"mihomo.memory": "内存使用",
@ -410,12 +425,6 @@
"profiles.traffic.expired": "已过期",
"profiles.traffic.remainingDays": "剩余 {{days}} 天",
"profiles.traffic.lastUpdate": "最后更新:{{time}}",
"profiles.openWebUI.title": "WebUI 管理面板",
"profiles.openWebUI.description": "选择一个 WebUI 面板打开",
"profiles.openWebUI.local": "本地 WebUI",
"profiles.updateWebUI.button": "更新面板",
"profiles.updateWebUI.success": "WebUI 面板更新成功",
"profiles.updateWebUI.failed": "WebUI 面板更新失败: {{error}}",
"profiles.editInfo.title": "编辑信息",
"profiles.editInfo.name": "名称",
"profiles.editInfo.url": "订阅地址",

View File

@ -7,16 +7,12 @@ import {
DropdownItem,
DropdownMenu,
DropdownTrigger,
Input,
Card,
CardBody,
CardHeader
Input
} from '@heroui/react'
import BasePage from '@renderer/components/base/base-page'
import ProfileItem from '@renderer/components/profiles/profile-item'
import { useProfileConfig } from '@renderer/hooks/use-profile-config'
import { useAppConfig } from '@renderer/hooks/use-app-config'
import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config'
import { getFilePath, readTextFile, subStoreCollections, subStoreSubs } from '@renderer/utils/ipc'
import type { KeyboardEvent } from 'react'
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -36,7 +32,6 @@ import SubStoreIcon from '@renderer/components/base/substore-icon'
import useSWR from 'swr'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { mihomoUpgradeUI } from '@renderer/utils/ipc'
const Profiles: React.FC = () => {
const { t } = useTranslation()
@ -50,9 +45,6 @@ const Profiles: React.FC = () => {
mutateProfileConfig
} = useProfileConfig()
const { appConfig } = useAppConfig()
const { controledMihomoConfig } = useControledMihomoConfig()
const externalController = controledMihomoConfig?.['external-controller'] || ''
const externalUI = (controledMihomoConfig as any)?.['external-ui']
const { useSubStore = true, useCustomSubStore = false, customSubStoreUrl = '' } = appConfig || {}
const { current, items = [] } = profileConfig || {}
const navigate = useNavigate()
@ -203,26 +195,6 @@ const Profiles: React.FC = () => {
setSortedItems(items)
}, [items])
// 获取本地WebUI的URL
const getLocalWebUIUrl = (): string => {
if (externalController) {
// 将地址转换为WebUI URL
// 例如: 127.0.0.1:9090 -> http://127.0.0.1:9090/ui
const controller = externalController.replace('0.0.0.0', '127.0.0.1')
// 如果配置了external-ui使用/ui路径否则可能需要使用不同的路径
const uiPath = externalUI ? '/ui' : '/ui' // 默认使用/ui路径
return `http://${controller}${uiPath}`
}
// 默认URL
return 'http://127.0.0.1:9090/ui'
}
// 检查本地WebUI是否可用
const isLocalWebUIAvailable = (): boolean => {
// 如果有配置的external-controller则认为本地WebUI可用
return !!externalController
}
return (
<BasePage
ref={pageRef}
@ -407,53 +379,6 @@ const Profiles: React.FC = () => {
</div>
<Divider />
</div>
{/* WebUI Card with Multiple Options */}
<div className="m-2">
<Card>
<CardHeader className="flex gap-3">
<div className="flex flex-col">
<p className="text-md">{t('profiles.openWebUI.title')}</p>
<p className="text-small text-default-500">{t('profiles.openWebUI.description')}</p>
</div>
</CardHeader>
<CardBody>
<div className="flex gap-2 flex-wrap">
<Button
onPress={() => window.open('https://metacubexd.pages.dev/', '_blank')}
>
MetaCubeXD
</Button>
<Button
onPress={() => window.open('https://zashboard.pages.dev/', '_blank')}
>
Zashboard
</Button>
{isLocalWebUIAvailable() && (
<>
<Button
onPress={() => window.open(getLocalWebUIUrl(), '_blank')}
>
{t('profiles.openWebUI.local')}
</Button>
<Button
color="success"
onPress={async () => {
try {
await mihomoUpgradeUI()
new Notification(t('profiles.updateWebUI.success'))
} catch (e) {
new Notification(t('profiles.updateWebUI.failed', { error: String(e) }))
}
}}
>
{t('profiles.updateWebUI.button')}
</Button>
</>
)}
</div>
</CardBody>
</Card>
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<div
className={`${fileOver ? 'blur-sm' : ''} grid sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2 m-2`}

View File

@ -10,6 +10,7 @@ import ShortcutConfig from '@renderer/components/settings/shortcut-config'
import { FaTelegramPlane } from 'react-icons/fa'
import SiderConfig from '@renderer/components/settings/sider-config'
import SubStoreConfig from '@renderer/components/settings/substore-config'
import WebUIConfig from '@renderer/components/settings/webui-config'
import { useTranslation } from 'react-i18next'
const Settings: React.FC = () => {
@ -60,6 +61,7 @@ const Settings: React.FC = () => {
}
>
<GeneralConfig />
<WebUIConfig />
<SubStoreConfig />
<SiderConfig />
<WebdavConfig />