feat: optimize settings management and admin functionality

- Add system log cleanup functionality with batch processing
- Optimize v2_settings table performance by unifying value storage
- Add comprehensive client support list for one-click subscription
- Fix QR code subscription links for specific node types
- Fix route addition issues in admin management panel
- Enhance admin system controller with log management APIs
This commit is contained in:
xboard
2025-06-21 12:11:27 +08:00
parent 895a870dfc
commit 272dbd2107
29 changed files with 1759 additions and 1392 deletions

View File

@@ -3,13 +3,9 @@
namespace App\Console\Commands;
use App\Models\Log;
use App\Models\Plan;
use App\Models\StatServer;
use App\Models\StatUser;
use App\Utils\Helper;
use Illuminate\Console\Command;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class ResetLog extends Command
{

View File

@@ -1,5 +1,6 @@
<?php
use App\Support\Setting;
use Illuminate\Support\Facades\App;
if (! function_exists('admin_setting')) {
/**
@@ -11,15 +12,31 @@ if (! function_exists('admin_setting')) {
*/
function admin_setting($key = null, $default = null)
{
$setting = Setting::getInstance();
if ($key === null) {
return App::make(Setting::class)->toArray();
return $setting->toArray();
}
if (is_array($key)) {
App::make(Setting::class)->save($key);
$setting->save($key);
return '';
}
$default = config('v2board.'. $key) ?? $default;
return App::make(Setting::class)->get($key) ?? $default ;
return $setting->get($key) ?? $default;
}
}
if (! function_exists('admin_settings_batch')) {
/**
* 批量获取配置参数,性能优化版本
*
* @param array $keys 配置键名数组
* @return array 返回键值对数组
*/
function admin_settings_batch(array $keys): array
{
return Setting::getInstance()->getBatch($keys);
}
}

View File

@@ -6,7 +6,6 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\ConfigSave;
use App\Protocols\Clash;
use App\Protocols\ClashMeta;
use App\Protocols\Loon;
use App\Protocols\SingBox;
use App\Protocols\Stash;
use App\Protocols\Surfboard;
@@ -17,7 +16,6 @@ use App\Services\ThemeService;
use App\Utils\Dict;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
class ConfigController extends Controller
{
@@ -84,22 +82,29 @@ class ConfigController extends Controller
return $this->success(true);
}
/**
* 获取自定义规则文件路径,如果不存在则返回默认文件路径
*
* @param string $customFile 自定义规则文件路径
* @param string $defaultFile 默认文件名
* @return string 文件名
*/
private function getRuleFile(string $customFile, string $defaultFile): string
{
return File::exists(base_path($customFile)) ? $customFile : $defaultFile;
}
public function fetch(Request $request)
{
$key = $request->input('key');
$data = [
// 构建配置数据映射
$configMappings = $this->getConfigMappings();
// 如果请求特定分组,直接返回
if ($key && isset($configMappings[$key])) {
return $this->success([$key => $configMappings[$key]]);
}
return $this->success($configMappings);
}
/**
* 获取配置映射数据
*
* @return array 配置映射数组
*/
private function getConfigMappings(): array
{
return [
'invite' => [
'invite_force' => (bool) admin_setting('invite_force', 0),
'invite_commission' => admin_setting('invite_commission', 10),
@@ -141,7 +146,6 @@ class ConfigController extends Controller
'default_remind_expire' => (bool) admin_setting('default_remind_expire', 1),
'default_remind_traffic' => (bool) admin_setting('default_remind_traffic', 1),
'subscribe_path' => admin_setting('subscribe_path', 's'),
],
'frontend' => [
'frontend_theme' => admin_setting('frontend_theme', 'Xboard'),
@@ -197,70 +201,23 @@ class ConfigController extends Controller
'password_limit_expire' => admin_setting('password_limit_expire', 60)
],
'subscribe_template' => [
'subscribe_template_singbox' => (function () {
$content = $this->getTemplateContent(
$this->getRuleFile(SingBox::CUSTOM_TEMPLATE_FILE, SingBox::DEFAULT_TEMPLATE_FILE));
return json_encode(json_decode($content), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
})(),
'subscribe_template_clash' => (string) $this->getTemplateContent(
$this->getRuleFile(Clash::CUSTOM_TEMPLATE_FILE, Clash::DEFAULT_TEMPLATE_FILE)
'subscribe_template_singbox' => $this->formatTemplateContent(
admin_setting('subscribe_template_singbox', $this->getDefaultTemplate('singbox')),
'json'
),
'subscribe_template_clashmeta' => (string) $this->getTemplateContent(
$this->getRuleFile(
ClashMeta::CUSTOM_TEMPLATE_FILE,
$this->getRuleFile(ClashMeta::CUSTOM_CLASH_TEMPLATE_FILE, ClashMeta::DEFAULT_TEMPLATE_FILE)
)
),
'subscribe_template_stash' => (string) $this->getTemplateContent(
$this->getRuleFile(
Stash::CUSTOM_TEMPLATE_FILE,
$this->getRuleFile(Stash::CUSTOM_CLASH_TEMPLATE_FILE, Stash::DEFAULT_TEMPLATE_FILE)
)
),
'subscribe_template_surge' => (string) $this->getTemplateContent(
$this->getRuleFile(Surge::CUSTOM_TEMPLATE_FILE, Surge::DEFAULT_TEMPLATE_FILE)
),
'subscribe_template_surfboard' => (string) $this->getTemplateContent(
$this->getRuleFile(Surfboard::CUSTOM_TEMPLATE_FILE, Surfboard::DEFAULT_TEMPLATE_FILE)
)
'subscribe_template_clash' => admin_setting('subscribe_template_clash', $this->getDefaultTemplate('clash')),
'subscribe_template_clashmeta' => admin_setting('subscribe_template_clashmeta', $this->getDefaultTemplate('clashmeta')),
'subscribe_template_stash' => admin_setting('subscribe_template_stash', $this->getDefaultTemplate('stash')),
'subscribe_template_surge' => admin_setting('subscribe_template_surge', $this->getDefaultTemplate('surge')),
'subscribe_template_surfboard' => admin_setting('subscribe_template_surfboard', $this->getDefaultTemplate('surfboard'))
]
];
if ($key && isset($data[$key])) {
return $this->success([
$key => $data[$key]
]);
}
;
// TODO: default should be in Dict
return $this->success($data);
}
public function save(ConfigSave $request)
{
$data = $request->validated();
// 处理特殊的模板设置字段,将其保存为文件
$templateFields = [
'subscribe_template_clash' => Clash::CUSTOM_TEMPLATE_FILE,
'subscribe_template_clashmeta' => ClashMeta::CUSTOM_TEMPLATE_FILE,
'subscribe_template_stash' => Stash::CUSTOM_TEMPLATE_FILE,
'subscribe_template_surge' => Surge::CUSTOM_TEMPLATE_FILE,
'subscribe_template_singbox' => SingBox::CUSTOM_TEMPLATE_FILE,
'subscribe_template_surfboard' => Surfboard::CUSTOM_TEMPLATE_FILE,
];
foreach ($templateFields as $field => $filename) {
if (isset($data[$field])) {
$content = $data[$field];
// 对于JSON格式的内容确保格式化正确
if ($field === 'subscribe_template_singbox' && is_array($content)) {
$content = json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$this->saveTemplateContent($filename, $content);
unset($data[$field]); // 从数据库保存列表中移除
}
}
foreach ($data as $k => $v) {
if ($k == 'frontend_theme') {
$themeService = app(ThemeService::class);
@@ -268,29 +225,86 @@ class ConfigController extends Controller
}
admin_setting([$k => $v]);
}
// \Artisan::call('horizon:terminate'); //重启队列使配置生效
return $this->success(true);
}
/**
* 保存规则模板内容到文件
* 格式化模板内容
*
* @param string $filepath 文件名
* @param string $content 文件内容
* @return bool 是否保存成功
* @param mixed $content 模板内容
* @param string $format 输出格式 (json|string)
* @return string 格式化后的内容
*/
private function saveTemplateContent(string $filepath, string $content): bool
private function formatTemplateContent(mixed $content, string $format = 'string'): string
{
$path = base_path($filepath);
try {
File::put($path, $content);
return true;
} catch (\Exception $e) {
Log::error('保存规则模板失败', [
'filepath' => $path,
'error' => $e->getMessage()
]);
return false;
return match ($format) {
'json' => match (true) {
is_array($content) => json_encode(
value: $content,
flags: JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
),
is_string($content) && str($content)->isJson() => rescue(
callback: fn() => json_encode(
value: json_decode($content, associative: true, flags: JSON_THROW_ON_ERROR),
flags: JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
),
rescue: $content,
report: false
),
default => str($content)->toString()
},
default => str($content)->toString()
};
}
/**
* 获取默认模板内容
*
* @param string $type 模板类型
* @return string 默认模板内容
*/
private function getDefaultTemplate(string $type): string
{
$fileMap = [
'singbox' => [SingBox::CUSTOM_TEMPLATE_FILE, SingBox::DEFAULT_TEMPLATE_FILE],
'clash' => [Clash::CUSTOM_TEMPLATE_FILE, Clash::DEFAULT_TEMPLATE_FILE],
'clashmeta' => [
ClashMeta::CUSTOM_TEMPLATE_FILE,
ClashMeta::CUSTOM_CLASH_TEMPLATE_FILE,
ClashMeta::DEFAULT_TEMPLATE_FILE
],
'stash' => [
Stash::CUSTOM_TEMPLATE_FILE,
Stash::CUSTOM_CLASH_TEMPLATE_FILE,
Stash::DEFAULT_TEMPLATE_FILE
],
'surge' => [Surge::CUSTOM_TEMPLATE_FILE, Surge::DEFAULT_TEMPLATE_FILE],
'surfboard' => [Surfboard::CUSTOM_TEMPLATE_FILE, Surfboard::DEFAULT_TEMPLATE_FILE],
];
if (!isset($fileMap[$type])) {
return '';
}
// 按优先级查找可用的模板文件
foreach ($fileMap[$type] as $file) {
$content = $this->getTemplateContent($file);
if (!empty($content)) {
// 对于 SingBox需要格式化 JSON
if ($type === 'singbox') {
$decoded = json_decode($content, true);
if (json_last_error() === JSON_ERROR_NONE) {
return json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
}
return $content;
}
}
return '';
}
}

View File

@@ -130,7 +130,7 @@ class SystemController extends Controller
$pageSize = $request->input('page_size') >= 10 ? $request->input('page_size') : 10;
$level = $request->input('level');
$keyword = $request->input('keyword');
$builder = LogModel::orderBy('created_at', 'DESC')
->when($level, function ($query) use ($level) {
return $query->where('level', strtoupper($level));
@@ -138,16 +138,16 @@ class SystemController extends Controller
->when($keyword, function ($query) use ($keyword) {
return $query->where(function ($q) use ($keyword) {
$q->where('data', 'like', '%' . $keyword . '%')
->orWhere('context', 'like', '%' . $keyword . '%')
->orWhere('title', 'like', '%' . $keyword . '%')
->orWhere('uri', 'like', '%' . $keyword . '%');
->orWhere('context', 'like', '%' . $keyword . '%')
->orWhere('title', 'like', '%' . $keyword . '%')
->orWhere('uri', 'like', '%' . $keyword . '%');
});
});
$total = $builder->count();
$res = $builder->forPage($current, $pageSize)
->get();
return response([
'data' => $res,
'total' => $total
@@ -174,4 +174,126 @@ class SystemController extends Controller
'page_size' => $pageSize,
]);
}
/**
* 清除系统日志
*
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function clearSystemLog(Request $request)
{
$request->validate([
'days' => 'integer|min:1|max:365',
'level' => 'string|in:info,warning,error,all',
'limit' => 'integer|min:100|max:10000'
], [
'days.required' => '请指定要清除多少天前的日志',
'days.integer' => '天数必须为整数',
'days.min' => '天数不能少于1天',
'days.max' => '天数不能超过365天',
'level.in' => '日志级别只能是info、warning、error、all',
'limit.min' => '单次清除数量不能少于100条',
'limit.max' => '单次清除数量不能超过10000条'
]);
$days = $request->input('days', 30); // 默认清除30天前的日志
$level = $request->input('level', 'all'); // 默认清除所有级别
$limit = $request->input('limit', 1000); // 默认单次清除1000条
try {
$cutoffDate = now()->subDays($days);
// 构建查询条件
$query = LogModel::where('created_at', '<', $cutoffDate->timestamp);
if ($level !== 'all') {
$query->where('level', strtoupper($level));
}
// 获取要删除的记录数量
$totalCount = $query->count();
if ($totalCount === 0) {
return $this->success([
'message' => '没有找到符合条件的日志记录',
'deleted_count' => 0,
'total_count' => $totalCount
]);
}
// 分批删除,避免单次删除过多数据
$deletedCount = 0;
$batchSize = min($limit, 1000); // 每批最多1000条
while ($deletedCount < $limit && $deletedCount < $totalCount) {
$remainingLimit = min($batchSize, $limit - $deletedCount);
$batchQuery = LogModel::where('created_at', '<', $cutoffDate->timestamp);
if ($level !== 'all') {
$batchQuery->where('level', strtoupper($level));
}
$idsToDelete = $batchQuery->limit($remainingLimit)->pluck('id');
if ($idsToDelete->isEmpty()) {
break;
}
$batchDeleted = LogModel::whereIn('id', $idsToDelete)->delete();
$deletedCount += $batchDeleted;
// 避免长时间占用数据库连接
if ($deletedCount < $limit && $deletedCount < $totalCount) {
usleep(100000); // 暂停0.1秒
}
}
return $this->success([
'message' => '日志清除完成',
'deleted_count' => $deletedCount,
'total_count' => $totalCount,
'remaining_count' => max(0, $totalCount - $deletedCount)
]);
} catch (\Exception $e) {
return $this->error('清除日志失败:' . $e->getMessage());
}
}
/**
* 获取日志清除统计信息
*
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function getLogClearStats(Request $request)
{
$days = $request->input('days', 30);
$level = $request->input('level', 'all');
try {
$cutoffDate = now()->subDays($days);
$query = LogModel::where('created_at', '<', $cutoffDate->timestamp);
if ($level !== 'all') {
$query->where('level', strtoupper($level));
}
$stats = [
'days' => $days,
'level' => $level,
'cutoff_date' => $cutoffDate->format(format: 'Y-m-d H:i:s'),
'total_logs' => LogModel::count(),
'logs_to_clear' => $query->count(),
'oldest_log' => LogModel::orderBy('created_at', 'asc')->first(),
'newest_log' => LogModel::orderBy('created_at', 'desc')->first(),
];
return $this->success($stats);
} catch (\Exception $e) {
return $this->error('获取统计信息失败:' . $e->getMessage());
}
}
}

View File

@@ -192,6 +192,8 @@ class AdminRoute
$router->get('/getQueueMasters', '\\Laravel\\Horizon\\Http\\Controllers\\MasterSupervisorController@index');
$router->get('/getSystemLog', [SystemController::class, 'getSystemLog']);
$router->get('/getHorizonFailedJobs', [SystemController::class, 'getHorizonFailedJobs']);
$router->post('/clearSystemLog', [SystemController::class, 'clearSystemLog']);
$router->get('/getLogClearStats', [SystemController::class, 'getLogClearStats']);
});
// Update

View File

@@ -9,29 +9,60 @@ class Setting extends Model
protected $table = 'v2_settings';
protected $guarded = [];
protected $casts = [
'key' => 'string',
'name' => 'string',
'value' => 'string',
];
public function getValueAttribute($value)
/**
* 获取实际内容值
*/
public function getContentValue()
{
if ($value === null) {
$rawValue = $this->attributes['value'] ?? null;
if ($rawValue === null) {
return null;
}
if (is_array($value)) {
return $value;
// 如果已经是数组,直接返回
if (is_array($rawValue)) {
return $rawValue;
}
if (is_numeric($value) && !preg_match('/[^\d.]/', $value)) {
return $value;
// 如果是数字字符串,返回原值
if (is_numeric($rawValue) && !preg_match('/[^\d.]/', $rawValue)) {
return $rawValue;
}
$decodedValue = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $decodedValue;
// 尝试解析 JSON
if (is_string($rawValue)) {
$decodedValue = json_decode($rawValue, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $decodedValue;
}
}
return $value;
return $rawValue;
}
/**
* 兼容性:保持原有的 value 访问器
*/
public function getValueAttribute($value)
{
return $this->getContentValue();
}
/**
* 创建或更新设置项
*/
public static function createOrUpdate(string $name, $value): self
{
$processedValue = is_array($value) ? json_encode($value) : $value;
return self::updateOrCreate(
['name' => $name],
['value' => $processedValue]
);
}
}

View File

@@ -18,9 +18,10 @@ class Clash extends AbstractProtocol
$user = $this->user;
$appName = admin_setting('app_name', 'XBoard');
$template = File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
// 优先从数据库配置中获取模板
$template = admin_setting('subscribe_template_clash', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE));
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE)));
$config = Yaml::parse($template);
$proxy = [];

View File

@@ -65,13 +65,13 @@ class ClashMeta extends AbstractProtocol
$user = $this->user;
$appName = admin_setting('app_name', 'XBoard');
$template = File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
$template = admin_setting('subscribe_template_clashmeta', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: (
File::exists(base_path(self::CUSTOM_CLASH_TEMPLATE_FILE))
? File::get(base_path(self::CUSTOM_CLASH_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE))
);
));
$config = Yaml::parse($template);
$proxy = [];

View File

@@ -72,11 +72,11 @@ class SingBox extends AbstractProtocol
protected function loadConfig()
{
$jsonData = File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
$jsonData = admin_setting('subscribe_template_singbox', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE));
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE)));
return json_decode($jsonData, true);
return is_array($jsonData) ? $jsonData : json_decode($jsonData, true);
}
protected function buildOutbounds()

