Revert "面板"

This reverts commit 564042a8cc.
This commit is contained in:
riglen
2026-04-20 14:42:19 +08:00
parent 7c7c233a2a
commit d30018ed71
14 changed files with 86 additions and 1401 deletions

View File

@@ -8,11 +8,6 @@ DATABASE_URL=sqlite:////app/data/app.db
# Docker 运行时数据目录,建议放在仓库外
APP_DATA_DIR=../sub-provider-data
# 管理面板鉴权;留空则不启用登录校验
ADMIN_TOKEN=
ADMIN_SESSION_SECRET=change-this-admin-session-secret
ADMIN_SESSION_MAX_AGE=86400
# 对外访问前缀,尽量改成足够长的随机字符串
PUBLIC_PATH=change-me-random-hash-path

View File

@@ -76,8 +76,6 @@ cp .env.example .env
- `APP_DATA_DIR` 建议指向仓库外目录,例如 `../sub-provider-data`
- `PUBLIC_PATH` 改成足够长的随机字符串
- `PUBLIC_BASE_URL` 建议填写你反代后的最终访问地址,例如 `https://sub.example.com`
- `ADMIN_TOKEN` 可选;配置后管理面板会启用登录鉴权,不配置则不校验登录
- `ADMIN_SESSION_SECRET` 用于签发管理面板 session部署时应改成随机长字符串
- `AIRPORT_A_URL` / `AIRPORT_B_URL` / `AIRPORT_C_URL` 都可以直接填订阅地址,项目会自动判断是 YAML 还是 URI 订阅
- 也可以直接填本地文件路径,例如 `/app/data/sources/airport-b.txt``file:///app/data/sources/airport-b.txt`
- 允许把其中一个留空;留空时这个机场会自动跳过
@@ -114,7 +112,6 @@ docker compose up -d --build
6. 访问检查:
- 健康检查:`http://YOUR_HOST:18080/healthz`
- 管理面板:`http://YOUR_HOST:18080/admin`
- 单 provider
`https://YOUR_DOMAIN/<PUBLIC_PATH>/providers/airport-a.yaml`
- merged provider
@@ -128,34 +125,6 @@ docker compose up -d --build
- Stash bundle
`https://YOUR_DOMAIN/<PUBLIC_PATH>/bundle/stash.yaml?sources=airport-a,airport-b,airport-c`
### 管理面板(最小版本)
当前已提供一版服务端渲染的最小管理面板:
- `/admin/sources`
- `/admin/profiles`
- `/admin/profiles/{profile_key}/sources`
- `/admin/profiles/{profile_key}/groups`
- `/admin/profiles/{profile_key}/rules`
- `/admin/profiles/{profile_key}/preview`
当前能力范围:
- Source 增删改
- Profile 基础字段编辑
- Profile 默认 source 绑定与顺序编辑
- Profile group 新增、删除、基础字段编辑
- Profile 规则启停、顺序、目标策略组编辑
- 预览最终 bundle YAML
鉴权方式:
-`.env` 中设置 `ADMIN_TOKEN`
- `docker compose` 会自动把 `.env` 注入容器
- 访问 `/admin` 时先进入登录页,提交 token 后写入 session cookie
- session 使用 `ADMIN_SESSION_SECRET` 签名,默认有效期由 `ADMIN_SESSION_MAX_AGE` 控制
- 如果 `ADMIN_TOKEN` 留空,管理面板默认不启用登录拦截
---
## 接口说明

View File

@@ -28,9 +28,6 @@ class Settings(BaseSettings):
default_user_agent: str = "sub-provider/0.2"
database_url: str = Field(default=f"sqlite:///{(DATA_DIR / 'app.db').resolve().as_posix()}")
database_echo: bool = False
admin_token: str | None = None
admin_session_secret: str = "change-this-admin-session-secret"
admin_session_max_age: int = 86400
config_dir: Path = CONFIG_DIR
sources_file: Path = CONFIG_DIR / "sources.yaml"

View File

