Add VPN admin tab with subscription proxy via cp-vpn for selected sources.
Persist settings in CA, expose /admin/vpn and /internal/vpn, and route Telegram (and optional web/nlp) traffic through mihomo SOCKS when enabled. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,8 +13,8 @@ for _candidate in (_HERE.parent, *_HERE.parents):
|
||||
break
|
||||
|
||||
from .database import Base, engine, get_db
|
||||
from .routers import admin, auth, internal, map, objects, parse_channels, parser_profiles, v1
|
||||
from .seed import seed_objects, seed_test_consumer
|
||||
from .routers import admin, auth, internal, map, objects, parse_channels, parser_profiles, v1, vpn
|
||||
from .seed import seed_objects, seed_test_consumer, seed_vpn_settings
|
||||
from .services.migrations import migrate_schema
|
||||
from .services.scheduler import start_scheduler
|
||||
from .storage import ensure_upload_dir
|
||||
@@ -30,6 +30,7 @@ async def lifespan(_: FastAPI):
|
||||
try:
|
||||
seed_objects(db)
|
||||
seed_test_consumer(db)
|
||||
seed_vpn_settings(db)
|
||||
finally:
|
||||
db.close()
|
||||
yield
|
||||
@@ -53,4 +54,5 @@ app.include_router(auth.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(parser_profiles.router)
|
||||
app.include_router(parse_channels.router)
|
||||
app.include_router(vpn.router)
|
||||
app.include_router(v1.router)
|
||||
|
||||
@@ -190,3 +190,22 @@ class ConsumerFilter(Base):
|
||||
date_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
consumer: Mapped["Consumer"] = relationship(back_populates="filter")
|
||||
|
||||
|
||||
class VpnSettingsRow(Base):
|
||||
"""Singleton VPN / proxy settings (id=1)."""
|
||||
|
||||
__tablename__ = "vpn_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
mode: Mapped[str] = mapped_column(String(32), default="subscription", nullable=False)
|
||||
subscription_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
subscription_interval_seconds: Mapped[int] = mapped_column(
|
||||
Integer, default=3600, nullable=False
|
||||
)
|
||||
host: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
username: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
password: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
proxied_source_types: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
@@ -6,9 +6,15 @@ from sqlalchemy.orm import Session
|
||||
from ..database import get_db
|
||||
from ..deps import verify_internal_token
|
||||
from ..models import ParseJob
|
||||
from ..schemas import IngestRequest, IngestResponse, ListenerSubscription
|
||||
from ..schemas import (
|
||||
IngestRequest,
|
||||
IngestResponse,
|
||||
ListenerSubscription,
|
||||
VpnSettingsInternalRead,
|
||||
)
|
||||
from ..services.ingest import ingest_events
|
||||
from ..services.jobs import resolve_job_source_config
|
||||
from ..services.vpn import ensure_vpn_settings, to_internal_read
|
||||
|
||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
||||
|
||||
@@ -85,3 +91,12 @@ def listener_subscriptions(
|
||||
)
|
||||
db.commit()
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/vpn", response_model=VpnSettingsInternalRead)
|
||||
def internal_vpn_settings(
|
||||
_: None = Depends(verify_internal_token),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
row = ensure_vpn_settings(db)
|
||||
return to_internal_read(row)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Admin VPN / proxy settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import verify_admin
|
||||
from ..schemas import VpnSettingsAdminRead, VpnSettingsUpdate
|
||||
from ..services.vpn import apply_vpn_update, ensure_vpn_settings, to_admin_read
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/vpn",
|
||||
tags=["vpn"],
|
||||
dependencies=[Depends(verify_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=VpnSettingsAdminRead)
|
||||
def get_vpn_settings(db: Session = Depends(get_db)):
|
||||
row = ensure_vpn_settings(db)
|
||||
return to_admin_read(row)
|
||||
|
||||
|
||||
@router.put("", response_model=VpnSettingsAdminRead)
|
||||
def put_vpn_settings(payload: VpnSettingsUpdate, db: Session = Depends(get_db)):
|
||||
try:
|
||||
row = apply_vpn_update(db, payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return to_admin_read(row)
|
||||
@@ -300,3 +300,43 @@ class TimelinePoint(BaseModel):
|
||||
class TopItem(BaseModel):
|
||||
name: str
|
||||
count: int
|
||||
|
||||
|
||||
class VpnSettingsUpdate(BaseModel):
|
||||
enabled: bool | None = None
|
||||
mode: Literal["subscription", "socks5", "http"] | None = None
|
||||
subscription_url: str | None = None
|
||||
clear_subscription_url: bool | None = None
|
||||
subscription_interval_seconds: int | None = Field(default=None, ge=60, le=86400)
|
||||
host: str | None = None
|
||||
port: int | None = Field(default=None, ge=1, le=65535)
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
clear_password: bool | None = None
|
||||
proxied_source_types: list[str] | None = None
|
||||
|
||||
|
||||
class VpnSettingsAdminRead(BaseModel):
|
||||
enabled: bool
|
||||
mode: Literal["subscription", "socks5", "http"]
|
||||
subscription_url_set: bool
|
||||
subscription_url_hint: str | None = None
|
||||
subscription_interval_seconds: int
|
||||
host: str | None = None
|
||||
port: int | None = None
|
||||
username: str | None = None
|
||||
password_set: bool
|
||||
proxied_source_types: list[str]
|
||||
available_source_types: list[str]
|
||||
|
||||
|
||||
class VpnSettingsInternalRead(BaseModel):
|
||||
enabled: bool
|
||||
mode: Literal["subscription", "socks5", "http"]
|
||||
subscription_url: str | None = None
|
||||
subscription_interval_seconds: int
|
||||
host: str | None = None
|
||||
port: int | None = None
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
proxied_source_types: list[str]
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from .models import Consumer, ConsumerFilter
|
||||
from .services.filtering import hash_api_key
|
||||
from .services.vpn import ensure_vpn_settings
|
||||
|
||||
|
||||
def seed_objects(db: Session) -> None:
|
||||
@@ -24,3 +25,7 @@ def seed_test_consumer(db: Session) -> None:
|
||||
db.flush()
|
||||
db.add(ConsumerFilter(consumer_id=consumer.id, regions=None, topics=None))
|
||||
db.commit()
|
||||
|
||||
|
||||
def seed_vpn_settings(db: Session) -> None:
|
||||
ensure_vpn_settings(db)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""VPN settings persistence helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contracts.queues import known_source_types
|
||||
from contracts.vpn import VpnSettings
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import VpnSettingsRow
|
||||
from ..schemas import VpnSettingsAdminRead, VpnSettingsInternalRead, VpnSettingsUpdate
|
||||
|
||||
SINGLETON_ID = 1
|
||||
|
||||
|
||||
def _hint_secret(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
if len(value) <= 4:
|
||||
return "****"
|
||||
return f"…{value[-4:]}"
|
||||
|
||||
|
||||
def ensure_vpn_settings(db: Session) -> VpnSettingsRow:
|
||||
row = db.query(VpnSettingsRow).filter(VpnSettingsRow.id == SINGLETON_ID).first()
|
||||
if row is not None:
|
||||
return row
|
||||
row = VpnSettingsRow(
|
||||
id=SINGLETON_ID,
|
||||
enabled=False,
|
||||
mode="subscription",
|
||||
subscription_interval_seconds=3600,
|
||||
proxied_source_types=[],
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def row_to_contract(row: VpnSettingsRow) -> VpnSettings:
|
||||
return VpnSettings.model_validate(
|
||||
{
|
||||
"enabled": row.enabled,
|
||||
"mode": row.mode,
|
||||
"subscription_url": row.subscription_url,
|
||||
"subscription_interval_seconds": row.subscription_interval_seconds,
|
||||
"host": row.host,
|
||||
"port": row.port,
|
||||
"username": row.username,
|
||||
"password": row.password,
|
||||
"proxied_source_types": row.proxied_source_types or [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def to_admin_read(row: VpnSettingsRow) -> VpnSettingsAdminRead:
|
||||
return VpnSettingsAdminRead(
|
||||
enabled=row.enabled,
|
||||
mode=row.mode, # type: ignore[arg-type]
|
||||
subscription_url_set=bool(row.subscription_url),
|
||||
subscription_url_hint=_hint_secret(row.subscription_url),
|
||||
subscription_interval_seconds=row.subscription_interval_seconds,
|
||||
host=row.host,
|
||||
port=row.port,
|
||||
username=row.username,
|
||||
password_set=bool(row.password),
|
||||
proxied_source_types=list(row.proxied_source_types or []),
|
||||
available_source_types=known_source_types(),
|
||||
)
|
||||
|
||||
|
||||
def to_internal_read(row: VpnSettingsRow) -> VpnSettingsInternalRead:
|
||||
return VpnSettingsInternalRead(
|
||||
enabled=row.enabled,
|
||||
mode=row.mode, # type: ignore[arg-type]
|
||||
subscription_url=row.subscription_url,
|
||||
subscription_interval_seconds=row.subscription_interval_seconds,
|
||||
host=row.host,
|
||||
port=row.port,
|
||||
username=row.username,
|
||||
password=row.password,
|
||||
proxied_source_types=list(row.proxied_source_types or []),
|
||||
)
|
||||
|
||||
|
||||
def apply_vpn_update(db: Session, payload: VpnSettingsUpdate) -> VpnSettingsRow:
|
||||
row = ensure_vpn_settings(db)
|
||||
data = {
|
||||
"enabled": row.enabled if payload.enabled is None else payload.enabled,
|
||||
"mode": row.mode if payload.mode is None else payload.mode,
|
||||
"subscription_url": row.subscription_url,
|
||||
"subscription_interval_seconds": (
|
||||
row.subscription_interval_seconds
|
||||
if payload.subscription_interval_seconds is None
|
||||
else payload.subscription_interval_seconds
|
||||
),
|
||||
"host": row.host if payload.host is None else (payload.host.strip() or None),
|
||||
"port": row.port if payload.port is None else payload.port,
|
||||
"username": (
|
||||
row.username
|
||||
if payload.username is None
|
||||
else (payload.username.strip() or None)
|
||||
),
|
||||
"password": row.password,
|
||||
"proxied_source_types": (
|
||||
list(row.proxied_source_types or [])
|
||||
if payload.proxied_source_types is None
|
||||
else payload.proxied_source_types
|
||||
),
|
||||
}
|
||||
|
||||
if payload.clear_subscription_url:
|
||||
data["subscription_url"] = None
|
||||
elif payload.subscription_url is not None:
|
||||
stripped = payload.subscription_url.strip()
|
||||
data["subscription_url"] = stripped or None
|
||||
|
||||
if payload.clear_password:
|
||||
data["password"] = None
|
||||
elif payload.password is not None:
|
||||
stripped = payload.password.strip()
|
||||
data["password"] = stripped or None
|
||||
|
||||
validated = VpnSettings.model_validate(data)
|
||||
|
||||
row.enabled = validated.enabled
|
||||
row.mode = validated.mode
|
||||
row.subscription_url = validated.subscription_url
|
||||
row.subscription_interval_seconds = validated.subscription_interval_seconds
|
||||
row.host = validated.host
|
||||
row.port = validated.port
|
||||
row.username = validated.username
|
||||
row.password = validated.password
|
||||
row.proxied_source_types = validated.proxied_source_types
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
ParseJobUpdate,
|
||||
TimelinePoint,
|
||||
TopItem,
|
||||
VpnSettings,
|
||||
VpnSettingsUpdate,
|
||||
} from "../types/admin";
|
||||
|
||||
function buildQuery(params: Record<string, string | number | undefined>): string {
|
||||
@@ -157,3 +159,15 @@ export async function testDistribution(apiKey: string, limit = 5): Promise<Event
|
||||
}
|
||||
return response.json() as Promise<EventRecord[]>;
|
||||
}
|
||||
|
||||
export function fetchVpnSettings(): Promise<VpnSettings> {
|
||||
return request<VpnSettings>("/vpn", undefined, ADMIN_BASE);
|
||||
}
|
||||
|
||||
export function updateVpnSettings(payload: VpnSettingsUpdate): Promise<VpnSettings> {
|
||||
return request<VpnSettings>(
|
||||
"/vpn",
|
||||
{ method: "PUT", body: JSON.stringify(payload) },
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const adminNavItems = [
|
||||
{ to: "/channels", label: "Каналы" },
|
||||
{ to: "/parser-profiles", label: "Профили" },
|
||||
{ to: "/parsers", label: "Парсеры" },
|
||||
{ to: "/vpn", label: "VPN" },
|
||||
{ to: "/events", label: "События" },
|
||||
{ to: "/analytics", label: "Аналитика" },
|
||||
{ to: "/consumers", label: "ПИ" },
|
||||
|
||||
@@ -10,6 +10,7 @@ import LoginView from "../views/LoginView.vue";
|
||||
import MapViewPage from "../views/MapViewPage.vue";
|
||||
import ParserProfilesView from "../views/ParserProfilesView.vue";
|
||||
import ParsersView from "../views/ParsersView.vue";
|
||||
import VpnView from "../views/VpnView.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -43,6 +44,12 @@ const router = createRouter({
|
||||
component: ParsersView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "vpn",
|
||||
name: "vpn",
|
||||
component: VpnView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "events",
|
||||
name: "events",
|
||||
|
||||
@@ -206,3 +206,33 @@ export interface TopItem {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export type VpnMode = "subscription" | "socks5" | "http";
|
||||
|
||||
export interface VpnSettings {
|
||||
enabled: boolean;
|
||||
mode: VpnMode;
|
||||
subscription_url_set: boolean;
|
||||
subscription_url_hint: string | null;
|
||||
subscription_interval_seconds: number;
|
||||
host: string | null;
|
||||
port: number | null;
|
||||
username: string | null;
|
||||
password_set: boolean;
|
||||
proxied_source_types: string[];
|
||||
available_source_types: string[];
|
||||
}
|
||||
|
||||
export interface VpnSettingsUpdate {
|
||||
enabled?: boolean;
|
||||
mode?: VpnMode;
|
||||
subscription_url?: string | null;
|
||||
clear_subscription_url?: boolean;
|
||||
subscription_interval_seconds?: number;
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
clear_password?: boolean;
|
||||
proxied_source_types?: string[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
import { fetchVpnSettings, updateVpnSettings } from "../api/admin";
|
||||
import type { VpnMode, VpnSettings } from "../types/admin";
|
||||
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref("");
|
||||
const success = ref("");
|
||||
const settings = ref<VpnSettings | null>(null);
|
||||
|
||||
const form = ref({
|
||||
enabled: false,
|
||||
mode: "subscription" as VpnMode,
|
||||
subscription_url: "",
|
||||
clear_subscription_url: false,
|
||||
subscription_interval_seconds: 3600,
|
||||
host: "",
|
||||
port: 1080 as number | null,
|
||||
username: "",
|
||||
password: "",
|
||||
clear_password: false,
|
||||
proxied_source_types: [] as string[],
|
||||
});
|
||||
|
||||
const sourceLabels: Record<string, string> = {
|
||||
telegram: "Telegram",
|
||||
crawl4ai: "Crawl4AI (web)",
|
||||
viina: "VIINA (nlp)",
|
||||
};
|
||||
|
||||
const isSubscription = computed(() => form.value.mode === "subscription");
|
||||
const isDirectProxy = computed(
|
||||
() => form.value.mode === "socks5" || form.value.mode === "http",
|
||||
);
|
||||
|
||||
function applySettings(data: VpnSettings) {
|
||||
settings.value = data;
|
||||
form.value = {
|
||||
enabled: data.enabled,
|
||||
mode: data.mode,
|
||||
subscription_url: "",
|
||||
clear_subscription_url: false,
|
||||
subscription_interval_seconds: data.subscription_interval_seconds,
|
||||
host: data.host ?? "",
|
||||
port: data.port ?? 1080,
|
||||
username: data.username ?? "",
|
||||
password: "",
|
||||
clear_password: false,
|
||||
proxied_source_types: [...data.proxied_source_types],
|
||||
};
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
success.value = "";
|
||||
try {
|
||||
applySettings(await fetchVpnSettings());
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось загрузить VPN";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSource(sourceType: string, checked: boolean) {
|
||||
const set = new Set(form.value.proxied_source_types);
|
||||
if (checked) set.add(sourceType);
|
||||
else set.delete(sourceType);
|
||||
form.value.proxied_source_types = [...set];
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
success.value = "";
|
||||
try {
|
||||
const payload: Parameters<typeof updateVpnSettings>[0] = {
|
||||
enabled: form.value.enabled,
|
||||
mode: form.value.mode,
|
||||
subscription_interval_seconds: form.value.subscription_interval_seconds,
|
||||
host: form.value.host.trim() || null,
|
||||
port: form.value.port,
|
||||
username: form.value.username.trim() || null,
|
||||
proxied_source_types: form.value.proxied_source_types,
|
||||
};
|
||||
|
||||
if (form.value.clear_subscription_url) {
|
||||
payload.clear_subscription_url = true;
|
||||
} else if (form.value.subscription_url.trim()) {
|
||||
payload.subscription_url = form.value.subscription_url.trim();
|
||||
}
|
||||
|
||||
if (form.value.clear_password) {
|
||||
payload.clear_password = true;
|
||||
} else if (form.value.password) {
|
||||
payload.password = form.value.password;
|
||||
}
|
||||
|
||||
applySettings(await updateVpnSettings(payload));
|
||||
success.value = "Настройки VPN сохранены";
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось сохранить";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-heading">VPN</h2>
|
||||
<p class="intro">
|
||||
Проксирование трафика выбранных источников парсинга. Режим
|
||||
<strong>subscription</strong> поднимает SOCKS через сервис
|
||||
<code>cp-vpn</code> (mihomo). Режимы SOCKS5/HTTP задают адрес напрямую для
|
||||
воркеров. Без включённого VPN остаётся fallback из
|
||||
<code>TELEGRAM_PROXY_*</code> в <code>.env</code>.
|
||||
</p>
|
||||
|
||||
<section class="card">
|
||||
<h3>Настройки <span v-if="loading" class="muted">(загрузка...)</span></h3>
|
||||
<form v-if="settings" class="admin-form" @submit.prevent="handleSave">
|
||||
<label class="checkbox-row">
|
||||
<input v-model="form.enabled" type="checkbox" />
|
||||
<span>Прокси включён</span>
|
||||
</label>
|
||||
|
||||
<div class="form-row vpn-row">
|
||||
<label>
|
||||
Способ проксирования
|
||||
<select v-model="form.mode">
|
||||
<option value="subscription">Subscription URL</option>
|
||||
<option value="socks5">SOCKS5</option>
|
||||
<option value="http">HTTP</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="isSubscription">
|
||||
<label>
|
||||
Адрес подписки (subscription URL)
|
||||
<input
|
||||
v-model="form.subscription_url"
|
||||
type="url"
|
||||
placeholder="https://sub.example.com/…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<p v-if="settings.subscription_url_set" class="hint">
|
||||
Сохранён:
|
||||
<code>{{ settings.subscription_url_hint }}</code>
|
||||
— оставьте поле пустым, чтобы не менять.
|
||||
</p>
|
||||
<label class="checkbox-row">
|
||||
<input v-model="form.clear_subscription_url" type="checkbox" />
|
||||
<span>Очистить сохранённый URL</span>
|
||||
</label>
|
||||
<label>
|
||||
Интервал обновления подписки (сек)
|
||||
<input
|
||||
v-model.number="form.subscription_interval_seconds"
|
||||
type="number"
|
||||
min="60"
|
||||
max="86400"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<template v-if="isDirectProxy">
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Host
|
||||
<input v-model="form.host" type="text" placeholder="127.0.0.1" />
|
||||
</label>
|
||||
<label>
|
||||
Port
|
||||
<input v-model.number="form.port" type="number" min="1" max="65535" />
|
||||
</label>
|
||||
<label>
|
||||
Username
|
||||
<input v-model="form.username" type="text" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="settings.password_set ? '•••• (оставьте пустым)' : ''"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label v-if="settings.password_set" class="checkbox-row">
|
||||
<input v-model="form.clear_password" type="checkbox" />
|
||||
<span>Очистить пароль</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<fieldset class="sources">
|
||||
<legend>Источники через прокси</legend>
|
||||
<label
|
||||
v-for="st in settings.available_source_types"
|
||||
:key="st"
|
||||
class="checkbox-row"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.proxied_source_types.includes(st)"
|
||||
@change="
|
||||
toggleSource(st, ($event.target as HTMLInputElement).checked)
|
||||
"
|
||||
/>
|
||||
<span>{{ sourceLabels[st] || st }} <code>({{ st }})</code></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="submitting || loading">
|
||||
{{ submitting ? "Сохранение..." : "Сохранить" }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="form-error">{{ error }}</p>
|
||||
<p v-if="success" class="form-success">{{ success }}</p>
|
||||
</form>
|
||||
<p v-else-if="error" class="form-error">{{ error }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.intro {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
|
||||
.vpn-row {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: -0.5rem 0 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.sources {
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1rem 0.25rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
|
||||
.sources legend {
|
||||
padding: 0 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.form-success {
|
||||
margin: 0.75rem 0 0;
|
||||
color: #1a7f37;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user