View File

@@ -67,13 +67,13 @@ class Stash extends AbstractProtocol
$user = $this->user;
$appName = admin_setting('app_name', 'XBoard');
$template = File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
$template = admin_setting('subscribe_template_stash', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: (
File::exists(base_path(self::CUSTOM_CLASH_TEMPLATE_FILE))
? File::get(base_path(self::CUSTOM_CLASH_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE))
);
));
$config = Yaml::parse($template);
$proxy = [];

View File

@@ -52,9 +52,9 @@ class Surfboard extends AbstractProtocol
}
}
$config = File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
$config = admin_setting('subscribe_template_surfboard', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE));
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE)));
// Subscription link
$subsURL = Helper::getSubscribeUrl($user['token']);
$subsDomain = request()->header('Host');

View File

@@ -60,9 +60,9 @@ class Surge extends AbstractProtocol
}
$config = File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
$config = admin_setting('subscribe_template_surge', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE));
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE)));
// Subscription link
$subsDomain = request()->header('Host');
@@ -83,6 +83,7 @@ class Surge extends AbstractProtocol
$config = str_replace('$subscribe_info', $subscribeInfo, $config);
return response($config, 200)
->header('content-type', 'application/octet-stream')
->header('content-disposition', "attachment;filename*=UTF-8''" . rawurlencode($appName) . ".conf");
}
@@ -202,11 +203,16 @@ class Surge extends AbstractProtocol
"{$server['host']}",
"{$server['port']}",
"password={$password}",
"download-bandwidth={$protocol_settings['bandwidth']['up']}",
$protocol_settings['tls']['server_name'] ? "sni={$protocol_settings['tls']['server_name']}" : "",
// 'tfo=true',
'udp-relay=true'
];
if (data_get($protocol_settings, 'bandwidth.up')) {
$config[] = "upload-bandwidth={$protocol_settings['bandwidth']['up']}";
}
if (data_get($protocol_settings, 'bandwidth.down')) {
$config[] = "download-bandwidth={$protocol_settings['bandwidth']['down']}";
}
if (data_get($protocol_settings, 'tls.allow_insecure')) {
$config[] = !!data_get($protocol_settings, 'tls.allow_insecure') ? 'skip-cert-verify=true' : 'skip-cert-verify=false';
}

