Feature: replace unstated-next with recoil

This commit is contained in:
Dreamacro 2020-05-30 16:12:17 +08:00
parent 45d1473adc
commit e4da18d01a
26 changed files with 1374 additions and 947 deletions

1373
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -29,71 +29,72 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.8.4", "@babel/cli": "^7.8.4",
"@babel/core": "^7.9.0", "@babel/core": "^7.9.6",
"@babel/preset-env": "^7.9.5", "@babel/preset-env": "^7.9.6",
"@babel/preset-react": "^7.9.4", "@babel/preset-react": "^7.9.4",
"@hot-loader/react-dom": "^16.13.0", "@hot-loader/react-dom": "^16.13.0",
"@types/classnames": "^2.2.10", "@types/classnames": "^2.2.10",
"@types/lodash-es": "^4.17.3", "@types/lodash-es": "^4.17.3",
"@types/node": "^13.13.2", "@types/node": "^14.0.6",
"@types/react": "^16.9.34", "@types/react": "^16.9.35",
"@types/react-dom": "^16.9.6", "@types/react-dom": "^16.9.8",
"@types/react-router-dom": "^5.1.4", "@types/react-router-dom": "^5.1.5",
"@types/react-table": "^7.0.16", "@types/react-table": "^7.0.18",
"@types/react-virtualized-auto-sizer": "^1.0.0", "@types/react-virtualized-auto-sizer": "^1.0.0",
"@types/react-window": "^1.8.2", "@types/react-window": "^1.8.2",
"@types/recoil": "0.0.0",
"@typescript-eslint/eslint-plugin": "^2.29.0", "@typescript-eslint/eslint-plugin": "^2.29.0",
"@typescript-eslint/parser": "^2.29.0", "@typescript-eslint/parser": "^2.29.0",
"autoprefixer": "^9.7.6", "autoprefixer": "^9.8.0",
"awesome-typescript-loader": "^5.2.1", "awesome-typescript-loader": "^5.2.1",
"babel-loader": "^8.1.0", "babel-loader": "^8.1.0",
"babel-preset-minify": "^0.5.1", "babel-preset-minify": "^0.5.1",
"css-loader": "^3.5.3", "css-loader": "^3.5.3",
"eslint": "^6.8.0", "eslint": "^6.8.0",
"eslint-config-standard": "^14.1.1", "eslint-config-standard": "^14.1.1",
"eslint-loader": "^4.0.0", "eslint-loader": "^4.0.2",
"eslint-plugin-import": "^2.20.2", "eslint-plugin-import": "^2.20.2",
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1", "eslint-plugin-promise": "^4.2.1",
"eslint-plugin-react": "^7.19.0", "eslint-plugin-react": "^7.20.0",
"eslint-plugin-standard": "^4.0.1", "eslint-plugin-standard": "^4.0.1",
"file-loader": "^6.0.0", "file-loader": "^6.0.0",
"html-webpack-plugin": "^4.2.0", "html-webpack-plugin": "^4.3.0",
"image-webpack-loader": "^6.0.0", "image-webpack-loader": "^6.0.0",
"mini-css-extract-plugin": "^0.9.0", "mini-css-extract-plugin": "^0.9.0",
"offline-plugin": "^5.0.7", "offline-plugin": "^5.0.7",
"postcss-loader": "^3.0.0", "postcss-loader": "^3.0.0",
"react-hot-loader": "^4.12.20", "react-hot-loader": "^4.12.21",
"sass": "^1.26.5", "sass": "^1.26.7",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"style-loader": "^1.2.0", "style-loader": "^1.2.1",
"stylelint": "^13.3.3", "stylelint": "^13.5.0",
"stylelint-config-standard": "^20.0.0", "stylelint-config-standard": "^20.0.0",
"stylelint-webpack-plugin": "^1.2.3", "stylelint-webpack-plugin": "^1.2.3",
"terser-webpack-plugin": "^2.3.5", "terser-webpack-plugin": "^2.3.5",
"typescript": "^3.8.3", "typescript": "^3.9.3",
"webpack": "^4.43.0", "webpack": "^4.43.0",
"webpack-cli": "^3.3.11", "webpack-cli": "^3.3.11",
"webpack-dev-middleware": "^3.7.2", "webpack-dev-middleware": "^3.7.2",
"webpack-dev-server": "^3.10.3", "webpack-dev-server": "^3.11.0",
"webpack-merge": "^4.2.2", "webpack-merge": "^4.2.2",
"webpack-pwa-manifest": "^4.2.0" "webpack-pwa-manifest": "^4.2.0"
}, },
"dependencies": { "dependencies": {
"axios": "^0.19.2", "axios": "^0.19.2",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"dayjs": "^1.8.25", "dayjs": "^1.8.28",
"eventemitter3": "^4.0.0", "eventemitter3": "^4.0.4",
"immer": "^6.0.3", "immer": "^6.0.9",
"lodash-es": "^4.17.15", "lodash-es": "^4.17.15",
"react": "^16.13.1", "react": "^16.13.1",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
"react-router-dom": "^5.1.2", "react-router-dom": "^5.2.0",
"react-table": "^7.0.4", "react-table": "^7.1.0",
"react-virtualized-auto-sizer": "^1.0.2", "react-virtualized-auto-sizer": "^1.0.2",
"react-window": "^1.8.5", "react-window": "^1.8.5",
"swr": "^0.2.0", "recoil": "0.0.7",
"unstated-next": "^1.1.0", "swr": "^0.2.2",
"use-immer": "^0.4.0" "use-immer": "^0.4.0"
} }
} }

View File

