Anankke 1 gadu atpakaļ
vecāks
revīzija
cbed396eda
90 mainītis faili ar 1807 papildinājumiem un 1462 dzēšanām
  1. 0 1
      .github/FUNDING.yml
  2. 11 0
      .github/dependabot.yml
  3. 0 36
      .github/workflows/lint.yml
  4. 0 37
      .github/workflows/minify.yml
  5. 0 39
      .github/workflows/sonarcloud.yml
  6. 0 22
      .github/workflows/stale.yml
  7. 0 48
      .github/workflows/unit.yaml
  8. 0 25
      .github/workflows/update_ota_api.yml
  9. 1 0
      .gitignore
  10. 6 10
      README.md
  11. 1 1
      app/predefine.php
  12. 8 5
      app/routes.php
  13. 2 1
      composer.json
  14. 230 169
      composer.lock
  15. 5 5
      config/.config.example.php
  16. 32 8
      config/clients.json
  17. 54 0
      config/settings.json
  18. 18 15
      db/migrations/2023020100-init.php
  19. 39 0
      db/migrations/2024061600-update_price_type.php
  20. 1 1
      public/index.php
  21. 22 3
      resources/views/tabler/admin/header.tpl
  22. 20 10
      resources/views/tabler/admin/setting/billing.tpl
  23. 25 0
      resources/views/tabler/admin/setting/email.tpl
  24. 41 2
      resources/views/tabler/admin/setting/llm.tpl
  25. 30 78
      resources/views/tabler/admin/ticket/view.tpl
  26. 120 91
      resources/views/tabler/admin/user/edit.tpl
  27. 0 4
      resources/views/tabler/gateway/epay.tpl
  28. 3 4
      resources/views/tabler/gateway/f2f.tpl
  29. 4 17
      resources/views/tabler/gateway/paypal.tpl
  30. 10 8
      resources/views/tabler/gateway/stripe.tpl
  31. 18 2
      resources/views/tabler/header.tpl
  32. 2 2
      resources/views/tabler/tinymce.tpl
  33. 5 2
      resources/views/tabler/user/edit.tpl
  34. 22 3
      resources/views/tabler/user/header.tpl
  35. 119 45
      resources/views/tabler/user/index.tpl
  36. 1 1
      resources/views/tabler/user/invoice/index.tpl
  37. 0 2
      resources/views/tabler/user/invoice/view.tpl
  38. 1 1
      resources/views/tabler/user/order/index.tpl
  39. 33 35
      resources/views/tabler/user/ticket/index.tpl
  40. 21 21
      resources/views/tabler/user/ticket/view.tpl
  41. 2 2
      src/Command/ClientDownload.php
  42. 1 1
      src/Command/Migration.php
  43. 67 61
      src/Controllers/Admin/Setting/BillingController.php
  44. 13 26
      src/Controllers/Admin/Setting/CaptchaController.php
  45. 13 20
      src/Controllers/Admin/Setting/CronController.php
  46. 13 51
      src/Controllers/Admin/Setting/EmailController.php
  47. 13 21
      src/Controllers/Admin/Setting/FeatureController.php
  48. 16 50
      src/Controllers/Admin/Setting/ImController.php
  49. 13 24
      src/Controllers/Admin/Setting/LlmController.php
  50. 13 15
      src/Controllers/Admin/Setting/RefController.php
  51. 13 22
      src/Controllers/Admin/Setting/RegController.php
  52. 13 12
      src/Controllers/Admin/Setting/SubController.php
  53. 13 15
      src/Controllers/Admin/Setting/SupportController.php
  54. 69 74
      src/Controllers/Admin/TicketController.php
  55. 36 22
      src/Controllers/Admin/UserController.php
  56. 7 3
      src/Controllers/User/ClientController.php
  57. 2 2
      src/Controllers/User/InfoController.php
  58. 3 3
      src/Controllers/User/OrderController.php
  59. 12 24
      src/Controllers/User/TicketController.php
  60. 38 22
      src/Models/Config.php
  61. 1 1
      src/Models/Node.php
  62. 1 1
      src/Models/Paylist.php
  63. 1 2
      src/Models/User.php
  64. 1 1
      src/Services/Bot/Telegram/Callback.php
  65. 2 0
      src/Services/DynamicRate.php
  66. 1 1
      src/Services/Exchange.php
  67. 25 12
      src/Services/Gateway/AlipayF2F.php
  68. 0 10
      src/Services/Gateway/Base.php
  69. 25 10
      src/Services/Gateway/Epay.php
  70. 40 26
      src/Services/Gateway/PayPal.php
  71. 38 19
      src/Services/Gateway/Stripe.php
  72. 10 6
      src/Services/I18n.php
  73. 6 15
      src/Services/LLM.php
  74. 36 20
      src/Services/LLM/Anthropic.php
  75. 59 0
      src/Services/LLM/AwsBedrock.php
  76. 1 1
      src/Services/LLM/Base.php
  77. 27 15
      src/Services/LLM/CloudflareWorkersAI.php
  78. 44 23
      src/Services/LLM/GoogleAI.php
  79. 27 15
      src/Services/LLM/HuggingFace.php
  80. 32 15
      src/Services/LLM/OpenAI.php
  81. 44 20
      src/Services/LLM/VertexAI.php
  82. 6 2
      src/Services/Mail.php
  83. 31 0
      src/Services/Mail/Resend.php
  84. 4 16
      src/Services/Payment.php
  85. 2 0
      src/Services/View.php
  86. 5 5
      src/Utils/Cookie.php
  87. 1 1
      src/Utils/Tools.php
  88. 59 0
      tests/App/Services/I18nTest.php
  89. 2 0
      tests/App/Services/ViewTest.php
  90. 1 1
      tests/App/Utils/ToolsTest.php

+ 0 - 1
.github/FUNDING.yml

@@ -1 +0,0 @@
-patreon: catdev

+ 11 - 0
.github/dependabot.yml

@@ -0,0 +1,11 @@
+version: 2
+updates:
+  - package-ecosystem: "composer"
+    directory: "/"
+    schedule:
+      interval: "monthly"
+
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "weekly"

+ 0 - 36
.github/workflows/lint.yml

@@ -1,36 +0,0 @@
-name: Lint PHP Code
-on:
-  push:
-  pull_request:
-    types: [opened, reopened, synchronize]
-
-jobs:
-  php-file-changed:
-    runs-on: ubuntu-latest
-    outputs:
-      php: ${{ steps.filter.outputs.php }}
-    steps:
-    - uses: actions/checkout@v4
-      with:
-        fetch-depth: 0
-    - uses: dorny/paths-filter@v3
-      id: filter
-      with:
-        filters: |
-          php:
-            - '**/*.php'
-  lint:
-    needs: php-file-changed
-    if: ${{ needs.php-file-changed.outputs.php == 'true' }}
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v4
-        with:
-          fetch-depth: 0
-      - uses: shivammathur/setup-php@v2
-        with:
-          php-version: 8.3'
-      - run: |
-          composer install --no-interaction --no-progress --no-suggest --quiet
-          php vendor/bin/phpinsights analyse --no-interaction --format=github-action \
-          --min-style=100 --min-architecture=100 --min-quality=100

+ 0 - 37
.github/workflows/minify.yml

@@ -1,37 +0,0 @@
-name: Auto Minify CSS/JS file
-
-on:
-  push:
-    paths:
-      - 'public/assets/css/**'
-      - 'public/assets/js/**'
-      - 'public/theme/**'
-
-jobs:
-  build:
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v4
-        with:
-          token: ${{ secrets.GITHUB_TOKEN }}
-      - name: Auto minify tabler theme CSS file
-        uses: nizarmah/auto-minify@v3
-        with:
-          directory: 'public/theme/tabler/css'
-      - name: Auto minify tabler theme JS file
-        uses: nizarmah/auto-minify@v3
-        with:
-          directory: 'public/theme/tabler/js'
-      - name: Auto minify asset CSS file
-        uses: nizarmah/auto-minify@v3
-        with:
-          directory: 'public/assets/css'
-      - name: Auto minify asset JS file
-        uses: nizarmah/auto-minify@v3
-        with:
-          directory: 'public/assets/js'
-      - name: Auto committing minified files
-        uses: stefanzweifel/git-auto-commit-action@v5
-        with:
-          repository: 'public'
-          commit_message: "chore: auto minified theme CSS/JS files"

+ 0 - 39
.github/workflows/sonarcloud.yml

@@ -1,39 +0,0 @@
-name: SonarCloud
-
-on:
-  push:
-  pull_request:
-    types: [ opened, reopened, synchronize ]
-
-permissions:
-  pull-requests: read
-
-jobs:
-  Analysis:
-    runs-on: ubuntu-latest
-    steps:
-      - name: Checkout
-        uses: actions/checkout@v4
-        with:
-          fetch-depth: 0
-      - name: Setup PHP with Xdebug
-        uses: shivammathur/setup-php@v2
-        with:
-          php-version: 8.3
-          coverage: xdebug
-      - name: Install dependencies with composer
-        run: composer update --no-interaction --no-progress --no-suggest --quiet
-      - name: Run tests with phpunit/phpunit
-        run: vendor/bin/phpunit --coverage-clover=coverage.xml
-      - name: Fix code coverage paths
-        run: sed -i 's@'$GITHUB_WORKSPACE'@/github/workspace@g' coverage.xml
-      - name: Analyze with SonarCloud
-        uses: SonarSource/sonarcloud-github-action@master
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
-        with:
-          args:
-            -Dsonar.projectKey=SSPanel-NeXT_NeXT-Panel-Dev
-            -Dsonar.organization=sspanel-next
-            -Dsonar.php.coverage.reportPaths=coverage.xml

+ 0 - 22
.github/workflows/stale.yml

@@ -1,22 +0,0 @@
-name: Mark stale issues and pull requests
-
-on:
-  schedule:
-  - cron: '0 0 * * *'
-
-jobs:
-  stale:
-    runs-on: ubuntu-latest
-    permissions:
-      issues: write
-      pull-requests: write
-    steps:
-    - uses: actions/stale@v9
-      with:
-        repo-token: ${{ secrets.GITHUB_TOKEN }}
-        days-before-stale: 180
-        days-before-close: 14
-        stale-issue-message: 'This issue has had no activity for over 180 days and will be closed in 14 days.'
-        stale-pr-message: 'This PR has had no activity for over 180 days and will be closed in 14 days.'
-        stale-issue-label: 'staled'
-        stale-pr-label: 'staled'

+ 0 - 48
.github/workflows/unit.yaml

@@ -1,48 +0,0 @@
-name: PHP Unit Tests
-
-on:
-  push:
-  pull_request:
-    types: [ opened, reopened, synchronize ]
-
-jobs:
-  php-file-changed:
-    runs-on: ubuntu-latest
-    outputs:
-      php: ${{ steps.filter.outputs.php }}
-    steps:
-      - uses: actions/checkout@v4
-        with:
-          fetch-depth: 0
-      - uses: dorny/paths-filter@v3
-        id: filter
-        with:
-          filters: |
-            php:
-              - '**/*.php'
-
-  php82:
-    needs: php-file-changed
-    if: ${{ needs.php-file-changed.outputs.php == 'true' }}
-    runs-on: ubuntu-latest
-    steps:
-    - uses: actions/checkout@v4
-    - uses: shivammathur/setup-php@v2
-      with:
-        php-version: 8.2
-    - run: |
-        composer install --no-interaction --no-progress --no-suggest --quiet
-        php vendor/bin/phpunit
-
-  php83:
-    needs: php-file-changed
-    if: ${{ needs.php-file-changed.outputs.php == 'true' }}
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v4
-      - uses: shivammathur/setup-php@v2
-        with:
-          php-version: 8.3
-      - run: |
-          composer install --no-interaction --no-progress --no-suggest --quiet
-          php vendor/bin/phpunit

+ 0 - 25
.github/workflows/update_ota_api.yml

@@ -1,25 +0,0 @@
-name: Update OTA API Version
-on:
-  push:
-    tags:
-      - '*'
-
-jobs:
-  update_ota_api:
-    name: Update Workers KV value
-    runs-on: ubuntu-latest
-    steps:
-      - name: Request Cloudflare API
-        run: |
-          echo "Updating OTA API version to ${{ github.ref_name }}"
-          curl --request PUT \
-            --url https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/storage/kv/namespaces/${{ secrets.CLOUDFLARE_KV_NAMESPACE_ID }}/bulk \
-            --header 'Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}' \
-            --header 'Content-Type: application/json' \
-            --data '[
-              {
-                "base64": false,
-                "key": "latest_next_version",
-                "value": "${{ github.ref_name }}"
-              }
-            ]'

+ 1 - 0
.gitignore

@@ -1,3 +1,4 @@
+*~
 .idea
 .htaccess
 */.DS_Store

+ 6 - 10
README.md