View File

@@ -5,17 +5,32 @@ namespace App\Support;
use App\Models\Setting as SettingModel;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Fluent;
class Setting
{
const CACHE_KEY = 'admin_settings';
private $cache;
public function __construct()
private static $instance = null;
private static $inMemoryCache = null;
private static $cacheLoaded = false;
private function __construct()
{
$this->cache = Cache::store('redis');
}
/**
* 获取单例实例
*/
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* 获取配置.
*
@@ -26,7 +41,28 @@ class Setting
public function get($key, $default = null)
{
$key = strtolower($key);
return Arr::get($this->fromDatabase(), $key, $default);
return Arr::get($this->getInMemoryCache(), $key, $default);
}
/**
* 获取内存缓存数据
*/
private function getInMemoryCache(): array
{
if (!self::$cacheLoaded) {
self::$inMemoryCache = $this->fromDatabase();
self::$cacheLoaded = true;
}
return self::$inMemoryCache ?? [];
}
/**
* 清除内存缓存
*/
public static function clearInMemoryCache(): void
{
self::$inMemoryCache = null;
self::$cacheLoaded = false;
}
/**
@@ -38,16 +74,16 @@ class Setting
*/
public function set(string $key, $value = null): bool
{
if (is_array($value)) {
$value = json_encode($value);
}
$key = strtolower($key);
SettingModel::updateOrCreate(['name' => $key], ['value' => $value]);
SettingModel::createOrUpdate($key, $value);
$this->cache->forget(self::CACHE_KEY);
// 清除内存缓存,下次访问时重新加载
self::clearInMemoryCache();
return true;
}
/**
* 保存配置到数据库.
*
@@ -57,8 +93,13 @@ class Setting
public function save(array $settings): bool
{
foreach ($settings as $key => $value) {
$this->set($key, $value);
$key = strtolower($key);
SettingModel::createOrUpdate($key, $value);
}
// 批量更新后清除缓存
$this->cache->forget(self::CACHE_KEY);
self::clearInMemoryCache();
return true;
}
@@ -73,6 +114,7 @@ class Setting
{
SettingModel::where('name', $key)->delete();
$this->cache->forget(self::CACHE_KEY);
self::clearInMemoryCache();
return true;
}
@@ -83,9 +125,25 @@ class Setting
public function fromDatabase(): array
{
try {
return $this->cache->rememberForever(self::CACHE_KEY, function (): array {
return array_change_key_case(SettingModel::pluck('value', 'name')->toArray(), CASE_LOWER);
// 统一从 value 字段获取所有配置
$settings = $this->cache->rememberForever(self::CACHE_KEY, function (): array {
return array_change_key_case(
SettingModel::pluck('value', 'name')->toArray(),
CASE_LOWER
);
});
// 处理JSON格式的值
foreach ($settings as $key => $value) {
if (is_string($value) && $value !== null) {
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE) {
$settings[$key] = $decoded;
}
}
}
return $settings;
} catch (\Throwable $th) {
return [];
}
@@ -98,7 +156,7 @@ class Setting
*/
public function toArray(): array
{
return $this->fromDatabase();
return $this->getInMemoryCache();
}
/**
@@ -110,12 +168,34 @@ class Setting
*/
public function update(string $key, $value): bool
{
if (is_array($value)) {
$value = json_encode($value);
return $this->set($key, $value);
}
/**
* 批量获取配置项,优化多个配置项获取的性能
*
* @param array $keys 配置键名数组,格式:['key1', 'key2' => 'default_value', ...]
* @return array 返回键值对数组
*/
public function getBatch(array $keys): array
{
$cache = $this->getInMemoryCache();
$result = [];
foreach ($keys as $index => $item) {
if (is_numeric(value: $index)) {
// 格式:['key1', 'key2']
$key = strtolower($item);
$default = config('v2board.'. $item);
$result[$item] = Arr::get($cache, $key, $default);
} else {
// 格式:['key1' => 'default_value']
$key = strtolower($index);
$default = config('v2board.'. $index) ?? $item;
$result[$index] = Arr::get($cache, $key, $default);
}
}
$key = strtolower($key);
SettingModel::updateOrCreate(['name' => $key], ['value' => $value]);
$this->cache->forget(self::CACHE_KEY);
return true;
return $result;
}
}

View File

@@ -7,6 +7,8 @@ use Illuminate\Support\Arr;
class Helper
{
private static $subscribeUrlCache = null;
public static function uuidToBase64($uuid, $length)
{
return base64_encode(substr($uuid, 0, $length));
@@ -122,13 +124,29 @@ class Helper
public static function getSubscribeUrl(string $token, $subscribeUrl = null)
{
$path = route('client.subscribe', ['token' => $token], false);
if (!$subscribeUrl) {
$subscribeUrls = explode(',', (string)admin_setting('subscribe_url', ''));
$subscribeUrl = Arr::random($subscribeUrls);
$subscribeUrl = self::replaceByPattern($subscribeUrl);
// 如果已提供订阅URL直接处理并返回
if ($subscribeUrl) {
$finalUrl = rtrim($subscribeUrl, '/') . $path;
return HookManager::filter('subscribe.url', $finalUrl);
}
$finalUrl = $subscribeUrl ? rtrim($subscribeUrl, '/') . $path : url($path);
// 使用静态缓存避免重复查询配置
if (self::$subscribeUrlCache === null) {
$urlString = (string)admin_setting('subscribe_url', '');
self::$subscribeUrlCache = $urlString ? explode(',', $urlString) : [];
}
// 如果没有配置订阅URL使用默认URL
if (empty(self::$subscribeUrlCache)) {
return HookManager::filter('subscribe.url', url($path));
}
// 高效随机选择URL并处理
$randomIndex = array_rand(self::$subscribeUrlCache);
$selectedUrl = self::replaceByPattern(self::$subscribeUrlCache[$randomIndex]);
$finalUrl = rtrim($selectedUrl, '/') . $path;
return HookManager::filter('subscribe.url', $finalUrl);
}
@@ -184,5 +202,4 @@ class Helper
$revert = array('%21'=>'!', '%2A'=>'*', '%27'=>"'", '%28'=>'(', '%29'=>')');
return strtr(rawurlencode($str), $revert);
}
}

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
class OptimizeV2SettingsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('v2_settings', function (Blueprint $table) {
// 将 value 字段改为 MEDIUMTEXT支持最大16MB内容
$table->mediumText('value')->nullable()->change();
// 添加优化索引
$table->index('name', 'idx_setting_name');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('v2_settings', function (Blueprint $table) {
$table->string('value')->nullable()->change();
$table->dropIndex('idx_setting_name');
});
}
}

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

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
<link rel="icon" type="image/png" href="/images/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shadcn Admin</title>
<meta
name="description"
content="Admin Dashboard UI built with Shadcn and Vite."
/>
<script>
window.settings = {
base_url: 'http://127.0.0.1:8000',
title: 'Xboard',
version: '1.0.0',
logo: 'https://xboard.io/i6mages/logo.png',
secure_path: '/6a416b7a',
}
</script>
<script src="./locales/en-US.js"></script>
<script src="./locales/zh-CN.js"></script>
<script src="./locales/ko-KR.js"></script>
<script type="module" crossorigin src="./assets/index.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index.css">
<link rel="stylesheet" crossorigin href="./assets/vendor.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -1079,7 +1079,7 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
"uri": "URI",
"requestData": "Request Data",
"exception": "Exception",
"totalLogs": "Total logs: {{count}}",
"totalLogs": "Total logs",
"tabs": {
"all": "All",
"info": "Info",

View File

@@ -1095,7 +1095,7 @@ window.XBOARD_TRANSLATIONS['ko-KR'] = {
"uri": "URI",
"requestData": "요청 데이터",
"exception": "예외",
"totalLogs": "총 로그 수: {{count}}",
"totalLogs": "총 로그 수",
"tabs": {
"all": "전체",
"info": "정보",

View File

@@ -1077,7 +1077,7 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
"uri": "URI",
"requestData": "请求数据",
"exception": "异常信息",
"totalLogs": "总日志数{{count}}",
"totalLogs": "总日志数",
"tabs": {
"all": "全部",
"info": "信息",

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View File

@@ -1,35 +1,33 @@
{
"name": "Xboard",
"description": "Xboard",
"version": "1.0.0",
"images": [
"https://raw.githubusercontent.com/cedar2025/Xboard/master/docs/images/user.png"
],
"configs": [
{
"label": "主题色",
"placeholder": "请选择主题颜色",
"field_name": "theme_color",
"field_type": "select",
"select_options": {
"default": "默认(绿色)",
"blue": "蓝色",
"black": "黑色",
"darkblue": "暗蓝色"
},
"default_value": "default"
},
{
"label": "背景",
"placeholder": "请输入背景图片URL",
"field_name": "background_url",
"field_type": "input"
},
{
"label": "自定义页脚HTML",
"placeholder": "可以实现客服JS代码的加入等",
"field_name": "custom_html",
"field_type": "textarea"
}
]
"name": "Xboard",
"description": "Xboard",
"version": "1.0.0",
"images": "",
"configs": [
{
"label": "主题色",
"placeholder": "请选择主题颜色",
"field_name": "theme_color",
"field_type": "select",
"select_options": {
"default": "默认(绿色)",
"blue": "蓝色",
"black": "黑色",
"darkblue": "蓝色"
},
"default_value": "default"
},
{
"label": "背景",
"placeholder": "请输入背景图片URL",
"field_name": "background_url",
"field_type": "input"
},
{
"label": "自定义页脚HTML",
"placeholder": "可以实现客服JS代码的加入等",
"field_name": "custom_html",
"field_type": "textarea"
}
]
}

19
theme/Xboard/env.example.js vendored Normal file
View File

@@ -0,0 +1,19 @@
// API地址
window.routerBase = 'http://127.0.0.1:8000/'
window.settings = {
// 站点名称
title: 'Xboard',
// 站点描述
description: 'Xboard',
assets_path: '/assets',
// 主题色
theme: {
color: 'default', //可选default、blue、black、、darkblue
},
// 版本号
version: '0.1.1-dev',
// 登陆背景
background_url: '',
// 站点LOGO
logo: '',
}

18
theme/Xboard/env.js vendored Normal file
View File

@@ -0,0 +1,18 @@
window.routerBase = 'http://127.0.0.1:8000/'
window.settings = {
// 站点名称
title: 'Xboard',
// 主题色
theme: {
color: 'anyway', //可选default、blue、black、、darkblue
},
// 站点描述
description: 'Xboard',
assets_path: '/assets',
// 版本号
version: '0.1.1-dev',
// 登陆背景
background_url: '',
// 站点LOGO
logo: '',
}

1
theme/Xboard/index.html Normal file
View File

@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no"><title>Xboard</title><script type="module" crossorigin src="/assets/umi.js"></script></head><body><script src="./env.js"></script><div id="app"></div></body></html>