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:
2025-10-19 03:13:10 +11:00
parent 1e8a79c2d2
commit 86b74f30e7
33 changed files with 3612 additions and 1064 deletions

24
server/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>sub2clash</title>
<link rel="stylesheet" href="./src/index.css" />
<script type="module" src="/src/app.ts"></script>
</head>
<body>
<sub2clash-app>
</sub2clash-app>
</body>
</html>

2030
server/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{
"name": "sub2clash-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.14",
"axios": "^1.12.2",
"daisyui": "^5.3.7",
"lit": "^3.3.1",
"tailwindcss": "^4.1.14"
},
"devDependencies": {
"typescript": "~5.9.3",
"vite": "^7.1.7"
}
}

712
server/frontend/src/app.ts Normal file
View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}

View 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 };
}

View 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);
}

View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"types": [
"vite/client"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"src"
]
}

View File

@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [tailwindcss()],
});