i18n(frontend): Phase 7-c — translate settings, inbounds modals, xray tabs

Continues the page-by-page translation pass started in cb37dd55 — runs
every user-visible string on settings (General/Security/Telegram/Sub),
inbounds (Client/QR/Info modals), and xray (Routing/Balancer/Rule/Warp/
Nord/Basics/Outbounds tabs) through useI18n. Updates the TOML→JSON sync
script to escape `@` (vue-i18n parses it as a linked-format prefix) and
refreshes all 13 locale files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-08 17:20:30 +02:00
parent cb37dd55ca
commit 4322a18ee3
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
36 changed files with 755 additions and 877 deletions

View file

@ -43,6 +43,21 @@ function unescape(value) {
});
}
// vue-i18n's message compiler treats `@` as the start of a linked
// reference (`@:key` or `@.modifier:key`). When the panel's strings
// contain a literal `@` (e.g. "@BotFather", "@userinfobot",
// "@every 1m"), the compiler aborts with "Invalid linked format".
// vue-i18n's escape syntax is `{'@'}` — that renders a literal `@`.
// We don't use linked references anywhere in the panel's locales,
// so a blanket escape is safe and keeps the TOML readable for
// translators (and for the Go-side template renderer that doesn't
// need this escape).
function escapeForVueI18n(value) {
// Keep the `{` and `}` characters that vue-i18n already uses for
// `{var}` named interpolation working — only `@` needs escaping.
return value.replace(/@/g, "{'@'}");
}
function setNested(target, path, value) {
let cursor = target;
for (let i = 0; i < path.length - 1; i++) {
@ -78,7 +93,7 @@ function parseToml(src) {
throw new Error(`Unsupported TOML construct at line ${lineNo}: ${rawLine}`);
}
const [, key, value] = kvMatch;
setNested(tree, [...section, unescape(key)], unescape(value));
setNested(tree, [...section, unescape(key)], escapeForVueI18n(unescape(value)));
}
return tree;
}

View file

@ -292,7 +292,7 @@
"IPLimitlogDesc": "سجل تاريخ الـ IPs. (عشان تفعل الإدخال بعد التعطيل، امسح السجل)",
"IPLimitlogclear": "امسح السجل",
"setDefaultCert": "استخدم شهادة البانل",
"telegramDesc": "ادخل ID شات Telegram. (استخدم '/id' في البوت) أو (@userinfobot)",
"telegramDesc": "ادخل ID شات Telegram. (استخدم '/id' في البوت) أو ({'@'}userinfobot)",
"subscriptionDesc": "عشان تلاقي رابط الاشتراك، ادخل على 'التفاصيل'. وكمان ممكن تستخدم نفس الاسم لعدة عملاء.",
"info": "معلومات",
"same": "نفسه",
@ -418,13 +418,13 @@
"telegramBotEnable": "تفعيل بوت Telegram",
"telegramBotEnableDesc": "يفعل بوت Telegram.",
"telegramToken": "توكن Telegram",
"telegramTokenDesc": "توكن البوت اللي جبت من '@BotFather'.",
"telegramTokenDesc": "توكن البوت اللي جبت من '{'@'}BotFather'.",
"telegramProxy": "بروكسي SOCKS",
"telegramProxyDesc": "يفعل بروكسي SOCKS5 للاتصال بـ Telegram. (اضبط الإعدادات حسب الدليل)",
"telegramAPIServer": "سيرفر Telegram API",
"telegramAPIServerDesc": "سيرفر Telegram API المستخدم. سيبه فاضي لاستخدام الافتراضي.",
"telegramChatId": "ID شات الأدمن",
"telegramChatIdDesc": "ID شات الأدمن في Telegram. (مفصول بفواصل)(تقدر تجيبه من @userinfobot) أو (استخدم '/id' في البوت)",
"telegramChatIdDesc": "ID شات الأدمن في Telegram. (مفصول بفواصل)(تقدر تجيبه من {'@'}userinfobot) أو (استخدم '/id' في البوت)",
"telegramNotifyTime": "وقت الإشعار",
"telegramNotifyTimeDesc": "وقت إشعار البوت للتقارير الدورية. (استخدم صيغة وقت crontab)",
"tgNotifyBackup": "نسخة احتياطية لقاعدة البيانات",

View file

@ -292,7 +292,7 @@
"IPLimitlogDesc": "The IPs history log. (to enable inbound after disabling, clear the log)",
"IPLimitlogclear": "Clear The Log",
"setDefaultCert": "Set Cert from Panel",
"telegramDesc": "Please provide Telegram Chat ID. (use '/id' command in the bot) or (@userinfobot)",
"telegramDesc": "Please provide Telegram Chat ID. (use '/id' command in the bot) or ({'@'}userinfobot)",
"subscriptionDesc": "To find your subscription URL, navigate to the 'Details'. Additionally, you can use the same name for several clients.",
"info": "Info",
"same": "Same",
@ -418,13 +418,13 @@
"telegramBotEnable": "Enable Telegram Bot",
"telegramBotEnableDesc": "Enables the Telegram bot.",
"telegramToken": "Telegram Token",
"telegramTokenDesc": "The Telegram bot token obtained from '@BotFather'.",
"telegramTokenDesc": "The Telegram bot token obtained from '{'@'}BotFather'.",
"telegramProxy": "SOCKS Proxy",
"telegramProxyDesc": "Enables SOCKS5 proxy for connecting to Telegram. (adjust settings as per guide)",
"telegramAPIServer": "Telegram API Server",
"telegramAPIServerDesc": "The Telegram API server to use. Leave blank to use the default server.",
"telegramChatId": "Admin Chat ID",
"telegramChatIdDesc": "The Telegram Admin Chat ID(s). (comma-separated)(get it here @userinfobot) or (use '/id' command in the bot)",
"telegramChatIdDesc": "The Telegram Admin Chat ID(s). (comma-separated)(get it here {'@'}userinfobot) or (use '/id' command in the bot)",
"telegramNotifyTime": "Notification Time",
"telegramNotifyTimeDesc": "The Telegram bot notification time set for periodic reports. (use the crontab time format)",
"tgNotifyBackup": "Database Backup",

View file

@ -292,7 +292,7 @@
"IPLimitlogDesc": "Registro de historial de IPs (antes de habilitar la entrada después de que haya sido desactivada por el límite de IP, debes borrar el registro).",
"IPLimitlogclear": "Limpiar el Registro",
"setDefaultCert": "Establecer certificado desde el panel",
"telegramDesc": "Por favor, proporciona el ID de Chat de Telegram. (usa el comando '/id' en el bot) o (@userinfobot)",
"telegramDesc": "Por favor, proporciona el ID de Chat de Telegram. (usa el comando '/id' en el bot) o ({'@'}userinfobot)",
"subscriptionDesc": "Puedes encontrar tu enlace de suscripción en Detalles, también puedes usar el mismo nombre para varias configuraciones.",
"info": "Info",
"same": "misma",
@ -418,13 +418,13 @@
"telegramBotEnable": "Habilitar bot de Telegram",
"telegramBotEnableDesc": "Conéctese a las funciones de este panel a través del bot de Telegram.",
"telegramToken": "Token de Telegram",
"telegramTokenDesc": "Debe obtener el token del administrador de bots de Telegram @botfather.",
"telegramTokenDesc": "Debe obtener el token del administrador de bots de Telegram {'@'}botfather.",
"telegramProxy": "Socks5 Proxy",
"telegramProxyDesc": "Si necesita el proxy Socks5 para conectarse a Telegram. Ajuste su configuración según la guía.",
"telegramAPIServer": "API Server de Telegram",
"telegramAPIServerDesc": "El servidor API de Telegram a utilizar. Déjelo en blanco para utilizar el servidor predeterminado.",
"telegramChatId": "IDs de Chat de Telegram para Administradores",
"telegramChatIdDesc": "IDs de Chat múltiples separados por comas. Use @userinfobot o use el comando '/id' en el bot para obtener sus IDs de Chat.",
"telegramChatIdDesc": "IDs de Chat múltiples separados por comas. Use {'@'}userinfobot o use el comando '/id' en el bot para obtener sus IDs de Chat.",
"telegramNotifyTime": "Hora de Notificación del Bot de Telegram",
"telegramNotifyTimeDesc": "Usar el formato de tiempo de Crontab.",
"tgNotifyBackup": "Respaldo de Base de Datos",

View file

@ -292,7 +292,7 @@
"IPLimitlogDesc": "گزارش تاریخچه آی‌پی. برای فعال کردن ورودی پس از غیرفعال شدن، گزارش را پاک کنید",
"IPLimitlogclear": "پاک کردن گزارش‌ها",
"setDefaultCert": "استفاده از گواهی پنل",
"telegramDesc": "لطفا شناسه گفتگوی تلگرام را وارد کنید. (از دستور '/id' در ربات استفاده کنید) یا (@userinfobot)",
"telegramDesc": "لطفا شناسه گفتگوی تلگرام را وارد کنید. (از دستور '/id' در ربات استفاده کنید) یا ({'@'}userinfobot)",
"subscriptionDesc": "شما می‌توانید لینک سابسکربپشن خودرا در 'جزئیات' پیدا کنید، همچنین می‌توانید از همین نام برای چندین کاربر استفاده‌کنید",
"info": "اطلاعات",
"same": "همسان",
@ -418,13 +418,13 @@
"telegramBotEnable": "فعال‌سازی ربات تلگرام",
"telegramBotEnableDesc": "ربات تلگرام را فعال می‌کند",
"telegramToken": "توکن تلگرام",
"telegramTokenDesc": "دریافت کنید @botfather توکن را می‌توانید از",
"telegramTokenDesc": "دریافت کنید {'@'}botfather توکن را می‌توانید از",
"telegramProxy": "SOCKS پراکسی",
"telegramProxyDesc": "را برای اتصال به تلگرام فعال می کند SOCKS5 پراکسی",
"telegramAPIServer": "سرور API تلگرام",
"telegramAPIServerDesc": "API سرور تلگرام برای اتصال را تغییر میدهد. برای استفاده از سرور پیش فرض خالی بگذارید",
"telegramChatId": "آی‌دی چت مدیر",
"telegramChatIdDesc": "دریافت ‌کنید ('/id'یا (دستور (@userinfobot) آی‌دی(های) چت تلگرام مدیر، از",
"telegramChatIdDesc": "دریافت ‌کنید ('/id'یا (دستور ({'@'}userinfobot) آی‌دی(های) چت تلگرام مدیر، از",
"telegramNotifyTime": "زمان نوتیفیکیشن",
"telegramNotifyTimeDesc": "زمان‌اطلاع‌رسانی ربات تلگرام برای گزارش های دوره‌ای. از فرمت زمانبندی لینوکس استفاده‌کنید‌",
"tgNotifyBackup": "پشتیبان‌گیری از دیتابیس",

View file

@ -292,7 +292,7 @@
"IPLimitlogDesc": "Log histori IP. (untuk mengaktifkan masuk setelah menonaktifkan, hapus log)",
"IPLimitlogclear": "Hapus Log",
"setDefaultCert": "Atur Sertifikat dari Panel",
"telegramDesc": "Harap berikan ID Obrolan Telegram. (gunakan perintah '/id' di bot) atau (@userinfobot)",
"telegramDesc": "Harap berikan ID Obrolan Telegram. (gunakan perintah '/id' di bot) atau ({'@'}userinfobot)",
"subscriptionDesc": "Untuk menemukan URL langganan Anda, buka 'Rincian'. Selain itu, Anda dapat menggunakan nama yang sama untuk beberapa klien.",
"info": "Info",
"same": "Sama",
@ -418,13 +418,13 @@
"telegramBotEnable": "Aktifkan Bot Telegram",
"telegramBotEnableDesc": "Mengaktifkan bot Telegram.",
"telegramToken": "Token Telegram",
"telegramTokenDesc": "Token bot Telegram yang diperoleh dari '@BotFather'.",
"telegramTokenDesc": "Token bot Telegram yang diperoleh dari '{'@'}BotFather'.",
"telegramProxy": "Proxy SOCKS",
"telegramProxyDesc": "Mengaktifkan proxy SOCKS5 untuk terhubung ke Telegram. (sesuaikan pengaturan sesuai panduan)",
"telegramAPIServer": "Telegram API Server",
"telegramAPIServerDesc": "Server API Telegram yang akan digunakan. Biarkan kosong untuk menggunakan server default.",
"telegramChatId": "ID Obrolan Admin",
"telegramChatIdDesc": "ID Obrolan Admin Telegram. (dipisahkan koma)(dapatkan di sini @userinfobot) atau (gunakan perintah '/id' di bot)",
"telegramChatIdDesc": "ID Obrolan Admin Telegram. (dipisahkan koma)(dapatkan di sini {'@'}userinfobot) atau (gunakan perintah '/id' di bot)",
"telegramNotifyTime": "Waktu Notifikasi",
"telegramNotifyTimeDesc": "Waktu notifikasi bot Telegram yang diatur untuk laporan berkala. (gunakan format waktu crontab)",
"tgNotifyBackup": "Cadangan Database",

View file

@ -292,7 +292,7 @@
"IPLimitlogDesc": "IP履歴ログ無効なインバウンドトラフィックを有効にするには、ログをクリアしてください",
"IPLimitlogclear": "ログをクリア",
"setDefaultCert": "パネル設定から証明書を設定",
"telegramDesc": "TelegramチャットIDを提供してください。ボットで'/id'コマンドを使用)または(@userinfobot",
"telegramDesc": "TelegramチャットIDを提供してください。ボットで'/id'コマンドを使用)または({'@'}userinfobot",
"subscriptionDesc": "サブスクリプションURLを見つけるには、“詳細情報”に移動してください。また、複数のクライアントに同じ名前を使用することができます。",
"info": "情報",
"same": "同じ",
@ -418,13 +418,13 @@
"telegramBotEnable": "Telegramボットを有効にする",
"telegramBotEnableDesc": "Telegramボット機能を有効にする",
"telegramToken": "Telegramボットトークン",
"telegramTokenDesc": "'@BotFather'から取得したTelegramボットトークン",
"telegramTokenDesc": "'{'@'}BotFather'から取得したTelegramボットトークン",
"telegramProxy": "SOCKS5プロキシ",
"telegramProxyDesc": "SOCKS5プロキシを有効にしてTelegramに接続するガイドに従って設定を調整",
"telegramAPIServer": "Telegram APIサーバー",
"telegramAPIServerDesc": "使用するTelegram APIサーバー。空白の場合はデフォルトサーバーを使用する",
"telegramChatId": "管理者チャットID",
"telegramChatIdDesc": "Telegram管理者チャットID複数の場合はカンマで区切る@userinfobotで取得するか、ボットで'/id'コマンドを使用して取得する",
"telegramChatIdDesc": "Telegram管理者チャットID複数の場合はカンマで区切る{'@'}userinfobotで取得するか、ボットで'/id'コマンドを使用して取得する",
"telegramNotifyTime": "通知時間",
"telegramNotifyTimeDesc": "定期的なTelegramボット通知時間を設定するcrontab時間形式を使用",
"tgNotifyBackup": "データベースバックアップ",

View file

@ -292,7 +292,7 @@
"IPLimitlogDesc": "O histórico de IPs. (para ativar o inbound após a desativação, limpe o log)",
"IPLimitlogclear": "Limpar o Log",
"setDefaultCert": "Definir Certificado pelo Painel",
"telegramDesc": "Por favor, forneça o ID do Chat do Telegram. (use o comando '/id' no bot) ou (@userinfobot)",
"telegramDesc": "Por favor, forneça o ID do Chat do Telegram. (use o comando '/id' no bot) ou ({'@'}userinfobot)",
"subscriptionDesc": "Para encontrar seu URL de assinatura, navegue até 'Detalhes'. Além disso, você pode usar o mesmo nome para vários clientes.",
"info": "Informações",
"same": "Igual",
@ -418,13 +418,13 @@
"telegramBotEnable": "Ativar Bot do Telegram",
"telegramBotEnableDesc": "Ativa o bot do Telegram.",
"telegramToken": "Token do Telegram",
"telegramTokenDesc": "O token do bot do Telegram obtido de '@BotFather'.",
"telegramTokenDesc": "O token do bot do Telegram obtido de '{'@'}BotFather'.",
"telegramProxy": "Proxy SOCKS",
"telegramProxyDesc": "Ativa o proxy SOCKS5 para conectar ao Telegram. (ajuste as configurações conforme o guia)",
"telegramAPIServer": "API Server do Telegram",
"telegramAPIServerDesc": "O servidor API do Telegram a ser usado. Deixe em branco para usar o servidor padrão.",
"telegramChatId": "ID de Chat do Administrador",
"telegramChatIdDesc": "O(s) ID(s) de Chat do Administrador no Telegram. (separado por vírgulas)(obtenha aqui @userinfobot) ou (use o comando '/id' no bot)",
"telegramChatIdDesc": "O(s) ID(s) de Chat do Administrador no Telegram. (separado por vírgulas)(obtenha aqui {'@'}userinfobot) ou (use o comando '/id' no bot)",
"telegramNotifyTime": "Hora da Notificação",
"telegramNotifyTimeDesc": "O horário de notificação do bot do Telegram configurado para relatórios periódicos. (use o formato de tempo do crontab)",
"tgNotifyBackup": "Backup do Banco de Dados",

View file

