├── public ├── favicon.ico ├── robots.txt ├── static │ ├── video.svg │ ├── image.svg │ ├── audio.svg │ ├── annex.svg │ └── zip.svg ├── index.php └── .htaccess ├── database ├── database.sqlite ├── seeders │ └── DatabaseSeeder.php └── migrations │ ├── 2025_01_01_000020_create_other_table.php │ ├── 2025_01_01_000008_create_user_table.php │ ├── 2025_01_01_000003_create_dict_table.php │ ├── 2025_01_01_000009_create_jobs_table.php │ ├── 2025_01_01_000005_create_setting_table.php │ └── 2025_01_01_000004_create_file_table.php ├── bootstrap ├── cache │ └── .gitignore ├── providers.php └── app.php ├── storage ├── logs │ └── .gitignore ├── app │ ├── private │ │ └── .gitignore │ ├── public │ │ └── .gitignore │ └── .gitignore ├── telescope │ └── .gitignore └── framework │ ├── testing │ └── .gitignore │ ├── views │ └── .gitignore │ ├── cache │ ├── data │ │ └── .gitignore │ └── .gitignore │ ├── sessions │ └── .gitignore │ └── .gitignore ├── config ├── swagger.php ├── services.php ├── filesystems.php ├── sanctum.php ├── telescope.php ├── cache.php └── mail.php ├── routes ├── web.php └── console.php ├── app ├── Exceptions │ ├── RepositoryException.php │ ├── HttpResponseException.php │ └── ExceptionsHandler.php ├── Providers │ ├── Telescope │ │ ├── EntryType.php │ │ ├── Watchers │ │ │ ├── Watcher.php │ │ │ ├── FetchesStackTrace.php │ │ │ ├── RedisWatcher.php │ │ │ └── QueryWatcher.php │ │ ├── Contracts │ │ │ └── EntriesRepository.php │ │ ├── RegistersWatchers.php │ │ ├── ListensForStorageOpportunities.php │ │ ├── TelescopeServiceProvider.php │ │ ├── Storage │ │ │ ├── EntryQueryOptions.php │ │ │ └── StorageRepository.php │ │ └── IncomingEntry.php │ ├── AnnoRoute │ │ ├── Attribute │ │ │ ├── GetMapping.php │ │ │ ├── PutMapping.php │ │ │ ├── PostMapping.php │ │ │ ├── DeleteMapping.php │ │ │ ├── Find.php │ │ │ ├── Query.php │ │ │ ├── Update.php │ │ │ ├── Create.php │ │ │ ├── Delete.php │ │ │ ├── RequestMapping.php │ │ │ └── Mapping.php │ │ └── RouteServiceProvider.php │ ├── AppServiceProvider.php │ └── AutoBindServiceProvider.php ├── Support │ ├── Enum │ │ ├── ShowType.php │ │ ├── FileType.php │ │ └── SettingType.php │ ├── helpers.php │ └── Mail │ │ └── VerificationCodeMail.php ├── Http │ ├── Requests │ │ └── App │ │ │ ├── UserRegisterRequest.php │ │ │ └── UserUpdateInfoRequest.php │ ├── Middleware │ │ ├── SysAuthGuardMiddleware.php │ │ ├── AllowCrossDomainMiddleware.php │ │ ├── AuthGuardMiddleware.php │ │ ├── LanguageMiddleware.php │ │ └── LoginLogMiddleware.php │ └── Controllers │ │ ├── IndexController.php │ │ ├── Sys │ │ ├── SysDictItemController.php │ │ ├── SysDictController.php │ │ ├── SysSettingGroupController.php │ │ ├── SysFileGroupController.php │ │ ├── SysUserDeptController.php │ │ ├── SysSettingItemsController.php │ │ ├── SysUserListController.php │ │ ├── SysUserRuleController.php │ │ ├── SysWatcherController.php │ │ ├── SysUserRoleController.php │ │ └── SysUserController.php │ │ ├── BaseController.php │ │ └── App │ │ ├── IndexController.php │ │ └── UserController.php ├── Services │ ├── Sys │ │ ├── SysLoginRecordService.php │ │ ├── SysFileGroupService.php │ │ ├── SysUserRoleService.php │ │ ├── SysUserRuleService.php │ │ └── SysUserDeptService.php │ ├── LengthAwarePaginatorService.php │ ├── UserService.php │ └── BaseService.php ├── Models │ ├── Sanctum │ │ └── PersonalAccessToken.php │ ├── Sys │ │ ├── SysSettingGroupModel.php │ │ ├── SysDictItemModel.php │ │ ├── SysLoginRecordModel.php │ │ ├── SysFileGroupModel.php │ │ ├── SysDeptModel.php │ │ ├── SysRuleModel.php │ │ ├── SysRoleModel.php │ │ ├── SysDictModel.php │ │ ├── SysSettingItemsModel.php │ │ ├── SysUserModel.php │ │ └── SysFileModel.php │ └── UserModel.php ├── Repositories │ ├── RepositoryInterface.php │ ├── UserRepository.php │ └── Sys │ │ ├── SysLoginRecordRepository.php │ │ ├── SysSettingGroupRepository.php │ │ ├── SysDictRepository.php │ │ ├── SysDictItemRepository.php │ │ ├── SysFileGroupRepository.php │ │ ├── SysRoleRepository.php │ │ ├── SysFileRepository.php │ │ ├── SysSettingItemsRepository.php │ │ ├── SysDeptRepository.php │ │ └── SysRuleRepository.php └── Observers │ └── SysSettingObserver.php ├── tests ├── TestCase.php ├── Unit │ └── ExampleTest.php ├── TestAutoBind.php └── Feature │ ├── ExampleTest.php │ └── MailServiceTest.php ├── resources └── views │ ├── model.blade.php │ ├── view.blade.php │ ├── controller.blade.php │ └── emails │ └── verification_code.blade.php ├── artisan ├── .gitignore ├── lang ├── en │ ├── pagination.php │ ├── auth.php │ ├── passwords.php │ ├── system.php │ └── user.php └── zh │ ├── pagination.php │ ├── auth.php │ ├── passwords.php │ ├── user.php │ └── system.php ├── .env.example ├── LICENSE ├── phpunit.xml ├── README.md └── composer.json /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /database/database.sqlite: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bootstrap/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /storage/app/private/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/app/public/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/telescope/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/testing/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/views/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/cache/data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/sessions/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !data/ 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /storage/app/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !private/ 3 | !public/ 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /config/swagger.php: -------------------------------------------------------------------------------- 1 | 'XinAdmin 文档', 5 | ]; -------------------------------------------------------------------------------- /routes/web.php: -------------------------------------------------------------------------------- 1 | comment(Inspiring::quote()); 8 | })->purpose('Display an inspiring quote')->hourly(); 9 | -------------------------------------------------------------------------------- /resources/views/model.blade.php: -------------------------------------------------------------------------------- 1 | {!! ' 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/Providers/Telescope/EntryType.php: -------------------------------------------------------------------------------- 1 | assertTrue(true); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /bootstrap/providers.php: -------------------------------------------------------------------------------- 1 | getBoundClasses()); 15 | } 16 | 17 | } -------------------------------------------------------------------------------- /public/static/image.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /artisan: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | handleCommand(new ArgvInput); 14 | 15 | exit($status); 16 | -------------------------------------------------------------------------------- /database/seeders/DatabaseSeeder.php: -------------------------------------------------------------------------------- 1 | call([ 15 | SysUserSeeder::class, 16 | SysDataSeeder::class, 17 | ]); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.phpunit.cache 2 | /node_modules 3 | /public/build 4 | /public/hot 5 | /public/storage 6 | /storage/*.key 7 | /vendor 8 | /.env 9 | /.env.backup 10 | /.env.production 11 | /.phpactor.json 12 | /.phpunit.result.cache 13 | /.phpstorm.meta.php 14 | /_ide_helper.php 15 | Homestead.json 16 | Homestead.yaml 17 | auth.json 18 | npm-debug.log 19 | yarn-error.log 20 | /.fleet 21 | /.idea 22 | /.vscode 23 | /.zed 24 | /composer.lock -------------------------------------------------------------------------------- /tests/Feature/ExampleTest.php: -------------------------------------------------------------------------------- 1 | get('/'); 16 | 17 | $response->assertStatus(200); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/Providers/AnnoRoute/Attribute/GetMapping.php: -------------------------------------------------------------------------------- 1 | 'required|min:4|alphaDash', 13 | 'password' => 'required|min:4|alphaDash', 14 | 'rePassword' => 'required|min:4|same:password', 15 | 'email' => 'required|email', 16 | ]; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/Services/Sys/SysLoginRecordService.php: -------------------------------------------------------------------------------- 1 | limit(10) 17 | ->orderBy('id', 'desc') 18 | ->get() 19 | ->toArray(); 20 | } 21 | } -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | handleRequest(Request::capture()); 18 | -------------------------------------------------------------------------------- /public/static/audio.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/Services/LengthAwarePaginatorService.php: -------------------------------------------------------------------------------- 1 | $this->items(), 17 | 'total' => $this->total(), 18 | 'pageSize' => $this->perPage(), 19 | 'current' => $this->currentPage(), 20 | ]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/Http/Middleware/SysAuthGuardMiddleware.php: -------------------------------------------------------------------------------- 1 | json('Not Authorized', 401); 22 | } 23 | } -------------------------------------------------------------------------------- /app/Http/Requests/App/UserUpdateInfoRequest.php: -------------------------------------------------------------------------------- 1 | 'required|min:4|max:20', 13 | 'nickname' => 'required|min:4|max:20', 14 | 'gender' => 'required', 15 | 'email' => 'required|email', 16 | 'avatar_id' => 'required|integer', 17 | 'mobile' => 'required|regex:/^1[34578]\d{9}$/', 18 | ]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/Providers/AnnoRoute/Attribute/Find.php: -------------------------------------------------------------------------------- 1 | '« Previous', 17 | 'next' => 'Next »', 18 | 19 | ]; 20 | -------------------------------------------------------------------------------- /lang/zh/pagination.php: -------------------------------------------------------------------------------- 1 | '« Previous', 17 | 'next' => 'Next »', 18 | 19 | ]; 20 | -------------------------------------------------------------------------------- /app/Providers/AnnoRoute/Attribute/Update.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/Http/Controllers/IndexController.php: -------------------------------------------------------------------------------- 1 | success($web_setting); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/Models/Sanctum/PersonalAccessToken.php: -------------------------------------------------------------------------------- 1 | tokenable_type == SysUserModel::class 14 | && $this->tokenable_id == 1 15 | ) { 16 | return true; 17 | } 18 | return in_array('*', $this->abilities) || 19 | array_key_exists($ability, array_flip($this->abilities)); 20 | } 21 | } -------------------------------------------------------------------------------- /app/Providers/Telescope/Watchers/Watcher.php: -------------------------------------------------------------------------------- 1 | options = $options; 23 | } 24 | 25 | /** 26 | * Register the watcher. 27 | */ 28 | abstract public function register($app); 29 | } 30 | -------------------------------------------------------------------------------- /lang/zh/auth.php: -------------------------------------------------------------------------------- 1 | '锁提供的凭据不匹配我们的记录。', 17 | 'password' => '输入的密码不正确。', 18 | 'throttle' => '登录尝试次数过多。请在:秒后重试。', 19 | 20 | ]; 21 | -------------------------------------------------------------------------------- /app/Models/Sys/SysSettingGroupModel.php: -------------------------------------------------------------------------------- 1 | hasMany(SysSettingItemsModel::class ,'group_id', 'id'); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /app/Providers/AnnoRoute/Attribute/RequestMapping.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | Options -MultiViews -Indexes 4 | 5 | 6 | RewriteEngine On 7 | 8 | # Handle Authorization Header 9 | RewriteCond %{HTTP:Authorization} . 10 | RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 11 | 12 | # Redirect Trailing Slashes If Not A Folder... 13 | RewriteCond %{REQUEST_FILENAME} !-d 14 | RewriteCond %{REQUEST_URI} (.+)/$ 15 | RewriteRule ^ %1 [L,R=301] 16 | 17 | # Send Requests To Front Controller... 18 | RewriteCond %{REQUEST_FILENAME} !-d 19 | RewriteCond %{REQUEST_FILENAME} !-f 20 | RewriteRule ^ index.php [L] 21 | 22 | -------------------------------------------------------------------------------- /lang/zh/passwords.php: -------------------------------------------------------------------------------- 1 | '您的密码已重置。', 17 | 'sent' => '我们已经通过电子邮件发送了您的密码重置链接。', 18 | 'throttled' => '请稍候再试。', 19 | 'token' => '此密码重置令牌无效。', 20 | 'user' => "我们找不到有那个邮箱地址的用户。", 21 | 22 | ]; 23 | -------------------------------------------------------------------------------- /public/static/zip.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # 网站基本配置 2 | APP_NAME=XinAdmin 3 | APP_ENV=local 4 | APP_KEY= 5 | APP_DEBUG=true 6 | APP_TIMEZONE=UTC 7 | APP_URL=http://localhost 8 | 9 | # 密码加密算法因子 10 | BCRYPT_ROUNDS=12 11 | 12 | # 日志配置 13 | LOG_CHANNEL=stack 14 | LOG_STACK=single 15 | LOG_DEPRECATIONS_CHANNEL=null 16 | LOG_LEVEL=debug 17 | 18 | # 数据库配置 19 | DB_CONNECTION=mysql 20 | DB_HOST=127.0.0.1 21 | DB_PORT=3306 22 | DB_DATABASE=laravel 23 | DB_USERNAME=root 24 | DB_PASSWORD=root 25 | 26 | # redis配置 27 | REDIS_CLIENT=phpredis 28 | REDIS_HOST=127.0.0.1 29 | REDIS_PASSWORD=null 30 | REDIS_PORT=6379 31 | 32 | # 队列配置 33 | BROADCAST_CONNECTION=log 34 | FILESYSTEM_DISK=local 35 | QUEUE_CONNECTION=database 36 | 37 | MEMCACHED_HOST=127.0.0.1 38 | 39 | # 系统设置 40 | SETTING_CACHE_KEY=settings -------------------------------------------------------------------------------- /app/Models/UserModel.php: -------------------------------------------------------------------------------- 1 | 'These credentials do not match our records.', 17 | 'password' => 'The provided password is incorrect.', 18 | 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 19 | 20 | ]; 21 | -------------------------------------------------------------------------------- /resources/views/view.blade.php: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import XinTable from '@/components/XinTable' 3 | import {ProFormColumnsAndProColumns, TableProps} from '@/components/XinTable/typings'; 4 | import * as verify from '@/utils/format'; 5 | 6 | /** 7 | * Api 接口 8 | */ 9 | const api = '/{{$api}}' 10 | 11 | /** 12 | * 数据类型 13 | */ 14 | interface Data { 15 | [key: string] : any 16 | } 17 | 18 | /** 19 | * 表格渲染 20 | */ 21 | const {{$name}}: React.FC = () => { 22 | 23 | {!! 'const columns: ProFormColumnsAndProColumns[] =' !!} 24 | {!! $columns !!} 25 | 26 | {!! 'const tableConfig: TableProps =' !!} 27 | {!! $table_config !!} 28 | 29 | {!! 'return {...tableConfig}/>' !!} 30 | 31 | } 32 | 33 | export default {{$name}} 34 | -------------------------------------------------------------------------------- /app/Services/UserService.php: -------------------------------------------------------------------------------- 1 | validate([ 17 | 'user_id' => 'required|exists:user,id', 18 | 'password' => 'required|string|min:6|max:20', 19 | 'rePassword' => 'required|same:password', 20 | ]); 21 | UserModel::find($data['user_id'])->update([ 22 | 'password' => password_hash($data['password'], PASSWORD_DEFAULT), 23 | ]); 24 | return $this->success(__('user.reset_password')); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/Services/BaseService.php: -------------------------------------------------------------------------------- 1 | $item) { 21 | if ($item['parent_id'] == $parentId) { 22 | $children = $this->getTreeData($list, $item['id']); 23 | ! empty($children) && $item['children'] = $children; 24 | $data[] = $item; 25 | unset($list[$k]); 26 | } 27 | } 28 | return $data; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/Http/Controllers/Sys/SysDictItemController.php: -------------------------------------------------------------------------------- 1 | 'Your password has been reset.', 17 | 'sent' => 'We have emailed your password reset link.', 18 | 'throttled' => 'Please wait before retrying.', 19 | 'token' => 'This password reset token is invalid.', 20 | 'user' => "We can't find a user with that email address.", 21 | 22 | ]; 23 | -------------------------------------------------------------------------------- /app/Models/Sys/SysDictItemModel.php: -------------------------------------------------------------------------------- 1 | 'integer', 16 | 'switch' => 'integer', 17 | 'created_at' => 'datetime', 18 | 'updated_at' => 'datetime' 19 | ]; 20 | 21 | protected $fillable = [ 22 | 'dict_id', 23 | 'label', 24 | 'value', 25 | 'switch', 26 | 'status', 27 | ]; 28 | 29 | /** 30 | * 字典项关联字典表 31 | */ 32 | public function dict(): BelongsTo 33 | { 34 | return $this->belongsTo(SysDictModel::class, 'dict_id', 'id'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/Providers/Telescope/Contracts/EntriesRepository.php: -------------------------------------------------------------------------------- 1 | orderBy('sort', 'asc'); 16 | $keywordSearch = request()->input('keywordSearch', ''); 17 | // 快速搜索 18 | if (isset($keywordSearch) && $keywordSearch != '') { 19 | $query->whereAny( 20 | ['name'], 21 | 'like', 22 | '%'.str_replace('%', '\%', $keywordSearch).'%' 23 | ); 24 | return $query->get()->toArray(); 25 | } 26 | $group = $query->get()->toArray(); 27 | return $this->getTreeData($group);; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lang/zh/user.php: -------------------------------------------------------------------------------- 1 | '请先登录', 16 | 'user_not_exist' => '用户不存在,请先注册', 17 | 'password_error' => '密码错误', 18 | 'admin_login' => '管理员登录', 19 | 'admin_logout' => '管理员退出', 20 | 'login_success' => '登录成功', 21 | 'login_error' => '登录失败,用户名或者密码错误!', 22 | 'logout_success' => '退出成功', 23 | 'old_password_error' => '旧密码错误', 24 | 'user_is_disabled' => '用户已被禁用', 25 | 'invalid_token' => '无效的令牌', 26 | 'refresh_token_expired' => '刷新令牌已过期,请重新登录', 27 | 28 | 'recharge_success' => '充值成功', 29 | 'reset_password' => '重置密码成功', 30 | 31 | ]; 32 | -------------------------------------------------------------------------------- /lang/zh/system.php: -------------------------------------------------------------------------------- 1 | [ 11 | 'image' => '图片', 12 | 'audio' => '音频', 13 | 'video' => '视频', 14 | 'zip' => '压缩包', 15 | 'annex' => '附件', 16 | 'size_limit' => '文件大小超出限制', 17 | 'ext_limit' => '文件扩展名不允许 :ext', 18 | 'upload_failed' => '上传失败', 19 | 'not_found' => '文件不存在', 20 | 'delete_failed' => '删除失败', 21 | 'download_failed' => '下载失败', 22 | 'invalid_visibility' => '无效的可见性设置', 23 | ], 24 | 'error' => [ 25 | 'no_permission' => '对不起,你暂时没有该权限,请联系管理员', 26 | 'route_not_exist' => '路由不存在', 27 | ], 28 | 'data_not_exist' => '数据不存在', 29 | ]; 30 | -------------------------------------------------------------------------------- /app/Repositories/RepositoryInterface.php: -------------------------------------------------------------------------------- 1 | 'integer', 31 | 'login_time' => 'datetime' 32 | ]; 33 | 34 | /** 35 | * 定义与用户的关联关系 36 | */ 37 | public function user(): BelongsTo 38 | { 39 | return $this->belongsTo(SysUserModel::class, 'user_id', 'id'); 40 | } 41 | } -------------------------------------------------------------------------------- /app/Http/Controllers/Sys/SysSettingGroupController.php: -------------------------------------------------------------------------------- 1 | [ 11 | 'image' => 'image', 12 | 'audio' => 'audio', 13 | 'video' => 'video', 14 | 'zip' => 'zip', 15 | 'annex' => 'annex', 16 | 'size_limit' => 'The file size exceeds the limit', 17 | 'ext_limit' => 'The file extension is not allowed :ext', 18 | 'upload_failed' => 'Upload failed', 19 | 'not_found' => 'File not found', 20 | 'delete_failed' => 'Delete failed', 21 | 'download_failed' => 'Download failed', 22 | 'invalid_visibility' => 'Invalid visibility setting', 23 | ], 24 | 'error' => [ 25 | 'no_permission' => 'Sorry, you do not have this permission at the moment, please contact the administrator', 26 | 'route_not_exist' => 'Route does not exist', 27 | ], 28 | 'data_not_exist' => 'Data does not exist', 29 | ]; 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 XinAdmin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/Support/Mail/VerificationCodeMail.php: -------------------------------------------------------------------------------- 1 | code = $code; 26 | $this->expireMinutes = $expireMinutes; 27 | } 28 | 29 | /** 30 | * Get the message content definition. 31 | */ 32 | public function content(): Content 33 | { 34 | return new Content( 35 | view: 'emails.verification_code', 36 | with: [ 37 | 'code' => $this->code, 38 | 'expireMinutes' => $this->expireMinutes, 39 | ], 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/Http/Controllers/Sys/SysFileGroupController.php: -------------------------------------------------------------------------------- 1 | success($service->list()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | [ 18 | 'token' => env('POSTMARK_TOKEN'), 19 | ], 20 | 21 | 'ses' => [ 22 | 'key' => env('AWS_ACCESS_KEY_ID'), 23 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 24 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 25 | ], 26 | 27 | 'resend' => [ 28 | 'key' => env('RESEND_KEY'), 29 | ], 30 | 31 | 'slack' => [ 32 | 'notifications' => [ 33 | 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), 34 | 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), 35 | ], 36 | ], 37 | 38 | ]; 39 | -------------------------------------------------------------------------------- /app/Http/Middleware/AllowCrossDomainMiddleware.php: -------------------------------------------------------------------------------- 1 | headers->set('Access-Control-Allow-Origin', '*'); 22 | $response->headers->set('Access-Control-Allow-Credentials', 'true'); 23 | $response->headers->set('Access-Control-Max-Age', 1800); 24 | $response->headers->set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); 25 | $response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, User-Language'); 26 | 27 | // 如果是预检请求, 返回 204 28 | if ($request->isMethod('OPTIONS')) { 29 | return response()->json([], 204, $response->headers->all()); 30 | } 31 | 32 | return $response; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /database/migrations/2025_01_01_000020_create_other_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->morphs('tokenable'); 19 | $table->string('name'); 20 | $table->string('token', 64)->unique(); 21 | $table->text('abilities')->nullable(); 22 | $table->timestamp('last_used_at')->nullable(); 23 | $table->timestamp('expires_at')->nullable(); 24 | $table->timestamps(); 25 | $table->comment('token table'); 26 | }); 27 | } 28 | } 29 | 30 | /** 31 | * Reverse the migrations. 32 | */ 33 | public function down(): void 34 | { 35 | Schema::dropIfExists('personal_access_tokens'); 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /database/migrations/2025_01_01_000008_create_user_table.php: -------------------------------------------------------------------------------- 1 | increments('id')->comment('用户ID'); 17 | $table->string('username', 20)->unique()->comment('用户名'); 18 | $table->string('password', 100)->comment('密码'); 19 | $table->string('nickname', 20)->default('')->comment('昵称'); 20 | $table->string('email', 50)->default('')->comment('邮箱'); 21 | $table->timestamp('email_verified_at')->nullable(); 22 | $table->rememberToken(); 23 | $table->timestamps(); 24 | $table->comment('APP用户表'); 25 | }); 26 | } 27 | } 28 | 29 | /** 30 | * Reverse the migrations. 31 | */ 32 | public function down(): void 33 | { 34 | Schema::dropIfExists('user'); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /app/Repositories/UserRepository.php: -------------------------------------------------------------------------------- 1 | 'required|string|max:20|unique:user,username', 16 | 'nickname' => 'sometimes|string|max:20', 17 | 'email' => 'sometimes|email|max:50|unique:user,email', 18 | ]; 19 | } 20 | 21 | protected function messages(): array 22 | { 23 | return [ 24 | 'username.required' => '用户名不能为空', 25 | 'username.string' => '用户名必须是字符串', 26 | 'username.max' => '用户名不能超过20个字符', 27 | 'username.unique' => '用户名已存在', 28 | 29 | 'nickname.string' => '昵称必须是字符串', 30 | 'nickname.max' => '昵称不能超过20个字符', 31 | 32 | 'email.email' => '邮箱格式不正确', 33 | 'email.max' => '邮箱不能超过50个字符', 34 | 'email.unique' => '邮箱已被注册', 35 | ]; 36 | } 37 | 38 | protected function model(): Builder 39 | { 40 | return UserModel::query(); 41 | } 42 | } -------------------------------------------------------------------------------- /lang/en/user.php: -------------------------------------------------------------------------------- 1 | 'Please log in first', 17 | 'user_not_exist' => 'User does not exist, please register first', 18 | 'password_error' => 'Password error', 19 | 'admin_login' => 'Admin Login', 20 | 'admin_logout' => 'Admin Logout', 21 | 'login_success' => 'Login Success', 22 | 'login_error' => 'Login Error, Please check your username and password', 23 | 'logout_success' => 'Logout Success', 24 | 'old_password_error' => 'Old password error', 25 | 'user_is_disabled' => 'User is disabled', 26 | 'invalid_token' => 'Invalid Token', 27 | 'refresh_token_expired' => 'Refresh Token Expired, Please login again', 28 | 29 | 'recharge_success' => 'Recharge Success', 30 | 'reset_password' => 'Reset Password Success', 31 | ]; 32 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | tests/Unit 10 | 11 | 12 | tests/Feature 13 | 14 | 15 | 16 | 17 | app 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/Providers/Telescope/RegistersWatchers.php: -------------------------------------------------------------------------------- 1 | $watcher) { 28 | if (is_string($key) && $watcher === false) { 29 | continue; 30 | } 31 | 32 | if (is_array($watcher) && ! ($watcher['enabled'] ?? true)) { 33 | continue; 34 | } 35 | 36 | $watcher = $app->make(is_string($key) ? $key : $watcher, [ 37 | 'options' => is_array($watcher) ? $watcher : [], 38 | ]); 39 | 40 | static::$watchers[] = get_class($watcher); 41 | 42 | $watcher->register($app); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /resources/views/controller.blade.php: -------------------------------------------------------------------------------- 1 | {!! ' $value) 21 | '{{ $key }}' => '{!! $value !!}', 22 | @endforeach 23 | ]; 24 | 25 | // 快速查询字段 26 | protected array $quickSearchField = [@foreach($quickSearchField as $value) '{!! $value !!}', @endforeach]; 27 | 28 | // 验证 29 | protected array $rule = [ 30 | @foreach($rules as $key => $value) 31 | '{{ $key }}' => '{!! $value !!}', 32 | @endforeach 33 | ]; 34 | 35 | // 错误提醒 36 | protected array $message = [ 37 | @foreach($message as $key => $value) 38 | '{{ $key }}' => '{!! $value !!}', 39 | @endforeach 40 | ]; 41 | 42 | /** 43 | * 若需重写新增、查看、编辑、删除等方法,请复制 @see \app\Http\Controllers\Admin\Controller 中对应的方法至此进行重写 44 | */ 45 | 46 | } 47 | -------------------------------------------------------------------------------- /app/Models/Sys/SysFileGroupModel.php: -------------------------------------------------------------------------------- 1 | 'int', 19 | 'parent_id' => 'int' 20 | ]; 21 | 22 | protected $fillable = [ 23 | 'name', 24 | 'parent_id', 25 | 'sort', 26 | 'describe', 27 | ]; 28 | 29 | protected $appends = ['countFiles']; 30 | 31 | /** 32 | * 获取父级分组 33 | */ 34 | public function parent(): BelongsTo 35 | { 36 | return $this->belongsTo(self::class, 'parent_id', 'id'); 37 | } 38 | 39 | /** 40 | * 获取子级分组 41 | */ 42 | public function children(): HasMany 43 | { 44 | return $this->hasMany(self::class, 'parent_id', 'id'); 45 | } 46 | 47 | /** 48 | * 获取分组下的文件 49 | */ 50 | public function files(): HasMany 51 | { 52 | return $this->hasMany(SysFileModel::class, 'group_id', 'id'); 53 | } 54 | 55 | /** 56 | * 获取分组下的文件数量 57 | */ 58 | public function getCountFilesAttribute(): int 59 | { 60 | return $this->files()->count(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /app/Exceptions/HttpResponseException.php: -------------------------------------------------------------------------------- 1 | msg = $data['msg'] ?? ''; 33 | $this->success = $data['success'] ?? true; 34 | if (empty($data['showType']) && $this->success) { 35 | $this->showType = ShowType::SUCCESS_MESSAGE; 36 | } elseif (empty($data['showType']) && ! $this->success) { 37 | $this->showType = ShowType::ERROR_MESSAGE; 38 | } else { 39 | $this->showType = $data['showType']; 40 | } 41 | $this->data = $data['data'] ?? []; 42 | parent::__construct($data['msg'] ?? '', $code); 43 | } 44 | 45 | public function toArray(): array 46 | { 47 | return [ 48 | 'data' => $this->data, 49 | 'success' => $this->success, 50 | 'msg' => $this->msg, 51 | 'showType' => $this->showType->value, 52 | ]; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /bootstrap/app.php: -------------------------------------------------------------------------------- 1 | withRouting( 16 | web: __DIR__.'/../routes/web.php' 17 | ) 18 | ->withMiddleware(function (Middleware $middleware) { 19 | // 全局跨域中间件 20 | $middleware->append(AllowCrossDomainMiddleware::class); 21 | $middleware->append(LanguageMiddleware::class); 22 | $middleware->alias([ 23 | 'login_log' => LoginLogMiddleware::class, 24 | 'abilities' => CheckAbilities::class, 25 | 'ability' => CheckForAnyAbility::class, 26 | 'authGuard' => AuthGuardMiddleware::class, 27 | ]); 28 | // 未登录响应 29 | $middleware->redirectGuestsTo(function (Request $request) { 30 | return response()->json([ 31 | 'success' => false, 32 | 'msg' => __('user.not_login') 33 | ], 401); 34 | }); 35 | }) 36 | ->withExceptions(function (Exceptions $exceptions) {})->create(); 37 | -------------------------------------------------------------------------------- /app/Models/Sys/SysDeptModel.php: -------------------------------------------------------------------------------- 1 | 'integer', 34 | 'sort' => 'integer', 35 | 'status' => 'integer', 36 | ]; 37 | 38 | protected $hidden = [ 'deleted_at' ]; 39 | 40 | /** 41 | * 定义与用户的关联关系(一个部门有多个用户) 42 | */ 43 | public function users(): HasMany 44 | { 45 | return $this->hasMany(SysUserModel::class, 'dept_id', 'id'); 46 | } 47 | 48 | /** 49 | * 定义父级部门关联 50 | */ 51 | public function parent(): BelongsTo 52 | { 53 | return $this->belongsTo(SysDeptModel::class, 'parent_id', 'id'); 54 | } 55 | 56 | /** 57 | * 定义子部门关联 58 | */ 59 | public function children(): HasMany 60 | { 61 | return $this->hasMany(SysDeptModel::class, 'parent_id', 'id'); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/Providers/AnnoRoute/RouteServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton(AnnoRoute::class, AnnoRoute::class); 13 | } 14 | 15 | public function boot(AnnoRoute $annoRoute): void 16 | { 17 | // 获取所有控制器 18 | $dir = app_path(); 19 | $controllers = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir)); 20 | foreach ($controllers as $controller) { 21 | if (! $controller->isFile()) { 22 | continue; 23 | } 24 | if (! str_contains($controller->getFilename(), 'Controller')) { 25 | continue; 26 | } 27 | if ($controller->getFilename() === 'BaseController.php') { 28 | continue; 29 | } 30 | $className = static::getClassName($controller); 31 | $annoRoute->register($className); 32 | } 33 | } 34 | 35 | /** 36 | * get class name 37 | */ 38 | private function getClassName($controller): string 39 | { 40 | $filePath = str_replace(app_path(), '', $controller->getPath()); 41 | $filePath = str_replace('/', '\\', $filePath); 42 | return 'App'.$filePath.'\\'.basename($controller->getFileName(), '.php'); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/Models/Sys/SysRuleModel.php: -------------------------------------------------------------------------------- 1 | 'integer', 33 | 'sort' => 'integer', 34 | 'status' => 'integer', 35 | 'show' => 'integer' 36 | ]; 37 | 38 | /** 39 | * 定义子权限关联 40 | */ 41 | public function children(): HasMany 42 | { 43 | return $this->hasMany(SysRuleModel::class, 'parent_id', 'id') 44 | ->orderBy('sort'); 45 | } 46 | 47 | /** 48 | * 定义父权限关联 49 | */ 50 | public function parent(): BelongsTo 51 | { 52 | return $this->belongsTo(SysRuleModel::class, 'parent_id', 'id'); 53 | } 54 | 55 | /** 56 | * 角色权限关联中间表 57 | */ 58 | public function roles(): BelongsToMany 59 | { 60 | return $this->belongsToMany(SysRoleModel::class, 'sys_role_rule', 'rule_id', 'role_id'); 61 | } 62 | } -------------------------------------------------------------------------------- /app/Models/Sys/SysRoleModel.php: -------------------------------------------------------------------------------- 1 | 'integer', 26 | 'status' => 'integer' 27 | ]; 28 | 29 | protected $appends = ['countUser', 'ruleIds']; 30 | 31 | /** 32 | * 角色用户关联 33 | */ 34 | public function users(): BelongsToMany 35 | { 36 | return $this->belongsToMany(SysUserModel::class, 'sys_user_role', 'role_id', 'user_id'); 37 | } 38 | 39 | /** 40 | * 角色权限关联中间表 41 | */ 42 | public function rules(): BelongsToMany 43 | { 44 | return $this->belongsToMany(SysRuleModel::class, 'sys_role_rule', 'role_id', 'rule_id'); 45 | } 46 | 47 | /** 用户总数 */ 48 | public function getCountUserAttribute(): int 49 | { 50 | return $this->users()->count(); 51 | } 52 | 53 | /** 拥有的权限ID */ 54 | public function getRuleIdsAttribute(): array 55 | { 56 | if(!empty($this->id) && $this->id == 1) { 57 | return SysRuleModel::query()->pluck('id')->toArray(); 58 | } 59 | return $this->rules()->pluck('id')->toArray(); 60 | } 61 | } -------------------------------------------------------------------------------- /app/Providers/Telescope/Watchers/FetchesStackTrace.php: -------------------------------------------------------------------------------- 1 | forget($forgetLines); 18 | 19 | return $trace->first(function ($frame) { 20 | if (! isset($frame['file'])) { 21 | return false; 22 | } 23 | 24 | return ! Str::contains($frame['file'], $this->ignoredPaths()); 25 | }); 26 | } 27 | 28 | /** 29 | * Get the file paths that should not be used by backtraces. 30 | * 31 | * @return array 32 | */ 33 | protected function ignoredPaths(): array 34 | { 35 | return array_merge( 36 | [base_path('vendor'.DIRECTORY_SEPARATOR.$this->ignoredVendorPath())], 37 | $this->options['ignore_paths'] ?? [] 38 | ); 39 | } 40 | 41 | /** 42 | * Choose the frame outside of either Telescope / Laravel or all extends. 43 | */ 44 | protected function ignoredVendorPath(): string 45 | { 46 | if (! ($this->options['ignore_packages'] ?? true)) { 47 | return 'laravel'; 48 | } 49 | return ""; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/Observers/SysSettingObserver.php: -------------------------------------------------------------------------------- 1 | refreshCache('created', $setting); 21 | } 22 | 23 | /** 24 | * 设置项更新后 25 | */ 26 | public function updated(SysSettingItemsModel $setting): void 27 | { 28 | $this->refreshCache('updated', $setting); 29 | } 30 | 31 | /** 32 | * 设置项删除后 33 | */ 34 | public function deleted(SysSettingItemsModel $setting): void 35 | { 36 | $this->refreshCache('deleted', $setting); 37 | } 38 | 39 | /** 40 | * 刷新缓存 41 | */ 42 | private function refreshCache(string $action, SysSettingItemsModel $setting): void 43 | { 44 | try { 45 | SysSettingService::refreshSettings(); 46 | Log::info("系统配置缓存已自动刷新", [ 47 | 'action' => $action, 48 | 'setting_id' => $setting->id, 49 | 'key' => $setting->key, 50 | ]); 51 | } catch (\Throwable $e) { 52 | Log::error("系统配置缓存刷新失败", [ 53 | 'action' => $action, 54 | 'setting_id' => $setting->id, 55 | 'error' => $e->getMessage(), 56 | ]); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/Http/Controllers/Sys/SysUserDeptController.php: -------------------------------------------------------------------------------- 1 | list(); 36 | } 37 | 38 | /** 删除部门 */ 39 | #[DeleteMapping(authorize: 'delete')] 40 | public function deleteDept(Request $request, SysUserDeptService $service): JsonResponse 41 | { 42 | return $service->delete($request); 43 | } 44 | 45 | /** 获取部门用户列表 */ 46 | #[GetMapping('/users/{id}', authorize: 'users')] 47 | public function deptUsers(int $id, SysUserDeptService $service): JsonResponse 48 | { 49 | return $service->users($id); 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /app/Providers/Telescope/ListensForStorageOpportunities.php: -------------------------------------------------------------------------------- 1 | listen(RequestReceived::class, function ($event) { 27 | if (static::requestIsToApprovedUri($event->request)) { 28 | static::startRecording(); 29 | } 30 | }); 31 | 32 | $app['events']->listen(RequestTerminated::class, function ($event) { 33 | static::stopRecording(); 34 | }); 35 | } 36 | 37 | /** 38 | * Store the entries in queue before the application termination. 39 | * 40 | * This handles storing entries for HTTP requests and Artisan commands. 41 | */ 42 | protected static function storeEntriesBeforeTermination($app): void 43 | { 44 | $app->terminating(function () use ($app) { 45 | static::store($app[EntriesRepository::class]); 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/Http/Middleware/AuthGuardMiddleware.php: -------------------------------------------------------------------------------- 1 | bearerToken(); 21 | if (!$token) { 22 | return response()->json(['msg' => 'Token not provided', 'success' => false], 401); 23 | } 24 | // 查找 token 25 | $accessToken = PersonalAccessToken::findToken($token); 26 | if (!$accessToken) { 27 | return response()->json(['msg' => 'Invalid token', 'success' => false], 401); 28 | } 29 | if (empty($guards)) { 30 | $guards = ['sys_users']; 31 | } 32 | Log::info('Guards: ', $guards); 33 | Log::info('Auth Providers: ', config('auth.providers')); 34 | foreach ($guards as $guard) { 35 | Log::info('Auth Providers Model: ' . config('auth.providers.' . $guard . '.model')); 36 | if ($accessToken->tokenable_type == config('auth.providers.' . $guard . '.model')) { 37 | return $next($request); 38 | } 39 | } 40 | return response()->json([ 41 | 'msg' => __('user.not_login'), 42 | 'success' => false 43 | ], 401); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/Http/Controllers/BaseController.php: -------------------------------------------------------------------------------- 1 | repository()->find($id); 32 | return $this->success($item); 33 | } 34 | 35 | /** 列表响应 */ 36 | public function query(Request $request): JsonResponse 37 | { 38 | $list = $this->repository()->list($request->query()); 39 | return $this->success($list); 40 | } 41 | 42 | /** 更新响应 */ 43 | public function update(Request $request, int $id): JsonResponse 44 | { 45 | $this->repository()->update($id, $request->all()); 46 | return $this->success(); 47 | } 48 | 49 | /** 新增响应 */ 50 | public function create(Request $request): JsonResponse 51 | { 52 | $this->repository()->create($request->all()); 53 | return $this->success(); 54 | } 55 | 56 | /** 删除响应 */ 57 | public function delete(int $id): JsonResponse 58 | { 59 | $this->repository()->delete($id); 60 | return $this->success(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /app/Models/Sys/SysDictModel.php: -------------------------------------------------------------------------------- 1 | 'datetime', 23 | 'updated_at' => 'datetime' 24 | ]; 25 | 26 | /** 27 | * 关联字典子项 28 | */ 29 | public function dictItems(): HasMany 30 | { 31 | return $this->hasMany(SysDictItemModel::class, 'dict_id', 'id'); 32 | } 33 | 34 | /** 35 | * 获取字典子项 36 | * @return array 37 | */ 38 | public static function getDictItems(): array 39 | { 40 | return static::with('dictItems') 41 | ->get() 42 | ->map(function ($dict) { 43 | return [ 44 | 'id' => $dict->id, 45 | 'name' => $dict->name, 46 | 'type' => $dict->type, 47 | 'code' => $dict->code, 48 | 'describe' => $dict->describe, 49 | 'dict_items' => $dict->dictItems->map(function ($dictItem) { 50 | return [ 51 | 'label' => $dictItem->label, 52 | 'value' => $dictItem->value, 53 | 'status' => $dictItem->status, 54 | ]; 55 | }), 56 | ]; 57 | })->toArray(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/Http/Controllers/Sys/SysSettingItemsController.php: -------------------------------------------------------------------------------- 1 | input('values'); 37 | if (is_null($values)) { 38 | return $this->error('请提供设置值'); 39 | } 40 | $service->setSetting($id, $values); 41 | return $this->success('保存成功'); 42 | } 43 | 44 | /** 刷新设置 */ 45 | #[PostMapping('/refreshCache', 'refresh')] 46 | public function refreshCache(): JsonResponse 47 | { 48 | SysSettingService::refreshSettings(); 49 | return $this->success('重载成功'); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /resources/views/emails/verification_code.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 验证码邮件 6 | 46 | 47 | 48 |
49 | 50 |

您的验证码

51 |
52 | 53 |

您好!

54 | 55 |

您正在尝试进行身份验证,请使用以下验证码完成操作:

56 | 57 |
58 |
{{ $code }}
59 |
60 | 61 |

此验证码将在 {{ $expireMinutes }} 分钟后失效,请尽快使用。

62 | 63 |

如果您没有请求此验证码,请忽略此邮件。

64 | 65 | 68 | 69 | -------------------------------------------------------------------------------- /app/Http/Controllers/Sys/SysUserListController.php: -------------------------------------------------------------------------------- 1 | resetPassword($request); 40 | } 41 | 42 | /** 获取用户角色选项栏数据 */ 43 | #[GetMapping('/role', 'getRole')] 44 | public function role(SysUserRoleService $service): JsonResponse 45 | { 46 | return $this->success($service->getRoleFields()); 47 | } 48 | 49 | /** 获取用户部门选项栏数据 */ 50 | #[GetMapping('/dept', 'getDept')] 51 | public function dept(SysUserDeptService $service): JsonResponse 52 | { 53 | return $this->success($service->getDeptField()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/Http/Controllers/Sys/SysUserRuleController.php: -------------------------------------------------------------------------------- 1 | getList(); 37 | } 38 | 39 | /** 获取父级权限 */ 40 | #[GetMapping('/parent', authorize: 'parentQuery')] 41 | public function getRulesParent(SysUserRuleService $service): JsonResponse 42 | { 43 | return $service->getRuleParent(); 44 | } 45 | 46 | /** 设置显示状态 */ 47 | #[PutMapping('/show/{id}', authorize: 'show')] 48 | public function show($id, SysUserRuleService $service): JsonResponse 49 | { 50 | return $service->setShow($id); 51 | } 52 | 53 | /** 设置启用状态 */ 54 | #[PutMapping('/status/{id}', authorize: 'status')] 55 | public function status($id, SysUserRuleService $service): JsonResponse 56 | { 57 | return $service->setStatus($id); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/Http/Controllers/Sys/SysWatcherController.php: -------------------------------------------------------------------------------- 1 | type('request'); 25 | return $this->success($storage->get($options)); 26 | } 27 | 28 | #[GetMapping('/query', 'query')] 29 | public function queryLog(Request $request, EntriesRepository $storage): JsonResponse 30 | { 31 | $options = EntryQueryOptions::fromRequest($request); 32 | $options->type('query'); 33 | return $this->success($storage->get($options)); 34 | } 35 | 36 | #[GetMapping('/cache', 'cache')] 37 | public function cache(Request $request, EntriesRepository $storage): JsonResponse 38 | { 39 | $options = EntryQueryOptions::fromRequest($request); 40 | $options->type('cache'); 41 | return $this->success($storage->get($options)); 42 | } 43 | 44 | #[GetMapping('/redis', 'redis')] 45 | public function redis(Request $request, EntriesRepository $storage): JsonResponse 46 | { 47 | $options = EntryQueryOptions::fromRequest($request); 48 | $options->type('redis'); 49 | return $this->success($storage->get($options)); 50 | } 51 | 52 | } -------------------------------------------------------------------------------- /database/migrations/2025_01_01_000003_create_dict_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->string('name', 50)->comment('字典名称'); 19 | $table->string('code', 50)->comment('字典编码'); 20 | $table->string('describe', 255)->nullable()->comment('字典描述'); 21 | $table->string('type', 10)->default('default')->comment('字典类型'); 22 | $table->timestamps(); 23 | $table->comment('字典表'); 24 | }); 25 | } 26 | if (! Schema::hasTable('sys_dict_item')) { 27 | Schema::create('sys_dict_item', function (Blueprint $table) { 28 | $table->increments('id'); 29 | $table->unsignedBigInteger('dict_id')->comment('字典ID'); 30 | $table->string('label', 50)->comment('字典项名称'); 31 | $table->string('value', 50)->comment('字典项值'); 32 | $table->unsignedInteger('switch')->default(1)->comment('是否启用:0:禁用,1:启用'); 33 | $table->string('status', 10)->default('default')->comment('状态:(default,success,error,processing,warning)'); 34 | $table->timestamps(); 35 | $table->comment('字典项表'); 36 | }); 37 | } 38 | } 39 | 40 | /** 41 | * Reverse the migrations. 42 | */ 43 | public function down(): void 44 | { 45 | Schema::dropIfExists('sys_dict'); 46 | Schema::dropIfExists('sys_dict_item'); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /app/Providers/Telescope/TelescopeServiceProvider.php: -------------------------------------------------------------------------------- 1 | app); 21 | Telescope::listenForStorageOpportunities($this->app); 22 | } 23 | 24 | /** 25 | * Register any package services. 26 | */ 27 | public function register(): void 28 | { 29 | $this->registerDatabaseDriver(); 30 | 31 | $this->hideSensitiveRequestDetails(); 32 | 33 | Telescope::filter(function (IncomingEntry $entry) { 34 | return $entry->isFailedRequest() || 35 | $entry->isQuery() || 36 | $entry->isSlowQuery() || 37 | $entry->isRequest() || 38 | $entry->isCache() || 39 | $entry->isRedis() || 40 | $entry->isAuth(); 41 | }); 42 | } 43 | 44 | /** 45 | * Register the package database storage driver. 46 | */ 47 | protected function registerDatabaseDriver(): void 48 | { 49 | $this->app->singleton( 50 | EntriesRepository::class, StorageRepository::class 51 | ); 52 | } 53 | 54 | /** 55 | * Prevent sensitive request details from being logged by Telescope. 56 | */ 57 | protected function hideSensitiveRequestDetails(): void 58 | { 59 | Telescope::hideRequestParameters(['_token']); 60 | 61 | Telescope::hideRequestHeaders([ 62 | 'cookie', 63 | 'x-csrf-token', 64 | 'x-xsrf-token', 65 | ]); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/Repositories/Sys/SysLoginRecordRepository.php: -------------------------------------------------------------------------------- 1 | '=', 15 | 'user_id' => '=' 16 | ]; 17 | 18 | /** @var array|string[] 快速搜索字段 */ 19 | protected array $quickSearchField = ['username', 'ipaddr', 'browser', 'os']; 20 | 21 | /** 22 | * @inheritDoc 23 | */ 24 | protected function model(): Builder 25 | { 26 | return SysLoginRecordModel::query(); 27 | } 28 | 29 | protected function rules(): array 30 | { 31 | return [ 32 | 'username' => 'required', 33 | 'user_id' => 'required|exists:sys_user,id', 34 | 'ipaddr' => 'required|ip', 35 | 'login_location' => 'required', 36 | 'browser' => 'required', 37 | 'os' => 'required', 38 | 'status' => 'required|in:0,1', 39 | 'msg' => 'required', 40 | 'login_time' => 'required|date' 41 | ]; 42 | } 43 | 44 | protected function messages(): array 45 | { 46 | return [ 47 | 'username.required' => '用户名不能为空', 48 | 'user_id.required' => '用户ID不能为空', 49 | 'user_id.exists' => '用户不存在', 50 | 'ipaddr.required' => 'IP地址不能为空', 51 | 'ipaddr.ip' => 'IP地址格式错误', 52 | 'login_location.required' => '登录地点不能为空', 53 | 'browser.required' => '浏览器不能为空', 54 | 'os.required' => '操作系统不能为空', 55 | 'status.required' => '登录状态不能为空', 56 | 'status.in' => '登录状态格式错误', 57 | 'msg.required' => '提示消息不能为空', 58 | 'login_time.required' => '登录时间不能为空', 59 | 'login_time.date' => '登录时间格式错误' 60 | ]; 61 | } 62 | } -------------------------------------------------------------------------------- /app/Repositories/Sys/SysSettingGroupRepository.php: -------------------------------------------------------------------------------- 1 | 'required', 25 | 'title' => 'required', 26 | 'remark' => 'sometimes|required', 27 | ]; 28 | } 29 | 30 | protected function messages(): array 31 | { 32 | return [ 33 | 'key.required' => '键名字段是必填的', 34 | 'title.required' => '标题字段是必填的', 35 | 'remark.required' => '备注字段是必填的', 36 | ]; 37 | } 38 | 39 | public function list(array $params): array 40 | { 41 | if(empty($params['keywordSearch'])){ 42 | return $this->model() 43 | ->get() 44 | ->toArray(); 45 | }else { 46 | return $this->model() 47 | ->whereAny( 48 | ['title', 'remark', 'key'], 49 | 'like', 50 | '%'.str_replace('%', '\%', $params['keywordSearch']).'%' 51 | ) 52 | ->get() 53 | ->toArray(); 54 | } 55 | } 56 | 57 | public function delete(int $id): bool 58 | { 59 | $model = $this->model()->find($id); 60 | if (empty($model)) { 61 | throw new RepositoryException('Model not found'); 62 | } 63 | $count = $model->settings()->count(); 64 | if ($count > 0) { 65 | throw new RepositoryException('当前分组有未删除的设置项!'); 66 | } 67 | return $model->delete(); 68 | } 69 | 70 | } -------------------------------------------------------------------------------- /app/Services/Sys/SysUserRoleService.php: -------------------------------------------------------------------------------- 1 | error(__('system.data_not_exist')); 22 | } 23 | $model->status = $model->status ? 0 : 1; 24 | $model->save(); 25 | return $this->success(); 26 | } 27 | 28 | public function setRule(Request $request): void 29 | { 30 | $validated = $request->validate([ 31 | 'role_id' => 'required|exists:sys_role,id', 32 | 'rule_ids' => 'required|array|exists:sys_rule,id', 33 | ]); 34 | if($validated['role_id'] == 1) { 35 | throw new RepositoryException('超级管理员不嫩修改权限'); 36 | } 37 | $model = SysRoleModel::findOrFail($validated['role_id']); 38 | $model->rules()->sync($validated['rule_ids']); 39 | } 40 | 41 | /** 获取角色选择项 */ 42 | public function getRoleFields(): array 43 | { 44 | return SysRoleModel::where('status', 1) 45 | ->get(['id as role_id', 'name']) 46 | ->toArray(); 47 | } 48 | 49 | /** 获取用户列表 */ 50 | public function users($id): array 51 | { 52 | $model = SysRoleModel::query()->find($id); 53 | if (empty($model)) { 54 | throw new RepositoryException('Model not found'); 55 | } 56 | $pageSize = request()->input('pageSize') ?? 10; 57 | return $model->users() 58 | ->paginate( 59 | $pageSize, 60 | ['id', 'username', 'nickname', 'email', 'mobile', 'status'] 61 | ) 62 | ->toArray(); 63 | } 64 | 65 | } -------------------------------------------------------------------------------- /app/Support/Enum/FileType.php: -------------------------------------------------------------------------------- 1 | __('system.file.image'), 33 | self::AUDIO => __('system.file.audio'), 34 | self::VIDEO => __('system.file.video'), 35 | self::ZIP => __('system.file.zip'), 36 | self::ANNEX => __('system.file.annex'), 37 | }; 38 | } 39 | 40 | /** 41 | * 获取预览地址 42 | */ 43 | public function previewPath(): string 44 | { 45 | return match ($this) { 46 | self::IMAGE => 'static/image.svg', 47 | self::AUDIO => 'static/audio.svg', 48 | self::VIDEO => 'static/video.svg', 49 | self::ZIP => 'static/zip.svg', 50 | self::ANNEX => 'static/annex.svg', 51 | }; 52 | } 53 | 54 | /** 55 | * 获取最大大小 56 | */ 57 | public function maxSize(): int 58 | { 59 | return match ($this) { 60 | self::IMAGE => 2097152, 61 | self::AUDIO, self::VIDEO, self::ZIP, self::ANNEX => 10485760, 62 | }; 63 | } 64 | 65 | /** 66 | * 文件扩展名 67 | */ 68 | public function fileExt(): array|string 69 | { 70 | return match ($this) { 71 | self::IMAGE => ['jpg', 'jpeg', 'png', 'bmp', 'gif', 'avif', 'webp'], 72 | self::AUDIO => ['mp3', 'wma', 'wav', 'ape', 'flac', 'ogg', 'aac'], 73 | self::VIDEO => ['mp4', 'mov', 'wmv', 'flv', 'avl', 'webm', 'mkv'], 74 | self::ZIP => ['zip', 'rar'], 75 | self::ANNEX => '*', 76 | }; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /app/Providers/AppServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton(SysSettingService::class, SysSettingService::class); 26 | 27 | $this->app->bind('Illuminate\Pagination\LengthAwarePaginator', function ($app, $options) { 28 | return new LengthAwarePaginatorService( 29 | $options['items'], 30 | $options['total'], 31 | $options['perPage'], 32 | $options['currentPage'], 33 | $options['options'] 34 | ); 35 | }); 36 | 37 | $this->app->bind(ExceptionsHandler::class, \App\Exceptions\ExceptionsHandler::class); 38 | } 39 | 40 | /** 41 | * Bootstrap any application services. 42 | */ 43 | public function boot(): void 44 | { 45 | Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class); 46 | 47 | // 注册系统设置观察者,自动刷新缓存 48 | SysSettingItemsModel::observe(SysSettingObserver::class); 49 | 50 | if (Schema::hasTable('sys_setting_items')) { 51 | // 刷新系统设置缓存 52 | SysSettingService::refreshSettings(); 53 | // 从系统设置初始化邮件配置 54 | MailConfigService::initFromSettings(); 55 | // 从系统设置初始化存储配置 56 | StorageConfigService::initFromSettings(); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/Repositories/Sys/SysDictRepository.php: -------------------------------------------------------------------------------- 1 | isUpdate()) { 28 | $id = request()->route('id'); 29 | return [ 30 | 'name' => 'required', 31 | 'code' => [ 32 | 'required', 33 | Rule::unique('sys_dict', 'code')->ignore($id) 34 | ], 35 | 'describe' => 'sometimes', 36 | 'type' => 'required|in:default,badge,tag' 37 | ]; 38 | } else { 39 | return [ 40 | 'name' => 'required', 41 | 'code' => 'required|unique:sys_dict,code', 42 | 'describe' => 'sometimes', 43 | 'type' => 'required|in:default,badge,tag' 44 | ]; 45 | } 46 | } 47 | 48 | protected function messages(): array 49 | { 50 | return [ 51 | 'name.required' => '字典名称不能为空', 52 | 'code.required' => '字典编码不能为空', 53 | 'code.unique' => '字典编码已存在', 54 | 'type.required' => '字典类型不能为空', 55 | 'type.in' => '字典类型格式错误' 56 | ]; 57 | } 58 | 59 | public function delete(int $id): bool 60 | { 61 | $model = $this->model()->find($id); 62 | $count = $model->dictItems()->count(); 63 | if ($count > 0) { 64 | throw new RepositoryException("字典包含子项!"); 65 | } 66 | return $model->delete(); 67 | } 68 | } -------------------------------------------------------------------------------- /app/Providers/AnnoRoute/Attribute/Mapping.php: -------------------------------------------------------------------------------- 1 | success($service->users($id)); 40 | } 41 | 42 | /** 设置启用状态 */ 43 | #[PutMapping('/status/{id}', authorize: 'status')] 44 | public function status(int $id, SysUserRoleService $service): JsonResponse 45 | { 46 | return $service->setStatus($id); 47 | } 48 | 49 | /** 设置角色权限 */ 50 | #[PostMapping('/rule', 'rule')] 51 | public function setRoleRule(Request $request, SysUserRoleService $service): JsonResponse 52 | { 53 | $service->setRule($request); 54 | return $this->success(); 55 | } 56 | 57 | /** 获取权限选项 */ 58 | #[GetMapping('/rule/list', 'rule.list')] 59 | public function ruleList(SysUserRuleService $service): JsonResponse 60 | { 61 | return $service->getRuleFields(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /database/migrations/2025_01_01_000009_create_jobs_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->string('queue')->index(); 17 | $table->longText('payload'); 18 | $table->unsignedTinyInteger('attempts'); 19 | $table->unsignedInteger('reserved_at')->nullable(); 20 | $table->unsignedInteger('available_at'); 21 | $table->unsignedInteger('created_at'); 22 | }); 23 | 24 | Schema::create('sys_job_batches', function (Blueprint $table) { 25 | $table->string('id')->primary(); 26 | $table->string('name'); 27 | $table->integer('total_jobs'); 28 | $table->integer('pending_jobs'); 29 | $table->integer('failed_jobs'); 30 | $table->longText('failed_job_ids'); 31 | $table->mediumText('options')->nullable(); 32 | $table->integer('cancelled_at')->nullable(); 33 | $table->integer('created_at'); 34 | $table->integer('finished_at')->nullable(); 35 | }); 36 | 37 | Schema::create('sys_failed_jobs', function (Blueprint $table) { 38 | $table->id(); 39 | $table->string('uuid')->unique(); 40 | $table->text('connection'); 41 | $table->text('queue'); 42 | $table->longText('payload'); 43 | $table->longText('exception'); 44 | $table->timestamp('failed_at')->useCurrent(); 45 | }); 46 | } 47 | 48 | /** 49 | * Reverse the migrations. 50 | */ 51 | public function down(): void 52 | { 53 | Schema::dropIfExists('sys_jobs'); 54 | Schema::dropIfExists('sys_job_batches'); 55 | Schema::dropIfExists('sys_failed_jobs'); 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /app/Services/Sys/SysUserRuleService.php: -------------------------------------------------------------------------------- 1 | toArray(); 18 | $data = $this->getTreeData($data); 19 | return $this->success($data); 20 | } 21 | 22 | /** 23 | * 设置显示状态 24 | */ 25 | public function setShow($ruleID): JsonResponse 26 | { 27 | $model = SysRuleModel::find($ruleID); 28 | if (! $model) { 29 | return $this->error(__('system.data_not_exist')); 30 | } 31 | $model->hidden = $model->hidden ? 0 : 1; 32 | $model->save(); 33 | return $this->success(); 34 | } 35 | 36 | /** 37 | * 设置状态 38 | */ 39 | public function setStatus($ruleID): JsonResponse 40 | { 41 | $model = SysRuleModel::find($ruleID); 42 | if (! $model) { 43 | return $this->error(__('system.data_not_exist')); 44 | } 45 | $model->status = $model->status ? 0 : 1; 46 | $model->save(); 47 | return $this->success(); 48 | } 49 | 50 | /** 51 | * 获取父节点 52 | */ 53 | public function getRuleParent(): JsonResponse 54 | { 55 | $data = SysRuleModel::query() 56 | ->whereIn('type', ['menu', 'route', 'nested-route']) 57 | ->get(['name', 'id', 'parent_id']) 58 | ->toArray(); 59 | $data = $this->getTreeData($data); 60 | return $this->success($data); 61 | } 62 | 63 | /** 获取权限选择项 */ 64 | public function getRuleFields(): JsonResponse 65 | { 66 | $data = SysRuleModel::query() 67 | ->where("status", 1) 68 | ->get(['name as title', 'parent_id', 'id as key', 'id', 'local']) 69 | ->toArray(); 70 | $data = $this->getTreeData($data); 71 | return $this->success($data); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /database/migrations/2025_01_01_000005_create_setting_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 17 | $table->string('key', 50)->comment('设置项标示'); 18 | $table->string('title', 50)->comment('设置标题'); 19 | $table->string('describe', 500)->default('')->comment('设置项描述'); 20 | $table->string('values', 255)->default('')->comment('设置值'); 21 | $table->string('type', 50)->comment('设置类型'); 22 | $table->string('options', 500)->nullable()->comment('options配置'); 23 | $table->string('props', 500)->nullable()->comment('props配置'); 24 | $table->integer('group_id')->comment('分组ID'); 25 | $table->integer('sort')->comment('排序'); 26 | $table->timestamps(); 27 | $table->comment('系统设置表'); 28 | $table->unique(['key', 'group_id']); 29 | }); 30 | } 31 | if (! Schema::hasTable('sys_setting_group')) { 32 | Schema::create('sys_setting_group', function (Blueprint $table) { 33 | $table->increments('id'); 34 | $table->string('title', 50)->comment('分组标题'); 35 | $table->string('key', 50)->comment('分组KEY'); 36 | $table->string('remark', 255)->nullable()->comment('备注描述'); 37 | $table->timestamps(); 38 | $table->comment('设置分组表'); 39 | }); 40 | } 41 | } 42 | 43 | /** 44 | * Reverse the migrations. 45 | */ 46 | public function down(): void 47 | { 48 | Schema::dropIfExists('sys_setting_items'); 49 | Schema::dropIfExists('sys_setting_group'); 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /database/migrations/2025_01_01_000004_create_file_table.php: -------------------------------------------------------------------------------- 1 | increments('id')->comment('文件ID'); 18 | $table->integer('group_id')->default(0)->comment('文件分组ID'); 19 | $table->integer('channel')->comment('上传来源(10:系统用户 20:App用户端)'); 20 | $table->string('disk', 10)->comment('存储方式'); 21 | $table->integer('file_type')->comment('文件类型'); 22 | $table->string('file_name', 255)->comment('文件名称'); 23 | $table->string('file_path', 255)->comment('文件路径'); 24 | $table->integer('file_size')->comment('文件大小(字节)'); 25 | $table->string('file_ext', 20)->comment('文件扩展名'); 26 | $table->integer('uploader_id')->comment('上传者用户ID'); 27 | $table->softDeletes(); 28 | $table->timestamps(); 29 | $table->comment('文件表'); 30 | }); 31 | } 32 | if (! Schema::hasTable('sys_file_group')) { 33 | Schema::create('sys_file_group', function (Blueprint $table) { 34 | $table->increments('id')->comment('文件分组ID'); 35 | $table->integer('parent_id')->default(0)->comment('上级ID'); 36 | $table->string('name', 50)->comment('文件名称'); 37 | $table->integer('sort')->comment('分组排序'); 38 | $table->string('describe', 500)->nullable()->comment('分组描述'); 39 | $table->timestamps(); 40 | $table->comment('文件分组表'); 41 | }); 42 | } 43 | } 44 | 45 | /** 46 | * Reverse the migrations. 47 | */ 48 | public function down(): void 49 | { 50 | Schema::dropIfExists('sys_file'); 51 | Schema::dropIfExists('sys_file_group'); 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /app/Http/Controllers/App/IndexController.php: -------------------------------------------------------------------------------- 1 | success(compact('web_setting')); 30 | } 31 | 32 | /** 用户登录 */ 33 | #[PostMapping('/login')] 34 | public function login(Request $request): JsonResponse 35 | { 36 | $credentials = $request->validate([ 37 | 'username' => 'required|min:4|alphaDash', 38 | 'password' => 'required|min:4|alphaDash', 39 | ]); 40 | if (Auth::guard('users')->attempt($credentials, true)) { 41 | $data = $request->user('users') 42 | ->createToken($credentials['username']) 43 | ->toArray(); 44 | return $this->success($data, __('user.login_success')); 45 | } 46 | return $this->error(__('user.login_error')); 47 | } 48 | 49 | /** 用户注册 */ 50 | #[PostMapping('/register')] 51 | public function register(UserRegisterRequest $request): JsonResponse 52 | { 53 | $data = $request->validated(); 54 | $model = new UserModel; 55 | $model->username = $data['username']; 56 | $model->password = password_hash($data['password'], PASSWORD_DEFAULT); 57 | $model->email = $data['email']; 58 | if ($model->save()) { 59 | return $this->success(); 60 | } 61 | 62 | return $this->error('创建用户失败'); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/Repositories/Sys/SysDictItemRepository.php: -------------------------------------------------------------------------------- 1 | '=' 14 | ]; 15 | 16 | /** @var array|string[] 快速搜索字段 */ 17 | protected array $quickSearchField = ['label', 'value']; 18 | 19 | /** 20 | * @inheritDoc 21 | */ 22 | protected function model(): Builder 23 | { 24 | return SysDictItemModel::query(); 25 | } 26 | 27 | protected function rules(): array 28 | { 29 | if($this->isUpdate()) { 30 | $request = request()->all(); 31 | return [ 32 | 'dict_id' => 'required|exists:sys_dict,id', 33 | 'label' => 'required', 34 | 'value' => [ 35 | Rule::unique('sys_dict_item')->where(function ($query) use ($request) { 36 | return $query->where('dict_id', $request['dict_id'] ?? null); 37 | }) 38 | ], 39 | 'switch' => 'required|int|in:0,1', 40 | 'status' => 'required|in:default,success,error,processing,warning' 41 | ]; 42 | } else { 43 | return [ 44 | 'dict_id' => 'required|exists:sys_dict,id', 45 | 'label' => 'required', 46 | 'value' => 'required', 47 | 'switch' => 'required|in:0,1', 48 | 'status' => 'required|in:default,success,error,processing,warning' 49 | ]; 50 | } 51 | 52 | } 53 | 54 | protected function messages(): array 55 | { 56 | return [ 57 | 'dict_id.required' => '字典ID不能为空', 58 | 'dict_id.exists' => '字典不存在', 59 | 'label.required' => '字典项名称不能为空', 60 | 'value.required' => '字典项值不能为空', 61 | 'switch.required' => '启用状态不能为空', 62 | 'switch.in' => '启用状态格式错误', 63 | 'status.required' => '状态不能为空', 64 | 'status.in' => '状态格式错误' 65 | ]; 66 | } 67 | } -------------------------------------------------------------------------------- /app/Models/Sys/SysSettingItemsModel.php: -------------------------------------------------------------------------------- 1 | 'int', 17 | 'sort' => 'int', 18 | ]; 19 | 20 | protected $fillable = [ 21 | 'key', 22 | 'title', 23 | 'describe', 24 | 'values', 25 | 'type', 26 | 'options', 27 | 'props', 28 | 'group_id', 29 | 'sort', 30 | ]; 31 | 32 | protected $appends = ['options_json', 'props_json']; 33 | 34 | /** 35 | * 关联设置 36 | * @return BelongsTo 37 | */ 38 | public function group(): BelongsTo 39 | { 40 | return $this->belongsTo(SysSettingGroupModel::class, 'id', 'group_id'); 41 | } 42 | 43 | public function getOptionsJsonAttribute(): string 44 | { 45 | if(empty($this->options)) { 46 | return "{}"; 47 | } 48 | $data = []; 49 | $value = explode("\n", $this->options); 50 | foreach ($value as $item) { 51 | $item = explode('=',$item); 52 | if(count($item) < 2) { 53 | continue; 54 | } 55 | $data[] = [ 56 | 'label' => $item[1], 57 | 'value' => $item[0] 58 | ]; 59 | } 60 | return json_encode($data); 61 | } 62 | 63 | public function getPropsJsonAttribute(): string 64 | { 65 | if(empty($this->props)) { 66 | return "{}"; 67 | } 68 | $data = []; 69 | $value = explode("\n",$this->props); 70 | foreach ($value as $item) { 71 | $item = explode('=',$item); 72 | if(count($item) < 2) { 73 | continue; 74 | } 75 | if($item[1] === 'false') { 76 | $data[$item[0]] = false; 77 | }elseif ($item[1] === 'true') { 78 | $data[$item[0]] = true; 79 | }else { 80 | $data[$item[0]] = $item[1]; 81 | } 82 | } 83 | return json_encode($data); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/Providers/Telescope/Storage/EntryQueryOptions.php: -------------------------------------------------------------------------------- 1 | validate([ 43 | 'type' => ['nullable', 'string'], 44 | 'date' => ['nullable', 'date'], 45 | 'pageSize' => ['nullable', 'integer'], 46 | 'current' => ['nullable', 'integer'], 47 | ]); 48 | 49 | return (new static) 50 | ->type($params['type'] ?? 'request') 51 | ->date($params['date'] ?? date('Y-m-d')) 52 | ->page($params['current'] ?? 1) 53 | ->limit($params['pageSize'] ?? 10); 54 | } 55 | 56 | /** 57 | * Set the date of entries to retrieve. 58 | */ 59 | public function date(string $date): static 60 | { 61 | $this->date = $date; 62 | 63 | return $this; 64 | } 65 | 66 | /** 67 | * Set the type of entries to retrieve. 68 | */ 69 | public function page(int $page): static 70 | { 71 | $this->page = $page; 72 | 73 | return $this; 74 | } 75 | 76 | /** 77 | * Set the type of entries to retrieve. 78 | */ 79 | public function type(string $type): static 80 | { 81 | $this->type = $type; 82 | 83 | return $this; 84 | } 85 | 86 | /** 87 | * Set the number of entries that should be retrieved. 88 | */ 89 | public function limit(int $limit): static 90 | { 91 | $this->limit = $limit; 92 | 93 | return $this; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /app/Repositories/Sys/SysFileGroupRepository.php: -------------------------------------------------------------------------------- 1 | 'like']; 15 | 16 | protected function model(): Builder 17 | { 18 | return SysFileGroupModel::query(); 19 | } 20 | 21 | protected function rules(): array 22 | { 23 | if(! $this->isUpdate()) { 24 | return [ 25 | 'parent_id' => [ 26 | 'required', 27 | 'integer', 28 | function ($attribute, $value, $fail) { 29 | if ($value != 0 && !DB::table('sys_dept')->where('id', $value)->exists()) { 30 | $fail('选择的上级部门不存在。'); 31 | } 32 | }, 33 | ], 34 | 'name' => 'required|string|max:255', 35 | 'describe' => 'sometimes|string|max:500', 36 | 'sort' => 'sometimes|integer|min:0', 37 | ]; 38 | } else { 39 | return [ 40 | 'name' => 'required|string|max:255', 41 | 'describe' => 'sometimes|string|max:500', 42 | 'sort' => 'sometimes|integer|min:0', 43 | ]; 44 | } 45 | } 46 | 47 | protected function messages(): array 48 | { 49 | return [ 50 | 'name.required' => '分组名称不能为空', 51 | 'name.string' => '分组名称必须是字符串', 52 | 'name.max' => '分组名称不能超过50个字符', 53 | 54 | 'sort.integer' => '分组排序必须是整数', 55 | 'sort.min' => '分组排序不能为负数', 56 | 57 | 'describe.string' => '分组描述必须是字符串', 58 | 'describe.max' => '分组描述不能超过500个字符', 59 | ]; 60 | } 61 | 62 | public function delete(int $id): bool 63 | { 64 | $model = SysFileGroupModel::find($id); 65 | if (empty($model)) { 66 | throw new RepositoryException('Model not found'); 67 | } 68 | if ($model->countFiles > 0) { 69 | throw new RepositoryException('该文件夹下存在文件,无法删除'); 70 | } 71 | return $model->delete(); 72 | } 73 | } -------------------------------------------------------------------------------- /app/Http/Controllers/App/UserController.php: -------------------------------------------------------------------------------- 1 | user(); 26 | return $this->success(compact('info')); 27 | } 28 | 29 | #[PostMapping('/logout')] 30 | public function logout(): JsonResponse 31 | { 32 | $user_id = auth('users')->id(); 33 | $model = new UserModel; 34 | if ($model->logout($user_id)) { 35 | return $this->success('退出登录成功'); 36 | } else { 37 | return $this->error($model->getErrorMsg()); 38 | } 39 | } 40 | 41 | #[PutMapping] 42 | public function setUserInfo(UserUpdateInfoRequest $request): JsonResponse 43 | { 44 | UserModel::where('user_id', auth('user')->id())->update($request->validated()); 45 | 46 | return $this->error('更新成功'); 47 | } 48 | 49 | #[PostMapping('/setPwd')] 50 | public function setPassword(Request $request): JsonResponse 51 | { 52 | $data = $request->validate([ 53 | 'oldPassword' => 'required|string|max:20', 54 | 'newPassword' => 'required|string|min:6|max:20', 55 | 'rePassword' => 'required|same:newPassword', 56 | ]); 57 | $user_id = auth('user')->id(); 58 | $user = UserModel::query()->find($user_id); 59 | if (! password_verify($data['oldPassword'], $user['password'])) { 60 | return $this->error('旧密码不正确!'); 61 | } 62 | $user->password = password_hash($data['newPassword'], PASSWORD_DEFAULT); 63 | if ($user->save()) { 64 | return $this->success('更新成功'); 65 | } 66 | 67 | return $this->error('更新失败'); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /app/Repositories/Sys/SysRoleRepository.php: -------------------------------------------------------------------------------- 1 | '=', 17 | 'name' => 'like', 18 | ]; 19 | 20 | /** @var array|string[] 快速搜索字段 */ 21 | protected array $quickSearchField = ['name', 'description']; 22 | 23 | /** 24 | * @inheritDoc 25 | */ 26 | protected function model(): Builder 27 | { 28 | return SysRoleModel::query(); 29 | } 30 | 31 | protected function rules(): array 32 | { 33 | if(! $this->isUpdate()) { 34 | return [ 35 | 'name' => 'required|unique:sys_role,name', 36 | 'sort' => 'required|integer|min:0', 37 | 'description' => 'nullable|string', 38 | 'status' => 'required|integer|in:0,1' 39 | ]; 40 | } else { 41 | $id = request()->route('id'); 42 | return [ 43 | 'name' => [ 44 | 'required', 45 | 'string', 46 | Rule::unique('sys_role', 'name')->ignore($id) 47 | ], 48 | 'sort' => 'required|integer|min:0', 49 | 'description' => 'nullable|string', 50 | 'status' => 'required|integer|in:0,1' 51 | ]; 52 | } 53 | } 54 | 55 | public function messages(): array 56 | { 57 | return [ 58 | 'name.required' => '角色名称不能为空', 59 | 'name.unique' => '角色名称已存在', 60 | 'sort.required' => '排序不能为空', 61 | 'sort.integer' => '排序必须为整数', 62 | 'status.required' => '状态不能为空', 63 | 'status.in' => '状态格式错误' 64 | ]; 65 | } 66 | 67 | public function delete(int $id): bool 68 | { 69 | $model = SysRoleModel::find($id); 70 | if (empty($model)) { 71 | throw new RepositoryException('Model not found'); 72 | } 73 | if ($model->countUser > 0) { 74 | throw new RepositoryException('该角色下存在用户,无法删除'); 75 | } 76 | return $model->delete(); 77 | } 78 | } -------------------------------------------------------------------------------- /config/filesystems.php: -------------------------------------------------------------------------------- 1 | env('FILESYSTEM_DISK', 'local'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Filesystem Disks 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Below you may configure as many filesystem disks as necessary, and you 24 | | may even configure multiple disks for the same driver. Examples for 25 | | most supported storage drivers are configured here for reference. 26 | | 27 | | Supported drivers: "local", "ftp", "sftp", "s3" 28 | | 29 | */ 30 | 31 | 'disks' => [ 32 | 'local' => [ 33 | 'driver' => 'local', 34 | 'root' => public_path('storage'), 35 | 'url' => env('APP_URL').'/storage', 36 | 'visibility' => 'public', 37 | 'throw' => false, 38 | ], 39 | 's3' => [ 40 | 'driver' => 's3', 41 | 'key' => env('AWS_ACCESS_KEY_ID'), 42 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 43 | 'region' => env('AWS_DEFAULT_REGION'), 44 | 'bucket' => env('AWS_BUCKET'), 45 | 'url' => env('AWS_URL'), 46 | 'endpoint' => env('AWS_ENDPOINT'), 47 | 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), 48 | 'throw' => false, 49 | ], 50 | ], 51 | 52 | /* 53 | |-------------------------------------------------------------------------- 54 | | Symbolic Links 55 | |-------------------------------------------------------------------------- 56 | | 57 | | Here you may configure the symbolic links that will be created when the 58 | | `storage:link` Artisan command is executed. The array keys should be 59 | | the locations of the links and the values should be their targets. 60 | | 61 | */ 62 | 63 | 'links' => [], 64 | 65 | ]; 66 | -------------------------------------------------------------------------------- /app/Http/Middleware/LanguageMiddleware.php: -------------------------------------------------------------------------------- 1 | 'en', // 英语 17 | 'zh' => 'zh', // 简体中文 18 | 'jp' => 'ja', // 日语 19 | ]; 20 | 21 | /** 22 | * 默认语言 23 | */ 24 | protected string $defaultLanguage = 'zh'; 25 | 26 | /** 27 | * Handle an incoming request. 28 | */ 29 | public function handle(Request $request, Closure $next) 30 | { 31 | // 获取当前语言 32 | $locale = $this->getLocale($request); 33 | // 设置应用语言 34 | App::setLocale($locale); 35 | // 让请求继续处理 36 | return $next($request); 37 | } 38 | 39 | /** 40 | * 获取当前语言设置 41 | */ 42 | protected function getLocale(Request $request): string 43 | { 44 | // 优先级 1: URL 参数 (例如 ?lang=en) 45 | if ($request->has('lang')) { 46 | $lang = $request->get('lang'); 47 | if ($this->isSupported($lang)) { 48 | return $lang; 49 | } 50 | } 51 | 52 | // 优先级 2: User-Language 头 53 | $browserLocale = $this->getBrowserLocale($request); 54 | if ($browserLocale && $this->isSupported($browserLocale)) { 55 | return $browserLocale; 56 | } 57 | 58 | // 优先级 3: Session 中存储的语言 59 | if (Session::has('locale')) { 60 | $lang = Session::get('locale'); 61 | if ($this->isSupported($lang)) { 62 | return $lang; 63 | } 64 | } 65 | 66 | // 优先级 4: 配置文件中的默认语言 67 | return config('app.locale', $this->defaultLanguage); 68 | } 69 | 70 | /** 71 | * 从 User-Language 头中获取浏览器偏好语言 72 | */ 73 | protected function getBrowserLocale(Request $request): ?string 74 | { 75 | $acceptLanguage = $request->header('User-Language'); 76 | 77 | if (!$acceptLanguage) { 78 | return null; 79 | } 80 | 81 | return $acceptLanguage; 82 | } 83 | 84 | /** 85 | * 检查语言是否被支持 86 | */ 87 | protected function isSupported(string $locale): bool 88 | { 89 | return array_key_exists($locale, $this->supportedLanguages); 90 | } 91 | } -------------------------------------------------------------------------------- /app/Support/Enum/SettingType.php: -------------------------------------------------------------------------------- 1 | '输入框', 27 | self::TEXTAREA => '文本域', 28 | self::INPUT_NUMBER => '数字输入框', 29 | self::SWITCH => '开关', 30 | self::RADIO => '单选框', 31 | self::CHECKBOX => '复选框', 32 | self::SELECT => '下拉选择', 33 | self::DATE_PICKER => '日期选择器', 34 | }; 35 | } 36 | 37 | /** 38 | * 判断是否为数字类型 39 | */ 40 | public function isNumeric(): bool 41 | { 42 | return match($this) { 43 | self::INPUT_NUMBER => true, 44 | default => false, 45 | }; 46 | } 47 | 48 | /** 49 | * 判断是否为布尔类型 50 | */ 51 | public function isBoolean(): bool 52 | { 53 | return match($this) { 54 | self::SWITCH => true, 55 | default => false, 56 | }; 57 | } 58 | 59 | /** 60 | * 判断是否为数组类型 61 | */ 62 | public function isArray(): bool 63 | { 64 | return match($this) { 65 | self::CHECKBOX => true, 66 | default => false, 67 | }; 68 | } 69 | 70 | /** 71 | * 获取所有前端组件类型 72 | */ 73 | public static function getFrontendTypes(): array 74 | { 75 | return [ 76 | self::INPUT->value, 77 | self::TEXTAREA->value, 78 | self::INPUT_NUMBER->value, 79 | self::SWITCH->value, 80 | self::RADIO->value, 81 | self::CHECKBOX->value, 82 | self::SELECT->value, 83 | self::DATE_PICKER->value, 84 | ]; 85 | } 86 | 87 | /** 88 | * 从字符串创建枚举实例(宽松匹配) 89 | */ 90 | public static function fromString(string $type): ?self 91 | { 92 | return self::tryFrom($type); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /app/Providers/Telescope/Watchers/RedisWatcher.php: -------------------------------------------------------------------------------- 1 | bound('redis')) { 18 | return; 19 | } 20 | 21 | $app['events']->listen(CommandExecuted::class, [$this, 'recordCommand']); 22 | 23 | foreach ((array) $app['redis']->connections() as $connection) { 24 | $connection->setEventDispatcher($app['events']); 25 | } 26 | 27 | $app['redis']->enableEvents(); 28 | } 29 | 30 | /** 31 | * Record a Redis command was executed. 32 | * 33 | * @param CommandExecuted $event 34 | * @return void 35 | */ 36 | public function recordCommand(CommandExecuted $event): void 37 | { 38 | if (! Telescope::isRecording() || $this->shouldIgnore($event)) { 39 | return; 40 | } 41 | 42 | Telescope::record(IncomingEntry::make([ 43 | 'connection' => $event->connectionName, 44 | 'command' => $this->formatCommand($event->command, $event->parameters), 45 | 'time' => number_format($event->time, 2, '.', ''), 46 | ], EntryType::REDIS)); 47 | } 48 | 49 | /** 50 | * Format the given Redis command. 51 | */ 52 | private function formatCommand(string $command, array $parameters): string 53 | { 54 | $parameters = collect($parameters)->map(function ($parameter) { 55 | if (is_array($parameter)) { 56 | return collect($parameter)->map(function ($value, $key) { 57 | if (is_array($value)) { 58 | return json_encode($value); 59 | } 60 | 61 | return is_int($key) ? $value : "{$key} {$value}"; 62 | })->implode(' '); 63 | } 64 | 65 | return $parameter; 66 | })->implode(' '); 67 | 68 | return "{$command} {$parameters}"; 69 | } 70 | 71 | /** 72 | * Determine if the event should be ignored. 73 | */ 74 | private function shouldIgnore(mixed $event): bool 75 | { 76 | return in_array($event->command, [ 77 | 'pipeline', 'transaction', 78 | ]); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | xinadmin 3 |

