webman后台

This commit is contained in:
2026-03-07 19:42:22 +08:00
parent 9ed4c1bc58
commit 83725aef88
181 changed files with 19115 additions and 1 deletions

View File

@@ -0,0 +1,23 @@
# BuildAdmin Webman 环境配置
# 复制为 .env 并修改实际值
# 应用
APP_DEBUG = true
APP_DEFAULT_TIMEZONE = Asia/Shanghai
# 语言
LANG_DEFAULT_LANG = zh-cn
# 数据库config/thinkorm.php
DATABASE_DRIVER = mysql
DATABASE_TYPE = mysql
DATABASE_HOSTNAME = 127.0.0.1
DATABASE_DATABASE = buildadmin_com
DATABASE_USERNAME = root
DATABASE_PASSWORD = admin888
DATABASE_HOSTPORT = 3306
DATABASE_CHARSET = utf8mb4
DATABASE_PREFIX =
# 缓存config/cache.php
CACHE_DRIVER = file

8
dafuweng-webman/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
/runtime
/.idea
/.vscode
/vendor
*.log
.env
/tests/tmp
/tests/.phpunit.result.cache

View File

@@ -0,0 +1,18 @@
FROM php:8.3.22-cli-alpine
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk update --no-cache \
&& docker-php-source extract
# install extensions
RUN docker-php-ext-install pdo pdo_mysql -j$(nproc) pcntl
# enable opcache and pcntl
RUN docker-php-ext-enable opcache pcntl
RUN docker-php-source delete \
rm -rf /var/cache/apk/*
RUN mkdir -p /app
WORKDIR /app

21
dafuweng-webman/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 walkor<walkor@workerman.net> and contributors (see https://github.com/walkor/webman/contributors)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

70
dafuweng-webman/README.md Normal file
View File

@@ -0,0 +1,70 @@
<div style="padding:18px;max-width: 1024px;margin:0 auto;background-color:#fff;color:#333">
<h1>webman</h1>
基于<a href="https://www.workerman.net" target="__blank">workerman</a>开发的超高性能PHP框架
<h1>学习</h1>
<ul>
<li>
<a href="https://www.workerman.net/webman" target="__blank">主页 / Home page</a>
</li>
<li>
<a href="https://webman.workerman.net" target="__blank">文档 / Document</a>
</li>
<li>
<a href="https://www.workerman.net/doc/webman/install.html" target="__blank">安装 / Install</a>
</li>
<li>
<a href="https://www.workerman.net/questions" target="__blank">问答 / Questions</a>
</li>
<li>
<a href="https://www.workerman.net/apps" target="__blank">市场 / Apps</a>
</li>
<li>
<a href="https://www.workerman.net/sponsor" target="__blank">赞助 / Sponsors</a>
</li>
<li>
<a href="https://www.workerman.net/doc/webman/thanks.html" target="__blank">致谢 / Thanks</a>
</li>
</ul>
<div style="float:left;padding-bottom:30px;">
<h1>赞助商</h1>
<h4>特别赞助</h4>
<a href="https://www.crmeb.com/?form=workerman" target="__blank">
<img src="https://www.workerman.net/img/sponsors/6429/20230719111500.svg" width="200">
</a>
<h4>铂金赞助</h4>
<a href="https://www.fadetask.com/?from=workerman" target="__blank"><img src="https://www.workerman.net/img/sponsors/1/20230719084316.png" width="200"></a>
<a href="https://www.yilianyun.net/?from=workerman" target="__blank" style="margin-left:20px;"><img src="https://www.workerman.net/img/sponsors/6218/20230720114049.png" width="200"></a>
</div>
<div style="float:left;padding-bottom:30px;clear:both">
<h1>请作者喝咖啡</h1>
<img src="https://www.workerman.net/img/wx_donate.png" width="200">
<img src="https://www.workerman.net/img/ali_donate.png" width="200">
<br>
<b>如果您觉得webman对您有所帮助欢迎捐赠。</b>
</div>
<div style="clear: both">
<h1>LICENSE</h1>
The webman is open-sourced software licensed under the MIT.
</div>
</div>

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace app\admin\controller;
use ba\Terminal;
use ba\TableManager;
use support\think\Db;
use app\admin\model\AdminLog;
use app\common\library\Upload;
use app\common\library\upload\WebmanUploadedFile;
use app\common\controller\Backend;
use Webman\Http\Request;
use support\Response;
class Ajax extends Backend
{
protected array $noNeedPermission = ['*'];
protected array $noNeedLogin = ['terminal'];
public function upload(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
AdminLog::instance($request)->setTitle(__('upload'));
$file = $request->file('file');
if (!$file) {
return $this->error(__('No files were uploaded'));
}
$file = new WebmanUploadedFile($file);
$driver = $request->get('driver', $request->post('driver', 'local'));
$topic = $request->get('topic', $request->post('topic', 'default'));
try {
$upload = new Upload();
$attachment = $upload
->setFile($file)
->setDriver($driver)
->setTopic($topic)
->upload(null, $this->auth->id);
unset($attachment['create_time'], $attachment['quote']);
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
return $this->success(__('File uploaded successfully'), [
'file' => $attachment ?? []
]);
}
public function area(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->success('', get_area($request));
}
public function buildSuffixSvg(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$suffix = $request->get('suffix', $request->post('suffix', 'file'));
$background = $request->get('background', $request->post('background'));
$content = build_suffix_svg((string) $suffix, (string) $background);
return response($content, 200, [
'Content-Length' => strlen($content),
'Content-Type' => 'image/svg+xml'
]);
}
public function getDatabaseConnectionList(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$quickSearch = $request->get('quickSearch', '');
$connections = config('thinkorm.connections', config('database.connections', []));
$desensitization = [];
foreach ($connections as $key => $connection) {
$connConfig = TableManager::getConnectionConfig($key);
$desensitization[] = [
'type' => $connConfig['type'] ?? 'mysql',
'database' => substr_replace($connConfig['database'] ?? '', '****', 1, strlen($connConfig['database'] ?? '') > 4 ? 2 : 1),
'key' => $key,
];
}
if ($quickSearch) {
$desensitization = array_values(array_filter($desensitization, function ($item) use ($quickSearch) {
return preg_match("/$quickSearch/i", $item['key']);
}));
}
return $this->success('', ['list' => $desensitization]);
}
public function getTablePk(Request $request, ?string $table = null, ?string $connection = null): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$table = $table ?? $request->get('table', $request->post('table'));
$connection = $connection ?? $request->get('connection', $request->post('connection'));
if (!$table) {
return $this->error(__('Parameter error'));
}
$table = TableManager::tableName($table, true, $connection);
if (!TableManager::phinxAdapter(false, $connection)->hasTable($table)) {
return $this->error(__('Data table does not exist'));
}
$conn = TableManager::getConnection($connection);
$tablePk = Db::connect($conn)->table($table)->getPk();
return $this->success('', ['pk' => $tablePk]);
}
public function getTableList(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$quickSearch = $request->get('quickSearch', $request->post('quickSearch', ''));
$connection = $request->get('connection', $request->post('connection'));
$samePrefix = filter_var($request->get('samePrefix', $request->post('samePrefix', true)), FILTER_VALIDATE_BOOLEAN);
$excludeTable = $request->get('excludeTable', $request->post('excludeTable', []));
$excludeTable = is_array($excludeTable) ? $excludeTable : [];
$dbConfig = TableManager::getConnectionConfig($connection);
$tables = TableManager::getTableList($connection);
if ($quickSearch) {
$tables = array_filter($tables, function ($comment) use ($quickSearch) {
return preg_match("/$quickSearch/i", $comment);
});
}
$pattern = '/^' . preg_quote($dbConfig['prefix'] ?? '', '/') . '/i';
$outTables = [];
foreach ($tables as $table => $comment) {
if ($samePrefix && !preg_match($pattern, $table)) continue;
$tableNoPrefix = preg_replace($pattern, '', $table);
if (!in_array($tableNoPrefix, $excludeTable)) {
$outTables[] = [
'table' => $tableNoPrefix,
'comment' => $comment,
'connection' => $connection,
'prefix' => $dbConfig['prefix'] ?? '',
];
}
}
return $this->success('', ['list' => $outTables]);
}
public function getTableFieldList(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$table = $request->get('table', $request->post('table'));
$clean = filter_var($request->get('clean', $request->post('clean', true)), FILTER_VALIDATE_BOOLEAN);
$connection = $request->get('connection', $request->post('connection'));
if (!$table) {
return $this->error(__('Parameter error'));
}
$conn = TableManager::getConnection($connection);
$tablePk = Db::connect($conn)->name($table)->getPk();
return $this->success('', [
'pk' => $tablePk,
'fieldList' => TableManager::getTableColumns($table, $clean, $conn),
]);
}
public function changeTerminalConfig(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
AdminLog::instance($request)->setTitle(__('Change terminal config'));
if (Terminal::changeTerminalConfig()) {
return $this->success();
}
return $this->error(__('Failed to modify the terminal configuration. Please modify the configuration file manually:%s', ['/config/terminal.php']));
}
public function clearCache(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
AdminLog::instance($request)->setTitle(__('Clear cache'));
$type = $request->post('type');
if ($type === 'tp' || $type === 'all') {
clear_config_cache();
} else {
return $this->error(__('Parameter error'));
}
event_trigger('cacheClearAfter');
return $this->success(__('Cache cleaned~'));
}
public function terminal(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
(new Terminal())->exec();
return $this->success();
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace app\admin\controller;
use app\common\controller\Backend;
use Webman\Http\Request;
use support\Response;
class Dashboard extends Backend
{
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->success('', [
'remark' => get_route_remark()
]);
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace app\admin\controller;
use ba\ClickCaptcha;
use app\common\facade\Token;
use app\admin\model\AdminLog;
use app\common\controller\Backend;
use support\validation\Validator;
use support\validation\ValidationException;
use Webman\Http\Request;
use support\Response;
class Index extends Backend
{
protected array $noNeedLogin = ['logout', 'login'];
protected array $noNeedPermission = ['index'];
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$adminInfo = $this->auth->getInfo();
$adminInfo['super'] = $this->auth->isSuperAdmin();
unset($adminInfo['token'], $adminInfo['refresh_token']);
$menus = $this->auth->getMenus();
if (!$menus) {
return $this->error(__('No background menu, please contact super administrator!'));
}
return $this->success('', [
'adminInfo' => $adminInfo,
'menus' => $menus,
'siteConfig' => [
'siteName' => get_sys_config('site_name'),
'version' => get_sys_config('version'),
'apiUrl' => config('buildadmin.api_url'),
'upload' => keys_to_camel_case(get_upload_config($request), ['max_size', 'save_name', 'allowed_suffixes', 'allowed_mime_types']),
'cdnUrl' => full_url(),
'cdnUrlParams' => config('buildadmin.cdn_url_params'),
],
'terminal' => [
'phpDevelopmentServer' => str_contains($_SERVER['SERVER_SOFTWARE'] ?? '', 'Development Server'),
'npmPackageManager' => config('terminal.npm_package_manager'),
]
]);
}
public function login(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($this->auth->isLogin()) {
return $this->success(__('You have already logged in. There is no need to log in again~'), [
'type' => $this->auth::LOGGED_IN
], $this->auth::LOGIN_RESPONSE_CODE);
}
$captchaSwitch = config('buildadmin.admin_login_captcha');
if ($request->method() === 'POST') {
$username = $request->post('username');
$password = $request->post('password');
$keep = $request->post('keep');
$rules = [
'username' => 'required|string|min:3|max:30',
'password' => 'required|string|regex:/^(?!.*[&<>"\'\n\r]).{6,32}$/',
];
$data = ['username' => $username, 'password' => $password];
if ($captchaSwitch) {
$rules['captchaId'] = 'required|string';
$rules['captchaInfo'] = 'required|string';
$data['captchaId'] = $request->post('captchaId');
$data['captchaInfo'] = $request->post('captchaInfo');
}
try {
Validator::make($data, $rules, [
'username.required' => __('Username'),
'password.required' => __('Password'),
'password.regex' => __('Please input correct password'),
])->validate();
} catch (ValidationException $e) {
return $this->error($e->getMessage());
}
if ($captchaSwitch) {
$captchaObj = new ClickCaptcha();
if (!$captchaObj->check($data['captchaId'], $data['captchaInfo'])) {
return $this->error(__('Captcha error'));
}
}
AdminLog::instance($request)->setTitle(__('Login'));
$res = $this->auth->login($username, $password, (bool) $keep);
if ($res === true) {
return $this->success(__('Login succeeded!'), [
'userInfo' => $this->auth->getInfo()
]);
}
$msg = $this->auth->getError();
return $this->error($msg ?: __('Incorrect user name or password!'));
}
return $this->success('', [
'captcha' => $captchaSwitch
]);
}
public function logout(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->method() === 'POST') {
$refreshToken = $request->post('refreshToken', '');
if ($refreshToken) {
Token::delete((string) $refreshToken);
}
$this->auth->logout();
return $this->success();
}
return $this->error(__('Method not allowed'), [], 0, ['statusCode' => 405]);
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace app\admin\controller;
use ba\Exception as BaException;
use app\admin\model\AdminLog;
use app\admin\library\module\Server;
use app\admin\library\module\Manage;
use app\common\controller\Backend;
use Webman\Http\Request;
use support\Response;
class Module extends Backend
{
protected array $noNeedPermission = ['state', 'dependentInstallComplete'];
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->success('', [
'installed' => Server::installedList(root_path() . 'modules' . DIRECTORY_SEPARATOR),
'sysVersion' => config('buildadmin.version'),
'nuxtVersion' => Server::getNuxtVersion(),
]);
}
public function state(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$uid = $request->get('uid', '');
if (!$uid) {
return $this->error(__('Parameter error'));
}
return $this->success('', [
'state' => Manage::instance($uid)->getInstallState()
]);
}
public function install(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
AdminLog::instance($request)->setTitle(__('Install module'));
$uid = $request->get('uid', $request->post('uid', ''));
$update = filter_var($request->get('update', $request->post('update', false)), FILTER_VALIDATE_BOOLEAN);
if (!$uid) {
return $this->error(__('Parameter error'));
}
$res = [];
try {
$res = Manage::instance($uid)->install($update);
} catch (BaException $e) {
return $this->error(__($e->getMessage()), $e->getData(), $e->getCode());
} catch (\Throwable $e) {
return $this->error(__($e->getMessage()));
}
return $this->success('', ['data' => $res]);
}
public function dependentInstallComplete(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$uid = $request->get('uid', '');
if (!$uid) {
return $this->error(__('Parameter error'));
}
try {
Manage::instance($uid)->dependentInstallComplete('all');
} catch (BaException $e) {
return $this->error(__($e->getMessage()), $e->getData(), $e->getCode());
} catch (\Throwable $e) {
return $this->error(__($e->getMessage()));
}
return $this->success();
}
public function changeState(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
AdminLog::instance($request)->setTitle(__('Change module state'));
$uid = $request->post('uid', '');
$state = filter_var($request->post('state', false), FILTER_VALIDATE_BOOLEAN);
if (!$uid) {
return $this->error(__('Parameter error'));
}
$info = [];
try {
$info = Manage::instance($uid)->changeState($state);
} catch (BaException $e) {
return $this->error(__($e->getMessage()), $e->getData(), $e->getCode());
} catch (\Throwable $e) {
return $this->error(__($e->getMessage()));
}
return $this->success('', ['info' => $info]);
}
public function uninstall(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
AdminLog::instance($request)->setTitle(__('Uninstall module'));
$uid = $request->post('uid', '');
if (!$uid) {
return $this->error(__('Parameter error'));
}
try {
Manage::instance($uid)->uninstall();
} catch (BaException $e) {
return $this->error(__($e->getMessage()), $e->getData(), $e->getCode());
} catch (\Throwable $e) {
return $this->error(__($e->getMessage()));
}
return $this->success();
}
public function upload(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
AdminLog::instance($request)->setTitle(__('Upload module'));
$file = $request->file('file');
if (!$file) {
return $this->error(__('Parameter error'));
}
try {
$res = Manage::uploadFromRequest($request);
} catch (BaException $e) {
return $this->error(__($e->getMessage()), $e->getData(), $e->getCode());
} catch (\Throwable $e) {
return $this->error(__($e->getMessage()));
}
return $this->success('', $res);
}
}

View File

@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\auth;
use Throwable;
use support\think\Db;
use support\validation\Validator;
use support\validation\ValidationException;
use app\common\controller\Backend;
use app\admin\model\Admin as AdminModel;
use support\Response;
use Webman\Http\Request;
class Admin extends Backend
{
protected ?object $model = null;
protected array|string $preExcludeFields = ['create_time', 'update_time', 'password', 'salt', 'login_failure', 'last_login_time', 'last_login_ip'];
protected array|string $quickSearchField = ['username', 'nickname'];
protected string|int|bool $dataLimit = 'allAuthAndOthers';
protected string $dataLimitField = 'id';
protected function initController(Request $request): void
{
$this->model = new AdminModel();
}
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->get('select') ?? $request->post('select')) {
$selectRes = $this->select($request);
if ($selectRes !== null) return $selectRes;
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withoutField('login_failure,password,salt')
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->method() === 'POST') {
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
if ($this->modelValidate) {
try {
$rules = [
'username' => 'required|string|regex:/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/|unique:admin,username',
'nickname' => 'required|string',
'password' => 'required|string|regex:/^(?!.*[&<>"\'\n\r]).{6,32}$/',
'email' => 'email|unique:admin,email',
'mobile' => 'regex:/^1[3-9]\d{9}$/|unique:admin,mobile',
'group_arr' => 'required|array',
];
$messages = [
'username.regex' => __('Please input correct username'),
'password.regex' => __('Please input correct password'),
];
Validator::make($data, $rules, $messages)->validate();
} catch (ValidationException $e) {
return $this->error($e->getMessage());
}
}
$passwd = $data['password'] ?? '';
$data = $this->excludeFields($data);
$result = false;
if (!empty($data['group_arr'])) {
$authRes = $this->checkGroupAuth($data['group_arr']);
if ($authRes !== null) return $authRes;
}
$this->model->startTrans();
try {
$result = $this->model->save($data);
if (!empty($data['group_arr'])) {
$groupAccess = [];
foreach ($data['group_arr'] as $datum) {
$groupAccess[] = [
'uid' => $this->model->id,
'group_id' => $datum,
];
}
Db::name('admin_group_access')->insertAll($groupAccess);
}
$this->model->commit();
if (!empty($passwd)) {
$this->model->resetPassword($this->model->id, $passwd);
}
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Added successfully'));
}
return $this->error(__('No rows were added'));
}
return $this->error(__('Parameter error'));
}
public function edit(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$pk = $this->model->getPk();
$id = $request->get($pk) ?? $request->post($pk);
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
return $this->error(__('You have no permission'));
}
if ($request->method() === 'POST') {
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
if ($this->modelValidate) {
try {
$rules = [
'username' => 'required|string|regex:/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/|unique:admin,username,' . $id,
'nickname' => 'required|string',
'password' => 'nullable|string|regex:/^(?!.*[&<>"\'\n\r]).{6,32}$/',
'email' => 'email|unique:admin,email,' . $id,
'mobile' => 'regex:/^1[3-9]\d{9}$/|unique:admin,mobile,' . $id,
'group_arr' => 'required|array',
];
$messages = [
'username.regex' => __('Please input correct username'),
'password.regex' => __('Please input correct password'),
];
Validator::make($data, $rules, $messages)->validate();
} catch (ValidationException $e) {
return $this->error($e->getMessage());
}
}
if ($this->auth->id == $data['id'] && ($data['status'] ?? '') == 'disable') {
return $this->error(__('Please use another administrator account to disable the current account!'));
}
if (!empty($data['password'])) {
$this->model->resetPassword($row->id, $data['password']);
}
$groupAccess = [];
if (!empty($data['group_arr'])) {
$checkGroups = [];
$rowGroupArr = $row->group_arr ?? [];
foreach ($data['group_arr'] as $datum) {
if (!in_array($datum, $rowGroupArr)) {
$checkGroups[] = $datum;
}
$groupAccess[] = [
'uid' => $id,
'group_id' => $datum,
];
}
$authRes = $this->checkGroupAuth($checkGroups);
if ($authRes !== null) return $authRes;
}
Db::name('admin_group_access')
->where('uid', $id)
->delete();
$data = $this->excludeFields($data);
$result = false;
$this->model->startTrans();
try {
$result = $row->save($data);
if ($groupAccess) {
Db::name('admin_group_access')->insertAll($groupAccess);
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Update successful'));
}
return $this->error(__('No rows updated'));
}
unset($row['salt'], $row['login_failure']);
$row['password'] = '';
return $this->success('', [
'row' => $row
]);
}
public function del(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$where = [];
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$where[] = [$this->dataLimitField, 'in', $dataLimitAdminIds];
}
$ids = $request->get('ids') ?? $request->post('ids') ?? [];
$ids = is_array($ids) ? $ids : [];
$where[] = [$this->model->getPk(), 'in', $ids];
$data = $this->model->where($where)->select();
$count = 0;
$this->model->startTrans();
try {
foreach ($data as $v) {
if ($v->id != $this->auth->id) {
$count += $v->delete();
Db::name('admin_group_access')
->where('uid', $v['id'])
->delete();
}
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($count) {
return $this->success(__('Deleted successfully'));
}
return $this->error(__('No rows were deleted'));
}
/**
* 远程下拉Admin 无自定义,返回 null 走默认列表)
*/
protected function select(Request $request): ?Response
{
return null;
}
private function checkGroupAuth(array $groups): ?Response
{
if ($this->auth->isSuperAdmin()) {
return null;
}
$authGroups = $this->auth->getAllAuthGroups('allAuthAndOthers');
foreach ($groups as $group) {
if (!in_array($group, $authGroups)) {
return $this->error(__('You have no permission to add an administrator to this group!'));
}
}
return null;
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\auth;
use Throwable;
use app\common\controller\Backend;
use app\admin\model\AdminLog as AdminLogModel;
use support\Response;
use Webman\Http\Request;
class AdminLog extends Backend
{
protected ?object $model = null;
protected string|array $preExcludeFields = ['create_time', 'admin_id', 'username'];
protected string|array $quickSearchField = ['title'];
protected function initController(Request $request): void
{
$this->model = new AdminLogModel();
}
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->get('select') ?? $request->post('select')) {
$selectRes = $this->select($request);
if ($selectRes !== null) return $selectRes;
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
if (!$this->auth->isSuperAdmin()) {
$where[] = ['admin_id', '=', $this->auth->id];
}
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 远程下拉AdminLog 无自定义,返回 null 走默认列表)
*/
protected function select(Request $request): ?Response
{
return null;
}
}

View File

@@ -0,0 +1,344 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\auth;
use Throwable;
use ba\Tree;
use support\think\Db;
use support\validation\Validator;
use support\validation\ValidationException;
use app\admin\model\AdminRule;
use app\admin\model\AdminGroup;
use app\common\controller\Backend;
use support\Response;
use Webman\Http\Request;
class Group extends Backend
{
protected string $authMethod = 'allAuthAndOthers';
protected ?object $model = null;
protected string|array $preExcludeFields = ['create_time', 'update_time'];
protected string|array $quickSearchField = 'name';
protected Tree $tree;
protected array $initValue = [];
protected string $keyword = '';
protected bool $assembleTree = true;
protected array $adminGroups = [];
protected function initController(Request $request): void
{
$this->model = new AdminGroup();
$this->tree = Tree::instance();
$isTree = $request->get('isTree') ?? $request->post('isTree') ?? true;
$this->initValue = $request->get('initValue') ?? [];
$this->initValue = is_array($this->initValue) ? array_filter($this->initValue) : [];
$this->keyword = $request->get('quickSearch') ?? $request->post('quickSearch') ?? '';
$this->assembleTree = $isTree && !$this->initValue;
$this->adminGroups = Db::name('admin_group_access')->where('uid', $this->auth->id)->column('group_id');
}
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->get('select') ?? $request->post('select')) {
return $this->select($request);
}
return $this->success('', [
'list' => $this->getGroups($request),
'group' => $this->adminGroups,
'remark' => get_route_remark(),
]);
}
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->method() === 'POST') {
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$rulesRes = $this->handleRules($data);
if ($rulesRes instanceof Response) return $rulesRes;
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
try {
$rules = [
'name' => 'required|string',
'rules' => 'required',
];
$messages = [
'rules.required' => __('Please select rules'),
];
Validator::make($data, $rules, $messages)->validate();
} catch (ValidationException $e) {
throw $e;
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Added successfully'));
}
return $this->error(__('No rows were added'));
}
return $this->error(__('Parameter error'));
}
public function edit(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$pk = $this->model->getPk();
$id = $request->get($pk) ?? $request->post($pk);
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
$authRes = $this->checkAuth($id);
if ($authRes !== null) return $authRes;
if ($request->method() === 'POST') {
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$adminGroup = Db::name('admin_group_access')->where('uid', $this->auth->id)->column('group_id');
if (in_array($data['id'], $adminGroup)) {
return $this->error(__('You cannot modify your own management group!'));
}
$data = $this->excludeFields($data);
$rulesRes = $this->handleRules($data);
if ($rulesRes instanceof Response) return $rulesRes;
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
try {
$rules = [
'name' => 'required|string',
'rules' => 'required',
];
$messages = [
'rules.required' => __('Please select rules'),
];
Validator::make($data, $rules, $messages)->validate();
} catch (ValidationException $e) {
throw $e;
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Update successful'));
}
return $this->error(__('No rows updated'));
}
$pidArr = AdminRule::field('pid')
->distinct()
->where('id', 'in', $row->rules)
->select()
->toArray();
$rules = $row->rules ? explode(',', $row->rules) : [];
foreach ($pidArr as $item) {
$ruKey = array_search($item['pid'], $rules);
if ($ruKey !== false) {
unset($rules[$ruKey]);
}
}
$row->rules = array_values($rules);
return $this->success('', [
'row' => $row
]);
}
public function del(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$ids = $request->get('ids') ?? $request->post('ids') ?? [];
$ids = is_array($ids) ? $ids : [];
$data = $this->model->where($this->model->getPk(), 'in', $ids)->select();
foreach ($data as $v) {
$authRes = $this->checkAuth($v->id);
if ($authRes !== null) return $authRes;
}
$subData = $this->model->where('pid', 'in', $ids)->column('pid', 'id');
foreach ($subData as $key => $subDatum) {
if (!in_array($key, $ids)) {
return $this->error(__('Please delete the child element first, or use batch deletion'));
}
}
$adminGroup = Db::name('admin_group_access')->where('uid', $this->auth->id)->column('group_id');
$count = 0;
$this->model->startTrans();
try {
foreach ($data as $v) {
if (!in_array($v['id'], $adminGroup)) {
$count += $v->delete();
}
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($count) {
return $this->success(__('Deleted successfully'));
}
return $this->error(__('No rows were deleted'));
}
public function select(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$data = $this->getGroups($request, [['status', '=', 1]]);
if ($this->assembleTree) {
$data = $this->tree->assembleTree($this->tree->getTreeArray($data));
}
return $this->success('', [
'options' => $data
]);
}
/**
* @return array|Response
*/
private function handleRules(array &$data)
{
if (!empty($data['rules']) && is_array($data['rules'])) {
$superAdmin = true;
$checkedRules = [];
$allRuleIds = AdminRule::column('id');
foreach ($data['rules'] as $postRuleId) {
if (in_array($postRuleId, $allRuleIds)) {
$checkedRules[] = $postRuleId;
}
}
foreach ($allRuleIds as $ruleId) {
if (!in_array($ruleId, $checkedRules)) {
$superAdmin = false;
}
}
if ($superAdmin && $this->auth->isSuperAdmin()) {
$data['rules'] = '*';
} else {
$ownedRuleIds = $this->auth->getRuleIds();
if (!array_diff($ownedRuleIds, $checkedRules)) {
return $this->error(__('Role group has all your rights, please contact the upper administrator to add or do not need to add!'));
}
if (array_diff($checkedRules, $ownedRuleIds) && !$this->auth->isSuperAdmin()) {
return $this->error(__('The group permission node exceeds the range that can be allocated'));
}
$data['rules'] = implode(',', $checkedRules);
}
} else {
unset($data['rules']);
}
return $data;
}
private function getGroups(Request $request, array $where = []): array
{
$pk = $this->model->getPk();
$initKey = $request->get('initKey') ?? $pk;
$absoluteAuth = $request->get('absoluteAuth') ?? false;
if ($this->keyword) {
$keyword = explode(' ', $this->keyword);
foreach ($keyword as $item) {
$where[] = [$this->quickSearchField, 'like', '%' . $item . '%'];
}
}
if ($this->initValue) {
$where[] = [$initKey, 'in', $this->initValue];
}
if (!$this->auth->isSuperAdmin()) {
$authGroups = $this->auth->getAllAuthGroups($this->authMethod, $where);
if (!$absoluteAuth) {
$authGroups = array_merge($this->adminGroups, $authGroups);
}
$where[] = ['id', 'in', $authGroups];
}
$data = $this->model->where($where)->select()->toArray();
foreach ($data as &$datum) {
if ($datum['rules']) {
if ($datum['rules'] == '*') {
$datum['rules'] = __('Super administrator');
} else {
$rules = explode(',', $datum['rules']);
if ($rules) {
$rulesFirstTitle = AdminRule::where('id', $rules[0])->value('title');
$datum['rules'] = count($rules) == 1 ? $rulesFirstTitle : $rulesFirstTitle . '等 ' . count($rules) . ' 项';
}
}
} else {
$datum['rules'] = __('No permission');
}
}
return $this->assembleTree ? $this->tree->assembleChild($data) : $data;
}
private function checkAuth($groupId): ?Response
{
$authGroups = $this->auth->getAllAuthGroups($this->authMethod, []);
if (!$this->auth->isSuperAdmin() && !in_array($groupId, $authGroups)) {
return $this->error(__($this->authMethod == 'allAuth' ? 'You need to have all permissions of this group to operate this group~' : 'You need to have all the permissions of the group and have additional permissions before you can operate the group~'));
}
return null;
}
}

View File

@@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\auth;
use Throwable;
use ba\Tree;
use app\common\library\Menu;
use app\admin\model\AdminRule;
use app\admin\model\AdminGroup;
use app\admin\library\crud\Helper;
use app\common\controller\Backend;
use support\Response;
use Webman\Http\Request;
class Rule extends Backend
{
protected string|array $preExcludeFields = ['create_time', 'update_time'];
protected string|array $defaultSortField = ['weigh' => 'desc'];
protected string|array $quickSearchField = 'title';
protected ?object $model = null;
protected Tree $tree;
protected array $initValue = [];
protected string $keyword = '';
protected bool $assembleTree = true;
protected bool $modelValidate = false;
protected function initController(Request $request): void
{
$this->model = new AdminRule();
$this->tree = Tree::instance();
$isTree = $request->get('isTree') ?? $request->post('isTree') ?? true;
$this->initValue = $request->get('initValue') ?? [];
$this->initValue = is_array($this->initValue) ? array_filter($this->initValue) : [];
$this->keyword = $request->get('quickSearch') ?? $request->post('quickSearch') ?? '';
$this->assembleTree = $isTree && !$this->initValue;
}
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->get('select') ?? $request->post('select')) {
return $this->select($request);
}
return $this->success('', [
'list' => $this->getMenus($request),
'remark' => get_route_remark(),
]);
}
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->method() === 'POST') {
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
$data[$this->dataLimitField] = $this->auth->id;
}
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('add');
}
$validate->check($data);
}
}
$result = $this->model->save($data);
if (!empty($data['pid'])) {
$this->autoAssignPermission($this->model->id, (int) $data['pid']);
}
if (($data['type'] ?? '') == 'menu' && !empty($data['buttons'])) {
$newButtons = [];
foreach ($data['buttons'] as $button) {
foreach (Helper::$menuChildren as $menuChild) {
if ($menuChild['name'] == '/' . $button) {
$menuChild['name'] = $data['name'] . $menuChild['name'];
$newButtons[] = $menuChild;
}
}
}
if (!empty($newButtons)) {
Menu::create($newButtons, $this->model->id, 'ignore');
$children = AdminRule::where('pid', $this->model->id)->select();
foreach ($children as $child) {
$this->autoAssignPermission($child['id'], $this->model->id);
}
}
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Added successfully'));
}
return $this->error(__('No rows were added'));
}
return $this->error(__('Parameter error'));
}
public function edit(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$id = $request->get($this->model->getPk()) ?? $request->post($this->model->getPk());
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
return $this->error(__('You have no permission'));
}
if ($request->method() === 'POST') {
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('edit');
}
$validate->check($data);
}
}
if (isset($data['pid']) && $data['pid'] > 0) {
$parent = $this->model->where('id', $data['pid'])->find();
if ($parent && $parent['pid'] == $row['id']) {
$parent->pid = 0;
$parent->save();
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Update successful'));
}
return $this->error(__('No rows updated'));
}
return $this->success('', [
'row' => $row
]);
}
public function del(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$ids = $request->get('ids') ?? $request->post('ids') ?? [];
$ids = is_array($ids) ? $ids : [];
$subData = $this->model->where('pid', 'in', $ids)->column('pid', 'id');
foreach ($subData as $key => $subDatum) {
if (!in_array($key, $ids)) {
return $this->error(__('Please delete the child element first, or use batch deletion'));
}
}
return $this->delFromTrait($request);
}
public function select(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$data = $this->getMenus($request, [['type', 'in', ['menu_dir', 'menu']], ['status', '=', 1]]);
if ($this->assembleTree) {
$data = $this->tree->assembleTree($this->tree->getTreeArray($data, 'title'));
}
return $this->success('', [
'options' => $data
]);
}
protected function getMenus(Request $request, array $where = []): array
{
$pk = $this->model->getPk();
$initKey = $request->get('initKey') ?? $pk;
$ids = $this->auth->getRuleIds();
if (!in_array('*', $ids)) {
$where[] = ['id', 'in', $ids];
}
if ($this->keyword) {
$keyword = explode(' ', $this->keyword);
foreach ($keyword as $item) {
$where[] = [$this->quickSearchField, 'like', '%' . $item . '%'];
}
}
if ($this->initValue) {
$where[] = [$initKey, 'in', $this->initValue];
}
$rules = $this->model
->where($where)
->order($this->queryOrderBuilder())
->select()
->toArray();
return $this->assembleTree ? $this->tree->assembleChild($rules) : $rules;
}
private function autoAssignPermission(int $id, int $pid): void
{
$groups = AdminGroup::where('rules', '<>', '*')->select();
foreach ($groups as $group) {
/** @var AdminGroup $group */
$rules = explode(',', (string) $group->rules);
if (in_array($pid, $rules) && !in_array($id, $rules)) {
$rules[] = $id;
$group->rules = implode(',', $rules);
$group->save();
}
}
}
/**
* 调用 trait 的 del 逻辑(因 Rule 重写了 del需手动调用 trait
*/
private function delFromTrait(Request $request): Response
{
$where = [];
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$where[] = [$this->dataLimitField, 'in', $dataLimitAdminIds];
}
$ids = $request->get('ids') ?? $request->post('ids') ?? [];
$ids = is_array($ids) ? $ids : [];
$where[] = [$this->model->getPk(), 'in', $ids];
$data = $this->model->where($where)->select();
$count = 0;
$this->model->startTrans();
try {
foreach ($data as $v) {
$count += $v->delete();
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($count) {
return $this->success(__('Deleted successfully'));
}
return $this->error(__('No rows were deleted'));
}
}

View File

@@ -0,0 +1,762 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\crud;
use Throwable;
use ba\Exception as BaException;
use ba\Filesystem;
use ba\TableManager;
use app\admin\model\CrudLog;
use app\admin\model\AdminLog;
use app\admin\model\AdminRule;
use app\common\controller\Backend;
use app\common\library\Menu;
use app\admin\library\crud\Helper;
use Webman\Http\Request;
use support\Response;
use support\think\Db;
/**
* CRUD 代码生成器Webman 迁移版)
*/
class Crud extends Backend
{
protected array $modelData = [];
protected array $controllerData = [];
protected array $indexVueData = [];
protected array $formVueData = [];
protected string $webTranslate = '';
protected array $langTsData = [];
protected array $dtStringToArray = ['checkbox', 'selects', 'remoteSelects', 'city', 'images', 'files'];
protected array $noNeedPermission = ['logStart', 'getFileData', 'parseFieldData', 'generateCheck', 'uploadCompleted'];
protected function initController(Request $request): ?Response
{
return null;
}
public function generate(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$type = $request->post('type', '');
$table = $request->post('table', []);
$fields = $request->post('fields', []);
if (!$table || !$fields || !isset($table['name']) || !$table['name']) {
return $this->error(__('Parameter error'));
}
$crudLogId = 0;
try {
$crudLogId = Helper::recordCrudStatus([
'table' => $table,
'fields' => $fields,
'status' => 'start',
]);
$tableName = TableManager::tableName($table['name'], false, $table['databaseConnection'] ?? null);
if ($type == 'create' || ($table['rebuild'] ?? '') == 'Yes') {
TableManager::phinxTable($tableName, [], true, $table['databaseConnection'] ?? null)->drop()->save();
}
[$tablePk] = Helper::handleTableDesign($table, $fields);
$tableComment = mb_substr($table['comment'] ?? '', -1) == '表'
? mb_substr($table['comment'], 0, -1) . '管理'
: ($table['comment'] ?? '');
$modelFile = Helper::parseNameData($table['isCommonModel'] ?? false ? 'common' : 'admin', $tableName, 'model', $table['modelFile'] ?? '');
$validateFile = Helper::parseNameData($table['isCommonModel'] ?? false ? 'common' : 'admin', $tableName, 'validate', $table['validateFile'] ?? '');
$controllerFile = Helper::parseNameData('admin', $tableName, 'controller', $table['controllerFile'] ?? '');
$webViewsDir = Helper::parseWebDirNameData($tableName, 'views', $table['webViewsDir'] ?? '');
$webLangDir = Helper::parseWebDirNameData($tableName, 'lang', $table['webViewsDir'] ?? '');
$this->webTranslate = implode('.', $webLangDir['lang']) . '.';
$quickSearchField = $table['quickSearchField'] ?? [$tablePk];
if (!in_array($tablePk, $quickSearchField)) {
$quickSearchField[] = $tablePk;
}
$quickSearchFieldZhCnTitle = [];
$this->modelData = [
'append' => [], 'methods' => [], 'fieldType' => [], 'createTime' => '', 'updateTime' => '',
'beforeInsertMixins' => [], 'beforeInsert' => '', 'afterInsert' => '',
'connection' => $table['databaseConnection'] ?? '', 'name' => $tableName,
'className' => $modelFile['lastName'], 'namespace' => $modelFile['namespace'],
'relationMethodList' => [],
];
$this->controllerData = [
'use' => [], 'attr' => [], 'methods' => [], 'filterRule' => '',
'className' => $controllerFile['lastName'], 'namespace' => $controllerFile['namespace'],
'tableComment' => $tableComment, 'modelName' => $modelFile['lastName'],
'modelNamespace' => $modelFile['namespace'],
];
$this->indexVueData = [
'enableDragSort' => false, 'defaultItems' => [],
'tableColumn' => [['type' => 'selection', 'align' => 'center', 'operator' => 'false']],
'dblClickNotEditColumn' => ['undefined'], 'optButtons' => ['edit', 'delete'],
'defaultOrder' => '',
];
$this->formVueData = ['bigDialog' => false, 'formFields' => [], 'formValidatorRules' => [], 'imports' => []];
$this->langTsData = ['en' => [], 'zh-cn' => []];
$fieldsMap = [];
foreach ($fields as $field) {
$fieldsMap[$field['name']] = $field['designType'];
Helper::analyseField($field);
Helper::getDictData($this->langTsData['en'], $field, 'en');
Helper::getDictData($this->langTsData['zh-cn'], $field, 'zh-cn');
if (in_array($field['name'], $quickSearchField)) {
$quickSearchFieldZhCnTitle[] = $this->langTsData['zh-cn'][$field['name']] ?? $field['name'];
}
if (($field['designType'] ?? '') == 'switch') {
$this->indexVueData['dblClickNotEditColumn'][] = $field['name'];
}
$columnDict = $this->getColumnDict($field);
if (in_array($field['name'], $table['formFields'] ?? [])) {
$this->formVueData['formFields'][] = $this->getFormField($field, $columnDict, $table['databaseConnection'] ?? null);
}
if (in_array($field['name'], $table['columnFields'] ?? [])) {
$this->indexVueData['tableColumn'][] = $this->getTableColumn($field, $columnDict);
}
if (in_array($field['designType'] ?? '', ['remoteSelect', 'remoteSelects'])) {
$this->parseJoinData($field, $table);
}
$this->parseModelMethods($field, $this->modelData);
$this->parseSundryData($field, $table);
if (!in_array($field['name'], $table['formFields'] ?? [])) {
$this->controllerData['attr']['preExcludeFields'][] = $field['name'];
}
}
$this->langTsData['en']['quick Search Fields'] = implode(',', $quickSearchField);
$this->langTsData['zh-cn']['quick Search Fields'] = implode('、', $quickSearchFieldZhCnTitle);
$this->controllerData['attr']['quickSearchField'] = $quickSearchField;
$weighKey = array_search('weigh', $fieldsMap);
if ($weighKey !== false) {
$this->indexVueData['enableDragSort'] = true;
$this->modelData['afterInsert'] = Helper::assembleStub('mixins/model/afterInsert', ['field' => $weighKey]);
}
$this->indexVueData['tableColumn'][] = [
'label' => "t('Operate')", 'align' => 'center',
'width' => $this->indexVueData['enableDragSort'] ? 140 : 100,
'render' => 'buttons', 'buttons' => 'optButtons', 'operator' => 'false',
];
if ($this->indexVueData['enableDragSort']) {
array_unshift($this->indexVueData['optButtons'], 'weigh-sort');
}
Helper::writeWebLangFile($this->langTsData, $webLangDir);
Helper::writeModelFile($tablePk, $fieldsMap, $this->modelData, $modelFile);
Helper::writeControllerFile($this->controllerData, $controllerFile);
$validateContent = Helper::assembleStub('mixins/validate/validate', [
'namespace' => $validateFile['namespace'],
'className' => $validateFile['lastName'],
]);
Helper::writeFile($validateFile['parseFile'], $validateContent);
$this->indexVueData['tablePk'] = $tablePk;
$this->indexVueData['webTranslate'] = $this->webTranslate;
Helper::writeIndexFile($this->indexVueData, $webViewsDir, $controllerFile);
Helper::writeFormFile($this->formVueData, $webViewsDir, $fields, $this->webTranslate);
Helper::createMenu($webViewsDir, $tableComment);
Helper::recordCrudStatus(['id' => $crudLogId, 'status' => 'success']);
} catch (BaException $e) {
Helper::recordCrudStatus(['id' => $crudLogId ?: 0, 'status' => 'error']);
return $this->error($e->getMessage());
} catch (Throwable $e) {
Helper::recordCrudStatus(['id' => $crudLogId ?: 0, 'status' => 'error']);
if (env('app_debug', false)) throw $e;
return $this->error($e->getMessage());
}
return $this->success('', ['crudLog' => CrudLog::find($crudLogId)]);
}
public function logStart(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$id = $request->post('id');
$type = $request->post('type', '');
if ($type == 'Cloud history') {
try {
$client = get_ba_client();
$response = $client->request('GET', '/api/v6.Crud/info', [
'query' => [
'id' => $id,
'server' => 1,
'ba-user-token' => $request->post('token', ''),
]
]);
$content = $response->getBody()->getContents();
$statusCode = $response->getStatusCode();
if ($content == '' || stripos($content, '<title>系统发生错误</title>') !== false || $statusCode != 200) {
return $this->error(__('Failed to load cloud data'));
}
$json = json_decode($content, true);
if (json_last_error() != JSON_ERROR_NONE || !is_array($json)) {
return $this->error(__('Failed to load cloud data'));
}
if (($json['code'] ?? 0) != 1) {
return $this->error($json['msg'] ?? __('Failed to load cloud data'));
}
$info = $json['data']['info'] ?? null;
} catch (Throwable $e) {
return $this->error(__('Failed to load cloud data'));
}
} else {
$row = CrudLog::find($id);
$info = $row ? $row->toArray() : null;
}
if (!$info) {
return $this->error(__('Record not found'));
}
$connection = TableManager::getConnection($info['table']['databaseConnection'] ?? '');
$tableName = TableManager::tableName($info['table']['name'], false, $connection);
$adapter = TableManager::phinxAdapter(true, $connection);
if ($adapter->hasTable($tableName)) {
$info['table']['empty'] = Db::connect($connection)->name($tableName)->limit(1)->select()->isEmpty();
} else {
$info['table']['empty'] = true;
}
AdminLog::instance($request)->setTitle(__('Log start'));
return $this->success('', [
'table' => $info['table'],
'fields' => $info['fields'],
'sync' => $info['sync'] ?? 0,
]);
}
public function delete(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$id = $request->post('id');
$row = CrudLog::find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
$info = $row->toArray();
$webLangDir = Helper::parseWebDirNameData($info['table']['name'], 'lang', $info['table']['webViewsDir'] ?? '');
$files = [
$webLangDir['en'] . '.ts',
$webLangDir['zh-cn'] . '.ts',
($info['table']['webViewsDir'] ?? '') . '/index.vue',
($info['table']['webViewsDir'] ?? '') . '/popupForm.vue',
$info['table']['controllerFile'] ?? '',
$info['table']['modelFile'] ?? '',
$info['table']['validateFile'] ?? '',
];
try {
foreach ($files as $file) {
$file = Filesystem::fsFit(root_path() . $file);
if (file_exists($file)) {
unlink($file);
}
Filesystem::delEmptyDir(dirname($file));
}
Menu::delete(Helper::getMenuName($webLangDir), true);
Helper::recordCrudStatus(['id' => $id, 'status' => 'delete']);
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
return $this->success(__('Deleted successfully'));
}
public function getFileData(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$table = $request->get('table');
$commonModel = $request->get('commonModel', false);
if (!$table) {
return $this->error(__('Parameter error'));
}
try {
$modelFile = Helper::parseNameData($commonModel ? 'common' : 'admin', $table, 'model');
$validateFile = Helper::parseNameData($commonModel ? 'common' : 'admin', $table, 'validate');
$controllerFile = Helper::parseNameData('admin', $table, 'controller');
$webViewsDir = Helper::parseWebDirNameData($table, 'views');
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
$adminModelFiles = Filesystem::getDirFiles(root_path() . 'app' . DIRECTORY_SEPARATOR . 'admin' . DIRECTORY_SEPARATOR . 'model' . DIRECTORY_SEPARATOR);
$commonModelFiles = Filesystem::getDirFiles(root_path() . 'app' . DIRECTORY_SEPARATOR . 'common' . DIRECTORY_SEPARATOR . 'model' . DIRECTORY_SEPARATOR);
$adminControllerFiles = get_controller_list();
$modelFileList = [];
$controllerFiles = [];
foreach ($adminModelFiles as $item) {
$item = Filesystem::fsFit('app/admin/model/' . $item);
$modelFileList[$item] = $item;
}
foreach ($commonModelFiles as $item) {
$item = Filesystem::fsFit('app/common/model/' . $item);
$modelFileList[$item] = $item;
}
$outExcludeController = ['Addon.php', 'Ajax.php', 'Dashboard.php', 'Index.php', 'Module.php', 'Terminal.php', 'routine/AdminInfo.php', 'routine/Config.php'];
foreach ($adminControllerFiles as $item) {
if (!in_array($item, $outExcludeController)) {
$item = Filesystem::fsFit('app/admin/controller/' . $item);
$controllerFiles[$item] = $item;
}
}
return $this->success('', [
'modelFile' => $modelFile['rootFileName'],
'controllerFile' => $controllerFile['rootFileName'],
'validateFile' => $validateFile['rootFileName'],
'controllerFileList' => $controllerFiles,
'modelFileList' => $modelFileList,
'webViewsDir' => $webViewsDir['views'],
]);
}
public function checkCrudLog(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$table = $request->get('table');
$connection = $request->get('connection', config('thinkorm.default', config('database.default', 'mysql')));
if (!$table) {
return $this->error(__('Parameter error'));
}
$crudLog = Db::name('crud_log')
->where('table_name', $table)
->where('connection', $connection)
->order('create_time desc')
->find();
$id = ($crudLog && isset($crudLog['status']) && $crudLog['status'] == 'success') ? $crudLog['id'] : 0;
return $this->success('', ['id' => $id]);
}
public function parseFieldData(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
AdminLog::instance($request)->setTitle(__('Parse field data'));
$type = $request->post('type');
$table = $request->post('table');
$connection = TableManager::getConnection($request->post('connection', ''));
$table = TableManager::tableName($table, true, $connection);
$connectionConfig = TableManager::getConnectionConfig($connection);
if ($type == 'db') {
$sql = 'SELECT * FROM `information_schema`.`tables` WHERE TABLE_SCHEMA = ? AND table_name = ?';
$tableInfo = Db::connect($connection)->query($sql, [$connectionConfig['database'], $table]);
if (!$tableInfo) {
return $this->error(__('Record not found'));
}
$adapter = TableManager::phinxAdapter(false, $connection);
$empty = $adapter->hasTable($table)
? Db::connect($connection)->table($table)->limit(1)->select()->isEmpty()
: true;
return $this->success('', [
'columns' => Helper::parseTableColumns($table, false, $connection),
'comment' => $tableInfo[0]['TABLE_COMMENT'] ?? '',
'empty' => $empty,
]);
}
return $this->error(__('Parameter error'));
}
public function generateCheck(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
AdminLog::instance($request)->setTitle(__('Generate check'));
$table = $request->post('table');
$connection = $request->post('connection', '');
$webViewsDir = $request->post('webViewsDir', '');
$controllerFile = $request->post('controllerFile', '');
if (!$table) {
return $this->error(__('Parameter error'));
}
try {
$webViewsDir = Helper::parseWebDirNameData($table, 'views', $webViewsDir);
$controllerFile = Helper::parseNameData('admin', $table, 'controller', $controllerFile)['rootFileName'];
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
$tableList = TableManager::getTableList($connection);
$tableExist = array_key_exists(TableManager::tableName($table, true, $connection), $tableList);
$controllerExist = file_exists(root_path() . $controllerFile);
$menuName = Helper::getMenuName($webViewsDir);
$menuExist = AdminRule::where('name', $menuName)->value('id');
if ($controllerExist || $tableExist || $menuExist) {
return $this->error('', [
'menu' => $menuExist,
'table' => $tableExist,
'controller' => $controllerExist,
], -1);
}
return $this->success();
}
public function uploadCompleted(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$syncIds = $request->post('syncIds', []);
$syncIds = is_array($syncIds) ? $syncIds : [];
$cancelSync = $request->post('cancelSync', false);
$crudLogModel = new CrudLog();
if ($cancelSync) {
$logData = $crudLogModel->where('id', 'in', array_keys($syncIds))->select();
foreach ($logData as $logDatum) {
if (isset($syncIds[$logDatum->id]) && $logDatum->sync == $syncIds[$logDatum->id]) {
$logDatum->sync = 0;
$logDatum->save();
}
}
return $this->success();
}
foreach ($syncIds as $key => $syncId) {
$row = $crudLogModel->find($key);
if ($row) {
$row->sync = $syncId;
$row->save();
}
}
return $this->success();
}
private function parseJoinData($field, $table): void
{
$dictEn = [];
$dictZhCn = [];
if (empty($field['form']['relation-fields']) || empty($field['form']['remote-table'])) {
return;
}
$columns = Helper::parseTableColumns($field['form']['remote-table'], true, $table['databaseConnection'] ?? null);
$relationFields = explode(',', $field['form']['relation-fields']);
$tableName = TableManager::tableName($field['form']['remote-table'], false, $table['databaseConnection'] ?? null);
$rnPattern = '/(.*)(_ids|_id)$/';
$relationName = preg_match($rnPattern, $field['name'])
? parse_name(preg_replace($rnPattern, '$1', $field['name']), 1, false)
: parse_name($field['name'] . '_table', 1, false);
if (empty($field['form']['remote-model']) || !file_exists(root_path() . $field['form']['remote-model'])) {
$joinModelFile = Helper::parseNameData('admin', $tableName, 'model', $field['form']['remote-model'] ?? '');
if (!file_exists(root_path() . $joinModelFile['rootFileName'])) {
$joinModelData = [
'append' => [], 'methods' => [], 'fieldType' => [], 'createTime' => '', 'updateTime' => '',
'beforeInsertMixins' => [], 'beforeInsert' => '', 'afterInsert' => '',
'connection' => $table['databaseConnection'] ?? '', 'name' => $tableName,
'className' => $joinModelFile['lastName'], 'namespace' => $joinModelFile['namespace'],
];
$joinTablePk = 'id';
$joinFieldsMap = [];
foreach ($columns as $column) {
$joinFieldsMap[$column['name']] = $column['designType'];
$this->parseModelMethods($column, $joinModelData);
if ($column['primaryKey']) $joinTablePk = $column['name'];
}
$weighKey = array_search('weigh', $joinFieldsMap);
if ($weighKey !== false) {
$joinModelData['afterInsert'] = Helper::assembleStub('mixins/model/afterInsert', ['field' => $weighKey]);
}
Helper::writeModelFile($joinTablePk, $joinFieldsMap, $joinModelData, $joinModelFile);
}
$field['form']['remote-model'] = $joinModelFile['rootFileName'];
}
if ($field['designType'] == 'remoteSelect') {
$this->controllerData['attr']['withJoinTable'][$relationName] = $relationName;
$relationData = [
'relationMethod' => $relationName, 'relationMode' => 'belongsTo',
'relationPrimaryKey' => $field['form']['remote-pk'] ?? 'id',
'relationForeignKey' => $field['name'],
'relationClassName' => str_replace(['.php', '/'], ['', '\\'], '\\' . $field['form']['remote-model']) . "::class",
];
$this->modelData['relationMethodList'][$relationName] = Helper::assembleStub('mixins/model/belongsTo', $relationData);
if ($relationFields) {
$this->controllerData['relationVisibleFieldList'][$relationData['relationMethod']] = $relationFields;
}
} elseif ($field['designType'] == 'remoteSelects') {
$this->modelData['append'][] = $relationName;
$this->modelData['methods'][] = Helper::assembleStub('mixins/model/getters/remoteSelectLabels', [
'field' => parse_name($relationName, 1),
'className' => str_replace(['.php', '/'], ['', '\\'], '\\' . $field['form']['remote-model']),
'primaryKey' => $field['form']['remote-pk'] ?? 'id',
'foreignKey' => $field['name'],
'labelFieldName' => $field['form']['remote-field'] ?? 'name',
]);
}
foreach ($relationFields as $relationField) {
if (!array_key_exists($relationField, $columns)) continue;
$relationFieldPrefix = $relationName . '.';
$relationFieldLangPrefix = strtolower($relationName) . '__';
Helper::getDictData($dictEn, $columns[$relationField], 'en', $relationFieldLangPrefix);
Helper::getDictData($dictZhCn, $columns[$relationField], 'zh-cn', $relationFieldLangPrefix);
if (($columns[$relationField]['designType'] ?? '') == 'switch') {
$this->indexVueData['dblClickNotEditColumn'][] = $field['name'];
}
$columnDict = $this->getColumnDict($columns[$relationField], $relationFieldLangPrefix);
$columns[$relationField]['designType'] = $field['designType'];
$columns[$relationField]['form'] = ($field['form'] ?? []) + ($columns[$relationField]['form'] ?? []);
$columns[$relationField]['table'] = ($field['table'] ?? []) + ($columns[$relationField]['table'] ?? []);
$remoteAttr = [
'pk' => $this->getRemoteSelectPk($field),
'field' => $field['form']['remote-field'] ?? 'name',
'remoteUrl' => $this->getRemoteSelectUrl($field),
];
if (($columns[$relationField]['table']['comSearchRender'] ?? '') == 'remoteSelect') {
$renderColumn = $columns[$relationField];
$renderColumn['table']['operator'] = 'false';
unset($renderColumn['table']['comSearchRender']);
$this->indexVueData['tableColumn'][] = $this->getTableColumn($renderColumn, $columnDict, $relationFieldPrefix, $relationFieldLangPrefix);
$columns[$relationField]['table']['show'] = 'false';
$columns[$relationField]['table']['label'] = "t('" . $this->webTranslate . $relationFieldLangPrefix . $columns[$relationField]['name'] . "')";
$columns[$relationField]['name'] = $field['name'];
if ($field['designType'] == 'remoteSelects') {
$remoteAttr['multiple'] = 'true';
}
$columnData = $this->getTableColumn($columns[$relationField], $columnDict, '', $relationFieldLangPrefix);
$columnData['comSearchInputAttr'] = array_merge($remoteAttr, $columnData['comSearchInputAttr'] ?? []);
} else {
$columnData = $this->getTableColumn($columns[$relationField], $columnDict, $relationFieldPrefix, $relationFieldLangPrefix);
}
$this->indexVueData['tableColumn'][] = $columnData;
}
$this->langTsData['en'] = array_merge($this->langTsData['en'], $dictEn);
$this->langTsData['zh-cn'] = array_merge($this->langTsData['zh-cn'], $dictZhCn);
}
private function parseModelMethods($field, &$modelData): void
{
if (($field['designType'] ?? '') == 'array') {
$modelData['fieldType'][$field['name']] = 'json';
} elseif (!in_array($field['name'], ['create_time', 'update_time', 'updatetime', 'createtime'])
&& ($field['designType'] ?? '') == 'datetime'
&& in_array($field['type'] ?? '', ['int', 'bigint'])) {
$modelData['fieldType'][$field['name']] = 'timestamp:Y-m-d H:i:s';
}
if (($field['designType'] ?? '') == 'spk') {
$modelData['beforeInsertMixins']['snowflake'] = Helper::assembleStub('mixins/model/mixins/beforeInsertWithSnowflake', []);
}
$fieldName = parse_name($field['name'], 1);
if (in_array($field['designType'] ?? '', $this->dtStringToArray)) {
$modelData['methods'][] = Helper::assembleStub('mixins/model/getters/stringToArray', ['field' => $fieldName]);
$modelData['methods'][] = Helper::assembleStub('mixins/model/setters/arrayToString', ['field' => $fieldName]);
} elseif (($field['designType'] ?? '') == 'array') {
$modelData['methods'][] = Helper::assembleStub('mixins/model/getters/jsonDecode', ['field' => $fieldName]);
} elseif (($field['designType'] ?? '') == 'time') {
$modelData['methods'][] = Helper::assembleStub('mixins/model/setters/time', ['field' => $fieldName]);
} elseif (($field['designType'] ?? '') == 'editor') {
$modelData['methods'][] = Helper::assembleStub('mixins/model/getters/htmlDecode', ['field' => $fieldName]);
} elseif (($field['designType'] ?? '') == 'spk') {
$modelData['methods'][] = Helper::assembleStub('mixins/model/getters/string', ['field' => $fieldName]);
} elseif (in_array($field['type'] ?? '', ['float', 'decimal', 'double'])) {
$modelData['methods'][] = Helper::assembleStub('mixins/model/getters/float', ['field' => $fieldName]);
}
if (($field['designType'] ?? '') == 'city') {
$modelData['append'][] = $field['name'] . '_text';
$modelData['methods'][] = Helper::assembleStub('mixins/model/getters/cityNames', [
'field' => $fieldName . 'Text',
'originalFieldName' => $field['name'],
]);
}
}
private function parseSundryData($field, $table): void
{
if (($field['designType'] ?? '') == 'editor') {
$this->formVueData['bigDialog'] = true;
$this->controllerData['filterRule'] = "\n" . Helper::tab(2) . '$this->request->filter(\'clean_xss\');';
}
if (!empty($table['defaultSortField']) && !empty($table['defaultSortType'])) {
$defaultSortField = "{$table['defaultSortField']},{$table['defaultSortType']}";
if ($defaultSortField == 'id,desc') {
$this->controllerData['attr']['defaultSortField'] = '';
} else {
$this->controllerData['attr']['defaultSortField'] = $defaultSortField;
$this->indexVueData['defaultOrder'] = Helper::buildDefaultOrder($table['defaultSortField'], $table['defaultSortType']);
}
}
if (($field['originalDesignType'] ?? '') == 'weigh' && $field['name'] != 'weigh') {
$this->controllerData['attr']['weighField'] = $field['name'];
}
}
private function getFormField($field, $columnDict, ?string $dbConnection = null): array
{
$formField = [
':label' => 't(\'' . $this->webTranslate . $field['name'] . '\')',
'type' => $field['designType'],
'v-model' => 'baTable.form.items!.' . $field['name'],
'prop' => $field['name'],
];
if ($columnDict || in_array($field['designType'], ['radio', 'checkbox', 'select', 'selects'])) {
$formField[':input-attr']['content'] = $columnDict;
} elseif ($field['designType'] == 'textarea') {
$formField[':input-attr']['rows'] = (int)($field['form']['rows'] ?? 3);
$formField['@keyup.enter.stop'] = '';
$formField['@keyup.ctrl.enter'] = 'baTable.onSubmit(formRef)';
} elseif (in_array($field['designType'], ['remoteSelect', 'remoteSelects'])) {
$formField[':input-attr']['pk'] = $this->getRemoteSelectPk($field);
$formField[':input-attr']['field'] = $field['form']['remote-field'] ?? 'name';
$formField[':input-attr']['remoteUrl'] = $this->getRemoteSelectUrl($field);
} elseif ($field['designType'] == 'number') {
$formField[':input-attr']['step'] = (int)($field['form']['step'] ?? 1);
} elseif ($field['designType'] == 'icon') {
$formField[':input-attr']['placement'] = 'top';
} elseif ($field['designType'] == 'editor') {
$formField['@keyup.enter.stop'] = '';
$formField['@keyup.ctrl.enter'] = 'baTable.onSubmit(formRef)';
}
if (!in_array($field['designType'], ['image', 'images', 'file', 'files', 'switch'])) {
if (in_array($field['designType'], ['radio', 'checkbox', 'datetime', 'year', 'date', 'time', 'select', 'selects', 'remoteSelect', 'remoteSelects', 'city', 'icon'])) {
$formField[':placeholder'] = "t('Please select field', { field: t('" . $this->webTranslate . $field['name'] . "') })";
} else {
$formField[':placeholder'] = "t('Please input field', { field: t('" . $this->webTranslate . $field['name'] . "') })";
}
}
if (($field['defaultType'] ?? '') == 'INPUT') {
$this->indexVueData['defaultItems'][$field['name']] = $field['default'];
}
if ($field['designType'] == 'editor') {
$this->indexVueData['defaultItems'][$field['name']] = (($field['defaultType'] ?? '') == 'INPUT' && ($field['default'] ?? '')) ? $field['default'] : '';
} elseif ($field['designType'] == 'array') {
$this->indexVueData['defaultItems'][$field['name']] = "[]";
} elseif (($field['defaultType'] ?? '') == 'INPUT' && in_array($field['designType'], $this->dtStringToArray) && str_contains($field['default'] ?? '', ',')) {
$this->indexVueData['defaultItems'][$field['name']] = Helper::buildSimpleArray(explode(',', $field['default']));
} elseif (($field['defaultType'] ?? '') == 'INPUT' && in_array($field['designType'], ['number', 'float'])) {
$this->indexVueData['defaultItems'][$field['name']] = (float)($field['default'] ?? 0);
}
if (isset($field['default']) && in_array($field['designType'], ['switch', 'number', 'float', 'remoteSelect']) && $field['default'] == 0) {
unset($this->indexVueData['defaultItems'][$field['name']]);
}
return $formField;
}
private function getRemoteSelectPk($field): string
{
$pk = $field['form']['remote-pk'] ?? 'id';
$alias = '';
if (!str_contains($pk, '.')) {
if (($field['form']['remote-source-config-type'] ?? '') == 'crud' && !empty($field['form']['remote-model'])) {
$alias = parse_name(basename(str_replace('\\', '/', $field['form']['remote-model']), '.php'));
} else {
$alias = $field['form']['remote-primary-table-alias'] ?? '';
}
}
return !empty($alias) ? "$alias.$pk" : $pk;
}
private function getRemoteSelectUrl($field): string
{
if (($field['form']['remote-source-config-type'] ?? '') == 'crud' && !empty($field['form']['remote-controller'])) {
$pathArr = [];
$controller = explode(DIRECTORY_SEPARATOR, $field['form']['remote-controller']);
$controller = str_replace('.php', '', $controller);
$redundantDir = ['app' => 0, 'admin' => 1, 'controller' => 2];
foreach ($controller as $key => $item) {
if (!array_key_exists($item, $redundantDir) || $key !== $redundantDir[$item]) {
$pathArr[] = $item;
}
}
$url = count($pathArr) > 1 ? implode('.', $pathArr) : ($pathArr[0] ?? '');
return '/admin/' . $url . '/index';
}
return $field['form']['remote-url'] ?? '';
}
private function getTableColumn($field, $columnDict, string $fieldNamePrefix = '', string $translationPrefix = ''): array
{
$column = [
'label' => "t('" . $this->webTranslate . $translationPrefix . $field['name'] . "')",
'prop' => $fieldNamePrefix . $field['name'] . (($field['designType'] ?? '') == 'city' ? '_text' : ''),
'align' => 'center',
];
if (isset($field['table']['operator']) && $field['table']['operator'] == 'LIKE') {
$column['operatorPlaceholder'] = "t('Fuzzy query')";
}
if (!empty($field['table'])) {
$column = array_merge($column, $field['table']);
$column['comSearchInputAttr'] = str_attr_to_array($column['comSearchInputAttr'] ?? '');
}
$columnReplaceValue = ['tag', 'tags', 'switch'];
if (!in_array($field['designType'] ?? '', ['remoteSelect', 'remoteSelects'])
&& ($columnDict || (isset($field['table']['render']) && in_array($field['table']['render'], $columnReplaceValue)))) {
$column['replaceValue'] = $columnDict;
}
if (isset($column['render']) && $column['render'] == 'none') {
unset($column['render']);
}
return $column;
}
private function getColumnDict($column, string $translationPrefix = ''): array
{
$dict = [];
if (in_array($column['type'] ?? '', ['enum', 'set'])) {
$dataType = str_replace(' ', '', $column['dataType'] ?? '');
$columnData = substr($dataType, stripos($dataType, '(') + 1, -1);
$columnData = explode(',', str_replace(["'", '"'], '', $columnData));
foreach ($columnData as $columnDatum) {
$dict[$columnDatum] = $column['name'] . ' ' . $columnDatum;
}
}
$dictData = [];
Helper::getDictData($dictData, $column, 'zh-cn', $translationPrefix);
if ($dictData) {
unset($dictData[$translationPrefix . $column['name']]);
foreach ($dictData as $key => $item) {
$keyName = str_replace($translationPrefix . $column['name'] . ' ', '', $key);
$dict[$keyName] = "t('" . $this->webTranslate . $key . "')";
}
}
return $dict;
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\crud;
use app\admin\model\CrudLog;
use app\common\controller\Backend;
use Webman\Http\Request;
use support\Response;
class Log extends Backend
{
protected ?object $model = null;
protected array|string $preExcludeFields = ['id', 'create_time'];
protected array|string $quickSearchField = ['id', 'table_name', 'comment'];
protected array $noNeedPermission = ['index'];
protected function initController(Request $request): ?Response
{
$this->model = new CrudLog();
if (!$this->auth->check('crud/crud/index')) {
return $this->error(__('You have no permission'), [], 401);
}
return null;
}
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->get('select') || $request->post('select')) {
return $this->select($request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withJoin($this->withJoinTable ?? [], $this->withJoinType ?? 'LEFT')
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\routine;
use app\admin\model\Admin;
use app\common\controller\Backend;
use Webman\Http\Request;
use support\Response;
class AdminInfo extends Backend
{
protected ?object $model = null;
protected array|string $preExcludeFields = ['username', 'last_login_time', 'password', 'salt', 'status'];
protected array $authAllowFields = ['id', 'username', 'nickname', 'avatar', 'email', 'mobile', 'motto', 'last_login_time'];
protected function initController(Request $request): void
{
$this->auth->setAllowFields($this->authAllowFields);
$this->model = $this->auth->getAdmin();
}
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$info = $this->auth->getInfo();
return $this->success('', ['info' => $info]);
}
public function edit(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$pk = $this->model->getPk();
$id = $request->post($pk) ?? $request->get($pk);
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
if ($request->method() === 'POST') {
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
if (!empty($data['avatar'])) {
$row->avatar = $data['avatar'];
if ($row->save()) {
return $this->success(__('Avatar modified successfully!'));
}
}
if ($this->modelValidate) {
$validateClass = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validateClass)) {
try {
$validate = new $validateClass();
$validate->scene('info')->check($data);
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
}
if (!empty($data['password'])) {
$this->model->resetPassword($this->auth->id, $data['password']);
}
$data = $this->excludeFields($data);
$result = false;
$this->model->startTrans();
try {
$result = $row->save($data);
$this->model->commit();
} catch (\Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
return $result !== false ? $this->success(__('Update successful')) : $this->error(__('No rows updated'));
}
return $this->success('', ['row' => $row]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\routine;
use app\common\controller\Backend;
use app\common\model\Attachment as AttachmentModel;
use Webman\Http\Request;
use support\Response;
class Attachment extends Backend
{
protected ?object $model = null;
protected array|string $quickSearchField = 'name';
protected array $withJoinTable = ['admin', 'user'];
protected array|string $defaultSortField = ['last_upload_time' => 'desc'];
protected function initController(Request $request): void
{
$this->model = new AttachmentModel();
}
public function del(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$where = [];
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$where[] = [$this->dataLimitField, 'in', $dataLimitAdminIds];
}
$ids = $request->post('ids', $request->get('ids', []));
$ids = is_array($ids) ? $ids : [];
$where[] = [$this->model->getPk(), 'in', $ids];
$data = $this->model->where($where)->select();
$count = 0;
try {
foreach ($data as $v) {
$count += $v->delete();
}
} catch (\Throwable $e) {
return $this->error(__('%d records and files have been deleted', [$count]) . $e->getMessage());
}
return $count ? $this->success(__('%d records and files have been deleted', [$count])) : $this->error(__('No rows were deleted'));
}
}

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\routine;
use ba\Filesystem;
use app\common\library\Email;
use PHPMailer\PHPMailer\PHPMailer;
use app\common\controller\Backend;
use app\admin\model\Config as ConfigModel;
use PHPMailer\PHPMailer\Exception as PHPMailerException;
use Webman\Http\Request;
use support\Response;
class Config extends Backend
{
protected ?object $model = null;
protected array $filePath = [
'appConfig' => 'config/app.php',
'webAdminBase' => 'web/src/router/static/adminBase.ts',
'backendEntranceStub' => 'app/admin/library/stubs/backendEntrance.stub',
];
protected function initController(Request $request): void
{
$this->model = new ConfigModel();
}
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$configGroup = get_sys_config('config_group');
$config = $this->model->order('weigh desc')->select()->toArray();
$list = [];
$newConfigGroup = [];
if (is_array($configGroup)) {
foreach ($configGroup as $item) {
$key = $item['key'] ?? $item;
$val = $item['value'] ?? $item;
$list[$key] = ['name' => $key, 'title' => __($val)];
$newConfigGroup[$key] = $list[$key]['title'];
}
}
foreach ($config as $item) {
$group = $item['group'] ?? '';
if (isset($newConfigGroup[$group])) {
$item['title'] = __($item['title'] ?? '');
$list[$group]['list'][] = $item;
}
}
return $this->success('', [
'list' => $list,
'remark' => get_route_remark(),
'configGroup' => $newConfigGroup,
'quickEntrance' => get_sys_config('config_quick_entrance'),
]);
}
public function edit(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$all = $this->model->select();
if ($request->method() === 'POST') {
$this->modelValidate = false;
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$configValue = [];
foreach ($all as $item) {
if (array_key_exists($item->name, $data)) {
$configValue[] = [
'id' => $item->id,
'type' => $item->getData('type'),
'value' => $data[$item->name]
];
}
}
$result = false;
$this->model->startTrans();
try {
foreach ($configValue as $cv) {
$this->model->where('id', $cv['id'])->update(['value' => $cv['value']]);
}
$this->model->commit();
$result = true;
} catch (\Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
return $result ? $this->success(__('The current page configuration item was updated successfully')) : $this->error(__('No rows updated'));
}
return $this->error(__('Parameter error'));
}
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validateClass = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validateClass)) {
$validate = new $validateClass();
$validate->scene('add')->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (\Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
return $result !== false ? $this->success(__('Added successfully')) : $this->error(__('No rows were added'));
}
public function sendTestMail(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$data = $request->post();
$mail = new Email();
try {
$mail->Host = $data['smtp_server'] ?? '';
$mail->SMTPAuth = true;
$mail->Username = $data['smtp_user'] ?? '';
$mail->Password = $data['smtp_pass'] ?? '';
$mail->SMTPSecure = ($data['smtp_verification'] ?? '') == 'SSL' ? PHPMailer::ENCRYPTION_SMTPS : PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = $data['smtp_port'] ?? 465;
$mail->setFrom($data['smtp_sender_mail'] ?? '', $data['smtp_user'] ?? '');
$mail->isSMTP();
$mail->addAddress($data['testMail'] ?? '');
$mail->isHTML();
$mail->setSubject(__('This is a test email') . '-' . get_sys_config('site_name'));
$mail->Body = __('Congratulations, receiving this email means that your email service has been configured correctly');
$mail->send();
} catch (PHPMailerException) {
return $this->error($mail->ErrorInfo ?? '');
}
return $this->success(__('Test mail sent successfully~'));
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\security;
use app\common\controller\Backend;
use app\admin\model\DataRecycle as DataRecycleModel;
use Webman\Http\Request;
use support\Response;
class DataRecycle extends Backend
{
protected ?object $model = null;
protected array|string $preExcludeFields = ['update_time', 'create_time'];
protected array|string $quickSearchField = 'name';
protected function initController(Request $request): ?Response
{
$this->model = new DataRecycleModel();
return null;
}
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->get('select') || $request->post('select')) {
return $this->select($request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withJoin($this->withJoinTable ?? [], $this->withJoinType ?? 'LEFT')
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->method() === 'POST') {
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$data['controller_as'] = str_ireplace('.php', '', $data['controller'] ?? '');
$data['controller_as'] = strtolower(str_ireplace(['\\', '.'], '/', $data['controller_as']));
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validateClass = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validateClass)) {
$validate = new $validateClass();
$validate->scene('add')->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (\Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
return $result !== false ? $this->success(__('Added successfully')) : $this->error(__('No rows were added'));
}
return $this->success('', [
'controllers' => $this->getControllerList(),
]);
}
public function edit(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$pk = $this->model->getPk();
$id = $request->post($pk) ?? $request->get($pk);
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
if ($request->method() === 'POST') {
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$data['controller_as'] = str_ireplace('.php', '', $data['controller'] ?? '');
$data['controller_as'] = strtolower(str_ireplace(['\\', '.'], '/', $data['controller_as']));
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validateClass = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validateClass)) {
$validate = new $validateClass();
$validate->scene('edit')->check(array_merge($data, [$pk => $row[$pk]]));
}
}
$result = $row->save($data);
$this->model->commit();
} catch (\Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
return $result !== false ? $this->success(__('Update successful')) : $this->error(__('No rows updated'));
}
return $this->success('', [
'row' => $row,
'controllers' => $this->getControllerList(),
]);
}
private function getControllerList(): array
{
$outExcludeController = [
'Addon.php',
'Ajax.php',
'Module.php',
'Terminal.php',
'Dashboard.php',
'Index.php',
'routine/AdminInfo.php',
'user/MoneyLog.php',
'user/ScoreLog.php',
];
$outControllers = [];
$controllers = get_controller_list();
foreach ($controllers as $key => $controller) {
if (!in_array($controller, $outExcludeController)) {
$outControllers[$key] = $controller;
}
}
return $outControllers;
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\security;
use ba\TableManager;
use support\think\Db;
use app\common\controller\Backend;
use app\admin\model\DataRecycleLog as DataRecycleLogModel;
use Webman\Http\Request;
use support\Response;
class DataRecycleLog extends Backend
{
protected ?object $model = null;
protected array|string $preExcludeFields = [];
protected array|string $quickSearchField = 'recycle.name';
protected array $withJoinTable = ['recycle', 'admin'];
protected function initController(Request $request): ?Response
{
$this->model = new DataRecycleLogModel();
return null;
}
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->get('select') || $request->post('select')) {
return $this->select($request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
public function restore(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$ids = $request->post('ids', $request->get('ids', []));
$ids = is_array($ids) ? $ids : [];
$data = $this->model->where('id', 'in', $ids)->select();
if (!$data) {
return $this->error(__('Record not found'));
}
$count = 0;
$this->model->startTrans();
try {
foreach ($data as $row) {
$recycleData = json_decode($row['data'], true);
if (is_array($recycleData) && Db::connect(TableManager::getConnection($row->connection))->name($row->data_table)->insert($recycleData)) {
$row->delete();
$count++;
}
}
$this->model->commit();
} catch (\Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
return $count ? $this->success(__('Restore successful')) : $this->error(__('No rows were restore'));
}
public function info(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$pk = $this->model->getPk();
$id = $request->get($pk) ?? $request->post($pk);
$row = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->where('data_recycle_log.id', $id)
->find();
if (!$row) {
return $this->error(__('Record not found'));
}
$data = $this->jsonToArray($row['data']);
if (is_array($data)) {
foreach ($data as $key => $item) {
$data[$key] = $this->jsonToArray($item);
}
}
$row['data'] = $data;
return $this->success('', ['row' => $row]);
}
protected function jsonToArray(mixed $value = ''): mixed
{
if (!is_string($value)) {
return $value;
}
$data = json_decode($value, true);
if (($data && is_object($data)) || (is_array($data) && !empty($data))) {
return $data;
}
return $value;
}
}

View File

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\security;
use app\common\controller\Backend;
use app\admin\model\SensitiveData as SensitiveDataModel;
use Webman\Http\Request;
use support\Response;
class SensitiveData extends Backend
{
protected ?object $model = null;
protected array|string $preExcludeFields = ['update_time', 'create_time'];
protected array|string $quickSearchField = 'controller';
protected function initController(Request $request): ?Response
{
$this->model = new SensitiveDataModel();
return null;
}
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->get('select') || $request->post('select')) {
return $this->select($request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
$items = $res->items();
foreach ($items as $item) {
if ($item->data_fields) {
$fields = [];
foreach ($item->data_fields as $key => $field) {
$fields[] = $field ?: $key;
}
$item->data_fields = $fields;
}
}
return $this->success('', [
'list' => $items,
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->method() !== 'POST') {
return $this->success('', ['controllers' => $this->getControllerList()]);
}
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$data['controller_as'] = str_ireplace('.php', '', $data['controller'] ?? '');
$data['controller_as'] = strtolower(str_ireplace(['\\', '.'], '/', $data['controller_as']));
if (is_array($data['fields'] ?? null)) {
$data['data_fields'] = [];
foreach ($data['fields'] as $field) {
$data['data_fields'][$field['name']] = $field['value'];
}
}
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validateClass = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validateClass)) {
$validate = new $validateClass();
$validate->scene('add')->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (\Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
return $result !== false ? $this->success(__('Added successfully')) : $this->error(__('No rows were added'));
}
public function edit(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$pk = $this->model->getPk();
$id = $request->get($pk) ?? $request->post($pk);
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
if ($request->method() !== 'POST') {
return $this->success('', [
'row' => $row,
'controllers' => $this->getControllerList(),
]);
}
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$data['controller_as'] = str_ireplace('.php', '', $data['controller'] ?? '');
$data['controller_as'] = strtolower(str_ireplace(['\\', '.'], '/', $data['controller_as']));
if (is_array($data['fields'] ?? null)) {
$data['data_fields'] = [];
foreach ($data['fields'] as $field) {
$data['data_fields'][$field['name']] = $field['value'];
}
}
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validateClass = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validateClass)) {
$validate = new $validateClass();
$validate->scene('edit')->check(array_merge($data, [$pk => $row[$pk]]));
}
}
$result = $row->save($data);
$this->model->commit();
} catch (\Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
return $result !== false ? $this->success(__('Update successful')) : $this->error(__('No rows updated'));
}
private function getControllerList(): array
{
$outExcludeController = [
'Addon.php',
'Ajax.php',
'Dashboard.php',
'Index.php',
'Module.php',
'Terminal.php',
'auth/AdminLog.php',
'routine/AdminInfo.php',
'routine/Config.php',
'user/MoneyLog.php',
'user/ScoreLog.php',
];
$outControllers = [];
$controllers = get_controller_list();
foreach ($controllers as $key => $controller) {
if (!in_array($controller, $outExcludeController)) {
$outControllers[$key] = $controller;
}
}
return $outControllers;
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\security;
use ba\TableManager;
use support\think\Db;
use app\common\controller\Backend;
use app\admin\model\SensitiveDataLog as SensitiveDataLogModel;
use Webman\Http\Request;
use support\Response;
class SensitiveDataLog extends Backend
{
protected ?object $model = null;
protected array|string $preExcludeFields = [];
protected array|string $quickSearchField = 'sensitive.name';
protected array $withJoinTable = ['sensitive', 'admin'];
protected function initController(Request $request): ?Response
{
$this->model = new SensitiveDataLogModel();
return null;
}
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->get('select') || $request->post('select')) {
return $this->select($request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
$items = $res->items();
foreach ($items as $item) {
$item->id_value = $item['primary_key'] . '=' . $item->id_value;
}
return $this->success('', [
'list' => $items,
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
public function info(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$pk = $this->model->getPk();
$id = $request->get($pk) ?? $request->post($pk);
$row = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->where('security_sensitive_data_log.id', $id)
->find();
if (!$row) {
return $this->error(__('Record not found'));
}
return $this->success('', ['row' => $row]);
}
public function rollback(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$ids = $request->post('ids', $request->get('ids', []));
$ids = is_array($ids) ? $ids : [];
$data = $this->model->where('id', 'in', $ids)->select();
if (!$data) {
return $this->error(__('Record not found'));
}
$count = 0;
$this->model->startTrans();
try {
foreach ($data as $row) {
$conn = Db::connect(TableManager::getConnection($row->connection));
if ($conn->name($row->data_table)->where($row->primary_key, $row->id_value)->update([$row->data_field => $row->before])) {
$count++;
}
}
$this->model->commit();
} catch (\Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
return $count ? $this->success(__('Rollback successful')) : $this->error(__('No rows were rolled back'));
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\user;
use Throwable;
use app\admin\model\UserRule;
use app\admin\model\UserGroup;
use app\common\controller\Backend;
use Webman\Http\Request;
use support\Response;
class Group extends Backend
{
protected ?object $model = null;
protected array|string $preExcludeFields = ['update_time', 'create_time'];
protected array|string $quickSearchField = 'name';
protected function initController(Request $request): void
{
$this->model = new UserGroup();
}
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$data = $this->handleRules($data);
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validateClass = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validateClass)) {
$validate = new $validateClass();
$validate->scene('add')->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
return $result !== false ? $this->success(__('Added successfully')) : $this->error(__('No rows were added'));
}
public function edit(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$pk = $this->model->getPk();
$id = $request->post($pk) ?? $request->get($pk);
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
if ($request->method() === 'POST') {
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$data = $this->handleRules($data);
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validateClass = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validateClass)) {
$validate = new $validateClass();
$validate->scene('edit')->check(array_merge($data, [$pk => $row[$pk]]));
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
return $result !== false ? $this->success(__('Update successful')) : $this->error(__('No rows updated'));
}
$pidArr = UserRule::field('pid')->distinct(true)->where('id', 'in', $row->rules)->select()->toArray();
$rules = $row->rules ? explode(',', $row->rules) : [];
foreach ($pidArr as $item) {
$ruKey = array_search($item['pid'], $rules);
if ($ruKey !== false) {
unset($rules[$ruKey]);
}
}
$row->rules = array_values($rules);
return $this->success('', ['row' => $row]);
}
private function handleRules(array $data): array
{
if (isset($data['rules']) && is_array($data['rules']) && $data['rules']) {
$rules = UserRule::select();
$super = true;
foreach ($rules as $rule) {
if (!in_array($rule['id'], $data['rules'])) {
$super = false;
break;
}
}
$data['rules'] = $super ? '*' : implode(',', $data['rules']);
} else {
unset($data['rules']);
}
return $data;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\user;
use app\admin\model\User;
use app\admin\model\UserMoneyLog;
use app\common\controller\Backend;
use Webman\Http\Request;
use support\Response;
class MoneyLog extends Backend
{
protected ?object $model = null;
protected array $withJoinTable = ['user'];
protected array|string $preExcludeFields = ['create_time'];
protected array|string $quickSearchField = ['user.username', 'user.nickname'];
protected function initController(Request $request): void
{
$this->model = new UserMoneyLog();
}
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->method() === 'POST') {
return $this->_add();
}
$userId = $request->get('userId', $request->post('userId', 0));
$user = User::where('id', $userId)->find();
if (!$user) {
return $this->error(__("The user can't find it"));
}
return $this->success('', ['user' => $user]);
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\user;
use ba\Tree;
use app\common\controller\Backend;
use app\admin\model\UserRule;
use Webman\Http\Request;
use support\Response;
class Rule extends Backend
{
protected ?object $model = null;
protected Tree $tree;
protected array $initValue = [];
protected bool $assembleTree = true;
protected string $keyword = '';
protected array|string $preExcludeFields = ['create_time', 'update_time'];
protected array|string $defaultSortField = ['weigh' => 'desc'];
protected array|string $quickSearchField = 'title';
protected function initController(Request $request): void
{
$this->model = new UserRule();
$this->tree = Tree::instance();
$isTree = filter_var($request->get('isTree', $request->post('isTree', true)), FILTER_VALIDATE_BOOLEAN);
$this->initValue = $request->get('initValue', $request->post('initValue', []));
$this->initValue = is_array($this->initValue) ? array_filter($this->initValue) : [];
$this->keyword = $request->get('quickSearch', $request->post('quickSearch', ''));
$this->assembleTree = $isTree && !$this->initValue;
}
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->get('select') || $request->post('select')) {
return $this->select($request);
}
list($where, , , ) = $this->queryBuilder();
$list = $this->model->where($where)->order($this->defaultSortField)->select();
if ($this->assembleTree) {
$list = $this->tree->assembleChild($list->toArray());
} else {
$list = $list->toArray();
}
return $this->success('', [
'list' => $list,
'remark' => get_route_remark(),
]);
}
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->_add();
}
public function edit(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->_edit();
}
public function del(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->_del();
}
public function select(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
list($where, , , ) = $this->queryBuilder();
$list = $this->model->where($where)->order($this->defaultSortField)->select();
if ($this->assembleTree) {
$list = $this->tree->assembleChild($list->toArray());
} else {
$list = $list->toArray();
}
return $this->success('', ['list' => $list]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\user;
use app\admin\model\User;
use app\admin\model\UserScoreLog;
use app\common\controller\Backend;
use Webman\Http\Request;
use support\Response;
class ScoreLog extends Backend
{
protected ?object $model = null;
protected array $withJoinTable = ['user'];
protected array|string $preExcludeFields = ['create_time'];
protected array|string $quickSearchField = ['user.username', 'user.nickname'];
protected function initController(Request $request): void
{
$this->model = new UserScoreLog();
}
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->method() === 'POST') {
return $this->_add();
}
$userId = $request->get('userId', $request->post('userId', 0));
$user = User::where('id', $userId)->find();
if (!$user) {
return $this->error(__("The user can't find it"));
}
return $this->success('', ['user' => $user]);
}
}

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\user;
use Throwable;
use app\common\controller\Backend;
use app\admin\model\User as UserModel;
use Webman\Http\Request;
use support\Response;
class User extends Backend
{
protected ?object $model = null;
protected array $withJoinTable = ['userGroup'];
protected array|string $preExcludeFields = ['last_login_time', 'login_failure', 'password', 'salt'];
protected array|string $quickSearchField = ['username', 'nickname', 'id'];
protected function initController(Request $request): void
{
$this->model = new UserModel();
}
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->get('select') || $request->post('select')) {
return $this->select($request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withoutField('password,salt')
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$passwd = $data['password'] ?? '';
$data = $this->excludeFields($data);
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validateClass = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validateClass)) {
$validate = new $validateClass();
if ($this->modelSceneValidate) $validate->scene('add');
$validate->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
if ($passwd) {
$this->model->resetPassword($this->model->id, $passwd);
}
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
return $result !== false ? $this->success(__('Added successfully')) : $this->error(__('No rows were added'));
}
public function edit(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$pk = $this->model->getPk();
$id = $request->post($pk) ?? $request->get($pk);
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
return $this->error(__('You have no permission'));
}
if ($request->method() === 'POST') {
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
if (!empty($data['password'])) {
$this->model->resetPassword($row->id, $data['password']);
}
$data = $this->excludeFields($data);
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validateClass = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validateClass)) {
$validate = new $validateClass();
$validate->scene('edit')->check(array_merge($data, [$pk => $row[$pk]]));
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
return $result !== false ? $this->success(__('Update successful')) : $this->error(__('No rows updated'));
}
unset($row['password'], $row['salt']);
$row['password'] = '';
return $this->success('', ['row' => $row]);
}
public function select(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withoutField('password,salt')
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
]);
}
}

View File

@@ -0,0 +1,356 @@
<?php
declare(strict_types=1);
namespace app\admin\library;
use Throwable;
use ba\Random;
use support\think\Db;
use app\admin\model\Admin;
use app\common\facade\Token;
use app\admin\model\AdminGroup;
/**
* 管理员权限类Webman 迁移版)
* @property int $id 管理员ID
* @property string $username 管理员用户名
* @property string $nickname 管理员昵称
* @property string $email 管理员邮箱
* @property string $mobile 管理员手机号
*/
class Auth extends \ba\Auth
{
public const LOGIN_RESPONSE_CODE = 303;
public const NEED_LOGIN = 'need login';
public const LOGGED_IN = 'logged in';
public const TOKEN_TYPE = 'admin';
protected bool $loginEd = false;
protected string $error = '';
protected ?Admin $model = null;
protected string $token = '';
protected string $refreshToken = '';
protected int $keepTime = 86400;
protected int $refreshTokenKeepTime = 2592000;
protected array $allowFields = ['id', 'username', 'nickname', 'avatar', 'last_login_time'];
public function __construct(array $config = [])
{
parent::__construct($config);
$this->setKeepTime((int) config('buildadmin.admin_token_keep_time', 86400 * 3));
}
public function __get($name): mixed
{
return $this->model?->$name;
}
/**
* 初始化Webman从 request 获取或创建新实例)
*/
public static function instance(array $options = []): Auth
{
$request = function_exists('request') ? request() : null;
if ($request !== null && isset($request->adminAuth) && $request->adminAuth instanceof Auth) {
return $request->adminAuth;
}
$auth = new static($options);
if ($request !== null) {
$request->adminAuth = $auth;
}
return $auth;
}
public function init(string $token): bool
{
$tokenData = Token::get($token);
if ($tokenData) {
Token::tokenExpirationCheck($tokenData);
$userId = (int) $tokenData['user_id'];
if ($tokenData['type'] === self::TOKEN_TYPE && $userId > 0) {
$this->model = Admin::where('id', $userId)->find();
if (!$this->model) {
$this->setError('Account not exist');
return false;
}
if ($this->model['status'] !== 'enable') {
$this->setError('Account disabled');
return false;
}
$this->token = $token;
$this->loginSuccessful();
return true;
}
}
$this->setError('Token login failed');
$this->reset();
return false;
}
public function login(string $username, string $password, bool $keep = false): bool
{
$this->model = Admin::where('username', $username)->find();
if (!$this->model) {
$this->setError('Username is incorrect');
return false;
}
if ($this->model->status === 'disable') {
$this->setError('Account disabled');
return false;
}
$adminLoginRetry = config('buildadmin.admin_login_retry');
if ($adminLoginRetry) {
$lastLoginTime = $this->model->getData('last_login_time');
if ($lastLoginTime) {
if ($this->model->login_failure > 0 && time() - $lastLoginTime >= 86400) {
$this->model->login_failure = 0;
$this->model->save();
$this->model = Admin::where('username', $username)->find();
}
if ($this->model->login_failure >= $adminLoginRetry) {
$this->setError('Please try again after 1 day');
return false;
}
}
}
if (!verify_password($password, $this->model->password, ['salt' => $this->model->salt ?? ''])) {
$this->loginFailed();
$this->setError('Password is incorrect');
return false;
}
if (config('buildadmin.admin_sso')) {
Token::clear(self::TOKEN_TYPE, $this->model->id);
Token::clear(self::TOKEN_TYPE . '-refresh', $this->model->id);
}
if ($keep) {
$this->setRefreshToken($this->refreshTokenKeepTime);
}
$this->loginSuccessful();
return true;
}
public function setRefreshToken(int $keepTime = 0): void
{
$this->refreshToken = Random::uuid();
Token::set($this->refreshToken, self::TOKEN_TYPE . '-refresh', $this->model->id, $keepTime);
}
public function loginSuccessful(): bool
{
if (!$this->model) {
return false;
}
$this->model->startTrans();
try {
$this->model->login_failure = 0;
$this->model->last_login_time = time();
$this->model->last_login_ip = function_exists('request') && request() ? request()->getRealIp() : '';
$this->model->save();
$this->loginEd = true;
if (!$this->token) {
$this->token = Random::uuid();
Token::set($this->token, self::TOKEN_TYPE, $this->model->id, $this->keepTime);
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->setError($e->getMessage());
return false;
}
return true;
}
public function loginFailed(): bool
{
if (!$this->model) {
return false;
}
$this->model->startTrans();
try {
$this->model->login_failure++;
$this->model->last_login_time = time();
$this->model->last_login_ip = function_exists('request') && request() ? request()->getRealIp() : '';
$this->model->save();
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
}
return $this->reset();
}
public function logout(): bool
{
if (!$this->loginEd) {
$this->setError('You are not logged in');
return false;
}
return $this->reset();
}
public function isLogin(): bool
{
return $this->loginEd;
}
public function getAdmin(): Admin
{
return $this->model;
}
public function getToken(): string
{
return $this->token;
}
public function getRefreshToken(): string
{
return $this->refreshToken;
}
public function getInfo(): array
{
if (!$this->model) {
return [];
}
$info = $this->model->toArray();
$info = array_intersect_key($info, array_flip($this->getAllowFields()));
$info['token'] = $this->getToken();
$info['refresh_token'] = $this->getRefreshToken();
return $info;
}
public function getAllowFields(): array
{
return $this->allowFields;
}
public function setAllowFields($fields): void
{
$this->allowFields = $fields;
}
public function setKeepTime(int $keepTime = 0): void
{
$this->keepTime = $keepTime;
}
public function check(string $name, int $uid = 0, string $relation = 'or', string $mode = 'url'): bool
{
return parent::check($name, $uid ?: $this->id, $relation, $mode);
}
public function getGroups(int $uid = 0): array
{
return parent::getGroups($uid ?: $this->id);
}
public function getRuleList(int $uid = 0): array
{
return parent::getRuleList($uid ?: $this->id);
}
public function getRuleIds(int $uid = 0): array
{
return parent::getRuleIds($uid ?: $this->id);
}
public function getMenus(int $uid = 0): array
{
return parent::getMenus($uid ?: $this->id);
}
public function isSuperAdmin(): bool
{
return in_array('*', $this->getRuleIds());
}
public function getAdminChildGroups(): array
{
$groupIds = Db::name('admin_group_access')
->where('uid', $this->id)
->select();
$children = [];
foreach ($groupIds as $group) {
$this->getGroupChildGroups($group['group_id'], $children);
}
return array_unique($children);
}
public function getGroupChildGroups(int $groupId, array &$children): void
{
$childrenTemp = AdminGroup::where('pid', $groupId)
->where('status', 1)
->select();
foreach ($childrenTemp as $item) {
$children[] = $item['id'];
$this->getGroupChildGroups($item['id'], $children);
}
}
public function getGroupAdmins(array $groups): array
{
return Db::name('admin_group_access')
->where('group_id', 'in', $groups)
->column('uid');
}
public function getAllAuthGroups(string $dataLimit, array $groupQueryWhere = [['status', '=', 1]]): array
{
$rules = $this->getRuleIds();
$allAuthGroups = [];
$groups = AdminGroup::where($groupQueryWhere)->select();
foreach ($groups as $group) {
if ($group['rules'] === '*') {
continue;
}
$groupRules = explode(',', $group['rules']);
$all = true;
foreach ($groupRules as $groupRule) {
if (!in_array($groupRule, $rules)) {
$all = false;
break;
}
}
if ($all) {
if ($dataLimit === 'allAuth' || ($dataLimit === 'allAuthAndOthers' && array_diff($rules, $groupRules))) {
$allAuthGroups[] = $group['id'];
}
}
}
return $allAuthGroups;
}
public function setError($error): Auth
{
$this->error = $error;
return $this;
}
public function getError(): string
{
return $this->error ? __($this->error) : '';
}
protected function reset(bool $deleteToken = true): bool
{
if ($deleteToken && $this->token) {
Token::delete($this->token);
}
$this->token = '';
$this->loginEd = false;
$this->model = null;
$this->refreshToken = '';
$this->setError('');
$this->setKeepTime((int) config('buildadmin.admin_token_keep_time', 86400 * 3));
return true;
}
}

View File

@@ -0,0 +1,924 @@
<?php
declare(strict_types=1);
namespace app\admin\library\crud;
use Throwable;
use ba\Filesystem;
use ba\TableManager;
use app\common\library\Menu;
use app\admin\model\AdminRule;
use app\admin\model\CrudLog;
use ba\Exception as BaException;
use Phinx\Db\Adapter\MysqlAdapter;
use Phinx\Db\Adapter\AdapterInterface;
use support\think\Db;
/**
* CRUD 代码生成器 HelperWebman 迁移版)
*/
class Helper
{
protected static array $reservedKeywords = [
'abstract', 'and', 'array', 'as', 'break', 'callable', 'case', 'catch', 'class', 'clone',
'const', 'continue', 'declare', 'default', 'die', 'do', 'echo', 'else', 'elseif', 'empty',
'enddeclare', 'endfor', 'endforeach', 'endif', 'endswitch', 'endwhile', 'eval', 'exit', 'extends',
'final', 'for', 'foreach', 'function', 'global', 'goto', 'if', 'implements', 'include', 'include_once',
'instanceof', 'insteadof', 'interface', 'isset', 'list', 'namespace', 'new', 'or', 'print', 'private',
'protected', 'public', 'require', 'require_once', 'return', 'static', 'switch', 'throw', 'trait', 'try',
'unset', 'use', 'var', 'while', 'xor', 'yield', 'match', 'readonly', 'fn',
];
protected static array $parseNamePresets = [
'controller' => [
'user' => ['user', 'user'],
'admin' => ['auth', 'admin'],
'admin_group' => ['auth', 'group'],
'attachment' => ['routine', 'attachment'],
'admin_rule' => ['auth', 'rule'],
],
'model' => [],
'validate' => [],
];
public static array $menuChildren = [
['type' => 'button', 'title' => '查看', 'name' => '/index', 'status' => 1],
['type' => 'button', 'title' => '添加', 'name' => '/add', 'status' => 1],
['type' => 'button', 'title' => '编辑', 'name' => '/edit', 'status' => 1],
['type' => 'button', 'title' => '删除', 'name' => '/del', 'status' => 1],
['type' => 'button', 'title' => '快速排序', 'name' => '/sortable', 'status' => 1],
];
protected static array $inputTypeRule = [
['type' => ['tinyint', 'int', 'enum'], 'suffix' => ['switch', 'toggle'], 'value' => 'switch'],
['column_type' => ['tinyint(1)', 'char(1)', 'tinyint(1) unsigned'], 'suffix' => ['switch', 'toggle'], 'value' => 'switch'],
['type' => ['longtext', 'text', 'mediumtext', 'smalltext', 'tinytext', 'bigtext'], 'suffix' => ['content', 'editor'], 'value' => 'editor'],
['type' => ['varchar'], 'suffix' => ['textarea', 'multiline', 'rows'], 'value' => 'textarea'],
['suffix' => ['array'], 'value' => 'array'],
['type' => ['int'], 'suffix' => ['time', 'datetime'], 'value' => 'timestamp'],
['type' => ['datetime', 'timestamp'], 'value' => 'datetime'],
['type' => ['date'], 'value' => 'date'],
['type' => ['year'], 'value' => 'year'],
['type' => ['time'], 'value' => 'time'],
['suffix' => ['select', 'list', 'data'], 'value' => 'select'],
['suffix' => ['selects', 'multi', 'lists'], 'value' => 'selects'],
['suffix' => ['_id'], 'value' => 'remoteSelect'],
['suffix' => ['_ids'], 'value' => 'remoteSelects'],
['suffix' => ['city'], 'value' => 'city'],
['suffix' => ['image', 'avatar'], 'value' => 'image'],
['suffix' => ['images', 'avatars'], 'value' => 'images'],
['suffix' => ['file'], 'value' => 'file'],
['suffix' => ['files'], 'value' => 'files'],
['suffix' => ['icon'], 'value' => 'icon'],
['column_type' => ['tinyint(1)', 'char(1)', 'tinyint(1) unsigned'], 'suffix' => ['status', 'state', 'type'], 'value' => 'radio'],
['suffix' => ['number', 'int', 'num'], 'value' => 'number'],
['type' => ['bigint', 'int', 'mediumint', 'smallint', 'tinyint', 'decimal', 'double', 'float'], 'value' => 'number'],
['type' => ['longtext', 'text', 'mediumtext', 'smalltext', 'tinytext', 'bigtext'], 'value' => 'textarea'],
['type' => ['enum'], 'value' => 'radio'],
['type' => ['set'], 'value' => 'checkbox'],
['suffix' => ['color'], 'value' => 'color'],
];
protected static array $parseWebDirPresets = [
'lang' => [],
'views' => [
'user' => ['user', 'user'],
'admin' => ['auth', 'admin'],
'admin_group' => ['auth', 'group'],
'attachment' => ['routine', 'attachment'],
'admin_rule' => ['auth', 'rule'],
],
];
protected static string $createTimeField = 'create_time';
protected static string $updateTimeField = 'update_time';
protected static array $attrType = [
'controller' => [
'preExcludeFields' => 'array|string',
'quickSearchField' => 'string|array',
'withJoinTable' => 'array',
'defaultSortField' => 'string|array',
'weighField' => 'string',
],
];
public static function getDictData(array &$dict, array $field, string $lang, string $translationPrefix = ''): array
{
if (!$field['comment']) return [];
$comment = str_replace(['', ''], [',', ':'], $field['comment']);
if (stripos($comment, ':') !== false && stripos($comment, ',') && stripos($comment, '=') !== false) {
[$fieldTitle, $item] = explode(':', $comment);
$dict[$translationPrefix . $field['name']] = $lang == 'en' ? $field['name'] : $fieldTitle;
foreach (explode(',', $item) as $v) {
$valArr = explode('=', $v);
if (count($valArr) == 2) {
[$key, $value] = $valArr;
$dict[$translationPrefix . $field['name'] . ' ' . $key] = $lang == 'en' ? $field['name'] . ' ' . $key : $value;
}
}
} else {
$dict[$translationPrefix . $field['name']] = $lang == 'en' ? $field['name'] : $comment;
}
return $dict;
}
public static function recordCrudStatus(array $data): int
{
if (isset($data['id'])) {
CrudLog::where('id', $data['id'])->update(['status' => $data['status']]);
return $data['id'];
}
$connection = $data['table']['databaseConnection'] ?? config('thinkorm.default', config('database.default', 'mysql'));
$log = CrudLog::create([
'table_name' => $data['table']['name'],
'comment' => $data['table']['comment'],
'table' => $data['table'],
'fields' => $data['fields'],
'connection' => $connection,
'status' => $data['status'],
]);
return $log->id;
}
public static function getPhinxFieldType(string $type, array $field): array
{
if ($type == 'tinyint') {
if (
(isset($field['dataType']) && $field['dataType'] == 'tinyint(1)') ||
($field['default'] == '1' && ($field['defaultType'] ?? '') == 'INPUT')
) {
$type = 'boolean';
}
}
$phinxFieldTypeMap = [
'tinyint' => ['type' => AdapterInterface::PHINX_TYPE_INTEGER, 'limit' => MysqlAdapter::INT_TINY],
'smallint' => ['type' => AdapterInterface::PHINX_TYPE_INTEGER, 'limit' => MysqlAdapter::INT_SMALL],
'mediumint' => ['type' => AdapterInterface::PHINX_TYPE_INTEGER, 'limit' => MysqlAdapter::INT_MEDIUM],
'int' => ['type' => AdapterInterface::PHINX_TYPE_INTEGER, 'limit' => null],
'bigint' => ['type' => AdapterInterface::PHINX_TYPE_BIG_INTEGER, 'limit' => null],
'boolean' => ['type' => AdapterInterface::PHINX_TYPE_BOOLEAN, 'limit' => null],
'varchar' => ['type' => AdapterInterface::PHINX_TYPE_STRING, 'limit' => null],
'tinytext' => ['type' => AdapterInterface::PHINX_TYPE_TEXT, 'limit' => MysqlAdapter::TEXT_TINY],
'mediumtext' => ['type' => AdapterInterface::PHINX_TYPE_TEXT, 'limit' => MysqlAdapter::TEXT_MEDIUM],
'longtext' => ['type' => AdapterInterface::PHINX_TYPE_TEXT, 'limit' => MysqlAdapter::TEXT_LONG],
'tinyblob' => ['type' => AdapterInterface::PHINX_TYPE_BLOB, 'limit' => MysqlAdapter::BLOB_TINY],
'mediumblob' => ['type' => AdapterInterface::PHINX_TYPE_BLOB, 'limit' => MysqlAdapter::BLOB_MEDIUM],
'longblob' => ['type' => AdapterInterface::PHINX_TYPE_BLOB, 'limit' => MysqlAdapter::BLOB_LONG],
];
return array_key_exists($type, $phinxFieldTypeMap) ? $phinxFieldTypeMap[$type] : ['type' => $type, 'limit' => null];
}
public static function analyseFieldLimit(string $type, array $field): array
{
$fieldType = ['decimal' => ['decimal', 'double', 'float'], 'values' => ['enum', 'set']];
$dataTypeLimit = self::dataTypeLimit($field['dataType'] ?? '');
if (in_array($type, $fieldType['decimal'])) {
if ($dataTypeLimit) {
return ['precision' => $dataTypeLimit[0], 'scale' => $dataTypeLimit[1] ?? 0];
}
$scale = isset($field['precision']) ? intval($field['precision']) : 0;
return ['precision' => $field['length'] ?? 10, 'scale' => $scale];
} elseif (in_array($type, $fieldType['values'])) {
foreach ($dataTypeLimit as &$item) {
$item = str_replace(['"', "'"], '', $item);
}
return ['values' => $dataTypeLimit];
} elseif ($dataTypeLimit && $dataTypeLimit[0]) {
return ['limit' => $dataTypeLimit[0]];
} elseif (isset($field['length'])) {
return ['limit' => $field['length']];
}
return [];
}
public static function dataTypeLimit(string $dataType): array
{
preg_match("/\((.*?)\)/", $dataType, $matches);
if (isset($matches[1]) && $matches[1]) {
return explode(',', trim($matches[1], ','));
}
return [];
}
public static function analyseFieldDefault(array $field): mixed
{
return match ($field['defaultType'] ?? 'NONE') {
'EMPTY STRING' => '',
'NULL' => null,
default => $field['default'] ?? null,
};
}
public static function searchArray($fields, callable $myFunction): array|bool
{
foreach ($fields as $key => $field) {
if (call_user_func($myFunction, $field, $key)) {
return $field;
}
}
return false;
}
public static function getPhinxFieldData(array $field): array
{
$conciseType = self::analyseFieldType($field);
$phinxTypeData = self::getPhinxFieldType($conciseType, $field);
$phinxColumnOptions = self::analyseFieldLimit($conciseType, $field);
if ($phinxTypeData['limit'] !== null) {
$phinxColumnOptions['limit'] = $phinxTypeData['limit'];
}
$noDefaultValueFields = [
'text', 'blob', 'geometry', 'geometrycollection', 'json', 'linestring', 'longblob', 'longtext', 'mediumblob',
'mediumtext', 'multilinestring', 'multipoint', 'multipolygon', 'point', 'polygon', 'tinyblob',
];
if (($field['defaultType'] ?? '') != 'NONE' && !in_array($conciseType, $noDefaultValueFields)) {
$phinxColumnOptions['default'] = self::analyseFieldDefault($field);
}
$phinxColumnOptions['null'] = (bool)($field['null'] ?? false);
$phinxColumnOptions['comment'] = $field['comment'] ?? '';
$phinxColumnOptions['signed'] = !($field['unsigned'] ?? false);
$phinxColumnOptions['identity'] = $field['autoIncrement'] ?? false;
return ['type' => $phinxTypeData['type'], 'options' => $phinxColumnOptions];
}
public static function updateFieldOrder(string $tableName, array $fields, array $designChange, ?string $connection = null): void
{
if ($designChange) {
$table = TableManager::phinxTable($tableName, [], false, $connection);
foreach ($designChange as $item) {
if (!$item['sync']) continue;
if (!empty($item['after'])) {
$fieldName = in_array($item['type'], ['add-field', 'change-field-name']) ? $item['newName'] : $item['oldName'];
$field = self::searchArray($fields, fn($f) => $f['name'] == $fieldName);
if (!$field) continue;
$phinxFieldData = self::getPhinxFieldData($field);
$phinxFieldData['options']['after'] = $item['after'] == 'FIRST FIELD' ? MysqlAdapter::FIRST : $item['after'];
$table->changeColumn($fieldName, $phinxFieldData['type'], $phinxFieldData['options']);
}
}
$table->update();
}
}
public static function handleTableDesign(array $table, array $fields): array
{
$name = TableManager::tableName($table['name'], true, $table['databaseConnection'] ?? null);
$comment = $table['comment'] ?? '';
$designChange = $table['designChange'] ?? [];
$adapter = TableManager::phinxAdapter(false, $table['databaseConnection'] ?? null);
$pk = self::searchArray($fields, fn($item) => $item['primaryKey'] ?? false);
$pk = $pk ? $pk['name'] : '';
if ($adapter->hasTable($name)) {
if ($designChange) {
$tableManager = TableManager::phinxTable($name, [], false, $table['databaseConnection'] ?? null);
$tableManager->changeComment($comment)->update();
$priorityOpt = false;
foreach ($designChange as $item) {
if (!$item['sync']) continue;
if (in_array($item['type'], ['change-field-name', 'del-field']) && !$tableManager->hasColumn($item['oldName'])) {
throw new BaException(__($item['type'] . ' fail not exist', [$item['oldName']]));
}
if ($item['type'] == 'change-field-name') {
$priorityOpt = true;
$tableManager->renameColumn($item['oldName'], $item['newName']);
} elseif ($item['type'] == 'del-field') {
$priorityOpt = true;
$tableManager->removeColumn($item['oldName']);
}
}
if ($priorityOpt) {
$tableManager->update();
}
foreach ($designChange as $item) {
if (!$item['sync']) continue;
if ($item['type'] == 'change-field-attr') {
if (!$tableManager->hasColumn($item['oldName'])) {
throw new BaException(__($item['type'] . ' fail not exist', [$item['oldName']]));
}
$field = self::searchArray($fields, fn($f) => $f['name'] == $item['oldName']);
if ($field) {
$phinxFieldData = self::getPhinxFieldData($field);
$tableManager->changeColumn($item['oldName'], $phinxFieldData['type'], $phinxFieldData['options']);
}
} elseif ($item['type'] == 'add-field') {
if ($tableManager->hasColumn($item['newName'])) {
throw new BaException(__($item['type'] . ' fail exist', [$item['newName']]));
}
$field = self::searchArray($fields, fn($f) => $f['name'] == $item['newName']);
if ($field) {
$phinxFieldData = self::getPhinxFieldData($field);
$tableManager->addColumn($item['newName'], $phinxFieldData['type'], $phinxFieldData['options']);
}
}
}
$tableManager->update();
self::updateFieldOrder($name, $fields, $designChange, $table['databaseConnection'] ?? null);
}
} else {
$tableManager = TableManager::phinxTable($name, [
'id' => false, 'comment' => $comment, 'row_format' => 'DYNAMIC',
'primary_key' => $pk, 'collation' => 'utf8mb4_unicode_ci',
], false, $table['databaseConnection'] ?? null);
foreach ($fields as $field) {
$phinxFieldData = self::getPhinxFieldData($field);
$tableManager->addColumn($field['name'], $phinxFieldData['type'], $phinxFieldData['options']);
}
$tableManager->create();
}
return [$pk];
}
public static function parseNameData($app, $table, $type, $value = ''): array
{
$pathArr = [];
if ($value) {
$value = str_replace('.php', '', $value);
$value = str_replace(['.', '/', '\\', '_'], '/', $value);
$pathArrTemp = explode('/', $value);
$redundantDir = ['app' => 0, $app => 1, $type => 2];
foreach ($pathArrTemp as $key => $item) {
if (!array_key_exists($item, $redundantDir) || $key !== $redundantDir[$item]) {
$pathArr[] = $item;
}
}
} elseif (isset(self::$parseNamePresets[$type]) && array_key_exists($table, self::$parseNamePresets[$type])) {
$pathArr = self::$parseNamePresets[$type][$table];
} else {
$table = str_replace(['.', '/', '\\', '_'], '/', $table);
$pathArr = explode('/', $table);
}
$originalLastName = array_pop($pathArr);
$pathArr = array_map('strtolower', $pathArr);
$lastName = ucfirst($originalLastName);
if (in_array(strtolower($originalLastName), self::$reservedKeywords)) {
throw new \Exception('Unable to use internal variable:' . $lastName);
}
$appDir = root_path() . 'app' . DIRECTORY_SEPARATOR . $app . DIRECTORY_SEPARATOR;
$namespace = "app\\$app\\$type" . ($pathArr ? '\\' . implode('\\', $pathArr) : '');
$parseFile = $appDir . $type . DIRECTORY_SEPARATOR . ($pathArr ? implode(DIRECTORY_SEPARATOR, $pathArr) . DIRECTORY_SEPARATOR : '') . $lastName . '.php';
$rootFileName = $namespace . "/$lastName" . '.php';
return [
'lastName' => $lastName,
'originalLastName' => $originalLastName,
'path' => $pathArr,
'namespace' => $namespace,
'parseFile' => Filesystem::fsFit($parseFile),
'rootFileName' => Filesystem::fsFit($rootFileName),
];
}
public static function parseWebDirNameData($table, $type, $value = ''): array
{
$pathArr = [];
if ($value) {
$value = str_replace(['.', '/', '\\', '_'], '/', $value);
$pathArrTemp = explode('/', $value);
$redundantDir = ['web' => 0, 'src' => 1, 'views' => 2, 'lang' => 2, 'backend' => 3, 'pages' => 3, 'en' => 4, 'zh-cn' => 4];
foreach ($pathArrTemp as $key => $item) {
if (!array_key_exists($item, $redundantDir) || $key !== $redundantDir[$item]) {
$pathArr[] = $item;
}
}
} elseif (isset(self::$parseWebDirPresets[$type]) && array_key_exists($table, self::$parseWebDirPresets[$type])) {
$pathArr = self::$parseWebDirPresets[$type][$table];
} else {
$table = str_replace(['.', '/', '\\', '_'], '/', $table);
$pathArr = explode('/', $table);
}
$originalLastName = array_pop($pathArr);
$pathArr = array_map('strtolower', $pathArr);
$lastName = lcfirst($originalLastName);
$webDir['path'] = $pathArr;
$webDir['lastName'] = $lastName;
$webDir['originalLastName'] = $originalLastName;
if ($type == 'views') {
$webDir['views'] = "web/src/views/backend" . ($pathArr ? '/' . implode('/', $pathArr) : '') . "/$lastName";
} elseif ($type == 'lang') {
$webDir['lang'] = array_merge($pathArr, [$lastName]);
foreach (['en', 'zh-cn'] as $item) {
$webDir[$item] = "web/src/lang/backend/$item" . ($pathArr ? '/' . implode('/', $pathArr) : '') . "/$lastName";
}
}
foreach ($webDir as &$item) {
if (is_string($item)) $item = Filesystem::fsFit($item);
}
return $webDir;
}
public static function getMenuName(array $webDir): string
{
return ($webDir['path'] ? implode('/', $webDir['path']) . '/' : '') . $webDir['originalLastName'];
}
public static function getStubFilePath(string $name): string
{
return root_path() . 'app' . DIRECTORY_SEPARATOR . 'admin' . DIRECTORY_SEPARATOR . 'library' . DIRECTORY_SEPARATOR . 'crud' . DIRECTORY_SEPARATOR . 'stubs' . DIRECTORY_SEPARATOR . Filesystem::fsFit($name) . '.stub';
}
public static function arrayToString(array|string $value): string
{
if (!is_array($value)) return $value;
foreach ($value as &$item) {
$item = self::arrayToString($item);
}
return implode(PHP_EOL, $value);
}
public static function assembleStub(string $name, array $data, bool $escape = false): string
{
foreach ($data as &$datum) {
$datum = self::arrayToString($datum);
}
$search = $replace = [];
foreach ($data as $k => $v) {
$search[] = "{%$k%}";
$replace[] = $v;
}
$stubPath = self::getStubFilePath($name);
$stubContent = file_get_contents($stubPath);
$content = str_replace($search, $replace, $stubContent);
return $escape ? self::escape($content) : $content;
}
public static function escape(array|string $value): string
{
if (is_array($value)) {
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
}
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8', false);
}
public static function tab(int $num = 1): string
{
return str_pad('', 4 * $num);
}
public static function parseTableColumns(string $table, bool $analyseField = false, ?string $connection = null): array
{
$connection = TableManager::getConnection($connection);
$connectionConfig = TableManager::getConnectionConfig($connection);
$sql = 'SELECT * FROM `information_schema`.`columns` '
. 'WHERE TABLE_SCHEMA = ? AND table_name = ? '
. 'ORDER BY ORDINAL_POSITION';
$tableColumn = Db::connect($connection)->query($sql, [$connectionConfig['database'], TableManager::tableName($table, true, $connection)]);
$columns = [];
foreach ($tableColumn as $item) {
$isNullAble = $item['IS_NULLABLE'] == 'YES';
if (str_contains($item['COLUMN_TYPE'], '(')) {
$dataType = substr_replace($item['COLUMN_TYPE'], '', stripos($item['COLUMN_TYPE'], ')') + 1);
} else {
$dataType = str_replace(' unsigned', '', $item['COLUMN_TYPE']);
}
$default = '';
if ($isNullAble && $item['COLUMN_DEFAULT'] === null) {
$defaultType = 'NULL';
} elseif ($item['COLUMN_DEFAULT'] == '' && in_array($item['DATA_TYPE'], ['varchar', 'char'])) {
$defaultType = 'EMPTY STRING';
} elseif (!$isNullAble && $item['COLUMN_DEFAULT'] === null) {
$defaultType = 'NONE';
} else {
$defaultType = 'INPUT';
$default = $item['COLUMN_DEFAULT'];
}
$column = [
'name' => $item['COLUMN_NAME'],
'type' => $item['DATA_TYPE'],
'dataType' => $dataType,
'default' => $default,
'defaultType' => $defaultType,
'null' => $isNullAble,
'primaryKey' => $item['COLUMN_KEY'] == 'PRI',
'unsigned' => (bool)stripos($item['COLUMN_TYPE'], 'unsigned'),
'autoIncrement' => stripos($item['EXTRA'], 'auto_increment') !== false,
'comment' => $item['COLUMN_COMMENT'],
'designType' => self::getTableColumnsDataType($item),
'table' => [],
'form' => [],
];
if ($analyseField) {
self::analyseField($column);
} else {
self::handleTableColumn($column);
}
$columns[$item['COLUMN_NAME']] = $column;
}
return $columns;
}
public static function handleTableColumn(&$column): void
{
}
public static function analyseFieldType(array $field): string
{
$dataType = (isset($field['dataType']) && $field['dataType']) ? $field['dataType'] : $field['type'];
if (stripos($dataType, '(') !== false) {
$typeName = explode('(', $dataType);
return trim($typeName[0]);
}
return trim($dataType);
}
public static function analyseFieldDataType(array $field): string
{
if (!empty($field['dataType'])) return $field['dataType'];
$conciseType = self::analyseFieldType($field);
$limit = self::analyseFieldLimit($conciseType, $field);
if (isset($limit['precision'])) {
return "$conciseType({$limit['precision']}, {$limit['scale']})";
}
if (isset($limit['values'])) {
return "$conciseType(" . implode(',', $limit['values']) . ")";
}
return "$conciseType({$limit['limit']})";
}
public static function analyseField(&$field): void
{
$field['type'] = self::analyseFieldType($field);
$field['originalDesignType'] = $field['designType'];
$designTypeComparison = ['pk' => 'string', 'weigh' => 'number', 'timestamp' => 'datetime', 'float' => 'number'];
if (array_key_exists($field['designType'], $designTypeComparison)) {
$field['designType'] = $designTypeComparison[$field['designType']];
}
$supportMultipleComparison = ['select', 'image', 'file', 'remoteSelect'];
if (in_array($field['designType'], $supportMultipleComparison)) {
$multiKey = $field['designType'] == 'remoteSelect' ? 'select-multi' : $field['designType'] . '-multi';
if (isset($field['form'][$multiKey]) && $field['form'][$multiKey]) {
$field['designType'] = $field['designType'] . 's';
}
}
}
public static function getTableColumnsDataType($column): string
{
if (stripos($column['COLUMN_NAME'], 'id') !== false && stripos($column['EXTRA'], 'auto_increment') !== false) {
return 'pk';
}
if ($column['COLUMN_NAME'] == 'weigh') return 'weigh';
if (in_array($column['COLUMN_NAME'], ['createtime', 'updatetime', 'create_time', 'update_time'])) return 'timestamp';
foreach (self::$inputTypeRule as $item) {
$typeBool = !isset($item['type']) || !$item['type'] || in_array($column['DATA_TYPE'], $item['type']);
$suffixBool = !isset($item['suffix']) || !$item['suffix'] || self::isMatchSuffix($column['COLUMN_NAME'], $item['suffix']);
$columnTypeBool = !isset($item['column_type']) || !$item['column_type'] || in_array($column['COLUMN_TYPE'], $item['column_type']);
if ($typeBool && $suffixBool && $columnTypeBool) {
return $item['value'];
}
}
return 'string';
}
protected static function isMatchSuffix(string $field, string|array $suffixArr): bool
{
$suffixArr = is_array($suffixArr) ? $suffixArr : explode(',', $suffixArr);
foreach ($suffixArr as $v) {
if (preg_match("/$v$/i", $field)) return true;
}
return false;
}
public static function createMenu($webViewsDir, $tableComment): void
{
$menuName = self::getMenuName($webViewsDir);
if (AdminRule::where('name', $menuName)->value('id')) {
return;
}
$menuChildren = self::$menuChildren;
foreach ($menuChildren as &$item) {
$item['name'] = $menuName . $item['name'];
}
$componentPath = str_replace(['\\', 'web/src'], ['/', '/src'], $webViewsDir['views'] . '/' . 'index.vue');
$menus = [
'type' => 'menu',
'title' => $tableComment ?: $webViewsDir['originalLastName'],
'name' => $menuName,
'path' => $menuName,
'menu_type' => 'tab',
'keepalive' => 1,
'component' => $componentPath,
'children' => $menuChildren,
];
$paths = array_reverse($webViewsDir['path']);
foreach ($paths as $path) {
$menus = [
'type' => 'menu_dir',
'title' => $path,
'name' => $path,
'path' => $path,
'children' => [$menus],
];
}
Menu::create([$menus], 0, 'ignore');
}
public static function writeWebLangFile($langData, $webLangDir): void
{
foreach ($langData as $lang => $langDatum) {
$langTsContent = '';
foreach ($langDatum as $key => $item) {
$quote = self::getQuote($item);
$keyStr = self::formatObjectKey($key);
$langTsContent .= self::tab() . $keyStr . ": $quote$item$quote,\n";
}
$langTsContent = "export default {\n" . $langTsContent . "}\n";
self::writeFile(root_path() . $webLangDir[$lang] . '.ts', $langTsContent);
}
}
public static function writeFile($path, $content): bool|int
{
$path = Filesystem::fsFit($path);
if (!is_dir(dirname($path))) {
mkdir(dirname($path), 0755, true);
}
return file_put_contents($path, $content);
}
public static function buildModelAppend($append): string
{
if (!$append) return '';
return "\n" . self::tab() . "// 追加属性\n" . self::tab() . "protected \$append = " . self::buildFormatSimpleArray($append) . ";\n";
}
public static function buildModelFieldType(array $fieldType): string
{
if (!$fieldType) return '';
$maxStrLang = 0;
foreach ($fieldType as $key => $item) {
$maxStrLang = max(strlen($key), $maxStrLang);
}
$str = '';
foreach ($fieldType as $key => $item) {
$str .= self::tab(2) . "'$key'" . str_pad('=>', ($maxStrLang - strlen($key) + 3), ' ', STR_PAD_LEFT) . " '$item',\n";
}
return "\n" . self::tab() . "// 字段类型转换\n" . self::tab() . "protected \$type = [\n" . rtrim($str, "\n") . "\n" . self::tab() . "];\n";
}
public static function writeModelFile(string $tablePk, array $fieldsMap, array $modelData, array $modelFile): void
{
if ($modelData['connection'] && $modelData['connection'] != config('thinkorm.default', config('database.default', 'mysql'))) {
$modelData['connection'] = "\n" . self::tab() . "// 数据库连接配置标识\n" . self::tab() . 'protected $connection = ' . "'{$modelData['connection']}';\n";
} else {
$modelData['connection'] = '';
}
$modelData['pk'] = $tablePk == 'id' ? '' : "\n" . self::tab() . "// 表主键\n" . self::tab() . 'protected $pk = ' . "'$tablePk';\n";
$modelData['autoWriteTimestamp'] = array_key_exists(self::$createTimeField, $fieldsMap) || array_key_exists(self::$updateTimeField, $fieldsMap) ? 'true' : 'false';
if ($modelData['autoWriteTimestamp'] == 'true') {
$modelData['createTime'] = array_key_exists(self::$createTimeField, $fieldsMap) ? '' : "\n" . self::tab() . "protected \$createTime = false;";
$modelData['updateTime'] = array_key_exists(self::$updateTimeField, $fieldsMap) ? '' : "\n" . self::tab() . "protected \$updateTime = false;";
}
$modelMethodList = isset($modelData['relationMethodList']) ? array_merge($modelData['methods'], $modelData['relationMethodList']) : $modelData['methods'];
$modelData['methods'] = $modelMethodList ? "\n" . implode("\n", $modelMethodList) : '';
$modelData['append'] = self::buildModelAppend($modelData['append'] ?? []);
$modelData['fieldType'] = self::buildModelFieldType($modelData['fieldType'] ?? []);
if (isset($modelData['beforeInsertMixins']['snowflake'])) {
$modelData['beforeInsert'] = self::assembleStub('mixins/model/beforeInsert', [
'setSnowFlakeIdCode' => $modelData['beforeInsertMixins']['snowflake']
]);
}
if (($modelData['afterInsert'] ?? '') && ($modelData['beforeInsert'] ?? '')) {
$modelData['afterInsert'] = "\n" . $modelData['afterInsert'];
}
$modelFileContent = self::assembleStub('mixins/model/model', $modelData);
self::writeFile($modelFile['parseFile'], $modelFileContent);
}
public static function writeControllerFile(array $controllerData, array $controllerFile): void
{
if (isset($controllerData['relationVisibleFieldList']) && $controllerData['relationVisibleFieldList']) {
$relationVisibleFields = '->visible([';
foreach ($controllerData['relationVisibleFieldList'] as $cKey => $controllerDatum) {
$relationVisibleFields .= "'$cKey' => ['" . implode("', '", $controllerDatum) . "'], ";
}
$relationVisibleFields = rtrim($relationVisibleFields, ', ') . '])';
$controllerData['methods']['index'] = self::assembleStub('mixins/controller/index', [
'relationVisibleFields' => $relationVisibleFields
]);
$controllerData['use']['Throwable'] = "\nuse Throwable;";
unset($controllerData['relationVisibleFieldList']);
}
$controllerAttr = '';
foreach ($controllerData['attr'] ?? [] as $key => $item) {
$attrType = self::$attrType['controller'][$key] ?? '';
if (is_array($item)) {
$controllerAttr .= "\n" . self::tab() . "protected $attrType \$$key = ['" . implode("', '", $item) . "'];\n";
} elseif ($item) {
$controllerAttr .= "\n" . self::tab() . "protected $attrType \$$key = '$item';\n";
}
}
$controllerData['attr'] = $controllerAttr;
$controllerData['initialize'] = self::assembleStub('mixins/controller/initialize', [
'modelNamespace' => $controllerData['modelNamespace'],
'modelName' => $controllerData['modelName'],
'filterRule' => $controllerData['filterRule'] ?? '',
]);
$contentFileContent = self::assembleStub('mixins/controller/controller', $controllerData);
self::writeFile($controllerFile['parseFile'], $contentFileContent);
}
public static function writeFormFile($formVueData, $webViewsDir, $fields, $webTranslate): void
{
$fieldHtml = "\n";
$formVueData['bigDialog'] = $formVueData['bigDialog'] ? "\n" . self::tab(2) . 'width="70%"' : '';
foreach ($formVueData['formFields'] ?? [] as $field) {
$fieldHtml .= self::tab(5) . "<FormItem";
foreach ($field as $key => $attr) {
if (is_array($attr)) {
$fieldHtml .= ' ' . $key . '="' . self::getJsonFromArray($attr) . '"';
} else {
$fieldHtml .= ' ' . $key . '="' . $attr . '"';
}
}
$fieldHtml .= " />\n";
}
$formVueData['formFields'] = rtrim($fieldHtml, "\n");
foreach ($fields as $field) {
if (isset($field['form']['validator'])) {
foreach ($field['form']['validator'] as $item) {
$message = isset($field['form']['validatorMsg']) && $field['form']['validatorMsg'] ? ", message: '{$field['form']['validatorMsg']}'" : '';
$formVueData['formValidatorRules'][$field['name']][] = "buildValidatorData({ name: '$item', title: t('$webTranslate{$field['name']}')$message })";
}
}
}
if ($formVueData['formValidatorRules'] ?? []) {
$formVueData['imports'][] = "import { buildValidatorData } from '/@/utils/validate'";
}
$formVueData['importExpand'] = self::buildImportExpand($formVueData['imports'] ?? []);
$formVueData['formItemRules'] = self::buildFormValidatorRules($formVueData['formValidatorRules'] ?? []);
$formVueContent = self::assembleStub('html/form', $formVueData);
self::writeFile(root_path() . $webViewsDir['views'] . '/' . 'popupForm.vue', $formVueContent);
}
public static function buildImportExpand(array $imports): string
{
$importExpand = '';
foreach ($imports as $import) {
$importExpand .= "\n$import";
}
return $importExpand;
}
public static function buildFormValidatorRules(array $formValidatorRules): string
{
$rulesHtml = "";
foreach ($formValidatorRules as $key => $formItemRule) {
$rulesArrHtml = '';
foreach ($formItemRule as $item) {
$rulesArrHtml .= $item . ', ';
}
$rulesHtml .= self::tab() . $key . ': [' . rtrim($rulesArrHtml, ', ') . "],\n";
}
return $rulesHtml ? "\n" . $rulesHtml : '';
}
public static function writeIndexFile($indexVueData, $webViewsDir, $controllerFile): void
{
$indexVueData['optButtons'] = self::buildSimpleArray($indexVueData['optButtons'] ?? []);
$indexVueData['defaultItems'] = self::getJsonFromArray($indexVueData['defaultItems'] ?? []);
$indexVueData['tableColumn'] = self::buildTableColumn($indexVueData['tableColumn'] ?? []);
$indexVueData['dblClickNotEditColumn'] = self::buildSimpleArray($indexVueData['dblClickNotEditColumn'] ?? ['undefined']);
$controllerFile['path'][] = $controllerFile['originalLastName'];
$indexVueData['controllerUrl'] = '\'/admin/' . ($controllerFile['path'] ? implode('.', $controllerFile['path']) : '') . '/\'';
$indexVueData['componentName'] = ($webViewsDir['path'] ? implode('/', $webViewsDir['path']) . '/' : '') . $webViewsDir['originalLastName'];
$indexVueContent = self::assembleStub('html/index', $indexVueData);
self::writeFile(root_path() . $webViewsDir['views'] . '/' . 'index.vue', $indexVueContent);
}
public static function buildTableColumn($tableColumnList): string
{
$columnJson = '';
$emptyUnset = ['comSearchInputAttr', 'replaceValue', 'custom'];
foreach ($tableColumnList as $column) {
foreach ($emptyUnset as $unsetKey) {
if (empty($column[$unsetKey])) unset($column[$unsetKey]);
}
$columnJson .= self::tab(3) . '{';
foreach ($column as $key => $item) {
$columnJson .= self::buildTableColumnKey($key, $item);
}
$columnJson = rtrim($columnJson, ',') . " },\n";
}
return rtrim($columnJson, "\n");
}
public static function buildTableColumnKey($key, $item): string
{
$key = self::formatObjectKey($key);
if (is_array($item)) {
$itemJson = ' ' . $key . ': {';
foreach ($item as $ik => $iItem) {
$itemJson .= self::buildTableColumnKey($ik, $iItem);
}
$itemJson = rtrim($itemJson, ',') . ' },';
} elseif ($item === 'false' || $item === 'true') {
$itemJson = ' ' . $key . ': ' . $item . ',';
} elseif (in_array($key, ['label', 'width', 'buttons'], true) || str_starts_with((string)$item, "t('") || str_starts_with((string)$item, 't("')) {
$itemJson = ' ' . $key . ': ' . $item . ',';
} else {
$itemJson = ' ' . $key . ': \'' . $item . '\',';
}
return $itemJson;
}
public static function formatObjectKey(string $keyName): string
{
if (preg_match("/^[a-zA-Z_][a-zA-Z0-9_]+$/", $keyName)) {
return $keyName;
}
$quote = self::getQuote($keyName);
return "$quote$keyName$quote";
}
public static function getQuote(string $value): string
{
return stripos($value, "'") === false ? "'" : '"';
}
public static function buildFormatSimpleArray($arr, int $tab = 2): string
{
if (!$arr) return '[]';
$str = '[' . PHP_EOL;
foreach ($arr as $item) {
if ($item == 'undefined' || $item == 'false' || is_numeric($item)) {
$str .= self::tab($tab) . $item . ',' . PHP_EOL;
} else {
$quote = self::getQuote((string)$item);
$str .= self::tab($tab) . "$quote$item$quote," . PHP_EOL;
}
}
return $str . self::tab($tab - 1) . ']';
}
public static function buildSimpleArray($arr): string
{
if (!$arr) return '[]';
$str = '';
foreach ($arr as $item) {
if ($item == 'undefined' || $item == 'false' || is_numeric($item)) {
$str .= $item . ', ';
} else {
$quote = self::getQuote((string)$item);
$str .= "$quote$item$quote, ";
}
}
return '[' . rtrim($str, ", ") . ']';
}
public static function buildDefaultOrder(string $field, string $type): string
{
if ($field && $type) {
$defaultOrderStub = self::getJsonFromArray(['prop' => $field, 'order' => $type]);
if ($defaultOrderStub) {
return "\n" . self::tab(2) . "defaultOrder: " . $defaultOrderStub . ',';
}
}
return '';
}
public static function getJsonFromArray($arr)
{
if (is_array($arr)) {
$jsonStr = '';
foreach ($arr as $key => $item) {
$keyStr = ' ' . self::formatObjectKey($key) . ': ';
if (is_array($item)) {
$jsonStr .= $keyStr . self::getJsonFromArray($item) . ',';
} elseif ($item === 'false' || $item === 'true') {
$jsonStr .= $keyStr . ($item === 'false' ? 'false' : 'true') . ',';
} elseif ($item === null) {
$jsonStr .= $keyStr . 'null,';
} elseif (str_starts_with((string)$item, "t('") || str_starts_with((string)$item, 't("') || $item == '[]' || in_array(gettype($item), ['integer', 'double'])) {
$jsonStr .= $keyStr . $item . ',';
} elseif (isset($item[0]) && $item[0] == '[' && str_ends_with((string)$item, ']')) {
$jsonStr .= $keyStr . $item . ',';
} else {
$quote = self::getQuote((string)$item);
$jsonStr .= $keyStr . "$quote$item$quote,";
}
}
return $jsonStr ? '{' . rtrim($jsonStr, ',') . ' }' : '{}';
}
return $arr;
}
}

View File

@@ -0,0 +1,63 @@
<template>
<!-- 对话框表单 -->
<!-- 建议使用 Prettier 格式化代码 -->
<!-- el-form 内可以混用 el-form-item、FormItem、ba-input 等输入组件 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"{%bigDialog%}
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '':'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
v-if="!baTable.form.loading"
ref="formRef"
@submit.prevent=""
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>{%formFields%}
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'{%importExpand%}
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({{%formItemRules%}})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,69 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<!-- 自定义按钮请使用插槽,甚至公共搜索也可以使用具名插槽渲染,参见文档 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('{%webTranslate%}quick Search Fields') })"
></TableHeader>
<!-- 表格 -->
<!-- 表格列有多种自定义渲染方式,比如自定义组件、具名插槽等,参见文档 -->
<!-- 要使用 el-table 组件原有的属性,直接加在 Table 标签上即可 -->
<Table ref="tableRef"></Table>
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: '{%componentName%}',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons({%optButtons%})
/**
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/
const baTable = new baTableClass(
new baTableApi({%controllerUrl%}),
{
pk: '{%tablePk%}',
column: [
{%tableColumn%}
],
dblClickNotEditColumn: {%dblClickNotEditColumn%},{%defaultOrder%}
},
{
defaultItems: {%defaultItems%},
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,24 @@
<?php
namespace {%namespace%};
{%use%}
use app\common\controller\Backend;
/**
* {%tableComment%}
*/
class {%className%} extends Backend
{
/**
* {%modelName%}模型对象
* @var object
* @phpstan-var \{%modelNamespace%}\{%modelName%}
*/
protected object $model;
{%attr%}{%initialize%}
{%methods%}
/**
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
*/
}

View File

@@ -0,0 +1,32 @@
/**
* 查看
* @throws Throwable
*/
public function index(): void
{
// 如果是 select 则转发到 select 方法,若未重写该方法,其实还是继续执行 index
if ($this->request->param('select')) {
$this->select();
}
/**
* 1. withJoin 不可使用 alias 方法设置表别名,别名将自动使用关联模型名称(小写下划线命名规则)
* 2. 以下的别名设置了主表别名,同时便于拼接查询参数等
* 3. paginate 数据集可使用链式操作 each(function($item, $key) {}) 遍历处理
*/
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
{%relationVisibleFields%}
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}

View File

@@ -0,0 +1,6 @@
public function initialize(): void
{
parent::initialize();
$this->model = new \{%modelNamespace%}\{%modelName%}();{%filterRule%}
}

View File

@@ -0,0 +1,12 @@
protected static function onAfterInsert($model): void
{
if (is_null($model->{%field%})) {
$pk = $model->getPk();
if (strlen($model[$pk]) >= 19) {
$model->where($pk, $model[$pk])->update(['{%field%}' => $model->count()]);
} else {
$model->where($pk, $model[$pk])->update(['{%field%}' => $model[$pk]]);
}
}
}

View File

@@ -0,0 +1,5 @@
protected static function onBeforeInsert($model): void
{
{%setSnowFlakeIdCode%}
}

View File

@@ -0,0 +1,5 @@
public function {%relationMethod%}(): \think\model\relation\BelongsTo
{
return $this->{%relationMode%}({%relationClassName%}, '{%relationForeignKey%}', '{%relationPrimaryKey%}');
}

View File

@@ -0,0 +1,7 @@
public function get{%field%}Attr($value, $row): string
{
if ($row['{%originalFieldName%}'] === '' || $row['{%originalFieldName%}'] === null) return '';
$cityNames = \think\facade\Db::name('area')->whereIn('id', $row['{%originalFieldName%}'])->column('name');
return $cityNames ? implode(',', $cityNames) : '';
}

View File

@@ -0,0 +1,5 @@
public function get{%field%}Attr($value): ?float
{
return is_null($value) ? null : (float)$value;
}

View File

@@ -0,0 +1,5 @@
public function get{%field%}Attr($value): string
{
return !$value ? '' : htmlspecialchars_decode($value);
}

View File

@@ -0,0 +1,5 @@
public function get{%field%}Attr($value): array
{
return !$value ? [] : json_decode($value, true);
}

View File

@@ -0,0 +1,7 @@
public function get{%field%}Attr($value, $row): array
{
return [
'{%labelFieldName%}' => {%className%}::whereIn('{%primaryKey%}', $row['{%foreignKey%}'])->column('{%labelFieldName%}'),
];
}

View File

@@ -0,0 +1,5 @@
public function get{%field%}Attr($value): string
{
return (string)$value;
}

View File

@@ -0,0 +1,9 @@
public function get{%field%}Attr($value): array
{
if ($value === '' || $value === null) return [];
if (!is_array($value)) {
return explode(',', $value);
}
return $value;
}

View File

@@ -0,0 +1,2 @@
$pk = $model->getPk();
$model->$pk = \app\common\library\SnowFlake::generateParticle();

View File

@@ -0,0 +1,18 @@
<?php
namespace {%namespace%};
use think\Model;
/**
* {%className%}
*/
class {%className%} extends Model
{{%connection%}{%pk%}
// 表名
protected $name = '{%name%}';
// 自动写入时间戳字段
protected $autoWriteTimestamp = {%autoWriteTimestamp%};{%createTime%}{%updateTime%}
{%append%}{%fieldType%}{%beforeInsert%}{%afterInsert%}{%methods%}
}

View File

@@ -0,0 +1,5 @@
public function set{%field%}Attr($value): string
{
return is_array($value) ? implode(',', $value) : $value;
}

View File

@@ -0,0 +1,5 @@
public function set{%field%}Attr($value): ?string
{
return $value ? date('H:i:s', strtotime($value)) : $value;
}

View File

@@ -0,0 +1,31 @@
<?php
namespace {%namespace%};
use think\Validate;
class {%className%} extends Validate
{
protected $failException = true;
/**
* 验证规则
*/
protected $rule = [
];
/**
* 提示消息
*/
protected $message = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => [],
'edit' => [],
];
}

View File

@@ -0,0 +1,900 @@
<?php
declare(strict_types=1);
namespace app\admin\library\module;
use Throwable;
use ba\Version;
use ba\Depends;
use ba\Exception;
use ba\Filesystem;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use Webman\Http\Request;
/**
* 模块管理类Webman 迁移版)
*/
class Manage
{
public const UNINSTALLED = 0;
public const INSTALLED = 1;
public const WAIT_INSTALL = 2;
public const CONFLICT_PENDING = 3;
public const DEPENDENT_WAIT_INSTALL = 4;
public const DIRECTORY_OCCUPIED = 5;
public const DISABLE = 6;
protected static ?Manage $instance = null;
protected string $installDir;
protected string $backupsDir;
protected string $uid;
protected string $modulesDir;
public static function instance(string $uid = ''): Manage
{
if (self::$instance === null) {
self::$instance = new static($uid);
}
return self::$instance->setModuleUid($uid);
}
public function __construct(string $uid)
{
$this->installDir = root_path() . 'modules' . DIRECTORY_SEPARATOR;
$this->backupsDir = $this->installDir . 'backups' . DIRECTORY_SEPARATOR;
if (!is_dir($this->installDir)) {
mkdir($this->installDir, 0755, true);
}
if (!is_dir($this->backupsDir)) {
mkdir($this->backupsDir, 0755, true);
}
if ($uid) {
$this->setModuleUid($uid);
} else {
$this->uid = '';
$this->modulesDir = $this->installDir;
}
}
public function getInstallState(): int
{
if (!is_dir($this->modulesDir)) {
return self::UNINSTALLED;
}
$info = $this->getInfo();
if ($info && isset($info['state'])) {
return $info['state'];
}
return Filesystem::dirIsEmpty($this->modulesDir) ? self::UNINSTALLED : self::DIRECTORY_OCCUPIED;
}
/**
* 从 Webman Request 上传安装(适配 Multipart 上传)
* @return array 模块基本信息
* @throws Throwable
*/
public static function uploadFromRequest(Request $request): array
{
$file = $request->file('file');
if (!$file) {
throw new Exception('Parameter error');
}
$token = $request->post('token', $request->get('token', ''));
if (!$token) {
throw new Exception('Please login to the official website account first');
}
$uploadDir = root_path() . 'public' . DIRECTORY_SEPARATOR . 'storage' . DIRECTORY_SEPARATOR . 'upload' . DIRECTORY_SEPARATOR;
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$saveName = 'temp' . DIRECTORY_SEPARATOR . date('YmdHis') . '_' . ($file->getUploadName() ?? 'module.zip');
$savePath = $uploadDir . $saveName;
$saveDir = dirname($savePath);
if (!is_dir($saveDir)) {
mkdir($saveDir, 0755, true);
}
$file->move($savePath);
$relativePath = 'storage/upload/' . str_replace(DIRECTORY_SEPARATOR, '/', $saveName);
try {
return self::instance('')->doUpload($token, $relativePath);
} finally {
if (is_file($savePath)) {
@unlink($savePath);
}
}
}
/**
* 下载模块文件
* @throws Throwable
*/
public function download(): string
{
$req = function_exists('request') ? request() : null;
$token = $req ? ($req->post('token', $req->get('token', ''))) : '';
$version = $req ? ($req->post('version', $req->get('version', ''))) : '';
$orderId = $req ? ($req->post('orderId', $req->get('orderId', 0))) : 0;
if (!$orderId) {
throw new Exception('Order not found');
}
$zipFile = Server::download($this->uid, $this->installDir, [
'version' => $version,
'orderId' => $orderId,
'nuxtVersion' => Server::getNuxtVersion(),
'sysVersion' => config('buildadmin.version', ''),
'installed' => Server::getInstalledIds($this->installDir),
'ba-user-token' => $token,
]);
Filesystem::delDir($this->modulesDir);
Filesystem::unzip($zipFile);
@unlink($zipFile);
$this->checkPackage();
$this->setInfo([
'state' => self::WAIT_INSTALL,
]);
return $zipFile;
}
/**
* 上传安装token + 文件相对路径)
* @throws Throwable
*/
public function doUpload(string $token, string $file): array
{
$file = Filesystem::fsFit(root_path() . 'public' . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $file));
if (!is_file($file)) {
throw new Exception('Zip file not found');
}
$copyTo = $this->installDir . 'uploadTemp' . date('YmdHis') . '.zip';
copy($file, $copyTo);
$copyToDir = Filesystem::unzip($copyTo);
$copyToDir .= DIRECTORY_SEPARATOR;
@unlink($file);
@unlink($copyTo);
$info = Server::getIni($copyToDir);
if (empty($info['uid'])) {
Filesystem::delDir($copyToDir);
throw new Exception('Basic configuration of the Module is incomplete');
}
$this->setModuleUid($info['uid']);
$upgrade = false;
if (is_dir($this->modulesDir)) {
$oldInfo = $this->getInfo();
if ($oldInfo && !empty($oldInfo['uid'])) {
$versions = explode('.', $oldInfo['version'] ?? '0.0.0');
if (isset($versions[2])) {
$versions[2]++;
}
$nextVersion = implode('.', $versions);
$upgrade = Version::compare($nextVersion, $info['version'] ?? '');
if ($upgrade) {
if (!in_array($oldInfo['state'], [self::UNINSTALLED, self::WAIT_INSTALL, self::DISABLE])) {
Filesystem::delDir($copyToDir);
throw new Exception('Please disable the module before updating');
}
} else {
Filesystem::delDir($copyToDir);
throw new Exception('Module already exists');
}
}
if (!Filesystem::dirIsEmpty($this->modulesDir) && !$upgrade) {
Filesystem::delDir($copyToDir);
throw new Exception('The directory required by the module is occupied');
}
}
try {
Server::installPreCheck([
'uid' => $info['uid'],
'version' => $info['version'] ?? '',
'sysVersion' => config('buildadmin.version', ''),
'nuxtVersion' => Server::getNuxtVersion(),
'moduleVersion' => $info['version'] ?? '',
'ba-user-token' => $token,
'installed' => Server::getInstalledIds($this->installDir),
'server' => 1,
]);
} catch (Throwable $e) {
Filesystem::delDir($copyToDir);
throw $e;
}
$newInfo = ['state' => self::WAIT_INSTALL];
if ($upgrade) {
$info['update'] = 1;
Filesystem::delDir($this->modulesDir);
}
rename($copyToDir, $this->modulesDir);
$this->checkPackage();
$this->setInfo($newInfo);
return $info;
}
/**
* 安装模块
* @throws Throwable
*/
public function install(bool $update): array
{
$state = $this->getInstallState();
if ($update) {
if (!in_array($state, [self::UNINSTALLED, self::WAIT_INSTALL, self::DISABLE])) {
throw new Exception('Please disable the module before updating');
}
if ($state == self::UNINSTALLED || $state != self::WAIT_INSTALL) {
$this->download();
}
} else {
if ($state == self::INSTALLED || $state == self::DIRECTORY_OCCUPIED || $state == self::DISABLE) {
throw new Exception('Module already exists');
}
if ($state == self::UNINSTALLED) {
$this->download();
}
}
Server::importSql($this->modulesDir);
$info = $this->getInfo();
if ($update) {
$info['update'] = 1;
Server::execEvent($this->uid, 'update');
}
$req = function_exists('request') ? request() : null;
$extend = $req ? ($req->post('extend') ?? []) : [];
if (!isset($extend['conflictHandle'])) {
Server::execEvent($this->uid, 'install');
}
$this->enable('install');
return $info;
}
/**
* 卸载
* @throws Throwable
*/
public function uninstall(): void
{
$info = $this->getInfo();
if (($info['state'] ?? 0) != self::DISABLE) {
throw new Exception('Please disable the module first', 0, [
'uid' => $this->uid,
]);
}
Server::execEvent($this->uid, 'uninstall');
Filesystem::delDir($this->modulesDir);
}
/**
* 修改模块状态
* @throws Throwable
*/
public function changeState(bool $state): array
{
$info = $this->getInfo();
if (!$state) {
$canDisable = [
self::INSTALLED,
self::CONFLICT_PENDING,
self::DEPENDENT_WAIT_INSTALL,
];
if (!in_array($info['state'] ?? 0, $canDisable)) {
throw new Exception('The current state of the module cannot be set to disabled', 0, [
'uid' => $this->uid,
'state' => $info['state'] ?? 0,
]);
}
return $this->disable();
}
if (($info['state'] ?? 0) != self::DISABLE) {
throw new Exception('The current state of the module cannot be set to enabled', 0, [
'uid' => $this->uid,
'state' => $info['state'] ?? 0,
]);
}
$this->setInfo([
'state' => self::WAIT_INSTALL,
]);
return $info;
}
/**
* 启用
* @throws Throwable
*/
public function enable(string $trigger): void
{
Server::installWebBootstrap($this->uid, $this->modulesDir);
Server::createRuntime($this->modulesDir);
$this->conflictHandle($trigger);
Server::execEvent($this->uid, 'enable');
$this->dependUpdateHandle();
}
/**
* 禁用
* @throws Throwable
*/
public function disable(): array
{
$req = function_exists('request') ? request() : null;
$update = $req ? filter_var($req->post('update', false), FILTER_VALIDATE_BOOLEAN) : false;
$confirmConflict = $req ? filter_var($req->post('confirmConflict', false), FILTER_VALIDATE_BOOLEAN) : false;
$dependConflictSolution = $req ? ($req->post('dependConflictSolution') ?? []) : [];
$info = $this->getInfo();
$zipFile = $this->backupsDir . $this->uid . '-install.zip';
$zipDir = false;
if (is_file($zipFile)) {
try {
$zipDir = $this->backupsDir . $this->uid . '-install' . DIRECTORY_SEPARATOR;
Filesystem::unzip($zipFile, $zipDir);
} catch (Exception) {
// skip
}
}
$conflictFile = Server::getFileList($this->modulesDir, true);
$dependConflict = $this->disableDependCheck();
if (($conflictFile || !self::isEmptyArray($dependConflict)) && !$confirmConflict) {
$dependConflictTemp = [];
foreach ($dependConflict as $env => $item) {
foreach ($item as $depend => $v) {
$dependConflictTemp[] = [
'env' => $env,
'depend' => $depend,
'dependTitle' => $depend . ' ' . $v,
'solution' => 'delete',
];
}
}
throw new Exception('Module file updated', -1, [
'uid' => $this->uid,
'conflictFile' => $conflictFile,
'dependConflict' => $dependConflictTemp,
]);
}
Server::execEvent($this->uid, 'disable', ['update' => $update]);
$delNpmDepend = false;
$delNuxtNpmDepend = false;
$delComposerDepend = false;
foreach ($dependConflictSolution as $env => $depends) {
if (!$depends) continue;
if ($env == 'require' || $env == 'require-dev') {
$delComposerDepend = true;
} elseif ($env == 'dependencies' || $env == 'devDependencies') {
$delNpmDepend = true;
} elseif ($env == 'nuxtDependencies' || $env == 'nuxtDevDependencies') {
$delNuxtNpmDepend = true;
}
}
$dependJsonFiles = [
'composer' => 'composer.json',
'webPackage' => 'web' . DIRECTORY_SEPARATOR . 'package.json',
'webNuxtPackage' => 'web-nuxt' . DIRECTORY_SEPARATOR . 'package.json',
];
$dependWaitInstall = [];
if ($delComposerDepend) {
$conflictFile[] = $dependJsonFiles['composer'];
$dependWaitInstall[] = [
'pm' => false,
'command' => 'composer.update',
'type' => 'composer_dependent_wait_install',
];
}
if ($delNpmDepend) {
$conflictFile[] = $dependJsonFiles['webPackage'];
$dependWaitInstall[] = [
'pm' => true,
'command' => 'web-install',
'type' => 'npm_dependent_wait_install',
];
}
if ($delNuxtNpmDepend) {
$conflictFile[] = $dependJsonFiles['webNuxtPackage'];
$dependWaitInstall[] = [
'pm' => true,
'command' => 'nuxt-install',
'type' => 'nuxt_npm_dependent_wait_install',
];
}
if ($conflictFile) {
$overwriteDir = Server::getOverwriteDir();
foreach ($conflictFile as $key => $item) {
$paths = explode(DIRECTORY_SEPARATOR, $item);
if (in_array($paths[0], $overwriteDir) || in_array($item, $dependJsonFiles)) {
$conflictFile[$key] = $item;
} else {
$conflictFile[$key] = Filesystem::fsFit(str_replace(root_path(), '', $this->modulesDir . $item));
}
if (!is_file(root_path() . $conflictFile[$key])) {
unset($conflictFile[$key]);
}
}
$backupsZip = $this->backupsDir . $this->uid . '-disable-' . date('YmdHis') . '.zip';
Filesystem::zip($conflictFile, $backupsZip);
}
$serverDepend = new Depends(root_path() . 'composer.json', 'composer');
$webDep = new Depends(root_path() . 'web' . DIRECTORY_SEPARATOR . 'package.json');
$webNuxtDep = new Depends(root_path() . 'web-nuxt' . DIRECTORY_SEPARATOR . 'package.json');
foreach ($dependConflictSolution as $env => $depends) {
if (!$depends) continue;
$dev = stripos($env, 'dev') !== false;
if ($env == 'require' || $env == 'require-dev') {
$serverDepend->removeDepends($depends, $dev);
} elseif ($env == 'dependencies' || $env == 'devDependencies') {
$webDep->removeDepends($depends, $dev);
} elseif ($env == 'nuxtDependencies' || $env == 'nuxtDevDependencies') {
$webNuxtDep->removeDepends($depends, $dev);
}
}
$composerConfig = Server::getConfig($this->modulesDir, 'composerConfig');
if ($composerConfig) {
$serverDepend->removeComposerConfig($composerConfig);
}
$protectedFiles = Server::getConfig($this->modulesDir, 'protectedFiles');
foreach ($protectedFiles as &$protectedFile) {
$protectedFile = Filesystem::fsFit(root_path() . $protectedFile);
}
$moduleFile = Server::getFileList($this->modulesDir);
foreach ($moduleFile as &$file) {
$moduleFilePath = Filesystem::fsFit($this->modulesDir . $file);
$file = Filesystem::fsFit(root_path() . $file);
if (!file_exists($file)) continue;
if (!file_exists($moduleFilePath)) {
if (!is_dir(dirname($moduleFilePath))) {
mkdir(dirname($moduleFilePath), 0755, true);
}
copy($file, $moduleFilePath);
}
if (in_array($file, $protectedFiles)) {
continue;
}
if (file_exists($file)) {
unlink($file);
}
Filesystem::delEmptyDir(dirname($file));
}
if ($zipDir) {
$unrecoverableFiles = [
Filesystem::fsFit(root_path() . 'composer.json'),
Filesystem::fsFit(root_path() . 'web/package.json'),
Filesystem::fsFit(root_path() . 'web-nuxt/package.json'),
];
foreach (
new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($zipDir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
) as $item
) {
$backupsFile = Filesystem::fsFit(root_path() . str_replace($zipDir, '', $item->getPathname()));
if (in_array($backupsFile, $moduleFile) && !in_array($backupsFile, $protectedFiles)) {
continue;
}
if ($item->isDir()) {
if (!is_dir($backupsFile)) {
mkdir($backupsFile, 0755, true);
}
} elseif (!in_array($backupsFile, $unrecoverableFiles)) {
copy($item->getPathname(), $backupsFile);
}
}
}
if ($zipDir && is_dir($zipDir)) {
Filesystem::delDir($zipDir);
}
Server::uninstallWebBootstrap($this->uid);
$this->setInfo([
'state' => self::DISABLE,
]);
if ($update) {
throw new Exception('update', -3, [
'uid' => $this->uid,
]);
}
if (!empty($dependWaitInstall)) {
throw new Exception('dependent wait install', -2, [
'uid' => $this->uid,
'wait_install' => $dependWaitInstall,
]);
}
return $info;
}
/**
* 处理依赖和文件冲突
* @throws Throwable
*/
public function conflictHandle(string $trigger): bool
{
$info = $this->getInfo();
if (!in_array($info['state'] ?? 0, [self::WAIT_INSTALL, self::CONFLICT_PENDING])) {
return false;
}
$fileConflict = Server::getFileList($this->modulesDir, true);
$dependConflict = Server::dependConflictCheck($this->modulesDir);
$installFiles = Server::getFileList($this->modulesDir);
$depends = Server::getDepend($this->modulesDir);
$coverFiles = [];
$discardFiles = [];
$serverDep = new Depends(root_path() . 'composer.json', 'composer');
$webDep = new Depends(root_path() . 'web' . DIRECTORY_SEPARATOR . 'package.json');
$webNuxtDep = new Depends(root_path() . 'web-nuxt' . DIRECTORY_SEPARATOR . 'package.json');
$req = function_exists('request') ? request() : null;
$extend = $req ? ($req->post('extend') ?? []) : [];
if ($fileConflict || !self::isEmptyArray($dependConflict)) {
if (!$extend) {
$fileConflictTemp = [];
foreach ($fileConflict as $key => $item) {
$fileConflictTemp[$key] = [
'newFile' => $this->uid . DIRECTORY_SEPARATOR . $item,
'oldFile' => $item,
'solution' => 'cover',
];
}
$dependConflictTemp = [];
foreach ($dependConflict as $env => $item) {
$dev = stripos($env, 'dev') !== false;
foreach ($item as $depend => $v) {
$oldDepend = '';
if (in_array($env, ['require', 'require-dev'])) {
$oldDepend = $depend . ' ' . $serverDep->hasDepend($depend, $dev);
} elseif (in_array($env, ['dependencies', 'devDependencies'])) {
$oldDepend = $depend . ' ' . $webDep->hasDepend($depend, $dev);
} elseif (in_array($env, ['nuxtDependencies', 'nuxtDevDependencies'])) {
$oldDepend = $depend . ' ' . $webNuxtDep->hasDepend($depend, $dev);
}
$dependConflictTemp[] = [
'env' => $env,
'newDepend' => $depend . ' ' . $v,
'oldDepend' => $oldDepend,
'depend' => $depend,
'solution' => 'cover',
];
}
}
$this->setInfo([
'state' => self::CONFLICT_PENDING,
]);
throw new Exception('Module file conflicts', -1, [
'fileConflict' => $fileConflictTemp,
'dependConflict' => $dependConflictTemp,
'uid' => $this->uid,
'state' => self::CONFLICT_PENDING,
]);
}
if ($fileConflict && isset($extend['fileConflict'])) {
foreach ($installFiles as $ikey => $installFile) {
if (isset($extend['fileConflict'][$installFile])) {
if ($extend['fileConflict'][$installFile] == 'discard') {
$discardFiles[] = $installFile;
unset($installFiles[$ikey]);
} else {
$coverFiles[] = $installFile;
}
}
}
}
if (!self::isEmptyArray($dependConflict) && isset($extend['dependConflict'])) {
foreach ($depends as $fKey => $fItem) {
foreach ($fItem as $cKey => $cItem) {
if (isset($extend['dependConflict'][$fKey][$cKey])) {
if ($extend['dependConflict'][$fKey][$cKey] == 'discard') {
unset($depends[$fKey][$cKey]);
}
}
}
}
}
}
if ($depends) {
foreach ($depends as $key => $item) {
if (!$item) continue;
if ($key == 'require' || $key == 'require-dev') {
$coverFiles[] = 'composer.json';
continue;
}
if ($key == 'dependencies' || $key == 'devDependencies') {
$coverFiles[] = 'web' . DIRECTORY_SEPARATOR . 'package.json';
}
if ($key == 'nuxtDependencies' || $key == 'nuxtDevDependencies') {
$coverFiles[] = 'web-nuxt' . DIRECTORY_SEPARATOR . 'package.json';
}
}
}
if ($coverFiles) {
$backupsZip = $trigger == 'install' ? $this->backupsDir . $this->uid . '-install.zip' : $this->backupsDir . $this->uid . '-cover-' . date('YmdHis') . '.zip';
Filesystem::zip($coverFiles, $backupsZip);
}
if ($depends) {
$npm = false;
$composer = false;
$nuxtNpm = false;
$composerConfig = Server::getConfig($this->modulesDir, 'composerConfig');
if ($composerConfig) {
$serverDep->setComposerConfig($composerConfig);
}
foreach ($depends as $key => $item) {
if (!$item) continue;
if ($key == 'require') {
$composer = true;
$serverDep->addDepends($item, false, true);
} elseif ($key == 'require-dev') {
$composer = true;
$serverDep->addDepends($item, true, true);
} elseif ($key == 'dependencies') {
$npm = true;
$webDep->addDepends($item, false, true);
} elseif ($key == 'devDependencies') {
$npm = true;
$webDep->addDepends($item, true, true);
} elseif ($key == 'nuxtDependencies') {
$nuxtNpm = true;
$webNuxtDep->addDepends($item, false, true);
} elseif ($key == 'nuxtDevDependencies') {
$nuxtNpm = true;
$webNuxtDep->addDepends($item, true, true);
}
}
if ($npm) {
$info['npm_dependent_wait_install'] = 1;
$info['state'] = self::DEPENDENT_WAIT_INSTALL;
}
if ($composer) {
$info['composer_dependent_wait_install'] = 1;
$info['state'] = self::DEPENDENT_WAIT_INSTALL;
}
if ($nuxtNpm) {
$info['nuxt_npm_dependent_wait_install'] = 1;
$info['state'] = self::DEPENDENT_WAIT_INSTALL;
}
$info = $info ?? $this->getInfo();
if (($info['state'] ?? 0) != self::DEPENDENT_WAIT_INSTALL) {
$this->setInfo(['state' => self::INSTALLED]);
} else {
$this->setInfo([], $info);
}
} else {
$this->setInfo(['state' => self::INSTALLED]);
}
$overwriteDir = Server::getOverwriteDir();
foreach ($overwriteDir as $dirItem) {
$baseDir = $this->modulesDir . $dirItem;
$destDir = root_path() . $dirItem;
if (!is_dir($baseDir)) continue;
foreach (
new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($baseDir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
) as $item
) {
$destDirItem = Filesystem::fsFit($destDir . DIRECTORY_SEPARATOR . str_replace($baseDir, '', $item->getPathname()));
if ($item->isDir()) {
Filesystem::mkdir($destDirItem);
} elseif (!in_array(str_replace(root_path(), '', $destDirItem), $discardFiles)) {
Filesystem::mkdir(dirname($destDirItem));
copy($item->getPathname(), $destDirItem);
}
}
if (config('buildadmin.module_pure_install', false)) {
Filesystem::delDir($baseDir);
}
}
return true;
}
/**
* 依赖升级处理
* @throws Throwable
*/
public function dependUpdateHandle(): void
{
$info = $this->getInfo();
if (($info['state'] ?? 0) == self::DEPENDENT_WAIT_INSTALL) {
$waitInstall = [];
if (isset($info['composer_dependent_wait_install'])) {
$waitInstall[] = 'composer_dependent_wait_install';
}
if (isset($info['npm_dependent_wait_install'])) {
$waitInstall[] = 'npm_dependent_wait_install';
}
if (isset($info['nuxt_npm_dependent_wait_install'])) {
$waitInstall[] = 'nuxt_npm_dependent_wait_install';
}
if ($waitInstall) {
throw new Exception('dependent wait install', -2, [
'uid' => $this->uid,
'state' => self::DEPENDENT_WAIT_INSTALL,
'wait_install' => $waitInstall,
]);
} else {
$this->setInfo(['state' => self::INSTALLED]);
}
}
}
/**
* 依赖安装完成标记
* @throws Throwable
*/
public function dependentInstallComplete(string $type): void
{
$info = $this->getInfo();
if (($info['state'] ?? 0) == self::DEPENDENT_WAIT_INSTALL) {
if ($type == 'npm') {
unset($info['npm_dependent_wait_install']);
}
if ($type == 'nuxt_npm') {
unset($info['nuxt_npm_dependent_wait_install']);
}
if ($type == 'composer') {
unset($info['composer_dependent_wait_install']);
}
if ($type == 'all') {
unset($info['npm_dependent_wait_install'], $info['composer_dependent_wait_install'], $info['nuxt_npm_dependent_wait_install']);
}
if (!isset($info['npm_dependent_wait_install']) && !isset($info['composer_dependent_wait_install']) && !isset($info['nuxt_npm_dependent_wait_install'])) {
$info['state'] = self::INSTALLED;
}
$this->setInfo([], $info);
}
}
public function disableDependCheck(): array
{
$depend = Server::getDepend($this->modulesDir);
if (!$depend) return [];
$serverDep = new Depends(root_path() . 'composer.json', 'composer');
$webDep = new Depends(root_path() . 'web' . DIRECTORY_SEPARATOR . 'package.json');
$webNuxtDep = new Depends(root_path() . 'web-nuxt' . DIRECTORY_SEPARATOR . 'package.json');
foreach ($depend as $key => $depends) {
$dev = stripos($key, 'dev') !== false;
if ($key == 'require' || $key == 'require-dev') {
foreach ($depends as $dependKey => $dependItem) {
if (!$serverDep->hasDepend($dependKey, $dev)) {
unset($depends[$dependKey]);
}
}
$depend[$key] = $depends;
} elseif ($key == 'dependencies' || $key == 'devDependencies') {
foreach ($depends as $dependKey => $dependItem) {
if (!$webDep->hasDepend($dependKey, $dev)) {
unset($depends[$dependKey]);
}
}
$depend[$key] = $depends;
} elseif ($key == 'nuxtDependencies' || $key == 'nuxtDevDependencies') {
foreach ($depends as $dependKey => $dependItem) {
if (!$webNuxtDep->hasDepend($dependKey, $dev)) {
unset($depends[$dependKey]);
}
}
$depend[$key] = $depends;
}
}
return $depend;
}
/**
* 检查包是否完整
* @throws Throwable
*/
public function checkPackage(): bool
{
if (!is_dir($this->modulesDir)) {
throw new Exception('Module package file does not exist');
}
$info = $this->getInfo();
$infoKeys = ['uid', 'title', 'intro', 'author', 'version', 'state'];
foreach ($infoKeys as $value) {
if (!array_key_exists($value, $info)) {
Filesystem::delDir($this->modulesDir);
throw new Exception('Basic configuration of the Module is incomplete');
}
}
return true;
}
public function getInfo(): array
{
return Server::getIni($this->modulesDir);
}
/**
* @throws Throwable
*/
public function setInfo(array $kv = [], array $arr = []): bool
{
if ($kv) {
$info = $this->getInfo();
foreach ($kv as $k => $v) {
$info[$k] = $v;
}
return Server::setIni($this->modulesDir, $info);
}
if ($arr) {
return Server::setIni($this->modulesDir, $arr);
}
throw new Exception('Parameter error');
}
public static function isEmptyArray($arr): bool
{
foreach ($arr as $item) {
if (is_array($item)) {
if (!self::isEmptyArray($item)) return false;
} elseif ($item) {
return false;
}
}
return true;
}
public function setModuleUid(string $uid): static
{
$this->uid = $uid;
$this->modulesDir = $this->installDir . $uid . DIRECTORY_SEPARATOR;
return $this;
}
}

View File

@@ -0,0 +1,551 @@
<?php
declare(strict_types=1);
namespace app\admin\library\module;
use Throwable;
use ba\Depends;
use ba\Exception;
use ba\Filesystem;
use support\think\Db;
use FilesystemIterator;
use RecursiveIteratorIterator;
use RecursiveDirectoryIterator;
use think\db\exception\PDOException;
use app\admin\library\crud\Helper;
use GuzzleHttp\Exception\TransferException;
/**
* 模块服务类Webman 迁移版)
*/
class Server
{
private static string $apiBaseUrl = '/api/v7.store/';
/**
* 下载
* @throws Throwable
*/
public static function download(string $uid, string $dir, array $extend = []): string
{
$tmpFile = $dir . $uid . ".zip";
try {
$client = get_ba_client();
$response = $client->get(self::$apiBaseUrl . 'download', ['query' => array_merge(['uid' => $uid, 'server' => 1], $extend)]);
$body = $response->getBody();
$content = $body->getContents();
if ($content == '' || stripos($content, '<title>系统发生错误</title>') !== false) {
throw new Exception('package download failed', 0);
}
if (str_starts_with($content, '{')) {
$json = (array)json_decode($content, true);
throw new Exception($json['msg'], $json['code'], $json['data'] ?? []);
}
} catch (TransferException $e) {
throw new Exception('package download failed', 0, ['msg' => $e->getMessage()]);
}
if ($write = fopen($tmpFile, 'w')) {
fwrite($write, $content);
fclose($write);
return $tmpFile;
}
throw new Exception("No permission to write temporary files");
}
/**
* 安装预检
* @throws Throwable
*/
public static function installPreCheck(array $query = []): bool
{
try {
$client = get_ba_client();
$response = $client->get(self::$apiBaseUrl . 'preCheck', ['query' => $query]);
$body = $response->getBody();
$statusCode = $response->getStatusCode();
$content = $body->getContents();
if ($content == '' || stripos($content, '<title>系统发生错误</title>') !== false || $statusCode != 200) {
return true;
}
if (str_starts_with($content, '{')) {
$json = json_decode($content, true);
if ($json && $json['code'] == 0) {
throw new Exception($json['msg'], $json['code'], $json['data'] ?? []);
}
}
} catch (TransferException $e) {
throw new Exception('package check failed', 0, ['msg' => $e->getMessage()]);
}
return true;
}
public static function getConfig(string $dir, $key = ''): array
{
$configFile = $dir . 'config.json';
if (!is_dir($dir) || !is_file($configFile)) {
return [];
}
$configContent = @file_get_contents($configFile);
$configContent = json_decode($configContent, true);
if (!$configContent) {
return [];
}
if ($key) {
return $configContent[$key] ?? [];
}
return $configContent;
}
public static function getDepend(string $dir, string $key = ''): array
{
if ($key) {
return self::getConfig($dir, $key);
}
$configContent = self::getConfig($dir);
$dependKey = ['require', 'require-dev', 'dependencies', 'devDependencies', 'nuxtDependencies', 'nuxtDevDependencies'];
$dependArray = [];
foreach ($dependKey as $item) {
if (array_key_exists($item, $configContent) && $configContent[$item]) {
$dependArray[$item] = $configContent[$item];
}
}
return $dependArray;
}
/**
* 依赖冲突检查
* @throws Throwable
*/
public static function dependConflictCheck(string $dir): array
{
$depend = self::getDepend($dir);
$serverDep = new Depends(root_path() . 'composer.json', 'composer');
$webDep = new Depends(root_path() . 'web' . DIRECTORY_SEPARATOR . 'package.json');
$webNuxtDep = new Depends(root_path() . 'web-nuxt' . DIRECTORY_SEPARATOR . 'package.json');
$sysDepend = [
'require' => $serverDep->getDepends(),
'require-dev' => $serverDep->getDepends(true),
'dependencies' => $webDep->getDepends(),
'devDependencies' => $webDep->getDepends(true),
'nuxtDependencies' => $webNuxtDep->getDepends(),
'nuxtDevDependencies' => $webNuxtDep->getDepends(true),
];
$conflict = [];
foreach ($depend as $key => $item) {
$conflict[$key] = array_uintersect_assoc($item, $sysDepend[$key] ?? [], function ($a, $b) {
return $a == $b ? -1 : 0;
});
}
return $conflict;
}
/**
* 获取模块[冲突]文件列表
*/
public static function getFileList(string $dir, bool $onlyConflict = false): array
{
if (!is_dir($dir)) {
return [];
}
$fileList = [];
$overwriteDir = self::getOverwriteDir();
$moduleFileList = self::getRuntime($dir, 'files');
if ($moduleFileList) {
if ($onlyConflict) {
$excludeFile = ['info.ini'];
foreach ($moduleFileList as $file) {
$path = Filesystem::fsFit(str_replace($dir, '', $file['path']));
$paths = explode(DIRECTORY_SEPARATOR, $path);
$overwriteFile = in_array($paths[0], $overwriteDir) ? root_path() . $path : $dir . $path;
if (is_file($overwriteFile) && !in_array($path, $excludeFile) && (filesize($overwriteFile) != $file['size'] || md5_file($overwriteFile) != $file['md5'])) {
$fileList[] = $path;
}
}
} else {
foreach ($overwriteDir as $item) {
$baseDir = $dir . $item;
foreach ($moduleFileList as $file) {
if (!str_starts_with($file['path'], $baseDir)) continue;
$fileList[] = Filesystem::fsFit(str_replace($dir, '', $file['path']));
}
}
}
return $fileList;
}
foreach ($overwriteDir as $item) {
$baseDir = $dir . $item;
if (!is_dir($baseDir)) {
continue;
}
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($baseDir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($files as $file) {
if ($file->isFile()) {
$filePath = $file->getPathName();
$path = str_replace($dir, '', $filePath);
$path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
if ($onlyConflict) {
$overwriteFile = root_path() . $path;
if (is_file($overwriteFile) && (filesize($overwriteFile) != filesize($filePath) || md5_file($overwriteFile) != md5_file($filePath))) {
$fileList[] = $path;
}
} else {
$fileList[] = $path;
}
}
}
}
return $fileList;
}
public static function getOverwriteDir(): array
{
return [
'app',
'config',
'database',
'extend',
'modules',
'public',
'vendor',
'web',
'web-nuxt',
];
}
public static function importSql(string $dir): bool
{
$sqlFile = $dir . 'install.sql';
$tempLine = '';
$prefix = config('thinkorm.connections.mysql.prefix', config('database.connections.mysql.prefix', ''));
if (is_file($sqlFile)) {
$lines = file($sqlFile);
foreach ($lines as $line) {
if (str_starts_with($line, '--') || $line == '' || str_starts_with($line, '/*')) {
continue;
}
$tempLine .= $line;
if (str_ends_with(trim($line), ';')) {
$tempLine = str_ireplace('__PREFIX__', $prefix, $tempLine);
$tempLine = str_ireplace('INSERT INTO ', 'INSERT IGNORE INTO ', $tempLine);
try {
Db::execute($tempLine);
} catch (PDOException) {
// ignore
}
$tempLine = '';
}
}
}
return true;
}
public static function installedList(string $dir): array
{
if (!is_dir($dir)) {
return [];
}
$installedDir = scandir($dir);
$installedList = [];
foreach ($installedDir as $item) {
if ($item === '.' or $item === '..' || is_file($dir . $item)) {
continue;
}
$tempDir = $dir . $item . DIRECTORY_SEPARATOR;
if (!is_dir($tempDir)) {
continue;
}
$info = self::getIni($tempDir);
if (!isset($info['uid'])) {
continue;
}
$installedList[] = $info;
}
return $installedList;
}
public static function getInstalledIds(string $dir): array
{
$installedIds = [];
$installed = self::installedList($dir);
foreach ($installed as $item) {
$installedIds[] = $item['uid'];
}
return $installedIds;
}
public static function getIni(string $dir): array
{
$infoFile = $dir . 'info.ini';
$info = [];
if (is_file($infoFile)) {
$info = parse_ini_file($infoFile, true, INI_SCANNER_TYPED) ?: [];
if (!$info) return [];
}
return $info;
}
/**
* @throws Throwable
*/
public static function setIni(string $dir, array $arr): bool
{
$infoFile = $dir . 'info.ini';
$ini = [];
foreach ($arr as $key => $val) {
if (is_array($val)) {
$ini[] = "[$key]";
foreach ($val as $ikey => $ival) {
$ini[] = "$ikey = $ival";
}
} else {
$ini[] = "$key = $val";
}
}
if (!file_put_contents($infoFile, implode("\n", $ini) . "\n", LOCK_EX)) {
throw new Exception("Configuration file has no write permission");
}
return true;
}
public static function getClass(string $uid, string $type = 'event', ?string $class = null): string
{
$name = parse_name($uid);
if (!is_null($class) && strpos($class, '.')) {
$class = explode('.', $class);
$class[count($class) - 1] = parse_name(end($class), 1);
$class = implode('\\', $class);
} else {
$class = parse_name(is_null($class) ? $name : $class, 1);
}
$namespace = match ($type) {
'controller' => '\\modules\\' . $name . '\\controller\\' . $class,
default => '\\modules\\' . $name . '\\' . $class,
};
return class_exists($namespace) ? $namespace : '';
}
public static function execEvent(string $uid, string $event, array $params = []): void
{
$eventClass = self::getClass($uid);
if (class_exists($eventClass)) {
$handle = new $eventClass();
if (method_exists($eventClass, $event)) {
$handle->$event($params);
}
}
}
public static function analysisWebBootstrap(string $uid, string $dir): array
{
$bootstrapFile = $dir . 'webBootstrap.stub';
if (!file_exists($bootstrapFile)) return [];
$bootstrapContent = file_get_contents($bootstrapFile);
$pregArr = [
'mainTsImport' => '/#main.ts import code start#([\s\S]*?)#main.ts import code end#/i',
'mainTsStart' => '/#main.ts start code start#([\s\S]*?)#main.ts start code end#/i',
'appVueImport' => '/#App.vue import code start#([\s\S]*?)#App.vue import code end#/i',
'appVueOnMounted' => '/#App.vue onMounted code start#([\s\S]*?)#App.vue onMounted code end#/i',
'nuxtAppVueImport' => '/#web-nuxt\/app.vue import code start#([\s\S]*?)#web-nuxt\/app.vue import code end#/i',
'nuxtAppVueStart' => '/#web-nuxt\/app.vue start code start#([\s\S]*?)#web-nuxt\/app.vue start code end#/i',
];
$codeStrArr = [];
foreach ($pregArr as $key => $item) {
preg_match($item, $bootstrapContent, $matches);
if (isset($matches[1]) && $matches[1]) {
$mainImportCodeArr = array_filter(preg_split('/\r\n|\r|\n/', $matches[1]));
if ($mainImportCodeArr) {
$codeStrArr[$key] = "\n";
if (count($mainImportCodeArr) == 1) {
foreach ($mainImportCodeArr as $codeItem) {
$codeStrArr[$key] .= $codeItem . self::buildMarkStr('module-line-mark', $uid, $key);
}
} else {
$codeStrArr[$key] .= self::buildMarkStr('module-multi-line-mark-start', $uid, $key);
foreach ($mainImportCodeArr as $codeItem) {
$codeStrArr[$key] .= $codeItem . "\n";
}
$codeStrArr[$key] .= self::buildMarkStr('module-multi-line-mark-end', $uid, $key);
}
}
}
unset($matches);
}
return $codeStrArr;
}
public static function installWebBootstrap(string $uid, string $dir): void
{
$bootstrapCode = self::analysisWebBootstrap($uid, $dir);
if (!$bootstrapCode) {
return;
}
$webPath = root_path() . 'web' . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR;
$webNuxtPath = root_path() . 'web-nuxt' . DIRECTORY_SEPARATOR;
$filePaths = [
'mainTsImport' => $webPath . 'main.ts',
'mainTsStart' => $webPath . 'main.ts',
'appVueImport' => $webPath . 'App.vue',
'appVueOnMounted' => $webPath . 'App.vue',
'nuxtAppVueImport' => $webNuxtPath . 'app.vue',
'nuxtAppVueStart' => $webNuxtPath . 'app.vue',
];
$marks = [
'mainTsImport' => self::buildMarkStr('import-root-mark'),
'mainTsStart' => self::buildMarkStr('start-root-mark'),
'appVueImport' => self::buildMarkStr('import-root-mark'),
'appVueOnMounted' => self::buildMarkStr('onMounted-root-mark'),
'nuxtAppVueImport' => self::buildMarkStr('import-root-mark'),
'nuxtAppVueStart' => self::buildMarkStr('start-root-mark'),
];
foreach ($bootstrapCode as $key => $item) {
if ($item && isset($marks[$key]) && isset($filePaths[$key]) && is_file($filePaths[$key])) {
$content = file_get_contents($filePaths[$key]);
$markPos = stripos($content, $marks[$key]);
if ($markPos && strripos($content, self::buildMarkStr('module-line-mark', $uid, $key)) === false && strripos($content, self::buildMarkStr('module-multi-line-mark-start', $uid, $key)) === false) {
$content = substr_replace($content, $item, $markPos + strlen($marks[$key]), 0);
file_put_contents($filePaths[$key], $content);
}
}
}
}
public static function uninstallWebBootstrap(string $uid): void
{
$webPath = root_path() . 'web' . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR;
$webNuxtPath = root_path() . 'web-nuxt' . DIRECTORY_SEPARATOR;
$filePaths = [
'mainTsImport' => $webPath . 'main.ts',
'mainTsStart' => $webPath . 'main.ts',
'appVueImport' => $webPath . 'App.vue',
'appVueOnMounted' => $webPath . 'App.vue',
'nuxtAppVueImport' => $webNuxtPath . 'app.vue',
'nuxtAppVueStart' => $webNuxtPath . 'app.vue',
];
$marksKey = [
'mainTsImport',
'mainTsStart',
'appVueImport',
'appVueOnMounted',
'nuxtAppVueImport',
'nuxtAppVueStart',
];
foreach ($marksKey as $item) {
if (!isset($filePaths[$item]) || !is_file($filePaths[$item])) {
continue;
}
$content = file_get_contents($filePaths[$item]);
$moduleLineMark = self::buildMarkStr('module-line-mark', $uid, $item);
$moduleMultiLineMarkStart = self::buildMarkStr('module-multi-line-mark-start', $uid, $item);
$moduleMultiLineMarkEnd = self::buildMarkStr('module-multi-line-mark-end', $uid, $item);
$moduleLineMarkPos = strripos($content, $moduleLineMark);
if ($moduleLineMarkPos !== false) {
$delStartTemp = explode($moduleLineMark, $content);
$delStartPos = strripos(rtrim($delStartTemp[0], "\n"), "\n");
$delEndPos = stripos($content, "\n", $moduleLineMarkPos);
$content = substr_replace($content, '', $delStartPos, $delEndPos - $delStartPos);
}
$moduleMultiLineMarkStartPos = stripos($content, $moduleMultiLineMarkStart);
if ($moduleMultiLineMarkStartPos !== false) {
$moduleMultiLineMarkStartPos--;
$moduleMultiLineMarkEndPos = stripos($content, $moduleMultiLineMarkEnd);
$delLang = ($moduleMultiLineMarkEndPos + strlen($moduleMultiLineMarkEnd)) - $moduleMultiLineMarkStartPos;
$content = substr_replace($content, '', $moduleMultiLineMarkStartPos, $delLang);
}
if (($moduleLineMarkPos ?? false) !== false || ($moduleMultiLineMarkStartPos ?? false) !== false) {
file_put_contents($filePaths[$item], $content);
}
}
}
public static function buildMarkStr(string $type, string $uid = '', string $extend = ''): string
{
$nonTabKeys = ['mti', 'avi', 'navi', 'navs'];
$extend = match ($extend) {
'mainTsImport' => 'mti',
'mainTsStart' => 'mts',
'appVueImport' => 'avi',
'appVueOnMounted' => 'avo',
'nuxtAppVueImport' => 'navi',
'nuxtAppVueStart' => 'navs',
default => '',
};
return match ($type) {
'import-root-mark' => '// modules import mark, Please do not remove.',
'start-root-mark' => '// modules start mark, Please do not remove.',
'onMounted-root-mark' => '// Modules onMounted mark, Please do not remove.',
'module-line-mark' => ' // Code from module \'' . $uid . "'" . ($extend ? "($extend)" : ''),
'module-multi-line-mark-start' => (in_array($extend, $nonTabKeys) ? '' : Helper::tab()) . "// Code from module '$uid' start" . ($extend ? "($extend)" : '') . "\n",
'module-multi-line-mark-end' => (in_array($extend, $nonTabKeys) ? '' : Helper::tab()) . "// Code from module '$uid' end",
default => '',
};
}
public static function getNuxtVersion(): mixed
{
$nuxtPackageJsonPath = Filesystem::fsFit(root_path() . 'web-nuxt/package.json');
if (is_file($nuxtPackageJsonPath)) {
$nuxtPackageJson = file_get_contents($nuxtPackageJsonPath);
$nuxtPackageJson = json_decode($nuxtPackageJson, true);
if ($nuxtPackageJson && isset($nuxtPackageJson['version'])) {
return $nuxtPackageJson['version'];
}
}
return false;
}
public static function createRuntime(string $dir): void
{
$runtimeFilePath = $dir . '.runtime';
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir), RecursiveIteratorIterator::LEAVES_ONLY
);
$filePaths = [];
foreach ($files as $file) {
if (!$file->isDir()) {
$pathName = $file->getPathName();
if ($pathName == $runtimeFilePath) continue;
$filePaths[] = [
'path' => Filesystem::fsFit($pathName),
'size' => filesize($pathName),
'md5' => md5_file($pathName),
];
}
}
file_put_contents($runtimeFilePath, json_encode([
'files' => $filePaths,
'pure' => config('buildadmin.module_pure_install', false),
]));
}
public static function getRuntime(string $dir, string $key = ''): mixed
{
$runtimeFilePath = $dir . '.runtime';
$runtimeContent = @file_get_contents($runtimeFilePath);
$runtimeContentArr = json_decode($runtimeContent, true);
if (!$runtimeContentArr) return [];
if ($key) {
return $runtimeContentArr[$key] ?? [];
}
return $runtimeContentArr;
}
}

View File

@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
namespace app\admin\library\traits;
use Throwable;
use support\Response;
/**
* 后台控制器 traitWebman 迁移版)
* 提供 CRUD 方法index、add、edit、del、sortable
* 方法需返回 Response供 Webman 路由直接返回
*/
trait Backend
{
/**
* 排除入库字段
*/
protected function excludeFields(array $params): array
{
if (!is_array($this->preExcludeFields)) {
$this->preExcludeFields = explode(',', (string) $this->preExcludeFields);
}
foreach ($this->preExcludeFields as $field) {
if (array_key_exists($field, $params)) {
unset($params[$field]);
}
}
return $params;
}
/**
* 查看(内部实现,由 Backend::index(Request) 调用)
*/
protected function _index(): Response
{
if ($this->request && $this->request->get('select')) {
$this->select();
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->field($this->indexField)
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 添加(内部实现)
*/
protected function _add(): Response
{
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
$data[$this->dataLimitField] = $this->auth->id;
}
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('add');
}
$validate->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Added successfully'));
}
return $this->error(__('No rows were added'));
}
return $this->error(__('Parameter error'));
}
/**
* 编辑(内部实现)
*/
protected function _edit(): Response
{
$pk = $this->model->getPk();
$id = $this->request ? ($this->request->post($pk) ?? $this->request->get($pk)) : null;
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
return $this->error(__('You have no permission'));
}
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('edit');
}
$data[$pk] = $row[$pk];
$validate->check($data);
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Update successful'));
}
return $this->error(__('No rows updated'));
}
return $this->success('', [
'row' => $row
]);
}
/**
* 删除(内部实现)
*/
protected function _del(): Response
{
$where = [];
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$where[] = [$this->dataLimitField, 'in', $dataLimitAdminIds];
}
$ids = $this->request ? ($this->request->post('ids') ?? $this->request->get('ids') ?? []) : [];
$ids = is_array($ids) ? $ids : [];
$where[] = [$this->model->getPk(), 'in', $ids];
$data = $this->model->where($where)->select();
$count = 0;
$this->model->startTrans();
try {
foreach ($data as $v) {
$count += $v->delete();
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($count) {
return $this->success(__('Deleted successfully'));
}
return $this->error(__('No rows were deleted'));
}
/**
* 排序 - 增量重排法(内部实现)
*/
protected function _sortable(): Response
{
$pk = $this->model->getPk();
$move = $this->request ? $this->request->post('move') ?? $this->request->get('move') : null;
$target = $this->request ? $this->request->post('target') ?? $this->request->get('target') : null;
$order = $this->request ? ($this->request->post('order') ?? $this->request->get('order')) : null;
$order = $order ?: $this->defaultSortField;
$direction = $this->request ? ($this->request->post('direction') ?? $this->request->get('direction')) : null;
$dataLimitWhere = [];
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$dataLimitWhere[] = [$this->dataLimitField, 'in', $dataLimitAdminIds];
}
$moveRow = $this->model->where($dataLimitWhere)->find($move);
$targetRow = $this->model->where($dataLimitWhere)->find($target);
if ($move == $target || !$moveRow || !$targetRow || !$direction) {
return $this->error(__('Record not found'));
}
if ($order && is_string($order)) {
$order = explode(',', $order);
$order = [$order[0] => $order[1] ?? 'asc'];
}
if (!is_array($order) || !array_key_exists($this->weighField, $order)) {
return $this->error(__('Please use the %s field to sort before operating', [$this->weighField]));
}
$order = $this->queryOrderBuilder();
$weigh = $targetRow[$this->weighField];
$updateMethod = $order[$this->weighField] == 'desc' ? ($direction == 'up' ? 'dec' : 'inc') : ($direction == 'up' ? 'inc' : 'dec');
$weighRowIds = $this->model
->where($dataLimitWhere)
->where($this->weighField, $weigh)
->order($order)
->column($pk);
$weighRowsCount = count($weighRowIds);
$this->model->where($dataLimitWhere)
->where($this->weighField, $updateMethod == 'dec' ? '<' : '>', $weigh)
->whereNotIn($pk, [$moveRow->$pk])
->$updateMethod($this->weighField, $weighRowsCount)
->save();
if ($direction == 'down') {
$weighRowIds = array_reverse($weighRowIds);
}
$moveComplete = 0;
$weighRowIdsStr = implode(',', $weighRowIds);
$weighRows = $this->model->where($dataLimitWhere)
->where($pk, 'in', $weighRowIdsStr)
->orderRaw("field($pk,$weighRowIdsStr)")
->select();
foreach ($weighRows as $key => $weighRow) {
if ($moveRow[$pk] == $weighRow[$pk]) {
continue;
}
$rowWeighVal = $updateMethod == 'dec' ? $weighRow[$this->weighField] - $key : $weighRow[$this->weighField] + $key;
if ($weighRow[$pk] == $targetRow[$pk]) {
$moveComplete = 1;
$moveRow[$this->weighField] = $rowWeighVal;
$moveRow->save();
}
$rowWeighVal = $updateMethod == 'dec' ? $rowWeighVal - $moveComplete : $rowWeighVal + $moveComplete;
$weighRow[$this->weighField] = $rowWeighVal;
$weighRow->save();
}
return $this->success();
}
/**
* 加载为 select(远程下拉选择框)数据
*/
public function select(): void
{
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace app\admin\model;
use support\think\Model;
use support\think\Db;
/**
* Admin 模型Webman 迁移版)
* @property int $id 管理员ID
* @property string $username 管理员用户名
* @property string $nickname 管理员昵称
* @property string $email 管理员邮箱
* @property string $mobile 管理员手机号
* @property string $last_login_ip 上次登录IP
* @property string $last_login_time 上次登录时间
* @property int $login_failure 登录失败次数
* @property string $password 密码密文
* @property string $salt 密码盐
* @property string $status 状态:enable=启用,disable=禁用
*/
class Admin extends Model
{
protected string $table = 'admin';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected array $append = [
'group_arr',
'group_name_arr',
];
public function getGroupArrAttr($value, $row): array
{
return Db::name('admin_group_access')
->where('uid', $row['id'])
->column('group_id');
}
public function getGroupNameArrAttr($value, $row): array
{
$groupAccess = Db::name('admin_group_access')
->where('uid', $row['id'])
->column('group_id');
return AdminGroup::whereIn('id', $groupAccess)->column('name');
}
public function getAvatarAttr($value): string
{
return full_url($value ?? '', false, config('buildadmin.default_avatar'));
}
public function setAvatarAttr($value): string
{
return $value === full_url('', false, config('buildadmin.default_avatar')) ? '' : $value;
}
public function resetPassword(int|string $uid, string $newPassword): int
{
return $this->where(['id' => $uid])->update(['password' => hash_password($newPassword), 'salt' => '']);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace app\admin\model;
use support\think\Model;
/**
* AdminGroup 模型Webman 迁移版)
*/
class AdminGroup extends Model
{
protected string $table = 'admin_group';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
}

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace app\admin\model;
use Throwable;
use app\admin\library\Auth;
use support\think\Model;
use Webman\Http\Request;
/**
* AdminLog 模型Webman 迁移版)
*/
class AdminLog extends Model
{
protected string $table = 'admin_log';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected bool $updateTime = false;
protected string $title = '';
protected string|array $data = '';
protected array $urlIgnoreRegex = [
'/^(.*)\/(select|index|logout)$/i',
];
protected array $desensitizationRegex = [
'/(password|salt|token)/i'
];
public static function instance(?Request $request = null): self
{
$request = $request ?? (function_exists('request') ? request() : null);
if ($request !== null && isset($request->adminLog) && $request->adminLog instanceof self) {
return $request->adminLog;
}
$log = new static();
if ($request !== null) {
$request->adminLog = $log;
}
return $log;
}
public function setTitle(string $title): void
{
$this->title = $title;
}
/** 设置日志内容BuildAdmin 控制器调用) */
public function setData(string|array $data): void
{
$this->data = $data;
}
public function setUrlIgnoreRegex(array|string $regex = []): void
{
$this->urlIgnoreRegex = array_merge($this->urlIgnoreRegex, is_array($regex) ? $regex : [$regex]);
}
public function setDesensitizationRegex(array|string $regex = []): void
{
$this->desensitizationRegex = array_merge($this->desensitizationRegex, is_array($regex) ? $regex : [$regex]);
}
protected function desensitization(array|string $data): array|string
{
if (!is_array($data) || !$this->desensitizationRegex) {
return $data;
}
foreach ($data as $index => &$item) {
foreach ($this->desensitizationRegex as $reg) {
if (preg_match($reg, (string) $index)) {
$item = '***';
} elseif (is_array($item)) {
$item = $this->desensitization($item);
}
}
}
return $data;
}
public function record(string $title = '', string|array|null $data = null, ?Request $request = null): void
{
$request = $request ?? (function_exists('request') ? request() : null);
if (!$request) {
return;
}
$auth = Auth::instance();
$adminId = $auth->isLogin() ? $auth->id : 0;
$username = $auth->isLogin() ? $auth->username : ($request->get('username') ?? $request->post('username') ?? __('Unknown'));
$controllerPath = get_controller_path($request) ?? '';
$pathParts = explode('/', trim($request->path(), '/'));
$action = $pathParts[array_key_last($pathParts)] ?? '';
$path = $controllerPath . ($action ? '/' . $action : '');
foreach ($this->urlIgnoreRegex as $item) {
if (preg_match($item, $path)) {
return;
}
}
$data = $data ?: $this->data;
if (!$data) {
$data = array_merge($request->get(), $request->post());
}
$data = $this->desensitization($data);
$title = $title ?: $this->title;
if (!$title && class_exists(\app\admin\model\AdminRule::class)) {
$controllerTitle = \app\admin\model\AdminRule::where('name', $controllerPath)->value('title');
$pathTitle = \app\admin\model\AdminRule::where('name', $path)->value('title');
$title = $pathTitle ?: __('Unknown') . '(' . $action . ')';
$title = $controllerTitle ? ($controllerTitle . '-' . $title) : $title;
}
if (!$title) {
$title = __('Unknown');
}
$url = $request->url();
$url = strlen($url) > 1500 ? substr($url, 0, 1500) : $url;
$useragent = $request->header('user-agent', '');
$useragent = strlen($useragent) > 255 ? substr($useragent, 0, 255) : $useragent;
self::create([
'admin_id' => $adminId,
'username' => $username,
'url' => $url,
'title' => $title,
'data' => !is_scalar($data) ? json_encode($data, JSON_UNESCAPED_UNICODE) : $data,
'ip' => $request->getRealIp(),
'useragent' => $useragent,
]);
}
public function admin()
{
return $this->belongsTo(Admin::class);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace app\admin\model;
use support\think\Model;
class AdminRule extends Model
{
protected string $table = 'admin_rule';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
public function setComponentAttr($value)
{
if ($value) $value = str_replace('\\', '/', $value);
return $value;
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace app\admin\model;
use support\think\Model;
/**
* 系统配置模型Webman 迁移版)
*/
class Config extends Model
{
public static string $cacheTag = 'sys_config';
protected string $table = 'config';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected array $append = ['value', 'content', 'extend', 'input_extend'];
protected array $jsonDecodeType = ['checkbox', 'array', 'selects'];
protected array $needContent = ['radio', 'checkbox', 'select', 'selects'];
public static function onBeforeInsert(Config $model): void
{
if (!in_array($model->getData('type'), $model->needContent)) {
$model->content = null;
} else {
$model->content = json_encode(str_attr_to_array($model->getData('content')));
}
if (is_array($model->rule)) {
$model->rule = implode(',', $model->rule);
}
if ($model->getData('extend') || $model->getData('inputExtend')) {
$extend = str_attr_to_array($model->getData('extend'));
$inputExtend = str_attr_to_array($model->getData('inputExtend'));
if ($inputExtend) {
$extend['baInputExtend'] = $inputExtend;
}
if ($extend) {
$model->extend = json_encode($extend);
}
}
$model->allow_del = 1;
}
public static function onAfterWrite(): void
{
clear_config_cache();
}
public function getValueAttr($value, $row)
{
if (!isset($row['type']) || $value == '0') return $value;
if (in_array($row['type'], $this->jsonDecodeType)) {
return empty($value) ? [] : json_decode($value, true);
}
if ($row['type'] == 'switch') {
return (bool) $value;
}
if ($row['type'] == 'editor') {
return !$value ? '' : htmlspecialchars_decode($value);
}
if (in_array($row['type'], ['city', 'remoteSelects'])) {
if (!$value) return [];
if (!is_array($value)) return explode(',', $value);
return $value;
}
return $value ?: '';
}
public function setValueAttr(mixed $value, $row): mixed
{
if (in_array($row['type'], $this->jsonDecodeType)) {
return $value ? json_encode($value) : '';
}
if ($row['type'] == 'switch') {
return $value ? '1' : '0';
}
if ($row['type'] == 'time') {
return $value ? date('H:i:s', strtotime($value)) : '';
}
if ($row['type'] == 'city') {
if ($value && is_array($value)) {
return implode(',', $value);
}
return $value ?: '';
}
if (is_array($value)) {
return implode(',', $value);
}
return $value;
}
public function getContentAttr($value, $row)
{
if (!isset($row['type'])) return '';
if (in_array($row['type'], $this->needContent)) {
$arr = json_decode($value, true);
return $arr ?: [];
}
return '';
}
public function getExtendAttr($value)
{
if ($value) {
$arr = json_decode($value, true);
if ($arr) {
unset($arr['baInputExtend']);
return $arr;
}
}
return [];
}
public function getInputExtendAttr($value, $row)
{
if ($row && $row['extend']) {
$arr = json_decode($row['extend'], true);
if ($arr && isset($arr['baInputExtend'])) {
return $arr['baInputExtend'];
}
}
return [];
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace app\admin\model;
use support\think\Model;
class CrudLog extends Model
{
protected string $table = 'crud_log';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected bool $updateTime = false;
protected array $type = [
'table' => 'array',
'fields' => 'array',
];
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace app\admin\model;
use support\think\Model;
class DataRecycle extends Model
{
protected string $table = 'security_data_recycle';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace app\admin\model;
use support\think\Model;
use think\model\relation\BelongsTo;
class DataRecycleLog extends Model
{
protected string $table = 'security_data_recycle_log';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected bool $updateTime = false;
public function recycle(): BelongsTo
{
return $this->belongsTo(DataRecycle::class, 'recycle_id');
}
public function admin(): BelongsTo
{
return $this->belongsTo(Admin::class, 'admin_id');
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace app\admin\model;
use support\think\Model;
class SensitiveData extends Model
{
protected string $table = 'security_sensitive_data';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected array $type = [
'data_fields' => 'array',
];
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace app\admin\model;
use support\think\Model;
use think\model\relation\BelongsTo;
class SensitiveDataLog extends Model
{
protected string $table = 'security_sensitive_data_log';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected bool $updateTime = false;
public function sensitive(): BelongsTo
{
return $this->belongsTo(SensitiveData::class, 'sensitive_id');
}
public function admin(): BelongsTo
{
return $this->belongsTo(Admin::class, 'admin_id');
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace app\admin\model;
use support\think\Model;
use think\model\relation\BelongsTo;
class User extends Model
{
protected string $table = 'user';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
public function getAvatarAttr($value): string
{
return full_url($value ?? '', false, config('buildadmin.default_avatar'));
}
public function setAvatarAttr($value): string
{
return $value === full_url('', false, config('buildadmin.default_avatar')) ? '' : $value;
}
public function getMoneyAttr($value): string
{
return bcdiv((string) $value, '100', 2);
}
public function setMoneyAttr($value): string
{
return bcmul((string) $value, '100', 2);
}
public function userGroup(): BelongsTo
{
return $this->belongsTo(UserGroup::class, 'group_id');
}
public function resetPassword(int|string $uid, string $newPassword): int
{
return $this->where(['id' => $uid])->update(['password' => hash_password($newPassword), 'salt' => '']);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace app\admin\model;
use support\think\Model;
class UserGroup extends Model
{
protected string $table = 'user_group';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
}

View File

@@ -0,0 +1,69 @@
<?php
namespace app\admin\model;
use support\think\Model;
use think\model\relation\BelongsTo;
class UserMoneyLog extends Model
{
protected string $table = 'user_money_log';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected bool $updateTime = false;
public static function onBeforeInsert($model): void
{
$user = User::where('id', $model->user_id)->lock(true)->find();
if (!$user) {
throw new \Exception(__("The user can't find it"));
}
if (!$model->memo) {
throw new \Exception(__("Change note cannot be blank"));
}
$model->before = $user->money;
$user->money += $model->money;
$user->save();
$model->after = $user->money;
}
public static function onBeforeDelete(): bool
{
return false;
}
public function getMoneyAttr($value): string
{
return bcdiv((string) $value, '100', 2);
}
public function setMoneyAttr($value): string
{
return bcmul((string) $value, '100', 2);
}
public function getBeforeAttr($value): string
{
return bcdiv((string) $value, '100', 2);
}
public function setBeforeAttr($value): string
{
return bcmul((string) $value, '100', 2);
}
public function getAfterAttr($value): string
{
return bcdiv((string) $value, '100', 2);
}
public function setAfterAttr($value): string
{
return bcmul((string) $value, '100', 2);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace app\admin\model;
use support\think\Model;
class UserRule extends Model
{
protected string $table = 'user_rule';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected static function onAfterInsert($model): void
{
$pk = $model->getPk();
$model->where($pk, $model[$pk])->update(['weigh' => $model[$pk]]);
}
public function setComponentAttr($value)
{
if ($value) $value = str_replace('\\', '/', $value);
return $value;
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace app\admin\model;
use support\think\Model;
use think\model\relation\BelongsTo;
class UserScoreLog extends Model
{
protected string $table = 'user_score_log';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected bool $updateTime = false;
public static function onBeforeInsert($model): void
{
$user = User::where('id', $model->user_id)->lock(true)->find();
if (!$user) {
throw new \Exception(__("The user can't find it"));
}
if (!$model->memo) {
throw new \Exception(__("Change note cannot be blank"));
}
$model->before = $user->score;
$user->score += $model->score;
$user->save();
$model->after = $user->score;
}
public static function onBeforeDelete(): bool
{
return false;
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace app\admin\validate;
use think\Validate;
class Admin extends Validate
{
protected $failException = true;
protected $rule = [
'username' => 'require|regex:^[a-zA-Z][a-zA-Z0-9_]{2,15}$|unique:admin',
'nickname' => 'require',
'password' => 'require|regex:^(?!.*[&<>"\'\n\r]).{6,32}$',
'email' => 'email|unique:admin',
'mobile' => 'mobile|unique:admin',
'group_arr' => 'require|array',
];
protected $message = [];
protected $field = [];
protected $scene = [
'add' => ['username', 'nickname', 'password', 'email', 'mobile', 'group_arr'],
];
public function sceneInfo(): static
{
return $this->only(['nickname', 'password', 'email', 'mobile'])
->remove('password', 'require');
}
public function sceneEdit(): static
{
return $this->only(['username', 'nickname', 'password', 'email', 'mobile', 'group_arr'])
->remove('password', 'require');
}
public function __construct()
{
$this->field = [
'username' => __('Username'),
'nickname' => __('Nickname'),
'password' => __('Password'),
'email' => __('Email'),
'mobile' => __('Mobile'),
'group_arr' => __('Group Name Arr'),
];
$this->message = array_merge($this->message, [
'username.regex' => __('Please input correct username'),
'password.regex' => __('Please input correct password')
]);
parent::__construct();
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace app\admin\validate;
use think\Validate;
class AdminGroup extends Validate
{
protected $failException = true;
protected $rule = [
'name' => 'require',
'rules' => 'require',
];
protected $message = [];
protected $field = [];
protected $scene = [
'add' => ['name', 'rules'],
'edit' => ['name', 'rules'],
];
public function __construct()
{
$this->field = [
'name' => __('name'),
];
$this->message = [
'rules' => __('Please select rules'),
];
parent::__construct();
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace app\admin\validate;
use think\Validate;
class AdminRule extends Validate
{
protected $failException = true;
protected $rule = [
'type' => 'require',
'title' => 'require',
'name' => 'require|unique:admin_rule',
];
protected $message = [];
protected $field = [];
protected $scene = [
'add' => ['type', 'title', 'name'],
'edit' => ['type', 'title', 'name'],
];
public function __construct()
{
$this->field = [
'type' => __('type'),
'title' => __('title'),
'name' => __('name'),
];
parent::__construct();
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace app\admin\validate;
use think\Validate;
class Config extends Validate
{
protected $failException = true;
protected $rule = [
'name' => 'require|unique:config',
];
protected $message = [];
protected $field = [];
protected $scene = [
'add' => ['name'],
];
public function __construct()
{
$this->field = [
'name' => __('Variable name'),
];
parent::__construct();
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace app\admin\validate;
use think\Validate;
class DataRecycle extends Validate
{
protected $failException = true;
protected $rule = [
'name' => 'require',
'controller' => 'require|unique:security_data_recycle',
'data_table' => 'require',
'primary_key' => 'require',
];
protected $message = [];
protected $field = [];
protected $scene = [
'add' => ['name', 'controller', 'data_table', 'primary_key'],
'edit' => ['name', 'controller', 'data_table', 'primary_key'],
];
public function __construct()
{
$this->field = [
'name' => __('Name'),
'controller' => __('Controller'),
'data_table' => __('Data Table'),
'primary_key' => __('Primary Key'),
];
parent::__construct();
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace app\admin\validate;
use think\Validate;
class SensitiveData extends Validate
{
protected $failException = true;
protected $rule = [
'name' => 'require',
'controller' => 'require|unique:security_sensitive_data',
'data_table' => 'require',
'primary_key' => 'require',
'data_fields' => 'require',
];
protected $message = [];
protected $field = [];
protected $scene = [
'add' => ['name', 'data_fields', 'controller', 'data_table', 'primary_key'],
'edit' => ['name', 'data_fields', 'controller', 'data_table', 'primary_key'],
];
public function __construct()
{
$this->field = [
'name' => __('Name'),
'data_fields' => __('Data Fields'),
'controller' => __('Controller'),
'data_table' => __('Data Table'),
'primary_key' => __('Primary Key'),
];
parent::__construct();
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace app\admin\validate;
use think\Validate;
class UserMoneyLog extends Validate
{
protected $failException = true;
protected $rule = [
'user_id' => 'require',
'money' => 'require',
'memo' => 'require',
];
protected $message = [];
protected $field = [];
protected $scene = [
'add' => ['user_id', 'money', 'memo'],
'edit' => ['user_id', 'money', 'memo'],
];
public function __construct()
{
$this->field = [
'user_id' => __('user_id'),
'money' => __('money'),
'memo' => __('memo'),
];
parent::__construct();
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace app\admin\validate;
use think\Validate;
class UserScoreLog extends Validate
{
protected $failException = true;
protected $rule = [
'user_id' => 'require',
'score' => 'require',
'memo' => 'require',
];
protected $message = [];
protected $field = [];
protected $scene = [
'add' => ['user_id', 'score', 'memo'],
'edit' => ['user_id', 'score', 'memo'],
];
public function __construct()
{
$this->field = [
'user_id' => __('user_id'),
'score' => __('score'),
'memo' => __('memo'),
];
parent::__construct();
}
}

View File

@@ -0,0 +1,257 @@
<?php
namespace app\api\controller;
use ba\Date;
use ba\Captcha;
use ba\Random;
use app\common\model\User;
use app\common\facade\Token;
use app\common\model\UserScoreLog;
use app\common\model\UserMoneyLog;
use app\common\controller\Frontend;
use support\validation\Validator;
use support\validation\ValidationException;
use Webman\Http\Request;
use support\Response;
class Account extends Frontend
{
protected array $noNeedLogin = ['retrievePassword'];
protected array $noNeedPermission = ['verification', 'changeBind'];
public function overview(Request $request): Response
{
$response = $this->initializeFrontend($request);
if ($response !== null) return $response;
$sevenDays = Date::unixTime('day', -6);
$score = $money = $days = [];
for ($i = 0; $i < 7; $i++) {
$days[$i] = date("Y-m-d", $sevenDays + ($i * 86400));
$tempToday0 = strtotime($days[$i]);
$tempToday24 = strtotime('+1 day', $tempToday0) - 1;
$score[$i] = UserScoreLog::where('user_id', $this->auth->id)
->where('create_time', 'BETWEEN', $tempToday0 . ',' . $tempToday24)
->sum('score');
$userMoneyTemp = UserMoneyLog::where('user_id', $this->auth->id)
->where('create_time', 'BETWEEN', $tempToday0 . ',' . $tempToday24)
->sum('money');
$money[$i] = bcdiv((string) $userMoneyTemp, '100', 2);
}
return $this->success('', [
'days' => $days,
'score' => $score,
'money' => $money,
]);
}
public function profile(Request $request): Response
{
$response = $this->initializeFrontend($request);
if ($response !== null) return $response;
if ($request->method() === 'POST') {
$model = $this->auth->getUser();
$data = $request->only(['avatar', 'username', 'nickname', 'gender', 'birthday', 'motto']);
$data['id'] = $this->auth->id;
if (!isset($data['birthday'])) {
$data['birthday'] = null;
}
try {
Validator::make($data, [
'username' => 'required|string|regex:/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/|unique:user,username,' . $this->auth->id,
'nickname' => 'required|string|regex:/^[\x{4e00}-\x{9fa5}a-zA-Z0-9_-]+$/u',
'birthday' => 'nullable|date',
], [
'nickname.regex' => __('nicknameChsDash'),
])->validate();
} catch (ValidationException $e) {
return $this->error($e->getMessage());
}
$model->startTrans();
try {
$model->save($data);
$model->commit();
} catch (\Throwable $e) {
$model->rollback();
return $this->error($e->getMessage());
}
return $this->success(__('Data updated successfully~'));
}
return $this->success('', [
'accountVerificationType' => get_account_verification_type()
]);
}
public function verification(Request $request): Response
{
$response = $this->initializeFrontend($request);
if ($response !== null) return $response;
$captcha = new Captcha();
$params = $request->only(['type', 'captcha']);
$key = ($params['type'] == 'email' ? $this->auth->email : $this->auth->mobile) . "user_{$params['type']}_verify";
if ($captcha->check($params['captcha'], $key)) {
$uuid = Random::uuid();
Token::set($uuid, $params['type'] . '-pass', $this->auth->id, 600);
return $this->success('', [
'type' => $params['type'],
'accountVerificationToken' => $uuid,
]);
}
return $this->error(__('Please enter the correct verification code'));
}
public function changeBind(Request $request): Response
{
$response = $this->initializeFrontend($request);
if ($response !== null) return $response;
$captcha = new Captcha();
$params = $request->only(['type', 'captcha', 'email', 'mobile', 'accountVerificationToken', 'password']);
$user = $this->auth->getUser();
if ($user[$params['type']]) {
if (!Token::check($params['accountVerificationToken'], $params['type'] . '-pass', $user->id)) {
return $this->error(__('You need to verify your account before modifying the binding information'));
}
} elseif (!isset($params['password']) || !verify_password($params['password'], $user->password, ['salt' => $user->salt])) {
return $this->error(__('Password error'));
}
if ($captcha->check($params['captcha'], $params[$params['type']] . "user_change_{$params['type']}")) {
$rules = $params['type'] == 'email'
? ['email' => 'required|email|unique:user,email']
: ['mobile' => 'required|regex:/^1[3-9]\d{9}$/|unique:user,mobile'];
try {
Validator::make($params, $rules)->validate();
} catch (ValidationException $e) {
return $this->error(__($e->getMessage()));
}
if ($params['type'] == 'email') {
$user->email = $params['email'];
} else {
$user->mobile = $params['mobile'];
}
Token::delete($params['accountVerificationToken']);
$user->save();
return $this->success();
}
return $this->error(__('Please enter the correct verification code'));
}
public function changePassword(Request $request): Response
{
$response = $this->initializeFrontend($request);
if ($response !== null) return $response;
if ($request->method() === 'POST') {
$model = $this->auth->getUser();
$params = $request->only(['oldPassword', 'newPassword']);
if (!verify_password($params['oldPassword'], $model->password, ['salt' => $model->salt])) {
return $this->error(__('Old password error'));
}
try {
Validator::make(
['password' => $params['newPassword']],
['password' => 'required|string|regex:/^(?!.*[&<>"\'\n\r]).{6,32}$/'],
['password.regex' => __('Please input correct password')]
)->validate();
} catch (ValidationException $e) {
return $this->error($e->getMessage());
}
$model->startTrans();
try {
$model->resetPassword($this->auth->id, $params['newPassword']);
$model->commit();
} catch (\Throwable $e) {
$model->rollback();
return $this->error($e->getMessage());
}
$this->auth->logout();
return $this->success(__('Password has been changed, please login again~'));
}
return $this->error(__('Method not allowed'), [], 0, ['statusCode' => 405]);
}
public function integral(Request $request): Response
{
$response = $this->initializeFrontend($request);
if ($response !== null) return $response;
$limit = $request->get('limit', $request->post('limit', 15));
$res = UserScoreLog::where('user_id', $this->auth->id)
->order('create_time', 'desc')
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
]);
}
public function balance(Request $request): Response
{
$response = $this->initializeFrontend($request);
if ($response !== null) return $response;
$limit = $request->get('limit', $request->post('limit', 15));
$res = UserMoneyLog::where('user_id', $this->auth->id)
->order('create_time', 'desc')
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
]);
}
public function retrievePassword(Request $request): Response
{
$response = $this->initializeFrontend($request);
if ($response !== null) return $response;
$params = $request->only(['type', 'account', 'captcha', 'password']);
try {
Validator::make($params, [
'type' => 'required|in:email,mobile',
'account' => 'required|string',
'captcha' => 'required|string',
'password' => 'required|string|regex:/^(?!.*[&<>"\'\n\r]).{6,32}$/',
], [
'password.regex' => __('Please input correct password'),
])->validate();
} catch (ValidationException $e) {
return $this->error($e->getMessage());
}
if ($params['type'] == 'email') {
$user = User::where('email', $params['account'])->find();
} else {
$user = User::where('mobile', $params['account'])->find();
}
if (!$user) {
return $this->error(__('Account does not exist~'));
}
$captchaObj = new Captcha();
if (!$captchaObj->check($params['captcha'], $params['account'] . 'user_retrieve_pwd')) {
return $this->error(__('Please enter the correct verification code'));
}
if ($user->resetPassword($user->id, $params['password'])) {
return $this->success(__('Password has been changed~'));
}
return $this->error(__('Failed to modify password, please try again later~'));
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace app\api\controller;
use app\common\controller\Frontend;
use Webman\Http\Request;
use support\Response;
class Ajax extends Frontend
{
protected array $noNeedLogin = ['area', 'buildSuffixSvg'];
protected array $noNeedPermission = ['upload'];
public function upload(Request $request): Response
{
$response = $this->initializeFrontend($request);
if ($response !== null) return $response;
$file = $request->file('file');
if (!$file) {
return $this->error(__('No files were uploaded'));
}
$file = new \app\common\library\upload\WebmanUploadedFile($file);
$driver = $request->get('driver', $request->post('driver', 'local'));
$topic = $request->get('topic', $request->post('topic', 'default'));
try {
$upload = new \app\common\library\Upload();
$attachment = $upload
->setFile($file)
->setDriver($driver)
->setTopic($topic)
->upload(null, 0, $this->auth->id);
unset($attachment['create_time'], $attachment['quote']);
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
return $this->success(__('File uploaded successfully'), [
'file' => $attachment ?? []
]);
}
public function area(Request $request): Response
{
$response = $this->initializeFrontend($request);
if ($response !== null) return $response;
return $this->success('', get_area($request));
}
public function buildSuffixSvg(Request $request): Response
{
$response = $this->initializeFrontend($request);
if ($response !== null) return $response;
$suffix = $request->get('suffix', $request->post('suffix', 'file'));
$background = $request->get('background', $request->post('background'));
$content = build_suffix_svg((string) $suffix, (string) $background);
return response($content, 200, [
'Content-Length' => strlen($content),
'Content-Type' => 'image/svg+xml'
]);
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace app\api\controller;
use ba\Random;
use ba\Captcha;
use ba\ClickCaptcha;
use app\common\controller\Api;
use app\common\facade\Token;
use app\admin\library\Auth as AdminAuth;
use app\common\library\Auth as UserAuth;
use Webman\Http\Request;
use support\Response;
class Common extends Api
{
public function captcha(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) return $response;
$captchaId = $request->get('id', $request->post('id', ''));
$config = [
'codeSet' => '123456789',
'fontSize' => 22,
'useCurve' => false,
'useNoise' => true,
'length' => 4,
'bg' => [255, 255, 255],
];
$captcha = new Captcha($config);
return $captcha->entry($captchaId);
}
public function clickCaptcha(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) return $response;
$id = $request->get('id', $request->post('id', ''));
$captcha = new ClickCaptcha();
return $this->success('', $captcha->creat($id));
}
public function checkClickCaptcha(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) return $response;
$id = $request->post('id', '');
$info = $request->post('info', '');
$unset = filter_var($request->post('unset', false), FILTER_VALIDATE_BOOLEAN);
$captcha = new ClickCaptcha();
if ($captcha->check($id, $info, $unset)) return $this->success();
return $this->error();
}
public function refreshToken(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) return $response;
$refreshToken = $request->post('refreshToken', '');
$refreshToken = Token::get($refreshToken);
if (!$refreshToken || $refreshToken['expire_time'] < time()) {
return $this->error(__('Login expired, please login again.'));
}
$newToken = Random::uuid();
if ($refreshToken['type'] == AdminAuth::TOKEN_TYPE . '-refresh') {
Token::set($newToken, AdminAuth::TOKEN_TYPE, $refreshToken['user_id'], (int)config('buildadmin.admin_token_keep_time', 259200));
}
if ($refreshToken['type'] == UserAuth::TOKEN_TYPE . '-refresh') {
Token::set($newToken, UserAuth::TOKEN_TYPE, $refreshToken['user_id'], (int)config('buildadmin.user_token_keep_time', 259200));
}
return $this->success('', [
'type' => $refreshToken['type'],
'token' => $newToken
]);
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace app\api\controller;
use ba\Captcha;
use ba\ClickCaptcha;
use app\common\model\User;
use app\common\library\Email;
use app\common\controller\Frontend;
use support\validation\Validator;
use support\validation\ValidationException;
use Webman\Http\Request;
use support\Response;
use PHPMailer\PHPMailer\Exception as PHPMailerException;
class Ems extends Frontend
{
protected array $noNeedLogin = ['send'];
public function send(Request $request): Response
{
$response = $this->initializeFrontend($request);
if ($response !== null) return $response;
$params = $request->post(['email', 'event', 'captchaId', 'captchaInfo']);
$mail = new Email();
if (!$mail->configured) {
return $this->error(__('Mail sending service unavailable'));
}
try {
Validator::make($params, [
'email' => 'required|email',
'event' => 'required|string',
'captchaId' => 'required|string',
'captchaInfo' => 'required|string',
], [
'email.required' => 'email format error',
'event.required' => 'Parameter error',
'captchaId.required' => 'Captcha error',
'captchaInfo.required' => 'Captcha error',
])->validate();
} catch (ValidationException $e) {
return $this->error(__($e->getMessage()));
}
$captchaObj = new Captcha();
$clickCaptcha = new ClickCaptcha();
if (!$clickCaptcha->check($params['captchaId'], $params['captchaInfo'])) {
return $this->error(__('Captcha error'));
}
$captcha = $captchaObj->getCaptchaData($params['email'] . $params['event']);
if ($captcha && time() - $captcha['create_time'] < 60) {
return $this->error(__('Frequent email sending'));
}
$userInfo = User::where('email', $params['email'])->find();
if ($params['event'] == 'user_register' && $userInfo) {
return $this->error(__('Email has been registered, please log in directly'));
}
if ($params['event'] == 'user_change_email' && $userInfo) {
return $this->error(__('The email has been occupied'));
}
if (in_array($params['event'], ['user_retrieve_pwd', 'user_email_verify']) && !$userInfo) {
return $this->error(__('Email not registered'));
}
if ($params['event'] == 'user_email_verify') {
if (!$this->auth->isLogin()) {
return $this->error(__('Please login first'));
}
if ($this->auth->email != $params['email']) {
return $this->error(__('Please use the account registration email to send the verification code'));
}
$password = $request->post('password');
if (!verify_password($password, $this->auth->password, ['salt' => $this->auth->salt])) {
return $this->error(__('Password error'));
}
}
$code = $captchaObj->create($params['email'] . $params['event']);
$subject = __($params['event']) . '-' . get_sys_config('site_name');
$body = __('Your verification code is: %s', [$code]);
try {
$mail->isSMTP();
$mail->addAddress($params['email']);
$mail->isHTML();
$mail->setSubject($subject);
$mail->Body = $body;
$mail->send();
} catch (PHPMailerException) {
return $this->error($mail->ErrorInfo);
}
return $this->success(__('Mail sent successfully~'));
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace app\api\controller;
use ba\Tree;
use support\think\Db;
use app\common\controller\Frontend;
use app\common\library\token\TokenExpirationException;
use Webman\Http\Request;
class Index extends Frontend
{
protected array $noNeedLogin = ['index'];
public function index(Request $request)
{
$response = $this->initializeFrontend($request);
if ($response !== null) return $response;
$menus = [];
if ($this->auth->isLogin()) {
$rules = [];
$userMenus = $this->auth->getMenus();
foreach ($userMenus as $item) {
if ($item['type'] == 'menu_dir') {
$menus[] = $item;
} elseif ($item['type'] != 'menu') {
$rules[] = $item;
}
}
$rules = array_values($rules);
} else {
$requiredLogin = filter_var($request->get('requiredLogin', false), FILTER_VALIDATE_BOOLEAN);
if ($requiredLogin) {
try {
$token = get_auth_token(['ba', 'user', 'token'], $request);
$this->auth->init($token);
} catch (TokenExpirationException) {
return $this->error(__('Token expiration'), [], 409);
}
return $this->error(__('Please login first'), [
'type' => $this->auth::NEED_LOGIN
], $this->auth::LOGIN_RESPONSE_CODE);
}
$rules = Db::name('user_rule')
->where('status', 1)
->where('no_login_valid', 1)
->where('type', 'in', ['route', 'nav', 'button'])
->order('weigh', 'desc')
->select()
->toArray();
$rules = Tree::instance()->assembleChild($rules);
}
return $this->success('', [
'site' => [
'siteName' => get_sys_config('site_name'),
'version' => get_sys_config('version'),
'cdnUrl' => full_url(),
'upload' => keys_to_camel_case(get_upload_config($request), ['max_size', 'save_name', 'allowed_suffixes', 'allowed_mime_types']),
'recordNumber' => get_sys_config('record_number'),
'cdnUrlParams' => config('buildadmin.cdn_url_params'),
],
'openMemberCenter' => config('buildadmin.open_member_center'),
'userInfo' => $this->auth->getUserInfo(),
'rules' => $rules,
'menus' => $menus,
]);
}
}

View File

@@ -0,0 +1,658 @@
<?php
declare(strict_types=1);
namespace app\api\controller;
use Throwable;
use ba\Random;
use ba\Version;
use ba\Terminal;
use ba\Filesystem;
use app\common\controller\Api;
use app\admin\model\Admin as AdminModel;
use app\admin\model\User as UserModel;
use support\Response;
use Webman\Http\Request;
/**
* 安装控制器
*/
class Install extends Api
{
public const X64 = 'x64';
public const X86 = 'x86';
protected bool $useSystemSettings = false;
/**
* 环境检查状态
*/
static string $ok = 'ok';
static string $fail = 'fail';
static string $warn = 'warn';
/**
* 安装锁文件名称
*/
static string $lockFileName = 'install.lock';
/**
* 配置文件
*/
static string $dbConfigFileName = 'thinkorm.php';
static string $buildConfigFileName = 'buildadmin.php';
/**
* 自动构建的前端文件的 outDir 相对于根目录
*/
static string $distDir = 'web' . DIRECTORY_SEPARATOR . 'dist';
/**
* 需要的依赖版本
*/
static array $needDependentVersion = [
'php' => '8.2.0',
'npm' => '9.8.1',
'cnpm' => '7.1.0',
'node' => '20.14.0',
'yarn' => '1.2.0',
'pnpm' => '6.32.13',
];
/**
* 安装完成标记
*/
static string $InstallationCompletionMark = 'install-end';
/**
* 命令执行窗口exec 为 SSE 长连接,不会返回)
* @throws Throwable
*/
public function terminal(Request $request): Response
{
$this->setRequest($request);
if ($this->isInstallComplete()) {
return $this->error(__('The system has completed installation. If you need to reinstall, please delete the %s file first', ['public/' . self::$lockFileName]));
}
(new Terminal())->exec(false);
return $this->success(); // unreachable: exec() blocks with SSE stream
}
public function changePackageManager(Request $request): Response
{
$this->setRequest($request);
if ($this->isInstallComplete()) {
return $this->error(__('The system has completed installation. If you need to reinstall, please delete the %s file first', ['public/' . self::$lockFileName]));
}
$newPackageManager = $request->post('manager', config('terminal.npm_package_manager'));
if (Terminal::changeTerminalConfig()) {
return $this->success('', [
'manager' => $newPackageManager
]);
}
return $this->error(__('Failed to switch package manager. Please modify the configuration file manually:%s', ['config/terminal.php']));
}
/**
* 环境基础检查
*/
public function envBaseCheck(Request $request): Response
{
$this->setRequest($request);
if ($this->isInstallComplete()) {
return $this->error(__('The system has completed installation. If you need to reinstall, please delete the %s file first', ['public/' . self::$lockFileName]), []);
}
if (($_ENV['DATABASE_TYPE'] ?? getenv('DATABASE_TYPE'))) {
return $this->error(__('The .env file with database configuration was detected. Please clean up and try again!'));
}
// php版本-start
$phpVersion = phpversion();
$phpBit = PHP_INT_SIZE == 8 ? self::X64 : self::X86;
$phpVersionCompare = Version::compare(self::$needDependentVersion['php'], $phpVersion);
if (!$phpVersionCompare) {
$phpVersionLink = [
[
'name' => __('need') . ' >= ' . self::$needDependentVersion['php'],
'type' => 'text'
],
[
'name' => __('How to solve?'),
'title' => __('Click to see how to solve it'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/preparePHP.html'
]
];
} elseif ($phpBit != self::X64) {
$phpVersionLink = [
[
'name' => __('need') . ' x64 PHP',
'type' => 'text'
],
[
'name' => __('How to solve?'),
'title' => __('Click to see how to solve it'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/preparePHP.html'
]
];
}
// php版本-end
// 配置文件-start
$dbConfigFile = config_path() . self::$dbConfigFileName;
$configIsWritable = Filesystem::pathIsWritable(config_path()) && Filesystem::pathIsWritable($dbConfigFile);
if (!$configIsWritable) {
$configIsWritableLink = [
[
'name' => __('View reason'),
'title' => __('Click to view the reason'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/dirNoPermission.html'
]
];
}
// 配置文件-end
// public-start
$publicIsWritable = Filesystem::pathIsWritable(public_path());
if (!$publicIsWritable) {
$publicIsWritableLink = [
[
'name' => __('View reason'),
'title' => __('Click to view the reason'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/dirNoPermission.html'
]
];
}
// public-end
// PDO-start
$phpPdo = extension_loaded("PDO") && extension_loaded('pdo_mysql');
if (!$phpPdo) {
$phpPdoLink = [
[
'name' => __('PDO extensions need to be installed'),
'type' => 'text'
],
[
'name' => __('How to solve?'),
'title' => __('Click to see how to solve it'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/missingExtension.html'
]
];
}
// PDO-end
// GD2和freeType-start
$phpGd2 = extension_loaded('gd') && function_exists('imagettftext');
if (!$phpGd2) {
$phpGd2Link = [
[
'name' => __('The gd extension and freeType library need to be installed'),
'type' => 'text'
],
[
'name' => __('How to solve?'),
'title' => __('Click to see how to solve it'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/gdFail.html'
]
];
}
// GD2和freeType-end
// proc_open
$phpProc = function_exists('proc_open') && function_exists('proc_close') && function_exists('proc_get_status');
if (!$phpProc) {
$phpProcLink = [
[
'name' => __('View reason'),
'title' => __('proc_open or proc_close functions in PHP Ini is disabled'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/disablement.html'
],
[
'name' => __('How to modify'),
'title' => __('Click to view how to modify'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/disablement.html'
],
[
'name' => __('Security assurance?'),
'title' => __('Using the installation service correctly will not cause any potential security problems. Click to view the details'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/senior.html'
],
];
}
// proc_open-end
return $this->success('', [
'php_version' => [
'describe' => $phpVersion . " ($phpBit)",
'state' => $phpVersionCompare && $phpBit == self::X64 ? self::$ok : self::$fail,
'link' => $phpVersionLink ?? [],
],
'config_is_writable' => [
'describe' => self::writableStateDescribe($configIsWritable),
'state' => $configIsWritable ? self::$ok : self::$fail,
'link' => $configIsWritableLink ?? []
],
'public_is_writable' => [
'describe' => self::writableStateDescribe($publicIsWritable),
'state' => $publicIsWritable ? self::$ok : self::$fail,
'link' => $publicIsWritableLink ?? []
],
'php_pdo' => [
'describe' => $phpPdo ? __('already installed') : __('Not installed'),
'state' => $phpPdo ? self::$ok : self::$fail,
'link' => $phpPdoLink ?? []
],
'php_gd2' => [
'describe' => $phpGd2 ? __('already installed') : __('Not installed'),
'state' => $phpGd2 ? self::$ok : self::$fail,
'link' => $phpGd2Link ?? []
],
'php_proc' => [
'describe' => $phpProc ? __('Allow execution') : __('disabled'),
'state' => $phpProc ? self::$ok : self::$warn,
'link' => $phpProcLink ?? []
],
]);
}
/**
* npm环境检查
*/
public function envNpmCheck(Request $request): Response
{
$this->setRequest($request);
if ($this->isInstallComplete()) {
return $this->error('', [], 2);
}
$packageManager = $request->post('manager', 'none');
// npm
$npmVersion = Version::getVersion('npm');
$npmVersionCompare = Version::compare(self::$needDependentVersion['npm'], $npmVersion);
if (!$npmVersionCompare || !$npmVersion) {
$npmVersionLink = [
[
'name' => __('need') . ' >= ' . self::$needDependentVersion['npm'],
'type' => 'text'
],
[
'name' => __('How to solve?'),
'title' => __('Click to see how to solve it'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/prepareNpm.html'
]
];
}
// 包管理器
$pmVersionLink = [];
$pmVersion = __('nothing');
$pmVersionCompare = false;
if (in_array($packageManager, ['npm', 'cnpm', 'pnpm', 'yarn'])) {
$pmVersion = Version::getVersion($packageManager);
$pmVersionCompare = Version::compare(self::$needDependentVersion[$packageManager], $pmVersion);
if (!$pmVersion) {
$pmVersionLink[] = [
'name' => __('need') . ' >= ' . self::$needDependentVersion[$packageManager],
'type' => 'text'
];
if ($npmVersionCompare) {
$pmVersionLink[] = [
'name' => __('Click Install %s', [$packageManager]),
'title' => '',
'type' => 'install-package-manager'
];
} else {
$pmVersionLink[] = [
'name' => __('Please install NPM first'),
'type' => 'text'
];
}
} elseif (!$pmVersionCompare) {
$pmVersionLink[] = [
'name' => __('need') . ' >= ' . self::$needDependentVersion[$packageManager],
'type' => 'text'
];
$pmVersionLink[] = [
'name' => __('Please upgrade %s version', [$packageManager]),
'type' => 'text'
];
}
} elseif ($packageManager == 'ni') {
$pmVersionCompare = true;
}
// nodejs
$nodejsVersion = Version::getVersion('node');
$nodejsVersionCompare = Version::compare(self::$needDependentVersion['node'], $nodejsVersion);
if (!$nodejsVersionCompare || !$nodejsVersion) {
$nodejsVersionLink = [
[
'name' => __('need') . ' >= ' . self::$needDependentVersion['node'],
'type' => 'text'
],
[
'name' => __('How to solve?'),
'title' => __('Click to see how to solve it'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/prepareNodeJs.html'
]
];
}
return $this->success('', [
'npm_version' => [
'describe' => $npmVersion ?: __('Acquisition failed'),
'state' => $npmVersionCompare ? self::$ok : self::$warn,
'link' => $npmVersionLink ?? [],
],
'nodejs_version' => [
'describe' => $nodejsVersion ?: __('Acquisition failed'),
'state' => $nodejsVersionCompare ? self::$ok : self::$warn,
'link' => $nodejsVersionLink ?? []
],
'npm_package_manager' => [
'describe' => $pmVersion ?: __('Acquisition failed'),
'state' => $pmVersionCompare ? self::$ok : self::$warn,
'link' => $pmVersionLink ?? [],
]
]);
}
/**
* 测试数据库连接
*/
public function testDatabase(Request $request): Response
{
$this->setRequest($request);
$database = [
'hostname' => $request->post('hostname'),
'username' => $request->post('username'),
'password' => $request->post('password'),
'hostport' => $request->post('hostport'),
'database' => '',
];
$conn = $this->connectDb($database);
if ($conn['code'] == 0) {
return $this->error($conn['msg']);
}
return $this->success('', [
'databases' => $conn['databases']
]);
}
/**
* 系统基础配置
* post请求=开始安装
*/
public function baseConfig(Request $request): Response
{
$this->setRequest($request);
if ($this->isInstallComplete()) {
return $this->error(__('The system has completed installation. If you need to reinstall, please delete the %s file first', ['public/' . self::$lockFileName]));
}
$envOk = $this->commandExecutionCheck();
$rootPath = str_replace('\\', '/', root_path());
if ($request->isGet()) {
return $this->success('', [
'rootPath' => $rootPath,
'executionWebCommand' => $envOk
]);
}
$connectData = $databaseParam = $request->only(['hostname', 'username', 'password', 'hostport', 'database', 'prefix']);
// 数据库配置测试
$connectData['database'] = '';
$connect = $this->connectDb($connectData, true);
if ($connect['code'] == 0) {
return $this->error($connect['msg']);
}
// 建立数据库
if (!in_array($databaseParam['database'], $connect['databases'])) {
$sql = "CREATE DATABASE IF NOT EXISTS `{$databaseParam['database']}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci";
$connect['pdo']->exec($sql);
}
// 写入数据库配置文件thinkorm.php 使用 $env('database.xxx', 'default') 格式)
$dbConfigFile = config_path() . self::$dbConfigFileName;
$dbConfigContent = @file_get_contents($dbConfigFile);
$callback = function ($matches) use ($databaseParam) {
$key = $matches[1];
$value = (string) ($databaseParam[$key] ?? '');
return "\$env('database.{$key}', '" . addslashes($value) . "')";
};
$dbConfigText = preg_replace_callback("/\\\$env\('database\.(hostname|database|username|password|hostport|prefix)',\s*'[^']*'\)/", $callback, $dbConfigContent);
$result = @file_put_contents($dbConfigFile, $dbConfigText);
if (!$result) {
return $this->error(__('File has no write permission:%s', ['config/' . self::$dbConfigFileName]));
}
// 写入.env-example文件
$envFile = root_path() . '.env-example';
$envFileContent = @file_get_contents($envFile);
if ($envFileContent) {
$databasePos = stripos($envFileContent, '[DATABASE]');
if ($databasePos !== false) {
$envFileContent = substr($envFileContent, 0, $databasePos);
}
$envFileContent .= "\n" . '[DATABASE]' . "\n";
$envFileContent .= 'TYPE = mysql' . "\n";
$envFileContent .= 'HOSTNAME = ' . $databaseParam['hostname'] . "\n";
$envFileContent .= 'DATABASE = ' . $databaseParam['database'] . "\n";
$envFileContent .= 'USERNAME = ' . $databaseParam['username'] . "\n";
$envFileContent .= 'PASSWORD = ' . $databaseParam['password'] . "\n";
$envFileContent .= 'HOSTPORT = ' . $databaseParam['hostport'] . "\n";
$envFileContent .= 'PREFIX = ' . ($databaseParam['prefix'] ?? '') . "\n";
$envFileContent .= 'CHARSET = utf8mb4' . "\n";
$envFileContent .= 'DEBUG = true' . "\n";
$result = @file_put_contents($envFile, $envFileContent);
if (!$result) {
return $this->error(__('File has no write permission:%s', ['/' . $envFile]));
}
}
// 设置新的Token随机密钥key
$oldTokenKey = config('buildadmin.token.key');
$newTokenKey = Random::build('alnum', 32);
$buildConfigFile = config_path() . self::$buildConfigFileName;
$buildConfigContent = @file_get_contents($buildConfigFile);
$buildConfigContent = preg_replace("/'key'(\s+)=>(\s+)'$oldTokenKey'/", "'key'\$1=>\$2'$newTokenKey'", $buildConfigContent);
$result = @file_put_contents($buildConfigFile, $buildConfigContent);
if (!$result) {
return $this->error(__('File has no write permission:%s', ['config/' . self::$buildConfigFileName]));
}
// 建立安装锁文件
$result = @file_put_contents(public_path() . self::$lockFileName, date('Y-m-d H:i:s'));
if (!$result) {
return $this->error(__('File has no write permission:%s', ['public/' . self::$lockFileName]));
}
return $this->success('', [
'rootPath' => $rootPath,
'executionWebCommand' => $envOk
]);
}
protected function isInstallComplete(): bool
{
if (is_file(public_path() . self::$lockFileName)) {
$contents = @file_get_contents(public_path() . self::$lockFileName);
if ($contents == self::$InstallationCompletionMark) {
return true;
}
}
return false;
}
/**
* 标记命令执行完毕
* @throws Throwable
*/
public function commandExecComplete(Request $request): Response
{
$this->setRequest($request);
if ($this->isInstallComplete()) {
return $this->error(__('The system has completed installation. If you need to reinstall, please delete the %s file first', ['public/' . self::$lockFileName]));
}
$param = $request->only(['type', 'adminname', 'adminpassword', 'sitename']);
if ($param['type'] == 'web') {
$result = @file_put_contents(public_path() . self::$lockFileName, self::$InstallationCompletionMark);
if (!$result) {
return $this->error(__('File has no write permission:%s', ['public/' . self::$lockFileName]));
}
} else {
// 管理员配置入库
$adminModel = new AdminModel();
$defaultAdmin = $adminModel->where('username', 'admin')->find();
$defaultAdmin->username = $param['adminname'];
$defaultAdmin->nickname = ucfirst($param['adminname']);
$defaultAdmin->save();
if (isset($param['adminpassword']) && $param['adminpassword']) {
$adminModel->resetPassword($defaultAdmin->id, $param['adminpassword']);
}
// 默认用户密码修改
$user = new UserModel();
$user->resetPassword(1, Random::build());
// 修改站点名称
if (class_exists(\app\admin\model\Config::class)) {
\app\admin\model\Config::where('name', 'site_name')->update([
'value' => $param['sitename']
]);
}
}
return $this->success();
}
/**
* 获取命令执行检查的结果
* @return bool 是否拥有执行命令的条件
*/
private function commandExecutionCheck(): bool
{
$pm = config('terminal.npm_package_manager');
if ($pm == 'none') {
return false;
}
$check['phpPopen'] = function_exists('proc_open') && function_exists('proc_close');
$check['npmVersionCompare'] = Version::compare(self::$needDependentVersion['npm'], Version::getVersion('npm'));
$check['pmVersionCompare'] = Version::compare(self::$needDependentVersion[$pm], Version::getVersion($pm));
$check['nodejsVersionCompare'] = Version::compare(self::$needDependentVersion['node'], Version::getVersion('node'));
$envOk = true;
foreach ($check as $value) {
if (!$value) {
$envOk = false;
break;
}
}
return $envOk;
}
/**
* 安装指引
*/
public function manualInstall(Request $request): Response
{
$this->setRequest($request);
return $this->success('', [
'webPath' => str_replace('\\', '/', root_path() . 'web')
]);
}
public function mvDist(Request $request): Response
{
$this->setRequest($request);
if (!is_file(root_path() . self::$distDir . DIRECTORY_SEPARATOR . 'index.html')) {
return $this->error(__('No built front-end file found, please rebuild manually!'));
}
if (Terminal::mvDist()) {
return $this->success();
}
return $this->error(__('Failed to move the front-end file, please move it manually!'));
}
/**
* 目录是否可写
* @param $writable
* @return string
*/
private static function writableStateDescribe($writable): string
{
return $writable ? __('Writable') : __('No write permission');
}
/**
* 数据库连接-获取数据表列表(使用 raw PDO
* @param array $database hostname, hostport, username, password, database
* @param bool $returnPdo
* @return array
*/
private function connectDb(array $database, bool $returnPdo = false): array
{
$host = $database['hostname'] ?? '127.0.0.1';
$port = $database['hostport'] ?? '3306';
$user = $database['username'] ?? '';
$pass = $database['password'] ?? '';
$db = $database['database'] ?? '';
$dsn = "mysql:host={$host};port={$port};charset=utf8mb4";
if ($db) {
$dsn .= ";dbname={$db}";
}
try {
$pdo = new \PDO($dsn, $user, $pass, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
]);
$pdo->exec("SELECT 1");
} catch (\PDOException $e) {
$errorMsg = $e->getMessage();
return [
'code' => 0,
'msg' => __('Database connection failed:%s', [mb_convert_encoding($errorMsg ?: 'unknown', 'UTF-8', 'UTF-8,GBK,GB2312,BIG5')])
];
}
$databases = [];
$databasesExclude = ['information_schema', 'mysql', 'performance_schema', 'sys'];
$stmt = $pdo->query("SHOW DATABASES");
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
$dbName = $row['Database'] ?? $row['database'] ?? '';
if ($dbName && !in_array($dbName, $databasesExclude)) {
$databases[] = $dbName;
}
}
return [
'code' => 1,
'msg' => '',
'databases' => $databases,
'pdo' => $returnPdo ? $pdo : null,
];
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace app\api\controller;
use ba\Captcha;
use ba\ClickCaptcha;
use app\common\controller\Frontend;
use app\common\facade\Token;
use support\validation\Validator;
use support\validation\ValidationException;
use Webman\Http\Request;
use support\Response;
class User extends Frontend
{
protected array $noNeedLogin = ['checkIn', 'logout'];
public function checkIn(Request $request): Response
{
$response = $this->initializeFrontend($request);
if ($response !== null) return $response;
$openMemberCenter = config('buildadmin.open_member_center');
if (!$openMemberCenter) {
return $this->error(__('Member center disabled'));
}
if ($this->auth->isLogin()) {
return $this->success(__('You have already logged in. There is no need to log in again~'), [
'type' => $this->auth::LOGGED_IN
], $this->auth::LOGIN_RESPONSE_CODE);
}
$userLoginCaptchaSwitch = config('buildadmin.user_login_captcha');
if ($request->method() === 'POST') {
$params = $request->post();
$params = array_merge($params, [
'tab' => $params['tab'] ?? '',
'email' => $params['email'] ?? '',
'mobile' => $params['mobile'] ?? '',
'username' => $params['username'] ?? '',
'password' => $params['password'] ?? '',
'keep' => $params['keep'] ?? false,
'captcha' => $params['captcha'] ?? '',
'captchaId' => $params['captchaId'] ?? '',
'captchaInfo' => $params['captchaInfo'] ?? '',
'registerType' => $params['registerType'] ?? '',
]);
if (!in_array($params['tab'], ['login', 'register'])) {
return $this->error(__('Unknown operation'));
}
try {
$rules = $params['tab'] === 'login' ? $this->getLoginRules($userLoginCaptchaSwitch) : $this->getRegisterRules();
Validator::make($params, $rules[0], $rules[1] ?? [], $rules[2] ?? [])->validate();
} catch (ValidationException $e) {
return $this->error($e->getMessage());
}
if ($params['tab'] === 'login') {
if ($userLoginCaptchaSwitch) {
$captchaObj = new ClickCaptcha();
if (!$captchaObj->check($params['captchaId'], $params['captchaInfo'])) {
return $this->error(__('Captcha error'));
}
}
$res = $this->auth->login($params['username'], $params['password'], !empty($params['keep']));
} else {
$captchaObj = new Captcha();
if (!$captchaObj->check($params['captcha'], $params[$params['registerType']] . 'user_register')) {
return $this->error(__('Please enter the correct verification code'));
}
$res = $this->auth->register($params['username'], $params['password'], $params['mobile'], $params['email']);
}
if ($res === true) {
return $this->success(__('Login succeeded!'), [
'userInfo' => $this->auth->getUserInfo(),
'routePath' => '/user'
]);
}
$msg = $this->auth->getError();
return $this->error($msg ?: __('Check in failed, please try again or contact the website administrator~'));
}
return $this->success('', [
'userLoginCaptchaSwitch' => $userLoginCaptchaSwitch,
'accountVerificationType' => get_account_verification_type()
]);
}
private function getLoginRules(bool $captchaSwitch): array
{
$rules = [
'username' => 'required|string',
'password' => 'required|string|regex:/^(?!.*[&<>"\'\n\r]).{6,32}$/',
];
$messages = [
'password.regex' => __('Please input correct password'),
];
if ($captchaSwitch) {
$rules['captchaId'] = 'required|string';
$rules['captchaInfo'] = 'required|string';
}
return [$rules, $messages, []];
}
private function getRegisterRules(): array
{
return [
[
'username' => 'required|string|regex:/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/|unique:user,username',
'password' => 'required|string|regex:/^(?!.*[&<>"\'\n\r]).{6,32}$/',
'registerType' => 'required|in:email,mobile',
'email' => 'required_if:registerType,email|email|unique:user,email',
'mobile' => 'required_if:registerType,mobile|regex:/^1[3-9]\d{9}$/|unique:user,mobile',
'captcha' => 'required|string',
],
[
'username.regex' => __('Please input correct username'),
'password.regex' => __('Please input correct password'),
],
[
'username' => __('Username'),
'email' => __('Email'),
'mobile' => __('Mobile'),
'password' => __('Password'),
'captcha' => __('captcha'),
'registerType' => __('Register type'),
]
];
}
public function logout(Request $request): Response
{
$response = $this->initializeFrontend($request);
if ($response !== null) return $response;
if ($request->method() === 'POST') {
$refreshToken = $request->post('refreshToken', '');
if ($refreshToken) {
Token::delete((string) $refreshToken);
}
$this->auth->logout();
return $this->success();
}
return $this->error(__('Method not allowed'), [], 0, ['statusCode' => 405]);
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace app\api\validate;
use think\Validate;
class Account extends Validate
{
protected $failException = true;
protected $rule = [
'username' => 'require|regex:^[a-zA-Z][a-zA-Z0-9_]{2,15}$|unique:user',
'nickname' => 'require|chsDash',
'birthday' => 'date',
'email' => 'require|email|unique:user',
'mobile' => 'require|mobile|unique:user',
'password' => 'require|regex:^(?!.*[&<>"\'\n\r]).{6,32}$',
'account' => 'require',
'captcha' => 'require',
];
protected $scene = [
'edit' => ['username', 'nickname', 'birthday'],
'changePassword' => ['password'],
'retrievePassword' => ['account', 'captcha', 'password'],
];
public function __construct()
{
$this->field = [
'username' => __('Username'),
'email' => __('Email'),
'mobile' => __('Mobile'),
'password' => __('Password'),
'nickname' => __('nickname'),
'birthday' => __('birthday'),
];
$this->message = array_merge($this->message, [
'nickname.chsDash' => __('nicknameChsDash'),
'password.regex' => __('Please input correct password')
]);
parent::__construct();
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace app\api\validate;
use think\Validate;
class User extends Validate
{
protected $failException = true;
protected $rule = [
'username' => 'require|regex:^[a-zA-Z][a-zA-Z0-9_]{2,15}$|unique:user',
'password' => 'require|regex:^(?!.*[&<>"\'\n\r]).{6,32}$',
'registerType' => 'require|in:email,mobile',
'email' => 'email|unique:user|requireIf:registerType,email',
'mobile' => 'mobile|unique:user|requireIf:registerType,mobile',
'captcha' => 'require',
'captchaId' => 'require',
'captchaInfo' => 'require',
];
protected $scene = [
'register' => ['username', 'password', 'registerType', 'email', 'mobile', 'captcha'],
];
public function sceneLogin(): static
{
$fields = ['username', 'password'];
$userLoginCaptchaSwitch = config('buildadmin.user_login_captcha');
if ($userLoginCaptchaSwitch) {
$fields[] = 'captchaId';
$fields[] = 'captchaInfo';
}
return $this->only($fields)->remove('username', ['regex', 'unique']);
}
public function __construct()
{
$this->field = [
'username' => __('Username'),
'email' => __('Email'),
'mobile' => __('Mobile'),
'password' => __('Password'),
'captcha' => __('captcha'),
'captchaId' => __('captchaId'),
'captchaInfo' => __('captcha'),
'registerType' => __('Register type'),
];
$this->message = array_merge($this->message, [
'username.regex' => __('Please input correct username'),
'password.regex' => __('Please input correct password')
]);
parent::__construct();
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace app\common\controller;
use app\support\BaseController;
use support\Response;
use support\think\Db;
use think\db\exception\PDOException;
use Webman\Http\Request as WebmanRequest;
/**
* API 控制器基类
* 迁移自 app/common/controller/Api.php适配 Webman
*/
class Api extends BaseController
{
/**
* 默认响应输出类型
*/
protected string $responseType = 'json';
/**
* 是否开启系统站点配置数据库检查、ip_check、set_timezone
*/
protected bool $useSystemSettings = true;
/**
* API 初始化(需在控制器方法开头调用)
* @param WebmanRequest $request
* @return Response|null 若需直接返回(如 ip 禁止、数据库错误)则返回 Response否则 null
*/
public function initializeApi(WebmanRequest $request): ?Response
{
$this->setRequest($request);
if ($this->useSystemSettings) {
// 检查数据库连接
try {
Db::execute('SELECT 1');
} catch (PDOException $e) {
return $this->error(mb_convert_encoding($e->getMessage(), 'UTF-8', 'UTF-8,GBK,GB2312,BIG5'));
}
// IP 检查
$ipCheckResponse = ip_check(null, $request);
if ($ipCheckResponse !== null) {
return $ipCheckResponse;
}
// 时区设定
set_timezone();
}
// 语言包由 LoadLangPack 中间件加载
return null;
}
/**
* 从 Request 或路由解析控制器路径(如 user/user
* 优先从 $request->controller 解析,否则从 path 解析
*/
protected function getControllerPath(WebmanRequest $request): ?string
{
return get_controller_path($request);
}
/**
* 从 Request 解析应用名api 或 admin
*/
protected function getAppFromRequest(WebmanRequest $request): string
{
$path = trim($request->path(), '/');
$parts = explode('/', $path);
return $parts[0] ?? 'api';
}
}

View File

@@ -0,0 +1,427 @@
<?php
declare(strict_types=1);
namespace app\common\controller;
use Throwable;
use app\admin\library\Auth;
use app\common\library\token\TokenExpirationException;
use app\admin\library\traits\Backend as BackendTrait;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 后台控制器基类
* 迁移 Auth 鉴权、权限校验、CRUD trait
*/
class Backend extends Api
{
use BackendTrait;
/**
* 无需登录的方法
*/
protected array $noNeedLogin = [];
/**
* 无需鉴权的方法
*/
protected array $noNeedPermission = [];
/**
* 权限类实例
* @var Auth|null
*/
protected ?Auth $auth = null;
/**
* 模型类实例(子类设置)
* @var object|null
*/
protected ?object $model = null;
/**
* 新增/编辑时排除字段
*/
protected array|string $preExcludeFields = [];
/**
* 权重字段
*/
protected string $weighField = 'weigh';
/**
* 默认排序
*/
protected string|array $defaultSortField = [];
/**
* 有序保证
*/
protected string|array $orderGuarantee = [];
/**
* 快速搜索字段
*/
protected string|array $quickSearchField = 'id';
/**
* 数据限制
*/
protected bool|string|int $dataLimit = false;
/**
* 数据限制字段
*/
protected string $dataLimitField = 'admin_id';
/**
* 数据限制开启时自动填充
*/
protected bool $dataLimitFieldAutoFill = true;
/**
* 查看请求返回的主表字段
*/
protected string|array $indexField = ['*'];
/**
* 是否开启模型验证
*/
protected bool $modelValidate = true;
/**
* 是否开启模型场景验证
*/
protected bool $modelSceneValidate = false;
/**
* 关联查询方法名
*/
protected array $withJoinTable = [];
/**
* 关联查询 JOIN 方式
*/
protected string $withJoinType = 'LEFT';
/**
* 后台初始化(需在控制器方法开头调用,在 initializeApi 之后)
* @return Response|null 需直接返回时返回 Response否则 null
*/
public function initializeBackend(WebmanRequest $request): ?Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$action = $this->getActionFromPath($request->path());
$needLogin = !action_in_arr($this->noNeedLogin, $action);
$needPermission = !action_in_arr($this->noNeedPermission, $action);
try {
$this->auth = Auth::instance();
$token = get_auth_token(['ba', 'token'], $request);
if ($token) {
$this->auth->init($token);
}
} catch (TokenExpirationException) {
if ($needLogin) {
return $this->error(__('Token expiration'), [], 409);
}
}
if ($needLogin) {
if (!$this->auth->isLogin()) {
return $this->error(__('Please login first'), [
'type' => Auth::NEED_LOGIN,
], 0, ['statusCode' => Auth::LOGIN_RESPONSE_CODE]);
}
if ($needPermission) {
$controllerPath = $this->getControllerPath($request);
$routePath = $controllerPath . '/' . $action;
if (!$this->auth->check($routePath)) {
return $this->error(__('You have no permission'), [], 401);
}
}
}
event_trigger('backendInit', $this->auth);
if (method_exists($this, 'initController')) {
$initResp = $this->initController($request);
if ($initResp !== null) {
return $initResp;
}
}
return null;
}
/**
* 子类可覆盖,用于初始化 model 等(替代原 initialize
* @return Response|null 需直接返回时返回 Response否则 null
*/
protected function initController(WebmanRequest $request): ?Response
{
return null;
}
public function index(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->_index();
}
public function add(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->_add();
}
public function edit(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->_edit();
}
public function del(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->_del();
}
public function sortable(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->_sortable();
}
public function select(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$this->select();
return $this->success();
}
/**
* 查询参数构建器
*/
public function queryBuilder(): array
{
if (empty($this->model) || !$this->request) {
return [[], [], 10, []];
}
$pk = $this->model->getPk();
$quickSearch = $this->request->get('quickSearch', '');
$limit = $this->request->get('limit', 10);
$limit = is_numeric($limit) ? intval($limit) : 10;
$search = $this->request->get('search', []);
$search = is_array($search) ? $search : [];
$initKey = $this->request->get('initKey', $pk);
$initValue = $this->request->get('initValue', '');
$initOperator = $this->request->get('initOperator', 'in');
$where = [];
$modelTable = strtolower($this->model->getTable());
$alias = [];
$alias[$modelTable] = parse_name(basename(str_replace('\\', '/', get_class($this->model))));
$mainTableAlias = $alias[$modelTable] . '.';
if ($quickSearch) {
$quickSearchArr = is_array($this->quickSearchField) ? $this->quickSearchField : explode(',', $this->quickSearchField);
foreach ($quickSearchArr as $k => $v) {
$quickSearchArr[$k] = str_contains($v, '.') ? $v : $mainTableAlias . $v;
}
$where[] = [implode('|', $quickSearchArr), 'LIKE', '%' . str_replace('%', '\%', $quickSearch) . '%'];
}
if ($initValue) {
$where[] = [$initKey, $initOperator, $initValue];
$limit = 999999;
}
foreach ($search as $field) {
if (!is_array($field) || !isset($field['operator']) || !isset($field['field']) || !isset($field['val'])) {
continue;
}
$field['operator'] = $this->getOperatorByAlias($field['operator']);
if (str_contains($field['field'], '.')) {
$fieldNameParts = explode('.', $field['field']);
$fieldNamePartsLastKey = array_key_last($fieldNameParts);
foreach ($fieldNameParts as $fieldNamePartsKey => $fieldNamePart) {
if ($fieldNamePartsKey !== $fieldNamePartsLastKey) {
$fieldNameParts[$fieldNamePartsKey] = parse_name($fieldNamePart);
}
}
$fieldName = implode('.', $fieldNameParts);
} else {
$fieldName = $mainTableAlias . $field['field'];
}
if (isset($field['render']) && $field['render'] == 'datetime') {
if ($field['operator'] == 'RANGE') {
$datetimeArr = explode(',', $field['val']);
if (!isset($datetimeArr[1])) {
continue;
}
$datetimeArr = array_filter(array_map('strtotime', $datetimeArr));
$where[] = [$fieldName, str_replace('RANGE', 'BETWEEN', $field['operator']), $datetimeArr];
continue;
}
$where[] = [$fieldName, '=', strtotime($field['val'])];
continue;
}
if ($field['operator'] == 'RANGE' || $field['operator'] == 'NOT RANGE') {
$arr = explode(',', $field['val']);
if (!isset($arr[0]) || $arr[0] === '') {
$operator = $field['operator'] == 'RANGE' ? '<=' : '>';
$arr = $arr[1];
} elseif (!isset($arr[1]) || $arr[1] === '') {
$operator = $field['operator'] == 'RANGE' ? '>=' : '<';
$arr = $arr[0];
} else {
$operator = str_replace('RANGE', 'BETWEEN', $field['operator']);
}
$where[] = [$fieldName, $operator, $arr];
continue;
}
switch ($field['operator']) {
case '=':
case '<>':
$where[] = [$fieldName, $field['operator'], strval($field['val'])];
break;
case 'LIKE':
case 'NOT LIKE':
$where[] = [$fieldName, $field['operator'], '%' . str_replace('%', '\%', $field['val']) . '%'];
break;
case '>':
case '>=':
case '<':
case '<=':
$where[] = [$fieldName, $field['operator'], intval($field['val'])];
break;
case 'FIND_IN_SET':
if (is_array($field['val'])) {
foreach ($field['val'] as $val) {
$where[] = [$fieldName, 'find in set', $val];
}
} else {
$where[] = [$fieldName, 'find in set', $field['val']];
}
break;
case 'IN':
case 'NOT IN':
$where[] = [$fieldName, $field['operator'], is_array($field['val']) ? $field['val'] : explode(',', $field['val'])];
break;
case 'NULL':
case 'NOT NULL':
$where[] = [$fieldName, strtolower($field['operator']), ''];
break;
}
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$where[] = [$mainTableAlias . $this->dataLimitField, 'in', $dataLimitAdminIds];
}
return [$where, $alias, $limit, $this->queryOrderBuilder()];
}
/**
* 查询的排序参数构建器
*/
public function queryOrderBuilder(): array
{
$pk = $this->model->getPk();
$order = $this->request ? $this->request->get('order') : null;
$order = $order ?: $this->defaultSortField;
if ($order && is_string($order)) {
$order = explode(',', $order);
$order = [$order[0] => $order[1] ?? 'asc'];
}
if (!$this->orderGuarantee) {
$this->orderGuarantee = [$pk => 'desc'];
} elseif (is_string($this->orderGuarantee)) {
$orderParts = explode(',', $this->orderGuarantee);
$this->orderGuarantee = [$orderParts[0] => $orderParts[1] ?? 'asc'];
}
$orderGuaranteeKey = array_key_first($this->orderGuarantee);
if (!is_array($order) || !array_key_exists($orderGuaranteeKey, $order)) {
$order[$orderGuaranteeKey] = $this->orderGuarantee[$orderGuaranteeKey];
}
return $order;
}
/**
* 数据权限控制 - 获取有权限访问的管理员 IDs
*/
protected function getDataLimitAdminIds(): array
{
if (!$this->dataLimit || !$this->auth || $this->auth->isSuperAdmin()) {
return [];
}
$adminIds = [];
if ($this->dataLimit == 'parent') {
$parentGroups = $this->auth->getAdminChildGroups();
if ($parentGroups) {
$adminIds = $this->auth->getGroupAdmins($parentGroups);
}
} elseif (is_numeric($this->dataLimit) && $this->dataLimit > 0) {
$adminIds = $this->auth->getGroupAdmins([$this->dataLimit]);
return in_array($this->auth->id, $adminIds) ? [] : [$this->auth->id];
} elseif ($this->dataLimit == 'allAuth' || $this->dataLimit == 'allAuthAndOthers') {
$allAuthGroups = $this->auth->getAllAuthGroups($this->dataLimit);
$adminIds = $this->auth->getGroupAdmins($allAuthGroups);
}
$adminIds[] = $this->auth->id;
return array_unique($adminIds);
}
/**
* 从别名获取原始的逻辑运算符
*/
protected function getOperatorByAlias(string $operator): string
{
$alias = [
'ne' => '<>',
'eq' => '=',
'gt' => '>',
'egt' => '>=',
'lt' => '<',
'elt' => '<=',
];
return $alias[$operator] ?? $operator;
}
/**
* 从 path 解析 action
*/
protected function getActionFromPath(string $path): string
{
$parts = explode('/', trim($path, '/'));
return $parts[array_key_last($parts)] ?? '';
}
/**
* 从 Request 或路由解析控制器路径(如 auth/admin
* 优先从 $request->controllerWebman 路由匹配时设置)解析,否则从 path 解析
*/
protected function getControllerPath(WebmanRequest $request): string
{
return get_controller_path($request);
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace app\common\controller;
use app\common\library\Auth;
use app\common\library\token\TokenExpirationException;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 前台/会员中心控制器基类
* 继承 Api增加会员鉴权
*/
class Frontend extends Api
{
protected array $noNeedLogin = [];
protected array $noNeedPermission = [];
protected ?Auth $auth = null;
/**
* 前台初始化(需在控制器方法开头调用)
* @return Response|null 若需直接返回则返回 Response否则 null
*/
public function initializeFrontend(WebmanRequest $request): ?Response
{
$response = $this->initializeApi($request);
if ($response !== null) return $response;
$this->setRequest($request);
$path = trim($request->path(), '/');
$parts = explode('/', $path);
$action = $parts[array_key_last($parts)] ?? '';
$needLogin = !action_in_arr($this->noNeedLogin, $action);
try {
$this->auth = Auth::instance();
$token = get_auth_token(['ba', 'user', 'token'], $request);
if ($token) $this->auth->init($token);
} catch (TokenExpirationException) {
if ($needLogin) return $this->error(__('Token expiration'), [], 409);
}
if ($needLogin) {
if (!$this->auth->isLogin()) {
return $this->error(__('Please login first'), ['type' => Auth::NEED_LOGIN], Auth::LOGIN_RESPONSE_CODE);
}
if (!action_in_arr($this->noNeedPermission, $action)) {
$routePath = get_controller_path($request) . '/' . $action;
if (!$this->auth->check($routePath)) {
return $this->error(__('You have no permission'), [], 401);
}
}
}
event_trigger('frontendInit', $this->auth);
return null;
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace app\common\event;
/**
* 后台安全事件(数据回收、敏感数据记录)
* 迁移自 app/common/event/Security.php需 Admin、Token、DataRecycle 等模型支持
*/
class Security
{
protected array $listenAction = ['edit', 'del'];
/**
* @param mixed $auth Auth 实例backendInit 传入)
*/
public function handle(mixed $auth): bool
{
$request = function_exists('request') ? request() : null;
if (!$request) {
return true;
}
$action = $this->getActionFromPath($request->path());
if (!in_array($action, $this->listenAction)) {
return true;
}
if ($action === 'del' && $request->method() !== 'POST' && $request->method() !== 'DELETE') {
return true;
}
if ($action === 'edit' && $request->method() !== 'POST') {
return true;
}
if (!class_exists(\app\admin\model\DataRecycle::class) || !class_exists(\app\admin\model\SensitiveData::class)) {
return true;
}
if ($action === 'del') {
return $this->handleDel($request, $auth);
}
return $this->handleEdit($request, $auth);
}
protected function getActionFromPath(string $path): string
{
$parts = explode('/', trim($path, '/'));
return $parts[array_key_last($parts)] ?? '';
}
protected function handleDel($request, $auth): bool
{
$dataIds = $request->post('ids') ?? $request->get('ids');
if (!$dataIds) {
return true;
}
try {
$controllerPath = get_controller_path($request) ?? '';
$recycle = \app\admin\model\DataRecycle::where('status', 1)
->where('controller_as', $controllerPath)
->find();
if (!$recycle) {
return true;
}
$connection = class_exists(\ba\TableManager::class)
? \ba\TableManager::getConnection($recycle['connection'])
: null;
$db = $connection ? \support\think\Db::connect($connection) : \support\think\Db::connect();
$recycleData = $db->name($recycle['data_table'])
->whereIn($recycle['primary_key'], $dataIds)
->select()
->toArray();
$recycleDataArr = [];
$adminId = $auth && $auth->isLogin() ? $auth->id : 0;
foreach ($recycleData as $item) {
$recycleDataArr[] = [
'admin_id' => $adminId,
'recycle_id' => $recycle['id'],
'data' => json_encode($item, JSON_UNESCAPED_UNICODE),
'connection' => $recycle['connection'],
'data_table' => $recycle['data_table'],
'primary_key' => $recycle['primary_key'],
'ip' => $request->getRealIp(),
'useragent' => substr($request->header('user-agent', ''), 0, 255),
];
}
if ($recycleDataArr) {
$model = new \app\admin\model\DataRecycleLog();
$model->saveAll($recycleDataArr);
}
} catch (\Throwable $e) {
\support\Log::warning('[DataSecurity] ' . $e->getMessage());
}
return true;
}
protected function handleEdit($request, $auth): bool
{
try {
$controllerPath = get_controller_path($request) ?? '';
$sensitiveData = \app\admin\model\SensitiveData::where('status', 1)
->where('controller_as', $controllerPath)
->find();
if (!$sensitiveData) {
return true;
}
$sensitiveData = $sensitiveData->toArray();
$dataId = $request->post($sensitiveData['primary_key']) ?? $request->get($sensitiveData['primary_key']);
$connection = class_exists(\ba\TableManager::class)
? \ba\TableManager::getConnection($sensitiveData['connection'])
: null;
$db = $connection ? \support\think\Db::connect($connection) : \support\think\Db::connect();
$editData = $db->name($sensitiveData['data_table'])
->field(array_keys($sensitiveData['data_fields']))
->where($sensitiveData['primary_key'], $dataId)
->find();
if (!$editData) {
return true;
}
$newData = $request->post();
$sensitiveDataLog = [];
foreach ($sensitiveData['data_fields'] as $field => $title) {
if (isset($editData[$field]) && isset($newData[$field]) && $editData[$field] != $newData[$field]) {
$newVal = $newData[$field];
if (stripos($field, 'password') !== false && $newVal) {
$newVal = '******';
}
$sensitiveDataLog[] = [
'admin_id' => $auth && $auth->isLogin() ? $auth->id : 0,
'sensitive_id' => $sensitiveData['id'],
'connection' => $sensitiveData['connection'],
'data_table' => $sensitiveData['data_table'],
'primary_key' => $sensitiveData['primary_key'],
'data_field' => $field,
'data_comment' => $title,
'id_value' => $dataId,
'before' => $editData[$field],
'after' => $newVal,
'ip' => $request->getRealIp(),
'useragent' => substr($request->header('user-agent', ''), 0, 255),
];
}
}
if ($sensitiveDataLog) {
$model = new \app\admin\model\SensitiveDataLog();
$model->saveAll($sensitiveDataLog);
}
} catch (\Throwable $e) {
\support\Log::warning('[DataSecurity] ' . $e->getMessage());
}
return true;
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace app\common\facade;
use app\common\library\Token as TokenLibrary;
/**
* Token 门面类Webman 等价实现,替代 ThinkPHP Facade
* @see TokenLibrary
* @method static array get(string $token)
* @method static bool set(string $token, string $type, int $userId, ?int $expire = null)
* @method static bool check(string $token, string $type, int $userId)
* @method static bool delete(string $token)
* @method static bool clear(string $type, int $userId)
* @method static void tokenExpirationCheck(array $token)
*/
class Token
{
private static ?TokenLibrary $instance = null;
private static function getInstance(): TokenLibrary
{
if (self::$instance === null) {
self::$instance = new TokenLibrary();
}
return self::$instance;
}
public static function get(string $token): array
{
return self::getInstance()->get($token);
}
public static function set(string $token, string $type, int $userId, ?int $expire = null): bool
{
return self::getInstance()->set($token, $type, $userId, $expire);
}
public static function check(string $token, string $type, int $userId): bool
{
return self::getInstance()->check($token, $type, $userId);
}
public static function delete(string $token): bool
{
return self::getInstance()->delete($token);
}
public static function clear(string $type, int $userId): bool
{
return self::getInstance()->clear($type, $userId);
}
public static function tokenExpirationCheck(array $token): void
{
self::getInstance()->tokenExpirationCheck($token);
}
}

View File

@@ -0,0 +1,352 @@
<?php
namespace app\common\library;
use Throwable;
use ba\Random;
use support\think\Db;
use app\common\model\User;
use app\common\facade\Token;
use Webman\Http\Request;
/**
* 公共权限类(会员权限类)
*/
class Auth extends \ba\Auth
{
public const LOGIN_RESPONSE_CODE = 303;
public const NEED_LOGIN = 'need login';
public const LOGGED_IN = 'logged in';
public const TOKEN_TYPE = 'user';
protected bool $loginEd = false;
protected string $error = '';
protected ?User $model = null;
protected string $token = '';
protected string $refreshToken = '';
protected int $keepTime = 86400;
protected int $refreshTokenKeepTime = 2592000;
protected array $allowFields = ['id', 'username', 'nickname', 'email', 'mobile', 'avatar', 'gender', 'birthday', 'money', 'score', 'join_time', 'motto', 'last_login_time', 'last_login_ip'];
public function __construct(array $config = [])
{
parent::__construct(array_merge([
'auth_group' => 'user_group',
'auth_group_access' => '',
'auth_rule' => 'user_rule',
], $config));
$this->setKeepTime((int)config('buildadmin.user_token_keep_time', 86400));
}
public function __get($name): mixed
{
return $this->model?->$name;
}
public static function instance(array $options = []): Auth
{
$request = function_exists('request') ? request() : null;
if ($request && !isset($request->userAuth)) {
$request->userAuth = new static($options);
}
return $request && isset($request->userAuth) ? $request->userAuth : new static($options);
}
public function init($token): bool
{
$tokenData = Token::get($token);
if ($tokenData) {
Token::tokenExpirationCheck($tokenData);
$userId = $tokenData['user_id'];
if ($tokenData['type'] == self::TOKEN_TYPE && $userId > 0) {
$this->model = User::where('id', $userId)->find();
if (!$this->model) {
$this->setError('Account not exist');
return false;
}
if ($this->model->status != 'enable') {
$this->setError('Account disabled');
return false;
}
$this->token = $token;
$this->loginSuccessful();
return true;
}
}
$this->setError('Token login failed');
$this->reset();
return false;
}
public function register(string $username, string $password = '', string $mobile = '', string $email = '', int $group = 1, array $extend = []): bool
{
$request = function_exists('request') ? request() : null;
$ip = $request ? $request->getRealIp() : '0.0.0.0';
if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->setError(__('Email'));
return false;
}
if ($username && !preg_match('/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/', $username)) {
$this->setError(__('Username'));
return false;
}
if (User::where('email', $email)->find() && $email) {
$this->setError(__('Email') . ' ' . __('already exists'));
return false;
}
if (User::where('username', $username)->find()) {
$this->setError(__('Username') . ' ' . __('already exists'));
return false;
}
$nickname = preg_replace_callback('/1[3-9]\d{9}/', fn($m) => substr($m[0], 0, 3) . '****' . substr($m[0], 7), $username);
$time = time();
$data = [
'group_id' => $group,
'nickname' => $nickname,
'join_ip' => $ip,
'join_time' => $time,
'last_login_ip' => $ip,
'last_login_time' => $time,
'status' => 'enable',
];
$data = array_merge(compact('username', 'password', 'mobile', 'email'), $data, $extend);
Db::startTrans();
try {
$this->model = User::create($data);
$this->token = Random::uuid();
Token::set($this->token, self::TOKEN_TYPE, $this->model->id, $this->keepTime);
Db::commit();
if ($password) {
$this->model->resetPassword($this->model->id, $password);
}
event_trigger('userRegisterSuccess', $this->model);
} catch (Throwable $e) {
$this->setError($e->getMessage());
Db::rollback();
return false;
}
return true;
}
public function login(string $username, string $password, bool $keep): bool
{
$accountType = false;
if (preg_match('/^1[3-9]\d{9}$/', $username)) $accountType = 'mobile';
elseif (filter_var($username, FILTER_VALIDATE_EMAIL)) $accountType = 'email';
elseif (preg_match('/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/', $username)) $accountType = 'username';
if (!$accountType) {
$this->setError('Account not exist');
return false;
}
$this->model = User::where($accountType, $username)->find();
if (!$this->model) {
$this->setError('Account not exist');
return false;
}
if ($this->model->status == 'disable') {
$this->setError('Account disabled');
return false;
}
$userLoginRetry = config('buildadmin.user_login_retry');
if ($userLoginRetry && $this->model->last_login_time) {
if ($this->model->login_failure > 0 && time() - strtotime($this->model->last_login_time) >= 86400) {
$this->model->login_failure = 0;
$this->model->save();
$this->model = User::where($accountType, $username)->find();
}
if ($this->model->login_failure >= $userLoginRetry) {
$this->setError('Please try again after 1 day');
return false;
}
}
if (!verify_password($password, $this->model->password, ['salt' => $this->model->salt])) {
$this->loginFailed();
$this->setError('Password is incorrect');
return false;
}
if (config('buildadmin.user_sso')) {
Token::clear(self::TOKEN_TYPE, $this->model->id);
Token::clear(self::TOKEN_TYPE . '-refresh', $this->model->id);
}
if ($keep) $this->setRefreshToken($this->refreshTokenKeepTime);
$this->loginSuccessful();
return true;
}
public function direct(int $userId): bool
{
$this->model = User::find($userId);
if (!$this->model) return false;
if (config('buildadmin.user_sso')) {
Token::clear(self::TOKEN_TYPE, $this->model->id);
Token::clear(self::TOKEN_TYPE . '-refresh', $this->model->id);
}
return $this->loginSuccessful();
}
public function loginSuccessful(): bool
{
if (!$this->model) return false;
$request = function_exists('request') ? request() : null;
$ip = $request ? $request->getRealIp() : '0.0.0.0';
$this->model->startTrans();
try {
$this->model->login_failure = 0;
$this->model->last_login_time = date('Y-m-d H:i:s');
$this->model->last_login_ip = $ip;
$this->model->save();
$this->loginEd = true;
if (!$this->token) {
$this->token = Random::uuid();
Token::set($this->token, self::TOKEN_TYPE, $this->model->id, $this->keepTime);
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->setError($e->getMessage());
return false;
}
return true;
}
public function loginFailed(): bool
{
if (!$this->model) return false;
$request = function_exists('request') ? request() : null;
$ip = $request ? $request->getRealIp() : '0.0.0.0';
$this->model->startTrans();
try {
$this->model->login_failure++;
$this->model->last_login_time = date('Y-m-d H:i:s');
$this->model->last_login_ip = $ip;
$this->model->save();
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
}
return $this->reset();
}
public function logout(): bool
{
if (!$this->loginEd) {
$this->setError('You are not logged in');
return false;
}
return $this->reset();
}
public function isLogin(): bool
{
return $this->loginEd;
}
public function getUser(): User
{
return $this->model;
}
public function getToken(): string
{
return $this->token;
}
public function setRefreshToken(int $keepTime = 0): void
{
$this->refreshToken = Random::uuid();
Token::set($this->refreshToken, self::TOKEN_TYPE . '-refresh', $this->model->id, $keepTime);
}
public function getRefreshToken(): string
{
return $this->refreshToken;
}
public function getUserInfo(): array
{
if (!$this->model) return [];
$info = $this->model->toArray();
$info = array_intersect_key($info, array_flip($this->getAllowFields()));
$info['token'] = $this->getToken();
$info['refresh_token'] = $this->getRefreshToken();
return $info;
}
public function getAllowFields(): array
{
return $this->allowFields;
}
public function setAllowFields($fields): void
{
$this->allowFields = $fields;
}
public function setKeepTime(int $keepTime = 0): void
{
$this->keepTime = $keepTime;
}
public function check(string $name, int $uid = 0, string $relation = 'or', string $mode = 'url'): bool
{
return parent::check($name, $uid ?: $this->id, $relation, $mode);
}
public function getRuleList(int $uid = 0): array
{
return parent::getRuleList($uid ?: $this->id);
}
public function getRuleIds(int $uid = 0): array
{
return parent::getRuleIds($uid ?: $this->id);
}
public function getMenus(int $uid = 0): array
{
return parent::getMenus($uid ?: $this->id);
}
public function isSuperUser(): bool
{
return in_array('*', $this->getRuleIds());
}
public function setError(string $error): Auth
{
$this->error = $error;
return $this;
}
public function getError(): string
{
return $this->error ? __($this->error) : '';
}
protected function reset(bool $deleteToken = true): bool
{
if ($deleteToken && $this->token) {
Token::delete($this->token);
}
$this->token = '';
$this->loginEd = false;
$this->model = null;
$this->refreshToken = '';
$this->setError('');
$this->setKeepTime((int)config('buildadmin.user_token_keep_time', 86400));
return true;
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace app\common\library;
use PHPMailer\PHPMailer\PHPMailer;
/**
* 邮件类Webman 迁移版)
*/
class Email extends PHPMailer
{
public bool $configured = false;
public array $options = [
'charset' => 'utf-8',
'debug' => true,
'lang' => 'zh_cn',
];
public function __construct(array $options = [])
{
$this->options = array_merge($this->options, $options);
parent::__construct($this->options['debug']);
$langSet = function_exists('locale') ? locale() : 'zh_CN';
if ($langSet == 'zh-cn' || $langSet == 'zh_CN' || !$langSet) {
$langSet = 'zh_cn';
}
$this->options['lang'] = $this->options['lang'] ?: $langSet;
$langPath = root_path() . 'vendor' . DIRECTORY_SEPARATOR . 'phpmailer' . DIRECTORY_SEPARATOR . 'phpmailer' . DIRECTORY_SEPARATOR . 'language' . DIRECTORY_SEPARATOR;
if (is_dir($langPath)) {
$this->setLanguage($this->options['lang'], $langPath);
}
$this->CharSet = $this->options['charset'];
$sysMailConfig = get_sys_config('', 'mail');
$this->configured = true;
if (is_array($sysMailConfig)) {
foreach ($sysMailConfig as $item) {
if (!$item) {
$this->configured = false;
break;
}
}
} else {
$this->configured = false;
}
if ($this->configured) {
$this->Host = $sysMailConfig['smtp_server'];
$this->SMTPAuth = true;
$this->Username = $sysMailConfig['smtp_user'];
$this->Password = $sysMailConfig['smtp_pass'];
$this->SMTPSecure = ($sysMailConfig['smtp_verification'] ?? '') == 'SSL' ? self::ENCRYPTION_SMTPS : self::ENCRYPTION_STARTTLS;
$this->Port = $sysMailConfig['smtp_port'] ?? 465;
$this->setFrom($sysMailConfig['smtp_sender_mail'] ?? '', $sysMailConfig['smtp_user'] ?? '');
}
}
public function setSubject($subject): void
{
$this->Subject = "=?utf-8?B?" . base64_encode($subject) . "?=";
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace app\common\library;
use Throwable;
use app\admin\model\AdminRule;
use app\admin\model\UserRule;
/**
* 菜单规则管理类Webman 迁移版)
*/
class Menu
{
/**
* @param array $menu
* @param int|string $parent 父级规则name或id
* @param string $mode 添加模式(规则重复时):cover=覆盖旧菜单,rename=重命名新菜单,ignore=忽略
* @param string $position 位置:backend=后台,frontend=前台
* @return void
* @throws Throwable
*/
public static function create(array $menu, int|string $parent = 0, string $mode = 'cover', string $position = 'backend'): void
{
$pid = 0;
$model = $position == 'backend' ? new AdminRule() : new UserRule();
$parentRule = $model->where((is_numeric($parent) ? 'id' : 'name'), $parent)->find();
if ($parentRule) {
$pid = $parentRule['id'];
}
foreach ($menu as $item) {
if (!self::requiredAttrCheck($item)) {
continue;
}
$item['status'] = 1;
if (!isset($item['pid'])) {
$item['pid'] = $pid;
}
$sameOldMenu = $model->where('name', $item['name'])->find();
if ($sameOldMenu) {
if ($mode == 'cover') {
$sameOldMenu->save($item);
} elseif ($mode == 'rename') {
$count = $model->where('name', $item['name'])->count();
$item['name'] = $item['name'] . '-CONFLICT-' . $count;
$item['path'] = $item['path'] . '-CONFLICT-' . $count;
$item['title'] = $item['title'] . '-CONFLICT-' . $count;
$sameOldMenu = $model->create($item);
} elseif ($mode == 'ignore') {
$sameOldMenu = $model
->where('name', $item['name'])
->where('pid', $item['pid'])
->find();
if (!$sameOldMenu) {
$sameOldMenu = $model->create($item);
}
}
} else {
$sameOldMenu = $model->create($item);
}
if (!empty($item['children'])) {
self::create($item['children'], $sameOldMenu['id'], $mode, $position);
}
}
}
public static function delete(string|int $id, bool $recursion = false, string $position = 'backend'): bool
{
if (!$id) {
return true;
}
$model = $position == 'backend' ? new AdminRule() : new UserRule();
$menuRule = $model->where((is_numeric($id) ? 'id' : 'name'), $id)->find();
if (!$menuRule) {
return true;
}
$children = $model->where('pid', $menuRule['id'])->select()->toArray();
if ($recursion && $children) {
foreach ($children as $child) {
self::delete($child['id'], true, $position);
}
}
if (!$children || $recursion) {
$menuRule->delete();
self::delete($menuRule->pid, false, $position);
}
return true;
}
public static function enable(string|int $id, string $position = 'backend'): bool
{
$model = $position == 'backend' ? new AdminRule() : new UserRule();
$menuRule = $model->where((is_numeric($id) ? 'id' : 'name'), $id)->find();
if (!$menuRule) {
return false;
}
$menuRule->status = 1;
$menuRule->save();
return true;
}
public static function disable(string|int $id, string $position = 'backend'): bool
{
$model = $position == 'backend' ? new AdminRule() : new UserRule();
$menuRule = $model->where((is_numeric($id) ? 'id' : 'name'), $id)->find();
if (!$menuRule) {
return false;
}
$menuRule->status = 0;
$menuRule->save();
return true;
}
public static function requiredAttrCheck($menu): bool
{
$attrs = ['type', 'title', 'name'];
foreach ($attrs as $attr) {
if (!array_key_exists($attr, $menu)) {
return false;
}
if (!$menu[$attr]) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace app\common\library;
use InvalidArgumentException;
use app\common\library\token\TokenExpirationException;
/**
* Token 管理类
*/
class Token
{
public array $instance = [];
public ?object $handler = null;
protected string $namespace = '\\app\\common\\library\\token\\driver\\';
public function getDriver(?string $name = null): object
{
if ($this->handler !== null) {
return $this->handler;
}
$name = $name ?: $this->getDefaultDriver();
if ($name === null) {
throw new InvalidArgumentException(sprintf('Unable to resolve NULL driver for [%s].', static::class));
}
return $this->createDriver($name);
}
protected function createDriver(string $name): object
{
$type = $this->resolveType($name);
$params = $this->resolveParams($name);
$class = $this->resolveClass($type);
if (isset($this->instance[$type])) {
return $this->instance[$type];
}
return new $class(...$params);
}
protected function getDefaultDriver(): string
{
return $this->getConfig('default');
}
protected function getConfig(?string $name = null, mixed $default = null): array|string
{
$config = config('buildadmin.token', []);
if ($name === null) {
return $config;
}
$keys = explode('.', $name);
$val = $config;
foreach ($keys as $k) {
if (!is_array($val) || !array_key_exists($k, $val)) {
return $default;
}
$val = $val[$k];
}
return $val;
}
protected function resolveParams(string $name): array
{
$config = $this->getStoreConfig($name);
return [$config];
}
protected function resolveClass(string $type): string
{
$class = str_contains($type, '\\') ? $type : $this->namespace . $this->studly($type);
if (class_exists($class)) {
return $class;
}
throw new InvalidArgumentException("Driver [{$type}] not supported.");
}
protected function getStoreConfig(string $store, ?string $name = null, mixed $default = null): array|string
{
$config = $this->getConfig("stores.{$store}");
if ($config === null) {
throw new InvalidArgumentException("Store [{$store}] not found.");
}
if ($name === null) {
return $config;
}
$keys = explode('.', $name);
$val = $config;
foreach ($keys as $k) {
if (!is_array($val) || !array_key_exists($k, $val)) {
return $default;
}
$val = $val[$k];
}
return $val;
}
protected function resolveType(string $name): string
{
return $this->getStoreConfig($name, 'type', 'Mysql');
}
private function studly(string $value): string
{
$value = ucwords(str_replace(['-', '_'], ' ', $value));
return str_replace(' ', '', $value);
}
public function set(string $token, string $type, int $userId, ?int $expire = null): bool
{
return $this->getDriver()->set($token, $type, $userId, $expire);
}
public function get(string $token, bool $expirationException = true): array
{
return $this->getDriver()->get($token);
}
public function check(string $token, string $type, int $userId, bool $expirationException = true): bool
{
return $this->getDriver()->check($token, $type, $userId);
}
public function delete(string $token): bool
{
return $this->getDriver()->delete($token);
}
public function clear(string $type, int $userId): bool
{
return $this->getDriver()->clear($type, $userId);
}
/**
* Token 过期检查
* @throws TokenExpirationException
*/
public function tokenExpirationCheck(array $token): void
{
if (isset($token['expire_time']) && $token['expire_time'] <= time()) {
throw new TokenExpirationException();
}
}
}

View File

@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
namespace app\common\library;
use ba\Random;
use ba\Filesystem;
use app\common\model\Attachment;
use app\common\library\upload\Driver;
use app\common\library\upload\WebmanUploadedFile;
use InvalidArgumentException;
use RuntimeException;
/**
* 上传Webman 迁移版,使用 WebmanUploadedFile
*/
class Upload
{
protected array $config = [];
protected ?WebmanUploadedFile $file = null;
protected bool $isImage = false;
protected array $fileInfo = [];
protected array $driver = [
'name' => 'local',
'handler' => [],
'namespace' => '\\app\\common\\library\\upload\\driver\\',
];
protected string $topic = 'default';
public function __construct(?WebmanUploadedFile $file = null, array $config = [])
{
$upload = config('upload', []);
$this->config = array_merge($upload, $config);
if ($file) {
$this->setFile($file);
}
}
public function setFile(?WebmanUploadedFile $file): self
{
if (empty($file)) {
throw new RuntimeException(__('No files were uploaded'));
}
$suffix = strtolower($file->extension());
$suffix = $suffix && preg_match("/^[a-zA-Z0-9]+$/", $suffix) ? $suffix : 'file';
$this->fileInfo = [
'suffix' => $suffix,
'type' => $file->getMime(),
'size' => $file->getSize(),
'name' => $file->getOriginalName(),
'sha1' => $file->sha1(),
];
$this->file = $file;
return $this;
}
public function setDriver(string $driver): self
{
$this->driver['name'] = $driver;
return $this;
}
public function getDriver(?string $driver = null, bool $noDriveException = true): Driver|false
{
$driver = $driver ?? $this->driver['name'];
if (!isset($this->driver['handler'][$driver])) {
$class = $this->resolveDriverClass($driver);
if ($class) {
$this->driver['handler'][$driver] = new $class();
} elseif ($noDriveException) {
throw new InvalidArgumentException(__('Driver %s not supported', [$driver]));
}
}
return $this->driver['handler'][$driver] ?? false;
}
protected function resolveDriverClass(string $driver): string|false
{
if ($this->driver['namespace'] || str_contains($driver, '\\')) {
$class = str_contains($driver, '\\') ? $driver : $this->driver['namespace'] . $this->studly($driver);
if (class_exists($class)) {
return $class;
}
}
return false;
}
protected function studly(string $value): string
{
$value = ucwords(str_replace(['-', '_'], ' ', $value));
return str_replace(' ', '', $value);
}
public function setTopic(string $topic): self
{
$this->topic = $topic;
return $this;
}
protected function checkIsImage(): bool
{
if (in_array($this->fileInfo['type'], ['image/gif', 'image/jpg', 'image/jpeg', 'image/bmp', 'image/png', 'image/webp'])
|| in_array($this->fileInfo['suffix'], ['gif', 'jpg', 'jpeg', 'bmp', 'png', 'webp'])) {
$path = $this->file->getPathname();
$imgInfo = is_file($path) ? getimagesize($path) : false;
if (!$imgInfo || !isset($imgInfo[0]) || !isset($imgInfo[1])) {
throw new RuntimeException(__('The uploaded image file is not a valid image'));
}
$this->fileInfo['width'] = $imgInfo[0];
$this->fileInfo['height'] = $imgInfo[1];
$this->isImage = true;
return true;
}
return false;
}
public function isImage(): bool
{
return $this->isImage;
}
public function getSuffix(): string
{
return $this->fileInfo['suffix'] ?? 'file';
}
public function getSaveName(?string $saveName = null, ?string $filename = null, ?string $sha1 = null): string
{
if ($filename) {
$suffix = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
$suffix = $suffix && preg_match("/^[a-zA-Z0-9]+$/", $suffix) ? $suffix : 'file';
} else {
$suffix = $this->fileInfo['suffix'];
}
$filename = $filename ?? $this->fileInfo['name'];
$sha1 = $sha1 ?? $this->fileInfo['sha1'];
$replaceArr = [
'{topic}' => $this->topic,
'{year}' => date('Y'),
'{mon}' => date('m'),
'{day}' => date('d'),
'{hour}' => date('H'),
'{min}' => date('i'),
'{sec}' => date('s'),
'{random}' => Random::build(),
'{random32}' => Random::build('alnum', 32),
'{fileName}' => $this->getFileNameSubstr($filename, $suffix),
'{suffix}' => $suffix,
'{.suffix}' => $suffix ? '.' . $suffix : '',
'{fileSha1}' => $sha1,
];
$saveName = $saveName ?? $this->config['save_name'];
return Filesystem::fsFit(str_replace(array_keys($replaceArr), array_values($replaceArr), $saveName));
}
public function validates(): void
{
if (empty($this->file)) {
throw new RuntimeException(__('No files have been uploaded or the file size exceeds the upload limit of the server'));
}
$size = Filesystem::fileUnitToByte($this->config['max_size'] ?? '10M');
$mime = $this->checkConfig($this->config['allowed_mime_types'] ?? []);
$suffix = $this->checkConfig($this->config['allowed_suffixes'] ?? '');
if ($this->fileInfo['size'] > $size) {
throw new RuntimeException(__('The uploaded file is too large (%sMiB), Maximum file size:%sMiB', [
round($this->fileInfo['size'] / pow(1024, 2), 2),
round($size / pow(1024, 2), 2)
]));
}
if ($suffix && !in_array(strtolower($this->fileInfo['suffix']), $suffix)) {
throw new RuntimeException(__('The uploaded file format is not allowed'));
}
if ($mime && !in_array(strtolower($this->fileInfo['type']), $mime)) {
throw new RuntimeException(__('The uploaded file format is not allowed'));
}
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $this->topic)) {
throw new RuntimeException(__('Topic format error'));
}
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $this->driver['name'])) {
throw new RuntimeException(__('Driver %s not supported', [$this->driver['name']]));
}
if ($this->checkIsImage()) {
$maxW = $this->config['image_max_width'] ?? 0;
$maxH = $this->config['image_max_height'] ?? 0;
if ($maxW && $this->fileInfo['width'] > $maxW) {
throw new RuntimeException(__('The uploaded image file is not a valid image'));
}
if ($maxH && $this->fileInfo['height'] > $maxH) {
throw new RuntimeException(__('The uploaded image file is not a valid image'));
}
}
}
public function upload(?string $saveName = null, int $adminId = 0, int $userId = 0): array
{
$this->validates();
$driver = $this->getDriver();
if (!$driver) {
throw new RuntimeException(__('Driver %s not supported', [$this->driver['name']]));
}
$saveName = $saveName ?: $this->getSaveName();
$params = [
'topic' => $this->topic,
'admin_id' => $adminId,
'user_id' => $userId,
'url' => $driver->url($saveName, false),
'width' => $this->fileInfo['width'] ?? 0,
'height' => $this->fileInfo['height'] ?? 0,
'name' => $this->getFileNameSubstr($this->fileInfo['name'], $this->fileInfo['suffix'], 100) . '.' . $this->fileInfo['suffix'],
'size' => $this->fileInfo['size'],
'mimetype' => $this->fileInfo['type'],
'storage' => $this->driver['name'],
'sha1' => $this->fileInfo['sha1'],
];
$attachment = Attachment::where('sha1', $params['sha1'])
->where('topic', $params['topic'])
->where('storage', $params['storage'])
->find();
if ($attachment && $driver->exists($attachment->url)) {
$attachment->quote++;
$attachment->last_upload_time = time();
} else {
$driver->save($this->file, $saveName);
$attachment = new Attachment();
$attachment->data(array_filter($params));
}
$attachment->save();
return $attachment->toArray();
}
public function getFileNameSubstr(string $fileName, string $suffix, int $length = 15): string
{
$pattern = "/[\s:@#?&\/=',+]+/u";
$fileName = str_replace(".$suffix", '', $fileName);
$fileName = preg_replace($pattern, '', $fileName);
return mb_substr(htmlspecialchars(strip_tags($fileName)), 0, $length);
}
protected function checkConfig(mixed $configItem): array
{
if (is_array($configItem)) {
return array_map('strtolower', $configItem);
}
return $configItem ? explode(',', strtolower((string)$configItem)) : [];
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace app\common\library\token;
/**
* Token 驱动抽象类
*/
abstract class Driver
{
/**
* 具体驱动的句柄 Mysql|Redis
* @var object
*/
protected object $handler;
/**
* @var array 配置数据
*/
protected array $options = [];
/**
* 设置 token
*/
abstract public function set(string $token, string $type, int $userId, ?int $expire = null): bool;
/**
* 获取 token 的数据
*/
abstract public function get(string $token): array;
/**
* 检查 token 是否有效
*/
abstract public function check(string $token, string $type, int $userId): bool;
/**
* 删除一个 token
*/
abstract public function delete(string $token): bool;
/**
* 清理一个用户的所有 token
*/
abstract public function clear(string $type, int $userId): bool;
/**
* 返回句柄对象
*/
public function handler(): ?object
{
return $this->handler;
}
protected function getEncryptedToken(string $token): string
{
$config = config('buildadmin.token');
return hash_hmac($config['algo'], $token, $config['key']);
}
protected function getExpiredIn(int $expireTime): int
{
return $expireTime ? max(0, $expireTime - time()) : 365 * 86400;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace app\common\library\token;
use Exception;
/**
* Token 过期异常
*/
class TokenExpirationException extends Exception
{
public function __construct(
protected string $message = '',
protected int $code = 409,
protected array $data = [],
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
public function getData(): array
{
return $this->data;
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace app\common\library\token\driver;
use support\think\Db;
use app\common\library\token\Driver;
/**
* Token Mysql 驱动
* @see Driver
*/
class Mysql extends Driver
{
protected array $options = [];
public function __construct(array $options = [])
{
if (!empty($options)) {
$this->options = array_merge($this->options, $options);
}
if (!empty($this->options['name'])) {
$this->handler = Db::connect($this->options['name'])->name($this->options['table']);
} else {
$this->handler = Db::name($this->options['table']);
}
}
public function set(string $token, string $type, int $userId, ?int $expire = null): bool
{
if ($expire === null) {
$expire = $this->options['expire'] ?? 2592000;
}
$expireTime = $expire !== 0 ? time() + $expire : 0;
$encryptedToken = $this->getEncryptedToken($token);
$this->handler->insert([
'token' => $encryptedToken,
'type' => $type,
'user_id' => $userId,
'create_time' => time(),
'expire_time' => $expireTime,
]);
// 每隔 48 小时清理一次过期 Token
$time = time();
$lastCacheCleanupTime = $this->getLastCacheCleanupTime();
if (!$lastCacheCleanupTime || $lastCacheCleanupTime < $time - 172800) {
$this->setLastCacheCleanupTime($time);
$this->handler->where('expire_time', '<', time())->where('expire_time', '>', 0)->delete();
}
return true;
}
public function get(string $token): array
{
$data = $this->handler->where('token', $this->getEncryptedToken($token))->find();
if (!$data) {
return [];
}
$data['token'] = $token;
$data['expires_in'] = $this->getExpiredIn($data['expire_time'] ?? 0);
return $data;
}
public function check(string $token, string $type, int $userId): bool
{
$data = $this->get($token);
if (!$data || ($data['expire_time'] && $data['expire_time'] <= time())) {
return false;
}
return $data['type'] == $type && $data['user_id'] == $userId;
}
public function delete(string $token): bool
{
$this->handler->where('token', $this->getEncryptedToken($token))->delete();
return true;
}
public function clear(string $type, int $userId): bool
{
$this->handler->where('type', $type)->where('user_id', $userId)->delete();
return true;
}
/**
* 使用文件存储 last_cache_cleanup_time兼容无 cache 插件环境)
*/
private function getLastCacheCleanupTime(): ?int
{
$path = $this->getCleanupTimePath();
if (!is_file($path)) {
return null;
}
$v = file_get_contents($path);
return $v !== false && $v !== '' ? (int) $v : null;
}
private function setLastCacheCleanupTime(int $time): void
{
$path = $this->getCleanupTimePath();
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
@file_put_contents($path, (string) $time);
}
private function getCleanupTimePath(): string
{
$base = defined('RUNTIME_PATH') ? RUNTIME_PATH : (base_path() . DIRECTORY_SEPARATOR . 'runtime');
return $base . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . 'token_last_cleanup.txt';
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace app\common\library\upload;
/**
* 上传驱动抽象类Webman 迁移版,支持 WebmanUploadedFile
*/
abstract class Driver
{
protected array $options = [];
/**
* 保存文件
* @param WebmanUploadedFile $file
* @param string $saveName
*/
abstract public function save(WebmanUploadedFile $file, string $saveName): bool;
/**
* 删除文件
*/
abstract public function delete(string $saveName): bool;
/**
* 获取资源 URL 地址
*/
abstract public function url(string $saveName, string|bool $domain = true, string $default = ''): string;
/**
* 文件是否存在
*/
abstract public function exists(string $saveName): bool;
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace app\common\library\upload;
use Webman\Http\UploadFile;
/**
* Webman UploadFile 适配器,提供 ThinkPHP UploadedFile 兼容接口
*/
class WebmanUploadedFile
{
public function __construct(
protected UploadFile $file
) {
}
public function extension(): string
{
$ext = $this->file->getUploadExtension();
return $ext && preg_match("/^[a-zA-Z0-9]+$/", $ext) ? $ext : 'file';
}
public function getMime(): string
{
return $this->file->getUploadMimeType() ?? '';
}
public function getSize(): int
{
$size = $this->file->getSize();
return $size !== false ? $size : 0;
}
public function getOriginalName(): string
{
return $this->file->getUploadName() ?? '';
}
public function sha1(): string
{
$path = $this->file->getPathname();
return is_file($path) ? hash_file('sha1', $path) : '';
}
public function getPathname(): string
{
return $this->file->getPathname();
}
/**
* 移动文件(兼容 ThinkPHP move($dir, $name) 与 Webman move($fullPath)
*/
public function move(string $directory, ?string $name = null): self
{
$destination = rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . ($name ?? basename($this->file->getUploadName()));
$this->file->move($destination);
return $this;
}
public function getUploadFile(): UploadFile
{
return $this->file;
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace app\common\library\upload\driver;
use ba\Filesystem;
use app\common\library\upload\Driver;
use app\common\library\upload\WebmanUploadedFile;
use RuntimeException;
/**
* 上传到本地磁盘的驱动Webman 迁移版)
*/
class Local extends Driver
{
protected array $options = [];
public function __construct(array $options = [])
{
$this->options = config('filesystem.disks.public', []);
if (!empty($options)) {
$this->options = array_merge($this->options, $options);
}
}
public function save(WebmanUploadedFile $file, string $saveName): bool
{
$savePathInfo = pathinfo($saveName);
$saveFullPath = $this->getFullPath($saveName);
$destination = rtrim($saveFullPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $savePathInfo['basename'];
$saveDir = dirname($destination);
if (!is_dir($saveDir) && !@mkdir($saveDir, 0755, true)) {
throw new RuntimeException(__('Failed to create upload directory'));
}
$uploadFile = $file->getUploadFile();
$uploadFile->move($destination);
@chmod($destination, 0666 & ~umask());
return true;
}
public function delete(string $saveName): bool
{
$saveFullName = $this->getFullPath($saveName, true);
if ($this->exists($saveFullName)) {
@unlink($saveFullName);
}
Filesystem::delEmptyDir(dirname($saveFullName));
return true;
}
public function url(string $saveName, string|bool $domain = true, string $default = ''): string
{
$saveName = $this->clearRootPath($saveName);
$saveName = $saveName ? '/' . ltrim(str_replace('\\', '/', $saveName), '/') : '';
if ($domain === true) {
$req = function_exists('request') ? request() : null;
$domain = $req && method_exists($req, 'host') ? '//' . $req->host() : '';
} elseif ($domain === false) {
$domain = '';
}
$saveName = $saveName ?: $default;
if (!$saveName) return $domain;
$regex = "/^((?:[a-z]+:)?\/\/|data:image\/)(.*)/i";
if (preg_match('/^http(s)?:\/\//', $saveName) || preg_match($regex, $saveName) || $domain === false) {
return $saveName;
}
return str_replace('\\', '/', $domain . $saveName);
}
public function exists(string $saveName): bool
{
$saveFullName = $this->getFullPath($saveName, true);
return file_exists($saveFullName);
}
public function getFullPath(string $saveName, bool $baseName = false): string
{
$savePathInfo = pathinfo($saveName);
$root = $this->getRootPath();
$dirName = $savePathInfo['dirname'] . '/';
if (str_starts_with($saveName, $root)) {
return Filesystem::fsFit($baseName || !isset($savePathInfo['extension']) ? $saveName : $dirName);
}
return Filesystem::fsFit($root . $dirName . ($baseName ? $savePathInfo['basename'] : ''));
}
public function clearRootPath(string $saveName): string
{
return str_replace($this->getRootPath(), '', Filesystem::fsFit($saveName));
}
public function getRootPath(): string
{
$root = $this->options['root'] ?? rtrim(base_path(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'public';
return Filesystem::fsFit($root);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace app\common\middleware;
use Webman\MiddlewareInterface;
use Webman\Http\Request;
use Webman\Http\Response;
use app\admin\model\AdminLog as AdminLogModel;
/**
* 管理员操作日志中间件Webman 迁移版)
* 仅对 /admin 路由的 POST、DELETE 请求记录日志
*/
class AdminLog implements MiddlewareInterface
{
public function process(Request $request, callable $handler): Response
{
$response = $handler($request);
$path = trim($request->path(), '/');
if (str_starts_with($path, 'admin/') && config('buildadmin.auto_write_admin_log', true)) {
$method = $request->method();
if ($method === 'POST' || $method === 'DELETE') {
try {
AdminLogModel::instance($request)->record();
} catch (\Throwable $e) {
\support\Log::warning('[AdminLog] ' . $e->getMessage());
}
}
}
return $response;
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace app\common\middleware;
use Webman\MiddlewareInterface;
use Webman\Http\Request;
use Webman\Http\Response;
/**
* 跨域请求支持Webman 迁移版)
* 安全起见,只支持配置中的域名
*/
class AllowCrossDomain implements MiddlewareInterface
{
protected array $header = [
'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Max-Age' => '1800',
'Access-Control-Allow-Methods' => '*',
'Access-Control-Allow-Headers' => '*',
];
public function process(Request $request, callable $handler): Response
{
$path = trim($request->path(), '/');
if (!str_starts_with($path, 'api/') && !str_starts_with($path, 'admin/')) {
return $handler($request);
}
$header = $this->header;
$origin = $request->header('origin');
if ($origin) {
$info = parse_url($origin);
$corsDomain = explode(',', config('buildadmin.cors_request_domain', ''));
$corsDomain[] = $request->host(true);
if (
in_array('*', $corsDomain)
|| in_array($origin, $corsDomain)
|| (isset($info['host']) && in_array($info['host'], $corsDomain))
) {
$header['Access-Control-Allow-Origin'] = $origin;
}
}
if ($request->method() === 'OPTIONS') {
return response('', 204, $header);
}
$response = $handler($request);
return $response->withHeaders($header);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace app\common\middleware;
use Webman\MiddlewareInterface;
use Webman\Http\Request;
use Webman\Http\Response;
/**
* 加载控制器语言包中间件Webman 迁移版,等价 ThinkPHP LoadLangPack
* 根据当前路由加载对应控制器的语言包到 Translator
*/
class LoadLangPack implements MiddlewareInterface
{
public function process(Request $request, callable $handler): Response
{
$path = trim($request->path(), '/');
if (str_starts_with($path, 'api/') || str_starts_with($path, 'admin/')) {
$this->loadLang($request);
}
return $handler($request);
}
protected function loadLang(Request $request): void
{
$controllerPath = get_controller_path($request);
if (!$controllerPath) {
return;
}
$langSet = config('lang.default_lang', config('translation.locale', 'zh-cn'));
$langSet = str_replace('_', '-', strtolower($langSet));
$path = trim($request->path(), '/');
$parts = explode('/', $path);
$app = $parts[0] ?? 'api';
$langFile = base_path() . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . $app
. DIRECTORY_SEPARATOR . 'lang' . DIRECTORY_SEPARATOR . $langSet
. DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $controllerPath) . '.php';
if (is_file($langFile) && class_exists(\support\Translation::class)) {
$translator = \support\Translation::instance();
$translator->addResource('phpfile', $langFile, $langSet, $controllerPath);
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace app\common\model;
use support\think\Model;
use think\model\relation\BelongsTo;
class Attachment extends Model
{
protected string $table = 'attachment';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected bool $updateTime = false;
protected array $append = ['suffix', 'full_url'];
public function getSuffixAttr($value, $row): string
{
if ($row['name'] ?? '') {
$suffix = strtolower(pathinfo($row['name'], PATHINFO_EXTENSION));
return $suffix && preg_match("/^[a-zA-Z0-9]+$/", $suffix) ? $suffix : 'file';
}
return 'file';
}
public function getFullUrlAttr($value, $row): string
{
return full_url($row['url'] ?? '');
}
public function admin(): BelongsTo
{
return $this->belongsTo(\app\admin\model\Admin::class, 'admin_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace app\common\model;
use support\think\Model;
/**
* 会员公共模型
*/
class User extends Model
{
protected string $table = 'user';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
public function getAvatarAttr($value): string
{
return full_url($value, false, config('buildadmin.default_avatar'));
}
public function setAvatarAttr($value): string
{
return $value == full_url('', false, config('buildadmin.default_avatar')) ? '' : $value;
}
public function resetPassword($uid, $newPassword)
{
return $this->where(['id' => $uid])->update(['password' => hash_password($newPassword), 'salt' => '']);
}
public function getMoneyAttr($value): string
{
return bcdiv((string)$value, '100', 2);
}
public function setMoneyAttr($value): string
{
return bcmul((string)$value, '100', 2);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace app\common\model;
use support\think\Model;
class UserMoneyLog extends Model
{
protected string $table = 'user_money_log';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected bool $updateTime = false;
public function getMoneyAttr($value): string
{
return bcdiv((string) $value, '100', 2);
}
public function setMoneyAttr($value): string
{
return bcmul((string) $value, '100', 2);
}
public function getBeforeAttr($value): string
{
return bcdiv((string) $value, '100', 2);
}
public function setBeforeAttr($value): string
{
return bcmul((string) $value, '100', 2);
}
public function getAfterAttr($value): string
{
return bcdiv((string) $value, '100', 2);
}
public function setAfterAttr($value): string
{
return bcmul((string) $value, '100', 2);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace app\common\model;
use support\think\Model;
class UserScoreLog extends Model
{
protected string $table = 'user_score_log';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected bool $updateTime = false;
}

View File

@@ -0,0 +1,42 @@
<?php
namespace app\controller;
use support\Request;
class IndexController
{
public function index(Request $request)
{
return <<<EOF
<style>
* {
padding: 0;
margin: 0;
}
iframe {
border: none;
overflow: scroll;
}
</style>
<iframe
src="https://www.workerman.net/wellcome"
width="100%"
height="100%"
allow="clipboard-write"
sandbox="allow-scripts allow-same-origin allow-popups allow-downloads"
></iframe>
EOF;
}
public function view(Request $request)
{
return view('index/view', ['name' => 'webman']);
}
public function json(Request $request)
{
return json(['code' => 0, 'msg' => 'ok']);
}
}

View File

@@ -0,0 +1,638 @@
<?php
/**
* BuildAdmin Webman 公共函数
*/
use support\Response;
if (!function_exists('env')) {
/**
* 获取环境变量(兼容 dot 格式如 database.hostname
*/
function env(string $key, mixed $default = null): mixed
{
$value = $_ENV[$key] ?? getenv($key);
if ($value !== false && $value !== null) {
return $value;
}
if (strpos($key, '.') !== false) {
$parts = explode('.', $key);
$upper = strtoupper(implode('_', $parts));
$value = $_ENV[$upper] ?? getenv($upper);
if ($value !== false && $value !== null) {
return $value;
}
}
return $default;
}
}
if (!function_exists('__')) {
/**
* 语言翻译BuildAdmin 兼容)
*/
function __(string $name, array $vars = [], string $lang = ''): mixed
{
if (is_numeric($name) || !$name) {
return $name;
}
return function_exists('trans') ? trans($name, $vars, null, $lang ?: null) : $name;
}
}
use Symfony\Component\HttpFoundation\IpUtils;
if (!function_exists('get_sys_config')) {
/**
* 获取系统配置(从数据库或 config
* 需 Config 模型支持,否则从 config 读取
*/
function get_sys_config(string $name = '', string $group = '', bool $concise = true): mixed
{
if (class_exists(\app\admin\model\Config::class)) {
$configModel = \app\admin\model\Config::class;
if ($name) {
$config = $configModel::cache($name, null, $configModel::$cacheTag)->where('name', $name)->find();
return $config ? $config['value'] : null;
}
if ($group) {
$temp = $configModel::cache('group' . $group, null, $configModel::$cacheTag)->where('group', $group)->select()->toArray();
} else {
$temp = $configModel::cache('sys_config_all', null, $configModel::$cacheTag)->order('weigh desc')->select()->toArray();
}
if ($concise) {
$config = [];
foreach ($temp as $item) {
$config[$item['name']] = $item['value'];
}
return $config;
}
return $temp;
}
return config("sys_config.{$name}", null);
}
}
if (!function_exists('clear_config_cache')) {
/**
* 清理配置缓存Config 写入后调用)
*/
function clear_config_cache(): void
{
$cachePath = base_path() . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'cache';
if (!is_dir($cachePath)) {
return;
}
$files = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($cachePath, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($files as $file) {
if ($file->isFile()) {
@unlink($file->getRealPath());
}
}
}
}
if (!function_exists('ip_check')) {
/**
* IP 检查
* @param string|null $ip 要检查的 IPnull 时从 request 获取
* @param \Webman\Http\Request|null $request
* @return Response|null 禁止访问时返回 Response否则 null
*/
function ip_check(?string $ip = null, $request = null): ?Response
{
if ($ip === null && $request) {
$ip = $request->getRealIp();
}
if (!$ip) {
return null;
}
$noAccess = get_sys_config('no_access_ip');
$noAccess = !$noAccess ? [] : array_filter(explode("\n", str_replace("\r\n", "\n", (string) $noAccess)));
if ($noAccess && IpUtils::checkIp($ip, $noAccess)) {
return response(json_encode(['msg' => 'No permission request']), 403, ['Content-Type' => 'application/json']);
}
return null;
}
}
if (!function_exists('get_auth_token')) {
/**
* 获取鉴权 token
* @param array $names 如 ['ba', 'token']
* @param \Webman\Http\Request|null $request
*/
function get_auth_token(array $names = ['ba', 'token'], $request = null): string
{
$request = $request ?? (function_exists('request') ? request() : null);
if (!$request) {
return '';
}
$separators = [
'header' => ['', '-'],
'param' => ['', '-', '_'],
'server' => ['_'],
];
$tokens = [];
foreach ($separators as $source => $sps) {
foreach ($sps as $sp) {
$key = ($source === 'server' ? 'http_' : '') . implode($sp, $names);
if ($source === 'header') {
$val = $request->header($key);
} elseif ($source === 'param') {
$val = $request->get($key) ?? $request->post($key);
} else {
$val = $_SERVER[$key] ?? null;
}
if ($val) {
$tokens[] = $val;
}
}
}
return $tokens[0] ?? '';
}
}
if (!function_exists('get_controller_path')) {
/**
* 从 Request 或路由获取控制器路径(等价于 ThinkPHP controllerPath
* 优先从 $request->controllerWebman 路由匹配时设置)解析,否则从 path 解析
* @param \Webman\Http\Request|null $request
* @return string|null 如 auth/admin、user/user
*/
function get_controller_path($request = null): ?string
{
$request = $request ?? (function_exists('request') ? request() : null);
if (!$request) {
return null;
}
// 优先从路由匹配的 controller 解析Webman 在路由匹配后设置)
$controller = $request->controller ?? null;
if ($controller && is_string($controller)) {
foreach (['app\\admin\\controller\\', 'app\\api\\controller\\'] as $prefix) {
if (str_starts_with($controller, $prefix)) {
$relative = substr($controller, strlen($prefix));
$parts = explode('\\', $relative);
$path = [];
foreach ($parts as $p) {
$path[] = strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $p));
}
return implode('/', $path);
}
}
}
// 回退:从 path 解析(如 admin/auth/admin/index -> auth/admin
$path = trim($request->path(), '/');
if (!$path) {
return null;
}
$parts = explode('/', $path);
if (count($parts) < 2) {
return $parts[0] ?? null;
}
return implode('/', array_slice($parts, 1, -1)) ?: $parts[1];
}
}
if (!function_exists('action_in_arr')) {
/**
* 检测当前方法是否在数组中(用于 noNeedLogin、noNeedPermission
* @param array $arr
* @param string|null $action 当前 actionnull 时从 request path 解析
*/
function action_in_arr(array $arr, ?string $action = null): bool
{
if (!$arr) {
return false;
}
$arr = array_map('strtolower', $arr);
if (in_array('*', $arr)) {
return true;
}
if ($action === null && function_exists('request')) {
$req = request();
$path = trim($req->path(), '/');
$parts = explode('/', $path);
$action = $parts[array_key_last($parts)] ?? '';
}
return $action ? in_array(strtolower($action), $arr) : false;
}
}
if (!function_exists('event_trigger')) {
/**
* 触发事件BuildAdmin 兼容,替代 Event::trigger
* @param string $event
* @param mixed ...$args
*/
function event_trigger(string $event, mixed ...$args): void
{
$listeners = config("events.listen.{$event}", []);
foreach ($listeners as $listener) {
try {
if (is_string($listener) && class_exists($listener)) {
$instance = new $listener();
if (method_exists($instance, 'handle')) {
$instance->handle(...$args);
}
} elseif (is_callable($listener)) {
$listener(...$args);
}
} catch (\Throwable $e) {
if (class_exists(\support\Log::class)) {
\support\Log::warning("[event_trigger] {$event}: " . $e->getMessage());
}
}
}
}
}
if (!function_exists('set_timezone')) {
/**
* 设置时区
*/
function set_timezone(?string $timezone = null): void
{
$defaultTimezone = config('app.default_timezone', 'Asia/Shanghai');
$timezone = $timezone ?? get_sys_config('time_zone');
if ($timezone && $defaultTimezone !== $timezone) {
date_default_timezone_set($timezone);
}
}
}
if (!function_exists('encrypt_password')) {
/**
* 加密密码(兼容旧版 md5
* @deprecated 使用 hash_password 代替
*/
function encrypt_password(string $password, string $salt = '', string $encrypt = 'md5'): string
{
return $encrypt($encrypt($password) . $salt);
}
}
if (!function_exists('hash_password')) {
/**
* 创建密码散列hash
*/
function hash_password(string $password): string
{
return password_hash($password, PASSWORD_DEFAULT);
}
}
if (!function_exists('verify_password')) {
/**
* 验证密码是否和散列值匹配
*/
function verify_password(string $password, string $hash, array $extend = []): bool
{
if (str_starts_with($hash, '$') || password_get_info($hash)['algoName'] !== 'unknown') {
return password_verify($password, $hash);
}
return encrypt_password($password, $extend['salt'] ?? '') === $hash;
}
}
if (!function_exists('full_url')) {
/**
* 获取资源完整 url 地址
*/
function full_url(string $relativeUrl = '', string|bool $domain = true, string $default = ''): string
{
$cdnUrl = config('buildadmin.cdn_url');
if (!$cdnUrl && function_exists('request')) {
$req = request();
$cdnUrl = $req ? '//' . $req->host() : '//localhost';
} elseif (!$cdnUrl) {
$cdnUrl = '//localhost';
}
if ($domain === true) {
$domain = $cdnUrl;
} elseif ($domain === false) {
$domain = '';
}
$relativeUrl = $relativeUrl ?: $default;
if (!$relativeUrl) {
return $domain;
}
$regex = "/^((?:[a-z]+:)?\/\/|data:image\/)(.*)/i";
if (preg_match('/^http(s)?:\/\//', $relativeUrl) || preg_match($regex, $relativeUrl) || $domain === false) {
return $relativeUrl;
}
$url = $domain . $relativeUrl;
$cdnUrlParams = config('buildadmin.cdn_url_params');
if ($domain === $cdnUrl && $cdnUrlParams) {
$separator = str_contains($url, '?') ? '&' : '?';
$url .= $separator . $cdnUrlParams;
}
return $url;
}
}
if (!function_exists('parse_name')) {
/**
* 命名转换ThinkPHP 兼容)
* @param string $name 名称
* @param int $type 0=转小写+下划线 1=驼峰 2=首字母大写驼峰
* @param string $delimiter 分隔符
*/
function parse_name(string $name, int $type = 0, string $delimiter = '_'): string
{
if ($type === 0) {
return strtolower(preg_replace('/([A-Z])/', $delimiter . '$1', lcfirst($name)));
}
if ($type === 1) {
$name = str_replace($delimiter, ' ', $name);
return lcfirst(str_replace(' ', '', ucwords($name)));
}
if ($type === 2) {
$name = str_replace($delimiter, ' ', $name);
return str_replace(' ', '', ucwords($name));
}
return $name;
}
}
if (!function_exists('root_path')) {
/**
* 根路径BuildAdmin 兼容,等价于 base_path
* @param string $path 子路径
*/
function root_path(string $path = ''): string
{
return base_path($path);
}
}
if (!function_exists('app_path')) {
/**
* 应用目录路径Webman 兼容,用于 CRUD Helper 等)
* @param string $path 子路径
*/
function app_path(string $path = ''): string
{
$base = rtrim(base_path(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'app';
return $path ? $base . DIRECTORY_SEPARATOR . ltrim(str_replace('/', DIRECTORY_SEPARATOR, $path), DIRECTORY_SEPARATOR) : $base;
}
}
if (!function_exists('get_controller_list')) {
/**
* 获取控制器文件列表(递归)
* @param string $app 应用名,默认 admin
*/
function get_controller_list(string $app = 'admin'): array
{
$controllerDir = root_path() . 'app' . DIRECTORY_SEPARATOR . $app . DIRECTORY_SEPARATOR . 'controller' . DIRECTORY_SEPARATOR;
return class_exists(\ba\Filesystem::class)
? \ba\Filesystem::getDirFiles($controllerDir)
: [];
}
}
if (!function_exists('get_route_remark')) {
/**
* 获取当前路由后台菜单规则的备注信息
*/
function get_route_remark(): string
{
$controllerPath = get_controller_path() ?? '';
$actionName = '';
if (function_exists('request')) {
$req = request();
if ($req) {
$path = trim($req->path(), '/');
$parts = explode('/', $path);
$actionName = $parts[array_key_last($parts)] ?? '';
}
}
$path = str_replace('.', '/', $controllerPath);
$names = [$path];
if ($actionName) {
$names[] = $path . '/' . $actionName;
}
$remark = \support\think\Db::name('admin_rule')
->where('name', 'in', $names)
->value('remark');
return __((string) ($remark ?? ''));
}
}
if (!function_exists('keys_to_camel_case')) {
function keys_to_camel_case(array $array, array $keys = []): array
{
$result = [];
foreach ($array as $key => $value) {
$camelCaseKey = ($keys && in_array($key, $keys)) ? parse_name($key, 1, '_') : $key;
if (is_array($value)) {
$result[$camelCaseKey] = keys_to_camel_case($value);
} else {
$result[$camelCaseKey] = $value;
}
}
return $result;
}
}
if (!function_exists('get_upload_config')) {
function get_upload_config($request = null): array
{
event_trigger('uploadConfigInit', null);
$uploadConfig = config('upload', []);
$uploadConfig['max_size'] = \ba\Filesystem::fileUnitToByte($uploadConfig['max_size'] ?? '10M');
$request = $request ?? (function_exists('request') ? request() : null);
$upload = $request && isset($request->upload) ? $request->upload : null;
if (!$upload) {
$uploadConfig['mode'] = 'local';
return $uploadConfig;
}
unset($upload['cdn']);
return array_merge($upload, $uploadConfig);
}
}
if (!function_exists('filter')) {
function filter(string $string): string
{
$string = trim($string);
$string = strip_tags($string);
return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8');
}
}
if (!function_exists('clean_xss')) {
function clean_xss(string $string): string
{
if (class_exists(\voku\helper\AntiXSS::class)) {
$antiXss = new \voku\helper\AntiXSS();
$antiXss->removeEvilAttributes(['style']);
$antiXss->setReplacement('cleanXss');
return $antiXss->xss_clean($string);
}
return strip_tags($string);
}
}
if (!function_exists('htmlspecialchars_decode_improve')) {
function htmlspecialchars_decode_improve(string $string, int $flags = ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401): string
{
return htmlspecialchars_decode($string, $flags);
}
}
if (!function_exists('get_ba_client')) {
/**
* 获取请求 BuildAdmin 开源社区的 Guzzle Client用于云端 CRUD 历史等)
*/
function get_ba_client(): \GuzzleHttp\Client
{
return new \GuzzleHttp\Client([
'base_uri' => config('buildadmin.api_url', 'https://api.buildadmin.com'),
'timeout' => 30,
'connect_timeout' => 30,
'verify' => false,
'http_errors' => false,
'headers' => [
'X-REQUESTED-WITH' => 'XMLHttpRequest',
'User-Agent' => 'BuildAdminClient',
]
]);
}
}
if (!function_exists('str_attr_to_array')) {
function str_attr_to_array(string $attr): array
{
if (!$attr) return [];
$attr = explode("\n", trim(str_replace("\r\n", "\n", $attr)));
$attrTemp = [];
foreach ($attr as $item) {
$item = explode('=', $item);
if (isset($item[0]) && isset($item[1])) {
$attrVal = $item[1];
if ($item[1] === 'false' || $item[1] === 'true') {
$attrVal = !($item[1] === 'false');
} elseif (is_numeric($item[1])) {
$attrVal = (float)$item[1];
}
if (strpos($item[0], '.') !== false) {
$attrKey = explode('.', $item[0]);
if (isset($attrKey[0]) && isset($attrKey[1])) {
$attrTemp[$attrKey[0]][$attrKey[1]] = $attrVal;
continue;
}
}
$attrTemp[$item[0]] = $attrVal;
}
}
return $attrTemp;
}
}
if (!function_exists('hsv2rgb')) {
function hsv2rgb($h, $s, $v): array
{
$r = $g = $b = 0;
$i = floor($h * 6);
$f = $h * 6 - $i;
$p = $v * (1 - $s);
$q = $v * (1 - $f * $s);
$t = $v * (1 - (1 - $f) * $s);
switch ($i % 6) {
case 0: $r = $v; $g = $t; $b = $p; break;
case 1: $r = $q; $g = $v; $b = $p; break;
case 2: $r = $p; $g = $v; $b = $t; break;
case 3: $r = $p; $g = $q; $b = $v; break;
case 4: $r = $t; $g = $p; $b = $v; break;
case 5: $r = $v; $g = $p; $b = $q; break;
}
return [floor($r * 255), floor($g * 255), floor($b * 255)];
}
}
if (!function_exists('build_suffix_svg')) {
function build_suffix_svg(string $suffix = 'file', ?string $background = null): string
{
$suffix = mb_substr(strtoupper($suffix), 0, 4);
$total = unpack('L', hash('adler32', $suffix, true))[1];
$hue = $total % 360;
[$r, $g, $b] = hsv2rgb($hue / 360, 0.3, 0.9);
$background = $background ?: "rgb($r,$g,$b)";
return '<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<path style="fill:#E2E5E7;" d="M128,0c-17.6,0-32,14.4-32,32v448c0,17.6,14.4,32,32,32h320c17.6,0,32-14.4,32-32V128L352,0H128z"/>
<path style="fill:#B0B7BD;" d="M384,128h96L352,0v96C352,113.6,366.4,128,384,128z"/>
<polygon style="fill:#CAD1D8;" points="480,224 384,128 480,128 "/>
<path style="fill:' . $background . ';" d="M416,416c0,8.8-7.2,16-16,16H48c-8.8,0-16-7.2-16-16V256c0-8.8,7.2-16,16-16h352c8.8,0,16,7.2,16,16 V416z"/>
<path style="fill:#CAD1D8;" d="M400,432H96v16h304c8.8,0,16-7.2,16-16v-16C416,424.8,408.8,432,400,432z"/>
<g><text><tspan x="220" y="380" font-size="124" font-family="Verdana, Helvetica, Arial, sans-serif" fill="white" text-anchor="middle">' . $suffix . '</tspan></text></g>
</svg>';
}
}
if (!function_exists('get_account_verification_type')) {
/**
* 获取可用的账户验证方式
* @return string[] email=电子邮件,mobile=手机短信验证
*/
function get_account_verification_type(): array
{
$types = [];
$sysMailConfig = get_sys_config('', 'mail');
$configured = true;
if (is_array($sysMailConfig)) {
foreach ($sysMailConfig as $item) {
if (!$item) {
$configured = false;
break;
}
}
} else {
$configured = false;
}
if ($configured) {
$types[] = 'email';
}
if (class_exists(\app\admin\library\module\Server::class)) {
$sms = \app\admin\library\module\Server::getIni(\ba\Filesystem::fsFit(root_path() . 'modules/sms/'));
if ($sms && ($sms['state'] ?? 0) == 1) {
$types[] = 'mobile';
}
}
return $types;
}
}
if (!function_exists('get_area')) {
function get_area($request = null): array
{
$request = $request ?? (function_exists('request') ? request() : null);
$province = $request ? $request->get('province', '') : '';
$city = $request ? $request->get('city', '') : '';
$where = ['pid' => 0, 'level' => 1];
if ($province !== '') {
$where['pid'] = $province;
$where['level'] = 2;
if ($city !== '') {
$where['pid'] = $city;
$where['level'] = 3;
}
}
return \support\think\Db::name('area')
->where($where)
->field('id as value,name as label')
->select()
->toArray();
}
}

View File

@@ -0,0 +1,42 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
namespace app\middleware;
use Webman\MiddlewareInterface;
use Webman\Http\Response;
use Webman\Http\Request;
/**
* Class StaticFile
* @package app\middleware
*/
class StaticFile implements MiddlewareInterface
{
public function process(Request $request, callable $handler): Response
{
// Access to files beginning with. Is prohibited
if (strpos($request->path(), '/.') !== false) {
return response('<h1>403 forbidden</h1>', 403);
}
/** @var Response $response */
$response = $handler($request);
// Add cross domain HTTP header
/*$response->withHeaders([
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Credentials' => 'true',
]);*/
return $response;
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace app\model;
use support\Model;
class Test extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'test';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = false;
}

View File

@@ -0,0 +1,10 @@
<?php
namespace app\process;
use Webman\App;
class Http extends App
{
}

View File

@@ -0,0 +1,305 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
namespace app\process;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
use Workerman\Timer;
use Workerman\Worker;
/**
* Class FileMonitor
* @package process
*/
class Monitor
{
/**
* @var array
*/
protected array $paths = [];
/**
* @var array
*/
protected array $extensions = [];
/**
* @var array
*/
protected array $loadedFiles = [];
/**
* @var int
*/
protected int $ppid = 0;
/**
* Pause monitor
* @return void
*/
public static function pause(): void
{
file_put_contents(static::lockFile(), time());
}
/**
* Resume monitor
* @return void
*/
public static function resume(): void
{
clearstatcache();
if (is_file(static::lockFile())) {
unlink(static::lockFile());
}
}
/**
* Whether monitor is paused
* @return bool
*/
public static function isPaused(): bool
{
clearstatcache();
return file_exists(static::lockFile());
}
/**
* Lock file
* @return string
*/
protected static function lockFile(): string
{
return runtime_path('monitor.lock');
}
/**
* FileMonitor constructor.
* @param $monitorDir
* @param $monitorExtensions
* @param array $options
*/
public function __construct($monitorDir, $monitorExtensions, array $options = [])
{
$this->ppid = function_exists('posix_getppid') ? posix_getppid() : 0;
static::resume();
$this->paths = (array)$monitorDir;
$this->extensions = $monitorExtensions;
foreach (get_included_files() as $index => $file) {
$this->loadedFiles[$file] = $index;
if (strpos($file, 'webman-framework/src/support/App.php')) {
break;
}
}
if (!Worker::getAllWorkers()) {
return;
}
$disableFunctions = explode(',', ini_get('disable_functions'));
if (in_array('exec', $disableFunctions, true)) {
echo "\nMonitor file change turned off because exec() has been disabled by disable_functions setting in " . PHP_CONFIG_FILE_PATH . "/php.ini\n";
} else {
if ($options['enable_file_monitor'] ?? true) {
Timer::add(1, function () {
$this->checkAllFilesChange();
});
}
}
$memoryLimit = $this->getMemoryLimit($options['memory_limit'] ?? null);
if ($memoryLimit && ($options['enable_memory_monitor'] ?? true)) {
Timer::add(60, [$this, 'checkMemory'], [$memoryLimit]);
}
}
/**
* @param $monitorDir
* @return bool
*/
public function checkFilesChange($monitorDir): bool
{
static $lastMtime, $tooManyFilesCheck;
if (!$lastMtime) {
$lastMtime = time();
}
clearstatcache();
if (!is_dir($monitorDir)) {
if (!is_file($monitorDir)) {
return false;
}
$iterator = [new SplFileInfo($monitorDir)];
} else {
// recursive traversal directory
$dirIterator = new RecursiveDirectoryIterator($monitorDir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS);
$iterator = new RecursiveIteratorIterator($dirIterator);
}
$count = 0;
foreach ($iterator as $file) {
$count ++;
/** @var SplFileInfo $file */
if (is_dir($file->getRealPath())) {
continue;
}
// check mtime
if (in_array($file->getExtension(), $this->extensions, true) && $lastMtime < $file->getMTime()) {
$lastMtime = $file->getMTime();
if (DIRECTORY_SEPARATOR === '/' && isset($this->loadedFiles[$file->getRealPath()])) {
echo "$file updated but cannot be reloaded because only auto-loaded files support reload.\n";
continue;
}
$var = 0;
exec('"'.PHP_BINARY . '" -l ' . $file, $out, $var);
if ($var) {
continue;
}
// send SIGUSR1 signal to master process for reload
if (DIRECTORY_SEPARATOR === '/') {
if ($masterPid = $this->getMasterPid()) {
echo $file . " updated and reload\n";
posix_kill($masterPid, SIGUSR1);
} else {
echo "Master process has gone away and can not reload\n";
}
return true;
}
echo $file . " updated and reload\n";
return true;
}
}
if (!$tooManyFilesCheck && $count > 1000) {
echo "Monitor: There are too many files ($count files) in $monitorDir which makes file monitoring very slow\n";
$tooManyFilesCheck = 1;
}
return false;
}
/**
* @return int
*/
public function getMasterPid(): int
{
if ($this->ppid === 0) {
return 0;
}
if (function_exists('posix_kill') && !posix_kill($this->ppid, 0)) {
echo "Master process has gone away\n";
return $this->ppid = 0;
}
if (PHP_OS_FAMILY !== 'Linux') {
return $this->ppid;
}
$cmdline = "/proc/$this->ppid/cmdline";
if (!is_readable($cmdline) || !($content = file_get_contents($cmdline)) || (!str_contains($content, 'WorkerMan') && !str_contains($content, 'php'))) {
// Process not exist
$this->ppid = 0;
}
return $this->ppid;
}
/**
* @return bool
*/
public function checkAllFilesChange(): bool
{
if (static::isPaused()) {
return false;
}
foreach ($this->paths as $path) {
if ($this->checkFilesChange($path)) {
return true;
}
}
return false;
}
/**
* @param $memoryLimit
* @return void
*/
public function checkMemory($memoryLimit): void
{
if (static::isPaused() || $memoryLimit <= 0) {
return;
}
$masterPid = $this->getMasterPid();
if ($masterPid <= 0) {
echo "Master process has gone away\n";
return;
}
$childrenFile = "/proc/$masterPid/task/$masterPid/children";
if (!is_file($childrenFile) || !($children = file_get_contents($childrenFile))) {
return;
}
foreach (explode(' ', $children) as $pid) {
$pid = (int)$pid;
$statusFile = "/proc/$pid/status";
if (!is_file($statusFile) || !($status = file_get_contents($statusFile))) {
continue;
}
$mem = 0;
if (preg_match('/VmRSS\s*?:\s*?(\d+?)\s*?kB/', $status, $match)) {
$mem = $match[1];
}
$mem = (int)($mem / 1024);
if ($mem >= $memoryLimit) {
posix_kill($pid, SIGINT);
}
}
}
/**
* Get memory limit
* @param $memoryLimit
* @return int
*/
protected function getMemoryLimit($memoryLimit): int
{
if ($memoryLimit === 0) {
return 0;
}
$usePhpIni = false;
if (!$memoryLimit) {
$memoryLimit = ini_get('memory_limit');
$usePhpIni = true;
}
if ($memoryLimit == -1) {
return 0;
}
$unit = strtolower($memoryLimit[strlen($memoryLimit) - 1]);
$memoryLimit = (int)$memoryLimit;
if ($unit === 'g') {
$memoryLimit = 1024 * $memoryLimit;
} else if ($unit === 'k') {
$memoryLimit = ($memoryLimit / 1024);
} else if ($unit === 'm') {
$memoryLimit = (int)($memoryLimit);
} else if ($unit === 't') {
$memoryLimit = (1024 * 1024 * $memoryLimit);
} else {
$memoryLimit = ($memoryLimit / (1024 * 1024));
}
if ($memoryLimit < 50) {
$memoryLimit = 50;
}
if ($usePhpIni) {
$memoryLimit = (0.8 * $memoryLimit);
}
return (int)$memoryLimit;
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace app\support;
use support\Response;
use Webman\Http\Request as WebmanRequest;
use function response;
/**
* 控制器基础类
* - 适配 Webman Request 注入子类方法签名public function xxx(Webman\Http\Request $request)
* - 移除对 think\App 的依赖,用注入的 $request 替代 $this->app->request
* - 子类在方法开头调用 setRequest($request),之后可用 $this->request 访问
*/
abstract class BaseController
{
/**
* 当前请求实例(由 setRequest($request) 设置,来源于方法参数 $request非 think\App
* @var WebmanRequest|null
*/
protected ?WebmanRequest $request = null;
/**
* 是否批量验证
* @var bool
*/
protected bool $batchValidate = false;
/**
* 初始化
* @access protected
*/
protected function initialize(): void
{
}
/**
* 设置当前请求(子类方法开头调用)
* @param WebmanRequest $request
*/
protected function setRequest(WebmanRequest $request): void
{
$this->request = $request;
}
/**
* 操作成功(封装为返回 Response 的方法,替代原 $this->success() 抛异常)
* @param string $msg 提示消息
* @param mixed $data 返回数据
* @param int $code 业务状态码1=成功)
* @param array $header 可含 statusCode 指定 HTTP 状态
*/
protected function success(string $msg = '', mixed $data = null, int $code = 1, array $header = []): Response
{
return $this->result($msg, $data, $code, $header);
}
/**
* 操作失败(封装为返回 Response 的方法,替代原 $this->error() 抛异常)
* @param string $msg 提示消息
* @param mixed $data 返回数据
* @param int $code 业务状态码0=失败)
* @param array $header 可含 statusCode 指定 HTTP 状态(如 401、409
*/
protected function error(string $msg = '', mixed $data = null, int $code = 0, array $header = []): Response
{
return $this->result($msg, $data, $code, $header);
}
/**
* 返回 API 数据BuildAdmin 格式code, msg, time, data
* 使用 response() 生成 JSON Response
*/
protected function result(string $msg, mixed $data = null, int $code = 0, array $header = []): Response
{
$body = [
'code' => $code,
'msg' => $msg,
'time' => time(),
'data' => $data,
];
$statusCode = $header['statusCode'] ?? 200;
unset($header['statusCode']);
$headers = array_merge(['Content-Type' => 'application/json'], $header);
$jsonBody = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return response($jsonBody, $statusCode, $headers);
}
}

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="/favicon.ico"/>
<title>webman</title>
</head>
<body>
hello <?=htmlspecialchars($name)?>
</body>
</html>

View File

@@ -0,0 +1,76 @@
{
"name": "workerman/webman",
"type": "project",
"keywords": [
"high performance",
"http service"
],
"homepage": "https://www.workerman.net",
"license": "MIT",
"description": "High performance HTTP Service Framework.",
"authors": [
{
"name": "walkor",
"email": "walkor@workerman.net",
"homepage": "https://www.workerman.net",
"role": "Developer"
}
],
"support": {
"email": "walkor@workerman.net",
"issues": "https://github.com/walkor/webman/issues",
"forum": "https://wenda.workerman.net/",
"wiki": "https://workerman.net/doc/webman",
"source": "https://github.com/walkor/webman"
},
"require": {
"php": ">=8.1",
"vlucas/phpdotenv": "^5.6",
"workerman/webman-framework": "^2.1",
"monolog/monolog": "^2.0",
"webman/console": "^2.2",
"webman/database": "^2.1",
"webman/think-orm": "^2.1",
"illuminate/pagination": "^12.53",
"illuminate/events": "^12.53",
"symfony/var-dumper": "^7.4",
"webman/redis": "^2.1",
"webman/validation": "^2.2",
"robmorgan/phinx": "^0.15",
"nelexa/zip": "^4.0.0",
"voku/anti-xss": "^4.1",
"topthink/think-validate": "^3.0"
},
"suggest": {
"ext-event": "For better performance. "
},
"autoload": {
"psr-4": {
"": "./",
"app\\": "./app",
"App\\": "./app",
"app\\View\\Components\\": "./app/view/components",
"ba\\": "./extend/ba/",
"modules\\": "./modules/"
}
},
"scripts": {
"post-package-install": [
"support\\Plugin::install"
],
"post-package-update": [
"support\\Plugin::install"
],
"pre-package-uninstall": [
"support\\Plugin::uninstall"
],
"post-create-project-cmd": [
"support\\Setup::run"
],
"setup-webman": [
"support\\Setup::run"
]
},
"minimum-stability": "dev",
"prefer-stable": true
}

View File

@@ -0,0 +1,26 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use support\Request;
return [
'debug' => true,
'error_reporting' => E_ALL,
'default_timezone' => 'Asia/Shanghai',
'request_class' => Request::class,
'public_path' => base_path() . DIRECTORY_SEPARATOR . 'public',
'runtime_path' => base_path(false) . DIRECTORY_SEPARATOR . 'runtime',
'controller_suffix' => 'Controller',
'controller_reuse' => false,
];

View File

@@ -0,0 +1,21 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
'files' => [
base_path() . '/app/functions.php',
base_path() . '/support/Request.php',
base_path() . '/support/Response.php',
]
];

View File

@@ -0,0 +1,20 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
support\bootstrap\Session::class,
Webman\ThinkOrm\ThinkOrm::class,
support\bootstrap\ValidateInit::class,
support\bootstrap\ModuleInit::class,
];

View File

@@ -0,0 +1,86 @@
<?php
// +----------------------------------------------------------------------
// | BuildAdmin设置
// +----------------------------------------------------------------------
return [
// 允许跨域访问的域名
'cors_request_domain' => 'localhost,127.0.0.1',
// 是否开启会员登录验证码
'user_login_captcha' => true,
// 是否开启管理员登录验证码
'admin_login_captcha' => true,
// 会员登录失败可重试次数,false则无限
'user_login_retry' => 10,
// 管理员登录失败可重试次数,false则无限
'admin_login_retry' => 10,
// 开启管理员单处登录它处失效
'admin_sso' => false,
// 开启会员单处登录它处失效
'user_sso' => false,
// 会员登录态保持时间非刷新token3天
'user_token_keep_time' => 60 * 60 * 24 * 3,
// 管理员登录态保持时间非刷新token3天
'admin_token_keep_time' => 60 * 60 * 24 * 3,
// 开启前台会员中心
'open_member_center' => true,
// 模块纯净安装(安装时移动模块文件而不是复制)
'module_pure_install' => true,
// 点选验证码配置
'click_captcha' => [
// 模式:text=文字,icon=图标(若只有icon则适用于国际化站点)
'mode' => ['text', 'icon'],
// 长度
'length' => 2,
// 混淆点长度
'confuse_length' => 2,
],
// 代理服务器IPRequest 类将尝试获取这些代理服务器发送过来的真实IP
'proxy_server_ip' => [],
// Token 配置
'token' => [
// 默认驱动方式
'default' => 'mysql',
// 加密key
'key' => 'tcbDgmqLVzuAdNH39o0QnhOisvSCFZ7I',
// 加密方式
'algo' => 'ripemd160',
// 驱动
'stores' => [
'mysql' => [
'type' => 'Mysql',
// 留空表示使用默认的 Mysql 数据库,也可以填写其他数据库连接配置的`name`
'name' => '',
// 存储token的表名
'table' => 'token',
// 默认 token 有效时间
'expire' => 2592000,
],
'redis' => [
'type' => 'Redis',
'host' => '127.0.0.1',
'port' => 6379,
'password' => '',
// Db索引非 0 以避免数据被意外清理
'select' => 1,
'timeout' => 0,
// 默认 token 有效时间
'expire' => 2592000,
'persistent' => false,
'prefix' => 'tk:',
],
]
],
// 自动写入管理员操作日志
'auto_write_admin_log' => true,
// 缺省头像图片路径
'default_avatar' => '/static/images/avatar.png',
// 内容分发网络URL末尾不带`/`
'cdn_url' => '',
// 内容分发网络URL参数将自动添加 `?`,之后拼接到 cdn_url 的结尾(例如 `imageMogr2/format/heif`
'cdn_url_params' => '',
// 版本号
'version' => 'v2.3.6',
// 中心接口地址(用于请求模块市场的数据等用途)
'api_url' => 'https://api.buildadmin.com',
];

View File

@@ -0,0 +1,37 @@
<?php
// +----------------------------------------------------------------------
// | 缓存设置
// +----------------------------------------------------------------------
$env = function (string $dotKey, $default = null) {
$upperKey = strtoupper(str_replace('.', '_', $dotKey));
$value = env($dotKey, null);
if ($value === null) {
$value = env($upperKey, $default);
}
return $value ?? $default;
};
return [
// 默认缓存驱动
'default' => $env('cache.driver', 'file'),
// 缓存连接方式配置
'stores' => [
'file' => [
// 驱动方式
'type' => 'File',
// 缓存保存目录
'path' => base_path() . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'cache',
// 缓存前缀
'prefix' => '',
// 缓存有效期 0表示永久缓存
'expire' => 0,
// 缓存标签前缀
'tag_prefix' => 'tag:',
// 序列化机制 例如 ['serialize', 'unserialize']
'serialize' => [],
],
// 更多的缓存连接
],
];

View File

@@ -0,0 +1,15 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return new Webman\Container;

View File

@@ -0,0 +1,29 @@
<?php
return [
'default' => 'mysql',
'connections' => [
'mysql' => [
'driver' => 'mysql',
'host' => '127.0.0.1',
'port' => '3306',
'database' => 'your_database',
'username' => 'your_username',
'password' => 'your_password',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_general_ci',
'prefix' => '',
'strict' => true,
'engine' => null,
'options' => [
PDO::ATTR_EMULATE_PREPARES => false, // Must be false for Swoole and Swow drivers.
],
'pool' => [
'max_connections' => 5,
'min_connections' => 1,
'wait_timeout' => 3,
'idle_timeout' => 60,
'heartbeat_interval' => 50,
],
],
],
];

View File

@@ -0,0 +1,15 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [];

View File

@@ -0,0 +1,11 @@
<?php
/**
* 事件监听BuildAdmin 兼容 Event::trigger
*/
return [
'listen' => [
'backendInit' => [
\app\common\event\Security::class, // 数据回收、敏感数据记录
],
],
];

View File

@@ -0,0 +1,17 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
'' => support\exception\Handler::class,
];

View File

@@ -0,0 +1,20 @@
<?php
/**
* 文件系统配置BuildAdmin Upload 所需)
*/
return [
'default' => env('filesystem.driver', 'local'),
'disks' => [
'local' => [
'type' => 'local',
'root' => rtrim(runtime_path(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'storage',
],
'public' => [
'type' => 'local',
'root' => rtrim(base_path(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'public',
'url' => '/storage',
'visibility' => 'public',
],
],
];

View File

@@ -0,0 +1,8 @@
<?php
/**
* 多语言设置BuildAdmin 兼容)
*/
return [
'default_lang' => env('LANG_DEFAULT_LANG', 'zh-cn'),
'allow_lang_list' => ['zh-cn', 'en'],
];

View File

@@ -0,0 +1,32 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
'default' => [
'handlers' => [
[
'class' => Monolog\Handler\RotatingFileHandler::class,
'constructor' => [
runtime_path() . '/logs/webman.log',
7, //$maxFiles
Monolog\Logger::DEBUG,
],
'formatter' => [
'class' => Monolog\Formatter\LineFormatter::class,
'constructor' => [null, 'Y-m-d H:i:s', true],
],
]
],
],
];

View File

@@ -0,0 +1,26 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
/**
* 中间件配置
* '' 为全局中间件,按顺序执行
* 原 ThinkPHP api/admin 中间件迁移至此
*/
return [
'' => [
\app\common\middleware\AllowCrossDomain::class,
\app\common\middleware\LoadLangPack::class,
\app\common\middleware\AdminLog::class,
],
];

View File

@@ -0,0 +1,28 @@
<?php
return [
'enable' => true,
'build_dir' => BASE_PATH . DIRECTORY_SEPARATOR . 'build',
'phar_filename' => 'webman.phar',
'phar_format' => Phar::PHAR, // Phar archive format: Phar::PHAR, Phar::TAR, Phar::ZIP
'phar_compression' => Phar::NONE, // Compression method for Phar archive: Phar::NONE, Phar::GZ, Phar::BZ2
'bin_filename' => 'webman.bin',
'signature_algorithm'=> Phar::SHA256, //set the signature algorithm for a phar and apply it. The signature algorithm must be one of Phar::MD5, Phar::SHA1, Phar::SHA256, Phar::SHA512, or Phar::OPENSSL.
'private_key_file' => '', // The file path for certificate or OpenSSL private key file.
'exclude_pattern' => '#^(?!.*(composer.json|/.github/|/.idea/|/.git/|/.setting/|/runtime/|/vendor-bin/|/build/|/vendor/webman/admin/))(.*)$#',
'exclude_files' => [
'.env', 'LICENSE', 'composer.json', 'composer.lock', 'start.php', 'webman.phar', 'webman.bin'
],
'custom_ini' => '
memory_limit = 256M
',
];

View File

@@ -0,0 +1,8 @@
<?php
use support\validation\ValidationException;
return [
'enable' => true,
'exception' => ValidationException::class,
];

View File

@@ -0,0 +1,7 @@
<?php
use Webman\Validation\Command\MakeValidatorCommand;
return [
MakeValidatorCommand::class
];

View File

@@ -0,0 +1,9 @@
<?php
use Webman\Validation\Middleware;
return [
'@' => [
Middleware::class,
],
];

View File

@@ -0,0 +1,62 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use support\Log;
use support\Request;
use app\process\Http;
global $argv;
return [
'webman' => [
'handler' => Http::class,
'listen' => 'http://0.0.0.0:8787',
'count' => cpu_count() * 4,
'user' => '',
'group' => '',
'reusePort' => false,
'eventLoop' => '',
'context' => [],
'constructor' => [
'requestClass' => Request::class,
'logger' => Log::channel('default'),
'appPath' => app_path(),
'publicPath' => public_path()
]
],
// File update detection and automatic reload
'monitor' => [
'handler' => app\process\Monitor::class,
'reloadable' => false,
'constructor' => [
// Monitor these directories
'monitorDir' => array_merge([
app_path(),
config_path(),
base_path() . '/process',
base_path() . '/support',
base_path() . '/resource',
base_path() . '/.env',
], glob(base_path() . '/plugin/*/app'), glob(base_path() . '/plugin/*/config'), glob(base_path() . '/plugin/*/api')),
// Files with these suffixes will be monitored
'monitorExtensions' => [
'php', 'html', 'htm', 'env'
],
'options' => [
'enable_file_monitor' => !in_array('-d', $argv) && DIRECTORY_SEPARATOR === '/',
'enable_memory_monitor' => DIRECTORY_SEPARATOR === '/',
]
]
]
];

View File

@@ -0,0 +1,29 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
'default' => [
'password' => '',
'host' => '127.0.0.1',
'port' => 6379,
'database' => 0,
'pool' => [
'max_connections' => 5,
'min_connections' => 1,
'wait_timeout' => 3,
'idle_timeout' => 60,
'heartbeat_interval' => 50,
],
]
];

View File

@@ -0,0 +1,178 @@
<?php
/**
* BuildAdmin Webman 路由
* 根据 ThinkPHP pathinfo 规则(应用/控制器/方法)生成
* GET=只读 POST=写入 ANY=兼容多种请求
*/
use Webman\Route;
// ==================== API 路由 ====================
// api/index
Route::get('/api/index/index', [\app\api\controller\Index::class, 'index']);
// api/userGET 获取配置POST 登录/注册)
Route::match(['get', 'post'], '/api/user/checkIn', [\app\api\controller\User::class, 'checkIn']);
Route::post('/api/user/logout', [\app\api\controller\User::class, 'logout']);
// api/install安装流程多为 POST
Route::any('/api/install/terminal', [\app\api\controller\Install::class, 'terminal']);
Route::post('/api/install/changePackageManager', [\app\api\controller\Install::class, 'changePackageManager']);
Route::get('/api/install/envBaseCheck', [\app\api\controller\Install::class, 'envBaseCheck']);
Route::get('/api/install/envNpmCheck', [\app\api\controller\Install::class, 'envNpmCheck']);
Route::post('/api/install/testDatabase', [\app\api\controller\Install::class, 'testDatabase']);
Route::match(['get', 'post'], '/api/install/baseConfig', [\app\api\controller\Install::class, 'baseConfig']);
Route::post('/api/install/commandExecComplete', [\app\api\controller\Install::class, 'commandExecComplete']);
Route::post('/api/install/manualInstall', [\app\api\controller\Install::class, 'manualInstall']);
Route::post('/api/install/mvDist', [\app\api\controller\Install::class, 'mvDist']);
// api/common
Route::get('/api/common/captcha', [\app\api\controller\Common::class, 'captcha']);
Route::get('/api/common/clickCaptcha', [\app\api\controller\Common::class, 'clickCaptcha']);
Route::post('/api/common/checkClickCaptcha', [\app\api\controller\Common::class, 'checkClickCaptcha']);
Route::post('/api/common/refreshToken', [\app\api\controller\Common::class, 'refreshToken']);
// api/ajax
Route::post('/api/ajax/upload', [\app\api\controller\Ajax::class, 'upload']);
Route::get('/api/ajax/area', [\app\api\controller\Ajax::class, 'area']);
Route::get('/api/ajax/buildSuffixSvg', [\app\api\controller\Ajax::class, 'buildSuffixSvg']);
// api/account
Route::get('/api/account/overview', [\app\api\controller\Account::class, 'overview']);
Route::match(['get', 'post'], '/api/account/profile', [\app\api\controller\Account::class, 'profile']);
Route::get('/api/account/verification', [\app\api\controller\Account::class, 'verification']);
Route::post('/api/account/changeBind', [\app\api\controller\Account::class, 'changeBind']);
Route::match(['get', 'post'], '/api/account/changePassword', [\app\api\controller\Account::class, 'changePassword']);
Route::get('/api/account/integral', [\app\api\controller\Account::class, 'integral']);
Route::get('/api/account/balance', [\app\api\controller\Account::class, 'balance']);
Route::post('/api/account/retrievePassword', [\app\api\controller\Account::class, 'retrievePassword']);
// api/ems
Route::post('/api/ems/send', [\app\api\controller\Ems::class, 'send']);
// ==================== Admin 路由 ====================
// Admin 多为 JSON API前端可能用 GET 传参查列表、POST 提交表单,使用 any 确保兼容
// admin/index
Route::get('/admin/index/index', [\app\admin\controller\Index::class, 'index']);
Route::post('/admin/index/login', [\app\admin\controller\Index::class, 'login']);
Route::post('/admin/index/logout', [\app\admin\controller\Index::class, 'logout']);
// admin/dashboard
Route::get('/admin/dashboard/index', [\app\admin\controller\Dashboard::class, 'index']);
// admin/module
Route::get('/admin/module/index', [\app\admin\controller\Module::class, 'index']);
Route::get('/admin/module/state', [\app\admin\controller\Module::class, 'state']);
Route::post('/admin/module/install', [\app\admin\controller\Module::class, 'install']);
Route::post('/admin/module/dependentInstallComplete', [\app\admin\controller\Module::class, 'dependentInstallComplete']);
Route::post('/admin/module/changeState', [\app\admin\controller\Module::class, 'changeState']);
Route::post('/admin/module/uninstall', [\app\admin\controller\Module::class, 'uninstall']);
Route::post('/admin/module/upload', [\app\admin\controller\Module::class, 'upload']);
// admin/ajax
Route::post('/admin/ajax/upload', [\app\admin\controller\Ajax::class, 'upload']);
Route::get('/admin/ajax/area', [\app\admin\controller\Ajax::class, 'area']);
Route::get('/admin/ajax/buildSuffixSvg', [\app\admin\controller\Ajax::class, 'buildSuffixSvg']);
Route::get('/admin/ajax/getDatabaseConnectionList', [\app\admin\controller\Ajax::class, 'getDatabaseConnectionList']);
Route::get('/admin/ajax/getTablePk', [\app\admin\controller\Ajax::class, 'getTablePk']);
Route::get('/admin/ajax/getTableList', [\app\admin\controller\Ajax::class, 'getTableList']);
Route::get('/admin/ajax/getTableFieldList', [\app\admin\controller\Ajax::class, 'getTableFieldList']);
Route::post('/admin/ajax/changeTerminalConfig', [\app\admin\controller\Ajax::class, 'changeTerminalConfig']);
Route::post('/admin/ajax/clearCache', [\app\admin\controller\Ajax::class, 'clearCache']);
Route::any('/admin/ajax/terminal', [\app\admin\controller\Ajax::class, 'terminal']);
// admin/auth/admin
Route::get('/admin/auth/admin/index', [\app\admin\controller\auth\Admin::class, 'index']);
Route::post('/admin/auth/admin/add', [\app\admin\controller\auth\Admin::class, 'add']);
Route::post('/admin/auth/admin/edit', [\app\admin\controller\auth\Admin::class, 'edit']);
Route::post('/admin/auth/admin/del', [\app\admin\controller\auth\Admin::class, 'del']);
// admin/auth/group
Route::get('/admin/auth/group/index', [\app\admin\controller\auth\Group::class, 'index']);
Route::post('/admin/auth/group/add', [\app\admin\controller\auth\Group::class, 'add']);
Route::post('/admin/auth/group/edit', [\app\admin\controller\auth\Group::class, 'edit']);
Route::post('/admin/auth/group/del', [\app\admin\controller\auth\Group::class, 'del']);
Route::get('/admin/auth/group/select', [\app\admin\controller\auth\Group::class, 'select']);
// admin/auth/rule
Route::get('/admin/auth/rule/index', [\app\admin\controller\auth\Rule::class, 'index']);
Route::post('/admin/auth/rule/add', [\app\admin\controller\auth\Rule::class, 'add']);
Route::post('/admin/auth/rule/edit', [\app\admin\controller\auth\Rule::class, 'edit']);
Route::post('/admin/auth/rule/del', [\app\admin\controller\auth\Rule::class, 'del']);
Route::get('/admin/auth/rule/select', [\app\admin\controller\auth\Rule::class, 'select']);
// admin/auth/adminLog
Route::get('/admin/auth/adminLog/index', [\app\admin\controller\auth\AdminLog::class, 'index']);
// admin/user/user
Route::get('/admin/user/user/index', [\app\admin\controller\user\User::class, 'index']);
Route::post('/admin/user/user/add', [\app\admin\controller\user\User::class, 'add']);
Route::post('/admin/user/user/edit', [\app\admin\controller\user\User::class, 'edit']);
Route::get('/admin/user/user/select', [\app\admin\controller\user\User::class, 'select']);
// admin/user/group
Route::post('/admin/user/group/add', [\app\admin\controller\user\Group::class, 'add']);
Route::post('/admin/user/group/edit', [\app\admin\controller\user\Group::class, 'edit']);
// admin/user/rule
Route::get('/admin/user/rule/index', [\app\admin\controller\user\Rule::class, 'index']);
Route::post('/admin/user/rule/add', [\app\admin\controller\user\Rule::class, 'add']);
Route::post('/admin/user/rule/edit', [\app\admin\controller\user\Rule::class, 'edit']);
Route::post('/admin/user/rule/del', [\app\admin\controller\user\Rule::class, 'del']);
Route::get('/admin/user/rule/select', [\app\admin\controller\user\Rule::class, 'select']);
// admin/user/scoreLog
Route::post('/admin/user/scoreLog/add', [\app\admin\controller\user\ScoreLog::class, 'add']);
// admin/user/moneyLog
Route::post('/admin/user/moneyLog/add', [\app\admin\controller\user\MoneyLog::class, 'add']);
// admin/routine/config
Route::get('/admin/routine/config/index', [\app\admin\controller\routine\Config::class, 'index']);
Route::post('/admin/routine/config/edit', [\app\admin\controller\routine\Config::class, 'edit']);
Route::post('/admin/routine/config/add', [\app\admin\controller\routine\Config::class, 'add']);
Route::post('/admin/routine/config/sendTestMail', [\app\admin\controller\routine\Config::class, 'sendTestMail']);
// admin/routine/adminInfo
Route::get('/admin/routine/adminInfo/index', [\app\admin\controller\routine\AdminInfo::class, 'index']);
Route::post('/admin/routine/adminInfo/edit', [\app\admin\controller\routine\AdminInfo::class, 'edit']);
// admin/routine/attachment
Route::post('/admin/routine/attachment/del', [\app\admin\controller\routine\Attachment::class, 'del']);
// admin/crud/crud
Route::post('/admin/crud/crud/generate', [\app\admin\controller\crud\Crud::class, 'generate']);
Route::post('/admin/crud/crud/logStart', [\app\admin\controller\crud\Crud::class, 'logStart']);
Route::post('/admin/crud/crud/delete', [\app\admin\controller\crud\Crud::class, 'delete']);
Route::get('/admin/crud/crud/getFileData', [\app\admin\controller\crud\Crud::class, 'getFileData']);
Route::get('/admin/crud/crud/checkCrudLog', [\app\admin\controller\crud\Crud::class, 'checkCrudLog']);
Route::post('/admin/crud/crud/parseFieldData', [\app\admin\controller\crud\Crud::class, 'parseFieldData']);
Route::post('/admin/crud/crud/generateCheck', [\app\admin\controller\crud\Crud::class, 'generateCheck']);
Route::post('/admin/crud/crud/uploadCompleted', [\app\admin\controller\crud\Crud::class, 'uploadCompleted']);
// admin/crud/log
Route::get('/admin/crud/log/index', [\app\admin\controller\crud\Log::class, 'index']);
// admin/security/sensitiveData
Route::get('/admin/security/sensitiveData/index', [\app\admin\controller\security\SensitiveData::class, 'index']);
Route::match(['get', 'post'], '/admin/security/sensitiveData/add', [\app\admin\controller\security\SensitiveData::class, 'add']);
Route::match(['get', 'post'], '/admin/security/sensitiveData/edit', [\app\admin\controller\security\SensitiveData::class, 'edit']);
Route::post('/admin/security/sensitiveData/del', [\app\admin\controller\security\SensitiveData::class, 'del']);
// admin/security/sensitiveDataLog
Route::get('/admin/security/sensitiveDataLog/index', [\app\admin\controller\security\SensitiveDataLog::class, 'index']);
Route::get('/admin/security/sensitiveDataLog/info', [\app\admin\controller\security\SensitiveDataLog::class, 'info']);
Route::post('/admin/security/sensitiveDataLog/rollback', [\app\admin\controller\security\SensitiveDataLog::class, 'rollback']);
// admin/security/dataRecycle
Route::get('/admin/security/dataRecycle/index', [\app\admin\controller\security\DataRecycle::class, 'index']);
Route::match(['get', 'post'], '/admin/security/dataRecycle/add', [\app\admin\controller\security\DataRecycle::class, 'add']);
Route::match(['get', 'post'], '/admin/security/dataRecycle/edit', [\app\admin\controller\security\DataRecycle::class, 'edit']);
Route::post('/admin/security/dataRecycle/del', [\app\admin\controller\security\DataRecycle::class, 'del']);
// admin/security/dataRecycleLog
Route::get('/admin/security/dataRecycleLog/index', [\app\admin\controller\security\DataRecycleLog::class, 'index']);
Route::post('/admin/security/dataRecycleLog/restore', [\app\admin\controller\security\DataRecycleLog::class, 'restore']);
Route::get('/admin/security/dataRecycleLog/info', [\app\admin\controller\security\DataRecycleLog::class, 'info']);

View File

@@ -0,0 +1,23 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
'event_loop' => '',
'stop_timeout' => 2,
'pid_file' => runtime_path() . '/webman.pid',
'status_file' => runtime_path() . '/webman.status',
'stdout_file' => runtime_path() . '/logs/stdout.log',
'log_file' => runtime_path() . '/logs/workerman.log',
'max_package_size' => 10 * 1024 * 1024
];

View File

@@ -0,0 +1,65 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use Webman\Session\FileSessionHandler;
use Webman\Session\RedisSessionHandler;
use Webman\Session\RedisClusterSessionHandler;
return [
'type' => 'file', // or redis or redis_cluster
'handler' => FileSessionHandler::class,
'config' => [
'file' => [
'save_path' => runtime_path() . '/sessions',
],
'redis' => [
'host' => '127.0.0.1',
'port' => 6379,
'auth' => '',
'timeout' => 2,
'database' => '',
'prefix' => 'redis_session_',
],
'redis_cluster' => [
'host' => ['127.0.0.1:7000', '127.0.0.1:7001', '127.0.0.1:7001'],
'timeout' => 2,
'auth' => '',
'prefix' => 'redis_session_',
]
],
'session_name' => 'PHPSID',
'auto_update_timestamp' => false,
'lifetime' => 7*24*60*60,
'cookie_lifetime' => 365*24*60*60,
'cookie_path' => '/',
'domain' => '',
'http_only' => true,
'secure' => false,
'same_site' => '',
'gc_probability' => [1, 1000],
];

View File

@@ -0,0 +1,23 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
/**
* Static file settings
*/
return [
'enable' => true,
'middleware' => [ // Static file Middleware
//app\middleware\StaticFile::class,
],
];

View File

@@ -0,0 +1,83 @@
<?php
// +----------------------------------------------------------------------
// | BuildAdmin - WEB终端配置
// | Webman 迁移migrate 命令需根据实际迁移工具调整
// +----------------------------------------------------------------------
return [
'npm_package_manager' => 'pnpm',
'commands' => [
'migrate' => [
'run' => [
'cwd' => '',
'command' => 'php webman migrate:run',
'notes' => 'Start the database migration'
],
'rollback' => 'php webman migrate:rollback',
'breakpoint' => 'php webman migrate:breakpoint',
],
'install' => [
'cnpm' => 'npm install cnpm -g --registry=https://registry.npmmirror.com',
'yarn' => 'npm install -g yarn',
'pnpm' => 'npm install -g pnpm',
'ni' => 'npm install -g @antfu/ni',
],
'version' => [
'npm' => 'npm -v',
'cnpm' => 'cnpm -v',
'yarn' => 'yarn -v',
'pnpm' => 'pnpm -v',
'node' => 'node -v',
],
'test' => [
'npm' => ['cwd' => 'public/npm-install-test', 'command' => 'npm install'],
'cnpm' => ['cwd' => 'public/npm-install-test', 'command' => 'cnpm install'],
'yarn' => ['cwd' => 'public/npm-install-test', 'command' => 'yarn install'],
'pnpm' => ['cwd' => 'public/npm-install-test', 'command' => 'pnpm install'],
'ni' => ['cwd' => 'public/npm-install-test', 'command' => 'ni install'],
],
'web-install' => [
'npm' => ['cwd' => 'web', 'command' => 'npm install'],
'cnpm' => ['cwd' => 'web', 'command' => 'cnpm install'],
'yarn' => ['cwd' => 'web', 'command' => 'yarn install'],
'pnpm' => ['cwd' => 'web', 'command' => 'pnpm install'],
'ni' => ['cwd' => 'web', 'command' => 'ni install'],
],
'nuxt-install' => [
'npm' => ['cwd' => 'web-nuxt', 'command' => 'npm install'],
'cnpm' => ['cwd' => 'web-nuxt', 'command' => 'cnpm install'],
'yarn' => ['cwd' => 'web-nuxt', 'command' => 'yarn install'],
'pnpm' => ['cwd' => 'web-nuxt', 'command' => 'pnpm install'],
'ni' => ['cwd' => 'web-nuxt', 'command' => 'ni install'],
],
'web-build' => [
'npm' => ['cwd' => 'web', 'command' => 'npm run build', 'notes' => 'Start executing the build command of the web project'],
'cnpm' => ['cwd' => 'web', 'command' => 'cnpm run build', 'notes' => 'Start executing the build command of the web project'],
'yarn' => ['cwd' => 'web', 'command' => 'yarn run build', 'notes' => 'Start executing the build command of the web project'],
'pnpm' => ['cwd' => 'web', 'command' => 'pnpm run build', 'notes' => 'Start executing the build command of the web project'],
'ni' => ['cwd' => 'web', 'command' => 'nr build', 'notes' => 'Start executing the build command of the web project'],
],
'set-npm-registry' => [
'npm' => 'npm config set registry https://registry.npmjs.org/ && npm config get registry',
'taobao' => 'npm config set registry https://registry.npmmirror.com/ && npm config get registry',
'tencent' => 'npm config set registry https://mirrors.cloud.tencent.com/npm/ && npm config get registry'
],
'set-composer-registry' => [
'composer' => 'composer config --unset repos.packagist',
'aliyun' => 'composer config -g repos.packagist composer https://mirrors.aliyun.com/composer/',
'tencent' => 'composer config -g repos.packagist composer https://mirrors.cloud.tencent.com/composer/',
'huawei' => 'composer config -g repos.packagist composer https://mirrors.huaweicloud.com/repository/php/',
'kkame' => 'composer config -g repos.packagist composer https://packagist.kr',
],
'npx' => [
'prettier' => ['cwd' => 'web', 'command' => 'npx prettier --write %s', 'notes' => 'Start formatting the web project code'],
],
'composer' => [
'update' => ['cwd' => '', 'command' => 'composer update --no-interaction', 'notes' => 'Start installing the composer dependencies']
],
'ping' => [
'baidu' => 'ping baidu.com',
'localhost' => 'ping 127.0.0.1 -n 6',
]
],
];

View File

@@ -0,0 +1,94 @@
<?php
/**
* ThinkORM 数据库配置
* 从 config/database.php 迁移
*
* .env 支持两种格式(二选一):
* 1. BuildAdmin 格式database.hostname、database.database 等(需 env 支持点号)
* 2. 标准格式DATABASE_HOSTNAME、DATABASE_DATABASE 等
*/
$env = function (string $dotKey, $default = null) {
$upperKey = strtoupper(str_replace('.', '_', $dotKey));
$value = env($dotKey, null);
if ($value === null) {
$value = env($upperKey, $default);
}
return $value ?? $default;
};
return [
// 默认使用的数据库连接配置
'default' => $env('database.driver', 'mysql'),
// 自定义时间查询规则
'time_query_rule' => [],
// 自动写入时间戳字段
'auto_timestamp' => true,
// 时间字段取出后的默认时间格式
'datetime_format' => false,
// 时间字段配置 配置格式create_time,update_time
'datetime_field' => '',
// 数据库连接配置信息
'connections' => [
'mysql' => [
// 数据库类型
'type' => $env('database.type', 'mysql'),
// 服务器地址
'hostname' => $env('database.hostname', '127.0.0.1'),
// 数据库名
'database' => $env('database.database', 'buildadmin_com'),
// 用户名
'username' => $env('database.username', 'root'),
// 密码
'password' => $env('database.password', 'admin888'),
// 端口
'hostport' => $env('database.hostport', '3306'),
// 数据库连接参数
'params' => [
\PDO::ATTR_TIMEOUT => 3,
],
// 数据库编码默认采用 utf8mb4
'charset' => $env('database.charset', 'utf8mb4'),
// 数据库表前缀
'prefix' => $env('database.prefix', ''),
// 数据库部署方式:0 集中式,1 分布式(主从)
'deploy' => 0,
// 数据库读写是否分离
'rw_separate' => false,
// 读写分离后 主服务器数量
'master_num' => 1,
// 指定从服务器序号
'slave_no' => '',
// 是否严格检查字段是否存在
'fields_strict' => true,
// 是否需要断线重连
'break_reconnect' => true,
// 监听SQL调试用
'trigger_sql' => $env('app_debug', false),
// 开启字段缓存
'fields_cache' => false,
// 连接池配置Webman 常驻内存模式必需)
'pool' => [
'max_connections' => 5,
'min_connections' => 1,
'wait_timeout' => 3,
'idle_timeout' => 60,
'heartbeat_interval' => 50,
],
],
],
// 自定义分页类
'paginator' => '',
// 迁移表名TableManager/Phinx 使用)
'migration_table' => 'migrations',
];

View File

@@ -0,0 +1,29 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
/**
* Multilingual configuration
*/
return [
// Default language
'locale' => 'zh_CN',
// Fallback language
'fallback_locale' => ['zh_CN', 'en'],
// Folder where language files are stored含 BuildAdmin app 语言包)
'path' => [
base_path() . '/resource/translations',
base_path() . '/app/api/lang',
base_path() . '/app/admin/lang',
],
];

View File

@@ -0,0 +1,21 @@
<?php
// +----------------------------------------------------------------------
// | BuildAdmin上传设置
// +----------------------------------------------------------------------
return [
// 最大上传
'max_size' => '10mb',
// 文件保存格式化方法:topic=存储子目录,fileName=文件名前15个字符
'save_name' => '/storage/{topic}/{year}{mon}{day}/{fileName}{fileSha1}{.suffix}',
/**
* 上传文件的后缀和 MIME类型 白名单
* 0. 永远使用最少配置
* 1. 此处不支持通配符
* 2. 千万不要允许 php,php5,.htaccess,.user.ini 等可执行或配置文件
* 3. 允许 pdf,ppt,docx 等可能含有脚本的文件时,请先从服务器配置此类文件直接下载而不是预览
*/
'allowed_suffixes' => 'jpg,png,bmp,jpeg,gif,webp,zip,rar,wav,mp4,mp3',
'allowed_mime_types' => [],
];

View File

@@ -0,0 +1,22 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use support\view\Raw;
use support\view\Twig;
use support\view\Blade;
use support\view\ThinkPHP;
return [
'handler' => Raw::class
];

View File

@@ -0,0 +1,11 @@
version: "3"
services:
webman:
build: .
container_name: docker-webman
restart: unless-stopped
volumes:
- "./:/app"
ports:
- "8787:8787"
command: ["php", "start.php", "start" ]

View File

@@ -0,0 +1,40 @@
# BuildAdmin Webman - Nginx 反向代理示例
# 将 server_name 和 root 改为实际值后,放入 nginx 的 conf.d 或 sites-available
upstream webman {
server 127.0.0.1:8787;
keepalive 10240;
}
server {
server_name 你的域名;
listen 80;
access_log off;
root /path/to/dafuweng-webman/public;
location / {
try_files $uri $uri/ @proxy;
}
location @proxy {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_pass http://webman;
}
location ~ \.php$ {
return 404;
}
location ~ ^/\.well-known/ {
allow all;
}
location ~ /\. {
return 404;
}
}

View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace ba;
use Throwable;
use support\think\Db;
/**
* 权限规则类Webman 迁移版)
*/
class Auth
{
protected array $config = [
'auth_group' => 'admin_group',
'auth_group_access' => 'admin_group_access',
'auth_rule' => 'admin_rule',
];
protected array $children = [];
public function __construct(array $config = [])
{
$this->config = array_merge($this->config, $config);
}
public function __get($name): mixed
{
return $this->config[$name] ?? null;
}
public function getMenus(int $uid): array
{
$this->children = [];
$originAuthRules = $this->getOriginAuthRules($uid);
foreach ($originAuthRules as $rule) {
$this->children[$rule['pid']][] = $rule;
}
if (!isset($this->children[0])) {
return [];
}
return $this->getChildren($this->children[0]);
}
private function getChildren(array $rules): array
{
foreach ($rules as $key => $rule) {
if (array_key_exists($rule['id'], $this->children)) {
$rules[$key]['children'] = $this->getChildren($this->children[$rule['id']]);
}
}
return $rules;
}
public function check(string $name, int $uid, string $relation = 'or', string $mode = 'url'): bool
{
$ruleList = $this->getRuleList($uid);
if (in_array('*', $ruleList)) {
return true;
}
if ($name) {
$name = strtolower($name);
$name = str_contains($name, ',') ? explode(',', $name) : [$name];
}
$list = [];
$requestParams = [];
if ($mode === 'url' && function_exists('request')) {
$req = request();
$requestParams = $req ? array_merge($req->get(), $req->post()) : [];
$requestParams = json_decode(strtolower(json_encode($requestParams, JSON_UNESCAPED_UNICODE)), true) ?? [];
}
foreach ($ruleList as $rule) {
$query = preg_replace('/^.+\?/U', '', $rule);
if ($mode === 'url' && $query !== $rule) {
parse_str($query, $param);
$intersect = array_intersect_assoc($requestParams, $param);
$rule = preg_replace('/\?.*$/U', '', $rule);
if (in_array($rule, $name) && $intersect == $param) {
$list[] = $rule;
}
} elseif (in_array($rule, $name)) {
$list[] = $rule;
}
}
if ($relation === 'or' && !empty($list)) {
return true;
}
$diff = array_diff($name, $list);
if ($relation === 'and' && empty($diff)) {
return true;
}
return false;
}
public function getRuleList(int $uid): array
{
$ids = $this->getRuleIds($uid);
if (empty($ids)) {
return [];
}
$originAuthRules = $this->getOriginAuthRules($uid);
$rules = [];
if (in_array('*', $ids)) {
$rules[] = '*';
}
foreach ($originAuthRules as $rule) {
$rules[$rule['id']] = strtolower($rule['name']);
}
return array_unique($rules);
}
public function getOriginAuthRules(int $uid): array
{
$ids = $this->getRuleIds($uid);
if (empty($ids)) {
return [];
}
$where = [['status', '=', '1']];
if (!in_array('*', $ids)) {
$where[] = ['id', 'in', $ids];
}
$rules = Db::name($this->config['auth_rule'])
->withoutField(['remark', 'status', 'weigh', 'update_time', 'create_time'])
->where($where)
->order('weigh desc,id asc')
->select()
->toArray();
foreach ($rules as $key => $rule) {
if (!empty($rule['keepalive'])) {
$rules[$key]['keepalive'] = $rule['name'];
}
}
return $rules;
}
public function getRuleIds(int $uid): array
{
$groups = $this->getGroups($uid);
$ids = [];
foreach ($groups as $g) {
$ids = array_merge($ids, explode(',', trim($g['rules'] ?? '', ',')));
}
return array_unique($ids);
}
public function getGroups(int $uid): array
{
$dbName = $this->config['auth_group_access'] ?: 'user';
if ($this->config['auth_group_access']) {
$userGroups = Db::name($dbName)
->alias('aga')
->join($this->config['auth_group'] . ' ag', 'aga.group_id = ag.id', 'LEFT')
->field('aga.uid,aga.group_id,ag.id,ag.pid,ag.name,ag.rules')
->where("aga.uid='$uid' and ag.status='1'")
->select()
->toArray();
} else {
$userGroups = Db::name($dbName)
->alias('u')
->join($this->config['auth_group'] . ' ag', 'u.group_id = ag.id', 'LEFT')
->field('u.id as uid,u.group_id,ag.id,ag.name,ag.rules')
->where("u.id='$uid' and ag.status='1'")
->select()
->toArray();
}
return $userGroups;
}
}

View File

@@ -0,0 +1,313 @@
<?php
// +----------------------------------------------------------------------
// | 妙码生花在 2022-2-26 进行修订通过Mysql保存验证码而不是Session以更好的支持API访问
// | 使用Cache不能清理过期验证码且一旦执行清理缓存操作验证码将失效
// +----------------------------------------------------------------------
namespace ba;
use GdImage;
use Throwable;
use support\think\Db;
use support\Response;
/**
* 验证码类(图形验证码、继续流程验证码)
* @property string $seKey 验证码加密密钥
* @property string $codeSet 验证码字符集合
* @property int $expire 验证码过期时间s
* @property bool $useZh 使用中文验证码
* @property string $zhSet 中文验证码字符串
* @property bool $useImgBg 使用背景图片
* @property int $fontSize 验证码字体大小(px)
* @property bool $useCurve 是否画混淆曲线
* @property bool $useNoise 是否添加杂点
* @property int $imageH 验证码图片高度
* @property int $imageW 验证码图片宽度
* @property int $length 验证码位数
* @property string $fontTtf 验证码字体,不设置随机获取
* @property array $bg 背景颜色
* @property bool $reset 验证成功后是否重置
*/
class Captcha
{
protected array $config = [
'seKey' => 'BuildAdmin',
'codeSet' => '2345678abcdefhijkmnpqrstuvwxyzABCDEFGHJKLMNPQRTUVWXY',
'expire' => 600,
'useZh' => false,
'zhSet' => '们以我到他会作时要动国产的一是工就年阶义发成部民可出能方进在了不和有大这主中人上为来分生对于学下级地个用同行面说种过命度革而多子后自社加小机也经力线本电高量长党得实家定深法表着水理化争现所二起政三好十战无农使性前等反体合斗路图把结第里正新开论之物从当两些还天资事队批点育重其思与间内去因件日利相由压员气业代全组数果期导平各基或月毛然如应形想制心样干都向变关问比展那它最及外没看治提五解系林者米群头意只明四道马认次文通但条较克又公孔领军流入接席位情运器并飞原油放立题质指建区验活众很教决特此常石强极土少已根共直团统式转别造切九你取西持总料连任志观调七么山程百报更见必真保热委手改管处己将修支识病象几先老光专什六型具示复安带每东增则完风回南广劳轮科北打积车计给节做务被整联步类集号列温装即毫知轴研单色坚据速防史拉世设达尔场织历花受求传口断况采精金界品判参层止边清至万确究书术状厂须离再目海交权且儿青才证低越际八试规斯近注办布门铁需走议县兵固除般引齿千胜细影济白格效置推空配刀叶率述今选养德话查差半敌始片施响收华觉备名红续均药标记难存测士身紧液派准斤角降维板许破述技消底床田势端感往神便贺村构照容非搞亚磨族火段算适讲按值美态黄易彪服早班麦削信排台声该击素张密害侯草何树肥继右属市严径螺检左页抗苏显苦英快称坏移约巴材省黑武培著河帝仅针怎植京助升王眼她抓含苗副杂普谈围食射源例致酸旧却充足短划剂宣环落首尺波承粉践府鱼随考刻靠够满夫失包住促枝局菌杆周护岩师举曲春元超负砂封换太模贫减阳扬江析亩木言球朝医校古呢稻宋听唯输滑站另卫字鼓刚写刘微略范供阿块某功套友限项余倒卷创律雨让骨远帮初皮播优占死毒圈伟季训控激找叫云互跟裂粮粒母练塞钢顶策双留误础吸阻故寸盾晚丝女散焊功株亲院冷彻弹错散商视艺灭版烈零室轻血倍缺厘泵察绝富城冲喷壤简否柱李望盘磁雄似困巩益洲脱投送奴侧润盖挥距触星松送获兴独官混纪依未突架宽冬章湿偏纹吃执阀矿寨责熟稳夺硬价努翻奇甲预职评读背协损棉侵灰虽矛厚罗泥辟告卵箱掌氧恩爱停曾溶营终纲孟钱待尽俄缩沙退陈讨奋械载胞幼哪剥迫旋征槽倒握担仍呀鲜吧卡粗介钻逐弱脚怕盐末阴丰雾冠丙街莱贝辐肠付吉渗瑞惊顿挤秒悬姆烂森糖圣凹陶词迟蚕亿矩康遵牧遭幅园腔订香肉弟屋敏恢忘编印蜂急拿扩伤飞露核缘游振操央伍域甚迅辉异序免纸夜乡久隶缸夹念兰映沟乙吗儒杀汽磷艰晶插埃燃欢铁补咱芽永瓦倾阵碳演威附牙芽永瓦斜灌欧献顺猪洋腐请透司危括脉宜笑若尾束壮暴企菜穗楚汉愈绿拖牛份染既秋遍锻玉夏疗尖殖井费州访吹荣铜沿替滚客召旱悟刺脑措贯藏敢令隙炉壳硫煤迎铸粘探临薄旬善福纵择礼愿伏残雷延烟句纯渐耕跑泽慢栽鲁赤繁境潮横掉锥希池败船假亮谓托伙哲怀割摆贡呈劲财仪沉炼麻罪祖息车穿货销齐鼠抽画饲龙库守筑房歌寒喜哥洗蚀废纳腹乎录镜妇恶脂庄擦险赞钟摇典柄辩竹谷卖乱虚桥奥伯赶垂途额壁网截野遗静谋弄挂课镇妄盛耐援扎虑键归符庆聚绕摩忙舞遇索顾胶羊湖钉仁音迹碎伸灯避泛亡答勇频皇柳哈揭甘诺概宪浓岛袭谁洪谢炮浇斑讯懂灵蛋闭孩释乳巨徒私银伊景坦累匀霉杜乐勒隔弯绩招绍胡呼痛峰零柴簧午跳居尚丁秦稍追梁折耗碱殊岗挖氏刃剧堆赫荷胸衡勤膜篇登驻案刊秧缓凸役剪川雪链渔啦脸户洛孢勃盟买杨宗焦赛旗滤硅炭股坐蒸凝竟陷枪黎救冒暗洞犯筒您宋弧爆谬涂味津臂障褐陆啊健尊豆拔莫抵桑坡缝警挑污冰柬嘴啥饭塑寄赵喊垫丹渡耳刨虎笔稀昆浪萨茶滴浅拥穴覆伦娘吨浸袖珠雌妈紫戏塔锤震岁貌洁剖牢锋疑霸闪埔猛诉刷狠忽灾闹乔唐漏闻沈熔氯荒茎男凡抢像浆旁玻亦忠唱蒙予纷捕锁尤乘乌智淡允叛畜俘摸锈扫毕璃宝芯爷鉴秘净蒋钙肩腾枯抛轨堂拌爸循诱祝励肯酒绳穷塘燥泡袋朗喂铝软渠颗惯贸粪综墙趋彼届墨碍启逆卸航衣孙龄岭骗休借',
'useImgBg' => false,
'fontSize' => 25,
'useCurve' => true,
'useNoise' => true,
'imageH' => 0,
'imageW' => 0,
'length' => 4,
'fontTtf' => '',
'bg' => [243, 251, 254],
'reset' => true,
];
private $image = null;
private bool|int|null $color = null;
public function __construct(array $config = [])
{
$this->config = array_merge($this->config, $config);
Db::name('captcha')
->where('expire_time', '<', time())
->delete();
}
public function __get(string $name): mixed
{
return $this->config[$name];
}
public function __set(string $name, mixed $value): void
{
if (isset($this->config[$name])) {
$this->config[$name] = $value;
}
}
public function __isset(string $name): bool
{
return isset($this->config[$name]);
}
public function check(string $code, string $id): bool
{
$key = $this->authCode($this->seKey, $id);
$seCode = Db::name('captcha')->where('key', $key)->find();
if (empty($code) || empty($seCode)) {
return false;
}
if (time() > $seCode['expire_time']) {
Db::name('captcha')->where('key', $key)->delete();
return false;
}
if ($this->authCode(strtoupper($code), $id) == $seCode['code']) {
$this->reset && Db::name('captcha')->where('key', $key)->delete();
return true;
}
return false;
}
public function create(string $id, string|bool $captcha = false): string
{
$nowTime = time();
$key = $this->authCode($this->seKey, $id);
$captchaTemp = Db::name('captcha')->where('key', $key)->find();
if ($captchaTemp) {
Db::name('captcha')->where('key', $key)->delete();
}
$captcha = $this->generate($captcha);
$code = $this->authCode($captcha, $id);
Db::name('captcha')
->insert([
'key' => $key,
'code' => $code,
'captcha' => $captcha,
'create_time' => $nowTime,
'expire_time' => $nowTime + $this->expire
]);
return $captcha;
}
public function getCaptchaData(string $id): array
{
$key = $this->authCode($this->seKey, $id);
$seCode = Db::name('captcha')->where('key', $key)->find();
return $seCode ?: [];
}
/**
* 输出图形验证码并把验证码的值保存的Mysql中
* @param string $id 要生成验证码的标识
* @return Response
* @throws Throwable
*/
public function entry(string $id): Response
{
$nowTime = time();
$this->imageW || $this->imageW = $this->length * $this->fontSize * 1.5 + $this->length * $this->fontSize / 2;
$this->imageH || $this->imageH = $this->fontSize * 2.5;
$this->image = imagecreate($this->imageW, $this->imageH);
imagecolorallocate($this->image, $this->bg[0], $this->bg[1], $this->bg[2]);
$this->color = imagecolorallocate($this->image, mt_rand(1, 150), mt_rand(1, 150), mt_rand(1, 150));
$ttfPath = public_path() . 'static' . DIRECTORY_SEPARATOR . 'fonts' . DIRECTORY_SEPARATOR . ($this->useZh ? 'zhttfs' : 'ttfs') . DIRECTORY_SEPARATOR;
if (empty($this->fontTtf)) {
$dir = dir($ttfPath);
$ttfFiles = [];
while (false !== ($file = $dir->read())) {
if ('.' != $file[0] && str_ends_with($file, '.ttf')) {
$ttfFiles[] = $file;
}
}
$dir->close();
$this->fontTtf = $ttfFiles[array_rand($ttfFiles)];
}
$this->fontTtf = $ttfPath . $this->fontTtf;
if ($this->useImgBg) {
$this->background();
}
if ($this->useNoise) {
$this->writeNoise();
}
if ($this->useCurve) {
$this->writeCurve();
}
$key = $this->authCode($this->seKey, $id);
$captcha = Db::name('captcha')->where('key', $key)->find();
if ($captcha && $nowTime <= $captcha['expire_time']) {
$this->writeText($captcha['captcha']);
} else {
$captcha = $this->writeText();
$code = $this->authCode(strtoupper(implode('', $captcha)), $id);
Db::name('captcha')->insert([
'key' => $key,
'code' => $code,
'captcha' => strtoupper(implode('', $captcha)),
'create_time' => $nowTime,
'expire_time' => $nowTime + $this->expire
]);
}
ob_start();
imagepng($this->image);
$content = ob_get_clean();
return response($content, 200, ['Content-Type' => 'image/png', 'Content-Length' => strlen($content)]);
}
private function writeText(string $captcha = ''): array|string
{
$code = [];
$codeNX = 0;
if ($this->useZh) {
for ($i = 0; $i < $this->length; $i++) {
$code[$i] = $captcha ? $captcha[$i] : iconv_substr($this->zhSet, floor(mt_rand(0, mb_strlen($this->zhSet, 'utf-8') - 1)), 1, 'utf-8');
imagettftext($this->image, $this->fontSize, mt_rand(-40, 40), $this->fontSize * ($i + 1) * 1.5, $this->fontSize + mt_rand(10, 20), (int)$this->color, $this->fontTtf, $code[$i]);
}
} else {
for ($i = 0; $i < $this->length; $i++) {
$code[$i] = $captcha ? $captcha[$i] : $this->codeSet[mt_rand(0, strlen($this->codeSet) - 1)];
$codeNX += mt_rand((int)($this->fontSize * 1.2), (int)($this->fontSize * 1.6));
imagettftext($this->image, $this->fontSize, mt_rand(-40, 40), $codeNX, (int)($this->fontSize * 1.6), (int)$this->color, $this->fontTtf, $code[$i]);
}
}
return $captcha ?: $code;
}
private function writeCurve(): void
{
$py = 0;
$A = mt_rand(1, $this->imageH / 2);
$b = mt_rand(-$this->imageH / 4, $this->imageH / 4);
$f = mt_rand(-$this->imageH / 4, $this->imageH / 4);
$T = mt_rand($this->imageH, $this->imageW * 2);
$w = (2 * M_PI) / $T;
$px1 = 0;
$px2 = mt_rand($this->imageW / 2, $this->imageW * 0.8);
for ($px = $px1; $px <= $px2; $px = $px + 1) {
if (0 != $w) {
$py = $A * sin($w * $px + $f) + $b + $this->imageH / 2;
$i = (int)($this->fontSize / 5);
while ($i > 0) {
imagesetpixel($this->image, $px + $i, $py + $i, (int)$this->color);
$i--;
}
}
}
$A = mt_rand(1, $this->imageH / 2);
$f = mt_rand(-$this->imageH / 4, $this->imageH / 4);
$T = mt_rand($this->imageH, $this->imageW * 2);
$w = (2 * M_PI) / $T;
$b = $py - $A * sin($w * $px + $f) - $this->imageH / 2;
$px1 = $px2;
$px2 = $this->imageW;
for ($px = $px1; $px <= $px2; $px = $px + 1) {
if (0 != $w) {
$py = $A * sin($w * $px + $f) + $b + $this->imageH / 2;
$i = (int)($this->fontSize / 5);
while ($i > 0) {
imagesetpixel($this->image, $px + $i, $py + $i, (int)$this->color);
$i--;
}
}
}
}
private function writeNoise(): void
{
$codeSet = '2345678abcdefhijkmnpqrstuvwxyz';
for ($i = 0; $i < 10; $i++) {
$noiseColor = imagecolorallocate($this->image, mt_rand(150, 225), mt_rand(150, 225), mt_rand(150, 225));
for ($j = 0; $j < 5; $j++) {
imagestring($this->image, 5, mt_rand(-10, $this->imageW), mt_rand(-10, $this->imageH), $codeSet[mt_rand(0, 29)], $noiseColor);
}
}
}
private function background(): void
{
$path = Filesystem::fsFit(public_path() . 'static/images/captcha/image/');
$dir = dir($path);
$bgs = [];
while (false !== ($file = $dir->read())) {
if ('.' != $file[0] && str_ends_with($file, '.jpg')) {
$bgs[] = $path . $file;
}
}
$dir->close();
$gb = $bgs[array_rand($bgs)];
list($width, $height) = @getimagesize($gb);
$bgImage = @imagecreatefromjpeg($gb);
@imagecopyresampled($this->image, $bgImage, 0, 0, 0, 0, $this->imageW, $this->imageH, $width, $height);
}
private function authCode(string $str, string $id): string
{
$key = substr(md5($this->seKey), 5, 8);
$str = substr(md5($str), 8, 10);
return md5($key . $str . $id);
}
private function generate(bool|string $captcha = false): string
{
$code = [];
if ($this->useZh) {
for ($i = 0; $i < $this->length; $i++) {
$code[$i] = $captcha ? $captcha[$i] : iconv_substr($this->zhSet, floor(mt_rand(0, mb_strlen($this->zhSet, 'utf-8') - 1)), 1, 'utf-8');
}
} else {
for ($i = 0; $i < $this->length; $i++) {
$code[$i] = $captcha ? $captcha[$i] : $this->codeSet[mt_rand(0, strlen($this->codeSet) - 1)];
}
}
$captcha = $captcha ?: implode('', $code);
return strtoupper($captcha);
}
}

View File

@@ -0,0 +1,252 @@
<?php
namespace ba;
use Throwable;
use support\think\Db;
/**
* 点选文字验证码类
*/
class ClickCaptcha
{
private int $expire = 600;
private array $bgPaths = [
'static/images/captcha/click/bgs/1.png',
'static/images/captcha/click/bgs/2.png',
'static/images/captcha/click/bgs/3.png',
];
private array $fontPaths = [
'static/fonts/zhttfs/SourceHanSansCN-Normal.ttf',
];
private array $iconDict = [
'aeroplane' => '飞机',
'apple' => '苹果',
'banana' => '香蕉',
'bell' => '铃铛',
'bicycle' => '自行车',
'bird' => '小鸟',
'bomb' => '炸弹',
'butterfly' => '蝴蝶',
'candy' => '糖果',
'crab' => '螃蟹',
'cup' => '杯子',
'dolphin' => '海豚',
'fire' => '火',
'guitar' => '吉他',
'hexagon' => '六角形',
'pear' => '梨',
'rocket' => '火箭',
'sailboat' => '帆船',
'snowflake' => '雪花',
'wolf head' => '狼头',
];
private array $config = [
'alpha' => 36,
'zhSet' => '们以我到他会作时要动国产的是工就年阶义发成部民可出能方进在和有大这主中为来分生对于学级地用同行面说种过命度革而多子后自社加小机也经力线本电高量长党得实家定深法表着水理化争现所起政好十战无农使前等反体合斗路图把结第里正新开论之物从当两些还天资事队点育重其思与间内去因件利相由压员气业代全组数果期导平各基或月然如应形想制心样都向变关问比展那它最及外没看治提五解系林者米群头意只明四道马认次文通但条较克又公孔领军流接席位情运器并飞原油放立题质指建区验活众很教决特此常石强极已根共直团统式转别造切九你取西持总料连任志观调么山程百报更见必真保热委手改管处己将修支识象先老光专什六型具示复安带每东增则完风回南劳轮科北打积车计给节做务被整联步类集号列温装即毫知轴研单坚据速防史拉世设达尔场织历花求传断况采精金界品判参层止边清至万确究书术状须离再目海权且青才证低越际八试规斯近注办布门铁需走议县兵固除般引齿胜细影济白格效置推空配叶率述今选养德话查差半敌始片施响收华觉备名红续均药标记难存测身紧液派准斤角降维板许破述技消底床田势端感往神便贺村构照容非亚磨族段算适讲按值美态易彪服早班麦削信排台声该击素张密害侯何树肥继右属市严径螺检左页抗苏显苦英快称坏移巴材省黑武培著河帝仅针怎植京助升王眼她抓苗副杂普谈围食源例致酸旧却充足短划剂宣环落首尺波承粉践府鱼随考刻靠够满夫失包住促枝局菌杆周护岩师举曲春元超负砂封换太模贫减阳扬江析亩木言球朝医校古呢稻宋听唯输滑站另卫字鼓刚写刘微略范供阿块某功友限项余倒卷创律雨让骨远帮初皮播优占圈伟季训控激找叫云互跟粮粒母练塞钢顶策双留误础阻故寸盾晚丝女散焊功株亲院冷彻弹错散商视艺版烈零室轻倍缺厘泵察绝富城冲喷壤简否柱李望盘磁雄似困巩益洲脱投送侧润盖挥距触星松送获兴独官混纪依未突架宽冬章偏纹吃执阀矿寨责熟稳夺硬价努翻奇甲预职评读背协损棉侵灰虽矛厚罗泥辟告箱掌氧恩爱停曾溶营终纲孟钱待尽俄缩沙退陈讨奋械载胞哪旋征槽倒握担仍呀鲜吧卡粗介钻逐弱脚怕盐末丰雾冠丙街莱贝辐肠付吉渗瑞惊顿挤秒悬姆森糖圣凹陶词迟蚕亿矩康遵牧遭幅园腔订香肉弟屋敏恢忘编印蜂急拿扩飞露核缘游振操央伍域甚迅辉异序免纸夜乡久隶念兰映沟乙吗儒汽磷艰晶埃燃欢铁补咱芽永瓦倾阵碳演威附牙芽永瓦斜灌欧献顺猪洋腐请透司括脉宜笑若尾束壮暴企菜穗楚汉愈绿拖牛份染既秋遍锻玉夏疗尖井费州访吹荣铜沿替滚客召旱悟刺脑措贯藏敢令隙炉壳硫煤迎铸粘探临薄旬善福纵择礼愿伏残雷延烟句纯渐耕跑泽慢栽鲁赤繁境潮横掉锥希池败船假亮谓托伙哲怀摆贡呈劲财仪沉炼麻祖息车穿货销齐鼠抽画饲龙库守筑房歌寒喜哥洗蚀废纳腹乎录镜脂庄擦险赞钟摇典柄辩竹谷乱虚桥奥伯赶垂途额壁网截野遗静谋弄挂课镇妄盛耐扎虑键归符庆聚绕摩忙舞遇索顾胶羊湖钉仁音迹碎伸灯避泛答勇频皇柳哈揭甘诺概宪浓岛袭谁洪谢炮浇斑讯懂灵蛋闭孩释巨徒私银伊景坦累匀霉杜乐勒隔弯绩招绍胡呼峰零柴簧午跳居尚秦稍追梁折耗碱殊岗挖氏刃剧堆赫荷胸衡勤膜篇登驻案刊秧缓凸役剪川雪链渔啦脸户洛孢勃盟买杨宗焦赛旗滤硅炭股坐蒸凝竟枪黎救冒暗洞犯筒您宋弧爆谬涂味津臂障褐陆啊健尊豆拔莫抵桑坡缝警挑冰柬嘴啥饭塑寄赵喊垫丹渡耳虎笔稀昆浪萨茶滴浅拥覆伦娘吨浸袖珠雌妈紫戏塔锤震岁貌洁剖牢锋疑霸闪埔猛诉刷忽闹乔唐漏闻沈熔氯荒茎男凡抢像浆旁玻亦忠唱蒙予纷捕锁尤乘乌智淡允叛畜俘摸锈扫毕璃宝芯爷鉴秘净蒋钙肩腾枯抛轨堂拌爸循诱祝励肯酒绳塘燥泡袋朗喂铝软渠颗惯贸综墙趋彼届墨碍启逆卸航衣孙龄岭休借',
];
public function __construct(array $config = [])
{
$clickConfig = config('buildadmin.click_captcha', []);
$this->config = array_merge($clickConfig, $this->config, $config);
Db::name('captcha')->where('expire_time', '<', time())->delete();
}
public function creat(string $id): array
{
$imagePath = Filesystem::fsFit(public_path() . $this->bgPaths[mt_rand(0, count($this->bgPaths) - 1)]);
$fontPath = Filesystem::fsFit(public_path() . $this->fontPaths[mt_rand(0, count($this->fontPaths) - 1)]);
$randPoints = $this->randPoints($this->config['length'] + $this->config['confuse_length']);
$lang = config('translation.locale', config('lang.default_lang', 'zh-cn'));
foreach ($randPoints as $v) {
$tmp['size'] = rand(15, 30);
if (isset($this->iconDict[$v])) {
$tmp['icon'] = true;
$tmp['name'] = $v;
$tmp['text'] = $lang == 'zh-cn' ? "<{$this->iconDict[$v]}>" : "<$v>";
$iconInfo = getimagesize(Filesystem::fsFit(public_path() . 'static/images/captcha/click/icons/' . $v . '.png'));
$tmp['width'] = $iconInfo[0];
$tmp['height'] = $iconInfo[1];
} else {
$fontArea = imagettfbbox($tmp['size'], 0, $fontPath, $v);
$textWidth = $fontArea[2] - $fontArea[0];
$textHeight = $fontArea[1] - $fontArea[7];
$tmp['icon'] = false;
$tmp['text'] = $v;
$tmp['width'] = $textWidth;
$tmp['height'] = $textHeight;
}
$textArr['text'][] = $tmp;
}
$imageInfo = getimagesize($imagePath);
$textArr['width'] = $imageInfo[0];
$textArr['height'] = $imageInfo[1];
foreach ($textArr['text'] as &$v) {
list($x, $y) = $this->randPosition($textArr['text'], $textArr['width'], $textArr['height'], $v['width'], $v['height'], $v['icon']);
$v['x'] = $x;
$v['y'] = $y;
$text[] = $v['text'];
}
unset($v);
$image = imagecreatefromstring(file_get_contents($imagePath));
foreach ($textArr['text'] as $v) {
if ($v['icon']) {
$this->iconCover($image, $v);
} else {
$color = imagecolorallocatealpha($image, 239, 239, 234, 127 - intval($this->config['alpha'] * (127 / 100)));
imagettftext($image, $v['size'], 0, $v['x'], $v['y'], $color, $fontPath, $v['text']);
}
}
$nowTime = time();
$textArr['text'] = array_splice($textArr['text'], 0, $this->config['length']);
$text = array_splice($text, 0, $this->config['length']);
Db::name('captcha')
->replace()
->insert([
'key' => md5($id),
'code' => md5(implode(',', $text)),
'captcha' => json_encode($textArr, JSON_UNESCAPED_UNICODE),
'create_time' => $nowTime,
'expire_time' => $nowTime + $this->expire
]);
while (ob_get_level()) {
ob_end_clean();
}
if (!ob_get_level()) ob_start();
switch ($imageInfo[2]) {
case 1:
imagegif($image);
$content = ob_get_clean();
break;
case 2:
imagejpeg($image);
$content = ob_get_clean();
break;
case 3:
imagepng($image);
$content = ob_get_clean();
break;
default:
$content = '';
break;
}
return [
'id' => $id,
'text' => $text,
'base64' => 'data:' . $imageInfo['mime'] . ';base64,' . base64_encode($content),
'width' => $textArr['width'],
'height' => $textArr['height'],
];
}
public function check(string $id, string $info, bool $unset = true): bool
{
$key = md5($id);
$captcha = Db::name('captcha')->where('key', $key)->find();
if ($captcha) {
if (time() > $captcha['expire_time']) {
Db::name('captcha')->where('key', $key)->delete();
return false;
}
$textArr = json_decode($captcha['captcha'], true);
list($xy, $w, $h) = explode(';', $info);
$xyArr = explode('-', $xy);
$xPro = $w / $textArr['width'];
$yPro = $h / $textArr['height'];
foreach ($xyArr as $k => $v) {
$xy = explode(',', $v);
$x = $xy[0];
$y = $xy[1];
if ($x / $xPro < $textArr['text'][$k]['x'] || $x / $xPro > $textArr['text'][$k]['x'] + $textArr['text'][$k]['width']) {
return false;
}
$phStart = $textArr['text'][$k]['icon'] ? $textArr['text'][$k]['y'] : $textArr['text'][$k]['y'] - $textArr['text'][$k]['height'];
$phEnd = $textArr['text'][$k]['icon'] ? $textArr['text'][$k]['y'] + $textArr['text'][$k]['height'] : $textArr['text'][$k]['y'];
if ($y / $yPro < $phStart || $y / $yPro > $phEnd) {
return false;
}
}
if ($unset) Db::name('captcha')->where('key', $key)->delete();
return true;
} else {
return false;
}
}
protected function iconCover($bgImg, $iconImgData): void
{
$iconImage = imagecreatefrompng(Filesystem::fsFit(public_path() . 'static/images/captcha/click/icons/' . $iconImgData['name'] . '.png'));
$trueColorImage = imagecreatetruecolor($iconImgData['width'], $iconImgData['height']);
imagecopy($trueColorImage, $bgImg, 0, 0, $iconImgData['x'], $iconImgData['y'], $iconImgData['width'], $iconImgData['height']);
imagecopy($trueColorImage, $iconImage, 0, 0, 0, 0, $iconImgData['width'], $iconImgData['height']);
imagecopymerge($bgImg, $trueColorImage, $iconImgData['x'], $iconImgData['y'], 0, 0, $iconImgData['width'], $iconImgData['height'], $this->config['alpha']);
}
public function randPoints(int $length = 4): array
{
$arr = [];
if (in_array('text', $this->config['mode'])) {
for ($i = 0; $i < $length; $i++) {
$arr[] = mb_substr($this->config['zhSet'], mt_rand(0, mb_strlen($this->config['zhSet'], 'utf-8') - 1), 1, 'utf-8');
}
}
if (in_array('icon', $this->config['mode'])) {
$icon = array_keys($this->iconDict);
shuffle($icon);
$icon = array_slice($icon, 0, $length);
$arr = array_merge($arr, $icon);
}
shuffle($arr);
return array_slice($arr, 0, $length);
}
private function randPosition(array $textArr, int $imgW, int $imgH, int $fontW, int $fontH, bool $isIcon): array
{
$x = rand(0, $imgW - $fontW);
$y = rand($fontH, $imgH - $fontH);
if (!$this->checkPosition($textArr, $x, $y, $fontW, $fontH, $isIcon)) {
$position = $this->randPosition($textArr, $imgW, $imgH, $fontW, $fontH, $isIcon);
} else {
$position = [$x, $y];
}
return $position;
}
public function checkPosition(array $textArr, int $x, int $y, int $w, int $h, bool $isIcon): bool
{
$flag = true;
foreach ($textArr as $v) {
if (isset($v['x']) && isset($v['y'])) {
$flagX = false;
$flagY = false;
$historyPw = $v['x'] + $v['width'];
if (($x + $w) < $v['x'] || $x > $historyPw) {
$flagX = true;
}
$currentPhStart = $isIcon ? $y : $y - $h;
$currentPhEnd = $isIcon ? $y + $v['height'] : $y;
$historyPhStart = $v['icon'] ? $v['y'] : ($v['y'] - $v['height']);
$historyPhEnd = $v['icon'] ? ($v['y'] + $v['height']) : $v['y'];
if ($currentPhEnd < $historyPhStart || $currentPhStart > $historyPhEnd) {
$flagY = true;
}
if (!$flagX && !$flagY) {
$flag = false;
}
}
}
return $flag;
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace ba;
use DateTime;
use Throwable;
use DateTimeZone;
use DateTimeInterface;
/**
* 日期时间处理类
* @form https://gitee.com/karson/fastadmin/blob/develop/extend/fast/Date.php
*/
class Date
{
private const YEAR = 31536000;
private const MONTH = 2592000;
private const WEEK = 604800;
private const DAY = 86400;
private const HOUR = 3600;
private const MINUTE = 60;
/**
* 计算两个时区间相差的时长,单位为秒
*
* [!!] A list of time zones that PHP supports can be found at
* <http://php.net/timezones>.
* @param string $remote timezone that to find the offset of
* @param string|null $local timezone used as the baseline
* @param string|int|null $now UNIX timestamp or date string
* @return int
* @throws Throwable
* @example $seconds = self::offset('America/Chicago', 'GMT');
*/
public static function offset(string $remote, ?string $local = null, string|int|null $now = null): int
{
if ($local === null) {
// Use the default timezone
$local = date_default_timezone_get();
}
if (is_int($now)) {
// Convert the timestamp into a string
$now = date(DateTimeInterface::RFC2822, $now);
}
// Create timezone objects
$zone_remote = new DateTimeZone($remote);
$zone_local = new DateTimeZone($local);
// Create date objects from timezones
$time_remote = new DateTime($now, $zone_remote);
$time_local = new DateTime($now, $zone_local);
// Find the offset
return $zone_remote->getOffset($time_remote) - $zone_local->getOffset($time_local);
}
/**
* 计算两个时间戳之间相差的时间
*
* $span = self::span(60, 182, 'minutes,seconds'); // array('minutes' => 2, 'seconds' => 2)
* $span = self::span(60, 182, 'minutes'); // 2
*
* @param int $remote timestamp to find the span of
* @param int|null $local timestamp to use as the baseline
* @param string $output formatting string
* @return bool|array|string associative list of all outputs requested|when only a single output is requested
* @from https://github.com/kohana/ohanzee-helpers/blob/master/src/Date.php
*/
public static function span(int $remote, ?int $local = null, string $output = 'years,months,weeks,days,hours,minutes,seconds'): bool|array|string
{
// Normalize output
$output = trim(strtolower($output));
if (!$output) {
// Invalid output
return false;
}
// Array with the output formats
$output = preg_split('/[^a-z]+/', $output);
// Convert the list of outputs to an associative array
$output = array_combine($output, array_fill(0, count($output), 0));
// Make the output values into keys
extract(array_flip($output), EXTR_SKIP);
if ($local === null) {
// Calculate the span from the current time
$local = time();
}
// Calculate timespan (seconds)
$timespan = abs($remote - $local);
if (isset($output['years'])) {
$timespan -= self::YEAR * ($output['years'] = (int)floor($timespan / self::YEAR));
}
if (isset($output['months'])) {
$timespan -= self::MONTH * ($output['months'] = (int)floor($timespan / self::MONTH));
}
if (isset($output['weeks'])) {
$timespan -= self::WEEK * ($output['weeks'] = (int)floor($timespan / self::WEEK));
}
if (isset($output['days'])) {
$timespan -= self::DAY * ($output['days'] = (int)floor($timespan / self::DAY));
}
if (isset($output['hours'])) {
$timespan -= self::HOUR * ($output['hours'] = (int)floor($timespan / self::HOUR));
}
if (isset($output['minutes'])) {
$timespan -= self::MINUTE * ($output['minutes'] = (int)floor($timespan / self::MINUTE));
}
// Seconds ago, 1
if (isset($output['seconds'])) {
$output['seconds'] = $timespan;
}
if (count($output) === 1) {
// Only a single output was requested, return it
return array_pop($output);
}
// Return array
return $output;
}
/**
* 格式化 UNIX 时间戳为人易读的字符串
*
* @param int $remote Unix 时间戳
* @param ?int $local 本地时间戳
* @return string 格式化的日期字符串
*/
public static function human(int $remote, ?int $local = null): string
{
$timeDiff = (is_null($local) ? time() : $local) - $remote;
$tense = $timeDiff < 0 ? 'after' : 'ago';
$timeDiff = abs($timeDiff);
$chunks = [
[60 * 60 * 24 * 365, 'year'],
[60 * 60 * 24 * 30, 'month'],
[60 * 60 * 24 * 7, 'week'],
[60 * 60 * 24, 'day'],
[60 * 60, 'hour'],
[60, 'minute'],
[1, 'second'],
];
$count = 0;
$name = '';
for ($i = 0, $j = count($chunks); $i < $j; $i++) {
$seconds = $chunks[$i][0];
$name = $chunks[$i][1];
if (($count = floor($timeDiff / $seconds)) != 0) {
break;
}
}
return __("%d $name%s $tense", [$count, $count > 1 ? 's' : '']);
}
/**
* 获取一个基于时间偏移的Unix时间戳
*
* @param string $type 时间类型默认为day可选minute,hour,day,week,month,quarter,year
* @param int $offset 时间偏移量 默认为0正数表示当前type之后负数表示当前type之前
* @param string $position 时间的开始或结束默认为begin可选前(begin,start,first,front)end
* @param int|null $year 基准年默认为null即以当前年为基准
* @param int|null $month 基准月默认为null即以当前月为基准
* @param int|null $day 基准天默认为null即以当前天为基准
* @param int|null $hour 基准小时默认为null即以当前年小时基准
* @param int|null $minute 基准分钟默认为null即以当前分钟为基准
* @return int 处理后的Unix时间戳
*/
public static function unixTime(string $type = 'day', int $offset = 0, string $position = 'begin', ?int $year = null, ?int $month = null, ?int $day = null, ?int $hour = null, ?int $minute = null): int
{
$year = is_null($year) ? date('Y') : $year;
$month = is_null($month) ? date('m') : $month;
$day = is_null($day) ? date('d') : $day;
$hour = is_null($hour) ? date('H') : $hour;
$minute = is_null($minute) ? date('i') : $minute;
$position = in_array($position, ['begin', 'start', 'first', 'front']);
return match ($type) {
'minute' => $position ? mktime($hour, $minute + $offset, 0, $month, $day, $year) : mktime($hour, $minute + $offset, 59, $month, $day, $year),
'hour' => $position ? mktime($hour + $offset, 0, 0, $month, $day, $year) : mktime($hour + $offset, 59, 59, $month, $day, $year),
'day' => $position ? mktime(0, 0, 0, $month, $day + $offset, $year) : mktime(23, 59, 59, $month, $day + $offset, $year),
// 使用固定的 this week monday 而不是 $offset weeks monday 的语法才能确保准确性
'week' => $position ? strtotime('this week monday', mktime(0, 0, 0, $month, $day + ($offset * 7), $year)) : strtotime('this week sunday 23:59:59', mktime(0, 0, 0, $month, $day + ($offset * 7), $year)),
'month' => $position ? mktime(0, 0, 0, $month + $offset, 1, $year) : mktime(23, 59, 59, $month + $offset, self::daysInMonth($month + $offset, $year), $year),
'quarter' => $position ?
mktime(0, 0, 0, 1 + ((ceil(date('n', mktime(0, 0, 0, $month, $day, $year)) / 3) + $offset) - 1) * 3, 1, $year) :
mktime(23, 59, 59, (ceil(date('n', mktime(0, 0, 0, $month, $day, $year)) / 3) + $offset) * 3, self::daysInMonth((ceil(date('n', mktime(0, 0, 0, $month, $day, $year)) / 3) + $offset) * 3, $year), $year),
'year' => $position ? mktime(0, 0, 0, 1, 1, $year + $offset) : mktime(23, 59, 59, 12, 31, $year + $offset),
default => mktime($hour, $minute, 0, $month, $day, $year),
};
}
/**
* 获取给定月份的天数 28 到 31
*/
public static function daysInMonth(int $month, ?int $year = null): int
{
return (int)date('t', mktime(0, 0, 0, $month, 1, $year));
}
}

View File

@@ -0,0 +1,211 @@
<?php
namespace ba;
use Throwable;
/**
* 依赖管理
*/
class Depends
{
/**
* json 文件内容
* @var array
*/
protected array $jsonContent = [];
public function __construct(protected string $json, protected string $type = 'npm')
{
}
/**
* 获取 json 文件内容
* @param bool $realTime 获取实时内容
* @return array
* @throws Throwable
*/
public function getContent(bool $realTime = false): array
{
if (!file_exists($this->json)) {
throw new Exception($this->json . ' file does not exist!');
}
if ($this->jsonContent && !$realTime) return $this->jsonContent;
$content = @file_get_contents($this->json);
$this->jsonContent = json_decode($content, true);
if (!$this->jsonContent) {
throw new Exception($this->json . ' file read failure!');
}
return $this->jsonContent;
}
/**
* 设置 json 文件内容
* @param array $content
* @throws Throwable
*/
public function setContent(array $content = []): void
{
if (!$content) $content = $this->jsonContent;
if (!isset($content['name'])) {
throw new Exception('Depend content file content is incomplete');
}
$content = json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
$result = @file_put_contents($this->json, $content . PHP_EOL);
if (!$result) {
throw new Exception('File has no write permission:' . $this->json);
}
}
/**
* 获取依赖项
* @param bool $devEnv 是否是获取开发环境依赖
* @return array
* @throws Throwable
*/
public function getDepends(bool $devEnv = false): array
{
try {
$content = $this->getContent();
} catch (Throwable) {
return [];
}
if ($this->type == 'npm') {
return $devEnv ? $content['devDependencies'] : $content['dependencies'];
} else {
return $devEnv ? $content['require-dev'] : $content['require'];
}
}
/**
* 是否存在某个依赖
* @param string $name 依赖名称
* @param bool $devEnv 是否是获取开发环境依赖
* @return bool|string false或者依赖版本号
* @throws Throwable
*/
public function hasDepend(string $name, bool $devEnv = false): bool|string
{
$depends = $this->getDepends($devEnv);
return $depends[$name] ?? false;
}
/**
* 添加依赖
* @param array $depends 要添加的依赖数组["xxx" => ">=7.1.0",]
* @param bool $devEnv 是否添加为开发环境依赖
* @param bool $cover 覆盖模式
* @return void
* @throws Throwable
*/
public function addDepends(array $depends, bool $devEnv = false, bool $cover = false): void
{
$content = $this->getContent(true);
$dKey = $devEnv ? ($this->type == 'npm' ? 'devDependencies' : 'require-dev') : ($this->type == 'npm' ? 'dependencies' : 'require');
if (!$cover) {
foreach ($depends as $key => $item) {
if (isset($content[$dKey][$key])) {
throw new Exception($key . ' depend already exists!');
}
}
}
$content[$dKey] = array_merge($content[$dKey], $depends);
$this->setContent($content);
}
/**
* 删除依赖
* @param array $depends 要删除的依赖数组["php", "w7corp/easyWechat"]
* @param bool $devEnv 是否为开发环境删除依赖
* @return void
* @throws Throwable
*/
public function removeDepends(array $depends, bool $devEnv = false): void
{
$content = $this->getContent(true);
$dKey = $devEnv ? ($this->type == 'npm' ? 'devDependencies' : 'require-dev') : ($this->type == 'npm' ? 'dependencies' : 'require');
foreach ($depends as $item) {
if (isset($content[$dKey][$item])) {
unset($content[$dKey][$item]);
}
}
$this->setContent($content);
}
/**
* 获取 composer.json 的 config 字段
*/
public function getComposerConfig(): array
{
try {
$content = $this->getContent();
} catch (Throwable) {
return [];
}
return $content['config'] ?? [];
}
/**
* 设置 composer.json 的 config 字段
* @throws Throwable
*/
public function setComposerConfig(array $config, bool $cover = true): void
{
$content = $this->getContent(true);
// 配置冲突检查
if (!$cover) {
foreach ($config as $key => $item) {
if (is_array($item)) {
foreach ($item as $configKey => $configItem) {
if (isset($content['config'][$key][$configKey]) && $content['config'][$key][$configKey] != $configItem) {
throw new Exception(__('composer config %s conflict', [$configKey]));
}
}
} elseif (isset($content['config'][$key]) && $content['config'][$key] != $item) {
throw new Exception(__('composer config %s conflict', [$key]));
}
}
}
foreach ($config as $key => $item) {
if (is_array($item)) {
foreach ($item as $configKey => $configItem) {
$content['config'][$key][$configKey] = $configItem;
}
} else {
$content['config'][$key] = $item;
}
}
$this->setContent($content);
}
/**
* 删除 composer 配置项
* @throws Throwable
*/
public function removeComposerConfig(array $config): void
{
if (!$config) return;
$content = $this->getContent(true);
foreach ($config as $key => $item) {
if (isset($content['config'][$key])) {
if (is_array($item)) {
foreach ($item as $configKey => $configItem) {
if (isset($content['config'][$key][$configKey])) unset($content['config'][$key][$configKey]);
}
// 没有子级配置项了
if (!$content['config'][$key]) {
unset($content['config'][$key]);
}
} else {
unset($content['config'][$key]);
}
}
}
$this->setContent($content);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace ba;
/**
* BuildAdmin通用异常类
* catch 到异常后可以直接 $this->error(__($e->getMessage()), $e->getData(), $e->getCode());
*/
class Exception extends \Exception
{
protected array $data = [];
public function __construct(string $message = '', int $code = 0, array $data = [], ?\Throwable $previous = null)
{
$this->data = $data;
parent::__construct($message, $code, $previous);
}
public function getData(): array
{
return $this->data;
}
}

View File

@@ -0,0 +1,248 @@
<?php
namespace ba;
use Throwable;
use PhpZip\ZipFile;
use FilesystemIterator;
use RecursiveIteratorIterator;
use RecursiveDirectoryIterator;
/**
* 访问和操作文件系统
*/
class Filesystem
{
/**
* 是否是空目录
*/
public static function dirIsEmpty(string $dir): bool
{
if (!file_exists($dir)) return true;
$handle = opendir($dir);
while (false !== ($entry = readdir($handle))) {
if ($entry != "." && $entry != "..") {
closedir($handle);
return false;
}
}
closedir($handle);
return true;
}
/**
* 递归删除目录
* @param string $dir 目录路径
* @param bool $delSelf 是否删除传递的目录本身
* @return bool
*/
public static function delDir(string $dir, bool $delSelf = true): bool
{
if (!is_dir($dir)) {
return false;
}
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($files as $fileInfo) {
if ($fileInfo->isDir()) {
self::delDir($fileInfo->getRealPath());
} else {
@unlink($fileInfo->getRealPath());
}
}
if ($delSelf) {
@rmdir($dir);
}
return true;
}
/**
* 删除一个路径下的所有相对空文件夹(删除此路径中的所有空文件夹)
* @param string $path 相对于根目录的文件夹路径 如`c:BuildAdmin/a/b/`
* @return void
*/
public static function delEmptyDir(string $path): void
{
$path = str_replace(root_path(), '', rtrim(self::fsFit($path), DIRECTORY_SEPARATOR));
$path = array_filter(explode(DIRECTORY_SEPARATOR, $path));
for ($i = count($path) - 1; $i >= 0; $i--) {
$dirPath = root_path() . implode(DIRECTORY_SEPARATOR, $path);
if (!is_dir($dirPath)) {
unset($path[$i]);
continue;
}
if (self::dirIsEmpty($dirPath)) {
self::delDir($dirPath);
unset($path[$i]);
} else {
break;
}
}
}
/**
* 检查目录/文件是否可写
* @param $path
* @return bool
*/
public static function pathIsWritable($path): bool
{
if (DIRECTORY_SEPARATOR == '/' && !@ini_get('safe_mode')) {
return is_writable($path);
}
if (is_dir($path)) {
$path = rtrim($path, '/') . '/' . md5(mt_rand(1, 100) . mt_rand(1, 100));
if (($fp = @fopen($path, 'ab')) === false) {
return false;
}
fclose($fp);
@chmod($path, 0777);
@unlink($path);
return true;
} elseif (!is_file($path) || ($fp = @fopen($path, 'ab')) === false) {
return false;
}
fclose($fp);
return true;
}
/**
* 路径分隔符根据当前系统分隔符适配
* @param string $path 路径
* @return string 转换后的路径
*/
public static function fsFit(string $path): string
{
return str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
}
/**
* 解压Zip
* @param string $file ZIP文件路径
* @param string $dir 解压路径
* @return string 解压后的路径
* @throws Throwable
*/
public static function unzip(string $file, string $dir = ''): string
{
if (!file_exists($file)) {
throw new Exception("Zip file not found");
}
$zip = new ZipFile();
try {
$zip->openFile($file);
} catch (Throwable $e) {
$zip->close();
throw new Exception('Unable to open the zip file', 0, ['msg' => $e->getMessage()]);
}
$dir = $dir ?: substr($file, 0, strripos($file, '.zip'));
if (!is_dir($dir)) {
@mkdir($dir, 0755);
}
try {
$zip->extractTo($dir);
} catch (Throwable $e) {
throw new Exception('Unable to extract ZIP file', 0, ['msg' => $e->getMessage()]);
} finally {
$zip->close();
}
return $dir;
}
/**
* 创建ZIP
* @param array $files 文件路径列表
* @param string $fileName ZIP文件名称
* @return bool
* @throws Throwable
*/
public static function zip(array $files, string $fileName): bool
{
$zip = new ZipFile();
try {
foreach ($files as $v) {
if (is_array($v) && isset($v['file']) && isset($v['name'])) {
$zip->addFile(root_path() . str_replace(root_path(), '', Filesystem::fsFit($v['file'])), $v['name']);
} else {
$saveFile = str_replace(root_path(), '', Filesystem::fsFit($v));
$zip->addFile(root_path() . $saveFile, $saveFile);
}
}
$zip->saveAsFile($fileName);
} catch (Throwable $e) {
throw new Exception('Unable to package zip file', 0, ['msg' => $e->getMessage(), 'file' => $fileName]);
} finally {
$zip->close();
}
if (file_exists($fileName)) {
return true;
} else {
return false;
}
}
/**
* 递归创建目录
* @param string $dir 目录路径
* @return bool
*/
public static function mkdir(string $dir): bool
{
if (!is_dir($dir)) {
return mkdir($dir, 0755, true);
}
return false;
}
/**
* 获取一个目录内的文件列表
* @param string $dir 目录路径
* @param array $suffix 要获取的文件列表的后缀
* @return array
*/
public static function getDirFiles(string $dir, array $suffix = []): array
{
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir), RecursiveIteratorIterator::LEAVES_ONLY
);
$fileList = [];
foreach ($files as $file) {
if ($file->isDir()) {
continue;
}
if (!empty($suffix) && !in_array($file->getExtension(), $suffix)) {
continue;
}
$filePath = $file->getRealPath();
$name = str_replace($dir, '', $filePath);
$name = str_replace(DIRECTORY_SEPARATOR, "/", $name);
$fileList[$name] = $name;
}
return $fileList;
}
/**
* 将一个文件单位转为字节
* @param string $unit 将b、kb、m、mb、g、gb的单位转为 byte
* @return int byte
*/
public static function fileUnitToByte(string $unit): int
{
preg_match('/([0-9.]+)(\w+)/', $unit, $matches);
if (!$matches) {
return 0;
}
$typeDict = ['b' => 0, 'k' => 1, 'kb' => 1, 'm' => 2, 'mb' => 2, 'gb' => 3, 'g' => 3];
return (int)($matches[1] * pow(1024, $typeDict[strtolower($matches[2])] ?? 0));
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace ba;
/**
* 随机字符工具类
*/
class Random
{
public static function uuid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-',
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000
) . substr(md5(uniqid((string) mt_rand(), true)), 0, 12);
}
public static function build(string $type = 'alnum', int $len = 8): string
{
switch ($type) {
case 'alpha':
case 'alnum':
case 'numeric':
case 'noZero':
$pool = match ($type) {
'alpha' => 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
'alnum' => '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
'numeric' => '0123456789',
'noZero' => '123456789',
default => '',
};
return substr(str_shuffle(str_repeat($pool, (int) ceil($len / strlen($pool)))), 0, $len);
case 'unique':
case 'md5':
return md5(uniqid((string) mt_rand()));
case 'encrypt':
case 'sha1':
return sha1(uniqid((string) mt_rand(), true));
}
return '';
}
}

View File

@@ -0,0 +1,183 @@
<?php
namespace ba;
use Throwable;
use support\think\Db;
use Phinx\Db\Table;
use Phinx\Db\Adapter\AdapterFactory;
use Phinx\Db\Adapter\AdapterInterface;
/**
* 数据表管理类
* 使用 thinkorm 配置Phinx 适配器
*/
class TableManager
{
/**
* 返回一个 Phinx/Db/Table 实例 用于操作数据表
* @param string $table 表名
* @param array $options 传递给 Phinx/Db/Table 的 options
* @param bool $prefixWrapper 是否使用表前缀包装表名
* @param ?string $connection 连接配置标识
* @return Table
* @throws Throwable
*/
public static function phinxTable(string $table, array $options = [], bool $prefixWrapper = true, ?string $connection = null): Table
{
return new Table($table, $options, self::phinxAdapter($prefixWrapper, $connection));
}
/**
* 返回 Phinx\Db\Adapter\AdapterFactory (适配器/连接驱动)实例
* @param bool $prefixWrapper 是否使用表前缀包装表名
* @param ?string $connection 连接配置标识
* @return AdapterInterface
* @throws Throwable
*/
public static function phinxAdapter(bool $prefixWrapper = true, ?string $connection = null): AdapterInterface
{
$config = static::getPhinxDbConfig($connection);
$factory = AdapterFactory::instance();
$adapter = $factory->getAdapter($config['adapter'], $config);
if ($prefixWrapper) return $factory->getWrapper('prefix', $adapter);
return $adapter;
}
/**
* 数据表名
* @param string $table 表名,带不带前缀均可
* @param bool $fullName 是否返回带前缀的表名
* @param ?string $connection 连接配置标识
* @return string 表名
* @throws Exception
*/
public static function tableName(string $table, bool $fullName = true, ?string $connection = null): string
{
$connectionConfig = self::getConnectionConfig($connection);
$pattern = '/^' . preg_quote($connectionConfig['prefix'], '/') . '/i';
return ($fullName ? $connectionConfig['prefix'] : '') . preg_replace($pattern, '', $table);
}
/**
* 数据表列表
* @param ?string $connection 连接配置标识
* @throws Exception
*/
public static function getTableList(?string $connection = null): array
{
$tableList = [];
$config = self::getConnectionConfig($connection);
$connName = self::getConnection($connection);
$tables = Db::connect($connName)->query("SELECT TABLE_NAME,TABLE_COMMENT FROM information_schema.TABLES WHERE table_schema = ? ", [$config['database']]);
foreach ($tables as $row) {
$tableList[$row['TABLE_NAME']] = $row['TABLE_NAME'] . ($row['TABLE_COMMENT'] ? ' - ' . $row['TABLE_COMMENT'] : '');
}
return $tableList;
}
/**
* 获取数据表所有列
* @param string $table 数据表名
* @param bool $onlyCleanComment 只要干净的字段注释信息
* @param ?string $connection 连接配置标识
* @throws Throwable
*/
public static function getTableColumns(string $table, bool $onlyCleanComment = false, ?string $connection = null): array
{
if (!$table) return [];
$table = self::tableName($table, true, $connection);
$config = self::getConnectionConfig($connection);
$connName = self::getConnection($connection);
$sql = "SELECT * FROM `information_schema`.`columns` "
. "WHERE TABLE_SCHEMA = ? AND table_name = ? "
. "ORDER BY ORDINAL_POSITION";
$columnList = Db::connect($connName)->query($sql, [$config['database'], $table]);
$fieldList = [];
foreach ($columnList as $item) {
if ($onlyCleanComment) {
$fieldList[$item['COLUMN_NAME']] = '';
if ($item['COLUMN_COMMENT']) {
$comment = explode(':', $item['COLUMN_COMMENT']);
$fieldList[$item['COLUMN_NAME']] = $comment[0];
}
continue;
}
$fieldList[$item['COLUMN_NAME']] = $item;
}
return $fieldList;
}
/**
* 系统是否存在多个数据库连接配置
*/
public static function isMultiDatabase(): bool
{
return count(config('thinkorm.connections', [])) > 1;
}
/**
* 获取数据库连接配置标识
* @param ?string $source
* @return string 连接配置标识
*/
public static function getConnection(?string $source = null): string
{
if (!$source || $source === 'default') {
return config('thinkorm.default', 'mysql');
}
return $source;
}
/**
* 获取某个数据库连接的配置数组
* @param ?string $connection 连接配置标识
* @throws Exception
*/
public static function getConnectionConfig(?string $connection = null): array
{
$connName = self::getConnection($connection);
$connection = config("thinkorm.connections.{$connName}");
if (!is_array($connection)) {
throw new Exception('Database connection configuration error');
}
// 分布式
if (($connection['deploy'] ?? 0) == 1) {
$keys = ['type', 'hostname', 'database', 'username', 'password', 'hostport', 'charset', 'prefix'];
foreach ($connection as $key => $item) {
if (in_array($key, $keys)) {
$connection[$key] = is_array($item) ? $item[0] : explode(',', $item)[0];
}
}
}
return $connection;
}
/**
* 获取 Phinx 适配器需要的数据库配置
* @param ?string $connection 连接配置标识
* @return array
* @throws Throwable
*/
protected static function getPhinxDbConfig(?string $connection = null): array
{
$config = self::getConnectionConfig($connection);
$connName = self::getConnection($connection);
$db = Db::connect($connName);
$db->query('SELECT 1');
$table = config('thinkorm.migration_table', 'migrations');
return [
'adapter' => $config['type'],
'connection' => $db->getPdo(),
'name' => $config['database'],
'table_prefix' => $config['prefix'],
'migration_table' => $config['prefix'] . $table,
];
}
}

View File

@@ -0,0 +1,381 @@
<?php
namespace ba;
use Throwable;
use support\Response;
use app\admin\library\Auth;
use app\common\library\token\TokenExpirationException;
use Workerman\Protocols\Http\ServerSentEvents;
use Workerman\Protocols\Http\Response as WorkermanResponse;
/**
* WEB 终端SSE 命令执行)
* 适配 Webman使用 request()->connection、config()
*/
class Terminal
{
protected static ?Terminal $instance = null;
protected string $commandKey = '';
protected array $descriptorsPec = [];
protected $process = false;
protected array $pipes = [];
protected int $procStatusMark = 0;
protected array $procStatusData = [];
protected string $uuid = '';
protected string $extend = '';
protected string $outputFile = '';
protected string $outputContent = '';
protected static string $distDir = 'web' . DIRECTORY_SEPARATOR . 'dist';
protected array $flag = [
'link-success' => 'command-link-success',
'exec-success' => 'command-exec-success',
'exec-completed' => 'command-exec-completed',
'exec-error' => 'command-exec-error',
];
public static function instance(): Terminal
{
if (is_null(self::$instance)) {
self::$instance = new static();
}
return self::$instance;
}
public function __construct()
{
$request = request();
$this->uuid = $request ? $request->param('uuid', '') : '';
$this->extend = $request ? $request->param('extend', '') : '';
$outputDir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'terminal';
$this->outputFile = $outputDir . DIRECTORY_SEPARATOR . 'exec.log';
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
file_put_contents($this->outputFile, '');
$this->descriptorsPec = [0 => ['pipe', 'r'], 1 => ['file', $this->outputFile, 'w'], 2 => ['file', $this->outputFile, 'w']];
}
public static function getCommand(string $key): bool|array
{
if (!$key) {
return false;
}
$commands = config('terminal.commands', []);
if (stripos($key, '.')) {
$key = explode('.', $key);
if (!array_key_exists($key[0], $commands) || !is_array($commands[$key[0]]) || !array_key_exists($key[1], $commands[$key[0]])) {
return false;
}
$command = $commands[$key[0]][$key[1]];
} else {
if (!array_key_exists($key, $commands)) {
return false;
}
$command = $commands[$key];
}
if (!is_array($command)) {
$command = [
'cwd' => root_path(),
'command' => $command,
];
} else {
$command['cwd'] = root_path() . ($command['cwd'] ?? '');
}
$request = request();
if ($request && str_contains($command['command'], '%')) {
$args = $request->param('extend', '');
$args = explode('~~', $args);
$args = array_map('escapeshellarg', $args);
array_unshift($args, $command['command']);
$command['command'] = call_user_func_array('sprintf', $args);
}
$command['cwd'] = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $command['cwd']);
return $command;
}
public function exec(bool $authentication = true): void
{
$this->sendHeader();
while (ob_get_level()) {
ob_end_clean();
}
if (!ob_get_level()) ob_start();
$request = request();
$this->commandKey = $request ? $request->param('command', '') : '';
$command = self::getCommand($this->commandKey);
if (!$command) {
$this->execError('The command was not allowed to be executed', true);
}
if ($authentication) {
try {
$token = get_auth_token();
$auth = Auth::instance();
$auth->init($token);
if (!$auth->isLogin() || !$auth->isSuperAdmin()) {
$this->execError("You are not super administrator or not logged in", true);
}
} catch (TokenExpirationException) {
$this->execError(__('Token expiration'));
}
}
$this->beforeExecution();
$this->outputFlag('link-success');
if (!empty($command['notes'])) {
$this->output('> ' . __($command['notes']), false);
}
$this->output('> ' . $command['command'], false);
$this->process = proc_open($command['command'], $this->descriptorsPec, $this->pipes, $command['cwd']);
if (!is_resource($this->process)) {
$this->execError('Failed to execute', true);
}
while ($this->getProcStatus()) {
$contents = file_get_contents($this->outputFile);
if (strlen($contents) && $this->outputContent != $contents) {
$newOutput = str_replace($this->outputContent, '', $contents);
$this->checkOutput($contents, $newOutput);
if (preg_match('/\r\n|\r|\n/', $newOutput)) {
$this->output($newOutput);
$this->outputContent = $contents;
}
}
if ($this->procStatusMark === 2) {
$this->output('exitCode: ' . $this->procStatusData['exitcode']);
if ($this->procStatusData['exitcode'] === 0) {
if ($this->successCallback()) {
$this->outputFlag('exec-success');
} else {
$this->output('Error: Command execution succeeded, but callback execution failed');
$this->outputFlag('exec-error');
}
} else {
$this->outputFlag('exec-error');
}
}
usleep(500000);
}
foreach ($this->pipes as $pipe) {
fclose($pipe);
}
proc_close($this->process);
$this->outputFlag('exec-completed');
}
public function getProcStatus(): bool
{
$this->procStatusData = proc_get_status($this->process);
if ($this->procStatusData['running']) {
$this->procStatusMark = 1;
return true;
} elseif ($this->procStatusMark === 1) {
$this->procStatusMark = 2;
return true;
} else {
return false;
}
}
public function output(string $data, bool $callback = true): void
{
$data = self::outputFilter($data);
$data = [
'data' => $data,
'uuid' => $this->uuid,
'extend' => $this->extend,
'key' => $this->commandKey,
];
$data = json_encode($data, JSON_UNESCAPED_UNICODE);
if ($data) {
$this->finalOutput($data);
if ($callback) $this->outputCallback($data);
@ob_flush();
}
}
public function checkOutput(string $outputs, string $rowOutput): void
{
if (str_contains($rowOutput, '(Y/n)')) {
$this->execError('Interactive output detected, please manually execute the command to confirm the situation.', true);
}
}
public function outputFlag(string $flag): void
{
$this->output($this->flag[$flag], false);
}
public function outputCallback($data): void
{
}
public function successCallback(): bool
{
if (stripos($this->commandKey, '.')) {
$commandKeyArr = explode('.', $this->commandKey);
$commandPKey = $commandKeyArr[0] ?? '';
} else {
$commandPKey = $this->commandKey;
}
if ($commandPKey == 'web-build') {
if (!self::mvDist()) {
$this->output('Build succeeded, but move file failed. Please operate manually.');
return false;
}
} elseif ($commandPKey == 'web-install' && $this->extend && class_exists(\app\admin\library\module\Manage::class)) {
[$type, $value] = explode(':', $this->extend);
if ($type == 'module-install' && $value) {
\app\admin\library\module\Manage::instance($value)->dependentInstallComplete('npm');
}
} elseif ($commandPKey == 'composer' && $this->extend && class_exists(\app\admin\library\module\Manage::class)) {
[$type, $value] = explode(':', $this->extend);
if ($type == 'module-install' && $value) {
\app\admin\library\module\Manage::instance($value)->dependentInstallComplete('composer');
}
} elseif ($commandPKey == 'nuxt-install' && $this->extend && class_exists(\app\admin\library\module\Manage::class)) {
[$type, $value] = explode(':', $this->extend);
if ($type == 'module-install' && $value) {
\app\admin\library\module\Manage::instance($value)->dependentInstallComplete('nuxt_npm');
}
}
return true;
}
public function beforeExecution(): void
{
if ($this->commandKey == 'test.pnpm') {
@unlink(root_path() . 'public' . DIRECTORY_SEPARATOR . 'npm-install-test' . DIRECTORY_SEPARATOR . 'pnpm-lock.yaml');
} elseif ($this->commandKey == 'web-install.pnpm') {
@unlink(root_path() . 'web' . DIRECTORY_SEPARATOR . 'pnpm-lock.yaml');
}
}
public static function outputFilter($str): string
{
$str = trim($str);
$preg = '/\x1b\[(.*?)m/i';
$str = preg_replace($preg, '', $str);
$str = str_replace(["\r\n", "\r", "\n"], "\n", $str);
return mb_convert_encoding($str, 'UTF-8', 'UTF-8,GBK,GB2312,BIG5');
}
public function execError($error, $break = false): void
{
$this->output('Error:' . $error);
$this->outputFlag('exec-error');
if ($break) $this->break();
}
public function break(): void
{
throw new TerminalBreakException();
}
public static function getOutputFromProc($commandKey): bool|string
{
if (!function_exists('proc_open') || !function_exists('proc_close')) {
return false;
}
$command = self::getCommand($commandKey);
if (!$command) {
return false;
}
$descriptorsPec = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open($command['command'], $descriptorsPec, $pipes, null, null);
if (is_resource($process)) {
$info = stream_get_contents($pipes[1]);
$info .= stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
return self::outputFilter($info);
}
return '';
}
public static function mvDist(): bool
{
$distPath = root_path() . self::$distDir . DIRECTORY_SEPARATOR;
$indexHtmlPath = $distPath . 'index.html';
$assetsPath = $distPath . 'assets';
if (!file_exists($indexHtmlPath) || !file_exists($assetsPath)) {
return false;
}
$toIndexHtmlPath = root_path() . 'public' . DIRECTORY_SEPARATOR . 'index.html';
$toAssetsPath = root_path() . 'public' . DIRECTORY_SEPARATOR . 'assets';
@unlink($toIndexHtmlPath);
Filesystem::delDir($toAssetsPath);
if (rename($indexHtmlPath, $toIndexHtmlPath) && rename($assetsPath, $toAssetsPath)) {
Filesystem::delDir($distPath);
return true;
} else {
return false;
}
}
public static function changeTerminalConfig($config = []): bool
{
$request = request();
$oldPackageManager = config('terminal.npm_package_manager');
$newPackageManager = $request ? $request->post('manager', $config['manager'] ?? $oldPackageManager) : $oldPackageManager;
if ($oldPackageManager == $newPackageManager) {
return true;
}
$buildConfigFile = config_path('terminal.php');
$buildConfigContent = @file_get_contents($buildConfigFile);
$buildConfigContent = preg_replace("/'npm_package_manager'(\s+)=>(\s+)'$oldPackageManager'/", "'npm_package_manager'\$1=>\$2'$newPackageManager'", $buildConfigContent);
$result = @file_put_contents($buildConfigFile, $buildConfigContent);
return (bool)$result;
}
public function finalOutput(string $data): void
{
$request = request();
$connection = $request && isset($request->connection) ? $request->connection : null;
if ($connection) {
$connection->send(new ServerSentEvents(['event' => 'message', 'data' => $data]));
} else {
echo 'data: ' . $data . "\n\n";
}
}
public function sendHeader(): void
{
$request = request();
$headers = array_merge($request->allowCrossDomainHeaders ?? [], [
'X-Accel-Buffering' => 'no',
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
]);
$connection = $request && isset($request->connection) ? $request->connection : null;
if ($connection) {
$connection->send(new WorkermanResponse(200, $headers, "\r\n"));
} else {
foreach ($headers as $name => $val) {
header($name . (!is_null($val) ? ':' . $val : ''));
}
}
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace ba;
/**
* 终端中断异常(用于 break() 停止 SSE 流)
*/
class TerminalBreakException extends \Exception
{
}

View File

@@ -0,0 +1,145 @@
<?php
namespace ba;
/**
* 树
*/
class Tree
{
/**
* 实例
* @var ?Tree
*/
protected static ?Tree $instance = null;
/**
* 生成树型结构所需修饰符号
* @var array
*/
public static array $icon = array('│', '├', '└');
/**
* 子级数据(树枝)
* @var array
*/
protected array $children = [];
/**
* 初始化
* @access public
* @return Tree
*/
public static function instance(): Tree
{
if (is_null(self::$instance)) {
self::$instance = new static();
}
return self::$instance;
}
/**
* 将数组某个字段渲染为树状,需自备children children可通过$this->assembleChild()方法组装
* @param array $arr 要改为树状的数组
* @param string $field '树枝'字段
* @param int $level 递归数组层次,无需手动维护
* @param bool $superiorEnd 递归上一级树枝是否结束,无需手动维护
* @return array
*/
public static function getTreeArray(array $arr, string $field = 'name', int $level = 0, bool $superiorEnd = false): array
{
$level++;
$number = 1;
$total = count($arr);
foreach ($arr as $key => $item) {
$prefix = ($number == $total) ? self::$icon[2] : self::$icon[1];
if ($level == 2) {
$arr[$key][$field] = str_pad('', 4) . $prefix . $item[$field];
} elseif ($level >= 3) {
$arr[$key][$field] = str_pad('', 4) . ($superiorEnd ? '' : self::$icon[0]) . str_pad('', ($level - 2) * 4) . $prefix . $item[$field];
}
if (isset($item['children']) && $item['children']) {
$arr[$key]['children'] = self::getTreeArray($item['children'], $field, $level, $number == $total);
}
$number++;
}
return $arr;
}
/**
* 递归合并树状数组根据children多维变二维方便渲染
* @param array $data 要合并的数组 ['id' => 1, 'pid' => 0, 'title' => '标题1', 'children' => ['id' => 2, 'pid' => 1, 'title' => ' └标题1-1']]
* @return array [['id' => 1, 'pid' => 0, 'title' => '标题1'], ['id' => 2, 'pid' => 1, 'title' => ' └标题1-1']]
*/
public static function assembleTree(array $data): array
{
$arr = [];
foreach ($data as $v) {
$children = $v['children'] ?? [];
unset($v['children']);
$arr[] = $v;
if ($children) {
$arr = array_merge($arr, self::assembleTree($children));
}
}
return $arr;
}
/**
* 递归的根据指定字段组装 children 数组
* @param array $data 数据源 例如:[['id' => 1, 'pid' => 0, title => '标题1'], ['id' => 2, 'pid' => 1, title => '标题1-1']]
* @param string $pid 存储上级id的字段
* @param string $pk 主键字段
* @return array ['id' => 1, 'pid' => 0, 'title' => '标题1', 'children' => ['id' => 2, 'pid' => 1, 'title' => '标题1-1']]
*/
public function assembleChild(array $data, string $pid = 'pid', string $pk = 'id'): array
{
if (!$data) return [];
$pks = [];
$topLevelData = []; // 顶级数据
$this->children = []; // 置空子级数据
foreach ($data as $item) {
$pks[] = $item[$pk];
// 以pid组成children
$this->children[$item[$pid]][] = $item;
}
// 上级不存在的就是顶级,只获取它们的 children
foreach ($data as $item) {
if (!in_array($item[$pid], $pks)) {
$topLevelData[] = $item;
}
}
if (count($this->children) > 0) {
foreach ($topLevelData as $key => $item) {
$topLevelData[$key]['children'] = $this->getChildren($this->children[$item[$pk]] ?? [], $pk);
}
return $topLevelData;
} else {
return $data;
}
}
/**
* 获取 children 数组
* 辅助 assembleChild 组装 children
* @param array $data
* @param string $pk
* @return array
*/
protected function getChildren(array $data, string $pk = 'id'): array
{
if (!$data) return [];
foreach ($data as $key => $item) {
if (array_key_exists($item[$pk], $this->children)) {
$data[$key]['children'] = $this->getChildren($this->children[$item[$pk]], $pk);
}
}
return $data;
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace ba;
/**
* 版本类
*/
class Version
{
/**
* 比较两个版本号
* @param string $v1 要求的版本号
* @param bool|string $v2 被比较版本号
* @return bool 是否达到要求的版本号
*/
public static function compare(string $v1, bool|string $v2): bool
{
if (!$v2) {
return false;
}
if (strtolower($v1[0]) == 'v') {
$v1 = substr($v1, 1);
}
if (strtolower($v2[0]) == 'v') {
$v2 = substr($v2, 1);
}
if ($v1 == "*" || $v1 == $v2) {
return true;
}
if (str_contains($v1, '-')) $v1 = explode('-', $v1)[0];
if (str_contains($v2, '-')) $v2 = explode('-', $v2)[0];
$v1 = explode('.', $v1);
$v2 = explode('.', $v2);
for ($i = 0; $i < count($v1); $i++) {
if (!isset($v2[$i])) {
break;
}
if ($v1[$i] == $v2[$i]) {
continue;
}
if ($v1[$i] > $v2[$i]) {
return false;
}
if ($v1[$i] < $v2[$i]) {
return true;
}
}
if (count($v1) != count($v2)) {
return !(count($v1) > count($v2));
}
return false;
}
/**
* 是否是一个数字版本号
* @param $version
* @return bool
*/
public static function checkDigitalVersion($version): bool
{
if (!$version) {
return false;
}
if (strtolower($version[0]) == 'v') {
$version = substr($version, 1);
}
$rule1 = '/\.{2,10}/';
$rule2 = '/^\d+(\.\d+){0,10}$/';
if (!preg_match($rule1, (string)$version)) {
return !!preg_match($rule2, (string)$version);
}
return false;
}
/**
* @return string
*/
public static function getCnpmVersion(): string
{
$execOut = Terminal::getOutputFromProc('version.cnpm');
if ($execOut) {
$preg = '/cnpm@(.+?) \(/is';
preg_match($preg, $execOut, $result);
return $result[1] ?? '';
} else {
return '';
}
}
/**
* 获取依赖版本号
* @param string $name 支持npm、cnpm、yarn、pnpm、node
* @return string
*/
public static function getVersion(string $name): string
{
if ($name == 'cnpm') {
return self::getCnpmVersion();
} elseif (in_array($name, ['npm', 'yarn', 'pnpm', 'node'])) {
$execOut = Terminal::getOutputFromProc('version.' . $name);
if ($execOut) {
if (strripos($execOut, 'npm WARN') !== false) {
$preg = '/\d+(\.\d+){0,2}/';
preg_match($preg, $execOut, $matches);
if (isset($matches[0]) && self::checkDigitalVersion($matches[0])) {
return $matches[0];
}
}
$execOut = preg_split('/\r\n|\r|\n/', $execOut);
for ($i = 0; $i < 2; $i++) {
if (isset($execOut[$i]) && self::checkDigitalVersion($execOut[$i])) {
return $execOut[$i];
}
}
} else {
return '';
}
}
return '';
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="200px" height="200px" viewBox="0 0 200 200" enable-background="new 0 0 200 200" xml:space="preserve"> <image id="image0" width="200" height="200" x="0" y="0"
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAMAAACahl6sAAAABGdBTUEAALGPC/xhBQAAACBjSFJN
AAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAC+lBMVEX///9No/1Dnv1Dnv1D
nv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Fn/1En/1GoP1Dnv1Dnv1Dnv1Dnv1Dnv1D
nv1Dnv1Dnv1Dnv1Dnv1Dnv1Jof1Dnv1Dnv1Zqf1Dnv1cq/1OpP1Qpf1Tp/1Mo/1PpP1En/2antB2
w/9+vfeAvvVxvv6FltWas+F41f+Dyv+FzP9zwP5PpP1Vp/1Vp/1erP1erP1ss/1Fn/1Vp/3TlW9i
tP6/mLFfsv5Lov1psv1Mo/2Gzf+ck8Nruv5Jof1Spv28nrpJov1jr/1ttP1Tpv3Clpywj7Vbqv1M
o/1nmud7xf99x/9Rpf1Iof22ka9br/68k69Kov1OpP1VqP3FpoZ2ldhUp/2VkM5rs/3EmZZouP5K
ov1Qpf11leJMnfe3psVOpP1nsf1Vp/3AkaSIzv9bq/xRpf1Up/1bmu+Cyv+qkbpNpP3Gn4+TksRW
qP1HoP1GoP1dsf5Tpv1/yP+7kqtNpP3EqYJUp/2lk7xMo/1Spv1WqP1Uqv1WqP1jvP7FjqXifIWn
msOQmM3neH/LiZ6BpuLWgZFwqe7ag5DThpatl72UotS8hKJSpv05mf0/nP114f+Fzv+I0P+B1v+K
z/89m/1Anf1Dnv2Z5XaF0PyLz/+Uxe1+2f9Cnv07mv3BwVCd52d43v8+m/2v4zqq12aHzfnBsnqp
7jSurtKMzf+G1P86mv2h6Vm12jymtty2w2212kCGzvSj412s2l+s2GK9uXSh61Zz5P9Cnf2m3l6r
3lqu11+otNiwzmeo41at2F+Zv+p83f+k6FWz2kSKz+OCzf+v2FGLz96Pyvaz2kmw2U6N0Naos9qQ
xvLFrnWy2U+Q0c2fu+SS0cGw2VWV0bmV0ras2GGX0q+w2ViY0qr+YVz/XVb5Z2b/V03Nk6iawOH8
ZGL0bm37Z2WG6bT1bGzXiZmd8lv/XWL4amm/u2r/XWn/WlHFtWL/V277Y27rc3jDnLar3F/LrGSZ
+VTya23////Mjm1rAAAAlnRSTlMABAsRHCczOkFGST43LyQXDwhTX2ZvdWlkW08sVQJiIBJYaty+
zuHWd7zDwsW0l+Xl5uy6i7axf28x/cD4meOV8kKE8amp9NjwfGEjmdy7lO+FzdOQ98SP2X/npvaL
yaE24qPq4ox4/odNotP5jd7Gf+K26uyeqnr7k9TZzoH3nbHtlLqGrqPe+smh/t+r753z6c68y0O0
Xjp1AAAAAWJLR0QAiAUdSAAAAAd0SU1FB+YEBBcDHr5guP0AAA+vSURBVHja7V15fFTVFQ7ZzL6R
vABJALUI2koVWUOriIo7SgVqQFErm4hgRQOlYBBLpdpFu8wgcTIVzVhSsNKgUkRJQYjWarW2tNTW
2oRYhqaaGitd+KPzZubet913z/dme6G/fD9//hTecr+593z3nHvOuzctrR/96Ec/+hHGgPSMzKzs
U3Jy8/LycvMLCouKS0rdbpNTlBQV5pcNLK9QjKgcNHhIQVXmyUEnPaugulyRomJgXnZmjdsNlaG0
KH9ghYKhsjo70+32ilFSWIaSYCjPLeprHZNeWO2QBOuY3CK3265D0RCnfWHol/xitwmEUVowNA4W
EVRnuc0iLSMnns7QMDTbVWvJyE0IizAqCwa4RaMkgTTCVLJdoTEgPzGDyjDAXLCVqsqE01BRnWIF
K45x2gCQk0qrPyVpNEIoH5ay7hicTB4h5KamUwqSTCOEQSnwJ0vLks8jhIJk88hMjlhZUZbc6bEw
RTRCKE+mEOekjkcISZsda1JjHhqSZCglA1PMI6TDyeAxPPGuFY2yxPModoOHopyacB6u0Ahh8P8J
jwQzyXCPR0JHV7o79sGQOIuPf5kkPuQniEfygigUhU6ae9rpnxJjxOkMZ4wcxZ995lmfFuAzZ6Nt
G/3Zc84VYcx57Iqx48ZzTJgoxmkCIpNqPV4Knsm8IZ/b9OhmET4PEjm/cfNjQlzArpgCtGe8qEsu
9DX5CXibpnIiP3z80S2CdlyE8bj4iSe3CHlcwq4Y52km4A9ME/v6kz0UEb/nUj40nvrRo0Im2OC6
bKuYxxh2weW1LVRjmgNXiK3kyiYvdW+T7yr2pqt/vG27kMnlAI/p2xuJgTWC/lUDI+3s/Rrg5mvZ
m2Y8/RMxky/QPGY8s3WHkMd17IqZXnKcN3kn2REZMMtHD67x7F2zt9n0yRdJItc3/lTcIXXsijnA
MJ9rL8FXtDZTt/tq+dC5YeezQibnUjxufM7GQrh9zQ2QDWnxD5dMJqcH6B9iHnvbTc/v3LXtZwIm
lxBEzt8qll5N8ZChMUE2K97cAgzNsex1t+x+YdceAZOLRkl5TLeT3i+xKwD9DNwqn9/HA2NzPnvf
ghdf2iPsk/NkPBYu2iu2dD4kF9Py2ea7TU5kuJ9+RmAJe+PtP98nZLL0DgmR2Y/bWDoXiWXAr7k8
jcAEoFensaFz59Mv7Rcy+bLE0l+2kV4u21chLsaVFJGaabSdBeayd9514KCYib3Ldf6TYktfyqV3
BaA4SygeqgSTj/FO4RJ898729j0Cix9jx+OeV14lnKx6eg5oqR1OE0kbCYzQEeyto5//xf72Pa+1
//J1U/PsXK7LtooNhDNfuQqQ3okAj7RJgATrXK7d+0J98tobvzIzEbtcd71pI71fYVesBoz0DIQH
JMGt3OVS7T3E5K1fm5lcJ+KxcNEmMY+vsivGemnPNXAhRgSQYIPLtS/C5G0TE5HLtcZOeteyK+YD
lj4Z4wFJsM7lulftkhCTt0x9InC5bvzNJrH0nuXA0gHp5bgV+Fm4vasSHGbyhsnirRJ8vU18u5Rd
MAqx9GtgHmkTA23kQNVcLlWCI0yMfTLG7HLd84rNXMidrOXAWFhVghMpn+/E5QpLcLvA4s0ul530
cq8XcLL8rfU5MI9MRDvaAg3s/SGXK9onJouvM/BY91sb6eVjEHCyWu9TKmAiZYoyD3G5uAQfCtt7
2OLfft1Ogu9ctJWQ3oYAMIOtVxS06K4UnV/vZy2Yvu1glMnvXvu9vk/0ErymkZLeKYDGrA5dVw0S
Cadux9Fd0uJfyZpwQ8TeLUx0EkzHt8BE7G0LL6yBlcOnwj8Pl+CbovYeZSLQI1uvl3OFBkFkqRNb
Cy6NPHeJIwlm9m5mspRJ8D2HbbxevpIFmKVvVeRxWMqEFQUgEjyHW/LTUXs3M2Fztp30OnGymltZ
IASNLZZNB0S9WQuxuL2rTN76g8ZkrVx6v6bgv1sr/92qAB4DKhz0dWAWu3ght3eVyS6NSfgnt11w
4E5WA72S1eSdya4eAhAZxs1zZa0TCdbsPcTknV1/5EzUSGP2drH0LuW6Nw2U3giQOVFXb7IB0MNm
PgvcsvsFrU/eefZPjMkYidfL49v7gVf5dY4CUNClL9S4D/iZeKJhwYvc3tvb39WYbP668kAjIb11
zUAIdL+uZXSZSqnuamU9vTAT9hkiWMMl2MBk8znrXraRXj7xI/HtFH3L6Mm9SH+5cinkxTEJPqTr
EpXJn6N98opYebWVrJn0MkGbr0HfMNpIjAWkUyG/ml297sBBPZP3oky2NG4OdYhgcPEgE1nJmm9o
mEKWpZly0cBysm8Vl567NQmOMPkLs/gdT1rnEe5kIfGtd7GxYeRMYq5ZRByg5ezi0ToJ1vfJYzs2
PbfIbCfO4tvlpnZR0VW66XplLu1yeZt5rvdqvb1rfbJj7xMdndtNfeIkieCbZQ6bqVqbIjMRZT69
hKpJ8Ay9BHMmIR5HOrve32sgwte5p7Y5cYUYKgki1jJSZMVMk+DZ7x60Mtn716OdwWMdTxgCEp55
AJTRs8LSLCVdTiTfegeQKNYkeOG9L7W3m5j8TeXRHez6+yadcnHpReYq73prs4gKekEBDeRybWBX
X3zA2CX73/3gqQ8/DHZ3d/d0HtYcFR6nKNc6crJQ2aoRVZICLpdBgt/T83jhg3989JHKozvYu/FV
7jqe7eDhLf46QavkTkq68OtUxOXiud4FL+okOMTj439+EuYRQtczzJl3lkQYJ2qUXH8zhLVyQC5M
GPWaeAQ7jzdGKzf4SpajZScD5N5WpvAeSFi4C6FFvXoe6r+DXdH4Sotvgfytlns1QD6RZImJTKW9
bN37WNRr6o/QPx3BzeEucRLfepaJ2zRUSiRbfBOUwecjIBr1GnlEyHT9S+0SHt8CKzUWJ4uhXErE
7uOpUUhNBbfJmw6E7H3/vy08uruPHnvz1S1LudeLxLfzbJokn9ptv6uoR1SSN/CW3ftCPP5j4RHq
kvf3ak4WsppZuzImIvbfeiLzFl94XHBop5BHd/eRjsPnsKuAIjkb6VUhD63ybIkg5QiaBE/f9l8h
j1CXbOSWjhTJTVESTQSKevkCmnLvx0Ie3d2933Dw07RpafDEEQGi3mbN5Xrwk6MiHsHOh7iThfgL
y5QYici+h3YW9X7zRI+ASE/nt9gFSBKhaXGsRPIlRKCyNi6WM44fEXRI77fZ30OWvlzSGrlqST/C
BQoNdS7XmhNWJkc6v8P+GrB0lkSIhYj8O8M5TlZtFj5kIRLsup797Vi6HFa3ziTCICmRItmtSDGu
Lte77vgxE4+OHq1Sw0nYKcRAKRHiEyRnK5sP95jsvetM9ldIOsy3XtoWufdbIv8Utw6xUL7WPNrY
JcHOjfxBs2KMb3WQp0hKB8nvHkevDekCU6ME9/Ry6QWU3CuMb3UgPvOhvrlH/FXucs3Q23uw9xHe
sUgSYTLREmLLjiHE7YAE61yu2bouOdbBV7Lill4VxCfJ5PYBSPW9tnC+8Ygmvd9lfwiIHyG9Koic
VRV1P5JF1qLei5m9BztOcK93DrAIu4JqRwXx4X4m9QDI+Z7Gr36ESXDX99gfAUkEXf7WDvL5UJ+c
tgPkJOkk+EhUetmYh6Lm1VQr6K8sB5GPAAJUnXbeHrb3nl7uZAHS20JJbwinUETyyEc4W629U5Xg
YO8D7P+RIjlD/tYG5D4K2fQz0PqwCO46cSx49Ci3dKBIThLfaiihiNDWjlXsabLzcGhgOYpvAw10
E8opHmlpwJfSi2kJ9nvq2dUPHu89wQNHZFjOp1ug5NFEkE+MoapWPjU/0uEsvh0LtAAoPUO2BoKS
AVqu9/vsv+qQnNE8oAFKBk0E2hWh3gNUfk+13AY5WSuB9wMmYs20C4G4XJeab0JK2VrnIq+Hapih
LfKAmLupxRziIR+BzUHejlQ56SvPZACiXnPQ3QDEty2kk6UCrMWGtqqo89OfaZt88Vjzt1YA4qsC
8FIUyOUyGi5QJIc4WSrAXeqwsQVVOOuWCoEulCQRjCML3aMO2walwUfnzXSLt46L5OwBb/IE7gfm
6CMTIL5tRpwsFfBeaCXY86DcMmubs1hfiqEoD1bmTwJwudhoqQdWX2RJBD2QMmxn5g6Vvo2Dr1yO
vRU2dRXl2DM3AN/1hhXVWZJIDkc7CaHbGIK5XqBITveJJgFn++eDG1EhEuxdnEjpRWd1hmzwsYgf
OALJq/hA6aVKAGPtEsQz99cCA2sZ9j6nHYJbCVBwBSwAwdLruEPA+AqLemmg0otFVEYUgY+upyWY
BJ1EiIJauhYC3Wf5vviZ0EmEKBzt4caA7mm4nv5mguJBJhGioJbgbYBuiguU20hB5W81xLoFMGjv
a4GUoAzW5RYbxLzZLLIOrAIIYiUg87cM+OffFmDRu6IAu9tIOgRIIoQRz9bl4OACcr220L7JTNbA
UoHuKrsiZgluDtRjr4BWSe0B7nYPfDthA1h6gWVrKcCNTAFHXQggfxuBg/hWjBrMTOqA3W1E0Oo9
5EjANt/gBB+bBKPSm5BtmEHvEahcEnQIJr2ViTmfIBt6GVBLZgEa38Zr6AzYoULAwqMJUP5Wgdes
AUBnEQDlNuaBhS0txuS72wBaegQyhEZLx+LbxJ6wgERZlwM5W0OHQPlbsugkCUzGO3JUsKXFRPPA
RheQ+9EAOVnJOLmDKndU1IVH3OWC8rfJOTQtn34xHvVCTlayDn3MJt+8to1OFEY7hM7fVg5PEo+0
tGHkQiqQPwgDyN+emsxjazPI422ACudwh5D526Qca6MDFcYvgaJe2smKO/4gQa1uA1lP2skamIrT
BDPkc+NMpLyOcLKSPawY5KcI0i4X4WSl7hzBtGLZNE9XOMuTCLkpPQQ1WyLEVLmN1MkanOoTqdMl
0RbhcrVusL2zwo2DXIttwy15hVnA/iOwHJeObh9mZyqyqLfJ9vvb3ESF5jGgSExFFvXa5W/dpKEi
U+jd20uwWHor80vcpaEiI9+6GLnSbxf1iqR3cKFrx04bUZNlsXs7CbZKb2VuqgVXivTCauPMYlNu
Y8rfVuYVuXqcuZhL1RDdGBN/X2FIIgzN6YMsIqjJLKhmZETlNlr+dlBeocsqRZMprsoZXC4ut1Hj
24qhZQVZfZ2EhvRhVdatVLz+H2RlujR5x4EBllyv/HygvgvzSSbU+UB9F8aTTNoCt8X/SHdwoeE7
P89qt9sTO/SJRa/3ZrebEzuKdRLsqXe7NfHgDt4l2PlAfRbaYVInq/QysPP8Tl7pZRjZGnGyTlrp
ZZgUrsKmj2br+1D3+HRyPlCfhXqYlJPzgfouJnh8q04+l1eEWz1XxP+QvoCJ4NFsfR5Xnhb/M/rR
j370I4L/AYWr1zfavhjTAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDIyLTA0LTA0VDE1OjAzOjMwKzA4
OjAwP7kofQAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyMi0wNC0wNFQxNTowMzozMCswODowME7kkMEA
AAAgdEVYdHNvZnR3YXJlAGh0dHBzOi8vaW1hZ2VtYWdpY2sub3JnvM8dnQAAABh0RVh0VGh1bWI6
OkRvY3VtZW50OjpQYWdlcwAxp/+7LwAAABh0RVh0VGh1bWI6OkltYWdlOjpIZWlnaHQAMjAwfdcV
aQAAABd0RVh0VGh1bWI6OkltYWdlOjpXaWR0aAAyMDDuJkU0AAAAGXRFWHRUaHVtYjo6TWltZXR5
cGUAaW1hZ2UvcG5nP7JWTgAAABd0RVh0VGh1bWI6Ok1UaW1lADE2NDkwNTU4MTD76yvxAAAAEnRF
WHRUaHVtYjo6U2l6ZQA0NTEzQkLz/Q6yAAAARnRFWHRUaHVtYjo6VVJJAGZpbGU6Ly8vYXBwL3Rt
cC9pbWFnZWxjL2ltZ3ZpZXcyXzlfMTY0Nzg0ODMyNDI4NTI2NzhfODZfWzBdhSsH1AAAAABJRU5E
rkJggg==" ></image>
</svg>

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/install/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BuildAdmin-安装</title>
<script type="module" crossorigin src="/install/assets/index.js"></script>
<link rel="stylesheet" crossorigin href="/install/assets/index.css">
</head>
<body>
<div id="app"></div>
</body>

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow:

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env php
<?php
chdir(__DIR__);
require_once __DIR__ . '/vendor/autoload.php';
support\App::run();

View File

@@ -0,0 +1,24 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
namespace support;
/**
* Class Request
* @package support
*/
class Request extends \Webman\Http\Request
{
}

View File

@@ -0,0 +1,24 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
namespace support;
/**
* Class Response
* @package support
*/
class Response extends \Webman\Http\Response
{
}

View File

@@ -0,0 +1,1558 @@
<?php
declare(strict_types=1);
namespace support;
use Composer\Console\Application as ComposerApplication;
use Composer\IO\IOInterface;
use Composer\Script\Event;
use Symfony\Component\Console\Cursor;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Terminal;
/**
* create-project setup wizard: interactive locale, timezone and optional components selection, then runs composer require.
*/
class Setup
{
// --- Optional component package names ---
private const PACKAGE_CONSOLE = 'webman/console';
private const PACKAGE_DATABASE = 'webman/database';
private const PACKAGE_THINK_ORM = 'webman/think-orm';
private const PACKAGE_REDIS = 'webman/redis';
private const PACKAGE_ILLUMINATE_EVENTS = 'illuminate/events';
private const PACKAGE_ILLUMINATE_PAGINATION = 'illuminate/pagination';
private const PACKAGE_SYMFONY_VAR_DUMPER = 'symfony/var-dumper';
private const PACKAGE_VALIDATION = 'webman/validation';
private const PACKAGE_BLADE = 'webman/blade';
private const PACKAGE_TWIG = 'twig/twig';
private const PACKAGE_THINK_TEMPLATE = 'topthink/think-template';
private const SETUP_TITLE = 'Webman Setup';
// --- Timezone regions ---
private const TIMEZONE_REGIONS = [
'Asia' => \DateTimeZone::ASIA,
'Europe' => \DateTimeZone::EUROPE,
'America' => \DateTimeZone::AMERICA,
'Africa' => \DateTimeZone::AFRICA,
'Australia' => \DateTimeZone::AUSTRALIA,
'Pacific' => \DateTimeZone::PACIFIC,
'Atlantic' => \DateTimeZone::ATLANTIC,
'Indian' => \DateTimeZone::INDIAN,
'Antarctica' => \DateTimeZone::ANTARCTICA,
'Arctic' => \DateTimeZone::ARCTIC,
'UTC' => \DateTimeZone::UTC,
];
// --- Locale => default timezone ---
private const LOCALE_DEFAULT_TIMEZONES = [
'zh_CN' => 'Asia/Shanghai',
'zh_TW' => 'Asia/Taipei',
'en' => 'UTC',
'ja' => 'Asia/Tokyo',
'ko' => 'Asia/Seoul',
'fr' => 'Europe/Paris',
'de' => 'Europe/Berlin',
'es' => 'Europe/Madrid',
'pt_BR' => 'America/Sao_Paulo',
'ru' => 'Europe/Moscow',
'vi' => 'Asia/Ho_Chi_Minh',
'tr' => 'Europe/Istanbul',
'id' => 'Asia/Jakarta',
'th' => 'Asia/Bangkok',
];
// --- Locale options (localized display names) ---
private const LOCALE_LABELS = [
'zh_CN' => '简体中文',
'zh_TW' => '繁體中文',
'en' => 'English',
'ja' => '日本語',
'ko' => '한국어',
'fr' => 'Français',
'de' => 'Deutsch',
'es' => 'Español',
'pt_BR' => 'Português (Brasil)',
'ru' => 'Русский',
'vi' => 'Tiếng Việt',
'tr' => 'Türkçe',
'id' => 'Bahasa Indonesia',
'th' => 'ไทย',
];
// --- Multilingual messages (%s = placeholder) ---
private const MESSAGES = [
'zh_CN' => [
'remove_package_question' => '发现以下已安装组件本次未选择,是否将其卸载 ?%s',
'removing_package' => '- 准备移除组件 %s',
'removing' => '卸载:',
'error_remove' => '卸载组件出错请手动执行composer remove %s',
'done_remove' => '已卸载组件。',
'skip' => '非交互模式,跳过安装向导。',
'default_choice' => ' (默认 %s)',
'timezone_prompt' => '时区 (默认 %s输入可联想补全): ',
'timezone_title' => '时区设置 (默认 %s)',
'timezone_help' => '输入关键字Tab自动补全可↑↓下选择:',
'timezone_region' => '选择时区区域',
'timezone_city' => '选择时区',
'timezone_invalid' => '无效的时区,已使用默认值 %s',
'timezone_input_prompt' => '输入时区或关键字:',
'timezone_pick_prompt' => '请输入数字编号或关键字:',
'timezone_no_match' => '未找到匹配的时区,请重试。',
'timezone_invalid_index' => '无效的编号,请重新输入。',
'yes' => '是',
'no' => '否',
'adding_package' => '- 添加依赖 %s',
'console_question' => '安装命令行组件 webman/console',
'db_question' => '数据库组件',
'db_none' => '不安装',
'db_invalid' => '请输入有效选项',
'redis_question' => '安装 Redis 组件 webman/redis',
'events_note' => ' (Redis 依赖 illuminate/events已自动包含)',
'validation_question' => '安装验证器组件 webman/validation',
'template_question' => '模板引擎',
'template_none' => '不安装',
'no_components' => '未选择额外组件。',
'installing' => '即将安装:',
'running' => '执行:',
'error_install' => '安装可选组件时出错请手动执行composer require %s',
'done' => '可选组件安装完成。',
'summary_locale' => '语言:%s',
'summary_timezone' => '时区:%s',
],
'zh_TW' => [
'skip' => '非交互模式,跳過安裝嚮導。',
'default_choice' => ' (預設 %s)',
'timezone_prompt' => '時區 (預設 %s輸入可聯想補全): ',
'timezone_title' => '時區設定 (預設 %s)',
'timezone_help' => '輸入關鍵字Tab自動補全可↑↓上下選擇:',
'timezone_region' => '選擇時區區域',
'timezone_city' => '選擇時區',
'timezone_invalid' => '無效的時區,已使用預設值 %s',
'timezone_input_prompt' => '輸入時區或關鍵字:',
'timezone_pick_prompt' => '請輸入數字編號或關鍵字:',
'timezone_no_match' => '未找到匹配的時區,請重試。',
'timezone_invalid_index' => '無效的編號,請重新輸入。',
'yes' => '是',
'no' => '否',
'adding_package' => '- 新增依賴 %s',
'console_question' => '安裝命令列組件 webman/console',
'db_question' => '資料庫組件',
'db_none' => '不安裝',
'db_invalid' => '請輸入有效選項',
'redis_question' => '安裝 Redis 組件 webman/redis',
'events_note' => ' (Redis 依賴 illuminate/events已自動包含)',
'validation_question' => '安裝驗證器組件 webman/validation',
'template_question' => '模板引擎',
'template_none' => '不安裝',
'no_components' => '未選擇額外組件。',
'installing' => '即將安裝:',
'running' => '執行:',
'error_install' => '安裝可選組件時出錯請手動執行composer require %s',
'done' => '可選組件安裝完成。',
'summary_locale' => '語言:%s',
'summary_timezone' => '時區:%s',
],
'en' => [
'skip' => 'Non-interactive mode, skipping setup wizard.',
'default_choice' => ' (default %s)',
'timezone_prompt' => 'Timezone (default=%s, type to autocomplete): ',
'timezone_title' => 'Timezone (default=%s)',
'timezone_help' => 'Type keyword then press Tab to autocomplete, use ↑↓ to choose:',
'timezone_region' => 'Select timezone region',
'timezone_city' => 'Select timezone',
'timezone_invalid' => 'Invalid timezone, using default %s',
'timezone_input_prompt' => 'Enter timezone or keyword:',
'timezone_pick_prompt' => 'Enter number or keyword:',
'timezone_no_match' => 'No matching timezone found, please try again.',
'timezone_invalid_index' => 'Invalid number, please try again.',
'yes' => 'yes',
'no' => 'no',
'adding_package' => '- Adding package %s',
'console_question' => 'Install console component webman/console',
'db_question' => 'Database component',
'db_none' => 'None',
'db_invalid' => 'Please enter a valid option',
'redis_question' => 'Install Redis component webman/redis',
'events_note' => ' (Redis requires illuminate/events, automatically included)',
'validation_question' => 'Install validator component webman/validation',
'template_question' => 'Template engine',
'template_none' => 'None',
'no_components' => 'No optional components selected.',
'installing' => 'Installing:',
'running' => 'Running:',
'error_install' => 'Failed to install. Try manually: composer require %s',
'done' => 'Optional components installed.',
'summary_locale' => 'Language: %s',
'summary_timezone' => 'Timezone: %s',
],
'ja' => [
'skip' => '非対話モードのため、セットアップウィザードをスキップします。',
'default_choice' => ' (デフォルト %s)',
'timezone_prompt' => 'タイムゾーン (デフォルト=%s、入力で補完): ',
'timezone_title' => 'タイムゾーン (デフォルト=%s)',
'timezone_help' => 'キーワード入力→Tabで補完、↑↓で選択:',
'timezone_region' => 'タイムゾーンの地域を選択',
'timezone_city' => 'タイムゾーンを選択',
'timezone_invalid' => '無効なタイムゾーンです。デフォルト %s を使用します',
'timezone_input_prompt' => 'タイムゾーンまたはキーワードを入力:',
'timezone_pick_prompt' => '番号またはキーワードを入力:',
'timezone_no_match' => '一致するタイムゾーンが見つかりません。再試行してください。',
'timezone_invalid_index' => '無効な番号です。もう一度入力してください。',
'yes' => 'はい',
'no' => 'いいえ',
'adding_package' => '- パッケージを追加 %s',
'console_question' => 'コンソールコンポーネント webman/console をインストール',
'db_question' => 'データベースコンポーネント',
'db_none' => 'インストールしない',
'db_invalid' => '有効なオプションを入力してください',
'redis_question' => 'Redis コンポーネント webman/redis をインストール',
'events_note' => ' (Redis は illuminate/events が必要です。自動的に含まれます)',
'validation_question' => 'バリデーションコンポーネント webman/validation をインストール',
'template_question' => 'テンプレートエンジン',
'template_none' => 'インストールしない',
'no_components' => 'オプションコンポーネントが選択されていません。',
'installing' => 'インストール中:',
'running' => '実行中:',
'error_install' => 'インストールに失敗しました。手動で実行してくださいcomposer require %s',
'done' => 'オプションコンポーネントのインストールが完了しました。',
'summary_locale' => '言語:%s',
'summary_timezone' => 'タイムゾーン:%s',
],
'ko' => [
'skip' => '비대화형 모드입니다. 설치 마법사를 건너뜁니다.',
'default_choice' => ' (기본값 %s)',
'timezone_prompt' => '시간대 (기본값=%s, 입력하여 자동완성): ',
'timezone_title' => '시간대 (기본값=%s)',
'timezone_help' => '키워드 입력 후 Tab 자동완성, ↑↓로 선택:',
'timezone_region' => '시간대 지역 선택',
'timezone_city' => '시간대 선택',
'timezone_invalid' => '잘못된 시간대입니다. 기본값 %s 을(를) 사용합니다',
'timezone_input_prompt' => '시간대 또는 키워드 입력:',
'timezone_pick_prompt' => '번호 또는 키워드 입력:',
'timezone_no_match' => '일치하는 시간대를 찾을 수 없습니다. 다시 시도하세요.',
'timezone_invalid_index' => '잘못된 번호입니다. 다시 입력하세요.',
'yes' => '예',
'no' => '아니오',
'adding_package' => '- 패키지 추가 %s',
'console_question' => '콘솔 컴포넌트 webman/console 설치',
'db_question' => '데이터베이스 컴포넌트',
'db_none' => '설치 안 함',
'db_invalid' => '유효한 옵션을 입력하세요',
'redis_question' => 'Redis 컴포넌트 webman/redis 설치',
'events_note' => ' (Redis는 illuminate/events가 필요합니다. 자동으로 포함됩니다)',
'validation_question' => '검증 컴포넌트 webman/validation 설치',
'template_question' => '템플릿 엔진',
'template_none' => '설치 안 함',
'no_components' => '선택된 추가 컴포넌트가 없습니다.',
'installing' => '설치 예정:',
'running' => '실행 중:',
'error_install' => '설치에 실패했습니다. 수동으로 실행하세요: composer require %s',
'done' => '선택 컴포넌트 설치가 완료되었습니다.',
'summary_locale' => '언어: %s',
'summary_timezone' => '시간대: %s',
],
'fr' => [
'skip' => 'Mode non interactif, assistant d\'installation ignoré.',
'default_choice' => ' (défaut %s)',
'timezone_prompt' => 'Fuseau horaire (défaut=%s, tapez pour compléter) : ',
'timezone_title' => 'Fuseau horaire (défaut=%s)',
'timezone_help' => 'Tapez un mot-clé, Tab pour compléter, ↑↓ pour choisir :',
'timezone_region' => 'Sélectionnez la région du fuseau horaire',
'timezone_city' => 'Sélectionnez le fuseau horaire',
'timezone_invalid' => 'Fuseau horaire invalide, utilisation de %s par défaut',
'timezone_input_prompt' => 'Entrez un fuseau horaire ou un mot-clé :',
'timezone_pick_prompt' => 'Entrez un numéro ou un mot-clé :',
'timezone_no_match' => 'Aucun fuseau horaire correspondant, veuillez réessayer.',
'timezone_invalid_index' => 'Numéro invalide, veuillez réessayer.',
'yes' => 'oui',
'no' => 'non',
'adding_package' => '- Ajout du paquet %s',
'console_question' => 'Installer le composant console webman/console',
'db_question' => 'Composant base de données',
'db_none' => 'Aucun',
'db_invalid' => 'Veuillez entrer une option valide',
'redis_question' => 'Installer le composant Redis webman/redis',
'events_note' => ' (Redis nécessite illuminate/events, inclus automatiquement)',
'validation_question' => 'Installer le composant de validation webman/validation',
'template_question' => 'Moteur de templates',
'template_none' => 'Aucun',
'no_components' => 'Aucun composant optionnel sélectionné.',
'installing' => 'Installation en cours :',
'running' => 'Exécution :',
'error_install' => 'Échec de l\'installation. Essayez manuellement : composer require %s',
'done' => 'Composants optionnels installés.',
'summary_locale' => 'Langue : %s',
'summary_timezone' => 'Fuseau horaire : %s',
],
'de' => [
'skip' => 'Nicht-interaktiver Modus, Einrichtungsassistent übersprungen.',
'default_choice' => ' (Standard %s)',
'timezone_prompt' => 'Zeitzone (Standard=%s, Eingabe zur Vervollständigung): ',
'timezone_title' => 'Zeitzone (Standard=%s)',
'timezone_help' => 'Stichwort tippen, Tab ergänzt, ↑↓ auswählen:',
'timezone_region' => 'Zeitzone Region auswählen',
'timezone_city' => 'Zeitzone auswählen',
'timezone_invalid' => 'Ungültige Zeitzone, Standardwert %s wird verwendet',
'timezone_input_prompt' => 'Zeitzone oder Stichwort eingeben:',
'timezone_pick_prompt' => 'Nummer oder Stichwort eingeben:',
'timezone_no_match' => 'Keine passende Zeitzone gefunden, bitte erneut versuchen.',
'timezone_invalid_index' => 'Ungültige Nummer, bitte erneut eingeben.',
'yes' => 'ja',
'no' => 'nein',
'adding_package' => '- Paket hinzufügen %s',
'console_question' => 'Konsolen-Komponente webman/console installieren',
'db_question' => 'Datenbank-Komponente',
'db_none' => 'Keine',
'db_invalid' => 'Bitte geben Sie eine gültige Option ein',
'redis_question' => 'Redis-Komponente webman/redis installieren',
'events_note' => ' (Redis benötigt illuminate/events, automatisch eingeschlossen)',
'validation_question' => 'Validierungs-Komponente webman/validation installieren',
'template_question' => 'Template-Engine',
'template_none' => 'Keine',
'no_components' => 'Keine optionalen Komponenten ausgewählt.',
'installing' => 'Installation:',
'running' => 'Ausführung:',
'error_install' => 'Installation fehlgeschlagen. Manuell ausführen: composer require %s',
'done' => 'Optionale Komponenten installiert.',
'summary_locale' => 'Sprache: %s',
'summary_timezone' => 'Zeitzone: %s',
],
'es' => [
'skip' => 'Modo no interactivo, asistente de instalación omitido.',
'default_choice' => ' (predeterminado %s)',
'timezone_prompt' => 'Zona horaria (predeterminado=%s, escriba para autocompletar): ',
'timezone_title' => 'Zona horaria (predeterminado=%s)',
'timezone_help' => 'Escriba una palabra clave, Tab autocompleta, use ↑↓ para elegir:',
'timezone_region' => 'Seleccione la región de zona horaria',
'timezone_city' => 'Seleccione la zona horaria',
'timezone_invalid' => 'Zona horaria inválida, usando valor predeterminado %s',
'timezone_input_prompt' => 'Ingrese zona horaria o palabra clave:',
'timezone_pick_prompt' => 'Ingrese número o palabra clave:',
'timezone_no_match' => 'No se encontró zona horaria coincidente, intente de nuevo.',
'timezone_invalid_index' => 'Número inválido, intente de nuevo.',
'yes' => 'sí',
'no' => 'no',
'adding_package' => '- Agregando paquete %s',
'console_question' => 'Instalar componente de consola webman/console',
'db_question' => 'Componente de base de datos',
'db_none' => 'Ninguno',
'db_invalid' => 'Por favor ingrese una opción válida',
'redis_question' => 'Instalar componente Redis webman/redis',
'events_note' => ' (Redis requiere illuminate/events, incluido automáticamente)',
'validation_question' => 'Instalar componente de validación webman/validation',
'template_question' => 'Motor de plantillas',
'template_none' => 'Ninguno',
'no_components' => 'No se seleccionaron componentes opcionales.',
'installing' => 'Instalando:',
'running' => 'Ejecutando:',
'error_install' => 'Error en la instalación. Intente manualmente: composer require %s',
'done' => 'Componentes opcionales instalados.',
'summary_locale' => 'Idioma: %s',
'summary_timezone' => 'Zona horaria: %s',
],
'pt_BR' => [
'skip' => 'Modo não interativo, assistente de instalação ignorado.',
'default_choice' => ' (padrão %s)',
'timezone_prompt' => 'Fuso horário (padrão=%s, digite para autocompletar): ',
'timezone_title' => 'Fuso horário (padrão=%s)',
'timezone_help' => 'Digite uma palavra-chave, Tab autocompleta, use ↑↓ para escolher:',
'timezone_region' => 'Selecione a região do fuso horário',
'timezone_city' => 'Selecione o fuso horário',
'timezone_invalid' => 'Fuso horário inválido, usando padrão %s',
'timezone_input_prompt' => 'Digite fuso horário ou palavra-chave:',
'timezone_pick_prompt' => 'Digite número ou palavra-chave:',
'timezone_no_match' => 'Nenhum fuso horário encontrado, tente novamente.',
'timezone_invalid_index' => 'Número inválido, tente novamente.',
'yes' => 'sim',
'no' => 'não',
'adding_package' => '- Adicionando pacote %s',
'console_question' => 'Instalar componente de console webman/console',
'db_question' => 'Componente de banco de dados',
'db_none' => 'Nenhum',
'db_invalid' => 'Por favor, digite uma opção válida',
'redis_question' => 'Instalar componente Redis webman/redis',
'events_note' => ' (Redis requer illuminate/events, incluído automaticamente)',
'validation_question' => 'Instalar componente de validação webman/validation',
'template_question' => 'Motor de templates',
'template_none' => 'Nenhum',
'no_components' => 'Nenhum componente opcional selecionado.',
'installing' => 'Instalando:',
'running' => 'Executando:',
'error_install' => 'Falha na instalação. Tente manualmente: composer require %s',
'done' => 'Componentes opcionais instalados.',
'summary_locale' => 'Idioma: %s',
'summary_timezone' => 'Fuso horário: %s',
],
'ru' => [
'skip' => 'Неинтерактивный режим, мастер установки пропущен.',
'default_choice' => ' (по умолчанию %s)',
'timezone_prompt' => 'Часовой пояс (по умолчанию=%s, введите для автодополнения): ',
'timezone_title' => 'Часовой пояс (по умолчанию=%s)',
'timezone_help' => 'Введите ключевое слово, Tab для автодополнения, ↑↓ для выбора:',
'timezone_region' => 'Выберите регион часового пояса',
'timezone_city' => 'Выберите часовой пояс',
'timezone_invalid' => 'Неверный часовой пояс, используется значение по умолчанию %s',
'timezone_input_prompt' => 'Введите часовой пояс или ключевое слово:',
'timezone_pick_prompt' => 'Введите номер или ключевое слово:',
'timezone_no_match' => 'Совпадающий часовой пояс не найден, попробуйте снова.',
'timezone_invalid_index' => 'Неверный номер, попробуйте снова.',
'yes' => 'да',
'no' => 'нет',
'adding_package' => '- Добавление пакета %s',
'console_question' => 'Установить консольный компонент webman/console',
'db_question' => 'Компонент базы данных',
'db_none' => 'Не устанавливать',
'db_invalid' => 'Пожалуйста, введите допустимый вариант',
'redis_question' => 'Установить компонент Redis webman/redis',
'events_note' => ' (Redis требует illuminate/events, автоматически включён)',
'validation_question' => 'Установить компонент валидации webman/validation',
'template_question' => 'Шаблонизатор',
'template_none' => 'Не устанавливать',
'no_components' => 'Дополнительные компоненты не выбраны.',
'installing' => 'Установка:',
'running' => 'Выполнение:',
'error_install' => 'Ошибка установки. Выполните вручную: composer require %s',
'done' => 'Дополнительные компоненты установлены.',
'summary_locale' => 'Язык: %s',
'summary_timezone' => 'Часовой пояс: %s',
],
'vi' => [
'skip' => 'Chế độ không tương tác, bỏ qua trình hướng dẫn cài đặt.',
'default_choice' => ' (mặc định %s)',
'timezone_prompt' => 'Múi giờ (mặc định=%s, nhập để tự động hoàn thành): ',
'timezone_title' => 'Múi giờ (mặc định=%s)',
'timezone_help' => 'Nhập từ khóa, Tab để tự hoàn thành, dùng ↑↓ để chọn:',
'timezone_region' => 'Chọn khu vực múi giờ',
'timezone_city' => 'Chọn múi giờ',
'timezone_invalid' => 'Múi giờ không hợp lệ, sử dụng mặc định %s',
'timezone_input_prompt' => 'Nhập múi giờ hoặc từ khóa:',
'timezone_pick_prompt' => 'Nhập số thứ tự hoặc từ khóa:',
'timezone_no_match' => 'Không tìm thấy múi giờ phù hợp, vui lòng thử lại.',
'timezone_invalid_index' => 'Số không hợp lệ, vui lòng thử lại.',
'yes' => 'có',
'no' => 'không',
'adding_package' => '- Thêm gói %s',
'console_question' => 'Cài đặt thành phần console webman/console',
'db_question' => 'Thành phần cơ sở dữ liệu',
'db_none' => 'Không cài đặt',
'db_invalid' => 'Vui lòng nhập tùy chọn hợp lệ',
'redis_question' => 'Cài đặt thành phần Redis webman/redis',
'events_note' => ' (Redis cần illuminate/events, đã tự động bao gồm)',
'validation_question' => 'Cài đặt thành phần xác thực webman/validation',
'template_question' => 'Công cụ mẫu',
'template_none' => 'Không cài đặt',
'no_components' => 'Không có thành phần tùy chọn nào được chọn.',
'installing' => 'Đang cài đặt:',
'running' => 'Đang thực thi:',
'error_install' => 'Cài đặt thất bại. Thử thủ công: composer require %s',
'done' => 'Các thành phần tùy chọn đã được cài đặt.',
'summary_locale' => 'Ngôn ngữ: %s',
'summary_timezone' => 'Múi giờ: %s',
],
'tr' => [
'skip' => 'Etkileşimsiz mod, kurulum sihirbazı atlanıyor.',
'default_choice' => ' (varsayılan %s)',
'timezone_prompt' => 'Saat dilimi (varsayılan=%s, otomatik tamamlama için yazın): ',
'timezone_title' => 'Saat dilimi (varsayılan=%s)',
'timezone_help' => 'Anahtar kelime yazın, Tab tamamlar, ↑↓ ile seçin:',
'timezone_region' => 'Saat dilimi bölgesini seçin',
'timezone_city' => 'Saat dilimini seçin',
'timezone_invalid' => 'Geçersiz saat dilimi, varsayılan %s kullanılıyor',
'timezone_input_prompt' => 'Saat dilimi veya anahtar kelime girin:',
'timezone_pick_prompt' => 'Numara veya anahtar kelime girin:',
'timezone_no_match' => 'Eşleşen saat dilimi bulunamadı, tekrar deneyin.',
'timezone_invalid_index' => 'Geçersiz numara, tekrar deneyin.',
'yes' => 'evet',
'no' => 'hayır',
'adding_package' => '- Paket ekleniyor %s',
'console_question' => 'Konsol bileşeni webman/console yüklensin mi',
'db_question' => 'Veritabanı bileşeni',
'db_none' => 'Yok',
'db_invalid' => 'Lütfen geçerli bir seçenek girin',
'redis_question' => 'Redis bileşeni webman/redis yüklensin mi',
'events_note' => ' (Redis, illuminate/events gerektirir, otomatik olarak dahil edildi)',
'validation_question' => 'Doğrulama bileşeni webman/validation yüklensin mi',
'template_question' => 'Şablon motoru',
'template_none' => 'Yok',
'no_components' => 'İsteğe bağlı bileşen seçilmedi.',
'installing' => 'Yükleniyor:',
'running' => 'Çalıştırılıyor:',
'error_install' => 'Yükleme başarısız. Manuel olarak deneyin: composer require %s',
'done' => 'İsteğe bağlı bileşenler yüklendi.',
'summary_locale' => 'Dil: %s',
'summary_timezone' => 'Saat dilimi: %s',
],
'id' => [
'skip' => 'Mode non-interaktif, melewati wizard instalasi.',
'default_choice' => ' (default %s)',
'timezone_prompt' => 'Zona waktu (default=%s, ketik untuk melengkapi): ',
'timezone_title' => 'Zona waktu (default=%s)',
'timezone_help' => 'Ketik kata kunci, Tab untuk melengkapi, gunakan ↑↓ untuk memilih:',
'timezone_region' => 'Pilih wilayah zona waktu',
'timezone_city' => 'Pilih zona waktu',
'timezone_invalid' => 'Zona waktu tidak valid, menggunakan default %s',
'timezone_input_prompt' => 'Masukkan zona waktu atau kata kunci:',
'timezone_pick_prompt' => 'Masukkan nomor atau kata kunci:',
'timezone_no_match' => 'Zona waktu tidak ditemukan, silakan coba lagi.',
'timezone_invalid_index' => 'Nomor tidak valid, silakan coba lagi.',
'yes' => 'ya',
'no' => 'tidak',
'adding_package' => '- Menambahkan paket %s',
'console_question' => 'Instal komponen konsol webman/console',
'db_question' => 'Komponen database',
'db_none' => 'Tidak ada',
'db_invalid' => 'Silakan masukkan opsi yang valid',
'redis_question' => 'Instal komponen Redis webman/redis',
'events_note' => ' (Redis memerlukan illuminate/events, otomatis disertakan)',
'validation_question' => 'Instal komponen validasi webman/validation',
'template_question' => 'Mesin template',
'template_none' => 'Tidak ada',
'no_components' => 'Tidak ada komponen opsional yang dipilih.',
'installing' => 'Menginstal:',
'running' => 'Menjalankan:',
'error_install' => 'Instalasi gagal. Coba manual: composer require %s',
'done' => 'Komponen opsional terinstal.',
'summary_locale' => 'Bahasa: %s',
'summary_timezone' => 'Zona waktu: %s',
],
'th' => [
'skip' => 'โหมดไม่โต้ตอบ ข้ามตัวช่วยติดตั้ง',
'default_choice' => ' (ค่าเริ่มต้น %s)',
'timezone_prompt' => 'เขตเวลา (ค่าเริ่มต้น=%s พิมพ์เพื่อเติมอัตโนมัติ): ',
'timezone_title' => 'เขตเวลา (ค่าเริ่มต้น=%s)',
'timezone_help' => 'พิมพ์คีย์เวิร์ดแล้วกด Tab เพื่อเติมอัตโนมัติ ใช้ ↑↓ เพื่อเลือก:',
'timezone_region' => 'เลือกภูมิภาคเขตเวลา',
'timezone_city' => 'เลือกเขตเวลา',
'timezone_invalid' => 'เขตเวลาไม่ถูกต้อง ใช้ค่าเริ่มต้น %s',
'timezone_input_prompt' => 'ป้อนเขตเวลาหรือคำค้น:',
'timezone_pick_prompt' => 'ป้อนหมายเลขหรือคำค้น:',
'timezone_no_match' => 'ไม่พบเขตเวลาที่ตรงกัน กรุณาลองอีกครั้ง',
'timezone_invalid_index' => 'หมายเลขไม่ถูกต้อง กรุณาลองอีกครั้ง',
'yes' => 'ใช่',
'no' => 'ไม่',
'adding_package' => '- เพิ่มแพ็กเกจ %s',
'console_question' => 'ติดตั้งคอมโพเนนต์คอนโซล webman/console',
'db_question' => 'คอมโพเนนต์ฐานข้อมูล',
'db_none' => 'ไม่ติดตั้ง',
'db_invalid' => 'กรุณาป้อนตัวเลือกที่ถูกต้อง',
'redis_question' => 'ติดตั้งคอมโพเนนต์ Redis webman/redis',
'events_note' => ' (Redis ต้องการ illuminate/events รวมไว้โดยอัตโนมัติ)',
'validation_question' => 'ติดตั้งคอมโพเนนต์ตรวจสอบ webman/validation',
'template_question' => 'เทมเพลตเอนจิน',
'template_none' => 'ไม่ติดตั้ง',
'no_components' => 'ไม่ได้เลือกคอมโพเนนต์เสริม',
'installing' => 'กำลังติดตั้ง:',
'running' => 'กำลังดำเนินการ:',
'error_install' => 'ติดตั้งล้มเหลว ลองด้วยตนเอง: composer require %s',
'done' => 'คอมโพเนนต์เสริมติดตั้งเรียบร้อยแล้ว',
'summary_locale' => 'ภาษา: %s',
'summary_timezone' => 'เขตเวลา: %s',
],
];
// --- Interrupt message (Ctrl+C) ---
private const INTERRUPTED_MESSAGES = [
'zh_CN' => '安装中断,可运行 composer setup-webman 可重新设置。',
'zh_TW' => '安裝中斷,可運行 composer setup-webman 重新設置。',
'en' => 'Setup interrupted. Run "composer setup-webman" to restart setup.',
'ja' => 'セットアップが中断されました。composer setup-webman を実行して再設定できます。',
'ko' => '설치가 중단되었습니다. composer setup-webman 을 실행하여 다시 설정할 수 있습니다.',
'fr' => 'Installation interrompue. Exécutez « composer setup-webman » pour recommencer.',
'de' => 'Einrichtung abgebrochen. Führen Sie "composer setup-webman" aus, um neu zu starten.',
'es' => 'Instalación interrumpida. Ejecute "composer setup-webman" para reiniciar.',
'pt_BR' => 'Instalação interrompida. Execute "composer setup-webman" para reiniciar.',
'ru' => 'Установка прервана. Выполните «composer setup-webman» для повторной настройки.',
'vi' => 'Cài đặt bị gián đoạn. Chạy "composer setup-webman" để cài đặt lại.',
'tr' => 'Kurulum kesildi. Yeniden kurmak için "composer setup-webman" komutunu çalıştırın.',
'id' => 'Instalasi terganggu. Jalankan "composer setup-webman" untuk mengatur ulang.',
'th' => 'การติดตั้งถูกขัดจังหวะ เรียกใช้ "composer setup-webman" เพื่อตั้งค่าใหม่',
];
// --- Signal handling state ---
/** @var string|null Saved stty mode for terminal restoration on interrupt */
private static ?string $sttyMode = null;
/** @var string Current locale for interrupt message */
private static string $interruptLocale = 'en';
// ═══════════════════════════════════════════════════════════════
// Entry
// ═══════════════════════════════════════════════════════════════
public static function run(Event $event): void
{
$io = $event->getIO();
// Non-interactive mode: use English for skip message
if (!$io->isInteractive()) {
$io->write('<comment>' . self::MESSAGES['en']['skip'] . '</comment>');
return;
}
try {
self::doRun($event, $io);
} catch (\Throwable $e) {
$io->writeError('');
$io->writeError('<error>Setup wizard error: ' . $e->getMessage() . '</error>');
$io->writeError('<comment>Run "composer setup-webman" to retry.</comment>');
}
}
private static function doRun(Event $event, IOInterface $io): void
{
$io->write('');
// Register Ctrl+C handler
self::registerInterruptHandler();
// Banner title (must be before locale selection)
self::renderTitle();
// 1. Locale selection
$locale = self::askLocale($io);
self::$interruptLocale = $locale;
$defaultTimezone = self::LOCALE_DEFAULT_TIMEZONES[$locale] ?? 'UTC';
$msg = fn(string $key, string ...$args): string =>
empty($args) ? self::MESSAGES[$locale][$key] : sprintf(self::MESSAGES[$locale][$key], ...$args);
// Write locale config (update when not default)
if ($locale !== 'zh_CN') {
self::updateConfig($event, 'config/translation.php', "'locale'", $locale);
}
$io->write('');
$io->write('');
// 2. Timezone selection (default by locale)
$timezone = self::askTimezone($io, $msg, $defaultTimezone);
if ($timezone !== 'Asia/Shanghai') {
self::updateConfig($event, 'config/app.php', "'default_timezone'", $timezone);
}
// 3. Optional components
$packages = self::askComponents($io, $msg);
// 4. Remove unselected components
$removePackages = self::askRemoveComponents($event, $packages, $io, $msg);
// 5. Summary
$io->write('');
$io->write('─────────────────────────────────────');
$io->write('<info>' . $msg('summary_locale', self::LOCALE_LABELS[$locale]) . '</info>');
$io->write('<info>' . $msg('summary_timezone', $timezone) . '</info>');
// Remove unselected packages first to avoid dependency conflicts
if ($removePackages !== []) {
$io->write('');
$io->write('<info>' . $msg('removing') . '</info>');
$secondaryPackages = [
self::PACKAGE_ILLUMINATE_EVENTS,
self::PACKAGE_ILLUMINATE_PAGINATION,
self::PACKAGE_SYMFONY_VAR_DUMPER,
];
$displayRemovePackages = array_diff($removePackages, $secondaryPackages);
foreach ($displayRemovePackages as $pkg) {
$io->write(' - ' . $pkg);
}
$io->write('');
self::runComposerRemove($removePackages, $io, $msg);
}
// Then install selected packages
if ($packages !== []) {
$io->write('');
$io->write('<info>' . $msg('installing') . '</info> ' . implode(', ', $packages));
$io->write('');
self::runComposerRequire($packages, $io, $msg);
} elseif ($removePackages === []) {
$io->write('<info>' . $msg('no_components') . '</info>');
}
}
private static function renderTitle(): void
{
$output = new ConsoleOutput();
$terminalWidth = (new Terminal())->getWidth();
if ($terminalWidth <= 0) {
$terminalWidth = 80;
}
$text = ' ' . self::SETUP_TITLE . ' ';
$minBoxWidth = 44;
$maxBoxWidth = min($terminalWidth, 96);
$boxWidth = min($maxBoxWidth, max($minBoxWidth, mb_strwidth($text) + 10));
$innerWidth = $boxWidth - 2;
$textWidth = mb_strwidth($text);
$pad = max(0, $innerWidth - $textWidth);
$left = intdiv($pad, 2);
$right = $pad - $left;
$line2 = '│' . str_repeat(' ', $left) . $text . str_repeat(' ', $right) . '│';
$line1 = '┌' . str_repeat('─', $innerWidth) . '┐';
$line3 = '└' . str_repeat('─', $innerWidth) . '┘';
$output->writeln('');
$output->writeln('<fg=blue;options=bold>' . $line1 . '</>');
$output->writeln('<fg=blue;options=bold>' . $line2 . '</>');
$output->writeln('<fg=blue;options=bold>' . $line3 . '</>');
$output->writeln('');
}
// ═══════════════════════════════════════════════════════════════
// Signal handling (Ctrl+C)
// ═══════════════════════════════════════════════════════════════
/**
* Register Ctrl+C (SIGINT) handler to show a friendly message on interrupt.
* Gracefully skipped when the required extensions are unavailable.
*/
private static function registerInterruptHandler(): void
{
// Unix/Linux/Mac: pcntl extension with async signals for immediate delivery
/*if (function_exists('pcntl_async_signals') && function_exists('pcntl_signal')) {
pcntl_async_signals(true);
pcntl_signal(\SIGINT, [self::class, 'handleInterrupt']);
return;
}*/
// Windows: sapi ctrl handler (PHP >= 7.4)
if (function_exists('sapi_windows_set_ctrl_handler')) {
sapi_windows_set_ctrl_handler(static function (int $event) {
if ($event === \PHP_WINDOWS_EVENT_CTRL_C) {
self::handleInterrupt();
}
});
}
}
/**
* Handle Ctrl+C: restore terminal, show tip, then exit.
*/
private static function handleInterrupt(): void
{
// Restore terminal if in raw mode
if (self::$sttyMode !== null && function_exists('shell_exec')) {
@shell_exec('stty ' . self::$sttyMode);
self::$sttyMode = null;
}
$output = new ConsoleOutput();
$output->writeln('');
$output->writeln('<comment>' . (self::INTERRUPTED_MESSAGES[self::$interruptLocale] ?? self::INTERRUPTED_MESSAGES['en']) . '</comment>');
exit(1);
}
// ═══════════════════════════════════════════════════════════════
// Interactive Menu System
// ═══════════════════════════════════════════════════════════════
/**
* Check if terminal supports interactive features (arrow keys, ANSI colors).
*/
private static function supportsInteractive(): bool
{
return function_exists('shell_exec') && Terminal::hasSttyAvailable();
}
/**
* Display a selection menu with arrow key navigation (if supported) or text input fallback.
*
* @param IOInterface $io Composer IO
* @param string $title Menu title
* @param array $items Indexed array of ['tag' => string, 'label' => string]
* @param int $default Default selected index (0-based)
* @return int Selected index
*/
private static function selectMenu(IOInterface $io, string $title, array $items, int $default = 0): int
{
// Append localized "default" hint to avoid ambiguity
// (Template should contain a single %s placeholder for the default tag.)
$defaultHintTemplate = null;
if (isset(self::MESSAGES[self::$interruptLocale]['default_choice'])) {
$defaultHintTemplate = self::MESSAGES[self::$interruptLocale]['default_choice'];
}
$defaultTag = $items[$default]['tag'] ?? '';
if ($defaultHintTemplate && $defaultTag !== '') {
$title .= sprintf($defaultHintTemplate, $defaultTag);
} elseif ($defaultTag !== '') {
// Fallback for early menus (e.g. locale selection) before locale is chosen.
$title .= sprintf(' (default %s)', $defaultTag);
}
if (self::supportsInteractive()) {
return self::arrowKeySelect($title, $items, $default);
}
return self::fallbackSelect($io, $title, $items, $default);
}
/**
* Display a yes/no confirmation as a selection menu.
*
* @param IOInterface $io Composer IO
* @param string $title Menu title
* @param bool $default Default value (true = yes)
* @return bool User's choice
*/
private static function confirmMenu(IOInterface $io, string $title, bool $default = true): bool
{
$locale = self::$interruptLocale;
$yes = self::MESSAGES[$locale]['yes'] ?? self::MESSAGES['en']['yes'] ?? 'yes';
$no = self::MESSAGES[$locale]['no'] ?? self::MESSAGES['en']['no'] ?? 'no';
$items = $default
? [['tag' => 'Y', 'label' => $yes], ['tag' => 'n', 'label' => $no]]
: [['tag' => 'y', 'label' => $yes], ['tag' => 'N', 'label' => $no]];
$defaultIndex = $default ? 0 : 1;
return self::selectMenu($io, $title, $items, $defaultIndex) === 0;
}
/**
* Interactive select with arrow key navigation, manual input and ANSI reverse-video highlighting.
* Input area and option list highlighting are bidirectionally linked.
* Requires stty (Unix-like terminals).
*/
private static function arrowKeySelect(string $title, array $items, int $default): int
{
$output = new ConsoleOutput();
$count = count($items);
$selected = $default;
$maxTagWidth = max(array_map(fn(array $item) => mb_strlen($item['tag']), $items));
$defaultTag = $items[$default]['tag'];
$input = $defaultTag;
// Print title and initial options
$output->writeln('');
$output->writeln('<fg=blue;options=bold>' . $title . '</>');
self::drawMenuItems($output, $items, $selected, $maxTagWidth);
$output->write('> ' . $input);
// Enter raw mode
self::$sttyMode = shell_exec('stty -g');
shell_exec('stty -icanon -echo');
try {
while (!feof(STDIN)) {
$c = fread(STDIN, 1);
if (false === $c || '' === $c) {
break;
}
// ── Backspace ──
if ("\177" === $c || "\010" === $c) {
if ('' !== $input) {
$input = mb_substr($input, 0, -1);
}
$selected = self::findItemByTag($items, $input);
$output->write("\033[{$count}A");
self::drawMenuItems($output, $items, $selected, $maxTagWidth);
$output->write("\033[2K\r> " . $input);
continue;
}
// ── Escape sequences (arrow keys) ──
if ("\033" === $c) {
$seq = fread(STDIN, 2);
if (isset($seq[1])) {
$changed = false;
if ('A' === $seq[1]) { // Up
$selected = ($selected <= 0 ? $count : $selected) - 1;
$changed = true;
} elseif ('B' === $seq[1]) { // Down
$selected = ($selected + 1) % $count;
$changed = true;
}
if ($changed) {
// Sync input with selected item's tag
$input = $items[$selected]['tag'];
$output->write("\033[{$count}A");
self::drawMenuItems($output, $items, $selected, $maxTagWidth);
$output->write("\033[2K\r> " . $input);
}
}
continue;
}
// ── Enter: confirm selection ──
if ("\n" === $c || "\r" === $c) {
if ($selected < 0) {
$selected = $default;
}
$output->write("\033[2K\r> <info>" . $items[$selected]['tag'] . ' ' . $items[$selected]['label'] . '</info>');
$output->writeln('');
break;
}
// ── Ignore other control characters ──
if (ord($c) < 32) {
continue;
}
// ── Printable character (with UTF-8 multi-byte support) ──
if ("\x80" <= $c) {
$extra = ["\xC0" => 1, "\xD0" => 1, "\xE0" => 2, "\xF0" => 3];
$c .= fread(STDIN, $extra[$c & "\xF0"] ?? 0);
}
$input .= $c;
$selected = self::findItemByTag($items, $input);
$output->write("\033[{$count}A");
self::drawMenuItems($output, $items, $selected, $maxTagWidth);
$output->write("\033[2K\r> " . $input);
}
} finally {
if (self::$sttyMode !== null) {
shell_exec('stty ' . self::$sttyMode);
self::$sttyMode = null;
}
}
return $selected < 0 ? $default : $selected;
}
/**
* Fallback select for terminals without stty support. Uses plain text input.
*/
private static function fallbackSelect(IOInterface $io, string $title, array $items, int $default): int
{
$maxTagWidth = max(array_map(fn(array $item) => mb_strlen($item['tag']), $items));
$defaultTag = $items[$default]['tag'];
$io->write('');
$io->write('<fg=blue;options=bold>' . $title . '</>');
foreach ($items as $item) {
$tag = str_pad($item['tag'], $maxTagWidth);
$io->write(" [$tag] " . $item['label']);
}
while (true) {
$io->write('> ', false);
$line = fgets(STDIN);
if ($line === false) {
return $default;
}
$answer = trim($line);
if ($answer === '') {
$io->write('> <info>' . $items[$default]['tag'] . ' ' . $items[$default]['label'] . '</info>');
return $default;
}
// Match by tag (case-insensitive)
foreach ($items as $i => $item) {
if (strcasecmp($item['tag'], $answer) === 0) {
$io->write('> <info>' . $items[$i]['tag'] . ' ' . $items[$i]['label'] . '</info>');
return $i;
}
}
}
}
/**
* Render menu items with optional ANSI reverse-video highlighting for the selected item.
* When $selected is -1, no item is highlighted.
*/
private static function drawMenuItems(ConsoleOutput $output, array $items, int $selected, int $maxTagWidth): void
{
foreach ($items as $i => $item) {
$tag = str_pad($item['tag'], $maxTagWidth);
$line = " [$tag] " . $item['label'];
if ($i === $selected) {
$output->writeln("\033[2K\r\033[7m" . $line . "\033[0m");
} else {
$output->writeln("\033[2K\r" . $line);
}
}
}
/**
* Find item index by tag (case-insensitive exact match).
* Returns -1 if no match found or input is empty.
*/
private static function findItemByTag(array $items, string $input): int
{
if ($input === '') {
return -1;
}
foreach ($items as $i => $item) {
if (strcasecmp($item['tag'], $input) === 0) {
return $i;
}
}
return -1;
}
// ═══════════════════════════════════════════════════════════════
// Locale selection
// ═══════════════════════════════════════════════════════════════
private static function askLocale(IOInterface $io): string
{
$locales = array_keys(self::LOCALE_LABELS);
$items = [];
foreach ($locales as $i => $code) {
$items[] = ['tag' => (string) $i, 'label' => self::LOCALE_LABELS[$code] . " ($code)"];
}
$selected = self::selectMenu(
$io,
'语言 / Language / 言語 / 언어',
$items,
0
);
return $locales[$selected];
}
// ═══════════════════════════════════════════════════════════════
// Timezone selection
// ═══════════════════════════════════════════════════════════════
private static function askTimezone(IOInterface $io, callable $msg, string $default): string
{
if (self::supportsInteractive()) {
return self::askTimezoneAutocomplete($msg, $default);
}
return self::askTimezoneSelect($io, $msg, $default);
}
/**
* Option A: when stty is available, custom character-by-character autocomplete
* (case-insensitive, substring match). Interaction: type to filter, hint on right;
* ↑↓ change candidate, Tab accept, Enter confirm; empty input = use default.
*/
private static function askTimezoneAutocomplete(callable $msg, string $default): string
{
$allTimezones = \DateTimeZone::listIdentifiers();
$output = new ConsoleOutput();
$cursor = new Cursor($output);
$output->writeln('');
$output->writeln('<fg=blue;options=bold>' . $msg('timezone_title', $default) . '</>');
$output->writeln($msg('timezone_help'));
$output->write('> ');
self::$sttyMode = shell_exec('stty -g');
shell_exec('stty -icanon -echo');
// Auto-fill default timezone in the input area; user can edit it directly.
$input = $default;
$output->write($input);
$ofs = 0;
$matches = self::filterTimezones($allTimezones, $input);
if (!empty($matches)) {
$hint = $matches[$ofs % count($matches)];
// Avoid duplicating hint when input already fully matches the only candidate.
if (!(count($matches) === 1 && $hint === $input)) {
$cursor->clearLineAfter();
$cursor->savePosition();
$output->write(' <fg=blue;options=bold>' . $hint . '</>');
if (count($matches) > 1) {
$output->write(' <info>(' . count($matches) . ' matches, ↑↓)</info>');
}
$cursor->restorePosition();
}
}
try {
while (!feof(STDIN)) {
$c = fread(STDIN, 1);
if (false === $c || '' === $c) {
break;
}
// ── Backspace ──
if ("\177" === $c || "\010" === $c) {
if ('' !== $input) {
$lastChar = mb_substr($input, -1);
$input = mb_substr($input, 0, -1);
$cursor->moveLeft(max(1, mb_strwidth($lastChar)));
}
$ofs = 0;
// ── Escape sequences (arrows) ──
} elseif ("\033" === $c) {
$seq = fread(STDIN, 2);
if (isset($seq[1]) && !empty($matches)) {
if ('A' === $seq[1]) {
$ofs = ($ofs - 1 + count($matches)) % count($matches);
} elseif ('B' === $seq[1]) {
$ofs = ($ofs + 1) % count($matches);
}
}
// ── Tab: accept current match ──
} elseif ("\t" === $c) {
if (isset($matches[$ofs])) {
self::replaceInput($output, $cursor, $input, $matches[$ofs]);
$input = $matches[$ofs];
$matches = [];
}
$cursor->clearLineAfter();
continue;
// ── Enter: confirm ──
} elseif ("\n" === $c || "\r" === $c) {
if (isset($matches[$ofs])) {
self::replaceInput($output, $cursor, $input, $matches[$ofs]);
$input = $matches[$ofs];
}
if ($input === '') {
$input = $default;
}
// Re-render user input with <comment> style
$cursor->moveToColumn(1);
$cursor->clearLine();
$output->write('> <info>' . $input . '</info>');
$output->writeln('');
break;
// ── Other control chars: ignore ──
} elseif (ord($c) < 32) {
continue;
// ── Printable character ──
} else {
if ("\x80" <= $c) {
$extra = ["\xC0" => 1, "\xD0" => 1, "\xE0" => 2, "\xF0" => 3];
$c .= fread(STDIN, $extra[$c & "\xF0"] ?? 0);
}
$output->write($c);
$input .= $c;
$ofs = 0;
}
// Update match list
$matches = self::filterTimezones($allTimezones, $input);
// Show autocomplete hint
$cursor->clearLineAfter();
if (!empty($matches)) {
$hint = $matches[$ofs % count($matches)];
$cursor->savePosition();
$output->write(' <fg=blue;options=bold>' . $hint . '</>');
if (count($matches) > 1) {
$output->write(' <info>(' . count($matches) . ' matches, ↑↓)</info>');
}
$cursor->restorePosition();
}
}
} finally {
if (self::$sttyMode !== null) {
shell_exec('stty ' . self::$sttyMode);
self::$sttyMode = null;
}
}
$result = '' === $input ? $default : $input;
if (!in_array($result, $allTimezones, true)) {
$output->writeln('<comment>' . $msg('timezone_invalid', $default) . '</comment>');
return $default;
}
return $result;
}
/**
* Clear current input and replace with new text.
*/
private static function replaceInput(ConsoleOutput $output, Cursor $cursor, string $oldInput, string $newInput): void
{
if ('' !== $oldInput) {
$cursor->moveLeft(mb_strwidth($oldInput));
}
$cursor->clearLineAfter();
$output->write($newInput);
}
/**
* Case-insensitive substring match for timezones.
*/
private static function filterTimezones(array $timezones, string $input): array
{
if ('' === $input) {
return [];
}
$lower = mb_strtolower($input);
return array_values(array_filter(
$timezones,
fn(string $tz) => str_contains(mb_strtolower($tz), $lower)
));
}
/**
* Find an exact timezone match (case-insensitive).
* Returns the correctly-cased system timezone name, or null if not found.
*/
private static function findExactTimezone(array $allTimezones, string $input): ?string
{
$lower = mb_strtolower($input);
foreach ($allTimezones as $tz) {
if (mb_strtolower($tz) === $lower) {
return $tz;
}
}
return null;
}
/**
* Search timezones by keyword (substring) and similarity.
* Returns combined results: substring matches first, then similarity matches (>=50%).
*
* @param string[] $allTimezones All valid timezone identifiers
* @param string $keyword User input to search for
* @param int $limit Maximum number of results
* @return string[] Matched timezone identifiers
*/
private static function searchTimezones(array $allTimezones, string $keyword, int $limit = 15): array
{
// 1. Substring matches (higher priority)
$substringMatches = self::filterTimezones($allTimezones, $keyword);
if (count($substringMatches) >= $limit) {
return array_slice($substringMatches, 0, $limit);
}
// 2. Similarity matches for remaining slots (normalized: strip _ and /)
$substringSet = array_flip($substringMatches);
$normalizedKeyword = str_replace(['_', '/'], ' ', mb_strtolower($keyword));
$similarityMatches = [];
foreach ($allTimezones as $tz) {
if (isset($substringSet[$tz])) {
continue;
}
$parts = explode('/', $tz);
$city = str_replace('_', ' ', mb_strtolower(end($parts)));
$normalizedTz = str_replace(['_', '/'], ' ', mb_strtolower($tz));
similar_text($normalizedKeyword, $city, $cityPercent);
similar_text($normalizedKeyword, $normalizedTz, $fullPercent);
$bestPercent = max($cityPercent, $fullPercent);
if ($bestPercent >= 50.0) {
$similarityMatches[] = ['tz' => $tz, 'score' => $bestPercent];
}
}
usort($similarityMatches, fn(array $a, array $b) => $b['score'] <=> $a['score']);
$results = $substringMatches;
foreach ($similarityMatches as $item) {
$results[] = $item['tz'];
if (count($results) >= $limit) {
break;
}
}
return $results;
}
/**
* Option B: when stty is not available (e.g. Windows), keyword search with numbered list.
* Flow: enter timezone/keyword → exact match uses it directly; otherwise show
* numbered results (substring + similarity) → pick by number or refine keyword.
*/
private static function askTimezoneSelect(IOInterface $io, callable $msg, string $default): string
{
$allTimezones = \DateTimeZone::listIdentifiers();
$io->write('');
$io->write('<fg=blue;options=bold>' . $msg('timezone_title', $default) . '</>');
$io->write($msg('timezone_input_prompt'));
/** @var string[]|null Currently displayed search result list */
$currentList = null;
while (true) {
$io->write('> ', false);
$line = fgets(STDIN);
if ($line === false) {
return $default;
}
$answer = trim($line);
// Empty input → use default
if ($answer === '') {
$io->write('> <info>' . $default . '</info>');
return $default;
}
// If a numbered list is displayed and input is a pure number
if ($currentList !== null && ctype_digit($answer)) {
$idx = (int) $answer;
if (isset($currentList[$idx])) {
$io->write('> <info>' . $currentList[$idx] . '</info>');
return $currentList[$idx];
}
$io->write('<comment>' . $msg('timezone_invalid_index') . '</comment>');
continue;
}
// Exact case-insensitive match → return the correctly-cased system value
$exact = self::findExactTimezone($allTimezones, $answer);
if ($exact !== null) {
$io->write('> <info>' . $exact . '</info>');
return $exact;
}
// Keyword + similarity search
$results = self::searchTimezones($allTimezones, $answer);
if (empty($results)) {
$io->write('<comment>' . $msg('timezone_no_match') . '</comment>');
$currentList = null;
continue;
}
// Single result → use it directly
if (count($results) === 1) {
$io->write('> <info>' . $results[0] . '</info>');
return $results[0];
}
// Display numbered list
$currentList = $results;
$padWidth = strlen((string) (count($results) - 1));
foreach ($results as $i => $tz) {
$io->write(' [' . str_pad((string) $i, $padWidth) . '] ' . $tz);
}
$io->write($msg('timezone_pick_prompt'));
}
}
// ═══════════════════════════════════════════════════════════════
// Optional component selection
// ═══════════════════════════════════════════════════════════════
private static function askComponents(IOInterface $io, callable $msg): array
{
$packages = [];
$addPackage = static function (string $package) use (&$packages, $io, $msg): void {
if (in_array($package, $packages, true)) {
return;
}
$packages[] = $package;
$io->write($msg('adding_package', '<info>' . $package . '</info>'));
};
// Console (default: yes)
if (self::confirmMenu($io, $msg('console_question'), true)) {
$addPackage(self::PACKAGE_CONSOLE);
}
// Database
$dbItems = [
['tag' => '0', 'label' => $msg('db_none')],
['tag' => '1', 'label' => 'webman/database'],
['tag' => '2', 'label' => 'webman/think-orm'],
['tag' => '3', 'label' => 'webman/database && webman/think-orm'],
];
$dbChoice = self::selectMenu($io, $msg('db_question'), $dbItems, 0);
if ($dbChoice === 1) {
$addPackage(self::PACKAGE_DATABASE);
} elseif ($dbChoice === 2) {
$addPackage(self::PACKAGE_THINK_ORM);
} elseif ($dbChoice === 3) {
$addPackage(self::PACKAGE_DATABASE);
$addPackage(self::PACKAGE_THINK_ORM);
}
// If webman/database is selected, add required dependencies automatically
if (in_array(self::PACKAGE_DATABASE, $packages, true)) {
$addPackage(self::PACKAGE_ILLUMINATE_PAGINATION);
$addPackage(self::PACKAGE_ILLUMINATE_EVENTS);
$addPackage(self::PACKAGE_SYMFONY_VAR_DUMPER);
}
// Redis (default: no)
if (self::confirmMenu($io, $msg('redis_question'), false)) {
$addPackage(self::PACKAGE_REDIS);
$addPackage(self::PACKAGE_ILLUMINATE_EVENTS);
}
// Validation (default: no)
if (self::confirmMenu($io, $msg('validation_question'), false)) {
$addPackage(self::PACKAGE_VALIDATION);
}
// Template engine
$tplItems = [
['tag' => '0', 'label' => $msg('template_none')],
['tag' => '1', 'label' => 'webman/blade'],
['tag' => '2', 'label' => 'twig/twig'],
['tag' => '3', 'label' => 'topthink/think-template'],
];
$tplChoice = self::selectMenu($io, $msg('template_question'), $tplItems, 0);
if ($tplChoice === 1) {
$addPackage(self::PACKAGE_BLADE);
} elseif ($tplChoice === 2) {
$addPackage(self::PACKAGE_TWIG);
} elseif ($tplChoice === 3) {
$addPackage(self::PACKAGE_THINK_TEMPLATE);
}
return $packages;
}
// ═══════════════════════════════════════════════════════════════
// Config file update
// ═══════════════════════════════════════════════════════════════
/**
* Update a config value like 'key' => 'old_value' in the given file.
*/
private static function updateConfig(Event $event, string $relativePath, string $key, string $newValue): void
{
$root = dirname($event->getComposer()->getConfig()->get('vendor-dir'));
$file = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath);
if (!is_readable($file)) {
return;
}
$content = file_get_contents($file);
if ($content === false) {
return;
}
$pattern = '/' . preg_quote($key, '/') . "\s*=>\s*'[^']*'/";
$replacement = $key . " => '" . $newValue . "'";
$newContent = preg_replace($pattern, $replacement, $content);
if ($newContent !== null && $newContent !== $content) {
file_put_contents($file, $newContent);
}
}
// ═══════════════════════════════════════════════════════════════
// Composer require
// ═══════════════════════════════════════════════════════════════
private static function runComposerRequire(array $packages, IOInterface $io, callable $msg): void
{
$io->write('<comment>' . $msg('running') . '</comment> composer require ' . implode(' ', $packages));
$io->write('');
$code = self::runComposerCommand('require', $packages);
if ($code !== 0) {
$io->writeError('<error>' . $msg('error_install', implode(' ', $packages)) . '</error>');
} else {
$io->write('<info>' . $msg('done') . '</info>');
}
}
private static function askRemoveComponents(Event $event, array $selectedPackages, IOInterface $io, callable $msg): array
{
$requires = $event->getComposer()->getPackage()->getRequires();
$allOptionalPackages = [
self::PACKAGE_CONSOLE,
self::PACKAGE_DATABASE,
self::PACKAGE_THINK_ORM,
self::PACKAGE_REDIS,
self::PACKAGE_ILLUMINATE_EVENTS,
self::PACKAGE_ILLUMINATE_PAGINATION,
self::PACKAGE_SYMFONY_VAR_DUMPER,
self::PACKAGE_VALIDATION,
self::PACKAGE_BLADE,
self::PACKAGE_TWIG,
self::PACKAGE_THINK_TEMPLATE,
];
$secondaryPackages = [
self::PACKAGE_ILLUMINATE_EVENTS,
self::PACKAGE_ILLUMINATE_PAGINATION,
self::PACKAGE_SYMFONY_VAR_DUMPER,
];
$installedOptionalPackages = [];
foreach ($allOptionalPackages as $pkg) {
if (isset($requires[$pkg])) {
$installedOptionalPackages[] = $pkg;
}
}
$allPackagesToRemove = array_diff($installedOptionalPackages, $selectedPackages);
if (count($allPackagesToRemove) === 0) {
return [];
}
$displayPackagesToRemove = array_diff($allPackagesToRemove, $secondaryPackages);
if (count($displayPackagesToRemove) === 0) {
return $allPackagesToRemove;
}
$pkgListStr = "";
foreach ($displayPackagesToRemove as $pkg) {
$pkgListStr .= "\n - {$pkg}";
}
$pkgListStr .= "\n";
$title = '<comment>' . $msg('remove_package_question', '') . '</comment>' . $pkgListStr;
if (self::confirmMenu($io, $title, false)) {
return $allPackagesToRemove;
}
return [];
}
private static function runComposerRemove(array $packages, IOInterface $io, callable $msg): void
{
$io->write('<comment>' . $msg('running') . '</comment> composer remove ' . implode(' ', $packages));
$io->write('');
$code = self::runComposerCommand('remove', $packages);
if ($code !== 0) {
$io->writeError('<error>' . $msg('error_remove', implode(' ', $packages)) . '</error>');
} else {
$io->write('<info>' . $msg('done_remove') . '</info>');
}
}
/**
* Run a Composer command (require/remove) in-process via Composer's Application API.
* No shell execution functions needed — works even when passthru/exec/shell_exec are disabled.
*/
private static function runComposerCommand(string $command, array $packages): int
{
try {
// Already inside a user-initiated Composer session — suppress duplicate root/superuser warnings
$_SERVER['COMPOSER_ALLOW_SUPERUSER'] = '1';
if (function_exists('putenv')) {
putenv('COMPOSER_ALLOW_SUPERUSER=1');
}
$application = new ComposerApplication();
$application->setAutoExit(false);
return $application->run(
new ArrayInput([
'command' => $command,
'packages' => $packages,
'--no-interaction' => true,
'--update-with-all-dependencies' => true,
]),
new ConsoleOutput()
);
} catch (\Throwable) {
return 1;
}
}
}

View File

@@ -0,0 +1,139 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use Dotenv\Dotenv;
use support\Log;
use Webman\Bootstrap;
use Webman\Config;
use Webman\Middleware;
use Webman\Route;
use Webman\Util;
use Workerman\Events\Select;
use Workerman\Worker;
$worker = $worker ?? null;
if (empty(Worker::$eventLoopClass)) {
Worker::$eventLoopClass = Select::class;
}
set_error_handler(function ($level, $message, $file = '', $line = 0) {
if (error_reporting() & $level) {
throw new ErrorException($message, 0, $level, $file, $line);
}
});
if ($worker) {
register_shutdown_function(function ($startTime) {
if (time() - $startTime <= 0.1) {
sleep(1);
}
}, time());
}
if (class_exists('Dotenv\Dotenv') && file_exists(base_path(false) . '/.env')) {
if (method_exists('Dotenv\Dotenv', 'createUnsafeMutable')) {
Dotenv::createUnsafeMutable(base_path(false))->load();
} else {
Dotenv::createMutable(base_path(false))->load();
}
}
Config::clear();
support\App::loadAllConfig(['route']);
if ($timezone = config('app.default_timezone')) {
date_default_timezone_set($timezone);
}
foreach (config('autoload.files', []) as $file) {
include_once $file;
}
foreach (config('plugin', []) as $firm => $projects) {
foreach ($projects as $name => $project) {
if (!is_array($project)) {
continue;
}
foreach ($project['autoload']['files'] ?? [] as $file) {
include_once $file;
}
}
foreach ($projects['autoload']['files'] ?? [] as $file) {
include_once $file;
}
}
Middleware::load(config('middleware', []));
foreach (config('plugin', []) as $firm => $projects) {
foreach ($projects as $name => $project) {
if (!is_array($project) || $name === 'static') {
continue;
}
Middleware::load($project['middleware'] ?? []);
}
Middleware::load($projects['middleware'] ?? [], $firm);
if ($staticMiddlewares = config("plugin.$firm.static.middleware")) {
Middleware::load(['__static__' => $staticMiddlewares], $firm);
}
}
Middleware::load(['__static__' => config('static.middleware', [])]);
foreach (config('bootstrap', []) as $className) {
if (!class_exists($className)) {
$log = "Warning: Class $className setting in config/bootstrap.php not found\r\n";
echo $log;
Log::error($log);
continue;
}
/** @var Bootstrap $className */
$className::start($worker);
}
foreach (config('plugin', []) as $firm => $projects) {
foreach ($projects as $name => $project) {
if (!is_array($project)) {
continue;
}
foreach ($project['bootstrap'] ?? [] as $className) {
if (!class_exists($className)) {
$log = "Warning: Class $className setting in config/plugin/$firm/$name/bootstrap.php not found\r\n";
echo $log;
Log::error($log);
continue;
}
/** @var Bootstrap $className */
$className::start($worker);
}
}
foreach ($projects['bootstrap'] ?? [] as $className) {
/** @var string $className */
if (!class_exists($className)) {
$log = "Warning: Class $className setting in plugin/$firm/config/bootstrap.php not found\r\n";
echo $log;
Log::error($log);
continue;
}
/** @var Bootstrap $className */
$className::start($worker);
}
}
$directory = base_path() . '/plugin';
$paths = [config_path()];
foreach (Util::scanDir($directory) as $path) {
if (is_dir($path = "$path/config")) {
$paths[] = $path;
}
}
Route::load($paths);

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace support\bootstrap;
use Workerman\Worker;
/**
* 模块加载引导:迁移自 app\common\service\moduleService
* 在 webman 启动时加载已启用模块的 AppInit 逻辑
*/
class ModuleInit implements \Webman\Bootstrap
{
public static function start(?Worker $worker): void
{
$modulesDir = rtrim(base_path(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR;
if (!is_dir($modulesDir)) {
return;
}
$installed = self::installedList($modulesDir);
foreach ($installed as $item) {
if (($item['state'] ?? 0) != 1) {
continue;
}
$uid = $item['uid'] ?? '';
if (!$uid) {
continue;
}
$moduleClass = self::getModuleEventClass($uid);
if ($moduleClass && class_exists($moduleClass) && method_exists($moduleClass, 'AppInit')) {
try {
$handle = new $moduleClass();
$handle->AppInit();
} catch (\Throwable $e) {
// 模块 AppInit 异常不阻断启动,仅记录
if (class_exists(\support\Log::class)) {
\support\Log::warning('[ModuleInit] ' . $uid . ': ' . $e->getMessage());
}
}
}
}
}
/**
* 获取已安装模块列表(与 Server::installedList 逻辑一致)
*/
protected static function installedList(string $dir): array
{
$installedDir = @scandir($dir);
if ($installedDir === false) {
return [];
}
$installedList = [];
foreach ($installedDir as $item) {
if ($item === '.' || $item === '..' || is_file($dir . $item)) {
continue;
}
$tempDir = $dir . $item . DIRECTORY_SEPARATOR;
if (!is_dir($tempDir)) {
continue;
}
$info = self::getIni($tempDir);
if (!isset($info['uid'])) {
continue;
}
$installedList[] = $info;
}
return $installedList;
}
/**
* 读取模块 info.ini
*/
protected static function getIni(string $dir): array
{
$infoFile = $dir . 'info.ini';
if (!is_file($infoFile)) {
return [];
}
$info = @parse_ini_file($infoFile, true, INI_SCANNER_TYPED);
return is_array($info) ? $info : [];
}
/**
* 获取模块 Event 类命名空间(与 Server::getClass 逻辑一致)
*/
protected static function getModuleEventClass(string $uid): ?string
{
$name = function_exists('parse_name') ? parse_name($uid) : str_replace('-', '_', $uid);
$class = function_exists('parse_name') ? parse_name($name, 1) : ucfirst(str_replace('_', '', ucwords($name, '_')));
$namespace = '\\modules\\' . $name . '\\' . $class;
return class_exists($namespace) ? $namespace : null;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace support\bootstrap;
use think\Validate;
use support\think\Db;
use Workerman\Worker;
/**
* 验证器引导:为 think-validate 注入 Db使 unique 等规则可用
*/
class ValidateInit implements \Webman\Bootstrap
{
public static function start(?Worker $worker): void
{
Validate::maker(function (Validate $validate): void {
$validate->setDb(Db::connect());
});
}
}

71
dafuweng-webman/webman Normal file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env php
<?php
use Webman\Config;
use Webman\Console\Command;
use Webman\Console\Util;
use support\Container;
use Dotenv\Dotenv;
if (!Phar::running()) {
chdir(__DIR__);
}
require_once __DIR__ . '/vendor/autoload.php';
if (!$appConfigFile = config_path('app.php')) {
throw new RuntimeException('Config file not found: app.php');
}
if (class_exists(Dotenv::class) && file_exists(run_path('.env'))) {
if (method_exists(Dotenv::class, 'createUnsafeImmutable')) {
Dotenv::createUnsafeImmutable(run_path())->load();
} else {
Dotenv::createMutable(run_path())->load();
}
}
$appConfig = require $appConfigFile;
if ($timezone = $appConfig['default_timezone'] ?? '') {
date_default_timezone_set($timezone);
}
if ($errorReporting = $appConfig['error_reporting'] ?? '') {
error_reporting($errorReporting);
}
if (!in_array($argv[1] ?? '', ['start', 'restart', 'stop', 'status', 'reload', 'connections'])) {
require_once __DIR__ . '/support/bootstrap.php';
} else {
if (class_exists('Support\App')) {
Support\App::loadAllConfig(['route']);
} else {
Config::reload(config_path(), ['route', 'container']);
}
}
$cli = new Command();
$cli->setName('webman cli');
$cli->installInternalCommands();
if (is_dir($command_path = Util::guessPath(app_path(), '/command', true))) {
$cli->installCommands($command_path);
}
foreach (config('plugin', []) as $firm => $projects) {
if (isset($projects['app'])) {
foreach (['', '/app'] as $app) {
if ($command_str = Util::guessPath(base_path() . "/plugin/$firm{$app}", 'command')) {
$command_path = base_path() . "/plugin/$firm{$app}/$command_str";
$cli->installCommands($command_path, "plugin\\$firm" . str_replace('/', '\\', $app) . "\\$command_str");
}
}
}
foreach ($projects as $name => $project) {
if (!is_array($project)) {
continue;
}
$project['command'] ??= [];
array_walk($project['command'], [$cli, 'createCommandInstance']);
}
}
$cli->run();

View File

@@ -0,0 +1,3 @@
CHCP 65001
php windows.php
pause

136
dafuweng-webman/windows.php Normal file
View File

@@ -0,0 +1,136 @@
<?php
/**
* Start file for windows
*/
chdir(__DIR__);
require_once __DIR__ . '/vendor/autoload.php';
use Dotenv\Dotenv;
use support\App;
use Workerman\Worker;
ini_set('display_errors', 'on');
error_reporting(E_ALL);
if (class_exists('Dotenv\Dotenv') && file_exists(base_path() . '/.env')) {
if (method_exists('Dotenv\Dotenv', 'createUnsafeImmutable')) {
Dotenv::createUnsafeImmutable(base_path())->load();
} else {
Dotenv::createMutable(base_path())->load();
}
}
App::loadAllConfig(['route']);
$errorReporting = config('app.error_reporting');
if (isset($errorReporting)) {
error_reporting($errorReporting);
}
$runtimeProcessPath = runtime_path() . DIRECTORY_SEPARATOR . '/windows';
$paths = [
$runtimeProcessPath,
runtime_path('logs'),
runtime_path('views')
];
foreach ($paths as $path) {
if (!is_dir($path)) {
mkdir($path, 0777, true);
}
}
$processFiles = [];
if (config('server.listen')) {
$processFiles[] = __DIR__ . DIRECTORY_SEPARATOR . 'start.php';
}
foreach (config('process', []) as $processName => $config) {
$processFiles[] = write_process_file($runtimeProcessPath, $processName, '');
}
foreach (config('plugin', []) as $firm => $projects) {
foreach ($projects as $name => $project) {
if (!is_array($project)) {
continue;
}
foreach ($project['process'] ?? [] as $processName => $config) {
$processFiles[] = write_process_file($runtimeProcessPath, $processName, "$firm.$name");
}
}
foreach ($projects['process'] ?? [] as $processName => $config) {
$processFiles[] = write_process_file($runtimeProcessPath, $processName, $firm);
}
}
function write_process_file($runtimeProcessPath, $processName, $firm): string
{
$processParam = $firm ? "plugin.$firm.$processName" : $processName;
$configParam = $firm ? "config('plugin.$firm.process')['$processName']" : "config('process')['$processName']";
$fileContent = <<<EOF
<?php
require_once __DIR__ . '/../../vendor/autoload.php';
use Workerman\Worker;
use Workerman\Connection\TcpConnection;
use Webman\Config;
use support\App;
ini_set('display_errors', 'on');
error_reporting(E_ALL);
if (is_callable('opcache_reset')) {
opcache_reset();
}
if (!\$appConfigFile = config_path('app.php')) {
throw new RuntimeException('Config file not found: app.php');
}
\$appConfig = require \$appConfigFile;
if (\$timezone = \$appConfig['default_timezone'] ?? '') {
date_default_timezone_set(\$timezone);
}
App::loadAllConfig(['route']);
worker_start('$processParam', $configParam);
if (DIRECTORY_SEPARATOR != "/") {
Worker::\$logFile = config('server')['log_file'] ?? Worker::\$logFile;
TcpConnection::\$defaultMaxPackageSize = config('server')['max_package_size'] ?? 10*1024*1024;
}
Worker::runAll();
EOF;
$processFile = $runtimeProcessPath . DIRECTORY_SEPARATOR . "start_$processParam.php";
file_put_contents($processFile, $fileContent);
return $processFile;
}
if ($monitorConfig = config('process.monitor.constructor')) {
$monitorHandler = config('process.monitor.handler');
$monitor = new $monitorHandler(...array_values($monitorConfig));
}
function popen_processes($processFiles)
{
$cmd = '"' . PHP_BINARY . '" ' . implode(' ', $processFiles);
$descriptorspec = [STDIN, STDOUT, STDOUT];
$resource = proc_open($cmd, $descriptorspec, $pipes, null, null, ['bypass_shell' => true]);
if (!$resource) {
exit("Can not execute $cmd\r\n");
}
return $resource;
}
$resource = popen_processes($processFiles);
echo "\r\n";
while (1) {
sleep(1);
if (!empty($monitor) && $monitor->checkAllFilesChange()) {
$status = proc_get_status($resource);
$pid = $status['pid'];
shell_exec("taskkill /F /T /PID $pid");
proc_close($resource);
$resource = popen_processes($processFiles);
}
}

157
docs/webman部署说明.md Normal file
View File

@@ -0,0 +1,157 @@
# Webman 部署说明(步骤 11
> 对应迁移文档「步骤 11入口与部署」
---
## 一、入口与启动
Webman **不使用** `public/index.php` 作为入口,而是通过命令行启动常驻进程:
```bash
# 进入项目目录
cd dafuweng-webman
# 启动(默认监听 8787 端口)
php start.php start
# 后台运行daemon 模式)
php start.php start -d
# 停止
php start.php stop
# 重启
php start.php restart
```
- 入口脚本:`start.php`(项目根目录)
- 默认端口:**8787**(在 `config/process.php` 中配置)
- 修改端口:编辑 `config/process.php``listen => 'http://0.0.0.0:8787'`
---
## 二、public 目录静态资源
`public/` 目录需保留以下内容,供 Nginx 直接提供或前端引用:
| 路径 | 说明 |
|------|------|
| `install/` | 安装向导index.html、assets 等) |
| `robots.txt` | 搜索引擎爬虫规则 |
| `favicon.ico` | 站点图标(可选) |
| `index.html` | 前端 SPA 入口(若已编译) |
**说明**`index.php``router.php` 为 ThinkPHP 入口Webman 不需要,无需复制。
---
## 三、Nginx 反向代理
将 Nginx 的 `root` 指向 Webman 的 **public** 目录,动态请求代理到 8787 端口。
### 配置示例
```nginx
upstream webman {
server 127.0.0.1:8787;
keepalive 10240;
}
server {
server_name 你的域名;
listen 80;
access_log off;
# 必须指向 webman 的 public 目录,不能是项目根目录
root /path/to/dafuweng-webman/public;
location / {
try_files $uri $uri/ @proxy;
}
location @proxy {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_pass http://webman;
}
# 拒绝访问 .php 文件Webman 无 PHP 入口)
location ~ \.php$ {
return 404;
}
# 允许 .well-known如 Let's Encrypt
location ~ ^/\.well-known/ {
allow all;
}
# 拒绝隐藏文件/目录
location ~ /\. {
return 404;
}
}
```
### 配置要点
- **root**:必须为 `dafuweng-webman/public`,否则可能暴露配置等敏感文件
- **try_files**:先查找静态文件,找不到再代理到 Webman
- **proxy_pass**:指向 `http://webman`upstream 8787
### HTTPS 示例
```nginx
server {
listen 443 ssl http2;
server_name 你的域名;
root /path/to/dafuweng-webman/public;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
try_files $uri $uri/ @proxy;
}
location @proxy {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_pass http://webman;
}
}
```
---
## 四、开发环境快速访问
不配置 Nginx 时,可直接访问:
- 接口:`http://127.0.0.1:8787/api/xxx``http://127.0.0.1:8787/admin/xxx`
- 静态:`http://127.0.0.1:8787/install/``http://127.0.0.1:8787/robots.txt`
Webman 会从 `public/` 目录提供静态文件。
---
## 五、安装检测说明
原 ThinkPHP 在 `public/index.php` 中做安装检测(无 `install.lock` 时跳转 `/install/`)。
Webman 迁移后:
- 若使用 Nginx安装检测逻辑需在 **路由/中间件****前端** 中实现
- 若直接访问 8787可在 `app/common/middleware` 或 Api/Backend 基类中增加安装检测
---
## 六、参考
- [Webman 官方文档 - nginx 代理](https://workerman.net/doc/webman/others/nginx-proxy.html)

View File

@@ -5,4 +5,4 @@ ENV = 'development'
VITE_BASE_PATH = './'
# 本地环境接口地址 - 尾部无需带'/'
VITE_AXIOS_BASE_URL = 'http://localhost:8000'
VITE_AXIOS_BASE_URL = 'http://localhost:8787'