@@ -1,32 +1,13 @@
from __future__ import annotations
import hmac
import logging
from urllib.parse import quote
from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import Response
from app.config import get_settings
from app.models import RuleConfig, SourceConfig, SourceSnapshot
from app.services.bundle_cache import build_bundle_cache_key, load_bundle_cache, save_bundle_cache
from app.services.config_store import (
delete_source,
get_profile_default_source_keys,
list_profile_groups,
list_profile_rule_bindings,
list_profile_source_bindings,
list_profiles,
list_sources,
load_profile_app_config_from_db,
replace_profile_groups,
save_profile,
save_source,
update_profile_source_bindings,
update_profile_rule_bindings,
)
from app.services.loader import load_app_config
from app.services.profiles import build_bundle_profile, build_thin_profile, dump_yaml
from app.services.rules import load_rule_text
@@ -41,17 +22,8 @@ from app.services.subscriptions import (
settings = get_settings()
logger = logging.getLogger(__name__)
app = FastAPI(title=settings.app_name)
app.add_middleware(
SessionMiddleware,
secret_key=settings.admin_session_secret,
session_cookie="sub_provider_admin",
max_age=settings.admin_session_max_age,
same_site="lax",
https_only=False,
)
app_config = load_app_config(settings.sources_file)
PUBLIC_PREFIX = "/" + (app_config.public_path or settings.public_path).strip("/")
templates = Jinja2Templates(directory=str(settings.config_dir.parent / "templates"))
@app.get("/healthz")
@@ -65,58 +37,9 @@ def _base_url(request: Request) -> str:
return str(request.base_url).rstrip("/")
def _admin_auth_enabled() -> bool:
return bool((settings.admin_token or "").strip())
def _is_admin_authenticated(request: Request) -> bool:
return not _admin_auth_enabled() or request.session.get("admin_authenticated") is True
def _set_flash(request: Request, message: str, level: str = "info") -> None:
request.session["_flash"] = {"message": message, "level": level}
def _pop_flash(request: Request):
return request.session.pop("_flash", None)
def _admin_context(request: Request, *, active: str, **extra):
return {
"request": request,
"active": active,
"flash": _pop_flash(request),
"auth_enabled": _admin_auth_enabled(),
"authenticated": _is_admin_authenticated(request),
**extra,
}
def _admin_guard(request: Request) -> RedirectResponse | None:
if _is_admin_authenticated(request):
return None
next_path = quote(str(request.url.path))
return _redirect(f"/admin/login?next={next_path}")
def _current_app_config(profile_key: str | None = None):
if profile_key:
profile_config = load_profile_app_config_from_db(profile_key)
if profile_config is not None:
return profile_config
return load_app_config(settings.sources_file)
def _resolve_sources(sources: str | None, profile_key: str | None = None) -> list[tuple[str, SourceConfig]]:
current_config = _current_app_config(profile_key=profile_key)
enabled = [(name, src) for name, src in current_config.sources.items() if src.enabled and str(src.url).strip()]
def _resolve_sources(sources: str | None) -> list[tuple[str, SourceConfig]]:
enabled = [(name, src) for name, src in app_config.sources.items() if src.enabled and str(src.url).strip()]
if not sources:
if profile_key:
default_keys = get_profile_default_source_keys(profile_key)
if default_keys:
selected = [(name, current_config.sources[name]) for name in default_keys if name in current_config.sources]
logger.info("resolve_sources profile default: profile=%s selected=%s", profile_key, [name for name, _ in selected])
return selected
logger.info("resolve_sources default: selected=%s", [name for name, _ in enabled])
return enabled
@@ -126,7 +49,7 @@ def _resolve_sources(sources: str | None, profile_key: str | None = None) -> lis
for name in names:
if name in seen:
continue
source = current_config.sources.get(name)
source = app_config.sources.get(name)
if source is None or not source.enabled or not str(source.url).strip():
raise HTTPException(status_code=404, detail=f"source not found or disabled: {name}")
selected.append((name, source))
@@ -192,8 +115,7 @@ async def merged_provider(request: Request, sources: str | None = Query(default=
@app.api_route(PUBLIC_PREFIX + "/providers/{name}.yaml", methods=["GET", "HEAD"])
async def provider(name: str, request: Request) -> Response:
current_config = _current_app_config()
source = current_config.sources.get(name)
source = app_config.sources.get(name)
if source is None or not source.enabled:
raise HTTPException(status_code=404, detail="provider not found")
@@ -210,8 +132,7 @@ async def provider(name: str, request: Request) -> Response:
@app.api_route(PUBLIC_PREFIX + "/rules/{name}.yaml", methods=["GET", "HEAD"])
async def rule_file(name: str, request: Request) -> Response:
current_config = _current_app_config()
rule = current_config.rules.get(name)
rule = app_config.rules.get(name)
if rule is None:
raise HTTPException(status_code=404, detail="rule not found")
content = load_rule_text(_rule_path(rule))
@@ -220,19 +141,18 @@ async def rule_file(name: str, request: Request) -> Response:
@app.api_route(PUBLIC_PREFIX + "/clients/{client_type}.yaml", methods=["GET", "HEAD"])
async def client_profile(client_type: str, request: Request, sources: str | None = Query(default=None)) -> Response:
current_config = _current_app_config(profile_key=client_type)
client = current_config.clients.get(client_type)
client = app_config.clients.get(client_type)
if client is None:
raise HTTPException(status_code=404, detail="client config not found")
source_items = _resolve_sources(sources, profile_key=client_type)
source_items = _resolve_sources(sources)
content = dump_yaml(
build_thin_profile(
client_type=client_type,
app_config=current_config,
app_config=app_config,
selected_source_names=[name for name, _ in source_items],
base_url=_base_url(request),
public_path=(current_config.public_path or settings.public_path).strip("/"),
public_path=(app_config.public_path or settings.public_path).strip("/"),
)
)
headers = {"profile-update-interval": str(client.provider_interval)}
@@ -247,12 +167,11 @@ async def bundle_profile(
sources: str | None = Query(default=None),
force_refresh: bool = Query(default=False),
) -> Response:
current_config = _current_app_config(profile_key=client_type)
client = current_config.clients.get(client_type)
client = app_config.clients.get(client_type)
if client is None:
raise HTTPException(status_code=404, detail="client config not found")
source_items = _resolve_sources(sources, profile_key=client_type)
source_items = _resolve_sources(sources)
cache_key = build_bundle_cache_key(client_type=client_type, source_names=[name for name, _ in source_items])
if not force_refresh:
cached = load_bundle_cache(
@@ -278,7 +197,7 @@ async def bundle_profile(
content = dump_yaml(
build_bundle_profile(
client_type=client_type,
app_config=current_config,
app_config=app_config,
snapshots=snapshots,
)
)
@@ -294,355 +213,3 @@ async def bundle_profile(
headers={key: value for key, value in headers.items() if key != "X-Sub-Provider-Bundle-Cache"},
)
return _yaml_response(content, request, headers=headers, filename=f"bundle-{client_type}.yaml")
def _redirect(url: str) -> RedirectResponse:
return RedirectResponse(url=url, status_code=303)
@app.get("/admin/login", response_class=HTMLResponse)
async def admin_login(request: Request, next: str = Query(default="/admin/sources")) -> HTMLResponse:
if _is_admin_authenticated(request):
return _redirect(next)
return templates.TemplateResponse(
request,
"login.html",
_admin_context(request, active="", next=next),
)
@app.post("/admin/login")
async def admin_login_submit(
request: Request,
token: str = Form(default=""),
next: str = Form(default="/admin/sources"),
) -> Response:
if not _admin_auth_enabled():
request.session["admin_authenticated"] = True
return _redirect(next)
if hmac.compare_digest((settings.admin_token or "").strip(), token.strip()):
request.session["admin_authenticated"] = True
_set_flash(request, "登录成功", "success")
return _redirect(next)
_set_flash(request, "Token 不正确", "error")
return _redirect(f"/admin/login?next={quote(next)}")
@app.post("/admin/logout")
async def admin_logout(request: Request) -> Response:
request.session.clear()
return _redirect("/admin/login")
@app.get("/admin", response_class=HTMLResponse)
async def admin_index(request: Request) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
return _redirect("/admin/sources")
@app.get("/admin/sources", response_class=HTMLResponse)
async def admin_sources(request: Request) -> HTMLResponse:
redirect = _admin_guard(request)
if redirect:
return redirect
return templates.TemplateResponse(
request,
"sources.html",
_admin_context(
request,
active="sources",
sources=list_sources(),
profiles=list_profiles(),
),
)
@app.post("/admin/sources")
async def admin_save_source(
request: Request,
key: str = Form(...),
url: str = Form(...),
kind: str = Form(default="auto"),
display_name: str = Form(default=""),
prefix: str = Form(default=""),
suffix: str = Form(default=""),
include_regex: str = Form(default=""),
exclude_regex: str = Form(default=""),
cache_ttl_seconds: str = Form(default=""),
enabled: str | None = Form(default=None),
) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
save_source(
key=key.strip(),
enabled=enabled is not None,
kind=kind,
url=url.strip(),
display_name=display_name.strip() or None,
headers={},
include_regex=include_regex.strip() or None,
exclude_regex=exclude_regex.strip() or None,
prefix=prefix,
suffix=suffix,
cache_ttl_seconds=int(cache_ttl_seconds) if cache_ttl_seconds.strip() else None,
)
_set_flash(request, f"Source {key.strip()} 已保存", "success")
return _redirect("/admin/sources")
@app.post("/admin/sources/{key}/delete")
async def admin_delete_source(request: Request, key: str) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
delete_source(key)
_set_flash(request, f"Source {key} 已删除", "success")
return _redirect("/admin/sources")
@app.get("/admin/profiles", response_class=HTMLResponse)
async def admin_profiles(request: Request) -> HTMLResponse:
redirect = _admin_guard(request)
if redirect:
return redirect
return templates.TemplateResponse(
request,
"profiles.html",
_admin_context(
request,
active="profiles",
profiles=list_profiles(),
),
)
@app.post("/admin/profiles")
async def admin_save_profile(
request: Request,
key: str = Form(...),
title: str = Form(...),
provider_interval: int = Form(default=21600),
rule_interval: int = Form(default=86400),
test_url: str = Form(...),
test_interval: int = Form(default=300),
main_policy: str = Form(...),
source_policy: str = Form(...),
mixed_auto_policy: str = Form(...),
manual_policy: str = Form(...),
direct_policy: str = Form(...),
mode: str = Form(default="rule"),
allow_lan: str | None = Form(default=None),
ipv6: str | None = Form(default=None),
mixed_port: str = Form(default=""),
socks_port: str = Form(default=""),
log_level: str = Form(default="info"),
) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
save_profile(
key=key.strip(),
title=title.strip(),
provider_interval=provider_interval,
rule_interval=rule_interval,
test_url=test_url.strip(),
test_interval=test_interval,
main_policy=main_policy.strip(),
source_policy=source_policy.strip(),
mixed_auto_policy=mixed_auto_policy.strip(),
manual_policy=manual_policy.strip(),
direct_policy=direct_policy.strip(),
mode=mode.strip(),
allow_lan=allow_lan is not None,
ipv6=ipv6 is not None,
mixed_port=int(mixed_port) if mixed_port.strip() else None,
socks_port=int(socks_port) if socks_port.strip() else None,
log_level=log_level.strip() or None,
)
_set_flash(request, f"Profile {key.strip()} 已保存", "success")
return _redirect("/admin/profiles")
@app.get("/admin/profiles/{profile_key}/sources", response_class=HTMLResponse)
async def admin_profile_sources(request: Request, profile_key: str) -> HTMLResponse:
redirect = _admin_guard(request)
if redirect:
return redirect
return templates.TemplateResponse(
request,
"profile_sources.html",
_admin_context(
request,
active="profiles",
profile_key=profile_key,
bindings=list_profile_source_bindings(profile_key),
),
)
@app.post("/admin/profiles/{profile_key}/sources")
async def admin_update_profile_sources(request: Request, profile_key: str) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
form = await request.form()
keys = form.getlist("source_key")
rows: list[dict] = []
for index, key in enumerate(keys):
rows.append(
{
"key": key,
"enabled": form.get(f"enabled_{key}") is not None,
"order_index": form.get(f"order_index_{key}", str(index)),
}
)
update_profile_source_bindings(profile_key, rows)
_set_flash(request, f"Profile {profile_key} 的默认源已更新", "success")
return _redirect(f"/admin/profiles/{profile_key}/sources")
@app.get("/admin/profiles/{profile_key}/rules", response_class=HTMLResponse)
async def admin_profile_rules(request: Request, profile_key: str) -> HTMLResponse:
redirect = _admin_guard(request)
if redirect:
return redirect
return templates.TemplateResponse(
request,
"rules.html",
_admin_context(
request,
active="profiles",
profile_key=profile_key,
bindings=list_profile_rule_bindings(profile_key),
),
)
@app.get("/admin/profiles/{profile_key}/groups", response_class=HTMLResponse)
async def admin_profile_groups(request: Request, profile_key: str) -> HTMLResponse:
redirect = _admin_guard(request)
if redirect:
return redirect
return templates.TemplateResponse(
request,
"groups.html",
_admin_context(
request,
active="profiles",
profile_key=profile_key,
groups=list_profile_groups(profile_key),
),
)
@app.post("/admin/profiles/{profile_key}/groups")
async def admin_update_profile_groups(request: Request, profile_key: str) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
form = await request.form()
row_ids = form.getlist("group_id")
rows: list[dict] = []
for index, group_id in enumerate(row_ids):
group_key = str(index)
name = str(form.get(f"group_name_{group_key}", "")).strip()
if not str(name).strip():
continue
proxies_raw = str(form.get(f"proxies_{group_key}", "")).strip()
if form.get(f"delete_{group_key}") is not None:
continue
rows.append(
{
"id": int(group_id) if str(group_id).strip() else None,
"name": str(name).strip(),
"group_kind": str(form.get(f"group_kind_{group_key}", "policy")).strip(),
"type": str(form.get(f"type_{group_key}", "select")).strip(),
"order_index": int(str(form.get(f"order_index_{group_key}", index))),
"proxies": [item.strip() for item in proxies_raw.splitlines() if item.strip()],
"filter_regex": str(form.get(f"filter_regex_{group_key}", "")).strip(),
"tolerance": int(str(form.get(f"tolerance_{group_key}", "")).strip()) if str(form.get(f"tolerance_{group_key}", "")).strip() else None,
"url": str(form.get(f"url_{group_key}", "")).strip(),
"interval": int(str(form.get(f"interval_{group_key}", "")).strip()) if str(form.get(f"interval_{group_key}", "")).strip() else None,
"enabled": form.get(f"enabled_{group_key}") is not None,
}
)
new_name = str(form.get("new_group_name", "")).strip()
if new_name:
new_proxies_raw = str(form.get("new_group_proxies", "")).strip()
rows.append(
{
"id": None,
"name": new_name,
"group_kind": str(form.get("new_group_kind", "policy")).strip(),
"type": str(form.get("new_group_type", "select")).strip(),
"order_index": int(str(form.get("new_group_order_index", len(rows))).strip() or len(rows)),
"proxies": [item.strip() for item in new_proxies_raw.splitlines() if item.strip()],
"filter_regex": str(form.get("new_group_filter_regex", "")).strip(),
"tolerance": int(str(form.get("new_group_tolerance", "")).strip()) if str(form.get("new_group_tolerance", "")).strip() else None,
"url": str(form.get("new_group_url", "")).strip(),
"interval": int(str(form.get("new_group_interval", "")).strip()) if str(form.get("new_group_interval", "")).strip() else None,
"enabled": form.get("new_group_enabled") is not None,
}
)
replace_profile_groups(profile_key, rows)
_set_flash(request, f"Profile {profile_key} 的策略组已更新", "success")
return _redirect(f"/admin/profiles/{profile_key}/groups")
@app.post("/admin/profiles/{profile_key}/rules")
async def admin_update_profile_rules(request: Request, profile_key: str) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
form = await request.form()
keys = form.getlist("rule_key")
rows: list[dict] = []
for index, key in enumerate(keys):
rows.append(
{
"key": key,
"enabled": form.get(f"enabled_{key}") is not None,
"order_index": form.get(f"order_index_{key}", str(index)),
"policy": form.get(f"policy_{key}", ""),
}
)
update_profile_rule_bindings(profile_key, rows)
_set_flash(request, f"Profile {profile_key} 的规则绑定已更新", "success")
return _redirect(f"/admin/profiles/{profile_key}/rules")
@app.get("/admin/profiles/{profile_key}/preview", response_class=HTMLResponse)
async def admin_profile_preview(
request: Request,
profile_key: str,
sources: str | None = Query(default=None),
) -> HTMLResponse:
redirect = _admin_guard(request)
if redirect:
return redirect
profile_config = _current_app_config(profile_key=profile_key)
source_items = _resolve_sources(sources, profile_key=profile_key)
selected_names = [name for name, _ in source_items]
snapshots = await build_source_snapshots([(name, profile_config.sources[name]) for name in selected_names])
yaml_text = dump_yaml(
build_bundle_profile(
client_type=profile_key,
app_config=profile_config,
snapshots=snapshots,
)
)
return templates.TemplateResponse(
request,
"preview.html",
_admin_context(
request,
active="profiles",
profile_key=profile_key,
yaml_text=yaml_text,
available_sources=list(profile_config.sources.keys()),
selected_sources=selected_names,
),
)

View File

@@ -82,366 +82,87 @@ def import_yaml_config_to_db(config_path, *, replace_existing: bool = False) ->
def load_app_config_from_db() -> AppConfig | None:
init_db()
with get_session() as session:
return _load_app_config_from_session(session, profile_key=None)
profiles = list(
session.scalars(
select(ProfileORM)
.options(
selectinload(ProfileORM.groups),
selectinload(ProfileORM.rule_links).selectinload(ProfileRuleModuleORM.rule_module),
)
.order_by(ProfileORM.key)
)
)
sources = list(session.scalars(select(SourceORM).order_by(SourceORM.key)))
if not profiles or not sources:
return None
public_path = _setting_value(session, "public_path")
primary_profile = profiles[0]
def load_profile_app_config_from_db(profile_key: str) -> AppConfig | None:
init_db()
with get_session() as session:
return _load_app_config_from_session(session, profile_key=profile_key)
regions: dict[str, RegionConfig] = {}
selector_groups: list[ProxyGroupConfig] = []
policy_groups: list[ProxyGroupConfig] = []
for group in sorted(primary_profile.groups, key=lambda item: (item.order_index, item.id)):
if not group.enabled:
continue
if group.group_kind == "region":
regions[_slugify(group.name)] = RegionConfig(
name=group.name,
filter=group.filter_regex or "",
tolerance=group.tolerance or 50,
)
continue
target = selector_groups if group.group_kind == "selector" else policy_groups
target.append(
ProxyGroupConfig(
name=group.name,
type=group.type,
proxies=_json_loads(group.proxies_json, []),
filter=group.filter_regex,
tolerance=group.tolerance,
url=group.url,
interval=group.interval,
)
)
def list_sources() -> list[SourceORM]:
init_db()
with get_session() as session:
return list(session.scalars(select(SourceORM).order_by(SourceORM.key)))
def save_source(
*,
key: str,
enabled: bool,
kind: str,
url: str,
display_name: str | None,
headers: dict[str, str],
include_regex: str | None,
exclude_regex: str | None,
prefix: str,
suffix: str,
cache_ttl_seconds: int | None,
) -> None:
init_db()
with get_session() as session:
source = _get_or_create_source(session, key)
source.enabled = enabled
source.kind = kind
source.url = url
source.display_name = display_name
source.headers_json = _json_dumps(headers)
source.include_regex = include_regex
source.exclude_regex = exclude_regex
source.prefix = prefix
source.suffix = suffix
source.cache_ttl_seconds = cache_ttl_seconds
session.commit()
def delete_source(key: str) -> None:
init_db()
with get_session() as session:
source = session.scalar(select(SourceORM).where(SourceORM.key == key))
if source is None:
return
session.execute(delete(ProfileSourceORM).where(ProfileSourceORM.source_id == source.id))
session.delete(source)
session.commit()
def list_profiles() -> list[ProfileORM]:
init_db()
with get_session() as session:
return list(session.scalars(select(ProfileORM).order_by(ProfileORM.key)))
def save_profile(
*,
key: str,
title: str,
provider_interval: int,
rule_interval: int,
test_url: str,
test_interval: int,
main_policy: str,
source_policy: str,
mixed_auto_policy: str,
manual_policy: str,
direct_policy: str,
mode: str,
allow_lan: bool,
ipv6: bool,
mixed_port: int | None,
socks_port: int | None,
log_level: str | None,
) -> None:
init_db()
with get_session() as session:
profile = _get_or_create_profile(session, key)
profile.title = title
profile.provider_interval = provider_interval
profile.rule_interval = rule_interval
profile.test_url = test_url
profile.test_interval = test_interval
profile.main_policy = main_policy
profile.source_policy = source_policy
profile.mixed_auto_policy = mixed_auto_policy
profile.manual_policy = manual_policy
profile.direct_policy = direct_policy
profile.mode = mode
profile.allow_lan = allow_lan
profile.ipv6 = ipv6
profile.mixed_port = mixed_port
profile.socks_port = socks_port
profile.log_level = log_level
session.commit()
def list_profile_rule_bindings(profile_key: str) -> list[dict]:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
return []
bindings: list[dict] = []
for link in sorted(profile.rule_links, key=lambda item: (item.order_index, item.id)):
rules: dict[str, RuleConfig] = {}
for link in sorted(primary_profile.rule_links, key=lambda item: (item.order_index, item.id)):
if not link.enabled:
continue
module = link.rule_module
bindings.append(
{
"key": module.key,
"enabled": link.enabled,
"order_index": link.order_index,
"policy": link.policy_override or module.policy,
"file_path": module.file_path,
"behavior": module.behavior,
"format": module.format,
}
rules[module.key] = RuleConfig(
file=module.file_path,
behavior=module.behavior,
format=module.format,
policy=link.policy_override or module.policy,
no_resolve=module.no_resolve if link.no_resolve_override is None else link.no_resolve_override,
payload=_json_loads(link.payload_override_json, _json_loads(module.payload_json, [])),
)
return bindings
def list_profile_source_bindings(profile_key: str) -> list[dict]:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
return []
bindings: list[dict] = []
for link in sorted(profile.source_links, key=lambda item: (item.order_index, item.id)):
bindings.append(
{
"key": link.source.key,
"enabled": link.enabled,
"order_index": link.order_index,
"display_name": link.source.display_name or link.source.key,
"kind": link.source.kind,
"url": link.source.url,
}
)
return bindings
def update_profile_source_bindings(profile_key: str, rows: list[dict]) -> None:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
raise KeyError(f"profile not found: {profile_key}")
source_keys = [row["key"] for row in rows]
sources = list(session.scalars(select(SourceORM).where(SourceORM.key.in_(source_keys))))
source_by_key = {source.key: source for source in sources}
session.execute(delete(ProfileSourceORM).where(ProfileSourceORM.profile_id == profile.id))
for row in rows:
source = source_by_key.get(row["key"])
if source is None:
continue
session.add(
ProfileSourceORM(
profile_id=profile.id,
source_id=source.id,
order_index=int(row["order_index"]),
enabled=bool(row["enabled"]),
return AppConfig(
public_path=public_path or None,
sources={
source.key: SourceConfig(
enabled=source.enabled,
kind=source.kind,
url=source.url,
display_name=source.display_name,
headers=_json_loads(source.headers_json, {}),
include_regex=source.include_regex,
exclude_regex=source.exclude_regex,
prefix=source.prefix,
suffix=source.suffix,
cache_ttl_seconds=source.cache_ttl_seconds,
)
)
session.commit()
def get_profile_default_source_keys(profile_key: str) -> list[str]:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
return []
keys = [
link.source.key
for link in sorted(profile.source_links, key=lambda item: (item.order_index, item.id))
if link.enabled and link.source.enabled and str(link.source.url).strip()
]
return keys
def list_profile_groups(profile_key: str) -> list[dict]:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
return []
rows: list[dict] = []
for group in sorted(profile.groups, key=lambda item: (item.order_index, item.id)):
rows.append(
{
"id": group.id,
"group_kind": group.group_kind,
"name": group.name,
"type": group.type,
"order_index": group.order_index,
"proxies": _json_loads(group.proxies_json, []),
"filter_regex": group.filter_regex or "",
"tolerance": group.tolerance,
"url": group.url or "",
"interval": group.interval,
"enabled": group.enabled,
}
)
return rows
def replace_profile_groups(profile_key: str, rows: list[dict]) -> None:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
raise KeyError(f"profile not found: {profile_key}")
session.execute(delete(PolicyGroupORM).where(PolicyGroupORM.profile_id == profile.id))
for row in rows:
session.add(
PolicyGroupORM(
profile_id=profile.id,
group_kind=row["group_kind"],
name=row["name"],
type=row["type"],
order_index=int(row["order_index"]),
proxies_json=_json_dumps(row["proxies"]) if row["proxies"] else None,
filter_regex=row["filter_regex"] or None,
tolerance=row["tolerance"],
url=row["url"] or None,
interval=row["interval"],
enabled=bool(row["enabled"]),
)
)
session.commit()
def update_profile_rule_bindings(profile_key: str, rows: list[dict]) -> None:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
raise KeyError(f"profile not found: {profile_key}")
module_by_key = {link.rule_module.key: link for link in profile.rule_links}
for row in rows:
link = module_by_key.get(row["key"])
if link is None:
continue
link.enabled = bool(row["enabled"])
link.order_index = int(row["order_index"])
policy = str(row["policy"]).strip()
link.policy_override = policy or None
session.commit()
def _load_app_config_from_session(session: Session, profile_key: str | None) -> AppConfig | None:
profiles = list(
session.scalars(
select(ProfileORM)
.options(
selectinload(ProfileORM.groups),
selectinload(ProfileORM.rule_links).selectinload(ProfileRuleModuleORM.rule_module),
)
.order_by(ProfileORM.key)
for source in sources
},
rules=rules,
clients={profile.key: _profile_to_client_config(profile) for profile in profiles},
regions=regions,
selector_groups=selector_groups,
policy_groups=policy_groups,
)
)
sources = list(session.scalars(select(SourceORM).order_by(SourceORM.key)))
if not profiles or not sources:
return None
target_profile = next((profile for profile in profiles if profile.key == profile_key), profiles[0])
public_path = _setting_value(session, "public_path")
regions, selector_groups, policy_groups = _build_group_configs(target_profile)
rules = _build_rule_configs(target_profile)
return AppConfig(
public_path=public_path or None,
sources={source.key: _source_to_config(source) for source in sources},
rules=rules,
clients={profile.key: _profile_to_client_config(profile) for profile in profiles},
regions=regions,
selector_groups=selector_groups,
policy_groups=policy_groups,
)
def _source_to_config(source: SourceORM) -> SourceConfig:
return SourceConfig(
enabled=source.enabled,
kind=source.kind,
url=source.url,
display_name=source.display_name,
headers=_json_loads(source.headers_json, {}),
include_regex=source.include_regex,
exclude_regex=source.exclude_regex,
prefix=source.prefix,
suffix=source.suffix,
cache_ttl_seconds=source.cache_ttl_seconds,
)
def _build_group_configs(profile: ProfileORM) -> tuple[dict[str, RegionConfig], list[ProxyGroupConfig], list[ProxyGroupConfig]]:
regions: dict[str, RegionConfig] = {}
selector_groups: list[ProxyGroupConfig] = []
policy_groups: list[ProxyGroupConfig] = []
for group in sorted(profile.groups, key=lambda item: (item.order_index, item.id)):
if not group.enabled:
continue
if group.group_kind == "region":
regions[_slugify(group.name)] = RegionConfig(
name=group.name,
filter=group.filter_regex or "",
tolerance=group.tolerance or 50,
)
continue
target = selector_groups if group.group_kind == "selector" else policy_groups
target.append(
ProxyGroupConfig(
name=group.name,
type=group.type,
proxies=_json_loads(group.proxies_json, []),
filter=group.filter_regex,
tolerance=group.tolerance,
url=group.url,
interval=group.interval,
)
)
return regions, selector_groups, policy_groups
def _build_rule_configs(profile: ProfileORM) -> dict[str, RuleConfig]:
rules: dict[str, RuleConfig] = {}
for link in sorted(profile.rule_links, key=lambda item: (item.order_index, item.id)):
if not link.enabled:
continue
module = link.rule_module
rules[module.key] = RuleConfig(
file=module.file_path,
behavior=module.behavior,
format=module.format,
policy=link.policy_override or module.policy,
no_resolve=module.no_resolve if link.no_resolve_override is None else link.no_resolve_override,
payload=_json_loads(link.payload_override_json, _json_loads(module.payload_json, [])),
)
return rules
def _load_profile_with_relationships(session: Session, profile_key: str) -> ProfileORM | None:
return session.scalar(
select(ProfileORM)
.where(ProfileORM.key == profile_key)
.options(
selectinload(ProfileORM.groups),
selectinload(ProfileORM.rule_links).selectinload(ProfileRuleModuleORM.rule_module),
selectinload(ProfileORM.source_links).selectinload(ProfileSourceORM.source),
)
)
def _clear_config_tables(session: Session) -> None:

View File

@@ -5,6 +5,3 @@ PyYAML>=6.0,<7.0
pydantic-settings>=2.3,<3.0
SQLAlchemy>=2.0,<3.0
alembic>=1.13,<2.0
Jinja2>=3.1,<4.0
python-multipart>=0.0.9,<1.0
itsdangerous>=2.2,<3.0

View File

@@ -1,136 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>sub-provider admin</title>
<style>
:root {
--bg: #f3efe6;
--panel: #fffdf8;
--ink: #1f2a2e;
--muted: #66757f;
--line: #d7ccbb;
--accent: #b8542a;
--accent-soft: #f0d7c5;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", "PingFang SC", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, #f7d9c5, transparent 28%),
radial-gradient(circle at bottom right, #dce8da, transparent 22%),
var(--bg);
}
a { color: inherit; }
.shell {
max-width: 1320px;
margin: 0 auto;
padding: 24px;
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 24px;
}
.nav, .panel {
background: rgba(255, 253, 248, 0.92);
backdrop-filter: blur(10px);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: 0 20px 40px rgba(31, 42, 46, 0.08);
}
.nav { padding: 18px; align-self: start; position: sticky; top: 18px; }
.brand { font-size: 24px; font-weight: 700; margin-bottom: 20px; }
.nav a {
display: block;
text-decoration: none;
padding: 10px 12px;
border-radius: 12px;
margin-bottom: 8px;
}
.nav a.active {
background: var(--accent);
color: #fff;
}
.nav form { margin-top: 18px; }
.panel { padding: 24px; }
h1, h2 { margin-top: 0; }
.muted { color: var(--muted); }
.stack { display: grid; gap: 20px; }
.grid-2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.card {
border: 1px solid var(--line);
border-radius: 18px;
padding: 18px;
background: var(--panel);
}
label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; }
input, select, textarea {
width: 100%;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--line);
background: #fff;
color: var(--ink);
}
textarea { min-height: 140px; resize: vertical; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
th { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
button, .button {
border: 0;
border-radius: 999px;
padding: 10px 16px;
font-weight: 600;
cursor: pointer;
background: var(--accent);
color: #fff;
text-decoration: none;
display: inline-block;
}
.button.secondary, button.secondary {
background: var(--accent-soft);
color: var(--ink);
}
.flash {
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--line);
margin-bottom: 18px;
}
.flash.success { background: #e8f3e6; border-color: #b7d3b1; }
.flash.error { background: #f7dfdb; border-color: #ddb0a7; }
.inline { display: inline-flex; align-items: center; gap: 8px; }
.wide { width: 100%; min-height: 65vh; font-family: Consolas, monospace; }
@media (max-width: 920px) {
.shell { grid-template-columns: 1fr; }
.nav { position: static; }
.grid-2 { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="shell">
<nav class="nav">
<div class="brand">sub-provider</div>
{% if authenticated %}
<a href="/admin/sources" class="{% if active == 'sources' %}active{% endif %}">Sources</a>
<a href="/admin/profiles" class="{% if active == 'profiles' %}active{% endif %}">Profiles</a>
{% if auth_enabled %}
<form method="post" action="/admin/logout">
<button type="submit" class="secondary" style="width:100%;">Logout</button>
</form>
{% endif %}
{% endif %}
</nav>
<main class="panel">
{% if flash %}
<div class="flash {{ flash.level }}">{{ flash.message }}</div>
{% endif %}
{% block content %}{% endblock %}
</main>
</div>
</body>
</html>

View File

@@ -1,126 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="stack">
<div class="actions" style="justify-content:space-between; align-items:center;">
<div>
<h1>Groups: {{ profile_key }}</h1>
<p class="muted">管理 region / selector / policy groups。当前版本以整表提交为主。</p>
</div>
<div class="actions">
<a class="button secondary" href="/admin/profiles">Back</a>
<a class="button" href="/admin/profiles/{{ profile_key }}/preview">Preview</a>
</div>
</div>
<form method="post" action="/admin/profiles/{{ profile_key }}/groups" class="stack">
{% for group in groups %}
<div class="card">
<input type="hidden" name="group_id" value="{{ group.id }}">
<div class="grid-2">
<div>
<label>Name</label>
<input name="group_name_{{ loop.index0 }}" value="{{ group.name }}">
</div>
<div>
<label>Kind</label>
<select name="group_kind_{{ loop.index0 }}">
<option value="region" {% if group.group_kind == 'region' %}selected{% endif %}>region</option>
<option value="selector" {% if group.group_kind == 'selector' %}selected{% endif %}>selector</option>
<option value="policy" {% if group.group_kind == 'policy' %}selected{% endif %}>policy</option>
</select>
</div>
<div>
<label>Type</label>
<select name="type_{{ loop.index0 }}">
<option value="select" {% if group.type == 'select' %}selected{% endif %}>select</option>
<option value="url-test" {% if group.type == 'url-test' %}selected{% endif %}>url-test</option>
</select>
</div>
<div>
<label>Order</label>
<input name="order_index_{{ loop.index0 }}" value="{{ group.order_index }}">
</div>
<div>
<label>Filter Regex</label>
<input name="filter_regex_{{ loop.index0 }}" value="{{ group.filter_regex }}">
</div>
<div>
<label>Tolerance</label>
<input name="tolerance_{{ loop.index0 }}" value="{{ group.tolerance or '' }}">
</div>
<div>
<label>URL</label>
<input name="url_{{ loop.index0 }}" value="{{ group.url }}">
</div>
<div>
<label>Interval</label>
<input name="interval_{{ loop.index0 }}" value="{{ group.interval or '' }}">
</div>
</div>
<div style="margin-top:12px;">
<label>Proxies / Tokens</label>
<textarea name="proxies_{{ loop.index0 }}">{% for item in group.proxies %}{{ item }}{% if not loop.last %}
{% endif %}{% endfor %}</textarea>
</div>
<div class="actions" style="margin-top:12px;">
<label class="inline"><input type="checkbox" name="enabled_{{ loop.index0 }}" {% if group.enabled %}checked{% endif %} style="width:auto"> Enabled</label>
<label class="inline"><input type="checkbox" name="delete_{{ loop.index0 }}" style="width:auto"> Delete</label>
</div>
</div>
{% endfor %}
<div class="card">
<h2>New Group</h2>
<div class="grid-2">
<div>
<label>Name</label>
<input name="new_group_name" placeholder="🤖 AI">
</div>
<div>
<label>Kind</label>
<select name="new_group_kind">
<option value="policy">policy</option>
<option value="selector">selector</option>
<option value="region">region</option>
</select>
</div>
<div>
<label>Type</label>
<select name="new_group_type">
<option value="select">select</option>
<option value="url-test">url-test</option>
</select>
</div>
<div>
<label>Order</label>
<input name="new_group_order_index" placeholder="{{ groups|length }}">
</div>
<div>
<label>Filter Regex</label>
<input name="new_group_filter_regex">
</div>
<div>
<label>Tolerance</label>
<input name="new_group_tolerance">
</div>
<div>
<label>URL</label>
<input name="new_group_url">
</div>
<div>
<label>Interval</label>
<input name="new_group_interval">
</div>
</div>
<div style="margin-top:12px;">
<label>Proxies / Tokens</label>
<textarea name="new_group_proxies"></textarea>
</div>
<label class="inline" style="margin-top:12px;"><input type="checkbox" name="new_group_enabled" checked style="width:auto"> Enabled</label>
</div>
<div class="actions">
<button type="submit">Save Groups</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -1,73 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>sub-provider login</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: "Segoe UI", "PingFang SC", sans-serif;
background:
radial-gradient(circle at top left, #f7d9c5, transparent 28%),
radial-gradient(circle at bottom right, #dce8da, transparent 22%),
#f3efe6;
color: #1f2a2e;
}
.card {
width: min(420px, calc(100vw - 32px));
background: rgba(255,253,248,0.95);
border: 1px solid #d7ccbb;
border-radius: 24px;
padding: 28px;
box-shadow: 0 20px 40px rgba(31,42,46,0.08);
}
h1 { margin-top: 0; }
label { display:block; margin-bottom: 6px; color:#66757f; font-size: 13px; }
input {
width:100%;
padding: 12px;
border-radius: 12px;
border: 1px solid #d7ccbb;
box-sizing: border-box;
}
button {
margin-top: 16px;
width: 100%;
border: 0;
border-radius: 999px;
padding: 12px 16px;
background: #b8542a;
color: #fff;
font-weight: 600;
cursor: pointer;
}
.muted { color:#66757f; }
.flash {
padding: 12px 14px;
border-radius: 14px;
border: 1px solid #ddb0a7;
background: #f7dfdb;
margin-bottom: 18px;
}
</style>
</head>
<body>
<div class="card">
<h1>Admin Login</h1>
<p class="muted">输入 `.env` 中配置的 `ADMIN_TOKEN`。</p>
{% if flash %}
<div class="flash">{{ flash.message }}</div>
{% endif %}
<form method="post" action="/admin/login">
<input type="hidden" name="next" value="{{ next }}">
<label>Token</label>
<input type="password" name="token" autofocus>
<button type="submit">Login</button>
</form>
</div>
</body>
</html>

View File

@@ -1,31 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="stack">
<div class="actions" style="justify-content:space-between; align-items:center;">
<div>
<h1>Preview: {{ profile_key }}</h1>
<p class="muted">预览 profile 当前生成的完整 bundle YAML。</p>
</div>
<div class="actions">
<a class="button secondary" href="/admin/profiles/{{ profile_key }}/rules">Rules</a>
<a class="button" href="/admin/profiles">Profiles</a>
</div>
</div>
<div class="card">
<form method="get" action="/admin/profiles/{{ profile_key }}/preview" class="stack">
<div>
<label>Sources (comma separated)</label>
<input name="sources" value="{{ selected_sources|join(',') }}" placeholder="{{ available_sources|join(',') }}">
</div>
<div class="actions">
<button type="submit">Refresh Preview</button>
</div>
</form>
</div>
<div class="card">
<textarea class="wide" readonly>{{ yaml_text }}</textarea>
</div>
</div>
{% endblock %}

View File

@@ -1,40 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="stack">
<div class="actions" style="justify-content:space-between; align-items:center;">
<div>
<h1>Profile Sources: {{ profile_key }}</h1>
<p class="muted">配置 profile 默认会使用哪些源,以及它们的顺序。</p>
</div>
<div class="actions">
<a class="button secondary" href="/admin/profiles">Back</a>
<a class="button" href="/admin/profiles/{{ profile_key }}/preview">Preview</a>
</div>
</div>
<form method="post" action="/admin/profiles/{{ profile_key }}/sources" class="card">
<table>
<thead>
<tr><th>Enabled</th><th>Key</th><th>Order</th><th>Type</th><th>URL</th></tr>
</thead>
<tbody>
{% for binding in bindings %}
<tr>
<td>
<input type="hidden" name="source_key" value="{{ binding.key }}">
<input type="checkbox" name="enabled_{{ binding.key }}" {% if binding.enabled %}checked{% endif %} style="width:auto">
</td>
<td><strong>{{ binding.key }}</strong><div class="muted">{{ binding.display_name }}</div></td>
<td><input name="order_index_{{ binding.key }}" value="{{ binding.order_index }}"></td>
<td>{{ binding.kind }}</td>
<td class="muted">{{ binding.url }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="actions" style="margin-top:18px;">
<button type="submit">Save Source Bindings</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -1,60 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="stack">
<div>
<h1>Profiles</h1>
<p class="muted">管理客户端模板,并跳转到规则和预览页。</p>
</div>
<div class="card">
<h2>New / Edit Profile</h2>
<form method="post" action="/admin/profiles" class="stack">
<div class="grid-2">
<div><label>Key</label><input name="key" required placeholder="mihomo"></div>
<div><label>Title</label><input name="title" required></div>
<div><label>Provider Interval</label><input name="provider_interval" value="21600"></div>
<div><label>Rule Interval</label><input name="rule_interval" value="86400"></div>
<div><label>Test URL</label><input name="test_url" value="https://www.gstatic.com/generate_204"></div>
<div><label>Test Interval</label><input name="test_interval" value="300"></div>
<div><label>Main Policy</label><input name="main_policy" value="🚀 节点选择"></div>
<div><label>Source Policy</label><input name="source_policy" value="☁️ 机场选择"></div>
<div><label>Mixed Auto Policy</label><input name="mixed_auto_policy" value="♻️ 自动选择"></div>
<div><label>Manual Policy</label><input name="manual_policy" value="🚀 手动切换"></div>
<div><label>Direct Policy</label><input name="direct_policy" value="DIRECT"></div>
<div><label>Mode</label><input name="mode" value="rule"></div>
<div><label>Mixed Port</label><input name="mixed_port" value="7890"></div>
<div><label>Socks Port</label><input name="socks_port" value="7891"></div>
<div><label>Log Level</label><input name="log_level" value="info"></div>
</div>
<label class="inline"><input type="checkbox" name="allow_lan" checked style="width:auto"> Allow LAN</label>
<label class="inline"><input type="checkbox" name="ipv6" checked style="width:auto"> IPv6</label>
<div class="actions"><button type="submit">Save Profile</button></div>
</form>
</div>
<div class="stack">
{% for profile in profiles %}
<div class="card">
<div class="actions" style="justify-content:space-between; align-items:center;">
<div>
<h2 style="margin-bottom:4px;">{{ profile.key }}</h2>
<div class="muted">{{ profile.title }}</div>
</div>
<div class="actions">
<a class="button secondary" href="/admin/profiles/{{ profile.key }}/sources">Sources</a>
<a class="button secondary" href="/admin/profiles/{{ profile.key }}/groups">Groups</a>
<a class="button secondary" href="/admin/profiles/{{ profile.key }}/rules">Rules</a>
<a class="button" href="/admin/profiles/{{ profile.key }}/preview">Preview</a>
</div>
</div>
<div class="grid-2" style="margin-top:14px;">
<div><strong>Main:</strong> {{ profile.main_policy }}</div>
<div><strong>Source:</strong> {{ profile.source_policy }}</div>
<div><strong>Test URL:</strong> <span class="muted">{{ profile.test_url }}</span></div>
<div><strong>Mode:</strong> {{ profile.mode }}</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@@ -1,40 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="stack">
<div class="actions" style="justify-content:space-between; align-items:center;">
<div>
<h1>Rules: {{ profile_key }}</h1>
<p class="muted">控制规则模块启用状态、顺序和目标策略组。</p>
</div>
<div class="actions">
<a class="button secondary" href="/admin/profiles">Back</a>
<a class="button" href="/admin/profiles/{{ profile_key }}/preview">Preview</a>
</div>
</div>
<form method="post" action="/admin/profiles/{{ profile_key }}/rules" class="card">
<table>
<thead>
<tr><th>Enabled</th><th>Key</th><th>Order</th><th>Policy</th><th>Rule File</th></tr>
</thead>
<tbody>
{% for binding in bindings %}
<tr>
<td>
<input type="hidden" name="rule_key" value="{{ binding.key }}">
<input type="checkbox" name="enabled_{{ binding.key }}" {% if binding.enabled %}checked{% endif %} style="width:auto">
</td>
<td><strong>{{ binding.key }}</strong><div class="muted">{{ binding.behavior }} / {{ binding.format }}</div></td>
<td><input name="order_index_{{ binding.key }}" value="{{ binding.order_index }}"></td>
<td><input name="policy_{{ binding.key }}" value="{{ binding.policy }}"></td>
<td class="muted">{{ binding.file_path or "inline payload" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="actions" style="margin-top:18px;">
<button type="submit">Save Rules</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -1,55 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="stack">
<div>
<h1>Sources</h1>
<p class="muted">管理订阅源。先做最小可用版本:增改删和启停。</p>
</div>
<div class="card">
<h2>New / Edit Source</h2>
<form method="post" action="/admin/sources" class="stack">
<div class="grid-2">
<div><label>Key</label><input name="key" required placeholder="airport-d"></div>
<div><label>Display Name</label><input name="display_name" placeholder="D"></div>
<div><label>Kind</label><select name="kind"><option value="auto">auto</option><option value="clash_yaml">clash_yaml</option><option value="base64_uri">base64_uri</option><option value="uri">uri</option></select></div>
<div><label>Cache TTL Seconds</label><input name="cache_ttl_seconds" placeholder="900"></div>
</div>
<div><label>URL</label><input name="url" required></div>
<div class="grid-2">
<div><label>Prefix</label><input name="prefix"></div>
<div><label>Suffix</label><input name="suffix"></div>
<div><label>Include Regex</label><input name="include_regex"></div>
<div><label>Exclude Regex</label><input name="exclude_regex"></div>
</div>
<label class="inline"><input type="checkbox" name="enabled" checked style="width:auto"> Enabled</label>
<div class="actions"><button type="submit">Save Source</button></div>
</form>
</div>
<div class="card">
<h2>Existing Sources</h2>
<table>
<thead>
<tr><th>Key</th><th>Type</th><th>URL</th><th>Prefix</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{% for source in sources %}
<tr>
<td>{{ source.key }}</td>
<td>{{ source.kind }}</td>
<td class="muted">{{ source.url }}</td>
<td>{{ source.prefix }}</td>
<td>{{ "enabled" if source.enabled else "disabled" }}</td>
<td>
<form method="post" action="/admin/sources/{{ source.key }}/delete" onsubmit="return confirm('Delete source {{ source.key }}?')">
<button type="submit" class="secondary">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}