diff --git a/.env.example b/.env.example
index 251858b..23120e8 100755
--- a/.env.example
+++ b/.env.example
@@ -1,4 +1,5 @@
APP_NAME=XBoard
+APP_FULL_NAME="Deep Link"
APP_ENV=production
APP_KEY=base64:PZXk5vTuTinfeEVG5FpYv2l6WEhLsyvGpiWK7IgJJ60=
APP_DEBUG=false
@@ -38,4 +39,4 @@ GOOGLE_CLOUD_KEY_FILE=config/googleCloudStorageKey.json
GOOGLE_CLOUD_STORAGE_BUCKET=
# Prevent reinstallation
-INSTALLED=false
\ No newline at end of file
+INSTALLED=false
diff --git a/.gitignore b/.gitignore
index c51b1df..74ee413 100755
--- a/.gitignore
+++ b/.gitignore
@@ -31,4 +31,19 @@ bun.lockb
compose.yaml
compose.host.yaml
compose.split.yaml
-.scribe
\ No newline at end of file
+.scribe
+# Generated tutorial previews and publication exports
+
+# Local Vite dev-server opt-in (never publish)
+/storage/framework/stellar-vite.hot
+
+# Separate frontend repository (commit it independently)
+/stellar/
+# Local reports, backups, screenshots and private operational records
+/codex/output/
+/deliverables/
+# Local native-client build products
+/macos-client/build/
+/macos-client/.build/
+
+/macos-client/.swiftpm/
diff --git a/app/Http/Controllers/V1/Guest/CommController.php b/app/Http/Controllers/V1/Guest/CommController.php
index 00c05c7..ae3c827 100644
--- a/app/Http/Controllers/V1/Guest/CommController.php
+++ b/app/Http/Controllers/V1/Guest/CommController.php
@@ -13,6 +13,8 @@ class CommController extends Controller
public function config()
{
$data = [
+ 'app_name' => config('app.name'),
+ 'app_full_name' => config('app.full_name'),
'tos_url' => admin_setting('tos_url'),
'is_email_verify' => (int) admin_setting('email_verify', 0) ? 1 : 0,
'is_invite_force' => (int) admin_setting('invite_force', 0) ? 1 : 0,
diff --git a/app/Http/Controllers/V2/Admin/PlanController.php b/app/Http/Controllers/V2/Admin/PlanController.php
index 6a39f9f..be74653 100644
--- a/app/Http/Controllers/V2/Admin/PlanController.php
+++ b/app/Http/Controllers/V2/Admin/PlanController.php
@@ -90,7 +90,8 @@ class PlanController extends Controller
$updateData = $request->only([
'show',
'renew',
- 'sell'
+ 'sell',
+ 'display_device_limit'
]);
$plan = Plan::find($request->input('id'));
@@ -98,6 +99,10 @@ class PlanController extends Controller
return $this->fail([400202, '该订阅不存在']);
}
+ $request->validate([
+ 'display_device_limit' => 'sometimes|nullable|integer|min:0|max:4294967295',
+ ]);
+
try {
$plan->update($updateData);
} catch (\Exception $e) {
diff --git a/app/Http/Requests/Admin/PlanSave.php b/app/Http/Requests/Admin/PlanSave.php
index c35e4a7..34e5c37 100755
--- a/app/Http/Requests/Admin/PlanSave.php
+++ b/app/Http/Requests/Admin/PlanSave.php
@@ -33,6 +33,7 @@ class PlanSave extends FormRequest
'group_id' => 'integer|nullable',
'speed_limit' => 'integer|nullable|min:0',
'device_limit' => 'integer|nullable|min:0',
+ 'display_device_limit' => 'nullable|integer|min:0|max:4294967295',
'capacity_limit' => 'integer|nullable|min:0',
'tags' => 'array|nullable',
];
@@ -133,6 +134,9 @@ class PlanSave extends FormRequest
'group_id.integer' => '权限组ID必须是整数',
'speed_limit.integer' => '速度限制必须是整数',
'speed_limit.min' => '速度限制不能为负数',
+ 'display_device_limit.integer' => '展示设备数必须是整数',
+ 'display_device_limit.min' => '展示设备数不能为负数',
+ 'display_device_limit.max' => '展示设备数过大',
'device_limit.integer' => '设备限制必须是整数',
'device_limit.min' => '设备限制不能为负数',
'capacity_limit.integer' => '容量限制必须是整数',
diff --git a/app/Http/Resources/PlanResource.php b/app/Http/Resources/PlanResource.php
index b78583a..50cd873 100644
--- a/app/Http/Resources/PlanResource.php
+++ b/app/Http/Resources/PlanResource.php
@@ -30,6 +30,7 @@ class PlanResource extends JsonResource
'transfer_enable' => $this->resource['transfer_enable'],
'speed_limit' => $this->resource['speed_limit'],
'device_limit' => $this->resource['device_limit'],
+ 'display_device_limit' => $this->resource['display_device_limit'],
'show' => (bool) $this->resource['show'],
'sell' => (bool) $this->resource['sell'],
'renew' => (bool) $this->resource['renew'],
@@ -84,7 +85,9 @@ class PlanResource extends JsonResource
{
$content = $this->resource['content'] ?? '';
+ $displayDevices = $this->resource->getDisplayDeviceLimit();
$replacements = [
+ '{{display_devices}}' => $displayDevices === null || $displayDevices === 0 ? __('No Limit') : $displayDevices,
'{{transfer}}' => $this->resource['transfer_enable'],
'{{speed}}' => $this->resource['speed_limit'] === NULL ? __('No Limit') : $this->resource['speed_limit'],
'{{devices}}' => $this->resource['device_limit'] === NULL ? __('No Limit') : $this->resource['device_limit'],
diff --git a/app/Models/Plan.php b/app/Models/Plan.php
index 13d66f2..ccbd0a5 100755
--- a/app/Models/Plan.php
+++ b/app/Models/Plan.php
@@ -87,6 +87,7 @@ class Plan extends Model
'capacity_limit',
'sell',
'device_limit',
+ 'display_device_limit',
'tags'
];
@@ -96,6 +97,7 @@ class Plan extends Model
'created_at' => 'timestamp',
'updated_at' => 'timestamp',
'group_id' => 'integer',
+ 'display_device_limit' => 'integer',
'prices' => 'array',
'tags' => 'array',
'reset_traffic_method' => 'integer',
@@ -302,6 +304,12 @@ class Plan extends Model
return array_key_exists($period, self::getAvailablePeriods());
}
+ public function getDisplayDeviceLimit(): ?int
+ {
+ $value = $this->display_device_limit ?? $this->device_limit;
+ return $value === null ? null : (int) $value;
+ }
+
public function users(): HasMany
{
return $this->hasMany(User::class);
diff --git a/app/Support/StellarDevAssets.php b/app/Support/StellarDevAssets.php
new file mode 100644
index 0000000..c674785
--- /dev/null
+++ b/app/Support/StellarDevAssets.php
@@ -0,0 +1,36 @@
+getHost(), ['127.0.0.1', 'localhost', '::1'], true)
+ || !is_file($marker) || trim(file_get_contents($marker)) !== $origin) {
+ return $html;
+ }
+ // A stale marker after an interrupted process falls back to built assets.
+ $socket = @fsockopen('127.0.0.1', 5173, $error, $message, 0.1);
+ if (!$socket) return $html;
+ fclose($socket);
+
+ $html = preg_replace('##', '', $html);
+ $html = preg_replace('#]*href="/theme/Stellar/assets/[^"]+"[^>]*>#', '', $html);
+ $html = str_replace('src="/theme/Stellar/env.js"', 'src="'.$origin.'/env.js"', $html);
+ // Allow only this loopback dev origin; production HTML is left unchanged.
+ $html = str_replace("script-src 'self'", "script-src 'self' ".$origin, $html);
+ $html = str_replace("style-src 'self'", "style-src 'self' ".$origin, $html);
+ $html = str_replace("connect-src 'self'", "connect-src 'self' ".$origin." ws://127.0.0.1:5173", $html);
+ $html = str_replace("img-src 'self'", "img-src 'self' ".$origin, $html);
+ $html = str_replace("font-src 'self'", "font-src 'self' ".$origin, $html);
+ $scripts = ''
+ .'';
+ return str_replace('', $scripts.'', $html);
+ }
+}
diff --git a/config/app.php b/config/app.php
index 3ed7fe4..b71a9c5 100755
--- a/config/app.php
+++ b/config/app.php
@@ -15,6 +15,9 @@ return [
'name' => env('APP_NAME', 'Laravel'),
+ // Human-readable full brand name; the site name remains APP_NAME.
+ 'full_name' => env('APP_FULL_NAME', env('APP_NAME', 'Laravel')),
+
/*
|--------------------------------------------------------------------------
| Application Environment
diff --git a/content/tutorials/README.txt b/content/tutorials/README.txt
new file mode 100644
index 0000000..65f2b03
--- /dev/null
+++ b/content/tutorials/README.txt
@@ -0,0 +1,11 @@
+教程为独立页面,Windows 教程按钮用普通链接打开新标签页。
+content/tutorials/<平台>/<客户端>/meta.json:标题、语言和平台信息。
+同目录 zh-CN.html:教程正文,只维护内容,不复制公共页脚和样式。
+同目录 images/:该教程配图,正文引用 ./images/图片名。
+resources/tutorials/layout.html:公共页面结构和页脚。
+resources/tutorials/tutorial.css:公共样式。
+scripts/tutorials/build.mjs:验证图片,生成 resources/views/tutorials 页面、public/tutorials 配图及前端入口索引。
+stellar 的 npm run build 自动构建教程。新增平台目录后构建即可连接对应按钮。
+生成文件不要直接编辑;从正文、模板、图片源文件修改。
+
+网站名称统一使用外层 .env 的 APP_NAME;模板使用 {{brand}},由后端在请求时填入。修改 APP_NAME 后刷新 Laravel 配置缓存,无需重新编译教程。
diff --git a/content/tutorials/android/flclash/images/01-download.png b/content/tutorials/android/flclash/images/01-download.png
new file mode 100644
index 0000000..3552035
Binary files /dev/null and b/content/tutorials/android/flclash/images/01-download.png differ
diff --git a/content/tutorials/android/flclash/images/02-install.svg b/content/tutorials/android/flclash/images/02-install.svg
new file mode 100644
index 0000000..72a7f33
--- /dev/null
+++ b/content/tutorials/android/flclash/images/02-install.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/content/tutorials/android/flclash/images/03-import.png b/content/tutorials/android/flclash/images/03-import.png
new file mode 100644
index 0000000..57cacb1
Binary files /dev/null and b/content/tutorials/android/flclash/images/03-import.png differ
diff --git a/content/tutorials/android/flclash/images/04-manual-import.svg b/content/tutorials/android/flclash/images/04-manual-import.svg
new file mode 100644
index 0000000..f4ceedf
--- /dev/null
+++ b/content/tutorials/android/flclash/images/04-manual-import.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/content/tutorials/android/flclash/images/05-proxy.svg b/content/tutorials/android/flclash/images/05-proxy.svg
new file mode 100644
index 0000000..107d307
--- /dev/null
+++ b/content/tutorials/android/flclash/images/05-proxy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/content/tutorials/android/flclash/images/06-connect.svg b/content/tutorials/android/flclash/images/06-connect.svg
new file mode 100644
index 0000000..71fcf27
--- /dev/null
+++ b/content/tutorials/android/flclash/images/06-connect.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/content/tutorials/android/flclash/images/import-buttons.png b/content/tutorials/android/flclash/images/import-buttons.png
new file mode 100644
index 0000000..87a50d5
Binary files /dev/null and b/content/tutorials/android/flclash/images/import-buttons.png differ
diff --git a/content/tutorials/android/flclash/meta.json b/content/tutorials/android/flclash/meta.json
new file mode 100644
index 0000000..8f8943c
--- /dev/null
+++ b/content/tutorials/android/flclash/meta.json
@@ -0,0 +1,11 @@
+{
+ "platform": "android",
+ "client": "flclash",
+ "title": "Android 使用教程",
+ "language": "zh-CN",
+ "sources": [
+ "https://github.com/chen08209/FlClash/tree/v0.8.99",
+ "https://developer.android.com/develop/connectivity/vpn"
+ ],
+ "illustrations": "网站实际截图;客户端操作示意根据 v0.8.99 官方源码整理,非手机实拍"
+}
diff --git a/content/tutorials/android/flclash/zh-CN.html b/content/tutorials/android/flclash/zh-CN.html
new file mode 100644
index 0000000..f11de0a
--- /dev/null
+++ b/content/tutorials/android/flclash/zh-CN.html
@@ -0,0 +1,32 @@
+
{{brand}}
Android 使用教程
FlClash · 从下载安装到开始连接
+
+
开始前:请先登录网站,确认已有可用套餐。本文使用网站提供的 FlClash 安卓客户端,默认下载 ARM64 安装包。网站操作图为实际截图;客户端配图为根据官方版本整理的操作示意,按钮与布局以实际版本为准。
+1下载并安装客户端
+在手机浏览器登录网站,进入 仪表盘 → 客户端下载,找到 Android,点击右侧 下载。
+
点击 Android 行右侧的「下载」;手机端排列可能略有不同
+下载完成后,从浏览器下载记录或文件管理器打开 FlClash 的 .apk 安装包。如果系统提示当前来源不允许安装应用,按提示进入设置,只允许当前用于打开安装包的浏览器或文件管理器安装,再返回完成安装。
+
安装操作示意:系统提示名称可能因手机品牌不同而变化
+安装后打开 FlClash。网站当前提供的 arm64-v8a 安装包适用于多数安卓手机;如果提示安装包不兼容,可到 官方发布页选择适合设备架构的 Android APK。
+2把网站配置导入客户端
+回到网站仪表盘,在 我的订阅 里点击 一键导入。
+
「一键导入」入口
+弹窗中选择 Android,再点击 FlClash。浏览器询问是否打开应用时,确认打开已安装的 FlClash,随后按客户端提示导入配置,并在 配置 页面选中这份订阅。
+
Android → FlClash;手机端排列可能略有不同
+如果一键导入不成功,可以用复制配置地址的方式导入
点击网站上的 复制配置地址,打开 FlClash 的 配置 页面,点击 + 添加配置,选择 URL(通过 URL 获取配置文件)。将完整地址粘贴到 URL 栏,按需填写名称,点击 提交;导入完成后选中这份配置。
+
手动导入操作示意
+配置地址属于你的个人订阅,请勿发到公开群聊或给他人使用。
+3选择线路与规则模式
+确认订阅已导入并选中后,在 FlClash 的 仪表盘 中找到模式选项,选择 规则。再打开 代理组,选一条可用线路;也可以使用订阅提供的自动选择组。
+
规则模式与线路选择示意,实际代理组以订阅为准
+规则模式会按订阅配置分流。节点列表为空时,先回到配置页面,确认已选中刚导入的配置,再尝试更新订阅。
+4启动连接,允许 VPN 请求
+回到 FlClash 的 仪表盘,点击 启动 / 播放按钮。首次连接时 Android 会弹出 VPN 连接请求,确认请求来自 FlClash 后,点击 确定 / 允许。
+
连接与 VPN 授权示意;系统弹窗文字以你的手机为准
+连接后,系统状态栏或 VPN 设置中通常会显示 VPN 标识。用浏览器访问你需要的网站,确认能正常加载。停止使用时,回到 FlClash 点击 暂停 / 停止按钮。
+完成标志:配置已导入并选中、线路可用、VPN 已启动,浏览器访问正常。Android 通过系统 VPN 接管连接,不需要寻找 Windows/macOS 的系统代理开关。
+遇到问题
+安装失败或提示安装包不兼容
确认下载已完成,检查 APK 是否适合设备架构。默认包为 ARM64;其他设备到官方发布页选择对应版本。若安装已存在的应用时失败,先核对现有应用的来源,不要直接卸载,以免丢失配置。
+一键导入没有反应
先确认 FlClash 已安装并打开。若浏览器未唤起应用,使用第 2 步里的复制地址导入。
+导入后看不到节点
确认套餐可用,检查地址是否完整,更新订阅并选中导入的配置。
+启动后没有连接,或网页仍打不开
确认已允许 FlClash 的 VPN 连接请求,并先停止其他正在使用的 VPN。尝试更换线路;仍有问题可在网站工单中附上客户端错误信息。
+切到后台后连接中断
检查手机是否把 FlClash 的后台运行限制为禁止或严格省电;根据实际需要允许该应用后台运行,再重新连接。
diff --git a/content/tutorials/ios/hiddify/images/01-store.png b/content/tutorials/ios/hiddify/images/01-store.png
new file mode 100644
index 0000000..27ae383
Binary files /dev/null and b/content/tutorials/ios/hiddify/images/01-store.png differ
diff --git a/content/tutorials/ios/hiddify/images/02-install.svg b/content/tutorials/ios/hiddify/images/02-install.svg
new file mode 100644
index 0000000..8324584
--- /dev/null
+++ b/content/tutorials/ios/hiddify/images/02-install.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/content/tutorials/ios/hiddify/images/03-import.png b/content/tutorials/ios/hiddify/images/03-import.png
new file mode 100644
index 0000000..f0b2894
Binary files /dev/null and b/content/tutorials/ios/hiddify/images/03-import.png differ
diff --git a/content/tutorials/ios/hiddify/images/04-manual-import.svg b/content/tutorials/ios/hiddify/images/04-manual-import.svg
new file mode 100644
index 0000000..240e635
--- /dev/null
+++ b/content/tutorials/ios/hiddify/images/04-manual-import.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/content/tutorials/ios/hiddify/images/05-connect.svg b/content/tutorials/ios/hiddify/images/05-connect.svg
new file mode 100644
index 0000000..8526111
--- /dev/null
+++ b/content/tutorials/ios/hiddify/images/05-connect.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/content/tutorials/ios/hiddify/images/06-proxy.svg b/content/tutorials/ios/hiddify/images/06-proxy.svg
new file mode 100644
index 0000000..17aaab3
--- /dev/null
+++ b/content/tutorials/ios/hiddify/images/06-proxy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/content/tutorials/ios/hiddify/images/import-buttons.png b/content/tutorials/ios/hiddify/images/import-buttons.png
new file mode 100644
index 0000000..87a50d5
Binary files /dev/null and b/content/tutorials/ios/hiddify/images/import-buttons.png differ
diff --git a/content/tutorials/ios/hiddify/meta.json b/content/tutorials/ios/hiddify/meta.json
new file mode 100644
index 0000000..c22dd8f
--- /dev/null
+++ b/content/tutorials/ios/hiddify/meta.json
@@ -0,0 +1,16 @@
+{
+ "platform": "ios",
+ "client": "hiddify",
+ "title": "iOS 使用教程",
+ "language": "zh-CN",
+ "sources": [
+ "https://apps.apple.com/us/app/hiddify-proxy-vpn/id6596777532",
+ "https://hiddify.com/app/How-to-use-Hiddify-app/",
+ "https://github.com/hiddify/hiddify-app/blob/main/lib/features/profile/add/add_profile_modal.dart",
+ "https://github.com/hiddify/hiddify-app/blob/main/ios/Runner/VPN/VPNManager.swift",
+ "https://support.apple.com/zh-cn/108647",
+ "https://www.aiai1888.com/product/d36b6e3f-cd8b-4959-b57f-e8a512b5876f"
+ ],
+ "illustrations": "网站部分为实际截图;客户端部分为依据官方文档与源码制作的操作示意,并非 iPhone 实机截图。",
+ "verifiedAt": "2026-10-08"
+}
diff --git a/content/tutorials/ios/hiddify/zh-CN.html b/content/tutorials/ios/hiddify/zh-CN.html
new file mode 100644
index 0000000..eaf066d
--- /dev/null
+++ b/content/tutorials/ios/hiddify/zh-CN.html
@@ -0,0 +1,34 @@
+
{{brand}}
iOS 使用教程
Hiddify · 从下载安装到开始连接
+
+开始前:登录网站,确认套餐可用。设备需运行 iOS 15.0 或以上。
+1从 App Store 获取客户端
+
+在手机浏览器登录网站,进入 仪表盘 → 客户端下载,找到 iOS,点击右侧 商店。
+
点击 iOS 行右侧的「商店」
+在 App Store 点击 获取,安装 Hiddify Proxy & VPN,然后打开应用。
+
商店获取与安装操作示意
+
+2把网站配置导入客户端
+回到网站仪表盘,在 我的订阅 里点击 一键导入。
+
「一键导入」入口
+弹窗中选择 iOS,再点击 Hiddify。浏览器询问是否打开应用时,确认打开已安装的 Hiddify,并按客户端提示确认添加配置。
+
iOS → Hiddify
+导入后,在 Hiddify 首页选中这份配置。
+如果一键导入不成功,可以用复制配置地址的方式导入
点击网站的 复制配置地址,打开 Hiddify 首页,点击 + 添加配置,选择从剪贴板添加(Add from clipboard);若系统询问是否允许粘贴,确认允许。也可以选择手动添加(Add manually),填写名称和完整的 URL,再点击保存(Save)。添加后选中这份配置。
+
手动导入操作示意
+配置地址属于你的个人订阅,请勿发到公开群聊或给他人使用。
+3允许 VPN 配置,开始连接
+点击 Hiddify 首页的连接按钮。系统提示添加 VPN 配置时,点击允许,按提示完成身份验证。
+
iOS VPN 授权操作示意
+显示已连接后,用浏览器访问网站检查连接。停止使用时,点击断开连接。
+
+4查看线路,按需切换
+连接后打开代理(Proxies)页面,使用自动选择组,或手动选择一条可用线路。
+
线路选择操作示意
+切换后再次访问网站确认可用。若列表为空,先确认当前配置已选中,并更新订阅。
+
+遇到问题
+商店提示应用不可用,或无法安装
检查 App Store 地区是否提供 Hiddify,以及设备是否满足 iOS 15.0 或以上要求。
+一键导入没有反应
先确认 Hiddify 已安装并打开。回到浏览器再次尝试,仍不成功时使用第 2 步的复制配置地址导入。
+导入后看不到线路
确认套餐可用,检查地址是否完整,更新订阅并选中刚导入的配置。
+没有允许 VPN,或连接后仍打不开网页
回到 Hiddify 再次点击连接,按系统提示完成 VPN 授权。确认当前配置正确,先断开其他正在使用的 VPN,再尝试更换线路。仍有问题可在网站工单中附上客户端错误信息。
diff --git a/content/tutorials/linux/clash-verge/images/01-download.png b/content/tutorials/linux/clash-verge/images/01-download.png
new file mode 100644
index 0000000..ea441e5
Binary files /dev/null and b/content/tutorials/linux/clash-verge/images/01-download.png differ
diff --git a/content/tutorials/linux/clash-verge/images/02-install.svg b/content/tutorials/linux/clash-verge/images/02-install.svg
new file mode 100644
index 0000000..77f3c46
--- /dev/null
+++ b/content/tutorials/linux/clash-verge/images/02-install.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/content/tutorials/linux/clash-verge/images/03-import.svg b/content/tutorials/linux/clash-verge/images/03-import.svg
new file mode 100644
index 0000000..2023b57
--- /dev/null
+++ b/content/tutorials/linux/clash-verge/images/03-import.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/content/tutorials/linux/clash-verge/images/04-proxy.svg b/content/tutorials/linux/clash-verge/images/04-proxy.svg
new file mode 100644
index 0000000..45a38e6
--- /dev/null
+++ b/content/tutorials/linux/clash-verge/images/04-proxy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/content/tutorials/linux/clash-verge/images/05-connect.svg b/content/tutorials/linux/clash-verge/images/05-connect.svg
new file mode 100644
index 0000000..d043541
--- /dev/null
+++ b/content/tutorials/linux/clash-verge/images/05-connect.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/content/tutorials/linux/clash-verge/images/import-buttons.png b/content/tutorials/linux/clash-verge/images/import-buttons.png
new file mode 100644
index 0000000..87a50d5
Binary files /dev/null and b/content/tutorials/linux/clash-verge/images/import-buttons.png differ
diff --git a/content/tutorials/linux/clash-verge/meta.json b/content/tutorials/linux/clash-verge/meta.json
new file mode 100644
index 0000000..9287174
--- /dev/null
+++ b/content/tutorials/linux/clash-verge/meta.json
@@ -0,0 +1,6 @@
+{
+ "platform": "linux",
+ "client": "clash-verge",
+ "title": "Linux 使用教程",
+ "language": "zh-CN"
+}
diff --git a/content/tutorials/linux/clash-verge/zh-CN.html b/content/tutorials/linux/clash-verge/zh-CN.html
new file mode 100644
index 0000000..a9b178a
--- /dev/null
+++ b/content/tutorials/linux/clash-verge/zh-CN.html
@@ -0,0 +1,35 @@
+
+
+适用于带图形界面的 Linux 桌面,Ubuntu 建议使用 24.04 或更新版本。开始前请登录网站,并准备好可用套餐。网站配图为实际截图,客户端配图为操作示意。
+1下载并安装客户端
+进入网站的 仪表盘 → 客户端下载,找到 Linux,点击 下载,打开 Clash Verge Rev 官方发布页。在页面的 Assets 中选择安装包。
+
Linux 行右侧的「下载」
+Ubuntu / Debian / Deepin 选择 .deb;Fedora / RHEL 系 选择 .rpm。在终端运行 uname -m 查看架构:x86_64 选 amd64 或 x86_64,aarch64 选 arm64 或 aarch64,armv7l 选 armhf 或 armhfp。
+
先选安装包格式,再选处理器架构
+下载完成后,在文件管理器打开安装包所在文件夹,右键选择 在终端中打开。以下以网站当前提供的 2.5.7、x86_64 安装包为例:
+Ubuntu / Debian:
sudo apt install ./Clash.Verge_2.5.7_amd64.deb
+Fedora / RHEL 系:
sudo dnf install ./Clash.Verge-2.5.7-1.x86_64.rpm
+输入系统密码并按提示完成安装;终端输入密码时不显示字符是正常现象。其他架构请将命令中的文件名替换为实际下载的文件名。安装后,从应用菜单打开 Clash Verge Rev。
+Arch Linux / Manjaro 安装方式
已有 AUR 助手 yay 时,可运行 yay -S clash-verge-rev-bin 安装正式版。其他安装方式参见 官方安装说明。
+
+2把网站配置导入客户端
+回到仪表盘,在 我的订阅 中点击 复制配置地址。
+
点击「复制配置地址」
+打开 Clash Verge Rev 的 订阅 页面,将地址粘贴到顶部的导入栏,点击 导入。导入完成后,点击这份订阅卡片,选中它作为当前配置。
+
导入后记得选中这份订阅
+配置地址是你的个人订阅,请勿公开分享。
+3选择线路与规则模式
+打开客户端的 代理 页面,选择 规则 模式,再在代理组中选择一条可用线路;也可以使用订阅提供的自动选择组。
+
规则模式与线路选择
+节点列表为空时,返回订阅页面更新订阅,并确认已选中这份配置。
+4开启系统代理,开始使用
+在客户端打开 系统代理 开关,再用浏览器访问你需要的网站。停止使用时,先关闭系统代理,再退出客户端。
+
完成导入、选好线路后开启系统代理
+系统代理主要作用于遵循系统代理设置的应用。终端工具或部分应用不会自动使用它;需要接管更多应用时,可安装客户端服务并开启 TUN 模式,按提示授权。
+遇到问题
+安装时提示架构不支持或缺少依赖
确认安装包架构与 uname -m 的结果一致。使用 apt 或 dnf 安装,让包管理器处理依赖;Ubuntu 20.04 等旧系统可能缺少所需库,建议升级到较新版本。
+应用打不开、黑屏或界面卡顿
Linux 桌面环境与显卡驱动可能影响界面启动。可参照 Linux 启动问题说明,根据自己的报错处理;向网站工单提供发行版、桌面环境与错误信息,有助于定位问题。
+导入失败或没有节点
重新复制完整配置地址,确认套餐可用,更新订阅并选中导入的配置。
+开启后网页仍打不开
确认系统代理已开启,并停止其他代理客户端。尝试换一条线路;若浏览器使用独立代理设置,将其改为使用系统代理。仍有问题可提交工单。
+没有桌面的 Linux 服务器能用吗?
本教程的 Clash Verge Rev 需要图形界面,不适用于仅通过 SSH 使用的服务器。
+
diff --git a/content/tutorials/macos/clash-verge/images/01-download.png b/content/tutorials/macos/clash-verge/images/01-download.png
new file mode 100644
index 0000000..5610a16
Binary files /dev/null and b/content/tutorials/macos/clash-verge/images/01-download.png differ
diff --git a/content/tutorials/macos/clash-verge/images/02-chip.png b/content/tutorials/macos/clash-verge/images/02-chip.png
new file mode 100644
index 0000000..6732ce1
Binary files /dev/null and b/content/tutorials/macos/clash-verge/images/02-chip.png differ
diff --git a/content/tutorials/macos/clash-verge/images/03-install.png b/content/tutorials/macos/clash-verge/images/03-install.png
new file mode 100644
index 0000000..fa2a322
Binary files /dev/null and b/content/tutorials/macos/clash-verge/images/03-install.png differ
diff --git a/content/tutorials/macos/clash-verge/images/04-macos-import.png b/content/tutorials/macos/clash-verge/images/04-macos-import.png
new file mode 100644
index 0000000..d84553f
Binary files /dev/null and b/content/tutorials/macos/clash-verge/images/04-macos-import.png differ
diff --git a/content/tutorials/macos/clash-verge/images/05-manual-import.png b/content/tutorials/macos/clash-verge/images/05-manual-import.png
new file mode 100644
index 0000000..17c271c
Binary files /dev/null and b/content/tutorials/macos/clash-verge/images/05-manual-import.png differ
diff --git a/content/tutorials/macos/clash-verge/images/06-proxy.png b/content/tutorials/macos/clash-verge/images/06-proxy.png
new file mode 100644
index 0000000..324b1a9
Binary files /dev/null and b/content/tutorials/macos/clash-verge/images/06-proxy.png differ
diff --git a/content/tutorials/macos/clash-verge/images/07-system-proxy.png b/content/tutorials/macos/clash-verge/images/07-system-proxy.png
new file mode 100644
index 0000000..f015eb2
Binary files /dev/null and b/content/tutorials/macos/clash-verge/images/07-system-proxy.png differ
diff --git a/content/tutorials/macos/clash-verge/images/import-buttons.png b/content/tutorials/macos/clash-verge/images/import-buttons.png
new file mode 100644
index 0000000..87a50d5
Binary files /dev/null and b/content/tutorials/macos/clash-verge/images/import-buttons.png differ
diff --git a/content/tutorials/macos/clash-verge/meta.json b/content/tutorials/macos/clash-verge/meta.json
new file mode 100644
index 0000000..b506428
--- /dev/null
+++ b/content/tutorials/macos/clash-verge/meta.json
@@ -0,0 +1,10 @@
+{
+ "platform": "macos",
+ "client": "clash-verge",
+ "title": "macOS 使用教程",
+ "language": "zh-CN",
+ "sources": [
+ "https://www.clashverge.dev/install.html",
+ "https://www.clashverge.dev/guide/quickstart.html"
+ ]
+}
diff --git a/content/tutorials/macos/clash-verge/zh-CN.html b/content/tutorials/macos/clash-verge/zh-CN.html
new file mode 100644
index 0000000..7c06840
--- /dev/null
+++ b/content/tutorials/macos/clash-verge/zh-CN.html
@@ -0,0 +1,33 @@
+
+
+开始前:请先登录网站,确认已有可用套餐。本文适用于 macOS 12 及以上系统,支持 Apple 芯片和 Intel 芯片。网站操作图为实际截图;安装及客户端操作图参考官方文档,线路选择图为 {{brand}} 教程示意,实际线路以你的订阅为准。
+1下载并安装客户端
+进入网站的 仪表盘 → 客户端下载,找到 macOS,点击右侧 下载。
+
点击 macOS 行右侧的「下载」
+在 苹果菜单 → 关于本机 中查看电脑的芯片类型,再在下载弹窗中选择对应版本:显示 Apple M 系列芯片时选择 Apple 芯片(M 系列 / ARM64);显示 Intel 处理器时选择 Intel 芯片(x64)。
+
根据「关于本机」中的芯片类型选择版本
+下载完成后双击 .dmg 文件,把 Clash Verge 图标拖到 Applications(应用程序) 文件夹。复制完成后,从「应用程序」打开 Clash Verge Rev。
+
官方 macOS 安装示意:将应用拖入 Applications 文件夹
+请从「应用程序」启动已经安装的客户端,不要一直在 DMG 安装窗口中运行。安装包下载失败时,可到
官方发布页选择对应架构的 .dmg 文件:Apple 芯片为 aarch64,Intel 芯片为 x64。
+2把网站配置导入客户端
+回到仪表盘,在 我的订阅 里点击 一键导入。
+
「一键导入」入口
+弹窗中选择 macOS,再点击 Clash Verge。浏览器询问是否打开应用时,确认打开已安装的 Clash Verge Rev,然后在客户端确认导入。
+
macOS → Clash Verge
+如果一键导入不成功,可以用复制配置地址的方式导入
点击网站上的 复制配置地址,打开 Clash Verge Rev 的 订阅 页面,将地址粘贴到导入栏并点击 导入。导入后选中这份订阅,使它成为当前配置。
+
官方客户端示意:订阅页面粘贴地址并导入
+配置地址属于你的个人订阅,请勿发到公开群聊或给他人使用。
+3选择线路与规则模式
+打开客户端的 代理 页面,选择 规则 模式,再在代理组里选一条可用线路;也可以使用配置中提供的自动选择组。
+
{{brand}} 教程示意:代理页面与规则模式,实际线路以订阅为准
+规则模式会按订阅配置分流。节点列表为空时,先回到订阅页面,确认已选中刚导入的配置,再尝试更新订阅。
+4开启系统代理,开始使用
+在客户端找到 系统代理 开关并打开,随后用浏览器访问你需要的网站,确认能正常加载。
+
官方客户端示意:开启系统代理;新版本开关位置可能不同
+普通网页使用先开启系统代理即可。停止使用时关闭系统代理,再退出客户端。
+完成标志:订阅已导入并选中、线路可用、系统代理已开启,浏览器访问正常。
+遇到问题
+不确定应该下载哪个版本
打开苹果菜单 → 关于本机。Apple M 系列选择 ARM64,Intel 处理器选择 x64;同时确认系统为 macOS 12 或更新版本。
+一键导入没有反应
先确认 Clash Verge Rev 已放入「应用程序」并打开;浏览器询问打开应用时确认打开。仍不成功时,使用第 2 步里的复制地址导入。
+导入后看不到节点
确认套餐可用,检查地址是否完整,更新订阅并选中导入的配置。
+已经导入,网页仍打不开
确认系统代理已开启,尝试换一条线路;同时避免多个代理客户端一起运行。仍有问题可在网站工单中附上错误信息。
diff --git a/content/tutorials/router/openclash/images/01-download.png b/content/tutorials/router/openclash/images/01-download.png
new file mode 100644
index 0000000..a9bb9c7
Binary files /dev/null and b/content/tutorials/router/openclash/images/01-download.png differ
diff --git a/content/tutorials/router/openclash/images/02-install.svg b/content/tutorials/router/openclash/images/02-install.svg
new file mode 100644
index 0000000..8b9cca8
--- /dev/null
+++ b/content/tutorials/router/openclash/images/02-install.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/content/tutorials/router/openclash/images/03-subscribe.svg b/content/tutorials/router/openclash/images/03-subscribe.svg
new file mode 100644
index 0000000..8b10655
--- /dev/null
+++ b/content/tutorials/router/openclash/images/03-subscribe.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/content/tutorials/router/openclash/images/04-start.svg b/content/tutorials/router/openclash/images/04-start.svg
new file mode 100644
index 0000000..76f3192
--- /dev/null
+++ b/content/tutorials/router/openclash/images/04-start.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/content/tutorials/router/openclash/images/05-network.svg b/content/tutorials/router/openclash/images/05-network.svg
new file mode 100644
index 0000000..a9ff08d
--- /dev/null
+++ b/content/tutorials/router/openclash/images/05-network.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/content/tutorials/router/openclash/images/import-buttons.png b/content/tutorials/router/openclash/images/import-buttons.png
new file mode 100644
index 0000000..87a50d5
Binary files /dev/null and b/content/tutorials/router/openclash/images/import-buttons.png differ
diff --git a/content/tutorials/router/openclash/meta.json b/content/tutorials/router/openclash/meta.json
new file mode 100644
index 0000000..7c6c6bf
--- /dev/null
+++ b/content/tutorials/router/openclash/meta.json
@@ -0,0 +1,6 @@
+{
+ "platform": "router",
+ "client": "openclash",
+ "title": "软路由使用教程",
+ "language": "zh-CN"
+}
diff --git a/content/tutorials/router/openclash/zh-CN.html b/content/tutorials/router/openclash/zh-CN.html
new file mode 100644
index 0000000..3147e33
--- /dev/null
+++ b/content/tutorials/router/openclash/zh-CN.html
@@ -0,0 +1,40 @@
+
+
+适用于 OpenWrt 或兼容固件的软路由,需要管理后台权限。已装好 OpenClash 可直接从第 2 步开始。网站配图为实际截图,路由器配图为操作示意。
+1安装 OpenClash,准备内核
+电脑连接软路由的网络,用浏览器打开它的管理地址并登录。安装前,在 系统 → 备份 / 升级 中保存一份当前配置备份。
+在网站 仪表盘 → 客户端下载 中找到 软路由,点击 下载,打开 OpenClash 官方发布页。在 Assets 中下载 luci-app-openclash 安装包。
+
软路由行右侧的「下载」
+使用 opkg 的固件选择 .ipk,使用 apk 的固件选择 .apk;这里的 APK 是 OpenWrt 软件包,不是安卓手机安装包。
+进入路由器 系统 → 软件包,先更新软件包列表,再使用 上传软件包 安装下载的文件。若固件内置 OpenClash 安装入口,也可以直接从该入口安装。
+
插件安装在路由器上,不是在电脑上
+安装完成后,刷新路由器后台,进入 服务 → OpenClash。在 插件设置 → 版本更新 中选择与路由器匹配的内核架构,下载或更新 Meta / Mihomo 内核,等下载完成后继续。
+没有上传入口,或安装提示缺少依赖
使用固件的软件包管理器安装 OpenClash 所需依赖,具体清单见 OpenClash 官方依赖说明。不同固件的防火墙和软件源不同,内核模块必须与固件匹配,不要强制忽略依赖或覆盖系统库。
+
+2添加网站订阅
+登录网站,在仪表盘 我的订阅 中点击 复制配置地址。
+
点击「复制配置地址」
+回到路由器的 服务 → OpenClash → 配置订阅,添加一条订阅,填写:
+- 配置文件名:填写一个好认的名字,例如 {{brand}}。
- 订阅地址:粘贴从网站复制的完整地址。
- User-Agent:选择 Clash / clash.meta 类型,以获取 Clash 格式配置。
- 在线订阅转换:关闭,直接使用网站提供的配置。
+保存这条订阅,保持条目 启用,再点击 保存配置 → 更新配置,等待订阅下载完成。
+
保存订阅后更新配置
+订阅地址是你的个人配置,请勿公开分享。
+
+
+3切换配置,启动并选择线路
+进入 配置管理,找到刚下载的配置文件,点击 切换。回到 运行状态 页面启用 OpenClash,等待插件与内核运行。
+打开 OpenClash 提供的 控制面板,进入 代理 页面,选择 规则 模式,并在代理组中选择可用线路;也可以使用订阅提供的自动选择组。
+
订阅下载后,切换配置再启动
+节点为空时,先回到配置管理确认当前配置,再更新订阅。
+4让设备经过软路由
+软路由作为主路由:手机、电脑连接它的 Wi-Fi 或 LAN 网络,通常保持自动获取 IP、网关和 DNS 即可。
+软路由作为旁路由:需要让使用代理的设备把 默认网关和 DNS 指向旁路由的 LAN 地址。旁路由的上游网关应指向主路由,不能指向自己;旁路由也需要已配置好转发和 NAT。
+
流量必须经过软路由,只有安装插件还不够
+测试时,先停止手机或电脑上其他代理客户端,再用浏览器访问你需要的网站。停止使用时,在 OpenClash 的运行状态页面关闭插件。
+旁路由网络结构因设备而异。先在一台设备上测试网关和 DNS,成功后再调整其他设备;请保留原网络设置,便于恢复。
+遇到问题
+后台没有 OpenClash 菜单
先刷新或重新登录路由器后台。仍没有菜单时,查看软件包安装结果,确认插件与所需依赖安装成功。
+提示内核不存在或不能启动
到版本更新页面检查内核是否下载完成、架构是否匹配。仍不能启动时,查看运行日志中的错误。
+订阅更新失败或配置格式不对
重新复制完整地址,检查路由器本身能否访问订阅网站。订阅的 User-Agent 使用 Clash 类型,关闭在线订阅转换,再更新配置。
+插件显示运行,但设备无法访问
确认设备流量经过软路由,旁路由场景尤其要检查网关和 DNS。先停止设备上的其他代理,换一条线路再试;若启用后原有网络也不通,先停用 OpenClash,并恢复设备原来的网关与 DNS。
+仍然无法解决
在网站提交工单,说明固件版本、主路由或旁路由模式及错误信息。OpenClash 的运行日志和调试日志有助于排查;发送前去掉其中的订阅地址、密码等个人配置。
diff --git a/content/tutorials/windows/clash-verge/images/01-download.png b/content/tutorials/windows/clash-verge/images/01-download.png
new file mode 100644
index 0000000..a65ebdf
Binary files /dev/null and b/content/tutorials/windows/clash-verge/images/01-download.png differ
diff --git a/content/tutorials/windows/clash-verge/images/02-import-buttons.png b/content/tutorials/windows/clash-verge/images/02-import-buttons.png
new file mode 100644
index 0000000..87a50d5
Binary files /dev/null and b/content/tutorials/windows/clash-verge/images/02-import-buttons.png differ
diff --git a/content/tutorials/windows/clash-verge/images/03-windows-import.png b/content/tutorials/windows/clash-verge/images/03-windows-import.png
new file mode 100644
index 0000000..135156e
Binary files /dev/null and b/content/tutorials/windows/clash-verge/images/03-windows-import.png differ
diff --git a/content/tutorials/windows/clash-verge/images/04-manual-import.png b/content/tutorials/windows/clash-verge/images/04-manual-import.png
new file mode 100644
index 0000000..17c271c
Binary files /dev/null and b/content/tutorials/windows/clash-verge/images/04-manual-import.png differ
diff --git a/content/tutorials/windows/clash-verge/images/05-proxy.png b/content/tutorials/windows/clash-verge/images/05-proxy.png
new file mode 100644
index 0000000..324b1a9
Binary files /dev/null and b/content/tutorials/windows/clash-verge/images/05-proxy.png differ
diff --git a/content/tutorials/windows/clash-verge/images/06-system-proxy.png b/content/tutorials/windows/clash-verge/images/06-system-proxy.png
new file mode 100644
index 0000000..f015eb2
Binary files /dev/null and b/content/tutorials/windows/clash-verge/images/06-system-proxy.png differ
diff --git a/content/tutorials/windows/clash-verge/meta.json b/content/tutorials/windows/clash-verge/meta.json
new file mode 100644
index 0000000..0f97cc0
--- /dev/null
+++ b/content/tutorials/windows/clash-verge/meta.json
@@ -0,0 +1,6 @@
+{
+ "platform": "windows",
+ "client": "clash-verge",
+ "title": "Windows 使用教程",
+ "language": "zh-CN"
+}
diff --git a/content/tutorials/windows/clash-verge/zh-CN.html b/content/tutorials/windows/clash-verge/zh-CN.html
new file mode 100644
index 0000000..d1b3591
--- /dev/null
+++ b/content/tutorials/windows/clash-verge/zh-CN.html
@@ -0,0 +1,15 @@
+开始前:请先登录网站,确认已有可用套餐。本文适用于 x64 电脑。网站操作图为实际截图;客户端操作图参考官方文档,线路选择图为 {{brand}} 教程示意,实际线路以你的订阅为准。
1下载并安装客户端
进入网站的 仪表盘 → 客户端下载,找到 Windows,点击右侧 下载。
+
点击 Windows 行右侧的「下载」下载完成后打开安装包,按安装向导完成安装,然后启动 Clash Verge Rev。
+当前提供的是 x64 安装包。Windows ARM 电脑请到
官方发布页选择对应架构的 Windows 安装包。
+2把网站配置导入客户端
回到仪表盘,在 我的订阅 里点击 一键导入。
+
「一键导入」入口
+弹窗中选择 Windows,再点击 Clash Verge。浏览器询问是否打开应用时,确认打开已安装的 Clash Verge Rev,然后在客户端确认导入。
+
Windows → Clash Verge如果一键导入不成功,可以用复制配置地址的方式导入
点击网站上的复制配置地址,打开 Clash Verge Rev 的订阅页面,将地址粘贴到导入栏并点击导入。导入后选中这份订阅,使它成为当前配置。
官方客户端示意:订阅页面粘贴地址并导入配置地址属于你的个人订阅,请勿发到公开群聊或给他人使用。
+3选择线路与规则模式
打开客户端的 代理 页面,选择 规则 模式,再在代理组里选一条可用线路;也可以使用配置中提供的自动选择组。
+
{{brand}} 教程示意:代理页面与规则模式,实际线路以订阅为准规则模式会按订阅配置分流。节点列表为空时,先回到订阅页面,确认已选中刚导入的配置,再尝试更新订阅。
+
+4开启系统代理,开始使用
在客户端找到 系统代理 开关并打开,随后用浏览器访问你需要的网站,确认能正常加载。
+
官方客户端示意:开启系统代理;新版本开关位置可能不同普通网页使用先开启系统代理即可。停止使用时关闭系统代理,再退出客户端。
+完成标志:订阅已导入并选中、线路可用、系统代理已开启,浏览器访问正常。
+遇到问题
一键导入没有反应
先确认客户端已经安装并打开;仍不成功时,使用第 2 步里的复制地址导入。
导入后看不到节点
确认套餐可用,检查地址是否完整,更新订阅并选中导入的配置。
已经导入,网页仍打不开
确认系统代理已开启,尝试换一条线路;同时避免多个代理客户端一起运行。仍有问题可在网站工单中附上错误信息。
+
\ No newline at end of file
diff --git a/database/migrations/2026_10_08_140000_add_display_device_limit_to_plans.php b/database/migrations/2026_10_08_140000_add_display_device_limit_to_plans.php
new file mode 100644
index 0000000..8f7630e
--- /dev/null
+++ b/database/migrations/2026_10_08_140000_add_display_device_limit_to_plans.php
@@ -0,0 +1,16 @@
+unsignedInteger('display_device_limit')->nullable()->after('device_limit');
+ });
+ }
+ public function down(): void {
+ Schema::table('v2_plan', function (Blueprint $table) {
+ $table->dropColumn('display_device_limit');
+ });
+ }
+};
diff --git a/macos-client/Package.swift b/macos-client/Package.swift
new file mode 100644
index 0000000..53bbf18
--- /dev/null
+++ b/macos-client/Package.swift
@@ -0,0 +1,3 @@
+// swift-tools-version: 5.9
+import PackageDescription
+let package = Package(name: "DPLink", platforms: [.macOS(.v13)], products: [.executable(name: "DPLink", targets: ["DPLink"])], targets: [.executableTarget(name: "DPLink")])
diff --git a/macos-client/README.md b/macos-client/README.md
new file mode 100644
index 0000000..d88e1d6
--- /dev/null
+++ b/macos-client/README.md
@@ -0,0 +1,32 @@
+# DP-LINK macOS 开发预览
+
+原生 SwiftUI 客户端,macOS 13+,无需 npm。
+
+## 运行
+
+```bash
+cd /private/var/www/wwwroot/dpl/macos-client
+swift run
+```
+
+后端开发环境先在项目根目录运行 `php artisan serve --host=127.0.0.1 --port=8000`。
+客户端填入 XBoard 地址和已有账户。远程服务器要求 HTTPS。
+
+登录成功后选择官方 Mihomo 的可执行文件;也可以选择本机已经安装的 Clash Verge 中的
+`/Applications/Clash Verge.app/Contents/MacOS/verge-mihomo`。
+本项目没有复制或分发第三方内核。分发产品前应按所选内核的许可证处理授权和源码义务。
+
+## 已实现
+
+- XBoard 登录和订阅获取,密码与认证信息只存内存。
+- Mihomo HTTP 订阅 provider 获取节点,认证控制接口仅监听本机。
+- 本地 HTTP/SOCKS5 代理 `127.0.0.1:17899`,选择节点、断开连接。
+- 临时配置目录权限 0700、配置文件权限 0600,断开后清理。
+
+## 尚未完成
+
+- 系统代理自动接管与旧设置恢复、TUN、权限 helper。
+- 内核打包、签名与公证、断线恢复、自动更新。
+- 真实账号与真实节点的端到端连接验证。
+
+目前只是开发预览,启动代理后需要在使用的应用中配置本地代理;不能视作完成的一键 VPN 客户端。
diff --git a/macos-client/Sources/DPLink/App.swift b/macos-client/Sources/DPLink/App.swift
new file mode 100644
index 0000000..500d755
--- /dev/null
+++ b/macos-client/Sources/DPLink/App.swift
@@ -0,0 +1,184 @@
+import SwiftUI
+import Foundation
+
+struct ClientError: LocalizedError {
+ let message: String
+ var errorDescription: String? { message }
+}
+
+@MainActor final class Client: ObservableObject {
+ @Published var server = "http://127.0.0.1:8000"
+ @Published var email = ""
+ @Published var password = ""
+ @Published var corePath = ""
+ @Published var status = "请登录 XBoard 账户"
+ @Published var busy = false
+ @Published var loggedIn = false
+ @Published var connected = false
+ @Published var nodes: [String] = []
+ @Published var selected = ""
+ private var authorization = ""
+ private var subscription = ""
+ private var process: Process?
+ private var secret = UUID().uuidString
+ private var directory: URL?
+
+ func request(_ path: String, method: String = "GET", body: [String: String]? = nil, controller: Bool = false) async throws -> [String: Any] {
+ let base = controller ? "http://127.0.0.1:19091" : server.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ guard let url = URL(string: base + path), let scheme = url.scheme, ["https", "http"].contains(scheme) else { throw ClientError(message: "服务器地址无效") }
+ if !controller && scheme == "http" && !["localhost", "127.0.0.1", "::1"].contains(url.host ?? "") { throw ClientError(message: "远程服务器请使用 HTTPS") }
+ var req = URLRequest(url: url)
+ req.timeoutInterval = 15
+ req.httpMethod = method
+ if controller { req.setValue("Bearer " + secret, forHTTPHeaderField: "Authorization") }
+ else if !authorization.isEmpty { req.setValue(authorization, forHTTPHeaderField: "Authorization") }
+ if let body { req.httpBody = try JSONSerialization.data(withJSONObject: body); req.setValue("application/json", forHTTPHeaderField: "Content-Type") }
+ let (data, response) = try await URLSession.shared.data(for: req)
+ guard let response = response as? HTTPURLResponse else { throw ClientError(message: "服务器未响应") }
+ let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:]
+ guard (200..<300).contains(response.statusCode) else { throw ClientError(message: object["message"] as? String ?? "请求失败(\(response.statusCode))") }
+ return object
+ }
+
+ func login() async {
+ busy = true
+ defer { busy = false }
+ do {
+ let response = try await request("/api/v1/passport/auth/login", method: "POST", body: ["email": email, "password": password])
+ guard let data = response["data"] as? [String: Any], let auth = data["auth_data"] as? String else { throw ClientError(message: "登录响应缺少认证信息") }
+ authorization = auth
+ password = ""
+ let sub = try await request("/api/v1/user/getSubscribe")
+ guard let data = sub["data"] as? [String: Any], let url = data["subscribe_url"] as? String else { throw ClientError(message: "账户没有订阅地址") }
+ subscription = url
+ loggedIn = true
+ status = "登录成功,请选择 Mihomo 内核后连接"
+ } catch { authorization = ""; status = error.localizedDescription }
+ }
+
+ func connect() async {
+ busy = true
+ defer { busy = false }
+ do {
+ guard FileManager.default.isExecutableFile(atPath: corePath) else { throw ClientError(message: "请选择可执行的 Mihomo 内核") }
+ guard var components = URLComponents(string: subscription) else { throw ClientError(message: "订阅地址无效") }
+ var query = components.queryItems ?? []
+ query.removeAll { $0.name == "flag" }
+ query.append(URLQueryItem(name: "flag", value: "clash"))
+ components.queryItems = query
+ guard let subURL = components.url, subURL.scheme == "https" || ["127.0.0.1", "localhost"].contains(subURL.host ?? "") else { throw ClientError(message: "订阅地址必须使用 HTTPS 或本机地址") }
+ let dir = FileManager.default.temporaryDirectory.appendingPathComponent("dplink-" + UUID().uuidString)
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
+ directory = dir
+ let quotedURL = String(data: try JSONSerialization.data(withJSONObject: [subURL.absoluteString]), encoding: .utf8)!.dropFirst().dropLast()
+ let config = """
+ mixed-port: 17899
+ allow-lan: false
+ bind-address: 127.0.0.1
+ mode: rule
+ log-level: warning
+ external-controller: 127.0.0.1:19091
+ secret: "\(secret)"
+ proxy-providers:
+ account:
+ type: http
+ url: \(quotedURL)
+ path: ./subscription.yaml
+ interval: 3600
+ proxy-groups:
+ - name: DP-LINK
+ type: select
+ use: [account]
+ rules:
+ - MATCH,DP-LINK
+ """
+ let file = dir.appendingPathComponent("config.yaml")
+ try config.write(to: file, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: file.path)
+ let task = Process()
+ task.executableURL = URL(fileURLWithPath: corePath)
+ task.arguments = ["-d", dir.path, "-f", file.path]
+ task.standardOutput = FileHandle.nullDevice
+ task.standardError = FileHandle.nullDevice
+ try task.run()
+ process = task
+ var ready = false
+ for _ in 0..<30 {
+ try await Task.sleep(nanoseconds: 500_000_000)
+ guard task.isRunning else { throw ClientError(message: "内核退出,请检查端口占用、订阅或内核文件") }
+ if let result = try? await request("/proxies", controller: true), let proxies = result["proxies"] as? [String: Any], let group = proxies["DP-LINK"] as? [String: Any], let all = group["all"] as? [String], !all.isEmpty {
+ nodes = all; selected = group["now"] as? String ?? all[0]; ready = true; break
+ }
+ }
+ guard ready else { throw ClientError(message: "订阅加载超时,请检查服务器和网络") }
+ connected = true
+ status = "本地代理已启动:127.0.0.1:17899(HTTP / SOCKS5)"
+ } catch { disconnect(); status = error.localizedDescription }
+ }
+
+ func chooseNode() async {
+ do { _ = try await request("/proxies/DP-LINK", method: "PUT", body: ["name": selected], controller: true); status = "当前节点:" + selected }
+ catch { status = error.localizedDescription }
+ }
+
+ func disconnect() {
+ if let process, process.isRunning { process.terminate(); process.waitUntilExit() }
+ process = nil
+ connected = false
+ nodes = []
+ if let directory { try? FileManager.default.removeItem(at: directory) }
+ directory = nil
+ status = "已断开"
+ }
+ func logout() { disconnect(); authorization = ""; subscription = ""; loggedIn = false }
+}
+
+struct ContentView: View {
+ @ObservedObject var client: Client
+ var body: some View {
+ VStack(alignment: .leading, spacing: 22) {
+ HStack {
+ Image(systemName: "network").font(.system(size: 32)).foregroundStyle(.blue)
+ VStack(alignment: .leading) { Text("DP-LINK").font(.title.bold()); Text("macOS 客户端 · 开发预览").foregroundStyle(.secondary) }
+ Spacer()
+ if client.loggedIn { Button("退出登录") { client.logout() }.disabled(client.busy) }
+ }
+ Divider()
+ if !client.loggedIn {
+ TextField("XBoard 地址", text: $client.server)
+ TextField("邮箱", text: $client.email)
+ SecureField("密码", text: $client.password)
+ Button("登录并获取订阅") { Task { await client.login() } }.buttonStyle(.borderedProminent).disabled(client.busy || client.email.isEmpty || client.password.isEmpty)
+ } else {
+ Text(client.email).foregroundStyle(.secondary)
+ HStack {
+ TextField("Mihomo 可执行文件路径", text: $client.corePath).disabled(client.connected)
+ Button("选择内核") {
+ let panel = NSOpenPanel(); panel.canChooseDirectories = false
+ if panel.runModal() == .OK { client.corePath = panel.url?.path ?? "" }
+ }.disabled(client.busy || client.connected)
+ }
+ if !client.nodes.isEmpty {
+ Picker("节点", selection: $client.selected) { ForEach(client.nodes, id: \.self) { Text($0).tag($0) } }
+ .onChange(of: client.selected) { _ in if client.connected { Task { await client.chooseNode() } } }
+ }
+ Button(client.connected ? "断开连接" : "启动代理") {
+ if client.connected { client.disconnect() } else { Task { await client.connect() } }
+ }.buttonStyle(.borderedProminent).disabled(client.busy)
+ Text("当前预览版提供本地代理。系统代理和 TUN 接管尚未实现;应用需配置为使用 127.0.0.1:17899。")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ if client.busy { ProgressView().controlSize(.small) }
+ Text(client.status).font(.callout).textSelection(.enabled)
+ Spacer()
+ Text("凭据仅保留在内存中,退出后需重新登录。").font(.caption).foregroundStyle(.secondary)
+ }.textFieldStyle(.roundedBorder).padding(30).frame(minWidth: 560, minHeight: 460)
+ }
+}
+
+@main struct DPLinkApp: App {
+ @StateObject private var client = Client()
+ var body: some Scene {
+ WindowGroup { ContentView(client: client).onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in client.disconnect() } }
+ }
+}
diff --git a/macos-client/build-app.sh b/macos-client/build-app.sh
new file mode 100755
index 0000000..9ff0fcc
--- /dev/null
+++ b/macos-client/build-app.sh
@@ -0,0 +1,21 @@
+#!/bin/sh
+set -eu
+cd "$(dirname "$0")"
+swift build -c release
+APP_DIR="$PWD/build/DP-Link.app"
+mkdir -p "$APP_DIR/Contents/MacOS"
+cp .build/release/DPLink "$APP_DIR/Contents/MacOS/DPLink"
+cat > "$APP_DIR/Contents/Info.plist" <<'PLIST'
+
+
+
+CFBundleExecutableDPLink
+CFBundleIdentifiernet.dp-link.macos
+CFBundleNameDP-Link
+CFBundlePackageTypeAPPL
+CFBundleShortVersionString0.1.0
+LSMinimumSystemVersion13.0
+NSHighResolutionCapable
+
+PLIST
+printf '%s\n' "$APP_DIR"
diff --git a/public/admin-custom/plan-device-display.js b/public/admin-custom/plan-device-display.js
new file mode 100644
index 0000000..1e4f081
--- /dev/null
+++ b/public/admin-custom/plan-device-display.js
@@ -0,0 +1,69 @@
+// Independent admin extension. No hooks into React or enforcement data.
+const host = document.createElement('div');
+host.id = 'plan-device-display-tools';
+const style = document.createElement('style');
+style.textContent = `
+#plan-device-display-tools .entry { position:fixed; right:24px; bottom:24px; z-index:40; border:1px solid #cbd5e1; border-radius:8px; padding:9px 14px; color:#0f172a; background:#fff; cursor:pointer; box-shadow:0 2px 8px #0001; }
+#plan-device-display-dialog { width:min(680px,calc(100vw - 40px)); max-height:80vh; overflow:auto; border:1px solid #cbd5e1; border-radius:12px; padding:24px; background:#fff; color:#0f172a; }
+#plan-device-display-dialog::backdrop {background:#0007}
+#plan-device-display-dialog h2 {font-size:18px;font-weight:600;margin:0 0 8px}
+#plan-device-display-dialog .hint {font-size:13px;color:#64748b;margin:8px 0 18px}
+#plan-device-display-dialog fieldset {border:1px solid #e2e8f0;border-radius:8px;padding:12px;margin:12px 0}
+#plan-device-display-dialog legend {padding:0 6px;font-weight:600}
+#plan-device-display-dialog label {font-size:13px;display:block;margin-bottom:6px}
+#plan-device-display-dialog input {border:1px solid #cbd5e1;border-radius:6px;padding:8px;width:160px;margin-right:12px}
+#plan-device-display-dialog button {border:1px solid #cbd5e1;border-radius:6px;padding:7px 12px;cursor:pointer;background:#f8fafc}
+#plan-device-display-dialog .message {font-size:13px;min-height:20px;margin-top:6px}
+`;
+host.append(style);
+const entry = document.createElement('button');
+entry.className='entry'; entry.type='button'; entry.textContent='展示设备数设置'; host.append(entry);
+const dialog = document.createElement('dialog'); dialog.id='plan-device-display-dialog';
+const heading=document.createElement('h2'); heading.textContent='套餐展示设备数'; dialog.append(heading);
+const hint=document.createElement('p'); hint.className='hint';
+hint.textContent='仅用于页面展示。留空沿用实际限制,0 展示“不限制”。保存不会修改实际设备限制。'; dialog.append(hint);
+const rows=document.createElement('div'); dialog.append(rows);
+const close=document.createElement('button'); close.type='button'; close.textContent='关闭'; close.addEventListener('click',()=>dialog.close()); dialog.append(close);
+host.append(dialog); document.body.append(host);
+function syncVisibility() {
+ const allowed=location.hash.split('?')[0]==='#/finance/plan' && document.querySelector('#root h2');
+ entry.hidden=!allowed;
+ if (!allowed && dialog.open) dialog.close();
+}
+window.addEventListener('hashchange',syncVisibility); syncVisibility();
+new MutationObserver(syncVisibility).observe(document.getElementById('root'),{childList:true,subtree:true});
+async function api(action,payload) {
+ let stored; try {stored=JSON.parse(localStorage.getItem('XBOARD_ACCESS_TOKEN')||'null');} catch {throw new Error('请重新登录后台');}
+ if (!stored?.value || (stored.expire!=null && stored.expire<=Date.now())) throw new Error('请重新登录后台');
+ const base=new URL(window.settings?.base_url||'/',location.origin);
+ if (base.origin!==location.origin) throw new Error('后台接口必须同源');
+ const prefix=base.pathname.replace(/\/$/,'')+'/api/v2/'+encodeURIComponent(window.settings.secure_path)+'/plan/';
+ const response=await fetch(prefix+action,{method:payload?'POST':'GET',credentials:'same-origin',headers:{Authorization:stored.value,'Content-Type':'application/json','Content-Language':'zh-CN'},...(payload?{body:JSON.stringify(payload)}:{})});
+ const data=await response.json();
+ if (!response.ok || data.status==='fail' || data.data===false) throw new Error(data.message||'保存失败');
+ return data.data;
+}
+entry.addEventListener('click',async()=>{
+ rows.textContent='正在读取套餐…'; dialog.showModal(); entry.disabled=true;
+ try {
+ const plans=await api('fetch'); rows.replaceChildren();
+ for (const plan of plans) {
+ const fieldset=document.createElement('fieldset'); const legend=document.createElement('legend'); legend.textContent=plan.name; fieldset.append(legend);
+ const actual=document.createElement('p'); actual.className='hint'; actual.textContent='实际设备限制:'+(plan.device_limit==null || Number(plan.device_limit)<=0?'不限制':plan.device_limit); fieldset.append(actual);
+ const label=document.createElement('label'); label.textContent='展示设备数'; label.htmlFor='display-device-'+plan.id; fieldset.append(label);
+ const input=document.createElement('input'); input.id=label.htmlFor; input.type='number'; input.min='0'; input.max='4294967295'; input.step='1'; input.placeholder='留空沿用实际限制'; input.value=plan.display_device_limit==null?'':String(plan.display_device_limit); fieldset.append(input);
+ const save=document.createElement('button'); save.type='button'; save.textContent='保存 '+plan.name; fieldset.append(save);
+ const message=document.createElement('p'); message.className='message'; message.setAttribute('role','status'); fieldset.append(message);
+ save.addEventListener('click',async()=>{
+ const value=input.value.trim(); const number=value===''?null:Number(value);
+ if (number!==null && (!Number.isInteger(number)||number<0||number>4294967295)) {message.textContent='请填写非负整数,或留空';return;}
+ save.disabled=true; message.textContent='';
+ try {await api('update',{id:plan.id,display_device_limit:number}); message.textContent='已保存;实际设备限制未修改。';}
+ catch(error){message.textContent=error.message||'保存失败';}
+ finally{save.disabled=false;}
+ });
+ rows.append(fieldset);
+ }
+ } catch(error){rows.textContent=error.message||'读取失败';}
+ finally{entry.disabled=false;}
+});
diff --git a/public/tutorials/android/flclash/images/01-download.png b/public/tutorials/android/flclash/images/01-download.png
new file mode 100644
index 0000000..3552035
Binary files /dev/null and b/public/tutorials/android/flclash/images/01-download.png differ
diff --git a/public/tutorials/android/flclash/images/02-install.svg b/public/tutorials/android/flclash/images/02-install.svg
new file mode 100644
index 0000000..72a7f33
--- /dev/null
+++ b/public/tutorials/android/flclash/images/02-install.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/tutorials/android/flclash/images/03-import.png b/public/tutorials/android/flclash/images/03-import.png
new file mode 100644
index 0000000..57cacb1
Binary files /dev/null and b/public/tutorials/android/flclash/images/03-import.png differ
diff --git a/public/tutorials/android/flclash/images/04-manual-import.svg b/public/tutorials/android/flclash/images/04-manual-import.svg
new file mode 100644
index 0000000..f4ceedf
--- /dev/null
+++ b/public/tutorials/android/flclash/images/04-manual-import.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/tutorials/android/flclash/images/05-proxy.svg b/public/tutorials/android/flclash/images/05-proxy.svg
new file mode 100644
index 0000000..107d307
--- /dev/null
+++ b/public/tutorials/android/flclash/images/05-proxy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/tutorials/android/flclash/images/06-connect.svg b/public/tutorials/android/flclash/images/06-connect.svg
new file mode 100644
index 0000000..71fcf27
--- /dev/null
+++ b/public/tutorials/android/flclash/images/06-connect.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/tutorials/android/flclash/images/import-buttons.png b/public/tutorials/android/flclash/images/import-buttons.png
new file mode 100644
index 0000000..87a50d5
Binary files /dev/null and b/public/tutorials/android/flclash/images/import-buttons.png differ
diff --git a/public/tutorials/assets/live-reload.js b/public/tutorials/assets/live-reload.js
new file mode 100644
index 0000000..2fba508
--- /dev/null
+++ b/public/tutorials/assets/live-reload.js
@@ -0,0 +1,12 @@
+(() => {
+ if (!['localhost', '127.0.0.1'].includes(location.hostname)) return;
+ const events = new EventSource('http://127.0.0.1:8588/events');
+ let revision;
+ const refresh = (event) => {
+ const next = JSON.parse(event.data).revision;
+ if (revision && revision !== next) location.reload();
+ revision = next;
+ };
+ events.addEventListener('ready', refresh);
+ events.addEventListener('update', refresh);
+})();
diff --git a/public/tutorials/assets/tutorial.css b/public/tutorials/assets/tutorial.css
new file mode 100644
index 0000000..b035948
--- /dev/null
+++ b/public/tutorials/assets/tutorial.css
@@ -0,0 +1 @@
+*{box-sizing:border-box}body{margin:0;background:#f1f5fb;color:#17253f;font:16px/1.8 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif}main{max-width:960px;margin:40px auto 0;padding:0 28px 32px}header{padding:36px;background:#142542;color:white;border-radius:22px}header img{width:42px;height:42px;object-fit:contain;vertical-align:middle;margin-right:12px}.brand{font-weight:750;letter-spacing:.04em}h1{font-size:36px;margin:18px 0 8px;line-height:1.25}header p{color:#becde2;margin:0}nav{display:flex;flex-wrap:wrap;gap:8px;margin:24px 0}nav a{background:white;border-radius:8px;padding:8px 14px;text-decoration:none;color:#305277;font-size:14px}section{background:white;border:1px solid #e1e8f2;border-radius:18px;margin:22px 0;padding:30px}h2{font-size:24px;line-height:1.5;margin:0 0 14px}.num{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;color:white;background:#3278e9;border-radius:10px;margin-right:12px;font-size:18px}p{margin:12px 0}figure{margin:20px 0 8px}figure img{display:block;max-width:100%;height:auto;border-radius:12px;border:1px solid #e3e9f2;margin:auto}figcaption{font-size:13px;color:#697d99;margin-top:10px;text-align:center}.note{padding:12px 16px;border-left:3px solid #5087db;background:#eef5ff;border-radius:6px;color:#3a567c;font-size:14px}.smallpic{max-width:560px;margin-left:auto;margin-right:auto}.pair{display:grid;grid-template-columns:1fr 1fr;gap:24px}a{color:#236bd3}code{background:#edf2fa;padding:2px 5px;border-radius:4px}details{border-top:1px solid #e7ecf3;padding:14px 0}summary{font-weight:650;cursor:pointer}footer{color:#6a7b93;font-size:13px;padding:0 10px}ul{padding-left:24px}b{font-weight:650}@media(max-width:640px){main{padding:0 16px;margin:20px auto}header,section{padding:22px}h1{font-size:28px}h2{font-size:21px}.pair{grid-template-columns:1fr}}@media print{body{background:white}main{margin:0;max-width:none;padding:0}nav{display:none}header{print-color-adjust:exact}section{break-inside:avoid;border-radius:0}details{display:block}details p{display:block}}#help h2{margin-bottom:20px}#help details{border:0;padding:0}#help details+details{margin-top:22px}#help details p{margin:8px 0 0}footer{line-height:1.6}@media(max-width:640px){footer{padding-left:10px!important;padding-right:10px!important}}
\ No newline at end of file
diff --git a/public/tutorials/ios/hiddify/images/01-store.png b/public/tutorials/ios/hiddify/images/01-store.png
new file mode 100644
index 0000000..27ae383
Binary files /dev/null and b/public/tutorials/ios/hiddify/images/01-store.png differ
diff --git a/public/tutorials/ios/hiddify/images/02-install.svg b/public/tutorials/ios/hiddify/images/02-install.svg
new file mode 100644
index 0000000..8324584
--- /dev/null
+++ b/public/tutorials/ios/hiddify/images/02-install.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/tutorials/ios/hiddify/images/03-import.png b/public/tutorials/ios/hiddify/images/03-import.png
new file mode 100644
index 0000000..f0b2894
Binary files /dev/null and b/public/tutorials/ios/hiddify/images/03-import.png differ
diff --git a/public/tutorials/ios/hiddify/images/04-manual-import.svg b/public/tutorials/ios/hiddify/images/04-manual-import.svg
new file mode 100644
index 0000000..240e635
--- /dev/null
+++ b/public/tutorials/ios/hiddify/images/04-manual-import.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/tutorials/ios/hiddify/images/05-connect.svg b/public/tutorials/ios/hiddify/images/05-connect.svg
new file mode 100644
index 0000000..8526111
--- /dev/null
+++ b/public/tutorials/ios/hiddify/images/05-connect.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/tutorials/ios/hiddify/images/06-proxy.svg b/public/tutorials/ios/hiddify/images/06-proxy.svg
new file mode 100644
index 0000000..17aaab3
--- /dev/null
+++ b/public/tutorials/ios/hiddify/images/06-proxy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/tutorials/ios/hiddify/images/import-buttons.png b/public/tutorials/ios/hiddify/images/import-buttons.png
new file mode 100644
index 0000000..87a50d5
Binary files /dev/null and b/public/tutorials/ios/hiddify/images/import-buttons.png differ
diff --git a/public/tutorials/linux/clash-verge/images/01-download.png b/public/tutorials/linux/clash-verge/images/01-download.png
new file mode 100644
index 0000000..ea441e5
Binary files /dev/null and b/public/tutorials/linux/clash-verge/images/01-download.png differ
diff --git a/public/tutorials/linux/clash-verge/images/02-install.svg b/public/tutorials/linux/clash-verge/images/02-install.svg
new file mode 100644
index 0000000..77f3c46
--- /dev/null
+++ b/public/tutorials/linux/clash-verge/images/02-install.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/tutorials/linux/clash-verge/images/03-import.svg b/public/tutorials/linux/clash-verge/images/03-import.svg
new file mode 100644
index 0000000..2023b57
--- /dev/null
+++ b/public/tutorials/linux/clash-verge/images/03-import.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/tutorials/linux/clash-verge/images/04-proxy.svg b/public/tutorials/linux/clash-verge/images/04-proxy.svg
new file mode 100644
index 0000000..45a38e6
--- /dev/null
+++ b/public/tutorials/linux/clash-verge/images/04-proxy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/tutorials/linux/clash-verge/images/05-connect.svg b/public/tutorials/linux/clash-verge/images/05-connect.svg
new file mode 100644
index 0000000..d043541
--- /dev/null
+++ b/public/tutorials/linux/clash-verge/images/05-connect.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/tutorials/linux/clash-verge/images/import-buttons.png b/public/tutorials/linux/clash-verge/images/import-buttons.png
new file mode 100644
index 0000000..87a50d5
Binary files /dev/null and b/public/tutorials/linux/clash-verge/images/import-buttons.png differ
diff --git a/public/tutorials/macos/clash-verge/images/01-download.png b/public/tutorials/macos/clash-verge/images/01-download.png
new file mode 100644
index 0000000..5610a16
Binary files /dev/null and b/public/tutorials/macos/clash-verge/images/01-download.png differ
diff --git a/public/tutorials/macos/clash-verge/images/02-chip.png b/public/tutorials/macos/clash-verge/images/02-chip.png
new file mode 100644
index 0000000..6732ce1
Binary files /dev/null and b/public/tutorials/macos/clash-verge/images/02-chip.png differ
diff --git a/public/tutorials/macos/clash-verge/images/03-install.png b/public/tutorials/macos/clash-verge/images/03-install.png
new file mode 100644
index 0000000..fa2a322
Binary files /dev/null and b/public/tutorials/macos/clash-verge/images/03-install.png differ
diff --git a/public/tutorials/macos/clash-verge/images/04-macos-import.png b/public/tutorials/macos/clash-verge/images/04-macos-import.png
new file mode 100644
index 0000000..d84553f
Binary files /dev/null and b/public/tutorials/macos/clash-verge/images/04-macos-import.png differ
diff --git a/public/tutorials/macos/clash-verge/images/05-manual-import.png b/public/tutorials/macos/clash-verge/images/05-manual-import.png
new file mode 100644
index 0000000..17c271c
Binary files /dev/null and b/public/tutorials/macos/clash-verge/images/05-manual-import.png differ
diff --git a/public/tutorials/macos/clash-verge/images/06-proxy.png b/public/tutorials/macos/clash-verge/images/06-proxy.png
new file mode 100644
index 0000000..324b1a9
Binary files /dev/null and b/public/tutorials/macos/clash-verge/images/06-proxy.png differ
diff --git a/public/tutorials/macos/clash-verge/images/07-system-proxy.png b/public/tutorials/macos/clash-verge/images/07-system-proxy.png
new file mode 100644
index 0000000..f015eb2
Binary files /dev/null and b/public/tutorials/macos/clash-verge/images/07-system-proxy.png differ
diff --git a/public/tutorials/macos/clash-verge/images/import-buttons.png b/public/tutorials/macos/clash-verge/images/import-buttons.png
new file mode 100644
index 0000000..87a50d5
Binary files /dev/null and b/public/tutorials/macos/clash-verge/images/import-buttons.png differ
diff --git a/public/tutorials/router/openclash/images/01-download.png b/public/tutorials/router/openclash/images/01-download.png
new file mode 100644
index 0000000..a9bb9c7
Binary files /dev/null and b/public/tutorials/router/openclash/images/01-download.png differ
diff --git a/public/tutorials/router/openclash/images/02-install.svg b/public/tutorials/router/openclash/images/02-install.svg
new file mode 100644
index 0000000..8b9cca8
--- /dev/null
+++ b/public/tutorials/router/openclash/images/02-install.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/tutorials/router/openclash/images/03-subscribe.svg b/public/tutorials/router/openclash/images/03-subscribe.svg
new file mode 100644
index 0000000..8b10655
--- /dev/null
+++ b/public/tutorials/router/openclash/images/03-subscribe.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/tutorials/router/openclash/images/04-start.svg b/public/tutorials/router/openclash/images/04-start.svg
new file mode 100644
index 0000000..76f3192
--- /dev/null
+++ b/public/tutorials/router/openclash/images/04-start.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/tutorials/router/openclash/images/05-network.svg b/public/tutorials/router/openclash/images/05-network.svg
new file mode 100644
index 0000000..a9ff08d
--- /dev/null
+++ b/public/tutorials/router/openclash/images/05-network.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/tutorials/router/openclash/images/import-buttons.png b/public/tutorials/router/openclash/images/import-buttons.png
new file mode 100644
index 0000000..87a50d5
Binary files /dev/null and b/public/tutorials/router/openclash/images/import-buttons.png differ
diff --git a/public/tutorials/windows/clash-verge/images/01-download.png b/public/tutorials/windows/clash-verge/images/01-download.png
new file mode 100644
index 0000000..a65ebdf
Binary files /dev/null and b/public/tutorials/windows/clash-verge/images/01-download.png differ
diff --git a/public/tutorials/windows/clash-verge/images/02-import-buttons.png b/public/tutorials/windows/clash-verge/images/02-import-buttons.png
new file mode 100644
index 0000000..87a50d5
Binary files /dev/null and b/public/tutorials/windows/clash-verge/images/02-import-buttons.png differ
diff --git a/public/tutorials/windows/clash-verge/images/03-windows-import.png b/public/tutorials/windows/clash-verge/images/03-windows-import.png
new file mode 100644
index 0000000..135156e
Binary files /dev/null and b/public/tutorials/windows/clash-verge/images/03-windows-import.png differ
diff --git a/public/tutorials/windows/clash-verge/images/04-manual-import.png b/public/tutorials/windows/clash-verge/images/04-manual-import.png
new file mode 100644
index 0000000..17c271c
Binary files /dev/null and b/public/tutorials/windows/clash-verge/images/04-manual-import.png differ
diff --git a/public/tutorials/windows/clash-verge/images/05-proxy.png b/public/tutorials/windows/clash-verge/images/05-proxy.png
new file mode 100644
index 0000000..324b1a9
Binary files /dev/null and b/public/tutorials/windows/clash-verge/images/05-proxy.png differ
diff --git a/public/tutorials/windows/clash-verge/images/06-system-proxy.png b/public/tutorials/windows/clash-verge/images/06-system-proxy.png
new file mode 100644
index 0000000..f015eb2
Binary files /dev/null and b/public/tutorials/windows/clash-verge/images/06-system-proxy.png differ
diff --git a/resources/js/admin/plan-device-display.js b/resources/js/admin/plan-device-display.js
new file mode 100644
index 0000000..1e4f081
--- /dev/null
+++ b/resources/js/admin/plan-device-display.js
@@ -0,0 +1,69 @@
+// Independent admin extension. No hooks into React or enforcement data.
+const host = document.createElement('div');
+host.id = 'plan-device-display-tools';
+const style = document.createElement('style');
+style.textContent = `
+#plan-device-display-tools .entry { position:fixed; right:24px; bottom:24px; z-index:40; border:1px solid #cbd5e1; border-radius:8px; padding:9px 14px; color:#0f172a; background:#fff; cursor:pointer; box-shadow:0 2px 8px #0001; }
+#plan-device-display-dialog { width:min(680px,calc(100vw - 40px)); max-height:80vh; overflow:auto; border:1px solid #cbd5e1; border-radius:12px; padding:24px; background:#fff; color:#0f172a; }
+#plan-device-display-dialog::backdrop {background:#0007}
+#plan-device-display-dialog h2 {font-size:18px;font-weight:600;margin:0 0 8px}
+#plan-device-display-dialog .hint {font-size:13px;color:#64748b;margin:8px 0 18px}
+#plan-device-display-dialog fieldset {border:1px solid #e2e8f0;border-radius:8px;padding:12px;margin:12px 0}
+#plan-device-display-dialog legend {padding:0 6px;font-weight:600}
+#plan-device-display-dialog label {font-size:13px;display:block;margin-bottom:6px}
+#plan-device-display-dialog input {border:1px solid #cbd5e1;border-radius:6px;padding:8px;width:160px;margin-right:12px}
+#plan-device-display-dialog button {border:1px solid #cbd5e1;border-radius:6px;padding:7px 12px;cursor:pointer;background:#f8fafc}
+#plan-device-display-dialog .message {font-size:13px;min-height:20px;margin-top:6px}
+`;
+host.append(style);
+const entry = document.createElement('button');
+entry.className='entry'; entry.type='button'; entry.textContent='展示设备数设置'; host.append(entry);
+const dialog = document.createElement('dialog'); dialog.id='plan-device-display-dialog';
+const heading=document.createElement('h2'); heading.textContent='套餐展示设备数'; dialog.append(heading);
+const hint=document.createElement('p'); hint.className='hint';
+hint.textContent='仅用于页面展示。留空沿用实际限制,0 展示“不限制”。保存不会修改实际设备限制。'; dialog.append(hint);
+const rows=document.createElement('div'); dialog.append(rows);
+const close=document.createElement('button'); close.type='button'; close.textContent='关闭'; close.addEventListener('click',()=>dialog.close()); dialog.append(close);
+host.append(dialog); document.body.append(host);
+function syncVisibility() {
+ const allowed=location.hash.split('?')[0]==='#/finance/plan' && document.querySelector('#root h2');
+ entry.hidden=!allowed;
+ if (!allowed && dialog.open) dialog.close();
+}
+window.addEventListener('hashchange',syncVisibility); syncVisibility();
+new MutationObserver(syncVisibility).observe(document.getElementById('root'),{childList:true,subtree:true});
+async function api(action,payload) {
+ let stored; try {stored=JSON.parse(localStorage.getItem('XBOARD_ACCESS_TOKEN')||'null');} catch {throw new Error('请重新登录后台');}
+ if (!stored?.value || (stored.expire!=null && stored.expire<=Date.now())) throw new Error('请重新登录后台');
+ const base=new URL(window.settings?.base_url||'/',location.origin);
+ if (base.origin!==location.origin) throw new Error('后台接口必须同源');
+ const prefix=base.pathname.replace(/\/$/,'')+'/api/v2/'+encodeURIComponent(window.settings.secure_path)+'/plan/';
+ const response=await fetch(prefix+action,{method:payload?'POST':'GET',credentials:'same-origin',headers:{Authorization:stored.value,'Content-Type':'application/json','Content-Language':'zh-CN'},...(payload?{body:JSON.stringify(payload)}:{})});
+ const data=await response.json();
+ if (!response.ok || data.status==='fail' || data.data===false) throw new Error(data.message||'保存失败');
+ return data.data;
+}
+entry.addEventListener('click',async()=>{
+ rows.textContent='正在读取套餐…'; dialog.showModal(); entry.disabled=true;
+ try {
+ const plans=await api('fetch'); rows.replaceChildren();
+ for (const plan of plans) {
+ const fieldset=document.createElement('fieldset'); const legend=document.createElement('legend'); legend.textContent=plan.name; fieldset.append(legend);
+ const actual=document.createElement('p'); actual.className='hint'; actual.textContent='实际设备限制:'+(plan.device_limit==null || Number(plan.device_limit)<=0?'不限制':plan.device_limit); fieldset.append(actual);
+ const label=document.createElement('label'); label.textContent='展示设备数'; label.htmlFor='display-device-'+plan.id; fieldset.append(label);
+ const input=document.createElement('input'); input.id=label.htmlFor; input.type='number'; input.min='0'; input.max='4294967295'; input.step='1'; input.placeholder='留空沿用实际限制'; input.value=plan.display_device_limit==null?'':String(plan.display_device_limit); fieldset.append(input);
+ const save=document.createElement('button'); save.type='button'; save.textContent='保存 '+plan.name; fieldset.append(save);
+ const message=document.createElement('p'); message.className='message'; message.setAttribute('role','status'); fieldset.append(message);
+ save.addEventListener('click',async()=>{
+ const value=input.value.trim(); const number=value===''?null:Number(value);
+ if (number!==null && (!Number.isInteger(number)||number<0||number>4294967295)) {message.textContent='请填写非负整数,或留空';return;}
+ save.disabled=true; message.textContent='';
+ try {await api('update',{id:plan.id,display_device_limit:number}); message.textContent='已保存;实际设备限制未修改。';}
+ catch(error){message.textContent=error.message||'保存失败';}
+ finally{save.disabled=false;}
+ });
+ rows.append(fieldset);
+ }
+ } catch(error){rows.textContent=error.message||'读取失败';}
+ finally{entry.disabled=false;}
+});
diff --git a/resources/tutorials/layout.html b/resources/tutorials/layout.html
new file mode 100644
index 0000000..5aa17ee
--- /dev/null
+++ b/resources/tutorials/layout.html
@@ -0,0 +1 @@
+{{title}} · {{brand}}{{body}}
\ No newline at end of file
diff --git a/resources/tutorials/live-reload.js b/resources/tutorials/live-reload.js
new file mode 100644
index 0000000..2fba508
--- /dev/null
+++ b/resources/tutorials/live-reload.js
@@ -0,0 +1,12 @@
+(() => {
+ if (!['localhost', '127.0.0.1'].includes(location.hostname)) return;
+ const events = new EventSource('http://127.0.0.1:8588/events');
+ let revision;
+ const refresh = (event) => {
+ const next = JSON.parse(event.data).revision;
+ if (revision && revision !== next) location.reload();
+ revision = next;
+ };
+ events.addEventListener('ready', refresh);
+ events.addEventListener('update', refresh);
+})();
diff --git a/resources/tutorials/tutorial.css b/resources/tutorials/tutorial.css
new file mode 100644
index 0000000..b035948
--- /dev/null
+++ b/resources/tutorials/tutorial.css
@@ -0,0 +1 @@
+*{box-sizing:border-box}body{margin:0;background:#f1f5fb;color:#17253f;font:16px/1.8 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif}main{max-width:960px;margin:40px auto 0;padding:0 28px 32px}header{padding:36px;background:#142542;color:white;border-radius:22px}header img{width:42px;height:42px;object-fit:contain;vertical-align:middle;margin-right:12px}.brand{font-weight:750;letter-spacing:.04em}h1{font-size:36px;margin:18px 0 8px;line-height:1.25}header p{color:#becde2;margin:0}nav{display:flex;flex-wrap:wrap;gap:8px;margin:24px 0}nav a{background:white;border-radius:8px;padding:8px 14px;text-decoration:none;color:#305277;font-size:14px}section{background:white;border:1px solid #e1e8f2;border-radius:18px;margin:22px 0;padding:30px}h2{font-size:24px;line-height:1.5;margin:0 0 14px}.num{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;color:white;background:#3278e9;border-radius:10px;margin-right:12px;font-size:18px}p{margin:12px 0}figure{margin:20px 0 8px}figure img{display:block;max-width:100%;height:auto;border-radius:12px;border:1px solid #e3e9f2;margin:auto}figcaption{font-size:13px;color:#697d99;margin-top:10px;text-align:center}.note{padding:12px 16px;border-left:3px solid #5087db;background:#eef5ff;border-radius:6px;color:#3a567c;font-size:14px}.smallpic{max-width:560px;margin-left:auto;margin-right:auto}.pair{display:grid;grid-template-columns:1fr 1fr;gap:24px}a{color:#236bd3}code{background:#edf2fa;padding:2px 5px;border-radius:4px}details{border-top:1px solid #e7ecf3;padding:14px 0}summary{font-weight:650;cursor:pointer}footer{color:#6a7b93;font-size:13px;padding:0 10px}ul{padding-left:24px}b{font-weight:650}@media(max-width:640px){main{padding:0 16px;margin:20px auto}header,section{padding:22px}h1{font-size:28px}h2{font-size:21px}.pair{grid-template-columns:1fr}}@media print{body{background:white}main{margin:0;max-width:none;padding:0}nav{display:none}header{print-color-adjust:exact}section{break-inside:avoid;border-radius:0}details{display:block}details p{display:block}}#help h2{margin-bottom:20px}#help details{border:0;padding:0}#help details+details{margin-top:22px}#help details p{margin:8px 0 0}footer{line-height:1.6}@media(max-width:640px){footer{padding-left:10px!important;padding-right:10px!important}}
\ No newline at end of file
diff --git a/resources/views/admin.blade.php b/resources/views/admin.blade.php
index 8733d36..cfd3d09 100644
--- a/resources/views/admin.blade.php
+++ b/resources/views/admin.blade.php
@@ -81,6 +81,7 @@
+