@@ -1,18 +1,14 @@
 <img src=".github/next_1000.png" alt="next" width="600"/>
 
 ![GitHub repo size](https://img.shields.io/github/repo-size/SSPanel-NeXT/NeXT-Panel-Dev?style=flat-square)
-![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/SSPanel-NeXT/NeXT-Panel-Dev/lint.yml?branch=dev&label=lint&style=flat-square)
-![Sonar Quality Gate](https://img.shields.io/sonar/quality_gate/SSPanel-NeXT_NeXT-Panel-Dev/dev?server=https%3A%2F%2Fsonarcloud.io&style=flat-square)
+![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/SSPanel-NeXT/NeXT-Panel-Dev/lint.yml?branch=dev&label=Lint&style=flat-square)
+![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/SSPanel-NeXT/NeXT-Panel-Dev/unit.yaml?branch=dev&label=Unit%20Test&style=flat-square)
 ![Sonar Coverage](https://img.shields.io/sonar/coverage/SSPanel-NeXT_NeXT-Panel-Dev/dev?server=https%3A%2F%2Fsonarcloud.io&style=flat-square)
+![Sonar Quality Gate](https://img.shields.io/sonar/quality_gate/SSPanel-NeXT_NeXT-Panel-Dev/dev?server=https%3A%2F%2Fsonarcloud.io&style=flat-square)
 
 [![X (formerly Twitter)](https://img.shields.io/twitter/url?url=https%3A%2F%2Ftwitter.com%2FSSPanel_NeXT)](https://twitter.com/SSPanel_NeXT)
 [![Discord](https://img.shields.io/discord/1049692075085549600?color=5865F2&label=Discord&style=flat-square)](https://discord.gg/A7uFKCvf8V)
 
-
-# Migration Notice: SSPanel-UIM
-
-## We would like to kindly inform you that the project has been migrated to **[SSPanel-NeXT](https://github.com/SSPanel-NeXT/NeXT-Server)**. We invite you to visit our new repository to access the latest updates and developments.
-
 ## TL;DR
 
 NeXT Panel is a multipurpose proxy service management system designed for Shadowsocks(2022) / Vmess / Trojan / TUIC protocol.
@@ -21,14 +17,14 @@ NeXT Panel is a multipurpose proxy service management system designed for Shadow
 
 - Integrate multiple payment systems such as Alipay F2F, PayPal, Stripe, etc.
 - Support multiple mail services, built-in mail queue function, no third-party components are required to use
-- Built-in tabler theme based on Bootstrap 5, template engine support
+- Built-in tabler theme based on Bootstrap 5, Smarty/Twig template engine support
 - Support Shadowsocks 2022, TUIC, and other latest proxy protocols
 - Universal subscription interface, one-click json/clash/sip008/sing-box format subscription distribution
 - Custom node configuration, modular subscription system, support multiple client-specific subscription formats
 - Refactored store system, support billing modes including but not limited to annual/monthly, pay-as-you-go, access type billing, etc.
 - Refactored scheduled task system, one command can automatically complete all scheduled tasks
 - Deep integration of large language models, support intelligent replies to tickets, document generation, and other functions
-- One-click access to OpenAI, Google AI, Vertex AI, Hugging Face Hosted API, Cloudflare Workers AI, Anthropic, and other large language model services
+- One-click access to OpenAI, Google AI, Vertex AI, Hugging Face Hosted API, Cloudflare Workers AI, Anthropic, AWS Bedrock and other large language model services
 
 ## Installation
 
@@ -53,7 +49,7 @@ NeXT Panel requires the following programs to be installed and run normally:
 
 ## Support Developers
 
-Sadly, there is no three-letter agency that wants to sponsor this project yet(/s), so the development is driven by the community and unpaid volunteers.
+Sadly, there is no three-letter agency that wants to sponsor this project yet (/s), so the development is driven by the community and unpaid volunteers.
 Nothing will be put behind the paywall or require a donation to use, but the CI/Linux Mirror/CDN server doesn't grow on trees, if you are interested in supporting the project, you can support devs using the following methods:
 
 <a href="https://www.patreon.com/catdev">Patreon (One time or monthly)</a>

+ 1 - 1
app/predefine.php

@@ -5,5 +5,5 @@ declare(strict_types=1);
 // Global constants
 const BASE_PATH = __DIR__ . '/..';
 const PANEL_NAME = 'NeXT-Panel';
-const PANEL_VERSION = '24.2.0';
+const PANEL_VERSION = '24.5.0';
 const OTA_API_URL = 'https://api.nextpanel.dev/v1/version/latest';

+ 8 - 5
app/routes.php

@@ -55,7 +55,7 @@ return static function (Slim\App $app): void {
         $group->get('/ticket/create', App\Controllers\User\TicketController::class . ':create');
         $group->post('/ticket', App\Controllers\User\TicketController::class . ':add');
         $group->get('/ticket/{id:[0-9]+}/view', App\Controllers\User\TicketController::class . ':detail');
-        $group->put('/ticket/{id:[0-9]+}', App\Controllers\User\TicketController::class . ':update');
+        $group->post('/ticket/{id:[0-9]+}', App\Controllers\User\TicketController::class . ':reply');
         // 资料编辑
         $group->get('/edit', App\Controllers\User\InfoController::class . ':index');
         $group->post('/edit/email', App\Controllers\User\InfoController::class . ':updateEmail');
@@ -108,7 +108,6 @@ return static function (Slim\App $app): void {
     $app->group('/payment', static function (RouteCollectorProxy $group): void {
         $group->get('/notify/{type}', App\Services\Payment::class . ':notify');
         $group->post('/notify/{type}', App\Services\Payment::class . ':notify');
-        $group->post('/status/{type}', App\Services\Payment::class . ':getStatus');
     });
     // Auth
     $app->group('/auth', static function (RouteCollectorProxy $group): void {
@@ -151,9 +150,9 @@ return static function (Slim\App $app): void {
         $group->get('/ticket', App\Controllers\Admin\TicketController::class . ':index');
         $group->post('/ticket', App\Controllers\Admin\TicketController::class . ':add');
         $group->get('/ticket/{id:[0-9]+}/view', App\Controllers\Admin\TicketController::class . ':detail');
-        $group->put('/ticket/{id:[0-9]+}/close', App\Controllers\Admin\TicketController::class . ':close');
-        $group->put('/ticket/{id:[0-9]+}', App\Controllers\Admin\TicketController::class . ':update');
-        $group->put('/ticket/{id:[0-9]+}/ai', App\Controllers\Admin\TicketController::class . ':updateAI');
+        $group->post('/ticket/{id:[0-9]+}/close', App\Controllers\Admin\TicketController::class . ':close');
+        $group->post('/ticket/{id:[0-9]+}', App\Controllers\Admin\TicketController::class . ':reply');
+        $group->post('/ticket/{id:[0-9]+}/llm_reply', App\Controllers\Admin\TicketController::class . ':llmReply');
         $group->delete('/ticket/{id:[0-9]+}', App\Controllers\Admin\TicketController::class . ':delete');
         $group->post('/ticket/ajax', App\Controllers\Admin\TicketController::class . ':ajax');
         // Ann
@@ -230,6 +229,10 @@ return static function (Slim\App $app): void {
             '/setting/billing/set_stripe_webhook',
             App\Controllers\Admin\Setting\BillingController::class . ':setStripeWebhook'
         );
+        $group->post(
+            '/setting/billing/set_paypal_webhook',
+            App\Controllers\Admin\Setting\BillingController::class . ':setPaypalWebhook'
+        );
         $group->get('/setting/captcha', App\Controllers\Admin\Setting\CaptchaController::class . ':index');
         $group->post('/setting/captcha', App\Controllers\Admin\Setting\CaptchaController::class . ':save');
         $group->get('/setting/cron', App\Controllers\Admin\Setting\CronController::class . ':index');

+ 2 - 1
composer.json

@@ -34,6 +34,7 @@
         "phpmailer/phpmailer": "^6",
         "postal/postal": "^2",
         "ramsey/uuid": "^4",
+        "resend/resend-php": "^0.12.0",
         "sendgrid/sendgrid": "^8",
         "sentry/sdk": "^4",
         "slim/http": "^1",
@@ -63,7 +64,7 @@
     "require-dev": {
         "dg/bypass-finals": "^1",
         "nunomaduro/phpinsights": "*",
-        "phpunit/phpunit": "^10"
+        "phpunit/phpunit": "^10|^11"
     },
     "scripts": {
         "update-dev-windows": [

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 230 - 169
composer.lock


+ 5 - 5
config/.config.example.php

@@ -6,7 +6,7 @@ $_ENV['pwdMethod'] = 'bcrypt'; // 密码加密 可选 bcrypt, argon2i, argon2id
 $_ENV['salt'] = '';            // bcrypt/argon2i/argon2id 会忽略此项
 
 $_ENV['debug'] = false;                  // debug模式开关,生产环境请保持为false
-$_ENV['appName'] = 'SSPanel-UIM';         // 站点名称
+$_ENV['appName'] = 'NeXT Panel';         // 站点名称
 $_ENV['baseUrl'] = 'https://example.com'; // 站点地址,必须以https://开头,不要以/结尾
 
 // WebAPI
@@ -21,9 +21,9 @@ $_ENV['checkNodeIp'] = true;           // 是否webapi验证节点ip
 // db_socket 例:/var/run/mysqld/mysqld.sock(需使用绝对地址)
 $_ENV['db_host'] = '';
 $_ENV['db_socket'] = '';
-$_ENV['db_database'] = 'sspanel'; // 数据库名
+$_ENV['db_database'] = 'nextpanel'; // 数据库名
 $_ENV['db_username'] = 'root';    // 数据库用户名
-$_ENV['db_password'] = 'sspanel'; // 用户密码
+$_ENV['db_password'] = 'nextpanel'; // 用户密码
 $_ENV['db_port'] = '3306';        // 端口
 #读写分离相关配置
 $_ENV['enable_db_rw_split'] = false; // 是否开启读写分离
@@ -86,8 +86,8 @@ $_ENV['auto_detect_ban_time'] = 60;          // 每次封禁的时长 (分钟)
 //节点检测---------------------------------------------------------------------------------------------------------------
 //TODO: move these settings to DB
 #GFW检测
-$_ENV['detect_gfw_port'] = 443;                                                    //所有节点服务器都打开的TCP端口
-$_ENV['detect_gfw_url'] = 'http://example.com:8080/v1/tcping?ip={ip}&port={port}'; //检测节点是否被gfw墙了的API的URL
+$_ENV['detect_gfw_port'] = 443;                                                //所有节点服务器都打开的TCP端口
+$_ENV['detect_gfw_url'] = 'https://example.com/v1/tcping?ip={ip}&port={port}'; // https://github.com/SSPanel-NeXT/NetStatus-API-Go
 #离线检测
 $_ENV['enable_detect_offline'] = true;
 

+ 32 - 8
config/clients.json

@@ -1,22 +1,22 @@
 {
     "clients":[
         {
-            "name": "Clash Verge",
+            "name": "Clash Nyanpasu",
             "tagMethod": "github_release",
-            "gitRepo": "clash-verge-rev/clash-verge-rev",
+            "gitRepo": "LibNyanpasu/clash-nyanpasu",
             "savePath": "public/clients/",
             "downloads": [
                 {
-                    "sourceName": "Clash.Verge_%tagName1%_x64-setup.exe",
-                    "saveName": "Clash.Verge.exe"
+                    "sourceName": "Clash.Nyanpasu_%tagName1%_x64-setup.exe",
+                    "saveName": "Clash.Nyanpasu.exe"
                 },
                 {
-                    "sourceName": "Clash.Verge_%tagName1%_aarch64.dmg",
-                    "saveName": "Clash.Verge_aarch64.dmg"
+                    "sourceName": "clash-nyanpasu_%tagName1%_amd64.AppImage",
+                    "saveName": "Clash.Nyanpasu.AppImage"
                 },
                 {
-                    "sourceName": "clash-verge_%tagName1%_amd64.AppImage.tar.gz",
-                    "saveName": "Clash.Verge.AppImage.tar.gz"
+                    "sourceName": "Clash.Nyanpasu_%tagName1%_aarch64.dmg",
+                    "saveName": "Clash.Nyanpasu_aarch64.dmg"
                 }
             ]
         },
@@ -47,6 +47,30 @@
                     "saveName": "SFM.zip"
                 }
             ]
+        },
+        {
+            "name": "Hiddify",
+            "tagMethod": "github_release",
+            "gitRepo": "hiddify/hiddify-next",
+            "savePath": "public/clients/",
+            "downloads": [
+                {
+                    "sourceName": "Hiddify-Android-universal.apk",
+                    "saveName": "Hiddify.apk"
+                },
+                {
+                    "sourceName": "Hiddify-Linux-x64.AppImage",
+                    "saveName": "Hiddify.AppImage"
+                },
+                {
+                    "sourceName": "Hiddify-MacOS.dmg",
+                    "saveName": "Hiddify.dmg"
+                },
+                {
+                    "sourceName": "Hiddify-Windows-Setup-x64.exe",
+                    "saveName": "Hiddify.exe"
+                }
+            ]
         }
     ]
 }

+ 54 - 0
config/settings.json

@@ -530,6 +530,24 @@
         "default": "",
         "mark": "AlibabaCloud DM From Alias"
     },
+    {
+        "item": "resend_api_key",
+        "value": "",
+        "class": "email",
+        "is_public": 0,
+        "type": "string",
+        "default": "",
+        "mark": "Resend API Key"
+    },
+    {
+        "item": "resend_from",
+        "value": "",
+        "class": "email",
+        "is_public": 0,
+        "type": "string",
+        "default": "",
+        "mark": "Resend From"
+    },
     {
         "item": "email_verify_code_ttl",
         "value": "3600",
@@ -1672,5 +1690,41 @@
         "type": "string",
         "default": "claude-3-sonnet-20240229",
         "mark": "Anthropic Model ID"
+    },
+    {
+        "item": "aws_bedrock_access_key_id",
+        "value": "",
+        "class": "llm",
+        "is_public": 0,
+        "type": "string",
+        "default": "",
+        "mark": "AWS Bedrock Access Key ID"
+    },
+    {
+        "item": "aws_bedrock_access_key_secret",
+        "value": "",
+        "class": "llm",
+        "is_public": 0,
+        "type": "string",
+        "default": "",
+        "mark": "AWS Bedrock Access Key Secret"
+    },
+    {
+        "item": "aws_bedrock_region",
+        "value": "us-west-2",
+        "class": "llm",
+        "is_public": 0,
+        "type": "string",
+        "default": "us-west-2",
+        "mark": "AWS Bedrock Region"
+    },
+    {
+        "item": "aws_bedrock_model_id",
+        "value": "meta.llama3-8b-instruct-v1:0",
+        "class": "llm",
+        "is_public": 0,
+        "type": "string",
+        "default": "meta.llama3-8b-instruct-v1:0",
+        "mark": "AWS Bedrock Model ID"
     }
 ]

+ 18 - 15
db/migrations/2023020100-init.php

@@ -120,7 +120,7 @@ return new class() implements MigrationInterface {
                 `user_id` bigint(20) unsigned NOT NULL DEFAULT 0 COMMENT '归属用户ID',
                 `order_id` bigint(20) unsigned NOT NULL DEFAULT 0 COMMENT '订单ID',
                 `content` longtext NOT NULL DEFAULT '{}' COMMENT '账单内容' CHECK (json_valid(`content`)),
-                `price` double unsigned NOT NULL DEFAULT 0 COMMENT '账单金额',
+                `price` decimal(12,2) unsigned NOT NULL DEFAULT 0 COMMENT '账单金额',
                 `status` varchar(255) NOT NULL DEFAULT '' COMMENT '账单状态',
                 `create_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
                 `update_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '更新时间',
@@ -160,12 +160,12 @@ return new class() implements MigrationInterface {
                 `server` varchar(255) NOT NULL DEFAULT '' COMMENT '节点地址',
                 `custom_config` longtext NOT NULL DEFAULT '{}' COMMENT '自定义配置' CHECK (json_valid(`custom_config`)),
                 `sort` tinyint(2) unsigned NOT NULL DEFAULT 14 COMMENT '节点类型',
-                `traffic_rate` double unsigned NOT NULL DEFAULT 1 COMMENT '流量倍率',
+                `traffic_rate` decimal(5,2) unsigned NOT NULL DEFAULT 1 COMMENT '流量倍率',
                 `is_dynamic_rate` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '是否启用动态流量倍率',
                 `dynamic_rate_type` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '动态流量倍率计算方式',
                 `dynamic_rate_config` longtext NOT NULL DEFAULT '{}' COMMENT '动态流量倍率配置' CHECK (json_valid(`custom_config`)),
                 `node_class` smallint(5) unsigned NOT NULL DEFAULT 0 COMMENT '节点等级',
-                `node_speedlimit` double unsigned NOT NULL DEFAULT 0 COMMENT '节点限速',
+                `node_speedlimit` smallint(6) unsigned NOT NULL DEFAULT 0 COMMENT '节点限速',
                 `node_bandwidth` bigint(20) unsigned NOT NULL DEFAULT 0 COMMENT '节点流量',
                 `node_bandwidth_limit` bigint(20) unsigned NOT NULL DEFAULT 0 COMMENT '节点流量限制',
                 `bandwidthlimit_resetday` tinyint(2) unsigned NOT NULL DEFAULT 0 COMMENT '流量重置日',
@@ -209,7 +209,7 @@ return new class() implements MigrationInterface {
                 `product_name` varchar(255) NOT NULL DEFAULT '' COMMENT '商品名称',
                 `product_content` longtext NOT NULL DEFAULT '{}' COMMENT '商品内容' CHECK (json_valid(`product_content`)),
                 `coupon` varchar(255) NOT NULL DEFAULT '' COMMENT '订单优惠码',
-                `price` double unsigned NOT NULL DEFAULT 0 COMMENT '订单金额',
+                `price` decimal(12,2) unsigned NOT NULL DEFAULT 0 COMMENT '订单金额',
                 `status` varchar(255) NOT NULL DEFAULT '' COMMENT '订单状态',
                 `create_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
                 `update_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '更新时间',
@@ -241,7 +241,7 @@ return new class() implements MigrationInterface {
                 `total` decimal(12,2) NOT NULL DEFAULT 0 COMMENT '总金额',
                 `status` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '状态',
                 `invoice_id` bigint(20) unsigned NOT NULL DEFAULT 0 COMMENT '账单ID',
-                `tradeno` varchar(255) NOT NULL DEFAULT '' COMMENT '网关单号',
+                `tradeno` varchar(255) NOT NULL DEFAULT '' COMMENT '网关识别码',
                 `gateway` varchar(255) NOT NULL DEFAULT '' COMMENT '支付网关',
                 `datetime` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
                 PRIMARY KEY (`id`),
@@ -255,7 +255,7 @@ return new class() implements MigrationInterface {
                 `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '商品ID',
                 `type` varchar(255) NOT NULL DEFAULT 'tabp' COMMENT '类型',
                 `name` varchar(255) NOT NULL DEFAULT '' COMMENT '名称',
-                `price` double unsigned NOT NULL DEFAULT 0 COMMENT '售价',
+                `price` decimal(12,2) unsigned NOT NULL DEFAULT 0 COMMENT '售价',
                 `content` longtext NOT NULL DEFAULT '{}' COMMENT '内容' CHECK (json_valid(`content`)),
                 `limit` longtext NOT NULL DEFAULT '{}' COMMENT '购买限制' CHECK (json_valid(`limit`)),
                 `status` tinyint(1) unsigned NOT NULL DEFAULT 1 COMMENT '销售状态',
@@ -335,17 +335,17 @@ return new class() implements MigrationInterface {
                 `last_check_in_time` int(11) unsigned DEFAULT 0 COMMENT '最后签到时间',
                 `last_login_time` int(11) unsigned DEFAULT 0 COMMENT '最后登录时间',
                 `reg_date` datetime NOT NULL DEFAULT '1989-06-04 00:05:00' COMMENT '注册时间',
-                `money` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '账户余额',
+                `money` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '账户余额',
                 `ref_by` bigint(20) unsigned NOT NULL DEFAULT 0 COMMENT '邀请人ID',
                 `method` varchar(255) NOT NULL DEFAULT 'aes-128-gcm' COMMENT '加密方式',
                 `reg_ip` varchar(255) NOT NULL DEFAULT '127.0.0.1' COMMENT '注册IP',
-                `node_speedlimit` double NOT NULL DEFAULT 0 COMMENT '用户限速',
+                `node_speedlimit` smallint(6) unsigned NOT NULL DEFAULT 0 COMMENT '用户限速',
                 `node_iplimit` smallint(6) unsigned NOT NULL DEFAULT 0 COMMENT '同时可连接IP数',
                 `is_admin` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '是否管理员',
-                `im_type` smallint(6) unsigned NOT NULL DEFAULT 0 COMMENT '联系方式类型',
+                `im_type` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '联系方式类型',
                 `im_value` varchar(255) NOT NULL DEFAULT '' COMMENT '联系方式',
-                `contact_method` smallint(6) NOT NULL DEFAULT 1 COMMENT '偏好的联系方式',
-                `daily_mail_enable` tinyint(1) NOT NULL DEFAULT 0 COMMENT '每日报告开关',
+                `contact_method` tinyint(3) unsigned NOT NULL DEFAULT 1 COMMENT '偏好的联系方式',
+                `daily_mail_enable` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '每日报告开关',
                 `class` smallint(5) unsigned NOT NULL DEFAULT 0 COMMENT '等级',
                 `class_expire` datetime NOT NULL DEFAULT '1989-06-04 00:05:00' COMMENT '等级过期时间',
                 `theme` varchar(255) NOT NULL DEFAULT 'tabler' COMMENT '网站主题',
@@ -371,6 +371,10 @@ return new class() implements MigrationInterface {
                 UNIQUE KEY `ga_token` (`ga_token`),
                 UNIQUE KEY `api_token` (`api_token`),
                 KEY `is_admin` (`is_admin`),
+                KEY `contact_method` (`contact_method`),
+                KEY `class` (`class`),
+                KEY `class_expire` (`class_expire`),
+                KEY `node_group` (`node_group`),
                 KEY `is_banned` (`is_banned`),
                 KEY `is_shadow_banned` (`is_shadow_banned`),
                 KEY `is_inactive` (`is_inactive`)
@@ -385,7 +389,6 @@ return new class() implements MigrationInterface {
                 `create_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
                 `expire_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '过期时间',
                 PRIMARY KEY (`id`),
-                KEY `id` (`id`),
                 UNIQUE KEY `code` (`code`),
                 KEY `expire_time` (`expire_time`)
             ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -402,9 +405,9 @@ return new class() implements MigrationInterface {
             CREATE TABLE `user_money_log` (
                 `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '记录ID',
                 `user_id` bigint(20) unsigned NOT NULL DEFAULT 0 COMMENT '用户ID',
-                `before` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '用户变动前账户余额',
-                `after` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '用户变动后账户余额',
-                `amount` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '变动总额',
+                `before` decimal(12,2) NOT NULL DEFAULT 0 COMMENT '用户变动前账户余额',
+                `after` decimal(12,2) NOT NULL DEFAULT 0 COMMENT '用户变动后账户余额',
+                `amount` decimal(12,2) NOT NULL DEFAULT 0 COMMENT '变动总额',
                 `remark` text NOT NULL DEFAULT '' COMMENT '备注',
                 `create_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
                 PRIMARY KEY (`id`),

+ 39 - 0
db/migrations/2024061600-update_price_type.php

@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Interfaces\MigrationInterface;
+use App\Services\DB;
+
+return new class() implements MigrationInterface {
+    public function up(): int
+    {
+        DB::getPdo()->exec("
+            ALTER TABLE invoice MODIFY COLUMN `price` decimal(12,2) unsigned NOT NULL DEFAULT 0 COMMENT '账单金额';
+            ALTER TABLE node MODIFY COLUMN `traffic_rate` decimal(5,2) unsigned NOT NULL DEFAULT 1 COMMENT '流量倍率';
+            ALTER TABLE node MODIFY COLUMN `node_speedlimit` smallint(6) unsigned NOT NULL DEFAULT 0 COMMENT '节点限速';
+            ALTER TABLE `order` MODIFY COLUMN `price` decimal(12,2) unsigned NOT NULL DEFAULT 0 COMMENT '订单金额';
+            ALTER TABLE paylist MODIFY COLUMN `tradeno` varchar(255) NOT NULL DEFAULT '' COMMENT '网关识别码';
+            ALTER TABLE product MODIFY COLUMN `price` decimal(12,2) unsigned NOT NULL DEFAULT 0 COMMENT '售价';
+            ALTER TABLE user MODIFY COLUMN `money` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '账户余额';
+            ALTER TABLE user MODIFY COLUMN `node_speedlimit` smallint(6) unsigned NOT NULL DEFAULT 0 COMMENT '用户限速';
+            ALTER TABLE user MODIFY COLUMN `im_type` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '联系方式类型';
+            ALTER TABLE user MODIFY COLUMN `contact_method` tinyint(3) unsigned NOT NULL DEFAULT 1 COMMENT '偏好的联系方式';
+            ALTER TABLE user MODIFY COLUMN `daily_mail_enable` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '每日报告开关';
+            ALTER TABLE user ADD KEY IF NOT EXISTS `contact_method` (`contact_method`);
+            ALTER TABLE user ADD KEY IF NOT EXISTS `class` (`class`);
+            ALTER TABLE user ADD KEY IF NOT EXISTS `class_expire` (`class_expire`);
+            ALTER TABLE user ADD KEY IF NOT EXISTS `node_group` (`node_group`);
+            ALTER TABLE user_money_log MODIFY COLUMN `before` decimal(12,2) NOT NULL DEFAULT 0 COMMENT '用户变动前账户余额';
+            ALTER TABLE user_money_log MODIFY COLUMN `after` decimal(12,2) NOT NULL DEFAULT 0 COMMENT '用户变动后账户余额';
+            ALTER TABLE user_money_log MODIFY COLUMN `amount` decimal(12,2) NOT NULL DEFAULT 0 COMMENT '变动总额';
+        ");
+
+        return 2024061600;
+    }
+
+    public function down(): int
+    {
+        return 2024061600;
+    }
+};

+ 1 - 1
public/index.php

@@ -31,7 +31,7 @@ $app = AppFactory::create($response_factory);
 
 $app->add(new ErrorHandler());
 
-$routes = require_once __DIR__ . '/../app/routes.php';
+$routes = require __DIR__ . '/../app/routes.php';
 $routes($app);
 
 $request = ServerRequest::fromGlobals();

+ 22 - 3
resources/views/tabler/admin/header.tpl

@@ -1,5 +1,6 @@
 <!doctype html>
-<html lang="zh">
+<html lang="{$user->locale}"
+        data-bs-theme="{$user->is_dark_mode === 1 ? 'dark' : ($user->is_dark_mode === 2 ? 'auto' : 'light')}">
 
 <head>
     <meta charset="utf-8"/>
@@ -7,6 +8,22 @@
     <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" name="viewport"/>
     <meta name="format-detection" content="telephone=no"/>
     <title>{$config['appName']}</title>
+    <!-- Auto dark mode -->
+    <script>
+        ;(function () {
+            const htmlElement = document.querySelector("html")
+            const theme = htmlElement.getAttribute("data-bs-theme");
+
+            if(theme === 'dark-auto' || theme === 'auto') {
+                function updateTheme() {
+                    htmlElement.setAttribute("data-bs-theme",
+                        window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
+                }
+                window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme)
+                updateTheme()
+            }
+        })()
+    </script>
     <!-- CSS files -->
     <link href="//{$config['jsdelivr_url']}/npm/@tabler/core@latest/dist/css/tabler.min.css" rel="stylesheet"/>
     <link href="//{$config['jsdelivr_url']}/npm/@tabler/icons-webfont@latest/tabler-icons.min.css" rel="stylesheet"/>
@@ -14,7 +31,7 @@
     <script src="//{$config['jsdelivr_url']}/npm/qrcode_js@latest/qrcode.min.js"></script>
     <script src="//{$config['jsdelivr_url']}/npm/clipboard@latest/dist/clipboard.min.js"></script>
     <script src="//{$config['jsdelivr_url']}/npm/jquery/dist/jquery.min.js"></script>
-    <script src="//{$config['jsdelivr_url']}/npm/htmx.org@latest/dist/htmx.min.js"></script>
+    <script src="//{$config['jsdelivr_url']}/npm/htmx.org@v2/dist/htmx.min.js"></script>
     <style>
         .home-subtitle {
             font-size: 14px;
@@ -26,8 +43,10 @@
     </style>
 </head>
 
-{if $user->is_dark_mode}
+{if $user->is_dark_mode === 1}
 <body data-bs-theme="dark">
+{elseif $user->is_dark_mode === 2}
+<body data-bs-theme="auto">
 {else}
 <body>
 {/if}

+ 20 - 10
resources/views/tabler/admin/setting/billing.tpl

@@ -51,15 +51,15 @@
                             <div class="tab-content">
                                 <div class="tab-pane active show" id="gateway">
                                     {foreach $payment_gateways as $key => $value}
-                                        <div class="form-group mb-3 row">
-                                            <div class="row align-items-center">
-                                                <label class="form-label col-3 col-form-label">{$key}</label>
-                                                <label class="col-auto ms-auto form-check form-check-single form-switch">
-                                                    <input id="{$value}_enable" class="form-check-input" type="checkbox"
-                                                           {if in_array($value, $active_payment_gateway)}checked="" {/if}>
-                                                </label>
-                                            </div>
+                                    <div class="form-group mb-3 row">
+                                        <div class="row align-items-center">
+                                            <label class="form-label col-3 col-form-label">{$key}</label>
+                                            <label class="col-auto ms-auto form-check form-check-single form-switch">
+                                                <input id="{$value}_enable" class="form-check-input" type="checkbox"
+                                                       {if in_array($value, $active_payment_gateway)}checked="" {/if}>
+                                            </label>
                                         </div>
+                                    </div>
                                     {/foreach}
                                 </div>
                                 <div class="tab-pane" id="f2f">
@@ -287,6 +287,16 @@
                                                 <input id="paypal_client_secret" type="text" class="form-control"
                                                        value="{$settings['paypal_client_secret']}">
                                             </div>
+                                            <div class="col-auto">
+                                                <button class="btn btn-primary"
+                                                        hx-post="/admin/setting/billing/set_paypal_webhook" hx-swap="none"
+                                                        hx-vals='js:{
+                                                            paypal_client_id: document.getElementById("paypal_client_id").value,
+                                                            paypal_client_secret: document.getElementById("paypal_client_secret").value,
+                                                        }'>
+                                                    Set Webhook
+                                                </button>
+                                            </div>
                                         </div>
                                         <div class="form-group mb-3 row">
                                             <label class="form-label col-3 col-form-label">Currency</label>
@@ -319,10 +329,10 @@
                     dataType: "json",
                     data: {
                         {foreach $update_field as $key}
-                        {$key}: $('#{$key}').val(),
+                            {$key}: $('#{$key}').val(),
                         {/foreach}
                         {foreach $payment_gateways as $key => $value}
-                        {$key}: $("#{$key}_enable").is(":checked"),
+                            {$value}: $("#{$value}_enable").is(":checked"),
                         {/foreach}
                     },
                     success: function (data) {

+ 25 - 0
resources/views/tabler/admin/setting/email.tpl

@@ -57,6 +57,9 @@
                                 <li class="nav-item">
                                     <a href="#alibabacloud" class="nav-link" data-bs-toggle="tab">AlibabaCloud DM</a>
                                 </li>
+                                <li class="nav-item">
+                                    <a href="#resend" class="nav-link" data-bs-toggle="tab">Resend</a>
+                                </li>
                             </ul>
                         </div>
                         <div class="card-body">
@@ -100,6 +103,10 @@
                                                             {if $settings['email_driver'] === "alibabacloud"}selected{/if}>
                                                         AlibabaCloud DM
                                                     </option>
+                                                    <option value="resend"
+                                                            {if $settings['email_driver'] === "resend"}selected{/if}>
+                                                        Resend
+                                                    </option>
                                                 </select>
                                             </div>
                                         </div>
@@ -416,6 +423,24 @@
                                         </div>
                                     </div>
                                 </div>
+                                <div class="tab-pane" id="resend">
+                                    <div class="card-body">
+                                        <div class="form-group mb-3 row">
+                                            <label class="form-label col-3 col-form-label">Api Key</label>
+                                            <div class="col">
+                                                <input id="resend_api_key" type="text" class="form-control"
+                                                       value="{$settings['resend_api_key']}">
+                                            </div>
+                                        </div>
+                                        <div class="form-group mb-3 row">
+                                            <label class="form-label col-3 col-form-label">From</label>
+                                            <div class="col">
+                                                <input id="resend_from" type="text" class="form-control"
+                                                       value="{$settings['resend_from']}">
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
                             </div>
                         </div>
                     </div>

+ 41 - 2
resources/views/tabler/admin/setting/llm.tpl

@@ -51,6 +51,9 @@
                                 <li class="nav-item">
                                     <a href="#anthropic" class="nav-link" data-bs-toggle="tab">Anthropic</a>
                                 </li>
+                                <li class="nav-item">
+                                    <a href="#aws-bedrock" class="nav-link" data-bs-toggle="tab">AWS Bedrock</a>
+                                </li>
                             </ul>
                         </div>
                         <div class="card-body">
@@ -90,6 +93,10 @@
                                                             {if $settings['llm_backend'] === "anthropic"}selected{/if}>
                                                         Anthropic
                                                     </option>
+                                                    <option value="aws-bedrock"
+                                                            {if $settings['llm_backend'] === "aws-bedrock"}selected{/if}>
+                                                        AWS Bedrock
+                                                    </option>
                                                 </select>
                                             </div>
                                         </div>
@@ -166,7 +173,7 @@
                                 <div class="tab-pane" id="huggingface">
                                     <div class="card-body">
                                         <div class="form-group mb-3 row">
-                                            <label class="form-label col-3 col-form-label">Api Key</label>
+                                            <label class="form-label col-3 col-form-label">API Key</label>
                                             <div class="col">
                                                 <input id="huggingface_api_key" type="text" class="form-control"
                                                        value="{$settings['huggingface_api_key']}">
@@ -191,7 +198,7 @@
                                             </div>
                                         </div>
                                         <div class="form-group mb-3 row">
-                                            <label class="form-label col-3 col-form-label">Api Token</label>
+                                            <label class="form-label col-3 col-form-label">API Token</label>
                                             <div class="col">
                                                 <input id="cf_workers_ai_api_token" type="text" class="form-control"
                                                        value="{$settings['cf_workers_ai_api_token']}">
@@ -224,6 +231,38 @@
                                         </div>
                                     </div>
                                 </div>
+                                <div class="tab-pane" id="aws-bedrock">
+                                    <div class="card-body">
+                                        <div class="form-group mb-3 row">
+                                            <label class="form-label col-3 col-form-label">Access Key ID</label>
+                                            <div class="col">
+                                                <input id="aws_bedrock_access_key_id" type="text" class="form-control"
+                                                       value="{$settings['aws_bedrock_access_key_id']}">
+                                            </div>
+                                        </div>
+                                        <div class="form-group mb-3 row">
+                                            <label class="form-label col-3 col-form-label">Access Key Secret</label>
+                                            <div class="col">
+                                                <input id="aws_bedrock_access_key_secret" type="text" class="form-control"
+                                                       value="{$settings['aws_bedrock_access_key_secret']}">
+                                            </div>
+                                        </div>
+                                        <div class="form-group mb-3 row">
+                                            <label class="form-label col-3 col-form-label">Region</label>
+                                            <div class="col">
+                                                <input id="aws_bedrock_region" type="text" class="form-control"
+                                                       value="{$settings['aws_bedrock_region']}">
+                                            </div>
+                                        </div>
+                                        <div class="form-group mb-3 row">
+                                            <label class="form-label col-3 col-form-label">Model ID</label>
+                                            <div class="col">
+                                                <input id="aws_bedrock_model_id" type="text" class="form-control"
+                                                       value="{$settings['aws_bedrock_model_id']}">
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
                             </div>
                         </div>
                     </div>

+ 30 - 78
resources/views/tabler/admin/ticket/view.tpl

@@ -15,15 +15,16 @@
                 <div class="col-auto">
                     <div class="btn-list">
                         {if $ticket->status !== 'closed'}
-                            <button href="#" class="btn btn-red" data-bs-toggle="modal"
-                                    data-bs-target="#close_ticket_confirm_dialog">
-                                <i class="icon ti ti-x"></i>
-                                关闭
-                            </button>
+                        <button href="#" class="btn btn-red" data-bs-toggle="modal"
+                                data-bs-target="#close_ticket_confirm_dialog">
+                            <i class="icon ti ti-x"></i>
+                            关闭
+                        </button>
                         {/if}
-                        <button id="add_ai_reply" href="#" class="btn btn-primary">
+                        <button href="#" class="btn btn-primary" hx-post="/admin/ticket/{$ticket->id}/llm_reply"
+                                hx-swap="none">
                             <i class="icon ti ti-robot"></i>
-                            AI 回复
+                            LLM 回复
                         </button>
                         <button href="#" class="btn btn-primary" data-bs-toggle="modal"
                                 data-bs-target="#add-reply">
@@ -52,23 +53,23 @@
                         <div class="card-body">
                             <div class="divide-y">
                                 {foreach $comments as $comment}
-                                    <div>
-                                        <div class="row">
-                                            <div class="col">
-                                                <div>
-                                                    {nl2br($comment->comment)}
-                                                </div>
-                                                <div class="text-secondary my-1">{$comment->commenter_name}
-                                                    回复于 {$comment->datetime}
-                                                </div>
+                                <div>
+                                    <div class="row">
+                                        <div class="col">
+                                            <div>
+                                                {$comment->comment}
+                                            </div>
+                                            <div class="text-secondary my-1">{$comment->commenter_name}
+                                                回复于 {$comment->datetime}
                                             </div>
-                                            <div class="col-auto">
-                                                <div>
-                                                    # {$comment->comment_id + 1}
-                                                </div>
+                                        </div>
+                                        <div class="col-auto">
+                                            <div>
+                                                # {$comment->comment_id + 1}
                                             </div>
                                         </div>
                                     </div>
+                                </div>
                                 {/foreach}
                             </div>
                         </div>
@@ -93,7 +94,13 @@
                 </div>
                 <div class="modal-footer">
                     <button type="button" class="btn me-auto" data-bs-dismiss="modal">取消</button>
-                    <button id="reply" type="button" class="btn btn-primary" data-bs-dismiss="modal">回复</button>
+                    <button class="btn btn-primary" data-bs-dismiss="modal"
+                        hx-post="/admin/ticket/{$ticket->id}" hx-swap="none"
+                        hx-vals='js:{
+                            comment: document.getElementById("reply-comment").value,
+                        }'>
+                        回复
+                    </button>
                 </div>
             </div>
         </div>
@@ -104,7 +111,8 @@
             <div class="modal-content">
                 <div class="modal-header">
                     <h5 class="modal-title">关闭工单</h5>
-                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+                    <button class="btn-close" data-bs-dismiss="modal" aria-label="Close"
+                            hx-post="/admin/ticket/{$ticket->id}/close" hx-swap="none"></button>
                 </div>
                 <div class="modal-body">
                     <div class="mb-3">
@@ -122,60 +130,4 @@
         </div>
     </div>
 
-    <script>
-        $("#reply").click(function () {
-            $.ajax({
-                url: "/admin/ticket/{$ticket->id}",
-                type: 'PUT',
-                dataType: "json",
-                data: {
-                    comment: $('#reply-comment').val()
-                },
-                success: function (data) {
-                    if (data.ret === 1) {
-                        $('#success-message').text(data.msg);
-                        $('#success-dialog').modal('show');
-                    } else {
-                        $('#fail-message').text(data.msg);
-                        $('#fail-dialog').modal('show');
-                    }
-                }
-            })
-        });
-
-        $("#add_ai_reply").click(function () {
-            $.ajax({
-                url: "/admin/ticket/{$ticket->id}/ai",
-                type: 'PUT',
-                dataType: "json",
-                success: function (data) {
-                    if (data.ret === 1) {
-                        $('#success-message').text(data.msg);
-                        $('#success-dialog').modal('show');
-                    } else {
-                        $('#fail-message').text(data.msg);
-                        $('#fail-dialog').modal('show');
-                    }
-                }
-            })
-        });
-
-        $("#confirm_close").click(function () {
-            $.ajax({
-                url: "/admin/ticket/{$ticket->id}/close",
-                type: 'PUT',
-                dataType: "json",
-                success: function (data) {
-                    if (data.ret === 1) {
-                        $('#success-message').text(data.msg);
-                        $('#success-dialog').modal('show');
-                    } else {
-                        $('#fail-message').text(data.msg);
-                        $('#fail-dialog').modal('show');
-                    }
-                }
-            })
-        });
-    </script>
-
 {include file='admin/footer.tpl'}

+ 120 - 91
resources/views/tabler/admin/user/edit.tpl

@@ -29,11 +29,11 @@
                 <div class="col-md-4 col-sm-12">
                     <div class="card">
                         <div class="card-header card-header-light">
-                            <h3 class="card-title">基础信息</h3>
+                            <h3 class="card-title">账户信息</h3>
                         </div>
                         <div class="card-body">
                             <div class="form-group mb-3 row">
-                                <label class="form-label col-3 col-form-label">注册邮箱</label>
+                                <label class="form-label col-3 col-form-label">邮箱</label>
                                 <div class="col">
                                     <input id="email" type="email" class="form-control" value="{$edit_user->email}">
                                 </div>
@@ -45,13 +45,6 @@
                                            value="{$edit_user->user_name}">
                                 </div>
                             </div>
-                            <div class="form-group mb-3 row">
-                                <label class="form-label col-3 col-form-label">备注</label>
-                                <div class="col">
-                                    <input id="remark" type="text" class="form-control" value="{$edit_user->remark}"
-                                           placeholder="仅管理员可见">
-                                </div>
-                            </div>
                             <div class="form-group mb-3 row">
                                 <label class="form-label col-3 col-form-label">账户密码</label>
                                 <div class="col">
@@ -62,43 +55,62 @@
                             <div class="form-group mb-3 row">
                                 <label class="form-label col-3 col-form-label">账户余额</label>
                                 <div class="col">
-                                    <input id="money" type="number" step="0.1" class="form-control"
+                                    <input id="money" type="number" step="1" class="form-control"
                                            value="{$edit_user->money}">
                                 </div>
                             </div>
-                            <div class="hr-text">
-                                <span>时间设置</span>
+                            <div class="form-group mb-3 row">
+                                <label class="form-label col-3 col-form-label">邀请人</label>
+                                <div class="col">
+                                    <input id="ref_by" type="text" class="form-control" value="{$edit_user->ref_by}">
+                                </div>
                             </div>
                             <div class="form-group mb-3 row">
-                                <label class="form-label col-4 col-form-label">等级过期时间</label>
+                                <label class="form-label col-3 col-form-label">SS端口</label>
                                 <div class="col">
-                                    <input id="class_expire" type="text" class="form-control"
-                                           value="{$edit_user->class_expire}">
+                                    <input id="port" type="text" class="form-control" value="{$edit_user->port}">
                                 </div>
                             </div>
                             <div class="form-group mb-3 row">
-                                <label class="form-label col-4 col-form-label">免费用户流量重置日</label>
+                                <label class="form-label col-3 col-form-label">SS加密方式</label>
                                 <div class="col">
-                                    <input id="auto_reset_day" type="text" class="form-control"
-                                           value="{$edit_user->auto_reset_day}">
+                                    <select id="method" class="col form-select" value="{$edit_user->method}">
+                                        {foreach $ss_methods as $method}
+                                            <option value="{$method}" {if $edit_user->method === $method}selected{/if}>
+                                                {$method}
+                                            </option>
+                                        {/foreach}
+                                    </select>
                                 </div>
                             </div>
                             <div class="form-group mb-3 row">
-                                <label class="form-label col-4 col-form-label">
-                                    重置的免费流量(GB)
-                                </label>
+                                <label class="form-label col-3 col-form-label">注册IP</label>
                                 <div class="col">
-                                    <input id="auto_reset_bandwidth" type="text" class="form-control"
-                                           value="{$edit_user->auto_reset_bandwidth}">
+                                    <input type="text" class="form-control" value="{$edit_user->reg_ip}" disabled/>
                                 </div>
                             </div>
-                            <div class="hr-text">
-                                <span>邀请注册</span>
+                            <div class="form-group mb-3 row">
+                                <label class="form-label col-3 col-form-label">注册日期</label>
+                                <div class="col">
+                                    <input type="text" class="form-control" value="{$edit_user->reg_date}" disabled/>
+                                </div>
                             </div>
                             <div class="form-group mb-3 row">
-                                <label class="form-label col-4 col-form-label">邀请人</label>
+                                <label class="form-label col-3 col-form-label">最后使用时间</label>
                                 <div class="col">
-                                    <input id="ref_by" type="text" class="form-control" value="{$edit_user->ref_by}">
+                                    <input type="text" class="form-control" value="{$edit_user->last_use_time}" disabled/>
+                                </div>
+                            </div>
+                            <div class="form-group mb-3 row">
+                                <label class="form-label col-3 col-form-label">最后签到时间</label>
+                                <div class="col">
+                                    <input type="text" class="form-control" value="{$edit_user->last_check_in_time}" disabled/>
+                                </div>
+                            </div>
+                            <div class="form-group mb-3 row">
+                                <label class="form-label col-3 col-form-label">最后登录时间</label>
+                                <div class="col">
+                                    <input type="text" class="form-control" value="{$edit_user->last_login_time}" disabled/>
                                 </div>
                             </div>
                         </div>
@@ -107,7 +119,7 @@
                 <div class="col-md-4 col-sm-12">
                     <div class="card">
                         <div class="card-header card-header-light">
-                            <h3 class="card-title">其他信息</h3>
+                            <h3 class="card-title">使用限制</h3>
                         </div>
                         <div class="card-body">
                             <div class="form-group mb-3 row">
@@ -120,19 +132,86 @@
                             <div class="form-group mb-3 row">
                                 <label class="form-label col-4 col-form-label">当期用量</label>
                                 <div class="col">
-                                    <input id="usedTraffic" type="text" class="form-control"
+                                    <input type="text" class="form-control"
                                            value="{$edit_user->usedTraffic()}" disabled/>
                                 </div>
                             </div>
                             <div class="form-group mb-3 row">
                                 <label class="form-label col-4 col-form-label">累计用量</label>
                                 <div class="col">
-                                    <input id="usedTraffic" type="text" class="form-control"
+                                    <input type="text" class="form-control"
                                            value="{$edit_user->totalTraffic()}" disabled/>
                                 </div>
                             </div>
-                            <div class="hr-text">
-                                <span>高级选项</span>
+                            <div class="form-group mb-3 row">
+                                <label class="form-label col-4 col-form-label">节点群组</label>
+                                <div class="col">
+                                    <input id="node_group" type="text" class="form-control"
+                                           value="{$edit_user->node_group}">
+                                </div>
+                            </div>
+                            <div class="form-group mb-3 row">
+                                <label class="form-label col-4 col-form-label">账户等级</label>
+                                <div class="col">
+                                    <input id="class" type="text" class="form-control"
+                                           value="{$edit_user->class}">
+                                </div>
+                            </div>
+                            <div class="form-group mb-3 row">
+                                <label class="form-label col-4 col-form-label">等级过期时间</label>
+                                <div class="col">
+                                    <input id="class_expire" type="text" class="form-control"
+                                           value="{$edit_user->class_expire}">
+                                </div>
+                            </div>
+                            <div class="form-group mb-3 row">
+                                <label class="form-label col-4 col-form-label">免费用户流量重置日</label>
+                                <div class="col">
+                                    <input id="auto_reset_day" type="text" class="form-control"
+                                           value="{$edit_user->auto_reset_day}">
+                                </div>
+                            </div>
+                            <div class="form-group mb-3 row">
+                                <label class="form-label col-4 col-form-label">重置的免费流量(GB)</label>
+                                <div class="col">
+                                    <input id="auto_reset_bandwidth" type="text" class="form-control"
+                                           value="{$edit_user->auto_reset_bandwidth}">
+                                </div>
+                            </div>
+                            <div class="form-group mb-3 row">
+                                <label class="form-label col-4 col-form-label">速度限制 (Mbps)</label>
+                                <div class="col">
+                                    <input id="node_speedlimit" type="text" class="form-control"
+                                           value="{$edit_user->node_speedlimit}">
+                                </div>
+                            </div>
+                            <div class="form-group mb-3 row">
+                                <label class="form-label col-4 col-form-label">同時连接 IP 限制</label>
+                                <div class="col">
+                                    <input id="node_iplimit" type="text" class="form-control"
+                                           value="{$edit_user->node_iplimit}">
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+                <div class="col-md-4 col-sm-12">
+                    <div class="card">
+                        <div class="card-header card-header-light">
+                            <h3 class="card-title">其他设置</h3>
+                        </div>
+                        <div class="card-body">
+                            <div class="form-group mb-3 row">
+                                <label class="form-label col-3 col-form-label">显示语言</label>
+                                <div class="col">
+                                    <select id="locale" class="col form-select" value="{$edit_user->locale}">
+                                        {foreach $locales as $locale}
+                                        <option value="{$locale}" {if $edit_user->locale === $locale}selected{/if}>
+                                            {$locale}
+                                        </option>
+                                        {/foreach}
+                                    </select>
+                                </div>
                             </div>
                             <div class="form-group mb-3 row">
                                 <span class="col">管理员</span>
@@ -153,7 +232,7 @@
                                 </span>
                             </div>
                             <div class="form-group mb-3 row">
-                                <span class="col">账户异常状态</span>
+                                <span class="col">账户异常状态(Shadow Banned)</span>
                                 <span class="col-auto form-check-single form-switch">
                                     <input id="is_shadow_banned" class="form-check-input" type="checkbox"
                                            {if $edit_user->is_shadow_banned}checked=""{/if}>
@@ -168,68 +247,18 @@
                                     </label>
                                 </span>
                             </div>
-                            <div class="form-group mb-3 row">
-                                <span class="col">手动封禁理由</span>
+                            <div class="form-group mb-3 col-12">
+                                <span class="form-label col-12 col-form-label">手动封禁理由</span>
                                 <span class="col-auto">
-                                    <input id="banned_reason" type="text" class="form-control"
-                                           value="{$edit_user->banned_reason}">
+                                    <textarea id="banned_reason" class="form-control"
+                                              value="{$edit_user->banned_reason}"></textarea>
                                 </span>
                             </div>
-                        </div>
-                    </div>
-                </div>
-                <div class="col-md-4 col-sm-12">
-                    <div class="card">
-                        <div class="card-header card-header-light">
-                            <h3 class="card-title">连接设置</h3>
-                        </div>
-                        <div class="card-body">
-                            <div class="form-group mb-3 row">
-                                <label class="form-label col-3 col-form-label">端口</label>
-                                <div class="col">
-                                    <input id="port" type="text" class="form-control" value="{$edit_user->port}">
-                                </div>
-                            </div>
-                            <div class="form-group mb-3 row">
-                                <label class="form-label col-3 col-form-label">密码</label>
-                                <div class="col">
-                                    <input id="passwd" type="text" class="form-control" value="{$edit_user->passwd}" disabled/>
-                                </div>
-                            </div>
-                            <div class="form-group mb-3 row">
-                                <label class="form-label col-3 col-form-label">加密</label>
-                                <div class="col">
-                                    <input id="method" type="text" class="form-control" value="{$edit_user->method}">
-                                </div>
-                            </div>
-                            <div class="hr-text">
-                                <span>使用限制</span>
-                            </div>
                             <div class="form-group mb-3 col-12">
-                                <label class="form-label col-12 col-form-label">节点群组</label>
+                                <label class="form-label col-12 col-form-label">账户备注</label>
                                 <div class="col">
-                                    <input id="node_group" type="text" class="form-control"
-                                           value="{$edit_user->node_group}">
-                                </div>
-                            </div>
-                            <div class="form-group mb-3 col-12">
-                                <label class="form-label col-12 col-form-label">账户等级</label>
-                                <div class="col">
-                                    <input id="class" type="text" class="form-control" value="{$edit_user->class}">
-                                </div>
-                            </div>
-                            <div class="form-group mb-3 col-12">
-                                <label class="form-label col-12 col-form-label">速度限制 (Mbps)</label>
-                                <div class="col">
-                                    <input id="node_speedlimit" type="text" class="form-control"
-                                           value="{$edit_user->node_speedlimit}">
-                                </div>
-                            </div>
-                            <div class="form-group mb-3 col-12">
-                                <label class="form-label col-12 col-form-label">同時连接 IP 限制</label>
-                                <div class="col">
-                                    <input id="node_iplimit" type="text" class="form-control"
-                                           value="{$edit_user->node_iplimit}">
+                                    <textarea id="remark" class="form-control" value="{$edit_user->remark}"
+                                              placeholder="仅管理员可见"></textarea>
                                 </div>
                             </div>
                         </div>
@@ -251,9 +280,9 @@
                 {$key}: $('#{$key}').val(),
                 {/foreach}
                 is_admin: $("#is_admin").is(":checked"),
-                is_banned: $("#is_banned").is(":checked"),
                 ga_enable: $("#ga_enable").is(":checked"),
                 is_shadow_banned: $("#is_shadow_banned").is(":checked"),
+                is_banned: $("#is_banned").is(":checked"),
             },
             success: function (data) {
                 if (data.ret === 1) {

+ 0 - 4
resources/views/tabler/gateway/epay.tpl

@@ -8,7 +8,6 @@
         <button class="btn btn-flat waves-attach"
                 hx-post="/user/payment/purchase/epay" hx-swap="none"
                 hx-vals='js:{
-                    price: {$invoice->price},
                     invoice_id: {$invoice->id},
                     type: "alipay",
                     redir: window.location.href
@@ -20,7 +19,6 @@
         <button class="btn btn-flat waves-attach"
                 hx-post="/user/payment/purchase/epay" hx-swap="none"
                 hx-vals='js:{
-                    price: {$invoice->price},
                     invoice_id: {$invoice->id},
                     type: "wxpay",
                     redir: window.location.href
@@ -32,7 +30,6 @@
         <button class="btn btn-flat waves-attach"
                 hx-post="/user/payment/purchase/epay" hx-swap="none"
                 hx-vals='js:{
-                    price: {$invoice->price},
                     invoice_id: {$invoice->id},
                     type: "qqpay",
                     redir: window.location.href
@@ -44,7 +41,6 @@
         <button class="btn btn-flat waves-attach"
                 hx-post="/user/payment/purchase/epay" hx-swap="none"
                 hx-vals='js:{
-                    price: {$invoice->price},
                     invoice_id: {$invoice->id},
                     type: "usdt",
                     redir: window.location.href

+ 3 - 4
resources/views/tabler/gateway/f2f.tpl

@@ -1,10 +1,10 @@
+<script src="//{$config['jsdelivr_url']}/npm/jquery/dist/jquery.min.js"></script>
+
 <div class="card-inner">
     <h4>
         支付宝当面付
     </h4>
     <p class="card-heading"></p>
-    <input hidden id="amount-f2fpay" name="amount-f2fpay" value="{$invoice->price}">
-    <input hidden id="invoice_id" name="invoice_id" value="{$invoice->id}">
     <div id="f2f-qrcode"></div>
     <button class="btn btn-flat waves-attach" id="f2fpay-button" type="button" onclick="f2fpay();">
         生成付款QR Code
@@ -20,8 +20,7 @@
             url: "/user/payment/purchase/f2f",
             dataType: "json",
             data: {
-                amount: $('#amount-f2fpay').val(),
-                invoice_id: $('#invoice_id').val(),
+                invoice_id: {$invoice->id},
             },
             success: (data) => {
                 if (data.ret === 1) {

+ 4 - 17
resources/views/tabler/gateway/paypal.tpl

@@ -1,4 +1,4 @@
-<script src="https://www.paypal.com/sdk/js?client-id={$public_setting['paypal_client_id']}&currency={$public_setting['paypal_currency']}"></script>
+<script src="//www.paypal.com/sdk/js?client-id={$public_setting['paypal_client_id']}&currency={$public_setting['paypal_currency']}"></script>
 
 <div class="card-inner">
     <h4>
@@ -17,28 +17,15 @@
                     "Content-Type": "application/json",
                 },
                 body: JSON.stringify({
-                    price: {$invoice->price},
                     invoice_id: {$invoice->id},
                 }),
             })
                 .then((response) => response.json())
                 .then((order) => order.id);
         },
-        onApprove(data) {
-            return fetch("/payment/notify/paypal", {
-                method: "POST",
-                headers: {
-                    "Content-Type": "application/json",
-                },
-                body: JSON.stringify({
-                    order_id: data.orderID,
-                }),
-            })
-                .then((response) => response.json())
-                .then(() => {
-                    window.setTimeout(location.href = '/user/invoice', {$config['jump_delay']});
-                });
+        onApprove() {
+            window.setTimeout(location.href = '/user/invoice', {$config['jump_delay']});
         }
     }).render('#paypal-button-container');
 
-</script>
+</script>

+ 10 - 8
resources/views/tabler/gateway/stripe.tpl

@@ -1,5 +1,5 @@
 <link rel="stylesheet"
-      href="https://{$config['jsdelivr_url']}/npm/@tabler/core@latest/dist/css/tabler-payments.min.css">
+      href="//{$config['jsdelivr_url']}/npm/@tabler/core@latest/dist/css/tabler-payments.min.css">
 
 <div class="card-inner">
     <h4>
@@ -11,11 +11,13 @@
         <span class="payment payment-xs payment-provider-mastercard me-auto"></span>
         <span class="payment payment-xs payment-provider-visa me-auto"></span>
         等标识的信用卡或借记卡</p>
-    <form action="/user/payment/purchase/stripe" method="post">
-        <div class="form-group form-group-label">
-            <input id="price" name="price" value="{$invoice->price}" hidden>
-            <input id="invoice_id" name="invoice_id" value="{$invoice->id}" hidden>
-            <button class="btn btn-flat waves-attach" type="submit"><i class="icon ti ti-credit-card"></i></button>
-        </div>
-    </form>
+    <div class="form-group form-group-label">
+        <button class="btn btn-flat waves-attach"
+            hx-post="/user/payment/purchase/stripe" hx-swap="none"
+            hx-vals='js:{
+                invoice_id: {$invoice->id},
+            }'>
+            <i class="icon ti ti-credit-card"></i>
+        </button>
+    </div>
 </div>

+ 18 - 2
resources/views/tabler/header.tpl

@@ -1,5 +1,5 @@
 <!doctype html>
-<html lang="zh">
+<html lang="{$config['locale']}" data-bs-theme="auto">
 
 <head>
     <meta charset="utf-8"/>
@@ -8,10 +8,26 @@
     <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" name="viewport"/>
     <meta http-equiv="X-UA-Compatible" content="ie=edge"/>
     <title>{$config['appName']}</title>
+    <!-- Auto dark mode -->
+    <script>
+        ;(function () {
+            const htmlElement = document.querySelector("html")
+            const theme = htmlElement.getAttribute("data-bs-theme");
+
+            if(theme === 'dark-auto' || theme === 'auto') {
+                function updateTheme() {
+                    htmlElement.setAttribute("data-bs-theme",
+                        window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
+                }
+                window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme)
+                updateTheme()
+            }
+        })()
+    </script>
     <!-- CSS files -->
     <link href="//{$config['jsdelivr_url']}/npm/@tabler/core@latest/dist/css/tabler.min.css" rel="stylesheet"/>
     <link href="//{$config['jsdelivr_url']}/npm/@tabler/icons-webfont@latest/tabler-icons.min.css" rel="stylesheet"/>
     <!-- JS files -->
     <script src="/assets/js/fuck.min.js"></script>
-    <script src="//{$config['jsdelivr_url']}/npm/htmx.org@latest/dist/htmx.min.js"></script>
+    <script src="//{$config['jsdelivr_url']}/npm/htmx.org@v2/dist/htmx.min.js"></script>
 </head>

+ 2 - 2
resources/views/tabler/tinymce.tpl

@@ -1,8 +1,8 @@
-<script src="//cdnjs.cloudflare.com/ajax/libs/tinymce/7.1.2/tinymce.min.js"></script>
+<script src="//cdnjs.cloudflare.com/ajax/libs/tinymce/7.2.0/tinymce.min.js"></script>
 
 <script>
     document.addEventListener("DOMContentLoaded", function () {
-        tinyMCE.baseURL = '//cdnjs.cloudflare.com/ajax/libs/tinymce/7.1.2';
+        tinyMCE.baseURL = '//cdnjs.cloudflare.com/ajax/libs/tinymce/7.2.0';
         tinyMCE.suffix = '.min';
         tinyMCE.init({
             selector: '#tinymce',

+ 5 - 2
resources/views/tabler/user/edit.tpl

@@ -459,10 +459,13 @@
                                                     <h3 class="card-title">修改主题模式</h3>
                                                     <div class="mb-3">
                                                         <select id="theme-mode" class="form-select">
-                                                            <option value="0" {if ! $user->is_dark_mode}selected{/if}>
+                                                            <option value="2" {if $user->is_dark_mode === 2}selected{/if}>
+                                                                自动
+                                                            </option>
+                                                            <option value="0" {if $user->is_dark_mode === 0}selected{/if}>
                                                                 浅色
                                                             </option>
-                                                            <option value="1" {if $user->is_dark_mode}selected{/if}>
+                                                            <option value="1" {if $user->is_dark_mode === 1}selected{/if}>
                                                                 深色
                                                             </option>
                                                         </select>

+ 22 - 3
resources/views/tabler/user/header.tpl

@@ -1,5 +1,6 @@
 <!doctype html>
-<html lang="zh">
+<html lang="{$user->locale}"
+      data-bs-theme="{$user->is_dark_mode === 1 ? 'dark' : ($user->is_dark_mode === 2 ? 'auto' : 'light')}">
 
 <head>
     <meta charset="utf-8"/>
@@ -8,6 +9,22 @@
     <meta http-equiv="X-UA-Compatible" content="ie=edge"/>
     <meta name="referrer" content="never">
     <title>{$config['appName']}</title>
+    <!-- Auto dark mode -->
+    <script>
+        ;(function () {
+            const htmlElement = document.querySelector("html")
+            const theme = htmlElement.getAttribute("data-bs-theme");
+
+            if(theme === 'dark-auto' || theme === 'auto') {
+                function updateTheme() {
+                    htmlElement.setAttribute("data-bs-theme",
+                        window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
+                }
+                window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme)
+                updateTheme()
+            }
+        })()
+    </script>
     <!-- CSS files -->
     <link href="//{$config['jsdelivr_url']}/npm/@tabler/core@latest/dist/css/tabler.min.css" rel="stylesheet"/>
     <link href="//{$config['jsdelivr_url']}/npm/@tabler/icons-webfont@latest/tabler-icons.min.css" rel="stylesheet"/>
@@ -15,7 +32,7 @@
     <script src="/assets/js/fuck.min.js"></script>
     <script src="//{$config['jsdelivr_url']}/npm/qrcode_js@latest/qrcode.min.js"></script>
     <script src="//{$config['jsdelivr_url']}/npm/clipboard@latest/dist/clipboard.min.js"></script>
-    <script src="//{$config['jsdelivr_url']}/npm/htmx.org@latest/dist/htmx.min.js"></script>
+    <script src="//{$config['jsdelivr_url']}/npm/htmx.org@v2/dist/htmx.min.js"></script>
     <style>
         .home-subtitle {
             font-size: 14px;
@@ -38,8 +55,10 @@
     </style>
 </head>
 
-{if $user->is_dark_mode}
+{if $user->is_dark_mode === 1}
 <body data-bs-theme="dark">
+{elseif $user->is_dark_mode === 2}
+<body data-bs-theme="auto">
 {else}
 <body>
 {/if}

+ 119 - 45
resources/views/tabler/user/index.tpl

@@ -110,7 +110,7 @@
                                                 速度限制
                                             </div>
                                             <div class="text-secondary">
-                                                {if $user->node_speedlimit !== 0.0}
+                                                {if $user->node_speedlimit !== 0}
                                                     <code>{$user->node_speedlimit}</code>
                                                     Mbps
                                                 {else}
@@ -181,43 +181,43 @@
                                 <div class="tab-pane active show" id="sub">
                                     <div>
                                         <p>
-                                            通用订阅(json):<code class="spoiler">{$UniversalSub}/json</code>
+                                            通用订阅(Json):<code class="spoiler">{$UniversalSub}/json</code>
                                         </p>
                                         <p>
-                                            通用订阅(clash):<code class="spoiler">{$UniversalSub}/clash</code>
+                                            通用订阅(Clash):<code class="spoiler">{$UniversalSub}/clash</code>
                                         </p>
                                         <p>
-                                            通用订阅(sing-box):<code class="spoiler">{$UniversalSub}/singbox</code>
+                                            通用订阅(SingBox):<code class="spoiler">{$UniversalSub}/singbox</code>
                                         </p>
                                         <p>
-                                            通用订阅(v2rayjson):<code class="spoiler">{$UniversalSub}/v2rayjson</code>
+                                            通用订阅(V2Ray Json):<code class="spoiler">{$UniversalSub}/v2rayjson</code>
                                         </p>
                                         {if $public_setting['enable_ss_sub']}
                                         <p>
-                                            通用订阅(sip008):<code class="spoiler">{$UniversalSub}/sip008</code>
+                                            通用订阅(SIP008):<code class="spoiler">{$UniversalSub}/sip008</code>
                                         </p>
                                         {/if}
                                         <div class="btn-list justify-content-start">
                                             <a data-clipboard-text="{$UniversalSub}/json"
                                                class="copy btn btn-primary">
-                                                复制通用订阅(json)
+                                                复制通用订阅(Json)
                                             </a>
                                             <a data-clipboard-text="{$UniversalSub}/clash"
                                                class="copy btn btn-primary">
-                                                复制通用订阅(clash)
+                                                复制通用订阅(Clash)
                                             </a>
                                             <a data-clipboard-text="{$UniversalSub}/singbox"
                                                class="copy btn btn-primary">
-                                                复制通用订阅(sing-box)
+                                                复制通用订阅(SingBox)
                                             </a>
                                             <a data-clipboard-text="{$UniversalSub}/v2rayjson"
                                                class="copy btn btn-primary">
-                                                复制通用订阅(v2rayjson)
+                                                复制通用订阅(V2Ray Json)
                                             </a>
                                             {if $public_setting['enable_ss_sub']}
                                             <a data-clipboard-text="{$UniversalSub}/sip008"
                                                class="copy btn btn-primary">
-                                                复制通用订阅(sip008)
+                                                复制通用订阅(SIP008)
                                             </a>
                                             {/if}
                                         </div>
@@ -273,21 +273,41 @@
                                         <p>
                                             适用于 Clash 的订阅:<code class="spoiler">{$UniversalSub}/clash</code>
                                         </p>
+                                        <p>
+                                            适用于 SingBox 的订阅:<code class="spoiler">{$UniversalSub}/clash</code>
+                                        </p>
                                         <div class="btn-list justify-content-start">
                                             <a  {if $config['enable_r2_client_download']}
-                                                href="/user/clients/Clash.Verge.exe"
+                                                href="/user/clients/Clash.Nyanpasu.exe"
                                                 {else}
-                                                href="/clients/Clash.Verge.exe"
+                                                href="/clients/Clash.Nyanpasu.exe"
                                                 {/if} class="btn btn-azure">
-                                                下载 Clash Verge
+                                                下载 Clash Nyanpasu
                                             </a>
                                             <a data-clipboard-text="{$UniversalSub}/clash"
                                                class="copy btn btn-primary">
                                                 复制 Clash 订阅链接
                                             </a>
-                                            <a href="clash://install-config?url={$UniversalSub}/clash&name={$config['appName']}"
+                                            <a href="clash-nyanpasu://subscribe-remote-profile?url={$UniversalSub}&name={$config['appName']}"
+                                               class="btn btn-indigo">
+                                                导入 Clash Nyanpasu
+                                            </a>
+                                        </div>
+                                        <div class="btn-list justify-content-start my-2">
+                                            <a  {if $config['enable_r2_client_download']}
+                                                href="/user/clients/Hiddify.exe"
+                                                {else}
+                                                href="/clients/Hiddify.exe"
+                                                {/if} class="btn btn-azure">
+                                                下载 Hiddify
+                                            </a>
+                                            <a data-clipboard-text="{$UniversalSub}/singbox"
+                                               class="copy btn btn-primary">
+                                                复制 SingBox 订阅链接
+                                            </a>
+                                            <a href="hiddify://import/{$UniversalSub}#{$config['appName']}"
                                                class="btn btn-indigo">
-                                                导入 Clash
+                                                导入 Hiddify
                                             </a>
                                         </div>
                                     </div>
@@ -297,56 +317,73 @@
                                         适用于 Clash 的订阅:<code class="spoiler">{$UniversalSub}/clash</code>
                                     </p>
                                     <p>
-                                        适用于 sing-box 的订阅:<code class="spoiler">{$UniversalSub}/singbox</code>
+                                        适用于 SingBox 的订阅:<code class="spoiler">{$UniversalSub}/singbox</code>
                                     </p>
                                     <div class="btn-list justify-content-start">
                                         <a {if $config['enable_r2_client_download']}
-                                            href="/user/clients/Clash.Verge_aarch64.dmg"
-                                        {else}
-                                            href="/clients/Clash.Verge_aarch64.dmg"
-                                        {/if} class="btn btn-azure">
-                                            下载 Clash Verge (aarch64)
+                                            href="/user/clients/Clash.Nyanpasu_aarch64.dmg"
+                                            {else}
+                                            href="/clients/Clash.Nyanpasu_aarch64.dmg"
+                                            {/if} class="btn btn-azure">
+                                            下载 Clash Nyanpasu (aarch64)
                                         </a>
                                         <a data-clipboard-text="{$UniversalSub}/clash"
                                            class="copy btn btn-primary">
                                             复制 Clash 订阅链接
                                         </a>
-                                        <a href="clash://install-config?url={$UniversalSub}/clash&name={$config['appName']}"
+                                        <a href="clash-nyanpasu://subscribe-remote-profile?url={$UniversalSub}&name={$config['appName']}"
                                            class="btn btn-indigo">
-                                            导入 Clash
+                                            导入 Clash Nyanpasu
                                         </a>
                                     </div>
                                     <div class="btn-list justify-content-start my-2">
                                         <a {if $config['enable_r2_client_download']}
                                             href="/user/clients/SFM.zip"
-                                        {else}
+                                            {else}
                                             href="/clients/SFM.zip"
-                                        {/if} class="btn btn-azure">
+                                            {/if} class="btn btn-azure">
                                             下载 SFM
                                         </a>
                                         <a data-clipboard-text="{$UniversalSub}/singbox"
                                            class="copy btn btn-primary">
-                                            复制 sing-box 订阅链接
+                                            复制 SingBox 订阅链接
                                         </a>
                                         <a href="sing-box://import-remote-profile?url={$UniversalSub}/singbox#{$config['appName']}"
                                            class="btn btn-indigo">
                                             导入 SFM
                                         </a>
                                     </div>
+                                    <div class="btn-list justify-content-start my-2">
+                                        <a  {if $config['enable_r2_client_download']}
+                                            href="/user/clients/Hiddify.dmg"
+                                            {else}
+                                            href="/clients/Hiddify.dmg"
+                                            {/if} class="btn btn-azure">
+                                            下载 Hiddify
+                                        </a>
+                                        <a data-clipboard-text="{$UniversalSub}/singbox"
+                                           class="copy btn btn-primary">
+                                            复制 SingBox 订阅链接
+                                        </a>
+                                        <a href="hiddify://import/{$UniversalSub}#{$config['appName']}"
+                                           class="btn btn-indigo">
+                                            导入 Hiddify
+                                        </a>
+                                    </div>
                                 </div>
                                 <div class="tab-pane" id="android">
                                     <p>
                                         适用于 Clash 的订阅:<code class="spoiler">{$UniversalSub}/clash</code>
                                     </p>
                                     <p>
-                                        适用于 sing-box 的订阅:<code class="spoiler">{$UniversalSub}/singbox</code>
+                                        适用于 SingBox 的订阅:<code class="spoiler">{$UniversalSub}/singbox</code>
                                     </p>
                                     <div class="btn-list justify-content-start">
                                         <a {if $config['enable_r2_client_download']}
                                             href="/user/clients/CMFA.apk"
-                                        {else}
+                                            {else}
                                             href="/clients/CMFA.apk"
-                                        {/if} class="btn btn-azure">
+                                            {/if} class="btn btn-azure">
                                             下载 Clash.Meta For Android
                                         </a>
                                         <a data-clipboard-text="{$UniversalSub}/clash"
@@ -361,37 +398,54 @@
                                     <div class="btn-list justify-content-start my-2">
                                         <a {if $config['enable_r2_client_download']}
                                             href="/user/clients/SFA.apk"
-                                        {else}
+                                            {else}
                                             href="/clients/SFA.apk"
-                                        {/if} class="btn btn-azure">
+                                            {/if} class="btn btn-azure">
                                             下载 SFA
                                         </a>
                                         <a data-clipboard-text="{$UniversalSub}/singbox"
                                            class="copy btn btn-primary">
-                                            复制 sing-box 订阅链接
+                                            复制 SingBox 订阅链接
                                         </a>
                                         <a href="sing-box://import-remote-profile?url={$UniversalSub}/singbox#{$config['appName']}"
                                            class="btn btn-indigo">
                                             导入 SFA
                                         </a>
                                     </div>
+                                    <div class="btn-list justify-content-start my-2">
+                                        <a  {if $config['enable_r2_client_download']}
+                                            href="/user/clients/Hiddify.apk"
+                                            {else}
+                                            href="/clients/Hiddify.apk"
+                                            {/if} class="btn btn-azure">
+                                            下载 Hiddify
+                                        </a>
+                                        <a data-clipboard-text="{$UniversalSub}/singbox"
+                                           class="copy btn btn-primary">
+                                            复制 SingBox 订阅链接
+                                        </a>
+                                        <a href="hiddify://import/{$UniversalSub}#{$config['appName']}"
+                                           class="btn btn-indigo">
+                                            导入 Hiddify
+                                        </a>
+                                    </div>
                                 </div>
                                 <div class="tab-pane" id="ios">
                                     <p>
-                                        适用于 sing-box 的订阅:<code class="spoiler">{$UniversalSub}/singbox</code>
+                                        适用于 SingBox 的订阅:<code class="spoiler">{$UniversalSub}/singbox</code>
                                     </p>
-                                    <div class="btn-list justify-content-start my-2">
+                                    <div class="btn-list justify-content-start">
                                         <a href="https://apps.apple.com/app/sing-box/id6451272673" target="_blank"
                                            class="btn btn-azure">
-                                            安裝 sing-box
+                                            安裝 SFI
                                         </a>
                                         <a data-clipboard-text="{$UniversalSub}/singbox"
                                            class="copy btn btn-primary">
-                                            复制 sing-box 订阅链接
+                                            复制 SingBox 订阅链接
                                         </a>
                                         <a href="sing-box://import-remote-profile?url={$UniversalSub}/singbox#{$config['appName']}"
                                            class="btn btn-indigo">
-                                            导入 sing-box
+                                            导入 SFI
                                         </a>
                                     </div>
                                 </div>
@@ -399,21 +453,41 @@
                                     <p>
                                         适用于 Clash 的订阅:<code class="spoiler">{$UniversalSub}/clash</code>
                                     </p>
+                                    <p>
+                                        适用于 SingBox 的订阅:<code class="spoiler">{$UniversalSub}/singbox</code>
+                                    </p>
                                     <div class="btn-list justify-content-start">
                                         <a {if $config['enable_r2_client_download']}
-                                            href="/user/clients/Clash.Verge.AppImage.tar.gz"
-                                        {else}
-                                            href="/clients/Clash.Verge.AppImage.tar.gz"
-                                        {/if} class="btn btn-azure">
-                                            下载 Clash Verge
+                                            href="/user/clients/Clash.Nyanpasu.AppImage"
+                                            {else}
+                                            href="/clients/Clash.Nyanpasu.AppImage"
+                                            {/if} class="btn btn-azure">
+                                            下载 Clash Nyanpasu
                                         </a>
                                         <a data-clipboard-text="{$UniversalSub}/clash"
                                            class="copy btn btn-primary">
                                             复制 Clash 订阅链接
                                         </a>
-                                        <a href="clash://install-config?url={$UniversalSub}/clash&name={$config['appName']}"
+                                        <a href="clash-nyanpasu://subscribe-remote-profile?url={$UniversalSub}&name={$config['appName']}"
                                            class="btn btn-indigo">
-                                            导入 Clash
+                                            导入 Clash Nyanpasu
+                                        </a>
+                                    </div>
+                                    <div class="btn-list justify-content-start my-2">
+                                        <a  {if $config['enable_r2_client_download']}
+                                            href="/user/clients/Hiddify.AppImage"
+                                            {else}
+                                            href="/clients/Hiddify.AppImage"
+                                            {/if} class="btn btn-azure">
+                                            下载 Hiddify
+                                        </a>
+                                        <a data-clipboard-text="{$UniversalSub}/singbox"
+                                           class="copy btn btn-primary">
+                                            复制 SingBox 订阅链接
+                                        </a>
+                                        <a href="hiddify://import/{$UniversalSub}#{$config['appName']}"
+                                           class="btn btn-indigo">
+                                            导入 Hiddify
                                         </a>
                                     </div>
                                 </div>

+ 1 - 1
resources/views/tabler/user/invoice/index.tpl

@@ -25,7 +25,7 @@
                                 <thead>
                                 <tr>
                                     {foreach $details['field'] as $key => $value}
-                                        <th>{$value}</th>
+                                    <th>{$value}</th>
                                     {/foreach}
                                 </tr>
                                 </thead>

+ 0 - 2
resources/views/tabler/user/invoice/view.tpl

@@ -1,7 +1,5 @@
 {include file='user/header.tpl'}
 
-<script src="//{$config['jsdelivr_url']}/npm/jquery/dist/jquery.min.js"></script>
-
 <div class="page-wrapper">
     <div class="container-xl">
         <div class="page-header d-print-none text-white">

+ 1 - 1
resources/views/tabler/user/order/index.tpl

@@ -25,7 +25,7 @@
                                 <thead>
                                 <tr>
                                     {foreach $details['field'] as $key => $value}
-                                        <th>{$value}</th>
+                                    <th>{$value}</th>
                                     {/foreach}
                                 </tr>
                                 </thead>

+ 33 - 35
resources/views/tabler/user/ticket/index.tpl

@@ -14,8 +14,7 @@
                 </div>
                 <div class="col-auto">
                     <div class="btn-list">
-                        <button href="#" class="btn btn-primary" data-bs-toggle="modal"
-                                data-bs-target="#create-ticket">
+                        <button href="#" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#create-ticket">
                             <i class="icon ti ti-plus"></i>
                             创建工单
                         </button>
@@ -31,45 +30,45 @@
                     <div class="row row-cards row-deck">
                         {if $tickets !== 0}
                             {foreach $tickets as $ticket}
-                                <div class="col-md-4 col-sm-12">
-                                    <div class="card">
-                                        <div class="card-body">
-                                            <div class="card-stamp">
-                                                {if $ticket->status !== 'closed'}
-                                                    <div class="card-stamp-icon bg-yellow">
-                                                        <i class="ti ti-clock"></i>
-                                                    </div>
-                                                {else}
-                                                    <div class="card-stamp-icon bg-green">
-                                                        <i class="ti ti-check"></i>
-                                                    </div>
-                                                {/if}
+                            <div class="col-md-4 col-sm-12">
+                                <div class="card">
+                                    <div class="card-body">
+                                        <div class="card-stamp">
+                                            {if $ticket->status !== 'closed'}
+                                            <div class="card-stamp-icon bg-yellow">
+                                                <i class="ti ti-clock"></i>
                                             </div>
-                                            <h3 class="card-title" style="font-size: 20px;">
-                                                #{$ticket->id}
-                                            </h3>
-                                            <p class="text-secondary text-truncate" style="height: 100px;">
-                                                {$ticket->title}
-                                            </p>
-                                        </div>
-                                        <div class="card-footer">
-                                            <div class="d-flex">
-                                                <span class="status status-grey">{$ticket->status}</span>
-                                                <span class="status status-grey">{$ticket->type}</span>
-                                                <a href="/user/ticket/{$ticket->id}/view"
-                                                   class="btn btn-primary ms-auto">查看</a>
+                                            {else}
+                                            <div class="card-stamp-icon bg-green">
+                                                <i class="ti ti-check"></i>
                                             </div>
+                                            {/if}
+                                        </div>
+                                        <h3 class="card-title" style="font-size: 20px;">
+                                            #{$ticket->id}
+                                        </h3>
+                                        <p class="text-secondary text-truncate" style="height: 100px;">
+                                            {$ticket->title}
+                                        </p>
+                                    </div>
+                                    <div class="card-footer">
+                                        <div class="d-flex">
+                                            <span class="status status-grey">{$ticket->status}</span>
+                                            <span class="status status-grey">{$ticket->type}</span>
+                                            <a href="/user/ticket/{$ticket->id}/view"
+                                               class="btn btn-primary ms-auto">查看</a>
                                         </div>
                                     </div>
                                 </div>
+                            </div>
                             {/foreach}
                         {else}
-                            <div class="card">
-                                <div class="card-header">
-                                    <h3 class="card-title">没有任何工单</h3>
-                                </div>
-                                <div class="card-body">如需帮助,请点击右上角按钮开启新工单</div>
+                        <div class="card">
+                            <div class="card-header">
+                                <h3 class="card-title">没有任何工单</h3>
                             </div>
+                            <div class="card-body">如需帮助,请点击右上角按钮开启新工单</div>
+                        </div>
                         {/if}
                     </div>
                 </div>
@@ -98,8 +97,7 @@
                         <input id="ticket-title" type="text" class="form-control" placeholder="请输入工单主题">
                     </div>
                     <div class="mb-3">
-                        <textarea id="ticket-comment" class="form-control" rows="12"
-                                  placeholder="请输入工单内容"></textarea>
+                        <textarea id="ticket-comment" class="form-control" rows="12" placeholder="请输入工单内容"></textarea>
                     </div>
                 </div>
                 <div class="modal-footer">

+ 21 - 21
resources/views/tabler/user/ticket/view.tpl

@@ -13,15 +13,15 @@
                     </div>
                 </div>
                 {if $ticket->status !== 'closed'}
-                    <div class="col-auto">
-                        <div class="btn-list">
-                            <a href="#" class="btn btn-primary" data-bs-toggle="modal"
-                               data-bs-target="#add-reply">
-                                <i class="icon ti ti-plus"></i>
-                                添加回复
-                            </a>
-                        </div>
+                <div class="col-auto">
+                    <div class="btn-list">
+                        <a href="#" class="btn btn-primary" data-bs-toggle="modal"
+                           data-bs-target="#add-reply">
+                            <i class="icon ti ti-plus"></i>
+                            添加回复
+                        </a>
                     </div>
+                </div>
                 {/if}
             </div>
         </div>
@@ -77,22 +77,22 @@
                         <div class="card-body">
                             <div class="divide-y">
                                 {foreach $comments as $comment}
-                                    <div>
-                                        <div class="row">
-                                            <div class="col">
-                                                <div>
-                                                    {nl2br($comment->comment)}
-                                                </div>
-                                                <div class="text-secondary my-1">{$comment->commenter_name}
-                                                    回复于 {$comment->datetime}</div>
+                                <div>
+                                    <div class="row">
+                                        <div class="col">
+                                            <div>
+                                                {$comment->comment}
                                             </div>
-                                            <div class="col-auto">
-                                                <div>
-                                                    # {$comment->comment_id + 1}
-                                                </div>
+                                            <div class="text-secondary my-1">{$comment->commenter_name}
+                                                回复于 {$comment->datetime}</div>
+                                        </div>
+                                        <div class="col-auto">
+                                            <div>
+                                                # {$comment->comment_id + 1}
                                             </div>
                                         </div>
                                     </div>
+                                </div>
                                 {/foreach}
                             </div>
                         </div>
@@ -117,7 +117,7 @@
                 <div class="modal-footer">
                     <button type="button" class="btn me-auto" data-bs-dismiss="modal">取消</button>
                     <button id="reply" class="btn btn-primary" data-bs-dismiss="modal"
-                            hx-put="/user/ticket/{$ticket->id}" hx-swap="none"
+                            hx-post="/user/ticket/{$ticket->id}" hx-swap="none"
                             hx-vals='js:{ comment: document.getElementById("reply-comment").value }'>
                         回复
                     </button>

+ 2 - 2
src/Command/ClientDownload.php

@@ -215,8 +215,8 @@ final class ClientDownload extends Command
         echo '====== ' . $task['name'] . ' 开始 ======' . PHP_EOL;
 
         $tagName = match ($task['tagMethod']) {
-            'github_pre_release' => self::getLatestPreReleaseTagName($task['gitRepo']),
-            default => self::getLatestReleaseTagName($task['gitRepo']),
+            'github_pre_release' => $this->getLatestPreReleaseTagName($task['gitRepo']),
+            default => $this->getLatestReleaseTagName($task['gitRepo']),
         };
 
         if (! isset($this->version[$task['name']])) {

+ 1 - 1
src/Command/Migration.php

@@ -97,7 +97,7 @@ END;
 
                 echo PHP_EOL;
 
-                $object = require_once BASE_PATH . '/db/migrations/' . $file;
+                $object = require BASE_PATH . '/db/migrations/' . $file;
 
                 if ($object instanceof MigrationInterface) {
                     $queue[$version] = $object;

+ 67 - 61
src/Controllers/Admin/Setting/BillingController.php

@@ -7,91 +7,65 @@ namespace App\Controllers\Admin\Setting;
 use App\Controllers\BaseController;
 use App\Models\Config;
 use App\Services\Payment;
-use Exception;
 use Psr\Http\Message\ResponseInterface;
 use Slim\Http\Response;
 use Slim\Http\ServerRequest;
+use Smarty\Exception;
+use Srmklive\PayPal\Services\PayPal;
 use Stripe\Exception\ApiErrorException;
 use Stripe\Stripe;
 use Stripe\WebhookEndpoint;
-use function json_decode;
-use function json_encode;
+use Throwable;
 
 final class BillingController extends BaseController
 {
-    private static array $update_field = [
-        // 支付宝当面付
-        'f2f_pay_app_id',
-        'f2f_pay_pid',
-        'f2f_pay_public_key',
-        'f2f_pay_private_key',
-        'f2f_pay_notify_url',
-        // Stripe
-        'stripe_api_key',
-        'stripe_endpoint_secret',
-        'stripe_currency',
-        'stripe_card',
-        'stripe_alipay',
-        'stripe_wechat',
-        'stripe_min_recharge',
-        'stripe_max_recharge',
-        // EPay
-        'epay_url',
-        'epay_pid',
-        'epay_key',
-        'epay_sign_type',
-        'epay_alipay',
-        'epay_wechat',
-        'epay_qq',
-        'epay_usdt',
-        // PayPal
-        'paypal_mode',
-        'paypal_client_id',
-        'paypal_client_secret',
-        'paypal_currency',
-        'paypal_locale',
-    ];
+    private array $update_field;
+    private array $settings;
+
+    public function __construct()
+    {
+        parent::__construct();
+        $this->update_field = Config::getItemListByClass('billing');
+        $this->settings = Config::getClass('billing');
+    }
 
     /**
      * @throws Exception
      */
     public function index(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        $settings = Config::getClass('billing');
-
         return $response->write(
             $this->view()
-                ->assign('update_field', self::$update_field)
-                ->assign('settings', $settings)
-                ->assign('payment_gateways', self::returnGatewaysList())
-                ->assign('active_payment_gateway', self::returnActiveGateways())
+                ->assign('update_field', $this->update_field)
+                ->assign('settings', $this->settings)
+                ->assign('payment_gateways', $this->returnGatewaysList())
+                ->assign('active_payment_gateway', $this->returnActiveGateways())
                 ->fetch('admin/setting/billing.tpl')
         );
     }
 
     public function save(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        $gateway_in_use = [];
+        $active_gateway = [];
 
-        foreach (self::returnGatewaysList() as $value) {
-            $payment_enable = $request->getParam($value);
-
-            if ($payment_enable === 'true') {
-                $gateway_in_use[] = $value;
+        foreach ($this->returnGatewaysList() as $key => $value) {
+            if ($request->getParam($value) === 'true') {
+                $active_gateway[] = $value;
             }
         }
 
-        $gateway = (new Config())->where('item', 'payment_gateway')->first();
-        $gateway->value = json_encode($gateway_in_use);
-
-        if (! $gateway->save()) {
+        if (! Config::set('payment_gateway', $active_gateway)) {
             return $response->withJson([
                 'ret' => 0,
                 'msg' => '保存支付网关时出错',
             ]);
         }
 
-        foreach (self::$update_field as $item) {
+        foreach ($this->update_field as $item) {
+            if ($item === 'payment_gateway') {
+                continue;
+            }
+
             if (! Config::set($item, $request->getParam($item))) {
                 return $response->withJson([
                     'ret' => 0,
@@ -119,17 +93,51 @@ final class BillingController extends BaseController
                     'payment_intent.succeeded',
                 ],
             ]);
-
-            return $response->withJson([
-                'ret' => 1,
-                'msg' => '设置 Stripe Webhook 成功',
-            ]);
         } catch (ApiErrorException) {
             return $response->withJson([
                 'ret' => 0,
                 'msg' => '设置 Stripe Webhook 失败',
             ]);
         }
+
+        return $response->withJson([
+            'ret' => 1,
+            'msg' => '设置 Stripe Webhook 成功',
+        ]);
+    }
+
+    public function setPaypalWebhook(ServerRequest $request, Response $response, array $args): ResponseInterface
+    {
+        $paypal_client_id = $request->getParam('paypal_client_id');
+        $paypal_client_secret = $request->getParam('paypal_client_secret');
+
+        $gateway_config = [
+            'mode' => 'live',
+            'live' => [
+                'client_id' => $paypal_client_id,
+                'client_secret' => $paypal_client_secret,
+            ],
+            'payment_action' => 'Sale',
+            'currency' => 'USD',
+            'notify_url' => '',
+            'locale' => 'en_US',
+        ];
+
+        try {
+            $pp = new PayPal($gateway_config);
+            $pp->getAccessToken();
+            $pp->createWebHook($_ENV['baseUrl'] . '/payment/notify/paypal', ['PAYMENT.CAPTURE.COMPLETED']);
+        } catch (Throwable $e) {
+            return $response->withJson([
+                'ret' => 0,
+                'msg' => '设置 PayPal Webhook 失败',
+            ]);
+        }
+
+        return $response->withJson([
+            'ret' => 1,
+            'msg' => '设置 PayPal Webhook 成功',
+        ]);
     }
 
     public function returnGatewaysList(): array
@@ -137,7 +145,7 @@ final class BillingController extends BaseController
         $result = [];
 
         foreach (Payment::getAllPaymentMap() as $payment) {
-            $result[$payment::_name()] = $payment::_name();
+            $result[$payment::_readableName()] = $payment::_name();
         }
 
         return $result;
@@ -145,8 +153,6 @@ final class BillingController extends BaseController
 
     public function returnActiveGateways(): ?array
     {
-        $payment_gateways = (new Config())->where('item', 'payment_gateway')->first();
-
-        return json_decode($payment_gateways->value);
+        return Config::obtain('payment_gateway');
     }
 }

+ 13 - 26
src/Controllers/Admin/Setting/CaptchaController.php

@@ -6,52 +6,39 @@ namespace App\Controllers\Admin\Setting;
 
 use App\Controllers\BaseController;
 use App\Models\Config;
-use Exception;
 use Psr\Http\Message\ResponseInterface;
 use Slim\Http\Response;
 use Slim\Http\ServerRequest;
+use Smarty\Exception;
 
 final class CaptchaController extends BaseController
 {
-    private static array $update_field = [
-        'captcha_provider',
-        'enable_reg_captcha',
-        'enable_login_captcha',
-        'enable_checkin_captcha',
-        'enable_reset_password_captcha',
-        // Turnstile
-        'turnstile_sitekey',
-        'turnstile_secret',
-        // Geetest
-        'geetest_id',
-        'geetest_key',
-        // hCaptcha
-        'hcaptcha_sitekey',
-        'hcaptcha_secret',
-        // reCAPTCHA Enterprise
-        'recaptcha_enterprise_key_id',
-        'recaptcha_enterprise_project_id',
-        'recaptcha_enterprise_api_key',
-    ];
+    private array $update_field;
+    private array $settings;
+
+    public function __construct()
+    {
+        parent::__construct();
+        $this->update_field = Config::getItemListByClass('captcha');
+        $this->settings = Config::getClass('captcha');
+    }
 
     /**
      * @throws Exception
      */
     public function index(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        $settings = Config::getClass('captcha');
-
         return $response->write(
             $this->view()
-                ->assign('update_field', self::$update_field)
-                ->assign('settings', $settings)
+                ->assign('update_field', $this->update_field)
+                ->assign('settings', $this->settings)
                 ->fetch('admin/setting/captcha.tpl')
         );
     }
 
     public function save(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        foreach (self::$update_field as $item) {
+        foreach ($this->update_field as $item) {
             if (! Config::set($item, $request->getParam($item))) {
                 return $response->withJson([
                     'ret' => 0,

+ 13 - 20
src/Controllers/Admin/Setting/CronController.php

@@ -6,39 +6,32 @@ namespace App\Controllers\Admin\Setting;
 
 use App\Controllers\BaseController;
 use App\Models\Config;
-use Exception;
 use Psr\Http\Message\ResponseInterface;
 use Slim\Http\Response;
 use Slim\Http\ServerRequest;
+use Smarty\Exception;
 
 final class CronController extends BaseController
 {
-    private static array $update_field = [
-        'daily_job_hour',
-        'daily_job_minute',
-        'enable_daily_finance_mail',
-        'enable_weekly_finance_mail',
-        'enable_monthly_finance_mail',
-        'enable_detect_gfw',
-        'enable_detect_ban',
-        'enable_detect_inactive_user',
-        'detect_inactive_user_checkin_days',
-        'detect_inactive_user_login_days',
-        'detect_inactive_user_use_days',
-        'remove_inactive_user_link_and_invite',
-    ];
+    private array $update_field;
+    private array $settings;
+
+    public function __construct()
+    {
+        parent::__construct();
+        $this->update_field = Config::getItemListByClass('cron');
+        $this->settings = Config::getClass('cron');
+    }
 
     /**
      * @throws Exception
      */
     public function index(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        $settings = Config::getClass('cron');
-
         return $response->write(
             $this->view()
-                ->assign('update_field', self::$update_field)
-                ->assign('settings', $settings)
+                ->assign('update_field', $this->update_field)
+                ->assign('settings', $this->settings)
                 ->fetch('admin/setting/cron.tpl')
         );
     }
@@ -62,7 +55,7 @@ final class CronController extends BaseController
             ]);
         }
 
-        foreach (self::$update_field as $item) {
+        foreach ($this->update_field as $item) {
             if ($item === 'daily_job_minute') {
                 Config::set($item, $daily_job_minute - ($daily_job_minute % 5));
                 continue;

+ 13 - 51
src/Controllers/Admin/Setting/EmailController.php

@@ -7,78 +7,40 @@ namespace App\Controllers\Admin\Setting;
 use App\Controllers\BaseController;
 use App\Models\Config;
 use App\Services\Mail;
-use Exception;
 use Psr\Http\Message\ResponseInterface;
 use Slim\Http\Response;
 use Slim\Http\ServerRequest;
+use Smarty\Exception;
 use Throwable;
 
 final class EmailController extends BaseController
 {
-    private static array $update_field = [
-        'email_driver',
-        'email_verify_code_ttl',
-        'email_password_reset_ttl',
-        'email_request_ip_limit',
-        'email_request_address_limit',
-        // SMTP
-        'smtp_host',
-        'smtp_username',
-        'smtp_password',
-        'smtp_port',
-        'smtp_name',
-        'smtp_sender',
-        'smtp_ssl',
-        'smtp_bbc',
-        // Mailgun
-        'mailgun_key',
-        'mailgun_domain',
-        'mailgun_sender',
-        'mailgun_sender_name',
-        // Sendgrid
-        'sendgrid_key',
-        'sendgrid_sender',
-        'sendgrid_name',
-        // AWS SES
-        'aws_ses_access_key_id',
-        'aws_ses_access_key_secret',
-        'aws_ses_region',
-        'aws_ses_sender',
-        // Postal
-        'postal_host',
-        'postal_key',
-        'postal_sender',
-        'postal_name',
-        // Mailchimp
-        'mailchimp_key',
-        'mailchimp_from_email',
-        'mailchimp_from_name',
-        // Alibaba Cloud
-        'alibabacloud_dm_access_key_id',
-        'alibabacloud_dm_access_key_secret',
-        'alibabacloud_dm_endpoint',
-        'alibabacloud_dm_account_name',
-        'alibabacloud_dm_from_alias',
-    ];
+    private array $update_field;
+    private array $settings;
+
+    public function __construct()
+    {
+        parent::__construct();
+        $this->update_field = Config::getItemListByClass('email');
+        $this->settings = Config::getClass('email');
+    }
 
     /**
      * @throws Exception
      */
     public function index(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        $settings = Config::getClass('email');
-
         return $response->write(
             $this->view()
-                ->assign('update_field', self::$update_field)
-                ->assign('settings', $settings)
+                ->assign('update_field', $this->update_field)
+                ->assign('settings', $this->settings)
                 ->fetch('admin/setting/email.tpl')
         );
     }
 
     public function save(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        foreach (self::$update_field as $item) {
+        foreach ($this->update_field as $item) {
             if (! Config::set($item, $request->getParam($item))) {
                 return $response->withJson([
                     'ret' => 0,

+ 13 - 21
src/Controllers/Admin/Setting/FeatureController.php

@@ -6,47 +6,39 @@ namespace App\Controllers\Admin\Setting;
 
 use App\Controllers\BaseController;
 use App\Models\Config;
-use Exception;
 use Psr\Http\Message\ResponseInterface;
 use Slim\Http\Response;
 use Slim\Http\ServerRequest;
+use Smarty\Exception;
 
 final class FeatureController extends BaseController
 {
-    private static array $update_field = [
-        'display_detect_log',
-        'display_docs',
-        'display_docs_only_for_paid_user',
-        'traffic_log',
-        'traffic_log_retention_days',
-        'subscribe_log',
-        'subscribe_log_retention_days',
-        'notify_new_subscribe',
-        'login_log',
-        'notify_new_login',
-        'enable_checkin',
-        'checkin_min',
-        'checkin_max',
-    ];
+    private array $update_field;
+    private array $settings;
+
+    public function __construct()
+    {
+        parent::__construct();
+        $this->update_field = Config::getItemListByClass('feature');
+        $this->settings = Config::getClass('feature');
+    }
 
     /**
      * @throws Exception
      */
     public function index(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        $settings = Config::getClass('feature');
-
         return $response->write(
             $this->view()
-                ->assign('update_field', self::$update_field)
-                ->assign('settings', $settings)
+                ->assign('update_field', $this->update_field)
+                ->assign('settings', $this->settings)
                 ->fetch('admin/setting/feature.tpl')
         );
     }
 
     public function save(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        foreach (self::$update_field as $item) {
+        foreach ($this->update_field as $item) {
             if (! Config::set($item, $request->getParam($item))) {
                 return $response->withJson([
                     'ret' => 0,

+ 16 - 50
src/Controllers/Admin/Setting/ImController.php

@@ -11,78 +11,44 @@ use App\Services\IM\Discord;
 use App\Services\IM\Slack;
 use App\Services\IM\Telegram;
 use App\Utils\Tools;
-use Exception;
 use GuzzleHttp\Exception\GuzzleException;
 use Psr\Http\Message\ResponseInterface;
 use Slim\Http\Response;
 use Slim\Http\ServerRequest;
+use Smarty\Exception;
 use Telegram\Bot\Api;
 use Telegram\Bot\Exceptions\TelegramSDKException;
 
 final class ImController extends BaseController
 {
-    private static array $update_field = [
-        // TODO: rename these to im service independent
-        'im_bot_group_notify_add_node',
-        'im_bot_group_notify_update_node',
-        'im_bot_group_notify_delete_node',
-        'im_bot_group_notify_node_gfwed',
-        'im_bot_group_notify_node_ungfwed',
-        'im_bot_group_notify_node_online',
-        'im_bot_group_notify_node_offline',
-        'im_bot_group_notify_daily_job',
-        'im_bot_group_notify_diary',
-        'im_bot_group_notify_ann_create',
-        'im_bot_group_notify_ann_update',
-        // Telegram
-        'telegram_token',
-        'telegram_bot',
-        'telegram_chatid',
-        'enable_telegram_group_notify',
-        'telegram_unbind_kick_member',
-        'telegram_group_bound_user',
-        'enable_welcome_message',
-        'telegram_group_quiet',
-        'allow_to_join_new_groups',
-        'group_id_allowed_to_join',
-        'help_any_command',
-        // Discord
-        'discord_bot_token',
-        'discord_client_id',
-        'discord_client_secret',
-        'discord_guild_id',
-        'discord_channel_id',
-        'enable_discord_channel_notify',
-        // Slack
-        'slack_token',
-        'slack_client_id',
-        'slack_client_secret',
-        'slack_team_id',
-        'slack_channel_id',
-        'enable_slack_channel_notify',
-    ];
-
     private static string $success_msg = '测试信息发送成功';
     private static string $err_msg = '测试信息发送失败';
+    private array $update_field;
+    private array $settings;
+
+    public function __construct()
+    {
+        parent::__construct();
+        $this->update_field = Config::getItemListByClass('im');
+        $this->settings = Config::getClass('im');
+    }
 
     /**
      * @throws Exception
      */
     public function index(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        $settings = Config::getClass('im');
-
         return $response->write(
             $this->view()
-                ->assign('update_field', self::$update_field)
-                ->assign('settings', $settings)
+                ->assign('update_field', $this->update_field)
+                ->assign('settings', $this->settings)
                 ->fetch('admin/setting/im.tpl')
         );
     }
 
     public function save(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        foreach (self::$update_field as $item) {
+        foreach ($this->update_field as $item) {
             if (! Config::set($item, $request->getParam($item))) {
                 return $response->withJson([
                     'ret' => 0,
@@ -156,7 +122,7 @@ final class ImController extends BaseController
                 (int) $request->getParam('telegram_chat_id'),
                 I18n::trans('bot.test_message', $_ENV['locale']),
             );
-        } catch (TelegramSDKException|Exception $e) {
+        } catch (TelegramSDKException|\Exception $e) {
             return $response->withJson([
                 'ret' => 0,
                 'msg' => $this::$err_msg . ' ' . $e->getMessage(),
@@ -176,7 +142,7 @@ final class ImController extends BaseController
                 (int) $request->getParam('discord_channel_id'),
                 I18n::trans('bot.test_message', $_ENV['locale']),
             );
-        } catch (GuzzleException|Exception $e) {
+        } catch (GuzzleException|\Exception $e) {
             return $response->withJson([
                 'ret' => 0,
                 'msg' => $this::$err_msg . ' ' . $e->getMessage(),
@@ -196,7 +162,7 @@ final class ImController extends BaseController
                 (int) $request->getParam('slack_channel_id'),
                 I18n::trans('bot.test_message', $_ENV['locale']),
             );
-        } catch (GuzzleException|Exception $e) {
+        } catch (GuzzleException|\Exception $e) {
             return $response->withJson([
                 'ret' => 0,
                 'msg' => $this::$err_msg . ' ' . $e->getMessage(),

+ 13 - 24
src/Controllers/Admin/Setting/LlmController.php

@@ -6,50 +6,39 @@ namespace App\Controllers\Admin\Setting;
 
 use App\Controllers\BaseController;
 use App\Models\Config;
-use Exception;
 use Psr\Http\Message\ResponseInterface;
 use Slim\Http\Response;
 use Slim\Http\ServerRequest;
+use Smarty\Exception;
 
 final class LlmController extends BaseController
 {
-    private static array $update_field = [
-        'llm_backend',
-        'openai_api_key',
-        'openai_model_id',
-        'google_ai_api_key',
-        'google_ai_model_id',
-        'vertex_ai_access_token',
-        'vertex_ai_location',
-        'vertex_ai_project_id',
-        'vertex_ai_model_id',
-        'huggingface_api_key',
-        'huggingface_endpoint_url',
-        'cf_workers_ai_account_id',
-        'cf_workers_ai_api_token',
-        'cf_workers_ai_model_id',
-        'anthropic_api_key',
-        'anthropic_model_id',
-    ];
+    private array $update_field;
+    private array $settings;
+
+    public function __construct()
+    {
+        parent::__construct();
+        $this->update_field = Config::getItemListByClass('llm');
+        $this->settings = Config::getClass('llm');
+    }
 
     /**
      * @throws Exception
      */
     public function index(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        $settings = Config::getClass('llm');
-
         return $response->write(
             $this->view()
-                ->assign('update_field', self::$update_field)
-                ->assign('settings', $settings)
+                ->assign('update_field', $this->update_field)
+                ->assign('settings', $this->settings)
                 ->fetch('admin/setting/llm.tpl')
         );
     }
 
     public function save(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        foreach (self::$update_field as $item) {
+        foreach ($this->update_field as $item) {
             if (! Config::set($item, $request->getParam($item))) {
                 return $response->withJson([
                     'ret' => 0,

+ 13 - 15
src/Controllers/Admin/Setting/RefController.php

@@ -6,41 +6,39 @@ namespace App\Controllers\Admin\Setting;
 
 use App\Controllers\BaseController;
 use App\Models\Config;
-use Exception;
 use Psr\Http\Message\ResponseInterface;
 use Slim\Http\Response;
 use Slim\Http\ServerRequest;
+use Smarty\Exception;
 
 final class RefController extends BaseController
 {
-    private static array $update_field = [
-        'invite_reg_money_reward',
-        'invite_reg_traffic_reward',
-        'invite_mode',
-        'invite_reward_mode',
-        'invite_reward_rate',
-        'invite_reward_count_limit',
-        'invite_reward_total_limit',
-    ];
+    private array $update_field;
+    private array $settings;
+
+    public function __construct()
+    {
+        parent::__construct();
+        $this->update_field = Config::getItemListByClass('ref');
+        $this->settings = Config::getClass('ref');
+    }
 
     /**
      * @throws Exception
      */
     public function index(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        $settings = Config::getClass('ref');
-
         return $response->write(
             $this->view()
-                ->assign('update_field', self::$update_field)
-                ->assign('settings', $settings)
+                ->assign('update_field', $this->update_field)
+                ->assign('settings', $this->settings)
                 ->fetch('admin/setting/ref.tpl')
         );
     }
 
     public function save(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        foreach (self::$update_field as $item) {
+        foreach ($this->update_field as $item) {
             if (! Config::set($item, $request->getParam($item))) {
                 return $response->withJson([
                     'ret' => 0,

+ 13 - 22
src/Controllers/Admin/Setting/RegController.php

@@ -6,48 +6,39 @@ namespace App\Controllers\Admin\Setting;
 
 use App\Controllers\BaseController;
 use App\Models\Config;
-use Exception;
 use Psr\Http\Message\ResponseInterface;
 use Slim\Http\Response;
 use Slim\Http\ServerRequest;
+use Smarty\Exception;
 
 final class RegController extends BaseController
 {
-    private static array $update_field = [
-        'reg_mode',
-        'reg_email_verify',
-        'reg_daily_report',
-        'random_group',
-        'min_port',
-        'max_port',
-        'reg_traffic',
-        'free_user_reset_day',
-        'free_user_reset_bandwidth',
-        'reg_class',
-        'reg_class_time',
-        'reg_method',
-        'reg_ip_limit',
-        'reg_speed_limit',
-    ];
+    private array $update_field;
+    private array $settings;
+
+    public function __construct()
+    {
+        parent::__construct();
+        $this->update_field = Config::getItemListByClass('reg');
+        $this->settings = Config::getClass('reg');
+    }
 
     /**
      * @throws Exception
      */
     public function index(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        $settings = Config::getClass('reg');
-
         return $response->write(
             $this->view()
-                ->assign('update_field', self::$update_field)
-                ->assign('settings', $settings)
+                ->assign('update_field', $this->update_field)
+                ->assign('settings', $this->settings)
                 ->fetch('admin/setting/reg.tpl')
         );
     }
 
     public function save(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        foreach (self::$update_field as $item) {
+        foreach ($this->update_field as $item) {
             if (! Config::set($item, $request->getParam($item))) {
                 return $response->withJson([
                     'ret' => 0,

+ 13 - 12
src/Controllers/Admin/Setting/SubController.php

@@ -6,38 +6,39 @@ namespace App\Controllers\Admin\Setting;
 
 use App\Controllers\BaseController;
 use App\Models\Config;
-use Exception;
 use Psr\Http\Message\ResponseInterface;
 use Slim\Http\Response;
 use Slim\Http\ServerRequest;
+use Smarty\Exception;
 
 final class SubController extends BaseController
 {
-    private static array $update_field = [
-        'enable_forced_replacement',
-        'enable_ss_sub',
-        'enable_v2_sub',
-        'enable_trojan_sub',
-    ];
+    private array $update_field;
+    private array $settings;
+
+    public function __construct()
+    {
+        parent::__construct();
+        $this->update_field = Config::getItemListByClass('subscribe');
+        $this->settings = Config::getClass('subscribe');
+    }
 
     /**
      * @throws Exception
      */
     public function index(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        $settings = Config::getClass('subscribe');
-
         return $response->write(
             $this->view()
-                ->assign('update_field', self::$update_field)
-                ->assign('settings', $settings)
+                ->assign('update_field', $this->update_field)
+                ->assign('settings', $this->settings)
                 ->fetch('admin/setting/sub.tpl')
         );
     }
 
     public function save(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        foreach (self::$update_field as $item) {
+        foreach ($this->update_field as $item) {
             if (! Config::set($item, $request->getParam($item))) {
                 return $response->withJson([
                     'ret' => 0,

+ 13 - 15
src/Controllers/Admin/Setting/SupportController.php

@@ -6,41 +6,39 @@ namespace App\Controllers\Admin\Setting;
 
 use App\Controllers\BaseController;
 use App\Models\Config;
-use Exception;
 use Psr\Http\Message\ResponseInterface;
 use Slim\Http\Response;
 use Slim\Http\ServerRequest;
+use Smarty\Exception;
 
 final class SupportController extends BaseController
 {
-    private static array $update_field = [
-        'live_chat',
-        'crisp_id',
-        'livechat_license',
-        // Ticket
-        'enable_ticket',
-        'mail_ticket',
-        'ticket_limit',
-    ];
+    private array $update_field;
+    private array $settings;
+
+    public function __construct()
+    {
+        parent::__construct();
+        $this->update_field = Config::getItemListByClass('support');
+        $this->settings = Config::getClass('support');
+    }
 
     /**
      * @throws Exception
      */
     public function index(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        $settings = Config::getClass('support');
-
         return $response->write(
             $this->view()
-                ->assign('update_field', self::$update_field)
-                ->assign('settings', $settings)
+                ->assign('update_field', $this->update_field)
+                ->assign('settings', $this->settings)
                 ->fetch('admin/setting/support.tpl')
         );
     }
 
     public function save(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        foreach (self::$update_field as $item) {
+        foreach ($this->update_field as $item) {
             if (! Config::set($item, $request->getParam($item))) {
                 return $response->withJson([
                     'ret' => 0,

+ 69 - 74
src/Controllers/Admin/TicketController.php

@@ -9,18 +9,20 @@ use App\Models\Ticket;
 use App\Models\User;
 use App\Services\LLM;
 use App\Services\Notification;
+use App\Utils\ResponseHelper;
 use App\Utils\Tools;
-use Exception;
 use GuzzleHttp\Exception\GuzzleException;
 use Psr\Http\Client\ClientExceptionInterface;
 use Psr\Http\Message\ResponseInterface;
 use Slim\Http\Response;
 use Slim\Http\ServerRequest;
+use Smarty\Exception;
 use Telegram\Bot\Exceptions\TelegramSDKException;
 use function array_merge;
 use function count;
 use function json_decode;
 use function json_encode;
+use function nl2br;
 use function time;
 
 final class TicketController extends BaseController
@@ -38,11 +40,7 @@ final class TicketController extends BaseController
             ],
         ];
 
-    private static string $err_msg = '请求失败';
-
     /**
-     * 后台工单页面
-     *
      * @throws Exception
      */
     public function index(ServerRequest $request, Response $response, array $args): ResponseInterface
@@ -54,106 +52,114 @@ final class TicketController extends BaseController
         );
     }
 
-    /**
-     * @throws TelegramSDKException
-     * @throws GuzzleException
-     * @throws ClientExceptionInterface
-     */
-    public function update(ServerRequest $request, Response $response, array $args): ResponseInterface
+    public function reply(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
         $id = $args['id'];
         $comment = $request->getParam('comment') ?? '';
 
         if ($comment === '') {
-            return $response->withJson([
-                'ret' => 0,
-                'msg' => self::$err_msg,
-            ]);
+            return ResponseHelper::error($response, '请输入评论内容');
         }
 
         $ticket = (new Ticket())->where('id', $id)->first();
 
         if ($ticket === null) {
-            return $response->withJson([
-                'ret' => 0,
-                'msg' => self::$err_msg,
-            ]);
+            return ResponseHelper::error($response, '工单不存在');
         }
 
         $content_old = json_decode($ticket->content, true);
         $content_new = [
             [
                 'comment_id' => $content_old[count($content_old) - 1]['comment_id'] + 1,
+                'commenter_type' => 'admin',
                 'commenter_name' => 'Admin',
                 'comment' => $comment,
                 'datetime' => time(),
             ],
         ];
 
-        $user = (new User())->find($ticket->userid);
-
-        Notification::notifyUser(
-            $user,
-            $_ENV['appName'] . '-工单被回复',
-            '你好,有人回复了<a href="' . $_ENV['baseUrl'] . '/user/ticket/' . $ticket->id . '/view">工单</a>,请你查看。'
-        );
-
         $ticket->content = json_encode(array_merge($content_old, $content_new));
         $ticket->status = 'open_wait_user';
         $ticket->save();
 
-        return $response->withJson([
-            'ret' => 1,
-            'msg' => '提交成功',
-        ]);
+        try {
+            Notification::notifyUser(
+                (new User())->find($ticket->userid),
+                $_ENV['appName'] . '-工单被回复',
+                '你好,有人回复了<a href="' . $_ENV['baseUrl'] . '/user/ticket/' . $ticket->id . '/view">工单</a>,请你查看。'
+            );
+        } catch (TelegramSDKException|GuzzleException|ClientExceptionInterface $e) {
+            return $response->withHeader('HX-Refresh', 'true');
+        }
+
+        return $response->withHeader('HX-Refresh', 'true');
     }
 
-    /**
-     * @throws GuzzleException
-     * @throws TelegramSDKException
-     * @throws ClientExceptionInterface
-     */
-    public function updateAI(ServerRequest $request, Response $response, array $args): ResponseInterface
+    public function llmReply(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
         $id = $args['id'];
-
         $ticket = (new Ticket())->where('id', $id)->first();
 
         if ($ticket === null) {
-            return $response->withJson([
-                'ret' => 0,
-                'msg' => self::$err_msg,
-            ]);
+            return ResponseHelper::error($response, '工单不存在');
         }
 
         $content_old = json_decode($ticket->content, true);
-        // 获取用户的第一个问题,作为 LLM 的输入
-        $ai_reply = LLM::genTextResponse($content_old[0]['comment']);
+
+        if (count($content_old) === 1) {
+            $context = [
+                [
+                    'role' => 'user',
+                    'content' => $ticket->title,
+                ],
+                [
+                    'role' => 'user',
+                    'content' => $content_old[0]['comment'],
+                ],
+            ];
+        } else {
+            $context = [
+                [
+                    'role' => 'user',
+                    'content' => $ticket->title,
+                ],
+            ];
+
+            foreach ($content_old as $comment) {
+                $context[] = [
+                    'role' => $comment['commenter_type'] ?? $comment['commenter_name'] === 'Admin' ? 'admin' : 'user',
+                    'content' => $comment['comment'],
+                ];
+            }
+        }
+
+        $llm_response = LLM::genTextResponseWithContext($context);
+
         $content_new = [
             [
                 'comment_id' => $content_old[count($content_old) - 1]['comment_id'] + 1,
-                'commenter_name' => 'AI Admin',
-                'comment' => $ai_reply,
+                'commenter_type' => 'llm',
+                'commenter_name' => 'AI Assistant',
+                'comment' => $llm_response,
                 'datetime' => time(),
             ],
         ];
 
-        $user = (new User())->find($ticket->userid);
-
-        Notification::notifyUser(
-            $user,
-            $_ENV['appName'] . '-工单被回复',
-            '你好,AI 回复了<a href="' . $_ENV['baseUrl'] . '/user/ticket/' . $ticket->id . '/view">工单</a>,请你查看。'
-        );
-
         $ticket->content = json_encode(array_merge($content_old, $content_new));
         $ticket->status = 'open_wait_user';
         $ticket->save();
 
-        return $response->withJson([
-            'ret' => 1,
-            'msg' => '提交成功',
-        ]);
+        try {
+            Notification::notifyUser(
+                (new User())->find($ticket->userid),
+                $_ENV['appName'] . '-工单被回复',
+                '你好,AI助理回复了<a href="' . $_ENV['baseUrl'] . '/user/ticket/' . $ticket->id . '/view">工单</a>,请你查看。'
+            );
+        } catch (TelegramSDKException|GuzzleException|ClientExceptionInterface $e) {
+            return $response->withHeader('HX-Refresh', 'true');
+        }
+
+        return $response->withHeader('HX-Refresh', 'true');
     }
 
     /**
@@ -173,6 +179,7 @@ final class TicketController extends BaseController
         $comments = json_decode($ticket->content);
 
         foreach ($comments as $comment) {
+            $comment->comment = nl2br($comment->comment);
             $comment->datetime = Tools::toDateTime((int) $comment->datetime);
         }
 
@@ -193,26 +200,17 @@ final class TicketController extends BaseController
         $ticket = (new Ticket())->where('id', '=', $id)->first();
 
         if ($ticket === null) {
-            return $response->withJson([
-                'ret' => 0,
-                'msg' => self::$err_msg,
-            ]);
+            return ResponseHelper::error($response, '工单不存在');
         }
 
         if ($ticket->status === 'closed') {
-            return $response->withJson([
-                'ret' => 0,
-                'msg' => self::$err_msg,
-            ]);
+            return ResponseHelper::error($response, '工单已关闭,无需重复操作');
         }
 
         $ticket->status = 'closed';
         $ticket->save();
 
-        return $response->withJson([
-            'ret' => 1,
-            'msg' => '关闭成功',
-        ]);
+        return ResponseHelper::success($response, '工单关闭成功');
     }
 
     /**
@@ -223,10 +221,7 @@ final class TicketController extends BaseController
         $id = $args['id'];
         (new Ticket())->where('id', '=', $id)->delete();
 
-        return $response->withJson([
-            'ret' => 1,
-            'msg' => '删除成功',
-        ]);
+        return ResponseHelper::success($response, '工单删除成功');
     }
 
     /**

+ 36 - 22
src/Controllers/Admin/UserController.php

@@ -9,6 +9,7 @@ use App\Controllers\BaseController;
 use App\Models\Config;
 use App\Models\User;
 use App\Models\UserMoneyLog;
+use App\Services\I18n;
 use App\Utils\Hash;
 use App\Utils\Tools;
 use Exception;
@@ -66,26 +67,22 @@ final class UserController extends BaseController
     private static array $update_field = [
         'email',
         'user_name',
-        'remark',
         'pass',
         'money',
-        'is_admin',
-        'ga_enable',
-        'is_banned',
-        'banned_reason',
-        'is_shadow_banned',
-        'transfer_enable',
         'ref_by',
-        'class_expire',
+        'port',
+        'method',
+        'transfer_enable',
         'node_group',
         'class',
+        'class_expire',
         'auto_reset_day',
         'auto_reset_bandwidth',
         'node_speedlimit',
         'node_iplimit',
-        'port',
-        'passwd',
-        'method',
+        'locale',
+        'banned_reason',
+        'remark',
     ];
 
     /**
@@ -130,7 +127,17 @@ final class UserController extends BaseController
             $password = Tools::genRandomChar(16);
         }
 
-        (new AuthController())->registerHelper($response, 'user', $email, $password, '', 0, '', $balance, 1);
+        (new AuthController())->registerHelper(
+            $response,
+            'user',
+            $email,
+            $password,
+            '',
+            0,
+            '',
+            $balance,
+            1
+        );
         $user = (new User())->where('email', $email)->first();
 
         if ($ref_by !== '') {
@@ -150,11 +157,16 @@ final class UserController extends BaseController
     public function edit(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
         $user = (new User())->find($args['id']);
+        $user->last_use_time = Tools::toDateTime($user->last_use_time);
+        $user->last_check_in_time = Tools::toDateTime($user->last_check_in_time);
+        $user->last_login_time = Tools::toDateTime($user->last_login_time);
 
         return $response->write(
             $this->view()
                 ->assign('update_field', self::$update_field)
                 ->assign('edit_user', $user)
+                ->assign('ss_methods', Tools::getSsMethod())
+                ->assign('locales', I18n::getLocaleList())
                 ->fetch('admin/user/edit.tpl')
         );
     }
@@ -185,23 +197,24 @@ final class UserController extends BaseController
 
         $user->email = $request->getParam('email');
         $user->user_name = $request->getParam('user_name');
-        $user->remark = $request->getParam('remark');
-        $user->is_admin = $request->getParam('is_admin') === 'true' ? 1 : 0;
-        $user->ga_enable = $request->getParam('ga_enable') === 'true' ? 1 : 0;
-        $user->is_banned = $request->getParam('is_banned') === 'true' ? 1 : 0;
-        $user->banned_reason = $request->getParam('banned_reason');
-        $user->is_shadow_banned = $request->getParam('is_shadow_banned') === 'true' ? 1 : 0;
-        $user->transfer_enable = Tools::autoBytesR($request->getParam('transfer_enable'));
         $user->ref_by = $request->getParam('ref_by');
-        $user->class_expire = $request->getParam('class_expire');
+        $user->port = $request->getParam('port');
+        $user->method = $request->getParam('method');
+        $user->transfer_enable = Tools::autoBytesR($request->getParam('transfer_enable'));
         $user->node_group = $request->getParam('node_group');
         $user->class = $request->getParam('class');
+        $user->class_expire = $request->getParam('class_expire');
         $user->auto_reset_day = $request->getParam('auto_reset_day');
         $user->auto_reset_bandwidth = $request->getParam('auto_reset_bandwidth');
         $user->node_speedlimit = $request->getParam('node_speedlimit');
         $user->node_iplimit = $request->getParam('node_iplimit');
-        $user->port = $request->getParam('port');
-        $user->method = $request->getParam('method');
+        $user->locale = $request->getParam('locale');
+        $user->is_admin = $request->getParam('is_admin') === 'true' ? 1 : 0;
+        $user->ga_enable = $request->getParam('ga_enable') === 'true' ? 1 : 0;
+        $user->is_shadow_banned = $request->getParam('is_shadow_banned') === 'true' ? 1 : 0;
+        $user->is_banned = $request->getParam('is_banned') === 'true' ? 1 : 0;
+        $user->banned_reason = $request->getParam('banned_reason');
+        $user->remark = $request->getParam('remark');
 
         if (! $user->save()) {
             return $response->withJson([
@@ -209,6 +222,7 @@ final class UserController extends BaseController
                 'msg' => '修改失败',
             ]);
         }
+
         return $response->withJson([
             'ret' => 1,
             'msg' => '修改成功',

+ 7 - 3
src/Controllers/User/ClientController.php

@@ -22,12 +22,16 @@ final class ClientController extends BaseController
         }
 
         $clients = [
-            'Clash.Verge.exe',
-            'Clash.Verge_aarch64.dmg',
-            'Clash.Verge.AppImage.tar.gz',
+            'Clash.Nyanpasu.exe',
+            'Clash.Nyanpasu.AppImage',
+            'Clash.Nyanpasu_aarch64.dmg',
             'CMFA.apk',
             'SFA.apk',
             'SFM.zip',
+            'Hiddify.apk',
+            'Hiddify.AppImage',
+            'Hiddify.dmg',
+            'Hiddify.exe',
         ];
 
         if (! in_array($clientName, $clients)) {

+ 2 - 2
src/Controllers/User/InfoController.php

@@ -33,7 +33,7 @@ final class InfoController extends BaseController
     public function index(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
         $themes = Tools::getDir(BASE_PATH . '/resources/views');
-        $methods = Tools::getSsMethod('method');
+        $methods = Tools::getSsMethod();
         $ga_url = MFA::getGaUrl($this->user);
 
         return $response->write($this->view()
@@ -289,7 +289,7 @@ final class InfoController extends BaseController
         $theme_mode = (int) $this->antiXss->xss_clean($request->getParam('theme_mode'));
         $user = $this->user;
 
-        $user->is_dark_mode = in_array($theme_mode, [0, 1]) ? $theme_mode : 0;
+        $user->is_dark_mode = in_array($theme_mode, [0, 1, 2]) ? $theme_mode : 0;
 
         if (! $user->save()) {
             return ResponseHelper::error($response, '切换失败');

+ 3 - 3
src/Controllers/User/OrderController.php

@@ -59,7 +59,7 @@ final class OrderController extends BaseController
         $product_id = $this->antiXss->xss_clean($request->getQueryParams()['product_id']) ?? null;
         $redir = Cookie::get('redir');
 
-        if ($redir !== null) {
+        if ($redir !== '') {
             Cookie::set(['redir' => ''], time() - 1);
         }
 
@@ -247,7 +247,7 @@ final class OrderController extends BaseController
         $order->product_content = $product->content;
         $order->coupon = $coupon_raw;
         $order->price = $buy_price;
-        $order->status = 'pending_payment';
+        $order->status = $buy_price === 0 ? 'pending_activation' : 'pending_payment';
         $order->create_time = time();
         $order->update_time = time();
         $order->save();
@@ -272,7 +272,7 @@ final class OrderController extends BaseController
         $invoice->order_id = $order->id;
         $invoice->content = json_encode($invoice_content);
         $invoice->price = $buy_price;
-        $invoice->status = 'unpaid';
+        $invoice->status = $buy_price === 0 ? 'paid_gateway' : 'unpaid';
         $invoice->create_time = time();
         $invoice->update_time = time();
         $invoice->pay_time = 0;

+ 12 - 24
src/Controllers/User/TicketController.php

@@ -9,24 +9,24 @@ use App\Models\Config;
 use App\Models\Ticket;
 use App\Services\Notification;
 use App\Services\RateLimit;
+use App\Utils\ResponseHelper;
 use App\Utils\Tools;
-use Exception;
 use GuzzleHttp\Exception\GuzzleException;
 use Psr\Http\Client\ClientExceptionInterface;
 use Psr\Http\Message\ResponseInterface;
 use Slim\Http\Response;
 use Slim\Http\ServerRequest;
+use Smarty\Exception;
 use Telegram\Bot\Exceptions\TelegramSDKException;
 use function array_merge;
 use function count;
 use function json_decode;
 use function json_encode;
+use function nl2br;
 use function time;
 
 final class TicketController extends BaseController
 {
-    private static string $err_msg = '请求失败';
-
     /**
      * @throws Exception
      */
@@ -69,15 +69,13 @@ final class TicketController extends BaseController
             $comment === '' ||
             $type === ''
         ) {
-            return $response->withJson([
-                'ret' => 0,
-                'msg' => self::$err_msg,
-            ]);
+            return ResponseHelper::error($response, '工单创建失败');
         }
 
         $content = [
             [
                 'comment_id' => 0,
+                'commenter_type' => 'user',
                 'commenter_name' => $this->user->user_name,
                 'comment' => $this->antiXss->xss_clean($comment),
                 'datetime' => time(),
@@ -100,10 +98,7 @@ final class TicketController extends BaseController
             );
         }
 
-        return $response->withJson([
-            'ret' => 1,
-            'msg' => '提交成功',
-        ]);
+        return $response->withHeader('HX-Redirect', '/user/ticket/' . $ticket->id . '/view');
     }
 
     /**
@@ -111,7 +106,7 @@ final class TicketController extends BaseController
      * @throws TelegramSDKException
      * @throws ClientExceptionInterface
      */
-    public function update(ServerRequest $request, Response $response, array $args): ResponseInterface
+    public function reply(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
         $id = $args['id'];
         $comment = $request->getParam('comment') ?? '';
@@ -120,25 +115,20 @@ final class TicketController extends BaseController
             $this->user->is_shadow_banned ||
             $comment === ''
         ) {
-            return $response->withJson([
-                'ret' => 0,
-                'msg' => self::$err_msg,
-            ]);
+            ResponseHelper::error($response, '工单回复失败');
         }
 
         $ticket = (new Ticket())->where('id', $id)->where('userid', $this->user->id)->first();
 
         if ($ticket === null) {
-            return $response->withJson([
-                'ret' => 0,
-                'msg' => self::$err_msg,
-            ]);
+            ResponseHelper::error($response, '工单不存在');
         }
 
         $content_old = json_decode($ticket->content, true);
         $content_new = [
             [
                 'comment_id' => $content_old[count($content_old) - 1]['comment_id'] + 1,
+                'commenter_type' => 'user',
                 'commenter_name' => $this->user->user_name,
                 'comment' => $this->antiXss->xss_clean($comment),
                 'datetime' => time(),
@@ -158,10 +148,7 @@ final class TicketController extends BaseController
             );
         }
 
-        return $response->withJson([
-            'ret' => 1,
-            'msg' => '提交成功',
-        ]);
+        return $response->withHeader('HX-Refresh', 'true');
     }
 
     /**
@@ -183,6 +170,7 @@ final class TicketController extends BaseController
         $comments = json_decode($ticket->content);
 
         foreach ($comments as $comment) {
+            $comment->comment = nl2br($comment->comment);
             $comment->datetime = Tools::toDateTime((int) $comment->datetime);
         }
 

+ 38 - 22
src/Models/Config.php

@@ -5,6 +5,10 @@ declare(strict_types=1);
 namespace App\Models;
 
 use Illuminate\Database\Query\Builder;
+use Illuminate\Database\QueryException;
+use function is_array;
+use function json_decode;
+use function json_encode;
 
 /**
  * @property int    $id         配置ID
@@ -12,6 +16,7 @@ use Illuminate\Database\Query\Builder;
  * @property string $value      配置值
  * @property string $class      配置类别
  * @property string $is_public  是否为公共参数
+ * @property string $type       配置值类型
  * @property string $default    默认值
  * @property string $mark       备注
  *
@@ -22,13 +27,14 @@ final class Config extends Model
     protected $connection = 'default';
     protected $table = 'config';
 
-    public static function obtain($item): bool|int|string
+    public static function obtain($item): bool|int|array|string
     {
         $config = (new Config())->where('item', $item)->first();
 
         return match ($config->type) {
             'bool' => (bool) $config->value,
             'int' => (int) $config->value,
+            'array' => json_decode($config->value),
             default => (string) $config->value,
         };
     }
@@ -39,46 +45,56 @@ final class Config extends Model
         $all_configs = (new Config())->where('class', $class)->get();
 
         foreach ($all_configs as $config) {
-            if ($config->type === 'bool') {
-                $configs[$config->item] = (bool) $config->value;
-            } elseif ($config->type === 'int') {
-                $configs[$config->item] = (int) $config->value;
-            } else {
-                $configs[$config->item] = (string) $config->value;
-            }
+            $configs[$config->item] = match ($config->type) {
+                'bool' => (bool) $config->value,
+                'int' => (int) $config->value,
+                'array' => json_decode($config->value),
+                default => (string) $config->value,
+            };
         }
 
         return $configs;
     }
 
+    public static function getItemListByClass($class): array
+    {
+        $items = [];
+        $all_configs = (new Config())->where('class', $class)->get();
+
+        foreach ($all_configs as $config) {
+            $items[] = $config->item;
+        }
+
+        return $items;
+    }
+
     public static function getPublicConfig(): array
     {
         $configs = [];
         $all_configs = (new Config())->where('is_public', '1')->get();
 
         foreach ($all_configs as $config) {
-            if ($config->type === 'bool') {
-                $configs[$config->item] = (bool) $config->value;
-            } elseif ($config->type === 'int') {
-                $configs[$config->item] = (int) $config->value;
-            } else {
-                $configs[$config->item] = (string) $config->value;
-            }
+            $configs[$config->item] = match ($config->type) {
+                'bool' => (bool) $config->value,
+                'int' => (int) $config->value,
+                'array' => json_decode($config->value),
+                default => (string) $config->value,
+            };
         }
 
         return $configs;
     }
 
-    public static function set($item, $value): bool
+    public static function set(string $item, mixed $value): bool
     {
-        $config = (new Config())->where('item', $item)->first();
+        $value = is_array($value) ? json_encode($value) : $value;
 
-        if ($config->tpye === 'array') {
-            $config->value = json_encode($value);
-        } else {
-            $config->value = $value;
+        try {
+            (new Config())->where('item', $item)->update(['value' => $value]);
+        } catch (QueryException $e) {
+            return false;
         }
 
-        return $config->save();
+        return true;
     }
 }

+ 1 - 1
src/Models/Node.php

@@ -24,7 +24,7 @@ use const DNS_AAAA;
  * @property int    $dynamic_rate_type       动态流量倍率计算方式
  * @property string $dynamic_rate_config     动态流量倍率配置
  * @property int    $node_class              节点等级
- * @property float  $node_speedlimit         节点限速
+ * @property int    $node_speedlimit         节点限速
  * @property int    $node_bandwidth          节点流量
  * @property int    $node_bandwidth_limit    节点流量限制
  * @property int    $bandwidthlimit_resetday 流量重置日

+ 1 - 1
src/Models/Paylist.php

@@ -12,7 +12,7 @@ use Illuminate\Database\Query\Builder;
  * @property float  $total      总金额
  * @property int    $status     状态
  * @property int    $invoice_id 账单ID
- * @property string $tradeno    网关单号
+ * @property string $tradeno    网关识别码
  * @property string $gateway    支付网关
  * @property int    $datetime   创建时间
  *

+ 1 - 2
src/Models/User.php

@@ -38,7 +38,7 @@ use const PHP_EOL;
  * @property int    $ref_by 邀请人ID
  * @property string $method Shadowsocks加密方式
  * @property string $reg_ip 注册IP
- * @property float  $node_speedlimit 用户限速
+ * @property int    $node_speedlimit 用户限速
  * @property int    $node_iplimit 同时可连接IP数
  * @property int    $is_admin 是否管理员
  * @property int    $im_type 联系方式类型
@@ -86,7 +86,6 @@ final class User extends Model
     protected $casts = [
         'money' => 'float',
         'port' => 'int',
-        'node_speedlimit' => 'float',
         'daily_mail_enable' => 'int',
         'ref_by' => 'int',
     ];

+ 1 - 1
src/Services/Bot/Telegram/Callback.php

@@ -586,7 +586,7 @@ final class Callback
             case 'encrypt':
                 // 加密方式更改
                 $keyboard = $back;
-                $method = Tools::getSsMethod('method');
+                $method = Tools::getSsMethod();
 
                 if (isset($CallbackDataExplode[1])) {
                     if (in_array($CallbackDataExplode[1], $method)) {

+ 2 - 0
src/Services/DynamicRate.php

@@ -68,6 +68,8 @@ final class DynamicRate
     ): bool {
         return ! ($max_rate < 0 ||
             $min_rate < 0 ||
+            $max_rate > 999 ||
+            $min_rate > 999 ||
             $max_rate_time < 0 ||
             $min_rate_time < 0 ||
             $max_rate_time > 24 ||

+ 1 - 1
src/Services/Exchange.php

@@ -18,7 +18,7 @@ final class Exchange
      */
     public function exchange(float $amount, string $from, string $to): float
     {
-        return round($amount * self::getExchangeRate($from, $to), 2);
+        return round($amount * $this->getExchangeRate($from, $to), 2);
     }
 
     /**

+ 25 - 12
src/Services/Gateway/AlipayF2F.php

@@ -12,6 +12,7 @@ use Alipay\OpenAPISDK\Util\AlipayConfigUtil;
 use Alipay\OpenAPISDK\Util\AlipayLogger;
 use Alipay\OpenAPISDK\Util\Model\AlipayConfig;
 use App\Models\Config;
+use App\Models\Invoice;
 use App\Models\Paylist;
 use App\Services\Auth;
 use App\Services\View;
@@ -64,9 +65,17 @@ final class AlipayF2F extends Base
      */
     public function purchase(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        $price = $this->antiXss->xss_clean($request->getParam('amount'));
         $invoice_id = $this->antiXss->xss_clean($request->getParam('invoice_id'));
-        $trade_no = self::generateGuid();
+        $invoice = (new Invoice())->find($invoice_id);
+
+        if ($invoice === null) {
+            return $response->withJson([
+                'ret' => 0,
+                'msg' => 'Invoice not found',
+            ]);
+        }
+
+        $price = $invoice->price;
 
         if ($price <= 0) {
             return $response->withJson([
@@ -76,13 +85,18 @@ final class AlipayF2F extends Base
         }
 
         $user = Auth::getUser();
-        $paylist = new Paylist();
-        $paylist->userid = $user->id;
-        $paylist->total = $price;
-        $paylist->invoice_id = $invoice_id;
-        $paylist->tradeno = $trade_no;
-        $paylist->gateway = self::_readableName();
-        $paylist->save();
+        $pl = (new Paylist())->where('invoice_id', $invoice_id)->first();
+
+        if ($pl === null) {
+            $pl = new Paylist();
+            $pl->userid = $user->id;
+            $pl->total = $price;
+            $pl->invoice_id = $invoice_id;
+            $pl->tradeno = self::generateGuid();
+        }
+
+        $pl->gateway = self::_readableName();
+        $pl->save();
 
         $f2f_pay_notify_url = Config::obtain('f2f_pay_notify_url');
 
@@ -94,9 +108,9 @@ final class AlipayF2F extends Base
 
         $api = $this->createApi();
         $aliRequest = new AlipayTradePrecreateModel();
-        $aliRequest->setOutTradeNo($trade_no);
+        $aliRequest->setOutTradeNo($pl->tradeno);
         $aliRequest->setTotalAmount($price);
-        $aliRequest->setSubject($trade_no);
+        $aliRequest->setSubject($pl->tradeno);
         $aliRequest->setNotifyUrl($notifyUrl);
 
         $aliResponse = $api->precreate($aliRequest);
@@ -106,7 +120,6 @@ final class AlipayF2F extends Base
         return $response->withJson([
             'ret' => 1,
             'qrcode' => $qrCode,
-            'pid' => $trade_no,
         ]);
     }
 

+ 0 - 10
src/Services/Gateway/Base.php

@@ -48,16 +48,6 @@ abstract class Base
         return $response->write('ok');
     }
 
-    public function getStatus(ServerRequest $request, Response $response, array $args): ResponseInterface
-    {
-        $paylist = (new Paylist())->where('tradeno', $_POST['pid'])->first();
-
-        return $response->withJson([
-            'ret' => 1,
-            'result' => $paylist->status,
-        ]);
-    }
-
     abstract public static function getPurchaseHTML(): string;
 
     public function postPayment(string $trade_no): void

+ 25 - 10
src/Services/Gateway/Epay.php

@@ -11,6 +11,7 @@ declare(strict_types=1);
 namespace App\Services\Gateway;
 
 use App\Models\Config;
+use App\Models\Invoice;
 use App\Models\Paylist;
 use App\Services\Auth;
 use App\Services\Gateway\Epay\EpayNotify;
@@ -59,11 +60,20 @@ final class Epay extends Base
 
     public function purchase(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        $price = $this->antiXss->xss_clean($request->getParam('price'));
         $invoice_id = $this->antiXss->xss_clean($request->getParam('invoice_id'));
         // EPay 特定参数
         $type = $this->antiXss->xss_clean($request->getParam('type'));
         $redir = $this->antiXss->xss_clean($request->getParam('redir'));
+        $invoice = (new Invoice())->find($invoice_id);
+
+        if ($invoice === null) {
+            return $response->withJson([
+                'ret' => 0,
+                'msg' => 'Invoice not found',
+            ]);
+        }
+
+        $price = $invoice->price;
 
         if ($price <= 0) {
             return $response->withJson([
@@ -73,12 +83,15 @@ final class Epay extends Base
         }
 
         $user = Auth::getUser();
-        $pl = new Paylist();
-
-        $pl->userid = $user->id;
-        $pl->total = $price;
-        $pl->invoice_id = $invoice_id;
-        $pl->tradeno = self::generateGuid();
+        $pl = (new Paylist())->where('invoice_id', $invoice_id)->first();
+
+        if ($pl === null) {
+            $pl = new Paylist();
+            $pl->userid = $user->id;
+            $pl->total = $price;
+            $pl->invoice_id = $invoice_id;
+            $pl->tradeno = self::generateGuid();
+        }
 
         $type_text = match ($type) {
             'qqpay' => 'QQ',
@@ -143,12 +156,14 @@ final class Epay extends Base
         if ($verify_result) {
             if ($_GET['trade_status'] === 'TRADE_SUCCESS') {
                 $this->postPayment($_GET['out_trade_no']);
-
-                return $response->withJson(['state' => 'success', 'msg' => 'Payment success']);
+                // EPay just fucking copied from Alipay's method of determining whether the payment is successful
+                // which is retarded
+                // https://pay.v8jisu.cn/doc.html
+                return $response->write('success');
             }
         }
 
-        return $response->withJson(['state' => 'fail', 'msg' => 'Payment failed']);
+        return $response->write('failed');
     }
 
     /**

+ 40 - 26
src/Services/Gateway/PayPal.php

@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace App\Services\Gateway;
 
 use App\Models\Config;
+use App\Models\Invoice;
 use App\Models\Paylist;
 use App\Services\Auth;
 use App\Services\Exchange;
@@ -65,9 +66,17 @@ final class PayPal extends Base
 
     public function purchase(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        $price = $this->antiXss->xss_clean($request->getParam('price'));
         $invoice_id = $this->antiXss->xss_clean($request->getParam('invoice_id'));
-        $trade_no = self::generateGuid();
+        $invoice = (new Invoice())->find($invoice_id);
+
+        if ($invoice === null) {
+            return $response->withJson([
+                'ret' => 0,
+                'msg' => 'Invoice not found',
+            ]);
+        }
+
+        $price = $invoice->price;
 
         if ($price <= 0) {
             return $response->withJson([
@@ -76,8 +85,26 @@ final class PayPal extends Base
             ]);
         }
 
+        $user = Auth::getUser();
+        $pl = (new Paylist())->where('invoice_id', $invoice_id)->first();
+
+        if ($pl === null) {
+            $pl = new Paylist();
+            $pl->userid = $user->id;
+            $pl->total = $price;
+            $pl->invoice_id = $invoice_id;
+            $pl->tradeno = self::generateGuid();
+        }
+
+        $pl->gateway = self::_readableName();
+        $pl->save();
+
         try {
-            $exchange_amount = (new Exchange())->exchange($price, 'CNY', Config::obtain('paypal_currency'));
+            $exchange_amount = (new Exchange())->exchange(
+                (float) $price,
+                'CNY',
+                Config::obtain('paypal_currency')
+            );
         } catch (GuzzleException|RedisException) {
             return $response->withJson([
                 'ret' => 0,
@@ -93,7 +120,7 @@ final class PayPal extends Base
                         'currency_code' => Config::obtain('paypal_currency'),
                         'value' => $exchange_amount,
                     ],
-                    'reference_id' => $trade_no,
+                    'invoice_id' => $pl->tradeno,
                 ],
             ],
         ];
@@ -109,27 +136,17 @@ final class PayPal extends Base
             ]);
         }
 
-        $user = Auth::getUser();
-
-        $pl = new Paylist();
-        $pl->userid = $user->id;
-        $pl->total = $price;
-        $pl->invoice_id = $invoice_id;
-        $pl->tradeno = $trade_no;
-        $pl->gateway = self::_readableName();
-        $pl->save();
-
         return $response->withJson($order);
     }
 
     public function notify($request, $response, $args): ResponseInterface
     {
-        $order_id = $this->antiXss->xss_clean($request->getParam('order_id'));
+        $webhook_data = json_decode($request->getBody()->getContents(), true);
 
         try {
             $pp = new PayPalClient($this->gateway_config);
             $pp->getAccessToken();
-            $result = $pp->capturePaymentOrder($order_id);
+            $verify_result = $pp->verifyWebHook($webhook_data);
         } catch (Throwable) {
             return $response->withJson([
                 'ret' => 0,
@@ -137,19 +154,16 @@ final class PayPal extends Base
             ]);
         }
 
-        if (isset($result['status']) && $result['status'] === 'COMPLETED') {
-            $this->postPayment($result['purchase_units'][0]['reference_id']);
+        if ($verify_result['verification_status'] === 'SUCCESS' &&
+            $webhook_data['event_type'] === 'PAYMENT.CAPTURE.COMPLETED' &&
+            $webhook_data['resource']['status'] === 'COMPLETED'
+        ) {
+            $this->postPayment($webhook_data['resource']['invoice_id']);
 
-            return $response->withJson([
-                'ret' => 1,
-                'msg' => '支付成功',
-            ]);
+            return $response->withStatus(204);
         }
 
-        return $response->withJson([
-            'ret' => 0,
-            'msg' => '支付失败',
-        ]);
+        return $response->withStatus(400);
     }
 
     /**

+ 38 - 19
src/Services/Gateway/Stripe.php

@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace App\Services\Gateway;
 
 use App\Models\Config;
+use App\Models\Invoice;
 use App\Models\Paylist;
 use App\Services\Auth;
 use App\Services\Exchange;
@@ -21,6 +22,7 @@ use Stripe\StripeClient;
 use Stripe\Webhook;
 use UnexpectedValueException;
 use voku\helper\AntiXSS;
+use function in_array;
 
 final class Stripe extends Base
 {
@@ -46,9 +48,17 @@ final class Stripe extends Base
 
     public function purchase(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
-        $price = $this->antiXss->xss_clean($request->getParam('price'));
         $invoice_id = $this->antiXss->xss_clean($request->getParam('invoice_id'));
-        $trade_no = self::generateGuid();
+        $invoice = (new Invoice())->find($invoice_id);
+
+        if ($invoice === null) {
+            return $response->withJson([
+                'ret' => 0,
+                'msg' => 'Invoice not found',
+            ]);
+        }
+
+        $price = $invoice->price;
 
         if ($price < Config::obtain('stripe_min_recharge') ||
             $price > Config::obtain('stripe_max_recharge')
@@ -60,23 +70,38 @@ final class Stripe extends Base
         }
 
         $user = Auth::getUser();
+        $pl = (new Paylist())->where('invoice_id', $invoice_id)->first();
+
+        if ($pl === null) {
+            $pl = new Paylist();
+            $pl->userid = $user->id;
+            $pl->total = $price;
+            $pl->invoice_id = $invoice_id;
+            $pl->tradeno = self::generateGuid();
+        }
 
-        $pl = new Paylist();
-        $pl->userid = $user->id;
-        $pl->total = $price;
-        $pl->invoice_id = $invoice_id;
-        $pl->tradeno = $trade_no;
         $pl->gateway = self::_readableName();
         $pl->save();
 
+        $stripe_currency = Config::obtain('stripe_currency');
+
         try {
-            $exchange_amount = (new Exchange())->exchange((float) $price, 'CNY', Config::obtain('stripe_currency'));
+            $exchange_amount = (new Exchange())->exchange((float) $price, 'CNY', $stripe_currency);
         } catch (GuzzleException|RedisException) {
             return $response->withJson([
                 'ret' => 0,
                 'msg' => '汇率获取失败',
             ]);
         }
+        // https://docs.stripe.com/currencies?presentment-currency=US#zero-decimal
+        if (! in_array(
+            $stripe_currency,
+            ['BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW',
+                'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF',
+            ]
+        )) {
+            $exchange_amount *= 100;
+        }
 
         $stripe = new StripeClient(Config::obtain('stripe_api_key'));
         $session = null;
@@ -99,11 +124,11 @@ final class Stripe extends Base
                 'mode' => 'payment',
                 'payment_intent_data' => [
                     'metadata' => [
-                        'trade_no' => $trade_no,
+                        'trade_no' => $pl->tradeno,
                     ],
                 ],
-                'success_url' => $_ENV['baseUrl'] . '/user/invoice/' . $invoice_id,
-                'cancel_url' => $_ENV['baseUrl'] . '/user/invoice/' . $invoice_id,
+                'success_url' => $_ENV['baseUrl'] . '/user/invoice/' . $invoice_id . '/view',
+                'cancel_url' => $_ENV['baseUrl'] . '/user/invoice/' . $invoice_id . '/view',
             ]);
         } catch (ApiErrorException) {
             return $response->withJson([
@@ -140,16 +165,10 @@ final class Stripe extends Base
         if ($event->type === 'payment_intent.succeeded' && $payment_intent->status === 'succeeded') {
             $this->postPayment($payment_intent->metadata->trade_no);
 
-            return $response->withJson([
-                'ret' => 1,
-                'msg' => 'Payment success',
-            ]);
+            return $response->withStatus(204);
         }
 
-        return $response->withJson([
-            'ret' => 0,
-            'msg' => 'Payment failed',
-        ]);
+        return $response->withStatus(400);
     }
 
     /**

+ 10 - 6
src/Services/I18n.php

@@ -6,6 +6,8 @@ namespace App\Services;
 
 use Symfony\Component\Translation\Loader\PhpFileLoader;
 use Symfony\Component\Translation\Translator;
+use function basename;
+use function glob;
 use const BASE_PATH;
 
 final class I18n
@@ -20,12 +22,14 @@ final class I18n
 
     public static function getLocaleList(): array
     {
-        return [
-            'en_US',
-            'ja_JP',
-            'zh_CN',
-            'zh_TW',
-        ];
+        $locales = [];
+        $files = glob(BASE_PATH . '/resources/locale/*.php');
+
+        foreach ($files as $file) {
+            $locales[] = basename($file, '.php');
+        }
+
+        return $locales;
     }
 
     public static function getTranslator($lang = 'en_US'): Translator

+ 6 - 15
src/Services/LLM.php

@@ -6,16 +6,16 @@ namespace App\Services;
 
 use App\Models\Config;
 use App\Services\LLM\Anthropic;
+use App\Services\LLM\AwsBedrock;
 use App\Services\LLM\CloudflareWorkersAI;
 use App\Services\LLM\GoogleAI;
 use App\Services\LLM\HuggingFace;
 use App\Services\LLM\OpenAI;
 use App\Services\LLM\VertexAI;
-use GuzzleHttp\Exception\GuzzleException;
 
 final class LLM
 {
-    public static function getBackend(): VertexAI|CloudflareWorkersAI|GoogleAI|HuggingFace|OpenAI|Anthropic
+    public static function getBackend(): VertexAI|CloudflareWorkersAI|GoogleAI|HuggingFace|OpenAI|Anthropic|AwsBedrock
     {
         return match (Config::obtain('llm_backend')) {
             'google-ai' => new GoogleAI(),
@@ -23,13 +23,11 @@ final class LLM
             'huggingface' => new HuggingFace(),
             'cf-workers-ai' => new CloudflareWorkersAI(),
             'anthropic' => new Anthropic(),
+            'aws-bedrock' => new AwsBedrock(),
             default => new OpenAI(),
         };
     }
 
-    /**
-     * @throws GuzzleException
-     */
     public static function genTextResponse(string $q): string
     {
         if (Config::obtain('llm_backend') === '') {
@@ -43,23 +41,16 @@ final class LLM
         return self::getBackend()->textPrompt($q);
     }
 
-    /**
-     * @throws GuzzleException
-     */
-    public static function genTextResponseWithContext(string $q, array $context = []): string
+    public static function genTextResponseWithContext(array $context = []): string
     {
         if (Config::obtain('llm_backend') === '') {
             return 'No LLM backend configured';
         }
 
-        if ($q === '') {
-            return 'No question provided';
-        }
-
         if ($context === []) {
-            return self::getBackend()->textPrompt($q);
+            return 'No context provided';
         }
 
-        return self::getBackend()->textPromptWithContext($q, $context);
+        return self::getBackend()->textPromptWithContext($context);
     }
 }

+ 36 - 20
src/Services/LLM/Anthropic.php

@@ -10,10 +10,33 @@ use function json_decode;
 
 final class Anthropic extends Base
 {
-    /**
-     * @throws GuzzleException
-     */
     public function textPrompt(string $q): string
+    {
+        return $this->makeRequest([
+            [
+                'role' => 'user',
+                'content' => $q,
+            ],
+        ]);
+    }
+
+    public function textPromptWithContext(array $context): string
+    {
+        $conversation = [];
+
+        if (count($context) > 0) {
+            foreach ($context as $role => $content) {
+                $conversation[] = [
+                    'role' => $role === 'user' ? 'user' : 'assistant',
+                    'content' => $content,
+                ];
+            }
+        }
+
+        return $this->makeRequest($conversation);
+    }
+
+    private function makeRequest(array $conversation): string
     {
         if (Config::obtain('anthropic_api_key') === '') {
             return 'Anthropic API key not set';
@@ -28,27 +51,20 @@ final class Anthropic extends Base
 
         $data = [
             'model' => Config::obtain('anthropic_model_id'),
-            'max_tokens' => 1024,
             'temperature' => 1,
-            'messages' => [
-                [
-                    'role' => 'user',
-                    'content' => $q,
-                ],
-            ],
+            'messages' => $conversation,
         ];
 
-        $response = json_decode($this->client->post($api_url, [
-            'headers' => $headers,
-            'json' => $data,
-            'timeout' => 30,
-        ])->getBody()->getContents());
+        try {
+            $response = json_decode($this->client->post($api_url, [
+                'headers' => $headers,
+                'json' => $data,
+                'timeout' => 30,
+            ])->getBody()->getContents());
+        } catch (GuzzleException $e) {
+            return '';
+        }
 
         return $response->content[0]->text;
     }
-
-    public function textPromptWithContext(string $q, array $context): string
-    {
-        return '';
-    }
 }

+ 59 - 0
src/Services/LLM/AwsBedrock.php

@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services\LLM;
+
+use App\Models\Config;
+use Aws\BedrockRuntime\BedrockRuntimeClient;
+use Aws\Credentials\Credentials;
+
+final class AwsBedrock extends Base
+{
+    public function textPrompt(string $q): string
+    {
+        return $this->makeRequest([
+            [
+                'role' => 'user',
+                'content' => $q,
+            ],
+        ]);
+    }
+
+    public function textPromptWithContext(array $context): string
+    {
+        return 'This service does not support context';
+    }
+
+    private function makeRequest(array $conversation): string
+    {
+        if (Config::obtain('aws_bedrock_access_key_id') === '' ||
+            Config::obtain('aws_bedrock_access_key_secret') === '') {
+            return 'Access Key ID or Access Key Secret is empty';
+        }
+
+        $client = new BedrockRuntimeClient([
+            'region' => Config::obtain('aws_bedrock_region'),
+            'version' => 'latest',
+            'profile' => 'default',
+            'credentials' => new Credentials(
+                Config::obtain('aws_bedrock_access_key_id'),
+                Config::obtain('aws_bedrock_access_key_secret'),
+            ),
+        ]);
+        // Note: Different models in AWS Bedrock have different inference parameters, this service currently only supports
+        // Meta Llama series models
+        // https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-meta.html
+        $request = [
+            'contentType' => 'application/json',
+            'body' => json_encode([
+                'prompt' => $conversation[0]['content'],
+                'temperature' => 0.5,
+                'max_gen_len' => 2048,
+            ]),
+            'modelId' => Config::obtain('aws_bedrock_model_id'),
+        ];
+
+        return json_decode($client->invokeModel($request)['body'])->generation;
+    }
+}

+ 1 - 1
src/Services/LLM/Base.php

@@ -17,5 +17,5 @@ abstract class Base
 
     abstract public function textPrompt(string $q): string;
 
-    abstract public function textPromptWithContext(string $q, array $context): string;
+    abstract public function textPromptWithContext(array $context): string;
 }

+ 27 - 15
src/Services/LLM/CloudflareWorkersAI.php

@@ -10,12 +10,25 @@ use function json_decode;
 
 final class CloudflareWorkersAI extends Base
 {
-    /**
-     * @throws GuzzleException
-     */
     public function textPrompt(string $q): string
     {
-        if (Config::obtain('cf_workers_ai_account_id') === '' || Config::obtain('cf_workers_ai_api_token') === '') {
+        return $this->makeRequest([
+            [
+                'role' => 'user',
+                'content' => $q,
+            ],
+        ]);
+    }
+
+    public function textPromptWithContext(array $context): string
+    {
+        return 'This service does not support context';
+    }
+
+    private function makeRequest(array $conversation): string
+    {
+        if (Config::obtain('cf_workers_ai_account_id') === '' ||
+            Config::obtain('cf_workers_ai_api_token') === '') {
             return 'Cloudflare Workers AI Account ID or API Token not set';
         }
 
@@ -28,20 +41,19 @@ final class CloudflareWorkersAI extends Base
         ];
 
         $data = [
-            'prompt' => $q,
+            'prompt' => $conversation[0]['content'],
         ];
 
-        $response = json_decode($this->client->post($api_url, [
-            'headers' => $headers,
-            'json' => $data,
-            'timeout' => 30,
-        ])->getBody()->getContents());
+        try {
+            $response = json_decode($this->client->post($api_url, [
+                'headers' => $headers,
+                'json' => $data,
+                'timeout' => 30,
+            ])->getBody()->getContents());
+        } catch (GuzzleException $e) {
+            return '';
+        }
 
         return $response->result->response;
     }
-
-    public function textPromptWithContext(string $q, array $context): string
-    {
-        return '';
-    }
 }

+ 44 - 23
src/Services/LLM/GoogleAI.php

@@ -10,10 +10,41 @@ use function json_decode;
 
 final class GoogleAI extends Base
 {
-    /**
-     * @throws GuzzleException
-     */
     public function textPrompt(string $q): string
+    {
+        return $this->makeRequest([
+            [
+                'parts' => [
+                    [
+                        'text' => $q,
+                    ],
+                ],
+                'role' => 'user',
+            ],
+        ]);
+    }
+
+    public function textPromptWithContext(array $context): string
+    {
+        $conversation = [];
+
+        if (count($context) > 0) {
+            foreach ($context as $role => $content) {
+                $conversation[] = [
+                    'parts' => [
+                        [
+                            'text' => $content,
+                        ],
+                    ],
+                    'role' => $role === 'user' ? 'user' : 'model',
+                ];
+            }
+        }
+
+        return $this->makeRequest($conversation);
+    }
+
+    private function makeRequest(array $conversation): string
     {
         if (Config::obtain('google_ai_api_key') === '') {
             return 'Google AI API key not set';
@@ -27,16 +58,7 @@ final class GoogleAI extends Base
         ];
 
         $data = [
-            'contents' => [
-                [
-                    'parts' => [
-                        [
-                            'text' => $q,
-                        ],
-                    ],
-                    'role' => 'user',
-                ],
-            ],
+            'contents' => $conversation,
             'generationConfig' => [
                 'temperature' => 1,
                 'candidateCount' => 1,
@@ -61,17 +83,16 @@ final class GoogleAI extends Base
             ],
         ];
 
-        $response = json_decode($this->client->post($api_url, [
-            'headers' => $headers,
-            'json' => $data,
-            'timeout' => 30,
-        ])->getBody()->getContents());
+        try {
+            $response = json_decode($this->client->post($api_url, [
+                'headers' => $headers,
+                'json' => $data,
+                'timeout' => 30,
+            ])->getBody()->getContents());
+        } catch (GuzzleException $e) {
+            return '';
+        }
 
         return $response->candidates[0]->content->parts[0]->text;
     }
-
-    public function textPromptWithContext(string $q, array $context): string
-    {
-        return '';
-    }
 }

+ 27 - 15
src/Services/LLM/HuggingFace.php

@@ -10,12 +10,25 @@ use function json_decode;
 
 final class HuggingFace extends Base
 {
-    /**
-     * @throws GuzzleException
-     */
     public function textPrompt(string $q): string
     {
-        if (Config::obtain('huggingface_api_key') === '' || Config::obtain('huggingface_endpoint_url') === '') {
+        return $this->makeRequest([
+            [
+                'role' => 'user',
+                'content' => $q,
+            ],
+        ]);
+    }
+
+    public function textPromptWithContext(array $context): string
+    {
+        return 'This service does not support context';
+    }
+
+    private function makeRequest(array $conversation): string
+    {
+        if (Config::obtain('huggingface_api_key') === '' ||
+            Config::obtain('huggingface_endpoint_url') === '') {
             return 'Hugging Face API key or Endpoint URL not set';
         }
 
@@ -26,21 +39,20 @@ final class HuggingFace extends Base
 
         $data = [
             'inputs' => [
-                'question' => $q,
+                'question' => $conversation[0]['content'],
             ],
         ];
 
-        $response = json_decode($this->client->post(Config::obtain('huggingface_endpoint_url'), [
-            'headers' => $headers,
-            'json' => $data,
-            'timeout' => 30,
-        ])->getBody()->getContents());
+        try {
+            $response = json_decode($this->client->post(Config::obtain('huggingface_endpoint_url'), [
+                'headers' => $headers,
+                'json' => $data,
+                'timeout' => 30,
+            ])->getBody()->getContents());
+        } catch (GuzzleException $e) {
+            return '';
+        }
 
         return $response->answer;
     }
-
-    public function textPromptWithContext(string $q, array $context): string
-    {
-        return '';
-    }
 }

+ 32 - 15
src/Services/LLM/OpenAI.php

@@ -10,6 +10,37 @@ use OpenAI as OpenAISDK;
 final class OpenAI extends Base
 {
     public function textPrompt(string $q): string
+    {
+        return $this->makeRequest([
+            [
+                'role' => 'user',
+                'content' => $q,
+            ],
+        ]);
+    }
+
+    public function textPromptWithContext(array $context): string
+    {
+        $conversation = [
+            [
+                'role' => 'system',
+                'content' => 'You are a helpful assistant.',
+            ],
+        ];
+
+        if (count($context) > 0) {
+            foreach ($context as $role => $content) {
+                $conversation[] = [
+                    'role' => $role === 'user' ? 'user' : 'assistant',
+                    'content' => $content,
+                ];
+            }
+        }
+
+        return $this->makeRequest($conversation);
+    }
+
+    private function makeRequest(array $conversation): string
     {
         if (Config::obtain('openai_api_key') === '') {
             return 'OpenAI API key not set';
@@ -20,23 +51,9 @@ final class OpenAI extends Base
         $response = $client->chat()->create([
             'model' => Config::obtain('openai_model_id'),
             'temperature' => 1,
-            'messages' => [
-                [
-                    'role' => 'system',
-                    'content' => 'You are a helpful assistant.',
-                ],
-                [
-                    'role' => 'user',
-                    'content' => $q,
-                ],
-            ],
+            'messages' => $conversation,
         ]);
 
         return $response->choices[0]->message->content;
     }
-
-    public function textPromptWithContext(string $q, array $context): string
-    {
-        return '';
-    }
 }

+ 44 - 20
src/Services/LLM/VertexAI.php

@@ -10,10 +10,41 @@ use function json_decode;
 
 final class VertexAI extends Base
 {
-    /**
-     * @throws GuzzleException
-     */
     public function textPrompt(string $q): string
+    {
+        return $this->makeRequest([
+            [
+                'parts' => [
+                    [
+                        'text' => $q,
+                    ],
+                ],
+                'role' => 'user',
+            ],
+        ]);
+    }
+
+    public function textPromptWithContext(array $context): string
+    {
+        $conversation = [];
+
+        if (count($context) > 0) {
+            foreach ($context as $role => $content) {
+                $conversation[] = [
+                    'parts' => [
+                        [
+                            'text' => $content,
+                        ],
+                    ],
+                    'role' => $role === 'user' ? 'user' : 'model',
+                ];
+            }
+        }
+
+        return $this->makeRequest($conversation);
+    }
+
+    private function makeRequest(array $conversation): string
     {
         if (Config::obtain('vertex_ai_access_token') === '') {
             return 'Vertex AI API key not set';
@@ -29,13 +60,7 @@ final class VertexAI extends Base
         ];
 
         $data = [
-            'contents' => [
-                'parts' => [
-                    [
-                        'text' => $q,
-                    ],
-                ],
-            ],
+            'contents' => $conversation,
             'generationConfig' => [
                 'temperature' => 1,
                 'candidateCount' => 1,
@@ -60,17 +85,16 @@ final class VertexAI extends Base
             ],
         ];
 
-        $response = json_decode($this->client->post($api_url, [
-            'headers' => $headers,
-            'json' => $data,
-            'timeout' => 30,
-        ])->getBody()->getContents());
+        try {
+            $response = json_decode($this->client->post($api_url, [
+                'headers' => $headers,
+                'json' => $data,
+                'timeout' => 30,
+            ])->getBody()->getContents());
+        } catch (GuzzleException $e) {
+            return '';
+        }
 
         return $response->candidates[0]->content->parts[0]->text;
     }
-
-    public function textPromptWithContext(string $q, array $context): string
-    {
-        return '';
-    }
 }

+ 6 - 2
src/Services/Mail.php

@@ -10,11 +10,13 @@ use App\Services\Mail\Mailchimp;
 use App\Services\Mail\Mailgun;
 use App\Services\Mail\NullMail;
 use App\Services\Mail\Postal;
+use App\Services\Mail\Resend;
 use App\Services\Mail\SendGrid;
 use App\Services\Mail\Ses;
 use App\Services\Mail\Smtp;
-use Exception;
 use Psr\Http\Client\ClientExceptionInterface;
+use SendGrid\Mail\TypeException;
+use Smarty\Exception;
 use Smarty\Smarty;
 
 /*
@@ -22,13 +24,14 @@ use Smarty\Smarty;
  */
 final class Mail
 {
-    public static function getClient(): AlibabaCloud|Mailchimp|Mailgun|NullMail|Postal|SendGrid|Ses|Smtp
+    public static function getClient(): AlibabaCloud|Mailchimp|Mailgun|NullMail|Postal|Resend|SendGrid|Ses|Smtp
     {
         return match (Config::obtain('email_driver')) {
             'alibabacloud' => new AlibabaCloud(),
             'mailchimp' => new Mailchimp(),
             'mailgun' => new Mailgun(),
             'postal' => new Postal(),
+            'resend' => new Resend(),
             'sendgrid' => new SendGrid(),
             'ses' => new Ses(),
             'smtp' => new Smtp(),
@@ -57,6 +60,7 @@ final class Mail
     /**
      * @throws Exception
      * @throws ClientExceptionInterface
+     * @throws TypeException
      */
     public static function send($to, $subject, $template, $array = []): void
     {

+ 31 - 0
src/Services/Mail/Resend.php

@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services\Mail;
+
+use App\Models\Config;
+use Resend as RS;
+
+final class Resend extends Base
+{
+    private RS\Client $resend;
+    private string $from;
+
+    public function __construct()
+    {
+        $configs = Config::getClass('email');
+        $this->resend = RS::client($configs['resend_api_key']);
+        $this->from = $configs['resend_from'];
+    }
+
+    public function send($to, $subject, $body): void
+    {
+        $this->resend->emails->send([
+            'from' => $this->from,
+            'to' => [$to],
+            'subject' => $subject,
+            'html' => $body,
+        ]);
+    }
+}

+ 4 - 16
src/Services/Payment.php

@@ -43,14 +43,14 @@ final class Payment
         return $result;
     }
 
-    public static function getPaymentByName($name)
+    public static function getPaymentByName($name): ?string
     {
         $all = self::getPaymentMap();
 
         return $all[$name];
     }
 
-    public static function notify($request, $response, $args)
+    public static function notify($request, $response, $args): ResponseInterface
     {
         $payment = self::getPaymentByName($args['type']);
 
@@ -62,7 +62,7 @@ final class Payment
         return $response->withStatus(404);
     }
 
-    public static function returnHTML($request, $response, $args): string|ResponseInterface
+    public static function returnHTML($request, $response, $args): ResponseInterface
     {
         $payment = self::getPaymentByName($args['type']);
 
@@ -71,22 +71,10 @@ final class Payment
             return $instance->getReturnHTML($request, $response, $args);
         }
 
-        return '';
-    }
-
-    public static function getStatus($request, $response, $args)
-    {
-        $payment = self::getPaymentByName($args['type']);
-
-        if ($payment !== null) {
-            $instance = new $payment();
-            return $instance->getStatus($request, $response, $args);
-        }
-
         return $response->withStatus(404);
     }
 
-    public static function purchase($request, $response, $args)
+    public static function purchase($request, $response, $args): ResponseInterface
     {
         $payment = self::getPaymentByName($args['type']);
 

+ 2 - 0
src/Services/View.php

@@ -69,6 +69,8 @@ final class View
             'enable_change_email' => $_ENV['enable_change_email'],
             'enable_r2_client_download' => $_ENV['enable_r2_client_download'],
             'jsdelivr_url' => $_ENV['jsdelivr_url'],
+            // site default language
+            'locale' => $_ENV['locale'],
         ];
     }
 }

+ 5 - 5
src/Utils/Cookie.php

@@ -6,21 +6,21 @@ namespace App\Utils;
 
 final class Cookie
 {
-    public static function set($arg, $time): void
+    public static function set(array $arg, int $time): void
     {
         foreach ($arg as $key => $value) {
-            setcookie((string) $key, (string) $value, (int) $time, '/', '', true, true);
+            setcookie($key, $value, $time, path: '/', secure: true, httponly: true);
         }
     }
 
-    public static function setWithDomain($arg, $time, $domain): void
+    public static function setWithDomain(array $arg, int $time, string $domain): void
     {
         foreach ($arg as $key => $value) {
-            setcookie((string) $key, (string) $value, (int) $time, '/', (string) $domain, true, true);
+            setcookie($key, $value, $time, path: '/', domain: $domain, secure: true, httponly: true);
         }
     }
 
-    public static function get($key)
+    public static function get(string $key): string
     {
         return $_COOKIE[$key] ?? '';
     }

+ 1 - 1
src/Utils/Tools.php

@@ -295,7 +295,7 @@ final class Tools
         return false;
     }
 
-    public static function getSsMethod($type): array
+    public static function getSsMethod(string $type = ''): array
     {
         return match ($type) {
             'ss_obfs' => [

+ 59 - 0
tests/App/Services/I18nTest.php

@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Translation\Translator;
+
+require_once __DIR__ . '/../../../app/predefine.php';
+
+final class I18nTest extends TestCase
+{
+    /**
+     * @covers App\Services\I18n::trans
+     */
+    public function testTrans(): void
+    {
+        // exsisting locale
+        $key = 'lang_name';
+        $lang = 'en_US';
+        $expectedTranslation = 'English(Simplified)';
+
+        $translation = I18n::trans($key, $lang);
+
+        $this->assertSame($expectedTranslation, $translation);
+        // non-existing locale
+        $key = 'non_existent_key';
+
+        $translation = I18n::trans($key, $lang);
+
+        $this->assertSame($key, $translation);
+    }
+
+    /**
+     * @covers App\Services\I18n::getLocaleList
+     */
+    public function testGetLocaleList(): void
+    {
+        $expectedLocales = ['en_US', 'ja_JP', 'zh_CN', 'zh_TW'];
+
+        $locales = I18n::getLocaleList();
+
+        $this->assertSame($expectedLocales, $locales);
+    }
+
+    /**
+     * @covers App\Services\I18n::getTranslator
+     */
+    public function testGetTranslatorr(): void
+    {
+        $lang = 'en_US';
+
+        $translator = I18n::getTranslator($lang);
+
+        $this->assertInstanceOf(Translator::class, $translator);
+        $this->assertSame($lang, $translator->getLocale());
+    }
+}

+ 2 - 0
tests/App/Services/ViewTest.php

@@ -50,6 +50,7 @@ final class ViewTest extends TestCase
         $_ENV['enable_change_email'] = true;
         $_ENV['enable_r2_client_download'] = true;
         $_ENV['jsdelivr_url'] = 'https://cdn.jsdelivr.net';
+        $_ENV['locale'] = 'en_US';
 
         $config = $this->view->getConfig();
 
@@ -68,5 +69,6 @@ final class ViewTest extends TestCase
         $this->assertTrue($config['enable_change_email']);
         $this->assertTrue($config['enable_r2_client_download']);
         $this->assertEquals('https://cdn.jsdelivr.net', $config['jsdelivr_url']);
+        $this->assertEquals('en_US', $config['locale']);
     }
 }

+ 1 - 1
tests/App/Utils/ToolsTest.php

@@ -286,7 +286,7 @@ class ToolsTest extends TestCase
             'chacha20-ietf-poly1305',
             'xchacha20-ietf-poly1305',
         ];
-        $result4 = Tools::getSsMethod('');
+        $result4 = Tools::getSsMethod();
         $this->assertEquals($expected4, $result4);
     }
 

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels