mirror of
https://github.com/woodchen-ink/clash-and-dashboard.git
synced 2025-07-18 05:51:56 +08:00
Feature: add basic multi hosts support
This commit is contained in:
parent
e463b878b4
commit
45818c1273
@ -8,19 +8,19 @@ import './style.scss'
|
||||
interface ButtonProps extends BaseComponentProps {
|
||||
type?: 'primary' | 'normal' | 'danger' | 'success' | 'warning'
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>
|
||||
disiabled?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function Button (props: ButtonProps) {
|
||||
const { type = 'normal', onClick = noop, children, className, style, disiabled } = props
|
||||
const classname = classnames('button', `button-${type}`, className, { 'button-disabled': disiabled })
|
||||
const { type = 'normal', onClick = noop, children, className, style, disabled } = props
|
||||
const classname = classnames('button', `button-${type}`, className, { 'button-disabled': disabled })
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classname}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
disabled={disiabled}
|
||||
disabled={disabled}
|
||||
>{children}</button>
|
||||
)
|
||||
}
|
||||
|
@ -7,6 +7,7 @@
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
user-select: none;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import classnames from 'classnames'
|
||||
import { useLayoutEffect } from 'react'
|
||||
import { unmountComponentAtNode, render } from 'react-dom'
|
||||
import { unmountComponentAtNode } from 'react-dom'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import { Icon } from '@components'
|
||||
import { noop } from '@lib/helper'
|
||||
@ -89,7 +90,7 @@ export function showMessage (args: ArgsProps) {
|
||||
onClose,
|
||||
}
|
||||
|
||||
render(<Message {...props} />, container)
|
||||
createRoot(container).render(<Message {...props} />)
|
||||
}
|
||||
|
||||
export const info = (
|
||||
|
@ -27,6 +27,9 @@ interface ModalProps extends BaseComponentProps {
|
||||
// show footer
|
||||
footer?: boolean
|
||||
|
||||
// footer extra
|
||||
footerExtra?: React.ReactNode
|
||||
|
||||
// on click ok
|
||||
onOk?: typeof noop
|
||||
|
||||
@ -45,6 +48,7 @@ export function Modal (props: ModalProps) {
|
||||
bodyClassName,
|
||||
bodyStyle,
|
||||
className,
|
||||
footerExtra,
|
||||
style,
|
||||
children,
|
||||
} = props
|
||||
@ -84,9 +88,12 @@ export function Modal (props: ModalProps) {
|
||||
>{children}</div>
|
||||
{
|
||||
footer && (
|
||||
<div className="footer">
|
||||
<Button onClick={() => onClose()}>{ t('cancel') }</Button>
|
||||
<Button type="primary" onClick={() => onOk()}>{ t('ok') }</Button>
|
||||
<div className="flex items-center justify-between">
|
||||
{footerExtra}
|
||||
<div className="flex flex-1 justify-end space-x-3">
|
||||
<Button onClick={() => onClose()}>{ t('cancel') }</Button>
|
||||
<Button type="primary" onClick={() => onOk()}>{ t('ok') }</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -47,18 +47,6 @@ $mobileBigWidth: 480px;
|
||||
font-size: 14px;
|
||||
color: $color-primary-darken;
|
||||
}
|
||||
|
||||
.footer {
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
.button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-small {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import classnames from 'classnames'
|
||||
import { useRef, useState, useMemo, useLayoutEffect } from 'react'
|
||||
import { useRef, useState, useMemo, useLayoutEffect, ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { Icon } from '@components'
|
||||
@ -9,7 +9,7 @@ import { BaseComponentProps } from '@models'
|
||||
import './style.scss'
|
||||
|
||||
export interface SelectOptions<T extends string | number> {
|
||||
label: string
|
||||
label: ReactNode
|
||||
value: T
|
||||
disabled?: boolean
|
||||
key?: React.Key
|
||||
@ -37,13 +37,6 @@ export function Select<T extends string | number> (props: SelectProps<T>) {
|
||||
|
||||
const [showDropDownList, setShowDropDownList] = useState(false)
|
||||
const [dropdownListStyles, setDropdownListStyles] = useState<React.CSSProperties>({})
|
||||
useLayoutEffect(() => {
|
||||
const targetRectInfo = targetRef.current!.getBoundingClientRect()
|
||||
setDropdownListStyles({
|
||||
top: Math.floor(targetRectInfo.top + targetRectInfo.height) + 6,
|
||||
left: Math.floor(targetRectInfo.left) - 10,
|
||||
})
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const current = portalRef.current
|
||||
@ -57,6 +50,14 @@ export function Select<T extends string | number> (props: SelectProps<T>) {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!showDropDownList) {
|
||||
const targetRectInfo = targetRef.current!.getBoundingClientRect()
|
||||
setDropdownListStyles({
|
||||
top: Math.floor(targetRectInfo.top + targetRectInfo.height) + 6,
|
||||
left: Math.floor(targetRectInfo.left) - 10,
|
||||
})
|
||||
}
|
||||
setShowDropDownList(!showDropDownList)
|
||||
}
|
||||
|
||||
@ -98,7 +99,7 @@ export function Select<T extends string | number> (props: SelectProps<T>) {
|
||||
ref={targetRef}
|
||||
onClick={handleShowDropList}
|
||||
>
|
||||
{matchChild?.label}
|
||||
<span className="select-none">{matchChild?.label}</span>
|
||||
<Icon type="triangle-down" />
|
||||
</div>
|
||||
{createPortal(dropDownList, portalRef.current)}
|
||||
|
@ -30,6 +30,7 @@
|
||||
background: $color-white;
|
||||
padding: 0;
|
||||
transition: all 200ms ease;
|
||||
border-radius: 4px;
|
||||
|
||||
> .option {
|
||||
color: $color-primary-darken;
|
||||
|
@ -302,7 +302,7 @@ export default function Connections () {
|
||||
</div>
|
||||
<ConnectionInfo className="mt-3 px-5" connection={drawerState.connection} />
|
||||
<div className="mt-3 flex justify-end pr-3">
|
||||
<Button type="danger" disiabled={drawerState.connection.completed} onClick={() => handleConnectionClosed()}>{ t('info.closeConnection') }</Button>
|
||||
<Button type="danger" disabled={drawerState.connection.completed} onClick={() => handleConnectionClosed()}>{ t('info.closeConnection') }</Button>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
|
@ -1,11 +1,10 @@
|
||||
import { useAtom } from 'jotai'
|
||||
import { useUpdateAtom } from 'jotai/utils'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { Modal, Input, Alert } from '@components'
|
||||
import { Modal, Input, Alert, Button, error } from '@components'
|
||||
import { useObject } from '@lib/hook'
|
||||
import { useI18n, useAPIInfo, identityAtom } from '@stores'
|
||||
import { localStorageAtom } from '@stores/request'
|
||||
import { hostSelectIdxStorageAtom, hostsStorageAtom } from '@stores/request'
|
||||
import './style.scss'
|
||||
|
||||
export default function ExternalController () {
|
||||
@ -23,18 +22,50 @@ export default function ExternalController () {
|
||||
set({ hostname, port, secret })
|
||||
}, [hostname, port, secret, set])
|
||||
|
||||
const setter = useUpdateAtom(localStorageAtom)
|
||||
const [hosts, setter] = useAtom(hostsStorageAtom)
|
||||
const [hostSelectIdx, setHostSelectIdx] = useAtom(hostSelectIdxStorageAtom)
|
||||
|
||||
function handleOk () {
|
||||
const { hostname, port, secret } = value
|
||||
setter([{ hostname, port, secret }])
|
||||
}
|
||||
|
||||
function handleAdd () {
|
||||
const { hostname, port, secret } = value
|
||||
const nextHosts = [...hosts, { hostname, port, secret }]
|
||||
setter(nextHosts)
|
||||
setHostSelectIdx(nextHosts.length - 1)
|
||||
}
|
||||
|
||||
function handleDelete () {
|
||||
const { hostname, port } = value
|
||||
const idx = hosts.findIndex(h => h.hostname === hostname && h.port === port)
|
||||
if (idx === -1) {
|
||||
error(t('externalControllerSetting.deleteErrorText'))
|
||||
return
|
||||
}
|
||||
|
||||
const nextHosts = [...hosts.slice(0, idx), ...hosts.slice(idx + 1)]
|
||||
setter(nextHosts)
|
||||
if (hostSelectIdx >= idx) {
|
||||
setHostSelectIdx(0)
|
||||
}
|
||||
}
|
||||
|
||||
const footerExtra = (
|
||||
<div className="space-x-3">
|
||||
<Button type="primary" onClick={() => handleAdd()}>{ t('externalControllerSetting.addText') }</Button>
|
||||
<Button type="danger" disabled={hosts.length < 2} onClick={() => handleDelete()}>{ t('externalControllerSetting.deleteText') }</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="!w-105 !<sm:w-84"
|
||||
show={!identity}
|
||||
title={t('externalControllerSetting.title')}
|
||||
bodyClassName="external-controller"
|
||||
footerExtra={footerExtra}
|
||||
onClose={() => setIdentity(true)}
|
||||
onOk={handleOk}
|
||||
>
|
||||
|
@ -1,13 +1,13 @@
|
||||
import classnames from 'classnames'
|
||||
import { useUpdateAtom } from 'jotai/utils'
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { capitalize } from 'lodash-es'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
import { Header, Card, Switch, ButtonSelect, ButtonSelectOptions, Input } from '@components'
|
||||
import { Header, Card, Switch, ButtonSelect, ButtonSelectOptions, Input, Select } from '@components'
|
||||
import { Lang } from '@i18n'
|
||||
import { useObject } from '@lib/hook'
|
||||
import { jsBridge } from '@lib/jsBridge'
|
||||
import { useI18n, useClashXData, useAPIInfo, useGeneral, useVersion, useClient, identityAtom } from '@stores'
|
||||
import { useI18n, useClashXData, useAPIInfo, useGeneral, useVersion, useClient, identityAtom, hostSelectIdxStorageAtom, hostsStorageAtom } from '@stores'
|
||||
import './style.scss'
|
||||
|
||||
const languageOptions: ButtonSelectOptions[] = [{ label: '中文', value: 'zh_CN' }, { label: 'English', value: 'en_US' }]
|
||||
@ -16,7 +16,9 @@ export default function Settings () {
|
||||
const { premium } = useVersion()
|
||||
const { data: clashXData, update: fetchClashXData } = useClashXData()
|
||||
const { general, update: fetchGeneral } = useGeneral()
|
||||
const setIdentity = useUpdateAtom(identityAtom)
|
||||
const setIdentity = useSetAtom(identityAtom)
|
||||
const [hostSelectIdx, setHostSelectIdx] = useAtom(hostSelectIdxStorageAtom)
|
||||
const hostsStorage = useAtomValue(hostsStorageAtom)
|
||||
const apiInfo = useAPIInfo()
|
||||
const { translation, setLang, lang } = useI18n()
|
||||
const { t } = translation('Settings')
|
||||
@ -95,6 +97,10 @@ export default function Settings () {
|
||||
return options
|
||||
}, [t, premium])
|
||||
|
||||
const controllerOptions = hostsStorage.map(
|
||||
(h, idx) => ({ value: idx, label: <span className="truncate text-right">{h.hostname}</span> }),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<Header title={t('title')} />
|
||||
@ -171,11 +177,19 @@ export default function Settings () {
|
||||
<div className="flex flex-wrap">
|
||||
<div className="flex w-full items-center justify-between py-3 px-8 md:w-1/2">
|
||||
<span className="label font-bold">{t('labels.externalController')}</span>
|
||||
<span
|
||||
className={classnames({ 'modify-btn': !isClashX }, 'external-controller')}
|
||||
onClick={() => !isClashX && setIdentity(false)}>
|
||||
{`${externalControllerHost}:${externalControllerPort}`}
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select
|
||||
disabled={hostsStorage.length < 2 && !isClashX}
|
||||
options={controllerOptions}
|
||||
value={hostSelectIdx}
|
||||
onSelect={idx => setHostSelectIdx(idx)}
|
||||
/>
|
||||
<span
|
||||
className={classnames({ 'modify-btn': !isClashX }, 'external-controller')}
|
||||
onClick={() => !isClashX && setIdentity(false)}>
|
||||
编辑
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/2 px-8"></div>
|
||||
</div>
|
||||
|
@ -37,6 +37,9 @@ const EN = {
|
||||
host: 'Host',
|
||||
port: 'Port',
|
||||
secret: 'Secret',
|
||||
addText: 'Add',
|
||||
deleteText: 'Delete',
|
||||
deleteErrorText: 'Host not found',
|
||||
},
|
||||
},
|
||||
Logs: {
|
||||
|
@ -37,6 +37,9 @@ const CN = {
|
||||
host: 'Host',
|
||||
port: '端口',
|
||||
secret: '密钥',
|
||||
addText: '添 加',
|
||||
deleteText: '删 除',
|
||||
deleteErrorText: '没有找到该 Host',
|
||||
},
|
||||
},
|
||||
Logs: {
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { usePreviousDistinct, useSyncedRef } from '@react-hookz/web/esm'
|
||||
import { AxiosError } from 'axios'
|
||||
import produce from 'immer'
|
||||
import { atom, useAtom, useAtomValue } from 'jotai'
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { atomWithImmer } from 'jotai-immer'
|
||||
import { atomWithStorage, useUpdateAtom } from 'jotai/utils'
|
||||
import { atomWithStorage } from 'jotai/utils'
|
||||
import { get } from 'lodash-es'
|
||||
import { ResultAsync } from 'neverthrow'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
@ -52,7 +52,7 @@ export const version = atom({
|
||||
export function useVersion () {
|
||||
const [data, set] = useAtom(version)
|
||||
const client = useClient()
|
||||
const setIdentity = useUpdateAtom(identityAtom)
|
||||
const setIdentity = useSetAtom(identityAtom)
|
||||
|
||||
useSWR([client], async function () {
|
||||
const result = await ResultAsync.fromPromise(client.getVersion(), e => e as AxiosError)
|
||||
|
@ -19,16 +19,18 @@ const clashxConfigAtom = atom(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
export const localStorageAtom = atomWithStorage<Array<{
|
||||
export const hostsStorageAtom = atomWithStorage<Array<{
|
||||
hostname: string
|
||||
port: string
|
||||
secret: string
|
||||
}>>('externalControllers', [])
|
||||
export const hostSelectIdxStorageAtom = atomWithStorage<number>('externalControllerIndex', 0)
|
||||
|
||||
export function useAPIInfo () {
|
||||
const clashx = useAtomValue(clashxConfigAtom)
|
||||
const location = useLocation()
|
||||
const localStorage = useAtomValue(localStorageAtom)
|
||||
const hostSelectIdxStorage = useAtomValue(hostSelectIdxStorageAtom)
|
||||
const hostsStorage = useAtomValue(hostsStorageAtom)
|
||||
|
||||
if (clashx != null) {
|
||||
return clashx
|
||||
@ -45,9 +47,9 @@ export function useAPIInfo () {
|
||||
|
||||
const qs = new URLSearchParams(location.search)
|
||||
|
||||
const hostname = qs.get('host') ?? localStorage?.[0]?.hostname ?? url?.hostname ?? '127.0.0.1'
|
||||
const port = qs.get('port') ?? localStorage?.[0]?.port ?? url?.port ?? '9090'
|
||||
const secret = qs.get('secret') ?? localStorage?.[0]?.secret ?? url?.username ?? ''
|
||||
const hostname = qs.get('host') ?? hostsStorage?.[hostSelectIdxStorage]?.hostname ?? url?.hostname ?? '127.0.0.1'
|
||||
const port = qs.get('port') ?? hostsStorage?.[hostSelectIdxStorage]?.port ?? url?.port ?? '9090'
|
||||
const secret = qs.get('secret') ?? hostsStorage?.[hostSelectIdxStorage]?.secret ?? url?.username ?? ''
|
||||
const protocol = qs.get('protocol') ?? hostname === '127.0.0.1' ? 'http:' : (url?.protocol ?? window.location.protocol)
|
||||
|
||||
return { hostname, port, secret, protocol }
|
||||
|
Loading…
x
Reference in New Issue
Block a user