├── .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 |
--------------------------------------------------------------------------------
/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 |
17 |
20 |
23 |
26 |
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 |
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 |
72 |
73 |
74 |
75 | {
76 | logs.map(
77 | (log, index) => (
78 | -
79 | [{ dayjs(log.time).format('YYYY-MM-DD HH:mm:ss') }]
80 | [{ log.type.toUpperCase() }]
81 | { log.payload }
82 |
83 | ),
84 | )
85 | }
86 |
87 |
88 |
89 | )
90 | }
91 |
--------------------------------------------------------------------------------
/src/containers/Logs/style.scss:
--------------------------------------------------------------------------------
1 | .logs-panel {
2 | display: flex;
3 | flex-direction: column;
4 | flex-grow: 1;
5 | flex-basis: 0;
6 | list-style: none;
7 | padding: 10px;
8 | border-radius: 2px;
9 | background-color: $color-gray-light;
10 | font-size: 12px;
11 | color: #73808f;
12 | overflow-y: auto;
13 | font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
14 | }
15 |
--------------------------------------------------------------------------------
/src/containers/Overview/index.tsx:
--------------------------------------------------------------------------------
1 | import logo from '@assets/logo-fixing.svg'
2 |
3 | export default function Overview () {
4 | return (
5 |
13 |

16 |
17 |
Coming Soon...
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/src/containers/Proxies/components/Group/index.tsx:
--------------------------------------------------------------------------------
1 | import { useAtom } from 'jotai'
2 | import { useMemo } from 'react'
3 |
4 | import { Tags, Tag } from '@components'
5 | import { type Group as IGroup } from '@lib/request'
6 | import { useProxy, useConfig, proxyMapping, useClient } from '@stores'
7 |
8 | import './style.scss'
9 |
10 | interface GroupProps {
11 | config: IGroup
12 | }
13 |
14 | export function Group (props: GroupProps) {
15 | const { markProxySelected } = useProxy()
16 | const [proxyMap] = useAtom(proxyMapping)
17 | const { data: Config } = useConfig()
18 | const client = useClient()
19 | const { config } = props
20 |
21 | async function handleChangeProxySelected (name: string) {
22 | await client.changeProxySelected(props.config.name, name)
23 | markProxySelected(props.config.name, name)
24 | if (Config.breakConnections) {
25 | const list: string[] = []
26 | const snapshot = await client.getConnections()
27 | for (const connection of snapshot.data.connections) {
28 | if (connection.chains.includes(props.config.name)) {
29 | list.push(connection.id)
30 | }
31 | }
32 |
33 | await Promise.all(list.map(id => client.closeConnection(id)))
34 | }
35 | }
36 |
37 | const errSet = useMemo(() => {
38 | const set = new Set()
39 | for (const proxy of config.all) {
40 | const history = proxyMap.get(proxy)?.history
41 | if (history?.length && history.slice(-1)[0].delay === 0) {
42 | set.add(proxy)
43 | }
44 | }
45 |
46 | return set
47 | }, [config.all, proxyMap])
48 |
49 | const canClick = config.type === 'Selector'
50 | return (
51 |
52 |
53 | { config.name }
54 | { config.type }
55 |
56 |
57 |
65 |
66 |
67 | )
68 | }
69 |
--------------------------------------------------------------------------------
/src/containers/Proxies/components/Group/style.scss:
--------------------------------------------------------------------------------
1 | .proxy-group {
2 | display: flex;
3 | align-items: flex-start;
4 | font-size: 14px;
5 | color: $color-black-light;
6 | }
7 |
8 | @media (max-width: 768px) {
9 | .proxy-group {
10 | flex-direction: column;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/containers/Proxies/components/Provider/index.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 |
3 | import { Card, Tag, Icon, Loading } from '@components'
4 | import { compareDesc } from '@containers/Proxies'
5 | import { Proxy } from '@containers/Proxies/components/Proxy'
6 | import { fromNow } from '@lib/date'
7 | import { useVisible } from '@lib/hook'
8 | import { type Provider as IProvider, type Proxy as IProxy } from '@lib/request'
9 | import { useClient, useI18n, useProxyProviders } from '@stores'
10 |
11 | import './style.scss'
12 |
13 | interface ProvidersProps {
14 | provider: IProvider
15 | }
16 |
17 | export function Provider (props: ProvidersProps) {
18 | const { update } = useProxyProviders()
19 | const { translation, lang } = useI18n()
20 | const client = useClient()
21 |
22 | const { provider } = props
23 | const { t } = translation('Proxies')
24 |
25 | const { visible, hide, show } = useVisible()
26 |
27 | function handleHealthChech () {
28 | show()
29 | client.healthCheckProvider(provider.name).then(async () => await update()).finally(() => hide())
30 | }
31 |
32 | function handleUpdate () {
33 | show()
34 | client.updateProvider(provider.name).then(async () => await update()).finally(() => hide())
35 | }
36 |
37 | const proxies = useMemo(() => {
38 | return (provider.proxies as IProxy[]).slice().sort((a, b) => -1 * compareDesc(a, b))
39 | }, [provider.proxies])
40 |
41 | return (
42 |
43 |
44 |
45 |
46 | { provider.name }
47 | { provider.vehicleType }
48 |
49 |
50 | {
51 | provider.updatedAt &&
52 | { `${t('providerUpdateTime')}: ${fromNow(new Date(provider.updatedAt), lang)}`}
53 | }
54 |
55 |
56 |
57 |
58 |
59 | {
60 | proxies.map((p: IProxy) => (
61 | -
62 |
63 |
64 | ))
65 | }
66 |
67 |
68 | )
69 | }
70 |
--------------------------------------------------------------------------------
/src/containers/Proxies/components/Provider/style.scss:
--------------------------------------------------------------------------------
1 | .proxy-provider {
2 | position: relative;
3 | display: flex;
4 | flex-direction: column;
5 | font-size: 16px;
6 | padding: 20px;
7 | color: $color-black-light;
8 | }
9 |
10 | .proxy-provider-item {
11 | box-shadow: 0 0 24px 0 rgba(44, 138, 248, 0.2);
12 |
13 | &:hover {
14 | box-shadow: 0 0 24px 0 rgba(84, 117, 154, 0.4);
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/containers/Proxies/components/Proxy/index.tsx:
--------------------------------------------------------------------------------
1 | import type { AxiosError } from 'axios'
2 | import classnames from 'classnames'
3 | import { ResultAsync } from 'neverthrow'
4 | import { useMemo, useLayoutEffect, useCallback } from 'react'
5 |
6 | import EE, { Action } from '@lib/event'
7 | import { isClashX, jsBridge } from '@lib/jsBridge'
8 | import { type Proxy as IProxy } from '@lib/request'
9 | import { type BaseComponentProps } from '@models'
10 | import { useClient, useProxy } from '@stores'
11 |
12 | import './style.scss'
13 |
14 | interface ProxyProps extends BaseComponentProps {
15 | config: IProxy
16 | }
17 |
18 | const TagColors = {
19 | '#909399': 0,
20 | '#00c520': 260,
21 | '#ff9a28': 600,
22 | '#ff3e5e': Infinity,
23 | }
24 |
25 | export function Proxy (props: ProxyProps) {
26 | const { config, className } = props
27 | const { set } = useProxy()
28 | const client = useClient()
29 |
30 | const getDelay = useCallback(async (name: string) => {
31 | if (isClashX()) {
32 | const delay = await jsBridge?.getProxyDelay(name) ?? 0
33 | return delay
34 | }
35 |
36 | const { data: { delay } } = await client.getProxyDelay(name)
37 | return delay
38 | }, [client])
39 |
40 | const speedTest = useCallback(async function () {
41 | const result = await ResultAsync.fromPromise(getDelay(config.name), e => e as AxiosError)
42 |
43 | const validDelay = result.isErr() ? 0 : result.value
44 | set(draft => {
45 | const proxy = draft.proxies.find(p => p.name === config.name)
46 | if (proxy != null) {
47 | proxy.history.push({ time: Date.now().toString(), delay: validDelay })
48 | }
49 | })
50 | }, [config.name, getDelay, set])
51 |
52 | const delay = config.history?.length ? config.history.slice(-1)[0].delay : 0
53 | const meanDelay = config.history?.length ? config.history.slice(-1)[0].meanDelay : undefined
54 |
55 | const delayText = delay === 0 ? '-' : `${delay}ms`
56 | const meanDelayText = !meanDelay ? '' : `(${meanDelay}ms)`
57 |
58 | useLayoutEffect(() => {
59 | const handler = () => { speedTest() }
60 | EE.subscribe(Action.SPEED_NOTIFY, handler)
61 | return () => EE.unsubscribe(Action.SPEED_NOTIFY, handler)
62 | }, [speedTest])
63 |
64 | const hasError = useMemo(() => delay === 0, [delay])
65 | const color = useMemo(
66 | () => Object.keys(TagColors).find(
67 | // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
68 | threshold => (meanDelay || delay) <= TagColors[threshold as keyof typeof TagColors],
69 | ),
70 | [delay, meanDelay],
71 | )
72 |
73 | const backgroundColor = hasError ? '#E5E7EB' : color
74 | return (
75 |
76 |
77 |
80 | {config.type}
81 |
82 |
{config.name}
83 |
84 |
85 |
{delayText}{meanDelayText}
86 | { config.udp &&
UDP
}
87 |
88 |
89 | )
90 | }
91 |
--------------------------------------------------------------------------------
/src/containers/Proxies/components/Proxy/style.scss:
--------------------------------------------------------------------------------
1 | .proxy-item {
2 | display: flex;
3 | flex-flow: column nowrap;
4 | position: relative;
5 | width: 100%;
6 | height: 110px;
7 | padding: 10px;
8 | border-radius: 4px;
9 | background: $color-white;
10 | user-select: none;
11 | overflow: hidden;
12 | cursor: default;
13 | box-shadow: 2px 5px 20px -3px rgba($color-primary-dark, 0.2);
14 | transition: all 300ms ease;
15 |
16 | .proxy-name {
17 | display: -webkit-box;
18 | margin: 6px 0;
19 | color: $color-primary-darken;
20 | font-size: 10px;
21 | overflow: hidden;
22 | word-break: break-word;
23 | -webkit-line-clamp: 2;
24 | -webkit-box-orient: vertical;
25 | }
26 |
27 | .proxy-delay {
28 | font-size: 10px;
29 | color: rgba($color: $color-primary-darken, $alpha: 0.8);
30 | }
31 |
32 | &:hover {
33 | box-shadow: 0 14px 20px -4px rgba($color-primary-darken, 0.4);
34 | }
35 | }
36 |
37 | @media (max-width: 768px) {
38 | .proxy-item {
39 | $height: 70px;
40 |
41 | flex-flow: row nowrap;
42 | height: $height;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/containers/Proxies/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Proxy'
2 | export * from './Group'
3 | export * from './Provider'
4 |
--------------------------------------------------------------------------------
/src/containers/Proxies/index.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 |
3 | import { Card, Header, Icon, Checkbox } from '@components'
4 | import EE from '@lib/event'
5 | import { useRound } from '@lib/hook'
6 | import type * as API from '@lib/request'
7 | import { useI18n, useConfig, useProxy, useProxyProviders, useGeneral } from '@stores'
8 |
9 | import { Proxy, Group, Provider } from './components'
10 | import './style.scss'
11 |
12 | enum sortType {
13 | None,
14 | Asc,
15 | Desc,
16 | }
17 |
18 | const sortMap = {
19 | [sortType.None]: 'sort',
20 | [sortType.Asc]: 'sort-ascending',
21 | [sortType.Desc]: 'sort-descending',
22 | }
23 |
24 | export function compareDesc (a: API.Proxy, b: API.Proxy) {
25 | const lastDelayA = (a.history.length > 0) ? a.history.slice(-1)[0].delay : 0
26 | const lastDelayB = (b.history.length > 0) ? b.history.slice(-1)[0].delay : 0
27 | return (lastDelayB || Number.MAX_SAFE_INTEGER) - (lastDelayA || Number.MAX_SAFE_INTEGER)
28 | }
29 |
30 | function ProxyGroups () {
31 | const { groups, global } = useProxy()
32 | const { data: config, set: setConfig } = useConfig()
33 | const { general } = useGeneral()
34 | const { translation } = useI18n()
35 | const { t } = translation('Proxies')
36 |
37 | const list = useMemo(
38 | () => general.mode === 'global' ? [global, ...groups] : groups,
39 | [general, groups, global],
40 | )
41 |
42 | return <>
43 | {
44 | list.length !== 0 &&
45 |
46 |
47 | setConfig('breakConnections', value)}>
51 | {t('breakConnectionsText')}
52 |
53 |
54 |
55 |
56 | {
57 | list.map(p => (
58 | -
59 |
60 |
61 | ))
62 | }
63 |
64 |
65 |
66 | }
67 | >
68 | }
69 |
70 | function ProxyProviders () {
71 | const { providers } = useProxyProviders()
72 | const { translation: useTranslation } = useI18n()
73 | const { t } = useTranslation('Proxies')
74 |
75 | return <>
76 | {
77 | providers.length !== 0 &&
78 |
79 |
80 |
81 | {
82 | providers.map(p => (
83 | -
84 |
85 |
86 | ))
87 | }
88 |
89 |
90 | }
91 | >
92 | }
93 |
94 | function Proxies () {
95 | const { proxies } = useProxy()
96 | const { translation: useTranslation } = useI18n()
97 | const { t } = useTranslation('Proxies')
98 |
99 | function handleNotitySpeedTest () {
100 | EE.notifySpeedTest()
101 | }
102 |
103 | const { current: sort, next } = useRound(
104 | [sortType.Asc, sortType.Desc, sortType.None],
105 | )
106 | const sortedProxies = useMemo(() => {
107 | switch (sort) {
108 | case sortType.Desc:
109 | return proxies.slice().sort((a, b) => compareDesc(a, b))
110 | case sortType.Asc:
111 | return proxies.slice().sort((a, b) => -1 * compareDesc(a, b))
112 | default:
113 | return proxies.slice()
114 | }
115 | }, [sort, proxies])
116 | const handleSort = next
117 |
118 | return <>
119 | {
120 | sortedProxies.length !== 0 &&
121 |
122 |
123 |
124 |
125 | {t('speedTestText')}
126 |
127 |
128 | {
129 | sortedProxies.map(p => (
130 | -
131 |
132 |
133 | ))
134 | }
135 |
136 |
137 | }
138 | >
139 | }
140 |
141 | export default function ProxyContainer () {
142 | return (
143 |
148 | )
149 | }
150 |
--------------------------------------------------------------------------------
/src/containers/Proxies/style.scss:
--------------------------------------------------------------------------------
1 | .proxies-list {
2 | --item-width: calc(100% / (var(--columns) + 1));
3 | --gap: calc(var(--item-width) / var(--columns));
4 |
5 | display: flex;
6 | margin-right: calc(-1 * var(--gap));
7 | margin-top: 20px;
8 | flex-wrap: wrap;
9 | align-content: flex-start;
10 | list-style: none;
11 |
12 | @include response(xxl) {
13 | --columns: 12;
14 | }
15 |
16 | @include response(xl) {
17 | --columns: 10;
18 | }
19 |
20 | @include response(lg) {
21 | --columns: 8;
22 | }
23 |
24 | @include response(md) {
25 | --columns: 7;
26 | }
27 |
28 | @include response(sm) {
29 | --columns: 6;
30 | }
31 |
32 | @include response(xs) {
33 | --columns: 5;
34 | }
35 |
36 | @include response(xxs) {
37 | --columns: 3;
38 | }
39 |
40 | > li {
41 | display: inline-block;
42 | width: var(--item-width);
43 | margin-right: var(--gap);
44 | flex-shrink: 0;
45 | margin-bottom: 10px;
46 | }
47 | }
48 |
49 | .proxies-speed-test {
50 | line-height: 32px;
51 | margin: 0 2px 0 6px;
52 | color: $color-primary-dark;
53 | text-shadow: 0 2px 6px rgba($color: $color-primary-dark, $alpha: 0.4);
54 | cursor: pointer;
55 | }
56 |
57 | @media (max-width: 768px) {
58 | .proxies-list {
59 | margin-right: 0;
60 | padding-bottom: 20px;
61 | flex-wrap: unset;
62 | flex-direction: column;
63 |
64 | > li {
65 | width: 100%;
66 | margin-right: 0;
67 | margin-bottom: 10px;
68 | }
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/containers/Rules/Provider/index.tsx:
--------------------------------------------------------------------------------
1 | import classnames from 'classnames'
2 |
3 | import { Tag, Icon } from '@components'
4 | import { fromNow } from '@lib/date'
5 | import { useVisible } from '@lib/hook'
6 | import { type RuleProvider } from '@lib/request'
7 | import { useClient, useI18n, useRuleProviders } from '@stores'
8 | import './style.scss'
9 |
10 | interface ProvidersProps {
11 | provider: RuleProvider
12 | }
13 |
14 | export function Provider (props: ProvidersProps) {
15 | const { update } = useRuleProviders()
16 | const { translation, lang } = useI18n()
17 | const client = useClient()
18 |
19 | const { provider } = props
20 | const { t } = translation('Rules')
21 |
22 | const { visible, hide, show } = useVisible()
23 |
24 | function handleUpdate () {
25 | show()
26 | client.updateRuleProvider(provider.name).then(async () => await update()).finally(() => hide())
27 | }
28 |
29 | const updateClassnames = classnames('rule-provider-icon', { 'rule-provider-loading': visible })
30 |
31 | return (
32 |
33 |
34 |
35 | { provider.name }
36 | { provider.vehicleType }
37 | { provider.behavior }
38 | { `${t('ruleCount')}: ${provider.ruleCount}` }
39 |
40 |
41 | {
42 | provider.updatedAt &&
43 | { `${t('providerUpdateTime')}: ${fromNow(new Date(provider.updatedAt), lang)}`}
44 | }
45 |
46 |
47 |
48 |
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/src/containers/Rules/Provider/style.scss:
--------------------------------------------------------------------------------
1 | .rule-provider {
2 | position: relative;
3 | display: flex;
4 | flex-direction: column;
5 | font-size: 16px;
6 | padding: 20px;
7 | color: $color-black-light;
8 | }
9 |
10 | .rule-provider-header {
11 | display: flex;
12 | align-items: center;
13 | justify-content: space-between;
14 | }
15 |
16 | .rule-provider-header-part {
17 | display: flex;
18 | align-items: center;
19 | }
20 |
21 | .rule-provider-name {
22 | width: 120px;
23 | margin-right: 6px;
24 | overflow: hidden;
25 | text-overflow: ellipsis;
26 | white-space: nowrap;
27 | }
28 |
29 | .rule-provider-behavior {
30 | width: 80px;
31 | margin: 0 20px 0 12px;
32 | background-color: $color-primary-dark;
33 | color: #fff;
34 | }
35 |
36 | .rule-provider-spinner {
37 | transform: scale(0.4);
38 | }
39 |
40 | .rule-provider-proxies {
41 | list-style: none;
42 | }
43 |
44 | .rule-provider-item {
45 | box-shadow: 0 0 24px 0 rgba(44, 138, 248, 0.2);
46 |
47 | &:hover {
48 | box-shadow: 0 0 24px 0 rgba(84, 117, 154, 0.4);
49 | }
50 | }
51 |
52 | .rule-provider-update {
53 | line-height: 14px;
54 | font-size: 14px;
55 | }
56 |
57 | .rule-provider-icon {
58 | display: block;
59 | margin-left: 20px;
60 | cursor: pointer;
61 |
62 | &.rule-provider-loading::before {
63 | color: $color-gray-darken;
64 | cursor: not-allowed;
65 | animation: spinner 2s infinite linear;
66 | }
67 | }
68 |
69 | @media (max-width: 768px) {
70 | .rule-provider-header {
71 | flex-direction: column;
72 | align-items: flex-start;
73 | }
74 |
75 | .rule-provider-header-part {
76 | margin: 6px 0;
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/containers/Rules/index.tsx:
--------------------------------------------------------------------------------
1 | import AutoSizer from 'react-virtualized-auto-sizer'
2 | import { FixedSizeList as List } from 'react-window'
3 | import useSWR from 'swr'
4 |
5 | import { Header, Card } from '@components'
6 | import { useI18n, useRule, useRuleProviders } from '@stores'
7 |
8 | import { Provider } from './Provider'
9 | import './style.scss'
10 |
11 | function RuleProviders () {
12 | const { providers } = useRuleProviders()
13 | const { translation } = useI18n()
14 | const { t } = translation('Rules')
15 |
16 | return <>
17 | {
18 | providers.length !== 0 &&
19 |
20 |
21 |
22 | {
23 | providers.map(p => (
24 |
25 | ))
26 | }
27 |
28 |
29 | }
30 | >
31 | }
32 |
33 | export default function Rules () {
34 | const { rules, update } = useRule()
35 | const { translation } = useI18n()
36 | const { t } = translation('Rules')
37 |
38 | useSWR('rules', update)
39 |
40 | function renderRuleItem ({ index, style }: { index: number, style: React.CSSProperties }) {
41 | const rule = rules[index]
42 | return (
43 |
44 |
45 |
{ rule.type }
46 |
{ rule.payload }
47 |
{ rule.proxy }
48 |
49 |
50 | )
51 | }
52 |
53 | return (
54 |
55 |
56 |
57 |
58 |
59 | {
60 | ({ height, width }) => (
61 |
67 | { renderRuleItem }
68 |
69 | )
70 | }
71 |
72 |
73 |
74 | )
75 | }
76 |
--------------------------------------------------------------------------------
/src/containers/Rules/style.scss:
--------------------------------------------------------------------------------
1 | .rule-item {
2 | line-height: 30px;
3 | padding: 5px 0;
4 | height: 50px;
5 | overflow: hidden;
6 | list-style: none;
7 | user-select: none;
8 | border-bottom: 1px solid rgba($color: $color-primary-lightly, $alpha: 0.5);
9 |
10 | .drag-handler {
11 | cursor: row-resize;
12 | margin: 0 10px;
13 | display: flex;
14 | justify-content: center;
15 |
16 | > i {
17 | font-weight: bold;
18 | color: $color-gray-dark;
19 | }
20 | }
21 |
22 | .rule-type {
23 | font-size: 14px;
24 | color: $color-primary-darken;
25 |
26 | > i {
27 | margin-left: 5px;
28 | color: $color-primary-darken;
29 | }
30 | }
31 |
32 | .payload {
33 | font-size: 14px;
34 | color: $color-primary-darken;
35 | cursor: pointer;
36 | }
37 |
38 | .rule-proxy {
39 | font-size: 14px;
40 | color: $color-primary-darken;
41 | }
42 |
43 | .delete-btn {
44 | opacity: 0;
45 | transition: all 300ms ease;
46 |
47 | span {
48 | font-size: 14px;
49 | color: $color-red;
50 | cursor: pointer;
51 | }
52 | }
53 |
54 | &:last-child {
55 | border-bottom: none;
56 | }
57 |
58 | &:hover {
59 | .delete-btn {
60 | opacity: 1;
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/containers/Settings/index.tsx:
--------------------------------------------------------------------------------
1 | import classnames from 'classnames'
2 | import { useAtom, useAtomValue, useSetAtom } from 'jotai'
3 | import { capitalize } from 'lodash-es'
4 | import { useEffect, useMemo } from 'react'
5 |
6 | import { Header, Card, Switch, ButtonSelect, type ButtonSelectOptions, Input, Select } from '@components'
7 | import { type Lang } from '@i18n'
8 | import { useObject } from '@lib/hook'
9 | import { jsBridge } from '@lib/jsBridge'
10 | import { useI18n, useClashXData, useGeneral, useVersion, useClient, identityAtom, hostSelectIdxStorageAtom, hostsStorageAtom, useAPIInfo } from '@stores'
11 | import './style.scss'
12 |
13 | const languageOptions: ButtonSelectOptions[] = [{ label: '中文', value: 'zh_CN' }, { label: 'English', value: 'en_US' }]
14 |
15 | export default function Settings () {
16 | const { premium } = useVersion()
17 | const { data: clashXData, update: fetchClashXData } = useClashXData()
18 | const { general, update: fetchGeneral } = useGeneral()
19 | const setIdentity = useSetAtom(identityAtom)
20 | const [hostSelectIdx, setHostSelectIdx] = useAtom(hostSelectIdxStorageAtom)
21 | const hostsStorage = useAtomValue(hostsStorageAtom)
22 | const apiInfo = useAPIInfo()
23 | const { translation, setLang, lang } = useI18n()
24 | const { t } = translation('Settings')
25 | const client = useClient()
26 | const [info, set] = useObject({
27 | socks5ProxyPort: 7891,
28 | httpProxyPort: 7890,
29 | mixedProxyPort: 0,
30 | })
31 |
32 | useEffect(() => {
33 | set('socks5ProxyPort', general?.socksPort ?? 0)
34 | set('httpProxyPort', general?.port ?? 0)
35 | set('mixedProxyPort', general?.mixedPort ?? 0)
36 | }, [general, set])
37 |
38 | async function handleProxyModeChange (mode: string) {
39 | await client.updateConfig({ mode })
40 | await fetchGeneral()
41 | }
42 |
43 | async function handleStartAtLoginChange (state: boolean) {
44 | await jsBridge?.setStartAtLogin(state)
45 | await fetchClashXData()
46 | }
47 |
48 | async function handleSetSystemProxy (state: boolean) {
49 | await jsBridge?.setSystemProxy(state)
50 | await fetchClashXData()
51 | }
52 |
53 | function changeLanguage (language: Lang) {
54 | setLang(language)
55 | }
56 |
57 | async function handleHttpPortSave () {
58 | await client.updateConfig({ port: info.httpProxyPort })
59 | await fetchGeneral()
60 | }
61 |
62 | async function handleSocksPortSave () {
63 | await client.updateConfig({ 'socks-port': info.socks5ProxyPort })
64 | await fetchGeneral()
65 | }
66 |
67 | async function handleMixedPortSave () {
68 | await client.updateConfig({ 'mixed-port': info.mixedProxyPort })
69 | await fetchGeneral()
70 | }
71 |
72 | async function handleAllowLanChange (state: boolean) {
73 | await client.updateConfig({ 'allow-lan': state })
74 | await fetchGeneral()
75 | }
76 |
77 | const {
78 | hostname: externalControllerHost,
79 | port: externalControllerPort,
80 | } = apiInfo
81 |
82 | const { allowLan, mode } = general
83 |
84 | const startAtLogin = clashXData?.startAtLogin ?? false
85 | const systemProxy = clashXData?.systemProxy ?? false
86 | const isClashX = clashXData?.isClashX ?? false
87 |
88 | const proxyModeOptions = useMemo(() => {
89 | const options = [
90 | { label: t('values.global'), value: 'Global' },
91 | { label: t('values.rules'), value: 'Rule' },
92 | { label: t('values.direct'), value: 'Direct' },
93 | ] as Array<{ label: string, value: string }>
94 | if (premium) {
95 | options.push({ label: t('values.script'), value: 'Script' })
96 | }
97 | return options
98 | }, [t, premium])
99 |
100 | const controllerOptions = hostsStorage.map(
101 | (h, idx) => ({ value: idx, label: {h.hostname} }),
102 | )
103 |
104 | const controllers = isClashX
105 | ? {`${externalControllerHost}:${externalControllerPort}`}
106 | : (
107 | <>
108 |