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 { interface ButtonProps extends BaseComponentProps {
type?: 'primary' | 'normal' | 'danger' | 'success' | 'warning' type?: 'primary' | 'normal' | 'danger' | 'success' | 'warning'
onClick?: MouseEventHandler<HTMLButtonElement> onClick?: MouseEventHandler<HTMLButtonElement>
disiabled?: boolean disabled?: boolean
} }
export function Button (props: ButtonProps) { export function Button (props: ButtonProps) {
const { type = 'normal', onClick = noop, children, className, style, disiabled } = props const { type = 'normal', onClick = noop, children, className, style, disabled } = props
const classname = classnames('button', `button-${type}`, className, { 'button-disabled': disiabled }) const classname = classnames('button', `button-${type}`, className, { 'button-disabled': disabled })
return ( return (
<button <button
className={classname} className={classname}
style={style} style={style}
onClick={onClick} onClick={onClick}
disabled={disiabled} disabled={disabled}
>{children}</button> >{children}</button>
) )
} }

View File

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

View File

@ -1,6 +1,7 @@
import classnames from 'classnames' import classnames from 'classnames'
import { useLayoutEffect } from 'react' 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 { Icon } from '@components'
import { noop } from '@lib/helper' import { noop } from '@lib/helper'
@ -89,7 +90,7 @@ export function showMessage (args: ArgsProps) {
onClose, onClose,
} }
render(<Message {...props} />, container) createRoot(container).render(<Message {...props} />)
} }
export const info = ( export const info = (

View File

@ -27,6 +27,9 @@ interface ModalProps extends BaseComponentProps {
// show footer // show footer
footer?: boolean footer?: boolean
// footer extra
footerExtra?: React.ReactNode
// on click ok // on click ok
onOk?: typeof noop onOk?: typeof noop
@ -45,6 +48,7 @@ export function Modal (props: ModalProps) {
bodyClassName, bodyClassName,
bodyStyle, bodyStyle,
className, className,
footerExtra,
style, style,
children, children,
} = props } = props
@ -84,9 +88,12 @@ export function Modal (props: ModalProps) {
>{children}</div> >{children}</div>
{ {
footer && ( footer && (
<div className="footer"> <div className="flex items-center justify-between">
<Button onClick={() => onClose()}>{ t('cancel') }</Button> {footerExtra}
<Button type="primary" onClick={() => onOk()}>{ t('ok') }</Button> <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> </div>
) )
} }

View File

@ -47,18 +47,6 @@ $mobileBigWidth: 480px;
font-size: 14px; font-size: 14px;
color: $color-primary-darken; 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 { .modal-small {

View File

@ -1,5 +1,5 @@
import classnames from 'classnames' 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 { createPortal } from 'react-dom'
import { Icon } from '@components' import { Icon } from '@components'
@ -9,7 +9,7 @@ import { BaseComponentProps } from '@models'
import './style.scss' import './style.scss'
export interface SelectOptions<T extends string | number> { export interface SelectOptions<T extends string | number> {
label: string label: ReactNode
value: T value: T
disabled?: boolean disabled?: boolean
key?: React.Key key?: React.Key
@ -37,13 +37,6 @@ export function Select<T extends string | number> (props: SelectProps<T>) {
const [showDropDownList, setShowDropDownList] = useState(false) const [showDropDownList, setShowDropDownList] = useState(false)
const [dropdownListStyles, setDropdownListStyles] = useState<React.CSSProperties>({}) 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(() => { useLayoutEffect(() => {
const current = portalRef.current const current = portalRef.current
@ -57,6 +50,14 @@ export function Select<T extends string | number> (props: SelectProps<T>) {
if (disabled) { if (disabled) {
return 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) setShowDropDownList(!showDropDownList)
} }
@ -98,7 +99,7 @@ export function Select<T extends string | number> (props: SelectProps<T>) {
ref={targetRef} ref={targetRef}
onClick={handleShowDropList} onClick={handleShowDropList}
> >
{matchChild?.label} <span className="select-none">{matchChild?.label}</span>
<Icon type="triangle-down" /> <Icon type="triangle-down" />
</div> </div>
{createPortal(dropDownList, portalRef.current)} {createPortal(dropDownList, portalRef.current)}

View File

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

View File

@ -302,7 +302,7 @@ export default function Connections () {
</div> </div>
<ConnectionInfo className="mt-3 px-5" connection={drawerState.connection} /> <ConnectionInfo className="mt-3 px-5" connection={drawerState.connection} />
<div className="mt-3 flex justify-end pr-3"> <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> </div>
</Drawer> </Drawer>
</div> </div>

View File

@ -1,11 +1,10 @@
import { useAtom } from 'jotai' import { useAtom, useSetAtom } from 'jotai'
import { useUpdateAtom } from 'jotai/utils'
import { useEffect } from 'react' import { useEffect } from 'react'
import { Modal, Input, Alert } from '@components' import { Modal, Input, Alert, Button, error } from '@components'
import { useObject } from '@lib/hook' import { useObject } from '@lib/hook'
import { useI18n, useAPIInfo, identityAtom } from '@stores' import { useI18n, useAPIInfo, identityAtom } from '@stores'
import { localStorageAtom } from '@stores/request' import { hostSelectIdxStorageAtom, hostsStorageAtom } from '@stores/request'
import './style.scss' import './style.scss'
export default function ExternalController () { export default function ExternalController () {
@ -23,18 +22,50 @@ export default function ExternalController () {
set({ hostname, port, secret }) set({ hostname, port, secret })
}, [hostname, port, secret, set]) }, [hostname, port, secret, set])
const setter = useUpdateAtom(localStorageAtom) const [hosts, setter] = useAtom(hostsStorageAtom)
const [hostSelectIdx, setHostSelectIdx] = useAtom(hostSelectIdxStorageAtom)
function handleOk () { function handleOk () {
const { hostname, port, secret } = value const { hostname, port, secret } = value
setter([{ hostname, port, secret }]) 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 ( return (
<Modal <Modal
className="!w-105 !<sm:w-84"
show={!identity} show={!identity}
title={t('externalControllerSetting.title')} title={t('externalControllerSetting.title')}
bodyClassName="external-controller" bodyClassName="external-controller"
footerExtra={footerExtra}
onClose={() => setIdentity(true)} onClose={() => setIdentity(true)}
onOk={handleOk} onOk={handleOk}
> >

View File

@ -1,13 +1,13 @@
import classnames from 'classnames' import classnames from 'classnames'
import { useUpdateAtom } from 'jotai/utils' import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { capitalize } from 'lodash-es' import { capitalize } from 'lodash-es'
import { useEffect, useMemo } from 'react' 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 { Lang } from '@i18n'
import { useObject } from '@lib/hook' import { useObject } from '@lib/hook'
import { jsBridge } from '@lib/jsBridge' 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' import './style.scss'
const languageOptions: ButtonSelectOptions[] = [{ label: '中文', value: 'zh_CN' }, { label: 'English', value: 'en_US' }] const languageOptions: ButtonSelectOptions[] = [{ label: '中文', value: 'zh_CN' }, { label: 'English', value: 'en_US' }]
@ -16,7 +16,9 @@ export default function Settings () {
const { premium } = useVersion() const { premium } = useVersion()
const { data: clashXData, update: fetchClashXData } = useClashXData() const { data: clashXData, update: fetchClashXData } = useClashXData()
const { general, update: fetchGeneral } = useGeneral() 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 apiInfo = useAPIInfo()
const { translation, setLang, lang } = useI18n() const { translation, setLang, lang } = useI18n()
const { t } = translation('Settings') const { t } = translation('Settings')
@ -95,6 +97,10 @@ export default function Settings () {
return options return options
}, [t, premium]) }, [t, premium])
const controllerOptions = hostsStorage.map(
(h, idx) => ({ value: idx, label: <span className="truncate text-right">{h.hostname}</span> }),
)
return ( return (
<div className="page"> <div className="page">
<Header title={t('title')} /> <Header title={t('title')} />
@ -171,11 +177,19 @@ export default function Settings () {
<div className="flex flex-wrap"> <div className="flex flex-wrap">
<div className="flex w-full items-center justify-between py-3 px-8 md:w-1/2"> <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="label font-bold">{t('labels.externalController')}</span>
<span <div className="flex items-center space-x-2">
className={classnames({ 'modify-btn': !isClashX }, 'external-controller')} <Select
onClick={() => !isClashX && setIdentity(false)}> disabled={hostsStorage.length < 2 && !isClashX}
{`${externalControllerHost}:${externalControllerPort}`} options={controllerOptions}
</span> value={hostSelectIdx}
onSelect={idx => setHostSelectIdx(idx)}
/>
<span
className={classnames({ 'modify-btn': !isClashX }, 'external-controller')}
onClick={() => !isClashX && setIdentity(false)}>
</span>
</div>
</div> </div>
<div className="w-1/2 px-8"></div> <div className="w-1/2 px-8"></div>
</div> </div>

View File

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

View File

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

View File

@ -1,9 +1,9 @@
import { usePreviousDistinct, useSyncedRef } from '@react-hookz/web/esm' import { usePreviousDistinct, useSyncedRef } from '@react-hookz/web/esm'
import { AxiosError } from 'axios' import { AxiosError } from 'axios'
import produce from 'immer' import produce from 'immer'
import { atom, useAtom, useAtomValue } from 'jotai' import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { atomWithImmer } from 'jotai-immer' import { atomWithImmer } from 'jotai-immer'
import { atomWithStorage, useUpdateAtom } from 'jotai/utils' import { atomWithStorage } from 'jotai/utils'
import { get } from 'lodash-es' import { get } from 'lodash-es'
import { ResultAsync } from 'neverthrow' import { ResultAsync } from 'neverthrow'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
@ -52,7 +52,7 @@ export const version = atom({
export function useVersion () { export function useVersion () {
const [data, set] = useAtom(version) const [data, set] = useAtom(version)
const client = useClient() const client = useClient()
const setIdentity = useUpdateAtom(identityAtom) const setIdentity = useSetAtom(identityAtom)
useSWR([client], async function () { useSWR([client], async function () {
const result = await ResultAsync.fromPromise(client.getVersion(), e => e as AxiosError) 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 hostname: string
port: string port: string
secret: string secret: string
}>>('externalControllers', []) }>>('externalControllers', [])
export const hostSelectIdxStorageAtom = atomWithStorage<number>('externalControllerIndex', 0)
export function useAPIInfo () { export function useAPIInfo () {
const clashx = useAtomValue(clashxConfigAtom) const clashx = useAtomValue(clashxConfigAtom)
const location = useLocation() const location = useLocation()
const localStorage = useAtomValue(localStorageAtom) const hostSelectIdxStorage = useAtomValue(hostSelectIdxStorageAtom)
const hostsStorage = useAtomValue(hostsStorageAtom)
if (clashx != null) { if (clashx != null) {
return clashx return clashx
@ -45,9 +47,9 @@ export function useAPIInfo () {
const qs = new URLSearchParams(location.search) const qs = new URLSearchParams(location.search)
const hostname = qs.get('host') ?? localStorage?.[0]?.hostname ?? url?.hostname ?? '127.0.0.1' const hostname = qs.get('host') ?? hostsStorage?.[hostSelectIdxStorage]?.hostname ?? url?.hostname ?? '127.0.0.1'
const port = qs.get('port') ?? localStorage?.[0]?.port ?? url?.port ?? '9090' const port = qs.get('port') ?? hostsStorage?.[hostSelectIdxStorage]?.port ?? url?.port ?? '9090'
const secret = qs.get('secret') ?? localStorage?.[0]?.secret ?? url?.username ?? '' 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) const protocol = qs.get('protocol') ?? hostname === '127.0.0.1' ? 'http:' : (url?.protocol ?? window.location.protocol)
return { hostname, port, secret, protocol } return { hostname, port, secret, protocol }