master
yutent 2022-09-28 15:03:46 +08:00
commit bf7b6b7993
13 changed files with 556 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@ -0,0 +1,17 @@
.DS_Store
.AppleDouble
.LSOverride
.idea
._*
.Spotlight-V100
.Trashes
build
build/**
node_modules
node_modules/**
package-lock.json

2
Readme.md Normal file
View File

@ -0,0 +1,2 @@
# Clash Web Panel
> 完整功能版。

53
electron/main.js Normal file
View File

@ -0,0 +1,53 @@
/* app */
const { app, session, Menu, ipcMain } = require('electron')
const { join } = require('path')
const fs = require('fs')
// const createTray = require('./tools/tray')
const { createMainWindow } = require('./tools/windows')
const ROOT = __dirname
const APP_DIR = join(app.getPath('appData'), './clash')
const CONFIG_FILE = join(APP_DIR, './config.json')
/* ----------------------------------------------------- */
app.commandLine.appendSwitch('lang', 'zh-CN')
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required')
Menu.setApplicationMenu(null)
/* ----------------------------------------------------- */
// 初始化应用
app.once('ready', () => {
let win = createMainWindow(join(ROOT, './images/app.png'))
// app.toggleTray = createTray(win)
})
ipcMain.on('app', (ev, conn) => {
switch (conn.type) {
case 'saveToken':
fs.writeFile(CONFIG_FILE, conn.data, function (err) {})
ev.returnValue = true
break
case 'readToken':
{
let cache = ''
try {
cache = fs.readFileSync(CONFIG_FILE).toString()
} catch (err) {}
ev.returnValue = cache
}
break
case 'toggleTray':
{
app.toggleTray(conn.data)
ev.returnValue = true
}
break
}
})

15
package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "clash-manager",
"version": "1.0.0",
"description": "clash管理面板",
"main": "src/main.js",
"scripts": {
"start": "electron ."
},
"author": {
"name": "yutent",
"email": "yutent.io@gmail.com"
},
"devDependencies": {},
"dependencies": {}
}

1
src/css/app.css Normal file
View File

