浏览代码

Add node renew information

- Simplify few JavaScript usage;
- Fix DDNS enable's node clone may cause bug;
- Fix System config page, multiple selectors will trigger update function multiple times.

1
BrettonYe 1 年之前
父节点
当前提交
1603c92d04
共有 27 个文件被更改,包括 846 次插入627 次删除
  1. 0 57
      app/Console/Commands/DailyNodeReport.php
  2. 107 0
      app/Console/Commands/NodeDailyMaintenance.php
  3. 19 18
      app/Console/Commands/NodeStatusDetection.php
  4. 2 2
      app/Console/Kernel.php
  5. 20 4
      app/Http/Controllers/Admin/NodeController.php
  6. 91 76
      app/Http/Controllers/Admin/SystemController.php
  7. 3 0
      app/Http/Requests/Admin/NodeRequest.php
  8. 1 1
      app/Models/Node.php
  9. 85 0
      app/Notifications/NodeRenewal.php
  10. 1 0
      app/Providers/SettingServiceProvider.php
  11. 35 0
      database/migrations/2024_08_03_225932_node_details.php
  12. 1 0
      database/seeders/ConfigSeeder.php
  13. 15 0
      public/assets/global/vendor/lodash/lodash.min.js
  14. 二进制
      public/assets/images/notification/custom.png
  15. 二进制
      public/assets/images/notification/offline.png
  16. 二进制
      public/assets/images/notification/renewal.png
  17. 二进制
      public/assets/images/notification/ticket.png
  18. 120 85
      resources/views/admin/config/system.blade.php
  19. 9 17
      resources/views/admin/coupon/create.blade.php
  20. 260 270
      resources/views/admin/node/info.blade.php
  21. 53 82
      resources/views/admin/shop/info.blade.php
  22. 1 1
      resources/views/components/system/input-test.blade.php
  23. 4 3
      resources/views/components/system/select.blade.php
  24. 1 1
      resources/views/mail/simpleMarkdown.blade.php
  25. 1 1
      resources/views/user/components/notifications/accountExpire.blade.php
  26. 13 0
      resources/views/user/components/notifications/nodeRenewal.blade.php
  27. 4 9
      resources/views/user/services.blade.php

+ 0 - 57
app/Console/Commands/DailyNodeReport.php

@@ -1,57 +0,0 @@
-<?php
-
-namespace App\Console\Commands;
-
-use App\Models\NodeDailyDataFlow;
-use App\Models\User;
-use App\Notifications\NodeDailyReport;
-use Illuminate\Console\Command;
-use Log;
-use Notification;
-
-class DailyNodeReport extends Command
-{
-    protected $signature = 'dailyNodeReport';
-
-    protected $description = '自动报告节点昨日使用情况';
-
-    public function handle(): void
-    {
-        $jobTime = microtime(true);
-
-        if (sysConfig('node_daily_notification')) {
-            $nodeDailyLogs = NodeDailyDataFlow::with('node:id,name')->has('node')->whereDate('created_at', date('Y-m-d', strtotime('yesterday')))->orderBy('node_id')->get();
-
-            $data = [];
-            $sum_u = 0;
-            $sum_d = 0;
-            foreach ($nodeDailyLogs as $log) {
-                $data[] = [
-                    'name' => $log->node->name,
-                    'upload' => formatBytes($log->u),
-                    'download' => formatBytes($log->d),
-                    'total' => formatBytes($log->u + $log->d),
-                ];
-                $sum_u += $log->u;
-                $sum_d += $log->d;
-            }
-
-            if ($data) {
-                $data[] = [
-                    'name' => trans('notification.node.total'),
-                    'upload' => formatBytes($sum_u),
-                    'download' => formatBytes($sum_d),
-                    'total' => formatBytes($sum_u + $sum_d),
-                ];
-
-                $superAdmins = User::role('Super Admin')->get();
-                if ($superAdmins->isNotEmpty()) {
-                    Notification::send($superAdmins, new NodeDailyReport($data));
-                }
-            }
-        }
-
-        $jobTime = round(microtime(true) - $jobTime, 4);
-        Log::info(__('----「:job」Completed, Used :time seconds ----', ['job' => $this->description, 'time' => $jobTime]));
-    }
-}

+ 107 - 0
app/Console/Commands/NodeDailyMaintenance.php

@@ -0,0 +1,107 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\Node;
+use App\Models\NodeDailyDataFlow;
+use App\Models\User;
+use App\Notifications\NodeDailyReport;
+use App\Notifications\NodeRenewal;
+use Carbon\Carbon;
+use Illuminate\Console\Command;
+use Illuminate\Database\Eloquent\Builder;
+use Log;
+use Notification;
+
+class NodeDailyMaintenance extends Command
+{
+    protected $signature = 'node:maintenance';
+
+    protected $description = '执行节点的日常维护,包括发送每日使用报告和检查续约提醒';
+
+    public function handle(): void
+    {
+        $jobTime = microtime(true);
+
+        if (sysConfig('node_daily_notification')) {
+            $this->nodedailyReport();
+        }
+
+        if (sysConfig('node_renewal_notification')) {// 通知节点急需续约
+            $this->checkNodeRenewDays();
+        }
+
+        $this->updateNodeRenewal();
+
+        $jobTime = round(microtime(true) - $jobTime, 4);
+        Log::info(__('----「:job」Completed, Used :time seconds ----', ['job' => $this->description, 'time' => $jobTime]));
+    }
+
+    private function nodedailyReport(): void
+    {
+        $nodeDailyLogs = NodeDailyDataFlow::with('node:id,name')->has('node')->whereDate('created_at', date('Y-m-d', strtotime('yesterday')))->orderBy('node_id')->get();
+
+        $data = [];
+        $sum_u = 0;
+        $sum_d = 0;
+        foreach ($nodeDailyLogs as $log) {
+            $data[] = [
+                'name' => $log->node->name,
+                'upload' => formatBytes($log->u),
+                'download' => formatBytes($log->d),
+                'total' => formatBytes($log->u + $log->d),
+            ];
+            $sum_u += $log->u;
+            $sum_d += $log->d;
+        }
+
+        if ($data) {
+            $data[] = [
+                'name' => trans('notification.node.total'),
+                'upload' => formatBytes($sum_u),
+                'download' => formatBytes($sum_d),
+                'total' => formatBytes($sum_u + $sum_d),
+            ];
+
+            $superAdmins = User::role('Super Admin')->get();
+            if ($superAdmins->isNotEmpty()) {
+                Notification::send($superAdmins, new NodeDailyReport($data));
+            }
+        }
+    }
+
+    private function checkNodeRenewDays(): void
+    {
+        $now = Carbon::now();
+
+        $notificationDates = [ // 通知日期 分别是 前1天,3天和7天
+            $now->addDays(1)->format('Y-m-d'),
+            $now->addDays(2)->format('Y-m-d'),
+            $now->addDays(4)->format('Y-m-d'),
+        ];
+
+        $nodes = Node::whereNotNull('details')->whereIn('details->next_renewal_date', $notificationDates)->pluck('name', 'id')->toArray();
+
+        Notification::send(User::find(1), new NodeRenewal($nodes));
+    }
+
+    private function updateNodeRenewal(): void
+    {
+        // 获取符合条件的节点
+        $nodes = Node::whereNotNull('details')
+            ->where(function (Builder $query) {
+                $query->where('details->subscription_term', '<>', null)
+                    ->where('details->next_renewal_date', '<=', Carbon::now()->format('Y-m-d'));
+            })
+            ->get();
+
+        // 更新每个节点的 next_renewal_date
+        foreach ($nodes as $node) {
+            $details = $node->details;
+            $details['next_renewal_date'] = Carbon::createFromFormat('Y-m-d', $details['next_renewal_date'])->add($details['subscription_term'])->format('Y-m-d');
+            $node->details = $details;
+
+            $node->save();
+        }
+    }
+}

+ 19 - 18
app/Console/Commands/NodeStatusDetection.php

@@ -15,19 +15,19 @@ use Notification;
 
 class NodeStatusDetection extends Command
 {
-    protected $signature = 'nodeStatusDetection';
+    protected $signature = 'node:detection';
 
-    protected $description = '节点状态检测';
+    protected $description = '检测节点状态,包括节点心跳和网络状态';
 
     public function handle(): void
     {
         $jobTime = microtime(true);
 
-        if (sysConfig('node_offline_notification')) {// 检测节点心跳是否异常
+        if (sysConfig('node_offline_notification')) {// 通知节点心跳异常
             $this->checkNodeStatus();
         }
 
-        if (sysConfig('node_blocked_notification')) {// 监测节点网络状态
+        if (sysConfig('node_blocked_notification')) {// 通知节点网络状态异常
             $lastCheckTime = Cache::get('LastCheckTime');
 
             if (! $lastCheckTime || $lastCheckTime <= time()) {
@@ -50,14 +50,9 @@ class NodeStatusDetection extends Command
         foreach (Node::whereRelayNodeId(null)->whereStatus(1)->whereNotIn('id', $onlineNode)->get() as $node) {
             // 近期无节点负载信息则认为是后端炸了
             if ($offlineCheckTimes > 0) {
-                $cacheKey = 'offline_check_times'.$node->id;
-                if (! Cache::has($cacheKey)) { // 已通知次数
-                    Cache::put($cacheKey, 1, now()->addDay()); // 键将保留24小时
-                } else {
-                    $times = Cache::increment($cacheKey);
-                    if ($times > $offlineCheckTimes) {
-                        continue;
-                    }
+                $times = $this->updateCache('offline_check_times'.$node->id, 24);
+                if ($times > $offlineCheckTimes) {
+                    continue;
                 }
             }
             $data[] = [
@@ -71,6 +66,17 @@ class NodeStatusDetection extends Command
         }
     }
 
+    private function updateCache(string $key, int $durationInHour): int
+    {
+        if (! Cache::has($key)) {
+            Cache::put($key, 1, now()->addHours($durationInHour));
+
+            return 1;
+        }
+
+        return Cache::increment($key);
+    }
+
     private function checkNodeNetwork(): void
     {
         $detectionCheckTimes = sysConfig('detection_check_times');
@@ -100,12 +106,7 @@ class NodeStatusDetection extends Command
                 // 已通知次数
                 $cacheKey = 'detection_check_times'.$node_id;
 
-                if (! Cache::has($cacheKey)) { // 已通知次数
-                    Cache::put($cacheKey, 1, now()->addHours(12)); // 键将保留12小时
-                    $times = 1;
-                } else {
-                    $times = Cache::increment($cacheKey);
-                }
+                $times = $this->updateCache($cacheKey, 12);
                 if ($times > $detectionCheckTimes) {
                     Cache::forget($cacheKey);
                     $node->update(['status' => 0]);

+ 2 - 2
app/Console/Kernel.php

@@ -13,11 +13,11 @@ class Kernel extends ConsoleKernel
     protected function schedule(Schedule $schedule): void
     {
         $schedule->command('serviceTimer')->everyFiveMinutes();
-        $schedule->command('nodeStatusDetection')->everyTenMinutes();
+        $schedule->command('node:detection')->everyTenMinutes();
         $schedule->command('autoClearLogs')->everyThirtyMinutes();
         $schedule->command('task:hourly')->hourly();
         $schedule->command('task:daily')->dailyAt('00:05');
-        $schedule->command('dailyNodeReport')->dailyAt('09:30');
+        $schedule->command('node:maintenance')->dailyAt('09:30');
         $schedule->command('userTrafficWarning')->dailyAt('10:30');
         $schedule->command('userExpireWarning')->dailyAt('20:30');
         $schedule->command('task:auto')->everyMinute();

+ 20 - 4
app/Http/Controllers/Admin/NodeController.php

@@ -115,6 +115,14 @@ class NodeController extends Controller
                 break;
         }
 
+        $details = [
+            'next_renewal_date' => $info['next_renewal_date'],
+            'subscription_term' => $info['subscription_term'],
+            'renewal_cost' => $info['renewal_cost'],
+        ];
+
+        array_clean($details);
+
         return [
             'type' => $info['type'],
             'name' => $info['name'],
@@ -126,6 +134,7 @@ class NodeController extends Controller
             'rule_group_id' => $info['rule_group_id'],
             'speed_limit' => $info['speed_limit'],
             'client_limit' => $info['client_limit'],
+            'details' => $details,
             'description' => $info['description'],
             'profile' => $profile ?? [],
             'traffic_rate' => $info['traffic_rate'],
@@ -143,10 +152,17 @@ class NodeController extends Controller
 
     public function clone(Node $node): RedirectResponse
     { // 克隆节点
-        $new = $node->replicate()->fill([
-            'name' => $node->name.'_克隆',
+        $clone = [
+            'name' => $node->name.'_'.trans('admin.clone'),
             'server' => null,
-        ]);
+        ];
+
+        if ($node->is_ddns) {
+            $clone['ip'] = '1.1.1.1';
+            $clone['is_ddns'] = 0;
+        }
+
+        $new = $node->replicate()->fill($clone);
         $new->save();
 
         return redirect()->route('admin.node.edit', $new);
@@ -155,7 +171,7 @@ class NodeController extends Controller
     public function edit(Node $node)
     { // 编辑节点页面
         return view('admin.node.info', [
-            'node' => $node,
+            'node' => $node->load('labels'),
             'nodes' => Node::whereNotIn('id', [$node->id])->orderBy('id')->pluck('id', 'name'),
             'countries' => Country::orderBy('code')->get(),
             'levels' => Level::orderBy('level')->get(),

+ 91 - 76
app/Http/Controllers/Admin/SystemController.php

@@ -30,43 +30,41 @@ class SystemController extends Controller
     public function index()
     {
         return view('admin.config.system', array_merge([
-            'payments' => $this->getPayment(),
+            'payments' => $this->getPayments(),
             'captcha' => $this->getCaptcha(),
+            'channels' => $this->getNotifyChannels(),
             'ddns_labels' => (new DDNS)->getLabels(),
         ], Config::pluck('value', 'name')->toArray()));
     }
 
-    private function getPayment(): array
-    { // 获取已经完成配置的支付渠道
-        if (sysConfig('f2fpay_app_id') && sysConfig('f2fpay_private_key') && sysConfig('f2fpay_public_key')) {
-            $payment[] = 'f2fpay';
-        }
-        if (sysConfig('codepay_url') && sysConfig('codepay_id') && sysConfig('codepay_key')) {
-            $payment[] = 'codepay';
-        }
-        if (sysConfig('epay_url') && sysConfig('epay_mch_id') && sysConfig('epay_key')) {
-            $payment[] = 'epay';
-        }
-        if (sysConfig('payjs_mch_id') && sysConfig('payjs_key')) {
-            $payment[] = 'payjs';
-        }
-        if (sysConfig('bitpay_secret')) {
-            $payment[] = 'bitpayx';
-        }
-        if (sysConfig('paypal_client_id') && sysConfig('paypal_client_secret') && sysConfig('paypal_app_id')) {
-            $payment[] = 'paypal';
-        }
-        if (sysConfig('stripe_public_key') && sysConfig('stripe_secret_key')) {
-            $payment[] = 'stripe';
-        }
-        if (sysConfig('paybeaver_app_id') && sysConfig('paybeaver_app_secret')) {
-            $payment[] = 'paybeaver';
-        }
-        if (sysConfig('theadpay_mchid') && sysConfig('theadpay_key')) {
-            $payment[] = 'theadpay';
+    private function getPayments(): array
+    {
+        $paymentConfigs = [ // 支付渠道及其所需配置项映射
+            'f2fpay' => ['f2fpay_app_id', 'f2fpay_private_key', 'f2fpay_public_key'],
+            'codepay' => ['codepay_url', 'codepay_id', 'codepay_key'],
+            'epay' => ['epay_url', 'epay_mch_id', 'epay_key'],
+            'payjs' => ['payjs_mch_id', 'payjs_key'],
+            'bitpayx' => ['bitpay_secret'],
+            'paypal' => ['paypal_client_id', 'paypal_client_secret', 'paypal_app_id'],
+            'stripe' => ['stripe_public_key', 'stripe_secret_key'],
+            'paybeaver' => ['paybeaver_app_id', 'paybeaver_app_secret'],
+            'theadpay' => ['theadpay_mchid', 'theadpay_key'],
+        ];
+
+        $payment = [];
+
+        // 遍历映射,检查配置项是否存在
+        foreach ($paymentConfigs as $paymentName => $configKeys) {
+            $allConfigsExist = array_reduce($configKeys, function ($carry, $configKey) {
+                return $carry && sysConfig($configKey);
+            }, true);
+
+            if ($allConfigsExist) {
+                $payment[] = $paymentName;
+            }
         }
 
-        return $payment ?? [];
+        return $payment;
     }
 
     private function getCaptcha(): bool
@@ -74,6 +72,36 @@ class SystemController extends Controller
         return sysConfig('captcha_secret') && sysConfig('captcha_key');
     }
 
+    private function getNotifyChannels(): array
+    {
+        $configs = [ // 支付渠道及其所需配置项映射
+            'bark' => ['bark_key'],
+            'dingTalk' => ['dingTalk_access_token'],
+            'iYuu' => ['iYuu_token'],
+            'pushDear' => ['pushDeer_key'],
+            'pushPlus' => ['pushplus_token'],
+            'serverChan' => ['server_chan_key'],
+            'telegram' => ['telegram_token'],
+            'tgChat' => ['tg_chat_token'],
+            'weChat' => ['wechat_cid', 'wechat_aid', 'wechat_secret', 'wechat_token', 'wechat_encodingAESKey'],
+        ];
+
+        $channels = ['database', 'mail'];
+
+        // 遍历映射,检查配置项是否存在
+        foreach ($configs as $channel => $configKeys) {
+            $allConfigsExist = array_reduce($configKeys, static function ($carry, $configKey) {
+                return $carry && sysConfig($configKey);
+            }, true);
+
+            if ($allConfigsExist) {
+                $channels[] = $channel;
+            }
+        }
+
+        return $channels;
+    }
+
     public function setExtend(Request $request): RedirectResponse  // 设置涉及到上传的设置
     {
         if ($request->hasAny(['website_home_logo', 'website_home_logo'])) { // 首页LOGO
@@ -85,8 +113,8 @@ class SystemController extends Controller
                 }
                 $file = $request->file('website_home_logo');
                 $file->move('uploads/logo', $file->getClientOriginalName());
-                if (Config::find('website_home_logo')->update(['value' => 'uploads/logo/'.$file->getClientOriginalName()])) {
-                    return redirect()->route('admin.system.index', '#other')->with('successMsg', '更新成功');
+                if (Config::findOrNew('website_home_logo')->update(['value' => 'uploads/logo/'.$file->getClientOriginalName()])) {
+                    return redirect()->route('admin.system.index', '#other')->with('successMsg', trans('common.success_item', ['attribute' => trans('common.update')]));
                 }
             }
             if ($request->hasFile('website_logo')) { // 站内LOGO
@@ -97,12 +125,12 @@ class SystemController extends Controller
                 }
                 $file = $request->file('website_logo');
                 $file->move('uploads/logo', $file->getClientOriginalName());
-                if (Config::findOrFail('website_logo')->update(['value' => 'uploads/logo/'.$file->getClientOriginalName()])) {
-                    return redirect()->route('admin.system.index', '#other')->with('successMsg', '更新成功');
+                if (Config::findOrNew('website_logo')->update(['value' => 'uploads/logo/'.$file->getClientOriginalName()])) {
+                    return redirect()->route('admin.system.index', '#other')->with('successMsg', trans('common.success_item', ['attribute' => trans('common.update')]));
                 }
             }
 
-            return redirect()->route('admin.system.index', '#other')->withErrors('更新失败');
+            return redirect()->route('admin.system.index', '#other')->withErrors(trans('common.failed_item', ['attribute' => trans('common.update')]));
         }
 
         if ($request->hasAny(['alipay_qrcode', 'wechat_qrcode'])) {
@@ -115,7 +143,7 @@ class SystemController extends Controller
                 $file = $request->file('alipay_qrcode');
                 $file->move('uploads/images', $file->getClientOriginalName());
                 if (Config::find('alipay_qrcode')->update(['value' => 'uploads/images/'.$file->getClientOriginalName()])) {
-                    return redirect()->route('admin.system.index', '#payment')->with('successMsg', '更新成功');
+                    return redirect()->route('admin.system.index', '#payment')->with('successMsg', trans('common.success_item', ['attribute' => trans('common.update')]));
                 }
             }
 
@@ -128,11 +156,11 @@ class SystemController extends Controller
                 $file = $request->file('wechat_qrcode');
                 $file->move('uploads/images', $file->getClientOriginalName());
                 if (Config::findOrFail('wechat_qrcode')->update(['value' => 'uploads/images/'.$file->getClientOriginalName()])) {
-                    return redirect()->route('admin.system.index', '#payment')->with('successMsg', '更新成功');
+                    return redirect()->route('admin.system.index', '#payment')->with('successMsg', trans('common.success_item', ['attribute' => trans('common.update')]));
                 }
             }
 
-            return redirect()->route('admin.system.index', '#payment')->withErrors('更新失败');
+            return redirect()->route('admin.system.index', '#payment')->withErrors(trans('common.failed_item', ['attribute' => trans('common.update')]));
         }
 
         return redirect()->route('admin.system.index');
@@ -148,12 +176,12 @@ class SystemController extends Controller
         }
 
         // 支付设置判断
-        if ($value !== null && in_array($name, ['is_AliPay', 'is_QQPay', 'is_WeChatPay'], true) && ! in_array($value, $this->getPayment(), true)) {
-            return Response::json(['status' => 'fail', 'message' => '请先完善该支付渠道的必要参数!']);
+        if ($value !== null && in_array($name, ['is_AliPay', 'is_QQPay', 'is_WeChatPay'], true) && ! in_array($value, $this->getPayments(), true)) {
+            return Response::json(['status' => 'fail', 'message' => trans('admin.system.params_required', ['attribute' => trans('admin.system.payment.attribute')])]);
         }
 
         if ($value > 1 && $name === 'is_captcha' && ! $this->getCaptcha()) {
-            return Response::json(['status' => 'fail', 'message' => '请先完善验证码的必要参数!']);
+            return Response::json(['status' => 'fail', 'message' => trans('admin.system.params_required', ['attribute' => trans('auth.captcha.attribute')])]);
         }
 
         // 演示环境禁止修改特定配置项
@@ -170,7 +198,7 @@ class SystemController extends Controller
             ];
 
             if (in_array($name, $denyConfig, true)) {
-                return Response::json(['status' => 'fail', 'message' => '演示环境禁止修改该配置']);
+                return Response::json(['status' => 'fail', 'message' => trans('admin.system.demo_restriction')]);
             }
         }
 
@@ -188,47 +216,34 @@ class SystemController extends Controller
 
         // 更新配置
         if (Config::findOrFail($name)->update(['value' => $value])) {
-            return Response::json(['status' => 'success', 'message' => trans('common.update_action', ['action' => trans('common.success')])]);
+            return Response::json(['status' => 'success', 'message' => trans('common.success_item', ['attribute' => trans('common.update')])]);
         }
 
-        return Response::json(['status' => 'fail', 'message' => trans('common.update_action', ['action' => trans('common.failed')])]);
+        return Response::json(['status' => 'fail', 'message' => trans('common.failed_item', ['attribute' => trans('common.update')])]);
     }
 
     public function sendTestNotification(): JsonResponse  // 推送通知测试
     {
-        $data = ['这是测试的标题', 'ProxyPanel测试内容'];
-        switch (request('channel')) {
-            case 'serverChan':
-                Notification::sendNow(Auth::getUser(), new Custom($data[0], $data[1]), [ServerChanChannel::class]);
-                break;
-            case 'bark':
-                Notification::sendNow(Auth::getUser(), new Custom($data[0], $data[1]), [BarkChannel::class]);
-                break;
-            case 'telegram':
-                Notification::sendNow(Auth::getUser(), new Custom($data[0], $data[1]), [TelegramChannel::class]);
-                break;
-            case 'weChat':
-                Notification::sendNow(Auth::getUser(), new Custom($data[0], $data[1]), [WeChatChannel::class]);
-                break;
-            case 'tgChat':
-                Notification::sendNow(Auth::getUser(), new Custom($data[0], $data[1]), [TgChatChannel::class]);
-                break;
-            case 'pushPlus':
-                Notification::sendNow(Auth::getUser(), new Custom($data[0], $data[1]), [PushPlusChannel::class]);
-                break;
-            case 'iYuu':
-                Notification::sendNow(Auth::getUser(), new Custom($data[0], $data[1]), [iYuuChannel::class]);
-                break;
-            case 'pushDeer':
-                Notification::sendNow(Auth::getUser(), new Custom($data[0], $data[1]), [PushDeerChannel::class]);
-                break;
-            case 'dingTalk':
-                Notification::sendNow(Auth::getUser(), new Custom($data[0], $data[1]), [DingTalkChannel::class]);
-                break;
-            default:
-                return Response::json(['status' => 'fail', 'message' => '未知渠道']);
+        $channels = [
+            'serverChan' => ServerChanChannel::class,
+            'bark' => BarkChannel::class,
+            'telegram' => TelegramChannel::class,
+            'weChat' => WeChatChannel::class,
+            'tgChat' => TgChatChannel::class,
+            'pushPlus' => PushPlusChannel::class,
+            'iYuu' => iYuuChannel::class,
+            'pushDeer' => PushDeerChannel::class,
+            'dingTalk' => DingTalkChannel::class,
+        ];
+
+        $selectedChannel = request('channel');
+
+        if (! array_key_exists($selectedChannel, $channels)) {
+            return Response::json(['status' => 'fail', 'message' => trans('admin.system.notification.test.unknown_channel')]);
         }
 
-        return Response::json(['status' => 'success', 'message' => '发送成功,请查看手机是否收到推送消息']);
+        Notification::sendNow(Auth::getUser(), new Custom(trans('admin.system.notification.test.title'), sysConfig('website_name').' '.trans('admin.system.notification.test.content')), [$channels[$selectedChannel]]);
+
+        return Response::json(['status' => 'success', 'message' => trans('admin.system.notification.test.success')]);
     }
 }

+ 3 - 0
app/Http/Requests/Admin/NodeRequest.php

@@ -23,6 +23,9 @@ class NodeRequest extends FormRequest
             'labels' => 'nullable|exists:label,id',
             'country_code' => 'required|exists:country,code',
             'description' => 'nullable|string',
+            'next_renewal_date' => 'nullable|date',
+            'subscription_term' => ['nullable', 'string', 'regex:/^\d+\s+(day|days|week|weeks|month|months|year|years)$/'],
+            'renewal_cost' => 'nullable|numeric|min:0',
             'sort' => 'required|numeric|between:0,255',
             'is_udp' => 'required|boolean',
             'status' => 'required|boolean',

+ 1 - 1
app/Models/Node.php

@@ -25,7 +25,7 @@ class Node extends Model
 
     protected $guarded = [];
 
-    protected $casts = ['speed_limit' => data_rate::class, 'profile' => 'array'];
+    protected $casts = ['speed_limit' => data_rate::class, 'profile' => 'array', 'details' => 'array'];
 
     public function labels(): BelongsToMany
     {

+ 85 - 0
app/Notifications/NodeRenewal.php

@@ -0,0 +1,85 @@
+<?php
+
+namespace App\Notifications;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Notifications\Messages\MailMessage;
+use Illuminate\Notifications\Notification;
+use NotificationChannels\Telegram\TelegramMessage;
+
+class NodeRenewal extends Notification
+{
+    use Queueable;
+
+    private array $nodes;
+
+    public function __construct(array $nodes)
+    {
+        $this->nodes = $nodes;
+    }
+
+    public function via(object $notifiable): array
+    {
+        return sysConfig('node_renewal_notification');
+    }
+
+    public function toMail(object $notifiable): MailMessage
+    {
+        return (new MailMessage)
+            ->subject(trans('notification.node_renewal'))
+            ->markdown('mail.simpleMarkdown', ['title' => trans('notification.node_renewal_content'), 'content' => $this->markdownMessage(), 'url' => route('admin.node.index')]);
+    }
+
+    private function markdownMessage(): string
+    {
+        $content = '';
+        foreach ($this->nodes as $node) {
+            $content .= "- $node".PHP_EOL;
+        }
+
+        return trim($content);
+    }
+
+    public function toBark(object $notifiable): array
+    {
+        return [
+            'title' => trans('notification.node_renewal'),
+            'content' => trans('notification.node_renewal_blade', ['nodes' => $this->stringMessage()]),
+            'group' => trans('common.bark.node_status'),
+            'icon' => asset('assets/images/notification/renewal.png'),
+        ];
+    }
+
+    private function stringMessage(): string
+    {
+        $content = '';
+        foreach ($this->nodes as $node) {
+            $content .= "$node | ";
+        }
+
+        return rtrim($content, ' | '); // Remove trailing separator
+    }
+
+    public function toCustom($notifiable): array
+    {
+        return [
+            'title' => trans('notification.node_renewal'),
+            'content' => trans('notification.node_renewal_blade', ['nodes' => $this->stringMessage()]),
+            'url_type' => 'markdown',
+        ];
+    }
+
+    public function toTelegram(object $notifiable): TelegramMessage
+    {
+        return TelegramMessage::create()
+            ->token(sysConfig('telegram_token'))
+            ->content(trans('notification.node_renewal').":\n".trans('notification.node_renewal_content')."\n".$this->markdownMessage());
+    }
+
+    public function toDataBase(object $notifiable): array
+    {
+        return [
+            'nodes' => $this->stringMessage(),
+        ];
+    }
+}

+ 1 - 0
app/Providers/SettingServiceProvider.php

@@ -33,6 +33,7 @@ class SettingServiceProvider extends ServiceProvider
             'node_blocked_notification',
             'node_daily_notification',
             'node_offline_notification',
+            'node_renewal_notification',
             'password_reset_notification',
             'payment_confirm_notification',
             'payment_received_notification',

+ 35 - 0
database/migrations/2024_08_03_225932_node_details.php

@@ -0,0 +1,35 @@
+<?php
+
+use App\Models\Config;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    private static array $configs = ['node_renewal_notification'];
+
+    public function up(): void
+    {
+        Schema::table('node', static function (Blueprint $table) {
+            $table->json('details')->nullable()->comment('节点信息')->after('client_limit');
+        });
+
+        if (Config::exists()) {
+            foreach (self::$configs as $config) {
+                Config::insert(['name' => $config]);
+            }
+        }
+    }
+
+    public function down(): void
+    {
+        Schema::table('node', static function (Blueprint $table) {
+            $table->dropColumn('details');
+        });
+
+        foreach (self::$configs as $config) {
+            Config::destroy(['name' => $config]);
+        }
+    }
+};

+ 1 - 0
database/seeders/ConfigSeeder.php

@@ -72,6 +72,7 @@ class ConfigSeeder extends Seeder
         'node_blocked_notification',
         'node_daily_notification',
         'node_offline_notification',
+        'node_renewal_notification',
         'oauth_path',
         'offline_check_times',
         'password_reset_notification',

文件差异内容过多而无法显示
+ 15 - 0
public/assets/global/vendor/lodash/lodash.min.js


二进制
public/assets/images/notification/custom.png


二进制
public/assets/images/notification/offline.png


二进制
public/assets/images/notification/renewal.png


二进制
public/assets/images/notification/ticket.png


+ 120 - 85
resources/views/admin/config/system.blade.php

@@ -241,6 +241,19 @@
                                 trans('admin.system.notification.channel.tg_chat') => 'tgChat',
                                 trans('admin.system.notification.channel.pushplus') => 'pushPlus',
                             ]" />
+                            <x-system.select code="node_renewal_notification" multiple="1" :list="[
+                                trans('admin.system.notification.channel.email') => 'mail',
+                                trans('admin.system.notification.channel.bark') => 'bark',
+                                trans('admin.system.notification.channel.serverchan') => 'serverChan',
+                                trans('admin.system.notification.channel.pushdeer') => 'pushDear',
+                                trans('admin.system.notification.channel.iyuu') => 'iYuu',
+                                trans('admin.system.notification.channel.telegram') => 'telegram',
+                                trans('admin.system.notification.channel.dingtalk') => 'dingTalk',
+                                trans('admin.system.notification.channel.wechat') => 'weChat',
+                                trans('admin.system.notification.channel.tg_chat') => 'tgChat',
+                                trans('admin.system.notification.channel.pushplus') => 'pushPlus',
+                                trans('admin.system.notification.channel.site') => 'database',
+                            ]" />
                             <x-system.input-limit code="offline_check_times" :value="$offline_check_times" unit="{{ trans('admin.times') }}" />
                             <x-system.select code="node_blocked_notification" multiple="1" :list="[
                                 trans('admin.system.notification.channel.email') => 'mail',
@@ -571,6 +584,7 @@
     </div>
 @endsection
 @section('javascript')
+    <script src="/assets/global/vendor/lodash/lodash.min.js"></script>
     <script src="/assets/global/vendor/bootstrap-select/bootstrap-select.min.js"></script>
     <script src="/assets/global/vendor/switchery/switchery.min.js"></script>
     <script src="/assets/global/vendor/dropify/dropify.min.js"></script>
@@ -582,60 +596,81 @@
     <script src="/assets/global/js/Plugin/dropify.js"></script>
     <script>
         $(document).ready(function() {
-            $('#forbid_mode').selectpicker('val', '{{ $forbid_mode }}');
-            $('#username_type').selectpicker('val', '{{ $username_type ?? 'email' }}');
-            $('#is_invite_register').selectpicker('val', '{{ $is_invite_register }}');
-            $('#is_activate_account').selectpicker('val', '{{ $is_activate_account }}');
-            $('#ddns_mode').selectpicker('val', '{{ $ddns_mode }}');
-            $('#is_captcha').selectpicker('val', '{{ $is_captcha }}');
-            $('#referral_type').selectpicker('val', '{{ $referral_type }}');
-            $('#is_email_filtering').selectpicker('val', '{{ $is_email_filtering }}');
-            $('#is_AliPay').selectpicker('val', '{{ $is_AliPay }}');
-            $('#is_QQPay').selectpicker('val', '{{ $is_QQPay }}');
-            $('#is_WeChatPay').selectpicker('val', '{{ $is_WeChatPay }}');
-            $('#standard_currency').selectpicker('val', '{{ $standard_currency }}');
-            $('#is_otherPay').selectpicker('val', {!! $is_otherPay !!});
-            $('#oauth_path').selectpicker('val', {!! $oauth_path !!});
-            $('#account_expire_notification').selectpicker('val', {!! $account_expire_notification !!});
-            $('#data_anomaly_notification').selectpicker('val', {!! $data_anomaly_notification !!});
-            $('#data_exhaust_notification').selectpicker('val', {!! $data_exhaust_notification !!});
-            $('#node_blocked_notification').selectpicker('val', {!! $node_blocked_notification !!});
-            $('#node_daily_notification').selectpicker('val', {!! $node_daily_notification !!});
-            $('#node_offline_notification').selectpicker('val', {!! $node_offline_notification !!});
-            $('#password_reset_notification').selectpicker('val', '{{ $password_reset_notification }}');
-            $('#payment_confirm_notification').selectpicker('val', '{{ $payment_confirm_notification }}');
-            $('#payment_received_notification').selectpicker('val', {!! $payment_received_notification !!});
-            $('#ticket_closed_notification').selectpicker('val', {!! $ticket_closed_notification !!});
-            $('#ticket_created_notification').selectpicker('val', {!! $ticket_created_notification !!});
-            $('#ticket_replied_notification').selectpicker('val', {!! $ticket_replied_notification !!});
+            const selectorValues = {
+                forbid_mode: '{{ $forbid_mode }}',
+                username_type: '{{ $username_type ?: 'email' }}',
+                is_invite_register: '{{ $is_invite_register }}',
+                is_activate_account: '{{ $is_activate_account }}',
+                ddns_mode: '{{ $ddns_mode }}',
+                is_captcha: '{{ $is_captcha }}',
+                referral_type: '{{ $referral_type }}',
+                is_email_filtering: '{{ $is_email_filtering }}',
+                is_AliPay: '{{ $is_AliPay }}',
+                is_QQPay: '{{ $is_QQPay }}',
+                is_WeChatPay: '{{ $is_WeChatPay }}',
+                is_otherPay: {!! $is_otherPay ?: 'null' !!},
+                standard_currency: '{{ $standard_currency }}',
+                oauth_path: {!! $oauth_path ?: 'null' !!},
+                account_expire_notification: {!! $account_expire_notification ?: 'null' !!},
+                data_anomaly_notification: {!! $data_anomaly_notification ?: 'null' !!},
+                data_exhaust_notification: {!! $data_exhaust_notification ?: 'null' !!},
+                node_blocked_notification: {!! $node_blocked_notification ?: 'null' !!},
+                node_daily_notification: {!! $node_daily_notification ?: 'null' !!},
+                node_offline_notification: {!! $node_offline_notification ?: 'null' !!},
+                node_renewal_notification: {!! $node_renewal_notification ?: 'null' !!},
+                password_reset_notification: {!! $password_reset_notification ?: 'null' !!},
+                payment_confirm_notification: {!! $payment_confirm_notification ?: 'null' !!},
+                payment_received_notification: {!! $payment_received_notification ?: 'null' !!},
+                ticket_closed_notification: {!! $ticket_closed_notification ?: 'null' !!},
+                ticket_created_notification: {!! $ticket_created_notification ?: 'null' !!},
+                ticket_replied_notification: {!! $ticket_replied_notification ?: 'null' !!},
+            };
 
-            // Get all options within select
-            disablePayment(document.getElementById('is_AliPay').getElementsByTagName('option'));
-            disablePayment(document.getElementById('is_QQPay').getElementsByTagName('option'));
-            disablePayment(document.getElementById('is_WeChatPay').getElementsByTagName('option'));
-            disablePayment(document.getElementById('is_otherPay').getElementsByTagName('option'));
+            Object.entries(selectorValues).forEach(([selector, value]) => {
+                $(`#${selector}`).selectpicker('val', value);
+            });
+
+            const disablePayment = (selectId) => {
+                const payments = @json($payments);
+                const parentId = $(`#${selectId}`);
+
+                parentId.find('option').each(function(index) {
+                    if (selectId === 'is_otherPay' || index > 0) {
+                        $(this).prop('disabled', !payments.includes($(this).val()));
+                    }
+                });
+
+                parentId.selectpicker('refresh');
+            };
+            ['is_AliPay', 'is_QQPay', 'is_WeChatPay', 'is_otherPay'].forEach(disablePayment);
+
+            const disableChannel = (selectId) => {
+                const channels = @json($channels);
+                const parentId = $(`#${selectId}`);
+
+                parentId.find('option').each(function() {
+                    $(this).prop('disabled', !channels.includes($(this).val()));
+                });
+
+                parentId.selectpicker('refresh');
+            };
+            ['account_expire_notification', 'data_anomaly_notification', 'data_exhaust_notification', 'node_blocked_notification',
+                'node_daily_notification', 'node_offline_notification', 'node_renewal_notification', 'password_reset_notification',
+                'payment_confirm_notification', 'payment_received_notification', 'ticket_closed_notification', 'ticket_created_notification',
+                'ticket_replied_notification'
+            ].forEach(disableChannel);
 
             @if (!$captcha)
-                disableCaptcha(document.getElementById('is_captcha').getElementsByTagName('option'));
+                $('#is_captcha').find('option').each(function(index) {
+                    if (index > 1) $(this).prop('disabled', true);
+                });
+                $('#is_captcha').selectpicker('refresh');
             @endif
 
         });
 
-        function disablePayment(op) {
-            for (let i = 1; i < op.length; i++) {
-                @json($payments).
-                includes(op[i].value) ? op[i].disabled = false : op[i].disabled = true;
-            }
-        }
-
-        function disableCaptcha(op) {
-            for (let i = 2; i < op.length; i++) {
-                op[i].disabled = true;
-            }
-        }
-
         // 系统设置更新
-        function systemUpdate(systemItem, value) {
+        const systemUpdate = _.debounce(function(systemItem, value) {
             @can('admin.system.update')
                 $.post('{{ route('admin.system.update') }}', {
                     _token: '{{ csrf_token() }}',
@@ -664,26 +699,25 @@
                     showConfirmButton: false,
                 });
             @endcan
-        }
+        }, 100);
 
         // 正常input更新
-        function update(systemItem) {
-            systemUpdate(systemItem, $('#' + systemItem).val());
-        }
+        const update = systemItem => systemUpdate(systemItem, $(`#${systemItem}`).val());
 
         // 需要检查限制的更新
-        function updateFromInput(systemItem, lowerBound = false, upperBound = false) {
-            let value = parseInt($('#' + systemItem).val());
-            if (lowerBound !== false && value < lowerBound) {
-                swal.fire({
-                    title: '不能小于' + lowerBound,
-                    icon: 'warning',
-                    timer: 1500,
-                    showConfirmButton: false
-                });
-            } else if (upperBound !== false && value > upperBound) {
-                swal.fire({
-                    title: '不能大于' + upperBound,
+        const updateFromInput = (systemItem, lowerBound = null, upperBound = null) => {
+            const value = parseInt($(`#${systemItem}`).val());
+            let errorMessage = null;
+
+            if (lowerBound !== null && value < lowerBound) {
+                errorMessage = `值不能小于 ${lowerBound}`;
+            } else if (upperBound !== null && value > upperBound) {
+                errorMessage = `值不能大于 ${upperBound}`;
+            }
+
+            if (errorMessage) {
+                Swal.fire({
+                    title: errorMessage,
                     icon: 'warning',
                     timer: 1500,
                     showConfirmButton: false
@@ -691,29 +725,29 @@
             } else {
                 systemUpdate(systemItem, value);
             }
-        }
+        };
 
         // 其他项更新选择
-        function updateFromOther(inputType, systemItem) {
-            let input = $('#' + systemItem);
-            switch (inputType) {
-                case 'select':
-                    input.on('changed.bs.select', function() {
-                        systemUpdate(systemItem, $(this).val());
-                    });
-                    break;
-                case 'multiSelect':
-                    input.on('changed.bs.select', function() {
-                        systemUpdate(systemItem, $(this).val().join(','));
-                    });
-                    break;
-                case 'switch':
-                    systemUpdate(systemItem, document.getElementById(systemItem).checked ? 1 : 0);
-                    break;
-                default:
-                    break;
+        const updateFromOther = (inputType, systemItem) => {
+            const input = $(`#${systemItem}`);
+            let pendingValue = null; // 用于存储待更新的值
+
+            const updateActions = {
+                select: () => input.on('changed.bs.select', () => systemUpdate(systemItem, input.val())),
+                multiSelect: () => input.on('changed.bs.select', () => {
+                    // 存储当前选择的值
+                    pendingValue = input.val();
+                }).on('hidden.bs.select', () => {
+                    // 当 selectpicker 隐藏时进行更新
+                    if (pendingValue !== null) {
+                        systemUpdate(systemItem, pendingValue);
+                        pendingValue = null; // 清除待更新的值
+                    }
+                }),
+                switch: () => systemUpdate(systemItem, document.getElementById(systemItem).checked ? 1 : 0)
             }
-        }
+            updateActions[inputType] && updateActions[inputType]();
+        };
 
         // 使用通知渠道 发送测试消息
         @can('admin.test.notify')
@@ -741,9 +775,10 @@
 
         // 生成网站安全码
         function makeWebsiteSecurityCode() {
-            $.get('{{ route('createStr') }}', function(ret) {
-                $('#website_security_code').val(ret);
-            });
+            $.get('{{ route('createStr') }}')
+                .done(function(securityCode) {
+                    $('#website_security_code').val(securityCode);
+                });
         }
 
         @can('admin.test.epay')

+ 9 - 17
resources/views/admin/coupon/create.blade.php

@@ -264,23 +264,15 @@
             format: 'yyyy-mm-dd',
         });
 
-        $('input[name=\'type\']').change(function() {
-            if ($(this).val() === '2') {
-                $('.discount').show();
-                $('.usage').show();
-                $('#amount').hide();
-                $('#value').attr('max', 99);
-            } else if ($(this).val() === '3') {
-                $('.discount').hide();
-                $('.usage').hide();
-                $('#amount').show();
-                $('#value').removeAttr('max');
-            } else {
-                $('.discount').hide();
-                $('.usage').show();
-                $('#amount').show();
-                $('#value').removeAttr('max');
-            }
+        $('input[name="type"]').change(function() {
+            const type = $(this).val();
+            const isType2 = type === '2';
+            const isType3 = type === '3';
+
+            $('.discount').toggle(isType2);
+            $('.usage').toggle(!isType3);
+            $('#amount').toggle(!isType2);
+            $('#value').attr('max', isType2 ? 99 : null);
         });
     </script>
 @endsection

+ 260 - 270
resources/views/admin/node/info.blade.php

@@ -1,6 +1,7 @@
 @extends('admin.layouts')
 @section('css')
     <link href="/assets/global/vendor/bootstrap-select/bootstrap-select.min.css" rel="stylesheet">
+    <link href="/assets/global/vendor/bootstrap-datepicker/bootstrap-datepicker.min.css" rel="stylesheet">
     <link href="/assets/global/vendor/switchery/switchery.min.css" rel="stylesheet">
     <style>
         .hidden {
@@ -24,7 +25,8 @@
                 {!! trans('admin.node.info.hint') !!}
             </div>
             <div class="panel-body">
-                <form class="form-horizontal" onsubmit="return Submit()">
+                <form class="form-horizontal" id="nodeForm">
+                    @csrf
                     <div class="row">
                         <div class="col-lg-6">
                             <div class="example-wrap">
@@ -83,9 +85,10 @@
                                         <div class="text-help offset-md-3"> {{ trans('admin.node.info.level_hint') }}</div>
                                     </div>
                                     <div class="form-group row">
-                                        <label class="col-md-3 col-form-label" for="ruleGroup">{{ trans('model.node.rule_group') }}</label>
-                                        <select class="col-md-5 form-control show-tick" id="ruleGroup" name="ruleGroup" data-plugin="selectpicker"
-                                                data-style="btn-outline btn-primary" title="{{ trans('common.none') }}">
+                                        <label class="col-md-3 col-form-label" for="rule_group_id">{{ trans('model.rule_group.attribute') }}</label>
+                                        <select class="col-md-5 form-control show-tick" id="rule_group_id" name="rule_group_id" data-plugin="selectpicker"
+                                                data-style="btn-outline btn-primary">
+                                            <option value="">{{ trans('common.none') }}</option>
                                             @foreach ($ruleGroups as $ruleGroup)
                                                 <option value="{{ $ruleGroup->id }}">{{ $ruleGroup->name }}</option>
                                             @endforeach
@@ -102,6 +105,12 @@
                                         <label class="col-md-3 col-form-label" for="client_limit">{{ trans('model.node.client_limit') }}</label>
                                         <input class="form-control col-md-4" id="client_limit" name="client_limit" type="number" value="1000" required>
                                     </div>
+                                    <div class="form-group row">
+                                        <label class="col-md-3 col-form-label" for="sort">{{ trans('model.common.sort') }}</label>
+                                        <input class="form-control col-md-4" id="sort" name="sort" type="text" value="1" required />
+                                        <span class="col-md-5"></span>
+                                        <div class="text-help offset-md-3"> {{ trans('admin.sort_asc') }}</div>
+                                    </div>
                                     <div class="form-group row">
                                         <label class="col-md-3 col-form-label" for="labels">{{ trans('model.node.label') }}</label>
                                         <select class="col-md-5 form-control show-tick" id="labels" name="labels" data-plugin="selectpicker"
@@ -120,26 +129,40 @@
                                             @endforeach
                                         </select>
                                     </div>
+                                    <!-- 节点 细则部分 -->
                                     <div class="form-group row">
-                                        <label class="col-md-3 col-form-label" for="description"> {{ trans('model.common.description') }} </label>
-                                        <input class="form-control col-md-6" id="description" name="description" type="text">
+                                        <label class="col-md-3 col-form-label" for="next_renewal_date">{{ trans('model.node.next_renewal_date') }}</label>
+                                        <input class="form-control col-md-4" id="next_renewal_date" name="next_renewal_date" data-plugin="datepicker"
+                                               type="text" autocomplete="off" />
                                     </div>
                                     <div class="form-group row">
-                                        <label class="col-md-3 col-form-label" for="sort">{{ trans('model.common.sort') }}</label>
-                                        <input class="form-control col-md-4" id="sort" name="sort" type="text" value="1" required />
-                                        <div class="text-help offset-md-3"> {{ trans('admin.sort_asc') }}</div>
+                                        <label class="col-md-3 col-form-label" for="subscription_term_value">
+                                            {{ trans('model.node.subscription_term') }}
+                                        </label>
+                                        <div class="col-md-4 input-group p-0">
+                                            <input class="form-control" id="subscription_term_value" type="number" min="1" />
+                                            <select class="form-control" id="subscription_term_unit" data-plugin="selectpicker"
+                                                    data-style="btn-outline btn-primary">
+                                                <option value="days" selected>{{ ucfirst(trans('validation.attributes.day')) }}</option>
+                                                <option value="months">{{ ucfirst(trans('validation.attributes.month')) }}</option>
+                                                <option value="years">{{ ucfirst(trans('validation.attributes.year')) }}</option>
+                                            </select>
+                                        </div>
                                     </div>
                                     <div class="form-group row">
-                                        <label class="col-md-3 col-form-label" for="is_udp">{{ trans('model.node.udp') }}</label>
-                                        <div class="col-md-9">
-                                            <input id="is_udp" name="is_udp" data-plugin="switchery" type="checkbox">
+                                        <label class="col-md-3 col-form-label" for="renewal_cost">{{ trans('model.node.renewal_cost') }}</label>
+                                        <div class="col-md-4 input-group p-0">
+                                            <div class="input-group-prepend">
+                                                <span class="input-group-text">
+                                                    {{ array_column(config('common.currency'), 'symbol', 'code')[sysConfig('standard_currency')] }}
+                                                </span>
+                                            </div>
+                                            <input class="form-control" id="renewal_cost" name="renewal_cost" type="number" step="0.01" />
                                         </div>
                                     </div>
                                     <div class="form-group row">
-                                        <label class="col-md-3 col-form-label" for="status">{{ trans('common.status.attribute') }}</label>
-                                        <div class="col-md-9">
-                                            <input id="status" name="status" data-plugin="switchery" type="checkbox">
-                                        </div>
+                                        <label class="col-md-3 col-form-label" for="description"> {{ trans('model.common.description') }} </label>
+                                        <input class="form-control col-md-6" id="description" name="description" type="text">
                                     </div>
                                 </div>
                             </div>
@@ -153,7 +176,7 @@
                                         <ul class="col-md-9 list-unstyled list-inline">
                                             <li class="list-inline-item">
                                                 <div class="radio-custom radio-primary">
-                                                    <input id="invisible" name="is_display" type="radio" value="0" checked />
+                                                    <input id="invisible" name="is_display" type="radio" value="0" />
                                                     <label for="invisible">{{ trans('admin.node.info.display.invisible') }}</label>
                                                 </div>
                                             </li>
@@ -212,7 +235,8 @@
                                     <div class="form-group row">
                                         <label class="col-md-3 col-form-label" for="relay_node_id">{{ trans('model.node.transfer') }}</label>
                                         <select class="col-md-5 form-control show-tick" id="relay_node_id" name="relay_node_id" data-plugin="selectpicker"
-                                                data-style="btn-outline btn-primary" title="{{ trans('common.none') }}">
+                                                data-style="btn-outline btn-primary">
+                                            <option value="">{{ trans('common.none') }}</option>
                                             @foreach ($nodes as $name => $id)
                                                 <option value="{{ $id }}">{{ $id }} - {{ $name }}</option>
                                             @endforeach
@@ -343,7 +367,7 @@
                                             </div>
                                             <div class="form-group row">
                                                 <label class="col-md-3 col-form-label" for="v2_method">{{ trans('model.node.method') }}</label>
-                                                <select class="col-md-5 form-control" id="v2_method" data-plugin="selectpicker"
+                                                <select class="col-md-5 form-control" id="v2_method" name="v2_method" data-plugin="selectpicker"
                                                         data-style="btn-outline btn-primary">
                                                     <option value="none">none</option>
                                                     <option value="auto">auto</option>
@@ -355,7 +379,7 @@
                                             </div>
                                             <div class="form-group row">
                                                 <label class="col-md-3 col-form-label" for="v2_net">{{ trans('model.node.v2_net') }}</label>
-                                                <select class="col-md-5 form-control" id="v2_net" data-plugin="selectpicker"
+                                                <select class="col-md-5 form-control" id="v2_net" name="v2_net" data-plugin="selectpicker"
                                                         data-style="btn-outline btn-primary">
                                                     <option value="tcp">TCP</option>
                                                     <option value="http">HTTP/2</option>
@@ -368,7 +392,7 @@
                                             </div>
                                             <div class="form-group row v2_type">
                                                 <label class="col-md-3 col-form-label" for="v2_type">{{ trans('model.node.v2_cover') }}</label>
-                                                <select class="col-md-5 form-control" id="v2_type" data-plugin="selectpicker"
+                                                <select class="col-md-5 form-control" id="v2_type" name="v2_type" data-plugin="selectpicker"
                                                         data-style="btn-outline btn-primary">
                                                     <option value="none">{{ trans('admin.node.info.v2_cover.none') }}</option>
                                                     <option value="http">{{ trans('admin.node.info.v2_cover.http') }}</option>
@@ -384,7 +408,7 @@
                                             <div class="form-group row v2_host">
                                                 <label class="col-md-3 col-form-label" for="v2_host">{{ trans('model.node.v2_host') }}</label>
                                                 <div class="col-md-4 pl-0">
-                                                    <input class="form-control" id="v2_host" name="v2_other" type="text">
+                                                    <input class="form-control" id="v2_host" name="v2_host" type="text">
                                                 </div>
                                                 <div class="text-help offset-md-3">
                                                     {{ trans('admin.node.info.v2_host_hint') }}
@@ -430,6 +454,18 @@
                                             <input class="form-control col-md-4" id="relay_port" name="port" type="number" value="443" hidden />
                                         </div>
                                     </div>
+                                    <div class="form-group row">
+                                        <label class="col-md-3 col-form-label" for="is_udp">{{ trans('model.node.udp') }}</label>
+                                        <div class="col-md-9">
+                                            <input id="is_udp" name="is_udp" data-plugin="switchery" type="checkbox">
+                                        </div>
+                                    </div>
+                                    <div class="form-group row">
+                                        <label class="col-md-3 col-form-label" for="status">{{ trans('common.status.attribute') }}</label>
+                                        <div class="col-md-9">
+                                            <input id="status" name="status" data-plugin="switchery" type="checkbox">
+                                        </div>
+                                    </div>
                                 </div>
                             </div>
                             <div class="col-md-12 form-actions">
@@ -447,148 +483,137 @@
 @endsection
 @section('javascript')
     <script src="/assets/global/vendor/bootstrap-select/bootstrap-select.min.js"></script>
+    <script src="/assets/global/vendor/bootstrap-datepicker/bootstrap-datepicker.min.js"></script>
     <script src="/assets/global/js/Plugin/bootstrap-select.js"></script>
+    <script src="/assets/global/js/Plugin/bootstrap-datepicker.js"></script>
     <script src="/assets/global/vendor/switchery/switchery.min.js"></script>
     <script src="/assets/global/js/Plugin/switchery.js"></script>
     <script>
+        $('[name="next_renewal_date"]').datepicker({
+            format: 'yyyy-mm-dd',
+        });
+
         const string = "{{ strtolower(Str::random()) }}";
+
         $(document).ready(function() {
-            $('.relay-config').hide();
-            let v2_path = $('#v2_path');
+            $('.single-setting').hide();
+            $('input:radio[name="type"]').on('change', updateServiceType);
+            $('#obfs').on('changed.bs.select', toggleObfsParam);
+            $('#relay_node_id').on('changed.bs.select', toggleRelayConfig);
+            $('#v2_net').on('changed.bs.select', updateV2RaySettings);
+
+            $('#nodeForm').on('submit', function(event) {
+                event.preventDefault();
+                formSubmit(event);
+            });
+
+            $('[name="next_renewal_date"]').datepicker({
+                format: 'yyyy-mm-dd'
+            });
+
+            toggleObfsParam();
+            toggleRelayConfig();
+
             @isset($node)
-                @if ($node->is_ddns)
-                    $('#is_ddns').click();
-                @endif
-                @if ($node->is_udp)
-                    $('#is_udp').click();
-                @endif
-                @if ($node->status)
-                    $('#status').click();
-                @endif
-                $("input[name='is_display'][value='{{ $node->is_display }}']").click();
-                $("input[name='detection_type'][value='{{ $node->detection_type }}']").click();
-                $("input[name='type'][value='{{ $node->type }}']").click();
-                $('#name').val('{{ $node->name }}');
-                $('#server').val('{{ $node->server }}');
-                $('#ip').val('{{ $node->ip }}');
-                $('#ipv6').val('{{ $node->ipv6 }}');
-                $('#push_port').val('{{ $node->push_port }}');
-                $('#traffic_rate').val('{{ $node->traffic_rate }}');
-                $('#level').selectpicker('val', '{{ $node->level }}');
-                $('#ruleGroup').selectpicker('val', '{{ $node->rule_group_id }}');
-                $('#speed_limit').val('{{ $node->speed_limit }}');
-                $('#client_limit').val('{{ $node->client_limit }}');
-                $('#labels').selectpicker('val', {{ $node->labels->pluck('id') }});
-                $('#country_code').selectpicker('val', '{{ $node->country_code }}');
-                $('#relay_node_id').selectpicker('val', '{{ $node->relay_node_id }}');
-                $('#description').val('{{ $node->description }}');
-                $('#sort').val('{{ $node->sort }}');
+                const nodeData = @json($node);
+                const {
+                    type,
+                    labels,
+                    relay_node_id,
+                    port,
+                    profile,
+                    tls_provider,
+                    details
+                } = nodeData;
+
+                ['is_ddns', 'is_udp', 'status'].forEach(prop => nodeData[prop] && $(`#${prop}`).click());
+                ['is_display', 'detection_type', 'type'].forEach(prop => $(`input[name="${prop}"][value="${nodeData[prop]}"]`).click());
 
-                @if (isset($node->relay_node_id))
-                    $('#relay_port').val('{{ $node->port }}');
-                @else
-                    @switch($node->type)
-                        @case(1)
-                        @case(4)
-                        $('#protocol').selectpicker('val', '{{ $node->profile['protocol'] ?? null }}');
-                        $('#protocol_param').val('{{ $node->profile['protocol_param'] ?? null }}');
-                        $('#obfs').selectpicker('val', '{{ $node->profile['obfs'] ?? null }}');
-                        $('#obfs_param').val('{{ $node->profile['obfs_param'] ?? null }}');
-                        @if (!empty($node->profile['passwd']) && $node->port)
-                            $('#single').click();
-                            $('#passwd').val('{{ $node->profile['passwd'] }}');
-                        @endif
-                        @case(0)
-                        $('#method').selectpicker('val', '{{ $node->profile['method'] ?? null }}');
-                        @break
+                ['name', 'server', 'ip', 'ipv6', 'push_port', 'traffic_rate', 'speed_limit', 'client_limit', 'description', 'sort']
+                .forEach(prop => $(`#${prop}`).val(nodeData[prop]));
 
-                        @case(2)
-                        //V2Ray
-                        $('#v2_alter_id').val('{{ $node->profile['v2_alter_id'] ?? null }}');
-                        $('#v2_method').selectpicker('val', '{{ $node->profile['method'] ?? null }}');
-                        $('#v2_net').selectpicker('val', '{{ $node->profile['v2_net'] ?? null }}');
-                        $('#v2_type').selectpicker('val', '{{ $node->profile['v2_type'] ?? null }}');
-                        $('#v2_host').val('{{ $node->profile['v2_host'] ?? null }}');
-                        $('#v2_port').val('{{ $node->port }}');
-                        $('#v2_sni').val('{{ $node->profile['v2_sni'] ?? null }}');
-                        v2_path.val('{{ $node->profile['v2_path'] ?? null }}');
-                        @if ($node->profile['v2_tls'] ?? false)
-                            $('#v2_tls').click();
-                        @endif
-                        $('#tls_provider').val('{!! $node->tls_provider !!}');
-                        @break
+                ['level', 'rule_group_id', 'country_code', 'relay_node_id'].forEach(prop => $(`#${prop}`).selectpicker('val', nodeData[prop]));
 
-                        @case(3)
-                        $('#trojan_port').val('{{ $node->port }}');
-                        @break
+                $('#labels').selectpicker('val', labels.map(label => label.id));
+                $('#next_renewal_date').datepicker('update', details.next_renewal_date ?? null);
+                if (details.subscription_term) {
+                    setSubscriptionTerm(details.subscription_term)
+                }
+                $('#renewal_cost').val(details.renewal_cost ?? null);
 
-                        @default
-                    @endswitch
-                    $('input[name = port]').val('{{ $node->port }}');
-                @endif
+                if (relay_node_id) {
+                    $('#relay_port').val(port);
+                } else {
+                    const typeHandlers = {
+                        0: () => $('#method').selectpicker('val', profile.method ?? null),
+                        1: setSSRValues,
+                        2: setV2RayValues,
+                        3: () => $('#trojan_port').val(port),
+                        4: setSSRValues
+                    };
+
+                    typeHandlers[type] && typeHandlers[type]();
+                    $('input[name="port"]').val(port);
+                }
+
+                function setSSRValues() {
+                    ['protocol', 'obfs'].forEach(prop => $(`#${prop}`).selectpicker('val', profile[prop] ?? null));
+                    ['protocol_param', 'obfs_param'].forEach(prop => $(`#${prop}`).val(profile[prop] ?? null));
+                    if (profile.passwd && port) {
+                        $('#single').click();
+                        $('#passwd').val(profile.passwd);
+                    }
+                }
+
+                function setV2RayValues() {
+                    ['v2_alter_id', 'v2_host', 'v2_sni', 'v2_path'].forEach(prop => $(`#${prop}`).val(profile[prop] ?? null));
+                    ['v2_net', 'v2_type'].forEach(prop => $(`#${prop}`).selectpicker('val', profile[prop] ?? null));
+                    $('#v2_method').selectpicker('val', profile['method'] ?? null);
+
+                    $('#v2_port').val(port);
+                    profile.v2_tls && $('#v2_tls').click();
+                    $('#tls_provider').val(tls_provider);
+                }
             @else
                 switchSetting('single');
                 switchSetting('is_ddns');
-                $('input[name=\'type\'][value=\'0\']').click();
-                $('#status').click();
-                $('#is_udp').click();
-                v2_path.val('/' + string);
+                $('input[name="type"][value="0"]').click();
+                $('#status, #is_udp').click();
+                $('#v2_path').val('/' + string);
             @endisset
-            if ($('#obfs').val() === 'plain') {
-                $('.obfs_param').hide();
+
+            function setSubscriptionTerm(term) {
+                const [value, unit] = term.split(' ');
+
+                $('#subscription_term_value').val(value || '');
+                $('#subscription_term_unit').selectpicker('val', unit || 'day'); // 默认选择 day
             }
         });
 
-        function Submit() { // ajax同步提交
+        function formSubmit(event) {
+            const $form = $(event.target); // 获取触发事件的表单
+            const data = Object.fromEntries($form.serializeArray().map(item => [item.name, item.value]));
+
+            // 拼接 subscription_term
+            const termValue = $('#subscription_term_value').val();
+            const termUnit = $('#subscription_term_unit').val();
+            data['subscription_term'] = termValue ? `${termValue} ${termUnit}` : null;
+
+            // 将序列化的表单数据转换为 JSON 对象
+            $form.find('input[type="checkbox"]').each(function() {
+                data[this.name] = this.checked ? 1 : 0;
+            });
+
+            // 处理多选 select
+            $form.find('select[multiple]').each(function() {
+                data[this.name] = $(this).val();
+            });
+
             $.ajax({
-                method: @isset($node)
-                    'PUT'
-                @else
-                    'POST'
-                @endisset ,
                 url: '{{ isset($node) ? route('admin.node.update', $node) : route('admin.node.store') }}',
-                dataType: 'json',
-                data: {
-                    _token: '{{ csrf_token() }}',
-                    is_ddns: document.getElementById('is_ddns').checked ? 1 : 0,
-                    name: $('#name').val(),
-                    server: $('#server').val(),
-                    ip: $('#ip').val(),
-                    ipv6: $('#ipv6').val(),
-                    push_port: $('#push_port').val(),
-                    traffic_rate: $('#traffic_rate').val(),
-                    level: $('#level').val(),
-                    rule_group_id: $('#ruleGroup').val(),
-                    speed_limit: $('#speed_limit').val(),
-                    client_limit: $('#client_limit').val(),
-                    labels: $('#labels').val(),
-                    country_code: $('#country_code option:selected').val(),
-                    description: $('#description').val(),
-                    sort: $('#sort').val(),
-                    is_udp: document.getElementById('is_udp').checked ? 1 : 0,
-                    status: document.getElementById('status').checked ? 1 : 0,
-                    type: $('input[name=\'type\']:checked').val(),
-                    method: $('#method').val(),
-                    protocol: $('#protocol').val(),
-                    protocol_param: $('#protocol_param').val(),
-                    obfs: $('#obfs').val(),
-                    obfs_param: $('#obfs_param').val(),
-                    is_display: $('input[name=\'is_display\']:checked').val(),
-                    detection_type: $('input[name=\'detection_type\']:checked').val(),
-                    single: document.getElementById('single').checked ? 1 : 0,
-                    port: $('input[name="port"]:not([hidden])').val(),
-                    passwd: $('#passwd').val(),
-                    v2_alter_id: $('#v2_alter_id').val(),
-                    v2_method: $('#v2_method').val(),
-                    v2_net: $('#v2_net').val(),
-                    v2_type: $('#v2_type').val(),
-                    v2_host: $('#v2_host').val(),
-                    v2_path: $('#v2_path').val(),
-                    v2_sni: $('#v2_sni').val(),
-                    v2_tls: document.getElementById('v2_tls').checked ? 1 : 0,
-                    tls_provider: $('#tls_provider').val(),
-                    relay_node_id: $('#relay_node_id option:selected').val(),
-                },
+                method: '{{ isset($node) ? 'PUT' : 'POST' }}',
+                contentType: 'application/json',
+                data: JSON.stringify(data),
                 success: function(ret) {
                     if (ret.status === 'success') {
                         swal.fire({
@@ -600,194 +625,159 @@
                             '{{ route('admin.node.index') . (Request::getQueryString() ? '?' . Request::getQueryString() : '') }}');
                     } else {
                         swal.fire({
-                            title: '[错误 | Error]',
+                            title: '{{ trans('common.error') }}',
                             text: ret.message,
                             icon: 'error'
                         });
                     }
                 },
                 error: function(data) {
-                    let str = '';
-                    const errors = data.responseJSON;
-                    if ($.isEmptyObject(errors) === false) {
-                        $.each(errors.errors, function(index, value) {
-                            str += '<li>' + value + '</li>';
-                        });
+                    const errors = data.responseJSON?.errors;
+                    if (errors) {
+                        const errorList = Object.values(errors).map(error => `<li>${error}</li>`).join('');
                         swal.fire({
                             title: '{{ trans('admin.hint') }}',
-                            html: str,
+                            html: `<ul>${errorList}</ul>`,
                             icon: 'error',
                             confirmButtonText: '{{ trans('common.confirm') }}',
                         });
                     }
                 },
             });
-
-            return false;
         }
 
         function switchSetting(id) {
-            let check = document.getElementById(id).checked ? 1 : 0;
-            switch (id) {
-                case 'single': // 设置单端口多用户
-                    if (check) {
-                        $('.single-setting').show();
-                        $('#single_port').removeAttr('hidden').attr('required', true);
-                    } else {
-                        $('#single_port').removeAttr('required').attr('hidden', true);
-                        $('#passwd').val('');
-                        $('.single-setting').hide();
-                    }
-                    break;
-
-                case 'is_ddns': // 设置是否使用DDNS
-                    if (check) {
-                        $('#ip').val('').attr('readonly', true);
-                        $('#ipv6').val('').attr('readonly', true);
-                        $('#server').attr('required', true);
-                    } else {
-                        $('#ip').removeAttr('readonly');
-                        $('#ipv6').removeAttr('readonly');
-                        $('#server').removeAttr('required');
-                    }
-                    break;
-                default:
-                    break;
+            const check = document.getElementById(id).checked;
+            if (id === 'single') {
+                $('.single-setting').toggle(check);
+                $('#single_port').attr({
+                    'hidden': !check,
+                    'required': check
+                });
+                if (!check) $('#passwd').val('');
+            } else if (id === 'is_ddns') {
+                $('#ip, #ipv6').attr('readonly', check).val('');
+                $('#server').attr('required', check);
             }
         }
 
         // 设置服务类型
-        $('input:radio[name=\'type\']').on('change', function() {
+        function updateServiceType() {
             const type = parseInt($(this).val());
-            const $ss_setting = $('.ss-setting');
-            const $ssr_setting = $('.ssr-setting');
-            const $v2ray_setting = $('.v2ray-setting');
-            const $trojan_setting = $('.trojan-setting');
-            $ssr_setting.hide();
-            $ss_setting.hide();
-            $v2ray_setting.hide();
-            $trojan_setting.hide();
+            $('.ss-setting, .ssr-setting, .v2ray-setting, .trojan-setting').hide();
             $('#v2_port').removeAttr('required').attr('hidden', true);
             $('#trojan_port').removeAttr('required');
             switch (type) {
                 case 0:
-                    $ss_setting.show();
+                    $('.ss-setting').show();
                     break;
                 case 2:
-                    $v2ray_setting.show();
-                    $('#v2_port').removeAttr('hidden').attr('required', true);
+                    $('.v2ray-setting').show();
+                    $('#v2_port').removeAttr('hidden').prop('required', true);
                     $('#v2_net').selectpicker('val', 'tcp');
                     break;
                 case 3:
-                    $trojan_setting.show();
-                    $('#trojan_port').removeAttr('hidden').attr('required', true);
+                    $('.trojan-setting').show();
+                    $('#trojan_port').removeAttr('hidden').prop('required', true);
                     break;
                 case 1:
                 case 4:
-                    $ss_setting.show();
-                    $ssr_setting.show();
+                    $('.ss-setting, .ssr-setting').show();
                     break;
-                default:
             }
-        });
+        }
 
-        $('#obfs').on('changed.bs.select', function() {
-            const obfs_param = $('.obfs_param');
-            if ($('#obfs').val() === 'plain') {
-                $('#obfs_param').val('');
-                obfs_param.hide();
-            } else {
-                obfs_param.show();
-            }
-        });
+        function toggleObfsParam() {
+            const $obfsParam = $('.obfs_param');
+            const isPlain = $('#obfs').val() === 'plain';
+            $obfsParam.toggle(!isPlain);
+            if (isPlain) $('#obfs_param').val('');
+        }
 
-        $('#relay_node_id').on('changed.bs.select', function() {
-            const relay = $('.relay-config');
-            const config = $('.proxy-config');
-            if ($('#relay_node_id').val() === '') {
-                relay.hide();
-                $('#relay_port').removeAttr('required').attr('hidden', true);
-                config.show();
-            } else {
-                relay.show();
-                config.hide();
-                $('#relay_port').removeAttr('hidden').attr('required', true);
-            }
-        });
+        function toggleRelayConfig() {
+            const hasRelay = $('#relay_node_id').val() !== '';
+            $('.relay-config').toggle(hasRelay);
+            $('.proxy-config').toggle(!hasRelay);
+            $('#relay_port').attr({
+                'hidden': !hasRelay,
+                'required': hasRelay
+            });
+        }
 
         // 设置V2Ray详细设置
-        $('#v2_net').on('changed.bs.select', function() {
-            const type = $('.v2_type');
-            const type_option = $('#type_option');
-            const host = $('.v2_host');
-            const path = $('#v2_path');
-            const v2_other = $('[name="v2_other"]');
-            type.show();
-            host.show();
-            v2_other.show();
-            path.val('/' + string);
-            switch ($(this).val()) {
-                case 'kcp':
-                    type_option.attr('disabled', false);
-                    break;
+        function updateV2RaySettings() {
+            const net = $(this).val();
+            const $type = $('.v2_type');
+            const $typeOption = $('#type_option');
+            const $host = $('.v2_host');
+            const $path = $('#v2_path');
+            $type.show();
+            $host.show();
+            $path.val('/' + string);
+            switch (net) {
                 case 'ws':
-                    type.hide();
-                    break;
                 case 'http':
-                    type.hide();
+                    $type.hide();
                     break;
                 case 'domainsocket':
-                    type.hide();
-                    host.hide();
+                    $type.hide();
+                    $host.hide();
                     break;
                 case 'quic':
-                    type_option.attr('disabled', false);
-                    path.val(string);
+                    $typeOption.attr('disabled', false);
+                    $path.val(string);
                     break;
+                case 'kcp':
                 case 'tcp':
                 default:
-                    type_option.attr('disabled', true);
+                    $typeOption.attr('disabled', true);
                     break;
             }
             $('#v2_type').selectpicker('refresh');
-        });
+        }
 
         // 服务条款
-        function showTnc() {
-            const content =
-                '<ol>' +
-                '<li>请勿直接复制黏贴以下配置,SSR(R)会报错的</li>' +
-                '<li>确保服务器时间为CST</li>' +
-                '</ol>' +
-                '&emsp;&emsp;"additional_ports" : {<br />' +
-                '&emsp;&emsp;&emsp;"443": {<br />' +
-                '&emsp;&emsp;&emsp;&emsp;"passwd": "ProxyPanel",<br />' +
-                '&emsp;&emsp;&emsp;&emsp;"method": "none",<br />' +
-                '&emsp;&emsp;&emsp;&emsp;"protocol": "auth_chain_a",<br />' +
-                '&emsp;&emsp;&emsp;&emsp;"protocol_param": "#",<br />' +
-                '&emsp;&emsp;&emsp;&emsp;"obfs": "plain",<br />' +
-                '&emsp;&emsp;&emsp;&emsp;"obfs_param": "fe2.update.microsoft.com"<br />' +
-                '&emsp;&emsp;&emsp;}<br />' +
-                '&emsp;&emsp;},';
+        window.showTnc = function() {
+            const jsonConfig = {
+                "additional_ports": {
+                    "443": {
+                        "passwd": "ProxyPanel",
+                        "method": "none",
+                        "protocol": "auth_chain_a",
+                        "protocol_param": "#",
+                        "obfs": "plain",
+                        "obfs_param": "fe2.update.microsoft.com"
+                    }
+                }
+            };
 
             swal.fire({
                 title: '[节点 user-config.json 配置示例]',
-                html: '<div class="p-10 bg-grey-900 text-white font-weight-300 text-left" style="line-height: 22px;">' +
-                    content + '</div>',
+                width: '36em',
+                html: `
+                    <div class="text-left">
+                        <ol>
+                            <li>请勿直接复制黏贴以下配置,SSR(R)会报错的</li>
+                            <li>确保服务器时间为CST</li>
+                        </ol>
+                        <pre class="bg-grey-800 text-white">${JSON.stringify(jsonConfig, null, 2)}</pre>
+                    </div>
+                `,
                 icon: 'info',
-            });
-        }
+            })
+        };
 
         // 模式提示
-        function showPortsOnlyConfig() {
-            const content = '严格模式:"additional_ports_only": "true"' +
-                '<br><br>' +
-                '兼容模式:"additional_ports_only": "false"';
-
+        window.showPortsOnlyConfig = function() {
             swal.fire({
                 title: '[节点 user-config.json 配置示例]',
-                html: '<div class="p-10 bg-grey-900 text-white font-weight-300 text-left" style="line-height: 22px;">' +
-                    content + '</div>',
+                width: '36em',
+                html: `
+                  <ul class="bg-grey-800 text-white text-left">
+                      <li>严格模式:"additional_ports_only": "true"</li>
+                      <li>兼容模式:"additional_ports_only": "false"</li>
+                  </ul>
+                `,
                 icon: 'info',
             });
         }

+ 53 - 82
resources/views/admin/shop/info.blade.php

@@ -218,95 +218,66 @@
     <script src="/assets/global/js/Plugin/dropify.js"></script>
     <script>
         $('[data-toggle="switch"]').bootstrapSwitch();
-        @isset($good)
-            $(document).ready(function() {
-                const type = $('input[name=\'type\']');
-                $('#id').val('{{ $good->id }}');
-                $("input[name='type'][value='{{ $good->type }}']").click();
-                type.attr('disabled', true);
-                $('#name').val('{{ $good->name }}');
-                $('#price').val('{{ $good->price }}');
-                $('#level').selectpicker('val', '{{ $good->level }}');
-                @if ($good->type == 2)
-                    $('#renew').val('{{ $good->renew }}');
-                    $('#speed_limit').val('{{ $good->speed_limit }}');
-                    $('#period').val('{{ $good->period }}');
-                    $('#days').val('{{ $good->days }}').attr('disabled', true);
-                @endif
-                $('#invite_num').val('{{ $good->invite_num }}');
-                $('#limit_num').val('{{ $good->limit_num }}');
-                @if ($good->is_hot)
-                    $('#is_hot').click();
-                @endif
-                @if ($good->status)
-                    $('#status').click();
-                @endif
-                $('#sort').val('{{ $good->sort }}');
-                $('#color').asColorPicker('val', '{{ $good->color }}');
-                $('#description').val(@json($good->description));
-                $('#info').val(@json($good->info));
-                $('#category_id').selectpicker('val', '{{ $good->category_id }}');
+
+        $(document).ready(function() {
+            const goodData = @json($good ?? null);
+            const oldData = @json(old());
+
+            if (goodData || oldData) {
+                const data = goodData || oldData;
+                setFormValues(data);
+                if (goodData) {
+                    $('input[name="type"]').attr('disabled', true);
+                    $('#traffic').attr('disabled', true);
+                    $('#traffic_unit').attr('disabled', true).selectpicker('refresh');
+                    if (goodData.type === 2) $('#days').attr('disabled', true);
+                }
+            } else {
+                $('#status').click();
+            }
+
+            function setFormValues(data) {
+                const simpleFields = ['id', 'name', 'price', 'invite_num', 'limit_num', 'sort', 'description', 'info'];
+                simpleFields.forEach(field => $(`#${field}`).val(data[field]));
+
+                $(`input[name='type'][value='${data.type}']`).click();
+                $('#level').selectpicker('val', data.level);
+                $('#category_id').selectpicker('val', data.category_id);
+
+                if (data.type === 2) {
+                    ['renew', 'speed_limit', 'period', 'days'].forEach(field => $(`#${field}`).val(data[field] || 0));
+                }
+
+                if (data.is_hot) $('#is_hot').click();
+                if (data.status) $('#status').click();
+
+                $('#color').asColorPicker('val', data.color);
+
+                setTrafficValue(data.traffic);
+            }
+
+            function setTrafficValue(traffic) {
                 const trafficUnit = $('#traffic_unit');
-                const traffic = $('#traffic');
-                @if ($good->traffic >= 1073741824)
-                    traffic.val('{{ $good->traffic / 1073741824 }}');
+                const trafficInput = $('#traffic');
+
+                if (traffic >= 1073741824) {
+                    trafficInput.val(traffic / 1073741824);
                     trafficUnit.selectpicker('val', '1073741824');
-                @elseif ($good->traffic >= 1048576)
-                    traffic.val('{{ $good->traffic / 1048576 }}');
+                } else if (traffic >= 1048576) {
+                    trafficInput.val(traffic / 1048576);
                     trafficUnit.selectpicker('val', '1048576');
-                @elseif ($good->traffic >= 1024)
-                    traffic.val('{{ $good->traffic / 1024 }}');
+                } else if (traffic >= 1024) {
+                    trafficInput.val(traffic / 1024);
                     trafficUnit.selectpicker('val', '1024');
-                @else
-                    traffic.val('{{ $good->traffic }}');
-                @endif
-                traffic.attr('disabled', true);
-                trafficUnit.attr('disabled', true).selectpicker('refresh');
-            });
-        @elseif (old('type'))
-            $(document).ready(function() {
-                const type = $('input[name=\'type\']');
-                $('#id').val('{{ old('id') }}');
-                $("input[name='type'][value='{{ old('type') }}']").click();
-                $('#name').val('{{ old('name') }}');
-                $('#price').val('{{ old('price') }}');
-                $('#level').selectpicker('val', '{{ old('level') }}');
-                @if (old('type') == 2)
-                    $('#renew').val('{{ old('renew', 0) }}');
-                    $('#speed_limit').val('{{ old('speed_limit', 0) }}');
-                    $('#period').val('{{ old('period', 0) }}');
-                    $('#days').val('{{ old('days', 0) }}');
-                @endif
-                $('#traffic').val('{{ old('traffic') }}');
-                $('#traffic_unit').selectpicker('val', '{{ old('traffic_unit') }}');
-                $('#invite_num').val('{{ old('invite_num') }}');
-                $('#limit_num').val('{{ old('limit_num') }}');
-                @if (old('is_hot'))
-                    $('#is_hot').click();
-                @endif
-                @if (old('status'))
-                    $('#status').click();
-                @endif
-                $('#sort').val('{{ old('sort') }}');
-                $('#color').asColorPicker('val', '{{ old('color') }}');
-                $('#description').val('{{ old('description') }}');
-                $('#info').val('{{ old('info') }}');
-            });
-        @else
-            $('#status').click();
-        @endisset
-
-        function itemControl(value) {
-            if (value === 1) {
-                $('.package-renew').hide();
-            } else {
-                $('.package-renew').show();
+                } else {
+                    trafficInput.val(traffic);
+                }
             }
-        }
+        });
 
         // 选择商品类型
-        $('input[name=\'type\']').change(function() {
-            itemControl(parseInt($(this).val()));
+        $('input[name="type"]').change(function() {
+            $('.package-renew').toggle(parseInt($(this).val()) !== 1);
         });
     </script>
 @endsection

+ 1 - 1
resources/views/components/system/input-test.blade.php

@@ -14,7 +14,7 @@
                         <a href="javascript:sendTestNotification('{{ $test }}');">[{{ trans('admin.system.notification.send_test') }}]</a>
                     @endcan
                 </span>
-            @endisset
+            @endif
         </div>
     </div>
 </div>

+ 4 - 3
resources/views/components/system/select.blade.php

@@ -5,14 +5,15 @@
         <label class="col-md-3 col-form-label" for="{{ $code }}">{{ trans('admin.system.' . $code) }}</label>
         <div class="col-md-9">
             <select id="{{ $code }}" data-plugin="selectpicker" data-style="btn-outline btn-primary"
-                    onchange="updateFromOther('select','{{ $code }}')" @if ($multiple) multiple @endif>
+                    onchange="updateFromOther('{{ $multiple ? 'multiSelect' : 'select' }}','{{ $code }}')"
+                    @if ($multiple) multiple @endif>
                 @foreach ($list as $key => $value)
                     <option value="{{ $value }}">{{ $key }}</option>
                 @endforeach
             </select>
             @if (trans('admin.system.hint.' . $code) !== 'admin.system.hint.' . $code)
                 <span class="text-help"> {!! trans('admin.system.hint.' . $code) !!} </span>
-            @endisset
+            @endif
+        </div>
     </div>
 </div>
-</div>

+ 1 - 1
resources/views/mail/simpleMarkdown.blade.php

@@ -1,7 +1,7 @@
 @component('mail::message')
     # {{ $title }}
 
-    {!! $content !!}
+{!! $content !!}
 
     @component('mail::button', ['url' => $url])
         {{ trans('notification.view_web') }}

+ 1 - 1
resources/views/user/components/notifications/accountExpire.blade.php

@@ -1,7 +1,7 @@
 <a class="list-group-item dropdown-item" href="javascript:void(0)" role="menuitem">
     <div class="media">
         <div class="pr-10">
-            <i class="icon wb-calendar bg-cyan-600 white icon-circle" aria-hidden="true"></i>
+            <i class="icon wb-loop bg-cyan-600 white icon-circle" aria-hidden="true"></i>
         </div>
         <div class="media-body">
             <h6 class="media-heading text-break">

+ 13 - 0
resources/views/user/components/notifications/nodeRenewal.blade.php

@@ -0,0 +1,13 @@
+<a class="list-group-item dropdown-item" href="javascript:void(0)" role="menuitem">
+    <div class="media">
+        <div class="pr-10">
+            <i class="icon wb-calendar bg-cyan-600 white icon-circle" aria-hidden="true"></i>
+        </div>
+        <div class="media-body">
+            <h6 class="media-heading text-break">
+                {{ trans('notification.node_renewal_blade', ['nodes' => $notification->data['nodes']]) }}
+            </h6>
+            <time class="media-meta" datetime="{{ $notification->created_at }}">{{ $notification->created_at->diffForHumans() }}</time>
+        </div>
+    </div>
+</a>

+ 4 - 9
resources/views/user/services.blade.php

@@ -151,15 +151,10 @@
     <script src="assets/global/js/Plugin/ionrangeslider.js"></script>
     <script>
         function itemControl(value) {
-            if (value === 1) {
-                $('.charge_credit').show();
-                $('#change_btn').hide();
-                $('#charge_coupon_code').hide();
-            } else {
-                $('.charge_credit').hide();
-                $('#charge_coupon_code').show();
-                $('#change_btn').show();
-            }
+            const control = value === 1;
+            $('.charge_credit').toggle(control);
+            $('#change_btn').toggle(!control);
+            $('#charge_coupon_code').toggle(!control);
         }
 
         $(document).ready(function() {

部分文件因为文件数量过多而无法显示