init: init proj
This commit is contained in:
6
.env.example
Normal file
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 服务端口(监听 0.0.0.0,局域网内的手机等设备可直接访问)
|
||||||
|
PORT=3000
|
||||||
|
# SQLite 数据目录(相对 server/ 或绝对路径)
|
||||||
|
DATA_DIR=./data
|
||||||
|
# 管理界面鉴权 token。未设置时 /api/* 仅放行 localhost 与局域网私有网段
|
||||||
|
ADMIN_TOKEN=
|
||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
99
CLAUDE.md
Normal file
99
CLAUDE.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## 这是什么
|
||||||
|
|
||||||
|
自托管的代理节点与分流规则管理平台:Web 界面管理多协议节点(VMess/Trojan/Shadowsocks/Hysteria2)与策略组、规则,向 macOS Surge、iOS ShadowRocket、Android ClashMeta 下发托管订阅。规则模板来自 [ClashConnectRules/Surge](https://github.com/ClashConnectRules/Surge),节点来自 `~/Development/Fusion/Projects/edge-nodes/` 交付的边缘节点舰队。
|
||||||
|
|
||||||
|
## 命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev # server :3000 + web :5173(都监听 0.0.0.0,局域网可访问)
|
||||||
|
pnpm -r typecheck # 三个包全量类型检查
|
||||||
|
pnpm test # 服务端单测(vitest)
|
||||||
|
pnpm --filter @proxy-station/web build
|
||||||
|
|
||||||
|
# 单测过滤(在 server/ 目录下)
|
||||||
|
npx vitest run parse # 按文件名
|
||||||
|
npx vitest run -t "round-trip" # 按用例名
|
||||||
|
npx vitest # watch 模式
|
||||||
|
|
||||||
|
# 校验生成的 Clash 配置(最有价值的一条端到端验证)
|
||||||
|
curl -s "http://localhost:3000/sub/<token>/clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**网络与安装的两个坑**:本机会话注入的 `HTTPS_PROXY` 会让 git/构建挂死,跑任何命令前先剥掉代理(见用户全局 CLAUDE.md)。pnpm 在无 TTY 时执行 `pnpm install` 需要 `CI=true`,原生模块(better-sqlite3、esbuild、vue-demi)的构建许可写在 `pnpm-workspace.yaml` 的 `allowBuilds`。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
pnpm monorepo:`shared/`(zod schema 与 DTO,前后端共用源码,非构建产物)+ `server/`(Hono + better-sqlite3 + Drizzle)+ `web/`(Vue 3 + Naive UI + Pinia)。生产模式由 server 静态托管 web 产物,单端口。
|
||||||
|
|
||||||
|
### 配置生成流水线(核心)
|
||||||
|
|
||||||
|
```
|
||||||
|
SQLite → buildProfileIR({nodeIds}) → ProfileIR → 四个序列化器
|
||||||
|
├─ generateSurge(ir, {selfUrl})
|
||||||
|
├─ generateShadowrocket(ir) 节点订阅
|
||||||
|
├─ generateShadowrocketConf(ir, …) 策略组+规则
|
||||||
|
└─ generateClash(ir, {convertBaseUrl})
|
||||||
|
```
|
||||||
|
|
||||||
|
`services/ir.ts` 是唯一的读库出口,把节点、策略组、规则、Surge 原文段落收成与客户端无关的 `ProfileIR`;四个生成器只读 IR,互不依赖。**新增客户端就是加一个序列化器**,不要在生成器里再查数据库。
|
||||||
|
|
||||||
|
IR 层承担三件防护,改动时不要绕过:
|
||||||
|
- 停用或已删除的策略组会被剔除,引用它的成员与规则一并跳过
|
||||||
|
- `FINAL` 规则永远保留,其策略组失效时降级为 `DIRECT`
|
||||||
|
- `nodeIds` 为空数组表示「不限制」,非空则按订阅分配的节点范围过滤
|
||||||
|
|
||||||
|
### 客户端能力矩阵
|
||||||
|
|
||||||
|
`shared/src/protocols.ts` 的 `clientSupports()` 决定某节点能否下发给某客户端,生成器据此静默排除并回报 `excluded`。当前唯一规则:**Surge 不支持 Shadowsocks over WebSocket**(Surge 的 ss 类型只有 simple-obfs,没有 WS 传输)。这不是 bug,同机的 VMess/Trojan 线路凭据相同、覆盖等价。
|
||||||
|
|
||||||
|
### 协议字段差异
|
||||||
|
|
||||||
|
同一协议在三端写法不同,映射集中在 `shared/src/protocols.ts` 与各生成器:
|
||||||
|
- SS cipher:分享链接用 Xray 命名 `chacha20-poly1305`,Surge/Clash 要 `chacha20-ietf-poly1305`(`normalizeSsCipher()`)
|
||||||
|
- VMess `alterId=0` 必须输出 Surge 的 `vmess-aead=true`,否则连不上且症状隐晦
|
||||||
|
- Hysteria2 的 salamander 混淆:Surge 用 `salamander-password=`,Clash 用 `obfs`/`obfs-password`
|
||||||
|
- SS over WS 在 Clash 侧唯一可行方案是 `plugin: v2ray-plugin`
|
||||||
|
|
||||||
|
这些写法参考了 `../sub-router` 项目(用户已在生产使用的同类系统),遇到新的客户端适配问题优先查它。
|
||||||
|
|
||||||
|
### 策略组的两种取节点方式
|
||||||
|
|
||||||
|
`includeAllNodes` 与 `filterRegex` 可以共存,生成器里 **filterRegex 分支优先**:
|
||||||
|
- 有 `filterRegex` → 动态筛选:Surge `include-all-proxies=true, policy-regex-filter=…`/Clash `filter:`/ShadowRocket `use=true, policy-regex-filter=…`。新增同地区节点会自动进组
|
||||||
|
- 仅 `includeAllNodes` → Surge 静态展开全部节点名,Clash/ShadowRocket 仍用动态引用
|
||||||
|
|
||||||
|
**地区组靠节点名称正则匹配,不看 `region` 字段**。节点命名规范与新增地区的完整步骤见 `docs/ADDING-REGIONS.md`——命名不对就进不了地区组。
|
||||||
|
|
||||||
|
### 规则集:映射与兜底转换
|
||||||
|
|
||||||
|
模板里的 `RULE-SET`/`DOMAIN-SET` 指向外部 URL。Surge/ShadowRocket 直接透传;Clash 需要 rule-providers,`services/rulesets/mapping.ts` 按序尝试映射到 skk.moe 的 Clash 版与 blackmatrix7 的 Clash 目录,映射不到的标记 `needsConvert`,provider URL 指向自托管端点 `/sub/:token/ruleset/:id.yaml`——该端点实时拉取 Surge 版并用 `convert.ts` 转成 classical provider。规则集拉取带 24h 缓存 + etag,失败回落陈旧缓存。
|
||||||
|
|
||||||
|
### ShadowRocket 分两步导入
|
||||||
|
|
||||||
|
ShadowRocket 的原生订阅格式只承载节点,因此拆成两个端点:`/shadowrocket`(节点 base64 URI 列表)与 `/shadowrocket-conf`(策略组与规则的 .conf)。**conf 里的策略组用 `use=true` 引用订阅名**,所以用户在 App 内必须把节点订阅命名为 `SHADOWROCKET_SUB_NAME`(= `Proxy Station`)。ShadowRocket 不支持 `DOMAIN-SET`/`AND`/`URL-REGEX`,生成时跳过并回报 `skippedRules`。**该 conf 尚未经过真机验证。**
|
||||||
|
|
||||||
|
### 鉴权与订阅
|
||||||
|
|
||||||
|
`/api/*` 走 `adminAuth`:设置了 `ADMIN_TOKEN` 则要求 Bearer,未设置时仅放行 localhost 与 RFC1918 私有网段(公网 IP 一律拒绝)。`/sub/:token/*` 公开,token 用常量时间比对。**订阅 URL 完全由请求来源决定**——后端只返回路径,前端用 `window.location.origin` 拼接,`#!MANAGED-CONFIG` 与 Clash provider URL 取本次请求的 origin,所以没有也不需要「订阅基址」配置项。
|
||||||
|
|
||||||
|
未匹配的 `/api/*` 显式返回 404,不落到 SPA 回退(否则会拿到一页 HTML,调接口时极难排查)。
|
||||||
|
|
||||||
|
## 前端约定
|
||||||
|
|
||||||
|
UI 走「站牌导视」设计方向(轨道交通导视系统语言):白底 `#f7f7f4`、深藏青重点色 `#2b3f5c`、**琥珀 `#b26a00` = 直连出口、青 `#007a72` = WARP 净出口**,协议各有线路色圆标。颜色只在承载信息时出现——避免发光、亮边条、装饰性渐变。四个方向的设计对比留在 `docs/design-demos.html`。
|
||||||
|
|
||||||
|
两个共用工具:
|
||||||
|
- `utils/nodeName.ts` 的 `nodeDisplayName()`:与出口标签并排显示时剥掉名字末尾的出口词。**下发给客户端的名字必须保留出口词**,客户端里没有标签,用户只能靠名字区分同协议的两条线路
|
||||||
|
- `utils/clipboard.ts` 的 `copyText()`:`navigator.clipboard` 只在 HTTPS/localhost 可用,局域网 HTTP 访问时是 `undefined`,直接调用会静默失败。必须走这个带 `execCommand` 降级的封装
|
||||||
|
|
||||||
|
## 数据与安全
|
||||||
|
|
||||||
|
SQLite 落在 `server/data/`,存有节点真实凭据。`data/`、`*.db`、`.env` 已在 `.gitignore`——**绝不入 git**。仓库内所有示例、测试、模板一律用占位凭据。节点通过 Web 界面粘贴分享链接导入,不直接读 edge-nodes 目录(见 `docs/SEEDING.md`)。
|
||||||
|
|
||||||
|
schema 变更走 `db/index.ts` 里的 `BOOTSTRAP_SQL` 加 `CREATE TABLE IF NOT EXISTS`,旧库补列用同文件下方的轻量 `ALTER TABLE` 循环(吞掉「列已存在」错误)——没有引入 migration 工具。
|
||||||
|
|
||||||
|
公网部署必须设 `ADMIN_TOKEN` 并用反代提供 HTTPS:订阅 token 就在 URL 里,明文 HTTP 会泄露。
|
||||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# ---- 构建阶段:完整镜像自带编译工具链(better-sqlite3 原生模块)----
|
||||||
|
FROM node:22 AS build
|
||||||
|
WORKDIR /app
|
||||||
|
RUN corepack enable
|
||||||
|
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||||
|
COPY shared/package.json shared/
|
||||||
|
COPY server/package.json server/
|
||||||
|
COPY web/package.json web/
|
||||||
|
RUN CI=true pnpm install --frozen-lockfile
|
||||||
|
COPY shared/ shared/
|
||||||
|
COPY server/ server/
|
||||||
|
COPY web/ web/
|
||||||
|
RUN pnpm --filter @proxy-station/web build
|
||||||
|
|
||||||
|
# ---- 运行阶段 ----
|
||||||
|
FROM node:22-slim
|
||||||
|
WORKDIR /app
|
||||||
|
RUN corepack enable
|
||||||
|
COPY --from=build /app ./
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV DATA_DIR=/data
|
||||||
|
VOLUME /data
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["pnpm", "--filter", "@proxy-station/server", "start"]
|
||||||
85
README.md
Normal file
85
README.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Proxy Station
|
||||||
|
|
||||||
|
自托管的代理节点与分流规则管理平台。在 Web 界面里管理多协议节点与策略组、规则,向 macOS、iOS、Android 三端下发托管订阅——客户端填一次 URL,之后配置自动更新。
|
||||||
|
|
||||||
|
```
|
||||||
|
节点 ──┐
|
||||||
|
策略组 ──┼──→ ProfileIR ──┬──→ Surge .conf
|
||||||
|
规则 ──┘ ├──→ ShadowRocket 节点订阅 + 规则配置
|
||||||
|
└──→ ClashMeta .yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## 能做什么
|
||||||
|
|
||||||
|
**多协议节点管理**——VMess、Trojan、Shadowsocks、Hysteria2,支持粘贴分享链接批量导入(`vmess://`、`trojan://`、`ss://` 的 SIP002 与 Xray 两种写法、`hysteria2://`),也支持手动填写与导出。节点按站点分段展示。
|
||||||
|
|
||||||
|
**分流规则与策略组**——首次启动自动从 [ClashConnectRules/Surge](https://github.com/ClashConnectRules/Surge) 模板导入(26 个策略组 + 88 条规则,含广告拦截、AI 服务、流媒体、国内直连等)。规则可排序、改策略、启停、增删;策略组可启停并调整顺序,顺序即客户端里的排列顺序。外部规则集可在界面上实时预览内容。
|
||||||
|
|
||||||
|
**按客户端自动适配**——同一份数据生成三端配置,客户端不支持的协议在下发时自动屏蔽(例如 Surge 的 ss 类型没有 WebSocket 传输,SS over WS 节点会被排除),字段差异自动映射(cipher 命名、VMess AEAD、Hysteria2 混淆参数等)。
|
||||||
|
|
||||||
|
**按订阅分配节点**——每个订阅可以只开放部分节点,用于把不同线路分给不同设备或他人。
|
||||||
|
|
||||||
|
## 客户端支持
|
||||||
|
|
||||||
|
| 客户端 | 节点 | 策略组 | 规则 | 导入方式 |
|
||||||
|
|---|:--:|:--:|:--:|---|
|
||||||
|
| **Surge** (macOS) | ✅ | ✅ | ✅ | 一个 URL,托管配置自动更新(`#!MANAGED-CONFIG`) |
|
||||||
|
| **ClashMeta** (Android) | ✅ | ✅ | ✅ | 一个 URL,`RULE-SET` 自动映射为 rule-providers |
|
||||||
|
| **ShadowRocket** (iOS) | ✅ | ✅ | ✅ | **两个 URL**:节点订阅 + 规则配置,见下 |
|
||||||
|
|
||||||
|
ShadowRocket 的原生订阅格式只承载节点,因此拆成两步:先在「订阅」里添加节点链接并**命名为 `Proxy Station`**,再在「配置」里添加规则链接。配置文件按这个名字引用节点,名字必须一致。订阅页的 ⓘ 图标里有同样的说明。
|
||||||
|
|
||||||
|
> ClashMeta 侧无 Clash 版本的规则集,由本服务实时拉取 Surge 版并转换后提供,无需外部转换服务。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev # server :3000 + web :5173
|
||||||
|
```
|
||||||
|
|
||||||
|
打开 http://localhost:3000 (或开发时的 :5173),然后:
|
||||||
|
|
||||||
|
1. **节点** → 批量导入,粘贴分享链接。命名建议带地区词(如 `🇺🇸 US LA VMess Direct`),地区策略组靠节点名正则匹配
|
||||||
|
2. **订阅** → 新建订阅,勾选要开放的节点
|
||||||
|
3. 复制对应客户端的 URL 填进客户端
|
||||||
|
|
||||||
|
两端都监听 `0.0.0.0`,手机在同一局域网下用 `http://<你的内网IP>:5173` 即可访问管理界面,页面上显示的订阅 URL 会自动带上该地址。
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ADMIN_TOKEN=<随机长字符串> docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
SQLite 落在 `./data` 卷。订阅 URL 由请求来源自动推断,无需配置基址。
|
||||||
|
|
||||||
|
**公网部署的两条硬要求**:设置 `ADMIN_TOKEN`(未设置时管理接口只放行 localhost 与局域网);用反向代理提供 **HTTPS**——订阅 token 就在 URL 里,明文传输会泄露。反代的 access log 建议对 `/sub/` 路径脱敏。
|
||||||
|
|
||||||
|
## 校验
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clash 产物(最有价值的一条端到端验证)
|
||||||
|
curl -s "http://localhost:3000/sub/<token>/clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.yaml
|
||||||
|
|
||||||
|
pnpm -r typecheck
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Surge 与 ShadowRocket 需真机导入验收。
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| `docs/SEEDING.md` | 从 edge-nodes 交付文档导入真实节点、命名建议 |
|
||||||
|
| `docs/ADDING-REGIONS.md` | 新增地区(香港、日本等)时的节点命名与策略组配套步骤 |
|
||||||
|
| `CLAUDE.md` | 架构要点与开发约定 |
|
||||||
|
|
||||||
|
## 安全
|
||||||
|
|
||||||
|
数据库存有节点真实凭据。`data/`、`*.db`、`.env` 已在 `.gitignore` 中,**绝不入 git**;仓库内所有示例、测试、模板一律使用占位凭据。订阅 token 可随时在「订阅」页禁用或删除。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
pnpm monorepo:`server/`(Hono + better-sqlite3 + Drizzle)+ `web/`(Vue 3 + Vite + Naive UI + Pinia)+ `shared/`(zod schema 与 DTO,前后端共用)。生产模式由 server 静态托管前端产物,单端口运行。
|
||||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
proxy-station:
|
||||||
|
build: .
|
||||||
|
container_name: proxy-station
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '3000:3000'
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
environment:
|
||||||
|
# 公网部署必须设置:管理接口的 Bearer token
|
||||||
|
# 未设置时管理接口仅放行 localhost 与局域网,订阅端点始终公开(token 即凭证)
|
||||||
|
- ADMIN_TOKEN=${ADMIN_TOKEN:-}
|
||||||
135
docs/ADDING-REGIONS.md
Normal file
135
docs/ADDING-REGIONS.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# 新增地区与新增站点
|
||||||
|
|
||||||
|
当舰队里加入新地区(香港、日本、韩国、台湾、英国……)的节点时,按本文操作。
|
||||||
|
|
||||||
|
> 背景:策略组的地区筛选靠**节点名称正则**,不靠节点的 `region` 字段。所以节点命名是一切的前提。
|
||||||
|
|
||||||
|
## 当前状态(2026-08-24)
|
||||||
|
|
||||||
|
只有美国与新加坡两个地区的节点,因此策略组里只有 `🇺🇸 United States` 与 `🇸🇬 Singapore` 两个地区组。
|
||||||
|
香港、日本、台湾、英国、韩国的地区组**已删除**——留着会是空组,在客户端里显示为无可用节点。
|
||||||
|
兜底的 `🌍 Other` 组也已删除:所有节点都能被现有两个地区组收纳,它恒为空。
|
||||||
|
|
||||||
|
## 一、给新节点命名(决定它能被哪个组收纳)
|
||||||
|
|
||||||
|
命名格式:`<国旗> <国家码> · <站点码> · <协议> · <出口>`
|
||||||
|
|
||||||
|
```
|
||||||
|
🇭🇰 HK · VMess · 直连
|
||||||
|
🇯🇵 JP · Trojan · WARP
|
||||||
|
🇺🇸 US · LA · VMess · 直连 ← 同国多站点时插入站点码
|
||||||
|
```
|
||||||
|
|
||||||
|
- **国家码必须出现在名字里**(HK/JP/US/SG…),地区组的正则靠它匹配
|
||||||
|
- 出口词(`直连` / `WARP`)必须保留:客户端里没有出口标签,用户只能靠名字区分同协议的两条线路。界面上显示时会自动隐去,不必担心重复
|
||||||
|
- 站点码(LA/RN/AWS)用于节点页的分段归类,来自节点的 `region` 字段
|
||||||
|
|
||||||
|
节点的 `region` 字段填站点码(`SG`、`LA`、`RN`、`HK`…),它决定节点页里归到哪一段。
|
||||||
|
|
||||||
|
## 二、让站点标题显示国旗
|
||||||
|
|
||||||
|
`web/src/components/stations.ts` 的 `STATION_LABELS` 加一行:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const STATION_LABELS: Record<string, string> = {
|
||||||
|
SG: '🇸🇬 SG AWS',
|
||||||
|
LA: '🇺🇸 US LA',
|
||||||
|
RN: '🇺🇸 US RN',
|
||||||
|
HK: '🇭🇰 HK 香港站点名', // ← 新增
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
未配置的站点码会原样显示,不影响功能。
|
||||||
|
|
||||||
|
## 三、新建地区策略组
|
||||||
|
|
||||||
|
在「策略组」页点「添加策略组」,按下表填写:
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 名称 | `🇭🇰 Hong Kong`(emoji 前缀 + 英文原名) |
|
||||||
|
| 类型 | `url-test` |
|
||||||
|
| 成员 | 留空 |
|
||||||
|
| 收纳全部节点 | **开启** |
|
||||||
|
| 名称筛选正则 | 见下方对照表 |
|
||||||
|
| 测速 URL | `http://cp.cloudflare.com/generate_204` |
|
||||||
|
| 间隔 | `300` |
|
||||||
|
| 容差 | `50` |
|
||||||
|
|
||||||
|
「收纳全部节点」+「筛选正则」的组合会生成 `include-all-proxies=true, policy-regex-filter=…`(Surge)、`include-all-proxies: true, filter: …`(Clash)、`use=true, policy-regex-filter=…`(ShadowRocket),三端都是**动态筛选**——之后再加同地区节点会自动进组,不用改配置。
|
||||||
|
|
||||||
|
各地区的正则(取自原模板,可直接复制):
|
||||||
|
|
||||||
|
```
|
||||||
|
香港 港|🇭🇰|香港|HK|Hong
|
||||||
|
日本 日|🇯🇵|日本|JP|Japan
|
||||||
|
台湾 台|🇹🇼|台湾|TW|Tai
|
||||||
|
韩国 韩|韩国|Korea|KR|🇰🇷
|
||||||
|
英国 🇬🇧|英国|英|英格兰|UK|United Kingdom
|
||||||
|
新加坡 坡|🇸🇬|新加坡|狮城|SG|Singapore
|
||||||
|
美国 美|🇺🇸|美国|US|States|American
|
||||||
|
```
|
||||||
|
|
||||||
|
## 四、兜底组 `🌍 Other`(当前不存在)
|
||||||
|
|
||||||
|
原模板有一个 `🌍 Other` 组,用否定前瞻正则收纳「不属于任何已建地区组」的节点。**当前已删除**,因为现有节点都能被美/新两个组收纳,它恒为空。
|
||||||
|
|
||||||
|
只有当你加入的节点**不属于任何已建地区组**(比如加了德国节点但没建德国组)时,才需要重建它:
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 名称 | `🌍 Other` |
|
||||||
|
| 类型 | `url-test`,收纳全部节点开启 |
|
||||||
|
| 筛选正则 | 见下(把所有**已建**地区组的关键字排除掉) |
|
||||||
|
|
||||||
|
```
|
||||||
|
^((?!港|🇭🇰|香港|HK|Hong|日|🇯🇵|日本|JP|Japan|坡|🇸🇬|新加坡|狮城|SG|Singapore|美|🇺🇸|美国|US|States|American|Remain|Expired|官网|如需|套餐|去除|剩余|距离|Reset|重置|流量).)+$
|
||||||
|
```
|
||||||
|
|
||||||
|
**如果重建了 Other,每次新增地区组都必须往这个否定前瞻里追加对应关键字**,否则新地区节点会同时出现在自己的组和 Other 里。
|
||||||
|
|
||||||
|
## 五、把新地区加进服务组的可选项
|
||||||
|
|
||||||
|
新建地区组后,它不会自动出现在服务组的成员里。按需编辑这些组,把新地区加进成员列表:
|
||||||
|
|
||||||
|
| 组 | 当前成员 | 建议 |
|
||||||
|
|---|---|---|
|
||||||
|
| `⚡️ Automatic` | SG、US | 加入所有地区组 |
|
||||||
|
| `🚀 Proxy` | Automatic、SG、US | 加入所有地区组 |
|
||||||
|
| `🎬 Streaming` | SG、US | 加入解锁能力强的地区(HK/TW/JP) |
|
||||||
|
| `🤖 AI` | Automatic、US、SG | 加入 JP、US 等 AI 服务可用地区 |
|
||||||
|
| `🌐 Global Services` | Automatic、US、SG | 同 AI |
|
||||||
|
| `🍎 Apple` | Mainland、US | 按需加入 HK |
|
||||||
|
|
||||||
|
成员的顺序就是客户端里的排列顺序,**第一个成员是该组的默认选中项**。
|
||||||
|
|
||||||
|
## 六、地区流媒体解锁规则(可选)
|
||||||
|
|
||||||
|
模板原有 `stream_hk`、`stream_jp`、`stream_tw`、`stream_kr`、`stream_eu` 等按地区解锁的规则集。删除地区组时,这些规则被改指向了 `🎬 Streaming`。
|
||||||
|
|
||||||
|
如果新建了对应地区组,可以在「规则」页把这些规则的策略改回该地区组,解锁效果更精准:
|
||||||
|
|
||||||
|
| 规则集 | 建议策略 |
|
||||||
|
|---|---|
|
||||||
|
| `stream_hk`(non_ip + ip 两条) | `🇭🇰 Hong Kong` |
|
||||||
|
| `stream_jp`(两条) | `🇯🇵 Japan` |
|
||||||
|
| `stream_tw`、`stream_kr`、`stream_eu` | 对应地区组 |
|
||||||
|
|
||||||
|
## 七、验证
|
||||||
|
|
||||||
|
1. 「策略组」页:新地区组的**命中**一栏应列出该地区的所有节点
|
||||||
|
2. 「节点」页:新站点应有独立分段,标题显示国旗
|
||||||
|
3. 命令行校验 Clash 产物:
|
||||||
|
```bash
|
||||||
|
curl -s "http://<host>/sub/<token>/clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.yaml
|
||||||
|
```
|
||||||
|
4. 若重建过 `🌍 Other`,确认它的命中里**不再包含**新地区节点
|
||||||
|
|
||||||
|
## 删除某个地区(反向操作)
|
||||||
|
|
||||||
|
1. 把指向该组的规则改指向别的组(否则规则会被自动跳过——IR 层有悬空引用防护,但那是兜底不是意图)
|
||||||
|
2. 从其他组的成员里摘掉它
|
||||||
|
3. 删除该组
|
||||||
|
4. Other 的排除列表可以保留该地区关键字(留着无害,将来重建时省事)
|
||||||
|
|
||||||
|
> 上述 1–3 步如果漏做,生成器不会产出无效配置(悬空引用会被自动跳过、FINAL 会降级为 DIRECT),但分流意图会悄悄改变,所以要显式处理。
|
||||||
33
docs/SEEDING.md
Normal file
33
docs/SEEDING.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# 节点导入指引
|
||||||
|
|
||||||
|
proxy-station 不直接读取 edge-nodes 工作区(那里全是真实凭据,保持解耦),节点通过 Web 界面批量粘贴分享链接导入。
|
||||||
|
|
||||||
|
## 从 edge-nodes 交付文档导入
|
||||||
|
|
||||||
|
每个节点目录 `<id>-edge-node-docs/` 已生成好分享链接:
|
||||||
|
|
||||||
|
- `<proto>-<egress>.link`——VMess/Trojan 的分享链接文件(`vmess://`、`trojan://`)
|
||||||
|
- Shadowsocks 线路没有通用分享链接,用 sing-box JSON(`ss-*-singbox.json`)里的参数手动添加,或按 SIP002 + v2ray-plugin 格式自拼:
|
||||||
|
`ss://base64url(chacha20-poly1305:密码)@域名:443?plugin=v2ray-plugin%3Btls%3Bmode%3Dwebsocket%3Bhost%3D域名%3Bpath%3D%2F路径#名称`
|
||||||
|
- Hysteria2 线路在 `scripts/node-links/` 下有 `hysteria2://` 链接(含 `obfs=salamander&obfs-password=…`)
|
||||||
|
|
||||||
|
操作:
|
||||||
|
|
||||||
|
1. 在 edge-nodes 侧汇总链接:`cat ~/Development/Fusion/Projects/edge-nodes/*-edge-node-docs/*.link`
|
||||||
|
2. 打开 proxy-station「节点」页 →「批量导入」→ 粘贴(每行一条)→「解析预览」确认字段 →「导入」
|
||||||
|
3. SOCKS5 线路为内部使用,不要导入本系统
|
||||||
|
|
||||||
|
## 命名建议(影响地区组归类)
|
||||||
|
|
||||||
|
模板的地区策略组按**节点名正则**筛选(如 `坡|🇸🇬|新加坡|狮城|SG|Singapore`、`美|🇺🇸|美国|US`)。命名时带上地区词与出口词,例如:
|
||||||
|
|
||||||
|
- `SG-vmess-direct`、`SG-trojan-warp`(命中 Singapore 组)
|
||||||
|
- `US-LA-trojan-direct`、`US-RN-vmess-warp`(命中 United States 组;只写 LA/RN 不会命中任何地区组,会落入 Other)
|
||||||
|
|
||||||
|
名称含 `direct`/`warp` 会自动打出口标签,线路矩阵按此归位(琥珀 = 直连,青 = WARP)。
|
||||||
|
|
||||||
|
## 导入后核对
|
||||||
|
|
||||||
|
- 「节点」页线路矩阵:每个站点的协议 × 出口格子是否齐全
|
||||||
|
- 「策略组」页:地区组的「命中」行是否圈中了预期节点
|
||||||
|
- 「订阅」页预览:Surge 端确认 SS over WS 节点已被自动屏蔽(预期行为,Surge 不支持该组合)
|
||||||
720
docs/design-demos.html
Normal file
720
docs/design-demos.html
Normal file
@@ -0,0 +1,720 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Proxy Station · 设计方向 Demo</title>
|
||||||
|
<style>
|
||||||
|
/* ============ 全局与切换器 ============ */
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html { -webkit-font-smoothing: antialiased; }
|
||||||
|
body { font-family: -apple-system, 'PingFang SC', 'Helvetica Neue', sans-serif; }
|
||||||
|
|
||||||
|
.switcher {
|
||||||
|
position: fixed; top: 16px; left: 50%; transform: translateX(-50%);
|
||||||
|
display: flex; gap: 2px; z-index: 99;
|
||||||
|
background: rgba(20,20,20,0.92); backdrop-filter: blur(12px);
|
||||||
|
border-radius: 8px; padding: 3px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
.switcher button {
|
||||||
|
appearance: none; border: none; cursor: pointer;
|
||||||
|
background: transparent; color: #999;
|
||||||
|
font: 500 13px/1 -apple-system, 'PingFang SC', sans-serif;
|
||||||
|
padding: 8px 14px; border-radius: 6px;
|
||||||
|
}
|
||||||
|
.switcher button:hover { color: #fff; }
|
||||||
|
.switcher button.on { background: #fff; color: #111; }
|
||||||
|
|
||||||
|
.demo { display: none; min-height: 100vh; padding: 88px 0 80px; }
|
||||||
|
.demo.on { display: block; }
|
||||||
|
.frame { max-width: 1080px; margin: 0 auto; padding: 0 40px; }
|
||||||
|
|
||||||
|
.demo-intro { margin-bottom: 36px; }
|
||||||
|
.demo-intro .no { font-size: 12px; letter-spacing: 0.12em; }
|
||||||
|
.demo-intro h1 { margin: 6px 0 8px; }
|
||||||
|
.demo-intro p { max-width: 640px; }
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
方向 A 「站牌导视」 —— 交通导视系统:白底、粗黑标题、
|
||||||
|
线路色圆标,站名牌是签名元素。名字都叫 station 了。
|
||||||
|
========================================================= */
|
||||||
|
.demo-a {
|
||||||
|
--bg: #f7f7f4; --ink: #17181a; --sub: #6e7073; --line: #e2e2dd;
|
||||||
|
--direct: #b26a00; --direct-bg: #faf1e2; --warp: #007a72; --warp-bg: #e2f2f0;
|
||||||
|
background: var(--bg); color: var(--ink);
|
||||||
|
}
|
||||||
|
.demo-a .demo-intro .no { color: var(--sub); }
|
||||||
|
.demo-a h1 { font-size: 30px; font-weight: 800; letter-spacing: -0.01em; }
|
||||||
|
.demo-a .demo-intro p { color: var(--sub); font-size: 14px; line-height: 1.8; }
|
||||||
|
|
||||||
|
.demo-a .toolbar { display: flex; gap: 10px; margin-bottom: 28px; }
|
||||||
|
.demo-a .btn {
|
||||||
|
appearance: none; cursor: pointer; font: 600 13px/1 inherit;
|
||||||
|
padding: 10px 18px; border-radius: 999px; border: 1.5px solid transparent;
|
||||||
|
transition: transform 0.06s;
|
||||||
|
}
|
||||||
|
.demo-a .btn:active { transform: scale(0.98); }
|
||||||
|
.demo-a .btn-primary { background: var(--ink); color: #fff; }
|
||||||
|
.demo-a .btn-primary:hover { background: #000; }
|
||||||
|
.demo-a .btn-secondary { background: transparent; border-color: var(--ink); color: var(--ink); }
|
||||||
|
.demo-a .btn-secondary:hover { background: rgba(0,0,0,0.05); }
|
||||||
|
.demo-a .btn-plain { background: transparent; color: var(--sub); padding-left: 8px; padding-right: 8px; }
|
||||||
|
.demo-a .btn-plain:hover { color: var(--ink); }
|
||||||
|
|
||||||
|
/* 站名牌:黑底白字 + 线路色条,仿轨道交通站名标 */
|
||||||
|
.demo-a .station-sign {
|
||||||
|
background: var(--ink); color: #fff; border-radius: 10px;
|
||||||
|
padding: 18px 22px 14px; margin-bottom: 2px;
|
||||||
|
display: flex; align-items: baseline; gap: 14px;
|
||||||
|
}
|
||||||
|
.demo-a .station-sign .code { font-size: 30px; font-weight: 800; letter-spacing: 0.02em; }
|
||||||
|
.demo-a .station-sign .name { font-size: 13px; color: #c9c9c4; font-family: ui-monospace, 'SF Mono', Menlo, monospace; }
|
||||||
|
.demo-a .station-sign .count { margin-left: auto; font-size: 12px; color: #8b8d90; }
|
||||||
|
.demo-a .sign-strip { display: flex; height: 5px; border-radius: 0 0 6px 6px; overflow: hidden; margin-bottom: 16px; }
|
||||||
|
.demo-a .sign-strip i { flex: 1; }
|
||||||
|
.demo-a .sign-strip .s-direct { background: var(--direct); }
|
||||||
|
.demo-a .sign-strip .s-warp { background: var(--warp); }
|
||||||
|
|
||||||
|
.demo-a .card {
|
||||||
|
background: #fff; border: 1px solid var(--line); border-radius: 10px;
|
||||||
|
padding: 22px; margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.demo-a .card .platforms { display: flex; gap: 28px; }
|
||||||
|
.demo-a .platform { flex: 1; }
|
||||||
|
.demo-a .platform-head {
|
||||||
|
font-size: 12px; font-weight: 700; letter-spacing: 0.1em;
|
||||||
|
padding-bottom: 8px; margin-bottom: 10px; border-bottom: 2.5px solid;
|
||||||
|
}
|
||||||
|
.demo-a .platform-direct .platform-head { color: var(--direct); border-color: var(--direct); }
|
||||||
|
.demo-a .platform-warp .platform-head { color: var(--warp); border-color: var(--warp); }
|
||||||
|
.demo-a .platform ul { list-style: none; }
|
||||||
|
.demo-a .platform li {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 7px 0; font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.demo-a .proto-dot {
|
||||||
|
width: 22px; height: 22px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font: 700 11px/1 inherit; color: #fff;
|
||||||
|
}
|
||||||
|
.demo-a .p-v { background: #275ad1; }
|
||||||
|
.demo-a .p-t { background: #8348c7; }
|
||||||
|
.demo-a .p-s { background: #2f7d3b; }
|
||||||
|
.demo-a .p-h { background: #c23d5d; }
|
||||||
|
.demo-a .platform .path { margin-left: auto; font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12px; color: var(--sub); }
|
||||||
|
|
||||||
|
.demo-a table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
|
||||||
|
.demo-a th {
|
||||||
|
text-align: left; font-size: 12px; font-weight: 700; letter-spacing: 0.06em;
|
||||||
|
color: var(--sub); padding: 12px 16px; border-bottom: 2px solid var(--ink); background: #fff;
|
||||||
|
}
|
||||||
|
.demo-a td { padding: 13px 16px; font-size: 13.5px; border-bottom: 1px solid var(--line); }
|
||||||
|
.demo-a tr:last-child td { border-bottom: none; }
|
||||||
|
.demo-a tr:hover td { background: #fbfbf8; }
|
||||||
|
.demo-a .mono { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12.5px; }
|
||||||
|
.demo-a .tag {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 999px;
|
||||||
|
}
|
||||||
|
.demo-a .tag i { width: 7px; height: 7px; border-radius: 50%; }
|
||||||
|
.demo-a .tag-direct { background: var(--direct-bg); color: var(--direct); }
|
||||||
|
.demo-a .tag-direct i { background: var(--direct); }
|
||||||
|
.demo-a .tag-warp { background: var(--warp-bg); color: var(--warp); }
|
||||||
|
.demo-a .tag-warp i { background: var(--warp); }
|
||||||
|
.demo-a .tag-off { background: #eeeeea; color: var(--sub); }
|
||||||
|
.demo-a .rowact { color: var(--sub); font-size: 12.5px; cursor: pointer; }
|
||||||
|
.demo-a .rowact:hover { color: var(--ink); text-decoration: underline; }
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
方向 B 「纸上台账」 —— 档案登记簿:纸面、衬线标题、
|
||||||
|
墨蓝正文、出口语义做成印章。
|
||||||
|
========================================================= */
|
||||||
|
.demo-b {
|
||||||
|
--paper: #f4f1e9; --card: #fbf9f3; --ink: #2b2a24; --navy: #23406e;
|
||||||
|
--sub: #8a8577; --line: #ddd7c8; --seal-red: #b53a2e; --seal-blue: #23406e;
|
||||||
|
background: var(--paper); color: var(--ink);
|
||||||
|
}
|
||||||
|
.demo-b .demo-intro .no { color: var(--sub); font-family: ui-monospace, 'SF Mono', Menlo, monospace; }
|
||||||
|
.demo-b h1 {
|
||||||
|
font-family: 'Songti SC', 'STSong', Georgia, serif;
|
||||||
|
font-size: 32px; font-weight: 700; color: var(--navy);
|
||||||
|
}
|
||||||
|
.demo-b .demo-intro p { color: var(--sub); font-size: 14px; line-height: 2; }
|
||||||
|
|
||||||
|
.demo-b .toolbar { display: flex; gap: 10px; margin-bottom: 28px; }
|
||||||
|
.demo-b .btn {
|
||||||
|
appearance: none; cursor: pointer;
|
||||||
|
font: 600 13px/1 'Songti SC', 'STSong', Georgia, serif;
|
||||||
|
padding: 10px 20px; border-radius: 3px; letter-spacing: 0.08em;
|
||||||
|
border: 1px solid var(--ink); background: transparent; color: var(--ink);
|
||||||
|
}
|
||||||
|
.demo-b .btn-primary { background: var(--navy); border-color: var(--navy); color: var(--paper); }
|
||||||
|
.demo-b .btn-primary:hover { background: #1a3157; }
|
||||||
|
.demo-b .btn:not(.btn-primary):hover { background: rgba(43,42,36,0.06); }
|
||||||
|
.demo-b .btn-plain { border-color: transparent; color: var(--sub); }
|
||||||
|
.demo-b .btn-plain:hover { color: var(--ink); background: transparent; text-decoration: underline; text-underline-offset: 4px; }
|
||||||
|
|
||||||
|
.demo-b .card {
|
||||||
|
background: var(--card); border: 1px solid var(--line); border-radius: 4px;
|
||||||
|
padding: 24px 26px; margin-bottom: 28px; position: relative;
|
||||||
|
}
|
||||||
|
.demo-b .card::before {
|
||||||
|
content: ''; position: absolute; inset: 6px; pointer-events: none;
|
||||||
|
border: 1px solid var(--line); border-radius: 2px;
|
||||||
|
}
|
||||||
|
.demo-b .card-head { display: flex; align-items: baseline; gap: 14px; margin-bottom: 16px; position: relative; }
|
||||||
|
.demo-b .card-head .code {
|
||||||
|
font-family: 'Songti SC', 'STSong', Georgia, serif;
|
||||||
|
font-size: 26px; font-weight: 700; color: var(--navy);
|
||||||
|
}
|
||||||
|
.demo-b .card-head .domain { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12px; color: var(--sub); }
|
||||||
|
.demo-b .card-head .no { margin-left: auto; font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 11px; color: var(--sub); letter-spacing: 0.1em; }
|
||||||
|
|
||||||
|
/* 印章:出口语义 */
|
||||||
|
.demo-b .seal {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 34px; height: 34px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
border: 2px solid; transform: rotate(-6deg);
|
||||||
|
font: 700 13px/1 'Songti SC', 'STSong', Georgia, serif;
|
||||||
|
}
|
||||||
|
.demo-b .seal-direct { color: var(--seal-red); border-color: var(--seal-red); }
|
||||||
|
.demo-b .seal-warp { color: var(--seal-blue); border-color: var(--seal-blue); }
|
||||||
|
|
||||||
|
.demo-b .ledger { list-style: none; }
|
||||||
|
.demo-b .ledger li {
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
padding: 10px 2px; border-bottom: 1px dotted var(--line); font-size: 14px;
|
||||||
|
}
|
||||||
|
.demo-b .ledger li:last-child { border-bottom: none; }
|
||||||
|
.demo-b .ledger .idx { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 11px; color: var(--sub); width: 26px; }
|
||||||
|
.demo-b .ledger .leader { flex: 1; border-bottom: 1px dotted var(--line); height: 1px; margin: 0 4px; }
|
||||||
|
.demo-b .ledger .path { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12.5px; color: var(--navy); }
|
||||||
|
|
||||||
|
.demo-b table { width: 100%; border-collapse: collapse; }
|
||||||
|
.demo-b th {
|
||||||
|
text-align: left; font-family: 'Songti SC', 'STSong', Georgia, serif;
|
||||||
|
font-size: 13px; font-weight: 700; color: var(--navy);
|
||||||
|
padding: 10px 14px; border-bottom: 3px double var(--ink);
|
||||||
|
}
|
||||||
|
.demo-b td { padding: 12px 14px; font-size: 13.5px; border-bottom: 1px solid var(--line); background: var(--card); }
|
||||||
|
.demo-b tr:hover td { background: #f6f3ea; }
|
||||||
|
.demo-b .mono { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12.5px; }
|
||||||
|
.demo-b .tag {
|
||||||
|
display: inline-block; font-size: 12px; padding: 2px 10px;
|
||||||
|
border: 1px solid; border-radius: 2px; letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.demo-b .tag-direct { color: var(--seal-red); border-color: var(--seal-red); }
|
||||||
|
.demo-b .tag-warp { color: var(--seal-blue); border-color: var(--seal-blue); }
|
||||||
|
.demo-b .tag-proto { color: var(--sub); border-color: var(--line); background: rgba(255,255,255,0.5); }
|
||||||
|
.demo-b .rowact { color: var(--navy); font-size: 12.5px; cursor: pointer; }
|
||||||
|
.demo-b .rowact:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
方向 C 「克制暗色」 —— 修正现有暗色的 AI 味:中性炭黑、
|
||||||
|
零装饰、纯排版分层,强调色只落在数据本身。
|
||||||
|
========================================================= */
|
||||||
|
.demo-c {
|
||||||
|
--bg: #161616; --panel: #1c1c1c; --line: #282828;
|
||||||
|
--text: #e8e6e1; --sub: #8f8c85;
|
||||||
|
--direct: #d0a05c; --warp: #5fb3a1;
|
||||||
|
background: var(--bg); color: var(--text);
|
||||||
|
}
|
||||||
|
.demo-c .demo-intro .no { color: var(--sub); }
|
||||||
|
.demo-c h1 { font-size: 26px; font-weight: 600; letter-spacing: -0.01em; }
|
||||||
|
.demo-c .demo-intro p { color: var(--sub); font-size: 14px; line-height: 1.9; }
|
||||||
|
|
||||||
|
.demo-c .toolbar { display: flex; gap: 8px; margin-bottom: 32px; }
|
||||||
|
.demo-c .btn {
|
||||||
|
appearance: none; cursor: pointer; font: 500 13px/1 inherit;
|
||||||
|
padding: 9px 16px; border-radius: 6px; border: 1px solid transparent;
|
||||||
|
background: transparent; color: var(--text);
|
||||||
|
}
|
||||||
|
.demo-c .btn-primary { background: var(--text); color: #111; }
|
||||||
|
.demo-c .btn-primary:hover { background: #fff; }
|
||||||
|
.demo-c .btn-secondary { border-color: var(--line); background: transparent; color: var(--text); }
|
||||||
|
.demo-c .btn-secondary:hover { border-color: #3a3a3a; background: var(--panel); }
|
||||||
|
.demo-c .btn-plain { color: var(--sub); }
|
||||||
|
.demo-c .btn-plain:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.demo-c .card { border: 1px solid var(--line); border-radius: 8px; margin-bottom: 32px; overflow: hidden; }
|
||||||
|
.demo-c .card-head {
|
||||||
|
display: flex; align-items: baseline; gap: 12px;
|
||||||
|
padding: 16px 20px; border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.demo-c .card-head .code { font-size: 15px; font-weight: 600; }
|
||||||
|
.demo-c .card-head .domain { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12px; color: var(--sub); }
|
||||||
|
.demo-c .card-head .count { margin-left: auto; font-size: 12px; color: var(--sub); font-variant-numeric: tabular-nums; }
|
||||||
|
.demo-c .lines { list-style: none; }
|
||||||
|
.demo-c .lines li {
|
||||||
|
display: grid; grid-template-columns: 110px 1fr 90px; align-items: center;
|
||||||
|
padding: 11px 20px; font-size: 13px; border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.demo-c .lines li:last-child { border-bottom: none; }
|
||||||
|
.demo-c .lines li:hover { background: var(--panel); }
|
||||||
|
.demo-c .lines .proto { color: var(--text); font-weight: 500; }
|
||||||
|
.demo-c .lines .path { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12.5px; }
|
||||||
|
.demo-c .path-direct { color: var(--direct); }
|
||||||
|
.demo-c .path-warp { color: var(--warp); }
|
||||||
|
.demo-c .lines .eg { text-align: right; font-size: 12px; color: var(--sub); }
|
||||||
|
|
||||||
|
.demo-c table { width: 100%; border-collapse: collapse; }
|
||||||
|
.demo-c th {
|
||||||
|
text-align: left; font-size: 11.5px; font-weight: 500; letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase; color: var(--sub);
|
||||||
|
padding: 10px 14px; border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.demo-c td { padding: 13px 14px; font-size: 13px; border-bottom: 1px solid var(--line); font-variant-numeric: tabular-nums; }
|
||||||
|
.demo-c tr:hover td { background: var(--panel); }
|
||||||
|
.demo-c .mono { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12.5px; }
|
||||||
|
.demo-c .tag { display: inline-block; font-size: 12px; padding: 2px 8px; border-radius: 4px; border: 1px solid var(--line); color: var(--sub); }
|
||||||
|
.demo-c .tag-direct { color: var(--direct); border-color: rgba(208,160,92,0.35); }
|
||||||
|
.demo-c .tag-warp { color: var(--warp); border-color: rgba(95,179,161,0.35); }
|
||||||
|
.demo-c .rowact { color: var(--sub); font-size: 12.5px; cursor: pointer; }
|
||||||
|
.demo-c .rowact:hover { color: var(--text); }
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
方向 D 「仪表工业」 —— Rams 式仪器面板:冷灰、指示灯、
|
||||||
|
实体按键手感、小写字母间距标签。
|
||||||
|
========================================================= */
|
||||||
|
.demo-d {
|
||||||
|
--bg: #e9eaec; --card: #f7f8f9; --ink: #1b1d1f; --sub: #70757b;
|
||||||
|
--line: #cfd2d6; --direct: #d9480f; --warp: #2b8a3e;
|
||||||
|
background: var(--bg); color: var(--ink);
|
||||||
|
}
|
||||||
|
.demo-d .demo-intro .no { color: var(--sub); letter-spacing: 0.18em; }
|
||||||
|
.demo-d h1 { font-size: 26px; font-weight: 700; letter-spacing: -0.01em; }
|
||||||
|
.demo-d .demo-intro p { color: var(--sub); font-size: 14px; line-height: 1.9; }
|
||||||
|
|
||||||
|
.demo-d .toolbar { display: flex; gap: 10px; margin-bottom: 28px; }
|
||||||
|
.demo-d .btn {
|
||||||
|
appearance: none; cursor: pointer; font: 600 13px/1 inherit;
|
||||||
|
padding: 9px 16px; border-radius: 4px;
|
||||||
|
background: var(--card); color: var(--ink);
|
||||||
|
border: 1px solid var(--line); border-bottom: 2.5px solid #b7bbc0;
|
||||||
|
}
|
||||||
|
.demo-d .btn:active { border-bottom-width: 1px; transform: translateY(1.5px); }
|
||||||
|
.demo-d .btn-primary { background: var(--ink); color: #fff; border-color: var(--ink); border-bottom-color: #000; }
|
||||||
|
.demo-d .btn-plain { background: transparent; border-color: transparent; color: var(--sub); }
|
||||||
|
.demo-d .btn-plain:hover { color: var(--ink); }
|
||||||
|
|
||||||
|
.demo-d .label {
|
||||||
|
font-size: 10.5px; font-weight: 600; letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase; color: var(--sub);
|
||||||
|
}
|
||||||
|
.demo-d .card {
|
||||||
|
background: var(--card); border: 1px solid var(--line); border-radius: 6px;
|
||||||
|
padding: 20px 22px; margin-bottom: 28px;
|
||||||
|
box-shadow: 0 1px 0 rgba(255,255,255,0.8) inset, 0 1px 3px rgba(27,29,31,0.06);
|
||||||
|
}
|
||||||
|
.demo-d .card-head { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
||||||
|
.demo-d .card-head .code { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
|
.demo-d .card-head .domain { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12px; color: var(--sub); }
|
||||||
|
.demo-d .meter { margin-left: auto; display: flex; align-items: center; gap: 6px; }
|
||||||
|
.demo-d .lamp { width: 9px; height: 9px; border-radius: 50%; }
|
||||||
|
.demo-d .lamp-direct { background: var(--direct); box-shadow: 0 0 0 2.5px rgba(217,72,15,0.15); }
|
||||||
|
.demo-d .lamp-warp { background: var(--warp); box-shadow: 0 0 0 2.5px rgba(43,138,62,0.15); }
|
||||||
|
.demo-d .grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||||
|
.demo-d .slot {
|
||||||
|
border: 1px solid var(--line); border-radius: 4px; background: #fff;
|
||||||
|
padding: 10px 12px; display: flex; align-items: center; gap: 10px; font-size: 13px;
|
||||||
|
}
|
||||||
|
.demo-d .slot .path { margin-left: auto; font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12px; color: var(--sub); }
|
||||||
|
|
||||||
|
.demo-d table { width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
|
||||||
|
.demo-d th { text-align: left; padding: 11px 14px; border-bottom: 1px solid var(--line); background: #eff1f3; }
|
||||||
|
.demo-d td { padding: 12px 14px; font-size: 13px; border-bottom: 1px solid #e3e5e8; font-variant-numeric: tabular-nums; }
|
||||||
|
.demo-d tr:last-child td { border-bottom: none; }
|
||||||
|
.demo-d tr:hover td { background: #fbfcfd; }
|
||||||
|
.demo-d .mono { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12.5px; }
|
||||||
|
.demo-d .tag {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 11px; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase;
|
||||||
|
padding: 3px 9px; border-radius: 3px; border: 1px solid var(--line); background: #fff; color: var(--sub);
|
||||||
|
}
|
||||||
|
.demo-d .tag i { width: 7px; height: 7px; border-radius: 50%; }
|
||||||
|
.demo-d .tag-direct { color: var(--direct); }
|
||||||
|
.demo-d .tag-direct i { background: var(--direct); }
|
||||||
|
.demo-d .tag-warp { color: var(--warp); }
|
||||||
|
.demo-d .tag-warp i { background: var(--warp); }
|
||||||
|
.demo-d .rowact { color: var(--sub); font-size: 12px; cursor: pointer; letter-spacing: 0.06em; }
|
||||||
|
.demo-d .rowact:hover { color: var(--ink); }
|
||||||
|
|
||||||
|
/* ============ 各方向的导航样例(选中态是重点) ============ */
|
||||||
|
.navdemo { display: flex; align-items: center; gap: 4px; margin-bottom: 28px; }
|
||||||
|
|
||||||
|
/* A:选中 = 粗下划线(站台线条),不加底不加边 */
|
||||||
|
.demo-a .navdemo { gap: 22px; border-bottom: 1px solid var(--line); }
|
||||||
|
.demo-a .navdemo a {
|
||||||
|
text-decoration: none; color: var(--sub); font-size: 14px; font-weight: 600;
|
||||||
|
padding: 10px 2px 12px; border-bottom: 3px solid transparent; margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.demo-a .navdemo a:hover { color: var(--ink); }
|
||||||
|
.demo-a .navdemo a.cur { color: var(--ink); border-bottom-color: var(--ink); }
|
||||||
|
|
||||||
|
/* B:选中 = 墨蓝衬线加粗 + 传统着重号 */
|
||||||
|
.demo-b .navdemo { gap: 26px; }
|
||||||
|
.demo-b .navdemo a {
|
||||||
|
text-decoration: none; color: var(--sub); font-size: 15px;
|
||||||
|
font-family: 'Songti SC', 'STSong', Georgia, serif; position: relative; padding: 4px 0;
|
||||||
|
}
|
||||||
|
.demo-b .navdemo a:hover { color: var(--ink); }
|
||||||
|
.demo-b .navdemo a.cur { color: var(--navy); font-weight: 700; }
|
||||||
|
.demo-b .navdemo a.cur::before {
|
||||||
|
content: '・'; position: absolute; left: 50%; transform: translateX(-50%);
|
||||||
|
top: -14px; color: var(--seal-red); font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* C:选中 = 纯字色与字重变化,零装饰 */
|
||||||
|
.demo-c .navdemo { gap: 6px; }
|
||||||
|
.demo-c .navdemo a {
|
||||||
|
text-decoration: none; color: var(--sub); font-size: 13.5px;
|
||||||
|
padding: 7px 12px; border-radius: 6px;
|
||||||
|
}
|
||||||
|
.demo-c .navdemo a:hover { color: var(--text); }
|
||||||
|
.demo-c .navdemo a.cur { color: #fff; font-weight: 600; }
|
||||||
|
|
||||||
|
/* D:选中 = 按下去的实体键(内凹) */
|
||||||
|
.demo-d .navdemo { gap: 6px; }
|
||||||
|
.demo-d .navdemo a {
|
||||||
|
text-decoration: none; color: var(--sub); font-size: 13px; font-weight: 600;
|
||||||
|
padding: 8px 14px; border-radius: 4px; border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.demo-d .navdemo a:hover { color: var(--ink); }
|
||||||
|
.demo-d .navdemo a.cur {
|
||||||
|
color: var(--ink); background: #dfe1e4; border-color: var(--line);
|
||||||
|
box-shadow: 0 1.5px 2px rgba(27,29,31,0.18) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label { margin: 0 0 12px; }
|
||||||
|
.demo-a .section-label { font-size: 13px; font-weight: 700; letter-spacing: 0.08em; color: var(--sub); }
|
||||||
|
.demo-b .section-label { font-family: 'Songti SC', 'STSong', Georgia, serif; font-size: 15px; font-weight: 700; color: var(--navy); }
|
||||||
|
.demo-c .section-label { font-size: 11.5px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: var(--sub); }
|
||||||
|
.demo-d .section-label { font-size: 10.5px; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: var(--sub); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="switcher" role="tablist">
|
||||||
|
<button class="on" data-t="a" role="tab">A 站牌导视</button>
|
||||||
|
<button data-t="b" role="tab">B 纸上台账</button>
|
||||||
|
<button data-t="c" role="tab">C 克制暗色</button>
|
||||||
|
<button data-t="d" role="tab">D 仪表工业</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ================= A 站牌导视 ================= -->
|
||||||
|
<section class="demo demo-a on" id="demo-a">
|
||||||
|
<div class="frame">
|
||||||
|
<header class="demo-intro">
|
||||||
|
<div class="no">方向 A</div>
|
||||||
|
<h1>站牌导视</h1>
|
||||||
|
<p>轨道交通导视系统的语言:白底粗黑、线路色圆标、站名牌。系统本来就叫 Station——站点是车站,协议是线路,直连和 WARP 是两个站台。信息密度靠导视系统级的层级控制,而不是靠深色背景。</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="navdemo">
|
||||||
|
<a href="#" onclick="return false">节点</a>
|
||||||
|
<a href="#" onclick="return false" class="cur">策略组</a>
|
||||||
|
<a href="#" onclick="return false">规则</a>
|
||||||
|
<a href="#" onclick="return false">订阅</a>
|
||||||
|
<a href="#" onclick="return false">设置</a>
|
||||||
|
</nav>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn btn-primary">添加节点</button>
|
||||||
|
<button class="btn btn-secondary">批量导入</button>
|
||||||
|
<button class="btn btn-plain">复制链接</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-label">站点卡</p>
|
||||||
|
<div class="station-sign">
|
||||||
|
<span class="code">SG</span>
|
||||||
|
<span class="name">sg.example.com</span>
|
||||||
|
<span class="count">7 条线路 · AWS 新加坡</span>
|
||||||
|
</div>
|
||||||
|
<div class="sign-strip"><i class="s-direct"></i><i class="s-warp"></i></div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="platforms">
|
||||||
|
<div class="platform platform-direct">
|
||||||
|
<div class="platform-head">直连出口 DIRECT</div>
|
||||||
|
<ul>
|
||||||
|
<li><span class="proto-dot p-v">V</span>VMess<span class="path">/9d3f01</span></li>
|
||||||
|
<li><span class="proto-dot p-t">T</span>Trojan<span class="path">/8a20c4</span></li>
|
||||||
|
<li><span class="proto-dot p-s">S</span>Shadowsocks<span class="path">/3f81b2</span></li>
|
||||||
|
<li><span class="proto-dot p-h">H</span>Hysteria2<span class="path">:443</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="platform platform-warp">
|
||||||
|
<div class="platform-head">WARP 净出口</div>
|
||||||
|
<ul>
|
||||||
|
<li><span class="proto-dot p-v">V</span>VMess<span class="path">/72e5a9</span></li>
|
||||||
|
<li><span class="proto-dot p-t">T</span>Trojan<span class="path">/93bf25</span></li>
|
||||||
|
<li><span class="proto-dot p-s">S</span>Shadowsocks<span class="path">/29c1b3</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-label">节点表</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>名称</th><th>协议</th><th>出口</th><th>服务器</th><th>路径</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>SG-vmess-direct</td>
|
||||||
|
<td><span class="proto-dot p-v">V</span></td>
|
||||||
|
<td><span class="tag tag-direct"><i></i>直连</span></td>
|
||||||
|
<td class="mono">cc.sg.example.com:443</td>
|
||||||
|
<td class="mono">/9d3f01</td>
|
||||||
|
<td><span class="rowact">编辑</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>SG-trojan-warp</td>
|
||||||
|
<td><span class="proto-dot p-t">T</span></td>
|
||||||
|
<td><span class="tag tag-warp"><i></i>WARP</span></td>
|
||||||
|
<td class="mono">cc.sg.example.com:443</td>
|
||||||
|
<td class="mono">/93bf25</td>
|
||||||
|
<td><span class="rowact">编辑</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>LA-ss-direct(已停用)</td>
|
||||||
|
<td><span class="proto-dot p-s">S</span></td>
|
||||||
|
<td><span class="tag tag-off">已停用</span></td>
|
||||||
|
<td class="mono">cc.la.example.com:443</td>
|
||||||
|
<td class="mono">/3f81b2</td>
|
||||||
|
<td><span class="rowact">编辑</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ================= B 纸上台账 ================= -->
|
||||||
|
<section class="demo demo-b" id="demo-b">
|
||||||
|
<div class="frame">
|
||||||
|
<header class="demo-intro">
|
||||||
|
<div class="no">No. B / 登记簿式</div>
|
||||||
|
<h1>纸上台账</h1>
|
||||||
|
<p>档案登记簿的秩序感:纸面底色、衬线标题、墨蓝正文、点线引导。出口语义不用高亮色块,而是盖两枚印章——朱砂是直连,藏青是 WARP。数据库本来就是台账,界面照着台账长。</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="navdemo">
|
||||||
|
<a href="#" onclick="return false">节点</a>
|
||||||
|
<a href="#" onclick="return false" class="cur">策略组</a>
|
||||||
|
<a href="#" onclick="return false">规则</a>
|
||||||
|
<a href="#" onclick="return false">订阅</a>
|
||||||
|
<a href="#" onclick="return false">设置</a>
|
||||||
|
</nav>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn btn-primary">添加节点</button>
|
||||||
|
<button class="btn">批量导入</button>
|
||||||
|
<button class="btn btn-plain">复制链接</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-label">站点卡</p>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<span class="code">新加坡 SG</span>
|
||||||
|
<span class="domain">sg.example.com</span>
|
||||||
|
<span class="no">REG-2026-001</span>
|
||||||
|
</div>
|
||||||
|
<ul class="ledger">
|
||||||
|
<li><span class="idx">01</span>VMess<span class="leader"></span><span class="path">/9d3f01</span><span class="seal seal-direct">直</span></li>
|
||||||
|
<li><span class="idx">02</span>VMess<span class="leader"></span><span class="path">/72e5a9</span><span class="seal seal-warp">W</span></li>
|
||||||
|
<li><span class="idx">03</span>Trojan<span class="leader"></span><span class="path">/8a20c4</span><span class="seal seal-direct">直</span></li>
|
||||||
|
<li><span class="idx">04</span>Shadowsocks<span class="leader"></span><span class="path">/29c1b3</span><span class="seal seal-warp">W</span></li>
|
||||||
|
<li><span class="idx">05</span>Hysteria2<span class="leader"></span><span class="path">:443</span><span class="seal seal-direct">直</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-label">节点表</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>名称</th><th>协议</th><th>出口</th><th>服务器</th><th>路径</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>SG-vmess-direct</td>
|
||||||
|
<td><span class="tag tag-proto">VMess</span></td>
|
||||||
|
<td><span class="tag tag-direct">直连</span></td>
|
||||||
|
<td class="mono">cc.sg.example.com:443</td>
|
||||||
|
<td class="mono">/9d3f01</td>
|
||||||
|
<td><span class="rowact">编辑</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>SG-trojan-warp</td>
|
||||||
|
<td><span class="tag tag-proto">Trojan</span></td>
|
||||||
|
<td><span class="tag tag-warp">WARP</span></td>
|
||||||
|
<td class="mono">cc.sg.example.com:443</td>
|
||||||
|
<td class="mono">/93bf25</td>
|
||||||
|
<td><span class="rowact">编辑</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>LA-ss-direct(已停用)</td>
|
||||||
|
<td><span class="tag tag-proto">Shadowsocks</span></td>
|
||||||
|
<td><span class="tag tag-proto">已停用</span></td>
|
||||||
|
<td class="mono">cc.la.example.com:443</td>
|
||||||
|
<td class="mono">/3f81b2</td>
|
||||||
|
<td><span class="rowact">编辑</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ================= C 克制暗色 ================= -->
|
||||||
|
<section class="demo demo-c" id="demo-c">
|
||||||
|
<div class="frame">
|
||||||
|
<header class="demo-intro">
|
||||||
|
<div class="no">方向 C</div>
|
||||||
|
<h1>克制暗色</h1>
|
||||||
|
<p>保留暗色,但把「AI 味」全部拿掉:中性炭黑而非深蓝、零发光零亮边条、层级全部交给字重与留白。琥珀与青只出现在数据本身(WS 路径)上——颜色只在承载信息时才出现。</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="navdemo">
|
||||||
|
<a href="#" onclick="return false">节点</a>
|
||||||
|
<a href="#" onclick="return false" class="cur">策略组</a>
|
||||||
|
<a href="#" onclick="return false">规则</a>
|
||||||
|
<a href="#" onclick="return false">订阅</a>
|
||||||
|
<a href="#" onclick="return false">设置</a>
|
||||||
|
</nav>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn btn-primary">添加节点</button>
|
||||||
|
<button class="btn btn-secondary">批量导入</button>
|
||||||
|
<button class="btn btn-plain">复制链接</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-label">站点卡</p>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<span class="code">SG · 新加坡</span>
|
||||||
|
<span class="domain">sg.example.com</span>
|
||||||
|
<span class="count">7 条线路</span>
|
||||||
|
</div>
|
||||||
|
<ul class="lines">
|
||||||
|
<li><span class="proto">VMess</span><span class="path path-direct">/9d3f01</span><span class="eg">直连</span></li>
|
||||||
|
<li><span class="proto">VMess</span><span class="path path-warp">/72e5a9</span><span class="eg">WARP</span></li>
|
||||||
|
<li><span class="proto">Trojan</span><span class="path path-direct">/8a20c4</span><span class="eg">直连</span></li>
|
||||||
|
<li><span class="proto">Shadowsocks</span><span class="path path-warp">/29c1b3</span><span class="eg">WARP</span></li>
|
||||||
|
<li><span class="proto">Hysteria2</span><span class="path path-direct">:443</span><span class="eg">直连</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-label">节点表</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>名称</th><th>协议</th><th>出口</th><th>服务器</th><th>路径</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>SG-vmess-direct</td>
|
||||||
|
<td><span class="tag">vmess</span></td>
|
||||||
|
<td><span class="tag tag-direct">直连</span></td>
|
||||||
|
<td class="mono">cc.sg.example.com:443</td>
|
||||||
|
<td class="mono path-direct">/9d3f01</td>
|
||||||
|
<td><span class="rowact">编辑</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>SG-trojan-warp</td>
|
||||||
|
<td><span class="tag">trojan</span></td>
|
||||||
|
<td><span class="tag tag-warp">WARP</span></td>
|
||||||
|
<td class="mono">cc.sg.example.com:443</td>
|
||||||
|
<td class="mono path-warp">/93bf25</td>
|
||||||
|
<td><span class="rowact">编辑</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>LA-ss-direct(已停用)</td>
|
||||||
|
<td><span class="tag">ss</span></td>
|
||||||
|
<td><span class="tag">已停用</span></td>
|
||||||
|
<td class="mono">cc.la.example.com:443</td>
|
||||||
|
<td class="mono">/3f81b2</td>
|
||||||
|
<td><span class="rowact">编辑</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ================= D 仪表工业 ================= -->
|
||||||
|
<section class="demo demo-d" id="demo-d">
|
||||||
|
<div class="frame">
|
||||||
|
<header class="demo-intro">
|
||||||
|
<div class="no">MODULE D</div>
|
||||||
|
<h1>仪表工业</h1>
|
||||||
|
<p>Dieter Rams 式仪器面板:冷灰机身、白色面板、实体按键的按压手感、大写字母间距的小标签。出口状态是两枚指示灯——橙灯直连、绿灯 WARP,像老式功放上的通道指示。</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="navdemo">
|
||||||
|
<a href="#" onclick="return false">节点</a>
|
||||||
|
<a href="#" onclick="return false" class="cur">策略组</a>
|
||||||
|
<a href="#" onclick="return false">规则</a>
|
||||||
|
<a href="#" onclick="return false">订阅</a>
|
||||||
|
<a href="#" onclick="return false">设置</a>
|
||||||
|
</nav>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn btn-primary">添加节点</button>
|
||||||
|
<button class="btn">批量导入</button>
|
||||||
|
<button class="btn btn-plain">复制链接</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-label">Station Card / 站点卡</p>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<span class="code">SG</span>
|
||||||
|
<span class="domain">sg.example.com</span>
|
||||||
|
<div class="meter">
|
||||||
|
<span class="label">DIRECT</span><i class="lamp lamp-direct"></i>
|
||||||
|
<span class="label" style="margin-left:10px">WARP</span><i class="lamp lamp-warp"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="slot"><i class="lamp lamp-direct"></i>VMess<span class="path">/9d3f01</span></div>
|
||||||
|
<div class="slot"><i class="lamp lamp-warp"></i>VMess<span class="path">/72e5a9</span></div>
|
||||||
|
<div class="slot"><i class="lamp lamp-direct"></i>Trojan<span class="path">/8a20c4</span></div>
|
||||||
|
<div class="slot"><i class="lamp lamp-warp"></i>Shadowsocks<span class="path">/29c1b3</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-label">Nodes / 节点表</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th><span class="label">名称</span></th><th><span class="label">协议</span></th>
|
||||||
|
<th><span class="label">出口</span></th><th><span class="label">服务器</span></th>
|
||||||
|
<th><span class="label">路径</span></th><th></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>SG-vmess-direct</td>
|
||||||
|
<td class="mono">vmess</td>
|
||||||
|
<td><span class="tag tag-direct"><i></i>Direct</span></td>
|
||||||
|
<td class="mono">cc.sg.example.com:443</td>
|
||||||
|
<td class="mono">/9d3f01</td>
|
||||||
|
<td><span class="rowact">EDIT</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>SG-trojan-warp</td>
|
||||||
|
<td class="mono">trojan</td>
|
||||||
|
<td><span class="tag tag-warp"><i></i>WARP</span></td>
|
||||||
|
<td class="mono">cc.sg.example.com:443</td>
|
||||||
|
<td class="mono">/93bf25</td>
|
||||||
|
<td><span class="rowact">EDIT</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>LA-ss-direct(已停用)</td>
|
||||||
|
<td class="mono">ss</td>
|
||||||
|
<td><span class="tag"><i style="background:#b7bbc0"></i>OFF</span></td>
|
||||||
|
<td class="mono">cc.la.example.com:443</td>
|
||||||
|
<td class="mono">/3f81b2</td>
|
||||||
|
<td><span class="rowact">EDIT</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll('.switcher button').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
document.querySelectorAll('.switcher button').forEach(function (b) { b.classList.remove('on'); });
|
||||||
|
document.querySelectorAll('.demo').forEach(function (d) { d.classList.remove('on'); });
|
||||||
|
btn.classList.add('on');
|
||||||
|
document.getElementById('demo-' + btn.dataset.t).classList.add('on');
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
15
package.json
Normal file
15
package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "proxy-station",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -n server,web -c cyan,yellow \"pnpm --filter @proxy-station/server dev\" \"pnpm --filter @proxy-station/web dev\"",
|
||||||
|
"build": "pnpm --filter @proxy-station/shared build && pnpm --filter @proxy-station/web build && pnpm --filter @proxy-station/server build",
|
||||||
|
"typecheck": "pnpm -r typecheck",
|
||||||
|
"test": "pnpm --filter @proxy-station/server test",
|
||||||
|
"start": "pnpm --filter @proxy-station/server start"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
2733
pnpm-lock.yaml
generated
Normal file
2733
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
pnpm-workspace.yaml
Normal file
8
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
packages:
|
||||||
|
- shared
|
||||||
|
- server
|
||||||
|
- web
|
||||||
|
allowBuilds:
|
||||||
|
better-sqlite3: true
|
||||||
|
esbuild: true
|
||||||
|
vue-demi: true
|
||||||
303
server/assets/surge-template.conf
Normal file
303
server/assets/surge-template.conf
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
[General]
|
||||||
|
# --- GENERAL / 通用设置 ---
|
||||||
|
# Enhanced Wi-Fi Assist / 增强 Wi-Fi 助手
|
||||||
|
wifi-assist = true
|
||||||
|
# Hybrid Network / 混合网络
|
||||||
|
all-hybrid = false
|
||||||
|
# Gaming Optimization / 游戏优化
|
||||||
|
udp-priority = false
|
||||||
|
# Latency Benchmark / 延迟基准测试
|
||||||
|
internet-test-url = http://connectivitycheck.gstatic.com/generate_204
|
||||||
|
proxy-test-url = http://captive.apple.com/generate_204
|
||||||
|
test-timeout = 3
|
||||||
|
# GeoIP Database / GeoIP 数据库
|
||||||
|
geoip-maxmind-url = https://github.com/Hackl0us/GeoIP2-CN/raw/release/Country.mmdb
|
||||||
|
# IPv6 Support / IPv6 支持
|
||||||
|
ipv6 = false
|
||||||
|
ipv6-vif = auto
|
||||||
|
# --- Wi-Fi ACCESS / Wi-Fi 访问 ---
|
||||||
|
allow-wifi-access = false
|
||||||
|
wifi-access-http-port = 6152
|
||||||
|
wifi-access-socks5-port = 6153
|
||||||
|
http-listen = 0.0.0.0
|
||||||
|
socks5-listen = 0.0.0.0
|
||||||
|
allow-hotspot-access = true
|
||||||
|
# --- REMOTE CONTROLLER / 远程控制器 ---
|
||||||
|
external-controller-access = surge@0.0.0.0:6160
|
||||||
|
http-api = clashconnectrules@0.0.0.0:6166
|
||||||
|
http-api-tls = true
|
||||||
|
http-api-web-dashboard = true
|
||||||
|
# --- COMPATIBILITY / 兼容性 ---
|
||||||
|
compatibility-mode = 1
|
||||||
|
# --- NETWORK / 网络 ---
|
||||||
|
skip-proxy = localhost, *.local, captive.apple.com, guzzoni.apple.com, 0.0.0.0/8, 10.0.0.0/8, 17.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 192.88.99.0/24, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4, 255.255.255.255/32
|
||||||
|
exclude-simple-hostnames = true
|
||||||
|
read-etc-hosts = true
|
||||||
|
# --- DNS / 域名系统 ---
|
||||||
|
use-local-host-item-for-proxy = true
|
||||||
|
encrypted-dns-follow-outbound-mode = false
|
||||||
|
include-all-networks = false
|
||||||
|
include-local-networks = false
|
||||||
|
dns-server = 223.5.5.5, 223.6.6.6, 119.29.29.29
|
||||||
|
encrypted-dns-server = https://doh.pub/dns-query, https://dns.alidns.com/dns-query
|
||||||
|
loglevel = notify
|
||||||
|
# --- ADVANCED / 高级设置 ---
|
||||||
|
show-error-page-for-reject = true
|
||||||
|
force-http-engine-hosts = *.ott.cibntv.net, 123.59.31.1, 119.18.193.135, 122.14.246.33, 175.102.178.52, 116.253.24.*, 175.6.26.*, 220.169.153.*
|
||||||
|
always-real-ip = link-ip.nextdns.io, *.msftconnecttest.com, *.msftncsi.com, *.srv.nintendo.net, *.stun.playstation.net, xbox.*.microsoft.com, *.xboxlive.com, *.logon.battlenet.com.cn, *.logon.battle.net, stun.l.google.com
|
||||||
|
hijack-dns = 8.8.8.8:53, 8.8.4.4:53
|
||||||
|
udp-policy-not-supported-behaviour = REJECT
|
||||||
|
proxy-restricted-to-lan = false
|
||||||
|
|
||||||
|
[Proxy]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[Proxy Group]
|
||||||
|
# ==================== 核心策略分组 / Core Policy Groups ====================
|
||||||
|
Mainland = select, DIRECT, no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/05icon/direct.png
|
||||||
|
|
||||||
|
AllServer = select, policy-path="Your Surge subscription link goes here", update-interval=86400, no-alert=0, hidden=0, include-all-proxies=0, policy-regex-filter=^((?!Remain|Expired|官网|如需|套餐|去除|剩余|距离|Reset|重置|流量).)+$, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/05icon/rocket(1).png
|
||||||
|
|
||||||
|
# ==================== 地区自动选择组 / Region Auto-Select Groups ====================
|
||||||
|
Hong Kong = url-test, update-interval=0, policy-regex-filter=港|🇭🇰|香港|HK|Hong, no-alert=0, hidden=0, include-all-proxies=0, include-other-group=AllServer, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/01Country/Hongkong.png, url=http://cp.cloudflare.com/generate_204, interval=300, tolerance=50, timeout=3
|
||||||
|
|
||||||
|
Taiwan = url-test, update-interval=0, policy-regex-filter=台|🇹🇼|台湾|TW|Tai, no-alert=0, hidden=0, include-all-proxies=0, include-other-group=AllServer, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/01Country/taiwan.png, url=http://cp.cloudflare.com/generate_204, interval=300, tolerance=50, timeout=3
|
||||||
|
|
||||||
|
Japan = url-test, update-interval=0, policy-regex-filter=日|🇯🇵|日本|JP|Japan, no-alert=0, hidden=0, include-all-proxies=0, include-other-group=AllServer, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/01Country/Japan.png, url=http://cp.cloudflare.com/generate_204, interval=300, tolerance=50, timeout=3
|
||||||
|
|
||||||
|
Singapore = url-test, update-interval=0, policy-regex-filter=坡|🇸🇬|新加坡|狮城|SG|Singapore, no-alert=0, hidden=0, include-all-proxies=0, include-other-group=AllServer, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/01Country/singapore.png, url=http://cp.cloudflare.com/generate_204, interval=300, tolerance=50, timeout=3
|
||||||
|
|
||||||
|
United States = url-test, update-interval=0, policy-regex-filter=美|🇺🇸|美国|US|States|American, no-alert=0, hidden=0, include-all-proxies=0, include-other-group=AllServer, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/01Country/US.png, url=http://cp.cloudflare.com/generate_204, interval=300, tolerance=50, timeout=3
|
||||||
|
|
||||||
|
United Kingdom = url-test, update-interval=0, policy-regex-filter=🇬🇧|英国|英|英格兰|UK|United Kingdom, no-alert=0, hidden=0, include-all-proxies=0, include-other-group=AllServer, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/01Country/UnitedKingdom.png, url=http://cp.cloudflare.com/generate_204, interval=300, tolerance=50, timeout=3
|
||||||
|
|
||||||
|
Korea = url-test, update-interval=0, policy-regex-filter=韩|韩国|Korea|KR|🇰🇷, no-alert=0, hidden=0, include-all-proxies=0, include-other-group=AllServer, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/01Country/Korea.png, url=http://cp.cloudflare.com/generate_204, interval=300, tolerance=50, timeout=3
|
||||||
|
|
||||||
|
Other = url-test, update-interval=0, policy-regex-filter=^((?!港|🇭🇰|香港|HK|Hong|台|🇹🇼|台湾|TW|Tai|日|🇯🇵|日本|JP|Japan|坡|🇸🇬|新加坡|狮城|SG|Singapore|美|🇺🇸|美国|US|States|American|🇬🇧|英国|英|英格兰|UK|United Kingdom|韩|韩国|Korea|KR|🇰🇷|Remain|Expired|官网|如需|套餐|去除|剩余|距离|Reset|重置|流量).)+$, no-alert=0, hidden=0, include-all-proxies=0, include-other-group=AllServer, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/01Country/EuropeanUnion.png, url=http://cp.cloudflare.com/generate_204, interval=300, tolerance=50, timeout=3
|
||||||
|
|
||||||
|
# ==================== 组合策略 / Combined Policies ====================
|
||||||
|
Automatic = select, "Hong Kong", Taiwan, Japan, Singapore, "United States", "United Kingdom", Korea, url=http://cp.cloudflare.com/generate_204, interval=300, tolerance=50, timeout=3, no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/05icon/lightning(5).png
|
||||||
|
|
||||||
|
Proxy = select, Automatic, "Hong Kong", Taiwan, Singapore, Japan, "United States", "United Kingdom", no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/05icon/rocket(1).png
|
||||||
|
|
||||||
|
NoAuto = select, Mainland, Automatic, no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/05icon/lightning.png
|
||||||
|
|
||||||
|
# ==================== 应用服务策略 / App Service Policies ====================
|
||||||
|
# --- 苹果服务 / Apple Services ---
|
||||||
|
Apple = select, Mainland, "Hong Kong", "United States", icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/03CNSoft/apple.png
|
||||||
|
|
||||||
|
# --- AI 服务 / AI Services ---
|
||||||
|
AI = select, Automatic, "United States", Japan, Singapore, no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/04ProxySoft/chatgpt(1).png
|
||||||
|
|
||||||
|
# --- 微软服务 / Microsoft Services ---
|
||||||
|
Microsoft = select, Mainland, "Hong Kong", Singapore, "United States", icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/03CNSoft/microsoft.png
|
||||||
|
OneDrive = select, Mainland, "Hong Kong", Singapore, "United States", icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/03CNSoft/OneDrive.png
|
||||||
|
|
||||||
|
# --- 社交通讯 / Social & Messaging ---
|
||||||
|
Telegram = select, Automatic, Singapore, "United States", "Hong Kong", Taiwan, Japan, no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/04ProxySoft/telegram.png
|
||||||
|
WeChat = select, Mainland, "Hong Kong", Singapore, "United States", icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/03CNSoft/wechat.png
|
||||||
|
X = select, Automatic, "Hong Kong", Taiwan, Singapore, Japan, "United States", no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/04ProxySoft/twitter.png
|
||||||
|
|
||||||
|
# --- 流媒体服务 / Streaming Services ---
|
||||||
|
Bilibili = select, Mainland, "Hong Kong", Taiwan, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/03CNSoft/bilibili.png
|
||||||
|
Disney+ = select, "Hong Kong", Singapore, no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/04ProxySoft/disney.png
|
||||||
|
Netflix = select, "Hong Kong", Taiwan, Singapore, Japan, "United States", icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/04ProxySoft/netflix.png
|
||||||
|
TikTok = select, Taiwan, Singapore, Japan, "United States", no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/04ProxySoft/tiktok.png
|
||||||
|
YouTube = select, Automatic, "Hong Kong", Taiwan, Singapore, Japan, "United States", no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/04ProxySoft/Youtube.png
|
||||||
|
|
||||||
|
# --- 工具服务 / Utility Services ---
|
||||||
|
Speedtest = select, Mainland, Automatic, AllServer, no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/03CNSoft/speedtest.png
|
||||||
|
|
||||||
|
|
||||||
|
[Rule]
|
||||||
|
# >> 规则修正 (Unbreak Rules)
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/zxfccmm4/Profiles/refs/heads/main/Surge/Ruleset/Unbreak.list,DIRECT
|
||||||
|
|
||||||
|
# >> 广告拦截 (Ad Blocking)
|
||||||
|
DOMAIN-SET,https://ruleset.skk.moe/List/domainset/reject.conf,REJECT
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/non_ip/reject-drop.conf,REJECT-DROP
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/non_ip/reject.conf,REJECT
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/ip/reject.conf,REJECT-DROP
|
||||||
|
|
||||||
|
# >> 隐私保护 (Privacy Protection)
|
||||||
|
DOMAIN,app-site-association.cdn-apple.com,REJECT
|
||||||
|
DOMAIN,prpr.96110.cn.com,DIRECT
|
||||||
|
DOMAIN-KEYWORD,96110,REJECT
|
||||||
|
DOMAIN-SUFFIX,gjfzpt.cn,REJECT
|
||||||
|
|
||||||
|
# >> 国内应用 (Mainland Apps)
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/WeChat/WeChat.list,WeChat
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/non_ip/neteasemusic.conf,Mainland
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/ip/neteasemusic.conf,Mainland
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/BiliBili/BiliBili.list,Bilibili
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Weibo/Weibo.list,Mainland
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/XiaoHongShu/XiaoHongShu.list,Mainland
|
||||||
|
|
||||||
|
# >> Apple 服务 (Apple Services)
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/AppStore/AppStore.list,DIRECT
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/AppleNews/AppleNews.list,"United States"
|
||||||
|
DOMAIN-SET,https://ruleset.skk.moe/List/domainset/apple_cdn.conf,Apple
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/AppleTV/AppleTV.list,Apple
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Apple/Apple.list,Apple
|
||||||
|
|
||||||
|
# >> AI 服务 (AI Services)
|
||||||
|
DOMAIN-KEYWORD,diabrowser.engineering,AI
|
||||||
|
DOMAIN-SUFFIX,apple-relay.apple.com,AI
|
||||||
|
DOMAIN-SUFFIX,apple-relay.cloudflare.com,AI
|
||||||
|
DOMAIN-KEYWORD,apple-relay,AI
|
||||||
|
DOMAIN-SUFFIX,googleapis.com,AI
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/non_ip/ai.conf,AI
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Bing/Bing.list,AI
|
||||||
|
DOMAIN-SUFFIX,diabrowser.engineering,AI
|
||||||
|
|
||||||
|
# >> 国外流媒体 (International Streaming)
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Disney/Disney.list,Disney+
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Netflix/Netflix.list,Netflix
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/Semporia/TikTok-Unlock/master/Surge/TikTok.list,TikTok
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/YouTube/YouTube.list,YouTube
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/GoogleSearch/GoogleSearch.list,YouTube
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Emby/Emby.list,Automatic
|
||||||
|
|
||||||
|
# >> 地区流媒体解锁 (Regional Streaming Unlock)
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/non_ip/stream_us.conf,"United States"
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/ip/stream_us.conf,"United States"
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/non_ip/stream_eu.conf,"United Kingdom"
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/ip/stream_eu.conf,"United Kingdom"
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/non_ip/stream_jp.conf,Japan
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/ip/stream_jp.conf,Japan
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/non_ip/stream_kr.conf,Korea
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/ip/stream_kr.conf,Korea
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/non_ip/stream_hk.conf,"Hong Kong"
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/ip/stream_hk.conf,"Hong Kong"
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/non_ip/stream_tw.conf,Taiwan
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/ip/stream_tw.conf,Taiwan
|
||||||
|
|
||||||
|
# >> 国外社交媒体 (International Social Media)
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Twitter/Twitter.list,X
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/non_ip/telegram.conf,Telegram
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/ip/telegram.conf,Telegram
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/ip/telegram_asn.conf,Telegram
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Facebook/Facebook.list,Automatic
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Instagram/Instagram.list,Automatic
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Threads/Threads.list,Automatic
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Discord/Discord.list,Automatic
|
||||||
|
|
||||||
|
# >> 其他国外服务 (Other International Services)
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/OneDrive/OneDrive.list,OneDrive
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/non_ip/microsoft.conf,Microsoft
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/GitHub/GitHub.list,Automatic
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/getsomecat/GetSomeCats/Surge/rule/substore.list,Automatic
|
||||||
|
DOMAIN-SUFFIX,vercel.app,Automatic
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Speedtest/Speedtest.list,Speedtest
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Riot/Riot.list,"United States"
|
||||||
|
DOMAIN-SUFFIX,mypikpak.com,"United States"
|
||||||
|
DOMAIN-SUFFIX,nssurge.com,Automatic
|
||||||
|
DOMAIN-SUFFIX,surgee.me,Automatic
|
||||||
|
DOMAIN-SUFFIX,www.torrentmac.net,Automatic
|
||||||
|
DOMAIN-SUFFIX,cloudflare.com,Automatic
|
||||||
|
DOMAIN,jable.tv,Automatic
|
||||||
|
DOMAIN,api.amplitude.com,Automatic
|
||||||
|
DOMAIN,m.cmx.im,Automatic
|
||||||
|
DOMAIN-SUFFIX,api.themoviedb.org,Automatic
|
||||||
|
|
||||||
|
# >> 国内规则 (Mainland China Rules)
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/non_ip/direct.conf,Mainland
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/non_ip/domestic.conf,Mainland
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/ip/domestic.conf,Mainland
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/ChinaMax/ChinaMax.list,Mainland
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/VirgilClyne/GetSomeFries/main/ruleset/ASN.China.list,DIRECT
|
||||||
|
DOMAIN-SET,https://ruleset.skk.moe/List/domainset/download.conf,Mainland
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/non_ip/download.conf,Mainland
|
||||||
|
RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Tesla/Tesla.list,DIRECT
|
||||||
|
DOMAIN-SUFFIX,appstorrent.ru,DIRECT
|
||||||
|
|
||||||
|
# >> 国外规则 (Global Rules)
|
||||||
|
DOMAIN-SET,https://ruleset.skk.moe/List/domainset/cdn.conf,Automatic
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/non_ip/cdn.conf,Automatic
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/non_ip/global_plus.conf,Automatic
|
||||||
|
RULE-SET,https://ruleset.skk.moe/List/non_ip/global.conf,Automatic
|
||||||
|
|
||||||
|
# >> 网络优化与特殊规则 (Network Optimization)
|
||||||
|
AND,((PROTOCOL,UDP), (DOMAIN-SUFFIX,googlevideo.com)),REJECT-NO-DROP
|
||||||
|
DOMAIN-SUFFIX,smtp,DIRECT
|
||||||
|
URL-REGEX,(Subject|HELO|SMTP),DIRECT
|
||||||
|
|
||||||
|
# >> 本地网络 (LAN)
|
||||||
|
RULE-SET,LAN,DIRECT
|
||||||
|
|
||||||
|
# >> 兜底规则 (Final Rule)
|
||||||
|
FINAL,NoAuto,dns-failed
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
*.taobao.com = server:223.5.5.5
|
||||||
|
*.tmall.com = server:223.5.5.5
|
||||||
|
*.alipay.com = server:223.5.5.5
|
||||||
|
*.alicdn.com = server:223.5.5.5
|
||||||
|
*.aliyun.com = server:223.5.5.5
|
||||||
|
*.jd.com = server:119.28.28.28
|
||||||
|
*.qq.com = server:119.28.28.28
|
||||||
|
*.tencent.com = server:119.28.28.28
|
||||||
|
*.weixin.com = server:119.28.28.28
|
||||||
|
*.bilibili.com = server:119.29.29.29
|
||||||
|
hdslb.com = server:119.29.29.29
|
||||||
|
*.163.com = server:119.29.29.29
|
||||||
|
*.126.com = server:119.29.29.29
|
||||||
|
*.126.net = server:119.29.29.29
|
||||||
|
*.127.net = server:119.29.29.29
|
||||||
|
*.netease.com = server:119.29.29.29
|
||||||
|
*.mi.com = server:119.29.29.29
|
||||||
|
*.xiaomi.com = server:119.29.29.29
|
||||||
|
*testflight.apple.com = server:8.8.4.4
|
||||||
|
# > Firebase Cloud Messaging / Firebase 云消息
|
||||||
|
mtalk.google.com = 108.177.125.188
|
||||||
|
# > Google Download / Google 下载
|
||||||
|
dl.google.com = server:119.29.29.29
|
||||||
|
dl.l.google.com = server:119.29.29.29
|
||||||
|
update.googleapis.com = server:119.29.29.29
|
||||||
|
# > Router Admin Panel / 路由器管理面板
|
||||||
|
amplifi.lan = server:system // Ubiquiti Amplifi Router
|
||||||
|
router.synology.com = server:system // Synology Router
|
||||||
|
sila.razer.com = server:system // Razer Sila Router
|
||||||
|
router.asus.com = server:system // Asus Router
|
||||||
|
routerlogin.net = server:system // Netgear Router
|
||||||
|
orbilogin.com = server:system // Netgear Obri Router
|
||||||
|
www.LinksysSmartWiFi.com = server:system // Linksys Router
|
||||||
|
LinksysSmartWiFi.com = server:system // Linksys Router
|
||||||
|
myrouter.local = server:system // Linksys Router
|
||||||
|
www.miwifi.com = server:system // Xiaomi Mi WiFi Router
|
||||||
|
miwifi.com = server:system // Xiaomi Mi WiFi Router
|
||||||
|
mediarouter.home = server:system // Huawei Router
|
||||||
|
tplogin.cn = server:system // TP-Link Router
|
||||||
|
tplinklogin.net = server:system // TP-Link Router
|
||||||
|
melogin.cn = server:system // MERCURY Router
|
||||||
|
falogin.cn = server:system // FAST Router
|
||||||
|
|
||||||
|
[URL Rewrite]
|
||||||
|
# > Redirect Google Services / 重定向 Google 服务
|
||||||
|
^https?:\/\/(www\.)?(g|google)\.cn https://www.google.com 302
|
||||||
|
^https?:\/\/(ditu|maps)\.google\.cn https://maps.google.com 302
|
||||||
|
|
||||||
|
# > HTTPS Redirects / HTTPS 重定向
|
||||||
|
^https?:\/\/(www\.)?taobao\.com\/ https://taobao.com/ 302
|
||||||
|
^https?:\/\/(www\.)?jd\.com\/ https://www.jd.com/ 302
|
||||||
|
^https?:\/\/(www\.)?mi\.com\/ https://www.mi.com/ 302
|
||||||
|
^https?:\/\/you\.163\.com\/ https://you.163.com/ 302
|
||||||
|
^https?:\/\/(www\.)?suning\.com\/ https://suning.com/ 302
|
||||||
|
^https?:\/\/(www\.)?yhd\.com\/ https://yhd.com/ 302
|
||||||
|
|
||||||
|
# > Service Optimizations / 服务优化
|
||||||
|
^https?:\/\/api\.abema\.io\/v\d\/ip\/check - reject
|
||||||
|
|
||||||
|
[Header Rewrite]
|
||||||
|
# 解决github速率限制返回429问题 / Fix GitHub 429 Rate Limit Issue
|
||||||
|
http-request ^https:\/\/github\.com\/ header-replace Accept-Language en-us
|
||||||
|
http-request ^https:\/\/(raw|gist)\.githubusercontent\.com\/ header-replace Accept-Language en-us
|
||||||
|
|
||||||
|
[MITM]
|
||||||
|
skip-server-cert-verify = true
|
||||||
|
tcp-connection = true
|
||||||
|
h2 = true
|
||||||
|
hostname = www.google.cn, api.abema.io, *.zhihu.com, -CUSTOMMitM, sub.store
|
||||||
|
[Script]
|
||||||
|
# > Flush DNS, show the DNS delay and server / 刷新 DNS,显示延迟和服务器
|
||||||
32
server/package.json
Normal file
32
server/package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@proxy-station/server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc --noEmit",
|
||||||
|
"start": "tsx src/index.ts",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/node-server": "^1.13.7",
|
||||||
|
"@hono/zod-validator": "^0.4.2",
|
||||||
|
"@proxy-station/shared": "workspace:*",
|
||||||
|
"better-sqlite3": "^11.7.0",
|
||||||
|
"drizzle-orm": "^0.38.3",
|
||||||
|
"hono": "^4.6.14",
|
||||||
|
"nanoid": "^5.0.9",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"undici": "^7.2.0",
|
||||||
|
"yaml": "^2.6.1",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
|
"@types/node": "^22.10.2",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
125
server/src/db/index.ts
Normal file
125
server/src/db/index.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { env } from '../env.js';
|
||||||
|
import * as schema from './schema.js';
|
||||||
|
|
||||||
|
const BOOTSTRAP_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS nodes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
protocol TEXT NOT NULL,
|
||||||
|
server TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
network TEXT NOT NULL DEFAULT 'tcp',
|
||||||
|
ws_path TEXT,
|
||||||
|
ws_host TEXT,
|
||||||
|
tls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sni TEXT,
|
||||||
|
skip_cert_verify INTEGER NOT NULL DEFAULT 0,
|
||||||
|
uuid TEXT,
|
||||||
|
alter_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
security TEXT NOT NULL DEFAULT 'auto',
|
||||||
|
password TEXT,
|
||||||
|
method TEXT,
|
||||||
|
obfs_type TEXT,
|
||||||
|
obfs_password TEXT,
|
||||||
|
up_mbps INTEGER,
|
||||||
|
down_mbps INTEGER,
|
||||||
|
region TEXT,
|
||||||
|
tags TEXT NOT NULL DEFAULT '[]',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
extra TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS policy_groups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
test_url TEXT,
|
||||||
|
interval INTEGER,
|
||||||
|
tolerance INTEGER,
|
||||||
|
filter_regex TEXT,
|
||||||
|
include_all_nodes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
source TEXT NOT NULL DEFAULT 'custom'
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS group_members (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
group_id TEXT NOT NULL REFERENCES policy_groups(id) ON DELETE CASCADE,
|
||||||
|
member_kind TEXT NOT NULL,
|
||||||
|
node_id TEXT REFERENCES nodes(id) ON DELETE CASCADE,
|
||||||
|
ref_name TEXT,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS rules (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
seq INTEGER NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
value TEXT,
|
||||||
|
policy TEXT NOT NULL,
|
||||||
|
params TEXT NOT NULL DEFAULT '[]',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
source TEXT NOT NULL DEFAULT 'custom'
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS rulesets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
surge_url TEXT NOT NULL UNIQUE,
|
||||||
|
clash_url TEXT,
|
||||||
|
clash_behavior TEXT NOT NULL DEFAULT 'classical',
|
||||||
|
clash_format TEXT NOT NULL DEFAULT 'yaml',
|
||||||
|
needs_convert INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS ruleset_cache (
|
||||||
|
url TEXT PRIMARY KEY,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
etag TEXT,
|
||||||
|
fetched_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sub_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
node_ids TEXT NOT NULL DEFAULT '[]',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_access_at INTEGER,
|
||||||
|
access_count INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function createDb(dbPath?: string) {
|
||||||
|
const file = dbPath ?? path.join(env.dataDir, 'proxy-station.db');
|
||||||
|
if (file !== ':memory:') {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
}
|
||||||
|
const sqlite = new Database(file);
|
||||||
|
sqlite.pragma('journal_mode = WAL');
|
||||||
|
sqlite.pragma('foreign_keys = ON');
|
||||||
|
sqlite.exec(BOOTSTRAP_SQL);
|
||||||
|
// 轻量迁移:旧库补列
|
||||||
|
for (const stmt of [
|
||||||
|
'ALTER TABLE policy_groups ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1',
|
||||||
|
"ALTER TABLE sub_tokens ADD COLUMN node_ids TEXT NOT NULL DEFAULT '[]'",
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
sqlite.exec(stmt);
|
||||||
|
} catch {
|
||||||
|
// 列已存在
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return drizzle(sqlite, { schema });
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Db = ReturnType<typeof createDb>;
|
||||||
|
|
||||||
|
export const db = createDb();
|
||||||
101
server/src/db/schema.ts
Normal file
101
server/src/db/schema.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||||
|
|
||||||
|
export const nodes = sqliteTable('nodes', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
name: text('name').notNull().unique(),
|
||||||
|
protocol: text('protocol', { enum: ['vmess', 'trojan', 'shadowsocks', 'hysteria2'] }).notNull(),
|
||||||
|
server: text('server').notNull(),
|
||||||
|
port: integer('port').notNull(),
|
||||||
|
network: text('network', { enum: ['tcp', 'ws'] }).notNull().default('tcp'),
|
||||||
|
wsPath: text('ws_path'),
|
||||||
|
wsHost: text('ws_host'),
|
||||||
|
tls: integer('tls', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
sni: text('sni'),
|
||||||
|
skipCertVerify: integer('skip_cert_verify', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
uuid: text('uuid'),
|
||||||
|
alterId: integer('alter_id').notNull().default(0),
|
||||||
|
security: text('security').notNull().default('auto'),
|
||||||
|
password: text('password'),
|
||||||
|
method: text('method'),
|
||||||
|
obfsType: text('obfs_type'),
|
||||||
|
obfsPassword: text('obfs_password'),
|
||||||
|
upMbps: integer('up_mbps'),
|
||||||
|
downMbps: integer('down_mbps'),
|
||||||
|
region: text('region'),
|
||||||
|
tags: text('tags').notNull().default('[]'),
|
||||||
|
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
|
extra: text('extra').notNull().default('{}'),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
updatedAt: integer('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const policyGroups = sqliteTable('policy_groups', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
name: text('name').notNull().unique(),
|
||||||
|
type: text('type', { enum: ['select', 'url-test', 'fallback'] }).notNull(),
|
||||||
|
testUrl: text('test_url'),
|
||||||
|
interval: integer('interval'),
|
||||||
|
tolerance: integer('tolerance'),
|
||||||
|
filterRegex: text('filter_regex'),
|
||||||
|
includeAllNodes: integer('include_all_nodes', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
|
source: text('source', { enum: ['template', 'custom'] }).notNull().default('custom'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const groupMembers = sqliteTable('group_members', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
groupId: text('group_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => policyGroups.id, { onDelete: 'cascade' }),
|
||||||
|
memberKind: text('member_kind', { enum: ['node', 'group', 'builtin'] }).notNull(),
|
||||||
|
nodeId: text('node_id').references(() => nodes.id, { onDelete: 'cascade' }),
|
||||||
|
refName: text('ref_name'),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rules = sqliteTable('rules', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
seq: integer('seq').notNull(),
|
||||||
|
type: text('type').notNull(),
|
||||||
|
value: text('value'),
|
||||||
|
policy: text('policy').notNull(),
|
||||||
|
params: text('params').notNull().default('[]'),
|
||||||
|
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||||
|
source: text('source', { enum: ['template', 'custom'] }).notNull().default('custom'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rulesets = sqliteTable('rulesets', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
surgeUrl: text('surge_url').notNull().unique(),
|
||||||
|
clashUrl: text('clash_url'),
|
||||||
|
clashBehavior: text('clash_behavior').notNull().default('classical'),
|
||||||
|
clashFormat: text('clash_format').notNull().default('yaml'),
|
||||||
|
needsConvert: integer('needs_convert', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rulesetCache = sqliteTable('ruleset_cache', {
|
||||||
|
url: text('url').primaryKey(),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
etag: text('etag'),
|
||||||
|
fetchedAt: integer('fetched_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const subTokens = sqliteTable('sub_tokens', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
token: text('token').notNull().unique(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
/** 分配给该订阅的节点 id(JSON 数组);空数组 = 不限制,下发全部启用节点 */
|
||||||
|
nodeIds: text('node_ids').notNull().default('[]'),
|
||||||
|
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
lastAccessAt: integer('last_access_at'),
|
||||||
|
accessCount: integer('access_count').notNull().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const settings = sqliteTable('settings', {
|
||||||
|
key: text('key').primaryKey(),
|
||||||
|
value: text('value').notNull(),
|
||||||
|
});
|
||||||
131
server/src/db/seed.ts
Normal file
131
server/src/db/seed.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { env } from '../env.js';
|
||||||
|
import type { Db } from './index.js';
|
||||||
|
import { policyGroups, groupMembers, rules, rulesets, settings } from './schema.js';
|
||||||
|
import { parseSurgeConf, type ParsedGroup } from '../services/template/surge-parser.js';
|
||||||
|
import { mapRulesetUrl, rulesetNameFromUrl } from '../services/rulesets/mapping.js';
|
||||||
|
|
||||||
|
const TEMPLATE_PATH = path.resolve(env.serverRoot, 'assets/surge-template.conf');
|
||||||
|
|
||||||
|
const BUILTIN_POLICIES = new Set(['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP', 'REJECT-TINYGIF']);
|
||||||
|
|
||||||
|
function seedGroups(db: Db, groups: ParsedGroup[], source: 'template' | 'custom') {
|
||||||
|
groups.forEach((g, i) => {
|
||||||
|
const groupId = nanoid(10);
|
||||||
|
db.insert(policyGroups)
|
||||||
|
.values({
|
||||||
|
id: groupId,
|
||||||
|
name: g.name,
|
||||||
|
type: g.type,
|
||||||
|
testUrl: g.testUrl ?? null,
|
||||||
|
interval: g.interval ?? null,
|
||||||
|
tolerance: g.tolerance ?? null,
|
||||||
|
filterRegex: g.filterRegex ?? null,
|
||||||
|
// AllServer 在模板中挂上游订阅(policy-path),在本系统中即「全部节点」组
|
||||||
|
includeAllNodes: !!(g.hasPolicyPath || g.includeAllProxies),
|
||||||
|
sortOrder: i,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
const members = [...g.members];
|
||||||
|
if (g.includeOtherGroup && !members.includes(g.includeOtherGroup)) {
|
||||||
|
members.push(g.includeOtherGroup);
|
||||||
|
}
|
||||||
|
members.forEach((m, j) => {
|
||||||
|
db.insert(groupMembers)
|
||||||
|
.values({
|
||||||
|
groupId,
|
||||||
|
memberKind: BUILTIN_POLICIES.has(m) ? 'builtin' : 'group',
|
||||||
|
nodeId: null,
|
||||||
|
refName: m,
|
||||||
|
sortOrder: j,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seedRulesFromTemplate(db: Db, opts: { keepCustom: boolean }) {
|
||||||
|
const text = fs.readFileSync(TEMPLATE_PATH, 'utf8');
|
||||||
|
const parsed = parseSurgeConf(text);
|
||||||
|
|
||||||
|
const custom = opts.keepCustom
|
||||||
|
? db.select().from(rules).where(eq(rules.source, 'custom')).all()
|
||||||
|
: [];
|
||||||
|
db.delete(rules).where(eq(rules.source, 'template')).run();
|
||||||
|
if (!opts.keepCustom) db.delete(rules).where(eq(rules.source, 'custom')).run();
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
for (const r of parsed.rules) {
|
||||||
|
let value = r.value;
|
||||||
|
if ((r.type === 'RULE-SET' || r.type === 'DOMAIN-SET') && value && /^https?:\/\//.test(value)) {
|
||||||
|
const existing = db.select().from(rulesets).where(eq(rulesets.surgeUrl, value)).get();
|
||||||
|
if (existing) {
|
||||||
|
value = existing.id;
|
||||||
|
} else {
|
||||||
|
const mapping = mapRulesetUrl(value, r.type);
|
||||||
|
const rulesetId = nanoid(10);
|
||||||
|
db.insert(rulesets)
|
||||||
|
.values({
|
||||||
|
id: rulesetId,
|
||||||
|
name: rulesetNameFromUrl(value),
|
||||||
|
surgeUrl: value,
|
||||||
|
clashUrl: mapping.clashUrl,
|
||||||
|
clashBehavior: mapping.clashBehavior,
|
||||||
|
clashFormat: mapping.clashFormat,
|
||||||
|
needsConvert: mapping.needsConvert,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
value = rulesetId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.insert(rules)
|
||||||
|
.values({
|
||||||
|
id: nanoid(10),
|
||||||
|
seq: seq++,
|
||||||
|
type: r.type,
|
||||||
|
value,
|
||||||
|
policy: r.policy,
|
||||||
|
params: JSON.stringify(r.params),
|
||||||
|
enabled: true,
|
||||||
|
source: 'template',
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
// 自定义规则重新插回:保持原相对顺序,排在 FINAL 之前
|
||||||
|
if (custom.length > 0) {
|
||||||
|
const finalRow = db.select().from(rules).where(eq(rules.type, 'FINAL')).get();
|
||||||
|
let base = finalRow ? finalRow.seq : seq;
|
||||||
|
// 给自定义行腾出 seq 空间:FINAL 及之后行整体后移
|
||||||
|
for (const c of custom.sort((a, b) => a.seq - b.seq)) {
|
||||||
|
db.insert(rules)
|
||||||
|
.values({ ...c, seq: base++ })
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
if (finalRow) db.update(rules).set({ seq: base }).where(eq(rules.id, finalRow.id)).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seedIfEmpty(db: Db) {
|
||||||
|
const hasGroups = db.select().from(policyGroups).limit(1).all().length > 0;
|
||||||
|
if (hasGroups) return;
|
||||||
|
|
||||||
|
const text = fs.readFileSync(TEMPLATE_PATH, 'utf8');
|
||||||
|
const parsed = parseSurgeConf(text);
|
||||||
|
|
||||||
|
seedGroups(db, parsed.groups, 'template');
|
||||||
|
seedRulesFromTemplate(db, { keepCustom: false });
|
||||||
|
|
||||||
|
for (const [name, content] of Object.entries(parsed.sections)) {
|
||||||
|
db.insert(settings)
|
||||||
|
.values({ key: `surge:${name}`, value: content })
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`seeded from template: ${parsed.groups.length} groups, ${parsed.rules.length} rules, sections: ${Object.keys(parsed.sections).join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
11
server/src/env.ts
Normal file
11
server/src/env.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
export const env = {
|
||||||
|
port: Number(process.env.PORT || 3000),
|
||||||
|
dataDir: path.resolve(serverRoot, process.env.DATA_DIR || './data'),
|
||||||
|
adminToken: process.env.ADMIN_TOKEN || '',
|
||||||
|
serverRoot,
|
||||||
|
};
|
||||||
54
server/src/index.ts
Normal file
54
server/src/index.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { serve } from '@hono/node-server';
|
||||||
|
import { serveStatic } from '@hono/node-server/serve-static';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import { env } from './env.js';
|
||||||
|
import { db } from './db/index.js';
|
||||||
|
import { seedIfEmpty } from './db/seed.js';
|
||||||
|
import { adminAuth } from './middleware/admin-auth.js';
|
||||||
|
import { nodesRoute } from './routes/nodes.js';
|
||||||
|
import { groupsRoute } from './routes/groups.js';
|
||||||
|
import { rulesRoute, rulesetsRoute } from './routes/rules.js';
|
||||||
|
import { tokensRoute } from './routes/tokens.js';
|
||||||
|
import { subRoute } from './routes/sub.js';
|
||||||
|
import { settingsRoute } from './routes/settings.js';
|
||||||
|
|
||||||
|
seedIfEmpty(db);
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get('/api/health', (c) => c.json({ ok: true, name: 'proxy-station' }));
|
||||||
|
|
||||||
|
app.use('/api/*', adminAuth);
|
||||||
|
app.route('/api/nodes', nodesRoute);
|
||||||
|
app.route('/api/groups', groupsRoute);
|
||||||
|
app.route('/api/rules', rulesRoute);
|
||||||
|
app.route('/api/rulesets', rulesetsRoute);
|
||||||
|
app.route('/api/tokens', tokensRoute);
|
||||||
|
app.route('/api/settings', settingsRoute);
|
||||||
|
|
||||||
|
// 公开订阅端点:token 即凭证
|
||||||
|
app.route('/sub', subRoute);
|
||||||
|
|
||||||
|
// 未匹配的接口路径返回 404,不落到下面的 SPA 回退(否则会拿到一页 HTML)
|
||||||
|
app.all('/api/*', (c) => c.json({ error: '接口不存在' }, 404));
|
||||||
|
|
||||||
|
// 生产模式:托管 web 构建产物(web/dist 相对 server 根目录)
|
||||||
|
const webDist = path.resolve(env.serverRoot, '../web/dist');
|
||||||
|
if (fs.existsSync(webDist)) {
|
||||||
|
const relRoot = path.relative(process.cwd(), webDist);
|
||||||
|
app.use('/*', serveStatic({ root: relRoot }));
|
||||||
|
app.get('*', serveStatic({ path: path.join(relRoot, 'index.html') }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听 0.0.0.0:局域网内的手机等设备可直接访问管理界面与订阅
|
||||||
|
serve({ fetch: app.fetch, port: env.port, hostname: '0.0.0.0' }, (info) => {
|
||||||
|
const lan = Object.values(os.networkInterfaces())
|
||||||
|
.flat()
|
||||||
|
.filter((i): i is NonNullable<typeof i> => !!i && i.family === 'IPv4' && !i.internal)
|
||||||
|
.map((i) => i.address);
|
||||||
|
console.log(`proxy-station server listening on http://localhost:${info.port}`);
|
||||||
|
for (const addr of lan) console.log(` http://${addr}:${info.port}`);
|
||||||
|
});
|
||||||
31
server/src/middleware/admin-auth.ts
Normal file
31
server/src/middleware/admin-auth.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { MiddlewareHandler } from 'hono';
|
||||||
|
import { env } from '../env.js';
|
||||||
|
|
||||||
|
/** 本机或 RFC1918 私有网段(局域网) */
|
||||||
|
function isLocalOrPrivate(addr: string): boolean {
|
||||||
|
const ip = addr.replace(/^::ffff:/, '');
|
||||||
|
if (!ip || ip === '127.0.0.1' || ip === '::1') return true;
|
||||||
|
if (/^10\./.test(ip)) return true;
|
||||||
|
if (/^192\.168\./.test(ip)) return true;
|
||||||
|
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /api/* 鉴权:设置了 ADMIN_TOKEN 则要求 Bearer;未设置时仅放行本机与局域网来源
|
||||||
|
*(本机/局域网开发免配置,公网部署必须设 token)。
|
||||||
|
*/
|
||||||
|
export const adminAuth: MiddlewareHandler = async (c, next) => {
|
||||||
|
if (env.adminToken) {
|
||||||
|
const header = c.req.header('Authorization') || '';
|
||||||
|
if (header !== `Bearer ${env.adminToken}`) {
|
||||||
|
return c.json({ error: '未授权' }, 401);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const addr = c.env?.incoming?.socket?.remoteAddress ?? '';
|
||||||
|
if (!isLocalOrPrivate(addr)) {
|
||||||
|
return c.json({ error: '服务未设置 ADMIN_TOKEN,仅允许本机与局域网访问管理接口' }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
};
|
||||||
107
server/src/routes/groups.ts
Normal file
107
server/src/routes/groups.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { asc, eq } from 'drizzle-orm';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { groupInputSchema, type GroupDto } from '@proxy-station/shared';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { groupMembers, policyGroups, rules } from '../db/schema.js';
|
||||||
|
|
||||||
|
function loadGroups(): GroupDto[] {
|
||||||
|
const groups = db.select().from(policyGroups).orderBy(asc(policyGroups.sortOrder)).all();
|
||||||
|
const members = db.select().from(groupMembers).orderBy(asc(groupMembers.sortOrder)).all();
|
||||||
|
return groups.map((g) => ({
|
||||||
|
...g,
|
||||||
|
members: members
|
||||||
|
.filter((m) => m.groupId === g.id)
|
||||||
|
.map((m) => ({ id: m.id, kind: m.memberKind, nodeId: m.nodeId, refName: m.refName, sortOrder: m.sortOrder })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceMembers(groupId: string, members: z.output<typeof groupInputSchema>['members']) {
|
||||||
|
db.delete(groupMembers).where(eq(groupMembers.groupId, groupId)).run();
|
||||||
|
members.forEach((m, i) => {
|
||||||
|
db.insert(groupMembers)
|
||||||
|
.values({
|
||||||
|
groupId,
|
||||||
|
memberKind: m.kind,
|
||||||
|
nodeId: m.kind === 'node' ? (m.nodeId ?? null) : null,
|
||||||
|
refName: m.kind === 'node' ? null : (m.refName ?? null),
|
||||||
|
sortOrder: i,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const groupsRoute = new Hono()
|
||||||
|
.get('/', (c) => c.json(loadGroups()))
|
||||||
|
.post('/', zValidator('json', groupInputSchema), (c) => {
|
||||||
|
const input = c.req.valid('json');
|
||||||
|
const id = nanoid(10);
|
||||||
|
try {
|
||||||
|
db.insert(policyGroups)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
name: input.name,
|
||||||
|
type: input.type,
|
||||||
|
testUrl: input.testUrl ?? null,
|
||||||
|
interval: input.interval ?? null,
|
||||||
|
tolerance: input.tolerance ?? null,
|
||||||
|
filterRegex: input.filterRegex || null,
|
||||||
|
includeAllNodes: input.includeAllNodes,
|
||||||
|
sortOrder: input.sortOrder,
|
||||||
|
source: 'custom',
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (String(e.message).includes('UNIQUE')) return c.json({ error: `策略组「${input.name}」已存在` }, 409);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
replaceMembers(id, input.members);
|
||||||
|
return c.json({ id }, 201);
|
||||||
|
})
|
||||||
|
.put('/reorder', zValidator('json', z.object({ ids: z.array(z.string()) })), (c) => {
|
||||||
|
const { ids } = c.req.valid('json');
|
||||||
|
ids.forEach((id, i) => db.update(policyGroups).set({ sortOrder: i }).where(eq(policyGroups.id, id)).run());
|
||||||
|
return c.json({ ok: true });
|
||||||
|
})
|
||||||
|
.patch('/:id/enabled', zValidator('json', z.object({ enabled: z.boolean() })), (c) => {
|
||||||
|
const res = db
|
||||||
|
.update(policyGroups)
|
||||||
|
.set({ enabled: c.req.valid('json').enabled })
|
||||||
|
.where(eq(policyGroups.id, c.req.param('id')))
|
||||||
|
.run();
|
||||||
|
if (res.changes === 0) return c.json({ error: '策略组不存在' }, 404);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
})
|
||||||
|
.put('/:id', zValidator('json', groupInputSchema), (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const existing = db.select().from(policyGroups).where(eq(policyGroups.id, id)).get();
|
||||||
|
if (!existing) return c.json({ error: '策略组不存在' }, 404);
|
||||||
|
const input = c.req.valid('json');
|
||||||
|
const oldName = existing.name;
|
||||||
|
db.update(policyGroups)
|
||||||
|
.set({
|
||||||
|
name: input.name,
|
||||||
|
type: input.type,
|
||||||
|
testUrl: input.testUrl ?? null,
|
||||||
|
interval: input.interval ?? null,
|
||||||
|
tolerance: input.tolerance ?? null,
|
||||||
|
filterRegex: input.filterRegex || null,
|
||||||
|
includeAllNodes: input.includeAllNodes,
|
||||||
|
})
|
||||||
|
.where(eq(policyGroups.id, id))
|
||||||
|
.run();
|
||||||
|
replaceMembers(id, input.members);
|
||||||
|
// 组名变更时同步引用它的成员与规则策略
|
||||||
|
if (oldName !== input.name) {
|
||||||
|
db.update(groupMembers).set({ refName: input.name }).where(eq(groupMembers.refName, oldName)).run();
|
||||||
|
db.update(rules).set({ policy: input.name }).where(eq(rules.policy, oldName)).run();
|
||||||
|
}
|
||||||
|
return c.json({ ok: true });
|
||||||
|
})
|
||||||
|
.delete('/:id', (c) => {
|
||||||
|
const res = db.delete(policyGroups).where(eq(policyGroups.id, c.req.param('id'))).run();
|
||||||
|
if (res.changes === 0) return c.json({ error: '策略组不存在' }, 404);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
132
server/src/routes/nodes.ts
Normal file
132
server/src/routes/nodes.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { asc, eq } from 'drizzle-orm';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { nodeInputSchema, type NodeDto } from '@proxy-station/shared';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { nodes } from '../db/schema.js';
|
||||||
|
import { encodeShareLink, parseShareLinkBatch } from '../services/sharelink/parse.js';
|
||||||
|
|
||||||
|
type NodeRow = typeof nodes.$inferSelect;
|
||||||
|
|
||||||
|
export function rowToDto(row: NodeRow): NodeDto {
|
||||||
|
return { ...row, tags: JSON.parse(row.tags) } as unknown as NodeDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dtoToRowPatch(input: z.output<typeof nodeInputSchema>) {
|
||||||
|
return {
|
||||||
|
name: input.name,
|
||||||
|
protocol: input.protocol,
|
||||||
|
server: input.server,
|
||||||
|
port: input.port,
|
||||||
|
network: input.network,
|
||||||
|
wsPath: input.wsPath ?? null,
|
||||||
|
wsHost: input.wsHost ?? null,
|
||||||
|
tls: input.tls,
|
||||||
|
sni: input.sni ?? null,
|
||||||
|
skipCertVerify: input.skipCertVerify,
|
||||||
|
uuid: input.uuid ?? null,
|
||||||
|
alterId: input.alterId,
|
||||||
|
security: input.security,
|
||||||
|
password: input.password ?? null,
|
||||||
|
method: input.method ?? null,
|
||||||
|
obfsType: input.obfsType ?? null,
|
||||||
|
obfsPassword: input.obfsPassword ?? null,
|
||||||
|
upMbps: input.upMbps ?? null,
|
||||||
|
downMbps: input.downMbps ?? null,
|
||||||
|
region: input.region ?? null,
|
||||||
|
tags: JSON.stringify(input.tags),
|
||||||
|
enabled: input.enabled,
|
||||||
|
sortOrder: input.sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 名称冲突时自动追加 -2 / -3 … 后缀 */
|
||||||
|
function uniqueName(base: string, taken: Set<string>): string {
|
||||||
|
if (!taken.has(base)) return base;
|
||||||
|
for (let i = 2; ; i++) {
|
||||||
|
const candidate = `${base}-${i}`;
|
||||||
|
if (!taken.has(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nodesRoute = new Hono()
|
||||||
|
.get('/', (c) => {
|
||||||
|
const rows = db.select().from(nodes).orderBy(asc(nodes.sortOrder), asc(nodes.name)).all();
|
||||||
|
return c.json(rows.map(rowToDto));
|
||||||
|
})
|
||||||
|
.post('/', zValidator('json', nodeInputSchema), (c) => {
|
||||||
|
const input = c.req.valid('json');
|
||||||
|
const now = Date.now();
|
||||||
|
const row = { id: nanoid(10), ...dtoToRowPatch(input), extra: '{}', createdAt: now, updatedAt: now };
|
||||||
|
try {
|
||||||
|
db.insert(nodes).values(row).run();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (String(e.message).includes('UNIQUE')) return c.json({ error: `节点名「${input.name}」已存在` }, 409);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return c.json(rowToDto(row as NodeRow), 201);
|
||||||
|
})
|
||||||
|
.post(
|
||||||
|
'/import',
|
||||||
|
zValidator('json', z.object({ text: z.string().min(1), commit: z.boolean().default(false) })),
|
||||||
|
(c) => {
|
||||||
|
const { text, commit } = c.req.valid('json');
|
||||||
|
const parsed = parseShareLinkBatch(text);
|
||||||
|
const taken = new Set(db.select({ name: nodes.name }).from(nodes).all().map((r) => r.name));
|
||||||
|
const now = Date.now();
|
||||||
|
const results = parsed.map((item) => {
|
||||||
|
if (!item.node) return { ok: false as const, uri: item.uri, error: item.error };
|
||||||
|
const name = uniqueName(item.node.name, taken);
|
||||||
|
taken.add(name);
|
||||||
|
const node = { ...item.node, name };
|
||||||
|
if (commit) {
|
||||||
|
const validated = nodeInputSchema.parse(node);
|
||||||
|
const row = { id: nanoid(10), ...dtoToRowPatch(validated), extra: '{}', createdAt: now, updatedAt: now };
|
||||||
|
db.insert(nodes).values(row).run();
|
||||||
|
}
|
||||||
|
return { ok: true as const, uri: item.uri, node };
|
||||||
|
});
|
||||||
|
return c.json({
|
||||||
|
committed: commit,
|
||||||
|
total: results.length,
|
||||||
|
succeeded: results.filter((r) => r.ok).length,
|
||||||
|
items: results,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.get('/:id/uri', (c) => {
|
||||||
|
const row = db.select().from(nodes).where(eq(nodes.id, c.req.param('id'))).get();
|
||||||
|
if (!row) return c.json({ error: '节点不存在' }, 404);
|
||||||
|
const dto = rowToDto(row);
|
||||||
|
return c.json({ uri: encodeShareLink({ ...dto, obfsType: dto.obfsType as 'salamander' | null }) });
|
||||||
|
})
|
||||||
|
.put('/:id', zValidator('json', nodeInputSchema), (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const existing = db.select().from(nodes).where(eq(nodes.id, id)).get();
|
||||||
|
if (!existing) return c.json({ error: '节点不存在' }, 404);
|
||||||
|
const input = c.req.valid('json');
|
||||||
|
try {
|
||||||
|
db.update(nodes)
|
||||||
|
.set({ ...dtoToRowPatch(input), updatedAt: Date.now() })
|
||||||
|
.where(eq(nodes.id, id))
|
||||||
|
.run();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (String(e.message).includes('UNIQUE')) return c.json({ error: `节点名「${input.name}」已存在` }, 409);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return c.json(rowToDto(db.select().from(nodes).where(eq(nodes.id, id)).get()!));
|
||||||
|
})
|
||||||
|
.patch('/:id/enabled', zValidator('json', z.object({ enabled: z.boolean() })), (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const { enabled } = c.req.valid('json');
|
||||||
|
const res = db.update(nodes).set({ enabled, updatedAt: Date.now() }).where(eq(nodes.id, id)).run();
|
||||||
|
if (res.changes === 0) return c.json({ error: '节点不存在' }, 404);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
})
|
||||||
|
.delete('/:id', (c) => {
|
||||||
|
const res = db.delete(nodes).where(eq(nodes.id, c.req.param('id'))).run();
|
||||||
|
if (res.changes === 0) return c.json({ error: '节点不存在' }, 404);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
116
server/src/routes/rules.ts
Normal file
116
server/src/routes/rules.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { asc, eq } from 'drizzle-orm';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ruleInputSchema, type RuleDto } from '@proxy-station/shared';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { rules, rulesets } from '../db/schema.js';
|
||||||
|
import { seedRulesFromTemplate } from '../db/seed.js';
|
||||||
|
|
||||||
|
function loadRules(): RuleDto[] {
|
||||||
|
const rows = db.select().from(rules).orderBy(asc(rules.seq)).all();
|
||||||
|
const sets = new Map(db.select().from(rulesets).all().map((r) => [r.id, r]));
|
||||||
|
return rows.map((r) => {
|
||||||
|
const rs = r.value ? sets.get(r.value) : undefined;
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
params: JSON.parse(r.params),
|
||||||
|
rulesetName: rs?.name ?? null,
|
||||||
|
rulesetUrl: rs?.surgeUrl ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextSeq(): number {
|
||||||
|
const rows = db.select({ seq: rules.seq }).from(rules).all();
|
||||||
|
return rows.length ? Math.max(...rows.map((r) => r.seq)) + 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rulesRoute = new Hono()
|
||||||
|
.get('/', (c) => c.json(loadRules()))
|
||||||
|
.post('/', zValidator('json', ruleInputSchema), (c) => {
|
||||||
|
const input = c.req.valid('json');
|
||||||
|
// 新自定义规则插到 FINAL 之前
|
||||||
|
const finalRow = db.select().from(rules).where(eq(rules.type, 'FINAL')).get();
|
||||||
|
let seq: number;
|
||||||
|
if (finalRow) {
|
||||||
|
seq = finalRow.seq;
|
||||||
|
db.update(rules).set({ seq: finalRow.seq + 1 }).where(eq(rules.id, finalRow.id)).run();
|
||||||
|
} else {
|
||||||
|
seq = nextSeq();
|
||||||
|
}
|
||||||
|
const id = nanoid(10);
|
||||||
|
db.insert(rules)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
seq,
|
||||||
|
type: input.type,
|
||||||
|
value: input.value ?? null,
|
||||||
|
policy: input.policy,
|
||||||
|
params: JSON.stringify(input.params),
|
||||||
|
enabled: input.enabled,
|
||||||
|
source: 'custom',
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return c.json({ id }, 201);
|
||||||
|
})
|
||||||
|
.put('/reorder', zValidator('json', z.object({ ids: z.array(z.string()) })), (c) => {
|
||||||
|
const { ids } = c.req.valid('json');
|
||||||
|
ids.forEach((id, i) => db.update(rules).set({ seq: i }).where(eq(rules.id, id)).run());
|
||||||
|
return c.json({ ok: true });
|
||||||
|
})
|
||||||
|
.post('/reset', (c) => {
|
||||||
|
seedRulesFromTemplate(db, { keepCustom: true });
|
||||||
|
return c.json({ ok: true, rules: loadRules().length });
|
||||||
|
})
|
||||||
|
.put('/:id', zValidator('json', ruleInputSchema), (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const existing = db.select().from(rules).where(eq(rules.id, id)).get();
|
||||||
|
if (!existing) return c.json({ error: '规则不存在' }, 404);
|
||||||
|
const input = c.req.valid('json');
|
||||||
|
db.update(rules)
|
||||||
|
.set({
|
||||||
|
type: input.type,
|
||||||
|
value: input.value ?? null,
|
||||||
|
policy: input.policy,
|
||||||
|
params: JSON.stringify(input.params),
|
||||||
|
enabled: input.enabled,
|
||||||
|
})
|
||||||
|
.where(eq(rules.id, id))
|
||||||
|
.run();
|
||||||
|
return c.json({ ok: true });
|
||||||
|
})
|
||||||
|
.patch('/:id/enabled', zValidator('json', z.object({ enabled: z.boolean() })), (c) => {
|
||||||
|
const res = db
|
||||||
|
.update(rules)
|
||||||
|
.set({ enabled: c.req.valid('json').enabled })
|
||||||
|
.where(eq(rules.id, c.req.param('id')))
|
||||||
|
.run();
|
||||||
|
if (res.changes === 0) return c.json({ error: '规则不存在' }, 404);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
})
|
||||||
|
.delete('/:id', (c) => {
|
||||||
|
const res = db.delete(rules).where(eq(rules.id, c.req.param('id'))).run();
|
||||||
|
if (res.changes === 0) return c.json({ error: '规则不存在' }, 404);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rulesetsRoute = new Hono()
|
||||||
|
.get('/', (c) => {
|
||||||
|
const rows = db.select().from(rulesets).all();
|
||||||
|
return c.json(rows);
|
||||||
|
})
|
||||||
|
.get('/:id/content', async (c) => {
|
||||||
|
const rs = db.select().from(rulesets).where(eq(rulesets.id, c.req.param('id'))).get();
|
||||||
|
if (!rs) return c.json({ error: '规则集不存在' }, 404);
|
||||||
|
const refresh = c.req.query('refresh') === '1';
|
||||||
|
try {
|
||||||
|
const { fetchRulesetContent } = await import('../services/rulesets/fetch.js');
|
||||||
|
const { content, fetchedAt, stale } = await fetchRulesetContent(rs.surgeUrl, { refresh });
|
||||||
|
const lineCount = content.split(/\r?\n/).filter((l) => l.trim() && !l.trim().startsWith('#')).length;
|
||||||
|
return c.json({ content, fetchedAt, stale, lineCount });
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json({ error: `拉取失败:${e.message}` }, 502);
|
||||||
|
}
|
||||||
|
});
|
||||||
22
server/src/routes/settings.ts
Normal file
22
server/src/routes/settings.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { getSetting, setSetting } from '../services/ir.js';
|
||||||
|
|
||||||
|
const SURGE_SECTION_KEYS = ['General', 'Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script'];
|
||||||
|
|
||||||
|
export const settingsRoute = new Hono()
|
||||||
|
.get('/', (c) => {
|
||||||
|
const surgeSections: Record<string, string> = {};
|
||||||
|
for (const key of SURGE_SECTION_KEYS) surgeSections[key] = getSetting(`surge:${key}`);
|
||||||
|
return c.json({ surgeSections });
|
||||||
|
})
|
||||||
|
.put('/', zValidator('json', z.object({ surgeSections: z.record(z.string()).optional() })), (c) => {
|
||||||
|
const input = c.req.valid('json');
|
||||||
|
if (input.surgeSections) {
|
||||||
|
for (const [key, value] of Object.entries(input.surgeSections)) {
|
||||||
|
if (SURGE_SECTION_KEYS.includes(key)) setSetting(`surge:${key}`, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
104
server/src/routes/sub.ts
Normal file
104
server/src/routes/sub.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { rulesets, subTokens } from '../db/schema.js';
|
||||||
|
import { buildProfileIR } from '../services/ir.js';
|
||||||
|
import { fetchRulesetContent } from '../services/rulesets/fetch.js';
|
||||||
|
import { convertSurgeList } from '../services/rulesets/convert.js';
|
||||||
|
import { generateSurge } from '../services/generators/surge.js';
|
||||||
|
import { generateShadowrocket } from '../services/generators/shadowrocket.js';
|
||||||
|
import { generateShadowrocketConf } from '../services/generators/shadowrocket-conf.js';
|
||||||
|
import { SHADOWROCKET_SUB_NAME } from '@proxy-station/shared';
|
||||||
|
import { generateClash } from '../services/generators/clash.js';
|
||||||
|
|
||||||
|
function constantTimeEqual(a: string, b: string): boolean {
|
||||||
|
const ha = crypto.createHash('sha256').update(a).digest();
|
||||||
|
const hb = crypto.createHash('sha256').update(b).digest();
|
||||||
|
return crypto.timingSafeEqual(ha, hb);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findToken(token: string) {
|
||||||
|
const rows = db.select().from(subTokens).where(eq(subTokens.enabled, true)).all();
|
||||||
|
return rows.find((r) => constantTimeEqual(r.token, token)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 该订阅可见的节点范围 */
|
||||||
|
function irFor(row: { nodeIds: string }) {
|
||||||
|
return buildProfileIR({ nodeIds: JSON.parse(row.nodeIds) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function touchToken(id: string, count: number) {
|
||||||
|
db.update(subTokens)
|
||||||
|
.set({ lastAccessAt: Date.now(), accessCount: count + 1 })
|
||||||
|
.where(eq(subTokens.id, id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 订阅 URL 基址:直接取本次请求的 origin——客户端能请求到这里,origin 就一定可达 */
|
||||||
|
function baseUrl(c: { req: { url: string } }): string {
|
||||||
|
return new URL(c.req.url).origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const subRoute = new Hono()
|
||||||
|
.get('/:token/surge', (c) => {
|
||||||
|
const row = findToken(c.req.param('token'));
|
||||||
|
if (!row) return c.text('not found', 404);
|
||||||
|
touchToken(row.id, row.accessCount);
|
||||||
|
const ir = irFor(row);
|
||||||
|
const selfUrl = `${baseUrl(c)}/sub/${row.token}/surge`;
|
||||||
|
const { content } = generateSurge(ir, { selfUrl });
|
||||||
|
c.header('content-type', 'text/plain; charset=utf-8');
|
||||||
|
c.header('content-disposition', 'attachment; filename="Proxy Station.conf"; filename*=UTF-8\'\'Proxy%20Station.conf');
|
||||||
|
return c.body(content);
|
||||||
|
})
|
||||||
|
.get('/:token/shadowrocket', (c) => {
|
||||||
|
const row = findToken(c.req.param('token'));
|
||||||
|
if (!row) return c.text('not found', 404);
|
||||||
|
touchToken(row.id, row.accessCount);
|
||||||
|
const { content } = generateShadowrocket(irFor(row));
|
||||||
|
c.header('content-type', 'text/plain; charset=utf-8');
|
||||||
|
return c.body(content);
|
||||||
|
})
|
||||||
|
// ShadowRocket 的策略组与规则(节点走上面的节点订阅端点)
|
||||||
|
.get('/:token/shadowrocket-conf', (c) => {
|
||||||
|
const row = findToken(c.req.param('token'));
|
||||||
|
if (!row) return c.text('not found', 404);
|
||||||
|
touchToken(row.id, row.accessCount);
|
||||||
|
const { content } = generateShadowrocketConf(irFor(row), {
|
||||||
|
subscriptionName: SHADOWROCKET_SUB_NAME,
|
||||||
|
});
|
||||||
|
c.header('content-type', 'text/plain; charset=utf-8');
|
||||||
|
c.header('content-disposition', 'attachment; filename="Proxy Station Rules.conf"; filename*=UTF-8\'\'Proxy%20Station%20Rules.conf');
|
||||||
|
return c.body(content);
|
||||||
|
})
|
||||||
|
.get('/:token/clash', (c) => {
|
||||||
|
const row = findToken(c.req.param('token'));
|
||||||
|
if (!row) return c.text('not found', 404);
|
||||||
|
touchToken(row.id, row.accessCount);
|
||||||
|
const ir = irFor(row);
|
||||||
|
const convertBaseUrl = `${baseUrl(c)}/sub/${row.token}`;
|
||||||
|
const { content } = generateClash(ir, { convertBaseUrl });
|
||||||
|
c.header('content-type', 'text/yaml; charset=utf-8');
|
||||||
|
c.header('content-disposition', 'attachment; filename="Proxy Station.yaml"; filename*=UTF-8\'\'Proxy%20Station.yaml');
|
||||||
|
c.header('profile-update-interval', '24');
|
||||||
|
return c.body(content);
|
||||||
|
})
|
||||||
|
// Clash 兜底转换端点:无 Clash 等价 URL 的规则集,由本服务拉取 Surge 版并转换
|
||||||
|
.get('/:token/ruleset/:file', async (c) => {
|
||||||
|
const row = findToken(c.req.param('token'));
|
||||||
|
if (!row) return c.text('not found', 404);
|
||||||
|
const id = c.req.param('file').replace(/\.yaml$/, '');
|
||||||
|
const rs = db.select().from(rulesets).where(eq(rulesets.id, id)).get();
|
||||||
|
if (!rs) return c.text('not found', 404);
|
||||||
|
try {
|
||||||
|
const { content, stale } = await fetchRulesetContent(rs.surgeUrl);
|
||||||
|
const result = convertSurgeList(content, rs.clashBehavior as 'classical' | 'domain' | 'ipcidr');
|
||||||
|
c.header('content-type', 'text/yaml; charset=utf-8');
|
||||||
|
c.header('x-proxy-station-converted', `${result.converted}/${result.total}`);
|
||||||
|
if (stale) c.header('x-proxy-station-stale', '1');
|
||||||
|
return c.body(result.yaml);
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.text(`upstream fetch failed: ${e.message}`, 502);
|
||||||
|
}
|
||||||
|
});
|
||||||
78
server/src/routes/tokens.ts
Normal file
78
server/src/routes/tokens.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { asc, eq } from 'drizzle-orm';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { CLIENTS, type TokenDto } from '@proxy-station/shared';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { subTokens } from '../db/schema.js';
|
||||||
|
|
||||||
|
/** 只返回路径,前端用当前页面的 origin 拼完整 URL */
|
||||||
|
function tokenUrls(token: string): TokenDto['urls'] {
|
||||||
|
return {
|
||||||
|
...(Object.fromEntries(CLIENTS.map((c) => [c, `/sub/${token}/${c}`])) as Record<
|
||||||
|
(typeof CLIENTS)[number],
|
||||||
|
string
|
||||||
|
>),
|
||||||
|
shadowrocketConf: `/sub/${token}/shadowrocket-conf`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenRow = typeof subTokens.$inferSelect;
|
||||||
|
|
||||||
|
function toDto(row: TokenRow): TokenDto {
|
||||||
|
return { ...row, nodeIds: JSON.parse(row.nodeIds), urls: tokenUrls(row.token) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenInput = z.object({
|
||||||
|
name: z.string().min(1).max(64),
|
||||||
|
/** 空数组 = 不限制,下发全部启用节点 */
|
||||||
|
nodeIds: z.array(z.string()).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tokensRoute = new Hono()
|
||||||
|
.get('/', (c) => {
|
||||||
|
const rows = db.select().from(subTokens).orderBy(asc(subTokens.createdAt)).all();
|
||||||
|
return c.json(rows.map(toDto));
|
||||||
|
})
|
||||||
|
.post('/', zValidator('json', tokenInput), (c) => {
|
||||||
|
const { name, nodeIds } = c.req.valid('json');
|
||||||
|
const row: TokenRow = {
|
||||||
|
id: nanoid(10),
|
||||||
|
token: crypto.randomBytes(16).toString('hex'),
|
||||||
|
name,
|
||||||
|
nodeIds: JSON.stringify(nodeIds),
|
||||||
|
enabled: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
lastAccessAt: null,
|
||||||
|
accessCount: 0,
|
||||||
|
};
|
||||||
|
db.insert(subTokens).values(row).run();
|
||||||
|
return c.json(toDto(row), 201);
|
||||||
|
})
|
||||||
|
.put('/:id', zValidator('json', tokenInput), (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const { name, nodeIds } = c.req.valid('json');
|
||||||
|
const res = db
|
||||||
|
.update(subTokens)
|
||||||
|
.set({ name, nodeIds: JSON.stringify(nodeIds) })
|
||||||
|
.where(eq(subTokens.id, id))
|
||||||
|
.run();
|
||||||
|
if (res.changes === 0) return c.json({ error: '订阅不存在' }, 404);
|
||||||
|
return c.json(toDto(db.select().from(subTokens).where(eq(subTokens.id, id)).get()!));
|
||||||
|
})
|
||||||
|
.patch('/:id/enabled', zValidator('json', z.object({ enabled: z.boolean() })), (c) => {
|
||||||
|
const res = db
|
||||||
|
.update(subTokens)
|
||||||
|
.set({ enabled: c.req.valid('json').enabled })
|
||||||
|
.where(eq(subTokens.id, c.req.param('id')))
|
||||||
|
.run();
|
||||||
|
if (res.changes === 0) return c.json({ error: '订阅不存在' }, 404);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
})
|
||||||
|
.delete('/:id', (c) => {
|
||||||
|
const res = db.delete(subTokens).where(eq(subTokens.id, c.req.param('id'))).run();
|
||||||
|
if (res.changes === 0) return c.json({ error: '订阅不存在' }, 404);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
192
server/src/services/generators/clash.ts
Normal file
192
server/src/services/generators/clash.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import YAML from 'yaml';
|
||||||
|
import { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
|
||||||
|
import type { ProfileIR, RuleIR } from '../ir.js';
|
||||||
|
|
||||||
|
export function clashProxy(n: NodeDto): Record<string, unknown> {
|
||||||
|
const base: Record<string, unknown> = { name: n.name, server: n.server, port: n.port };
|
||||||
|
switch (n.protocol) {
|
||||||
|
case 'vmess': {
|
||||||
|
Object.assign(base, { type: 'vmess', uuid: n.uuid, alterId: n.alterId, cipher: n.security || 'auto' });
|
||||||
|
if (n.tls) {
|
||||||
|
base.tls = true;
|
||||||
|
if (n.sni) base.servername = n.sni;
|
||||||
|
}
|
||||||
|
if (n.network === 'ws') {
|
||||||
|
base.network = 'ws';
|
||||||
|
base['ws-opts'] = {
|
||||||
|
path: n.wsPath,
|
||||||
|
...(n.wsHost ? { headers: { Host: n.wsHost } } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'trojan': {
|
||||||
|
Object.assign(base, { type: 'trojan', password: n.password, sni: n.sni || n.server });
|
||||||
|
if (n.network === 'ws') {
|
||||||
|
base.network = 'ws';
|
||||||
|
base['ws-opts'] = {
|
||||||
|
path: n.wsPath,
|
||||||
|
...(n.wsHost ? { headers: { Host: n.wsHost } } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (n.skipCertVerify) base['skip-cert-verify'] = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'shadowsocks': {
|
||||||
|
Object.assign(base, { type: 'ss', cipher: normalizeSsCipher(n.method || ''), password: n.password });
|
||||||
|
if (n.network === 'ws') {
|
||||||
|
// SS over WS+TLS 在 Clash 侧唯一可行方案是 v2ray-plugin
|
||||||
|
base.plugin = 'v2ray-plugin';
|
||||||
|
base['plugin-opts'] = {
|
||||||
|
mode: 'websocket',
|
||||||
|
tls: n.tls,
|
||||||
|
host: n.wsHost || n.sni || n.server,
|
||||||
|
path: n.wsPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'hysteria2': {
|
||||||
|
Object.assign(base, { type: 'hysteria2', password: n.password });
|
||||||
|
if (n.sni) base.sni = n.sni;
|
||||||
|
if (n.obfsType === 'salamander' && n.obfsPassword) {
|
||||||
|
base.obfs = 'salamander';
|
||||||
|
base['obfs-password'] = n.obfsPassword;
|
||||||
|
}
|
||||||
|
if (n.upMbps) base.up = `${n.upMbps} Mbps`;
|
||||||
|
if (n.downMbps) base.down = `${n.downMbps} Mbps`;
|
||||||
|
if (n.skipCertVerify) base['skip-cert-verify'] = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Surge 独有策略 → mihomo 等价策略 */
|
||||||
|
function mapPolicy(policy: string): string {
|
||||||
|
if (policy === 'REJECT-NO-DROP' || policy === 'REJECT-TINYGIF') return 'REJECT';
|
||||||
|
return policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Surge 逻辑规则里的子类型 → mihomo 命名 */
|
||||||
|
function mapLogicValue(value: string): string {
|
||||||
|
return value.replace(/\bPROTOCOL\b/g, 'NETWORK');
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLASH_UNSUPPORTED_RULE_TYPES = new Set(['URL-REGEX', 'USER-AGENT']);
|
||||||
|
|
||||||
|
export interface ClashOutput {
|
||||||
|
content: string;
|
||||||
|
excluded: ExcludedNode[];
|
||||||
|
skippedRules: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }): ClashOutput {
|
||||||
|
const excluded: ExcludedNode[] = [];
|
||||||
|
const usable = ir.nodes.filter((n) => {
|
||||||
|
const verdict = clientSupports('clash', n);
|
||||||
|
if (!verdict.ok) excluded.push({ name: n.name, reason: verdict.reason! });
|
||||||
|
return verdict.ok;
|
||||||
|
});
|
||||||
|
const usableNames = new Set(usable.map((n) => n.name));
|
||||||
|
|
||||||
|
const proxies = usable.map(clashProxy);
|
||||||
|
|
||||||
|
const proxyGroups = ir.groups.map((g) => {
|
||||||
|
const group: Record<string, unknown> = { name: g.name, type: g.type };
|
||||||
|
const members = g.members
|
||||||
|
.filter((m) => m.kind !== 'node' || usableNames.has(m.name))
|
||||||
|
.map((m) => m.name);
|
||||||
|
if (g.filterRegex) {
|
||||||
|
group['include-all-proxies'] = true;
|
||||||
|
group.filter = g.filterRegex;
|
||||||
|
} else if (g.includeAllNodes) {
|
||||||
|
group['include-all-proxies'] = true;
|
||||||
|
const extra = members.filter((m) => !usableNames.has(m));
|
||||||
|
if (extra.length) group.proxies = extra;
|
||||||
|
} else {
|
||||||
|
group.proxies = members.length ? members : ['DIRECT'];
|
||||||
|
}
|
||||||
|
if (g.type !== 'select') {
|
||||||
|
group.url = g.testUrl || 'http://cp.cloudflare.com/generate_204';
|
||||||
|
group.interval = g.interval ?? 300;
|
||||||
|
if (g.tolerance) group.tolerance = g.tolerance;
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
});
|
||||||
|
|
||||||
|
// rule-providers:从启用规则引用的规则集生成,名称唯一化
|
||||||
|
const providerNameByRulesetId = new Map<string, string>();
|
||||||
|
const providers: Record<string, unknown> = {};
|
||||||
|
const takenNames = new Set<string>();
|
||||||
|
const usedRulesets = ir.rules.flatMap((r) => (r.ruleset ? [r.ruleset] : []));
|
||||||
|
for (const rs of usedRulesets) {
|
||||||
|
if (providerNameByRulesetId.has(rs.id)) continue;
|
||||||
|
let name = rs.name;
|
||||||
|
for (let i = 2; takenNames.has(name); i++) name = `${rs.name}-${i}`;
|
||||||
|
takenNames.add(name);
|
||||||
|
providerNameByRulesetId.set(rs.id, name);
|
||||||
|
const url = rs.needsConvert || !rs.clashUrl ? `${opts.convertBaseUrl}/ruleset/${rs.id}.yaml` : rs.clashUrl;
|
||||||
|
providers[name] = {
|
||||||
|
type: 'http',
|
||||||
|
url,
|
||||||
|
behavior: rs.clashBehavior,
|
||||||
|
format: rs.needsConvert || !rs.clashUrl ? 'yaml' : rs.clashFormat === 'yaml' ? 'yaml' : 'text',
|
||||||
|
interval: 86400,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const skippedRules: string[] = [];
|
||||||
|
const ruleLines: string[] = [];
|
||||||
|
for (const r of ir.rules) {
|
||||||
|
if (r.type === 'FINAL') {
|
||||||
|
ruleLines.push(`MATCH,${mapPolicy(r.policy)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (CLASH_UNSUPPORTED_RULE_TYPES.has(r.type)) {
|
||||||
|
skippedRules.push(`${r.type},${r.value},${r.policy}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const policy = mapPolicy(r.policy);
|
||||||
|
if (r.ruleset) {
|
||||||
|
ruleLines.push(`RULE-SET,${providerNameByRulesetId.get(r.ruleset.id)},${policy}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Surge 内置规则集(非 URL):LAN → 私网 GEOIP;SYSTEM 无对应物,跳过
|
||||||
|
if (r.type === 'RULE-SET' || r.type === 'DOMAIN-SET') {
|
||||||
|
if (r.value === 'LAN') {
|
||||||
|
ruleLines.push(`GEOIP,private,${policy},no-resolve`);
|
||||||
|
} else {
|
||||||
|
skippedRules.push(`${r.type},${r.value},${r.policy}`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (r.type === 'AND' || r.type === 'OR' || r.type === 'NOT') {
|
||||||
|
ruleLines.push(`${r.type},${mapLogicValue(r.value || '')},${policy}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const params = r.params.filter((p) => p === 'no-resolve');
|
||||||
|
ruleLines.push([r.type, r.value, policy, ...params].join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = {
|
||||||
|
'mixed-port': 7890,
|
||||||
|
'allow-lan': false,
|
||||||
|
mode: 'rule',
|
||||||
|
'log-level': 'info',
|
||||||
|
'unified-delay': true,
|
||||||
|
ipv6: false,
|
||||||
|
dns: {
|
||||||
|
enable: true,
|
||||||
|
listen: '0.0.0.0:1053',
|
||||||
|
'enhanced-mode': 'fake-ip',
|
||||||
|
nameserver: ['https://doh.pub/dns-query', 'https://dns.alidns.com/dns-query'],
|
||||||
|
},
|
||||||
|
proxies,
|
||||||
|
'proxy-groups': proxyGroups,
|
||||||
|
'rule-providers': providers,
|
||||||
|
rules: ruleLines,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { content: YAML.stringify(doc, { lineWidth: 0 }), excluded, skippedRules };
|
||||||
|
}
|
||||||
110
server/src/services/generators/shadowrocket-conf.ts
Normal file
110
server/src/services/generators/shadowrocket-conf.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import type { ProfileIR } from '../ir.js';
|
||||||
|
|
||||||
|
/** 成员名含空格/逗号时加引号 */
|
||||||
|
function quoteName(name: string): string {
|
||||||
|
return /[\s,]/.test(name) ? `"${name}"` : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShadowRocket 的 [General] 只保留它认识的通用键——模板里的 Surge 专有键
|
||||||
|
* (all-hybrid / udp-priority / http-api / compatibility-mode 等)会被过滤掉。
|
||||||
|
*/
|
||||||
|
const SR_GENERAL_KEYS = new Set([
|
||||||
|
'bypass-system',
|
||||||
|
'skip-proxy',
|
||||||
|
'bypass-tun',
|
||||||
|
'dns-server',
|
||||||
|
'fallback-dns-server',
|
||||||
|
'ipv6',
|
||||||
|
'prefer-ipv6',
|
||||||
|
'dns-direct-system',
|
||||||
|
'icmp-auto-reply',
|
||||||
|
'always-real-ip',
|
||||||
|
'hijack-dns',
|
||||||
|
'udp-policy-not-supported-behaviour',
|
||||||
|
'interface-mode',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function shadowrocketGeneral(surgeGeneral: string): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const raw of surgeGeneral.split(/\r?\n/)) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line || line.startsWith('#') || line.startsWith('//')) continue;
|
||||||
|
const key = line.split('=')[0].trim();
|
||||||
|
if (SR_GENERAL_KEYS.has(key)) lines.push(line);
|
||||||
|
}
|
||||||
|
// ShadowRocket 的常用默认值,模板里没有就补上
|
||||||
|
if (!lines.some((l) => l.startsWith('bypass-system'))) lines.push('bypass-system = true');
|
||||||
|
if (!lines.some((l) => l.startsWith('ipv6'))) lines.push('ipv6 = false');
|
||||||
|
if (!lines.some((l) => l.startsWith('dns-server'))) lines.push('dns-server = 223.5.5.5, 119.29.29.29');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ShadowRocket 不认识的规则类型 */
|
||||||
|
const SR_UNSUPPORTED_RULE_TYPES = new Set(['DOMAIN-SET', 'AND', 'OR', 'NOT', 'URL-REGEX']);
|
||||||
|
|
||||||
|
export interface ShadowrocketConfOutput {
|
||||||
|
content: string;
|
||||||
|
skippedRules: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 ShadowRocket 配置文件:策略组 + 规则,不含节点。
|
||||||
|
* 节点由「节点订阅」单独导入,策略组通过 `use=true` 引用该订阅的名称,
|
||||||
|
* 因此用户在 App 里给订阅起的名字必须与 subscriptionName 一致。
|
||||||
|
*/
|
||||||
|
export function generateShadowrocketConf(
|
||||||
|
ir: ProfileIR,
|
||||||
|
opts: { subscriptionName: string }
|
||||||
|
): ShadowrocketConfOutput {
|
||||||
|
const sub = quoteName(opts.subscriptionName);
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
lines.push('# 由 Proxy Station 生成的 ShadowRocket 配置(策略组 + 规则)');
|
||||||
|
lines.push(`# 节点请另行导入「节点订阅」,并在 App 中将其命名为:${opts.subscriptionName}`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('[General]');
|
||||||
|
lines.push(shadowrocketGeneral(ir.sections['General'] ?? ''));
|
||||||
|
|
||||||
|
lines.push('', '[Proxy Group]');
|
||||||
|
for (const g of ir.groups) {
|
||||||
|
const parts: string[] = [g.type];
|
||||||
|
if (g.filterRegex) {
|
||||||
|
// 地区组:引用订阅 + 正则筛选(ShadowRocket 原生支持 policy-regex-filter)
|
||||||
|
parts.push(sub, 'use=true', `policy-regex-filter=${g.filterRegex}`);
|
||||||
|
} else if (g.includeAllNodes) {
|
||||||
|
// 收纳全部节点:引用订阅
|
||||||
|
parts.push(sub, 'use=true');
|
||||||
|
} else {
|
||||||
|
const members = g.members.map((m) => quoteName(m.name));
|
||||||
|
parts.push(...(members.length ? members : ['DIRECT']));
|
||||||
|
}
|
||||||
|
if (g.type !== 'select') {
|
||||||
|
parts.push(`url=${g.testUrl || 'http://cp.cloudflare.com/generate_204'}`);
|
||||||
|
if (g.interval) parts.push(`interval=${g.interval}`);
|
||||||
|
if (g.tolerance) parts.push(`tolerance=${g.tolerance}`);
|
||||||
|
}
|
||||||
|
lines.push(`${g.name} = ${parts.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const skippedRules: string[] = [];
|
||||||
|
lines.push('', '[Rule]');
|
||||||
|
for (const r of ir.rules) {
|
||||||
|
if (r.type === 'FINAL') {
|
||||||
|
lines.push(['FINAL', quoteName(r.policy)].join(','));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (SR_UNSUPPORTED_RULE_TYPES.has(r.type)) {
|
||||||
|
skippedRules.push(`${r.type},${r.ruleset ? r.ruleset.name : r.value},${r.policy}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = r.ruleset ? r.ruleset.surgeUrl : r.value;
|
||||||
|
lines.push([r.type, value, quoteName(r.policy), ...r.params].join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const section of ['Host', 'URL Rewrite', 'MITM']) {
|
||||||
|
if (ir.sections[section]) lines.push('', `[${section}]`, ir.sections[section]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content: lines.join('\n') + '\n', skippedRules };
|
||||||
|
}
|
||||||
27
server/src/services/generators/shadowrocket.ts
Normal file
27
server/src/services/generators/shadowrocket.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { clientSupports, type ExcludedNode } from '@proxy-station/shared';
|
||||||
|
import type { ProfileIR } from '../ir.js';
|
||||||
|
import { encodeShareLink } from '../sharelink/parse.js';
|
||||||
|
|
||||||
|
export interface ShadowrocketOutput {
|
||||||
|
content: string; // base64 后的 URI 列表(ShadowRocket 原生订阅格式)
|
||||||
|
excluded: ExcludedNode[];
|
||||||
|
uris: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateShadowrocket(ir: ProfileIR): ShadowrocketOutput {
|
||||||
|
const excluded: ExcludedNode[] = [];
|
||||||
|
const uris: string[] = [];
|
||||||
|
for (const n of ir.nodes) {
|
||||||
|
const verdict = clientSupports('shadowrocket', n);
|
||||||
|
if (!verdict.ok) {
|
||||||
|
excluded.push({ name: n.name, reason: verdict.reason! });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
uris.push(encodeShareLink({ ...n, obfsType: n.obfsType as 'salamander' | null }));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: Buffer.from(uris.join('\n'), 'utf8').toString('base64'),
|
||||||
|
excluded,
|
||||||
|
uris,
|
||||||
|
};
|
||||||
|
}
|
||||||
124
server/src/services/generators/surge.ts
Normal file
124
server/src/services/generators/surge.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
|
||||||
|
import type { ProfileIR } from '../ir.js';
|
||||||
|
|
||||||
|
/** 成员名含空格/逗号时按 Surge 习惯加引号 */
|
||||||
|
function quoteName(name: string): string {
|
||||||
|
return /[\s,]/.test(name) ? `"${name}"` : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function surgeNodeLine(n: NodeDto): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
switch (n.protocol) {
|
||||||
|
case 'vmess': {
|
||||||
|
parts.push('vmess', n.server, String(n.port), `username=${n.uuid}`);
|
||||||
|
if (n.network === 'ws') {
|
||||||
|
parts.push('ws=true', `ws-path=${n.wsPath}`);
|
||||||
|
if (n.wsHost) parts.push(`ws-headers=Host:"${n.wsHost}"`);
|
||||||
|
}
|
||||||
|
if (n.tls) {
|
||||||
|
parts.push('tls=true');
|
||||||
|
if (n.sni) parts.push(`sni=${n.sni}`);
|
||||||
|
}
|
||||||
|
// alterId=0 的 AEAD 头必须显式打开,否则 Surge 连不上且症状隐晦
|
||||||
|
parts.push('vmess-aead=true');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'trojan': {
|
||||||
|
parts.push('trojan', n.server, String(n.port), `password=${n.password}`);
|
||||||
|
if (n.network === 'ws') {
|
||||||
|
parts.push('ws=true', `ws-path=${n.wsPath}`);
|
||||||
|
if (n.wsHost) parts.push(`ws-headers=Host:"${n.wsHost}"`);
|
||||||
|
}
|
||||||
|
if (n.sni) parts.push(`sni=${n.sni}`);
|
||||||
|
if (n.skipCertVerify) parts.push('skip-cert-verify=true');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'shadowsocks': {
|
||||||
|
parts.push('ss', n.server, String(n.port), `encrypt-method=${normalizeSsCipher(n.method || '')}`, `password=${n.password}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'hysteria2': {
|
||||||
|
parts.push('hysteria2', n.server, String(n.port), `password=${n.password}`);
|
||||||
|
if (n.sni) parts.push(`sni=${n.sni}`);
|
||||||
|
// Surge 的 salamander 混淆参数名(sub-router 生产验证过)
|
||||||
|
if (n.obfsType === 'salamander' && n.obfsPassword) parts.push(`salamander-password=${n.obfsPassword}`);
|
||||||
|
if (n.downMbps) parts.push(`download-bandwidth=${n.downMbps}`);
|
||||||
|
if (n.skipCertVerify) parts.push('skip-cert-verify=true');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${n.name} = ${parts.join(', ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SurgeOutput {
|
||||||
|
content: string;
|
||||||
|
excluded: ExcludedNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOutput {
|
||||||
|
const excluded: ExcludedNode[] = [];
|
||||||
|
const usable = ir.nodes.filter((n) => {
|
||||||
|
const verdict = clientSupports('surge', n);
|
||||||
|
if (!verdict.ok) excluded.push({ name: n.name, reason: verdict.reason! });
|
||||||
|
return verdict.ok;
|
||||||
|
});
|
||||||
|
const usableNames = new Set(usable.map((n) => n.name));
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`#!MANAGED-CONFIG ${opts.selfUrl} interval=86400 strict=true`);
|
||||||
|
lines.push('# 由 Proxy Station 生成,请勿手动编辑(改动会在下次更新时被覆盖)');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('[General]');
|
||||||
|
lines.push(ir.sections['General'] ?? '');
|
||||||
|
|
||||||
|
lines.push('', '[Proxy]');
|
||||||
|
for (const n of usable) lines.push(surgeNodeLine(n));
|
||||||
|
|
||||||
|
lines.push('', '[Proxy Group]');
|
||||||
|
for (const g of ir.groups) {
|
||||||
|
const parts: string[] = [g.type];
|
||||||
|
const memberNames = g.members
|
||||||
|
.filter((m) => m.kind !== 'node' || usableNames.has(m.name))
|
||||||
|
.map((m) => quoteName(m.name));
|
||||||
|
if (g.filterRegex) {
|
||||||
|
// 按名称正则动态筛选:节点来源可以是全部节点,也可以是另一个组
|
||||||
|
if (g.includeAllNodes) {
|
||||||
|
parts.push('include-all-proxies=true');
|
||||||
|
} else {
|
||||||
|
const source = g.members.find((m) => m.kind === 'group');
|
||||||
|
if (source) parts.push(`include-other-group=${quoteName(source.name)}`);
|
||||||
|
}
|
||||||
|
parts.push(`policy-regex-filter=${g.filterRegex}`);
|
||||||
|
} else if (g.includeAllNodes) {
|
||||||
|
// 「全部节点」组:显式展开当前启用且 Surge 支持的节点
|
||||||
|
parts.push(...usable.map((n) => quoteName(n.name)), ...memberNames.filter((m) => !usableNames.has(m)));
|
||||||
|
if (usable.length === 0) parts.push('DIRECT');
|
||||||
|
} else {
|
||||||
|
parts.push(...(memberNames.length ? memberNames : ['DIRECT']));
|
||||||
|
}
|
||||||
|
if (g.type !== 'select') {
|
||||||
|
parts.push(`url=${g.testUrl || 'http://cp.cloudflare.com/generate_204'}`);
|
||||||
|
if (g.interval) parts.push(`interval=${g.interval}`);
|
||||||
|
if (g.tolerance) parts.push(`tolerance=${g.tolerance}`);
|
||||||
|
}
|
||||||
|
lines.push(`${g.name} = ${parts.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('', '[Rule]');
|
||||||
|
for (const r of ir.rules) {
|
||||||
|
if (r.type === 'FINAL') {
|
||||||
|
lines.push(['FINAL', quoteName(r.policy), ...r.params].join(','));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = r.ruleset ? r.ruleset.surgeUrl : r.value;
|
||||||
|
lines.push([r.type, value, quoteName(r.policy), ...r.params].join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const section of ['Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']) {
|
||||||
|
if (ir.sections[section]) {
|
||||||
|
lines.push('', `[${section}]`, ir.sections[section]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content: lines.join('\n') + '\n', excluded };
|
||||||
|
}
|
||||||
131
server/src/services/ir.ts
Normal file
131
server/src/services/ir.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { asc, eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { groupMembers, nodes, policyGroups, rules, rulesets, settings } from '../db/schema.js';
|
||||||
|
import type { NodeDto } from '@proxy-station/shared';
|
||||||
|
import { rowToDto } from '../routes/nodes.js';
|
||||||
|
|
||||||
|
export interface GroupIR {
|
||||||
|
name: string;
|
||||||
|
type: 'select' | 'url-test' | 'fallback';
|
||||||
|
testUrl: string | null;
|
||||||
|
interval: number | null;
|
||||||
|
tolerance: number | null;
|
||||||
|
filterRegex: string | null;
|
||||||
|
includeAllNodes: boolean;
|
||||||
|
/** 有序成员:节点名 / 组名 / DIRECT 等内置策略 */
|
||||||
|
members: { kind: 'node' | 'group' | 'builtin'; name: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleIR {
|
||||||
|
type: string;
|
||||||
|
value: string | null;
|
||||||
|
policy: string;
|
||||||
|
params: string[];
|
||||||
|
ruleset: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
surgeUrl: string;
|
||||||
|
clashUrl: string | null;
|
||||||
|
clashBehavior: string;
|
||||||
|
clashFormat: string;
|
||||||
|
needsConvert: boolean;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileIR {
|
||||||
|
nodes: NodeDto[]; // 仅启用节点,按 sortOrder
|
||||||
|
groups: GroupIR[];
|
||||||
|
rules: RuleIR[]; // 仅启用规则,按 seq
|
||||||
|
sections: Record<string, string>; // Surge 原文段落(General/Host/…)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSetting(key: string): string {
|
||||||
|
const row = db.select().from(settings).where(eq(settings.key, key)).get();
|
||||||
|
return row?.value ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSetting(key: string, value: string) {
|
||||||
|
db.insert(settings)
|
||||||
|
.values({ key, value })
|
||||||
|
.onConflictDoUpdate({ target: settings.key, set: { value } })
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param opts.nodeIds 订阅分配的节点范围;为空或未提供时下发全部启用节点
|
||||||
|
*/
|
||||||
|
export function buildProfileIR(opts: { nodeIds?: string[] } = {}): ProfileIR {
|
||||||
|
const allowed = opts.nodeIds && opts.nodeIds.length > 0 ? new Set(opts.nodeIds) : null;
|
||||||
|
const nodeRows = db
|
||||||
|
.select()
|
||||||
|
.from(nodes)
|
||||||
|
.where(eq(nodes.enabled, true))
|
||||||
|
.orderBy(asc(nodes.sortOrder), asc(nodes.name))
|
||||||
|
.all()
|
||||||
|
.map(rowToDto)
|
||||||
|
.filter((n) => !allowed || allowed.has(n.id));
|
||||||
|
const nodeById = new Map(nodeRows.map((n) => [n.id, n]));
|
||||||
|
|
||||||
|
const memberRows = db.select().from(groupMembers).orderBy(asc(groupMembers.sortOrder)).all();
|
||||||
|
const allGroupRows = db.select().from(policyGroups).orderBy(asc(policyGroups.sortOrder)).all();
|
||||||
|
// 停用的组从输出中剔除;引用它的成员与规则一并跳过,保证产物有效
|
||||||
|
const groupRows = allGroupRows.filter((g) => g.enabled);
|
||||||
|
const liveNames = new Set(groupRows.map((g) => g.name));
|
||||||
|
const BUILTIN_POLICIES = new Set(['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP', 'REJECT-TINYGIF']);
|
||||||
|
/** 组已被删除或停用时,引用它的成员/规则应当跳过 */
|
||||||
|
const isDeadRef = (name: string) => !liveNames.has(name) && !BUILTIN_POLICIES.has(name);
|
||||||
|
const groups: GroupIR[] = groupRows.map((g) => ({
|
||||||
|
name: g.name,
|
||||||
|
type: g.type,
|
||||||
|
testUrl: g.testUrl,
|
||||||
|
interval: g.interval,
|
||||||
|
tolerance: g.tolerance,
|
||||||
|
filterRegex: g.filterRegex,
|
||||||
|
includeAllNodes: g.includeAllNodes,
|
||||||
|
members: memberRows
|
||||||
|
.filter((m) => m.groupId === g.id)
|
||||||
|
.flatMap((m): GroupIR['members'] => {
|
||||||
|
if (m.memberKind === 'node') {
|
||||||
|
const node = m.nodeId ? nodeById.get(m.nodeId) : undefined;
|
||||||
|
return node ? [{ kind: 'node', name: node.name }] : []; // 被禁用/删除的节点成员跳过
|
||||||
|
}
|
||||||
|
if (!m.refName) return [];
|
||||||
|
if (m.memberKind === 'group' && isDeadRef(m.refName)) return [];
|
||||||
|
return [{ kind: m.memberKind, name: m.refName }];
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const setRows = new Map(db.select().from(rulesets).all().map((r) => [r.id, r]));
|
||||||
|
const ruleRows = db
|
||||||
|
.select()
|
||||||
|
.from(rules)
|
||||||
|
.where(eq(rules.enabled, true))
|
||||||
|
.orderBy(asc(rules.seq))
|
||||||
|
.all()
|
||||||
|
.filter((r) => r.type === 'FINAL' || !isDeadRef(r.policy))
|
||||||
|
// FINAL 必须保留:其策略组被停用或删除时兜底 DIRECT
|
||||||
|
.map((r) => (r.type === 'FINAL' && isDeadRef(r.policy) ? { ...r, policy: 'DIRECT' } : r));
|
||||||
|
const ruleIRs: RuleIR[] = ruleRows.map((r) => {
|
||||||
|
const rs = r.value ? setRows.get(r.value) : undefined;
|
||||||
|
return {
|
||||||
|
type: r.type,
|
||||||
|
value: r.value,
|
||||||
|
policy: r.policy,
|
||||||
|
params: JSON.parse(r.params),
|
||||||
|
ruleset: rs ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const sections: Record<string, string> = {};
|
||||||
|
for (const key of ['General', 'Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']) {
|
||||||
|
const v = getSetting(`surge:${key}`);
|
||||||
|
if (v) sections[key] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: nodeRows,
|
||||||
|
groups,
|
||||||
|
rules: ruleIRs,
|
||||||
|
sections,
|
||||||
|
};
|
||||||
|
}
|
||||||
29
server/src/services/rulesets/convert.test.ts
Normal file
29
server/src/services/rulesets/convert.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import YAML from 'yaml';
|
||||||
|
import { convertSurgeList } from './convert.js';
|
||||||
|
|
||||||
|
describe('convertSurgeList', () => {
|
||||||
|
it('classical:保留支持类型与 no-resolve,丢弃 USER-AGENT', () => {
|
||||||
|
const input = [
|
||||||
|
'# 注释',
|
||||||
|
'DOMAIN,example.com',
|
||||||
|
'DOMAIN-SUFFIX,example.org',
|
||||||
|
'IP-CIDR,10.0.0.0/8,no-resolve',
|
||||||
|
'USER-AGENT,Foo*',
|
||||||
|
'URL-REGEX,^http://bad',
|
||||||
|
].join('\n');
|
||||||
|
const result = convertSurgeList(input, 'classical');
|
||||||
|
expect(YAML.parse(result.yaml).payload).toEqual([
|
||||||
|
'DOMAIN,example.com',
|
||||||
|
'DOMAIN-SUFFIX,example.org',
|
||||||
|
'IP-CIDR,10.0.0.0/8,no-resolve',
|
||||||
|
]);
|
||||||
|
expect(result.dropped).toHaveLength(2);
|
||||||
|
expect(result.total).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('domain(domainset):前导点转 +.', () => {
|
||||||
|
const result = convertSurgeList('example.com\n.cdn.example.com', 'domain');
|
||||||
|
expect(YAML.parse(result.yaml).payload).toEqual(['example.com', '+.cdn.example.com']);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
server/src/services/rulesets/convert.ts
Normal file
65
server/src/services/rulesets/convert.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import YAML from 'yaml';
|
||||||
|
|
||||||
|
/** mihomo classical provider 支持的规则类型 */
|
||||||
|
const CLASSICAL_TYPES = new Set([
|
||||||
|
'DOMAIN',
|
||||||
|
'DOMAIN-SUFFIX',
|
||||||
|
'DOMAIN-KEYWORD',
|
||||||
|
'DOMAIN-REGEX',
|
||||||
|
'IP-CIDR',
|
||||||
|
'IP-CIDR6',
|
||||||
|
'IP-ASN',
|
||||||
|
'GEOIP',
|
||||||
|
'PROCESS-NAME',
|
||||||
|
'PROCESS-PATH',
|
||||||
|
'DST-PORT',
|
||||||
|
'SRC-PORT',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface ConvertResult {
|
||||||
|
yaml: string;
|
||||||
|
total: number;
|
||||||
|
converted: number;
|
||||||
|
dropped: string[]; // 被丢弃的行(Clash 不支持的类型)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surge .list/.conf 规则集 → Clash rule-provider yaml。
|
||||||
|
* behavior=domain 时按 domainset 处理(每行一个域名,.former 前缀转 +.)。
|
||||||
|
*/
|
||||||
|
export function convertSurgeList(content: string, behavior: 'classical' | 'domain' | 'ipcidr'): ConvertResult {
|
||||||
|
const lines = content
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l && !l.startsWith('#') && !l.startsWith('//') && !l.startsWith(';'));
|
||||||
|
|
||||||
|
const payload: string[] = [];
|
||||||
|
const dropped: string[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (behavior === 'domain') {
|
||||||
|
// Surge domainset:`example.com` 或 `.example.com`(含子域)→ Clash `example.com` / `+.example.com`
|
||||||
|
payload.push(line.startsWith('.') ? `+${line}` : line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (behavior === 'ipcidr') {
|
||||||
|
payload.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parts = line.split(',').map((p) => p.trim());
|
||||||
|
const type = parts[0];
|
||||||
|
if (!CLASSICAL_TYPES.has(type)) {
|
||||||
|
dropped.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const params = parts.slice(2).filter((p) => p === 'no-resolve');
|
||||||
|
payload.push([type, parts[1], ...params].join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
yaml: YAML.stringify({ payload }, { lineWidth: 0 }),
|
||||||
|
total: lines.length,
|
||||||
|
converted: payload.length,
|
||||||
|
dropped,
|
||||||
|
};
|
||||||
|
}
|
||||||
48
server/src/services/rulesets/fetch.ts
Normal file
48
server/src/services/rulesets/fetch.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { Agent, fetch as undiciFetch } from 'undici';
|
||||||
|
import { db } from '../../db/index.js';
|
||||||
|
import { rulesetCache } from '../../db/schema.js';
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// 规则集拉取一律直连(显式 Agent,隔离环境注入的 HTTPS_PROXY)
|
||||||
|
function dispatcher() {
|
||||||
|
return new Agent();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchResult {
|
||||||
|
content: string;
|
||||||
|
fetchedAt: number;
|
||||||
|
stale: boolean; // 拉取失败时回落陈旧缓存
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRulesetContent(url: string, opts: { refresh?: boolean } = {}): Promise<FetchResult> {
|
||||||
|
const cached = db.select().from(rulesetCache).where(eq(rulesetCache.url, url)).get();
|
||||||
|
const fresh = cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS;
|
||||||
|
if (cached && fresh && !opts.refresh) {
|
||||||
|
return { content: cached.content, fetchedAt: cached.fetchedAt, stale: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { 'user-agent': 'proxy-station/0.1' };
|
||||||
|
if (cached?.etag) headers['if-none-match'] = cached.etag;
|
||||||
|
const res = await undiciFetch(url, { dispatcher: dispatcher(), headers });
|
||||||
|
if (res.status === 304 && cached) {
|
||||||
|
const fetchedAt = Date.now();
|
||||||
|
db.update(rulesetCache).set({ fetchedAt }).where(eq(rulesetCache.url, url)).run();
|
||||||
|
return { content: cached.content, fetchedAt, stale: false };
|
||||||
|
}
|
||||||
|
if (res.status !== 200) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const content = await res.text();
|
||||||
|
const etag = res.headers.get('etag');
|
||||||
|
const fetchedAt = Date.now();
|
||||||
|
db.insert(rulesetCache)
|
||||||
|
.values({ url, content, etag, fetchedAt })
|
||||||
|
.onConflictDoUpdate({ target: rulesetCache.url, set: { content, etag, fetchedAt } })
|
||||||
|
.run();
|
||||||
|
return { content, fetchedAt, stale: false };
|
||||||
|
} catch (e) {
|
||||||
|
if (cached) return { content: cached.content, fetchedAt: cached.fetchedAt, stale: true };
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
server/src/services/rulesets/mapping.ts
Normal file
53
server/src/services/rulesets/mapping.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Surge 规则集 URL → Clash 等价 URL 的静态映射。
|
||||||
|
* 映射不到的返回 needsConvert=true,走自托管兜底转换端点。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RulesetMapping {
|
||||||
|
clashUrl: string | null;
|
||||||
|
clashBehavior: 'classical' | 'domain' | 'ipcidr';
|
||||||
|
clashFormat: 'yaml' | 'text';
|
||||||
|
needsConvert: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapRulesetUrl(surgeUrl: string, ruleType: 'RULE-SET' | 'DOMAIN-SET'): RulesetMapping {
|
||||||
|
// skk.moe:List/{non_ip|ip|domainset}/{name}.conf → Clash/{同路径}/{name}.txt
|
||||||
|
const skk = surgeUrl.match(/^https:\/\/ruleset\.skk\.moe\/List\/(non_ip|ip|domainset)\/([\w-]+)\.conf$/);
|
||||||
|
if (skk) {
|
||||||
|
const [, dir, name] = skk;
|
||||||
|
return {
|
||||||
|
clashUrl: `https://ruleset.skk.moe/Clash/${dir}/${name}.txt`,
|
||||||
|
clashBehavior: dir === 'domainset' ? 'domain' : 'classical',
|
||||||
|
clashFormat: 'text',
|
||||||
|
needsConvert: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// blackmatrix7:rule/Surge/{Name}/{Name}.list → rule/Clash/{Name}/{Name}.yaml
|
||||||
|
const bm7 = surgeUrl.match(
|
||||||
|
/^https:\/\/raw\.githubusercontent\.com\/blackmatrix7\/ios_rule_script\/master\/rule\/Surge\/([\w-]+)\/([\w-]+)\.list$/
|
||||||
|
);
|
||||||
|
if (bm7) {
|
||||||
|
const [, dir, name] = bm7;
|
||||||
|
return {
|
||||||
|
clashUrl: `https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Clash/${dir}/${name}.yaml`,
|
||||||
|
clashBehavior: 'classical',
|
||||||
|
clashFormat: 'yaml',
|
||||||
|
needsConvert: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其余来源(zxfccmm4/Profiles、Semporia/TikTok-Unlock 等):走兜底转换
|
||||||
|
return {
|
||||||
|
clashUrl: null,
|
||||||
|
clashBehavior: ruleType === 'DOMAIN-SET' ? 'domain' : 'classical',
|
||||||
|
clashFormat: 'yaml',
|
||||||
|
needsConvert: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 URL 提取展示名:最后一段文件名去扩展名 */
|
||||||
|
export function rulesetNameFromUrl(url: string): string {
|
||||||
|
const last = url.split('/').pop() || url;
|
||||||
|
return last.replace(/\.(list|conf|txt|yaml)$/, '');
|
||||||
|
}
|
||||||
44
server/src/services/sharelink/hysteria2.ts
Normal file
44
server/src/services/sharelink/hysteria2.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { inferTags, readInsecure } from './util.js';
|
||||||
|
import type { ParsedShareLink } from './types.js';
|
||||||
|
|
||||||
|
// hysteria2://auth@host:port?sni=s&insecure=1&obfs=salamander&obfs-password=x#name
|
||||||
|
// hy2:// 是等价别名
|
||||||
|
export function parseHysteria2(uri: string): ParsedShareLink {
|
||||||
|
const url = new URL(uri);
|
||||||
|
const server = url.hostname;
|
||||||
|
const port = parseInt(url.port || '443', 10);
|
||||||
|
const user = decodeURIComponent(url.username);
|
||||||
|
const pass = decodeURIComponent(url.password);
|
||||||
|
const auth = pass ? `${user}:${pass}` : user;
|
||||||
|
if (!server || !auth) throw new Error('hysteria2 链接缺少 host/auth');
|
||||||
|
const name = decodeURIComponent(url.hash.slice(1)) || 'Hysteria2';
|
||||||
|
const params = url.searchParams;
|
||||||
|
const obfs = params.get('obfs');
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
protocol: 'hysteria2',
|
||||||
|
server,
|
||||||
|
port,
|
||||||
|
network: 'tcp',
|
||||||
|
tls: true,
|
||||||
|
sni: params.get('sni') || null,
|
||||||
|
skipCertVerify: readInsecure(params) || !!(params.get('pinSHA256') || params.get('pinsha256')),
|
||||||
|
password: auth,
|
||||||
|
obfsType: obfs === 'salamander' ? 'salamander' : null,
|
||||||
|
obfsPassword: params.get('obfs-password') || params.get('obfs_password') || null,
|
||||||
|
tags: inferTags(name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeHysteria2(n: ParsedShareLink): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (n.sni) params.set('sni', n.sni);
|
||||||
|
if (n.skipCertVerify) params.set('insecure', '1');
|
||||||
|
if (n.obfsType === 'salamander' && n.obfsPassword) {
|
||||||
|
params.set('obfs', 'salamander');
|
||||||
|
params.set('obfs-password', n.obfsPassword);
|
||||||
|
}
|
||||||
|
const qs = params.toString();
|
||||||
|
return `hysteria2://${encodeURIComponent(n.password || '')}@${n.server}:${n.port}${qs ? `?${qs}` : ''}#${encodeURIComponent(n.name)}`;
|
||||||
|
}
|
||||||
167
server/src/services/sharelink/parse.test.ts
Normal file
167
server/src/services/sharelink/parse.test.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { encodeShareLink, parseShareLink } from './parse.js';
|
||||||
|
|
||||||
|
const PLACEHOLDER_UUID = '00000000-1111-2222-3333-444444444444';
|
||||||
|
const PLACEHOLDER_PASS = 'placeholder-password';
|
||||||
|
|
||||||
|
describe('vmess', () => {
|
||||||
|
const json = {
|
||||||
|
v: '2',
|
||||||
|
ps: 'Edge Node XX-vmess-direct',
|
||||||
|
add: 'cc.xx.example.com',
|
||||||
|
port: '443',
|
||||||
|
id: PLACEHOLDER_UUID,
|
||||||
|
aid: '0',
|
||||||
|
scy: 'auto',
|
||||||
|
net: 'ws',
|
||||||
|
type: 'none',
|
||||||
|
host: 'cc.xx.example.com',
|
||||||
|
path: '/abc123',
|
||||||
|
tls: 'tls',
|
||||||
|
sni: 'cc.xx.example.com',
|
||||||
|
alpn: '',
|
||||||
|
};
|
||||||
|
const uri = 'vmess://' + Buffer.from(JSON.stringify(json)).toString('base64');
|
||||||
|
|
||||||
|
it('解析 edge-nodes 交付格式', () => {
|
||||||
|
const n = parseShareLink(uri);
|
||||||
|
expect(n).toMatchObject({
|
||||||
|
protocol: 'vmess',
|
||||||
|
server: 'cc.xx.example.com',
|
||||||
|
port: 443,
|
||||||
|
network: 'ws',
|
||||||
|
wsPath: '/abc123',
|
||||||
|
wsHost: 'cc.xx.example.com',
|
||||||
|
tls: true,
|
||||||
|
sni: 'cc.xx.example.com',
|
||||||
|
uuid: PLACEHOLDER_UUID,
|
||||||
|
alterId: 0,
|
||||||
|
tags: ['direct'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trip 一致', () => {
|
||||||
|
const n = parseShareLink(uri);
|
||||||
|
expect(parseShareLink(encodeShareLink(n))).toEqual(n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('trojan', () => {
|
||||||
|
const uri = `trojan://${PLACEHOLDER_PASS}@cc.xx.example.com:443?security=tls&type=ws&host=cc.xx.example.com&path=%2F4688c6&sni=cc.xx.example.com#Edge%20Node%20XX-trojan-warp-clean-exit`;
|
||||||
|
|
||||||
|
it('解析 edge-nodes 交付格式', () => {
|
||||||
|
const n = parseShareLink(uri);
|
||||||
|
expect(n).toMatchObject({
|
||||||
|
protocol: 'trojan',
|
||||||
|
server: 'cc.xx.example.com',
|
||||||
|
port: 443,
|
||||||
|
network: 'ws',
|
||||||
|
wsPath: '/4688c6',
|
||||||
|
wsHost: 'cc.xx.example.com',
|
||||||
|
tls: true,
|
||||||
|
password: PLACEHOLDER_PASS,
|
||||||
|
tags: ['warp'],
|
||||||
|
});
|
||||||
|
expect(n.name).toBe('Edge Node XX-trojan-warp-clean-exit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trip 一致', () => {
|
||||||
|
const n = parseShareLink(uri);
|
||||||
|
expect(parseShareLink(encodeShareLink(n))).toEqual(n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shadowsocks', () => {
|
||||||
|
const userinfo = Buffer.from(`chacha20-poly1305:${PLACEHOLDER_PASS}`).toString('base64url');
|
||||||
|
const plugin = encodeURIComponent('v2ray-plugin;tls;mode=websocket;host=cc.xx.example.com;path=/22a3d7');
|
||||||
|
const uri = `ss://${userinfo}@cc.xx.example.com:443?plugin=${plugin}#ss-direct`;
|
||||||
|
|
||||||
|
it('解析 SIP002 + v2ray-plugin(SS over WS+TLS)', () => {
|
||||||
|
const n = parseShareLink(uri);
|
||||||
|
expect(n).toMatchObject({
|
||||||
|
protocol: 'shadowsocks',
|
||||||
|
server: 'cc.xx.example.com',
|
||||||
|
port: 443,
|
||||||
|
network: 'ws',
|
||||||
|
wsPath: '/22a3d7',
|
||||||
|
wsHost: 'cc.xx.example.com',
|
||||||
|
tls: true,
|
||||||
|
method: 'chacha20-poly1305',
|
||||||
|
password: PLACEHOLDER_PASS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trip 一致', () => {
|
||||||
|
const n = parseShareLink(uri);
|
||||||
|
expect(parseShareLink(encodeShareLink(n))).toEqual(n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('解析纯 TCP 的 SIP002', () => {
|
||||||
|
const n = parseShareLink(`ss://${userinfo}@1.2.3.4:8388#plain`);
|
||||||
|
expect(n).toMatchObject({ network: 'tcp', tls: false, server: '1.2.3.4', port: 8388 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('解析 Xray 风格参数(3x-ui 导出格式)', () => {
|
||||||
|
const uri = `ss://${userinfo}@cc.xx.example.com:443?type=ws&security=tls&path=%2F22a3d7&sni=cc.xx.example.com#ss-warp`;
|
||||||
|
expect(parseShareLink(uri)).toMatchObject({
|
||||||
|
network: 'ws',
|
||||||
|
wsPath: '/22a3d7',
|
||||||
|
wsHost: 'cc.xx.example.com',
|
||||||
|
tls: true,
|
||||||
|
sni: 'cc.xx.example.com',
|
||||||
|
method: 'chacha20-poly1305',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hysteria2', () => {
|
||||||
|
const uri = `hysteria2://${PLACEHOLDER_PASS}@hy2.xx.example.com:443?sni=hy2.xx.example.com&obfs=salamander&obfs-password=obfs-placeholder#hy2-direct`;
|
||||||
|
|
||||||
|
it('解析含 salamander obfs 的链接', () => {
|
||||||
|
const n = parseShareLink(uri);
|
||||||
|
expect(n).toMatchObject({
|
||||||
|
protocol: 'hysteria2',
|
||||||
|
server: 'hy2.xx.example.com',
|
||||||
|
port: 443,
|
||||||
|
tls: true,
|
||||||
|
password: PLACEHOLDER_PASS,
|
||||||
|
obfsType: 'salamander',
|
||||||
|
obfsPassword: 'obfs-placeholder',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trip 一致', () => {
|
||||||
|
const n = parseShareLink(uri);
|
||||||
|
expect(parseShareLink(encodeShareLink(n))).toEqual(n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hy2:// 别名可解析', () => {
|
||||||
|
expect(parseShareLink(uri.replace('hysteria2://', 'hy2://')).protocol).toBe('hysteria2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('节点命名规范', () => {
|
||||||
|
// 与 web/src/utils/nodeName.ts 的 TRAILING_EGRESS 保持一致
|
||||||
|
const strip = (name: string) => name.replace(/[\s·・_-]+(direct|warp|直连)\s*$/iu, '').trim();
|
||||||
|
|
||||||
|
it('界面显示名剥掉末尾出口词', () => {
|
||||||
|
expect(strip('🇺🇸 US LA Trojan Warp')).toBe('🇺🇸 US LA Trojan');
|
||||||
|
expect(strip('🇸🇬 SG VMess Direct')).toBe('🇸🇬 SG VMess');
|
||||||
|
expect(strip('🇸🇬 SG · HY2 · 直连')).toBe('🇸🇬 SG · HY2'); // 兼容旧格式
|
||||||
|
expect(strip('无出口词的节点')).toBe('无出口词的节点');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('新命名仍能被地区组正则命中', () => {
|
||||||
|
const sg = /坡|🇸🇬|新加坡|狮城|SG|Singapore/u;
|
||||||
|
const us = /美|🇺🇸|美国|US|States|American/u;
|
||||||
|
expect(sg.test('🇸🇬 SG VMess Direct')).toBe(true);
|
||||||
|
expect(us.test('🇺🇸 US RN HY2 Warp')).toBe(true);
|
||||||
|
expect(sg.test('🇺🇸 US LA Trojan Warp')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('批量与异常', () => {
|
||||||
|
it('未知协议抛错', () => {
|
||||||
|
expect(() => parseShareLink('vless://x@y:1')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
44
server/src/services/sharelink/parse.ts
Normal file
44
server/src/services/sharelink/parse.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { parseVmess, encodeVmess } from './vmess.js';
|
||||||
|
import { parseTrojan, encodeTrojan } from './trojan.js';
|
||||||
|
import { parseSs, encodeSs } from './ss.js';
|
||||||
|
import { parseHysteria2, encodeHysteria2 } from './hysteria2.js';
|
||||||
|
import type { ParsedShareLink } from './types.js';
|
||||||
|
|
||||||
|
export type { ParsedShareLink };
|
||||||
|
|
||||||
|
export function parseShareLink(uri: string): ParsedShareLink {
|
||||||
|
const s = uri.trim();
|
||||||
|
if (s.startsWith('vmess://')) return parseVmess(s);
|
||||||
|
if (s.startsWith('trojan://')) return parseTrojan(s);
|
||||||
|
if (s.startsWith('ss://')) return parseSs(s);
|
||||||
|
if (s.startsWith('hysteria2://') || s.startsWith('hy2://')) return parseHysteria2(s);
|
||||||
|
throw new Error('不支持的链接协议(支持 vmess:// trojan:// ss:// hysteria2://)');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeShareLink(n: ParsedShareLink): string {
|
||||||
|
switch (n.protocol) {
|
||||||
|
case 'vmess':
|
||||||
|
return encodeVmess(n);
|
||||||
|
case 'trojan':
|
||||||
|
return encodeTrojan(n);
|
||||||
|
case 'shadowsocks':
|
||||||
|
return encodeSs(n);
|
||||||
|
case 'hysteria2':
|
||||||
|
return encodeHysteria2(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 多行文本 → 逐行解析(跳过空行与注释行) */
|
||||||
|
export function parseShareLinkBatch(text: string): { uri: string; node?: ParsedShareLink; error?: string }[] {
|
||||||
|
return text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l && !l.startsWith('#') && !l.startsWith('//'))
|
||||||
|
.map((uri) => {
|
||||||
|
try {
|
||||||
|
return { uri, node: parseShareLink(uri) };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { uri, error: e.message || '解析失败' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
102
server/src/services/sharelink/ss.ts
Normal file
102
server/src/services/sharelink/ss.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { b64decode, inferTags, normalizeWsPath } from './util.js';
|
||||||
|
import type { ParsedShareLink } from './types.js';
|
||||||
|
|
||||||
|
// SIP002: ss://base64url(method:password)@host:port?plugin=v2ray-plugin%3Btls%3Bmode%3Dwebsocket%3Bpath%3D%2Fx%3Bhost%3Dh#name
|
||||||
|
// 旧格式: ss://base64(method:password@host:port)#name
|
||||||
|
export function parseSs(uri: string): ParsedShareLink {
|
||||||
|
const url = new URL(uri);
|
||||||
|
let method: string;
|
||||||
|
let password: string;
|
||||||
|
let server: string;
|
||||||
|
let port: number;
|
||||||
|
|
||||||
|
if (url.username) {
|
||||||
|
const decoded = b64decode(decodeURIComponent(url.username));
|
||||||
|
const idx = decoded.indexOf(':');
|
||||||
|
if (idx === -1) throw new Error('ss userinfo 缺少 method:password');
|
||||||
|
method = decoded.slice(0, idx);
|
||||||
|
password = decoded.slice(idx + 1);
|
||||||
|
server = url.hostname;
|
||||||
|
port = parseInt(url.port, 10);
|
||||||
|
} else {
|
||||||
|
// 整体 base64 的旧格式
|
||||||
|
const decoded = b64decode(uri.slice('ss://'.length).split('#')[0]);
|
||||||
|
const at = decoded.lastIndexOf('@');
|
||||||
|
if (at === -1) throw new Error('无法解析 ss 链接');
|
||||||
|
const [m, ...rest] = decoded.slice(0, at).split(':');
|
||||||
|
method = m;
|
||||||
|
password = rest.join(':');
|
||||||
|
const hostPort = decoded.slice(at + 1);
|
||||||
|
const colon = hostPort.lastIndexOf(':');
|
||||||
|
server = hostPort.slice(0, colon);
|
||||||
|
port = parseInt(hostPort.slice(colon + 1), 10);
|
||||||
|
}
|
||||||
|
if (!server || !port || !method) throw new Error('ss 链接缺少 server/port/method');
|
||||||
|
|
||||||
|
const name = decodeURIComponent(url.hash.slice(1)) || 'Shadowsocks';
|
||||||
|
const node: ParsedShareLink = {
|
||||||
|
name,
|
||||||
|
protocol: 'shadowsocks',
|
||||||
|
server,
|
||||||
|
port,
|
||||||
|
network: 'tcp',
|
||||||
|
tls: false,
|
||||||
|
method,
|
||||||
|
password,
|
||||||
|
tags: inferTags(name),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Xray 风格参数(3x-ui 导出即此形式):type=ws&security=tls&path=&host=&sni=
|
||||||
|
const params = url.searchParams;
|
||||||
|
if (params.get('type') === 'ws') {
|
||||||
|
node.network = 'ws';
|
||||||
|
node.wsPath = normalizeWsPath(params.get('path'));
|
||||||
|
node.wsHost = params.get('host') || params.get('sni') || null;
|
||||||
|
}
|
||||||
|
if (params.get('security') === 'tls') {
|
||||||
|
node.tls = true;
|
||||||
|
node.sni = params.get('sni') || node.wsHost || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v2ray-plugin 的 websocket/tls 参数(ShadowRocket、ClashMeta 通用写法)
|
||||||
|
const plugin = params.get('plugin');
|
||||||
|
if (plugin && plugin.includes('v2ray-plugin')) {
|
||||||
|
const opts = new Map(
|
||||||
|
plugin
|
||||||
|
.split(';')
|
||||||
|
.slice(1)
|
||||||
|
.map((kv) => {
|
||||||
|
const eq = kv.indexOf('=');
|
||||||
|
return eq === -1 ? [kv, 'true'] : [kv.slice(0, eq), kv.slice(eq + 1)];
|
||||||
|
}) as [string, string][]
|
||||||
|
);
|
||||||
|
if (opts.get('mode') === 'websocket') {
|
||||||
|
node.network = 'ws';
|
||||||
|
node.wsPath = normalizeWsPath(opts.get('path'));
|
||||||
|
node.wsHost = opts.get('host') || null;
|
||||||
|
}
|
||||||
|
if (opts.has('tls')) {
|
||||||
|
node.tls = true;
|
||||||
|
node.sni = opts.get('host') || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeSs(n: ParsedShareLink): string {
|
||||||
|
const userinfo = Buffer.from(`${n.method}:${n.password}`, 'utf8')
|
||||||
|
.toString('base64')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '');
|
||||||
|
let uri = `ss://${userinfo}@${n.server}:${n.port}`;
|
||||||
|
if (n.network === 'ws') {
|
||||||
|
const parts = ['v2ray-plugin'];
|
||||||
|
if (n.tls) parts.push('tls');
|
||||||
|
parts.push('mode=websocket');
|
||||||
|
if (n.wsHost) parts.push(`host=${n.wsHost}`);
|
||||||
|
if (n.wsPath) parts.push(`path=${n.wsPath}`);
|
||||||
|
uri += `?plugin=${encodeURIComponent(parts.join(';'))}`;
|
||||||
|
}
|
||||||
|
return `${uri}#${encodeURIComponent(n.name)}`;
|
||||||
|
}
|
||||||
41
server/src/services/sharelink/trojan.ts
Normal file
41
server/src/services/sharelink/trojan.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { inferTags, normalizeWsPath, readInsecure } from './util.js';
|
||||||
|
import type { ParsedShareLink } from './types.js';
|
||||||
|
|
||||||
|
// trojan://password@host:port?security=tls&type=ws&host=h&path=%2Fx&sni=s#name
|
||||||
|
export function parseTrojan(uri: string): ParsedShareLink {
|
||||||
|
const url = new URL(uri);
|
||||||
|
const server = url.hostname;
|
||||||
|
const port = parseInt(url.port || '443', 10);
|
||||||
|
const password = decodeURIComponent(url.username);
|
||||||
|
if (!server || !password) throw new Error('trojan 链接缺少 host/password');
|
||||||
|
const name = decodeURIComponent(url.hash.slice(1)) || 'Trojan';
|
||||||
|
const params = url.searchParams;
|
||||||
|
const network = params.get('type') === 'ws' ? 'ws' : 'tcp';
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
protocol: 'trojan',
|
||||||
|
server,
|
||||||
|
port,
|
||||||
|
network,
|
||||||
|
wsPath: network === 'ws' ? normalizeWsPath(params.get('path')) : null,
|
||||||
|
wsHost: network === 'ws' ? params.get('host') : null,
|
||||||
|
tls: true,
|
||||||
|
sni: params.get('sni') || server,
|
||||||
|
skipCertVerify: readInsecure(params),
|
||||||
|
password,
|
||||||
|
tags: inferTags(name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeTrojan(n: ParsedShareLink): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('security', 'tls');
|
||||||
|
if (n.network === 'ws') {
|
||||||
|
params.set('type', 'ws');
|
||||||
|
if (n.wsHost) params.set('host', n.wsHost);
|
||||||
|
if (n.wsPath) params.set('path', n.wsPath);
|
||||||
|
}
|
||||||
|
if (n.sni) params.set('sni', n.sni);
|
||||||
|
return `trojan://${encodeURIComponent(n.password || '')}@${n.server}:${n.port}?${params.toString()}#${encodeURIComponent(n.name)}`;
|
||||||
|
}
|
||||||
23
server/src/services/sharelink/types.ts
Normal file
23
server/src/services/sharelink/types.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Protocol } from '@proxy-station/shared';
|
||||||
|
|
||||||
|
/** 分享链接解析出的节点字段(与 nodeInputSchema 对齐,未落库) */
|
||||||
|
export interface ParsedShareLink {
|
||||||
|
name: string;
|
||||||
|
protocol: Protocol;
|
||||||
|
server: string;
|
||||||
|
port: number;
|
||||||
|
network: 'tcp' | 'ws';
|
||||||
|
wsPath?: string | null;
|
||||||
|
wsHost?: string | null;
|
||||||
|
tls: boolean;
|
||||||
|
sni?: string | null;
|
||||||
|
skipCertVerify?: boolean;
|
||||||
|
uuid?: string | null;
|
||||||
|
alterId?: number;
|
||||||
|
security?: string;
|
||||||
|
password?: string | null;
|
||||||
|
method?: string | null;
|
||||||
|
obfsType?: 'salamander' | null;
|
||||||
|
obfsPassword?: string | null;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
27
server/src/services/sharelink/util.ts
Normal file
27
server/src/services/sharelink/util.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export function b64decode(s: string): string {
|
||||||
|
return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function b64encode(s: string): string {
|
||||||
|
return Buffer.from(s, 'utf8').toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WS path 统一保证以 / 开头 */
|
||||||
|
export function normalizeWsPath(p: string | null | undefined): string | null {
|
||||||
|
if (!p) return null;
|
||||||
|
return p.startsWith('/') ? p : `/${p}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readInsecure(params: URLSearchParams): boolean {
|
||||||
|
const v = params.get('insecure') || params.get('allowInsecure') || params.get('skip-cert-verify');
|
||||||
|
return v === '1' || v === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从节点名推断出口 tag(warp/direct),供前端线路矩阵归位 */
|
||||||
|
export function inferTags(name: string): string[] {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
const tags: string[] = [];
|
||||||
|
if (lower.includes('warp')) tags.push('warp');
|
||||||
|
else if (lower.includes('direct')) tags.push('direct');
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
50
server/src/services/sharelink/vmess.ts
Normal file
50
server/src/services/sharelink/vmess.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { b64decode, inferTags, normalizeWsPath } from './util.js';
|
||||||
|
import type { ParsedShareLink } from './types.js';
|
||||||
|
|
||||||
|
// vmess://base64({"v":"2","ps":name,"add":host,"port":"443","id":uuid,"aid":"0",
|
||||||
|
// "scy":"auto","net":"ws","type":"none","host":wsHost,"path":"/x","tls":"tls","sni":sni})
|
||||||
|
export function parseVmess(uri: string): ParsedShareLink {
|
||||||
|
const json = JSON.parse(b64decode(uri.slice('vmess://'.length)));
|
||||||
|
const name = json.ps || 'VMess';
|
||||||
|
const server = String(json.add || '');
|
||||||
|
const port = parseInt(String(json.port || '0'), 10);
|
||||||
|
const uuid = String(json.id || '');
|
||||||
|
if (!server || !port || !uuid) throw new Error('vmess 链接缺少 add/port/id');
|
||||||
|
|
||||||
|
const network = json.net === 'ws' ? 'ws' : 'tcp';
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
protocol: 'vmess',
|
||||||
|
server,
|
||||||
|
port,
|
||||||
|
network,
|
||||||
|
wsPath: network === 'ws' ? normalizeWsPath(json.path) : null,
|
||||||
|
wsHost: network === 'ws' ? json.host || null : null,
|
||||||
|
tls: json.tls === 'tls',
|
||||||
|
sni: json.sni || null,
|
||||||
|
uuid,
|
||||||
|
alterId: parseInt(String(json.aid ?? '0'), 10) || 0,
|
||||||
|
security: json.scy || 'auto',
|
||||||
|
tags: inferTags(name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeVmess(n: ParsedShareLink): string {
|
||||||
|
const json: Record<string, string> = {
|
||||||
|
v: '2',
|
||||||
|
ps: n.name,
|
||||||
|
add: n.server,
|
||||||
|
port: String(n.port),
|
||||||
|
id: n.uuid || '',
|
||||||
|
aid: String(n.alterId ?? 0),
|
||||||
|
scy: n.security || 'auto',
|
||||||
|
net: n.network === 'ws' ? 'ws' : 'tcp',
|
||||||
|
type: 'none',
|
||||||
|
host: n.wsHost || '',
|
||||||
|
path: n.wsPath || '',
|
||||||
|
tls: n.tls ? 'tls' : '',
|
||||||
|
sni: n.sni || '',
|
||||||
|
alpn: '',
|
||||||
|
};
|
||||||
|
return 'vmess://' + Buffer.from(JSON.stringify(json)).toString('base64');
|
||||||
|
}
|
||||||
55
server/src/services/template/surge-parser.test.ts
Normal file
55
server/src/services/template/surge-parser.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseSurgeConf, splitTopLevel } from './surge-parser.js';
|
||||||
|
|
||||||
|
describe('splitTopLevel', () => {
|
||||||
|
it('引号内逗号不切分', () => {
|
||||||
|
expect(splitTopLevel('select, "Hong Kong", Taiwan')).toEqual(['select', '"Hong Kong"', 'Taiwan']);
|
||||||
|
});
|
||||||
|
it('括号内逗号不切分(AND 复合规则)', () => {
|
||||||
|
expect(splitTopLevel('AND,((PROTOCOL,UDP), (DOMAIN-SUFFIX,googlevideo.com)),REJECT-NO-DROP')).toEqual([
|
||||||
|
'AND',
|
||||||
|
'((PROTOCOL,UDP), (DOMAIN-SUFFIX,googlevideo.com))',
|
||||||
|
'REJECT-NO-DROP',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseSurgeConf', () => {
|
||||||
|
const conf = `
|
||||||
|
[General]
|
||||||
|
ipv6 = false
|
||||||
|
|
||||||
|
[Proxy Group]
|
||||||
|
Auto = url-test, include-other-group=All, policy-regex-filter=SG|Singapore, url=http://x/gen, interval=300, tolerance=50, icon-url=http://icon
|
||||||
|
Pick = select, Auto, "Hong Kong", DIRECT
|
||||||
|
All = select, policy-path="placeholder", hidden=0
|
||||||
|
|
||||||
|
[Rule]
|
||||||
|
RULE-SET,https://example.com/a.list,Pick
|
||||||
|
DOMAIN-SUFFIX,example.com,"Hong Kong",no-resolve
|
||||||
|
FINAL,Pick,dns-failed
|
||||||
|
|
||||||
|
[MITM]
|
||||||
|
hostname = *.example.com
|
||||||
|
`;
|
||||||
|
const parsed = parseSurgeConf(conf);
|
||||||
|
|
||||||
|
it('组解析:类型、成员、regex、include-other-group', () => {
|
||||||
|
const auto = parsed.groups.find((g) => g.name === 'Auto')!;
|
||||||
|
expect(auto).toMatchObject({ type: 'url-test', filterRegex: 'SG|Singapore', includeOtherGroup: 'All', interval: 300, tolerance: 50 });
|
||||||
|
const pick = parsed.groups.find((g) => g.name === 'Pick')!;
|
||||||
|
expect(pick.members).toEqual(['Auto', 'Hong Kong', 'DIRECT']);
|
||||||
|
expect(parsed.groups.find((g) => g.name === 'All')!.hasPolicyPath).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('规则解析:RULE-SET、带引号策略、FINAL 参数', () => {
|
||||||
|
expect(parsed.rules[0]).toMatchObject({ type: 'RULE-SET', value: 'https://example.com/a.list', policy: 'Pick' });
|
||||||
|
expect(parsed.rules[1]).toMatchObject({ type: 'DOMAIN-SUFFIX', policy: 'Hong Kong', params: ['no-resolve'] });
|
||||||
|
expect(parsed.rules[2]).toMatchObject({ type: 'FINAL', policy: 'Pick', params: ['dns-failed'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('其他段落原文保留', () => {
|
||||||
|
expect(parsed.sections['MITM']).toBe('hostname = *.example.com');
|
||||||
|
expect(parsed.sections['General']).toBe('ipv6 = false');
|
||||||
|
});
|
||||||
|
});
|
||||||
159
server/src/services/template/surge-parser.ts
Normal file
159
server/src/services/template/surge-parser.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* 解析 Surge .conf 模板:分段原文 + [Proxy Group] / [Rule] 的结构化结果。
|
||||||
|
* 只解析本项目建模所需的字段,未识别的组参数原样忽略。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ParsedGroup {
|
||||||
|
name: string;
|
||||||
|
type: 'select' | 'url-test' | 'fallback';
|
||||||
|
members: string[]; // 组名或 DIRECT/REJECT,顺序保留
|
||||||
|
testUrl?: string;
|
||||||
|
interval?: number;
|
||||||
|
tolerance?: number;
|
||||||
|
filterRegex?: string;
|
||||||
|
includeOtherGroup?: string;
|
||||||
|
includeAllProxies?: boolean;
|
||||||
|
hasPolicyPath?: boolean; // 上游订阅占位(AllServer)→ 我们的「全部节点」组
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedRule {
|
||||||
|
type: string;
|
||||||
|
value: string | null;
|
||||||
|
policy: string;
|
||||||
|
params: string[]; // 如 no-resolve / dns-failed / extended-matching
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedSurgeConf {
|
||||||
|
sections: Record<string, string>; // 段名(不含中括号)→ 原文
|
||||||
|
groups: ParsedGroup[];
|
||||||
|
rules: ParsedRule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按逗号切分,但忽略引号与括号内部的逗号 */
|
||||||
|
export function splitTopLevel(line: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
let cur = '';
|
||||||
|
let depth = 0;
|
||||||
|
let quote: string | null = null;
|
||||||
|
for (const ch of line) {
|
||||||
|
if (quote) {
|
||||||
|
cur += ch;
|
||||||
|
if (ch === quote) quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"' || ch === "'") {
|
||||||
|
quote = ch;
|
||||||
|
cur += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '(') depth++;
|
||||||
|
if (ch === ')') depth--;
|
||||||
|
if (ch === ',' && depth === 0) {
|
||||||
|
out.push(cur.trim());
|
||||||
|
cur = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cur += ch;
|
||||||
|
}
|
||||||
|
if (cur.trim()) out.push(cur.trim());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unquote(s: string): string {
|
||||||
|
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||||
|
return s.slice(1, -1);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWN_GROUP_TYPES = new Set(['select', 'url-test', 'fallback']);
|
||||||
|
/** 这些参数不建模,序列化 Surge 时也不透传(icon 等纯装饰) */
|
||||||
|
const RULE_PARAM_WHITELIST = new Set(['no-resolve', 'dns-failed', 'extended-matching', 'force-remote-dns']);
|
||||||
|
|
||||||
|
export function parseSurgeConf(text: string): ParsedSurgeConf {
|
||||||
|
const sections: Record<string, string[]> = {};
|
||||||
|
let current = '';
|
||||||
|
for (const raw of text.split(/\r?\n/)) {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
const m = trimmed.match(/^\[(.+)\]$/);
|
||||||
|
if (m) {
|
||||||
|
current = m[1];
|
||||||
|
sections[current] = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current) sections[current].push(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: ParsedGroup[] = [];
|
||||||
|
for (const raw of sections['Proxy Group'] ?? []) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line || line.startsWith('#') || line.startsWith('//')) continue;
|
||||||
|
const eq = line.indexOf('=');
|
||||||
|
if (eq === -1) continue;
|
||||||
|
const name = line.slice(0, eq).trim();
|
||||||
|
const parts = splitTopLevel(line.slice(eq + 1));
|
||||||
|
const type = parts[0];
|
||||||
|
if (!KNOWN_GROUP_TYPES.has(type)) continue;
|
||||||
|
|
||||||
|
const group: ParsedGroup = { name, type: type as ParsedGroup['type'], members: [] };
|
||||||
|
for (const part of parts.slice(1)) {
|
||||||
|
const kvEq = part.indexOf('=');
|
||||||
|
// 无 = 的项是成员(可能带引号)
|
||||||
|
if (kvEq === -1 || part.startsWith('"') || part.startsWith("'")) {
|
||||||
|
group.members.push(unquote(part));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = part.slice(0, kvEq).trim();
|
||||||
|
const value = unquote(part.slice(kvEq + 1).trim());
|
||||||
|
switch (key) {
|
||||||
|
case 'url':
|
||||||
|
group.testUrl = value;
|
||||||
|
break;
|
||||||
|
case 'interval':
|
||||||
|
group.interval = parseInt(value, 10) || undefined;
|
||||||
|
break;
|
||||||
|
case 'tolerance':
|
||||||
|
group.tolerance = parseInt(value, 10) || undefined;
|
||||||
|
break;
|
||||||
|
case 'policy-regex-filter':
|
||||||
|
group.filterRegex = value;
|
||||||
|
break;
|
||||||
|
case 'include-other-group':
|
||||||
|
group.includeOtherGroup = value;
|
||||||
|
break;
|
||||||
|
case 'include-all-proxies':
|
||||||
|
group.includeAllProxies = value === '1' || value === 'true';
|
||||||
|
break;
|
||||||
|
case 'policy-path':
|
||||||
|
group.hasPolicyPath = true;
|
||||||
|
break;
|
||||||
|
// no-alert / hidden / icon-url / update-interval / timeout 等不建模
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups.push(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules: ParsedRule[] = [];
|
||||||
|
for (const raw of sections['Rule'] ?? []) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line || line.startsWith('#') || line.startsWith('//')) continue;
|
||||||
|
const parts = splitTopLevel(line);
|
||||||
|
const type = parts[0];
|
||||||
|
if (!type) continue;
|
||||||
|
if (type === 'FINAL') {
|
||||||
|
rules.push({ type, value: null, policy: unquote(parts[1] ?? 'DIRECT'), params: parts.slice(2) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
const params = parts.slice(3).filter((p) => RULE_PARAM_WHITELIST.has(p));
|
||||||
|
rules.push({ type, value: parts[1], policy: unquote(parts[2]), params });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawSections: Record<string, string> = {};
|
||||||
|
for (const [name, lines] of Object.entries(sections)) {
|
||||||
|
if (name === 'Proxy' || name === 'Proxy Group' || name === 'Rule') continue;
|
||||||
|
rawSections[name] = lines.join('\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sections: rawSections, groups, rules };
|
||||||
|
}
|
||||||
14
server/tsconfig.json
Normal file
14
server/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
21
shared/package.json
Normal file
21
shared/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@proxy-station/shared",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --noEmit",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
119
shared/src/dto.ts
Normal file
119
shared/src/dto.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import type { ClientType, Egress, Protocol } from './protocols.js';
|
||||||
|
|
||||||
|
export interface NodeDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
protocol: Protocol;
|
||||||
|
server: string;
|
||||||
|
port: number;
|
||||||
|
network: 'tcp' | 'ws';
|
||||||
|
wsPath: string | null;
|
||||||
|
wsHost: string | null;
|
||||||
|
tls: boolean;
|
||||||
|
sni: string | null;
|
||||||
|
skipCertVerify: boolean;
|
||||||
|
uuid: string | null;
|
||||||
|
alterId: number;
|
||||||
|
security: string;
|
||||||
|
password: string | null;
|
||||||
|
method: string | null;
|
||||||
|
obfsType: string | null;
|
||||||
|
obfsPassword: string | null;
|
||||||
|
upMbps: number | null;
|
||||||
|
downMbps: number | null;
|
||||||
|
region: string | null;
|
||||||
|
tags: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从节点 tags/名称推断的出口类型,仅前端展示用 */
|
||||||
|
export function nodeEgress(node: Pick<NodeDto, 'tags' | 'name'>): Egress {
|
||||||
|
const haystack = [...node.tags, node.name].join(' ').toLowerCase();
|
||||||
|
if (haystack.includes('warp')) return 'warp';
|
||||||
|
if (haystack.includes('direct')) return 'direct';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportPreviewItem {
|
||||||
|
ok: boolean;
|
||||||
|
uri: string;
|
||||||
|
error?: string;
|
||||||
|
node?: Partial<NodeDto> & { name: string; protocol: Protocol };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupMemberDto {
|
||||||
|
id: number;
|
||||||
|
kind: 'node' | 'group' | 'builtin';
|
||||||
|
nodeId: string | null;
|
||||||
|
refName: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'select' | 'url-test' | 'fallback';
|
||||||
|
testUrl: string | null;
|
||||||
|
interval: number | null;
|
||||||
|
tolerance: number | null;
|
||||||
|
filterRegex: string | null;
|
||||||
|
includeAllNodes: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
source: 'template' | 'custom';
|
||||||
|
members: GroupMemberDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleDto {
|
||||||
|
id: string;
|
||||||
|
seq: number;
|
||||||
|
type: string;
|
||||||
|
value: string | null;
|
||||||
|
policy: string;
|
||||||
|
params: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
source: 'template' | 'custom';
|
||||||
|
rulesetName?: string | null;
|
||||||
|
rulesetUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RulesetDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
surgeUrl: string;
|
||||||
|
clashUrl: string | null;
|
||||||
|
clashBehavior: string;
|
||||||
|
needsConvert: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenDto {
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
name: string;
|
||||||
|
/** 分配给该订阅的节点 id;空数组 = 全部节点 */
|
||||||
|
nodeIds: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
lastAccessAt: number | null;
|
||||||
|
accessCount: number;
|
||||||
|
/** 各客户端订阅路径;ShadowRocket 额外有 shadowrocketConf(策略组与规则) */
|
||||||
|
urls: Record<ClientType, string> & { shadowrocketConf: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExcludedNode {
|
||||||
|
name: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewResult {
|
||||||
|
client: ClientType;
|
||||||
|
content: string;
|
||||||
|
excluded: ExcludedNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsDto {
|
||||||
|
surgeSections: Record<string, string>;
|
||||||
|
}
|
||||||
3
shared/src/index.ts
Normal file
3
shared/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './protocols.js';
|
||||||
|
export * from './schemas.js';
|
||||||
|
export * from './dto.js';
|
||||||
50
shared/src/protocols.ts
Normal file
50
shared/src/protocols.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
export const PROTOCOLS = ['vmess', 'trojan', 'shadowsocks', 'hysteria2'] as const;
|
||||||
|
export type Protocol = (typeof PROTOCOLS)[number];
|
||||||
|
|
||||||
|
export const CLIENTS = ['surge', 'shadowrocket', 'clash'] as const;
|
||||||
|
export type ClientType = (typeof CLIENTS)[number];
|
||||||
|
|
||||||
|
export const CLIENT_LABELS: Record<ClientType, string> = {
|
||||||
|
surge: 'Surge (macOS)',
|
||||||
|
shadowrocket: 'ShadowRocket (iOS)',
|
||||||
|
clash: 'ClashMeta (Android)',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShadowRocket 分两步导入:节点走订阅、策略组与规则走配置文件。
|
||||||
|
* 配置文件里的策略组用 `use=true` 引用订阅,因此 App 内的订阅名必须与此一致。
|
||||||
|
*/
|
||||||
|
export const SHADOWROCKET_SUB_NAME = 'Proxy Station';
|
||||||
|
|
||||||
|
export type Egress = 'direct' | 'warp' | 'unknown';
|
||||||
|
|
||||||
|
/** Surge 与 Clash 使用 IETF 命名,Xray 分享链接使用简名 */
|
||||||
|
export const SS_CIPHER_ALIASES: Record<string, string> = {
|
||||||
|
'chacha20-poly1305': 'chacha20-ietf-poly1305',
|
||||||
|
'xchacha20-poly1305': 'xchacha20-ietf-poly1305',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeSsCipher(method: string): string {
|
||||||
|
return SS_CIPHER_ALIASES[method] ?? method;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeCapabilityInput {
|
||||||
|
protocol: Protocol;
|
||||||
|
network: 'tcp' | 'ws';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapabilityVerdict {
|
||||||
|
ok: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端能力矩阵:不支持的组合在下发订阅时自动屏蔽。
|
||||||
|
* Surge 的 ss 类型没有 WebSocket 传输(仅 simple-obfs),SS over WS 线路必须排除。
|
||||||
|
*/
|
||||||
|
export function clientSupports(client: ClientType, node: NodeCapabilityInput): CapabilityVerdict {
|
||||||
|
if (client === 'surge' && node.protocol === 'shadowsocks' && node.network === 'ws') {
|
||||||
|
return { ok: false, reason: 'Surge 不支持 Shadowsocks over WebSocket' };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
99
shared/src/schemas.ts
Normal file
99
shared/src/schemas.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { PROTOCOLS } from './protocols.js';
|
||||||
|
|
||||||
|
export const nodeInputSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1).max(64),
|
||||||
|
protocol: z.enum(PROTOCOLS),
|
||||||
|
server: z.string().min(1),
|
||||||
|
port: z.number().int().min(1).max(65535),
|
||||||
|
network: z.enum(['tcp', 'ws']).default('tcp'),
|
||||||
|
wsPath: z.string().startsWith('/').optional().nullable(),
|
||||||
|
wsHost: z.string().optional().nullable(),
|
||||||
|
tls: z.boolean().default(false),
|
||||||
|
sni: z.string().optional().nullable(),
|
||||||
|
skipCertVerify: z.boolean().default(false),
|
||||||
|
uuid: z.string().uuid().optional().nullable(),
|
||||||
|
alterId: z.number().int().min(0).default(0),
|
||||||
|
security: z.string().default('auto'),
|
||||||
|
password: z.string().optional().nullable(),
|
||||||
|
method: z.string().optional().nullable(),
|
||||||
|
obfsType: z.enum(['salamander']).optional().nullable(),
|
||||||
|
obfsPassword: z.string().optional().nullable(),
|
||||||
|
upMbps: z.number().int().positive().optional().nullable(),
|
||||||
|
downMbps: z.number().int().positive().optional().nullable(),
|
||||||
|
region: z.string().max(8).optional().nullable(),
|
||||||
|
tags: z.array(z.string()).default([]),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
sortOrder: z.number().int().default(0),
|
||||||
|
})
|
||||||
|
.superRefine((v, ctx) => {
|
||||||
|
if (v.protocol === 'vmess' && !v.uuid) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['uuid'], message: 'VMess 需要 UUID' });
|
||||||
|
}
|
||||||
|
if ((v.protocol === 'trojan' || v.protocol === 'shadowsocks' || v.protocol === 'hysteria2') && !v.password) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['password'], message: '该协议需要密码' });
|
||||||
|
}
|
||||||
|
if (v.protocol === 'shadowsocks' && !v.method) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['method'], message: 'Shadowsocks 需要加密方式' });
|
||||||
|
}
|
||||||
|
if (v.network === 'ws' && !v.wsPath) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['wsPath'], message: 'WebSocket 传输需要路径' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type NodeInput = z.input<typeof nodeInputSchema>;
|
||||||
|
export type NodeParsed = z.output<typeof nodeInputSchema>;
|
||||||
|
|
||||||
|
export const groupInputSchema = z.object({
|
||||||
|
name: z.string().min(1).max(64),
|
||||||
|
type: z.enum(['select', 'url-test', 'fallback']),
|
||||||
|
testUrl: z.string().url().optional().nullable(),
|
||||||
|
interval: z.number().int().positive().optional().nullable(),
|
||||||
|
tolerance: z.number().int().positive().optional().nullable(),
|
||||||
|
filterRegex: z.string().optional().nullable(),
|
||||||
|
includeAllNodes: z.boolean().default(false),
|
||||||
|
sortOrder: z.number().int().default(0),
|
||||||
|
members: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
kind: z.enum(['node', 'group', 'builtin']),
|
||||||
|
nodeId: z.string().optional().nullable(),
|
||||||
|
refName: z.string().optional().nullable(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.default([]),
|
||||||
|
});
|
||||||
|
export type GroupInput = z.input<typeof groupInputSchema>;
|
||||||
|
|
||||||
|
export const RULE_TYPES = [
|
||||||
|
'RULE-SET',
|
||||||
|
'DOMAIN-SET',
|
||||||
|
'DOMAIN',
|
||||||
|
'DOMAIN-SUFFIX',
|
||||||
|
'DOMAIN-KEYWORD',
|
||||||
|
'IP-CIDR',
|
||||||
|
'IP-CIDR6',
|
||||||
|
'IP-ASN',
|
||||||
|
'GEOIP',
|
||||||
|
'PROCESS-NAME',
|
||||||
|
'USER-AGENT',
|
||||||
|
'URL-REGEX',
|
||||||
|
'AND',
|
||||||
|
'OR',
|
||||||
|
'NOT',
|
||||||
|
'FINAL',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const ruleInputSchema = z.object({
|
||||||
|
type: z.enum(RULE_TYPES),
|
||||||
|
value: z.string().optional().nullable(),
|
||||||
|
policy: z.string().min(1),
|
||||||
|
params: z.array(z.string()).default([]),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
export type RuleInput = z.input<typeof ruleInputSchema>;
|
||||||
|
|
||||||
|
export const tokenInputSchema = z.object({
|
||||||
|
name: z.string().min(1).max(64),
|
||||||
|
});
|
||||||
11
shared/tsconfig.json
Normal file
11
shared/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
12
web/index.html
Normal file
12
web/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Proxy Station</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
web/package.json
Normal file
25
web/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@proxy-station/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"typecheck": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/ibm-plex-mono": "^5.1.0",
|
||||||
|
"@proxy-station/shared": "workspace:*",
|
||||||
|
"naive-ui": "^2.40.4",
|
||||||
|
"pinia": "^2.3.0",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.6",
|
||||||
|
"vue-tsc": "^2.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
189
web/src/App.vue
Normal file
189
web/src/App.vue
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { NConfigProvider, NMessageProvider, NDialogProvider } from 'naive-ui';
|
||||||
|
import { themeOverrides } from './theme';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/nodes', label: '节点' },
|
||||||
|
{ path: '/groups', label: '策略组' },
|
||||||
|
{ path: '/rules', label: '规则' },
|
||||||
|
{ path: '/subscriptions', label: '订阅' },
|
||||||
|
{ path: '/settings', label: '设置' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const active = computed(() => route.path);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NConfigProvider :theme-overrides="themeOverrides">
|
||||||
|
<NMessageProvider>
|
||||||
|
<NDialogProvider>
|
||||||
|
<div class="shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-inner">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-name">Proxy Station</span>
|
||||||
|
<span class="brand-strip"><i class="strip-direct" /><i class="strip-warp" /></span>
|
||||||
|
</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<button
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.path"
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: active.startsWith(item.path) }"
|
||||||
|
@click="router.push(item.path)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<div class="legend">
|
||||||
|
<span class="legend-item"><i class="lamp lamp-direct" />直连出口</span>
|
||||||
|
<span class="legend-item"><i class="lamp lamp-warp" />WARP 出口</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="content">
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</NDialogProvider>
|
||||||
|
</NMessageProvider>
|
||||||
|
</NConfigProvider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-inner {
|
||||||
|
max-width: 1240px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 品牌下的线路色条:站名牌的双色带 */
|
||||||
|
.brand-strip {
|
||||||
|
display: flex;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-strip i {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-direct {
|
||||||
|
background: var(--direct);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-warp {
|
||||||
|
background: var(--warp);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 26px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中 = 粗下划线(站台线条),不加底不加边 */
|
||||||
|
.nav-item {
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font: 600 14px var(--font-body);
|
||||||
|
padding: 0 2px;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
border-top: 3px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
color: var(--ink);
|
||||||
|
border-bottom-color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
max-width: 1240px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 32px 64px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.topbar-inner {
|
||||||
|
padding: 0 16px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
gap: 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 16px 16px 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
44
web/src/api/client.ts
Normal file
44
web/src/api/client.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
const ADMIN_TOKEN_KEY = 'proxy-station:admin-token';
|
||||||
|
|
||||||
|
export function getAdminToken(): string {
|
||||||
|
return localStorage.getItem(ADMIN_TOKEN_KEY) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAdminToken(token: string) {
|
||||||
|
if (token) localStorage.setItem(ADMIN_TOKEN_KEY, token);
|
||||||
|
else localStorage.removeItem(ADMIN_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const token = getAdminToken();
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
if (body !== undefined) headers['content-type'] = 'application/json';
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(res.status, (data as any)?.error || `请求失败(${res.status})`);
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T>(path: string) => request<T>('GET', path),
|
||||||
|
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||||
|
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
||||||
|
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
||||||
|
delete: <T>(path: string) => request<T>('DELETE', path),
|
||||||
|
};
|
||||||
62
web/src/components/EgressBadge.vue
Normal file
62
web/src/components/EgressBadge.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { Egress } from '@proxy-station/shared';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ egress: Egress; compact?: boolean }>(), { compact: false });
|
||||||
|
|
||||||
|
const label = computed(
|
||||||
|
() => ({ direct: '直连', warp: 'WARP', unknown: '未标注' })[props.egress]
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- compact:空间紧张处只用语义圆点,颜色即信息 -->
|
||||||
|
<span v-if="compact" class="dot-only" :title="label">
|
||||||
|
<i class="lamp" :class="{
|
||||||
|
'lamp-direct': egress === 'direct',
|
||||||
|
'lamp-warp': egress === 'warp',
|
||||||
|
'lamp-off': egress === 'unknown',
|
||||||
|
}" />
|
||||||
|
</span>
|
||||||
|
<span v-else class="badge" :class="`badge-${egress}`"><i class="lamp" :class="{
|
||||||
|
'lamp-direct': egress === 'direct',
|
||||||
|
'lamp-warp': egress === 'warp',
|
||||||
|
'lamp-off': egress === 'unknown',
|
||||||
|
}" />{{ label }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 导视胶囊标签:淡色底 + 语义圆点 */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-direct {
|
||||||
|
background: var(--direct-dim);
|
||||||
|
color: var(--direct);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warp {
|
||||||
|
background: var(--warp-dim);
|
||||||
|
color: var(--warp);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-unknown {
|
||||||
|
background: #eeeeea;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-only {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
121
web/src/components/ImportModal.vue
Normal file
121
web/src/components/ImportModal.vue
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { NModal, NInput, NButton, NSpace, NTag, useMessage } from 'naive-ui';
|
||||||
|
import { useNodesStore, type ImportResult } from '../stores/nodes';
|
||||||
|
|
||||||
|
const props = defineProps<{ show: boolean }>();
|
||||||
|
const emit = defineEmits<{ (e: 'update:show', v: boolean): void; (e: 'imported'): void }>();
|
||||||
|
|
||||||
|
const store = useNodesStore();
|
||||||
|
const message = useMessage();
|
||||||
|
const text = ref('');
|
||||||
|
const preview = ref<ImportResult | null>(null);
|
||||||
|
const busy = ref(false);
|
||||||
|
|
||||||
|
async function doPreview() {
|
||||||
|
if (!text.value.trim()) return;
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
preview.value = await store.importText(text.value, false);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doCommit() {
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
const result = await store.importText(text.value, true);
|
||||||
|
message.success(`已导入 ${result.succeeded} 个节点`);
|
||||||
|
text.value = '';
|
||||||
|
preview.value = null;
|
||||||
|
emit('imported');
|
||||||
|
emit('update:show', false);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
preview.value = null;
|
||||||
|
emit('update:show', false);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NModal :show="show" preset="card" title="批量导入分享链接" style="max-width: 680px" @update:show="close">
|
||||||
|
<p class="hint">每行一条链接,支持 vmess:// trojan:// ss:// hysteria2://。重名节点自动加后缀。</p>
|
||||||
|
<NInput
|
||||||
|
v-model:value="text"
|
||||||
|
type="textarea"
|
||||||
|
:rows="8"
|
||||||
|
class="mono"
|
||||||
|
placeholder="vmess://… trojan://…"
|
||||||
|
@input="preview = null"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="preview" class="preview">
|
||||||
|
<div v-for="(item, i) in preview.items" :key="i" class="preview-row">
|
||||||
|
<NTag :type="item.ok ? 'success' : 'error'" size="small">{{ item.ok ? '可导入' : '失败' }}</NTag>
|
||||||
|
<span v-if="item.ok" class="preview-name">
|
||||||
|
{{ item.node!.name }}
|
||||||
|
<span class="mono preview-detail">{{ item.node!.protocol }} · {{ item.node!.server }}:{{ item.node!.port }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-else class="preview-error">{{ item.error }}<span class="mono preview-detail">{{ item.uri.slice(0, 48) }}…</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<NSpace justify="end">
|
||||||
|
<NButton @click="close">取消</NButton>
|
||||||
|
<NButton :loading="busy" :disabled="!text.trim()" @click="doPreview">解析预览</NButton>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
:loading="busy"
|
||||||
|
:disabled="!preview || preview.succeeded === 0"
|
||||||
|
@click="doCommit"
|
||||||
|
>
|
||||||
|
导入 {{ preview?.succeeded ?? 0 }} 个节点
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</template>
|
||||||
|
</NModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-detail {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
184
web/src/components/NodeFormModal.vue
Normal file
184
web/src/components/NodeFormModal.vue
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
NModal,
|
||||||
|
NForm,
|
||||||
|
NFormItem,
|
||||||
|
NInput,
|
||||||
|
NInputNumber,
|
||||||
|
NSelect,
|
||||||
|
NSwitch,
|
||||||
|
NButton,
|
||||||
|
NSpace,
|
||||||
|
useMessage,
|
||||||
|
} from 'naive-ui';
|
||||||
|
import { nodeInputSchema, PROTOCOLS, type NodeDto, type NodeInput } from '@proxy-station/shared';
|
||||||
|
|
||||||
|
const props = defineProps<{ show: boolean; node?: NodeDto | null }>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:show', v: boolean): void;
|
||||||
|
(e: 'submit', input: NodeInput, id?: string): void;
|
||||||
|
(e: 'delete', id: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const message = useMessage();
|
||||||
|
|
||||||
|
const blank = (): NodeInput => ({
|
||||||
|
name: '',
|
||||||
|
protocol: 'vmess',
|
||||||
|
server: '',
|
||||||
|
port: 443,
|
||||||
|
network: 'ws',
|
||||||
|
wsPath: '/',
|
||||||
|
wsHost: null,
|
||||||
|
tls: true,
|
||||||
|
sni: null,
|
||||||
|
skipCertVerify: false,
|
||||||
|
uuid: null,
|
||||||
|
alterId: 0,
|
||||||
|
security: 'auto',
|
||||||
|
password: null,
|
||||||
|
method: 'chacha20-poly1305',
|
||||||
|
obfsType: null,
|
||||||
|
obfsPassword: null,
|
||||||
|
region: null,
|
||||||
|
tags: [],
|
||||||
|
enabled: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = reactive<NodeInput>(blank());
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(show) => {
|
||||||
|
if (!show) return;
|
||||||
|
Object.assign(form, blank(), props.node ?? {});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.node);
|
||||||
|
const protocolOptions = PROTOCOLS.map((p) => ({ label: p, value: p }));
|
||||||
|
const tagOptions = [
|
||||||
|
{ label: 'direct(直连出口)', value: 'direct' },
|
||||||
|
{ label: 'warp(WARP 出口)', value: 'warp' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const showWs = computed(() => form.protocol !== 'hysteria2');
|
||||||
|
const showVmess = computed(() => form.protocol === 'vmess');
|
||||||
|
const showPassword = computed(() => form.protocol !== 'vmess');
|
||||||
|
const showSs = computed(() => form.protocol === 'shadowsocks');
|
||||||
|
const showHy2 = computed(() => form.protocol === 'hysteria2');
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const result = nodeInputSchema.safeParse({ ...form });
|
||||||
|
if (!result.success) {
|
||||||
|
message.error(result.error.issues[0]?.message || '表单校验失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('submit', { ...form }, props.node?.id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NModal
|
||||||
|
:show="show"
|
||||||
|
preset="card"
|
||||||
|
:title="isEdit ? `编辑节点 · ${node?.name}` : '添加节点'"
|
||||||
|
style="max-width: 560px"
|
||||||
|
@update:show="emit('update:show', $event)"
|
||||||
|
>
|
||||||
|
<NForm label-placement="left" label-width="92">
|
||||||
|
<NFormItem label="名称" required>
|
||||||
|
<NInput v-model:value="form.name" placeholder="如 SG-vmess-warp" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="协议" required>
|
||||||
|
<NSelect v-model:value="form.protocol" :options="protocolOptions" :disabled="isEdit" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="服务器" required>
|
||||||
|
<NInput v-model:value="form.server" placeholder="域名或 IP" class="mono" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="端口" required>
|
||||||
|
<NInputNumber v-model:value="form.port" :min="1" :max="65535" style="width: 100%" />
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<template v-if="showVmess">
|
||||||
|
<NFormItem label="UUID" required>
|
||||||
|
<NInput v-model:value="form.uuid" class="mono" />
|
||||||
|
</NFormItem>
|
||||||
|
</template>
|
||||||
|
<template v-if="showPassword">
|
||||||
|
<NFormItem label="密码" required>
|
||||||
|
<NInput v-model:value="form.password" type="password" show-password-on="click" class="mono" />
|
||||||
|
</NFormItem>
|
||||||
|
</template>
|
||||||
|
<template v-if="showSs">
|
||||||
|
<NFormItem label="加密方式" required>
|
||||||
|
<NInput v-model:value="form.method" placeholder="chacha20-poly1305" class="mono" />
|
||||||
|
</NFormItem>
|
||||||
|
</template>
|
||||||
|
<template v-if="showHy2">
|
||||||
|
<NFormItem label="混淆">
|
||||||
|
<NSelect
|
||||||
|
v-model:value="form.obfsType"
|
||||||
|
:options="[
|
||||||
|
{ label: '无', value: null as any },
|
||||||
|
{ label: 'salamander', value: 'salamander' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem v-if="form.obfsType === 'salamander'" label="混淆密码" required>
|
||||||
|
<NInput v-model:value="form.obfsPassword" type="password" show-password-on="click" class="mono" />
|
||||||
|
</NFormItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="showWs">
|
||||||
|
<NFormItem label="传输">
|
||||||
|
<NSelect
|
||||||
|
v-model:value="form.network"
|
||||||
|
:options="[
|
||||||
|
{ label: 'TCP', value: 'tcp' },
|
||||||
|
{ label: 'WebSocket', value: 'ws' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<template v-if="form.network === 'ws'">
|
||||||
|
<NFormItem label="WS 路径" required>
|
||||||
|
<NInput v-model:value="form.wsPath" placeholder="/xxxxxx" class="mono" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="WS Host">
|
||||||
|
<NInput v-model:value="form.wsHost" placeholder="默认与服务器相同" class="mono" />
|
||||||
|
</NFormItem>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<NFormItem label="TLS">
|
||||||
|
<NSwitch v-model:value="form.tls" :disabled="form.protocol === 'hysteria2'" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem v-if="form.tls" label="SNI">
|
||||||
|
<NInput v-model:value="form.sni" placeholder="默认与服务器相同" class="mono" />
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="出口标签">
|
||||||
|
<NSelect v-model:value="form.tags" multiple :options="tagOptions" placeholder="direct / warp,用于线路矩阵归位" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="站点代号">
|
||||||
|
<NInput v-model:value="form.region" placeholder="如 SG/LA/RN,留空则按域名推断" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="启用">
|
||||||
|
<NSwitch v-model:value="form.enabled" />
|
||||||
|
</NFormItem>
|
||||||
|
</NForm>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<NSpace justify="space-between">
|
||||||
|
<NButton v-if="isEdit" type="error" quaternary @click="emit('delete', node!.id)">删除节点</NButton>
|
||||||
|
<span v-else />
|
||||||
|
<NSpace>
|
||||||
|
<NButton @click="emit('update:show', false)">取消</NButton>
|
||||||
|
<NButton type="primary" @click="submit">{{ isEdit ? '保存' : '添加' }}</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</NSpace>
|
||||||
|
</template>
|
||||||
|
</NModal>
|
||||||
|
</template>
|
||||||
51
web/src/components/ProtoDot.vue
Normal file
51
web/src/components/ProtoDot.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { Protocol } from '@proxy-station/shared';
|
||||||
|
|
||||||
|
const props = defineProps<{ protocol: Protocol }>();
|
||||||
|
|
||||||
|
const LETTERS: Record<Protocol, string> = {
|
||||||
|
vmess: 'V',
|
||||||
|
trojan: 'T',
|
||||||
|
shadowsocks: 'S',
|
||||||
|
hysteria2: 'H',
|
||||||
|
};
|
||||||
|
|
||||||
|
const letter = computed(() => LETTERS[props.protocol]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="proto-dot" :class="`proto-${protocol}`" :title="protocol">{{ letter }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 线路色圆标:导视系统的线路标识 */
|
||||||
|
.proto-dot {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-vmess {
|
||||||
|
background: var(--proto-vmess);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-trojan {
|
||||||
|
background: var(--proto-trojan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-shadowsocks {
|
||||||
|
background: var(--proto-shadowsocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-hysteria2 {
|
||||||
|
background: var(--proto-hysteria2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
91
web/src/components/RulesetPreviewDrawer.vue
Normal file
91
web/src/components/RulesetPreviewDrawer.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { NButton, NDrawer, NDrawerContent, NSpace, NSpin, NTag, useMessage } from 'naive-ui';
|
||||||
|
import type { RuleDto } from '@proxy-station/shared';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
|
||||||
|
const props = defineProps<{ show: boolean; rule: RuleDto | null }>();
|
||||||
|
const emit = defineEmits<{ (e: 'update:show', v: boolean): void }>();
|
||||||
|
|
||||||
|
const message = useMessage();
|
||||||
|
const content = ref('');
|
||||||
|
const lineCount = ref(0);
|
||||||
|
const fetchedAt = ref(0);
|
||||||
|
const stale = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
async function load(refresh = false) {
|
||||||
|
if (!props.rule?.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await api.get<{ content: string; fetchedAt: number; stale: boolean; lineCount: number }>(
|
||||||
|
`/api/rulesets/${props.rule.value}/content${refresh ? '?refresh=1' : ''}`
|
||||||
|
);
|
||||||
|
content.value = result.content;
|
||||||
|
lineCount.value = result.lineCount;
|
||||||
|
fetchedAt.value = result.fetchedAt;
|
||||||
|
stale.value = result.stale;
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message);
|
||||||
|
content.value = '';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(show) => {
|
||||||
|
if (show) load();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NDrawer :show="show" :width="560" @update:show="emit('update:show', $event)">
|
||||||
|
<NDrawerContent :title="rule?.rulesetName ?? '规则集预览'" closable>
|
||||||
|
<template #header>
|
||||||
|
<NSpace align="center" size="small">
|
||||||
|
<span>{{ rule?.rulesetName }}</span>
|
||||||
|
<NTag v-if="lineCount" size="small" :bordered="false">{{ lineCount }} 条</NTag>
|
||||||
|
<NTag v-if="stale" size="small" type="warning" :bordered="false">缓存已过期,上游暂不可达</NTag>
|
||||||
|
</NSpace>
|
||||||
|
</template>
|
||||||
|
<div class="meta">
|
||||||
|
<code class="mono url">{{ rule?.rulesetUrl }}</code>
|
||||||
|
<NSpace size="small" style="margin: 8px 0">
|
||||||
|
<span v-if="fetchedAt" class="fetched">拉取于 {{ new Date(fetchedAt).toLocaleString() }}</span>
|
||||||
|
<NButton size="tiny" secondary :loading="loading" @click="load(true)">刷新</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</div>
|
||||||
|
<NSpin :show="loading">
|
||||||
|
<pre class="content mono">{{ content || '(无内容)' }}</pre>
|
||||||
|
</NSpin>
|
||||||
|
</NDrawerContent>
|
||||||
|
</NDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.url {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fetched {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
max-height: calc(100vh - 220px);
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
236
web/src/components/TokenFormModal.vue
Normal file
236
web/src/components/TokenFormModal.vue
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { NButton, NCheckbox, NInput, NModal, NSpace, useMessage } from 'naive-ui';
|
||||||
|
import { nodeEgress, type NodeDto, type TokenDto } from '@proxy-station/shared';
|
||||||
|
import { stationOf } from './stations';
|
||||||
|
import ProtoDot from './ProtoDot.vue';
|
||||||
|
import EgressBadge from './EgressBadge.vue';
|
||||||
|
import { nodeDisplayName } from '../utils/nodeName';
|
||||||
|
|
||||||
|
const props = defineProps<{ show: boolean; token?: TokenDto | null; nodes: NodeDto[] }>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:show', v: boolean): void;
|
||||||
|
(e: 'submit', payload: { name: string; nodeIds: string[] }, id?: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const message = useMessage();
|
||||||
|
const name = ref('');
|
||||||
|
const selected = ref<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.token);
|
||||||
|
const enabledNodes = computed(() => props.nodes.filter((n) => n.enabled));
|
||||||
|
|
||||||
|
/** 按站点分组,与线路矩阵一致 */
|
||||||
|
const stations = computed(() => {
|
||||||
|
const map = new Map<string, NodeDto[]>();
|
||||||
|
for (const n of enabledNodes.value) {
|
||||||
|
const key = stationOf(n);
|
||||||
|
if (!map.has(key)) map.set(key, []);
|
||||||
|
map.get(key)!.push(n);
|
||||||
|
}
|
||||||
|
return [...map.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([code, list]) => ({ code, nodes: list }));
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(show) => {
|
||||||
|
if (!show) return;
|
||||||
|
name.value = props.token?.name ?? '';
|
||||||
|
// 空数组语义是「全部」,编辑时展开成全选,用户能直观看到勾选状态
|
||||||
|
const ids = props.token?.nodeIds ?? [];
|
||||||
|
selected.value = new Set(ids.length ? ids : enabledNodes.value.map((n) => n.id));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const allSelected = computed(() => selected.value.size === enabledNodes.value.length);
|
||||||
|
const selectedCount = computed(() => selected.value.size);
|
||||||
|
|
||||||
|
function toggleNode(id: string, checked: boolean) {
|
||||||
|
const next = new Set(selected.value);
|
||||||
|
if (checked) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
selected.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleStation(code: string, checked: boolean) {
|
||||||
|
const next = new Set(selected.value);
|
||||||
|
const ids = stations.value.find((s) => s.code === code)!.nodes.map((n) => n.id);
|
||||||
|
for (const id of ids) {
|
||||||
|
if (checked) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
}
|
||||||
|
selected.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stationState(code: string): { all: boolean; some: boolean } {
|
||||||
|
const ids = stations.value.find((s) => s.code === code)!.nodes.map((n) => n.id);
|
||||||
|
const hit = ids.filter((id) => selected.value.has(id)).length;
|
||||||
|
return { all: hit === ids.length, some: hit > 0 && hit < ids.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(checked: boolean) {
|
||||||
|
selected.value = checked ? new Set(enabledNodes.value.map((n) => n.id)) : new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (!name.value.trim()) {
|
||||||
|
message.error('请填写订阅名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selected.value.size === 0) {
|
||||||
|
message.error('至少选择一个节点');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 全选时存空数组:语义为「不限制」,之后新增的节点会自动包含进来
|
||||||
|
const nodeIds = allSelected.value ? [] : [...selected.value];
|
||||||
|
emit('submit', { name: name.value.trim(), nodeIds }, props.token?.id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NModal
|
||||||
|
:show="show"
|
||||||
|
preset="card"
|
||||||
|
:title="isEdit ? `编辑订阅 · ${token?.name}` : '新建订阅'"
|
||||||
|
style="max-width: 620px"
|
||||||
|
@update:show="emit('update:show', $event)"
|
||||||
|
>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">订阅名称</label>
|
||||||
|
<NInput v-model:value="name" placeholder="设备或用户名,如 kid-macbook / 朋友-A" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div class="field-head">
|
||||||
|
<label class="field-label">可用节点</label>
|
||||||
|
<div class="field-actions">
|
||||||
|
<span class="count">已选 {{ selectedCount }} / {{ enabledNodes.length }}</span>
|
||||||
|
<NButton size="tiny" quaternary @click="toggleAll(true)">全选</NButton>
|
||||||
|
<NButton size="tiny" quaternary @click="toggleAll(false)">清空</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="field-hint">
|
||||||
|
只有勾选的节点会下发给这个订阅。全选时后续新增的节点会自动包含进来。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="stations">
|
||||||
|
<section v-for="st in stations" :key="st.code" class="station">
|
||||||
|
<NCheckbox
|
||||||
|
:checked="stationState(st.code).all"
|
||||||
|
:indeterminate="stationState(st.code).some"
|
||||||
|
@update:checked="(v: boolean) => toggleStation(st.code, v)"
|
||||||
|
>
|
||||||
|
<span class="station-code">{{ st.code }}</span>
|
||||||
|
<span class="station-count">{{ st.nodes.length }} 条</span>
|
||||||
|
</NCheckbox>
|
||||||
|
<div class="node-list">
|
||||||
|
<NCheckbox
|
||||||
|
v-for="n in st.nodes"
|
||||||
|
:key="n.id"
|
||||||
|
:checked="selected.has(n.id)"
|
||||||
|
class="node-item"
|
||||||
|
@update:checked="(v: boolean) => toggleNode(n.id, v)"
|
||||||
|
>
|
||||||
|
<span class="node-line">
|
||||||
|
<ProtoDot :protocol="n.protocol" />
|
||||||
|
<span class="node-name">{{ nodeDisplayName(n) }}</span>
|
||||||
|
<EgressBadge :egress="nodeEgress(n)" />
|
||||||
|
</span>
|
||||||
|
</NCheckbox>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<NSpace justify="end">
|
||||||
|
<NButton @click="emit('update:show', false)">取消</NButton>
|
||||||
|
<NButton type="primary" @click="submit">{{ isEdit ? '保存' : '创建' }}</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</template>
|
||||||
|
</NModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.field {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stations {
|
||||||
|
max-height: 380px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station + .station {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-code {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
margin: 6px 0 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-item {
|
||||||
|
padding: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-line {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-name {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
23
web/src/components/stations.ts
Normal file
23
web/src/components/stations.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { NodeDto } from '@proxy-station/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站点归组:优先 region 字段,否则从域名猜测(cc.sg.example.com → SG),
|
||||||
|
* 最后回落到 server 本身。
|
||||||
|
*/
|
||||||
|
export function stationOf(node: NodeDto): string {
|
||||||
|
if (node.region) return node.region.toUpperCase();
|
||||||
|
const parts = node.server.split('.');
|
||||||
|
if (parts.length >= 4 && parts[1].length <= 4) return parts[1].toUpperCase();
|
||||||
|
return node.server;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 站点展示名:国旗 + 国家 + 站点代号 */
|
||||||
|
const STATION_LABELS: Record<string, string> = {
|
||||||
|
SG: '🇸🇬 SG AWS',
|
||||||
|
LA: '🇺🇸 US LA',
|
||||||
|
RN: '🇺🇸 US RN',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function stationLabel(code: string): string {
|
||||||
|
return STATION_LABELS[code] ?? code;
|
||||||
|
}
|
||||||
10
web/src/main.ts
Normal file
10
web/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import App from './App.vue';
|
||||||
|
import { router } from './router';
|
||||||
|
|
||||||
|
import '@fontsource/ibm-plex-mono/400.css';
|
||||||
|
import '@fontsource/ibm-plex-mono/500.css';
|
||||||
|
import './styles/tokens.css';
|
||||||
|
|
||||||
|
createApp(App).use(createPinia()).use(router).mount('#app');
|
||||||
353
web/src/pages/GroupsPage.vue
Normal file
353
web/src/pages/GroupsPage.vue
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
NButton,
|
||||||
|
NCard,
|
||||||
|
NForm,
|
||||||
|
NFormItem,
|
||||||
|
NInput,
|
||||||
|
NInputNumber,
|
||||||
|
NModal,
|
||||||
|
NSelect,
|
||||||
|
NSpace,
|
||||||
|
NSwitch,
|
||||||
|
NTag,
|
||||||
|
useDialog,
|
||||||
|
useMessage,
|
||||||
|
} from 'naive-ui';
|
||||||
|
import { nodeEgress, type GroupDto, type GroupInput } from '@proxy-station/shared';
|
||||||
|
import { useGroupsStore } from '../stores/groups';
|
||||||
|
import { useNodesStore } from '../stores/nodes';
|
||||||
|
import EgressBadge from '../components/EgressBadge.vue';
|
||||||
|
import { nodeDisplayName } from '../utils/nodeName';
|
||||||
|
|
||||||
|
const store = useGroupsStore();
|
||||||
|
const nodesStore = useNodesStore();
|
||||||
|
const message = useMessage();
|
||||||
|
const dialog = useDialog();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.fetchAll();
|
||||||
|
nodesStore.fetchAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
const showForm = ref(false);
|
||||||
|
const editing = ref<GroupDto | null>(null);
|
||||||
|
const form = ref<GroupInput>(blankForm());
|
||||||
|
const memberSelection = ref<string[]>([]);
|
||||||
|
|
||||||
|
function blankForm(): GroupInput {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
type: 'select',
|
||||||
|
testUrl: null,
|
||||||
|
interval: null,
|
||||||
|
tolerance: null,
|
||||||
|
filterRegex: null,
|
||||||
|
includeAllNodes: false,
|
||||||
|
sortOrder: 0,
|
||||||
|
members: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成员选项:builtin + 其他组 + 节点,用前缀区分 */
|
||||||
|
const memberOptions = computed(() => [
|
||||||
|
{ type: 'group' as const, label: '— 内置策略 —', key: 'h1', children: ['DIRECT', 'REJECT'].map((b) => ({ label: b, value: `builtin:${b}` })) },
|
||||||
|
{
|
||||||
|
type: 'group' as const,
|
||||||
|
label: '— 策略组 —',
|
||||||
|
key: 'h2',
|
||||||
|
children: store.groups.filter((g) => g.id !== editing.value?.id).map((g) => ({ label: g.name, value: `group:${g.name}` })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'group' as const,
|
||||||
|
label: '— 节点 —',
|
||||||
|
key: 'h3',
|
||||||
|
children: nodesStore.nodes.map((n) => ({ label: n.name, value: `node:${n.id}` })),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editing.value = null;
|
||||||
|
form.value = blankForm();
|
||||||
|
memberSelection.value = [];
|
||||||
|
showForm.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(group: GroupDto) {
|
||||||
|
editing.value = group;
|
||||||
|
form.value = {
|
||||||
|
name: group.name,
|
||||||
|
type: group.type,
|
||||||
|
testUrl: group.testUrl,
|
||||||
|
interval: group.interval,
|
||||||
|
tolerance: group.tolerance,
|
||||||
|
filterRegex: group.filterRegex,
|
||||||
|
includeAllNodes: group.includeAllNodes,
|
||||||
|
sortOrder: group.sortOrder,
|
||||||
|
members: [],
|
||||||
|
};
|
||||||
|
memberSelection.value = group.members.map((m) =>
|
||||||
|
m.kind === 'node' ? `node:${m.nodeId}` : m.kind === 'builtin' ? `builtin:${m.refName}` : `group:${m.refName}`
|
||||||
|
);
|
||||||
|
showForm.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
form.value.members = memberSelection.value.map((v) => {
|
||||||
|
const [kind, ...rest] = v.split(':');
|
||||||
|
const ref = rest.join(':');
|
||||||
|
return kind === 'node' ? { kind: 'node' as const, nodeId: ref } : { kind: kind as 'group' | 'builtin', refName: ref };
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (editing.value) await store.update(editing.value.id, form.value);
|
||||||
|
else await store.create(form.value);
|
||||||
|
showForm.value = false;
|
||||||
|
message.success('已保存');
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(group: GroupDto) {
|
||||||
|
dialog.warning({
|
||||||
|
title: '删除策略组',
|
||||||
|
content: `确认删除「${group.name}」?引用它的规则策略不会自动更新。`,
|
||||||
|
positiveText: '删除',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: () => store.remove(group.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排序:客户端订阅里的策略组顺序即此顺序 */
|
||||||
|
async function move(group: GroupDto, dir: -1 | 1) {
|
||||||
|
const ids = store.groups.map((g) => g.id);
|
||||||
|
const i = ids.indexOf(group.id);
|
||||||
|
const j = i + dir;
|
||||||
|
if (j < 0 || j >= ids.length) return;
|
||||||
|
[ids[i], ids[j]] = [ids[j], ids[i]];
|
||||||
|
await store.reorder(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** regex 组实时预览:当前节点里有哪些会被圈中 */
|
||||||
|
function matchedNodes(group: GroupDto) {
|
||||||
|
if (!group.filterRegex) return [];
|
||||||
|
try {
|
||||||
|
const re = new RegExp(group.filterRegex);
|
||||||
|
return nodesStore.nodes.filter((n) => re.test(n.name));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberLabel(m: GroupDto['members'][number]): string {
|
||||||
|
if (m.kind === 'node') return nodesStore.nodes.find((n) => n.id === m.nodeId)?.name ?? '(已删除节点)';
|
||||||
|
return m.refName ?? '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-head">
|
||||||
|
<h1 class="display page-title">策略组</h1>
|
||||||
|
<NButton type="primary" @click="openCreate">添加策略组</NButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint">顺序即客户端里策略组的排列顺序;停用的组不会下发(引用它的规则一并跳过)。</p>
|
||||||
|
<div class="group-grid">
|
||||||
|
<NCard v-for="(g, i) in store.groups" :key="g.id" size="small" class="group-card" :class="{ off: !g.enabled }">
|
||||||
|
<template #header>
|
||||||
|
<span class="group-order mono">{{ String(i + 1).padStart(2, '0') }}</span>
|
||||||
|
<span class="group-name">{{ g.name }}</span>
|
||||||
|
<NTag size="small" :bordered="false" style="margin-left: 8px">{{ g.type }}</NTag>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<NSpace size="small" align="center">
|
||||||
|
<NButton size="tiny" quaternary :disabled="i === 0" @click="move(g, -1)">↑</NButton>
|
||||||
|
<NButton size="tiny" quaternary :disabled="i === store.groups.length - 1" @click="move(g, 1)">↓</NButton>
|
||||||
|
<NSwitch size="small" :value="g.enabled" @update:value="(v: boolean) => store.toggle(g.id, v)" />
|
||||||
|
<NButton size="tiny" quaternary @click="openEdit(g)">编辑</NButton>
|
||||||
|
<NButton v-if="g.source === 'custom'" size="tiny" quaternary type="error" @click="confirmDelete(g)">删除</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="g.includeAllNodes" class="group-line">
|
||||||
|
<span class="group-label">收纳</span>全部启用节点
|
||||||
|
</div>
|
||||||
|
<div v-if="g.filterRegex" class="group-line">
|
||||||
|
<span class="group-label">筛选</span>
|
||||||
|
<span class="mono regex">{{ g.filterRegex.length > 48 ? g.filterRegex.slice(0, 48) + '…' : g.filterRegex }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="g.filterRegex" class="group-line group-line-block">
|
||||||
|
<div v-if="matchedNodes(g).length" class="matched-wrap">
|
||||||
|
<div class="matched-count"><span class="group-label">命中</span>{{ matchedNodes(g).length }} 个节点</div>
|
||||||
|
<div class="matched-list">
|
||||||
|
<div v-for="n in matchedNodes(g)" :key="n.id" class="matched-row" :title="n.name">
|
||||||
|
<EgressBadge :egress="nodeEgress(n)" compact />
|
||||||
|
<span class="matched-name">{{ nodeDisplayName(n) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-else class="muted"><span class="group-label">命中</span>当前没有节点名匹配</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="g.members.length" class="group-line">
|
||||||
|
<span class="group-label">成员</span>
|
||||||
|
<span class="members">{{ g.members.map(memberLabel).join('、') }}</span>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NModal
|
||||||
|
:show="showForm"
|
||||||
|
preset="card"
|
||||||
|
:title="editing ? `编辑策略组 · ${editing.name}` : '添加策略组'"
|
||||||
|
style="max-width: 560px"
|
||||||
|
@update:show="showForm = $event"
|
||||||
|
>
|
||||||
|
<NForm label-placement="left" label-width="96">
|
||||||
|
<NFormItem label="名称" required>
|
||||||
|
<NInput v-model:value="form.name" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="类型">
|
||||||
|
<NSelect
|
||||||
|
v-model:value="form.type"
|
||||||
|
:options="['select', 'url-test', 'fallback'].map((t) => ({ label: t, value: t }))"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="成员(有序)">
|
||||||
|
<NSelect v-model:value="memberSelection" multiple filterable :options="memberOptions" placeholder="选择节点、组或内置策略" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="收纳全部节点">
|
||||||
|
<NSwitch v-model:value="form.includeAllNodes" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="名称筛选正则">
|
||||||
|
<NInput v-model:value="form.filterRegex" placeholder="如 SG|Singapore(配合收纳全部节点或成员组)" class="mono" />
|
||||||
|
</NFormItem>
|
||||||
|
<template v-if="form.type !== 'select'">
|
||||||
|
<NFormItem label="测速 URL">
|
||||||
|
<NInput v-model:value="form.testUrl" placeholder="http://cp.cloudflare.com/generate_204" class="mono" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="间隔(秒)">
|
||||||
|
<NInputNumber v-model:value="form.interval" :min="10" style="width: 100%" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="容差(ms)">
|
||||||
|
<NInputNumber v-model:value="form.tolerance" :min="0" style="width: 100%" />
|
||||||
|
</NFormItem>
|
||||||
|
</template>
|
||||||
|
</NForm>
|
||||||
|
<template #footer>
|
||||||
|
<NSpace justify="end">
|
||||||
|
<NButton @click="showForm = false">取消</NButton>
|
||||||
|
<NButton type="primary" @click="submit">保存</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</template>
|
||||||
|
</NModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin: -12px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 同行卡片等宽等高 */
|
||||||
|
.group-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-card.off {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-order {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-line {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
line-height: 1.7;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-label {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-right: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regex {
|
||||||
|
color: var(--warp);
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 命中清单:一行一个,节点名与出口标签左右对齐 */
|
||||||
|
.matched-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 命中区整块换行,让清单占满卡片宽度 */
|
||||||
|
.group-line-block {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matched-count {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全部铺开不滚动;两列等宽,无左缩进 */
|
||||||
|
.matched-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matched-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 2.5px 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matched-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.members {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
300
web/src/pages/NodesPage.vue
Normal file
300
web/src/pages/NodesPage.vue
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, h, onMounted, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
NButton,
|
||||||
|
NDataTable,
|
||||||
|
NEmpty,
|
||||||
|
NSpace,
|
||||||
|
NSwitch,
|
||||||
|
useDialog,
|
||||||
|
useMessage,
|
||||||
|
type DataTableColumns,
|
||||||
|
} from 'naive-ui';
|
||||||
|
import { nodeEgress, type NodeDto, type NodeInput, type Protocol } from '@proxy-station/shared';
|
||||||
|
import { useNodesStore } from '../stores/nodes';
|
||||||
|
import { stationLabel, stationOf } from '../components/stations';
|
||||||
|
import EgressBadge from '../components/EgressBadge.vue';
|
||||||
|
import ProtoDot from '../components/ProtoDot.vue';
|
||||||
|
import NodeFormModal from '../components/NodeFormModal.vue';
|
||||||
|
import ImportModal from '../components/ImportModal.vue';
|
||||||
|
import { copyText } from '../utils/clipboard';
|
||||||
|
import { nodeDisplayName } from '../utils/nodeName';
|
||||||
|
|
||||||
|
const store = useNodesStore();
|
||||||
|
const message = useMessage();
|
||||||
|
const dialog = useDialog();
|
||||||
|
|
||||||
|
const showForm = ref(false);
|
||||||
|
const showImport = ref(false);
|
||||||
|
const editing = ref<NodeDto | null>(null);
|
||||||
|
|
||||||
|
onMounted(() => store.fetchAll());
|
||||||
|
|
||||||
|
const PROTOCOL_ORDER: Protocol[] = ['vmess', 'trojan', 'shadowsocks', 'hysteria2'];
|
||||||
|
const EGRESS_ORDER = { direct: 0, warp: 1, unknown: 2 };
|
||||||
|
|
||||||
|
/** 站点展示域名:去掉子域首段(cc.sg.example.com → sg.example.com) */
|
||||||
|
function baseDomain(server: string): string {
|
||||||
|
const parts = server.split('.');
|
||||||
|
return parts.length >= 4 ? parts.slice(1).join('.') : server;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按站点分段,段内先按协议、再按出口排序 */
|
||||||
|
const sections = computed(() => {
|
||||||
|
const byStation = new Map<string, NodeDto[]>();
|
||||||
|
for (const n of store.nodes) {
|
||||||
|
const key = stationOf(n);
|
||||||
|
if (!byStation.has(key)) byStation.set(key, []);
|
||||||
|
byStation.get(key)!.push(n);
|
||||||
|
}
|
||||||
|
return [...byStation.entries()]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([code, list]) => ({
|
||||||
|
code,
|
||||||
|
label: stationLabel(code),
|
||||||
|
domain: baseDomain(list[0].server),
|
||||||
|
nodes: [...list].sort(
|
||||||
|
(a, b) =>
|
||||||
|
PROTOCOL_ORDER.indexOf(a.protocol) - PROTOCOL_ORDER.indexOf(b.protocol) ||
|
||||||
|
EGRESS_ORDER[nodeEgress(a)] - EGRESS_ORDER[nodeEgress(b)]
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editing.value = null;
|
||||||
|
showForm.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(node: NodeDto) {
|
||||||
|
editing.value = node;
|
||||||
|
showForm.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(input: NodeInput, id?: string) {
|
||||||
|
try {
|
||||||
|
if (id) await store.update(id, input);
|
||||||
|
else await store.create(input);
|
||||||
|
showForm.value = false;
|
||||||
|
message.success(id ? '已保存' : '已添加');
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(id: string) {
|
||||||
|
const node = store.nodes.find((n) => n.id === id);
|
||||||
|
dialog.warning({
|
||||||
|
title: '删除节点',
|
||||||
|
content: `确认删除「${node?.name}」?引用它的策略组成员会一并移除。`,
|
||||||
|
positiveText: '删除',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
await store.remove(id);
|
||||||
|
showForm.value = false;
|
||||||
|
message.success('已删除');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyUri(node: NodeDto) {
|
||||||
|
try {
|
||||||
|
const uri = await store.exportUri(node.id);
|
||||||
|
if (await copyText(uri)) message.success('分享链接已复制');
|
||||||
|
else message.warning('浏览器阻止了自动复制,请改用 HTTPS 或 localhost 访问');
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用分享链接唤起本机已安装的客户端,把这条线路导进去 */
|
||||||
|
async function connect(node: NodeDto) {
|
||||||
|
try {
|
||||||
|
const uri = await store.exportUri(node.id);
|
||||||
|
window.location.href = uri;
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = computed<DataTableColumns<NodeDto>>(() => [
|
||||||
|
{
|
||||||
|
title: '名称',
|
||||||
|
key: 'name',
|
||||||
|
ellipsis: { tooltip: true },
|
||||||
|
// 出口词由「出口」列承担,这里隐去避免重复
|
||||||
|
render: (row) => nodeDisplayName(row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '协议',
|
||||||
|
key: 'protocol',
|
||||||
|
width: 148,
|
||||||
|
render: (row) =>
|
||||||
|
h('span', { class: 'proto-cell' }, [
|
||||||
|
h(ProtoDot, { protocol: row.protocol }),
|
||||||
|
h('span', {}, row.protocol),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '出口',
|
||||||
|
key: 'egress',
|
||||||
|
width: 92,
|
||||||
|
render: (row) => h(EgressBadge, { egress: nodeEgress(row) }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '服务器',
|
||||||
|
key: 'server',
|
||||||
|
ellipsis: { tooltip: true },
|
||||||
|
render: (row) => h('span', { class: 'mono cell-mono' }, `${row.server}:${row.port}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '路径',
|
||||||
|
key: 'wsPath',
|
||||||
|
width: 108,
|
||||||
|
render: (row) => h('span', { class: 'mono cell-mono' }, row.wsPath ?? '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '启用',
|
||||||
|
key: 'enabled',
|
||||||
|
width: 72,
|
||||||
|
render: (row) =>
|
||||||
|
h(NSwitch, {
|
||||||
|
size: 'small',
|
||||||
|
value: row.enabled,
|
||||||
|
onUpdateValue: (v: boolean) => store.toggle(row.id, v),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
width: 176,
|
||||||
|
render: (row) =>
|
||||||
|
h(NSpace, { size: 2, wrap: false }, () => [
|
||||||
|
h(NButton, { size: 'tiny', quaternary: true, onClick: () => copyUri(row) }, () => '复制'),
|
||||||
|
h(NButton, { size: 'tiny', quaternary: true, onClick: () => connect(row) }, () => '连接'),
|
||||||
|
h(NButton, { size: 'tiny', quaternary: true, onClick: () => openEdit(row) }, () => '编辑'),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-head">
|
||||||
|
<h1 class="display page-title">节点</h1>
|
||||||
|
<NSpace>
|
||||||
|
<NButton ghost @click="showImport = true">批量导入</NButton>
|
||||||
|
<NButton type="primary" @click="openCreate">添加节点</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NEmpty
|
||||||
|
v-if="!store.loading && store.nodes.length === 0"
|
||||||
|
description="还没有节点。粘贴分享链接批量导入,或手动添加。"
|
||||||
|
style="margin-top: 80px"
|
||||||
|
>
|
||||||
|
<template #extra>
|
||||||
|
<NButton type="primary" @click="showImport = true">批量导入</NButton>
|
||||||
|
</template>
|
||||||
|
</NEmpty>
|
||||||
|
|
||||||
|
<section v-for="st in sections" :key="st.code" class="station">
|
||||||
|
<header class="station-head">
|
||||||
|
<span class="station-name display">{{ st.label }}</span>
|
||||||
|
<span class="station-domain mono">{{ st.domain }}</span>
|
||||||
|
<span class="station-count">{{ st.nodes.length }} 条线路</span>
|
||||||
|
</header>
|
||||||
|
<div class="strip"><i class="strip-direct" /><i class="strip-warp" /></div>
|
||||||
|
<NDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:data="st.nodes"
|
||||||
|
:bordered="false"
|
||||||
|
:single-line="false"
|
||||||
|
size="small"
|
||||||
|
:row-key="(n: NodeDto) => n.id"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<NodeFormModal v-model:show="showForm" :node="editing" @submit="handleSubmit" @delete="handleDelete" />
|
||||||
|
<ImportModal v-model:show="showImport" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station + .station {
|
||||||
|
margin-top: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 2px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-name {
|
||||||
|
font-size: 17px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-domain {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 站牌双色条:琥珀 = 直连,青 = WARP */
|
||||||
|
.strip {
|
||||||
|
display: flex;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip i {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-direct {
|
||||||
|
background: var(--direct);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-warp {
|
||||||
|
background: var(--warp);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.proto-cell) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.cell-mono) {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
266
web/src/pages/RulesPage.vue
Normal file
266
web/src/pages/RulesPage.vue
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, h, onMounted, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
NButton,
|
||||||
|
NDataTable,
|
||||||
|
NModal,
|
||||||
|
NForm,
|
||||||
|
NFormItem,
|
||||||
|
NInput,
|
||||||
|
NSelect,
|
||||||
|
NSpace,
|
||||||
|
NSwitch,
|
||||||
|
NTag,
|
||||||
|
useDialog,
|
||||||
|
useMessage,
|
||||||
|
type DataTableColumns,
|
||||||
|
} from 'naive-ui';
|
||||||
|
import { RULE_TYPES, type RuleDto, type RuleInput } from '@proxy-station/shared';
|
||||||
|
import { useRulesStore } from '../stores/rules';
|
||||||
|
import { useGroupsStore } from '../stores/groups';
|
||||||
|
import RulesetPreviewDrawer from '../components/RulesetPreviewDrawer.vue';
|
||||||
|
|
||||||
|
const store = useRulesStore();
|
||||||
|
const groupsStore = useGroupsStore();
|
||||||
|
const message = useMessage();
|
||||||
|
const dialog = useDialog();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.fetchAll();
|
||||||
|
groupsStore.fetchAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
const showForm = ref(false);
|
||||||
|
const editing = ref<RuleDto | null>(null);
|
||||||
|
const previewRule = ref<RuleDto | null>(null);
|
||||||
|
const showPreview = ref(false);
|
||||||
|
|
||||||
|
function openPreview(rule: RuleDto) {
|
||||||
|
previewRule.value = rule;
|
||||||
|
showPreview.value = true;
|
||||||
|
}
|
||||||
|
const form = ref<RuleInput>({ type: 'DOMAIN-SUFFIX', value: '', policy: 'Proxy', params: [], enabled: true });
|
||||||
|
|
||||||
|
const policyOptions = computed(() => [
|
||||||
|
...['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP'].map((p) => ({ label: p, value: p })),
|
||||||
|
...groupsStore.groups.map((g) => ({ label: g.name, value: g.name })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const typeOptions = RULE_TYPES.filter((t) => t !== 'RULE-SET' && t !== 'DOMAIN-SET' && t !== 'FINAL').map((t) => ({
|
||||||
|
label: t,
|
||||||
|
value: t,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editing.value = null;
|
||||||
|
form.value = { type: 'DOMAIN-SUFFIX', value: '', policy: 'Proxy', params: [], enabled: true };
|
||||||
|
showForm.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(rule: RuleDto) {
|
||||||
|
editing.value = rule;
|
||||||
|
form.value = {
|
||||||
|
type: rule.type as RuleInput['type'],
|
||||||
|
value: rule.value,
|
||||||
|
policy: rule.policy,
|
||||||
|
params: rule.params,
|
||||||
|
enabled: rule.enabled,
|
||||||
|
};
|
||||||
|
showForm.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
try {
|
||||||
|
if (editing.value) await store.update(editing.value.id, form.value);
|
||||||
|
else await store.create(form.value);
|
||||||
|
showForm.value = false;
|
||||||
|
message.success('已保存');
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(rule: RuleDto) {
|
||||||
|
dialog.warning({
|
||||||
|
title: '删除规则',
|
||||||
|
content: `确认删除这条 ${rule.type} 规则?`,
|
||||||
|
positiveText: '删除',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: () => store.remove(rule.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmReset() {
|
||||||
|
dialog.warning({
|
||||||
|
title: '重置为模板规则',
|
||||||
|
content: '模板来源的规则将恢复为初始状态,你添加的自定义规则会保留。继续?',
|
||||||
|
positiveText: '重置',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
await store.reset();
|
||||||
|
message.success('已重置');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function move(rule: RuleDto, dir: -1 | 1) {
|
||||||
|
const ids = store.rules.map((r) => r.id);
|
||||||
|
const i = ids.indexOf(rule.id);
|
||||||
|
const j = i + dir;
|
||||||
|
if (j < 0 || j >= ids.length) return;
|
||||||
|
[ids[i], ids[j]] = [ids[j], ids[i]];
|
||||||
|
await store.reorder(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePolicy(rule: RuleDto, policy: string) {
|
||||||
|
await store.update(rule.id, {
|
||||||
|
type: rule.type as RuleInput['type'],
|
||||||
|
value: rule.value,
|
||||||
|
policy,
|
||||||
|
params: rule.params,
|
||||||
|
enabled: rule.enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = computed<DataTableColumns<RuleDto>>(() => [
|
||||||
|
{ title: '#', key: 'seq', width: 56, render: (_row, i) => h('span', { class: 'mono', style: 'color:var(--muted);font-size:12px' }, String(i + 1)) },
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
key: 'type',
|
||||||
|
width: 130,
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
NTag,
|
||||||
|
{ size: 'small', bordered: false, type: row.type === 'RULE-SET' || row.type === 'DOMAIN-SET' ? 'info' : 'default' },
|
||||||
|
() => row.type
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '匹配值',
|
||||||
|
key: 'value',
|
||||||
|
ellipsis: { tooltip: true },
|
||||||
|
render: (row) =>
|
||||||
|
row.rulesetName
|
||||||
|
? h('span', { style: 'cursor:pointer', onClick: () => openPreview(row), title: '点击预览规则集内容' }, [
|
||||||
|
h('span', { style: 'text-decoration:underline dotted var(--muted)' }, row.rulesetName),
|
||||||
|
h('span', { class: 'mono', style: 'color:var(--muted);font-size:11px;margin-left:8px' }, row.rulesetUrl ?? ''),
|
||||||
|
])
|
||||||
|
: h('span', { class: 'mono', style: 'font-size:12px' }, row.value ?? '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '策略',
|
||||||
|
key: 'policy',
|
||||||
|
width: 170,
|
||||||
|
render: (row) =>
|
||||||
|
h(NSelect, {
|
||||||
|
size: 'small',
|
||||||
|
value: row.policy,
|
||||||
|
options: policyOptions.value,
|
||||||
|
consistentMenuWidth: false,
|
||||||
|
onUpdateValue: (v: string) => changePolicy(row, v),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '来源',
|
||||||
|
key: 'source',
|
||||||
|
width: 76,
|
||||||
|
render: (row) => h('span', { style: 'color:var(--muted);font-size:12px' }, row.source === 'template' ? '模板' : '自定义'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '启用',
|
||||||
|
key: 'enabled',
|
||||||
|
width: 70,
|
||||||
|
render: (row) => h(NSwitch, { size: 'small', value: row.enabled, onUpdateValue: (v: boolean) => store.toggle(row.id, v) }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
width: 170,
|
||||||
|
render: (row, i) =>
|
||||||
|
h(NSpace, { size: 2 }, () => [
|
||||||
|
h(NButton, { size: 'tiny', quaternary: true, disabled: i === 0, onClick: () => move(row, -1) }, () => '↑'),
|
||||||
|
h(NButton, { size: 'tiny', quaternary: true, disabled: i === store.rules.length - 1, onClick: () => move(row, 1) }, () => '↓'),
|
||||||
|
...(row.type !== 'RULE-SET' && row.type !== 'DOMAIN-SET' && row.type !== 'FINAL'
|
||||||
|
? [h(NButton, { size: 'tiny', quaternary: true, onClick: () => openEdit(row) }, () => '编辑')]
|
||||||
|
: []),
|
||||||
|
...(row.source === 'custom'
|
||||||
|
? [h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmDelete(row) }, () => '删除')]
|
||||||
|
: []),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-head">
|
||||||
|
<h1 class="display page-title">规则</h1>
|
||||||
|
<NSpace>
|
||||||
|
<NButton ghost @click="confirmReset">重置为模板</NButton>
|
||||||
|
<NButton type="primary" @click="openCreate">添加规则</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</div>
|
||||||
|
<p class="hint">规则自上而下匹配。RULE-SET/DOMAIN-SET 引用外部规则集,点击名称可预览内容;末尾 FINAL 是兜底策略。</p>
|
||||||
|
|
||||||
|
<NDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:data="store.rules"
|
||||||
|
:loading="store.loading"
|
||||||
|
:bordered="false"
|
||||||
|
size="small"
|
||||||
|
:row-key="(r: RuleDto) => r.id"
|
||||||
|
:max-height="640"
|
||||||
|
virtual-scroll
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RulesetPreviewDrawer v-model:show="showPreview" :rule="previewRule" />
|
||||||
|
|
||||||
|
<NModal
|
||||||
|
:show="showForm"
|
||||||
|
preset="card"
|
||||||
|
:title="editing ? '编辑规则' : '添加自定义规则'"
|
||||||
|
style="max-width: 480px"
|
||||||
|
@update:show="showForm = $event"
|
||||||
|
>
|
||||||
|
<NForm label-placement="left" label-width="72">
|
||||||
|
<NFormItem label="类型">
|
||||||
|
<NSelect v-model:value="form.type" :options="typeOptions" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="匹配值">
|
||||||
|
<NInput v-model:value="form.value" placeholder="如 example.com / 1.2.3.0/24" class="mono" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="策略">
|
||||||
|
<NSelect v-model:value="form.policy" :options="policyOptions" filterable />
|
||||||
|
</NFormItem>
|
||||||
|
</NForm>
|
||||||
|
<template #footer>
|
||||||
|
<NSpace justify="end">
|
||||||
|
<NButton @click="showForm = false">取消</NButton>
|
||||||
|
<NButton type="primary" @click="submit">保存</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</template>
|
||||||
|
</NModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
92
web/src/pages/SettingsPage.vue
Normal file
92
web/src/pages/SettingsPage.vue
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, watch } from 'vue';
|
||||||
|
import { NButton, NCard, NInput, NSpace, NTabPane, NTabs, useMessage } from 'naive-ui';
|
||||||
|
import { getAdminToken, setAdminToken } from '../api/client';
|
||||||
|
import { useSubscriptionsStore } from '../stores/subscriptions';
|
||||||
|
|
||||||
|
const store = useSubscriptionsStore();
|
||||||
|
const message = useMessage();
|
||||||
|
|
||||||
|
const sections = ref<Record<string, string>>({});
|
||||||
|
const adminToken = ref(getAdminToken());
|
||||||
|
const activeSection = ref('General');
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.fetchAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => store.settings,
|
||||||
|
(s) => {
|
||||||
|
if (!s) return;
|
||||||
|
sections.value = { ...s.surgeSections };
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
async function saveSections() {
|
||||||
|
await store.saveSettings({ surgeSections: sections.value });
|
||||||
|
message.success('已保存');
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAdminToken() {
|
||||||
|
setAdminToken(adminToken.value.trim());
|
||||||
|
message.success(adminToken.value.trim() ? '已保存到本浏览器' : '已清除');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="display page-title">设置</h1>
|
||||||
|
|
||||||
|
<NCard title="管理鉴权" size="small" class="block">
|
||||||
|
<p class="hint">
|
||||||
|
服务端设置了 ADMIN_TOKEN 环境变量时,管理接口需要携带同样的 token。此处保存的值仅存在本浏览器 localStorage。
|
||||||
|
</p>
|
||||||
|
<NSpace>
|
||||||
|
<NInput v-model:value="adminToken" type="password" show-password-on="click" placeholder="ADMIN_TOKEN" class="mono" style="width: 320px" />
|
||||||
|
<NButton @click="saveAdminToken">保存</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</NCard>
|
||||||
|
|
||||||
|
<NCard title="Surge 原文段落" size="small" class="block">
|
||||||
|
<p class="hint">
|
||||||
|
这些段落按原文透传进 Surge 配置([General]、[MITM] 等),此处直接编辑文本。ShadowRocket/ClashMeta 不使用这些内容。
|
||||||
|
</p>
|
||||||
|
<NTabs v-model:value="activeSection" type="line" size="small">
|
||||||
|
<NTabPane v-for="(content, name) in sections" :key="name" :name="String(name)" :tab="String(name)">
|
||||||
|
<NInput
|
||||||
|
v-model:value="sections[String(name)]"
|
||||||
|
type="textarea"
|
||||||
|
:rows="16"
|
||||||
|
class="mono section-editor"
|
||||||
|
/>
|
||||||
|
</NTabPane>
|
||||||
|
</NTabs>
|
||||||
|
<NButton type="primary" style="margin-top: 12px" @click="saveSections">保存段落</NButton>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
max-width: 860px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-editor :deep(textarea) {
|
||||||
|
font-family: var(--font-mono) !important;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
404
web/src/pages/SubscriptionsPage.vue
Normal file
404
web/src/pages/SubscriptionsPage.vue
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onMounted, ref, watch } from 'vue';
|
||||||
|
import { NButton, NPopover, NSpace, NSwitch, useDialog, useMessage } from 'naive-ui';
|
||||||
|
import { SHADOWROCKET_SUB_NAME, type TokenDto } from '@proxy-station/shared';
|
||||||
|
import { useSubscriptionsStore } from '../stores/subscriptions';
|
||||||
|
import { useNodesStore } from '../stores/nodes';
|
||||||
|
import { copyText } from '../utils/clipboard';
|
||||||
|
import TokenFormModal from '../components/TokenFormModal.vue';
|
||||||
|
|
||||||
|
const store = useSubscriptionsStore();
|
||||||
|
const nodesStore = useNodesStore();
|
||||||
|
const message = useMessage();
|
||||||
|
const dialog = useDialog();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.fetchAll();
|
||||||
|
nodesStore.fetchAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
const showForm = ref(false);
|
||||||
|
const editing = ref<TokenDto | null>(null);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editing.value = null;
|
||||||
|
showForm.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(token: TokenDto) {
|
||||||
|
editing.value = token;
|
||||||
|
showForm.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(payload: { name: string; nodeIds: string[] }, id?: string) {
|
||||||
|
try {
|
||||||
|
if (id) await store.updateToken(id, payload);
|
||||||
|
else await store.createToken(payload);
|
||||||
|
showForm.value = false;
|
||||||
|
message.success(id ? '已保存' : '订阅已创建');
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(token: TokenDto) {
|
||||||
|
dialog.warning({
|
||||||
|
title: '删除订阅',
|
||||||
|
content: `删除「${token.name}」后,使用该 URL 的客户端将无法再更新配置。`,
|
||||||
|
positiveText: '删除',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: () => store.removeToken(token.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 订阅完整 URL:当前页面 origin + 后端返回的路径 */
|
||||||
|
function fullUrl(path: string): string {
|
||||||
|
return `${window.location.origin}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 节点范围文案:空数组表示不限制 */
|
||||||
|
function scopeLabel(t: TokenDto): string {
|
||||||
|
const total = nodesStore.nodes.filter((n) => n.enabled).length;
|
||||||
|
return t.nodeIds.length === 0 ? `全部节点 · ${total}` : `${t.nodeIds.length} / ${total} 节点`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy(text: string) {
|
||||||
|
if (await copyText(text)) {
|
||||||
|
message.success('已复制');
|
||||||
|
} else {
|
||||||
|
manualCopyText.value = text;
|
||||||
|
message.warning('浏览器阻止了自动复制,请手动复制下方链接');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自动复制失败时的兜底展示 */
|
||||||
|
const manualCopyText = ref('');
|
||||||
|
const manualCopyInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
watch(manualCopyText, async (v) => {
|
||||||
|
if (!v) return;
|
||||||
|
await nextTick();
|
||||||
|
manualCopyInput.value?.focus();
|
||||||
|
manualCopyInput.value?.select();
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-head">
|
||||||
|
<h1 class="display page-title">订阅</h1>
|
||||||
|
<NButton type="primary" @click="openCreate">新建订阅</NButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="token-grid">
|
||||||
|
<article v-for="t in store.tokens" :key="t.id" class="token" :class="{ off: !t.enabled }">
|
||||||
|
<header class="token-head">
|
||||||
|
<div class="token-title">
|
||||||
|
<span class="token-name">{{ t.name }}</span>
|
||||||
|
<span class="token-scope">{{ scopeLabel(t) }}</span>
|
||||||
|
</div>
|
||||||
|
<NSpace size="small" align="center">
|
||||||
|
<NSwitch size="small" :value="t.enabled" @update:value="(v: boolean) => store.toggleToken(t.id, v)" />
|
||||||
|
<NButton size="tiny" quaternary @click="openEdit(t)">编辑</NButton>
|
||||||
|
<NButton size="tiny" quaternary type="error" @click="confirmDelete(t)">删除</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="rows">
|
||||||
|
<div class="row">
|
||||||
|
<span class="row-client">Surge<span class="row-os">macOS</span></span>
|
||||||
|
<code class="mono row-url">{{ fullUrl(t.urls.surge) }}</code>
|
||||||
|
<NButton size="tiny" quaternary @click="copy(fullUrl(t.urls.surge))">复制</NButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<span class="row-client">
|
||||||
|
ShadowRocket<span class="row-os">iOS</span>
|
||||||
|
<NPopover trigger="hover" placement="bottom-start" style="max-width: 320px">
|
||||||
|
<template #trigger>
|
||||||
|
<button class="info" aria-label="导入说明">?</button>
|
||||||
|
</template>
|
||||||
|
<p class="pop-title">分两步导入</p>
|
||||||
|
<p class="pop-body">
|
||||||
|
① 在 App 的「订阅」里添加节点链接,并把订阅命名为
|
||||||
|
<code class="mono pop-code">{{ SHADOWROCKET_SUB_NAME }}</code>。
|
||||||
|
配置文件按这个名字引用节点,名字必须完全一致。
|
||||||
|
</p>
|
||||||
|
<p class="pop-body">② 在「配置」里添加规则链接并启用。</p>
|
||||||
|
</NPopover>
|
||||||
|
</span>
|
||||||
|
<div class="row-split">
|
||||||
|
<div class="split-line">
|
||||||
|
<span class="split-tag">节点</span>
|
||||||
|
<code class="mono row-url">{{ fullUrl(t.urls.shadowrocket) }}</code>
|
||||||
|
<NButton size="tiny" quaternary @click="copy(fullUrl(t.urls.shadowrocket))">复制</NButton>
|
||||||
|
</div>
|
||||||
|
<div class="split-line">
|
||||||
|
<span class="split-tag">规则</span>
|
||||||
|
<code class="mono row-url">{{ fullUrl(t.urls.shadowrocketConf) }}</code>
|
||||||
|
<NButton size="tiny" quaternary @click="copy(fullUrl(t.urls.shadowrocketConf))">复制</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<span class="row-client">ClashMeta<span class="row-os">Android</span></span>
|
||||||
|
<code class="mono row-url">{{ fullUrl(t.urls.clash) }}</code>
|
||||||
|
<NButton size="tiny" quaternary @click="copy(fullUrl(t.urls.clash))">复制</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="token-foot">
|
||||||
|
最近访问 {{ t.lastAccessAt ? new Date(t.lastAccessAt).toLocaleString() : '从未' }} · 共 {{ t.accessCount }} 次
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 自动复制失败时的兜底:全选好的输入框,长按或 Cmd+C 即可 -->
|
||||||
|
<div v-if="manualCopyText" class="manual-copy">
|
||||||
|
<span class="manual-copy-label">手动复制这条链接:</span>
|
||||||
|
<input
|
||||||
|
ref="manualCopyInput"
|
||||||
|
class="mono manual-copy-input"
|
||||||
|
:value="manualCopyText"
|
||||||
|
readonly
|
||||||
|
@focus="($event.target as HTMLInputElement).select()"
|
||||||
|
/>
|
||||||
|
<NButton size="tiny" quaternary @click="manualCopyText = ''">关闭</NButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TokenFormModal v-model:show="showForm" :token="editing" :nodes="nodesStore.nodes" @submit="handleSubmit" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(560px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.off {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 13px 18px;
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 节点范围徽标 */
|
||||||
|
.token-scope {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--warp);
|
||||||
|
background: var(--warp-dim);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows {
|
||||||
|
padding: 6px 18px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row + .row {
|
||||||
|
border-top: 1px solid var(--line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-client {
|
||||||
|
width: 128px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-os {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex: 0 0 16px; /* 不参与 flex 收缩,否则会被压成椭圆 */
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: help;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-url {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 5px 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ShadowRocket 的两条链接 */
|
||||||
|
.row-split {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
width: 26px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-foot {
|
||||||
|
padding: 9px 18px;
|
||||||
|
border-top: 1px solid var(--line-soft);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-title {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-body {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-code {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-copy {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--direct-dim);
|
||||||
|
border: 1px solid var(--direct);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-copy-label {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--direct);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-copy-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.token-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-client {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
13
web/src/router.ts
Normal file
13
web/src/router.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', redirect: '/nodes' },
|
||||||
|
{ path: '/nodes', component: () => import('./pages/NodesPage.vue'), meta: { title: '节点' } },
|
||||||
|
{ path: '/groups', component: () => import('./pages/GroupsPage.vue'), meta: { title: '策略组' } },
|
||||||
|
{ path: '/rules', component: () => import('./pages/RulesPage.vue'), meta: { title: '规则' } },
|
||||||
|
{ path: '/subscriptions', component: () => import('./pages/SubscriptionsPage.vue'), meta: { title: '订阅' } },
|
||||||
|
{ path: '/settings', component: () => import('./pages/SettingsPage.vue'), meta: { title: '设置' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
41
web/src/stores/groups.ts
Normal file
41
web/src/stores/groups.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import type { GroupDto, GroupInput } from '@proxy-station/shared';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
|
||||||
|
export const useGroupsStore = defineStore('groups', {
|
||||||
|
state: () => ({
|
||||||
|
groups: [] as GroupDto[],
|
||||||
|
loading: false,
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async fetchAll() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
this.groups = await api.get<GroupDto[]>('/api/groups');
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async create(input: GroupInput) {
|
||||||
|
await api.post('/api/groups', input);
|
||||||
|
await this.fetchAll();
|
||||||
|
},
|
||||||
|
async update(id: string, input: GroupInput) {
|
||||||
|
await api.put(`/api/groups/${id}`, input);
|
||||||
|
await this.fetchAll();
|
||||||
|
},
|
||||||
|
async remove(id: string) {
|
||||||
|
await api.delete(`/api/groups/${id}`);
|
||||||
|
this.groups = this.groups.filter((g) => g.id !== id);
|
||||||
|
},
|
||||||
|
async toggle(id: string, enabled: boolean) {
|
||||||
|
await api.patch(`/api/groups/${id}/enabled`, { enabled });
|
||||||
|
const group = this.groups.find((g) => g.id === id);
|
||||||
|
if (group) group.enabled = enabled;
|
||||||
|
},
|
||||||
|
async reorder(ids: string[]) {
|
||||||
|
await api.put('/api/groups/reorder', { ids });
|
||||||
|
await this.fetchAll();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
53
web/src/stores/nodes.ts
Normal file
53
web/src/stores/nodes.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import type { NodeDto, NodeInput } from '@proxy-station/shared';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
committed: boolean;
|
||||||
|
total: number;
|
||||||
|
succeeded: number;
|
||||||
|
items: { ok: boolean; uri: string; error?: string; node?: Partial<NodeDto> & { name: string } }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useNodesStore = defineStore('nodes', {
|
||||||
|
state: () => ({
|
||||||
|
nodes: [] as NodeDto[],
|
||||||
|
loading: false,
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async fetchAll() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
this.nodes = await api.get<NodeDto[]>('/api/nodes');
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async create(input: NodeInput) {
|
||||||
|
await api.post('/api/nodes', input);
|
||||||
|
await this.fetchAll();
|
||||||
|
},
|
||||||
|
async update(id: string, input: NodeInput) {
|
||||||
|
await api.put(`/api/nodes/${id}`, input);
|
||||||
|
await this.fetchAll();
|
||||||
|
},
|
||||||
|
async remove(id: string) {
|
||||||
|
await api.delete(`/api/nodes/${id}`);
|
||||||
|
this.nodes = this.nodes.filter((n) => n.id !== id);
|
||||||
|
},
|
||||||
|
async toggle(id: string, enabled: boolean) {
|
||||||
|
await api.patch(`/api/nodes/${id}/enabled`, { enabled });
|
||||||
|
const node = this.nodes.find((n) => n.id === id);
|
||||||
|
if (node) node.enabled = enabled;
|
||||||
|
},
|
||||||
|
async importText(text: string, commit: boolean): Promise<ImportResult> {
|
||||||
|
const result = await api.post<ImportResult>('/api/nodes/import', { text, commit });
|
||||||
|
if (commit) await this.fetchAll();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async exportUri(id: string): Promise<string> {
|
||||||
|
const { uri } = await api.get<{ uri: string }>(`/api/nodes/${id}/uri`);
|
||||||
|
return uri;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
49
web/src/stores/rules.ts
Normal file
49
web/src/stores/rules.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import type { RuleDto, RuleInput, RulesetDto } from '@proxy-station/shared';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
|
||||||
|
export const useRulesStore = defineStore('rules', {
|
||||||
|
state: () => ({
|
||||||
|
rules: [] as RuleDto[],
|
||||||
|
rulesets: [] as RulesetDto[],
|
||||||
|
loading: false,
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async fetchAll() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
[this.rules, this.rulesets] = await Promise.all([
|
||||||
|
api.get<RuleDto[]>('/api/rules'),
|
||||||
|
api.get<RulesetDto[]>('/api/rulesets'),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async create(input: RuleInput) {
|
||||||
|
await api.post('/api/rules', input);
|
||||||
|
await this.fetchAll();
|
||||||
|
},
|
||||||
|
async update(id: string, input: RuleInput) {
|
||||||
|
await api.put(`/api/rules/${id}`, input);
|
||||||
|
await this.fetchAll();
|
||||||
|
},
|
||||||
|
async remove(id: string) {
|
||||||
|
await api.delete(`/api/rules/${id}`);
|
||||||
|
this.rules = this.rules.filter((r) => r.id !== id);
|
||||||
|
},
|
||||||
|
async toggle(id: string, enabled: boolean) {
|
||||||
|
await api.patch(`/api/rules/${id}/enabled`, { enabled });
|
||||||
|
const rule = this.rules.find((r) => r.id === id);
|
||||||
|
if (rule) rule.enabled = enabled;
|
||||||
|
},
|
||||||
|
async reorder(ids: string[]) {
|
||||||
|
await api.put('/api/rules/reorder', { ids });
|
||||||
|
await this.fetchAll();
|
||||||
|
},
|
||||||
|
async reset() {
|
||||||
|
await api.post('/api/rules/reset');
|
||||||
|
await this.fetchAll();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
49
web/src/stores/subscriptions.ts
Normal file
49
web/src/stores/subscriptions.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import type { TokenDto } from '@proxy-station/shared';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
|
||||||
|
export interface SettingsData {
|
||||||
|
surgeSections: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSubscriptionsStore = defineStore('subscriptions', {
|
||||||
|
state: () => ({
|
||||||
|
tokens: [] as TokenDto[],
|
||||||
|
settings: null as SettingsData | null,
|
||||||
|
loading: false,
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async fetchAll() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
[this.tokens, this.settings] = await Promise.all([
|
||||||
|
api.get<TokenDto[]>('/api/tokens'),
|
||||||
|
api.get<SettingsData>('/api/settings'),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async createToken(payload: { name: string; nodeIds: string[] }) {
|
||||||
|
await api.post('/api/tokens', payload);
|
||||||
|
await this.fetchAll();
|
||||||
|
},
|
||||||
|
async updateToken(id: string, payload: { name: string; nodeIds: string[] }) {
|
||||||
|
await api.put(`/api/tokens/${id}`, payload);
|
||||||
|
await this.fetchAll();
|
||||||
|
},
|
||||||
|
async removeToken(id: string) {
|
||||||
|
await api.delete(`/api/tokens/${id}`);
|
||||||
|
this.tokens = this.tokens.filter((t) => t.id !== id);
|
||||||
|
},
|
||||||
|
async toggleToken(id: string, enabled: boolean) {
|
||||||
|
await api.patch(`/api/tokens/${id}/enabled`, { enabled });
|
||||||
|
const token = this.tokens.find((t) => t.id === id);
|
||||||
|
if (token) token.enabled = enabled;
|
||||||
|
},
|
||||||
|
async saveSettings(patch: Partial<SettingsData>) {
|
||||||
|
await api.put('/api/settings', patch);
|
||||||
|
await this.fetchAll();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
98
web/src/styles/tokens.css
Normal file
98
web/src/styles/tokens.css
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/* 站牌导视(轨道交通导视系统):白底粗黑、线路色圆标、站名牌。
|
||||||
|
颜色语义:琥珀 = direct 直连出口,青 = WARP 净出口;协议各有线路色。 */
|
||||||
|
:root {
|
||||||
|
--bg: #f7f7f4;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--panel-raised: #ffffff;
|
||||||
|
--line: #e2e2dd;
|
||||||
|
--line-soft: #ecece8;
|
||||||
|
--direct: #b26a00;
|
||||||
|
--direct-dim: #faf1e2;
|
||||||
|
--warp: #007a72;
|
||||||
|
--warp-dim: #e2f2f0;
|
||||||
|
--text: #212529;
|
||||||
|
--muted: #6e7073;
|
||||||
|
--danger: #c0392b;
|
||||||
|
/* 重点色:深藏青(站名牌、主按钮、导航下划线),比纯黑柔和 */
|
||||||
|
--ink: #2b3f5c;
|
||||||
|
--ink-hover: #24354e;
|
||||||
|
|
||||||
|
/* 协议线路色(导视系统的线路标识) */
|
||||||
|
--proto-vmess: #275ad1;
|
||||||
|
--proto-trojan: #8348c7;
|
||||||
|
--proto-shadowsocks: #2f7d3b;
|
||||||
|
--proto-hysteria2: #c23d5d;
|
||||||
|
|
||||||
|
--font-body: -apple-system, 'PingFang SC', 'Helvetica Neue', 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页面大标题:导视级粗黑 */
|
||||||
|
.display {
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 导视小标签:加粗 + 字距 */
|
||||||
|
.label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.egress-direct {
|
||||||
|
color: var(--direct);
|
||||||
|
}
|
||||||
|
|
||||||
|
.egress-warp {
|
||||||
|
color: var(--warp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 语义圆点(图例、标签内) */
|
||||||
|
.lamp {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lamp-direct {
|
||||||
|
background: var(--direct);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lamp-warp {
|
||||||
|
background: var(--warp);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lamp-off {
|
||||||
|
background: #b9bab5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--text);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
web/src/theme.ts
Normal file
46
web/src/theme.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { GlobalThemeOverrides } from 'naive-ui';
|
||||||
|
|
||||||
|
/* 站牌导视主题:白底粗黑 + 胶囊按钮 + 墨黑主按钮 */
|
||||||
|
export const themeOverrides: GlobalThemeOverrides = {
|
||||||
|
common: {
|
||||||
|
bodyColor: '#f7f7f4',
|
||||||
|
cardColor: '#ffffff',
|
||||||
|
modalColor: '#ffffff',
|
||||||
|
popoverColor: '#ffffff',
|
||||||
|
tableColor: '#ffffff',
|
||||||
|
tableHeaderColor: '#ffffff',
|
||||||
|
borderColor: '#e2e2dd',
|
||||||
|
dividerColor: '#ecece8',
|
||||||
|
primaryColor: '#2b3f5c',
|
||||||
|
primaryColorHover: '#24354e',
|
||||||
|
primaryColorPressed: '#1d2b40',
|
||||||
|
primaryColorSuppl: '#2b3f5c',
|
||||||
|
infoColor: '#275ad1',
|
||||||
|
successColor: '#007a72',
|
||||||
|
warningColor: '#b26a00',
|
||||||
|
warningColorHover: '#9a5c00',
|
||||||
|
errorColor: '#c0392b',
|
||||||
|
textColorBase: '#212529',
|
||||||
|
textColor1: '#212529',
|
||||||
|
textColor2: '#3c3e42',
|
||||||
|
textColor3: '#6e7073',
|
||||||
|
fontFamily: "-apple-system, 'PingFang SC', 'Helvetica Neue', 'Segoe UI', sans-serif",
|
||||||
|
fontFamilyMono: "'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace",
|
||||||
|
borderRadius: '10px',
|
||||||
|
borderRadiusSmall: '6px',
|
||||||
|
},
|
||||||
|
Button: {
|
||||||
|
fontWeight: '600',
|
||||||
|
borderRadiusMedium: '999px',
|
||||||
|
borderRadiusSmall: '999px',
|
||||||
|
borderRadiusTiny: '999px',
|
||||||
|
borderRadiusLarge: '999px',
|
||||||
|
},
|
||||||
|
Tag: {
|
||||||
|
borderRadius: '999px',
|
||||||
|
},
|
||||||
|
DataTable: {
|
||||||
|
thFontWeight: '700',
|
||||||
|
thTextColor: '#6e7073',
|
||||||
|
},
|
||||||
|
};
|
||||||
45
web/src/utils/clipboard.ts
Normal file
45
web/src/utils/clipboard.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* 复制文本到剪贴板。
|
||||||
|
*
|
||||||
|
* navigator.clipboard 仅在安全上下文(HTTPS 或 localhost)可用,
|
||||||
|
* 通过局域网 IP 以 HTTP 访问时它是 undefined,此时回落到 execCommand。
|
||||||
|
*/
|
||||||
|
export async function copyText(text: string): Promise<boolean> {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// 继续尝试回落方案
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
// 置于视口内但不可见,否则 iOS Safari 不会执行复制
|
||||||
|
ta.setAttribute('readonly', '');
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.top = '0';
|
||||||
|
ta.style.left = '0';
|
||||||
|
ta.style.width = '1px';
|
||||||
|
ta.style.height = '1px';
|
||||||
|
ta.style.padding = '0';
|
||||||
|
ta.style.border = 'none';
|
||||||
|
ta.style.outline = 'none';
|
||||||
|
ta.style.boxShadow = 'none';
|
||||||
|
ta.style.background = 'transparent';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
ta.setSelectionRange(0, text.length); // iOS 需要显式指定范围
|
||||||
|
|
||||||
|
const ok = document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
return ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
web/src/utils/nodeName.ts
Normal file
12
web/src/utils/nodeName.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { NodeDto } from '@proxy-station/shared';
|
||||||
|
|
||||||
|
/** 名字末尾的出口词:Direct / Warp / 直连 / WARP,前面可以是空格、点号或连字符 */
|
||||||
|
const TRAILING_EGRESS = /[\s·・_-]+(direct|warp|直连)\s*$/iu;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 界面上与出口标签并排时的节点名:剥掉名字里的出口词,避免和标签重复。
|
||||||
|
* 只影响显示——下发给客户端的名字保持原样,用户要靠它区分同协议的两条线路。
|
||||||
|
*/
|
||||||
|
export function nodeDisplayName(node: Pick<NodeDto, 'name'>): string {
|
||||||
|
return node.name.replace(TRAILING_EGRESS, '').trim();
|
||||||
|
}
|
||||||
13
web/tsconfig.json
Normal file
13
web/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
16
web/vite.config.ts
Normal file
16
web/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
// 监听 0.0.0.0:局域网设备可访问开发中的管理界面
|
||||||
|
host: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000',
|
||||||
|
// 精确到 /sub/,避免把前端路由 /subscriptions 也代理走
|
||||||
|
'^/sub/': 'http://localhost:3000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user