├── .all-contributorsrc ├── .editorconfig ├── .eslintrc.yml ├── .github └── workflows │ └── ghpages.yml ├── .gitignore ├── .npmrc ├── .vscode └── settings.json ├── CONTRIBUTION.md ├── LICENSE ├── README.md ├── build ├── Dockerfile ├── nginx.conf └── start.sh ├── index.html ├── package.json ├── pnpm-lock.yaml ├── src ├── assets │ ├── Icon.png │ ├── LOGO-fixing.svg │ └── logo.png ├── components │ ├── Alert │ │ ├── index.tsx │ │ └── style.scss │ ├── Button │ │ ├── index.tsx │ │ └── style.scss │ ├── ButtonSelect │ │ ├── index.tsx │ │ └── style.scss │ ├── Card │ │ ├── index.tsx │ │ └── style.scss │ ├── Checkbox │ │ ├── index.tsx │ │ └── style.scss │ ├── Drawer │ │ └── index.tsx │ ├── Header │ │ ├── index.tsx │ │ └── style.scss │ ├── Icon │ │ └── index.tsx │ ├── Input │ │ ├── index.tsx │ │ └── style.scss │ ├── Loading │ │ ├── Spinner │ │ │ ├── index.tsx │ │ │ └── style.scss │ │ ├── index.tsx │ │ └── style.scss │ ├── Message │ │ ├── index.tsx │ │ └── style.scss │ ├── Modal │ │ ├── index.tsx │ │ └── style.scss │ ├── Select │ │ ├── index.tsx │ │ └── style.scss │ ├── Switch │ │ ├── index.tsx │ │ └── style.scss │ ├── Tag │ │ ├── index.tsx │ │ └── style.scss │ ├── Tags │ │ ├── index.tsx │ │ └── style.scss │ └── index.ts ├── containers │ ├── App.tsx │ ├── Connections │ │ ├── Devices │ │ │ ├── index.tsx │ │ │ └── style.scss │ │ ├── Info │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── store.ts │ │ └── style.scss │ ├── ExternalControllerDrawer │ │ ├── index.tsx │ │ └── style.scss │ ├── Logs │ │ ├── index.tsx │ │ └── style.scss │ ├── Overview │ │ └── index.tsx │ ├── Proxies │ │ ├── components │ │ │ ├── Group │ │ │ │ ├── index.tsx │ │ │ │ └── style.scss │ │ │ ├── Provider │ │ │ │ ├── index.tsx │ │ │ │ └── style.scss │ │ │ ├── Proxy │ │ │ │ ├── index.tsx │ │ │ │ └── style.scss │ │ │ └── index.ts │ │ ├── index.tsx │ │ └── style.scss │ ├── Rules │ │ ├── Provider │ │ │ ├── index.tsx │ │ │ └── style.scss │ │ ├── index.tsx │ │ └── style.scss │ ├── Settings │ │ ├── index.tsx │ │ └── style.scss │ └── Sidebar │ │ ├── index.tsx │ │ └── style.scss ├── i18n │ ├── en_US.ts │ ├── index.ts │ └── zh_CN.ts ├── index.ts ├── lib │ ├── date.ts │ ├── event.ts │ ├── helper.ts │ ├── hook.ts │ ├── ip.ts │ ├── jotai.ts │ ├── jsBridge.ts │ ├── request.ts │ ├── streamer.ts │ └── type.ts ├── models │ ├── BaseProps.ts │ ├── Cipher.ts │ ├── Config.ts │ ├── Log.ts │ ├── Rule.ts │ └── index.ts ├── render.tsx ├── stores │ ├── index.ts │ ├── jotai.ts │ └── request.ts └── styles │ ├── common.scss │ ├── iconfont.scss │ └── variables.scss ├── tsconfig.json ├── uno.config.ts └── vite.config.ts /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "clash-dashboard", 3 | "projectOwner": "Dreamacro", 4 | "files": [ 5 | "README.md" 6 | ], 7 | "imageSize": 100, 8 | "commit": false, 9 | "contributors": [ 10 | { 11 | "login": "jas0ncn", 12 | "name": "Jason Chen", 13 | "avatar_url": "https://avatars2.githubusercontent.com/u/3380894?v=4", 14 | "profile": "https://ijason.cc", 15 | "contributions": [ 16 | "design", 17 | "code", 18 | "bug", 19 | "ideas", 20 | "review" 21 | ] 22 | }, 23 | { 24 | "login": "Dreamacro", 25 | "name": "Dreamacro", 26 | "avatar_url": "https://avatars1.githubusercontent.com/u/8615343?v=4", 27 | "profile": "https://github.com/Dreamacro", 28 | "contributions": [ 29 | "code", 30 | "bug", 31 | "ideas", 32 | "review", 33 | "platform" 34 | ] 35 | }, 36 | { 37 | "login": "chs97", 38 | "name": "chs97", 39 | "avatar_url": "https://avatars1.githubusercontent.com/u/12679581?v=4", 40 | "profile": "http://www.hs97.cn", 41 | "contributions": [ 42 | "code", 43 | "bug", 44 | "review" 45 | ] 46 | }, 47 | { 48 | "login": "yichengchen", 49 | "name": "Yicheng", 50 | "avatar_url": "https://avatars3.githubusercontent.com/u/11733500?v=4", 51 | "profile": "https://github.com/yichengchen", 52 | "contributions": [ 53 | "ideas", 54 | "platform" 55 | ] 56 | } 57 | ], 58 | "repoType": "github" 59 | } 60 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - standard-with-typescript 3 | - airbnb/hooks 4 | - plugin:@typescript-eslint/recommended 5 | - '@unocss' 6 | parser: '@typescript-eslint/parser' 7 | parserOptions: 8 | project: './tsconfig.json' 9 | settings: 10 | import/parsers: 11 | '@typescript-eslint/parser': [.ts, .tsx] 12 | import/resolver: 13 | typescript: 14 | alwaysTryTypes: true 15 | rules: 16 | comma-dangle: [error, always-multiline] 17 | import/order: [error, { 18 | groups: [builtin, external, internal, [parent, sibling, index, object]], 19 | newlines-between: always, 20 | alphabetize: { order: asc } 21 | }] 22 | jsx-quotes: [error, prefer-double] 23 | '@typescript-eslint/indent': [error, 4] 24 | '@typescript-eslint/explicit-function-return-type': off 25 | '@typescript-eslint/restrict-template-expressions': off 26 | '@typescript-eslint/strict-boolean-expressions': off 27 | '@typescript-eslint/no-non-null-assertion': off 28 | '@typescript-eslint/consistent-type-assertions': off 29 | '@typescript-eslint/promise-function-async': off 30 | '@typescript-eslint/no-floating-promises': off 31 | '@typescript-eslint/no-invalid-void-type': off 32 | '@typescript-eslint/no-misused-promises': off 33 | '@typescript-eslint/no-confusing-void-expression': off 34 | '@typescript-eslint/comma-dangle': [error, only-multiline] 35 | -------------------------------------------------------------------------------- /.github/workflows/ghpages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout Dashboard code 10 | uses: actions/checkout@v3 11 | - uses: pnpm/action-setup@v2 12 | with: 13 | version: latest 14 | - name: Setup Nodejs 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: '18.x' 18 | cache: pnpm 19 | - name: Install package and build 20 | run: | 21 | pnpm install --frozen-lockfile 22 | pnpm build 23 | - name: Deploy 24 | uses: crazy-max/ghaction-github-pages@v3 25 | with: 26 | target_branch: gh-pages 27 | build_dir: dist 28 | fqdn: clash.razord.top 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | dist/ 3 | node_modules/ 4 | src/**/*.jsx 5 | tests/__coverage__/ 6 | tests/**/*.jsx 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=false 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "i18n-ally.localesPaths": ["src/i18n"] 3 | } 4 | -------------------------------------------------------------------------------- /CONTRIBUTION.md: -------------------------------------------------------------------------------- 1 | Welcome to join us. Please read the following instructions to setup your local development environment. 2 | 3 | First, install `pnpm` according to [the official document](https://pnpm.io/installation). 4 | 5 | then, install some necessary depdencies: 6 | 7 | ```shell 8 | pnpm install 9 | ``` 10 | 11 | you can run it once you have finished it. 12 | 13 | ```shell 14 | pnpm start 15 | ``` 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 ClashTeam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clash and Dashboard 2 | - 这是一个基于[Dreamacro/clash-dashboard](https://github.com/Dreamacro/clash-dashboard)修改的仓库 3 | - 项目用于将Dashboard管理页面直接打包进clash的docker镜像中,**实现一个容器同时启动Clash和Dashboard** 4 | - 修改了后台接口部分代码,使后台只会连接到同一docker容器中clash的9090端口,不再需要配置即可直接管理。 5 | - 当然,因为去除了后台接口配置功能,所以此页面只能一对一了。 6 | 7 | ## 打包 8 | ```sh 9 | npm install -g pnpm 10 | pnpm i 11 | pnpm build 12 | docker build -f ./build/Dockerfile -t clash-and-dashboard:latest . 13 | ``` 14 | 15 | ## 启动 16 | ```sh 17 | docker run -d \ 18 | --name clash \ 19 | -v clash.yaml:/root/.config/clash/config.yaml \ 20 | -p 8080:8080 -p 7890:7890 \ 21 | clash-and-dashboard:latest 22 | ``` 23 | 24 | - 8080为管理界面端口 25 | - 7890为http端口 26 | - 7891为socks端口 27 | - 注意勾选允许局域网连接 28 | 29 | ## LICENSE 30 | MIT License 31 | -------------------------------------------------------------------------------- /build/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$TARGETPLATFORM dreamacro/clash:v1.16.0 2 | 3 | EXPOSE 8080 4 | 5 | RUN apk update && apk add nginx 6 | 7 | COPY ./dist/ /dashboard 8 | COPY ./build/nginx.conf /etc/nginx 9 | COPY ./build/start.sh / 10 | 11 | ENTRYPOINT [ "sh", "/start.sh" ] -------------------------------------------------------------------------------- /build/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes 1; 2 | 3 | events { 4 | worker_connections 1024; 5 | } 6 | 7 | http { 8 | include mime.types; 9 | sendfile on; 10 | keepalive_timeout 65; 11 | 12 | server { 13 | listen 8080; 14 | server_name localhost; 15 | default_type 'text/html'; 16 | charset utf-8; 17 | 18 | location / { 19 | root /dashboard; 20 | } 21 | 22 | location /api { 23 | rewrite ^/api(.*)$ $1 break; 24 | proxy_set_header Upgrade $http_upgrade; 25 | proxy_set_header Connection "upgrade"; 26 | proxy_pass http://localhost:9090; 27 | } 28 | 29 | error_page 404 /404.html; 30 | error_page 500 502 503 504 /50x.html; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /build/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | sleep 5s 3 | /usr/sbin/nginx 4 | /clash 5 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Clash 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clash-dashboard", 3 | "version": "0.1.0", 4 | "description": "Web port of clash.", 5 | "keywords": [ 6 | "clash", 7 | "dashboard" 8 | ], 9 | "author": "Dreamacro", 10 | "license": "MIT", 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/Dreamacro/clash-dashboard.git" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/Dreamacro/clash-dashboard/issues" 17 | }, 18 | "homepage": "https://github.com/Dreamacro/clash-dashboard", 19 | "scripts": { 20 | "start": "vite", 21 | "build": "vite build", 22 | "lint": "pnpm lint:ts", 23 | "lint:ts": "eslint --ext=jsx,ts,tsx --fix src", 24 | "contributors:add": "all-contributors add", 25 | "contributors:generate": "all-contributors generate" 26 | }, 27 | "devDependencies": { 28 | "@types/lodash-es": "^4.17.7", 29 | "@types/node": "^18.15.11", 30 | "@types/react": "^18.0.37", 31 | "@types/react-dom": "^18.0.11", 32 | "@types/react-virtualized-auto-sizer": "^1.0.1", 33 | "@types/react-window": "^1.8.5", 34 | "@typescript-eslint/eslint-plugin": "^5.59.0", 35 | "@typescript-eslint/parser": "^5.59.0", 36 | "@unocss/eslint-config": "^0.51.4", 37 | "@unocss/preset-wind": "^0.51.4", 38 | "@unocss/reset": "^0.51.4", 39 | "@vitejs/plugin-react": "^3.1.0", 40 | "eslint": "^8.38.0", 41 | "eslint-config-airbnb": "^19.0.4", 42 | "eslint-config-airbnb-typescript": "^17.0.0", 43 | "eslint-config-standard-with-typescript": "^34.0.1", 44 | "eslint-import-resolver-typescript": "^3.5.5", 45 | "eslint-plugin-import": "^2.27.5", 46 | "eslint-plugin-jsx-a11y": "^6.7.1", 47 | "eslint-plugin-n": "^15.7.0", 48 | "eslint-plugin-promise": "^6.1.1", 49 | "eslint-plugin-react": "^7.32.2", 50 | "eslint-plugin-react-hooks": "^4.6.0", 51 | "sass": "^1.62.0", 52 | "type-fest": "^3.8.0", 53 | "typescript": "^5.0.4", 54 | "unocss": "^0.51.4", 55 | "vite": "^4.2.2", 56 | "vite-plugin-pwa": "^0.14.7", 57 | "vite-tsconfig-paths": "^4.2.0" 58 | }, 59 | "dependencies": { 60 | "@react-hookz/web": "^23.0.0", 61 | "@tanstack/react-table": "^8.8.5", 62 | "axios": "^1.3.5", 63 | "classnames": "^2.3.2", 64 | "dayjs": "^1.11.7", 65 | "eventemitter3": "^5.0.0", 66 | "immer": "^10.0.1", 67 | "jotai": "^2.0.4", 68 | "jotai-immer": "^0.2.0", 69 | "lodash-es": "^4.17.21", 70 | "neverthrow": "^6.0.0", 71 | "react": "^18.2.0", 72 | "react-dom": "^18.2.0", 73 | "react-router-dom": "^6.10.0", 74 | "react-virtualized-auto-sizer": "^1.0.15", 75 | "react-window": "^1.8.9", 76 | "swr": "^2.1.3", 77 | "use-immer": "^0.9.0" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/assets/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LaoYutang/clash-and-dashboard/3b4371b0f1c012be6b0eda82c5e72f3a1b600d92/src/assets/Icon.png -------------------------------------------------------------------------------- /src/assets/LOGO-fixing.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | LOGO-fixing 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LaoYutang/clash-and-dashboard/3b4371b0f1c012be6b0eda82c5e72f3a1b600d92/src/assets/logo.png -------------------------------------------------------------------------------- /src/components/Alert/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | 3 | import { Icon } from '@components' 4 | import { type BaseComponentProps } from '@models' 5 | import './style.scss' 6 | 7 | interface AlertProps extends BaseComponentProps { 8 | message?: string 9 | type?: 'success' | 'info' | 'warning' | 'error' 10 | inside?: boolean 11 | } 12 | 13 | const iconMap = { 14 | success: 'check', 15 | info: 'info', 16 | warning: 'info', 17 | error: 'close', 18 | } 19 | 20 | export function Alert (props: AlertProps) { 21 | const { message = '', type = 'info', inside = false, children, className, style } = props 22 | const classname = classnames('alert', `alert-${inside ? 'note' : 'box'}-${type}`, className) 23 | return ( 24 |
25 | 26 | 27 | 28 | { 29 | message 30 | ?

{message}

31 | :
{children}
32 | } 33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/components/Alert/style.scss: -------------------------------------------------------------------------------- 1 | $iconSize: 20px; 2 | $borderWidth: 4px; 3 | 4 | @mixin box ($color) { 5 | background: linear-gradient(135deg, darken($color, 5%), $color); 6 | box-shadow: 0 2px 8px rgba($color: darken($color, 5%), $alpha: 0.3); 7 | 8 | .alert-icon > i { 9 | color: $color; 10 | } 11 | } 12 | 13 | @mixin note ($color) { 14 | background: rgba($color: $color, $alpha: 0.05); 15 | border-radius: 1px 4px 4px 1px; 16 | border-left: 2px solid $color; 17 | box-shadow: 0 2px 8px rgba($color: darken($color, 5%), $alpha: 0.3); 18 | 19 | .alert-icon { 20 | background: $color; 21 | 22 | > i { 23 | color: $color-white; 24 | } 25 | } 26 | 27 | .alert-message { 28 | color: darken($color: $color, $amount: 20%); 29 | } 30 | } 31 | 32 | .alert { 33 | padding: 15px; 34 | background: $color-white; 35 | border-radius: 4px; 36 | box-shadow: 0 2px 8px rgba($color: $color-primary-dark, $alpha: 0.3); 37 | font-size: 13px; 38 | line-height: 1.6; 39 | text-align: justify; 40 | display: flex; 41 | 42 | .alert-icon { 43 | margin-right: 10px; 44 | width: $iconSize; 45 | height: $iconSize; 46 | border-radius: 50%; 47 | flex-shrink: 0; 48 | display: flex; 49 | justify-content: center; 50 | align-items: center; 51 | background: $color-white; 52 | 53 | > i { 54 | transform: scale(0.5); 55 | font-weight: bold; 56 | } 57 | } 58 | 59 | .alert-message { 60 | width: 100%; 61 | color: $color-white; 62 | } 63 | } 64 | 65 | .alert-box-success { 66 | @include box($color-green); 67 | } 68 | 69 | .alert-box-info { 70 | @include box($color-primary); 71 | } 72 | 73 | .alert-box-warning { 74 | @include box($color-orange); 75 | } 76 | 77 | .alert-box-error { 78 | @include box($color-red); 79 | } 80 | 81 | .alert-note-success { 82 | @include note($color-green); 83 | } 84 | 85 | .alert-note-info { 86 | @include note($color-primary); 87 | } 88 | 89 | .alert-note-warning { 90 | @include note($color-orange); 91 | } 92 | 93 | .alert-note-error { 94 | @include note($color-red); 95 | } 96 | -------------------------------------------------------------------------------- /src/components/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | import { type MouseEventHandler } from 'react' 3 | 4 | import { noop } from '@lib/helper' 5 | import { type BaseComponentProps } from '@models' 6 | import './style.scss' 7 | 8 | interface ButtonProps extends BaseComponentProps { 9 | type?: 'primary' | 'normal' | 'danger' | 'success' | 'warning' 10 | onClick?: MouseEventHandler 11 | disabled?: boolean 12 | } 13 | 14 | export function Button (props: ButtonProps) { 15 | const { type = 'normal', onClick = noop, children, className, style, disabled } = props 16 | const classname = classnames('button', `button-${type}`, className, { 'button-disabled': disabled }) 17 | 18 | return ( 19 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/components/Button/style.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | outline: 0; 3 | padding: 0 15px; 4 | height: 32px; 5 | line-height: 32px; 6 | border-radius: 16px; 7 | font-size: 14px; 8 | cursor: pointer; 9 | transition: all 150ms ease; 10 | user-select: none; 11 | 12 | &:focus { 13 | outline: none; 14 | } 15 | } 16 | 17 | .button-primary { 18 | color: $color-white; 19 | border: none; 20 | background: linear-gradient(135deg, $color-primary, $color-primary-dark); 21 | box-shadow: 0 2px 8px rgba($color: $color-primary-dark, $alpha: 0.5); 22 | 23 | &:hover { 24 | border: none; 25 | } 26 | 27 | &:active { 28 | box-shadow: 0 0 2px rgba($color: $color-primary-dark, $alpha: 0.5); 29 | } 30 | } 31 | 32 | .button-normal { 33 | color: $color-gray-darken; 34 | background: $color-white; 35 | border: 1px solid rgba($color: $color-black, $alpha: 0.1); 36 | 37 | &:hover { 38 | border-color: $color-gray-dark; 39 | color: $color-primary-darken; 40 | } 41 | 42 | &:active { 43 | background: darken($color-white, 2%); 44 | color: $color-primary-darken; 45 | } 46 | } 47 | 48 | .button-danger { 49 | color: $color-white; 50 | border: none; 51 | background: linear-gradient(135deg, $color-red, darken($color-red, 10%)); 52 | box-shadow: 0 2px 8px rgba($color: darken($color-red, 10%), $alpha: 0.5); 53 | 54 | &:hover { 55 | border: none; 56 | } 57 | 58 | &:active { 59 | box-shadow: 0 0 2px rgba($color: darken($color-red, 10%), $alpha: 0.5); 60 | } 61 | } 62 | 63 | .button-success { 64 | color: $color-white; 65 | border: none; 66 | background: linear-gradient(135deg, $color-green, darken($color-green, 5%)); 67 | box-shadow: 0 2px 8px rgba($color: darken($color-green, 5%), $alpha: 0.5); 68 | 69 | &:hover { 70 | border: none; 71 | } 72 | 73 | &:active { 74 | box-shadow: 0 0 2px rgba($color: darken($color-green, 5%), $alpha: 0.5); 75 | } 76 | } 77 | 78 | .button-warning { 79 | color: $color-white; 80 | border: none; 81 | background: linear-gradient(135deg, $color-orange, darken($color-orange, 5%)); 82 | box-shadow: 0 2px 8px rgba($color: darken($color-orange, 5%), $alpha: 0.5); 83 | 84 | &:hover { 85 | border: none; 86 | } 87 | 88 | &:active { 89 | box-shadow: 0 0 2px rgba($color: darken($color-orange, 5%), $alpha: 0.5); 90 | } 91 | } 92 | 93 | .button.button-disabled { 94 | color: $color-gray-dark; 95 | background: linear-gradient(135deg, $color-gray-light, darken($color-gray-light, 5%)); 96 | box-shadow: 0 2px 8px rgba($color: darken($color-gray-light, 5%), $alpha: 0.5); 97 | cursor: not-allowed; 98 | 99 | &:active { 100 | box-shadow: 0 0 2px rgba($color: darken($color-gray-light, 5%), $alpha: 0.5); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/components/ButtonSelect/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | 3 | import { type BaseComponentProps } from '@models/BaseProps' 4 | import './style.scss' 5 | 6 | export interface ButtonSelectOptions { 7 | label: string 8 | value: T 9 | } 10 | 11 | export interface ButtonSelectProps extends BaseComponentProps { 12 | // options 13 | options: Array> 14 | 15 | // active value 16 | value: T 17 | 18 | // select callback 19 | onSelect?: (value: T) => void 20 | } 21 | 22 | export function ButtonSelect (props: ButtonSelectProps) { 23 | const { options, value, onSelect } = props 24 | 25 | return ( 26 |
27 | { 28 | options.map(option => ( 29 | 36 | )) 37 | } 38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/components/ButtonSelect/style.scss: -------------------------------------------------------------------------------- 1 | .button-select { 2 | display: flex; 3 | flex-direction: row; 4 | 5 | .button-select-options { 6 | height: 30px; 7 | padding: 0 15px; 8 | color: $color-primary-darken; 9 | font-size: 12px; 10 | line-height: 30px; 11 | background: $color-white; 12 | border: 1px solid $color-primary-lightly; 13 | border-right: none; 14 | transition: all 300ms ease; 15 | cursor: pointer; 16 | outline: 0; 17 | display: block; 18 | } 19 | 20 | .button-select-options:first-child { 21 | border-radius: 3px 0 0 3px; 22 | } 23 | 24 | .button-select-options:last-child { 25 | border-radius: 0 3px 3px 0; 26 | border-right: 1px solid $color-primary-lightly; 27 | } 28 | 29 | .button-select-options.actived { 30 | background: $color-primary; 31 | color: $color-white; 32 | border-color: $color-primary; 33 | box-shadow: 0 2px 5px rgba($color: $color-primary, $alpha: 0.5); 34 | 35 | &:active { 36 | box-shadow: none; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/Card/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | import { forwardRef } from 'react' 3 | 4 | import { type BaseComponentProps } from '@models/BaseProps' 5 | import './style.scss' 6 | 7 | interface CardProps extends BaseComponentProps { 8 | ref?: React.ForwardedRef 9 | } 10 | 11 | export const Card = forwardRef((props: CardProps, ref) => { 12 | const { className, style, children } = props 13 | return ( 14 |
15 | { children } 16 |
17 | ) 18 | }) 19 | -------------------------------------------------------------------------------- /src/components/Card/style.scss: -------------------------------------------------------------------------------- 1 | .card { 2 | padding: 15px; 3 | box-shadow: 2px 5px 20px -3px rgba($color-primary-dark, 0.18); 4 | background-color: $color-white; 5 | border-radius: 4px; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/Checkbox/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | 3 | import { Icon } from '@components' 4 | import { noop } from '@lib/helper' 5 | import { type BaseComponentProps } from '@models/BaseProps' 6 | import './style.scss' 7 | 8 | interface CheckboxProps extends BaseComponentProps { 9 | checked: boolean 10 | onChange?: (checked: boolean) => void 11 | } 12 | 13 | export function Checkbox (props: CheckboxProps) { 14 | const { className, checked = false, onChange = noop } = props 15 | const classname = classnames('checkbox', { checked }, className) 16 | 17 | function handleClick () { 18 | onChange(!checked) 19 | } 20 | 21 | return ( 22 |
23 | 24 |
{ props.children }
25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/components/Checkbox/style.scss: -------------------------------------------------------------------------------- 1 | $length: 18px; 2 | $padding: 26px; 3 | 4 | .checkbox { 5 | display: flex; 6 | position: relative; 7 | padding-left: $padding; 8 | cursor: pointer; 9 | line-height: $length; 10 | 11 | &::before { 12 | content: ''; 13 | display: inline-block; 14 | position: absolute; 15 | left: 0; 16 | top: 50%; 17 | width: $length; 18 | height: $length; 19 | border-radius: 3px; 20 | transition: background-color 0.3s ease; 21 | transform: translateY(math.div(-$length, 2)); 22 | background-color: $color-white; 23 | border: 1px solid $color-primary-lightly; 24 | } 25 | 26 | &.checked::before { 27 | background-color: $color-primary; 28 | } 29 | } 30 | 31 | .checkbox-icon { 32 | position: absolute; 33 | left: 0; 34 | top: 50%; 35 | line-height: $length; 36 | transform: translateY(math.div(-$length, 2)) scale(0.6); 37 | text-shadow: none; 38 | font-weight: bold; 39 | 40 | &.checkbox-icon { 41 | color: $color-white; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/components/Drawer/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | import { useLayoutEffect, useRef, type RefObject } from 'react' 3 | import { createPortal } from 'react-dom' 4 | 5 | import { Card } from '@components' 6 | import { type BaseComponentProps } from '@models/BaseProps' 7 | 8 | interface DrawerProps extends BaseComponentProps { 9 | visible?: boolean 10 | width?: number 11 | bodyClassName?: string 12 | containerRef?: RefObject 13 | } 14 | 15 | export function Drawer (props: DrawerProps) { 16 | const portalRef = useRef(document.createElement('div')) 17 | 18 | useLayoutEffect(() => { 19 | const current = portalRef.current 20 | document.body.appendChild(current) 21 | return () => { document.body.removeChild(current) } 22 | }, []) 23 | 24 | const cardStyle = 'absolute h-full right-0 transition-transform transform duration-100 pointer-events-auto' 25 | 26 | const container = ( 27 |
28 | {props.children} 33 |
34 | ) 35 | 36 | return createPortal(container, props.containerRef?.current ?? portalRef.current) 37 | } 38 | -------------------------------------------------------------------------------- /src/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | 3 | import { type BaseComponentProps } from '@models/BaseProps' 4 | import './style.scss' 5 | 6 | interface HeaderProps extends BaseComponentProps { 7 | // header title 8 | title: string 9 | } 10 | 11 | export function Header (props: HeaderProps) { 12 | const { title, children, className, style } = props 13 | 14 | return
15 |

{title}

16 |
{children}
17 |
18 | } 19 | -------------------------------------------------------------------------------- /src/components/Header/style.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | margin: 10px 0; 3 | width: 100%; 4 | display: flex; 5 | flex-direction: row; 6 | align-items: center; 7 | justify-content: space-between; 8 | user-select: none; 9 | 10 | > h1 { 11 | flex-shrink: 0; 12 | font-size: 24px; 13 | color: $color-primary-dark; 14 | font-weight: 500; 15 | text-shadow: 0 2px 6px rgba($color: $color-primary-dark, $alpha: 0.4); 16 | line-height: 32px; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/Icon/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | 3 | import { type BaseComponentProps } from '@models/BaseProps' 4 | 5 | interface IconProps extends BaseComponentProps { 6 | // icon type 7 | type: string 8 | 9 | // icon size 10 | size?: number 11 | 12 | onClick?: React.FormEventHandler 13 | } 14 | 15 | export function Icon (props: IconProps) { 16 | const { type, size = 14, className: cn, style: s } = props 17 | const className = classnames('clash-iconfont', `icon-${type}`, cn) 18 | const style: React.CSSProperties = { fontSize: size, ...s } 19 | const iconProps = { ...props, className, style } 20 | 21 | return 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Input/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | import { type KeyboardEvent, type FocusEvent, type ChangeEvent } from 'react' 3 | 4 | import { noop } from '@lib/helper' 5 | import { type BaseComponentProps } from '@models/BaseProps' 6 | import './style.scss' 7 | 8 | interface InputProps extends BaseComponentProps { 9 | value?: string | number 10 | align?: 'left' | 'center' | 'right' 11 | inside?: boolean 12 | autoFocus?: boolean 13 | type?: string 14 | disabled?: boolean 15 | onChange?: (value: string, event?: ChangeEvent) => void 16 | onEnter?: (event?: KeyboardEvent) => void 17 | onBlur?: (event?: FocusEvent) => void 18 | } 19 | 20 | export function Input (props: InputProps) { 21 | const { 22 | className, 23 | style, 24 | value = '', 25 | align = 'center', 26 | inside = false, 27 | autoFocus = false, 28 | type = 'text', 29 | disabled = false, 30 | onChange = noop, 31 | onBlur = noop, 32 | onEnter = noop, 33 | } = props 34 | const classname = classnames('input', `text-${align}`, { 'focus:shadow-none': inside }, className) 35 | 36 | function handleKeyDown (e: KeyboardEvent) { 37 | if (e.code === 'Enter') { 38 | onEnter(e) 39 | } 40 | } 41 | 42 | return ( 43 | onChange(event.target.value, event)} 51 | onBlur={onBlur} 52 | onKeyDown={handleKeyDown} 53 | /> 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /src/components/Input/style.scss: -------------------------------------------------------------------------------- 1 | $height: 30px; 2 | 3 | .input { 4 | display: inline-block; 5 | height: $height; 6 | width: 100%; 7 | padding: 0 10px; 8 | font-size: 14px; 9 | color: $color-primary-darken; 10 | border-radius: 3px; 11 | border: 1px solid $color-primary-lightly; 12 | transition: all 0.3s; 13 | transition-property: border-color, color, box-shadow; 14 | 15 | &:focus { 16 | outline: 0; 17 | border-color: $color-primary; 18 | color: $color-primary-dark; 19 | box-shadow: 0 2px 5px rgba($color: $color-primary, $alpha: 0.5); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Loading/Spinner/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | 3 | import { type BaseComponentProps } from '@models/BaseProps' 4 | 5 | import './style.scss' 6 | 7 | type SpinnerProps = BaseComponentProps 8 | 9 | export function Spinner (props: SpinnerProps) { 10 | const classname = classnames('spinner', props.className) 11 | 12 | return ( 13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/components/Loading/Spinner/style.scss: -------------------------------------------------------------------------------- 1 | .spinner { 2 | position: relative; 3 | width: 80px; 4 | height: 80px; 5 | border-radius: 100%; 6 | animation: spinner 5s infinite linear; 7 | } 8 | 9 | .spinner-circle { 10 | position: absolute; 11 | width: 100%; 12 | height: 100%; 13 | transform-origin: 48% 48%; 14 | } 15 | 16 | .spinner-inner { 17 | width: 100%; 18 | height: 100%; 19 | border-radius: 100%; 20 | border: 5px solid transparentize($color-primary-dark, 0.3); 21 | border-right: none; 22 | border-top: none; 23 | background-clip: padding-box; 24 | box-shadow: inset 0 0 10px transparentize($color-primary-dark, 0.85); 25 | } 26 | 27 | @keyframes spinner { 28 | from { 29 | transform: rotate(0deg); 30 | } 31 | 32 | to { 33 | transform: rotate(360deg); 34 | } 35 | } 36 | 37 | .spinner-circle:nth-of-type(0) { 38 | transform: rotate(0deg); 39 | } 40 | 41 | .spinner-circle:nth-of-type(0) .spinner-inner { 42 | animation: spinner 2s infinite linear; 43 | } 44 | 45 | .spinner-circle:nth-of-type(1) { 46 | transform: rotate(70deg); 47 | } 48 | 49 | .spinner-circle:nth-of-type(1) .spinner-inner { 50 | animation: spinner 2s infinite linear; 51 | } 52 | 53 | .spinner-circle:nth-of-type(2) { 54 | transform: rotate(140deg); 55 | } 56 | 57 | .spinner-circle:nth-of-type(2) .spinner-inner { 58 | animation: spinner 2s infinite linear; 59 | } 60 | -------------------------------------------------------------------------------- /src/components/Loading/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | 3 | import { type BaseComponentProps } from '@models/BaseProps' 4 | 5 | import { Spinner } from './Spinner' 6 | import './style.scss' 7 | 8 | interface LoadingProps extends BaseComponentProps { 9 | visible: boolean 10 | spinnerClassName?: string 11 | } 12 | 13 | export function Loading (props: LoadingProps) { 14 | const classname = classnames('loading', 'visible', props.className) 15 | return props.visible 16 | ? ( 17 |
18 | 19 |
20 | ) 21 | : null 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Loading/style.scss: -------------------------------------------------------------------------------- 1 | .loading { 2 | position: absolute; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | top: 0; 7 | left: 0; 8 | bottom: 0; 9 | right: 0; 10 | background-color: rgba($color: #fff, $alpha: 0.9); 11 | box-shadow: inset 0 0 80px rgba(0, 0, 0, 0.1); 12 | z-index: 1000; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Message/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | import { useLayoutEffect } from 'react' 3 | import { unmountComponentAtNode } from 'react-dom' 4 | import { createRoot } from 'react-dom/client' 5 | 6 | import { Icon } from '@components' 7 | import { noop } from '@lib/helper' 8 | import { useVisible } from '@lib/hook' 9 | import './style.scss' 10 | 11 | const TYPE_ICON_MAP = { 12 | info: 'info', 13 | success: 'check', 14 | warning: 'info-o', 15 | error: 'close', 16 | } 17 | 18 | type NoticeType = 'success' | 'info' | 'warning' | 'error' 19 | 20 | interface ArgsProps { 21 | content: string 22 | type: NoticeType 23 | duration?: number 24 | onClose?: typeof noop 25 | } 26 | 27 | interface MessageProps { 28 | content?: string 29 | type?: NoticeType 30 | icon?: React.ReactNode 31 | duration?: number 32 | onClose?: typeof noop 33 | removeComponent: typeof noop 34 | } 35 | 36 | export function Message (props: MessageProps) { 37 | const { 38 | removeComponent = noop, 39 | onClose = noop, 40 | icon = , 41 | content = '', 42 | type = 'info', 43 | duration = 1500, 44 | } = props 45 | 46 | const { visible, show, hide } = useVisible() 47 | 48 | useLayoutEffect(() => { 49 | window.setTimeout(() => show(), 0) 50 | 51 | const id = window.setTimeout(() => { 52 | hide() 53 | onClose() 54 | }, duration) 55 | return () => window.clearTimeout(id) 56 | }, [duration, hide, onClose, show]) 57 | 58 | return ( 59 |
!visible && removeComponent()} 62 | > 63 | {icon} 64 | {content} 65 |
66 | ) 67 | } 68 | 69 | export function showMessage (args: ArgsProps) { 70 | // create container element 71 | const container = document.createElement('div') 72 | document.body.appendChild(container) 73 | 74 | // remove container when component unmount 75 | const removeComponent = () => { 76 | const isUnMount = unmountComponentAtNode(container) 77 | if (isUnMount) { 78 | document.body.removeChild(container) 79 | } 80 | } 81 | 82 | const icon = 83 | const { type, content, duration, onClose } = args 84 | const props: MessageProps = { 85 | icon, 86 | type, 87 | content, 88 | removeComponent, 89 | duration, 90 | onClose, 91 | } 92 | 93 | createRoot(container).render() 94 | } 95 | 96 | export const info = ( 97 | content: string, 98 | duration?: number, 99 | onClose?: typeof noop, 100 | ) => showMessage({ type: 'info', content, duration, onClose }) 101 | 102 | export const success = ( 103 | content: string, 104 | duration?: number, 105 | onClose?: typeof noop, 106 | ) => showMessage({ type: 'success', content, duration, onClose }) 107 | 108 | export const warning = ( 109 | content: string, 110 | duration?: number, 111 | onClose?: typeof noop, 112 | ) => showMessage({ type: 'warning', content, duration, onClose }) 113 | 114 | export const error = ( 115 | content: string, 116 | duration?: number, 117 | onClose?: typeof noop, 118 | ) => showMessage({ type: 'error', content, duration, onClose }) 119 | -------------------------------------------------------------------------------- /src/components/Message/style.scss: -------------------------------------------------------------------------------- 1 | .message { 2 | position: fixed; 3 | top: 20px; 4 | right: 20px; 5 | border-radius: 4px; 6 | opacity: 0; 7 | background: $color-white; 8 | display: flex; 9 | box-shadow: 0 0 20px rgba($color-primary-dark, 0.2); 10 | transition: all 200ms ease; 11 | transform: translateX(100%); 12 | 13 | .message-icon { 14 | width: 36px; 15 | flex: 1; 16 | border-radius: 4px 0 0 4px; 17 | display: flex; 18 | justify-content: center; 19 | align-items: center; 20 | 21 | > i { 22 | color: $color-white; 23 | } 24 | } 25 | 26 | .message-content { 27 | padding: 10px 15px; 28 | font-size: 13px; 29 | color: $color-primary-darken; 30 | } 31 | } 32 | 33 | .message-info { 34 | .message-icon { 35 | background: linear-gradient(135deg, $color-primary, $color-primary-dark); 36 | } 37 | } 38 | 39 | .message-success { 40 | .message-icon { 41 | background: linear-gradient(135deg, $color-green, darken($color-green, 5%)); 42 | } 43 | } 44 | 45 | .message-warning { 46 | .message-icon { 47 | background: linear-gradient(135deg, $color-orange, darken($color-orange, 5%)); 48 | } 49 | } 50 | 51 | .message-error { 52 | .message-icon { 53 | background: linear-gradient(135deg, $color-red, darken($color-red, 10%)); 54 | } 55 | } 56 | 57 | .message-show { 58 | opacity: 1; 59 | transition: all 200ms ease; 60 | transform: translateX(0); 61 | } 62 | -------------------------------------------------------------------------------- /src/components/Modal/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | import { useRef, useLayoutEffect, type MouseEvent } from 'react' 3 | import { createPortal } from 'react-dom' 4 | 5 | import { Button } from '@components' 6 | import { noop } from '@lib/helper' 7 | import { type BaseComponentProps } from '@models' 8 | import { useI18n } from '@stores' 9 | import './style.scss' 10 | 11 | interface ModalProps extends BaseComponentProps { 12 | // show modal 13 | show?: boolean 14 | 15 | // modal title 16 | title: string 17 | 18 | // size 19 | size?: 'small' | 'big' 20 | 21 | // body className 22 | bodyClassName?: string 23 | 24 | // body style 25 | bodyStyle?: React.CSSProperties 26 | 27 | // show footer 28 | footer?: boolean 29 | 30 | // footer extra 31 | footerExtra?: React.ReactNode 32 | 33 | // on click ok 34 | onOk?: typeof noop 35 | 36 | // on click close 37 | onClose?: typeof noop 38 | } 39 | 40 | export function Modal (props: ModalProps) { 41 | const { 42 | show = true, 43 | title = 'Modal', 44 | size = 'small', 45 | footer = true, 46 | onOk = noop, 47 | onClose = noop, 48 | bodyClassName, 49 | bodyStyle, 50 | className, 51 | footerExtra, 52 | style, 53 | children, 54 | } = props 55 | 56 | const { translation } = useI18n() 57 | const { t } = translation('Modal') 58 | 59 | const portalRef = useRef(document.createElement('div')) 60 | const maskRef = useRef(null) 61 | 62 | useLayoutEffect(() => { 63 | const current = portalRef.current 64 | document.body.appendChild(current) 65 | return () => { document.body.removeChild(current) } 66 | }, []) 67 | 68 | function handleMaskMouseDown (e: MouseEvent) { 69 | if (e.target === maskRef.current) { 70 | onClose() 71 | } 72 | } 73 | 74 | const modal = ( 75 |
80 |
84 |
{title}
85 |
{children}
89 | { 90 | footer && ( 91 |
92 | {footerExtra} 93 |
94 | 95 | 96 |
97 |
98 | ) 99 | } 100 |
101 |
102 | ) 103 | 104 | return createPortal(modal, portalRef.current) 105 | } 106 | -------------------------------------------------------------------------------- /src/components/Modal/style.scss: -------------------------------------------------------------------------------- 1 | $width: 400px; 2 | $bigWidth: 600px; 3 | 4 | $mobileWidth: 280px; 5 | $mobileBigWidth: 480px; 6 | 7 | .modal-mask { 8 | position: fixed; 9 | top: 0; 10 | left: 0; 11 | width: 100%; 12 | height: 100%; 13 | background: rgba($color: $color-black, $alpha: 0.15); 14 | opacity: 0; 15 | pointer-events: none; 16 | transition: all 500ms ease; 17 | display: flex; 18 | justify-content: center; 19 | align-items: center; 20 | z-index: 9999; 21 | 22 | .modal { 23 | margin-top: -50px; 24 | padding: 20px 30px; 25 | background: $color-white; 26 | box-shadow: 0 2px 16px rgba($color: $color-primary-darken, $alpha: 0.2); 27 | border-radius: 4px; 28 | display: flex; 29 | flex-direction: column; 30 | transform: scale(0); 31 | transition: all 300ms cubic-bezier(0.32, 0.26, 0.71, 1.29); 32 | 33 | .modal-title { 34 | margin: 5px 0; 35 | width: 100%; 36 | display: flex; 37 | align-items: center; 38 | justify-content: space-between; 39 | font-weight: bold; 40 | font-size: 18px; 41 | color: $color-primary-dark; 42 | text-shadow: 0 2px 6px rgba($color: $color-primary-dark, $alpha: 0.4); 43 | } 44 | 45 | .modal-body { 46 | margin: 10px 0; 47 | font-size: 14px; 48 | color: $color-primary-darken; 49 | } 50 | } 51 | 52 | .modal-small { 53 | width: $width; 54 | } 55 | 56 | .modal-big { 57 | width: $bigWidth; 58 | } 59 | } 60 | 61 | .modal-show { 62 | opacity: 1; 63 | pointer-events: visible; 64 | 65 | .modal { 66 | transform: scale(1); 67 | } 68 | } 69 | 70 | @media (max-width: 768px) { 71 | .modal-mask { 72 | .modal { 73 | margin-top: 0; 74 | padding: 18px 20px; 75 | } 76 | 77 | .modal-small { 78 | width: $mobileWidth; 79 | } 80 | 81 | .modal-big { 82 | width: $mobileBigWidth; 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/components/Select/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | import { useRef, useState, useMemo, useLayoutEffect, type ReactNode } from 'react' 3 | import { createPortal } from 'react-dom' 4 | 5 | import { Icon } from '@components' 6 | import { noop } from '@lib/helper' 7 | import { type BaseComponentProps } from '@models' 8 | 9 | import './style.scss' 10 | 11 | export interface SelectOptions { 12 | label: ReactNode 13 | value: T 14 | disabled?: boolean 15 | key?: React.Key 16 | } 17 | 18 | interface SelectProps extends BaseComponentProps { 19 | /** 20 | * selected value 21 | * must match one of options 22 | */ 23 | value: T 24 | 25 | options: Array> 26 | 27 | disabled?: boolean 28 | 29 | onSelect?: (value: T, e: React.MouseEvent) => void 30 | } 31 | 32 | export function Select (props: SelectProps) { 33 | const { value, options, onSelect, disabled, className: cn, style } = props 34 | 35 | const portalRef = useRef(document.createElement('div')) 36 | const targetRef = useRef(null) 37 | 38 | const [showDropDownList, setShowDropDownList] = useState(false) 39 | const [dropdownListStyles, setDropdownListStyles] = useState({}) 40 | 41 | useLayoutEffect(() => { 42 | const current = portalRef.current 43 | document.body.appendChild(current) 44 | return () => { 45 | document.body.removeChild(current) 46 | } 47 | }, []) 48 | 49 | function handleShowDropList () { 50 | if (disabled) { 51 | return 52 | } 53 | 54 | if (!showDropDownList) { 55 | const targetRectInfo = targetRef.current!.getBoundingClientRect() 56 | setDropdownListStyles({ 57 | top: Math.floor(targetRectInfo.top + targetRectInfo.height) + 6, 58 | left: Math.floor(targetRectInfo.left) - 10, 59 | }) 60 | } 61 | setShowDropDownList(!showDropDownList) 62 | } 63 | 64 | const matchChild = useMemo( 65 | () => options.find(o => o.value === value), 66 | [value, options], 67 | ) 68 | 69 | const dropDownList = ( 70 |
74 |
    75 | { 76 | options.map(option => ( 77 | 88 | )) 89 | } 90 |
91 |
92 | ) 93 | 94 | return ( 95 | <> 96 |
102 | {matchChild?.label} 103 | 104 |
105 | {createPortal(dropDownList, portalRef.current)} 106 | 107 | ) 108 | } 109 | 110 | interface OptionProps extends BaseComponentProps { 111 | value: T 112 | disabled?: boolean 113 | onClick?: (e: React.MouseEvent) => void 114 | } 115 | 116 | function Option (props: OptionProps) { 117 | const { className: cn, style, disabled = false, children, onClick = noop } = props 118 | const className = classnames('option', { disabled }, cn) 119 | 120 | return ( 121 |
  • {children}
  • 122 | ) 123 | } 124 | -------------------------------------------------------------------------------- /src/components/Select/style.scss: -------------------------------------------------------------------------------- 1 | .select { 2 | cursor: pointer; 3 | font-size: 14px; 4 | line-height: 30px; 5 | color: $color-primary-darken; 6 | display: flex; 7 | overflow: hidden; 8 | 9 | > i { 10 | margin-left: 5px; 11 | color: $color-primary-darken; 12 | } 13 | 14 | &.disabled { 15 | cursor: not-allowed; 16 | } 17 | } 18 | 19 | .select-list { 20 | position: absolute; 21 | max-width: 170px; 22 | border-radius: 4px; 23 | pointer-events: none; 24 | transition: all 200ms ease; 25 | 26 | .list { 27 | opacity: 0; 28 | max-height: 300px; 29 | overflow: auto; 30 | background: $color-white; 31 | padding: 0; 32 | transition: all 200ms ease; 33 | border-radius: 4px; 34 | 35 | > .option { 36 | color: $color-primary-darken; 37 | padding: 10px 15px; 38 | font-size: 14px; 39 | list-style: none; 40 | cursor: pointer; 41 | 42 | &:hover { 43 | background: rgba($color: $color-primary-lightly, $alpha: 0.5); 44 | } 45 | } 46 | 47 | > .selected { 48 | background: rgba($color: $color-primary-lightly, $alpha: 0.5); 49 | } 50 | } 51 | } 52 | 53 | .select-list-show { 54 | pointer-events: visible; 55 | transform: scaleY(1); 56 | box-shadow: 0 2px 5px rgba($color: $color-gray-dark, $alpha: 0.5); 57 | 58 | .list { 59 | opacity: 1; 60 | transform: scaleY(1); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/components/Switch/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | 3 | import { Icon } from '@components' 4 | import { noop } from '@lib/helper' 5 | import { type BaseComponentProps } from '@models/BaseProps' 6 | import './style.scss' 7 | 8 | interface SwitchProps extends BaseComponentProps { 9 | checked: boolean 10 | disabled?: boolean 11 | onChange?: (checked: boolean) => void 12 | } 13 | 14 | export function Switch (props: SwitchProps) { 15 | const { className, checked = false, disabled = false, onChange = noop } = props 16 | const classname = classnames('switch', { checked, disabled }, className) 17 | 18 | function handleClick () { 19 | if (!disabled) { 20 | onChange(!checked) 21 | } 22 | } 23 | 24 | return ( 25 |
    26 | 27 |
    28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Switch/style.scss: -------------------------------------------------------------------------------- 1 | $height: 16px; 2 | $switch-radius: 18px; 3 | $switch-offset: 2px; 4 | $width: 32px; 5 | 6 | .switch { 7 | display: inline-block; 8 | width: $width; 9 | height: $height; 10 | border-radius: math.div($height, 2); 11 | background-color: $color-gray; 12 | transition: background-color 0.3s ease; 13 | position: relative; 14 | cursor: pointer; 15 | 16 | &.checked { 17 | background-color: $color-primary; 18 | 19 | &::after { 20 | transform: translateX($width - $switch-radius + $switch-offset); 21 | } 22 | } 23 | 24 | &.disabled { 25 | cursor: not-allowed; 26 | background-color: $color-gray-dark; 27 | 28 | &::after { 29 | background-color: lighten($color-primary-lightly, 0.1); 30 | box-shadow: 0 0 8px rgba($color-gray-darken, 0.5); 31 | } 32 | } 33 | 34 | &.checked.disabled { 35 | background-color: $color-primary-lightly; 36 | } 37 | 38 | &::after { 39 | content: ''; 40 | position: absolute; 41 | top: math.div($switch-radius - $height, -2); 42 | height: $switch-radius; 43 | width: $switch-radius; 44 | border-radius: math.div($switch-radius, 2); 45 | background-color: $color-white; 46 | box-shadow: 0 0 8px rgba($color-primary-dark, 0.4); 47 | transition: transform 0.3s ease; 48 | transform: translateX(-$switch-offset); 49 | } 50 | } 51 | 52 | .switch-icon { 53 | position: absolute; 54 | transform: translateX(-1px) scale(0.4); 55 | color: $color-white; 56 | line-height: $height; 57 | } 58 | -------------------------------------------------------------------------------- /src/components/Tag/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | 3 | import { type BaseComponentProps } from '@models/BaseProps' 4 | 5 | import './style.scss' 6 | 7 | interface TagProps extends BaseComponentProps { 8 | color?: string 9 | } 10 | 11 | export function Tag (props: TagProps) { 12 | const { color, className: cn, style: s } = props 13 | const className = classnames('tag', cn) 14 | const style: React.CSSProperties = { color, ...s } 15 | const spanProps = { ...props, className, style } 16 | 17 | return { props.children } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/Tag/style.scss: -------------------------------------------------------------------------------- 1 | .tag { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | height: 24px; 6 | font-size: 12px; 7 | padding: 0 12px; 8 | text-align: center; 9 | background-color: #fff; 10 | border: 2px solid $color-primary-dark; 11 | color: $color-primary-dark; 12 | border-radius: 12px; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Tags/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | import { useState, useRef, useLayoutEffect } from 'react' 3 | 4 | import { noop } from '@lib/helper' 5 | import { type BaseComponentProps } from '@models' 6 | import { useI18n } from '@stores' 7 | import './style.scss' 8 | 9 | interface TagsProps extends BaseComponentProps { 10 | data: string[] 11 | onClick: (name: string) => void 12 | errSet?: Set 13 | select: string 14 | rowHeight: number 15 | canClick: boolean 16 | } 17 | 18 | export function Tags (props: TagsProps) { 19 | const { className, data, onClick, select, canClick, errSet, rowHeight: rawHeight } = props 20 | 21 | const { translation } = useI18n() 22 | const { t } = translation('Proxies') 23 | const [expand, setExpand] = useState(false) 24 | const [showExtend, setShowExtend] = useState(false) 25 | 26 | const ulRef = useRef(null) 27 | useLayoutEffect(() => { 28 | setShowExtend((ulRef?.current?.offsetHeight ?? 0) > 30) 29 | }, []) 30 | 31 | const rowHeight = expand ? 'auto' : rawHeight 32 | const handleClick = canClick ? onClick : noop 33 | 34 | function toggleExtend () { 35 | setExpand(!expand) 36 | } 37 | 38 | const tags = data 39 | .map(t => { 40 | const tagClass = classnames({ 'tags-selected': select === t, 'cursor-pointer': canClick, error: errSet?.has(t) }) 41 | return ( 42 |
  • handleClick(t)}> 43 | { t } 44 |
  • 45 | ) 46 | }) 47 | 48 | return ( 49 |
    50 |
      51 | { tags } 52 |
    53 | { 54 | showExtend && 55 | { expand ? t('collapseText') : t('expandText') } 56 | } 57 |
    58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/components/Tags/style.scss: -------------------------------------------------------------------------------- 1 | $height: 22px; 2 | 3 | .tags { 4 | display: flex; 5 | flex: 1; 6 | align-items: center; 7 | list-style: none; 8 | flex-wrap: wrap; 9 | box-sizing: content-box; 10 | 11 | li { 12 | position: relative; 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | border: 1px solid $color-primary-dark; 17 | color: $color-primary-darken; 18 | height: $height; 19 | border-radius: math.div($height, 2); 20 | padding: 0 6px; 21 | margin: 3px 4px; 22 | font-size: 10px; 23 | } 24 | 25 | li.error { 26 | color: #fff; 27 | background-color: $color-red; 28 | border-color: $color-red; 29 | } 30 | 31 | li.tags-selected.error { 32 | background: linear-gradient(135deg, $color-primary-dark, $color-red); 33 | border: none; 34 | height: $height + 2px; 35 | padding: 0 7px; 36 | } 37 | 38 | .tags-selected { 39 | background-color: $color-primary-dark; 40 | color: #fff; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Header' 2 | export * from './Icon' 3 | export * from './Switch' 4 | export * from './Card' 5 | export * from './ButtonSelect' 6 | export * from './Tags' 7 | export * from './Input' 8 | export * from './Select' 9 | export * from './Modal' 10 | export * from './Alert' 11 | export * from './Button' 12 | export * from './Message' 13 | export * from './Checkbox' 14 | export * from './Tag' 15 | export * from './Loading' 16 | export * from './Drawer' 17 | -------------------------------------------------------------------------------- /src/containers/App.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | import { Route, Navigate, Routes, useLocation, Outlet } from 'react-router-dom' 3 | 4 | // import Overview from '@containers/Overview' 5 | import Connections from '@containers/Connections' 6 | import ExternalControllerModal from '@containers/ExternalControllerDrawer' 7 | import Logs from '@containers/Logs' 8 | import Proxies from '@containers/Proxies' 9 | import Rules from '@containers/Rules' 10 | import Settings from '@containers/Settings' 11 | import SideBar from '@containers/Sidebar' 12 | import { isClashX } from '@lib/jsBridge' 13 | import { useLogsStreamReader } from '@stores' 14 | 15 | import '../styles/common.scss' 16 | import '../styles/iconfont.scss' 17 | 18 | export default function App () { 19 | useLogsStreamReader() 20 | 21 | const location = useLocation() 22 | 23 | const routes = [ 24 | // { path: '/', name: 'Overview', component: Overview, exact: true }, 25 | { path: '/proxies', name: 'Proxies', element: }, 26 | { path: '/logs', name: 'Logs', element: }, 27 | { path: '/rules', name: 'Rules', element: , noMobile: true }, 28 | { path: '/connections', name: 'Connections', element: , noMobile: true }, 29 | { path: '/settings', name: 'Settings', element: }, 30 | ] 31 | 32 | const layout = ( 33 |
    34 | 35 |
    36 | 37 |
    38 | 39 |
    40 | ) 41 | 42 | return ( 43 | 44 | 45 | } /> 46 | { 47 | routes.map( 48 | route => , 49 | ) 50 | } 51 | 52 | 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/containers/Connections/Devices/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | import { useMemo } from 'react' 3 | 4 | import { type BaseComponentProps } from '@models' 5 | import { useI18n } from '@stores' 6 | import './style.scss' 7 | 8 | interface DevicesProps extends BaseComponentProps { 9 | devices: Array<{ label: string, number: number }> 10 | selected: string 11 | onChange?: (label: string) => void 12 | } 13 | 14 | export function Devices (props: DevicesProps) { 15 | const { translation } = useI18n() 16 | const t = useMemo(() => translation('Connections').t, [translation]) 17 | 18 | const { className, style } = props 19 | const classname = classnames('flex flex-wrap px-1', className) 20 | function handleSelected (label: string) { 21 | props.onChange?.(label) 22 | } 23 | 24 | return ( 25 |
    26 |
    handleSelected('')}> 27 | { t('filter.all') } 28 |
    29 | { 30 | props.devices.map( 31 | device => ( 32 |
    handleSelected(device.label)}> 36 | { device.label } ({ device.number }) 37 |
    38 | ), 39 | ) 40 | } 41 |
    42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/containers/Connections/Devices/style.scss: -------------------------------------------------------------------------------- 1 | .connections-devices-item { 2 | margin-right: 20px; 3 | font-size: 14px; 4 | color: $color-gray-darken; 5 | border-radius: 3px; 6 | cursor: pointer; 7 | transition: color .3s ease; 8 | 9 | &.selected { 10 | color: $color-primary-dark; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/containers/Connections/Info/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | import { useMemo } from 'react' 3 | 4 | import { basePath, formatTraffic } from '@lib/helper' 5 | import { type BaseComponentProps } from '@models' 6 | import { useI18n } from '@stores' 7 | 8 | import { type Connection } from '../store' 9 | 10 | interface ConnectionsInfoProps extends BaseComponentProps { 11 | connection: Partial 12 | } 13 | 14 | export function ConnectionInfo (props: ConnectionsInfoProps) { 15 | const { translation } = useI18n() 16 | const t = useMemo(() => translation('Connections').t, [translation]) 17 | 18 | return ( 19 |
    20 |
    21 | {t('info.id')} 22 | {props.connection.id} 23 |
    24 |
    25 |
    26 | {t('info.network')} 27 | {props.connection.metadata?.network} 28 |
    29 |
    30 | {t('info.inbound')} 31 | {props.connection.metadata?.type} 32 |
    33 |
    34 |
    35 | {t('info.host')} 36 | { 37 | props.connection.metadata?.host 38 | ? `${props.connection.metadata.host}:${props.connection.metadata?.destinationPort}` 39 | : t('info.hostEmpty') 40 | } 41 |
    42 |
    43 | {t('info.dstIP')} 44 | { 45 | props.connection.metadata?.destinationIP 46 | ? `${props.connection.metadata.destinationIP}:${props.connection.metadata?.destinationPort}` 47 | : t('info.hostEmpty') 48 | } 49 |
    50 |
    51 | {t('info.srcIP')} 52 | { 53 | `${props.connection.metadata?.sourceIP}:${props.connection.metadata?.sourcePort}` 54 | } 55 |
    56 |
    57 | {t('info.process')} 58 | { 59 | props.connection.metadata?.processPath 60 | ? `${basePath(props.connection.metadata.processPath)}` 61 | : t('info.hostEmpty') 62 | } 63 |
    64 |
    65 | {t('info.processPath')} 66 | { 67 | props.connection.metadata?.processPath 68 | ? `${props.connection.metadata.processPath}` 69 | : t('info.hostEmpty') 70 | } 71 |
    72 |
    73 | {t('info.rule')} 74 | 75 | { props.connection.rule && `${props.connection.rule}${props.connection.rulePayload && ` :: ${props.connection.rulePayload}`}` } 76 | 77 |
    78 |
    79 | {t('info.chains')} 80 | 81 | { props.connection.chains?.slice().reverse().join(' / ') } 82 | 83 |
    84 |
    85 |
    86 | {t('info.upload')} 87 | {formatTraffic(props.connection.upload ?? 0)} 88 |
    89 |
    90 | {t('info.download')} 91 | {formatTraffic(props.connection.download ?? 0)} 92 |
    93 |
    94 |
    95 | {t('info.status')} 96 | { 97 | !props.connection.completed 98 | ? {t('info.opening')} 99 | : {t('info.closed')} 100 | } 101 |
    102 |
    103 | ) 104 | } 105 | -------------------------------------------------------------------------------- /src/containers/Connections/index.tsx: -------------------------------------------------------------------------------- 1 | import { useIntersectionObserver, useSyncedRef, useUnmountEffect } from '@react-hookz/web' 2 | import { useReactTable, getSortedRowModel, getFilteredRowModel, getCoreRowModel, flexRender, createColumnHelper } from '@tanstack/react-table' 3 | import classnames from 'classnames' 4 | import { groupBy } from 'lodash-es' 5 | import { useMemo, useLayoutEffect, useRef, useState, useEffect } from 'react' 6 | 7 | import { Header, Checkbox, Modal, Icon, Drawer, Card, Button } from '@components' 8 | import { fromNow } from '@lib/date' 9 | import { basePath, formatTraffic } from '@lib/helper' 10 | import { useObject, useVisible } from '@lib/hook' 11 | import type * as API from '@lib/request' 12 | import { useClient, useConnectionStreamReader, useI18n } from '@stores' 13 | 14 | import { Devices } from './Devices' 15 | import { ConnectionInfo } from './Info' 16 | import { type Connection, type FormatConnection, useConnections } from './store' 17 | import './style.scss' 18 | 19 | const Columns = { 20 | Host: 'host', 21 | Network: 'network', 22 | Process: 'process', 23 | Type: 'type', 24 | Chains: 'chains', 25 | Rule: 'rule', 26 | Speed: 'speed', 27 | Upload: 'upload', 28 | Download: 'download', 29 | SourceIP: 'sourceIP', 30 | Time: 'time', 31 | } as const 32 | 33 | const shouldCenter = new Set([Columns.Network, Columns.Type, Columns.Speed, Columns.Upload, Columns.Download, Columns.SourceIP, Columns.Time, Columns.Process]) 34 | 35 | function formatSpeed (upload: number, download: number) { 36 | switch (true) { 37 | case upload === 0 && download === 0: 38 | return '-' 39 | case upload !== 0 && download !== 0: 40 | return `↑ ${formatTraffic(upload)}/s ↓ ${formatTraffic(download)}/s` 41 | case upload !== 0: 42 | return `↑ ${formatTraffic(upload)}/s` 43 | default: 44 | return `↓ ${formatTraffic(download)}/s` 45 | } 46 | } 47 | 48 | const columnHelper = createColumnHelper() 49 | 50 | export default function Connections () { 51 | const { translation, lang } = useI18n() 52 | const t = useMemo(() => translation('Connections').t, [translation]) 53 | const connStreamReader = useConnectionStreamReader() 54 | const readerRef = useSyncedRef(connStreamReader) 55 | const client = useClient() 56 | const cardRef = useRef(null) 57 | 58 | // total 59 | const [traffic, setTraffic] = useObject({ 60 | uploadTotal: 0, 61 | downloadTotal: 0, 62 | }) 63 | 64 | // close all connections 65 | const { visible, show, hide } = useVisible() 66 | function handleCloseConnections () { 67 | client.closeAllConnections().finally(() => hide()) 68 | } 69 | 70 | // connections 71 | const { connections, feed, save, toggleSave } = useConnections() 72 | const data: FormatConnection[] = useMemo(() => connections.map( 73 | c => ({ 74 | id: c.id, 75 | host: `${c.metadata.host || c.metadata.destinationIP}:${c.metadata.destinationPort}`, 76 | chains: c.chains.slice().reverse().join(' / '), 77 | rule: c.rulePayload ? `${c.rule} :: ${c.rulePayload}` : c.rule, 78 | time: new Date(c.start).getTime(), 79 | upload: c.upload, 80 | download: c.download, 81 | sourceIP: c.metadata.sourceIP, 82 | type: c.metadata.type, 83 | network: c.metadata.network.toUpperCase(), 84 | process: c.metadata.processPath, 85 | speed: { upload: c.uploadSpeed, download: c.downloadSpeed }, 86 | completed: !!c.completed, 87 | original: c, 88 | }), 89 | ), [connections]) 90 | const devices = useMemo(() => { 91 | const gb = groupBy(connections, 'metadata.sourceIP') 92 | return Object.keys(gb) 93 | .map(key => ({ label: key, number: gb[key].length })) 94 | .sort((a, b) => a.label.localeCompare(b.label)) 95 | }, [connections]) 96 | 97 | // table 98 | const pinRef = useRef(null) 99 | const intersection = useIntersectionObserver(pinRef, { threshold: [1] }) 100 | const columns = useMemo( 101 | () => [ 102 | columnHelper.accessor(Columns.Host, { minSize: 260, size: 260, header: t(`columns.${Columns.Host}`) }), 103 | columnHelper.accessor(Columns.Network, { minSize: 80, size: 80, header: t(`columns.${Columns.Network}`) }), 104 | columnHelper.accessor(Columns.Type, { minSize: 100, size: 100, header: t(`columns.${Columns.Type}`) }), 105 | columnHelper.accessor(Columns.Chains, { minSize: 200, size: 200, header: t(`columns.${Columns.Chains}`) }), 106 | columnHelper.accessor(Columns.Rule, { minSize: 140, size: 140, header: t(`columns.${Columns.Rule}`) }), 107 | columnHelper.accessor(Columns.Process, { minSize: 100, size: 100, header: t(`columns.${Columns.Process}`), cell: cell => cell.getValue() ? basePath(cell.getValue()!) : '-' }), 108 | columnHelper.accessor( 109 | row => [row.speed.upload, row.speed.download], 110 | { 111 | id: Columns.Speed, 112 | header: t(`columns.${Columns.Speed}`), 113 | minSize: 200, 114 | size: 200, 115 | sortDescFirst: true, 116 | sortingFn (rowA, rowB) { 117 | const speedA = rowA.original?.speed ?? { upload: 0, download: 0 } 118 | const speedB = rowB.original?.speed ?? { upload: 0, download: 0 } 119 | return speedA.download === speedB.download 120 | ? speedA.upload - speedB.upload 121 | : speedA.download - speedB.download 122 | }, 123 | cell: cell => formatSpeed(cell.getValue()[0], cell.getValue()[1]), 124 | }, 125 | ), 126 | columnHelper.accessor(Columns.Upload, { minSize: 100, size: 100, header: t(`columns.${Columns.Upload}`), cell: cell => formatTraffic(cell.getValue()) }), 127 | columnHelper.accessor(Columns.Download, { minSize: 100, size: 100, header: t(`columns.${Columns.Download}`), cell: cell => formatTraffic(cell.getValue()) }), 128 | columnHelper.accessor(Columns.SourceIP, { minSize: 140, size: 140, header: t(`columns.${Columns.SourceIP}`), filterFn: 'equals' }), 129 | columnHelper.accessor( 130 | Columns.Time, 131 | { 132 | minSize: 120, 133 | size: 120, 134 | header: t(`columns.${Columns.Time}`), 135 | cell: cell => fromNow(new Date(cell.getValue()), lang), 136 | sortingFn: (rowA, rowB) => (rowB.original?.time ?? 0) - (rowA.original?.time ?? 0), 137 | }, 138 | ), 139 | ], 140 | [lang, t], 141 | ) 142 | 143 | useLayoutEffect(() => { 144 | function handleConnection (snapshots: API.Snapshot[]) { 145 | for (const snapshot of snapshots) { 146 | setTraffic({ 147 | uploadTotal: snapshot.uploadTotal, 148 | downloadTotal: snapshot.downloadTotal, 149 | }) 150 | 151 | feed(snapshot.connections) 152 | } 153 | } 154 | 155 | connStreamReader?.subscribe('data', handleConnection) 156 | return () => { 157 | connStreamReader?.unsubscribe('data', handleConnection) 158 | } 159 | }, [connStreamReader, feed, setTraffic]) 160 | useUnmountEffect(() => { 161 | readerRef.current?.destory() 162 | }) 163 | 164 | const instance = useReactTable({ 165 | data, 166 | columns, 167 | getCoreRowModel: getCoreRowModel(), 168 | getSortedRowModel: getSortedRowModel(), 169 | getFilteredRowModel: getFilteredRowModel(), 170 | initialState: { 171 | sorting: [{ id: Columns.Time, desc: false }], 172 | }, 173 | columnResizeMode: 'onChange', 174 | enableColumnResizing: true, 175 | }) 176 | 177 | const headerGroup = instance.getHeaderGroups()[0] 178 | 179 | // filter 180 | const [device, setDevice] = useState('') 181 | function handleDeviceSelected (label: string) { 182 | setDevice(label) 183 | instance.getColumn(Columns.SourceIP)?.setFilterValue(label || undefined) 184 | } 185 | 186 | // click item 187 | const [drawerState, setDrawerState] = useObject({ 188 | visible: false, 189 | selectedID: '', 190 | connection: {} as Partial, 191 | }) 192 | function handleConnectionClosed () { 193 | setDrawerState(d => { d.connection.completed = true }) 194 | client.closeConnection(drawerState.selectedID) 195 | } 196 | const latestConntion = useSyncedRef(drawerState.connection) 197 | useEffect(() => { 198 | const conn = data.find(c => c.id === drawerState.selectedID)?.original 199 | if (conn) { 200 | setDrawerState(d => { 201 | d.connection = { ...conn } 202 | if (drawerState.selectedID === latestConntion.current.id) { 203 | d.connection.completed = latestConntion.current.completed 204 | } 205 | }) 206 | } else if (Object.keys(latestConntion.current).length !== 0 && !latestConntion.current.completed) { 207 | setDrawerState(d => { d.connection.completed = true }) 208 | } 209 | }, [data, drawerState.selectedID, latestConntion, setDrawerState]) 210 | 211 | const scrolled = useMemo(() => (intersection?.intersectionRatio ?? 0) < 1, [intersection]) 212 | const headers = headerGroup.headers.map((header, idx) => { 213 | const column = header.column 214 | const id = column.id 215 | return ( 216 | 225 |
    226 | { flexRender(header.column.columnDef.header, header.getContext()) } 227 | { 228 | column.getIsSorted() !== false 229 | ? column.getIsSorted() === 'desc' ? ' ↓' : ' ↑' 230 | : null 231 | } 232 |
    233 | { idx !== headerGroup.headers.length - 1 && 234 |
    238 | } 239 | 240 | ) 241 | }) 242 | 243 | const content = instance.getRowModel().rows.map(row => { 244 | return ( 245 | setDrawerState({ visible: true, selectedID: row.original?.id })}> 249 | { 250 | row.getAllCells().map(cell => { 251 | const classname = classnames( 252 | 'connections-block', 253 | { 'text-center': shouldCenter.has(cell.column.id), completed: row.original?.completed }, 254 | { 255 | fixed: cell.column.id === Columns.Host, 256 | shadow: scrolled && cell.column.id === Columns.Host, 257 | }, 258 | ) 259 | return ( 260 | 264 | { flexRender(cell.column.columnDef.cell, cell.getContext()) } 265 | 266 | ) 267 | }) 268 | } 269 | 270 | ) 271 | }) 272 | 273 | return ( 274 |
    275 |
    276 | 277 | {`(${t('total.text')}: ${t('total.upload')} ${formatTraffic(traffic.uploadTotal)} ${t('total.download')} ${formatTraffic(traffic.downloadTotal)})`} 278 | 279 | {t('keepClosed')} 280 | 281 |
    282 | { devices.length > 1 && } 283 | 284 |
    285 | 286 | 287 | 288 | { headers } 289 | 290 | 291 | 292 | { content } 293 | 294 |
    295 |
    296 |
    297 | {t('closeAll.content')} 298 | 299 |
    300 | {t('info.title')} 301 | setDrawerState('visible', false)} /> 302 |
    303 | 304 |
    305 | 306 |
    307 |
    308 |
    309 | ) 310 | } 311 | -------------------------------------------------------------------------------- /src/containers/Connections/store.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer' 2 | import { useState, useMemo, useRef, useCallback } from 'react' 3 | 4 | import type * as API from '@lib/request' 5 | 6 | export type Connection = API.Connections & { completed?: boolean, uploadSpeed: number, downloadSpeed: number } 7 | 8 | export interface FormatConnection { 9 | id: string 10 | host: string 11 | chains: string 12 | rule: string 13 | time: number 14 | upload: number 15 | download: number 16 | type: string 17 | network: string 18 | process?: string 19 | sourceIP: string 20 | speed: { 21 | upload: number 22 | download: number 23 | } 24 | completed: boolean 25 | original: Connection 26 | } 27 | 28 | class Store { 29 | protected connections = new Map() 30 | 31 | protected saveDisconnection = false 32 | 33 | appendToSet (connections: API.Connections[]) { 34 | const mapping = connections.reduce( 35 | (map, c) => map.set(c.id, c), new Map(), 36 | ) 37 | 38 | for (const id of this.connections.keys()) { 39 | if (!mapping.has(id)) { 40 | if (!this.saveDisconnection) { 41 | this.connections.delete(id) 42 | } else { 43 | const connection = this.connections.get(id) 44 | if (connection != null) { 45 | this.connections.set(id, produce(connection, draft => { 46 | draft.completed = true 47 | draft.uploadSpeed = 0 48 | draft.downloadSpeed = 0 49 | })) 50 | } 51 | } 52 | } 53 | } 54 | 55 | for (const id of mapping.keys()) { 56 | if (!this.connections.has(id)) { 57 | this.connections.set(id, { ...mapping.get(id)!, uploadSpeed: 0, downloadSpeed: 0 }) 58 | continue 59 | } 60 | 61 | const c = this.connections.get(id)! 62 | const n = mapping.get(id)! 63 | this.connections?.set(id, { ...n, uploadSpeed: n.upload - c.upload, downloadSpeed: n.download - c.download }) 64 | } 65 | } 66 | 67 | toggleSave () { 68 | if (this.saveDisconnection) { 69 | this.saveDisconnection = false 70 | for (const id of this.connections.keys()) { 71 | if (this.connections?.get(id)?.completed) { 72 | this.connections.delete(id) 73 | } 74 | } 75 | } else { 76 | this.saveDisconnection = true 77 | } 78 | 79 | return this.saveDisconnection 80 | } 81 | 82 | getConnections () { 83 | return [...this.connections.values()] 84 | } 85 | } 86 | 87 | export function useConnections () { 88 | const store = useMemo(() => new Store(), []) 89 | const shouldFlush = useRef(true) 90 | const [connections, setConnections] = useState([]) 91 | const [save, setSave] = useState(false) 92 | 93 | const feed = useCallback(function (connections: API.Connections[]) { 94 | store.appendToSet(connections) 95 | if (shouldFlush.current) { 96 | setConnections(store.getConnections()) 97 | } 98 | 99 | shouldFlush.current = !shouldFlush.current 100 | }, [store]) 101 | 102 | const toggleSave = useCallback(function () { 103 | const state = store.toggleSave() 104 | setSave(state) 105 | 106 | if (!state) { 107 | setConnections(store.getConnections()) 108 | } 109 | 110 | shouldFlush.current = true 111 | }, [store]) 112 | 113 | return { connections, feed, toggleSave, save } 114 | } 115 | -------------------------------------------------------------------------------- /src/containers/Connections/style.scss: -------------------------------------------------------------------------------- 1 | .connections-card { 2 | display: flex; 3 | flex: 1; 4 | margin-top: 10px; 5 | padding: 0; 6 | overflow: hidden; 7 | 8 | .connections-th { 9 | $height: 30px; 10 | 11 | display: inline-block; 12 | position: relative; 13 | text-align: center; 14 | color: $color-gray-darken; 15 | background: $color-gray-light; 16 | height: $height; 17 | line-height: $height; 18 | font-weight: 500; 19 | font-size: 14px; 20 | cursor: pointer; 21 | user-select: none; 22 | -webkit-user-select: none; 23 | 24 | &.resizing .connections-resizer { 25 | opacity: 1; 26 | cursor: col-resize; 27 | } 28 | 29 | &.fixed { 30 | position: sticky !important; 31 | left: -0.1px; 32 | z-index: 99; 33 | &.shadow { 34 | box-shadow: inset -9px 0 8px -14px $color-black; 35 | } 36 | } 37 | } 38 | 39 | .connections-resizer { 40 | $padding: 8px; 41 | $width: 20px; 42 | 43 | position: absolute; 44 | opacity: 0; 45 | right: math.div($width, -2); 46 | top: $padding; 47 | bottom: $padding; 48 | width: $width; 49 | transition: opacity 0.3s ease; 50 | z-index: 10; 51 | font-size: 14px; 52 | font-weight: 300; 53 | touch-action: none; 54 | cursor: col-resize; 55 | 56 | &::before { 57 | content: ''; 58 | display: block; 59 | position: absolute; 60 | left: math.div($width, 2); 61 | transform: translateX(-1px); 62 | width: 2px; 63 | height: 100%; 64 | background-color: rgba($color-gray-darken, 60%); 65 | } 66 | } 67 | 68 | .connections-header { 69 | position: sticky; 70 | top: 0; 71 | z-index: 999; 72 | white-space: nowrap; 73 | 74 | &:hover .connections-resizer { 75 | opacity: 1; 76 | } 77 | } 78 | 79 | .connections-block { 80 | display: inline-block; 81 | font-size: 14px; 82 | line-height: 36px; 83 | padding: 0 10px; 84 | color: $color-primary-darken; 85 | overflow: hidden; 86 | text-overflow: ellipsis; 87 | white-space: nowrap; 88 | 89 | &.completed { 90 | background-color: darken($color-gray-light, 3%); 91 | color: rgba($color-primary-darken, 50%); 92 | } 93 | 94 | &.fixed { 95 | position: sticky; 96 | left: 0; 97 | z-index: 998; 98 | background-color: $color-white; 99 | &.shadow { 100 | box-shadow: inset -9px 0 8px -14px $color-black; 101 | } 102 | } 103 | } 104 | } 105 | 106 | .connections-filter { 107 | color: $color-primary-dark; 108 | font-size: 14px; 109 | line-height: 20px; 110 | margin-left: 15px; 111 | text-shadow: 0 0 6px rgba($color: $color-primary-dark, $alpha: 0.4); 112 | cursor: pointer; 113 | 114 | &.dangerous { 115 | color: $color-red; 116 | text-shadow: 0 0 6px rgba($color: $color-primary, $alpha: 0.2); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/containers/ExternalControllerDrawer/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from 'jotai' 2 | import { useEffect } from 'react' 3 | 4 | import { Modal, Input, Alert, Button, error } from '@components' 5 | import { useObject } from '@lib/hook' 6 | import { useI18n, useAPIInfo, identityAtom } from '@stores' 7 | import { hostSelectIdxStorageAtom, hostsStorageAtom } from '@stores/request' 8 | import './style.scss' 9 | 10 | export default function ExternalController () { 11 | const { translation } = useI18n() 12 | const { t } = translation('Settings') 13 | const { hostname, port, secret } = useAPIInfo() 14 | const [identity, setIdentity] = useAtom(identityAtom) 15 | const [value, set] = useObject({ 16 | hostname: '', 17 | port: '', 18 | secret: '', 19 | }) 20 | 21 | useEffect(() => { 22 | set({ hostname, port, secret }) 23 | }, [hostname, port, secret, set]) 24 | 25 | const [hosts, setter] = useAtom(hostsStorageAtom) 26 | const [hostSelectIdx, setHostSelectIdx] = useAtom(hostSelectIdxStorageAtom) 27 | 28 | function handleOk () { 29 | const { hostname, port, secret } = value 30 | setter([{ hostname, port, secret }]) 31 | } 32 | 33 | function handleAdd () { 34 | const { hostname, port, secret } = value 35 | const nextHosts = [...hosts, { hostname, port, secret }] 36 | setter(nextHosts) 37 | setHostSelectIdx(nextHosts.length - 1) 38 | } 39 | 40 | function handleDelete () { 41 | const { hostname, port } = value 42 | const idx = hosts.findIndex(h => h.hostname === hostname && h.port === port) 43 | if (idx === -1) { 44 | error(t('externalControllerSetting.deleteErrorText')) 45 | return 46 | } 47 | 48 | const nextHosts = [...hosts.slice(0, idx), ...hosts.slice(idx + 1)] 49 | setter(nextHosts) 50 | if (hostSelectIdx >= idx) { 51 | setHostSelectIdx(0) 52 | } 53 | } 54 | 55 | const footerExtra = ( 56 |
    57 | 58 | 59 |
    60 | ) 61 | 62 | return ( 63 | setIdentity(true)} 70 | onOk={handleOk} 71 | > 72 | 73 |

    {t('externalControllerSetting.note')}

    74 |
    75 |
    76 | {t('externalControllerSetting.host')} 77 | set('hostname', hostname)} 83 | onEnter={handleOk} 84 | /> 85 |
    86 |
    87 |
    {t('externalControllerSetting.port')}
    88 | set('port', port)} 94 | onEnter={handleOk} 95 | /> 96 |
    97 |
    98 |
    {t('externalControllerSetting.secret')}
    99 | set('secret', secret)} 105 | onEnter={handleOk} 106 | /> 107 |
    108 |
    109 | ) 110 | } 111 | -------------------------------------------------------------------------------- /src/containers/ExternalControllerDrawer/style.scss: -------------------------------------------------------------------------------- 1 | .external-controller { 2 | .alert { 3 | margin: 10px 0; 4 | } 5 | } 6 | 7 | @media (max-width: 768px) { 8 | .external-controller { 9 | // hack 10 | .alert { 11 | display: none; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/containers/Logs/index.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import { lowerCase } from 'lodash-es' 3 | import { useLayoutEffect, useEffect, useRef, useState } from 'react' 4 | 5 | import { Select, Card, Header } from '@components' 6 | import { type Log } from '@models/Log' 7 | import { useConfig, useGeneral, useI18n, useLogsStreamReader } from '@stores' 8 | 9 | import './style.scss' 10 | 11 | const logLevelOptions = [ 12 | { label: 'Default', value: '' }, 13 | { label: 'Debug', value: 'debug' }, 14 | { label: 'Info', value: 'info' }, 15 | { label: 'Warn', value: 'warning' }, 16 | { label: 'Error', value: 'error' }, 17 | { label: 'Silent', value: 'silent' }, 18 | ] 19 | const logMap = new Map([ 20 | ['debug', 'text-teal-500'], 21 | ['info', 'text-sky-500'], 22 | ['warning', 'text-pink-500'], 23 | ['error', 'text-rose-500'], 24 | ]) 25 | 26 | export default function Logs () { 27 | const listRef = useRef(null) 28 | const logsRef = useRef([]) 29 | const [logs, setLogs] = useState([]) 30 | const { translation } = useI18n() 31 | const { data: { logLevel }, set: setConfig } = useConfig() 32 | const { general: { logLevel: configLevel } } = useGeneral() 33 | const { t } = translation('Logs') 34 | const logsStreamReader = useLogsStreamReader() 35 | const scrollHeightRef = useRef(listRef.current?.scrollHeight ?? 0) 36 | 37 | const isConfigSilent = lowerCase(configLevel) === 'silent' 38 | 39 | useLayoutEffect(() => { 40 | const ul = listRef.current 41 | if (ul != null && scrollHeightRef.current === (ul.scrollTop + ul.clientHeight)) { 42 | ul.scrollTop = ul.scrollHeight - ul.clientHeight 43 | } 44 | scrollHeightRef.current = ul?.scrollHeight ?? 0 45 | }) 46 | 47 | useEffect(() => { 48 | function handleLog (newLogs: Log[]) { 49 | logsRef.current = logsRef.current.slice().concat(newLogs.map(d => ({ ...d, time: new Date() }))) 50 | setLogs(logsRef.current) 51 | } 52 | 53 | if (logsStreamReader != null) { 54 | logsStreamReader.subscribe('data', handleLog) 55 | logsRef.current = logsStreamReader.buffer() 56 | setLogs(logsRef.current) 57 | } 58 | return () => logsStreamReader?.unsubscribe('data', handleLog) 59 | }, [logsStreamReader]) 60 | 61 | return ( 62 |
    63 |
    64 | {t('levelLabel')}: 65 | setHostSelectIdx(idx)} 113 | /> 114 | !isClashX && setIdentity(false)}> 117 | 编辑 118 | 119 | 120 | ) 121 | 122 | return ( 123 |
    124 |
    125 | 126 |
    127 |
    128 | {t('labels.startAtLogin')} 129 | 130 |
    131 |
    132 | {t('labels.language')} 133 | changeLanguage(lang as Lang)} /> 134 |
    135 |
    136 |
    137 |
    138 | {t('labels.setAsSystemProxy')} 139 | 144 |
    145 |
    146 | {t('labels.allowConnectFromLan')} 147 | 148 |
    149 |
    150 |
    151 | 152 | 153 |
    154 |
    155 | {t('labels.proxyMode')} 156 | 161 |
    162 |
    163 | {t('labels.socks5ProxyPort')} 164 | set('socks5ProxyPort', +socks5ProxyPort)} 169 | onBlur={handleSocksPortSave} 170 | /> 171 |
    172 |
    173 |
    174 |
    175 | {t('labels.httpProxyPort')} 176 | set('httpProxyPort', +httpProxyPort)} 181 | onBlur={handleHttpPortSave} 182 | /> 183 |
    184 |
    185 | {t('labels.mixedProxyPort')} 186 | set('mixedProxyPort', +mixedProxyPort)} 191 | onBlur={handleMixedPortSave} 192 | /> 193 |
    194 |
    195 |
    196 | {/* 197 | 198 | 199 | 200 |

    {t('versionString')}

    201 | {t('checkUpdate')} 202 |
    */} 203 |
    204 | ) 205 | } 206 | -------------------------------------------------------------------------------- /src/containers/Settings/style.scss: -------------------------------------------------------------------------------- 1 | .settings-card { 2 | margin-top: 10px; 3 | padding: 0.75rem 0; 4 | 5 | .label { 6 | font-size: 14px; 7 | color: $color-primary-darken; 8 | } 9 | 10 | .external-controller { 11 | font-size: 14px; 12 | color: $color-primary-darken; 13 | display: flex; 14 | justify-content: flex-end; 15 | font-weight: normal; 16 | line-height: 17px; 17 | 18 | &.modify-btn { 19 | color: $color-primary; 20 | cursor: pointer; 21 | } 22 | } 23 | } 24 | 25 | .clash-version { 26 | position: relative; 27 | margin-top: 10px; 28 | padding: 20px 45px; 29 | display: flex; 30 | 31 | .check-icon { 32 | width: 24px; 33 | height: 24px; 34 | border-radius: 50%; 35 | background: linear-gradient(135deg, $color-primary, $color-primary-dark); 36 | display: flex; 37 | justify-content: center; 38 | align-items: center; 39 | 40 | > i { 41 | transform: scale(0.5); 42 | color: $color-white; 43 | font-weight: bold; 44 | } 45 | } 46 | 47 | .version-info { 48 | margin-left: 10px; 49 | font-size: 14px; 50 | line-height: 24px; 51 | color: $color-primary-darken; 52 | } 53 | 54 | .check-update-btn { 55 | position: absolute; 56 | right: 45px; 57 | font-size: 14px; 58 | line-height: 24px; 59 | color: $color-gray-dark; 60 | cursor: pointer; 61 | transition: all 150ms ease; 62 | 63 | &:hover { 64 | color: $color-primary-darken; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/containers/Sidebar/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | import { NavLink, useLocation } from 'react-router-dom' 3 | 4 | import logo from '@assets/logo.png' 5 | import { type Lang, type Language } from '@i18n' 6 | import { useI18n, useVersion, useClashXData } from '@stores' 7 | import './style.scss' 8 | 9 | interface SidebarProps { 10 | routes: Array<{ 11 | path: string 12 | name: string 13 | noMobile?: boolean 14 | }> 15 | } 16 | 17 | export default function Sidebar (props: SidebarProps) { 18 | const { routes } = props 19 | const { translation } = useI18n() 20 | const { version, premium } = useVersion() 21 | const { data } = useClashXData() 22 | const { t } = translation('SideBar') 23 | const location = useLocation() 24 | 25 | const navlinks = routes.map( 26 | ({ path, name, noMobile }) => ( 27 |
  • 28 | classnames({ active: isActive })}> 29 | { t(name as keyof typeof Language[Lang]['SideBar']) } 30 | 31 |
  • 32 | ), 33 | ) 34 | 35 | return ( 36 |
    37 | logo 38 |
      39 | { navlinks } 40 |
    41 |
    42 | Clash{ data?.isClashX && 'X' } { t('Version') } 43 | { version } 44 | { premium && Premium } 45 |
    46 |
    47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /src/containers/Sidebar/style.scss: -------------------------------------------------------------------------------- 1 | .sidebar { 2 | position: fixed; 3 | top: 0; 4 | bottom: 0; 5 | left: 0; 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | flex-shrink: 0; 10 | width: 160px; 11 | user-select: none; 12 | } 13 | 14 | .sidebar-logo { 15 | margin-top: 50px; 16 | width: 60px; 17 | height: 60px; 18 | } 19 | 20 | .sidebar-menu { 21 | display: flex; 22 | flex-direction: column; 23 | flex: 1; 24 | margin-top: 12px; 25 | 26 | .item { 27 | display: block; 28 | margin-top: 18px; 29 | 30 | > a { 31 | display: block; 32 | width: 120px; 33 | height: 36px; 34 | line-height: 36px; 35 | font-size: 14px; 36 | border-radius: 18px; 37 | text-align: center; 38 | } 39 | 40 | > a, 41 | a:active, 42 | a:visited { 43 | color: $color-gray-darken; 44 | text-decoration: none; 45 | } 46 | 47 | > a.active { 48 | background: linear-gradient(135deg, $color-primary, $color-primary-dark); 49 | color: $color-white; 50 | box-shadow: 0 2px 8px rgba($color: $color-primary-dark, $alpha: 0.5); 51 | } 52 | } 53 | } 54 | 55 | .sidebar-version { 56 | display: flex; 57 | flex-direction: column; 58 | align-items: center; 59 | padding-bottom: 20px; 60 | } 61 | 62 | .sidebar-version-label { 63 | font-size: 14px; 64 | color: $color-primary-dark; 65 | text-shadow: 0 2px 6px rgba($color: $color-primary-dark, $alpha: 0.4); 66 | } 67 | 68 | .sidebar-version-text { 69 | text-align: center; 70 | font-size: 14px; 71 | margin: 8px 0; 72 | padding: 0 10px; 73 | color: $color-primary-darken; 74 | } 75 | 76 | @media (max-width: 768px) { 77 | .sidebar { 78 | width: 100%; 79 | height: 60px; 80 | flex-direction: row; 81 | background: $background; 82 | z-index: 10; 83 | } 84 | 85 | .sidebar-logo { 86 | margin: 0 15px; 87 | width: 36px; 88 | height: 36px; 89 | } 90 | 91 | .sidebar-menu { 92 | flex: 1; 93 | flex-direction: row; 94 | justify-content: center; 95 | margin-top: 0; 96 | overflow-x: scroll; 97 | padding: 10px; 98 | 99 | &::-webkit-scrollbar { 100 | display: none; 101 | } 102 | 103 | .item { 104 | margin: 0 3px; 105 | 106 | > a { 107 | width: 80px; 108 | height: 32px; 109 | line-height: 32px; 110 | } 111 | } 112 | 113 | .item.no-mobile { 114 | display: none; 115 | } 116 | } 117 | 118 | .sidebar-version { 119 | display: none; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/i18n/en_US.ts: -------------------------------------------------------------------------------- 1 | const EN = { 2 | SideBar: { 3 | Proxies: 'Proxies', 4 | Overview: 'Overview', 5 | Logs: 'Logs', 6 | Rules: 'Rules', 7 | Settings: 'Setting', 8 | Connections: 'Connections', 9 | Version: 'Version', 10 | }, 11 | Settings: { 12 | title: 'Settings', 13 | labels: { 14 | startAtLogin: 'Start at login', 15 | language: 'language', 16 | setAsSystemProxy: 'Set as system proxy', 17 | allowConnectFromLan: 'Allow connect from Lan', 18 | proxyMode: 'Mode', 19 | socks5ProxyPort: 'Socks5 proxy port', 20 | httpProxyPort: 'HTTP proxy port', 21 | mixedProxyPort: 'Mixed proxy port', 22 | externalController: 'External controller', 23 | }, 24 | values: { 25 | cn: '中文', 26 | en: 'English', 27 | global: 'Global', 28 | rules: 'Rules', 29 | direct: 'Direct', 30 | script: 'Script', 31 | }, 32 | versionString: 'Current ClashX is the latest version:{{version}}', 33 | checkUpdate: 'Check Update', 34 | externalControllerSetting: { 35 | title: 'External Controller', 36 | note: 'Please note that modifying this configuration will only configure Dashboard. Will not modify your Clash configuration file. Please make sure that the external controller address matches the address in the Clash configuration file, otherwise, Dashboard will not be able to connect to Clash.', 37 | host: 'Host', 38 | port: 'Port', 39 | secret: 'Secret', 40 | addText: 'Add', 41 | deleteText: 'Delete', 42 | deleteErrorText: 'Host not found', 43 | }, 44 | }, 45 | Logs: { 46 | title: 'Logs', 47 | levelLabel: 'Log level', 48 | }, 49 | Rules: { 50 | title: 'Rules', 51 | providerTitle: 'Providers', 52 | providerUpdateTime: 'Last updated at', 53 | ruleCount: 'Rule count', 54 | }, 55 | Connections: { 56 | title: 'Connections', 57 | keepClosed: 'Keep closed connections', 58 | total: { 59 | text: 'total', 60 | upload: 'upload', 61 | download: 'download', 62 | }, 63 | closeAll: { 64 | title: 'Warning', 65 | content: 'This would close all connections', 66 | }, 67 | filter: { 68 | all: 'All', 69 | }, 70 | columns: { 71 | host: 'Host', 72 | network: 'Network', 73 | type: 'Type', 74 | chains: 'Chains', 75 | process: 'Process', 76 | rule: 'Rule', 77 | time: 'Time', 78 | speed: 'Speed', 79 | upload: 'Upload', 80 | download: 'Download', 81 | sourceIP: 'Source IP', 82 | }, 83 | info: { 84 | title: 'Connection', 85 | id: 'ID', 86 | host: 'Host', 87 | hostEmpty: 'Empty', 88 | dstIP: 'IP', 89 | dstIPEmpty: 'Empty', 90 | srcIP: 'Source', 91 | upload: 'Up', 92 | download: 'Down', 93 | network: 'Network', 94 | process: 'Process', 95 | processPath: 'Path', 96 | inbound: 'Inbound', 97 | rule: 'Rule', 98 | chains: 'Chains', 99 | status: 'Status', 100 | opening: 'Open', 101 | closed: 'Closed', 102 | closeConnection: 'Close', 103 | }, 104 | }, 105 | Proxies: { 106 | title: 'Proxies', 107 | editDialog: { 108 | title: 'Edit Proxy', 109 | color: 'Color', 110 | name: 'Name', 111 | type: 'Type', 112 | server: 'Server', 113 | port: 'Port', 114 | password: 'Password', 115 | cipher: 'Cipher', 116 | obfs: 'Obfs', 117 | 'obfs-host': 'Obfs-host', 118 | uuid: 'UUID', 119 | alterId: 'AlterId', 120 | tls: 'TLS', 121 | }, 122 | groupTitle: 'Policy Group', 123 | providerTitle: 'Providers', 124 | providerUpdateTime: 'Last updated at', 125 | expandText: 'Expand', 126 | collapseText: 'Collapse', 127 | speedTestText: 'Speed Test', 128 | breakConnectionsText: 'Close connections which include the group', 129 | }, 130 | Modal: { 131 | ok: 'Ok', 132 | cancel: 'Cancel', 133 | }, 134 | } as const 135 | 136 | export default EN 137 | -------------------------------------------------------------------------------- /src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import { type IsEqual } from 'type-fest' 2 | 3 | import { type Infer } from '@lib/type' 4 | 5 | import en_US from './en_US' 6 | import zh_CN from './zh_CN' 7 | 8 | export const Language = { 9 | en_US, 10 | zh_CN, 11 | } 12 | 13 | export type Lang = keyof typeof Language 14 | 15 | type US = typeof Language.en_US 16 | type CN = typeof Language.zh_CN 17 | 18 | // type guard for US and CN 19 | type TrueGuard = T 20 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 21 | type _equalGuard = TrueGuard, Infer>> 22 | 23 | export type LocalizedType = US 24 | 25 | export const locales = Object.keys(Language) 26 | 27 | export function getDefaultLanguage (): Lang { 28 | for (const language of window.navigator.languages) { 29 | if (language.includes('zh')) { 30 | return 'zh_CN' 31 | } else if (language.includes('us')) { 32 | return 'en_US' 33 | } 34 | } 35 | 36 | return 'en_US' 37 | } 38 | -------------------------------------------------------------------------------- /src/i18n/zh_CN.ts: -------------------------------------------------------------------------------- 1 | const CN = { 2 | SideBar: { 3 | Proxies: '代理', 4 | Overview: '总览', 5 | Logs: '日志', 6 | Rules: '规则', 7 | Settings: '设置', 8 | Connections: '连接', 9 | Version: '版本', 10 | }, 11 | Settings: { 12 | title: '设置', 13 | labels: { 14 | startAtLogin: '开机时启动', 15 | language: '语言', 16 | setAsSystemProxy: '设置为系统代理', 17 | allowConnectFromLan: '允许来自局域网的连接', 18 | proxyMode: '代理模式', 19 | socks5ProxyPort: 'Socks5 代理端口', 20 | httpProxyPort: 'HTTP 代理端口', 21 | mixedProxyPort: '混合代理端口', 22 | externalController: '外部控制设置', 23 | }, 24 | values: { 25 | cn: '中文', 26 | en: 'English', 27 | global: '全局', 28 | rules: '规则', 29 | direct: '直连', 30 | script: '脚本', 31 | }, 32 | versionString: '当前 ClashX 已是最新版本:{{version}}', 33 | checkUpdate: '检查更新', 34 | externalControllerSetting: { 35 | title: '编辑外部控制设置', 36 | note: '请注意,修改该配置项并不会修改你的 Clash 配置文件,请确认修改后的外部控制地址和 Clash 配置文件内的地址一致,否则会导致 Dashboard 无法连接。', 37 | host: 'Host', 38 | port: '端口', 39 | secret: '密钥', 40 | addText: '添 加', 41 | deleteText: '删 除', 42 | deleteErrorText: '没有找到该 Host', 43 | }, 44 | }, 45 | Logs: { 46 | title: '日志', 47 | levelLabel: '日志等级', 48 | }, 49 | Rules: { 50 | title: '规则', 51 | providerTitle: '规则集', 52 | providerUpdateTime: '最后更新于', 53 | ruleCount: '规则条数', 54 | }, 55 | Connections: { 56 | title: '连接', 57 | keepClosed: '保留关闭连接', 58 | total: { 59 | text: '总量', 60 | upload: '上传', 61 | download: '下载', 62 | }, 63 | closeAll: { 64 | title: '警告', 65 | content: '将会关闭所有连接', 66 | }, 67 | filter: { 68 | all: '全部', 69 | }, 70 | columns: { 71 | host: '域名', 72 | network: '网络', 73 | process: '进程', 74 | type: '类型', 75 | chains: '节点链', 76 | rule: '规则', 77 | time: '连接时间', 78 | speed: '速率', 79 | upload: '上传', 80 | download: '下载', 81 | sourceIP: '来源 IP', 82 | }, 83 | info: { 84 | title: '连接信息', 85 | id: 'ID', 86 | host: '域名', 87 | hostEmpty: '空', 88 | dstIP: 'IP', 89 | dstIPEmpty: '空', 90 | srcIP: '来源', 91 | upload: '上传', 92 | download: '下载', 93 | network: '网络', 94 | process: '进程', 95 | processPath: '路径', 96 | inbound: '入口', 97 | rule: '规则', 98 | chains: '代理', 99 | status: '状态', 100 | opening: '连接中', 101 | closed: '已关闭', 102 | closeConnection: '关闭连接', 103 | }, 104 | }, 105 | Proxies: { 106 | title: '代理', 107 | editDialog: { 108 | title: '编辑代理', 109 | color: '颜色', 110 | name: '名字', 111 | type: '类型', 112 | server: '服务器', 113 | port: '端口', 114 | password: '密码', 115 | cipher: '加密方式', 116 | obfs: 'Obfs', 117 | 'obfs-host': 'Obfs-host', 118 | uuid: 'UUID', 119 | alterId: 'AlterId', 120 | tls: 'TLS', 121 | }, 122 | groupTitle: '策略组', 123 | providerTitle: '代理集', 124 | providerUpdateTime: '最后更新于', 125 | expandText: '展开', 126 | collapseText: '收起', 127 | speedTestText: '测速', 128 | breakConnectionsText: '切换时打断包含策略组的连接', 129 | }, 130 | Modal: { 131 | ok: '确 定', 132 | cancel: '取 消', 133 | }, 134 | } as const 135 | 136 | export default CN 137 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { isClashX, setupJsBridge } from '@lib/jsBridge' 2 | 3 | import renderApp from './render' 4 | 5 | /** 6 | * Global entry 7 | * Will check if need setup jsbridge 8 | */ 9 | if (isClashX()) { 10 | setupJsBridge(() => renderApp()) 11 | } else { 12 | renderApp() 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/date.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import relativeTime from 'dayjs/plugin/relativeTime' 3 | import 'dayjs/locale/zh-cn' 4 | 5 | import { type Lang } from '@i18n' 6 | 7 | dayjs.extend(relativeTime) 8 | 9 | export function fromNow (date: Date, lang: Lang): string { 10 | const locale = lang === 'en_US' ? 'en' : 'zh-cn' 11 | return dayjs(date).locale(locale).from(dayjs()) 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/event.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'eventemitter3' 2 | 3 | export enum Action { 4 | SPEED_NOTIFY = 'speed-notify', 5 | } 6 | 7 | class Event { 8 | protected EE = new EventEmitter() 9 | 10 | notifySpeedTest () { 11 | this.EE.emit(Action.SPEED_NOTIFY) 12 | } 13 | 14 | subscribe (event: Action, callback: (data?: T) => void) { 15 | this.EE.addListener(event, callback) 16 | } 17 | 18 | unsubscribe (event: Action, callback: (data?: T) => void) { 19 | this.EE.removeListener(event, callback) 20 | } 21 | } 22 | 23 | export default new Event() 24 | -------------------------------------------------------------------------------- /src/lib/helper.ts: -------------------------------------------------------------------------------- 1 | import { floor } from 'lodash-es' 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-empty-function 4 | export function noop () {} 5 | 6 | export function partition (arr: T[], fn: (arg: T) => boolean): [T[], T[]] { 7 | const left: T[] = [] 8 | const right: T[] = [] 9 | for (const item of arr) { 10 | fn(item) ? left.push(item) : right.push(item) 11 | } 12 | return [left, right] 13 | } 14 | 15 | export function formatTraffic (num: number) { 16 | const s = ['B', 'KiB', 'MiB', 'GiB', 'TiB'] 17 | const exp = Math.floor(Math.log(num || 1) / Math.log(1024)) 18 | return `${floor(num / Math.pow(1024, exp), 2).toFixed(2)} ${s?.[exp] ?? ''}` 19 | } 20 | 21 | export function basePath (path: string) { 22 | return path.replace(/.*[/\\]/, '') 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/hook.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-redeclare */ 2 | import { type Draft } from 'immer' 3 | import { useRef, useEffect, useState, useMemo } from 'react' 4 | import { useImmer } from 'use-immer' 5 | 6 | import { noop } from '@lib/helper' 7 | 8 | export function useObject> (initialValue: T) { 9 | const [copy, rawSet] = useImmer(initialValue) 10 | 11 | const set = useMemo(() => { 12 | function set (data: Partial): void 13 | function set (f: (draft: Draft) => void | T): void 14 | function set> (key: K, value: Draft[K]): void 15 | function set> (data: unknown, value?: Draft[K]): void { 16 | if (typeof data === 'string') { 17 | rawSet(draft => { 18 | const key = data as K 19 | const v = value 20 | draft[key] = v! 21 | }) 22 | } else if (typeof data === 'function') { 23 | rawSet(data as (draft: Draft) => void | T) 24 | } else if (typeof data === 'object') { 25 | rawSet((draft: Draft) => { 26 | const obj = data as Draft 27 | for (const key of Object.keys(obj)) { 28 | const k = key as keyof Draft 29 | draft[k] = obj[k] 30 | } 31 | }) 32 | } 33 | } 34 | return set 35 | }, [rawSet]) 36 | 37 | return [copy, set] as [T, typeof set] 38 | } 39 | 40 | export function useInterval (callback: () => void, delay: number) { 41 | const savedCallback = useRef(noop) 42 | 43 | useEffect(() => { 44 | savedCallback.current = callback 45 | }, [callback]) 46 | 47 | useEffect( 48 | () => { 49 | const handler = () => savedCallback.current() 50 | 51 | if (delay !== null) { 52 | const id = setInterval(handler, delay) 53 | return () => clearInterval(id) 54 | } 55 | }, 56 | [delay], 57 | ) 58 | } 59 | 60 | export function useRound (list: T[], defidx = 0) { 61 | if (list.length < 2) { 62 | throw new Error('List requires at least two elements') 63 | } 64 | 65 | const [state, setState] = useState(defidx) 66 | 67 | function next () { 68 | setState((state + 1) % list.length) 69 | } 70 | 71 | const current = useMemo(() => list[state], [list, state]) 72 | 73 | return { current, next } 74 | } 75 | 76 | export function useVisible (initial = false) { 77 | const [visible, setVisible] = useState(initial) 78 | 79 | function hide () { 80 | setVisible(false) 81 | } 82 | 83 | function show () { 84 | setVisible(true) 85 | } 86 | return { visible, hide, show } 87 | } 88 | -------------------------------------------------------------------------------- /src/lib/ip.ts: -------------------------------------------------------------------------------- 1 | // copy from https://github.com/sindresorhus/ip-regex 2 | 3 | const v4 = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}' 4 | 5 | const v6segment = '[a-fA-F\\d]{1,4}' 6 | 7 | const v6 = ` 8 | (?: 9 | (?:${v6segment}:){7}(?:${v6segment}|:)| // 1:2:3:4:5:6:7:: 1:2:3:4:5:6:7:8 10 | (?:${v6segment}:){6}(?:${v4}|:${v6segment}|:)| // 1:2:3:4:5:6:: 1:2:3:4:5:6::8 1:2:3:4:5:6::8 1:2:3:4:5:6::1.2.3.4 11 | (?:${v6segment}:){5}(?::${v4}|(?::${v6segment}){1,2}|:)| // 1:2:3:4:5:: 1:2:3:4:5::7:8 1:2:3:4:5::8 1:2:3:4:5::7:1.2.3.4 12 | (?:${v6segment}:){4}(?:(?::${v6segment}){0,1}:${v4}|(?::${v6segment}){1,3}|:)| // 1:2:3:4:: 1:2:3:4::6:7:8 1:2:3:4::8 1:2:3:4::6:7:1.2.3.4 13 | (?:${v6segment}:){3}(?:(?::${v6segment}){0,2}:${v4}|(?::${v6segment}){1,4}|:)| // 1:2:3:: 1:2:3::5:6:7:8 1:2:3::8 1:2:3::5:6:7:1.2.3.4 14 | (?:${v6segment}:){2}(?:(?::${v6segment}){0,3}:${v4}|(?::${v6segment}){1,5}|:)| // 1:2:: 1:2::4:5:6:7:8 1:2::8 1:2::4:5:6:7:1.2.3.4 15 | (?:${v6segment}:){1}(?:(?::${v6segment}){0,4}:${v4}|(?::${v6segment}){1,6}|:)| // 1:: 1::3:4:5:6:7:8 1::8 1::3:4:5:6:7:1.2.3.4 16 | (?::(?:(?::${v6segment}){0,5}:${v4}|(?::${v6segment}){1,7}|:)) // ::2:3:4:5:6:7:8 ::2:3:4:5:6:7:8 ::8 ::1.2.3.4 17 | )(?:%[0-9a-zA-Z]{1,})? // %eth0 %1 18 | `.replace(/\s*\/\/.*$/gm, '').replace(/\n/g, '').trim() 19 | 20 | const v46Exact = new RegExp(`(?:^${v4}$)|(?:^${v6}$)`) 21 | 22 | export function isIP (input: string): boolean { 23 | return v46Exact.test(input) 24 | } 25 | -------------------------------------------------------------------------------- /src/lib/jotai.ts: -------------------------------------------------------------------------------- 1 | import { produce, type Draft } from 'immer' 2 | import { useMemo } from 'react' 3 | 4 | export type WritableDraft = (draft: Draft) => void 5 | 6 | export function useWarpImmerSetter (setter: (f: WritableDraft) => void) { 7 | const set = useMemo(() => { 8 | function set> (key: K, value: Draft[K]): void 9 | function set (data: Partial): void 10 | function set (f: (draft: Draft) => void | T): void 11 | function set> (data: unknown, value?: Draft[K]): void { 12 | if (typeof data === 'string') { 13 | setter((draft: Draft) => { 14 | const key = data as K 15 | const v = value 16 | draft[key] = v! 17 | }) 18 | } else if (typeof data === 'function') { 19 | const fn = data as (draft: Draft) => void | T 20 | setter(draft => fn(draft)) 21 | } else if (typeof data === 'object') { 22 | setter(pre => produce(pre, (draft: Draft) => { 23 | const obj = data as Draft 24 | for (const key of Object.keys(obj)) { 25 | const k = key as keyof Draft 26 | draft[k] = obj[k] 27 | } 28 | })) 29 | } 30 | } 31 | 32 | return set 33 | }, [setter]) 34 | 35 | return set 36 | } 37 | -------------------------------------------------------------------------------- /src/lib/jsBridge.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * For support ClashX runtime 3 | * 4 | * Clash Dashboard will use jsbridge to 5 | * communicate with ClashX 6 | * 7 | * Before React app rendered, jsbridge 8 | * should be checked if initialized, 9 | * and also should checked if it's 10 | * ClashX runtime 11 | * 12 | * @author jas0ncn 13 | */ 14 | 15 | /** 16 | * declare javascript bridge API 17 | */ 18 | export interface JsBridgeAPI { 19 | 20 | /** 21 | * Register a javascript bridge event handle 22 | */ 23 | registerHandler: (eventName: string, callback?: (data: D, responseCallback: (param: P) => void) => void) => void 24 | 25 | /** 26 | * Call a native handle 27 | */ 28 | callHandler: (handleName: string, data?: D, responseCallback?: (responseData: T) => void) => void 29 | 30 | /** 31 | * Who knows 32 | */ 33 | disableJavscriptAlertBoxSafetyTimeout: () => void 34 | 35 | } 36 | 37 | declare global { 38 | 39 | interface Window { 40 | 41 | /** 42 | * Global jsbridge instance 43 | */ 44 | WebViewJavascriptBridge?: JsBridgeAPI | null 45 | 46 | /** 47 | * Global jsbridge init callback 48 | */ 49 | WVJBCallbacks?: JsBridgeCallback[] 50 | 51 | } 52 | 53 | } 54 | 55 | type JsBridgeCallback = (jsbridge: JsBridgeAPI | null) => void 56 | 57 | /** 58 | * Check if perched in ClashX Runtime 59 | */ 60 | export function isClashX () { 61 | return navigator.userAgent === 'ClashX Runtime' 62 | } 63 | 64 | /** 65 | * Closure save jsbridge instance 66 | */ 67 | export let jsBridge: JsBridge | null = null 68 | 69 | /** 70 | * JsBridge class 71 | */ 72 | export class JsBridge { 73 | instance: JsBridgeAPI | null = null 74 | 75 | constructor (callback: () => void) { 76 | if (window.WebViewJavascriptBridge != null) { 77 | this.instance = window.WebViewJavascriptBridge 78 | } 79 | 80 | // init jsbridge 81 | this.initBridge(jsBridge => { 82 | this.instance = jsBridge 83 | callback() 84 | }) 85 | } 86 | 87 | /** 88 | * setup a jsbridge before app render 89 | * @param {Function} cb callback when jsbridge initialized 90 | * @see https://github.com/marcuswestin/WebViewJavascriptBridge 91 | */ 92 | private initBridge (callback: JsBridgeCallback) { 93 | /** 94 | * You need check if inClashX first 95 | */ 96 | if (!isClashX()) { 97 | return callback?.(null) 98 | } 99 | 100 | if (window.WebViewJavascriptBridge != null) { 101 | return callback(window.WebViewJavascriptBridge) 102 | } 103 | 104 | // setup callback 105 | if (window.WVJBCallbacks != null) { 106 | return window.WVJBCallbacks.push(callback) 107 | } 108 | 109 | window.WVJBCallbacks = [callback] 110 | 111 | const WVJBIframe = document.createElement('iframe') 112 | WVJBIframe.style.display = 'none' 113 | WVJBIframe.src = 'https://__bridge_loaded__' 114 | document.documentElement.appendChild(WVJBIframe) 115 | setTimeout(() => document.documentElement.removeChild(WVJBIframe), 0) 116 | } 117 | 118 | public async callHandler (handleName: string, data?: D) { 119 | return await new Promise((resolve) => { 120 | this.instance?.callHandler( 121 | handleName, 122 | data, 123 | resolve, 124 | ) 125 | }) 126 | } 127 | 128 | public async ping () { 129 | return await this.callHandler('ping') 130 | } 131 | 132 | public async readConfigString () { 133 | return await this.callHandler('readConfigString') 134 | } 135 | 136 | public async getPasteboard () { 137 | return await this.callHandler('getPasteboard') 138 | } 139 | 140 | public async getAPIInfo () { 141 | return await this.callHandler<{ host: string, port: string, secret: string }>('apiInfo') 142 | } 143 | 144 | public async setPasteboard (data: string) { 145 | return await this.callHandler('setPasteboard', data) 146 | } 147 | 148 | public async writeConfigWithString (data: string) { 149 | return await this.callHandler('writeConfigWithString', data) 150 | } 151 | 152 | public async setSystemProxy (data: boolean) { 153 | return await this.callHandler('setSystemProxy', data) 154 | } 155 | 156 | public async getStartAtLogin () { 157 | return await this.callHandler('getStartAtLogin') 158 | } 159 | 160 | public async getProxyDelay (name: string) { 161 | return await this.callHandler('speedTest', name) 162 | } 163 | 164 | public async setStartAtLogin (data: boolean) { 165 | return await this.callHandler('setStartAtLogin', data) 166 | } 167 | 168 | public async isSystemProxySet () { 169 | return await this.callHandler('isSystemProxySet') 170 | } 171 | } 172 | 173 | export function setupJsBridge (callback: () => void) { 174 | if (jsBridge != null) { 175 | callback() 176 | return 177 | } 178 | 179 | jsBridge = new JsBridge(callback) 180 | } 181 | -------------------------------------------------------------------------------- /src/lib/request.ts: -------------------------------------------------------------------------------- 1 | import axios, { type AxiosInstance } from 'axios' 2 | 3 | export interface Config { 4 | port: number 5 | 'socks-port': number 6 | 'redir-port': number 7 | 'mixed-port': number 8 | 'allow-lan': boolean 9 | mode: string 10 | 'log-level': string 11 | } 12 | 13 | export interface Rules { 14 | rules: Rule[] 15 | } 16 | 17 | export interface Rule { 18 | type: string 19 | payload: string 20 | proxy: string 21 | } 22 | 23 | export interface Proxies { 24 | proxies: Record 25 | } 26 | 27 | export interface Provider { 28 | name: string 29 | proxies: Array 30 | type: 'Proxy' 31 | vehicleType: 'HTTP' | 'File' | 'Compatible' 32 | updatedAt?: string 33 | } 34 | 35 | export interface RuleProvider { 36 | name: string 37 | type: 'Rule' 38 | vehicleType: 'HTTP' | 'File' 39 | behavior: string 40 | ruleCount: number 41 | updatedAt?: string 42 | } 43 | 44 | export interface RuleProviders { 45 | providers: Record 46 | } 47 | 48 | export interface ProxyProviders { 49 | providers: Record 50 | } 51 | 52 | interface History { 53 | time: string 54 | delay: number 55 | meanDelay?: number 56 | } 57 | 58 | export interface Proxy { 59 | name: string 60 | type: 'Direct' | 'Reject' | 'Shadowsocks' | 'Vmess' | 'Trojan' | 'Socks' | 'Http' | 'Snell' 61 | history: History[] 62 | udp: boolean 63 | } 64 | 65 | export interface Group { 66 | name: string 67 | type: 'Selector' | 'URLTest' | 'Fallback' 68 | now: string 69 | all: string[] 70 | history: History[] 71 | } 72 | 73 | export interface Snapshot { 74 | uploadTotal: number 75 | downloadTotal: number 76 | connections: Connections[] 77 | } 78 | 79 | export interface Connections { 80 | id: string 81 | metadata: { 82 | network: string 83 | type: string 84 | host: string 85 | processPath?: string 86 | sourceIP: string 87 | sourcePort: string 88 | destinationPort: string 89 | destinationIP?: string 90 | } 91 | upload: number 92 | download: number 93 | start: string 94 | chains: string[] 95 | rule: string 96 | rulePayload: string 97 | } 98 | 99 | export class Client { 100 | private readonly axiosClient: AxiosInstance 101 | 102 | constructor (url: string, secret?: string) { 103 | this.axiosClient = axios.create({ 104 | baseURL: url, 105 | headers: secret ? { Authorization: `Bearer ${secret}` } : {}, 106 | }) 107 | } 108 | 109 | async getConfig () { 110 | return await this.axiosClient.get('configs') 111 | } 112 | 113 | async updateConfig (config: Partial) { 114 | return await this.axiosClient.patch('configs', config) 115 | } 116 | 117 | async getRules () { 118 | return await this.axiosClient.get('rules') 119 | } 120 | 121 | async getProxyProviders () { 122 | const resp = await this.axiosClient.get('providers/proxies', { 123 | validateStatus (status) { 124 | // compatible old version 125 | return (status >= 200 && status < 300) || status === 404 126 | }, 127 | }) 128 | if (resp.status === 404) { 129 | resp.data = { providers: {} } 130 | } 131 | return resp 132 | } 133 | 134 | async getRuleProviders () { 135 | return await this.axiosClient.get('providers/rules') 136 | } 137 | 138 | async updateProvider (name: string) { 139 | return await this.axiosClient.put(`providers/proxies/${encodeURIComponent(name)}`) 140 | } 141 | 142 | async updateRuleProvider (name: string) { 143 | return await this.axiosClient.put(`providers/rules/${encodeURIComponent(name)}`) 144 | } 145 | 146 | async healthCheckProvider (name: string) { 147 | return await this.axiosClient.get(`providers/proxies/${encodeURIComponent(name)}/healthcheck`) 148 | } 149 | 150 | async getProxies () { 151 | return await this.axiosClient.get('proxies') 152 | } 153 | 154 | async getProxy (name: string) { 155 | return await this.axiosClient.get(`proxies/${encodeURIComponent(name)}`) 156 | } 157 | 158 | async getVersion () { 159 | return await this.axiosClient.get<{ version: string, premium?: boolean }>('version') 160 | } 161 | 162 | async getProxyDelay (name: string) { 163 | return await this.axiosClient.get<{ delay: number }>(`proxies/${encodeURIComponent(name)}/delay`, { 164 | params: { 165 | timeout: 5000, 166 | url: 'http://www.gstatic.com/generate_204', 167 | }, 168 | }) 169 | } 170 | 171 | async closeAllConnections () { 172 | return await this.axiosClient.delete('connections') 173 | } 174 | 175 | async closeConnection (id: string) { 176 | return await this.axiosClient.delete(`connections/${id}`) 177 | } 178 | 179 | async getConnections () { 180 | return await this.axiosClient.get('connections') 181 | } 182 | 183 | async changeProxySelected (name: string, select: string) { 184 | return await this.axiosClient.put(`proxies/${encodeURIComponent(name)}`, { name: select }) 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/lib/streamer.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'eventemitter3' 2 | import { type SetRequired } from 'type-fest' 3 | 4 | export interface Config { 5 | bufferLength?: number 6 | retryInterval?: number 7 | } 8 | 9 | export class StreamReader { 10 | protected EE = new EventEmitter() 11 | 12 | protected config: SetRequired 13 | 14 | protected innerBuffer: T[] = [] 15 | 16 | protected url = '' 17 | 18 | protected connection: WebSocket | null = null 19 | 20 | constructor (config: Config) { 21 | this.config = Object.assign( 22 | { 23 | bufferLength: 0, 24 | retryInterval: 5000, 25 | }, 26 | config, 27 | ) 28 | } 29 | 30 | protected connectWebsocket () { 31 | if (!this.url) { 32 | return 33 | } 34 | 35 | const url = new URL(this.url) 36 | 37 | this.connection = new WebSocket(url.toString()) 38 | this.connection.addEventListener('message', msg => { 39 | const data = JSON.parse(msg.data) 40 | this.EE.emit('data', [data]) 41 | if (this.config.bufferLength > 0) { 42 | this.innerBuffer.push(data) 43 | if (this.innerBuffer.length > this.config.bufferLength) { 44 | this.innerBuffer.splice(0, this.innerBuffer.length - this.config.bufferLength) 45 | } 46 | } 47 | }) 48 | 49 | this.connection.addEventListener('error', err => { 50 | this.EE.emit('error', err) 51 | this.connection?.close() 52 | setTimeout(this.connectWebsocket, this.config.retryInterval) 53 | }) 54 | } 55 | 56 | connect (url: string) { 57 | if (this.url === url && this.connection) { 58 | return 59 | } 60 | this.url = url 61 | this.connection?.close() 62 | this.connectWebsocket() 63 | } 64 | 65 | subscribe (event: string, callback: (data: T[]) => void) { 66 | this.EE.addListener(event, callback) 67 | } 68 | 69 | unsubscribe (event: string, callback: (data: T[]) => void) { 70 | this.EE.removeListener(event, callback) 71 | } 72 | 73 | buffer () { 74 | return this.innerBuffer.slice() 75 | } 76 | 77 | destory () { 78 | this.EE.removeAllListeners() 79 | this.connection?.close() 80 | this.connection = null 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/lib/type.ts: -------------------------------------------------------------------------------- 1 | type InferRecursion = 2 | T extends object 3 | ? { [P in keyof T]: P extends string ? InferRecursion : K }[keyof T] 4 | : K 5 | 6 | export type Infer = 7 | Extract<{ [K in keyof T]: K extends string ? InferRecursion : unknown }[keyof T], string> 8 | -------------------------------------------------------------------------------- /src/models/BaseProps.ts: -------------------------------------------------------------------------------- 1 | import { type CSSProperties, type ReactNode } from 'react' 2 | 3 | export interface BaseComponentProps { 4 | className?: string 5 | children?: ReactNode 6 | style?: CSSProperties 7 | } 8 | -------------------------------------------------------------------------------- /src/models/Cipher.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ss ciphers which clash supported 3 | * @see https://github.com/Dreamacro/go-shadowsocks2/blob/master/core/cipher.go 4 | */ 5 | export const SsCipher = [ 6 | // AEAD ciphers 7 | 'AEAD_AES_128_GCM', 8 | 'AEAD_AES_192_GCM', 9 | 'AEAD_AES_256_GCM', 10 | 'AEAD_CHACHA20_POLY1305', 11 | 'AEAD_XCHACHA20_POLY1305', 12 | 13 | // stream ciphers 14 | 'RC4-MD5', 15 | 'AES-128-CTR', 16 | 'AES-192-CTR', 17 | 'AES-256-CTR', 18 | 'AES-128-CFB', 19 | 'AES-192-CFB', 20 | 'AES-256-CFB', 21 | 'CHACHA20', 22 | 'CHACHA20-IETF', 23 | 'XCHACHA20', 24 | ] 25 | 26 | /** 27 | * vmess ciphers which clash supported 28 | * @see https://github.com/Dreamacro/clash/blob/master/component/vmess/vmess.go#L34 29 | */ 30 | export const VmessCipher = [ 31 | 'auto', 32 | 'none', 33 | 'aes-128-gcm', 34 | 'chacha20-poly1305', 35 | ] 36 | 37 | /** 38 | * pickCipherWithAlias returns a cipher of the given name. 39 | */ 40 | export function pickCipherWithAlias (c: string) { 41 | const cipher = c.toUpperCase() 42 | 43 | switch (cipher) { 44 | case 'CHACHA20-IETF-POLY1305': 45 | return 'AEAD_CHACHA20_POLY1305' 46 | case 'XCHACHA20-IETF-POLY1305': 47 | return 'AEAD_XCHACHA20_POLY1305' 48 | case 'AES-128-GCM': 49 | return 'AEAD_AES_128_GCM' 50 | case 'AES-196-GCM': 51 | return 'AEAD_AES_196_GCM' 52 | case 'AES-256-GCM': 53 | return 'AEAD_AES_256_GCM' 54 | } 55 | 56 | return SsCipher.find(c => c === cipher) ?? '' 57 | } 58 | -------------------------------------------------------------------------------- /src/models/Config.ts: -------------------------------------------------------------------------------- 1 | import type * as API from '@lib/request' 2 | 3 | import { type Rule } from './Rule' 4 | 5 | /** 6 | * clash config 7 | * @see https://github.com/Dreamacro/clash#config 8 | */ 9 | export interface Config { 10 | 11 | general?: { 12 | 13 | /** 14 | * http proxy port 15 | */ 16 | port?: number 17 | 18 | /** 19 | * socks proxy port 20 | */ 21 | socksPort?: number 22 | 23 | /** 24 | * redir proxy port 25 | */ 26 | redirPort?: number 27 | 28 | /** 29 | * proxy is allow lan 30 | */ 31 | allowLan?: boolean 32 | 33 | /** 34 | * controller port 35 | */ 36 | externalControllerPort?: string 37 | 38 | /** 39 | * controller address 40 | */ 41 | externalControllerAddr?: string 42 | 43 | /** 44 | * controller secret 45 | */ 46 | secret?: string 47 | 48 | /** 49 | * clash proxy mode 50 | */ 51 | mode?: string 52 | 53 | /** 54 | * clash tty log level 55 | */ 56 | logLevel?: string 57 | } 58 | 59 | rules?: Rule[] 60 | 61 | } 62 | 63 | export interface Data { 64 | version?: string 65 | 66 | general: { 67 | 68 | /** 69 | * http proxy port 70 | */ 71 | port?: number 72 | 73 | /** 74 | * socks proxy port 75 | */ 76 | socksPort?: number 77 | 78 | /** 79 | * mixed porxy port 80 | */ 81 | mixedPort?: number 82 | 83 | /** 84 | * redir proxy port 85 | */ 86 | redirPort?: number 87 | 88 | /** 89 | * proxy is allow lan 90 | */ 91 | allowLan: boolean 92 | 93 | /** 94 | * clash proxy mode 95 | */ 96 | mode: 'script' | 'rule' | 'direct' | 'global' 97 | 98 | /** 99 | * clash tty log level 100 | */ 101 | logLevel?: string 102 | } 103 | 104 | proxy?: API.Proxy[] 105 | 106 | proxyGroup?: API.Group[] 107 | 108 | proxyProviders?: API.Provider[] 109 | 110 | rules?: API.Rule[] 111 | 112 | proxyMap?: Map 113 | } 114 | -------------------------------------------------------------------------------- /src/models/Log.ts: -------------------------------------------------------------------------------- 1 | export interface Log { 2 | type: string 3 | payload: string 4 | time: Date 5 | } 6 | -------------------------------------------------------------------------------- /src/models/Rule.ts: -------------------------------------------------------------------------------- 1 | export enum RuleType { 2 | Domain = 'Domain', 3 | DomainSuffix = 'DomainSuffix', 4 | DomainKeyword = 'DomainKeyword', 5 | GeoIP = 'GeoIP', 6 | IPCIDR = 'IPCIDR', 7 | SrcIPCIDR = 'SrcIPCIDR', 8 | SrcPort = 'SrcPort', 9 | DstPort = 'DstPort', 10 | MATCH = 'MATCH', 11 | RuleSet = 'RuleSet', 12 | } 13 | 14 | export interface Rule { 15 | type?: RuleType 16 | 17 | payload?: string 18 | 19 | proxy?: string // proxy or proxy group name 20 | } 21 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './BaseProps' 2 | export * from './Config' 3 | export * from './Rule' 4 | export * from './Cipher' 5 | -------------------------------------------------------------------------------- /src/render.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense, StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { HashRouter } from 'react-router-dom' 4 | 5 | import { Loading } from '@components' 6 | import App from '@containers/App' 7 | import '@unocss/reset/tailwind.css' 8 | import 'uno.css' 9 | 10 | export default function renderApp () { 11 | const rootEl = document.getElementById('root') 12 | const AppInstance = ( 13 | 14 | 15 | }> 16 | 17 | 18 | 19 | 20 | ) 21 | 22 | const root = createRoot(rootEl!) 23 | root.render(AppInstance) 24 | } 25 | -------------------------------------------------------------------------------- /src/stores/index.ts: -------------------------------------------------------------------------------- 1 | export * from './jotai' 2 | export * from './request' 3 | -------------------------------------------------------------------------------- /src/stores/jotai.ts: -------------------------------------------------------------------------------- 1 | import { usePreviousDistinct, useSyncedRef } from '@react-hookz/web' 2 | import { type AxiosError } from 'axios' 3 | import { produce } from 'immer' 4 | import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' 5 | import { atomWithStorage } from 'jotai/utils' 6 | import { atomWithImmer } from 'jotai-immer' 7 | import { get } from 'lodash-es' 8 | import { ResultAsync } from 'neverthrow' 9 | import { useCallback, useEffect, useMemo, useRef } from 'react' 10 | import useSWR from 'swr' 11 | import { type Get } from 'type-fest' 12 | 13 | import { Language, locales, type Lang, getDefaultLanguage, type LocalizedType } from '@i18n' 14 | import { partition } from '@lib/helper' 15 | import { useWarpImmerSetter, type WritableDraft } from '@lib/jotai' 16 | import { isClashX, jsBridge } from '@lib/jsBridge' 17 | import type * as API from '@lib/request' 18 | import { StreamReader } from '@lib/streamer' 19 | import { type Infer } from '@lib/type' 20 | import type * as Models from '@models' 21 | import { type Log } from '@models/Log' 22 | 23 | import { useAPIInfo, useClient } from './request' 24 | 25 | export const identityAtom = atom(true) 26 | 27 | export const languageAtom = atomWithStorage('language', undefined) 28 | 29 | export function useI18n () { 30 | const [defaultLang, setLang] = useAtom(languageAtom) 31 | const lang = useMemo(() => defaultLang ?? getDefaultLanguage(), [defaultLang]) 32 | 33 | const translation = useCallback( 34 | function (namespace: Namespace) { 35 | function t> (path: Path) { 36 | return get(Language[lang][namespace], path) as unknown as Get 37 | } 38 | return { t } 39 | }, 40 | [lang], 41 | ) 42 | 43 | return { lang, locales, setLang, translation } 44 | } 45 | 46 | export const version = atom({ 47 | version: '', 48 | premium: false, 49 | }) 50 | 51 | export function useVersion () { 52 | const [data, set] = useAtom(version) 53 | const client = useClient() 54 | const setIdentity = useSetAtom(identityAtom) 55 | 56 | useSWR([client], async function () { 57 | const result = await ResultAsync.fromPromise(client.getVersion(), e => e as AxiosError) 58 | setIdentity(result.isOk()) 59 | 60 | set( 61 | result.isErr() 62 | ? { version: '', premium: false } 63 | : { version: result.value.data.version, premium: !!result.value.data.premium }, 64 | ) 65 | }) 66 | 67 | return data 68 | } 69 | 70 | export function useRuleProviders () { 71 | const [{ premium }] = useAtom(version) 72 | const client = useClient() 73 | 74 | const { data, mutate } = useSWR(['/providers/rule', client, premium], async () => { 75 | if (!premium) { 76 | return [] 77 | } 78 | 79 | const ruleProviders = await client.getRuleProviders() 80 | 81 | return Object.keys(ruleProviders.data.providers) 82 | .map(name => ruleProviders.data.providers[name]) 83 | }) 84 | 85 | return { providers: data ?? [], update: mutate } 86 | } 87 | 88 | export const configAtom = atomWithStorage('profile', { 89 | breakConnections: false, 90 | logLevel: '', 91 | }) 92 | 93 | export function useConfig () { 94 | const [data, set] = useAtom(configAtom) 95 | 96 | const setter = useCallback((f: WritableDraft) => { 97 | set(produce(data, f)) 98 | }, [data, set]) 99 | 100 | return { data, set: useWarpImmerSetter(setter) } 101 | } 102 | 103 | export const proxyProvider = atom([] as API.Provider[]) 104 | 105 | export function useProxyProviders () { 106 | const [providers, set] = useAtom(proxyProvider) 107 | const client = useClient() 108 | 109 | const { data, mutate } = useSWR(['/providers/proxy', client], async () => { 110 | const proxyProviders = await client.getProxyProviders() 111 | 112 | return Object.keys(proxyProviders.data.providers) 113 | .map(name => proxyProviders.data.providers[name]) 114 | .filter(pd => pd.name !== 'default') 115 | .filter(pd => pd.vehicleType !== 'Compatible') 116 | }) 117 | 118 | useEffect(() => { set(data ?? []) }, [data, set]) 119 | return { providers, update: mutate } 120 | } 121 | 122 | export function useGeneral () { 123 | const client = useClient() 124 | 125 | const { data, mutate } = useSWR(['/config', client], async () => { 126 | const resp = await client.getConfig() 127 | const data = resp.data 128 | return { 129 | port: data.port, 130 | socksPort: data['socks-port'], 131 | mixedPort: data['mixed-port'] ?? 0, 132 | redirPort: data['redir-port'], 133 | mode: data.mode.toLowerCase() as Models.Data['general']['mode'], 134 | logLevel: data['log-level'], 135 | allowLan: data['allow-lan'], 136 | } as Models.Data['general'] 137 | }) 138 | 139 | return { general: data ?? {} as Models.Data['general'], update: mutate } 140 | } 141 | 142 | export const proxies = atomWithImmer({ 143 | proxies: [] as API.Proxy[], 144 | groups: [] as API.Group[], 145 | global: { 146 | name: 'GLOBAL', 147 | type: 'Selector', 148 | now: '', 149 | history: [], 150 | all: [], 151 | } as API.Group, 152 | }) 153 | 154 | export function useProxy () { 155 | const [allProxy, rawSet] = useAtom(proxies) 156 | const set = useWarpImmerSetter(rawSet) 157 | const client = useClient() 158 | 159 | const { mutate } = useSWR(['/proxies', client], async () => { 160 | const allProxies = await client.getProxies() 161 | 162 | const global = allProxies.data.proxies.GLOBAL as API.Group 163 | // fix missing name 164 | global.name = 'GLOBAL' 165 | 166 | const policyGroup = new Set(['Selector', 'URLTest', 'Fallback', 'LoadBalance']) 167 | const unUsedProxy = new Set(['DIRECT', 'REJECT', 'GLOBAL']) 168 | const proxies = global.all 169 | .filter(key => !unUsedProxy.has(key)) 170 | .map(key => ({ ...allProxies.data.proxies[key], name: key })) 171 | const [proxy, groups] = partition(proxies, proxy => !policyGroup.has(proxy.type)) 172 | set({ proxies: proxy as API.Proxy[], groups: groups as API.Group[], global }) 173 | }) 174 | 175 | const markProxySelected = useCallback((name: string, selected: string) => { 176 | set(draft => { 177 | if (name === 'GLOBAL') { 178 | draft.global.now = selected 179 | } 180 | for (const group of draft.groups) { 181 | if (group.name === name) { 182 | group.now = selected 183 | } 184 | } 185 | }) 186 | }, [set]) 187 | 188 | return { 189 | proxies: allProxy.proxies, 190 | groups: allProxy.groups, 191 | global: allProxy.global, 192 | update: mutate, 193 | markProxySelected, 194 | set, 195 | } 196 | } 197 | 198 | export const proxyMapping = atom((get) => { 199 | const ps = get(proxies) 200 | const providers = get(proxyProvider) 201 | const proxyMap = new Map() 202 | for (const p of ps.proxies) { 203 | proxyMap.set(p.name, p) 204 | } 205 | 206 | for (const provider of providers) { 207 | for (const p of provider.proxies) { 208 | proxyMap.set(p.name, p as API.Proxy) 209 | } 210 | } 211 | 212 | return proxyMap 213 | }) 214 | 215 | export function useClashXData () { 216 | const { data, mutate } = useSWR('/clashx', async () => { 217 | if (!isClashX()) { 218 | return { 219 | isClashX: false, 220 | startAtLogin: false, 221 | systemProxy: false, 222 | } 223 | } 224 | 225 | const startAtLogin = await jsBridge?.getStartAtLogin() ?? false 226 | const systemProxy = await jsBridge?.isSystemProxySet() ?? false 227 | 228 | return { startAtLogin, systemProxy, isClashX: true } 229 | }) 230 | 231 | return { data, update: mutate } 232 | } 233 | 234 | export const rules = atomWithImmer([] as API.Rule[]) 235 | 236 | export function useRule () { 237 | const [data, rawSet] = useAtom(rules) 238 | const set = useWarpImmerSetter(rawSet) 239 | const client = useClient() 240 | 241 | async function update () { 242 | const resp = await client.getRules() 243 | set(resp.data.rules) 244 | } 245 | 246 | return { rules: data, update } 247 | } 248 | 249 | const logsAtom = atom(new StreamReader({ bufferLength: 200 })) 250 | 251 | export function useLogsStreamReader () { 252 | const apiInfo = useAPIInfo() 253 | const { general } = useGeneral() 254 | const { data: { logLevel } } = useConfig() 255 | const item = useAtomValue(logsAtom) 256 | 257 | const level = logLevel || general.logLevel 258 | const previousKey = usePreviousDistinct( 259 | `${apiInfo.protocol}//${apiInfo.hostname}:${apiInfo.port}/logs?level=${level}&secret=${encodeURIComponent(apiInfo.secret)}`, 260 | ) 261 | 262 | const apiInfoRef = useSyncedRef(apiInfo) 263 | 264 | useEffect(() => { 265 | if (level) { 266 | const apiInfo = apiInfoRef.current 267 | const protocol = apiInfo.protocol === 'http:' ? 'ws:' : 'wss:' 268 | const logUrl = `${protocol}//${window.location.host}/api/logs?level=${level}&token=${encodeURIComponent(apiInfo.secret)}` 269 | item.connect(logUrl) 270 | } 271 | }, [apiInfoRef, item, level, previousKey]) 272 | 273 | return item 274 | } 275 | 276 | export function useConnectionStreamReader () { 277 | const apiInfo = useAPIInfo() 278 | 279 | const connection = useRef(new StreamReader({ bufferLength: 200 })) 280 | 281 | const protocol = apiInfo.protocol === 'http:' ? 'ws:' : 'wss:' 282 | const url = `${protocol}//${window.location.host}/api/connections?token=${encodeURIComponent(apiInfo.secret)}` 283 | 284 | useEffect(() => { 285 | connection.current.connect(url) 286 | }, [url]) 287 | 288 | return connection.current 289 | } 290 | -------------------------------------------------------------------------------- /src/stores/request.ts: -------------------------------------------------------------------------------- 1 | import { atom, useAtom, useAtomValue } from 'jotai' 2 | import { atomWithStorage } from 'jotai/utils' 3 | import { useLocation } from 'react-router-dom' 4 | 5 | import { isIP } from '@lib/ip' 6 | import { isClashX, jsBridge } from '@lib/jsBridge' 7 | import { Client } from '@lib/request' 8 | 9 | const clashxConfigAtom = atom(async () => { 10 | if (!isClashX()) { 11 | return null 12 | } 13 | 14 | const info = await jsBridge!.getAPIInfo() 15 | return { 16 | hostname: info.host, 17 | port: info.port, 18 | secret: info.secret, 19 | protocol: 'http:', 20 | } 21 | }) 22 | 23 | function getControllerFromLocation (): string | null { 24 | if (!isIP(location.hostname)) { 25 | return null 26 | } 27 | 28 | if (location.port !== '') { 29 | return JSON.stringify([ 30 | { 31 | hostname: location.hostname, 32 | port: +location.port, 33 | secret: '', 34 | }, 35 | ]) 36 | } 37 | 38 | const port = location.protocol === 'https:' ? 443 : 80 39 | return JSON.stringify([ 40 | { 41 | hostname: location.hostname, 42 | port, 43 | secret: '', 44 | }, 45 | ]) 46 | } 47 | 48 | // jotai v2 use initialValue first avoid hydration warning, but we don't want that 49 | const hostsStorageOrigin = '[{ "hostname": "127.0.0.1", "port": 9090, "secret": "" }]' 50 | const hostSelectIdxStorageOrigin = '0' 51 | 52 | export const hostsStorageAtom = atomWithStorage>('externalControllers', JSON.parse(hostsStorageOrigin)) 57 | export const hostSelectIdxStorageAtom = atomWithStorage('externalControllerIndex', parseInt(hostSelectIdxStorageOrigin)) 58 | 59 | export function useAPIInfo () { 60 | const clashx = useAtomValue(clashxConfigAtom) 61 | const location = useLocation() 62 | const hostSelectIdxStorage = useAtomValue(hostSelectIdxStorageAtom) 63 | const hostsStorage = useAtomValue(hostsStorageAtom) 64 | 65 | if (clashx != null) { 66 | return clashx 67 | } 68 | 69 | let url: URL | undefined 70 | { 71 | const meta = document.querySelector('meta[name="external-controller"]') 72 | if ((meta?.content?.match(/^https?:/)) != null) { 73 | // [protocol]://[secret]@[hostname]:[port] 74 | url = new URL(meta.content) 75 | } 76 | } 77 | 78 | const qs = new URLSearchParams(location.search) 79 | 80 | const hostname = qs.get('host') ?? hostsStorage?.[hostSelectIdxStorage]?.hostname ?? url?.hostname ?? '127.0.0.1' 81 | const port = qs.get('port') ?? hostsStorage?.[hostSelectIdxStorage]?.port ?? url?.port ?? '9090' 82 | const secret = qs.get('secret') ?? hostsStorage?.[hostSelectIdxStorage]?.secret ?? url?.username ?? '' 83 | const protocol = qs.get('protocol') ?? hostname === '127.0.0.1' ? 'http:' : (url?.protocol ?? window.location.protocol) 84 | 85 | return { hostname, port, secret, protocol } 86 | } 87 | 88 | const clientAtom = atom({ 89 | key: '', 90 | instance: null as Client | null, 91 | }) 92 | 93 | export function useClient () { 94 | const { secret } = useAPIInfo() 95 | 96 | const [item, setItem] = useAtom(clientAtom) 97 | const key = `/api?secret=${secret}` 98 | if (item.key === key) { 99 | return item.instance! 100 | } 101 | 102 | const client = new Client('/api', secret) 103 | setItem({ key, instance: client }) 104 | 105 | return client 106 | } 107 | -------------------------------------------------------------------------------- /src/styles/common.scss: -------------------------------------------------------------------------------- 1 | // styles initial 2 | html { 3 | box-sizing: border-box; 4 | background: rgba($color: $color-white, $alpha: 0.8); 5 | overflow: hidden; 6 | } 7 | 8 | *, 9 | *::before, 10 | *::after { 11 | margin: 0; 12 | padding: 0; 13 | box-sizing: inherit; 14 | } 15 | 16 | body { 17 | margin: 0; 18 | padding: 0; 19 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 20 | 21 | ::-webkit-scrollbar { 22 | z-index: 11; 23 | background: transparent; 24 | 25 | &-thumb { 26 | border-radius: 5px; 27 | background: #2c8af8; 28 | } 29 | } 30 | 31 | ::-webkit-scrollbar:vertical { 32 | width: 6px; 33 | } 34 | 35 | ::-webkit-scrollbar:horizontal { 36 | height: 6px; 37 | } 38 | } 39 | 40 | .app { 41 | min-height: 100vh; 42 | padding-left: 150px; 43 | } 44 | 45 | .app.not-clashx { 46 | background: $background; 47 | } 48 | 49 | .page-container { 50 | width: 100%; 51 | height: 100vh; 52 | padding-left: 10px; 53 | overflow-y: scroll; 54 | } 55 | 56 | .page { 57 | padding: 20px 35px 30px 20px; 58 | width: 100%; 59 | min-height: 100vh; 60 | margin: 0 auto; 61 | display: flex; 62 | flex-direction: column; 63 | } 64 | 65 | .container { 66 | margin: 20px 0; 67 | } 68 | 69 | input { 70 | -webkit-appearance: none; 71 | } 72 | 73 | @media (max-width: 768px) { 74 | .app { 75 | padding-left: 0; 76 | padding-top: 60px; 77 | } 78 | 79 | .page-container { 80 | width: 100%; 81 | padding: 0 10px; 82 | height: calc(100vh - 60px); 83 | -webkit-overflow-scrolling: touch; 84 | 85 | &::-webkit-scrollbar { 86 | display: none; 87 | } 88 | } 89 | 90 | .page { 91 | padding: 0 0 20px; 92 | height: 100%; 93 | min-height: unset; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/styles/iconfont.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Clash Dashboard Iconfont 3 | * generated by iconfont.cn 4 | */ 5 | 6 | @font-face { 7 | font-family: "clash-iconfont"; 8 | src: url('//at.alicdn.com/t/font_841708_ok9czskbhel.ttf?t=1576162884356') format('truetype'); 9 | } 10 | 11 | .clash-iconfont { 12 | font-family: "clash-iconfont" !important; 13 | font-size: 14px; 14 | font-style: normal; 15 | -webkit-font-smoothing: antialiased; 16 | -moz-osx-font-smoothing: grayscale; 17 | color: $color-primary-dark; 18 | } 19 | 20 | .icon-update::before { content: "\e66f"; } 21 | 22 | .icon-healthcheck::before { content: "\e63c"; } 23 | 24 | .icon-speed::before { content: "\e61b"; } 25 | 26 | .icon-close::before { content: "\e602"; } 27 | 28 | .icon-drag::before { content: "\e604"; } 29 | 30 | .icon-down-arrow-o::before { content: "\e605"; } 31 | 32 | .icon-check::before { content: "\e606"; } 33 | 34 | .icon-plus::before { content: "\e607"; } 35 | 36 | .icon-triangle-up::before { content: "\e608"; } 37 | 38 | .icon-triangle-down::before { content: "\e609"; } 39 | 40 | .icon-up-arrow-o::before { content: "\e60a"; } 41 | 42 | .icon-info::before { content: "\e60b"; } 43 | 44 | .icon-info-o::before { content: "\e60c"; } 45 | 46 | .icon-setting::before { content: "\e60d"; } 47 | 48 | .icon-show::before { content: "\e60e"; } 49 | 50 | .icon-hide::before { content: "\e60f"; } 51 | 52 | .icon-sort::before { content: "\e8b3"; } 53 | 54 | .icon-sort-descending::before { content: "\e8b4"; } 55 | 56 | .icon-sort-ascending::before { content: "\e8b5"; } 57 | 58 | .icon-close-all::before { content: "\e71b"; } 59 | -------------------------------------------------------------------------------- /src/styles/variables.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Clash Dashboard 3 | * Style Common Variables 4 | */ 5 | 6 | $background: #f4f5f6; 7 | 8 | // primary colors 9 | $color-primary: #57befc; 10 | $color-primary-dark: #2c8af8; 11 | $color-primary-darken: #54759a; 12 | $color-primary-light: #7fcae4; 13 | $color-primary-lightly: #e4eaef; 14 | 15 | // common colors 16 | $color-gray: #d8dee2; 17 | $color-gray-light: #f3f6f9; 18 | $color-gray-dark: #b7c5d6; 19 | $color-gray-darken: #909399; 20 | $color-white: #fff; 21 | $color-green: #67c23a; 22 | $color-orange: #e6a23c; 23 | $color-red: #f56c6c; 24 | $color-black-light: #546b87; 25 | $color-black: #000; 26 | 27 | // response break point 28 | @mixin response($name) { 29 | @if map-has-key($breakpoints, $name) { 30 | @media #{inspect(map-get($breakpoints, $name))} { 31 | @content; 32 | } 33 | } 34 | } 35 | 36 | $breakpoints: ( 37 | 'xxs': ( 38 | max-width: 760px 39 | ), 40 | 'xs': ( 41 | max-width: 860px 42 | ), 43 | 'sm': ( 44 | max-width: 960px 45 | ), 46 | 'md': ( 47 | min-width: 961px 48 | ) 49 | and 50 | ( 51 | max-width: 1340px 52 | ), 53 | 'lg': ( 54 | min-width: 1341px 55 | ) 56 | and 57 | ( 58 | max-width: 1600px 59 | ), 60 | 'xl': ( 61 | min-width: 1601px 62 | ) 63 | and 64 | ( 65 | max-width: 2000px 66 | ), 67 | 'xxl': ( 68 | min-width: 2001px 69 | ) 70 | ); 71 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "lib": [ 5 | "dom", 6 | ], 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "types": ["vite/client"], 14 | "jsx": "react-jsx", 15 | "baseUrl": ".", 16 | "paths": { 17 | "@assets": ["src/assets"], 18 | "@assets/*": ["src/assets/*"], 19 | "@lib": ["src/lib"], 20 | "@lib/*": ["src/lib/*"], 21 | "@components": ["src/components"], 22 | "@components/*": ["src/components/*"], 23 | "@containers": ["src/containers"], 24 | "@containers/*": ["src/containers/*"], 25 | "@i18n": ["src/i18n"], 26 | "@i18n/*": ["src/i18n/*"], 27 | "@stores": ["src/stores"], 28 | "@stores/*": ["src/stores/*"], 29 | "@models": ["src/models"], 30 | "@models/*": ["src/models/*"], 31 | "@styles": ["src/styles"], 32 | "@styles/*": ["src/styles/*"] 33 | } 34 | }, 35 | "include": [ 36 | "src", 37 | "vite.config.ts", 38 | "uno.config.ts" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- 1 | import presetWind from '@unocss/preset-wind' 2 | import { defineConfig } from 'unocss' 3 | 4 | export default defineConfig({ 5 | presets: [presetWind()], 6 | theme: { 7 | colors: { 8 | primary: { 9 | 500: '#57befc', 10 | 600: '#2c8af8', 11 | darken: '#54759a', 12 | }, 13 | red: '#f56c6c', 14 | green: '#67c23a', 15 | }, 16 | boxShadow: { 17 | primary: '2px 5px 20px -3px rgb(44 138 248 / 18%)', 18 | }, 19 | textShadow: { 20 | primary: '0 0 6px rgb(44 138 248 / 40%)', 21 | }, 22 | }, 23 | }) 24 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import jotaiDebugLabel from 'jotai/babel/plugin-debug-label' 3 | import jotaiReactRefresh from 'jotai/babel/plugin-react-refresh' 4 | import UnoCSS from 'unocss/vite' 5 | import { defineConfig, splitVendorChunkPlugin } from 'vite' 6 | import { VitePWA } from 'vite-plugin-pwa' 7 | import tsConfigPath from 'vite-tsconfig-paths' 8 | 9 | export default defineConfig( 10 | env => ({ 11 | plugins: [ 12 | // only use react-fresh 13 | env.mode === 'development' && react({ 14 | babel: { plugins: [jotaiDebugLabel, jotaiReactRefresh] }, 15 | }), 16 | tsConfigPath(), 17 | UnoCSS(), 18 | VitePWA({ 19 | injectRegister: 'inline', 20 | manifest: { 21 | icons: [{ 22 | src: '//cdn.jsdelivr.net/gh/Dreamacro/clash-dashboard/src/assets/Icon.png', 23 | sizes: '512x512', 24 | type: 'image/png', 25 | }], 26 | start_url: '/', 27 | short_name: 'Clash Dashboard', 28 | name: 'Clash Dashboard', 29 | }, 30 | }), 31 | splitVendorChunkPlugin(), 32 | ], 33 | server: { 34 | port: 3000, 35 | }, 36 | base: './', 37 | css: { 38 | preprocessorOptions: { 39 | scss: { 40 | additionalData: '@use "sass:math"; @import "src/styles/variables.scss";', 41 | }, 42 | }, 43 | }, 44 | build: { reportCompressedSize: false }, 45 | esbuild: { 46 | jsxInject: "import React from 'react'", 47 | }, 48 | }), 49 | ) 50 | --------------------------------------------------------------------------------