@ -0,0 +1 @@
html{width:840px;height:540px}body{width:100%;height:100%;font-size:14px;color:var(--color-dark-1)}.app{display:flex;width:100%;height:100%;background:#f7f8fb}.holder{flex:1}.aside{flex-shrink:0;display:flex;flex-direction:column;width:128px;box-shadow:4px 0 12px rgba(0,0,0,.03)}.aside .logo{width:64px;height:64px;margin:16px auto;background:url(../icons/128x128.png) no-repeat;background-size:100%}.aside .nav-list{display:flex;flex-direction:column;width:100%;padding:0 16px}.aside .nav-list .item{width:100%;height:32px;margin-top:16px;line-height:32px;border-radius:16px;text-align:center;cursor:pointer;transition:color .2s linear,background .2s linear}.aside .nav-list .item:hover{color:var(--color-blue-1)}.aside .nav-list .item.active{color:#fff;background:var(--color-blue-1)}.aside .version{line-height:36px;text-align:center}.tab-content{flex:1;flex-shrink:0;display:flex;flex-direction:column;height:540px;padding:16px}.tab-content .field{display:flex;align-items:center;width:100%;height:36px;margin-top:12px}.tab-content .field .label{width:128px;font-weight:bold;color:var(--color-grey-3)}.tab-content .field .full{flex:1}.tab-content.configs{width:49%;margin-left:1%}.tab-content.remote wc-button,.tab-content.rules wc-button{width:64px;min-width:64px;margin-left:16px}.tab-content.remote .scroll,.tab-content.rules .scroll{overflow:hidden;flex:1;margin-top:24px}.tab-content.remote .card,.tab-content.rules .card{margin:0 auto}.tab-content.remote .list,.tab-content.rules .list{word-break:break-all}.tab-content.remote .list .item,.tab-content.rules .list .item{display:flex;align-items:center;justify-content:space-between;height:36px;padding:0 16px;border-bottom:1px solid var(--color-plain-1);font-family:Menlo,"Courier New",Courier,monospace;transition:background .2s linear}.tab-content.remote .list .item:hover,.tab-content.rules .list .item:hover{background-color:var(--color-plain-1)}.tab-content.remote .list span.type,.tab-content.rules .list span.type{padding:2px 3px;border-radius:2px;background-color:var(--color-blue-1);color:#fff}.card{flex:1;flex-shrink:0;display:flex;flex-direction:column;flex-wrap:wrap;width:100%;max-height:100%;padding:12px;margin:0 auto 24px;border:0;border-radius:4px;background-color:#fff;box-shadow:0 0 8px rgba(0,0,0,.075)}.card legend{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;color:var(--color-blue-1);font-weight:bold}

170
src/css/app.scss Normal file
View File

@ -0,0 +1,170 @@
html {
width: 840px;
height: 540px;
}
body {
width: 100%;
height: 100%;
font-size: 14px;
color: var(--color-dark-1);
}
.app {
display: flex;
width: 100%;
height: 100%;
background: #f7f8fb;
}
.holder {
flex: 1;
}
.aside {
flex-shrink: 0;
display: flex;
flex-direction: column;
width: 128px;
box-shadow: 4px 0 12px rgba(0, 0, 0, 0.03);
.logo {
width: 64px;
height: 64px;
margin: 16px auto;
background: url(../icons/128x128.png) no-repeat;
background-size: 100%;
}
.nav-list {
display: flex;
flex-direction: column;
width: 100%;
padding: 0 16px;
.item {
width: 100%;
height: 32px;
margin-top: 16px;
line-height: 32px;
border-radius: 16px;
text-align: center;
cursor: pointer;
transition: color 0.2s linear, background 0.2s linear;
&:hover {
color: var(--color-blue-1);
}
&.active {
color: #fff;
background: var(--color-blue-1);
}
}
}
.version {
line-height: 36px;
text-align: center;
}
}
.tab-content {
flex: 1;
flex-shrink: 0;
display: flex;
flex-direction: column;
height: 540px;
padding: 16px;
.field {
display: flex;
align-items: center;
width: 100%;
height: 36px;
margin-top: 12px;
.label {
width: 128px;
font-weight: bold;
color: var(--color-grey-3);
}
.full {
flex: 1;
}
}
&.configs {
width: 49%;
margin-left: 1%;
}
&.remote,
&.rules {
wc-button {
width: 64px;
min-width: 64px;
margin-left: 16px;
}
.scroll {
overflow: hidden;
flex: 1;
margin-top: 24px;
}
.card {
margin: 0 auto;
}
.list {
word-break: break-all;
.item {
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
padding: 0 16px;
border-bottom: 1px solid var(--color-plain-1);
font-family: Menlo, 'Courier New', Courier, monospace;
transition: background 0.2s linear;
&:hover {
background-color: var(--color-plain-1);
}
}
span.type {
padding: 2px 3px;
border-radius: 2px;
background-color: var(--color-blue-1);
color: #fff;
}
}
}
}
.card {
flex: 1;
flex-shrink: 0;
display: flex;
flex-direction: column;
flex-wrap: wrap;
width: 100%;
max-height: 100%;
padding: 12px;
margin: 0 auto 24px;
border: 0;
border-radius: 4px;
background-color: #fff;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.075);
legend {
-webkit-touch-callout: none;
user-select: none;
color: var(--color-blue-1);
font-weight: bold;
}
}

BIN
src/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
src/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
src/icons/192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
src/icons/256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
src/icons/512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

126
src/index.html Normal file
View File

@ -0,0 +1,126 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no">
<title>Clash Web Panel</title>
<link rel="favicon" href="favicon.ico">
<link rel="icon" type="image/x-icon" href="icons/192x192.png">
<link href="//unpkg.yutent.top/@bytedo/wcui/dist/css/reset-basic.css" rel="stylesheet">
<link href="css/app.css" rel="stylesheet">
<script src="js/app.js" type="module"></script>
</head>
<body>
<div class="app noselect" anot="app">
<aside class="aside">
<a class="logo"></a>
<nav class="nav-list">
<a
class="item"
:for="i it in navs"
:class="{active: i === tab}"
@click="changeTab(i)"
:text="it">
</a>
</nav>
<span class="holder"></span>
<span class="version">{{version}}</span>
</aside>
<main class="tab-content" :if="tab === 0"></main>
<main class="tab-content rules" :if="tab === 1">
<fieldset class="card">
<legend>规则列表</legend>
<wc-scroll class="scroll">
<ul class="list">
<li class="item" :for="i it in rules">
<span>{{it.type}}</span>
<span>{{it.payload}}</span>
<span class="type">{{it.proxy}}</span>
</li>
</ul>
</wc-scroll>
</fieldset>
</main>
<main class="tab-content" :if="tab === 2"></main>
<main class="tab-content remote" :if="tab === 3">
<fieldset class="card">
<legend>订阅配置</legend>
<section class="field">
<span class="label">订阅地址</span>
<wc-input class="full" :duplex="remote.link"></wc-input>
<wc-button type="info" :disabled="!remote.link" @click="updateRemote">更新</wc-button>
</section>
<wc-scroll class="scroll">
<ul class="list">
<li class="item" :for="i it in remote.list">
{{i + 1}}.
<span>{{it[0]}}</span>
<span class="type">{{it[1]}}</span>
</li>
</ul>
</wc-scroll>
</fieldset>
</main>
<main class="tab-content configs" :if="tab === 4">
<fieldset class="card">
<legend>系统设置</legend>
<section class="field">
<span class="label">开机启动Clash</span>
<wc-switch disabled></wc-switch>
</section>
<section class="field">
<span class="label">设置为系统代理</span>
<wc-switch disabled></wc-switch>
</section>
<section class="field">
<span class="label">允许局域网的连接</span>
<wc-switch :duplex="configs.allowLan"></wc-switch>
</section>
</fieldset>
<fieldset class="card">
<legend>Clash设置</legend>
<section class="field">
<span class="label">代理模式</span>
<wc-radio-group :duplex="configs.proxy">
<wc-radio value="global">全局</wc-radio>
<wc-radio value="rule">规则</wc-radio>
<wc-radio value="direct">直连</wc-radio>
</wc-radio-group>
</section>
<section class="field">
<span class="label">Socks5 代理端口</span>
<wc-input size="small" :duplex="configs.socks5"></wc-input>
</section>
<section class="field">
<span class="label">HTTP 代理端口</span>
<wc-input size="small" :duplex="configs.http"></wc-input>
</section>
<section class="field">
<span class="label">混合代理端口</span>
<wc-input size="small" :duplex="configs.mixed"></wc-input>
</section>
</fieldset>
</main>
</div>
</body>
</html>

172
src/js/app.js Normal file
View File

@ -0,0 +1,172 @@
/**
*
* @author yutent<yutent.io@gmail.com>
* @date 2022/05/16 00:38:48
*/
import Anot from '//unpkg.yutent.top/anot/dist/anot.js'
import fetch from '//unpkg.yutent.top/@bytedo/fetch/dist/index.js'
import '//unpkg.yutent.top/@bytedo/wcui/dist/layer/index.js'
import '//unpkg.yutent.top/@bytedo/wcui/dist/form/switch.js'
import '//unpkg.yutent.top/@bytedo/wcui/dist/form/radio.js'
import '//unpkg.yutent.top/@bytedo/wcui/dist/form/button.js'
fetch.inject.response(r => r.json())
Anot({
$id: 'app',
state: {
version: '1.10.0',
navs: ['代理', '规则', '连接', '订阅', '设置'],
tab: +Anot.ls('tab') || 0,
contrl: {
host: '//127.0.0.1:',
port: 6767,
key: ''
},
rules: [],
remote: {
link: Anot.ls('remote_link') || '',
list: []
},
configs: {
proxy: 'rule',
http: 0,
socks5: 0,
mixed: 7890,
allowLan: false
}
},
mounted() {
if (this.contrl.port) {
fetch.BASE_URL = this.contrl.host + this.contrl.port
} else {
return layer
.prompt('请输入Clash本地管理端口', (v, done) => {
let n = +v.trim()
if (n === n) {
done()
}
})
.then(v => {
Anot.ls('web_port', v)
location.reload()
})
.catch(e => {
location.reload()
})
}
this.getVersion()
this.getConfig()
this.getRule()
},
methods: {
changeTab(idx) {
this.tab = idx
Anot.ls('tab', idx)
},
getVersion() {
fetch('/version').then(r => {
this.version = r.version
})
},
getConfig() {
fetch('/configs').then(r => {
console.log(r)
Object.assign(this.configs, {
proxy: r.mode,
http: r.port,
socks5: r['socks-port'],
mixed: r['mixed-port'],
allowLan: r['allow-lan']
})
// this.version = r.version
})
},
getRule() {
fetch('/rules').then(r => {
console.log(r)
this.rules = r.rules
})
},
updateRemote() {
if (this.remote.link) {
if (!/^(https?:)?\/\//.test(this.remote.link)) {
return layer.toast('订阅地址格式不正确', 'error')
}
Anot.ls('remote_link', this.remote.link)
} else {
return
}
let txt = ''
let names = ['DIRECT']
let r = (Anot.ss('temp') || '').trim()
this.remote.list = r.split('\n').map(it => {
let tmp = decodeURIComponent(it).split('#')
let tmp2 = new URL(tmp[0])
let protocol = tmp2.protocol.slice(0, -1)
let path = tmp2.pathname.slice(2)
let info = { name: tmp.pop(), type: protocol, udp: true }
path = path.split(':')
info.port = path.pop()
path = path[0].split('@')
info.server = path.pop()
switch (protocol) {
case 'ss':
path = atob(path[0]).split(':')
console.log(path)
Object.assign(info, {
cipher: path[0],
password: path[1]
})
break
case 'trojan':
Object.assign(info, {
password: path[0],
sni: tmp2.searchParams.get('sni'),
'skip-cert-verify': true
})
break
}
names.push(info.name)
txt += ` - { name: ${info.name}, type: ${info.type}, server: ${info.server}, port: ${
info.port
}, password: ${info.password}, udp: true, ${info.type === 'ss' ? 'cipher' : 'sni'}: ${
info.type === 'ss' ? info.cipher : info.sni
}}\n`
return [info.name, info.type]
})
// window.foo = txt
// window.bar = names.join(', ')
// // console.log(txt)
return
window
.fetch(this.remote.link, { cors: true })
.then(r => r.text())
.then(r => {
r = atob(r).trim()
Anot.ss('temp', r)
layer.toast('订阅更新成功', 'success')
this.remote.list = r.split('\n').map(it => {
let tmp = decodeURIComponent(it).split('#')
return [tmp.pop(), tmp[0]?.split('://').shift()]
})
})
}
}
})