@ -292,7 +292,7 @@
"IPLimitlogDesc": "Лог IP-адресов (перед включением лога IP-адресов, вы должны очистить лог)",
"IPLimitlogclear": "Очистить лог",
"setDefaultCert": "Установить сертификат панели",
"telegramDesc": "Пожалуйста, укажите Chat ID Telegram. (используйте команду '/id' в боте) или (@userinfobot)",
"telegramDesc": "Пожалуйста, укажите Chat ID Telegram. (используйте команду '/id' в боте) или ({'@'}userinfobot)",
"subscriptionDesc": "Вы можете найти свою ссылку подписки в разделе 'Подробнее'",
"info": "Информация",
"same": "Тот же",
@ -418,13 +418,13 @@
"telegramBotEnable": "Включить Telegram бота",
"telegramBotEnableDesc": "Доступ к функциям панели через Telegram-бота",
"telegramToken": "Токен Telegram бота",
"telegramTokenDesc": "Необходимо получить токен у менеджера ботов Telegram @botfather",
"telegramTokenDesc": "Необходимо получить токен у менеджера ботов Telegram {'@'}botfather",
"telegramProxy": "Прокси-сервер Socks5",
"telegramProxyDesc": "Если для подключения к Telegram вам нужен прокси Socks5, настройте его параметры согласно руководству.",
"telegramAPIServer": "API-сервер Telegram",
"telegramAPIServerDesc": "Используемый API-сервер Telegram. Оставьте пустым, чтобы использовать сервер по умолчанию.",
"telegramChatId": "User ID администратора бота",
"telegramChatIdDesc": "Один или несколько User ID администратора(-ов) Telegram-бота. Для получения User ID используйте @userinfobot или команду '/id' в боте.",
"telegramChatIdDesc": "Один или несколько User ID администратора(-ов) Telegram-бота. Для получения User ID используйте {'@'}userinfobot или команду '/id' в боте.",
"telegramNotifyTime": "Частота уведомлений для администраторов от бота",
"telegramNotifyTimeDesc": "Укажите интервал уведомлений в формате Crontab",
"tgNotifyBackup": "Резервное копирование базы данных",

View file

@ -292,7 +292,7 @@
"IPLimitlogDesc": "IP geçmiş günlüğü. (devre dışı bırakıldıktan sonra gelini etkinleştirmek için günlüğü temizleyin)",
"IPLimitlogclear": "Günlüğü Temizle",
"setDefaultCert": "Panelden Sertifikayı Ayarla",
"telegramDesc": "Lütfen Telegram Sohbet Kimliği sağlayın. (botta '/id' komutunu kullanın) veya (@userinfobot)",
"telegramDesc": "Lütfen Telegram Sohbet Kimliği sağlayın. (botta '/id' komutunu kullanın) veya ({'@'}userinfobot)",
"subscriptionDesc": "Abonelik URL'inizi bulmak için 'Detaylar'a gidin. Ayrıca, aynı adı birden fazla müşteri için kullanabilirsiniz.",
"info": "Bilgi",
"same": "Aynı",
@ -418,13 +418,13 @@
"telegramBotEnable": "Telegram Botunu Etkinleştir",
"telegramBotEnableDesc": "Telegram botunu etkinleştirir.",
"telegramToken": "Telegram Token",
"telegramTokenDesc": "'@BotFather'dan alınan Telegram bot token.",
"telegramTokenDesc": "'{'@'}BotFather'dan alınan Telegram bot token.",
"telegramProxy": "SOCKS Proxy",
"telegramProxyDesc": "Telegram'a bağlanmak için SOCKS5 proxy'sini etkinleştirir. (ayarları kılavuzda belirtilen şekilde ayarlayın)",
"telegramAPIServer": "Telegram API Server",
"telegramAPIServerDesc": "Kullanılacak Telegram API sunucusu. Varsayılan sunucuyu kullanmak için boş bırakın.",
"telegramChatId": "Yönetici Sohbet Kimliği",
"telegramChatIdDesc": "Telegram Yönetici Sohbet Kimliği(leri). (virgülle ayrılmış)(buradan alın @userinfobot) veya (botta '/id' komutunu kullanın)",
"telegramChatIdDesc": "Telegram Yönetici Sohbet Kimliği(leri). (virgülle ayrılmış)(buradan alın {'@'}userinfobot) veya (botta '/id' komutunu kullanın)",
"telegramNotifyTime": "Bildirim Zamanı",
"telegramNotifyTimeDesc": "Periyodik raporlar için ayarlanan Telegram bot bildirim zamanı. (crontab zaman formatını kullanın)",
"tgNotifyBackup": "Veritabanı Yedeği",

View file

@ -292,7 +292,7 @@
"IPLimitlogDesc": "Журнал історії IP-адрес. (щоб увімкнути вхідну після вимкнення, очистіть журнал)",
"IPLimitlogclear": "Очистити журнал",
"setDefaultCert": "Установити сертифікат з панелі",
"telegramDesc": "Будь ласка, вкажіть ID чату Telegram. (використовуйте команду '/id' у боті) або (@userinfobot)",
"telegramDesc": "Будь ласка, вкажіть ID чату Telegram. (використовуйте команду '/id' у боті) або ({'@'}userinfobot)",
"subscriptionDesc": "Щоб знайти URL-адресу вашої підписки, перейдіть до «Деталі». Крім того, ви можете використовувати одне ім'я для кількох клієнтів.",
"info": "Інформація",
"same": "Те саме",
@ -418,13 +418,13 @@
"telegramBotEnable": "Увімкнути Telegram Bot",
"telegramBotEnableDesc": "Вмикає бота Telegram.",
"telegramToken": "Telegram Токен",
"telegramTokenDesc": "Токен бота Telegram, отриманий від '@BotFather'.",
"telegramTokenDesc": "Токен бота Telegram, отриманий від '{'@'}BotFather'.",
"telegramProxy": "SOCKS Проксі",
"telegramProxyDesc": "Вмикає проксі-сервер SOCKS5 для підключення до Telegram. (відкоригуйте параметри відповідно до посібника)",
"telegramAPIServer": "Сервер Telegram API",
"telegramAPIServerDesc": "Сервер Telegram API для використання. Залиште поле порожнім, щоб використовувати сервер за умовчанням.",
"telegramChatId": "Ідентифікатор чату адміністратора",
"telegramChatIdDesc": "Ідентифікатори чату адміністратора Telegram. (розділені комами) (отримайте тут @userinfobot) або (використовуйте команду '/id' у боті)",
"telegramChatIdDesc": "Ідентифікатори чату адміністратора Telegram. (розділені комами) (отримайте тут {'@'}userinfobot) або (використовуйте команду '/id' у боті)",
"telegramNotifyTime": "Час сповіщення",
"telegramNotifyTimeDesc": "Час повідомлення бота Telegram, встановлений для періодичних звітів. (використовуйте формат часу crontab)",
"tgNotifyBackup": "Резервне копіювання бази даних",

View file

@ -292,7 +292,7 @@
"IPLimitlogDesc": "Lịch sử đăng nhập IP (trước khi kích hoạt điểm vào sau khi bị vô hiệu hóa bởi giới hạn IP, bạn nên xóa lịch sử).",
"IPLimitlogclear": "Xóa Lịch sử",
"setDefaultCert": "Đặt chứng chỉ từ bảng điều khiển",
"telegramDesc": "Vui lòng cung cấp ID Trò chuyện Telegram. (sử dụng lệnh '/id' trong bot) hoặc (@userinfobot)",
"telegramDesc": "Vui lòng cung cấp ID Trò chuyện Telegram. (sử dụng lệnh '/id' trong bot) hoặc ({'@'}userinfobot)",
"subscriptionDesc": "Bạn có thể tìm liên kết gói đăng ký của mình trong Chi tiết, cũng như bạn có thể sử dụng cùng tên cho nhiều cấu hình khác nhau",
"info": "Thông tin",
"same": "Giống nhau",
@ -418,13 +418,13 @@
"telegramBotEnable": "Bật Bot Telegram",
"telegramBotEnableDesc": "Kết nối với các tính năng của bảng điều khiển này thông qua bot Telegram",
"telegramToken": "Token Telegram",
"telegramTokenDesc": "Bạn phải nhận token từ quản lý bot Telegram @botfather",
"telegramTokenDesc": "Bạn phải nhận token từ quản lý bot Telegram {'@'}botfather",
"telegramProxy": "Socks5 Proxy",
"telegramProxyDesc": "Nếu bạn cần socks5 proxy để kết nối với Telegram. Điều chỉnh cài đặt của nó theo hướng dẫn.",
"telegramAPIServer": "Telegram API Server",
"telegramAPIServerDesc": "Máy chủ API Telegram để sử dụng. Để trống để sử dụng máy chủ mặc định.",
"telegramChatId": "Chat ID Telegram của quản trị viên",
"telegramChatIdDesc": "Nhiều Chat ID phân tách bằng dấu phẩy. Sử dụng @userinfobot hoặc sử dụng lệnh '/id' trong bot để lấy Chat ID của bạn.",
"telegramChatIdDesc": "Nhiều Chat ID phân tách bằng dấu phẩy. Sử dụng {'@'}userinfobot hoặc sử dụng lệnh '/id' trong bot để lấy Chat ID của bạn.",
"telegramNotifyTime": "Thời gian thông báo của bot Telegram",
"telegramNotifyTimeDesc": "Sử dụng định dạng thời gian Crontab.",
"tgNotifyBackup": "Sao lưu Cơ sở dữ liệu",

View file

@ -292,7 +292,7 @@
"IPLimitlogDesc": "IP 历史日志(要启用被禁用的入站流量,请清除日志)",
"IPLimitlogclear": "清除日志",
"setDefaultCert": "从面板设置证书",
"telegramDesc": "请提供Telegram聊天ID。在机器人中使用'/id'命令)或(@userinfobot",
"telegramDesc": "请提供Telegram聊天ID。在机器人中使用'/id'命令)或({'@'}userinfobot",
"subscriptionDesc": "要找到你的订阅 URL请导航到“详细信息”。此外你可以为多个客户端使用相同的名称。",
"info": "信息",
"same": "相同",
@ -418,13 +418,13 @@
"telegramBotEnable": "启用 Telegram 机器人",
"telegramBotEnableDesc": "启用 Telegram 机器人功能",
"telegramToken": "Telegram 机器人令牌token",
"telegramTokenDesc": "从 '@BotFather' 获取的 Telegram 机器人令牌",
"telegramTokenDesc": "从 '{'@'}BotFather' 获取的 Telegram 机器人令牌",
"telegramProxy": "SOCKS5 Proxy",
"telegramProxyDesc": "启用 SOCKS5 代理连接到 Telegram根据指南调整设置",
"telegramAPIServer": "Telegram API Server",
"telegramAPIServerDesc": "要使用的 Telegram API 服务器。留空以使用默认服务器。",
"telegramChatId": "管理员聊天 ID",
"telegramChatIdDesc": "Telegram 管理员聊天 ID (多个以逗号分隔)(可通过 @userinfobot 获取,或在机器人中使用 '/id' 命令获取)",
"telegramChatIdDesc": "Telegram 管理员聊天 ID (多个以逗号分隔)(可通过 {'@'}userinfobot 获取,或在机器人中使用 '/id' 命令获取)",
"telegramNotifyTime": "通知时间",
"telegramNotifyTimeDesc": "设置周期性的 Telegram 机器人通知时间(使用 crontab 时间格式)",
"tgNotifyBackup": "数据库备份",

View file

@ -292,7 +292,7 @@
"IPLimitlogDesc": "IP 歷史日誌(要啟用被禁用的入站流量,請清除日誌)",
"IPLimitlogclear": "清除日誌",
"setDefaultCert": "從面板設定證書",
"telegramDesc": "請提供Telegram聊天ID。在機器人中使用'/id'命令)或(@userinfobot",
"telegramDesc": "請提供Telegram聊天ID。在機器人中使用'/id'命令)或({'@'}userinfobot",
"subscriptionDesc": "要找到你的訂閱 URL請導航到“詳細資訊”。此外你可以為多個客戶端使用相同的名稱。",
"info": "資訊",
"same": "相同",
@ -418,13 +418,13 @@
"telegramBotEnable": "啟用 Telegram 機器人",
"telegramBotEnableDesc": "啟用 Telegram 機器人功能",
"telegramToken": "Telegram 機器人令牌token",
"telegramTokenDesc": "從 '@BotFather' 獲取的 Telegram 機器人令牌",
"telegramTokenDesc": "從 '{'@'}BotFather' 獲取的 Telegram 機器人令牌",
"telegramProxy": "SOCKS5 Proxy",
"telegramProxyDesc": "啟用 SOCKS5 代理連線到 Telegram根據指南調整設定",
"telegramAPIServer": "Telegram API Server",
"telegramAPIServerDesc": "要使用的 Telegram API 伺服器。留空以使用預設伺服器。",
"telegramChatId": "管理員聊天 ID",
"telegramChatIdDesc": "Telegram 管理員聊天 ID (多個以逗號分隔)(可通過 @userinfobot 獲取,或在機器人中使用 '/id' 命令獲取)",
"telegramChatIdDesc": "Telegram 管理員聊天 ID (多個以逗號分隔)(可通過 {'@'}userinfobot 獲取,或在機器人中使用 '/id' 命令獲取)",
"telegramNotifyTime": "通知時間",
"telegramNotifyTimeDesc": "設定週期性的 Telegram 機器人通知時間(使用 crontab 時間格式)",
"tgNotifyBackup": "資料庫備份",

View file

