mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 09:36:05 +00:00
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:
parent
cb37dd55ca
commit
4322a18ee3
36 changed files with 755 additions and 877 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "نسخة احتياطية لقاعدة البيانات",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "پشتیبانگیری از دیتابیس",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "データベースバックアップ",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Резервное копирование базы данных",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Резервне копіювання бази даних",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "数据库备份",
|
||||
|
|
|
|||
|
|
@ -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": "資料庫備份",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue