Compare commits

..

4 Commits

Author SHA1 Message Date
xboard
d2462bc683 fix: correct know file issues
Some checks are pending
Docker Build and Publish / build (push) Waiting to run
2025-01-13 20:34:50 +08:00
xboard
b7f2af7d6a fix: resolve various issues and add new features 2025-01-13 20:29:09 +08:00
xboard
1f31c6b585 fix: correct know file issues 2025-01-13 18:38:16 +08:00
xboard
d54eabb617 fix: correct know file issues 2025-01-13 12:53:23 +08:00
33 changed files with 630 additions and 221 deletions

View File

@ -4,6 +4,8 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use Google\Cloud\Storage\StorageClient;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
class BackupDatabase extends Command
@ -85,14 +87,14 @@ class BackupDatabase extends Command
]);
// 输出文件链接
\Log::channel('backup')->info("🎉:数据库备份已上传到 Google Cloud Storage: $objectName");
Log::channel('backup')->info("🎉:数据库备份已上传到 Google Cloud Storage: $objectName");
$this->info("🎉:数据库备份已上传到 Google Cloud Storage: $objectName");
\File::delete($compressedBackupPath);
File::delete($compressedBackupPath);
}
}catch(\Exception $e){
\Log::channel('backup')->error("😔:数据库备份失败 \n" . $e);
Log::channel('backup')->error("😔:数据库备份失败 \n" . $e);
$this->error("😔:数据库备份失败\n" . $e);
\File::delete($compressedBackupPath);
File::delete($compressedBackupPath);
}
}
}

View File

@ -45,7 +45,7 @@ class ClearUser extends Command
->where('last_login_at', NULL);
$count = $builder->count();
if ($builder->delete()) {
$this->info("已删除${count}位没有任何数据的用户");
$this->info("已删除{$count}位没有任何数据的用户");
}
}
}

View File

@ -4,6 +4,7 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class ExportV2Log extends Command
{
@ -20,7 +21,7 @@ class ExportV2Log extends Command
$days = $this->argument('days');
$date = Carbon::now()->subDays($days)->startOfDay();
$logs = \DB::table('v2_log')
$logs = DB::table('v2_log')
->where('created_at', '>=', $date->timestamp)
->get();

View File

@ -4,6 +4,8 @@ namespace App\Console\Commands;
use App\Models\Setting;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
class MigrateFromV2b extends Command
{
@ -132,7 +134,7 @@ class MigrateFromV2b extends Command
try {
foreach ($sqlCommands[$version] as $sqlCommand) {
// Execute SQL command
\DB::statement($sqlCommand);
DB::statement($sqlCommand);
}
$this->info('1⃣、数据库差异矫正成功');
@ -158,7 +160,7 @@ class MigrateFromV2b extends Command
public function MigrateV2ConfigToV2Settings()
{
\Artisan::call('config:clear');
Artisan::call('config:clear');
$configValue = config('v2board') ?? [];
foreach ($configValue as $k => $v) {
@ -176,7 +178,7 @@ class MigrateFromV2b extends Command
]);
$this->info("配置 {$k} 迁移成功");
}
\Artisan::call('config:cache');
Artisan::call('config:cache');
$this->info('所有配置迁移完成');
}

View File

@ -7,6 +7,10 @@ use Illuminate\Encryption\Encrypter;
use App\Models\User;
use App\Utils\Helper;
use Illuminate\Support\Env;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\text;
use function Laravel\Prompts\note;
@ -55,7 +59,7 @@ class XboardInstall extends Command
$this->info(" / /\ \ | |_) | (_) | (_| | | | (_| | ");
$this->info("/_/ \_\|____/ \___/ \__,_|_| \__,_| ");
if (
(\File::exists(base_path() . '/.env') && $this->getEnvValue('INSTALLED'))
(File::exists(base_path() . '/.env') && $this->getEnvValue('INSTALLED'))
|| (env('INSTALLED', false) && $isDocker)
) {
$securePath = admin_setting('secure_path', admin_setting('frontend_admin_path', hash('crc32b', config('app.key'))));
@ -86,11 +90,11 @@ class XboardInstall extends Command
'DB_PASSWORD' => '',
];
try {
\Config::set("database.default", 'sqlite');
\Config::set("database.connections.sqlite.database", base_path($envConfig['DB_DATABASE']));
\DB::purge('sqlite');
\DB::connection('sqlite')->getPdo();
if (!blank(\DB::connection('sqlite')->getPdo()->query("SELECT name FROM sqlite_master WHERE type='table'")->fetchAll(\PDO::FETCH_COLUMN))) {
Config::set("database.default", 'sqlite');
Config::set("database.connections.sqlite.database", base_path($envConfig['DB_DATABASE']));
DB::purge('sqlite');
DB::connection('sqlite')->getPdo();
if (!blank(DB::connection('sqlite')->getPdo()->query("SELECT name FROM sqlite_master WHERE type='table'")->fetchAll(\PDO::FETCH_COLUMN))) {
if (confirm(label: '检测到数据库中已经存在数据,是否要清空数据库以便安装新的数据?', default: false, yes: '清空', no: '退出安装')) {
$this->info('正在清空数据库请稍等');
$this->call('db:wipe', ['--force' => true]);
@ -115,16 +119,16 @@ class XboardInstall extends Command
'DB_PASSWORD' => text(label: '请输入数据库密码', required: false),
];
try {
\Config::set("database.default", 'mysql');
\Config::set("database.connections.mysql.host", $envConfig['DB_HOST']);
\Config::set("database.connections.mysql.port", $envConfig['DB_PORT']);
\Config::set("database.connections.mysql.database", $envConfig['DB_DATABASE']);
\Config::set("database.connections.mysql.username", $envConfig['DB_USERNAME']);
\Config::set("database.connections.mysql.password", $envConfig['DB_PASSWORD']);
\DB::purge('mysql');
\DB::connection('mysql')->getPdo();
Config::set("database.default", 'mysql');
Config::set("database.connections.mysql.host", $envConfig['DB_HOST']);
Config::set("database.connections.mysql.port", $envConfig['DB_PORT']);
Config::set("database.connections.mysql.database", $envConfig['DB_DATABASE']);
Config::set("database.connections.mysql.username", $envConfig['DB_USERNAME']);
Config::set("database.connections.mysql.password", $envConfig['DB_PASSWORD']);
DB::purge('mysql');
DB::connection('mysql')->getPdo();
$isMysqlValid = true;
if (!blank(\DB::connection('mysql')->select('SHOW TABLES'))) {
if (!blank(DB::connection('mysql')->select('SHOW TABLES'))) {
if (confirm(label: '检测到数据库中已经存在数据,是否要清空数据库以便安装新的数据?', default: false, yes: '清空', no: '不清空')) {
$this->info('正在清空数据库请稍等');
$this->call('db:wipe', ['--force' => true]);
@ -192,10 +196,10 @@ class XboardInstall extends Command
$this->saveToEnv($envConfig);
$this->call('config:cache');
\Artisan::call('cache:clear');
Artisan::call('cache:clear');
$this->info('正在导入数据库请稍等...');
\Artisan::call("migrate", ['--force' => true]);
$this->info(\Artisan::output());
Artisan::call("migrate", ['--force' => true]);
$this->info(Artisan::output());
$this->info('数据库导入完成');
$this->info('开始注册管理员账号');
if (!$this->registerAdmin($email, $password)) {

View File

@ -47,6 +47,60 @@ class ClientController extends Controller
private const ALLOWED_TYPES = ['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks', 'hysteria2'];
/**
* 处理浏览器访问订阅的情况
*/
private function handleBrowserSubscribe($user, UserService $userService)
{
$useTraffic = $user['u'] + $user['d'];
$totalTraffic = $user['transfer_enable'];
$remainingTraffic = Helper::trafficConvert($totalTraffic - $useTraffic);
$expiredDate = $user['expired_at'] ? date('Y-m-d', $user['expired_at']) : __('Unlimited');
$resetDay = $userService->getResetDay($user);
// 获取通用订阅地址
$subscriptionUrl = Helper::getSubscribeUrl($user->token);
// 生成二维码
$writer = new \BaconQrCode\Writer(
new \BaconQrCode\Renderer\ImageRenderer(
new \BaconQrCode\Renderer\RendererStyle\RendererStyle(200),
new \BaconQrCode\Renderer\Image\SvgImageBackEnd()
)
);
$qrCode = base64_encode($writer->writeString($subscriptionUrl));
$data = [
'username' => $user->email,
'status' => $userService->isAvailable($user) ? 'active' : 'inactive',
'data_limit' => $totalTraffic ? Helper::trafficConvert($totalTraffic) : '∞',
'data_used' => Helper::trafficConvert($useTraffic),
'expired_date' => $expiredDate,
'reset_day' => $resetDay,
'subscription_url' => $subscriptionUrl,
'qr_code' => $qrCode
];
// 只有当 device_limit 不为 null 时才添加到返回数据中
if ($user->device_limit !== null) {
$data['device_limit'] = $user->device_limit;
}
return response()->view('client.subscribe', $data);
}
/**
* 检查是否是浏览器访问
*/
private function isBrowserAccess(Request $request): bool
{
$userAgent = strtolower($request->header('User-Agent'));
return str_contains($userAgent, 'mozilla')
|| str_contains($userAgent, 'chrome')
|| str_contains($userAgent, 'safari')
|| str_contains($userAgent, 'edge');
}
public function subscribe(Request $request)
{
$request->validate([
@ -62,6 +116,11 @@ class ClientController extends Controller
return response()->json(['message' => 'Account unavailable'], 403);
}
// 检测是否是浏览器访问
if ($this->isBrowserAccess($request)) {
return $this->handleBrowserSubscribe($user, $userService);
}
$types = $this->getFilteredTypes($request->input('types', 'all'));
$filterArr = $this->getFilterArray($request->input('filter'));
$clientInfo = $this->getClientInfo($request);
@ -107,7 +166,7 @@ class ClientController extends Controller
private function getFilterArray(?string $filter): ?array
{
return mb_strlen($filter ?? '') > 20 ? null :
return mb_strlen((string) $filter) > 20 ? null :
explode('|', str_replace(['|', '', ','], '|', $filter));
}
@ -178,7 +237,7 @@ class ClientController extends Controller
$useTraffic = $user['u'] + $user['d'];
$totalTraffic = $user['transfer_enable'];
$remainingTraffic = Helper::trafficConvert($totalTraffic - $useTraffic);
$expiredDate = $user['expired_at'] ? date('Y-m-d', $user['expired_at']) : '长期有效';
$expiredDate = $user['expired_at'] ? date('Y-m-d', $user['expired_at']) : __('长期有效');
$userService = new UserService();
$resetDay = $userService->getResetDay($user);
array_unshift($servers, array_merge($servers[0], [

View File

@ -32,7 +32,7 @@ class UniProxyController extends Controller
$response['users'] = $users;
$eTag = sha1(json_encode($response));
if (strpos($request->header('If-None-Match'), $eTag) !== false) {
if (strpos($request->header('If-None-Match', ''), $eTag) !== false) {
return response(null, 304);
}

View File

@ -20,7 +20,7 @@ class ServerController extends Controller
$servers = ServerService::getAvailableServers($user);
}
$eTag = sha1(json_encode(array_column($servers, 'cache_key')));
if (strpos($request->header('If-None-Match'), $eTag) !== false ) {
if (strpos($request->header('If-None-Match', ''), $eTag) !== false ) {
return response(null,304);
}
$data = NodeResource::collection($servers);

View File

@ -23,6 +23,35 @@ class StatController extends Controller
}
public function getOverride(Request $request)
{
// 获取在线节点数
$onlineNodes = Server::all()->filter(function ($server) {
$server->loadServerStatus();
return $server->is_online;
})->count();
// 获取在线设备数和在线用户数
$onlineDevices = User::where('t', '>=', time() - 600)
->sum('online_count');
$onlineUsers = User::where('t', '>=', time() - 600)
->count();
// 获取今日流量统计
$todayStart = strtotime('today');
$todayTraffic = StatServer::where('record_at', '>=', $todayStart)
->where('record_at', '<', time())
->selectRaw('SUM(u) as upload, SUM(d) as download, SUM(u + d) as total')
->first();
// 获取本月流量统计
$monthStart = strtotime(date('Y-m-1'));
$monthTraffic = StatServer::where('record_at', '>=', $monthStart)
->where('record_at', '<', time())
->selectRaw('SUM(u) as upload, SUM(d) as download, SUM(u + d) as total')
->first();
// 获取总流量统计
$totalTraffic = StatServer::selectRaw('SUM(u) as upload, SUM(d) as download, SUM(u + d) as total')
->first();
return [
'data' => [
'month_income' => Order::where('created_at', '>=', strtotime(date('Y-m-1')))
@ -53,6 +82,25 @@ class StatController extends Controller
'commission_last_month_payout' => CommissionLog::where('created_at', '>=', strtotime('-1 month', strtotime(date('Y-m-1'))))
->where('created_at', '<', strtotime(date('Y-m-1')))
->sum('get_amount'),
// 新增统计数据
'online_nodes' => $onlineNodes,
'online_devices' => $onlineDevices,
'online_users' => $onlineUsers,
'today_traffic' => [
'upload' => $todayTraffic->upload ?? 0,
'download' => $todayTraffic->download ?? 0,
'total' => $todayTraffic->total ?? 0
],
'month_traffic' => [
'upload' => $monthTraffic->upload ?? 0,
'download' => $monthTraffic->download ?? 0,
'total' => $monthTraffic->total ?? 0
],
'total_traffic' => [
'upload' => $totalTraffic->upload ?? 0,
'download' => $totalTraffic->download ?? 0,
'total' => $totalTraffic->total ?? 0
]
]
];
}
@ -213,11 +261,39 @@ class StatController extends Controller
$currentMonthStart = strtotime(date('Y-m-01'));
$lastMonthStart = strtotime('-1 month', $currentMonthStart);
$twoMonthsAgoStart = strtotime('-2 month', $currentMonthStart);
// Today's start timestamp
$todayStart = strtotime('today');
$yesterdayStart = strtotime('-1 day', $todayStart);
// 获取在线节点数
$onlineNodes = Server::all()->filter(function ($server) {
$server->loadServerStatus();
return $server->is_online;
})->count();
// 获取在线设备数和在线用户数
$onlineDevices = User::where('t', '>=', time() - 600)
->sum('online_count');
$onlineUsers = User::where('t', '>=', time() - 600)
->count();
// 获取今日流量统计
$todayTraffic = StatServer::where('record_at', '>=', $todayStart)
->where('record_at', '<', time())
->selectRaw('SUM(u) as upload, SUM(d) as download, SUM(u + d) as total')
->first();
// 获取本月流量统计
$monthTraffic = StatServer::where('record_at', '>=', $currentMonthStart)
->where('record_at', '<', time())
->selectRaw('SUM(u) as upload, SUM(d) as download, SUM(u + d) as total')
->first();
// 获取总流量统计
$totalTraffic = StatServer::selectRaw('SUM(u) as upload, SUM(d) as download, SUM(u + d) as total')
->first();
// Today's income
$todayIncome = Order::where('created_at', '>=', $todayStart)
->where('created_at', '<', time())
@ -230,10 +306,6 @@ class StatController extends Controller
->whereNotIn('status', [0, 2])
->sum('total_amount');
// Online users (active in last 10 minutes)
$onlineUsers = User::where('t', '>=', time() - 600)
->count();
// Current month income
$currentMonthIncome = Order::where('created_at', '>=', $currentMonthStart)
->where('created_at', '<', time())
@ -251,6 +323,11 @@ class StatController extends Controller
->where('created_at', '<', $currentMonthStart)
->sum('get_amount');
// Current month commission payout
$currentMonthCommissionPayout = CommissionLog::where('created_at', '>=', $currentMonthStart)
->where('created_at', '<', time())
->sum('get_amount');
// Current month new users
$currentMonthNewUsers = User::where('created_at', '>=', $currentMonthStart)
->where('created_at', '<', time())
@ -288,21 +365,60 @@ class StatController extends Controller
$userGrowth = $lastMonthNewUsers > 0 ? round(($currentMonthNewUsers - $lastMonthNewUsers) / $lastMonthNewUsers * 100, 1) : 0;
$dayIncomeGrowth = $yesterdayIncome > 0 ? round(($todayIncome - $yesterdayIncome) / $yesterdayIncome * 100, 1) : 0;
// 获取待处理工单和佣金数据
$ticketPendingTotal = Ticket::where('status', 0)->count();
$commissionPendingTotal = Order::where('commission_status', 0)
->where('invite_user_id', '!=', NULL)
->whereIn('status', [Order::STATUS_COMPLETED])
->where('commission_balance', '>', 0)
->count();
return [
'data' => [
// 收入相关
'todayIncome' => $todayIncome,
'onlineUsers' => $onlineUsers,
'dayIncomeGrowth' => $dayIncomeGrowth,
'currentMonthIncome' => $currentMonthIncome,
'lastMonthIncome' => $lastMonthIncome,
'monthIncomeGrowth' => $monthIncomeGrowth,
'lastMonthIncomeGrowth' => $lastMonthIncomeGrowth,
// 佣金相关
'currentMonthCommissionPayout' => $currentMonthCommissionPayout,
'lastMonthCommissionPayout' => $lastMonthCommissionPayout,
'commissionGrowth' => $commissionGrowth,
'commissionPendingTotal' => $commissionPendingTotal,
// 用户相关
'currentMonthNewUsers' => $currentMonthNewUsers,
'totalUsers' => $totalUsers,
'activeUsers' => $activeUsers,
'monthIncomeGrowth' => $monthIncomeGrowth,
'lastMonthIncomeGrowth' => $lastMonthIncomeGrowth,
'commissionGrowth' => $commissionGrowth,
'userGrowth' => $userGrowth
'userGrowth' => $userGrowth,
'onlineUsers' => $onlineUsers,
'onlineDevices' => $onlineDevices,
// 工单相关
'ticketPendingTotal' => $ticketPendingTotal,
// 节点相关
'onlineNodes' => $onlineNodes,
// 流量统计
'todayTraffic' => [
'upload' => $todayTraffic->upload ?? 0,
'download' => $todayTraffic->download ?? 0,
'total' => $todayTraffic->total ?? 0
],
'monthTraffic' => [
'upload' => $monthTraffic->upload ?? 0,
'download' => $monthTraffic->download ?? 0,
'total' => $monthTraffic->total ?? 0
],
'totalTraffic' => [
'upload' => $totalTraffic->upload ?? 0,
'download' => $totalTraffic->download ?? 0,
'total' => $totalTraffic->total ?? 0
]
]
];
}

View File

@ -6,11 +6,6 @@ use App\Http\Controllers\V2\Admin\PlanController;
use App\Http\Controllers\V2\Admin\Server\GroupController;
use App\Http\Controllers\V2\Admin\Server\RouteController;
use App\Http\Controllers\V2\Admin\Server\ManageController;
use App\Http\Controllers\V2\Admin\Server\TrojanController;
use App\Http\Controllers\V2\Admin\Server\VmessController;
use App\Http\Controllers\V2\Admin\Server\ShadowsocksController;
use App\Http\Controllers\V2\Admin\Server\HysteriaController;
use App\Http\Controllers\V2\Admin\Server\VlessController;
use App\Http\Controllers\V2\Admin\OrderController;
use App\Http\Controllers\V2\Admin\UserController;
use App\Http\Controllers\V2\Admin\StatController;
@ -118,15 +113,14 @@ class AdminRoute
$router->group([
'prefix' => 'stat'
], function ($router) {
$router->get('/getStat', [StatController::class, 'getStat']);
$router->get('/getOverride', [StatController::class, 'getOverride']);
$router->get('/getStats', [StatController::class, 'getStats']);
$router->get('/getServerLastRank', [StatController::class, 'getServerLastRank']);
$router->get('/getServerYesterdayRank', [StatController::class, 'getServerYesterdayRank']);
$router->get('/getOrder', [StatController::class, 'getOrder']);
$router->any('/getStatUser', [StatController::class, 'getStatUser']);
$router->get('/getRanking', [StatController::class, 'getRanking']);
$router->get('/getStatRecord', [StatController::class, 'getStatRecord']);
$router->get('/getStats', [StatController::class, 'getStats']);
$router->get('/getTrafficRank', [StatController::class, 'getTrafficRank']);
});

View File

@ -231,6 +231,9 @@ class Clash implements ProtocolInterface
private function isRegex($exp)
{
return @preg_match($exp, null) !== false;
if (empty($exp)) {
return false;
}
return @preg_match((string)$exp, '') !== false;
}
}

View File

@ -333,6 +333,9 @@ class ClashMeta implements ProtocolInterface
private function isRegex($exp)
{
return @preg_match($exp, null) !== false;
if (empty($exp)) {
return false;
}
return @preg_match($exp, '') !== false;
}
}

View File

@ -284,7 +284,10 @@ class Stash implements ProtocolInterface
private function isRegex($exp)
{
return @preg_match($exp, null) !== false;
if (empty($exp)) {
return false;
}
return @preg_match($exp, '') !== false;
}
private function isMatch($exp, $str)

View File

@ -12,6 +12,7 @@
"license": "MIT",
"require": {
"php": "^8.2",
"bacon/bacon-qr-code": "^2.0",
"doctrine/dbal": "^3.7",
"google/cloud-storage": "^1.35",
"google/recaptcha": "^1.2",

View File

@ -100,6 +100,13 @@ return [
'driver' => 'errorlog',
'level' => 'debug',
],
'deprecations' => [
'driver' => 'daily',
'path' => storage_path('logs/deprecations.log'),
'level' => 'debug',
'days' => 14,
],
],
];

View File

@ -2,6 +2,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration

View File

@ -1,10 +1,8 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
use App\Models\Plan;
return new class extends Migration {
/**
@ -39,7 +37,7 @@ return new class extends Migration {
->count();
if ($unconvertedCount > 0) {
\Log::warning("Found {$unconvertedCount} orders with unconverted period values");
Log::warning("Found {$unconvertedCount} orders with unconverted period values");
}
}
@ -55,4 +53,4 @@ return new class extends Migration {
->update(['period' => $oldPeriod]);
}
}
};
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -95,5 +95,23 @@
"Sending frequently, please try again later": "Sending frequently, please try again later",
"Current product is sold out": "Current product is sold out",
"There are too many password errors, please try again after :minute minutes.": "There are too many password errors, please try again after :minute minutes.",
"Reset failed, Please try again later": "Reset failed, Please try again later"
"Reset failed, Please try again later": "Reset failed, Please try again later",
"Subscribe": "Subscribe",
"User Information": "User Information",
"Username": "Username",
"Status": "Status",
"Active": "Active",
"Inactive": "Inactive",
"Data Used": "Data Used",
"Data Limit": "Data Limit",
"Expiration Date": "Expiration Date",
"Reset In": "Reset In",
"Days": "Days",
"Subscription Link": "Subscription Link",
"Copy": "Copy",
"Copied": "Copied",
"QR Code": "QR Code",
"Unlimited": "Unlimited",
"Device Limit": "Device Limit",
"Devices": "Devices"
}

View File

@ -95,5 +95,23 @@
"Sending frequently, please try again later": "发送频繁,请稍后再试",
"Current product is sold out": "当前商品已售罄",
"There are too many password errors, please try again after :minute minutes.": "密码错误次数过多,请 :minute 分钟后再试",
"Reset failed, Please try again later": "重置失败,请稍后再试"
"Reset failed, Please try again later": "重置失败,请稍后再试",
"Subscribe": "订阅信息",
"User Information": "用户信息",
"Username": "用户名",
"Status": "状态",
"Active": "正常",
"Inactive": "未激活",
"Data Used": "已用流量",
"Data Limit": "流量限制",
"Expiration Date": "到期时间",
"Reset In": "距离重置",
"Days": "天",
"Subscription Link": "订阅链接",
"Copy": "复制",
"Copied": "已复制",
"QR Code": "二维码",
"Unlimited": "长期有效",
"Device Limit": "设备限制",
"Devices": "台设备"
}

View File

@ -95,5 +95,23 @@
"Sending frequently, please try again later": "發送頻繁,請稍後再試",
"Current product is sold out": "當前商品已售罄",
"There are too many password errors, please try again after :minute minutes.": "密碼錯誤次數過多,請 :minute 分鐘後再試",
"Reset failed, Please try again later": "重置失敗,請稍後再試"
"Reset failed, Please try again later": "重置失敗,請稍後再試",
"Subscribe": "訂閱資訊",
"User Information": "用戶資訊",
"Username": "用戶名",
"Status": "狀態",
"Active": "正常",
"Inactive": "未啟用",
"Data Used": "已用流量",
"Data Limit": "流量限制",
"Expiration Date": "到期時間",
"Reset In": "距離重置",
"Days": "天",
"Subscription Link": "訂閱連結",
"Copy": "複製",
"Copied": "已複製",
"QR Code": "二維碼",
"Unlimited": "長期有效",
"Device Limit": "設備限制",
"Devices": "台設備"
}

View File

@ -0,0 +1,296 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ __('Subscribe') }}</title>
<style>
:root {
--bg: #ffffff;
--text: #000000;
--text-secondary: #666666;
--primary: #2196f3;
--success: #4caf50;
--danger: #f44336;
--border: #eee;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
padding: 2rem 1rem;
}
.container {
max-width: 600px;
margin: 0 auto;
}
.title {
font-size: 1.75rem;
font-weight: bold;
margin-bottom: 2rem;
}
.info-list {
display: flex;
flex-direction: column;
gap: 1.25rem;
margin-bottom: 3rem;
}
.info-item {
display: flex;
align-items: center;
font-size: 1rem;
}
.info-label {
min-width: 100px;
color: var(--text-secondary);
}
.info-value {
color: var(--text);
flex: 1;
font-weight: 500;
margin-left: 1rem;
}
.status {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
}
.status.active {
background: var(--success);
color: white;
}
.status.inactive {
background: var(--danger);
color: white;
}
.links-section {
margin-top: 2rem;
}
.links-title {
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 1rem;
color: var(--text-secondary);
}
.link-item {
position: relative;
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.link-input {
flex: 1;
width: 100%;
padding: 0.75rem;
padding-right: 4rem;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.875rem;
color: var(--text);
background: #f5f5f5;
}
.link-input:focus {
outline: none;
border-color: var(--primary);
}
.copy-btn {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
padding: 0.5rem;
border: none;
background: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.25rem;
transition: color 0.2s;
}
.copy-btn:hover {
color: var(--primary);
}
.copy-btn svg {
width: 1rem;
height: 1rem;
}
.copy-btn.copied {
color: var(--success);
}
.qr-section {
display: flex;
justify-content: center;
margin-top: 2rem;
}
.qr-section img {
width: 180px;
height: 180px;
padding: 0.75rem;
background: white;
border-radius: 4px;
}
@media (max-width: 640px) {
body {
padding: 1.5rem 1rem;
}
.title {
font-size: 1.5rem;
margin-bottom: 1.5rem;
}
.info-item {
font-size: 0.875rem;
}
.info-label {
min-width: 80px;
}
.qr-section img {
width: 160px;
height: 160px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #000000;
--text: #ffffff;
--text-secondary: #999999;
--border: #222;
}
.link-input {
background: #111;
border-color: var(--border);
color: var(--text);
}
}
</style>
</head>
<body>
<div class="container">
<h1 class="title">{{ __('User Information') }}</h1>
<div class="info-list">
<div class="info-item">
<div class="info-label">{{ __('Username') }}</div>
<div class="info-value">{{ $username }}</div>
</div>
<div class="info-item">
<div class="info-label">{{ __('Status') }}</div>
<div class="info-value">
<span class="status {{ $status }}">
{{ $status === 'active' ? __('Active') : __('Inactive') }}
</span>
</div>
</div>
<div class="info-item">
<div class="info-label">{{ __('Data Used') }}</div>
<div class="info-value">{{ $data_used }}</div>
</div>
<div class="info-item">
<div class="info-label">{{ __('Data Limit') }}</div>
<div class="info-value">{{ $data_limit }}</div>
</div>
<div class="info-item">
<div class="info-label">{{ __('Expiration Date') }}</div>
<div class="info-value">{{ $expired_date }}</div>
</div>
@if (isset($device_limit))
<div class="info-item">
<div class="info-label">{{ __('Device Limit') }}</div>
<div class="info-value">{{ $device_limit }} {{ __('Devices') }}</div>
</div>
@endif
@if ($reset_day)
<div class="info-item">
<div class="info-label">{{ __('Reset In') }}</div>
<div class="info-value">{{ $reset_day }} {{ __('Days') }}</div>
</div>
@endif
</div>
<div class="links-section">
<h2 class="links-title">{{ __('Subscription Link') }}</h2>
<div class="link-item">
<input type="text" value="{{ $subscription_url }}" readonly id="sub_url" class="link-input" onclick="this.select()">
<button class="copy-btn" onclick="copyToClipboard('sub_url')" title="{{ __('Copy') }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
<span>{{ __('Copy') }}</span>
</button>
</div>
<div class="qr-section">
<img src="data:image/svg+xml;base64,{{ $qr_code }}" alt="{{ __('QR Code') }}">
</div>
</div>
</div>
<script>
function copyToClipboard(elementId) {
const element = document.getElementById(elementId);
element.select();
document.execCommand('copy');
element.blur();
const btn = element.nextElementSibling;
const span = btn.querySelector('span');
const originalText = span.textContent;
btn.classList.add('copied');
span.textContent = '{{ __('Copied') }}';
setTimeout(() => {
btn.classList.remove('copied');
span.textContent = originalText;
}, 1000);
}
</script>
</body>
</html>

View File

@ -1,5 +1,8 @@
<?php
use Illuminate\Support\Facades\Broadcast;
/*
|--------------------------------------------------------------------------
| Broadcast Channels

View File

@ -1,6 +1,7 @@
<?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
/*
|--------------------------------------------------------------------------

View File

@ -2,6 +2,7 @@
use App\Services\ThemeService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\File;

View File

@ -1,26 +0,0 @@
<?php
use Illuminate\Http\Request;
use SwooleTW\Http\Websocket\Facades\Websocket;
/*
|--------------------------------------------------------------------------
| Websocket Routes
|--------------------------------------------------------------------------
|
| Here is where you can register websocket events for your application.
|
*/
Websocket::on('connect', function ($websocket, Request $request) {
// called while socket on connect
});
Websocket::on('disconnect', function ($websocket) {
// called while socket on disconnect
});
Websocket::on('example', function ($websocket, $data) {
$websocket->emit('message', $data);
});

View File

@ -1,42 +0,0 @@
<?php
namespace Tests;
use Illuminate\Contracts\Console\Kernel;
use PHPUnit\Runner\AfterLastTestHook;
use PHPUnit\Runner\BeforeFirstTestHook;
class Bootstrap implements BeforeFirstTestHook, AfterLastTestHook
{
/*
|--------------------------------------------------------------------------
| Bootstrap The Test Environment
|--------------------------------------------------------------------------
|
| You may specify console commands that execute once before your test is
| run. You are free to add your own additional commands or logic into
| this file as needed in order to help your test suite run quicker.
|
*/
use CreatesApplication;
public function executeBeforeFirstTest(): void
{
$console = $this->createApplication()->make(Kernel::class);
$commands = [
'config:cache',
'event:cache',
];
foreach ($commands as $command) {
$console->call($command);
}
}
public function executeAfterLastTest(): void
{
array_map('unlink', glob('bootstrap/cache/*.phpunit.php'));
}
}

View File

@ -1,22 +0,0 @@
<?php
namespace Tests;
use Illuminate\Contracts\Console\Kernel;
trait CreatesApplication
{
/**
* Creates the application.
*
* @return \Illuminate\Foundation\Application
*/
public function createApplication()
{
$app = require __DIR__ . '/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
}

View File

@ -1,21 +0,0 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*
* @return void
*/
public function testBasicTest()
{
$response = $this->get('/');
$response->assertStatus(200);
}
}

View File

@ -1,10 +0,0 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
}

View File

@ -1,19 +0,0 @@
<?php
namespace Tests\Unit;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*
* @return void
*/
public function testBasicTest()
{
$this->assertTrue(true);
}
}