4 |

Xin Admin

5 |

企业级 PHP 全栈快速开发框架

6 |

7 | 8 | xinadmin 9 | 10 | 11 | php 12 | 13 | 14 | laravel 15 | 16 | 17 | React 18 | 19 | 20 | UmiJs 21 | 22 | 23 | UmiJs 24 | 25 | 26 | license 27 | 28 |

29 | 30 | 31 | 基于 PHP8.2 + Laravel12 + Mysql + React + TypeScript + UmiJs + Ant Design 等前沿技术栈开发的全栈开发框架,提供按钮级权限验证、动态菜单、用户分组权限、数据字典、系统配置、文件管理系统、AI模块等便捷开发, 32 | 遵循 Apache License 无需授权即可免费商用. 33 | 34 | 35 | 36 | ## 主要特征 37 | 38 | ### ✨ 前后端分离 39 | 采用Api接口规范,前后端分离开发模式,后端不必考虑视图UI功能,前端web目录不包含任何后端代码。 40 | 41 | ### 🎨 React 技术栈 42 | 阿里 Umi Js 以及 AntdPro 组件库,不仅简单易用,并且可以是你的技术更上一层楼,带你体验技术的革新,站在巨人肩膀上享受开发的便捷和乐趣。 43 | 44 | ### 📟 权限控制系统 45 | 我们提供了完善的权限验证系统,支持客户端、管理端,双动态菜单,页面按钮级权限控制,使用PHP8注解验证精确控制接口请求,支持分组权限禁用继承。 46 | 47 | ### ♻️ 数据字典和全局设置 48 | 强大的数据字典,支持CRUD生成,value、label 映射,支持标签、文字、徽标三种表格展示类型,多种显示状态,还有方便的系统配置。 49 | 50 | ### 🎁 文件管理系统 51 | XinAdmin 拥有强大的文件系统,可拓展 AliOss 存储 支持多选、文件分组等,支持图片、视频、音频、压缩文件和其它文件上传 52 | 53 | ## 内置功能 54 | 55 | - 仪表盘:提供基于 antv 开箱即用的仪表盘方案,以及演示页面 56 | - 示例组件:包含图标、表格、列表、表单等组件的示例 57 | - 前台会员:前台会员的权限管理、分组和列表以及余额记录等 58 | - 管理员:管理员是后台系统的访问者,提供管理员分组、权限、列表以及管理员信息设置 59 | - 系统设置:系统设置是对服务器可变参数快速设置的表单,可以自定义分组以及表单类型 60 | - 文件管理:文件上传解决方案,可拓展 AliOss 存储,后台文件管理文件夹,支持多选、文件分组等,支持图片、视频、音频、压缩文件和其它文件上传 61 | - 字典管理:对系统中经常使用的一些较为固定的数据进行维护 62 | 63 | ## 其他 64 | - 接口文档:[https://api.xinadmin.cn/](https://api.xinadmin.cn/) 65 | - 演示站:[https://laravel.xinadmin.cn/](https://laravel.xinadmin.cn/) -------------------------------------------------------------------------------- /app/Http/Controllers/Sys/SysUserController.php: -------------------------------------------------------------------------------- 1 | login($request); 31 | } 32 | 33 | /** 退出登录 */ 34 | #[PostMapping('/logout')] 35 | public function logout(Request $request): JsonResponse 36 | { 37 | $request->user()->currentAccessToken()->delete(); 38 | return $this->success(__('user.logout_success')); 39 | } 40 | 41 | /** 获取管理员信息 */ 42 | #[GetMapping('/info')] 43 | public function info(SysUserService $service): JsonResponse 44 | { 45 | $info = Auth::user(); 46 | $id = Auth::id(); 47 | $access = $service->ruleKeys($id); 48 | $menus = $service->getAdminMenus($id); 49 | return $this->success(compact('access', 'menus', 'info')); 50 | } 51 | 52 | /** 更新管理员信息 */ 53 | #[PutMapping] 54 | public function updateInfo(Request $request, SysUserService $service): JsonResponse 55 | { 56 | return $service->updateInfo(Auth::id(), $request); 57 | } 58 | 59 | /** 修改密码 */ 60 | #[PutMapping('/updatePassword')] 61 | public function updatePassword(Request $request, SysUserService $service): JsonResponse 62 | { 63 | return $service->updatePassword($request); 64 | } 65 | 66 | /** 上传头像 */ 67 | #[PostMapping('/avatar')] 68 | public function uploadAvatar(SysUserService $service): JsonResponse 69 | { 70 | return $service->uploadAvatar(); 71 | } 72 | 73 | /** 获取管理员登录日志 */ 74 | #[GetMapping('/login/record')] 75 | public function get(SysLoginRecordService $service): JsonResponse 76 | { 77 | $id = Auth::id(); 78 | $data = $service->getRecordByID($id); 79 | return $this->success($data); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel/laravel", 3 | "type": "project", 4 | "description": "The skeleton application for the Laravel framework.", 5 | "keywords": ["laravel", "framework"], 6 | "license": "MIT", 7 | "require": { 8 | "php": "^8.2", 9 | "ext-bcmath": "*", 10 | "ext-curl": "*", 11 | "ext-pdo": "*", 12 | "ext-redis": "*", 13 | "laravel/framework": "^v12.0", 14 | "laravel/sanctum": "^4.0", 15 | "laravel/tinker": "^2.10.1", 16 | "league/flysystem-aws-s3-v3": "3.0", 17 | "predis/predis": "2.0" 18 | }, 19 | "require-dev": { 20 | "fakerphp/faker": "^1.23", 21 | "laravel/pail": "^1.2.2", 22 | "laravel/sail": "^1.41", 23 | "mockery/mockery": "^1.6", 24 | "nunomaduro/collision": "^8.6", 25 | "phpunit/phpunit": "^11.5.3" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "App\\": "app/", 30 | "Database\\Seeders\\": "database/seeders/" 31 | }, 32 | "files": [ 33 | "app/Support/helpers.php" 34 | ] 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "Tests\\": "tests/" 39 | } 40 | }, 41 | "scripts": { 42 | "dev": [ 43 | "Composer\\Config::disableProcessTimeout", 44 | "npx concurrently -c \"#93c5fd,#c4b5fd,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" --names='server,queue'" 45 | ], 46 | "post-create-project-cmd": [ 47 | "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"", 48 | "@php artisan key:generate --ansi", 49 | "@php artisan migrate --graceful --ansi", 50 | "@php artisan db:seed --force" 51 | ], 52 | "post-autoload-dump": [ 53 | "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", 54 | "@php artisan package:discover --ansi" 55 | ], 56 | "post-update-cmd": [ 57 | "@php artisan vendor:publish --tag=laravel-assets --ansi --force" 58 | ] 59 | }, 60 | "extra": { 61 | "laravel": { 62 | "dont-discover": [] 63 | } 64 | }, 65 | "replace": { 66 | "symfony/polyfill-php80": "*" 67 | }, 68 | "config": { 69 | "optimize-autoloader": true, 70 | "preferred-install": "dist", 71 | "sort-packages": true, 72 | "allow-plugins": { 73 | "pestphp/pest-plugin": true, 74 | "php-http/discovery": true 75 | } 76 | }, 77 | "minimum-stability": "stable", 78 | "prefer-stable": true 79 | } 80 | -------------------------------------------------------------------------------- /app/Providers/Telescope/Storage/StorageRepository.php: -------------------------------------------------------------------------------- 1 | type. '-' . $options->date . '.jsonl'; 16 | $storage = Storage::disk('telescope'); 17 | if (!$storage->exists($fileName)) { 18 | return []; 19 | } 20 | $filePath = storage_path('telescope/' . $fileName); 21 | 22 | $collection = LazyCollection::make(function () use ($filePath) { 23 | $file = fopen($filePath, 'r'); 24 | 25 | while (($line = fgets($file)) !== false) { 26 | yield json_decode($line, true); 27 | } 28 | fclose($file); 29 | }); 30 | 31 | return [ 32 | 'data' => $collection->forPage($options->page, $options->limit)->values()->all(), 33 | 'total' => $collection->count(), 34 | 'pageSize' => $options->limit, 35 | 'current' => $options->page, 36 | ]; 37 | } 38 | 39 | public function store(array|Collection $entries): void 40 | { 41 | if ($entries->isEmpty()) { 42 | return; 43 | } 44 | $date = date('Y-m-d'); 45 | $contents = []; 46 | foreach (config('telescope.watchers') as $key => $watcher) { 47 | if (! is_string($key)) continue; 48 | if( $watcher === false ) continue; 49 | $contents[$key] = []; 50 | } 51 | $entries->each(function (IncomingEntry $chunked) use (&$contents) { 52 | if(empty($chunked->content)) { 53 | return; 54 | } 55 | $contents[$chunked->type][] = $chunked->getContentAsString(); 56 | }); 57 | $storage = Storage::disk('telescope'); 58 | foreach ($contents as $type => $entries) { 59 | if (empty($entries)) { 60 | continue; 61 | } 62 | $fileName = $type. '-' . $date . '.jsonl'; 63 | // 文件不存在创建文件 64 | if (!$storage->exists($fileName)) { 65 | $storage->put($fileName, implode("\n", $entries)); 66 | continue; 67 | } 68 | $storage->append($fileName, implode("\n", $entries)); 69 | } 70 | } 71 | 72 | public function clear(): void 73 | { 74 | // TODO: Implement clear() method. 75 | } 76 | } -------------------------------------------------------------------------------- /app/Repositories/Sys/SysFileRepository.php: -------------------------------------------------------------------------------- 1 | '=', 13 | 'name' => 'like', 14 | 'file_type' => '=', 15 | ]; 16 | 17 | /** 18 | * @inheritDoc 19 | */ 20 | protected function model(): Builder 21 | { 22 | return SysFileModel::query(); 23 | } 24 | 25 | protected function rules(): array 26 | { 27 | return [ 28 | 'group_id' => 'required|integer|exists:sys_file_group,id', 29 | 'channel' => 'required|integer|in:10,20', 30 | 'disk' => 'required|string|max:10', 31 | 'file_type' => 'required|integer', 32 | 'file_name' => 'required|string|max:255', 33 | 'file_path' => 'required|string|max:255', 34 | 'file_size' => 'required|integer|min:0', 35 | 'file_ext' => 'required|string|max:20', 36 | 'uploader_id' => 'required|integer', 37 | ]; 38 | } 39 | 40 | protected function messages(): array 41 | { 42 | return [ 43 | // 文件表验证消息 44 | 'group_id.required' => '文件分组ID不能为空', 45 | 'group_id.integer' => '文件分组ID必须是整数', 46 | 'group_id.exists' => '文件分组不存在', 47 | 48 | 'channel.required' => '上传来源不能为空', 49 | 'channel.integer' => '上传来源必须是整数', 50 | 'channel.in' => '上传来源必须是10(系统用户)或20(App用户端)', 51 | 52 | 'disk.required' => '存储方式不能为空', 53 | 'disk.string' => '存储方式必须是字符串', 54 | 'disk.max' => '存储方式不能超过10个字符', 55 | 56 | 'file_type.required' => '文件类型不能为空', 57 | 'file_type.integer' => '文件类型必须是整数', 58 | 59 | 'file_name.required' => '文件名称不能为空', 60 | 'file_name.string' => '文件名称必须是字符串', 61 | 'file_name.max' => '文件名称不能超过255个字符', 62 | 63 | 'file_path.required' => '文件路径不能为空', 64 | 'file_path.string' => '文件路径必须是字符串', 65 | 'file_path.max' => '文件路径不能超过255个字符', 66 | 67 | 'file_size.required' => '文件大小不能为空', 68 | 'file_size.integer' => '文件大小必须是整数', 69 | 'file_size.min' => '文件大小不能为负数', 70 | 71 | 'file_ext.required' => '文件扩展名不能为空', 72 | 'file_ext.string' => '文件扩展名必须是字符串', 73 | 'file_ext.max' => '文件扩展名不能超过20个字符', 74 | 75 | 'uploader_id.required' => '上传者用户ID不能为空', 76 | 'uploader_id.integer' => '上传者用户ID必须是整数', 77 | ]; 78 | } 79 | 80 | public function getTrashedList(array $params = []): array 81 | { 82 | $pageSize = $params['pageSize'] ?? 10; 83 | return $this->model() 84 | ->onlyTrashed() 85 | ->paginate($pageSize) 86 | ->toArray(); 87 | } 88 | } -------------------------------------------------------------------------------- /app/Repositories/Sys/SysSettingItemsRepository.php: -------------------------------------------------------------------------------- 1 | '=' ]; 14 | 15 | protected function rules(): array 16 | { 17 | return [ 18 | 'title' => 'required', 19 | 'key' => 'required|min:2|max:255', 20 | 'group_id' => 'required|exists:sys_setting_group,id', 21 | 'type' => 'required', 22 | 'describe' => 'sometimes|string', 23 | 'options' => [ 24 | 'sometimes', 25 | 'nullable', 26 | 'string', 27 | 'regex:/^(?:[^=\n]+=[^=\n]+)(?:\n[^=\n]+=[^=\n]+)*$/', 28 | ], 29 | 'props' => [ 30 | 'sometimes', 31 | 'nullable', 32 | 'string', 33 | 'regex:/^(?:[^=\n]+=[^=\n]+)(?:\n[^=\n]+=[^=\n]+)*$/', 34 | ], 35 | 'sort' => 'sometimes|integer', 36 | 'values' => 'sometimes|string', 37 | ]; 38 | } 39 | 40 | protected function messages(): array 41 | { 42 | return [ 43 | 'title.required' => '标题字段是必填的', 44 | 'key.required' => '键名字段是必填的', 45 | 'key.min' => '键名至少需要 :min 个字符', 46 | 'key.max' => '键名不能超过 :max 个字符', 47 | 'group_id.required' => '分组ID是必填的', 48 | 'group_id.exists' => '选择的分组不存在', 49 | 'type.required' => '类型字段是必填的', 50 | 'describe.string' => '描述必须是字符串', 51 | 'options.regex' => '选项格式不正确,应为 key=value 格式,多个用换行分隔', 52 | 'props.regex' => '属性格式不正确,应为 key=value 格式,多个用换行分隔', 53 | 'sort.integer' => '排序必须是整数', 54 | 'values.string' => '值必须是字符串', 55 | ]; 56 | } 57 | 58 | /** 验证数据 */ 59 | protected function validation(array $data): array 60 | { 61 | $data = parent::validation($data); 62 | $num = $this->model()->where('group_id', $data['group_id'])->where('key', $data['key'])->count(); 63 | if($num > 0 && request()->method() != 'PUT') { 64 | throw new RepositoryException( 65 | 'Validation failed: ' . '该键名在此分组中已存在', 66 | ); 67 | } 68 | return $data; 69 | } 70 | 71 | /** 72 | * @inheritDoc 73 | */ 74 | protected function model(): Builder 75 | { 76 | return SysSettingItemsModel::query(); 77 | } 78 | 79 | public function list(array $params): array 80 | { 81 | if(empty($params['group_id'])) { 82 | throw new RepositoryException('请选择设置分组'); 83 | } 84 | return $this->model() 85 | ->where('group_id', $params['group_id']) 86 | ->get() 87 | ->toArray(); 88 | } 89 | } -------------------------------------------------------------------------------- /app/Services/Sys/SysUserDeptService.php: -------------------------------------------------------------------------------- 1 | get()->toArray(); 22 | $data = $this->getTreeData($data); 23 | 24 | return $this->success($data); 25 | } 26 | 27 | /** 28 | * 批量删除部门 29 | * @param Request $request 30 | * @return JsonResponse 31 | */ 32 | public function delete(Request $request): JsonResponse 33 | { 34 | $request->validate([ 35 | 'ids' => 'required|array', 36 | 'ids.*' => 'integer|exists:sys_dept,id' 37 | ]); 38 | 39 | $ids = $request->input('ids'); 40 | 41 | // 检查是否有下级部门 42 | $departmentsWithChildren = SysDeptModel::whereIn('id', $ids) 43 | ->whereHas('children') 44 | ->get(); 45 | 46 | if ($departmentsWithChildren->isNotEmpty()) { 47 | return $this->error('存在下级部门的部门无法删除'); 48 | } 49 | 50 | // 执行删除 51 | SysDeptModel::whereIn('id', $ids)->delete(); 52 | 53 | return $this->success('部门删除成功'); 54 | } 55 | 56 | /** 57 | * 获取部门用户列表 58 | * @param int $id 59 | * @return JsonResponse 60 | */ 61 | public function users(int $id): JsonResponse 62 | { 63 | $model = SysDeptModel::query()->find($id); 64 | if (empty($model)) { 65 | return $this->error('部门不存在'); 66 | } 67 | $pageSize = request()->input('pageSize') ?? 10; 68 | $data = $model->users() 69 | ->select(['id', 'username', 'nickname', 'email', 'mobile', 'status']) 70 | ->paginate($pageSize) 71 | ->toArray(); 72 | return $this->success($data); 73 | } 74 | 75 | 76 | /** 获取部门选择项 */ 77 | public function getDeptField(): array 78 | { 79 | $field = SysDeptModel::where('status', 0) 80 | ->select(['id as dept_id', 'name', 'parent_id']) 81 | ->get() 82 | ->toArray(); 83 | return $this->buildTree($field); 84 | } 85 | 86 | /** 构建树形结构 */ 87 | private function buildTree(array $items, $parentId = 0): array 88 | { 89 | $tree = []; 90 | foreach ($items as $item) { 91 | if ($item['parent_id'] == $parentId) { 92 | $children = $this->buildTree($items, $item['dept_id']); 93 | $node = [ 94 | 'dept_id' => $item['dept_id'], 95 | 'name' => $item['name'], 96 | 'children' => $children 97 | ]; 98 | $tree[] = $node; 99 | } 100 | } 101 | return $tree; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /app/Models/Sys/SysUserModel.php: -------------------------------------------------------------------------------- 1 | 'datetime', 42 | 'login_time' => 'datetime', 43 | 'status' => 'integer', 44 | 'sex' => 'integer', 45 | 'avatar_id' => 'integer', 46 | 'dept_id' => 'integer' 47 | ]; 48 | 49 | protected $appends = ['role_id', 'dept_name', 'avatar_url']; 50 | 51 | protected $with = ['dept', 'avatar']; 52 | 53 | protected $hidden = [ 54 | 'dept', 55 | 'avatar', 56 | 'password', 57 | 'remember_token', 58 | 'deleted_at', 59 | ]; 60 | 61 | /** 62 | * 定义与部门的归属关系 63 | */ 64 | public function dept(): BelongsTo 65 | { 66 | return $this->belongsTo(SysDeptModel::class, 'dept_id', 'id'); 67 | } 68 | 69 | /** 部门名称 */ 70 | public function getDeptNameAttribute(): string 71 | { 72 | return $this->dept->name ?? ''; 73 | } 74 | 75 | /** 76 | * 定义与角色的关联 77 | */ 78 | public function roles(): BelongsToMany 79 | { 80 | return $this->belongsToMany(SysRoleModel::class, 'sys_user_role', 'user_id', 'role_id'); 81 | } 82 | 83 | /** 84 | * 获取用户角色列表 85 | */ 86 | public function getRoleIdAttribute() 87 | { 88 | return $this->roles() 89 | ->pluck('id')->toArray(); 90 | } 91 | 92 | /** 93 | * 定义与登录日志的关联 94 | */ 95 | public function loginRecords(): HasMany 96 | { 97 | return $this->hasMany(SysLoginRecordModel::class, 'user_id', 'id'); 98 | } 99 | 100 | /** 101 | * 关联用户头像 102 | * @return HasOne 103 | */ 104 | public function avatar(): HasOne 105 | { 106 | return $this->hasOne(SysFileModel::class, 'id', 'avatar_id'); 107 | } 108 | 109 | /** 110 | * 获取用户角色列表 111 | */ 112 | public function getAvatarUrlAttribute() 113 | { 114 | if($this->avatar) { 115 | return $this->avatar->preview_url; 116 | } 117 | return null; 118 | } 119 | 120 | 121 | 122 | } 123 | -------------------------------------------------------------------------------- /app/Models/Sys/SysFileModel.php: -------------------------------------------------------------------------------- 1 | 'int', 24 | 'channel' => 'int', 25 | 'file_type' => 'int', 26 | 'file_size' => 'int', 27 | 'uploader_id' => 'int', 28 | ]; 29 | 30 | protected $fillable = [ 31 | 'group_id', 32 | 'disk', 33 | 'channel', 34 | 'file_type', 35 | 'file_name', 36 | 'file_path', 37 | 'file_size', 38 | 'file_ext', 39 | 'uploader_id', 40 | ]; 41 | 42 | protected $appends = ['preview_url', 'file_url']; 43 | 44 | /** 45 | * 获取文件所属分组 46 | */ 47 | public function group(): BelongsTo 48 | { 49 | return $this->belongsTo(SysFileGroupModel::class, 'group_id', 'id'); 50 | } 51 | 52 | /** 53 | * 获取上传者 54 | * 根据channel字段判断是系统用户还是App用户 55 | */ 56 | public function uploader(): BelongsTo 57 | { 58 | // channel 10:系统用户 20:App用户端 59 | if ($this->channel == 10) { 60 | return $this->belongsTo(SysUserModel::class, 'uploader_id', 'id'); 61 | } else { 62 | return $this->belongsTo(UserModel::class, 'uploader_id', 'id'); 63 | } 64 | } 65 | 66 | protected function previewUrl(): Attribute 67 | { 68 | return new Attribute( 69 | get: function ($value, array $data) { 70 | try { 71 | $fileType = FileType::tryFrom($data['file_type']); 72 | 73 | // 图片类型:直接返回图片URL作为预览 74 | if ($fileType === FileType::IMAGE) { 75 | return Storage::disk($data['disk'])->url($data['file_path']); 76 | } 77 | 78 | // 其他类型:返回默认类型图标 79 | $previewPath = $fileType?->previewPath() ?? FileType::ANNEX->previewPath(); 80 | return config('app.url') . '/' . $previewPath; 81 | 82 | } catch (\Throwable $e) { 83 | // 发生异常时返回默认图标 84 | return config('app.url') . '/' . FileType::ANNEX->previewPath(); 85 | } 86 | } 87 | ); 88 | } 89 | 90 | /** 91 | * 获取文件访问URL 92 | */ 93 | protected function fileUrl(): Attribute 94 | { 95 | return new Attribute( 96 | get: function ($value, array $data) { 97 | try { 98 | return Storage::disk($data['disk'])->url($data['file_path']); 99 | } catch (\Throwable $e) { 100 | return null; 101 | } 102 | } 103 | ); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /config/sanctum.php: -------------------------------------------------------------------------------- 1 | explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( 19 | '%s%s', 20 | 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', 21 | Sanctum::currentApplicationUrlWithPort() 22 | ))), 23 | 24 | /* 25 | |-------------------------------------------------------------------------- 26 | | Sanctum Guards 27 | |-------------------------------------------------------------------------- 28 | | 29 | | This array contains the authentication guards that will be checked when 30 | | Sanctum is trying to authenticate a request. If none of these guards 31 | | are able to authenticate the request, Sanctum will use the bearer 32 | | token that's present on an incoming request for authentication. 33 | | 34 | */ 35 | 36 | 'guard' => ['web'], 37 | 38 | /* 39 | |-------------------------------------------------------------------------- 40 | | Expiration Minutes 41 | |-------------------------------------------------------------------------- 42 | | 43 | | This value controls the number of minutes until an issued token will be 44 | | considered expired. This will override any values set in the token's 45 | | "expires_at" attribute, but first-party sessions are not affected. 46 | | 47 | */ 48 | 49 | 'expiration' => null, 50 | 51 | /* 52 | |-------------------------------------------------------------------------- 53 | | Token Prefix 54 | |-------------------------------------------------------------------------- 55 | | 56 | | Sanctum can prefix new tokens in order to take advantage of numerous 57 | | security scanning initiatives maintained by open source platforms 58 | | that notify developers if they commit tokens into repositories. 59 | | 60 | | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning 61 | | 62 | */ 63 | 64 | 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), 65 | 66 | /* 67 | |-------------------------------------------------------------------------- 68 | | Sanctum Middleware 69 | |-------------------------------------------------------------------------- 70 | | 71 | | When authenticating your first-party SPA with Sanctum you may need to 72 | | customize some of the middleware Sanctum uses while processing the 73 | | request. You may change the middleware listed below as required. 74 | | 75 | */ 76 | 77 | 'middleware' => [ 78 | 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, 79 | 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, 80 | 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, 81 | ], 82 | 83 | ]; 84 | -------------------------------------------------------------------------------- /app/Repositories/Sys/SysDeptRepository.php: -------------------------------------------------------------------------------- 1 | '=', 17 | 'status' => '=' 18 | ]; 19 | 20 | /** @var array|string[] 快速搜索字段 */ 21 | protected array $quickSearchField = ['name', 'leader', 'phone']; 22 | 23 | /** 24 | * @inheritDoc 25 | */ 26 | protected function model(): Builder 27 | { 28 | return SysDeptModel::query(); 29 | } 30 | 31 | protected function rules(): array 32 | { 33 | if (! $this->isUpdate()) { 34 | return [ 35 | 'name' => 'required|unique:sys_dept,name', 36 | 'code' => 'required|unique:sys_dept,code', 37 | 'type' => 'required|integer|in:0,1,2', 38 | 'parent_id' => [ 39 | 'required', 40 | 'integer', 41 | function ($attribute, $value, $fail) { 42 | if ($value != 0 && !DB::table('sys_dept')->where('id', $value)->exists()) { 43 | $fail('选择的上级部门不存在。'); 44 | } 45 | }, 46 | ], 47 | 'sort' => 'required|integer', 48 | 'phone' => 'nullable', 49 | 'address' => 'nullable', 50 | 'email' => 'nullable|email', 51 | 'status' => 'required|in:0,1', 52 | 'remark' => 'nullable', 53 | ]; 54 | } else { 55 | $id = request()->route('id'); 56 | return [ 57 | 'name' => [ 58 | 'required', 59 | Rule::unique('sys_dept', 'name')->ignore($id) 60 | ], 61 | 'code' => [ 62 | 'required', 63 | Rule::unique('sys_dept', 'code')->ignore($id) 64 | ], 65 | 'type' => 'required|integer|in:0,1,2', 66 | 'sort' => 'required|integer', 67 | 'phone' => 'nullable', 68 | 'address' => 'nullable', 69 | 'email' => 'nullable|email', 70 | 'status' => 'required|in:0,1', 71 | 'remark' => 'nullable', 72 | ]; 73 | } 74 | } 75 | 76 | protected function messages(): array 77 | { 78 | return [ 79 | 'name.required' => '部门名称不能为空', 80 | 'name.unique' => '部门名称已存在', 81 | 'code.required' => '部门编码不能为空', 82 | 'code.unique' => '部门编码已存在', 83 | 'type.required' => '部门类型不能为空', 84 | 'type.integer' => '部门类型必须是整数', 85 | 'type.in' => '部门类型错误', 86 | 'parent_id.required' => '上级部门不能为空', 87 | 'parent_id.integer' => '上级部门ID必须是整数', 88 | 'parent_id.exists' => '选择的上级部门不存在', 89 | 'sort.required' => '排序字段不能为空', 90 | 'sort.integer' => '排序字段必须是整数', 91 | 'email.email' => '请输入有效的邮箱地址', 92 | 'status.required' => '状态不能为空', 93 | 'status.in' => '状态类型错误', 94 | ]; 95 | } 96 | } -------------------------------------------------------------------------------- /config/telescope.php: -------------------------------------------------------------------------------- 1 | env('TELESCOPE_ENABLED', true), 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Telescope Domain. 监控可访问的域名,为空时监控所有 23 | |-------------------------------------------------------------------------- 24 | | 25 | | This is the subdomain where Telescope will be accessible from. If the 26 | | setting is null, Telescope will reside under the same domain as the 27 | | application. Otherwise, this value will be used as the subdomain. 28 | | 29 | */ 30 | 31 | 'domain' => env('TELESCOPE_DOMAIN'), 32 | 33 | /* 34 | |-------------------------------------------------------------------------- 35 | | Allowed / Ignored Paths & Commands . 监控地址白名单 36 | |-------------------------------------------------------------------------- 37 | | 38 | | The following array lists the URI paths and Artisan commands that will 39 | | not be watched by Telescope. In addition to this list, some Laravel 40 | | commands. 41 | | 42 | */ 43 | 44 | 'only_paths' => [ 45 | // 'api/*' 46 | ], 47 | 48 | 'ignore_paths' => [ 49 | 'livewire*', 50 | 'nova-api*', 51 | 'pulse*', 52 | ], 53 | 54 | /* 55 | |-------------------------------------------------------------------------- 56 | | Telescope Watchers 监控器 57 | |-------------------------------------------------------------------------- 58 | | 59 | | The following array lists the "watchers" that will be registered with 60 | | Telescope. The watchers gather the application's profile data when 61 | | a request or task is executed. Feel free to customize this list. 62 | | 63 | */ 64 | 65 | 'watchers' => [ 66 | 67 | \App\Providers\Telescope\Watchers\CacheWatcher::class => [ 68 | 'enabled' => env('TELESCOPE_CACHE_WATCHER', true), 69 | 'hidden' => [], 70 | ], 71 | 72 | \App\Providers\Telescope\Watchers\QueryWatcher::class => [ 73 | 'enabled' => env('TELESCOPE_QUERY_WATCHER', true), 74 | 'ignore_packages' => true, 75 | 'ignore_paths' => [], 76 | 'slow' => 100, 77 | ], 78 | 79 | \App\Providers\Telescope\Watchers\RedisWatcher::class => env('TELESCOPE_REDIS_WATCHER', true), 80 | 81 | \App\Providers\Telescope\Watchers\RequestWatcher::class => [ 82 | 'enabled' => env('TELESCOPE_REQUEST_WATCHER', true), 83 | 'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 10), 84 | 'ignore_http_methods' => [ 85 | 'OPTIONS' 86 | ], 87 | 'ignore_status_codes' => [], 88 | 'ignore_http_path' => [ 89 | 'system/watcher*', 90 | '*login', 91 | '*logout', 92 | ], 93 | ], 94 | ], 95 | ]; 96 | -------------------------------------------------------------------------------- /app/Providers/AutoBindServiceProvider.php: -------------------------------------------------------------------------------- 1 | 'App\\Repositories', 17 | 'Services' => 'App\\Services', 18 | ]; 19 | 20 | /** 21 | * 要排除的基类 22 | */ 23 | protected array $excludeClasses = [ 24 | 'App\\Repositories\\BaseRepository', // 仓储基类 25 | 'App\\Services\\BaseService', // 服务基类 26 | ]; 27 | 28 | public function register(): void 29 | { 30 | foreach ($this->scanConfig as $directory => $namespace) { 31 | $classes = $this->scanClasses($directory, $namespace); 32 | 33 | foreach ($classes as $class) { 34 | if ($this->shouldBind($class)) { 35 | $this->app->bind($class, $class); 36 | // 如果需要单例模式,使用:$this->app->singleton($class, $class); 37 | } 38 | } 39 | } 40 | } 41 | 42 | /** 43 | * 扫描目录获取所有类 44 | */ 45 | protected function scanClasses(string $directory, string $namespace): array 46 | { 47 | $absolutePath = app_path($directory); 48 | 49 | if (!is_dir($absolutePath)) { 50 | return []; 51 | } 52 | 53 | $classes = []; 54 | $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($absolutePath)); 55 | 56 | $regex = new RegexIterator($iterator, '/^.+\.php$/i', RegexIterator::GET_MATCH); 57 | 58 | foreach ($regex as $file) { 59 | $filePath = $file[0]; 60 | $relativePath = str_replace([$absolutePath, '.php'], '', $filePath); 61 | $relativePath = trim($relativePath, DIRECTORY_SEPARATOR); 62 | 63 | $className = str_replace( 64 | DIRECTORY_SEPARATOR, 65 | '\\', 66 | $namespace . '\\' . $relativePath 67 | ); 68 | 69 | if (class_exists($className)) { 70 | $classes[] = $className; 71 | } 72 | } 73 | 74 | return $classes; 75 | } 76 | 77 | /** 78 | * 判断是否应该绑定该类 79 | */ 80 | protected function shouldBind(string $className): bool 81 | { 82 | // 排除抽象类 83 | if ((new \ReflectionClass($className))->isAbstract()) { 84 | return false; 85 | } 86 | 87 | // 排除基类 88 | foreach ($this->excludeClasses as $exclude) { 89 | if (str_contains($className, $exclude) || class_basename($className) === $exclude) { 90 | return false; 91 | } 92 | } 93 | 94 | return true; 95 | } 96 | 97 | /** 98 | * 获取已绑定的类列表(用于调试) 99 | */ 100 | public function getBoundClasses(): array 101 | { 102 | $boundClasses = []; 103 | 104 | foreach ($this->scanConfig as $directory => $namespace) { 105 | $classes = $this->scanClasses($directory, $namespace); 106 | foreach ($classes as $class) { 107 | if ($this->shouldBind($class)) { 108 | $boundClasses[] = $class; 109 | } 110 | } 111 | } 112 | 113 | return $boundClasses; 114 | } 115 | } -------------------------------------------------------------------------------- /app/Providers/Telescope/IncomingEntry.php: -------------------------------------------------------------------------------- 1 | type = $type; 30 | 31 | $this->content = array_merge($content, [ 32 | 'host_name' => gethostname(), 33 | 'recorded_at' => now()->toDateTimeString() 34 | ]); 35 | } 36 | 37 | /** 38 | * Create a new entry instance. 39 | */ 40 | public static function make(array $content, string $type): static 41 | { 42 | return new static($content, $type); 43 | } 44 | 45 | /** 46 | * Set the currently authenticated user. 47 | */ 48 | public function user(Authenticatable $user): static 49 | { 50 | $this->user = $user; 51 | 52 | $this->content = array_merge($this->content, [ 53 | 'user' => [ 54 | 'id' => $user->getAuthIdentifier(), 55 | 'name' => $user->username ?? null, 56 | 'email' => $user->email ?? null, 57 | 'avatar' => $user->avatarUrl ?? null, 58 | ], 59 | ]); 60 | 61 | return $this; 62 | } 63 | 64 | /** 65 | * Set the entry's type. 66 | */ 67 | public function type(string $type): static 68 | { 69 | $this->type = $type; 70 | 71 | return $this; 72 | } 73 | 74 | /** 75 | * Determine if the incoming entry is a cache entry. 76 | */ 77 | public function isCache(): bool 78 | { 79 | return $this->type === EntryType::CACHE; 80 | } 81 | 82 | /** 83 | * Determine if the incoming entry is a auth. 84 | */ 85 | public function isAuth(): bool 86 | { 87 | return $this->type === EntryType::AUTH; 88 | } 89 | 90 | /** 91 | * Determine if the incoming entry is a query. 92 | */ 93 | public function isQuery(): bool 94 | { 95 | return $this->type === EntryType::QUERY; 96 | } 97 | 98 | /** 99 | * Determine if the incoming entry is a slow query. 100 | */ 101 | public function isSlowQuery(): bool 102 | { 103 | return $this->type === EntryType::QUERY && ($this->content['slow'] ?? false); 104 | } 105 | 106 | /** 107 | * Determine if the incoming entry is a redis. 108 | */ 109 | public function isRedis(): bool 110 | { 111 | return $this->type === EntryType::REDIS; 112 | } 113 | 114 | /** 115 | * Determine if the incoming entry is a request. 116 | */ 117 | public function isRequest(): bool 118 | { 119 | return $this->type === EntryType::REQUEST; 120 | } 121 | 122 | /** 123 | * Determine if the incoming entry is a failed request. 124 | */ 125 | public function isFailedRequest(): bool 126 | { 127 | return $this->type === EntryType::REQUEST && 128 | ($this->content['response_status'] ?? 200) >= 500; 129 | } 130 | 131 | /** 132 | * Get the entry's content as a string. 133 | */ 134 | public function getContentAsString(): string 135 | { 136 | return json_encode($this->content, JSON_INVALID_UTF8_SUBSTITUTE); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /app/Providers/Telescope/Watchers/QueryWatcher.php: -------------------------------------------------------------------------------- 1 | listen(QueryExecuted::class, [$this, 'recordQuery']); 20 | } 21 | 22 | /** 23 | * Record a query was executed. 24 | * 25 | * @param QueryExecuted $event 26 | * @return void 27 | */ 28 | public function recordQuery(QueryExecuted $event): void 29 | { 30 | if (! Telescope::isRecording()) { 31 | return; 32 | } 33 | 34 | $time = $event->time; 35 | 36 | if ($caller = $this->getCallerFromStackTrace()) { 37 | Telescope::record(IncomingEntry::make([ 38 | 'connection' => $event->connectionName, 39 | 'sql' => $this->replaceBindings($event), 40 | 'time' => number_format($time, 2, '.', ''), 41 | 'slow' => isset($this->options['slow']) && $time >= $this->options['slow'], 42 | 'file' => $caller['file'], 43 | 'line' => $caller['line'], 44 | ], EntryType::QUERY)); 45 | } 46 | } 47 | 48 | /** 49 | * Calculate the family look-up hash for the query event. 50 | */ 51 | public function familyHash(QueryExecuted $event): string 52 | { 53 | return md5($event->sql); 54 | } 55 | 56 | /** 57 | * Format the given bindings to strings. 58 | */ 59 | protected function formatBindings(QueryExecuted $event): array 60 | { 61 | return $event->connection->prepareBindings($event->bindings); 62 | } 63 | 64 | /** 65 | * Replace the placeholders with the actual bindings. 66 | */ 67 | public function replaceBindings(QueryExecuted $event): string 68 | { 69 | $sql = $event->sql; 70 | 71 | foreach ($this->formatBindings($event) as $key => $binding) { 72 | $regex = is_numeric($key) 73 | ? "/\?(?=(?:[^'\\\']*'[^'\\\']*')*[^'\\\']*$)/" 74 | : "/:{$key}(?=(?:[^'\\\']*'[^'\\\']*')*[^'\\\']*$)/"; 75 | 76 | if ($binding === null) { 77 | $binding = 'null'; 78 | } elseif (! is_int($binding) && ! is_float($binding)) { 79 | $binding = $this->quoteStringBinding($event, $binding); 80 | } 81 | 82 | $sql = preg_replace( 83 | $regex, 84 | $binding, 85 | $sql, 86 | is_numeric($key) ? 1 : -1 87 | ); 88 | } 89 | 90 | return $sql; 91 | } 92 | 93 | /** 94 | * Add quotes to string bindings. 95 | */ 96 | protected function quoteStringBinding(QueryExecuted $event, string $binding): string 97 | { 98 | try { 99 | $pdo = $event->connection->getPdo(); 100 | 101 | if ($pdo instanceof \PDO) { 102 | return $pdo->quote($binding); 103 | } 104 | } catch (\PDOException $e) { 105 | throw_if('IM001' !== $e->getCode(), $e); 106 | } 107 | 108 | // Fallback when PDO::quote function is missing... 109 | $binding = \strtr($binding, [ 110 | chr(26) => '\\Z', 111 | chr(8) => '\\b', 112 | '"' => '\"', 113 | "'" => "\'", 114 | '\\' => '\\\\', 115 | ]); 116 | 117 | return "'".$binding."'"; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /app/Repositories/Sys/SysRuleRepository.php: -------------------------------------------------------------------------------- 1 | '=', 17 | 'status' => '=', 18 | 'show' => '=', 19 | 'parent_id' => '=' 20 | ]; 21 | 22 | protected array $quickSearchField = ['name', 'key', 'path']; 23 | 24 | protected function model(): Builder 25 | { 26 | return SysRuleModel::query(); 27 | } 28 | 29 | protected function rules(): array 30 | { 31 | $type = request()->input('type'); 32 | if (empty($type)) { 33 | throw new RepositoryException('权限类型为必填项!'); 34 | } 35 | if (!in_array($type, ['menu', 'route', 'nested-route', 'rule'])) { 36 | throw new RepositoryException('权限类型错误!'); 37 | } 38 | $rules = [ 39 | 'parent_id' => [ 40 | 'required', 41 | 'integer', 42 | 'numeric', 43 | function ($attribute, $value, $fail) { 44 | if ($value != 0 && !DB::table('sys_rule')->where('id', $value)->exists()) { 45 | $fail('选择的上级部门不存在。'); 46 | } 47 | }, 48 | ], 49 | 'order' => 'required|integer', 50 | 'name' => 'required', 51 | 'key' => 'required|unique:sys_rule,key' 52 | ]; 53 | if ($this->isUpdate()) { 54 | $rules['key'] = [ 55 | 'required', 56 | Rule::unique('sys_rule', 'key')->ignore(request()->route('id')) 57 | ]; 58 | } 59 | if ($type == 'menu') { 60 | $rules += [ 61 | 'type' => 'required|string|in:menu', 62 | 'local' => 'nullable|string', 63 | 'icon' => 'nullable|string', 64 | ]; 65 | } else if ($type == 'route') { 66 | $rules += [ 67 | 'type' => 'required|string|in:route', 68 | 'path' => 'required|string', 69 | 'local' => 'nullable|string', 70 | 'icon' => 'nullable|string', 71 | 'link' => 'required|integer|numeric|in:0,1', 72 | 'elementPath' => 'sometimes|required|string', 73 | ]; 74 | } else if ($type == 'nested-route') { 75 | $rules += [ 76 | 'type' => 'required|string|in:nested-route', 77 | 'path' => 'required|string', 78 | 'elementPath' => 'required|string', 79 | ]; 80 | } else { 81 | $rules += [ 82 | 'type' => 'required|string|in:rule', 83 | ]; 84 | } 85 | return $rules; 86 | } 87 | 88 | protected function messages(): array 89 | { 90 | return [ 91 | 'name.required' => '权限名称不能为空', 92 | 'type.required' => '类型不能为空', 93 | 'type.in' => '类型格式错误', 94 | 'order.required' => '排序不能为空', 95 | 'order.integer' => '排序必须为整数', 96 | 'key.required' => '唯一标识不能为空', 97 | 'key.unique' => '唯一标识已存在', 98 | 'path.required' => '路径不能为空', 99 | 'status.required' => '状态不能为空', 100 | 'status.in' => '状态格式错误', 101 | 'show.required' => '显示状态不能为空', 102 | 'show.in' => '显示状态格式错误', 103 | 'parent_id.required' => '父级权限不能为空', 104 | 'parent_id.integer' => '父级权限格式错误', 105 | 'parent_id.exists' => '父级权限不存在' 106 | ]; 107 | } 108 | } -------------------------------------------------------------------------------- /app/Http/Middleware/LoginLogMiddleware.php: -------------------------------------------------------------------------------- 1 | userAgent(); 23 | // 继续处理请求 24 | $response = $next($request); 25 | $user_id = auth()->id(); 26 | $username = auth()->user()['username']; 27 | // 获取响应状态和消息 28 | $content = json_decode($response->getContent(), true); // 响应内容 29 | $message = $content['msg'] ?? 'No message'; // 从响应中提取消息 30 | SysLoginRecordModel::create([ 31 | 'ipaddr' => $request->ip(), 32 | 'browser' => $this->getBrowser($userAgent), 33 | 'os' => $this->getOs($userAgent), 34 | 'username' => $username, 35 | 'user_id' => $user_id, 36 | 'login_location' => $this->getLocation($request->ip()), 37 | 'status' => $content['success'] ? '0' : '1', 38 | 'msg' => $message, 39 | 'login_time' => date('Y-m-d H:i:s'), 40 | ]); 41 | }catch (\Exception $e) { 42 | // 记录错误日志 43 | Log::error('Failed to log user login info: ' . $e->getMessage()); 44 | } 45 | return $response; 46 | } 47 | 48 | /** 49 | * 获取浏览器信息 50 | * @param string $userAgent 51 | * @return string 52 | */ 53 | private function getBrowser(string $userAgent): string 54 | { 55 | $browser = 'XXX'; 56 | // 简单的解析逻辑(可以根据需要扩展) 57 | if (str_contains($userAgent, 'Firefox')) { 58 | $browser = 'Firefox'; 59 | } elseif (str_contains($userAgent, 'Chrome')) { 60 | $browser = 'Chrome'; 61 | } elseif (str_contains($userAgent, 'Safari')) { 62 | $browser = 'Safari'; 63 | } elseif (str_contains($userAgent, 'MSIE') || str_contains($userAgent, 'Trident')) { 64 | $browser = 'Internet Explorer'; 65 | } 66 | return $browser; 67 | } 68 | 69 | /** 70 | * 获取操作系统信息 71 | * @param string $userAgent 72 | * @return string 73 | */ 74 | private function getOs(string $userAgent): string 75 | { 76 | $os = 'Unknown'; 77 | if (str_contains($userAgent, 'Windows')) { 78 | $os = 'Windows'; 79 | } elseif (str_contains($userAgent, 'Macintosh')) { 80 | $os = 'Mac OS'; 81 | } elseif (str_contains($userAgent, 'Linux')) { 82 | $os = 'Linux'; 83 | } elseif (str_contains($userAgent, 'Android')) { 84 | $os = 'Android'; 85 | } elseif (str_contains($userAgent, 'iOS')) { 86 | $os = 'iOS'; 87 | } 88 | return $os; 89 | } 90 | 91 | /** 92 | * 获取 IP 地址对应的地理位置 93 | * 94 | * @param string $ip 95 | * @return string 96 | */ 97 | private function getLocation(string $ip): string 98 | { 99 | if($ip == '127.0.0.1') { 100 | return '本地'; 101 | } 102 | // 这里可以使用第三方 API(如 IPStack 或 IPInfo)来获取地理位置 103 | try { 104 | $response = file_get_contents("https://ipinfo.io/{$ip}/json"); 105 | $data = json_decode($response, true); 106 | return $data['city'] . ', ' . $data['country']; 107 | }catch (\Exception $e) { 108 | return 'XXX'; 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /app/Exceptions/ExceptionsHandler.php: -------------------------------------------------------------------------------- 1 | function (HttpResponseException $e) { 35 | return response()->json($e->toArray(), $e->getCode()); 36 | }, 37 | MissingAbilityException::class => function ($e) { 38 | return $this->notification( 39 | 'No Permission', 40 | __('system.error.no_permission'), 41 | ShowType::WARN_NOTIFICATION 42 | ); 43 | }, 44 | AuthenticationException::class => function ($e) { 45 | return response()->json([ 46 | 'msg' => __('user.not_login'), 47 | 'success' => false 48 | ], 401); 49 | }, 50 | NotFoundHttpException::class => function ($e) { 51 | return $this->notification( 52 | 'Route Not Exist', 53 | __('system.error.route_not_exist'), 54 | ShowType::WARN_NOTIFICATION 55 | ); 56 | }, 57 | ValidationException::class => function (ValidationException $e) { 58 | return response()->json([ 59 | 'msg' => $e->validator->errors()->first(), 60 | 'showType' => ShowType::WARN_MESSAGE->value, 61 | 'success' => false, 62 | ]); 63 | }, 64 | ]; 65 | 66 | foreach ($exceptionHandlers as $exceptionType => $handler) { 67 | if ($e instanceof $exceptionType) { 68 | $response = $handler($e); 69 | break; 70 | } 71 | } 72 | 73 | if (!isset($response)) { 74 | $debug = config('app.debug'); 75 | $data = [ 76 | 'msg' => $e->getMessage(), 77 | 'showType' => ShowType::ERROR_MESSAGE->value, 78 | 'success' => false, 79 | ]; 80 | 81 | if ($debug) { 82 | $data += [ 83 | 'file' => $e->getFile(), 84 | 'line' => $e->getLine(), 85 | 'trace' => $e->getTrace(), 86 | 'code' => $e->getCode(), 87 | ]; 88 | } 89 | 90 | $response = response()->json($data); 91 | } 92 | 93 | $response->headers->set('Access-Control-Allow-Origin', '*'); 94 | $response->headers->set('Access-Control-Allow-Credentials', 'true'); 95 | $response->headers->set('Access-Control-Max-Age', 1800); 96 | $response->headers->set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); 97 | $response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, User-Language'); 98 | 99 | return $response; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /config/cache.php: -------------------------------------------------------------------------------- 1 | env('CACHE_STORE', 'redis'), 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Cache Stores 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here you may define all of the cache "stores" for your application as 26 | | well as their drivers. You may even define multiple stores for the 27 | | same cache driver to group types of items stored in your caches. 28 | | 29 | | Supported drivers: "array", "database", "file", "memcached", 30 | | "redis", "dynamodb", "octane", "null" 31 | | 32 | */ 33 | 34 | 'stores' => [ 35 | 36 | 'array' => [ 37 | 'driver' => 'array', 38 | 'serialize' => false, 39 | ], 40 | 41 | 'database' => [ 42 | 'driver' => 'database', 43 | 'connection' => env('DB_CACHE_CONNECTION'), 44 | 'table' => env('DB_CACHE_TABLE', 'sys_cache'), 45 | 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), 46 | 'lock_table' => env('DB_CACHE_LOCK_TABLE', 'sys_cache_locks'), 47 | ], 48 | 49 | 'file' => [ 50 | 'driver' => 'file', 51 | 'path' => storage_path('framework/cache/data'), 52 | 'lock_path' => storage_path('framework/cache/data'), 53 | ], 54 | 55 | 'memcached' => [ 56 | 'driver' => 'memcached', 57 | 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), 58 | 'sasl' => [ 59 | env('MEMCACHED_USERNAME'), 60 | env('MEMCACHED_PASSWORD'), 61 | ], 62 | 'options' => [ 63 | // Memcached::OPT_CONNECT_TIMEOUT => 2000, 64 | ], 65 | 'servers' => [ 66 | [ 67 | 'host' => env('MEMCACHED_HOST', '127.0.0.1'), 68 | 'port' => env('MEMCACHED_PORT', 11211), 69 | 'weight' => 100, 70 | ], 71 | ], 72 | ], 73 | 74 | 'redis' => [ 75 | 'driver' => 'redis', 76 | 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), 77 | 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), 78 | ], 79 | 80 | 'dynamodb' => [ 81 | 'driver' => 'dynamodb', 82 | 'key' => env('AWS_ACCESS_KEY_ID'), 83 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 84 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 85 | 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), 86 | 'endpoint' => env('DYNAMODB_ENDPOINT'), 87 | ], 88 | 89 | 'octane' => [ 90 | 'driver' => 'octane', 91 | ], 92 | 93 | ], 94 | 95 | /* 96 | |-------------------------------------------------------------------------- 97 | | Cache Key Prefix 98 | |-------------------------------------------------------------------------- 99 | | 100 | | When utilizing the APC, database, memcached, Redis, and DynamoDB cache 101 | | stores, there might be other applications using the same cache. For 102 | | that reason, you may prefix every cache key to avoid collisions. 103 | | 104 | */ 105 | 106 | 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'), 107 | 108 | ]; 109 | -------------------------------------------------------------------------------- /tests/Feature/MailServiceTest.php: -------------------------------------------------------------------------------- 1 | assertArrayHasKey('default', $config); 27 | $this->assertArrayHasKey('smtp', $config); 28 | $this->assertArrayHasKey('from', $config); 29 | } 30 | 31 | /** 32 | * 测试检查邮件配置是否有效 33 | */ 34 | public function test_mail_config_is_configured(): void 35 | { 36 | $isConfigured = MailConfigService::isConfigured(); 37 | 38 | // 根据系统设置中是否配置了邮件返回结果 39 | $this->assertIsBool($isConfigured); 40 | } 41 | 42 | /** 43 | * 测试验证码邮件类创建 44 | */ 45 | public function test_verification_code_mail_can_be_created(): void 46 | { 47 | $code = '123456'; 48 | $expireMinutes = 30; 49 | 50 | $mail = new VerificationCodeMail($code, $expireMinutes); 51 | 52 | $this->assertEquals($code, $mail->code); 53 | $this->assertEquals($expireMinutes, $mail->expireMinutes); 54 | } 55 | 56 | /** 57 | * 测试邮件发送(使用 Fake) 58 | */ 59 | public function test_verification_code_mail_can_be_sent(): void 60 | { 61 | Mail::fake(); 62 | 63 | $testEmail = 'test@example.com'; 64 | $code = '654321'; 65 | 66 | // 发送邮件 67 | Mail::to($testEmail)->send(new VerificationCodeMail($code)); 68 | 69 | // 验证邮件已发送 70 | Mail::assertSent(VerificationCodeMail::class, function ($mail) use ($code, $testEmail) { 71 | return $mail->code === $code 72 | && $mail->hasTo($testEmail); 73 | }); 74 | } 75 | 76 | /** 77 | * 测试邮件发送队列(使用 Fake) 78 | */ 79 | public function test_verification_code_mail_can_be_queued(): void 80 | { 81 | Mail::fake(); 82 | 83 | $testEmail = 'queue@example.com'; 84 | $code = '789012'; 85 | 86 | // 队列发送邮件 87 | Mail::to($testEmail)->queue(new VerificationCodeMail($code)); 88 | 89 | // 验证邮件已加入队列 90 | Mail::assertQueued(VerificationCodeMail::class, function ($mail) use ($code) { 91 | return $mail->code === $code; 92 | }); 93 | } 94 | 95 | /** 96 | * 真实发送测试邮件(需要配置正确的邮件服务) 97 | * 运行命令: php artisan test --filter=test_send_real_email 98 | * 99 | * 注意:运行此测试前请确保: 100 | * 1. 已在系统设置中配置正确的邮件参数 (host, port, username, password, encryption, from_address) 101 | * 2. 修改下方的 TEST_MAIL_TO 或在 .env 中设置为真实邮箱地址 102 | * 3. 创议使用 SSL 加密 (port: 465, encryption: ssl) 103 | */ 104 | public function test_send_real_email(): void 105 | { 106 | // 如果邮件未配置,跳过此测试 107 | if (!MailConfigService::isConfigured()) { 108 | $this->markTestSkipped('邮件服务未配置,跳过真实发送测试'); 109 | } 110 | 111 | // 初始化邮件配置 112 | MailConfigService::initFromSettings(); 113 | 114 | // 修改为真实的测试邮箱 115 | $testEmail = env('TEST_MAIL_TO', 'test@example.com'); 116 | 117 | // 如果是默认邮箱,跳过测试 118 | if ($testEmail === 'test@example.com') { 119 | $this->markTestSkipped('请设置 TEST_MAIL_TO 环境变量为真实邮箱地址'); 120 | } 121 | 122 | $code = sprintf('%06d', random_int(0, 999999)); 123 | 124 | try { 125 | Mail::to($testEmail)->send(new VerificationCodeMail($code, 5)); 126 | $this->assertTrue(true, '邮件发送成功'); 127 | } catch (\Exception $e) { 128 | $this->fail('邮件发送失败: ' . $e->getMessage()); 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /config/mail.php: -------------------------------------------------------------------------------- 1 | env('MAIL_MAILER', 'log'), 18 | 19 | /* 20 | |-------------------------------------------------------------------------- 21 | | Mailer Configurations 22 | |-------------------------------------------------------------------------- 23 | | 24 | | Here you may configure all of the mailers used by your application plus 25 | | their respective settings. Several examples have been configured for 26 | | you and you are free to add your own as your application requires. 27 | | 28 | | Laravel supports a variety of mail "transport" drivers that can be used 29 | | when delivering an email. You may specify which one you're using for 30 | | your mailers below. You may also add additional mailers if needed. 31 | | 32 | | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", 33 | | "postmark", "resend", "log", "array", 34 | | "failover", "roundrobin" 35 | | 36 | */ 37 | 38 | 'mailers' => [ 39 | 'mailgun' => [ 40 | 'transport' => 'mailgun', 41 | // 'client' => [ 42 | // 'timeout' => 5, 43 | // ], 44 | ], 45 | 46 | 'smtp' => [ 47 | 'transport' => 'smtp', 48 | 'scheme' => env('MAIL_SCHEME'), 49 | 'url' => env('MAIL_URL'), 50 | 'host' => env('MAIL_HOST', '127.0.0.1'), 51 | 'port' => env('MAIL_PORT', 2525), 52 | 'username' => env('MAIL_USERNAME'), 53 | 'password' => env('MAIL_PASSWORD'), 54 | 'timeout' => null, 55 | 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url(env('APP_URL', 'http://localhost'), PHP_URL_HOST)), 56 | ], 57 | 58 | 'ses' => [ 59 | 'transport' => 'ses', 60 | ], 61 | 62 | 'postmark' => [ 63 | 'transport' => 'postmark', 64 | // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), 65 | // 'client' => [ 66 | // 'timeout' => 5, 67 | // ], 68 | ], 69 | 70 | 'resend' => [ 71 | 'transport' => 'resend', 72 | ], 73 | 74 | 'sendmail' => [ 75 | 'transport' => 'sendmail', 76 | 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), 77 | ], 78 | 79 | 'log' => [ 80 | 'transport' => 'log', 81 | 'channel' => env('MAIL_LOG_CHANNEL'), 82 | ], 83 | 84 | 'array' => [ 85 | 'transport' => 'array', 86 | ], 87 | 88 | 'failover' => [ 89 | 'transport' => 'failover', 90 | 'mailers' => [ 91 | 'smtp', 92 | 'log', 93 | ], 94 | ], 95 | 96 | 'roundrobin' => [ 97 | 'transport' => 'roundrobin', 98 | 'mailers' => [ 99 | 'ses', 100 | 'postmark', 101 | ], 102 | ], 103 | 104 | ], 105 | 106 | /* 107 | |-------------------------------------------------------------------------- 108 | | Global "From" Address 109 | |-------------------------------------------------------------------------- 110 | | 111 | | You may wish for all emails sent by your application to be sent from 112 | | the same address. Here you may specify a name and address that is 113 | | used globally for all emails that are sent by your application. 114 | | 115 | */ 116 | 117 | 'from' => [ 118 | 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), 119 | 'name' => env('MAIL_FROM_NAME', 'Example'), 120 | ], 121 | 122 | ]; 123 | --------------------------------------------------------------------------------