@ -1,9 +1,12 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import dayjs from 'dayjs';
import { SyncOutlined } from '@ant-design/icons-vue';
import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
const { t } = useI18n();
import {
Inbound,
Protocols,
@ -171,9 +174,9 @@ async function submit() {
<template>
<a-modal
:open="open"
title="Add bulk clients"
ok-text="Create"
cancel-text="Close"
:title="t('pages.client.bulk')"
:ok-text="t('create')"
:cancel-text="t('close')"
:confirm-loading="saving"
:mask-closable="false"
@ok="submit"
@ -185,7 +188,7 @@ async function submit() {
:label-col="{ md: { span: 8 } }"
:wrapper-col="{ md: { span: 14 } }"
>
<a-form-item label="Email method">
<a-form-item :label="t('pages.client.method')">
<a-select v-model:value="form.emailMethod">
<a-select-option :value="0">Random</a-select-option>
<a-select-option :value="1">Random + Prefix</a-select-option>
@ -195,23 +198,23 @@ async function submit() {
</a-select>
</a-form-item>
<a-form-item v-if="form.emailMethod > 1" label="First number">
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.client.first')">
<a-input-number v-model:value="form.firstNum" :min="1" />
</a-form-item>
<a-form-item v-if="form.emailMethod > 1" label="Last number">
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.client.last')">
<a-input-number v-model:value="form.lastNum" :min="form.firstNum" />
</a-form-item>
<a-form-item v-if="form.emailMethod > 0" label="Prefix">
<a-form-item v-if="form.emailMethod > 0" :label="t('pages.client.prefix')">
<a-input v-model:value="form.emailPrefix" />
</a-form-item>
<a-form-item v-if="form.emailMethod > 2" label="Postfix">
<a-form-item v-if="form.emailMethod > 2" :label="t('pages.client.postfix')">
<a-input v-model:value="form.emailPostfix" />
</a-form-item>
<a-form-item v-if="form.emailMethod < 2" label="Client count">
<a-form-item v-if="form.emailMethod < 2" :label="t('pages.client.clientCount')">
<a-input-number v-model:value="form.quantity" :min="1" :max="500" />
</a-form-item>
<a-form-item v-if="inbound.protocol === Protocols.VMESS" label="Security">
<a-form-item v-if="inbound.protocol === Protocols.VMESS" :label="t('security')">
<a-select v-model:value="form.security">
<a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
</a-select>
@ -219,50 +222,48 @@ async function submit() {
<a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
<a-select v-model:value="form.flow">
<a-select-option value="">none</a-select-option>
<a-select-option value="">{{ t('none') }}</a-select-option>
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="subEnable">
<template #label>
<a-tooltip title="Same subscription token for every generated client (random when blank)">
Subscription
<SyncOutlined class="random-icon" @click="form.subId = RandomUtil.randomLowerAndNum(16)" />
</a-tooltip>
{{ t('subscription.title') }}
<SyncOutlined class="random-icon" @click="form.subId = RandomUtil.randomLowerAndNum(16)" />
</template>
<a-input v-model:value="form.subId" />
</a-form-item>
<a-form-item v-if="tgBotEnable" label="Telegram chat ID">
<a-form-item v-if="tgBotEnable" label="Telegram ID">
<a-input-number v-model:value="form.tgId" :min="0" :style="{ width: '50%' }" />
</a-form-item>
<a-form-item v-if="ipLimitEnable" label="IP limit">
<a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
<a-input-number v-model:value="form.limitIp" :min="0" />
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip title="0 means no limit">Total traffic (GB)</a-tooltip>
<a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
</template>
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" />
</a-form-item>
<a-form-item label="Delayed start">
<a-form-item :label="t('pages.client.delayedStart')">
<a-switch
v-model:checked="delayedStart"
@click="form.expiryTime = 0"
/>
</a-form-item>
<a-form-item v-if="delayedStart" label="Days from first connection">
<a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
<a-input-number v-model:value="delayedExpireDays" :min="0" />
</a-form-item>
<a-form-item v-else>
<template #label>
<a-tooltip title="Leave blank to never expire">Expiry date</a-tooltip>
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate') }}</a-tooltip>
</template>
<a-date-picker
v-model:value="expiryDate"
@ -274,9 +275,7 @@ async function submit() {
<a-form-item v-if="form.expiryTime !== 0">
<template #label>
<a-tooltip title="Days between automatic renewals (0 = no renewal)">
Renewal cycle (days)
</a-tooltip>
<a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
</template>
<a-input-number v-model:value="form.reset" :min="0" />
</a-form-item>

View file

@ -1,5 +1,6 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import dayjs from 'dayjs';
import { SyncOutlined, RetweetOutlined, DeleteOutlined } from '@ant-design/icons-vue';
@ -11,6 +12,8 @@ import {
} from '@/utils';
import { Inbound, Protocols, USERS_SECURITY, TLS_FLOW_CONTROL } from '@/models/inbound.js';
const { t } = useI18n();
// Add OR edit a single client on a multi-user inbound (VMess / VLess /
// Trojan / Shadowsocks-multi / Hysteria). The legacy panel routes both
// flows through the same modal same here.
@ -36,9 +39,6 @@ const props = defineProps({
const emit = defineEmits(['update:open', 'saved']);
// === Reactive draft =================================================
// We keep a parsed Inbound copy so its existing toString() / canEnableTlsFlow()
// helpers continue to work; `client` is the entry inside that inbound's
// clients array we're editing.
const inbound = ref(null);
const client = ref(null);
const oldClientId = ref('');
@ -58,8 +58,6 @@ const isTrojanOrSS = computed(() =>
protocol.value === Protocols.TROJAN || protocol.value === Protocols.SHADOWSOCKS,
);
// Bridge dayjs <-> the client's epoch-ms expiryTime field (legacy uses
// moment via _expiryTime getter; we go direct so we don't pull moment in).
const expiryDate = computed({
get: () => (client.value?.expiryTime > 0 ? dayjs(client.value.expiryTime) : null),
set: (next) => { if (client.value) client.value.expiryTime = next ? next.valueOf() : 0; },
@ -87,7 +85,6 @@ const totalGB = computed({
},
});
// Display: "Expired" tag in edit mode when past expiry.
const isExpired = computed(() => {
if (props.mode !== 'edit' || !client.value) return false;
return client.value.expiryTime > 0 && client.value.expiryTime < Date.now();
@ -126,8 +123,6 @@ function makeNewClient(proto, parsed) {
watch(() => props.open, (next) => {
if (!next) return;
if (!props.dbInbound) return;
// Clone the inbound so cancelling the modal doesn't leak edits onto
// the row's parsed-cache copy.
const parsed = Inbound.fromJson(props.dbInbound.toInbound().toJson());
inbound.value = parsed;
delayedStart.value = false;
@ -144,7 +139,6 @@ watch(() => props.open, (next) => {
oldClientId.value = '';
}
// Find the existing per-client traffic stats row for the usage display.
clientStats.value = (props.dbInbound.clientStats || []).find(
(s) => s.email === client.value?.email,
) || null;
@ -154,8 +148,6 @@ function close() {
emit('update:open', false);
}
// Random helpers wired to the small <SyncOutlined /> icons next to each
// label (matches legacy ergonomics).
function randomEmail() {
if (client.value) client.value.email = RandomUtil.randomLowerAndNum(9);
}
@ -179,7 +171,6 @@ function randomSubId() {
if (client.value) client.value.subId = RandomUtil.randomLowerAndNum(16);
}
// === Per-client IP-limit log helpers ================================
const clientIpsText = ref('');
async function loadClientIps() {
if (!client.value?.email) return;
@ -205,7 +196,6 @@ async function clearClientIps() {
if (msg?.success) clientIpsText.value = '';
}
// === Reset traffic on the open client ===============================
async function resetClientTraffic() {
if (!clientStats.value || !client.value?.email) return;
const msg = await HttpUtil.post(
@ -217,7 +207,6 @@ async function resetClientTraffic() {
}
}
// === Submit =========================================================
async function submit() {
if (!client.value || !inbound.value) return;
saving.value = true;
@ -240,7 +229,7 @@ async function submit() {
}
const title = computed(() =>
props.mode === 'edit' ? 'Edit client' : 'Add client',
props.mode === 'edit' ? t('pages.client.edit') : t('pages.client.add'),
);
</script>
@ -248,8 +237,8 @@ const title = computed(() =>
<a-modal
:open="open"
:title="title"
:ok-text="mode === 'edit' ? 'Update' : 'Create'"
cancel-text="Close"
:ok-text="mode === 'edit' ? t('pages.client.submitEdit') : t('pages.client.submitAdd')"
:cancel-text="t('close')"
:confirm-loading="saving"
:mask-closable="false"
@ok="submit"
@ -260,7 +249,7 @@ const title = computed(() =>
color="red"
class="status-banner"
>
Account is (expired | traffic ended) and disabled
{{ t('depleted') }}
</a-tag>
<a-form
@ -270,47 +259,39 @@ const title = computed(() =>
:label-col="{ md: { span: 8 } }"
:wrapper-col="{ md: { span: 14 } }"
>
<a-form-item label="Enable">
<a-form-item :label="t('enable')">
<a-switch v-model:checked="client.enable" />
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip title="Friendly identifier — appears in logs and the client list">
Email <SyncOutlined class="random-icon" @click="randomEmail" />
</a-tooltip>
{{ t('pages.inbounds.email') }} <SyncOutlined class="random-icon" @click="randomEmail" />
</template>
<a-input v-model:value="client.email" />
</a-form-item>
<a-form-item v-if="isTrojanOrSS">
<template #label>
<a-tooltip title="Reset to a fresh random value">
Password <SyncOutlined class="random-icon" @click="randomPassword" />
</a-tooltip>
{{ t('password') }} <SyncOutlined class="random-icon" @click="randomPassword" />
</template>
<a-input v-model:value="client.password" />
</a-form-item>
<a-form-item v-if="protocol === Protocols.HYSTERIA">
<template #label>
<a-tooltip title="Reset to a fresh random value">
Auth password <SyncOutlined class="random-icon" @click="randomAuth" />
</a-tooltip>
{{ t('password') }} <SyncOutlined class="random-icon" @click="randomAuth" />
</template>
<a-input v-model:value="client.auth" />
</a-form-item>
<a-form-item v-if="isVmessOrVless">
<template #label>
<a-tooltip title="Reset to a fresh random UUID">
ID <SyncOutlined class="random-icon" @click="randomId" />
</a-tooltip>
ID <SyncOutlined class="random-icon" @click="randomId" />
</template>
<a-input v-model:value="client.id" />
</a-form-item>
<a-form-item v-if="protocol === Protocols.VMESS" label="Security">
<a-form-item v-if="protocol === Protocols.VMESS" :label="t('security')">
<a-select v-model:value="client.security">
<a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">
{{ key }}
@ -320,92 +301,85 @@ const title = computed(() =>
<a-form-item v-if="client.email && subEnable">
<template #label>
<a-tooltip title="Subscription token — clients fetch their config under this id">
Subscription <SyncOutlined class="random-icon" @click="randomSubId" />
</a-tooltip>
{{ t('subscription.title') }} <SyncOutlined class="random-icon" @click="randomSubId" />
</template>
<a-input v-model:value="client.subId" />
</a-form-item>
<a-form-item v-if="client.email && tgBotEnable" label="Telegram chat ID">
<a-form-item v-if="client.email && tgBotEnable" label="Telegram ID">
<a-input-number v-model:value="client.tgId" :min="0" :style="{ width: '50%' }" />
</a-form-item>
<a-form-item v-if="client.email" label="Comment">
<a-form-item v-if="client.email" :label="t('comment')">
<a-input v-model:value="client.comment" />
</a-form-item>
<a-form-item v-if="ipLimitEnable" label="IP limit">
<a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
<a-input-number v-model:value="client.limitIp" :min="0" />
</a-form-item>
<a-form-item
v-if="ipLimitEnable && client.limitIp > 0 && client.email && mode === 'edit'"
label="IP log"
:label="t('pages.inbounds.IPLimitlog')"
>
<a-textarea
v-model:value="clientIpsText"
readonly
placeholder="Click to load client IPs"
:placeholder="t('pages.inbounds.IPLimitlogDesc')"
:auto-size="{ minRows: 3, maxRows: 8 }"
@click="loadClientIps"
/>
<a-button type="link" size="small" danger @click="clearClientIps">
<template #icon><DeleteOutlined /></template>
Clear
{{ t('pages.inbounds.IPLimitlogclear') }}
</a-button>
</a-form-item>
<a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
<a-select v-model:value="client.flow">
<a-select-option value="">none</a-select-option>
<a-select-option value="">{{ t('none') }}</a-select-option>
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">
{{ key }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="protocol === Protocols.VLESS">
<template #label>
<a-tooltip title="Reverse tag — for xray reverse-proxy outbound matching">
Reverse tag
</a-tooltip>
</template>
<a-form-item v-if="protocol === Protocols.VLESS" label="Reverse tag">
<a-input v-model:value="client.reverseTag" placeholder="Optional reverse tag" />
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip title="0 means no limit">Total traffic (GB)</a-tooltip>
<a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
</template>
<a-input-number v-model:value="totalGB" :min="0" :step="0.1" />
</a-form-item>
<a-form-item v-if="mode === 'edit' && clientStats" label="Usage">
<a-form-item v-if="mode === 'edit' && clientStats" :label="t('usage')">
<a-tag :color="ColorUtils.clientUsageColor(clientStats, trafficDiff)">
{{ SizeFormatter.sizeFormat(clientStats.up) }} /
{{ SizeFormatter.sizeFormat(clientStats.down) }}
({{ SizeFormatter.sizeFormat(clientStats.up + clientStats.down) }})
</a-tag>
<a-tooltip v-if="client.email" title="Reset traffic">
<a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
<RetweetOutlined class="action-icon" @click="resetClientTraffic" />
</a-tooltip>
</a-form-item>
<a-form-item label="Delayed start">
<a-form-item :label="t('pages.client.delayedStart')">
<a-switch
v-model:checked="delayedStart"
@click="client.expiryTime = 0"
/>
</a-form-item>
<a-form-item v-if="delayedStart" label="Days from first connection">
<a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
<a-input-number v-model:value="delayedExpireDays" :min="0" />
</a-form-item>
<a-form-item v-else>
<template #label>
<a-tooltip title="Leave blank to never expire">Expiry date</a-tooltip>
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate') }}</a-tooltip>
</template>
<a-date-picker
v-model:value="expiryDate"
@ -413,14 +387,12 @@ const title = computed(() =>
format="YYYY-MM-DD HH:mm:ss"
:style="{ width: '100%' }"
/>
<a-tag v-if="mode === 'edit' && isExpired" color="red">Expired</a-tag>
<a-tag v-if="mode === 'edit' && isExpired" color="red">{{ t('depleted') }}</a-tag>
</a-form-item>
<a-form-item v-if="client.expiryTime !== 0">
<template #label>
<a-tooltip title="Days between automatic renewals (0 = no renewal)">
Renewal cycle (days)
</a-tooltip>
<a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
</template>
<a-input-number v-model:value="client.reset" :min="0" />
</a-form-item>

View file

@ -1,5 +1,6 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import {
EditOutlined,
InfoCircleOutlined,
@ -12,6 +13,8 @@ import { Modal } from 'ant-design-vue';
import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
const { t } = useI18n();
// Per-inbound expand-row table. Rendered inside the inbound list's
// a-table#expandedRowRender slot for any inbound where
// `dbInbound.isMultiUser()` returns true. Mirrors the legacy
@ -142,20 +145,20 @@ function statusBadgeColor(client) {
// === Action confirms (mounted on the row, not a modal) ==============
function confirmReset(client) {
Modal.confirm({
title: `Reset traffic for ${client.email}?`,
content: 'Resets up/down counters to 0 for this client.',
okText: 'Reset',
cancelText: 'Cancel',
title: `${t('pages.inbounds.resetTraffic')} ${client.email}`,
content: t('pages.inbounds.resetTrafficContent'),
okText: t('reset'),
cancelText: t('cancel'),
onOk: () => emit('reset-traffic-client', { dbInbound: props.dbInbound, client }),
});
}
function confirmDelete(client) {
Modal.confirm({
title: `Delete client ${client.email}?`,
content: 'This cannot be undone.',
okText: 'Delete',
title: `${t('pages.inbounds.deleteClient')} ${client.email}`,
content: t('pages.inbounds.deleteClientContent'),
okText: t('delete'),
okType: 'danger',
cancelText: 'Cancel',
cancelText: t('cancel'),
onOk: () => emit('delete-client', { dbInbound: props.dbInbound, client }),
});
}
@ -163,22 +166,23 @@ function confirmDelete(client) {
// === Columns ========================================================
// Two layouts: desktop has icon-row actions across; mobile collapses
// the per-row actions into a single dropdown + an info popover.
const desktopColumns = [
{ title: 'Action', key: 'actions', width: 140 },
{ title: 'Enable', key: 'enable', width: 60 },
{ title: 'Online', key: 'online', width: 80 },
{ title: 'Client', key: 'client', width: 160 },
{ title: 'Traffic', key: 'traffic', align: 'center', width: 200 },
{ title: 'All-time', key: 'allTime', align: 'center', width: 110 },
{ title: 'Expiry', key: 'expiryTime', align: 'center', width: 180 },
];
const mobileColumns = [
{ title: 'Action', key: 'actionMenu', align: 'center', width: 10 },
{ title: 'Client', key: 'client', align: 'left', width: 90 },
{ title: 'Info', key: 'info', align: 'center', width: 10 },
];
// Computed so column titles re-render after a locale swap.
const desktopColumns = computed(() => [
{ title: t('pages.settings.actions'), key: 'actions', width: 140 },
{ title: t('enable'), key: 'enable', width: 60 },
{ title: t('online'), key: 'online', width: 80 },
{ title: t('pages.inbounds.client'), key: 'client', width: 160 },
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 200 },
{ title: t('pages.inbounds.allTimeTraffic'), key: 'allTime', align: 'center', width: 110 },
{ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 180 },
]);
const mobileColumns = computed(() => [
{ title: t('pages.settings.actions'), key: 'actionMenu', align: 'center', width: 10 },
{ title: t('pages.inbounds.client'), key: 'client', align: 'left', width: 90 },
{ title: t('info'), key: 'info', align: 'center', width: 10 },
]);
const columns = computed(() => (props.isMobile ? mobileColumns : desktopColumns));
const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value));
</script>
<template>
@ -195,28 +199,28 @@ const columns = computed(() => (props.isMobile ? mobileColumns : desktopColumns)
<!-- ============== Desktop action icons ============== -->
<template v-if="column.key === 'actions'">
<a-space :size="6">
<a-tooltip v-if="dbInbound.hasLink()" title="QR code">
<a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
<QrcodeOutlined
class="row-icon"
@click="emit('qrcode-client', { dbInbound, client: record })"
/>
</a-tooltip>
<a-tooltip title="Edit">
<a-tooltip :title="t('edit')">
<EditOutlined
class="row-icon"
@click="emit('edit-client', { dbInbound, client: record })"
/>
</a-tooltip>
<a-tooltip title="Info">
<a-tooltip :title="t('info')">
<InfoCircleOutlined
class="row-icon"
@click="emit('info-client', { dbInbound, client: record })"
/>
</a-tooltip>
<a-tooltip v-if="record.email" title="Reset traffic">
<a-tooltip v-if="record.email" :title="t('pages.inbounds.resetTraffic')">
<RetweetOutlined class="row-icon" @click="confirmReset(record)" />
</a-tooltip>
<a-tooltip v-if="isRemovable" title="Delete">
<a-tooltip v-if="isRemovable" :title="t('delete')">
<DeleteOutlined class="row-icon danger" @click="confirmDelete(record)" />
</a-tooltip>
</a-space>
@ -233,9 +237,9 @@ const columns = computed(() => (props.isMobile ? mobileColumns : desktopColumns)
<!-- ============== Online tag ============== -->
<template v-else-if="column.key === 'online'">
<a-popover>
<template #content>Last online: {{ lastOnlineLabel(record.email) }}</template>
<a-tag v-if="record.enable && isClientOnline(record.email)" color="green">online</a-tag>
<a-tag v-else>offline</a-tag>
<template #content>{{ t('lastOnline') }}: {{ lastOnlineLabel(record.email) }}</template>
<a-tag v-if="record.enable && isClientOnline(record.email)" color="green">{{ t('online') }}</a-tag>
<a-tag v-else>{{ t('offline') }}</a-tag>
</a-popover>
</template>
@ -244,10 +248,10 @@ const columns = computed(() => (props.isMobile ? mobileColumns : desktopColumns)
<a-space :size="2" class="client-id-cell" :style="{ flexWrap: 'nowrap' }">
<a-tooltip>
<template #title>
<template v-if="isClientDepleted(record.email)">depleted</template>
<template v-else-if="!record.enable">disabled</template>
<template v-else-if="isClientOnline(record.email)">online</template>
<template v-else>offline</template>
<template v-if="isClientDepleted(record.email)">{{ t('depleted') }}</template>
<template v-else-if="!record.enable">{{ t('disabled') }}</template>
<template v-else-if="isClientOnline(record.email)">{{ t('online') }}</template>
<template v-else>{{ t('offline') }}</template>
</template>
<a-badge :color="statusBadgeColor(record)" />
</a-tooltip>
@ -273,7 +277,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns : desktopColumns)
<td> {{ SizeFormatter.sizeFormat(getDown(record.email)) }}</td>
</tr>
<tr v-if="record.totalGB > 0">
<td>Remaining</td>
<td>{{ t('remained') }}</td>
<td>{{ SizeFormatter.sizeFormat(getRem(record.email)) }}</td>
</tr>
</tbody>
@ -314,7 +318,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns : desktopColumns)
<template v-if="record.expiryTime !== 0 && record.reset > 0">
<a-popover>
<template #content>
<span v-if="record.expiryTime < 0">Delayed start</span>
<span v-if="record.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
<span v-else>{{ IntlUtil.formatDate(record.expiryTime) }}</span>
</template>
<div class="traffic-cell">
@ -333,7 +337,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns : desktopColumns)
<template v-else>
<a-popover v-if="record.expiryTime !== 0">
<template #content>
<span v-if="record.expiryTime < 0">Delayed start</span>
<span v-if="record.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
<span v-else>{{ IntlUtil.formatDate(record.expiryTime) }}</span>
</template>
<a-tag
@ -362,18 +366,18 @@ const columns = computed(() => (props.isMobile ? mobileColumns : desktopColumns)
<a-menu-item
v-if="dbInbound.hasLink()"
@click="emit('qrcode-client', { dbInbound, client: record })"
><QrcodeOutlined /> QR code</a-menu-item>
><QrcodeOutlined /> {{ t('qrCode') }}</a-menu-item>
<a-menu-item @click="emit('edit-client', { dbInbound, client: record })">
<EditOutlined /> Edit
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item @click="emit('info-client', { dbInbound, client: record })">
<InfoCircleOutlined /> Info
<InfoCircleOutlined /> {{ t('info') }}
</a-menu-item>
<a-menu-item v-if="record.email" @click="confirmReset(record)">
<RetweetOutlined /> Reset traffic
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
</a-menu-item>
<a-menu-item v-if="isRemovable" @click="confirmDelete(record)">
<DeleteOutlined /> <span class="danger">Delete</span>
<DeleteOutlined /> <span class="danger">{{ t('delete') }}</span>
</a-menu-item>
<a-menu-item>
<a-switch
@ -381,7 +385,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns : desktopColumns)
:checked="record.enable"
@change="(next) => emit('toggle-enable-client', { dbInbound, client: record, next })"
/>
Enable
{{ t('enable') }}
</a-menu-item>
</a-menu>
</template>
@ -395,7 +399,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns : desktopColumns)
<table cellpadding="2">
<tbody>
<tr>
<td colspan="2" class="text-center">Traffic</td>
<td colspan="2" class="text-center">{{ t('pages.inbounds.traffic') }}</td>
</tr>
<tr>
<td class="num-cell">
@ -406,7 +410,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns : desktopColumns)
<tr>
<td colspan="2" class="text-center">
<a-divider style="margin: 0" />
Expiry
{{ t('pages.inbounds.expireDate') }}
</td>
</tr>
<tr>
@ -415,7 +419,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns : desktopColumns)
{{ IntlUtil.formatRelativeTime(record.expiryTime) }}
</a-tag>
<a-tag v-else-if="record.expiryTime < 0" color="green">
{{ -record.expiryTime / 86400000 }} d (delayed)
{{ -record.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
</a-tag>
<a-tag v-else color="purple"></a-tag>
</td>

View file

@ -1,5 +1,6 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { CopyOutlined, SyncOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
@ -13,6 +14,8 @@ import {
import { Inbound, Protocols } from '@/models/inbound.js';
import QrPanel from './QrPanel.vue';
const { t } = useI18n();
// One modal handles every protocol's info / share view because the
// legacy template did the same. The big v-if forks at the top decide
// which sub-block of the body renders:
@ -143,7 +146,7 @@ async function loadClientIps() {
clientIpsText.value = arr.join(' | ');
} else {
clientIpsArray.value = [];
clientIpsText.value = String(ips || 'No IP record');
clientIpsText.value = String(ips || t('tgbot.noIpRecord'));
}
} finally {
refreshing.value = false;
@ -155,13 +158,13 @@ async function clearClientIps() {
const msg = await HttpUtil.post(`/panel/api/inbounds/clearClientIps/${clientStats.value.email}`);
if (msg?.success) {
clientIpsArray.value = [];
clientIpsText.value = 'No IP record';
clientIpsText.value = t('tgbot.noIpRecord');
}
}
async function copyText(value) {
const ok = await ClipboardManager.copyText(String(value ?? ''));
if (ok) message.success('Copied');
if (ok) message.success(t('copied'));
}
// === Build state on open ===========================================
@ -243,7 +246,7 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
<template>
<a-modal
:open="open"
title="Inbound details"
:title="t('pages.inbounds.inboundData')"
:footer="null"
width="640px"
@cancel="close"
@ -255,11 +258,11 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
<table class="info-table">
<tbody>
<tr>
<td>Protocol</td>
<td>{{ t('pages.inbounds.protocol') }}</td>
<td><a-tag color="purple">{{ dbInbound.protocol }}</a-tag></td>
</tr>
<tr>
<td>Address</td>
<td>{{ t('pages.inbounds.address') }}</td>
<td>
<a-tooltip :title="dbInbound.address">
<a-tag class="info-large-tag">{{ dbInbound.address }}</a-tag>
@ -267,7 +270,7 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
</td>
</tr>
<tr>
<td>Port</td>
<td>{{ t('pages.inbounds.port') }}</td>
<td><a-tag>{{ dbInbound.port }}</a-tag></td>
</tr>
</tbody>
@ -279,22 +282,22 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
<table class="info-table">
<tbody>
<tr>
<td>Transmission</td>
<td>{{ t('transmission') }}</td>
<td><a-tag color="green">{{ networkLabel }}</a-tag></td>
</tr>
<template v-if="inbound.isTcp || inbound.isWs || inbound.isHttpupgrade || inbound.isXHTTP">
<tr>
<td>Host</td>
<td>{{ t('host') }}</td>
<td>
<a-tag v-if="inbound.host" class="info-large-tag">{{ inbound.host }}</a-tag>
<a-tag v-else color="orange">none</a-tag>
<a-tag v-else color="orange">{{ t('none') }}</a-tag>
</td>
</tr>
<tr>
<td>Path</td>
<td>{{ t('path') }}</td>
<td>
<a-tag v-if="inbound.path" class="info-large-tag">{{ inbound.path }}</a-tag>
<a-tag v-else color="orange">none</a-tag>
<a-tag v-else color="orange">{{ t('none') }}</a-tag>
</td>
</tr>
</template>
@ -322,9 +325,9 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
<!-- ============== Security / encryption / SNI ============== -->
<div v-if="dbInbound.hasLink()" class="security-line">
<span>Security</span>
<span>{{ t('security') }}</span>
<a-tag :color="securityColor">{{ securityLabel }}</a-tag>
<span v-if="encryptionLabel">Encryption</span>
<span v-if="encryptionLabel">{{ t('encryption') }}</span>
<a-tag
v-if="encryptionLabel"
class="info-large-tag"
@ -332,15 +335,15 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
>
{{ encryptionLabel }}
</a-tag>
<a-tooltip v-if="encryptionLabel" title="Copy">
<a-tooltip v-if="encryptionLabel" :title="t('copy')">
<a-button size="small" @click="copyText(encryptionLabel)">
<template #icon><CopyOutlined /></template>
</a-button>
</a-tooltip>
<template v-if="securityLabel !== 'none'">
<span>Domain</span>
<span>{{ t('domainName') }}</span>
<a-tag v-if="serverNameLabel" color="green">{{ serverNameLabel }}</a-tag>
<a-tag v-else color="orange">none</a-tag>
<a-tag v-else color="orange">{{ t('none') }}</a-tag>
</template>
</div>
@ -348,15 +351,15 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
<table v-if="dbInbound.isSS" class="info-table block">
<tbody>
<tr>
<td>Encryption</td>
<td>{{ t('encryption') }}</td>
<td><a-tag color="green">{{ inbound.settings.method }}</a-tag></td>
</tr>
<tr v-if="inbound.isSS2022">
<td>Password</td>
<td>{{ t('password') }}</td>
<td><a-tag class="info-large-tag">{{ inbound.settings.password }}</a-tag></td>
</tr>
<tr>
<td>Network</td>
<td>{{ t('pages.inbounds.network') }}</td>
<td><a-tag color="green">{{ inbound.settings.network }}</a-tag></td>
</tr>
</tbody>
@ -364,14 +367,14 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
<!-- ============== Per-client info (multi-user) ============== -->
<template v-if="clientSettings">
<a-divider>Client</a-divider>
<a-divider>{{ t('pages.inbounds.client') }}</a-divider>
<table class="info-table block">
<tbody>
<tr>
<td>Email</td>
<td>{{ t('pages.inbounds.email') }}</td>
<td>
<a-tag v-if="clientSettings.email" color="green">{{ clientSettings.email }}</a-tag>
<a-tag v-else color="red">none</a-tag>
<a-tag v-else color="red">{{ t('none') }}</a-tag>
</td>
</tr>
<tr v-if="clientSettings.id">
@ -379,30 +382,30 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
<td><a-tag>{{ clientSettings.id }}</a-tag></td>
</tr>
<tr v-if="dbInbound.isVMess">
<td>Security</td>
<td>{{ t('security') }}</td>
<td><a-tag>{{ clientSettings.security }}</a-tag></td>
</tr>
<tr v-if="inbound.canEnableTlsFlow()">
<td>Flow</td>
<td>
<a-tag v-if="clientSettings.flow">{{ clientSettings.flow }}</a-tag>
<a-tag v-else color="orange">none</a-tag>
<a-tag v-else color="orange">{{ t('none') }}</a-tag>
</td>
</tr>
<tr v-if="clientSettings.password">
<td>Password</td>
<td>{{ t('password') }}</td>
<td><a-tag class="info-large-tag">{{ clientSettings.password }}</a-tag></td>
</tr>
<tr>
<td>Status</td>
<td>{{ t('status') }}</td>
<td>
<a-tag v-if="isDepleted" color="red">depleted</a-tag>
<a-tag v-else-if="isEnable" color="green">enabled</a-tag>
<a-tag v-else>disabled</a-tag>
<a-tag v-if="isDepleted" color="red">{{ t('depleted') }}</a-tag>
<a-tag v-else-if="isEnable" color="green">{{ t('enabled') }}</a-tag>
<a-tag v-else>{{ t('disabled') }}</a-tag>
</td>
</tr>
<tr v-if="clientStats">
<td>Usage</td>
<td>{{ t('usage') }}</td>
<td>
<a-tag color="green">
{{ SizeFormatter.sizeFormat(clientStats.up + clientStats.down) }}
@ -414,33 +417,33 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
</td>
</tr>
<tr>
<td>Created</td>
<td>{{ t('pages.inbounds.createdAt') }}</td>
<td>
<a-tag v-if="clientSettings.created_at">{{ IntlUtil.formatDate(clientSettings.created_at) }}</a-tag>
<a-tag v-else>-</a-tag>
</td>
</tr>
<tr>
<td>Updated</td>
<td>{{ t('pages.inbounds.updatedAt') }}</td>
<td>
<a-tag v-if="clientSettings.updated_at">{{ IntlUtil.formatDate(clientSettings.updated_at) }}</a-tag>
<a-tag v-else>-</a-tag>
</td>
</tr>
<tr>
<td>Last online</td>
<td>{{ t('lastOnline') }}</td>
<td><a-tag>{{ formatLastOnline(clientSettings.email || '') }}</a-tag></td>
</tr>
<tr v-if="clientSettings.comment">
<td>Comment</td>
<td>{{ t('comment') }}</td>
<td><a-tag class="info-large-tag">{{ clientSettings.comment }}</a-tag></td>
</tr>
<tr v-if="ipLimitEnable">
<td>IP limit</td>
<td>{{ t('pages.inbounds.IPLimit') }}</td>
<td><a-tag>{{ clientSettings.limitIp }}</a-tag></td>
</tr>
<tr v-if="ipLimitEnable && clientSettings.limitIp > 0">
<td>IP log</td>
<td>{{ t('pages.inbounds.IPLimitlog') }}</td>
<td>
<div class="ip-log">
<div v-if="clientIpsArray.length > 0">
@ -451,11 +454,11 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
class="ip-log-row"
>{{ item }}</a-tag>
</div>
<a-tag v-else>{{ clientIpsText || 'No IP record' }}</a-tag>
<a-tag v-else>{{ clientIpsText || t('tgbot.noIpRecord') }}</a-tag>
</div>
<div class="ip-log-actions">
<SyncOutlined :spin="refreshing" @click="loadClientIps" />
<a-tooltip title="Clear IP log">
<a-tooltip :title="t('pages.inbounds.IPLimitlogclear')">
<DeleteOutlined @click="clearClientIps" />
</a-tooltip>
</div>
@ -468,9 +471,9 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
<table class="info-table summary-table">
<thead>
<tr>
<th>Remaining</th>
<th>Total</th>
<th>Expiry</th>
<th>{{ t('remained') }}</th>
<th>{{ t('pages.inbounds.totalUsage') }}</th>
<th>{{ t('pages.inbounds.expireDate') }}</th>
</tr>
</thead>
<tbody>
@ -494,7 +497,7 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
:color="ColorUtils.usageColor(Date.now(), expireDiff, clientSettings.expiryTime)"
>{{ IntlUtil.formatDate(clientSettings.expiryTime) }}</a-tag>
<a-tag v-else-if="clientSettings.expiryTime < 0" color="green">
{{ clientSettings.expiryTime / -86400000 }} days
{{ clientSettings.expiryTime / -86400000 }} {{ t('day') }}
</a-tag>
<a-tag v-else color="purple"></a-tag>
</td>
@ -504,26 +507,26 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
<!-- ============== Subscription URLs ============== -->
<template v-if="subSettings.enable && clientSettings.subId">
<a-divider>Subscription URL</a-divider>
<a-divider>{{ t('subscription.title') }}</a-divider>
<QrPanel
:value="subLink"
remark="Subscription link"
:remark="t('subscription.title')"
:show-qr="false"
/>
<QrPanel
v-if="subSettings.subJsonEnable && subJsonLink"
:value="subJsonLink"
remark="JSON link"
remark="JSON"
:show-qr="false"
/>
</template>
<!-- ============== Telegram chat id ============== -->
<template v-if="tgBotEnable && clientSettings.tgId">
<a-divider>Telegram chat ID</a-divider>
<a-divider>Telegram</a-divider>
<div class="tg-row">
<a-tag color="blue">{{ clientSettings.tgId }}</a-tag>
<a-tooltip title="Copy">
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyText(clientSettings.tgId)">
<template #icon><CopyOutlined /></template>
</a-button>
@ -533,7 +536,7 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
<!-- ============== Share links + QR codes ============== -->
<template v-if="dbInbound.hasLink() && links.length > 0">
<a-divider>Share links</a-divider>
<a-divider>{{ t('pages.inbounds.copyLink') }}</a-divider>
<QrPanel
v-for="(link, idx) in links"
:key="idx"
@ -545,7 +548,7 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
<!-- ============== Single-user SS share link ============== -->
<template v-else-if="dbInbound.isSS && !inbound.isSSMultiUser && links.length > 0">
<a-divider>Share link</a-divider>
<a-divider>{{ t('pages.inbounds.copyLink') }}</a-divider>
<QrPanel
v-for="(link, idx) in links"
:key="idx"
@ -558,9 +561,9 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
<table v-if="inbound.protocol === Protocols.TUNNEL" class="info-table protocol-table">
<thead>
<tr>
<th>Target address</th>
<th>Destination port</th>
<th>Network</th>
<th>{{ t('pages.inbounds.targetAddress') }}</th>
<th>{{ t('pages.inbounds.destinationPort') }}</th>
<th>{{ t('pages.inbounds.network') }}</th>
<th>FollowRedirect</th>
</tr>
</thead>
@ -592,8 +595,8 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
<template v-if="inbound.settings.auth === 'password'">
<tr>
<td></td>
<td>Username</td>
<td>Password</td>
<td>{{ t('username') }}</td>
<td>{{ t('password') }}</td>
</tr>
<tr v-for="(account, idx) in inbound.settings.accounts" :key="idx">
<td>{{ idx }}</td>
@ -609,8 +612,8 @@ const serverNameLabel = computed(() => inbound.value?.serverName || '');
<thead>
<tr>
<th></th>
<th>Username</th>
<th>Password</th>
<th>{{ t('username') }}</th>
<th>{{ t('password') }}</th>
</tr>
</thead>
<tbody>

View file

@ -1,9 +1,12 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Inbound, Protocols } from '@/models/inbound.js';
import QrPanel from './QrPanel.vue';
const { t } = useI18n();
// Light QR-only modal used for the "qrcode" row action on
// single-user Shadowsocks and WireGuard inbounds. The big info modal
// (InboundInfoModal) is too detailed when the user just wants the
@ -43,7 +46,7 @@ function close() {
</script>
<template>
<a-modal :open="open" title="QR code" :footer="null" width="420px" @cancel="close">
<a-modal :open="open" :title="t('qrCode')" :footer="null" width="420px" @cancel="close">
<template v-if="dbInbound">
<QrPanel
v-for="(link, idx) in links"

View file

@ -1,11 +1,14 @@
<script setup>
import { onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import QRious from 'qrious';
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { ClipboardManager, FileManager } from '@/utils';
const { t } = useI18n();
// Renders a single share-link as a clickable QR code + a copy button
// + (optional) a download button. Used per-link inside the inbound
// info modal the canvas is repainted whenever `value` changes.
@ -47,7 +50,7 @@ watch(() => props.size, paint);
async function copy() {
const ok = await ClipboardManager.copyText(props.value);
if (ok) message.success('Copied');
if (ok) message.success(t('copied'));
}
function download() {
@ -60,12 +63,12 @@ function download() {
<div class="qr-panel">
<div class="qr-panel-header">
<a-tag color="green" class="qr-remark">{{ remark }}</a-tag>
<a-tooltip title="Copy">
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copy">
<template #icon><CopyOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-if="downloadName" title="Download">
<a-tooltip v-if="downloadName" :title="t('download')">
<a-button size="small" @click="download">
<template #icon><DownloadOutlined /></template>
</a-button>

View file

@ -1,9 +1,12 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { HttpUtil, LanguageManager } from '@/utils';
import SettingListItem from '@/components/SettingListItem.vue';
const { t } = useI18n();
const props = defineProps({
// Reactive AllSetting instance shared with the parent page.
allSetting: { type: Object, required: true },
@ -91,17 +94,14 @@ onMounted(loadInboundTags);
<template>
<a-collapse default-active-key="1">
<a-collapse-panel key="1" header="General">
<a-collapse-panel key="1" :header="t('pages.settings.panelSettings')">
<SettingListItem paddings="small">
<template #title>Remark model</template>
<template #description>Sample: <i>#{{ remarkSample }}</i></template>
<template #title>{{ t('pages.settings.remarkModel') }}</template>
<template #description>{{ t('pages.settings.sampleRemark') }}: <i>#{{ remarkSample }}</i></template>
<template #control>
<a-input-group :style="{ width: '100%' }">
<a-select
v-model:value="remarkModel"
mode="multiple"
:style="{ paddingRight: '.5rem', minWidth: '80%', width: 'auto' }"
>
<a-select v-model:value="remarkModel" mode="multiple"
:style="{ paddingRight: '.5rem', minWidth: '80%', width: 'auto' }">
<a-select-option v-for="(label, key) in remarkModels" :key="key" :value="key">
{{ label }}
</a-select-option>
@ -114,77 +114,59 @@ onMounted(loadInboundTags);
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Panel listen IP</template>
<template #description>The IP the panel binds to. Leave empty to listen on all interfaces.</template>
<template #title>{{ t('pages.settings.panelListeningIP') }}</template>
<template #description>{{ t('pages.settings.panelListeningIPDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.webListen" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Panel domain</template>
<template #description>Optional domain used in URLs and certs.</template>
<template #title>{{ t('pages.settings.panelListeningDomain') }}</template>
<template #description>{{ t('pages.settings.panelListeningDomainDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.webDomain" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Panel port</template>
<template #description>Restart required after changing.</template>
<template #title>{{ t('pages.settings.panelPort') }}</template>
<template #description>{{ t('pages.settings.panelPortDesc') }}</template>
<template #control>
<a-input-number
v-model:value="allSetting.webPort"
:min="1"
:max="65535"
:style="{ width: '100%' }"
/>
<a-input-number v-model:value="allSetting.webPort" :min="1" :max="65535" :style="{ width: '100%' }" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Panel base path</template>
<template #description>The URL prefix the panel is served under. Default is "/".</template>
<template #title>{{ t('pages.settings.panelUrlPath') }}</template>
<template #description>{{ t('pages.settings.panelUrlPathDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.webBasePath" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Session max age (minutes)</template>
<template #description>Login session lifetime.</template>
<template #title>{{ t('pages.settings.sessionMaxAge') }}</template>
<template #description>{{ t('pages.settings.sessionMaxAgeDesc') }}</template>
<template #control>
<a-input-number
v-model:value="allSetting.sessionMaxAge"
:min="60"
:style="{ width: '100%' }"
/>
<a-input-number v-model:value="allSetting.sessionMaxAge" :min="60" :style="{ width: '100%' }" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Page size</template>
<template #description>Inbounds table page size. 0 disables pagination.</template>
<template #title>{{ t('pages.settings.pageSize') }}</template>
<template #description>{{ t('pages.settings.pageSizeDesc') }}</template>
<template #control>
<a-input-number
v-model:value="allSetting.pageSize"
:min="0"
:step="5"
:style="{ width: '100%' }"
/>
<a-input-number v-model:value="allSetting.pageSize" :min="0" :step="5" :style="{ width: '100%' }" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Language</template>
<template #title>{{ t('pages.settings.language') }}</template>
<template #control>
<a-select v-model:value="lang" :style="{ width: '100%' }" @change="onLangChange">
<a-select-option
v-for="l in LanguageManager.supportedLanguages"
:key="l.value"
:value="l.value"
:label="l.value"
>
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value" :value="l.value"
:label="l.value">
<span role="img" :aria-label="l.name">{{ l.icon }}</span>
&nbsp;&nbsp;<span>{{ l.name }}</span>
</a-select-option>
@ -193,92 +175,81 @@ onMounted(loadInboundTags);
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="2" header="Notifications">
<a-collapse-panel key="2" :header="t('pages.settings.notifications')">
<SettingListItem paddings="small">
<template #title>Expiry notification (days)</template>
<template #description>Notify before clients expire (0 = disabled).</template>
<template #title>{{ t('pages.settings.expireTimeDiff') }}</template>
<template #description>{{ t('pages.settings.expireTimeDiffDesc') }}</template>
<template #control>
<a-input-number
v-model:value="allSetting.expireDiff"
:min="0"
:style="{ width: '100%' }"
/>
<a-input-number v-model:value="allSetting.expireDiff" :min="0" :style="{ width: '100%' }" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Traffic notification (GB)</template>
<template #description>Notify before clients run out of traffic (0 = disabled).</template>
<template #title>{{ t('pages.settings.trafficDiff') }}</template>
<template #description>{{ t('pages.settings.trafficDiffDesc') }}</template>
<template #control>
<a-input-number
v-model:value="allSetting.trafficDiff"
:min="0"
:style="{ width: '100%' }"
/>
<a-input-number v-model:value="allSetting.trafficDiff" :min="0" :style="{ width: '100%' }" />
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="3" header="Certificates">
<a-collapse-panel key="3" :header="t('pages.settings.certs')">
<SettingListItem paddings="small">
<template #title>Public key path</template>
<template #description>Absolute path to the panel's TLS certificate.</template>
<template #title>{{ t('pages.settings.publicKeyPath') }}</template>
<template #description>{{ t('pages.settings.publicKeyPathDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.webCertFile" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Private key path</template>
<template #description>Absolute path to the panel's TLS private key.</template>
<template #title>{{ t('pages.settings.privateKeyPath') }}</template>
<template #description>{{ t('pages.settings.privateKeyPathDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.webKeyFile" type="text" />
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="4" header="External traffic webhook">
<a-collapse-panel key="4" :header="t('pages.settings.externalTraffic')">
<SettingListItem paddings="small">
<template #title>Enable external traffic info</template>
<template #description>Push traffic events to an external endpoint.</template>
<template #title>{{ t('pages.settings.externalTrafficInformEnable') }}</template>
<template #description>{{ t('pages.settings.externalTrafficInformEnableDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.externalTrafficInformEnable" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>External traffic URI</template>
<template #description>HTTP(S) endpoint that receives traffic events.</template>
<template #title>{{ t('pages.settings.externalTrafficInformURI') }}</template>
<template #description>{{ t('pages.settings.externalTrafficInformURIDesc') }}</template>
<template #control>
<a-input
v-model:value="allSetting.externalTrafficInformURI"
placeholder="(http|https)://domain[:port]/path/"
type="text"
/>
<a-input v-model:value="allSetting.externalTrafficInformURI" placeholder="(http|https)://domain[:port]/path/"
type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Restart xray on client disable</template>
<template #description>Apply changes immediately by restarting xray.</template>
<template #title>{{ t('pages.settings.restartXrayOnClientDisable') }}</template>
<template #description>{{ t('pages.settings.restartXrayOnClientDisableDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.restartXrayOnClientDisable" />
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="5" header="Date and time">
<a-collapse-panel key="5" :header="t('pages.settings.dateAndTime')">
<SettingListItem paddings="small">
<template #title>Time location</template>
<template #description>IANA timezone, e.g. "Local", "UTC", "Asia/Tehran".</template>
<template #title>{{ t('pages.settings.timeZone') }}</template>
<template #description>{{ t('pages.settings.timeZoneDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.timeLocation" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Date picker</template>
<template #description>Calendar style used for expiry pickers.</template>
<template #title>{{ t('pages.settings.datepicker') }}</template>
<template #description>{{ t('pages.settings.datepickerDescription') }}</template>
<template #control>
<a-select v-model:value="datepicker" :style="{ width: '100%' }">
<a-select-option v-for="item in datepickerList" :key="item.value" :value="item.value">
@ -307,12 +278,7 @@ onMounted(loadInboundTags);
<SettingListItem paddings="small">
<template #title>LDAP port</template>
<template #control>
<a-input-number
v-model:value="allSetting.ldapPort"
:min="1"
:max="65535"
:style="{ width: '100%' }"
/>
<a-input-number v-model:value="allSetting.ldapPort" :min="1" :max="65535" :style="{ width: '100%' }" />
</template>
</SettingListItem>
@ -331,7 +297,7 @@ onMounted(loadInboundTags);
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Password</template>
<template #title>{{ t('password') }}</template>
<template #control>
<a-input-password v-model:value="allSetting.ldapPassword" />
</template>
@ -401,11 +367,7 @@ onMounted(loadInboundTags);
<template #title>Inbound tags</template>
<template #description>Inbounds that LDAP sync may auto-create or auto-delete clients on.</template>
<template #control>
<a-select
v-model:value="ldapInboundTagList"
mode="multiple"
:style="{ width: '100%' }"
>
<a-select v-model:value="ldapInboundTagList" mode="multiple" :style="{ width: '100%' }">
<a-select-option v-for="opt in inboundOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
@ -433,33 +395,21 @@ onMounted(loadInboundTags);
<SettingListItem paddings="small">
<template #title>Default total (GB)</template>
<template #control>
<a-input-number
v-model:value="allSetting.ldapDefaultTotalGB"
:min="0"
:style="{ width: '100%' }"
/>
<a-input-number v-model:value="allSetting.ldapDefaultTotalGB" :min="0" :style="{ width: '100%' }" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Default expiry (days)</template>
<template #control>
<a-input-number
v-model:value="allSetting.ldapDefaultExpiryDays"
:min="0"
:style="{ width: '100%' }"
/>
<a-input-number v-model:value="allSetting.ldapDefaultExpiryDays" :min="0" :style="{ width: '100%' }" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Default IP limit</template>
<template #control>
<a-input-number
v-model:value="allSetting.ldapDefaultLimitIP"
:min="0"
:style="{ width: '100%' }"
/>
<a-input-number v-model:value="allSetting.ldapDefaultLimitIP" :min="0" :style="{ width: '100%' }" />
</template>
</SettingListItem>
</a-collapse-panel>

View file

@ -1,11 +1,14 @@
<script setup>
import { reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue';
import { HttpUtil, RandomUtil } from '@/utils';
import SettingListItem from '@/components/SettingListItem.vue';
import TwoFactorModal from './TwoFactorModal.vue';
const { t } = useI18n();
const props = defineProps({
allSetting: { type: Object, required: true },
});
@ -20,7 +23,7 @@ const tfa = reactive({
type: 'set',
// resolveConfirm is called by the modal's @confirm with the success bool;
// it then routes the value back to whichever flow opened the modal.
resolveConfirm: (_success) => {},
resolveConfirm: (_success) => { },
});
function openTfa({ title, description = '', token = '', type, onConfirm }) {
@ -62,8 +65,8 @@ async function sendUpdateUser() {
function updateUser() {
if (props.allSetting.twoFactorEnable) {
openTfa({
title: 'Confirm with 2FA',
description: 'Enter the current 6-digit code to change credentials.',
title: t('pages.settings.security.twoFactorModalChangeCredentialsTitle'),
description: t('pages.settings.security.twoFactorModalChangeCredentialsStep'),
token: props.allSetting.twoFactorToken,
type: 'confirm',
onConfirm: (ok) => { if (ok) sendUpdateUser(); },
@ -78,12 +81,12 @@ function toggleTwoFactor() {
if (!props.allSetting.twoFactorEnable) {
const newToken = RandomUtil.randomBase32String();
openTfa({
title: 'Enable two-factor authentication',
title: t('pages.settings.security.twoFactorModalSetTitle'),
token: newToken,
type: 'set',
onConfirm: (ok) => {
if (ok) {
message.success('Two-factor authentication enabled.');
message.success(t('pages.settings.security.twoFactorModalSetSuccess'));
props.allSetting.twoFactorToken = newToken;
}
props.allSetting.twoFactorEnable = ok;
@ -91,13 +94,13 @@ function toggleTwoFactor() {
});
} else {
openTfa({
title: 'Disable two-factor authentication',
description: 'Enter the current 6-digit code to disable 2FA.',
title: t('pages.settings.security.twoFactorModalDeleteTitle'),
description: t('pages.settings.security.twoFactorModalRemoveStep'),
token: props.allSetting.twoFactorToken,
type: 'confirm',
onConfirm: (ok) => {
if (!ok) return;
message.success('Two-factor authentication disabled.');
message.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
props.allSetting.twoFactorEnable = false;
props.allSetting.twoFactorToken = '';
},
@ -108,30 +111,30 @@ function toggleTwoFactor() {
<template>
<a-collapse default-active-key="1">
<a-collapse-panel key="1" header="Admin">
<a-collapse-panel key="1" :header="t('pages.settings.security.admin')">
<SettingListItem paddings="small">
<template #title>Current username</template>
<template #title>{{ t('pages.settings.oldUsername') }}</template>
<template #control>
<a-input v-model:value="user.oldUsername" autocomplete="username" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Current password</template>
<template #title>{{ t('pages.settings.currentPassword') }}</template>
<template #control>
<a-input-password v-model:value="user.oldPassword" autocomplete="current-password" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>New username</template>
<template #title>{{ t('pages.settings.newUsername') }}</template>
<template #control>
<a-input v-model:value="user.newUsername" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>New password</template>
<template #title>{{ t('pages.settings.newPassword') }}</template>
<template #control>
<a-input-password v-model:value="user.newPassword" autocomplete="new-password" />
</template>
@ -139,15 +142,15 @@ function toggleTwoFactor() {
<a-list-item>
<a-space direction="horizontal" :style="{ padding: '0 20px' }">
<a-button type="primary" :loading="updating" @click="updateUser">Confirm</a-button>
<a-button type="primary" :loading="updating" @click="updateUser">{{ t('confirm') }}</a-button>
</a-space>
</a-list-item>
</a-collapse-panel>
<a-collapse-panel key="2" header="Two-factor authentication">
<a-collapse-panel key="2" :header="t('pages.settings.security.twoFactor')">
<SettingListItem paddings="small">
<template #title>Enable 2FA</template>
<template #description>Require a 6-digit TOTP code on every panel login.</template>
<template #title>{{ t('pages.settings.security.twoFactorEnable') }}</template>
<template #description>{{ t('pages.settings.security.twoFactorEnableDesc') }}</template>
<template #control>
<a-switch :checked="allSetting.twoFactorEnable" @click="toggleTwoFactor" />
</template>
@ -155,12 +158,6 @@ function toggleTwoFactor() {
</a-collapse-panel>
</a-collapse>
<TwoFactorModal
v-model:open="tfa.open"
:title="tfa.title"
:description="tfa.description"
:token="tfa.token"
:type="tfa.type"
@confirm="onTfaConfirm"
/>
<TwoFactorModal v-model:open="tfa.open" :title="tfa.title" :description="tfa.description" :token="tfa.token"
:type="tfa.type" @confirm="onTfaConfirm" />
</template>

View file

@ -33,6 +33,16 @@ const { isMobile } = useMediaQuery();
const basePath = window.__X_UI_BASE_PATH__ || '';
const requestUri = window.location.pathname;
// AD-Vue 4's <a-back-top> calls `target()` after mount to find the
// scrolled element. Inline-arrow `() => document.getElementById(...)`
// in the template threw "Cannot read properties of undefined (reading
// 'getElementById')" because of how Vue 3 evaluates the expression
// outside the script-setup scope wrap in a regular function so
// `document` resolves to the window global at call time.
function scrollTarget() {
return document.getElementById('content-layout');
}
// `entry*` mirrors the URL the user opened the panel with so the page
// can rebuild it after a restart that may change host/port/scheme.
const entryHost = ref('');
@ -129,7 +139,7 @@ const confAlerts = computed(() => {
if (allSetting.subEnable) {
let subPath = allSetting.subPath;
if (allSetting.subURI) {
try { subPath = new URL(allSetting.subURI).pathname; } catch (_e) {}
try { subPath = new URL(allSetting.subURI).pathname; } catch (_e) { }
}
if (subPath === '/sub/') {
out.push('Default subscription path "/sub/" is well-known — change it.');
@ -138,7 +148,7 @@ const confAlerts = computed(() => {
if (allSetting.subJsonEnable) {
let p = allSetting.subJsonPath;
if (allSetting.subJsonURI) {
try { p = new URL(allSetting.subJsonURI).pathname; } catch (_e) {}
try { p = new URL(allSetting.subJsonURI).pathname; } catch (_e) { }
}
if (p === '/json/') {
out.push('Default JSON subscription path "/json/" is well-known — change it.');
@ -152,10 +162,7 @@ const alertVisible = ref(true);
<template>
<a-config-provider :theme="antdThemeConfig">
<a-layout
class="settings-page"
:class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }"
>
<a-layout class="settings-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
<a-layout class="content-shell">
@ -164,14 +171,8 @@ const alertVisible = ref(true);
<div v-if="!fetched" class="loading-spacer" />
<template v-else>
<a-alert
v-if="confAlerts.length > 0 && alertVisible"
type="error"
show-icon
closable
class="conf-alert"
@close="alertVisible = false"
>
<a-alert v-if="confAlerts.length > 0 && alertVisible" type="error" show-icon closable class="conf-alert"
@close="alertVisible = false">
<template #message>Security warnings</template>
<template #description>
<b>Your panel may be exposed:</b>
@ -196,12 +197,8 @@ const alertVisible = ref(true);
</a-space>
</a-col>
<a-col :xs="24" :sm="14" class="header-info">
<a-back-top :target="() => document.getElementById('content-layout')" :visibility-height="200" />
<a-alert
type="warning"
show-icon
:message="t('pages.settings.infoDesc')"
/>
<a-back-top :target="scrollTarget" :visibility-height="200" />
<a-alert type="warning" show-icon :message="t('pages.settings.infoDesc')" />
</a-col>
</a-row>
</a-card>
@ -237,11 +234,7 @@ const alertVisible = ref(true);
</template>
<SubscriptionGeneralTab :all-setting="allSetting" />
</a-tab-pane>
<a-tab-pane
v-if="allSetting.subJsonEnable || allSetting.subClashEnable"
key="5"
class="tab-pane"
>
<a-tab-pane v-if="allSetting.subJsonEnable || allSetting.subClashEnable" key="5" class="tab-pane">
<template #tab>
<CodeOutlined />
<span>{{ t('pages.settings.subSettings') }} (Formats)</span>
@ -283,23 +276,38 @@ const alertVisible = ref(true);
background: transparent;
}
.content-shell { background: transparent; }
.content-area { padding: 24px; }
.content-shell {
background: transparent;
}
.loading-spacer { min-height: calc(100vh - 120px); }
.content-area {
padding: 24px;
}
.conf-alert { margin-bottom: 10px; }
.loading-spacer {
min-height: calc(100vh - 120px);
}
.conf-alert {
margin-bottom: 10px;
}
.header-row {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.header-actions { padding: 4px; }
.header-actions {
padding: 4px;
}
.header-info {
display: flex;
justify-content: flex-end;
}
.tab-pane { padding-top: 20px; }
.tab-pane {
padding-top: 20px;
}
</style>

View file

@ -1,7 +1,10 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import SettingListItem from '@/components/SettingListItem.vue';
const { t } = useI18n();
const props = defineProps({
allSetting: { type: Object, required: true },
});
@ -207,62 +210,46 @@ const directDomains = computed({
<template>
<a-collapse default-active-key="1">
<a-collapse-panel key="1" header="General">
<a-collapse-panel key="1" :header="t('pages.settings.panelSettings')">
<SettingListItem v-if="allSetting.subJsonEnable" paddings="small">
<template #title>JSON path</template>
<template #description>URL prefix for JSON subscription endpoints.</template>
<template #title>JSON {{ t('pages.settings.subPath') }}</template>
<template #description>{{ t('pages.settings.subPathDesc') }}</template>
<template #control>
<a-input
v-model:value="subJsonPath"
type="text"
placeholder="/json/"
@blur="normalizePath('subJsonPath')"
/>
<a-input v-model:value="subJsonPath" type="text" placeholder="/json/" @blur="normalizePath('subJsonPath')" />
</template>
</SettingListItem>
<SettingListItem v-if="allSetting.subJsonEnable" paddings="small">
<template #title>JSON URI override</template>
<template #description>Full URL returned to JSON-format clients.</template>
<template #title>JSON {{ t('pages.settings.subURI') }}</template>
<template #description>{{ t('pages.settings.subURIDesc') }}</template>
<template #control>
<a-input
v-model:value="allSetting.subJsonURI"
type="text"
placeholder="(http|https)://domain[:port]/path/"
/>
<a-input v-model:value="allSetting.subJsonURI" type="text" placeholder="(http|https)://domain[:port]/path/" />
</template>
</SettingListItem>
<SettingListItem v-if="allSetting.subClashEnable" paddings="small">
<template #title>Clash path</template>
<template #description>URL prefix for Clash/Mihomo subscription endpoints.</template>
<template #title>Clash {{ t('pages.settings.subPath') }}</template>
<template #description>{{ t('pages.settings.subPathDesc') }}</template>
<template #control>
<a-input
v-model:value="subClashPath"
type="text"
placeholder="/clash/"
@blur="normalizePath('subClashPath')"
/>
<a-input v-model:value="subClashPath" type="text" placeholder="/clash/"
@blur="normalizePath('subClashPath')" />
</template>
</SettingListItem>
<SettingListItem v-if="allSetting.subClashEnable" paddings="small">
<template #title>Clash URI override</template>
<template #description>Full URL returned to Clash-format clients.</template>
<template #title>Clash {{ t('pages.settings.subURI') }}</template>
<template #description>{{ t('pages.settings.subURIDesc') }}</template>
<template #control>
<a-input
v-model:value="allSetting.subClashURI"
type="text"
placeholder="(http|https)://domain[:port]/path/"
/>
<a-input v-model:value="allSetting.subClashURI" type="text"
placeholder="(http|https)://domain[:port]/path/" />
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="2" header="Fragment">
<a-collapse-panel key="2" :header="t('pages.settings.fragment')">
<SettingListItem paddings="small">
<template #title>Enable fragmentation</template>
<template #description>Apply TLS-hello fragmentation to outbound connections.</template>
<template #title>{{ t('pages.settings.fragment') }}</template>
<template #description>{{ t('pages.settings.fragmentDesc') }}</template>
<template #control>
<a-switch v-model:checked="fragment" />
</template>
@ -270,7 +257,7 @@ const directDomains = computed({
<a-list-item v-if="fragment" class="nested-block">
<a-collapse>
<a-collapse-panel header="Fragment settings">
<a-collapse-panel :header="t('pages.settings.fragmentSett')">
<SettingListItem paddings="small">
<template #title>Packets</template>
<template #control>
@ -302,8 +289,8 @@ const directDomains = computed({
<a-collapse-panel key="3" header="Noises">
<SettingListItem paddings="small">
<template #title>Enable noises</template>
<template #description>Inject noise packets to obfuscate traffic patterns.</template>
<template #title>Noises</template>
<template #description>{{ t('pages.settings.noisesDesc') }}</template>
<template #control>
<a-switch v-model:checked="noises" />
</template>
@ -311,19 +298,12 @@ const directDomains = computed({
<a-list-item v-if="noises" class="nested-block">
<a-collapse>
<a-collapse-panel
v-for="(noise, index) in noisesArray"
:key="index"
:header="`Noise №${index + 1}`"
>
<a-collapse-panel v-for="(noise, index) in noisesArray" :key="index" :header="`Noise №${index + 1}`">
<SettingListItem paddings="small">
<template #title>Type</template>
<template #control>
<a-select
:value="noise.type"
:style="{ width: '100%' }"
@change="(v) => updateNoiseField(index, 'type', v)"
>
<a-select :value="noise.type" :style="{ width: '100%' }"
@change="(v) => updateNoiseField(index, 'type', v)">
<a-select-option v-for="p in ['rand', 'base64', 'str', 'hex']" :key="p" :value="p">
{{ p }}
</a-select-option>
@ -333,31 +313,22 @@ const directDomains = computed({
<SettingListItem paddings="small">
<template #title>Packet</template>
<template #control>
<a-input
:value="noise.packet"
placeholder="5-10"
@input="(e) => updateNoiseField(index, 'packet', e.target.value)"
/>
<a-input :value="noise.packet" placeholder="5-10"
@input="(e) => updateNoiseField(index, 'packet', e.target.value)" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Delay (ms)</template>
<template #control>
<a-input
:value="noise.delay"
placeholder="10-20"
@input="(e) => updateNoiseField(index, 'delay', e.target.value)"
/>
<a-input :value="noise.delay" placeholder="10-20"
@input="(e) => updateNoiseField(index, 'delay', e.target.value)" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Apply to</template>
<template #control>
<a-select
:value="noise.applyTo"
:style="{ width: '100%' }"
@change="(v) => updateNoiseField(index, 'applyTo', v)"
>
<a-select :value="noise.applyTo" :style="{ width: '100%' }"
@change="(v) => updateNoiseField(index, 'applyTo', v)">
<a-select-option v-for="p in ['ip', 'ipv4', 'ipv6']" :key="p" :value="p">
{{ p }}
</a-select-option>
@ -367,20 +338,20 @@ const directDomains = computed({
<a-space direction="horizontal" :style="{ padding: '10px 20px' }">
<a-button v-if="noisesArray.length > 1" type="primary" danger @click="removeNoise(index)">
Remove
{{ t('delete') }}
</a-button>
</a-space>
</a-collapse-panel>
</a-collapse>
<a-button type="primary" :style="{ marginTop: '10px' }" @click="addNoise">Add noise</a-button>
<a-button type="primary" :style="{ marginTop: '10px' }" @click="addNoise">+ Noise</a-button>
</a-list-item>
</a-collapse-panel>
<a-collapse-panel key="4" header="Mux">
<a-collapse-panel key="4" :header="t('pages.settings.mux')">
<SettingListItem paddings="small">
<template #title>Enable mux</template>
<template #description>Multiplex multiple streams over a single connection.</template>
<template #title>{{ t('pages.settings.mux') }}</template>
<template #description>{{ t('pages.settings.muxDesc') }}</template>
<template #control>
<a-switch v-model:checked="enableMux" />
</template>
@ -388,27 +359,17 @@ const directDomains = computed({
<a-list-item v-if="enableMux" class="nested-block">
<a-collapse>
<a-collapse-panel header="Mux settings">
<a-collapse-panel :header="t('pages.settings.muxSett')">
<SettingListItem paddings="small">
<template #title>Concurrency</template>
<template #control>
<a-input-number
v-model:value="muxConcurrency"
:min="-1"
:max="1024"
:style="{ width: '100%' }"
/>
<a-input-number v-model:value="muxConcurrency" :min="-1" :max="1024" :style="{ width: '100%' }" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>xudp concurrency</template>
<template #control>
<a-input-number
v-model:value="muxXudpConcurrency"
:min="-1"
:max="1024"
:style="{ width: '100%' }"
/>
<a-input-number v-model:value="muxXudpConcurrency" :min="-1" :max="1024" :style="{ width: '100%' }" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
@ -426,10 +387,10 @@ const directDomains = computed({
</a-list-item>
</a-collapse-panel>
<a-collapse-panel key="5" header="Direct routing">
<a-collapse-panel key="5" :header="t('pages.settings.direct')">
<SettingListItem paddings="small">
<template #title>Enable direct rules</template>
<template #description>Bypass the proxy for matched IPs and domains.</template>
<template #title>{{ t('pages.settings.direct') }}</template>
<template #description>{{ t('pages.settings.directDesc') }}</template>
<template #control>
<a-switch v-model:checked="enableDirect" />
</template>
@ -437,15 +398,11 @@ const directDomains = computed({
<a-list-item v-if="enableDirect" class="nested-block">
<a-collapse>
<a-collapse-panel header="Direct">
<a-collapse-panel :header="t('pages.settings.direct')">
<SettingListItem paddings="small">
<template #title>Direct IPs</template>
<template #title>{{ t('pages.settings.direct') }} IPs</template>
<template #control>
<a-select
v-model:value="directIPs"
mode="tags"
:style="{ width: '100%' }"
>
<a-select v-model:value="directIPs" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in directIPsOptions" :key="p.value" :value="p.value" :label="p.label">
{{ p.label }}
</a-select-option>
@ -453,13 +410,9 @@ const directDomains = computed({
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Direct domains</template>
<template #title>{{ t('pages.settings.direct') }} {{ t('domainName') }}</template>
<template #control>
<a-select
v-model:value="directDomains"
mode="tags"
:style="{ width: '100%' }"
>
<a-select v-model:value="directDomains" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in directDomainsOptions" :key="p.value" :value="p.value" :label="p.label">
{{ p.label }}
</a-select-option>

View file

@ -1,7 +1,10 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import SettingListItem from '@/components/SettingListItem.vue';
const { t } = useI18n();
const props = defineProps({
allSetting: { type: Object, required: true },
});
@ -27,10 +30,10 @@ function normalizeSubPath() {
<template>
<a-collapse default-active-key="1">
<a-collapse-panel key="1" header="General">
<a-collapse-panel key="1" :header="t('pages.settings.panelSettings')">
<SettingListItem paddings="small">
<template #title>Subscription enable</template>
<template #description>Master switch for /sub endpoints.</template>
<template #title>{{ t('pages.settings.subEnable') }}</template>
<template #description>{{ t('pages.settings.subEnableDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.subEnable" />
</template>
@ -38,7 +41,7 @@ function normalizeSubPath() {
<SettingListItem paddings="small">
<template #title>JSON subscription</template>
<template #description>Expose /json subscription endpoints alongside /sub.</template>
<template #description>{{ t('pages.settings.subJsonEnable') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.subJsonEnable" />
</template>
@ -46,168 +49,146 @@ function normalizeSubPath() {
<SettingListItem paddings="small">
<template #title>Clash / Mihomo subscription</template>
<template #description>Enable direct Clash and Mihomo YAML subscriptions.</template>
<template #control>
<a-switch v-model:checked="allSetting.subClashEnable" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Subscription listen IP</template>
<template #description>The IP the subscription server binds to. Leave empty to share the panel listener.</template>
<template #title>{{ t('pages.settings.subListen') }}</template>
<template #description>{{ t('pages.settings.subListenDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.subListen" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Subscription domain</template>
<template #description>Domain returned in subscription URLs.</template>
<template #title>{{ t('pages.settings.subDomain') }}</template>
<template #description>{{ t('pages.settings.subDomainDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.subDomain" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Subscription port</template>
<template #description>Restart required after changing.</template>
<template #title>{{ t('pages.settings.subPort') }}</template>
<template #description>{{ t('pages.settings.subPortDesc') }}</template>
<template #control>
<a-input-number
v-model:value="allSetting.subPort"
:min="1"
:max="65535"
:style="{ width: '100%' }"
/>
<a-input-number v-model:value="allSetting.subPort" :min="1" :max="65535" :style="{ width: '100%' }" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Subscription path</template>
<template #description>URL prefix for subscription endpoints (must start and end with /).</template>
<template #title>{{ t('pages.settings.subPath') }}</template>
<template #description>{{ t('pages.settings.subPathDesc') }}</template>
<template #control>
<a-input
v-model:value="subPath"
type="text"
placeholder="/sub/"
@blur="normalizeSubPath"
/>
<a-input v-model:value="subPath" type="text" placeholder="/sub/" @blur="normalizeSubPath" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Subscription URI override</template>
<template #description>Full URL returned to clients overrides scheme/domain/port/path when set.</template>
<template #title>{{ t('pages.settings.subURI') }}</template>
<template #description>{{ t('pages.settings.subURIDesc') }}</template>
<template #control>
<a-input
v-model:value="allSetting.subURI"
type="text"
placeholder="(http|https)://domain[:port]/path/"
/>
<a-input v-model:value="allSetting.subURI" type="text" placeholder="(http|https)://domain[:port]/path/" />
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="2" header="Information">
<a-collapse-panel key="2" :header="t('pages.settings.information')">
<SettingListItem paddings="small">
<template #title>Encrypt subscription</template>
<template #description>Encrypt subscription content; clients need the matching key.</template>
<template #title>{{ t('pages.settings.subEncrypt') }}</template>
<template #description>{{ t('pages.settings.subEncryptDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.subEncrypt" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Show usage info</template>
<template #description>Include used/total traffic and expiry in the subscription headers.</template>
<template #title>{{ t('pages.settings.subShowInfo') }}</template>
<template #description>{{ t('pages.settings.subShowInfoDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.subShowInfo" />
</template>
</SettingListItem>
<a-divider>Basic template</a-divider>
<a-divider>{{ t('pages.settings.subTitle') }}</a-divider>
<SettingListItem paddings="small">
<template #title>Title</template>
<template #description>Subscription title shown in clients.</template>
<template #title>{{ t('pages.settings.subTitle') }}</template>
<template #description>{{ t('pages.settings.subTitleDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.subTitle" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Support URL</template>
<template #description>Link surfaced to clients for support.</template>
<template #title>{{ t('pages.settings.subSupportUrl') }}</template>
<template #description>{{ t('pages.settings.subSupportUrlDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.subSupportUrl" type="text" placeholder="https://example.com" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Profile URL</template>
<template #description>Profile/announcement URL surfaced to clients.</template>
<template #title>{{ t('pages.settings.subProfileUrl') }}</template>
<template #description>{{ t('pages.settings.subProfileUrlDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.subProfileUrl" type="text" placeholder="https://example.com" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Announce</template>
<template #description>Free-form announcement appended to the subscription header.</template>
<template #title>{{ t('pages.settings.subAnnounce') }}</template>
<template #description>{{ t('pages.settings.subAnnounceDesc') }}</template>
<template #control>
<a-textarea v-model:value="allSetting.subAnnounce" />
</template>
</SettingListItem>
<a-divider>Advanced template (Happ)</a-divider>
<a-divider>Happ</a-divider>
<SettingListItem paddings="small">
<template #title>Enable Happ routing</template>
<template #description>Embed Happ routing rules in the subscription.</template>
<template #title>{{ t('pages.settings.subEnableRouting') }}</template>
<template #description>{{ t('pages.settings.subEnableRoutingDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.subEnableRouting" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Routing rules</template>
<template #description>One happ:// directive per line.</template>
<template #title>{{ t('pages.settings.subRoutingRules') }}</template>
<template #description>{{ t('pages.settings.subRoutingRulesDesc') }}</template>
<template #control>
<a-textarea
v-model:value="allSetting.subRoutingRules"
placeholder="happ://routing/add/..."
/>
<a-textarea v-model:value="allSetting.subRoutingRules" placeholder="happ://routing/add/..." />
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="3" header="Certificates">
<a-collapse-panel key="3" :header="t('pages.settings.certs')">
<SettingListItem paddings="small">
<template #title>Subscription cert path</template>
<template #description>Absolute path to the subscription server's TLS certificate.</template>
<template #title>{{ t('pages.settings.subCertPath') }}</template>
<template #description>{{ t('pages.settings.subCertPathDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.subCertFile" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Subscription key path</template>
<template #description>Absolute path to the subscription server's private key.</template>
<template #title>{{ t('pages.settings.subKeyPath') }}</template>
<template #description>{{ t('pages.settings.subKeyPathDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.subKeyFile" type="text" />
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="4" header="Update interval">
<a-collapse-panel key="4" :header="t('pages.settings.intervals')">
<SettingListItem paddings="small">
<template #title>Update hours</template>
<template #description>Hours clients should wait before re-fetching the subscription.</template>
<template #title>{{ t('pages.settings.subUpdates') }}</template>
<template #description>{{ t('pages.settings.subUpdatesDesc') }}</template>
<template #control>
<a-input-number
v-model:value="allSetting.subUpdates"
:min="1"
:style="{ width: '100%' }"
/>
<a-input-number v-model:value="allSetting.subUpdates" :min="1" :style="{ width: '100%' }" />
</template>
</SettingListItem>
</a-collapse-panel>

View file

@ -1,7 +1,10 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { LanguageManager } from '@/utils';
import SettingListItem from '@/components/SettingListItem.vue';
const { t } = useI18n();
defineProps({
allSetting: { type: Object, required: true },
});
@ -9,41 +12,37 @@ defineProps({
<template>
<a-collapse default-active-key="1">
<a-collapse-panel key="1" header="General">
<a-collapse-panel key="1" :header="t('pages.settings.panelSettings')">
<SettingListItem paddings="small">
<template #title>Enable Telegram bot</template>
<template #description>Toggle the in-bot notification flow.</template>
<template #title>{{ t('pages.settings.telegramBotEnable') }}</template>
<template #description>{{ t('pages.settings.telegramBotEnableDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.tgBotEnable" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Bot token</template>
<template #description>Token issued by @BotFather.</template>
<template #title>{{ t('pages.settings.telegramToken') }}</template>
<template #description>{{ t('pages.settings.telegramTokenDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.tgBotToken" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Chat ID</template>
<template #description>Telegram chat that receives notifications.</template>
<template #title>{{ t('pages.settings.telegramChatId') }}</template>
<template #description>{{ t('pages.settings.telegramChatIdDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.tgBotChatId" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Bot language</template>
<template #title>{{ t('pages.settings.telegramBotLanguage') }}</template>
<template #control>
<a-select v-model:value="allSetting.tgLang" :style="{ width: '100%' }">
<a-select-option
v-for="l in LanguageManager.supportedLanguages"
:key="l.value"
:value="l.value"
:label="l.value"
>
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value" :value="l.value"
:label="l.value">
<span role="img" :aria-label="l.name">{{ l.icon }}</span>
&nbsp;&nbsp;<span>{{ l.name }}</span>
</a-select-option>
@ -52,67 +51,54 @@ defineProps({
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="2" header="Notifications">
<a-collapse-panel key="2" :header="t('pages.settings.notifications')">
<SettingListItem paddings="small">
<template #title>Notification schedule</template>
<template #description>Cron expression e.g. <code>@daily</code> or <code>0 0 * * *</code>.</template>
<template #title>{{ t('pages.settings.telegramNotifyTime') }}</template>
<template #description>{{ t('pages.settings.telegramNotifyTimeDesc') }}</template>
<template #control>
<a-input v-model:value="allSetting.tgRunTime" type="text" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Send database backup</template>
<template #description>Attach a backup of x-ui.db on each scheduled notification.</template>
<template #title>{{ t('pages.settings.tgNotifyBackup') }}</template>
<template #description>{{ t('pages.settings.tgNotifyBackupDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.tgBotBackup" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Notify on login</template>
<template #description>Send a message whenever the panel is logged into.</template>
<template #title>{{ t('pages.settings.tgNotifyLogin') }}</template>
<template #description>{{ t('pages.settings.tgNotifyLoginDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.tgBotLoginNotify" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>CPU notification threshold (%)</template>
<template #description>Notify when CPU usage stays above this for a sustained window. 0 disables.</template>
<template #title>{{ t('pages.settings.tgNotifyCpu') }}</template>
<template #description>{{ t('pages.settings.tgNotifyCpuDesc') }}</template>
<template #control>
<a-input-number
v-model:value="allSetting.tgCpu"
:min="0"
:max="100"
:style="{ width: '100%' }"
/>
<a-input-number v-model:value="allSetting.tgCpu" :min="0" :max="100" :style="{ width: '100%' }" />
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="3" header="Proxy and API server">
<a-collapse-panel key="3" :header="t('pages.settings.proxyAndServer')">
<SettingListItem paddings="small">
<template #title>Bot proxy</template>
<template #description>Outbound proxy used to reach the Telegram API.</template>
<template #title>{{ t('pages.settings.telegramProxy') }}</template>
<template #description>{{ t('pages.settings.telegramProxyDesc') }}</template>
<template #control>
<a-input
v-model:value="allSetting.tgBotProxy"
type="text"
placeholder="socks5://user:pass@host:port"
/>
<a-input v-model:value="allSetting.tgBotProxy" type="text" placeholder="socks5://user:pass@host:port" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Telegram API server</template>
<template #description>Override the default api.telegram.org endpoint.</template>
<template #title>{{ t('pages.settings.telegramAPIServer') }}</template>
<template #description>{{ t('pages.settings.telegramAPIServerDesc') }}</template>
<template #control>
<a-input
v-model:value="allSetting.tgBotAPIServer"
type="text"
placeholder="https://api.example.com"
/>
<a-input v-model:value="allSetting.tgBotAPIServer" type="text" placeholder="https://api.example.com" />
</template>
</SettingListItem>
</a-collapse-panel>

View file

@ -1,11 +1,14 @@
<script setup>
import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue';
import * as OTPAuth from 'otpauth';
import QRious from 'qrious';
import { ClipboardManager } from '@/utils';
const { t } = useI18n();
// Two flavors of this modal:
// type='set' shows a QR code + manual key + a 6-digit verifier
// (used when enabling 2FA the first time);
@ -79,7 +82,7 @@ function onOk() {
if (totp.generate() === enteredCode.value) {
close(true);
} else {
message.error('Invalid code — check your authenticator and try again.');
message.error(t('pages.settings.security.twoFactorModalError'));
}
}
@ -89,21 +92,16 @@ function onCancel() {
async function copyToken() {
const ok = await ClipboardManager.copyText(props.token);
if (ok) message.success('Copied');
if (ok) message.success(t('copied'));
}
</script>
<template>
<a-modal
:open="open"
:title="title"
:closable="true"
@cancel="onCancel"
>
<a-modal :open="open" :title="title" :closable="true" @cancel="onCancel">
<template v-if="type === 'set'">
<p>Scan the QR code with your authenticator app, then enter the 6-digit code it shows.</p>
<p>{{ t('pages.settings.security.twoFactorModalSteps') }}</p>
<a-divider />
<p>Step 1 Scan the QR code (click to copy the secret).</p>
<p>{{ t('pages.settings.security.twoFactorModalFirstStep') }}</p>
<div class="qr-wrap">
<div class="qr-bg">
<canvas ref="qrCanvas" class="qr-cv" @click="copyToken" />
@ -111,7 +109,7 @@ async function copyToken() {
<span class="qr-token">{{ token }}</span>
</div>
<a-divider />
<p>Step 2 Enter the 6-digit code from your authenticator.</p>
<p>{{ t('pages.settings.security.twoFactorModalSecondStep') }}</p>
<a-input v-model:value="enteredCode" :style="{ width: '100%' }" />
</template>
@ -121,8 +119,8 @@ async function copyToken() {
</template>
<template #footer>
<a-button @click="onCancel">Cancel</a-button>
<a-button type="primary" :disabled="enteredCode.length < 6" @click="onOk">Confirm</a-button>
<a-button @click="onCancel">{{ t('cancel') }}</a-button>
<a-button type="primary" :disabled="enteredCode.length < 6" @click="onOk">{{ t('confirm') }}</a-button>
</template>
</a-modal>
</template>
@ -134,6 +132,7 @@ async function copyToken() {
align-items: center;
gap: 12px;
}
.qr-bg {
width: 180px;
height: 180px;
@ -141,11 +140,13 @@ async function copyToken() {
padding: 4px;
border-radius: 6px;
}
.qr-cv {
cursor: pointer;
width: 100% !important;
height: 100% !important;
}
.qr-token {
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;

View file

@ -1,5 +1,8 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
// Balancer add/edit modal mirrors xray_balancer_modal.html.
// Tag must be unique across other balancers; selector is a tag-mode
@ -61,27 +64,21 @@ function onOk() {
emit('confirm', { ...form });
}
const title = computed(() => (isEdit.value ? 'Edit balancer' : 'Add balancer'));
const okText = computed(() => (isEdit.value ? 'Update' : 'Add'));
const title = computed(() =>
isEdit.value
? `${t('edit')} ${t('pages.xray.Balancers')}`
: `+ ${t('pages.xray.Balancers')}`,
);
const okText = computed(() =>
isEdit.value ? t('pages.client.submitEdit') : t('create'),
);
</script>
<template>
<a-modal
:open="open"
:title="title"
:ok-text="okText"
cancel-text="Close"
:ok-button-props="{ disabled: !isValid }"
:mask-closable="false"
@ok="onOk"
@cancel="close"
>
<a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')"
:ok-button-props="{ disabled: !isValid }" :mask-closable="false" @ok="onOk" @cancel="close">
<a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
<a-form-item
label="Tag"
:validate-status="duplicateTag ? 'warning' : 'success'"
has-feedback
>
<a-form-item label="Tag" :validate-status="duplicateTag ? 'warning' : 'success'" has-feedback>
<a-input v-model:value="form.tag" placeholder="unique balancer tag" />
</a-form-item>
@ -91,19 +88,17 @@ const okText = computed(() => (isEdit.value ? 'Update' : 'Add'));
</a-select>
</a-form-item>
<a-form-item
label="Selector"
:validate-status="emptySelector ? 'warning' : 'success'"
has-feedback
>
<a-form-item label="Selector" :validate-status="emptySelector ? 'warning' : 'success'" has-feedback>
<a-select v-model:value="form.selector" mode="tags" :token-separators="[',']">
<a-select-option v-for="t in outboundTags" :key="t" :value="t">{{ t }}</a-select-option>
<a-select-option v-for="tag in outboundTags" :key="tag" :value="tag">{{ tag }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Fallback">
<a-select v-model:value="form.fallbackTag" allow-clear>
<a-select-option v-for="t in ['', ...outboundTags]" :key="t || '__empty'" :value="t">{{ t || '(none)' }}</a-select-option>
<a-select-option v-for="tag in ['', ...outboundTags]" :key="tag || '__empty'" :value="tag">
{{ tag || `(${t('none')})` }}
</a-select-option>
</a-select>
</a-form-item>
</a-form>

View file

@ -1,5 +1,6 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
PlusOutlined,
MoreOutlined,
@ -10,6 +11,8 @@ import { Modal } from 'ant-design-vue';
import BalancerFormModal from './BalancerFormModal.vue';
const { t } = useI18n();
// Balancers tab list + add/edit/delete over
// templateSettings.routing.balancers. The legacy panel kept the wire
// shape's `strategy: { type: 'random' }` nesting only when non-default;
@ -111,46 +114,43 @@ function onConfirm(form) {
function confirmDelete(idx) {
Modal.confirm({
title: `Delete balancer #${idx + 1}?`,
okText: 'Delete',
title: `${t('delete')} ${t('pages.xray.Balancers')} #${idx + 1}?`,
okText: t('delete'),
okType: 'danger',
cancelText: 'Cancel',
cancelText: t('cancel'),
onOk: () => props.templateSettings.routing.balancers.splice(idx, 1),
});
}
const columns = [
const columns = computed(() => [
{ title: '#', key: 'action', align: 'center', width: 80 },
{ title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
{ title: 'Strategy', key: 'strategy', align: 'center', width: 140 },
{ title: 'Selector', key: 'selector', align: 'center' },
{ title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 },
];
]);
</script>
<template>
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
<a-empty v-if="rows.length === 0" description="No balancers yet">
<a-empty v-if="rows.length === 0" :description="t('emptyBalancersDesc')">
<a-button type="primary" @click="openAdd">
<template #icon><PlusOutlined /></template>
Add balancer
<template #icon>
<PlusOutlined />
</template>
{{ t('pages.xray.Balancers') }}
</a-button>
</a-empty>
<template v-else>
<a-button type="primary" @click="openAdd">
<template #icon><PlusOutlined /></template>
Add balancer
<template #icon>
<PlusOutlined />
</template>
{{ t('pages.xray.Balancers') }}
</a-button>
<a-table
:columns="columns"
:data-source="rows"
:row-key="(r) => r.key"
:pagination="false"
size="small"
bordered
>
<a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false" size="small" bordered>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'action'">
<span class="row-index">{{ index + 1 }}</span>
@ -161,10 +161,10 @@ const columns = [
<template #overlay>
<a-menu>
<a-menu-item @click="openEdit(index)">
<EditOutlined /> Edit
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)">
<DeleteOutlined /> Delete
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
@ -184,13 +184,8 @@ const columns = [
</a-table>
</template>
<BalancerFormModal
v-model:open="modalOpen"
:balancer="editingBalancer"
:outbound-tags="outboundTags"
:other-tags="otherTags"
@confirm="onConfirm"
/>
<BalancerFormModal v-model:open="modalOpen" :balancer="editingBalancer" :outbound-tags="outboundTags"
:other-tags="otherTags" @confirm="onConfirm" />
</a-space>
</template>
@ -200,6 +195,12 @@ const columns = [
opacity: 0.7;
margin-right: 6px;
}
.action-btn { vertical-align: middle; }
.danger { color: #ff4d4f; }
.action-btn {
vertical-align: middle;
}
.danger {
color: #ff4d4f;
}
</style>

View file

@ -1,10 +1,13 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { ExclamationCircleFilled, CloudOutlined, ApiOutlined } from '@ant-design/icons-vue';
import { OutboundDomainStrategies } from '@/models/outbound.js';
import SettingListItem from '@/components/SettingListItem.vue';
const { t } = useI18n();
// Phase 6-ii: structured editor for the most-touched fields of the
// xray template outbound strategy, routing strategy, log levels,
// stat counters, and the "basic routing" lists (block IPs/domains/
@ -223,18 +226,16 @@ const localOutboundTestUrl = computed({
<template>
<a-collapse default-active-key="1">
<a-collapse-panel key="1" header="General">
<a-alert
type="warning"
class="mb-12 hint-alert"
message="Editing this template manually is for advanced users only."
>
<template #icon><ExclamationCircleFilled style="color: #FFA031;" /></template>
<a-collapse-panel key="1" :header="t('pages.xray.generalConfigs')">
<a-alert type="warning" class="mb-12 hint-alert" :message="t('pages.xray.generalConfigsDesc')">
<template #icon>
<ExclamationCircleFilled style="color: #FFA031;" />
</template>
</a-alert>
<SettingListItem paddings="small">
<template #title>Freedom outbound strategy</template>
<template #description>How the direct outbound resolves destinations.</template>
<template #title>{{ t('pages.xray.FreedomStrategy') }}</template>
<template #description>{{ t('pages.xray.FreedomStrategyDesc') }}</template>
<template #control>
<a-select v-model:value="freedomStrategy" :style="{ width: '100%' }">
<a-select-option v-for="s in OutboundDomainStrategies" :key="s" :value="s">{{ s }}</a-select-option>
@ -243,8 +244,8 @@ const localOutboundTestUrl = computed({
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Routing strategy</template>
<template #description>Domain strategy applied at the routing level.</template>
<template #title>{{ t('pages.xray.RoutingStrategy') }}</template>
<template #description>{{ t('pages.xray.RoutingStrategyDesc') }}</template>
<template #control>
<a-select v-model:value="routingStrategy" :style="{ width: '100%' }">
<a-select-option v-for="s in ROUTING_DOMAIN_STRATEGIES" :key="s" :value="s">{{ s }}</a-select-option>
@ -253,25 +254,25 @@ const localOutboundTestUrl = computed({
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Outbound test URL</template>
<template #description>HTTP endpoint used for outbound latency tests.</template>
<template #title>{{ t('pages.xray.outboundTestUrl') }}</template>
<template #description>{{ t('pages.xray.outboundTestUrlDesc') }}</template>
<template #control>
<a-input v-model:value="localOutboundTestUrl" placeholder="https://www.google.com/generate_204" />
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="2" header="Statistics">
<a-collapse-panel key="2" :header="t('pages.xray.statistics')">
<SettingListItem paddings="small">
<template #title>Inbound uplink stats</template>
<template #title>{{ t('pages.xray.statsInboundUplink') }}</template>
<template #control><a-switch v-model:checked="statsInboundUplink" /></template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Inbound downlink stats</template>
<template #title>{{ t('pages.xray.statsInboundDownlink') }}</template>
<template #control><a-switch v-model:checked="statsInboundDownlink" /></template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Outbound uplink stats</template>
<template #title>{{ t('pages.xray.statsOutboundUplink') }}</template>
<template #control><a-switch v-model:checked="statsOutboundUplink" /></template>
</SettingListItem>
<SettingListItem paddings="small">
@ -280,17 +281,16 @@ const localOutboundTestUrl = computed({
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="3" header="Logs">
<a-alert
type="warning"
class="mb-12 hint-alert"
message="Empty access/error log fields disable that log; ./access.log writes to disk."
>
<template #icon><ExclamationCircleFilled style="color: #FFA031;" /></template>
<a-collapse-panel key="3" :header="t('pages.xray.logConfigs')">
<a-alert type="warning" class="mb-12 hint-alert" :message="t('pages.xray.logConfigsDesc')">
<template #icon>
<ExclamationCircleFilled style="color: #FFA031;" />
</template>
</a-alert>
<SettingListItem paddings="small">
<template #title>Log level</template>
<template #title>{{ t('pages.xray.logLevel') }}</template>
<template #description>{{ t('pages.xray.logLevelDesc') }}</template>
<template #control>
<a-select v-model:value="logLevel" :style="{ width: '100%' }">
<a-select-option v-for="s in LOG_LEVELS" :key="s" :value="s">{{ s }}</a-select-option>
@ -299,165 +299,144 @@ const localOutboundTestUrl = computed({
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Access log</template>
<template #title>{{ t('pages.xray.accessLog') }}</template>
<template #description>{{ t('pages.xray.accessLogDesc') }}</template>
<template #control>
<a-select v-model:value="accessLog" :style="{ width: '100%' }">
<a-select-option value="">Empty</a-select-option>
<a-select-option value="">{{ t('none') }}</a-select-option>
<a-select-option v-for="s in ACCESS_LOG" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Error log</template>
<template #title>{{ t('pages.xray.errorLog') }}</template>
<template #description>{{ t('pages.xray.errorLogDesc') }}</template>
<template #control>
<a-select v-model:value="errorLog" :style="{ width: '100%' }">
<a-select-option value="">Empty</a-select-option>
<a-select-option value="">{{ t('none') }}</a-select-option>
<a-select-option v-for="s in ERROR_LOG" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Mask address</template>
<template #description>Truncate IPs in logs.</template>
<template #title>{{ t('pages.xray.maskAddress') }}</template>
<template #description>{{ t('pages.xray.maskAddressDesc') }}</template>
<template #control>
<a-select v-model:value="maskAddressLog" :style="{ width: '100%' }">
<a-select-option value="">Empty</a-select-option>
<a-select-option value="">{{ t('none') }}</a-select-option>
<a-select-option v-for="s in MASK_ADDRESS" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>DNS log</template>
<template #title>{{ t('pages.xray.dnsLog') }}</template>
<template #description>{{ t('pages.xray.dnsLogDesc') }}</template>
<template #control><a-switch v-model:checked="dnslog" /></template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="4" header="Basic routing">
<a-alert
type="warning"
class="mb-12 hint-alert"
message="These shortcuts edit the underlying routing rules. Use the Routing tab for full control."
>
<template #icon><ExclamationCircleFilled style="color: #FFA031;" /></template>
<a-collapse-panel key="4" :header="t('pages.xray.basicRouting')">
<a-alert type="warning" class="mb-12 hint-alert" :message="t('pages.xray.blockConnectionsConfigsDesc')">
<template #icon>
<ExclamationCircleFilled style="color: #FFA031;" />
</template>
</a-alert>
<SettingListItem paddings="small">
<template #title>Block torrent</template>
<template #title>{{ t('pages.xray.Torrent') }}</template>
<template #control><a-switch v-model:checked="torrentSettings" /></template>
</SettingListItem>
<a-alert
type="warning"
class="mb-12 hint-alert"
message="Block lists drop traffic matching these IPs / domains."
>
<template #icon><ExclamationCircleFilled style="color: #FFA031;" /></template>
</a-alert>
<SettingListItem paddings="small">
<template #title>Block IPs</template>
<template #title>{{ t('pages.xray.blockips') }}</template>
<template #control>
<a-select v-model:value="blockedIPs" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in IPS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label }}</a-select-option>
<a-select-option v-for="p in IPS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
}}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Block domains</template>
<template #title>{{ t('pages.xray.blockdomains') }}</template>
<template #control>
<a-select v-model:value="blockedDomains" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in BLOCK_DOMAINS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label }}</a-select-option>
<a-select-option v-for="p in BLOCK_DOMAINS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{
p.label }}</a-select-option>
</a-select>
</template>
</SettingListItem>
<a-alert
type="warning"
class="mb-12 hint-alert"
message="Direct lists bypass the proxy for matched IPs / domains."
>
<template #icon><ExclamationCircleFilled style="color: #FFA031;" /></template>
<a-alert type="warning" class="mb-12 hint-alert" :message="t('pages.xray.directConnectionsConfigsDesc')">
<template #icon>
<ExclamationCircleFilled style="color: #FFA031;" />
</template>
</a-alert>
<SettingListItem paddings="small">
<template #title>Direct IPs</template>
<template #title>{{ t('pages.xray.directips') }}</template>
<template #control>
<a-select v-model:value="directIPs" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in IPS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label }}</a-select-option>
<a-select-option v-for="p in IPS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
}}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Direct domains</template>
<template #title>{{ t('pages.xray.directdomains') }}</template>
<template #control>
<a-select v-model:value="directDomains" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in DOMAINS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label }}</a-select-option>
<a-select-option v-for="p in DOMAINS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
}}</a-select-option>
</a-select>
</template>
</SettingListItem>
<a-alert
type="warning"
class="mb-12 hint-alert"
message="IPv4 routing forces the listed services through an IPv4-only outbound."
>
<template #icon><ExclamationCircleFilled style="color: #FFA031;" /></template>
</a-alert>
<SettingListItem paddings="small">
<template #title>IPv4 forced</template>
<template #title>{{ t('pages.xray.ipv4Routing') }}</template>
<template #description>{{ t('pages.xray.ipv4RoutingDesc') }}</template>
<template #control>
<a-select v-model:value="ipv4Domains" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label }}</a-select-option>
<a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
}}</a-select-option>
</a-select>
</template>
</SettingListItem>
<a-alert
type="warning"
class="mb-12 hint-alert"
message="WARP / NordVPN routing requires the matching outbound to exist — use the buttons to provision it."
>
<template #icon><ExclamationCircleFilled style="color: #FFA031;" /></template>
</a-alert>
<SettingListItem paddings="small">
<template #title>WARP routing</template>
<template #title>{{ t('pages.xray.warpRouting') }}</template>
<template #description>{{ t('pages.xray.warpRoutingDesc') }}</template>
<template #control>
<a-select
v-if="warpExist"
v-model:value="warpDomains"
mode="tags"
:style="{ width: '100%' }"
>
<a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label }}</a-select-option>
<a-select v-if="warpExist" v-model:value="warpDomains" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
}}</a-select-option>
</a-select>
<a-button v-else type="primary" @click="emit('show-warp')">
<template #icon><CloudOutlined /></template>
Configure WARP
<template #icon>
<CloudOutlined />
</template>
WARP
</a-button>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>NordVPN routing</template>
<template #title>{{ t('pages.xray.nordRouting') }}</template>
<template #description>{{ t('pages.xray.nordRoutingDesc') }}</template>
<template #control>
<a-select
v-if="nordExist"
v-model:value="nordDomains"
mode="tags"
:style="{ width: '100%' }"
>
<a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label }}</a-select-option>
<a-select v-if="nordExist" v-model:value="nordDomains" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
}}</a-select-option>
</a-select>
<a-button v-else type="primary" @click="emit('show-nord')">
<template #icon><ApiOutlined /></template>
Configure NordVPN
<template #icon>
<ApiOutlined />
</template>
NordVPN
</a-button>
</template>
</SettingListItem>
@ -466,6 +445,11 @@ const localOutboundTestUrl = computed({
</template>
<style scoped>
.mb-12 { margin-bottom: 12px; }
.hint-alert { text-align: center; }
.mb-12 {
margin-bottom: 12px;
}
.hint-alert {
text-align: center;
}
</style>

View file

@ -225,44 +225,35 @@ function close() { emit('update:open', false); }
</script>
<template>
<a-modal
:open="open"
title="NordVPN NordLynx"
:footer="null"
:closable="true"
:mask-closable="true"
@cancel="close"
>
<a-modal :open="open" title="NordVPN NordLynx" :footer="null" :closable="true" :mask-closable="true" @cancel="close">
<!-- WARP / NordVPN provisioning forms keep technical wire labels in
English on purpose: they map directly to API field names users
look up in vendor docs. Only the primary action buttons +
dialog headers translate. -->
<!-- Not authenticated tabbed login (token or manual key) -->
<template v-if="nordData == null">
<a-tabs default-active-key="token">
<a-tab-pane key="token" tab="Access token">
<a-form
:colon="false"
:label-col="{ md: { span: 6 } }"
:wrapper-col="{ md: { span: 18 } }"
class="mt-20"
>
<a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 18 } }" class="mt-20">
<a-form-item label="Access token">
<a-input v-model:value="token" placeholder="Access token" />
<a-button type="primary" class="mt-10" :loading="loading" @click="login">
<template #icon><LoginOutlined /></template>
<template #icon>
<LoginOutlined />
</template>
Login
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="key" tab="Private key">
<a-form
:colon="false"
:label-col="{ md: { span: 6 } }"
:wrapper-col="{ md: { span: 18 } }"
class="mt-20"
>
<a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 18 } }" class="mt-20">
<a-form-item label="Private key">
<a-input v-model:value="manualKey" placeholder="Private key" />
<a-button type="primary" class="mt-10" :loading="loading" @click="saveKey">
<template #icon><SaveOutlined /></template>
<template #icon>
<SaveOutlined />
</template>
Save
</a-button>
</a-form-item>
@ -290,43 +281,20 @@ function close() { emit('update:open', false); }
<a-divider class="zero-margin">Settings</a-divider>
<a-form
:colon="false"
:label-col="{ md: { span: 6 } }"
:wrapper-col="{ md: { span: 18 } }"
class="mt-10"
>
<a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 18 } }" class="mt-10">
<a-form-item label="Country">
<a-select
v-model:value="countryId"
show-search
option-filter-prop="label"
@change="fetchServers"
>
<a-select-option
v-for="c in countries"
:key="c.id"
:value="c.id"
:label="c.name"
>
<a-select v-model:value="countryId" show-search option-filter-prop="label" @change="fetchServers">
<a-select-option v-for="c in countries" :key="c.id" :value="c.id" :label="c.name">
{{ c.name }} ({{ c.code }})
</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="cities.length > 0" label="City">
<a-select
v-model:value="cityId"
show-search
option-filter-prop="label"
>
<a-select v-model:value="cityId" show-search option-filter-prop="label">
<a-select-option :value="null" label="All cities">All cities</a-select-option>
<a-select-option
v-for="c in cities"
:key="c.id"
:value="c.id"
:label="c.name"
>{{ c.name }}</a-select-option>
<a-select-option v-for="c in cities" :key="c.id" :value="c.id" :label="c.name">{{ c.name
}}</a-select-option>
</a-select>
</a-form-item>
@ -349,13 +317,8 @@ function close() { emit('update:open', false); }
</template>
<template v-else>
<a-tag color="orange">Disabled</a-tag>
<a-button
type="primary"
class="ml-8"
:disabled="!serverId"
:loading="loading"
@click="addOutbound"
>Add outbound</a-button>
<a-button type="primary" class="ml-8" :disabled="!serverId" :loading="loading" @click="addOutbound">Add
outbound</a-button>
</template>
</template>
</a-modal>
@ -367,29 +330,50 @@ function close() { emit('update:open', false); }
width: 100%;
border-collapse: collapse;
}
.nord-data-table td {
padding: 4px 8px;
word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.nord-data-table td:first-child {
font-family: inherit;
font-weight: 500;
white-space: nowrap;
width: 130px;
}
.row-odd {
background: rgba(0, 0, 0, 0.03);
}
:global(body.dark) .row-odd {
background: rgba(255, 255, 255, 0.04);
}
.zero-margin { margin: 0; }
.mt-8 { margin-top: 8px; }
.mt-10 { margin-top: 10px; }
.mt-20 { margin-top: 20px; }
.my-10 { margin: 10px 0; }
.ml-8 { margin-left: 8px; }
.zero-margin {
margin: 0;
}
.mt-8 {
margin-top: 8px;
}
.mt-10 {
margin-top: 10px;
}
.mt-20 {
margin-top: 20px;
}
.my-10 {
margin: 10px 0;
}
.ml-8 {
margin-left: 8px;
}
</style>

View file

@ -1,5 +1,6 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
PlusOutlined,
CloudOutlined,
@ -23,6 +24,8 @@ import { SizeFormatter } from '@/utils';
import { Protocols } from '@/models/outbound.js';
import OutboundFormModal from './OutboundFormModal.vue';
const { t } = useI18n();
// Outbounds tab list + actions over templateSettings.outbounds.
// Mirrors the legacy outbound table layout (identity / address /
// traffic / test result / test button) plus the row action menu
@ -72,10 +75,10 @@ function onConfirm(outbound) {
function confirmDelete(idx) {
Modal.confirm({
title: `Delete outbound #${idx + 1}?`,
okText: 'Delete',
title: `${t('delete')} ${t('pages.xray.Outbounds')} #${idx + 1}?`,
okText: t('delete'),
okType: 'danger',
cancelText: 'Cancel',
cancelText: t('cancel'),
onOk: () => props.templateSettings.outbounds.splice(idx, 1),
});
}
@ -148,14 +151,15 @@ function showSecurity(security) {
}
// === Columns ========================================================
const columns = [
// Computed so titles re-render after a locale swap.
const columns = computed(() => [
{ title: '#', key: 'action', align: 'center', width: 70 },
{ title: 'Tag', key: 'identity', align: 'left', width: 220 },
{ title: 'Address', key: 'address', align: 'left', width: 230 },
{ title: 'Traffic', key: 'traffic', align: 'left', width: 200 },
{ title: 'Test result', key: 'testResult', align: 'left', width: 140 },
{ title: 'Test', key: 'test', align: 'center', width: 80 },
];
{ title: t('pages.inbounds.address'), key: 'address', align: 'left', width: 230 },
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200 },
{ title: t('check'), key: 'testResult', align: 'left', width: 140 },
{ title: t('check'), key: 'test', align: 'center', width: 80 },
]);
const rows = computed(() => {
if (!props.templateSettings?.outbounds) return [];
@ -171,7 +175,7 @@ const rows = computed(() => {
<a-space size="small">
<a-button type="primary" @click="openAdd">
<template #icon><PlusOutlined /></template>
<span v-if="!isMobile">Add outbound</span>
<span v-if="!isMobile">{{ t('pages.xray.Outbounds') }}</span>
</a-button>
<a-button type="primary" @click="emit('show-warp')">
<template #icon><CloudOutlined /></template>
@ -190,9 +194,9 @@ const rows = computed(() => {
</a-button>
<a-popconfirm
placement="topRight"
ok-text="Reset"
cancel-text="Cancel"
title="Reset traffic on every outbound?"
:ok-text="t('reset')"
:cancel-text="t('cancel')"
:title="t('pages.inbounds.resetAllTrafficContent')"
@confirm="emit('reset-traffic', '-alltags-')"
>
<a-button>
@ -230,16 +234,16 @@ const rows = computed(() => {
<template #overlay>
<a-menu>
<a-menu-item v-if="index > 0" @click="setFirst(index)">
<VerticalAlignTopOutlined /> Move to top
<VerticalAlignTopOutlined />
</a-menu-item>
<a-menu-item @click="openEdit(index)">
<EditOutlined /> Edit
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item @click="emit('reset-traffic', record.tag || '')">
<RetweetOutlined /> Reset traffic
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
</a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)">
<DeleteOutlined /> Delete
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
@ -303,10 +307,10 @@ const rows = computed(() => {
<EditOutlined /> Edit
</a-menu-item>
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
<ArrowUpOutlined /> Move up
<ArrowUpOutlined />
</a-menu-item>
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
<ArrowDownOutlined /> Move down
<ArrowDownOutlined />
</a-menu-item>
<a-menu-item @click="emit('reset-traffic', record.tag || '')">
<RetweetOutlined /> Reset traffic
@ -368,7 +372,7 @@ const rows = computed(() => {
</template>
<template v-else-if="column.key === 'test'">
<a-tooltip title="Run a latency test through this outbound">
<a-tooltip :title="t('check')">
<a-button
type="primary"
shape="circle"

View file

@ -1,5 +1,6 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
PlusOutlined,
MoreOutlined,
@ -14,6 +15,8 @@ import { Modal } from 'ant-design-vue';
import RuleFormModal from './RuleFormModal.vue';
const { t } = useI18n();
// Routing tab table over templateSettings.routing.rules with the
// modernised legacy column layout. Each row is rendered as a single
// "lead value + N more" pill per criterion (matches the legacy pill
@ -122,10 +125,10 @@ function onRuleConfirm(rule) {
function confirmDelete(idx) {
Modal.confirm({
title: `Delete rule #${idx + 1}?`,
okText: 'Delete',
title: `${t('delete')} ${t('pages.xray.Routings')} #${idx + 1}?`,
okText: t('delete'),
okType: 'danger',
cancelText: 'Cancel',
cancelText: t('cancel'),
onOk: () => props.templateSettings.routing.rules.splice(idx, 1),
});
}
@ -142,27 +145,28 @@ function moveDown(idx) {
}
// === Columns =========================================================
const desktopColumns = [
// Computed so titles re-render after a locale swap.
const desktopColumns = computed(() => [
{ title: '#', align: 'center', width: 70, key: 'action' },
{ title: 'Source', align: 'left', width: 180, key: 'source' },
{ title: 'Network', align: 'left', width: 180, key: 'network' },
{ title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' },
{ title: 'Destination', align: 'left', key: 'destination' },
{ title: 'Inbound', align: 'left', width: 180, key: 'inbound' },
{ title: 'Outbound', align: 'left', width: 170, key: 'target' },
];
const mobileColumns = [
{ title: t('pages.xray.Inbounds'), align: 'left', width: 180, key: 'inbound' },
{ title: t('pages.xray.Outbounds'), align: 'left', width: 170, key: 'target' },
]);
const mobileColumns = computed(() => [
{ title: '#', align: 'center', width: 70, key: 'action' },
{ title: 'Inbound', align: 'left', key: 'inbound' },
{ title: 'Outbound', align: 'left', width: 140, key: 'target' },
];
const columns = computed(() => (props.isMobile ? mobileColumns : desktopColumns));
{ title: t('pages.xray.Inbounds'), align: 'left', key: 'inbound' },
{ title: t('pages.xray.Outbounds'), align: 'left', width: 140, key: 'target' },
]);
const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value));
</script>
<template>
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
<a-button type="primary" @click="openAdd">
<template #icon><PlusOutlined /></template>
Add rule
{{ t('pages.xray.Routings') }}
</a-button>
<a-table
@ -186,16 +190,16 @@ const columns = computed(() => (props.isMobile ? mobileColumns : desktopColumns)
<template #overlay>
<a-menu>
<a-menu-item @click="openEdit(index)">
<EditOutlined /> Edit
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
<ArrowUpOutlined /> Move up
<ArrowUpOutlined />
</a-menu-item>
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
<ArrowDownOutlined /> Move down
<ArrowDownOutlined />
</a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)">
<DeleteOutlined /> Delete
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>

View file

@ -120,8 +120,17 @@ function onOk() {
emit('confirm', buildResult());
}
const title = computed(() => (isEdit.value ? 'Edit rule' : 'Add rule'));
const okText = computed(() => (isEdit.value ? 'Update' : 'Add rule'));
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const title = computed(() =>
isEdit.value
? `${t('edit')} ${t('pages.xray.Routings')}`
: `+ ${t('pages.xray.Routings')}`,
);
const okText = computed(() =>
isEdit.value ? t('pages.client.submitEdit') : t('create'),
);
const NETWORKS = ['', 'TCP', 'UDP', 'TCP,UDP'];
const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
@ -132,7 +141,7 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
:open="open"
:title="title"
:ok-text="okText"
cancel-text="Close"
:cancel-text="t('close')"
:mask-closable="false"
width="640px"
@ok="onOk"

View file

@ -190,6 +190,10 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
:mask-closable="true"
@cancel="close"
>
<!-- WARP / NordVPN provisioning forms keep technical wire labels in
English on purpose: they map directly to API field names users
look up in vendor docs. Only the primary action buttons +
dialog headers translate. -->
<!-- Not registered yet single Create CTA -->
<template v-if="!hasWarp">
<a-button type="primary" :loading="loading" @click="register">

View file

@ -42,6 +42,7 @@ const {
fetched,
spinning,
saveDisabled,
fetchError,
xraySetting,
templateSettings,
outboundTestUrl,
@ -50,6 +51,7 @@ const {
restartResult,
outboundsTraffic,
outboundTestStates,
fetchAll,
fetchOutboundsTraffic,
resetOutboundsTraffic,
testOutbound,
@ -131,6 +133,11 @@ const { isMobile } = useMediaQuery();
const basePath = window.__X_UI_BASE_PATH__ || '';
const requestUri = window.location.pathname;
// See SettingsPage scrollTarget wrap so `document` is in scope.
function scrollTarget() {
return document.getElementById('content-layout');
}
function confirmRestart() {
Modal.confirm({
title: 'Restart xray?',
@ -155,6 +162,17 @@ function confirmRestart() {
<a-spin :spinning="spinning || !fetched" :delay="200" tip="Loading…" size="large">
<div v-if="!fetched" class="loading-spacer" />
<a-result
v-else-if="fetchError"
status="error"
:title="t('somethingWentWrong')"
:sub-title="fetchError"
>
<template #extra>
<a-button type="primary" @click="fetchAll">{{ t('check') }}</a-button>
</template>
</a-result>
<template v-else>
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
<!-- Save / Restart bar -->
@ -179,7 +197,7 @@ function confirmRestart() {
</a-space>
</a-col>
<a-col :xs="24" :sm="10" class="header-info">
<a-back-top :target="() => document.getElementById('content-layout')" :visibility-height="200" />
<a-back-top :target="scrollTarget" :visibility-height="200" />
<a-alert
type="warning"
show-icon