Feature: add basic multi hosts support

This commit is contained in:
Dreamacro 2023-01-20 14:13:25 +08:00
parent e463b878b4
commit 45818c1273
14 changed files with 106 additions and 54 deletions

View File

@ -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>
)
}

View File

@ -7,6 +7,7 @@
font-size: 14px;
cursor: pointer;
transition: all 150ms ease;
user-select: none;
&:focus {
outline: none;

View File

@ -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 = (

View File

@ -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>
)
}

View File

@ -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 {

View File

@ -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)}

View File

@ -30,6 +30,7 @@
background: $color-white;
padding: 0;
transition: all 200ms ease;
border-radius: 4px;
> .option {
color: $color-primary-darken;

View File

@ -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>

View File

@ -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}
>

View File

@ -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>

View File

@ -37,6 +37,9 @@ const EN = {
host: 'Host',
port: 'Port',
secret: 'Secret',
addText: 'Add',
deleteText: 'Delete',
deleteErrorText: 'Host not found',
},
},
Logs: {

View File

@ -37,6 +37,9 @@ const CN = {
host: 'Host',
port: '端口',
secret: '密钥',
addText: '添 加',
deleteText: '删 除',
deleteErrorText: '没有找到该 Host',
},
},
Logs: {

View File

@ -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)

View File

@ -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 }