@ -1,5 +1,5 @@
import React, { useState, useRef, useLayoutEffect } from 'react' import React, { useState, useRef, useLayoutEffect } from 'react'
import { containers } from '@stores' import { useI18n } from '@stores'
import { BaseComponentProps } from '@models' import { BaseComponentProps } from '@models'
import { noop } from '@lib/helper' import { noop } from '@lib/helper'
import classnames from 'classnames' import classnames from 'classnames'
@ -8,16 +8,16 @@ import './style.scss'
interface TagsProps extends BaseComponentProps { interface TagsProps extends BaseComponentProps {
data: string[] data: string[]
onClick: (name: string) => void onClick: (name: string) => void
shouldError?: (name: string) => boolean errSet?: Set<string>
select: string select: string
rowHeight: number rowHeight: number
canClick: boolean canClick: boolean
} }
export function Tags (props: TagsProps) { export function Tags (props: TagsProps) {
const { className, data, onClick, select, canClick, shouldError, rowHeight: rawHeight } = props const { className, data, onClick, select, canClick, errSet, rowHeight: rawHeight } = props
const { useTranslation } = containers.useI18n() const { useTranslation } = useI18n()
const { t } = useTranslation('Proxies') const { t } = useTranslation('Proxies')
const [expand, setExpand] = useState(false) const [expand, setExpand] = useState(false)
const [showExtend, setShowExtend] = useState(false) const [showExtend, setShowExtend] = useState(false)
@ -36,7 +36,7 @@ export function Tags (props: TagsProps) {
const tags = data const tags = data
.map(t => { .map(t => {
const tagClass = classnames({ 'tags-selected': select === t, 'can-click': canClick, error: shouldError && shouldError(t) }) const tagClass = classnames({ 'tags-selected': select === t, 'can-click': canClick, error: errSet?.has(t) })
return ( return (
<li className={tagClass} key={t} onClick={() => handleClick(t)}> <li className={tagClass} key={t} onClick={() => handleClick(t)}>
{ t } { t }

View File

@ -2,7 +2,7 @@ import React, { useMemo, useLayoutEffect } from 'react'
import { useBlockLayout, useResizeColumns, useTable } from 'react-table' import { useBlockLayout, useResizeColumns, useTable } from 'react-table'
import classnames from 'classnames' import classnames from 'classnames'
import { Header, Card, Checkbox, Modal, Icon } from '@components' import { Header, Card, Checkbox, Modal, Icon } from '@components'
import { containers } from '@stores' import { useI18n } from '@stores'
import * as API from '@lib/request' import * as API from '@lib/request'
import { StreamReader } from '@lib/streamer' import { StreamReader } from '@lib/streamer'
import { useObject, useVisible } from '@lib/hook' import { useObject, useVisible } from '@lib/hook'
@ -62,7 +62,7 @@ function formatSpeed (upload: number, download: number) {
} }
export default function Connections () { export default function Connections () {
const { useTranslation, lang } = containers.useI18n() const { useTranslation, lang } = useI18n()
const t = useMemo(() => useTranslation('Connections').t, [useTranslation]) const t = useMemo(() => useTranslation('Connections').t, [useTranslation])
// total // total

View File

@ -1,14 +1,14 @@
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { useObject } from '@lib/hook' import { useObject } from '@lib/hook'
import { Modal, Input, Row, Col, Alert } from '@components' import { Modal, Input, Row, Col, Alert } from '@components'
import { containers } from '@stores' import { useI18n, useAPIInfo, useIdentity } from '@stores'
import './style.scss' import './style.scss'
export default function ExternalController () { export default function ExternalController () {
const { useTranslation } = containers.useI18n() const { useTranslation } = useI18n()
const { t } = useTranslation('Settings') const { t } = useTranslation('Settings')
const { data: info, update, fetch } = containers.useAPIInfo() const { data: info, update, fetch } = useAPIInfo()
const { unauthorized: { hide, visible } } = containers.useData() const { identity, set: setIdentity } = useIdentity()
const [value, set] = useObject({ const [value, set] = useObject({
hostname: '', hostname: '',
port: '', port: '',
@ -30,10 +30,10 @@ export default function ExternalController () {
return ( return (
<Modal <Modal
show={visible} show={!identity}
title={t('externalControllerSetting.title')} title={t('externalControllerSetting.title')}
bodyClassName="external-controller" bodyClassName="external-controller"
onClose={hide} onClose={() => setIdentity(true)}
onOk={handleOk} onOk={handleOk}
> >
<Alert type="info" inside={true}> <Alert type="info" inside={true}>

View File

@ -1,6 +1,6 @@
import React, { useLayoutEffect, useEffect, useRef, useState } from 'react' import React, { useLayoutEffect, useEffect, useRef, useState } from 'react'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { containers } from '@stores' import { useI18n } from '@stores'
import { Card, Header } from '@components' import { Card, Header } from '@components'
import { getLogsStreamReader } from '@lib/request' import { getLogsStreamReader } from '@lib/request'
import { StreamReader } from '@lib/streamer' import { StreamReader } from '@lib/streamer'
@ -11,7 +11,7 @@ export default function Logs () {
const listRef = useRef<HTMLUListElement>() const listRef = useRef<HTMLUListElement>()
const logsRef = useRef<Log[]>([]) const logsRef = useRef<Log[]>([])
const [logs, setLogs] = useState<Log[]>([]) const [logs, setLogs] = useState<Log[]>([])
const { useTranslation } = containers.useI18n() const { useTranslation } = useI18n()
const { t } = useTranslation('Logs') const { t } = useTranslation('Logs')
useLayoutEffect(() => { useLayoutEffect(() => {

View File

@ -1,5 +1,6 @@
import * as React from 'react' import React, { useMemo } from 'react'
import { containers } from '@stores' import { useRecoilValue } from 'recoil'
import { useProxy, useConfig, proxyMapping } from '@stores'
import { changeProxySelected, Group as IGroup, getConnections, closeConnection } from '@lib/request' import { changeProxySelected, Group as IGroup, getConnections, closeConnection } from '@lib/request'
import { Tags, Tag } from '@components' import { Tags, Tag } from '@components'
import './style.scss' import './style.scss'
@ -9,13 +10,14 @@ interface GroupProps {
} }
export function Group (props: GroupProps) { export function Group (props: GroupProps) {
const { fetch, data: Data } = containers.useData() const { update } = useProxy()
const { data: Config } = containers.useConfig() const proxyMap = useRecoilValue(proxyMapping)
const { data: Config } = useConfig()
const { config } = props const { config } = props
async function handleChangeProxySelected (name: string) { async function handleChangeProxySelected (name: string) {
await changeProxySelected(props.config.name, name) await changeProxySelected(props.config.name, name)
await fetch() await update()
if (Config.breakConnections) { if (Config.breakConnections) {
const list: string[] = [] const list: string[] = []
const snapshot = await getConnections() const snapshot = await getConnections()
@ -31,14 +33,17 @@ export function Group (props: GroupProps) {
} }
} }
function shouldError (name: string) { const errSet = useMemo(() => {
const history = Data.proxyMap.get(name)?.history const set = new Set<string>()
if (history?.length) { for (const proxy of config.all) {
return !history.slice(-1)[0].delay const history = proxyMap.get(proxy)?.history
if (history?.length && history.slice(-1)[0].delay !== 0) {
set.add(proxy)
}
} }
return false return set
} }, [proxyMap])
const canClick = config.type === 'Selector' const canClick = config.type === 'Selector'
return ( return (
@ -52,7 +57,7 @@ export function Group (props: GroupProps) {
className="proxy-group-tags" className="proxy-group-tags"
data={config.all} data={config.all}
onClick={handleChangeProxySelected} onClick={handleChangeProxySelected}
shouldError={shouldError} errSet={errSet}
select={config.now} select={config.now}
canClick={canClick} canClick={canClick}
rowHeight={30} /> rowHeight={30} />

View File

@ -1,7 +1,7 @@
import * as React from 'react' import * as React from 'react'
import { useMemo } from 'react' import { useMemo } from 'react'
import { Card, Tag, Icon, Loading } from '@components' import { Card, Tag, Icon, Loading } from '@components'
import { containers } from '@stores' import { useI18n, useProxyProviders } from '@stores'
import { fromNow } from '@lib/date' import { fromNow } from '@lib/date'
import { Provider as IProvider, Proxy as IProxy, updateProvider, healthCheckProvider } from '@lib/request' import { Provider as IProvider, Proxy as IProxy, updateProvider, healthCheckProvider } from '@lib/request'
import { useVisible } from '@lib/hook' import { useVisible } from '@lib/hook'
@ -14,22 +14,22 @@ interface ProvidersProps {
} }
export function Provider (props: ProvidersProps) { export function Provider (props: ProvidersProps) {
const { fetch } = containers.useData() const { update } = useProxyProviders()
const { useTranslation, lang } = containers.useI18n() const { useTranslation, lang } = useI18n()
const { provider } = props
const { provider } = props
const { t } = useTranslation('Proxies') const { t } = useTranslation('Proxies')
const { visible, hide, show } = useVisible() const { visible, hide, show } = useVisible()
function handleHealthChech () { function handleHealthChech () {
show() show()
healthCheckProvider(provider.name).then(() => fetch()).finally(() => hide()) healthCheckProvider(provider.name).then(() => update()).finally(() => hide())
} }
function handleUpdate () { function handleUpdate () {
show() show()
updateProvider(provider.name).then(() => fetch()).finally(() => hide()) updateProvider(provider.name).then(() => update()).finally(() => hide())
} }
const proxies = useMemo(() => { const proxies = useMemo(() => {

View File

@ -1,7 +1,7 @@
import React, { useState, useMemo, useLayoutEffect, useEffect } from 'react' import React, { useMemo, useLayoutEffect } from 'react'
import classnames from 'classnames' import classnames from 'classnames'
import { BaseComponentProps } from '@models' import { BaseComponentProps } from '@models'
import { containers } from '@stores' import { useProxy } from '@stores'
import { getProxyDelay, Proxy as IProxy } from '@lib/request' import { getProxyDelay, Proxy as IProxy } from '@lib/request'
import EE, { Action } from '@lib/event' import EE, { Action } from '@lib/event'
import { isClashX, jsBridge } from '@lib/jsBridge' import { isClashX, jsBridge } from '@lib/jsBridge'
@ -14,8 +14,8 @@ interface ProxyProps extends BaseComponentProps {
const TagColors = { const TagColors = {
'#909399': 0, '#909399': 0,
'#00c520': 150, '#00c520': 260,
'#ff9a28': 500, '#ff9a28': 600,
'#ff3e5e': Infinity '#ff3e5e': Infinity
} }
@ -31,20 +31,24 @@ async function getDelay (name: string) {
export function Proxy (props: ProxyProps) { export function Proxy (props: ProxyProps) {
const { config, className } = props const { config, className } = props
const [delay, setDelay] = useState(0) const { set } = useProxy()
const { updateDelay } = containers.useData()
async function speedTest () { async function speedTest () {
const [delay, err] = await to(getDelay(config.name)) const [delay, err] = await to(getDelay(config.name))
const validDelay = err ? 0 : delay const validDelay = err ? 0 : delay
setDelay(validDelay) set(draft => {
updateDelay(config.name, validDelay) const proxy = draft.proxies.find(p => p.name === proxy)
if (proxy) {
proxy.history.push({ time: Date.now().toString(), delay: validDelay })
}
})
} }
useEffect(() => { const delay = useMemo(
setDelay(config.history && config.history.length ? config.history.slice(-1)[0].delay : 0) () => config.history?.length ? config.history.slice(-1)[0].delay : 0,
}, [config]) [config]
)
useLayoutEffect(() => { useLayoutEffect(() => {
EE.subscribe(Action.SPEED_NOTIFY, speedTest) EE.subscribe(Action.SPEED_NOTIFY, speedTest)

View File

@ -3,7 +3,7 @@ import useSWR from 'swr'
import EE from '@lib/event' import EE from '@lib/event'
import { useRound } from '@lib/hook' import { useRound } from '@lib/hook'
import { Card, Header, Icon, Checkbox } from '@components' import { Card, Header, Icon, Checkbox } from '@components'
import { containers } from '@stores' import { useI18n, useConfig, useProxy, useProxyProviders } from '@stores'
import * as API from '@lib/request' import * as API from '@lib/request'
import { Proxy, Group, Provider } from './components' import { Proxy, Group, Provider } from './components'
@ -27,12 +27,68 @@ export function compareDesc (a: API.Proxy, b: API.Proxy) {
return (lastDelayB || Number.MAX_SAFE_INTEGER) - (lastDelayA || Number.MAX_SAFE_INTEGER) return (lastDelayB || Number.MAX_SAFE_INTEGER) - (lastDelayA || Number.MAX_SAFE_INTEGER)
} }
export default function Proxies () { function ProxyGroups () {
const { data, fetch } = containers.useData() const { groups } = useProxy()
const { useTranslation } = containers.useI18n() const { data: config, set: setConfig } = useConfig()
const { data: config, set: setConfig } = containers.useConfig() const { useTranslation } = useI18n()
const { t } = useTranslation('Proxies')
return <>
{
groups.length !== 0 &&
<div className="proxies-container">
<Header title={t('groupTitle')}>
<Checkbox
className="connections-filter"
checked={config.breakConnections}
onChange={value => setConfig('breakConnections', value)}>
{t('breakConnectionsText')}
</Checkbox>
</Header>
<Card className="proxies-group-card">
<ul className="proxies-group-list">
{
groups.map(p => (
<li className="proxies-group-item" key={p.name}>
<Group config={p} />
</li>
))
}
</ul>
</Card>
</div>
}
</>
}
function ProxyProviders () {
const { providers } = useProxyProviders()
const { useTranslation } = useI18n()
const { t } = useTranslation('Proxies')
return <>
{
providers.length !== 0 &&
<div className="proxies-container">
<Header title={t('providerTitle')} />
<ul className="proxies-providers-list">
{
providers.map(p => (
<li className="proxies-providers-item" key={p.name}>
<Provider provider={p} />
</li>
))
}
</ul>
</div>
}
</>
}
function Proxies () {
const { proxies } = useProxy()
const { useTranslation } = useI18n()
const { t } = useTranslation('Proxies') const { t } = useTranslation('Proxies')
useSWR('data', fetch)
function handleNotitySpeedTest () { function handleNotitySpeedTest () {
EE.notifySpeedTest() EE.notifySpeedTest()
@ -41,78 +97,53 @@ export default function Proxies () {
const { current: sort, next } = useRound( const { current: sort, next } = useRound(
[sortType.Asc, sortType.Desc, sortType.None] [sortType.Asc, sortType.Desc, sortType.None]
) )
const proxies = useMemo(() => { const sortedProxies = useMemo(() => {
switch (sort) { switch (sort) {
case sortType.Desc: case sortType.Desc:
return data.proxy.slice().sort((a, b) => compareDesc(a, b)) return proxies.slice().sort((a, b) => compareDesc(a, b))
case sortType.Asc: case sortType.Asc:
return data.proxy.slice().sort((a, b) => -1 * compareDesc(a, b)) return proxies.slice().sort((a, b) => -1 * compareDesc(a, b))
default: default:
return data.proxy.slice() return proxies.slice()
} }
}, [sort, data]) }, [sort, proxies])
const handleSort = next const handleSort = next
return <>
{
sortedProxies.length !== 0 &&
<div className="proxies-container">
<Header title={t('title')}>
<Icon className="proxies-action-icon" type={sortMap[sort]} onClick={handleSort} size={20} />
<Icon className="proxies-action-icon" type="speed" size={20} />
<span className="proxies-speed-test" onClick={handleNotitySpeedTest}>{t('speedTestText')}</span>
</Header>
<ul className="proxies-list">
{
sortedProxies.map(p => (
<li key={p.name}>
<Proxy config={p} />
</li>
))
}
</ul>
</div>
}
</>
}
export default function ProxyContainer () {
const { update: updateProxy } = useProxy()
const { update: updateProvider } = useProxyProviders()
useSWR('proxies', updateProxy)
useSWR('providers', updateProvider)
return ( return (
<div className="page"> <div className="page">
{ <ProxyGroups />
data.proxyGroup.length !== 0 && <ProxyProviders />
<div className="proxies-container"> <Proxies />
<Header title={t('groupTitle')}>
<Checkbox
className="connections-filter"
checked={config.breakConnections}
onChange={value => setConfig('breakConnections', value)}>
{t('breakConnectionsText')}
</Checkbox>
</Header>
<Card className="proxies-group-card">
<ul className="proxies-group-list">
{
data.proxyGroup.map(p => (
<li className="proxies-group-item" key={p.name}>
<Group config={p} />
</li>
))
}
</ul>
</Card>
</div>
}
{
data.proxyProviders.length !== 0 &&
<div className="proxies-container">
<Header title={t('providerTitle')} />
<ul className="proxies-providers-list">
{
data.proxyProviders.map(p => (
<li className="proxies-providers-item" key={p.name}>
<Provider provider={p} />
</li>
))
}
</ul>
</div>
}
{
proxies.length !== 0 &&
<div className="proxies-container">
<Header title={t('title')}>
<Icon className="proxies-action-icon" type={sortMap[sort]} onClick={handleSort} size={20} />
<Icon className="proxies-action-icon" type="speed" size={20} />
<span className="proxies-speed-test" onClick={handleNotitySpeedTest}>{t('speedTestText')}</span>
</Header>
<ul className="proxies-list">
{
proxies.map(p => (
<li key={p.name}>
<Proxy config={p} />
</li>
))
}
</ul>
</div>
}
</div> </div>
) )
} }

View File

@ -1,19 +1,17 @@
import React, { useEffect } from 'react' import React from 'react'
import { Header, Card, Row, Col } from '@components' import { Header, Card, Row, Col } from '@components'
import { containers } from '@stores' import { useI18n, useRule } from '@stores'
import { FixedSizeList as List } from 'react-window' import { FixedSizeList as List } from 'react-window'
import AutoSizer from 'react-virtualized-auto-sizer' import AutoSizer from 'react-virtualized-auto-sizer'
import useSWR from 'swr'
import './style.scss' import './style.scss'
export default function Rules () { export default function Rules () {
const { data, fetch } = containers.useData() const { rules, update } = useRule()
const { useTranslation } = containers.useI18n() const { useTranslation } = useI18n()
const { t } = useTranslation('Rules') const { t } = useTranslation('Rules')
const { rules } = data
useEffect(() => { useSWR('rules', update)
fetch()
}, [])
function renderRuleItem ({ index, style }: { index: number, style: React.CSSProperties }) { function renderRuleItem ({ index, style }: { index: number, style: React.CSSProperties }) {
const rule = rules[index] const rule = rules[index]

View File

@ -1,9 +1,8 @@
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { Header, Card, Row, Col, Switch, ButtonSelect, ButtonSelectOptions, Input, Icon } from '@components' import { Header, Card, Row, Col, Switch, ButtonSelect, ButtonSelectOptions, Input, Icon } from '@components'
import { containers } from '@stores' import { useI18n, useClashXData, useAPIInfo, useGeneral, useIdentity } from '@stores'
import { updateConfig } from '@lib/request' import { updateConfig } from '@lib/request'
import { useObject } from '@lib/hook' import { useObject } from '@lib/hook'
import { to } from '@lib/helper'
import { isClashX, jsBridge } from '@lib/jsBridge' import { isClashX, jsBridge } from '@lib/jsBridge'
import { Lang } from '@i18n' import { Lang } from '@i18n'
import './style.scss' import './style.scss'
@ -11,10 +10,11 @@ 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' }]
export default function Settings () { export default function Settings () {
const { data: clashXData, fetch: fetchClashXData } = containers.useClashXData() const { data: clashXData, update: fetchClashXData } = useClashXData()
const { data, fetch, unauthorized: { show } } = containers.useData() const { general, update: fetchGeneral } = useGeneral()
const { data: apiInfo } = containers.useAPIInfo() const { set: setIdentity } = useIdentity()
const { useTranslation, setLang, lang } = containers.useI18n() const { data: apiInfo } = useAPIInfo()
const { useTranslation, setLang, lang } = useI18n()
const { t } = useTranslation('Settings') const { t } = useTranslation('Settings')
const [info, set] = useObject({ const [info, set] = useObject({
socks5ProxyPort: 7891, socks5ProxyPort: 7891,
@ -23,22 +23,20 @@ export default function Settings () {
}) })
useEffect(() => { useEffect(() => {
fetch() fetchGeneral()
if (isClashX()) { if (isClashX()) {
fetchClashXData().then(() => set('isClashX', true)) fetchClashXData().then(() => set('isClashX', true))
} }
}, []) }, [])
useEffect(() => { useEffect(() => {
set('socks5ProxyPort', data.general.socksPort) set('socks5ProxyPort', general.socksPort)
set('httpProxyPort', data.general.port) set('httpProxyPort', general.port)
}, [data]) }, [general])
async function handleProxyModeChange (mode: string) { async function handleProxyModeChange (mode: string) {
const [, err] = await to(updateConfig({ mode })) await updateConfig({ mode })
if (!err) { await fetchGeneral()
fetch()
}
} }
async function handleStartAtLoginChange (state: boolean) { async function handleStartAtLoginChange (state: boolean) {
@ -56,26 +54,18 @@ export default function Settings () {
} }
async function handleHttpPortSave () { async function handleHttpPortSave () {
const [, err] = await to(updateConfig({ port: info.httpProxyPort })) await updateConfig({ port: info.httpProxyPort })
if (!err) { await fetchGeneral()
await fetch()
set('httpProxyPort', data.general.port)
}
} }
async function handleSocksPortSave () { async function handleSocksPortSave () {
const [, err] = await to(updateConfig({ 'socks-port': info.socks5ProxyPort })) await updateConfig({ 'socks-port': info.socks5ProxyPort })
if (!err) { await fetchGeneral()
await fetch()
set('socks5ProxyPort', data.general.socksPort)
}
} }
async function handleAllowLanChange (state: boolean) { async function handleAllowLanChange (state: boolean) {
const [, err] = await to(updateConfig({ 'allow-lan': state })) await updateConfig({ 'allow-lan': state })
if (!err) { await fetchGeneral()
await fetch()
}
} }
const { const {
@ -83,7 +73,7 @@ export default function Settings () {
port: externalControllerPort port: externalControllerPort
} = apiInfo } = apiInfo
const { allowLan, mode } = data.general const { allowLan, mode } = general
const { const {
startAtLogin, startAtLogin,
systemProxy systemProxy
@ -189,7 +179,7 @@ export default function Settings () {
<span className="label">{t('labels.externalController')}</span> <span className="label">{t('labels.externalController')}</span>
</Col> </Col>
<Col className="external-controller" span={10}> <Col className="external-controller" span={10}>
<span className="modify-btn" onClick={show}> <span className="modify-btn" onClick={() => setIdentity(false)}>
{`${externalControllerHost}:${externalControllerPort}`} {`${externalControllerHost}:${externalControllerPort}`}
</span> </span>
</Col> </Col>

View File

@ -1,10 +1,11 @@
import * as React from 'react' import * as React from 'react'
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import classnames from 'classnames' import classnames from 'classnames'
import { containers } from '@stores' import { useI18n, useVersion } from '@stores'
import './style.scss' import './style.scss'
import logo from '@assets/logo.png' import logo from '@assets/logo.png'
import useSWR from 'swr'
interface SidebarProps { interface SidebarProps {
routes: { routes: {
@ -17,9 +18,12 @@ interface SidebarProps {
export default function Sidebar (props: SidebarProps) { export default function Sidebar (props: SidebarProps) {
const { routes } = props const { routes } = props
const { useTranslation } = containers.useI18n() const { useTranslation } = useI18n()
const { version, premium, update } = useVersion()
const { t } = useTranslation('SideBar') const { t } = useTranslation('SideBar')
useSWR('version', update)
const navlinks = routes.map( const navlinks = routes.map(
({ path, name, exact, noMobile }) => ( ({ path, name, exact, noMobile }) => (
<li className={classnames('item', { 'no-mobile': noMobile })} key={name}> <li className={classnames('item', { 'no-mobile': noMobile })} key={name}>
@ -34,6 +38,11 @@ export default function Sidebar (props: SidebarProps) {
<ul className="sidebar-menu"> <ul className="sidebar-menu">
{ navlinks } { navlinks }
</ul> </ul>
<div className="sidebar-version">
<span className="sidebar-version-label">Clash { t('Version') }</span>
<span className="sidebar-version-text">{ version }</span>
{ premium && <span className="sidebar-version-label">Premium</span> }
</div>
</div> </div>
) )
} }

View File

@ -3,6 +3,7 @@
.sidebar { .sidebar {
position: fixed; position: fixed;
top: 0; top: 0;
bottom: 0;
left: 0; left: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -21,6 +22,7 @@
.sidebar-menu { .sidebar-menu {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1;
margin-top: 12px; margin-top: 12px;
.item { .item {
@ -52,6 +54,26 @@
} }
} }
.sidebar-version {
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 20px;
}
.sidebar-version-label {
font-size: 14px;
color: $color-primary-dark;
text-shadow: 0 2px 6px rgba($color: $color-primary-dark, $alpha: 0.4);
}
.sidebar-version-text {
text-align: center;
font-size: 14px;
margin: 8px 0;
color: $color-primary-darken;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { .sidebar {
width: 100%; width: 100%;

View File

@ -5,7 +5,8 @@ export default {
Logs: 'Logs', Logs: 'Logs',
Rules: 'Rules', Rules: 'Rules',
Settings: 'Setting', Settings: 'Setting',
Connections: 'Connections' Connections: 'Connections',
Version: 'Version'
}, },
Settings: { Settings: {
title: 'Settings', title: 'Settings',

View File

@ -1,13 +1,11 @@
/* eslint-disable camelcase */ /* eslint-disable camelcase */
/* eslint-disable @typescript-eslint/camelcase */ /* eslint-disable @typescript-eslint/camelcase */
import { useState, useCallback } from 'react'
import get from 'lodash/get'
import { getLocalStorageItem, setLocalStorageItem } from '@lib/helper' import { getLocalStorageItem, setLocalStorageItem } from '@lib/helper'
import en_US from './en_US' import en_US from './en_US'
import zh_CN from './zh_CN' import zh_CN from './zh_CN'
const Language = { export const Language = {
en_US, en_US,
zh_CN zh_CN
} }
@ -16,7 +14,7 @@ export type Lang = keyof typeof Language
const languageKey = 'language' const languageKey = 'language'
const locales = Object.keys(Language) export const locales = Object.keys(Language)
function getNavigatorLanguage (): string[] { function getNavigatorLanguage (): string[] {
const found: string[] = [] const found: string[] = []
@ -34,7 +32,7 @@ function getNavigatorLanguage (): string[] {
return found return found
} }
function getLanguage (): Lang { export function getLanguage (): Lang {
const localLanguage = getLocalStorageItem(languageKey) const localLanguage = getLocalStorageItem(languageKey)
if (localLanguage && locales.includes(localLanguage)) { if (localLanguage && locales.includes(localLanguage)) {
return localLanguage as Lang return localLanguage as Lang
@ -52,23 +50,6 @@ function getLanguage (): Lang {
return 'en_US' return 'en_US'
} }
export function useI18n () { export function setLanguage (lang: Lang) {
const [lang, set] = useState(getLanguage()) setLocalStorageItem(languageKey, lang)
function setLang (lang: Lang) {
set(lang)
setLocalStorageItem(languageKey, lang)
}
const useTranslation = useCallback(
function (namespace: string) {
function t (path: string) {
return get(Language[lang][namespace], path) as string
}
return { t }
},
[lang]
)
return { lang, locales, setLang, useTranslation }
} }

View File

@ -5,7 +5,8 @@ export default {
Logs: '日志', Logs: '日志',
Rules: '规则', Rules: '规则',
Settings: '设置', Settings: '设置',
Connections: '连接' Connections: '连接',
Version: '版本'
}, },
Settings: { Settings: {
title: '设置', title: '设置',

View File

@ -26,8 +26,6 @@ export async function to <T, E = Error> (promise: Promise<T>): Promise<[T, E]> {
} }
} }
export type Partial<T> = { [P in keyof T]?: T[P] }
export function partition<T> (arr: T[], fn: (T) => boolean): [T[], T[]] { export function partition<T> (arr: T[], fn: (T) => boolean): [T[], T[]] {
const left: T[] = [] const left: T[] = []
const right: T[] = [] const right: T[] = []

View File

@ -1,6 +1,5 @@
import { Draft } from 'immer' import { Draft } from 'immer'
import { useImmer } from 'use-immer' import { useImmer } from 'use-immer'
import { createContainer } from 'unstated-next'
import { useRef, useEffect, useState, useMemo } from 'react' import { useRef, useEffect, useState, useMemo } from 'react'
import { noop } from '@lib/helper' import { noop } from '@lib/helper'
@ -54,26 +53,6 @@ export function useInterval (callback: () => void, delay: number) {
) )
} }
type containerFn<Value, State = void> = (initialState?: State) => Value
export function composeContainer<T, C extends containerFn<T>, U extends { [key: string]: C }, K extends keyof U> (mapping: U) {
function Global () {
return Object.keys(mapping).reduce((obj, key) => {
obj[key as K] = mapping[key]()
return obj
}, {} as { [K in keyof U]: T })
}
const allContainer = createContainer(Global)
return {
Provider: allContainer.Provider,
containers: Object.keys(mapping).reduce((obj, key) => {
obj[key as K] = (() => allContainer.useContainer()[key]) as U[K]
return obj
}, {} as { [K in keyof U]: U[K] })
}
}
export function useRound<T> (list: T[], defidx = 0) { export function useRound<T> (list: T[], defidx = 0) {
if (list.length < 2) { if (list.length < 2) {
throw new Error('List requires at least two elements') throw new Error('List requires at least two elements')

33
src/lib/recoil.ts Normal file
View File

@ -0,0 +1,33 @@
import { useRecoilState, RecoilState } from 'recoil'
import produce, { Draft } from 'immer'
import { useCallback } from 'react'
export function useRecoilObjectWithImmer<T> (value: RecoilState<T>) {
const [copy, rawSet] = useRecoilState(value)
function set<K extends keyof Draft<T>> (key: K, value: Draft<T>[K]): void
function set<K extends keyof Draft<T>> (data: Partial<T>): void
function set<K extends keyof Draft<T>> (f: (draft: Draft<T>) => void | T): void
function set<K extends keyof Draft<T>> (data: any, value?: Draft<T>[K]): void {
if (typeof data === 'string') {
rawSet(produce(copy, (draft: Draft<T>) => {
const key = data as K
const v = value
draft[key] = v
}))
} else if (typeof data === 'function') {
const fn = data as (draft: Draft<T>) => void | T
rawSet(produce(copy, fn) as T)
} else if (typeof data === 'object') {
rawSet(produce(copy, (draft: Draft<T>) => {
const obj = data as Draft<T>
for (const key of Object.keys(obj)) {
const k = key as keyof Draft<T>
draft[k] = obj[k]
}
}))
}
}
return [copy, useCallback(set, [copy])] as [T, typeof set]
}

View File

@ -1,5 +1,5 @@
import axios from 'axios' import axios from 'axios'
import { Partial, getLocalStorageItem, to } from '@lib/helper' import { getLocalStorageItem, to } from '@lib/helper'
import { isClashX, jsBridge } from '@lib/jsBridge' import { isClashX, jsBridge } from '@lib/jsBridge'
import { createAsyncSingleton } from '@lib/asyncSingleton' import { createAsyncSingleton } from '@lib/asyncSingleton'
import { Log } from '@models/Log' import { Log } from '@models/Log'
@ -181,7 +181,7 @@ export async function getProxy (name: string) {
export async function getVersion () { export async function getVersion () {
const req = await getInstance() const req = await getInstance()
return req.get<{ version: string }>('version') return req.get<{ version: string, premium?: boolean }>('version')
} }
export async function getProxyDelay (name: string) { export async function getProxyDelay (name: string) {

View File

@ -64,17 +64,6 @@ export interface Config {
} }
export interface ClashXData {
startAtLogin: boolean
systemProxy: boolean
}
export interface APIInfo {
hostname: string
port: string
secret?: string
}
export interface Data { export interface Data {
version?: string version?: string

View File

@ -1,17 +1,17 @@
import * as React from 'react' import * as React from 'react'
import { render } from 'react-dom' import { render } from 'react-dom'
import { HashRouter } from 'react-router-dom' import { HashRouter } from 'react-router-dom'
import { Provider as Global } from '@stores' import { RecoilRoot } from 'recoil'
import App from '@containers/App' import App from '@containers/App'
export default function renderApp () { export default function renderApp () {
const rootEl = document.getElementById('root') const rootEl = document.getElementById('root')
const AppInstance = ( const AppInstance = (
<Global> <RecoilRoot>
<HashRouter> <HashRouter>
<App /> <App />
</HashRouter> </HashRouter>
</Global> </RecoilRoot>
) )
render(AppInstance, rootEl) render(AppInstance, rootEl)

View File

@ -1,149 +0,0 @@
import * as Models from '@models'
import * as API from '@lib/request'
import { useObject, composeContainer, useVisible } from '@lib/hook'
import { jsBridge } from '@lib/jsBridge'
import { setLocalStorageItem, partition, to } from '@lib/helper'
import { useI18n } from '@i18n'
import { AxiosError } from 'axios'
function useData () {
const [data, set] = useObject<Models.Data>({
version: '',
general: {},
proxy: [],
proxyGroup: [],
proxyProviders: [],
rules: [],
proxyMap: new Map<string, API.Proxy>()
})
const { visible, show, hide } = useVisible()
async function fetch () {
const [resp, err] = await to(Promise.all([API.getConfig(), API.getProxies(), API.getRules(), API.getProxyProviders()]))
const rErr = err as AxiosError
if (rErr && (!rErr.response || rErr.response.status === 401)) {
show()
}
const [{ data: general }, rawProxies, rules, proxyProviders] = resp
set('general', {
port: general.port,
socksPort: general['socks-port'],
redirPort: general['redir-port'],
mode: general.mode,
logLevel: general['log-level'],
allowLan: general['allow-lan']
})
const policyGroup = new Set(['Selector', 'URLTest', 'Fallback', 'LoadBalance'])
const unUsedProxy = new Set(['DIRECT', 'REJECT', 'GLOBAL'])
const proxyList = rawProxies.data.proxies.GLOBAL as API.Group
// fix missing name
proxyList.name = 'GLOBAL'
const proxies = proxyList.all
.filter(key => !unUsedProxy.has(key))
.map(key => ({ ...rawProxies.data.proxies[key], name: key }))
const [proxy, groups] = partition(proxies, proxy => !policyGroup.has(proxy.type))
const providers = Object.keys(proxyProviders.data.providers)
.map<API.Provider>(name => proxyProviders.data.providers[name])
.filter(pd => pd.name !== 'default')
.filter(pd => pd.vehicleType !== 'Compatible')
const proxyMap = new Map<string, API.Proxy>()
for (const p of proxy) {
proxyMap.set(p.name, p as API.Proxy)
}
for (const provider of providers) {
for (const p of provider.proxies) {
proxyMap.set(p.name, p as API.Proxy)
}
}
set({
proxy: proxy as API.Proxy[],
proxyGroup: general.mode === 'Global' ? [proxyList] : groups as API.Group[],
proxyProviders: providers,
rules: rules.data.rules,
proxyMap
})
const [version, vErr] = await to(API.getVersion())
if (vErr) {
return
}
set('version', version.data.version)
}
function updateDelay (proxy: string, delay: number) {
set(draft => {
const p = draft.proxy.find(p => p.name === proxy)
if (p) {
p.history.push({ time: Date.now().toString(), delay })
}
})
}
return { data, fetch, unauthorized: { visible, show, hide }, updateDelay }
}
function useAPIInfo () {
const [data, set] = useObject<Models.APIInfo>({
hostname: '127.0.0.1',
port: '9090',
secret: ''
})
async function fetch () {
const info = await API.getExternalControllerConfig()
set({ ...info })
}
async function update (info: Models.APIInfo) {
const { hostname, port, secret } = info
setLocalStorageItem('externalControllerAddr', hostname)
setLocalStorageItem('externalControllerPort', port)
setLocalStorageItem('secret', secret)
window.location.reload()
}
return { data, fetch, update }
}
function useConfig () {
const [data, set] = useObject({
breakConnections: false
})
return { data, set }
}
function useClashXData () {
const [data, set] = useObject<Models.ClashXData>({
startAtLogin: false,
systemProxy: false
})
async function fetch () {
const startAtLogin = await jsBridge.getStartAtLogin()
const systemProxy = await jsBridge.isSystemProxySet()
set({ startAtLogin, systemProxy })
}
return { data, fetch }
}
const { Provider, containers } = composeContainer({
useData,
useAPIInfo,
useClashXData,
useI18n,
useConfig
})
export { Provider, containers }

View File

@ -1 +1 @@
export * from './HookStore' export * from './recoil'

271
src/stores/recoil.ts Normal file
View File

@ -0,0 +1,271 @@
import { atom, useRecoilState, selector } from 'recoil'
import get from 'lodash/get'
import { useCallback } from 'react'
import { AxiosError } from 'axios'
import { getLanguage, setLanguage, Lang, locales, Language } from '@i18n'
import { useRecoilObjectWithImmer } from '@lib/recoil'
import * as API from '@lib/request'
import { setLocalStorageItem, partition, to } from '@lib/helper'
import { jsBridge } from '@lib/jsBridge'
import * as Models from '@models'
const identity = atom({
key: 'identity',
default: true
})
type AsyncFunction<A, O> = (...args: A[]) => Promise<O>
export function useIdentity () {
const [id, set] = useRecoilState(identity)
function wrapFetcher<A, O> (fn: AsyncFunction<A, O>) {
return async function (...args: A[]) {
const [resp, err] = await to(fn(...args))
const rErr = err as AxiosError
if (rErr && (!rErr.response || rErr.response.status === 401)) {
set(false)
throw err
}
set(true)
return resp
}
}
return { identity: id, wrapFetcher, set }
}
const language = atom({
key: 'i18n',
default: getLanguage()
})
export function useI18n () {
const [lang, set] = useRecoilState(language)
function setLang (lang: Lang) {
set(lang)
setLanguage(lang)
}
const useTranslation = useCallback(
function (namespace: string) {
function t (path: string) {
return get(Language[lang][namespace], path) as string
}
return { t }
},
[lang]
)
return { lang, locales, setLang, useTranslation }
}
export const config = atom({
key: 'config',
default: {
breakConnections: false
}
})
export function useConfig () {
const [data, set] = useRecoilObjectWithImmer(config)
return { data, set }
}
export const proxyProvider = atom({
key: 'proxyProvider',
default: [] as API.Provider[]
})
export function useProxyProviders () {
const [data, set] = useRecoilState(proxyProvider)
async function update () {
const proxyProviders = await API.getProxyProviders()
const providers = Object.keys(proxyProviders.data.providers)
.map<API.Provider>(name => proxyProviders.data.providers[name])
.filter(pd => pd.name !== 'default')
.filter(pd => pd.vehicleType !== 'Compatible')
set(providers)
}
return { providers: data, update }
}
export const general = atom({
key: 'general',
default: {} as Models.Data['general']
})
export function useGeneral () {
const [data, set] = useRecoilState(general)
async function update () {
const resp = await API.getConfig()
const data = resp.data
set({
port: data.port,
socksPort: data['socks-port'],
redirPort: data['redir-port'],
mode: data.mode,
logLevel: data['log-level'],
allowLan: data['allow-lan']
})
}
return { general: data, update }
}
export const proxies = atom({
key: 'proxies',
default: {
proxies: [] as API.Proxy[],
groups: [] as API.Group[],
global: {} as API.Group
}
})
export function useProxy () {
const [data, set] = useRecoilObjectWithImmer(proxies)
async function update () {
const allProxies = await API.getProxies()
const global = allProxies.data.proxies.GLOBAL as API.Group
// fix missing name
global.name = 'GLOBAL'
const policyGroup = new Set(['Selector', 'URLTest', 'Fallback', 'LoadBalance'])
const unUsedProxy = new Set(['DIRECT', 'REJECT', 'GLOBAL'])
const proxies = global.all
.filter(key => !unUsedProxy.has(key))
.map(key => ({ ...allProxies.data.proxies[key], name: key }))
const [proxy, groups] = partition(proxies, proxy => !policyGroup.has(proxy.type))
set({ proxies: proxy as API.Proxy[], groups: groups as API.Group[], global: global })
}
return {
proxies: data.proxies,
groups: data.groups,
global: data.global,
update,
set
}
}
export const proxyMapping = selector({
key: 'proxyMapping',
get: ({ get }) => {
const ps = get(proxies)
const providers = get(proxyProvider)
const proxyMap = new Map<string, API.Proxy>()
for (const p of ps.proxies) {
proxyMap.set(p.name, p as API.Proxy)
}
for (const provider of providers) {
for (const p of provider.proxies) {
proxyMap.set(p.name, p as API.Proxy)
}
}
return proxyMap
}
})
export const version = atom({
key: 'version',
default: {
version: '',
premium: false
}
})
export function useVersion () {
const [data, set] = useRecoilState(version)
const { set: setIdentity } = useIdentity()
async function update () {
const [resp, err] = await to(API.getVersion())
setIdentity(!err)
set(
err
? { version: '', premium: false }
: { version: resp.data.version, premium: !!resp.data.premium }
)
}
return { version: data.version, premium: data.premium, update }
}
export const clashxData = atom({
key: 'clashxData',
default: {
startAtLogin: false,
systemProxy: false
}
})
export function useClashXData () {
const [data, set] = useRecoilState(clashxData)
async function update () {
const startAtLogin = await jsBridge.getStartAtLogin()
const systemProxy = await jsBridge.isSystemProxySet()
set({ startAtLogin, systemProxy })
}
return { data, update }
}
export const apiData = atom({
key: 'apiData',
default: {
hostname: '127.0.0.1',
port: '9090',
secret: ''
}
})
export function useAPIInfo () {
const [data, set] = useRecoilState(apiData)
async function fetch () {
const info = await API.getExternalControllerConfig()
set({ ...info })
}
async function update (info: typeof data) {
const { hostname, port, secret } = info
setLocalStorageItem('externalControllerAddr', hostname)
setLocalStorageItem('externalControllerPort', port)
setLocalStorageItem('secret', secret)
window.location.reload()
}
return { data, fetch, update }
}
export const rules = atom({
key: 'rules',
default: [] as API.Rule[]
})
export function useRule () {
const [data, set] = useRecoilObjectWithImmer(rules)
async function update () {
const resp = await API.getRules()
set(resp.data.rules)
}
return { rules: data, update }
}