first commit

This commit is contained in:
liu
2026-07-13 15:23:29 +08:00
commit 50885a98c8
473 changed files with 33772 additions and 0 deletions
@@ -0,0 +1,45 @@
<?php
namespace Modules\SystemTool\Ai\Agents;
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Messages\Message;
use Laravel\Ai\Promptable;
use Stringable;
class TestAgent implements Agent, Conversational, HasTools
{
use Promptable, RemembersConversations;
/**
* Get the instructions that the agent should follow.
*/
public function instructions(): Stringable|string
{
return '';
}
/**
* Get the list of messages comprising the conversation so far.
*
* @return Message[]
*/
public function messages(): iterable
{
return [];
}
/**
* Get the tools available to the agent.
*
* @return Tool[]
*/
public function tools(): iterable
{
return [];
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Modules\SystemTool\Ai\Boots;
use Laravel\Boost\Contracts\SupportsGuidelines;
use Laravel\Boost\Contracts\SupportsMcp;
use Laravel\Boost\Contracts\SupportsSkills;
use Laravel\Boost\Install\Agents\Agent;
use Laravel\Boost\Install\Enums\McpInstallationStrategy;
use Laravel\Boost\Install\Enums\Platform;
class Reasonix extends Agent implements SupportsGuidelines, SupportsMcp, SupportsSkills
{
public function name(): string
{
return 'reasonix';
}
public function displayName(): string
{
return 'Reasonix';
}
public function systemDetectionConfig(Platform $platform): array
{
return match ($platform) {
Platform::Darwin, Platform::Linux => [
'command' => 'command -v reasonix',
],
Platform::Windows => [
'command' => 'cmd /c where reasonix 2>nul',
],
};
}
public function projectDetectionConfig(): array
{
return [
'paths' => ['.reasonix'],
'files' => ['REASONIX.md'],
];
}
public function mcpInstallationStrategy(): McpInstallationStrategy
{
return McpInstallationStrategy::FILE;
}
public function mcpConfigPath(): string
{
return config('boost.agents.reasonix.mcp_config_path', '.mcp.json');
}
public function guidelinesPath(): string
{
return config('boost.agents.reasonix.guidelines_path', 'REASONIX.md');
}
public function skillsPath(): string
{
return config('boost.agents.reasonix.skills_path', '.reasonix/skills');
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Modules\SystemTool\Attributes;
use Attribute;
use Modules\SystemTool\Enum\ESettingType;
/**
* 在 SettingsDefinition 子类上声明配置项
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
class Setting
{
public function __construct(
public string $config,
public ESettingType $type = ESettingType::String,
public ?string $description = null,
) {}
}
@@ -0,0 +1,346 @@
<?php
namespace Modules\SystemTool\Base;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Modules\SystemTool\Attributes\Setting;
use Modules\SystemTool\Enum\ESettingType;
use ReflectionClass;
/**
* 设置定义基类
*/
abstract class SettingsDefinition
{
/**
* 聚合缓存键名 — 全局中间件 LoadAppSettingsMiddleware 使用
* 所有设置一次性缓存在此键中,set() 写入时自动清除。
*/
const AGGREGATE_CACHE_KEY = 'app-settings-all';
/**
* 解析后的定义缓存(类名 → 定义数组)
*
* @var array<string, array<string, array{config: string, type: string, description: string|null}>>
*/
protected static array $definitions = [];
/**
* 从 #[Setting] Attribute 解析设置定义
*
* @return array<string, array{config: string, type: string, description: string|null}>
*/
public static function getDefinition(): array
{
$class = static::class;
if (! isset(self::$definitions[$class])) {
$reflection = new ReflectionClass($class);
$attributes = $reflection->getAttributes(Setting::class);
$definition = [];
foreach ($attributes as $attribute) {
/** @var Setting $instance */
$instance = $attribute->newInstance();
$definition[$instance->config] = [
'config' => $instance->config,
'type' => $instance->type->value,
'description' => $instance->description,
];
}
self::$definitions[$class] = $definition;
}
return self::$definitions[$class];
}
/**
* 判断某个 key 是否在本类的定义中
*/
public static function hasDefinitionKey(string $key): bool
{
return array_key_exists($key, static::getDefinition());
}
/**
* 获取数据库表名
*/
public static function getTableName(): string
{
return config('app_settings.table', 'sys_app_settings');
}
/**
* 将本类的所有配置项定义同步到 应用配置 表
*
* - 新 key 插入,值来自当前 config() 的默认值
* - 已有 key 只更新 description,不覆盖值
* - 部署时在 migration 中调用
*/
public static function init(): void
{
$definition = static::getDefinition();
$table = static::getTableName();
foreach ($definition as $key => $setting) {
$exists = DB::table($table)->where('key', $key)->exists();
if (! $exists) {
$value = static::getConfigValue($key);
static::set($key, $value);
} else {
DB::table($table)
->where('key', $key)
->update([
'description' => $setting['description'] ?? null,
'updated_at' => now(),
]);
}
}
}
/**
* 读取配置值
*
* @throws InvalidArgumentException 当 key 未定义时
*/
public static function get(string $key, mixed $default = null): mixed
{
if (! static::hasDefinitionKey($key)) {
throw new InvalidArgumentException("Setting key '{$key}' is not defined in " . static::class);
}
return static::getCacheValue($key, function () use ($key, $default) {
return static::getDBValue($key, function () use ($key, $default) {
return static::setCacheValue(
key: $key,
value: static::getConfigValue($key, $default),
);
});
});
}
/**
* 写入配置值 → DB + Cache
*
* @throws InvalidArgumentException 当 key 未定义或类型不匹配时
*/
public static function set(string $key, mixed $value): void
{
if (! static::hasDefinitionKey($key)) {
throw new InvalidArgumentException("Setting key '{$key}' is not defined in " . static::class);
}
static::setDBValue($key, $value);
static::setCacheValue($key, $value);
// 当前请求立即生效(中间件在下一个请求才会重新加载)
config([$key => $value]);
// 清除聚合缓存,使下次请求通过中间件重新从 DB 加载
Cache::forget(self::AGGREGATE_CACHE_KEY);
}
/**
* 从 Laravel config 读取(最终 fallback
*/
public static function getConfigValue(string $key, mixed $default = null): mixed
{
return config($key, $default);
}
/**
* 从缓存读取
*
* @param mixed $default 默认值或闭包 (fn() => mixed)
*/
public static function getCacheValue(string $key, mixed $default = null): mixed
{
if ($default instanceof \Closure) {
return Cache::rememberForever('app-setting-' . $key, $default);
}
return Cache::get('app-setting-' . $key, $default);
}
/**
* 写入缓存
*/
public static function setCacheValue(string $key, mixed $value, ?int $ttl = null): mixed
{
if ($ttl !== null) {
Cache::put('app-setting-' . $key, $value, $ttl);
} else {
Cache::forever('app-setting-' . $key, $value);
}
return $value;
}
/**
* 将本类所有配置项从 DB 加载到 Laravel config() 运行时
*
* 调用后 config('filesystems.default') 等可直接返回 DB 值。
* 在 getConfig() 批量读取场景下避免逐个 get() 的开销。
*/
public static function reloadIntoConfig(): void
{
$table = static::getTableName();
$rows = DB::table($table)->whereIn('key', array_keys(static::getDefinition()))->get();
foreach ($rows as $row) {
$value = match ((int) $row->type) {
ESettingType::String->value => $row->s,
ESettingType::Bool->value => is_null($row->n) ? null : (bool) $row->n,
ESettingType::Number->value => is_null($row->n) ? null : (int) $row->n,
ESettingType::Array->value => is_null($row->e) ? null : json_decode($row->e, true),
ESettingType::Object->value => is_null($row->e) ? null : unserialize(base64_decode($row->e)),
ESettingType::EncryptedString->value => is_null($row->e) ? null : \Illuminate\Support\Facades\Crypt::decrypt(base64_decode($row->e)),
default => $row->s ?? null,
};
config([$row->key => $value]);
}
}
/**
* 清除某个 key 的缓存
*/
public static function forgetCache(string $key): void
{
Cache::forget('app-setting-' . $key);
// 同时清除聚合缓存,保持一致性
Cache::forget(self::AGGREGATE_CACHE_KEY);
}
/**
* 从数据库读取(按类型自动转换)
*
* @param mixed $default 默认值或闭包
*/
public static function getDBValue(string $key, mixed $default = null): mixed
{
$rec = DB::table(static::getTableName())->where('key', $key)->first();
if (! $rec) {
return $default instanceof \Closure ? $default() : value($default);
}
return match ((int) $rec->type) {
ESettingType::String->value => $rec->s,
ESettingType::Bool->value => is_null($rec->n) ? null : (bool) $rec->n,
ESettingType::Number->value => is_null($rec->n) ? null : (int) $rec->n,
ESettingType::Array->value => is_null($rec->e) ? null : json_decode($rec->e, true),
ESettingType::Object->value => is_null($rec->e) ? null : unserialize(base64_decode($rec->e)),
ESettingType::EncryptedString->value => is_null($rec->e) ? null : Crypt::decrypt(base64_decode($rec->e)),
default => $rec->s ?? $rec->value ?? null,
};
}
/**
* 写入数据库(按类型选择列)
*
* @throws InvalidArgumentException
*/
public static function setDBValue(string $key, mixed $value): void
{
$definition = static::getDefinition();
if (! isset($definition[$key])) {
throw new InvalidArgumentException("Setting key '{$key}' is not defined in " . static::class);
}
$type = $definition[$key]['type'];
$description = $definition[$key]['description'] ?? null;
$table = static::getTableName();
$data = [
'key' => $key,
'type' => $type,
'description' => $description,
'updated_at' => now(),
];
switch ((int) $type) {
case ESettingType::String->value:
if (! is_string($value) && ! is_null($value)) {
throw new InvalidArgumentException("Value for '{$key}' must be a string.");
}
$data['s'] = $value;
$data['n'] = null;
$data['e'] = null;
break;
case ESettingType::Bool->value:
if (! is_bool($value) && ! is_null($value)) {
throw new InvalidArgumentException("Value for '{$key}' must be a boolean.");
}
$data['n'] = is_null($value) ? null : (int) $value;
$data['s'] = null;
$data['e'] = null;
break;
case ESettingType::Number->value:
if (! is_numeric($value) && ! is_null($value)) {
throw new InvalidArgumentException("Value for '{$key}' must be a number.");
}
$data['n'] = $value;
$data['s'] = null;
$data['e'] = null;
break;
case ESettingType::Array->value:
if (! is_array($value) && ! is_null($value)) {
throw new InvalidArgumentException("Value for '{$key}' must be an array.");
}
$data['e'] = is_null($value) ? null : json_encode($value, JSON_UNESCAPED_UNICODE);
$data['s'] = null;
$data['n'] = null;
break;
case ESettingType::Object->value:
if (! is_object($value) && ! is_null($value)) {
throw new InvalidArgumentException("Value for '{$key}' must be an object.");
}
$data['e'] = is_null($value) ? null : base64_encode(serialize($value));
$data['s'] = null;
$data['n'] = null;
break;
case ESettingType::EncryptedString->value:
if (! is_string($value) && ! is_null($value)) {
throw new InvalidArgumentException("Value for '{$key}' must be a string.");
}
$data['e'] = is_null($value) ? null : base64_encode(Crypt::encrypt($value));
$data['s'] = null;
$data['n'] = null;
break;
default:
throw new InvalidArgumentException("Unknown setting type '{$type}' for key '{$key}'.");
}
$exists = DB::table($table)->where('key', $key)->exists();
if ($exists) {
DB::table($table)->where('key', $key)->update($data);
} else {
$data['created_at'] = now();
DB::table($table)->insert($data);
}
}
/**
* 从数据库删除某个配置项
*/
public static function deleteDBValue(string $key): void
{
DB::table(static::getTableName())->where('key', $key)->delete();
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Modules\SystemTool\Enum;
/**
* 设置类型枚举
*
* 对应 应用设置 表的 type 列,决定值存储在 s / n / e 哪个字段。
*/
enum ESettingType: int
{
case String = 10;
case Bool = 15;
case Number = 20;
case Array = 30;
case Object = 40;
case EncryptedString = 50;
/**
* 是否为字符串类型
*/
public function isString(): bool
{
return $this === self::String;
}
/**
* 是否为数字/布尔类型(存储在 n 列)
*/
public function isNumeric(): bool
{
return in_array($this, [self::Bool, self::Number], true);
}
/**
* 是否为扩展类型(存储在 e 列)
*/
public function isExtended(): bool
{
return in_array($this, [self::Array, self::Object, self::EncryptedString], true);
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace Modules\SystemTool\Enum;
/**
* 枚举类:文件类型
* Class FileType
*/
enum FileType: int
{
// 图片
case IMAGE = 10;
// 音频
case AUDIO = 20;
// 视频
case VIDEO = 30;
// 压缩包
case ZIP = 40;
// 文档
case DOCUMENT = 50;
// 未知文件
case ANNEX = 99;
/**
* 获取类型名称
*/
public function name(): string
{
return match ($this) {
self::IMAGE => __('system.file.image'),
self::AUDIO => __('system.file.audio'),
self::VIDEO => __('system.file.video'),
self::ZIP => __('system.file.zip'),
self::DOCUMENT => __('system.file.document'),
self::ANNEX => __('system.file.annex'),
};
}
/**
* 获取预览地址
*/
public function previewPath(): string
{
return match ($this) {
self::IMAGE => 'static/image.svg',
self::AUDIO => 'static/audio.svg',
self::VIDEO => 'static/video.svg',
self::ZIP => 'static/zip.svg',
self::DOCUMENT => 'static/document.svg',
self::ANNEX => 'static/annex.svg',
};
}
/**
* 根据扩展名推断文件类型
*/
public static function guessFromExtension(string $extension): self
{
$extension = strtolower($extension);
foreach (self::cases() as $case) {
if ($case === self::ANNEX) continue; // 跳过 OTHER
if (in_array($extension, $case->fileExt())) {
return $case;
}
}
return self::ANNEX;
}
/**
* 文件扩展名
*/
public function fileExt(): array|string
{
return match ($this) {
self::IMAGE => ['jpg', 'jpeg', 'png', 'bmp', 'gif', 'avif', 'webp', 'svg', 'ico'],
self::AUDIO => ['mp3', 'wma', 'wav', 'ape', 'flac', 'ogg', 'aac'],
self::VIDEO => ['mp4', 'mov', 'wmv', 'flv', 'avl', 'webm', 'mkv'],
self::DOCUMENT => ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'csv'],
self::ZIP => ['zip', 'rar', '7z', 'tar', 'gz'],
self::ANNEX => '*',
};
}
}
+161
View File
@@ -0,0 +1,161 @@
<?php
namespace Modules\SystemTool\Enum;
/**
* 系统设置类型枚举
*/
enum SiteConfigType: string
{
// 前端表单组件类型(新版,与前端保持一致)
case INPUT = 'Input'; // 输入框
case TEXTAREA = 'TextArea'; // 文本域
case INPUT_NUMBER = 'InputNumber'; // 数字输入框
case SWITCH = 'Switch'; // 开关
case RADIO = 'Radio'; // 单选框
case CHECKBOX = 'Checkbox'; // 复选框
/**
* 从字符串创建枚举实例(宽松匹配)
*/
public static function fromString(string $type): ?self
{
return self::tryFrom($type);
}
/**
* 根据类型自动转换值
*
* @param mixed $value 原始值
* @return mixed 转换后的值
*/
public function castValue(mixed $value): mixed
{
// 如果值为 null,直接返回 null
if ($value === null) {
return null;
}
return match($this) {
// 数字类型:转换为整数或浮点数
self::INPUT_NUMBER => $this->castToNumber($value),
// 布尔类型:转换为布尔值
self::SWITCH => $this->castToBoolean($value),
// 数组类型:转换为数组
self::CHECKBOX => $this->castToArray($value),
// 默认:保持字符串(去除首尾空白)
self::INPUT, self::TEXTAREA, self::RADIO => $value,
};
}
/**
* 转换为数字(整数或浮点数)
*/
private function castToNumber(mixed $value): int|float|null
{
// 如果是数字字符串或数字
if (is_numeric($value)) {
// 如果包含小数点,转换为浮点数,否则转换为整数
return str_contains((string)$value, '.')
? (float)$value
: (int)$value;
}
// 如果是布尔值
if (is_bool($value)) {
return (int)$value;
}
// 其他情况返回 null 或 0?这里返回 null 表示无效转换
return null;
}
/**
* 转换为布尔值
*/
private function castToBoolean(mixed $value): bool
{
// 如果是字符串
if (is_string($value)) {
$lowerValue = strtolower(trim($value));
// 检查是否为 true 相关的字符串
if (in_array($lowerValue, ['true', '1', 'on', 'yes', 'y'], true)) {
return true;
}
// 检查是否为 false 相关的字符串
if (in_array($lowerValue, ['false', '0', 'off', 'no', 'n', ''], true)) {
return false;
}
// 默认转换为布尔值
return (bool)$value;
}
// 如果是数字
if (is_numeric($value)) {
return (bool)$value;
}
// 其他类型直接转换为布尔值
return (bool)$value;
}
/**
* 转换为数组
*/
private function castToArray(mixed $value): array
{
// 如果已经是数组,直接返回
if (is_array($value)) {
return $value;
}
// 如果是 JSON 字符串
if (is_string($value)) {
$trimmed = trim($value);
// 检查是否为 JSON 数组格式
if ((str_starts_with($trimmed, '[') && str_ends_with($trimmed, ']')) ||
(str_starts_with($trimmed, '{') && str_ends_with($trimmed, '}'))) {
$decoded = json_decode($trimmed, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
return $decoded;
}
}
// 尝试按逗号分割(支持中文逗号和英文逗号)
if (str_contains($trimmed, ',') || str_contains($trimmed, '')) {
$separator = str_contains($trimmed, '') ? '' : ',';
return array_map('trim', explode($separator, $trimmed));
}
// 单个值转为数组
return [$trimmed];
}
// 其他类型转为数组
return (array)$value;
}
/**
* 转换为字符串
*/
private function castToString(mixed $value): string
{
if (is_array($value)) {
// 数组转为 JSON 字符串
return json_encode($value, JSON_UNESCAPED_UNICODE);
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if ($value === null) {
return '';
}
return trim((string)$value);
}
}
@@ -0,0 +1,183 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use Illuminate\Http\Request;
use Laravel\Ai\Enums\Lab;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Artisan;
use Laravel\Ai\Responses\AgentResponse;
use Laravel\Ai\Responses\StreamableAgentResponse;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Ai\Agents\TestAgent;
use Modules\SystemTool\Settings\AiSettings;
/**
* AI 服务配置管理
*/
#[RequestAttribute('/system/ai', 'system.ai')]
class SysAiController extends BaseController
{
/** 获取支持的AI */
#[GetRoute('/list', 'list')]
public function index(): JsonResponse
{
$default = [Lab::Anthropic, Lab::OpenAI, Lab::Gemini, Lab::Azure, Lab::Bedrock, Lab::Groq, Lab::xAI, Lab::DeepSeek, Lab::Mistral, Lab::Ollama, Lab::OpenRouter ];
return $this->success(compact('default'));
}
/**
* 获取 AI 配置(从 DB 加载已保存的值,fallback 到 config 文件)
*/
#[GetRoute('/config', 'config')]
public function getConfig(): JsonResponse
{
$ai = config('ai');
return $this->success([
'default' => $ai['default'] ?? 'openai',
'providers' => $ai['providers'] ?? [],
]);
}
/**
* 保存 AI 配置到数据库
*/
#[PostRoute('/save', 'save')]
public function saveConfig(): JsonResponse
{
$data = request()->all();
try {
// 默认驱动
AiSettings::set('ai.default', $data['default'] ?? 'openai');
// 各供应商配置
$providers = $data['providers'] ?? [];
// Anthropic
if (isset($providers['anthropic'])) {
$p = $providers['anthropic'];
AiSettings::set('ai.providers.anthropic.key', $p['key'] ?? '');
if (isset($p['url'])) {
AiSettings::set('ai.providers.anthropic.url', $p['url']);
}
}
// Azure
if (isset($providers['azure'])) {
$p = $providers['azure'];
AiSettings::set('ai.providers.azure.key', $p['key'] ?? '');
AiSettings::set('ai.providers.azure.url', $p['url'] ?? '');
AiSettings::set('ai.providers.azure.api_version', $p['api_version'] ?? '2025-04-01-preview');
AiSettings::set('ai.providers.azure.deployment', $p['deployment'] ?? 'gpt-4o');
AiSettings::set('ai.providers.azure.embedding_deployment', $p['embedding_deployment'] ?? 'text-embedding-3-small');
AiSettings::set('ai.providers.azure.image_deployment', $p['image_deployment'] ?? 'gpt-image-1');
}
// Bedrock
if (isset($providers['bedrock'])) {
$p = $providers['bedrock'];
AiSettings::set('ai.providers.bedrock.region', $p['region'] ?? 'us-east-1');
AiSettings::set('ai.providers.bedrock.key', $p['key'] ?? '');
AiSettings::set('ai.providers.bedrock.access_key_id', $p['access_key_id'] ?? '');
AiSettings::set('ai.providers.bedrock.secret_access_key', $p['secret_access_key'] ?? '');
AiSettings::set('ai.providers.bedrock.session_token', $p['session_token'] ?? '');
}
// Cohere
if (isset($providers['cohere'])) {
AiSettings::set('ai.providers.cohere.key', $providers['cohere']['key'] ?? '');
}
// DeepSeek
if (isset($providers['deepseek'])) {
AiSettings::set('ai.providers.deepseek.key', $providers['deepseek']['key'] ?? '');
}
// ElevenLabs
if (isset($providers['eleven'])) {
AiSettings::set('ai.providers.eleven.key', $providers['eleven']['key'] ?? '');
}
// Gemini
if (isset($providers['gemini'])) {
$p = $providers['gemini'];
AiSettings::set('ai.providers.gemini.key', $p['key'] ?? '');
if (isset($p['url'])) {
AiSettings::set('ai.providers.gemini.url', $p['url']);
}
}
// Groq
if (isset($providers['groq'])) {
AiSettings::set('ai.providers.groq.key', $providers['groq']['key'] ?? '');
}
// Jina
if (isset($providers['jina'])) {
AiSettings::set('ai.providers.jina.key', $providers['jina']['key'] ?? '');
}
// Mistral
if (isset($providers['mistral'])) {
AiSettings::set('ai.providers.mistral.key', $providers['mistral']['key'] ?? '');
}
// Ollama
if (isset($providers['ollama'])) {
$p = $providers['ollama'];
AiSettings::set('ai.providers.ollama.key', $p['key'] ?? '');
AiSettings::set('ai.providers.ollama.url', $p['url'] ?? 'http://localhost:11434');
}
// OpenAI
if (isset($providers['openai'])) {
$p = $providers['openai'];
AiSettings::set('ai.providers.openai.key', $p['key'] ?? '');
if (isset($p['url'])) {
AiSettings::set('ai.providers.openai.url', $p['url']);
}
}
// OpenRouter
if (isset($providers['openrouter'])) {
AiSettings::set('ai.providers.openrouter.key', $providers['openrouter']['key'] ?? '');
}
// VoyageAI
if (isset($providers['voyageai'])) {
AiSettings::set('ai.providers.voyageai.key', $providers['voyageai']['key'] ?? '');
}
// xAI
if (isset($providers['xai'])) {
AiSettings::set('ai.providers.xai.key', $providers['xai']['key'] ?? '');
}
// 清除 Laravel 配置缓存
Artisan::call('config:clear');
return $this->success('保存成功');
} catch (\Throwable $e) {
return $this->error('保存失败:' . $e->getMessage());
}
}
/**
* 测试 AI 供应商连接
*/
#[PostRoute('/test', 'test')]
public function testConnection(Request $request): StreamableAgentResponse | JsonResponse
{
try {
$agent = TestAgent::make()->forUser($request->user());
return $agent->stream('Hello, Who are you?');
} catch (\Throwable $e) {
return $this->error('连接测试失败: ' . $e->getMessage());
}
}
}
@@ -0,0 +1,85 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Http\Requests\SysCarouselFormRequest;
use Modules\SystemTool\Models\SysCarouselModel;
/**
* 首页轮播图管理
*/
#[RequestAttribute('/system/carousel', 'system.carousel')]
class SysCarouselController extends BaseController
{
protected array $searchField = [
'status' => '=',
];
protected array $quickSearchField = ['title'];
/** 查询轮播图列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$query = SysCarouselModel::query();
$data = $this->buildSearch($request->all(), $query)
->orderBy('sort', 'desc')
->orderBy('id', 'desc')
->paginate($request->input('pageSize', 10))
->toArray();
return $this->success($data);
}
/** 创建轮播图 */
#[PostRoute(authorize: 'create')]
public function create(SysCarouselFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysCarouselModel::create($validated);
if (empty($model)) {
return $this->error();
}
return $this->success();
}
/** 编辑轮播图 */
#[PutRoute(
route: '/{id}',
authorize: 'update',
where: ['id' => '[0-9]+']
)]
public function update(int $id, SysCarouselFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysCarouselModel::find($id);
if (empty($model)) {
return $this->error('轮播图不存在');
}
$model->update($validated);
return $this->success();
}
/** 删除轮播图 */
#[DeleteRoute(
route: '/{id}',
authorize: 'delete',
where: ['id' => '[0-9]+']
)]
public function delete(int $id): JsonResponse
{
$model = SysCarouselModel::find($id);
if (empty($model)) {
return $this->error('轮播图不存在');
}
$model->delete();
return $this->success();
}
}
@@ -0,0 +1,97 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use App\Exceptions\RepositoryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Http\Requests\SysDictFormRequest;
use Modules\SystemTool\Models\SysDictModel;
/**
* 字典管理
*/
#[RequestAttribute('/system/dict/list', 'system.dict.list')]
class SysDictController extends BaseController
{
protected array $quickSearchField = ['name', 'code', 'describe'];
protected array $searchField = [
'status' => '='
];
/** 查询字典列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$query = SysDictModel::query();
$data = $this->buildSearch($params, $query)
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 创建字典 */
#[PostRoute(authorize: 'create')]
public function create(SysDictFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysDictModel::create($validated);
if (empty($model)) {
return $this->error();
}
return $this->success();
}
/** 编辑字典 */
#[PutRoute(
route: '/{id}',
authorize: 'update',
where: ['id' => '[0-9]+']
)]
public function update(int $id, SysDictFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysDictModel::find($id);
if (empty($model)) {
return $this->error();
}
$model->update($validated);
return $this->success();
}
/** 删除字典 */
#[DeleteRoute(
route: '/{id}',
authorize: 'delete',
where: ['id' => '[0-9]+']
)]
public function delete(int $id): JsonResponse
{
$model = SysDictModel::find($id);
if (empty($model)) {
throw new RepositoryException('字典不存在');
}
$count = $model->dictItems()->count();
if ($count > 0) {
throw new RepositoryException('字典包含子项,请先删除子项!');
}
$model->delete();
return $this->success();
}
/** 获取所有字典数据 */
#[GetRoute('/all', false)]
public function all(): JsonResponse
{
$data = SysDictModel::getAllDictWithItems();
return $this->success($data);
}
}
@@ -0,0 +1,87 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Http\Requests\SysDictItemFormRequest;
use Modules\SystemTool\Models\SysDictItemModel;
/**
* 字典项控制器
*/
#[RequestAttribute('/system/dict/item', 'system.dict.item')]
class SysDictItemController extends BaseController
{
protected array $quickSearchField = ['label', 'value'];
protected array $searchField = [
'dict_id' => '=',
'status' => '='
];
public function __construct() {}
/** 查询字典项列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$query = SysDictItemModel::query();
$data = $this->buildSearch($params, $query)
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 创建字典项 */
#[PostRoute(authorize: 'create')]
public function create(SysDictItemFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysDictItemModel::create($validated);
if (empty($model)) {
return $this->error();
}
return $this->success();
}
/** 编辑字典项 */
#[PutRoute(
route: '/{id}',
authorize: 'update',
where: ['id' => '[0-9]+']
)]
public function update(int $id, SysDictItemFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysDictItemModel::find($id);
if (empty($model)) {
return $this->error();
}
$model->update($validated);
return $this->success();
}
/** 删除字典项 */
#[DeleteRoute(
route: '/{id}',
authorize: 'delete',
where: ['id' => '[0-9]+']
)]
public function delete(int $id): JsonResponse
{
$model = SysDictItemModel::find($id);
if (empty($model)) {
return $this->error();
}
$model->delete();
return $this->success();
}
}
@@ -0,0 +1,184 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Http\Requests\SysFileMoveOrCopyRequest;
use Modules\SystemTool\Models\SysFileModel;
use Modules\SystemTool\Services\SysFileService;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 文件列表
*/
#[RequestAttribute('/system/file/list', 'system.file.list')]
class SysFileController extends BaseController
{
protected array $searchField = [
'group_id' => '=',
'name' => 'like',
'file_type' => '=',
];
public function __construct(
protected SysFileService $service
) {}
/** 查询文件列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$query = SysFileModel::query();
$data = $this->buildSearch($params, $query)
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 上传文件 */
#[PostRoute('/upload', 'upload')]
public function uploadImage(Request $request): JsonResponse
{
$data = $request->validate([
'file' => 'required|file',
'group_id' => [
'nullable', 'integer',
function ($attribute, $value, $fail) {
if ($value == 0) {
return;
}
if (!DB::table('sys_file_group')->where('id', $value)->exists()) {
$fail('所选的分组 ID 不存在。');
}
},
],
]);
$result = $this->service->upload(
$data['file'],
$data['group_id'] ?? 0,
10,
Auth::id()
);
return $this->success($result);
}
/** 获取回收站文件列表 */
#[GetRoute('/trashed', 'trashed')]
public function trashed(): JsonResponse
{
$list = $this->service->getTrashedList(request()->all());
return $this->success($list);
}
/** 删除文件(软删除) */
#[DeleteRoute('/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
public function delete(int $id): JsonResponse
{
$this->service->delete($id);
return $this->success();
}
/** 批量删除文件 */
#[DeleteRoute('/batch/delete', 'delete')]
public function batchDelete(Request $request): JsonResponse
{
$ids = $request->input('ids', []);
$count = $this->service->batchDelete($ids);
return $this->success(['count' => $count]);
}
/** 彻底删除文件 */
#[DeleteRoute('/force-delete/{id}', 'force-delete', where: ['id' => '[0-9]+'])]
public function forceDelete(int $id): JsonResponse
{
$this->service->forceDelete($id);
return $this->success();
}
/** 批量彻底删除文件 */
#[DeleteRoute('/batch/force-delete', 'force-delete')]
public function batchForceDelete(Request $request): JsonResponse
{
$ids = $request->input('ids', []);
$count = $this->service->batchForceDelete($ids);
return $this->success(['count' => $count]);
}
/** 恢复文件 */
#[PostRoute('/restore/{id}', 'restore', where: ['id' => '[0-9]+'])]
public function restore(int $id): JsonResponse
{
$this->service->restore($id);
return $this->success();
}
/** 批量恢复文件 */
#[PostRoute('/batch/restore', 'restore')]
public function batchRestore(Request $request): JsonResponse
{
$ids = $request->input('ids', []);
$count = $this->service->batchRestore($ids);
return $this->success(['count' => $count]);
}
/** 复制文件 */
#[PostRoute('/copy', 'copy')]
public function copy(SysFileMoveOrCopyRequest $request): JsonResponse
{
$data = $request->validated();
if(! is_array($data['ids'])) {
$result = $this->service->copy($data['ids'], $data['group_id']);
} else {
$result = $this->service->batchCopy($data['ids'], $data['group_id']);
}
return $this->success($result);
}
/** 移动文件 */
#[PostRoute('/move', 'move')]
public function move(SysFileMoveOrCopyRequest $request): JsonResponse
{
$data = $request->validated();
if(! is_array($data['ids'])) {
$result = $this->service->move($data['ids'], $data['group_id']);
} else {
$result = $this->service->batchMove($data['ids'], $data['group_id']);
}
return $this->success($result);
}
/** 重命名文件 */
#[PutRoute('/rename/{id}', 'rename', where: ['id' => '[0-9]+'])]
public function rename(int $id, Request $request): JsonResponse
{
$newName = $request->input('name');
$this->service->rename($id, $newName);
return $this->success();
}
/** 下载文件 */
#[GetRoute('/download/{id}', false, where: ['id' => '[0-9]+'])]
public function download(int $id): StreamedResponse
{
return $this->service->download($id);
}
/** 清空回收站文件 */
#[DeleteRoute('/clean/trashed', 'clean-trashed')]
public function cleanTrashed(Request $request): JsonResponse
{
$count = $this->service->cleanTrashed();
return $this->success(['count' => $count]);
}
}
@@ -0,0 +1,89 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use App\Exceptions\RepositoryException;
use Illuminate\Http\JsonResponse;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Http\Requests\SysFileGroupFormRequest;
use Modules\SystemTool\Models\SysFileGroupModel;
/**
* 文件分组控制器
*/
#[RequestAttribute('/system/file/group', 'system.file.group')]
class SysFileGroupController extends BaseController
{
public function __construct() {}
/** 获取文件分组列表 */
#[GetRoute(authorize: 'query')]
public function list(): JsonResponse
{
$query = SysFileGroupModel::query()->orderBy('sort', 'asc');
$keywordSearch = request()->input('keywordSearch', '');
if (isset($keywordSearch) && $keywordSearch != '') {
$query->whereAny(
['name'],
'like',
'%' . str_replace('%', '\%', $keywordSearch) . '%'
);
return $this->success($query->get()->toArray());
}
$group = $query->get()->toArray();
return $this->success(getTreeData($group));
}
/** 创建文件分组 */
#[PostRoute(authorize: 'create')]
public function create(SysFileGroupFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysFileGroupModel::create($validated);
if (empty($model)) {
return $this->error();
}
return $this->success();
}
/** 编辑文件分组 */
#[PutRoute(
route: '/{id}',
authorize: 'update',
where: ['id' => '[0-9]+']
)]
public function update(int $id, SysFileGroupFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysFileGroupModel::find($id);
if (empty($model)) {
return $this->error();
}
$model->update($validated);
return $this->success();
}
/** 删除文件分组 */
#[DeleteRoute(
route: '/{id}',
authorize: 'delete',
where: ['id' => '[0-9]+']
)]
public function delete(int $id): JsonResponse
{
$model = SysFileGroupModel::find($id);
if (empty($model)) {
throw new RepositoryException('Model not found');
}
if ($model->countFiles > 0) {
throw new RepositoryException('该文件夹下存在文件,无法删除');
}
$model->delete();
return $this->success();
}
}
@@ -0,0 +1,85 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Http\Requests\SysGridNavFormRequest;
use Modules\SystemTool\Models\SysGridNavModel;
/**
* 首页宫格导航管理
*/
#[RequestAttribute('/system/grid-nav', 'system.grid-nav')]
class SysGridNavController extends BaseController
{
protected array $searchField = [
'status' => '=',
];
protected array $quickSearchField = ['title'];
/** 查询宫格导航列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$query = SysGridNavModel::query();
$data = $this->buildSearch($request->all(), $query)
->orderBy('sort', 'desc')
->orderBy('id', 'desc')
->paginate($request->input('pageSize', 10))
->toArray();
return $this->success($data);
}
/** 创建宫格导航 */
#[PostRoute(authorize: 'create')]
public function create(SysGridNavFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysGridNavModel::create($validated);
if (empty($model)) {
return $this->error();
}
return $this->success();
}
/** 编辑宫格导航 */
#[PutRoute(
route: '/{id}',
authorize: 'update',
where: ['id' => '[0-9]+']
)]
public function update(int $id, SysGridNavFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysGridNavModel::find($id);
if (empty($model)) {
return $this->error('宫格导航不存在');
}
$model->update($validated);
return $this->success();
}
/** 删除宫格导航 */
#[DeleteRoute(
route: '/{id}',
authorize: 'delete',
where: ['id' => '[0-9]+']
)]
public function delete(int $id): JsonResponse
{
$model = SysGridNavModel::find($id);
if (empty($model)) {
return $this->error('宫格导航不存在');
}
$model->delete();
return $this->success();
}
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
#[RequestAttribute]
class SysIndexController extends BaseController
{
/** 获取首页信息 */
#[GetRoute('/index', false)]
public function index(): JsonResponse
{
$web_setting = site_config('web');
return $this->success($web_setting);
}
}
@@ -0,0 +1,150 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Mail;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Settings\MailSettings;
/**
* 邮件配置管理
*/
#[RequestAttribute('/system/mail', 'system.mail')]
class SysMailController extends BaseController
{
/**
* 获取邮件配置(从 DB 加载已保存的值,fallback 到 config 文件)
*/
#[GetRoute('/config', 'config')]
public function getConfig(): JsonResponse
{
$mail = config('mail');
$mode = $mail['default'] ?? 'single';
$mailers = [];
if($mode === 'failover') {
$mailers = $mail['mailers']['failover']['mailers'] ?? [];
} elseif ($mode === 'roundrobin') {
$mailers = $mail['mailers']['roundrobin']['mailers'] ?? [];
}
$other = [
'mode' => 'single',
'mailers' => $mailers
];
if($mode === 'failover' || $mode == 'roundrobin') {
$other['mode'] = $mode;
$mail['default'] = $mailers[0] ?? 'smtp';
}
return $this->success([
'other' => $other,
'mail' => $mail,
'services' => config('services')
]);
}
/**
* 保存邮件配置到数据库
*
* 使用 MailSettings::set() 将每个配置项写入 应用配置 表,
* 写入后自动更新缓存,并通过全局中间件同步到 config() 运行时。
*/
#[PostRoute('/save', 'save')]
public function saveConfig(): JsonResponse
{
$data = request()->all();
// 解析前端提交的数据结构
$other = $data['other'] ?? [];
$mail = $data['mail'] ?? [];
$services = $data['services'] ?? [];
// 确定模式并构建 mailers 配置
$mode = $other['mode'] ?? 'single';
$selectedMailers = $other['mailers'] ?? [];
try {
// 邮件默认驱动
if ($mode === 'single') {
MailSettings::set('mail.default', $mail['default'] ?? 'smtp');
} elseif ($mode === 'failover') {
MailSettings::set('mail.default', 'failover');
MailSettings::set('mail.mailers.failover.mailers', $selectedMailers);
} elseif ($mode === 'roundrobin') {
MailSettings::set('mail.default', 'roundrobin');
MailSettings::set('mail.mailers.roundrobin.mailers', $selectedMailers);
}
// SMTP 配置
if (isset($mail['mailers']['smtp'])) {
$smtp = $mail['mailers']['smtp'];
MailSettings::set('mail.mailers.smtp.host', $smtp['host'] ?? '127.0.0.1');
MailSettings::set('mail.mailers.smtp.port', (int) ($smtp['port'] ?? 587));
MailSettings::set('mail.mailers.smtp.username', $smtp['username'] ?? '');
MailSettings::set('mail.mailers.smtp.password', $smtp['password'] ?? '');
}
// 发件人配置
if (isset($mail['from'])) {
MailSettings::set('mail.from.address', $mail['from']['address'] ?? '');
MailSettings::set('mail.from.name', $mail['from']['name'] ?? '');
}
// 日志驱动配置
if (isset($mail['mailers']['log'])) {
MailSettings::set('mail.mailers.log.channel', $mail['mailers']['log']['channel'] ?? 'stack');
}
// 第三方服务配置
if (isset($services['postmark'])) {
MailSettings::set('services.postmark.token', $services['postmark']['token'] ?? '');
}
if (isset($services['ses'])) {
MailSettings::set('services.ses.key', $services['ses']['key'] ?? '');
MailSettings::set('services.ses.secret', $services['ses']['secret'] ?? '');
MailSettings::set('services.ses.region', $services['ses']['region'] ?? 'us-east-1');
MailSettings::set('services.ses.token', $services['ses']['token'] ?? '');
}
if (isset($services['resend'])) {
MailSettings::set('services.resend.key', $services['resend']['key'] ?? '');
}
if (isset($services['mailgun'])) {
MailSettings::set('services.mailgun.domain', $services['mailgun']['domain'] ?? '');
MailSettings::set('services.mailgun.secret', $services['mailgun']['secret'] ?? '');
MailSettings::set('services.mailgun.endpoint', $services['mailgun']['endpoint'] ?? 'api.mailgun.net');
}
// 清除 Laravel 配置缓存
Artisan::call('config:clear');
return $this->success('保存成功');
} catch (\Throwable $e) {
return $this->error('保存失败:' . $e->getMessage());
}
}
/**
* 发送测试邮件
*/
#[PostRoute('/test', 'test')]
public function sendTest(): JsonResponse
{
$to = request()->input('to');
if (empty($to)) {
return $this->error('请输入收件人邮箱');
}
try {
Mail::raw('这是一封来自 Xin Admin 的测试邮件,用于验证邮件服务配置是否正确。', function ($message) use ($to) {
$message->to($to)
->subject('Xin Admin 邮件配置测试');
});
return $this->success('测试邮件发送成功');
} catch (\Throwable $e) {
return $this->error('发送失败: ' . $e->getMessage());
}
}
}
@@ -0,0 +1,90 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use App\Exceptions\RepositoryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Http\Requests\SysSiteConfigGroupFormRequest;
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
/**
* 设置分组控制器
*/
#[RequestAttribute('/system/config/group', 'system.config.group')]
class SysSiteConfigGroupController extends BaseController
{
/** 查询设置分组列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$query = SysSiteConfigGroupModel::query();
if (!empty($params['keywordSearch'])) {
$query->whereAny(
['title', 'remark', 'key'],
'like',
'%' . str_replace('%', '\%', $params['keywordSearch']) . '%'
);
}
$data = $query->get()->toArray();
return $this->success($data);
}
/** 创建设置分组 */
#[PostRoute(authorize: 'create')]
public function create(SysSiteConfigGroupFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysSiteConfigGroupModel::create($validated);
if (empty($model)) {
return $this->error();
}
return $this->success();
}
/** 编辑设置分组 */
#[PutRoute(
route: '/{id}',
authorize: 'update',
where: ['id' => '[0-9]+']
)]
public function update(int $id, SysSiteConfigGroupFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysSiteConfigGroupModel::find($id);
if (empty($model)) {
return $this->error();
}
$model->update($validated);
return $this->success();
}
/** 删除设置分组 */
#[DeleteRoute(
route: '/{id}',
authorize: 'delete',
where: ['id' => '[0-9]+']
)]
public function delete(int $id): JsonResponse
{
$model = SysSiteConfigGroupModel::find($id);
if (empty($model)) {
return $this->error();
}
$count = $model->configs()->count();
if ($count > 0) {
throw new RepositoryException('当前分组有未删除的设置项!');
}
$model->delete();
return $this->success();
}
}
@@ -0,0 +1,115 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use App\Exceptions\RepositoryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Http\Requests\SysSiteConfigItemsFormRequest;
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
use Modules\SystemTool\Services\SysSiteConfigService;
/**
* 系统设置
*/
#[RequestAttribute('/system/config/items', 'system.config.items')]
class SysSiteConfigItemsController extends BaseController
{
protected array $searchField = [
'group_id' => '=',
];
/** 查询设置项列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
if (empty($params['group_id'])) {
throw new RepositoryException('请选择设置分组');
}
$query = SysSiteConfigItemsModel::query();
$data = $this->buildSearch($params, $query)
->orderBy('sort', 'desc')
->get()
->toArray();
return $this->success($data);
}
/** 创建设置项 */
#[PostRoute(authorize: 'create')]
public function create(SysSiteConfigItemsFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysSiteConfigItemsModel::create($validated);
if (empty($model)) {
return $this->error();
}
return $this->success();
}
/** 编辑设置项 */
#[PutRoute(
route: '/{id}',
authorize: 'update',
where: ['id' => '[0-9]+']
)]
public function update(int $id, SysSiteConfigItemsFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysSiteConfigItemsModel::find($id);
if (empty($model)) {
return $this->error();
}
$model->update($validated);
return $this->success();
}
/** 删除设置项 */
#[DeleteRoute(
route: '/{id}',
authorize: 'delete',
where: ['id' => '[0-9]+']
)]
public function delete(int $id): JsonResponse
{
$model = SysSiteConfigItemsModel::find($id);
if (empty($model)) {
return $this->error();
}
$model->delete();
return $this->success();
}
/** 批量保存设置 */
#[PutRoute('/save', 'save')]
public function save(): JsonResponse
{
$configs = request()->input('configs');
if (empty($configs) || !is_array($configs)) {
return $this->error('请提供设置数据');
}
$result = SysSiteConfigService::batchSaveSiteConfig($configs);
if ($result['success']) {
SysSiteConfigService::refreshSiteConfig();
return $this->success();
}
$message = '设置保存失败:' . $result['message'];
return $this->error($message);
}
/** 刷新设置 */
#[PostRoute('/refreshCache', 'refresh')]
public function refreshCache(): JsonResponse
{
SysSiteConfigService::refreshSiteConfig();
return $this->success();
}
}
@@ -0,0 +1,184 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Storage;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Settings\StorageSettings;
/**
* 文件存储配置管理
*/
#[RequestAttribute('/system/storage', 'system.storage')]
class SysStorageController extends BaseController
{
/**
* 获取存储配置(从 DB 加载已保存的值,fallback 到 config 文件)
*/
#[GetRoute('/config', 'config')]
public function getConfig(): JsonResponse
{
$filesystems = config('filesystems');
$default = $filesystems['default'] ?? 'local';
// 获取各驱动配置
$disks = $filesystems['disks'] ?? [];
// 本地存储配置
$local = [
'root' => $disks['local']['root'] ?? storage_path('app/public'),
'url' => $disks['local']['url'] ?? config('app.url') . '/storage',
'visibility' => $disks['local']['visibility'] ?? 'public',
];
// S3 / OSS 配置
$s3 = [
'key' => $disks['s3']['key'] ?? '',
'secret' => $disks['s3']['secret'] ?? '',
'region' => $disks['s3']['region'] ?? '',
'bucket' => $disks['s3']['bucket'] ?? '',
'url' => $disks['s3']['url'] ?? '',
'endpoint' => $disks['s3']['endpoint'] ?? '',
'use_path_style_endpoint' => $disks['s3']['use_path_style_endpoint'] ?? false,
];
// FTP 配置
$ftp = [
'host' => $disks['ftp']['host'] ?? '',
'username' => $disks['ftp']['username'] ?? '',
'password' => $disks['ftp']['password'] ?? '',
'port' => $disks['ftp']['port'] ?? 21,
'root' => $disks['ftp']['root'] ?? '',
'passive' => $disks['ftp']['passive'] ?? true,
'ssl' => $disks['ftp']['ssl'] ?? false,
'timeout' => $disks['ftp']['timeout'] ?? 30,
];
// SFTP 配置
$sftp = [
'host' => $disks['sftp']['host'] ?? '',
'username' => $disks['sftp']['username'] ?? '',
'password' => $disks['sftp']['password'] ?? '',
'port' => $disks['sftp']['port'] ?? 22,
'root' => $disks['sftp']['root'] ?? '',
'timeout' => $disks['sftp']['timeout'] ?? 30,
'private_key' => $disks['sftp']['privateKey'] ?? '',
'passphrase' => $disks['sftp']['passphrase'] ?? '',
];
return $this->success([
'default' => $default,
'local' => $local,
's3' => $s3,
'ftp' => $ftp,
'sftp' => $sftp,
]);
}
/**
* 保存存储配置到数据库
*
* 使用 StorageSettings::set() 将每个配置项写入 应用配置 表,
* 写入后自动更新缓存,并通过全局中间件同步到 config() 运行时。
*/
#[PostRoute('/save', 'save')]
public function saveConfig(): JsonResponse
{
$data = request()->all();
$default = $data['default'] ?? 'local';
$local = $data['local'] ?? [];
$s3 = $data['s3'] ?? [];
$ftp = $data['ftp'] ?? [];
$sftp = $data['sftp'] ?? [];
try {
// 默认存储驱动
StorageSettings::set('filesystems.default', $default);
// 本地存储
if (!empty($local['url'])) {
StorageSettings::set('filesystems.disks.local.url', $local['url']);
}
// S3
if ($default === 's3' || !empty($s3['key'])) {
StorageSettings::set('filesystems.disks.s3.key', $s3['key'] ?? '');
StorageSettings::set('filesystems.disks.s3.secret', $s3['secret'] ?? '');
StorageSettings::set('filesystems.disks.s3.region', $s3['region'] ?? '');
StorageSettings::set('filesystems.disks.s3.bucket', $s3['bucket'] ?? '');
StorageSettings::set('filesystems.disks.s3.url', $s3['url'] ?? '');
StorageSettings::set('filesystems.disks.s3.endpoint', $s3['endpoint'] ?? '');
StorageSettings::set('filesystems.disks.s3.use_path_style_endpoint', (bool) ($s3['use_path_style_endpoint'] ?? false));
}
// FTP
if ($default === 'ftp' || !empty($ftp['host'])) {
StorageSettings::set('filesystems.disks.ftp.host', $ftp['host'] ?? '');
StorageSettings::set('filesystems.disks.ftp.username', $ftp['username'] ?? '');
StorageSettings::set('filesystems.disks.ftp.password', $ftp['password'] ?? '');
StorageSettings::set('filesystems.disks.ftp.port', (int) ($ftp['port'] ?? 21));
StorageSettings::set('filesystems.disks.ftp.root', $ftp['root'] ?? '');
StorageSettings::set('filesystems.disks.ftp.passive', (bool) ($ftp['passive'] ?? true));
StorageSettings::set('filesystems.disks.ftp.ssl', (bool) ($ftp['ssl'] ?? false));
StorageSettings::set('filesystems.disks.ftp.timeout', (int) ($ftp['timeout'] ?? 30));
}
// SFTP
if ($default === 'sftp' || !empty($sftp['host'])) {
StorageSettings::set('filesystems.disks.sftp.host', $sftp['host'] ?? '');
StorageSettings::set('filesystems.disks.sftp.username', $sftp['username'] ?? '');
StorageSettings::set('filesystems.disks.sftp.password', $sftp['password'] ?? '');
StorageSettings::set('filesystems.disks.sftp.port', (int) ($sftp['port'] ?? 22));
StorageSettings::set('filesystems.disks.sftp.root', $sftp['root'] ?? '');
StorageSettings::set('filesystems.disks.sftp.timeout', (int) ($sftp['timeout'] ?? 30));
StorageSettings::set('filesystems.disks.sftp.privateKey', $sftp['private_key'] ?? '');
StorageSettings::set('filesystems.disks.sftp.passphrase', $sftp['passphrase'] ?? '');
}
// 清除 Laravel 配置缓存
Artisan::call('config:clear');
return $this->success('保存成功');
} catch (\Throwable $e) {
return $this->error('保存失败:' . $e->getMessage());
}
}
/**
* 测试存储连接
*/
#[PostRoute('/test', 'test')]
public function testConnection(): JsonResponse
{
$disk = request()->input('disk', 'local');
try {
$storage = Storage::disk($disk);
$testFile = 'storage_test_' . time() . '.txt';
$testContent = 'XinAdmin 存储测试文件 - ' . date('Y-m-d H:i:s');
// 测试写入
$storage->put($testFile, $testContent);
// 测试读取
$readContent = $storage->get($testFile);
if ($readContent !== $testContent) {
return $this->error('读取测试失败:内容不匹配');
}
// 测试删除
$storage->delete($testFile);
return $this->success('存储连接测试成功');
} catch (\Throwable $e) {
return $this->error('连接测试失败: ' . $e->getMessage());
}
}
}
@@ -0,0 +1,68 @@
<?php
namespace Modules\SystemTool\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Modules\SystemTool\Base\SettingsDefinition;
use Modules\SystemTool\Enum\ESettingType;
use Symfony\Component\HttpFoundation\Response;
/**
* 全局中间件 — 从缓存加载 DB 应用设置到 config() 运行时
*
* 每次请求自动执行,确保 config('mail.*') / config('filesystems.*') 等
* 返回的是数据库中的值而非配置文件的默认值。
*
* 聚合缓存键名: SettingsDefinition::AGGREGATE_CACHE_KEY
* 在 SettingsDefinition::set() 写入时会自动清除该缓存,
* 使下一次请求重新从 DB 加载最新值。
*/
class LoadAppSettingsMiddleware
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
// 从聚合缓存加载(rememberForever = 永不过期,仅 set() 时清除)
$settings = Cache::rememberForever(SettingsDefinition::AGGREGATE_CACHE_KEY, function () {
return $this->loadAllFromDB();
});
// 写入 Laravel config() 运行时
foreach ($settings as $key => $value) {
config([$key => $value]);
}
return $next($request);
}
/**
* 从数据库加载所有应用设置,按类型转换后返回 key → value 数组
*/
protected function loadAllFromDB(): array
{
$rows = DB::table(SettingsDefinition::getTableName())->get();
$result = [];
foreach ($rows as $row) {
$result[$row->key] = match ((int) $row->type) {
ESettingType::String->value => $row->s,
ESettingType::Bool->value => is_null($row->n) ? null : (bool) $row->n,
ESettingType::Number->value => is_null($row->n) ? null : (int) $row->n,
ESettingType::Array->value => is_null($row->e) ? null : json_decode($row->e, true),
ESettingType::Object->value => is_null($row->e) ? null : unserialize(base64_decode($row->e)),
ESettingType::EncryptedString->value => is_null($row->e) ? null : Crypt::decrypt(base64_decode($row->e)),
default => $row->s ?? null,
};
}
return $result;
}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\SystemTool\Http\Requests;
use Illuminate\Validation\Rules\Exists;
use Modules\Common\Http\Requests\BaseFormRequest;
use Modules\SystemTool\Models\SysFileModel;
class SysCarouselFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'title' => 'required|string|max:100',
'image_id' => ['required', 'integer', new Exists(SysFileModel::class, 'id')],
'link' => 'nullable|string|max:500',
'status' => 'nullable|integer|in:0,1',
'sort' => 'nullable|integer',
];
}
public function messages(): array
{
return [
'title.required' => '轮播图标题是必填的',
'title.max' => '轮播图标题不能超过 :max 个字符',
'image_id.required' => '轮播图图片是必填的',
'link.max' => '跳转链接不能超过 :max 个字符',
'status.in' => '状态值只能是 0(启用)或 1(禁用)',
'sort.integer' => '排序必须是整数',
];
}
}
@@ -0,0 +1,52 @@
<?php
namespace Modules\SystemTool\Http\Requests;
use Illuminate\Validation\Rule;
use Modules\Common\Http\Requests\BaseFormRequest;
class SysDictFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
if (!$this->isUpdate()) {
return [
'name' => 'required|max:100',
'code' => 'required|max:100|unique:sys_dict,code',
'describe' => 'nullable|max:500',
'status' => 'required|in:0,1',
'sort' => 'nullable|integer|min:0',
];
} else {
$id = $this->route('id');
return [
'name' => 'required|max:100',
'code' => [
'required',
'max:100',
Rule::unique('sys_dict', 'code')->ignore($id)
],
'describe' => 'nullable|max:500',
'status' => 'required|in:0,1',
'sort' => 'nullable|integer|min:0',
];
}
}
public function messages(): array
{
return [
'name.required' => '字典名称不能为空',
'name.max' => '字典名称不能超过100个字符',
'code.required' => '字典编码不能为空',
'code.max' => '字典编码不能超过100个字符',
'code.unique' => '字典编码已存在',
'status.required' => '状态不能为空',
'status.in' => '状态格式错误',
'sort.integer' => '排序必须为整数',
'sort.min' => '排序不能小于0',
];
}
}
@@ -0,0 +1,67 @@
<?php
namespace Modules\SystemTool\Http\Requests;
use Illuminate\Validation\Rule;
use Modules\Common\Http\Requests\BaseFormRequest;
class SysDictItemFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
if (!$this->isUpdate()) {
$dict_id = $this->input('dict_id');
return [
'dict_id' => 'required|exists:sys_dict,id',
'label' => 'required|max:100',
'value' => [
'required',
'max:100',
Rule::unique('sys_dict_item')->where(function ($query) use ($dict_id) {
return $query->where('dict_id', $dict_id);
})
],
'color' => 'nullable|string',
'status' => 'required|in:0,1',
'sort' => 'nullable|integer|min:0',
];
} else {
$id = $this->route('id');
$dict_id = $this->input('dict_id');
return [
'dict_id' => 'required|exists:sys_dict,id',
'label' => 'required|max:100',
'value' => [
'required',
'max:100',
Rule::unique('sys_dict_item')->where(function ($query) use ($dict_id) {
return $query->where('dict_id', $dict_id);
})->ignore($id)
],
'color' => 'nullable|string',
'status' => 'required|in:0,1',
'sort' => 'nullable|integer|min:0',
];
}
}
public function messages(): array
{
return [
'dict_id.required' => '字典ID不能为空',
'dict_id.exists' => '字典不存在',
'label.required' => '字典标签不能为空',
'label.max' => '字典标签不能超过100个字符',
'value.required' => '字典键值不能为空',
'value.max' => '字典键值不能超过100个字符',
'value.unique' => '该字典下已存在相同的键值',
'color.in' => '颜色格式错误',
'status.required' => '状态不能为空',
'status.in' => '状态格式错误',
'sort.integer' => '排序必须为整数',
'sort.min' => '排序不能小于0',
];
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\SystemTool\Http\Requests;
use Illuminate\Support\Facades\DB;
use Modules\Common\Http\Requests\BaseFormRequest;
class SysFileGroupFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
if (!$this->isUpdate()) {
return [
'parent_id' => [
'required',
'integer',
function ($attribute, $value, $fail) {
if ($value != 0 && !DB::table('sys_file_group')->where('id', $value)->exists()) {
$fail('选择的上级分组不存在。');
}
},
],
'name' => 'required|string|max:255',
'describe' => 'sometimes|string|max:500',
'sort' => 'sometimes|integer|min:0',
];
} else {
return [
'name' => 'required|string|max:255',
'describe' => 'sometimes|string|max:500',
'sort' => 'sometimes|integer|min:0',
];
}
}
public function messages(): array
{
return [
'name.required' => '分组名称不能为空',
'name.string' => '分组名称必须是字符串',
'name.max' => '分组名称不能超过50个字符',
'sort.integer' => '分组排序必须是整数',
'sort.min' => '分组排序不能为负数',
'describe.string' => '分组描述必须是字符串',
'describe.max' => '分组描述不能超过500个字符',
];
}
}
@@ -0,0 +1,44 @@
<?php
namespace Modules\SystemTool\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\DB;
class SysFileMoveOrCopyRequest extends FormRequest
{
public function rules(): array
{
return [
'group_id' => [
'required',
'integer',
function ($attribute, $value, $fail) {
if ($value != 0 && !DB::table('sys_file_group')->where('id', $value)->exists()) {
$fail('选择的上级部门不存在。');
}
},
],
'ids' => [
'required',
function ($attribute, $value, $fail) {
// 检查是否为数字
if (is_numeric($value)) {
return;
}
// 检查是否为数字数组
if (is_array($value)) {
foreach ($value as $item) {
if (!is_numeric($item)) {
$fail("$attribute 中的元素必须全部是数字");
return;
}
}
return;
}
$fail("$attribute 必须是数字或数字数组");
},
]
];
}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\SystemTool\Http\Requests;
use Illuminate\Validation\Rules\Exists;
use Modules\Common\Http\Requests\BaseFormRequest;
use Modules\SystemTool\Models\SysFileModel;
class SysGridNavFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'title' => 'required|string|max:100',
'image_id' => ['required', 'integer', new Exists(SysFileModel::class, 'id')],
'link' => 'nullable|string|max:500',
'status' => 'nullable|integer|in:0,1',
'sort' => 'nullable|integer',
];
}
public function messages(): array
{
return [
'title.required' => '导航标题是必填的',
'title.max' => '导航标题不能超过 :max 个字符',
'image_id.required' => '导航图标是必填的',
'link.max' => '跳转链接不能超过 :max 个字符',
'status.in' => '状态值只能是 0(启用)或 1(禁用)',
'sort.integer' => '排序必须是整数',
];
}
}
@@ -0,0 +1,40 @@
<?php
namespace Modules\SystemTool\Http\Requests;
use Illuminate\Validation\Rule;
use Modules\Common\Http\Requests\BaseFormRequest;
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
class SysSiteConfigGroupFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
if (!$this->isUpdate()) {
return [
'key' => ['required', Rule::unique(SysSiteConfigGroupModel::class, 'key')],
'title' => 'required',
'remark' => 'sometimes|required',
];
} else {
$id = $this->route('id');
return [
'key' => ['required', Rule::unique(SysSiteConfigGroupModel::class, 'key')->ignore($id)],
'title' => 'required',
'remark' => 'sometimes|required',
];
}
}
public function messages(): array
{
return [
'key.required' => '键名字段是必填的',
'key.unique' => '键名已存在',
'title.required' => '标题字段是必填的',
'remark.required' => '备注字段是必填的',
];
}
}
@@ -0,0 +1,74 @@
<?php
namespace Modules\SystemTool\Http\Requests;
use Illuminate\Validation\Rules\Exists;
use Modules\Common\Http\Requests\BaseFormRequest;
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
use Modules\SystemTool\Rules\SysSiteConfigTypeRule;
class SysSiteConfigItemsFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
$rules = [
'title' => 'required|string',
'key' => ['required', 'string', 'min:2', 'max:255'],
'group_id' => ['required', 'integer', new Exists(SysSiteConfigGroupModel::class, 'id')],
'type' => ['required', 'string', new SysSiteConfigTypeRule],
'describe' => 'nullable|string',
'options' => [
'sometimes',
'nullable',
'string',
'regex:/^(?:[^=\n]+=[^=\n]+)(?:\n[^=\n]+=[^=\n]+)*$/',
],
'props' => [
'sometimes',
'nullable',
'string',
'regex:/^(?:[^=\n]+=[^=\n]+)(?:\n[^=\n]+=[^=\n]+)*$/',
],
'sort' => 'nullable|integer',
'values' => 'nullable|string',
];
if (!$this->isUpdate()) {
$rules['key'][] = function ($attribute, $value, $fail) {
$groupId = $this->input('group_id');
$exists = SysSiteConfigItemsModel::query()
->where('group_id', $groupId)
->where('key', $value)
->exists();
if ($exists) {
$fail('该键名在此分组中已存在');
}
};
}
return $rules;
}
public function messages(): array
{
return [
'title.required' => '标题字段是必填的',
'title.string' => '标题字段必须是字符串',
'key.required' => '键名字段是必填的',
'key.string' => '键名字段必填是字符串',
'key.min' => '键名至少需要 :min 个字符',
'key.max' => '键名不能超过 :max 个字符',
'group_id.required' => '分组ID是必填的',
'group_id.exists' => '选择的分组不存在',
'type.required' => '类型字段是必填的',
'describe.string' => '描述必须是字符串',
'options.regex' => '选项格式不正确,应为 key=value 格式,多个用换行分隔',
'props.regex' => '属性格式不正确,应为 key=value 格式,多个用换行分隔',
'sort.integer' => '排序必须是整数',
'values.string' => '值必须是字符串',
];
}
}
@@ -0,0 +1,40 @@
<?php
namespace Modules\SystemTool\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* 首页轮播图模型
*/
class SysCarouselModel extends Model
{
protected $table = 'sys_carousel';
protected $casts = [
'status' => 'int',
'sort' => 'int',
'image_id' => 'array',
];
protected $fillable = [
'title',
'image_id',
'link',
'status',
'sort',
];
protected $with = ['image'];
/**
* 关联图片
* @return HasOne
*/
public function image(): HasOne
{
return $this->hasOne(SysFileModel::class, 'id', 'image_id');
}
}
@@ -0,0 +1,38 @@
<?php
namespace Modules\SystemTool\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 字典数据模型
*/
class SysDictItemModel extends Model
{
protected $table = 'sys_dict_item';
protected $fillable = [
'dict_id',
'label',
'value',
'color',
'status',
'sort',
];
protected $casts = [
'dict_id' => 'integer',
'status' => 'integer',
'sort' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime'
];
/**
* 字典项关联字典表
*/
public function dict(): BelongsTo
{
return $this->belongsTo(SysDictModel::class, 'dict_id', 'id');
}
}
@@ -0,0 +1,70 @@
<?php
namespace Modules\SystemTool\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 字典类型模型
*/
class SysDictModel extends Model
{
protected $table = 'sys_dict';
protected $fillable = [
'name',
'code',
'describe',
'status',
'sort',
];
protected $casts = [
'status' => 'integer',
'sort' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime'
];
/**
* 关联字典子项
*/
public function dictItems(): HasMany
{
return $this->hasMany(SysDictItemModel::class, 'dict_id', 'id')
->orderBy('sort')
->orderBy('id');
}
/**
* 获取所有字典及其子项
* @return array
*/
public static function getAllDictWithItems(): array
{
return static::with('dictItems')
->where('status', 0)
->orderBy('sort')
->orderBy('id')
->get()
->map(function ($dict) {
return [
'id' => $dict->id,
'name' => $dict->name,
'code' => $dict->code,
'describe' => $dict->describe,
'status' => $dict->status,
'sort' => $dict->sort,
'dict_items' => $dict->dictItems->filter(fn($item) => $item->status === 0)->map(function ($item) {
return [
'id' => $item->id,
'label' => $item->label,
'value' => $item->value,
'color' => $item->color,
'sort' => $item->sort,
];
})->values()->toArray(),
];
})->toArray();
}
}
@@ -0,0 +1,62 @@
<?php
namespace Modules\SystemTool\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Class FileGroup
*/
class SysFileGroupModel extends Model
{
protected $table = 'sys_file_group';
protected $primaryKey = 'id';
protected $casts = [
'sort' => 'int',
'parent_id' => 'int'
];
protected $fillable = [
'name',
'parent_id',
'sort',
'describe',
];
protected $appends = ['countFiles'];
/**
* 获取父级分组
*/
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id', 'id');
}
/**
* 获取子级分组
*/
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id', 'id');
}
/**
* 获取分组下的文件
*/
public function files(): HasMany
{
return $this->hasMany(SysFileModel::class, 'group_id', 'id');
}
/**
* 获取分组下的文件数量
*/
public function getCountFilesAttribute(): int
{
return $this->files()->count();
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace Modules\SystemTool\Models;
use App\Models\UserModel;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
use Modules\SystemTool\Enum\FileType;
use Modules\SystemUser\Models\SysUserModel;
/**
* Class File
*/
class SysFileModel extends Model
{
use SoftDeletes;
protected $table = 'sys_file';
protected $primaryKey = 'id';
protected $casts = [
'group_id' => 'int',
'channel' => 'int',
'file_type' => 'int',
'file_size' => 'int',
'uploader_id' => 'int',
];
protected $fillable = [
'group_id',
'disk',
'channel',
'file_type',
'file_name',
'file_path',
'file_size',
'file_ext',
'uploader_id',
];
protected $appends = ['preview_url', 'file_url'];
/**
* 获取文件所属分组
*/
public function group(): BelongsTo
{
return $this->belongsTo(SysFileGroupModel::class, 'group_id', 'id');
}
/**
* 获取上传者
* 根据channel字段判断是系统用户还是App用户
*/
public function uploader(): BelongsTo
{
// channel 10:系统用户 20:App用户端
if ($this->channel == 10) {
return $this->belongsTo(SysUserModel::class, 'uploader_id', 'id');
} else {
return $this->belongsTo(UserModel::class, 'uploader_id', 'id');
}
}
protected function previewUrl(): Attribute
{
return new Attribute(
get: function ($value, array $data) {
try {
// 图片类型:直接返回图片URL作为预览
if ($data['file_type'] === FileType::IMAGE->value) {
if($data['disk'] === 'local') {
return Storage::disk($data['disk'])->url($data['file_path']);
}
return Storage::disk($data['disk'])->temporaryUrl(
$data['file_path'], now()->plus(minutes: 5)
);
}
$fileType = FileType::tryFrom($data['file_type']);
// 其他类型:返回默认类型图标
$previewPath = $fileType?->previewPath() ?? FileType::ANNEX->previewPath();
return config('app.url') . '/' . $previewPath;
} catch (\Throwable $e) {
// 发生异常时返回默认图标
return config('app.url') . '/' . FileType::ANNEX->previewPath();
}
}
);
}
/**
* 获取文件访问URL
*/
protected function fileUrl(): Attribute
{
return new Attribute(
get: function ($value, array $data) {
try {
return Storage::disk($data['disk'])->url($data['file_path']);
} catch (\Throwable $e) {
return null;
}
}
);
}
}
@@ -0,0 +1,40 @@
<?php
namespace Modules\SystemTool\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* 首页宫格导航模型
*/
class SysGridNavModel extends Model
{
protected $table = 'sys_grid_nav';
protected $casts = [
'status' => 'int',
'sort' => 'int',
'image_id' => 'int',
];
protected $fillable = [
'title',
'image_id',
'link',
'status',
'sort',
];
protected $with = ['image'];
/**
* 关联图片
* @return HasOne
*/
public function image(): HasOne
{
return $this->hasOne(SysFileModel::class, 'id', 'image_id');
}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\SystemTool\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Class SiteConfig Group
*/
class SysSiteConfigGroupModel extends Model
{
protected $table = 'sys_site_config_group';
protected $fillable = [
'title',
'key',
'remark',
];
/**
* 关联设置项目
* @return HasMany
*/
public function configs(): HasMany
{
return $this->hasMany(SysSiteConfigItemsModel::class ,'group_id', 'id');
}
}
@@ -0,0 +1,101 @@
<?php
namespace Modules\SystemTool\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\SystemTool\Enum\SiteConfigType;
/**
* Class Setting
*/
class SysSiteConfigItemsModel extends Model
{
protected $table = 'sys_site_config_items';
protected $casts = [
'group_id' => 'int',
'sort' => 'int',
];
protected $fillable = [
'key',
'title',
'describe',
'values',
'type',
'options',
'props',
'group_id',
'sort',
];
protected $appends = ['options_json', 'props_json'];
/**
* 关联设置
* @return BelongsTo
*/
public function group(): BelongsTo
{
return $this->belongsTo(SysSiteConfigGroupModel::class, 'id', 'group_id');
}
protected function values(): Attribute
{
return Attribute::make(
get: function (mixed $value, array $attributes) {
$type_enum = SiteConfigType::tryFrom($attributes['type']);
if($type_enum) {
return $type_enum->castValue($value);
} else {
return $value;
}
},
);
}
public function getOptionsJsonAttribute(): string
{
if(empty($this->options)) {
return "{}";
}
$data = [];
$value = explode("\n", $this->options);
foreach ($value as $item) {
$item = explode('=',$item);
if(count($item) < 2) {
continue;
}
$data[] = [
'label' => $item[1],
'value' => $item[0]
];
}
return json_encode($data);
}
public function getPropsJsonAttribute(): string
{
if(empty($this->props)) {
return "{}";
}
$data = [];
$value = explode("\n",$this->props);
foreach ($value as $item) {
$item = explode('=',$item);
if(count($item) < 2) {
continue;
}
if($item[1] === 'false') {
$data[$item[0]] = false;
}elseif ($item[1] === 'true') {
$data[$item[0]] = true;
}else {
$data[$item[0]] = $item[1];
}
}
return json_encode($data);
}
}
@@ -0,0 +1,32 @@
<?php
namespace Modules\SystemTool\Providers;
use Laravel\Boost\Boost;
use Illuminate\Support\ServiceProvider;
use Modules\AnnoRoute\AnnoRoute;
use Modules\SystemTool\Ai\Boots\Reasonix;
use Modules\SystemTool\Services\SysSiteConfigService;
class SystemToolServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->app->singleton(SysSiteConfigService::class, SysSiteConfigService::class);
}
/**
* Bootstrap any application services.
*/
public function boot(AnnoRoute $annoRoute): void
{
Boost::registerAgent('reasonix', Reasonix::class);
// 注册路由
$annoRoute->register(base_path('modules/SystemTool/Http/Controllers'));
}
}
@@ -0,0 +1,23 @@
<?php
namespace Modules\SystemTool\Rules;
use Closure;
use Modules\SystemTool\Enum\SiteConfigType;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
class SysSiteConfigTypeRule implements ValidationRule
{
/**
* Run the validation rule.
*
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (SiteConfigType::tryFrom($value) == null) {
$fail('设置类型不存在!');
}
}
}
@@ -0,0 +1,477 @@
<?php
namespace Modules\SystemTool\Services;
use App\Exceptions\HttpResponseException;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Modules\SystemTool\Enum\FileType;
use Modules\SystemTool\Models\SysFileModel;
use Modules\SystemTool\Settings\StorageSettings;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 文件服务类
*/
class SysFileService
{
/**
* 获取回收站列表
* @param array $params
* @return array
*/
public function getTrashedList(array $params = []): array
{
$pageSize = $params['pageSize'] ?? 10;
return SysFileModel::onlyTrashed()
->paginate($pageSize)
->toArray();
}
/**
* 获取存储磁盘实例
*/
protected function disk($disk = null): FilesystemAdapter
{
if(!$disk) {
$disk = config('filesystems.default');
}
if ($disk === 's3' && !self::isS3Configured()) {
throw new HttpResponseException(['success' => false, 'msg' => __('system.storage.s3_not_configured')]);
}
/** @var FilesystemAdapter */
return Storage::disk($disk);
}
/**
* 生成存储路径
*/
protected function generateStoragePath(string $extension): string
{
return date('Ymd') . '/' . uniqid() . '.' . $extension;
}
/**
* 上传文件
* @param UploadedFile $file 文件
* @param int $groupId 分组 ID
* @param int $channel 上传来源 0:匿名用户,10:后台用户,20:APP用户
* @param int|null $user_id 上传用户 ID
* @return array
*/
public function upload(UploadedFile $file, int $groupId = 0, int $channel = 0, ?int $user_id = null): array
{
// 文件扩展名
$fileExt = strtolower($file->getClientOriginalExtension() ?: $file->extension());
if (empty($fileExt)) {
throw new HttpResponseException(['success' => false, 'msg' => '无法识别的文件扩展名']);
}
// 推断文件类型
$fileType = FileType::guessFromExtension($fileExt);
// 获取储存路径
$storagePath = $this->generateStoragePath($fileExt);
// 获取磁盘
$disk = StorageSettings::get('filesystems.default', 'local');
// 存储文件并设置可见性
$stored = $this->disk($disk)->put($storagePath, $file->getContent(), 'public');
if (!$stored) {
throw new HttpResponseException(['success' => false, 'msg' => __('system.file.upload_failed')]);
}
// 保存到数据库
$model = new SysFileModel();
$model->disk = $disk;
$model->group_id = $groupId;
$model->channel = $channel;
$model->file_type = $fileType->value;
$model->file_path = $storagePath;
$model->file_name = $file->getClientOriginalName();
$model->file_size = $file->getSize();
$model->file_ext = $fileExt;
$model->uploader_id = $user_id;
$model->save();
return $model->toArray();
}
/**
* 软删除文件(移入回收站)
*/
public function delete(int $id): bool
{
$file = SysFileModel::find($id);
if (!$file) {
throw new HttpResponseException(['success' => false, 'msg' => __('system.file.not_found')]);
}
return (bool) $file->delete();
}
/**
* 批量软删除文件
*/
public function batchDelete(array $fileIds): int
{
return SysFileModel::whereIn('id', $fileIds)->delete();
}
/**
* 恢复已删除的文件
*/
public function restore(int $fileId): bool
{
$file = SysFileModel::withTrashed()->find($fileId);
if (!$file) {
throw new HttpResponseException(['success' => false, 'msg' => __('system.file.not_found')]);
}
return $file->restore();
}
/**
* 批量恢复文件
*/
public function batchRestore(array $fileIds): int
{
return SysFileModel::withTrashed()->whereIn('id', $fileIds)->restore();
}
/**
* 彻底删除文件(含物理删除)
*/
public function forceDelete(int $fileId): bool
{
$file = SysFileModel::withTrashed()->find($fileId);
if (!$file) {
throw new HttpResponseException(['success' => false, 'msg' => __('system.file.not_found')]);
}
// 删除物理文件
$this->disk($file->disk)->delete($file->file_path);
return (bool) $file->forceDelete();
}
/**
* 批量彻底删除文件
*/
public function batchForceDelete(array $fileIds): int
{
$files = SysFileModel::withTrashed()->whereIn('id', $fileIds)->get();
$count = 0;
foreach ($files as $file) {
$this->disk($file->disk)->delete($file->file_path);
$file->forceDelete();
$count++;
}
return $count;
}
/**
* 下载文件
*/
public function download(int $fileId, ?string $filename = null): StreamedResponse
{
$file = SysFileModel::find($fileId);
if (!$file) {
throw new HttpResponseException(['success' => false, 'msg' => __('system.file.not_found')]);
}
$downloadName = $filename ?? $file->file_name;
return $this->disk($file->disk)->download($file->file_path, $downloadName);
}
/**
* 获取文件流式响应(用于在线预览等场景)
*/
public function stream(int $fileId): StreamedResponse
{
$file = SysFileModel::find($fileId);
if (!$file) {
throw new HttpResponseException(['success' => false, 'msg' => __('system.file.not_found')]);
}
$mimeType = $this->getMimeType($file->file_ext);
return response()->stream(
function () use ($file) {
$stream = $this->disk($file->disk)->readStream($file->file_path);
fpassthru($stream);
if (is_resource($stream)) {
fclose($stream);
}
},
200,
[
'Content-Type' => $mimeType,
'Content-Disposition' => 'inline; filename="' . $file->file_name . '"',
]
);
}
/**
* 获取文件内容
*/
public function getContent(int $fileId): string
{
$file = SysFileModel::find($fileId);
if (!$file) {
throw new HttpResponseException(['success' => false, 'msg' => __('system.file.not_found')]);
}
return $this->disk($file->disk)->get($file->file_path);
}
/**
* 获取文件访问URL
*/
public function getUrl(int $fileId): ?string
{
$file = SysFileModel::find($fileId);
if (!$file) {
return null;
}
return $this->disk($file->disk)->url($file->file_path);
}
/**
* 根据路径获取访问URL
*/
public function getUrlByPath(string $path, ?string $disk = null): string
{
return $this->disk($disk)->url($path);
}
/**
* 获取文件元数据信息
*/
public function getMetadata(int $fileId): ?array
{
$file = SysFileModel::find($fileId);
if (!$file) {
return null;
}
$disk = $this->disk($file->disk);
return [
'id' => $file->id,
'name' => $file->file_name,
'path' => $file->file_path,
'disk' => $file->disk,
'size' => $file->file_size,
'extension' => $file->file_ext,
'mime_type' => $this->getMimeType($file->file_ext),
'last_modified' => $disk->exists($file->file_path)
? date('Y-m-d H:i:s', $disk->lastModified($file->file_path))
: null,
'url' => $this->getUrl($file->id),
'group_id' => $file->group_id,
'uploader_id' => $file->uploader_id,
'created_at' => $file->created_at?->format('Y-m-d H:i:s'),
'updated_at' => $file->updated_at?->format('Y-m-d H:i:s'),
];
}
/**
* 复制文件
*/
public function copy(int $fileId, int $targetGroupId = 0): array
{
$file = SysFileModel::find($fileId);
if (!$file) {
throw new HttpResponseException(['success' => false, 'msg' => __('system.file.not_found')]);
}
$newPath = $this->generateStoragePath($file->file_ext);
$this->disk($file->disk)->copy($file->file_path, $newPath);
// 创建新的文件记录
$newFile = SysFileModel::create([
'disk' => $file->disk,
'group_id' => $targetGroupId,
'channel' => $file->channel,
'file_name' => $file->file_name,
'file_type' => $file->file_type,
'file_path' => $newPath,
'file_size' => $file->file_size,
'file_ext' => $file->file_ext,
'uploader_id' => Auth::id(),
]);
return $newFile->toArray();
}
/**
* 批量复制文件
*/
public function batchCopy(array $fileIds, int $targetGroupId = 0): bool
{
$files = SysFileModel::whereIn('id', $fileIds)->get();
$fileArray = [];
foreach ($files as $file) {
$newPath = $this->generateStoragePath($file->file_ext);
$this->disk($file->disk)->copy($file->file_path, $newPath);
$fileArray[] =[
'disk' => $file->disk,
'group_id' => $targetGroupId,
'channel' => $file->channel,
'file_name' => $file->file_name,
'file_type' => $file->file_type,
'file_path' => $newPath,
'file_size' => $file->file_size,
'file_ext' => $file->file_ext,
'uploader_id' => Auth::id(),
'created_at' => now(),
'updated_at' => now(),
];
}
return SysFileModel::insert($fileArray);
}
/**
* 移动文件
*/
public function move(int $fileId, int $groupId): bool
{
$file = SysFileModel::find($fileId);
return $file->update(['group_id' => $groupId]);
}
/**
* 批量移动
*/
public function batchMove(array $fileIds, int $groupId): int
{
return SysFileModel::whereIn('id', $fileIds)->update(['group_id' => $groupId]);
}
/**
* 重命名文件
*/
public function rename(int $fileId, string $newName): bool
{
$file = SysFileModel::find($fileId);
if (!$file) {
throw new HttpResponseException(['success' => false, 'msg' => __('system.file.not_found')]);
}
return $file->update(['file_name' => $newName]);
}
/**
* 检查文件是否存在
*/
public function exists(int $fileId): bool
{
$file = SysFileModel::find($fileId);
if (!$file) {
return false;
}
return $this->disk($file->disk)->exists($file->file_path);
}
/**
* 获取文件大小(字节)
*/
public function getSize(int $fileId): ?int
{
$file = SysFileModel::find($fileId);
if (!$file) {
return null;
}
return $this->disk($file->disk)->size($file->file_path);
}
/**
* 获取MIME类型
*/
protected function getMimeType(string $extension): string
{
$mimeTypes = [
// 图片
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'avif' => 'image/avif',
'bmp' => 'image/bmp',
'svg' => 'image/svg+xml',
// 音频
'mp3' => 'audio/mpeg',
'wav' => 'audio/wav',
'ogg' => 'audio/ogg',
'flac' => 'audio/flac',
'aac' => 'audio/aac',
// 视频
'mp4' => 'video/mp4',
'webm' => 'video/webm',
'mkv' => 'video/x-matroska',
'mov' => 'video/quicktime',
'avi' => 'video/x-msvideo',
// 文档
'pdf' => 'application/pdf',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
// 压缩包
'zip' => 'application/zip',
'rar' => 'application/vnd.rar',
'7z' => 'application/x-7z-compressed',
// 其他
'json' => 'application/json',
'xml' => 'application/xml',
'txt' => 'text/plain',
];
return $mimeTypes[strtolower($extension)] ?? 'application/octet-stream';
}
/**
* 清空回收站文件
*/
public function cleanTrashed(): int
{
$expiredFiles = SysFileModel::onlyTrashed()->get();
$count = 0;
foreach ($expiredFiles as $file) {
$this->disk($file->disk)->delete($file->file_path);
$file->forceDelete();
$count++;
}
return $count;
}
/**
* 检查 S3 配置是否有效
* @return bool
*/
public static function isS3Configured(): bool
{
$storageConfig = config('filesystems.disks.s3');
if (empty($storageConfig)) {
return false;
}
return !empty($storageConfig['key'])
&& !empty($storageConfig['secret'])
&& !empty($storageConfig['bucket'])
&& !empty($storageConfig['region']);
}
}
@@ -0,0 +1,130 @@
<?php
namespace Modules\SystemTool\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\SystemTool\Enum\SiteConfigType;
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
use Throwable;
/**
* 系统设置服务
*/
class SysSiteConfigService
{
/**
* 缓存过期时间(秒),默认30天
*/
private const int|float CACHE_TTL = 60 * 60 * 24 * 30;
/**
* 缓存键
*/
private const string CACHE_KEY = 'site_config';
/**
* 刷新系统设置缓存
* @return bool
*/
public static function refreshSiteConfig(): bool
{
try {
$configs = SysSiteConfigGroupModel::with('configs')
->get()
->mapWithKeys(fn($group) => [
$group->key => $group->configs
->mapWithKeys(fn($config) => [ $config->key => $config->values ])
->toArray()
])
->toArray();
Cache::put(self::CACHE_KEY, $configs, self::CACHE_TTL);
Log::info('系统配置缓存已刷新', ['settings_count' => $configs]);
return true;
} catch (Throwable $e) {
Log::error('刷新系统配置缓存失败', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return false;
}
}
/**
* 获取设置
* 格式:'group.key' 或 'group',为null时返回所有配置
*/
public static function getSiteConfig(?string $name = null, mixed $default = null): mixed
{
$configs = Cache::get(self::CACHE_KEY);
// 缓存不存在时重新加载
if (empty($configs)) {
self::refreshSiteConfig();
$configs = Cache::get(self::CACHE_KEY, []);
}
// 返回所有配置
if (is_null($name)) {
return $configs;
}
// 解析配置路径
$keys = explode('.', $name);
// 支持多级获取:group.key 或 group
if (count($keys) === 2) {
return $configs[$keys[0]][$keys[1]] ?? $default;
} elseif (count($keys) === 1) {
return $configs[$keys[0]] ?? $default;
}
return $default;
}
/**
* 批量保存设置项
* [['id' => int, 'values' => mixed], ...]
*/
public static function batchSaveSiteConfig(array $configs): array
{
$errors = [];
$ids = array_column($configs, 'id');
$items = SysSiteConfigItemsModel::whereIn('id', $ids)->get()->keyBy('id');
// 先验证所有 ID 是否存在,收集缺失项信息
foreach ($configs as $item) {
$id = (int)$item['id'];
if (!$items->has($id)) {
$errors[] = [
'key' => "unknown_{$id}",
'title' => "未知设置(ID: {$id})",
];
}
}
if (!empty($errors)) {
return ['success' => false, 'errors' => $errors];
}
try {
DB::transaction(function () use ($configs, $items) {
foreach ($configs as $item) {
$id = (int)$item['id'];
$model = $items->get($id);
$value = $item['value'];
$model->values = is_array($value) || is_object($value) ? json_encode($value) : (string)$value;
$model->save();
}
});
return ['success' => true, 'errors' => []];
} catch (Throwable $e) {
return ['success' => false, 'errors' => $e->getMessage()];
}
}
}
@@ -0,0 +1,79 @@
<?php
namespace Modules\SystemTool\Settings;
use Modules\SystemTool\Attributes\Setting;
use Modules\SystemTool\Base\SettingsDefinition;
use Modules\SystemTool\Enum\ESettingType;
/**
* AI 服务配置定义
*
* 对应 config/ai.php 中的可配置项。
* 敏感值(API Key、Token)使用 EncryptedString 类型存储。
*/
#[Setting(config: 'ai.default', type: ESettingType::String, description: '默认 AI 供应商')]
#[Setting(config: 'ai.default_for_images', type: ESettingType::String, description: '默认图片生成供应商')]
#[Setting(config: 'ai.default_for_audio', type: ESettingType::String, description: '默认音频生成供应商')]
#[Setting(config: 'ai.default_for_transcription', type: ESettingType::String, description: '默认语音转文字供应商')]
// Anthropic
#[Setting(config: 'ai.providers.anthropic.key', type: ESettingType::EncryptedString, description: 'Anthropic API Key')]
#[Setting(config: 'ai.providers.anthropic.url', type: ESettingType::String, description: 'Anthropic API URL')]
// Azure
#[Setting(config: 'ai.providers.azure.key', type: ESettingType::EncryptedString, description: 'Azure OpenAI API Key')]
#[Setting(config: 'ai.providers.azure.url', type: ESettingType::String, description: 'Azure OpenAI URL')]
#[Setting(config: 'ai.providers.azure.api_version', type: ESettingType::String, description: 'Azure API 版本')]
#[Setting(config: 'ai.providers.azure.deployment', type: ESettingType::String, description: 'Azure 模型部署名')]
#[Setting(config: 'ai.providers.azure.embedding_deployment', type: ESettingType::String, description: 'Azure Embedding 模型部署名')]
#[Setting(config: 'ai.providers.azure.image_deployment', type: ESettingType::String, description: 'Azure 图片模型部署名')]
// Bedrock
#[Setting(config: 'ai.providers.bedrock.region', type: ESettingType::String, description: 'Bedrock 区域')]
#[Setting(config: 'ai.providers.bedrock.key', type: ESettingType::EncryptedString, description: 'Bedrock API Key')]
#[Setting(config: 'ai.providers.bedrock.access_key_id', type: ESettingType::EncryptedString, description: 'Bedrock Access Key ID')]
#[Setting(config: 'ai.providers.bedrock.secret_access_key', type: ESettingType::EncryptedString, description: 'Bedrock Secret Access Key')]
#[Setting(config: 'ai.providers.bedrock.session_token', type: ESettingType::EncryptedString, description: 'Bedrock Session Token')]
// Cohere
#[Setting(config: 'ai.providers.cohere.key', type: ESettingType::EncryptedString, description: 'Cohere API Key')]
// DeepSeek
#[Setting(config: 'ai.providers.deepseek.key', type: ESettingType::EncryptedString, description: 'DeepSeek API Key')]
// ElevenLabs
#[Setting(config: 'ai.providers.eleven.key', type: ESettingType::EncryptedString, description: 'ElevenLabs API Key')]
// Gemini
#[Setting(config: 'ai.providers.gemini.key', type: ESettingType::EncryptedString, description: 'Gemini API Key')]
#[Setting(config: 'ai.providers.gemini.url', type: ESettingType::String, description: 'Gemini API URL')]
// Groq
#[Setting(config: 'ai.providers.groq.key', type: ESettingType::EncryptedString, description: 'Groq API Key')]
// Jina
#[Setting(config: 'ai.providers.jina.key', type: ESettingType::EncryptedString, description: 'Jina API Key')]
// Mistral
#[Setting(config: 'ai.providers.mistral.key', type: ESettingType::EncryptedString, description: 'Mistral API Key')]
// Ollama
#[Setting(config: 'ai.providers.ollama.key', type: ESettingType::EncryptedString, description: 'Ollama API Key')]
#[Setting(config: 'ai.providers.ollama.url', type: ESettingType::String, description: 'Ollama API URL')]
// OpenAI
#[Setting(config: 'ai.providers.openai.key', type: ESettingType::EncryptedString, description: 'OpenAI API Key')]
#[Setting(config: 'ai.providers.openai.url', type: ESettingType::String, description: 'OpenAI API URL')]
// OpenRouter
#[Setting(config: 'ai.providers.openrouter.key', type: ESettingType::EncryptedString, description: 'OpenRouter API Key')]
// VoyageAI
#[Setting(config: 'ai.providers.voyageai.key', type: ESettingType::EncryptedString, description: 'VoyageAI API Key')]
// xAI
#[Setting(config: 'ai.providers.xai.key', type: ESettingType::EncryptedString, description: 'xAI API Key')]
class AiSettings extends SettingsDefinition
{
}
@@ -0,0 +1,40 @@
<?php
namespace Modules\SystemTool\Settings;
use Modules\SystemTool\Attributes\Setting;
use Modules\SystemTool\Base\SettingsDefinition;
use Modules\SystemTool\Enum\ESettingType;
/**
* 邮件与服务配置定义
*
* 对应 config/mail.php 和 config/services.php 中的可配置项。
* 敏感值(密码、Token、API Key)使用 EncryptedString 类型存储。
*/
#[Setting(config: 'mail.default', type: ESettingType::String, description: '默认邮件驱动(smtp / failover / roundrobin / log 等)')]
#[Setting(config: 'mail.mailers.smtp.host', type: ESettingType::String, description: 'SMTP 主机')]
#[Setting(config: 'mail.mailers.smtp.port', type: ESettingType::Number, description: 'SMTP 端口')]
#[Setting(config: 'mail.mailers.smtp.username', type: ESettingType::String, description: 'SMTP 用户名')]
#[Setting(config: 'mail.mailers.smtp.password', type: ESettingType::EncryptedString, description: 'SMTP 密码')]
#[Setting(config: 'mail.from.address', type: ESettingType::String, description: '发件人邮箱')]
#[Setting(config: 'mail.from.name', type: ESettingType::String, description: '发件人名称')]
#[Setting(config: 'mail.mailers.log.channel', type: ESettingType::String, description: '日志驱动通道')]
// Failover / RoundRobin
#[Setting(config: 'mail.mailers.failover.mailers', type: ESettingType::Array, description: 'Failover 邮件驱动列表')]
#[Setting(config: 'mail.mailers.roundrobin.mailers', type: ESettingType::Array, description: 'RoundRobin 邮件驱动列表')]
// 第三方服务
#[Setting(config: 'services.postmark.token', type: ESettingType::EncryptedString, description: 'Postmark Token')]
#[Setting(config: 'services.ses.key', type: ESettingType::EncryptedString, description: 'SES Access Key ID')]
#[Setting(config: 'services.ses.secret', type: ESettingType::EncryptedString, description: 'SES Secret Access Key')]
#[Setting(config: 'services.ses.region', type: ESettingType::String, description: 'SES 区域')]
#[Setting(config: 'services.ses.token', type: ESettingType::EncryptedString, description: 'SES Session Token')]
#[Setting(config: 'services.resend.key', type: ESettingType::EncryptedString, description: 'Resend API Key')]
#[Setting(config: 'services.mailgun.domain', type: ESettingType::String, description: 'Mailgun 域名')]
#[Setting(config: 'services.mailgun.secret', type: ESettingType::EncryptedString, description: 'Mailgun Secret')]
#[Setting(config: 'services.mailgun.endpoint', type: ESettingType::String, description: 'Mailgun Endpoint')]
class MailSettings extends SettingsDefinition
{
}
@@ -0,0 +1,48 @@
<?php
namespace Modules\SystemTool\Settings;
use Modules\SystemTool\Attributes\Setting;
use Modules\SystemTool\Base\SettingsDefinition;
use Modules\SystemTool\Enum\ESettingType;
/**
* 文件存储配置定义
*
* 对应 config/filesystems.php 中的可配置项。
* 敏感值(密钥、密码)使用 EncryptedString 类型存储。
*/
#[Setting(config: 'filesystems.default', type: ESettingType::String, description: '默认存储驱动(local / s3 / ftp / sftp')]
#[Setting(config: 'filesystems.disks.local.url', type: ESettingType::String, description: '本地存储 URL')]
// S3
#[Setting(config: 'filesystems.disks.s3.key', type: ESettingType::EncryptedString, description: 'S3 Access Key ID')]
#[Setting(config: 'filesystems.disks.s3.secret', type: ESettingType::EncryptedString, description: 'S3 Secret Access Key')]
#[Setting(config: 'filesystems.disks.s3.region', type: ESettingType::String, description: 'S3 区域')]
#[Setting(config: 'filesystems.disks.s3.bucket', type: ESettingType::String, description: 'S3 Bucket')]
#[Setting(config: 'filesystems.disks.s3.url', type: ESettingType::String, description: 'S3 URL')]
#[Setting(config: 'filesystems.disks.s3.endpoint', type: ESettingType::String, description: 'S3 Endpoint')]
#[Setting(config: 'filesystems.disks.s3.use_path_style_endpoint', type: ESettingType::Bool, description: 'S3 使用路径样式端点')]
// FTP
#[Setting(config: 'filesystems.disks.ftp.host', type: ESettingType::String, description: 'FTP 主机')]
#[Setting(config: 'filesystems.disks.ftp.username', type: ESettingType::String, description: 'FTP 用户名')]
#[Setting(config: 'filesystems.disks.ftp.password', type: ESettingType::EncryptedString, description: 'FTP 密码')]
#[Setting(config: 'filesystems.disks.ftp.port', type: ESettingType::Number, description: 'FTP 端口')]
#[Setting(config: 'filesystems.disks.ftp.root', type: ESettingType::String, description: 'FTP 根目录')]
#[Setting(config: 'filesystems.disks.ftp.passive', type: ESettingType::Bool, description: 'FTP 被动模式')]
#[Setting(config: 'filesystems.disks.ftp.ssl', type: ESettingType::Bool, description: 'FTP SSL')]
#[Setting(config: 'filesystems.disks.ftp.timeout', type: ESettingType::Number, description: 'FTP 超时(秒)')]
// SFTP
#[Setting(config: 'filesystems.disks.sftp.host', type: ESettingType::String, description: 'SFTP 主机')]
#[Setting(config: 'filesystems.disks.sftp.username', type: ESettingType::String, description: 'SFTP 用户名')]
#[Setting(config: 'filesystems.disks.sftp.password', type: ESettingType::EncryptedString, description: 'SFTP 密码')]
#[Setting(config: 'filesystems.disks.sftp.port', type: ESettingType::Number, description: 'SFTP 端口')]
#[Setting(config: 'filesystems.disks.sftp.root', type: ESettingType::String, description: 'SFTP 根目录')]
#[Setting(config: 'filesystems.disks.sftp.timeout', type: ESettingType::Number, description: 'SFTP 超时(秒)')]
#[Setting(config: 'filesystems.disks.sftp.privateKey', type: ESettingType::EncryptedString, description: 'SFTP 私钥')]
#[Setting(config: 'filesystems.disks.sftp.passphrase', type: ESettingType::EncryptedString, description: 'SFTP 私钥口令')]
class StorageSettings extends SettingsDefinition
{
}