mirror of
https://github.com/bestnite/sub2clash.git
synced 2025-10-26 17:14:24 +00:00
Refactor(frontend): Refactor frontend using Lit
Refactor(database): use gorm+sqlite instead of bbolt Feat: Add delete short link functionality Fix: Load correct configuration template during meta config conversion
This commit is contained in:
712
server/frontend/src/app.ts
Normal file
712
server/frontend/src/app.ts
Normal file
@@ -0,0 +1,712 @@
|
||||
import { LitElement, html, unsafeCSS } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import globalStyles from "./index.css?inline";
|
||||
import { type Config, type Rule, type RuleProvider } from "./interface.js";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { base64EncodeUnicode, base64decodeUnicode } from "./utils.js";
|
||||
import "./components/rule-provider-input.js";
|
||||
import "./components/rule-input.js";
|
||||
import "./components/rename-input.js";
|
||||
|
||||
@customElement("sub2clash-app")
|
||||
export class Sub2clashApp extends LitElement {
|
||||
static styles = [unsafeCSS(globalStyles)];
|
||||
|
||||
private _config: Config = {
|
||||
clashType: 2,
|
||||
subscriptions: [],
|
||||
proxies: [],
|
||||
refresh: false,
|
||||
autoTest: false,
|
||||
lazy: false,
|
||||
ignoreCountryGroup: false,
|
||||
useUDP: false,
|
||||
template: "",
|
||||
sort: "nameasc",
|
||||
remove: "",
|
||||
nodeList: false,
|
||||
ruleProviders: [],
|
||||
replace: undefined,
|
||||
rules: [],
|
||||
};
|
||||
|
||||
@state()
|
||||
set config(value: Config) {
|
||||
console.log(JSON.stringify(value));
|
||||
if (
|
||||
(value.subscriptions == null || value.subscriptions.length == 0) &&
|
||||
(value.proxies == null || value.proxies.length == 0)
|
||||
) {
|
||||
this.configUrl = "";
|
||||
return;
|
||||
}
|
||||
const oldValue = this._config;
|
||||
this.configUrl = `${
|
||||
window.location.origin
|
||||
}${window.location.pathname.replace(
|
||||
/\/$/,
|
||||
""
|
||||
)}/convert/${base64EncodeUnicode(JSON.stringify(value))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")}`;
|
||||
this._config = value;
|
||||
this.requestUpdate("config", oldValue);
|
||||
}
|
||||
|
||||
get config(): Config {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
@state({
|
||||
hasChanged(value: boolean) {
|
||||
localStorage.setItem("theme", value ? "dark" : "light");
|
||||
document
|
||||
.querySelector("html")
|
||||
?.setAttribute("data-theme", value ? "dark" : "light");
|
||||
return true;
|
||||
},
|
||||
})
|
||||
darkTheme: boolean = this.initTheme();
|
||||
|
||||
initTheme(): boolean {
|
||||
const savedTheme = localStorage.getItem("theme");
|
||||
if (savedTheme != null) {
|
||||
return savedTheme === "dark" ? true : false;
|
||||
}
|
||||
const prefersDark = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)"
|
||||
).matches;
|
||||
return prefersDark;
|
||||
}
|
||||
|
||||
@state()
|
||||
reverseUrl: string = "";
|
||||
|
||||
@state()
|
||||
dialogMessage: string = "";
|
||||
|
||||
@state()
|
||||
dialogTitle: string = "";
|
||||
|
||||
@query("dialog#my_modal")
|
||||
dialog!: HTMLDialogElement;
|
||||
|
||||
showDialog(title: string, message: string): void {
|
||||
if (title.trim() === "") {
|
||||
title = "警告";
|
||||
}
|
||||
this.dialogTitle = title;
|
||||
this.dialogMessage = message;
|
||||
this.dialog.showModal();
|
||||
}
|
||||
|
||||
@state()
|
||||
configUrl: string = "";
|
||||
|
||||
@state()
|
||||
shortLinkID: string = "";
|
||||
|
||||
@state()
|
||||
shortLinkPasswd: string = "";
|
||||
|
||||
async copyToClipboard(content: string, e: HTMLButtonElement) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
let text = e.textContent;
|
||||
e.addEventListener("mouseout", function () {
|
||||
e.textContent = text;
|
||||
});
|
||||
e.textContent = "复制成功";
|
||||
} catch (err) {
|
||||
console.error("复制到剪贴板失败:", err);
|
||||
}
|
||||
}
|
||||
|
||||
generateShortLink() {
|
||||
if (this.configUrl === "") {
|
||||
this.showDialog("", "还未填写配置");
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.post(
|
||||
"./short",
|
||||
{
|
||||
config: this.config,
|
||||
password: this.shortLinkPasswd,
|
||||
id: this.shortLinkID,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((response) => {
|
||||
// 设置返回的短链ID和密码
|
||||
this.shortLinkID = response.data.id;
|
||||
this.shortLinkPasswd = response.data.password;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response && error.response.data) {
|
||||
this.showDialog("", "生成短链失败:" + error.response.data);
|
||||
} else {
|
||||
this.showDialog("", "生成短链失败");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateShortLink() {
|
||||
if (this.shortLinkID.trim() === "") {
|
||||
this.showDialog("", "请输入ID");
|
||||
return;
|
||||
}
|
||||
if (this.shortLinkPasswd.trim() === "") {
|
||||
this.showDialog("", "请输入密码");
|
||||
return;
|
||||
}
|
||||
if (this.configUrl === "") {
|
||||
this.showDialog("", "还未填写配置");
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.put(
|
||||
"./short",
|
||||
{
|
||||
id: this.shortLinkID,
|
||||
config: this.config,
|
||||
password: this.shortLinkPasswd,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
this.showDialog("成功", "更新成功");
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
this.showDialog("", "密码错误");
|
||||
} else if (error.response && error.response.data) {
|
||||
this.showDialog("", "更新短链失败:" + error.response.data);
|
||||
} else {
|
||||
this.showDialog("", "更新短链失败");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteShortLink() {
|
||||
if (this.shortLinkID.trim() === "") {
|
||||
this.showDialog("", "请输入ID");
|
||||
return;
|
||||
}
|
||||
if (this.shortLinkPasswd.trim() === "") {
|
||||
this.showDialog("", "请输入密码");
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.append("password", this.shortLinkPasswd);
|
||||
axios
|
||||
.delete(`./short/${this.shortLinkID}?${params.toString()}`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
this.showDialog("成功", "删除成功");
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
this.showDialog("", "短链不存在或密码错误");
|
||||
} else if (error.response && error.response.data) {
|
||||
this.showDialog("", "删除短链失败:" + error.response.data);
|
||||
} else {
|
||||
this.showDialog("", "删除短链失败");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getRawConfigFromShortLink() {
|
||||
const s = this.reverseUrl.split("/s/");
|
||||
if (s.length != 2) {
|
||||
this.showDialog("", "解析失败");
|
||||
return;
|
||||
}
|
||||
|
||||
axios
|
||||
.get(`./short/${s[1]}`)
|
||||
.then((resp) => {
|
||||
this.config = resp.data;
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
if (err.response && err.response.status == 401) {
|
||||
this.showDialog("", "短链不存在或密码错误");
|
||||
} else if (err.response && err.response.data) {
|
||||
this.showDialog("", "获取配置失败:" + err.response.data);
|
||||
} else {
|
||||
this.showDialog("", "获取配置失败");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
parseConfig() {
|
||||
if (this.reverseUrl.trim() === "") {
|
||||
this.showDialog("", "无法解析,链接为空");
|
||||
}
|
||||
if (this.reverseUrl.indexOf("/s/") != -1) {
|
||||
this.getRawConfigFromShortLink();
|
||||
return;
|
||||
}
|
||||
let url = new URL(this.reverseUrl);
|
||||
const pathSections = url.pathname.split("/");
|
||||
if (pathSections.length < 2) {
|
||||
this.showDialog("", "无法解析,链接格式错误");
|
||||
}
|
||||
if (pathSections[pathSections.length - 2] == "convert") {
|
||||
let base64Data = pathSections[pathSections.length - 1];
|
||||
base64Data = base64Data.replace(/-/g, "+").replace(/_/g, "/");
|
||||
try {
|
||||
const configData = base64decodeUnicode(base64Data);
|
||||
this.config = JSON.parse(configData) as Config;
|
||||
} catch (e: any) {
|
||||
this.showDialog("", "无法解析 Base64,配置格式错误");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.showDialog("", "无法解析,链接格式错误");
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<dialog id="my_modal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">${this.dialogTitle}</h3>
|
||||
<p class="py-4">${this.dialogMessage}</p>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn">关闭</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<div class="max-w-4xl mx-auto p-4 flex flex-col items-center">
|
||||
<form class="w-full max-w-2xl bg-base-100">
|
||||
<fieldset class="fieldset mb-6">
|
||||
<div class="flex flex-row justify-between items-center my-6">
|
||||
<legend
|
||||
class="fieldset-legend text-2xl font-semibold inline-block m-0 p-0">
|
||||
sub2clash
|
||||
</legend>
|
||||
<label class="swap swap-rotate h-7 w-7">
|
||||
<!-- this hidden checkbox controls the state -->
|
||||
<input
|
||||
type="checkbox"
|
||||
class="theme-controller"
|
||||
.checked="${!this.darkTheme}"
|
||||
@change="${() => (this.darkTheme = !this.darkTheme)}" />
|
||||
|
||||
<!-- sun icon -->
|
||||
<svg
|
||||
class="swap-off h-7 w-7 fill-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z" />
|
||||
</svg>
|
||||
|
||||
<!-- moon icon -->
|
||||
<svg
|
||||
class="swap-on h-7 w-7 fill-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" />
|
||||
</svg>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Input URL -->
|
||||
<div class="form-control mb-5">
|
||||
<label class="label mb-1 pl-1">解析链接</label>
|
||||
<div class="join w-full">
|
||||
<input
|
||||
class="input input-bordered w-full join-item"
|
||||
type="text"
|
||||
@change="${(e: Event) => {
|
||||
this.reverseUrl = (e.target as HTMLInputElement).value;
|
||||
}}"
|
||||
placeholder="通过生成的链接重新填写下方设置" />
|
||||
<button
|
||||
class="btn btn-primary join-item"
|
||||
@click="${this.parseConfig}"
|
||||
type="button">
|
||||
解析
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Endpoint -->
|
||||
<div class="form-control mb-3">
|
||||
<label class="label mb-1 pl-1" for="endpoint">客户端类型</label>
|
||||
<select
|
||||
class="select select-bordered w-full"
|
||||
name="endpoint"
|
||||
.value="${this.config.clashType == 1 ? "1" : "2"}"
|
||||
@change="${(e: Event) => {
|
||||
this.config = {
|
||||
...this.config,
|
||||
clashType: Number((e.target as HTMLInputElement).value),
|
||||
};
|
||||
}}">
|
||||
<option value="1">Clash</option>
|
||||
<option value="2" selected>Clash.Meta</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Template -->
|
||||
<div class="form-control mb-3">
|
||||
<label class="label mb-1 pl-1" for="template">模板链接</label>
|
||||
<input
|
||||
class="input input-bordered w-full"
|
||||
name="template"
|
||||
placeholder="输入模板链接"
|
||||
type="text"
|
||||
.value="${this.config.template ?? ""}"
|
||||
@change="${(e: Event) => {
|
||||
this.config = {
|
||||
...this.config,
|
||||
template: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
}}" />
|
||||
</div>
|
||||
|
||||
<!-- Subscription Link -->
|
||||
<div class="form-control mb-3">
|
||||
<label class="label mb-1 pl-1" for="sub">订阅链接</label>
|
||||
<div>
|
||||
<textarea
|
||||
class="textarea textarea-bordered h-24 w-full"
|
||||
name="sub"
|
||||
placeholder="每行输入一个订阅链接"
|
||||
.value="${this.config.subscriptions
|
||||
? this.config.subscriptions.join("\n")
|
||||
: ""}"
|
||||
@change="${(e: Event) => {
|
||||
this.config = {
|
||||
...this.config,
|
||||
subscriptions: (e.target as HTMLInputElement).value
|
||||
.split("\n")
|
||||
.filter((e) => e.trim() !== ""),
|
||||
};
|
||||
}}"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proxy Link -->
|
||||
<div class="form-control mb-3">
|
||||
<label class="label mb-1 pl-1" for="proxy">节点分享链接</label>
|
||||
<div>
|
||||
<textarea
|
||||
class="textarea textarea-bordered h-24 w-full"
|
||||
name="proxy"
|
||||
placeholder="每行输入一个节点分享链接"
|
||||
.value="${this.config.proxies
|
||||
? this.config.proxies.join("\n")
|
||||
: ""}"
|
||||
@change="${(e: Event) => {
|
||||
this.config = {
|
||||
...this.config,
|
||||
proxies: (e.target as HTMLInputElement).value
|
||||
.split("\n")
|
||||
.filter((e) => e.trim() !== ""),
|
||||
};
|
||||
}}"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Agent -->
|
||||
<div class="form-control mb-3">
|
||||
<label class="label mb-1 pl-1" for="user-agent">UA 标识</label>
|
||||
<div>
|
||||
<textarea
|
||||
class="textarea textarea-bordered h-20 w-full"
|
||||
name="user-agent"
|
||||
placeholder="用于获取订阅的 http 请求中的 User-Agent 标识"
|
||||
.value="${this.config.userAgent ?? ""}"
|
||||
@change="${(e: Event) => {
|
||||
this.config = {
|
||||
...this.config,
|
||||
userAgent: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
}}"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sort -->
|
||||
<div class="form-control mb-3">
|
||||
<label class="label mb-1 pl-1" for="sort">
|
||||
国家策略组排序规则
|
||||
</label>
|
||||
<select
|
||||
class="select select-bordered w-full"
|
||||
name="sort"
|
||||
.value="${this.config.sort ?? "nameasc"}"
|
||||
@change="${(e: Event) => {
|
||||
this.config = {
|
||||
...this.config,
|
||||
sort: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
}}">
|
||||
<option value="nameasc">名称(升序)</option>
|
||||
<option value="namedesc">名称(降序)</option>
|
||||
<option value="sizeasc">节点数量(升序)</option>
|
||||
<option value="sizedesc">节点数量(降序)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Remove -->
|
||||
<div class="form-control mb-3">
|
||||
<label class="label mb-1 pl-1" for="remove">
|
||||
<span class="label-text">排除节点</span>
|
||||
</label>
|
||||
<input
|
||||
class="input input-bordered w-full"
|
||||
type="text"
|
||||
name="remove"
|
||||
placeholder="正则表达式"
|
||||
.value="${this.config.remove ?? ""}"
|
||||
@change="${(e: Event) => {
|
||||
this.config = {
|
||||
...this.config,
|
||||
remove: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
}}" />
|
||||
</div>
|
||||
|
||||
<!-- Checkboxes -->
|
||||
<div class="form-control mb-3">
|
||||
<label class="label cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="refresh"
|
||||
class="checkbox"
|
||||
.checked="${this.config.refresh ?? false}"
|
||||
@change="${(e: Event) => {
|
||||
this.config = {
|
||||
...this.config,
|
||||
refresh: (e.target as HTMLInputElement).checked,
|
||||
};
|
||||
}}" />
|
||||
强制重新获取订阅
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control mb-3">
|
||||
<label class="label cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="nodeList"
|
||||
class="checkbox"
|
||||
.checked="${this.config.nodeList ?? false}"
|
||||
@change="${(e: Event) => {
|
||||
this.config = {
|
||||
...this.config,
|
||||
nodeList: (e.target as HTMLInputElement).checked,
|
||||
};
|
||||
}}" />
|
||||
输出为 Node List
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control mb-3">
|
||||
<label class="label cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="autoTest"
|
||||
class="checkbox"
|
||||
.checked="${this.config.autoTest ?? false}"
|
||||
@change="${(e: Event) => {
|
||||
this.config = {
|
||||
...this.config,
|
||||
autoTest: (e.target as HTMLInputElement).checked,
|
||||
};
|
||||
}}" />
|
||||
国家策略组自动测速
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control mb-3">
|
||||
<label class="label cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="lazy"
|
||||
class="checkbox"
|
||||
.checked="${this.config.lazy ?? false}"
|
||||
@change="${(e: Event) => {
|
||||
this.config = {
|
||||
...this.config,
|
||||
lazy: (e.target as HTMLInputElement).checked,
|
||||
};
|
||||
}}" />
|
||||
自动测速启用 lazy 模式
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control mb-3">
|
||||
<label class="label cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="igcg"
|
||||
class="checkbox"
|
||||
.checked="${this.config.ignoreCountryGroup ?? false}"
|
||||
@change="${(e: Event) => {
|
||||
this.config = {
|
||||
...this.config,
|
||||
ignoreCountryGroup: (e.target as HTMLInputElement)
|
||||
.checked,
|
||||
};
|
||||
}}" />
|
||||
不输出国家策略组
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control mb-5">
|
||||
<label class="label cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="useUDP"
|
||||
class="checkbox"
|
||||
.checked="${this.config.useUDP ?? false}"
|
||||
@change="${(e: Event) => {
|
||||
this.config = {
|
||||
...this.config,
|
||||
useUDP: (e.target as HTMLInputElement).checked,
|
||||
};
|
||||
}}" />
|
||||
使用 UDP
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<rule-provider-input
|
||||
@change="${(e: CustomEvent<Array<RuleProvider>>) => {
|
||||
this.config = {
|
||||
...this.config,
|
||||
ruleProviders: e.detail,
|
||||
};
|
||||
}}"></rule-provider-input>
|
||||
|
||||
<rule-input
|
||||
@change="${(e: CustomEvent<Array<Rule>>) => {
|
||||
this.config = {
|
||||
...this.config,
|
||||
rules: e.detail,
|
||||
};
|
||||
}}"></rule-input>
|
||||
<rename-input
|
||||
@change="${(e: CustomEvent<{ [key: string]: string }>) => {
|
||||
this.config = {
|
||||
...this.config,
|
||||
replace: e.detail,
|
||||
};
|
||||
}}"></rename-input>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset mb-8">
|
||||
<legend
|
||||
class="fieldset-legend text-2xl font-semibold mb-4 text-center">
|
||||
输出配置
|
||||
</legend>
|
||||
|
||||
<!-- Display the API Link -->
|
||||
<div class="form-control mb-5">
|
||||
<div class="join w-full mb-2">
|
||||
<input
|
||||
class="input input-bordered w-full join-item cursor-not-allowed"
|
||||
type="text"
|
||||
placeholder="链接"
|
||||
.value="${this.configUrl}"
|
||||
readonly />
|
||||
<button
|
||||
class="btn btn-primary join-item"
|
||||
@click="${(e: Event) => {
|
||||
this.copyToClipboard(
|
||||
this.configUrl,
|
||||
e.target as HTMLButtonElement
|
||||
);
|
||||
}}"
|
||||
type="button">
|
||||
复制链接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-2">
|
||||
<div class="join w-full">
|
||||
<input
|
||||
class="input input-bordered w-1/2 join-item"
|
||||
type="text"
|
||||
placeholder="ID(可选)"
|
||||
.value="${this.shortLinkID}"
|
||||
@change="${(e: Event) => {
|
||||
this.shortLinkID = (e.target as HTMLInputElement).value;
|
||||
}}" />
|
||||
<input
|
||||
class="input input-bordered w-1/2 join-item"
|
||||
type="text"
|
||||
placeholder="密码"
|
||||
.value="${this.shortLinkPasswd}"
|
||||
@change="${(e: Event) => {
|
||||
this.shortLinkPasswd = (e.target as HTMLInputElement).value;
|
||||
}}" />
|
||||
<button
|
||||
class="btn btn-primary join-item"
|
||||
type="button"
|
||||
@click="${this.generateShortLink}">
|
||||
生成短链
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary join-item"
|
||||
@click="${this.updateShortLink}"
|
||||
type="button">
|
||||
更新短链
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary join-item"
|
||||
@click="${this.deleteShortLink}"
|
||||
type="button">
|
||||
删除短链
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary join-item"
|
||||
type="button"
|
||||
@click="${(e: Event) => {
|
||||
this.copyToClipboard(
|
||||
`${window.location.origin}${window.location.pathname}s/${this.shortLinkID}?password=${this.shortLinkPasswd}`,
|
||||
e.target as HTMLButtonElement
|
||||
);
|
||||
}}">
|
||||
复制短链
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<footer class="footer footer-horizontal footer-center mb-8">
|
||||
<aside>
|
||||
<p>
|
||||
Powered by
|
||||
<a class="link" href="https://github.com/bestnite/sub2clash"
|
||||
>sub2clash</a
|
||||
>
|
||||
</p>
|
||||
<p>Version: ${import.meta.env.APP_VERSION ?? "dev"}</p>
|
||||
</aside>
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"sub2clash-app": Sub2clashApp;
|
||||
}
|
||||
}
|
||||
101
server/frontend/src/components/rename-input.ts
Normal file
101
server/frontend/src/components/rename-input.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { LitElement, html, unsafeCSS } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import globalStyles from "../index.css?inline";
|
||||
import type { Rename } from "../interface";
|
||||
|
||||
@customElement("rename-input")
|
||||
export class RenameInput extends LitElement {
|
||||
static styles = [unsafeCSS(globalStyles)];
|
||||
|
||||
private _replaceArray: Array<Rename> = [];
|
||||
|
||||
@state()
|
||||
set replaceArray(value: Array<Rename>) {
|
||||
this._replaceArray = value;
|
||||
let updatedReplaceMap: { [key: string]: string } = {};
|
||||
value.forEach((e) => {
|
||||
updatedReplaceMap[e.old] = e.new;
|
||||
});
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: updatedReplaceMap,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
get replaceArray(): Array<Rename> {
|
||||
return this._replaceArray;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<!-- Rename -->
|
||||
<div class="form-control mb-3">
|
||||
<label class="label mb-1 pl-1">
|
||||
<span class="label-text">节点名称替换</span>
|
||||
<button
|
||||
class="btn btn-primary btn-xs"
|
||||
type="button"
|
||||
@click="${() => {
|
||||
let updatedReplaceArray = [...this.replaceArray];
|
||||
updatedReplaceArray.push({ old: "", new: "" });
|
||||
this.replaceArray = updatedReplaceArray;
|
||||
}}">
|
||||
+
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
${this.replaceArray.map((_, i) => this.RenameTemplate(i))}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
RenameTemplate(index: number) {
|
||||
const replaceItem = this.replaceArray[index];
|
||||
return html`<div class="join mb-1">
|
||||
<input
|
||||
class="input join-item"
|
||||
placeholder="旧名称 (正则表达式)"
|
||||
.value="${replaceItem?.old ?? ""}"
|
||||
@change="${(e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
let updatedReplaceArray = [...this.replaceArray];
|
||||
updatedReplaceArray[index] = {
|
||||
...updatedReplaceArray[index],
|
||||
old: target.value,
|
||||
};
|
||||
this.replaceArray = updatedReplaceArray;
|
||||
}}" />
|
||||
<input
|
||||
class="input join-item"
|
||||
placeholder="新名称"
|
||||
.value="${replaceItem?.new ?? ""}"
|
||||
@change="${(e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
let updatedReplaceArray = [...this.replaceArray];
|
||||
updatedReplaceArray[index] = {
|
||||
...updatedReplaceArray[index],
|
||||
new: target.value,
|
||||
};
|
||||
this.replaceArray = updatedReplaceArray;
|
||||
}}" />
|
||||
<button
|
||||
class="btn join-item bg-error"
|
||||
type="button"
|
||||
@click="${() => {
|
||||
let updatedReplaceArray = this.replaceArray.filter(
|
||||
(_, i) => i !== index
|
||||
);
|
||||
this.replaceArray = updatedReplaceArray;
|
||||
}}">
|
||||
删除
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"rename-input": RenameInput;
|
||||
}
|
||||
}
|
||||
92
server/frontend/src/components/rule-input.ts
Normal file
92
server/frontend/src/components/rule-input.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { LitElement, html, unsafeCSS } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import type { Rule } from "../interface";
|
||||
import globalStyles from "../index.css?inline";
|
||||
|
||||
@customElement("rule-input")
|
||||
export class RuleInput extends LitElement {
|
||||
static styles = [unsafeCSS(globalStyles)];
|
||||
|
||||
_rules: Array<Rule> = [];
|
||||
@state()
|
||||
set rules(value: Array<Rule>) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: value,
|
||||
})
|
||||
);
|
||||
this._rules = value;
|
||||
}
|
||||
get rules() {
|
||||
return this._rules;
|
||||
}
|
||||
render() {
|
||||
return html`<!-- Rule -->
|
||||
<div class="form-control mb-3">
|
||||
<label class="label mb-1 pl-1">
|
||||
<span class="label-text">规则</span>
|
||||
<button
|
||||
class="btn btn-primary btn-xs"
|
||||
type="button"
|
||||
@click="${() => {
|
||||
let updatedRules = this.rules ? [...this.rules] : [];
|
||||
updatedRules?.push({
|
||||
rule: "",
|
||||
prepend: false,
|
||||
});
|
||||
this.rules = updatedRules;
|
||||
}}">
|
||||
+
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
${this.rules?.map((_, i) => this.RuleTemplate(i))}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
RuleTemplate(index: number) {
|
||||
return html`<div class="join mb-1">
|
||||
<input
|
||||
class="input join-item"
|
||||
placeholder="规则"
|
||||
.value="${this.rules![index].rule}"
|
||||
@change="${(e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
let updatedRules = this.rules;
|
||||
updatedRules![index].rule = target.value;
|
||||
this.rules = updatedRules;
|
||||
}}" />
|
||||
<div class="tooltip" data-tip="是否置于规则列表最前">
|
||||
<select
|
||||
class="select join-item w-fit"
|
||||
.value="${String(this.rules![index].prepend)}"
|
||||
@change="${(e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
let updatedRules = this.rules;
|
||||
updatedRules![index].prepend = Boolean(target.value);
|
||||
this.rules = updatedRules;
|
||||
}}">
|
||||
<option value="true">是</option>
|
||||
<option value="false" selected>否</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
class="btn join-item bg-error"
|
||||
type="button"
|
||||
@click="${() => {
|
||||
let updatedRules = this.rules?.filter((_, i) => i !== index);
|
||||
this.rules = updatedRules;
|
||||
}}">
|
||||
删除
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"rule-input": RuleInput;
|
||||
}
|
||||
}
|
||||
143
server/frontend/src/components/rule-provider-input.ts
Normal file
143
server/frontend/src/components/rule-provider-input.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { LitElement, html, unsafeCSS } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import type { RuleProvider } from "../interface";
|
||||
import globalStyles from "../index.css?inline";
|
||||
|
||||
@customElement("rule-provider-input")
|
||||
export class RuleProviderInput extends LitElement {
|
||||
static styles = [unsafeCSS(globalStyles)];
|
||||
|
||||
_ruleProviders: Array<RuleProvider> = [];
|
||||
|
||||
@state()
|
||||
set ruleProviders(value) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: value,
|
||||
})
|
||||
);
|
||||
this._ruleProviders = value;
|
||||
}
|
||||
|
||||
get ruleProviders() {
|
||||
return this._ruleProviders;
|
||||
}
|
||||
|
||||
RuleProviderTemplate(index: number) {
|
||||
return html`
|
||||
<div class="join mb-1">
|
||||
<div class="tooltip" data-tip="不能重复">
|
||||
<input
|
||||
class="input join-item"
|
||||
placeholder="名称"
|
||||
.value="${this.ruleProviders![index].name}"
|
||||
@change="${(e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
let updatedRuleProviders = this.ruleProviders;
|
||||
updatedRuleProviders![index].name = target.value;
|
||||
this.ruleProviders = updatedRuleProviders;
|
||||
}}" />
|
||||
</div>
|
||||
<div class="tooltip" data-tip="类型">
|
||||
<select
|
||||
class="select join-item w-fit"
|
||||
.value="${this.ruleProviders![index].behavior}"
|
||||
@change="${(e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
let updatedRuleProviders = this.ruleProviders;
|
||||
updatedRuleProviders![index].behavior = target.value;
|
||||
this.ruleProviders = updatedRuleProviders;
|
||||
}}">
|
||||
<option value="classical" selected>classical</option>
|
||||
<option value="domain">domain</option>
|
||||
<option value="ipcidr">ipcidr</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
class="input join-item"
|
||||
placeholder="Url"
|
||||
.value="${this.ruleProviders![index].url}"
|
||||
@change="${(e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
let updatedRuleProviders = this.ruleProviders;
|
||||
updatedRuleProviders![index].url = target.value;
|
||||
this.ruleProviders = updatedRuleProviders;
|
||||
}}" />
|
||||
</div>
|
||||
<input
|
||||
class="input join-item"
|
||||
placeholder="出站策略组"
|
||||
.value="${this.ruleProviders![index].group}"
|
||||
@change="${(e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
let updatedRuleProviders = this.ruleProviders;
|
||||
updatedRuleProviders![index].group = target.value;
|
||||
this.ruleProviders = updatedRuleProviders;
|
||||
}}" />
|
||||
<div class="tooltip" data-tip="是否置于规则列表最前">
|
||||
<select
|
||||
class="select join-item w-fit"
|
||||
.value="${String(this.ruleProviders![index].prepend)}"
|
||||
@change="${(e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
let updatedRuleProviders = this.ruleProviders;
|
||||
updatedRuleProviders![index].prepend = Boolean(target.value);
|
||||
this.ruleProviders = updatedRuleProviders;
|
||||
}}">
|
||||
<option value="true">是</option>
|
||||
<option value="false" selected>否</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
class="btn join-item bg-error"
|
||||
type="button"
|
||||
@click="${() => {
|
||||
let updatedRuleProviders = this.ruleProviders?.filter(
|
||||
(_, i) => i !== index
|
||||
);
|
||||
this.ruleProviders = updatedRuleProviders;
|
||||
}}">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <!-- Rule Provider -->
|
||||
<div class="form-control mb-3">
|
||||
<label class="label mb-1 pl-1">
|
||||
<span class="label-text">Rule Provider</span>
|
||||
<button
|
||||
class="btn btn-primary btn-xs"
|
||||
type="button"
|
||||
@click="${() => {
|
||||
let updatedRuleProviders = this.ruleProviders
|
||||
? [...this.ruleProviders]
|
||||
: [];
|
||||
updatedRuleProviders.push({
|
||||
behavior: "classical",
|
||||
url: "",
|
||||
name: "",
|
||||
prepend: false,
|
||||
group: "",
|
||||
});
|
||||
this.ruleProviders = updatedRuleProviders;
|
||||
}}">
|
||||
+
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
${this.ruleProviders?.map((_, i) => this.RuleProviderTemplate(i))}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"rule-provider-input": RuleProviderInput;
|
||||
}
|
||||
}
|
||||
72
server/frontend/src/index.css
Normal file
72
server/frontend/src/index.css
Normal file
@@ -0,0 +1,72 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: "light";
|
||||
default: false;
|
||||
prefersdark: false;
|
||||
color-scheme: "light";
|
||||
--color-base-100: oklch(98% 0.001 106.423);
|
||||
--color-base-200: oklch(97% 0.001 106.424);
|
||||
--color-base-300: oklch(92% 0.003 48.717);
|
||||
--color-base-content: oklch(21% 0.006 56.043);
|
||||
--color-primary: oklch(54% 0.281 293.009);
|
||||
--color-primary-content: oklch(96% 0.016 293.756);
|
||||
--color-secondary: oklch(57% 0.245 27.325);
|
||||
--color-secondary-content: oklch(97% 0.013 17.38);
|
||||
--color-accent: oklch(59% 0.249 0.584);
|
||||
--color-accent-content: oklch(97% 0.014 343.198);
|
||||
--color-neutral: oklch(14% 0.004 49.25);
|
||||
--color-neutral-content: oklch(98% 0.001 106.423);
|
||||
--color-info: oklch(78% 0.154 211.53);
|
||||
--color-info-content: oklch(30% 0.056 229.695);
|
||||
--color-success: oklch(79% 0.209 151.711);
|
||||
--color-success-content: oklch(26% 0.065 152.934);
|
||||
--color-warning: oklch(82% 0.189 84.429);
|
||||
--color-warning-content: oklch(27% 0.077 45.635);
|
||||
--color-error: oklch(71% 0.194 13.428);
|
||||
--color-error-content: oklch(27% 0.105 12.094);
|
||||
--radius-selector: 1rem;
|
||||
--radius-field: 1rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1.5px;
|
||||
--depth: 0;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: "dark";
|
||||
default: true;
|
||||
prefersdark: true;
|
||||
color-scheme: "dark";
|
||||
--color-base-100: oklch(21% 0.006 285.885);
|
||||
--color-base-200: oklch(21% 0.006 285.885);
|
||||
--color-base-300: oklch(27% 0.006 286.033);
|
||||
--color-base-content: oklch(96% 0.001 286.375);
|
||||
--color-primary: oklch(55% 0.288 302.321);
|
||||
--color-primary-content: oklch(97% 0.014 308.299);
|
||||
--color-secondary: oklch(44% 0.03 256.802);
|
||||
--color-secondary-content: oklch(98% 0.002 247.839);
|
||||
--color-accent: oklch(59% 0.249 0.584);
|
||||
--color-accent-content: oklch(97% 0.014 343.198);
|
||||
--color-neutral: oklch(37% 0.013 285.805);
|
||||
--color-neutral-content: oklch(98% 0 0);
|
||||
--color-info: oklch(54% 0.245 262.881);
|
||||
--color-info-content: oklch(97% 0.014 254.604);
|
||||
--color-success: oklch(64% 0.2 131.684);
|
||||
--color-success-content: oklch(98% 0.031 120.757);
|
||||
--color-warning: oklch(66% 0.179 58.318);
|
||||
--color-warning-content: oklch(98% 0.022 95.277);
|
||||
--color-error: oklch(58% 0.253 17.585);
|
||||
--color-error-content: oklch(96% 0.015 12.422);
|
||||
--radius-selector: 1rem;
|
||||
--radius-field: 1rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 0;
|
||||
--noise: 1;
|
||||
}
|
||||
36
server/frontend/src/interface.ts
Normal file
36
server/frontend/src/interface.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface RuleProvider {
|
||||
behavior: string;
|
||||
url: string;
|
||||
group: string;
|
||||
prepend: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Rule {
|
||||
rule: string;
|
||||
prepend: boolean;
|
||||
}
|
||||
|
||||
export interface Rename {
|
||||
old: string;
|
||||
new: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
clashType: number;
|
||||
subscriptions?: string[];
|
||||
proxies?: string[];
|
||||
userAgent?: string;
|
||||
refresh?: boolean;
|
||||
autoTest?: boolean;
|
||||
lazy?: boolean;
|
||||
nodeList?: boolean;
|
||||
ignoreCountryGroup?: boolean;
|
||||
useUDP?: boolean;
|
||||
template?: string;
|
||||
ruleProviders?: RuleProvider[];
|
||||
rules?: Rule[];
|
||||
sort?: string;
|
||||
remove?: string;
|
||||
replace?: { [key: string]: string };
|
||||
}
|
||||
15
server/frontend/src/utils.ts
Normal file
15
server/frontend/src/utils.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function base64EncodeUnicode(str: string) {
|
||||
const bytes = new TextEncoder().encode(str);
|
||||
let binary = "";
|
||||
bytes.forEach((b) => (binary += String.fromCharCode(b)));
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export function base64decodeUnicode(str: string) {
|
||||
const binaryString = atob(str);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
Reference in New Issue
Block a user