Remove 3x-ui integration from the panel.
Drop API sync, multi-panel registry, and UI/protocol paths; keep DB columns only for compatibility with existing installs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1
-1
@@ -81,7 +81,7 @@ CREATE TABLE IF NOT EXISTS invite_links (
|
|||||||
max_uses INTEGER NOT NULL DEFAULT 1,
|
max_uses INTEGER NOT NULL DEFAULT 1,
|
||||||
used_count INTEGER NOT NULL DEFAULT 0,
|
used_count INTEGER NOT NULL DEFAULT 0,
|
||||||
user_id UUID,
|
user_id UUID,
|
||||||
protocol TEXT NOT NULL DEFAULT 'xui',
|
protocol TEXT NOT NULL DEFAULT 'awg',
|
||||||
server_id INTEGER NOT NULL DEFAULT 0,
|
server_id INTEGER NOT NULL DEFAULT 0,
|
||||||
xui_inbound_id INTEGER NOT NULL DEFAULT 0,
|
xui_inbound_id INTEGER NOT NULL DEFAULT 0,
|
||||||
xui_panel_id TEXT NOT NULL DEFAULT '',
|
xui_panel_id TEXT NOT NULL DEFAULT '',
|
||||||
|
|||||||
+3
-23
@@ -35,29 +35,15 @@ DEFAULT_SETTINGS = {
|
|||||||
'remnawave_create_conns': False,
|
'remnawave_create_conns': False,
|
||||||
'remnawave_server_id': 0,
|
'remnawave_server_id': 0,
|
||||||
'remnawave_protocol': 'awg',
|
'remnawave_protocol': 'awg',
|
||||||
'xui_sync': False,
|
|
||||||
'xui_url': '',
|
|
||||||
'xui_username': '',
|
|
||||||
'xui_password': '',
|
|
||||||
'xui_api_token': '',
|
|
||||||
'xui_sync_users': False,
|
|
||||||
'xui_create_conns': False,
|
|
||||||
'xui_server_id': 0,
|
|
||||||
'xui_protocol': 'xray',
|
|
||||||
'xui_inbound_id': 0,
|
|
||||||
'xui_sub_url': '',
|
|
||||||
},
|
},
|
||||||
'xui_servers': [],
|
|
||||||
'guest': {
|
'guest': {
|
||||||
'enabled': False,
|
'enabled': False,
|
||||||
'token': '',
|
'token': '',
|
||||||
'password_hash': None,
|
'password_hash': None,
|
||||||
'user_id': '',
|
'user_id': '',
|
||||||
'allow_create': False,
|
'allow_create': False,
|
||||||
'create_protocol': 'xui',
|
'create_protocol': 'awg',
|
||||||
'create_server_id': 0,
|
'create_server_id': 0,
|
||||||
'create_inbound_id': 0,
|
|
||||||
'create_xui_panel_id': '',
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +91,6 @@ def _merge_settings(raw: Optional[dict]) -> dict:
|
|||||||
for key, value in raw.items():
|
for key, value in raw.items():
|
||||||
if key not in settings:
|
if key not in settings:
|
||||||
settings[key] = value
|
settings[key] = value
|
||||||
# Multi 3x-ui panels: migrate legacy sync.xui_* if needed
|
|
||||||
try:
|
|
||||||
from managers.xui_servers import ensure_xui_servers
|
|
||||||
ensure_xui_servers(settings)
|
|
||||||
except Exception:
|
|
||||||
settings.setdefault('xui_servers', [])
|
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
@@ -214,7 +194,7 @@ def _row_to_invite(row) -> dict:
|
|||||||
'max_uses': int(row['max_uses'] or 0),
|
'max_uses': int(row['max_uses'] or 0),
|
||||||
'used_count': int(row['used_count'] or 0),
|
'used_count': int(row['used_count'] or 0),
|
||||||
'user_id': str(row['user_id']) if row['user_id'] else '',
|
'user_id': str(row['user_id']) if row['user_id'] else '',
|
||||||
'protocol': row['protocol'] or 'xui',
|
'protocol': row['protocol'] or 'awg',
|
||||||
'server_id': int(row['server_id'] or 0),
|
'server_id': int(row['server_id'] or 0),
|
||||||
'xui_inbound_id': int(row['xui_inbound_id'] or 0),
|
'xui_inbound_id': int(row['xui_inbound_id'] or 0),
|
||||||
'xui_panel_id': (row.get('xui_panel_id') or '') if hasattr(row, 'get') else '',
|
'xui_panel_id': (row.get('xui_panel_id') or '') if hasattr(row, 'get') else '',
|
||||||
@@ -415,7 +395,7 @@ def save_data(data: dict) -> None:
|
|||||||
int(link.get('max_uses') or 0),
|
int(link.get('max_uses') or 0),
|
||||||
int(link.get('used_count') or 0),
|
int(link.get('used_count') or 0),
|
||||||
_as_uuid(uid) if uid else None,
|
_as_uuid(uid) if uid else None,
|
||||||
link.get('protocol') or 'xui',
|
link.get('protocol') or 'awg',
|
||||||
int(link.get('server_id') or 0),
|
int(link.get('server_id') or 0),
|
||||||
int(link.get('xui_inbound_id') or 0),
|
int(link.get('xui_inbound_id') or 0),
|
||||||
link.get('xui_panel_id') or '',
|
link.get('xui_panel_id') or '',
|
||||||
|
|||||||
@@ -1,561 +0,0 @@
|
|||||||
"""3x-ui HTTP API client — create VLESS clients and fetch share links.
|
|
||||||
|
|
||||||
Works with modern panels (/panel/api/clients/*) and falls back to legacy
|
|
||||||
/panel/api/inbounds/addClient when needed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
import string
|
|
||||||
import uuid
|
|
||||||
from typing import Any, Optional
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _new_sub_id(length: int = 16) -> str:
|
|
||||||
alphabet = string.ascii_lowercase + string.digits
|
|
||||||
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
|
||||||
|
|
||||||
|
|
||||||
def _settings_creds(settings: dict) -> dict:
|
|
||||||
sync = (settings or {}).get('sync') or {}
|
|
||||||
return {
|
|
||||||
'url': (sync.get('xui_url') or '').strip().rstrip('/'),
|
|
||||||
'api_token': (sync.get('xui_api_token') or '').strip(),
|
|
||||||
'username': (sync.get('xui_username') or '').strip(),
|
|
||||||
'password': sync.get('xui_password') or '',
|
|
||||||
'inbound_id': sync.get('xui_inbound_id'),
|
|
||||||
'sub_url': (sync.get('xui_sub_url') or '').strip().rstrip('/'),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_subscription_url(settings: dict, sub_id: str) -> str:
|
|
||||||
"""Build public subscription URL from panel settings + client subId."""
|
|
||||||
sub_id = (sub_id or '').strip()
|
|
||||||
if not sub_id:
|
|
||||||
return ''
|
|
||||||
base = _settings_creds(settings).get('sub_url') or ''
|
|
||||||
if not base:
|
|
||||||
return ''
|
|
||||||
return f"{base}/{sub_id}"
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_settings(settings: dict, panel_id: Optional[str] = None) -> dict:
|
|
||||||
"""Scope settings to a specific xui panel when panel_id / multi-server registry is used."""
|
|
||||||
try:
|
|
||||||
from managers.xui_servers import resolve_panel_settings, ensure_xui_servers
|
|
||||||
ensure_xui_servers(settings)
|
|
||||||
return resolve_panel_settings(settings, panel_id)
|
|
||||||
except Exception:
|
|
||||||
return settings
|
|
||||||
|
|
||||||
|
|
||||||
class XuiApiError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class XuiApi:
|
|
||||||
def __init__(self, base_url: str, *, api_token: str = '', username: str = '', password: str = ''):
|
|
||||||
self.base_url = (base_url or '').rstrip('/')
|
|
||||||
if not self.base_url:
|
|
||||||
raise XuiApiError('3x-ui URL is not configured')
|
|
||||||
self.api_token = (api_token or '').strip()
|
|
||||||
self.username = (username or '').strip()
|
|
||||||
self.password = password or ''
|
|
||||||
self._client: Optional[httpx.AsyncClient] = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_panel_settings(cls, settings: dict) -> 'XuiApi':
|
|
||||||
c = _settings_creds(settings)
|
|
||||||
return cls(c['url'], api_token=c['api_token'], username=c['username'], password=c['password'])
|
|
||||||
|
|
||||||
async def __aenter__(self) -> 'XuiApi':
|
|
||||||
headers = {'Accept': 'application/json'}
|
|
||||||
if self.api_token:
|
|
||||||
headers['Authorization'] = f'Bearer {self.api_token}'
|
|
||||||
self._client = httpx.AsyncClient(
|
|
||||||
base_url=self.base_url,
|
|
||||||
timeout=30.0,
|
|
||||||
follow_redirects=True,
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
if not self.api_token:
|
|
||||||
await self._login()
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *args):
|
|
||||||
if self._client:
|
|
||||||
await self._client.aclose()
|
|
||||||
self._client = None
|
|
||||||
|
|
||||||
async def _login(self):
|
|
||||||
if not self.username or not self.password:
|
|
||||||
raise XuiApiError('Provide 3x-ui API token or username/password')
|
|
||||||
resp = await self._client.post('/login', json={
|
|
||||||
'username': self.username,
|
|
||||||
'password': self.password,
|
|
||||||
})
|
|
||||||
if resp.status_code != 200:
|
|
||||||
raise XuiApiError(f'3x-ui login failed: HTTP {resp.status_code}')
|
|
||||||
try:
|
|
||||||
body = resp.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
if body.get('success') is False:
|
|
||||||
raise XuiApiError(f"3x-ui login failed: {body.get('msg', 'unknown error')}")
|
|
||||||
|
|
||||||
async def _request(self, method: str, path: str, **kwargs) -> Any:
|
|
||||||
assert self._client is not None
|
|
||||||
resp = await self._client.request(method, path, **kwargs)
|
|
||||||
try:
|
|
||||||
payload = resp.json()
|
|
||||||
except Exception:
|
|
||||||
raise XuiApiError(f'3x-ui {method} {path}: HTTP {resp.status_code} non-JSON')
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise XuiApiError(
|
|
||||||
f"3x-ui {method} {path}: HTTP {resp.status_code} {payload.get('msg') or payload}"
|
|
||||||
)
|
|
||||||
if isinstance(payload, dict) and payload.get('success') is False:
|
|
||||||
raise XuiApiError(payload.get('msg') or f'3x-ui {method} {path} failed')
|
|
||||||
return payload
|
|
||||||
|
|
||||||
async def list_inbounds(self) -> list:
|
|
||||||
for path, method in (
|
|
||||||
('/panel/api/inbounds/list', 'GET'),
|
|
||||||
('/panel/api/inbounds/list', 'POST'),
|
|
||||||
('/panel/inbound/list', 'POST'),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
payload = await self._request(method, path)
|
|
||||||
except XuiApiError:
|
|
||||||
continue
|
|
||||||
obj = payload.get('obj') if isinstance(payload, dict) else None
|
|
||||||
if isinstance(obj, list):
|
|
||||||
return obj
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def list_vless_inbounds(self) -> list:
|
|
||||||
result = []
|
|
||||||
for inbound in await self.list_inbounds():
|
|
||||||
if not isinstance(inbound, dict):
|
|
||||||
continue
|
|
||||||
proto = (inbound.get('protocol') or '').lower()
|
|
||||||
if proto != 'vless':
|
|
||||||
continue
|
|
||||||
result.append({
|
|
||||||
'id': inbound.get('id'),
|
|
||||||
'remark': inbound.get('remark') or f"VLESS:{inbound.get('port')}",
|
|
||||||
'port': inbound.get('port'),
|
|
||||||
'protocol': proto,
|
|
||||||
'enable': bool(inbound.get('enable', True)),
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def get_inbound(self, inbound_id: int) -> dict:
|
|
||||||
inbound_id = int(inbound_id)
|
|
||||||
for inbound in await self.list_inbounds():
|
|
||||||
if isinstance(inbound, dict) and int(inbound.get('id') or 0) == inbound_id:
|
|
||||||
return inbound
|
|
||||||
raise XuiApiError(f'Inbound #{inbound_id} not found on 3x-ui')
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_inbound_settings(inbound: dict) -> dict:
|
|
||||||
settings_raw = inbound.get('settings') or '{}'
|
|
||||||
if isinstance(settings_raw, str):
|
|
||||||
try:
|
|
||||||
settings_obj = json.loads(settings_raw)
|
|
||||||
except Exception:
|
|
||||||
settings_obj = {}
|
|
||||||
else:
|
|
||||||
settings_obj = settings_raw if isinstance(settings_raw, dict) else {}
|
|
||||||
return settings_obj if isinstance(settings_obj, dict) else {}
|
|
||||||
|
|
||||||
def _client_template_from_inbound(self, inbound: dict) -> dict:
|
|
||||||
"""Copy only fields 3x-ui already uses on this inbound — do not invent extras."""
|
|
||||||
settings_obj = self._parse_inbound_settings(inbound)
|
|
||||||
clients = settings_obj.get('clients') or []
|
|
||||||
template = next((c for c in clients if isinstance(c, dict)), None) or {}
|
|
||||||
flow = template.get('flow')
|
|
||||||
if not isinstance(flow, str):
|
|
||||||
flow = ''
|
|
||||||
# If no existing clients, detect Reality → vision (required for those inbounds)
|
|
||||||
if not flow:
|
|
||||||
stream_raw = inbound.get('streamSettings') or '{}'
|
|
||||||
if isinstance(stream_raw, str):
|
|
||||||
try:
|
|
||||||
stream = json.loads(stream_raw)
|
|
||||||
except Exception:
|
|
||||||
stream = {}
|
|
||||||
else:
|
|
||||||
stream = stream_raw if isinstance(stream_raw, dict) else {}
|
|
||||||
security = (stream.get('security') or '').lower()
|
|
||||||
network = (stream.get('network') or '').lower()
|
|
||||||
if security == 'reality' and network in ('tcp', 'raw', ''):
|
|
||||||
flow = 'xtls-rprx-vision'
|
|
||||||
return {
|
|
||||||
'flow': flow,
|
|
||||||
'limitIp': int(template.get('limitIp') or 0),
|
|
||||||
'totalGB': 0,
|
|
||||||
'tgId': '' if template.get('tgId') in (None, 0) else str(template.get('tgId') or ''),
|
|
||||||
}
|
|
||||||
|
|
||||||
async def add_vless_client(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
email: str,
|
|
||||||
inbound_id: int,
|
|
||||||
enable: bool = True,
|
|
||||||
expiry_time: int = 0,
|
|
||||||
) -> dict:
|
|
||||||
"""Add client via 3x-ui addClient only. Minimal payload; flow taken from inbound."""
|
|
||||||
email = (email or '').strip()
|
|
||||||
if not email:
|
|
||||||
raise XuiApiError('Client email is required')
|
|
||||||
if not inbound_id:
|
|
||||||
raise XuiApiError('VLESS inbound id is required')
|
|
||||||
|
|
||||||
inbound = await self.get_inbound(int(inbound_id))
|
|
||||||
if (inbound.get('protocol') or '').lower() != 'vless':
|
|
||||||
raise XuiApiError(f'Inbound #{inbound_id} is not VLESS')
|
|
||||||
|
|
||||||
tmpl = self._client_template_from_inbound(inbound)
|
|
||||||
client_uuid = str(uuid.uuid4())
|
|
||||||
sub_id = _new_sub_id()
|
|
||||||
|
|
||||||
# Minimal client object — nothing beyond what 3x-ui expects for this inbound
|
|
||||||
client = {
|
|
||||||
'id': client_uuid,
|
|
||||||
'email': email,
|
|
||||||
'enable': bool(enable),
|
|
||||||
'flow': tmpl['flow'],
|
|
||||||
'limitIp': tmpl['limitIp'],
|
|
||||||
'totalGB': 0,
|
|
||||||
'expiryTime': int(expiry_time or 0),
|
|
||||||
'tgId': tmpl['tgId'],
|
|
||||||
'subId': sub_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Official path: addClient with settings as JSON string (object form breaks some builds)
|
|
||||||
await self._request(
|
|
||||||
'POST',
|
|
||||||
'/panel/api/inbounds/addClient',
|
|
||||||
json={
|
|
||||||
'id': int(inbound_id),
|
|
||||||
'settings': json.dumps({'clients': [client]}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Share links ONLY from the panel — never build vless:// here
|
|
||||||
links = await self.get_client_links(email)
|
|
||||||
if not links:
|
|
||||||
links = await self.get_sub_links(sub_id)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'client_id': email,
|
|
||||||
'uuid': client_uuid,
|
|
||||||
'sub_id': sub_id,
|
|
||||||
'email': email,
|
|
||||||
'config': '',
|
|
||||||
'links': links,
|
|
||||||
'expiry_time': int(expiry_time or 0),
|
|
||||||
'inbound_id': int(inbound_id),
|
|
||||||
}
|
|
||||||
|
|
||||||
async def get_client_links(self, email: str) -> list:
|
|
||||||
email = (email or '').strip()
|
|
||||||
if not email:
|
|
||||||
return []
|
|
||||||
for path in (
|
|
||||||
f'/panel/api/clients/links/{quote(email, safe="")}',
|
|
||||||
f'/panel/api/inbounds/clientLinks/0/{quote(email, safe="")}',
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
payload = await self._request('GET', path)
|
|
||||||
obj = payload.get('obj') if isinstance(payload, dict) else None
|
|
||||||
if isinstance(obj, list):
|
|
||||||
return [x for x in obj if isinstance(x, str) and x.strip()]
|
|
||||||
except XuiApiError as e:
|
|
||||||
logger.warning('get_client_links %s failed: %s', path, e)
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def get_sub_links(self, sub_id: str) -> list:
|
|
||||||
"""Ask 3x-ui for protocol URLs produced for this subscription id."""
|
|
||||||
sub_id = (sub_id or '').strip()
|
|
||||||
if not sub_id:
|
|
||||||
return []
|
|
||||||
path = f'/panel/api/inbounds/getSubLinks/{quote(sub_id, safe="")}'
|
|
||||||
try:
|
|
||||||
payload = await self._request('GET', path)
|
|
||||||
obj = payload.get('obj') if isinstance(payload, dict) else None
|
|
||||||
if isinstance(obj, list):
|
|
||||||
return [x for x in obj if isinstance(x, str) and x.strip()]
|
|
||||||
except XuiApiError as e:
|
|
||||||
logger.warning('getSubLinks failed: %s', e)
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def get_panel_sub_base(self) -> str:
|
|
||||||
"""Try to read public subscription base URL from 3x-ui panel settings."""
|
|
||||||
for path, method in (
|
|
||||||
('/panel/api/setting', 'GET'),
|
|
||||||
('/panel/setting/all', 'POST'),
|
|
||||||
('/panel/api/settings', 'GET'),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
payload = await self._request(method, path)
|
|
||||||
except XuiApiError:
|
|
||||||
continue
|
|
||||||
obj = payload.get('obj') if isinstance(payload, dict) else payload
|
|
||||||
if not isinstance(obj, dict):
|
|
||||||
continue
|
|
||||||
# Common field names across 3x-ui versions
|
|
||||||
sub_uri = (obj.get('subURI') or obj.get('subUri') or obj.get('sub_uri') or '').strip()
|
|
||||||
sub_path = (obj.get('subPath') or obj.get('sub_path') or '/sub').strip() or '/sub'
|
|
||||||
sub_port = obj.get('subPort') or obj.get('sub_port')
|
|
||||||
sub_listen = (obj.get('subListen') or '').strip()
|
|
||||||
if sub_uri:
|
|
||||||
return sub_uri.rstrip('/')
|
|
||||||
# Build from panel host + sub path if only path is configured
|
|
||||||
if sub_path and self.base_url:
|
|
||||||
from urllib.parse import urlparse, urlunparse
|
|
||||||
parsed = urlparse(self.base_url)
|
|
||||||
host = sub_listen or parsed.hostname or ''
|
|
||||||
if not host:
|
|
||||||
continue
|
|
||||||
scheme = 'https' if parsed.scheme == 'https' else 'http'
|
|
||||||
netloc = f'{host}:{sub_port}' if sub_port else host
|
|
||||||
path_part = sub_path if sub_path.startswith('/') else f'/{sub_path}'
|
|
||||||
return urlunparse((scheme, netloc, path_part.rstrip('/'), '', '', ''))
|
|
||||||
return ''
|
|
||||||
|
|
||||||
async def resolve_share_link(self, *, email: str, sub_id: str = '', configured_sub_base: str = '') -> dict:
|
|
||||||
"""Use only links returned by 3x-ui. Do not synthesize protocol configs."""
|
|
||||||
email = (email or '').strip()
|
|
||||||
sub_id = (sub_id or '').strip()
|
|
||||||
|
|
||||||
links: list = []
|
|
||||||
if email:
|
|
||||||
links = await self.get_client_links(email)
|
|
||||||
if not links and sub_id:
|
|
||||||
links = await self.get_sub_links(sub_id)
|
|
||||||
|
|
||||||
# Prefer protocol share from panel; then HTTP(S) subscription URL from panel
|
|
||||||
panel_proto = next(
|
|
||||||
(u for u in links if isinstance(u, str) and u.startswith(('vless://', 'vmess://', 'trojan://', 'ss://'))),
|
|
||||||
'',
|
|
||||||
)
|
|
||||||
panel_http = next(
|
|
||||||
(u for u in links if isinstance(u, str) and u.startswith(('http://', 'https://'))),
|
|
||||||
'',
|
|
||||||
)
|
|
||||||
|
|
||||||
# Subscription URL: panel HTTP link first; optional configured base only as last resort
|
|
||||||
base = (configured_sub_base or '').strip().rstrip('/')
|
|
||||||
if not panel_http and not base:
|
|
||||||
try:
|
|
||||||
base = (await self.get_panel_sub_base() or '').rstrip('/')
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning('get_panel_sub_base failed: %s', e)
|
|
||||||
base = ''
|
|
||||||
subscription_url = panel_http or (f'{base}/{sub_id}' if base and sub_id else '')
|
|
||||||
|
|
||||||
# Prefer panel-generated protocol link (real config); else subscription URL
|
|
||||||
share = panel_proto or subscription_url or (links[0] if links else '')
|
|
||||||
return {
|
|
||||||
'subscription_url': subscription_url,
|
|
||||||
'links': links,
|
|
||||||
'share': share,
|
|
||||||
'sub_base': base,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_client(self, email: str) -> None:
|
|
||||||
email = (email or '').strip()
|
|
||||||
if not email:
|
|
||||||
return
|
|
||||||
path = f'/panel/api/clients/del/{quote(email, safe="")}'
|
|
||||||
try:
|
|
||||||
await self._request('POST', path)
|
|
||||||
return
|
|
||||||
except XuiApiError as e:
|
|
||||||
logger.warning('clients/del failed (%s), trying legacy', e)
|
|
||||||
# Legacy: need inbound id — try remove by scanning
|
|
||||||
for inbound in await self.list_inbounds():
|
|
||||||
if not isinstance(inbound, dict):
|
|
||||||
continue
|
|
||||||
settings = inbound.get('settings')
|
|
||||||
if isinstance(settings, str):
|
|
||||||
try:
|
|
||||||
settings = json.loads(settings)
|
|
||||||
except Exception:
|
|
||||||
settings = {}
|
|
||||||
clients = (settings or {}).get('clients') if isinstance(settings, dict) else None
|
|
||||||
if not isinstance(clients, list):
|
|
||||||
continue
|
|
||||||
match = next((c for c in clients if isinstance(c, dict) and c.get('email') == email), None)
|
|
||||||
if not match:
|
|
||||||
continue
|
|
||||||
cid = match.get('id') or email
|
|
||||||
inbound_id = inbound.get('id')
|
|
||||||
for path in (
|
|
||||||
f'/panel/api/inbounds/{inbound_id}/delClient/{quote(str(cid), safe="")}',
|
|
||||||
f'/panel/api/inbounds/{inbound_id}/delClientByEmail/{quote(email, safe="")}',
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
await self._request('POST', path)
|
|
||||||
return
|
|
||||||
except XuiApiError:
|
|
||||||
continue
|
|
||||||
raise XuiApiError(f'Failed to delete 3x-ui client {email}')
|
|
||||||
|
|
||||||
async def set_client_enabled(self, email: str, enable: bool) -> None:
|
|
||||||
email = (email or '').strip()
|
|
||||||
if not email:
|
|
||||||
return
|
|
||||||
# Prefer full client get + update
|
|
||||||
try:
|
|
||||||
payload = await self._request('GET', f'/panel/api/clients/get/{quote(email, safe="")}')
|
|
||||||
obj = payload.get('obj') if isinstance(payload, dict) else None
|
|
||||||
client = None
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
client = obj.get('client') if isinstance(obj.get('client'), dict) else obj
|
|
||||||
if isinstance(client, dict):
|
|
||||||
client = dict(client)
|
|
||||||
client['enable'] = bool(enable)
|
|
||||||
client['email'] = email
|
|
||||||
await self._request(
|
|
||||||
'POST',
|
|
||||||
f'/panel/api/clients/update/{quote(email, safe="")}',
|
|
||||||
json=client,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
except XuiApiError as e:
|
|
||||||
logger.warning('toggle via clients/update failed: %s', e)
|
|
||||||
raise XuiApiError(f'Failed to toggle 3x-ui client {email}')
|
|
||||||
|
|
||||||
|
|
||||||
async def xui_create_vless_config(
|
|
||||||
settings: dict,
|
|
||||||
*,
|
|
||||||
name: str,
|
|
||||||
inbound_id: Optional[int] = None,
|
|
||||||
expiry_time: int = 0,
|
|
||||||
panel_id: Optional[str] = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Create client on selected 3x-ui inbound; return only links from the panel."""
|
|
||||||
scoped = _resolve_settings(settings, panel_id)
|
|
||||||
creds = _settings_creds(scoped)
|
|
||||||
inbound = inbound_id if inbound_id not in (None, 0, '0') else creds.get('inbound_id')
|
|
||||||
try:
|
|
||||||
inbound = int(inbound or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
inbound = 0
|
|
||||||
if not inbound:
|
|
||||||
raise XuiApiError('Select a VLESS inbound from 3x-ui')
|
|
||||||
|
|
||||||
async with XuiApi.from_panel_settings(scoped) as api:
|
|
||||||
base = ''.join(ch if ch.isalnum() or ch in '._-+@' else '_' for ch in (name or 'user').strip())
|
|
||||||
base = base[:48] or f'user_{secrets.token_hex(4)}'
|
|
||||||
email = base
|
|
||||||
created = None
|
|
||||||
for _ in range(5):
|
|
||||||
try:
|
|
||||||
created = await api.add_vless_client(
|
|
||||||
email=email,
|
|
||||||
inbound_id=inbound,
|
|
||||||
expiry_time=int(expiry_time or 0),
|
|
||||||
)
|
|
||||||
break
|
|
||||||
except XuiApiError as e:
|
|
||||||
if 'exist' in str(e).lower() or 'duplicate' in str(e).lower() or 'already' in str(e).lower():
|
|
||||||
email = f'{base}_{secrets.token_hex(3)}'
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
if created is None:
|
|
||||||
created = await api.add_vless_client(
|
|
||||||
email=email,
|
|
||||||
inbound_id=inbound,
|
|
||||||
expiry_time=int(expiry_time or 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
share = await api.resolve_share_link(
|
|
||||||
email=created.get('email') or email,
|
|
||||||
sub_id=created.get('sub_id') or '',
|
|
||||||
configured_sub_base=creds.get('sub_url') or '',
|
|
||||||
)
|
|
||||||
panel_links = share.get('links') or created.get('links') or []
|
|
||||||
created['links'] = panel_links
|
|
||||||
created['subscription_url'] = share.get('subscription_url') or ''
|
|
||||||
# Prefer real vless:// (etc.) from 3x-ui; subscription URL only as fallback
|
|
||||||
created['config'] = share.get('share') or ''
|
|
||||||
if not created['config'] and panel_links:
|
|
||||||
created['config'] = panel_links[0]
|
|
||||||
created['inbound_id'] = int(created.get('inbound_id') or inbound)
|
|
||||||
created['panel_id'] = panel_id or ''
|
|
||||||
return created
|
|
||||||
|
|
||||||
|
|
||||||
async def xui_get_config(settings: dict, email: str, panel_id: Optional[str] = None) -> str:
|
|
||||||
scoped = _resolve_settings(settings, panel_id)
|
|
||||||
creds = _settings_creds(scoped)
|
|
||||||
async with XuiApi.from_panel_settings(scoped) as api:
|
|
||||||
sub_id = ''
|
|
||||||
try:
|
|
||||||
for inbound in await api.list_inbounds():
|
|
||||||
if not isinstance(inbound, dict):
|
|
||||||
continue
|
|
||||||
settings_raw = inbound.get('settings') or '{}'
|
|
||||||
if isinstance(settings_raw, str):
|
|
||||||
try:
|
|
||||||
settings_obj = json.loads(settings_raw)
|
|
||||||
except Exception:
|
|
||||||
settings_obj = {}
|
|
||||||
else:
|
|
||||||
settings_obj = settings_raw if isinstance(settings_raw, dict) else {}
|
|
||||||
for c in settings_obj.get('clients') or []:
|
|
||||||
if isinstance(c, dict) and (c.get('email') or '') == email:
|
|
||||||
sub_id = (c.get('subId') or c.get('sub_id') or '').strip()
|
|
||||||
break
|
|
||||||
if sub_id:
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning('Could not resolve subId for %s: %s', email, e)
|
|
||||||
|
|
||||||
share = await api.resolve_share_link(
|
|
||||||
email=email,
|
|
||||||
sub_id=sub_id,
|
|
||||||
configured_sub_base=creds.get('sub_url') or '',
|
|
||||||
)
|
|
||||||
if share.get('share'):
|
|
||||||
return share['share']
|
|
||||||
links = share.get('links') or await api.get_client_links(email)
|
|
||||||
vless = next((u for u in links if isinstance(u, str) and u.startswith('vless://')), None)
|
|
||||||
if vless:
|
|
||||||
return vless
|
|
||||||
if links:
|
|
||||||
return links[0]
|
|
||||||
raise XuiApiError(f'No share links for 3x-ui client {email}')
|
|
||||||
|
|
||||||
|
|
||||||
async def xui_delete_client(settings: dict, email: str, panel_id: Optional[str] = None) -> None:
|
|
||||||
scoped = _resolve_settings(settings, panel_id)
|
|
||||||
async with XuiApi.from_panel_settings(scoped) as api:
|
|
||||||
await api.delete_client(email)
|
|
||||||
|
|
||||||
|
|
||||||
async def xui_toggle_client(settings: dict, email: str, enable: bool, panel_id: Optional[str] = None) -> None:
|
|
||||||
scoped = _resolve_settings(settings, panel_id)
|
|
||||||
async with XuiApi.from_panel_settings(scoped) as api:
|
|
||||||
await api.set_client_enabled(email, enable)
|
|
||||||
|
|
||||||
|
|
||||||
async def xui_list_vless_inbounds(settings: dict, panel_id: Optional[str] = None) -> list:
|
|
||||||
scoped = _resolve_settings(settings, panel_id)
|
|
||||||
async with XuiApi.from_panel_settings(scoped) as api:
|
|
||||||
return await api.list_vless_inbounds()
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
"""Multi-panel 3x-ui server registry stored in settings.xui_servers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
|
|
||||||
def _new_id() -> str:
|
|
||||||
return str(uuid.uuid4())
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_server(raw: dict | None) -> dict:
|
|
||||||
raw = raw or {}
|
|
||||||
return {
|
|
||||||
'id': str(raw.get('id') or _new_id()),
|
|
||||||
'name': (raw.get('name') or '').strip() or '3x-ui',
|
|
||||||
'url': (raw.get('url') or '').strip().rstrip('/'),
|
|
||||||
'sub_url': (raw.get('sub_url') or '').strip().rstrip('/'),
|
|
||||||
'api_token': (raw.get('api_token') or '').strip(),
|
|
||||||
'username': (raw.get('username') or '').strip(),
|
|
||||||
'password': raw.get('password') or '',
|
|
||||||
'default_inbound_id': int(raw.get('default_inbound_id') or 0),
|
|
||||||
'enabled': bool(raw.get('enabled', True)),
|
|
||||||
'sync_users': bool(raw.get('sync_users', False)),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def legacy_sync_to_server(sync: dict) -> Optional[dict]:
|
|
||||||
"""Build one xui_servers entry from old settings.sync.xui_* fields."""
|
|
||||||
sync = sync or {}
|
|
||||||
url = (sync.get('xui_url') or '').strip()
|
|
||||||
if not url:
|
|
||||||
return None
|
|
||||||
return normalize_server({
|
|
||||||
'id': 'legacy-default',
|
|
||||||
'name': '3x-ui (default)',
|
|
||||||
'url': url,
|
|
||||||
'sub_url': sync.get('xui_sub_url') or '',
|
|
||||||
'api_token': sync.get('xui_api_token') or '',
|
|
||||||
'username': sync.get('xui_username') or '',
|
|
||||||
'password': sync.get('xui_password') or '',
|
|
||||||
'default_inbound_id': int(sync.get('xui_inbound_id') or 0),
|
|
||||||
'enabled': True,
|
|
||||||
'sync_users': bool(sync.get('xui_sync_users')),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_xui_servers(settings: dict) -> list:
|
|
||||||
"""Ensure settings['xui_servers'] exists; migrate legacy sync fields once."""
|
|
||||||
settings = settings if isinstance(settings, dict) else {}
|
|
||||||
servers = settings.get('xui_servers')
|
|
||||||
if not isinstance(servers, list):
|
|
||||||
servers = []
|
|
||||||
servers = [normalize_server(s) for s in servers if isinstance(s, dict)]
|
|
||||||
|
|
||||||
if not servers:
|
|
||||||
migrated = legacy_sync_to_server(settings.get('sync') or {})
|
|
||||||
if migrated:
|
|
||||||
servers = [migrated]
|
|
||||||
settings['xui_servers'] = servers
|
|
||||||
|
|
||||||
# Mirror primary into legacy sync.* for older code paths (no get_xui_server — avoids recursion)
|
|
||||||
sync = settings.setdefault('sync', {})
|
|
||||||
primary = None
|
|
||||||
for s in servers:
|
|
||||||
if s.get('enabled'):
|
|
||||||
primary = s
|
|
||||||
break
|
|
||||||
if primary is None and servers:
|
|
||||||
primary = servers[0]
|
|
||||||
if primary and primary.get('url'):
|
|
||||||
sync.setdefault('xui_url', primary.get('url') or '')
|
|
||||||
sync.setdefault('xui_sub_url', primary.get('sub_url') or '')
|
|
||||||
sync.setdefault('xui_api_token', primary.get('api_token') or '')
|
|
||||||
sync.setdefault('xui_username', primary.get('username') or '')
|
|
||||||
sync.setdefault('xui_password', primary.get('password') or '')
|
|
||||||
sync.setdefault('xui_inbound_id', int(primary.get('default_inbound_id') or 0))
|
|
||||||
return servers
|
|
||||||
|
|
||||||
|
|
||||||
def list_xui_servers(settings: dict, *, enabled_only: bool = False) -> list:
|
|
||||||
servers = ensure_xui_servers(settings)
|
|
||||||
if enabled_only:
|
|
||||||
return [s for s in servers if s.get('enabled')]
|
|
||||||
return servers
|
|
||||||
|
|
||||||
|
|
||||||
def get_xui_server(settings: dict, panel_id: Optional[str] = None) -> Optional[dict]:
|
|
||||||
servers = ensure_xui_servers(settings)
|
|
||||||
if not servers:
|
|
||||||
return None
|
|
||||||
if panel_id:
|
|
||||||
for s in servers:
|
|
||||||
if str(s.get('id')) == str(panel_id):
|
|
||||||
return s
|
|
||||||
return None
|
|
||||||
for s in servers:
|
|
||||||
if s.get('enabled'):
|
|
||||||
return s
|
|
||||||
return servers[0]
|
|
||||||
|
|
||||||
|
|
||||||
def public_server_view(server: dict) -> dict:
|
|
||||||
"""Safe view for UI (no password/token secrets in list if desired — we keep them for admin edit)."""
|
|
||||||
return {
|
|
||||||
'id': server.get('id'),
|
|
||||||
'name': server.get('name') or '3x-ui',
|
|
||||||
'url': server.get('url') or '',
|
|
||||||
'sub_url': server.get('sub_url') or '',
|
|
||||||
'api_token': server.get('api_token') or '',
|
|
||||||
'username': server.get('username') or '',
|
|
||||||
'password': server.get('password') or '',
|
|
||||||
'default_inbound_id': int(server.get('default_inbound_id') or 0),
|
|
||||||
'enabled': bool(server.get('enabled', True)),
|
|
||||||
'sync_users': bool(server.get('sync_users', False)),
|
|
||||||
'has_token': bool(server.get('api_token')),
|
|
||||||
'has_password': bool(server.get('password')),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def upsert_xui_server(settings: dict, payload: dict, *, panel_id: Optional[str] = None) -> dict:
|
|
||||||
servers = ensure_xui_servers(settings)
|
|
||||||
panel_id = panel_id or payload.get('id')
|
|
||||||
existing = None
|
|
||||||
if panel_id:
|
|
||||||
for s in servers:
|
|
||||||
if str(s.get('id')) == str(panel_id):
|
|
||||||
existing = s
|
|
||||||
break
|
|
||||||
|
|
||||||
merged = dict(existing or {})
|
|
||||||
for key in ('name', 'url', 'sub_url', 'api_token', 'username', 'password'):
|
|
||||||
if key in payload and payload[key] is not None:
|
|
||||||
# Empty password/token on edit means "keep previous" when editing
|
|
||||||
if key in ('password', 'api_token') and existing and payload[key] == '':
|
|
||||||
continue
|
|
||||||
merged[key] = payload[key]
|
|
||||||
if 'default_inbound_id' in payload and payload['default_inbound_id'] is not None:
|
|
||||||
merged['default_inbound_id'] = int(payload['default_inbound_id'] or 0)
|
|
||||||
if 'enabled' in payload and payload['enabled'] is not None:
|
|
||||||
merged['enabled'] = bool(payload['enabled'])
|
|
||||||
if 'sync_users' in payload and payload['sync_users'] is not None:
|
|
||||||
merged['sync_users'] = bool(payload['sync_users'])
|
|
||||||
if not merged.get('id'):
|
|
||||||
merged['id'] = _new_id()
|
|
||||||
|
|
||||||
server = normalize_server(merged)
|
|
||||||
if not server['url']:
|
|
||||||
raise ValueError('3x-ui URL is required')
|
|
||||||
if not server['name']:
|
|
||||||
server['name'] = server['url']
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
for i, s in enumerate(servers):
|
|
||||||
if str(s.get('id')) == str(existing.get('id')):
|
|
||||||
servers[i] = server
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
servers.append(server)
|
|
||||||
|
|
||||||
settings['xui_servers'] = servers
|
|
||||||
_mirror_primary_to_sync(settings)
|
|
||||||
return server
|
|
||||||
|
|
||||||
|
|
||||||
def delete_xui_server(settings: dict, panel_id: str) -> bool:
|
|
||||||
servers = ensure_xui_servers(settings)
|
|
||||||
new_list = [s for s in servers if str(s.get('id')) != str(panel_id)]
|
|
||||||
if len(new_list) == len(servers):
|
|
||||||
return False
|
|
||||||
settings['xui_servers'] = new_list
|
|
||||||
_mirror_primary_to_sync(settings)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _mirror_primary_to_sync(settings: dict) -> None:
|
|
||||||
sync = settings.setdefault('sync', {})
|
|
||||||
primary = get_xui_server(settings, None)
|
|
||||||
if not primary:
|
|
||||||
sync['xui_url'] = ''
|
|
||||||
sync['xui_sub_url'] = ''
|
|
||||||
return
|
|
||||||
sync['xui_url'] = primary.get('url') or ''
|
|
||||||
sync['xui_sub_url'] = primary.get('sub_url') or ''
|
|
||||||
sync['xui_api_token'] = primary.get('api_token') or ''
|
|
||||||
sync['xui_username'] = primary.get('username') or ''
|
|
||||||
sync['xui_password'] = primary.get('password') or ''
|
|
||||||
sync['xui_inbound_id'] = int(primary.get('default_inbound_id') or 0)
|
|
||||||
if any(s.get('sync_users') for s in settings.get('xui_servers') or []):
|
|
||||||
sync['xui_sync_users'] = True
|
|
||||||
|
|
||||||
|
|
||||||
def server_to_settings_slice(server: dict) -> dict:
|
|
||||||
"""Shape expected by xui_api._settings_creds / from_panel_settings."""
|
|
||||||
return {
|
|
||||||
'sync': {
|
|
||||||
'xui_url': server.get('url') or '',
|
|
||||||
'xui_sub_url': server.get('sub_url') or '',
|
|
||||||
'xui_api_token': server.get('api_token') or '',
|
|
||||||
'xui_username': server.get('username') or '',
|
|
||||||
'xui_password': server.get('password') or '',
|
|
||||||
'xui_inbound_id': int(server.get('default_inbound_id') or 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_panel_settings(full_settings: dict, panel_id: Optional[str] = None) -> dict:
|
|
||||||
"""Return a settings-like dict scoped to one 3x-ui panel (fallback: primary / legacy)."""
|
|
||||||
server = get_xui_server(full_settings, panel_id)
|
|
||||||
if server:
|
|
||||||
return server_to_settings_slice(server)
|
|
||||||
# Fallback to legacy global sync
|
|
||||||
return {'sync': (full_settings or {}).get('sync') or {}}
|
|
||||||
@@ -139,7 +139,6 @@
|
|||||||
if (p === 'awg2') return 'AmneziaWG 2.0';
|
if (p === 'awg2') return 'AmneziaWG 2.0';
|
||||||
if (p === 'awg_legacy') return 'AWG Legacy';
|
if (p === 'awg_legacy') return 'AWG Legacy';
|
||||||
if (p === 'xray') return 'Xray';
|
if (p === 'xray') return 'Xray';
|
||||||
if (p === 'xui') return '3x-ui VLESS';
|
|
||||||
return (p || '').toUpperCase();
|
return (p || '').toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-99
@@ -17,18 +17,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if not xui_has_sub_url and xui_configured %}
|
|
||||||
<div class="card" style="margin-bottom:var(--space-lg); border-color: rgba(245, 158, 11, 0.35); background: rgba(245, 158, 11, 0.06);">
|
|
||||||
<div style="display:flex; gap:var(--space-md); align-items:flex-start;">
|
|
||||||
<div style="font-size:1.4rem;">⚠️</div>
|
|
||||||
<div>
|
|
||||||
<div style="font-weight:600; margin-bottom:4px;">{{ _('invite_sub_url_missing_title') }}</div>
|
|
||||||
<div class="form-hint" style="margin:0;">{{ _('invite_sub_url_missing_hint') }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div id="invitesEmpty" class="empty-state {% if invites %}hidden{% endif %}">
|
<div id="invitesEmpty" class="empty-state {% if invites %}hidden{% endif %}">
|
||||||
<div class="empty-icon">🔗</div>
|
<div class="empty-icon">🔗</div>
|
||||||
<div class="empty-title">{{ _('invites_empty') }}</div>
|
<div class="empty-title">{{ _('invites_empty') }}</div>
|
||||||
@@ -42,12 +30,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div style="font-weight:700; font-size:1.05rem;">{{ inv.name }}</div>
|
<div style="font-weight:700; font-size:1.05rem;">{{ inv.name }}</div>
|
||||||
<div style="font-size:0.8rem; color:var(--text-muted); margin-top:2px;">
|
<div style="font-size:0.8rem; color:var(--text-muted); margin-top:2px;">
|
||||||
{% if inv.protocol == 'xui' %}
|
{{ inv.protocol }}
|
||||||
3x-ui · {% for s in xui_servers if s.id == inv.xui_panel_id %}{{ s.name }}{% else %}{{ _('xui_server_select_label') }}{% endfor %}
|
|
||||||
· inbound #{{ inv.xui_inbound_id or '—' }}
|
|
||||||
{% else %}
|
|
||||||
{{ inv.protocol }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if inv.available %}
|
{% if inv.available %}
|
||||||
@@ -127,8 +110,7 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">{{ _('protocol_label') }}</label>
|
<label class="form-label">{{ _('protocol_label') }}</label>
|
||||||
<select class="form-select" id="inviteProtocol" onchange="updateInviteProtoFields()">
|
<select class="form-select" id="inviteProtocol">
|
||||||
<option value="xui">3x-ui VLESS</option>
|
|
||||||
{% for s in servers %}
|
{% for s in servers %}
|
||||||
{% set server_idx = loop.index0 %}
|
{% set server_idx = loop.index0 %}
|
||||||
{% for key, info in (s.protocols or {}).items() %}
|
{% for key, info in (s.protocols or {}).items() %}
|
||||||
@@ -140,21 +122,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="inviteInboundGroup">
|
|
||||||
<label class="form-label">{{ _('xui_server_select_label') }}</label>
|
|
||||||
<select class="form-select" id="inviteXuiPanel" onchange="loadInviteInbounds()">
|
|
||||||
{% for s in xui_servers %}
|
|
||||||
<option value="{{ s.id }}">{{ s.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<div class="form-hint">{{ _('xui_server_select_hint') }}</div>
|
|
||||||
<label class="form-label" style="margin-top:var(--space-sm);">{{ _('xui_inbound_label') }}</label>
|
|
||||||
<select class="form-select" id="inviteInboundId">
|
|
||||||
<option value="">{{ _('invite_inbound_loading') }}</option>
|
|
||||||
</select>
|
|
||||||
<div class="form-hint">{{ _('invite_inbound_hint') }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">{{ _('invite_password_label') }}</label>
|
<label class="form-label">{{ _('invite_password_label') }}</label>
|
||||||
<input class="form-input" type="password" id="invitePassword" placeholder="{{ _('share_password_hint') }}" autocomplete="new-password">
|
<input class="form-input" type="password" id="invitePassword" placeholder="{{ _('share_password_hint') }}" autocomplete="new-password">
|
||||||
@@ -186,9 +153,6 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const invitesData = {{ invites | tojson }};
|
const invitesData = {{ invites | tojson }};
|
||||||
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
|
|
||||||
const xuiDefaultInbound = {{ xui_default_inbound | int }};
|
|
||||||
const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }};
|
|
||||||
|
|
||||||
function inviteUrl(token) {
|
function inviteUrl(token) {
|
||||||
return `${window.location.origin}/invite/${token}`;
|
return `${window.location.origin}/invite/${token}`;
|
||||||
@@ -199,46 +163,8 @@
|
|||||||
showToast(_('copied'), 'success');
|
showToast(_('copied'), 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateInviteProtoFields() {
|
|
||||||
const v = document.getElementById('inviteProtocol').value;
|
|
||||||
document.getElementById('inviteInboundGroup').style.display = (v === 'xui') ? '' : 'none';
|
|
||||||
if (v === 'xui') loadInviteInbounds(document.getElementById('inviteInboundId').value || xuiDefaultInbound);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadInviteInbounds(selected) {
|
|
||||||
const select = document.getElementById('inviteInboundId');
|
|
||||||
select.innerHTML = `<option value="">${_('invite_inbound_loading')}</option>`;
|
|
||||||
if (!xuiConfigured) {
|
|
||||||
select.innerHTML = `<option value="">${_('invite_inbound_need_xui')}</option>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const panelId = document.getElementById('inviteXuiPanel')?.value || xuiDefaultPanelId || '';
|
|
||||||
const q = panelId ? `?panel_id=${encodeURIComponent(panelId)}` : '';
|
|
||||||
const data = await apiCall('/api/settings/xui/inbounds' + q);
|
|
||||||
const list = data.inbounds || [];
|
|
||||||
if (!list.length) {
|
|
||||||
select.innerHTML = `<option value="">${_('invite_inbound_empty')}</option>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
select.innerHTML = '';
|
|
||||||
list.forEach((ib, idx) => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = ib.id;
|
|
||||||
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
|
|
||||||
const prefer = selected || xuiDefaultInbound;
|
|
||||||
if (prefer && String(ib.id) === String(prefer)) opt.selected = true;
|
|
||||||
else if (!prefer && idx === 0) opt.selected = true;
|
|
||||||
select.appendChild(opt);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
select.innerHTML = `<option value="">${_('error')}: ${e.message}</option>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseProtocolValue() {
|
function parseProtocolValue() {
|
||||||
const raw = document.getElementById('inviteProtocol').value;
|
const raw = document.getElementById('inviteProtocol').value;
|
||||||
if (raw === 'xui') return { protocol: 'xui', server_id: 0 };
|
|
||||||
if (raw.includes('|')) {
|
if (raw.includes('|')) {
|
||||||
const [protocol, sid] = raw.split('|');
|
const [protocol, sid] = raw.split('|');
|
||||||
return { protocol, server_id: parseInt(sid || '0') };
|
return { protocol, server_id: parseInt(sid || '0') };
|
||||||
@@ -248,13 +174,9 @@
|
|||||||
|
|
||||||
function setProtocolSelect(protocol, serverId) {
|
function setProtocolSelect(protocol, serverId) {
|
||||||
const sel = document.getElementById('inviteProtocol');
|
const sel = document.getElementById('inviteProtocol');
|
||||||
if (protocol === 'xui') sel.value = 'xui';
|
const want = `${protocol}|${serverId || 0}`;
|
||||||
else {
|
if ([...sel.options].some(o => o.value === want)) sel.value = want;
|
||||||
const want = `${protocol}|${serverId || 0}`;
|
else if (sel.options.length) sel.value = sel.options[0].value;
|
||||||
if ([...sel.options].some(o => o.value === want)) sel.value = want;
|
|
||||||
else sel.value = 'xui';
|
|
||||||
}
|
|
||||||
updateInviteProtoFields();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreateInvite() {
|
function openCreateInvite() {
|
||||||
@@ -268,12 +190,7 @@
|
|||||||
document.getElementById('inviteNote').value = '';
|
document.getElementById('inviteNote').value = '';
|
||||||
document.getElementById('inviteClearPwdWrap').style.display = 'none';
|
document.getElementById('inviteClearPwdWrap').style.display = 'none';
|
||||||
document.getElementById('inviteResetUsedWrap').style.display = 'none';
|
document.getElementById('inviteResetUsedWrap').style.display = 'none';
|
||||||
setProtocolSelect('xui', 0);
|
setProtocolSelect('awg', 0);
|
||||||
if (xuiDefaultPanelId) {
|
|
||||||
const p = document.getElementById('inviteXuiPanel');
|
|
||||||
if (p && [...p.options].some(o => o.value === xuiDefaultPanelId)) p.value = xuiDefaultPanelId;
|
|
||||||
}
|
|
||||||
loadInviteInbounds(xuiDefaultInbound);
|
|
||||||
openModal('inviteModal');
|
openModal('inviteModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,12 +209,7 @@
|
|||||||
document.getElementById('inviteClearPassword').checked = false;
|
document.getElementById('inviteClearPassword').checked = false;
|
||||||
document.getElementById('inviteResetUsedWrap').style.display = 'block';
|
document.getElementById('inviteResetUsedWrap').style.display = 'block';
|
||||||
document.getElementById('inviteResetUsed').checked = false;
|
document.getElementById('inviteResetUsed').checked = false;
|
||||||
setProtocolSelect(inv.protocol || 'xui', inv.server_id || 0);
|
setProtocolSelect(inv.protocol || 'awg', inv.server_id || 0);
|
||||||
const panelSel = document.getElementById('inviteXuiPanel');
|
|
||||||
if (panelSel && inv.xui_panel_id && [...panelSel.options].some(o => o.value === inv.xui_panel_id)) {
|
|
||||||
panelSel.value = inv.xui_panel_id;
|
|
||||||
}
|
|
||||||
loadInviteInbounds(inv.xui_inbound_id || xuiDefaultInbound);
|
|
||||||
openModal('inviteModal');
|
openModal('inviteModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,8 +221,6 @@
|
|||||||
try {
|
try {
|
||||||
const editId = document.getElementById('inviteEditId').value;
|
const editId = document.getElementById('inviteEditId').value;
|
||||||
const proto = parseProtocolValue();
|
const proto = parseProtocolValue();
|
||||||
const inbound = parseInt(document.getElementById('inviteInboundId').value || '0');
|
|
||||||
if (proto.protocol === 'xui' && !inbound) throw new Error(_('invite_inbound_required'));
|
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
name: document.getElementById('inviteName').value || 'Invite',
|
name: document.getElementById('inviteName').value || 'Invite',
|
||||||
@@ -319,8 +229,6 @@
|
|||||||
user_id: document.getElementById('inviteUserId').value || '',
|
user_id: document.getElementById('inviteUserId').value || '',
|
||||||
protocol: proto.protocol,
|
protocol: proto.protocol,
|
||||||
server_id: proto.server_id,
|
server_id: proto.server_id,
|
||||||
xui_inbound_id: inbound,
|
|
||||||
xui_panel_id: document.getElementById('inviteXuiPanel')?.value || '',
|
|
||||||
note: document.getElementById('inviteNote').value || '',
|
note: document.getElementById('inviteNote').value || '',
|
||||||
};
|
};
|
||||||
const pwd = document.getElementById('invitePassword').value;
|
const pwd = document.getElementById('invitePassword').value;
|
||||||
|
|||||||
+3
-404
@@ -111,7 +111,6 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">{{ _('protocol_label') }}</label>
|
<label class="form-label">{{ _('protocol_label') }}</label>
|
||||||
<select class="form-select" id="guest_create_protocol" onchange="updateGuestCreateFields()">
|
<select class="form-select" id="guest_create_protocol" onchange="updateGuestCreateFields()">
|
||||||
<option value="xui" {% if settings.guest.create_protocol == 'xui' %}selected{% endif %}>3x-ui VLESS</option>
|
|
||||||
{% for s in servers %}
|
{% for s in servers %}
|
||||||
{% set server_idx = loop.index0 %}
|
{% set server_idx = loop.index0 %}
|
||||||
{% for key, info in (s.protocols or {}).items() %}
|
{% for key, info in (s.protocols or {}).items() %}
|
||||||
@@ -125,19 +124,6 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="guestInboundGroup" style="{% if settings.guest.create_protocol != 'xui' %}display:none;{% endif %}">
|
|
||||||
<label class="form-label">{{ _('xui_server_select_label') }}</label>
|
|
||||||
<select class="form-select" id="guest_create_xui_panel" onchange="loadGuestInbounds()">
|
|
||||||
{% for s in xui_servers %}
|
|
||||||
<option value="{{ s.id }}" {% if settings.guest.create_xui_panel_id == s.id %}selected{% endif %}>{{ s.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<label class="form-label" style="margin-top:var(--space-sm);">{{ _('xui_inbound_label') }}</label>
|
|
||||||
<select class="form-select" id="guest_create_inbound_id">
|
|
||||||
<option value="0">{{ _('invite_inbound_loading') }}</option>
|
|
||||||
</select>
|
|
||||||
<div class="form-hint">{{ _('xui_inbound_hint') }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -347,10 +333,6 @@
|
|||||||
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
|
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
|
||||||
<input type="checkbox" disabled> Marzban
|
<input type="checkbox" disabled> Marzban
|
||||||
</label>
|
</label>
|
||||||
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
|
|
||||||
<input type="checkbox" name="xui_sync" {% if settings.sync.xui_sync %}checked{% endif %}>
|
|
||||||
3x-ui
|
|
||||||
</label>
|
|
||||||
<label
|
<label
|
||||||
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
|
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
|
||||||
<input type="checkbox" disabled> Hiddify
|
<input type="checkbox" disabled> Hiddify
|
||||||
@@ -435,106 +417,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="xuiFields"
|
|
||||||
style="{% if not settings.sync.xui_sync %}display:none;{% endif %} border-top: 1px solid var(--border-color); padding-top: var(--space-md); margin-top: var(--space-md);">
|
|
||||||
<div class="form-hint" style="margin-bottom: var(--space-md);">
|
|
||||||
{{ _('xui_servers_manage_hint') }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label
|
|
||||||
style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer; margin-bottom: var(--space-xs);">
|
|
||||||
<input type="checkbox" name="xui_sync_users" {% if settings.sync.xui_sync_users %}checked{% endif %}>
|
|
||||||
{{ _('enable_sync') }}
|
|
||||||
</label>
|
|
||||||
<div class="form-hint"
|
|
||||||
style="margin-left: var(--space-lg); line-height: 1.4; margin-bottom: var(--space-sm);">
|
|
||||||
{{ _('xui_sync_hint') }}
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; gap: var(--space-sm); margin-left: var(--space-lg);">
|
|
||||||
<button type="button" class="btn btn-secondary btn-sm" onclick="syncXuiNow()" id="xuiSyncNowBtn">
|
|
||||||
<span id="xuiSyncNowBtnText">🔄 {{ _('sync_now_btn') }}</span>
|
|
||||||
<div class="spinner hidden" id="xuiSyncNowSpinner" style="width:14px; height:14px;"></div>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-danger btn-sm" onclick="deleteSyncXui()" id="xuiSyncDelBtn">
|
|
||||||
<span id="xuiSyncDelBtnText">🗑 {{ _('delete_sync_btn') }}</span>
|
|
||||||
<div class="spinner hidden" id="xuiSyncDelSpinner" style="width:14px; height:14px;"></div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
|
|
||||||
<input type="checkbox" name="xui_create_conns" id="xuiSyncCreateConns" {% if
|
|
||||||
settings.sync.xui_create_conns %}checked{% endif %}>
|
|
||||||
{{ _('auto_create_conns') }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="xuiAutoConnFields"
|
|
||||||
style="{% if not settings.sync.xui_create_conns %}display:none;{% endif %} background: var(--bg-primary); padding: var(--space-md); border-radius: var(--radius-md); margin-top: var(--space-sm);">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">{{ _('sync_server_label') }}</label>
|
|
||||||
<select class="form-select" name="xui_server_id" onchange="updateProtocolsForXuiSync()">
|
|
||||||
{% for s in servers %}
|
|
||||||
<option value="{{ loop.index0 }}" {% if settings.sync.xui_server_id==loop.index0
|
|
||||||
%}selected{% endif %}>{{ s.name or s.host }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">{{ _('protocol_label') }}</label>
|
|
||||||
<select class="form-select" name="xui_protocol" id="xuiSyncProtocolSelect">
|
|
||||||
<option value="xray" {% if settings.sync.xui_protocol=='xray' %}selected{% endif %}>Xray</option>
|
|
||||||
<option value="awg" {% if settings.sync.xui_protocol=='awg' %}selected{% endif %}>AmneziaWG</option>
|
|
||||||
<option value="awg2" {% if settings.sync.xui_protocol=='awg2' %}selected{% endif %}>AmneziaWG 2.0</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- keep hidden legacy fields so saveSettings still posts them -->
|
|
||||||
<input type="hidden" name="xui_url" value="{{ settings.sync.xui_url or '' }}">
|
|
||||||
<input type="hidden" name="xui_sub_url" value="{{ settings.sync.xui_sub_url or '' }}">
|
|
||||||
<input type="hidden" name="xui_api_token" value="{{ settings.sync.xui_api_token or '' }}">
|
|
||||||
<input type="hidden" name="xui_username" value="{{ settings.sync.xui_username or '' }}">
|
|
||||||
<input type="hidden" name="xui_password" value="{{ settings.sync.xui_password or '' }}">
|
|
||||||
<input type="hidden" name="xui_inbound_id" value="{{ settings.sync.xui_inbound_id or 0 }}">
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- BLOCK: 3x-ui servers (multi) -->
|
|
||||||
<div class="card" style="margin-top: var(--space-lg);">
|
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-lg);">
|
|
||||||
<h3 class="card-title" style="margin:0;">{{ _('xui_servers_title') }}</h3>
|
|
||||||
<button type="button" class="btn btn-primary btn-sm" onclick="openXuiServerModal()">+ {{ _('xui_server_add') }}</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-hint" style="margin-bottom: var(--space-md);">{{ _('xui_servers_hint') }}</div>
|
|
||||||
<div id="xuiServersList">
|
|
||||||
{% if xui_servers %}
|
|
||||||
{% for s in xui_servers %}
|
|
||||||
<div class="client-item" style="margin-bottom:var(--space-sm);" data-xui-id="{{ s.id }}">
|
|
||||||
<div class="client-info" style="flex:1; min-width:0;">
|
|
||||||
<div class="client-avatar">🌀</div>
|
|
||||||
<div style="min-width:0;">
|
|
||||||
<div class="client-name">{{ s.name }}</div>
|
|
||||||
<div class="client-meta" style="word-break:break-all;">
|
|
||||||
<span>{{ s.url }}</span>
|
|
||||||
{% if s.sub_url %}<span>· sub: {{ s.sub_url }}</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="client-actions">
|
|
||||||
<button class="btn btn-secondary btn-sm" type="button" onclick='editXuiServer({{ s | tojson }})'>{{ _('edit') }}</button>
|
|
||||||
<button class="btn btn-danger btn-sm" type="button" onclick="deleteXuiServer('{{ s.id }}')">{{ _('delete') }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<div style="text-align:center; padding:var(--space-lg); color:var(--text-muted);">{{ _('xui_servers_empty') }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- BLOCK: Simple Backup -->
|
<!-- BLOCK: Simple Backup -->
|
||||||
<div class="card" style="margin-top: var(--space-lg);">
|
<div class="card" style="margin-top: var(--space-lg);">
|
||||||
<h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3>
|
<h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3>
|
||||||
@@ -614,64 +499,6 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ===== 3x-ui Server Modal ===== -->
|
|
||||||
<div class="modal-backdrop" id="xuiServerModal">
|
|
||||||
<div class="modal" style="max-width:520px;">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2 class="modal-title" id="xuiServerModalTitle">{{ _('xui_server_add') }}</h2>
|
|
||||||
<button class="modal-close" onclick="closeModal('xuiServerModal')">×</button>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" id="xuiServerEditId" value="">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">{{ _('xui_server_name_label') }}</label>
|
|
||||||
<input class="form-input" type="text" id="xuiServerName" placeholder="US / NL / Home">
|
|
||||||
<div class="form-hint">{{ _('xui_server_name_hint') }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">{{ _('xui_url_label') }}</label>
|
|
||||||
<input class="form-input" type="url" id="xuiServerUrl" placeholder="https://panel.example.com:2053/path">
|
|
||||||
<div class="form-hint">{{ _('xui_url_hint') }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">{{ _('xui_sub_url_label') }}</label>
|
|
||||||
<input class="form-input" type="url" id="xuiServerSubUrl" placeholder="https://sub.example.com:2096/sub">
|
|
||||||
<div class="form-hint">{{ _('xui_sub_url_hint') }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">{{ _('xui_api_token_label') }}</label>
|
|
||||||
<input class="form-input" type="password" id="xuiServerToken" placeholder="{{ _('xui_api_token_placeholder') }}" autocomplete="off">
|
|
||||||
<div class="form-hint">{{ _('xui_api_token_hint') }}</div>
|
|
||||||
</div>
|
|
||||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-sm);">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">{{ _('xui_username_label') }}</label>
|
|
||||||
<input class="form-input" type="text" id="xuiServerUser" autocomplete="off">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">{{ _('xui_password_label') }}</label>
|
|
||||||
<input class="form-input" type="password" id="xuiServerPass" autocomplete="off">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer;">
|
|
||||||
<input type="checkbox" id="xuiServerEnabled" checked> {{ _('enabled') if False else 'Enabled' }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer;">
|
|
||||||
<input type="checkbox" id="xuiServerSyncUsers"> {{ _('enable_sync') }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-secondary" onclick="closeModal('xuiServerModal')">{{ _('cancel') }}</button>
|
|
||||||
<button class="btn btn-primary" onclick="saveXuiServer()" id="xuiServerSaveBtn">
|
|
||||||
<span id="xuiServerSaveText">{{ _('save') }}</span>
|
|
||||||
<div class="spinner hidden" id="xuiServerSaveSpinner" style="width:14px;height:14px;"></div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ===== Create API Token Modal ===== -->
|
<!-- ===== Create API Token Modal ===== -->
|
||||||
<div class="modal-backdrop" id="createTokenModal">
|
<div class="modal-backdrop" id="createTokenModal">
|
||||||
<div class="modal" style="max-width: 460px;">
|
<div class="modal" style="max-width: 460px;">
|
||||||
@@ -758,148 +585,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateProtocolsForXuiSync() {
|
|
||||||
const serverIdx = document.querySelector('[name="xui_server_id"]').value;
|
|
||||||
const protoSelect = document.getElementById('xuiSyncProtocolSelect');
|
|
||||||
protoSelect.innerHTML = '';
|
|
||||||
|
|
||||||
if (serverIdx === '' || !serversData[serverIdx]) return;
|
|
||||||
|
|
||||||
const protocols = serversData[serverIdx].protocols || {};
|
|
||||||
let count = 0;
|
|
||||||
|
|
||||||
for (const [key, info] of Object.entries(protocols)) {
|
|
||||||
if (info.installed) {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = key;
|
|
||||||
opt.textContent = key === 'awg' ? 'AmneziaWG' : (key === 'awg2' ? 'AmneziaWG 2.0' : (key === 'awg_legacy' ? 'AWG Legacy' : (key === 'xray' ? 'Xray' : key.toUpperCase())));
|
|
||||||
if (key === "{{ settings.sync.xui_protocol }}") opt.selected = true;
|
|
||||||
protoSelect.appendChild(opt);
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count === 0) {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.textContent = _('no_protocols');
|
|
||||||
opt.disabled = true;
|
|
||||||
protoSelect.appendChild(opt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelector('[name="remnawave_sync"]').addEventListener('change', (e) => {
|
document.querySelector('[name="remnawave_sync"]').addEventListener('change', (e) => {
|
||||||
document.getElementById('remnawaveFields').style.display = e.target.checked ? 'block' : 'none';
|
document.getElementById('remnawaveFields').style.display = e.target.checked ? 'block' : 'none';
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelector('[name="xui_sync"]').addEventListener('change', (e) => {
|
|
||||||
document.getElementById('xuiFields').style.display = e.target.checked ? 'block' : 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
function openXuiServerModal(server) {
|
|
||||||
document.getElementById('xuiServerEditId').value = server?.id || '';
|
|
||||||
document.getElementById('xuiServerModalTitle').textContent = server?.id ? (_('edit') + ' — 3x-ui') : _('xui_server_add');
|
|
||||||
document.getElementById('xuiServerName').value = server?.name || '';
|
|
||||||
document.getElementById('xuiServerUrl').value = server?.url || '';
|
|
||||||
document.getElementById('xuiServerSubUrl').value = server?.sub_url || '';
|
|
||||||
document.getElementById('xuiServerToken').value = '';
|
|
||||||
document.getElementById('xuiServerUser').value = server?.username || '';
|
|
||||||
document.getElementById('xuiServerPass').value = '';
|
|
||||||
document.getElementById('xuiServerEnabled').checked = server ? !!server.enabled : true;
|
|
||||||
document.getElementById('xuiServerSyncUsers').checked = !!(server && server.sync_users);
|
|
||||||
openModal('xuiServerModal');
|
|
||||||
}
|
|
||||||
|
|
||||||
function editXuiServer(server) { openXuiServerModal(server); }
|
|
||||||
|
|
||||||
async function saveXuiServer() {
|
|
||||||
const id = document.getElementById('xuiServerEditId').value;
|
|
||||||
const body = {
|
|
||||||
name: document.getElementById('xuiServerName').value.trim(),
|
|
||||||
url: document.getElementById('xuiServerUrl').value.trim(),
|
|
||||||
sub_url: document.getElementById('xuiServerSubUrl').value.trim(),
|
|
||||||
api_token: document.getElementById('xuiServerToken').value,
|
|
||||||
username: document.getElementById('xuiServerUser').value.trim(),
|
|
||||||
password: document.getElementById('xuiServerPass').value,
|
|
||||||
enabled: document.getElementById('xuiServerEnabled').checked,
|
|
||||||
sync_users: document.getElementById('xuiServerSyncUsers').checked,
|
|
||||||
default_inbound_id: 0,
|
|
||||||
};
|
|
||||||
if (!body.url) { showToast(_('error') + ': URL', 'error'); return; }
|
|
||||||
const btn = document.getElementById('xuiServerSaveBtn');
|
|
||||||
const text = document.getElementById('xuiServerSaveText');
|
|
||||||
const spinner = document.getElementById('xuiServerSaveSpinner');
|
|
||||||
btn.disabled = true; text.textContent = _('saving') || 'Saving...'; spinner.classList.remove('hidden');
|
|
||||||
try {
|
|
||||||
if (id) await apiCall(`/api/settings/xui/servers/${encodeURIComponent(id)}`, 'PUT', body);
|
|
||||||
else await apiCall('/api/settings/xui/servers', 'POST', body);
|
|
||||||
showToast(_('saved') || 'OK', 'success');
|
|
||||||
closeModal('xuiServerModal');
|
|
||||||
setTimeout(() => location.reload(), 400);
|
|
||||||
} catch (err) {
|
|
||||||
showToast(_('error') + ': ' + err.message, 'error');
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false; text.textContent = _('save'); spinner.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteXuiServer(id) {
|
|
||||||
if (!confirm(_('xui_server_delete_confirm') || 'Delete this 3x-ui server?')) return;
|
|
||||||
try {
|
|
||||||
await apiCall(`/api/settings/xui/servers/${encodeURIComponent(id)}`, 'DELETE');
|
|
||||||
showToast(_('deleted') || 'OK', 'success');
|
|
||||||
setTimeout(() => location.reload(), 400);
|
|
||||||
} catch (err) {
|
|
||||||
showToast(_('error') + ': ' + err.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load VLESS inbounds from the selected 3x-ui panel
|
|
||||||
async function loadXuiInbounds() {
|
|
||||||
await loadGuestInbounds();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadGuestInbounds() {
|
|
||||||
const select = document.getElementById('guest_create_inbound_id');
|
|
||||||
if (!select) return;
|
|
||||||
const preferred = {{ settings.guest.create_inbound_id | default(0) | int }};
|
|
||||||
select.innerHTML = `<option value="">${_('invite_inbound_loading')}</option>`;
|
|
||||||
try {
|
|
||||||
const panelSel = document.getElementById('guest_create_xui_panel');
|
|
||||||
const panelId = panelSel ? panelSel.value : '';
|
|
||||||
const q = panelId ? `?panel_id=${encodeURIComponent(panelId)}` : '';
|
|
||||||
const res = await fetch('/api/settings/xui/inbounds' + q);
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error || 'Failed to load inbounds');
|
|
||||||
const list = data.inbounds || [];
|
|
||||||
if (!list.length) {
|
|
||||||
select.innerHTML = `<option value="">${_('invite_inbound_empty')}</option>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
select.innerHTML = '';
|
|
||||||
list.forEach((ib, idx) => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = ib.id;
|
|
||||||
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
|
|
||||||
if (preferred && String(ib.id) === String(preferred)) opt.selected = true;
|
|
||||||
else if (!preferred && idx === 0) opt.selected = true;
|
|
||||||
select.appendChild(opt);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('loadGuestInbounds:', err);
|
|
||||||
select.innerHTML = `<option value="">${_('error')}: ${err.message}</option>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('syncCreateConns').addEventListener('change', (e) => {
|
document.getElementById('syncCreateConns').addEventListener('change', (e) => {
|
||||||
document.getElementById('autoConnFields').style.display = e.target.checked ? 'block' : 'none';
|
document.getElementById('autoConnFields').style.display = e.target.checked ? 'block' : 'none';
|
||||||
if (e.target.checked) updateProtocolsForSync();
|
if (e.target.checked) updateProtocolsForSync();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('xuiSyncCreateConns').addEventListener('change', (e) => {
|
|
||||||
document.getElementById('xuiAutoConnFields').style.display = e.target.checked ? 'block' : 'none';
|
|
||||||
if (e.target.checked) updateProtocolsForXuiSync();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('ssl_enabled').addEventListener('change', (e) => {
|
document.getElementById('ssl_enabled').addEventListener('change', (e) => {
|
||||||
document.getElementById('sslFields').style.display = e.target.checked ? 'block' : 'none';
|
document.getElementById('sslFields').style.display = e.target.checked ? 'block' : 'none';
|
||||||
});
|
});
|
||||||
@@ -1149,78 +843,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncXuiNow() {
|
|
||||||
const btn = document.getElementById('xuiSyncNowBtn');
|
|
||||||
const text = document.getElementById('xuiSyncNowBtnText');
|
|
||||||
const spinner = document.getElementById('xuiSyncNowSpinner');
|
|
||||||
const syncForm = document.getElementById('syncForm');
|
|
||||||
|
|
||||||
btn.disabled = true;
|
|
||||||
text.textContent = _('sync_running');
|
|
||||||
spinner.classList.remove('hidden');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
xui_sync: true,
|
|
||||||
xui_sync_users: true,
|
|
||||||
xui_url: syncForm.xui_url.value.trim(),
|
|
||||||
xui_username: syncForm.xui_username.value.trim(),
|
|
||||||
xui_password: syncForm.xui_password.value,
|
|
||||||
xui_api_token: syncForm.xui_api_token.value.trim(),
|
|
||||||
xui_create_conns: syncForm.xui_create_conns.checked,
|
|
||||||
xui_server_id: parseInt(syncForm.xui_server_id.value || '0'),
|
|
||||||
xui_protocol: syncForm.xui_protocol.value,
|
|
||||||
xui_inbound_id: parseInt(syncForm.xui_inbound_id.value || '0'),
|
|
||||||
xui_sub_url: (syncForm.xui_sub_url && syncForm.xui_sub_url.value || '').trim()
|
|
||||||
};
|
|
||||||
const res = await fetch('/api/settings/xui_sync_now', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.status === 'success') {
|
|
||||||
showToast(_('sync_success').replace('{}', data.count), 'success');
|
|
||||||
setTimeout(() => window.location.reload(), 1500);
|
|
||||||
} else {
|
|
||||||
throw new Error(data.message || data.error || 'Error occurred');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${_('error')}: ` + err.message, 'error');
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
text.textContent = `🔄 ${_('sync_now_btn')}`;
|
|
||||||
spinner.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteSyncXui() {
|
|
||||||
if (!confirm(_('delete_sync_confirm'))) return;
|
|
||||||
|
|
||||||
const btn = document.getElementById('xuiSyncDelBtn');
|
|
||||||
const text = document.getElementById('xuiSyncDelBtnText');
|
|
||||||
const spinner = document.getElementById('xuiSyncDelSpinner');
|
|
||||||
|
|
||||||
btn.disabled = true;
|
|
||||||
text.textContent = _('deleting');
|
|
||||||
spinner.classList.remove('hidden');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/settings/xui_sync_delete', { method: 'POST' });
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.status === 'success') {
|
|
||||||
showToast(_('sync_deleted').replace('{}', data.count), 'success');
|
|
||||||
setTimeout(() => window.location.reload(), 1500);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${_('error')}: ` + err.message, 'error');
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
text.textContent = `🗑 ${_('delete_sync_btn')}`;
|
|
||||||
spinner.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
const btn = document.getElementById('saveBtn');
|
const btn = document.getElementById('saveBtn');
|
||||||
const spinner = document.getElementById('saveSpinner');
|
const spinner = document.getElementById('saveSpinner');
|
||||||
@@ -1244,17 +866,6 @@
|
|||||||
remnawave_create_conns: syncForm.remnawave_create_conns.checked,
|
remnawave_create_conns: syncForm.remnawave_create_conns.checked,
|
||||||
remnawave_server_id: parseInt(syncForm.remnawave_server_id.value || '0'),
|
remnawave_server_id: parseInt(syncForm.remnawave_server_id.value || '0'),
|
||||||
remnawave_protocol: syncForm.remnawave_protocol.value,
|
remnawave_protocol: syncForm.remnawave_protocol.value,
|
||||||
xui_sync: syncForm.xui_sync.checked,
|
|
||||||
xui_url: syncForm.xui_url.value,
|
|
||||||
xui_username: syncForm.xui_username.value,
|
|
||||||
xui_password: syncForm.xui_password.value,
|
|
||||||
xui_api_token: syncForm.xui_api_token.value,
|
|
||||||
xui_sync_users: syncForm.xui_sync_users.checked,
|
|
||||||
xui_create_conns: syncForm.xui_create_conns.checked,
|
|
||||||
xui_server_id: parseInt(syncForm.xui_server_id.value || '0'),
|
|
||||||
xui_protocol: syncForm.xui_protocol.value,
|
|
||||||
xui_inbound_id: parseInt(syncForm.xui_inbound_id.value || '0'),
|
|
||||||
xui_sub_url: (syncForm.xui_sub_url && syncForm.xui_sub_url.value || '').trim()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -1277,12 +888,10 @@
|
|||||||
panel_port: parseInt(document.getElementById('panel_port').value || '5000')
|
panel_port: parseInt(document.getElementById('panel_port').value || '5000')
|
||||||
};
|
};
|
||||||
|
|
||||||
let createProtocol = 'xui';
|
let createProtocol = 'awg';
|
||||||
let createServerId = 0;
|
let createServerId = 0;
|
||||||
const guestProtoRaw = document.getElementById('guest_create_protocol').value;
|
const guestProtoRaw = document.getElementById('guest_create_protocol').value;
|
||||||
if (guestProtoRaw === 'xui') {
|
if (guestProtoRaw.includes('|')) {
|
||||||
createProtocol = 'xui';
|
|
||||||
} else if (guestProtoRaw.includes('|')) {
|
|
||||||
const parts = guestProtoRaw.split('|');
|
const parts = guestProtoRaw.split('|');
|
||||||
createProtocol = parts[0];
|
createProtocol = parts[0];
|
||||||
createServerId = parseInt(parts[1] || '0');
|
createServerId = parseInt(parts[1] || '0');
|
||||||
@@ -1297,8 +906,6 @@
|
|||||||
allow_create: document.getElementById('guest_allow_create').checked,
|
allow_create: document.getElementById('guest_allow_create').checked,
|
||||||
create_protocol: createProtocol,
|
create_protocol: createProtocol,
|
||||||
create_server_id: createServerId,
|
create_server_id: createServerId,
|
||||||
create_inbound_id: parseInt(document.getElementById('guest_create_inbound_id').value || '0'),
|
|
||||||
create_xui_panel_id: document.getElementById('guest_create_xui_panel')?.value || '',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1319,15 +926,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateGuestCreateFields() {
|
function updateGuestCreateFields() {
|
||||||
const v = document.getElementById('guest_create_protocol')?.value;
|
// No-op: only server-backed protocols are selectable now.
|
||||||
const group = document.getElementById('guestInboundGroup');
|
|
||||||
if (group) group.style.display = (v === 'xui') ? 'block' : 'none';
|
|
||||||
if (v === 'xui') loadGuestInbounds();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefill guest inbound list when page opens on 3x-ui
|
|
||||||
if (document.getElementById('guest_create_protocol')?.value === 'xui') {
|
|
||||||
loadGuestInbounds();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyGuestLink() {
|
function copyGuestLink() {
|
||||||
|
|||||||
+1
-74
@@ -291,20 +291,6 @@
|
|||||||
<div class="form-hint">{{ _('existing_conn_hint') }}</div>
|
<div class="form-hint">{{ _('existing_conn_hint') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="ucXuiInboundGroup" style="display:none;">
|
|
||||||
<label class="form-label">{{ _('xui_server_select_label') }}</label>
|
|
||||||
<select class="form-select" id="ucXuiPanel" onchange="loadXuiInbounds()">
|
|
||||||
{% for s in xui_servers %}
|
|
||||||
<option value="{{ s.id }}" {% if s.id == xui_default_panel_id %}selected{% endif %}>{{ s.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<label class="form-label" style="margin-top:var(--space-sm);">{{ _('xui_inbound_label') }}</label>
|
|
||||||
<select class="form-select" id="ucXuiInbound">
|
|
||||||
<option value="">{{ _('invite_inbound_loading') }}</option>
|
|
||||||
</select>
|
|
||||||
<div class="form-hint">{{ _('xui_inbound_hint') }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" id="ucNameGroup">
|
<div class="form-group" id="ucNameGroup">
|
||||||
<label class="form-label">{{ _('conn_name_panel') }}</label>
|
<label class="form-label">{{ _('conn_name_panel') }}</label>
|
||||||
<input class="form-input" type="text" id="ucName" placeholder="{{ _('connection_name_placeholder') }}">
|
<input class="form-input" type="text" id="ucName" placeholder="{{ _('connection_name_placeholder') }}">
|
||||||
@@ -411,9 +397,6 @@
|
|||||||
let searchTimeout = null;
|
let searchTimeout = null;
|
||||||
|
|
||||||
const serversData = {{ servers | tojson }};
|
const serversData = {{ servers | tojson }};
|
||||||
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
|
|
||||||
const xuiDefaultInboundId = {{ xui_inbound_id | int }};
|
|
||||||
const xuiServers = {{ xui_servers | default([]) | tojson }};
|
|
||||||
|
|
||||||
function populateTimeSelect(selectId) {
|
function populateTimeSelect(selectId) {
|
||||||
const select = document.getElementById(selectId);
|
const select = document.getElementById(selectId);
|
||||||
@@ -620,14 +603,6 @@
|
|||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (xuiConfigured && selectId === 'ucProtocol') {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = 'xui';
|
|
||||||
opt.textContent = '3x-ui VLESS';
|
|
||||||
select.appendChild(opt);
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
if (group) group.style.display = '';
|
if (group) group.style.display = '';
|
||||||
} else {
|
} else {
|
||||||
@@ -637,48 +612,6 @@
|
|||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
if (group) group.style.display = '';
|
if (group) group.style.display = '';
|
||||||
}
|
}
|
||||||
if (selectId === 'ucProtocol') {
|
|
||||||
updateXuiInboundVisibility();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadXuiInbounds() {
|
|
||||||
const select = document.getElementById('ucXuiInbound');
|
|
||||||
if (!select) return;
|
|
||||||
select.innerHTML = `<option value="">${_('invite_inbound_loading')}</option>`;
|
|
||||||
try {
|
|
||||||
const panelId = document.getElementById('ucXuiPanel')?.value || '';
|
|
||||||
const q = panelId ? `?panel_id=${encodeURIComponent(panelId)}` : '';
|
|
||||||
const data = await apiCall('/api/settings/xui/inbounds' + q);
|
|
||||||
const list = data.inbounds || [];
|
|
||||||
if (!list.length) {
|
|
||||||
select.innerHTML = `<option value="">${_('invite_inbound_empty')}</option>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
select.innerHTML = '';
|
|
||||||
list.forEach((ib, idx) => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = ib.id;
|
|
||||||
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
|
|
||||||
if (xuiDefaultInboundId && String(ib.id) === String(xuiDefaultInboundId)) opt.selected = true;
|
|
||||||
else if (!xuiDefaultInboundId && idx === 0) opt.selected = true;
|
|
||||||
select.appendChild(opt);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
select.innerHTML = `<option value="">${_('error')}: ${err.message}</option>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateXuiInboundVisibility() {
|
|
||||||
const proto = document.getElementById('ucProtocol')?.value;
|
|
||||||
const group = document.getElementById('ucXuiInboundGroup');
|
|
||||||
if (!group) return;
|
|
||||||
if (proto === 'xui') {
|
|
||||||
group.style.display = 'block';
|
|
||||||
loadXuiInbounds();
|
|
||||||
} else {
|
|
||||||
group.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show/hide connection fields
|
// Show/hide connection fields
|
||||||
@@ -711,7 +644,6 @@
|
|||||||
fetchExistingClients();
|
fetchExistingClients();
|
||||||
}
|
}
|
||||||
updateTelemtOptions('ucProtocol', 'ucTelemtOptions');
|
updateTelemtOptions('ucProtocol', 'ucTelemtOptions');
|
||||||
updateXuiInboundVisibility();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function addUser(e) {
|
async function addUser(e) {
|
||||||
@@ -878,11 +810,6 @@
|
|||||||
const clientId = document.getElementById('ucExistingClient').value;
|
const clientId = document.getElementById('ucExistingClient').value;
|
||||||
if (!clientId) throw new Error(_('select_connection'));
|
if (!clientId) throw new Error(_('select_connection'));
|
||||||
body.client_id = clientId;
|
body.client_id = clientId;
|
||||||
} else if (body.protocol === 'xui') {
|
|
||||||
const inbound = parseInt(document.getElementById('ucXuiInbound').value || '0');
|
|
||||||
if (!inbound) throw new Error(_('invite_inbound_required'));
|
|
||||||
body.xui_inbound_id = inbound;
|
|
||||||
body.xui_panel_id = document.getElementById('ucXuiPanel')?.value || '';
|
|
||||||
} else if (body.protocol === 'telemt') {
|
} else if (body.protocol === 'telemt') {
|
||||||
body.telemt_quota = document.getElementById('ucTelemtQuota').value || null;
|
body.telemt_quota = document.getElementById('ucTelemtQuota').value || null;
|
||||||
body.telemt_max_ips = parseInt(document.getElementById('ucTelemtMaxIps').value) || null;
|
body.telemt_max_ips = parseInt(document.getElementById('ucTelemtMaxIps').value) || null;
|
||||||
@@ -938,7 +865,7 @@
|
|||||||
<div class="client-name">${c.name || 'Connection'}</div>
|
<div class="client-name">${c.name || 'Connection'}</div>
|
||||||
<div class="client-meta">
|
<div class="client-meta">
|
||||||
<span>🖥 ${c.server_name || ''}</span>
|
<span>🖥 ${c.server_name || ''}</span>
|
||||||
<span>${c.protocol === 'awg' ? 'AmneziaWG' : (c.protocol === 'awg2' ? 'AmneziaWG 2.0' : (c.protocol === 'awg_legacy' ? 'AWG Legacy' : (c.protocol === 'xray' ? 'Xray' : (c.protocol === 'xui' ? '3x-ui VLESS' : c.protocol.toUpperCase()))))}</span>
|
<span>${c.protocol === 'awg' ? 'AmneziaWG' : (c.protocol === 'awg2' ? 'AmneziaWG 2.0' : (c.protocol === 'awg_legacy' ? 'AWG Legacy' : (c.protocol === 'xray' ? 'Xray' : c.protocol.toUpperCase())))}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-31
@@ -186,7 +186,7 @@
|
|||||||
"guest_password_keep": "Leave blank to keep current password",
|
"guest_password_keep": "Leave blank to keep current password",
|
||||||
"guest_clear_password": "Remove password protection",
|
"guest_clear_password": "Remove password protection",
|
||||||
"guest_allow_create": "Allow guests to create a new config",
|
"guest_allow_create": "Allow guests to create a new config",
|
||||||
"guest_allow_create_hint": "Creates a config for the guest user (3x-ui VLESS or selected server protocol).",
|
"guest_allow_create_hint": "Creates a config for the guest user using the selected server protocol.",
|
||||||
"guest_access_btn": "Continue as guest",
|
"guest_access_btn": "Continue as guest",
|
||||||
"guest_access_login_hint": "No account required",
|
"guest_access_login_hint": "No account required",
|
||||||
"guest_title": "Guest access",
|
"guest_title": "Guest access",
|
||||||
@@ -220,13 +220,6 @@
|
|||||||
"invite_duration_short": "Lifetime",
|
"invite_duration_short": "Lifetime",
|
||||||
"invite_duration_starts_hint": "After you get a config it will work for {} day(s)",
|
"invite_duration_starts_hint": "After you get a config it will work for {} day(s)",
|
||||||
"invite_config_expires_at": "Config valid until: {}",
|
"invite_config_expires_at": "Config valid until: {}",
|
||||||
"invite_inbound_hint": "Inbounds are loaded from the selected 3x-ui panel. Only 3x-ui returns the config/share link.",
|
|
||||||
"invite_inbound_loading": "Loading inbounds from 3x-ui…",
|
|
||||||
"invite_inbound_empty": "No VLESS inbounds on this 3x-ui panel",
|
|
||||||
"invite_inbound_need_xui": "Configure 3x-ui in Settings first",
|
|
||||||
"invite_inbound_required": "Select a VLESS inbound from 3x-ui",
|
|
||||||
"invite_sub_url_missing_title": "Subscription URL is not set",
|
|
||||||
"invite_sub_url_missing_hint": "In Settings → 3x-ui set Subscription URL base so invite links issue /sub/… configs.",
|
|
||||||
"invite_sub_tab": "Subscription",
|
"invite_sub_tab": "Subscription",
|
||||||
"days_short": "d",
|
"days_short": "d",
|
||||||
"invite_clear_expires": "Remove expiration",
|
"invite_clear_expires": "Remove expiration",
|
||||||
@@ -289,29 +282,6 @@
|
|||||||
"api_key_label": "API Key",
|
"api_key_label": "API Key",
|
||||||
"enable_sync": "Enable synchronization",
|
"enable_sync": "Enable synchronization",
|
||||||
"sync_hint": "Users will be created, disabled, and deleted automatically based on Remnawave data",
|
"sync_hint": "Users will be created, disabled, and deleted automatically based on Remnawave data",
|
||||||
"xui_url_label": "3x-ui URL",
|
|
||||||
"xui_url_hint": "Include scheme, port and webBasePath if configured (example: https://host:2053/secret)",
|
|
||||||
"xui_sub_url_label": "Subscription URL base",
|
|
||||||
"xui_sub_url_hint": "Public /sub base without trailing slash (example: https://host:2096/sub). Invite configs are issued as subscription links.",
|
|
||||||
"xui_api_token_label": "API Token (preferred)",
|
|
||||||
"xui_api_token_placeholder": "Settings → Security → API Token",
|
|
||||||
"xui_api_token_hint": "If set, username/password login is skipped",
|
|
||||||
"xui_username_label": "3x-ui Username",
|
|
||||||
"xui_password_label": "3x-ui Password",
|
|
||||||
"xui_sync_hint": "Clients from 3x-ui inbounds will be created, disabled, and deleted as panel users (matched by email)",
|
|
||||||
"xui_inbound_label": "VLESS inbound",
|
|
||||||
"xui_inbound_auto": "Auto (first VLESS inbound)",
|
|
||||||
"xui_inbound_hint": "Inbounds are loaded from 3x-ui. This site only creates the client and takes the ready share link — no local config generation.",
|
|
||||||
"xui_servers_title": "3x-ui servers",
|
|
||||||
"xui_servers_hint": "Add 3x-ui panels. When creating, pick a server and an inbound from the 3x-ui list — the panel returns the share link.",
|
|
||||||
"xui_servers_empty": "No 3x-ui servers yet. Click Add server.",
|
|
||||||
"xui_servers_manage_hint": "Panel URLs and subscription bases are managed in the “3x-ui servers” section below.",
|
|
||||||
"xui_server_add": "Add server",
|
|
||||||
"xui_server_name_label": "Name / description",
|
|
||||||
"xui_server_name_hint": "Shown in invites and user forms (e.g. US, NL, Home).",
|
|
||||||
"xui_server_select_label": "3x-ui server",
|
|
||||||
"xui_server_select_hint": "Pick a 3x-ui panel, then an inbound from the list that panel returns via API.",
|
|
||||||
"xui_server_delete_confirm": "Delete this 3x-ui server from the panel?",
|
|
||||||
"sync_now_btn": "🔄 Sync now",
|
"sync_now_btn": "🔄 Sync now",
|
||||||
"delete_sync_btn": "🗑 Delete synchronization",
|
"delete_sync_btn": "🗑 Delete synchronization",
|
||||||
"auto_create_conns": "Create connections automatically",
|
"auto_create_conns": "Create connections automatically",
|
||||||
|
|||||||
+1
-31
@@ -184,7 +184,7 @@
|
|||||||
"guest_password_keep": "برای حفظ رمز فعلی خالی بگذارید",
|
"guest_password_keep": "برای حفظ رمز فعلی خالی بگذارید",
|
||||||
"guest_clear_password": "حذف محافظت با رمز",
|
"guest_clear_password": "حذف محافظت با رمز",
|
||||||
"guest_allow_create": "اجازه ساخت کانفیگ جدید به مهمان",
|
"guest_allow_create": "اجازه ساخت کانفیگ جدید به مهمان",
|
||||||
"guest_allow_create_hint": "برای کاربر مهمان کانفیگ میسازد (3x-ui VLESS یا پروتکل سرور).",
|
"guest_allow_create_hint": "برای کاربر مهمان با استفاده از پروتکل سرور انتخابشده کانفیگ میسازد.",
|
||||||
"guest_access_btn": "ادامه بهعنوان مهمان",
|
"guest_access_btn": "ادامه بهعنوان مهمان",
|
||||||
"guest_access_login_hint": "بدون حساب کاربری",
|
"guest_access_login_hint": "بدون حساب کاربری",
|
||||||
"guest_title": "دسترسی مهمان",
|
"guest_title": "دسترسی مهمان",
|
||||||
@@ -218,13 +218,6 @@
|
|||||||
"invite_duration_short": "مدت",
|
"invite_duration_short": "مدت",
|
||||||
"invite_duration_starts_hint": "پس از دریافت، کانفیگ {} روز معتبر است",
|
"invite_duration_starts_hint": "پس از دریافت، کانفیگ {} روز معتبر است",
|
||||||
"invite_config_expires_at": "اعتبار تا: {}",
|
"invite_config_expires_at": "اعتبار تا: {}",
|
||||||
"invite_inbound_hint": "یکبار inbound مورد نیاز VLESS را انتخاب کنید",
|
|
||||||
"invite_inbound_loading": "در حال بارگذاری…",
|
|
||||||
"invite_inbound_empty": "inbound VLESS یافت نشد",
|
|
||||||
"invite_inbound_need_xui": "ابتدا 3x-ui را در تنظیمات پیکربندی کنید",
|
|
||||||
"invite_inbound_required": "یک inbound VLESS انتخاب کنید",
|
|
||||||
"invite_sub_url_missing_title": "آدرس اشتراک تنظیم نشده",
|
|
||||||
"invite_sub_url_missing_hint": "در تنظیمات → 3x-ui پایه /sub را وارد کنید.",
|
|
||||||
"invite_sub_tab": "اشتراک",
|
"invite_sub_tab": "اشتراک",
|
||||||
"days_short": "روز",
|
"days_short": "روز",
|
||||||
"invite_clear_expires": "حذف تاریخ انقضا",
|
"invite_clear_expires": "حذف تاریخ انقضا",
|
||||||
@@ -287,29 +280,6 @@
|
|||||||
"api_key_label": "کلید API",
|
"api_key_label": "کلید API",
|
||||||
"enable_sync": "فعالسازی همگامسازی",
|
"enable_sync": "فعالسازی همگامسازی",
|
||||||
"sync_hint": "کاربران طبق دادههای Remnawave بهطور خودکار مدیریت میشوند",
|
"sync_hint": "کاربران طبق دادههای Remnawave بهطور خودکار مدیریت میشوند",
|
||||||
"xui_url_label": "آدرس 3x-ui",
|
|
||||||
"xui_url_hint": "شامل پروتکل، پورت و webBasePath در صورت وجود",
|
|
||||||
"xui_sub_url_label": "آدرس پایه اشتراک",
|
|
||||||
"xui_sub_url_hint": "پایه عمومی /sub بدون اسلش پایانی (مثال: https://host:2096/sub).",
|
|
||||||
"xui_api_token_label": "توکن API (ترجیحی)",
|
|
||||||
"xui_api_token_placeholder": "Settings → Security → API Token",
|
|
||||||
"xui_api_token_hint": "در صورت تنظیم، ورود با نام کاربری/رمز رد میشود",
|
|
||||||
"xui_username_label": "نام کاربری 3x-ui",
|
|
||||||
"xui_password_label": "رمز عبور 3x-ui",
|
|
||||||
"xui_sync_hint": "کلاینتهای 3x-ui بر اساس email به کاربران پنل همگام میشوند",
|
|
||||||
"xui_inbound_label": "ورودی VLESS",
|
|
||||||
"xui_inbound_auto": "خودکار (اولین ورودی VLESS)",
|
|
||||||
"xui_inbound_hint": "برای ساخت کانفیگ 3x-ui VLESS استفاده میشود. ابتدا یک ورودی VLESS در 3x-ui بسازید.",
|
|
||||||
"xui_servers_title": "سرورهای 3x-ui",
|
|
||||||
"xui_servers_hint": "چند پنل 3x-ui اضافه کنید. هنگام ساخت، سرور را با نام انتخاب کنید تا URL اشتراک همان سرور داده شود.",
|
|
||||||
"xui_servers_empty": "هنوز سرور 3x-ui نیست. افزودن را بزنید.",
|
|
||||||
"xui_servers_manage_hint": "آدرس پنل و پایه اشتراک در بخش «سرورهای 3x-ui» مدیریت میشود.",
|
|
||||||
"xui_server_add": "افزودن سرور",
|
|
||||||
"xui_server_name_label": "نام / توضیح",
|
|
||||||
"xui_server_name_hint": "در دعوتها نمایش داده میشود (مثل US، NL).",
|
|
||||||
"xui_server_select_label": "سرور 3x-ui",
|
|
||||||
"xui_server_select_hint": "پنل 3x-ui که URL اشتراک میدهد را انتخاب کنید.",
|
|
||||||
"xui_server_delete_confirm": "این سرور 3x-ui حذف شود؟",
|
|
||||||
"sync_now_btn": "🔄 همگامسازی اکنون",
|
"sync_now_btn": "🔄 همگامسازی اکنون",
|
||||||
"delete_sync_btn": "🗑 حذف همگامسازی",
|
"delete_sync_btn": "🗑 حذف همگامسازی",
|
||||||
"auto_create_conns": "ایجاد خودکار اتصالها",
|
"auto_create_conns": "ایجاد خودکار اتصالها",
|
||||||
|
|||||||
+1
-31
@@ -184,7 +184,7 @@
|
|||||||
"guest_password_keep": "Laisser vide pour conserver",
|
"guest_password_keep": "Laisser vide pour conserver",
|
||||||
"guest_clear_password": "Retirer le mot de passe",
|
"guest_clear_password": "Retirer le mot de passe",
|
||||||
"guest_allow_create": "Autoriser la création d'une config",
|
"guest_allow_create": "Autoriser la création d'une config",
|
||||||
"guest_allow_create_hint": "Crée une config pour l'utilisateur invité (3x-ui VLESS ou protocole serveur).",
|
"guest_allow_create_hint": "Crée une config pour l'utilisateur invité en utilisant le protocole du serveur sélectionné.",
|
||||||
"guest_access_btn": "Continuer en invité",
|
"guest_access_btn": "Continuer en invité",
|
||||||
"guest_access_login_hint": "Sans compte",
|
"guest_access_login_hint": "Sans compte",
|
||||||
"guest_title": "Accès invité",
|
"guest_title": "Accès invité",
|
||||||
@@ -218,13 +218,6 @@
|
|||||||
"invite_duration_short": "Durée",
|
"invite_duration_short": "Durée",
|
||||||
"invite_duration_starts_hint": "Après obtention, valable {} jour(s)",
|
"invite_duration_starts_hint": "Après obtention, valable {} jour(s)",
|
||||||
"invite_config_expires_at": "Valable jusqu'au : {}",
|
"invite_config_expires_at": "Valable jusqu'au : {}",
|
||||||
"invite_inbound_hint": "Choisissez l'inbound VLESS une fois",
|
|
||||||
"invite_inbound_loading": "Chargement…",
|
|
||||||
"invite_inbound_empty": "Aucun inbound VLESS",
|
|
||||||
"invite_inbound_need_xui": "Configurez 3x-ui d'abord",
|
|
||||||
"invite_inbound_required": "Sélectionnez un inbound VLESS",
|
|
||||||
"invite_sub_url_missing_title": "URL d'abonnement manquante",
|
|
||||||
"invite_sub_url_missing_hint": "Dans Paramètres → 3x-ui, définissez l'URL de base /sub.",
|
|
||||||
"invite_sub_tab": "Abonnement",
|
"invite_sub_tab": "Abonnement",
|
||||||
"days_short": "j",
|
"days_short": "j",
|
||||||
"invite_clear_expires": "Retirer l'expiration",
|
"invite_clear_expires": "Retirer l'expiration",
|
||||||
@@ -287,29 +280,6 @@
|
|||||||
"api_key_label": "Clé API",
|
"api_key_label": "Clé API",
|
||||||
"enable_sync": "Activer synchronisation",
|
"enable_sync": "Activer synchronisation",
|
||||||
"sync_hint": "Synchro automatique avec Remnawave",
|
"sync_hint": "Synchro automatique avec Remnawave",
|
||||||
"xui_url_label": "URL 3x-ui",
|
|
||||||
"xui_url_hint": "Inclure le schéma, le port et webBasePath si configuré",
|
|
||||||
"xui_sub_url_label": "URL de base d'abonnement",
|
|
||||||
"xui_sub_url_hint": "Base /sub publique sans slash final (ex: https://host:2096/sub).",
|
|
||||||
"xui_api_token_label": "Jeton API (préféré)",
|
|
||||||
"xui_api_token_placeholder": "Settings → Security → API Token",
|
|
||||||
"xui_api_token_hint": "Si défini, le login/mot de passe est ignoré",
|
|
||||||
"xui_username_label": "Identifiant 3x-ui",
|
|
||||||
"xui_password_label": "Mot de passe 3x-ui",
|
|
||||||
"xui_sync_hint": "Les clients 3x-ui deviennent des utilisateurs du panneau (liés par email)",
|
|
||||||
"xui_inbound_label": "Inbound VLESS",
|
|
||||||
"xui_inbound_auto": "Auto (premier inbound VLESS)",
|
|
||||||
"xui_inbound_hint": "Utilisé pour créer des configs 3x-ui VLESS. Créez d'abord un inbound VLESS dans 3x-ui.",
|
|
||||||
"xui_servers_title": "Serveurs 3x-ui",
|
|
||||||
"xui_servers_hint": "Ajoutez plusieurs panneaux 3x-ui. À la création, choisissez le serveur par nom — son URL d'abonnement est utilisée.",
|
|
||||||
"xui_servers_empty": "Aucun serveur 3x-ui. Cliquez sur Ajouter.",
|
|
||||||
"xui_servers_manage_hint": "Les URL et bases d'abonnement se gèrent dans la section « Serveurs 3x-ui » ci-dessous.",
|
|
||||||
"xui_server_add": "Ajouter un serveur",
|
|
||||||
"xui_server_name_label": "Nom / description",
|
|
||||||
"xui_server_name_hint": "Affiché dans les invitations (ex: US, NL).",
|
|
||||||
"xui_server_select_label": "Serveur 3x-ui",
|
|
||||||
"xui_server_select_hint": "Choisissez le panneau 3x-ui qui émettra l'URL d'abonnement.",
|
|
||||||
"xui_server_delete_confirm": "Supprimer ce serveur 3x-ui ?",
|
|
||||||
"sync_now_btn": "🔄 Sync maintenant",
|
"sync_now_btn": "🔄 Sync maintenant",
|
||||||
"delete_sync_btn": "🗑 Supprimer synchro",
|
"delete_sync_btn": "🗑 Supprimer synchro",
|
||||||
"auto_create_conns": "Créer connexions auto",
|
"auto_create_conns": "Créer connexions auto",
|
||||||
|
|||||||
+1
-31
@@ -186,7 +186,7 @@
|
|||||||
"guest_password_keep": "Оставьте пустым, чтобы сохранить текущий",
|
"guest_password_keep": "Оставьте пустым, чтобы сохранить текущий",
|
||||||
"guest_clear_password": "Убрать защиту паролем",
|
"guest_clear_password": "Убрать защиту паролем",
|
||||||
"guest_allow_create": "Разрешить гостям создавать новый конфиг",
|
"guest_allow_create": "Разрешить гостям создавать новый конфиг",
|
||||||
"guest_allow_create_hint": "Создаёт конфиг у гостевого пользователя (3x-ui VLESS или протокол выбранного сервера).",
|
"guest_allow_create_hint": "Создаёт конфиг у гостевого пользователя с использованием протокола выбранного сервера.",
|
||||||
"guest_access_btn": "Войти как гость",
|
"guest_access_btn": "Войти как гость",
|
||||||
"guest_access_login_hint": "Регистрация не нужна",
|
"guest_access_login_hint": "Регистрация не нужна",
|
||||||
"guest_title": "Гостевой доступ",
|
"guest_title": "Гостевой доступ",
|
||||||
@@ -220,13 +220,6 @@
|
|||||||
"invite_duration_short": "Срок",
|
"invite_duration_short": "Срок",
|
||||||
"invite_duration_starts_hint": "После получения конфиг будет действовать {} дн.",
|
"invite_duration_starts_hint": "После получения конфиг будет действовать {} дн.",
|
||||||
"invite_config_expires_at": "Конфиг действует до: {}",
|
"invite_config_expires_at": "Конфиг действует до: {}",
|
||||||
"invite_inbound_hint": "Список inbound загружается из выбранной панели 3x-ui. Конфиг и ссылку отдаёт только 3x-ui.",
|
|
||||||
"invite_inbound_loading": "Загрузка inbound из 3x-ui…",
|
|
||||||
"invite_inbound_empty": "VLESS inbound не найдены на этой панели 3x-ui",
|
|
||||||
"invite_inbound_need_xui": "Сначала настройте 3x-ui в Настройках",
|
|
||||||
"invite_inbound_required": "Выберите VLESS inbound из 3x-ui",
|
|
||||||
"invite_sub_url_missing_title": "Не задан URL подписки",
|
|
||||||
"invite_sub_url_missing_hint": "В Настройки → 3x-ui укажите «Базовый URL подписки», чтобы ссылки выдавали /sub/… конфиги.",
|
|
||||||
"invite_sub_tab": "Подписка",
|
"invite_sub_tab": "Подписка",
|
||||||
"days_short": "дн",
|
"days_short": "дн",
|
||||||
"invite_clear_expires": "Убрать срок действия",
|
"invite_clear_expires": "Убрать срок действия",
|
||||||
@@ -289,29 +282,6 @@
|
|||||||
"api_key_label": "API Key",
|
"api_key_label": "API Key",
|
||||||
"enable_sync": "Включить синхронизацию",
|
"enable_sync": "Включить синхронизацию",
|
||||||
"sync_hint": "Пользователи будут создаваться, отключаться и удаляться автоматически на основе данных из Remnawave",
|
"sync_hint": "Пользователи будут создаваться, отключаться и удаляться автоматически на основе данных из Remnawave",
|
||||||
"xui_url_label": "URL 3x-ui",
|
|
||||||
"xui_url_hint": "Укажите схему, порт и webBasePath при наличии (пример: https://host:2053/secret)",
|
|
||||||
"xui_sub_url_label": "Базовый URL подписки",
|
|
||||||
"xui_sub_url_hint": "Публичный /sub без слэша в конце (пример: https://host:2096/sub). Конфиги по ссылкам выдаются как subscription URL.",
|
|
||||||
"xui_api_token_label": "API Token (предпочтительно)",
|
|
||||||
"xui_api_token_placeholder": "Settings → Security → API Token",
|
|
||||||
"xui_api_token_hint": "Если задан, вход по логину/паролю не используется",
|
|
||||||
"xui_username_label": "Логин 3x-ui",
|
|
||||||
"xui_password_label": "Пароль 3x-ui",
|
|
||||||
"xui_sync_hint": "Клиенты из inbounds 3x-ui будут создаваться, отключаться и удаляться как пользователи панели (связь по email)",
|
|
||||||
"xui_inbound_label": "VLESS inbound",
|
|
||||||
"xui_inbound_auto": "Авто (первый VLESS inbound)",
|
|
||||||
"xui_inbound_hint": "Inbound подгружается из 3x-ui. Сайт только создаёт клиента и забирает готовую ссылку — без своей генерации конфига.",
|
|
||||||
"xui_servers_title": "Серверы 3x-ui",
|
|
||||||
"xui_servers_hint": "Добавьте панели 3x-ui. При создании выберите сервер и inbound из списка 3x-ui — ссылку вернёт панель.",
|
|
||||||
"xui_servers_empty": "Серверов 3x-ui пока нет. Нажмите «Добавить сервер».",
|
|
||||||
"xui_servers_manage_hint": "URL панелей и базовые URL подписок настраиваются в разделе «Серверы 3x-ui» ниже.",
|
|
||||||
"xui_server_add": "Добавить сервер",
|
|
||||||
"xui_server_name_label": "Название / описание",
|
|
||||||
"xui_server_name_hint": "Так сервер будет отображаться в инвайтах и у пользователей (например: US, NL, Home).",
|
|
||||||
"xui_server_select_label": "Сервер 3x-ui",
|
|
||||||
"xui_server_select_hint": "Выберите панель 3x-ui, затем inbound из списка, который она отдаёт по API.",
|
|
||||||
"xui_server_delete_confirm": "Удалить этот сервер 3x-ui из панели?",
|
|
||||||
"sync_now_btn": "🔄 Синхронизировать сейчас",
|
"sync_now_btn": "🔄 Синхронизировать сейчас",
|
||||||
"delete_sync_btn": "🗑 Удалить синхронизацию",
|
"delete_sync_btn": "🗑 Удалить синхронизацию",
|
||||||
"auto_create_conns": "Создавать подключения автоматически",
|
"auto_create_conns": "Создавать подключения автоматически",
|
||||||
|
|||||||
+1
-31
@@ -184,7 +184,7 @@
|
|||||||
"guest_password_keep": "留空以保留当前密码",
|
"guest_password_keep": "留空以保留当前密码",
|
||||||
"guest_clear_password": "取消密码保护",
|
"guest_clear_password": "取消密码保护",
|
||||||
"guest_allow_create": "允许访客创建新配置",
|
"guest_allow_create": "允许访客创建新配置",
|
||||||
"guest_allow_create_hint": "为访客用户创建配置(3x-ui VLESS 或所选服务器协议)。",
|
"guest_allow_create_hint": "使用所选服务器协议为访客用户创建配置。",
|
||||||
"guest_access_btn": "以访客继续",
|
"guest_access_btn": "以访客继续",
|
||||||
"guest_access_login_hint": "无需账号",
|
"guest_access_login_hint": "无需账号",
|
||||||
"guest_title": "访客访问",
|
"guest_title": "访客访问",
|
||||||
@@ -218,13 +218,6 @@
|
|||||||
"invite_duration_short": "有效期",
|
"invite_duration_short": "有效期",
|
||||||
"invite_duration_starts_hint": "获取后配置将有效 {} 天",
|
"invite_duration_starts_hint": "获取后配置将有效 {} 天",
|
||||||
"invite_config_expires_at": "配置有效至:{}",
|
"invite_config_expires_at": "配置有效至:{}",
|
||||||
"invite_inbound_hint": "一次性选择所需的 VLESS 入站",
|
|
||||||
"invite_inbound_loading": "加载入站中…",
|
|
||||||
"invite_inbound_empty": "未找到 VLESS 入站",
|
|
||||||
"invite_inbound_need_xui": "请先在设置中配置 3x-ui",
|
|
||||||
"invite_inbound_required": "请选择 VLESS 入站",
|
|
||||||
"invite_sub_url_missing_title": "未设置订阅 URL",
|
|
||||||
"invite_sub_url_missing_hint": "在 设置 → 3x-ui 中填写订阅基础 URL,以便发放 /sub/… 配置。",
|
|
||||||
"invite_sub_tab": "订阅",
|
"invite_sub_tab": "订阅",
|
||||||
"days_short": "天",
|
"days_short": "天",
|
||||||
"invite_clear_expires": "清除过期时间",
|
"invite_clear_expires": "清除过期时间",
|
||||||
@@ -287,29 +280,6 @@
|
|||||||
"api_key_label": "API 密钥",
|
"api_key_label": "API 密钥",
|
||||||
"enable_sync": "开启同步",
|
"enable_sync": "开启同步",
|
||||||
"sync_hint": "将根据 Remnawave 数据自动创建、禁用和删除用户",
|
"sync_hint": "将根据 Remnawave 数据自动创建、禁用和删除用户",
|
||||||
"xui_url_label": "3x-ui 地址",
|
|
||||||
"xui_url_hint": "包含协议、端口和 webBasePath(如有)",
|
|
||||||
"xui_sub_url_label": "订阅基础 URL",
|
|
||||||
"xui_sub_url_hint": "公共 /sub 基础地址,末尾不要斜杠(如 https://host:2096/sub)。",
|
|
||||||
"xui_api_token_label": "API Token(推荐)",
|
|
||||||
"xui_api_token_placeholder": "Settings → Security → API Token",
|
|
||||||
"xui_api_token_hint": "若已设置,将跳过用户名/密码登录",
|
|
||||||
"xui_username_label": "3x-ui 用户名",
|
|
||||||
"xui_password_label": "3x-ui 密码",
|
|
||||||
"xui_sync_hint": "3x-ui 客户端将按 email 同步为面板用户",
|
|
||||||
"xui_inbound_label": "VLESS 入站",
|
|
||||||
"xui_inbound_auto": "自动(第一个 VLESS 入站)",
|
|
||||||
"xui_inbound_hint": "创建 3x-ui VLESS 配置时使用。请先在 3x-ui 中创建 VLESS 入站。",
|
|
||||||
"xui_servers_title": "3x-ui 服务器",
|
|
||||||
"xui_servers_hint": "可添加多个 3x-ui 面板。创建配置时按名称选择服务器,并使用其订阅基础 URL。",
|
|
||||||
"xui_servers_empty": "暂无 3x-ui 服务器。请点击添加。",
|
|
||||||
"xui_servers_manage_hint": "面板与订阅地址在下方「3x-ui 服务器」中管理。",
|
|
||||||
"xui_server_add": "添加服务器",
|
|
||||||
"xui_server_name_label": "名称 / 描述",
|
|
||||||
"xui_server_name_hint": "显示在邀请与用户表单中(如 US、NL)。",
|
|
||||||
"xui_server_select_label": "3x-ui 服务器",
|
|
||||||
"xui_server_select_hint": "选择签发订阅 URL 的 3x-ui 面板。",
|
|
||||||
"xui_server_delete_confirm": "从面板删除此 3x-ui 服务器?",
|
|
||||||
"sync_now_btn": "🔄 立即同步",
|
"sync_now_btn": "🔄 立即同步",
|
||||||
"delete_sync_btn": "🗑 删除同步数据",
|
"delete_sync_btn": "🗑 删除同步数据",
|
||||||
"auto_create_conns": "自动创建连接",
|
"auto_create_conns": "自动创建连接",
|
||||||
|
|||||||
Reference in New Issue
Block a user